Skip to content

Technical design — MOD-072 Customer profile & settings

Module: MOD-072 — Customer profile & settings System: SD08 — Customer App & Back Office Platform Repo: bank-app Module type: Hybrid (BFF Lambda + IaC + UI) FR scope: FR-357, FR-358, FR-359, FR-360 NFR scope: NFR-005, NFR-009, NFR-024 Policies satisfied: PRI-001 (AUTO), CON-001 (AUTO) Author: AI agent (Claude Opus 4.7) Date: 2026-05-14 Dependencies (all Deployed): MOD-068, MOD-104, MOD-103, MOD-052 (back-office mode); event-driven coupling to MOD-009/MOD-010 in bank-kyc


Objective

Self-service surface for the non-transactional aspects of the customer's relationship with the bank: contact details (email, phone, address), notification channel preferences per event type, language, app theme, card design, marketing opt-ins, and linked external bank accounts.

Updates to regulated identity fields (email/phone/residential address, linked external accounts) are gated by re-authentication via MOD-068 step-up (FR-358). Every change is recorded in an immutable audit log retained for 7 years (FR-360, ADR-048 Cat 1, NFR-024 = 0). A single bank.app.profile_updated event is published per commit so downstream consumers stay in sync within 60 s (FR-359).

The module satisfies PRI-001 (access + correction rights under NZ Privacy Act and AU Privacy Act 1988) and CON-001 (no silent updates — every change goes through an explicit draft → preview → commit step).

NFR-009 (zero divergence between CUSTOMER and BACK_OFFICE modes) is enforced by single-source mode-aware components in ui/. Back-office agents (compliance / senior roles) can edit with a mandatory actor_justification recorded in the audit log; customer-facing and operations roles are read-only.


Architecture

Customer app / Back-office console
        ├─ GET    /profile                          ─► getProfile          ─► profile-service + party-read-client (MOD-009)
        ├─ POST   /profile/draft                    ─► draftProfileUpdate  ─► HMAC draft_token (10-min TTL)
        ├─ POST   /profile/commit                   ─► commitProfileUpdate ─► step-up gate → audit + UPSERT → publish event
        ├─ GET    /notification-prefs               ─► getNotificationPrefs
        ├─ PUT    /notification-prefs               ─► updateNotificationPrefs
        ├─ GET    /linked-accounts                  ─► listLinkedAccounts
        ├─ POST   /linked-accounts                  ─► linkExternalAccount   (step-up + event)
        ├─ DELETE /linked-accounts/{id}             ─► unlinkExternalAccount (step-up + event)
        ├─ PUT    /appearance                       ─► updateAppearance
        └─ PUT    /language                         ─► updateLanguage

MOD-063 (Notification orchestration, bank-platform)
        └─ GET /internal/notification-prefs/{customer_id}  ─► getNotificationPrefsById (IAM-authed)

MOD-068 (intra-domain)
        ├─ validate-session   — invoked on every handler
        ├─ step-up            — invoked before any regulated commit
        └─ (revoke-session)   — not invoked here; logout is owned by MOD-069 app shell

MOD-009 (bank-kyc, via MOD-075)
        └─ GET /internal/v1/customer/{party_id}/contact-details
             — Read-only call from getProfile for regulated identity fields.
               Returns null + contact_fields_pending: true if not yet wired
               (graceful degradation). docs/handoffs/MOD-009-contact-read-endpoint.

bank-app EventBridge bus
        └─ bank.app.profile_updated  (published once per commit)
             ├─► bank-kyc            (re-verification when re_verification_required=true)
             ├─► bank-app MOD-063    (notification routing refresh)
             └─► statement modules   (header re-render on contact change)

Data plane

Tables added to app schema (V001–V005, bank_app Neon database)

Table Cat FKs (intra-DB only) Purpose
app.card_designs mutable seed Reference palette for CAP-056. Seeded 3–5 rows in V001
app.customer_profile_preferences mutable card_design_id → app.card_designs(id) Per-customer SD08-owned settings (language, theme, accent, marketing opt-ins, card design)
app.notification_channel_preferences mutable (party_id, notification_type) → channel choice. DB CHECK forbids opt-out on SECURITY_ALERT / FRAUD_ALERT
app.linked_external_accounts mutable External bank accounts (CAP-116). is_verified_for_payment constrained to require verification_status='VERIFIED'
app.profile_audit_log ADR-048 Cat 1 immutable actor_user_id → access.user_identities(user_id), session_id → app.customer_sessions(id) Append-only audit row per change. Stores SHA-256 hashes, never raw values. 7-year retention

