Skip to main content

D102 — Fraud / device-intelligence go-live — Fingerprint primary + SEON permitted (all six), DeviceRiskGuard postures, deploy-gated (RESOLVED)

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Program context + the fraud/device-login build plan live in docs/tracking/multi-provider/11-fraud-device-login.md.

Device intelligence goes live, deploy-gated. After the whole fraud path shipped registered-not-enforcing (phase 1 the rails AssessesFraudRisk seam + the Foundation DeviceRiskGuard/EvaluatesDeviceRisk bus markers; phase 2 the sealed Fingerprint + SEON adapters; phase 3 the fraud module — the central device_signals + tenant login_events stores, the CQRS write path, the DeviceRiskEvaluated event, DLP), phase 4 flips it ON behind two independent gates: the enforcing BusDeviceRiskGuard binds, the auth + payout commands carry the device-risk marker, and Fingerprint becomes the Fraud PRIMARY across all six conduit-baseline jurisdictions (ZA/NG/KE/GB/EU/US) with SEON permitted as a secondary. This is the third worked instance of the D95 one-liner (after D98 Utila custody) — a fleet-wide primary grant for a single capability group — but for a 4th off-umbrella segregated capability (AssessesFraudRisk; not one of the 13 RailsProvider umbrella capabilities).

The grant is one declaration. In FeaturesServiceProvider::registerFeatureDeclarations(), after the conduit baseline + the D95 Onboarding + D98 Custody lines, one later line overrides only the (*, Fraud) tuples: $providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Fraud, ['fingerprint','seon'], 'fingerprint'). It lives in the features provider — not the fraud module — precisely so the baseline-then-override ordering is guaranteed in one sequential method (a boot-order-dependent grant declared from FraudServiceProvider could be clobbered by the conduit baseline). conduit is not a permitted Fraud key: conduit does not implement AssessesFraudRisk, so it could never screen a device — the baseline was a routing placeholder that features:sync's primary-flip demotes. data_region/dpa_ref are left null (the D95/D98 precedent — a non-null region breaks the region=null EU bloc via assertResidency); Fingerprint's residency is pinned at the vendor account (a config-driven base URL), a documented production go-live gate.

Two independent deploy gates — code-live, behaviour-inert. The grant routes in dev/test, but real behaviour needs BOTH: (1) the fraud.device-risk FEATURE ships default-disabled — offered (permitted) in all six jurisdictions so it can be enabled, but granted no tier entitlement, so the resolver falls through to the disabled default and every tenant reads active == false until an ops per-tenant override enables it; (2) CENTRAL-context assessment (operator login, pre-tenancy sign-up — no tenant, so the tenant-only ProviderRouter cannot resolve a provider) is config-gated by config('fraud.central_provider'), default null = off. Until ops sets both, the guard skips silently — zero behaviour change on deploy. In this pre-enable state a payout is neither screened nor recorded — the deploy-gate skip is deliberate and total, and is DISTINCT from the never-silent screening-unavailable posture (below), which applies only once a tenant is enabled and a live screening then fails to complete.

DeviceRiskGuard postures (D-C, phase-4 fix-round). The guard runs LAST among the bus's pre-execution gates (cheap local gates shield the metered vendor call). On a completed assessment: allow/review proceed + record; deny records, forgets step-up, and throws FraudBlocked (403). When the assessment cannot complete (no device token, provider unavailable/unroutable, a wiring-invariant miswire — see below — or a defensive Unknown): login/sign-up fail-OPEN (proceed, structured warn, no row — a login must not be bricked by a screening outage); payout is NEVER SILENT — BEFORE the block/proceed decision it durably records the skipped screening as a device-less verdict = Unknown assessment row via RecordDeviceAssessmentCommand, whose own command.executed audit_events row (D69, populated by the command's ProvidesAuditPayload with screening_unavailable:<reason>) IS the "never silent" durable record — so a skipped screening is captured whether the payout then proceeds (fresh step-up) or is blocked. THEN it fails CLOSED to step-up: a fresh step-up is the compensating control that lets a payout through, and without it the payout is blocked with a durable FraudScreeningRequired — a distinct 403 (not FraudBlocked): nothing was denied, screening merely could not run, so the client re-confirms MFA and retries rather than being shown a fraud block.

