7.2 KiB
Ledger Correctness Boundaries - Implementation Summary
This document summarizes the implementation of ledger correctness boundaries that enforce the separation between authoritative ledger operations and external synchronization.
Overview
DBIS Core maintains an authoritative ledger (issuance, settlement, balances) while also orchestrating dual-ledger synchronization with external SCB ledgers. This requires two different correctness regimes:
- Authoritative ledger correctness (must be atomic, invariant-safe)
- External synchronization correctness (must be idempotent, replayable, eventually consistent)
Architecture Changes
1. Atomic Ledger Posting (Postgres as Ledger Engine)
Problem: Balance updates were happening in separate Prisma calls, risking race conditions and inconsistent state.
Solution: Created post_ledger_entry() SQL function that:
- Enforces idempotency via unique constraint on
(ledger_id, reference_id) - Updates balances atomically within the same transaction as entry creation
- Uses deadlock-safe lock ordering
- Computes block hash with hash chaining
- Validates sufficient funds at DB level
Location: db/migrations/005_post_ledger_entry.sql
2. Dual-Ledger Outbox Pattern
Problem: Original implementation posted to SCB ledger first, then DBIS. If SCB was unavailable, DBIS couldn't commit. This violated "DBIS is authoritative" principle.
Solution: Implemented transactional outbox pattern:
- DBIS commits first (authoritative)
- Outbox event created in same transaction
- Async worker processes outbox jobs
- Idempotent retries with exponential backoff
- State machine enforces valid transitions
Files:
db/migrations/002_dual_ledger_outbox.sql- Outbox tabledb/migrations/003_outbox_state_machine.sql- State machine constraintssrc/workers/dual-ledger-outbox.worker.ts- Worker servicesrc/workers/run-dual-ledger-outbox.ts- Worker runner
3. Guarded Access Module
Problem: Any code could directly mutate ledger_entries or bank_accounts, bypassing correctness guarantees.
Solution: Created LedgerPostingModule that is the only allowed path to mutate ledger:
- All mutations go through atomic SQL function
- Direct balance updates are banned
- Singleton pattern enforces single access point
Location: src/core/ledger/ledger-posting.module.ts
4. Refactored GSS Master Ledger Service
Changes:
- DBIS-first: Posts to DBIS ledger first (authoritative)
- Transactional: DBIS post + outbox creation + master record in single transaction
- Non-blocking: Returns immediately; SCB sync happens async
- Explicit states:
DBIS_COMMITTED→SETTLED(when SCB sync completes)
Location: src/core/settlement/gss/gss-master-ledger.service.ts
Migration Files
All migrations are in db/migrations/:
- 001_ledger_idempotency.sql - Unique constraint on
(ledger_id, reference_id) - 002_dual_ledger_outbox.sql - Outbox table with indexes
- 003_outbox_state_machine.sql - Status transition enforcement
- 004_balance_constraints.sql - Balance integrity constraints
- 005_post_ledger_entry.sql - Atomic posting function
State Machine
Outbox States
QUEUED → SENT → ACKED → FINALIZED
↓ ↓ ↓
FAILED ← FAILED ← FAILED
↑
(retry)
Master Ledger States
PENDING- Initial stateDBIS_COMMITTED- DBIS ledger posted, SCB sync queuedSETTLED- Both ledgers synchronizedFAILED- Posting failed
Key Constraints
Database Level
- Idempotency:
UNIQUE (ledger_id, reference_id)onledger_entries - Balance integrity:
available_balance >= 0reserved_balance >= 0available_balance <= balance(available_balance + reserved_balance) <= balance
- State transitions: Trigger enforces valid outbox status transitions
Application Level
- Guarded access: Only
LedgerPostingModulecan mutate ledger - Atomic operations: All posting via SQL function
- Transactional outbox: Outbox creation in same transaction as posting
Usage
Posting to Master Ledger
import { gssMasterLedgerService } from '@/core/settlement/gss/gss-master-ledger.service';
const result = await gssMasterLedgerService.postToMasterLedger({
nodeId: 'SSN-1',
sourceBankId: 'SCB-1',
destinationBankId: 'SCB-2',
amount: '1000.00',
currencyCode: 'USD',
assetType: 'fiat',
sovereignSignature: '...',
}, 'my-reference-id');
// Returns immediately with DBIS hash
// SCB sync happens async via outbox worker
Running Outbox Worker
# Run worker process
npm run worker:dual-ledger-outbox
# Or use process manager
pm2 start src/workers/run-dual-ledger-outbox.ts
Testing
Verify Migrations
-- Check idempotency constraint
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'ledger_entries'
AND constraint_name LIKE '%reference%';
-- Check outbox table
SELECT COUNT(*) FROM dual_ledger_outbox;
-- Test posting function
SELECT * FROM post_ledger_entry(
'Test'::TEXT,
'account1'::TEXT,
'account2'::TEXT,
100::NUMERIC,
'USD'::TEXT,
'fiat'::TEXT,
'Type_A'::TEXT,
'test-ref-123'::TEXT,
NULL::NUMERIC,
NULL::JSONB
);
Verify State Machine
-- Try invalid transition (should fail)
UPDATE dual_ledger_outbox
SET status = 'QUEUED'
WHERE status = 'FINALIZED';
-- ERROR: Invalid outbox transition: FINALIZED -> QUEUED
Next Steps
- Apply migrations in order (see
db/migrations/README.md) - Update Prisma schema (already done -
dual_ledger_outboxmodel added) - Deploy worker to process outbox jobs
- Implement SCB API client in
DualLedgerOutboxWorker.callScbLedgerApi() - Add monitoring for outbox queue depth and processing latency
- Add reconciliation job to detect and fix sync failures
Breaking Changes
API Changes
postToMasterLedger()now returns immediately withdualCommit: falsesovereignLedgerHashisnullinitially (populated by worker)- Status is
DBIS_COMMITTEDinstead ofsettledinitially
Database Changes
- New constraint on
ledger_entries(idempotency) - New balance constraints (may fail if data is inconsistent)
- New
dual_ledger_outboxtable
Code Changes
- Direct use of
ledgerService.postDoubleEntry()for GSS should be replaced withledgerPostingModule.postEntry() - Direct balance updates via Prisma are now banned (use
ledgerPostingModule)
Rollback Plan
If needed, migrations can be rolled back:
-- Drop function
DROP FUNCTION IF EXISTS post_ledger_entry(...);
-- Drop outbox table
DROP TABLE IF EXISTS dual_ledger_outbox CASCADE;
-- Remove constraints
ALTER TABLE ledger_entries
DROP CONSTRAINT IF EXISTS ledger_entries_unique_ledger_reference;
ALTER TABLE bank_accounts
DROP CONSTRAINT IF EXISTS bank_accounts_reserved_nonnegative,
DROP CONSTRAINT IF EXISTS bank_accounts_available_nonnegative,
DROP CONSTRAINT IF EXISTS bank_accounts_balance_consistency;
References
- Architecture discussion: See user query about "hard mode" answer
- Transactional Outbox Pattern: https://microservices.io/patterns/data/transactional-outbox.html
- Prisma transaction docs: https://www.prisma.io/docs/concepts/components/prisma-client/transactions