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.accountsto addobr_frozen_amountandobr_available_amountcolumns. Runtime: V003 trigger fires on everyINSERT INTO accounts.postingsto enforce FR-635. - MOD-125 — joint-account
balance_share_pctis 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_activatedandbank.core.obr_unwoundfor customer notification. - MOD-104 — provides EventBridge bus ARN, KMS key, SNS alerts topic.
- MOD-103 — provides Neon and the
coreschema.
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 textactivated_at,activated_by,resolved_at,resolved_bylast_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 0CHECK ≥ 0obr_available_amount numeric(18,2) NOT NULL DEFAULT 0CHECK ≥ 0- Partial index
idx_accounts_obr_partition_activecovers 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_byidempotency_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:
- Read
core.obr_resolution_state.resolution_state— if not'activated', RETURN NEW immediately. Hot-path short-circuit. - First line after the state check:
IF NEW.entry_type = 'CREDIT' THEN RETURN NEW; END IF;— incoming credits never frozen (FR-635, spec). - 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 INTOaccounts.postings. The trigger function is in thecoreschema; 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_gateshort-circuits onresolution_state != 'activated'first; the very next line isIF NEW.entry_type = 'CREDIT' THEN RETURN NEW; END IF;. Only then isobr_available_amountconsulted. 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
BankResolutionOfficerRoleIAM principal — never invokable by*. Implementation:AuthType=AWS_IAM(default-deny on the Function URL) plus an identity-basedaws.iam.RolePolicyattached to the officer role grantinglambda:InvokeFunctionUrlon 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:AddPermissionrejects an IAM role ARN as Principal forlambda: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 UPDATEon 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_EMPTYand returns 500. Implemented asassertSingletonRowExists()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_eventsruns inside the same Pg transaction asSELECT … FOR UPDATEoncore.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_amountinstead ofavailable_balancewhen state isactivated. Channel rollout is out of v1 scope. OBR_PARTITION_RECONCILEDevent — schema includes the event_type but v1 doesn't emit it. A daily reconciliation pass againstaccounts.accounts.balancevsobr_frozen + obr_availableis a natural follow-up.