Extend the detector: add a signal, a cross-check, or a hard rule

Quadrant: How-to (task-oriented). Audience: Blue detection engineers and contributors adding coverage to their own engine.

Warning: This page is defensive-only, local-only, and self-target-only. It teaches you to extend the detection catalog of the engine you build and run on your own loopback (127.0.0.1), validated against automation simulators you launch yourself. It is not third-party evasion guidance and gives you nothing to point at a host you do not operate. Every number you produce here is reference-measured on your machine — it describes your run, not a guarantee about live traffic.

This is the procedure for adding one unit of coverage — a signal, a cross-check, or a hard rule — to the standalone detection engine (internal/scoring, internal/signals) so it fires, scores honestly, and shows up truthfully in the Observatory and the reference tables. It assumes you can already build and run the engine and drive a red profile against it. If you cannot yet, do Run the detection engine first.

For the model you are extending — layers, dedup, noisy-OR, the hard-rule table, and the ScoreTrace — read Detection engine internals alongside this page. The verdict tables you will update at the end live in Hard rules, verdicts and signal-ID reference.

The full procedure, end to end:

flowchart TD
  S1["1. Choose layer + (layer, group)"] --> S2["2. Add the signal (internal/signals)"]
  S2 --> S3["3. Cross-check (L6, x.* namespace) if it is a contradiction"]
  S3 --> S4["4. Promotion rule as data (promotionRules) — optional"]
  S4 --> S5["5. Guard the FP path (applyFPMitigation damp)"]
  S5 --> S6["6. Do not fork trace logic (golden test)"]
  S6 --> S7["7. Validate end to end (Observatory + Docker e2e)"]
  S7 --> S8["8. Update the reader surface in lockstep"]

Before you start

  • You have the engine building and running on loopback: bin/server.exe -addr 127.0.0.1:8443 -web web (and, for client tells, the rebuilt web/detector.wasm). See Run the detection engine.
  • You can run the red harness: node test/e2e/runner.mjs. See Red-team catalog.
  • You know which layer your evidence belongs to (L1–L7) and whether it is collected in the browser (js/wasm) or on the server. If not, read Detection engine internals first.

Note: Everything below changes the standalone engine (the one the tests and the Observatory exercise). Because Gate embeds the same internal/scoring/internal/signals engine, coverage you add here is the coverage the proxy runs at the edge. You do not edit two engines — see Which piece am I using?.

Step 1 — Choose the layer and the group

Every signal carries a layer (L1–L7) and an id of the form l{n}.{group}.{item} (lowercase; layer tokens are uppercase L1–L7, but signal IDs are lowercase). The group is not cosmetic — it drives dedup.

During combination, dedupGroups keeps the highest-scoring signal per (layer, group) correlation group and drops the rest. So the rule is:

  • Signals that share a root cause must share a (layer, group). Two tells that are really the same underlying fact (for example, two different reads of the same webdriver leak) belong in one group so they cannot double-count into the score.
  • Independent tells must use distinct groups (or an empty group). An empty group means the signal always stands alone and is never deduped against a sibling.

Pick the group deliberately. Putting an independent tell in an existing group silently suppresses it whenever a stronger sibling fires; putting a correlated tell in its own group inflates a single root cause into two contributions.

Step 2 — Add the signal

Mint the id and set its fields honestly. The signal schema is in internal/signals/signals.go:

  • id — lowercase l{n}.{group}.{item}, e.g. l1.cdp.proxy_leak.
  • layerL1..L7.
  • weight — the maximum contribution, 0..100. This is the ceiling this signal can ever add.
  • confidence — 0..1, how sure you are the observation means what you think.
  • verdict — the per-signal verdict (OK SUSPICIOUS BOT UNKNOWN), distinct from the request verdict. SUSPICIOUS-or-BOT is what fired(id) reads; BOT alone is what firedBot(id) reads (this matters in Step 4).
  • collectedjs wasm server, where the evidence was gathered.
  • plus value, expectedHuman, score, notes.

Set weight and confidence honestly. The per-signal score is weight × severity × confidence, clamped to 0..weight — with severity fixed by the per-signal verdict (BOT = 1, SUSPICIOUS = ½, OK/UNKNOWN = 0):

