Skip to content

MOD-134 — Community 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-134-community-account-management/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048


1. Purpose

Manages community-entity accounts — sports clubs, residents' associations, incorporated societies, charitable trusts, body corporates. The account holder is the entity, not any individual; authority is vested in committee officers (signatories) who rotate annually. The module enforces full-roster eIDV at activation, runs a standalone authorisation service that gates outbound transactions against the configured signing rule, supports the annual committee refresh, and detects KYC degradation that drops the account below its signing-rule minimum and restricts it.

2. Architecture

HTTP caller ── POST /internal/v1/community-accounts/* ──> API Gateway ──> 9 Lambdas
                                                                        ├─ open-community
                                                                        ├─ add-signatory
                                                                        ├─ remove-signatory
                                                                        ├─ activate                  → MOD-007 transitionToActive
                                                                        ├─ refresh-committee
                                                                        ├─ create-authorisation     ──┐ FR-598
                                                                        ├─ record-approval          ──┘ signing rule
                                                                        ├─ check-signatory-kyc       → MOD-007 transition to RESTRICTED on FR-600
                                                                        └─ close

Each handler runs in a single Postgres transaction:
  accounts.accounts                        (PENDING insert / status transition through MOD-007)
  accounts.account_party_relationships     (relationship_type='SIGNATORY')
  core.community_accounts                  (1:1 with accounts.accounts)
  core.community_signatory_metadata        (1:1 overlay carrying committee_role)
  core.community_authorisations            (FR-598 — frozen snapshot at create)
  core.community_authorisation_approvals   (1 row per signatory approval; UNIQUE)
  core.community_governance_events         (immutable per-event log)

EventBridge bank-core (best effort, post-commit):
  bank.core.community_account_activated v1
  bank.core.community_signatory_changed v1
  bank.core.community_signing_rule_changed v1
  bank.core.community_authorisation_completed v1
  bank.core.community_account_restricted_insufficient_signatories v1

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

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

Module type

Application Lambda module. 9 handlers (one more than the 8-handler count in the orchestrator confirmation; the authorisation lifecycle is split into create + record-approval for clarity instead of being collapsed into one endpoint).

State transition delegation (orchestrator Q4)

  • PENDING → ACTIVE: MOD-134's activate handler runs the signatory + constitution gate, then calls applyTransitionInClient(...) from @bank-core/mod-007-account-state-machine with reason_code: 'COMMUNITY_GATE_PASS'. The pure validator (extended in this build) bypasses its single-party KYC mirror check when the reason code is COMMUNITY_GATE_PASS (or TRUST_GATE_PASS from MOD-133) — community accounts have no single ACCOUNT_HOLDER party_id.
  • ACTIVE → RESTRICTED (FR-600): kyc-monitor calls applyTransitionInClient(...) with reason_code: 'SIGNATORY_KYC_DEGRADED' and restriction_reason: 'INSUFFICIENT_SIGNATORIES'. Both values are added to MOD-007's union types in this build (additive cross- module touch).
  • RESTRICTED → ACTIVE: stays staff-gated per MOD-007's existing FR-440 reinstatement flow (orchestrator Q3 — confirmed). MOD-134 does not add an auto-restore path.

3. Data model

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

Column Notes
community_account_id uuid PK
account_id NOT NULL UNIQUE → accounts.accounts(id)
entity_name, entity_type entity_type ∈
abn_acn_nzbn nullable; unincorporated entities have none
constitution_document_id uuid; nullable in DDL, NOT NULL gate at activation Lambda — cross-domain to MOD-073
signing_rule NOT NULL ∈
jurisdiction NOT NULL ∈
activated_at, closed_at stamped on activation / close

V005 ADR-048 CHECK: closed_at IS NULL OR closed_at > activated_at.

core.community_signatory_metadata (V002) — overlay on relationship rows

Keyed 1:1 by relationship_id. Parent accounts.account_party_relationships.relationship_type is always 'SIGNATORY' (no CHECK extension required). Committee role lives on the overlay — community-specific semantics that don't belong on the SDV/CRS-shared parent table.

Column Notes
relationship_id PK FK
community_account_id FK to header
committee_role NOT NULL ∈
valid_from / valid_until community-specific authority window; valid_until stamped on remove
kyc_status denormalised cache; activation gate reads via accounts.kyc_status_mirror

core.community_authorisations (V003) — FR-598 service

Snapshot semantics — orchestrator clarification, captured in signatory_snapshot jsonb at create-time, frozen for the life of the authorisation. signing_rule is also captured at create-time to prevent mid-authorisation rule changes from affecting open authorisations.

Column Notes
authorisation_id uuid PK
community_account_id, account_id denormalised
metadata jsonb — caller-supplied (e.g. "Payment of $500 to ABC supplier")
signing_rule frozen at create-time
signatory_snapshot jsonb array of {relationship_id, party_id, committee_role}
status PENDING / COMPLETE / EXPIRED / CANCELLED with coherence CHECK
expires_at default 72h via SSM-driven AUTHORISATION_DEFAULT_EXPIRY_SECONDS; CHECK > created_at
completed_at, cancelled_at populated when status transitions
idempotency_key UNIQUE NOT NULL

core.community_authorisation_approvals (V003)

Column Notes
approval_id uuid PK
authorisation_id, signatory_relationship_id FK; UNIQUE pair (one signatory cannot approve twice)
idempotency_key UNIQUE NOT NULL
party_id, committee_role snapshot copies
approved_at timestamp

core.community_governance_events (V004) — append-only audit log

17-value event_type CHECK domain enumerated in V004. Coherence CHECK: authorisation_id populated iff event_type ∈ AUTHORISATION_. idempotency_key UNIQUE NOT NULL* (orchestrator Q6 addition; negative test covers both UNIQUE and NOT NULL paths).

V005 ADR-048: BEFORE UPDATE/DELETE/TRUNCATE triggers reject mutation.

Cross-module ALTERs (V001)

  • accounts.accounts.restriction_reason CHECK — adds 'INSUFFICIENT_SIGNATORIES' to MOD-007 V004's four-value enumeration. Required for FR-600's restriction path.
  • accounts.account_state_history.restriction_reason CHECK — same enumeration, extended in lockstep. The transition engine inserts both rows in the same Postgres transaction.

Trust product codes (V006)

NZ_COMMUNITY_01 (NZD) and AU_COMMUNITY_01 (AUD), product_type = TRANSACTION (orchestrator Q5). No accruals v1.

4. ADR-048 DB-enforced invariants register

Table Enforcement Migration Negative test
core.community_governance_events Immutability triggers (UPDATE/DELETE/TRUNCATE) V005 tests/integration/db-trigger-community-governance-immutable.test.ts
core.community_governance_events idempotency_key UNIQUE NOT NULL V004 tests/integration/db-unique-governance-idempotency-key.test.ts
core.community_accounts closed_at IS NULL OR closed_at > activated_at V005 tests/integration/db-check-community-temporal.test.ts
core.community_authorisations expires_at > created_at, completed_at >= created_at, cancelled_at >= created_at, status-coherence CHECK V003 column-level covered by FR-598 + service unit tests
core.community_signatory_metadata committee_role enum, kyc_status enum, valid_until >= valid_from V002 column-level covered by activation-gate-pure tests

Cat 3 (Lambda-only): activation gate (cross-service: reads accounts.kyc_status_mirror); KYC degradation detection (time- dependent); MOD-001 community-account guard at posting time (future debt, see §11).

5. Activation gate logic (pure)

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

  1. constitution_document_id IS NOT NULL
  2. ≥ 1 active signatory enrolled
  3. Every active signatory's kyc_status_mirror.status = 'VERIFIED'

Per FR-597 the activation gate enforces FULL-roster KYC even when signing_rule = any_one — the spec is explicit ("all designated authorised signatories have individually completed eIDV"), so partial-roster activation is not a supported path.

6. Signing rule enforcement (FR-598) — standalone authorisation service

The spec ("not solely in the UI") is satisfied by a standalone service-layer module: core.community_authorisations + core.community_authorisation_approvals + service code in src/services/authorisation-store.ts.

Snapshot semantics

When an authorisation is created, the active signatory roster is captured into signatory_snapshot and FROZEN. Two invariants follow:

  1. Prior approvals stay valid: an approval recorded WHILE the signatory's parent relationship end_date IS NULL counts for the life of the authorisation. If that signatory is later removed or has their KYC degrade, the approval is not invalidated.
  2. New approvals from removed signatories are rejected: when a signatory's parent end_date is no longer NULL (or never was), the recordApproval service throws SIGNATORY_NO_LONGER_ACTIVE or SIGNATORY_NOT_IN_SNAPSHOT.

The "stuck all rule" trade-off

For signing_rule = all, if a snapshot member is removed before approving, the authorisation can never reach COMPLETE. It will EXPIRE at expires_at (default 72h, configurable via SSM); a fresh authorisation can be created against the new roster. This is deliberate — the all rule is meant to be strict.

Required-count math

Rule Required
any_one min(1, snapshot.length)
any_two min(2, snapshot.length)
all snapshot.length

7. KYC degradation → restriction (FR-600)

check-signatory-kyc handler:

  1. Refresh local kyc_status denorm from accounts.kyc_status_mirror.
  2. Re-count active vs verified signatories.
  3. If verified < computeRequiredCount(rule, active) AND the underlying account is currently ACTIVE, transition it to RESTRICTED via MOD-007 with restriction_reason='INSUFFICIENT_SIGNATORIES'.
  4. Publish bank.core.community_account_restricted_insufficient_signatories v1 event for MOD-063 to fan out to signatories.
  5. Log the COMMUNITY_RESTRICTED_INSUFFICIENT_SIGNATORIES row.

v1: HTTP-triggered only (orchestrator Q4). When a future bank.kyc.identity_expired event lands in the catalogue, an EventBridge consumer will call this same path.

Restoration is staff-gated via MOD-007's existing FR-440 reinstatement (orchestrator Q3 — confirmed). MOD-134 does not add an auto-restore path.

8. SSM outputs

Path Value
/bank/{stage}/mod-134/api/base-url API Gateway base URL
/bank/{stage}/mod-134/lambdas/{open,add-signatory,remove-signatory,activate,refresh-committee,create-authorisation,record-approval,check-signatory-kyc,close}/arn per-handler ARN
/bank/{stage}/mod-134/tables/{community-accounts,community-signatory-metadata,community-authorisations,community-authorisation-approvals,community-governance-events}/name table FQNs
/bank/{stage}/mod-134/authorisation-default-expiry-seconds configurable expiry (default 259200 = 72h)

9. Cross-module touches

MOD-007 (additive — same shape as MOD-133 TRUST_GATE_PASS):

  • src/types/account-state.ts: RestrictionReason union gains 'INSUFFICIENT_SIGNATORIES'; ReasonCode union gains 'COMMUNITY_GATE_PASS' and 'SIGNATORY_KYC_DEGRADED'.
  • src/services/transition-engine-pure.ts: PENDING→ACTIVE KYC gate now bypasses on either TRUST_GATE_PASS or COMMUNITY_GATE_PASS.
  • 2 new MOD-007 unit tests (39 → 41).

Cross-module schema ALTERs in MOD-134 V001 (orchestrator Q3 + edge case):

  • accounts.accounts.restriction_reason CHECK extended.
  • accounts.account_state_history.restriction_reason CHECK extended in lockstep — the transition engine writes a history row inside the same transaction as the accounts row UPDATE, so the history CHECK must accept the new value too.

10. Policies satisfied

Policy Mode How satisfied Test
AML-002 GATE Activation rejects when any signatory is not VERIFIED in accounts.kyc_status_mirror. tests/policy/aml-002-gate-signatory-eidv.test.ts
CON-001 AUTO Every signatory enrolled has can_view=true and can_transact=true on the parent relationship row — single notification roster. MOD-063 reads this list. tests/policy/con-001-auto-equal-signatory-treatment.test.ts
GOV-006 LOG core.community_governance_events immutability triggers reject UPDATE/DELETE/TRUNCATE. tests/policy/gov-006-log-immutable-governance.test.ts
PRI-001 AUTO Each signatory has its own relationship row + metadata row; no cross-signatory data sharing. tests/policy/pri-001-auto-per-signatory-row.test.ts

11. Named debt items

These are explicit follow-ups documented at build time. Each becomes a separate task when its prerequisite ships.

MOD-001 community-account guard (REQUIRED before MOD-134 accounts can receive payment-initiated debits)

Trigger: SD04 payments module ships its first debit caller.

Required change: MOD-001's processPosting must add a community-account check. When the debit account has a row in core.community_accounts, the posting metadata must carry a community_authorisation_id and the lookup must verify status = 'COMPLETE'. Without this, a direct caller of MOD-001's posting API could bypass the FR-598 signing rule.

In v1 there is no payments module, so the gap is harmless. The authorisation service is fully tested as a standalone enforcement layer (FR-598 verified at the authorisation layer per the spec).

MOD-110 community-account fee gating (advisory)

Trigger: if MOD-110 (fee engine) ever seeds a fee schedule targeting NZ_COMMUNITY_01 / AU_COMMUNITY_01, fees would post directly under the ledger-direct-write contract without authorisation. Either:

  • (a) MOD-110 grows a community-account check (consult core.community_authorisations).
  • (b) Don't add community fee schedules until MOD-001's guard ships.

Currently MOD-110 has no community schedules seeded. Track when adding any.

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-134 build; once it deploys, the activation gate has live data. Until then, integration tests use direct mirror inserts via tests/fixtures/community-fixtures.ts.

MOD-073 deployment

constitution_document_id is currently an opaque cross-domain UUID. When MOD-073 ships, the activate Lambda gains a vault lookup before letting the gate pass; the schema doesn't change.

MOD-047 deployment

agent.action_taken / staff.action_taken events are best-effort published to the bank-platform bus. Once MOD-047 deploys, governance audit records start landing in audit.agent_actions.

Future EventBridge integrations

  • bank.kyc.identity_expired (when catalogued) → automatic check-signatory-kyc invocation.
  • bank.risk.aml_risk_elevated (MOD-034 when built) → no direct MOD-134 path; trust accounts route through MOD-133's CDD review, community accounts may follow a similar pattern in a future build.

12. Test approach + results

Tier Files Result
Unit tests/unit/{activation-gate-pure, signing-rule-pure, errors, logger, emf}.test.ts 34 / 34
Contract tests/contract/community-events.test.ts 7 / 7
FR integration tests/integration/fr-{597,598,599,600}-*.test.ts 13 / 13
ADR-048 negative tests/integration/db-{trigger,check,unique}-*.test.ts 9 / 9
Policy tests/policy/{aml-002, con-001, gov-006, pri-001}-*.test.ts 8 / 8

Combined MOD-134: 41 unit/contract + 30 integration/policy = 71/71.

13. Architectural decisions captured here

  • Standalone authorisation service for FR-598 (orchestrator Q2). Snapshot at create-time; UNIQUE on (authorisation_id, signatory_relationship_id) prevents one-signatory double-counting. The service is the FR-598 enforcement point in v1; MOD-001 integration is a documented follow-up debt.

  • core.community_signatory_metadata as overlay, not replacement (orchestrator Q1) — same pattern as MOD-133's core.trust_party_metadata. Parent account_party_relationships row carries SDV/CRS/notification semantics; the overlay carries community-specific committee role.

  • Snapshot semantics for in-flight authorisations — prior approvals stay valid when a signatory leaves; new approvals from removed signatories are rejected at insert time. The all rule's stuck-state on mid-authorisation removal is deliberate.

  • Restriction state via MOD-007 (orchestrator Q3) — the account's lifecycle stays in MOD-007's hands. MOD-134 emits the trigger and the bank.core.community_account_restricted_* v1 event; reinstatement is staff-gated through MOD-007's existing FR-440 path.