MOD-118 — Member equity and share registry¶
Module: MOD-118 System: SD01 Core Banking Repo: bank-core Module type: Application Lambda + IaC FR scope: FR-533, FR-534, FR-535, FR-536 Policies satisfied: CLQ-001 (GATE), GOV-001 (LOG), CON-001 (AUTO), CON-004 (AUTO) Author: Claude (bank-core SD-01 agent) Date: 2026-05-19 Dependencies (Deployed): MOD-001, MOD-007, MOD-033, MOD-073, MOD-076, MOD-140, MOD-103, MOD-104 Dependencies (optional / deferred): MOD-113 (statement renderer), MOD-105 (eligibility)
Objective¶
Maintain the authoritative member shareholding register for mutual institutions, enforce a regulatory capital gate on redemptions (without which member shares cease to qualify as CET1 under RBNZ BS2A / APRA APS 110), and execute the dividend declaration + distribution workflow with consistent per-member calculation and automatic FY-end tax certificate dispatch. The module is gated by a tenant flag — inactive for proprietary tenants.
Internal architecture¶
┌────── tenant gate (SSM /bank/{stage}/tenant/institution-type)
│ proprietary → 501 / 404 short-circuit
▼
API GW ─▶ create-member.handler → MemberStore.create
─▶ purchase-shares.handler → ShareTxService.purchase
─▶ redeem-shares.handler → ShareTxService.redeem (calls CapitalGateProvider)
─▶ declare-dividend.handler → DividendService.declare
─▶ record-vote.handler → MemberStore.recordVote
─▶ list-share-transactions.handler → ShareTransactionStore.listForMember
Schedules ─▶ replay-blocked-redemptions (daily 02:15 NZT)
▶ execute-dividend-batch (daily 05:00 NZT)
▶ publish-fy-end-statements (annual 30 Jun 23:00 NZT)
Each write path runs inside a single withTransaction block that
locks the member row FOR UPDATE, calls MOD-001's posting API to
commit the ledger movement, then INSERTs the immutable share_tx row
and updates shares_held on the member register.
Key design decisions¶
Decision: CapitalGateProvider interface — Gap #1 ruling¶
Context: FR-534 requires MOD-118 to call MOD-033 for the current CET1 ratio before processing a redemption. MOD-033 is Snowflake-native and has no real-time API in v1; bank-risk-platform is delivering a Lambda wrapper per issue #38.
Choice: Implement the gate behind a CapitalGateProvider
interface with two providers — StubCapitalGateProvider (dev / uat
default, returns a healthy stub ratio configurable via
CAPITAL_GATE_STUB_CET1) and Mod033CapitalGateProvider (prod path,
calls the MOD-033 Lambda wrapper). Factory at the bottom of
src/lib/capital-gate-provider.ts selects via
CAPITAL_GATE_PROVIDER=stub | mod-033 env var. Handler code never
imports a concrete class.
Reason: Unblocks the MOD-118 build today and keeps the swap to
the prod provider mechanical (one env var flip + the wrapper URL).
Pattern mirrors MOD-087's EnrichmentProvider.
Trade-off: v1 redemption gate uses a synthetic CET1 figure in dev/uat. Smoke + integration tests in those environments cover the orchestration shape; the actual regulatory math is exercised in prod once the MOD-033 wrapper ships.
Decision: core.share_transactions is Cat 1 immutable, INSERT-only¶
Context: GOV-001 LOG requires that share transactions form an immutable governance log.
Choice: V001 grants INSERT, SELECT only (no UPDATE/DELETE);
V002 adds a row trigger
(fn_share_transactions_reject_mutation) that raises on
UPDATE/DELETE/TRUNCATE. Status is terminal at write time —
SETTLED, BLOCKED, or REVERSED — never updated. A correction
(reversal) is a NEW row with tx_type='CORRECTION' and negative
shares, correlating to the original via correlated_tx_id.
Reason: Belt + braces; revocation guards superuser misuse, the
trigger guards a future GRANT UPDATE ... TO some_user regression.
Matches MOD-001's accounts.postings pattern.
Decision: PENDING redemption state lives in core.redemption_queue¶
Context: core.share_transactions is immutable; we still need a
mutable record of "this redemption was blocked and we'll retry it
tomorrow".
Choice: A separate mutable core.redemption_queue table holds
each blocked-but-not-cancelled redemption. The replayer mutates the
queue row's status (PENDING → PROCESSED / CANCELLED). Every
capital-gate evaluation result writes a new share_transactions
row (BLOCKED if the gate fails again, SETTLED if it finally clears),
correlated to the queue row via redemption_queue_id.
Reason: Separates the audit trail (immutable share_tx rows) from the operational queue (mutable, indexable by jurisdiction + sequence_no for FIFO replay).
Decision: cross-domain references via party_id, no Postgres FK¶
Context: MOD-118.md initially specified core.customers(customer_id)
as the FK target; customers actually live in bank-kyc's DB (MOD-012).
Choice: core.member_register.party_id is UUID NOT NULL with
no FK. App-layer enforcement only.
Reason: Mirrors the established cross-domain pattern
(accounts.account_party_relationships). Cross-database FKs aren't
supported in Postgres; introducing a stub FK would silently allow
orphan members.
Decision: dispatch FY-end artefacts via events — Gap #2 ruling Option C¶
Context: MOD-113 (statement / tax certificate renderer) is Not started, but FR-535 / FR-536 require dispatch of those artefacts.
Choice: MOD-118 emits bank.core.member_statement_due and
bank.core.tax_certificate_due events at FY end with the full
data payload (opening / closing shares, every share-tx in the FY,
every dividend payment, withholding rates, totals). MOD-113
subscribes when deployed.
Reason: Lets MOD-118 complete its data computation and audit-trail responsibility independently of the renderer. CON-004 AUTO is satisfied: the dispatch is automatic, no member request required. FR-535 / FR-536 delivery is deferred until MOD-113 ships.
Data model¶
| Table | Mutability | Notes |
|---|---|---|
core.member_register |
mutable | Status / shareholding / voting history; one row per party. |
core.share_transactions |
Cat 1 immutable (ADR-048) | INSERT-only; status terminal at write time; corrections are new CORRECTION rows. |
core.dividend_declarations |
mutable | Status: DECLARED → PROCESSING → PAID (or REVERSED). |
core.dividend_payments |
mutable (append-once) | One per (declaration, member); paid_at flipped from null when the net credit posts. |
core.redemption_queue |
mutable | FIFO queue for capital-gate-blocked redemptions; replayer mutates status. |
Full DDL in migrations/V001-V003.
External dependencies¶
- Database:
bankon Neon (provisioned by MOD-103). Runtime rolecore_app_user; migration rolecore_migrate_user. - EventBridge: publishes on
bank-corebus (ARN via/bank/{stage}/eventbridge/bank-core/arn). - MOD-001: all postings submitted via
/postingson the SSM path/bank/{stage}/mod-001/api/base-url. - MOD-033: capital snapshot via
CapitalGateProvider(stub in v1; Lambda wrapper API in prod per bank-risk-platform issue #38). - MOD-073: doc vault (consumed when MOD-113 deploys; no direct call from MOD-118 v1).
SSM outputs table¶
| Output | SSM path | Consumers |
|---|---|---|
| API base URL | /bank/{stage}/mod-118/api/base-url |
bank-app member equity UI |
| create-member Lambda ARN | /bank/{stage}/mod-118/lambda/create-member/arn |
back-office onboarding |
| purchase-shares Lambda ARN | /bank/{stage}/mod-118/lambda/purchase-shares/arn |
bank-app share-purchase flow |
| redeem-shares Lambda ARN | /bank/{stage}/mod-118/lambda/redeem-shares/arn |
bank-app redemption flow |
| declare-dividend Lambda ARN | /bank/{stage}/mod-118/lambda/declare-dividend/arn |
back-office board ops |
| execute-dividend-batch Lambda ARN | /bank/{stage}/mod-118/lambda/execute-dividend-batch/arn |
scheduled trigger |
| replay-blocked-redemptions Lambda ARN | /bank/{stage}/mod-118/lambda/replay-blocked-redemptions/arn |
scheduled trigger |
| publish-fy-end-statements Lambda ARN | /bank/{stage}/mod-118/lambda/publish-fy-end-statements/arn |
scheduled trigger + ops |
| record-vote Lambda ARN | /bank/{stage}/mod-118/lambda/record-vote/arn |
back-office AGM tool |
| list-share-transactions Lambda ARN | /bank/{stage}/mod-118/lambda/list-share-transactions/arn |
bank-app statements |
| Error code enum | /bank/{stage}/mod-118/error-codes |
bank-app PAYMENT_FAILED-style mapping |
EventBridge contract¶
| Direction | Source | Detail-type | Schema |
|---|---|---|---|
| Publish | bank.core |
bank.core.share_transaction_settled |
ShareTransactionSettledV1 |
| Publish | bank.core |
bank.core.share_redemption_blocked |
ShareRedemptionBlockedV1 |
| Publish | bank.core |
bank.core.dividend_declared |
DividendDeclaredV1 |
| Publish | bank.core |
bank.core.dividend_paid |
DividendPaidV1 |
| Publish | bank.core |
bank.core.member_statement_due |
MemberStatementDueV1 (Gap #2) |
| Publish | bank.core |
bank.core.tax_certificate_due |
TaxCertificateDueV1 (Gap #2) |
| Publish | bank.core |
bank.core.member_status_changed |
MemberStatusChangedV1 |
Typed payload shapes in contract/events/index.ts (@bank-core/mod-118-contracts/events).
Tenant gate¶
SSM /bank/{stage}/tenant/institution-type returns mutual |
proprietary. Read at Lambda cold start by
src/lib/tenant-flag.ts. Proprietary tenants get an immediate 501
MODULE_DISABLED_PROPRIETARY_TENANT from every handler — no DB writes,
no events, no posting calls. The override env var
TENANT_TYPE_OVERRIDE exists for tests.
Test approach¶
| Test type | Location | Count |
|---|---|---|
| Unit | tests/unit/ |
dividend-calc, capital-gate-provider, share-tx-validator, tenant-flag, logger, errors, emf (≥80% coverage gate on src/lib/* + pure-calc services) |
| Contract | tests/contract/ |
event-types parity — service union ⇔ contract enum |
| FR integration | tests/integration/fr-533, fr-534, fr-535, fr-536 |
one per FR |
| Policy satisfaction | tests/policy/CLQ-001, GOV-001, CON-001, CON-004 |
one per row; CLQ-001 has a dedicated negative test |
| Deployment smoke | tests/verify-deployment.mjs |
SSM + Lambda liveness |
Integration + policy tests connect to the deployed dev Neon
database; they skip cleanly when NEON_DIRECT_HOST /
NEON_APP_PASSWORD aren't set. In CI, RUN_INTEGRATION=1 keeps the
guard off so every test runs.
Open items / v2¶
- Wire
Mod033CapitalGateProvideronce bank-risk-platform issue #38 shipsGET /capital/current/{jurisdiction}+ the/bank/{stage}/mod-033/api/base-urlSSM output + BankCoreRole IAM grant. One env var flip (CAPITAL_GATE_PROVIDER=mod-033) plus the base-url env wires it; no source-code change. - Wire MOD-105 eligibility check when MOD-105 ships. v1 accepts any
party with
status='member'. - MOD-113 subscription on
bank.core.{member_statement_due, tax_certificate_due}— once MOD-113 deploys, FR-535/FR-536 delivery completes. - Replace the ops-supplied
paymentAccountResolverenv shim with a proper MOD-007 lookup once the join path is stable. - The
execute-dividend-batchscheduler is a daily 05:00 NZT cron; v2 should look at the declarations table and emit per-declaration events rather than a single daily kick. v1 is fine because declarations are board-driven and infrequent.