CrawlCheck
Specification

Guard acceptance suite

A guard is the check an agent runs where the action happens: ask CrawlCheck, verify the signed decision, and act only if it permits this operation. This suite is the line every guard must hold, ours included: 54 cases with signed receipts, 9 of them through redirects. Download cases.json; run it against any implementation.

54

cases, suite 1.2.0

54 / 54

@crawlcheck/sdk 1.3.0 (npm)

54 / 54

crawlcheck 1.3.0 (PyPI)

41 / 41

reference verifier, receipt cases (eligibleToAct)

1 · Rules

What a guard must check

In this order; the first failure refuses.

CheckRuleCases
Kind and versionThe answer is a crawlcheck-decision with v 1. Any other document, an empty object, an array or text is refused.wrong-kind, wrong-version, malformed-*
IntegrityThe canonical JSON hashes to sha256 and the Ed25519 signature verifies over “crawlcheck-data-v1\n” + sha256 with the key whose RFC 7638 thumbprint is the kid. A field added or changed after signing is refused; an unknown field that was signed is accepted.tampered, tampered-unsigned-field, unknown-signed-field, unsigned, kid-mismatch
IssuerThe kid is in CrawlCheck’s published key directory (or a pinned list), and so is every key the receipt leans on (the key of the resolve answer it names). Keys are separated by purpose, so those may differ; a key that is not published is refused: integrity is not trust.foreign-key, resolve-key-foreign, purpose-key-decision
Same domainThe receipt names the domain about to be contacted (www. is the same site; a subdomain is not).other-domain, subdomain, www-same
Same actionThe receipt names the action about to be taken. A read allow never covers transact.other-action
This requestThe nonce the guard sent comes back in the receipt. Another request’s receipt, or one without the nonce, is a replay.nonce-other, nonce-missing
Same operationWhen the guard names the operation (method and URL, or an MCP tool with the digest of its definition and its scopes), the receipt must carry exactly that operation. A receipt for another URL, method, tool definition or set of scopes is refused; a guarded fetch asks again for every hop.op-*, fetch-receipt-reuse
In timeNow is before expires_at plus the stated clock skew, capped at 300 s; decided_at is not in the future; the receipt is not valid for longer than its action allows.expired, expired-in-skew, skew-inflated, future, lifetime-*, no-expiry
Bound to evidenceThe receipt names the resolve answer it was made from (resolve.sha256), signed by the same key.resolve-unbound
The decisionallow proceeds. warn proceeds only when the caller opted in. require_confirmation proceeds only when a human confirmed. block, unsupported and any unknown value never proceed.allow, warn-*, confirm-*, block, unsupported, decision-unknown
Fail closedNo answer, an error status, a rate limit or an unreachable CrawlCheck refuses. Nothing proceeds on a default.http-500, http-429, unreachable
Every hopA guarded fetch checks before the first request and again before each redirect to a new domain; a refused hop is never requested; only http(s) to a domain name; at most 10 redirects; the receipt header goes only to the domain it names.fetch-*, redirect-*
2 · Lifetimes

How long a receipt may last, per action

CrawlCheck issues shorter ones when the evidence is due sooner.

ActionAt most
read24 h
cite24 h
connect1 h
transact5 min
administer5 min
clock skew5 min
3 · Cases

Every case and what must happen

Receipts are signed with a test key published in the suite’s own directory.

