Receipt format
A receipt is the evidence attest produces. This document specifies it
completely enough to write an independent verifier without reading the Rust.
That is the point. "Anyone can check it" is not a property of our implementation being available — it is a property of the format being written down.
Current version: schema_version: 3.
Sections marked planned describe fields the format reserves but this release does not yet write. They are here so an independent implementer knows what is coming, not so anyone can claim support for them.
1. Where receipts live
.attest/receipts/receipt_<UTC timestamp>_<pipeline>_<suffix>.yaml, YAML,
UTF-8. The filename carries a random suffix so two receipts produced in the
same millisecond cannot collide; nothing in the format depends on it.
2. Document
schema_version: 3
pipeline_hash: "9f2c…" # 64 lowercase hex
steps: [...] # see §3
timestamp: "2026-09-16T10:36:36.554327Z"
total_duration_secs: 4
signature: "a71f…" # 128 lowercase hex, or null
signer_public_key: "333d…" # 64 lowercase hex, or null
attest_version: "0.1.0"
causal_events: ["…", "…"] # event ids
causal_chain_hash: "4c8b…" # or null
reproducibility: {...} # optional, see §5
provenance: {...} # optional, see §6
timestamp_token: "MIIG…" # optional, base64 DER, see §7Unknown fields are rejected. A receipt is a security document; a field the verifier does not understand is a field it cannot reason about. This is the opposite of the plugin wire protocol, which must tolerate unknown fields to evolve.
Version handling. A verifier reads schema_version from the raw document
before strict parsing. A value higher than it supports must produce an
explicit "upgrade" message, never an opaque unknown-field error. A receipt
with no schema_version is version 1 and remains valid: the field is omitted
rather than written as null precisely so that pre-versioning receipts, and
their signatures, stay byte-identical.
3. Steps
steps:
- name: "build"
input_hash: "1a2b…" # 64 lowercase hex, see §4
output_hash: "3c4d…"
duration_secs: 3
exit_code: 0
cache_hit: false
stdout: "…"
stderr: "…"
capsule_hash: "5e6f…" # optional; present for capsule stepsOnly steps that ran appear. A step skipped because a dependency failed is
absent: a receipt entry must carry real hashes, and a step that never ran
cannot produce them. A failed step is present, with its non-zero exit_code.
4. Hashes: attest-manifest/v1
input_hash and output_hash are blake3(manifest_bytes) where the
manifest is a canonical text document:
attest-manifest/v1\n
run:<blake3-hex of the step's run command, UTF-8 bytes>\n (input manifests only)
f:<blake3-hex of file content> <normalized relative path>\n
l:<blake3-hex of symlink target string> <normalized relative path>\nRules a conforming implementation must follow:
- Lines after the header are sorted by byte-wise lexicographic order of the normalized path, not by locale and not by insertion order.
- Paths are relative to the workspace root, normalized,
/-separated. - Symlinks are never followed. A symlink is recorded by hashing its target
string (
l:line). A symlink whose target escapes the workspace root is an error, not a skip. - A declared path that does not exist is a hard error. Never a silent skip: silently omitting a missing input would let a receipt claim coverage it does not have.
- Directories contribute no line of their own, so an empty directory is invisible to the manifest.
- The
run:line is domain-separated from file lines by its prefix, so a file whose content equals a command cannot be confused with it.
attest hash exposes this directly, which is the simplest way to check an
independent implementation against ours:
$ attest hash --run "cargo build" --input src/ --input Cargo.toml
$ attest hash --output target/release/attest5. Reproducibility
reproducibility:
verified: true
runs: 2
method: "double-build"Written only by attest run --check-reproducibility. Absent means not
checked — which is not the same as not reproducible, and a verifier must
not report it as either.
6. Provenance
Signed observations made by the runner about itself: invocation_id,
started_at, pipeline_name, source (repository URL, commit, branch,
tracked_dirty), runner_os, runner_arch, ci (provider, run id, job
id), per-step command_hash with declared inputs and outputs, and produced
artifacts with their digests.
These are assertions, not independent evidence. A runner that lies about its own commit produces a receipt that is internally consistent and wrong. Provenance is useful for correlation and audit; it is not a trust anchor.
7. Timestamp token
An RFC 3161 TimeStampToken, base64 of its DER, written by
attest run --sign --timestamp. Absent when the run did not ask for one.
The token covers the signature, not the receipt body. Signing binds the body; timestamping binds the signature. Together they place a specific signature by a specific key at a time the signer did not choose. Timestamping the body instead would prove the content existed — true, but silent on when it was signed, which is the question that matters.
The imprint is SHA-256 of the raw signature bytes, that is the 64 bytes
obtained by hex-decoding signature.
The field is outside the signed bytes (§8). It has to be: the token is issued after the signature exists, over that signature. Including it would be circular. Its integrity comes from the authority's signature, not ours.
Verifying one
- Decode the base64 to DER and parse the CMS
SignedData. - Extract the encapsulated
TSTInfo; readgenTimeandmessageImprint. - The imprint must equal
SHA-256(raw signature bytes). If not, the token is about something else and means nothing here. - The
message-digestsigned attribute must equalSHA-256(TSTInfo DER). - Verify the authority's signature over the signed attributes, re-encoded
with the
SET OFtag0x31in place of the wire's implicit[0](RFC 5652 §5.4). - The signing certificate must be issued by a pinned authority, and must
have been valid at
genTime.
The reference implementation pins the issuing CA, not the responder, and verifies exactly one signature between them. It builds no X.509 path: no discovery, no name constraints, no CRL or OCSP. Responder certificates rotate, often yearly; pinning one would stop verification of receipts issued after the rotation.
Plugins (planned)
A plugins list is reserved for a later schema version: one record per plugin
consulted, folded into the signing bytes. Two invariants are fixed now because
verifiers will depend on them — a plugin can never change a value in §3, and a
verifier must never require a plugin to reach its verdict.
See the plugin protocol.
8. Signature
Ed25519 over the canonical signing bytes, which are built as follows:
- Take the receipt.
- Set
signatureandsigner_public_keyto null. - Sort
stepsbyname. 2b. Settimestamp_tokento null. - Sort
causal_events. - Serialize as JSON — not YAML — in struct field order, exactly as listed in §2.
The three cleared fields are the ones that only exist once signing has happened. Clearing them is what lets a receipt be signed, then timestamped, and still verify.
The result is the byte string that is signed.
signatureis the 64-byte Ed25519 signature, lowercase hex (128 chars).signer_public_keyis the raw 32-byte Ed25519 public key, lowercase hex (64 chars).- The key id used throughout the CLI and the trust store is
hex(blake3(raw_public_key)[..16])— the first 16 bytes of the digest, 32 hex characters.
Note the asymmetry: receipts are stored as YAML and signed as JSON. The signing form is canonical and stable; the storage form is for humans.
9. Trust store
.attest/trust/ is committed to the repository: <key-id>.pub holds a
PEM-encoded public key, and trust.toml records the policy.
version = 1
[[key]]
id = "0b948c80672304dda0bb674dcc963b71" # hex(blake3(pubkey)[..16])
name = "release"
status = "trusted" # or "revoked"
# revoked_at = "2026-09-16T10:00:00Z"Revocation is time-aware. A receipt signed before revoked_at still
passes, with a warning; a receipt signed after it fails. Revoking a key
does not retroactively invalidate work done while it was trusted.
Which clock decides. When the receipt carries a timestamp token, the
comparison uses the time the authority attested. Without one it falls back to
timestamp, which the signer wrote and signed — so whoever holds a stolen key
can backdate a receipt past its own revocation. A verifier must warn when it
falls back; the reference implementation does.
Pinned timestamp authorities live in .attest/trust/tsa/, added with
attest keys trust-tsa <certificate> --name <name>. Several may be pinned,
and old ones should stay: they are what keeps old receipts verifiable after an
authority rotates.
Keys are never deleted, only marked revoked. Private keys live in
.attest/keys/ and are git-ignored; attest keys export refuses to emit
anything that is not a public key.
10. Verification
Five checks, in order, each independently reportable:
| Check | What it establishes |
|---|---|
schema |
Well-formed, and of a version this verifier understands |
consistency |
Internal references hold together |
signature |
Signed by a trusted key, before that key was revoked |
timestamp |
A third party attested when the signature existed |
recompute |
Declared inputs still hash to what the receipt records |
recompute is opt-in (--recompute) because it needs the workspace; the
first three are pure functions of the receipt and the trust store.
Exit codes: 0 every receipt passes, 1 at least one verification failure, 2 operational error (unreadable file, bad flags). A verifier that conflates 1 and 2 makes a CI pipeline unable to tell "the evidence is bad" from "I could not read the file".
11. What a valid receipt does not establish
Stated here so an independent implementer does not overclaim on our behalf:
- Without a timestamp token, the time is the runner's clock, written and
signed by the runner.
--timestampis what replaces that with a third party's word. - The causal root is not published anywhere. The ledger is append-only by local construction; there is no transparency log and no external anchor, so the property holds only against an attacker who does not control the machine holding it.
- The signer is a key, not a person. There is no binding between an Ed25519 key and a legal identity.
A receipt establishes internal consistency, verifiable by a third party against the source. It is not, today, an independent guarantee.