Technical design — MOD-002 Immutable transaction log¶
Module: MOD-002 System: SD01 Core Banking Repo: bank-core FR scope: FR-049, FR-050, FR-051, FR-052, FR-425, FR-426, FR-427, FR-428 NFR scope: NFR-012, NFR-013, NFR-019, NFR-024 Policies satisfied: GOV-006 (LOG), REP-005 (LOG), AML-005 (LOG), PAY-002 (LOG), PAY-007 (LOG) Author: AI coding agent (Claude) Date: 2026-04-28
Objective¶
MOD-002 is the bank's append-only, hash-verifiable, 7-year-retained
event-sourced record of every ledger posting. It subscribes to
bank.core.posting_completed (and forward-compatibly to
bank.core.interest_accrued) on the bank-core EventBridge bus,
persists each posting as a row in core.transaction_log, computes a
SHA-256 over the immutable fields at insert time, exposes a paginated
account-history read endpoint and a batch hash-verification endpoint,
and runs a 60-second-cadence sliding-window verifier that pages on-call
within the FR-052 SLA on any integrity mismatch. It is the canonical
source for AML monitoring (AML-005), regulatory data lineage (REP-005),
settlement legal certainty (PAY-002, PAY-007), and internal audit
(GOV-006) — five LOG-mode policy obligations.
Internal architecture¶
EventBridge bank-core ─▶ Mod002IngestRule ─▶ Mod002IngestHandler
│ (withTransaction)
├─▶ posting-fetch.fetchPostingMeta
│ (looks up posting_date,
│ transaction_id, source_module
│ from accounts.postings)
└─▶ log-writer.writeLog
INSERT core.transaction_log
ON CONFLICT (posting_id) DO NOTHING
+ SHA-256 row_hash
API Gateway HTTP API
GET /internal/v1/transaction-log/{account_id} ─▶ Mod002QueryHandler
└▶ log-reader.readAccountHistory
keyset pagination on
(posting_date DESC, id DESC)
POST /internal/v1/transaction-log/verify ─▶ Mod002VerifyHandler (HTTP)
└▶ verifier.verify
re-hashes [from, to]
returns mismatches[]
EventBridge schedule rate(1 minute) ─▶ Mod002VerifyHandler (scheduled)
└▶ verifier.verify
over the last 90 s window
emits hash_mismatch_total
on detection → SNS alarm
Three Lambdas + one HTTP API + two EventBridge rules (one consumer subscription, one schedule) + two CloudWatch alarms.
Key design decisions¶
Decision: schema lives in core per FR-426, not accounts¶
Context: FR-426 names the table core.transaction_log. The SD01
data-model prose says MOD-002 writes to the accounts schema.
Choice: core.transaction_log per the orchestrator's resolution A1.
Reason: The FR is more specific than the prose; the SD01 design doc gets a downstream edit.
Decision: no foreign keys to accounts.postings / accounts.accounts¶
Context: The natural FK from transaction_log.posting_id to
accounts.postings(id) would couple migrations across modules and
block normal lifecycle operations (account close, branch refresh,
disaster recovery) on the upstream tables.
Choice: UUID references with application-level integrity. The
writer service validates posting existence via posting-fetch.ts
before insert; broken references surface as POSTING_NOT_FOUND
(VALIDATION_FAILURE → DLQ), not silent corruption.
Reason: Consistent with the architecture's general
no-cross-domain-FK posture (cross-domain references are UUIDs validated
at write time, per the design standards). Also surfaced as a critical
property when dev's accounts.postings was missing during verification
— the schema migration still applied cleanly.
Decision: idempotency via UNIQUE on posting_id¶
Context: EventBridge delivers each event at-least-once. The naïve
methodology pattern ({schema}.idempotency_keys table) is for sync
request/response idempotency where the result must be returned to the
caller; for an async insert-only consumer, a UNIQUE constraint is cleaner.
Choice: posting_id uuid NOT NULL UNIQUE with
INSERT ... ON CONFLICT (posting_id) DO NOTHING (orchestrator A6).
Reason: One row per ledger posting is the canonical invariant.
Two events delivered for the same posting_id MUST result in one row
or the audit is broken. Encoding that at the schema level is stronger
than a sidecar table.
Decision: SHA-256 hash over a deterministic key=value canonical form¶
Context: FR-427 requires a per-row hash and a verification endpoint that re-computes from data. JSON.stringify is non-deterministic across runtimes (key order, whitespace, number formatting). A canonical projection is required.
Choice: Newline-separated key=value over a fixed ordered list of
the 18 hashable fields (see src/lib/hash.ts).
Reason: Every step is reproducible from data alone. Adding a field
to the hash is an additive schema change requiring a backfill story —
captured by the HASHABLE_FIELDS_ORDER array; reviewers see the field
list change in diff.
Decision: FR-052 60-second SLA via 90-second sliding-window scheduler¶
Context: "Detect and alert within 60 seconds" cannot mean re-hashing the entire 7-year corpus every minute.
Choice: A scheduled EventBridge rule firing every 60 s invokes the
verifier over now() - 90 s ↦ now() (orchestrator A4). 30-second
overlap so a row inserted right at a window boundary is checked twice
rather than missed. The on-demand POST /verify endpoint covers
operator-initiated full-range audits — no SLA, sized by request.
Reason: Detection latency is bounded by (window_overlap +
verify_run_duration + alarm_evaluation_period) ≈ 60 s. Production
load should keep the per-window row count under a few hundred — the
current writer rate is single-digit qps; cost-per-run stays trivial.
Decision: total immutability — no deletion ever, even past retention¶
Context: FR-428 wording says "row-level policy that prevents deletion ... within the retention window", implying deletion AFTER 7 years could be permitted. FR-049 and FR-426 both say append-only absolutely.
Choice: Per orchestrator A5 — the V002 trigger rejects DELETE for
every role unconditionally. The V003 RLS policy is defence-in-depth
and reads USING(false); not a retention-conditional gate.
Reason: A future "MOD-XXX retention pruner" with a bypass role is the only scenario where deletion past 7 years is meaningful, and that module hasn't been scoped. Total immutability is the safer default.
External dependencies¶
- Database:
bank_coreon Neon (provisioned by MOD-103). - WRITE/READ:
core.transaction_log - READ:
accounts.postings— for transaction_id, posting_date, source_module that the EventBridge payload omits. - EventBridge: consumes
bank.core.posting_completed(andbank.core.interest_accrued) on thebank-corebus. - Secrets Manager:
bank-neon/{stage}/bank_core/app_user— Neon connection forbank_core_app_user(now reachable per the bank-platform3ad7498widening). - SSM (read):
/bank/{stage}/eventbridge/bank-core/arn/bank/{stage}/eventbridge/bank-core/dlq-arn/bank/{stage}/iam/lambda/bank-core/arn/bank/{stage}/observability/adot-nodejs-layer-arn/bank/{stage}/sns/alerts/arn
SSM outputs table¶
| Output | SSM path | Consumers |
|---|---|---|
| Read API base URL | /bank/{stage}/mod-002/api/base-url |
MOD-070 (transaction history & search), MOD-022 (payment audit), MOD-074 (back-office customer 360), MOD-018 (AML alert case mgmt) |
| Hash verification endpoint URL | /bank/{stage}/mod-002/verify/url |
Internal audit dashboards, MOD-076 alarms |
| Ingest Lambda ARN | /bank/{stage}/mod-002/consumer-lambda/arn |
MOD-043 (EventBridge governance — replay tooling) |
| Query Lambda ARN | /bank/{stage}/mod-002/query-lambda/arn |
(operational metrics target) |
| Verify Lambda ARN | /bank/{stage}/mod-002/verify-lambda/arn |
(operational metrics target) |
| Transaction log table name | /bank/{stage}/mod-002/transaction-log-table |
MOD-042 (CDC pipeline — Snowflake replication) |
Security and data handling¶
- No customer PII flows through MOD-002. UUIDs (
posting_id,account_id,trace_id) and money amounts only. - The
coreschema's append-only and RLS policies protect the audit record at the Postgres layer regardless of what code runs above. - Logger emits
party_id = nullper the observability standard; amounts go to structured fields, never free text.
Performance approach¶
- NFR-012 (write p99 ≤ 10 ms): a single INSERT into
core.transaction_logwith a hash compute. The hash is ~200 µs CPU; commit is the dominant factor. - NFR-013 (read p99 ≤ 5 ms):
idx_transaction_log_account_posting_dateis a covering index for the FR-051 keyset pagination shape. - The verifier runs at 1024 MB to give SHA-256 throughput headroom; rows-per-window will stay small.
- ADOT Node.js layer attached to all three Lambdas — HTTP, AWS SDK,
and pg spans flow into X-Ray automatically, correlated by
trace_idviasrc/lib/trace.ts.
Error handling¶
- Async ingest path — re-raises on
VALIDATION_FAILURE/TRANSIENT_INFRAso EventBridge's retry-then-DLQ takes over.POSTING_NOT_FOUND(race with MOD-001 commit) isVALIDATION_FAILUREbut should self-resolve on the first retry. - Sync HTTP paths — standard error envelope per the error-handling
standard (HTTP 422 / 503 / 500). Error codes:
INVALID_QUERY/DATE_RANGE_INVALID/ACCOUNT_NOT_FOUND/EVENT_REPLAY_IGNORED/POSTING_NOT_FOUND/INVALID_EVENT_PAYLOAD. - Scheduled verify path — re-raises on transient errors so the next scheduled invoke retries the same window. Hash mismatches don't raise; they emit a metric. The alarm is the page.
Event types emitted in structured logs¶
Registered in src/lib/logger.ts (EVENT_TYPES):
transaction_log_written— successful inserttransaction_log_replay_ignored— idempotent dedup hittransaction_log_query_served— successful HTTP readtransaction_log_verify_completed— successful hash check, no mismatchtransaction_log_hash_mismatch— FR-052 detection (alarm trips on EMF metric)trace_id_missing_from_upstream— observability standard WARNvalidation_failed/internal_error
Test approach¶
| Tier | Location | Count |
|---|---|---|
| Unit | tests/unit/ |
hash, logger, trace, errors, emf — 5 suites |
| Contract | tests/contract/ |
event consumer / query response / verify response — 3 suites |
| FR integration | tests/integration/fr-* |
8 (one per FR) + observability + NFR-024 |
| Policy satisfaction | tests/policy/ |
5 — one per policies_satisfied row |
Two test "shapes" per the dev environment's state:
- Schema-only tests — exercise migrations, triggers, RLS, indexes,
NOT NULL constraints. Use
insertSyntheticLogRowto plant a known row directly incore.transaction_log(the writer service is unit- tested in isolation). These run unconditionally when the database is reachable. - Consumer-path tests — exercise the full EventBridge → DB flow.
These need MOD-001's
accounts.postingsto exist (the consumer looks it up viaposting-fetch.ts). They skip cleanly via theconsumerPathDisabledguard if the table is missing on dev.
Run all tiers with pnpm test:unit (unit + contract, no AWS) and
pnpm test:integration (against deployed dev Neon).