Technical design — MOD-074 Back-office customer 360¶
Module: MOD-074 System: SD08 Customer App & Back Office Platform Repo: bank-app FR scope: FR-365, FR-366, FR-367, FR-368 NFR scope: NFR-005, NFR-009, NFR-013, NFR-024 Policies satisfied: GOV-002 (LOG), PRI-003 (GATE) Author: SD08 build agent (Claude) Date: 2026-05-19
Objective¶
Primary back-office workspace. One screen aggregates identity, KYC, account balances, transaction history, cases, documents, and risk / compliance flags for a single customer; loads within p99 ≤2 s; every field role-scoped; every view and every action audited via MOD-047.
MOD-074 owns no Postgres tables (l-1 ruling). It is a pure read-aggregator + action-proxy:
- Cross-schema SELECTs in the consolidated
bankDB (ADR-064) - SigV4-signed POST/PATCH to downstream BFFs for action paths
- PutEvents
staff.action_takenon bank-platform bus → MOD-047 captures the 7-year append-only audit (FR-367 + GOV-002 LOG)
Internal architecture¶
back-office JWT
→ API Gateway HTTP API (MOD-074 routes)
→ MOD-044 Lambda authoriser (scope check; route gating)
→ MOD-074 Lambda
→ withStaffSession (re-checks scope + role)
→ handler body
↪ aggregator parallel SELECTs (kyc + banking + app schemas)
↪ project-snapshot (field-masking matrix → response shape)
↪ publishStaffAction (bank-platform bus → MOD-047)
↪ delegate action (bank-core / bank-kyc / MOD-053 — SigV4)
→ response
Six Lambdas, one HTTP API, one CloudWatch log group, no Postgres migrations, no EventBridge consumer rules.
Key design decisions¶
Decision: no Postgres tables of MOD-074's own¶
The canonical audit lives in MOD-047's audit.agent_actions (7-year
retention, ADR-048 Cat 1 immutability). Adding a local
customer_360_access_log would duplicate that record and risk
divergence between the two. Pure event-publish to MOD-047, audited
once.
Decision: field-masking matrix lives in wiki + mirrored code¶
src/lib/field-masking.ts carries SECTION_ACCESS and
ACTION_ALLOWED constants that mirror the matrix in MOD-074.md
exactly. The policy test (tests/policy/pri-003-gate-static.test.ts)
asserts the wiki carries the four roles + four actions we encode.
Changes to either must touch both — the static test fails otherwise.
Decision: Hidden = field omitted, not masked¶
Per PRI-003, when a field's section is "Hidden" for the operator's role, the field is excluded from the JSON response — not returned with a placeholder. A client that has never seen the value cannot leak it. This is stricter than the FR-366 wording ("masking restricted fields with a clear indicator") — that wording applies to LastFour- masked fields (e.g. government_id viewed by customer-support shows "***6789" with a "masked" badge). Hidden sections do not render a panel at all.
Decision: action endpoints delegate to downstream BFFs¶
MOD-074 doesn't mutate any other domain's state directly. The four action endpoints (l-4) POST/PATCH to:
- bank-core MOD-007 — update account limits, change account state
- bank-kyc MOD-010 — CDD override (FR-449)
- MOD-053 (this repo) — add case note
All cross-domain calls are IAM/SigV4 with BankAppRole. Downstream BFFs' resource policies must list BankAppRole; if not, the first 503 in dev triggers a follow-up handoff (no different from MOD-070's MOD-002 pattern).
Decision: audit-then-respond for views; audit-regardless for actions¶
View handlers (get-customer-360, search-customer) AWAIT the
staff.action_taken publish and return 503 DOWNSTREAM_UNAVAILABLE
if it fails — an unaudited 200 response would violate GOV-002 LOG.
Action handlers (update-account-limits etc.) emit the audit
regardless of the downstream call's success/failure — so attempted-
but-failed actions are also traceable. The publish uses .catch and
only logs the error; the response surface depends on the action's
own success, not the audit publish.
Decision: customer search via published view (l-6)¶
kyc.party_search_view in the consolidated DB carries the
denormalised search shape (name, email, phone, primary_account_number,
cdd_tier, kyc_status). Direct SQL — no inter-service hop. The
GRANT to app_readonly is part of the cross-schema grants handoff
(issue #32; blocks deployment).
External dependencies¶
- Database: consolidated
bankNeon DB (ADR-064). - READ-ONLY: cross-schema (kyc.party_search_view,
kyc.cdd_tier_assignments, kyc.party_regulatory_profiles,
banking.customer_relationships, banking.customer_contact_readable)
via
app_readonlyGRANTs — pending issue #32. - READ: own-schema (app.transaction_view, app.cases,
app.document_metadata) — already granted as part of
app_app_userbaseline. - EventBridge: publishes
staff.action_takenon bank-platform bus. Cross-bus grant already exists (MOD-053 precedent). - MOD-044: JWT authoriser at API Gateway gate-keeps every route.
- MOD-047: consumes
staff.action_taken→ writes 7-year audit. - MOD-053, bank-core MOD-007, bank-kyc MOD-010: downstream BFFs for action paths. SigV4 with BankAppRole.
SSM outputs table¶
| Output | SSM path | Consumer |
|---|---|---|
| Customer 360 API base URL | /bank/{env}/mod074/customer-360-api/url |
MOD-069 app shell; back-office routing |
| Customer 360 API ID | /bank/{env}/mod074/customer-360-api/id |
MOD-044 authoriser attachment |
| get-customer-360 Lambda ARN | /bank/{env}/mod074/get-customer-360/fn-arn |
Operational metrics |
| search-customer Lambda ARN | /bank/{env}/mod074/search-customer/fn-arn |
Operational metrics |
| update-account-limits Lambda ARN | /bank/{env}/mod074/update-account-limits/fn-arn |
Operational metrics |
| change-account-state Lambda ARN | /bank/{env}/mod074/change-account-state/fn-arn |
Operational metrics |
| override-cdd-decision Lambda ARN | /bank/{env}/mod074/override-cdd-decision/fn-arn |
Operational metrics |
| add-case-note Lambda ARN | /bank/{env}/mod074/add-case-note/fn-arn |
Operational metrics |
| Customer 360 access log group ARN | /bank/{env}/mod074/customer-360-access-log/group-arn |
MOD-076 SIEM subscription |
| Customer 360 access log group name | /bank/{env}/mod074/customer-360-access-log/group-name |
Same |
SSM consumed¶
| Path | Origin |
|---|---|
/bank/{env}/iam/lambda/bank-app/arn |
MOD-104 |
/bank/{env}/observability/adot-layer-arn |
MOD-076 |
/bank/{env}/eventbridge/bank-platform/arn |
MOD-104 |
/bank/{env}/kms/operational/arn |
MOD-104 |
/bank/{env}/mod053/case-api/url |
MOD-053 |
/bank/{env}/mod-007/account-api/base-url |
bank-core MOD-007 |
/bank/{env}/kyc/cdd/override-api-endpoint |
bank-kyc MOD-010 |
Performance approach¶
Parallel fan-out via Promise.all over six independent SELECTs. With
NFR-013 ≤5 ms per query and the longest path serially dominated by
the kyc joins, end-to-end response stays well under FR-365's 2 s p99
budget. Each Lambda has 512 MB and the ADOT layer for X-Ray traces.
Partial failures (one section's SELECT errors but the rest succeed)
are tolerated — the failing section is added to
partial_failures[] in the response, the UI flags it, and the rest
of the snapshot renders. NEVER let one slow source block the whole
view.
Error handling¶
- withStaffSession: 401 UNAUTHORIZED when claims missing; 403 ROLE_NOT_PERMITTED when no back-office group; 403 SCOPE_DENIED when required scope absent.
- Aggregator: per-leg failures captured as
partial_failures; catastrophic DB outage → 503 DB_TIMEOUT from the pool itself. - Action handlers: 403 ACTION_NOT_ALLOWED when role doesn't permit the action (defence in depth — MOD-044 authoriser should have blocked at the gateway); 503 DOWNSTREAM_UNAVAILABLE when the downstream BFF (bank-core / bank-kyc / MOD-053) refuses.
- Audit publish: GET handlers hard-fail (503) on audit publish failure; action handlers log+continue (audit is best-effort on the downstream-already-mutated path).
Event types in structured logs¶
Registered in src/lib/logger.ts:
customer_360_view_served— successful 360 fetchcustomer_search_served— successful searchaction_attempted/action_succeeded/action_failedaggregator_partial_failure— one or more SELECTs faileddownstream_call_failed— bank-core / bank-kyc / MOD-053 5xxstaff_action_event_emit_failed— MOD-047 publish failedrbac_denied/scope_denied/session_invalidvalidation_failed/internal_errortrace_id_missing_from_upstream— observability standard WARN
Test approach¶
| Tier | Location | Count |
|---|---|---|
| Unit | tests/unit/ |
trace, logger, errors, roles, field-masking, scope-gate, project-snapshot — 7 suites |
| Contract | tests/contract/ |
staff.action_taken publisher contract |
| Policy | tests/policy/ |
PRI-003 GATE structural + GOV-002 LOG handler scan |
| FR integration | tests/integration/fr/ |
FR-365 aggregation, FR-366 read shape, FR-367 audit shape, FR-368 stub |
| Post-deploy smoke | tests/verify-deployment.mjs |
Auth-gate 401/403 check |
PRI-003 GATE structural test asserts the four back-office roles in
roles.ts match the four columns of the wiki matrix and that
withStaffSession invokes checkScope before any handler body.
GOV-002 LOG structural test asserts every handler imports
publishStaffAction, the publish carries the required MOD-047 fields
(actor_kind, staff_id, action_type, duration_ms,
occurred_at, etc.), and view handlers return 503 if the audit
publish fails.
v2 follow-ups¶
- Wire MOD-083 AI summary when MOD-083 deploys; replace stub with real fetch (lib/ai-summary-stub.ts becomes a real client).
- Add Snowflake risk score panel via MOD-039 if SSM-published.
- Move
field-masking.tsconstants into MOD-052's shared library when MOD-074's pattern is being reused by multiple back-office modules. - Add a customer 360 ACL view (
app.customer_360_access_log) for fast "who viewed customer X in last 30d" queries — supplements MOD-047 which currently requires a query against the full 7-year audit table.