ADR-009: No DELETE on financial rows — reversals only
- Status: Accepted
- Date: 2026-05-23
- Deciders: CTO / Founding Engineering / Ledger team
Context
In a regulated financial system, the historical record must be reconstructible at any later point — for disputes, audits, regulator inquiries, and internal investigations. A DELETE destroys that record.
Soft-delete (deletedAt column) is no better for financial rows: it removes the row from queries and breaks reconciliation, while still letting the data resurface through joins or projections in unpredictable ways.
Decision
- No
DELETE FROMstatement, ever, in migrations or app code, targeting any financial table:journal_entry,transaction,wallet_transaction,payment,disbursement,payroll,payroll_entry,settlement_batch,settlement_record,- any audit table.
- "Cancelling" a transaction is posting a reversal — a new transaction whose entries are the original's mirror, plus a link to the original via
reverses_transaction_id. - Status changes (e.g. mark a
PayrollasCANCELLED) are allowed viaUPDATEfor non-financial state machines; the financial entries underneath them are still immutable. - Non-financial entities (e.g.
Department,Role) may use soft-delete viadeletedAtif there's a real business need. They are not what this ADR governs.
Alternatives considered
- Soft-delete with
deletedAt. Rejected for financial rows — discussed above. - Allow DELETE during a "draft" status. Rejected — drift between "draft" and "posted" semantics is its own bug surface; better to require explicit reversal even during drafts.
- Allow admin-only DELETE for "test data." Rejected. Test data lives in test databases.
Consequences
Positive
- The audit record is complete by construction.
- Reconciliation works.
- A regulator's "show me everything that happened to account X" query is one
WHEREclause.
Negative
- Tables grow monotonically. Negligible at expected scale; partitioning is the answer if it ever isn't.
- Test fixtures sometimes want to "clear" data — they target test schemas only, not the migrations under audit.
Follow-ups
scripts/ci/adr-guards.sh(wired into.github/workflows/ci.ymlas theadr-guardsjob) fails the build on anyprisma.<financialModel>.delete*()call inapps/packages/servicesproduction code. (Note: this superseded the originally-plannedtooling/prisma-lint/, which was never built.)- Ledger service rejects any internal SQL that contains
DELETEagainst journal tables (codified inside the Go service). - A future migration adds a Postgres
REVOKE DELETEfrom the application role on financial tables, as a defense-in-depth.