ADR 0002: Universal content root (jcs-sha256-v1)
July 10, 2026 · View on GitHub
- Status: accepted. Foundation landed (PR #47). Activation implemented on
feat/wp2-eval-svr-migration(the eval-result / test-result / SVR paths now default tojcs-sha256-v1with an explicit legacy mode — see §Activation). Shipping it to PyPI as 2.1.0 remains the owner gate. - Date: 2026-07-10 (decision date; commit date live)
- Deciders: proofbundle maintainer (b7n0de)
- Builds on: ADR 0001 (decision-receipt as a separate vendored predicate)
Context
Two proofbundle attestation paths hash a Statement, and today they disagree on how:
- The decision-receipt path (
decision.py, PR #45 / 2.1.0) defines a receipt's content root over the RFC-8785 (JCS) canonical Statement bytes, and bindsevidenceRefs[].digestandstatement-target anchors to that root. - The released eval-result / test-result / SVR in-toto export paths (
intoto.py) serialize the Statement withjson.dumps(sort_keys=True, separators=(",", ":"))(_canonical_body). That is not full RFC-8785 — it does not normalize number formatting or string escaping, and differs on non-ASCII / mixed-case keys — so it cannot carry a stable, cross-implementation content root.
The consequence is real and already documented as a No-Overclaim caveat in
docs/predicates/decision-receipt.md §3 (on the PR #45 branch): a decision receipt can only be guaranteed to
compose byte-for-byte with an eval-result statement it cites when the evidence side was itself emitted
RFC-8785-canonically. The two content-root definitions must converge on one.
The convergence target was fixed publicly on b7n0de/proofbundle#7 (2026-07-10) with an external collaborator ("converging on the same bytes"): the content root is SHA-256 over the RFC-8785 canonical Statement bytes (the pre-signature object), signature bytes never in the preimage.
Decision
-
contentRootAlg = jcs-sha256-v1. A Statement's content root isSHA-256over the RFC-8785 (JCS) canonical bytes of the full Statement —_type,subject,predicateType,predicate— taken before signing. The signature/envelope bytes are never part of the preimage. The algorithm id is a first-class, versioned string (CONTENT_ROOT_ALGincanonical.py); a future algorithm registers its own distinct id and a verifier MUST NOT silently default a missing/unknown value (that is where an algorithm-confusion attack would hide, mirroringmerkle.hash_alg). -
Full-Statement scope, never a subset. The preimage is the whole Statement, not a predicate-only object and not any field subset. Binding only the predicate would drop
subject+predicateTypeand reopen a context-confusion attack (the §2.1 finding of the audit addendum, at the primitive level). Subset canonicalization is forbidden everywhere. -
Signature bytes never in the preimage. Because the root commits the claim content and not the signature, it survives counter-signing, key rotation and multi-signature envelopes — the property that lets evidence and the decision that cites it both live on content roots and compose.
-
Two-part producer/verifier rule.
- A producer MUST emit its Statement canonically (RFC-8785) and sign exactly those bytes.
- A verifier MUST hash the exact transmitted payload bytes and MUST NOT re-canonicalize. A payload
that deviates from its own canonical form is a fail-closed error the verifier rejects (the decision path's
hash_bindingcheck already does this). Re-canonicalizing on verify would let a non-canonical payload masquerade as canonical.
-
One shared primitive. The two operations live in
src/proofbundle/canonical.py:canonicalize_statement(obj) -> bytes— the producer canonicalization (RFC-8785, lazy[eval]extra, fail-closedCanonicalizerUnavailablewhen the extra is absent).statement_content_root(statement) -> bytes— the 32-byte content root. Given a JSON object it canonicalizes then hashes (producer); given rawbytesit hashes exactly those bytes (verifier). Both yield the same root when the producer emitted canonically..hex()is the form used inevidenceRefs[].digest.sha256and astatementanchor'scanonicalRoot.
Migration (this is the crux; nothing released breaks)
The released intoto.py export paths (export_intoto_dsse, export_eval_result_dsse, export_svr_dsse) sign
over _canonical_body(...) = json.dumps(sort_keys=True). Switching the signed bytes to RFC-8785 changes
the wire (existing signatures no longer verify against a re-emitted body), so the migration is a compatible
evolution with an explicit legacy mode, not a data-loss cutover:
-
Versioned algorithm, declared per receipt. A content root is qualified by its
contentRootAlg. The new default isjcs-sha256-v1. The historicjson.dumps(sort_keys=True)form is retained as a named legacy algorithm (legacy-sortkeys-json-v0, an explicit declared mode — not an unlabeled fallback). -
Old receipts keep verifying. A receipt/attestation that declares (or, for pre-declaration artifacts, is verified under an explicitly selected) legacy mode is hashed with the legacy serializer, so already-signed bytes still verify. Absence of a declared algorithm is never silently treated as JCS — a verifier selects legacy only when the caller explicitly opts in.
-
New receipts default to
jcs-sha256-v1via the sharedcanonical.canonicalize_statement, unifying the decision-receipt and eval-result/SVR content roots so cross-predicate composition matches byte-for-byte. -
The decision-receipt path already uses the target algorithm — this ADR standardizes the primitive it defined and makes it the shared home for the eval-result/SVR paths to adopt during activation.
Honest scope of THIS ADR (No-Overclaim)
This ADR designs the universal content root and its migration. It does not activate the default switch for the released eval-result / test-result / SVR paths. That activation:
- is a wire change to released, signed attestations (it changes the signed bytes for new receipts and adds a declared-legacy verify branch), and is therefore a T3 / SemVer owner-gated step, part of the 2.1.0 release owner gate — the same gate that ships the decision-receipt predicate;
- carries a P0 activation test: "a
json.dumps(sort_keys=True)root offered as ajcs-sha256-v1root is rejected unless legacy mode is explicitly selected" (the eval-export migration test named in the audit addendum §3.4). That test belongs to the activation phase, not to this foundation, because it asserts the behavior the activation introduces.
What landed with the WP2 foundation (PR #47) was non-breaking and additive: the ADR, the shared
canonical.py primitive, its exports and tests. No released path was migrated at that point. The
decision-receipt module (decision.py) is the first adopter of the primitive — its local _rfc8785_bytes
delegates to canonical.canonicalize_statement (catching CanonicalizerUnavailable to preserve its own
DecisionReceiptError message), and anchors.statement_content_root (bytes → root) delegates to
canonical.statement_content_root with identical behavior. That adoption was a pure refactor.
The activation described in the next section (the eval-result / test-result / SVR migration) is the step that this foundation deliberately deferred; it is now implemented, and this section describes the state before it.
Activation (WP2, feat/wp2-eval-svr-migration)
The migration designed above is now implemented for the released intoto.py export paths. It is a
compatible evolution with an explicit legacy mode, not a data-loss cutover:
-
Versioned wire field. A Statement declares its content-root algorithm in a top-level
contentRootAlgfield (in-toto Statement v1 setsadditionalProperties: true, so this is schema-valid and uniform across the vendor eval-result predicate and the standard test-result / SVR predicates, which cannot carry a custom field inside their predicate). The field is inside the signed payload, so it cannot be flipped after signing. New default:jcs-sha256-v1. Historic serializer:legacy-sortkeys-json-v0(json.dumps(sort_keys=True)), retained as a named mode. Absent ⇒ legacy — this is exactly how already-signed 2.0.0 receipts (which carry no field) keep verifying; absence is never silently jcs. -
Producer.
export_intoto_dsse/export_eval_result_dsse/export_svr_dssedefault tojcs-sha256-v1viacanonical.canonicalize_statement. The oldjson.dumpspath is retained as the named legacy serializer (_canonical_body) and is selectable withcontent_root_alg=LEGACY_CONTENT_ROOT_ALGfor a byte-identical legacy re-emission. -
Verifier. Each verify reads the declared
contentRootAlg(absent ⇒ legacy) and re-serializes the Statement with exactly that algorithm to confirm the transmitted payload is its own canonical form (fail-closed). It hashes / checks the exact bytes and never re-canonicalizes to compute a root, and it never falls back between algorithms. Verifyingjcs-sha256-v1canonicality needs the[eval]extra and is fail-closed without it; legacy verification is stdlib-only, so released 2.0.0 receipts verify on a base install. -
P0 activation test (addendum §3.4). A
json.dumps(sort_keys=True)root offered asjcs-sha256-v1is rejected (proven with a value where the two serializers diverge), while the same bytes declared/absent as legacy verify; the reverse (genuine JCS bytes declared legacy) is rejected too. An unknown algorithm is fail-closed. Tests:tests/test_intoto_content_root_migration.py(proofs A/B/C).
The release of this wire change (a new PyPI 2.1.0, the same gate that ships the decision-receipt predicate) remains the owner gate — the code carries the change behind SemVer, it is not published here.
Consequences
- The primitive is a stable, tested public API (
proofbundle.canonicalize_statement,proofbundle.statement_content_root) with a declaredCONTENT_ROOT_ALG. It is dependency-light: the base install and the plain verify path pull no canonicalizer; the producer path lazily needs[eval]. - The eval-result/SVR migration is a known, owner-gated follow-up with a named P0 test; until it is activated, the cross-predicate content-root caveat in the decision-receipt doc §3 remains accurate and stays published.
- The
#7consensus (content root over pre-signature Statement bytes, no subset canonicalization) and ADR 0001 (decision-receipt as its own predicate) are the references this ADR honors; deviating would require reopening the#7discussion.