Skip to content

Consents and forms — one thing modelled twice

Status: DECIDED AND BUILT, 2026-08-19. This document recorded the gap; it now records the decisions taken and where they landed. The schema change is folded into 000043/000044, which are unapplied on production — so it went in as an edit plus a staging rebuild, not a catch-up script.

Written after a session that fixed four symptoms and found the cause underneath them. The owner's call was to stop patching and reshape the piece, which is what happened.

The decision in one paragraph

Two records stay — GDPR needs both: Art. 7(1) wants the immutable document, Art. 7(3) wants the withdrawable switch. What was wrong was not the record count but that one agreement had two catalogs, two version clocks, two authors and no link between them. The fix gives the words one owner: an instrument — a published, immutable consent_purpose_versions row for (purpose, org), which is exactly what publish_legal_document had always minted for terms and the privacy notice. Four more purposes now use it. A form declares a purpose and RENDERS its instrument; it never carries the text.

DecisionAnswer
D1 — one record or two?Two. The seam was ownership, not count.
D2 — which clock?The instrument's. The form keeps its own clock for what was ASKED; it stops deciding whether a consent is current.
D3 — what does the gate check?A live grant AT THE CURRENT VERSION. Hard stop: no exercise under an agreement the patient never accepted.
D4 — substantive vs editorial?A declared intent at publish, version vs revision. Not verified — see below.
D5 — grant without the document?No. The Portal's one-click Grant is gone for instrument-backed purposes. Withdrawal stays one click.
D6 — where does the text live?The instrument, authored by the clinic. The platform-default bodies were deleted.
D7 — work in flight?An outstanding form is exempt — it granted nothing yet, and re-issuing a copy already on the wall is noise.
D8 — withdrawal vs the document?Unchanged. Withdrawal flips the switch and never reaches back.

Why the editorial track is a declaration and not a check

The platform cannot tell a typo from a rewrite by diffing text; any threshold would be arbitrary and gameable. So intent is the clinic's own claim, and what the platform guarantees instead is that the record does not lie about it: the publish is audited with actor and before/after body, a correction appends a revision rather than rewriting the row anybody signed, and every grant pins purpose_version_id — the exact revision it was shown. A substantive change pushed through the correction path is therefore visible afterwards in the clinic's own evidence. The clinic is the data controller; that judgment is theirs to make and theirs to answer for.

This also gave org_terms a correction path it never had. Before this, a misspelled street address re-blocked a clinic's entire panel.

Two things that fell out, which were not the goal

  • FormPin supersession is deleted. It existed only to detect a version move across two unlinked clocks. With one clock, supersession is the comparison Grant always had.
  • The trigger re-asks. ListSatisfiedTemplates now un-satisfies a form whose declared instrument has moved. Without it the hard stop would be a refusal with no remedy: care stopped, nothing on the wall to sign.

What is deliberately still true

Onboarding blocks on terms + privacy_notice only. An unpublished clinical instrument is a clinic that does not offer that treatment, and nothing is withheld from its patients — the gates decline to arm, and publish validation refuses a form declaring a purpose with no instrument.


1. The gap in one paragraph

The platform has two complete models of "the patient agreed to something", built at different times for different reasons, and a form-declared consent lives in both. They version differently, enforce differently, are authored in different places, and nothing reconciles them. Every symptom below is that seam showing.

The LEDGER (1B.9)The FORM (F3)
Tableconsentsforms
Catalogconsent_purposesform_templates
Versionsconsent_purpose_versions.versionform_template_versions.version
What a version meansthe purpose's own body text movedthe clinic's document content moved
Who bumps itsuperadmin; the legal-documents editorthe clinic republishing a form
Enforcementrequired_consent_versions_for_profile → 412, blocks_access onlyListSatisfiedTemplates → the trigger asks again
Answers"is this switch on""did this person sign this document"
Authored byplatform (thin body) or the 1B.10 editorthe clinic, in the form builder

consents.source_form_id bridges them in ONE direction: a ledger row can name the document that produced it. Nothing goes the other way, and nothing keeps the two version numbers related.


2. Symptoms observed, and which are still live

Each was reproduced against a real database this session, not reasoned about.

#SymptomState
1A consent form asks nothing, so its fields was empty and the agreement text went in description — which nothing snapshots, compares or freezes. Publishing an edit was refused as already_published; no version recorded the superseded wording.FIXEDstatement blocks (4250d72a)
2Signing a REPUBLISHED agreement was a silent no-op. purpose_version had not moved, so supersession did not fire, the insert hit ON CONFLICT DO NOTHING, and the ledger kept pointing at the superseded document.FIXED — supersede on form version too (00e45b2d)
3Republishing does not gate. The patient is asked to sign the new version and can ignore it: the gate checks that a live grant EXISTS, not that it is current. They keep running telerehab sessions under terms they never accepted.FIXEDHasCurrentOrgConsent compares the version, and ListSatisfiedTemplates puts the new agreement back on the wall
4The Portal's Grant button re-grants a form-declared consent with one click and no document. source_form_id is NULL on the resulting row, so the live grant has no evidence behind it while a withdrawn one does.FIXED — removed for instrument-backed purposes; withdrawal kept
5The clinic's consents tab rendered the ledger with no current-state answer, so a withdrawn row read as the present position.FIXED — current state, then history (e7dcfc50)
6A form-declared purpose has TWO authored texts — consent_purposes.description + its consent_purpose_versions body (platform-written) and the form's statement (clinic-written) — describing one agreement, with nothing keeping them consistent.FIXED — the platform-default bodies are deleted; the clinic authors the one instrument
7Generated documents omitted the agreement text: a statement carries no value, so the "omit unanswered fields" rule dropped it.FIXED (89622b16)

