Skip to content

MOD-166 — Transaction category corrections

System: SD01 Core Banking · Repo: bank-core Status: Built (pre-deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD01-core-banking/modules/MOD-166-transaction-category-corrections/ Driven by: docs/handoffs/processed/2026-05-08/MOD-041-needs-SD01-transaction-overrides-cdc.handoff.md + docs/handoffs/processed/2026-05-08/MOD-166-transaction-category-corrections-scope-approved.handoff.md Related ADRs: ADR-046, ADR-048, ADR-029, ADR-030, ADR-031, ADR-042


1. Purpose

Capture customer + back-office corrections to transaction categorisation per FR-239. The accounts.transaction_overrides table is the SD01-side source-of-truth for labelled corrections; MOD-041 (bank-risk-platform) reads it via the MOD-042 CDC pipeline for its weekly retrain.

Single Lambda, single migration, single endpoint. Append-only audit table — no UPDATE / DELETE / TRUNCATE path.

2. Architecture

HTTP POST /internal/v1/transactions/{posting_id}/overrides   ─▶ Mod166CreateOverrideHandler
   ├─ validateCreateOverrideShape()                          (validator-pure)
   ├─ assertCustomerOwnsPostingInClient()                    (AD-2 — 403 on mismatch)
   └─ insertOverrideInClient()                               (idempotent insert)

Plus a Function URL with AuthType=AWS_IAM for direct-IAM callers.

Cross-module integration

  • MOD-001 — read-only joins of accounts.postings and accounts.account_party_relationships to validate posting/customer ownership (AD-2). FK on posting_id.
  • MOD-041 — primary downstream consumer; reads via MOD-042 CDC. MOD-041 has a graceful-absence fallback so it operates on synthetic
  • bootstrap labels until this module ships and CDC is wired.
  • MOD-042 — CDC pipeline (bank-platform). A follow-up handoff (MOD-042-needs-transaction-overrides-cdc.handoff.md) requests inclusion of accounts.transaction_overrides per AD-3.

3. Data model

accounts.transaction_overrides (V001)

Column Type Constraints
override_id uuid PK DEFAULT public.gen_uuidv7()
posting_id uuid NOT NULL FK → accounts.postings(id)
customer_id uuid NOT NULL — validated server-side, not trusted from request
override_category_l1 text NOT NULL
override_category_l2 text nullable
override_source text NOT NULL CHECK ∈
idempotency_key text NOT NULL UNIQUE — see §4
trace_id uuid NOT NULL
correlation_id uuid NOT NULL
created_at timestamptz NOT NULL DEFAULT now()

Indexes: - uniq_transaction_overrides_idempotency_key UNIQUE - idx_transaction_overrides_posting_created on (posting_id, created_at DESC) — supports MOD-041's MAX(created_at) per posting retrain query - idx_transaction_overrides_customer_created on (customer_id, created_at DESC) — per-customer history

Privileges: - bank_core_app_user: SELECT + INSERT only (UPDATE/DELETE/TRUNCATE revoked) - bank_core_readonly: SELECT

4. ADR-048 Cat 1 immutability

trg_transaction_overrides_immutable (BEFORE UPDATE/DELETE/TRUNCATE) raises insufficient_privilege regardless of caller. Mirrors core.notice_events / core.obr_partition_events / loans.amortisation_events / treasury.tp_rates's pattern. Privileges also revoked at the GRANT level for defence in depth.

Negative tests in tests/integration/transaction-overrides-table-shape.test.ts: - UPDATE on any column rejected - DELETE rejected - TRUNCATE rejected

5. Idempotency — minor scope deviation from the orchestrator's handoff

The MOD-166 scope-approved handoff requested:

Use credit.idempotency_keys (shared store, same DB) keyed by the caller-supplied idempotency_key request header. module_id='MOD-166'.

credit.idempotency_keys actually lives in SD05's bank_credit Neon DB — provisioned by MOD-128 V002 — and isn't reachable from bank_core (cross-DB queries aren't supported in our Neon deployment). Same-DB shared idempotency stores in bank-core are limited to accounts.idempotency_keys (MOD-001 V002), which uses a different schema (composite key + 24h TTL) optimised for the posting flow.

Decision: fall back to the bank-core audit-table convention used by MOD-130 (core.notice_events.idempotency_key), MOD-143 (core.obr_partition_events.idempotency_key), MOD-112 (loans.amortisation_events.idempotency_key), and MOD-161 Transfer Pricing — a per-table idempotency_key text NOT NULL UNIQUE column with in-handler replay-detection. Functionally equivalent for an append-only audit endpoint; consistent with the other Cat 1 audit tables in bank-core.

If a future cross-module shared idempotency store lands in bank_core (e.g. core.idempotency_keys) we can migrate behind the same external interface in a single small follow-up.

6. AD-2 — server-side ownership validation

The handler MUST verify the caller's claimed customer_id actually holds the account that the posting_id belongs to. Implementation:

SELECT EXISTS(
  SELECT 1
    FROM accounts.postings p
    JOIN accounts.account_party_relationships r
      ON r.account_id = p.account_id
     AND r.relationship_type = 'ACCOUNT_HOLDER'
     AND (r.start_date IS NULL OR r.start_date <= current_date)
     AND (r.end_date   IS NULL OR r.end_date   >  current_date)
   WHERE p.id        = $1::uuid
     AND r.party_id  = $2::uuid
)

If the EXISTS check fails the handler returns HTTP 403 POSTING_NOT_OWNED. The error message is identical whether the posting doesn't exist OR exists but belongs to another customer — the caller should not be able to fish for posting IDs.

The validated customer_id (not the request body's claimed customer_id) is recorded on the row. In v1 they're identical because the validation passes only if they match; if a future variant allows admin overrides, the validated value lives on the row and the request body field becomes "claimed customer_id" for audit only.

7. SSM outputs

Path Value
/bank/{stage}/mod-166/api/base-url API GW base URL
/bank/{stage}/mod-166/function-urls/create-override Function URL (AWS_IAM)
/bank/{stage}/mod-166/lambdas/create-override/arn Lambda ARN
/bank/{stage}/mod-166/tables/transaction-overrides/name accounts.transaction_overrides

8. FR / policy mapping

FR Mode Implementation
FR-239 (transaction category corrections) gated endpoint + table + AD-2 ownership gate

No policies_satisfied for v1 per AD-5. REP-005 LOG is the candidate if the regulatory team decides this audit trail satisfies that policy later.

9. Test approach + results

Tier Files Local result
Unit tests/unit/{errors, override-validator-pure}.test.ts 16 / 16
Contract tests/contract/override-types.test.ts 4 / 4
Integration tests/integration/transaction-overrides-table-shape.test.ts (column shape, CHECK, immutability) run in CI

Local total: 20 / 20 unit + contract.

10. Architectural decisions captured here (orchestrator AD-1..AD-5)

  • AD-1 confirmed: standalone module ownership, not an extension to an existing module.
  • AD-2 confirmed + implemented: server-side ownership validation via accounts.account_party_relationships; HTTP 403 on mismatch; identical error message for "posting doesn't exist" vs "doesn't belong to claimed customer" (no information leak).
  • AD-3 deferred to follow-up: CDC inclusion is a bank-platform / MOD-042 task. Handoff filed at docs/handoffs/MOD-042-needs-transaction-overrides-cdc.handoff.md.
  • AD-4 confirmed: Cat 1 append-only triggers + REVOKE on bank_core_app_user.
  • AD-5 confirmed: no policies_satisfied for v1.
  • Minor deviation from idempotency advice (§5): credit.idempotency_keys isn't reachable from bank_core; using the bank-core audit-table convention (per-table idempotency_key UNIQUE column).

11. Known follow-ups

  • CDC inclusion — bank-platform / MOD-042 handoff filed; tracked in docs/handoffs/MOD-042-needs-transaction-overrides-cdc.handoff.md.
  • MOD-041 retrain unblocking — automatic once CDC lands; MOD-041's adapter.get_relation graceful-absence flips to live read with no code change.
  • Override category taxonomy — v1 accepts any string ≤ 64 chars for override_category_l1/l2. A future enhancement can add a reference table or CHECK enum once the categorisation taxonomy is formalised in the wiki.
  • Replace text-only category fields with FK — same as above; v1 is permissive for the model retrain to ingest any label the customer / agent chooses.