S
Signal Trust
Redis
Postgres
Sheets
connecting…
Good afternoon
Policy v37 is live and healthy. The platform has made 1.84M decisions today. 2 need a human. Below is the system at a glance. Open Atlas for how it decides.
Live pulse NORMAL
Throughput · 60s
2,140 dec/min
Decision mix
allow72%
step-up20%
deny8%
Needs you 2
••8FZ1 · transfer · $8,900
4m left
••3MZ9 · reset-pwd
11m left
Policy v37 · live ▾
Active rules142
Last change2d ago · you
Draftv38 · 3 edits
Fundamentals

The decision lifecycle

Every request walks the same six stages. This is the exact pipeline unpacked in the Decision Trace — in the Simulator and on every case in Review.

1
Input
who · what · device · auth
2
Data lookup
history · profile
3
Signals
geo · device · ATO · bot
4
Policy eval
rules → required AL
5
Decision
allow · step-up · deny
6
Route
vendor / adapter

How it works

A request arrives with who, what and the device fingerprint. The platform enriches it with history, gathers live signals, then evaluates policy to pick the minimum friction that keeps the action safe.

An unknown customer defaults to fraud score 50, an unknown device to 0. Enrichment is always best-effort — if a source is slow or down, the decision still completes on what's available. The intelligence layer adds context; it never holds a decision up.

Worked example

login · OTP · new device
fraud 12 (LOW) · device 0 (untrusted) · ATO 1/3 · bot No → policy: AL2 sufficient → allow
FRICTIONLESS

Where this appears

Monitor — every row Simulator — trace Review — case timeline Policy Lab — eval step
Fundamentals

Trust vs. friction

The goal is lopsided on purpose. Let the safe majority through untouched, and spend friction only where the risk earns it.

The minimum-friction model

Every customer action carries a question: is this really them, and is it safe right now? The platform answers it in real time and returns one of four decisions. The challenge is knowing when the risk earns the cost of adding a step.

Two design choices make that work. Risk logic lives in editable policy, so product and risk tune thresholds without an engineering release. Trust is contextual: the same customer doing the same thing can get a different answer depending on their device, their network, and how they've behaved lately.

Why a "normal" account can still get stopped

Account takeover is hard to catch because the attacker looks like the customer on paper — they're using real credentials, so the fraud score reads normal. The platform watches 3 independent signals that move the moment someone else is at the controls.

breach+ new device+ proxy IDV any 2 of 3

Any 2 of the 3 force AL4 IDV, whatever the fraud score says. This is the logic major banks run in production.

Trust ramp

deny zone · <30 step-up zone · 30–70 allow zone · >70

Where this appears

Simulator — outcome Policy Lab — threshold
Fundamentals

Auth levels (AL1–AL4)

Four assurance levels form a ladder. Every action requires a minimum level; the step-up mechanism climbs the customer to exactly what the action needs.

The assurance ladder

AL1
Face ID / passcode
conf 45
AL2
Passkey
conf 65
AL3
Selfie
conf 80
AL4
IDV
conf 95

Action tiers

Tier
Examples
Min AL
Risk ceiling
Tier1login, view balanceAL185 Tier2bill pay, P2P transferAL270 Tier3wire >£10K, change passwordAL355 Tier4account recovery, add payeeAL440

How step-up is triggered

When the current auth level is below what the action requires, policy picks the challenge mode:

AL_PLUS_1 — ask for one level above required (safety margin)
REQUIRED_AL — ask for exactly the minimum (minimum friction)

Where this appears

Simulator — auth level field Control — tier thresholds
Signals

Risk signals

Fraud score and geography are the identity-layer signals. They come from the profile store and set the baseline risk before enrichment adjusts them.

fraud_score

0–100, higher is riskier. The confidence engine maps it to a risk level. Enrichment adjusts it additively:

LOW0–25Frictionless path
MEDIUM26–79Step-up likely
HIGH80–100Deny or IDV
Tor exit +40 · GreyNoise bot +40 · proxy/VPN +15 · email breach (2+) +20
Additive, clamped at 100. Base score is never replaced.

geography

Country code derived from the request IP via ip-api.com (cached 30 min), falling back to the customer record. Used by policy rules and the IDV routing engine.

Connected intelligence vendors

