Forms — Behavior
This document is the canonical behavior spec for compliance forms. Check-in behavior is summarised at the end; its deep dive lives elsewhere.
1. Domain shape
A compliance form is two persistent objects:
- Template (
formsrow) — versioned, per-organization, per-typeKey. Drafts are mutable; published rows are immutable except viabumpVersion. - Instance (
form_instancesrow) — issued to oneassigneeUserId, pinned to a specific(formId, formVersion). Carriesstatus, optionalsigningToken, optionalexpiresAt.
A signed instance has a third object attached append-only:
- Signature (
form_signaturesrow, 1:1 with instance) —r2Keyof the PDF,pdfChecksumSha256,signedAt,ipAddress,userAgent,signatureImageR2Key.
Source: libs/db/src/lib/schema/forms.ts.
2. Template state machine
┌──── patchDraft (any number of times) ──┐
│ ▼
createTemplate ──┼──> draft (publishedAt = null)
│ │
│ ├── publishTemplate ──> published (publishedAt = now)
│ │ │
│ │ ├── bumpVersion ──> NEW row, version+1,
│ │ │ publishedAt = now
│ │ │ (old row stays)
│ └── archiveTemplate ────┐ │
│ ▼ ▼
└────── setAutoIssueOnJoin ──> archived (archivedAt = now,
(works on draft + EVERY version in the family)
published; NO
version bump)
deleteTemplate ──> hard DELETE of the whole family (all versions + their
open instances). 409 `form_has_submissions` once any
completed submission or signature exists — archive is
the only path from there.Encoded in:
FormsService.createTemplateacceptspublish: trueto create+publish in one step (runsassertPublishablefirst), and always inserts atversion = max(existing versions for org+typeKey) + 1so re-creating an existing typeKey (e.g. after archiving) never collides withforms_org_type_version_uq.FormsService.patchDraftrejects published rows with 409 (forms.service.ts:142).FormsService.publishTemplaterejects double-publish with 409 (forms.service.ts:170).FormsService.bumpVersionrejects unpublished sources with 409 (forms.service.ts:229).FormsService.assertPublishablerequires asignaturefield on compliance kinds before publish (forms.service.ts:269).FormsService.archiveTemplatearchives the whole version family — everyformsrow sharing(org, kind, typeKey, planId | planGroupId)— and retires the family’s open instances in the same transaction. Archiving one version while its siblings stayed live left the family still satisfying gates and still listed as active.FormsService.deleteTemplate(DELETE /templates/:formId) hard-deletes the family: open instances first, then every version row, in one transaction. It refuses with 409FORM_HAS_SUBMISSIONS(shared const) while any instance issigned/answered/reviewed, hasansweredAt, or anyform_signaturesrow exists — including a revoked-signed instance, whose status leftsignedbut whose signature row remains. A signature committed between the guard read and the delete hits theform_signaturesFK; the service maps that to the same 409.
Template invariants
| Invariant | Enforcement |
|---|---|
One row per (organization_id, type_key, version) | DB unique forms_org_type_version_uq |
Compliance template has no recurrence; check-in has no validityPeriodDays | DB CHECK forms_kind_payload_chk |
Compliance template must have a signature field before publish | FormsService.assertPublishable (publish + bumpVersion) |
Compliance typeKey is one of the six FIT-158 slugs | complianceTypeKeyEnum.safeParse at create |
| Online orgs cannot create compliance templates | FormsService.assertCompliancePermitted |
autoIssueOnJoin toggles do NOT bump version | setAutoIssueOnJoin only updates the flag column |
3. Compliance instance state machine
┌─ archiveInstance (TODO — not yet exposed) ─┐
│ ▼
assignCompliance ──> pending ──┐ │ archived
issueOnboardingForms │ │
assignAllMissing ├─ submitInstanceAuthenticated ──> signed
├─ submitByToken (writes form_signatures,
│ PDF in R2, expires set
│ if validityPeriodDays)
│
└─ generateSigningLink (mints `signing_token`; does NOT change status)draft exists as a status enum value but no current code path puts compliance instances into draft — every issuance goes straight to pending. The status enum stays in place for future “save and send later” flows.
Allowed transitions (canonical in complianceTransitions, libs/shared/src/lib/schemas/forms.ts:207):
| From | To |
|---|---|
draft | pending, archived |
pending | signed, archived |
signed | archived |
archived | — (terminal) |
canTransitionCompliance(from, to) is the single gate (forms.service.ts:742).
Instance lifecycle markers
| Field | Set when | Notes |
|---|---|---|
sentAt | At issuance | Authenticated assignment + bulk + auto-issue all set this to now |
openedAt | First GET via token OR first authenticated submit attempt | Side-effect; idempotent (only writes when null) |
answeredAt | Inside commitSignedInstance | Same value as signedAt |
reviewedAt | Check-in only | Coach marks reviewed |
expiresAt | (a) when signing_token is minted → token expiry (7 days); (b) on signed → document expiry (validityPeriodDays from signedAt); (c) NULL if neither | Overloaded on purpose — there is no separate signed_at + validity_period column; both meanings live in expires_at per phase |
signingToken | generateSigningLink | Burned (set to NULL) on successful signed transition |
Instance invariants
| Invariant | Enforcement |
|---|---|
kind and status must agree (compliance ∈ {draft,pending,signed,archived}; check-in ∈ {scheduled,sent,answered,reviewed}) | DB CHECK form_instances_kind_status_chk |
signing_token only on compliance | DB CHECK form_instances_token_kind_chk |
signing_token is globally unique among non-null tokens | DB partial unique form_instances_signing_token_uq |
| A signed instance cannot be edited | Service never updates form_instances after status='signed' except to set archived. form_signatures is append-only (no UPDATE path in code). PDF in R2 is content-addressable by SHA-256. |
| Only the assignee can sign | FormsService.submitInstanceAuthenticated rejects when instance.assigneeUserId !== membership.userId (forms.service.ts:678) — staff cannot sign on a member’s behalf, ever |
| Token can sign for one instance only and is single-use | Partial unique index + signingToken: null on successful submit |
| Token expires 7 days after mint | SIGNING_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000 (forms.service.ts:50), enforced in getByToken with GoneException |
| Auto-issue never crosses kinds | handleMembershipActivated short-circuits when event.role !== 'member' |
| Auto-issue is idempotent | coveredTypeKeys set excludes pending + valid-signed; bulk also filters alreadyPending |
4. Golden paths
4.1 Coach publishes a health declaration and bulk-issues to every member
- Coach lands on
/dashboard/forms, clicks New form → form builder. - Picks
kind=compliance,typeKey=health_declaration,locale=he. Clicks Use preset to load the Hebrew PAR-Q skeleton fromforms-presets.ts. - POST
/organizations/{orgId}/forms/templates→ row inserted withversion=1,publishedAt=null. - Coach edits fields, clicks Publish. POST
/templates/{formId}/publish→assertPublishablechecks for asignaturefield; setspublishedAt=now. - Coach toggles Auto-issue to every new member (PATCH
/templates/{formId}/auto-issuewith{value:true}). Existing members are unaffected. - Coach clicks Coverage. GET
/templates/{formId}/coverage→{ missingCount: 42, missing: [...] }. - Coach clicks Send to 42 members. POST
/templates/{formId}/assign-all-missing→ 42form_instancesrows inserted withstatus='pending',sentAt=now. Already-pending members are skipped (idempotent re-press). - From now on, every new member triggers
handleMembershipActivated→ auto-issue of the latest published version.
4.2 Member signs in-app (authenticated, default path)
- Member opens the mobile app (or
/forms/mineweb — pending) and sees a pending instance card. - Tap card →
GET /organizations/{orgId}/forms/instances/{instanceId}returns{instance, form}envelope. Service marksopenedAt=nowon first open. - Member fills out the form; draws their signature on the canvas; the canvas-PNG is uploaded via the standard authenticated staged-PUT (
POST /organizations/{orgId}/uploads/presign), producing anr2Key. - Member taps Submit.
POST /organizations/{orgId}/forms/instances/{instanceId}/submitwith{ answers: { ..., signature_field_id: { r2Key } } }. - Service: validates assignee identity, validates answer shape against
formFieldsSchema+ required fields, fetches signature PNG bytes from R2, fetches org name + logo, renders the Hebrew RTL PDF via Puppeteer, hashes the PDF (sha256), uploads to compliance R2 bucket under{orgId}/forms/{memberId}/{typeKey}/{ts}_{audit16}.pdf. - In one transaction:
INSERT form_signatureswith the checksum + audit metadata;UPDATE form_instancestostatus='signed',answers=...,answeredAt=now,signingToken=null,expiresAt = now + validityPeriodDays(or null if not configured).
4.3 Parent signs on behalf of a minor via WhatsApp link (tokenized)
- Coach assigns
parental_consent_youthto the minor’s member account:POST /compliance/assign. Instance created withstatus='pending', no token. - Coach clicks Generate link.
POST /instances/{instanceId}/generate-link→ 64-hex token,expiresAt = now+7dwritten to the same instance row. - Web UI builds
https://app.taikan.fit/{lang}/forms/sign/{token}and copies it to clipboard. Coach pastes into WhatsApp and sends to the parent. - The signer opens the link. Auth is required: the page bounces a signed-out visitor to sign-in carrying
redirect_urlback to the link, then callsGET /forms/sign/{token}, which demands an active membership in the instance’s org and that the caller is the assignee (403 otherwise). MarksopenedAt=now. - The page hands off to the one authenticated signing screen —
/{lang}/forms/{instanceId}?org={instance.organizationId}. The org rides along deliberately: that screen reads the org-scoped instance route, and its fallback is the browser’s active org, which is arbitrary for a multi-org member. - Signer fills out and signs on canvas. Mobile / web requests a tokenized presign:
POST /forms/sign/{token}/uploads/presign→{ uploadUrl, r2Key }. Server generates the r2Key server-side; client cannot choose the path; presign pins the content type. - Client PUTs the PNG bytes to the presigned URL.
- Client submits:
POST /forms/sign/{token}/submitwith{ answers: { signature: { r2Key } } }. - Same
commitSignedInstancepipeline runs; PDF is rendered, hashed, stored; instance flips tosignedand the token is burned (signingToken=null).
4.4 Coach downloads a signed PDF
- Coach navigates to a member’s profile → Forms tab.
GET /forms/users/{userId}→ list of instances. - Coach clicks Download PDF for a signed row.
GET /forms/instances/{instanceId}/pdf→{ url, expiresInSeconds: 2592000 }. - Web opens the URL in a new tab. Cloudflare R2 serves the PDF bytes. URL is cached in Redis with TTL
expiresIn - 300s.
4.5 Coach updates the health declaration (version bump)
- Coach opens the published v1 row. Editing the fields is blocked (UI shows “Already published — use Publish new version”).
- Coach clicks Publish new version with patch.
POST /templates/{formId}/bump-versionwith updatedfields,bodyRichtext, etc. - Service inserts a new
formsrow withversion=2, sametypeKey,publishedAt=now. v1 remains untouched. - In-flight v1 instances stay pinned; their
formVersion=1is preserved. Newauto_issue_on_joinissuances and bulk fan-outs use v2 going forward. - Members who already signed v1 are NOT automatically re-issued v2 (gap — see README). They will be when v1’s
expiresAtlapses and re-coverage runs.
5. Edge cases
| Scenario | Behavior |
|---|---|
| Member opens signing token twice | Each open marks openedAt only on first call. Token still valid until submit or expiry. |
| Token used after 7 days | getByToken returns 410 Gone. Staff must re-mint via generateSigningLink. |
| Token used after a successful submit | Token is NULLed on submit; getByToken returns 404. |
| Two simultaneous submits with the same token | DB transaction; second one sees status='signed' and canTransitionCompliance('signed','signed') returns false → 409 Conflict. |
| Member tries to submit someone else’s instance | assigneeUserId !== membership.userId → 403 Forbidden. |
| Staff tries to sign for a member | Same 403 — staff lack the assignee identity. Audit-correct: signature must trace to the actual person. |
| Signature PNG missing in answers | 400 Bad Request — commitSignedInstance requires a { r2Key } shape on the signature field. |
| Required field missing | 400 with Missing required fields: id1, id2, …. “Present” is type-aware (FormsService.isAnswerPresent, mirrors the mobile renderer): a required checkbox must be true — an unticked acknowledgement can never produce a signed PDF; strings must be non-empty trimmed; multi-choice non-empty array; signature/photo must carry r2Key. |
signing_token in API responses | Only the generate-link endpoint returns the token. Every other authenticated read/write (mine, get-instance, submit, staff per-user list, assign) strips it (stripSigningToken in the controller) so a routine response can’t leak a live signing credential. |
| Puppeteer render fails | Exception bubbles; commitSignedInstance aborts before transaction; instance stays pending. R2 has no orphan because upload happens after render. |
| R2 upload fails (network) | Exception bubbles; instance stays pending. No form_signatures row, no status change. Safe retry. |
| R2 upload succeeds, DB transaction fails | R2 has an orphan PDF. Acceptable — “janitor concern” per service comment (forms.service.ts:728). The signed status was never set, so legally the form is still pending. |
Org type changes from physical → online | Existing compliance instances remain (legal evidence retained). New compliance creates / assigns are rejected. |
| Member account deleted | Blocked at the DB — assigneeUserId / assignedByUserId are ON DELETE RESTRICT (FIT-280 made the previously implicit NO ACTION explicit), so a user with form instances cannot be deleted and the rows can never dangle. Remaining gap: nothing redacts, so PII inside the signed PDF (national_id, full name) survives; “right to erasure” needs a bespoke wipe path. Tracked in README gaps. |
| Auto-issue fires while membership is being onboarded | Async event listener; if it throws, the error is logged and swallowed (forms.service.ts:457-463) — onboarding never blocks on forms. |
| Bulk-assign while another bulk is in flight | Both queries run independently; the second one sees the first’s just-inserted pending rows in its alreadyPending set and skips them. |
| RTL Hebrew rendering with English fallback fonts | Puppeteer + Chromium uses native font shaping; the HTML template declares font-family: 'Heebo', 'Helvetica', 'Arial', sans-serif. Verify Heebo is bundled or the OS-installed fallback handles Hebrew before signing. |
| PDF over A4 height | page.pdf({ format: 'A4', margins: ... }) auto-paginates. Long Hebrew bodies span multiple pages. |
validityPeriodDays = null on template | expiresAt = null on signed instance — perpetually valid until archived. Used for one-shot agreements (studio rental for a specific date). |
6. Side effects per action
| Action | Writes | Emits | Reads R2 | Writes R2 |
|---|---|---|---|---|
createTemplate | forms insert | — | — | — |
publishTemplate | forms update (publishedAt) | — | — | — |
bumpVersion | forms insert (new row) | — | — | — |
setAutoIssueOnJoin | forms update (autoIssueOnJoin) | — | — | — |
archiveTemplate | forms update (archivedAt, whole family) + form_instances retirement cascade | — | — | — |
deleteTemplate | form_instances delete + forms delete (whole family) | — | — | — |
assignCompliance | form_instances insert | — | — | — |
assignAllMissing | N × form_instances insert | — | — | — |
handleMembershipActivated (event) | N × form_instances insert (idempotent) | — | — | — |
generateSigningLink | form_instances update (signingToken, expiresAt) | — | — | — |
getByToken | form_instances update (openedAt on first call) | — | — | — |
getSignatureUploadUrlForToken | — | — | — | (presign — no actual write) |
submitInstanceAuthenticated / submitByToken | form_signatures insert, form_instances update | TODO (no event emitted on signed; future: emit form.signed for spotter-agent + push) | signature PNG | signed PDF |
getSignedPdfUrl | — | — | (presign + Redis cache write) | — |
7. Permissions matrix
| Action | Role required | Additional gate |
|---|---|---|
listTemplates | staff (owner/admin/coach) | org-scoped |
createTemplate, patchDraft, publish, archive, delete, bumpVersion, setAutoIssueOnJoin | staff | compliance kind also requires org.type != 'online' |
assignCompliance, assignAllMissing, generateSigningLink, coverageForTemplate | staff | + non-online org |
getSignedPdfUrl, listForUserAsStaff | staff | org-scoped |
listForMember (own instances) | active org member | self only |
getInstanceForCaller | assignee OR any staff | both gated by org membership |
submitInstanceAuthenticated | assignee only | assigneeUserId === membership.userId |
getByToken, submitByToken, getFormUploadUrlForToken | the assignee, signed in | token locates the instance; requireMembership + assigneeUserId match decide access (401 signed out, 403 wrong account) |
isStaffRole lives in libs/shared/src/lib/utils/roles.ts and accepts owner | admin | coach.
8. Audit trail (current vs. should-be)
Current — per-row audit metadata only:
form_signatures.ipAddress(fromX-Forwarded-Forfirst hop, fallback toreq.socket.remoteAddress, capped at 45 chars for IPv6).form_signatures.userAgent(raw header).form_signatures.signedAt.form_signatures.pdfChecksumSha256(re-computable from the R2 bytes for tamper detection).- Inside the PDF: token id (first 16 chars), IP, UA, instance UUID, timestamp — visible in the footer.
Missing — what a full audit trail SHOULD look like (FIT-20):
- A separate
audit_logtable writing one row per state transition:{ actor_id, action: 'form.signed' | 'form.assigned' | 'form.token_minted' | 'form.archived', entity_type: 'form_instance', entity_id, metadata: jsonb, ip, ua, occurred_at }. - Coverage of all mutations, not just the final sign event: who created the template, who toggled
autoIssueOnJoin, who archived. - Immutable (append-only) with a periodic snapshot to S3 / R2 cold storage.
- Searchable from the staff UI.
- Required for SOC2 / ISO 27001 readiness; required for Israeli regulator inquiries about who signed what when.
9. Form gating (member-onboarding funnel)
Two flows are gated on a signed, unexpired compliance instance:
| Gate | Trigger | Enforcement | UX signal |
|---|---|---|---|
| Plan purchase | Published, non-archived template with plan_id = <plan> (any compliance typeKey; plan_regulations always) | PlansService.purchase → 409 { code: 'form_signature_required', requirements } before any subscription/payment work | formRequirements on member-facing plan responses; shop routes into the signing screen and returns to checkout |
| Class booking | Published, non-archived org-wide template with required_for_booking = true | BookingsService.book → same 409 | bookingEligibility.status='form_required' + requiredForms on session responses |
Semantics:
- Requirement identity is the template family —
(typeKey, planId | planGroupId). A signed org-wideparental_consent_youthnever covers a plan-linked one, and vice versa; a plan-linked family and a group-linked one are likewise distinct. - Group-attached regulation forms (FIT-289) — a compliance template may target a plan or a plan group, never both (CHECK
forms_plan_target_chk). A תקנון is written per offer, not per price variant, so attaching it to the group is what stops four copies of one legal document drifting apart. Because the family key carries the group rather than the plan, two properties fall out structurally rather than being special-cased: the form gates purchase of every plan in the group, and signing it once for one plan satisfies it for all the siblings — a member who signs the founders תקנון buying monthly is never asked again when switching to annual. Re-signing after a material republish (requiresResign) is demanded on the next purchase, never retroactively, exactly as for plan-linked families. - Group forms stay out of org-wide machinery — onboarding fan-out, the cancellation-notice form and the booking gate all filter
plan_id IS NULL AND plan_group_id IS NULL, so a group תקנון can never leak into them. - Deleting a group leaves its form pointing at a soft-deleted group; the form then resolves into no plan’s requirements. Re-target it to a plan or another group if the gate is still wanted.
- “Satisfied” = signed + unexpired, any version in the family. Version bumps re-issue via coverage/expiry (FIT-275) but never lock a member out mid-funnel. An expired signature re-closes the gate.
- Lazy issuance — the gate check that refuses also mints the pending instance (system-issued, idempotent through the pending check), so the 409 payload always carries a signable
instanceId. - Staff bypass —
isStaffRolecallers skip both gates. Staff enrollment (SubscriptionsService.enrollMember,skipPayment) bypasses but auto-issues the pending instances (fail-soft, never blocks the enrollment) so the front desk can follow up with a WhatsApp signing link. - No feature flag — both gates are inert until an org publishes a plan-linked template or toggles
required_for_booking; the data model itself is the per-org opt-in. - Requirement resolution lives in
FormsService.getPlanFormRequirements/getBookingFormRequirements/getFormRequirementsForPlans(forms.service.ts), typed byformRequirementSchema+FORM_SIGNATURE_REQUIREDin@taikan/shared.
Version supersession
Publishing a new version asks the coach one question: is this change material?
- Material (
requiresResign: true, the default) — signatures on earlier versions of the family stop satisfying the gates, and everyone who signed an older version is re-issued the new one immediately (not on the next cron pass). Consent to superseded text is not consent to the new text. - Minor (
requiresResign: false) — a typo or formatting fix; existing signatures stay valid and nobody is asked to sign again. Without this escape hatch a one-word edit would block an entire gym from booking.
The gate compares form_instances.form_version against the highest
published version in the family carrying requiresResign (minAcceptedVersionPerFamily),
so a chain of versions resolves to the most recent supersession. Coverage and
bulk-assign use the same threshold, so “missing” always matches what the gates
enforce.
One open copy per family
Issuing a compliance instance archives any older pending instance the same
member holds for the same family (supersedeOpenPending, called from the
shared issuance write path and from the direct staff assign). Two open copies
of the same document is a defect from every angle: the member sees the same
title twice with no way to tell them apart, and signing the older one would
record consent to text the gym has already replaced. Superseded copies are
archived (never deleted) and their signing tokens are burned.
Signing links are locators, not credentials
The token used to be the only gate, which made every signing URL a bearer
credential for a legal signature: forwarded once, anyone could sign as the
member — including a minor consenting on their own behalf on a
parental_consent_youth form assigned to them. The signing routes now
require a session whose user IS the assignee (getByToken), so the link only
says which form to open:
| Caller | Result |
|---|---|
| Signed out | 401 → web bounces to sign-in with the link as redirect_url |
| Signed in, not a member of the org | 403 (requireMembership) |
| Signed in, member, not the assignee | 403 “belongs to a different member” |
| Signed in, the assignee | Handed off to /[lang]/forms/[instanceId] |
Consequence, by design: signers without a Taikan account can no longer
use a link. Parental consent should be assigned to the parent’s profile
(which is also what makes the signature attributable and stops the minor
signing their own consent). Supplier / contract counterparties need an
account, or an explicit exemption — undecided, tracked as an open gap.
Revoking an instance
POST /forms/instances/:instanceId/archive (staff) stamps archivedAt, moves
a compliance row to archived, and burns any outstanding signing token so a
previously-sent link stops working. The member stops being covered: coverage
and both gates ignore archived rows.
Revoking a signed instance never deletes form_signatures or the PDF in
R2 — that record is evidence of what the member actually agreed to and is
retained under the same 7-year posture as everything else. Revoking asserts
“this no longer satisfies our requirement”, not “this never happened”.
The authenticated member signing screen is /[lang]/forms/[instanceId] (member app group) — the public-sign form with a Clerk-authenticated transport; ?returnTo= (validated same-app relative path) round-trips the member back to the flow that sent them.
10. Check-in flow (summary, for contrast)
- Kind
check_in, status enum subset{ scheduled, sent, answered, reviewed }. - No PDF, no signature field, no signing token (
form_instances_token_kind_chkenforces it). recurrenceJSONB drives a scheduler (not yet wired in this bucket — seedocs/features/insights/).- Same template versioning, same answer validation, same
form_instancestable.
This bucket does not test check-in behavior; see docs/features/announcements/ and docs/features/insights/ for the schedule + review surfaces when they ship.