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]¶
- Accuracy pass on non-normative text. Examples, schema descriptions, adapter docstrings and unsigned test records use vendor-neutral placeholders (
example-provider,example-model), the Section 6.2 list keeps confidential-computing and standards organizations, and the Appendix B reference titles use each project's own name. The Acta crosswalk now cites v0.2 sections. Platform pages no longer name a cMCP provider value. The package description, spec abstract, charter, schema descriptions and conformance messages no longer say "hardware-attested": a record is a signed governance record that can carry hardware attestation evidence, andverify_record()performs no attestation verification. Signed and digest-pinned vectors are unchanged. No normative text changes. - Conformance site links point at trace.agentrust-io.com/conformance/, where the combined docs build publishes it; the stale
conformance/CNAMEis removed. - Generators write LF on every platform.
gen_number_spelling.pyand the anchor-inclusion generator opened their output in text mode, so a Windows run wrote CRLF and the byte-for-byte regeneration check failed. - BREAKING: Successful
provenance.verify_record()calls previously returnedNone; they now returnRevocationCheck(#279). Callers can distinguishno_check_performedwhen no revocation store is supplied fromverifiedwhen the supplied store was checked and clean for this call. Revoked-key refusal is unchanged. No new revocation source or bundle support is introduced. - BREAKING: a verifier that reports
affirmingafter a section 3.3 step 4 reference match, while its appraisal policy requires a layer, must now establish that layer inappraisal.platform_measurement(section 3.1.5, rule 2, #279). Reportingaffirmingwith the block absent, or with a required layer not established or not listed, is no longer conformant. Records do not change: the schema change is one new optional member ofappraisal, and no existing document changes verdict. v0.2 is a Draft, so the 30-day period in GOVERNANCE does not apply. -
appraisal.platform_measurement: what a matching measurement covers, layer by layer (section 3.1.5, #279). A matching compositeruntime.measurementdoes not say which layers recorded anything, which were appraised before they ran, or whether the evidence describes one boot, and the record could say onlyappraisal.status: noneabout it. A verifier now writes, per layer,establishedornot-establishedwith one of three reasons:layer-not-measured,measured-not-appraisedorevidence-spans-multiple-boots. The first two are what a digest comparison reports as a pass; the third is what a replay check reports as tampering. A layer the result does not list is not established, nor is any layer of a record without the block; a replay mismatch the verifier cannot attribute to one boot is not, on that basis alone,contraindicated; and a record carrying a result does not reportstatus: none. The block is a member ofappraisalin its own right, not anappraisal.methodvalue, so it sits beside a re-execution result; itsmeasurementequalsruntime.measurement, areasonis nevernull, and ontpm2a layer is namedpcr:<n>for a register from 0 to 23. Schema, packaged copy, reference model anddocs/schema.mdcarry it; the v0.3 draft schema is regenerated.tests/test_safe_integer_range.pynow admits a map whose every member is one object schema, which it then holds to the same rule. Forty-four signed vectors inexamples/platform-measurement-appraisal/: five case-and-twin pairs, two records carrying measurements from published quotes on a board with a discrete TPM, at least two rejections per rule, and an accepting twin for every rejection that differs from it only in the rule it tests. Proposed and written by @revenue7-eng in #279; carried by @imran-siddique as sponsoring maintainer. -
verify_recordreports what a caller-supplied appraiser found aboutruntime.measurement, per layer (#279 platform-measurement row, #431). A matching composite measurement says nothing about which layers were measured, which were appraised, or whether the evidence describes one boot, andverify_recordnever sees the quote, log or reference values to decide it. The newplatform_appraiserargument, modelled oncitation_resolver, is called last with a copy ofruntime; the result's newplatform_measurementfield carries its report asappraisedwith aLayerCheckper layer (established, ornot_establishedwithlayer_not_measured,measured_not_appraisedorevidence_spans_multiple_boots),appraisal_rejectedwhen the appraiser raised, returned another shape or appraised another measurement, ornot_attemptedwithout one. The names are not accepted normative text. No outcome movesrevocation, the thumbprint,citationsorappraisal.status, andnot_attemptedis never a pass. Sixteen signed vectors inexamples/platform-measurement/: each cause with a twin that differs only in the condition producing it, and two carrying measurements from published quotes on a board with a discrete TPM. No schema or wire-format change. -
A recomputable
observed_digestfortool-catalogcomponents, with the pinnedtools/listbytes it recomputes from (#443).docs/rfcs/tool-catalog-observed-digest.mdstates one derivation from thetoolsmember of atools/listresult to a digest per tool (six-member allowlist, RFC 8785, SHA-256, under a literal profile label), a printable-ASCII key per tool, and a catalog digest folded over the sortedkey=digestlines, so a relying party can setexpected_observed_digestfrom bytes it holds and a second implementation can recompute it.examples/verifier-token-conformance/catalog/pins two served catalogs (mcp.deepwiki.com, three tools;docs.mcp.cloudflare.com, two tools), one constructed drift of the first, and the digests recorded for each. Three vectors underTR-COMP-MCP-001:COMP-MCP-004pins and presents the served catalog's digest;COMP-MCP-005(digest-mismatch) presents the drifted catalog's digest;COMP-MCP-006(wrong-subject) presents the other server's digest. Both counterexamples arecomponent_observation_mismatch, because the token has no member naming the server a catalog came from; separating them is the relationship methodTR-COMP-MCP-002leaves open, which this change does not propose.gen_corpus.pyrecomputes the digests from the pinned files on every run, andtests/test_tool_catalog_digest.pyrecomputes them again without the generator's code. Informative; endpoint identity and transport key binding stay open. No schema or wire-format change. -
A surrogate code point in an object key is refused as
rfc8785.CanonicalizationError(found by fuzzing #452).rfc8785refuses one in a value itself, but in an object key the UTF-16 key sort reaches it first and raisedUnicodeEncodeError. The three places that catchCanonicalizationErrorto raise their own error, the intent bridge's_jcsandprovenance.sign_recordandverify_record, let it through:sign_bridgeandverify_bridge(a key intool_call.arguments, say) raised it instead ofIntentBridgeError, and the two provenance functions instead ofProvenanceError. The canonicalizer now reports it as aCanonicalizationErrorsubclass, as it already does for nesting too deep to walk. No schema or wire-format change. -
verify_successor_artifactbinds the observer's name to the observer key (#451, following #432). It now takestrusted_observer, the identitytrusted_observer_jwkbelongs to, and refuses a successor whose signedobserveris anything else. Before this, any key the verifier accepted for observers could sign under another observer's name, andevaluate_successor_observation, which decides trust and independence on that name, could reachestablishedwith independence required on evidence from the executor alone.trusted_observeris a required keyword argument, so existing callers must pass it; the function is on main only and in no release. New tests cover the four refusals in the function that had none: a key other than the trusted one, akidmismatch, a signedobserverorobserved_atthat differs from the envelope, and a wrong profile. No schema or wire-format change. -
Proposal: the embedded
signaturevalue must be canonically encoded (#247). Incompatible tightening of the draft's acceptance rules. Section 3.2.2 already names the embeddedsignaturefield's encoding as base64url, no padding; it does not say whether a different spelling of the same bytes is the same record. A 64-byte signature (Ed25519 or ES256) spends 86 base64url characters on 512 bits, leaving 4 unused bits in the final character; RFC 4648 section 3.5 requires those bits to be zero. The added sentence requires the canonical spelling and requires a verifier to reject a record whose signature is not canonically encoded. The schemapatternis tightened so an 86-character value's final character must beA,Q,g, orw;models.py(through the shared pattern helper of #412), the packaged schema copy, and the v0.3 draft (regenerated from canonical) track it. Newly rejected: an 86-charactersignatureending in any of the other 60 base64url characters, which the previous pattern^[A-Za-z0-9_-]+$accepted; each valid signature had 15 such respellings. The reference library verified them through 0.10.0, because its decoder discarded the unused bits, and has refused them at decode time since 0.11.0 (#418); this change makes the schema, the models and the normative text give the same answer, so a consumer that validates a record without decoding its signature refuses it too. Values of any other length are unaffected; a 96-byte ES384 signature (128 characters) has no unused bits. Existing records: a record whosesignatureis non-canonical is rejected. The signature bytes are unchanged, so zeroing the final character's unused bits makes the record verify again without re-signing; the respelled record is nevertheless a different record for section 3.1.3, whoseparent_record_hashcovers the parent'ssignaturemember, so any child that names the old spelling must be re-issued.examples/signature-encoding/adds a canonical vector, reusing an already-published record and signature, and two non-canonical respellings of the same 64 bytes with different unused-bit patterns, plus a re-runnablescan_published_signatures.py. Scan, limited to the revisions examined: this repository'sexamples/at63f4d15,agentrust-io/trace-testsatda4369bandagentrust-io/trace-registryate26b85ahold 232 signatures, all canonical; the 2 non-canonical values the scan also reports are this change's own deliberately non-canonical vectors. The scan says nothing about records outside those revisions. Thecnf.jwk.xand trusted-key cases raise the same question for the specification text and are not part of this change; the library's decoder has treated them alike since #418. Requested by @imran-siddique on #247; proposed by @chernistry. -
Proposal: integer-typed members are decided by value, not by how they are written (#247). JSON has one number type, and RFC 8785 writes a number by its value, so
iatwritten1785000000,1785000000.0or1.785e9has one pre-image and one signature. The schemas already decidedintegerby value, as JSON Schema 2020-12 defines it.verify_recorddid not: its freshness check tested for a Pythonint,json.loadsreturns afloatfor the other two spellings, and a correctly signed record that had just passed the schema was rejected for having "no valid integer 'iat'". Section 3.2.2 gains a paragraph, "What counts as an integer", stating the rule and that the value is the IEEE 754 double RFC 8785 serializes. In the reference, the freshness check and the bridge'sauthorized_atandexpires_atchecks and theobserved_atchecks of the successor envelope and of the successor artifact accept a whole number however it is written and name the rule a value fails: not an integer value, or outside the safe-integer range._canonical_bytes, and through it signing, every digest and both adapters' transcript hashes, refuses a whole number past the range written as a float, as it already refused one written as an integer. The model reads1785000000.0as the integer it is. Two inputs that were accepted are now refused by the library: a numeric string such as"1785000000", which the model read as an integer and the schema never accepted, and a whole float past the range in an object the library signs or digests, such as a nanosecond timestamp that went through a double. Neither occurs in a Trust Record that passes the schema. The second changes a verdict where no schema stands in front of the canonicalizer, which Scope below spells out.examples/number-spelling/adds seven vectors, a JavaScript test that runs on Node.js with no dependencies, and a scan of published records: of 1,341 numbers in this repository's examples, spec, docs and schemas, inagentrust-io/trace-testsatda4369band inagentrust-io/trace-registryate26b85a, 549 are integer-typed and none is written with a fraction or an exponent; the one other number written that way,1e400in atrace-testsvector built to be refused, has no finite value and no canonical form before or after. No published verdict changes. Scope. The paragraph governs every object the range rule of section 3.2.2 covers. For the range, that includes the MCP Server Provenance Record (spec/server-provenance-v1.mdandspec/server-provenance-v2.md), which is signed under section 3.2, and the transcript behind section 3.1.4'stranscript_digest.provenance.sign_recordandprovenance.verify_recordcanonicalize through_canonical_bytes, so a provenance record of either format that carries1e21,9007199254740992.0,-1e21or-9007199254740992.0in a member its verifier does not type, which signed and verified through 0.11.0, is refused by both. The paragraph retypes no provenance member:provenance.pykeeps its type-based checks onissued_atandtool_count, and whether those are decided by value is a separate question, not settled here. Registry-anchor encoding is not touched. Python API compatibility (breaking for callers, not for records that pass the schema). (1)TrustRecordand the other models no longer read a numeric string as a number:"iat": "1785000000"raised nothing before and raises a validation error now (tests/test_integer_by_value.py, thegot a strandis a str, not a JSON numbercases). The schemas never accepted it, so no record that passedvalidateis affected. (2)_canonical_bytesrefuses a whole float past the safe-integer range, such as1e21or a nanosecond timestamp that went through a double; before, the float was written as it was, giving a pre-image no conformant verifier could re-derive. Each caller reports the refusal in its own error type:sign.sign_record,revocation.bundle_digestand both adapters' transcript hashes raiserfc8785.IntegerDomainError, aValueError;intent_bridge.digest_jcs,sign_bridgeandverify_bridgeraiseIntentBridgeError;provenance.sign_recordandprovenance.verify_recordraiseProvenanceError, for both provenance formats (test_every_digest_refuses_a_whole_number_past_the_range_written_as_a_float,test_each_signing_function_refuses_a_whole_float_past_the_range_in_its_own_error_type,test_a_provenance_record_refuses_a_whole_float_past_the_range_when_signing_and_verifying). (3)verify_record's freshness check and the bridge's time checks accept a whole float where they demanded a Pythonint; a call that was rejected withno valid integer 'iat'now verifies, and the rejection message for a non-integer names the value that failed. Nothing else in the public API changes signature or return type. Requested by @imran-siddique on #247; proposed by @chernistry. -
Propose explicit verifier profile declarations and verifier-result field requirements for obligations 2 and 3 of #116, carried by Imran Siddique following the discussion with @lywinged. Obligations 1 and 4 remain deferred; the v0.1 cutover is unchanged.
-
Breaking, proposed:
silentmode allows a policy-denied action (#143, following #28). Section 4.3 now says a deny MUST NOT block insilentmode and the audit chain MUST record every would-have-denied decision. v0.9.0 and v0.10.0 describedsilentas evaluated and enforced under the sameeat_profile, so a producer that followed that text and blocks on deny no longer conforms, and asilentrecord does not say which meaning it was written under. Classified breaking under GOVERNANCE.md, so adoption needs the 30-day comment period and Project Lead sign-off. Section 4.3 also orders the modes:advisoryis not weaker thansilent. Enforce remains the default; schema constraints and runtime behavior are unchanged. Classification raised by @lywinged. -
Specification precedence and reproducible conformance claims (#247). The v0.2 draft now states that normative text governs meaning and conformance; disagreements in the schema, reference model or explanatory documentation are defects in those supporting artifacts. Schema validation alone is not complete conformance, and a normative gap needs a specification decision. Conformance reports identify the specification revision and schema artifact, including its digest, plus the verifier or suite version and assessed level where used. This adds reporting requirements without changing Trust Record fields or verifier acceptance behavior. Based on the maintainer ruling and producer feedback from @chernistry and @lywinged in #247.
-
CitationCheckandcheck_citationsare exported from the package root (#190).verify_record()returns oneVerificationResultcarrying both aRevocationCheckand a mapping ofCitationCheck, and until now the revocation half was reachable asagentrust_trace.RevocationCheckwhile the citation half raisedAttributeErrorand had to be reached by module path. The two names now sit beside their revocation counterparts in__all__, which the description of the change that addedcitationssaid would follow it. Nothing else changes: the same objects stay reachable atagentrust_trace.citationas before, and no behaviour, signature or outcome is affected. Informative only: no schema, wire-format or normative change. -
Add an executable MCP lost-response/retry example with full paginated declaration snapshots, signed transcript commitments and focused integrity checks for #324. Keep the first execution outcome unknown after a reported successful retry. Uses an example-local format with a v0.2 record, not a v0.3 implementation.
[0.11.0] - 2026-09-25¶
-
Breaking:
TraceSandboxAdapterandTraceAGTAdapterrequireenforcement_mode; it no longer defaults to"enforce". An evidence constructor that fills in"enforce"signs a claim that a policy was evaluated when nothing observed an evaluation (#416), and the first fix, a"declared"default (#417), is ruled out by spec section 4.3:declaredMUST NOT be a default. Callers that omitted the argument now get aTypeErrorand must pass the mode their runtime actually ran under, or"declared"when no policy engine evaluated the policy. Runtime enforcement defaults are unchanged. Diagnosis and first fix by @solloek369-arch. -
Signatures accept one spelling.
sign._b64url_decodeaccepts only canonical unpadded base64url, so a Trust Record, JWKx, provenance, intent-bridge or revocation-bundle signature carrying padding or non-canonical trailing bits is refused instead of decoding to the same bytes.content_markingrefuses served record bytes with duplicate members. Deeply nestedcnf.jwk, tool calls and provenance records raise each module's documented error type instead ofRecursionError, andtool_catalog_hashraisesProvenanceErrorfor a catalog with no anchor form. ClusterFuzzLite now covers the record, bundle, provenance, bridge, content-marking and canonicalization paths (#418). -
Correct regulatory context in the published v0.1/v0.2 introductions and LIMITATIONS.md (#328, following #314). Separate Article 12 logging capability from TRACE tamper-evidence, identify Article 11 / Annex IV as documentation context, correct the applicability dates and remove unsupported TRACE-level compliance claims. Editorial only; reported by @ioanavalea with review context from @lywinged.
- Propose v0.3 MCP profile requirements for producer-defined attempts, signed full declaration snapshots and explicit retry outcomes (#324). Clarify existing session wording without changing v0.2 validation. Proposed by @madeinplutofabio with declaration-binding refinements from @Mayur021; maintainer-carried proposal, not adopted or implemented.
-
Propose MCP Server Provenance v2 with normalized behavioral-hint binding (#406). Preserve v1 hashes and producer defaults; add explicit v2 production and consumer format requirements. Reported by @altrudev.
-
Compare challenge nonces as UTF-8 bytes so non-ASCII mismatches raise the documented ValueError instead of TypeError (#381). Matching strings remain accepted without Unicode normalization. Invalid non-string expected_nonce settings raise a clear ValueError. Reported by @chernistry; follow-up review by @lywinged.
-
Report revocation bundles without an RFC 8785 form as
unverified_for_revocationwith causebundle_malformed, while continuing record verification (#382). The direct bundle-digest API still refuses such input. Reported by @chernistry. -
Regenerate the v0.3 draft after canonical reproducibility additions and make the schema inventory test use portable repository paths on Windows.
-
Keep untrusted input on each verifier's documented error type. Nesting a few hundred levels deep in
cnf.jwk, a tool call, a provenance record or served content-marking bytes raisedRecursionError; a 4,400 digit URL port raised Python's integer-stringValueError; a float or unsafe integer in an offered tool'sinput_schemaraisedUnanchorableValuefromcheck_tool_catalog. All were refusals already, none was an acceptance. Found by the new ClusterFuzzLite targets in.clusterfuzzlite/; regression tests intests/test_untrusted_input_stays_on_the_documented_error.py. -
Decode signatures and JWK
xas canonical unpadded base64url only. The decoder took the standard alphabet,=padding and stray characters, and ignored the unused bits of the last character, so one signature had several spellings that all verified, and a revocation bundle'sbundle_digest(which coverssig.value) gave one bundle several identities. The record-level rule forsignatureis the normative proposal in #401 and is not changed here.verify_assertionandbuild_assertionalso refuse served record bytes that name one member twice, sincejson.loadskeeps the last and another parser keeps the first. Tests intests/test_signature_spelling_is_canonical.py.
Added¶
-
observed-effectis a registeredreferencesrelation. The four registered values cover what was asked, who approved, what the agent did and what a check found; none points at what actually changed.observed-effectis a signed record of the state change an observer outside the agent saw over one interval: the state root before and after, the paths observed and any part of them unseen, the digest of the authority change ran under, and a subject digest equal to the after-state root.docs/references-registry.mddefines the referenced object as facts rather than a wire format and bounds what a relying party may establish to the object, as forcondition-appraisal: an interval in which the observer and the observed party agree is not attested evidence that the agent's report was true, and one in which they disagree is not a finding against the record.examples/observed-effect/carries five signed records against an effect store whose two statements are copied byte for byte from the published observed-effect conformance corpus: verified, altered after issue, a disagreeing interval that verifies exactly as the agreeing one does, unresolvable, and an observer whose key the relying party does not hold;tests/test_observed_effect_fixtures.pyrecomputes every finding from the committed bytes and re-runs the generator against them. Each verdict names the state of the referenced observation (verified, digest mismatch, unresolved, unverified) and never the effect it reports, with resolution, digest match and signature verification kept as separate findings. Proposed on #402. Informative: no requirement, schema shape or wire format changes; the schema'sreldescription gains the name. -
PIC/TRACE successor evidence is temporally separated from pre-execution authorization (#424, superseding the one-stage binding added in #340 for #338). The signed pre-execution authorization no longer carries a digest of evidence that can exist only after execution.
intent_bridge.sign_successor_artifact()creates a separate observer-authenticated post-execution artifact bindingauthorization_idto the exact successor-envelope digest, andverify_successor_artifact()checks the observer key, authorization link and envelope binding.schema/pic-trace-successor-v1.jsondefines the detached successor artifact whileschema/pic-trace-bridge-v1.jsonremains the pre-execution authorization schema. This preserves the invariant that post-execution evidence cannot be a signed input to the artifact relied on as pre-execution authorization. Reported and implemented by @altrudev; #363 requires corresponding corpus regeneration before merge. -
provenance.verify_record()takes an explicitnow(#411). A non-negative integer Unix timestamp pins the freshness decision so it can be replayed. Booleans and other types are refused as configuration errors. Omitting it keeps the host clock. -
condition-appraisalis a registeredreferencesrelation, and the registry says what it is. #191, #209, #220 and later #321 each asked for a way to bind a governance fact held outside the record, and #226 read them as one question about therelregistry of section 3.1.2. This settles the part ruled on there: the registry is informative and open, the verifier rules of section 3.1.2 stay normative and section 3.1.2 now says so below them, anddocs/references-registry.mdholds each registered value's referenced object, what a relying party may establish from a resolved one, and the steps by which a name is added. The fourth value,condition-appraisal, is an independent check's finding on whether a stated condition is established by a stated subject: a test run, a schema validation, a contract check. Its referenced object binds the condition and the subject by digest, names its issuer and key, carries the outcome in the issuer's own closed vocabulary, and signs the canonical bytes of the rest; the reference'sdigestis over the object as retained. What a relying party may establish is bounded to the object: that the cited bytes are the cited bytes and, where it holds the issuer's key, that the issuer signed them; neither reaches the record, apassis not attested evidence that the condition held, and afailis not a finding against the record.examples/condition-appraisal/carries five signed records from a seeded generator against an appraisal store: verified, altered after issue, a fail that verifies exactly as the pass does, unresolvable, and an issuer whose key the relying party does not hold;tests/test_condition_appraisal_fixtures.pyrecomputes every verdict from the committed bytes and re-runs the generator against them. Re-running a cited check is a separate proposal and no part of this change. Proposed by @nutstrut on #226; ruled on by @imran-siddique. Informative: no requirement, schema shape or wire format changes; the schema'sreldescription gains the name. -
The schema, the reference model and the schema reference carry the reproducibility claim (section 3.1.4).
schema/trace-claim.jsonand its packaged copy gain the optional top-levelreproducibilityobject:function,code_identity, an optionalcode_resolver,input_closureas{id, digest, resolver}entries with every member required, andtranscript_digest.appraisalgainsmethod, the closed setre-execution, andre_execution:outcomeofreproduced,divergedornot-attempted,observed_digest,reason, and an optionalverifier_code_identity. The three presence rules the section states are schema conditionals and model validators alike:re_executionis present exactly whenmethodisre-execution,observed_digestis required ondiverged,reasonis required onnot-attempted.models.pyaddsReproducibility,ClosureEntryandReExecution;docs/schema.mddocuments every member in its section.examples/reproducibility-claim/holds 21 signed vectors from a seeded key: five accepted records, a claim with no result and one result per outcome, and sixteen rejected ones, two per rule, with the transcript and closure digests recomputable from the JSON they are taken over. Tracks #364; closes #366. -
Vendor annex: Bernstein (software-only).
docs/platforms/bernstein.mdmaps a deterministic-scheduler producer with no hardware root onto the Trust Record: the journal each claim is derived from, the delegation link and aggregate composition under the section 3.1.3 preimage, where the two recomputable commitments of section 3.1.4 sit on such a record today, the mapping of the reproducibility claim onto the producer's re-derivation check, what a verifier can recompute without the producer, and the Level 0 ceiling a committed software-only corpus has by construction. Seven emitter-minted vectors, the supplementary-plane pair among them, are pinned by commit. Informative, per GOVERNANCE; the annex track is #231. -
Reproducibility claim: deterministic re-execution as evidence (section 3.1.4). A record may now carry
reproducibility, the claim that re-executing a named deterministic function of the run, the producer's coordination logic rather than the workload's side effects, over a pinned input closure yields a transcript whose RFC 8785 digest equals the claimed value. The claim has three explicit parts: the code identity (code_identity, with thefunctionit exposes and an optionalcode_resolverwhere the digest alone does not locate the artifact),input_closureas{id, digest, resolver}entries covering everything the function reads, model calls included, andtranscript_digest. A closure that omits anything which can change the transcript makes the claim malformed rather than weak. A verifier must hold the implementation, obtainable as a public artifact without the producer; the closure blobs, each matching the digest the producer signed, which is an integrity bar and not a provenance one, since a run's closure is run-private; and the claim itself from the signed record. If re-execution needs state only the producer's environment can supply, it is not reproduction, and a read beyond the closure fixes the outcome atnot-attemptedwith the read named, whatever the re-run would otherwise have produced. The re-execution result is an appraisal underappraisal.method: "re-execution"with a tri-stateoutcome:reproduced,diverged(recording the verifier's observed digest) ornot-attempted(carrying the reason), andnot-attemptedMUST NOT be reported as either of the other two.appraisal.statusstays the closed EAR set of #190, and the section says whynot-attemptedis notstatus: none. Non-breaking: a new optional member, an optionalappraisal.method, andappraisal.re_executionpresent exactly when the method isre-execution; no change to required fields or wire format. The schema, the reference model anddocs/schema.mdtrack the text in a follow-up, and vectors from a producer that makes the claim follow with them. Proposed and written by @chernistry in #249; carried by @imran-siddique as sponsoring maintainer. The vendor annex in #231 is sequenced after it. -
A verifier can declare the set of profile URIs it supports, and the verification statement says what it ran under (#116). Compliance evidence outlives the verifier build that reads it, and
docs/verification.mdsays nothing about what a verifier owes when it meets a record written under semantics it does not implement. Two of the four obligations proposed on #116 are verifier behaviour and need no normative text, and they are what this adds.DEFAULT_ACCEPTED_PROFILESmakes addressable the rulespec/trace-v0.2.mdalready states under "Changes from v0.1": accepttag:agentrust-io.com,2026:trace-v0.2, reject the superseded identifier, never both.verify_record()takes anaccepted_profilesset, refuses a record whoseeat_profileis outside it, and refuses the configuration itself where the declared set names the superseded identifier or a profile this build carries no schema for.validate.profiles_with_schema()reads the packaged schemas for theireat_profileconst and is that ceiling, so support cannot be declared for a shape that cannot be checked.VerificationResultgainsprofileandaccepted_profiles: a declaration living only in the verifier's documentation cannot be recovered from a result read years later, which is the condition the issue opens with. Eight conformance vectors inexamples/verifier-compatibility/, and a separation guard that runs eight wrong implementations against the whole set and records what each is caught by, so the figure fails loudly rather than expiring quietly the next time a second gate lands over the same inputs. Measured: the set separates 4 of its 8 vectors against a verifier implementing none of the obligations, with one recorded shortfall that no vector written against this build can close. Each vector'sfailureis informative: a verifier that applies every rule and reports one generic refusal conforms, and two controls hold that open. This library's own refusal messages are pinned separately. The normativedocs/verification.mdtext for these obligations is not part of this change and is not proposed here. Obligations 1 and 4 of the issue stay deferred as unobservable against the current schema. -
A guard that every schema field and every fixed value is documented in its place, and the gaps it found on arrival.
docs/schema.mdlistedcnfas a one-row table with no member table under it, sokty,crv,x,y,n,eandkidappeared only inside a fenced example, and itspolicy.enforcement_moderow read "enforceorsilent(advisory)", naming neitheradvisoryas a value nordeclaredat all. Both are corrected: acnf.jwkmember table with what each key type requires, and all four enforcement modes on the row, with section 4.3's meaning forenforce,silentanddeclaredand the schema's own one-line description foradvisory, which the spec does not define.tests/test_schema_doc_coverage.pyasserts, directly against the hand-maintained page, that every property inschema/trace-claim.jsonhas a table row whose first cell is its name in the section for its containing object, and that everyenumandconstvalue is an inline-code span in that row's description cell. Position, not the bare name: a name that survives in a sentence, a heading or a same-named field elsewhere on the page does not stand in for the row. Nothing is generated from the schema: generated text cannot drift from its source, so a test over it would exercise the generator and not the agreement between two surfaces. The guard is shown to fail in both directions, a field, enum value orconstadded to the schema without a row and a row deleted or a value stripped from its row while the name survives elsewhere on the page, so a green run is a finding rather than a coincidence of the page. Narrow by design: presence is mechanically decidable and description agreement is not, and the precedence rule is separate normative text. Scope ruled on #247. Informative only: no schema, wire-format or normative change. -
The PIC/TRACE bridge cites the specification it consumes.
docs/integration/pic-trace-bridge-v1.mdgains a References section naming the PIC Standard repository, PIC Canonical JSON v1 (PIC-CJSON/1.0), whose section 8 gives the byte rules for theintent_digestandargs_digestvalues 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 ofaction.args, section 8.3 over the UTF-8 bytes of theintentstring with no JSON wrapping or canonicalization.schema/pic-trace-bridge-v1.jsonconstrains both fields through its$defs/digest,^sha256:[0-9a-f]{64}$, so an adapter serializes the PIC verifier's output assha256:<pic_hex>at the boundary. A bare hexadecimal value is refused withauthorization.pic.intent_digest must be a sha256 digeston the artifact, orintent_digest must be a sha256 digeston 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.pypins 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-outcomereferences. A cross-walk,docs/crosswalks/chap-review-decisions.md, and four fixtures inexamples/chap-approval-outcome/show a Trust Record pointing at a CHAPdecide.approveby the RFC 8785 SHA-256 of its envelope. The CHAP envelopes were produced bychap-coordinator0.2.13 through the generator in the CHAP integration inagentrust-io/integrations, andtests/test_chap_approval_outcome_fixtures.pyrecomputes every reference digest and replays the CHAP hash chain withrfc8785alone, 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 withrfc8785also confirmed that the two canonicalizers produce identical bytes on every committed envelope. Informative only: no schema, wire-format or normative change. -
verify_record()reports whether the objects a record cites can be resolved (#190). A record citesappraisal.policy_ref,runtime.rim_uriandmodel.aibom_urias URIs the schema checks only for shape, and, until this change, nothing undersrc/read them.verify_record()now takescitation_resolver, a caller-supplied function from URI to bytes, and returns aVerificationResultwhose newcitationsfield carries one row per surface:resolvedwith the SHA-256 over exactly the returned bytes and their count,unresolvablewith the cause and the exception's class name when the resolver raised or the returned value's type name when it returned non-bytes, ornot_attemptedwhen no resolver was supplied, the field is absent, or the surface is deferred.transparencyis deferred to the coordination in agentrust-io/trace-tests#92 and section 7 open question 3, and every vector shows it so. The check records resolvability and asserts nothing about binding, which #280 holds; it never readsreferences[], which keeps it inside section 3.1.2 rule 3 by construction; and it takes its resolver from the caller only, never from the record, per section 3.1.2. No outcome changes the revocation check, the thumbprint, or whether verification raises; a resolver that is neither callable norNoneis refused at entry.examples/citation-resolution/carries sixteen generated vectors with the cited bytes in hand, andtests/test_citation_resolution.pyholds eleven invariants over them, including that every revocation vectorverify_record()accepted before still verifies with the same revocation result. The outcome names are not accepted normative text (#279). Informative only: no schema, wire-format or normative change.
Fixed¶
-
Schema regex semantics now match between
validateand the Pydantic models (#412). Patterns are shared from_patterns.py, somodels,content_markingand the sandbox adapter accept and refuse the same strings the JSON Schema does.tests/test_regex_surface_parity.pyholds them together. -
Schema
patternchecks use ECMA-262 boundaries (#388). Pythonrelets$match before a trailing newline and ECMA-262 does not, so asubjector URI ending in,, U+2028 or U+2029 validated before this change and is now refused. -
Malformed IPv4 literals inside URIs are refused (#387). The
uriformat check now rejects an IPv4 address with a leading-zero octet embedded in a bracketed IPv6 literal, such ashttps://[::1.2.3.04]/. -
A present but malformed
signatureis reported as malformed, not missing (#390).sign.verify_record()treated an empty or falsy value as absent. -
The AGT and sandbox adapters validate
iatbefore it reaches the record (#343). A boolean, negative or non-integeriatwas written into the claims and only caught at signing or verification. -
content_markingrefuses a non-string or emptyeat_profile(#410), a boolean assertionversion(#282) and a malformedrecord.url(#284). -
provenancerefuses non-string identity locators (#287) and a tool whose two schema aliases disagree (#300, closes #297). Aliases are compared by canonical bytes. -
Revocation bundle freshness inputs are validated (#288). Boolean and non-integer policy values raise a configuration error instead of being read as numbers.
-
TraceSandboxAdaptervalidates its required textual fields (#290). -
intent_bridgekeeps a canonicalization failure's error class (#318) instead of reporting it as an authorization mismatch. -
The README named an API the package never had (#309). It told readers to call
TrustRecord.sign(),record.anchor()andrecord.verify(). It now showssign_recordandverify_record, andtests/test_readme_api_claims.pyfails if it drifts. The README is the PyPI long description, so this release is the first to show the correct calls on the project page. -
sign.verify_record()now validatesmax_age_secondsandmax_future_skew_secondsthe same wayprovenance.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 onlymax_future_skew_seconds < 0, andmax_age_secondswas compared against unvalidated. Passingmax_age_seconds=-1(a value a caller might use meaning "no bound," sinceNoneis the documented way to disable the check) rejected every record, including one issued the same second, asrecord is stale, naming the record rather than the misconfigured argument._check_seconds()is now defined once insign.pyand shared:sign.verify_record()calls it directly, andprovenance.verify_record()imports it, passing its ownProvenanceErrorvia a newexcparameter so each keeps its existing public error type. -
The v0.3 draft schema accepted the private half of a confirmation key.
schema/trace-claim-v0.3-draft.jsonstates that outside the profile identifier andruntime.evidenceit is byte-identical toschema/trace-claim.json, but it was forked from an older revision and never received thecnf.jwkprivate-key refusal (#296) or the RSA branch (#311), so a v0.3 record carryingdvalidated against it. The shipped verifier refuses any profile other than v0.2 before schema checks, so nothing accepted such a record in practice. The draft is now produced byscripts/gen_v03_draft_schema.pyfrom the canonical schema plus its own declared members, andtests/test_v03_draft_schema.pyfails when the committed file drifts. Reported by @saintmalik in #368. -
Server provenance records now enforce the existing attestation shape in both
build_record()andverify_record(). After #325, non-object values were already refused. The remaining gaps included an empty object on a non-TEE record, an unknown platform, andplatform: "software-only"on atee-attestedrecord. Non-TEE records now requireattestation is None;tee-attestedrecords validate againstRuntimeInfoand additionally rejectsoftware-only. This implementsspec/server-provenance-v1.mdsection 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.jsondisallows additional runtime properties and requires strings forrim_uri,nonce, andfirmware_versionwhen present. The existingRuntimeInfomodel 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()stampedappraisal.statusasaffirmingon every record it produced, with no way to change it.appraisal.statusis a verifier-owned field: section 3.3.1 says a verifier MUST record the depth it actually checked and MUST set the status tocontraindicatedwhen evidence fails, andmodels.Appraisalcarries 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.TraceSandboxAdapteralready had this right, withappraisal_statusdefaulting to"none"and tests pinning both the default and the override;TraceAGTAdapternow matches it, and the defaulting-to-affirmingline was the only remaining hardcoded status in the package. This changes the content of records this adapter emits: an unappraised record now saysnonewhere it used to sayaffirming, which is the correct direction and is what a caller who really did appraise must now declare withappraisal_status="affirming". Two documents described the old behaviour and are corrected with it:docs/integration/agt.mdsaid the adapter "populates anaffirmingappraisal without independently evaluating the session", anddocs/tutorials/agt-adapter.mdcarried a post-hocrecord["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 foreat_profilecompare two reads, and two absences compare equal. A peer-produced assertion omittingdata.subject, paired with a hash-matching record that also omittedsubject, agreed by mutual absence andverify_assertionreturned the parsed record as a successful binding.spec/content-marking-v1.mdsection 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 isContentMarkingError, because a malformed assertion is the caller's own input andRecordMismatchwould point the reader at the server serving the URL, the same reasoningtest_an_int_no_longer_reports_a_record_mismatchalready pins. A record missing one isRecordMismatch, 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, fromRecordMismatchto itsContentMarkingErrorparent, 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 suppliedissued_atbefore 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:Truearrived as1,Falseas0,1.9as1,"123"as123, and-0.5as0, 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]leftbuild_recordas aTypeErrorandissued_at="abc"as aValueError, where every other public function in the module is held toProvenanceError. An explicitly supplied value now reaches_check_structure()untouched and only an omitted one is stamped withint(time.time()), which putsisinstancein 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.pylistedprovenance.build_recordunderNO_ARGUMENT_TO_SWEEPbecause it has no positional argument, which is why the junk matrix never reached it, so the function is now wired into that sweep withissued_atas 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 usedbefore.get("tool_call") != tool_call. Python holdsTrue == 1andFalse == 0, nested objects included, so a transcript whosebefore.tool_callsubstituted 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 signedtool_call_digestwas never affected: it is checked against the actualtool_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 nowcompare_digestoverdigest_jcs, reusing the digest already computed for thetool_call_digestcheck. The isinstance guard stays in front of it, and atranscript.before.tool_callthat JCS has no form for raisesAuthorizationMismatchrather thanIntentBridgeError, 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-allowvalue as a policy denial. The bridge schema permits only the literal strings"allow"and"deny", while the runtime previously useddecision != "allow"as its branch, so re-signed values such astrue,1,null,"", and"reject"all surfaced asAuthorizationDenied. 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"reachesAuthorizationDenied, while malformed values raiseIntentBridgeError. 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.SandboxAttestationvalidates shape only --platformagainst the enum onRuntimeInfo,measurementagainst thesha256:/sha384:digest pattern -- and has never checked a quote, a signature, or a nonce._runtime()then copiesplatformandmeasurementfrom the attestation into the record unchanged. Nothing stops the same process that constructs aSandboxAttestationfrom inventing both values, e.g.SandboxAttestation(platform="amd-sev-snp", measurement="sha256:" + "0" * 64), andbuild_trust_record()accepts it,TrustRecord.model_validate()accepts the result, andsign_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 saystpm2therefore 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 changingruntime.platform, copying a nonzero digest, or settingappraisal.status="affirming"does not establish that evidence" and "agentrust_trace.verify_recorddoes not itself appraise hardware quotes") -- butsandbox.py's docstring anddocs/integration/sandbox-runtime.mdasserted 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 aSandboxAttestation, is the caller's responsibility -- consistent with how every other Level 1 producer in this codebase is documented. The same unconditional phrasing also remained inbuild_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:SandboxAttestationandTraceSandboxAdapteraccept 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.jwkaccepted an RSA confirmation key carrying no key material. The schema says "Keys must carry actual key material" and enforced it forOKPandEConly, so acnf.jwkof{"kty": "RSA"}with nonand noevalidated, and the record then failed inside the verifier, wheresign.jwk_thumbprintreports 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.RSAnow requiresnande, which states what the description already claimed and refuses nothing that verifies. Aktyenum 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 narrowingktyhere would add a constraint the specification does not make.models.JWK, which is exported and is what a Python caller reaches, carried the sameOKP/EC-only table and is corrected with it;nandeare 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()andintent_bridge.verify_bridge()raised exceptions their own modules do not document when the untrustedsignaturefield was malformed. Both functions decode a caller-supplied signature before its shape has been established.provenance.verify_record()never checked thatrecord["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 bareTypeError(object of type 'int' has no len()for an int; aTypeErroron+for a dict or list) rather than theProvenanceErrorthis 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 calledsign._b64url_decode()unwrapped: that function raises the bareValueErrorsigndocuments for itself, not anIntentBridgeError, so a correctly-typed but undecodable string (too short to pad to a whole byte, or carrying a non-ASCII character) escaped as thatValueError. Same shape as therfc8785.CanonicalizationErrorleak 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 reusesign._b64url_decode(), alreadysign.verify_record()'s own guard for this exact field, and wrap itsValueErrorin the calling module's documented type. Inprovenance.verify_record()the guard is placed where the crash it replaces was, after thecnf.jwkchecks, 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.jwkno longer accepts private key material (GHSA-vc4p-h84j-7qxj). RFC 8747 definescnfas a confirmation key: the public half, carried so a verifier can bind the record to the key that signed it.models.TrustRecordalready refusedd,p,q,dp,dq,qiandkvia_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'sjwkblock constrained only thekty/crv/x/yshapes, so everything else fell throughadditionalProperties. A Trust Record carrying its own private key validated andsign.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 takesrevocation_bundle,trusted_bundle_keys,max_bundle_age_secondsandnow, and returns aVerificationResultwhoserevocationfield carries one of section 3.2.3's three states as a value:verified,unverified_for_revocation, orno_check_performed, with the cause and the evidence a second verifier needs. Previously the function returnedNoneand a caller could not tell a verified key from one nobody checked, which is #246. Two bounds govern bundle age, the issuer'svalid_untiland the caller's maximum measured fromissued_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. Noappraisal.statusvalue is named; where an unresolvable check is recorded in the record stays open on #190. Callers that ignored the oldNonereturn are unaffected; a caller assertingis Noneon 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
GapDisclosureis 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 shortcutsort_keys=Truetakes in several JSON libraries, since the two agree everywhere except where an object key holds a supplementary-plane character.24-parent-key-supplementary-plane.jsonis that vector: the root'scnf.jwkcarries an additional member keyed outside the Basic Multilingual Plane (permitted byschema/trace-claim.json'sadditionalPropertiesoncnf.jwk, covered by the chain digest per section 3.1.3), and the leaf'sparent_record_hashis reachable only under the correct ordering. A code-point canonicalizer computes a different digest for the same root and reportsparent_not_foundon a chain that is otherwise vector 01's.docs/rfcs/a2a-delegation-profile.mdand 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.mdnow states thatruntime.measurementundersoftware-onlyis a documented software commitment, not a hardware measurement forced to all-zero.runtime.measurementis required on every record. Undersoftware-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 indocs/integration/sandbox-runtime.mdanddocs/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 forsoftware-onlyin general. The earlier draft of this fix would have declared both released reference adapters non-conformant, since they emit non-zero measurements undersoftware-onlyby 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.mddoesn'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 fromdocs/schema.md. Both schema copies carry it. Raised by @chernistry on #232 and on #234. -
schema/trace-claim.json'sruntime.platformdescription now states the same rule as spec 3.1.1. The schema description saidsoftware-onlymarks "development-mode records with no hardware backing", which is narrower than what 3.1.1 requires: a record whoseorigin.kindis notselfMUST also carryruntime.platform: "software-only", a class that is not development-mode records.docs/schema.mdalready 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
subjectpattern was a prefix test where the model requires a full identity, so a record could be schema-conformant and refused bymodel_validate.^(spiffe://|did:)constrains only a leadingspiffe://ordid:;models.pyrequires^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$.spiffe://bernstein.runnames 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 fixesmethod-charto%x61-7A / DIGIT, sodid:X:abcis 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 intests/test_the_schema_and_the_models_agree.pycould 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 at84df8ef("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.mdstill described the prefix reading and is corrected here too. -
The exported
SCHEMAwas the live object the validator reads._schema()islru_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: loweringSCHEMA["properties"]["iat"]["minimum"]made a record dated 1970 valid tovalidate_json(), toiter_errors(), and to the structural gate insidesign.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 nestedpropertiesdicts shared and the same edit would still land. -
All eight
"format": "uri"declarations in the schema were inert, and"not a uri"validated.jsonschematreatsformatas an annotation rather than an assertion unless a checker for that format is installed, andFormatChecker().checkersdoes not carryuriwithout an optional dependency the project did not declare. The wiring invalidate.pywas correct and the behaviour was a no-op, which is the shape that survives review.rfc3986-validatoris now a dependency, chosen overjsonschema[format]because that pullsrfc3987, 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 explicitnull, 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 tripsign_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:JWKsetsextra="allow"and the schema permits a null among those members, so a null insidecnf.jwkis 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 matchtrue, soschema/trace-claim.jsonrejects{"slsa_level": true}andmodels.BuildProvenanceaccepted it and coerced it to1, making the record a claim of SLSA build level 1 assembled out of a boolean.tool_transcript.call_countdid the same, andappraisal.timestampreadtrueas 1 January 1970.iatandorigin.ingested_atdid 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()andload_key()read.public_key()and.encode()off their argument before establishing its type;key_to_jwknow 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 tojson.dumps, so a type JSON cannot serialize came back as a message about a serializer.intent_bridgeletrfc8785errors out as themselves: those areValueErrorsubclasses, which satisfiessign's contract but not this module's, since aCanonicalizationErroris not anIntentBridgeError.verify_bridge()did not leak but misattributed, canonicalizing inside thetrythat 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.mdwrites an unencrypted private key into the reader's working tree and.gitignoredid not cover it. The block writestrace-key.pemunder 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 onegit add -Aaway from committing their signing key. The three paths the documentation writes are ignored now, along with*.pem,*.key,*.p8and*.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.
RevocationStoreisContainer[str] | Callable[[str], bool], and the callable's return value was read by truthiness, soNone,"",0and[]all read as "not revoked" and let the key through, while the string"no"read as revoked.Noneis 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 existingexceptclause 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 answeringNonesupplied 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, becauseinyields a real bool whatever__contains__returns. -
provenance.verify_record(),provenance.check_tool_catalog()andcontent_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 elevenAttributeErrors apiece; the twelfth value is{}, which is an object and so reached the refusal each function documents, which is not theProvenanceErrorverify_recorddocuments.content_marking.verify_assertion()was worse than a crash rather than merely undocumented: it did not check thatrecord_byteswere bytes, andbytes(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()andverify_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 raisedAttributeError, which is not theValueErrorverify_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 reachesjwk_thumbprintfrom a peer, a key document or a record's owncnf, and the record handed toverify_recordis by definition not yet known to be an object. Both now raiseValueErrornaming 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. -
provenance.tool_catalog_hash()now refuses a malformedtoolslist instead of crashing. Reached primarily throughcheck_tool_catalog(record, tools), the function the module's own docstring calls "the step that catches a live attack," becausetoolsthere is what the MCP server actually returned, i.e. the untrusted party this check exists to catch. The function iteratedtoolsand called.get(...)on each entry with no check thattoolswas a list or that its entries were objects, so a malformed response (an entry that is a string,None, a number, ortoolsitself not being a list) raised an unhandledAttributeErrororTypeErrorinstead of the documentedProvenanceError. A server that is misbehaving maliciously or just buggily and is exactly the sourcetoolshas no reason to trust its shape. Fixed with an explicitisinstancecheck ontoolsand on each of its entries, naming the offending index. -
content_marking.build_assertion()andcontent_marking.verify_assertion()now refuse record bytes that aren't a JSON object, instead of crashing. Both functions called.get(...)on the result ofjson.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 andrecord_bytesis exactly the kind of externally-sourced input this is likely to happen to:build_assertion()takes whatever bytes a caller hands it, andverify_assertion()'s docstring is explicit that itsrecord_bytesare "the record bytes actually retrieved from its URL," i.e. a network response the caller does not control. Either function raised an unhandledAttributeErrorinstead of the documentedContentMarkingError.verify_assertion()had a second, related gap: its second parse ofrecord_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 raisedjson.JSONDecodeErrorinstead ofContentMarkingErrortoo. Both functions now checkisinstance(record, dict)after parsing, andverify_assertion()'s second parse now catchesValueErrorthe same way its hash-computation path already implicitly required valid bytes to reach. -
TraceAGTAdapternow hashestool_transcriptthe same wayTraceSandboxAdapterdoes, and stopped rejecting ordinary floats. The two adapters compute the same schema field,tool_transcript.hash, whichdocs/schema.mdanddocs/integration/agt.mdboth describe as "the canonical JSON of the ... AuditEntry list", canonical JSON meaning RFC 8785 (JCS) everywhere else in this codebase, and whatTraceSandboxAdapter.transcript_hashalready uses.TraceAGTAdapter._transcript_hashinstead went throughanchor_bytes, the registry-anchor sorted-key format thatspec/registry-anchor-v1.md§0 scopes explicitly to thetransparencyanchor 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 throughanchor_bytesrather thanrfc8785, which stopped the collision but kept the two adapters on different canonicalizations and introduced a regressionanchor_bytescarries 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_recordraisedUnanchorableValueinstead of returning one.TraceAGTAdapter._transcript_hashnow usesrfc8785.dumps, matching its sibling exactly: same bytes hashed, same digest on identical input, samerfc8785.IntegerDomainErroron an out-of-range integer, and no error at all on a float. -
provenance.verify_record()andprovenance.check_tool_catalog()now fail closed on a malformedidentityortool_catalog, instead of crashing. Both functions' documented contract is to raiseProvenanceError(or its subclassToolCatalogMismatch) on rejection, so a caller can safely wrap either inexcept ProvenanceError. Four spots read a nested block withrecord.get(field) or {}and or, incheck_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 theor {}, and the next.get()call on it raised an unhandledAttributeErrorinstead.identityitself,identity.artifact,identity.endpoint, andtool_catalog(read independently by both functions, sincecheck_tool_catalog()is callable on its own withoutverify_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 malformedidentityortool_catalogis 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()checksartifact/endpointthe same way before touching them.
Added¶
- Section 3.1.2 now states what a
referencesentry cannot carry, and separatesresolverfrom 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 newreldoes 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
referencesblock 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.jsonsetsadditionalProperties: falseat the top level andTrustRecordisextra="forbid", so a record carrying the field the specification permits was rejected by both artifacts a producer validates against.referencesis an array of entries with requiredrel,idandresolver, and optionalretention(ISO 8601 duration) anddigest.relis a registry rather than a closed set:authorized-intent,approval-outcomeandbehavior-traceare the values section 3.1.2 registers today, and the schema holdsrelto 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 theTraceRevocation/1.0claim type: a record from a revoked key is valid if and only if its SCITT inclusion entry ID is at or belowlast_valid_entry_idon the log the statement names. The intuitive time-based rule cannot work, because a compromised record-signing key also signs theiatit 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_provenancenow declares verification depth. A new optionalprovenance_depth(surface,builder,transitive) says how far down the supply chain the issuer claims to have walked, and a new optionalappraisal.provenance_depth_verifiedrecords 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 omittingprovenance_depthis read assurface, 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
srcto its import path, and a regression test asserts thatagentrust_traceresolves 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 ofcnf.jwkand 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 asValueErrorwith the failing field path.
Fixed¶
- Future-dated records no longer create an unbounded freshness window.
verify_record()now rejects aniatlater than the verifier's clock plusmax_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_countandappraisal.timestampwere typedintegerwith no upper bound, as wereissued_atandvalid_untilin the revocation bundle,revoked_atin a revocation statement, andauthorized_atandexpires_atin the PIC/TRACE bridge, whosesign_bridgeandverify_bridgecall the same_canonical_bytesas everything else. A record carrying a value above 2^53 was therefore schema-valid and had no canonical form to sign:9007199254740992and9007199254740993are 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:canonicalize4.0.0 (npm) applies the algorithm as written and emits identical bytes for the pair, whilerfc87850.1.4 (PyPI, pinned here) refuses both.cnf.jwkneeded the same treatment for a different reason: RFC 7517 lets a JWK carry members this schema does not name, the signature covers them, andadditionalPropertieswas 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 carriesmaximum: 9007199254740991, undeclaredcnf.jwkmembers are held recursively to a new$defs/canonicalizableValuethat excludesnumberand bounds integers, and section 3.2.2 states the rule.appraisal.timestamphad 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'sjson.dumpswrites the exact digits and a JavaScript implementation goes throughJSON.stringifyand emits one value for both. Measured ontool_catalog_hash, which runs those rules over a tool'sinput_schemawhere amaximumis 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 newagentrust_trace.sign.anchor_bytesperforms the sorted-key serialization and refuses a value §1 puts outside the profile with a namedUnanchorableValue. Both implementations of that format,tool_catalog_hashand 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.modelsmirrors every schema constraint (ge=0forcall_count,ge=0, le=3forslsa_level), soiat,origin.ingested_at,tool_transcript.call_countandappraisal.timestampcarry 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_jcstakes the digest of a caller-supplied declaration or tool-call object, andSandboxAdapter.transcript_hashof a decision log, neither of which any schema validates. Measured: two declarations differing only in an integer above the range produce one digest undercanonicalize4.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 thatdigest_jcsfails closed here. The superseded v0.1 schema is unchanged and is the one file left out:verify_recordrejects 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-trace0.x as supported; and marks the superseded v0.1 profile unsupported.
[0.9.0] - 2026-08-09¶
Added¶
spec/content-marking-v1.mdandagentrust_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:enforceacted on the result,advisorydid not,silentacted with the log lines suppressed.declaredasserts 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, implementingspec/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()loadssrc/agentrust_trace/schema/trace-v0.2.json, while the spec, README and CONTRIBUTING all point a reader atschema/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 andmodels.pyboth accept adid:subject, the packaged copy still required^spiffe://, soagentrust_trace.validate_json()rejected a subject form the specification permits. Also resynced: theslsa_leveldescription, and the root file's$id, which still saidtrace-v0.1.jsonon a schema whoseeat_profileconst 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### Fixedblocks 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 meansself. Same shape of change asdelegationin 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 intrace-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.mddescribedtransparencyas 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.mdanddocs/verification.mdstill namedregistry.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 toregistry.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_urifrom 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 describedtransparencyas 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
delegationlink 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 frompyproject.tomlat #36 and was never corrected, so v0.3.0, v0.4.0, v0.5.0 and v0.5.1 each shipped a wheel reporting0.2.0at runtime. Anyone pinning or logging onagentrust_trace.__version__got the wrong answer, and nothing failed. It now derives from installed package metadata, which makes the two unable to disagree, andtests/test_version.pypins the source tree againstpyproject.tomland 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", butverify_record()never readeat_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 thanTRACE_PROFILE_V0_2(newly exported) raisesValueError, 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.mdsection 2) that the reference implementation did not carry out.docs/verification.mdstep 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", butverify_record()checked only signature and freshness, so a record signed by a revoked or compromised key kept verifying. The newrevocationparameter 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¶
transparencyis optional below Level 2. The model required a non-empty URI on every record, which was stricter than bothschema/trace-claim.json(required, nominLength) and the conformance suite, which runsTR-ANCat 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.Nonenow 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(wastag:agentrust.io,2026:trace-v0.1).agentrust.iowas 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.ioURLs moved toagentrust-io.com: the registry and verifier hosts in the AGT adapter and the schema$id.
[0.4.0]¶
Added¶
-
azure-cvm-sev-snpplatform: Azure confidential VMs run AMD SEV-SNP behind a Hyper-V paravisor: the SNP report is read from the vTPM (the guest does not controlREPORT_DATA), so the runtime binding rides a vTPM AK-signed quote rather than the SNPreport_data. Given its ownruntime.platformvalue (distinct fromamd-sev-snp) so a consumer keying onruntime.platformknows the root of trust is vTPM-rooted, not direct-silicon. Added to theRuntimeInfomodel 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, carryingparent_record_hash(digest of the parent hop's Trust Record) andcredential_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 withoutdelegationremain 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_recordnow requires an explicit trusted key. Self-verification from the embeddedcnf.jwkis no longer the default; useallow_embedded_key=Trueto opt in.- Verification enforces freshness (
iat/max_age_seconds, default 24h) and an optionalexpected_nonce. JWKkty/crvare validated.
Breaking¶
- BREAKING: Canonicalization is now RFC 8785 (JCS). Trust records are NOT cross-verifiable with 0.2.0 (the prior
json.dumpscanonicalization 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 Recordexamples/intel-tdx.json: Intel TDX Trust Recordexamples/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
subjectfield to accept DID URIs (anydid:method) in addition to SPIFFE SVIDs. Previously^spiffe://only; now^(spiffe://|did:). Additive, backward-compatible. DID-native runtimes (e.g. AGTdid: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:subjectpattern updated to^(spiffe://|did:), description updated.
Reference Implementation¶
TrustRecord.subjectpattern updated tor"^(spiffe://|did:)".
Upcoming¶
See ROADMAP.md for planned changes in v0.2 and v1.0.