IP Geolocation
ip-api.com
FreeNo API keyCache 30 min

Country, city, ISP, and proxy / hosting / Tor flags from the request IP. The platform's primary source of network context — no key required, no quota.

is_proxy · is_vpn → fraud +15
is_tor → fraud +40
geography → IDV vendor routing
IP Reputation
AbuseIPDB
Free tier1,000 checks/dayCache 1h

Crowd-sourced IP reputation score 0–100, where 100 means the IP has been reported for abuse many times. Used to degrade device trust — an abusive IP on a known device still pulls it down.

ip_abuse_score > 80 → device −40
ip_abuse_score > 50 → device −20
Email Breach Intelligence
HaveIBeenPwned (HIBP)
API key requiredCache 24h

Whether the customer's email address appears in a known data breach corpus. A hit with more than 2 breaches is treated as a meaningful ATO signal — attackers buy lists of highly-breached emails specifically because those credentials are tried elsewhere.

email_breached && breach_count > 2 → fraud +20 · ATS −15
Combined with new device or proxy → ATO rule fires → IDV
Bot & Scanner Detection
GreyNoise Community
Community freeReal-time

Whether the request IP is a known internet scanner or bot. GreyNoise passively observes mass scanning activity and tags IPs that are definitively non-human. A hit here almost always means automated credential stuffing — most legitimate customers will never appear in this dataset.

is_greynoise_bot → fraud +40 · fires deny_greynoise_bot rule directly
Password Breach Check
Pwned Passwords (k-anonymity)
Freek-anonymity SHA-1

Whether the submitted password appears in breach data, checked using the k-anonymity SHA-1 prefix method — only the first 5 characters of the hash are sent, so the actual password is never transmitted. A hit forces AL3 selfie or higher regardless of other signals.

password_compromised → force step-up to AL3 minimum
All enrichment runs async and cached in Redis. Every call fires without blocking the decision; results land in 30 min to 24h depending on how fast the signal changes. If a source is slow or down, the engine uses the last cached value or the static score. The intelligence layer adds context to a decision — it never holds one up.

Authentication state

current_auth_level — how the customer authenticated this session: AL1 Face ID or passcode · AL2 passkey · AL3 selfie · AL4 IDV. The confidence engine checks whether it meets the action's required AL. If it falls short, the decision engine triggers a step-up.

Where this appears

Simulator — Signals stage Review — case detail Control — risk bands
Signals

Composite risk score

Five components — customer, device, behavioural, network, velocity — are normalised to 0–100 (higher = riskier) and blended into one compositeRisk score. Weights live in policy, not code.

The formula

compositeRisk = Σ(component × weight) / 100 customerRisk = fraudScore (weight 40) deviceRisk = 100 − deviceScore (weight 25) behaviouralRisk = 100 − ambientTrust (weight 15) networkRisk = additive sub-score (weight 15) velocityRisk = non-burst counts (weight 5) networkRisk = ip_abuse×0.6 + breach 25 + proxy 20 + new_device 20 + vpn 15 (cap 100)

Result is banded: LOW (0–35) · MEDIUM (36–64) · HIGH (65–100). Weights are editable in Control.

Risk ceiling per tier

Tier1
85
Tier2
70
Tier3
55
Tier4
40

Where this appears

Simulator — Score stage Control — formula weights
Signals

Device trust

Device trust combines a stored score with live fingerprinting. A known device on a clean network scores high. A first-time fingerprint on a known customer is one of the clearest ATO tells available.

device_score

0–100, higher is more trusted. Adjusted at decision time by network signals:

In v4 these signals feed networkRisk (additive sub-score, cap 100) — they no longer mutate deviceScore or fraudScore directly.

ip_abuse_score × 0.6 (max 60) · email_breached +25 · proxy +20 · new_device +20 · vpn +15

Device fingerprinting

FingerprintJS OSS generates a stable visitorId from browser/hardware signals. If the ID is new for this customer, is_new_device=true. Combined with a breached email or a proxy, it fires the ATO rule and forces IDV.

Where this appears

Simulator — device field Review — signal panel
Signals

Ambient trust

A running 0–100 read on each customer that carries across sessions. Unlike fraud score — a static attribute — ambient trust moves with behaviour and decays without renewal.

How it moves

