Skip to content

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_consents does not exist. The shipped table is consents (foundation 1B.9) — an append-on-grant ledger with purpose_version, granted_by_principal_id, granted_via_ip, source, withdrawal columns and a partial-unique active grant. consents.source_form_id is already reserved in 000008_consents.up.sql, with CHECK ((source = 'form') = (source_form_id IS NOT NULL)) and a comment recording that the FK lights up when this feature ships the forms table. F3 adds the FK; it does not add the column.
  • Consent purposes are codes in consent_purposes + consent_purpose_versions, not free strings. hipaa_notice is not a purpose the platform registers — see features.md → F3.5.1 for the Tier B purpose set.
  • There is no user_id. Actors are created_by_principal_id / signed_by_principal_id; the subject is patient_profile_id.
  • PKs are UUIDs, not integers. Read "id": 42 / "custom_field_id": 10 / "pdf_template_id": 5 as 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 automatically

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 to
  • granted_by_principal_id, granted_via_ip, timestamp
  • source = '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:

  1. At instance creation, the resolver copies field definitions (from custom_fields at their current published version) and any known answers (from custom_field_values via custom_field_id, and from patient_profiles via profile_field_key) into forms.fields and forms.values, and stamps forms.template_version.
  2. 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 ​

json
{
  "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 ​

TypeUse Case
disclaimerConsent forms — signing inserts rows into the consents ledger
surveyPatient questionnaires, feedback
parametersClinical measurements
reportAssessment reports
adviceCare recommendations
prescriptionMedication prescriptions
CategoryWhen generated
new_patientFirst-time registration
first_appointmentFirst consultation
new_appointmentRecurring 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 appointment
  • survey — multiple per appointment
  • parameters — single
  • analysis — single, and it deliberately does not pre-create value rows. The mobility-evaluation flow writes them from measurements instead
  • advice — single
  • report / 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 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 BYTEA via internal/core/crypto, stored once on the patient-owned patient_profiles. Never duplicated per-org, never a per-form value.
  • Never in the generic value store. A custom_field_values.value TEXT column cannot legally hold it. A field of type national_id routes 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.AllowedFor rather than hand-building the field list (P39).
  • Reads are permissioned and audited — patients.view_national_id on the reveal endpoint, plus the audit.ActionRead row 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:

MethodField TypeStorageUse Case
Drawn signaturesignature (canvas)PNG image uploaded to S3, referenced in files JSONBClinical consent forms, privacy policy, telerehab informed consent — anything with legal weight
Checkbox confirmationcheckbox (required)Boolean in values JSONBMarketing 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 files JSONB 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 saved
  • in_progress → completed: all required fields filled
  • completed → 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 ​

  1. Form instance created from template
  2. For each field with a custom_field_id, backend looks up existing custom_field_values for the patient
  3. Found values are pre-filled in form.values
  4. When patient saves values, custom_field_values are upserted for fields with a custom_field_id
  5. 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 ​

json
{
  "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 private flag 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 file

Immutability 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_required enforcement point — per-field on save (breaks autosave) or at the pending → completed transition (allows an indefinite in_progress with 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.