Skip to content

Forms ​

Production-ready

The Forms capability is Lukeflow's end-to-end form platform: a headless design/render/validate library (@lukeflow/form-*), a versioned form-management data layer inside the core engine, and public embed/respond surfaces that turn a browser submission into a running BPMN process. It spans authoring (draft → check-in → sign-off → publish), inbound collection (embed → submit → durable process start), and outbound delivery (prefill → send → OTP-gated respond), all tenant-scoped and driven by transactional outboxes so no submission or process-start intent is ever lost.

Components at a glance ​

Data model ​

All tables live in the core engine's capability schema (com.luke.engine.capability.form), tenant-scoped by tenantId.

Entity (table)Key fieldsPurpose
FormDefinition (luke_form_definitions)id, @Version version, code (FM-XXXX-DDMMMYY, unique per tenant), name, kind (INBOUND/OUTBOUND), submissionHandling, outboundRolesJson, status (DRAFT/PUBLISHED/RETIRED), publishedVersion, draftSchema, allowedEmbedOrigins, embedOriginNames, embedKeyVersion, showBranding, embedVersionMode/embedVersion, lockedBy/lockedAt, deletedAt, lastTestedAtThe versioned, tenant-scoped form template. Editable working draft in draftSchema; publishedVersion is what consumers resolve. JPA @Version gives optimistic locking (concurrent draft saves → 409).
FormVersion (luke_form_versions)id, formId, version (unique per form), schema (immutable), checkedInBy/checkedInAt, signedOffAt/signedOffByAn immutable checked-in snapshot — the artifact a renderer/process resolves and never changes underneath them. Publish is gated on signedOffAt being set.
FormInstance (luke_form_instances)id, token (unique, opaque URL handle), definitionCode + version (pinned), state, prefill/data/recipient/context (JSON maps), recipientEmail/recipientPhone (denormalised + indexed, kept in sync by setRecipient), expiresAt, submittedAt, submittedIp/submittedUserAgent/submittedVia, consentText/consentAgreedAtA concrete runtime occurrence: hosted submission, prefilled invitation, or task-bound fill. context links back to processInstanceId/taskId/businessKey. The denormalised recipientEmail powers the portal's "forms assigned to me" query.
FormSubmissionOutbox (luke_form_submission_outbox)id, businessKey (= instance id, unique/idempotent), formInstanceId, processBusinessKey (SM-…), formDataJson, formMetaJson, status (QUEUED/PUBLISHED/FAILED), processInstanceId, retryCountTransactional outbox for submit → Camunda process start. Written in the same tx as the SUBMITTED state; drained off-thread.
FormEventOutbox (luke_form_event_outbox)id, eventType (lower-cased state, e.g. submitted), formCode, instanceId, payloadJson, state (QUEUED/SENT/SKIPPED/FAILED), retryCountTransactional outbox for the forms → workflow event rail. A lifecycle change enqueues a QUEUED row; the correlator starts subscribed workflows / advances waiting message catches.
FormAuditEvent (luke_form_audit)id, formId, action (created/checked_in/tested/published/archived/…), detail, actor, atImmutable activity feed for a definition's lifecycle.
FormRecipientOtp (luke_form_recipient_otp)id, instanceId (unique), codeHash, codeSalt, attempts, expiresAtOne salted-SHA-256 OTP challenge for an outbound recipient; attempt-capped, expiring, never stored in the clear.
FormEmbedSite (luke_form_embed_sites)id, tenantId+formCode+origin (unique), firstSeenAt, lastSeenAt, renderCountWebsites observed framing a form, from the embed page's Referer. Bounded per form and sampled — intelligence for the author, never a permission (see below).

Design-time flow ​

Function / endpointWhat it does
FormDefinitionController.create (POST /api/form-definitions)Mints a new definition with a generated code and kind (INBOUND/OUTBOUND), status DRAFT.
FormDefinitionController.checkout (POST /{id}/checkout)Acquires the advisory edit lock (lockedBy/lockedAt); stale locks can be taken over.
FormDefinitionController.saveDraft (PUT /{id}/draft)Persists the editable draftSchema (coltorapps-style JSON). @Version guards concurrent saves.
FormDefinitionController.checkIn (POST /{id}/versions)Snapshots the draft into a new immutable FormVersion (version = max+1), releases the lock; status untouched.
FormDefinitionController.signOff (POST /{id}/sign-off)Marks the latest version signedOffAt/signedOffBy and mirrors lastTestedAt for the "Tested" badge.
FormDefinitionController.publish (POST /{id}/versions/{v}/publish)Promotes a version to live — gated: rejects with 409 unless the version is signed off; sets publishedVersion + status PUBLISHED.
FormDefinitionController.restoreVersion / retire / unretire / cloneRestore an old version to draft, retire/unretire the definition, or clone it.
FormDefinitionController.release / discardRelease the lock, or discard draft changes back to the last checked-in version.