Cross-domain anchor (no Postgres FK): all customer-scoped rows use party_id uuid NOT NULL as a soft FK to SD02 party.parties(party_id). This is the established SD08 pattern (matches app.consents, app.disclosures, app.cases, app.automation_rules, etc.) — verified against the live migrations in MOD-049/050/068. kyc.customers does not exist in the live SD02 schema (replaced by the party model); regulated identity fields live in banking.customer_relationships (email/phone) and party.addresses via party.person_profiles.residential_address_id (address).

Migration order

V001  app.card_designs (+ seed)
V002  app.customer_profile_preferences
V003  app.notification_channel_preferences
V004  app.linked_external_accounts
V005  app.profile_audit_log  (+ trg_profile_audit_log_immutable)

DB-enforced invariants (ADR-048)

Table Invariant Enforcement
app.profile_audit_log append-only trg_profile_audit_log_immutable BEFORE UPDATE OR DELETE OR TRUNCATE (Cat 1)
app.profile_audit_log.previous_value_sha256 64-char lowercase hex CHECK ^[0-9a-f]{64}$
app.profile_audit_log.new_value_sha256 64-char lowercase hex CHECK ^[0-9a-f]{64}$
app.profile_audit_log STAFF changes require justification (≥ 10 chars) chk_staff_changes_have_justification CHECK
app.linked_external_accounts UNVERIFIED cannot be is_verified_for_payment chk_payment_requires_verified CHECK
app.linked_external_accounts.account_number_last4 4 digits CHECK ^[0-9]{4}$
app.notification_channel_preferences SECURITY_ALERT / FRAUD_ALERT must have ≥ 1 channel chk_security_fraud_no_opt_out CHECK
app.notification_channel_preferences.channels subset of chk_valid_channels CHECK

Tables read (not written)

Table Purpose
app.customer_sessions Resolve session_id → user_id for audit FK
access.user_identities Resolve user_id → person_party_id cross-domain anchor

SSM outputs (consumer contract)

Path convention: /bank/{env}/mod072/{name}.

SSM path Value Consumed by
/bank/{env}/mod072/profile-api/url HTTP API base URL MOD-069 app shell
/bank/{env}/mod072/profile-api/id API ID Diagnostics, MOD-076
/bank/{env}/mod072/get-profile/fn-arn Lambda ARN MOD-074 back-office 360
/bank/{env}/mod072/draft-profile-update/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/commit-profile-update/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/get-notification-prefs/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/update-notification-prefs/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/notification-prefs/fn-arn Lambda ARN MOD-063 in bank-platform (preference cutover)
/bank/{env}/mod072/list-linked-accounts/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/link-external-account/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/unlink-external-account/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/update-appearance/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/update-language/fn-arn Lambda ARN Diagnostics
/bank/{env}/mod072/profile-events-log/group-arn CW log group ARN MOD-076
/bank/{env}/mod072/profile-events-log/group-name CW log group name Lambda env injection

Schema ARNs are intentionally not published — schemas are referenced by name at publish time, not via SSM.

Upstream SSM paths consumed

Path Owner Used for
/bank/{env}/iam/lambda/bank-app/arn MOD-104 BankAppRole — every Lambda's execution role
/bank/{env}/observability/adot-layer-arn MOD-076 ADOT layer
/bank/{env}/eventbridge/bank-app/arn MOD-104 Bus name (publish profile_updated)
/bank/{env}/kms/operational/arn MOD-104 Log group KMS encryption
/bank/{env}/neon/pooler-host MOD-103 Postgres host
/bank/{env}/mod068/validate-session/fn-arn MOD-068 Session gate
/bank/{env}/mod068/step-up/fn-arn MOD-068 FR-358 regulated-field gate
/bank/{env}/mod009/contact-read/fn-arn MOD-009 (bank-kyc) Pending — graceful-degraded read

Secrets (Secrets Manager): bank-neon/{env}/bank_app/app_user.


EventBridge events

Published on bank-app bus

Event DetailType Notes
bank.app.profile_updated profile_updated Schema in schemas/bank.app.profile_updated.json. Carries party_id, fields_changed[] with SHA-256 hashes, re_verification_required, actor_type, actor_user_id, session_id, idempotency_key, trace_id, effective_at. Wiki amendment in MOD-072 complete handoff adds it to event-catalogue.md.

Cross-bus IAM grants required

bank-kyc subscribes to bank.app.profile_updated when re_verification_required = true. Requires events:PutRule / events:PutTargets on the bank-app bus ARN granted to bank-kyc's role — filed via docs/handoffs/MOD-104-profile-updated-cross-bus-grant.handoff.md.


Handler contracts

GET /profile

Header Authorization: Bearer {session_token} required.

Response (200):

