chore: sync submodule state (parent ref update)
Made-with: Cursor
This commit is contained in:
235
LEDGER_CORRECTNESS_BOUNDARIES.md
Normal file
235
LEDGER_CORRECTNESS_BOUNDARIES.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# 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:
|
||||
|
||||
1. **Authoritative ledger correctness** (must be atomic, invariant-safe)
|
||||
2. **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 table
|
||||
- `db/migrations/003_outbox_state_machine.sql` - State machine constraints
|
||||
- `src/workers/dual-ledger-outbox.worker.ts` - Worker service
|
||||
- `src/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/`:
|
||||
|
||||
1. **001_ledger_idempotency.sql** - Unique constraint on `(ledger_id, reference_id)`
|
||||
2. **002_dual_ledger_outbox.sql** - Outbox table with indexes
|
||||
3. **003_outbox_state_machine.sql** - Status transition enforcement
|
||||
4. **004_balance_constraints.sql** - Balance integrity constraints
|
||||
5. **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 state
|
||||
- `DBIS_COMMITTED` - DBIS ledger posted, SCB sync queued
|
||||
- `SETTLED` - Both ledgers synchronized
|
||||
- `FAILED` - Posting failed
|
||||
|
||||
## Key Constraints
|
||||
|
||||
### Database Level
|
||||
|
||||
1. **Idempotency**: `UNIQUE (ledger_id, reference_id)` on `ledger_entries`
|
||||
2. **Balance integrity**:
|
||||
- `available_balance >= 0`
|
||||
- `reserved_balance >= 0`
|
||||
- `available_balance <= balance`
|
||||
- `(available_balance + reserved_balance) <= balance`
|
||||
3. **State transitions**: Trigger enforces valid outbox status transitions
|
||||
|
||||
### Application Level
|
||||
|
||||
1. **Guarded access**: Only `LedgerPostingModule` can mutate ledger
|
||||
2. **Atomic operations**: All posting via SQL function
|
||||
3. **Transactional outbox**: Outbox creation in same transaction as posting
|
||||
|
||||
## Usage
|
||||
|
||||
### Posting to Master Ledger
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```sql
|
||||
-- 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
|
||||
|
||||
```sql
|
||||
-- Try invalid transition (should fail)
|
||||
UPDATE dual_ledger_outbox
|
||||
SET status = 'QUEUED'
|
||||
WHERE status = 'FINALIZED';
|
||||
-- ERROR: Invalid outbox transition: FINALIZED -> QUEUED
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Apply migrations** in order (see `db/migrations/README.md`)
|
||||
2. **Update Prisma schema** (already done - `dual_ledger_outbox` model added)
|
||||
3. **Deploy worker** to process outbox jobs
|
||||
4. **Implement SCB API client** in `DualLedgerOutboxWorker.callScbLedgerApi()`
|
||||
5. **Add monitoring** for outbox queue depth and processing latency
|
||||
6. **Add reconciliation** job to detect and fix sync failures
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### API Changes
|
||||
|
||||
- `postToMasterLedger()` now returns immediately with `dualCommit: false`
|
||||
- `sovereignLedgerHash` is `null` initially (populated by worker)
|
||||
- Status is `DBIS_COMMITTED` instead of `settled` initially
|
||||
|
||||
### Database Changes
|
||||
|
||||
- New constraint on `ledger_entries` (idempotency)
|
||||
- New balance constraints (may fail if data is inconsistent)
|
||||
- New `dual_ledger_outbox` table
|
||||
|
||||
### Code Changes
|
||||
|
||||
- Direct use of `ledgerService.postDoubleEntry()` for GSS should be replaced with `ledgerPostingModule.postEntry()`
|
||||
- Direct balance updates via Prisma are now banned (use `ledgerPostingModule`)
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If needed, migrations can be rolled back:
|
||||
|
||||
```sql
|
||||
-- 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
|
||||
Reference in New Issue
Block a user