Rules, checks and verification flows — contract

This is the implementation contract of abusend's decision logic: how a verdict is decided, how checks (verification tools) are run and how the SDKs drive them. Backend, SDKs, panel and docs are built against this file; change it first when the contract changes. The customer-facing guide is INTEGRATION.md; the edge API schema is api/openapi.yaml (served at /v1/openapi.yaml); the product story is docs/PIVOT.md.

1. Concepts

Concept Meaning
Action A protected flow (login, topup): a label with token required · optional and token_missing review · deny. Discovered from traffic (or added by hand). Actions have no mode: everything below applies on every action, configured or not.
Signal Something abusend measures: TLS/JA4/H2, probe, browser, IP (datacenter, VPN, Tor, relay, country, ASN), device, velocity. Signals are rule fields and hints, not verdicts.
Risk A derived signal: risk.score 0–100 and risk.level low · medium · high · critical · unknown from the built-in weighted signal definitions (internal/engine/signals.go, weights editable per project) and the project's level thresholds (§2.4). It never decides by itself.
Context What the customer's server sends: user.attributes (persistent) and context (this request: amount, currency, plan). Typed.
Rule when <condition> → allow · review · deny · challenge(check group); ordered; optional action scope; state Off · Monitoring · Enforcing (enabled + mode monitor · enforce). A rule's state is the only monitor/enforce switch: an enforcing rule decides in every action of its scope, including one seen for the first time; a monitoring rule only reports what it would do.
Check A connected verification tool. Widget: turnstile, recaptcha (v3 / v2 invisible / v2 checkbox), recaptcha_enterprise, hcaptcha, friendly_captcha, geetest — run by the browser SDK (invisible, or visible in the SDK's verification dialog), verified by the edge — and abusend's own abusend_pow · abusend_hold · abusend_trace (no site key, no secret, no third party: the edge verifies them itself, §3.1). App: sms, email, kyc, custom — run by the customer's app, reported by its server.
Check group What a challenge rule asks for: 1–3 distinct checks and a mode — any (one of them, picked at random by weight when it is issued), sequence (all, one after another) or parallel (all at once). A single check is a group of one (any). See §2.3.
Challenge One issued verification of a check: pending → passed · failed · incomplete · error (a pending challenge past its expiry is incomplete).
Flow Verdict → challenge(s) → final verdict of one attempt. At most 3 challenges, counting every issued one (a parallel group's included); each check once.

Vocabulary (API → UI): allow Allow · review Your app decides · deny Block · challenge + check Verify with ‹check› (groups: "Verify with Captcha or hCaptcha (70/30)", "… with Captcha, then SMS", "… with Captcha and SMS at once") · check Check · challenge Verification · action Action · rule states Monitoring / Enforcing / Off (rules only) · signal Signal (hint). Developer-facing names use challenge; "Verification" is only a UI label.

2. Verdict evaluation

2.1 Inputs

The token's assessment (signals), the user (stored attributes merged with the request's), the context, the presented challenges (if any) and their flow, the project's rules in priority order (with their states), the action's token requirement and the verdict time.

2.2 Built-ins, before any rule

  1. Token: reused, user_mismatch, invalid, project_mismatch, expired → deny (decided_by.type = token), whatever the rules' states. The redemption is one atomic script on the token's hot record in Valkey (DESIGN §5.2): the first user to redeem binds the token, a repeated verdict is a retry only within 5 s of the first redemption, at most 3 redemptions, for the same action and client_ip; user_mismatch wins over reused. A token whose record is gone (its token expired, or Valkey lost it) is invalid; a state store that cannot be reached fails the verdict (503 state_unavailable), it never skips the check. Durability, a documented weakening of single use: Valkey persists with an append-only file synced every second (appendfsync everysec), so a redemption made in the last second before a Valkey crash can be lost and the same token redeemed once more within its lifetime (at most its 10 minutes by default, 1 hour at most); a record that was lost is invalid instead (the safe side). A token is still never valid twice at the same moment and a lost record never turns an unusable token valid.

  2. Block lists (users, IPs, devices) → deny, whatever the rules' states. block_devices applies to the device id the browser presented (or was issued), never to a device recognition merely linked it to (§2.6).

  3. Token missing: on a token: required action with token_missing = deny → deny. With review (the default) the rules still run (a withheld token never skips a check or a deny rule) and the token only floors their outcome: where they would allow, the decision is review decided by token (the would-decision is floored the same way). On a token: optional action the rules run with token.status = "missing", probe.status = "none", risk.level = "unknown" (IP signals come from client_ip).

  4. Presented challenge_ids (0–3 ch_… ids; the SDKs forward the comma-separated X-Abusend-Challenge; more than 3 or a malformed id → 400 invalid_request). Each id is checked on its own:

    • unknown, other action or user → no credit (info reason challenge_invalid);
    • issued with a device (a valid token) but the retry has no valid token or another device → no credit;
    • consumed by the open-challenge lookup (another tab) → no credit;
    • consumed by a verdict: within 5 s by the same caller (user, client_ip, device, action) → the stored response is replayed; otherwise deny challenge_reused;
    • passed more than 5 minutes ago and unconsumed → no credit (use-by).

    The remaining ids must belong to one flow: the flow of the latest-created presented challenge; ids of other flows give no credit (challenge_invalid). That flow is attached, then:

    • any challenge of the flow still pending → every pending challenge of the flow is returned again, reissued: true (not counted; SDK hooks do not run again; nothing is consumed) — whether its id was presented or withheld;
    • otherwise the flow's results are the flow state, and this verdict consumes every unconsumed challenge of the flow (a withheld one included): one set of passes buys one decision.

    Without a usable presented id → step 5.

  5. Open-challenge lookup (withholding the ids never helps): with no usable presented id, the latest unconsumed challenge of the same action and user (or, without a user, the same device), issued within the longest check timeout + 15 minutes and not passed, attaches its flow, with the same rules as step 4: pending challenges of the flow → re-returned (reissued); otherwise the flow's results count and all its unconsumed challenges are consumed (info reason challenge_attached). The lookup never attaches a flow through a passed challenge. Token-less verdicts without a user have no binding key; the network counters (§5) cover them.

  6. App issue cap (SMS pumping): an app challenge is not issued when the check's max_issued_per_hour (default 5) is reached for the user, else the device, else the IPv4 /24 · IPv6 /48 → the check's on_incomplete, decided_by.type = limit, code check_rate_limited, retry_after_seconds: 3600. The cap is per check: when an app check of a parallel group is capped, that check's on_incomplete decides and nothing of the group is issued.

Steps 4–6 run on every action (there is no action mode; only an enforcing rule issues a challenge). The attached flow is locked as a whole — every challenge of it, SELECT … FOR UPDATE in id order — so concurrent verdicts of one flow serialize; consuming, issuing the next challenges and recording the verdict happen in one transaction. A verdict that involves no challenge flow (nothing presented, no open challenge, none to issue) is recorded behind its answer instead (§8, invariant 10).

2.3 Rules (engine.Ruleset.Decide, a pure function)

matches   = enabled rules in scope whose condition is true
enforcing = matches of rules in state Enforcing (rule.mode = enforce)
1. any enforcing deny                                → deny
2. a check failed in this flow                       → its on_fail (deny; review only for score-based
                                                       widgets); several failed → the strictest (deny over review)
3. walk enforcing rules top → bottom (allow lists first):
     allow                                            → allow
     review                                           → review
     challenge(group)                                 → evaluate the group (below); satisfied → continue
4. nothing decided                                    → allow

A check of a group is satisfied when it passed in this flow or its pass is remembered (a remembered pass uses one skip). Unavailable: the check's circuit breaker is open or its secret is missing or unreadable. Issued counts every challenge the flow has issued so far.

any (one of these, random by weight):
  an item satisfied                                   → satisfied
  an item incomplete / error in this flow             → that check's on_incomplete / on_error
                                                        (never a switch to another check of the group)
  candidates = available items with weight > 0,
               else available weight-0 items (fallbacks, in group order)
  no candidate                                        → the first check's on_error (check_unavailable)
  issued ≥ budget                                     → review (challenge_limit)
  otherwise                                           → challenge: ask {any, candidates}
sequence (all, one after another), items in order:
  satisfied items are skipped; for the first other item c:
  c incomplete / error in this flow, or unavailable   → c.on_incomplete / c.on_error
  issued ≥ budget                                     → review (challenge_limit)
  otherwise                                           → challenge: ask {sequence, [c]}
  every item satisfied                                → satisfied
parallel (all at once):
  missing = items not satisfied; none                 → satisfied
  missing items incomplete / error / unavailable      → the strictest of their on_incomplete /
                                                        on_error (deny over review); nothing issued
  issued + len(missing) > budget                      → review (challenge_limit)
  otherwise                                           → challenge: ask {parallel, missing}

2.4 Conditions and fields

Every new project gets these two rules in Monitoring: they report what they would do (would_decision) and decide nothing until someone enforces them. Once they have would-hits, the to-do rule_ready (one per rule, as for any monitoring rule) suggests enforcing each once they look right.

Rule Outcome
Repeated failed or unfinished verifications: device.present ? device.challenges_not_passed_1h >= 5 : network.challenges_not_passed_1h >= 50 Block
High risk: risk.level in ["high", "critical"] Your app decides until a widget check exists; the to-do high_risk_without_check turns it into Verify with that check

No default rule blocks on risk alone ("Critical risk → Block" is a strict template).

2.6 Recognised devices (privacy.device_id = recognition)

Opt-in per project. Recognition links, it never merges: a browser that presents no valid device id (a private window, cleared storage) always gets its own new device id (cookie and localStorage as usual). When its keyed fingerprint print matches a device the project saw in the last 30 days, the assessment only records a probabilistic link to that device (assessments.recognized_device_id + a confidence; docs/DESIGN.md, "Device recognition"). Nothing ever writes a known device id into a new browser.

It is deliberately narrow, because look-alike browsers are common:

field type meaning
device.recognized bool no device id was sent; recognition linked this browser (its own new id) to a known device
device.recognition high · medium · "" confidence of the link
device.recognized_blocked bool the linked device (or a browser linked to it earlier) is on block_devices, or a user on block_users was seen with it
device.recognized_users number distinct users seen with the linked device (and the browsers linked to it) in the last 30 days
js.fingerprint_randomized bool canvas / audio fingerprints changed between identical renders (privacy browsers, private modes, anti-detect tools)

A link is a hint for your rules, never an automatic decision and never carried trust: every other device.* field (age, users, denies, blocked-user flag, allow and block lists, challenge_passed_recently, remembered passes) and user.new_device are the browser's own new id's. No built-in deny comes from a link — the linked device's block list entry only shows as device.recognized_blocked, which your rules can act on (e.g. device.recognized && device.recognized_blocked → Verify). Recognition can therefore never make an outcome worse than no recognition, except through your own rules on these fields; a private window of a blocked device is a new device, as without recognition. The fields are stored with the verdict (device.recognized_blocked, device.recognized_users as counters) for replay.

3. Checks

Field Meaning
key slug rules and SDK handlers use (captcha, sms)
kind, provider widget: turnstile · recaptcha · recaptcha_enterprise · hcaptcha · friendly_captcha · geetest · abusend_pow · abusend_hold · abusend_trace; app: sms · email · kyc · custom
config widget only: site_key (GeeTest: the 32-hex captcha_id), recaptcha_version (v3 · v2_invisible · v2_checkbox), gcp_project_id, threshold, appearance, region (Friendly Captcha: global · eu); abusend's own checks take only pow, strictness and appearance (no site_key; pow / strictness on another provider → 400 invalid_check)
config.appearance invisible (default, stored omitted: the widget runs in the background and only shows up when the provider wants an interaction) · visible (the browser SDK shows the widget — checkbox, puzzle — in its verification dialog). Turnstile, hCaptcha, Friendly Captcha, GeeTest: both; reCAPTCHA v2_checkbox, abusend_hold, abusend_trace: visible only (stored visible); reCAPTCHA v3 / v2_invisible, Enterprise and abusend_pow: invisible only. Anything else → 400 invalid_check
config.pow own checks: the proof-of-work puzzle's size as work (2^work hashes expected; each step doubles it), {mode: risk | fixed | off, fixed, low, medium, high, critical}, every size 10–26; default risk with 15 / 18 / 20 / 22 (low / medium / high / critical), fixed 18. risk takes the size of the verdict's risk.level when the challenge is issued (unknown, e.g. no browser token → low); off (no puzzle) only for abusend_hold / abusend_trace
config.strictness abusend_hold, abusend_trace: lenient · normal (default) · strict — the movement score an attempt needs: ≥ 0.3 · 0.5 · 0.7 (§3.1)
secret widget only, AES-GCM, AAD bound to (project, check), never returned; abusend's own checks have none (a secret → 400 invalid_check) and are always configured
remember_seconds 0–604800, i.e. up to 7 days (default 24 h widget / 0 app); remember_hours = whole hours, rounded down (older clients; accepted when remember_seconds is absent)
on_fail deny (review only for reCAPTCHA v3 / Enterprise; not for v2 invisible / checkbox)
on_incomplete review (widget default: ad blockers hit real users) · deny (app default: a real user retries)
on_error review · deny (never allow)
timeout_seconds widget 30–300 (120), app 60–1800 (600)
max_issued_per_hour app only, 1–100 (5)

3.1 abusend's own checks

Three widget providers the edge verifies itself: no site key, no secret, no provider script, nothing sent to a third party and no egress (internal/owncheck). The browser SDK (3.3.0) runs them from the challenge's params (§4) and answers /verify with a solution instead of a provider_token.

provider appearance what the browser does
abusend_pow invisible solves a proof-of-work puzzle in the background
abusend_hold visible in the verification dialog, presses and holds two dots in order until each ring fills (targets random per challenge, hold_ms 700–1200); the puzzle is solved meanwhile
abusend_trace visible moves the pointer or a finger around a 16:10 box until a bar fills (3–5 s of movement, random per challenge); the puzzle is solved meanwhile

4. Edge API

Endpoint Auth Body → answer
POST /v1/assess site key signals (sealed probe) → {token, expires_in, device_id}
POST /v1/verdict secret key {token?, action (required), user: {id, attributes}, context, client_ip, challenge_ids?, review_handling?, simulate?} → verdict (challenge_ids: 0–3 ch_… ids)
POST /v1/challenges/{id}/verify site key widget: exactly one of {provider_token}, {solution} (abusend's own checks only) or {error: script_blocked | timeout | widget_error | abandoned} (pending only; never passes an app check; body ≤ 192 KB) → {status}, for an own visible check also retry, missing
POST /v1/challenges/{id}/result secret key {status: passed | failed | abandoned, user_id} → {status}; 409 challenge_resolved, challenge_user_mismatch, challenge_kind_mismatch
GET/PATCH/DELETE /v1/users/{id} secret key user attributes, erasure
POST /v1/feedback secret key {verdict_id, label: fraud | legit, note} (labels the whole flow)
POST /v1/events secret key {events: [{user_id, type, value?, properties?, occurred_at?, id?}]} (1–100, all or nothing) → {accepted, duplicates}; 400 invalid_events, invalid_event, invalid_occurred_at, event_value_out_of_range (§5.1)

Verdict response:

{ "id": "…", "flow_id": "fl_…", "decision": "challenge", "would_decision": null, "would_challenge": null,
  "decided_by": {"type": "rule", "id": "…", "name": "Dormant account, big first top-up", "code": "rule_…"},
  "challenges": [{"id": "ch_…", "check": "sms", "name": "SMS", "kind": "app", "provider": "sms", "expires_in": 600, "reissued": false}],
  "risk": {"score": 12, "level": "low"},
  "reasons": [{"code": "rule_…", "label": "…", "kind": "rule", "effect": "challenge", "rule_id": "…"}, {"code": "ip_datacenter", "kind": "signal", "weight": 25, "label": "…"}],
  "signals": {"ip": {"address": "…", "country": "SG", "asn": 7473, "datacenter": false, "vpn": false, "tor": false, "relay": false},
              "client": {"kind": "browser", "label": "chrome", "ja4": "…", "ua_family": "chrome"}, "probe": "ok",
              "device": {"present": true, "id": "d1.…", "new": false, "users": 1}},
  "token_status": "valid", "user": {"id": "u_…", "attributes": {}}, "action": "topup", "test": false,
  "created_at": "…", "retry_after_seconds": null }

challenges holds 1–3 challenge objects when the decision is challenge (all to run now; several only for a parallel group) and is [] otherwise. would_challenge is {mode, checks} for a would-decision challenge, else null. Widget challenges add site_key, mode (turnstile, recaptcha_v3, recaptcha_v2_invisible, recaptcha_v2_checkbox, recaptcha_enterprise, hcaptcha, friendly_captcha, geetest_v4, abusend_pow, abusend_hold, abusend_trace), action, nonce, script_url, timeout_ms, appearance, ui and, for abusend's own checks, params:

428 contract (what protect() / guard() answer and Abusend.fetch understands, also for native/mobile clients): status 428, header Abusend-Challenge: 1, body {"error": "challenge_required", "challenges": [{…}]}. The client runs every challenge and retries with a fresh token and X-Abusend-Challenge: <id>,<id> (comma-separated, every id of the 428). /v1/challenges/{id}/verify and /v1/challenges/{id}/result stay per challenge.

/verify for abusend's own checks: the body carries solution = {pow: [n, …] (one nonce per puzzle, [] without a puzzle), telemetry?}; telemetry (the visible checks) is {v: 1, w, h (the box in CSS px), dpr, pt: mouse | touch | pen, ev: [[t, x, y, type], …] (≤ 3000; t in ms since the box was shown, x / y in px inside it, type 0 move · 1 down · 2 up), ut (events the page dispatched itself)}. A solution for another provider, or a provider_token for an own check → 400 invalid_request; more than one of provider_token, solution, error → 400 invalid_request. The answer is {status}; while a visible check has attempts left and the attempt did not pass: {"status": "pending", "retry": true, "missing": "hold_short"} (missing omitted when the task was done but did not convince).

5. Counters (fields)

From the challenges table, computed only when a compiled rule uses them: device.challenges_not_passed_1h, network.challenges_not_passed_1h (failed + incomplete), device.challenges_issued_1h, network.challenges_issued_1h, user.challenges_issued_1h. The network is the IPv4 /24 · IPv6 /48 (careful with shared networks: CGNAT, offices).

Velocity hints, not exact totals:

5.1 Events

What abusend cannot see — a completed top-up, payment, withdrawal — the customer's server reports after it happened with POST /v1/events (secret key; Node SDK abusend.events.track):

5.2 Shared counters

Everything above is one user's (or one device's, one network's). A counter adds up across users: every attempt at an action, or every reported event of a type, over a recent window, optionally per group.

6. Replay and simulation

7. SDK behaviour

8. Invariants

  1. Nothing fails open to allow: outages, 5xx (503 busy and 503 state_unavailable included: a token that cannot be redeemed is never treated as valid), 429 → review (or deny); on_fail / on_incomplete / on_error / token_missing are review or deny; the SDKs' onReview: "continue" never continues a review decided by a verification or a limit. An own check whose params are missing or unreadable is an error (on_error), never a pass.
  2. Denies always win: built-in token and list denies (whatever the rules' states) and enforcing deny rules beat any passed check and any allow rule.
  3. Withholding never helps: a caller that doesn't run a check, doesn't send a token, omits or forges a challenge id, or retries after a failure never does better than one that ran the check and didn't complete it. With groups: completing one of two parallel checks never satisfies the rule; withholding the other's id re-returns it while pending (and its outcome applies once it has one); retrying never re-draws an any pick and a failed pick never switches to another check; a flow's results are consumed once (every unconsumed challenge of it, withheld ones included). A browser that leaves its device id behind is a new device; recognition only links it (§2.6) and never lends it a known device's trust.
  4. App checks cannot pump SMS/KYC cost: re-returned challenges don't re-run hooks; issuing is capped per user / device / network.
  5. Challenges are bound to (project, action, user, device when issued with a token), consumed once (5 s replay for the same caller), at most the project's budget per flow (max_challenges_per_flow, 1–5, default 3) counting every issued challenge (a parallel group's included), each check once; the app issue cap applies per check; app results need the secret key and the matching user.
  6. Decide is pure; replay of a stored verdict with its rules (and the signal weights and risk thresholds compiled with them) reproduces it. The random pick of an any group is the service's, made when the challenge is issued and stored (picked) for replay.
  7. A passed widget check may outweigh a failed probe (the probe is a hint). Device recognition is a hint too: it links, never merges; a link never denies by itself, never carries trust and never changes the browser's own device id (§2.6).
  8. Test-project features (simulate, forced decisions, provider test keys, localhost origins) never work on live projects.
  9. Monitor is safe: new rules — the seeded baseline rules included, and rules drafted by templates, the assistant or MCP — start in Monitoring; the panel's rule editor saves as Monitoring unless Enforcing is picked. Enforcing a rule is always an explicit, confirmed step; a replay is offered in that confirmation but never required. A rule's state is the only monitor/enforce switch: newly discovered actions are labels and never change what applies. Watch (§9) creates its rules the same way and can do nothing more: it never enforces one.
  10. The verdict log is written behind the answer (log.async, default on). A verdict that involves no challenge flow is answered before its row is written; the row (ClickHouse's verdicts table, the only store of the verdict log), the end user's row change and the device link (PostgreSQL) are written by the pod's log writer within milliseconds (log.flush_interval, 10 ms; ClickHouse's own batch adds up to clickhouse.flush_interval, 1 s). Nothing that decides, consumes, issues, redeems or remembers is written this way: challenge issue, consumption, replay, results, token redemption (an atomic script on the token's record in Valkey), remembered passes, issue caps, erasure and every panel write stay synchronous and exact, and a request that presents challenge ids, finds an open challenge or has one to issue always takes the synchronous flow transaction (invariant 3 is unchanged; its verdict row goes to ClickHouse after the transaction commits). A pod that crashes loses the rows still queued (normally the last 10–20 ms of its plain verdicts, and what ClickHouse's writer had not inserted yet, about a second); a log queue that stays full for log.enqueue_wait answers 503 busy (an outage for the SDKs: the failure decision, never allow, invariant 1); ClickHouse's writer queue that stays full for the same time drops the log rows, counted (abusend_ch_dropped_total), without delaying the answer further. What reads the log (the panel's lists, replays) lags by the log's delay; attempts(), user.verdicts_1h and the counters are Valkey state written behind the answer as well. The answer never depends on the write, and the stored inputs are the ones the decision used (invariant 6). Erasure fences the log and ClickHouse's writer: rows of the erased user still queued on any pod are anonymised before they are written, every pod is told, and the erasure is repeated about 30 seconds later. What a queued verdict names (its assessment, the challenges of its flow, its device's recognition prints, the hot record in Valkey) is erased with the user although the masked verdict no longer names it. "Erased" means, precisely: the erasure is recorded (a pending_erasures row naming the user only by derived ids, never the external id) and its PostgreSQL part is done when the call answers 200; ClickHouse and Valkey are attempted in the same call, and whatever fails or is still running is retried by a leader job with backoff until every step succeeded (AbusendErasurePending alerts after 15 minutes). The API answer does not wait for it; completion is guaranteed, normally within a minute. The write-behind queues of other pods and Valkey writes still in flight on them are fenced by the erase notification and swept by the repeat. log.async = false writes the users and device links of every verdict synchronously.

9. Watch ("Nöbet")

Watch is continuous monitoring that notices abuse within minutes, lets the organization's own assistant (bring your own LLM key, the same one as the panel's assistant) investigate headlessly, and reports with proposals. It is off by default (settings.watch.enabled), per project. It never changes a live decision on its own: the only things it may do alone are add a Monitoring rule and create a counter that does not exist yet. The design (buckets, scanner, runs) is in docs/DESIGN.md "Watch"; the panel page in docs/PANEL.md §3.7.

9.1 What it does

  1. Traffic buckets. Every project's traffic is counted in 5-minute buckets per action (traffic_buckets): attempts (the first verdict of each flow, after its decision; verdicts decided by the token built-ins count too), how many were denied and challenged, and how many verifications failed or were left unfinished — in total and per country and per ASN of the client IP (a failed verification under the country and ASN the verdict that issued it saw, stored on the challenge). It is recorded whether or not watch is on, so switching it on starts with history. Buckets are kept 8 days. The scanner and the counter checks read them from ClickHouse rollups (summed per key), so a scan sees traffic a few seconds after the pods flushed it, like the shared counters.

  2. The scanner looks at each project with watch on about every five minutes (an atomic claim per project: two pods never scan it at once). No LLM is involved. The windows are the last 5–10 minutes (the previous bucket and the one in progress), 15 and 30 minutes; the bucket in progress counts partially, and every window is compared as a rate per minute over its real length. The baseline is the median of the same slot on the previous 7 days (needs at least 3 days with traffic recorded near that time); without it, the average of the two hours before the windows (needs at least 10 attempts, so a project that has just started has no "usual" yet and raises nothing). Detectors, all pure functions over the bucket series:

    Detector Fires when (sensitivity normal)
    volume_spike attempts in a window ≥ 50 and its rate ≥ 3× the baseline (10× for the last 5–10 minutes); a quiet action with a zero baseline fires at the minimum
    dominant_country, dominant_asn a key has ≥ 30 attempts and ≥ 30% of the window, and had < 5% in the baseline (unknown country / ASN 0 never; at most 3 per dimension and action)
    verif_failure_rate ≥ 20 challenges in the window, failure rate ≥ 30%, at least 25 points and 2× above the baseline (no baseline: ≥ 60%)
    deny_rate, challenge_rate ≥ 50 attempts, the share rose by ≥ 20 points and 2×, or a share that was ≥ 15% fell to a quarter (a rule or an integration may have broken)
    counter_near_threshold a counter group is at ≥ 80% of the literal threshold an enforcing rule compares it with (counter("k", "1h") > 100) and below it: a heads-up before the rule fires. Judged per group (the 50 largest groups of a grouped counter; at most three findings per comparison); the text and the brief name the counter, window, the dimension (an IP address, a user, …), value, the threshold and the rule — the threshold is not a "usual" value, and there are no attempts or baseline. While the same comparison already has a rule-applying episode open (below), none of its groups raises this finding and open ones close silently. Thresholds that are computed, or written on the left of the comparison, are not read

    A counter group at or over the threshold is not an anomaly: the rule is doing its job. It never wakes the assistant, is never escalated and is not in the daily digest. Watch tells you about it with short notes (a run record with trigger rule_active for the first note and the digests, resolved for the last, no investigation), and it does so per rule comparison, not per group:

    • One episode per comparison. The comparison is the counter, its window and the literal threshold of the enforcing rule (the first rule that reads the same comparison wins; a rule with two comparisons gets two episodes). All groups that are over the threshold at the same time (all the IP addresses of a burst, say) belong to one episode, so a burst of 200 IP addresses is one note, one run record and one e-mail, not 200.
    • The operator counts. > and >= are told apart: with > 20 a value of 20 is not over the threshold, with >= 20 it is.
    • What is read. The 50 largest groups of the counter over the window (MaxCounterGroups). When all 50 are over the threshold the list may be cut off, and one more query counts the groups at or over the threshold exactly, so the number in a note (group_count) is exact, while the examples (top_groups, highest first) are at most 50 in the run record and at most 5 in the note itself. A group that falls out of the 50 largest while it is still over the threshold is not "gone": the episode only ends when no group is over the threshold for two scans.
    • Kinds. started (the first note: the rule now applies to N values, the highest, up to five examples), grew (a digest: the number went up or there are new values, with how many there were at the last note and how many are new) and ended (two scans in a row with no group over the threshold; the number is the most it applied to at once). There is at most one note per episode per scan.
    • Cooldown. After a note, the next grew digest is due when the number of values grew or a listed value is new and the cooldown has passed: 30 minutes after the first note, then 1 hour, 2 hours, 4 hours, … (doubling with every note sent, at most once a day). Nothing is sent while nothing changed, however often the scanner looks. ended is not delayed by the cooldown.
    • At most 6 notes an hour per project (started, grew and ended together; the count is derived from the run records, so it survives a restart). A note over the limit is held: it is recorded as a run with status skipped and the error note_limit, nothing is mailed, and its cooldown starts again (a held start is sent later as started). The first note that is sent afterwards says how many notes were held. Notes count towards the five alerts per scan (§9.3).
    • An ended note whose start was never sent (it was held by the hourly limit) is not mailed: it is recorded as a run with status skipped and the error start_not_sent, and it uses none of the hourly limit.
    • Wording. Every note names what the counter is grouped by in words (ip.address → "IP addresses", user.id → "users", device.id → "devices", ip.country, ip.asn, network.prefix, action, client.ja4; any other expression → "values"), says what the rule does and that it is Enforcing, says whether anyone is blocked (a challenge or "your app decides" rule blocks nobody by itself), describes the counter in words, and gives the highest value and up to five examples. It says that it is a note, not an alarm, and what to do if the traffic is expected (raise the threshold or narrow the rule).
    • Rolling update. During a rollout an old pod may still send the old per-group notes once (at most five e-mails and the "N more" one); the entries of the old form are merged into one episode afterwards and are not announced again.

    The approaching finding of the same comparison is dropped silently when a group crosses (no "back to normal" note), and none starts again while the episode is open. Sensitivity scales the 80%.

    Sensitivity low (5× / 100 attempts / 45% …) and high (2× / 25 attempts / 20% …) scale the thresholds.

  3. Wake-up policy. Each anomaly has a fingerprint (project, detector, action, dimension, key). A new anomaly sends an alert and starts an investigation; one that has grown ≥ 2× since its last report does the same (escalation); one still ongoing gets a short status update at most every 30 minutes (a notification, no investigation); one gone for two scans gets a "back to normal" note (a notification). Counter groups that reach their rule's threshold are notes, one episode per rule comparison (started, digests on a growing cooldown, ended), not anomalies (§9.1 table). Several findings of one scan share one investigation. Optionally a daily digest runs at a chosen UTC hour, and Run now starts one by hand.

  4. The investigation is a normal assistant conversation owned by the watch owner: the admin who switched watch on (owner_user_id). The first message is a generated brief (the findings with their numbers, what the agent may do alone, the report format); the system prompt gets a watch addendum. The tools act through the same in-process invoker as the assistant, with the owner's permissions capped at admin, and every change is audited as via: watch. Report and pending proposals appear in the owner's assistant drawer; proposals are approved with the assistant's normal confirmation.

9.2 What it may do alone, and what needs a person

Tool In a watch run
read-only tools (get_stats, search_verdicts, list_networks, simulate_rule, …) always
create_rule alone, always saved Monitoring (the panel API keeps a rule created through watch Monitoring whatever it was asked), marked origin: watch; at most 5 per project per day by watch; only in the run's own project
save_counter alone, only when no counter with that key exists (changing one would reset its count)
everything else that changes data: set_rule_mode, update_rule, set_rule_enabled, delete_rule, add_to_list, remove_from_list, set_action_token, set_attribute_type, set_risk_thresholds, apply_todo_fix, and any tool added later never alone: the call waits as a pending proposal in the conversation until the owner approves it

The policy reads only the tool's name and its parsed arguments. Nothing the model writes — or anything that came from traffic, such as an attribute, a user agent or an event property — can approve a call; the model is told that everything from traffic is untrusted data. Rules and counters watch created are listed in the run's record.

9.3 Limits and failure