Skip to content

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_found CONFIRMED_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-empty staff_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:

  1. Idempotency — every transition is recorded with a UNIQUE idempotency_key in account_state_history. Writing accounts.accounts directly would skip the audit row and break replay safety.
  2. Gate evaluation — KYC gate / staff rationale / balance check live in the engine. Direct writes bypass them.
  3. Coherence — V004's CHECK constraint requires restriction_reason to be non-null iff status='RESTRICTED'. Direct writes risk constraint violations from incomplete updates.
  4. 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 markDormant on 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 for core.transaction_log was designed for posting events. Status-change events have no posting_id / amount. MOD-002's ingest handler will need a small adaptation when the first event flows through; flag for the MOD-002 owner.