Under the hood

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.

Watch an entry go in

← All registers

Methadone 1mg/ml oral solution sugar freeVMP 36120811000001107

⚠ Schedule 2 CD

Expired

0 ml

Total received

22,500

Total supplied

18,900

Brand balancesFull registerBalance check
Full register — 50 entriesExport CSV
Supplier, invoice, prescriber, GMC, RP, notes, or #entryFromTo

Showing 50 of 175 entries

RefDateTypeBrandPatient / supplierInOutBalanceExpiredRP
#17611 Sept 2026, 16:05SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeIbrahim Begum—1002,660 ml—Amira Siddiqui
#17510 Sept 2026, 09:20SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeHarold Adebayo—602,760 ml—Amira Siddiqui
#17409 Sept 2026, 11:40SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeLinda Kowalski—1602,820 ml—Amira Siddiqui
#17309 Sept 2026, 10:05SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeIbrahim Begum—1002,980 ml—Tom Bradley
#17208 Sept 2026, 16:35ReceiptMethadone 1mg/ml oral solution sugar freeAAH · inv 882132,500—3,080 ml—Amira Siddiqui
A day in the app

From the PIN to the inspection.

  1. Sign in with your PIN.

    At the shared terminal, tap your name and enter your PIN. A locum uses their GPhC number. From here, everything you record carries your name.

  2. Record it at the bench.

    The entry form takes what a paper register takes — drug, patient, quantity, prescription type — and signs it with your PIN. The balance updates itself.

  3. Nobody can quietly change it.

    There is no edit button on a register entry, and the database refuses one anyway. Keyed 10 instead of 100? A void and a re-entry go in, the original stays, struck through, and the balance still adds up.

    See the database refuse it, for real.

  4. Ready when the inspector asks.

    Every write is in the audit log, chained to the one before it and re-verified when the page opens. Export the register as CSV in one click.

Who’s recording?

Type your name…
ASAmira SiddiquiPharmacist
TBTom BradleyPharmacist
POPriya OkaforTechnician
DMDaniel MossDispenser
LKLinda KowalskiCounter
RHRosa HughesTechnician

Personal PIN

1234567890⌫

← All registers

Methadone 1mg/ml oral solution sugar freeVMP 36120811000001107

⚠ Schedule 2 CD

Expired

0 ml

Total received

22,500

Total supplied

18,900

Brand balancesFull registerBalance check
Full register — 50 entriesExport CSV
Supplier, invoice, prescriber, GMC, RP, notes, or #entryFromTo

Showing 50 of 175 entries

RefDateTypeBrandPatient / supplierInOutBalanceExpiredRP
#17611 Sept 2026, 16:05SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeIbrahim Begum—1002,660 ml—Amira Siddiqui
#17510 Sept 2026, 09:20SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeHarold Adebayo—602,760 ml—Amira Siddiqui
#17409 Sept 2026, 11:40SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeLinda Kowalski—1602,820 ml—Amira Siddiqui
#17309 Sept 2026, 10:05SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeIbrahim Begum—1002,980 ml—Tom Bradley

← All registers

Methadone 1mg/ml oral solution sugar freeVMP 36120811000001107

⚠ Schedule 2 CD
Brand balancesFull registerBalance check
Full register — 50 entriesExport CSV
Supplier, invoice, prescriber, GMC, RP, notes, or #entryFromTo

Showing 50 of 175 entries

RefDateTypeBrandPatient / supplierInOutBalanceExpiredRP
#17811 Sept 2026, 16:20Supply · re-entryFP10MDAMethadone 1mg/ml oral solution sugar freeIbrahim Begumreplaces #176—1002,660 ml—Amira Siddiqui
#17711 Sept 2026, 16:20Supply · voidMethadone 1mg/ml oral solution sugar freecorrects #176 — keyed as 10 ml instead of 100 ml10—2,760 ml—Amira Siddiqui
#17611 Sept 2026, 16:05SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeIbrahim Begum—102,750 ml—Amira Siddiqui
#17510 Sept 2026, 09:20SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeHarold Adebayo—602,760 ml—Amira Siddiqui
#17409 Sept 2026, 11:40SupplyFP10MDAMethadone 1mg/ml oral solution sugar freeLinda Kowalski—1602,820 ml—Amira Siddiqui

Audit log

Every write, chained to the one before it

Chain verified · 1,727 events
16:20:42RecordedRegister entry #178 · replaces #176fcfba848…
16:20:41VoidedRegister entry #176 · reason recorded57a3ee23…
16:05:12RecordedRegister entry #176 · supply, PIN sign-offed24ee4e…
09:01:03Signed inAmira Siddiqui, as responsible pharmacist9b1c04aa…
Export CSVVerify chain
The register, as an inspector reads it

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.

One controlled-drug register entry from the demo register
RefDateTypePatientOutBalanceBy
#15802 Sept 2026, 16:05Supply FP10MDAMethadone 1mg/ml oral solution sugar freeIbrahim Begum102,765 mlTom 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.

The original entry, its void and its re-entry
RefDateTypePatient / noteInOutBalanceBy
#15802 Sept 2026, 16:05SupplyvoidedIbrahim Begum—102,765 mlTom Bradley
#15902 Sept 2026, 16:20Supply · voidcorrects #158 — Quantity keyed as 10 ml instead of 100 ml10—2,775 mlAmira Siddiqui
#16002 Sept 2026, 16:20Supply · re-entryIbrahim Begumreplaces #158—1002,675 mlAmira 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.

  1. 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

  2. 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

    sha256 → 57a3ee23d145…739271c27ede

  3. 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

    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 {