\[s_i = w_i \cdot \sigma(v_i) \cdot \operatorname{clamp}_{[0,1]}(c_i)\]
  • Low confidence means a weak weight, never a penalty. A tell you are unsure about gets a small ceiling; it does not push the score negative and it does not get promoted by wishful weighting.
  • The layer cap (LayerCap = 60) and noisy-OR mean an over-weighted signal cannot dominate a layer on its own — but honest weighting is still your job, because the golden and e2e checks in Steps 6–7 will catch a weight that flips a human baseline.

Emit it from the right place:

  • L1–L4 client tells (navigator/CDP/UA/window, integrity/guard, mouse/key/event) are collected in the browser and emitted from cmd/wasm (collected: wasm or js), then carried in the client.signals[] of the SessionReport.
  • L5/L6 signals (traffic/RIT/correlation/abuse; cross-checks) are computed on the server path (collected: server) and land in network.signals[] / crosschecks[].

Step 3 — Add a cross-check (L6), if the tell is a contradiction

If your evidence is not a single observation but a contradiction between two claims — “the UA says Chrome but the TLS fingerprint says something else” — model it as a cross-check, not a plain signal. Cross-checks are the L6 family and carry the x. group prefix (e.g. x.ua_vs_ja4, x.ua_vs_h2, x.browser_no_js, x.uach_present) — not l6.x.*.

A CrossCheck (see internal/signals/signals.go) has: id (in the x. namespace), inputs, claim, observed, consistent (bool), weight, score. Fill inputs/claim/observed explicitly and set consistent = false when the two disagree. That is exactly what a hard rule reads through crossFail(id) in Step 4 — a cross-check that does not set consistent cannot be promoted.

Step 4 — Author a promotion rule as data (optional)

A signal or cross-check contributes to the risk score on its own. You only add a hard rule when a specific combination should promote the verdict regardless of the score. Hard rules are data, not code branches: append a promoRule{id, verdict, pred, why} to the promotionRules list in internal/scoring/hardrules.go.

Build pred from the predicate helpers:

  • fired(id) — a signal with that id is SUSPICIOUS-or-BOT.
  • firedBot(id) — that id is BOT only.
  • crossFail(id) — a named cross-check came back consistent == false.
  • context: hasClient, browserClaim, combined.

Choose the verdict deliberately:

  • DENY only for a combination you are confident is automated — typically two independent tells (an artifact plus a webdriver/CDP tell), so a single false read cannot promote to a block.
  • CHALLENGE for a heuristic. Any rule that can catch a real human must be CHALLENGE, never DENY. The existing heuristics HR-10, HR-11, HR-12, HR-17 are all CHALLENGE for exactly this reason (e.g. zero-interaction and datacenter-ASN can describe some real people).

Place the rule by precedence, not number. promotionRules is evaluated in first-match order, which is not numeric order (see the ordered table in Hard rules, verdicts and signal-ID reference). Insert your rule where its verdict should win relative to the rules around it — a broad CHALLENGE placed above a specific DENY will shadow the DENY.

Write a plain-language why field. It is not an internal note: it publishes to the Observatory trace and to the hard-rules reference table. Name the mechanism in Blue voice (“a CDP leak together with any automation hint”), never third-party evasion framing, and never an internal spec identifier.

Note: Give your rule the next id in the engine-plane sequence for its plane. The detection engine evaluates HR-1..HR-21 only. HR-22..HR-30 are Gate-plane rules (proxy edge: bans, rate-limit, smuggling, token) that this engine never evaluates and the Observatory never shows — do not reuse or extend into that range here.

Step 5 — Guard the false-positive path

Before you ship a DENY, decide whether the honest move is a damp, not a promotion. applyFPMitigation runs before combination and reduces scores that a legitimate-but-unusual client would otherwise trip. It is a damp, not a promotion — the worked example is the privacy-browser case, where canvas/webgl scores are halved so a privacy browser’s blocked-fingerprinting reads do not stack into a bot score.

Use applyFPMitigation (in internal/scoring) when your new signal will also fire for a legitimate configuration — a privacy browser, a hardened extension, an older device. Damp it there instead of, or in addition to, weighting it down.