Symptoms 3, 4 and 6 are not independent bugs. They are the same seam seen from the gate, the grant path and the authoring surface.


3. What is already settled, and should not be relitigated

These held up under pressure this session. A redesign may move the machinery but should preserve the meaning.

  • A signature is history; a consent is a switch. Signatures accumulate and are immutable; switches have a current position. One event can produce both records. See decisions.md.
  • The clinic authors its own clinical consent text, on its own form. The 1B.10 editor holds terms and privacy_notice only. A platform authoring a controller's clinical text is Art. 26 joint-controllership drift, which is why org_service_disclaimer was retired rather than rewritten.
  • The ledger is append-on-grant. Withdrawal is the only UPDATE; there is no DELETE. consents_active_unique is partial (WHERE withdrawn_at IS NULL), which is what lets a withdrawn row coexist with a later grant.
  • Consent is per-clinic. Consent at Clinic A never extends to Clinic B.
  • A purpose earns a ledger row only if code branches on it. Clinic-defined treatment_specific_* purposes were struck for this reason; they are signed documents, not switches.
  • enforcement is separate from legal_basis. blocks_access / gates_feature / preference decide what a missing consent COSTS; legal_basis stays the GDPR Article 6 ground.

4. The decisions, as they were framed

Kept as written, because the tension in each is the reason the answer is what it is. The answers are in the table at the top of this document.

Today it is two: a forms row (the signed document) and a consents row (the switch), joined by source_form_id.

  • Keep two. The switch is queryable and withdrawable without touching an immutable document; code asks one cheap question. Cost: two things to keep in step, which is what produced symptoms 2, 3 and 4.
  • Collapse to one. The signed form IS the consent; "is it granted" becomes a query over forms. Cost: withdrawal has nowhere to live (a signed form is immutable), and every existing RequireConsent-shaped call site changes.

Everything below depends on this answer.

  • The purpose's catalog version (consent_purpose_versions)
  • The form's content version (form_template_versions)
  • One unified version — e.g. publishing a form publishes an org-scoped consent_purpose_versions row carrying the form's text

The third makes the existing 412 machinery work unchanged, at the cost of storing the agreement text twice, where the copies can drift.

D3. What does gates_feature enforcement actually check?

Currently: a live grant exists. Options: also require it to be at the current version; or require it only when the clinic marks a change as substantive.

This is symptom 3, and it is the decision with a patient-facing cost. Requiring currency means a clinic fixing a typo stops every patient's programme until each re-signs — a service interruption caused by a comma. Not requiring it means a patient continues under terms they never accepted.

D4. Substantive versus editorial — who decides, and how is it bounded?

The system cannot tell a typo from a rewrite by diffing text; only the publisher knows. If an editorial track exists it must be a DECLARED intent, and the platform can bound the claim structurally — refusing it when the consent declaration, signature mode or answerable fields changed, so only prose may be corrected silently.

Deferred twice already. D3 cannot be answered honestly without it.

The Portal renders a Grant button for any withdrawable purpose with no active grant (consents-trail.tsx). For telerehab that means re-consenting with one click, never seeing the agreement, and producing a grant with no source_form_id.

  • Remove it for purposes a published form declares, and route to the form. Cost: what if the clinic later unpublishes the form — can the patient re-grant at all?
  • Keep it, and accept that a live grant may have no document behind it.

D6. Where does the authored text live?

A form-declared purpose currently has a platform-written catalog body AND a clinic-written statement. Two texts, one agreement, no rule about which is authoritative — and the Portal shows the platform one under "Read document" while the form shows the clinic's.

D7. What happens to work in flight when a clinic republishes?

A pending form renders LIVE from the template, so its text can change under a patient mid-fill. A materialised form has frozen its own copy. Is either right, and should an outstanding form be superseded, left, or reissued?

D8. What does withdrawal do to the signed document?

Believed settled — the form is history and withdrawal does not reach back — but it has never been written down, and D1 could change it.


5. Constraints any answer has to satisfy

  • GDPR Art. 7(1) — the controller must be able to DEMONSTRATE that the patient consented. Whatever the model, "show me what this person agreed to, and when" must be answerable years later.
  • Withdrawal is immediate and per-purpose (Art. 7(3)), and must be as easy as granting.
  • Six-year retention, and consents is append-only in practice.
  • The legacy migration brings ~20k patients whose consent history was recorded by a system with none of these distinctions. Whatever shape is chosen has to accept imported history that cannot be reconstructed.
  • Per-clinic isolation (RLS) and the Art. 26 authoring boundary.
  • The gate must fail closed where it fails at all, but must not withhold care over a configuration a clinic has not completed — the reason the telerehab gate only arms when a published form declares the purpose.

6. Where the code is

  • services/api/internal/core/domain/consents/ — ledger, Grant, supersession, HasActiveOrgConsent
  • services/api/internal/core/domain/forms/ — instances, materialisation, signing, trigger resolution
  • services/api/internal/core/domain/formtemplates/ — templates, versions, differsFromVersion, statement validation
  • services/api/internal/core/domain/sessions/consentgate.go — the telerehab gate (symptom 3 lives here)
  • services/api/migrations/core/000008_consents.up.sql — ledger schema, required_consent_versions_for_profile
  • services/api/migrations/core/000043_forms.up.sql / 000044_consent_forms.up.sql — forms, statements, purposes, enforcement
  • apps/portal/components/forms/form-filler.tsx, apps/portal/app/(patient)/consents/consents-trail.tsx
  • apps/clinic/components/forms/form-builder.tsx