ADR-015: Adjustment-journal process for non-zero reconciliation drift
- Status: Proposed
- Date: 2026-05-31
- Deciders: Principal Architect, Engineering Lead, Finance/Ops Lead (pending counter-sign)
- Relates to: ADR-006, ADR-009, ADR-012, ADR-014
- Closes:
docs/SYSTEM_GAP.md§1.H bullet "No adjustment-journal process."
Context
The reconciliation primitive (Ledger.ReconcileWithBank) reports drift between the ledger's POSTED total and the partner bank's statement total. ADR-014 §5 fixes the truth ranking: when ledger and bank disagree, the bank wins.
The primitive answers "is there drift?". It does not answer "what do we do when drift is non-zero?".
Today: zero. The recon-runner logs a finding and pages an operator. There is no governed process for posting the offsetting entry that makes the ledger match reality. Without one, the only options on the floor are:
- Manually
INSERTintoledger_entry— forbidden by ADR-009 (no DELETE/UPDATE, and bypass entry posting subverts the same constraints). - Call
Reverseon the orphaned transaction — only correct when we know which transaction is wrong, which during a real drift incident is exactly what we don't know yet. - Do nothing — drift compounds. The 7-day soak gate (Sprint 4 / GO-LIVE) becomes unblockable in practice because every drifted day stays drifted.
The gap is operational (we need a defined workflow) and architectural (the workflow needs a ledger-shape that exists today).
Worked example
- Morning of 2026-06-15 the recon-runner reports: Dashen pool A123 —
ledger_total = 2,500,000 santim,statement_total = 2,498,000 santim,drift = +2,000 santim. The ledger says we hold 2,000 more than the bank does. - Investigation finds: a Dashen-side fee was deducted from the pool on 2026-06-14 that we did not book. The fee is a real loss, not a bug.
- The right answer: post an adjustment that moves 2,000 santim out of the receivable-from-employee account into a fee-expense account so the ledger total drops to 2,498,000 and matches the bank. Then the next recon-runner reports drift=0 and the soak counter resumes.
The adjustment must be: forward-only, fully audited, approved by a named human, idempotent, and trivially queryable as a class.
Decision
Adjustment-journal is a first-class ledger transaction subtype, posted through a new admin-only RPC Ledger.PostAdjustment. It is not a separate table, not a soft-update of an existing transaction, not a Reverse — it is a new POSTED transaction with a marked subtype and a required justification trail.
Shape
1. New transaction subtype
// In ledger.proto (no schema migration; uses the existing
// ledger_transaction.metadata JSONB pocket).
enum LedgerTransactionSubtype {
LEDGER_TRANSACTION_SUBTYPE_UNSPECIFIED = 0; // default — normal posting
LEDGER_TRANSACTION_SUBTYPE_ADJUSTMENT = 1; // ADR-015 adjustment
}
A subtype = ADJUSTMENT row is identical in storage to a normal POSTED transaction (entries, balance, sign rules, RLS) but carries:
metadata.subtype = "ADJUSTMENT"metadata.adjustment_reason— free text, mandatory, redacted-PII-only ("Dashen pool A123 fee not booked 2026-06-14").metadata.adjustment_source—"RECON_DRIFT","STATEMENT_LINE_UNMATCHED","BANK_DISPUTE_OUTCOME","DATA_CORRECTION","MANUAL".metadata.approved_by— user-id of the human who signed off. Required. A machine-only approval is rejected at the RPC.metadata.reconciliation_run_id— optional foreign key to the recon-runner output that motivated this adjustment, when one exists.
2. New RPC
rpc PostAdjustment(PostAdjustmentRequest) returns (PostAdjustmentResponse);
Same shape as PostTransaction plus the four metadata fields above as first-class request fields. The handler:
- Refuses if
approved_byis empty. - Refuses if
adjustment_reasonis empty or under 10 characters. - Refuses if any entry credits/debits a
receivable-from-employee/receivable-from-borroweraccount belonging to anemployee_idnot named inmetadata.affected_subjects(forces explicit acknowledgement when an adjustment touches a customer-attributable balance). - Inserts the transaction with
status = POSTEDand the subtype metadata. Idempotent via the existing(tenant_id, idempotency_key)UNIQUE. - Records an audit row (
actor = approved_by,event = ADJUSTMENT_POSTED) in the same DB transaction (ADR-008).
3. Authorization
PostAdjustment is platform-admin-only (@RequirePlatformAdmin). Org-admins cannot self-approve adjustments against their own tenant — that's the segregation-of-duties guard.
4. Reporting
A dedicated read endpoint GET /api/admin/ledger/adjustments?tenant_id=...&since=... returns every adjustment for the period, with affected_subjects expanded. This is the artefact compliance auditors read. It is mandatorily reviewed at month-end close.
What this is NOT
- Not a Reverse. Reverse is for "the original transaction was wrong and is now compensated by an inverse". Adjustment is for "the original transaction was right at the time, but external truth has since diverged and we book the offsetting reality."
- Not an UPDATE. ADR-009 stands. Adjustments are new POSTED entries; the original entry is never touched.
- Not a tenant-self-service endpoint. Adjustments mint money on the ledger; only platform admins post them, only after written justification.
- Not a bypass of
ConfirmSettlement/MarkSettlementFailed. Those handle the routine PENDING→POSTED / PENDING→FAILED lifecycle. Adjustment is reserved for POSTED→POSTED drift correction, never for routine settlement state.
Alternatives considered
A — Reverse + new transaction
Use the existing Reverse RPC to cancel the offending transaction, then post a replacement. Rejected: Reverse needs a target transaction id, but most drift findings are aggregate (statement total off by X santim across N transactions) — there is no single transaction to reverse. Also overloads Reverse beyond its current bug-fix semantics.
B — Direct schema column is_adjustment BOOLEAN
Cleaner query shape but requires a migration and an immutable column for every existing row. Rejected: adjustments are sub-1% of postings; metadata-JSONB-with-index meets the query load without schema churn, and lets the same shape extend to future subtypes (SUBTYPE_BNPL_FEE_REVERSAL etc.) without further migrations.
C — Out-of-ledger adjustment table
Park adjustments in a sibling table; the ledger stays "pure". Rejected: drives reconciliation toward two systems-of-record. ADR-006 (ledger is sole source of money truth) forbids this directly.
D — Defer to the bank statement as the only truth, never adjust
Just let drift be the answer. Rejected: we need a flat ledger total that matches the bank for compliance + tax reporting + investor reporting. "The ledger is drifted by X" is not a valid line in a financial statement.
Consequences
Positive
- The 7-day-drift-clean soak gate becomes practically achievable: drifted days can be closed by a deliberate, audited action instead of staying open forever.
- Every adjustment carries a named human + a written reason — the audit trail compliance asks for.
- No schema migration. Ships behind a feature flag (
LEDGER_ADJUSTMENT_ENABLED) and an RBAC gate. - The subtype field is forward-compatible: future drift-class operations (BNPL chargeback acceptance, FI servicing-fee true-up) reuse the shape.
Negative / open questions
- The fee-expense / write-off chart of accounts needs to exist before any adjustment can be posted. The relevant accounts (
misc-fee-expense,partner-fee-expense,drift-write-off,unknown-credit) must be added to the chart-of-accounts adapters as part of the rollout. Not a blocker — same place we added the post-GAP-04 taxonomy — but it's prerequisite work. - A maliciously-drafted
affected_subjectsvalue can hide a customer-attributable adjustment from the by-subject view. Mitigated by the human-approval gate + monthly compliance review. - Adjustment posting cannot run automatically from the recon-runner. Confirmed: that's the point — every adjustment needs human judgement.
Implementation plan
| Step | Owner | Estimated effort |
|---|---|---|
1. Add LedgerTransactionSubtype enum + PostAdjustment RPC to packages/contracts/grpc/ledger.proto. Regenerate stubs. | Eng-B (Go) | 0.5 day |
2. Implement services/ledger/internal/server/post_adjustment.go. Mirrors post_transaction.go plus the metadata validations. Audit row in same tx. | Eng-B | 1 day |
3. Add the fee/write-off chart-of-accounts entries to apps/api/src/{ewa,lending}/ledger-accounts.adapter.ts. | Eng-A | 0.5 day |
4. New NestJS admin controller apps/api/src/money/integration/ledger-adjustment.controller.ts. POST /api/admin/ledger/adjustments + GET /api/admin/ledger/adjustments. @RequirePlatformAdmin. | Eng-A | 0.5 day |
| 5. Wire the recon-runner finding view to surface a "propose adjustment" admin link that opens the controller with the drift pre-populated (web is out of scope here — this is just the API field shape). | Eng-A | 0.5 day |
6. Documentation: runbook entry under docs/runbooks/drift-investigation.md (currently a Stub template). | Eng-A | 0.5 day |
Total: ~3.5 engineer-days. No schema migration. No production traffic risk; the RPC is admin-only behind a feature flag.
Open follow-ups
- Dispute workflow (ADR-016) consumes this: the terminal action of a dispute that finds in the customer's favour is a
PostAdjustmentcall withadjustment_source = "BANK_DISPUTE_OUTCOME". ADR-016 closes that loop. - A separate ADR (not now) will define automated adjustment proposals for the narrow case of bank-statement fees with a stable pattern (e.g. "Dashen monthly maintenance fee" — predictable, recurring, auto-codable into a queued proposal that still requires human approval).