Threads · 07 / 18 · Sep 7, 2026 · Apache-2.0

Ledger invariants the database enforces, not your prose

Your balance check is correct. It is also the only door with a lock, in a building with four doors — and the one that gets used at 2 a.m. is the migration script.

Real Postgres in the test

No Docker, no server, no fixture database — PGlite runs Postgres in-process. Every rejection on this page is Postgres rejecting it: real CHECK constraints, a real deferred constraint trigger, real REVOKE against a real role.

The check is right, and it is never asked

await expect(postThroughTheApp(db, UNBALANCED)).rejects.toThrow(OutOfBalance);

Nothing wrong with that code. It works, and every test of the posting path is green because of it.

Then the same rows arrive through the other door:

await postDirectly(db, UNBALANCED);

expect(await lineCount(db)).toBe(1);      // half an entry, posted
expect(await balanceOf(db, "bank")).toBe(40);

The ledger no longer balances and nothing raised its voice. The check did not fail — it was never asked.

That door is not an attacker. It is a migration, an admin console, a report job that needed to backfill a couple of rows, a second service pointed at the same database. A colleague, with credentials.

Move the rule to where every writer passes

await expect(postDirectly(db, UNBALANCED)).rejects.toThrow(/out of balance by 40.00/);
expect(await lineCount(db)).toBe(0);

Same rows, same connection, same absence of an application. Who called it stops being a question anybody has to get right.

The detail that decides whether the rule survives

An entry is written line by line, so it is out of balance between its first INSERT and its last. A check that ran per statement would make every correct posting impossible — and a rule that blocks correct work is dropped within the month.

CREATE CONSTRAINT TRIGGER entry_must_balance
  AFTER INSERT ON ledger_entries
  DEFERRABLE INITIALLY DEFERRED
  FOR EACH ROW EXECUTE FUNCTION assert_entry_balanced();

Deferred, it asks at COMMIT, when the transaction has said everything it has to say:

await postThroughTheApp(db, BALANCED);
expect(await lineCount(db)).toBe(2);

This is also why the balance rule cannot be a CHECK: a CHECK sees one row, and this invariant is about a set of them.

One invariant is not the invariant

await expect(postDirectly(db, [ …, debit: "-40.00", …, credit: "-40.00" ])).rejects
  .toThrow(/amounts_not_negative/);

That pair sums to zero. The balance rule is satisfied and the entry is still nonsense — a negative debit is a credit in a disguise, and every report that groups by side is wrong. So is a line that is a debit and a credit:

CONSTRAINT amounts_not_negative CHECK (debit >= 0 AND credit >= 0),
CONSTRAINT one_side_only        CHECK ((debit = 0) <> (credit = 0))

Three ways to be wrong, and you are protected only from the ones you wrote down.

Append-only is a privilege, not a method

await expect(db.exec("UPDATE ledger_entries SET debit = '400.00' …")).rejects.toThrow(/permission denied/);
await expect(db.exec("DELETE FROM ledger_entries …")).rejects.toThrow(/permission denied/);
GRANT SELECT, INSERT ON ledger_entries TO app;
REVOKE UPDATE, DELETE ON ledger_entries FROM app;

Not a repository that declines, not a convention: the role the application connects as has no such privilege, so neither does anything holding those credentials — including the console somebody opens at midnight.

Be honest about the limit: the table's owner and a superuser are not bound by this, and no REVOKE will bind them. What it buys is that violating the rule requires deliberately connecting as somebody else — an act with a name, that leaves a trace, that nobody performs by accident during a backfill.

The same table without the rules

await postDirectly(db, [{ … debit: "-40.00", credit: "-40.00" … }]);
await db.exec("UPDATE ledger_entries SET debit = '999.00' …");
await db.exec("DELETE FROM ledger_entries …");

expect(await lineCount(db)).toBe(0);

Out of balance, negative, on both sides at once, then edited, then gone. Six lines, no error — with the same application code sitting on top, all its checks intact, and none of them asked.

Where this comes from

niiko keeps a double-entry ledger where money is concerned, and its rule is that no invariant about money lives only in application code. sql/enforced.sql is the shape the real migration takes.

Related: append-only-that-blocked-itself — what REVOKE UPDATE, DELETE costs the day you have to correct a genuine mistake, and why it is still right.

License

Apache-2.0 — see LICENSE. This is a demonstration, not a package. Copy what you need.


Built by Vorluno — a software studio from Panamá.

// next threadPut the second wall where the prompt cannot reach