MOD-125 — Joint account management¶
System: SD01 Core Banking · Repo: bank-core · Phase: 5 Status: Built (pre-deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD01-core-banking/modules/MOD-125-joint-account-management/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048
1. Purpose¶
Manages multi-holder personal accounts ("joint accounts") — two or more natural-person customers sharing a single deposit account. Distinct from MOD-133 (trust accounts: a legal entity holding for beneficiaries) and MOD-134 (community accounts: an entity holding on behalf of an unincorporated body). The module:
- Enrols ≥ 2 holders, each individually KYC-verified and consenting.
- Activates the account through MOD-007 once all four FR-565 gates pass.
- Runs a per-action multi-holder authorisation service (FR-566) for payments, holder mutations, and signing-authority changes.
- Records ownership shares on
accounts.account_party_relationshipsfor the NZ Depositor Compensation Scheme (DCS) Single Depositor View (SDV) report (FR-567 / REP-007). - Handles the death-of-a-holder lifecycle: freeze on notification, unfreeze on documentation acceptance (FR-568).
2. Architecture¶
HTTP caller ── /internal/v1/joint-accounts/* ──> API Gateway ──> 12 Lambdas
│
├─ open (POST /joint-accounts)
├─ activate → MOD-007 with JOINT_GATE_PASS
├─ add-holder ──┐
├─ remove-holder ──┤ FR-566 multi-holder auth
├─ change-signing-authority ──┘
├─ create-authorisation ──┐
├─ record-approval ──┘ FR-566 service
├─ record-consent (FR-565 per-holder consent)
├─ record-death ──┐
├─ accept-death-documentation ──┘ FR-568
├─ get-share-apportionment (FR-567 / REP-007)
└─ close
Each handler runs in a single Postgres transaction:
accounts.accounts (PENDING insert / status transition through MOD-007)
accounts.account_party_relationships (relationship_type='JOINT_HOLDER';
ownership_share_pct numeric(7,4))
core.joint_accounts (1:1 with accounts.accounts)
core.joint_holder_metadata (1:1 overlay carrying is_primary, holder_status, consent)
core.joint_authorisations (FR-566 — frozen snapshot at create)
core.joint_authorisation_approvals (1 row per holder approval; UNIQUE)
core.joint_governance_events (immutable per-event log)
EventBridge bank-core (best effort, post-commit):
bank.core.joint_account_activated v1
bank.core.joint_holder_changed v1 (ADDED | REMOVED | DECEASED)
bank.core.joint_signing_authority_changed v1
bank.core.joint_share_adjusted v1 (reserved for future server-driven re-allocation)
bank.core.joint_authorisation_completed v1
bank.core.joint_holder_death_recorded v1
KYC mirror (read-only):
accounts.kyc_status_mirror — populated by MOD-007's existing
bank.kyc.identity_verified consumer. MOD-125 does NOT add a
second consumer (orchestrator confirmation 3 carries forward).
Module type¶
Application Lambda module. 12 handlers.
State transition delegation (orchestrator Q3)¶
- PENDING → ACTIVE: MOD-125's
activatehandler runs the per-holder consent + KYC + sum-to-100 gate, then callsapplyTransitionInClient(...)withreason_code: 'JOINT_GATE_PASS'. The pure validator (extended in this build) bypasses its single-party KYC mirror check when the reason code isJOINT_GATE_PASS(alongside the existingTRUST_GATE_PASS/COMMUNITY_GATE_PASScases) — joint accounts have no single ACCOUNT_HOLDER party_id.
3. Data model¶
core.joint_accounts (V001) — 1:1 with accounts.accounts¶
| Column | Notes |
|---|---|
joint_account_id |
uuid PK |
account_id |
NOT NULL UNIQUE → accounts.accounts(id) |
signing_authority |
NOT NULL ∈ |
jurisdiction |
NOT NULL ∈ |
death_documentation_status |
NOT NULL ∈ {none, frozen, accepted}; default 'none' |
death_documentation_id |
uuid; opaque MOD-073 reference; nullable |
activated_at |
stamped on activation |
closed_at |
stamped on close; CHECK > activated_at (V005) |
tenant_id |
denormalised |
V005 ADR-048 CHECK: closed_at IS NULL OR closed_at > activated_at.
core.joint_holder_metadata (V002) — overlay on relationship rows¶
Keyed 1:1 by relationship_id. Parent
accounts.account_party_relationships.relationship_type is always
'JOINT_HOLDER' (no CHECK extension required — already in MOD-001
V005's domain). Joint-account-specific fields live on the overlay so
the parent table stays SDV/CRS-shared. ownership_share_pct lives
on the parent (not the overlay) — orchestrator Q1 — so existing
SDV / CRS reporting can read it without joining to a domain-specific
overlay.
| Column | Notes |
|---|---|
relationship_id |
PK FK |
joint_account_id |
FK to header |
is_primary |
informational marker for the inviter; not a legal/banking concept |
holder_status |
NOT NULL ∈ |
deceased_at / removed_at / removal_reason |
populated iff status matches |
consent_given / consent_given_at |
NOT NULL coherence CHECK |
kyc_status |
denormalised cache; activation gate reads via accounts.kyc_status_mirror |
V001 cross-table CHECK: accounts.account_party_relationships.ownership_share_pct
between 0 and 100 (numeric(7,4) precision).
core.joint_authorisations (V003) — FR-566 service¶
Snapshot semantics — captured in signatory_snapshot jsonb at
create-time, frozen for the life of the authorisation. signing_rule
is also captured at create-time so a mid-flight signing-authority
change does not invalidate open authorisations.
Per-action signing rule (orchestrator decision):
| Action type | Required rule |
|---|---|
PAYMENT |
inherit from core.joint_accounts.signing_authority |
ADD_HOLDER |
'all' (every existing holder) |
REMOVE_HOLDER |
'all' |
CHANGE_SIGNING_AUTHORITY |
'all' |
| Column | Notes |
|---|---|
authorisation_id |
uuid PK |
joint_account_id, account_id |
denormalised |
action_type |
NOT NULL ∈ |
metadata |
jsonb — caller-supplied (e.g. payment description) |
signing_rule |
frozen at create-time |
signatory_snapshot |
jsonb array of {relationship_id, party_id, is_primary} |
status |
PENDING / COMPLETE / EXPIRED / CANCELLED with coherence CHECK |
expires_at |
default 24h via SSM-driven AUTHORISATION_DEFAULT_EXPIRY_SECONDS; CHECK > created_at |
completed_at, cancelled_at |
populated when status transitions |
idempotency_key |
UNIQUE NOT NULL |
Why 24h vs MOD-134's 72h? Joint payments are time-sensitive personal cashflow; the longer 72h committee-decision window doesn't fit. Both modules are SSM-driven so the choice is reversible.
core.joint_authorisation_approvals (V003)¶
| Column | Notes |
|---|---|
approval_id |
uuid PK |
authorisation_id, holder_relationship_id |
FK; UNIQUE pair (one holder cannot approve twice) |
idempotency_key |
UNIQUE NOT NULL |
party_id |
snapshot copy |
approved_at |
timestamp |
core.joint_governance_events (V004) — append-only audit log¶
14-value event_type CHECK domain enumerated in V004:
JOINT_OPENED, HOLDER_ADDED, HOLDER_REMOVED, HOLDER_DECEASED,
DEATH_DOCUMENTATION_ACCEPTED, JOINT_ACTIVATED, CONSENT_RECORDED,
SIGNING_AUTHORITY_CHANGED, SHARE_ADJUSTED, AUTHORISATION_CREATED,
AUTHORISATION_APPROVAL_RECORDED, AUTHORISATION_COMPLETED,
AUTHORISATION_CANCELLED, JOINT_CLOSED.
idempotency_key UNIQUE NOT NULL (orchestrator Q9; negative test
covers both UNIQUE and NOT NULL paths).
V005 ADR-048: BEFORE UPDATE/DELETE/TRUNCATE triggers reject mutation.
Trust / community / joint product codes¶
Joint accounts re-use existing single-holder transaction / savings
products (orchestrator Q7 — joint is an account-attribute, not a
product type): NZ_TRANSACTION_01, NZ_SAVINGS_01,
AU_TRANSACTION_01, AU_SAVINGS_01. The presence of a
core.joint_accounts row is the discriminator.
4. ADR-048 DB-enforced invariants register¶
| Table | Enforcement | Migration | Negative test |
|---|---|---|---|
core.joint_governance_events |
Immutability triggers (UPDATE/DELETE/TRUNCATE) | V005 | tests/integration/db-trigger-joint-governance-immutable.test.ts |
core.joint_governance_events |
idempotency_key UNIQUE NOT NULL |
V004 | tests/integration/db-unique-governance-idempotency-key.test.ts |
core.joint_accounts |
closed_at IS NULL OR closed_at > activated_at |
V005 | tests/integration/db-check-joint-temporal.test.ts |
core.joint_authorisations |
expires_at > created_at, completed_at >= created_at, cancelled_at >= created_at, status-coherence CHECK |
V003 column-level | covered by FR-566 service tests |
core.joint_holder_metadata |
holder_status enum, kyc_status enum, deceased_at-iff-deceased / removed_at-iff-removed / consent_given_at-iff-given coherence |
V002 column-level | covered by activation-gate-pure tests |
accounts.account_party_relationships.ownership_share_pct |
0 ≤ pct ≤ 100 | V001 | indirectly verified by activation-gate sum-to-100 tests |
Cat 3 (Lambda-only): activation gate (cross-service: reads
accounts.kyc_status_mirror); MOD-001 joint-account guard at
posting time (future debt, see §11).
5. Activation gate logic (pure)¶
evaluateActivationGate in
src/services/activation-gate-pure.ts enforces (FR-565):
- ≥ 2 active holders
- Every active holder's
kyc_status_mirror.status = 'VERIFIED' - Every active holder has
consent_given = true - Sum of active holders'
ownership_share_pct= 100.0000
Activation is rejected (ComplianceBlock) until all four pass. The
public spec stops at "accept death documentation" — surviving-holder
payouts to the deceased's estate are MOD-073 / payments territory.
6. Signing rule enforcement (FR-566)¶
The spec mandates per-action authorisation; the service mirrors
MOD-134's pattern (orchestrator Q2 — parallel tables for v1, share
the abstraction at the third use case). Snapshot at create-time;
UNIQUE on (authorisation_id, holder_relationship_id) prevents
one-holder double-counting.
Snapshot semantics¶
When an authorisation is created, the active joint-holder roster is
captured into signatory_snapshot and FROZEN.
- Prior approvals stay valid if recorded WHILE the holder's
parent relationship
end_date IS NULLANDholder_status = 'active'at the moment of approval. - New approvals from removed/deceased holders are rejected —
recordApprovalraisesHOLDER_NO_LONGER_ACTIVEorHOLDER_NOT_IN_SNAPSHOT.
The "stuck all rule" trade-off¶
For signing_rule = all or any of ADD/REMOVE/CHANGE_SIGNING (which
force 'all'), if a snapshot member dies or is removed before
approving, the authorisation can never reach COMPLETE. It will
EXPIRE at expires_at (24h default). A fresh authorisation against
the new roster is the unblock path.
Required-count math¶
| Rule | Required |
|---|---|
any_one |
min(1, snapshot.length) |
any_two |
min(2, snapshot.length) |
all |
snapshot.length |
7. DCS / SDV apportionment (FR-567 / REP-007)¶
accounts.accounts.balance is a single number; the per-holder
apportionment is computed at query time from the fixed-point
ownership_share_pct values:
Banker's rounding (HALF_EVEN) on bigint cents. The LAST sorted holder
absorbs any rounding residual so the sum exactly equals the balance
(reproducible across calls; verified by the REP-007 CALC test).
Sort order: is_primary DESC, relationship_id ASC for stability.
The query supports active_only=true|false:
- active_only=true — current operational view (default for the
public read endpoint).
- active_only=false — full historical view including deceased and
removed holders. Required for SDV reports that reproduce balance
state at a prior point.
8. Death-of-a-holder lifecycle (FR-568)¶
Two-step flow (orchestrator Q4):
-
recordDeath— flip overlay rowholder_status='deceased'(parent relationship'send_datestays NULL so the historical share remains queryable for estate accounting). Setcore.joint_accounts.death_documentation_status = 'frozen'. While frozen,createAuthorisationand the holder/signing mutators reject withJOINT_FROZEN_PENDING_DEATH_DOCUMENTATION. -
acceptDeathDocumentation— supplied with an opaque MOD-073 document UUID; persists the doc id, flips status to'accepted', unfreezes the joint for surviving holders. v1 stops here. Estate payout to the deceased's share is owned by MOD-073 / future payments flows (FR-567's apportionment query is the data source).
A second death on an already-'accepted' joint re-freezes ('frozen'
again) and clears death_documentation_id — the new death needs a
new documentation review.
9. SSM outputs¶
| Path | Value |
|---|---|
/bank/{stage}/mod-125/api/base-url |
API Gateway base URL |
/bank/{stage}/mod-125/lambdas/{open,activate,add-holder,remove-holder,record-death,accept-death-documentation,change-signing-authority,create-authorisation,record-approval,record-consent,get-share-apportionment,close}/arn |
per-handler ARN |
/bank/{stage}/mod-125/tables/{joint-accounts,joint-holder-metadata,joint-authorisations,joint-authorisation-approvals,joint-governance-events}/name |
table FQNs |
/bank/{stage}/mod-125/authorisation-default-expiry-seconds |
configurable expiry (default 86400 = 24h) |
10. Cross-module touches¶
MOD-007 (additive — same shape as MOD-133 TRUST_GATE_PASS / MOD-134 COMMUNITY_GATE_PASS):
src/types/account-state.ts:ReasonCodeunion gains'JOINT_GATE_PASS'.src/services/transition-engine-pure.ts: PENDING→ACTIVE single- party KYC mirror gate now bypasses onTRUST_GATE_PASS || COMMUNITY_GATE_PASS || JOINT_GATE_PASS.- 2 new MOD-007 unit tests (41 → 43).
No other ALTERs are required for MOD-125 — JOINT_HOLDER was already
in account_party_relationships.relationship_type's CHECK domain
(MOD-001 V005), and joint accounts re-use existing product codes.
11. Policies satisfied¶
| Policy | Mode | How satisfied | Test |
|---|---|---|---|
| AML-002 | GATE | Activation rejects when any holder is not VERIFIED in accounts.kyc_status_mirror (FR-565). |
tests/policy/aml-002-gate-holder-eidv.test.ts |
| CON-001 | AUTO | Every holder enrolled has can_view=true and can_transact=true on the parent relationship row — single notification roster. MOD-063 reads this list. is_primary is informational-only. |
tests/policy/con-001-auto-equal-holder-treatment.test.ts |
| PRI-001 | AUTO | Each holder has its own relationship row + overlay row; consent_given is per-holder (not a single account-wide flag). |
tests/policy/pri-001-auto-per-holder-row.test.ts |
| REP-007 | CALC | getShareApportionment returns balance × ownership_share_pct per holder, banker's-rounded with last-holder residual absorption; reproducible. |
tests/policy/rep-007-calc-share-apportionment.test.ts |
| GOV-006 | LOG (cross-cut) | core.joint_governance_events immutability triggers reject UPDATE/DELETE/TRUNCATE. |
tests/policy/gov-006-log-immutable-governance.test.ts |
12. Named debt items¶
These are explicit follow-ups documented at build time. Each becomes a separate task when its prerequisite ships.
MOD-001 joint-account guard (REQUIRED before MOD-125 accounts can receive payment-initiated debits)¶
Trigger: SD04 payments module ships its first debit caller.
Required change: MOD-001's processPosting must add a
joint-account check parallel to MOD-134's (when shipped):
when the debit account has a row in core.joint_accounts, the
posting metadata must carry a joint_authorisation_id and the
lookup must verify status = 'COMPLETE' AND
action_type = 'PAYMENT'. Without this, a direct caller of
MOD-001's posting API could bypass the FR-566 signing rule.
In v1 there is no payments module; the gap is harmless. The authorisation service is fully tested as a standalone enforcement layer (FR-566 verified at the authorisation layer per the spec).
MOD-110 joint-account fee gating (advisory)¶
Trigger: if MOD-110 (fee engine) ever seeds a fee schedule
targeting the four supported product codes AND the schedule could
be account-specific to a joint, fees would post directly under the
ledger-direct-write contract without authorisation. Flag this
when adding any joint-targeted schedules.
MOD-073 deployment¶
death_documentation_id is currently an opaque cross-domain UUID.
When MOD-073 ships, the accept-death-documentation Lambda gains
a vault lookup before the documentation can be accepted; the schema
doesn't change.
MOD-009 deployment¶
accounts.kyc_status_mirror is fed by MOD-007's existing consumer
of bank.kyc.identity_verified. MOD-009 is Built but not Deployed
at the time of MOD-125 build; once it deploys, the activation gate
has live data. Until then, integration tests use direct mirror
inserts via tests/fixtures/joint-fixtures.ts.
MOD-047 deployment¶
agent.action_taken / staff.action_taken events to the
bank-platform bus are NOT published in v1 (orchestrator Q10 — cross-
domain governance bus integration deferred to MOD-047).
Generalising the 3 multi-party authorisation services¶
MOD-133 (trust), MOD-134 (community), and MOD-125 (joint) each have
a parallel core.{trust|community|joint}_authorisations +
*_authorisation_approvals table set with very similar logic.
Orchestrator Q2 confirmed parallel-tables-for-v1; promote to a
shared abstraction (e.g. core.multi_party_authorisations) if a
fourth use case appears (term deposit joint signatories?).
Future EventBridge integrations¶
bank.kyc.identity_expired(when catalogued) → could trigger an automatic re-evaluation of joint authorisations whose snapshot members have expired KYC. Out of scope v1.bank.risk.aml_risk_elevated(MOD-034 when built) → no direct MOD-125 path; if a holder's risk elevates, the existing per-holderkyc_status_mirrorflow flips them out of VERIFIED and any new authorisations will reject.
13. Test approach + results¶
| Tier | Files | Result |
|---|---|---|
| Unit | tests/unit/{activation-gate-pure, signing-rule-pure, share-apportionment-pure, errors, logger, emf}.test.ts |
48 / 48 |
| Contract | tests/contract/joint-events.test.ts |
8 / 8 |
| FR integration | tests/integration/fr-{565,566,567,568}-*.test.ts |
14 / 14 |
| ADR-048 negative | tests/integration/db-{trigger,check,unique}-*.test.ts |
9 / 9 |
| Idempotency | tests/integration/idempotency.test.ts |
1 / 1 |
| Policy | tests/policy/{aml-002, con-001, gov-006, pri-001, rep-007}-*.test.ts |
9 / 9 |
Combined MOD-125: 48 unit + 8 contract + 24 integration + 9 policy = 89/89. MOD-007 unit suite: 43/43 (added 2 JOINT_GATE_PASS tests).
14. Architectural decisions captured here¶
-
Standalone authorisation service for FR-566 (orchestrator Q2). Snapshot at create-time; UNIQUE on
(authorisation_id, holder_relationship_id); per-action signing rule resolution (PAYMENT inherits, mutators force'all'). -
Overlay table over per-domain replacement (orchestrator Q1) — same pattern as MOD-133 / MOD-134.
ownership_share_pctlives on the SDV/CRS-shared parent so REP-007 reads it without a domain join;is_primary,consent_given,holder_statuslive on the overlay. -
Two-step death lifecycle (orchestrator Q4) —
recordDeathfreezes;acceptDeathDocumentationunfreezes. v1 explicitly stops at unfreeze; estate payout is MOD-073 / payments concern. -
24h authorisation expiry (orchestrator Q6) — joint payments are personal cashflow; 72h is too long. SSM-driven, so per-stage override is one touch.
-
Joint as account-attribute, not product type (orchestrator Q7) — re-use existing transaction / savings products. The
core.joint_accountsrow is the discriminator. -
DCS apportionment via banker's rounding + last-holder residual (orchestrator Q11 / FR-567) — exact reproducibility for regulatory reporting, integer-cent arithmetic on bigint.