What property are we after?
An accounting ledger has one property that matters more than speed or features: after a period is approved, the numbers in it must not change quietly. Here is how LedgerDock gets that with two small pieces in PostgreSQL.
Why is the unit a commit and not a row?
Nothing is written to the ledger one row at a time. Approved work is grouped into a commit: one bank statement, one import, or one journal entry. A person reviews it and finalizes it. Only then are its rows written, each carrying the id of its commit.
Piece one: what does the hash chain do?
When a commit is finalized, the application computes a SHA-256 hash over the commit: its totals, the ids of the ledger rows it wrote, its documents, its decision trail, the time it was sealed, and the hash of the commit before it. Each commit stores its own hash and the previous one.
Change a sealed commit’s totals, its list of rows, its documents or its decision trail, and its hash no longer matches. Because the next commit’s hash includes it, every later hash is wrong too. A verifier can walk the chain and recompute each one.
The hash does not cover each row’s own date, account and amount. Those are guarded by the second piece.
One practical lesson: hash the exact strings the database gives back. A total stored as numeric(15,2) comes back as 0.00, not 0. Hash the number in one place and the string in another, and the chain fails to verify with nothing wrong in the data.
Piece two: what does the trigger do?
The hash shows that a sealed record was changed. It does not stop a change, and it does not look inside the rows. That is the trigger’s job. Before any update or delete on a ledger row, it looks up the row’s commit:
- if the commit is finalized, a delete is refused;
- if the commit is finalized, an update is refused when it changes the commit, the date, an account, the debit or the credit;
- anything else on the row, such as a memo or a class, can still be edited.
The last line is deliberate. The money identity of a row is frozen. Its labels are not.
How is a mistake corrected, then?
You do not edit a sealed row. You make a new commit that adjusts it, with your name and the date on it. The old row, the new row and the reason all stay in the books. See fixing a mistake in a closed period.
Why both pieces?
The trigger alone can be dropped by someone with enough access. The hash chain alone tells you only after the fact, and only about the sealed record. Together, the trigger blocks the ordinary mistake, and the chain keeps a sealed set of totals for every batch that the rows can be checked against.
For the other half of trusting the books, the numbers going in, see the footing check.