Skip to content

Latest commit

 

History

History
1067 lines (942 loc) · 78.1 KB

SPEC.md

File metadata and controls

1067 lines (942 loc) · 78.1 KB

proofbundle format specification — proofbundle/v0.1

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.

1. Overview

A bundle is a single UTF-8 JSON object. It asserts, checkable fully offline, that a fixed byte string (the payload) was:

  1. signed by a stated Ed25519 key, and
  2. 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.

2. Encoding conventions

  • 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.compact uses 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_b64 values 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, Infinity and -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), and agent_review.load_policy reads the agent-review policy file with it, a refusal being an AgentReviewError. 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.

3. Object fields

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

4. signature

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.

4a. Verification semantics — the edge-case envelope (normative for this implementation)

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.

4b. Trust-anchor keys (normative for this implementation)

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.

5. merkle

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

6. sd_jwt_vc (optional)

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: in sd_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 the receipt anchor root (§7i) and the identity of a pb1. 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's sd_hash covers 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 s is 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:

  1. payload_b64, decoded, read as UTF-8 and parsed with the strict reader of §2, is a JSON object whose member schema is the string proofbundle/eval-claim/v0.1 (members such as passed, threshold or suite do not make a payload an eval claim; only schema does), and the bundle carries merkle.root_b64: the SD-JWT's always-open disclosures (passed, threshold, comparator, suite, issuer) and its committed receipt.root_b64 MUST 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 of sd_jwt_vc.compact before 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 (1 equals 1.0, as RFC 8785 writes both 1), and true and false never equal to a number. receipt.root_b64 of the issuer-signed payload must be a string equal, character for character, to merkle.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).
  2. N1 (since 3.1.1): payload_b64 is not an eval-claim, but the SD-JWT carries an eval-binding root commitment (a receipt.root_b64 string 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 of receipt.root_b64 (the real substitution vector check_binds_bundle uses), not a heuristic word-match on passed/threshold/etc. A generic SD-JWT-VC (iss/vct, no receipt.root_b64 commitment) 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.

7. Verification order (normative)

A conforming verifier MUST perform, in this order, and report each result:

  1. schema — reject if schema != "proofbundle/v0.1".
  2. ed25519-signature (§4).
  3. merkle-inclusion (§5).
  4. sd-jwt-disclosures and sd-jwt-issuer-signature — only if sd_jwt_vc is present (§6). Since revision 2026-07-11, a present sd_jwt_vc with no issuer_public_key_b64 makes sd-jwt-issuer-signature FAIL (unsigned → unauthenticated); it is not a skipped or warning-only check.
  5. sd-jwt-key-binding — only if sd_jwt_vc is present AND its compact serialization carries a Key Binding JWT (§6, since v1.2; fail-closed).
  6. sd-jwt-issuer-identity — only if sd_jwt_vc is present, its issuer signature verified, and it discloses an issuer (§6, WP-C1; fail-closed against a forged issuer identity).
  7. sd-jwt-bundle-binding — if sd_jwt_vc is present and its issuer signature verified, in EITHER of two cases (§6): the payload is a proofbundle/eval-claim/v0.1 claim by its schema member (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-binding receipt.root_b64 commitment (N1, since 3.1.1; fail-closed against grafting a signed eval SD-JWT onto an arbitrary payload). A generic SD-JWT-VC with no receipt.root_b64 is 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's sd_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 no receipt.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.
  8. 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_b64 MUST equal the stated root's bytes, and a given expected_tree_size MUST equal merkle.tree_size; a mismatch FAILS. Absent an expected value, root authenticity is NOT_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 report payloadSignature / merkleConsistency / rootAuthenticity (PASS/FAIL/NOT_EVALUATED) separately, so Merkle inclusion is never read as root authentication.
  9. 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, matching verify-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.

Atomic tree context (normative, since 3.1.3 — A-P0-1)

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: PASS requires that the stated root AND the stated tree_size both match ONE authenticated source: a verified signed checkpoint (--trusted-checkpoint/--checkpoint-vkey, or a policy merkle.trusted_checkpoints entry — signature-valid under its pinned checkpointSigner, unexpired, supported hashAlg), or a relying-party-supplied expected_root + expected_tree_size PAIR.
  • A naked root pin reaches at most ROOT_BYTES_AUTHENTICITY: PASS — NEVER TREE_CONTEXT_AUTHENTICITY: PASS and never safeForAutomation: 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, and checkpointAuthenticity (PASS/FAIL/NOT_EVALUATED). The legacy rootAuthenticity key remains as a wire-compat ALIAS of rootBytesAuthenticity; it never asserted more than root bytes and MUST NOT be read as tree-context or automation trust.

Automation-safety verdict and additive output objects (since 3.1.1)

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

7a. Scope guardrail (honest)

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.

7b. in-toto test-result profile (normative, v0.9)

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). subject is a single ResourceDescriptor with a real digest (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) — result is PASSED when the threshold holds, else FAILED (WARNED is unused); configuration is a required list of ResourceDescriptors for the model and dataset commitments. Each descriptor carries a digest (a salted commitment hex under a proofbundle-specific algorithm key — never sha256, which would falsely imply an artifact hash); a bare name-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's annotations. passedTests/failedTests carry the suite name. A verifier refuses a test-result statement whose predicate lacks result or configuration, the two members the in-toto predicate marks required; url and 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: result one of PASSED, WARNED, FAILED, url a string in URI form (ResourceURI, the form only, never fetched; to_test_result_statement refuses a url in another form), each case list an array of strings, and each configuration entry a ResourceDescriptor that specifies one of uri, digest or content.
  • 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, where LEN is the ASCII-decimal byte length with no leading zeros and body is the raw Statement bytes — never the base64 string. A verifier decodes payload and reconstructs the PAE over exactly those bytes (it never re-serializes), and pins payloadType (sign and verify MUST use the same string).

7c. C2SP tlog-checkpoint (normative, v0.9)

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

7d. C2SP tlog-cosignature, Ed25519 cosignature/v1 (normative, v1.2)

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.

7e. C2SP tlog-proof (normative, v1.3)

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.

7f. Token Status List snapshot (normative, v1.3)

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

7g. Per-sample commitment and audit protocol (normative, v1.5)

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

7h. TEE-attestation bridge — enclave_attested (EXPERIMENTAL, v2.0 preview)

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.

7i. External time anchors — anchors[] (EXPERIMENTAL, the [anchors] extra)

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.

Anchor entry

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.

Targets (never mixed)

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.

Verify contract and the three anchor states

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.

Built-in types (informative)

  • rfc3161-tsa — an RFC 3161 timestamp token, verified offline against a TSA root certificate the RELYING PARTY supplies (WP-A1: CLI --trusted-tsa-root, policy anchors.trusted_tsa_roots), NOT the anchor's own frozen root (which the producer controls). The chain is validated at the token's own gen_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 at gen_time fails closed. With no relying-party root the token is needs_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-only frozen.policyOid; a token whose TSTInfo.policy differs fails closed.
  • opentimestamps — an OpenTimestamps proof anchored in Bitcoin. A fresh stamp is PENDING (a WARN) until ots upgrade embeds the Bitcoin block-header path. Confirming it needs the block's hashMerkleRoot for the attested height — supplied by the RELYING PARTY (WP-A1: CLI --bitcoin-header, policy anchors.bitcoin_block_headers), from their own trusted/pruned Bitcoin node, NOT the anchor's frozen header. The root is in internal (node) byte order as returned by bitcoind — NOT the byte-reversed order block explorers display; a reimplementer MUST use the internal order. With no relying-party header the upgraded proof is needs_rp_trust (ok=False), never confirmed.

Trust model (WP-A1, normative)

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

Privacy

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.

7j. Lineage / relationship profile — relation/v0.1 (EXPERIMENTAL)

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: supersedes revises corrects retracts renews derivedFrom amends (extension only via a spec change; an unknown relation is fail-closed).
  • targetReceiptDigest (required) is the target's jcs-sha256-v1 content root with an EXPLICIT, never-defaulted digestAlgorithm. targetSubjectDigest (optional) is the target's subject digest — when PRESENT it is binding (checked against the resolved target, since 3.4.0): its digest is compared with subject[0].digest.sha256 of a target that has exactly one subject entry, although its digestAlgorithm names the content-root algorithm (the exact rule and its failure codes are in docs/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. lineage NEVER feeds cryptoValid in 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 and docs/predicates/action-outcome.md §5: the DSSE envelope carries payloadType application/vnd.in-toto+json and its signature is checked over the PAE of §7b (another payloadType fails the signature check, exit 1); the check passes when one entry of signatures carries a sig that verifies under the key, and the other entries, an entry that is no object, every keyid and any further envelope member are not read for the verdict; an envelope without such an entry, an empty or missing signatures included, fails it (exit 1); the Statement carries the predicateType https://b7n0de.com/proofbundle/predicates/decision-receipt/v0.1 or https://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 present validity.expiresAt of the receipt under verification is judged at one evaluation time, --verification-time when given, else the wall clock (expired is exit 2 when no earlier check has decided the exit; the order is in docs/predicates/decision-receipt.md §7); relation-statement verify takes no --verification-time (giving it is a usage error); outcome verify takes it only as the evaluation time at which a bound --decision is verified, and both judge the lifetime of a --policy at the verifier's wall clock. An attached target is verified STANDALONE by its own signature under its key, its payloadType and the statement oracle of docs/predicates/relation.md §4; its own validity.expiresAt is not judged. The edges of the receipt under verification are judged once its signature verifies, whatever else its validation finds: a foreign predicateType, an unknown member or another finding make it malformed (exit 2), an expired validity fails its freshness check, and lineage is still reported as its edges give it; under a signature that fails (exit 1) the report's lineage is null.

Trust-policy hook (trust-policy v0.2, section relations)

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, blocker LINEAGE_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.

7k. Standalone relation profile — relation-statement/v0.1 (EXPERIMENTAL)

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 relations gate 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 retracts statement 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). lineage NEVER feeds cryptoValid (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 (and reject_superseded for 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_signer decides WHO may declare it, identically to 7j.

Independent Rust cross-verification (tools/pb_verify_rs)

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.

7l. Normative companion texts and the conformance corpus

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.

8. References

  • 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).