Skip to content

MOD-143 — Open Bank Resolution pre-positioning

System: SD01 Core Banking · Repo: bank-core · Phase: 6 Status: Built (pre-deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD01-core-banking/modules/MOD-143-obr-pre-positioning/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048


1. Purpose

Pre-position the platform so a deploying deposit taker can execute an RBNZ Open Bank Resolution event in compliance with the RBNZ DTA OBR Pre-positioning Standard (Deposit Takers Act 2023). The platform must remain at resolution_state = pre_positioned continuously in production, and on RBNZ instruction must atomically partition every deposit account into a frozen + available split, switch channels into restricted mode, and unwind safely when the regulator issues a resolution-end notice.

2. Architecture

HTTP POST <activate Function URL>           ─▶ Mod143ActivateObrHandler   (AWS_IAM, BankResolutionOfficerRole only)
HTTP POST <unwind Function URL>             ─▶ Mod143UnwindObrHandler     (AWS_IAM, BankResolutionOfficerRole only)
HTTP GET  /internal/v1/obr/resolution-state ─▶ Mod143GetResolutionStateHandler   (RBNZ verification + ops + smoke)
HTTP GET  /internal/v1/obr/partition-events ─▶ Mod143ListPartitionEventsHandler  (regulatory record listing)

4 Lambdas (4 HTTP, no scheduled handler). The two write paths (activate / unwind) are NOT exposed via API Gateway — they are reachable only via Function URLs whose resource policy restricts invocation to BankResolutionOfficerRole.

Cross-module integration

  • MOD-001 — V001 ALTERs accounts.accounts to add obr_frozen_amount and obr_available_amount columns. Runtime: V003 trigger fires on every INSERT INTO accounts.postings to enforce FR-635.
  • MOD-125 — joint-account balance_share_pct is read at activation to apportion the haircut per holder. v1 partition operates on the account-level balance directly; per-holder split is documented as v2 follow-up under §13.
  • MOD-063 — consumes bank.core.obr_activated and bank.core.obr_unwound for customer notification.
  • MOD-104 — provides EventBridge bus ARN, KMS key, SNS alerts topic.
  • MOD-103 — provides Neon and the core schema.

3. Data model

core.obr_resolution_state (V001) — singleton

  • id text PK CHECK (id='current')
  • resolution_state text ∈ {normal, pre_positioned, activated, resolved}
  • haircut_pct numeric(5,4) (NULL outside activated/resolved)
  • rbnz_instruction_ref text
  • activated_at, activated_by, resolved_at, resolved_by
  • last_trace_id, last_correlation_id, updated_at
  • Coherence CHECKs: activation snapshot fields populated iff state IN (activated, resolved); resolved fields populated iff resolved.

accounts.accounts ALTER (V001)

  • obr_frozen_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK ≥ 0
  • obr_available_amount numeric(18,2) NOT NULL DEFAULT 0 CHECK ≥ 0
  • Partial index idx_accounts_obr_partition_active covers the active partition population (where frozen > 0).

core.obr_partition_events (V002) — full append-only

  • event_id PK, event_type ∈ {OBR_ACTIVATED, OBR_UNWOUND, OBR_PARTITION_RECONCILED}
  • status ∈ {pre_positioned, activated, resolved}
  • haircut_pct, rbnz_instruction_ref, accounts_partitioned, total_frozen_amount, total_available_amount, activated_by
  • idempotency_key UNIQUE NOT NULL, trace_id, correlation_id, occurred_at
  • UPDATE / DELETE / TRUNCATE all rejected by trigger; UPDATE/DELETE/TRUNCATE privileges revoked from bank_core_app_user.

V004 seed

Inserts the singleton row with resolution_state='pre_positioned' via ON CONFLICT (id) DO NOTHING. The AD-4 startup check refuses to operate if this row is missing.

4. ADR-048 DB-enforced invariants register

Invariant Migration Negative test
fn_posting_obr_channel_gate (FR-635 channel gate; AD-1 short-circuit ordering) V003 tests/integration/fr-635-channel-gate.test.ts, tests/policy/ops-001-auto-partition-and-channel.test.ts
trg_obr_resolution_state_guard (semi-permissive transition graph; DELETE/TRUNCATE blocked) V003 tests/integration/db-trigger-state-transition.test.ts
trg_obr_partition_events_no_{update,delete,truncate} (full append-only; NFR-024 / REP-001 LOG) V002 tests/integration/db-trigger-events-immutable.test.ts, tests/policy/rep-001-log-partition-events.test.ts
chk_activated_fields_iff_active, chk_resolved_fields_iff_resolved (CHECK) V001 covered by state-store integration
uniq_obr_partition_events_idempotency_key (UNIQUE) V002 covered by activate-obr replay tests
Singleton CHECK (id='current') on resolution-state row V001 covered by V004 ON CONFLICT idempotency

AD-1 — channel-gate trigger structure (orchestrator-mandated)

core.fn_posting_obr_channel_gate MUST be ordered:

  1. Read core.obr_resolution_state.resolution_state — if not 'activated', RETURN NEW immediately. Hot-path short-circuit.
  2. First line after the state check: IF NEW.entry_type = 'CREDIT' THEN RETURN NEW; END IF; — incoming credits never frozen (FR-635, spec).
  3. Only then load accounts.accounts.obr_available_amount, sum running activation-window debits, and reject if breach.

Verbatim implementation lives in V003.

5. FR mapping

FR Mode Implementation
FR-633 (resolution-state flag with values + default + audit) gated core.obr_resolution_state singleton; activate-obr handler holds SELECT … FOR UPDATE row lock; transition guard trigger; every transition appends to core.obr_partition_events with operator + RBNZ ref + timestamp
FR-634 (atomic partition across deposit accounts) structural applyPartitionInClient() runs as a single Pg transaction — SELECT … FOR UPDATE on the deposit-account population, per-row UPDATE, aggregate sums, partition-event INSERT — all in one tx; partial completion impossible
FR-635 (restricted channel; OBR_FROZEN_BALANCE rejection on breach; credits continue) structural V003 fn_posting_obr_channel_gate BEFORE INSERT trigger on accounts.postings — AD-1 short-circuit ordering: state check → CREDIT short-circuit → breach check
FR-636 (atomic unwind on RBNZ end notice) structural unwindPartitionInClient() runs in one Pg transaction — clear partition columns on every deposit account + stamp resolution_state activated → resolved + append OBR_UNWOUND event

6. Policies satisfied

Policy Mode How satisfied Test
REP-001 LOG Every activation, partition execution, and resolution-exit appended to core.obr_partition_events with operator + RBNZ ref + timestamp + aggregates; UPDATE/DELETE/TRUNCATE all rejected by trigger; privileges revoked tests/policy/rep-001-log-partition-events.test.ts
OPS-001 AUTO Activation transaction stamps account partition columns AND state row in one Pg transaction; FR-635 channel gate engages atomically; OPS-001 test asserts both halves visible together tests/policy/ops-001-auto-partition-and-channel.test.ts

7. SSM outputs

Path Value
/bank/{stage}/mod-143/api/base-url API GW base URL (read endpoints)
/bank/{stage}/mod-143/function-urls/activate-obr Function URL — AWS_IAM, BankResolutionOfficerRole only
/bank/{stage}/mod-143/function-urls/unwind-obr Function URL — AWS_IAM, BankResolutionOfficerRole only
/bank/{stage}/mod-143/lambdas/{activate-obr,unwind-obr,get-resolution-state,list-partition-events}/arn per-handler ARN
/bank/{stage}/mod-143/tables/{obr-resolution-state,obr-partition-events}/name table FQNs

SSM inputs

Path Source Purpose
/bank/{stage}/iam/bank-resolution-officer/arn external (see §10) Function URL invoke principal
/bank/{stage}/iam/lambda/bank-core/arn MOD-104 Lambda execution role
/bank/{stage}/observability/adot-nodejs-layer-arn MOD-104 ADOT layer
/bank/{stage}/eventbridge/bank-core/arn MOD-104 outbound events bus
/bank/{stage}/eventbridge/bank-platform/arn MOD-104 platform bus (MOD-063 consumer)
/bank/{stage}/sns/alerts/arn MOD-104 CloudWatch alarm sink
/bank/{stage}/neon/direct-host MOD-103 Postgres host (flyway)

8. Cross-module touches

  • accounts.accounts (MOD-001 schema) — V001 ALTER adds two partition columns; V003 trigger fires on every INSERT INTO accounts.postings. The trigger function is in the core schema; no source-code changes to MOD-001.
  • No other module's tables are written.

9. Test approach + results

Tier Files Local result
Unit tests/unit/{partition-validator-pure, partition-calc-pure, errors, logger, emf}.test.ts 32 / 32
Contract tests/contract/obr-events.test.ts 2 / 2
FR integration tests/integration/fr-{633,634,635,636}-*.test.ts run in CI
ADR-048 negative tests/integration/db-trigger-{events-immutable,state-transition}.test.ts run in CI
Policy tests/policy/{rep-001-log-partition-events, ops-001-auto-partition-and-channel}.test.ts run in CI

Local total: 34 / 34 unit + contract.

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

  • AD-1 (corrected): channel-gate trigger ordering. fn_posting_obr_channel_gate short-circuits on resolution_state != 'activated' first; the very next line is IF NEW.entry_type = 'CREDIT' THEN RETURN NEW; END IF;. Only then is obr_available_amount consulted. Verbatim in V003.
  • AD-2: schema names. core.obr_resolution_state, core.obr_partition_events, accounts.accounts.obr_frozen_amount, accounts.accounts.obr_available_amount.
  • AD-3 (corrected): three hard requirements on the activate path.
  • Function URL invocation is restricted to the BankResolutionOfficerRole IAM principal — never invokable by *. Implementation: AuthType=AWS_IAM (default-deny on the Function URL) plus an identity-based aws.iam.RolePolicy attached to the officer role granting lambda:InvokeFunctionUrl on those specific function ARNs and only those. No other identity in the account holds that grant; combined with the default-deny resource auth, only the officer role can invoke. This is a mechanical deviation from the AD-3 wording (which said "resource- based policy") forced by an AWS Lambda API constraint: lambda:AddPermission rejects an IAM role ARN as Principal for lambda:InvokeFunctionUrl ("InvalidParameterValueException: The provided principal was invalid", CI run 25466539785). The effective security outcome — only the officer role can invoke — is identical.
  • Idempotency-first guard (with ALREADY_ACTIVATED state precondition) runs inside the same Pg transaction that holds SELECT … FOR UPDATE on the singleton state row.
  • Compliance-debt callout (this section, §11.1).
  • AD-4: startup state-table assertion. Every handler invocation re-asserts that the singleton row exists; on miss, raises OBR_STATE_TABLE_EMPTY and returns 500. Implemented as assertSingletonRowExists() and called from every handler before lock acquisition.
  • AD-5 (corrected): NOTICE_PENDING-restricted accounts ARE partitioned; document precedence. OBR partition takes precedence over notice restrictions — when activated, every non-internal account participates in the partition regardless of restriction_reason. The MOD-130 notice-pending block applies only when state is pre_positioned. This is the documented OBR > NOTICE_PENDING precedence.
  • AD-6 (corrected): jurisdiction is hard-coded "NZ" on outbound events; document AU/APRA gap. OBR is a NZ-specific RBNZ regime under DTA 2023. An equivalent APRA statutory-management regime exists for AU but is not in v1 scope. See §11.2.
  • AD-7 (corrected): TOCTOU-free idempotency. Idempotency key check on core.obr_partition_events runs inside the same Pg transaction as SELECT … FOR UPDATE on core.obr_resolution_state. The lock on the singleton row guarantees no concurrent activator can race past our idempotency check before our state UPDATE commits.
  • AD-8: bank's internal playbook is the v1 cryptographic-verification control. v1 does NOT cryptographically verify the RBNZ instruction payload. The bank's internal playbook (CEO/CFO pre-call verification) is the v1 control. See §11.1.

11. Compliance debt — explicitly documented under RBNZ DTA OBR Pre-positioning Standard

These gaps are intentional v1 limitations. Each one is named here so the RBNZ examination team can see what is and isn't in scope, and so the internal compliance team can route the gap into the v2 backlog rather than discovering it in production.

11.1 No cryptographic verification of the RBNZ instruction payload

The activate-obr handler accepts rbnz_instruction_ref as an opaque string and does NOT verify a digital signature on the payload. The v1 trust model is:

  • The Function URL is reachable only via a SigV4-signed request from BankResolutionOfficerRole (resource policy, §7).
  • The bank's internal playbook requires CEO/CFO pre-call verification of the RBNZ instruction (out of MOD-143 scope; AD-8).
  • The audit trail (core.obr_partition_events.activated_by + RBNZ ref + timestamp) is immutable post-fact.

The RBNZ DTA OBR Pre-positioning Standard does not strictly mandate cryptographic verification at v1 — but a production deposit taker would typically want it in place before live operation. v2 plan: PKI-signed instruction payload + on-handler verification before the state row is unlocked.

11.2 Jurisdiction hard-coded to "NZ"; no APRA statutory-management equivalent

The outbound bank.core.obr_activated and bank.core.obr_unwound events both carry jurisdiction: "NZ" as a literal. AU's APRA statutory-management regime is structurally different (per-deposit guarantee with a different cap, different operator, different trigger-pull mechanics). v1 explicitly does not handle the AU regime.

v2 plan: introduce a core.obr_resolution_jurisdiction discriminator on the singleton row; emit per-jurisdiction events; reuse the partition arithmetic but vary the operational controls.

11.3 No signed status endpoint

The spec calls for "Verification that resolution_state = pre_positioned is available to the RBNZ on demand via a signed status endpoint." The v1 GET /internal/v1/obr/resolution-state returns the JSON state but the response body is not signed. The endpoint sits behind the read API Gateway and is reachable to any caller with stage credentials.

v2 plan: detached JWS over the response body using a KMS-managed RBNZ-known public key; rotate quarterly.

11.4 No per-holder partition for joint accounts (MOD-125 split)

v1 partition operates on the account-level balance. MOD-125 records balance_share_pct per holder; a true per-depositor SDV-correct partition would apply the haircut per share before aggregation. v1 treats joint accounts as if owned by a single notional depositor; the practical SDV outcome differs from the spec's per-holder rule.

v2 plan: extend applyPartitionInClient() to expand joint accounts into per-holder rows from MOD-125 first, then apply the haircut per holder, then re-aggregate.

11.5 No credit-loss posting on permanent haircut at unwind

FR-636 calls for permanent-haircut write-off amounts confirmed by RBNZ to be posted as credit-loss entries in MOD-001 against the institution's credit-loss GL account. v1's unwind-obr handler only clears the partition columns and records the unwind. The permanent-haircut credit- loss path is not implemented in v1.

v2 plan: introduce a permanent_haircut_amount column on the unwind path; post credit-loss legs to the GL account via the ledger-direct-write contract; record the postings in core.obr_partition_events.metadata.

12. Required wiki updates (apply via separate bank-wiki commit)

12.1 SD01 data model

Add core.obr_resolution_state (singleton) and core.obr_partition_events (append-only) under the core schema. Document the accounts.accounts ALTER adding obr_frozen_amount and obr_available_amount (MOD-143 owns writes; MOD-001 owns the table).

12.2 ADR-048 register additions

See §4 — two new triggers (fn_posting_obr_channel_gate, trg_obr_resolution_state_guard) and three append-only triggers on core.obr_partition_events.

12.3 Event catalogue

DetailType Producer Consumers
bank.core.obr_activated MOD-143 MOD-063 (customer notification)
bank.core.obr_unwound MOD-143 MOD-063 (customer notification)

Both schema_version = "1"; both carry jurisdiction: "NZ" (AD-6).

12.4 BankResolutionOfficerRole IAM role (module-managed by default)

A BankResolutionOfficerRole IAM role is REQUIRED for the activate / unwind Function URL invocations. v1 default: MOD-143 provisions the role itself in each stage as bank-{stage}-MOD-143-resolution-officer and writes its ARN to /bank/{stage}/iam/bank-resolution-officer/arn.

Production institutions with an existing externally-managed officer role can override by pre-provisioning the SSM parameter pointing at their role's ARN before MOD-143's first deploy; v1 still creates the internal role and overwrites the SSM (the read-vs-create switch is a v2 follow-up). The internal role's trust policy allows sts:AssumeRole from the AWS account root only — production deployers who want SSO / specific-identity restrictions should override.

13. Verification results (today)

Gate Result
pnpm install clean
pnpm typecheck MOD-143 clean
pnpm test:unit MOD-143 34 / 34
pnpm test:integration MOD-143 (run in CI under RUN_INTEGRATION=1)
MOD-143 V001-V004 migrate against dev Neon (run by reusable-lambda.yml)

14. Known follow-ups (v2)

  • Cryptographic instruction verification (§11.1)
  • AU/APRA statutory-management regime (§11.2)
  • Signed status endpoint (§11.3)
  • Per-holder joint-account partition via MOD-125 (§11.4)
  • Credit-loss postings for permanent haircut at unwind (§11.5)
  • MOD-063 OBR notification template — outside MOD-143 scope; the deploying institution must supply the OBR communication-plan template.
  • MOD-068/MOD-069/MOD-129 channel UI — channel surfaces must consume obr_available_amount instead of available_balance when state is activated. Channel rollout is out of v1 scope.
  • OBR_PARTITION_RECONCILED event — schema includes the event_type but v1 doesn't emit it. A daily reconciliation pass against accounts.accounts.balance vs obr_frozen + obr_available is a natural follow-up.