Then check your change against the baselines before shipping any DENY:

  • Confirm the privacy-browser and old-device baselines still score ALLOW (or at worst CHALLENGE), not DENY.
  • Remember the metric asymmetry: classify() scores a human CHALLENGE as TN, so humanFPR is DENY-only and under-reports friction. Inspect the human baseline’s challenge-rate separately — a rule that starts challenging real humans will not show up in humanFPR.

Step 6 — Do not fork the rule logic (the divergence guard)

The Observatory trace must show what the engine actually did. Engine.ScoreWithTrace shares assemble()/decide() with Score() and uses combineTrace, which reuses the same dedupGroups keep-rule, the same noisy-OR, and the same promotionRules table. Because of that, the golden test asserts trace.Score == Combine and ScoreWithTrace == Score.

So: add your signal, cross-check, and rule in one place (the shared data/logic) and let both paths read it. Never add a special case to the trace path — that is exactly what the golden test exists to catch. Run it after every change to the scoring logic.

Run the golden trace-equivalence tests (they assert trace.Score == Combine and ScoreWithTrace == Score):

go test ./internal/scoring/ -run 'ScoreWithTrace|CombineTrace'

Step 7 — Validate end to end

  1. Watch it fire in the Observatory. With the engine running dev-gated (HMN_PLAYGROUND=1) on loopback, launch the matching bundled profile and open its GET /playground/explain/{id} trace. Confirm your signal appears with the score you expect, that dedup kept/dropped the right sibling, and — if you added a rule — that it shows as matched/won with your why text. See Detection Observatory and Observatory architecture.

    Note: If no profile exercises your tell yet, add one first — see Write a red profile. Adding a profile has three registration points (the runner.mjs PROFILES array, the launchProfiles allowlist in cmd/server/launch.go, and the Observatory catalog in web/playground.html); missing any one makes it silently absent.

  2. Run the Docker e2e harness (authoritative).

    make e2e
    # or faster: make e2e-quick
    # or only the catalog: make up && make attack && make e2e-assert
    

    Host node test/e2e/runner.mjs is for local debugging only — not completion authority.

  3. Confirm the trade-off is honest. Your change is good only if botTPR improved without raising humanFPR, and without a spike in the human baseline’s challenge-rate (Step 5). If a bot profile that should now be caught still passes (FN), re-check your group choice (Step 1) and per-signal verdict (Step 2 — fired vs firedBot). If a human baseline flipped to DENY, prefer a damp (Step 5) or a CHALLENGE (Step 4) over the DENY.

Step 8 — Update the reader surface in lockstep

A rule that fires but is undocumented is a drift bug. When you add or change an engine hard rule:

  • Add the row to the engine-plane hard-rule table in Hard rules, verdicts and signal-ID reference — id, verdict, the why, and the signal/cross-check IDs it fires on.
  • Keep it on the engine plane: HR-1..HR-21 only, no HR-22..HR-30.

Important: Reader-facing pages must never carry an internal source-of-truth identifier. Name the signal IDs (l{n}.{group}.{item}), the layer tokens (L1–L7), the cross-checks (x.*), and the hard rule (HR-N) — nothing else.

Checklist

  • Layer and (layer, group) chosen so correlated tells share a group and independent tells do not.
  • Signal id lowercase l{n}.{group}.{item}; weight/confidence honest (low confidence → weak weight, no penalty); per-signal verdict set; emitted from cmd/wasm (L1–L4) or the server path (L5/L6).
  • Cross-checks in the x. namespace with inputs/claim/observed/consistent set so crossFail() can read them.
  • Any hard rule appended as a promoRule to promotionRules, verdict chosen deliberately (human-catching heuristic ⇒ CHALLENGE), placed by precedence, with a plain why.
  • FP guarded: applyFPMitigation damp added where a legitimate client would trip; privacy-browser and old-device baselines re-checked before any DENY.
  • Golden test run (no forked trace logic): trace.Score == Combine, ScoreWithTrace == Score.
  • End-to-end (Docker): fires in the Observatory; make e2e / make attack shows botTPR up, humanFPR flat, challenge-rate on the human baseline inspected.
  • Reader surface updated in lockstep: engine-plane row in hard-rules-verdicts.md + derivation-index row; no internal spec IDs on reader pages.