CaseSituationMust
allowallow proceeds
positive control
proceed
warn-defaultwarn does not proceed by default
warn means a stated doubt; the caller must opt in
refuse
warn-allowedwarn proceeds when the caller allows warn
positive control
proceed
confirm-missingrequire_confirmation without a confirm callback
no human, no action
refuse
confirm-norequire_confirmation, human says norefuse
confirm-yesrequire_confirmation, human says yes
positive control
proceed
blockblock never proceeds, whatever the optionsrefuse
unsupportedunsupported never proceedsrefuse
decision-unknownan unknown decision value (signed) never proceeds
fail closed on values this guard does not know
refuse
other-domaina valid allow for another domain
a receipt is valid only for the domain it names
refuse
subdomaina valid allow for the parent domain, asked for a subdomain
no inheritance across hosts
refuse
www-samewww. is the same site
CrawlCheck stores a site without www.
proceed
other-actiona valid read allow, used to transact
a receipt is valid only for its action
refuse
expiredexpired an hour agorefuse
expired-in-skewexpired 60 s ago, inside the stated 300 s clock skew
the skew is part of the receipt
proceed
skew-inflatedclaims a 1-day clock skew to stay alive
skew is capped at 300 s
refuse
futuredecided an hour in the future
a clock that far off cannot be trusted
refuse
lifetime-transacta transact allow valid for 24 hours
transact receipts last at most 300 s
refuse
lifetime-connecta connect allow valid for 24 hours
connect receipts last at most 3600 s
refuse
no-expiryno expires_atrefuse
nonce-othera receipt carrying another request's nonce
replayed receipt
refuse
nonce-missingthe request sent a nonce, the receipt has nonerefuse
op-matcha receipt for exactly this request
positive control
proceed
op-other-urla receipt for another URL on the same domain
a receipt is valid only for the request it names
refuse
op-other-methoda GET receipt used for a POSTrefuse
op-missingthe request names an operation, the receipt names none
an unscoped receipt cannot stand in for a scoped one
refuse
op-tool-schemaa receipt for the same tool with a different definition
the tool changed since it was decided
refuse
op-scopesa receipt for fewer scopes than requestedrefuse
purpose-key-decisiona decision signed by another published key than its resolve answer
keys are separated by purpose; both are published
proceed
resolve-key-foreigna decision whose resolve answer names a key that is not published
every key a receipt leans on must be published
refuse
tampereda block receipt edited to allow
the digest no longer matches
refuse
tampered-unsigned-fielda field added after signing
every field is signed
refuse
unknown-signed-fieldan unknown field that was signed
forward compatible: ignore unknown fields, but only signed ones
proceed
foreign-keya valid signature by a key CrawlCheck does not publish
integrity is not trust
refuse
kid-mismatcha published kid on a signature by another key
the key id must be the key's thumbprint
refuse
unsignedno signaturerefuse
resolve-unboundno resolve answer named
a decision must name the answer it was made from
refuse
wrong-kinda signed document of another kind
only a decision receipt decides
refuse
wrong-versiona decision of an unknown versionrefuse
malformed-emptyan empty objectrefuse
malformed-arraya JSON arrayrefuse
malformed-textnot JSONrefuse
http-500CrawlCheck answers 500
fail closed
refuse
http-429CrawlCheck answers 429
fail closed
refuse
unreachableCrawlCheck cannot be reached
fail closed
refuse
fetch-oka guarded fetch with no redirect
positive control
proceed
fetch-blockeda blocked domain is never contacted
the check runs before the request
refuse
redirect-to-blockedan allowed page redirects to a blocked domain
every hop is re-checked; the blocked hop is never requested
refuse
redirect-to-allowedan allowed page redirects to another allowed domain
each domain gets its own receipt
proceed
redirect-relativea relative redirect stays on the checked domainproceed
redirect-schemea redirect to a file: URL
only http(s)
refuse
redirect-loopmore than the hop limit
at most 10 redirects
refuse
redirect-ipa redirect to a bare IP address
an address is not a domain CrawlCheck can vouch for
refuse
fetch-receipt-reusea receipt for one page used for another page on the same domain
each hop asks again; the first page's receipt never covers the second
proceed
4 · Run it

Run it against your guard

CI runs it on every SDK change, against the copies on npm and PyPI after each release, and every Monday.

  1. Serve directory at /.well-known/http-message-signatures-directory and answer POST /api/v1/preflight with each case’s crawlcheck (status and body; unreachable = refuse the connection; by_domain = the receipt for the domain asked).
  2. Call your guard with the case’s request, pinning nonce and now. For a fetch case, also serve sites and record every URL requested.
  3. expect.proceed must match, and for fetch cases the requested URLs must match exactly. A refusal must say why.

Our runners: sdk/test/guard-acceptance.test.js in @crawlcheck/sdk and tests/test_guard_acceptance.py in crawlcheck (both ship the suite in their test fixtures). For a receipt you already hold: node crawlcheck-verify.mjs receipt.json --act example.com:read --nonce <yours> --online, or run-verifier.mjs over the whole suite.

What the SDKs before 1.1.0 did: their guard verified the signature and the decision but not the scope. In this suite 1.0.9 proceeded on a valid receipt for another domain or action, an expired one, one with an inflated skew or too long a life, a replayed nonce, and a signed document of another kind or version (14 cases), and had no guarded fetch, so nothing re-checked redirects (8 cases). 1.1.0 added those checks but did not scope a receipt to the operation: it accepted a receipt for another URL, method, tool definition or set of scopes (5 cases), and its guarded fetch reused one receipt across pages. 1.2.0 still required a decision and its resolve answer to share one key, which stops holding once decisions get their own key on 2026-11-09. Upgrade with npm i @crawlcheck/sdk@^1.3.0 or pip install -U crawlcheck.

Suite built 2026-10-10. Related: quickstart (the guarded fetch), failure policy, assurance.