Immutability and the hash chain
What it guarantees
A posted journal entry is never updated and never deleted. Not by the application, not by an agent, and not by someone with a database session. A correction is a new reversing entry, which is what preserves both the audit trail and the chain. And any change made outside those rules is detectable, because every entry commits to its predecessor.Immutability, enforced twice
The writer never issues an UPDATE against a posted entry, and the database refuses one anyway.
The second layer is the one that matters for the claim.
An application rule is a promise about the code you can see; a trigger is a property of the data, and it holds for the SQL console, the migration you wrote at midnight, and the connector nobody remembers writing.
Corrections are typed business reversals
The low-level journal mirror is not the public correction API.BusinessReversalService.reverse first binds the exact original journal UUID, stored checksum version and checksum, proves the source belongs to a closed supported family, verifies complete native state and dependency order, and only then appends the mirror while compensating the linked native records in the same transaction.
The supported families are AP/AR obligations, settlements and allocations, inventory and costing, payroll, loans, tax/VAT and adjustments.
Specific source shapes include bills and invoices; payments, receipts and advances; stock and costing events; payroll and PIT remittance; loan movements; adjustment notes; and approved adjustment workpapers.
An unsupported, partial, stale, cross-organization or dependency-blocked event writes no reversal journal.
Five details:
- The reviewed reversal date must fall in an open period. It does not rewrite the original date or reopen the original period, so invariant 9 applies exactly as it does to any entry.
- Identity is exact. The original UUID, checksum algorithm version and checksum prevent an old review from targeting changed facts.
- Replay is exact. An exact request key and fingerprint returns the existing reversal; changed request facts conflict.
- Only a posted, unreversed, supported event with complete native evidence can be reversed. There is no GL-only fallback when the corresponding business state cannot be compensated safely.
- The reversal links back through
reverses_entry_id, and active Books/AP-aging/Close reads exclude the compensated event without deleting its history.
ledger.reverse, a written reason, exact acknowledgement and a five-minute actor/organization/facts-bound confirmation grant.
It is registered as the current capability greatbook.accounting.event.reverse@1.
The web product creates an entry-scoped durable reversal proposal and routes a different named person to its proposal-specific review page.
The 1.14.1 release-source transport includes typed staging, but its generic workspace commit can only replay a reversal already approved through the durable proposal control; it cannot originate the compensation.
The public MCP service is current on registry 1.14.1, but provider behavior has not been exercised.
The chain
Every entry commits to the one before it, per organisation.Why the serialisation is canonical
This is the detail that makes the chain work rather than merely exist. Current checksum version 2 sorts over complete canonical line facts and stringifies the decimals. So line order cannot change a checksum, and10 and 10.0000 hash alike.
Without that, the checksum would be a hash of one particular rendering of the entry.
A verifier reading the same rows back out of Postgres, where NUMERIC(20,4) normalises the scale and the row order is whatever the planner chose, would compute a different hash and report a tamper that never happened.
A chain that cries wolf is a chain nobody checks.
The second chain
Every posting also appends toevent_stream, which is its own hash chain:
entry.posted and entry.reversed with the entry id, the source key, the checksum and the base-currency debit total.
Rewriting an entry therefore means rewriting two independent chains from that point forward, in a database that rejects the UPDATE.
Verification is a recompute, not a flag
The Auditor does not read averified column.
It relinks the chain and recomputes every checksum from the stored rows, using the writer’s own hash helpers (LedgerService._entry_checksum and _canonical_lines) rather than a reimplementation.
That import is the point.
A verifier with its own copy of the hashing rule drifts from the writer the first time the writer changes, and then reports either false tampering or false safety.
Here, if the writer’s hashing changes, the auditor recomputes with the new rule because it is literally the same code.
Where it is enforced
The test that would fail if it broke
backend/tests/test_gl_security.py asserts that a posted row rejects an UPDATE at the database level.
backend/tests/test_business_reversals.py covers the closed family registry, native compensation, dependencies, replay, conflicts and atomic rollback.
backend/tests/test_ledger_engine.py covers journal-mirror semantics and chain continuity.
The Auditor’s live acceptance suite seeds real faults, including a wrong prev_checksum and a line tampered with the immutability trigger temporarily disabled, and asserts each is flagged.
What goes wrong without it
An editable ledger is a ledger where “what did the books say last Tuesday” has no answer. Every downstream artifact - a filed VAT return, a bank covenant certificate, a signed statement - becomes a claim about a number that has since moved, and nobody can prove which way. The weaker version of this control is amodified_at column and a policy.
That tells you a row changed, not what it changed from, and only if the person who changed it left the column alone.
Related
- The 18 GL invariants - invariants 12, 14, 15 and 16 in context
- The general ledger - the writer that builds the chain
- The Auditor - the agent that recomputes it
- Tenancy, RLS and derived identity - why the auditor cannot write even if it wanted to