Diátaxis quadrant: Explanation. Audience: Blue detection engineers extending or debugging the engine, plus advanced evaluators who want to predict a score before running the request.
Inside the detection engine: signals, scoring math and the score trace
This page explains how our own detector reaches a verdict. It is defensive documentation: the goal is that a Blue engineer can look at a set of collected signals and reason forward to the risk number and the request verdict — and, when the two disagree with intuition, know exactly which rule or which cap did it. It is not evasion guidance, and it does not describe how to defeat any third party’s product. Everything below is a description of code we control: internal/signals, internal/scoring and the policy in policy.go.
The engine has a deliberately small number of moving parts. If you hold four ideas in your head — the signal schema, the two kinds of verdict, the exact combine pipeline, and the enforcement-rule table — you can predict most outcomes by hand.
The signal schema
Every observation the engine makes, whether it was collected in the browser by the WASM client, in page JavaScript, or on the Go server, is normalised into one shared Signal struct (internal/signals/signals.go). One schema, two producers: the WASM client and the Go server emit the same shape so that the scorer never has to care where a signal came from.
| Field | Type / domain | Meaning |
|---|---|---|
id |
lowercase dotted l{n}.{group}.{item} |
Stable signal identity, e.g. l1.cdp.proxy_leak. The leading l{n} mirrors the stage. |
layer |
one of the seven legacy stage values | Which named detection stage produced it. Treat this as a machine value, not a reader-facing label. |
value |
any | The raw observed value. |
expectedHuman |
any | What a genuine browser would have produced, for contrast. |
verdict |
OK | SUSPICIOUS | BOT | UNKNOWN |
The per-signal verdict (see below). |
weight |
0..100 |
Maximum score this signal may contribute. |
score |
0..weight |
The actual contribution after severity and confidence. |
confidence |
0..1 |
How sure the collector is about value. |
collected |
js | wasm | server |
Producer that emitted the signal. |
notes |
string | Free-text teaching / debugging context. |
Note:
weightis a ceiling, not the contribution. A signal withweight = 40can still contributescore = 0if its severity or confidence is low. Never readweightas “how much this fired for.”
The two verdicts, and why fired() vs firedBot() is load-bearing
The word “verdict” is used for two different things, and conflating them is the most common source of confusion when predicting an outcome.
- Per-signal verdict — the
verdictfield above:OK,SUSPICIOUS,BOT, orUNKNOWN. It describes one observation. - Request verdict — the engine’s final decision for the whole request:
ALLOW,CHALLENGE, orDENY. It is what the caller acts on.
The enforcement-rule predicates bridge these two, and they distinguish between them on purpose:
fired(id)is true when the signal with thatidisSUSPICIOUSorBOT.firedBot(id)is true only when the signal isBOT.
This is load-bearing. An enforcement rule can name signals that are all “present” in the collected set and still fail to fire, because a rule written with firedBot(...) needs those signals at BOT severity, not merely SUSPICIOUS. So “all the named signals are there” is not sufficient to predict a DENY — you must check the severity each predicate demands. When you are debugging a rule that “should have matched but didn’t,” check firedBot vs fired first.
Consistency checks, and why they run first
The consistency-check stage does not measure automation likelihood directly; it measures agreement between stages. A cross-check (CrossCheck schema) has its own x. identifier namespace and this shape:
| Field | Meaning |
|---|---|
id |
x. namespace, e.g. x.ua_vs_ja4, x.ua_vs_h2, x.browser_no_js |
inputs |
the signals/claims being compared |
claim |
what the client asserts (typically from the UA) |
observed |
what the lower layers actually measured |
consistent |
bool — do claim and observation agree? |
weight |
maximum contribution |
score |
actual contribution |
Examples: x.ua_vs_ja4 compares the User-Agent’s claimed engine against the JA4 TLS fingerprint; x.ua_vs_h2 compares it against the HTTP/2 fingerprint; x.browser_no_js compares a browser UA claim against the presence of any client-side JS evidence.
Cross-check first is the governing principle here: a disagreement between stages outweighs any single-stage automation score. A client can look perfectly human on every individual axis and still be caught because its axes contradict each other — a user-agent header claiming Chrome while the encrypted-connection and HTTP/2 fingerprints resolve to a different engine is stronger evidence than one uncertain score. This is why consistency checks carry real weight and why several enforcement rules depend on cross-check failure.
From a raw signal to a scored contribution
Each signal’s contribution is computed the same way, before any combination happens:
score = clamp(weight × severity × confidence, 0, weight)
weightis the ceiling from the schema.severityscales by how farvaluediverges fromexpectedHuman(and maps to the per-signal verdict).confidenceis the collector’s certainty.- The result is clamped to
0..weight, so a signal can never exceed its own ceiling.
This is why two signals with identical weight can contribute very differently: the one collected with low confidence, or whose value only mildly diverges from the human baseline, lands lower.
The combine pipeline, exactly
Combination happens in internal/scoring/combine.go, in this exact order. ScoreWithTrace reuses the same steps through combineTrace, so the trace you read in the Observatory is the real pipeline, not a reconstruction.
dedupGroups— correlated signals are grouped by(layer, group). Within each correlation group, only the highest-scoring signal is kept; the rest are dropped so that one phenomenon observed three ways does not count three times. Signals with an empty group are uncorrelated and each stand alone.capByLayer— within each stage, the surviving signals accumulate by noisy-OR, then the per-stage probability is clamped toLayerCap = 60(0.60). No single layer may contribute more than 60% on its own.- Final noisy-OR across layers — the capped per-stage probabilities combine into the risk score:
risk = 100 × (1 − Π(1 − p_layer))
The same, formally
Each signal contributes a score bounded by its own weight — severity zeroes out non-bot verdicts, confidence scales it, and the result can never exceed the weight ceiling:
\[s_i = w_i \cdot \sigma(v_i) \cdot \operatorname{clamp}_{[0,1]}(c_i), \qquad \sigma(\text{BOT})=1,\ \sigma(\text{SUSPICIOUS})=\tfrac{1}{2},\ \sigma(\text{OK})=\sigma(\text{UNKNOWN})=0\]Correlated signals are de-duplicated to the strongest per group $g$, converted to a probability, noisy-OR’d within their layer, and capped at LayerCap:
The seven capped layer probabilities combine by a final cross-layer noisy-OR into the 0–100 risk:
\[\text{risk} = 100 \cdot \left(1 - \prod_{\ell = 1}^{7} \left(1 - p_\ell\right)\right)\]Because every stage is a noisy-OR of values in $[0,1]$, risk is saturating: more tells always raise it, but never past 100, and the per-stage cap keeps any single layer from dominating.
flowchart LR
S["scored signals<br/>sᵢ = wᵢ·σ(vᵢ)·cᵢ"] --> D["dedup by (layer, group)<br/>keep max per group"]
D --> C["per-stage noisy-OR<br/>then cap at LayerCap = 60"]
C --> N["cross-layer noisy-OR<br/>risk = 100·(1 − Π(1 − p_ℓ))"]
N --> R{"risk 0–100"}
R -->|"0–29"| A([ALLOW])
R -->|"30–69"| H([CHALLENGE])
R -->|"70–100"| Y([DENY])
Rule["Enforcement rule matched"] -. "raise verdict" .-> Y
Worked example: a consistency-check stage at 84%, clamped to 60%
Suppose the consistency-check stage produced two checks that both fired, each contributing a probability of 0.60:
x.ua_vs_ja4→0.60x.ua_vs_h2→0.60
Their within-layer noisy-OR is:
p_L6_raw = 1 − (1 − 0.60)(1 − 0.60)
= 1 − (0.40 × 0.40)
= 1 − 0.16
= 0.84
That is 84%. But LayerCap = 60, so capByLayer clamps it:
p_L6 = min(0.84, 0.60) = 0.60 (capHit = true)
Now let one static client inspection signal contribute 0.30. The final combine multiplies the complements of the capped per-stage probabilities:
risk = 100 × (1 − (1 − p_L6)(1 − p_L1))
= 100 × (1 − (1 − 0.60)(1 − 0.30))
= 100 × (1 − (0.40 × 0.70))
= 100 × (1 − 0.28)
= 72
Risk 72. Under policy 1.0.0 that is DENY (see thresholds below). Note two things you can now predict by hand: the raw 0.84 never reaches the final product — the cap does its job first — and the stage still enters the product at its ceiling of 0.60, which combined with a modest static client inspection stage is already enough to cross the deny line.
False-positive mitigation runs before combination
applyFPMitigation runs before the combine pipeline. It is a damp, not a promotion: it can only lower scores, never raise them. For example, when the client looks like a privacy-hardened but genuine browser, canvas and WebGL divergence scores are halved before they enter dedupGroups, because those axes are noisy for privacy browsers and we would rather lose a marginal detection than mislabel a real person.
Note: This ordering is deliberate and tied to the low-false-positive principle. Because the damp happens first, every downstream number — the deduped keep, the stage cap, the final noisy-OR — already reflects the mitigation. If you are predicting a score for a privacy-browser session, halve the affected inputs before you run the arithmetic above.
The enforcement-rule table as data
enforcement rules live in internal/scoring/hardrules.go as a list of promotionRules, each {id, verdict, pred, why}. They are data, not scattered if statements, which is why they can be enumerated, tested, and published verbatim.
They are evaluated in the first-match precedence order shown in the table below. The order intentionally differs from the legacy machine identifiers.
The first rule whose predicate matches wins; evaluation stops there. So disabled developer console plus automation evidence rule is checked before missing browser evidence rule, and the CHALLENGE heuristics (missing browser evidence rule, missing or replayed request-integrity token rule, no interaction observed rule, datacenter-network browser rule) sit late so a stronger DENY can pre-empt them.
Predicate helpers available to a rule’s pred:
fired(id)— signal isSUSPICIOUSorBOT.firedBot(id)— signal isBOTonly.crossFail(x.id)— the named cross-check is inconsistent.- Context:
hasClient(any WASM/JS signal was collected at all),browserClaim(the UA claims a real browser),combined(the combined risk score).
The one-line why field is the teaching text. It is not a comment — it publishes to the Observatory’s “Why this verdict” panel and to the enforcement-rule reference page, so it is written to be read by a human predicting or auditing a verdict.
Warning: This page documents the Core scoring-engine plane only. Gate edge protections run outside this table. Some legacy machine identifiers overlap across those planes, so never interpret a raw number without its event type and plane.
Engine-plane enforcement rules (the Core enforcement-rule set)
Listed in precedence order. verdict is what the rule promotes to on match.
| Order | Rule | Verdict | why (teaching text) |
|---|---|---|---|
| 1 | hard automation artifact rule | DENY | Hard automation artifact present (Selenium / Puppeteer / Playwright). |
| 2 | browser and network-engine mismatch rule | DENY | UA claims a browser but TLS and HTTP/2 both resolve to a different engine. |
| 3 | synthetic-event plus browser-driver evidence rule | DENY | Synthetic (untrusted) events combined with a webdriver / Chrome DevTools Protocol tell. |
| 4 | WebDriver concealment rule | DENY | Webdriver flag together with a patched native getter hiding it. |
| 5 | request-integrity failure plus protocol mismatch rule | DENY | request-integrity token tamper / invalid HMAC together with a TLS engine mismatch. |
| 6 | runtime-hook plus automation evidence rule | DENY | Runtime hooking / prototype pollution with a webdriver / Chrome DevTools Protocol tell. |
| 7 | headless browser plus another automation indicator rule | DENY | Headless browser plus a second automation indicator. |
| 8 | stealth browser modification rule | DENY | Patched native getter in a chromeless / headless window (stealth tool). |
| 9 | browser-control leak plus automation evidence rule | DENY | A Chrome DevTools Protocol leak together with any automation hint. |
| 10 | disabled developer console plus automation evidence rule | DENY | Disabled Console API (Patchright) with a chromeless / automation tell. |
| 11 | missing browser evidence rule | CHALLENGE | Heuristic: no client-side (WASM / JS) signals at all. |
| 12 | request-body integrity failure rule | DENY | request-integrity token that fails the body HMAC (request-body tamper). |
| 13 | in-session protocol-fingerprint rotation rule | DENY | TLS fingerprint rotated within a single session. |
| 14 | browser claim without execution evidence rule | DENY | Browser UA with zero client-side JS evidence (HTTP parrot). |
| 15 | automated-browser interaction signature rule | DENY | AI browser-agent signature (teleport click plus a second agent / Chrome DevTools Protocol tell). |
| 16 | cross-session correlation rule | DENY | One fingerprint across many subnets (residential-proxy rotation), or a proof of work solved implausibly fast. |
| 17 | Hypertext Transfer Protocol version 2 denial-of-service protection rule | DENY | HTTP-2 DoS only: Rapid Reset / CONTINUATION flood. (Application-layer flood and credential-stuffing velocity are handled by score-based CHALLENGE + the rate-limit ban ladder, not this rule.) |
| 18 | multi-axis identity rotation rule | DENY | Multi-axis rotation (UA changed together with TLS / IP rotation). |
| 19 | missing or replayed request-integrity token rule | CHALLENGE | Heuristic: a replayed or absent request-integrity token on an API call. |
| 20 | no interaction observed rule | CHALLENGE | Heuristic (can catch some humans): zero interaction over the observation window. |
| 21 | datacenter-network browser rule | CHALLENGE | Heuristic: a consistent browser from a datacenter ASN. |
| 22 | advanced browser residual rule | CHALLENGE | Audio-rate, graphics-processor, or mobile-versus-desktop evidence remains inconsistent near the coherent-browser boundary. |
| 23 | Chromium-container residual rule | CHALLENGE | A Chrome claim lacks Widevine, media devices, and voices in the specific combined pattern. |
| 24 | network residual policy rule | CHALLENGE | Operator policy promotes configured proxy, virtual-private-network, Tor, or source-spoofing residuals; these signals remain score-exempt. |
Note: The heuristic CHALLENGE rules (missing browser evidence rule, datacenter-network browser rule, no interaction observed rule, missing or replayed request-integrity token rule) are explicitly softer and can occasionally match genuine humans — no interaction observed rule in particular. They sit later in precedence so that any confident DENY pre-empts them, and they promote only to CHALLENGE, never DENY.
Score thresholds (policy 1.0.0)
When no enforcement rule matches, the request verdict comes from the combined risk against policy.go thresholds:
| Risk | Verdict | Constant |
|---|---|---|
0–29 |
ALLOW | below ChallengeAt |
30–69 |
CHALLENGE | ChallengeAt = 30 |
70–100 |
DENY | DenyAt = 70 |
An enforcement rule overrides these thresholds: a matched DENY rule denies even if the score is low, and a CHALLENGE rule raises an otherwise-ALLOW score to CHALLENGE.
The proof of work boundary
The signal l7.pow.solved (a valid proof-of-work) has a narrow, one-directional effect: it upgrades only a score-based CHALLENGE to ALLOW. It never overrides an enforcement-rule verdict.
So a request that landed at CHALLENGE purely because its risk was in the 30–69 band can clear to ALLOW by solving the proof of work. But a request denied by, say, hard automation artifact rule or held at CHALLENGE by no interaction observed rule is unaffected — the proof of work cannot buy its way past a rule verdict. (And note the other direction: under cross-session correlation rule a proof of work solved implausibly fast is itself a DENY signal.)
The ScoreTrace: the observable form of the decision
The same decomposition is shown to the visitor on /demo — each stage’s contribution, side by side, under the final verdict:
internal/scoring/scoretrace.go exposes Engine.ScoreWithTrace. It shares assemble() and decide() with the production Score() path and runs the same combineTrace (which reuses the identical dedupGroups keep-rule, noisy-OR, and promotionRules table). It records what the engine did without changing the decision.
Trace shapes, mapped to their Observatory panel:
| Trace shape | Fields | Observatory panel |
|---|---|---|
ContribTrace |
{id, layer, group, score} |
“Signals” — every scored contribution that entered combination. |
DedupTrace |
{layer, group, keptId, droppedId, keptScore, droppedScore} |
“Correlation” — which signal won its (layer, group) group and which was dropped. |
LayerTrace |
{layer, rawProb, cappedProb, capHit} |
“Layers” — per-stage noisy-OR before and after the LayerCap clamp; capHit flags the 84%→60% case. |
HardRuleEval |
{rule, verdict, why, matched, won} |
“Why this verdict” — every rule evaluated in precedence order, which matched, and which one won. The why string is shown verbatim. |
The golden invariant
A golden test asserts:
trace.Score == Combine
ScoreWithTrace == Score
That is: the traced score equals the plain combined score, and the traced verdict equals the production verdict. This is what lets the Observatory’s “Why this verdict” panel show exactly what the engine did — the trace is not a parallel re-implementation that can drift. The production Score() path is untouched by tracing.
Note: When you extend the engine, keep this invariant green. If
ScoreWithTraceandScorediverge, the panel is lying to whoever is debugging a verdict, which defeats the entire point of the trace.