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-machinewithreason_code: 'COMMUNITY_GATE_PASS'. The pure validator (extended in this build) bypasses its single-party KYC mirror check when the reason code isCOMMUNITY_GATE_PASS(orTRUST_GATE_PASSfrom MOD-133) — community accounts have no single ACCOUNT_HOLDER party_id. - ACTIVE → RESTRICTED (FR-600):
kyc-monitorcallsapplyTransitionInClient(...)withreason_code: 'SIGNATORY_KYC_DEGRADED'andrestriction_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_reasonCHECK — adds'INSUFFICIENT_SIGNATORIES'to MOD-007 V004's four-value enumeration. Required for FR-600's restriction path.accounts.account_state_history.restriction_reasonCHECK — 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:
constitution_document_id IS NOT NULL- ≥ 1 active signatory enrolled
- 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:
- Prior approvals stay valid: an approval recorded WHILE the
signatory's parent relationship
end_date IS NULLcounts for the life of the authorisation. If that signatory is later removed or has their KYC degrade, the approval is not invalidated. - New approvals from removed signatories are rejected: when a
signatory's parent
end_dateis no longer NULL (or never was), therecordApprovalservice throwsSIGNATORY_NO_LONGER_ACTIVEorSIGNATORY_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:
- Refresh local
kyc_statusdenorm fromaccounts.kyc_status_mirror. - Re-count active vs verified signatories.
- If
verified < computeRequiredCount(rule, active)AND the underlying account is currently ACTIVE, transition it to RESTRICTED via MOD-007 withrestriction_reason='INSUFFICIENT_SIGNATORIES'. - Publish
bank.core.community_account_restricted_insufficient_signatoriesv1 event for MOD-063 to fan out to signatories. - Log the
COMMUNITY_RESTRICTED_INSUFFICIENT_SIGNATORIESrow.
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:RestrictionReasonunion gains'INSUFFICIENT_SIGNATORIES';ReasonCodeunion gains'COMMUNITY_GATE_PASS'and'SIGNATORY_KYC_DEGRADED'.src/services/transition-engine-pure.ts: PENDING→ACTIVE KYC gate now bypasses on eitherTRUST_GATE_PASSorCOMMUNITY_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_reasonCHECK extended.accounts.account_state_history.restriction_reasonCHECK 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) → automaticcheck-signatory-kycinvocation.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_metadataas overlay, not replacement (orchestrator Q1) — same pattern as MOD-133'score.trust_party_metadata. Parentaccount_party_relationshipsrow 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
allrule'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.