Skip to content

Changelog

All notable changes to the TRACE specification will be documented here.

Format: Semantic Versioning. Spec versions follow MAJOR.MINOR.PATCH: - MAJOR: breaking changes to wire format or required Trust Record fields - MINOR: new optional fields, new platform profiles, new conformance levels - PATCH: editorial fixes, clarifications, non-normative additions


[Unreleased]

Added

  • The PIC/TRACE bridge cites the specification it consumes. docs/integration/pic-trace-bridge-v1.md gains a References section naming the PIC Standard repository, PIC Canonical JSON v1 (PIC-CJSON/1.0), whose section 8 gives the byte rules for the intent_digest and args_digest values this bridge preserves, and the PIC vocabulary that PIC asks downstream specifications to cite rather than recoin. It also gains a subsection on the representation boundary those values cross. PIC gives each digest as 64 lowercase hexadecimal characters and computes the two differently: section 8.1 over the canonical bytes of action.args, section 8.3 over the UTF-8 bytes of the intent string with no JSON wrapping or canonicalization. schema/pic-trace-bridge-v1.json constrains both fields through its $defs/digest, ^sha256:[0-9a-f]{64}$, so an adapter serializes the PIC verifier's output as sha256:<pic_hex> at the boundary. A bare hexadecimal value is refused with authorization.pic.intent_digest must be a sha256 digest on the artifact, or intent_digest must be a sha256 digest on the argument to the reference implementation. The prefix is a TRACE serialization of the same digest value: it does not redefine the PIC digests, and the bridge still does not recompute them. tests/test_pic_bridge_doc_matches_the_code.py pins the quoted pattern and both quoted messages to what they quote, since nothing else in the suite reads that document. Reported by @madeinplutofabio in #361. Informative only: no schema, wire-format or normative change.

  • CHAP review decisions as approval-outcome references. A cross-walk, docs/crosswalks/chap-review-decisions.md, and four fixtures in examples/chap-approval-outcome/ show a Trust Record pointing at a CHAP decide.approve by the RFC 8785 SHA-256 of its envelope. The CHAP envelopes were produced by chap-coordinator 0.2.13 through the generator in the CHAP integration in agentrust-io/integrations, and tests/test_chap_approval_outcome_fixtures.py recomputes every reference digest and replays the CHAP hash chain with rfc8785 alone, so CI takes no dependency on CHAP. The cases are an approval that checks out, the same approval altered in the log after the record was issued, a rejection in the approval's place, and a reference with no entry behind it. The last still verifies as a record and reports the approval as unconfirmed, as section 3.1.2 rule 3 requires. Replaying CHAP's chain with rfc8785 also confirmed that the two canonicalizers produce identical bytes on every committed envelope. Informative only: no schema, wire-format or normative change.

