SD01 — Core Banking data model¶
Database: core (Neon Postgres, ap-southeast-2)
Repo: bank-core
ADRs: ADR-001 (Postgres as OLTP), ADR-024 (Neon hosting; partially superseded by ADR-052 — naming convention correction only; all other decisions remain in force), ADR-003 (CDC to Snowflake), ADR-048 (database-enforced invariants)
Schema owner: SD01 Core Banking
This is the authoritative schema reference for all modules in bank-core. Use exact column names and types from this document — do not invent alternatives.
The accounts schema is the primary write domain for MOD-001 (double-entry posting), MOD-002 (immutable transaction log), MOD-003 (real-time balance engine), MOD-004 (multi-currency ledger), MOD-005 (daily accrual calculator), MOD-006 (rate change propagation), MOD-007 (account state machine), and MOD-008 (dormancy and escheatment engine). All postings are immutable — corrections are made via reversal entries, never edits.
Schema: accounts¶
accounts.accounts¶
The primary account record. Every customer account — savings, transaction, and multi-currency — has one row here. Account lifecycle transitions are managed exclusively by MOD-007.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | Surrogate account identifier |
| account_number | text | NOT NULL UNIQUE | Bank-issued account number (BSB+account for AU, account number for NZ) |
| product_code | text | NOT NULL | References account_products.product_code |
| currency | char(3) | NOT NULL | ISO 4217 currency code (NZD, AUD) |
| status | text | NOT NULL | CHECK (status IN ('PENDING','ACTIVE','RESTRICTED','DORMANT','CLOSED')) |
| balance | numeric(18,2) | NOT NULL DEFAULT 0.00 | Current cleared balance (denormalised from postings for read speed) |
| available_balance | numeric(18,2) | NOT NULL DEFAULT 0.00 | Balance minus pending holds |
| overdraft_limit | numeric(18,2) | NOT NULL DEFAULT 0.00 | Approved overdraft facility amount |
| jurisdiction | char(2) | NOT NULL | CHECK (jurisdiction IN ('NZ','AU')) |
| opened_at | timestamptz | NOT NULL DEFAULT now() | When account moved to ACTIVE |
| closed_at | timestamptz | Populated on closure | |
| dormancy_flagged_at | timestamptz | Set by MOD-008 when inactivity threshold crossed | |
| last_transaction_at | timestamptz | Updated on each posting | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL | |
| deleted_at | timestamptz | Soft-delete for closed accounts awaiting escheatment | |
| version | int | NOT NULL DEFAULT 0 CHECK (version >= 0) | Optimistic-lock counter (FR-430). MOD-001 increments inside its posting transaction; MOD-003 hold-management writers do UPDATE … WHERE version = $expected and retry on row-count 0. Added by MOD-003 V002 migration. |
| is_internal | bool | NOT NULL DEFAULT false | True for bank-owned internal accounts (FX nostro, P&L collectors). Added by MOD-004 V007 migration. Internal accounts are excluded from customer-facing balance aggregations. |
| restriction_reason | text | CHECK (restriction_reason IN ('SANCTIONS','FRAUD_INVESTIGATION','HARDSHIP_ARRANGEMENT','ADMIN','INSUFFICIENT_SIGNATORIES','NOTICE_PENDING')) | Populated when status = RESTRICTED; NULL otherwise. Discriminates the cause so downstream modules can determine which RESTRICTED accounts to skip. Set by MOD-007. INSUFFICIENT_SIGNATORIES added by MOD-134 V001. NOTICE_PENDING added by MOD-130 V001 (lockstep with accounts.account_state_history per MOD-134 precedent). |
| obr_frozen_amount | numeric(18,2) | NOT NULL DEFAULT 0 | OBR partition: amount frozen by haircut. 0 in normal/pre_positioned state; set atomically by MOD-143 activate handler. Added by MOD-143 V001. |
| obr_available_amount | numeric(18,2) | NOT NULL DEFAULT 0 | OBR partition: amount available to depositor after haircut. Equals balance in normal/pre_positioned state; set atomically by MOD-143 V001 activation. Channel-restriction trigger (trg_posting_obr_channel_gate) rejects DEBIT postings that would breach this floor during activated state. Added by MOD-143 V001; existing rows initialised to balance in the same migration. |
Indexes:
- idx_accounts_status on (status) WHERE status IN ('ACTIVE','RESTRICTED')
- idx_accounts_dormancy_flagged on (dormancy_flagged_at) WHERE dormancy_flagged_at IS NOT NULL
- idx_accounts_account_number on (account_number)
Notes: balance is a denormalised read-cache derived from summing postings.amount. The canonical balance is always re-derivable from the postings table. The balance column is updated atomically within the same transaction as each posting by MOD-001. Account-to-party relationships are expressed exclusively via accounts.account_party_relationships — there is no customer_id column on the account itself.
accounts.postings¶
Immutable double-entry ledger entries. Every financial movement — debit or credit — is a posting row. Rows are never updated or deleted. Corrections are effected by reversal postings referencing reverses_posting_id.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT uuidv7() | Surrogate posting identifier — UUIDv7 (time-ordered). Postgres 17 built-in function is uuidv7() (no gen_ prefix). Satisfies FR-047 monotonic ordering requirement for deterministic replay. If uuidv7() is not available on the target Neon instance, install the pg_uuidv7 extension or define a wrapper: CREATE FUNCTION uuidv7() RETURNS uuid LANGUAGE sql AS $$ SELECT gen_random_uuid() $$ as a temporary fallback (revert to true v7 once available). |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | Target account |
| transaction_id | uuid | NOT NULL | Groups the two legs of a double-entry pair |
| entry_type | text | NOT NULL CHECK (entry_type IN ('DEBIT','CREDIT')) | Direction of movement |
| amount | numeric(18,2) | NOT NULL CHECK (amount > 0) | Absolute value; always positive |
| currency | char(3) | NOT NULL | ISO 4217; must match account currency or be a cross-currency leg |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | Must match the destination (credit-leg) account's registered jurisdiction per FR-424 |
| value_date | date | NOT NULL | Settlement date for interest accrual |
| posting_date | timestamptz | NOT NULL DEFAULT now() | Wall-clock time of posting |
| payment_id | uuid | FK to payments.payments(id) if originated by SD04 | |
| fx_conversion_id | uuid | REFERENCES accounts.fx_conversions(id) | Set on all 4 legs of a multi-currency conversion. Added by MOD-004 V003 migration. NULL for non-FX postings. |
| source_module | text | NOT NULL | Module that initiated the posting (e.g. MOD-001, MOD-005) |
| narrative | text | NOT NULL | Human-readable transaction description |
| reverses_posting_id | uuid | REFERENCES accounts.postings(id) | Populated on reversal/cancellation postings — the cancels pointer per FR-422. Reverse lookup (find the cancellation of a given posting) uses idx_postings_reverses_posting_id. |
| metadata | jsonb | NOT NULL DEFAULT '{}' | Extensible structured metadata (FX rate, reference, etc.) |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_postings_account_id_posting_date on (account_id, posting_date DESC)
- idx_postings_transaction_id on (transaction_id)
- idx_postings_payment_id on (payment_id) WHERE payment_id IS NOT NULL
- idx_postings_value_date on (value_date)
- idx_postings_reverses_posting_id on (reverses_posting_id) WHERE reverses_posting_id IS NOT NULL
Notes: This table is append-only enforced at the DB layer by trg_postings_immutable (BEFORE UPDATE OR DELETE) and at the application layer by MOD-001 — the DB trigger is the hard floor. No updated_at column — immutable rows do not change. The transaction_id groups debit and credit legs; every valid transaction has exactly two postings with opposite entry_type and equal absolute amount per currency — enforced at COMMIT time by the deferred trg_postings_double_entry constraint trigger. UUIDv7 IDs are time-ordered — the id column is sortable in creation order without a separate sequence column. See the DB-enforced invariants section for the full trigger register.
accounts.account_products¶
Defines the rate and feature configuration for each account product type. MOD-006 reads this table to propagate rate changes across affected accounts.
| Column | Type | Constraints | Description |
|---|---|---|---|
| product_code | text | PK | Unique product identifier (e.g. NZ_SAVINGS_01) |
| product_name | text | NOT NULL | Human-readable name |
| product_type | text | NOT NULL CHECK (product_type IN ('SAVINGS','TRANSACTION','TERM_DEPOSIT','LOAN')) | |
| currency | char(3) | NOT NULL | |
| base_interest_rate | numeric(8,6) | NOT NULL DEFAULT 0.000000 | Annual rate as a decimal (0.045 = 4.5%) |
| overdraft_rate | numeric(8,6) | NOT NULL DEFAULT 0.000000 | |
| jurisdiction | char(2) | NOT NULL | |
| effective_from | date | NOT NULL | Rate effective date |
| effective_to | date | CHECK (effective_to IS NULL OR effective_to > effective_from) | NULL = currently active rate |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_account_products_product_code_effective on (product_code, effective_from DESC)
- idx_account_products_active on (product_code) WHERE effective_to IS NULL
accounts.interest_rates¶
Historical rate schedule for all product types. MOD-005 (daily accrual) reads the rate active on accrual_date. For lending products, the published rate is tp_rate_bps + margin_bps; tp_rate_bps is written by MOD-086 write-back and margin_bps is the product manager's configured overlay. Separate from account_products to allow point-in-time rate reconstruction for audit.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| product_code | text | NOT NULL REFERENCES accounts.account_products(product_code) | |
| rate_type | text | NOT NULL CHECK (rate_type IN ('BASE','BONUS','PENALTY','OVERDRAFT','FIXED_LENDING','VARIABLE_LENDING')) | |
| annual_rate | numeric(8,6) | NOT NULL CHECK (annual_rate >= 0) | Decimal fraction (not percentage) — the all-in published rate |
| tp_rate_bps | int | TP base rate in basis points at time of set (from treasury.tp_rates); NULL for non-lending products | |
| margin_bps | int | Product manager margin over TP in basis points; NULL for non-lending products | |
| lvr_min | numeric(5,4) | Minimum LVR for this rate band (lending only; e.g. 0.8000 = 80%) | |
| lvr_max | numeric(5,4) | Maximum LVR for this rate band (NULL = no upper bound) | |
| credit_tier | text | Credit tier this band applies to (e.g. 'PRIME','NEAR_PRIME','STANDARD'); NULL = all tiers | |
| effective_from | date | NOT NULL | Inclusive start date |
| effective_to | date | CHECK (effective_to IS NULL OR effective_to > effective_from) | NULL = current rate |
| authorised_by | text | NOT NULL | Role that approved the rate change |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_interest_rates_product_effective on (product_code, effective_from DESC)
- idx_interest_rates_active on (product_code, rate_type) WHERE effective_to IS NULL
- idx_interest_rates_lending_band on (product_code, lvr_min, lvr_max, credit_tier) WHERE rate_type IN ('FIXED_LENDING','VARIABLE_LENDING')
accounts.pending_holds¶
Temporary holds that reduce available_balance pending payment settlement or authorisation. Holds are released either by settlement (posting) or expiry.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| payment_id | uuid | Originating payment reference | |
| hold_type | text | NOT NULL CHECK (hold_type IN ('PAYMENT_AUTH','LEGAL_HOLD','ADMIN_HOLD','CARD_AUTH')) | |
| amount | numeric(18,2) | NOT NULL | |
| currency | char(3) | NOT NULL | |
| expires_at | timestamptz | NOT NULL | After this time the hold is auto-released |
| released_at | timestamptz | Set when hold is explicitly released | |
| release_reason | text | ||
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_pending_holds_account_id_active on (account_id) WHERE released_at IS NULL
- idx_pending_holds_expires_at on (expires_at) WHERE released_at IS NULL
- idx_pending_holds_payment_id on (payment_id) WHERE payment_id IS NOT NULL
accounts.daily_balance_snapshots¶
Append-only end-of-day balance snapshot per account, captured by MOD-003's Mod003EodSnapshotJob Lambda at jurisdiction-local 23:55 (separate cron per jurisdiction). Retained 7 years to support regulatory point-in-time balance queries (FR-431). Immutable by design — corrections are new rows, not updates.
| Column | Type | Constraints | Description |
|---|---|---|---|
| snapshot_date | date | NOT NULL, PK component | Business date of the snapshot |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id), PK component | |
| ledger_balance | numeric(18,2) | NOT NULL | Cleared balance at end of day |
| available_balance | numeric(18,2) | NOT NULL | Ledger balance minus active holds |
| currency | char(3) | NOT NULL | ISO 4217 |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| captured_at | timestamptz | NOT NULL DEFAULT now() | Exact time the snapshot was written |
Primary key: (snapshot_date, account_id)
Indexes:
- idx_daily_balance_snapshots_account_date on (account_id, snapshot_date DESC)
- idx_daily_balance_snapshots_jurisdiction_date on (jurisdiction, snapshot_date DESC)
Immutability: UPDATE/DELETE are blocked via RLS policies (FOR UPDATE USING (false), FOR DELETE USING (false)). bank_core_app_user has INSERT + SELECT only. Consumed by MOD-036 (prudential return builder) for regulatory capital reporting.
accounts.dormancy_records¶
Audit log of dormancy lifecycle events processed by MOD-008. Tracks each stage of the inactivity → dormancy → escheatment pipeline with statutory reference dates for NZ Unclaimed Money Act and AU unclaimed money legislation.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| event_type | text | NOT NULL CHECK (event_type IN ('INACTIVITY_FLAGGED','DORMANCY_NOTICE_SENT','DORMANCY_CONFIRMED','ESCHEATMENT_NOTIFIED','ESCHEATMENT_SUBMITTED','REACTIVATED')) | |
| event_date | timestamptz | NOT NULL DEFAULT now() | |
| statutory_deadline | date | Calculated deadline under relevant legislation | |
| jurisdiction | char(2) | NOT NULL | |
| notified_at | timestamptz | When customer notification was sent | |
| submitted_to_regulator | timestamptz | When escheatment filing was made | |
| amount_at_event | numeric(18,2) | Balance at time of event | |
| notes | text | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_dormancy_records_account_id on (account_id)
- idx_dormancy_records_event_type_date on (event_type, event_date)
- idx_dormancy_records_statutory_deadline on (statutory_deadline) WHERE event_type IN ('DORMANCY_CONFIRMED','ESCHEATMENT_NOTIFIED')
Note: statutory_deadline is computed as last_transaction_at + statutory_threshold — anchored to the account's last transaction, not to dormant_at. This ensures the deadline is immune to changes in the configurable dormancy threshold. NZ threshold: 12 months (Unclaimed Money Act 1971 — filed with IRD). AU threshold: 7 years (Banking Act 1959 — filed with ASIC).
accounts.escheatment_submissions¶
One row per jurisdiction-period submission file generated by MOD-008. Tracks the S3 artefact and submission lifecycle. Append-only once submitted. Maintained by MOD-008.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| period_end | date | NOT NULL | Last day of the period covered by this submission |
| regulator | text | NOT NULL | IRD (NZ) or ASIC (AU) |
| account_count | int | NOT NULL | Number of accounts included |
| total_amount | numeric(18,2) | NOT NULL | Total funds escheated in this submission |
| currency | char(3) | NOT NULL | |
| s3_key | text | NOT NULL UNIQUE | S3 object key where the submission file was written |
| submission_status | text | NOT NULL CHECK (submission_status IN ('PENDING_OPS','SUBMITTED','ACKNOWLEDGED','REJECTED')) | |
| submitted_at | timestamptz | When ops confirmed manual submission to regulator | |
| regulator_reference | text | Reference returned by regulator on acknowledgement | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_escheatment_submissions_jurisdiction_period on (jurisdiction, period_end DESC)
- idx_escheatment_submissions_status on (submission_status) WHERE submission_status = 'PENDING_OPS'
accounts.account_party_relationships¶
The regulated relationship between an account and the parties associated with it. This table makes the Single Depositor View (DCS/SDV), CRS account-holder reporting, and AML beneficial owner identification structurally possible. Do not put a single customer_id on an account for these purposes — joint accounts, trustee holdings, and authorised individuals all require rows here.
Written by MOD-012 (digital onboarding), MOD-007 (account state machine), and MOD-133 (trust account management) when accounts are opened or party relationships change.
| Column | Type | Constraints | Description |
|---|---|---|---|
| relationship_id | uuid | PK DEFAULT gen_random_uuid() | |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| party_id | uuid | NOT NULL | Cross-domain FK to SD02 party.parties(party_id) |
| relationship_type | text | NOT NULL CHECK (relationship_type IN ('ACCOUNT_HOLDER','JOINT_HOLDER','DEPOSITOR','BENEFICIAL_OWNER','TRUSTEE','NOMINEE','AUTHORISED_INDIVIDUAL','SIGNATORY','CARDHOLDER','APPOINTOR','PROTECTOR','SETTLOR')) | |
| ownership_share_pct | numeric(7,4) | For joint accounts and beneficial ownership; shares must sum to 100 where applicable | |
| can_transact | boolean | NOT NULL DEFAULT false | Whether this party can initiate transactions |
| can_view | boolean | NOT NULL DEFAULT true | Whether this party can view balances and statements |
| dcs_relevant | boolean | NOT NULL DEFAULT false | Include in Single Depositor View (SDV) computation |
| crs_account_holder | boolean | NOT NULL DEFAULT false | CRS-reportable account holder |
| crs_controlling_person | boolean | NOT NULL DEFAULT false | CRS-reportable controlling person (passive NFEs) |
| start_date | date | NOT NULL | |
| end_date | date | NULL = current relationship | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_acct_party_rel_account_id on (account_id) WHERE end_date IS NULL
- idx_acct_party_rel_party_id on (party_id) WHERE end_date IS NULL
- idx_acct_party_rel_dcs on (party_id) WHERE dcs_relevant = true AND end_date IS NULL
DCS note: The Single Depositor View is produced by joining accounts to account_party_relationships filtered on dcs_relevant = true, then aggregating balances per party_id. Joint accounts appear under both holders. Trustee accounts appear under the trustee party. Authorised individuals appear as non-depositor participants.
accounts.currency_register¶
Active ISO 4217 codes permitted on postings (FR-433). Seeded by MOD-004 V001/V006 migrations. Only currencies listed here may appear in accounts.postings.currency or accounts.accounts.currency.
| Column | Type | Constraints | Description |
|---|---|---|---|
| code | char(3) | PK | ISO 4217 currency code (e.g. NZD, AUD) |
| name | text | NOT NULL | Display name |
| minor_units | int | NOT NULL | Decimal places (2 for NZD/AUD) |
| is_active | bool | NOT NULL DEFAULT true | Gates posting validation |
| created_at | timestamptz | NOT NULL DEFAULT now() |
accounts.fx_rates¶
Append-only price history for all supported currency pairs. Written by MOD-004's Mod004FxRateIngest Lambda on bank.core.fx_rate_received. Immutable — corrections are new rows (append-only trigger V005).
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| source_currency | char(3) | NOT NULL | ISO 4217 source |
| target_currency | char(3) | NOT NULL | ISO 4217 target |
| mid_rate | numeric(18,8) | NOT NULL | Mid-market rate (target per 1 source unit) |
| spread_pct | numeric(8,6) | NOT NULL | Applied spread percentage |
| provider_id | text | NOT NULL | Rate provider identifier |
| rate_timestamp | timestamptz | NOT NULL | Point-in-time the rate was valid |
| received_at | timestamptz | NOT NULL DEFAULT now() | When MOD-004 ingested the rate |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_fx_rates_pair_ts on (source_currency, target_currency, rate_timestamp DESC)
accounts.fx_conversions¶
Audit row per committed 4-leg multi-currency conversion (FR-434, PAY-004 LOG). Immutable — append-only trigger V005. The idempotency_key UNIQUE constraint enables safe replay.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Customer party reference |
| source_account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| target_account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| source_amount | numeric(18,2) | NOT NULL | Amount debited from source account |
| target_amount | numeric(18,2) | NOT NULL | Amount credited to target account |
| source_currency | char(3) | NOT NULL | |
| target_currency | char(3) | NOT NULL | |
| applied_rate | numeric(18,8) | NOT NULL | Rate used for this conversion |
| spread_pct | numeric(8,6) | NOT NULL | Spread applied |
| rate_timestamp | timestamptz | NOT NULL | Timestamp of the rate row used |
| cross_border | bool | NOT NULL | True when source_currency ≠ target_currency OR source_jurisdiction ≠ target_jurisdiction (AML-008 AUTO) |
| jurisdiction | char(2) | NOT NULL | Customer's jurisdiction |
| idempotency_key | text | UNIQUE NOT NULL | Caller-supplied; UNIQUE prevents double-conversion |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_fx_conversions_party_id on (party_id)
- idx_fx_conversions_created_at on (created_at DESC)
accounts.daily_trial_balance¶
Daily per-currency reconciliation snapshot produced by MOD-004's Mod004TrialBalance cron (FR-060, REP-002 CALC). Append-only (V005 trigger). One row per (date, currency, jurisdiction) per run.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| balance_date | date | NOT NULL | Business date |
| currency | char(3) | NOT NULL | |
| jurisdiction | char(2) | NOT NULL | |
| total_debits | numeric(18,2) | NOT NULL | Sum of all debit postings for the day |
| total_credits | numeric(18,2) | NOT NULL | Sum of all credit postings for the day |
| net_position | numeric(18,2) | NOT NULL | Credits minus debits |
| is_balanced | bool | NOT NULL | True when net_position = 0 (excluding revaluation P&L rows) |
| run_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_daily_trial_balance_date_ccy on (balance_date, currency, jurisdiction)
accounts.fx_revaluation_snapshots¶
One mutable row per (jurisdiction) holding the most recent EOD revaluation state. Replaced in-place on each revaluation run (not append-only — this is a state table, not an audit table). MOD-004 uses this to compute the P&L delta on each revaluation run.
| Column | Type | Constraints | Description |
|---|---|---|---|
| jurisdiction | char(2) | PK CHECK (jurisdiction IN ('NZ','AU')) | |
| last_rate_timestamp | timestamptz | NOT NULL | Timestamp of the rate row used in the last revaluation |
| last_run_at | timestamptz | NOT NULL | Wall-clock of last revaluation run |
| open_position_nzd | numeric(18,2) | NZD-equivalent open position at last revaluation | |
| open_position_aud | numeric(18,2) | AUD-equivalent open position | |
| cumulative_pnl | numeric(18,2) | NOT NULL DEFAULT 0.00 | Running FX translation gain/loss (FR-435) |
Note: Revaluation P&L postings to accounts.postings carry metadata.revaluation_pnl = 'true' and are excluded from the FR-060 trial-balance reconciliation check.
accounts.transaction_overrides¶
Append-only register of customer and back-office corrections to transaction categorisation. Owned by MOD-166. Each row records a single correction event — when a customer or agent overrides the category assigned by MOD-041's ML model. Append-only Cat 1 immutable per ADR-048; a customer changing their mind writes a new row (the latest created_at wins for retrain purposes). Flows to Snowflake via the MOD-042 CDC pipeline as BANK_{ENV}_CORE.RAW.TRANSACTION_OVERRIDES (a TRANSFORM-layer view projecting from the per-domain cdc_events Iceberg union table — see MOD-042 § "Snowflake operator workflow"), where MOD-041's stg_customer_corrections dbt model reads it for weekly retrain.
| Column | Type | Constraints | Description |
|---|---|---|---|
| override_id | uuid | PK DEFAULT gen_random_uuid() | |
| posting_id | uuid | NOT NULL REFERENCES accounts.postings(id) | The posting being re-categorised |
| customer_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) — validated by handler against accounts.account_party_relationships; not trusted from caller |
| override_category_l1 | text | NOT NULL | Corrected top-level category |
| override_category_l2 | text | Corrected sub-category (nullable) | |
| override_source | text | NOT NULL CHECK (override_source IN ('customer_app','back_office_agent')) | Origin of the correction |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_transaction_overrides_posting_id on (posting_id)
- idx_transaction_overrides_customer_id on (customer_id)
Notes: No updated_at. Immutability enforced by trg_transaction_overrides_immutable (ADR-048 Cat 1 — BEFORE UPDATE OR DELETE raises insufficient_privilege). CDC path: cdc_core_pub is FOR ALL TABLES — this table is auto-included. The Snowflake projection (BANK_{ENV}_CORE.RAW.TRANSACTION_OVERRIDES) is operator-applied via MOD-042-cdc-pipeline-neon-to-iceberg/snowflake/transaction-overrides-projection.sql after MOD-166 deploys and the first Iceberg snapshot lands.
Internal accounts register¶
The following is_internal = true accounts are seeded by migrations and must never appear in customer-facing balance aggregations or Single Depositor View calculations.
account_number |
product_code |
currency |
jurisdiction |
Seeded by | Purpose |
|---|---|---|---|---|---|
| INT-NZ-NZD-NOSTRO | INTERNAL_FX_NOSTRO_NZD | NZD | NZ | MOD-004 V006 | NZD leg of FX nostro pair |
| INT-AU-AUD-NOSTRO | INTERNAL_FX_NOSTRO_AUD | AUD | AU | MOD-004 V006 | AUD leg of FX nostro pair |
| INT-NZ-FX-PL | INTERNAL_FX_PL_NZD | NZD | NZ | MOD-004 V006 | FR-435 FX P&L collector (NZ) |
| INT-AU-FX-PL | INTERNAL_FX_PL_AU | AUD | AU | MOD-004 V006 | FR-435 FX P&L collector (AU) |
| INT-NZ-UNCLAIMED | INTERNAL_UNCLAIMED_MONEY_NZ | NZD | NZ | MOD-008 V004 | Escheated NZD funds (IRD lodgement) |
| INT-AU-UNCLAIMED | INTERNAL_UNCLAIMED_MONEY_AU | AUD | AU | MOD-008 V004 | Escheated AUD funds (ASIC lodgement) |
| INT-NZ-INTEREST-EXPENSE | INTERNAL_INTEREST_EXPENSE_NZ | NZD | NZ | MOD-005 V004 | DEBIT leg for savings interest credits (bank's interest expense) |
| INT-AU-INTEREST-EXPENSE | INTERNAL_INTEREST_EXPENSE_AU | AUD | AU | MOD-005 V004 | DEBIT leg for savings interest credits (bank's interest expense) |
| INT-NZ-INTEREST-INCOME | INTERNAL_INTEREST_INCOME_NZ | NZD | NZ | MOD-005 V004 | CREDIT leg for overdraft interest charges (bank's interest income) |
| INT-AU-INTEREST-INCOME | INTERNAL_INTEREST_INCOME_AU | AUD | AU | MOD-005 V004 | CREDIT leg for overdraft interest charges (bank's interest income) |
| GL-ECL-EXP-9100-NZ | GL_ECL_EXPENSE_NZD | NZD | NZ | MOD-001 V008 | DEBIT leg for ECL expense recognition (NZD) — deterministic UUID 00000000-0000-0000-0000-00000000ec01 |
| GL-ECL-PRV-9200-NZ | GL_ECL_PROVISION_NZD | NZD | NZ | MOD-001 V008 | CREDIT leg for ECL provision balance (NZD) — deterministic UUID 00000000-0000-0000-0000-00000000ec02 |
| GL-ECL-EXP-9100-AU | GL_ECL_EXPENSE_AUD | AUD | AU | MOD-001 V008 | DEBIT leg for ECL expense recognition (AUD) — deterministic UUID 00000000-0000-0000-0000-00000000ec03 |
| GL-ECL-PRV-9200-AU | GL_ECL_PROVISION_AUD | AUD | AU | MOD-001 V008 | CREDIT leg for ECL provision balance (AUD) — deterministic UUID 00000000-0000-0000-0000-00000000ec04 |
Declared pattern: ledger-direct-write¶
An SD01 module MAY write directly to accounts.postings outside MOD-001's handler when all of the following hold: (1) atomicity is required — multi-leg journal that needs single-transaction guarantees; (2) writes as bank_core_app_user; (3) populates every NOT NULL column including source_module; (4) updates accounts.accounts.{balance, available_balance, version} for each affected account inside the same transaction with pessimistic SELECT … FOR UPDATE in deterministic ID order; (5) emits bank.core.posting_completed or a domain event explicitly subscribed to by MOD-002's ingest rule; (6) caller-supplied idempotency key on a UNIQUE constraint; (7) integration tests include row-hash equivalence check against core.transaction_log. Outside these conditions, modules call MOD-001's posting handler. First adopted by MOD-004 (FX conversion 4-leg journal). Candidate consumer modules: MOD-005, MOD-008, MOD-110.
Schema: treasury¶
treasury.tp_rates¶
Daily funds transfer pricing rate grid, written by the MOD-086 write-back Lambda after end-of-day Snowflake computation. SD05 credit decisioning and SD01 product rate configuration read from this table — neither system queries Snowflake inline.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| rate_date | date | NOT NULL | Business date this grid applies to |
| tenor_bucket | text | NOT NULL CHECK (tenor_bucket IN ('ON','1M','3M','6M','1Y','2Y','3Y','5Y','10Y')) | Standard tenor bucket |
| tp_rate_bps | int | NOT NULL | TP rate in basis points (swap/OIS curve + liquidity premium) |
| base_curve_bps | int | NOT NULL | Raw swap/OIS curve rate before liquidity premium |
| liquidity_premium_bps | int | NOT NULL | Liquidity premium overlay applied by Treasury |
| curve_source_version | text | NOT NULL | Snowflake market.* provider data version identifier |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | NZ uses NZD swap curve; AU uses AUD swap curve |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_tp_rates_date_tenor on (rate_date DESC, tenor_bucket, jurisdiction) UNIQUE
- idx_tp_rates_current on (rate_date DESC) — supports efficient latest-day lookup
Notes: This table is append-only at the application layer. Each business day adds one row per tenor bucket per jurisdiction (18 rows per day). The curve_source_version links back to the Snowflake market.* provider snapshot used, enabling full audit traceability for any rate applied to a product.
Schema: loans¶
Loan accounting objects tightly coupled to the core banking ledger. This schema lives in bank_core (SD01) — distinct from SD05's credit schema in bank_credit — because amortisation schedules require direct write access to accounts.postings (FR-501 revolving auto-post) and direct read access to accounts.accounts, accounts.interest_rates, and accounts.postings. Cross-domain writes from SD05 are prohibited by AP-010.
This schema is for loan accounting modules that require direct ledger access. Credit decisioning, origination, bureau, and scoring remain in SD05's credit schema.
Owned by MOD-112.
loans.amortisation_schedules¶
Versioned amortisation schedules for instalment (PRD-009) and revolving (PRD-008) loan accounts. One current schedule per account at all times; prior versions retained for audit.
| Column | Type | Constraints | Description |
|---|---|---|---|
| schedule_id | uuid | PK DEFAULT gen_random_uuid() | |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| version | int | NOT NULL | Increments on every recalculation |
| schedule_type | text | NOT NULL CHECK (schedule_type IN ('PI','IO','REVOLVING')) | Principal+interest, interest-only, or revolving minimum |
| generated_at | timestamptz | NOT NULL DEFAULT now() | |
| generated_by | text | NOT NULL | Handler identifier that produced this version |
| rate_at_generation | numeric(8,6) | NOT NULL | Effective annual rate used for this schedule calculation |
| is_current | boolean | NOT NULL DEFAULT true | Exactly one row per account_id is true at any time |
Indexes:
- Partial unique index on (account_id) WHERE is_current = true — DB-enforced single-current-schedule invariant
- idx_amortisation_schedules_account on (account_id, version DESC)
ADR-048: trg_amortisation_schedules_guard — semi-permissive supersession-only: the only permitted UPDATE is the supersession transition (is_current true → false) applied to the previous current schedule when a new version is INSERTed. All other UPDATE columns, DELETE, and TRUNCATE are blocked unconditionally. New current schedules are always new INSERT rows (never false → true on an existing row).
loans.schedule_instalments¶
Per-payment-period rows for each schedule version. Produced at schedule generation; status updated as payments are received.
| Column | Type | Constraints | Description |
|---|---|---|---|
| instalment_id | uuid | PK DEFAULT gen_random_uuid() | |
| schedule_id | uuid | NOT NULL REFERENCES loans.amortisation_schedules(schedule_id) | |
| payment_number | int | NOT NULL | 1-based ordinal within the schedule |
| due_date | date | NOT NULL | |
| opening_balance | numeric(18,2) | NOT NULL | Outstanding principal at start of period |
| principal_component | numeric(18,2) | NOT NULL | Portion reducing outstanding principal |
| interest_component | numeric(18,2) | NOT NULL | Interest accrued for the period |
| instalment_amount | numeric(18,2) | NOT NULL | principal_component + interest_component |
| closing_balance | numeric(18,2) | NOT NULL | opening_balance - principal_component |
| status | text | NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','PAID','MISSED','PARTIAL')) | |
| paid_at | timestamptz | Set when status transitions to PAID or PARTIAL | |
| paid_amount | numeric(18,2) | Actual amount received |
Indexes:
- idx_schedule_instalments_schedule on (schedule_id, payment_number)
- idx_schedule_instalments_due_date on (due_date) WHERE status = 'PENDING'
ADR-048: trg_schedule_instalments_guard — semi-permissive status-graph trigger: permits only legally valid status transitions: PENDING → PAID, PENDING → MISSED, PENDING → PARTIAL, PARTIAL → PAID, PARTIAL → MISSED, MISSED → PAID. All other mutations (UPDATE on non-status columns, DELETE, TRUNCATE) are blocked unconditionally.
loans.amortisation_events¶
Per-module governance log (append-only). One row per significant schedule lifecycle event for audit traceability.
| Column | Type | Constraints | Description |
|---|---|---|---|
| event_id | uuid | PK DEFAULT gen_uuidv7() | Time-ordered |
| account_id | uuid | NOT NULL | |
| schedule_id | uuid | Associated schedule version (NULL for revolving fee events) | |
| event_type | text | NOT NULL CHECK (event_type IN ('SCHEDULE_GENERATED','SCHEDULE_RECALCULATED','EXTRA_REPAYMENT_OFFERED','EXTRA_REPAYMENT_ACCEPTED','IO_EXPIRY_TRANSITION','HARDSHIP_RESTRUCTURED','REVOLVING_MIN_REPAYMENT_POSTED','REVOLVING_LATE_FEE_ASSESSED')) | |
| triggered_by | text | NOT NULL | Source event or handler (e.g. rate_change_propagated, originate-schedule) |
| idempotency_key | text | UNIQUE NOT NULL | |
| payload | jsonb | NOT NULL DEFAULT '{}' | Event-specific detail |
| occurred_at | timestamptz | NOT NULL DEFAULT now() | |
| trace_id | text | X-Ray trace correlation |
ADR-048 Cat 1: amortisation_events_reject_mutation — full BEFORE UPDATE OR DELETE OR TRUNCATE block. Raises exception unconditionally; REVOKE UPDATE/DELETE/TRUNCATE on bank_core_app_user is the application-layer complement.
Schema: contexts¶
Non-legal but economically meaningful units. The mechanism for sole trader business views, rental property tracking, and operating context separation without creating fake legal entities. See party and user schema for design rationale.
contexts.operating_contexts¶
| Column | Type | Constraints | Description |
|---|---|---|---|
| context_id | uuid | PK DEFAULT gen_random_uuid() | |
| context_type | text | NOT NULL CHECK (context_type IN ('PERSONAL','SOLE_TRADER_BUSINESS','RENTAL_PROPERTY','RENTAL_PORTFOLIO','COMPANY_VIEW','TRUST_VIEW','COST_CENTRE','PROJECT')) | |
| display_name | text | NOT NULL | e.g. "Ross's Consulting", "12 Smith St Investment" |
| primary_party_id | uuid | NOT NULL | Cross-domain FK to SD02 party.parties(party_id) — the legal owner |
| legal_party_id | uuid | Cross-domain FK to SD02 party.parties(party_id) — set only when context IS a registered legal entity |
|
| tax_profile_id | uuid | REFERENCES contexts.tax_profiles(tax_profile_id) | |
| status | text | NOT NULL CHECK (status IN ('ACTIVE','SUSPENDED','CLOSED')) | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| closed_at | timestamptz |
Rules: SOLE_TRADER_BUSINESS: legal_party_id = NULL (the natural person IS the sole trader — no separate entity). COMPANY_VIEW: legal_party_id = the company's party_id. RENTAL_PROPERTY: legal_party_id = NULL unless property is held in a company.
Indexes:
- idx_operating_contexts_primary_party on (primary_party_id) WHERE status = 'ACTIVE'
contexts.operating_context_memberships¶
| Column | Type | Constraints | Description |
|---|---|---|---|
| membership_id | uuid | PK DEFAULT gen_random_uuid() | |
| context_id | uuid | NOT NULL REFERENCES contexts.operating_contexts(context_id) | |
| party_id | uuid | NOT NULL | Cross-domain FK to SD02 party.parties(party_id) |
| role_type | text | NOT NULL CHECK (role_type IN ('OWNER','CO_OWNER','MANAGER','ACCOUNTANT','PROPERTY_MANAGER')) | |
| economic_interest_pct | numeric(7,4) | For co-ownership | |
| start_date | date | NOT NULL | |
| end_date | date | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
contexts.account_context_links¶
Links an account to an operating context. Enables sole trader accounts to be attributed to a sole trader context, property income accounts to a property context, and virtual allocation of a personal account across contexts.
| Column | Type | Constraints | Description |
|---|---|---|---|
| link_id | uuid | PK DEFAULT gen_random_uuid() | |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| context_id | uuid | NOT NULL REFERENCES contexts.operating_contexts(context_id) | |
| link_type | text | NOT NULL CHECK (link_type IN ('REAL_ACCOUNT','VIRTUAL_ACCOUNT','PRIMARY_SETTLEMENT','REPORTING_ONLY')) | |
| allocation_method | text | NOT NULL CHECK (allocation_method IN ('DIRECT','PERCENT_SPLIT','RULE_BASED')) | |
| default_allocation_pct | numeric(7,4) | For PERCENT_SPLIT — must sum to 100 across all links for an account | |
| effective_from | date | NOT NULL | |
| effective_to | date | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
contexts.tax_profiles¶
Tax treatment configuration per operating context. Owns the GST registration, filing frequency, ring-fencing elections, and provisional tax method for each economic unit.
| Column | Type | Constraints | Description |
|---|---|---|---|
| tax_profile_id | uuid | PK DEFAULT gen_random_uuid() | |
| context_type | text | NOT NULL | Mirrors operating_contexts.context_type |
| jurisdiction | char(2) | NOT NULL | |
| gst_registered | boolean | NOT NULL DEFAULT false | |
| gst_number | text | ||
| gst_filing_frequency | text | CHECK (IN ('MONTHLY','TWO_MONTHLY','SIX_MONTHLY')) | |
| gst_basis | text | CHECK (IN ('INVOICE','PAYMENTS','HYBRID')) | |
| income_tax_treatment | text | CHECK (IN ('SCHEDULAR','STANDARD','COMPANY','TRUST')) | |
| ring_fencing_flag | boolean | NOT NULL DEFAULT false | True for NZ residential rental (ss EE 1–8 ITA 2007) |
| ring_fencing_group_id | uuid | Groups properties for portfolio ring-fence offset calculations | |
| provisional_tax_method | text | CHECK (IN ('STANDARD','ESTIMATION','RATIO')) | |
| effective_from | date | NOT NULL | |
| effective_to | date | NULL = currently active | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Schema: assets¶
External and physical assets owned by parties — primarily properties and investment accounts. Not accounts. Not customers. Written by MOD-100 (external asset connector) for financial assets (KiwiSaver/super) and by customer-facing modules for property assets.
assets.assets¶
| Column | Type | Constraints | Description |
|---|---|---|---|
| asset_id | uuid | PK DEFAULT gen_random_uuid() | |
| asset_type | text | NOT NULL CHECK (asset_type IN ('PROPERTY','KIWISAVER','SUPERANNUATION','VEHICLE','INVESTMENT','OTHER')) | |
| display_name | text | NOT NULL | e.g. "12 Smith St, Wellington", "Fisher Funds KiwiSaver" |
| jurisdiction | char(2) | NOT NULL | |
| property_use_type | text | CHECK (IN ('OWNER_OCCUPIED','RENTAL_RESIDENTIAL','RENTAL_COMMERCIAL','MIXED_USE','HOLIDAY_HOME')) — PROPERTY only | |
| status | text | NOT NULL CHECK (status IN ('ACTIVE','SOLD','TRANSFERRED','CLOSED')) | |
| estimated_value | numeric(18,2) | Customer-provided estimate; not a valuation | |
| current_balance | numeric(18,2) | For financial assets (KiwiSaver/super): current balance from provider | |
| provider_name | text | For financial assets: provider or fund manager name | |
| provider_fund_type | text | For KiwiSaver/super: CONSERVATIVE, BALANCED, GROWTH, AGGRESSIVE | |
| last_refreshed_at | timestamptz | When external data was last retrieved (financial assets) | |
| acquired_date | date | ||
| disposed_date | date | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_assets_type_status on (asset_type, status)
- idx_assets_last_refreshed on (last_refreshed_at) WHERE asset_type IN ('KIWISAVER','SUPERANNUATION')
assets.asset_party_relationships¶
| Column | Type | Constraints | Description |
|---|---|---|---|
| asset_relationship_id | uuid | PK DEFAULT gen_random_uuid() | |
| asset_id | uuid | NOT NULL REFERENCES assets.assets(asset_id) | |
| party_id | uuid | NOT NULL | Cross-domain FK to SD02 party.parties(party_id) |
| relationship_type | text | NOT NULL CHECK (relationship_type IN ('LEGAL_OWNER','BENEFICIAL_OWNER','CO_OWNER','PROPERTY_MANAGER','MORTGAGEE','MEMBER')) | MEMBER for KiwiSaver/super account holder |
| ownership_share_pct | numeric(7,4) | For co-ownership | |
| start_date | date | NOT NULL | |
| end_date | date | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
assets.property_contexts¶
Connects a property asset to its operating context for tax reporting. One row per property asset.
| Column | Type | Constraints | Description |
|---|---|---|---|
| property_context_id | uuid | PK DEFAULT gen_random_uuid() | |
| asset_id | uuid | NOT NULL REFERENCES assets.assets(asset_id) UNIQUE | |
| context_id | uuid | NOT NULL REFERENCES contexts.operating_contexts(context_id) | |
| ring_fencing_group_id | uuid | Groups properties for NZ portfolio ring-fence offset | |
| tax_profile_id | uuid | NOT NULL REFERENCES contexts.tax_profiles(tax_profile_id) | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Schema: core¶
Shared tables used across bank-core modules — fee configuration (MOD-110), trust account governance (MOD-133), and supplementary account metadata. Written by the owning module's migrations; read by multiple SD01 modules.
core.fee_schedule¶
Fee configuration for product-linked charges. Written by MOD-110. notice_days >= 14 is a CON-005 statutory floor enforced at DB layer (ADR-048).
(Detailed column listing owned by MOD-110 design doc.)
core.fee_events¶
Append-only log of assessed fee events. Written by MOD-110. idempotency_key UNIQUE prevents double-charge. (Detailed column listing owned by MOD-110 design doc.)
core.fee_config_propagation_events (MOD-110 V007)¶
Append-only audit log of fee-parameter changes propagated from MOD-127 (product config panel) via the bank-app.product_config_applied EventBridge event. Cat 1 immutable (ADR-048 trigger core.fee_config_propagation_events_reject_mutation). Written by MOD-110's consume-product-config-applied Lambda.
| Column | Type | Constraints | Description |
|---|---|---|---|
| event_id | uuid | PK DEFAULT gen_random_uuid() | |
| proposal_id | uuid | NOT NULL | MOD-127 proposal that triggered the propagation |
| product_id | text | NOT NULL | |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| parameter_key | text | NOT NULL | e.g. fee.account.monthly |
| previous_value | jsonb | Null on first-ever set | |
| current_value | jsonb | NOT NULL | |
| applied_at | timestamptz | NOT NULL | From MOD-127 event payload |
| idempotency_key | text | NOT NULL UNIQUE | Keyed on EventBridge event_id — replay-safe |
| trace_id | uuid | ||
| correlation_id | uuid | ||
| received_at | timestamptz | NOT NULL DEFAULT now() |
v1 is audit-only — records the propagation but does not bridge to core.fee_schedule. The full bridge (syncing parameter values into fee-schedule rows) is a v2 scope item requiring explicit design.
core.trust_accounts (MOD-133 V001)¶
One row per trust account — 1:1 with accounts.accounts. Contains trust-specific metadata not appropriate in the generic accounts table. Created by MOD-133 V001 migration.
| Column | Type | Constraints | Description |
|---|---|---|---|
| trust_account_id | uuid | PK DEFAULT gen_random_uuid() | |
| account_id | uuid | NOT NULL UNIQUE REFERENCES accounts.accounts(id) | |
| trust_name | text | NOT NULL | Display name for the trust |
| trust_type | text | NOT NULL CHECK (trust_type IN ('family_discretionary','testamentary','charitable','commercial_unit')) | |
| trust_deed_document_id | uuid | Cross-domain reference to MOD-073 document store; nullable in DDL, required at activation Lambda gate | |
| established_date | date | NOT NULL | Date the trust was legally established |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| enhanced_dd_completed | boolean | NOT NULL DEFAULT false | AML-004 enhanced CDD gate — must be true before activation |
| enhanced_dd_completed_at | timestamptz | Stamped when enhanced_dd_completed set to true | |
| review_interval_months | int | CHECK (review_interval_months IS NULL OR review_interval_months > 0) | Periodic CDD review cadence; null = no periodic review configured |
| next_review_date | date | Computed from review_interval_months when set | |
| activated_at | timestamptz | Stamped by the activate Lambda on successful gate pass | |
| closed_at | timestamptz | CHECK (closed_at IS NULL OR closed_at > activated_at) | Stamped by close Lambda (ADR-048 Cat 2, V004) |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_trust_accounts_account_id on (account_id) UNIQUE
- idx_trust_accounts_jurisdiction on (jurisdiction) WHERE closed_at IS NULL
core.trust_party_metadata (MOD-133 V002)¶
Overlay table keyed 1:1 by relationship_id from accounts.account_party_relationships. Holds trust-role semantics and ownership percentages that are distinct from the SDV/CRS ownership_share_pct on the parent relationship row.
| Column | Type | Constraints | Description |
|---|---|---|---|
| relationship_id | uuid | PK REFERENCES accounts.account_party_relationships(relationship_id) | 1:1 with the relationship row |
| trust_account_id | uuid | NOT NULL REFERENCES core.trust_accounts(trust_account_id) | Denormalised header reference |
| trust_role | text | NOT NULL CHECK (trust_role IN ('TRUSTEE','BENEFICIAL_OWNER','APPOINTOR','PROTECTOR','SETTLOR')) | |
| ownership_pct | numeric(5,2) | CHECK (ownership_pct IS NULL OR (ownership_pct >= 0 AND ownership_pct <= 100)) | Trust ownership percentage; NULL for discretionary BOs and non-ownership roles |
| kyc_status | text | Denormalised cache of accounts.kyc_status_mirror.status; activation gate reads mirror for source-of-truth |
|
| created_at | timestamptz | NOT NULL DEFAULT now() |
Note: accounts.account_party_relationships.ownership_share_pct is the SDV/CRS aggregation field for joint accounts and is NOT used for trust ownership — trust ownership semantics live in this overlay table.
core.trust_governance_events (MOD-133 V003)¶
Append-only immutable log of all trust governance events. Immutability enforced by ADR-048 triggers (V004). One row per lifecycle or review event. idempotency_key UNIQUE prevents duplicate writes on handler retry.
| Column | Type | Constraints | Description |
|---|---|---|---|
| event_id | uuid | PK DEFAULT gen_random_uuid() | |
| trust_account_id | uuid | NOT NULL REFERENCES core.trust_accounts(trust_account_id) | |
| account_id | uuid | NOT NULL | Denormalised for read scans |
| party_id | uuid | Nullable; populated for per-party events (TRUSTEE_ADDED etc.) | |
| relationship_id | uuid | Nullable; populated when event relates to a specific relationship row | |
| event_type | text | NOT NULL CHECK (event_type IN ('TRUST_OPENED','TRUSTEE_ADDED','TRUSTEE_REMOVED','BENEFICIAL_OWNER_ADDED','BENEFICIAL_OWNER_CHANGED','BENEFICIAL_OWNER_REMOVED','APPOINTOR_ADDED','PROTECTOR_ADDED','SETTLOR_RECORDED','TRUST_ACTIVATED','DEED_UPLOADED','DEED_UPDATED','CDD_REVIEW_TRIGGERED','CDD_REVIEW_COMPLETED','TRUST_CLOSED')) | 15 event types |
| due_at | timestamptz | Populated iff event_type = CDD_REVIEW_TRIGGERED (FR-595 30-day SLA) | |
| review_trigger | text | CHECK (review_trigger IN ('TRUSTEE_CHANGE','BENEFICIARY_CHANGE','RISK_ELEVATION','PERIODIC')) | Populated iff event_type = CDD_REVIEW_TRIGGERED |
| completes_event_id | uuid | REFERENCES core.trust_governance_events(event_id) | Populated iff event_type = CDD_REVIEW_COMPLETED; links back to the TRIGGERED row |
| actor_kind | text | NOT NULL CHECK (actor_kind IN ('agent','staff','system')) | MOD-047 producer contract |
| actor_id | text | NOT NULL | Sub of the JWT or system identifier |
| metadata | jsonb | Arbitrary additional context | |
| idempotency_key | text | UNIQUE NOT NULL | Caller-supplied; UNIQUE prevents duplicate event rows on handler retry |
| trace_id | text | OpenTelemetry trace identifier | |
| correlation_id | text | Request correlation identifier | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_trust_governance_events_trust_account_id on (trust_account_id, created_at DESC)
- idx_trust_governance_events_open_reviews on (trust_account_id, due_at) WHERE event_type = 'CDD_REVIEW_TRIGGERED' AND completes_event_id IS NULL
core.community_accounts (MOD-134 V001)¶
One row per community entity account — 1:1 with accounts.accounts. Holds community-specific metadata. The signing rule is frozen at account opening and governs all subsequent authorisation requests.
| Column | Type | Constraints | Description |
|---|---|---|---|
| community_account_id | uuid | PK DEFAULT gen_random_uuid() | |
| account_id | uuid | NOT NULL UNIQUE REFERENCES accounts.accounts(id) | |
| entity_name | text | NOT NULL | Legal name of the community entity (sports club, residents' association, etc.) |
| entity_type | text | NOT NULL CHECK (entity_type IN ('sports_club','residents_association','incorporated_society','charitable_trust','body_corporate','other')) | |
| constitution_document_id | uuid | Cross-domain reference to MOD-073 document store; nullable in DDL, required at activation Lambda gate (FR-597) | |
| signing_rule | text | NOT NULL CHECK (signing_rule IN ('any_one','any_two','all')) | FR-598 signing rule; frozen at opening; changes require a new governance event |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| enhanced_dd_completed | boolean | NOT NULL DEFAULT false | AML-002 gate — all signatories eIDV-verified before activation |
| enhanced_dd_completed_at | timestamptz | Stamped when enhanced_dd_completed set to true | |
| activated_at | timestamptz | Stamped by activate Lambda on gate pass | |
| closed_at | timestamptz | CHECK (closed_at IS NULL OR closed_at > activated_at) | Stamped by close Lambda (ADR-048 Cat 2, V005) |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_community_accounts_account_id on (account_id) UNIQUE
- idx_community_accounts_jurisdiction on (jurisdiction) WHERE closed_at IS NULL
core.community_signatory_metadata (MOD-134 V002)¶
Overlay table keyed 1:1 by relationship_id from accounts.account_party_relationships. All community entity signatories use relationship_type = 'SIGNATORY' on the parent row. Committee role lives on this overlay.
| Column | Type | Constraints | Description |
|---|---|---|---|
| relationship_id | uuid | PK REFERENCES accounts.account_party_relationships(relationship_id) | 1:1 with the relationship row |
| community_account_id | uuid | NOT NULL REFERENCES core.community_accounts(community_account_id) | Denormalised header reference |
| committee_role | text | NOT NULL CHECK (committee_role IN ('president','treasurer','secretary','authorised_signatory')) | |
| valid_from | date | NOT NULL | Shadows relationship start_date for community semantics |
| valid_until | date | Shadows relationship end_date; NULL = current signatory | |
| kyc_status | text | Denormalised cache of accounts.kyc_status_mirror.status; activation gate reads mirror for source-of-truth |
|
| created_at | timestamptz | NOT NULL DEFAULT now() |
core.community_authorisations (MOD-134 V003)¶
One row per outbound transaction-authorisation request. The signing rule and signatory roster are frozen at create time (snapshot semantics). An authorisation reaches COMPLETE when count(distinct approver) >= rule_minimum. FR-598's "not UI-only" enforcement: callers must present a COMPLETE authorisation before a payment posting is accepted.
| Column | Type | Constraints | Description |
|---|---|---|---|
| authorisation_id | uuid | PK DEFAULT gen_random_uuid() | |
| community_account_id | uuid | NOT NULL REFERENCES core.community_accounts(community_account_id) | |
| account_id | uuid | NOT NULL | Denormalised for query performance |
| signing_rule | text | NOT NULL CHECK (signing_rule IN ('any_one','any_two','all')) | Frozen copy of community_accounts.signing_rule at create time |
| signatory_snapshot | jsonb | NOT NULL | Array of {relationship_id, party_id} active at create time; defines who may approve |
| required_approvals | int | NOT NULL CHECK (required_approvals > 0) | Derived from signing_rule: 1 / 2 / count(signatories) |
| status | text | NOT NULL CHECK (status IN ('PENDING','COMPLETE','EXPIRED','CANCELLED')) DEFAULT 'PENDING' | |
| expires_at | timestamptz | NOT NULL CHECK (expires_at > created_at) | Default: created_at + AUTHORISATION_DEFAULT_EXPIRY_SECONDS SSM param (default 72 h) |
| completed_at | timestamptz | CHECK (completed_at IS NULL OR completed_at >= created_at) | Stamped when final required approval is recorded |
| idempotency_key | text | UNIQUE NOT NULL | Caller-supplied |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_community_authorisations_account_id_status on (account_id, status) WHERE status = 'PENDING'
- idx_community_authorisations_expires_at on (expires_at) WHERE status = 'PENDING'
core.community_authorisation_approvals (MOD-134 V003)¶
One row per signatory's approval of a given authorisation. UNIQUE (authorisation_id, signatory_relationship_id) prevents one signatory counting twice. Only signatories present in the parent authorisation's signatory_snapshot may approve.
| Column | Type | Constraints | Description |
|---|---|---|---|
| approval_id | uuid | PK DEFAULT gen_random_uuid() | |
| authorisation_id | uuid | NOT NULL REFERENCES core.community_authorisations(authorisation_id) | |
| signatory_relationship_id | uuid | NOT NULL REFERENCES accounts.account_party_relationships(relationship_id) | Must be present in parent authorisation's signatory_snapshot |
| party_id | uuid | NOT NULL | Denormalised for audit queries |
| approved_at | timestamptz | NOT NULL DEFAULT now() | |
| idempotency_key | text | UNIQUE NOT NULL | Caller-supplied; prevents duplicate approvals on handler retry |
Constraints:
- UNIQUE (authorisation_id, signatory_relationship_id) — one approval per signatory per authorisation
core.community_governance_events (MOD-134 V004)¶
Append-only immutable log of all community account governance events. Immutability enforced by ADR-048 triggers (V005). idempotency_key UNIQUE NOT NULL ensures replay-safe handler retries.
| Column | Type | Constraints | Description |
|---|---|---|---|
| event_id | uuid | PK DEFAULT gen_random_uuid() | |
| community_account_id | uuid | NOT NULL REFERENCES core.community_accounts(community_account_id) | |
| account_id | uuid | NOT NULL | Denormalised for read scans |
| party_id | uuid | Nullable; populated for per-signatory events | |
| relationship_id | uuid | Nullable; populated when event relates to a specific relationship row | |
| event_type | text | NOT NULL CHECK (event_type IN ('COMMUNITY_OPENED','SIGNATORY_ADDED','SIGNATORY_REMOVED','SIGNING_RULE_SET','SIGNING_RULE_CHANGED','COMMUNITY_ACTIVATED','CONSTITUTION_UPLOADED','CONSTITUTION_UPDATED','AUTHORISATION_CREATED','AUTHORISATION_COMPLETED','AUTHORISATION_EXPIRED','AUTHORISATION_CANCELLED','COMMITTEE_REFRESHED','KYC_DEGRADATION_DETECTED','RESTRICTION_APPLIED','RESTRICTION_LIFTED','COMMUNITY_CLOSED')) | 17 event types |
| actor_kind | text | NOT NULL CHECK (actor_kind IN ('agent','staff','system')) | MOD-047 producer contract |
| actor_id | text | NOT NULL | Sub of the JWT or system identifier |
| metadata | jsonb | Arbitrary additional context | |
| idempotency_key | text | UNIQUE NOT NULL | Caller-supplied; UNIQUE prevents duplicate event rows on handler retry |
| trace_id | text | OpenTelemetry trace identifier | |
| correlation_id | text | Request correlation identifier | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_community_governance_events_account_id on (community_account_id, created_at DESC)
core.accrual_runs (MOD-005 V002)¶
One row per accrual run. Identity fields are immutable (V003 trigger rejects UPDATE on identity columns and rejects all DELETE/TRUNCATE); completion fields are mutable (set by completeRun on run finish).
| Column | Type | Constraints | Description |
|---|---|---|---|
| run_id | uuid | PK DEFAULT gen_random_uuid() | |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | Run jurisdiction |
| accrual_date | date | NOT NULL | Business date the accrual is calculated for |
| run_type | text | NOT NULL CHECK (run_type IN ('DAILY','CORRECTION')) | DAILY = scheduled EOD run; CORRECTION = manual retroactive correction |
| period_start | date | NOT NULL | Inclusive start of interest period (= accrual_date for DAILY) |
| period_end | date | NOT NULL | Inclusive end; chk_accrual_run_dates requires period_end >= period_start |
| correction_of_run_id | uuid | REFERENCES core.accrual_runs(run_id) | Populated for CORRECTION runs; FK to the original DAILY run |
| status | text | NOT NULL CHECK (status IN ('PENDING','IN_PROGRESS','COMPLETED','FAILED')) | Mutable — updated by completeRun |
| started_at | timestamptz | NOT NULL DEFAULT now() | Wall-clock start — identity field, immutable |
| completed_at | timestamptz | Mutable — populated when status reaches terminal state | |
| accounts_processed | int | Mutable — total accounts evaluated | |
| accounts_posted | int | Mutable — accounts with a posted accrual row | |
| accounts_skipped | int | Mutable — accounts skipped (eligibility or zero-cent) | |
| accounts_errored | int | Mutable — accounts that errored | |
| interest_credited | numeric(18,8) | Mutable — total interest credited (savings) | |
| interest_charged | numeric(18,8) | Mutable — total interest charged (overdraft) | |
| by_product | jsonb | Mutable — per product_code breakdown {count, amount} |
|
| variance_flags | jsonb | Mutable — accounts where posted amount deviated from expected formula | |
| idempotency_key | text | UNIQUE NOT NULL | Caller-supplied; prevents duplicate run creation on handler retry |
| actor_kind | text | NOT NULL CHECK (actor_kind IN ('agent','staff','system')) | Who initiated the run |
| actor_id | text | NOT NULL | Sub of the JWT or system identifier |
| trace_id | text | OpenTelemetry trace identifier | |
| correlation_id | text | Request correlation identifier | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_accrual_runs_jurisdiction_date on (jurisdiction, accrual_date, run_type) — run-status lookup by date
- idx_accrual_runs_correction_of_run_id on (correction_of_run_id) WHERE correction_of_run_id IS NOT NULL
CHECKs: chk_accrual_run_dates (period_end ≥ period_start); chk_accrual_run_completion_coherence (completed_at populated iff status terminal); chk_accrual_run_correction_link_iff_correction (correction_of_run_id IS NOT NULL iff run_type = 'CORRECTION'). All added by V002.
Partial immutability (V003): trg_accrual_runs_immutable_identity — BEFORE UPDATE, rejects changes to identity fields (run_id, jurisdiction, accrual_date, run_type, period_start, period_end, correction_of_run_id, started_at, idempotency_key, actor_kind, actor_id). Completion fields (status, completed_at, accounts_*, interest_*, by_product, variance_flags) remain mutable for completeRun. trg_accrual_runs_no_delete_truncate — blocks all DELETE and TRUNCATE.
core.accrual_postings (MOD-005 V002)¶
One row per interest accrual calculation per (account, accrual_date). Append-only (V003 immutability triggers — Cat 1 full immutability). For DAILY runs a partial UNIQUE index on (account_id, accrual_date) WHERE corrects_accrual_posting_id IS NULL prevents double-accrual; CORRECTION rows are exempt because they reference the original.
| Column | Type | Constraints | Description |
|---|---|---|---|
| accrual_posting_id | uuid | PK DEFAULT gen_random_uuid() | |
| run_id | uuid | NOT NULL REFERENCES core.accrual_runs(run_id) | Parent run |
| account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | Target account |
| product_code | text | NOT NULL | Product code at accrual time — captured for audit |
| accrual_date | date | NOT NULL | Business date this accrual was calculated for |
| principal_cents | bigint | NOT NULL | Balance at accrual time in minor units — captured for audit |
| annual_rate | numeric(8,6) | NOT NULL | Rate applied — captured for audit |
| rate_type | text | NOT NULL | Rate band (BASE, OVERDRAFT, etc.) |
| day_count_basis | text | NOT NULL DEFAULT 'ACT365' | Day-count convention — ACT/365 in v1; configurable for future per-product override |
| amount | numeric(18,2) | NOT NULL CHECK (amount <> 0) | Signed — positive for savings credits, negative for overdraft debits. SUM(amount) reconciles to net interest paid. |
| currency | char(3) | NOT NULL | ISO 4217 — matches account currency |
| residual_micros | bigint | NOT NULL DEFAULT 0 | Sub-cent carry-forward balance in micros. Signed — can be negative ("borrow forward" when banker's rounding pushes the posted cent beyond the total). Carried into the next day's accrual calculation. |
| posting_id | uuid | NOT NULL REFERENCES accounts.postings(id) | The ledger posting row this accrual produced |
| corrects_accrual_posting_id | uuid | REFERENCES core.accrual_postings(accrual_posting_id) | Self-FK. NULL for DAILY rows; populated for CORRECTION rows — links correction to the original posting. |
| metadata | jsonb | NOT NULL DEFAULT '{}' | kind ∈ {CORRECTED, REVERSAL} for correction rows |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- UNIQUE (account_id, accrual_date) WHERE corrects_accrual_posting_id IS NULL — prevents double-accrual on daily path
- idx_accrual_postings_run_id on (run_id)
- idx_accrual_postings_account_date on (account_id, accrual_date DESC)
core.joint_accounts (MOD-125 V001)¶
Overlay table 1:1 with accounts.accounts. Presence of a row is the discriminator for joint account behaviour; single-holder accounts have no corresponding row. Re-uses existing product codes (NZ_TRANSACTION_01, NZ_SAVINGS_01, AU_TRANSACTION_01, AU_SAVINGS_01).
| Column | Type | Constraints | Description |
|---|---|---|---|
| joint_account_id | uuid | PK REFERENCES accounts.accounts(id) | 1:1 with accounts.accounts |
| signing_authority | text | NOT NULL CHECK (signing_authority IN ('any_one','any_two','all')) | Default signing rule for PAYMENT authorisations |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| death_documentation_status | text | NOT NULL DEFAULT 'none' CHECK (death_documentation_status IN ('none','frozen','accepted')) | 'none' = no holder death recorded; 'frozen' = death recorded, account frozen pending documentation; 'accepted' = documentation accepted, account unfrozen |
| death_documentation_id | uuid | Opaque MOD-073 document UUID; populated by acceptDeathDocumentation |
|
| activated_at | timestamptz | Stamped when activation gate passes | |
| closed_at | timestamptz | CHECK (closed_at IS NULL OR closed_at >= activated_at) | Temporal constraint — V005 (MOD-125) |
| idempotency_key | text | UNIQUE NOT NULL | Handler retry safety |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
core.joint_holder_metadata (MOD-125 V002)¶
1:1 overlay on accounts.account_party_relationships rows where relationship_type = 'JOINT_HOLDER'. Carries per-holder lifecycle state that is distinct from the party relationship itself.
| Column | Type | Constraints | Description |
|---|---|---|---|
| holder_relationship_id | uuid | PK REFERENCES accounts.account_party_relationships(relationship_id) | 1:1 with the JOINT_HOLDER relationship row |
| is_primary | boolean | NOT NULL DEFAULT false | Primary holder designation (informational only; all holders have equal rights in v1) |
| holder_status | text | NOT NULL DEFAULT 'active' CHECK (holder_status IN ('active','deceased','removed')) | |
| deceased_at | timestamptz | CHECK (deceased_at IS NOT NULL OR holder_status != 'deceased') — chk_joint_holder_deceased_at_iff_deceased |
Populated by recordDeath; NULL unless status = 'deceased' |
| removed_at | timestamptz | CHECK (removed_at IS NOT NULL OR holder_status != 'removed') — chk_joint_holder_removed_at_iff_removed |
Populated by removeHolder; NULL unless status = 'removed' |
| consent_given | boolean | NOT NULL DEFAULT false | Holder has confirmed consent to be a joint account holder |
| consent_given_at | timestamptz | CHECK (consent_given_at IS NOT NULL OR NOT consent_given) — chk_joint_holder_consent_at_iff_given |
Populated when consent_given flips to true |
| kyc_status | text | NOT NULL DEFAULT 'PENDING' | Mirror of KYC status from accounts.kyc_status_mirror; read by activation gate |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
core.joint_authorisations (MOD-125 V003)¶
FR-566 multi-holder authorisation service. Standalone table (parallel to core.community_authorisations and core.trust_authorisations) — the signing rule and active holder roster are frozen at create-time (snapshot semantics). An authorisation against a joint account requires all applicable holders to approve before the action is executed.
| Column | Type | Constraints | Description |
|---|---|---|---|
| authorisation_id | uuid | PK DEFAULT gen_random_uuid() | |
| joint_account_id | uuid | NOT NULL REFERENCES core.joint_accounts(joint_account_id) | |
| action_type | text | NOT NULL CHECK (action_type IN ('PAYMENT','ADD_HOLDER','REMOVE_HOLDER','CHANGE_SIGNING')) | |
| signing_rule | text | NOT NULL CHECK (signing_rule IN ('any_one','any_two','all')) | Frozen at create-time from the account's signing_authority. ADD/REMOVE/CHANGE_SIGNING always freeze as 'all'. |
| signatory_snapshot | jsonb | NOT NULL | Array of {holder_relationship_id, party_id, is_primary} — frozen roster at create-time. Approvals are validated against this snapshot; new approvals from holders not in the snapshot are rejected. |
| status | text | NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','COMPLETE','EXPIRED','CANCELLED')) | |
| expires_at | timestamptz | NOT NULL CHECK (expires_at > created_at) — chk_joint_auth_expires_after_created |
Default 24h from SSM authorisation-default-expiry-seconds (vs MOD-134's 72h — joint payments are personal cashflow) |
| completed_at | timestamptz | CHECK (completed_at IS NULL OR completed_at >= created_at) | |
| idempotency_key | text | UNIQUE NOT NULL | |
| trace_id | text | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_joint_authorisations_account_status on (joint_account_id, status) WHERE status = 'PENDING'
- idx_joint_authorisations_expires_at on (expires_at) WHERE status = 'PENDING' — expiry sweeper
core.joint_authorisation_approvals (MOD-125 V003)¶
One row per holder approval within an authorisation. UNIQUE (authorisation_id, holder_relationship_id) prevents double-approval; UNIQUE idempotency_key prevents duplicate writes on retry.
| Column | Type | Constraints | Description |
|---|---|---|---|
| approval_id | uuid | PK DEFAULT gen_random_uuid() | |
| authorisation_id | uuid | NOT NULL REFERENCES core.joint_authorisations(authorisation_id) | |
| holder_relationship_id | uuid | NOT NULL REFERENCES accounts.account_party_relationships(relationship_id) | Must be a member of the authorisation's signatory_snapshot |
| approved_at | timestamptz | NOT NULL DEFAULT now() | |
| idempotency_key | text | UNIQUE NOT NULL | |
| trace_id | text | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
- UNIQUE (authorisation_id, holder_relationship_id) — one approval per holder per authorisation
core.joint_governance_events (MOD-125 V004–V005)¶
Append-only immutable log of all joint account governance events. Immutability enforced by ADR-048 triggers (V005). idempotency_key UNIQUE NOT NULL ensures replay-safe handler retries.
| Column | Type | Constraints | Description |
|---|---|---|---|
| event_id | uuid | PK DEFAULT gen_random_uuid() | |
| joint_account_id | uuid | NOT NULL REFERENCES core.joint_accounts(joint_account_id) | |
| event_type | text | NOT NULL CHECK (event_type IN ('JOINT_ACCOUNT_OPENED','JOINT_ACCOUNT_ACTIVATED','JOINT_ACCOUNT_CLOSED','HOLDER_ADDED','HOLDER_REMOVED','HOLDER_CONSENT_RECORDED','HOLDER_DEATH_RECORDED','DEATH_DOCUMENTATION_ACCEPTED','SIGNING_AUTHORITY_CHANGED','AUTHORISATION_CREATED','AUTHORISATION_COMPLETED','AUTHORISATION_EXPIRED','AUTHORISATION_CANCELLED','SHARE_ADJUSTED')) | 14-value CHECK |
| actor_kind | text | NOT NULL CHECK (actor_kind IN ('agent','staff','system')) | |
| actor_id | text | NOT NULL | |
| detail | jsonb | NOT NULL DEFAULT '{}' | |
| idempotency_key | text | UNIQUE NOT NULL | Prevents duplicate governance event writes on handler retry |
| trace_id | text | ||
| correlation_id | text | ||
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_joint_governance_events_account_id on (joint_account_id, created_at DESC)
core.rate_change_proposals (MOD-006 V001)¶
Maker/checker workflow records for interest rate changes. One row per proposal. Reviewed by a second authorised user (four-eyes rule enforced by no_self_approval CHECK). Rate increases on retail variable products also record the 14-day advance-notice obligation (FR-516). The owning module (MOD-006) exclusively writes accounts.interest_rates on activation; MOD-005 is read-only from this point.
| Column | Type | Constraints | Description |
|---|---|---|---|
| proposal_id | uuid | PK DEFAULT gen_uuidv7() | |
| product_code | text | NOT NULL REFERENCES accounts.account_products(product_code) | |
| rate_type | text | NOT NULL CHECK (BASE, BONUS, PENALTY, OVERDRAFT, FIXED_LENDING, VARIABLE_LENDING) |
|
| new_annual_rate | numeric(8,6) | NOT NULL CHECK (≥ 0) | |
| previous_annual_rate | numeric(8,6) | NULL on first-ever rate for this product+type | |
| change_kind | text | NOT NULL CHECK (INCREASE, DECREASE) |
|
| tp_rate_bps | int | Lending only | |
| margin_bps | int | Lending only | |
| lvr_min | numeric(5,4) | Lending only | |
| lvr_max | numeric(5,4) | Lending only | |
| credit_tier | text | Lending only | |
| change_reason | text | NOT NULL | |
| is_retroactive | boolean | NOT NULL DEFAULT false | |
| proposed_by | uuid | NOT NULL | Cross-domain staff UUID (no FK) |
| proposed_at | timestamptz | NOT NULL DEFAULT now() | |
| status | text | NOT NULL DEFAULT 'pending' CHECK (pending, approved, rejected, live, withdrawn) |
|
| reviewed_by | uuid | Cross-domain staff UUID; populated after review | |
| reviewed_at | timestamptz | ||
| review_comment | text | ||
| effective_from | date | NOT NULL | |
| applied_at | timestamptz | Populated when rate goes live | |
| rate_id | uuid | accounts.interest_rates.id created at activation |
|
| customer_notice_required | boolean | NOT NULL DEFAULT false | True for retail variable-rate increases (FR-516) |
| customer_notice_published_at | timestamptz | Set when bank.core.rate_change_notified is emitted |
|
| before_state | jsonb | Pre-change snapshot for FR-066 audit | |
| idempotency_key | text | NOT NULL | |
| trace_id | uuid | NOT NULL | |
| correlation_id | uuid | NOT NULL |
CHECK constraints: no_self_approval (reviewed_by IS NULL OR reviewed_by <> proposed_by); chk_reviewed_at_iff_reviewed_by (reviewed_at and reviewed_by populated together); chk_applied_at_iff_live (applied_at populated iff status='live'); chk_rate_id_iff_live (rate_id populated iff status='live').
core.rate_change_events (MOD-006 V002)¶
Append-only audit log of every governance event in the rate change lifecycle. idempotency_key UNIQUE NOT NULL ensures replay-safe writes. Immutable per ADR-048 Cat 1 — UPDATE/DELETE/TRUNCATE blocked by V003 trigger.
| Column | Type | Constraints | Description |
|---|---|---|---|
| event_id | uuid | PK DEFAULT gen_uuidv7() | |
| event_type | text | NOT NULL CHECK (PROPOSAL_CREATED, PROPOSAL_APPROVED, PROPOSAL_REJECTED, PROPOSAL_WITHDRAWN, RATE_CHANGE_NOTIFIED, RATE_CHANGE_ACTIVATED) |
|
| proposal_id | uuid | References core.rate_change_proposals |
|
| product_code | text | ||
| rate_type | text | ||
| actor_kind | text | NOT NULL CHECK (agent, staff, system) |
|
| actor_id | uuid | ||
| metadata | jsonb | NOT NULL DEFAULT '{}' | |
| before_state | jsonb | ||
| after_state | jsonb | ||
| idempotency_key | text | NOT NULL UNIQUE | ADR-048 duplicate-safe write |
| trace_id | uuid | NOT NULL | |
| correlation_id | uuid | NOT NULL | |
| occurred_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- uniq_rate_change_events_idempotency_key on (idempotency_key) — UNIQUE
- idx_rate_change_events_proposal on (proposal_id, occurred_at DESC) WHERE proposal_id IS NOT NULL
- idx_rate_change_events_product on (product_code, rate_type, occurred_at DESC) WHERE product_code IS NOT NULL
Immutability: trg_rate_change_events_no_update, trg_rate_change_events_no_delete, trg_rate_change_events_no_truncate — ADR-048 Cat 1. MOD-006 V003.
core.term_deposits (MOD-111 V001)¶
One row per term deposit instance. Terminal states (MATURED, ROLLED, BROKEN_EARLY) are frozen by DB trigger — no further UPDATEs permitted. Rollover chains are navigable via the previous_deposit_id self-FK.
| Column | Type | Constraints | Notes |
|---|---|---|---|
deposit_id |
uuid | PK DEFAULT gen_uuidv7() | |
account_id |
uuid | NOT NULL REFERENCES accounts.accounts(id) | Underlying transactional account |
amount |
numeric(18,2) | NOT NULL CHECK (amount > 0) | Principal at opening |
currency |
char(3) | NOT NULL | ISO 4217 |
term_months |
int | NOT NULL CHECK (term_months > 0) | Term in months |
annual_interest_rate |
numeric(8,6) | NOT NULL | Rate at opening (snapshot) |
opened_at |
timestamptz | NOT NULL DEFAULT now() | |
maturity_date |
date | NOT NULL | |
lifecycle_state |
text | NOT NULL DEFAULT 'PRE_MATURITY' CHECK (lifecycle_state IN ('PRE_MATURITY','MATURING','MATURED','ROLLED','BROKEN_EARLY')) | |
default_instruction |
text | NULL CHECK (default_instruction IN ('ROLLOVER','WITHDRAW')) | NULL until customer sets or auto-default fires |
default_instruction_applied_at |
timestamptz | NULL | Set when auto-default stamped (FR-495) |
previous_deposit_id |
uuid | NULL REFERENCES core.term_deposits(deposit_id) | Rollover chain back-pointer |
rolled_into_deposit_id |
uuid | NULL REFERENCES core.term_deposits(deposit_id) | Forward pointer (set when ROLLED) |
matured_at |
timestamptz | NULL | Populated on terminal transition |
accrued_interest |
numeric(18,2) | NULL | Computed at maturity from core.accrual_postings |
final_proceeds |
numeric(18,2) | NULL | Principal + accrued interest at maturity |
idempotency_key |
text | NOT NULL UNIQUE | |
trace_id |
text | NULL | |
correlation_id |
text | NULL | |
created_at |
timestamptz | NOT NULL DEFAULT now() |
Indexes: idx_term_deposits_account_id on (account_id); idx_term_deposits_maturity_date on (maturity_date) WHERE lifecycle_state IN ('PRE_MATURITY','MATURING'); idx_term_deposits_lifecycle on (lifecycle_state, maturity_date)
CHECK constraints: chk_maturity_after_open (maturity_date > opened_at::date); chk_matured_at_iff_terminal (matured_at IS NOT NULL = lifecycle_state IN ('MATURED','ROLLED','BROKEN_EARLY')); chk_rolled_into_iff_rolled (rolled_into_deposit_id IS NOT NULL = lifecycle_state = 'ROLLED'); chk_proceeds_iff_terminal (final_proceeds IS NOT NULL = lifecycle_state IN ('MATURED','ROLLED','BROKEN_EARLY'))
Immutability: trg_term_deposits_no_update_after_terminal — BEFORE UPDATE, raises when OLD.lifecycle_state IN ('MATURED','ROLLED','BROKEN_EARLY'). trg_term_deposits_no_delete / _no_truncate — unconditional. MOD-111 V003.
core.term_deposit_instructions (MOD-111 V002)¶
Append-only record of every maturity instruction captured for a term deposit (customer-set or auto-defaulted). Immutable once written.
| Column | Type | Constraints | Notes |
|---|---|---|---|
instruction_id |
uuid | PK DEFAULT gen_uuidv7() | |
deposit_id |
uuid | NOT NULL REFERENCES core.term_deposits(deposit_id) | |
instruction_type |
text | NOT NULL CHECK (instruction_type IN ('ROLLOVER','WITHDRAW')) | |
source |
text | NOT NULL CHECK (source IN ('CUSTOMER','AUTO_DEFAULT')) | FR-495 auto-default vs customer-captured |
term_months |
int | NULL | Present for ROLLOVER — term of the new deposit |
nominated_account_id |
uuid | NULL REFERENCES accounts.accounts(id) | Present for WITHDRAW (internal account only in v1) |
idempotency_key |
text | NOT NULL UNIQUE | |
captured_at |
timestamptz | NOT NULL DEFAULT now() | |
trace_id |
text | NULL |
Immutability: trg_term_deposit_instructions_no_{update,delete,truncate} — raises unconditionally. MOD-111 V002.
core.break_cost_disclosures (MOD-111 V002)¶
One row per early-withdrawal break-cost disclosure. Semi-permissive immutability: accepted_at may be stamped exactly once (NULL → timestamp); all other fields and subsequent mutations are rejected by trigger.
| Column | Type | Constraints | Notes |
|---|---|---|---|
disclosure_id |
uuid | PK DEFAULT gen_uuidv7() | |
deposit_id |
uuid | NOT NULL REFERENCES core.term_deposits(deposit_id) | |
break_cost_amount |
numeric(18,2) | NOT NULL CHECK (break_cost_amount >= 0) | Calculated by MOD-111 break-cost service |
calculation_basis |
text | NOT NULL | Human-readable formula snapshot |
remaining_term_days |
int | NOT NULL | Days remaining at time of disclosure |
annual_interest_rate |
numeric(8,6) | NOT NULL | Rate at time of disclosure |
currency |
char(3) | NOT NULL | |
disclosed_at |
timestamptz | NOT NULL DEFAULT now() | |
accepted_at |
timestamptz | NULL | One-shot acceptance stamp (FR-496) |
idempotency_key |
text | NOT NULL UNIQUE | |
trace_id |
text | NULL |
Immutability: trg_break_cost_disclosures_guard — semi-permissive. Allows exactly one UPDATE: OLD.accepted_at IS NULL AND NEW.accepted_at IS NOT NULL with all other fields unchanged. Rejects re-acceptance, mutation of immutable fields, DELETE, and TRUNCATE. MOD-111 V002.
core.term_deposit_events (MOD-111 V003)¶
Append-only audit log of every lifecycle event on a term deposit. Nine event types. Immutable.
| Column | Type | Constraints | Notes |
|---|---|---|---|
event_id |
uuid | PK DEFAULT gen_uuidv7() | |
deposit_id |
uuid | NOT NULL REFERENCES core.term_deposits(deposit_id) | |
event_type |
text | NOT NULL CHECK (event_type IN ('OPENED','INSTRUCTION_CAPTURED','DEFAULT_INSTRUCTION_APPLIED','BREAK_COST_DISCLOSED','BREAK_COST_ACCEPTED','PRE_MATURITY_NOTICE_SENT','ROLLED_OVER','PROCEEDS_DISBURSED','BROKEN_EARLY')) | |
occurred_at |
timestamptz | NOT NULL DEFAULT now() | |
payload |
jsonb | NULL | Event-type-specific data snapshot |
idempotency_key |
text | NOT NULL UNIQUE | |
trace_id |
text | NULL | |
correlation_id |
text | NULL |
Immutability: trg_term_deposit_events_no_{update,delete,truncate} — raises unconditionally. MOD-111 V003. Satisfies NFR-024.
core.notice_lodgements (MOD-130 V001)¶
One row per notice period lodgement. The annual_interest_rate column is snapshotted at lodgement time — never re-read from accounts.interest_rates at withdrawal time (correction §2). amount = NULL means full account balance is released at maturity.
| Column | Type | Constraints | Description |
|---|---|---|---|
lodgement_id |
uuid | PK DEFAULT gen_random_uuid() | |
account_id |
uuid | NOT NULL REFERENCES accounts.accounts(id) | |
product_code |
text | NOT NULL REFERENCES accounts.account_products(product_code) | Identifies the notice product (NZ_NOTICE_30, NZ_NOTICE_90, AU_NOTICE_30, AU_NOTICE_90) |
notice_period_days |
int | NOT NULL | Product-level config; stored at lodgement time |
annual_interest_rate |
numeric(8,6) | NOT NULL | Rate snapshot at lodgement time. Used for all subsequent penalty calculations — never the current rate. |
amount |
numeric(18,2) | nullable | NULL = full balance at release |
currency |
text | NOT NULL | |
jurisdiction |
text | NOT NULL CHECK (NZ, AU) | |
lodged_at |
timestamptz | NOT NULL DEFAULT now() | |
withdrawal_available_date |
date | NOT NULL | lodged_at::date + notice_period_days |
status |
text | NOT NULL CHECK (pending, withdrawn, cancelled, lapsed) | |
withdrawn_at |
timestamptz | nullable | Set on successful early or maturity withdrawal |
cancelled_at |
timestamptz | nullable | Set on cancellation (pre-date, if permitted by product) |
posting_id |
uuid | nullable | MOD-001 posting ID for the release debit |
penalty_amount |
numeric(18,2) | nullable | Populated on early withdrawal |
final_proceeds |
numeric(18,2) | nullable | Net amount released to customer |
idempotency_key |
text | NOT NULL UNIQUE | |
trace_id |
text | nullable | ADR-031 propagation |
correlation_id |
text | nullable |
Indexes: account_id; withdrawal_available_date WHERE status = 'pending'; lodged_at DESC.
CHECK constraints (V001): chk_withdrawal_after_lodged (withdrawal_available_date > lodged_at::date); chk_withdrawn_at_iff_withdrawn; chk_cancelled_at_iff_cancelled.
Immutability (MOD-130 V003): trg_notice_lodgements_no_update_after_terminal — BEFORE UPDATE raises when OLD.status IN ('withdrawn','cancelled','lapsed').
core.notice_early_withdrawals (MOD-130 V002)¶
One-shot penalty disclosure and acceptance record. Semi-permissive immutability trigger (same pattern as core.break_cost_disclosures): accepted_at may transition NULL → timestamp exactly once; all other fields are immutable after insert.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
uuid | PK DEFAULT gen_random_uuid() | |
lodgement_id |
uuid | NOT NULL REFERENCES core.notice_lodgements(lodgement_id) | |
penalty_amount |
numeric(18,2) | NOT NULL | Calculated from notice_lodgements.annual_interest_rate snapshot |
calculation_basis |
jsonb | NOT NULL | {snapshot_annual_interest_rate, notice_period_days, lodgement_amount, formula_version: "v1"} |
disclosed_at |
timestamptz | NOT NULL DEFAULT now() | |
accepted_at |
timestamptz | nullable | NULL → timestamp transition triggers the actual posting |
accepted_via |
text | nullable CHECK (app, agent) |
Immutability (MOD-130 V002): trg_notice_early_withdrawals_guard — semi-permissive. Permits exactly one UPDATE (accepted_at NULL → timestamp, all other fields verified unchanged). Rejects re-acceptance, any field mutation, DELETE, TRUNCATE. Satisfies CON-005 GATE.
core.notice_events (MOD-130 V003)¶
Per-module governance log. Append-only. One row per lifecycle event on a notice lodgement. Satisfies NFR-024.
| Column | Type | Constraints | Description |
|---|---|---|---|
event_id |
uuid | PK DEFAULT gen_uuidv7() | |
lodgement_id |
uuid | NOT NULL REFERENCES core.notice_lodgements(lodgement_id) | |
event_type |
text | NOT NULL CHECK (LODGED, REMINDER_SENT, FUNDS_RELEASED, EARLY_WITHDRAWAL_DISCLOSED, EARLY_WITHDRAWAL_ACCEPTED, CANCELLED, LAPSED) | |
occurred_at |
timestamptz | NOT NULL DEFAULT now() | |
payload |
jsonb | nullable | Event-type-specific snapshot |
idempotency_key |
text | NOT NULL UNIQUE | |
trace_id |
text | nullable |
Immutability (MOD-130 V003): trg_notice_events_no_{update,delete,truncate} — raises unconditionally. Satisfies NFR-024.
core.obr_resolution_state (MOD-143 V002 + V004 seed)¶
Single-row table tracking the bank's current Open Bank Resolution state. The sentinel-PK constraint ensures exactly one row exists at all times. The activate and unwind handlers hold a SELECT … FOR UPDATE lock during state transitions to guarantee atomicity with core.obr_partition_events.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
uuid | PK CHECK (id = '00000000-0000-0000-0000-000000000001'::uuid) | Sentinel constant — enforces single-row invariant |
resolution_state |
text | NOT NULL CHECK (normal, pre_positioned, activated) | Current state. Seeded as pre_positioned in production by V004. |
haircut_pct |
numeric(5,4) | nullable | NULL in non-activated states; set at activation (e.g. 0.2000 = 20%) |
rbnz_instruction_ref |
text | nullable | RBNZ instruction reference; set at activation, cleared on unwind |
activated_at |
timestamptz | nullable | |
activated_by |
text | nullable | Cognito staff-identity UUID of resolution officer (v1: recorded; cryptographic verification is follow-up) |
updated_at |
timestamptz | NOT NULL |
Mutable — updated via FOR UPDATE lock in activate/unwind handlers. Not subject to immutability triggers; state transitions are the point.
core.obr_partition_events (MOD-143 V003)¶
Append-only audit log of OBR resolution events — activation, unwind, and state verification calls. One row per event. Satisfies FR-633 ("all transitions audited") and REP-001 LOG.
| Column | Type | Constraints | Description |
|---|---|---|---|
event_id |
uuid | PK DEFAULT gen_uuidv7() | |
event_type |
text | NOT NULL CHECK (ACTIVATION, UNWIND, STATE_VERIFIED) | |
resolution_state_after |
text | NOT NULL CHECK (normal, pre_positioned, activated) | State at the time of event |
rbnz_instruction_ref |
text | nullable | Present for ACTIVATION and UNWIND events |
haircut_pct |
numeric(5,4) | nullable | Snapshot at ACTIVATION |
accounts_affected |
int | nullable | Count of accounts partitioned (ACTIVATION) or unwound (UNWIND) |
total_frozen_amount |
numeric(18,2) | nullable | Aggregate at ACTIVATION |
total_available_amount |
numeric(18,2) | nullable | Aggregate at ACTIVATION |
total_writeoff_amount |
numeric(18,2) | nullable | Aggregate credit-loss postings at UNWIND |
performed_by |
text | NOT NULL | Cognito staff-identity UUID |
idempotency_key |
text | NOT NULL UNIQUE | Prevents duplicate events on Lambda retry |
occurred_at |
timestamptz | NOT NULL DEFAULT now() | |
trace_id |
text | nullable | ADR-031 propagation |
Immutability (MOD-143 V003): trg_obr_partition_events_no_{update,delete,truncate} — raises unconditionally (ADR-048 Cat 1). Satisfies NFR-024 and FR-633.
Member equity tables (MOD-118)¶
Mutual-institution only (tenant flag /bank/{stage}/tenant/institution-type = mutual). All tables in the core schema. party_id fields are soft cross-domain references — no Postgres FK (cross-domain boundary; same pattern as accounts.account_party_relationships).
core.member_register (mutable — V001)¶
Definitive record of member shareholdings. One row per member. shares_held is updated on every share transaction.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| member_id | uuid | PRIMARY KEY DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Soft ref to SD02 party.parties(party_id) — no FK (cross-domain) |
| membership_number | text | NOT NULL UNIQUE | System-assigned sequential identifier |
| shares_held | numeric(18,4) | NOT NULL DEFAULT 0 | Running balance; updated on each SETTLED share transaction |
| share_class | text | NOT NULL DEFAULT 'ORDINARY' | ORDINARY only in v1 |
| status | text | NOT NULL DEFAULT 'MEMBER' CHECK (status IN ('APPLICANT','MEMBER','SUSPENDED','FORMER')) | |
| jurisdiction | text | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| joined_at | timestamptz | NOT NULL | |
| resigned_at | timestamptz | Set when status → FORMER | |
| last_voted_at | date | Updated on each AGM vote recorded | |
| idempotency_key | text | NOT NULL UNIQUE | |
| trace_id | text | NOT NULL | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Grants: SELECT, INSERT, UPDATE to bank_core_app_user. No DELETE.
Indexes: (party_id), (status).
core.share_transactions (Cat 1 immutable — V002)¶
Append-only ledger of share capital movements. One row per transaction in its terminal state. Reversals are new CORRECTION rows (negative shares), not updates. Status at INSERT is final. Satisfies GOV-001 LOG and FR-533.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| tx_id | uuid | PRIMARY KEY DEFAULT gen_random_uuid() | |
| member_id | uuid | NOT NULL REFERENCES core.member_register(member_id) | |
| tx_type | text | NOT NULL CHECK (tx_type IN ('PURCHASE','REDEMPTION','TRANSFER_IN','TRANSFER_OUT','DIVIDEND_REINVESTMENT','CORRECTION')) | |
| shares | numeric(18,4) | NOT NULL | Negative for REDEMPTION, TRANSFER_OUT, CORRECTION |
| par_value | numeric(18,2) | NOT NULL | Per-share par value at transaction time |
| total_amount | numeric(18,2) | NOT NULL | shares × par_value; negative for deductions |
| posting_id | uuid | Cross-domain ref to SD01 posting — no FK; populated after MOD-001 credit/debit | |
| status | text | NOT NULL CHECK (status IN ('SETTLED','BLOCKED','REVERSED')) | Terminal at INSERT; BLOCKED rows have a corresponding redemption_queue row |
| blocked_reason | text | capital_gate when CLQ-001 fires |
|
| idempotency_key | text | NOT NULL UNIQUE | |
| trace_id | text | NOT NULL | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Grants: SELECT, INSERT to bank_core_app_user. No UPDATE or DELETE.
Trigger: trg_share_transactions_immutable — BEFORE UPDATE OR DELETE, calls fn_immutable_row() (ADR-048 Cat 1).
Indexes: (member_id), (created_at).
core.dividend_declarations (mutable — V003)¶
One row per board-declared dividend. Transitions: DECLARED → PROCESSING (batch start) → PAID (all member payments settled).
| Column | Type | Constraints | Notes |
|---|---|---|---|
| declaration_id | uuid | PRIMARY KEY DEFAULT gen_random_uuid() | |
| declaration_date | date | NOT NULL | |
| record_date | date | NOT NULL | Shareholding snapshot date |
| payment_date | date | NOT NULL | |
| rate_per_share | numeric(10,6) | NOT NULL | |
| total_declared | numeric(18,2) | NOT NULL | SUM(shares_held at record_date) × rate_per_share |
| board_resolution | text | NOT NULL | Board resolution reference |
| jurisdiction | text | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| status | text | NOT NULL DEFAULT 'DECLARED' CHECK (status IN ('DECLARED','PROCESSING','PAID','REVERSED')) | |
| idempotency_key | text | NOT NULL UNIQUE | |
| trace_id | text | NOT NULL | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Grants: SELECT, INSERT, UPDATE to bank_core_app_user. No DELETE.
core.dividend_payments (mutable — V004)¶
One row per member per dividend declaration. Populated by the dividend batch Lambda. paid_at is set when MOD-001 confirms the credit posting.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| payment_id | uuid | PRIMARY KEY DEFAULT gen_random_uuid() | |
| declaration_id | uuid | NOT NULL REFERENCES core.dividend_declarations(declaration_id) | |
| member_id | uuid | NOT NULL REFERENCES core.member_register(member_id) | |
| shares_at_record | numeric(18,4) | NOT NULL | Snapshot at record date |
| gross_dividend | numeric(18,2) | NOT NULL | shares_at_record × rate_per_share |
| tax_withheld | numeric(18,2) | NOT NULL DEFAULT 0 | Per-jurisdiction withholding rate |
| net_dividend | numeric(18,2) | NOT NULL | gross_dividend − tax_withheld |
| posting_id | uuid | Cross-domain ref to SD01 posting — no FK | |
| paid_at | timestamptz | Set on confirmed MOD-001 credit | |
| idempotency_key | text | NOT NULL UNIQUE | |
| trace_id | text | NOT NULL | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL DEFAULT now() | |
| UNIQUE | (declaration_id, member_id) | One payment row per member per declaration |
Grants: SELECT, INSERT, UPDATE to bank_core_app_user. No DELETE.
Indexes: (declaration_id), (member_id).
core.redemption_queue (mutable — V005)¶
FIFO queue for redemptions blocked by the CLQ-001 capital gate. The blocked-redemption-replayer processes this daily in created_at order when capital headroom allows.
| Column | Type | Constraints | Notes |
|---|---|---|---|
| queue_id | uuid | PRIMARY KEY DEFAULT gen_random_uuid() | |
| tx_id | uuid | NOT NULL REFERENCES core.share_transactions(tx_id) | The BLOCKED share transaction row |
| member_id | uuid | NOT NULL REFERENCES core.member_register(member_id) | |
| shares_requested | numeric(18,4) | NOT NULL | |
| redemption_amount | numeric(18,2) | NOT NULL | |
| blocked_cet1_ratio | numeric(6,4) | NOT NULL | CET1 ratio at time of block |
| required_cet1_ratio | numeric(6,4) | NOT NULL | Configured minimum floor at time of block |
| status | text | NOT NULL DEFAULT 'QUEUED' CHECK (status IN ('QUEUED','PROCESSING','PROCESSED','EXPIRED')) | |
| replayed_at | timestamptz | Set when the replayer first attempts processing | |
| resolved_at | timestamptz | Set when redemption completes | |
| idempotency_key | text | NOT NULL UNIQUE | |
| trace_id | text | NOT NULL | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Grants: SELECT, INSERT, UPDATE to bank_core_app_user. No DELETE.
Indexes: (member_id), (status) WHERE status = 'QUEUED', (created_at) WHERE status = 'QUEUED' (FIFO order for replayer).
DB-enforced invariants (ADR-048)¶
The following constraints and triggers are enforced at the database layer per ADR-048. All are implemented in numbered Flyway migrations (V0NN__describe.sql). Lambda validation is not removed — it provides the user-facing error path and handles config-driven thresholds. The DB layer is the hard floor that no bypass path can circumvent.
Immutability triggers¶
| Table | Migration | Behaviour |
|---|---|---|
accounts.postings |
V0NN (MOD-001) | BEFORE UPDATE OR DELETE: raises exception unconditionally |
accounts.fx_rates |
V005 (MOD-004) | Same pattern |
accounts.fx_conversions |
V005 (MOD-004) | Same pattern |
accounts.daily_trial_balance |
V005 (MOD-004) | Same pattern |
accounts.daily_balance_snapshots |
(RLS) | FOR UPDATE USING (false), FOR DELETE USING (false) — app user has INSERT + SELECT only |
core.trust_governance_events |
V004 (MOD-133) | BEFORE UPDATE OR DELETE OR TRUNCATE: raises exception unconditionally (ADR-048 Cat 1) |
core.community_governance_events |
V005 (MOD-134) | Same pattern |
core.accrual_postings |
V003 (MOD-005) | trg_accrual_postings_no_{update,delete,truncate}: raises exception unconditionally (Cat 1 — full immutability) |
core.accrual_runs |
V003 (MOD-005) | trg_accrual_runs_no_{delete,truncate}: blocks all DELETE and TRUNCATE. trg_accrual_runs_immutable_identity: BEFORE UPDATE — rejects changes to identity fields; completion fields remain mutable for completeRun. |
core.joint_governance_events |
V005 (MOD-125) | trg_joint_governance_events_no_{update,delete,truncate}: raises exception unconditionally (ADR-048 Cat 1) |
core.rate_change_events |
V003 (MOD-006) | trg_rate_change_events_no_{update,delete,truncate}: raises exception unconditionally (ADR-048 Cat 1) |
core.term_deposit_events |
V003 (MOD-111) | trg_term_deposit_events_no_{update,delete,truncate}: raises exception unconditionally (ADR-048 Cat 1). Satisfies NFR-024. |
core.term_deposit_instructions |
V002 (MOD-111) | trg_term_deposit_instructions_no_{update,delete,truncate}: raises exception unconditionally (ADR-048 Cat 1) |
core.break_cost_disclosures |
V002 (MOD-111) | trg_break_cost_disclosures_guard: semi-permissive — permits exactly one UPDATE (accepted_at NULL → timestamp, all other fields unchanged); rejects re-acceptance, field mutation, DELETE, TRUNCATE |
core.term_deposits |
V003 (MOD-111) | trg_term_deposits_no_update_after_terminal: BEFORE UPDATE raises when OLD.lifecycle_state IN ('MATURED','ROLLED','BROKEN_EARLY'). trg_term_deposits_no_delete / _no_truncate: unconditional block. |
core.notice_events |
V003 (MOD-130) | trg_notice_events_no_{update,delete,truncate}: raises unconditionally (ADR-048 Cat 1). Satisfies NFR-024. |
core.notice_early_withdrawals |
V002 (MOD-130) | trg_notice_early_withdrawals_guard: semi-permissive — permits exactly one UPDATE (accepted_at NULL → timestamp, all other fields unchanged); rejects re-acceptance, field mutation, DELETE, TRUNCATE. Satisfies CON-005 GATE. |
core.notice_lodgements |
V003 (MOD-130) | trg_notice_lodgements_no_update_after_terminal: BEFORE UPDATE raises when OLD.status IN ('withdrawn','cancelled','lapsed'). |
core.obr_partition_events |
V003 (MOD-143) | trg_obr_partition_events_no_{update,delete,truncate}: raises unconditionally (ADR-048 Cat 1). Satisfies NFR-024 and FR-633. |
core.share_transactions |
V002 (MOD-118) | trg_share_transactions_immutable: BEFORE UPDATE OR DELETE, calls fn_immutable_row() (ADR-048 Cat 1). Satisfies GOV-001 LOG and FR-533. |
loans.amortisation_schedules |
V003 (MOD-112) | trg_amortisation_schedules_guard — semi-permissive supersession-only: permits ONLY is_current true → false on superseded schedule rows; all other UPDATE columns, DELETE, TRUNCATE blocked unconditionally. |
loans.schedule_instalments |
V003 (MOD-112) | trg_schedule_instalments_guard — semi-permissive status graph: permits only PENDING → PAID/MISSED/PARTIAL, PARTIAL → PAID/MISSED, MISSED → PAID; all other UPDATE columns, DELETE, TRUNCATE blocked unconditionally. |
loans.amortisation_events |
V003 (MOD-112) | amortisation_events_reject_mutation — full Cat 1: BEFORE UPDATE OR DELETE OR TRUNCATE raises unconditionally; REVOKE UPDATE/DELETE/TRUNCATE ON bank_core_app_user is the application-layer complement. |
core.fee_config_propagation_events |
V007 (MOD-110) | core.fee_config_propagation_events_reject_mutation — Cat 1: BEFORE UPDATE OR DELETE OR TRUNCATE raises unconditionally. REVOKE UPDATE/DELETE/TRUNCATE ON bank_core_app_user. |
Posting validation triggers¶
| Trigger | Timing | Rule | Notes |
|---|---|---|---|
trg_posting_account_status |
BEFORE INSERT on accounts.postings |
Rejects INSERT when accounts.accounts.status IN ('CLOSED','DORMANT') |
DB-layer defence; Lambda also checks pre-INSERT as the user-facing error path |
trg_posting_obr_channel_gate |
BEFORE INSERT on accounts.postings (MOD-143 V001) |
Short-circuits when core.obr_resolution_state.resolution_state != 'activated'. When activated: rejects DEBIT legs where NEW.amount > accounts.obr_available_amount. Credits pass unconditionally (FR-635). Returns error code OBR_FROZEN_BALANCE. |
Fires after trg_posting_account_status (alphabetical ordering). Hot-path cost minimal — single 1-row lookup on a table permanently in Postgres buffer pool; non-activated short-circuit avoids the amount check. |
trg_postings_double_entry |
AFTER INSERT on accounts.postings — DEFERRABLE INITIALLY DEFERRED |
Per currency: SUM(DEBIT amounts) = SUM(CREDIT amounts) within the same transaction_id |
Fires at COMMIT so both legs are visible. Per-currency check handles MOD-004's 4-leg multi-currency conversions correctly. |
CHECK constraints¶
| Table | Column | Constraint |
|---|---|---|
accounts.postings |
amount |
CHECK (amount > 0) |
accounts.postings |
entry_type |
CHECK (entry_type IN ('DEBIT','CREDIT')) |
accounts.postings |
jurisdiction |
CHECK (jurisdiction IN ('NZ','AU')) |
accounts.accounts |
status |
CHECK (status IN ('PENDING','ACTIVE','RESTRICTED','DORMANT','CLOSED')) |
accounts.accounts |
jurisdiction |
CHECK (jurisdiction IN ('NZ','AU')) |
accounts.account_products |
effective_to |
CHECK (effective_to IS NULL OR effective_to > effective_from) |
accounts.interest_rates |
effective_to |
CHECK (effective_to IS NULL OR effective_to > effective_from) |
accounts.interest_rates |
annual_rate |
CHECK (annual_rate >= 0) |
treasury.tp_rates |
tenor_bucket |
CHECK (tenor_bucket IN ('ON','1M','3M','6M','1Y','2Y','3Y','5Y','10Y')) |
treasury.tp_rates |
jurisdiction |
CHECK (jurisdiction IN ('NZ','AU')) |
core.fee_schedule |
notice_days |
CHECK (notice_days >= 14) — CON-005 statutory floor |
core.fee_schedule |
effective_to |
CHECK (effective_to IS NULL OR effective_to > effective_from) |
core.fee_events |
idempotency_key |
UNIQUE — duplicate-safe fee assessment |
core.fee_config_propagation_events |
idempotency_key |
UNIQUE NOT NULL — EventBridge event_id; replay-safe (V007, MOD-110) |
core.fee_config_propagation_events |
jurisdiction |
CHECK (jurisdiction IN ('NZ','AU')) |
core.trust_accounts |
closed_at |
CHECK (closed_at IS NULL OR closed_at > activated_at) — V004 (MOD-133) |
core.trust_accounts |
review_interval_months |
CHECK (review_interval_months IS NULL OR review_interval_months > 0) — V001 column-level |
core.trust_party_metadata |
ownership_pct |
CHECK (ownership_pct IS NULL OR (ownership_pct >= 0 AND ownership_pct <= 100)) — V002 column-level |
core.trust_governance_events |
event_type |
CHECK (event_type IN (15 values)) — see table definition |
core.trust_governance_events |
idempotency_key |
UNIQUE — duplicate-safe governance event write |
accounts.accounts |
restriction_reason |
CHECK (restriction_reason IN ('SANCTIONS','FRAUD_INVESTIGATION','HARDSHIP_ARRANGEMENT','ADMIN','INSUFFICIENT_SIGNATORIES','NOTICE_PENDING')) — INSUFFICIENT_SIGNATORIES added MOD-134 V001; NOTICE_PENDING added MOD-130 V001 |
accounts.account_state_history |
restriction_reason |
Same CHECK domain as accounts.accounts.restriction_reason — extended in lockstep by MOD-134 V001 and MOD-130 V001 |
core.community_accounts |
closed_at |
CHECK (closed_at IS NULL OR closed_at > activated_at) — V005 (MOD-134) |
core.community_accounts |
entity_type |
CHECK (entity_type IN (6 values)) — see table definition |
core.community_accounts |
signing_rule |
CHECK (signing_rule IN ('any_one','any_two','all')) — column-level |
core.community_signatory_metadata |
committee_role |
CHECK (committee_role IN ('president','treasurer','secretary','authorised_signatory')) — Cat 1 column-level |
core.community_authorisations |
expires_at |
CHECK (expires_at > created_at) — V003 column-level |
core.community_authorisations |
completed_at |
CHECK (completed_at IS NULL OR completed_at >= created_at) — V003 column-level |
core.community_authorisation_approvals |
(authorisation_id, signatory_relationship_id) |
UNIQUE — one approval per signatory per authorisation |
core.community_authorisation_approvals |
idempotency_key |
UNIQUE NOT NULL — duplicate-safe approval write |
core.community_governance_events |
event_type |
CHECK (event_type IN (17 values)) — see table definition |
core.community_governance_events |
idempotency_key |
UNIQUE NOT NULL — duplicate-safe governance event write (orchestrator Q6 addition) |
core.accrual_runs |
period_start, period_end |
chk_accrual_run_dates: period_end >= period_start — V002 (MOD-005) |
core.accrual_runs |
completed_at, status |
chk_accrual_run_completion_coherence: completed_at populated iff status IN ('COMPLETED','FAILED') — V002 (MOD-005) |
core.accrual_runs |
correction_of_run_id, run_type |
chk_accrual_run_correction_link_iff_correction: correction_of_run_id IS NOT NULL iff run_type = 'CORRECTION' — V002 (MOD-005) |
core.accrual_runs |
idempotency_key |
UNIQUE NOT NULL — duplicate-safe run creation on handler retry |
core.accrual_postings |
amount |
chk_accrual_posting_amount_nonzero: amount <> 0 — V002 (MOD-005). amount is signed; positive = savings credit, negative = overdraft debit. |
accounts.account_party_relationships |
ownership_share_pct |
chk_ownership_share_pct_range: ownership_share_pct IS NULL OR (ownership_share_pct >= 0 AND ownership_share_pct <= 100) — V001 (MOD-125). Defensive constraint for the SDV/CRS-shared column; structural underpinning for FR-567 / REP-007. |
core.joint_accounts |
closed_at |
CHECK (closed_at IS NULL OR closed_at >= activated_at) — V005 (MOD-125) |
core.joint_accounts |
signing_authority |
CHECK (signing_authority IN ('any_one','any_two','all')) — column-level |
core.joint_accounts |
death_documentation_status |
CHECK (death_documentation_status IN ('none','frozen','accepted')) — column-level |
core.joint_holder_metadata |
holder_status |
CHECK (holder_status IN ('active','deceased','removed')) — column-level |
core.joint_holder_metadata |
deceased_at, holder_status |
chk_joint_holder_deceased_at_iff_deceased — V002 column-level |
core.joint_holder_metadata |
removed_at, holder_status |
chk_joint_holder_removed_at_iff_removed — V002 column-level |
core.joint_holder_metadata |
consent_given_at, consent_given |
chk_joint_holder_consent_at_iff_given — V002 column-level |
core.joint_authorisations |
expires_at |
chk_joint_auth_expires_after_created: expires_at > created_at — V003 column-level |
core.joint_authorisations |
completed_at |
CHECK (completed_at IS NULL OR completed_at >= created_at) — V003 column-level |
core.joint_authorisation_approvals |
(authorisation_id, holder_relationship_id) |
UNIQUE — one approval per holder per authorisation |
core.joint_authorisation_approvals |
idempotency_key |
UNIQUE NOT NULL — duplicate-safe approval write |
core.joint_governance_events |
event_type |
CHECK (event_type IN (14 values)) — see table definition |
core.joint_governance_events |
idempotency_key |
UNIQUE NOT NULL — duplicate-safe governance event write (ADR-048 requirement, V004) |
Testing requirement¶
Every constraint and trigger above requires a negative integration test: attempt the violating INSERT/UPDATE/DELETE inside a test transaction, assert the expected Postgres exception code or message, ROLLBACK. These tests live in tests/integration/ for the owning module.
Cross-domain references¶
| This table | Column | References | Notes |
|---|---|---|---|
| accounts.account_party_relationships | party_id | SD02 party.parties(party_id) |
Cross-domain UUID FK; enforced at application layer |
| accounts.postings | payment_id | SD04 payments.payments(id) |
Optional; populated when a payment originates the posting |
| accounts.pending_holds | payment_id | SD04 payments.payments(id) |
Optional; links hold to originating payment authorisation |
| treasury.tp_rates | (written by) | SD06 MOD-086 write-back Lambda | Read by SD05 credit decisioning and SD01 product rate config |
| contexts.operating_contexts | primary_party_id | SD02 party.parties(party_id) |
Cross-domain UUID FK |
| assets.asset_party_relationships | party_id | SD02 party.parties(party_id) |
Cross-domain UUID FK |
| assets.assets | (written by) | SD07 MOD-100 external asset connector | KiwiSaver and super balance refreshed daily |
| core.trust_accounts | trust_deed_document_id | SD08/MOD-073 document store | Cross-domain UUID reference; nullable in DDL, required at activation gate |
| core.trust_party_metadata | (reads via) | accounts.kyc_status_mirror |
Activation gate reads KYC status mirror for trustee and BO verification; mirror populated by MOD-007's bank.kyc.identity_verified consumer |
| core.community_accounts | constitution_document_id | SD08/MOD-073 document store | Cross-domain UUID reference; nullable in DDL, required at activation gate (FR-597) |
| core.community_signatory_metadata | (reads via) | accounts.kyc_status_mirror |
Activation gate reads KYC mirror for each signatory; same pattern as MOD-133 |
| core.joint_accounts | death_documentation_id | SD08/MOD-073 document store | Cross-domain UUID reference; validated opaquely in v1, replaced with vault lookup once MOD-073 ships |
| core.joint_holder_metadata | (reads via) | accounts.kyc_status_mirror |
Activation gate reads KYC mirror for each joint holder; same pattern as MOD-133/MOD-134 |
Account ownership note: Account ownership is expressed via accounts.account_party_relationships.party_id — a cross-domain UUID reference to SD02 party.parties(party_id).
Consumers of SD01 data: SD04 reads accounts.accounts for balance and status checks. SD05 reads accounts.accounts and treasury.tp_rates. SD08 reads accounts.postings for transaction history (MOD-070). SD06 reads assets.assets for wealth analytics (MOD-101). Snowflake receives all tables via CDC (MOD-042). SD02 KYC mirror (accounts.kyc_status_mirror) is read by MOD-133 activation gate to verify trustee and beneficial owner eIDV status.