Extending ToolUp.Forms
Extending ToolUp.Forms
How to write custom validators, guards, actions, replace the action ledger, add submission analysers, and write a custom renderer.
Custom validators
For per-field domain validation that the built-in ValidationRules don't cover. The shipped contract is intentionally small — a custom validator receives the string form of a single field's submitted value and returns Ok () to pass or Error message to fail.
open ToolUp.Forms.FormValidator
let blockListValidator : CustomValidator =
fun raw ->
let blockedDomains = [ "example-bad.com"; "trash-mail.test" ]
let lower = raw.ToLowerInvariant ()
if blockedDomains |> List.exists (fun d -> lower.EndsWith ("@" + d)) then
Error "this email domain is not accepted"
else
Ok ()
Register on FormsServerApp:
open ToolUp.Forms.FormsCompose
let app =
FormsServerApp.create ()
// ...
|> FormsServerApp.withCustomValidator "blocklist-check" blockListValidator
Reference from a FormSchema:
open ToolUp.Forms.FormSchema
let userOnboardingSchema =
FormSchema.create "user-onboarding" "User onboarding" [
{ Key = "email"
DisplayName = "Email"
Description = None
Kind = TextField (Some 254)
Required = true
Validators = [
Regex (@"^[^@\s]+@[^@\s]+\.[^@\s]+$", Some "valid email address")
Custom "blocklist-check"
] }
// ...
]
The validator name ("blocklist-check") matches between the schema's Custom name and the registry registration. Schemas don't reference closures — only names — so the schema crosses the wire as data and the lookup happens server-side.
Missing-by-name custom validators are a soft pass (no error). Renaming a registered validator doesn't break in-flight forms, but it does silently disable the check — keep names stable.
Cross-field / async / per-submission validation
The per-field CustomValidator shape is string -> Result<unit, string> — synchronous and field-local. For cross-field rules ("end date must be after start date"), async lookups ("is this email already registered?"), or per-submission constraints that depend on multiple values, layer the check at the route-handler tier in your own module rather than fighting the registry shape. The shipped engine has no built-in cross-field validator surface.
Multiple custom validators on one field
Validators = [
Regex (@"^[^@\s]+@[^@\s]+\.[^@\s]+$", Some "valid email address")
Custom "blocklist-check"
Custom "domain-allowlist"
]
Run in order. The validator engine accumulates every failure across every rule and every field — no short-circuit, so the UI shows all problems at once.
Workflow guards
Predicates that veto a transition. Receive (Submission * AccessContext); return Async<Result<unit, string>>. Ok () allows the transition; Error reason surfaces as FormError.TransitionDenied reason.
open ToolUp.Forms.FormSubmission
open ToolUp.Forms.IWorkflowEngine
let hasProposalAttached : WorkflowGuard =
fun (submission, _ctx) -> async {
if submission.Values.ContainsKey "proposal_file_id" then
return Ok ()
else
return Error "no proposal file attached"
}
let creditCheckPassed (creditApi: ICreditCheckApi) : WorkflowGuard =
fun (submission, _ctx) -> async {
match Map.tryFind "company_name" submission.Values with
| Some (TextValue companyName) ->
let! score = creditApi.GetCreditScore companyName
if score >= 600.0 then return Ok ()
else return Error (sprintf "credit score %g below threshold" score)
| _ -> return Error "missing company_name field"
}
Register on FormsServerApp:
let app =
FormsServerApp.create ()
// ...
|> FormsServerApp.withGuard "has-proposal-attached" hasProposalAttached
|> FormsServerApp.withGuard "credit-check-passed" (creditCheckPassed creditApi)
Reference from a WorkflowDefinition:
{ From = "quoted"
Event = "approve"
To = "approved"
Guard = Some "credit-check-passed"
Action = None }
A failing guard returns Error (FormError.TransitionDenied reason) to the caller. A guard that throws surfaces as FormError.GuardEvaluationFailed (guardName, exceptionMessage) so callers can choose to retry on transient faults vs surface a hard denial to the user.
Guard chaining
A transition has at most one Guard. For multiple checks, compose them into one named guard:
let approvalGuard
(proposalGuard: WorkflowGuard)
(creditGuard: WorkflowGuard)
: WorkflowGuard
=
fun (submission, ctx) -> async {
let! proposalCheck = proposalGuard (submission, ctx)
match proposalCheck with
| Error r -> return Error r
| Ok () ->
let! creditCheck = creditGuard (submission, ctx)
return creditCheck
}
Workflow actions
Side-effects fired after a successful transition. Receive (Submission * AccessContext); return Async<unit>. The engine wraps every invocation in the Phase 21d IActionLedger lifecycle (exactly-once invocation across replays) and applies the per-action ActionFailurePolicy registered via withActionPolicy to decide what happens on exception. Without an explicit policy the engine defaults to DeadLetter — see concepts.md "Action ledger and failure policy" for the full table.
open ToolUp.Forms.FormSubmission
open ToolUp.Forms.IWorkflowEngine
let sendWelcomeEmail (notify: IEmailService) : WorkflowAction =
fun (submission, _ctx) -> async {
let email =
match Map.tryFind "email" submission.Values with
| Some (TextValue s) -> s
| _ -> ""
let name =
match Map.tryFind "name" submission.Values with
| Some (TextValue s) -> s
| _ -> "there"
do! notify.Send {|
To = email
Subject = sprintf "Welcome, %s!" name
Body = welcomeEmailBody submission
|}
}
let kickoffOnboardingJob (jobs: IJobScheduler) : WorkflowAction =
fun (submission, _ctx) -> async {
do! jobs.TriggerOnce ("onboarding-" + submission.Id)
}
Register:
open ToolUp.Forms.Workflow
open ToolUp.Forms.FormsCompose
let app =
FormsServerApp.create ()
// ...
|> FormsServerApp.withAction "send-welcome-email" (sendWelcomeEmail emailService)
|> FormsServerApp.withAction "kickoff-onboarding" (kickoffOnboardingJob jobs)
// Best-effort observability beacon: use LogOnly.
|> FormsServerApp.withActionPolicy "send-welcome-email" LogOnly
// Load-bearing payment capture: abort transition if it throws.
|> FormsServerApp.withActionPolicy "kickoff-onboarding" FailSubmission
Reference from a WorkflowDefinition:
{ From = "submitted"
Event = "approve"
To = "approved"
Guard = None
Action = Some "send-welcome-email" }
Multiple actions per transition
A transition has at most one Action. For multiple effects, compose into a single named action and register it once:
let onboardingActions
(welcomeEmail: WorkflowAction)
(kickoffJob: WorkflowAction)
(createCustomer: WorkflowAction)
: WorkflowAction
=
fun args -> async {
do! welcomeEmail args
do! kickoffJob args
do! createCustomer args
}
Or use jobs / events as the multi-effect substrate — fire one action that publishes an event; multiple subscribers handle the event independently.
Phase 21d — ActionFailurePolicy and the action ledger
Why this matters
Pre-21d, a workflow action that threw was warn-logged and swallowed. The submission committed; the side effect (email send, webhook fan-out, downstream API) was lost with no metric, no audit, no dead-letter row. Operators only heard about the failure when the customer followed up. Worse, a process restart between state-persist and action-completion re-fired the action on the next ApplyTransition — double emails, double payments, double webhook events.
Phase 21d introduces two coordinated primitives:
IActionLedger— exactly-once invocation per(SubmissionId, transitionId, actionName). Survives process restarts when wired to a durable backend.ActionFailurePolicy— per-action policy controlling what happens when the action throws.
ActionFailurePolicy cases
type ActionFailurePolicy =
/// Abort the transition. State NOT committed.
| FailSubmission
/// Default. Commit submission. Persist a `Failed` ledger entry
/// the operator drains via a retry orchestrator.
| DeadLetter
/// Pre-21d behaviour. Warn-log + commit. No dead-letter row.
| LogOnly
Choose by the consequence of losing the side effect:
| Policy | Choose when |
|---|---|
DeadLetter (default) |
Most actions — outbound notifications, downstream API fan-out, audit hooks. A retry orchestrator drains the dead-letter ledger asynchronously; the user-facing transition still succeeds. |
FailSubmission |
Load-bearing side effects: payment capture, regulatory webhook, legal notice. Committing the transition without the side effect leaves the system in a worse state than not transitioning at all. |
LogOnly |
Genuinely best-effort sinks where a retry orchestrator adds no value — fire-and-forget observability beacons. Also the explicit opt-in for pre-21d behaviour. |
Last write wins — calling withActionPolicy twice with the same action name keeps the most recent policy.
Observability surfaces
Regardless of policy, every action invocation emits on three surfaces:
- Metric counter —
forms.workflow.action.outcome{status="succeeded" | "failed" | "skipped_replay" | "skipped_pending"}on every registeredIMetricsSink. - Audit row —
WorkflowActionExecuted{status=...}to_platform.audit. CarriesactionName,submissionId,transitionId, actor, timestamp, plus the exception message on failure. - Ledger entry —
ActionLedgerEntry{Status = Failed reason | Succeeded | Pending}in the registeredIActionLedger.
Silent loss is structurally impossible: a failure missing from all three would require all three substrates to fail concurrently. Dead-letter retry orchestrators consume the ledger; operator dashboards consume the metric counter; compliance trails consume the audit log.
IActionLedger contract
type ActionLedgerStatus =
| Pending
| Succeeded
| Failed of reason: string
type ActionLedgerEntry = {
SubmissionId: SubmissionId
/// Engine-derived "{from}:{event}:{to}" triple.
TransitionId: string
ActionName: string
Status: ActionLedgerStatus
}
type LedgerError =
| StorageFailed of string
| EntryMissing
type IActionLedger =
abstract Record :
ActionLedgerEntry -> Async<Result<unit, LedgerError>>
abstract Lookup :
SubmissionId * transitionId: string * actionName: string ->
Async<Result<ActionLedgerEntry option, LedgerError>>
abstract MarkSucceeded :
SubmissionId * transitionId: string * actionName: string ->
Async<Result<unit, LedgerError>>
abstract MarkFailed :
SubmissionId * transitionId: string * actionName: string *
reason: string ->
Async<Result<unit, LedgerError>>
Identity by value. Entries are keyed by (SubmissionId, transitionId, actionName). The engine derives transitionId per call as "{From}:{Event}:{To}" so two transitions firing the same action on the same submission (unusual but legal) get independent ledger rows.
Idempotent Record. Inserting an entry whose key already exists is a no-op — the existing row is preserved and the engine branches on its Status rather than overwriting. This protects against double-Record on retry.
Six-rule portability. The interface satisfies all six rules: identity by string, async at every boundary, retry/failure expressed as the LedgerError data record, stateless between calls (grain deactivation is safe), no cross-key ordering promised, no sub-second timing claims. Distributed-ready by construction.
Default — InMemoryActionLedger
FormsServerApp.run auto-defaults to InMemoryActionLedger if withActionLedger is not called. This is correct for dev and single-process deployments — survives in-process retries within the same process, but does not survive a process restart. The compose step prints an out-of-band warning to stderr in non-Anonymous modes so deployments running workflow-bearing forms in production see a clear signal they need a durable ledger.
Wiring a durable IActionLedger
Skeleton for a SQL-backed implementation:
open ToolUp.Forms.FormSubmission
open ToolUp.Forms.IActionLedger
type PostgresActionLedger(connectionString: string) =
interface IActionLedger with
member _.Record entry = async {
// INSERT ... ON CONFLICT DO NOTHING for idempotency.
// Return Ok () on insert-or-retain;
// Error (StorageFailed msg) on backing-store failure.
return Ok ()
}
member _.Lookup (submissionId, transitionId, actionName) = async {
// SELECT ... WHERE submission_id = $1 AND transition_id = $2 AND action_name = $3
// Return Ok None if no row; Ok (Some entry) otherwise.
return Ok None
}
member _.MarkSucceeded (submissionId, transitionId, actionName) = async {
// UPDATE ... SET status = 'succeeded' WHERE ...
// Return Error EntryMissing if 0 rows affected.
return Ok ()
}
member _.MarkFailed (submissionId, transitionId, actionName, reason) = async {
// UPDATE ... SET status = 'failed', reason = $4 WHERE ...
// Return Error EntryMissing if 0 rows affected.
return Ok ()
}
Register on FormsServerApp:
open ToolUp.Forms.FormsCompose
let app =
FormsServerApp.create ()
// ...
|> FormsServerApp.withActionLedger (PostgresActionLedger connStr :> IActionLedger)
Conformance — IActionLedgerContract
Bind your implementation to the eight-test IActionLedgerContract pack in ToolUp.Forms.Tests/Contracts/IActionLedgerContract.fs. The pack covers:
Recordis idempotent (re-Recording returns the existing row).LookupreturnsNonefor unknown keys andSomeforRecorded keys.MarkSucceeded/MarkFailedupdate status correctly.MarkSucceeded/MarkFailedagainst an unknown key returnEntryMissing.- Distinct
(submissionId, transitionId, actionName)keys are independent. - Concurrent
Recordcalls converge to one row per key.
Passing the pack is the conformance bar — the engine relies on these lifecycle invariants, so any drop-in must satisfy them.
Replacing the renderer
FormRenderer is generic; sometimes you need custom UX (multi-step wizards, mobile-specific layouts, branded look-and-feel). Write your own renderer against the same FormSchema:
module MyCustomFormRenderer
open Feliz
open ToolUp.Forms.FormSchema
open ToolUp.Forms.FormSubmission
let render
(props:
{| Schema : FormSchema
InitialValues : Map<string, FieldValue>
OnSubmit : Map<string, FieldValue> -> unit |})
=
let values, setValues = React.useState props.InitialValues
Html.form [
prop.onSubmit (fun e ->
e.preventDefault()
props.OnSubmit values)
prop.children [
for field in props.Schema.Fields do
renderField field values setValues
Html.button [
prop.type'.submit
prop.text "Submit"
]
]
]
and renderField field values setValues =
// Render per-FieldKind.
match field.Kind with
| TextField _ -> Html.text "..."
| NumberField _ -> Html.text "..."
| DateField -> Html.text "..."
| BoolField -> Html.text "..."
| ChoiceField _ -> Html.text "..."
| _ -> Html.text "..."
The submission API still validates server-side via the schema's ValidationRules. Your custom renderer's UX matters for input quality; the server's validation is the authority.
Submission analysers (extension stub)
IFormSubmissionAnalyser is the extension point for richer analysis of submission corpora (sentiment, clustering, key-theme extraction). The default impl ships nothing; consumer companion packages plug in. Phase 21c added IAnalyserCache for memoising results across calls — wire via FormsServerApp.withAnalyserCache or let the compose step auto-construct a ResultStoreAnalyserCache when an IResultStore is in DI (Phase 8 substrate).
Skeleton for a custom analyser:
open ToolUp.Forms.FormSchema
open ToolUp.Forms.FormSubmission
open ToolUp.Forms.IFormSubmissionAnalyser
type ClaudeBasedAnalyser(aiProvider: IAIProvider) =
interface IFormSubmissionAnalyser with
member _.Analyse (scopeId, schema, submissions) = async {
// Extract long free-text fields by inspecting Kind on
// each FieldSchema (e.g. TextField with a large maxLength).
let textFieldKeys =
schema.Fields
|> List.filter (fun f ->
match f.Kind with
| TextField (Some n) when n >= 500 -> true
| TextField None -> true
| _ -> false)
|> List.map _.Key
let texts =
submissions
|> List.collect (fun s ->
textFieldKeys
|> List.choose (fun key ->
match Map.tryFind key s.Values with
| Some (TextValue text) -> Some text
| _ -> None))
// Run sentiment + clustering via the LLM ...
return analysisResult
}
Register the analyser in the DI container (the SDK's standard services.AddSingleton<IFormSubmissionAnalyser> pattern); FormsServerApp.run resolves GetServices<IFormSubmissionAnalyser>() per request and composes the registered analysers. Multiple analysers (one for sentiment, one for topic clustering) compose cleanly without a fan-out wrapper.
Multi-scope schema overrides
DefaultedFormStore (the decorator wired by default) makes compose-time-registered schemas available in every scope without a per-scope SaveSchema call. A scope can override the default by calling IFormApi.SaveSchema with the same FormSchemaId — the inner IFormStore takes precedence over the registered default.
The lookup order on GetSchema:
- Inner store's per-scope copy.
- Compose-time registered default (only when the inner store returns
NotFound).
Scopes without overrides see the default; scopes with overrides see their version. Every SaveSchema write goes to the inner store unchanged — defaults are read-fallbacks, never written through.
For deployments that don't want per-scope overrides, skip the default decorator by wiring your own IFormStore directly into the SDK's DI container before FormsServerApp.run resolves it. (Without a per-scope override the decorator's behaviour is identical to the inner store, so this is rarely worth the complexity.)
Schema versioning
FormSchema.Version is overwritten by IFormStore.SaveSchema — version 1 on first save, monotonically increasing on subsequent saves. Submissions persist SchemaVersion (the schema version at submit time) so historical submissions stay readable even after the schema has evolved.
IFormStore.GetSchema takes version: int option:
version = Nonereturns the latest.version = Some nreturns that specific historical version.
FormRenderer always renders against the latest schema. For displaying historical submissions against their original schema, fetch the versioned schema via IFormApi.GetSchema (formId, Some submission.SchemaVersion).
Replacing IFormStore or IWorkflowEngine
Both interfaces are six-rule portable. Drop-in alternatives mirror the same shape:
open ToolUp.Forms.IFormStore
type PostgresFormStore(connectionString: string) =
interface IFormStore with
member _.GetSchema (scopeId, schemaId, version) = async { return Ok Unchecked.defaultof<_> }
// ... every other abstract method ...
member _.SaveSchema (scopeId, schema) = async { return Ok schema }
member _.ListSchemas scopeId = async { return [] }
member _.DeleteSchema (scopeId, schemaId) = async { return Ok () }
member _.SaveSubmission (scopeId, submission) = async { return Ok submission }
member _.GetSubmission (scopeId, submissionId) = async { return Ok Unchecked.defaultof<_> }
member _.ListSubmissions (scopeId, query) = async { return Ok [] }
member _.DeleteSubmission (scopeId, submissionId) = async { return Ok () }
Wire via the SDK's DI registration (replacing the default FormStore factory). Conformance: bind to IFormStoreContract (in ToolUp.Forms.Tests) — the same pack passes for the shipped FormStore and any drop-in.
The default WorkflowEngine constructor accepts IFormStore, IAuditLog, IActionLedger, IMetricsSink, a warn callback, plus the workflows / guards / actions / action-policies maps. Replacing it means mirroring that constructor shape so FormsServerApp.run can pass through.
Phase 21e — IShareTokenRateLimiter
Per-token rate-limit gate consulted by IPublicFormApi.SubmitWithToken before any validation or persistence side-effect. The default implementation (InMemoryShareTokenRateLimiter) is single-instance only; multi-replica deployments wire a distributed companion so per-token windows are shared across nodes.
Interface
type IShareTokenRateLimiter =
/// Consult the rolling window for (scopeId, tokenId). Returns
/// Ok () when admission is granted (and bookkeeps the admission
/// against the window); returns Error ShareTokenError.RateLimited
/// when the caller has consumed its rate.MaxUses budget inside
/// the rolling rate.Window. Tokens whose claim has no RateLimit
/// configured skip this call entirely.
abstract Admit:
scopeId: string * tokenId: string * rate: ShareTokenRateLimit ->
Async<Result<unit, ShareTokenError>>
The interface is six-rule-portable by construction — identity by value, async at boundary, retry as data (the DU Result return), stateless handlers (window state lives in the implementation, not the caller), per-key ordering only, and precision documented at the 1-second lower bound enforced by ShareTokenTypes.validateRateLimit.
Wiring a custom limiter
let myRedisLimiter : IShareTokenRateLimiter =
MyCompany.RedisShareTokenRateLimiter(redisConnString) :> _
FormsServerApp.create ()
|> FormsServerApp.withShareTokenRateLimiter myRedisLimiter
|> // ...rest of pipeline
|> FormsServerApp.run
Without withShareTokenRateLimiter, the compose step auto-builds a fresh InMemoryShareTokenRateLimiter per process — fine for dev / single-replica deployments.
Conformance — IShareTokenRateLimiterContract
ToolUp.Forms.Tests/Contracts/IShareTokenRateLimiterContract.fs ships a framework-agnostic test pack. Any drop-in implementation MUST pass it:
let myLimiterTests =
IShareTokenRateLimiterContract.tests
"MyCompany.RedisShareTokenRateLimiter"
(fun () -> MyCompany.RedisShareTokenRateLimiter(testConnString) :> IShareTokenRateLimiter)
Six checks covering admission inside budget, exact-budget boundary, beyond-budget rejection, per-token isolation, per-scope isolation, and sliding-window forward-motion.
Companion conventions
For deeper customisation (custom field kinds, new ValidationRule cases, etc.), the right shape is "fork the package source" — the source is fully visible in the fable/ directory of the .Core / .Client nupkgs. Read existing code, copy what you need, modify in your own copy.
For shallow customisation (validators / guards / actions / analysers / ledger), use the extension points above.
ToolUp.Forms is intentionally small. The interfaces are stable; the wire format is committed; the extension points are well-defined. For the 90% case of CRUD-with-state + schema-driven UX, the SDK ships everything needed. For the 10% case of bespoke shapes, customisation through the extension points + the per-scope override mechanism handles most needs; the rest is custom modules built directly against the interfaces.
Documentation as-of forge 32ef4aa.