Availability wins over wiring purity (fix-round). A wiring-invariant breach — the routed fraud key has no registered adapter (UnknownProviderAdapterException) or resolves to a partial adapter that does not implement AssessesFraudRisk (AdapterMissingCapabilityException), both RailsException subtypes that normally surface as HTTP 500 — is caught by the guard as a screening-unavailable condition (login fail-open, payout the step-up/record path above), NEVER allowed to escape and down every login. A miswired fraud provider must not brick auth; the misconfiguration stays observable via the CI wiring tests, the structured warn, and the payout durable record.

Durable deny / durable screening-skip (D-D, fix-round). Both ErrorCode::FraudBlocked and ErrorCode::FraudScreeningRequired are registered in isDurableRejection() (the enum mechanism, not the DurableRejection interface — the two partition with no overlap). The bus's EvaluatesDeviceRisk catch — previously file-audited only — fires a durable CommandRejected via recordAttempt(...) when ExpectedClientError::isDurableRejection($e), mirroring the execution-catch path, so both a fraud deny and a blocked-payout screening-required rejection land in the hash-chained audit_events trail for the regulator-grade trail + intrusion detection.

Tenant-scoped dedup shields the meter, not the vendor call. No stable device id exists BEFORE the vendor call, so the guard always calls the vendor, THEN looks up a prior in-window assessment of the RETURNED device (GetRecentDeviceAssessmentQuery, scope derived from the active RLS context — a verdict is never reused across tenant scopes). On a MISS it consumes one fraud.device-risk meter unit; on a HIT it does not (the device was already metered within the window). It records the assessment on hit and miss alike, so tenant login history stays complete and the DeviceRiskEvaluated event still fires.

Tenant login_events for a login lands POST-AUTH (fix-round). Because the guard screens the login command PRE-AUTH, no authenticated client uuid exists at record time, so the guard's own write path lands only the central device_signals row. A SYNC MirrorClientLoginAssessment listener on authentication's ClientLoggedIn event closes the gap in the same request — it reads the guard-stashed signal uuid off the request-scoped Context (BusDeviceRiskGuard::LAST_SIGNAL_CONTEXT_KEY), a HARD uuid handoff that replaced the original correlation-id JOIN (which could in principle match a different in-request row — a Security fix), rehydrates the signal via GetDeviceSignalForLinkQuery (uuid-keyed, tenant-scoped by context, purpose = login sanity guard), and dispatches LinkLoginEventCommand to insert the tenant row once the uuid is known. The tenant row stamps device_signal_uuid for provenance, and the link handler is idempotent on it (a re-fired event never writes a duplicate). It is safe inline (the login command is WithoutTransaction) and swallows its own failures so a mirror hiccup never breaks a completed login. MFA-enrolled logins complete on the VERIFY request, so VerifyMfaChallengeCommand also carries EvaluatesDeviceRisk (closing the MFA blind spot): both the password and verify requests screen, and it is the verify request's signal that mirrors — the dedup window shields the meter from a double-consume for the same device. A login is mirrored only when the request that completed it carried a device token and screening ran (else fail-open, central-only). Payout rows still mirror DIRECTLY (the guard runs post-auth with an actor); failed logins — and a deny on the verify step — fire no event and stay central-only (no user attribution before auth). This is a new Events-only cross-module edge, fraud → authentication\Events — direction-sound (authentication sits below fraud and imports nothing from it), mirroring compliance → fraud\Events. The consume is a standalone auto-committed UPDATE — the guard runs outside the outer command's transaction (every EvaluatesDeviceRisk command is WithoutTransaction; none is bus-level Idempotent, which is mutually exclusive with it) — correct, because the metered action (the vendor call) already, irreversibly, happened.

Global verdict bands are the v1 posture. The Fingerprint suspect_score bands + deny-signal list live in the adapter/SDK config (never in the domain — the domain must never band/compare a vendor score) as a single GLOBAL set. Jurisdiction-tunable bands are a recorded seam (the mapper ctor already takes bands), not wired in v1.

Compliance case-open. A deny DeviceRiskEvaluated opens a CaseType::Fraud case (SLA 240 min, the 4h class) via the OpenCaseForDeviceRiskDeny queued afterCommit listener → CaseIntake::openOrAdvance, keyed on the provider:device external ref.

