Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesFormsForms — Behavior

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 (forms row) — versioned, per-organization, per-typeKey. Drafts are mutable; published rows are immutable except via bumpVersion.
  • Instance (form_instances row) — issued to one assigneeUserId, pinned to a specific (formId, formVersion). Carries status, optional signingToken, optional expiresAt.

A signed instance has a third object attached append-only:

  • Signature (form_signatures row, 1:1 with instance) — r2Key of 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.createTemplate accepts publish: true to create+publish in one step (runs assertPublishable first), and always inserts at version = max(existing versions for org+typeKey) + 1 so re-creating an existing typeKey (e.g. after archiving) never collides with forms_org_type_version_uq.
  • FormsService.patchDraft rejects published rows with 409 (forms.service.ts:142).
  • FormsService.publishTemplate rejects double-publish with 409 (forms.service.ts:170).
  • FormsService.bumpVersion rejects unpublished sources with 409 (forms.service.ts:229).
  • FormsService.assertPublishable requires a signature field on compliance kinds before publish (forms.service.ts:269).
  • FormsService.archiveTemplate archives the whole version family — every forms row 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 409 FORM_HAS_SUBMISSIONS (shared const) while any instance is signed/answered/reviewed, has answeredAt, or any form_signatures row exists — including a revoked-signed instance, whose status left signed but whose signature row remains. A signature committed between the guard read and the delete hits the form_signatures FK; the service maps that to the same 409.

Template invariants

InvariantEnforcement
One row per (organization_id, type_key, version)DB unique forms_org_type_version_uq
Compliance template has no recurrence; check-in has no validityPeriodDaysDB CHECK forms_kind_payload_chk
Compliance template must have a signature field before publishFormsService.assertPublishable (publish + bumpVersion)
Compliance typeKey is one of the six FIT-158 slugscomplianceTypeKeyEnum.safeParse at create
Online orgs cannot create compliance templatesFormsService.assertCompliancePermitted
autoIssueOnJoin toggles do NOT bump versionsetAutoIssueOnJoin 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):

FromTo
draftpending, archived
pendingsigned, archived
signedarchived
archived— (terminal)

canTransitionCompliance(from, to) is the single gate (forms.service.ts:742).

Instance lifecycle markers

FieldSet whenNotes
sentAtAt issuanceAuthenticated assignment + bulk + auto-issue all set this to now
openedAtFirst GET via token OR first authenticated submit attemptSide-effect; idempotent (only writes when null)
answeredAtInside commitSignedInstanceSame value as signedAt
reviewedAtCheck-in onlyCoach marks reviewed
expiresAt(a) when signing_token is minted → token expiry (7 days); (b) on signed → document expiry (validityPeriodDays from signedAt); (c) NULL if neitherOverloaded on purpose — there is no separate signed_at + validity_period column; both meanings live in expires_at per phase
signingTokengenerateSigningLinkBurned (set to NULL) on successful signed transition

Instance invariants

InvariantEnforcement
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 complianceDB CHECK form_instances_token_kind_chk
signing_token is globally unique among non-null tokensDB partial unique form_instances_signing_token_uq
A signed instance cannot be editedService 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 signFormsService.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-usePartial unique index + signingToken: null on successful submit
Token expires 7 days after mintSIGNING_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000 (forms.service.ts:50), enforced in getByToken with GoneException
Auto-issue never crosses kindshandleMembershipActivated short-circuits when event.role !== 'member'
Auto-issue is idempotentcoveredTypeKeys 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

  1. Coach lands on /dashboard/forms, clicks New form → form builder.
  2. Picks kind=compliance, typeKey=health_declaration, locale=he. Clicks Use preset to load the Hebrew PAR-Q skeleton from forms-presets.ts.
  3. POST /organizations/{orgId}/forms/templates → row inserted with version=1, publishedAt=null.
  4. Coach edits fields, clicks Publish. POST /templates/{formId}/publishassertPublishable checks for a signature field; sets publishedAt=now.
  5. Coach toggles Auto-issue to every new member (PATCH /templates/{formId}/auto-issue with {value:true}). Existing members are unaffected.
  6. Coach clicks Coverage. GET /templates/{formId}/coverage{ missingCount: 42, missing: [...] }.
  7. Coach clicks Send to 42 members. POST /templates/{formId}/assign-all-missing → 42 form_instances rows inserted with status='pending', sentAt=now. Already-pending members are skipped (idempotent re-press).
  8. From now on, every new member triggers handleMembershipActivated → auto-issue of the latest published version.

4.2 Member signs in-app (authenticated, default path)

  1. Member opens the mobile app (or /forms/mine web — pending) and sees a pending instance card.
  2. Tap card → GET /organizations/{orgId}/forms/instances/{instanceId} returns {instance, form} envelope. Service marks openedAt=now on first open.
  3. 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 an r2Key.
  4. Member taps Submit. POST /organizations/{orgId}/forms/instances/{instanceId}/submit with { answers: { ..., signature_field_id: { r2Key } } }.
  5. 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.
  6. In one transaction: INSERT form_signatures with the checksum + audit metadata; UPDATE form_instances to status='signed', answers=..., answeredAt=now, signingToken=null, expiresAt = now + validityPeriodDays (or null if not configured).
  1. Coach assigns parental_consent_youth to the minor’s member account: POST /compliance/assign. Instance created with status='pending', no token.
  2. Coach clicks Generate link. POST /instances/{instanceId}/generate-link → 64-hex token, expiresAt = now+7d written to the same instance row.
  3. 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.
  4. The signer opens the link. Auth is required: the page bounces a signed-out visitor to sign-in carrying redirect_url back to the link, then calls GET /forms/sign/{token}, which demands an active membership in the instance’s org and that the caller is the assignee (403 otherwise). Marks openedAt=now.
  5. 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.
  6. 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.
  7. Client PUTs the PNG bytes to the presigned URL.
  8. Client submits: POST /forms/sign/{token}/submit with { answers: { signature: { r2Key } } }.
  9. Same commitSignedInstance pipeline runs; PDF is rendered, hashed, stored; instance flips to signed and the token is burned (signingToken=null).

