Open formats and reference verifiers for Seal records: what an AI decided, and what an agent was allowed to do. Ed25519. Offline-verifiable. No call to the issuer required.
Check a receipt in the browser · ATTESTATION-v1 · ACTION-v1 · 5-minute walkthrough
The formats are written up for the IETF as an individual Internet-Draft, draft-chueayen-attestation-receipts (revision -05, October 2026). It is a draft, not a standard.
Two record formats in one family, signed by the same key and checked by the same verifiers:
| Format | Answers | Fields |
|---|---|---|
| ATTESTATION-v1 | What did the gateway decide about this model call? | 12 |
| ACTION-v1 | What was this agent allowed to do? | 13 |
ACTION-v1 covers agent tool actions: a declared tool call authorised or
refused under policy before it runs, bound to the session's registered
intent, with the arguments hashed canonically. A refusal is a record in its
own right. It is a sibling format, not a new version: the v1 receipt
envelope is untouched and every existing receipt verifies exactly as before.
Profile selection is always explicit, never guessed from field shape:
npx aqta-verify-receipt record.json --profile action-1 --key <key>Draft: GRANT-v1, USE-v1 and the chain profile (1.3.0). A GRANT-v1 record
is a designated approver's signed approval for a bounded number of uses of an
action; a USE-v1 record is the boundary's signed statement that one action
consumed use i of N. The chain profile checks a bundle of grants, uses
and the ACTION-v1 records they authorised together, and returns PASS,
FAIL or INCOMPLETE. Missing evidence is INCOMPLETE, never PASS. Both
specifications are drafts and not frozen: their wire formats may change.
Opt in explicitly:
npx aqta-verify-receipt bundle.json --profile chain --key <issuer key> --approver-keys <k1,k2>
# exit 0 PASS, 1 FAIL, 3 INCOMPLETE, 2 usage or IOSpecifications: spec/GRANT-v1.md, spec/USE-v1.md. Vectors: test-vectors/chain/.
Anyone with the issuer's published public key can verify either offline.
This repo is the formats, two reference verifiers, stand-alone reference issuers, and 67 test vectors. It is not the managed Seal gateway (app.aqta.ai).
A valid signature proves what the gateway said, not what the provider's compute did. Details: WHAT-RECEIPTS-PROVE.md, THREAT-MODEL.md.
pip install aqta-verify-receipt
# or: npm install aqta-verify-receiptfrom aqta_verify_receipt import verify_receipt, fetch_published_public_key
trusted = fetch_published_public_key() # once, then pin
result = verify_receipt(receipt, trusted_public_key=trusted)
print(result.valid)From v1.0.4, a pinned trusted_public_key is required by default.
Verifying against the key embedded in the receipt alone only proves
integrity, not issuer identity. CLI: --key <pinned> or --integrity-only.
Current production key (also at
/v1/attestation/public-key):
9Y3Eiq6V8QjRDUM5nPqSwKIOPQaoEU4SbagfYFdvWa4
Keys rotate; receipts do not. A receipt verifies against the key that was
current when it was signed. The permanent key record, including retired keys
and their validity windows, is at
app.aqta.ai/security/issuer-keys.txt.
Default output is one compact line. Words carry the verdict; colour is optional.
$ aqta-verify-receipt test-vectors/valid/001-allowed.json --integrity-only
✓ valid ALLOWED 0000…0001 untrusted embedded key (integrity only)Tampered signature:
$ aqta-verify-receipt test-vectors/invalid/001-tampered-signature.json --integrity-only
✕ invalid signature mismatch 0000…ffffExit 0 valid, 1 invalid, 2 usage/IO. --json for automation. --pretty
adds a short flourish (seal intact · verified offline); it is never the proof.
--integrity-only checks the receipt against the key embedded in it, which
proves internal consistency but not who issued it. Pass --key with the
published key above to bind it to the issuer.
| Path | What |
|---|---|
| spec/ATTESTATION-v1.md | Wire format (CC-BY-4.0) |
| spec/ACTION-v1.md | Agent action records (frozen) |
| spec/GRANT-v1.md, spec/USE-v1.md | Approvals, uses and the chain profile (DRAFT, not frozen) |
| packages/verify-receipt-py | Python verifier (PyPI) |
| packages/verify-receipt | TypeScript verifier (npm) |
| examples/ | Reference issuer and sample receipt |
| test-vectors/ | Known-good and known-bad receipts |
| CONFORMANCE.md | Issuer and verifier expectations |
| RELATIONSHIP-TO-SCITT.md | Where this sits against RFC 9943, and where it is weaker |
Run the whole suite from a clean checkout. Each block is independent, so you can paste them one at a time or all together.
# Python verifier
pip install -e packages/verify-receipt-py
pip install pytest cryptography
pytest packages/verify-receipt-py/tests/ -q
# TypeScript verifier. The build step is required, dist/ is not committed.
(cd packages/verify-receipt && npm ci && npm run build && npm test)
# Cross-implementation check: every test vector, both verifiers, same verdict.
node scripts/make-interop-fixture.mjs
node scripts/transparency-interop-sweep.mjs
node scripts/chain-interop-sweep.mjsThe two reference verifiers, TypeScript and Python, must return the same verdict on the same bytes. These defaults differ between the languages and each one caused a divergence that is now pinned by a vector:
| Pitfall | What differs | Fixed in |
|---|---|---|
| String ordering | JavaScript sorts by UTF-16 code unit; Python's sorted() sorts by code point. They split once a character outside the BMP appears. Sort by s.encode("utf-16-be", "surrogatepass") in Python. |
1.2.5 |
| Number formatting | Canonical numbers follow ECMA-262 Number::toString (RFC 8785), not Python's repr. They disagree for 0 < abs(x) < 1e-4. |
CHANGELOG |
| Invalid UTF-8 | Node's readFileSync(file, "utf8") replaces bad bytes with U+FFFD. Decode with a fatal decoder in both. |
1.2.6 |
| End anchors | Python's $ also matches before a final line feed. Use fullmatch(). |
1.2.7 |
| Digits | Python's \d matches any Unicode digit unless re.ASCII is set. |
1.2.7 |
| Lone surrogates | They have no UTF-8 form: TextEncoder writes U+FFFD and Python raises. Refuse them explicitly. |
1.2.7 |
Every class has vectors under test-vectors/, and the sweeps in
scripts/ (action-interop-sweep.mjs, bytes-interop-sweep.mjs,
transparency-interop-sweep.mjs, chain-interop-sweep.mjs, differential-fuzz.mjs) run both implementations over them in
CI and fail on any disagreement. If you write a third verifier, run it against the same vectors.
Please credit the authors when you implement, fork, cite, or redistribute.
Specification (spec/): CC-BY-4.0. You must give
appropriate credit to Aqta Technologies Ltd, link the licence, and note
if you changed the text. Suggested credit line:
ATTESTATION-v1 by Aqta Technologies Ltd, https://github.com/Aqta-ai/attestation-spec (CC-BY-4.0)
Code (packages/, examples/, scripts/, test-vectors/):
Apache-2.0. Keep copyright and licence notices when you
redistribute.
Machine-readable citation: CITATION.cff (GitHub → Cite this repository).
ATTESTATION-v1 is adjacent to SCITT/COSE, W3C Verifiable Credentials, and in-toto/SLSA. It is not a conforming profile of those standards.
- Browser verifier: https://aqta.ai/verify
- Published key: https://api.aqta.ai/v1/attestation/public-key
- Security: SECURITY.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
