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¶
docs/handoffs/MOD-072-complete.handoff.md— wiki amendments (5 tables → SD08-app.md,bank.app.profile_updated→ event-catalogue, removecontract: apifrom MOD-072.yaml dep on MOD-010).docs/handoffs/MOD-104-profile-updated-cross-bus-grant.handoff.md— requestevents:PutRule/events:PutTargetson bank-app bus for bank-kyc role.docs/handoffs/MOD-063-preference-cutover.handoff.md— bank-platform action to switch MOD-063 from local preferences table to MOD-072's IAM-authedGET /notification-prefs/{customer_id}.docs/handoffs/MOD-009-contact-read-endpoint.handoff.md— request bank-kyc exposeGET /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).