BenefitsCLI docsRelease 0.1Get started
CLI documentation

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 §7

Unknown 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 steps

Only 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>\n

Rules 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/attest

5. 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

  1. Decode the base64 to DER and parse the CMS SignedData.
  2. Extract the encapsulated TSTInfo; read genTime and messageImprint.
  3. The imprint must equal SHA-256(raw signature bytes). If not, the token is about something else and means nothing here.
  4. The message-digest signed attribute must equal SHA-256(TSTInfo DER).
  5. Verify the authority's signature over the signed attributes, re-encoded with the SET OF tag 0x31 in place of the wire's implicit [0] (RFC 5652 §5.4).
  6. 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:

  1. Take the receipt.
  2. Set signature and signer_public_key to null.
  3. Sort steps by name. 2b. Set timestamp_token to null.
  4. Sort causal_events.
  5. 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.

  • signature is the 64-byte Ed25519 signature, lowercase hex (128 chars).
  • signer_public_key is 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. --timestamp is 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.