Skip to content

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 bank DB (ADR-064)
  • SigV4-signed POST/PATCH to downstream BFFs for action paths
  • PutEvents staff.action_taken on 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 bank Neon 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_readonly GRANTs — pending issue #32.
  • READ: own-schema (app.transaction_view, app.cases, app.document_metadata) — already granted as part of app_app_user baseline.
  • EventBridge: publishes staff.action_taken on 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 fetch
  • customer_search_served — successful search
  • action_attempted / action_succeeded / action_failed
  • aggregator_partial_failure — one or more SELECTs failed
  • downstream_call_failed — bank-core / bank-kyc / MOD-053 5xx
  • staff_action_event_emit_failed — MOD-047 publish failed
  • rbac_denied / scope_denied / session_invalid
  • validation_failed / internal_error
  • trace_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.ts constants 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.