Consents and agreements — the corrected model
Status: BUILT 2026-08-16/17, repo-only — FOLDED 2026-08-17 into 000043 and 000044, applied to no environment. D1–D5 are in, and so are the Portal and Clinic surfaces that make them reachable. F3.5.3 closed 2026-08-17: the patient with no login can fill and sign — a tablet at reception or a one-time link — through the same renderer a logged-in patient uses. What that took beyond the UI, including a drawn_kiosk that could not sign, is in §4 item 7. The telerehab gate landed 2026-08-17 (§4 item 8) — until then the purpose was enforced nowhere, so a patient who withdrew kept playing the program the Portal had just told them would stop.
A GAP UNDER ALL OF THIS IS OPEN (2026-08-19). The corrections below are sound and shipped, but they sit on a seam: a form-declared consent is modelled twice — as a ledger switch and as a signed document — with two version clocks, two enforcement paths and two authored texts. The owner's call is to reshape it rather than keep bridging it. Decisions, symptoms and constraints are in consents-and-forms-gap.md. Settle it before
000040–000050promote, while the schema is still editable.
Production holds a demo tenant only, and the owner has ruled that the existing catalog rows must not shape the design. Where a change needs a data migration, plan one — do not bend the model to avoid it.
Five decisions changed while building. Read these before the body.
The body below is the original brief. It is accurate except where this table says otherwise, and each of these was settled with the owner during the build.
| Decision | The brief says | What was built, and why |
|---|---|---|
| Which purpose inherits the disclaimer | D2 sends §1/2/3/5 to telemedicine | telerehab. telemedicine is a remote consultation; telerehab is the video protocols a patient works through alone. A clinic may offer either without the other. |
| What "retire" means | D2 leaves it OPEN — retired_at or a data migration | Deleted outright, rows and all. retired_at would be a column, two guard checks and an error code carrying a case that does not exist before launch. When a purpose must retire against real patient history it is a one-column migration, added then. |
| Who authors clinical consent text | implied: the 1B.10 editor | The clinic, on its own form. The editor holds terms and privacy_notice only — documents where the platform supplies a legally-structured frame. The clinic is the data controller; a platform authoring its clinical consent text would shape the controller's processing purposes, which is Art. 26 drift. |
| How a form declares a purpose | D4 lists "signable" as an independent property | Derived, not stored. blocks_access → embedded (form renders the canonical body); everything else → authored (the clinic's own words). Given the row above, the derivation is total and a column would carry no information. |
| D5's trigger shape and satisfaction | OPEN shape; "once per patient per template version" as a universal | A sibling table, form_triggers, leaving offering_forms / calendar_forms untouched. The universal rule is false — appointment paperwork satisfies per appointment and the safety acknowledgement per run, deliberately. It holds in form_triggers because nothing appointment- or run-scoped belongs there. |
Two things the brief could not have known, both found by tests against a real database rather than by review:
- Firing a trigger at the moment of the event covers a minority of patients. Portal signup, the legacy claim and adding a dependent all create the
patientsrow on an admin transaction — the patient has no RLS context yet, because they are being created. So the obligation is resolved on the patient's own form wall, which is the shapeRequireCurrentConsentsalready uses: it does not chase creation paths, it computes what is owed per request. An event becomes a condition when evaluated on a read, and the two are not the same statement. - Swallowing an error inside a transaction does not undo it. A failed statement aborts the whole Postgres transaction, so "log it and carry on" turned a clinic with a broken trigger into a patient whose form wall returned a 500. The resolution runs in a savepoint.
1. The mental model
Two artifacts, and the axis between them is not how serious the document is. org_terms is legal_basis = 'contract' and lives in the ledger; a dry-needling agreement is arguably more medically serious and is a form. Seriousness sorts nothing.
The axis is: does someone have to ask "is this still true?" before acting?
- Yes → a consent. A row in the ledger. It has a current position, it can be withdrawn, and code (or a person) checks it before doing something.
- No → a signed document. A form. It is history: this person, this text, this date, this signature. It never changes; you add another rather than altering one.
A signature is history. A consent is a switch. Signatures accumulate. Switches have a current position.
They are not alternatives. A form can be the act that flips a switch — the signed form goes in the file and the ledger row records the new position, with consents.source_form_id pointing back at the form as its evidence. One event, two records, each answering a different question.
This is already the rule of record for whether something earns a ledger row at all — see decisions.md and the struck treatment_specific_* purposes. Nothing here reopens that. What follows corrects how the ledger models the consequence of a consent.
2. What is wrong today
2.1 The schema expresses two tiers; the product has three
The only thing distinguishing a purpose's consequence is legal_basis:
legal_basis | Effect |
|---|---|
anything but consent | RequireCurrentConsents blocks every gated route |
consent | blocks nothing, ever |
So every optional purpose is modelled as equally optional — but they are not:
| Withdrawing it means | Purposes |
|---|---|
| A capability is gone | telemedicine, biometric_capture, telerehab |
| A preference changed | marketing_email, marketing_sms, analytics, ai_processing, video_recording |
Nothing records which is which. The knowledge is scattered across whichever service happens to check: F5.6 gates the video room on telemedicine in the service layer, the pose preview checks biometric_capture, the session gate checks telerehab. middleware.RequireConsent(code) was built for exactly this and has no production caller.
The patient-visible consequence: withdrawing telemedicine gives no warning that the next booking will fail. On screen it is indistinguishable from unticking a marketing box.
2.2 GDPR basis and enforcement are welded together
legal_basis is a GDPR field and must stay legally accurate. It is currently doing a second job — deciding what blocks — and the two are not the same question. That conflation is the root of everything in §2.1.
2.3 DeclarableOnForms is the exact complement of the re-consent gate
current_required_consent_versions → filters legal_basis <> 'consent'
DeclarableOnForms → requires legal_basis = 'consent'So anything signable on a form is, by construction, excluded from the re-consent gate, and anything that blocks access can only ever be a tick-box. Three independent properties — is it signed, does it block, is it re-accepted on a version bump — were welded into one flag.
This is why the clinic's terms are a checkbox and why the only way to record an acceptance for a patient with no login is staff typing it in.
2.4 org_service_disclaimer is a composite of three different artifacts
Mapping the live document (v4) against what already exists:
| Section | Content | Already lives in |
|---|---|---|
| 1, 3 | What the services are | telemedicine — "how a telerehabilitation session is delivered" |
| 2 | The Plan is a recommendation, no clinical exam | telemedicine — "what it can and cannot assess" |
| 5 | No guaranteed outcome | telemedicine, again |
| 4 | Unsupervised home exercise, stop on pain | session_runs.safety_acknowledged_at + safety/v1.md, per run |
| 6 | Adults only; minors need a legal representative | the age rule + representative attestation (built 2026-08-15) |
| 1.1 | RestartiX is an intermediary; limitation of liability | nowhere — and it is platform, not per-clinic |
The seeded descriptions give the duplication away on their own:
org_service_disclaimer— …the limits of remote care, and the patient's informed, voluntary participation…telemedicine— …what it can and cannot assess, and the patient's informed agreement to be treated at a distance.
In practice the patient meets the same statement twice: at signup, and again before a video appointment.
2.5 ⚠️ Two safety texts that give opposite instructions
A defect independent of everything else here, and worth fixing first because it is live:
- Disclaimer §4 — stop immediately on breathing difficulty, dizziness, weakness or acute pain, and seek medical advice.
apps/portal/content/safety/v1.md, shown before every session — ZERO DURERE; reduce the range or the reps, and if pain persists move to the next exercise.
One says stop and see a doctor; the other says carry on. Both are shown to the same patient.
For MDR Class I traceability the per-run acknowledgement is the artifact that counts — a signature at signup establishes nothing about this session — so §4 in a signup document adds no evidence and creates a second wording that can drift. It already has.
3. Decisions
D1 — consent_purposes gains an enforcement column
Three values, covering all thirteen purposes and replacing the legal_basis overload:
enforcement | Meaning | Purposes |
|---|---|---|
blocks_access | Not accepted → the patient cannot use the platform at this clinic | platform_terms, platform_privacy_notice, org_terms, org_privacy_notice |
gates_feature | Not accepted → one capability is unavailable; everything else works | telemedicine, telerehab, biometric_capture, video_recording |
preference | Not accepted → nothing the patient would notice is withheld | marketing_email, marketing_sms, analytics, ai_processing, video_recording |
legal_basis stays and stays legally accurate. It stops deciding enforcement. current_required_consent_versions keys on enforcement = 'blocks_access' instead of legal_basis <> 'consent'.
Why a column rather than a Go map: four apps need to know, and the portal has to warn before withdrawing a gates_feature consent without every surface carrying the list. It is one TEXT with a CHECK — a third value would be a code change by definition, which is the test document_categories.filled_by already passes.
D2 — org_service_disclaimer retires
Its content redistributes:
- §1, 2, 3, 5 →
telemedicine, whose description already covers them. - §4 → deleted. The per-run safety acknowledgement is the record. Fix §2.5.
- §6 → not a consent. Minors-need-a-legal-representative is a rule the platform now enforces; nobody agrees to it. It belongs in the terms as a statement of fact.
- §1.1 → platform scope. It limits RestartiX's liability, so a clinic must not be authoring it. Either into
platform_termsor its own platform purpose.
OPEN — what "retire" means for existing rows. The ledger is append-only with six-year retention, so deleting consents rows conflicts with that; deleting only the consent_purposes row orphans them. Options: a retired_at column on the purpose (rows survive, nothing requires it), or a data migration that rewrites history. Decide before writing the migration.
D3 — telerehab replaces it as a gates_feature consent
"I understand that exercises are performed alone, at home, unsupervised, and I accept that." Withdrawn → no telerehab prescription; in-person care is unaffected.
The argument that settles it is the product's own scope: telerehab is an org-level capability that can be disabled, and the platform serves physical-only clinics. Today every patient of every clinic must accept a document describing remote consultations and home exercise before doing anything — including patients of clinics that offer none of it. As a feature gate that disappears by construction.
D4 — the three welded properties come apart
A purpose gains, independently:
- signable on a form — no longer implied by
legal_basis = 'consent' - blocks access — now
enforcement - re-accepted on a version bump — follows
blocks_access, not the basis
For a blocks_access purpose, the form EMBEDS the canonical consent_purpose_versions text rather than replacing it. The clinic still authors that text through the 1B.10 editor; the form renders it and captures a signature. That is what kills the "a clinic writes its own contract" objection which DeclarableOnForms currently prevents by brute force.
Consequences worth having: the clinic's terms become a signed document instead of a tick-box, source_form_id points at a form containing the full text, and publishing v5 still fires the 412 re-consent gate.
D5 — form triggers become a first-class concept
What exists today, verified — do not re-derive it:
- Templates attach to an offering (
offering_forms) or a calendar (calendar_forms). document_categories.filled_by∈{patient, staff}decides when a form materialises:patient→ at booking,staff→ at consultation start.
So two trigger points exist, and both are anchored on an appointment. What D3 needs — "asked once, before the first program starts" — has no anchor, because a prescription is not a booking.
The work is to make the trigger an attribute rather than an implication of the attachment point. Sketch, not a design:
| Trigger | Fires when | Needed by |
|---|---|---|
booking | an appointment is booked | today's filled_by = 'patient' |
consultation_start | the consultation opens | today's filled_by = 'staff' |
patient_onboarded | a patient joins the clinic | the terms / privacy notice as signed documents (D4) |
program_start | a protocol activates | telerehab |
OPEN — the shape. Whether this generalises offering_forms into a polymorphic attachment, or adds a sibling table keyed on an event, is for the building session. Two constraints that are not preferences: a trigger must fire once per patient per template version (not once per program, or a patient re-signs on every prescription), and a form that has already been signed at the current version must not re-materialise.
4. Build order
§2.5— dropped. The contradicting §4 lived only in alegal_document_templatesrow, not in the repo, and the whole disclaimer is now deleted. It resolves when the clinic writes itstelerehabtext.D1— BUILT (000044). Theenforcementcolumn, andrequired_consent_versions_for_profilerepointed at it. Behaviour-neutral on landing, by design.D4— BUILT. Signability keys on scope alone;blocks_accesspurposes declare inembeddedmode, where the form renders the canonical body and pins the version it displayed. A signature against superseded text is refused withversion_movedrather than recorded.D5— BUILT (000043).form_triggers+forms.trigger_event, fired eagerly where an RLS context exists and resolved on the patient's wall everywhere else.D3 + D2— BUILT (000044).telerehabin,org_service_disclaimerdeleted, the editor down to two document types.Portal + Clinic surfaces— BUILT, except the last item.Seeded starter form templates per org— BUILT, then DELETED 2026-08-17 on the owner's call, and it was the right one. It seeded every clinic atelerehabagreement plus aprogram_starttrigger, unpublished. Three things were wrong with it: the platform was writing text in the clinic's voice — placeholder prose is still prose, and the settled rule is that clinical consent text is the controller's own words; the text was hardcoded Romanian with no translations envelope; and it leaned on alphabetical trigger-name ordering againstorganizations_seed_document_categories, which its own comment admitted. It delivered no behaviour either, being inert by design. The clinic now builds its agreement in the form builder, which is where the onboarding-checklist item below points.Clinic UI for form triggers—/forms/triggers, grouped by event, showing "not live" for a trigger on an unpublished template.Warn before withdrawing a— names the capability being given up. Agates_featureconsentpreferencewithdrawal is deliberately NOT interrupted; a confirmation on every toggle is how people learn to dismiss confirmations.Declaration mode in the form builder— no tie control for an access-blocking purpose, and the reason in its place.— the patient's consent list reaches the signed document.source_form_idlink
F3.5.3— BUILT, repo-only, end to end. The patient with no login can now fill and sign.drawn_kiosk+remote_linkBuilt with
000043:form_sessions, token-authorised access to ONE form. Mint / resolve / revoke / save / complete / sign. Only the SHA-256 is stored (BYTEA — anauth_secret_hashcolumn must be, and the classification guard enforces it). RLS is the enforcement: a token-authorised request has no principal, so it satisfies no existing policy branch and the session sets its own transaction-scoped variable instead. Reaching for the admin pool here would have been the shortcut and the wrong one.Built on top of it, in the order it was done:
FormFillertakes an injectable action bundle (FormActions— save, complete, sign, upload, deleteFile), defaulting to the patient's own.patientFormActionsandsessionFormActionsare files of URLs; there is still exactly ONE renderer, so the autosave contract, the required-field pass, the embedded bodies and the signature step cannot diverge between a logged-in patient and a tablet at reception. Save is a route handler on both sides — an action re-renders its route, which cost 538 API calls to fill one form on the clinic panel.apps/portal/app/f/[token]/, outside the(patient)group, plus/api/public-form-sessions/[token]/values. The route handler is namedpublic-*on purpose: the proxy treats that prefix as anonymous by construction, which is the rule that exists because two public pages shipped with auth-gated handlers."/f/(.*)"with the slash —/f(.*)would also match/formsand quietly make the patient's own wall anonymous.- Clinic "hand over the tablet" / "send the link" on each unsigned form, with the link, a QR code and a one-shot warning. The URL is built SERVER-SIDE and returned by the mint, so the link staff reads out and the link the email carries cannot be two strings; it prefers the clinic's own verified portal hostname, because a consent signed on a domain the patient has never seen is the shape of every phishing email they were taught to distrust.
- Email delivery —
form_session_link, the platform's FIRST tenant-scope notify category (the clinic talking to its own patient, metered against the clinic's plan). The mint reportsdelivery:no_addressIS the ordinary walk-in, since staff intake takes an email to RECOGNISE somebody and never stores one for somebody with no login. A failed delivery never fails the mint — that would remove the fallback along with the failure — and the address itself never crosses the wire, because "we emailed it" is an outcome, and a caller who may mint a link has not thereby asked for the address.
Four things the brief did not know, found while building it:
drawn_kioskwas unsignable. The name rule widened to every mode ("a drawn signature on a tablet still has to say whose hand it was") butresolveSignaturecollected a name forclick_typedalone, so every drawn signature died onchk_forms_signature_has_name— an error blaming the UPDATE and naming neither half. The code now follows the constraint: the drawing is the mark, the typed name is the attribution, and both are required.- A session had no way to upload. A drawn signature is a
signatureFIELD whose answer is a PNG, and the session routes carried no file endpoint — so the one thing a kiosk exists to collect had nowhere to go.POST/DELETE /v1/public/form-sessions/{token}/files/{fieldKey}close it, on the PATIENT ceiling. - The purpose catalog is an authenticated read. A token page could not fetch it, so a patient would have signed under the heading
telerehab. Labels now ride the resolve payload (consent_labels), resolved from the same catalog read that already produced the bodies. - Autosave did not fit the anti-enumeration rate limit. The write routes now carry their own
form_sessionpolicy: a 1s debounce plus a flush per blur is tens of requests a minute per patient, and a reception desk puts several tablets behind ONE public IP — under public_resolve's 30/min those patients 429 mid-form. What stands between a stranger and someone else's form is a 256-bit token, not a request cap. - The builder offered one mode.
SIGNATURE_MODESin the clinic form builder still read["click_typed"], with a comment saying widening it takes "a schema change plus a constant here" — the schema had done the schema half months before the constant followed, so no clinic could configure the feature that shipped. Adjacent, and fixed in the same pass: the consultation panel decided whether to offer staff a Sign button offsignature_mode(the EVIDENCE column, null until somebody signs) rather thanrequired_signature_mode(the declaration), so it offered the button on every completed form and 400'd on the ones that belong to the patient. Its comment had described the intent correctly all along; the column under it had not, and the test agreed with the column.
Three traps that were carried into the work, all found the hard way:
INSERT ... RETURNINGrequires the new row to pass the SELECT policy, not just the WITH CHECK. Postgres reports the failure as "new row violates row-level security policy", which points at the insert and is about the returning clause. The identical insert without RETURNING succeeds, which is what makes it baffling.- A probe that runs anything before the call under test reports an echo. Every statement after a failed one in the same transaction returns "current transaction is aborted". Run the call under test first, in a clean transaction, or the answer is noise.
- Reading a form template INNER JOINs
document_categories. A session that can see the template and not its category sees nothing, and the error says "form template not found" — true about the result set, entirely misleading about the cause.
The— BUILT 2026-08-17, repo-only. Up to here the purpose was seeded, the agreement was seeded, the trigger put it on the patient's wall and the Portal warned that withdrawing would stop the program they were on — and nothing stopped it.telerehabgate itselfHasActiveOrgConsenthad exactly one caller in the platform, F5.6'stelemedicinecheck. A gate promised in copy and absent in code is worse than an ungated feature, because the clinic and the patient both believe there is one.Enforced at run creation, not at prescribe time, for the reason F5.6 checks
telemedicinewhen an appointment starts rather than when it is booked: consent is withdrawable with immediate effect, so a check run once at prescription is a snapshot of a fact that keeps changing. The run is the moment the unsupervised exercise happens. It rides the existing play gate asPlayGateReasonconsent_required→ 409, which is a client contract the Portal already speaks.Three conditions arm it, and each one is a way of not blocking somebody who cannot comply:
- A protocol owns the session. Standalone catalog play is not a prescribed program, and decisively the
program_starttrigger fires onHasActiveProtocol— so gating what the trigger cannot reach would refuse a patient with nothing to sign anywhere. - The protocol is
unsupervised.supervisedmeans a specialist is present, which is the opposite of what this consent describes and is already gated at the appointment. - The clinic publishes a template declaring the purpose. This is the load-bearing one. No agreement is seeded, so a gate that fired regardless would have taken every patient's program away on the day it shipped, platform-wide, with no form for anyone to sign. Publishing is the clinic's act of adopting the instrument and it is the switch — the same
publishedpredicateListTriggeredTemplatesjoins on, so the query that decides whether the form can appear and the query that decides whether its absence blocks anything cannot disagree.
Order is cost, not policy: the ledger read runs first because the common case is a patient who signed, and the instrument lookup only runs on the miss. Both reads are RLS-scoped and both resolve because the caller is the patient —
POST /v1/session-runscarries no permission middleware,consents_select_selfcovers their ledger andform_templates_selectadmits them to their clinic's published templates. A staff-initiated run path would need both policies re-checked: an RLS-hidden row here reads as "no consent" and refuses a patient who actually signed.All four branches are covered by
internal/test/rlstest/telerehab_consent_gate_test.goand the refusal is mutation-verified. The Portal block links to/formsrather than only naming the obstacle, because this is the one gate reason a patient can clear themselves.- A protocol owns the session. Standalone catalog play is not a prescribed program, and decisively the
STILL OPEN, both small.
- Nothing prompts a clinic to write its agreement. With the seed gone this is the only remaining item, and it is now the whole configuration story: a clinic that never builds and publishes a
telerehabform collects no consent and the gate stays off. Correct, and invisible. Wants an onboarding-checklist item or an empty-state on the forms page, not a behaviour change. - The safety contradiction is no longer re-seeded — deleting the starter took its "în ce situații trebuie să oprească exercițiul" prompt with it, which was pointing clinics straight at the text where the old disclaimer contradicted
apps/portal/content/safety/v1.md(reduce the range, move to the next exercise). Whatever a clinic writes can still contradict it; nothing in the platform invites that now.
- Nothing prompts a clinic to write its agreement. With the seed gone this is the only remaining item, and it is now the whole configuration story: a clinic that never builds and publishes a
5. Traps
- Do not model enforcement by changing
legal_basis. It is a GDPR field. A purpose that is genuinely consent-basis must keep saying so even when withdrawing it disables a feature. No longer true, and deliberately so:DeclarableOnFormsand the re-consent filter are exact complements.DeclarableOnFormskeys on scope, the re-consent filter keys onenforcement. They were the same question by accident, which is what D1 and D4 came apart to fix.- A form-declared grant only fires when its checkbox is ticked (
forms.grantConsents), and a purpose added after the form froze does not grant at all. Both are deliberate. Preserve them. org_termswithdrawal cascades — soft-deletes thepatientsrow and cancels the subscription. Anything that changes howorg_termsis accepted must leave the withdrawal path alone.The patient with no login still cannot sign anything.They can, as of 2026-08-17 — a form session, one token, one form. Two rules that survive from it: the mode is read off the TEMPLATE and never from the request (a caller that could name its own mode could satisfy a drawn-signature consent by typing a name), and every mode records the typed name beside whatever mark was made, because a signature that says an act happened but not whose is the defect the rule exists against.
6. Related
- decisions.md — why a purpose earns a ledger row
- platform-completion.md — the adult-with-no-email work this follows from
000008_consents.up.sql,000043_forms.up.sql,000044_consent_forms.up.sql