Climbs with
Completed passkey+3
Completed selfie+5
Completed IDV+8
Drops with
New device−10
Detected breach−15
Velocity burst−20

Idle accounts drift back toward 50 over time — trust has to keep being renewed. In v4, ATS feeds the behaviouralRisk component of the composite score (100 − ATS, weight 15).

The effect in practice

A loyal customer who's cleared step-ups for months sits at a high score and stops getting asked. A wire that would demand a selfie just goes through. The same wire from an account showing a low score after a breach gets pushed all the way to IDV — even with a normal fraud score.

Where this appears

Simulator — Trust stage Monitor — customer ATS
Decisions

Outcomes

Every decision resolves to one of four outcomes. Actionable ones carry a machine-readable reference ID used by the step-up journey and the Review Queue.

Decision table

Decision
Customer experience
When
Ref
FRICTIONLESSNothing. The action goes through.Low risk, auth level met STEP-UPOne extra challenge: passcode, passkey, selfie, or IDV.Auth gap or risk signalTXN-… DENYAction blocked, protection message shown.High risk or velocity burstINC-… MANUALQueued for a human operator.Elevated risk, human judgment neededCASE-…

Reference IDs

Format: PREFIX-YYYYMMDD-XXXX

TXN-20260617-K7P2 → STEP_UP
INC-20260617-A3BF → DENY
CASE-20260617-X9RQ → MANUAL_REVIEW

Where this appears

Monitor — decision column Simulator — outcome Review — case list
Decisions

Step-up logic

A STEP_UP decision asks the customer for one more proof of identity. The type of proof and the journey are defined in policy.

Step-up types

PASSCODEAL1 · Face ID or PIN
PASSKEYAL2 · Cryptographic passkey challenge
SELFIEAL3 · Liveness-checked face match
IDVAL4 · Full identity document check
AL_PLUS_1 — one level above required (safety margin)
REQUIRED_AL — exactly the minimum required (minimum friction)

The step-up journey

1.Decision issues a reference ID (TXN-…) and returns step_up_type to the client.
2.Customer completes (or abandons) the challenge. Client posts to POST /trust/step-up/complete.
3.Platform re-evaluates with the new auth level — typically resolves to FRICTIONLESS.

Where this appears

Simulator — journey panel Review — case timeline
Decisions

Deny & review

DENY blocks the action outright. MANUAL_REVIEW queues it for a human operator. The distinction is whether human judgment could change the outcome.

When we deny

Tor exit node or GreyNoise-tagged bot — fraud shoots to 90+.
Velocity burst — more than 5 requests in 1 minute. Automated stuffing pattern.
High risk (≥80) + Tier4 action — account recovery or adding a new payee.

When we send to review

Elevated velocity — 10–15 requests in 5 minutes. Borderline: could be scripted, could be legitimate.
Medium–high risk on a Tier3/4 action — worth a human look before blocking permanently.
Velocity rules require Redis. When Redis is unavailable these rules are skipped and the decision carries on without them.

Where this appears

Review — case queue Control — velocity toggle
Policy

Rules & primitives

All risk logic lives in policies/decisions.json — an ordered array of rules. First match wins. No risk logic in code.

Rule structure

{ "id": "deny_tor_exit_node", "condition": { "is_tor": true }, "decision": "DENY", "reason": "Tor exit node detected", "enabled": true }

Available conditions

riskLevel · LOW / MEDIUM / HIGH (composite band)
risk_ceiling_breached · boolean — compositeRisk > action's ceiling
alMeetsRequired · boolean — assurance gap check
geography · country code array
actionTier · Tier1–Tier4
velocity_1m_gt / 5m_gt / 15m_gt · count thresholds (Redis)
is_tor / is_greynoise_bot · hard gate flags
is_proxy / is_vpn / is_new_device / email_breached · enrichment flags
ip_abuse_score_gte / ato_signal_count_gte · numeric thresholds

Where this appears

Policy Lab — rule editor Control — rule toggles Simulator — Policy eval stage
Policy

Publishing

Policy changes follow a simulate-then-publish loop. Nothing goes live without a before/after comparison against real traffic.

The publish flow

