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:
enhanced_dd_completed = truetrust_deed_document_id IS NOT NULL- At least one TRUSTEE row with
end_date IS NULL - Every TRUSTEE row's
kyc_status_mirror.status = 'VERIFIED' - Every BENEFICIAL_OWNER row with
ownership_pct >= 25.0haskyc_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:
ReasonCodeunion gainsTRUST_GATE_PASStransition-engine-pure.tsPENDING→ACTIVE branch bypasses the single-party KYC gate whenreason_code === 'TRUST_GATE_PASS'- New
src/index.tsbarrel export so other bank-core modules can importapplyTransitionInClientwithout reaching intosrc/ package.jsongains"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_typeCHECK (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 built —
trust_deed_document_idis 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-reviewwithreview_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_accountsfornext_review_date <= today AND closed_at IS NULLand POSTs to the same endpoint withreview_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_01schedules 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_mirrorrather 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_metadataas overlay, not replacement (orchestrator Q1). The parentaccounts.account_party_relationshipsrow 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_PASSreason code is the explicit handoff token.