Technical design — MOD-024 Device & session intelligence¶
Module: MOD-024 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-137, FR-138, FR-139, FR-140 NFR scope: NFR-021, NFR-023, NFR-024 Policies satisfied: DT-001 (GATE), PAY-005 (ALERT), AML-005 (LOG) Author: AI coding agent (Claude) Date: 2026-05-07
Objective¶
MOD-024 is the fraud / AML-grade device & session intelligence
store. It exists deliberately separate from MOD-068's auth-grade
access.device_registry (which is the trust-for-login record). The
two modules answer different questions:
| Module | DB | Table | Question answered |
|---|---|---|---|
| MOD-068 | bank_app |
access.device_registry |
Can this device establish an authenticated session? |
| MOD-024 | bank_payments |
payments.device_intelligence |
Should this device be trusted for payment initiation? |
Both stores key on the same device_fingerprint_hash (SHA-256), so
cross-correlation is possible without a cross-DB join. The two trust
signals can diverge: a device can be auth-trusted (MOD-068 happy)
while simultaneously raising fraud signals in MOD-024. Downstream
payment gating (MOD-020 when built) reads MOD-024's check-device API,
not MOD-068's registry.
Architecture¶
App ──── /auth/session ───────────────► MOD-068 (issue session)
│ │ writes access.device_registry
│ │ emits bank.app.session_created
│
│── (sequential, after session) ──────► MOD-024 /devices/observe
│ │ validates session via MOD-068
│ │ upserts payments.device_intelligence
│ │ inserts payments.session_observations
│ │ runs anomaly detection
│ │ emits bank.payments.device_anomaly_detected
EventBridge bank-app (cross-bus) ─ session_created ─► Mod024SessionConsumerHandler
(first-touch NEW_DEVICE check)
EventBridge bank-payments ─ fraud_alert_raised ─► Mod024FraudAlertConsumerHandler
(MOD-023 future) (sets device.flagged_as_fraudulent)
API Gateway HTTP API
POST /internal/v1/devices/observe ─► Mod024ObserveDeviceHandler (FR-137 sync from app)
POST /internal/v1/devices/check ─► Mod024CheckDeviceHandler (PAY-002 / NFR-021 hot path; MOD-020 caller)
GET /internal/v1/sessions/{id} ─► Mod024SessionsQueryHandler (FR-140 read API for MOD-022)
EventBridge bank-payments (publish)
device_anomaly_detected ◀─ observe-device, check-device, session-consumer
Five Lambdas; one HTTP API; two EventBridge consumer rules (cross-bus on bank-app + same-bus on bank-payments); six CloudWatch alarms; one dashboard. No scheduled jobs.
Tables¶
| Table | Migration | Mutability | Purpose |
|---|---|---|---|
payments.device_intelligence |
V001 | MUTABLE | Per-device fraud/AML profile. Counters, last_seen_at, sticky boolean signals, trust_score, flagged_as_fraudulent are all updated by application code. |
payments.session_observations |
V002 | APPEND-ONLY (V004 trigger) | Per-session fingerprint snapshot. FR-140 7-year retention. |
payments.device_anomalies |
V003 | APPEND-ONLY (V004 trigger) | Per-anomaly record. AML-005 LOG surface. |
payments.idempotency_keys |
(MOD-021 V005) | n/a | Shared SD04 idempotency store. We use module_id='MOD-024'. |
The mutable / append-only split is intentional: the audit trail of what changed and when lives in the immutable companion tables; the current aggregate state lives in the mutable one.
payments.device_intelligence — fields of interest¶
device_fingerprint_hash— canonical key, shared with MOD-068.trust_score∈ [0.00, 1.00] — MOD-024's fraud-trust assessment. Independent of MOD-068'sdevice_status/trust_level. Decreases on each anomaly.flagged_as_fraudulent— sticky flag set by thefraud-alert-consumerwhen MOD-023 emits a fraud_alert. Once set, every check-device for the device returnsaction_recommended=BLOCK.is_emulator,is_rooted,is_jailbroken— sticky boolean signals (OR'd on each observation; once true, stay true).
Dev / UAT seed (V900)¶
| Fingerprint hash | trust_score | flagged_as_fraudulent | Purpose |
|---|---|---|---|
aaaa…aa |
1.00 | false | Known-good device for happy-path tests |
ffff…ff |
0.10 | true | Flagged device for FR-138 BLOCK assertion |
Lambdas¶
| Function | Trigger | Purpose |
|---|---|---|
Mod024ObserveDeviceHandler |
HTTP POST /internal/v1/devices/observe |
FR-137 — capture rich fingerprint per-session. Sequential after MOD-068 session establishment. |
Mod024CheckDeviceHandler |
HTTP POST /internal/v1/devices/check |
FR-138/139 — payment hot path; MOD-020 caller. |
Mod024SessionsQueryHandler |
HTTP GET /internal/v1/sessions/{session_id} |
FR-140 — read API for MOD-022 (when built). |
Mod024SessionConsumerHandler |
EventBridge bank.app.session_created (cross-bus) |
First-touch new-device check on every authenticated session. |
Mod024FraudAlertConsumerHandler |
EventBridge bank.payments.fraud_alert_raised |
Sets flagged_as_fraudulent=true on the device when MOD-023 (future) emits a fraud alert. |
Memory: 512 MB. Timeouts: 10s hot-path, 30s consumer. Reserved concurrency tiered (prod / uat / dev).
EventBridge¶
Consumes
| Event | Source bus | Notes |
|---|---|---|
bank.app.session_created |
bank-app (cross-bus) | MOD-068 publisher. Cross-bus rule needs MOD-104 grant. |
bank.payments.fraud_alert_raised |
bank-payments (same-bus) | MOD-023 future publisher. Rule binds; no events flow until MOD-023 ships. |
Publishes
| Event | Notes |
|---|---|
bank.payments.device_anomaly_detected |
NEW — pending wiki catalogue add. Includes payment_id: uuid \| null (populated when triggered from check-device, null from observe path). |
Sequential observe flow (FR-137)¶
The observe API requires a valid MOD-068 session token. The flow is sequential, not parallel:
1. App authenticates to MOD-068 (POST /auth/session) → receives session_token
2. App calls MOD-024 (POST /internal/v1/devices/observe) with Bearer session_token + rich fingerprint
3. MOD-024 invokes MOD-068 validate-session via SDK Lambda invoke
4. On 200, MOD-024 stores observation + runs anomaly detection
5. Anomaly event(s) emitted on bank-payments bus
Step 3 is the gate: the observe handler will not persist a row without a valid MOD-068 session. This is the boundary between auth identity and fraud signal.
SSM contract¶
Reads¶
| Path | Owner |
|---|---|
/bank/{stage}/neon/pooler-host, /bank/{stage}/neon/direct-host |
MOD-103 |
/bank/{stage}/eventbridge/bank-payments/arn |
MOD-104 |
/bank/{stage}/eventbridge/bank-app/arn |
MOD-104 |
/bank/{stage}/iam/lambda/bank-payments/arn |
MOD-104 |
/bank/{stage}/observability/adot-nodejs-layer-arn |
MOD-076 |
/bank/{stage}/sns/alerts/arn |
MOD-104 |
/bank/{stage}/mod068/validate-session/fn-arn |
MOD-068 |
Writes¶
| Path | Value |
|---|---|
/bank/{stage}/mod-024/api/base-url |
API Gateway base URL |
/bank/{stage}/mod-024/observe-device/url |
Full /internal/v1/devices/observe URL |
/bank/{stage}/mod-024/check-device/url |
Full /internal/v1/devices/check URL |
/bank/{stage}/mod-024/sessions-query/url |
Full /internal/v1/sessions URL prefix |
/bank/{stage}/mod-024/{observe-device,check-device,sessions-query,session-consumer,fraud-alert-consumer}-lambda/arn |
Lambda ARNs |
/bank/{stage}/mod-024/device-intelligence-table |
payments.device_intelligence |
/bank/{stage}/mod-024/session-observations-table |
payments.session_observations |
/bank/{stage}/mod-024/device-anomalies-table |
payments.device_anomalies |
Configuration¶
| Env | Default | Purpose |
|---|---|---|
IMPOSSIBLE_TRAVEL_KM |
500 |
FR-139 distance threshold |
IMPOSSIBLE_TRAVEL_WINDOW_MINUTES |
30 |
FR-139 time window |
MOD068_VALIDATE_SESSION_LAMBDA_ARN |
(from SSM) | observe-device session validator |
LOG_LEVEL |
info (prod) / debug (non-prod) |
Log gate |
Policy mapping¶
| Policy | Mode | How satisfied | Test |
|---|---|---|---|
| DT-001 | GATE | Detection runs unconditionally on every observe / check. Source contains no skip / override / SOC-bypass tokens. KNOWN_FRAUD_DEVICE always emits with action=BLOCK. | tests/policy/dt-001-gate.test.ts (unit + token scan) + tests/integration/fr-138-check-device.test.ts (live) |
| PAY-005 | ALERT | Every FR-138/139 condition emits a device_anomaly_detected event with severity + action_recommended; downstream consumers (MOD-068 step-up, MOD-018 case mgmt, MOD-022 audit) act on it. |
tests/policy/pay-005-alert.test.ts (signal coverage + schema) |
| AML-005 | LOG | payments.session_observations and payments.device_anomalies are append-only via V004 triggers. INSERT-only role grants. Application code emits no UPDATE/DELETE. |
tests/policy/aml-005-log.test.ts (structural) + tests/integration/nfr-024-audit-immutability.test.ts (runtime) |
Performance approach¶
- NFR-021 ≤ 200 ms p99 on the check-device hot path: a single
findByFingerprintSELECT + optionalcustomerHasUsedDeviceBeforeEXISTS, plus optional INSERT on anomaly.withConnectionfor the read-mostly path;withTransactiononly when an anomaly is being recorded. Alarm trips at p99 ≥ 200 ms. - NFR-023 MTTD ≤ 5 min on anomalous access: CloudWatch alarm
(
bank-{stage}-MOD-024-critical-anomaly) trips on the first CRITICAL anomaly metric in any 1-minute window.
Error handling¶
- Sync HTTP paths — standard error envelope (HTTP 422 / 502 / 503 / 500).
- Observe-device — 401-class errors when session validation fails
(mapped from MOD-068 to
SESSION_NOT_VALIDATED422). - EventBridge consumer — re-raise on transient failures so EB retries; bank-payments / bank-app DLQ catches after retry exhaustion.
Event types in structured logs¶
device_observed, device_observe_failed, device_check_passed,
device_check_failed, anomaly_detected, anomaly_publish_failed,
device_flagged_fraudulent, session_consumed,
session_consume_failed, fraud_alert_consumed,
sessions_query_served, session_validated,
session_validation_failed, idempotency_replay,
trace_id_missing_from_upstream, validation_failed,
internal_error.
Test approach¶
| Tier | Files | Cases |
|---|---|---|
| Unit (≥80% gate) | tests/unit/{errors,trace,logger,emf,fingerprint-hash,impossible-travel,anomaly-detector}.test.ts |
35 |
| Contract | tests/contract/{device-anomaly-detected-schema,api-contract}.test.ts |
17 |
| Policy satisfaction | tests/policy/{dt-001-gate,pay-005-alert,aml-005-log}.test.ts |
12 |
| Integration | tests/integration/{fr-138-check-device,fr-139-anomaly-types,fr-140-sessions-query,nfr-024-audit-immutability,idempotency,observability-fields}.test.ts |
one per FR + idempotency + observability + NFR-024 |
| Smoke | tests/verify-deployment.mjs |
check-device against V900 flagged device |
Security and data handling¶
- No customer PII flows through MOD-024 except
customer_id(uuid reference). The rich fingerprint (OS / app / screen / network) is device data, not PII. - Raw
ip_addressis not stored — onlyip_region(e.g.NZ-AKL). Geolocation is rounded to 1dp (~11 km cell) at ingest. - The two audit tables have INSERT-only role grants + trigger-level append-only enforcement.
Open items / handoff follow-ups¶
-
bank.payments.device_anomaly_detected— wiki catalogue add. Schema bundled in this module'sschemas/. Consumers: MOD-022 (audit trail), MOD-018 (case mgmt — future), MOD-068 (step-up — future). Add tobank-wiki/source/pages/design/system/event-catalogue.md. -
SD04 data model — three new tables. Add to
bank-wiki/source/pages/design/system/data-models/SD04-payments.md: payments.device_intelligence(mutable)payments.session_observations(immutable per ADR-048 Cat 1)-
payments.device_anomalies(immutable per ADR-048 Cat 1) -
Event-catalogue clarification. The existing
bank.app.session_createdentry saysdevice_fingerprint_id: uuid✓ "Reference to MOD-024 device record". As-built, that field references MOD-068'saccess.device_registry.id, not MOD-024's. Update the catalogue note. -
Cross-bus IAM grant. BankPaymentsRole needs
events:PutRule+events:PutTargetson the bank-app bus. Filed indocs/handoffs/MOD-104-bank-app-cross-bus-grant.handoff.md. Same pattern as MOD-082's bank-core grant. Per the established contract pattern: SST resource step fails until grant lands; build is not gated on the grant. -
bank.payments.fraud_alert_raisedconsumer — MOD-023 isn't built yet. The rule binds, no events flow. The fraud-alert-consumer Lambda is wired; once MOD-023 ships, devices flagged by fraud alerts automatically propagate to MOD-024. -
MOD-068 session-token forwarding from MOD-024 events — when MOD-068 ships an enriched
session_createdevent with the rich fingerprint inline, the session-consumer Lambda can fold the same anomaly logic the observe API runs. v2 — until then the observe path is the canonical fingerprint-capture surface.