Changelog¶
All notable changes to the TRACE specification will be documented here.
Format: Semantic Versioning. Spec versions follow MAJOR.MINOR.PATCH: - MAJOR: breaking changes to wire format or required Trust Record fields - MINOR: new optional fields, new platform profiles, new conformance levels - PATCH: editorial fixes, clarifications, non-normative additions
[Unreleased]¶
Added¶
-
The PIC/TRACE bridge cites the specification it consumes.
docs/integration/pic-trace-bridge-v1.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.
Fixed¶
-
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. -
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. -
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.