1.
Author the rule
Write it in Policy Lab directly, or describe it in plain language and let the AI copilot draft it.
2.
Simulate
Replay the draft against recent real traffic. Read the before/after decision mix, the transition matrix, and which rules fired or never fired.
3.
Publish
Click Publish in Policy Lab. The engine picks up the new config immediately — no restart.

A/B experiments

Run a draft against a deterministic slice of live traffic. The same customer always lands in the same variant (hash-based split). Compare decision mixes before a full rollout. Configure in Control → A/B Experiment.

Versioning & rollback

Every published policy is a version. The Policy Lab header shows v37 · live and v38 · 3 edits. Rollback is one click back to any prior version.

Where this appears

Policy Lab Control — A/B panel
Signals

Velocity

Request counts in rolling time windows, tracked in Redis sorted sets. Velocity is the fastest signal the platform has — a credential-stuffing burst shows up within seconds.

Sliding windows

velocity_1mrequests in the last 60 seconds>5 → DENY
velocity_5mrequests in the last 5 minutes>10 → REVIEW
velocity_15mrequests in the last 15 minutespolicy-tunable
Windows are tracked in Redis sorted sets, one per customer. Each request is recorded with a Unix millisecond timestamp as the score. The engine trims old entries and counts in the relevant window before every decision. When Redis is unavailable, velocity rules are skipped — the rest of the pipeline continues unchanged.

How velocity rules fire

Velocity is evaluated as a rule condition, exactly like risk level or action tier. Two built-in rules cover the most common patterns:

DENYdeny_velocity_burst — more than 5 requests in 1 minute. Automated credential stuffing. Requires Redis.
REVIEWmanual_review_velocity_elevated — more than 10 requests in 5 minutes. Elevated but borderline — human judgment needed. Requires Redis.

Both rules can be toggled on/off in Control without a deployment. They can also be disabled globally via the Velocity Enforcement switch, which is useful when Redis is first brought up in a new environment.

Velocity and ambient trust

A velocity burst also records a suspicion signal into the Ambient Trust Score (−20). So even if the burst is borderline and goes to review rather than deny, the customer's ATS degrades — meaning subsequent actions require more trust to clear.

Where this appears

Monitor — velocity column Simulator — Signals stage Control — velocity toggle
System

Synthetic traffic

You don't wait for real fraud to see the platform work. A traffic engine drives continuous realistic decisions that stream into Monitor in real time, and a scheduler mixes in attack scenarios on demand.

Traffic daemon

Traffic daemon

Node process under PM2, fires requests time-weighted to look like real load with an attack scheduler mixed in. Built on generate-traffic.js.

Persona bank

20–30 synthetic customers, each with a stable fraud score, device set, geography, and behaviour pattern, plus an attack probability. Stored in personas.json.

Live feed

Decisions stream into Monitor over Server-Sent Events. No polling, no websocket overhead — each decision appears within milliseconds.

6 persona archetypes

Persona
Profile
Headline signals
Expected
Laraloyal retail, UK, dailyfraud 12, known iPhone, home ISP, not breachedALLOW
Maximcautious saver, infrequentfraud 8, known Android, ManchesterALLOW
Jasonbusiness, DE/UK travelfraud 38, 3 devices, 1 old breach, large wiresSTEP-UP
Harveyhigh riskfraud 82, unknown device, VPN, 3 breaches, recoveryDENY
Bot-001credential stufferfraud 95, new device per request, AbuseIPDB 90+DENY
ATO-Nikkyaccount takeoverfraud 40 (normal), new fingerprint, Nigeria proxy, email breachedIDV

3 attack scenarios

Credential stuffing burst

Bot-001 fires 20 logins in 60 seconds across rotating IPs. velocity_1m > 5 trips and AbuseIPDB returns 90+. Watch the DENY cascade land in the live feed.

Account takeover chain

Nikky's stolen credentials from a new device, a VPN, and a breached email. The fraud score looks normal, the ATO stack fires, and the action is forced to IDV.

Mule network

5 customers send to the same new payee inside 10 minutes. The pattern surfaces across the decision log and routes the later ones to manual review.

Where this appears

Monitor — live feed Control — daemon + attacks
System

Passkeys & authentication

Signal uses WebAuthn (FIDO2) passkeys as the primary credential — no password ever leaves the device. A PIN backup provides AL1 read-only access when the passkey device isn't available. Every login runs through the trust decision engine before a session is issued.

