Verify a record offline
Follow this page to check an agent evidence record on a machine with no network and no account. You need the record, a key set you chose, and the files in offline-verify/. Nothing on this page calls home: the verifier reads bytes from disk, prints a decision, and stops.
The record format is the Adversarial Execution Evidence statement, an in-toto Statement whose predicate type is https://in-toto.io/attestation/adversarial-execution-evidence/v0.7. The verifier is the released agent-evidence-verify 0.5.1 from PyPI, pinned by hash.
What you need
| Machine | Needs |
|---|---|
| Connected machine, once | Python 3.12 or newer with pip, the same operating system and CPU architecture as the offline machine |
| Offline machine | Python 3.12 or newer with its venv module, and sha256sum |
The connected machine only downloads. Everything that decides runs on the offline machine.
1. Download on the connected machine
Copy the offline-verify/ folder from this repository at the release tag you trust, then run connected.sh inside it. Its two working steps:
python3 -m pip download --require-hashes --only-binary=:all: --dest wheels -r requirements.txt
(cd records && sha256sum -c ../SHA256SUMS)
requirements.txt pins agent-evidence-verify==0.5.1 and its five dependencies, each with every published SHA-256, so pip refuses any wheel whose hash it does not list. The script also fetches eight example records from the v0.36.3 tag of this repository into records/ and checks them against SHA256SUMS. To check your own record instead, put it in records/ and add its line to SHA256SUMS.
2. Move the folder
Carry the folder, now holding wheels/ and records/, across by your own transfer process. Send the SHA-256 of the tarball by a second channel so a swapped copy cannot carry its own matching digest.
3. Verify on the offline machine
Run offline.sh. Its steps:
python3 -m venv venv
venv/bin/pip install --no-index --find-links wheels --require-hashes -r requirements.txt
python3 make_case.py --out pinned --keys keys.json records/*.json
python3 make_case.py --out unpinned records/*.json
for c in pinned/*.case.json unpinned/*.case.json; do venv/bin/agent-evidence-verify "$c" --policy "$(dirname "$c")/policy.json" --artifact-root records --json > "${c%.case.json}.out.json"; done
venv/bin/python summary.py pinned unpinned || echo "summary exit $?"
--no-index stops pip from reaching any package index, and --require-hashes stops it installing anything the lock does not name. make_case.py uses the standard library only. It writes one case file per record, naming the record by path, length and SHA-256, and one policy file that holds your key set. The pinned run uses keys.json; the unpinned run pins no key, which is what a party holding only the record can check.
summary.py prints one line per record and exits 1 when any record carries a verdict code you did not allow with --allow CODE. Use that exit status as your gate.
The key set
A key set is a JSON file in the shape the verifier contract defines:
{"substrateObservationKeys": [{"keyid": "<sha256 of the raw key, hex>", "publicKeyHex": "<32-byte Ed25519 public key, hex>"}]}
The keys are those of the observers you accept: the components that watched the agent from outside its reach and signed what they saw. Get them out of band, from a party and a channel you already trust, and never from the record. The record's keyid is a lookup hint and the verifier never takes a key from it.
The keys.json in this folder holds the corpus's published test key (derivation in vectors/keys/README.md). Anyone can sign with it, so a clean verdict under it shows the mechanics and nothing about who observed. Never put it in a production key set. The Go verifier's admission stage refuses it as observation-key-published; the Python verifier on this page does not, so keeping it out is your key set's job.
Reading the output
Each *.out.json is one decision. The fields that matter:
| Field | Meaning |
|---|---|
decision |
supported when the statement verifies, contradicted when it does not |
checks[0].verdict |
valid or invalid |
checks[0].verdict_code |
One of the seven codes below, or null for a clean report |
checks[0].codes |
The detailed failure codes, in detection order; the first decides the verdict code |
checks[0].result |
For a valid statement only: the run's own result (pass, fail, pass_indirect and so on), recomputed from the rows |
checks[0].tiers |
For a valid statement only: attested, unattested or declared per row under your key set |
A supported decision says the record is sound. It does not say the agent passed: read result for that.
Verdict codes
The closed list lives in receipt-verdict-codes.json, and the verifier contract maps every detailed code to one of them. A code your verifier version does not classify is refused, never read as a pass.
| Verdict code | Kind | What it tells you | Example record |
|---|---|---|---|
none (null) |
clean | Valid, and every substrate row is attested under a key in your set | vfcb846afff2d6979 with keys.json |
MALFORMED |
failure | Unreadable, a broken shape rule, or a signed root that commits to records the document no longer carries | v577011f7dd9a08fe |
LEAF_HASH_MISMATCH |
failure | Carried bytes do not hash to the digest stated or pinned for them: the document was edited | v02fd1bc1d7916002 |
PROOF_BROKEN |
failure | A record's hash does not reach the signed batch root | v28b0cf874ea02de1 |
CLAIM_NOT_BOUND |
failure | A readable claim disagrees with the sealed record behind it, or cites none that can carry it | v131478f1be775746 |
SUMMARY_MISMATCH |
failure | The summary disagrees with the record's own rows | vcb23d2c4509c9daf |
NOT_PUBLICLY_VERIFIABLE |
limit | Valid, but a substrate row is not attested under your keys: the document alone cannot show who observed it | v550c91df0d36a218, and vfcb846afff2d6979 without a key |
NOT_RECONSTRUCTIBLE |
limit | Valid, but the rows rest on no sealed observation record, so the claims cannot be tied back to hashes | v9cc570167acf4ae8 |
A limit is not a pass. Allow one with --allow only when your use of the record can live with what it says the record cannot show.
The recorded run
TRANSCRIPT.txt is a run of exactly these files on 2026-11-08: connected.sh in a python:3.12-slim container, then offline.sh in a fresh container started with --network none, whose only interface was lo and whose probe of the package index failed with no route. scripts/offline-verify-run.sh reproduces it. The summary it printed:
pinned v02fd1bc1d7916002 contradicted invalid LEAF_HASH_MISMATCH corpus-digest-mismatch None -
pinned v131478f1be775746 contradicted invalid CLAIM_NOT_BOUND refs-empty,interception-record-orphaned None -
pinned v28b0cf874ea02de1 contradicted invalid PROOF_BROKEN batch-root-mismatch None -
pinned v550c91df0d36a218 supported valid NOT_PUBLICLY_VERIFIABLE - fail unattested
pinned v577011f7dd9a08fe contradicted invalid MALFORMED batch-root-orphaned None -
pinned v9cc570167acf4ae8 supported valid NOT_RECONSTRUCTIBLE - pass_indirect declared
pinned vcb23d2c4509c9daf contradicted invalid SUMMARY_MISMATCH result-recompute-mismatch None -
pinned vfcb846afff2d6979 supported valid None - pass attested
unpinned vfcb846afff2d6979 supported valid NOT_PUBLICLY_VERIFIABLE - pass unattested
The last two lines are one record. With the key pinned its row is attested and the report is clean; with no key the same bytes verify but nobody can say who observed them.
Pin the verifier in your CI
Commit requirements.txt, make_case.py and summary.py from this folder at a release tag, and your key set, then run the check on every record your build produces or receives:
jobs:
verify-records:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --require-hashes -r evidence/requirements.txt
- run: python evidence/make_case.py --out cases --keys evidence/keys.json records/*.json
- run: for c in cases/*.case.json; do agent-evidence-verify "$c" --policy cases/policy.json --artifact-root records --json > "${c%.case.json}.out.json"; done
- run: python evidence/summary.py cases
To replay the conformance corpus against a verifier of your own in the same workflow, add the GitHub Action, pinned to a tag.
Let Dependabot propose each new verifier release as a pull request your CI tests, so a pin never goes stale and never moves without a green run. Add to .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: pip
directory: /evidence
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
A new verdict code, or a code moved between verdict codes, ships as a minor release, never a patch, so a bump that can turn your gate red says so in its version.
What an offline check establishes
| An offline check establishes | It does not establish | It needs no trust in |
|---|---|---|
| The bytes are a well-formed statement of predicate version 0.7, read under the strict I-JSON profile | That a key in your set belongs to the party you think: that is your key set's provenance | The agent that was observed |
| Every carried observation record hashes into the signed batch root, with none added, removed or duplicated | That the observer saw everything the agent did, beyond the coverage the record declares and the verifier checks | The party that handed you the record, for every row attested under your keys: altering it breaks a hash, the root or a signature |
| The stated result equals the result recomputed from the rows | That the record is the latest one, or that no other record was withheld: offline means no log, no revocation list and no freshness check | Any network, service, account or licence |
Each substrate row's covering records are signed by a key in your set, or the row is reported unattested |
That issuedAt is the true time: it is what the signer wrote |
The issuer being online, or still existing |
| A claim that disagrees with its sealed record is caught and named | That the verifier wheel is sound: the hash pin proves these are the published bytes, and whether to trust that release is your decision |
To check the release itself, the signed digest list and its timestamp proofs, follow Release verification. To carry the whole corpus and verifier into an air-gapped enclave as one signed bundle, use the enclave guide.