Runtime flow (inbound embed → submit → task complete) ​

Function / endpointWhat it does
createEmbed (form-embed)Injects a sandboxed iframe pointed at /embed/{token}, wires postMessage auto-resize (connectEmbedFrame), nonce-guards the channel; autoEmbed() scans embed script tags.
EmbedPageController.page (GET /embed/{token})Serves the engine-hosted embed HTML shell with per-form frame-ancestors CSP (from allowedEmbedOrigins, sanitized via FrameAncestors).
FormEmbedController.render (GET /api/public/embed/{token})Resolves the token (EmbedFormResolver) to the published FormVersion.schema + title; the signed embed token carries embedKeyVersion for revocation.
FormEmbedController.submit (POST /api/public/embed/{token}/submit)Per-IP + per-token rate limits, Honeypot.tripped bot trap, SubmissionValidator.clean server backstop, then creates a SUBMITTED FormInstance and calls FormSubmissionService.submit.
SubmissionValidator.cleanStrips unknown fields against the schema, enforces required, bounds payload size (413), removes C0/C1 control chars — the public endpoint cannot trust the client.
FormSubmissionService.submitIn ONE transaction: sets state SUBMITTED, merges data, writes a QUEUED FormSubmissionOutbox row (business key = instance id → idempotent), marks processStartStatus=QUEUED on context.
FormSubmissionOutboxConsumer.drain@Scheduled poller (luke.forms.outbox-poll-ms, default 2s) starts the Camunda intake process via InternalProcessService.start, mirrors attachments, flips the row PUBLISHED/FAILED with retry.
FormEventPublisher.emitEnqueues a FormEventOutbox row (lower-cased state as eventType) so subscribed workflows start / waiting message catches advance.
FormInstanceWriteBackDelegate.executeBPMN JavaDelegate at the intake process's write-back task — marks the originating instance PROCESSED and records processInstanceId; failures are logged, never rethrown.

Outbound forms ​

