The normative description of the proofbundle/v0.1 evidence-bundle format. This page is for anyone who writes a second verifier or needs the exact rules a bundle must follow. It defines the fields, the order of the checks and the optional layers, and section 7a says what a bundle attests and what it does not. A short overview for readers is in the README.
Revision: 2026-07-13
This is the normative description of the proofbundle/v0.1 evidence-bundle format.
An independent implementation that follows this document MUST interoperate with
proofbundle verify. The machine-readable companion is
schemas/proofbundle_v0_1.schema.json;
where the two disagree, this document is normative and the schema is a bug.
The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.
A bundle is a single UTF-8 JSON object. It asserts, checkable fully offline, that a fixed byte string (the payload) was:
- signed by a stated Ed25519 key, and
- included as a leaf of an RFC 6962 / RFC 9162 Merkle tree with a stated root,
and MAY additionally carry an SD-JWT selective-disclosure credential.
The verifier treats the payload as opaque bytes; it proves that these exact bytes were signed and anchored, not what they mean.
- All top-level and nested string fields carrying binary data use standard
Base64 (RFC 4648 §4),
with padding. (Exception: the SD-JWT compact string in
sd_jwt_vc.compactuses base64url per the SD-JWT spec.) Such a field is decoded canonically: the standard alphabet only, padding to a multiple of four characters, pad bits zero, no white space and no other character; any other spelling of the same bytes is refused. - Hashes are SHA-256 (32 bytes).
- Integers are JSON numbers with no fractional part.
- Duplicate object keys MUST be rejected (WP-C1), at any nesting depth, in
the bundle document and in every other JSON input a verifier parses. JSON
parsers disagree on duplicates (first-wins vs last-wins), so two
implementations could verify DIFFERENT
root_b64/sig_b64values from the same bytes — an interoperating implementation that silently keeps either occurrence is non-conforming. (RFC 8785 forbids duplicates outright; this extends the rule to the non-canonical inputs too.) - The strict reader that refuses them refuses as well
NaN,Infinityand-Infinity, a number with no finite IEEE 754 double, an integer literal outside ±(2^53 − 1), a lone surrogate in a string, and input beyond the verification budget (200,000 keys and items, nesting depth 64;docs/ANCHORS.md). The command line reads with it every bundle, envelope and trust policy it is given, a refusal being malformed (exit 2), andagent_review.load_policyreads the agent-review policy file with it, a refusal being anAgentReviewError. Every JSON file of the conformance corpus passes the strict reader except one bundle that carries a duplicate key; for every other case file the choice of reader changes nothing.
| field | required | type | meaning |
|---|---|---|---|
schema |
yes | string | MUST be the exact string "proofbundle/v0.1". |
payload_b64 |
yes | string | Base64 of the payload bytes that were signed and anchored. |
signature |
yes | object | Ed25519 signature over the payload (§4). |
merkle |
yes | object | RFC 6962 inclusion proof of the payload leaf (§5). |
sd_jwt_vc |
no | object | Optional SD-JWT selective-disclosure credential (§6). |
anchors |
no | array | Optional, EXPERIMENTAL external time-anchor evidence, detached from the signed payload (§7i, the [anchors] extra). |
A verifier MUST reject a bundle whose schema is not "proofbundle/v0.1"
(unsupported), and MUST reject unknown top-level fields (the schema is
additionalProperties: false). Such a rejection is malformed (exit 2), as is a document that is not a JSON
object, a missing or wrongly typed member, an alg or hash_alg other than the one §4 and §5 require, and a
Base64 field that does not decode; a bundle that is well formed in this sense and fails a check, a signature or
key that decodes to the wrong length and a tree_size that admits no leaf included, is a verification failure
(exit 1). The optional anchors field is now a KNOWN
field: a bundle carrying it is not malformed, but the core verifier (§7) does
NOT verify it — anchors are a separate, opt-in relying-party step (§7i), so a
bundle's crypto verdict is identical whether or not it carries anchors.
signature, merkle and sd_jwt_vc are closed like the top level: a member outside their tables (§4 to
§6) is malformed (exit 2). Of an anchors entry the core verifier reads only that it is an object whose
target is receipt or preRegistration (else malformed, exit 2); a further or a missing member does not
change its verdict. When anchors are verified (§7i), an entry with a member outside the table there makes
the anchor verification FAIL, so --require-anchor is unmet (exit 3).
| field | required | type | meaning |
|---|---|---|---|
alg |
yes | string | MUST be "ed25519". |
public_key_b64 |
yes | string | Base64 of the 32-byte raw Ed25519 public key. |
sig_b64 |
yes | string | Base64 of the 64-byte Ed25519 signature over the raw payload bytes. |
Check ed25519-signature: decode payload_b64 to P, verify the Ed25519
signature sig_b64 over P under public_key_b64. The message is the raw
payload bytes — no pre-hashing, no domain separation.
Ed25519 implementations disagree on adversarially crafted edge-case signatures
(cofactored vs cofactorless verification, the RFC 8032 S-bound, non-canonical
point encodings, small-order components — "Taming the Many EdDSAs",
eprint 2020/1244). proofbundle delegates
verification to cryptography (OpenSSL) and PINS that behavior as a documented
property rather than an undocumented accident:
- cofactorless verification;
- the RFC 8032 S-bound is enforced (a signature whose S ≥ L is rejected);
- a non-canonical R encoding is rejected;
- a non-canonical A (public key) encoding is partially accepted (one of the two published variants verifies);
- small-/mixed-order components are accepted (no torsion check).
Against the "Taming the Many EdDSAs" 12-vector corpus this profile matches the
BoringSSL / Dalek (non-strict) row exactly — ACCEPT {0,1,2,3,11}, REJECT
{4,5,6,7,8,9,10} — observed identically from cryptography 42.0.8 (the declared
floor) through the current release. It is NEITHER Dalek-strict (which
rejects {0,1,2,11} and accepts only vector 3, whose rejection would need a
full-order check no surveyed library performs) NOR ZIP-215 (Zebra, which
additionally accepts {4,5,9,10}). The divergence from Dalek-strict is exactly
{0,1,2,11}; from ZIP-215 exactly {4,5,9,10}. Signatures produced by an honest
RFC 8032 signer over a canonical public key verify identically under all of
these profiles — the divergence envelope exists ONLY for adversarially crafted
signatures. Consequence for cross-verifier consensus: an independent
verifier using a different profile (e.g. ZIP-215) MAY disagree with proofbundle
on such crafted signatures; a relying party that needs multi-implementation
agreement on hostile inputs must pin one profile across its verifiers. The exact
12-vector envelope is vendored (byte-pinned) and asserted by
tests/test_ed25519_semantics.py — a change in the backing library turns the
repository's CI red (a deliberate, documented decision), never a silent drift.
No wire or behavior change is made by documenting this; switching profiles would
be a breaking, versioned change.
The §4a profile is right for checking a signature and wrong for a key a verifier
RELIES on: under a low-order key a signature made with no private key verifies.
Under the identity point the fixed signature R = identity, S = 0 verifies for
every message; under the other points of small order it verifies for about one
message in the key's order, and a forger who varies the message or R finds one
after a few tries. No private key exists for such a key. Therefore every
Ed25519 key that is not the bundle's own signature.public_key_b64 MUST be
canonical (y < p) and MUST NOT be one of the 8-torsion points (y ∈ {0, 1, p−1}
or either order-8 value, under both x-sign bits); a verifier refuses such a key
before any signature arithmetic. This covers the keys a relying party supplies
(trust-policy pins, a DSSE verification key such as --pub, C2SP log and witness
vkeys of §7c/§7d, a status-list issuer key, a time-authority key, a RATS Verifier
key, trust-pack keys of every role and a caller-supplied previous root, the
classical leg of a hybrid signature, an AGT authorizer key) and the keys that
authenticate on another party's behalf (the SD-JWT issuer key of §6, the KB-JWT
holder key). The forgery is the reason for the torsion points; a non-canonical
spelling is refused because a trusted key has exactly one encoding, and of the
nineteen (y = p … p + 18) only y = p and y = p + 1 also spell points of small
order. A refused vkey is a malformed input; any other refused key simply
verifies nothing. The bundle's own key keeps the §4a profile: it is in-band, and
trust in it comes from a pin that already carries this rule.
A key that passes has exactly one encoding, so counting DISTINCT key material
(§7d witness quorums, trust-pack thresholds) counts distinct points. Distinct
points are NOT distinct secrets or parties. A mixed-order key is not refused, and
whoever holds the secret of a key can also sign under its mixed-order variants
(seven, besides the key itself), grinding the nonce of each signature: on average
as many tries as the order of the torsion component (2, 4 or 8). A test keeps a
2-of-2 witness quorum met by two variants of one key against this implementation.
No forgery without a secret follows from that, but a quorum or threshold counts
keys, and whether they belong to different parties is a property of the roster,
as it is for any party that simply holds several keys. The rule is signature.ed25519_trust_anchor_weakness, and the
independent Rust verifier applies the same rule on its DSSE, attached-target,
SD-JWT and trust-pack paths.
| field | required | type | meaning |
|---|---|---|---|
hash_alg |
yes | string | MUST be present and MUST equal "sha256-rfc6962" in this schema version (REQUIRED since v1.6; SPEC.md corrected to match the verifier in this revision). A future hashing algorithm MUST register its own distinct hash_alg value — a verifier MUST NOT silently default a missing value to "sha256-rfc6962", which is exactly where an algorithm-confusion attack would hide. |
leaf_index |
yes | integer ≥ 0 | 0-based index of the payload leaf in the tree. |
tree_size |
yes | integer ≥ 1 | Number of leaves in the tree. |
inclusion_proof_b64 |
yes | array of string | The RFC 6962 inclusion proof: sibling hashes, Base64, leaf-to-root order. |
root_b64 |
yes | string | Base64 of the 32-byte Merkle tree root. |
Hashing (RFC 6962 / RFC 9162 §2):
- Leaf hash:
SHA-256(0x00 || leaf_data). - Interior node hash:
SHA-256(0x01 || left_child || right_child). - Empty tree hash:
SHA-256("").
Check merkle-inclusion: the leaf is the payload bytes P. Recompute the
root from leaf_hash(P), leaf_index, tree_size and inclusion_proof_b64
using the RFC 6962 inclusion-proof algorithm, and require it to equal root_b64.
0 <= leaf_index < tree_size MUST hold. The proof length MUST match the RFC 6962
expected length for (leaf_index, tree_size).
| field | required | type | meaning |
|---|---|---|---|
compact |
yes | string | SD-JWT in compact serialization: an issuer-signed JWT followed by ~-separated disclosures. |
issuer_public_key_b64 |
yes¹ | string | Base64 of the issuer public key, format keyed by the issuer JWT header's alg: the 32-byte raw Ed25519 key for alg: EdDSA, or (since Finding 20, 2026-07-15) the 65-byte SEC1 uncompressed point 0x04‖X‖Y for alg: ES256 (ECDSA P-256, RFC 7518 §3.4). ¹Structurally optional for backward wire-compatibility, but its absence now fails the bundle — see below. |
The sd_jwt_vc block lives outside payload_b64, so the bundle's Ed25519
signature (§4) does not cover it; the only thing that authenticates the
SD-JWT is its own issuer signature. Therefore (secure-by-default since revision
2026-07-11, WP-C1/C2 — a breaking change from the prior null-and-warn behaviour):
Check sd-jwt-disclosures: the compact string is well formed and every
presented disclosure is committed (its digest appears in the issuer-signed
payload's _sd array). This is self-consistency only — it proves nothing about
provenance and is forgeable without any key. Committed means placed as RFC 9901 places
it: an object-property disclosure in the _sd array of any object of the issuer-signed
payload or of a value disclosed in turn, an array-element disclosure as {"...": digest}
in an array, in any order of the presented disclosures; a disclosure placed nowhere, or
in the place of the other kind, fails the check. A disclosure's digest is the base64url
encoding without padding of the hash that the issuer-signed payload's _sd_alg names
(sha-256, sha-384 or sha-512; sha-256 when the member is absent; any other name
fails the check), taken over the US-ASCII bytes of the disclosure as it stands in the
compact string, never over its decoded JSON (RFC 9901 §4.2.3).
Check sd-jwt-issuer-signature: the issuer JWT signature verifies under
issuer_public_key_b64, using the algorithm-specific primitive named by the
issuer JWT header's literal alg claim — EdDSA (Ed25519) or, since Finding 20
(2026-07-15, issue #27), ES256 (ECDSA P-256, RFC 7518 §3.4; the JWS signature
is the fixed-width 64-byte R‖S concatenation, not DER). alg is part of the
signed bytes (the issuer JWT header, base64url-encoded, is covered by its own
signature), so it is cryptographically bound: an attacker cannot relabel it to
route one algorithm's signature through the other's verifier without breaking
the original signature. Any other alg value FAILS (sig_ok false, reason
names the unsupported alg) — no silent "unchecked" downgrade. When sd_jwt_vc
is present but issuer_public_key_b64 is absent, this check FAILS
(reason: unsigned) — the disclosures are unauthenticated and MUST NOT be
treated as a passing credential. There is no opt-out that lets an unsigned
SD-JWT verify.
Both spellings of an ES256 signature verify, and they are one credential
(finding D1, owner decisions of 2026-09-26). ECDSA is malleable: whoever holds a
valid ES256 signature (r, s) can write (r, n − s) without the key (n is the
order of the P-256 group), and it verifies as well. RFC 7518 §3.4 does not
require the low half, OpenSSL (which this implementation uses) signs with either
half and accepts both, and three of the five IETF SD-JWT VC examples vendored in
tests/fixtures/sdjwtvc carry a high s (the fourth and the fifth in the issuer
signature, the second in its Key Binding JWT). A verifier therefore MUST accept
both spellings, and the rules are these:
- An identity is computed over the canonical form. Every identity, receipt
root, deduplication, replay or log key computed from bytes that carry an ES256
signature is computed over the form in which every such signature has
s ≤ n/2: insd_jwt_vc.compact, the issuer JWT's and a trailing Key Binding JWT's (signature.canonical_es256_signature,sdjwt.canonical_sd_jwt_compact). So(r, s)and(r, n − s)have one identity. In this implementation that covers thereceiptanchor root (§7i) and the identity of apb1.receipt token (hf_evals.receipt_token_identity, which is that root of the bundle the token carries). - A foreign issuer's bytes are never rewritten. A compact the implementation
emits, the compact inside a
pb1.token and the bundle a token verifier returns carry the issuer's bytes exactly as presented, with or without a Key Binding JWT: the KB-JWT'ssd_hashcovers the issuer JWT as presented, so a rewritten issuer signature would fail that check at every verifier that hashes the bytes it gets. - A low
sis required only of signatures the implementation makes itself. This implementation makes no ES256 signature today. Its own signatures on these paths (the bundle signature, an SD-JWT it issues, a Key Binding JWT it presents) are Ed25519, which has one spelling (§4a). It also signs with ML-DSA (pqsig.sign_mldsa,checkpoint.cosign_checkpoint_mldsa, the renewal layer); whether an ML-DSA signature has a second spelling was not measured.
The cost is that a receipt and its twin are two different pb1. token strings;
they have one identity. The sd-jwt-key-binding check below accepts an
sd_hash over either spelling of the issuer signature.
Check sd-jwt-issuer-identity (WP-C1): performed iff sd_jwt_vc is present,
its issuer signature verified, and the SD-JWT discloses an issuer. The key that
verified the signature MUST be the key it names (issuer_public_key_b64 is already
the Base64 of the raw/SEC1-encoded key, per §6), with a fingerprint prefix keyed by
the alg that verified: "ed25519:" + issuer_public_key_b64 == disclosed issuer for
alg: EdDSA, "es256:" + issuer_public_key_b64 == disclosed issuer for
alg: ES256. A signature that verifies under an attacker-chosen key while the
always-open issuer names a trusted party is a forged identity (valid signature,
wrong signer) and FAILS (reason: issuer-key-mismatch). The disclosed issuer is the
member named exactly issuer of the issuer-signed JWT payload, the part of
sd_jwt_vc.compact before the first ~; the registered JWT claim iss is not an issuer
identity for this check, and a value carried only in a selective disclosure does not trigger
it. An SD-JWT whose issuer-signed payload has no member issuer performs no
sd-jwt-issuer-identity check.
Check sd-jwt-bundle-binding (WP-C1 + N1): performed when sd_jwt_vc is present
and its issuer signature verified, in two cases:
payload_b64, decoded, read as UTF-8 and parsed with the strict reader of §2, is a JSON object whose memberschemais the stringproofbundle/eval-claim/v0.1(members such aspassed,thresholdorsuitedo not make a payload an eval claim; onlyschemadoes), and the bundle carriesmerkle.root_b64: the SD-JWT's always-open disclosures (passed,threshold,comparator,suite,issuer) and its committedreceipt.root_b64MUST match the bundle payload and bind this bundle's Merkle root. The five members are read from the issuer-signed payload of the SD-JWT (the part ofsd_jwt_vc.compactbefore the first~; a value carried only in a selective disclosure does not count) and from the bundle payload; each must be present in both, a member absent from either side never binds, and two values match when their RFC 8785 bytes are equal: strings and object members exactly, numbers by value (1equals1.0, as RFC 8785 writes both1), andtrueandfalsenever equal to a number.receipt.root_b64of the issuer-signed payload must be a string equal, character for character, tomerkle.root_b64. A valid issuer signature over disclosures that describe a different bundle (a receipt lifted and grafted on — cross-receipt substitution) FAILS (reason:unbound/mismatch).- N1 (since 3.1.1):
payload_b64is not an eval-claim, but the SD-JWT carries an eval-binding root commitment (areceipt.root_b64string in its always-open payload). There is nothing on this bundle for that commitment to bind to, so the check is added and FAILS fail-closed (reason:unbindable eval SD-JWT). The discriminator is the presence ofreceipt.root_b64(the real substitution vectorcheck_binds_bundleuses), not a heuristic word-match onpassed/threshold/etc. A generic SD-JWT-VC (iss/vct, noreceipt.root_b64commitment) carries no eval anchoring claim and is out of scope — it does not add this check (backward-compatible). A conforming verifier MUST implement case 2, or it remains vulnerable to grafting a signed eval SD-JWT onto an arbitrary payload.
A payload that the strict reader refuses (a duplicate key at any depth, §2; NaN,
Infinity or -Infinity; a number with no finite IEEE 754 double; an integer literal
outside ±(2^53 − 1); nesting beyond the reader's depth bound) also performs the check,
and it FAILS (reason: unreadable claim): whether it is an eval claim cannot be
read, so it does not read as no claim. A payload that is not UTF-8 or not JSON text (a
leading byte order mark, a raw control character inside a string) is no eval claim;
only case 2 can add the check for it.
Check sd-jwt-key-binding (since v1.2, RFC 9901 §4.3): performed iff the
compact serialization carries a trailing Key Binding JWT (a compact form ending
in ~ carries none). The check is fail-closed — a present KB-JWT that cannot
be verified fails the bundle; it is never silently ignored. The verifier
requires: header typ = kb+jwt and alg = EdDSA; payload claims iat,
aud, nonce, sd_hash all present; sd_hash = base64url(H(US-ASCII of the
presented <Issuer-signed JWT>~<Disclosure 1>~…~<Disclosure N>~)) with H the
SD-JWT's _sd_alg hash, where for an ES256 issuer JWT a match over either
spelling of the issuer signature counts (the holder hashed the spelling it
received; the two differ in the signature segment only, see above); and the
KB-JWT signature verifies under the holder key
from the issuer-signed payload's cnf.jwk (RFC 7800, OKP/Ed25519 — the issuer's
binding is authoritative). aud/nonce value policy and iat freshness are
the relying party's (an offline verifier has no trusted clock); the library
exposes them and accepts expected_aud/expected_nonce parameters.
Scope of the SD-JWT support (stated honestly): the SD-JWT core is
RFC 9901 (2025); the verifier does
not verify an X.509 / trust-list chain, status lists, or resolve vct
type-metadata documents (schema/display metadata — an offline integrity pin
on opaque metadata bytes is a separate, already-implemented capability,
sdjwt_vc.check_vc_profile's requireTypeMetadataIntegrity). SD-JWT VC is
the IETF draft
draft-ietf-oauth-sd-jwt-vc;
proofbundle implements its issuer-signature algorithms (EdDSA and, since
Finding 20, ES256), its typ/vct syntactic markers, a relying-party vct
allowlist check (sdjwt_vc.check_vc_profile), and — since Finding 20 — an
exact-vct trust-policy pin (sd_jwt.expected_vct, evaluated by
policy.evaluate_policy — the trust-policy schema itself is not part of this
wire-format spec; see schemas/trust_policy_v0_1.schema.json). See
docs/SD_JWT_VC_PROFILE.md for the current, honest split of what remains on
the roadmap against issue #27.
A conforming verifier MUST perform, in this order, and report each result:
- schema — reject if
schema != "proofbundle/v0.1". - ed25519-signature (§4).
- merkle-inclusion (§5).
- sd-jwt-disclosures and sd-jwt-issuer-signature — only if
sd_jwt_vcis present (§6). Since revision 2026-07-11, a presentsd_jwt_vcwith noissuer_public_key_b64makes sd-jwt-issuer-signature FAIL (unsigned → unauthenticated); it is not a skipped or warning-only check. - sd-jwt-key-binding — only if
sd_jwt_vcis present AND its compact serialization carries a Key Binding JWT (§6, since v1.2; fail-closed). - sd-jwt-issuer-identity — only if
sd_jwt_vcis present, its issuer signature verified, and it discloses anissuer(§6, WP-C1; fail-closed against a forged issuer identity). - sd-jwt-bundle-binding — if
sd_jwt_vcis present and its issuer signature verified, in EITHER of two cases (§6): the payload is aproofbundle/eval-claim/v0.1claim by itsschemamember (WP-C1; fail-closed against cross-receipt substitution; a payload the strict reader refuses fails the check), OR the payload is NOT an eval-claim but the SD-JWT carries an eval-bindingreceipt.root_b64commitment (N1, since 3.1.1; fail-closed against grafting a signed eval SD-JWT onto an arbitrary payload). A generic SD-JWT-VC with noreceipt.root_b64is out of scope. 7a. sd-jwt-issuer-trust — only if sd-jwt-key-binding was performed and passed: the SD-JWT is trusted when its issuer signature verified and either the relying party pinned an issuer key (a trust policy'ssd_jwt.issuer_key_pin) that matches the key that verified the SD-JWT, or, with no pin, sd-jwt-bundle-binding passed and the key that verified the SD-JWT is the key under which ed25519-signature verified the bundle. A supplied pin decides alone. A sd-jwt-bundle-binding that was not performed (a payload that is no eval claim, with an SD-JWT that carries noreceipt.root_b64) has not passed: with no pin the check then FAILS, whether or not the two keys are equal. The check is reported only when it FAILS, and its failure fails the bundle (exit 1) like any other failed check, also when every other check passed. - root-authenticity and tree-size — additive relying-party root
authentication (ADR 0004, since the root is NOT in the signature input, §5).
Performed ONLY when the relying party supplies an expected value: a given
expected_root_b64MUST equal the stated root's bytes, and a givenexpected_tree_sizeMUST equalmerkle.tree_size; a mismatch FAILS. Absent an expected value, root authenticity isNOT_EVALUATED(the verdict is unchanged). A trust policy MAY additionally require an authenticated root (merkle.require_authenticated_root/merkle.trusted_roots), enforced over the crypto result (exit 3, not part of this crypto order). A conforming verifier MUST reportpayloadSignature/merkleConsistency/rootAuthenticity(PASS/FAIL/NOT_EVALUATED) separately, so Merkle inclusion is never read as root authentication. - checkpoint-authenticity (since 3.1.3, A-P0-1) — performed ONLY when the relying
party supplies a trusted checkpoint (
--trusted-checkpoint+--checkpoint-vkey, a signed C2SP note per §7c). The checkpoint signature MUST verify under the supplied verifier key; the checkpoint's(root, tree size)pair then becomes the expected root AND expected tree size ATOMICALLY (feeding check 8). A checkpoint that does not verify FAILS the verdict — the expectations it would have carried are never used unauthenticated. The signature authenticates THAT a log signed, not WHICH log (since 3.8.0): a checkpoint's C2SP origin line and the name in its signature block are separate fields, and a signer MAY legitimately serve several origins under one key. A relying party that pins only the verifier key therefore has NOT pinned the tree it trusts.--expected-origin(CLI) /expected_origin=(verify_witnessed_checkpoint) binds it; the comparison MUST be EXACT (no prefix, case-folding, trimming or substring match). It is OPT-IN and defaults to unconstrained, matchingverify-proof --expected-origin: a verifier MUST NOT invent a default origin, and a conforming implementation MUST report the origin it observed so an unpinned run stays auditable.
The bundle verifies iff every performed check passes. Trust anchors (the expected signer key, the expected Merkle root) are inputs the relying party supplies out of band; the verifier does not fetch anything.
Root and tree size MUST be authenticated atomically for production trust. An RFC 6962
inclusion proof constrains (leaf_index, tree_size) only up to path-shape equivalence: a
receipt honestly anchored at index 1 of a 2-leaf tree also verifies relabelled as index 2 of
a claimed 3-leaf tree — same payload, same signature, same root, same proof. A root-BYTES
pin (bare expected_root, or a policy trusted_roots entry) cannot distinguish the two,
because both labelings share the root. Therefore:
TREE_CONTEXT_AUTHENTICITY: PASSrequires that the stated root AND the statedtree_sizeboth match ONE authenticated source: a verified signed checkpoint (--trusted-checkpoint/--checkpoint-vkey, or a policymerkle.trusted_checkpointsentry — signature-valid under its pinnedcheckpointSigner, unexpired, supportedhashAlg), or a relying-party-suppliedexpected_root+expected_tree_sizePAIR.- A naked root pin reaches at most
ROOT_BYTES_AUTHENTICITY: PASS— NEVERTREE_CONTEXT_AUTHENTICITY: PASSand neversafeForAutomation: true(rootTrustLevel: ROOT_BYTES_ONLY). - The verifier reports
rootTrustLevel∈CHECKPOINT(atomic pair from a verified signed checkpoint) /ROOT_AND_TREE_SIZE_PINNED(atomic pair supplied directly by the relying party) /ROOT_BYTES_ONLY/NONE, andcheckpointAuthenticity(PASS/FAIL/NOT_EVALUATED). The legacyrootAuthenticitykey remains as a wire-compat ALIAS ofrootBytesAuthenticity; it never asserted more than root bytes and MUST NOT be read as tree-context or automation trust.
safeForAutomation is a global "safe to act on automatically" verdict, distinct from the crypto
verdict. It is true ONLY when: the crypto verification passed; the Merkle root was affirmatively
authenticated (expected_root or a policy trusted_roots / require_authenticated_root); root and
tree size were authenticated ATOMICALLY from one source (TREE_CONTEXT_AUTHENTICITY: PASS, since
3.1.3 — see "Atomic tree context" above; a root-bytes-only pin never qualifies); a supplied
trust policy PASSED (policy_ok is True — no policy, i.e. None, never qualifies); that policy pins a
trusted signer identity; the policy is not a raw template (requiresIdentityOverlay is not set); the
policy carries no blocking warning, is not expired and is not yet-to-become-valid; and no required
anchor / public-transparency / replay gate FAILED. Expiry is INCLUSIVE: a policy is valid up to and
including its valid_until instant and expired strictly after it. A conforming verifier that emits the
field MUST also emit automationBlockers — an array naming every reason it is false, drawn from at
least: POLICY_NOT_EVALUATED, POLICY_FAILED, SIGNER_NOT_PINNED, TEMPLATE_NOT_INSTANTIATED,
ROOT_NOT_AUTHENTICATED, TREE_CONTEXT_NOT_AUTHENTICATED, POLICY_EXPIRED, POLICY_NOT_YET_VALID,
POLICY_WARNINGS_PRESENT, ANCHOR_REQUIRED_FAILED, PUBLIC_TRANSPARENCY_REQUIRED_FAILED,
REPLAY_BINDING_REQUIRED_FAILED, CRYPTO_FAILED. The human and machine forms of the verdict MUST
agree.
Policy lifecycle and purpose (normative since 3.1.3 — A-P0-2 / A-P0-4). Policy lifecycle is part
of the policy EVALUATION itself on BOTH verify paths, not only of the automation verdict: a policy
whose valid_until is in the past, whose valid_from is still in the future, or which is a raw
template (requiresIdentityOverlay: true) FAILS the policy (POLICY: FAIL, exit 3). Historical
verification never happens silently: only an explicit --verification-time <ISO-8601> (together with
--policy) evaluates the lifecycle AS OF that instant, and the output is labelled
(VERIFICATION_TIME: HISTORICAL, CURRENT_POLICY_STATUS, HISTORICAL_POLICY_STATUS). Historical mode
relaxes ONLY the POLICY verdict (exit code + label); safeForAutomation is a PRESENT-tense verdict
whose lifecycle and tree-context inputs are always evaluated at the REAL current time, so a policy that
is expired OR not-yet-valid TODAY, or a trusted_checkpoints entry whose validUntil is past TODAY,
keeps safeForAutomation: false (POLICY_EXPIRED / POLICY_NOT_YET_VALID /
TREE_CONTEXT_NOT_AUTHENTICATED) even in historical mode. --verification-time MUST be a PAST instant
(a historical query); a future instant is a usage error (exit 2). A policy
MAY declare policyPurpose ∈ eval / decision / outcome / trust-pack / public-transparency:
the eval verify path accepts only eval, the decision path only decision; the wrong purpose FAILS
the policy (exit 3). A policy without the field is treated as a transitional legacy policy (no purpose
check); policy lint --strict requires the field. Pinned merkle.trusted_roots entries MUST be valid
standard base64 decoding to exactly 32 bytes — a malformed pin is a load-time error with its own
reason, never a silent never-matches (A-P0-5).
Enforcement status of the gate conditions (No-Overclaim). ANCHOR_REQUIRED_FAILED,
POLICY_EXPIRED, POLICY_NOT_YET_VALID, TEMPLATE_NOT_INSTANTIATED, SIGNER_NOT_PINNED,
POLICY_FAILED, POLICY_NOT_EVALUATED, POLICY_WARNINGS_PRESENT, ROOT_NOT_AUTHENTICATED,
TREE_CONTEXT_NOT_AUTHENTICATED and CRYPTO_FAILED are LIVE:
the reference verifier wires each to a real verdict. PUBLIC_TRANSPARENCY_REQUIRED_FAILED and
REPLAY_BINDING_REQUIRED_FAILED are forward-compatible/dormant in the current core: the reference
CLI never supplies a False value for them (the public-transparency policy-evaluation library is built
EXPERIMENTAL as public_transparency.py in 3.2.0, but is not yet wired into the reference CLI's --policy
enforcement — see docs/PUBLIC_TRANSPARENCY_PROFILE.md; replay/audience binding already fails the
CRYPTO verdict when a required KB-JWT is absent). They are enumerated so a future policy layer can flip
them without a format change; today they never fire.
treeSizeExpectation is an additive object {status: PASS|FAIL|NOT_REQUESTED, expected, actual} making
the tree-size gate's outcome explicit (NOT_REQUESTED when no expected_tree_size was supplied). The
tree-size check is evaluated INDEPENDENTLY of the root: a mismatch fails the crypto verdict on its own,
and without a pinned root the root itself remains unauthenticated.
A trust policy MAY carry additive metadata: deploymentReady / requiresIdentityOverlay (a policy is a
raw TEMPLATE until instantiated with a signer overlay) and valid_until (an ISO-8601 UTC expiry).
requiresIdentityOverlay: true is a HARD gate on the automation verdict — such a policy can never yield
safeForAutomation: true (blocker TEMPLATE_NOT_INSTANTIATED), and an expired valid_until yields
POLICY_EXPIRED — not merely policy lint --strict advisories; they never change the crypto verdict.
The same template-not-instantiated and expiry gates are ALSO enforced on the decision verify path
(over decision_receipt.trusted_decision_makers), so a raw or expired decision policy cannot authorise a
decision (exit 3).
On the receipt verify paths a policy constrains the signer only through that section: on the decision verify path a policy without a decision_receipt section, or with one that pins no
trusted_decision_makers, constrains no signer, and a pin that does not match the key the receipt
verified under fails the policy (exit 3); the outcome verify and relation-statement verify paths
judge a policy by its relations section alone, so a policy without one, or one that sets a rule the
path does not apply, a decision_receipt section among them, is refused (exit 2). On all three paths
valid_from, valid_until, requiresIdentityOverlay, policyPurpose and the relations section
apply, and an unmet one fails the policy (exit 3). Of the relations rules, require_relation_resolution,
reject_superseded, relation_signer and require_relation_target apply on all three paths, and
reject_retracted applies only on the relation-statement verify path; the decision verify and outcome verify paths refuse a policy that sets reject_retracted (exit 2).
A bundle attests the authenticity and integrity of the exact payload bytes — signed by the stated
key, anchored under the stated Merkle root. It does not attest the correctness of any computation that
produced the payload, nor the absence of cherry-picking in an eval it carries. Those are separate concerns
(e.g. trusted-execution audits) with different trust models.
A receipt MAY be exported as a DSSE-signed in-toto attestation using the generic in-toto
test-result predicate, so a generic in-toto verifier understands it (alongside the self-hosted
predicate of §PREDICATE.md). The mapping is fixed:
- Statement —
_type=https://in-toto.io/Statement/v1;predicateType=https://in-toto.io/attestation/test-result/v0.1(there is no v1; the predicate is v0.1).subjectis a single ResourceDescriptor with a realdigest(a sha256 over a stable binder of the receipt's model and dataset commitments, root, and timestamp — a hash that binds the attestation to the receipt, not the model itself). - Predicate (test-result/v0.1) —
resultisPASSEDwhen the threshold holds, elseFAILED(WARNED is unused);configurationis a required list of ResourceDescriptors for the model and dataset commitments. Each descriptor carries adigest(a salted commitment hex under a proofbundle-specific algorithm key — neversha256, which would falsely imply an artifact hash); a barename-only descriptor is invalid per the ResourceDescriptor rule. test-result has no native metric field and no predicate-level annotations, so metric/comparator/threshold/passed/provenance live in the model descriptor'sannotations.passedTests/failedTestscarry the suite name. A verifier refuses a test-result statement whose predicate lacksresultorconfiguration, the two members the in-toto predicate marks required;urland the case lists are optional. It also refuses one whose members are not of the type the predicate gives them, for a generic statement as for one of ours:resultone ofPASSED,WARNED,FAILED,urla string in URI form (ResourceURI, the form only, never fetched;to_test_result_statementrefuses aurlin another form), each case list an array of strings, and eachconfigurationentry a ResourceDescriptor that specifies one ofuri,digestorcontent. - DSSE —
payloadType=application/vnd.in-toto.test-result+json;payload= base64 of the serialized Statement bytes in the standard (RFC 4648 §4) or the URL-safe (RFC 4648 §5) alphabet, which the DSSE envelope allows ("Either standard or URL-safe encoding is allowed"), padded and canonical in either alphabet; the exporter writes the standard alphabet, and a payload without its padding does not verify. The rule of section 2, the standard alphabet only, governs the native bundle fields, not this envelope.signatures[].sig= base64 of the raw Ed25519 signature. The signature is over the DSSE PAE:"DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(body) SP body, whereLENis the ASCII-decimal byte length with no leading zeros andbodyis the raw Statement bytes — never the base64 string. A verifier decodespayloadand reconstructs the PAE over exactly those bytes (it never re-serializes), and pinspayloadType(sign and verify MUST use the same string).
A receipt's RFC 6962 Merkle root MAY be published as a
C2SP tlog-checkpoint signed note: a note text
of at least three non-empty U+000A-separated lines — origin (schemeless, no space/+), tree size (ASCII
decimal, no leading zeros), and the root in standard base64 (RFC 4648 §4, not base64url) — ending in
U+000A, followed by an empty line and one or more signature lines. A signature line is
U+2014 SP keyname SP base64(keyID ‖ signature) U+000A (U+2014 is the EM DASH, not a hyphen), where
keyID = SHA-256(keyname ‖ 0x0A ‖ 0x01 ‖ ed25519_pubkey)[:4] and the signature is the raw Ed25519
signature over the note-text bytes including the trailing newline (raw bytes, no PAE). The verifier key
is distributed as keyname + "+" + hex8(keyID) + "+" + base64(0x01 ‖ pubkey).
A checkpoint (§7c) MAY additionally carry witness cosignatures per
C2SP tlog-cosignature: verifying a quorum of
cosignatures rules out a split view by the log operator, entirely offline. A cosignature is a note
signature line (same U+2014 SP name SP base64(blob) framing as §7c) where the blob is exactly
keyID[4] ‖ timestamp[8, big-endian u64] ‖ ed25519_signature[64] (76 bytes). The witness
keyID = SHA-256(witness_name ‖ 0x0A ‖ 0x04 ‖ ed25519_pubkey)[:4] — algorithm byte 0x04
(cosignature/v1), deliberately distinct from the log's 0x01 so a log key can never masquerade as a
witness. The signed message is "cosignature/v1\n" ‖ "time <timestamp>\n" ‖ <whole note body including the final U+000A, excluding signature lines>. The timestamp is a POSIX timestamp ≤ 2^63−1; freshness
policy is the relying party's (offline verifier, no trusted clock). Witness verifier keys use the §7c
vkey encoding with algorithm byte 0x04. A witnessed checkpoint verifies iff the log signature (§7c)
verifies AND at least threshold cosignatures from distinct witness key material verify — witnesses
attest consistency, they never replace the log's own signature. A log does not vote in its own witness
quorum: a cosignature is excluded when its key material equals the log's own signing key (the robust,
algorithm-agnostic test — the relying party supplies the log key), OR when its name equals the origin
line (an exact-codepoint name test; robust for ML-DSA-44, whose signed message binds the cosigner name,
and a defence-in-depth catch for Ed25519, whose cosignature/v1 message does NOT bind the name). The
origin line itself is refused if it carries whitespace, zero-width or control characters, so a look-alike
name cannot cloak the compare (fail-closed; the C2SP specs are silent on self-cosignature, so the
verifier holds the line). Honest limit: a log cosigning with a SEPARATE key under a non-origin alias that
a relying party wrongly trusts as an independent witness is roster provenance, not a local check. Real
split-view resistance additionally requires the witnesses to be operationally independent, which is a
deployment property outside this format.
Since v1.3 the ML-DSA-44 cosignature type (algorithm byte 0x06, FIPS 204) is also
verified: witness keyID = SHA-256(witness_name ‖ 0x0A ‖ 0x06 ‖ 1312-byte pubkey)[:4], blob =
keyID[4] ‖ u64-BE-timestamp ‖ signature[2420] (2432 bytes exactly). The signed message is the
C2SP cosigned_message structure (RFC 8446 conventions): fixed 12-byte label "subtree/v1\n\0",
opaque<1..2^8-1> cosigner name, u64 timestamp, opaque<1..2^8-1> log origin, u64 start (0 for a
checkpoint), u64 end (= tree size), 32-byte root. Unlike Ed25519 cosignatures it commits to the
cosigner NAME and NOT to checkpoint extension lines. Verification requires a cryptography build
with ML-DSA (proofbundle[pq]); a configured ML-DSA witness on a build without it raises
UnsupportedError — fail-closed, never a silent False. Ed25519 and ML-DSA witnesses mix freely in
one quorum; the C2SP spec says ML-DSA-44 SHOULD be used for new witness deployments.
A receipt's inclusion evidence MAY be carried as a
C2SP tlog-proof file (extension
.tlog-proof): line 1 exactly c2sp.org/tlog-proof@v1; an OPTIONAL extra <base64> line
(opaque and unauthenticated — a verifier MUST NOT trust it); an index <decimal> line
(zero-based, no leading zeros); zero or more standard-base64 SHA-256 inclusion-proof hashes, one
per line, leaf-sibling upward (RFC 6962 §2.1.1); one empty line; then a signed tlog-checkpoint
(§7c/§7d) verbatim. The proof/checkpoint split is the FIRST empty line. Verification order:
(1) recompute the leaf hash from the exact payload bytes (RFC 6962 leaf_hash, never taken from
the file), (2) log signature over an acceptable origin, (3) witness cosignatures against a k-of-n
policy over DISTINCT witness key material — never counting a cosignature made with the log's own
signing key or under its own origin name (§7d), (4) inclusion proof binds the leaf at index to the
checkpoint root at its size. The overall verdict is the CONJUNCTION of all four — each sub-verdict
is reported. Cosignature timestamps are verified-then-ignored; freshness is relying-party policy.
Note: the C2SP spec file is on main and not yet version-tagged; the format string is pinned.
A receipt SD-JWT MAY carry a status.status_list.{idx, uri} claim
(draft-ietf-oauth-status-list,
in the RFC-Editor queue — wire format frozen at draft-21). Revocation state is checked OFFLINE
against a supplied Status List Token snapshot: a signed JWT with header typ =
statuslist+jwt and alg = EdDSA (this profile), payload sub (MUST equal the receipt's
uri), iat (REQUIRED), optional exp/ttl, and status_list: {bits, lst} with bits ∈
{1,2,4,8} and lst = base64url(zlib(bit-array)), statuses packed LSB-first. Registered values:
0x00 VALID, 0x01 INVALID, 0x02 SUSPENDED. Freshness is REPORTED (iat/exp/ttl) and only
JUDGED when the relying party supplies its own clock — an offline verifier has no trusted time.
The bundle format (§3) is UNCHANGED: the snapshot is a separate verifier input, never a bundle
field. The SD-JWT issuer header is typ: dc+sd-jwt with a vct type URI since v1.3 (SD-JWT VC
draft markers; full VC conformance remains deferred until that draft is an RFC).
An eval claim MAY carry a samples object {root_b64, n, leaf_alg}: an RFC 6962 SHA-256
Merkle tree head over ONE leaf per sample, committed in canonical order (sorted by sample
identity; the 0-based position idx is embedded INSIDE each leaf record). samples.n MUST
equal the claim's n — the signature, not the inclusion proof, is the truth anchor for the
tree size: an RFC 6962 inclusion proof constrains n only up to path-shape equivalence
(measured: index 4 of a 10-leaf tree verifies under any claimed n′ ∈ [9..16]).
leaf_alg = sha256-rfc6962-sdjwt-v1: leaf hash = RFC 6962 leaf hash (0x00 domain
separation) over the US-ASCII bytes of a base64url disclosure encoding [salt_b64, record] (RFC 9901 digest mechanic — verification re-hashes the transported string, never
canonicalizes JSON). Salts are per-leaf, ≥128 bit, derived
HMAC-SHA-256(tree_secret, "proofbundle/v2/leaf-salt" ‖ id ‖ 0x00 ‖ epoch)[:16] from ONE
holder-kept secret (never in the receipt); revealing one salt reveals nothing about siblings
(HMAC-as-PRF). An opening = {index, disclosure, proof_b64[]}; verification recomputes
the leaf hash, checks inclusion at index under the SIGNED (root, n), decodes the disclosure,
and enforces record.idx == index (replay guard against a lying producer — the lie sits
inside the committed leaf, so only this check catches it).
Audit challenge = SHA-256("proofbundle/v2/audit-challenge" ‖ root ‖ u64(n) ‖ u64(k) ‖ nonce), expanded by HMAC-SHA-256 counter mode into u64 draws, mapped to [0, n) by rejection
sampling (accept iff v < ⌊2^64/n⌋·n; zero modulo bias), duplicates skipped until k distinct
indices. Nonce modes: auditor nonce (fresh, supplied after signing — grinding impossible;
the default for audits), public beacon (a pulse from a round emitting AFTER the signed
timestamp, RFC 3797-style, publicly re-verifiable — since v1.9 formalized as
nonce = SHA-256("proofbundle/v1.9/beacon-nonce" ‖ 0x00 ‖ beacon_id ‖ 0x00 ‖ u64(round) ‖ pulse_randomness) binding a drand/NIST beacon id + round, so any third party re-derives the
same indices; the relying party validates the beacon's own signature and the round's emission
time out of band), self-challenge (empty nonce; sanity check ONLY — a producer can grind by
re-salting, escaping with ≈ g·(1−m/n)^k over g attempts). Soundness is the proof-of-retrievability
bound 1−(1−m)^k, independent of n (k=300 → 95% at m=1%; 459 → 99%).
Openings are auditor-directed and never part of the public receipt (every opened sample is
burned for future evals — contamination economics are the relying parties' policy). The domain
strings are pinned at proofbundle/v2/* (protocol identifiers, independent of the package
version).
A receipt MAY carry assurance_level = enclave_attested and be accompanied by a TEE Attestation
Result that a relying party verifies offline. This is a preview (proofbundle.experimental,
[experimental] extra; API/wire-format unstable). Model: IETF RATS Passport (RFC 9334). The
enclave places enclave_binding_for(receipt) = base64url(SHA-256("proofbundle/v2/enclave-binding"
‖ payload)) into its hardware quote user-data (Intel TDX REPORTDATA / NVIDIA GPU report nonce);
a RATS Verifier appraises the raw evidence out of band and issues a signed EAT (RFC 9711,
JSON/JWS, EdDSA) whose eat_nonce equals that binding. proofbundle verifies, offline: the EAT
signature under the Verifier key (a supplied trust anchor), typ = eat+jwt, alg = EdDSA,
eat_nonce == the binding, and optionally eat_profile. It reports the tier (this
preview's stand-in for the still-draft AR4SI/EAR trustworthiness tier) VERBATIM. proofbundle does
NOT parse or appraise raw hardware evidence — that is the Verifier's role. See
docs/EXPERIMENTAL_ENCLAVE.md.
A receipt MAY carry a top-level anchors array of external time-anchor
evidence. An anchor adds evidence of when, from a party the producer does not
control — something the receipt's own Ed25519 signature and Merkle structure
cannot establish on their own (a self-emitted timestamp is only producer-clock
testimony). This layer is EXPERIMENTAL: the wire format MAY change, the base
install does not verify it, and a receipt with no anchors verifies exactly as
before. The prose companion is docs/ANCHORS.md.
This field is DETACHED from the content root. An anchor is evidence about
the receipt, never part of what it attests. A receipt-target anchor stamps the
canonical root of the receipt without its own anchors field (an anchor
cannot attest a root that already contains itself); a verifier computing that
root MUST exclude anchors. Anchor bytes are never part of any signed payload.
Producer rollout — one-way compatibility. Emitting anchors[] is a one-way
compatibility step. anchors became a KNOWN top-level field only in this
revision (2026-07-10); a verifier built against an earlier SPEC revision does not
list it, so — because the bundle schema is additionalProperties: false (§3) —
it rejects an anchored bundle as malformed (exit 2) rather than ignoring the
field. This is not a security bug (a fail-closed verifier erring toward rejection
is correct), but a producer that starts adding anchors[] SHOULD know that older
verifiers will refuse the bundle until they are updated to this revision.
Each anchors[] entry is a JSON object:
| field | required | type | meaning |
|---|---|---|---|
type |
yes | string | A REGISTERED type name, compared as an exact string against the verifier's anchor-type registry: rfc3161-tsa and opentimestamps are built in, chia-datalayer/v1 is the first-party extension that always registers. An unknown type is a FAIL, never a silent pass. |
target |
yes | string | receipt or preRegistration (see below). |
canonicalRoot |
yes | string | Base64 of the canonical root of the anchor's OWN target. |
proof |
yes | string | Base64 of the type-specific proof (an RFC 3161 token, an OpenTimestamps proof, …). |
anchoredAt |
no | string | null | RFC 3339 Z, INFORMATIVE only — the trusted time comes from the proof, never this field. |
frozen |
no | object | OPTIONAL producer-supplied type-specific EVIDENCE bundled at emit time (e.g. the TSA certificate chain, a Bitcoin block header). WP-A1 (rev 2026-07-11): frozen is producer-controlled and is NEVER a trust source — it is reported as evidence (frozenEvidence) but a confirmed verdict requires the relying party's own trust material (see Trust model). The frozen intermediateCertsDerB64 / tsaCertDerB64 are path-building only (validated up to the RP root); an optional policyOid still pins stricter-only. |
| target | claim | canonical root |
|---|---|---|
preRegistration |
the commitment existed before the run (backdating protection; in-toto/attestation#565) | SHA-256 of the raw protocol bytes, i.e. the receipt's prereg_sha256 |
receipt |
the receipt existed from time T (publication proof) | RFC 8785 (JCS) SHA-256 of the receipt bundle excluding anchors, computed over the canonical form in which every ES256 signature in sd_jwt_vc.compact (the issuer JWT's and a trailing Key Binding JWT's) has s ≤ n/2 (§6; the steps are in docs/ANCHORS.md). The bundle itself is not rewritten. |
A verifier reads the preRegistration root from the payload read as an eval claim
(EVAL_CLAIM.md §5): it is the 32 bytes that the 64 hex characters of prereg_sha256 spell, not a
hash of those characters. A payload that is not an eval claim, or carries no prereg_sha256, gives
the receipt no preRegistration root, and a preRegistration anchor on it fails as an anchor whose
target has no root.
The type name is an identifier, not a grammar (rev 2026-09-20). An earlier revision of the
row above gave the extension form as <org>/<name>/vN, which reads as if a verifier could accept or
reject a type by its SHAPE. It cannot, and no anchor type this project ships has that shape.
Measured 2026-09-20 over every name that reaches register_anchor_type: rfc3161-tsa and
opentimestamps (the two built-ins this document itself names as valid), chia-datalayer/v1 (the
shipped first-party extension, which always registers) and markovian-provenance/v1 (a shipped
third-party example, registered only when a caller registers it). All four fail
^[^/]+/[^/]+/v[0-9]+$, the two built-ins most obviously of all. The decision is a membership test
and nothing else: the reference verifier asks whether type is a key of its registry, and the
bundle schema puts no pattern on the field. A conforming verifier MUST therefore match type as
an exact string against what it has registered; <org>/<name>/vN is a RECOMMENDED shape for NEW
extension names and nothing more. A verifier that enforced it as a grammar would reject the
receipts this implementation emits.
Honest limits of that measurement, because a list of four invites the reading that it is the whole
world. The registry is populated at first use and degrades on purpose: without the [anchors]
extra the two built-ins do not register at all, so the set a given install carries is smaller, not
different in kind. And the identifiers of the C2SP checkpoint machinery (§7c/§7d), such as
markovianprotocol.com/bitcoin-anchor/rootcommit/v1, are NOT anchor types in the sense of this
row: they name a signature block on a checkpoint, never a member of anchors[], and they are
listed here only so a reader does not go looking for them in the registry.
What the reference registers. Its verify command line carries chia-datalayer/v1 always, and
rfc3161-tsa and opentimestamps when the [anchors] extra is installed; markovian-provenance/v1
is carried only once a caller registers it (anchors_markovian.register()), which the command line
does not do. An entry whose canonicalRoot is not its target's root FAILs before the verifier of its
type runs; an entry of a type that is not registered FAILs with no verifier registered for anchor type '<type>', exactly as an unknown type, so to a verifier that does not carry a type an entry of
that type is an unknown type. The one anchored case of the conformance corpus is marked
requiresAnchorsExtra: it is read with the extra installed, and without it a run with
--require-anchors fails and any other run does not run the case.
canonicalRoot is compared to the root of the anchor's OWN target: a
preRegistration anchor can never validate a receipt target and vice versa
(the roots differ, and a mismatch is a FAIL). A future statement target — for
a signed decision receipt whose content root is the DSSE Statement bytes — is
RESERVED (in-toto/attestation#565, proofbundle#7) and is not part of this
experimental layer yet.
Verification is fail-closed and reported as one aggregate status plus a per-entry status:
- absent — missing/empty
anchors→ SKIP (never FAIL). This matches in-toto's Monotonic Principle: deny only when an attestation is present and wrong, not when it is absent. - confirmed — a fully verifying anchor (
ok): its root matches the target, its type is known, and its proof verifies. All entries confirmed → PASS. - pending — an anchor that is honestly not yet a full external-time proof
(a
warn), e.g. an un-upgraded OpenTimestamps proof or a Merkle-only chia-datalayer level-i anchor → WARN. It is never conflated with a confirmed anchor.
A root mismatch, an unknown type, a broken proof, or a valid-but-untrusted anchor
(needs_rp_trust — an upgraded, structurally-bound proof for which the relying
party supplied no trust material; see Trust model) is a hard FAIL; a
verifier that raises is treated as FAIL. verify --require-anchor (optionally
narrowed by --anchor-type <type>) turns "no verifying anchor (of that type)"
into a FAIL — a relying-party gate OVER the crypto result, exit 3 when unmet
(distinct from a crypto failure, exit 1), exactly like --policy. A pending
anchor does NOT satisfy --require-anchor unless --allow-pending is given.
A FAIL of a present anchor never changes the crypto verdict: without --require-anchor the exit is the
one the bundle has without its anchors, and under --require-anchor the anchor leaves the gate unmet,
exit 3.
Target gate (WP-A1). --anchor-target receipt|preRegistration|statement
(implies --require-anchor) additionally requires the verifying anchor to
stamp THAT target: matched = ok ∧ ¬warn ∧ type ∧ target. Without it the
requirement matches the TYPE alone — a receipt anchor stamped today would
satisfy a relying party who meant backdating protection (preRegistration),
although existence-now proves nothing about existence-before-the-run. The same
requirement is expressible as a policy key (trust-policy v0.2 anchors
section: require_anchor, require_anchor_target, allow_pending); a CLI
flag conflicting with the policy value is an ambiguity error (exit 2), never a
silent override.
Structured trusted time (WP-A2). A verifying anchor's per-entry result MAY
carry trustedTime — {source: "rfc3161_gen_time", time: <RFC 3339>, tz: "Z"}
from a verified RFC 3161 token's own gen_time, or
{source: "bitcoin_block", height: <int>} from a confirmed OpenTimestamps
attestation (the block HEIGHT is the proof's native unit; no wall-clock value
is guessed for it). The field is present ONLY when the proof genuinely carries
the time; it is never derived from the informative anchoredAt. This is what
makes a time-window policy (t₁ < run < t₂) buildable over verify --json.
rfc3161-tsa— an RFC 3161 timestamp token, verified offline against a TSA root certificate the RELYING PARTY supplies (WP-A1: CLI--trusted-tsa-root, policyanchors.trusted_tsa_roots), NOT the anchor's ownfrozenroot (which the producer controls). The chain is validated at the token's owngen_time, not the current wall clock, so a token stays re-verifiable after the TSA certificate has expired or rotated; a certificate that was not valid atgen_timefails closed. With no relying-party root the token isneeds_rp_trust(ok=False). The TSA policy OID is not pinned by default; a relying party MAY pin it (anchors.trusted_tsa_policy_oids) or the producer MAY declare a stricter-onlyfrozen.policyOid; a token whoseTSTInfo.policydiffers fails closed.opentimestamps— an OpenTimestamps proof anchored in Bitcoin. A fresh stamp is PENDING (a WARN) untilots upgradeembeds the Bitcoin block-header path. Confirming it needs the block'shashMerkleRootfor the attested height — supplied by the RELYING PARTY (WP-A1: CLI--bitcoin-header, policyanchors.bitcoin_block_headers), from their own trusted/pruned Bitcoin node, NOT the anchor'sfrozenheader. The root is in internal (node) byte order as returned bybitcoind— NOT the byte-reversed order block explorers display; a reimplementer MUST use the internal order. With no relying-party header the upgraded proof isneeds_rp_trust(ok=False), never confirmed.
An external time anchor's TRUST comes ONLY from the relying party, never from
the bundle. The frozen block is producer-controlled EVIDENCE (surfaced as
frozenEvidence), never a trust source: a malicious producer could freeze its
own self-signed TSA root, or a self-committed backdated Bitcoin header, and
self-certify a backdated timestamp. Therefore a confirmed verdict requires
the relying party to supply the matching trust material (--trusted-tsa-root /
--bitcoin-header, or the policy anchors section); without it the anchor is
needs_rp_trust and --require-anchor is unmet → exit 3, never a silent pass.
A per-entry result carries rp_trusted (verified against RP material),
needs_rp_trust (proof present but no RP material), and frozenEvidence (the
bundle carried frozen material, reported but not trusted).
Public anchoring transmits and publishes only digests / roots, never
payload contents: the anchored value is canonicalRoot (a SHA-256), so nothing
about the underlying claim, protocol, or samples leaks to the timestamping
authority, calendar, or chain.
Status: EXPERIMENTAL since 3.3.0 (API and wire format may change without deprecation).
Normative-short here; the deep contract and interop mapping live in
docs/predicates/relation.md; src/proofbundle/relation.py is its reference
implementation, which is not normative (§7l).
Change is never expressed by mutation. A receipt binds exact bytes and stays valid for
them forever; when a result is deliberately corrected, re-run, retracted, or renewed, the
NEW receipt carries a TYPED, SIGNED relationship edge in an OPTIONAL relationships: [edge, …] field of the decision-receipt / action-outcome predicate — INSIDE the DSSE-signed
bytes. The verifier reports the relationship as its own lineage state (VERIFIED /
DECLARED_UNRESOLVED / FAIL / NOT_EVALUATED) rather than leaving replacement invisible
(silent landing) or treating it as tampering.
- Closed vocabulary:
supersedesrevisescorrectsretractsrenewsderivedFromamends(extension only via a spec change; an unknown relation is fail-closed). targetReceiptDigest(required) is the target'sjcs-sha256-v1content root with an EXPLICIT, never-defaulteddigestAlgorithm.targetSubjectDigest(optional) is the target's subject digest — when PRESENT it is binding (checked against the resolved target, since 3.4.0): itsdigestis compared withsubject[0].digest.sha256of a target that has exactly one subject entry, although itsdigestAlgorithmnames the content-root algorithm (the exact rule and its failure codes are indocs/predicates/relation.md§5). Hard caps: 64 edges per receipt, attached-ancestry depth 32.- Honesty boundary (verbatim, claims-hygiene enforced): relationship declared by issuer,
not a statement of correctness.
lineageNEVER feedscryptoValidin either direction (lattice monotonicity); a DECLARED_UNRESOLVED edge never reads as a pass. - Offline: targets are attached by the caller (
--with-related), never fetched. - The verify paths that carry the profile are defined in
docs/predicates/decision-receipt.md§4 to §7 anddocs/predicates/action-outcome.md§5: the DSSE envelope carriespayloadTypeapplication/vnd.in-toto+jsonand its signature is checked over the PAE of §7b (anotherpayloadTypefails the signature check, exit 1); the check passes when one entry ofsignaturescarries asigthat verifies under the key, and the other entries, an entry that is no object, everykeyidand any further envelope member are not read for the verdict; an envelope without such an entry, an empty or missingsignaturesincluded, fails it (exit 1); the Statement carries the predicateTypehttps://b7n0de.com/proofbundle/predicates/decision-receipt/v0.1orhttps://b7n0de.com/proofbundle/predicates/action-outcome/v0.1(another is malformed, exit 2); an unknown predicate member is malformed (exit 2), as is every other finding of the default-mode validation of the receipt under verification; and a presentvalidity.expiresAtof the receipt under verification is judged at one evaluation time,--verification-timewhen given, else the wall clock (expired is exit 2 when no earlier check has decided the exit; the order is indocs/predicates/decision-receipt.md§7);relation-statement verifytakes no--verification-time(giving it is a usage error);outcome verifytakes it only as the evaluation time at which a bound--decisionis verified, and both judge the lifetime of a--policyat the verifier's wall clock. An attached target is verified STANDALONE by its own signature under its key, itspayloadTypeand the statement oracle ofdocs/predicates/relation.md§4; its ownvalidity.expiresAtis not judged. The edges of the receipt under verification are judged once its signature verifies, whatever else its validation finds: a foreignpredicateType, an unknown member or another finding make it malformed (exit 2), an expiredvalidityfails its freshness check, andlineageis still reported as its edges give it; under a signature that fails (exit 1) the report'slineageisnull.
All fail-closed and additive; a receipt without any of these pins behaves exactly as before.
require_relation_resolution— a named relation that APPEARS as an edge must resolve (target attached + verified); unmet is exit 3, blockerLINEAGE_REQUIREMENT_FAILED.reject_superseded— an attached, verified successor/retractor over the receipt under verification blocks automation.relation_signer(since 3.4.0, WHO may replace) — per relation,{"mode":"same-key"}or{"mode":"pinned","keys":[<b64>,…]}. The SUCCESSOR's issuer key must satisfy the rule (byte membership of the raw Ed25519 key, never a keyId alias). Unmet →RELATION_SIGNER_UNAUTHORIZED, exit 3. Enables cross-issuer chains (--with-related PATH --related-pub B64).require_relation_target(since 3.4.0, WHICH parent) — per relation, an expected parent content root or a list of them; a supersedes-like edge that resolves to any OTHER parent (even a valid one) →RELATION_TARGET_MISMATCH, exit 3, on EVERY such edge, accept path included (closes the decoy-parent gap).
Authorization AND parent-pinning are RELYING-PARTY POLICY, never format truth: a passing
check proves set membership / the named parent under the verifier's pins, not that anyone
is "really" authorized or in the right (SCITT RFC 9943 line — authorization is Registration
Policy terrain, not statement format). The relations gate is enforced identically on the
decision AND outcome verify paths (outcome verify --policy).
Deliberately OPEN (do not read them into 3.4.0/3.5.0): threshold signatures (TUF N-of-M) and identity indirection (DID/VC controller / CA chains). Pins are raw Ed25519 public keys and raw content-root digests, matching the offline house contract.
Status: EXPERIMENTAL since 3.5.0. Normative-short here; prose in
docs/predicates/relation.md; src/proofbundle/relation_statement.py is its
reference implementation, which is not normative (§7l).
The in-receipt edges of 7j express change from the SUCCESSOR's side. A relation statement is the
INDEPENDENT case: a DSSE-signed statement OVER a target receipt, carrying EXACTLY ONE typed edge and
NO decision/outcome payload of its own — a retroactive retraction, supersession or amendment declared
WITHOUT touching the original (status-as-a-separate-object precedent: W3C Bitstring Status List v1.0, CT/OCSP
revocation, SCITT protected-object-binding). predicateType
https://b7n0de.com/proofbundle/predicates/relation-statement/v0.1, predicate
{schemaVersion, statementId, relationships:[edge]}.
- The edge reuses the 7j edge schema verbatim; validation, lineage resolution and the trust-policy
relationsgate reuse the SAME functions as the in-receipt path (relation.validate_relationships,relation.verify_relationship_edges,relation.evaluate_relations_policy) — no second implementation of the logic. - Honesty boundary (verbatim, claims-hygiene enforced): a relation statement proves the issuer
DECLARED the relation over exact bytes; it does not retract the target's cryptographic validity,
and whether the issuer may declare it is a relying-party policy decision. A
retractsstatement sets a visible declared state BESIDE the target — the target receipt stays valid for its bytes forever, and a verifier that does not know the statement still sees a valid target (offline reality; a retraction is relying-party knowledge, not a global kill).lineageNEVER feedscryptoValid(lattice monotonicity). - CLI:
proofbundle relation-statement init|emit|verify|inspect, exit contract 0/1/2/3 identical to the decision/outcome verify paths. - Standalone policy extension:
relations.reject_retracted(andreject_supersededfor the successor relations) — a relying party who knows BOTH the target and a verified retracts statement of a pinned/authorized signer can treat continued automated use of the target as an exit-3 block. Without the policy the verified statement is pure visibility, never a crypto invalidation.relation_signerdecides WHO may declare it, identically to 7j.
Since 3.5.0 the independent Rust verifier carries the relation profile (verify-relation,
verify-relation-statement), sharing NO canonicalizer/parser code with Python. crosscheck.py drives
ALL relation vectors — in-receipt (decision + outcome) and standalone, positive AND negative — through
BOTH implementations and asserts they land on the SAME common-vocabulary label (exit class + lineage)
on every vector. This is differential AGREEMENT on these vectors, not a correctness proof of either
implementation; the parity registry (scripts/rust_parity_registry.json, AST-checked by
scripts/rust_parity_gate.py) records honestly which surfaces are COVERED / PARTIAL / PENDING.
The conformance corpus (CONFORMANCE.md, conformance/README.md,
conformance/vector_schema.json) is judged against this document and
the companion texts below. Each of them is normative for the part it names; where one of them and this
document disagree, this document is normative and the companion text is a bug, the same precedence as
for the bundle schema at the top of this document.
| text | normative for | corpus kinds |
|---|---|---|
EVAL_CLAIM.md |
the proofbundle/eval-claim/v0.1 claim a payload carries (§6), its canonicalization, its issuer binding and the reported-version status rule |
native_bundle, envelope_profile_rule, provenance_version_status |
docs/RECEIPT_ENVELOPE_PROFILE.md |
the receipt-envelope rules R1 to R4 and how their vectors are read | envelope_profile_rule |
docs/adr/0002-universal-content-root.md |
the jcs-sha256-v1 content root, the two-part rule, contentRootAlg and legacy-sortkeys-json-v0 |
content_root_vector, the content roots of §7j |
docs/predicates/decision-receipt.md |
the decision-receipt predicate, its default-mode and strict validation and its verify path | decision_relation, relation_statement, content_root_vector, decision_crossimpl |
docs/ANCHORS.md |
anchors, their trust model and the OpenTimestamps proof format and verdict | native_bundle, decision_crossimpl |
docs/predicates/action-outcome.md |
the action-outcome predicate and its verify path | outcome_relation |
docs/predicates/relation.md |
the relation profile (§7j, §7k), its lineage states and stable codes | decision_relation, outcome_relation, relation_statement |
docs/AGENT_REVIEW_PREDICATE.md, docs/VERIFIER_BLOCK.md |
the agent-review predicate v0.1 to v0.3, its verifier block, receipt chains and policy decisions | agent_review_predicate |
conformance/cap1/draft-hillier-coverage-attestation-00.txt, conformance/cap1/CAP-1.schema.json |
the CAP-1 coverage-attestation document (its Section 4, and the members and their forms in the JSON Schema it names), the rules R0 to R8 a verifier enforces (its Section 5) and the closed disposition vocabulary (its Section 6) | cap1_document |
The JSON Schemas beside them (schemas/eval_claim_v0_1.schema.json,
schemas/decision-receipt-v0.1.schema.json,
schemas/action-outcome-v0.1.schema.json) are informative mirrors:
they never gate a verdict (docs/predicates/relation.md §2), and where a mirror and its text disagree the
text governs the corpus. Five relation fixtures do not validate against the decision-receipt mirror, each
on purpose: two counter-proofs carry the malformed value they test
(conformance/relation/malformed-digest/receipt.json,
conformance/relation/malformed-relationships-successor/related_n.json); the targets
conformance/relation/target-subject-ambiguous/related_r0.json and
conformance/relation/target-subject-missing/related_r0.json carry only schemaVersion and a note in
their predicate, because those cases test the subject shape of a target; and the attachment
conformance/relation/null-relationships-successor/related_n.json carries relationships: null, which
the mirror does not allow and docs/predicates/relation.md §4 reads as absent on an attachment.
The CAP-1 draft is not written here but vendored byte for byte from its author's repository:
conformance/cap1/draft-hillier-coverage-attestation-00.txt is the file cap-1/draft-hillier-coverage-attestation-00.txt
of https://github.com/Certisyn-Inc/certisyn-drafts at commit 0d8ebd751b95dd85a094686c2768f9cce7a27b03, 32544 bytes,
sha256 7a9eeb1fbdb1fee95697622546d2ae7efba762fff193d6ee34765233539ac353, fetched with git on 2026-10-26, and
conformance/cap1/LICENSE-SPEC (sha256
b32b9706f7067c7f51ed74f9d75562a89518839d322b15fa7f5546ff12482161) and conformance/cap1/NOTICE
(sha256 2f95eb9b1220b57f8992de1bae36e8c1c48c088e369910f1ab77be2d53463092) are the files of the same name at that commit.
conformance/cap1/CAP-1.schema.json is the file cap-1/src/CAP-1.schema.json
at the same commit, 4921 bytes, sha256 4453f216089543780bfecc4295cc4a61462fdc585b88d1e35b7d1aba79716b4a, the JSON
Schema that Section 4 of the draft names as the normative shape of a document; LICENSE-SPEC places it under the same
licence as the draft text. Which parts of it the rules read is stated in conformance/README.md (cap1_document).
The draft text and its schema are licensed under the Creative Commons Attribution 4.0 International licence (CC BY 4.0,
https://creativecommons.org/licenses/by/4.0/) with attribution to Dr Joel David Hillier, Certisyn, Inc.; the vectors
are under the Apache License 2.0 (conformance/cap1/vectors/LICENSE.author, the LICENSE that LICENSE-SPEC names).
The MIT licence of this repository does not replace CC BY 4.0 for these files. The citation LICENSE-SPEC asks for:
Hillier, J. D., "The Coverage Attestation Profile (CAP-1)",
draft-hillier-coverage-attestation-00, Certisyn, Inc., 2026.
Whether these bytes equal the text of the draft in the IETF archive is not measured.
- RFC 6962 — Certificate Transparency (Merkle tree hashing, inclusion proofs).
- RFC 9162 — Certificate Transparency v2.
- RFC 8032 — EdDSA / Ed25519.
- RFC 4648 — Base16/Base32/Base64 encodings.
- RFC 9901 — Selective Disclosure for JWTs (SD-JWT), incl. §4.3 Key Binding JWT.
- RFC 7800 — Proof-of-Possession Key Semantics for JWTs (
cnf). - C2SP tlog-checkpoint / signed-note / tlog-cosignature / tlog-proof — witness ecosystem formats.
- FIPS 204 — Module-Lattice-Based Digital Signature Standard (ML-DSA).
- draft-ietf-oauth-status-list — Token Status List (RFC-Editor queue).
- RFC 3797 — Publicly Verifiable Nominations Committee Random Selection.
- RFC 2104 / FIPS 198 — HMAC (per-leaf salt PRF).
- RFC 9334 — RATS Architecture (Passport model).
- RFC 9711 — Entity Attestation Token (EAT).