Who opened which patient record, and a log the database refuses to edit
Most clinic software keeps a log. Few say what that log is worth on the day somebody wants a line out of it. In Elaior the answer is not a policy and not a permission: the trail is one Postgres table, and the database itself refuses to change a row in it.
What the database actually refuses
RAISE EXCEPTION 'AuditLog is append-only';
CREATE TRIGGER audit_log_append_only
BEFORE UPDATE OR DELETE ON "AuditLog"
FOR EACH ROW EXECUTE FUNCTION forbid_audit_log_mutation();A trigger rather than a permission, and the migration says why: "on a managed Postgres the application connects as the TABLE OWNER, so REVOKE is toothless and a trigger is the only thing that holds for every role". So the refusal binds the application's own privileged connection, the one every handler holds.
One exemption exists, and in code that ships in the server binary the demo wipe is its only caller: a session setting scoped to a single transaction, so it dies with that transaction on a commit and on a rollback alike. Turning the trigger off at table level was rejected in writing, because that is a global switch: while it is off every other connection can delete too, and a wipe that dies partway leaves the protection off with nothing to notice.
Database, guarded write, or display only
Three different strengths of promise, and only the first rows are a guarantee the database keeps on its own.
What a row carries
- The columns are id, clinic, actor, action, entity type, entity id, a JSON meta object, a timestamp, an IP address and a user agent. Entity type and entity id are empty strings, never NULL, when an action names no entity.
- Three actor populations: a staff user id, a distinct prefixed form for a patient acting through the portal, and the empty string, which the feed renders as System.
- Of 169 registered actions, 166 set SkipAudit, which leaves the domain to file its own richer row where it files one at all. Three are audited by the request pipeline itself: patient.update, patient.resetPortalPin and clinic.clearLogo.
- Over a hundred distinct named kinds of event reach the table from code.
- A before value, a snapshot of the record as it was first, is filed on exactly six write actions: patient.update, rx.edit, staff.update, invoice.void, invoice.discount, and the update and revive arms of service.upsert. Everywhere else meta carries what was asked for, not a diff.
Opening one named record is an event too
Most audit trails record writes. The line here is drawn at opening or downloading one named record. Nine read events are filed from ten hook sites: the print routes for a prescription, an invoice, a bill and a register; the blob routes for an attachment, a vault document and the clinic logo; the portal's own two document reads; and patient.history.
What this mechanism does not cover
This section is the reason to believe the rest. Every item is in the code, or is an absence in it.
- The protection is a row trigger on UPDATE and DELETE. It never sees TRUNCATE, and the repository has no TRUNCATE guard. Nor does it stop dropping the trigger, disabling it, dropping the table, or a restore from a doctored dump, and the application connects as the table owner, the role that could do all of those. The honest claim is narrower than nobody can delete it: nothing in the application can edit or delete a row.
- Nothing in the running server checks that the migration was applied. Boot runs four completeness assertions, none of which looks at the trigger, and the binary runs no migrations. A dropped or never created trigger would leave the service starting normally and writing into an unprotected table. A test proves it, not startup.
- Append only is not gap free. Every audit write swallows its own failure and logs it rather than returning an error: the action already succeeded, and turning a bookkeeping problem into a failed consult would be worse. A row can be lost while the action commits.
- Some mutating actions file nothing: the bulk medicine catalogue import writes no row, and neither do formTemplate.use, ai.recordUsage or assistant.chat.
- Refusals are recorded on four surfaces only, the action dispatcher, the print URLs, the file URLs and the exports. A 403 elsewhere files nothing, and a 401 and a 400 file nothing anywhere: a 401 is what every expired cookie looks like, a 400 is a mistyped form.
- Failed staff sign ins are recorded only when the identifier resolves to a real user holding a seat, so the trail cannot be read as a directory of which identifiers exist, and the identifier itself is never stored. A failed patient portal PIN entry writes no row at all.
- List views, searches and the queue board are not audited, by explicit decision. Who opened this record is answerable for the nine named reads. Who ran a search that returned this patient is not answerable at all.
- Even audited reads undercount: four looks at one record by one person inside fifteen minutes are a single row. The throttle fails open, so a blip yields a duplicate rather than a hole. A CSV export files one row for the whole export, not one per patient.
- The before value is best effort. The prescription edit snapshot runs outside the edit's transaction and swallows its failure, so an amendment can commit without one, and it carries only the shape of the edit form.
- The most sensitive free text stays out on purpose. A patient note edit files the character count of the new note, never the text, because nothing could remove a sentence afterwards. The patient update snapshot covers demographic columns and excludes allergies and chronic conditions.
- IP and user agent are NULL for rows written before migration 20260815120000 and for anything written outside an HTTP request. No backfill was done, because inventing a value would file a guess in the one table meant to hold things that are not guesses.
- A row cannot be taken back out. Prior demographics are in there by design, the trigger refuses deletes, and nothing in the repository prunes, archives or expires the table.
None of that is comfortable to publish for a product with no customers yet. It is here because a mechanism is worth something only when its edges are written beside it.