MOD-007 — Account state machine¶
System: SD01 Core Banking · Repo: bank-core Phase: 4 · Build status: Built (pre-deploy) Depends on: MOD-001, MOD-002, MOD-009, MOD-013, MOD-104, MOD-103 Date: 2026-04-30
Purpose¶
Owns accounts.accounts.status lifecycle. Five states — PENDING,
ACTIVE, RESTRICTED, DORMANT, CLOSED — and the transition table that
gates them. Specifically:
- KYC gate on PENDING → ACTIVE (FR-437, AML-002 GATE)
- Sanctions auto-restrict on
bank.kyc.sanctions_match_foundCONFIRMED_MATCH (FR-438, AML-007 GATE) - Fraud auto-restrict via STAFF transition with
restriction_reason=FRAUD_INVESTIGATION(PAY-005 GATE) - Compliance officer reinstatement RESTRICTED → ACTIVE requires
actor_type=STAFF+ non-emptystaff_rationale+ the underlying sanctions flag must be cleared (FR-440, AML-007 GATE — no agent override path) - Zero-balance close on → CLOSED (FR-439); active relationships end-dated in the same transaction
- Dormancy primitive (FR-441) — MOD-007 ships
markDormant(account_id), MOD-008 owns the cron that evaluates the jurisdiction threshold (NZ 12 mo / AU 7 yr)
Every transition writes an append-only history row (FR-070, NFR-024)
and emits bank.core.account_status_changed (FR-072).
State transition table¶
┌──────── PENDING
│ │ KYC_VERIFIED + actor_type=EVENT|STAFF
│ ▼
├──────── ACTIVE ────────────────┐
│ ▲ │
│ │ STAFF + rationale │
│ │ + cleared sanctions│
│ │ flag (FR-440) │
│ │ ▼
│ RESTRICTED DORMANT
│ │ │
│ │ │
▼ ▼ ▼
CLOSED ◄───────────────────────────────
(terminal; balance=0; cascades end_date on relationships)
restriction_reason (V004 cross-module additive ALTER on
accounts.accounts) is REQUIRED when status=RESTRICTED, NULL
otherwise. Permitted values: SANCTIONS, FRAUD_INVESTIGATION,
HARDSHIP_ARRANGEMENT, ADMIN. CHECK constraint
chk_restriction_reason_iff_restricted enforces the iff at the DB
level.
Declared account-status-write contract¶
MOD-007 owns the only writer to accounts.accounts.{status,
restriction_reason}. Every write goes through
services/transition-engine.ts → applyTransition(). Even cross-module
status updates (e.g. MOD-008 dormancy cron) call MOD-007 rather than
write directly. Reasons:
- Idempotency — every transition is recorded with a UNIQUE
idempotency_keyinaccount_state_history. Writingaccounts.accountsdirectly would skip the audit row and break replay safety. - Gate evaluation — KYC gate / staff rationale / balance check live in the engine. Direct writes bypass them.
- Coherence — V004's CHECK constraint requires
restriction_reasonto be non-null iffstatus='RESTRICTED'. Direct writes risk constraint violations from incomplete updates. - Eventing — every transition fans out
bank.core.account_status_changed. Direct writes wouldn't.
This is a sibling pattern to MOD-004's declared ledger-direct-write
contract — both are "this module owns the writer" guarantees that
deserve wiki adoption as pattern: state-machine-write.
Data model¶
Tables owned by MOD-007 (V001-V006)¶
| Table | Migration | Purpose |
|---|---|---|
accounts.account_state_history |
V001 | Append-only audit trail. UNIQUE idempotency_key. V006 trigger blocks UPDATE/DELETE/TRUNCATE (load-bearing because BankCoreRole has BYPASS RLS). |
accounts.kyc_status_mirror |
V002 | One row per party_id with the latest KYC outcome from MOD-009. Mutable upsert; out-of-order delivery filtered (WHERE EXCLUDED.verified_at >= existing.verified_at). |
accounts.sanctions_flags |
V003 | One row per account_id with active sanctions flag. Mutable; cleared by the FR-440 reinstatement path. |
Cross-module additive ALTERs (V004, V005)¶
| Migration | Target | Change |
|---|---|---|
| V004 | accounts.accounts |
Add restriction_reason text (nullable, CHECK in SANCTIONS / FRAUD_INVESTIGATION / HARDSHIP_ARRANGEMENT / ADMIN) + iff CHECK constraint |
| V005 | accounts.postings (trigger) |
BEFORE INSERT — block DEBIT against RESTRICTED + any leg against CLOSED. Defence-in-depth backstop for MOD-001's app-level guard. |
Cross-module additive ALTER pattern matches MOD-003 V002 (added
accounts.accounts.version) and MOD-004 V003+V007 (added
accounts.postings.fx_conversion_id and
accounts.accounts.is_internal).
Lambdas¶
| Function | Trigger | Purpose |
|---|---|---|
Mod007TransitionHandler |
HTTP POST /internal/v1/accounts/{account_id}/transition |
Explicit transitions (ops + onboarding fallback) |
Mod007KycIngest |
EventBridge bank.kyc.identity_verified (cross-bus from bank-kyc) |
Upserts KYC mirror; auto-activates PENDING accounts on VERIFIED |
Mod007SanctionsIngest |
EventBridge bank.kyc.sanctions_match_found (cross-bus from bank-kyc) |
Flags accounts; auto-restricts on CONFIRMED_MATCH |
Mod007MarkDormant |
Direct invoke (target for MOD-008's cron when it lands) | DORMANT primitive |
EventBridge¶
Consumes (cross-bus from bank-kyc):
- bank.kyc.identity_verified
- bank.kyc.sanctions_match_found
Publishes (bank-core bus):
- bank.core.account_status_changed — every committed (non-noop) transition.
MOD-002's ingest rule widened in this PR to consume it (1-line
change, same precedent as MOD-004).
SSM outputs¶
| Path | Type | Purpose |
|---|---|---|
/bank/{stage}/mod-007/api/base-url |
String | HTTP API root |
/bank/{stage}/mod-007/transition/url |
String | Convenience URL |
/bank/{stage}/mod-007/state-history-table |
String | accounts.account_state_history |
/bank/{stage}/mod-007/kyc-mirror-table |
String | accounts.kyc_status_mirror |
/bank/{stage}/mod-007/sanctions-flags-table |
String | accounts.sanctions_flags |
/bank/{stage}/mod-007/transition-lambda/arn |
String | Transition Lambda ARN |
/bank/{stage}/mod-007/kyc-ingest-lambda/arn |
String | KYC consumer ARN |
/bank/{stage}/mod-007/sanctions-ingest-lambda/arn |
String | Sanctions consumer ARN |
/bank/{stage}/mod-007/mark-dormant-lambda/arn |
String | Direct-invoke target (for MOD-008) |
Configuration¶
| Env | Default | Purpose |
|---|---|---|
DORMANCY_MONTHS_NZ |
12 | FR-441 NZ Unclaimed Money Act 1971 |
DORMANCY_YEARS_AU |
7 | FR-441 AU Banking Act 1959 |
MAX_RATE_AGE_HOURS |
n/a | (MOD-004 carries its own; MOD-007 doesn't read rates) |
Policy mapping¶
| Policy | Mode | How satisfied | Test |
|---|---|---|---|
| AML-002 | GATE | KYC gate in transition-engine-pure.ts rejects PENDING → ACTIVE when mirror is not VERIFIED. No actor_type override. |
tests/policy/aml-002-gate-kyc-required-for-active.test.ts |
| AML-007 | GATE | Sanctions consumer auto-restricts on CONFIRMED_MATCH; reinstatement requires cleared flag (FR-440 + sanctions_flag check). | tests/policy/aml-007-gate-sanctions-auto-restrict.test.ts |
| PAY-005 | GATE | Fraud-team transition (manual) sets restriction_reason=FRAUD_INVESTIGATION; V005 trigger + MOD-001 guard block subsequent DEBITs. |
tests/policy/pay-005-gate-fraud-restrict-blocks-debits.test.ts |
| (CON-008) | n/a | Removed from MOD-007 yaml per orchestrator A2 — CON-008 is about pricing/fees (the body has been replaced in the wiki). Hardship is governed by MOD-116/117/139. |
Cross-bus IAM follow-up¶
MOD-007 creates EventBridge rules ON the bank-kyc bus targeting
its own Lambdas. The deploy role used by mod-007.yml needs
events:PutRule + events:PutTargets + events:DeleteRule +
events:RemoveTargets permission on arn:aws:events:*:*:event-bus/bank-kyc.
If absent, expect a deploy-stage failure of the form:
User: ... is not authorized to perform: events:PutRule on resource:
arn:aws:events:.../event-bus/bank-kyc. MOD-104 follow-up: widen
the deploy role's events:* resource list to include the bank-kyc
bus ARN. One-line change. (Same shape as the Lambda concurrency
fix we landed for MOD-004.)
Capability scope¶
The yaml originally listed CAP-052 (term deposit states), CAP-088 (signatory permissions), CAP-089 (parent-child accounts), and CAP-014 (savings pots) on MOD-007. Per orchestrator A6 these are all deferred:
- CAP-052 → MOD-111 (Term deposit maturity engine, phase 6)
- CAP-088, CAP-089, CAP-014 → future dedicated modules
MOD-007 v1 ships only the core 5-state machine + the four transitions enumerated above.
Known follow-ups for subsequent modules¶
- MOD-008 dormancy cron wires
markDormanton the schedule. The primitive is here; the trigger isn't. - CON-008 hardship enforcement lives in MOD-116/117/139.
restriction_reason='HARDSHIP_ARRANGEMENT'is the structural piece these modules will set on the accounts row when a hardship arrangement starts. - Account-state webhook — FR-072 says "webhook OR event"; we ship
events only. If a webhook is needed for a partner integration, it
goes in a future bridge module that subscribes to
bank.core.account_status_changed. - MOD-002 ingest of
account_status_changed— MOD-002's V001 schema forcore.transaction_logwas designed for posting events. Status-change events have noposting_id/amount. MOD-002's ingest handler will need a small adaptation when the first event flows through; flag for the MOD-002 owner.