Why passkeys instead of passwords

A passkey is a public/private key pair generated on the user's device. The private key never leaves the device — not even to Signal's server. At login, the device signs a server-issued challenge with the private key; the server verifies the signature against the stored public key. There is no password to phish, no hash to breach, and no credential to replay.

Phishing-resistantOrigin is bound into the credential — a spoofed domain gets a different credential ID and signature verification fails.
Device-boundPrivate key is stored in the device's secure enclave (TouchID / FaceID / Windows Hello / Android biometrics). Syncs via iCloud Keychain or Google Password Manager.
No shared secretServer only stores the public key. A breach of the credential store exposes nothing exploitable.
StandardW3C WebAuthn Level 2 + FIDO2. Implemented via @simplewebauthn/server on the Node side and the browser's PublicKeyCredential API on the client.

Registration flow

First-time users prove ownership of their email via a magic link. The link sets a short-lived sig_enroll cookie that gates the WebAuthn registration endpoints.

1.Email entered → POST /auth/magic-link/request. Server generates a 32-byte random token, SHA-256 hashes it, stores hash + 15-min expiry in data/credentials.json. Raw token goes into the link; never stored.
2.User clicks link → GET /auth/magic-link/verify. Server re-hashes token, compares to stored hash, checks expiry. On match: clears token, sets 10-min sig_enroll JWT cookie, redirects to /login?step=register.
3.Passkey creation → POST /auth/webauthn/register/start. Server calls generateRegistrationOptions(), stores challenge in memory (5-min TTL), returns options JSON to browser.
4.Device prompts biometrics. Browser generates key pair in secure enclave, returns signed attestation to POST /auth/webauthn/register/finish.
5.Server verifies attestation via verifyRegistrationResponse(). Stores { id, publicKey (base64), counter, transports } in credentials.json. Issues sig_session JWT at AL2. Clears sig_enroll cookie.
6.PIN setup offered. User can optionally set a 4–8 digit PIN (stored as bcrypt hash) before entering the app.

Login flow

1.Email entered → GET /auth/user-info. Returns { has_passkey, has_passcode }. If passkey registered: go to passkey step. If not: send magic link (same as registration).
2.Challenge issued → POST /auth/webauthn/login/start. Server builds allowCredentials from stored passkeys for this email, calls generateAuthenticationOptions(), stores challenge.
3.Device signs challenge. Browser shows biometric prompt, signs with stored private key, returns assertion to POST /auth/webauthn/login/finish.
4.Server verifies signature via verifyAuthenticationResponse(). Checks counter hasn't regressed (replay protection). Updates counter in store.
5.Trust decision. getDecision() is called with current_auth_level: 'AL2'. On ALLOW/FRICTIONLESS: session issued. On STEP_UP/DENY: login blocked.
6.Session cookie set. sig_session JWT (24h, httpOnly, sameSite=strict) with { customer_id, email, achieved_al: 'AL2', device_id }.

PIN backup (AL1)

The PIN is a secondary factor for when the passkey device isn't available. It deliberately gives fewer privileges.

SetPOST /auth/passcode/set — requires active AL2 session. Stored as bcrypt hash (cost 10) in credentials.json.
LoginPOST /auth/passcode/login. bcrypt.compare against stored hash. Also runs trust decision with current_auth_level: 'AL1'. Issues session at AL1.
AL1 accessHome, Atlas, Monitor, Analytics, Review Queue — all read-only surfaces.
AL2 lockedControl Panel and Policy Lab show an in-page passkey challenge. On success the session is re-issued at AL2 and the page reloads — no redirect.

Cookies & sessions

