Skip to content

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_core on 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 (and bank.core.interest_accrued) on the bank-core bus.
  • Secrets Manager: bank-neon/{stage}/bank_core/app_user — Neon connection for bank_core_app_user (now reachable per the bank-platform 3ad7498 widening).
  • 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 core schema's append-only and RLS policies protect the audit record at the Postgres layer regardless of what code runs above.
  • Logger emits party_id = null per 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_log with 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_date is 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_id via src/lib/trace.ts.

Error handling

  • Async ingest path — re-raises on VALIDATION_FAILURE / TRANSIENT_INFRA so EventBridge's retry-then-DLQ takes over. POSTING_NOT_FOUND (race with MOD-001 commit) is VALIDATION_FAILURE but 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 insert
  • transaction_log_replay_ignored — idempotent dedup hit
  • transaction_log_query_served — successful HTTP read
  • transaction_log_verify_completed — successful hash check, no mismatch
  • transaction_log_hash_mismatch — FR-052 detection (alarm trips on EMF metric)
  • trace_id_missing_from_upstream — observability standard WARN
  • validation_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 insertSyntheticLogRow to plant a known row directly in core.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.postings to exist (the consumer looks it up via posting-fetch.ts). They skip cleanly via the consumerPathDisabled guard 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).