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.