Fixed

  • Server provenance records now enforce the existing attestation shape in both build_record() and verify_record(). After #325, non-object values were already refused. The remaining gaps included an empty object on a non-TEE record, an unknown platform, and platform: "software-only" on a tee-attested record. Non-TEE records now require attestation is None; tee-attested records validate against RuntimeInfo and additionally reject software-only. This implements spec/server-provenance-v1.md section 3's reference to the TRACE v0.2 runtime shape. Previously accepted signed records with extra attestation members or incorrectly typed optional fields are also refused. schema/trace-claim.json disallows additional runtime properties and requires strings for rim_uri, nonce, and firmware_version when present. The existing RuntimeInfo model enforces the extra-member restriction and the types of non-null optional values; it is reused here without changing its behavior. Regression cases exercise the builder and verification of directly signed records, including extra members and each optional field's type, with valid controls. All nine hardware platform names accepted by the model remain accepted.

  • TraceAGTAdapter.build_trust_record() stamped appraisal.status as affirming on every record it produced, with no way to change it. appraisal.status is a verifier-owned field: section 3.3.1 says a verifier MUST record the depth it actually checked and MUST set the status to contraindicated when evidence fails, and models.Appraisal carries the same point in a comment, "What this verifier ran, not what the issuer claimed." The adapter set it at record-construction time, before signing and before any verifier existed, and __init__ had no parameter to override it. The result signed and verified, so a consumer reading the field to find out whether anybody checked was told yes by a record nobody had appraised. TraceSandboxAdapter already had this right, with appraisal_status defaulting to "none" and tests pinning both the default and the override; TraceAGTAdapter now matches it, and the defaulting-to-affirming line was the only remaining hardcoded status in the package. This changes the content of records this adapter emits: an unappraised record now says none where it used to say affirming, which is the correct direction and is what a caller who really did appraise must now declare with appraisal_status="affirming". Two documents described the old behaviour and are corrected with it: docs/integration/agt.md said the adapter "populates an affirming appraisal without independently evaluating the session", and docs/tutorials/agt-adapter.md carried a post-hoc record["appraisal"]["status"] = "none" line, which is to say the gap was known well enough to be worked around in a tutorial rather than fixed in the adapter. That line is gone because the default now does it. Reported by @Yatsuiii in #331, who found it by comparing the two adapters.

  • content_marking.verify_assertion() established that the duplicated binding fields agreed, not that they existed. record.get("subject") != data.get("subject") and the same line for eat_profile compare two reads, and two absences compare equal. A peer-produced assertion omitting data.subject, paired with a hash-matching record that also omitted subject, agreed by mutual absence and verify_assertion returned the parsed record as a successful binding. spec/content-marking-v1.md section 2 marks both fields required and section 6 says a conforming consumer checks both against the fetched record, so this layer has to establish its own required shape: the function performs only the binding check and returns before any Trust Record signature or schema verification, and a caller is allowed to run it on its own. Presence is now checked for each field on both sides. An assertion missing one is ContentMarkingError, because a malformed assertion is the caller's own input and RecordMismatch would point the reader at the server serving the URL, the same reasoning test_an_int_no_longer_reports_a_record_mismatch already pins. A record missing one is RecordMismatch, because it matched the declared hash and that URL really is serving something that is not a conformant record. Two present values that disagree are unchanged. The only behaviour change for input that was already refused is the class on an assertion-side omission, from RecordMismatch to its ContentMarkingError parent, which no caller catching the documented contract loses. Regression coverage carries all six cases from the reproduction, including the two single-side controls that make the hole precisely mutual absence rather than something wider, and a complete-pair control. Reported by @altrudev in #326, reproduced independently by @lywinged with the six-case matrix and the check against #325's head.

  • provenance.build_record() coerced an explicitly supplied issued_at before the validator that exists to inspect it ever ran. stamped_at = int(issued_at if issued_at is not None else time.time()) handed the converted value to _check_structure(), whose guard carries the comment "bool is an int subclass, and True would otherwise pass as a timestamp". The order defeated that guard: True arrived as 1, False as 0, 1.9 as 1, "123" as 123, and -0.5 as 0, so every one of them satisfied the non-negative-integer test and was written into the record. The last case is the diagnostic one, since a negative non-integer became an accepted non-negative timestamp. The same line leaked two exception classes the module does not document: issued_at=[1] left build_record as a TypeError and issued_at="abc" as a ValueError, where every other public function in the module is held to ProvenanceError. An explicitly supplied value now reaches _check_structure() untouched and only an omitted one is stamped with int(time.time()), which puts isinstance in front of the conversion and closes both at once. This is distinct from #142 and #146: those moved the structural rules into the shared helper, and the helper was always strict. The caller path defeated it by normalizing first. Nothing that used to produce a valid record stops doing so; a valid integer is carried through unchanged and an omitted value is still stamped. tests/test_public_functions_raise_what_they_document.py listed provenance.build_record under NO_ARGUMENT_TO_SWEEP because it has no positional argument, which is why the junk matrix never reached it, so the function is now wired into that sweep with issued_at as the varied argument and a witness that pins the sweep actually arrives. Reported by @altrudev in #320, with the two undocumented exception classes and the reason the sweep never saw them found by @lywinged.

  • intent_bridge.verify_bridge() compared the required transcript's call to the executed call with host-language equality, which is not the identity relation the bridge digests under. Every other comparison in that function is over RFC 8785 canonical bytes, but the transcript binding used before.get("tool_call") != tool_call. Python holds True == 1 and False == 0, nested objects included, so a transcript whose before.tool_call substituted a boolean for the corresponding integer (or the reverse) had different JCS bytes from the call the authorization digested and was still reported as bound to the execution. The signed tool_call_digest was never affected: it is checked against the actual tool_call, so the executed call could not differ from the authorized one. What could differ was the separately supplied transcript, in the one place whose purpose is to show that the two agree. The comparison is now compare_digest over digest_jcs, reusing the digest already computed for the tool_call_digest check. The isinstance guard stays in front of it, and a transcript.before.tool_call that JCS has no form for raises AuthorizationMismatch rather than IntentBridgeError, so the documented result class for a malformed transcript is unchanged. Regression coverage pins all four substitutions with an assertion that each one is Python-equal, so a test that stopped exercising the defect would fail rather than pass quietly, plus an unchanged-call control and a parametrized check that a non-object transcript call keeps its original exception class. No wire format, schema, scope, digest definition, or normative bridge semantics change. Reported by @altrudev in #317.

  • intent_bridge.verify_bridge() now refuses malformed signed decision values instead of classifying every non-allow value as a policy denial. The bridge schema permits only the literal strings "allow" and "deny", while the runtime previously used decision != "allow" as its branch, so re-signed values such as true, 1, null, "", and "reject" all surfaced as AuthorizationDenied. That conflated malformed producer output with a legitimate signed denial. The verifier now establishes the enum explicitly before allow/deny semantics; only the literal valid "deny" reaches AuthorizationDenied, while malformed values raise IntentBridgeError. The signature, trust-key, scope, digest, transcript, and wire-format rules are unchanged.

  • TraceSandboxAdapter's documentation claimed a guarantee it does not provide: that a caller cannot claim hardware it does not have. SandboxAttestation validates shape only -- platform against the enum on RuntimeInfo, measurement against the sha256:/sha384: digest pattern -- and has never checked a quote, a signature, or a nonce. _runtime() then copies platform and measurement from the attestation into the record unchanged. Nothing stops the same process that constructs a SandboxAttestation from inventing both values, e.g. SandboxAttestation(platform="amd-sev-snp", measurement="sha256:" + "0" * 64), and build_trust_record() accepts it, TrustRecord.model_validate() accepts the result, and sign_record() signs it -- producing a Level 1-shaped record with no hardware evidence behind it. The module docstring's "It will not let a caller claim hardware it does not have" and its closing claim that "a record that says tpm2 therefore carries a measurement that something other than this process produced" were both false as written: they described appraisal this code does not perform. This isn't a gap unique to the sandbox adapter -- docs/trust-levels.md's Level 1 section already states the same boundary for the format generally ("Merely changing runtime.platform, copying a nonzero digest, or setting appraisal.status="affirming" does not establish that evidence" and "agentrust_trace.verify_record does not itself appraise hardware quotes") -- but sandbox.py's docstring and docs/integration/sandbox-runtime.md asserted the opposite for this adapter specifically, which is what made it a documentation defect rather than a restatement of a known limitation. Fabricating an attestation was never a bypass of anything this adapter checks; the check that was missing had never existed and was never implemented, only claimed. Both docs are corrected to say what is actually enforced (accepted-platform and digest-shape validation) and to state plainly that verifying genuine evidence from the named platform, before constructing a SandboxAttestation, is the caller's responsibility -- consistent with how every other Level 1 producer in this codebase is documented. The same unconditional phrasing also remained in build_trust_record()'s docstring and in the integration guide's "Adding a root of trust" opening line and Levels table; corrected there too, with the guide's table gaining an explicit assurance column so record shape and verified hardware assurance are no longer collapsed together. No runtime behavior changes: SandboxAttestation and TraceSandboxAdapter accept exactly the input they always accepted. A new regression test, test_a_fabricated_but_well_shaped_attestation_is_accepted_verbatim, pins the actual contract so it cannot silently drift toward either a false sense of verification or an undocumented new rejection.

  • cnf.jwk accepted an RSA confirmation key carrying no key material. The schema says "Keys must carry actual key material" and enforced it for OKP and EC only, so a cnf.jwk of {"kty": "RSA"} with no n and no e validated, and the record then failed inside the verifier, where sign.jwk_thumbprint reports the missing thumbprint member. Nothing was accepted that should have been refused, since every path downstream fails closed. What was wrong is which instrument spoke: the schema is the artifact an implementation in any language validates against, and it was not the thing that told the producer the key was unusable. RSA now requires n and e, which states what the description already claimed and refuses nothing that verifies. A kty enum is deliberately not added, because section 3.2.1 states signing algorithms per envelope context and fixes no set for the embedded-signature form of section 3.2.2, so narrowing kty here would add a constraint the specification does not make. models.JWK, which is exported and is what a Python caller reaches, carried the same OKP/EC-only table and is corrected with it; n and e are declared members there too, so a non-string modulus is refused rather than stored as an untyped extra. A parametrized test now checks the schema and the model against each other on every case, since a key one takes and the other refuses fails somewhere the producer did not choose. Both copies of the schema move together, and a test asserts they are the same bytes.

  • provenance.verify_record() and intent_bridge.verify_bridge() raised exceptions their own modules do not document when the untrusted signature field was malformed. Both functions decode a caller-supplied signature before its shape has been established. provenance.verify_record() never checked that record["signature"] was a string at all: signature + "=" * (-len(signature) % 4) ran directly on whatever JSON value sat under the key, and a non-string (an int, a bool, a list, a nested object) raised a bare TypeError (object of type 'int' has no len() for an int; a TypeError on + for a dict or list) rather than the ProvenanceError this function documents for every other malformed input, including its own signature-presence check three lines above. intent_bridge.verify_bridge() did check the type, but called sign._b64url_decode() unwrapped: that function raises the bare ValueError sign documents for itself, not an IntentBridgeError, so a correctly-typed but undecodable string (too short to pad to a whole byte, or carrying a non-ASCII character) escaped as that ValueError. Same shape as the rfc8785.CanonicalizationError leak the "Six public functions" fix (below) already closed in this module; that sweep did not cover this call site. Neither is a signature-verification bypass: a malformed signature was always rejected, only with the wrong exception type, so a caller written against the module's own documented exception (as both modules' docstrings instruct) would see an uncaught crash instead of a handled refusal. Both now reuse sign._b64url_decode(), already sign.verify_record()'s own guard for this exact field, and wrap its ValueError in the calling module's documented type. In provenance.verify_record() the guard is placed where the crash it replaces was, after the cnf.jwk checks, so a record with more than one defect still reports them in the same order it did before. 13 regression tests added across both modules' non-string and malformed-base64-string cases, plus one pinning that check order.

[0.10.0] - 2026-09-05

Security

  • cnf.jwk no longer accepts private key material (GHSA-vc4p-h84j-7qxj). RFC 8747 defines cnf as a confirmation key: the public half, carried so a verifier can bind the record to the key that signed it. models.TrustRecord already refused d, p, q, dp, dq, qi and k via _JWK_PRIVATE_PARAMS, with the comment "cnf.jwk is a public proof-of-possession key". The verification path validates against the schema rather than the model, and the schema's jwk block constrained only the kty/crv/x/y shapes, so everything else fell through additionalProperties. A Trust Record carrying its own private key validated and sign.verify_record() accepted it. The rule existed, and only in the half verification does not call.

No attacker step is involved, and that is not a mitigation. A Trust Record is signed, self-authenticating and typically anchored, so once such a record is out the key is out and the only remedy is to revoke the identity. sign.sign_record() could never produce one, because it builds cnf from key_to_jwk(), which returns the public half only; the exposure is a record assembled by hand or by another implementation, which is the population a published schema exists to constrain.

Both schema copies carry the constraint and a test holds them byte-identical. agentrust-io/trace-tests carried a third copy with the same gap and its conformance suite passed such a record, since TR-ENV-004 checks only that kty is present; that repo adds TR-ENV-005 for it.

Breaking for producers emitting a private cnf.jwk: those records were always invalid per the reference model and are now rejected by the schema too.