Go-live gates (fix-round additions). Three operational preconditions before fraud.device-risk is enabled for a tenant: (0) confirm the Fingerprint plan includes Smart Signals — an identification-only plan returns no risk signals, so every assessment bands to a signal-less Allow (device intelligence silently no-ops); the presence of Smart Signals is surfaced on the assessment via extras.smart_signals_present, so verify it before trusting a verdict. (1) the per-identity login rate limiter is load-bearing for vendor-cost abuse — the device-risk gate triggers a metered/paid vendor call per login attempt, so enabling the feature without a per-identity limiter would let an attacker run up Fingerprint/SEON spend by hammering login. The limiter SHIPPED in phase 5 (throttle:login, email|ip, 5/min, on client + operator login + the MFA-challenge verify step — AuthenticationServiceProvider), so the precondition is met on this deploy; the ordering note still holds — enable the feature only on a deploy that carries the limiter. (2) Trusted-proxy caveat — ip/ua are threaded strictly from $request->ip()/$request->userAgent() (never a client header), and the app does not trust proxy headers today; if ops later trusts proxies for IP fidelity, trust only the KNOWN proxy hop, never * (a wildcard lets a client spoof its source IP back into the assessment + the durable rows).

Phase 5 shipped (the final slice). The inbound Fingerprint webhook (a webhooks/fingerprint source with single-header HMAC verification; the fraud module's pull-truth handler + device→tenant resolver — webhook = a changed signal, so it re-pulls the truth and never trusts the body, drop-and-acks a re-pull it cannot complete rather than parking, and does not meter) and the per-identity login rate limiter both landed.

Deferred / carry-overs. SEON's webhook signature scheme is unverified → SEON ships sync-only (no validator, D-G). Jurisdiction-tunable verdict bands (global bands are the v1 posture), central-context assessment enablement (fraud.central_provider ships null), and personal-data retention/prune for device_signals + login_events remain tracked in 11-fraud-device-login.md.

Amendment D102.1 (phase 6 — deferred seams delivered). The six seams D102 recorded as tracked-but-deferred are now built. Four (A/B/C/F) are gated or inert at deploy; the dual-limit login limiter (D) and the durable anti-replay (E) ship live-but-benign — neither changes a screening outcome. (A) A retention prune (fraud:prune-signals, scheduled daily, one-server/no-overlap) hard-deletes device_signals (one central pass) + login_events (per-tenant, under RLS) older than fraud.retention.*_days (default 90) — the stores are security TELEMETRY (encrypted-Pii ip/ua), NOT compliance evidence-of-record (the audit trail + cases hold that under the D31 floor); the 24h dedup window «« the 90d retention window. (B) The jurisdiction-tunable Fingerprint verdict bands seam is WIRED — config/fingerprint.php verdict.per_jurisdiction (partial overrides over the global set) + FingerprintVerdictBandResolver, with the guard/webhook threading the tenant's jurisdiction server-derived (DeviceContext::$extras['jurisdiction_code'], never client input); v1 ships an empty map (global-only). (C) Client device-hint reuse (EvaluatesDeviceRisk::deviceHint() + config('fraud.hint_reuse_enabled'), default OFF) — login/sign-up only, never payout, never a cached deny; a same-tenant cached Allow/Review keyed by the hint skips the vendor call + meter, recording a fresh row that copies the cached row's vendor-sourced device id (the raw hint is never persisted). Risk posture: trades a vendor call for trusting a same-tenant client device claim — acceptable only on fail-open surfaces. (D) The login limiter became dual-limit — the tight (email|ip) 5/min bucket plus an IP-independent companion on the identity alone at 20/min, closing the distributed IP-rotation gap (accepted residual: the companion is reciprocal — spraying a victim's identity across many IPs can 429-lock that identity for the bounded, self-healing minute window; the standard trade, revisit if abused). (E) A durable store-backed anti-replay (GetDeviceSignalByEventQuery, asked before re-pulling) makes Fingerprint-webhook replay protection durable for the full device_signals retention window — retention now bounds the residual, not the 30-day webhook_calls prune. (One SANDBOX-CONFIRM residual: because the guard also records the requestId, a same-requestId re-fire carrying LATE-ARRIVING signals is dropped as replayed; whether Fingerprint re-fires the same requestId with updated signals is unverified, so the conservative drop wins until sandbox confirms — if it re-fires, narrow the anti-replay set to prior webhook re-pull rows only. Tracked in 11-fraud-device-login.md §7.) (F) A no-double-metering tripwire (MeterConsumptionTripwireTest) pins the sole fraud.device-risk consumer to BusDeviceRiskGuard, so a future Sumsub-OEM path cannot double-charge. Still deferred: the SEON webhook validator (sandbox-blocked), central-context assessment enablement (fraud.central_provider ships null), and the Fingerprint rate-limit / Smart-Signals go-live gates.

This decision builds on the router substrate (D94) and the earlier non-Conduit primary grants (D95 Sumsub, D98 Utila); it does not restate them.


← Decision log index