Skip to main content

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.
The authenticated HTTP boundary requires 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, and 10 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 to event_stream, which is its own hash chain:
It is org-scoped, monotonically sequenced, and it records 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 a verified 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.
One historical limit. New checksum-version-2 entries preserve enough canonical facts for full recomputation, including foreign-currency lines. A legacy version-1 non-base entry may not preserve the raw exchange-rate and zero-side rendering needed to reproduce its old bytes, so the Auditor checks its chain linkage only and labels it legacy linkage-only. The version stored on each entry decides the algorithm; GreatBook never reinterprets historical bytes under the newer rule.

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 a modified_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.