Skip to content

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_relationships for 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 activate handler runs the per-holder consent + KYC + sum-to-100 gate, then calls applyTransitionInClient(...) with reason_code: 'JOINT_GATE_PASS'. The pure validator (extended in this build) bypasses its single-party KYC mirror check when the reason code is JOINT_GATE_PASS (alongside the existing TRUST_GATE_PASS / COMMUNITY_GATE_PASS cases) — 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):

  1. ≥ 2 active holders
  2. Every active holder's kyc_status_mirror.status = 'VERIFIED'
  3. Every active holder has consent_given = true
  4. 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.

  1. Prior approvals stay valid if recorded WHILE the holder's parent relationship end_date IS NULL AND holder_status = 'active' at the moment of approval.
  2. New approvals from removed/deceased holders are rejectedrecordApproval raises HOLDER_NO_LONGER_ACTIVE or HOLDER_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:

holder_amount_cents = bankersRound(balance_cents × pct_micros / 1_000_000)

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):

  1. recordDeath — flip overlay row holder_status='deceased' (parent relationship's end_date stays NULL so the historical share remains queryable for estate accounting). Set core.joint_accounts.death_documentation_status = 'frozen'. While frozen, createAuthorisation and the holder/signing mutators reject with JOINT_FROZEN_PENDING_DEATH_DOCUMENTATION.

  2. 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: ReasonCode union gains 'JOINT_GATE_PASS'.
  • src/services/transition-engine-pure.ts: PENDING→ACTIVE single- party KYC mirror gate now bypasses on TRUST_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-holder kyc_status_mirror flow 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_pct lives on the SDV/CRS-shared parent so REP-007 reads it without a domain join; is_primary, consent_given, holder_status live on the overlay.

  • Two-step death lifecycle (orchestrator Q4) — recordDeath freezes; acceptDeathDocumentation unfreezes. 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_accounts row 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.