How we know it can’t be changed.
The homepage says nobody can quietly change a record. This page is for the person who would rather see that than hear it: the database’s actual answer when the app tries, the correction trail, and the audit chain — each with the line of code that causes it.
Not your thing? Back to the plain version.
← All registers
Methadone 1mg/ml oral solution sugar freeVMP 36120811000001107
⚠ Schedule 2 CDIn-date balance
2,660 ml
Expired
0 ml
Total received
22,500
Total supplied
18,900
Showing 50 of 175 entries
| Ref | Date | Type | Brand | Patient / supplier | In | Out | Balance | Expired | RP | |
|---|---|---|---|---|---|---|---|---|---|---|
| #176 | 11 Sept 2026, 16:05 | SupplyFP10MDA | Methadone 1mg/ml oral solution sugar free | Ibrahim Begum | — | 100 | 2,660 ml | — | Amira Siddiqui | Void / replace |
| #175 | 10 Sept 2026, 09:20 | SupplyFP10MDA | Methadone 1mg/ml oral solution sugar free | Harold Adebayo | — | 60 | 2,760 ml | — | Amira Siddiqui | Void / replace |
| #174 | 09 Sept 2026, 11:40 | SupplyFP10MDA | Methadone 1mg/ml oral solution sugar free | Linda Kowalski | — | 160 | 2,820 ml | — | Amira Siddiqui | Void / replace |
| #173 | 09 Sept 2026, 10:05 | SupplyFP10MDA | Methadone 1mg/ml oral solution sugar free | Ibrahim Begum | — | 100 | 2,980 ml | — | Tom Bradley | Void / replace |
| #172 | 08 Sept 2026, 16:35 | Receipt | Methadone 1mg/ml oral solution sugar free | AAH · inv 88213 | 2,500 | — | 3,080 ml | — | Amira Siddiqui | Void / replace |
Try to change this entry.
A controlled-drug entry, once written, is not editable by the application. Here is the proof rather than the promise: one row from our demo register, and what the database says when the application’s own role tries to change it.
| Ref | Date | Type | Patient | Out | Balance | By |
|---|---|---|---|---|---|---|
| #158 | 02 Sept 2026, 16:05 | Supply FP10MDAMethadone 1mg/ml oral solution sugar free | Ibrahim Begum | 10 | 2,765 ml | Tom Bradley |
Demo data: an invented patient at a ZZ99 postcode. The register it comes from is seeded bypnpm db:seed:demo.
Try to change this entryWhat the database said
We asked the database, as app_tenant (the role the application connects with), to change the quantity on that row:
UPDATE "CdEntry" SET "qtySupplied" = $1 WHERE "id" = $2
Postgres answered
ERROR 42501: permission denied for table CdEntry
Refused at the privilege check (aclcheck_error), before a single row was read, in 20.27 ms round trip. A DELETE gets the same answer: 42501, permission denied for table CdEntry, in 18.74 ms.
Why it says that
- The migration that revokes the permission:
prisma/migrations/20260525160000_cd_entry_append_only/migration.sql:9REVOKE UPDATE, DELETE ON "CdEntry" FROM app_tenant; - And a trigger that refuses again if that grant is ever widened:
prisma/migrations/20260525160000_cd_entry_append_only/migration.sql:21CREATE TRIGGER cd_entry_no_updateprisma/migrations/20260525160000_cd_entry_append_only/migration.sql:14RAISE EXCEPTION 'CdEntry is append-only (% blocked); record a correction instead', TG_OP;
The role keeps insert and select on the table — it can write a new entry and read the register, and nothing else.
Captured on 11 Sept 2026 against PostgreSQL 17.6 in a transaction that was rolled back, by the same test that runs on every build; if the answer ever changes, the build fails and this page is not published. Nothing on this page runs against a database when you open it.
So what happens when someone keys 10 instead of 100 at four o’clock?
A correction is a new, dated entry that references the original. The original stays on the register, struck through; the void reverses it; the re-entry records what should have been written. Nothing is overwritten, and the balance still reconciles.
| Ref | Date | Type | Patient / note | In | Out | Balance | By |
|---|---|---|---|---|---|---|---|
| #158 | 02 Sept 2026, 16:05 | Supplyvoided | Ibrahim Begum | — | 10 | 2,765 ml | Tom Bradley |
| #159 | 02 Sept 2026, 16:20 | Supply · void | corrects #158 — Quantity keyed as 10 ml instead of 100 ml | 10 | — | 2,775 ml | Amira Siddiqui |
| #160 | 02 Sept 2026, 16:20 | Supply · re-entry | Ibrahim Begumreplaces #158 | — | 100 | 2,675 ml | Amira Siddiqui |
The link from a void to its original is a column, not a convention:
prisma/schema.prisma:1698correctsEntryId String?Every write is chained to the one before it.
Each audit event is hashed together with the hash of the previous one, so changing any past event breaks every link after it — and the audit page re-verifies the whole chain from the first event each time it opens. These are the three events behind the correction above, shown exactly as they were hashed: keys sorted, one per line.
Event 1 · 02 Sept 2026
- action
- "CREATE"
- actorId
- "cmdemo00000000000000amir"
- actorName
- "Amira Siddiqui"
- after
- null
- before
- null
- branchId
- "cmdemo00000000000000ashc"
- createdAt
- "2026-09-02T15:05:12.000Z"
- entityId
- "cmdemo0000000000000e0158"
- entityType
- "CdEntry"
- metadata
- {"balanceAfter":"2765","entryType":"SUPPLY_PATIENT","isTerminal":true}
- orgId
- "cmdemo0000000000000000org"
- prevHash
- null
sha256 → ed24ee4e7d9a…037bef2f3c30
Event 2 · 02 Sept 2026
- action
- "UPDATE"
- actorId
- "cmdemo00000000000000amir"
- actorName
- "Amira Siddiqui"
- after
- null
- before
- null
- branchId
- "cmdemo00000000000000ashc"
- createdAt
- "2026-09-02T15:20:41.000Z"
- entityId
- "cmdemo0000000000000e0158"
- entityType
- "CdEntry"
- metadata
- {"balanceAfter":"2775","correction":true,"correctionEntryId":"cmdemo0000000000000e0159","isTerminal":true,"reason":"Quantity keyed as 10 ml instead of 100 ml"}
- orgId
- "cmdemo0000000000000000org"
- prevHash
- "ed24ee4e7d9a48aab18efefcc0a7e8981c08f8d9141a937bd8ab037bef2f3c30"
sha256 → 57a3ee23d145…739271c27ede
Event 3 · 02 Sept 2026
- action
- "CREATE"
- actorId
- "cmdemo00000000000000amir"
- actorName
- "Amira Siddiqui"
- after
- null
- before
- null
- branchId
- "cmdemo00000000000000ashc"
- createdAt
- "2026-09-02T15:20:42.000Z"
- entityId
- "cmdemo0000000000000e0160"
- entityType
- "CdEntry"
- metadata
- {"balanceAfter":"2675","isTerminal":true,"replacement":true,"replacesEntryId":"cmdemo0000000000000e0158"}
- orgId
- "cmdemo0000000000000000org"
- prevHash
- "57a3ee23d145522a1d86b58fc9addf0687646722356bb7dd8998739271c27ede"
sha256 → fcfba848600e…f5d5d8e11c18
The function, and why the keys are sorted before hashing:
src/server/audit/audit-core.ts:175export function computeHash(prevHash: string | null, row: AuditRow, createdAt: Date): string {src/server/audit/stable-stringify.ts:17export function stableStringify(value: unknown): string {