Forms
Structured data collection — intake questionnaires, consent forms, assessments, and more — all built from a shared field library with complete immutability once signed.
NOT BUILT — reconciled 2026-08-02
No form_templates, no forms, no custom_fields, no Go domain, no routes, and zero forms.* permission rows in any migration. The clinic Forms tab is a placeholder. Ships in migrations 000042 + 000043, after F1 and F2.1 and before F4 (calendars reference form_templates).
The template-version snapshot on the instance is the single most important structural fix in the entire F1–F6 port. The live legacy system has none: editing a template retroactively rewrites how every historical form renders, which its own migration docs call the #1 reason to redesign. Everything else in this feature is secondary to getting that right.
Corrections to this page:
user_consentsdoes not exist. The shipped table isconsents(foundation 1B.9) — an append-on-grant ledger withpurpose_version,granted_by_principal_id,granted_via_ip,source, withdrawal columns and a partial-unique active grant.consents.source_form_idis already reserved in000008_consents.up.sql, withCHECK ((source = 'form') = (source_form_id IS NOT NULL))and a comment recording that the FK lights up when this feature ships theformstable. F3 adds the FK; it does not add the column.- Consent purposes are codes in
consent_purposes+consent_purpose_versions, not free strings.hipaa_noticeis not a purpose the platform registers — see features.md → F3.5.1 for the Tier B purpose set. - There is no
user_id. Actors arecreated_by_principal_id/signed_by_principal_id; the subject ispatient_profile_id. - PKs are UUIDs, not integers. Read
"id": 42/"custom_field_id": 10/"pdf_template_id": 5as UUIDs. - Custom-field keys are unique per org, never globally — see custom-fields/.
- A
national_id(CNP) field never lands in the generic value store. See the CNP section below. - F7 Automations and F8 Segments are out of scope, so any behaviour on this page that routes through them does not have a home yet.
See features.md → F3 and leo-port-map.md §3 → F3.
What this enables
- Clinics design reusable form templates using a shared field library — define a field once, use it across any form
- When a patient fills in a field they've answered before (city, blood type, etc.), it's pre-filled automatically from their profile
- Forms attached to appointments are generated automatically — no manual setup per booking
- Once a patient signs a form, it becomes legally immutable — no one can alter what was recorded
- Consent forms automatically create a timestamped legal consent record when signed
- Specialists can have private fields that are visible during the session but excluded from patient-facing documents
How it works
Forms have two layers:
Templates (design time) — An admin builds a form by selecting fields from the field library, setting their order, and marking some as required or specialist-only.
Instances (runtime) — When an appointment is created, the system generates a form instance from the template. The instance captures a frozen snapshot of every field's definition at that exact moment — so even if the field definition changes later, the historical record is preserved exactly as it was.
Admin designs template (references field library)
↓
Appointment booked → form instance created from template
→ fields snapshot taken at current versions
→ patient's existing profile values auto-filled
↓
Patient fills form (in_progress)
→ saving a field also updates their profile (auto-fill loop)
↓
Patient submits (completed)
↓
Patient signs (signed) — IMMUTABLE from this point
→ if it's a consent form, consent record created automaticallyConsent forms
When a form declaring consent purposes is signed, the platform inserts one row per declared purpose into the shipped consents ledger, in the same transaction as the signature:
purpose_code+purpose_version— pinned from the template's declaration, so the grant records exactly which version of the legal text was agreed togranted_by_principal_id,granted_via_ip, timestampsource = 'form'+source_form_id— the FK back to the signed instance
The form is the canonical legal content; the ledger row is the queryable index pointing at it. Withdrawal never mutates the signed form (it is immutable) — it sets withdrawn_at + withdrawn_by_principal_id on the ledger row, leaving the form as the audit artefact of the original grant.
Nothing may write a consent by any other path. In the legacy system a patient's consent signature is publishedAt = now() set by an ordinary PUT — no signer, no IP, no user agent, no version pin, no hash, and the same PUT can unset it. The ledger is the whole answer to that.
Consent is per-clinic. Consent granted at Clinic A does not extend to Clinic B, ever.
Private fields
Specialists often need to record clinical observations that shouldn't appear in the patient's copy of the document. Fields marked private: true are:
- Visible to both patient and specialist during form filling
- Excluded from patient-facing PDFs and documents
- Always visible to admin and specialist exports
Technical Reference
Everything below is intended for developers.
Architecture
Templates reference custom fields by ID — they don't duplicate field schemas. This is the key architectural decision:
- Field changes (new options, updated label) propagate automatically to all templates
- Form instances snapshot the field at the current version at creation time — immutability preserved
- One-off fields (
custom_field_id: null) are supported for form-specific fields that shouldn't sync to profile
The snapshot is the load-bearing part, and it is a two-step contract, not a pointer:
- At instance creation, the resolver copies field definitions (from
custom_fieldsat their current published version) and any known answers (fromcustom_field_valuesviacustom_field_id, and frompatient_profilesviaprofile_field_key) intoforms.fieldsandforms.values, and stampsforms.template_version. - On save, the instance's own values are written, and a separate, separately audited write-back updates the profile / value store.
Never one step. The legacy system routes form answers through a middleware that silently redirects them into a shared user-scoped value store, so answering a question in one form rewrites the answer in every other form the patient ever filled — and no historical instance is stable. The two-step copy makes historical instances immutable by construction.
Table shape
forms carries: id, organization_id NOT NULL, patient_profile_id, appointment_id NULL, form_template_id, fields JSONB (snapshot), template_version INT, values JSONB (GIN-indexed), files JSONB, status enum, completed_at, signed_at, deleted_at, created_by_principal_id, signed_by_principal_id.
Soft delete only; no DELETE RLS policy. (The legacy system hard-deletes forms, their values, the generated report and patient uploads, in a loop, outside a transaction.)
Template structure
{
"id": 42,
"title": "Patient Intake Survey",
"type": "survey",
"category": "first_appointment",
"pdf_template_id": 5,
"consent_types": [],
"version": 1,
"published": true,
"fields": [
{ "custom_field_id": 10, "sort_order": 1, "required": true },
{ "custom_field_id": 11, "sort_order": 2, "private": true },
{
"custom_field_id": null,
"key": "chief_complaint",
"type": "textarea",
"label": "What brings you in today?",
"sort_order": 3,
"required": true
}
]
}Template types & categories
| Type | Use Case |
|---|---|
disclaimer | Consent forms — signing inserts rows into the consents ledger |
survey | Patient questionnaires, feedback |
parameters | Clinical measurements |
report | Assessment reports |
advice | Care recommendations |
prescription | Medication prescriptions |
| Category | When generated |
|---|---|
new_patient | First-time registration |
first_appointment | First consultation |
new_appointment | Recurring appointments |
Slot cardinality — business rules from the live clinic
The type doubles as the slot in offering_forms / calendar_forms, and the slots have real cardinality rules that no spec on either side had written down:
disclaimer— multiple per appointmentsurvey— multiple per appointmentparameters— singleanalysis— single, and it deliberately does not pre-create value rows. The mobility-evaluation flow writes them from measurements insteadadvice— singlereport/prescription— not attachable slots. Report is generated via a separate path (see documents/); prescription has no attach route at all
Two more that are easy to lose:
- A global form (one with
appointment_id IS NULL) gates every appointment. That is how clinic-wide disclaimers work. - Detaching a form must never delete shared profile-level values. Easy to get wrong here precisely because auto-fill copies values in.
required + private is a dead zone — reject it at publish time
Private fields are omitted from the patient's DOM, from the submitted payload, and from the required-check. So a field that is both required and private can never block a patient submit — it is silently unenforceable. Catch it at template publish time as an authoring error, not at fill time.
Patient date input is three selects, not a date picker
Deliberate, and correct for older patients on mobile. The legacy implementation hardcodes the year range to currentYear-100 … currentYear-10, which makes under-10s unrepresentable. Keep the control; drive the range from config.
Autosave contract
1s debounce + flush on blur + a dirty ref (blur with no change is a no-op) + per-field spinner + per-field error + an "add missing entry" affordance. Saves batch into one PATCH, one transaction, one coalesced audit row carrying the changed-key diff — at 20 fields × 20k patients, one audit row per field is the difference between a usable and an unusable audit table.
Field keys are immutable
Generated as {type}_{4 alnum} and never changed once assigned — PDFs and exports reference them.
Consent-body interpolation is an allow-list
Consent bodies interpolate {{patient.name}} / {{fields.<key>}}, and unresolved paths render a neutral placeholder rather than erroring. The variable namespace is a server-side allow-list derived from the classification registry — not "whatever is on the object." The legacy system interpolated a plaintext password field into rendered consent text.
CNP (national ID) — settled 2026-08-02
CNP is required on some forms and documents, not all, so it is opt-in per template, default off. It is pii_regulated, which forces:
- One home: encrypted
BYTEAviainternal/core/crypto, stored once on the patient-ownedpatient_profiles. Never duplicated per-org, never a per-form value. - Never in the generic value store. A
custom_field_values.value TEXTcolumn cannot legally hold it. A field of typenational_idroutes to the dedicated encrypted column or is rejected outright — it must not fall through to the EAV path. (The legacy system stores CNP as a plaintext value row and prints it on every report.) - Explicit egress: a data-classification.md entry with an explicit egress target for the PDF renderer, which calls
classification.AllowedForrather than hand-building the field list (P39). - Reads are permissioned and audited —
patients.view_national_idon the reveal endpoint, plus theaudit.ActionReadrow it writes.
Template versioning
On publish, a new version is created. Existing form instances are unaffected — they hold a snapshot of the version at creation time. New form instances use the latest published version.
Signature methods
Forms support two signature methods, configured per template:
| Method | Field Type | Storage | Use Case |
|---|---|---|---|
| Drawn signature | signature (canvas) | PNG image uploaded to S3, referenced in files JSONB | Clinical consent forms, privacy policy, telerehab informed consent — anything with legal weight |
| Checkbox confirmation | checkbox (required) | Boolean in values JSONB | Marketing opt-in, session feedback acknowledgements — lower-stakes confirmations |
Clinical consent forms (type: 'disclaimer') always use drawn signatures. The form template defines which fields are required — a consent form with a signature field cannot be signed without a drawn signature image.
In-clinic signing: The Patient Portal runs in any browser. In physical clinics, staff hand a tablet to the patient — the patient opens the form, draws their signature with a finger or stylus, and the form is signed immediately. Same flow as remote signing on a phone.
Drawn signature capture:
- Frontend renders a canvas element (touch-enabled for mobile/tablet)
- Patient draws with finger (phone), stylus (tablet), or mouse (desktop)
- Canvas is exported as PNG and uploaded via the file upload flow
- Stored in
filesJSONB under the signature field key (e.g.,consent_signature) - Embedded as base64 in generated PDFs — no external URL references
Form instance lifecycle
pending → in_progress → completed → signed (immutable)pending→in_progress: first value savedin_progress→completed: all required fields filledcompleted→signed: explicit patient confirmation (with drawn signature if required by template)- After
signed: values, fields, and files cannot be changed (API returns 409)
Auto-fill flow
- Form instance created from template
- For each field with a
custom_field_id, backend looks up existingcustom_field_valuesfor the patient - Found values are pre-filled in
form.values - When patient saves values,
custom_field_valuesare upserted for fields with acustom_field_id - Next form with the same field auto-fills from the updated profile
One-off fields (custom_field_id: null) do NOT sync to profile — they're appointment-specific.
Instance snapshot structure
{
"fields": [
{
"custom_field_id": 10,
"version": 2,
"key": "city",
"field_type": "text",
"required": true
}
],
"values": {
"field_10": "Amsterdam"
},
"files": {
"consent_signature": {
"s3_key": "org-1/forms/1024/signature/abc.png",
"size": 45000
}
}
}Multi-user filling
Both patient and specialist can write to the same form instance. Last write wins (JSONB merge). Each save is recorded in the audit log with the acting principal (audit_log.actor_id + actor_type). Field-level authorship tracking is deferred to v2.
Audience projection is server-side
What a caller is sent, what is validated, and what renders into the patient PDF are all decided by a server-side audience projection (patient | staff | admin). It is not a client-side filter on a fully-loaded payload — a private field must never reach the patient's browser at all.
Files in forms
File fields (signatures, attachments) are uploaded to S3. Access via signed URLs with 15-minute expiry.
Data storage
All form values are stored as plaintext JSONB (not app-level encrypted):
- Infrastructure encryption (AWS RDS AES-256) satisfies HIPAA at-rest requirement
- Fully queryable for segments and analytics
- The
privateflag controls document visibility, not encryption
API overview
POST /v1/form-templates Create template (draft)
GET /v1/form-templates List templates
PATCH /v1/form-templates/{id} Edit template (sets to draft)
POST /v1/form-templates/{id}/publish Publish (increments version)
GET /v1/form-templates/{id}/versions Version history
POST /v1/forms Create form instance (usually automatic)
GET /v1/forms/{id} Get form
PATCH /v1/forms/{id} Update values (fails if signed)
POST /v1/forms/{id}/sign Sign (immutable from here)
POST /v1/forms/{id}/files Upload file to form field
GET /v1/forms/{id}/files/{key} Get signed URL for fileImmutability enforcement
Once status = 'signed', every mutation returns 409 Conflict, enforced at the handler layer AND the service layer — not one or the other. form_template_versions and custom_field_versions get no UPDATE or DELETE policies at all.
Audit
All mutations (form.create, form.update, form.sign) are logged with resource_type = "form". Field names are logged — never field values — to ensure no patient data in the audit trail.
Events
Cat E internal events published on the shipped events.Bus: form.created, form.completed, form.signed, form_template.published.
Open decisions
is_requiredenforcement point — per-field on save (breaks autosave) or at thepending → completedtransition (allows an indefinitein_progresswith gaps, which matters for the patient wall's "is this done" query).- Historical form import fidelity — legacy forms have no snapshots. Import with the template's current state (accepting that history was already rewritten), or as read-only "legacy answers" with a synthesized snapshot and a visible provenance marker? The GDPR / medical-record posture differs sharply. A Phase 3 migration decision, but this feature's schema must not foreclose either.