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.postingsandaccounts.account_party_relationshipsto validate posting/customer ownership (AD-2). FK onposting_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 ofaccounts.transaction_overridesper 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-suppliedidempotency_keyrequest 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_satisfiedfor v1. - Minor deviation from idempotency advice (§5):
credit.idempotency_keysisn'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_relationgraceful-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.