Skip to content

MOD-133 — Trust account management

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


1. Purpose

Manages opening, ongoing governance, and closure of trust accounts — family discretionary, testamentary, charitable, and commercial unit trusts. Trust accounts cannot activate until every trustee and every beneficial owner with ≥ 25% interest has individually completed eIDV via MOD-009 (read locally through accounts.kyc_status_mirror), enhanced CDD has been completed, and the trust deed reference is set on the trust record. Trustee/BO/risk changes trigger a CDD review with a 30-day SLA. All governance events are logged immutably.

2. Architecture

HTTP caller ── POST /internal/v1/trust-accounts/* ──> API Gateway ──> 7 Lambdas
                                                                     ├─ open-trust          (creates accounts.accounts in PENDING + core.trust_*)
                                                                     ├─ add-party           (enrolls a trustee/BO/appointor/protector/settlor)
                                                                     ├─ remove-party        (end-dates a relationship)
                                                                     ├─ activate            (runs the gate → calls MOD-007 transitionToActive)
                                                                     ├─ request-review      (CDD_REVIEW_TRIGGERED with due_at)
                                                                     ├─ complete-review     (CDD_REVIEW_COMPLETED linked back)
                                                                     └─ close               (ends parties + stamps closed_at)

Each handler runs in a single Postgres transaction:
  accounts.accounts          (PENDING insert / status transition through MOD-007)
  accounts.account_party_relationships  (TRUSTEE/BO/APPOINTOR/PROTECTOR/SETTLOR row)
  core.trust_accounts        (1:1 with accounts.accounts)
  core.trust_party_metadata  (1:1 overlay on relationship row)
  core.trust_governance_events   (immutable per-event log)

EventBridge bank-core (best effort, post-commit):
  bank.core.trust_account_activated v1
  bank.core.trustee_changed v1
  bank.core.beneficial_owner_changed v1
  bank.core.cdd_review_required v1
  bank.core.cdd_review_completed v1

EventBridge bank-platform (MOD-047 producer contract):
  agent.action_taken / staff.action_taken (governance audit trail)

KYC mirror (read-only):
  accounts.kyc_status_mirror — populated by MOD-007's existing
  bank.kyc.identity_verified consumer. MOD-133 does NOT add a
  second consumer (orchestrator correction 3).

Module type

Application Lambda module (hybrid). 7 handlers + 5 migrations.

State transition delegation (orchestrator Q4)

MOD-133's activate handler calls applyTransitionInClient(...) from @bank-core/mod-007-account-state-machine with reason_code: "TRUST_GATE_PASS". MOD-007 owns accounts.accounts.status transitions exclusively per SD01. The new reason code bypasses the single-party KYC gate in transition-engine-pure.ts (trust accounts have no single ACCOUNT_HOLDER party — every required party was verified by MOD-133's own gate before the transition is requested).

3. Data model — what MOD-133 owns

core.trust_accounts (V001) — 1:1 with accounts.accounts

Column Notes
trust_account_id uuid PK
account_id NOT NULL UNIQUE → accounts.accounts(id)
trust_name, trust_type trust_type ∈
trust_deed_document_id uuid; nullable in DDL, NOT NULL gate at activation Lambda (Q2) — cross-domain to MOD-073
established_date, jurisdiction jurisdiction ∈
enhanced_dd_completed, enhanced_dd_completed_at required true before activation (AML-004)
review_interval_months CHECK > 0; null = no periodic review configured
next_review_date computed from review_interval_months
activated_at stamped by activate Lambda
closed_at nullable; CHECK closed_at > activated_at (V004)

core.trust_party_metadata (V002) — overlay on relationship rows

Keyed 1:1 by relationship_id from accounts.account_party_relationships.

Column Notes
relationship_id PK FK → accounts.account_party_relationships(relationship_id)
trust_account_id FK to header
trust_role TRUSTEE / BENEFICIAL_OWNER / APPOINTOR / PROTECTOR / SETTLOR
ownership_pct numeric(5,2) CHECK [0, 100]; NULL for discretionary BOs and non-ownership roles
kyc_status denormalised cache; activation gate reads via accounts.kyc_status_mirror for source-of-truth

accounts.account_party_relationships.ownership_share_pct is the SDV/CRS field (joint-account aggregation) and is NOT used for trust ownership semantics — those live in this overlay table per orchestrator Q1.

core.trust_governance_events (V003) — append-only event log

Column Notes
event_id uuid PK
trust_account_id, account_id denormalised for read scans
party_id, relationship_id nullable; populated for per-party events
event_type CHECK enumerates 15 governance event types (see V003)
due_at populated iff event_type = CDD_REVIEW_TRIGGERED (FR-595 30-day SLA)
review_trigger populated iff CDD_REVIEW_TRIGGERED; one of TRUSTEE_CHANGE / BENEFICIARY_CHANGE / RISK_ELEVATION / PERIODIC
completes_event_id self-FK; populated iff CDD_REVIEW_COMPLETED
actor_kind, actor_id agent / staff / system (MOD-047 producer contract)
metadata, idempotency_key, trace_id, correlation_id standard envelope; idempotency_key UNIQUE

Coherence CHECKs in V003 enforce the per-event-type field-population rules; ADR-048 immutability triggers in V004 reject UPDATE/DELETE.

Extension to accounts.account_party_relationships (V001)

Drops + re-adds relationship_type CHECK to add APPOINTOR, PROTECTOR, SETTLOR to the existing nine values. Per AML rules and the SD01 SDV/CRS pattern, all controlling/associated trust persons go through this table — not a parallel core.trust_parties table.

Trust product codes (V005)

NZ_TRUST_01 (NZD) and AU_TRUST_01 (AUD), product_type = TRANSACTION (orchestrator Q3 — accruals not in scope v1; if a future module adds trust-account accrual, it'll seed a separate TRUST_SAVINGS product).

4. ADR-048 DB-enforced invariants register

Table Enforcement Migration Negative test
core.trust_governance_events Immutability triggers (UPDATE/DELETE/TRUNCATE) V004 tests/integration/db-trigger-trust-governance-events-immutable.test.ts
core.trust_accounts closed_at IS NULL OR closed_at > activated_at V004 tests/integration/db-check-trust-accounts-temporal.test.ts
core.trust_accounts review_interval_months IS NULL OR review_interval_months > 0 V001 column-level covered by ValidationFailure path in pure tests
core.trust_party_metadata ownership_pct IS NULL OR 0 ≤ ownership_pct ≤ 100 V002 column-level covered by activation-gate-pure tests
core.fee_schedule.notice_days >= 14 (already enforced by MOD-110 V001 / ADR-048) (out of scope for MOD-133)

Cat 3 (Lambda-only): activation gate (cross-service: reads accounts.kyc_status_mirror), 30-day CDD SLA (time-dependent + CloudWatch alarm), MOD-034 risk-elevated trigger (cross-service, not yet built).

5. Activation gate logic (pure)

evaluateActivationGate in src/services/activation-gate-pure.ts enforces:

  1. enhanced_dd_completed = true
  2. trust_deed_document_id IS NOT NULL
  3. At least one TRUSTEE row with end_date IS NULL
  4. Every TRUSTEE row's kyc_status_mirror.status = 'VERIFIED'
  5. Every BENEFICIAL_OWNER row with ownership_pct >= 25.0 has kyc_status_mirror.status = 'VERIFIED'

Discretionary BOs with ownership_pct IS NULL are required to be identified (row exists) but are not individually eIDV-gated; enhanced_dd_completed = true is the entity-level coverage that fills this gap (orchestrator Q6). 11 unit tests in tests/unit/activation-gate-pure.test.ts cover the cases.

6. CDD review SLA (FR-595)

computeReviewDueAt = triggered_at + 30 days. Stored in core.trust_governance_events.due_at. CloudWatch alarm queries TRIGGERED rows whose due_at is in the past with no matching COMPLETED row. v1: HTTP-triggered only (orchestrator Q5); the four trigger sources (trustee change, beneficiary change, risk elevation, periodic) all flow through POST /internal/v1/trust-accounts/{id}/ cdd-review/start with review_trigger in the body.

Auto-restriction on overdue v1 is out of scope — that's MOD-007 / MOD-034 territory. MOD-133 only records and alarms.

7. Events emitted (catalogue v1)

All five new event types are schema_version = "1" per the MOD-110 / MOD-008 precedent (string, not "1.0.0").

// bank.core.trust_account_activated v1
{
  "event_id": "uuid", "event_time": "ISO", "schema_version": "1",
  "tenant_id": "totara-bank",
  "trust_account_id": "uuid", "account_id": "uuid",
  "trust_type": "family_discretionary",
  "jurisdiction": "NZ",
  "activated_at": "ISO",
  "trace_id": "...", "correlation_id": "..."
}

// bank.core.trustee_changed v1  (change ∈ {ADDED, REMOVED})
// bank.core.beneficial_owner_changed v1
//   ↳ ownership_pct nullable; change ∈ {ADDED, CHANGED, REMOVED}
// bank.core.cdd_review_required v1
//   ↳ review_trigger ∈ {TRUSTEE_CHANGE, BENEFICIARY_CHANGE, RISK_ELEVATION, PERIODIC}
//   ↳ due_at populated
// bank.core.cdd_review_completed v1
//   ↳ triggered_event_id links back; next_review_date nullable

Contract tests in tests/contract/trust-events.test.ts materialise the zod schemas the published catalogue will use.

8. SSM outputs

Path Value
/bank/{stage}/mod-133/api/base-url API Gateway base URL
/bank/{stage}/mod-133/lambdas/{open,add-party,remove-party,activate,request-review,complete-review,close}/arn per-handler ARN
/bank/{stage}/mod-133/tables/trust-accounts/name core.trust_accounts
/bank/{stage}/mod-133/tables/trust-party-metadata/name core.trust_party_metadata
/bank/{stage}/mod-133/tables/trust-governance-events/name core.trust_governance_events

Upstream SSM read

Path Owner
/bank/{stage}/iam/lambda/bank-core/arn MOD-104
/bank/{stage}/observability/adot-nodejs-layer-arn MOD-076
/bank/{stage}/eventbridge/bank-core/arn MOD-104
/bank/{stage}/eventbridge/bank-platform/arn MOD-104 (MOD-047 target)
/bank/{stage}/sns/alerts/arn MOD-104

9. Cross-module touches

  • MOD-007 (account state machine) — small additive change:
  • ReasonCode union gains TRUST_GATE_PASS
  • transition-engine-pure.ts PENDING→ACTIVE branch bypasses the single-party KYC gate when reason_code === 'TRUST_GATE_PASS'
  • New src/index.ts barrel export so other bank-core modules can import applyTransitionInClient without reaching into src/
  • package.json gains "exports": { ".": "./src/index.ts" }
  • Two new MOD-007 unit tests cover the new branch (37 → 39)

  • No cross-module schema changes beyond V001's additive ALTER on accounts.account_party_relationships.relationship_type CHECK (covered above; metadata-only).

10. Policies satisfied

Policy Mode How satisfied Test
AML-002 GATE Activation rejects when any trustee or ≥25% BO is not VERIFIED in accounts.kyc_status_mirror. Negative test required. tests/policy/aml-002-gate-trustee-bo-eidv.test.ts
AML-004 GATE Activation rejects when enhanced_dd_completed = false for the trust record. tests/policy/aml-004-gate-enhanced-dd.test.ts
PRI-001 AUTO Each trust party is recorded as its own row in accounts.account_party_relationships + core.trust_party_metadata; no cross-party row sharing. tests/policy/pri-001-auto-per-person-row.test.ts
GOV-006 LOG core.trust_governance_events immutability — every governance event is append-only, UPDATE/DELETE/TRUNCATE rejected. tests/policy/gov-006-log-immutable-governance.test.ts

11. Test approach + results

Tier Files Result
Unit tests/unit/{activation-gate-pure, cdd-review-pure, errors, logger, emf}.test.ts 31 / 31
Contract tests/contract/trust-events.test.ts 7 / 7
FR integration tests/integration/fr-{593,594,595,596}-*.test.ts 14 / 14
ADR-048 negative tests/integration/db-{trigger,check}-*.test.ts 7 / 7
Policy tests/policy/{aml-002, aml-004, pri-001, gov-006}-*.test.ts 9 / 9

Combined MOD-133: 38 unit/contract + 30 integration/policy = 68/68.

12. Known limitations / follow-ups

  • MOD-073 not builttrust_deed_document_id is an opaque cross-domain UUID. When MOD-073 ships, the activate Lambda gains an FK validation step against the document vault. DDL doesn't change.
  • MOD-034 not built — FR-595 trigger (d) "AML risk rating elevated" is currently a manual call to request-review with review_trigger: "RISK_ELEVATION". When MOD-034 ships, subscribe to its risk-elevation event and call the same handler.
  • CDD review cron — orchestrator Q5: HTTP-triggered only v1. Future scheduled-runner queries core.trust_accounts for next_review_date <= today AND closed_at IS NULL and POSTs to the same endpoint with review_trigger: "PERIODIC".
  • CDD SLA breach auto-restriction — out of scope v1; alarm only. When MOD-007's restriction infrastructure can accept a restriction_reason='CDD_OVERDUE', an automated path becomes feasible.
  • Trust-specific fee schedules — MOD-110 currently has no NZ_TRUST_01 / AU_TRUST_01 schedules seeded; product manager fees apply once seeds are added.

13. Architectural decisions captured here

  • Single Postgres transaction per operation. Open / activate / add-party / remove-party / close / cdd-review-{start,complete} each commit account state + relationship rows + metadata + the governance log row in one transaction. Same precedent as MOD-110 fee_event + posting (ledger-direct-write).
  • Reuse accounts.kyc_status_mirror rather than re-subscribe (orchestrator correction 3). MOD-007's existing consumer is the single source of truth for per-party KYC; MOD-133 reads at activation time.
  • core.trust_party_metadata as overlay, not replacement (orchestrator Q1). The parent accounts.account_party_relationships row carries SDV / CRS / DCS semantics; the trust overlay carries trust-specific roles + ownership_pct.
  • Activation through MOD-007's shared service (orchestrator Q4). No EventBridge round-trip, no cross-Lambda HTTP. The TRUST_GATE_PASS reason code is the explicit handoff token.