# Verifying a receipt

One command checks any Deadline Desk receipt end to end. In an empty directory, with Node 22 or later:

```bash
curl -O https://deadline.bot2botlabs.com/verify-receipt.mjs
node verify-receipt.mjs ddr_01M5TGEHAKVDX3R43QCJ2JMFER
```

[`verify-receipt.mjs`](https://deadline.bot2botlabs.com/verify-receipt.mjs) is one file with no dependencies: no `npm install`, no keys, no secrets, no access to our code. Read it before you run it (about 1,100 lines of code, then the embedded JSON Schema). The response carries `X-Content-Hash: sha256:…` of the file, so you can check the copy you got: `shasum -a 256 verify-receipt.mjs`. This page is at [`https://deadline.bot2botlabs.com/VERIFY.md`](https://deadline.bot2botlabs.com/VERIFY.md), and a short version at [`/verify`](https://deadline.bot2botlabs.com/verify).

Both files are served byte for byte from `passport/verify/` in the (private) repo. Paths like `passport/schema/RECEIPT-V1.md` below are repo paths, for maintainers; nothing in "What it reads" to "Options" needs them. Tests: `node --test passport/verify/verify-receipt.test.mts` after a root `npm ci`.

## What it reads

All of it public:

| What | From |
|---|---|
| The receipt | `https://deadline.bot2botlabs.com/api/v1/receipts/{id}` for an ID; a `/receipts/{id}` page URL is read through the same API; or any URL, a file, or stdin (`-`). For an ID or page URL, the answer must carry that same ID, or the result is `NOT VALID` (exit 1). http is allowed only when the URL you give is http (a local `--resolver`); once any hop is https, a redirect to http is refused |
| The signing keys | `https://bot2botlabs.com/.well-known/jwks.json`, the fixed address in `passport/identity/IDENTITY.md`. Never an address taken from the receipt |
| The page copies | each `evidence[].snapshot.url`, https only, redirects followed by hand (https only), 2 MiB cap, 20 s timeout, no credentials |
| The format rules | `receipt-v1.schema.json` (`passport/schema/`), embedded in the file |

## What it checks

The five steps of "Checking a receipt" in the receipt format spec (`passport/schema/RECEIPT-V1.md`), each reported as one line:

| Check | Passes when |
|---|---|
| `schema` | the receipt matches the format v1 JSON Schema |
| `receipt_id` | the ULID time part of `receipt_id` equals `issued_at`, so the ID cannot be re-pointed |
| `digest` | SHA-256 of the JCS canonical receipt minus `trace` equals the signed `tool_transcript.hash`. This is what makes the one signature cover every receipt field |
| `field_map` | every row of the field map holds: `references` names this receipt, `subject` is the agent, `iat` is `issued_at`, `runtime.measurement` is the primary page hash, `call_count` is the evidence count, software-only with no appraisal, TRACE v0.2, `origin.kind: self`, the two signature copies agree |
| `signature` | the Ed25519 signature verifies over JCS(record minus `signature`) with `cnf.jwk` |
| `key` | `cnf.jwk` is a published bot2botlabs key, same `kid` and same `x`, and (when the published set says) was in use at `iat` and is not revoked. Same rules as `keyTrustAt`. A published window that cannot be read as a date is `FAIL`, not trusted |
| `snapshot` | one line per evidence item: the copy at `snapshot.url` hashes to `content_hash`, and for the primary item `claim.quote` is on the page (whitespace ignored, nothing else; same rule as passport/snapshots) |
| `unsigned` | v0 only, always `WARN`: `created_at` and the `policy` fields other than `content_hash` are not covered by the signature |
| `correction` | correction receipts only: which receipt this corrects, and whether it replaces or withdraws the claim |

Each line is `PASS`, `FAIL` (the receipt is not valid), `WARN` (valid, with a limit worth knowing), `ERROR` (could not be checked: a network failure, a rate limit) or `SKIP`. The verdict:

| Verdict | Exit | Meaning |
|---|---|---|
| `VALID` | 0 | every check passed |
| `VALID, WITH WARNINGS` | 0 | nothing failed, but a `WARN` or `SKIP` line says what was not confirmed (for example the key's validity window, or page copies not fetched with `--no-fetch`) |
| `NOT VALID` | 1 | at least one `FAIL`. Also exit 1 when the resolver says the receipt does not exist (404) |
| `INCOMPLETE` | 3 | nothing failed, but something could not be checked. Try again, or give local copies |
| bad usage | 2 | |

`--json` prints the same report as JSON (`verdict`, `checks[]`, `summary`).

### Warnings you will see today

- **`key` WARN: no status or validity window.** Per `passport/identity/IDENTITY.md`'s known gap, the key set at `https://bot2botlabs.com/.well-known/jwks.json` may serve only the standard JWK members. Then the verifier can match `kid` and the public key, but cannot see whether the key was in use at `iat` or has been revoked. When the site serves the full key document, this line becomes `PASS` with no change to the verifier. Until then, `--keys keys.json` (a copy of `passport/identity/keys.json`) gives the full check.
- **`key` ERROR, verdict `INCOMPLETE` (exit 3).** The key address did not answer (it returned HTTP 500 until 2026-10-25). Try again later, or give `--keys`.
- **`snapshot` WARN: no copy was stored.** The receipt is valid but cannot be re-checked once the live page changes.

### Format v0 receipts

`dd-…` receipts (the log entries written before format v1) are checked as far as they can be: shape, the signature, `references`, and `runtime.measurement` against `policy.content_hash`. Their `tool_transcript.hash` covers fields v0 never stored, so `digest` is `SKIP`, and they carry no page copies. The signature binds only `policy.content_hash`: `store`, `kind`, `confidence`, the other `policy` fields and `created_at` are not signed, so an `unsigned` `WARN` line says so and the report prints them as "unsigned, not checked" (`summary.unsigned` in `--json`), never as the receipt's answer or issue time. `summary.issued_at` is `null` for v0, and the report says "issue time not signed". v0 receipts were signed before any key was published, so `key` fails for them: the signature is intact, but nothing ties the key to bot2botlabs.

## What a valid result does not prove

Receipts are **not hardware-attested**. A valid receipt proves the holder of a published bot2botlabs key issued this exact receipt and nobody changed it since, and that the page copy is the bytes the claim was read from. It does not prove which code ran, that the server was uncompromised, that the retailer really published that page, or that the claim is true. The verifier also does not read the hash-chained log; for a receipt from a revoked key it says to run `passport/log/verify-log.ts` against the published fingerprints.

## Options

```
node verify-receipt.mjs <receipt-id | receipt URL | file | -> [options]
  --keys <file|https url>   key document: a JWK Set or a bot2botlabs/keyset (keys.json)
  --snapshot <file>         a local copy of a page, used for any evidence whose hash it matches (repeatable)
  --no-fetch                fetch no page copies (reported SKIP, so at best VALID, WITH WARNINGS); local --snapshot files are still used
  --resolver <origin>       where IDs are fetched (default https://deadline.bot2botlabs.com; http allowed, for a local server)
  --json
```

A copy of a page from anywhere is as good as ours if it hashes right, which is why `--snapshot` takes any file.

## Using it as a library

It is an ES module and uses only WebCrypto, `fetch` and `TextEncoder`, so the checks also run in a browser:

```js
import { verifyReceipt, fetchReceipt, fetchPublishedKeys, fetchSnapshotResolver } from "./verify-receipt.mjs";
const { receipt } = await fetchReceipt("ddr_…");
const report = await verifyReceipt(receipt, { keys: await fetchPublishedKeys(), resolveSnapshot: fetchSnapshotResolver() });
```

A browser page would need to be served from `deadline.bot2botlabs.com` (the receipts API sends no CORS headers) and browsers hide manual redirects, so redirected fetches stop with a message there.

## Keeping it in step

The file repeats rules from three folders so it can stand alone. `verify-receipt.test.mts` fails if any copy drifts:

| Repeated here | Source | Cross-check |
|---|---|---|
| The JSON Schema | `passport/schema/receipt-v1.schema.json` | embedded copy must be identical. After a schema change run `node passport/verify/sync-schema.mts` |
| Schema validation (a subset validator, no ajv) | ajv 2020 + ajv-formats | same answer on every example, every batch receipt, and ~50 mutations. An unknown schema keyword throws instead of being skipped |
| JCS, `receiptDigest` | `passport/schema/receipt-v1.ts` | same output on every receipt |
| `keyTrustAt` | `passport/identity/keyset.ts` | same answer for every status × time × presented key |
| Page text, quote check | `passport/snapshots/page-text.ts`, `quoteInBytes` | same text on every fixture and saved page, same result on every quote in `fixture-quotes.json` |

The site serves this folder's `verify-receipt.mjs` and `VERIFY.md` from a generated copy, `apps/web/src/app/verify/published-files.ts`. After changing either file run `node passport/verify/sync-web.mts`; `apps/web/src/app/verify/published-files.test.ts` (root `npm test`) fails while the served bytes differ from these files.

A new receipt `format_version` needs a new verifier version that knows it; this one reports any other version as not a v1 receipt and never guesses.

## Outsider test (issue #11)

The definition of done: Grok verifies five receipts using only public information, with no access to the repo or secrets. Before that run:

1. **The verifier is public** (#85): `https://deadline.bot2botlabs.com/verify-receipt.mjs` and `/VERIFY.md`, served byte for byte from this folder once deployed.
2. **The key set answers.** `https://bot2botlabs.com/.well-known/jwks.json` returns 200 as of 2026-10-25. Without it every check below ends `INCOMPLETE`.
3. **The five receipts below are served** at `/api/v1/receipts/{id}` with their snapshots. They are batch-1 receipts issued with the published key (#76, #81); every one verifies offline against `keys.json` and its saved page.

Then Grok, in a fresh directory, without the repo:

```bash
curl -sO https://deadline.bot2botlabs.com/verify-receipt.mjs
for id in ddr_01M5TGEHAKVDX3R43QCJ2JMFER ddr_01M5TGA2MPTP94ZCWWR8Y3TMBD ddr_01M5TGAXHKZZVKA6V24VT39M7D \
          ddr_01M5TGA9CPCDSX37F4DE46XDDT ddr_01M5TG9MWQ0XDZDBFVS41X266N; do
  node verify-receipt.mjs "$id"; echo "exit $?"
done
```

| Receipt | Store | Why it is in the set |
|---|---|---|
| `ddr_01M5TGEHAKVDX3R43QCJ2JMFER` | Zappos | `high`, 60 days |
| `ddr_01M5TGA2MPTP94ZCWWR8Y3TMBD` | Bose | `medium`, anchor `unknown` |
| `ddr_01M5TGAXHKZZVKA6V24VT39M7D` | eBay | `unknown`, no quote: the copy is hashed, no quote to find |
| `ddr_01M5TGA9CPCDSX37F4DE46XDDT` | CVS | `high` |
| `ddr_01M5TG9MWQ0XDZDBFVS41X266N` | American Eagle | `unknown` with a quote |

Expected: all five `VALID` or `VALID, WITH WARNINGS` (exit 0), the only possible warning being the `key` window line above. If the key address fails: `INCOMPLETE` (exit 3) with only the `key` line as `ERROR`. Then the negative control: save one receipt (`curl -s https://deadline.bot2botlabs.com/api/v1/receipts/<id> > r.json`), change one character of `claim.answer`, and run `node verify-receipt.mjs r.json`: it must say `NOT VALID` with `digest` failing (exit 1). Grok posts the output on issue #11.