Outbound definitions (kind = OUTBOUND) are never embeddable — a preparer prefills fields and sends the form to a named recipient. Per-field roles live in FormDefinition.outboundRolesJson (a { fieldKey → role } map where role ∈ PREPARER | RECIPIENT | EITHER), configured via PUT /{id}/outbound-config.

  • OutboundSendController.send (POST /api/form-definitions/{id}/send) → OutboundSendService.send: creates a prefilled FormInstance with recipient identity, returns the opaque token + recipient link, and best-effort emails the recipient ("Please complete: …") — the send never fails on email.
  • Recipient access is OTP-gated: PublicFormInstanceService.requestOtp mails a FormRecipientOtp code; verify marks the instance OPENED and mints a short-lived access token (RecipientAccessTokens); render/save/submit require it. The public API (PublicFormInstanceController at /api/public/form-instances/**) never leaks whether a token exists.

Recipient portal (forms is the first provider) ​

Beyond the single-link /respond/{token} flow, a recipient can visit a per-tenant portal and see every open item assigned to their email — forms today, other capabilities next. The portal is a separate, capability-agnostic surface (core-engine module com.luke.engine.recipient, front-end app luke-portal); it owns recipient identity, and each capability contributes items through the RecipientItemProvider SPI. Forms is the first provider.

  • FormRecipientItemProvider maps every open outbound FormInstance for (tenant, email) into a "form" portal item (querying the denormalised recipientEmail), and supplies the recipient phone for the SMS channel. That is the only forms-specific piece.
  • The portal session (PortalAccessTokens, email-scoped) authorises the existing forms fill surface unchanged: PublicFormInstanceService.authorize() accepts either a per-instance RecipientAccessToken or a portal session whose tenant + recipient email match — so render/save/submit are reused, and a session for tenant A / email X can never reach another tenant's or another email's forms.
  • Outbound sends include the portal link (/portal/{tenantToken}) alongside the direct /respond link.

See Recipient Portal for the identity flow (email/SMS OTP + magic link), the SPI, and the standalone app.

Component reference ​

ComponentTypeResponsibilityKey methods
FormDefinitionControllercontrollerDesign-time CRUD + lifecycle over definitions/versionscreate, saveDraft, checkIn, signOff, publish, checkout, clone, auditTrail
FormInstanceControllercontrollerAuthenticated instance management (internal)create, list, submit, retryProcess, setState, send, cancel, processed
FormEmbedControllercontroller (public)Inbound embed render + submit with abuse guardsrender, submit, rateLimit
PublicFormInstanceControllercontroller (public)OTP-gated recipient respond flowotp, verify, GET/PATCH {token}, submit
FormRecipientItemProviderproviderForms as the first recipient-portal RecipientItemProvider — open instances → "form" itemsitemsFor, phoneFor
OutboundSendControllercontrollerSend an outbound form to a recipient (incl. the portal link)send
EmbedPageController / RespondPageControllercontrollerServe engine-hosted /embed and /respond HTML shells with per-form CSPpage
FormSubmissionServiceserviceDurable submit → process-start bridge (outbox)submit, reEnqueue, enqueue
FormSubmissionOutboxConsumerserviceDrains submission outbox, starts Camunda processdrain, process
PublicFormInstanceServiceserviceRecipient OTP/verify/render/save/submitrequestOtp, verify, render, save, submit
OutboundSendServiceserviceCreate prefilled instance + email linksend
SubmissionValidatorutilServer-side validate/clean of untrusted submissionsclean
FormEventPublisherserviceEmit lifecycle events onto the workflow railemit
EmbedFormResolver / EmbedTokens / FrameAncestors / HoneypotsupportToken resolution, embed-key revocation, CSP sanitization, bot trapresolve, tripped
FormInstanceExpirySweeperschedulerSweep expired open instances to EXPIRED@Scheduled sweep
createFormEnginelib fn (form-core)Headless form engine: dependency graph, visibility/required, validation, defaults, gridsinit, update, validate, getState, serialize
FormRenderer / useFormEnginereact comp (form-react)Renders a schema against the engine; controls, i18n, theme, sanitized HTMLrender controls, MinionProvider
FormBuilder / useFormBuilderreact comp (form-builder)Drag-drop authoring: palette, canvas, settings, problems, previewFormBuilderHandle, SettingsPanel
createEmbed / connectEmbedFramelib fn (form-embed)Host-page iframe embed SDK + auto-resize bridgecreateEmbed, autoEmbed
FormBuilderPage / FormInbox / FormInstancesListreact page (consumer-ui)Authoring UI + inbox/instance managementlifecycle actions, trace panel

Submission provenance (what makes a submission enforceable) ​

Every submission records who submitted, from where, and when — the evidence a completed form needs to be relied on. It is captured at the request edge (SubmissionSource.from(request, via, consentAgreed)) and written by FormSubmissionService.submit, the single choke point all three doors funnel through, so a submit path added later inherits it and cannot silently skip it.

For the part that shows what the person agreed to — the consent record — see Consent record below.

FieldMeaning
submittedAtWhen the instance flipped to SUBMITTED (already existed).
submittedIpThe observed client IP. Prefers the gateway-stamped X-Real-Client-IP (spoof-resistant, since the gateway strips any client-supplied one) over the forgeable X-Forwarded-For / X-Real-IP, then the socket address.
submittedUserAgentThe browser's User-Agent, truncated to 512 chars.
submittedViaEMBED (public iframe) · RESPOND (emailed link + OTP, incl. the portal) · APP (signed-in user in Lukeflow).

It is written once — a retry or re-submit never overwrites the original record — and it lands in three places so they can be reconciled:

  1. the FormInstance row (shown as "Submission record" in the instance detail),
  2. the immutable formMetaData snapshot the process instance carries (survives instance retention),
  3. the submission PDF, as a printed "Submission record" block (provenance → the render harness).

What an IP does and doesn't prove

Record it as "the address we observed", not as proof of identity: IPs are shared, proxied and reassigned. The strongest attribution in the platform is the outbound flow's email OTP — the recipient proved control of an address — with the IP as corroboration, and the consent record for what was actually accepted. Note also that an IP is personal data under GDPR/CCPA; it inherits the instance's retention, so purging an instance purges its provenance.

Provenance says where a submission came from. The consent record says what the person accepted — the piece that turns a stored response into something enforceable.

A form opts in via Form settings → Legal → "Require the filler to accept an agreement", which writes the statement into the versioned schema settings:

json
{ "settings": { "consent": { "enabled": true, "text": "I agree to the Acme terms…" } } }

Versioned, not form metadata — deliberately. Enforceability rests on proving which wording someone was shown, so the statement is pinned to the version they filled: changing it re-gates sign-off/publish like any field edit, and an instance created against v3 keeps v3's wording for ever.

How it is enforced ​

StepWhere
The filler sees the statement and must tick itFormConsentGate, rendered above the form on every fill surface (embed, respond page, recipient portal, in-app fill)
The client sends only the tick (consentAgreed: true)the submit body of each door
The server reads the wording from the schema of the version it servedConsentTerms.requiredText(schemaJson)
A submission without agreement is refused (400)FormSubmissionService.submit — the one choke point
The exact statement + timestamp are written onceFormInstance.consentText / consentAgreedAt

The client never sends the wording. That is the whole point: a tampered browser can change what it displays, but not what gets recorded, so the stored statement always matches what the served form actually asked.

Fail-closed, in three places

  1. A door that reports no consent state (source == null, or an old client omitting the field) is refused, not waived — so a submit path added later cannot quietly skip the gate.
  2. A form with consent on but no wording still requires agreement, against ConsentTerms.DEFAULT_TEXT. Silently waiving it is the one failure mode that would cost a tenant their evidence, so it can't happen. The builder seeds the statement when you switch consent on, and warns if you clear it.
  3. CONSENT_DEFAULT_TEXT in consumer-ui and luke-portal is byte-identical to the server's — if they drifted, a filler would agree to one statement while another was recorded.

Where the record lands (same three places as the rest of the provenance, so they reconcile): the FormInstance row (quoted verbatim under "Submission record"), the immutable formMetaData snapshot (consent.text / consent.agreedAt — omitted entirely when no agreement was required, so absent means "not required", never "not captured"), and the submission PDF, as a quoted block.

consentText being null on an existing row means the form asked for nothing, or the row predates the feature (V21 deliberately does not backfill) — never that someone declined, since such a submission never persisted.

Embed version control ("Update embeds") ​

The embed snippet points at an opaque token (tenant + form code + embed-key version) — never a version number. The server resolves the version per request, which means nothing on a customer's website ever has to change: there is no form source out there to rewrite.

What is configurable is which version that resolution returns:

embedVersionModeBehaviour
AUTO (default)Serves publishedVersion. Publishing reaches every live embed immediately.
PINNEDServes embedVersion. Publishing does not change what fillers see until someone explicitly updates the embed — which is what makes a legally-significant form safe to iterate on.

EmbedVersions.resolve is the single home for that rule, and both the public render and the public submit call it. That matters: if they resolved differently, a pinned embed would render v3 and validate answers against v5 — silently dropping fields the filler was actually shown. A pin that is null, or points at a deleted version, falls back to publishedVersion: a stale pin must degrade to "serves the current form", never to a blank iframe on someone else's site.

GET /api/form-definitions/{id}/embed-version reports mode, pinnedVersion, publishedVersion, servingVersion and updateAvailable (true when fillers are behind), which drives the Embed panel's "Update embeds to vN" button.

"Embedded on" — where a form is live ​

luke_form_embed_sites records the origins observed framing a form, learned from the Referer of the iframe's request for /embed/{token} — the one request that can see it (the XHRs the bundle makes afterwards carry the iframe's own URL). Browsers send origin-only for a cross-origin iframe under the default strict-origin-when-cross-origin policy, which is exactly the datum wanted and nothing more.

Three deliberate limits, because the recorder runs on a public, unauthenticated page:

  • Never breaks the page — every failure is swallowed, and it runs in its own REQUIRES_NEW transaction so a failed insert can't poison the caller's.
  • Bounded — at most luke.embed.sites.max-per-form (50) origins per form, since a forged Referer could otherwise write unbounded rows.
  • Sampled — an already-seen origin is only re-written once its lastSeenAt is older than luke.embed.sites.throttle-minutes (5), so renderCount is "roughly how busy", not an analytics figure.

The embed dialog ​

FormEmbedPanel is a fixed-height dialog with three tabs, rather than the single long scroll it began as — its sections answer unrelated questions, and the allowlist (the part authors come back to change) had ended up below three screens of content.

TabContents
SnippetThe copy-paste snippet, Open preview, Regenerate link
VersionWhich version visitors are served (AUTO vs PINNED)
WebsitesThe allowed-websites editor, plus the observed "Seen embedding this form" list

Allowed websites are edited as Name + Website rows with Add another. The name is stored in embedOriginNames, a JSON map keyed by canonical origin and deliberately held in its own column — allowedEmbedOrigins is the CSP input, so its format is a security surface, while a label is decoration. A label is only kept for an origin that is actually on the allowlist, so removing a site drops its name with it.

A malformed origin blocks the save with the offending row marked, rather than being filtered out: dropping it would narrow the allowlist to whatever happened to parse, so a typo would look saved while the site it was meant to permit stayed blocked. The engine independently fails the same case closed with a 400.

Observation, not permission

A host page can suppress the header (Referrer-Policy: no-referrer) and never appear in this list, and the header is client-supplied so a caller could name an origin that isn't really embedding the form. The authoritative "who may frame this form" remains allowedEmbedOrigins, enforced by the browser as CSP frame-ancestors. The list annotates each origin with allowed so an author can spot a site the policy is blocking — but it must never become the gate.

Who fills each field (outbound) ​

An outbound form is completed by two people, so every data variable belongs to one of them. The Who fills dialog is the single place that decision is made:

AnswerStored roleMeaning
Recipient fillsRECIPIENTThey see an empty field to complete
You fillPREPARERYou answer it when sending; the recipient can't change it
You fill, they can editEITHERYou seed it and they may still correct it

The map is keyed by FIELD KEY and stored on the definition, so changing it takes effect for the next send without cutting a new version.

Enforced, not merely rendered ​

PublicFormInstanceService.recipientWritable strips every PREPARER field from a recipient's save/submit server-side. Without it, anyone holding a valid recipient link could POST a new value for a preparer-owned field and silently rewrite the terms of what they were sent — the endpoint is reachable without a browser.

Rejected keys are dropped silently rather than 400'd: a legitimate client never sends them (they are disabled in its DOM), so a request carrying them is either tampering or a stale tab, and neither deserves a descriptive error.

EITHER stays recipient-writable — the preparer may seed it, the recipient may still change it. And a form authored before roles existed keeps working: a field marked disabled in the schema is treated as preparer-owned, which is how preparer fields were always expressed.

The map can outlive the fields it names

Roles live on the DEFINITION while schemas are versioned, so an entry can name a field that no longer exists. Everything derives the field list from the schema and treats the map as advisory — a stale entry is ignored rather than breaking a send. It is therefore not a historical record of what a given instance was sent under.

Generate template ​

The same dialog generates the spreadsheet a preparer fills for a campaign, because the template's contents depend on exactly these decisions. It carries:

  • the recipient — recipient.firstName, recipient.lastName, recipient.email, recipient.phone. A prefill row is useless without one: the form data says what to send, never to whom;
  • only the fields the preparer owns — PREPARER and EITHER. A column for a recipient-owned field reads as an instruction to answer on their behalf, and the engine strips exactly those values on submit, so the column would be a lie.

Backed by the pure buildFormTemplate(schema, registry, { includeKeys, metaColumns }) in form-core. An empty includeKeys yields no field columns rather than all of them — the opposite default would quietly hand the preparer every recipient-owned field.

Email or phone is documented, not enforced

Email and phone are an "at least one of these" pair, stated in the generated file itself. There is no CSV import to validate it against.

Separately, POST /send still requires recipient.email and delivery is email-only — there is no SMS path. A phone-only row therefore cannot be sent today; the column exists because the recipient record wants it.

There is no Data view

The builder's Design ⇄ Data toggle is hidden on every Lukeflow form (hideDataView). An inbound form has one filler, so there is nothing to divide the data between; for outbound, the same decisions — and the template that depends on them — live in Who fills. Two places to answer one question is one too many.

Builder status icon ​

Next to the gear sits a status icon (FormStatusPopover) carrying everything about the form's state. It replaced a row of chips — status · version · signed-off · save state — plus a full-width view-only banner, which stated the same few facts in four places and pushed the canvas down the page.

The glyph and colour still answer the question without a click:

IconMeaning
Grey padlockView only — you have edit rights but haven't checked the form out
Green filled circlePublished vN — a version is live on the public surfaces
Amber dashed circleNot published — nothing is live yet

Opening it gives the detail (version being edited vs live, sign-off date, autosave state) and, when view-only, the Checkout button — "read-only" and "how do I edit this" are the same question, and they used to sit at opposite ends of the page.

One banner deliberately stays inline

The "this form is being edited by …" warning is NOT folded into the icon. It is time-critical and destructive if missed — your changes may overwrite a colleague's — and a conflict warning nobody opened is a conflict warning that did not work. Ambient status can live behind a click; a warning about losing someone else's work cannot.

Form settings dialog ​

Everything about a form that isn't a field lives behind the gear icon next to the form name in the builder toolbar, split into five tabs (FormSettingsModal):

TabContentsSave model
GeneralName, description, form ID, created/last-edited byMetadata — Save button
SubmissionSubmission message, file attachments (paid — see File attachments), "save submission as Attachment" (PDF)Versioned schema — autosaves
LegalRequire an agreement + its wording, plus what is always recordedVersioned schema — autosaves
AppearanceForm font (with preview), "Developed at Lukeflow" badgeFont: versioned schema · badge: metadata
ActivityThe form's audit feed (FormAuditEvent)Read-only

The two save models are stated in the dialog footer rather than implied: versioned schema settings autosave and re-gate sign-off/publish (they are part of what gets published), while metadata applies to the live form immediately with no re-publish. Editing any of it requires a checkout, matching the builder canvas.

Form font ​

A form's typeface is a font-catalog id stored in its versioned schema settings (settings.font), resolved to a CSS stack and applied as the renderer's --lf-font design token. Because that happens in the one adapter every surface goes through (LukeFormRenderer), the choice shows up in the builder preview, the in-app fill, the public embed, the outbound respond page, the recipient portal and the submission PDF with no per-surface wiring.

The catalog (luke-consumer-ui/src/lib/formFonts.ts, mirrored in luke-portal) mixes system stacks that need no network at all with a few Google-hosted families fetched on demand only when selected. A form stores an id, never a family name or URL — an unknown id falls back to the default, so a hand-edited schema cannot inject CSS into the token or a stylesheet URL into the page. Authors choosing a Google-hosted font are told in the UI that the filler's browser will contact Google, with the system stacks offered as the no-third-party alternative.

"Developed at Lukeflow" badge ​

Every PUBLIC form surface — the embed iframe, the outbound /respond/{token} page and the recipient portal — renders a small attribution pill under the form card. It is the product's own marketing on work our customers publish, so the rule is deliberately asymmetric:

Tenant planPer-form optionWhat a filler sees
FREE (no luke_tenant_plan row)Locked ON, shown with a "Paid plan" hintBadge always
PAIDAuthor's choice, defaults to ONBadge unless they switched it off
  • Where the flag lives. FormDefinition.showBranding — form metadata, not versioned schema, so toggling it takes effect on the live embed with no re-publish (same class of setting as allowedEmbedOrigins).
  • Who decides. com.luke.engine.branding.BrandingPolicy is the single home for the rule. Both public payloads (GET /api/public/embed/{token} and GET /api/public/form-instances/{token}) carry the already-effective showBranding boolean, so a browser never re-derives visibility. The authoring PATCH /api/form-definitions/{id} independently returns 402 Payment Required if a free tenant tries to hide it — the UI's lock is an affordance, not the boundary. Reads carry a transient brandingLocked so the builder can render the option as locked; it defaults to locked so any response that skips the enrichment fails closed.
  • Plan source of truth. luke_tenant_plan (id = tenantId, plan = FREE|PAID) — the absence of a row is the free tier, so there is one representation of "not paying" and a deleted row can never silently upgrade anyone. Written only by the operator-authenticated PUT /api/tenants/{id}/plan (audited via AdminAuditService), which is the seam a real billing integration will write to. A downgrade never rewrites the author's stored preference: the badge reappears while they are free and their original choice returns if they upgrade again.
  • Fail-safe. An unreachable plan table keeps the badge visible rather than failing the render.

File attachments (paid) ​

Attachments are the expensive part of a form — object storage we hold, retain and serve on the tenant's behalf — so they are a paid feature. A free tenant collecting uploads is an unbounded cost with no revenue against it.

com.luke.engine.branding.PlanFeatures owns the rule, sitting beside BrandingPolicy (which owns the badge, the rule that came first) so a second gated feature does not end up inside a class named after branding. Every method fails closed: a blank tenant or an unreachable plan table means free, because handing out a paid feature on a billing blip is the expensive mistake while briefly refusing one is visible and recoverable.

Enforced in two places, only one of which is the boundary:

WhereWhatIs it the gate?
EmbedDocumentService.authorize402 Payment RequiredYes. The endpoint is reachable by anyone holding the embed token, so the browser cannot be trusted with it. Refused before the rate-limit window and attachment count are touched, so blocked uploads can't burn a shared token's budget.
GET /api/public/embed/{token}Effective attachmentsEnabled (schema setting and plan)No — a courtesy. It spares a filler an upload that was never going to be accepted, and stops a free tenant re-opening the tab by editing the payload.

Reads carry a transient attachmentsLocked beside brandingLocked so the builder renders the option locked with a "Paid plan" chip. Both default to locked, so a response that skips the enrichment fails closed rather than offering a feature the server then refuses.

FormSettingsRead is the one server-side reader of the versioned schema's settings.*, keeping "did the author ask for it" and "are they entitled to it" as two separate questions rather than one flag that means neither.

Payments (Stripe, bring your own account) ​

A form can take a card payment as part of submitting. Each workspace connects its own Stripe account (Connect OAuth); charges are direct charges on that account, so the tenant is the merchant of record — their descriptor, receipts, payouts, refunds and disputes. Lukeflow never holds funds and takes no application fee; payments are an entitlement of the Pro plan and up (PlanCatalog.payments). The whole feature is config-gated on the platform's Stripe keys.

Pricing is the server's. The payment field is priced one of three ways, and a browser-computed total is never trusted — which is why there is no formula mode (the engine validates answers but does not re-run form expressions):

ModeAmountThe respondent controls
fixedamountMinornothing
perUnitamountMinor × quantity (≤ maxQuantity, default 100)a whole-number quantity field
enteredthe respondent's amount, between minAmountMinor and maxAmountMinoran amount field

A source field must be a required, plain number/currency field that is placed on the form. It can't be calculated, hidden, disabled, conditional, or inside a grid. For an entered amount, a currency field must show the payment's own currency, and the field may not allow more decimal places than that currency charges. form-core's payment-amount-source diagnostic blocks publishing anything else.

The payment field itself has rules too:

  • It must be placed on the form (payment-unreachable).
  • It can't be conditional (including customConditionalJs) or sit inside a conditional container. Both languages refuse to price it: the server can't tell whether the payment was due.
  • In a multi-page form, it and every submit button must be on the last page (payment-wizard-page), because the card form only exists while its page is showing.

The rules follow the engine exactly:

  • Containment is read from children (not parentId); a numeric reference counts like a property key.
  • hidden/disabled use JavaScript truthiness.
  • An entities array is keyed by position, as the renderer reads it (the server's submission validator does the same).

The pricing function exists twice: form-core resolvePaymentAmount for the payer's preview, and core-engine PaymentAmountResolver for the charge. The shared payment-parity.json fixture (167 cases plus the currency table) runs in both test suites.

Lifecycle.

  • Nothing is queued to a process, no workflow event fires and no usage is metered until Stripe says the charge succeeded — every settlement path re-reads the intent from Stripe rather than trusting a request or an event body, and checks amount, currency and mode match what was priced.
  • No surprise amounts. The browser confirms a charge on its own only when the server's amount equals the total the payment field showed. Otherwise the pay-only step names the real amount and waits for the payer.
    • An embed submission also sends the version it rendered. A payment form served at a newer version refuses the submission with 409.
    • The respond page previews against the served schema, not the copy with the preparer's fields locked.
  • A declined card keeps the same charge payable: the payer retries from a pay-only step, which first re-reads the charge. After any confirmation attempt, even a failed one, the page asks the server what happened, so a charge Stripe took counts as paid.
  • If the charge can't be started (Stripe unreachable), the submission stays saved and the page offers Try again (POST …/payments/{instanceId} for the embed, …/payment for respond).
  • A recipient who leaves mid-payment can verify again and resume. Resuming restarts the abandonment clock.
  • A paid submission returned for correction asks for no card and is not charged twice (payment.alreadyPaid in the render). Before it is resubmitted, its charge is re-read from Stripe. A refunded or disputed one can't be resubmitted (409); disputes arrive through charge.dispute.* and are audited as payments.disputed.
  • On an emailed-link form, a preparer-owned quantity or amount is priced from the preparer's prefill.
  • A payment row is never lost while its intent could take money.
    • A late intent for an attempt that has already ended is cancelled and never handed out.
    • An intent changed in Stripe is cancelled as soon as the payer returns.
    • A charge the platform can no longer read ends as UNRESOLVED: it is released, audited as payments.unresolved, and re-checked daily. It blocks a resubmission until Stripe says what happened. Lost access is decided from the account (a revoked grant disconnects the workspace), never from a bare 403/404.
  • The reconciler runs on its own thread under a per-run time budget. It takes the oldest charges first, and a charge that keeps failing is retried on the next run, behind the others. It claims a charge under its lock before cancelling it, so a returning payer is never handed a secret that is about to be voided.
  • The in-app door refuses payment forms (staff would be keying in someone else's card), and the generic state-transition endpoint cannot move a submission into or out of AWAITING_PAYMENT.
  • Publishing a payment form, or pinning embeds to a version that has one, is refused (422 misconfigured, 409 not ready) unless the payment is sound and the workspace has a connected account that can take charges. The in-app Fill page checks the published version first (takesPayment in GET …/by-code/{code}/fields) and creates no instance for a payment form.
  • Disconnecting marks the account DISCONNECTING, so it takes no new charges. It then cancels the workspace's unpaid charges at Stripe, re-reading them until none are left; a processing charge blocks the disconnect (409). The Stripe grant is revoked only if no other connected workspace uses the same account. If a cancel or the revoke fails, the account goes back to connected (502) so the owner can retry.
  • A deauthorization event is checked against Stripe before it disconnects anything, so a late retry can't undo a reconnection. The payments page also notices a revoked account the next time it re-reads the account.
  • The Stripe Connect return is completed for the workspace that started it, and the page switches to that workspace.
  • A tenant purge removes the payment rows and the account's webhook-event rows. After it commits, it makes a best-effort attempt to cancel the tenant's open intents and to revoke the grant.
  • Card only, wallets off — forms are commonly iframed, where redirect-based methods break, and Apple/Google Pay need per-domain registration on every connected account. Stripe.js loads only from js.stripe.com; the embed page's CSP allows exactly that plus hooks.stripe.com (3-D Secure).

Setup, the test-mode checklist, go-live and incident handling: luke-core-engine/docs/runbooks/form-payments.md.

Endpoints ​

MethodPathPurposeAuth model
POST/api/form-definitionsCreate a definitionTenant (X-Tenant-Id)
PATCH/api/form-definitions/{id}Edit metadata: name, description, embed origins, showBranding (402 if a free plan tries to hide the badge)Tenant + user
GET/api/form-definitions · /{id} · /by-code/{code}List / fetch definitionsTenant
PUT/api/form-definitions/{id}/draftAutosave working draftTenant + X-User-Id
POST/api/form-definitions/{id}/versionsCheck in an immutable versionTenant + user
POST/api/form-definitions/{id}/sign-offSign off latest version (tested)Tenant + user
POST/api/form-definitions/{id}/versions/{v}/publishPublish (gated on sign-off)Tenant + user
POST/api/form-definitions/{id}/checkout · /release · /discardEdit-lock lifecycleTenant + user
PUT/api/form-definitions/{id}/outbound-configSet per-field preparer/recipient rolesTenant
POST/api/form-definitions/{id}/sendSend an outbound form to a recipientTenant
GET/api/form-definitions/{id}/embed-token · POST /embed-token/rotateFetch / revoke embed tokenTenant
GET/PUT/api/form-definitions/{id}/embed-versionRead / set which version embeds serve (AUTO | PINNED)Tenant
GET/api/form-definitions/{id}/embed-sitesWebsites observed embedding this formTenant
POST/api/form-instances · GET / · PATCH /{id} · /{id}/submitInternal instance managementTenant
GET/embed/{token}Engine-hosted embed page (per-form CSP)Public (origin-gated)
GET/api/public/embed/{token}Public render of published schemaPublic (token)
POST/api/public/embed/{token}/submitInbound submission → process startPublic (token, rate-limited)
GET/respond/{token}Engine-hosted respond pagePublic
POST/api/public/form-instances/{token}/otp · /verifyRecipient OTP challenge / verifyPublic (token)
GET/PATCH/POST/api/public/form-instances/{token} · /submitRecipient render / autosave / submitPublic (token + access token)
POST/api/public/embed/{token}/payments/{instanceId}Start / resume a saved embed submission's chargePublic (token, rate-limited)
POST/api/public/embed/{token}/payments/{instanceId}/syncRe-read a submission's charge from Stripe and settle itPublic (token, rate-limited)
POST/api/public/form-instances/{token}/payment · /payment/syncResume / settle a recipient's chargePublic (token + access token)
GET/api/payments/accountWorkspace payment status (enabled · plan · connected account · ready)Member
POST/api/payments/connect · /connect/completeStart / finish Stripe Connect OAuthWorkspace owner
POST · DELETE/api/payments/account/refresh · /api/payments/accountRe-read / disconnect the accountWorkspace owner
GET/api/payments/submissions/{instanceId}A submission's charge (no secrets)Member with FORMS read
POST/webhooks/stripe-connectConnected-account events (settles charges, account status)Stripe signature
GET/portal/{tenantToken}Engine-hosted recipient portal pagePublic (origin-gated)
—/api/public/portal/** · /portal/{tenantToken}Recipient portal identity + item listing (capability-agnostic)See Recipient Portal
GET/PUT/api/tenants/{tenantId}/planRead / set a tenant's plan (FREE|PAID) — gates hiding the Lukeflow badgeOperator credential

Status & gaps ​

Production-ready across authoring, inbound embed, outbound respond, and the recipient portal, all backed by transactional outboxes and per-tenant isolation. The portal's SMS OTP channel is wired behind a seam but inert until an SMS gateway is configured (luke.forms.sms.enabled + a real SmsPortalOtpSender); email OTP and magic link are live. See the Completeness Scorecard for the fleet-wide status view.

Lukeflow Manual · documentation snapshot July 2026