sig_session24h JWT. httpOnly, sameSite=strict. Contains customer_id, email, achieved_al, device_id. Checked on every protected route. Verified by middleware/session.js.
sig_enroll10-min JWT. httpOnly, sameSite=strict. Set after magic link verification; cleared after passkey registration completes. Gates /auth/webauthn/register/*.
sig_device365-day UUIDv4. httpOnly=false (intentional — FingerprintJS also reads it). Used as stable device identifier fed to the trust decision engine. Set on login and passkey registration.

Credential store

data/credentials.json — file-backed, gitignored. Managed by auth/credentialStore.js. Structure:

{ "users": {
"user@example.com": {
"customer_id": "user",
"passkeys": [{ "id", "publicKey", "counter", "transports" }],
"passcode_hash": "$2b$10$…", // bcrypt, optional
"magic_token_hash": "sha256…", // cleared on use
"magic_token_expires": 1718900000000
}
} }

Key files

auth/router.jsAll /auth/* endpoints. Magic link request/verify, WebAuthn register/login, passcode set/login, logout.
auth/credentialStore.jsFile-backed CRUD over credentials.json. getByEmail, addPasskey, getPasskeyById, updateCounter, setPasscodeHash, setMagicToken.
auth/challengeStore.jsIn-memory Map. Stores WebAuthn challenges keyed by email, with 5-min auto-expiry GC.
middleware/session.jsrequireSession (verifies sig_session JWT, attaches req.user), requireAL(level) factory, issueSessionCookie, clearSessionCookie.
public/login.html4-step state machine: email → passkey login → register passkey → PIN setup. Mode switcher on passkey step sends a magic link without leaving the page.

In-page AL2 uplift

Control Panel and Policy Lab require AL2. When an AL1 (PIN) session tries to access them, the tab surface is replaced with a "Verify with passkey" prompt — no redirect to the login page. The upgradeToAL2() function in index.html runs the full WebAuthn authentication inline, calls /auth/webauthn/login/finish, and on success triggers a page reload. The server re-issues the session cookie at AL2 in the same request, so the reload sees full access immediately.

1.checkSession() stores email in _sessionEmail, calls lockAL2Tabs() when AL1.
2.Lock panel renders inline — button calls upgradeToAL2(tabId).
3.WebAuthn challenge → device biometric → /auth/webauthn/login/finish.
4.On result.ok: location.reload(). New session cookie at AL2 is in place; tabs unlock on next checkSession() call.

Where this appears

Home — user chip + AL badge Login page — all steps Control — AL2 lock panel Policy Lab — AL2 lock panel
System

Architecture

The decision path is a Node.js + Express service with a strict pipeline and no risk logic in code. It degrades on purpose — each dependency can go missing without stopping decisions.

End-to-end system diagram

Internal service
External vendor (async)
Output / sink
Client request customer_id action device_id current_auth_level ip
Stage 1
Input resolve
data/store.js Google Sheets (optional) Local JSON fallback Redis score cache
Stage 2
Enrichment
async · best-effort
ip-api.com — geo + proxy/Tor AbuseIPDB — IP reputation HaveIBeenPwned — email breach GreyNoise — bot / scanner FingerprintJS OSS — device ID Redis enrichment cache
Stage 3
Scoring
riskEngine.js 5 components → compositeRisk → riskLevel · assurance gap → alMeetsRequired
Stage 4
Velocity & ATS
Redis — sorted sets 1m · 5m · 15m Redis — ambient trust score decays to 50 · cross-session
Stage 5
Policy eval
policyEngine.js policies/decisions.json ordered rules · first-match wins
Stage 6
Route & record
IDV vendor — Onfido · Persona · Jumio Amplitude — event tracking Postgres — decision log decisions.jsonl — append-only fallback SSE stream → Monitor Redis — session store
Decision returned FRICTIONLESS STEP-UP DENY MANUAL + reference_id + display_message + trace

Dependency tolerance

RedisWithout it: velocity rules skip, caching falls back to uncached lookups, ATS returns 50.
PostgresWithout it: decision log writes append-only JSONL, analytics reads in-memory ring buffer.
SheetsWithout it: data source falls back to local JSON in data/.
EnrichmentWithout any given vendor: engine uses last cached value or static score. Decision never waits.

Live health

Redis
Postgres
Sheets
Adapters

Where this appears

Control — system health Monitor — status bar
System

Integration reference architecture

The engine earns its value only if it fires across the whole bank — not once at login. This document describes how to embed the platform into a real banking estate: the integration patterns, what is decisioned versus what is enforced, the admin model, and the three-step runtime flow that turns a STEP_UP into a completed action.

The shift: perimeter → continuous authorization

Today the engine evaluates a request and returns one of four decisions. The shift being made is from perimeter authentication (decide once, at the front door) to continuous authorization (decide per action). The named pattern for this is the PEP/PDP split — separating the Policy Enforcement Point from the Policy Decision Point.

PDPPolicy Decision Point — what we already built: the decision engine, plus enrichment as the PIP (Policy Information Point) and the Policy Lab as the PAP (Policy Administration Point).
PEPPolicy Enforcement Point — the new piece. A thin hook placed in front of customer actions that calls the PDP and enforces the result.

The integration problem is entirely about the PEP: how to place a consistent enforcement hook in front of every action across a heterogeneous estate, cheaply, without forcing every team to re-plumb their service. The product being sold internally is one decision contract plus one step-up protocol, served by many enforcement adapters — the same shape that made OPA adoptable for authorization.

The four integration patterns

PatternWhere the PEP livesBusiness contextCoverageFriction to adopt
API gateway filterEdge (Apigee, Kong, DataPower)Weak — sees HTTP onlyBlanket, single hookLowest
BFF middlewareThe channel's own backendStrong — knows the actionPer channelLow
Service SDKEmbedded in each serviceStrongestPer serviceMedium (code change)
Mesh sidecarService mesh (Envoy ext_authz)Medium (header-passed)Cloud-native servicesLow where a mesh exists

Recommendation: a contract-first hybrid, not a single pattern. Ship a small SDK that speaks a stable decide() contract, plus a BFF middleware and a gateway filter built on that same contract. The gateway gives blanket coverage from day one. The BFF and SDK supply the business context where it matters.

Lead with the BFF middleware. Every channel already has a backend that decides "do you want to log in?" — that backend is the natural choke point. It holds the action semantics and the session, so placing the PEP there needs no client rewrite and loses no context. The riskiest assumption is not the engine — it is whether channel teams will pass good context to the decision point.

Decision everywhere, enforcement selectively

A core design rule: call the engine on every action; introduce friction only when policy says so. Decoupling "decide" from "enforce" is the move that makes ubiquitous decisioning affordable.

Call everythingIncluding read-only balance checks — the ambient trust score and behavioural signal only work if the engine observes the full action stream.
Cheap callsMost calls return ALLOW in single-digit milliseconds from cache. Friction is the expensive part, and friction fires only when policy and risk score demand it.
One code pathConsistency — closes the "did someone remember to gate this endpoint?" gap across every channel.

The admin model: Action Registry

The admin surface is an Action Registry. Every action a channel can perform is registered once with its tier, required Auth Assurance Level, and risk ceiling. Channels reference an action key, not a hardcoded rule — stopping the same logical action ("make a payment") from drifting in meaning across app, web, phone, and branch.

Registry fieldExampleDrives
action_keypayment.new_payeeCanonical identity across channels
tier4Sensitivity band
required_alAL3 (selfie)The bar a step-up must clear
risk_ceiling40Composite-risk threshold that forces review/deny
step_up_graintransactionWhether a step-up is remembered per session or per action

Unregistered actions fall back to a safe default: treat as Tier 3, fail-closed for money movement and fail-open for reads. That default posture is itself a policy choice, set per channel.

The runtime flow: tell, decide, step-up and retry

Three steps move a customer from intent to a completed action.

1 — Tell
The PEP intercepts the action and calls POST /trust/decision with { customer_id, action, device_id, current_auth_level, context }. It is asserting intent: the customer wants to do X, and here is what we know about them.
2 — Decide
The engine runs its pipeline and returns ALLOW, STEP_UP, DENY, or MANUAL_REVIEW. On STEP_UP, return a challenge — not just a flag — with the required acr, a max_age, and a challenge or transaction id. RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge Protocol) is the standard to adopt here — WWW-Authenticate: insufficient_user_authentication with required acr_values — so the step-up plugs into any OIDC/OAuth estate.
3 — Retry
The client runs the named authenticator — the AL ladder of passcode, passkey, selfie, IDV — and the auth service mints a fresh token carrying the satisfied acr/amr. The client replays the original action with the elevated token. The engine re-decides; alMeetsRequired is now true and it returns ALLOW.

Step-up grain: session vs. transaction

Session-grainedUplift the session AL — step up once, then proceed at that tier for the session. Better UX, looser security. Right for Tiers 1–2 (viewing).
Transaction-grainedBind the step-up to one action instance via a short-lived receipt in Redis, keyed by transaction id with a TTL. Higher assurance, more friction. Right for Tier 4 (new-payee payments).

Make grain a per-tier policy knob, not a constant. Both paths fit the existing Redis and policy model with no new infrastructure.

What a smart skeptic will challenge

Latency — we just put a synchronous hop in front of everything
The slow part is enrichment, not scoring. Use tiered evaluation: low-tier actions take a fast path with cached fraud and device scores and no live enrichment, returning under 10ms; full enrichment runs only on cache miss or for elevated tiers. Breach and IP-reputation services are never called on a balance check.
Availability — the engine is now in the critical path of the whole bank
This is the serious objection. Design the fail posture per tier: reads fail-open, money movement fails-closed. Add a local PEP fallback (cached last-known decision plus a conservative default) so an unreachable engine degrades rather than blocks, circuit breakers in every adapter, and a genuinely highly-available, multi-region PDP. An architecture review board will ask this first.
Idempotency on retry
Re-deciding after a step-up must not re-run side effects — but that is the business service's responsibility via its own idempotency key, not the trust layer's. The trust call is a gate before the action; the action owns its own idempotency. Also handle DENY-after-step-up: a step-up that succeeds while a hard gate (Tor, velocity burst) still denies the retry must render cleanly on the client.

Recommended next build

The Action Registry is the next piece to build. It is the component that turns the platform from an engine into a platform — the single source of truth that every PEP reads from and that the admin console governs. Everything else (the adapters, the RFC 9470 step-up, the fail-posture policy) hangs off it.

One-liner for steering
Trust decisions as a shared bank service — like OPA for authorization, but for transaction risk and adaptive step-up. Easy to integrate (one SDK line for the common case, standards-based step-up), easy to sell internally (additive and incremental — start with Tier 4 in one channel, prove the loop, then expand action by action).
On this page
Atlas → Product
Every "where this appears" chip deep-links into the live tab. Concepts and the running system stay one click apart.
Live Monitor
Real-time decision stream and system metrics.
Disconnected
Throughput · live
dec/min
Total today
Events in feed0
Decision mix
allow
step-up
review
deny
P95 decision time
ms
P50 (median)
P99
Open cases
Time
Customer
Action
Rule
Risk
Decision
Waiting for decisions…
Analytics
Decision distribution from the in-memory ring buffer.
Step-up outcomes
No data yet
Manual review outcomes
No data yet
By action
By risk level
By rule (top matched)
Review Queue
Manual review cases pending decision.
Time
Ref
Customer
Action
Risk
Status
Select a case to review
Decision Simulator
Run trust decisions against the live policy and inspect the full trace.
Construct request
Customer
Action
Device
Current auth level
None AL1 AL2 AL3 AL4
Run a decision to see the trace.
Policy Lab
Edit, simulate, and publish decision rules.
decisions.json
Policy Copilot
Describe the rule
Control Panel
Policy rules, confidence model, and system controls.
Policy rules
Toggle rules on or off. Changes apply immediately to the live policy.
Risk band · composite risk threshold
HIGH if composite ≥65
MEDIUM if composite ≥36
LOWMEDIUMHIGH
Composite risk weights
Five component weights — must sum to 100
Customer risk (fraud score)40
Device risk (100 − device score)25
Behavioural risk (100 − ATS)15
Network risk (IP / VPN / proxy)15
Velocity risk (non-burst counts)5
Weight sum: 100
Authenticator assurance levels
Velocity enforcement
Loading…
Redis-backed burst detection and elevated-velocity routing. Requires Redis.
Velocity thresholds
Edits the live rule conditions. Requires Redis to have effect.
Burst deny · max req / 1 min5
Elevated review · max req / 5 min10
IDV routing strategy
Active strategy
Ambient trust override
Customer ID
ATS decay rate
Points drifted toward baseline 50 per 6 h cycle. Range 0–20.
Decay pts / cycle2
Interval: 6 h (fixed — requires restart to change)
Cache TTLs
Redis score cache expiry. Takes effect immediately for new lookups.
Fraud score TTL300 s
Device score TTL600 s
Traffic daemon
Adapters
Loading…
A/B experiment
Loading…
Attack scenarios
Tor exit
Burst
New device
Breach
VPN
High T4
New A/B experiment
Experiment ID
Name
Treatment split %
Treatment policy (full decisions config)