Skip to content

Patient Activity

Status: built 2026-08-21. No migration of its own — the column lives in 000001 with audit_log, and the policy arm in 000002 with audit_select.

What happened to a patient, in one chronology — both what they did and what the clinic did to them.

Do we log everything?

Mutations: yes. Reads: no, deliberately.

Every state-changing action writes an audit_log row carrying actor, action, entity, IP, user-agent, field-level before/after and request id — around fifty entity types. Patient-side actions are included: consents granted and withdrawn, session runs started and ended, feedback submitted, forms filled in.

Reads are not recorded. There is exactly one audit.ActionRead call site in the platform — the CNP reveal endpoint. So this feature cannot answer "who has looked at this patient's file", and the empty state says so in as many words rather than letting a reader assume the record is complete. The deferred general-access-log design lives in F11.5.

Why a column and not a query

audit_log has always answered "who did what to which row". "What happened to this patient" is a different question, and the existing columns cannot answer it: a patient's history is scattered across a dozen entity types with a dozen different ids — their consents, forms, runs, appointments, prescriptions, documents. Answering it by collecting every id they own first is a fan-out that grows with every feature and is silently wrong the moment somebody adds a table and forgets.

actor_id answers only the half they did themselves — nothing staff did to them, and nothing at all for a patient with no login, which is most dependents.

So the subject gets its own column, written once at write time: audit_log.patient_profile_id, with a partial index (organization_id, patient_profile_id, created_at DESC).

The portable id, not the per-org one

It stores patient_profiles.id, not patients.id. A patient who leaves a clinic and returns gets a fresh patients row — the register number survives, the row does not. Keyed on the org-scoped id, their history would split in two at exactly the moment a clinic is most likely to be reading it.

Cross-clinic leakage is not a risk this opens: the row still carries organization_id, and audit_select scopes every read to the caller's org.

How the subject gets written

A middleware, not fifty call sites.

middleware.AttachPatientSubject (and its …FromAppointment / …FromRun siblings) resolves the patient from the URL once and stamps the request context; the recorder reads it for every row written while handling that request. Mounted on:

Route groupResolves from
/v1/organizations/{id}/patients/{patientId}/*patients.id
/v1/patients/{patientId}/protocols/*patients.id
/v1/organizations/{id}/appointments/{appointmentId}/*appointments
/v1/me/appointments/{appointmentId}/*appointments
/v1/session-runs/{runId}/*session_runs
/v1/me/* (the whole patient-self tier)patientscope.ResolveSubject

The last row is the one that was missed first. /v1/me/… names nobody — that is what "me" means — so everything a patient does in the Portal landed with a NULL subject: granting a consent, updating their own details, adding a family member, self-enrolling. Which is to say the half of the feed that is actually the PATIENT's activity was empty. It surfaced within the hour of shipping, because a NULL subject is invisible: nothing fails, nothing logs, the feed is simply short.

Which person "me" means depends on the family switcher, so the middleware asks patientscope rather than growing a fifth copy of that ordering. The rules it inherits matter:

  • Owning beats caring for. An action taken with no switcher selection is the caller's own, not their child's.
  • A selection moves the subject. A parent who switches to a child and grants a consent has acted for the child, and the row belongs on the child's history — filing it under the parent would put a consent on the record of somebody who never gave it.
  • A stale selection falls back rather than blanking. An unattributed audit row is worse than one attributed to whoever actually made the request.
  • A caregiver who is not a patient resolves to their dependent — a real state, and the only history those rows belong to.

It runs on mutations only, returning immediately for GET: audit rows are written by mutations, and /v1/me is the Portal's hottest read.

Stamping at each audit.Record call would mean threading a patient id through services with no other reason to know about auditing — and the sites that got missed would fail invisibly, because a NULL subject is indistinguishable from a row that legitimately concerns nobody.

Handlers whose route names no patient set Event.PatientProfileID explicitly, and that override always wins. Creating a session run is the case that needs it: the run is what the call creates, so there is nothing in the URL to resolve from — and "started a session" is the most common entry in any patient's history.

It resolves, it never rejects. A URL naming a patient the clinic does not have simply does not stamp; the handler returns its own 404 a moment later. Failing there would turn an auditing concern into a request outcome.

Who can read it

patients.viewnot audit_log.view_org.

The latter is admin-only in the system role templates, so gating on it would hide a patient's timeline from the specialist treating them, which is the reader this exists for. 000001 therefore adds one arm to audit_select:

You may see what happened to a patient you may see.

It applies only to rows naming a patient as their subject. Everything else on the table — role grants, org settings, catalog edits — stays behind audit_log.view_org exactly as before. The people who gain access are admin, specialist and customer_support: precisely those who can already read that patient's forms, consents, appointments and documents through their own tabs. What they gain is the order those things happened in, which no single tab can show.

What the feed does not carry

GET /v1/organizations/{id}/patients/{patientId}/activity returns a narrower projection than the org audit log.

  • No changes payload. An audit row's before/after can carry the content of what changed — the answers on a form, the text of a note — and that content sits behind its own permissions (forms.view_org, documents.view_org). Shipping the diff here would route around all of them. The feed says what happened, when, and which side did it; the reader opens the relevant tab to see what it said.
  • No request forensics. IP, user-agent, path and method describe the request, not the event. The org audit log still has them, still behind audit_log.view_org.
  • Plus one field the audit log does not have: by_patient. Both a patient and a receptionist are actor_type: human, so the type cannot answer the question every reader has first. It is resolved server-side — the client has no business being told the patient's principal id just to compare.

Known limits, recorded rather than hidden

  • Only as complete as the column is old. Nothing backfills patient_profile_id in a database that predates it, because the information needed to do it is the fan-out the column exists to avoid. The tab shows history from 000001 onward; older rows are still in the org audit log, they simply do not name their subject.
  • Not an access log. See above. This is the limit most likely to be misread, which is why the empty state states it.
  • A caregiver link lands on the ACTOR, not the dependent. Adding a family member is filed under the account that did it. The new person is named in entity_id, but "added to X's account" does not appear as the first entry of their own history — one row carries one subject, and the actor is the more general rule. Worth a second row if that origin entry turns out to matter.
  • Unstamped domains still exist. Anything writing an audit row outside the route groups listed above lands with a NULL subject and is missing from the feed. Adding a domain means mounting the middleware or setting the field — and because the failure is silent, a new patient-facing route group should be checked against this list.