Added

  • verify_record() consumes the section 3.2.3 revocation bundle and reports what it checked (#190, closes #246). The bundle format merged with #187 and nothing read it. verify_record() now takes revocation_bundle, trusted_bundle_keys, max_bundle_age_seconds and now, and returns a VerificationResult whose revocation field carries one of section 3.2.3's three states as a value: verified, unverified_for_revocation, or no_check_performed, with the cause and the evidence a second verifier needs. Previously the function returned None and a caller could not tell a verified key from one nobody checked, which is #246. Two bounds govern bundle age, the issuer's valid_until and the caller's maximum measured from issued_at, and the tighter governs; an expired outcome names which bound tripped. examples/revocation-bundle/ carries 25 conformance vectors, generated, covering both bounds with margin and every non-verified state. No appraisal.status value is named; where an unresolvable check is recorded in the record stays open on #190. Callers that ignored the old None return are unaffected; a caller asserting is None on the return will see a change.

  • Section 3.3.4: disclosed gaps in a receipt chain (#117). Under a profile requiring action receipts, a specification that offers only "complete" and "broken" rewards concealment: an operator who backfills a lost receipt scores better than one who reports the loss. A GapDisclosure is a signed chain element stating that receipts which would have occupied its position were never emitted. Coverage is structural rather than asserted: the disclosure links back to the element before the gap, the next element emitted links back to the disclosure, and verification is two link checks a verifier already performs on every ordinary element. No range fields exist, because a hash chain cannot express a range and an emitter cannot know its successor's hash at write time.

The action-receipt outcome receipt_missing_required is narrowed to silent absence, and receipt_gap_disclosed is added beside it, distinct by requirement, with acceptance a verifier policy input. It never satisfies a profile requiring independently proven completeness: a disclosed gap does not establish that the missing receipts existed, how many were lost, or that omission was not selective. A disclosure at the live tail, where no successor exists to seal it, is unverified rather than disclosed or invalid: a chain truncated immediately after a disclosure is indistinguishable from an honest tail, so whatever the tail is granted, truncation is granted too. Conformance vectors in examples/action-receipts/gap-disclosure/, two per rule with a byte-for-byte generator; the tail case is pinned by its own test. Proposed and authored from production operation of a per-action receipt emitter; carried per the maintainer-carry provision in CONTRIBUTING.

Two cross-references that predate the renumbering which introduced section 3.3.1 are updated to name section 3.3.2, where the text they cite now lives.

  • examples/delegation-link/ closes the canonicalization gap spec section 3.1.3 names. That section states the corpus was entirely ASCII, so it could not discriminate an implementation that canonicalizes the parent-record digest by RFC 8785 (UTF-16 code-unit key order) from one that takes the code-point shortcut sort_keys=True takes in several JSON libraries, since the two agree everywhere except where an object key holds a supplementary-plane character. 24-parent-key-supplementary-plane.json is that vector: the root's cnf.jwk carries an additional member keyed outside the Basic Multilingual Plane (permitted by schema/trace-claim.json's additionalProperties on cnf.jwk, covered by the chain digest per section 3.1.3), and the leaf's parent_record_hash is reachable only under the correct ordering. A code-point canonicalizer computes a different digest for the same root and reports parent_not_found on a chain that is otherwise vector 01's. docs/rfcs/a2a-delegation-profile.md and the corpus's own README are updated to 24 vectors; the profile's ten rules and their margins are unchanged, since this vector tests the shared digest primitive rather than any one rule.

Fixed

  • docs/trust-levels.md now states that runtime.measurement under software-only is a documented software commitment, not a hardware measurement forced to all-zero. runtime.measurement is required on every record. Under software-only (no hardware root of trust), the field carries a software commitment whose preimage the producing profile MUST document, so a verifier can recompute it for example, sha256(image_digest + "\n" + bundle_hash) (TraceSandboxAdapter.software_measurement) or SHA-256 of the Merkle chain tip (TraceAGTAdapter), both already documented in docs/integration/sandbox-runtime.md and docs/integration/agt.md. All-zero (sha256:000...000) is reserved for a producer with no commitment to offer at all, such as a bare development record with nothing measured; it is not the default for software-only in general. The earlier draft of this fix would have declared both released reference adapters non-conformant, since they emit non-zero measurements under software-only by design. The schema (schema/trace-claim.json, src/agentrust_trace/schema/trace-v0.2.json) is unchanged: once the rule reads this way there is nothing left for the schema description to narrow. spec/trace-v0.2.md doesn't mention this convention and is unaffected. Resolves #240.

  • runtime.platform's description now reads its two situations as examples rather than as the whole set. #234 aligned the schema description with spec 3.1.1, whose lead clause is "no hardware root of trust" and whose enumeration names a development-mode execution and a record assembled from evidence produced outside the runtime. A production runtime emitting for its own executions, origin.kind: self, with no TEE anywhere in its stack, is covered by the lead clause and named by neither example, so the enumeration read as exhaustive and narrowed the rule again in a smaller way. "For example" restores the reading without adding a case or diverging from docs/schema.md. Both schema copies carry it. Raised by @chernistry on #232 and on #234.

  • schema/trace-claim.json's runtime.platform description now states the same rule as spec 3.1.1. The schema description said software-only marks "development-mode records with no hardware backing", which is narrower than what 3.1.1 requires: a record whose origin.kind is not self MUST also carry runtime.platform: "software-only", a class that is not development-mode records. docs/schema.md already stated the two-situation rule (dev-mode execution, or evidence assembled from outside the runtime); only the schema description, the text closest to a validator and most integrators, was out of step. Same shape as #172, resolved the same way: aligning the texts rather than picking one as authoritative. #232

  • The schema's subject pattern was a prefix test where the model requires a full identity, so a record could be schema-conformant and refused by model_validate. ^(spiffe://|did:) constrains only a leading spiffe:// or did:; models.py requires ^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$. spiffe://bernstein.run names a trust domain and no workload, and passed the published artifact a producer in any language validates against. The schema is tightened to the model rather than the model loosened: a bare prefix is not a constraint on an identity, and the model's rule matches DID Core section 3.1, whose ABNF fixes method-char to %x61-7A / DIGIT, so did:X:abc is not a conformant DID rather than a DID this package happens to refuse. Both schema copies carry the change and both now state the rule in prose instead of leaving it to be read off a regex. The differential in tests/test_the_schema_and_the_models_agree.py could not see this: subject's pattern was a prefix test, so every value in its matrix failed it too and the two validators agreed by both rejecting. Four values that pass the prefix and fail the shape are added, and they are the only pair of patterns among the schema's ten that differed. The two surfaces parted at 84df8ef ("security: pre-launch hardening", #49, 2026-06-18), which tightened the model's pattern from ^(spiffe://|did:) to the current one and left the schema on the old string; docs/schema.md still described the prefix reading and is corrected here too.

  • The exported SCHEMA was the live object the validator reads. _schema() is lru_cached and _validator() is built over whatever it returns, so the name exposed "for downstream tooling that needs the raw dict" and the validator's schema were one object: lowering SCHEMA["properties"]["iat"]["minimum"] made a record dated 1970 valid to validate_json(), to iter_errors(), and to the structural gate inside sign.verify_record(), for every later call in the process. Nothing about the call site looks wrong, since adapting the raw dict is the use the comment invites. It is a deep copy now; a shallow one would leave the nested properties dicts shared and the same edit would still land.

  • All eight "format": "uri" declarations in the schema were inert, and "not a uri" validated. jsonschema treats format as an annotation rather than an assertion unless a checker for that format is installed, and FormatChecker().checkers does not carry uri without an optional dependency the project did not declare. The wiring in validate.py was correct and the behaviour was a no-op, which is the shape that survives review. rfc3986-validator is now a dependency, chosen over jsonschema[format] because that pulls rfc3987, which is GPLv3, into an Apache-2.0 package's dependency tree. Turning the constraint on changes nothing about the existing corpus: the suite passed unchanged before any new test was added.

  • TrustRecord.model_validate(record).model_dump() returned a record this package's own validator rejects and whose signature no longer verifies. Pydantic serializes every unset optional as an explicit null, the schema types no named field as nullable, and the added members change the RFC 8785 canonical bytes the signature is taken over. This is the round trip sign_record()'s own docstring points a caller at, so a caller who validated the model and then wrote it out wrote a broken record, with neither check running at the moment the damage is done. Absent optionals are omitted now and the round trip is exact identity. Only declared fields are dropped: JWK sets extra="allow" and the schema permits a null among those members, so a null inside cnf.jwk is data.

  • The models accepted booleans where JSON says integer, and read them as numbers. isinstance(True, int) is a Python fact and not a JSON one: JSON Schema's "type": "integer" does not match true, so schema/trace-claim.json rejects {"slsa_level": true} and models.BuildProvenance accepted it and coerced it to 1, making the record a claim of SLSA build level 1 assembled out of a boolean. tool_transcript.call_count did the same, and appraisal.timestamp read true as 1 January 1970. iat and origin.ingested_at did not have the hole and were safe by accident rather than by design, their lower bound sitting above 1 so the coerced value failed the range check afterwards; all five carry an explicit guard now. Found by mutating every field of a valid record and comparing the two validators, which had never been compared. The differential is committed, and the disagreements it does not fix are declared with the reason.

  • Six public functions raised exceptions no module documents, and the sweep that finds them is committed. key_to_jwk() and load_key() read .public_key() and .encode() off their argument before establishing its type; key_to_jwk now names the public-key case separately, because that is the plausible mistake for a function whose name reads as "turn a key into a JWK" and whose result is the public JWK. sign_record() in both modules unpacked {**record} before checking it was a mapping. anchor_bytes() refused the two value classes registry-anchor-v1 section 1 excludes and handed everything else to json.dumps, so a type JSON cannot serialize came back as a message about a serializer. intent_bridge let rfc8785 errors out as themselves: those are ValueError subclasses, which satisfies sign's contract but not this module's, since a CanonicalizationError is not an IntentBridgeError. verify_bridge() did not leak but misattributed, canonicalizing inside the try that reports the signature invalid. The sweep walks the package rather than listing functions, and a coverage test fails until every discovered function is either swept or declared unsweepable.

  • docs/quickstart.md writes an unencrypted private key into the reader's working tree and .gitignore did not cover it. The block writes trace-key.pem under the comment "keep secure, never commit or log", and that comment was the whole of the enforcement: a reader following the quickstart inside a clone was one git add -A away from committing their signing key. The three paths the documentation writes are ignored now, along with *.pem, *.key, *.p8 and *.pfx, since the repository tracks no key material today. The accompanying test recovers the written paths from the documentation rather than listing them, so a doc that starts writing somewhere new fails rather than widening the gap quietly.

  • The revocation check no longer fails open when the store answers with a non-bool. RevocationStore is Container[str] | Callable[[str], bool], and the callable's return value was read by truthiness, so None, "", 0 and [] all read as "not revoked" and let the key through, while the string "no" read as revoked. None is the case that matters: it is what a CRL, status or SCITT lookup returns when its author handled the 200 and forgot every other response, which is exactly the outage the existing except clause was written to survive. That clause already treats a store that raises as a rejection, on the stated grounds that an unavailable source is not evidence a key is unrevoked; a store answering None supplied no more evidence and was being believed. provenance.verify_record() imports the same function and makes the same claim in its own docstring, so one fix closes both entry points. The membership branch is untouched, because in yields a real bool whatever __contains__ returns.

  • provenance.verify_record(), provenance.check_tool_catalog() and content_marking.verify_assertion() now hold their externally supplied argument to the type they document. Each read that argument before establishing its shape. Measured across a twelve-value junk matrix, the two provenance functions leaked eleven AttributeErrors apiece; the twelfth value is {}, which is an object and so reached the refusal each function documents, which is not the ProvenanceError verify_record documents. content_marking.verify_assertion() was worse than a crash rather than merely undocumented: it did not check that record_bytes were bytes, and bytes(5) is five zero bytes, so an int was hashed, failed to match, and the caller was told the record at the URL had changed, which is a specific and false accusation about somebody else's server. All three now raise the error their module documents, naming the type received.

  • jwk_thumbprint() and verify_record() now refuse a non-object argument with the error they document. Both read a member off the argument before establishing its shape, so a string, a number, None, a list or a bool raised AttributeError, which is not the ValueError verify_record's docstring names for every rejection other than a bad signature, and is not caught by a caller written against that contract. Neither argument is one the caller has already established: a JWK reaches jwk_thumbprint from a peer, a key document or a record's own cnf, and the record handed to verify_record is by definition not yet known to be an object. Both now raise ValueError naming the type received. 21 tests: removing the two guards fails 20 of them, and the twenty-first is the control that has to keep passing.

  • sign.verify_record() now validates max_age_seconds and max_future_skew_seconds the same way provenance.verify_record() does. provenance.verify_record() rejects a malformed freshness bound via _check_seconds(), added per the review on #164. sign.verify_record(), the original Trust Record verifier, never received the same hardening: it checked only max_future_skew_seconds < 0, and max_age_seconds was compared against unvalidated. Passing max_age_seconds=-1 (a value a caller might use meaning "no bound," since None is the documented way to disable the check) rejected every record, including one issued the same second, as record is stale, naming the record rather than the misconfigured argument. _check_seconds() is now defined once in sign.py and shared: sign.verify_record() calls it directly, and provenance.verify_record() imports it, passing its own ProvenanceError via a new exc parameter so each keeps its existing public error type.

  • provenance.tool_catalog_hash() now refuses a malformed tools list instead of crashing. Reached primarily through check_tool_catalog(record, tools), the function the module's own docstring calls "the step that catches a live attack," because tools there is what the MCP server actually returned, i.e. the untrusted party this check exists to catch. The function iterated tools and called .get(...) on each entry with no check that tools was a list or that its entries were objects, so a malformed response (an entry that is a string, None, a number, or tools itself not being a list) raised an unhandled AttributeError or TypeError instead of the documented ProvenanceError. A server that is misbehaving maliciously or just buggily and is exactly the source tools has no reason to trust its shape. Fixed with an explicit isinstance check on tools and on each of its entries, naming the offending index.

  • content_marking.build_assertion() and content_marking.verify_assertion() now refuse record bytes that aren't a JSON object, instead of crashing. Both functions called .get(...) on the result of json.loads(record_bytes) without checking it was a dict first. Valid JSON is not always an object that is an array, a string, a number, null, and a bool are all valid top-level JSON and record_bytes is exactly the kind of externally-sourced input this is likely to happen to: build_assertion() takes whatever bytes a caller hands it, and verify_assertion()'s docstring is explicit that its record_bytes are "the record bytes actually retrieved from its URL," i.e. a network response the caller does not control. Either function raised an unhandled AttributeError instead of the documented ContentMarkingError. verify_assertion() had a second, related gap: its second parse of record_bytes (after the hash check) was not wrapped in the try/except its first parse-adjacent check uses, so genuinely malformed (non-JSON) bytes that happened to hash-match raised json.JSONDecodeError instead of ContentMarkingError too. Both functions now check isinstance(record, dict) after parsing, and verify_assertion()'s second parse now catches ValueError the same way its hash-computation path already implicitly required valid bytes to reach.

  • TraceAGTAdapter now hashes tool_transcript the same way TraceSandboxAdapter does, and stopped rejecting ordinary floats. The two adapters compute the same schema field, tool_transcript.hash, which docs/schema.md and docs/integration/agt.md both describe as "the canonical JSON of the ... AuditEntry list", canonical JSON meaning RFC 8785 (JCS) everywhere else in this codebase, and what TraceSandboxAdapter.transcript_hash already uses. TraceAGTAdapter._transcript_hash instead went through anchor_bytes, the registry-anchor sorted-key format that spec/registry-anchor-v1.md §0 scopes explicitly to the transparency anchor leaf, not to a hash carried inside the record. The prior "Fixed" entry above closed the out-of-range-integer gap between the two adapters by routing the AGT adapter through anchor_bytes rather than rfc8785, which stopped the collision but kept the two adapters on different canonicalizations and introduced a regression anchor_bytes carries for a different reason: it also refuses any non-integer number. A Cedar/AGT audit entry carrying an ordinary float that is a timestamp with fractional seconds, a decision latency, a risk score, all routine and could no longer be turned into a Trust Record at all; build_trust_record raised UnanchorableValue instead of returning one. TraceAGTAdapter._transcript_hash now uses rfc8785.dumps, matching its sibling exactly: same bytes hashed, same digest on identical input, same rfc8785.IntegerDomainError on an out-of-range integer, and no error at all on a float.

  • provenance.verify_record() and provenance.check_tool_catalog() now fail closed on a malformed identity or tool_catalog, instead of crashing. Both functions' documented contract is to raise ProvenanceError (or its subclass ToolCatalogMismatch) on rejection, so a caller can safely wrap either in except ProvenanceError. Four spots read a nested block with record.get(field) or {} and or, in check_tool_catalog(), (record.get(field) or {}).get(...), which only guards against the block being absent: a present but non-dict value (a string, a list, a bare number, True) is truthy, survives the or {}, and the next .get() call on it raised an unhandled AttributeError instead. identity itself, identity.artifact, identity.endpoint, and tool_catalog (read independently by both functions, since check_tool_catalog() is callable on its own without verify_record() having seen the record first) could each be set to a non-object and crash a caller that was, correctly, only catching the documented exception type. A record with a malformed identity or tool_catalog is exactly the kind of adversarial input these functions exist to reject rather than choke on. A new internal _as_object() helper enforces "must be an object or absent" everywhere a nested block is read in either function, and _check_structure() checks artifact/endpoint the same way before touching them.

Added

  • Section 3.1.2 now states what a references entry cannot carry, and separates resolver from a verification authority. The block's assurance-neutrality already told a reader what a reference is; it did not say what follows. Two things do, and both are properties of the block rather than of its relation set, so registering a new rel does not touch either: an entry cannot carry compliance evidence, because the signature covers the pointer and not the target, and it cannot carry a pre-execution commitment, because a Trust Record is issued per execution and the evidence exists only after the thing it would have governed. Written as a general boundary rather than against any one use, since the first case to hit it will not be the last.

resolver needed the same treatment for a different reason. It is a retention undertaking, naming the party obliged to keep id resolvable, and it reads as a verification authority until the two are separated. The conformance suite refuses the opposite arrangement for a different field: TR-POL-003 takes its resolver from the caller and never derives it from the record, because a record that names its own checker can name one that agrees with it. Naming yourself as the party who must retain an artifact is ordinary; naming yourself as the party who decides whether it is true is that circularity. Rule 4 is aimed at a producer who can name no obliged party at all, not at one who is that party. No normative text, schema, or record field changed.

  • The references block is now in the schema and the model. Spec section 3.1.2 landed the block in #198 as text only, and text alone did not make it usable: schema/trace-claim.json sets additionalProperties: false at the top level and TrustRecord is extra="forbid", so a record carrying the field the specification permits was rejected by both artifacts a producer validates against. references is an array of entries with required rel, id and resolver, and optional retention (ISO 8601 duration) and digest. rel is a registry rather than a closed set: authorized-intent, approval-outcome and behavior-trace are the values section 3.1.2 registers today, and the schema holds rel to being non-empty rather than to that list, so a new relation is a spec change and not also a schema change. The array itself may be empty; rule 4 tells a producer to omit the entry, not the block.

Two of the four rules in section 3.1.2 are properties of a record and are tested here: a record carrying references and no origin keeps the hardware runtime.platform it earned, and the signature covers the block, so rewriting any member of any entry in transit fails verification. The other two, a verifier MUST NOT reject a record for an entry it cannot resolve, and MUST NOT treat a resolved entry as attested evidence, are verifier behaviour that no schema can express, and belong to the conformance suite.

resolver is constrained on presence and not on value: section 3.1.2 requires a producer that cannot name a resolver to omit the entry rather than emit a self-asserted one, and whether an identifier is self-asserted is not decidable from the record.

  • Record-signing key revocation is anchored to transparency-log entry ordering, not to iat. New spec section 3.2.3 defines the TraceRevocation/1.0 claim type: a record from a revoked key is valid if and only if its SCITT inclusion entry ID is at or below last_valid_entry_id on the log the statement names. The intuitive time-based rule cannot work, because a compromised record-signing key also signs the iat it would be judged against, so an attacker backdates the record and the rule passes. Entry IDs are monotonic and bound to the Merkle structure, so ordering survives the compromise a timestamp does not.

Distribution keeps section 3.3's no-callback property: statements are anchored in the same log as the records they govern, and verifiers cache a signed bundle carrying valid_until. An expired bundle is not a pass, and a verifier with none reports that it performed no revocation check rather than reporting an affirming appraisal. A revocation statement MUST be signed by a key above the revoked one in the section 3.2.1 hierarchy, or by a recovery key with an independent compromise domain, because a statement the compromised key could sign for itself is a tool for whoever stole it. Records with no usable inclusion entry ID fall back to binary revocation, which is the existing behaviour. Schemas: schema/trace-revocation.json, schema/trace-revocation-bundle.json. Resolves #67.

Section 3.2.1 previously required verifiers to "consult current revocation status at verification time", which contradicted the offline-verification property in the same document. It now points at 3.2.3. - A verification profile is proposed for the delegation block, with the conformance material to argue it against. The block is normative in v0.2 and nothing says what a verifier does with a chain of them: spec/trace-v0.2.md never mentions parent_record_hash or credential_id, and the one descriptive sentence in docs/schema.md leaves every operative term open, so two conforming implementations can agree on nothing. docs/rfcs/a2a-delegation-profile.md proposes ten rules over the fields that already exist, no schema change, and examples/delegation-link/ carries 23 vectors that score an implementation against them. Three forks in the current text had to be settled before any vector could be written, and each is stated with the reason rather than assumed: the digest covers the complete parent record including its signature, because a digest of the signed body alone does not bind the parent's signer; there is no cycle rule, because a delegation cycle is a hash collision and the reachable analogue is an unbounded chain, which is what the depth bound is for; and a link naming a digest algorithm the verifier cannot compute makes the chain unverifiable rather than invalid, which is the delegation-surface instance of the semantics already merged in docs/verification.md. Nothing here binds an implementation until the profile is adopted. Targets the v0.3 A2A profile named in ROADMAP.md.

  • build_provenance now declares verification depth. A new optional provenance_depth (surface, builder, transitive) says how far down the supply chain the issuer claims to have walked, and a new optional appraisal.provenance_depth_verified records how far the verifier actually walked. Spec section 3.3 step 7 previously left three stopping points equally conformant, so two verifiers could reach opposite conclusions on the same record with no way to say why. Both fields are optional and a record omitting provenance_depth is read as surface, so existing records keep their meaning. Evidence that does not resolve and evidence that resolves and contradicts the record are separate outcomes: the first downgrades the recorded depth and names what was missing, the second fails the appraisal and cannot be downgraded away. Resolves #50.

Security

  • Release gates now fail closed. CodeQL analysis failures block instead of being ignored. Before trusted PyPI publication, clean virtual environments install and verify both the built wheel and source distribution outside the checkout, checking tag/version identity, packaged schema resources, signing and verification, and rejection of unknown security fields.

Fixed

  • Local test runs now always exercise the checkout. Pytest prepends src to its import path, and a regression test asserts that agentrust_trace resolves to the repository source. A stale installed wheel can no longer shadow current security fixes and produce misleading failures or passes.

Fixed

  • The confirmation key must now match the trusted signing key. verify_record() compares the RFC 7638 thumbprints of cnf.jwk and the caller-supplied trusted key before accepting the signature. A trusted signer can no longer produce a record that verifies under one key while naming another key for downstream proof-of-possession checks.

  • verify_record() now enforces the canonical v0.2 JSON Schema. A cryptographically valid signature no longer causes an object with unknown fields, missing required claims, or invalid nested values to be accepted as a verified TRACE record. Schema failures are surfaced as ValueError with the failing field path.

Fixed

  • Future-dated records no longer create an unbounded freshness window. verify_record() now rejects an iat later than the verifier's clock plus max_future_skew_seconds (default 5 minutes), independently of the maximum-age check. The v0.2 freshness requirements and verification tutorial document both bounds.

Fixed

  • Integer fields and undeclared JWK members are now bounded to the JCS safe-integer domain. Section 3.2.2 serializes numbers as IEEE 754 doubles, and iat, origin.ingested_at, tool_transcript.call_count and appraisal.timestamp were typed integer with no upper bound, as were issued_at and valid_until in the revocation bundle, revoked_at in a revocation statement, and authorized_at and expires_at in the PIC/TRACE bridge, whose sign_bridge and verify_bridge call the same _canonical_bytes as everything else. A record carrying a value above 2^53 was therefore schema-valid and had no canonical form to sign: 9007199254740992 and 9007199254740993 are distinct integers that the mandated algorithm maps to the same bytes, so one signature stands for two records. Measured on two independent RFC 8785 implementations, which disagree: canonicalize 4.0.0 (npm) applies the algorithm as written and emits identical bytes for the pair, while rfc8785 0.1.4 (PyPI, pinned here) refuses both. cnf.jwk needed the same treatment for a different reason: RFC 7517 lets a JWK carry members this schema does not name, the signature covers them, and additionalProperties was absent, so bounding the declared fields left the collision reachable through an undeclared one. RFC 8785 Appendix B note 1 names the range -9007199254740991 to 9007199254740991 as a SHOULD on values interpreted as true integers, and section 3.2.2 now raises it to a MUST, on the grounds that a Trust Record is a signed statement. Every integer field in a schema whose signature is defined over an RFC 8785 canonical form now carries maximum: 9007199254740991, undeclared cnf.jwk members are held recursively to a new $defs/canonicalizableValue that excludes number and bounds integers, and section 3.2.2 states the rule. appraisal.timestamp had no lower bound either and takes the symmetric floor rather than a calendar one, because what a sensible earliest appraisal time would be is a separate question and this change does not answer it. spec/registry-anchor-v1.md §0 carried the same claim about a second canonicalization and had to be corrected with it: it said the anchoring layer and JCS agree on records "whose numbers are integers", which holds only inside the safe-integer range, and it listed three ways they diverge where there are four. The anchor profile is worse off than JCS here, because two implementations of its own four rules disagree with each other: Python's json.dumps writes the exact digits and a JavaScript implementation goes through JSON.stringify and emits one value for both. Measured on tool_catalog_hash, which runs those rules over a tool's input_schema where a maximum is ordinary content: Python gives two digests for two catalogs and JavaScript gives one, so the tool-description rug-pull that hash exists to catch goes undetected. §1 now excludes the range, as it already excluded non-integer numbers and for the same reason, and the exclusion is executable rather than only written: a new agentrust_trace.sign.anchor_bytes performs the sorted-key serialization and refuses a value §1 puts outside the profile with a named UnanchorableValue. Both implementations of that format, tool_catalog_hash and the AGT adapter's transcript hash, go through it, so the symptom §0 describes as having no useful diagnostic now has one. The AGT adapter's transcript hash now refuses an out-of-range integer where it previously returned a digest; its sibling, the sandbox adapter, already refused one, so the two agree for the first time on input a governance framework really produces, such as an audit entry carrying a nanosecond timestamp. docs/schema.md, the field reference a producer reads, states the range under its own heading rather than leaving it to be discovered from the spec. agentrust_trace.models mirrors every schema constraint (ge=0 for call_count, ge=0, le=3 for slsa_level), so iat, origin.ingested_at, tool_transcript.call_count and appraisal.timestamp carry the bound there too; a producer building against the model no longer gets a record the schema rejects. One surface no schema can reach is covered by the normative sentence alone: digest_jcs takes the digest of a caller-supplied declaration or tool-call object, and SandboxAdapter.transcript_hash of a decision log, neither of which any schema validates. Measured: two declarations differing only in an integer above the range produce one digest under canonicalize 4.0.0. Section 3.2.2 now states the rule for any object canonicalized under it rather than for a Trust Record alone, and a test pins that digest_jcs fails closed here. The superseded v0.1 schema is unchanged and is the one file left out: verify_record rejects the v0.1 profile identifier, nothing in the repository loads that schema, and a test holds that reason to the tree rather than leaving it as a comment. No record that could ever have been signed by this repository is affected: the canonicalizer it pins already refuses the values the schema now refuses.

[0.9.0] - 2026-08-09

Documentation

  • The security policy now describes the software that is actually released. It puts the Python signing and verification APIs, schemas, adapters, packaging, and release automation in scope; lists TRACE v0.2 and agentrust-trace 0.x as supported; and marks the superseded v0.1 profile unsupported.

[0.9.0] - 2026-08-09

Added

  • spec/content-marking-v1.md and agentrust_trace.content_marking: bind a marked asset to the execution that produced it. EU AI Act Article 50(2) and 50(4) have been in force since 2 August 2026 and are the only AI Act obligations that bite this year. One C2PA assertion, com.agentrust-io.trace, carries a hashed reference to the Trust Record for the execution that produced the asset.

The document opens with what this does not do, because the temptation to overclaim here is strong. It does not stop anyone stripping the mark: removing a C2PA manifest from a file is trivial and nothing here changes that. It is not watermarking. It does not make a deployment compliant. What it buys is narrower and real: a mark that is present becomes checkable against a hardware-rooted claim instead of being an unverifiable label, and in a channel that requires marks an absent one is detectable.

Three separate things must be checked and the assertion merges none of them: the C2PA signature says the assertion was in the manifest when the asset was signed, the TRACE signature says the execution happened as described, and the hash says the record being pointed at is the one the signer meant. A verifier that checks one of the three has checked a third.

build_assertion() takes the exact bytes that will be served rather than a record object, because a hash over a re-serialized dict is a hash of bytes nobody will fetch; a test pins that indent=2 alone breaks the binding. verify_assertion() has no signature-only path: record_bytes is a required parameter, since an assertion whose hash was never checked is a URL in a file.

17 tests.

Added

  • enforcement_mode: "declared". The three existing modes all assert that something evaluated the policy: enforce acted on the result, advisory did not, silent acted with the log lines suppressed. declared asserts less: the policy is named and bound into the signed record, and nothing evaluated it.

That is not a corner case, it is the common one. An agent framework has no policy engine, so a record built by observing a LangChain or LlamaIndex run has a policy the operator declares and no evaluation of it anywhere. With three values, such a record had to claim an evaluation that never happened; both framework adapters refused to default the field and documented the overstatement instead, which is honest and still leaves every framework record marginally untrue.

declared is the weakest value and is never a default. A producer that evaluates policy MUST NOT use it, and a consumer MUST NOT read it as evidence that any rule was checked. A verifier appraising for enforcement SHOULD treat it as it treats an absent enforcement claim.

Additive to a closed enum in both the model and the JSON schema, so unknown values are still rejected. Same one-directional consequence as origin: a verifier older than this release rejects a record carrying declared.

[0.8.0] - 2026-08-09

Added

  • agentrust_trace.provenance: build, sign and verify MCP Server Provenance Records. Step 2 of the sequence, implementing spec/server-provenance-v1.md. In the SDK rather than in cMCP, so a publisher can produce a record without adopting a runtime, which is the only way the format reaches an ecosystem that will not adopt one.

check_tool_catalog() is a separate call on purpose. Verifying the signature proves a document is internally consistent and signed by a key you trust, which is exactly what an attacker holding a stolen publisher key can produce. What they cannot do is make the server in front of you offer the tools their record describes. That comparison needs something verify_record() does not have, what the server said to you, so it is its own obvious call rather than a flag, and it raises its own exception type so a consumer can tell "bad document" from "wrong server".

The builder refuses records that cannot mean anything: an identity with neither artifact nor endpoint, a tee-attested record with no evidence, evidence attached to a kind that does not claim it (a reader would take it as an attestation that was made), a publisher that is a display name rather than a resolvable DID or SPIFFE URI, and an endpoint URL with no key digest, since a URL alone is not an identity.

verify_record() requires a trusted key and never takes one from the record. Verifying a document against a key it supplies proves only that it is internally consistent, which is what a forgery is.

24 tests, including the one the format exists for: a perfectly valid signature over a description of a different server still fails the catalog check.

Added

  • spec/server-provenance-v1.md: a signed statement about an MCP server. cMCP enforces policy at the call boundary and can say nothing about whether the server on the other end is what it claims. Its catalog answers that locally, approved definitions, a measured catalog hash, a pinned TLS fingerprint, but every part of that is operator-asserted, so nothing one operator learns is usable by the next.

The format carries the same shape of honesty as the origin block: a closed kind (publisher-asserted, observer-attested, tee-attested) because the interesting fact is never that provenance exists but who is asserting it, and an explicit rule that a verifier MUST NOT treat absence as any of them.

Identity is artifact, endpoint, or both, with the record saying which. A URL is the obvious handle and the worst candidate: it moves, it is per-deployment, and two operators running the same server produce different ones. artifact.digest covers the entrypoint rather than the interpreter, for the reason the stdio work found: every interpreted server on a host shares one interpreter digest, so a pin over it matches a completely different server.

The tool-catalog hash covers name, description and input schema. Description is in deliberately: a tool whose description changes from "search the docs" to "search the docs and email results to the address in the query" is exactly the rug-pull the hash exists to catch, and a hash over names alone misses it.

Three things are stated as out of scope rather than hand-waved: key distribution for publisher (a PKI question this format would only pretend to solve), whether a server is any good (the moment a provenance format scores servers, its publisher becomes the party everyone must trust), and what the code does at runtime (provenance narrows what code you are talking to, nothing more).

Fixed

  • The packaged schema had drifted from the normative one, and a DID subject was the casualty. validate_json() loads src/agentrust_trace/schema/trace-v0.2.json, while the spec, README and CONTRIBUTING all point a reader at schema/trace-claim.json. The two disagreed in three places, so the schema someone reads was not the schema their record was checked against. The visible consequence: the root file and models.py both accept a did: subject, the packaged copy still required ^spiffe://, so agentrust_trace.validate_json() rejected a subject form the specification permits. Also resynced: the slsa_level description, and the root file's $id, which still said trace-v0.1.json on a schema whose eat_profile const is v0.2.

tests/test_validate.py now compares the two as parsed JSON on every run, so the next drift fails instead of shipping. Compared parsed rather than byte for byte because the two files differ in line endings by long-standing accident, which changes nothing about how either validates a record.

  • A 0.6.0 changelog entry was filed under 0.5.1. The verify_record() profile-cutover enforcement shipped in 0.6.0; its entry landed in the 0.5.1 section, next to the cutover declaration it implements, which left 0.5.1 with two ### Fixed blocks and 0.6.0 with no entry for a behaviour change. Moved, with its two internal cross-references corrected to match where it now sits.

[0.7.0] - 2026-08-08

Added

  • origin (optional object): who assembled this record, when it was not the runtime that ran. {kind, producer, source_event_id, ingested_at}. Additive and backward compatible; existing records without it stay valid, and absence means self. Same shape of change as delegation in 0.4.0, under the same v0.2 profile URI.

It exists because runtime.platform: "software-only" is ambiguous, and the ambiguity is about to matter. It is the honest value for a dev-mode record, where nothing attested the execution, and for a record transcribed from a third-party control plane, where the party asserting the evidence also wrote the log. Those are different claims, and a consumer weighing a record could not tell them apart from platform alone. kind is a closed set (self, third-party-control-plane, log-import) rather than free text, because the value of the field is that a verifier can key on it.

A record whose kind is not self MUST carry runtime.platform: "software-only", enforced in the model and in schema/trace-claim.json via if/then, so a validator that never loads the Python still rejects it. An importer holding someone else's log has no quote to present, so a hardware platform there is untrue rather than stronger. It is also exactly what an adapter produces by starting from a hardware example and editing the fields it understood, which is why it is a MUST and why it is tested from both directions.

The block launders assurance in neither direction. Naming your producer does not make unattested evidence attested, and a record with a verified hardware quote is what it is whether or not it says origin: self.

Spec §3.1.1, docs/schema.md, both schemas, 11 tests.

  • spec/registry-anchor-v1.md: the registry anchor and inclusion-proof format is now public (#111). The format was already normative and already written so a conforming verifier could be built from it alone, but it lived in trace-registry, which is private. The effect was that the one document an external verifier needs was the one they could not read, and an inclusion proof nobody outside can check is not transparency. It is published here, in the public spec home, as @l33tdawg proposed in the discussion that raised this.

§0 leads with the trap, because it is the one that costs an implementer a day and gives no useful error: TRACE canonicalizes with RFC 8785 (JCS) for signing and with sorted-key JSON for the anchor leaf. The two agree on ASCII-only records with integer numbers, which is most records, which is exactly what makes assuming JCS at the leaf dangerous.

§8 states conformance, including the requirement that the append-only property be externally checkable rather than asserted. An operator that issues verifiable proofs but publishes nothing an outsider can audit is running a log, not a transparency log.

Fixed

  • docs/schema.md described transparency as required with an empty string for unanchored records. It has been optional below Level 2 since 0.5.1, and "" is rejected.

  • Four documents told readers to send signed records to a domain this project does not own. docs/integration/agt.md, docs/integration/cmcp.md, docs/trust-levels.md and docs/verification.md still named registry.agentrust.io, which resolves to third-party parked addresses. This is the same defect the v0.2 profile cutover fixed in the identifier, missed in the prose. Moved to registry.agentrust-io.com, which is what the SDK's adapters already emit. The v0.1 spec keeps its original values; it is a superseded document and a record of what was published.

  • The anchoring tutorial documented an API that does not exist. It instructed readers to POST signed Trust Records to a SCITT HTTP endpoint at the parked domain above and to read a receipt_uri from the response. There is no such endpoint. Rewritten against the actual mechanism: submit to staging, retrieve an inclusion proof, and verify that proof yourself against the published entry. It also still described transparency as a required string, which 0.5.1 changed. The page carries a dated note saying what it used to say, because anyone who built against it deserves to know nothing they sent was received.

Changed

  • The spec, the README and the roadmap named three different standards homes between them. §6.1 proposed splitting TRACE between CoSAI and the Linux Foundation entity hosting MCP; the README said "Targeting AAIF"; neither is where this is going. TRACE is being formed at the Linux Foundation as its own series, "TRACE Specification, a Series of LF Projects, LLC" (see #127). §6.1 is rewritten, the README line is corrected, and §7 Q1 is marked resolved rather than deleted so a reader tracking it can see how it landed.

  • §4.1 described the MCP and A2A profiles as "targeted for v0.2" in the v0.2 document. Neither shipped in v0.2. Both are now stated as targeted for v0.3, and the A2A entry says what did land: the delegation link block, as the foundation the binding rules will attach to. §7 Q6 (A2A timing) is marked resolved, since A2A stabilizing at v1.x was the blocker it asked about.

  • §7 was headed "These need input before v0.2". Now v1.0. No normative text, schema, or record field changed.

[0.6.0] - 2026-08-07

Fixed

  • __version__ reported the wrong number for four releases. It was a literal that drifted from pyproject.toml at #36 and was never corrected, so v0.3.0, v0.4.0, v0.5.0 and v0.5.1 each shipped a wheel reporting 0.2.0 at runtime. Anyone pinning or logging on agentrust_trace.__version__ got the wrong answer, and nothing failed. It now derives from installed package metadata, which makes the two unable to disagree, and tests/test_version.py pins the source tree against pyproject.toml and requires the changelog to carry a section for the declared version before a tag is cut.

  • The package description advertised TRACE v0.1. The PyPI summary still named the superseded profile.

  • verify_record() now enforces the profile cutover this changelog already declares. The 0.5.1 cutover entry states that a v0.2 verifier "requires the new URI and rejects the old one; it does not accept both", but verify_record() never read eat_profile, so a record carrying the v0.1 identifier, a future version, a foreign tag, or no profile at all verified exactly as a v0.2 record, provided its signature checked out. A valid signature over semantics this build does not implement is not evidence, so the profile is now checked first, before any cryptographic work: anything other than TRACE_PROFILE_V0_2 (newly exported) raises ValueError, with a message that says why when the profile is the superseded v0.1 identifier. Same shape as the revocation enforcement in this release: an already-merged spec requirement (spec/trace-v0.2.md section 2) that the reference implementation did not carry out. docs/verification.md step 4 notes the check is now built in. No normative text, schema, or record field changed.

Added

  • TraceSandboxAdapter: Trust Records from a sandboxed agent runtime. A kernel sandbox confines one agent on one machine. It does not answer, on its own, which agent on which of two hundred machines took an action, what actually ran rather than what the policy said, or how to say either on a host with no secure hardware. The adapter builds a record from what such a runtime already has at session close: sandbox identity, image digest, the effective policy bundle bytes, and the decision log. No change to the runtime is required.

Unlike TraceAGTAdapter, one code path spans Level 0 and Level 1. Passing a SandboxAttestation moves the record from software-only to the attested platform and nothing else about the call changes, because a sandbox runs wherever the customer runs it and the deployments that most need evidence often have the least hardware.

A caller cannot claim hardware it does not have: platform is only ever set from a supplied attestation, an attestation may not name software-only, the platform is validated against the enum on RuntimeInfo rather than a copy of it, and the measurement must be a sha256:/sha384: digest. Sandbox identity and image ride the existing subject and build_provenance.digest, so no schema change was needed.

Two defaults differ from the AGT adapter, deliberately. appraisal.status is "none", because building a record does not appraise it and affirming would put a verdict in the field a consumer reads to find out whether anybody checked. transparency is None and omitted, which is what an unanchored record should say.

tool_transcript.hash is taken over the RFC 8785 canonical form of the decision log rather than json.dumps(sort_keys=True). The two agree on ASCII and diverge on non-ASCII strings and number formatting; a decision log carries paths and hostnames, and the signature pre-image already uses JCS. See docs/integration/sandbox-runtime.md and examples/sandbox-runtime.json.

  • verify_record(..., revocation=...) enforces key revocation at verification time (#76). §3.2.1 has always required that "Verifiers MUST consult current revocation status at verification time", but verify_record() checked only signature and freshness, so a record signed by a revoked or compromised key kept verifying. The new revocation parameter accepts either a container of revoked key identifiers or a callable performing a live CRL, status-endpoint, or SCITT lookup. A listed key is rejected, and a store that cannot answer is also rejected: an unavailable revocation source is not evidence that a key is unrevoked.

Keys are identified by RFC 7638 JWK Thumbprint or kid. The check reads the trusted key rather than record["cnf"]["jwk"], which is attacker-controlled until the signature verifies.

Additive and backward compatible: revocation defaults to None, which leaves verification purely offline and unchanged. That mode cannot prove non-revocation, now stated in LIMITATIONS.md and docs/verification.md. No normative text, schema, or record field changed.

  • jwk_thumbprint(jwk): RFC 7638 JWK Thumbprint (RFC 8037 §2 for OKP), exported so callers can key a revocation list on the same identifier the verifier derives.

[0.5.1] - 2026-07-28

Fixed

  • transparency is optional below Level 2. The model required a non-empty URI on every record, which was stricter than both schema/trace-claim.json (required, no minLength) and the conformance suite, which runs TR-ANC at Level 2 only. A Level 0 or Level 1 record is not anchored, so it has no receipt to name, and that state was unrepresentable. None now means unanchored; an empty string stays rejected, since "" is not a URI and a field that looks populated but resolves to nothing is worse in a trust record than an absent one.

Changed

  • BREAKING: TRACE v0.2 changes the EAT profile URI to tag:agentrust-io.com,2026:trace-v0.2 (was tag:agentrust.io,2026:trace-v0.1). agentrust.io was never a domain this project controlled; it resolves to third-party parked addresses. RFC 4151 permits a tag URI only where the minting authority controlled the named domain on the stated date, so the v0.1 identifier was invalid rather than merely misspelled: it asserted authority over a name someone else could stand up a conflicting definition at.

Cutover, not coexistence. A v0.2 verifier requires the new URI and rejects the old one; it does not accept both. Dual acceptance would keep the invalid identifier live indefinitely, which is the thing being fixed. Records already issued under v0.1 stay verifiable against spec/trace-v0.1.md and the published agentrust-trace 0.4.x releases, which remain on PyPI. They are v0.1 records and are read as such.

Nothing else in the record format changed. No field was added, removed, or re-typed, so migration for a producer is the profile string and a dependency bump.

Moved together: spec/trace-v0.2.md (new, with a "Changes from v0.1" section), spec/trace-v0.1.md (retained, marked superseded), the root schema/trace-claim.json const, the packaged agentrust_trace/schema/trace-v0.2.json, the eat_profile Literal in models.py, the AGT adapter, validate.py's schema resource, the four platform example records, and the docs.

  • Other agentrust.io URLs moved to agentrust-io.com: the registry and verifier hosts in the AGT adapter and the schema $id.

[0.4.0]

Added

  • azure-cvm-sev-snp platform: Azure confidential VMs run AMD SEV-SNP behind a Hyper-V paravisor: the SNP report is read from the vTPM (the guest does not control REPORT_DATA), so the runtime binding rides a vTPM AK-signed quote rather than the SNP report_data. Given its own runtime.platform value (distinct from amd-sev-snp) so a consumer keying on runtime.platform knows the root of trust is vTPM-rooted, not direct-silicon. Added to the RuntimeInfo model and the JSON schema enum. Hardware-validated on a live Azure SEV-SNP VM via cMCP.

  • delegation (optional object): the A2A profile delegation-link block, carrying parent_record_hash (digest of the parent hop's Trust Record) and credential_id (the delegation credential this hop acted under). A chain of records linked this way forms an offline-verifiable delegation DAG. Backward-compatible: existing records without delegation remain valid. This is a MINOR (additive) change and the foundation of the forthcoming A2A profile; A2A is now stable at v1.x, clearing the prior blocker.


[0.3.0] - 2026-06-30

Security

  • verify_record now requires an explicit trusted key. Self-verification from the embedded cnf.jwk is no longer the default; use allow_embedded_key=True to opt in.
  • Verification enforces freshness (iat / max_age_seconds, default 24h) and an optional expected_nonce. JWK kty / crv are validated.

Breaking

  • BREAKING: Canonicalization is now RFC 8785 (JCS). Trust records are NOT cross-verifiable with 0.2.0 (the prior json.dumps canonicalization was non-conformant).

[0.1.0] - 2026-06-23

Initial public draft. Announced at Confidential Computing Summit, San Francisco.

Specification

  • Trust Record logical schema (§3.1): subject, model, runtime, policy, data_class, tool_transcript, build_provenance, appraisal, transparency, cnf
  • Wire format (§3.2): EAT/JWT and CBOR-COSE envelopes; profile URI tag:agentrust.io,2026:trace-v0.1
  • Signing and key management (§3.2.1): ES256/ES384/EdDSA; four-layer key hierarchy; hash agility; revocation
  • Verification protocol (§3.3): five-step offline verification, no issuer callback
  • Standards composition (§4): RATS/EAT, SLSA, SPIFFE, SCITT, EAR, MCP, A2A, AIBOM, C2PA
  • Hardware roots (§4.2): NVIDIA H100/Blackwell, Intel TDX, AMD SEV-SNP, Azure MAA, GCP Confidential Space, AWS Nitro
  • Reference implementation (§5): cMCP Phase 1 to 3 roadmap

Schema

  • schema/trace-claim.json: JSON Schema (draft/2020-12) for Trust Record validation

Examples

  • examples/amd-sev-snp.json: AMD SEV-SNP Trust Record
  • examples/intel-tdx.json: Intel TDX Trust Record
  • examples/nvidia-h100.json: NVIDIA H100 Confidential Computing Trust Record

Open questions

Seven open questions requiring community input before v0.2 are documented in §7 of the spec.


[0.2.0] - TBD

Specification

  • Extend subject field to accept DID URIs (any did: method) in addition to SPIFFE SVIDs. Previously ^spiffe:// only; now ^(spiffe://|did:). Additive, backward-compatible. DID-native runtimes (e.g. AGT did:mesh: identities) no longer require a parallel SPIFFE identity. Closes: microsoft/agent-governance-toolkit ADR-0032, agentrust-io/trace-spec#35.

Schema

  • schema/trace-claim.json: subject pattern updated to ^(spiffe://|did:), description updated.

Reference Implementation

  • TrustRecord.subject pattern updated to r"^(spiffe://|did:)".

Upcoming

See ROADMAP.md for planned changes in v0.2 and v1.0.