4.4 Coach downloads a signed PDF

  1. Coach navigates to a member’s profile → Forms tab. GET /forms/users/{userId} → list of instances.
  2. Coach clicks Download PDF for a signed row. GET /forms/instances/{instanceId}/pdf{ url, expiresInSeconds: 2592000 }.
  3. 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)

  1. Coach opens the published v1 row. Editing the fields is blocked (UI shows “Already published — use Publish new version”).
  2. Coach clicks Publish new version with patch. POST /templates/{formId}/bump-version with updated fields, bodyRichtext, etc.
  3. Service inserts a new forms row with version=2, same typeKey, publishedAt=now. v1 remains untouched.
  4. In-flight v1 instances stay pinned; their formVersion=1 is preserved. New auto_issue_on_join issuances and bulk fan-outs use v2 going forward.
  5. Members who already signed v1 are NOT automatically re-issued v2 (gap — see README). They will be when v1’s expiresAt lapses and re-coverage runs.

5. Edge cases

ScenarioBehavior
Member opens signing token twiceEach open marks openedAt only on first call. Token still valid until submit or expiry.
Token used after 7 daysgetByToken returns 410 Gone. Staff must re-mint via generateSigningLink.
Token used after a successful submitToken is NULLed on submit; getByToken returns 404.
Two simultaneous submits with the same tokenDB transaction; second one sees status='signed' and canTransitionCompliance('signed','signed') returns false → 409 Conflict.
Member tries to submit someone else’s instanceassigneeUserId !== membership.userId → 403 Forbidden.
Staff tries to sign for a memberSame 403 — staff lack the assignee identity. Audit-correct: signature must trace to the actual person.
Signature PNG missing in answers400 Bad Request — commitSignedInstance requires a { r2Key } shape on the signature field.
Required field missing400 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 responsesOnly 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 failsException 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 failsR2 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 physicalonlineExisting compliance instances remain (legal evidence retained). New compliance creates / assigns are rejected.
Member account deletedBlocked 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 onboardedAsync 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 flightBoth 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 fontsPuppeteer + 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 heightpage.pdf({ format: 'A4', margins: ... }) auto-paginates. Long Hebrew bodies span multiple pages.
validityPeriodDays = null on templateexpiresAt = null on signed instance — perpetually valid until archived. Used for one-shot agreements (studio rental for a specific date).

6. Side effects per action

ActionWritesEmitsReads R2Writes R2
createTemplateforms insert
publishTemplateforms update (publishedAt)
bumpVersionforms insert (new row)
setAutoIssueOnJoinforms update (autoIssueOnJoin)
archiveTemplateforms update (archivedAt, whole family) + form_instances retirement cascade
deleteTemplateform_instances delete + forms delete (whole family)
assignComplianceform_instances insert
assignAllMissingN × form_instances insert
handleMembershipActivated (event)N × form_instances insert (idempotent)
generateSigningLinkform_instances update (signingToken, expiresAt)
getByTokenform_instances update (openedAt on first call)
getSignatureUploadUrlForToken(presign — no actual write)
submitInstanceAuthenticated / submitByTokenform_signatures insert, form_instances updateTODO (no event emitted on signed; future: emit form.signed for spotter-agent + push)signature PNGsigned PDF
getSignedPdfUrl(presign + Redis cache write)

7. Permissions matrix

ActionRole requiredAdditional gate
listTemplatesstaff (owner/admin/coach)org-scoped
createTemplate, patchDraft, publish, archive, delete, bumpVersion, setAutoIssueOnJoinstaffcompliance kind also requires org.type != 'online'
assignCompliance, assignAllMissing, generateSigningLink, coverageForTemplatestaff+ non-online org
getSignedPdfUrl, listForUserAsStaffstafforg-scoped
listForMember (own instances)active org memberself only
getInstanceForCallerassignee OR any staffboth gated by org membership
submitInstanceAuthenticatedassignee onlyassigneeUserId === membership.userId
getByToken, submitByToken, getFormUploadUrlForTokenthe assignee, signed intoken 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 (from X-Forwarded-For first hop, fallback to req.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_log table 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:

GateTriggerEnforcementUX signal
Plan purchasePublished, 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 workformRequirements on member-facing plan responses; shop routes into the signing screen and returns to checkout
Class bookingPublished, non-archived org-wide template with required_for_booking = trueBookingsService.book → same 409bookingEligibility.status='form_required' + requiredForms on session responses

Semantics:

  • Requirement identity is the template family(typeKey, planId | planGroupId). A signed org-wide parental_consent_youth never 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 bypassisStaffRole callers 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 by formRequirementSchema + FORM_SIGNATURE_REQUIRED in @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.

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:

CallerResult
Signed out401 → web bounces to sign-in with the link as redirect_url
Signed in, not a member of the org403 (requireMembership)
Signed in, member, not the assignee403 “belongs to a different member”
Signed in, the assigneeHanded 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_chk enforces it).
  • recurrence JSONB drives a scheduler (not yet wired in this bucket — see docs/features/insights/).
  • Same template versioning, same answer validation, same form_instances table.

This bucket does not test check-in behavior; see docs/features/announcements/ and docs/features/insights/ for the schedule + review surfaces when they ship.