SD08 — Customer App & Back Office data model¶
Database: app (Neon Postgres, ap-southeast-2)
Repo: bank-app
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-026 (Cognito passwordless auth), ADR-028 (S3 document storage), ADR-020 (automation rules), ADR-048 (database-enforced invariants)
Schema owner: SD08 Customer App & Back Office Platform
This is the authoritative schema reference for all modules in bank-app. Use exact column names and types from this document — do not invent alternatives.
The app schema is the primary write domain for MOD-049 (consent capture), MOD-050 (disclosure enforcement), MOD-051 (financial automation rules), MOD-052 (role-scoped data access), MOD-053 (case and complaint management), MOD-054 (call recording), MOD-064 (operations work queue), MOD-068 (authentication and session management), MOD-069 (app shell), MOD-070 (transaction history), MOD-071 (payment initiation), MOD-072 (customer profile), MOD-073 (document vault), MOD-074 (back-office customer 360), MOD-077 (account dashboard), MOD-078 (card and account controls), MOD-083 (agent assist), and MOD-127 (product configuration panel). SD08 is a consumer of data from SD01–SD05 via their APIs; it writes primarily to the app schema for UX state, consent, compliance, and case management.
Schema: app¶
app.customer_sessions¶
Authentication sessions for customers using the mobile and web app, managed by MOD-068 using AWS Cognito passwordless auth. One row per active session; expired sessions are retained for security audit.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| user_id | uuid | NOT NULL REFERENCES access.user_identities(user_id) | Authenticated user — FK to access.user_identities |
| cognito_sub | text | NOT NULL | AWS Cognito subject identifier |
| session_token_hash | text | NOT NULL | SHA256 of the session token; never store the token itself |
| device_id | text | Device fingerprint from MOD-024 | |
| device_type | text | CHECK (device_type IN ('IOS','ANDROID','WEB','DESKTOP')) | |
| ip_address | text | Session originating IP | |
| jurisdiction | char(2) | NOT NULL | |
| auth_method | text | NOT NULL CHECK (auth_method IN ('BIOMETRIC','PASSKEY','SMS_OTP','EMAIL_OTP','PIN')) | |
| session_status | text | NOT NULL CHECK (session_status IN ('ACTIVE','EXPIRED','REVOKED','STEP_UP_REQUIRED')) | |
| mfa_completed | boolean | NOT NULL DEFAULT false | Step-up auth completed |
| initiated_at | timestamptz | NOT NULL DEFAULT now() | |
| last_active_at | timestamptz | NOT NULL DEFAULT now() | |
| expires_at | timestamptz | NOT NULL CHECK (expires_at > initiated_at) | |
| revoked_at | timestamptz | ||
| revocation_reason | text | ||
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_customer_sessions_user_id_active on (user_id) WHERE session_status = 'ACTIVE'
- idx_customer_sessions_expires_at on (expires_at) WHERE session_status = 'ACTIVE'
- idx_customer_sessions_device_id on (device_id) WHERE device_id IS NOT NULL
- idx_customer_sessions_token_hash on (session_token_hash)
Notes: Session tokens are never stored. Only the SHA256 hash is persisted. Session validation reads this hash to verify the presented token.
app.consents¶
Customer consent records captured by MOD-049. Each row represents a specific consent grant or withdrawal for a defined purpose. Required for PRI-001 (privacy policy) and PAY-010 (Open Banking consent).
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | cross-domain ref to SD02 party.parties(party_id) |
| consent_type | text | NOT NULL CHECK (consent_type IN ('PRIVACY_POLICY','TERMS_CONDITIONS','MARKETING_EMAIL','MARKETING_SMS','DATA_SHARING','OPEN_BANKING','CREDIT_CHECK','PRODUCT_DISCLOSURE')) | |
| consent_version | text | NOT NULL | Version of the consent document |
| status | text | NOT NULL CHECK (status IN ('GRANTED','WITHDRAWN','EXPIRED')) | |
| granted_at | timestamptz | NULL if status = WITHDRAWN at creation (e.g. pre-existing opt-out record) | |
| withdrawn_at | timestamptz | ||
| withdrawal_method | text | CHECK (withdrawal_method IN ('APP','STAFF','API','EMAIL','BRANCH')) | |
| delivery_channel | text | CHECK (delivery_channel IN ('APP','WEB','BRANCH','API')) | Channel where consent was collected |
| disclosure_id | uuid | REFERENCES app.disclosures(id) | Disclosure presented at time of consent |
| ip_address | text | If digital consent | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_consents_party_id_type on (party_id, consent_type)
- idx_consents_status_type on (status, consent_type) WHERE status = 'GRANTED'
- idx_consents_party_current on (party_id) WHERE status = 'GRANTED'
app.ob_consents¶
Open banking consent arrangements managed by MOD-049 (V002). Jurisdiction-profile-aware model per FR-669. Each row represents a full TPP arrangement: the consented data scopes, the TPP identity, the sharing period, and the full jurisdiction-native payload. Complements app.consents — the OPEN_BANKING row in app.consents acts as a summary/status flag; this table carries the arrangement detail.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| consent_id | uuid | NOT NULL UNIQUE DEFAULT gen_random_uuid() | Public consent reference (returned to caller) |
| jurisdiction_profile | text | NOT NULL CHECK (jurisdiction_profile IN ('au_cdr','nz_payments_nz','generic_fapi2')) | |
| customer_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| third_party_id | uuid | NOT NULL REFERENCES app.ob_third_parties(id) | |
| granted_scopes | text[] | NOT NULL | Internal scope identifiers (profile-agnostic) |
| granted_at | timestamptz | NOT NULL DEFAULT now() | |
| expires_at | timestamptz | NULL = no expiry | |
| status | text | NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked','expired','suspended')) | |
| consent_payload | jsonb | NOT NULL | Full jurisdiction-native consent representation |
| arrangement_id | text | CDR arrangement_id or profile-native reference | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_ob_consents_customer_status on (customer_id, status) WHERE status = 'active'
- idx_ob_consents_third_party on (third_party_id, status)
- idx_ob_consents_expires_active on (expires_at) WHERE status = 'active' AND expires_at IS NOT NULL — sweeper scan
app.ob_consent_events¶
Immutable audit log of every open banking consent lifecycle event. ADR-048 Cat 1. Append-only — no row may ever be updated or deleted. Provides the full consent audit trail required by FR-314 and CON-007.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| consent_id | uuid | NOT NULL REFERENCES app.ob_consents(consent_id) | |
| event_type | text | NOT NULL CHECK (event_type IN ('GRANTED','AMENDED','REVOKED','EXPIRED','SUSPENDED')) | |
| customer_id | uuid | NOT NULL | |
| actor | text | NOT NULL CHECK (actor IN ('CUSTOMER','STAFF','SYSTEM','TPP')) | Who triggered the event |
| previous_status | text | NULL on GRANTED | |
| new_status | text | NOT NULL | |
| changed_scopes | text[] | Populated on AMENDED — delta only | |
| channel | text | CHECK (channel IN ('APP','API','SYSTEM')) | |
| ip_address | text | ||
| trace_id | text | ||
| recorded_at | timestamptz | NOT NULL DEFAULT now() |
Immutability: ADR-048 Cat 1 — trigger rejects all UPDATE and DELETE on this table unconditionally.
Indexes:
- idx_ob_consent_events_consent_id on (consent_id, recorded_at DESC)
- idx_ob_consent_events_customer on (customer_id, recorded_at DESC)
app.ob_third_parties¶
Stub TPP registry used by MOD-049 (V004). Stores known Accredited Data Recipients / API clients that may hold open banking consents with the platform's customers. Zero-seeded on deploy; populated via SQL or a future admin endpoint. Grant endpoint validates third_party_id against this table before inserting a consent.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| display_name | text | NOT NULL | Human-readable TPP name shown in consent UI |
| jurisdiction_profile | text | NOT NULL CHECK (jurisdiction_profile IN ('au_cdr','nz_payments_nz','generic_fapi2')) | |
| status | text | NOT NULL DEFAULT 'active' CHECK (status IN ('active','suspended','deregistered')) | |
| logo_url | text | HTTPS only | |
| registered_at | timestamptz | NOT NULL DEFAULT now() |
app.staff_training_acks¶
Immutable record of AML/CFT training completions per staff member. ADR-048 Cat 1. Satisfies AML-010 LOG — the platform's evidence of staff awareness obligations under NZ AML/CFT Act and AU AML/CTF Act. Seven-year retention. Managed by MOD-049 (V005).
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| staff_id | text | NOT NULL | Staff identifier from Cognito staff pool |
| training_code | text | NOT NULL | e.g. AML_ANNUAL_2026, AML_ONBOARDING |
| training_version | text | NOT NULL | Version of training content completed |
| completed_at | timestamptz | NOT NULL | When training was completed |
| delivery_method | text | NOT NULL CHECK (delivery_method IN ('ONLINE','IN_PERSON','BLENDED')) | |
| acknowledged_at | timestamptz | NOT NULL DEFAULT now() | When ack was submitted to this endpoint |
| ip_address | text |
Immutability: ADR-048 Cat 1 — trigger rejects all UPDATE and DELETE unconditionally. Seven-year row-level retention policy.
Indexes:
- idx_staff_training_acks_staff on (staff_id, training_code, completed_at DESC)
app.disclosures¶
Disclosure document registry managed by MOD-050 (AD-7 split design). One row per versioned disclosure document. Content (PDFs) lives in S3; this table stores metadata and the content-addressed SHA-256 for integrity verification. Mutable only for superseded_at / superseded_by_id transitions when a new version supersedes the previous one.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| product_id | text | NOT NULL | e.g. PRD-001 |
| version | text | NOT NULL | Semantic version of this disclosure document |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| disclosure_type | text | NOT NULL CHECK (disclosure_type IN ('CREDIT_CONTRACT','PRODUCT_TERMS','FEE_SCHEDULE','PDS','KEY_FACTS_SHEET','PRIVACY_NOTICE','HARDSHIP_RIGHTS','RESPONSIBLE_LENDING')) | |
| s3_key | text | NOT NULL | S3 key for the disclosure document content |
| content_checksum_sha256 | text | NOT NULL | SHA-256 of the document content at publish time |
| disclosure_context | jsonb | NOT NULL DEFAULT '{}' | Key-value substitution context for template rendering |
| effective_from | date | NOT NULL | When this version came into effect |
| superseded_at | timestamptz | NULL | Set when a newer version supersedes this one |
| superseded_by_id | uuid | NULL REFERENCES app.disclosures(id) | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_disclosures_product_version on (product_id, jurisdiction, disclosure_type, effective_from DESC) WHERE superseded_at IS NULL
- idx_disclosures_checksum on (content_checksum_sha256)
app.disclosure_acks¶
Acknowledgement ledger — one row per customer disclosure event. ADR-048 Cat 1 immutable. The gate check reads this table to verify the customer has acknowledged the current version before allowing product/feature access.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| disclosure_id | uuid | NOT NULL REFERENCES app.disclosures(id) | Specific versioned document acknowledged |
| disclosure_type | text | NOT NULL | Denormalised from app.disclosures for fast gate queries |
| product_id | text | NOT NULL | |
| jurisdiction | char(2) | NOT NULL | |
| channel | text | NOT NULL CHECK (channel IN ('APP','WEB','BRANCH','API')) | |
| session_id | text | NULL | Cognito/MOD-068 session ID if APP/WEB channel |
| content_checksum_sha256 | text | NOT NULL | SHA-256 of the document the customer was shown — ties the ack to the exact content |
| idempotency_key | text | NOT NULL UNIQUE | Prevents duplicate ack rows from retried requests |
| trace_id | uuid | NULL | ADR-031 propagation |
| acknowledged_at | timestamptz | NOT NULL | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- (party_id, disclosure_type, product_id, jurisdiction, acknowledged_at DESC) — gate lookup
- (disclosure_id, acknowledged_at DESC) — version-level audit
Immutability: trg_disclosure_acks_immutable — BEFORE UPDATE OR DELETE, calls app.fn_immutable_row(). ADR-048 Cat 1. CON-004 / CON-005 GATE evidence.
app.dcs_fcs_disclosures¶
NZ-only Key Information Summary (KIS) acknowledgement ledger for Designated Credit Suitability / First Charge Security products under CCCFA. ADR-048 Cat 1 immutable. One row per KIS presentation event.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| product_id | text | NOT NULL | |
| kis_version | text | NOT NULL | Version of the KIS template used |
| content_checksum_sha256 | text | NOT NULL | SHA-256 of the rendered KIS content shown |
| application_id | uuid | NULL | Credit application context if applicable |
| presented_at | timestamptz | NOT NULL | |
| acknowledged_at | timestamptz | NULL | NULL = presented but not yet acknowledged (STEP_UP state) |
| channel | text | NOT NULL CHECK (channel IN ('APP','WEB','BRANCH')) | |
| session_id | text | NULL | MOD-068 session ID |
| idempotency_key | text | NOT NULL UNIQUE | |
| trace_id | uuid | NULL | ADR-031 propagation |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- (party_id, product_id, acknowledged_at DESC) — current KIS ack gate
- (application_id) WHERE application_id IS NOT NULL
Immutability: trg_dcs_fcs_disclosures_immutable — BEFORE UPDATE OR DELETE, calls app.fn_immutable_row(). ADR-048 Cat 1. NZ CCCFA KIS compliance evidence.
Note: NZ-only. MOD-050 passes silently for AU jurisdiction (no KIS requirement). UNKNOWN jurisdiction blocks at the handler edge.
app.product_configurations¶
Product configuration stub, provisioned by MOD-050 (AD-4). MOD-127 (product configuration engine, not yet built) will assume ownership of this table when it ships. v1 contains only the columns needed by MOD-050's gate logic. Mutable.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| product_id | text | NOT NULL UNIQUE | e.g. PRD-001 |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| disclosure_required | boolean | NOT NULL DEFAULT true | Whether MOD-050 gate applies |
| kis_required | boolean | NOT NULL DEFAULT false | NZ KIS requirement flag |
| config_version | text | NOT NULL | Configuration schema version |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes: (product_id) UNIQUE.
Ownership: Forward-owned by MOD-050 v1; MOD-127 takes ownership via a follow-on migration when it ships.
app.product_parameter_metadata (MOD-127)¶
Startup-time seed table (V004). Defines, per parameter_key, whether a change in a given direction is unfavourable to customers, the required advance-notice period, and a JSON Schema for validating proposed_value. Seeded at startup; readable by classify-unfavourable.ts. Mutable (admin can update metadata rows to adjust notice periods without a code deploy).
| Column | Type | Constraints | Description |
|---|---|---|---|
| parameter_key | text | PK | Canonical key e.g. rate.lending.variable, fee.account.monthly |
| unfavourable_direction | text | NOT NULL CHECK (unfavourable_direction IN ('UP','DOWN','DOWN_TO_FALSE')) | UP = higher value is worse for customer; DOWN = lower is worse; DOWN_TO_FALSE = boolean flag removal |
| advance_notice_days | int | NOT NULL CHECK (advance_notice_days > 0) | Required advance notice before unfavourable change can take effect (v1: jurisdiction-agnostic conservative value; v2 adds jurisdiction column) |
| value_schema | jsonb | NOT NULL | JSON Schema used to validate proposed_value in proposals; e.g. {"type":"number","minimum":0} |
| display_name | text | NOT NULL | Human-readable label for back-office UI |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL | Touch trigger |
Seed rows (V004):
| parameter_key | unfavourable_direction | advance_notice_days |
|---|---|---|
rate.lending.variable |
UP | 14 |
rate.lending.fixed_1y |
UP | 14 |
rate.deposit.savings |
DOWN | 14 |
rate.deposit.term_3m |
DOWN | 14 |
fee.account.monthly |
UP | 30 |
fee.transaction.foreign |
UP | 30 |
threshold.minimum_balance |
UP | 14 |
threshold.lvr_cap |
DOWN | 30 |
feature.joint_accounts.enabled |
DOWN_TO_FALSE | 30 |
feature.overdraft.enabled |
DOWN_TO_FALSE | 30 |
app.product_parameters (MOD-127)¶
EAV current-state table. One row per (product_id, jurisdiction, parameter_key) — the live effective value after the most recent approved proposal has been applied. Updated atomically by the apply-due-proposals Lambda when a proposal transitions to live. Mutable. Managed by MOD-127.
| Column | Type | Constraints | Description |
|---|---|---|---|
| product_id | text | NOT NULL | e.g. PRD-001 |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| parameter_key | text | NOT NULL REFERENCES app.product_parameter_metadata(parameter_key) | |
| current_value | jsonb | NOT NULL | Current effective value; validated against parameter_key's value_schema at write time |
| last_proposal_id | uuid | NULL REFERENCES app.product_config_proposals(id) | The proposal that last updated this row; NULL for seed values |
| updated_at | timestamptz | NOT NULL | Touch trigger |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Primary key: (product_id, jurisdiction, parameter_key).
Index: (product_id, jurisdiction) for getCurrentParameter library lookups.
app.product_config_proposals (MOD-127)¶
Maker/checker workflow table for product configuration changes. Append-only by application logic — no UPDATE or DELETE on terminal-state rows (approved, rejected, live, superseded). ADR-048 Cat 2: immutability trigger prevents tampering with audit trail for terminal rows. Mutable for pending rows (status transitions to under_review and then terminal states). Managed by MOD-127.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| product_id | text | NOT NULL | e.g. PRD-001 |
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| parameter_key | text | NOT NULL REFERENCES app.product_parameter_metadata(parameter_key) | |
| previous_value | jsonb | NOT NULL | Snapshot of current_value at proposal creation — CON-005 before-state |
| proposed_value | jsonb | NOT NULL | Requested new value; validated against value_schema |
| status | text | NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','under_review','approved','rejected','live','superseded')) | |
| proposed_by | text | NOT NULL | Staff user ID (UUID string; cross-domain ref to access.user_identities) |
| reviewed_by | text | NULL | Staff user ID of approver/rejecter; GOV-007 CHECK ensures ≠ proposed_by |
| review_comment | text | NULL | |
| effective_date | date | NOT NULL | Date on or after which the change may be applied; must be ≥ advance_notice_days from proposal approval for unfavourable changes |
| notification_required | boolean | NOT NULL DEFAULT false | True if change is unfavourable per app.product_parameter_metadata |
| notification_confirmed_at | timestamptz | NULL | Set to now() when MOD-063 dispatch is triggered (OPTIMISTIC_V1); updated to actual dispatch timestamp once MOD-063 emits bank.platform.notification_dispatched |
| notification_method | text | NULL CHECK (notification_method IN ('OPTIMISTIC_V1','MOD_063_CONFIRMED')) | Compliance audit trail: OPTIMISTIC_V1 = optimistic stop-gap; MOD_063_CONFIRMED = real dispatch confirmed |
| superseded_by | uuid | NULL REFERENCES app.product_config_proposals(id) | Set when a newer proposal supersedes this one |
| idempotency_key | text | NOT NULL UNIQUE | Caller-supplied |
| trace_id | uuid | NULL | ADR-031 propagation |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL | Touch trigger |
DB-level CHECK (GOV-007 GATE):
Immutability (ADR-048 Cat 2): trg_product_config_proposals_terminal_immutable — BEFORE UPDATE, raises exception if OLD.status IN ('approved','rejected','live','superseded'). Application can transition pending → under_review → approved|rejected and approved → live|superseded. Terminal states are write-once.
Indexes:
- (product_id, jurisdiction, status) — apply-due-proposals query
- (status, effective_date) WHERE status = 'approved' — daily sweep filter
- (proposed_by, created_at DESC) — audit view
- (idempotency_key) UNIQUE
app.cases¶
Case and complaint records managed by MOD-053. Covers service requests, complaints, escalations, and regulatory-required case types (hardship, breach). One row per case; linked complaint events track the lifecycle.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| case_reference | text | NOT NULL UNIQUE | Human-readable reference (e.g. CAS-2026-009182) |
| party_id | uuid | NOT NULL | cross-domain ref to SD02 party.parties(party_id) |
| case_type | text | NOT NULL CHECK (case_type IN ('COMPLAINT','SERVICE_REQUEST','HARDSHIP','DISPUTE','FRAUD_REPORT','REGULATORY_ENQUIRY','INTERNAL_REVIEW','AML_REFER')) | |
| case_status | text | NOT NULL CHECK (case_status IN ('OPEN','UNDER_REVIEW','PENDING_CUSTOMER','PENDING_THIRD_PARTY','ESCALATED','RESOLVED','CLOSED','WITHDRAWN')) | |
| priority | text | NOT NULL CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')) | |
| subject | text | NOT NULL | |
| description | text | NOT NULL | |
| channel | text | NOT NULL CHECK (channel IN ('APP','WEB','PHONE','EMAIL','BRANCH','REGULATOR')) | |
| assigned_to | text | Staff ID | |
| team | text | Team assignment | |
| related_account_id | uuid | cross-domain bare UUID ref to accounts.accounts(id) — no REFERENCES constraint | |
| related_payment_id | uuid | cross-domain bare UUID ref to payments.payments(id) — no REFERENCES constraint | |
| vulnerability_flag | boolean | NOT NULL DEFAULT false | Set by staff or auto-flagged by MOD-053 |
| vulnerability_notes | jsonb | Structured notes on vulnerability type/circumstances; NULL if not flagged | |
| opened_at | timestamptz | NOT NULL DEFAULT now() | |
| sla_deadline | timestamptz | Computed from case_type and jurisdiction SLA rules | |
| resolved_at | timestamptz | ||
| closed_at | timestamptz | ||
| resolution_summary | text | Required before closure | |
| regulatory_reference | text | External reference if regulator-initiated | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_cases_party_id on (party_id)
- idx_cases_status on (case_status) WHERE case_status NOT IN ('CLOSED','WITHDRAWN')
- idx_cases_sla_deadline on (sla_deadline) WHERE case_status NOT IN ('RESOLVED','CLOSED','WITHDRAWN')
- idx_cases_case_reference on (case_reference)
app.case_reference_sequence¶
Per-year atomic counter backing the human-readable CAS-YYYY-NNNNNN case reference format. Uses INSERT … ON CONFLICT DO UPDATE (not a Postgres sequence) so the counter survives schema recreates and remains portable across Neon branches.
| Column | Type | Constraints | Description |
|---|---|---|---|
| year | integer | PK | Calendar year (e.g. 2026) |
| seq | bigint | NOT NULL DEFAULT 0 | Last sequence number issued for this year |
| updated_at | timestamptz | NOT NULL DEFAULT now() | Set on every upsert |
Access pattern: INSERT INTO app.case_reference_sequence (year, seq) VALUES ($1, 1) ON CONFLICT (year) DO UPDATE SET seq = app.case_reference_sequence.seq + 1, updated_at = now() RETURNING seq — returns the next seq value atomically. The caller formats CAS-{year}-{LPAD(seq, 6, '0')}.
app.complaint_events¶
Append-only event log for case/complaint lifecycle, linked to app.cases. Every status change, contact, action, and note creates a new event row. Provides the full case audit trail.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| case_id | uuid | NOT NULL REFERENCES app.cases(id) | |
| event_type | text | NOT NULL CHECK (event_type IN ('OPENED','NOTE_ADDED','STATUS_CHANGED','ASSIGNED','CONTACT_ATTEMPTED','CONTACT_MADE','DOCUMENT_ATTACHED','ESCALATED','RESOLVED','CLOSED','REOPENED','ACCEPTANCE_REFERRED')) | |
| previous_status | text | For STATUS_CHANGED events | |
| new_status | text | For STATUS_CHANGED events | |
| note | text | Free-text note | |
| actor | text | NOT NULL | Staff ID or system module |
| actor_type | text | NOT NULL CHECK (actor_type IN ('STAFF','CUSTOMER','SYSTEM','REGULATOR')) | |
| trace_id | text | Propagated from originating request or Lambda context | |
| event_timestamp | timestamptz | NOT NULL DEFAULT now() | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Constraints:
- chk_status_changed_has_states: CHECK (event_type != 'STATUS_CHANGED' OR (previous_status IS NOT NULL AND new_status IS NOT NULL))
- chk_note_added_has_note: CHECK (event_type != 'NOTE_ADDED' OR note IS NOT NULL)
Indexes:
- idx_complaint_events_case_id on (case_id, event_timestamp)
- idx_complaint_events_timestamp on (event_timestamp DESC)
Notes: Append-only. No updated_at column. Immutability is enforced by trg_complaint_events_immutable (ADR-048 Category 1).
app.document_metadata¶
Metadata for documents stored in S3, managed by MOD-073. Documents include identity verification files, disclosure PDFs, credit contracts, and customer-uploaded supporting documents. Actual files are in S3; this table is the index.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | cross-domain ref to SD02 party.parties(party_id) |
| document_category | text | NOT NULL CHECK (document_category IN ('IDENTITY','DISCLOSURE','CREDIT_CONTRACT','SUPPORTING_EVIDENCE','CALL_RECORDING','STATEMENT','OTHER')) | |
| document_type | text | NOT NULL | More specific type within category |
| s3_key | text | NOT NULL | S3 object key |
| s3_bucket | text | NOT NULL | S3 bucket name |
| file_name | text | NOT NULL | Original filename |
| mime_type | text | NOT NULL | |
| file_size_bytes | bigint | NOT NULL CHECK (file_size_bytes > 0 AND file_size_bytes <= 26214400) | 25 MB cap per FR-361 |
| checksum_sha256 | text | NOT NULL CHECK (checksum_sha256 ~ '^[0-9a-f]{64}$') | SHA-256 hex; verified on finalize |
| upload_status | text | NOT NULL CHECK (upload_status IN ('PENDING','COMPLETE','FAILED')) | PENDING = pre-signed URL issued; COMPLETE = S3 object confirmed |
| related_entity_type | text | e.g. CASE, KYC_CHECK, CREDIT_APPLICATION | |
| related_entity_id | uuid | FK value for the related entity | |
| retention_delete_at | date | Date this document must be deleted per policy | |
| uploaded_by | text | NOT NULL | Customer, staff ID, or module |
| uploaded_at | timestamptz | NOT NULL DEFAULT now() | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| deleted_at | timestamptz | Soft-delete; S3 object deleted by retention sweeper |
Indexes:
- idx_document_metadata_party_id on (party_id)
- idx_document_metadata_related on (related_entity_type, related_entity_id) WHERE related_entity_id IS NOT NULL
- idx_document_metadata_retention on (retention_delete_at) WHERE retention_delete_at IS NOT NULL AND deleted_at IS NULL
app.document_audit_log (Cat 1 immutable)¶
Append-only vault audit log per FR-364 and NFR-024 (zero mutations). Every upload, download, denial, and retention purge writes one row. Immutable via trg_document_audit_log_immutable (BEFORE UPDATE OR DELETE, calls fn_immutable_row()). 7-year retention per FR-364.
| Column | Type | Notes |
|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() |
| event_type | text NOT NULL | CHECK (event_type IN ('UPLOAD_INITIATED','UPLOAD_COMPLETED','DOWNLOAD','DENIED','RETENTION_PURGE')) |
| document_id | uuid NULL | NULL for UPLOAD_INITIATED (pre-metadata) or DENIED with no resolved doc |
| party_id | uuid NOT NULL | Customer the document belongs to |
| actor_user_id | uuid NULL | REFERENCES access.user_identities(user_id); NULL for SYSTEM actor |
| actor_type | text NOT NULL | CHECK (actor_type IN ('CUSTOMER','STAFF','SYSTEM')) |
| actor_justification | text NULL | CHECK (actor_type <> 'STAFF' OR (justification IS NOT NULL AND length(trim(justification)) > 0)) — staff access requires non-empty justification |
| document_category | text NULL | Category at time of event |
| session_id | uuid NULL | REFERENCES app.customer_sessions(id); NULL for SYSTEM events (e.g. retention purge) |
| s3_key | text NULL | Object key at time of event |
| ip_address | text NULL | |
| trace_id | uuid NOT NULL | ADR-031 propagation |
| recorded_at | timestamptz NOT NULL DEFAULT now() |
Constraints:
- chk_staff_actor_has_justification: STAFF actor_type requires non-empty justification (see above)
- chk_human_actor_has_user_id: CHECK (actor_type = 'SYSTEM' OR actor_user_id IS NOT NULL)
app.automation_rules¶
Customer-defined financial automation rules managed by MOD-051 (savings rules, scheduled transfers, payment rules). Each row is a single rule belonging to one customer.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | cross-domain ref to SD02 party.parties(party_id) |
| rule_name | text | NOT NULL | Customer-given label |
| rule_type | text | NOT NULL CHECK (rule_type IN ('ROUND_UP','SCHEDULED_TRANSFER','SAVE_PERCENT','BILL_ALERT','MERCHANT_BLOCK','SPEND_LIMIT','IDLE_BALANCE_SWEEP','SAFETY_NET','FX_RATE_SWEEP')) | FX_RATE_SWEEP trigger handler deferred to v2 (awaiting MOD-006 FX rate stream) |
| source_account_id | uuid | NOT NULL REFERENCES accounts.accounts(id) | |
| destination_account_id | uuid | REFERENCES accounts.accounts(id) | NULL for rule types that don't transfer funds |
| rule_config | jsonb | NOT NULL | Rule parameters (amount, schedule, threshold, etc.) |
| rule_status | text | NOT NULL CHECK (rule_status IN ('ACTIVE','PAUSED','EXPIRED','DELETED')) | |
| trigger_count | int | NOT NULL DEFAULT 0 | Number of times the rule has fired |
| last_triggered_at | timestamptz | ||
| effective_from | date | NOT NULL DEFAULT CURRENT_DATE | |
| effective_to | date | NULL = no expiry | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL | |
| deleted_at | timestamptz |
Indexes:
- idx_automation_rules_party_id_active on (party_id) WHERE rule_status = 'ACTIVE'
- idx_automation_rules_source_account on (source_account_id) WHERE rule_status = 'ACTIVE'
app.automation_rule_executions¶
Immutable append-only audit log of every rule evaluation and its outcome. ADR-048 Cat 1. One row per evaluation — including evaluations where the condition was not met (NOT_APPLICABLE rows are only written on FAILED outcomes, not on every no-op evaluation, to keep the log focused on customer-visible events). Provides the FR-323 execution history visible to the customer and the NFR-024 audit trail.
| Column | Type | Constraints | Description |
|---|---|---|---|
| execution_id | uuid | PK DEFAULT gen_random_uuid() | |
| rule_id | uuid | NOT NULL REFERENCES app.automation_rules(id) | |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| trigger_source | text | NOT NULL CHECK (trigger_source IN ('EVENT','SCHEDULE')) | Whether fired by an EventBridge event or the daily cron |
| trigger_event_id | text | EventBridge event_id of the triggering event; NULL for SCHEDULE triggers |
|
| condition_result | text | NOT NULL CHECK (condition_result IN ('PASSED','FAILED','NOT_APPLICABLE')) | |
| action_outcome | text | NOT NULL CHECK (action_outcome IN ('EXECUTED','SKIPPED_INSUFFICIENT_FUNDS','SKIPPED_RULE_PAUSED','MOD_020_DECLINED','FAILED','NOTIFICATION_ONLY')) | |
| amount | numeric(18,4) | Sweep or round-up amount; NULL for non-monetary actions | |
| currency | char(3) | ISO 4217; NULL for non-monetary actions | |
| source_account_id | uuid | Account funds moved from | |
| dest_account_id | uuid | Account funds moved to | |
| mod_020_payment_id | uuid | payments.payments(id) — populated when action_outcome = EXECUTED and a real payment was produced |
|
| failure_reason | text | Populated on action_outcome IN ('FAILED','MOD_020_DECLINED','SKIPPED_INSUFFICIENT_FUNDS') | |
| idempotency_key | text | NOT NULL UNIQUE | rule:{rule_id}:event:{trigger_event_id} for EVENT triggers; rule:{rule_id}:schedule:{date} for SCHEDULE triggers. Prevents duplicate evaluation on EB redelivery |
| trace_id | uuid | ADR-031 trace propagation from triggering event | |
| occurred_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_automation_rule_executions_rule_id on (rule_id, occurred_at DESC) — customer-facing execution history
- idx_automation_rule_executions_party_id on (party_id, occurred_at DESC)
- idx_automation_rule_executions_mod020_payment on (mod_020_payment_id) WHERE mod_020_payment_id IS NOT NULL
Immutability: trg_automation_rule_executions_immutable — BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row(). FR-323 + CON-005 LOG + NFR-024; 7-year retention.
GRANTs: SELECT, INSERT only to bank_app_lambda_role. No UPDATE, DELETE, or TRUNCATE to any role.
app.work_queue_items¶
Operational work queue for staff task routing, managed by MOD-064. Covers payment exceptions, KYC review tasks, fraud alerts requiring human review, and case escalations. The queue feeds the back-office workflow.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| queue_type | text | NOT NULL CHECK (queue_type IN ('KYC_REVIEW','FRAUD_ALERT','PAYMENT_EXCEPTION','SANCTIONS_ADJUDICATION','CASE_ESCALATION','AML_ALERT','COLLECTIONS','CREDIT_REFERRAL','DISCLOSURE_EXCEPTION')) | |
| priority | text | NOT NULL CHECK (priority IN ('LOW','MEDIUM','HIGH','URGENT')) | |
| item_status | text | NOT NULL CHECK (item_status IN ('QUEUED','ASSIGNED','IN_PROGRESS','COMPLETED','CANCELLED','ESCALATED')) | |
| source_entity_type | text | NOT NULL | Entity that generated this work item |
| source_entity_id | uuid | NOT NULL | |
| party_id | uuid | cross-domain ref to SD02 party.parties(party_id) |
|
| sla_deadline | timestamptz | Work must be completed by this time | |
| assigned_to | text | Staff ID | |
| assigned_at | timestamptz | ||
| completed_at | timestamptz | ||
| completion_outcome | text | Result of the work | |
| created_by | text | NOT NULL | Module or staff that created this item |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_work_queue_status_priority on (item_status, priority) WHERE item_status IN ('QUEUED','ASSIGNED','IN_PROGRESS')
- idx_work_queue_queue_type on (queue_type, item_status)
- idx_work_queue_sla_deadline on (sla_deadline) WHERE item_status IN ('QUEUED','ASSIGNED')
- idx_work_queue_party_id on (party_id) WHERE party_id IS NOT NULL
app.call_recordings¶
Metadata for call recordings captured by MOD-054. Audio files are stored in S3; this table holds metadata, transcript references, and case linkage.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | cross-domain ref to SD02 party.parties(party_id) — NULL if party unidentified at call start |
|
| case_id | uuid | REFERENCES app.cases(id) | Populated after call is linked to a case |
| agent_id | text | NOT NULL | Staff ID of the agent who handled the call |
| call_direction | text | NOT NULL CHECK (call_direction IN ('INBOUND','OUTBOUND')) | |
| call_duration_seconds | int | ||
| s3_key_audio | text | NOT NULL | S3 key for the audio recording |
| s3_key_transcript | text | S3 key for the transcript (populated after transcription) | |
| transcript_status | text | NOT NULL CHECK (transcript_status IN ('PENDING','PROCESSING','COMPLETED','FAILED')) | |
| recording_consent_given | boolean | NOT NULL DEFAULT false | Whether customer consented to recording |
| started_at | timestamptz | NOT NULL | |
| ended_at | timestamptz | ||
| retention_delete_at | date | NOT NULL | Retention period end date |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_call_recordings_party_id on (party_id) WHERE party_id IS NOT NULL
- idx_call_recordings_case_id on (case_id) WHERE case_id IS NOT NULL
- idx_call_recordings_agent_id on (agent_id, started_at DESC)
- idx_call_recordings_retention on (retention_delete_at) WHERE retention_delete_at IS NOT NULL
app.transaction_view¶
Per-party projection of the canonical transaction log. Populated by the posting_completed EventBridge consumer (INSERT/refresh) and updated by the transactions_categorised EventBridge consumer (enrichment UPDATE). Owned by MOD-070. This is a projection — SD01 accounts.postings is the source of truth.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| posting_id | uuid | NOT NULL UNIQUE | Application-layer ref to SD01 accounts.postings(id); no FK — cross-DB. Dedup key for projection replay |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| account_id | uuid | NOT NULL | Cross-domain ref to SD01 accounts.accounts(id) |
| posting_date | timestamptz | NOT NULL | Timestamp from posting_completed event |
| amount | numeric(18,2) | NOT NULL | |
| currency | char(3) | NOT NULL | ISO 4217 |
| fx_rate | numeric(16,8) | NULL for same-currency postings | |
| fee | numeric(18,2) | NULL when no fee applied | |
| base_currency_amount | numeric(18,2) | NZD or AUD base amount after FX; NULL if same-currency | |
| running_balance | numeric(18,2) | Balance after this posting; updated by consumer | |
| raw_description | text | NOT NULL | Verbatim description from posting_completed event |
| merchant_name | text | Normalised merchant name from transactions_categorised; NULL until enriched |
|
| merchant_logo_url | text | CDN URL from transactions_categorised; NULL until enriched |
|
| merchant_map_url | text | Per-location static map URL; NULL until enriched | |
| mcc | char(4) | ISO 18245 MCC code; NULL until enriched | |
| category_l1 | text | Top-level spend category (e.g. GROCERIES); NULL until enriched |
|
| category_l2 | text | Sub-category (e.g. SUPERMARKET); NULL when not available |
|
| categorisation_confidence | numeric(5,4) | 0.0000–1.0000; NULL until enriched | |
| source_module | text | NOT NULL | Module that inserted this row (e.g. MOD-070) |
| fts_vector | tsvector | GENERATED ALWAYS AS (to_tsvector('english', coalesce(raw_description,'') || ' ' || coalesce(merchant_name,''))) STORED | Full-text search vector for FR-350 GIN index |
| last_enriched_at | timestamptz | NULL until first enrichment; updated on each transactions_categorised event |
|
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL | Touch trigger maintains |
Indexes:
- UNIQUE (posting_id) — projection dedup
- idx_transaction_view_party_date on (party_id, posting_date DESC) — FR-349 keyset pagination (covering index: includes account_id, amount, currency)
- idx_transaction_view_account_date on (account_id, posting_date DESC) — account-filtered history
- idx_transaction_view_fts GIN on (fts_vector) — FR-350 full-text search
- idx_transaction_view_party_pending_enrich on (party_id) WHERE last_enriched_at IS NULL — enrichment sweep
Notes: Projection semantics — rows are INSERT on posting_completed, UPDATE on transactions_categorised. Not immutable (enrichment columns are mutable); only posting_id, party_id, account_id, posting_date, amount, currency, and raw_description are effectively immutable (sourced from the ledger). Keyset pagination uses opaque base64-encoded (posting_date, posting_id) cursor per FR-349. fts_vector GENERATED column covers raw_description and merchant_name for FR-350 full-text search across up to 12 months.
app.transaction_exports (Cat 1 immutable)¶
Immutable audit log of every customer-initiated export request. One row per export. Satisfies GOV-006 LOG obligation. Owned by MOD-070. Immutability enforced by trg_transaction_exports_immutable — BEFORE UPDATE OR DELETE, calls fn_immutable_row() — ADR-048 Category 1.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Customer who requested the export |
| user_id | uuid | NOT NULL | Authenticated session user (app user) |
| filters | jsonb | NOT NULL DEFAULT '{}' | Filter params applied (date_from, date_to, account_ids, categories) |
| format | text | NOT NULL CHECK (format IN ('CSV','PDF')) | Export format requested |
| row_count | integer | NOT NULL | Number of transaction rows included |
| s3_key | text | NOT NULL | S3 object key under mod070/exports/ prefix |
| trace_id | uuid | NOT NULL | ADR-031 trace propagation |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_transaction_exports_party_id on (party_id, created_at DESC)
Notes: Append-only — trg_transaction_exports_immutable blocks UPDATE/DELETE/TRUNCATE (ADR-048 Category 1, same pattern as app.document_audit_log). No updated_at column. s3_key uses format mod070/exports/{party_id}/{yyyy-mm-dd}/{id}.{csv|pdf}.
app.account_summary (MOD-077)¶
Balance projection per account, maintained by the bank.core.balance_updated EventBridge consumer. Created by MOD-077 V001. This is a mutable projection — SD01 accounts.accounts is the source of truth. Upserted on every balance_updated event; idempotent on last_event_id.
| Column | Type | Constraints | Description |
|---|---|---|---|
| account_id | uuid | PRIMARY KEY | Cross-domain ref to SD01 accounts.accounts(id); no FK — cross-DB |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| account_type | text | NOT NULL | e.g. EVERYDAY, SAVINGS, TERM_DEPOSIT |
| currency | char(3) | NOT NULL | ISO 4217 |
| balance | numeric(18,2) | NOT NULL | Ledger balance at as_at |
| available_balance | numeric(18,2) | NOT NULL | Available balance (balance minus holds) |
| accrued_interest | numeric(18,2) | NOT NULL DEFAULT 0 | Accrued but unposted interest |
| jurisdiction | text | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| last_event_id | uuid | NOT NULL | event_id from the most recent balance_updated event — dedup key for idempotent upserts |
| as_at | timestamptz | NOT NULL | Balance snapshot timestamp from the event |
| trace_id | text | NOT NULL | ADR-031 trace propagation |
| updated_at | timestamptz | NOT NULL DEFAULT current_timestamp | Touch-updated by consumer |
Indexes:
- PRIMARY KEY (account_id)
- idx_account_summary_party on (party_id) — dashboard load fetches all accounts for a party
Notes: Mutable projection — no immutability trigger. UPSERT ON CONFLICT (account_id) DO UPDATE is the consumer pattern. last_event_id guards against out-of-order or duplicate event delivery. No DELETE grant — stale rows are acceptable; the consumer refreshes on each event.
app.dashboard_insight_card (MOD-077)¶
Local cache of computed insight cards surfaced on the customer home screen. Refreshed on dashboard load and on qualifying event triggers (balance change, new categorised transaction, health score update). Created by MOD-077 V002.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PRIMARY KEY DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Customer this card belongs to |
| card_type | text | NOT NULL CHECK (card_type IN ('LOW_BALANCE','RECURRING_FORECAST','IDLE_CASH','SPEND_ANOMALY','HEALTH_INSIGHT')) | Insight category |
| title | text | NOT NULL | Short display title |
| body | text | NOT NULL | Card body copy |
| cta_type | text | CHECK (cta_type IN ('MOVE_TO_SAVINGS','CHECK_LOAN_TERMS','REVIEW_TRANSACTION','INITIATE_TRANSFER','NONE')) | One-tap action type; NULL = no CTA |
| cta_payload | jsonb | Action parameters (e.g. account_id for MOVE_TO_SAVINGS) | |
| priority | integer | NOT NULL DEFAULT 5 | Render order — lower integer = higher priority |
| expires_at | timestamptz | Card becomes stale after this time; NULL = no expiry | |
| displayed_at | timestamptz | Set when card is rendered to the customer | |
| dismissed_at | timestamptz | Set when customer dismisses; excluded from active feed | |
| trace_id | text | NOT NULL | ADR-031 trace propagation |
| created_at | timestamptz | NOT NULL DEFAULT current_timestamp | |
| updated_at | timestamptz | NOT NULL DEFAULT current_timestamp |
Indexes:
- idx_insight_card_party_active on (party_id, priority) WHERE dismissed_at IS NULL — active card feed
- idx_insight_card_party_displayed on (party_id, displayed_at DESC) — display history
Notes: Mutable — cards are refreshed (UPDATE) and dismissed (UPDATE). Not immutable; no audit-log requirement (insight cards are derived, not authoritative). card_type and cta_type CHECK constraints ensure v2 additions require an explicit migration.
Schema: access¶
The access schema is owned by SD08. It is the authentication and authorisation layer — linking a stable digital identity to the party model and expressing what that identity can see and do across all party relationships.
access.user_identities¶
An authenticated digital identity. One human = one user_id. The user_id persists across all party relationships, context changes, and device changes. This is the stable anchor for sessions, audit, and access grants.
| Column | Type | Constraints | Description |
|---|---|---|---|
| user_id | uuid | PK DEFAULT gen_random_uuid() | Stable identifier — never changes, never reused |
| person_party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) — must be a NATURAL_PERSON party |
| login_status | text | NOT NULL CHECK (login_status IN ('ACTIVE','LOCKED','SUSPENDED','PENDING_VERIFICATION')) | |
| mfa_status | text | NOT NULL CHECK (mfa_status IN ('ENROLLED','NOT_ENROLLED','BYPASSED')) | |
| preferred_notification_channel | text | CHECK (preferred_notification_channel IN ('PUSH','SMS','EMAIL')) | |
| last_login_at | timestamptz | ||
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_user_identities_person_party_id on (person_party_id)
- idx_user_identities_login_status on (login_status) WHERE login_status = 'ACTIVE'
Notes: person_party_id is a UUID convention cross-domain reference to SD02 party.parties(party_id); it is not a Postgres FK (different Neon databases). Application code enforces the constraint at write time. app.customer_sessions references access.user_identities(user_id) via a real Postgres FK — both tables are in the app database.
access.access_grants¶
What a user can see and do. A user has one or more access grants, each scoping access to a specific party, account, operating context, or portfolio. This is how a single login sees personal accounts, sole trader accounts, and charity treasurer access under one authenticated session.
| Column | Type | Constraints | Description |
|---|---|---|---|
| grant_id | uuid | PK DEFAULT gen_random_uuid() | |
| user_id | uuid | NOT NULL REFERENCES access.user_identities(user_id) | |
| scope_type | text | NOT NULL CHECK (scope_type IN ('PARTY','ACCOUNT','OPERATING_CONTEXT','PRODUCT','PORTFOLIO')) | What kind of thing access is granted to |
| scope_id | uuid | NOT NULL | The party_id, account_id, context_id, etc. — cross-domain UUID convention |
| access_role | text | NOT NULL CHECK (access_role IN ('OWNER','VIEWER','OPERATOR','ACCOUNTANT','TREASURER','ADMIN','READ_ONLY')) | |
| permissions_mask | jsonb | NOT NULL DEFAULT '{}' | Fine-grained flags: |
| granted_by_party_id | uuid | Cross-domain ref to SD02 party.parties(party_id) — who granted access; NULL = self/system for OWNER |
|
| authority_basis | text | NOT NULL CHECK (authority_basis IN ('SELF','BOARD_RESOLUTION','MANDATE','DELEGATION','SYSTEM')) | |
| start_date | date | NOT NULL | |
| end_date | date | CHECK (end_date IS NULL OR end_date >= start_date) | NULL = open-ended |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| revoked_at | timestamptz | Populated on explicit revocation |
Indexes:
- idx_access_grants_user_active on (user_id, scope_type, scope_id) WHERE revoked_at IS NULL AND (end_date IS NULL OR end_date >= CURRENT_DATE)
- idx_access_grants_scope on (scope_type, scope_id) WHERE revoked_at IS NULL
Example — the charity treasurer: Ross (user_id = uuid-ross-user) has an access grant with scope_type=PARTY, scope_id=uuid-wellington-community-trust, access_role=TREASURER. Ross's session resolves all accounts, contexts, and data associated with the trust. The trust remains the separate depositor under DCS. Ross's personal accounts are separate access grants.
access.user_roles¶
Back-office staff role assignments. One active role per staff user at a time — enforced by a partial unique index on (user_id) WHERE revoked_at IS NULL. Role history is retained (rows are never deleted; revocation stamps revoked_at). Created by MOD-052 V001.
| Column | Type | Constraints | Description |
|---|---|---|---|
| role_id | uuid | PK DEFAULT gen_random_uuid() | |
| user_id | uuid | NOT NULL REFERENCES access.user_identities(user_id) | Staff member being assigned the role |
| role | text | NOT NULL CHECK (role IN ('customer-facing','compliance','operations','senior')) | FR-325 role names; matches Cognito group name |
| granted_by_user_id | uuid | NOT NULL REFERENCES access.user_identities(user_id) | Staff admin who made the assignment |
| granted_at | timestamptz | NOT NULL DEFAULT now() | |
| revoked_at | timestamptz | NULL = active assignment | |
| revoked_by_user_id | uuid | REFERENCES access.user_identities(user_id) | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- Partial UNIQUE on (user_id) WHERE revoked_at IS NULL — one active role per staff user
- idx_user_roles_role_active on (role) WHERE revoked_at IS NULL
Notes: Mutable by design — revoked_at is stamped on revocation. Inserts and revocations publish bank.app.role_assigned / bank.app.role_revoked events to the bank-app bus (FR-328, NFR-023). Role propagation to warm Lambdas occurs via 60s TTL cache expiry; cold-start Lambdas always load fresh from DB.
access.role_permissions¶
Column-level permission matrix: which attributes each role may access on each entity. One row per (role, entity, attribute) triple. Seeded at V002 with the FR-325 role specifications. Updated at runtime via MOD-052 admin endpoints. Mutable.
| Column | Type | Constraints | Description |
|---|---|---|---|
| permission_id | uuid | PK DEFAULT gen_random_uuid() | |
| role | text | NOT NULL CHECK (role IN ('customer-facing','compliance','operations','senior')) | |
| entity | text | NOT NULL | Logical entity path e.g. customer.profile, aml.alerts, app.cases |
| attribute | text | NOT NULL | Column-level attribute name; * = all attributes on the entity |
| granted | boolean | NOT NULL DEFAULT true | True = attribute accessible; false = explicit deny |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- UNIQUE on (role, entity, attribute)
- idx_role_permissions_role on (role)
Notes: Roles are parallel and explicit — senior has no implicit inheritance from operations. All elevated access for senior is expressed as explicit granted = true rows. The RESTRICTED_TO_COMPLIANCE_AND_LEGAL override in src/lib/restricted.ts (AML-006) short-circuits this matrix for SAR-flagged entities regardless of role_permissions rows.
access.access_log¶
Immutable audit log of every enforcement decision made by the MOD-052 library. One row per enforce() call. Every allowed and denied attribute request is recorded. Retained for 7 years per FR-327. Created by MOD-052 V003.
| Column | Type | Constraints | Description |
|---|---|---|---|
| log_id | uuid | PK DEFAULT gen_random_uuid() | |
| requested_at | timestamptz | NOT NULL DEFAULT now() | |
| staff_user_id | uuid | NOT NULL REFERENCES access.user_identities(user_id) | |
| role | text | NOT NULL | Role at time of request (snapshot — not FK to current assignment) |
| entity | text | NOT NULL | Entity path evaluated |
| attributes_requested | text[] | NOT NULL DEFAULT '{}' | |
| attributes_allowed | text[] | NOT NULL DEFAULT '{}' | |
| attributes_denied | text[] | NOT NULL DEFAULT '{}' | |
| decision | text | NOT NULL CHECK (decision IN ('ALLOWED','DENIED','PARTIAL')) | PARTIAL = some attributes allowed, some denied |
| deny_reason | text | e.g. INSUFFICIENT_ROLE_SCOPE, RESTRICTED_COMPLIANCE_AND_LEGAL |
|
| jurisdiction | char(2) | NOT NULL CHECK (jurisdiction IN ('NZ','AU')) | |
| trace_id | text | ADR-031 X-Ray propagation | |
| created_at | timestamptz | NOT NULL DEFAULT now() |
Indexes:
- idx_access_log_staff_user on (staff_user_id, requested_at DESC)
- idx_access_log_entity on (entity, requested_at DESC)
- idx_access_log_denied on (requested_at DESC) WHERE decision = 'DENIED'
Immutability (ADR-048 Cat 1): trg_access_log_immutable — BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row(). This is the durable compliance record. The CloudWatch Logs write to /aws/bank-app/access-log-{env} is a secondary SIEM feed; a CW Logs write failure does not compromise the compliance obligation, which is met by the Postgres row.
Payment initiation tables (MOD-071)¶
MOD-071 manages the two-step preview→confirm payment initiation flow, the payee address book, and scheduled payment instructions. All tables are in the app schema (ADR-064). Migrations land in bank-app as V00N__app_payment_*.sql.
app.payment_previews¶
One row per payment preview session (CAP-004/005/007/008/114). Carries the full ValidatePaymentRequest payload, the MOD-071-minted payment_id that will be passed to MOD-020 on confirmation, FX/fee disclosure fields, and step-up flags. Rows expire after 15 minutes.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | Public preview_id returned to the client and required on POST /payments/confirm |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| payment_type | text | NOT NULL CHECK (payment_type IN ('DOMESTIC_TRANSFER','INTERNATIONAL_WIRE','BPAY','INTERBANK_NZ','OSKO')) | Determines rail and MOD-020 fields |
| payment_id | uuid | NOT NULL | Minted by MOD-071 at preview creation; passed to MOD-020 and MOD-001 for audit linkage. No FK — payments.payments row created by MOD-020 in same chain |
| step_up_required | boolean | NOT NULL DEFAULT false | True if amount > threshold or first-time payee (FR-355) |
| step_up_reason | text | CHECK (step_up_reason IN ('AMOUNT_THRESHOLD','FIRST_TIME_PAYEE','BOTH')) | Populated when step_up_required = true |
| disclosure_required | boolean | NOT NULL DEFAULT false | True for FX payments; MOD-050 check result |
| request_payload | jsonb | NOT NULL | Full ValidatePaymentRequest payload |
| fx_rate | numeric(18,8) | Spot rate at preview time (FX payments only) | |
| fx_markup | numeric(18,8) | Markup applied on top of spot rate | |
| fee_amount | numeric(18,2) | Total fee in fee_currency | |
| fee_currency | char(3) | ISO-4217 | |
| base_currency_amount | numeric(18,2) | Amount in source currency before conversion | |
| total_to_debit | numeric(18,2) | Total debited including fees (source currency) | |
| mod_020_decision | text | CHECK (mod_020_decision IN ('AUTHORISED','VALIDATION_FAILED','PENDING_AUTH')) | Populated after MOD-020 response on confirmation |
| mod_020_failure_reason | text | MOD-020 failure_reason if VALIDATION_FAILED | |
| status | text | NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','CONFIRMED','EXPIRED','CANCELLED')) | |
| idempotency_key | text | NOT NULL UNIQUE | sha256(id::text || ':' || party_id::text); passed to MOD-020 as idempotency_key |
| expires_at | timestamptz | NOT NULL DEFAULT now() + interval '15 minutes' | |
| trace_id | text | NOT NULL | OTel trace ID from preview request |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_payment_previews_party_status on (party_id, status) WHERE status = 'PENDING'
- idx_payment_previews_expires_at on (expires_at) WHERE status = 'PENDING' — TTL sweeper
- idx_payment_previews_payment_id on (payment_id)
Notes: id is the public preview_id. payment_id is minted here — established pattern (same as MOD-141/MOD-119/MOD-120/MOD-122). No FK to payments.payments because the payments row is created by MOD-020 in the same synchronous confirmation chain. step_up_required is evaluated from the STEP_UP_THRESHOLD_DEFAULT env var and the payee's first_use_completed flag.
app.payment_initiation_events¶
ADR-048 Cat 1 immutable. One row per state transition in the payment initiation lifecycle. Provides the FR-356 + NFR-024 audit trail (session_id, device_fingerprint, timestamps). State sequence: PREVIEWED → CONFIRMED → SUBMITTED → RESULTED | EXPIRED | CANCELLED.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| preview_id | uuid | NOT NULL REFERENCES app.payment_previews(id) | |
| party_id | uuid | NOT NULL | Denormalised from preview — avoids JOIN |
| event_type | text | NOT NULL CHECK (event_type IN ('PREVIEWED','CONFIRMED','SUBMITTED','RESULTED','EXPIRED','CANCELLED')) | State transition label |
| session_id | uuid | NOT NULL REFERENCES app.customer_sessions(id) | Active session at event time |
| device_fingerprint | text | sha256 hex from x-device-fingerprint header (MOD-069 mobile shell) |
|
| payment_id | uuid | MOD-071-minted payment_id — present from CONFIRMED onwards | |
| mod_020_decision | text | CHECK (mod_020_decision IN ('AUTHORISED','VALIDATION_FAILED','PENDING_AUTH')) | Present on RESULTED rows |
| mod_020_failure_reason | text | Present on RESULTED + VALIDATION_FAILED rows | |
| trace_id | text | NOT NULL | OTel trace ID |
| event_timestamp | timestamptz | NOT NULL DEFAULT now() |
Immutability: trg_payment_initiation_events_immutable — BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row(). FR-356 + NFR-024 audit record; 7-year retention.
Indexes:
- idx_payment_initiation_events_preview_id on (preview_id, event_type)
- idx_payment_initiation_events_party_id on (party_id, event_timestamp DESC)
- idx_payment_initiation_events_payment_id on (payment_id) WHERE payment_id IS NOT NULL
app.payees¶
Mutable payee address book (CAP-114). Each row is a saved recipient for a party. Soft-deleted on removal; first_use_completed drives the FR-355 first-time-payee step-up gate.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| payment_type | text | NOT NULL CHECK (payment_type IN ('DOMESTIC_TRANSFER','INTERNATIONAL_WIRE','BPAY','INTERBANK_NZ','OSKO')) | |
| payee_name | text | NOT NULL | Display name |
| destination_account_number | text | NOT NULL | Account number, IBAN, PayID, or BPAY biller code depending on payment_type |
| swift_bic | text | For INTERNATIONAL_WIRE | |
| bank_code | text | BSB (AU) or routing number | |
| currency | char(3) | ISO-4217; populated for cross-currency payees | |
| bpay_crn | text | Customer Reference Number for BPAY payees | |
| first_use_completed | boolean | NOT NULL DEFAULT false | Set true after first successful payment; clears FR-355 step-up gate for repeat payments |
| deleted_at | timestamptz | Soft-delete | |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Unique constraint: UNIQUE (party_id, payment_type, destination_account_number, swift_bic) WHERE deleted_at IS NULL
Indexes:
- idx_payees_party_active on (party_id) WHERE deleted_at IS NULL
- idx_payees_party_type_active on (party_id, payment_type) WHERE deleted_at IS NULL
Notes: Soft-delete preserves audit record after customer removes a payee. first_use_completed = false on the matched payee record causes the next preview to set step_up_required = true with step_up_reason = 'FIRST_TIME_PAYEE'.
app.scheduled_payments¶
Mutable scheduled payment instructions (CAP-007). Supports one-off and recurring payments. The scheduled-payment-sweeper Lambda reads ACTIVE rows WHERE next_run_at <= now() on its daily cron (cron(0 19 ? * * *) = 07:00 NZST).
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| payment_type | text | NOT NULL CHECK (payment_type IN ('DOMESTIC_TRANSFER','INTERNATIONAL_WIRE','BPAY','INTERBANK_NZ')) | OSKO excluded — real-time only, not schedulable |
| frequency | text | NOT NULL CHECK (frequency IN ('ONE_OFF','WEEKLY','FORTNIGHTLY','MONTHLY')) | |
| request_payload | jsonb | NOT NULL | Base ValidatePaymentRequest template — amount, recipient, reference |
| status | text | NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE','PAUSED','COMPLETED','CANCELLED','FAILED')) | |
| start_date | date | NOT NULL | First execution date |
| end_date | date | NULL = recurring indefinitely | |
| next_run_at | timestamptz | NOT NULL | Next scheduled execution; updated by sweeper after each run |
| last_run_at | timestamptz | Most recent execution timestamp | |
| last_payment_id | uuid | payment_id minted on most recent sweep run | |
| run_count | integer | NOT NULL DEFAULT 0 | Total successful executions |
| idempotency_prefix | text | NOT NULL | sha256(id::text || ':' || party_id::text); sweeper appends run_count to derive per-run idempotency_key |
| trace_id | text | NOT NULL | OTel trace ID from creation request |
| created_at | timestamptz | NOT NULL DEFAULT now() | |
| updated_at | timestamptz | NOT NULL |
Indexes:
- idx_scheduled_payments_sweeper on (next_run_at) WHERE status = 'ACTIVE' — sweeper query
- idx_scheduled_payments_party_active on (party_id) WHERE status IN ('ACTIVE','PAUSED')
Notes: OSKO is excluded — real-time payments are not schedulable in v1. Recurring payments with end_date IS NULL must display a UI warning ("This payment will continue indefinitely — set an end date or cancel to stop it"). idempotency_prefix || ':' || run_count produces a unique per-run idempotency_key, preventing duplicate MOD-020 submissions on sweeper retry.
DB-enforced invariants (ADR-048)¶
Registers the Postgres constraints and triggers applied to this domain. All constraints and triggers are implemented in numbered Flyway migrations in bank-app.
Immutability triggers¶
| Table | Trigger | Category |
|---|---|---|
app.complaint_events |
trg_complaint_events_immutable — BEFORE UPDATE OR DELETE, calls fn_immutable_row() |
Cat 1 |
access.access_log |
trg_access_log_immutable — BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row() (MOD-052 V003). 7-year retention per FR-327; durable compliance record regardless of CW Logs availability. |
Cat 1 |
app.ob_consent_events |
trg_ob_consent_events_immutable — BEFORE UPDATE OR DELETE, calls fn_immutable_row() (MOD-049 V003). Consent audit trail per FR-314 and CON-007; 7-year retention. |
Cat 1 |
app.staff_training_acks |
trg_staff_training_acks_immutable — BEFORE UPDATE OR DELETE, calls fn_immutable_row() (MOD-049 V005). AML-010 LOG evidence; 7-year retention. |
Cat 1 |
app.document_audit_log |
trg_document_audit_log_immutable — BEFORE UPDATE OR DELETE, calls fn_immutable_row() (MOD-073 V002). FR-364 + NFR-024 vault audit log; 7-year retention. |
Cat 1 |
app.payment_initiation_events |
trg_payment_initiation_events_immutable — BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row() (MOD-071 V002). FR-356 + NFR-024 payment audit trail; 7-year retention. |
Cat 1 |
app.automation_rule_executions |
trg_automation_rule_executions_immutable — BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row() (MOD-051 V002). FR-323 + CON-005 LOG + NFR-024 rule execution audit log; 7-year retention. |
Cat 1 |
| app.product_config_proposals | trg_product_config_proposals_terminal_immutable — BEFORE UPDATE, raises exception if OLD.status IN ('approved','rejected','live','superseded') (MOD-127 V002). GOV-007 GATE + GOV-006 LOG evidence; 7-year audit retention. | Cat 2 |
| app.card_control_audit | trg_card_control_audit_immutable — BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row() (MOD-078 V002). FR-376 + NFR-024 controls audit log; 7-year retention. | Cat 1 |
Other tables in the app and access schemas are mutable by design — sessions expire, consents are withdrawn, cases progress through status transitions, access grants are revoked, role_permissions are updated by admins, and app.product_parameters, app.product_parameter_metadata reflect current live product configuration.
CHECK constraints¶
| Table | Column | Constraint |
|---|---|---|
app.customer_sessions |
expires_at |
CHECK (expires_at > initiated_at) |
app.document_metadata |
file_size_bytes |
CHECK (file_size_bytes > 0 AND file_size_bytes <= 26214400) — 25 MB cap per FR-361 |
app.document_metadata |
checksum_sha256 |
CHECK (checksum_sha256 ~ '^[0-9a-f]{64}$') — SHA-256 hex format |
app.document_metadata |
upload_status |
CHECK (upload_status IN ('PENDING','COMPLETE','FAILED')) |
app.document_audit_log |
event_type |
CHECK (event_type IN ('UPLOAD_INITIATED','UPLOAD_COMPLETED','DOWNLOAD','DENIED','RETENTION_PURGE')) |
app.document_audit_log |
actor_type |
CHECK (actor_type IN ('CUSTOMER','STAFF','SYSTEM')) |
app.document_audit_log |
actor_justification (chk_staff_actor_has_justification) |
CHECK (actor_type <> 'STAFF' OR (justification IS NOT NULL AND length(trim(justification)) > 0)) |
app.document_audit_log |
actor_user_id (chk_human_actor_has_user_id) |
CHECK (actor_type = 'SYSTEM' OR actor_user_id IS NOT NULL) |
access.access_grants |
end_date |
CHECK (end_date IS NULL OR end_date >= start_date) |
app.product_config_proposals |
reviewed_by |
CHECK (reviewed_by IS NULL OR reviewed_by != proposed_by) — GOV-007 four-eyes |
app.product_config_proposals |
status |
CHECK (status IN ('pending','under_review','approved','rejected','live','superseded')) |
app.product_parameter_metadata |
advance_notice_days |
CHECK (advance_notice_days > 0) |
app.card_controls |
freeze_status |
CHECK (freeze_status IN ('ACTIVE','FROZEN')) |
app.card_controls |
channels_disabled |
Array element check — valid values: 'ONLINE','INTERNATIONAL','CONTACTLESS' |
app.card_control_audit |
control_type |
CHECK (control_type IN ('FREEZE','UNFREEZE','LIMIT_SET','CHANNEL_TOGGLE','VIRTUAL_CARD')) |
app.card_control_audit |
actor_type |
CHECK (actor_type IN ('CUSTOMER','SYSTEM','AGENT')) |
app.automation_rules |
rule_type |
CHECK (rule_type IN ('ROUND_UP','SCHEDULED_TRANSFER','SAVE_PERCENT','BILL_ALERT','MERCHANT_BLOCK','SPEND_LIMIT','IDLE_BALANCE_SWEEP','SAFETY_NET','FX_RATE_SWEEP')) |
app.automation_rule_executions |
trigger_source |
CHECK (trigger_source IN ('EVENT','SCHEDULE')) |
app.automation_rule_executions |
condition_result |
CHECK (condition_result IN ('PASSED','FAILED','NOT_APPLICABLE')) |
app.automation_rule_executions |
action_outcome |
CHECK (action_outcome IN ('EXECUTED','SKIPPED_INSUFFICIENT_FUNDS','SKIPPED_RULE_PAUSED','MOD_020_DECLINED','FAILED','NOTIFICATION_ONLY')) |
Not DB-enforced (Category 3 — cross-service or config-driven)¶
Session expiry enforcement, consent verification at product onboarding, and SLA deadline computation all depend on AppConfig, KYC state from SD02, or runtime policy. These live in their respective Lambda modules.
Card & account controls tables (MOD-078)¶
app.card_controls¶
Current freeze and channel-toggle state per card. Mutable — one row per card_id, upserted on each customer control action. This table is the authoritative read source for card authorization decisions (FR-557: ATM toggle status reads from MOD-078's controls API, which queries this table). Owned by MOD-078 V001.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) — no FK |
| card_id | uuid | NOT NULL UNIQUE | Soft FK to SD01 card-management system (not yet deployed) |
| freeze_status | text | NOT NULL DEFAULT 'ACTIVE' CHECK (freeze_status IN ('ACTIVE','FROZEN')) | Current freeze state |
| channels_disabled | text[] | NOT NULL DEFAULT '{}' | Array of disabled channel flags: 'ONLINE', 'INTERNATIONAL', 'CONTACTLESS' |
| per_transaction_limit | numeric(15,2) | NULL = no customer-set limit (MOD-021 platform default applies) | |
| daily_limit | numeric(15,2) | NULL = no customer-set limit (MOD-021 platform default applies) | |
| limit_currency | char(3) | CHECK (limit_currency IS NULL OR length(limit_currency) = 3) | ISO 4217 — required when limits are set |
| updated_at | timestamptz | NOT NULL DEFAULT now() | Last control change; updated on every upsert |
| updated_by_session_id | uuid | REFERENCES app.customer_sessions(id) | Session that made the last change |
Indexes:
- idx_card_controls_party_id on (party_id)
- idx_card_controls_card_id — unique index enforcing one row per card
Notes: channels_disabled array is the authoritative toggle state read by the ATM auth module (FR-557) and future card network auth handlers. Spending limits here are denormalised for display; the enforcing values live in payments.payment_limits (MOD-021) which MOD-078 updates via the admin API on each customer change. On freeze: freeze_status = 'FROZEN' is read by authorization handlers; simultaneously MOD-078 writes a limit_type = 'FREEZE' row to MOD-021 for the payments-channel enforcement path.
app.card_control_audit (Cat 1 immutable)¶
Append-only audit log of every card control action. One row per control event. Satisfies FR-376 and NFR-024 (zero mutations, 7-year retention). Immutable via trg_card_control_audit_immutable (BEFORE UPDATE OR DELETE OR TRUNCATE, calls fn_immutable_row()). Owned by MOD-078 V002.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | uuid | PK DEFAULT gen_random_uuid() | |
| party_id | uuid | NOT NULL | Cross-domain ref to SD02 party.parties(party_id) |
| card_id | uuid | NOT NULL | Soft FK to SD01 card-management (no Postgres FK) |
| control_type | text | NOT NULL CHECK (control_type IN ('FREEZE','UNFREEZE','LIMIT_SET','CHANNEL_TOGGLE','VIRTUAL_CARD')) | |
| prev_state | jsonb | NOT NULL | Snapshot of app.card_controls row before the change |
| new_state | jsonb | NOT NULL | Snapshot of app.card_controls row after the change |
| session_id | uuid | REFERENCES app.customer_sessions(id) | Initiating session — NULL only for SYSTEM actor |
| actor_type | text | NOT NULL CHECK (actor_type IN ('CUSTOMER','SYSTEM','AGENT')) | |
| trace_id | text | NOT NULL | AWS X-Ray trace ID for end-to-end correlation |
| created_at | timestamptz | NOT NULL DEFAULT now() | Immutable timestamp |
Indexes:
- idx_card_control_audit_party_id_created on (party_id, created_at DESC)
- idx_card_control_audit_card_id_created on (card_id, created_at DESC)
Notes: Append-only — no updated_at column. prev_state / new_state are JSONB snapshots of the app.card_controls row, ensuring the full before/after delta is captured even if the controls table schema changes in future. 7-year row-level retention per FR-376.
Cross-domain references¶
| This table | Column | References | Notes |
|---|---|---|---|
| app.customer_sessions | user_id | access.user_identities(user_id) |
Real Postgres FK within app database |
| app.consents | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.disclosures | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.cases | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.cases | related_account_id | SD01 accounts.accounts(id) |
Optional — case linked to an account |
| app.cases | related_payment_id | SD04 payments.payments(id) |
Optional — case linked to a payment |
| app.transaction_view | posting_id | SD01 accounts.postings(id) |
Soft reference — no FK; cross-DB projection |
| app.transaction_view | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.transaction_view | account_id | SD01 accounts.accounts(id) |
Cross-domain UUID convention |
| app.transaction_exports | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.account_summary | account_id | SD01 accounts.accounts(id) |
Soft reference — no FK; cross-DB projection |
| app.account_summary | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.dashboard_insight_card | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.automation_rules | source_account_id | SD01 accounts.accounts(id) |
Rules operate on accounts |
| app.automation_rules | destination_account_id | SD01 accounts.accounts(id) |
|
| app.automation_rule_executions | rule_id | app.automation_rules(id) |
Real FK — within same database |
| app.automation_rule_executions | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.automation_rule_executions | source_account_id | SD01 accounts.accounts(id) |
Soft ref — no FK (cross-domain) |
| app.automation_rule_executions | dest_account_id | SD01 accounts.accounts(id) |
Soft ref — no FK (cross-domain) |
| app.automation_rule_executions | mod_020_payment_id | SD04 payments.payments(id) |
Soft ref — no FK; populated when execution produced a real payment |
| app.work_queue_items | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| access.user_identities | person_party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention; must be a NATURAL_PERSON party |
| access.access_grants | scope_id | SD02 party.parties(party_id) (where scope_type=PARTY) |
Cross-domain UUID convention |
| access.access_grants | scope_id | SD01 accounts.accounts(id) (where scope_type=ACCOUNT) |
Cross-domain UUID convention |
| access.access_grants | scope_id | SD01 contexts.operating_contexts(context_id) (where scope_type=OPERATING_CONTEXT) |
Cross-domain UUID convention |
| app.payment_previews | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.payment_previews | payment_id | SD04 payments.payments(id) |
Soft ref — no FK; minted by MOD-071, row created by MOD-020 in same chain |
| app.payment_initiation_events | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.payment_initiation_events | payment_id | SD04 payments.payments(id) |
Soft ref — no FK |
| app.payees | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.scheduled_payments | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.card_controls | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.card_controls | card_id | SD01 card-management system (not yet deployed) | Soft reference — no FK; UUID convention |
| app.card_control_audit | party_id | SD02 party.parties(party_id) |
Cross-domain UUID convention |
| app.card_control_audit | card_id | SD01 card-management system (not yet deployed) | Soft reference — no FK; UUID convention |
Consumers of SD08 data: SD06 Snowflake receives app.consents, app.cases, and access.access_grants via CDC (MOD-042) for governance, consent audit, and access analytics. SD07 MOD-047 (agent action log) captures all staff access to SD08 back-office screens. The work_queue_items table drives back-office operational SLAs tracked in SD06 analytics. The access.* tables are the authoritative source for any audit of who had access to what and when.