{
  "party_id": "...",
  "preferences": { "language": "en-NZ", "theme": "SYSTEM", ... },
  "notification_prefs": [{ "notification_type": "...", "channels": [...] }, ...],
  "linked_accounts": [{ "id": "...", "verification_status": "VERIFIED", "is_verified_for_payment": true, ... }, ...],
  "contact": {
    "email": "..." | null,
    "phone": "..." | null,
    "residential_address": { "line1": "...", ... } | null,
    "contact_fields_pending": false   // true if MOD-009 endpoint not yet wired
  }
}

POST /profile/draft (CON-001)

Body: { fields: [{ field_path, new_value }] }.

Response (200):

{
  "draft_token": "hmac.timestamp",
  "expires_in_seconds": 600,
  "fields": [...],
  "regulated_fields": ["contact.email"],
  "step_up_required": true
}

POST /profile/commit

Body: { draft_token, step_up_token?, fields: [...], idempotency_key? }.

Validates draft_token (CON-001), then verifies step_up_token if any field is regulated (FR-358). Applies non-regulated UPSERTs + audit row in a transaction; for regulated fields, writes audit row only (SD02 applies via event). Emits one bank.app.profile_updated event.

Error responses follow the standard envelope: | HTTP | error_code | When | |---|---|---| | 401 | STEP_UP_REQUIRED / STEP_UP_FAILED | FR-358 gate | | 401 | SESSION_* | session invalid | | 422 | INVALID_DRAFT_TOKEN / DRAFT_TOKEN_EXPIRED | CON-001 verification | | 503 | PARTY_READ_PENDING | MOD-009 endpoint not wired (only used by /profile) |

GET /internal/notification-prefs/{customer_id} — IAM-authed

For MOD-063 in bank-platform. SigV4-authed, no session token. Returns the same notification_prefs payload as the customer-facing endpoint for a specified party.


Policy satisfaction

Policy Mode Mechanism Tests
PRI-001 AUTO (1) GET /profile reads every category in the schema (preferences, notification prefs, linked accounts, contact). (2) Every editable field has a corresponding write path that touches the audit log via insertAuditRow or commitPreferenceUpdate. (3) Source-level scan rejects bypass tokens. tests/policy/pri-001-auto-static.test.ts — source scan + behavioural
CON-001 AUTO commit-profile-update is the only path that applies changes, and it gate-checks verifyDraft(...) first. Draft tokens are HMAC-signed with 10-min TTL and constant-time compared. tests/policy/con-001-auto-static.test.ts

NFR compliance

NFR Threshold How met
NFR-005 ≥ 85% self-service resolution MOD-072 surfaces every profile concern as a customer-actionable endpoint; one of several contributing modules. No module-level test expected.
NFR-009 = 0 component divergence ui/ exports single mode-aware components; CUSTOMER vs BACK_OFFICE branching is prop-driven, no forked codepaths. Verified in policy/source scan.
NFR-024 = 0 audit log mutations trg_profile_audit_log_immutable rejects UPDATE/DELETE/TRUNCATE unconditionally. FR-360 integration test covers this.

Observability

Per ADR-031. Mandatory log fields on every event: trace_id, correlation_id, module_id=MOD-072, jurisdiction, event_type, party_id, level, timestamp. Dedicated audit log group /aws/bank-app/profile-events-{env} is a secondary SIEM feed; Postgres app.profile_audit_log is the durable compliance record.


Cross-module handoffs filed

  1. docs/handoffs/MOD-072-complete.handoff.md — wiki amendments (5 tables → SD08-app.md, bank.app.profile_updated → event-catalogue, remove contract: api from MOD-072.yaml dep on MOD-010).
  2. docs/handoffs/MOD-104-profile-updated-cross-bus-grant.handoff.md — request events:PutRule/events:PutTargets on bank-app bus for bank-kyc role.
  3. docs/handoffs/MOD-063-preference-cutover.handoff.md — bank-platform action to switch MOD-063 from local preferences table to MOD-072's IAM-authed GET /notification-prefs/{customer_id}.
  4. docs/handoffs/MOD-009-contact-read-endpoint.handoff.md — request bank-kyc expose GET /internal/v1/customer/{party_id}/contact-details.

Deployment

GitLab CI .gitlab/ci/mod-072.gitlab-ci.yml extends .lambda-mr and .lambda-deploy from bank-platform/.gitlab/ci/templates/lambda.gitlab-ci.yml. HAS_POSTGRES: "true" triggers flyway validate (pre-deploy) and flyway migrate (post-deploy) against the bank_app Neon database.

Contract package @bank-app/mod-072-contracts@1.0.0 is built and published to the GitLab Package Registry by the appended mod-072-publish-contracts job whenever contract/** changes (ADR-063).