Changelog
September 21, 2026 · View on GitHub
All notable changes to this project are documented here. The format follows Keep a Changelog and the project adheres to Semantic Versioning.
Editorial 2026-07-20: internal gate codename replaced by its external name throughout; content unchanged.
[6.1.0] - 2026-09-19
The work on main after the v6.0.0 tag, cut into a release. Owner word, order
QITEM-PROOFBUNDLE-610-SCHNITT-LANDEN-KETTE-01, option A: 6.1.0 is what has been on main since
v6.0.0, plus the two P1 findings of the 2026-09-19 audit, R7 and the cheap documentation
findings. The other 54 lines of the 2026-09-12 scope move to 6.2.0 unchanged, and
docs/release_scope/6.2.0.md carries them word for word.
This section was ## [Unreleased] until the cut, and it said the version was deliberately not
bumped because "a bump without a release would claim a delivery that did not happen". That
sentence was true while it stood and would have become false the moment the number moved, so it is
replaced rather than left standing. What is open and why is in RESTRISIKO_610.md, which lands
before the closing round, not after it.
Added
- The verifier block, and
agent-review/v0.3to carry it (producer.verifier,src/proofbundle/verifier_block.py,verify_agent_review_v03,validate_agent_review_v03_predicate; v0.3 is v0.2 plus this one optional field, and v0.2 stays exactly what 6.0.0 shipped — the same bytes were measured refused by 6.0.0 and accepted by a v0.2-extension draft, which is why the block is a version and not an extension; contract indocs/VERIFIER_BLOCK.md). A receipt can now say WHICH build produced it: a digest over the package's own files with the measurement's source stated (installed-recordfrom the installer's RECORD, orsource-tree), the conformance vector set it was held against (digest over manifest and every case file), and a reference to a separate in-tototest-result/v0.1statement, joinable by digest equality without trusting the issuer for the join. Measured on 2026-09-12 against 6.0.0: no receipt and no verify result carried a build identity, and two wheels of the same version were indistinguishable from the receipt. The block is self-declared and says so; the verifier reports it (verifier_block,matches_this_verifierin MATCH / MISMATCH / NOT_EVALUATED) and never folds it intook. v0.1 refuses the field, a 6.0.0 verifier refuses a v0.2 receipt carrying it — loudly, never by misreading. Five conformance cases with flip tests. conformance/run_conformance.py --test-result-out PATHwrites the run as that in-toto test-result statement: subject the measured build digest, configuration the vector set, result by the corpus rule that a skipped check is never a passed one (FAILEDon any failure,WARNEDwhen a case ran partially or not at all,PASSEDonly when every case ran in full), the case ids listed per outcome. Written unsigned; signing is the producer's step.scripts/verify_pre_tag_receipt.py: the pre-tag audit receipt, verified by someone who holds a clone. Receipt, pinned key and gate source are read from the COMMIT, never from the working tree; the tree digest is taken by the same library the release gate uses, at the checked-out commit, and a checkout at another head is refused rather than measured. Three contracts, each planted and refused: no valid receipt fails; a receipt made for another commit fails; a receipt with a correct signature over the wrong subject fails. The limit — the trust root is a key committed in the same repository, and script and library are files of the tree they verify — is printed with every verdict.RELEASE.mdnames the command where a reader decides they have checked a release, and says what it establishes and what it does not;docs/PRE_TAG_AUDIT.mdno longer describes the prose check the gate stopped reading.- A collector job
all-checks-passedin ci.yml that ALWAYS reports: it needs the jobs the ruleset requires from this file (test,coverage), runs underif: ${{ !cancelled() }}, and turns red when any needed job did not succeed --skippedincluded -- or when the full test matrix did not run on the event (fork pull request without thelandunglabel). Owner decision A on card OA-3c67b06ad6: one always-running context replaces six, four of which a condition could leave uncreated.guardstays a required context of its own, because it lives in fork-pr-isolation.yml and a job cannot need a job of another workflow. The ruleset is NOT switched by this change; that is the owner's step once this job has run green. - The reachability gate learned two shapes of that job. A job condition made only of
always()or!cancelled()is unconditional, notproduced-only-if(the first draft would have called the collector conditional and been red for the wrong reason). A REQUIRED context on a job withneedsand no such guard is reported asskipped-is-passedand turns the gate red: the job is skipped whenever a needed job fails, and a skipped required check reads as passed, so it can never block on the failures it depends on. A contract holds the collector's copy of the matrix condition byte-identical to the matrix's own. - Register form 6.1 for the findings register, as a second carrier next to the signed v1: the
producer emits
findings_register_v2.jsonplus two generated views, every record carries the byte range of its own evidence, and the three 6.1 register lines are written directly in the new form with their measured starting position. - The 6.0.0 register body now carries the signature of the anchor key, and the written target value carries its provenance — a chain is appended rather than the previous value overwritten.
- A guard that no shipped test module imports a non-shipped module by bare name. The
published-artifact-gate / hermetic-cleanroomjob aborted at collection becausetests/test_belegdatei_traegt_ihren_eigenen_digest.pyputscripts/onsys.pathand then wroteimport gen_findings_register, while that script is deliberately withheld from the sdist. Nothing ran, not one of the other tests. The guard decides in the checkout, where both the file and the distribution listing are present, and leaves the cleanroom unchanged. - Coverage in the language of CAP-1, target 6.1.0 (not part of 6.0.0). A
proofbundle.cap1module checks a coverage-attestation document ofdraft-hillier-coverage-attestation-00(profilecap/1) against the draft's rules R0 to R8, never raises, and reads strictly (a duplicate JSON name is a read error, not a verdict — RFC 8259 section 4 calls that behaviour unpredictable, and the draft author's own probes show three equally conformant readers disagreeing). The fifteen conformance vectors of the draft author (Certisyn-Inc/certisyn-drafts, commit 0980d32, Apache-2.0) ship underconformance/cap1/as cases of the new kindcap1_documentwith one axis,cap1Rules, the exact set of rules that must fire; NC-05 pins R1 and R5 together, and a counter-proof that fails for the wrong reason fails the case. Anagent-review/v0.2predicate may carrycoverage.strata,coverage.integrityandcoverage.absenceAssertions; the rules are borrowed fromcap1, each reports its own reason code (COVERAGE_CAP1_SHAPE…COVERAGE_CAP1_SUPPORTS_MISSING,COVERAGE_CAP1_RULE_UNMAPPEDfor a rule the mapping does not know),statusis derived fromintegrity.completeand a stated status that disagrees is rejected (COVERAGE_CAP1_STATUS_CONTRADICTS_STRATA). The older counters stay readable as aliases with a stated decay (COMPATIBILITY.md); a v0.2 receipt without strata carries the advisory codeCOVERAGE_LEGACY_FIELDS. v0.1 rejects the three fields as unknown.
Fixed
- A-15 / A-55 / A-60 — a loader annotated
-> dictreturned whatever it decoded. The verify boundary now types what it reads instead of trusting the annotation, and three exporters that coerced the verdict field withbool()are covered by it: a claim carryingpassedas the STRING"false"verified as passed. Catch-proofs measure at the export sites, one case each, red before the fix and green after, rather than only at the boundary that fixes them. - A-16 — the boundary refuses a malformed claim instead of deciding on it. SemVer: this can
turn a verdict that was
okinto a refusal for input that was never valid. That is a behaviour change for callers who were relying on the silent acceptance, and it is deliberate — a verifier that decides on bytes it could not parse is not stricter, it is wrong. No ADDITIVE version bump can carry it, so it rides the MINOR that this release already is. - A-39 — CAP-1 coverage divided by a denominator that could be zero. The null case now has its
own answer instead of an exception or a silent ratio, and the scope line for CAP-1 in
docs/release_scope/6.1.0.mdnames this finding, because 6.0.0 deferred CAP-1 to 6.1.0 and main carries it. - R7 — three numbers in shipped comments that no longer matched the tree. They are now derived by running the cases rather than by counting lines, and the run refuses to report a ratio when it did not finish: killed and survived are not an exhaustive pair, and a partial run reports neither.
- A-70 — the pre-tag receipt could be produced from a tree that was not the one measured.
scripts/pre_tag_receipt.pyrefuses a working tree that differs from the committed head, and since 2026-09-21 it runs the audit itself between two measurements of that tree: a supplied record and a typed exit code are no longer accepted, because a counter-reading bound output produced from a modified tree to the clean head by restoring the file before the emit. The cleanliness measurement no longer asksgit status, whose answer depends on state outside the committed tree:status.showUntrackedFiles=no, a globalcore.excludesFile,.git/info/exclude, an untracked ignore file covering itself andGIT_DIRin the environment each hid a path from the first version; a second counter-reading showed the index bitsassume-unchangedandskip-worktreehiding a modified tracked file from the version that followed; and the own sweep plus a third counter-reading showed a clean filter defined in the configuration,core.worktreeandcore.fileMode=falsestill deciding whatgit diff-indexreported on a fresh index, a fourth counter-reading showedcore.ignoreCase=truehiding an untracked file fromls-files --others, and a fifth showed a negated rule in a tracked.gitignoretaken for a hiding rule andrefs/replacesubstituting the head's objects. The tree is now compared by COMPUTING the property: the bytes on disk againstgit ls-tree -r HEAD(hash-object --no-filtersper entry, modes from the file, symbolic-link targets hashed) and the paths on disk against the same listing, with only a non-negated tracked.gitignorerule allowed to hide one, and every git call of the process reading the raw objects (GIT_NO_REPLACE_OBJECTS=1); each of the thirteen has a case that was red against the version it was measured on. A checkout whose files differ from their blobs by design (core.autocrlf=true, no executable bit) refuses, and the docstring says so..hypothesis/is named in.gitignore, because hypothesis ignored its cache only through a file it wrote itself, which is exactly the shape the gate refuses. Named limit: a change made and undone during the run lies between the two measurements. - Evidence digests: a record named a
pathand asha256that described different objects, the digest of the excerpt versus the bytes of the file. Measured across all 145 records, 0 matched the file. The checker also never opened the file it named, so a deleted or altered piece of evidence stayed green. - Gaps in the register numbering were silent; they are now named and gated.
- The producer is read against the signed register rather than against its own in-memory list.
Changed
-
scripts/required_check_reachability_gate.py --verify-live-pr(#219) judges the LIVE pull request: it evaluates each gating condition against the run's own event and reports per required context whether it arrives, will not arrive, or is not measurable; the advisory job runs it after the offline gate. Measured 2026-09-17 on pull request 218: the offline gate was green while four required contexts could never arrive. -
ci.yml (#220): the full five-version test matrix runs for every pull request from this repository; the
landunglabel stays the gate only for fork pull requests (owner directive 2026-09-17, velocity). The reachability declaration re-binds its accepted contexts to the new condition. -
scripts/audit_candidate_matrix.pyreports three outcomes per cell (PASS, FAIL, NOT_MEASURED with a reason) and exits non-zero only for a release-deciding FAIL, forNOTHING_MEASUREDor for an unbound version pin; on a pull request candidate-bound release evidence that is not bound to that head is NOT_MEASURED instead of FAIL. New cell C6.4 runs a short fuzz-soak live on the head; the 24h soak moved to.github/workflows/soak-nightly.yml.scripts/pre_tag_audit_gate.pydistinguishes a genuine receipt of another tree (other_tree) and a foreign artefact in the receipt folder (foreign_files) from a rejected receipt. (Owner order 2026-09-17; measured on pull request 218: the advisory job was red on every pull request with DATA_BLOCKED 0 and FAIL 4.) -
The findings register is scoped to 6.1.0 (
VERSIONandFINDINGSinscripts/gen_findings_register.py): the 21 findings carried from the 6.0.0 register, withN16closed for this tree and named as still open in the published Action tag, plus the five class entries the risk sheets promise asRegister entry, each with a severity the producer assigned from the sheet's stated reach and a note that says so — 26 entries, 14 closed, 12 open, 0 open P0/P1.tests/test_register_610_carries_what_the_sheets_promise.pybinds producer and sheets in both directions and the producer's version topyproject.toml; the numbers guard now reads the document of the register's own version. The line-610 carrier andaudit_artifacts/610/README.mdfollow the producer. The signed v1 register is assembled from the owner's signature over the emitted payload; until it lands,tests/test_register_gegen_erzeuger.pyreports the gap, which is what it is for. -
Identifiers transcribed, internal codename and account names.
-
The twelve evidence files are excerpts and are not rewritten; the earlier rewrite was reverted.
Fixed — the CI cut (PR 202), four defects the review found in the cut itself
- Explicit re-pin of one accepted conformance case
(
agent_review/agent-review-v02-counter-proof-unknown-predicate-type-is-refused): its example of a foreign predicate version was…/agent-review/v0.3, which 6.1.0 turns into a real version. A case that files an existing version as unknown measures the opposite of what it says, so the foreign example is now…/v0.9and the case is regenerated. Recorded here because the corpus rule (conformance/README.md, "Adding a case") allows an accepted vector to change only as an explicit, reviewed re-pin — this is that record. - A concurrency group coalesces a queue, it does not serialize one.
cancel-in-progressis evaluated on the arriving run, but the group decides which run dies. The eight workflow groups now carrygithub.event_name, so a pull-request run and a push run of the same ref no longer displace each other. - A called workflow cancelled its caller. In a
workflow_callworkflowgithub.workflowis the caller's name, soreusable-build-attest.ymlshared a concurrency group with whatever invoked it. Itsconcurrencyblock is removed; a reusable workflow does not own the group. - A running release could be cancelled.
release.ymlnow carriescancel-in-progress: false, whichRESTRISIKO_600.mdhad required verbatim and which had never been implemented. Between draft, upload and publication there is no safe interruption point. - Two time budgets sat below the duration they were meant to bound.
testwas capped at 30 minutes against a measured 32.3,coverageat 30 against a measured 40.0. A timeout reports ascancelled, notfailure, so both would have read as somebody's cancellation rather than as too small a budget — and under the standing merge rule an unmeasurable required check halts a landing. Now 50 and 60.
The matrix expression that drives the cut was also a string where a list was meant, and
tests/test_der_ci_schnitt_haelt.py (27 cases) now binds each of these properties with a
counter-example that fails against the pre-fix shape.
[6.0.0] - 2026-09-05 (v0.2 is what the emitter produces · MAJOR)
The break in one sentence: agent-review/v0.2 is what build_agent_review_statement and emit_agent_review produce without an argument; v0.1 needs an explicit legacy_v01=True, stays readable and verifiable without a deadline, and is reported as predicateVersionStatus: legacy.
Fixed — after the freeze of 2026-09-05, before the tag
The freeze head of 2026-09-05 was not the tagged head. Twelve more deep-gate rounds ran on the candidate between the freeze and the tag (rounds 3 to 14, six lenses each, a cross-family reader on every fix head), and what they found in the shipped package is closed here — each entry names its measurement. From round 12 on the severity rule was sharpened by the owner (2026-09-11): P0/P1 is reserved for the wheel on PyPI — a verdict, exit code, bound or security property of the shipped verifier — and everything found in test riegel, measurement scripts, the Rust cross-verifier or documentation is a register row for 6.0.1 or 6.1 (see Known issues at the tag below).
-
A lone UTF-16 surrogate in any JSON string is refused, on the file path and the dict path (2026-09-11, round 13, found by the cross-family reader, not by the six lenses). Python's
jsonaccepts"\ud800",serde_jsondoes not, and RFC 8785 cannot canonicalize it — so a DSSE envelope carrying a lone surrogate inkeyidverified in Python (verify_envelope -> True) and was refused by the Rust verifier: the same file, two verdicts._strict_jsonnow rejects it fail-closed asBundleFormatError(not I-JSON, RFC 7493 §2.1) wherever the structural budget is enforced, so both verifiers agree, and the crosscheck carries the negative vector. -
The receipt-chain resolver reads the payload with the same strict parser as the verifiers (2026-09-11, round 14, L1).
agent_review.resolve_receipt_chainparsed the DSSE payload with a rawjson.loadswhile everyverify_agent_review*reads the same bytes withloads_strict. Measured on the candidate: a 3000-deepsupersessionlet a rawRecursionErrorout of the resolver, a lone surrogate inpriorDigest.sha256ordered the chain, and a duplicatedsha256key was read last-wins — three forms the verifier refuses that nevertheless decided which receipt is current. An envelope whose payload the strict parser refuses no longer orders anything; it is named underunaddressableand counts againstintegrity_ok, exactly like a non-addressable envelope. -
verify_sd_jwtreturns a verdict when the SIGNATURE segment exceeds the input budget (2026-09-11, round 14, L2)._b64url_decoderefuses a segment aboveinput_bytesbefore decoding it, raisingBundleFormatError— aProofBundleError, not aValueError— and the signature call site caught onlyValueError, so the documented never-crash surface crashed; header and payload two blocks above had caughtProofBundleErrorfor months. The same exception ran throughsdjwt_vc.verify_sdjwt_vc, andsdjwt_vccarried a third copy of_b64url_decodewithout the pre-decode cap (a 40 MiB segment was fully decoded before any bound applied). Now one decoder, one except clause per its contract, and a fail-closed verdict; the sibling instatuslistis widened the same way although it is unreachable today. -
evaluate_policy,evaluate_decision_policyandrelation.evaluate_relations_policycheck the policy's shape themselves (2026-09-11, round 14, L4).load_policypromised that "a typo that silently weakens a policy is impossible" — and kept it only for the caller that goes throughload_policy. The three evaluators everyverify_*surface calls read their switches with.get(name); measured:{"signature": {"require_expected_signerr": true}}(onertoo many) producedpolicy_ok: True, checks: []for a bundle whose signer is the literal textany-attacker-key-at-all, and{"reject_superseeded": true}left an attached supersession unenforced. The CLI was never affected (it callsload_policyfirst at all seven sites); the library was. One shape check,_huelle_pruefen, now servesload_policyand the three evaluators — no second key list to drift. Behaviour change for library callers: a policy dict handed directly to an evaluator with an unknown key at any level is now a fail-closed verdict (policy_ok: False, reasonunknown field(s) in …), not a silent pass; required fields and types remainload_policy's business, so partial policies stay accepted. -
A placeholder the subject can carry is not a comparison (2026-09-07). The pre-tag gate replaces an unmeasurable tree with
"unknown"and an unreadable gate source with"unreadable"so that it can RULE instead of crashing. Both placeholders then went into an equality comparison against a field the subject itself writes. Measured: a receipt signed with the LEGITIMATE key carrying the literalsubject_tree_digest: "unknown"verified every time, regardless of the tree —ok=true, state=verified.verify_receiptnow checks the FORM of the EXPECTED value (a lowercase 64-hex sha256, for both digest fields) before comparing, so every present and future placeholder is unbindable without anyone having to enumerate them; an enumeration would be silently short at the next one. The neighbouring surface has done it this way since Auflage C3 (audit_candidate_matrixtestsif not gebunden/if not heutebefore comparing) — this function was the straggler. Side effect that confirms the finding: the fixtures intests/test_pre_tag_receipt_gate.pyandscripts/gate_qualification_harness.pycarried 40-char values — the length of a git SHA-1, never a valid sha256 — and now carry 64. -
A library under a gate does not raise
SystemExit(2026-09-07).subject_tree_digestaborted the process on an unreadable tree.SystemExitis aBaseException, and the gate's own backstop catchesexcept Exception, so the abort flew straight past it: five negative tests that run the gate against an evidence-free non-git directory DIED instead of receivingok=false— a guard that no longer rejects but expires. It now raises a typedBaumNichtLesbar, which the existing backstop catches. The siblingsign_readiness_artifact.tree_digestkeepsSystemExitbecause it only runs from a CLI, where the abort IS the verdict. The difference is the caller, not the error. -
The verdict allowlist has a near-miss case (2026-09-07). Mutating
not in _GATE_VERDICTS_PASSinto a prefix comparison survived the whole matrix unchanged — measured onb9d35d4withpytest -k "freigabe_evidenz or commit_flow", identical before and after. (The bare number that stood here named no head and no command, and a gegenlesung measuring a different head with a different selection got a different one. A count without its command is not reproducible.) It would have madeWITHSTANDS_DEEPGATE_PARTIALLYa pass. The three existing cases test absence, a known fail value and a foreign word; none tested a value carrying the allowed constant as a prefix. A "partially withstood" is not a withstanding. -
The receipt exclusion applies to version tokens, not to any folder name (2026-09-07). The pattern read the directory as an arbitrary word, so
audit_artifacts/anything/pre_tag_receipt_ v9.9.9.jsonalso fell out of the binding. No consumer exploited it, but an exception that excludes more than it must is the start of the very class this round closes. A NEW ORDERING CONSTRAINT follows from the narrowing and is recorded inRESTRISIKO_600.md: everything else underaudit_artifacts/<token>/must be committed BEFORE the receipt context is produced. -
The wheel is canonicalised in the build path, so the second half of the byte freeze holds (2026-09-07). The release standard requires two byte-identical sdists AND a wheel built from the shipped sdist that equals the directly built one. The second half was red: 82 entries per side, zero content differences, 11 entries differing in file mode alone (
0o100664against0o100644). The cause is measured, not guessed:normalize_sdistsets every file in the sdist to0o644, the same files carry0o664in the working tree, and the packer copies the source file's mode into the ZIP entry.normalize_wheelnow rewrites the finished archive with fixed modes and fixed timestamps, entry order untouched becauseRECORDsits last by convention;release.ymlruns the same path via--with-wheel. Measured with isolation, the way the workflow builds:identical: true. The digest is deliberately not repeated here any more. It reada009a968…until 2026-09-07 and a gegenlesung could not reproduce it on any head of this line (68aa6f3givesdeaedb1a…,c335b26062188c9…,0aca175bc806f89…). The number was not wrong when it was written; it was written without the two things that make a build digest checkable — WHICH head and WHICH toolchain, andbuild-system.requirespins neither setuptools nor wheel. A digest without those is a number, not evidence. The verdictidentical: trueis the claim that carries, and it was confirmed on four heads. An earlier counter-check had ruled outumaskas the cause: atumask 022both digests changed and stayed different. -
The second half of the byte freeze has a measuring point at all (2026-09-07). It stood in the fail-closed sentence of the release standard and nothing measured it: this script built no wheels, no other tool under
scripts/did either, and the audit matrix readscandidate.wheel_sha256without recomputing it. A condition inside a fail-closed sentence with no measuring point counts as green without ever having been measured.build_reproducible.py --check-wheelnow measures it, and it found a real defect on its first run. -
The skip decision of the Go differential comes from exit codes, not from a message text (2026-09-07). The test decided whether a failure was a network problem by looking for three substrings in Go's stderr. That is fragile — a foreign tool's message is not a contract — and a scanner read the host substring as a weak host check. Availability is now determined up front by commands whose exit codes ARE the class, in three outcomes: obtained (offline, else over the network), not obtainable (skip), tooling broken (hard failure). The third exists because a first attempt turned EVERY failure of
go mod downloadinto a skip, which would have swallowed a checksum mismatch againstgo.sum— an integrity signal — in a tool whose purpose is verifying signatures. A named limit remains and is not fixed: with a cold cache Go discards a mismatching download, the module directory never appears, and that case is indistinguishable from "no network" without reading text. -
Tag text and release note pass through the same claim hygiene as the documents (2026-09-07).
claims_hygiene_check.pyscans 49 documents in CI, but the tag text is not a file in the tree and the release note is composed by GitHub from pull request titles — both went out unchecked while every README line had to pass forty forbidden phrases.release_text_hygiene.pycallsclaims_hygiene_check.scan_text, the same rule set rather than a second list, andrelease.ymlruns it before the build. -
One signed artefact, ONE accepted wire form, and both shipped verifiers agree on it (deep gate run 3 on 049b3195, 2026-09-05, findings L1-600-01 and L1-600-03, class
canonicity_preserving_perturbation_accepted/ RT-08).validate=Truealone refused foreign characters and a missing pad character but not NON-ZERO PAD BITS (b64decode(b"QUJ=", validate=True) == b"AB"), the url-safe arm re-padded an unpadded string, and the Rust verifier trimmed whitespace Python refuses — so Python and Rust returned different verdicts for the same file, in both directions._wire_b64v1.1 decodes strictly and then re-encodes: a spelling that does not round-trip byte-for-byte is refused (pad bits, padding, alphabet, whitespace).decode_b64(standard, padded),decode_b64url(JWS, unpadded),decode_b64_either(DSSE: standard OR url-safe, each padded — RFC 4648 §3.2 applies because the DSSE envelope specification says nothing about padding) anddecode_b64_c2sp(C2SP signed notes: non-zero pad bits tolerated for parity with Go's reference decoder, named in one place). Every stdlib base64 decode in the package (51 sites) now goes through these helpers;tests/test_wire_bytes_strict.pyrefuses any direct stdlib decode outside the wrapper, enumerates the canonicity-preserving population (pad bits, unpadded, surplus padding, other alphabet, whitespace) over DSSE envelopes and native bundles, and its Rust arm now MEASURES agreement over that population instead of skipping itself. Rust:b64_strict(bundle fields, keys) andb64_dsse(envelopes) without.trim(),b64url_nopadwithout padding tolerance. Behaviour change: an UNPADDED DSSEpayload/sigis now refused by Python as it already was by Rust; the padded url-safe spelling the spec mandates still verifies. -
A Trust Pack role applies to the key that signed, never to a self-declared keyId (finding L1-600-02, P2 fail-open).
verify_outcome_receipt(..., trust_pack=)reportedexecutor_role_trusted=TrueandsafeForAutomation=Truefor an outcome signed by a fresh key whose predicate merely claimedexecutor.keyId = root-0. It now binds the keyId to the verifying key againstkeys[keyId].publicKey(outcome.pack_key_binds_signer;executor_trusted_by_role(..., public_key=)), reportsexecutor_key_boundand the blockerKEY_ID_NOT_BOUND_TO_SIGNER. On the receiver side areceiver_attestation_resolvermay return the referenced statement's 32-byte signer key; when the pack names key material for areceiverKeyId, promotion toINDEPENDENTLY_ATTESTEDandreceiver_role_trustedrequire that key to match (receiver_key_bound); a bareTrueno longer binds a label the pack names. Callers without a trust pack are unchanged. -
A rejection must not fail harder than the check it explains. The messages of
verify_bundle,recompute_merkle_root_b64,anchors.verify_anchor(s),hashalg.verify_dual_hashand every_reject_unknowninterpolated a caller-supplied value directly, so an implausibly large integer in an enum-typed field (schema,signature.alg,merkle.hash_alg,anchors[].target) or a mixed-type key set raised a rawValueError/TypeErrorout of a typed-raise surface — and throughdecision.verify_decision_receipt(anchors=…)out of a documented never-raise one. The class is closed at two chokepoints rather than at nine sites: integer magnitude is now a dimension of the structural budget (enforce_structural_budget, which also walks tuples), andbudget.render_safe/render_keys_safeare the one bounded renderer that never raises. Ordinary values render byte-identically to before. -
Every relying-party expectation argument is type-floored at entry.
verify_status_snapshot(now=…)compared the caller's clock raw once the token carriedexp/ttl; a string, list, float or huge integer raised a rawTypeErroron a surface that declares it never crashes. It is now a fail-closed verdict that names the reason. -
The cap runs before the work it bounds, on the C2SP note family and the tlog-proof parser. A signed note carried no count cap on its signature lines: an 8 MiB note with 74,234 lines for the vkey's own key id drove ~74k Ed25519 verifications (measured 9.9 s) through the attacker-supplied
verify-prooffile.verify_checkpoint,verify_cosignatureand the witness roster now refuse above thesignatures/witnessesbudget before the first verification, andparse_tlog_proofrefuses abovemerkle_pathbefore decoding a single step;verify_tlog_proofroutes its inclusion check throughmerkle.verify_inclusionso one oracle carries the caps. -
CLI consumer surfaces never raise (class RT-06, deep gate 2026-09-05, findings L3-600-05/06/07/08). One writer discipline for every untrusted string on the human path:
_safe_linerenders a lone UTF-16 surrogate in its escaped form (\ud800) instead of dying inprint()under a strict utf-8 stdout, and neutralises control characters in Check rows,show-evalfields,svr --verifyproperty rows,anchorcalendar lines and everyERROR:line on stderr — an embedded newline can no longer forge an extra[PASS] …/=> OKrow.verify_svr_dssenow carries the SVR predicate shape in its verdict (predicate_shape_ok): a validly signed SVR whose predicate is not an object, or whosepropertiesis not a list of strings, isok=False, andsvr --verifyexits 2 without printing a PASS line first.policy._parse_iso_utcmaps the whole stdlib failure family of a timestamp parse (ValueError,OverflowErrorfromastimezoneon0001-01-01T00:00:00+23:00) to "unparseable", sopolicy lint/explain,verify --policy,--verification-timeand every<verb> verify --policyexit 2 with the typed PolicyError instead of a raw OverflowError; the sibling datetime sites (check_freshness, agent-review time axis) carry the same guard.main()gains a documented backstop: anUnicodeEncodeErroror a member of the named type-confusion family that escapes a per-command handler ends in exit 2 with an ASCII-safe message (traceback onPROOFBUNDLE_DEBUG=1), never a raw traceback on a consumer surface. -
An attached target is VERIFIED only if it verifies standalone (deep gate finding L4-01, P1). The
--with-relatedresolver verified a target's SIGNATURE and then parsed its payload leniently, so a strict-parser refusal (duplicate JSON key, NaN, BOM, non-canonical, not an object) was swallowed into "verified, no edges, subject absent". A chain hidden behind a duplicatepredicatekey therefore came outlineage=VERIFIED/ exit 0 in both shipped verifiers, while the same bytes failed standalone. Loader and standalone verifier now share ONE payload oracle (_statement_payload.load_statement_strict); a refused payload isRELATION_TARGET_MALFORMEDand FAILs at every hop, in Python and in Rust. -
A retraction is silently suppressed when the attached successor is itself malformed (L4-600-01, P1).
relation.successor_warningskipped an attached, standalone-verified receipt whose OWNrelationshipsblock failed validation — and with it anyretracts/supersedesedge declared inside. One deliberately malformed edge next to the retraction was enough:supersededByAttachedstayed empty,reject_supersededfound nothing,safeForAutomationflipped false→true and the CLI exit 3→0. Python and Rust made the SAME mistake, so the differential between them was blind. An unreadable block now reportsRELATION_MALFORMED_SUCCESSOR(a receipt with NOrelationshipsfield still stays silent); a READABLE retraction still wins over the unreadable report, so the verdict does not depend on attachment order. Closed on Owner instruction rather than carried as a named residual risk. -
A statement with more than one subject binds to none of them silently (L4-02, P2).
classify_subjectreadsubject[0], so[derived, foreign]classified asDERIVEDand reachedsafeForAutomation: true, while[foreign, derived]failed — the verdict depended on the order the issuer wrote.len(subject) != 1is now its own mode,AMBIGUOUS, on the decision, outcome and relation-statement paths;require_derived_subjectfails closed (exit 2). The resolver already reported such a target asambiguous; both sides of the invariant now agree. -
A release gate distinguishes ABSENT from REJECTED by a typed field, never by prose (L5-G6-01, P2). C12.1 narrowed "no receipt binds this tree" to NOT_APPLICABLE on a pull request by matching a substring of the gate's reason — a sentence that opens the reason for rejection too. An untrusted signer, a tampered signature, a copied v5.0.0 receipt and an unreadable file all inherited that leniency and the matrix exited 0.
pre_tag_audit_gate.evaluatenow reportsstate∈ {absent, rejected, verified, not_determinable}, an unreadable candidate is rejected rather than skipped, and C12.1 narrows only onabsent. Neighbour swept with it:pyproject_versionno longer raises a rawFileNotFoundErrorout of a gate whose contract is to rule. -
A version-scoped signed artefact binds its version to the version under test (L5-G6-02, P1). The release-deciding C12.2 reported PASS for 6.0.0 out of a signed findings register scoped to
3.6.1; a register carrying0.0.1or no version at all was accepted just as readily.verify_and_countnow takesexpected_version, fails closed withREGISTER_VERSION_MISMATCHon mismatch, absence or an unmeasurablegenerated_at, and reportsversion_boundso an unbound caller is visible as unbound. This is the L6-01 lesson applied to the artefact rather than to the matrix pin. -
An extracted sdist behaves the same wherever it is unpacked (L6-600-01, P2). The repo-context derivation asked
git check-ignoreof whatever repository CONTAINED the tree, so the same sdist bytes produced 40 failures under a gitignoredvendor/, 1 under a non-ignored sibling and 0 in a plain directory. git is now consulted only when this tree is itself the repository (--show-toplevelequality); otherwise the stricter no-git behaviour applies.
Changed
-
BREAKING (6.0.0):
agent-review/v0.2is what the emitter produces without an argument. The previous version needs an explicitlegacy_v01=True;v02=is deprecated and warns. v0.2 requiressubjectContext.disclosureCoreDigest, requireslimitationCodes, separates time claims by source, and accepts only the full 40-characterfixCommit(FIXCOMMIT_NOT_FULL_SHAotherwise). -
A named policy axis, and it is reported rather than assumed. Without a policy the result carries
policy_decision: null, the reason codePOLICY_NOT_EVALUATED, andautomation.safeForAutomationis false — the axis was not evaluated and says so.okitself is unaffected unless a policy actively rejects (policy_decision == "reject"): a check that was never run is "not applicable", not "failed", and inventing a rejection for it would be a claim about something unmeasured. The named standard policy ships inside the package asproofbundle/policies/agent-review-default-v1.json(the copy underconformance/agent_review/policies/default_v1.jsonis byte-identical and kept so by a test), and its digest is reported, so a later reading can say what the decision was made against. -
agent-review/v0.1is unchanged and stays readable. Its verifier is byte-pinned to the 5.1.0 source; the six published receipts underreceipts/agent_review/verify as before, and a result from the new dispatcherverify_agent_review_anycarriespredicateVersionStatus: legacyplus theAGENT_REVIEW_LEGACY_V01reason code. -
A time conflict is fatal, and no policy can accept it. Measured by a review lens on 2026-09-05: a v0.2 predicate with two contradicting
reviewCompletedtime claims verified withok=TrueandsafeForAutomation=Trueunder the shipped standard policy. The module saidCONFLICT"always" rejects — inevaluate_time_policy, which nothing in the verifier called; the verifier only evaluated the limitation policy, which reads no time axis. The result now carriestime_consistency_ok, aCONFLICTon the event or observation axis adds the fatal reason codeTIME_CLAIMS_CONFLICT, setsokto false and blocks automation. A policy file may additionally carry atimeblock ({"kind": "freshness" | "ttl" | "certificate_validity" | "currentness" | "existence"}); it is then evaluated withevaluate_time_policy, reported astime_policy_decision, and the stricter of the two policy decisions wins. The standard policy carries none, and says so (time_policy_decision: null). -
A policy file is validated before it decides. Measured on 2026-09-05:
blocking: "COVERAGE_PARTIAL"(a string instead of a list) was read as a set of characters and blocked nothing, andrequire_coverage_status: "XPARTIALX"turned the membership test into a substring test — both accepted with emptyerrors,warningsandreason_codes.load_policyandevaluate_limitation_policynow reject a policy whoseblocking,never_blockingorrequire_coverage_statusis not a list, names an unknown code or status, or repeats one, and whosetimeblock names no known kind; a rejected policy reaches the verifier asPOLICY_NOT_EVALUABLEwithinsufficient_evidence, never as an accept. -
verify_agent_review_anyno longer raises on a v0.1 envelope when givenpolicy=. The dispatcher passed every keyword to whichever verifier the envelope'spredicateTypeselected, so the same call was a verdict for v0.2 and a rawTypeErrorfor v0.1 — the sender chose which. An argument no version knows is now aTypeErrorbefore the envelope is read, the same for both; an argument only the other version knows is dropped, named inwarnings, and marked with the advisory codeARGUMENT_NOT_APPLICABLE_TO_VERSION(plusPOLICY_NOT_EVALUATEDandpolicy_decision: nullwhen it was the policy). The dispatcher also decodes the payload with the same decoder asdsse.verify_envelope, so a url-safe base64 envelope that verifies directly no longer comes back asAGENT_REVIEW_ENVELOPE_UNREADABLEthrough the dispatcher. -
Non-fatal codes moved to
advisory_codes.POLICY_NOT_EVALUATED(no policy given) andAGENT_REVIEW_LEGACY_V01(a v0.1 receipt read through the dispatcher) describe an axis that was not run and a version that is not current; neither makesokfalse. They were listed inreason_codes, which the documentation defines as fatal-only. They are now inadvisory_codes;reason_codesis empty for a valid receipt, as documented. Consumers that branched onPOLICY_NOT_EVALUATED in reason_codesmust readadvisory_codesinstead (6.0.0 is the first release with either code, so nothing published changes meaning). -
The disclosure renderers accept an explicit version, and read three markers instead of one. Measured on 2026-09-05: the renderers detected v0.2 only by
declaration.timeClaims, which is optional in v0.2 and absent from 10 of the 10 v0.2 predicates in the conformance corpus, so the three v0.2 counter-proofs (limitation-codes-are-required,disclosure-core-digest-is-required,fixcommit-must-be-the-full-sha) rendered under v0.1 rules.render_disclosure_block,render_disclosure_lineandrequire_valid_agent_review_predicate_anytakelegacy_v01=(an explicit answer wins); without it,timeClaims,limitationCodesorsubjectContext.disclosureCoreDigestselect v0.2. The six published v0.1 receipts carry none of the three. -
attested_inference: a mapping with unserialisable content is malformed evidence, not a crash, andcounts_as_own_domainanswers false for anything that is not a mapping. Both were raw exceptions on hostile input at a public surface. -
receipts/agent_review/ships in the sdist, sotests/test_agent_review_v01_regression.pyruns from the published package instead of skipping all 20 cases as repo-context (same class as PB-2026-0717-02, PB-2026-0831-01 and PB-2026-0902-01). -
The
Receipt:line of a published disclosure block is the sha256 of the receipt file as published, and this is now written down: it is what a reader can recompute withsha256sum.receipt_digest()— the canonical digest that supersession (priorDigest) andresolve_receipt_chainkey on — is a different value. A correction must cite the canonical digest, never the file hash from the block. -
The disclosure renderers read the predicate version from the predicate. Measured on 2026-09-04 while emitting the first real
agent-review/v0.2receipt (PR 185):render_disclosure_blockvalidated with the v0.1 rules and refused the predicate withunknown field 'timeClaims'. The block and the line now choose the validator from the fields the predicate carries; a v0.2 predicate is checked with the v0.2 rules, which include v0.1. -
The standard policy ships inside the package. Found by the published-artifact gate on 2026-09-04:
standard_policy_path()walked three levels up from the module file to the repository root, which exists in a checkout and not in an installed package, soload_policy()failed withpolicy not readablefrom the very package a stranger installs. The file now lives atproofbundle/policies/agent-review-default-v1.jsonand is resolved withimportlib.resourceslike the other policy profiles; the corpus copy is byte-identical and a test keeps it so. -
attested_inference.check_on_receiptno longer raises on a claimed hash without bytes. Found by the type checker in CI on 2026-09-04: withrequest_bytesof the wrong type and an evidence record that claimsrequest_hash, the comparisonNone not in signedwas aTypeError, not a verdict. A claimed hash that cannot be measured is now reported as not measurable, never as a mismatch, and each axis is hashed on its own, so a broken response byte string no longer takes the request hash's measurement with it. -
The 5.1.0 follow-up landed the parts the tag deliberately skipped. The release ran its pre-flight and was tagged without its follow-up so the tag would not wait; that was right for the tag and wrong for the reader. Measured on 2026-09-02, PyPI served 5.1.0 while the site still said v5.0.0. The two superseded receipts from the first attempt on 2026-09-01 moved into
audit_artifacts/510/superseded/with a README that records both the intent and the measurement correcting it — the gate scans withrglob, so they remain rejected candidates and the move is organizational, not a removal.RESTRISIKO_510_NACHTRAG_20260903.mdadds A-P0-2 to the residual risk record as a NEW file: the boundRESTRISIKO_510.mdcarries the sha256 that the pre-tag receipt pins asaudit_output_digest, so an edit there would have broken the attestation for the sake of a paragraph. -
A trust anchor's
not_afternow actually expires, on the register path too — and the register body has an age. Two halves of one mechanism, landed together because either alone is a promise that only half holds. Measured on 2026-09-06:_autorisierte_schluesselreadnot_afterfrom the anchor and never evaluated it, filtering onrolealone. Withnot_after=2000-01-01the key stayed authorised andC12.2reported PASS — while the shipped anchor says, in its own words, that the field is "the last day this key may produce evidence, compared against the artifact'sproduced_at", and the rolereadiness_und_register_signierer_600coversC12.2explicitly. A revocation by loweringnot_afterwould have been inert and looked effective; that is the direction in which an error costs most. The deadline is now compared against the evidence's own measurement time —produced_atfor the readiness artifacts,generated_atfor the register — never against "now", because evidence from yesterday does not become inadmissible just because the matrix runs today. The second half closes the way around the first: the signature coversgenerated_at, so a holder of a revoked key could have back-dated a register into the window where the key was still valid. The register body therefore now carries the same 180-day window and future-skew guard the readiness artifacts already had, and agenerated_atthat cannot be read at all isREGISTER_STALE, fail-closed — an unmeasurable freshness is never a satisfied one. Catch-proof, both directions and both halves: without the fixnot_after=2000-01-01yields 1 authorised key and PASS, with it 0 and FAIL;not_after=2099-12-31still yields 1, so the filter is not simply always-reject;generated_atat 2020-01-01, absent, or in the future each becomeREGISTER_STALE; and the real tree with the real register stays PASS, 20 of 20 evaluated. Readinggenerated_atfrom the not-yet-verified register to pick the key set is safe by direction: it can only SHRINK the authorised set, so tampering with it locks the tamperer out rather than in.Where this commit sits in the order of measurement, stated because it matters. The mutation run and the closing deep-gate round (verdict
FIX_FIRST) both ran ona62d8cb4. This change landed afterwards, on the owner's decision of 2026-09-06 (cardOA-89f05b70cd, option A with four conditions). No gate round has seen it. It is guarded by its own catch-proof and by the suite, not by a round — a named gap in the evidence is better than an unnamed one in the guard. -
The findings register decides a release only if the trust anchor authorises its signer, and only for the version it names. Two holes closed together. The register verifier used to carry its own pinned key inside the module, so the artefact and the thing that authorised it lived in the same place; the authorised set now travels in from the caller, read from
audit_artifacts/readiness_trusted_pubkeys.txtwith the anchor'srole=field deciding which check a key may speak for. A caller that passes no set is refused withREGISTER_UNAUTHORISED_KEY— an unbound caller does not decide a release — rather than falling back on a module default. And the signedversionof the register is bound to the version under test: measured on 049b3195, a register signed3.6.1and generated 2026-07-18 reported PASS for 6.0.0, as did one signed0.0.1and one with no version field at all; the signature was valid in every case, because nothing compared the two numbers. The generator gained the same emit/assemble split the pre-tag receipt already had, so the release signature is produced where the private key lives and never on the build host. -
The mutation gate now compares two numbers from the same test set.
baselineand the closing run used the full suite while each mutant ran without the excluded module, sored > baselineweighed two different sets against each other. The bias ran toward false SURVIVED — an excluded test going red raised only the baseline, and a real kill was recorded as a survivor. Measured with one planted failing test: three of nine operators in shard 1/10 flipped; with the fix and the same planted test, the shard is clean. -
Shard partition is weighted rather than round-robin. The wall clock of the matrix is set by its longest shard, not by the mean; on the first sharded run the shards spanned 931 s to 1232 s around a mean of 1116 s. Operator durations are recorded per run and used to balance the shards.
Documentation
- docs(run-ledger): state the local-chain limit; equivocation across readers is detected only by a witnessed checkpoint (SPEC 7d).
Known issues at the tag
Stated here so a reader of the release notes does not have to open the residual-risk register to
learn them; each has its row in RESTRISIKO_600.md.
- The Rust cross-verifier is experimental and advisory.
tools/pb_verify_rsis not part of the wheel or the sdist (measured: 82 wheel entries, all underproofbundle/; 0 of 1107 sdist entries undertools/). For 6.0.0 it carries no conformance promise:tools/pb_verify_rs/crosscheck.pyand the parity registry are differential-agreement instruments over the recorded vectors, not a correctness proof of either implementation (SPEC, "Independent Rust cross-verification";docs/readiness_pack/rust_parity_scope.md). On the tagged head the crosscheck reportsCROSS-IMPL OKand reproduces 61 of the 110 conformance-corpus cases independently (45 relation vectors differentially); the other 49 are Python-only predicates with no Rust counterpart. Rust conformance is its own milestone, 6.1; the Rust findings of rounds 12 to 14 (an emptysignatureslist classified as malformed by Python and as not-verified by Rust; fiveErr(_)sites that print no reason) are 6.1 register rows. The policy-shape check on the Rust side (a typo made Rust ignore the whole policy with exit 0 where Python refuses with exit 2) landed in the candidate in round 13 with a crosscheck vector, without a conformance claim. C8.2(Python↔Rust differential) is a signed, candidate-bound artefact. The candidate matrix reads it fromaudit_artifacts/; it is produced over the tagged tree and signed by the owner on the key-holding machine together with the other readiness artefacts, so on any head before that signature the check reports the artefact as absent. What is measured on the tagged head without a signature is the crosscheck run above. A constraint on the way to that signature, stated rather than discovered later: emitting the canonical bytes of the readiness artefacts (C6.2,C6.3,C8.2) requires the gate line of a deep-gate workflow verdict over the tagged head (scripts/sign_readiness_artifact.py --gate-zeile-aus-verdikt, copied verbatim, never invented), and the candidate matrix accepts onlyWITHSTANDS_DEEPGATEthere. Rounds 10 to 14 ran as lens-and-jury rounds outside that workflow, and the owner closed the round series after round 14; at the time of writing no workflow verdict over this head exists, so those three rows stay red as known, signature-bound rows (register row S120; owner decisionOA-d29083cead, 2026-09-11, option A). The closing path is decided, not open: after the tag, the classes of the ordered ledger correction get real replayable proof nodes (the owner's decision named two; the pre-sweep of 2026-09-11 15:24Z measures six non-monotone classes, and the measured number is the one that counts), then the deep-gate v4 workflow runs over the tagged head. Green:C6.2,C6.3andC8.2are emitted, signed and published as an addendum to this release and S120 is closed by measurement. Red: a 6.0.1 finding. No round 15 before the signature.- One mutation operator is recorded as NOT MEASURABLE, not as killed (N20). On the round-11
fix head
e8a7f8e, the sharded mutation runmutation (6)killed 9 of its 10 operators; operator 90 (budget: data_digestsceiling practically removed,2_000 -> 2_000_000_000) left no balance line inside its window (915.5 s): the mutated tree's own load-building tests explode before any test can go red, so the tool reports the third state rather than a kill or a survivor. The operator is bounded in the follow-up release. The mutation run on the tagged head, and thegit diff --numstatbetween the last measured head and the tagged head (N11), are recorded in the signature card rather than here. - Register rows for 6.0.1 from rounds 12 to 14 (found in the shipped tree, not changing a
verdict, exit code, bound or security property of the verifier): eight emit-side CLI paths open a
writer-less FIFO without the stat guard the verify side has (
decision emit <fifo>hangs, no verdict;decision verify <fifo>refuses with exit 2);--jsonprints no error object on the exit-2 path of nine subcommands (exit code and stderr are correct, stdout is empty, so an integrator parsing stdout stops rather than misreads);rfc8785has been a core dependency since 3.6.1 while a dozen messages still say "install proofbundle[eval]";SUPPORT.mdsays "the current line is 3.x"; the sd-jwt structure gate folds every parser refusal into the "duplicate JSON key" text (the verdict is right, the sentence is not); the artefact reader of the candidate matrix mapsEACCES/ELOOPto "malformed" although they are the machine's state; the AST riegel that scan for lax decoders and uncapped test load cannot see dynamically computed imports or budget values read from files; the L2 riegel's scan roots omitconformance/. C12.2turns red on 2027-09-07 by design (N21): the only anchor key carriesnot_after=2027-09-06; rotate it before that date or accept the red.- Operator-side gate instrumentation, named because it sits in this tag's evidence chain and
not because it concerns the package: the release-side gate replay reports a
regressionon a ledger-monotonicity check since 2026-09-09 that is an ordered correction (owner cardOA-dcf17fc652, register rowDAS-LIVE-LEDGER-IST-SEIT-DEM-09-09-NICHT-MONOTON-01), and its witness ran 96 of 192 evidence nodes (the other 96 ran once by hand on 2026-09-11, green). Both are fixed after the tag, on the operator's side.
Known limitation of the 6.0.0 artefacts (N15)
Both distributions of 6.0.0 are bit-reproducible as shipped, and this section states the
property with the path to recompute it rather than a digest, because a digest written inside the
tree that produces the artefact is a fixed point nobody can hold: changing the number changes the
tree, the tree changes the artefact, the artefact changes the number. The digests of what is
actually delivered belong in the SHA256SUMS of the GitHub Release, outside the tree — the same
place 5.1.0 publishes them.
How a reader checks it. Export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)", then run
python scripts/build_reproducible.py --outdir dist --with-wheel — exactly the one line
.github/workflows/release.yml runs. Do it twice into two separate directories and compare with
sha256sum. Measured on this candidate: both runs byte-identical, for the wheel and for the sdist.
This instruction changed on 2026-09-07, and following the old one would mislead you. It used to
name two lines, the second of them a bare python -m build --wheel. Since the wheel is canonicalised
in the build path, that bare invocation produces an archive whose ZIP entries carry the mode of their
source files — not what is shipped. A reader following the old wording would compute a digest that
differs from the delivered wheel and could reasonably conclude the artefacts do not reproduce. The
single line above is what the workflow runs and what produces the delivered bytes. Note that SOURCE_DATE_EPOCH is bound to the HEAD commit time, so a
checkout at a different commit legitimately yields different digests; reproducibility here means
"the same tree twice", not "the same number forever".
About the sdist, in four statements, because the earlier wording accused this release of something
it does not do. First: the sdist that ships is the NORMALISED one — release.yml builds it with
scripts/build_reproducible.py, never the raw python -m build --sdist output — and it came out
byte-identical across two independent runs. Second: the RAW setuptools output is genuinely not
bit-reproducible, and the cause is measured to the byte — the sdist path of setuptools 84.0.0
(setuptools/_distutils/archive_util.py::make_tarball, which calls tar.add(base_dir, filter=_set_uid_gid) and normalises uid and gid but not mtime) does not honour
SOURCE_DATE_EPOCH; the variable occurs exactly once in the whole setuptools tree, in the vendored
wheel writer (setuptools/_vendor/wheel/wheelfile.py:53). Each raw archive therefore carries a pax
header with the wall clock at sub-second precision, and the differing number of decimals changes
the pax record length by one byte, which cascades into the header checksum and the compressed size.
Third: whoever builds this project with plain setuptools instead of the shipped path will therefore
NOT reproduce, and that is said here plainly rather than left for them to discover. Fourth: the
normalisation exists precisely for this reason, and tests/test_reproducible_build_361.py has
asserted it since 3.3.1.
Owner decision 2026-09-06 (card OA-b94f677926, option A): the concrete wheel digest comes out of
this entry, the property with its recomputation path takes its place, and the delivered digest goes
where it is not circular. The earlier wording said "the sdist is not [bit-reproducible]" — true of
the raw intermediate, false of what is delivered. A false self-accusation is as wrong as an
overclaim, only in the other direction. The build-backend change remains a 6.1 item with its own
measurement and no time pressure.
It is recorded as N15 in RESTRISIKO_600.md with the same wording, and repeated here so that a
reader of the release notes does not have to open the residual-risk register to learn it.
Adversarial pre-tag audit: verdict FIX_FIRST, and the scope each statement holds over
The adversarial deep gate ran a closing round on this candidate (DEEP, six lenses, seven
iterations). Its verdict is FIX_FIRST, and no WITHSTANDS_DEEPGATE is claimed for 6.0.0.
Three findings were confirmed. All three are recorded as open entries in RESTRISIKO_600.md, and
all three are closed in the follow-up release rather than here — an owner decision, made with the
findings in view:
- N16 (P2) —
action/action.yml:35-36interpolates${{ inputs.version }}and${{ inputs.extras }}directly into arun:shell body, while the step one line below routesinputs.commandthroughenv:and says in a comment why it does so. Measured, and it decides the ordering: the file is byte-identical to the version at the public tagv1.0.0(a8aca8cd, sha256 prefix91cfcdc4ecbab94con both sides), exactly one commit has ever touched it, and that commit is an ancestor of this candidate. 6.0.0 does not introduce the injection, and releasing 6.0.0 does not remove it. Measured separately: a fix onmainalone would not reach the documented users either —INTEGRATIONS.mdpinsaction@v1.0.0, no moving major tag exists, and the one channel that does update by itself (the composite action'spip install proofbundle) does not carryaction.ymlat all; the sdist and the wheel contain zero copies of it. - N17 (P2) —
scripts/rust_parity_gate.pyswallows an unparseable or unreadable source file and then derives its verdict from the ABSENCE of complaints over the resulting population, so a release-deciding check can report PASS over a population that shrank quietly. On this candidate the population is complete — 68 of 68 files undersrc/proofbundleparse and read, and the gate reportsregistry_integrity_ok: truewithuntracked,orphanedandstaleall empty. What is open is the capability, not its occurrence. - N18 (P2) —
pip install <sdist> && pytestWITHOUT the[test]extras is RED, not skipped, while the shippedpyproject.tomlpromises that a bare install "degrades to clean skips". Measured: 1 failed, 3075 passed, 482 skipped. Either the promise is kept or the wording inpyproject.tomlis corrected; that choice belongs to the follow-up release, and until it is made the promise is the thing that is wrong, not the test.
The scope each of these statements holds over. Three figures, named here rather than left to be inferred, because a verdict that rules over an excerpt without saying so cannot be checked by a reader:
-
2537 of 3702 tests, measured at
59d0679. The mutation gate collects its population withunittest discover, which sees only methods ofunittest.TestCase; 59 of 252 test files carry pytest functions only and are invisible to that collector. Every mutation statement of this run holds over that subset and not over the suite (N19).The commit is part of the figure, and here is why. The suite kept growing after that measurement: the closing-round fixes added test files, so a re-count on the tagged tree returns a larger denominator (3727 collected at the time of writing, 254 test files). The ratio the mutation gate ruled over is a property of the tree it ran on, not of the release, and a bare number here would silently claim otherwise — the same stale-metric failure that
tests/test_docs_truth.pyexists to prevent forREADME.md, just outside that test's scope. The figure therefore names its commit. A reader who wants today's number runspython -B -m unittest discover -s tests(the gate's own invocation) against the tagged tree. -
The coverage of the class-ledger replay: NOT MEASURABLE, and an earlier draft of this section said otherwise. That draft carried "94 of 182 classes" here. The pairing is withdrawn because the two numbers do not count the same kind of thing: 94 counts CLASSES whose status is
class_closed; 182 counts the pytest NODES the replay executes. Measured on 2026-09-06: the ledger holds 183 effective classes, 94 of them closed, and all 94 carry both evidence fields as real in-repo nodes; the node set is 182 rather than 2 x 94 = 188 because six nodes are shared between classes. It read as a ratio only because the effective class count happened to be 182 as well, until this round's own class was written. What is well defined, with its definition beside it: 94 of 183 ledger classes carry in-repo runnable evidence — a class counts iff its status isclass_closed, which the validator grants only for two DISTINCT in-repo pytest nodes, a live regression guard and a plant-and-must-catch meta test. The other 89 carry no runnable test, and all 89 state why; none is unexplained. That is a property of the ledger's contents. It is NOT the replay's coverage and must not be read as one: the replay set is DEFINED by the closed status, so the ratio cannot say how much assurance the replay leaves unchecked. Answering that would require knowing the class population is complete, which is exactly what is not measured. -
68 of 68 files. The parity gate's population on this candidate is complete. This is the one figure here that is not a subset, and it is what keeps
N17below the release-stopping bar.
The distribution digests in the readiness artifacts are the CANDIDATE BUILD ON a382eae — not the
published package. Owner decision of 2026-09-06 (card OA-b92bd4ff84, option A with three naming
conditions), after the following measurement, which was made before anything was signed:
SOURCE_DATE_EPOCH is taken from the HEAD commit's time, and the tag sits on a later commit than
the build. Measured in a real clone with two worktrees:
| Head | epoch | sdist sha256 | wheel sha256 |
|---|---|---|---|
a382eae (candidate build) | 1788709947 | c4490ac46c80474c… | 58759ce9add58a95… |
one commit later, touching only audit_artifacts/ | 1788710469 | 168d1e4c351a1695… | be66743a1b4dc344… |
Both differ, at identical byte size (1 958 351 / 540 910): the content is the same, only the
embedded timestamps move. audit_artifacts/ is not in the package at all — 0 of 1055 sdist entries,
0 of 82 wheel entries, MANIFEST.in says prune audit_artifacts — so a later evidence commit
changes nothing but the clock. A control build of a382eae in the clone reproduced the real build's
digests exactly, so the measurement measures what it claims to.
Three things follow, and they are stated here rather than left to be inferred:
- What the fields name.
candidate.sdist_sha256andcandidate.wheel_sha256in the readiness artifacts identify the candidate build ona382eae. They are not a statement about the artifact published to PyPI or attached to the GitHub release. - Where the published digest is. The digests of the shipped artifacts are in the release's
SHA256SUMS, outside this tree — the same separationN15established for the wheel digest. - What the gate does with them. Both fields are MANDATORY parts of the candidate binding, and
audit_candidate_matrixrecomputes them from the files present indist/at gate time — never from a fresh build. They are therefore a binding between evidence and candidate, not an assurance about the package a user installs.release.ymldoes not invoke the candidate matrix at all;ci.ymldoes.
The cleaner mechanism — pinning SOURCE_DATE_EPOCH to the candidate commit instead of HEAD, so the
published package carries the same digests — is a change to the release path itself and is deferred
to the 6.1 collection release by the same owner decision.
What "0 open P0/P1" can and cannot say — and why this sentence is here at all. The structured,
signed register audit_artifacts/findings_register_361.json holds 20 entries as of its
generated_at = 2026-09-06T10:27:05Z: 13 closed, 7 open, 0 open P0/P1. That count is a state
at that instant, not a closure. The register's own preamble calls itself the SINGLE STRUCTURED
SOURCE for the count without naming the time cut; the wording is right about what it decides and
silent about when it was taken, and the signed artifact cannot be changed any more, so the
correction lives here.
It matters because the review lane kept running after the signature and found four more, one of
them a P1: not_after was never evaluated on the register path, so an expired anchor key kept
the ability to sign the register — a revocation by lowering not_after would have looked effective
and done nothing. It is closed, in 7eba21e and its two corrections 3385d80 and 2ba939b, and
it is a code path changed after the mutation run and after the closing round; no gate round saw
it. The other three are P2/P2/P3 and stay open; each has its own row in
RESTRISIKO_600.md.
So, plainly: zero open P0 and P1 speaks only about the findings already found. While that P1 was open and unknown, the register truthfully said 0 open P0/P1. This is the sixth instance of one class on a single day — a verdict over an excerpt phrased as a verdict over the whole — and this time it sits in the register itself. The honest form names the excerpt in the same sentence as the verdict, which is what this paragraph does. For 6.1 the register gets a field naming its own time cut and where later findings are recorded, so a machine reader does not have to take it out of prose.
N20 records a mutation operator whose outcome is NOT MEASURABLE rather than killed or survived:
the operator removes the very resource ceiling under test, and the mutated run reached 111 GiB
resident (88.3 % of memory, 1 GiB free) before it was stopped deliberately rather than left to the
OOM killer. Not measurable is its own state; it is not counted as a kill and not counted as a
survivor.
[5.1.0] - 2026-08-31 (the profile a stranger can read · MINOR)
Moved from [Unreleased] on 2026-09-02, because the release ships it
The five sections below described agent-review/v0.1 and its hardening while sitting under
[Unreleased]. Measured on the frozen release artefacts: agent_review is new after v5.0.0,
it is in the sdist and in the wheel, and it exposes 45 public names of which 28 are callables.
A section that says "unreleased" about code the release ships is an under-claim, and an
under-claim is the same honesty failure as an over-claim.
Found by an adversarial lens of the closing gate round. The project's own guard did not catch it
because scripts/check_version_and_changelog.py verifies the heading for the current version,
never its content — it is structurally blind to what the section says. That blindness is recorded
as a finding; this entry is the correction, not the fix for the guard.
Note on scope: CHANGELOG.md is in neither the sdist nor the wheel (measured: 0 occurrences
in each). Correcting it therefore leaves both release artefacts byte-identical, and the sha256
digests pre-registered for this gate round remain valid.
Added — agent-review/v0.1, a signed self-declaration that says so
A pull request often carries "an AI agent helped here, and it was reviewed". Today a reader has to
believe that sentence. src/proofbundle/agent_review.py lets a reader check, offline, that a stated
key signed exactly those bytes and that they have not changed since. It does not make the sentence
true, and every receipt it produces says so in its own limitations and nonClaims blocks — both
structurally mandatory.
Built against an external adversarial read (18 findings). Its core sentence is adopted: a strong signature must not optically harden a weak self-report. Everything below follows from that.
- Field provenance. Every declared item carries an
assurancerung, and v0.1 emits onlyselfDeclared. The higher rungs are refused AT EMIT, not merely reported at verify: a receipt that cannot be produced cannot be shown to anyone.observationsmust be empty for the same reason — Tier 2 and Tier 3 need a witness outside the agent's own workspace, and that is a separate step, deliberately not half-built here. - Exact subject binding. Repository id, PR node id,
headSha,baseSha, reviewed diff digest, andbodyCoreDigest— taken over the body after the machine-managed disclosure block is replaced by a fixed token, because a digest cannot cover bytes containing its own value. Two blocks or an unbalanced marker raise instead of picking a winner. Issues get their own profile. okmeans "usable as proof for the object in front of you". A receipt that is internally sound may still belong to something else, sointernal_consistency_okandokare separate fields and the second one requires an expectation supplied from outside. A warning beside a green verdict does not carry.- Validity is not currency. The offline verifier always reports
CURRENTNESS_UNKNOWN, and that is the point.anchoredAtmust be null: a signature proves the bytes contain a time value, not that the value is externally true. - Coverage and findings.
COMPLETErequires a stated expectation,PARTIALrequires a named gap — "incomplete, but I will not say in what" is as unfalsifiable as an unqualified "complete".findingsTotalseparates listed from recorded and may never undercount its own list. - Ordering.
prepare_body_for_disclosureandreplace_disclosure_blockmake the wrong order hard to take: introducing the first block moves the body core digest, so a receipt emitted before the block exists binds a body that stops existing the moment its own disclosure line is added.
Thirteen conformance vectors under conformance/agent_review/, run by the real runner, one
expectation axis each. Three classifications, and the difference between the last two is the
substance: valid, invalid (produced, then rejected), refused (the producer would not build it).
Added — the visible block is bound, and the chain cannot be taken over
An external counter-reading returned eleven findings against agent-review/v0.1. Four of them are
closed here, and two were exploitable rather than theoretical.
disclosureCoreDigest.bodyCoreDigestreplaces the entire disclosure block with a token, which is right for its own job and wrong as a statement about the block's content. Measured: editing the visibleselfDeclaredintoindependentlyWitnessedleft every digest unchanged and verification green — a reader was told something the signed object does not say. The new digest covers the block and excludes only what cannot appear inside its own preimage. Required in v0.2, optional in v0.1, because requiring it there would invalidate receipts that already exist.- The receipt chain cannot be taken over by a stranger.
resolve_receipt_chainlet any envelope in the given set mark another as corrected. Measured attack: an outsider signing with their own key added one envelope naming our receipt's digest; afterwardscurrentpointed at theirs, ours was listed as corrected, andintegrity_okwas true. Verifying signatures elsewhere did not help, because the ordering happened first and was already poisoned.verifiedis now a required keyword argument with no default; a rejected takeover attempt is reported rather than dropped. - Coverage rejects three shapes it used to accept. A boolean run count (
isinstance(True, int)is true in Python, so the check was correctly written and still blind), a negative count, andCOMPLETEover zero expected runs.COMPLETEnow also has to name its sources, its window and its collection method. - Time is separated by source and by assurance.
agent-review/v0.2moves business times intodeclaration.timeClaimsand reservesobservedAtfor an observation by a separately named witness. A self-declared review time cannot satisfy a freshness policy; a verified RFC 3161 timestamp raises the signature axis and leaves the event axis untouched, because it proves that bytes existed, not when a review happened. The two verifiers refuse each other's version rather than guessing. limitationCodes. Free-text limitations cannot be held against a policy without being read, and what a relying party cannot evaluate it does not evaluate. The codes are derived from the predicate rather than typed, because a hand-set code drifts from the content without moving any digest.
Fixed — the conformance corpus had no runner
The entry above under agent-review/v0.1 says the thirteen vectors are "run by the real runner".
That was written before the runner existed: the corpus held thirteen cases in twenty-six files and
no test executed them. There was a generator that writes them and nobody who reads them. It is
true now, and the sentence stays as written rather than being quietly repaired, because a changelog
that edits its own past claims is worth less than one that corrects them in the open.
The runner fails on an unknown expectation shape instead of skipping it, and for every counter-proof that can be defused it checks that the verdict FLIPS once the defect is removed — without that, a corpus can consist of cases any validator passes.
Added — our own entry in a third-party transparency log
tests/fixtures/anchors/markovian_log/submit_7727 covers the half its neighbour said it could not:
proof_7271 verifies a proof the log issued about its own stream statement and names the gap in its
own words — the public POST /submit path is not exercised there. It is now, with an entry we
submitted. The inclusion path recomputes with standalone RFC 6962 (plain hashlib, written from the
spec, not proofbundle.merkle), and the recomputed root is byte-identical to the root in the signed
checkpoint that first covered it.
Fixed — _http had half a fallback
_http returned on the first HTTPError instead of falling back to GET, while its own docstring
described exactly that case. Measured live: a health endpoint answering HEAD with 501 and GET with
200 counted as dead.
Added — receipt envelope profile and its conformance vectors
docs/RECEIPT_ENVELOPE_PROFILE.md and the ten vectors under conformance/envelope_profile/
landed on main in #159. This release is what makes them reachable for anyone who is not
reading the repository directly.
Fixed — the profile was not in the distribution, and no rule had removed it
Measured before the fix: docs/RECEIPT_ENVELOPE_PROFILE.md appeared 0 times in the
sdist and 0 times in the wheel built from main at 27a84db3c6dc. The vectors were
already complete — 21 of 21 files, 10 of 10 case.json — because graft conformance covers
them.
The cause is worth stating precisely, because the obvious explanation was wrong. No
prune and no exclude rule matched the file. MANIFEST.in is an allowlist, and the file
was simply not on any line: it was missing by absence, not by exclusion. Looking for the
offending prune rule would have found nothing and left the document out.
One include docs/RECEIPT_ENVELOPE_PROFILE.md line fixes it. Measured after the fix: 1
occurrence in the sdist, the file readable from the archive at its full 240 lines, docs
entries in the sdist 19 → 20 — exactly one more. The wheel still carries 0, and that is
correct: the wheel is the import package and prose does not belong in it.
Why this matters beyond packaging: the profile says a stranger can recompute the result. A profile that ships its vectors without the document explaining what they prove asks the stranger to take the explanation on trust.
Added — mutation operator freshness
scripts/mutation_operators_fresh.py. It is a script, not a gate. It reports whether the
mutation operator set has gone stale against the code it is meant to mutate; nothing blocks
on its output.
Why MINOR and not PATCH or MAJOR
Measured against tag v5.0.0 at the release candidate cece0cc: 72 commits, 162 files,
14 498 insertions, 150 deletions, 0 files removed. Across all of src/: 0 removed public
names, 0 removed or changed signatures. New shipped material (the profile document, ten
vectors, and agent_review with 45 public names) rules out PATCH; nothing removed and no
signature changed rules out MAJOR.
Corrected on 2026-09-02. These figures previously read "23 commits, 41 files, 2071
insertions" and "one new public function". Those numbers were true for 1cfb4dd (31 August) and
were never re-derived as 48 further commits landed. An adversarial lens of the closing gate round
measured the difference. The conclusion (MINOR) was and remains correct — the reasoning behind
it was measuring a tree that is no longer the one being released.
Changed — automation_summary now blocks on a receipt that is not ok
automation_summary has been public since 5.0.0. It now adds RECEIPT_NOT_OK to blockers when
the result carries ok and that value is not True, so safeForAutomation becomes False.
Measured on this release: ok=False → safe=False; ok=True → safe=True; ok absent →
safe=True, unchanged, because a caller who runs no ok check must not be blocked by one.
The change can only ever be stricter: it can stop an automated action that was previously
allowed, never allow one that was previously stopped. It exists because a fourth surface was
measured returning ok=False, safeForAutomation=True, blockers=[] — a summary more lenient than
the verdict it summarises.
Reach, measured rather than assumed. automation_summary is called from 17 sites across
seven modules: agent_review (4), automation_verdict (2), decision (2), outcome (2),
run_ledger (2), trust_pack (3), verification_summary (2). Every one of them sets ok on the
result it passes in, so every one of them can now report safeForAutomation: false where it
previously reported true for the same input. An earlier draft of this entry said the change was
confined to agent_review; an adversarial lens measured that it is not, and this sentence is the
correction.
It is listed here because RELEASE.md requires it: the CHANGELOG entry says explicitly whether
semantics change. This one does, and the earlier version of this section did not say so.
[5.0.0] - 2026-08-25 (the cap runs before the work it bounds · MAJOR)
Added — reported-version status (additive; no further major bump)
Every provenance field carrying a harness-reported version (harness_version, task_version,
promptfoo_version) now also carries <field>_status with the literals reported /
not_reported / not_bound, plus a <field>_status_reason that is mandatory whenever the
status is not reported.
The gap this closes. Until now such a field was simply ABSENT when the harness reported no version. Absence therefore meant two different things — the harness ran and reported nothing and no harness was bound — and the receipt did not say which. For a verifier that is the failure class the product exists against.
Not a boolean, deliberately. A boolean has three states of its own (true, false, absent) and would move the ambiguity one level up.
Backwards compatible. The version field itself is unchanged: when nothing was reported it
stays absent, and no value the harness never reported is ever written (the contract
test_missing_version_field_stays_absent_not_invented is untouched). Existing receipts remain
valid; the status is additive metadata. The status is never derived, and not_reported never
folds to PASS.
Verifier side: version_status_issues() rejects an unknown literal, a missing mandatory reason,
and a status/field contradiction in either direction. Conformance vectors:
conformance/provenance/version-status-* (one per status value, one per rejection class).
MAJOR (SemVer), and there are TWO independent triggers. Either would carry the increment on its own; both are recorded because a release note that names one and omits the other invites the reader to assume it read the whole picture.
Trigger 1, the exit-code class: an input class that previously exited 2 now exits 1.
SPEC.mdis normative under RFC 2119 and documents exit 2 as malformed / usage error and exit 1 as crypto failure / verdict. A caller who branches on those numbers takes a different branch for this input class from this release on. Semantic Versioning 2.0.0 requires a MAJOR increment for any backward-incompatible change to the declared public API, and a declared exit-code contract is public API. The change itself is deliberate (Owner decision 2026-08-18) and is not being reconsidered here — only the number that carries it.Trigger 2, a threshold is now required for a verdict. The Inspect lifecycle hook and the pytest plugin previously defaulted
PROOFBUNDLE_THRESHOLDto"0", which madepassed: truefor any non-negative score — a verdict that could not fail by construction. A measured run scoredmean 0.0with both samples wrong and its receipt still said passed. Both integrations now require the variable and skip emission with a clear message when it is unset.Measured, both sides, before the decision: exit codes do not move (both
0on a trivial green test), but the contract surface does. Under identical conditions the previous release writes a signed receipt and this one writes none. A caller who relied on getting a receipt gets nothing — and for a verification tool that is a break, whatever the return value says. The migration is one line:export PROOFBUNDLE_THRESHOLD=0for anyone who wants binding without a verdict.What does NOT change: no verdict flips. The affected input is simultaneously over the cap and carries invalid base64; it could never verify under any release. Nothing that verified before stops verifying, and nothing that failed before starts passing.
Support line: the 4.x line ends here. There is no
release/v4.0.xmaintenance branch, and fixes will not be backported to it. Saying so plainly is the honest option:SECURITY.mdgives fixes to the latest released minor of the current major line, and announcing a maintenance branch we do not intend to serve would be a promise with nothing behind it.The pre-tag audit is PENDING for this scope. An earlier run reached
WITHSTANDS_DEEPGATEon9bc179eafter four rounds — but that commit predates the scope decision recorded below, and a record over a tree that has since changed is worth nothing. The gate runs again over the final scope, and the record underaudit_artifacts/500/is what counts for the tag. What follows describes the earlier run and is kept because its findings are real and were fixed as classes; it is not the release attestation.Earlier run:
WITHSTANDS_DEEPGATEon9bc179e, after four rounds. The six falsification targets were frozen before the first run in audit_artifacts/500/PRE_REGISTRATION_DEEP_500.md; the outcome of all four rounds is in audit_artifacts/500/DEEP_RUN_RECORD_500.md.The first three rounds returned
FIX_FIRSTwith real findings — a CI gate attesting readiness from another release's evidence, an unbounded integer magnitude on three exported verify surfaces, and its neighbour on the rendering axis that the second round's own sweep had not asked about. Each was fixed as a class. The fourth round found nothing that survived the three-juror refute-to-kill.
WITHSTANDS_DEEPGATEmeans ready for the Owner's tag, not "released" and not "bug-free". One reproducible candidate that did not survive the jury is recorded in the run record rather than dropped, and the round's ledger coverage (80 of 140 learned classes) is stated there too.
Changed
- BREAKING. A threshold is now required for a verdict.
PROOFBUNDLE_THRESHOLDno longer defaults to"0". The Inspect lifecycle hook and the pytest plugin skip emission when it is unset, with a message naming the reason. Previously a default of0made every non-negative score pass; a measured run scoredmean 0.0with both samples wrong and its receipt still readpassed: true. The published claim schema is untouched —thresholdandpassedremain required claim fields. Migration: set the threshold explicitly. - The
merkle_pathcap now runs BEFORE the decoding it bounds, on all three surfaces that carry an inclusion proof:verify_bundle,recompute_merkle_root_b64, andverify_sample_opening. Owner decision 2026-08-18 unifying the class on the budget module's documented house pattern ("the cap, then the work"). Measured on a 195000-step proof: the base64 decoder was called 195002 / 195004 times before the refusal, and the time scaled with the size of each proof element (365 ms -> 5714 ms at 32 -> 3200 bytes per step). It is now called 2 / 4 times, and the time no longer scales with element size at all. Verdicts do not change: an over-cap proof could never verify, so this removes work, not acceptance. One error CLASS changes: an input that is simultaneously over the cap and carries invalid base64 previously surfaced as a format error (CLI exit 2) and now returns a verdict (exit 1). That consequence is what caused the first attempt (2c52596) to be reverted before the 4.0.0 release; the Owner has now decided it deliberately. checkpoint_noterefuses an empty Merkle root. An emptyrootencoded to an empty third note line, whichverify_checkpointand_note_text_ofboth reject as malformed — the emitter could sign a note that no verifier accepts. The realistic path wasroot_bytes_from_b64(""), which returnsb""rather thanNone. A non-empty root of the wrong length still round-trips and is unchanged.save_signervalidates its path argument the way its siblingload_signerhas since 2026-08-16: a non-path raises a typedBundleFormatErrorinstead of a rawTypeErrorfromos.open, and the private seed is no longer materialised before the check.
Added
capture_mechanismin signed provenance.from_inspect_ai_log(..., capture=...)records how a receipt came into existence:lifecycle_hook(livedata.logread inside the producing process),lifecycle_hook_log_reread(hook-triggered, log re-read from disk) andpersisted_log_reader(the reader default). Previously a hook-emitted and a reader-emitted receipt were byte-indistinguishable, so a verifier could not see how the evidence arose.- lm-eval binds its framework version.
provenance.harness_versionis now taken from thelm_eval_versionfield of the results file. If the field is absent the provenance field stays honestly absent — nothing is invented. This closes an asymmetry: promptfoo boundpromptfoo_version, Inspect boundharness_version, lm-eval bound nothing. --expect-issueronshow-evalpins the accepted signer, repeatable for key rotation, and compares against the verified signature key. A mismatch prints a clear message and exits 1. Opt-in and backwards compatible. Adversarially motivated: a flipped receipt re-signed with a fresh key previously passed withrc=0under self-attested scope.verify_witnessed_checkpointandverify_tlog_proofresults carryexpected_origin_wellformed(True/False/Nonewhen no pin was supplied), andverify-proof --jsoncarries it on every invocation, including the fail-closed paths. It reports whether the caller's own--expected-originsatisfies the printable-ASCII rule the log side already enforces. The comparison itself is unchanged — a malformed pin still produces a verdict, not an exception, because a near-miss must stay distinguishable from a signature failure.
[4.0.0] - 2026-08-18 (origin-quorum rule · printable-ASCII identities · MAJOR)
MAJOR (SemVer): the printable-ASCII identity rule now refuses a non-ASCII (IDN/Unicode) origin or witness name for the verifier's exact identity compare — a deliberate behaviour change at the public verify interface (detailed under Changed). A pre-tag adversarial deep-gate (DEEP 6L/7I) was run on this digest; its record and attestation are in audit_artifacts/400/DEEP_RUN_RECORD_400.md.
Semantics: changed, in one deliberate, fail-closed direction — the origin-quorum rule.
Changed
- Origin-quorum rule: a log does not vote in its own witness quorum.
checkpoint.witness_quorum(and with itverify_witnessed_checkpoint,tlogproof.verify_tlog_proofand the experimentalpublic_transparencyprofile, which all share it) now excludes a cosignature when its key material equals the audited log's own signing key, OR when its name equals the origin line — fail-closed, before any signature math. The excluded entry stays visible in thewitnessesdict asok=Falsewithorigin_excluded=Trueand adetailsentence naming which test fired. Two operands, chosen because neither is the log's to pick: the caller (which knows the log) passes its key material; the origin line is the note's own first line. The key-material test is the robust, algorithm-agnostic one; the name test is exact-codepoint (robust for ML-DSA-44, whose signed message binds the cosigner name — Colin's live vector — and defence-in-depth for Ed25519, whose cosignature/v1 message does NOT bind the name and so can be relabelled). Verdict change, named precisely: a roster listing a cosignature made with the log's key (under the origin name or any alias) could previously satisfy part — or, atthreshold=1, all — of the quorum with the log's own signature; measured 2026-08-16 with a live probe (witness_quorum(threshold=1) -> Truefor a self-cosigned mini-log), decided with the operator in issue #7, and re-gated 2026-08-17 after an adversarial pass showed a name-only rule was bypassable (an alias, or a zero-width character in the origin line). Honest limit, documented at the call sites: a log cosigning with a SEPARATE key under a non-origin alias that a relying party wrongly trusts as an independent witness is roster provenance, not a local check. The C2SP specs are silent on self-cosignature (checked 2026-08-17), so the verifier holds the line. Rosters without a log-key or origin-named cosignature — including every vector previously shipped in this repository — keep their verdict bit for bit. witness_quorum'slog_key_materialis now a required keyword-only argument (pre-tag deep-gate D1, BREAKING for a direct caller of the primitive). It defaulted toNone, so a barewitness_quorum(note, roster, threshold)silently ran the name-test-only mode — under which a log cosigning under an ALIAS with its own key (name ≠ origin) was counted toward the quorum, because the robust key-material prong had nothing to test against. The three shipped verification surfaces (verify_witnessed_checkpoint,verify_tlog_proof,public_transparency) always passed the material and are unaffected; the change forces a direct caller to STATE the choice — the log's key material for the full rule, or an explicitNoneto opt into the documented name-only mode. No silent weak default.- Origins and witness names must be printable ASCII (re-gate 2026-08-17). An adversarial re-gate
showed the name test was bypassable one character class at a time — a zero-width (Cf), then a NBSP
(Zs), then a variation selector or Default-Ignorable letter (Mn / Lo), then an appended plain space —
because the compare used an operand the log writes (its own origin line). The durable fix is a
POSITIVE, non-enumerated rule:
checkpoint_note,verify_checkpointand the shared note parser require the origin to be printable ASCII with no leading/trailing or double space (a single internal space stays legal for Go sumdb'sgo.sum database tree);_parse_witness_vkeyand the emit path require a witness name to be printable ASCII with no space at all. None of the cloaking characters is printable ASCII, so the whole look-alike class is closed at once. Fail-closed; measured against every shipped external vector (Go sumdb, Rekor, rootcommit, Colin's fixtures) — all pass. Deliberate, documented restriction: a non-ASCII (IDN/Unicode) origin is now refused for the verifier's identity compare; no real tlog origin is non-ASCII. This also closes the NFC/NFD normalisation question at the root — a decomposed non-ASCII identity cannot be built or verified at all. The rule covers all THREE identity slots (re-gate: the first cut hardened origin and witness name but not the log key name — the third):key_id,cosign_key_id,cosign_key_id_mldsa,sign_checkpointand_parse_vkeyall require a printable-ASCII name now, so a surrogate name can no longer raise a rawUnicodeEncodeErrorout of the public verify API, and a zero-width log key name can no longer substitute for a real one. Honest limit named precisely: the name compare is exact bytes, so byte-different forms of the SAME identity — an ASCII case variant (DNS is case-insensitive), an FQDN trailing dot, a path-normalisation form — are not caught by the name prong (they are the same owner, not a look-alike); the robust defences for them are the key-material prong andexpected_origin. Exactness is kept deliberately (normalising would loosenexpected_originacceptance, whose safe direction is the reverse). public_transparencywitness-quorum fails closed on an unusable log_vkey (re-gate F-9). Alog_vkeythat is supplied but malformed is now "not measurable", a THIRD state distinct from "no log context" — treating it as no-context silently switched the key-material exclusion off and let the log vote in its own quorum under an alias witherrors=[]. A relying party that SUPPLIED log context and had it dropped is exactly the hidden fail-open; it now fails closed with a named error. (_log_key_material_ofitself still never raises — re-gate F-7 — but itsNoneis no longer read as a pass.)verify-proofcarries the exclusion reason on BOTH output paths.--jsonprojectsorigin_excluded+detailper witness; the human text path prints an indented reason line per non-verifying witness that has a detail (re-gate F-6 — the reason previously existed only in the library and, on the text path, in--json; the reader most likely to act saw nothing). The pre-existing[pq]-missing detail rides along on both._log_key_material_ofnever raises (re-gate F-7). It now catches theValueError/TypeErrorbase families, not justBundleFormatError, so a lone-surrogate log-vkey name (which reachesname.encode("utf-8")→UnicodeEncodeError) can no longer escape as a raw traceback out of thepublic_transparencyfail-closed surface. Honest limit kept: thepublic_transparencywitness-quorum path applies the key-material exclusion only when alog_vkeyis supplied (it is optional there, and the profile is EXPERIMENTAL); the always-wired surfaces (verify_witnessed_checkpoint,verify_tlog_proof) always pass it.- The whole checkpoint note body is validated UTF-8-safe before it is encoded (pre-tag deep-gate D2/D3,
class fix). Two parsers encode the note body — origin, size, root AND the optional C2SP extension lines
— to sign or verify over it:
verify_checkpoint's own, and the shared cosignature-path parser_note_text_of. Both encoded BEFORE checking the body was UTF-8-safe, so a lone/unpaired UTF-16 surrogate anywhere in the note (astrsurvives splitting but is not valid UTF-8) raised a rawUnicodeEncodeErrorout of a public verify surface. An independent pre-merge fix-review caught that a first cut had fixed onlyverify_checkpoint, leaving the shared parser — so a surrogate in an EXTENSION line still crashed the top-levelverify_cosignature,evaluate_public_transparency's witness-quorum branch, andcosign_checkpoint. Both parsers now validate the whole body first (_note_text_ofonce, for every consumer); the encode fails closed with a typedBundleFormatError, never a raw traceback.verify_witnessed_checkpointandverify_tlog_proofwere already shielded (call-ordering / a broadexceptthat already caught theValueErrorsubclass). A fourth adversarial pass then found the note-body FIELDS still returned unvalidated: the ML-DSA cosigned message excludes extension lines by spec, but the ML-DSA cosign function (cosign_checkpoint_mldsa, a public witness-signing surface) — unlike the verify surfaces, which re-validate them — fed the raw size/root lines intoint(size_s).to_bytes(8)andbase64.b64decode, raising a rawbinascii.Error/ValueError/OverflowErroron a non-base64 root or a non-decimal / negative / ≥ / over-long size (the CVE-2020-10735 integer-string DoS class)._note_text_ofnow validates the size (uint64 decimal, no leading zeros) and root (standard base64) too, so every consumer of the shared parser is closed. - Every public constructor/producer surface validates its argument TYPES, not only their content (pre-tag
deep-gate iter5). The identity helpers (
_origin_wellformed,_witness_name_wellformed) and the tlog-proof producers checked a string's content but never that it WAS a string, and the key/root/proof/extra byte arguments were unguarded — so a non-str / non-bytes / non-dict CALLER argument (a JSON field that came backnullor numeric from an upstream contract violation) raised a rawAttributeError/TypeErrorout ofcheckpoint_note,key_id,vkey,sign_checkpoint, thecosign_*family,witness_quorum,format_tlog_proofandtlog_proof_for_bundle, instead of the documented typedBundleFormatError. Found by a fifth, completeness-critic adversarial pass; the verify surfaces were already safe (they derive identity from a prior split).isinstanceguards — the same the parse helpers already carried — now close the whole caller-contract class, confirmed by that pass's own ~1,830-probe battery reporting 0 raw-exception escapes across every producer and verify surface.
Added
-
The
markovian_log/proof_7271fixture now verifies 8 of its 11 signature lines (up from 6): the two ML-DSA-44 witness cosignatures (navigli6bc44249, ring-any-bells5774b075) are covered by operator-published verifier keys fetched from outside the audited log (witness.navigli.sunlight.geomys.organdtransparency.dev/witnesses, digest-frozen inSOURCES.md), each verified independently against the frozen checkpoint with positive and bit-flip counter-probes before being carried. Without the[pq]extra those two lines count as non-verifying (fail-closed) and the fixture still verifies 6 of 11 — now a measured statement inMANIFEST.jsoninstead of a wrong guess (the pre-3.8.0 wording blamed the missing backend; the backend was never the reason). The three remaining unverified lines carry their measured reasons: two Ed25519 witnesses deliberately not carried, and the log's own origin-name ML-DSA line, which no independent source can key today and which the origin-quorum rule would refuse to count anyway. -
A live self-signed checkpoint as the origin-quorum test vector:
markovian_log/checkpoint_7397/freezes the log's checkpoint at tree size 7397 (fetched 2026-08-17, digest-pinned), which carries the log's own name twice — as its Ed25519 note signature (excluded from witness quorums by the existing 0x01/0x04 domain separation) and as an ML-DSA-44 line in cosignature shape (excluded by the new rule).tests/test_origin_quorum_rule.pyholds both halves plus the self-cosigned mini-log regression probe from the 2026-08-16 report. -
A killing test for the NFC-origin mutation operator. The gate's frozen fixture origin is pure ASCII, on which NFC is the identity, so no
--expected-originagainst it could distinguish an NFC-normalising origin compare from the exact one — the operator survived as an UNEXPECTED gap. Closed with a self-signed checkpoint whose origin carries aKand a KELVIN-SIGN (U+212A) near-hit (NFC(kelvin)=='K'): exact rejects, NFC accepts. Verified against the planted operator line (log_okflips False→True under it) — red on the mutant, green on real code; the operator stays should-kill.
[3.8.0] - 2026-08-16 (CLI origin pinning, corpus fixture, BETA, relation EXPERIMENTAL)
Aus der
[Unreleased]-Rubrik aufmainuebernommen (Merge 2026-08-17). Diese Rubrik trug Aenderungen, die aufmainlagen und damit in 3.8.0 ausgeliefert werden — gemessen im Baum dieses Zweigs (typisierte Fehler inevalcard/preregvorhanden,json_nodesim Budget). Sie als "unveroeffentlicht" stehen zu lassen waere falsch geworden, sobald dieser Tag faellt; der Wortlaut ist unveraendert uebernommen, nur die Rubrik-Ueberschrift entfaellt. Die###-Rubriken darunter stehen am ENDE dieses Abschnitts, damit sie nicht mit den gleichnamigen Rubriken dieses Release verwechselt werden.
Semantics: unchanged. Resource ceilings: one deliberate tightening, disclosed below.
This banner said "Nothing under src/ changes" until 2026-08-08. That was false by then. Measured
at bc3ae70 with git diff --numstat origin/main HEAD -- src/proofbundle/: 8 files, 196
insertions, 3 deletions. The sentence was written when it was true and was not pulled when the tree
moved past it — a statement nobody re-measured.
The first correction of this banner then repeated the fault it describes. It claimed "204 lines", a
figure already 9 off when it was written and 17 further off after the revert below landed. A count
against a moving branch is only true at a named ref, so this one names its ref and its command.
Found by the mandatory review lane, both times, and not by a check — scripts/check_version_and_changelog.py
reads only headings, and the release-scope checkbox in RELEASE.md is read by a human,
not by a gate.
What actually changed, and why each is patch-safe:
- No public interface gains or loses a field, and no verdict flips from fail to pass. Every change below is fail-closed: input that was accepted and is over a generous ceiling is now refused before the work it would cost.
- Structural budget on the direct-dict path. Six public surfaces that accept an already-parsed
structure now apply the same
VerificationBudgetceilings the string/file path has always applied (string_len1 000 000,json_nodes200 000). On that path theinput_bytescap is inert — there are no bytes to measure — so those surfaces were unbounded. This is the same deliberate exception the project shipped in 3.2.3 (Finding 15b) and is disclosed here for the same reason: COMPATIBILITY.md requires that a tightening of a previously accepted input say so explicitly. Each surface reports it in its own documented failure form — a result dict where the surface returns dicts,BundleFormatErrorwhere it raises — so no new exception type appears anywhere. - Withdrawn before release: moving the
merkle_pathcap earlier. An earlier commit in this cycle moved themerkle_path(256) check inverify_sample_openingahead of the base64 decode. It was reverted, and this entry records why rather than dropping it silently. The claim it originally carried here — that the outcome is unchanged for every input — was measured false. Method, since the repo asks every number to name its object and its source: two worktrees at the commit and its parent, the sameverify-openinginvocation against each, exit codes compared per input class. The CLI exit code moved from 2 to 1 whenever any proof element, orroot_b64, would have been rejected byb64decode(validate=True). Two independent partitions were counted — one gave at least 12 diverging classes, an independent re-count gave 22; "input class" is not a defined unit here, so the lower bound is what the claim rests on. The verdict itself never flipped (okstaysFalse), but COMPATIBILITY.md lists the meaning of exit codes as a public surface, and this project already keptstash@{0}out of 3.7.1 for the same reason. The change also did not achieve what it was for: anOmega(n)structural budget walk runs one line above the cap, so the cap cannot precede the work it bounds. Peak memory at n=190000 was 11867 KiB against 2.2 KiB at n=257 — a linear path, not a flat one. Wall-clock figures for the same runs are deliberately not quoted: they were host-dependent and differed by 28% between two measurements of the same code. The underlying finding stays open for a minor release. - Typed errors on two path arguments.
evaluation_card_hashandprereg_hashraiseBundleFormatErroron a non-path argument instead of leakingOverflowError/TypeError/FileNotFoundError. The CLI always passes astr, no test or doc pinned the old types, and the surrounding failure form in both functions was alreadyBundleFormatError.
The planned scope for the next patch is written down in docs/release_scope/3.7.1.md.
Status boundary (No-Overclaim): 3.8.0 remains audit-candidate BETA, relation/v0.1 EXPERIMENTAL. This is a
MINOR release whose one capability change is that verify-proof gains a command-line flag it did not
have. No crypto verdict (.ok) semantics change, and every existing invocation keeps its verdict.
Corrected 2026-08-16, and the correction matters to anyone who parses our output. This sentence said "one behavioural change", which a counter-read measured false: the release changes observable behaviour in four places, of which only the first is the new capability.
- the new flag itself;
verify-proof --jsongained the keyexpected_origin— every invocation now carries it, withnullwhen the flag is absent. A consumer that enumerates keys strictly sees a new one. This is the reason the sentence had to change: "every existing invocation keeps its verdict" is true and remains true, but a verdict is not an output shape, and the earlier wording let one stand for the other;- three text lines (
log-signature,sample-opening,enclave-attestation) now pass their value through the control-character neutraliser, so a value containing an escape sequence prints differently — see### Security; - the
SHA256SUMSfile attached to the GitHub Release no longer carries adist/prefix, and the Release now appears as a draft until the PyPI upload succeeds — see### CI.
None of the four changes a .ok verdict. Listing them is not pedantry: the one class of consumer this
project exists for is the one that automates on our output, and "one behavioural change" told them
they had nothing to check.
Why MINOR, and a retraction. MINOR follows from the rule this project binds itself to: SemVer 2.0.0
§7 requires MINOR for new backward-compatible functionality in the public API, and proofbundle is a
console entry point ([project.scripts]), so a new option on the shipped CLI is exactly that. An earlier
draft of this paragraph argued from repository precedent instead, claiming a user-facing CLI flag had
never shipped in a patch release. That claim is retracted here rather than quietly deleted, because it
was measured false: four patch releases grew the shipped CLI. The count of flags depends on what
is being counted, so both numbers and their rule are given rather than one number without its object —
which is the mistake an earlier draft of this very retraction made. Counting distinct long-option
names that did not previously occur anywhere in src/proofbundle/cli.py: ten —
--expected-root-file --issuer-key --output --policy-id --valid-until (3.1.1),
--checkpoint-vkey --trusted-checkpoint --verification-time (3.1.3), --require-derived-subject
(3.2.2), --eat (3.2.3). Counting added add_argument("--…") lines in the same diffs, which is a
different thing because one name can appear on several subcommands: sixteen (6 / 3 / 2 / 5). Either way
the precedent points the other way; the rule does not, and the rule governs.
The choice is reinforced by a cost asymmetry. A consumer pinned to ~=3.7.0 picks up a patch
automatically, so under 3.7.1 they would silently acquire a verification capability they never asked
for — in a library whose whole purpose is that nothing arrives unannounced. Under 3.8.0 they stay where
they are until they choose to move, and the larger number harms nobody.
Two further corrections to earlier drafts of this section, kept visible for the same reason: the delta
over 3.7.0 is not "one commit touching the shipped package" — 911fd5c and this release commit both
touch src/, MANIFEST.in grafts tests, scripts, schemas, examples, conformance, formal
and docs/readiness_pack into the sdist (23 files over 8 commits changed across exactly those seven
paths — and these are TWO numbers from two commands, which the first draft gave as one: the files come
from git diff --name-only v3.7.0..f64d35e -- <the seven paths> | wc -l, the commits from
git log --oneline v3.7.0..f64d35e -- <the seven paths> | wc -l. Naming only the second under both is
exactly the error this paragraph goes on to correct in the sentence after next — that is WITH merge commits; the
--no-merges count over the same paths is 6, and two of the eight merges carry no change
of their own. The number is given with its command because the paragraph below retracts an
earlier pair that did not), and the dev extra narrows ruff>=0.5 to ruff>=0.5,<0.17 and mypy>=1.8 to mypy>=1.8,<3.
None of that is public API, which is why the version verdict is unchanged, but "for one reason only"
was not accurate.
An earlier draft of this same paragraph said "27 files over 13 commits", and both numbers were wrong in
the same way the sentence above warns about: 27 counts the whole of docs/ rather than
docs/readiness_pack, so it includes four files MANIFEST.in deliberately does not graft, inside a
sentence about the sdist; 13 is the repository-wide --no-merges count over a DIFFERENT endpoint
(v3.7.0..ac0688c), six of which are ci: commits that touch neither src/ nor any
grafted path. So the two numbers differed in THREE ways at once — population, merge policy
and endpoint — and named none of them. A counter-read measured v3.7.0..f64d35e --no-merges
= 14, which is what a reader would have had to guess. Two numbers from two populations, neither of them the
one named. They are corrected here rather than quietly replaced, because that is the same discipline
this section asks of the precedent claim above it.
Added
-
verify-proof --expected-origin(#137,911fd5c):verify_tlog_proofhas acceptedexpected_originsince 1.3.0 (release-review fix #5), but the argparse parser carried no flag and the command never passed one. A command-line verifier therefore could not reject a validly signed checkpoint issued by a DIFFERENT log than the one it meant to trust: the signature check passes, and without the origin constraint nothing else looks wrong. The default staysNone(origin unconstrained), so existing invocations keep their verdict; their--jsonoutput shape does change — see the correction at the top of this section — because the keyexpected_originis now always present,nullwhen the flag is absent. The first draft of this bullet said "existing invocations are unaffected", which conflated the two. On the human path a mismatch now reads(expected <origin>)rather than looking like a broken signature. Covered bytests/test_verify_proof_expected_origin.py: flag discoverable in--help, default unconstrained, matching origin passes, mismatching origin fails closed withinclusion_okstill true, text output names the expectation — plus, added after a counter-read of this release, seventeen near-miss origins that each must be rejected (prefix, suffix, case, whitespace, newline, trailing slash, scheme, domain-only, empty, full-width, trailing host dot, doubled slash, percent-encoding), the positive direction without which an always-false comparison would also be green, and a guard that the four control-character call sites stay wrapped.What the new JSON key does NOT do. It reports what the caller asked, not why the answer is no. Pin the origin you trust — the documented use — and a foreign origin is machine-readable (
expected_origindiffers fromorigin). An earlier draft of the test and the commit message read as if the field separated three causes; a first correction then overshot and said it separated none. Both are wrong in the same way — measured on one construction and reported over another — and the measured form is above.What remained indistinguishable was a wrong
--log-vkeyagainst a tampered signature, and that half is now closed too, by a different mechanism: seesigner_presentbelow. -
verify-proof --jsonnow separates a wrong key from a tampered signature (signer_present). This was a different kind of gap from the rest of this release. Everywhere else the information existed and was dropped one layer before the output; here it looked as if it did not exist at all, because a signature check is a two-input predicate and a mismatch does not attribute blame to either input. The verifier cannot know whether the key is wrong or the signature is.The key ID can. A C2SP signature line carries the signer's key ID, and
verify_checkpointalready made the distinction inside its loop —kid != kid_vmeans this line is not for your key — before collapsing it into a singleok=False. Measured, with the good run as the control: a valid run reportssigner_present: true; a foreign key reports false ("this key did not sign this note"); a tampered signature reports true withlog_ok: false("it signed, but the bytes do not match"). The two outputs are no longer byte-identical.Honest limit, and it is not a weakness of the field: a tamper that hits exactly the four keyID bytes is indistinguishable from a wrong key — at that point the note carries no evidence that this key ever signed. That is a true statement about the situation, not a measurement error.
The guard that pinned the collision carried its own replacement instruction ("if these become distinguishable — good, then the finding is closed and this guard belongs replaced by a positive assurance"). It went red the moment the flag landed and now asserts the separation. That is the difference between pinning a gap and pinning a property: the first must go red when the work is done, or it holds an old state after it has stopped being true.
-
verify --expected-originandverify_witnessed_checkpoint(expected_origin=…)— the same binding on the checkpoint surface.verify-proofgot the origin pin above; its neighbour, the--trusted-checkpointpath, still had none. Both flags are new; the library parameter is new.Measured, not assumed. A C2SP checkpoint's origin line and the name in its signature block are separate arguments to
sign_checkpoint, and C2SP permits one signer to serve several origins. Signing a note whose origin line readsevil.example/other-treeunder a key namedexample.com/logtherefore producedok=Trueunder the trusted verifier key, and its root and tree size were adopted as the authenticated tree context. A relying party who pinned only the key had not pinned which tree was speaking. On the CLI the two verdicts — the honest checkpoint and the foreign one — were byte-identical, bothcheckpointAuthenticity: PASS, and no parameter separated them.The default stays unconstrained (
None), matchingverify-proof --expected-origin: there is no origin a verifier could honestly default to. So this closes the gap for callers who pin, and an unpinned run is unchanged — which is why the unpinned run keeps naming the origin it observed in thecheckpoint-authenticitydetail, and why SPEC.md §9 now states the property normatively instead of leaving it to be inferred. A mismatch reads as a mismatch rather than as a broken signature, and rides the existing rails:cp_okis bound once, sotreeContextAuthenticity,treeSizeExpectationandsafeForAutomationall follow without a parallel code path.The comparison is EXACT on both surfaces, and they are two comparison sites, so the shared near-miss corpus (
tests/_beinahe_treffer.py, also used by kbjwt, statuslist, intoto, evalclaim and policy) runs against both — the library directly, the CLI throughmain(). Six rollback probes were run: loosening either comparison tostartswith, removing either binding entirely, and weakening eitheris Noneto a falsy test (which would silently turn an empty--expected-origin ""from a question that always fails into no question at all). All six turn the guards red, and the baseline returns to exactly 41 passed / 28 subtests.Two more defects, found by reviewing this very change and fixed in it. The first: a pin whose object is absent is not a pin.
verify BUNDLE --expected-origin some.logwithout--trusted-checkpointexited 0 in silence — the caller believes the origin is bound, nothing was checked, and nothing says so. That is the same class this release closes, one level up: the comparison was not too loose, it did not happen. It is now a usage error (exit 2), the rule the neighbouring line already applied to--trusted-checkpoint/--checkpoint-vkey. A sweep over every other value-takingverifyflag found no second member:--expected-root,--expected-tree-size,--audand--nonceare each honoured without a companion flag — measured with a WRONG value, because a correct one cannot tell "checked" from "ignored", and the first version of that sweep nearly reported the opposite for exactly that reason.The second:
--jsonreported the answer but not the question.verify-proofcarriesexpected_originat the top level;verifycarried nothing, so an automated consumer could not tell "pinned and matched" from "not pinned at all" — both yieldcheckpointAuthenticity: PASS. New keycheckpointOriginExpectation, deliberately shaped like its immediate neighbourtreeSizeExpectation(status/expected/actual) rather than as a bare value, because that shape answers all three questions. Four states, all distinguishable; the load-bearing one is that a checkpoint supplied WITHOUT a pin still reports the origin it observed, which is what keeps an unpinned run auditable and what SPEC.md §9 now requires. The key is always present (nullwhen not asked), so its absence never has to be read as "not asked": existing invocations keep their verdict, and their--jsonoutput shape gains one key — the same distinction this section had to correct once already for the sibling surface. -
verify-proof --jsonnow carriesthreshold.witness_quorumreturnslen(confirmed) >= thresholdand the default is0, sowitnesses_okwas unconditionally true when nobody demanded a quorum. A program saw the sametruefor "a quorum was demanded and met" and "no quorum was ever demanded", and no field separated them — countingwitnessesdid not settle it either, because zero confirming witnesses is a legitimate state underthreshold=0, while the same zero under a demanded bound would have madewitnesses_okfalse. The text path has always named it (threshold {T}), so this removes an asymmetry rather than inventing a field: a relying party automating on--jsonwas getting less than one reading the terminal. The verdict itself was never wrong; what was missing was the legibility of the answer.The key is always present, because it always has a value. The family was measured over every value-taking flag rather than assumed:
--expected-tree-sizewas already in the richstatus/expected/actualform,--verification-timeappears once set (and its absence means "now", a different requirement rather than an absent one), andverify-opening's--n/--kare required arguments with no absent-requirement case. One member, and this was it. Rollback probes: removing the key turns three guards red, and wiring it to a constant0— which looks filled — turns one red. -
verify-proofnow says WHICH question failed —detailreaches both output paths. A verifier that cannot read its own input must not report that in the shape it uses for a completed evaluation that came out negative: not measurable is not measured no. Measured: an empty proof file — the artifact really is not a proof — and a malformed--log-vkey— the verifier's own typo — produced byte-identical JSON. The relying party reads a verdict about the artifact and goes to investigate the artifact, while the fault is on their own command line. On the text path it was worse: a bad key printed[FAIL] log-signature: None, naming the one thing the operator will now go and look at, when nothing had been checked at all.Nothing was invented. The library already carried a precise cause for each case — no empty-line separator before the checkpoint, vkey must have 3 '+'-separated parts — and
cli.pysimply did not copydetailwhen it listed the keys. The information existed and was dropped one layer before the output. It is now in the JSON (always present,nullon the green path) and on the text path as areason:line. All four causes produce pairwise distinct stdout, with the good run as the control.Two notes kept honest rather than tidy. The
_safe_lineon the text line is precautionary: two of the four causes interpolate an exception message whose forms cannot be enumerated, but three probes (ESC, newline injection, NUL) produced no control character, because the parse errors are library-authored — so this is not a measured leak. And the finding behind this reported three colliding causes; re-measuring while closing it gives two, because--threshold -1now separates thanks to thethresholdkey added above. That is an effect of work done in between, written out rather than silently renumbered.
Fixed
-
load_signerno longer reads a file descriptor when handed an integer.open()accepts an int as a file descriptor, soload_signer(123)did not fail on the wrong type — it read whatever happened to be open on fd 123 and tried to make an Ed25519 private key out of it. A wrong-typed argument silently reaching an unrelated open file is a worse outcome than a crash. The primary argument is now type-checked and a non-path raisesBundleFormatError.The guard is held by a test that proves the descriptor is not read — it is still open afterwards — rather than merely that something was raised, and the opposite direction (
str,Pathandbytespaths still load) is pinned alongside it, because a guard that also blocks the correct call is not hardening.How it was found is the more useful half. The never-raise family property walked a hand-maintained list of 36 modules while the package ships 62; it was correct over the set it walked, and that set was smaller than the set it was read as covering. It now enumerates its family from the tree (
pkgutil.walk_packages, subpackages included), which raised the swept surfaces from 79 to 91 — andemit.load_signerhad never been in it. Verified both ways: a planted raw raise inanchors_ots(outside the old list) now turns the property red, the same plant inanchors(inside it) still does, and restoring the list makes the first one pass green again.Two smaller things came with it, both kept rather than smoothed over. The property crashed with a traceback when a surface terminated with something on neither its accepted nor its forbidden list; a blocklist over an open alphabet means unclassified, not permitted, so that is now its own reported category. And accepting
OSErrorbecame necessary once a path-taking surface joined the family — the file is not there is an honest typed answer for a loader — but that same line would have re-hidden the fd hazard, so the hazard is closed at the surface and pinned by its own test instead of by the list. -
The markovian_log fixture recorded the wrong reason for its unverified ML-DSA-44 lines (#138,
03bf127). This is a correction of a claim, not a feature.MANIFEST.jsonand the fixture README said the three ML-DSA-44 lines were unverified because that "needs the optionalproofbundle[pq]backend". That reason was never checked and it is wrong: the fixture carries six verifier keys and none of them is ML-DSA-44, so there is no public key to recompute against and no backend can change it. Two of the three missing keys would come from the witness operators (navigli, ring-any-bells); the third carries the log's own origin name, so an independent key for it cannot be sourced without leaning on the audited log — the one dependency this fixture avoids everywhere else. The wrong sentence survived because a test pinned its wording rather than the fact behind it (test_manifest_declares_no_overclaimasserted the stringproofbundle[pq]appeared in the purpose field). That assertion now pins the measured reason, andTestMarkovianLogMldsaKeysAreAbsentmeasures the underlying fact in three tests: no carried key has the ML-DSA-44 algorithm byte, each ML-DSA line's key ID is absent from the carried set, and exactly one of the three carries the log's own origin name. Independently re-measured on a machine with the post-quantum backend installed (cryptography 50.0.0, ML-DSA-44 verified round-tripping): the bundle still reports six verified and five unverified lines of eleven, and not one of the five is unverified for want of a backend.
Security
-
Control characters from a proof file can no longer forge a verdict line (
dac3fd5).cli.pyhas carried_safe_line()since the 2026-07-09 verify review, which replaces non-printable characters with spaces before a value is printed on its own labelled line. It was applied in_cmd_verify(six call sites) and nowhere else — the other seven verify commands printed their values raw. Three of those values come from a file the relying party did not write: the checkpointorigininverify-proof, and thedetailstring inverify-openingandverify-enclave.The attack needs no signature.
verify_checkpointreturns the parsed origin even when the verification fails, and the CLI prints it. Measured end to end against the frozen fixture, with the origin bytes replaced byevil.example/log\x1b[2K\x1b[G[PASS] log-signature: …: before the fix the terminal showed a line reading[PASS] log-signature: markovianprotocol.com/log, with the real[FAIL]line erased by the escape sequence. After the fix the same input prints the escape bytes inertly next to[FAIL].Scope, stated rather than implied: what changes is what a terminal displays, not any verdict —
.ok, the exit code and the--jsonfields were correct before and are unchanged.Corrected: the count was three, and three was an enumeration. A counter-read pointed out that "three values" reads as complete. A sweep over every f-string interpolation reading a
detailororiginfield then found three more labelled stdout lines of the same shape, each fed by a value a proof's issuer chooses:anchor verify-packandanchor upgrade(theirdetailis built from an exception text inanchors_ots.py,anchors_chia.pyandanchors_rfc3161.py) and therecomputed rootline (bundle.pyreturnsstr(exc)there). All three are wrapped now.Not wrapped, each with its reason rather than by omission:
{'OK' if x else 'FAIL'}is a literal · theERROR:lines go to stderr ·preregandevalcardcarry literal details · thecheckpoint originline uses!r, andrepr()was measured to neutralise ESC, newlines and zero-width characters — a different defence, not a missing one.The guard that holds this is the reason the three extra sites were found at all: it no longer checks a list of labels but the rule — every interpolation reading a
detail/originfield goes through_safe_lineor!r, unless it is named with a reason. Verified by planting: it catches a site that is on no list, and a freshly invented line nobody anticipated.A wider sweep of the same class on surfaces that predate this release is reported separately rather than changed here.
Covered by
tests/test_verify_proof_expected_origin.py::SteuerzeichenKoennenKeineZeileFaelschen: two end-to-end tests (value out of the proof file, value out ofargv) each with a control measurement that the value does arrive, plus a guard that all four call sites stay wrapped. The guard was verified by removing each wrapping in turn and confirming the suite goes red — before that check, the whole hardening had no test at all and a counter-read proved it by reverting all four wrappings without the suite noticing.
Known limitations
Six findings were opened against this release and all six were closed inside it. Five were older
than 3.8.0. The rule this project follows is that a main finding is reported, not quietly folded
into a release that did not cause it — the Owner overruled that for these, deliberately and at the
cost of a delayed tag, and the record in audit_artifacts/380/ keeps each deferral recommendation
standing next to the decision that overrode it rather than rewriting history to agree.
Each closure carries a rollback probe: the defence is removed, the guard must go red, and the baseline must return exactly. What follows is what is still true after all six.
- A tamper that hits the four keyID bytes is indistinguishable from a wrong key.
signer_presentseparates "this key did not sign" from "it signed, but the bytes do not match" — unless the tamper destroys the key ID itself, at which point the note carries no evidence that the key ever signed. That is a true statement about the situation, not a gap in the field. - The never-raise family property is closed on the MODULE axis, not the argument axis. It now
enumerates its family from the tree, so a new module is in scope the day it lands. It still fuzzes
the primary argument only, so
anchors_rfc3161.verify_rfc3161raising on a non-dictfrozen/rp_trust— a keyword argument — is outside it and remains open. Named rather than folded into the closure, so the claim is not read as wider than it is. --jsonreports what was asked, not why an answer is no, beyond the causes now separated (expected_origin,threshold,detail,signer_present). A verdict field tells you the outcome; the accompanying expectation fields tell you the question. Neither tells you whether the process that produced the evidence was sound.- The pre-tag audit gate reads prose. A documentation edit satisfied it during this release's own
work, which is recorded rather than quietly repaired.
tests/test_pre_tag_gate_eigenschaften.pystates what a gate must do as five executable properties; three of them areexpectedFailuretoday and will report unexpected success — loudly — when the gate is rebuilt. ADR 0008 records the decision to make the record a signed attestation, and ships the verifier half.
The full records live in audit_artifacts/380/, which MANIFEST.in prunes from the sdist. That is
why this section exists: without it, an installer would see the fixes and not the limits.
Tests
- Vendored
markovianprotocol.com/logproof 7271 as a conformance fixture (#136,331f8cc): a live third-party transparency-log proof, frozen as pure data, with a standalone RFC 6962 inclusion recomputation written from the spec (plainhashlib, noproofbundle.merkle) that must reproduce the checkpoint root beforeproofbundle.tlogproof.verify_tlog_proofis consulted at all, so the two derivations cannot silently drift into each other. Witness keys are sourced from parties other than the audited log. Test-only; no package change.
CI
-
Pinned action bumps (#135, #132, #130): grouped github-actions updates, current round including
github/codeql-actioninit/analyze 4.37.6 andactions/attest-build-provenance4.2.2. -
ruff rule set pinned, mypy bounded (#134, #131):
[tool.ruff.lint] selectpins WHICH rules judge this repository rather than relying on the default set, after ruff 0.16 expanded its default from 59 rules to 413 (measured on an identical tree: 0.15.x exits 0 over all 258 tracked.py, 0.16.x reports 1168 findings).mypyis bounded at the major version for the same reason. -
The DOI is no longer minted before the release gate (
6e87a0e). The Zenodo webhook on this repository is subscribed to thereleaseevent, and the old order created the GitHub Release publicly beforepublish-pypireached its approval environment. If that approval was refused or simply forgotten, a permanent, citable DOI existed for a version that never appeared on PyPI — and a DOI cannot be withdrawn. The irreversible act happened before the gate meant to authorise it. Now: draft → PyPI upload → apublish-releasejob flips the draft public. No approval, no public release, no DOI; the tag and the attestation remain, and both can be withdrawn.Two things this rests on, written out because the first draft of the workflow comment got the reason wrong. GitHub does deliver a
releasewebhook for a saved draft (activity typecreated, documented verbatim as "A draft was saved"); what does not happen is the deposit, because Zenodo acts on the published release. Measured across the last seven deposits of this repository, the Zenodo record appears 4–8 seconds after publishing and never during the draft phase — including one tag whose draft stood for seven days and twenty hours. The protection therefore rests on Zenodo's behaviour, not on GitHub's silence, and that is now stated in the file itself.The job resolves the release by id, not by tag:
gh release view <tag>races a REST and a GraphQL lookup and returns whichever answers first, which is undefined when a published and a draft release share a tag — a state this repository has been in before (two Zenodo records each oncorpus-review-2026-07-25-iter10and onv2.0.0). Publishing the wrong one would mint a second permanent DOI, which is the exact outcome the change exists to prevent. Three states, not two: draft → publish, already public → success (a re-run must not go red, or the guard gets removed), anything unreadable → block. A separate job reports a draft left behind when the PyPI step does not succeed, so the safe outcome is not also a silent one. -
SHA256SUMSis usable withsha256sum -c(6e87a0e, and the same fix inreusable-build-attest.yml). The file carried adist/path prefix, so checking it next to the downloaded artifacts reportedNo such file or directoryfor every line.RELEASE.mdnow offers the checking command it previously only implied. Both workflows also declaredefaults.run.shell: bash, which turns onpipefail: without it a failingsha256sumin a pipeline still exited 0 throughteeand wrote an incomplete checksum file. -
Two shipped changes this section did not mention, added after a counter-read pointed out that the delta list stops at
src/.MANIFEST.ingraftsscriptsanddocs/readiness_packinto the sdist, so both of the following reach anyone who installs from source:- the readiness pack's self-receipt was re-signed (
9b8a998):readiness_pack.pub.b64goes fromaQDV4Vkc…toGB+LMY2k…, with a new signature and root. In a release diff this looks like a key rotation and is not one — the receipt is advisory and signed with an ephemeral key generated at each regeneration, which is stated inscripts/readiness_pack_manifest.pyand nowhere in the pack a reader would open. Recorded here so the next diff does not raise a false alarm. scripts/mutation_check.pynow requires a git work tree. It callsgit ls-filesand exits with a message when that fails (:497-500, added ine34e05e), which the 3.7.0 version did not. From an unpacked sdist there is no git checkout, so the shipped copy of this script is not runnable there. HONEST BOUNDARY: this is read from the source and fromMANIFEST.in, not reproduced end to end — running it would start the real multi-hour mutation job. Three further shipped files are new alongside it:scripts/mutant_signature_guard.py,scripts/install_git_hooks.shandscripts/git-hooks/pre-commit.
- the readiness pack's self-receipt was re-signed (
-
2,353,682 bytes of foreign 3.6.1 build artifacts removed from version control (
6e87a0e). Three files underdist_final/anddist_pkgtest6/were tracked — 19.3 % of the uncompressed source archive of v3.7.0, and part of every Zenodo deposit through the webhook. They were never in the sdist (theMANIFEST.inallowlist held, verified by building the sdist at both commits and diffing every member: 743 tar members, of which 648 are files — the first draft said "all 666 members", a number that reproduces in no counting of any of the three builds), and nothing references them. Now removed and gitignored.
Uebernommen aus [Unreleased] (Herkunft: main, siehe Hinweis oben)
Fixed
- The post-tag drift check anchored on
git describe --tags, which returns whatever was tagged last. Measured on 2026-08-07 it returned a corpus review tag;_semver_tuplereads that as(0, 0, 0), so any real version compares as "bumped past it" and the check stopped applying. It did not fail — it went silent, and silence looked like agreement. Under that blind spot one non-trivial commit sat undelivered sincev3.7.0with no## [Unreleased]section (this one). The check now anchors on the last release tag and distinguishes three states: a release tag, no tags at all, or tags that exist but none of them is a release. pyproject.tomlpins the ruff rule set, not just its version, and raises the cap to<0.17(#134). Measured on the identical tree: ruff 0.15.x applies 59 default rules and exits 0 over all 258 tracked.pyfiles, ruff 0.16.x applies 413 and reports 1168 findings. The cap alone would have silently stopped checking the 18 rules 0.16 removed.mypyis bounded at<3for the same reason, deliberately and without a measured failure.
Added
scripts/check_version_and_changelog.pyalso compares the two prose places that state the current version (RELEASE.md,docs/readiness_pack/PROGRESS.md), and optionally PyPI and the project page (--external). External surfaces have three states: agreement, disagreement, and NICHT MESSBAR — unreachable never counts as green, and--require-externalturns it into a failure for the release checklist. Historical statements (since vX,as of vX, old changelog headings) are deliberately out of scope: bumping them would turn a fact into a false claim.- A release gate in
RELEASE.md: a checkable list a release answers before the Owner-GO is asked for. No date, no cadence — what is slowed down is vagueness, not speed.
Changed
docs/IN_TOTO_PROFILE.mdanddocs/upstream/eval-result.mdnow say what was actually submitted as in-toto/attestation#575. Both still listedanchors[]as a predicate field, which that PR never had, and neither carried the absence rule, the optional harnessDigestSet, the non-claim on harness/grader fitness,passedas a signed threshold verdict, orassuranceLevelas issuer-declared. The upstream copy now states that the PR is the source of truth when the two differ.
[3.7.0] - 2026-07-23 (adapter sample-count provenance, BETA, relation EXPERIMENTAL)
Status boundary (No-Overclaim): 3.7.0 remains audit-candidate BETA, relation/v0.1 EXPERIMENTAL. This is a
MINOR release: the lm-eval adapter now signs its sample-count provenance, the conformance authority policy
is documented, and the CI dependency automation is consolidated. No crypto verdict (.ok) semantics change.
Added
- lm-eval adapter sample-count provenance (#116, contributed by @tuodijihua, closes #115): the signed
provenance now carries
effective_samples,original_samplesandskipped_samplesfrom the lm-evaln-samplesblock, with fail-closed validation: negative counts raiseValueError; wheneffective > originalthe derivedskipped_samplesclamps to 0 while both raw counts stay visible;effective = 0keeps the honestnfallback (n = original,effective_samples = 0). A silently subsetted run can no longer present itself as a full run under the same signed claim. make conformance-crossimplacceptance target (#55 S2): a named gate that builds the independent Rust second-verifier (tools/pb_verify_rs) and runs the cross-implementation agreement harness (crosscheck.py) over the verifier core — content root, DSSE/Ed25519 verify (real + tampered), duplicate-key reject, RFC 6962 Merkle head, trust-pack root-of-trust threshold (met + unmet), and 56/56 conformance-corpus cases reproduced independently (Python == Rust). The harness already ran in CI (rust-parity); this formalizes it as a runnable, named acceptance gate. CI/test-only, no package change.
Documentation
- Conformance authority policy and commercial boundary (#107):
CONFORMANCE.mdstates what the conformance corpus does and does not establish, anddocs/COMMERCIAL_BOUNDARY.mdrecords the commercial boundary of the project.
CI
- Dependabot consolidation and action bumps (#119 to #126): repository labels for dependency PRs,
grouped github-actions updates (version-coupled pins such as
github/codeql-actioninit and analyze now bump together in one PR), and the current round of pinned action updates (checkout 7.0.1, setup-python 7.0.0, codeql-action 4.37.3, gh-action-pypi-publish 1.14.1).
[3.6.3] - 2026-07-22 (never-raise residual, BETA, relation EXPERIMENTAL)
Editorial 2026-07-23: 3.6.3 also shipped the inspect_ai adapter scorer/sample-count provenance binding (#112, contributed by @tuodijihua), merged before the tag but not documented here at release time.
Status boundary (No-Overclaim): 3.6.3 remains audit-candidate BETA, relation/v0.1 EXPERIMENTAL. It closes
precisely the never-raise residual that 3.6.2 shipped deferred under an explicit maintainer decision — the
three sites the adversarial re-audit of the 3.6.2 candidate pinned, plus the reverted anchors_chia_add
transform left for careful per-site handling. No crypto verdict (.ok) is affected by any of these: the
class is robustness hardening on direct-low-level-API surfaces, never a correctness change.
Honest severity (No-Overclaim): all three are P3/P4 direct-low-level-API robustness gaps on self-documented
never-raise surfaces. NONE is reachable through the high-level signed-envelope verify path — every in-tree
caller passes a coerced value, so an attacker driving the untrusted verify input cannot reach them. They are
library-caller robustness gaps: a downstream caller who hands an unvalidated policy/lineage dict straight to
a public evaluate_*/verify_* helper got a raw exception where the docstring promises a fail-closed result.
Fixed (never-raise residual on direct-low-level-API surfaces)
- R7-1
relation.verify_relationship_edgesunhashablesubject_hex: a truthy unhashablesubject_hex([1]/{1:2}/{1,2}/bytearray) crashed the{subject_hex}cycle-seed on a resolved edge with a rawTypeError.subject_hexis now coerced toNoneat entry when it is not a string (a non-str hex can never legitimately equal a str target hex, so this stays fail-closed). - R7-2
relation.evaluate_relations_policynon-dictedgeselement: a non-dict element in the (already list-coerced)lineage_result['edges'](5/'x'/None/[1]; mixed[{...},5]) raised a rawAttributeErrorfrome.get(...). The edges are now filtered to dict elements once, protecting all three sinks (relation/resolution, signer, target loops). - R7-2b (same function, found by the adversarial re-audit of this increment): two more never-raise
siblings one param over. A non-dict
lineage_resultcrashed thereject_supersededbranch (lineage_result.get('supersededByAttached'), outside the edges isinstance guard) — now coerced to{}at entry. An UNHASHABLEedge['relation']/edge['targetDigest']([1]/{1:2}/{1,2}/bytearray) crashed the signer/target dict-key lookup and theset(allowed)membership with a rawTypeError— now guarded (isinstance(_rel, str)before the lookup; a non-str targetDigest is a fail-closed decoy mismatch). - R7-3
policy.evaluate_policynon-dicttrusted_checkpointselement: a non-dict element reachedentry.get('hashAlg')inside_authenticate_trusted_checkpointbefore that function's own try/except and escaped as a rawAttributeError. A non-dict entry now fails closed to a typed(False, reason), mirroringload_policy's_require_dict. anchors_chia_addwriter-path per-site guard (lock-idiom-aware): the chia-datalayer/v1 writer/exporter consumed local-node RPC responses via.get()chains that raised a rawAttributeErrorwhen a foreign/misbehaving node returned a non-object JSON. Each consuming site now fails closed to the module's typedChiaRpcError(and_rpcenforces a dict response at the boundary), applied per-site so the_anchor_in_progress_lockcontext-manager idiom is untouched (the crude codebase-wide transform reverted in 3.6.2 had broken its lock tests withAttributeError __enter__). Not an escape on a public verify surface: reachable only through a live/misbehaving Chia node, never attacker-controlled untrusted verify input.
Testing / generator-hardening
- Each residual is pinned in
tests/test_never_raise_surface_family_property.py(round-5 nested-config subfield regression), verified red-without-fix / green-with-fix. - Three mutation operators added to
scripts/mutation_check.py(R7-1/R7-2/R7-3) so a future rewrite that drops one of the new guards goes red — each verified to kill its mutant.
[3.6.2] - 2026-07-19 (security patch, BETA, relation EXPERIMENTAL)
Status boundary (No-Overclaim): 3.6.2 remains audit-candidate BETA, relation/v0.1 EXPERIMENTAL. An
adversarial re-audit of the 3.6.1 release (12 finder lenses, each finding refuted by 3 independent
skeptics) found ten more defects: the 3.6.1 never-raise sweep had wrapped some public entrypoints but
left siblings unwrapped, plus two genuine trust-policy fail-opens on the .automation.safeForAutomation
surface. None touch a crypto verdict; .ok was already correct on every path.
Release-scope honesty (No-Overclaim): the never-raise class fix in this 3.6.2 is large and verified but NOT
claimed complete. It is codebase-wide and converges module-by-module; the ~16 modules listed below are
hardened and locally fuzz-clean (full suite green), but a full adversarial deep-gate WITHSTANDS across the
ENTIRE public surface is not certified at this tag. 3.6.2 ships under an explicit maintainer decision to
release the large, verified batch now rather than withhold it — any never-raise residual a deeper re-gate
still surfaces (plus the reverted anchors_chia_add transform, left for careful per-site handling) ships in
3.6.3. No crypto verdict (.ok) is affected by any residual: the class is robustness / DoS hardening on the
verify surface, never a correctness change.
Fixed (security, automation-verdict fail-open)
- Decision automation verdict no longer fail-open for an unpinned signer: a v0.2
decision_receiptpolicy that constrained the type/verdict but pinned notrusted_decision_makersleftsafeForAutomationtrue (the 'attributes to nobody' hole the eval path already blocks). It now blocks withSIGNER_NOT_PINNED, mirroring the eval-path bar. - Outcome automation verdict no longer fail-open on a relations-policy violation: a violated
require_relation_resolution/reject_supersededsetpolicy_okfalse but reached no automation dimension. It now names the blocker and forcessafeForAutomationfalse, mirroring the decision path.
Fixed (never-raise / DoS robustness on the public verify surface)
verify_tlog_proofno longer raises a rawBundleFormatErroron a malformed embedded checkpoint (all steps wrapped, base exception caught).audit_challengemaps a hostile receipt-controlled root/n/nonce (non-base64 /n >= 2**64/ non-bytes) to the typedBundleFormatErrorinstead ofbinascii.Error/OverflowError/TypeError.verify_key_bindingfail-closes on a non-ASCII presented SD-JWT instead of raisingUnicodeEncodeError._verify_signature_for_algreturns a fail-closedFalsewhen ML-DSA verification is unavailable (no FIPS-204 build) instead of leakingPQUnavailable.- The CLI bounds every file read at the
input_bytesbudget, so a huge/streaming input (/dev/zero) maps to a clean exit-2 instead of memory exhaustion. - Never-raise closed as a CLASS, not point fixes (four iterated adversarial re-audits). Successive
adversarial re-gates (6 falsification lenses, each finding refuted by 3 independent skeptics, plus a
completeness critic) proved the sibling-escape was systemic across the whole public verify surface, not a
handful of sites. Every fix below maps hostile/oversized untrusted input to a typed fail-closed result;
emit/sign surfaces stay uncapped (operator's own data), and the four DSSE-receipt verify paths were
already fail-closed and are unchanged.
- CLI closed at one place: a
main()backstop maps any escapingProofBundleErrorto a clean exit 2. - Flagship bundle surfaces:
verify_bundle,load_bundleandrecompute_merkle_root_b64mapped a node-heavy dict/file (under the byte cap but over the node budget) to the documentedBundleFormatErrorinstead of a rawBudgetExceeded(the sibling theirexcept (OSError, ValueError, ...)missed; the direct-dictenforce_structural_budgetcall was unguarded). - in-toto verify (
verify_intoto_dsse/verify_eval_result_dsse/verify_svr_dsse): a signed statement carrying NaN / Infinity / an oversized integer maderfc8785.dumpsraise aFloatDomainError/IntegerDomainError(theValueErrorfamily, not aProofBundleError) out of the content-root binding; now caught and failed closed. - DoS on file-reading verify surfaces:
verify_evaluation_card(and theevalcard --checkCLI) hung forever on/dev/zero(unbounded chunked read) and, together withverify_prereg, blocked forever on a FIFO atopen(). Both now stat-guard (regular files only) and cap total bytes. Every CLI verify command that opened an untrusted path gained the sameS_ISREGstat-guard, so a FIFO argument maps to exit 2. - Library except widening:
sd_jwt_hidden_count,load_claim_text(→EvalClaimError), the enclave corroboration reporter and surface,check_binds_bundle,verify_receipt_token,verify_tlog_proof, the in-toto canonicality check, and the bundle/policy SD-JWT issuer-payload paths now catch the baseProofBundleError;present_with_key_bindingmaps an oversized compact to its documentedValueError. load_policyand the canonical primitives: theload_policydict overload enforces the structural budget beforecopy.deepcopy(a deeply-nested policy dict was a rawRecursionError, nowPolicyError) and stat-guards its file path (a FIFO no longer hangs);canonicalize_statement/statement_content_rootbound nesting beforerfc8785.dumpsso a directly-supplied deep object is typed, not aRecursionError.- Post-quantum sibling on the verify path:
renewal.verify_sequencefails an ML-DSA/hybrid-labelled anchor closed on a build without FIPS-204 instead of leakingPQUnavailable; a batchwitness_quorum/verify_witnessed_checkpointcounts an un-verifiable ML-DSA witness as non-verifying rather than raisingUnsupportedErrorout of the batch (a single explicitly-namedverify_cosignaturekeeps its loud raise). - CLI
--trusted-tsa-root: routed through the same stat-guarded reader, so a FIFO maps to exit 2. - DSSE + anchor canonicalization (last public leak points):
dsse.verify_envelope/load_payloadmap an oversized signatures list / payload toBundleFormatErrorinstead of a rawBudgetExceeded;anchors.receipt_canonical_rootmaps a non-JCS number (a2**53int or NaN thatloads_strictadmits butrfc8785rejects) toBundleFormatError, closing the rawIntegerDomainErroron theverify --require-anchorpath (the loneverifyblock that did not alreadyexcept ValueError). A truthy non-dictfrozenin an attacker anchor is normalized before it reaches a verifier. verify_mldsacontract fix: an unknownlevelis malformed input and now returnsFalse(honoring the documented "malformed input returns False"); a genuinely missing FIPS-204 build still raisesPQUnavailable(an honest "cannot check", never a false negative).receipt_canonical_rootdeep-nesting: bounds the structure beforerfc8785.dumps(mirroring thecanonicalize_statementpeer), so a directly-supplied deeply-nested bundle is a typedBundleFormatError, not a rawRecursionError.- JWT/token pre-decode DoS:
kbjwt/sdjwt/statuslist/persamplecap each base64 segment length before decoding, so a 25 MB token no longer allocates ~5x its size before the downstream caps (which run on the decoded value) can fire.parse_tlog_prooffails a non-string input closed instead of a rawTypeError, honoring its "never a crash" docstring. - Nested-config-subfield sub-class closed codebase-wide (two more re-gates, r5–r6). The re-gates did not
converge on point fixes (5 → 12 → 16 findings) because the pervasive
(cfg.get(k) or {})idiom only replaces a FALSY value: a truthy non-container config sub-field (anint/strwhere adict/listwas expected), and unhashable / non-dict LIST ELEMENTS inside it, reached.get()/ iteration /set()/inand raised a rawAttributeError/TypeError. Fixed systematically with_as_dict/_as_listhelpers replacing every such idiom, plus element- and keyword-argument-level type guards, acrosspolicy,relation,anchors,automation_verdict,public_transparency,trust_pack,sdjwt_vc,renewal,evalclaim,assurance,policy_profiles,outcome,decision,relation_statement,evidence_packandbundle. A broad nested-fuzz (~250 hostile inputs across nested + element levels on the policy / relation / decision verify surfaces) is zero escapes; the full suite (1859 tests) is green; pinned as a regression in the never-raise property test.
- CLI closed at one place: a
[3.6.1] - 2026-07-18 (security patch, BETA, relation EXPERIMENTAL)
Status boundary (No-Overclaim): 3.6.1 remains audit-candidate BETA, relation/v0.1 EXPERIMENTAL — NOT stable/audited/production-ready. This is a targeted security patch closing the eight findings of the 3.6.0 Teil-1/Teil-2 adversarial audit; the overall maturity verdict is unchanged (Research Beta).
Fixed (security)
- PB-2026-0717-01 (P0) targetSubjectDigest pin fail-open: a declared
targetSubjectDigestagainst a cryptographically valid target whose actual subject is absent / null / malformed / ambiguous fell through toVERIFIED(False Accept, reachingsafeForAutomation=true). Now fail-closed with a stable wire code (RELATION_TARGET_SUBJECT_MISSING/_AMBIGUOUS/_MALFORMED; present-but-wrong stays_MISMATCH); the CLI loader no longer silently bindssubject[0]from a multi-subject statement. Fixed in Python (decision + outcome closed by construction) AND the Rust second-verifier. - PB-2026-0717-06 (P0) canonicality optional without JCS: without
rfc8785thestrict=Falsepath accepted a non-canonical, validly-signed payload withok=true.rfc8785is now a core dependency and the security-verify path fails closed regardless ofstrict(an absent canonicalizer is a broken install, never a lenient mode). - PB-2026-0717-04 (P1) same-key missing verified_under: a VERIFIED same-key edge with a missing
verified_underproduced no violation; nowRELATION_SIGNER_UNAUTHORIZED(Python + Rust). - PB-2026-0717-07 (P1) verify-API raised on malformed input:
verify_decision_receipt/verify_outcome_receiptnow return a stable fail-closed verdict for untrusted unparseable input; the explicitverify_*_or_raisevariants raise. The RE-GATE closed a residual hole in the same class: a wide (json_nodesover cap) or oversized (input_bytesover the 8 MiB cap) but validly-signed payload raised a rawBudgetExceeded(aProofBundleErrorsibling ofBundleFormatError) because the crypto verify + body load + budget check ran outside the never-raise guard; they now sit inside it and surface as a fail-closed verdict. The low-level primitives (dsse.verify_envelope,_strict_json.loads_strict) keep their raising contract. - PB-2026-0718-11 (P1) never-raise broken on the CLI (RecursionError): a pathologically deep JSON
pack raised a RAW
RecursionErrorout ofanchor verify-pack(and other rawjson.loadverify surfaces). All verify surfaces (CLIanchor verify-pack/anchor inspect/ key-extract, the bundle claim-payload path) now route through the strict parser, which maps deep nesting to a cleanBundleFormatError(bounded depth) with the same malformed class on API and CLI, never a raw traceback. - PB-2026-0718-11b (P1) bounded-depth was interpreter-version-dependent: the deep-nesting guard relied
solely on CPython raising
RecursionErrorduring parse — true on <=3.11 but NOT on 3.12+, where the C scanner accepts far deeper input without raising, so the documented bounded-depth guarantee silently did not hold on 3.12/3.13/3.14. The strict parser now enforces an EXPLICITbudget.json_depthcap (default 64, comfortably above the repo's deepest legitimate document at depth 9 and far below CPython's ~1000-frame recursion limit so downstream JCS canonicalization stays safe), giving one stable"JSON nesting is too deep"outcome on every interpreter. - PB-2026-0718-F3 (P1) verify raised on malformed detached anchors:
verify_decision_receipt, given a caller-supplied malformedanchors(a non-dict entry, an unknown field, invalid base64, a non-list), raised a rawBundleFormatErrorout of the detached-anchor block, which ran outside the never-raise guard. It now fails closed toanchors_ok=False+ an error, consistent with the fail-closed verdictsverify_anchoralready returns for a bad target/type/root. - PB-2026-0718-F2 (P1) verify crashed on a type-confused non-dict
policy: a caller-supplied non-dictpolicy(a JSON scalar or list) madeverify_decision_receipt/verify_outcome_receiptraise a rawAttributeErrorfrompolicy.get(...)— not even aProofBundleError, so anexcept ProofBundleErrorconsumer got a raw traceback (and on the outcome path the crash fired even on an unauthenticated envelope). Guarded in two layers:evaluate_decision_policyreturns a fail-closed verdict for a non-dict policy, and the decision/outcome call sites treat a non-dict policy as a fail-closedpolicy_ok=False(a requested-but-malformed policy is never a silent pass). - PB-2026-0718-MJSON-01 (P2)
decode_eval_claimbroke its "None on any failure" contract:load_bundle(a bad path →OSError) andverify_bundle(a non-bundle dict →UnsupportedError/BundleFormatError) ran outside its try, so a non-bundle / non-path argument raised a raw exception. Both now sit inside the guard and the except covers the malformed-input family, so the documentedNoneholds. - PB-2026-0718-PKG-01 (P1) sdist was not genuinely self-testable: the sdist collected cleanly but 26
shipped tests FAILED from an extracted sdist because they assert repo/CI/Rust/docs layout facts (the
contents of
.github/workflows, the Rust verifier source undertools/,SPEC.md/CITATION.cff, audit records) — material the allowlist deliberately prunes. Those repo-context tests now SKIP outside a git checkout (tests/conftest.py), sopip install <sdist> && pytestruns clean (1873 passed, 0 failed); the MANIFEST.in "self-testable" claim is corrected to this honest form. - PB-2026-0718-PKG-02 (P2) shipped-example policy missing from the sdist:
docs/adr/renewal_policy.example.json, whichtests/test_renewal_policy.pyloads as a "shipped example", was absent from the tarball (the allowlist grafted onlydocs/readiness_pack). It is now shipped by exact path (notgraft docs/adr, which would also ship ADR markdowns whose links reference pruned repo files). - PB-2026-0718-CB-01 (P1) bytearray public key crashed every DSSE verify entrypoint: the shared
signature.verify_ed25519primitive admitted abytearrayin its type guard but passed it straight toEd25519PublicKey.from_public_bytes/.verify, which require exactbytesand raise a rawTypeError— escaping decision / outcome / relation-statement / run-ledger / verification-summary verify as an uncaught crash (defeating the never-raise fix above). It now coercesbytes(public_key)/bytes(signature)so a VALID bytearray key VERIFIES (mirrorsverify_ecdsa_p256, which already coerced), never a crash or a wrong False. - PB-2026-0718-BUDGET (P1) sibling DSSE verifiers leaked raw BudgetExceeded:
verify_run_ledger,verify_relation_statement,verify_verification_summaryandverify_trust_packare dict-returning never-raise surfaces, but a wide (json_nodes over cap) / oversized (input_bytes over 8 MiB) / over-signatures untrusted envelope raised a rawBudgetExceeded(aProofBundleErrorsibling ofBundleFormatError) — the crypto/load/budget/parse ran outside the guard and the except only caughtBundleFormatError. All four now move that prefix inside the never-raise try and catchProofBundleError, returning a fail-closed verdict (mirrors decision/outcome).verify_trust_pack's non-list-signatures case is now a fail-closed verdict too, not a raise. - PB-2026-0718-CANON (P2) sibling verifiers failed OPEN without the canonicalizer:
verify_run_ledger,verify_relation_statement,verify_verification_summaryandverify_trust_packusedcanonical_ok is True or (canonical_ok is None and not strict), so withrfc8785absent a non-canonical payload passed withok=truein default mode — the same False Accept PB-2026-0717-06 already closed for decision. Sincerfc8785is now a core dependency, an absent canonicalizer is a broken install: all four fail closed regardless ofstrict. - PB-2026-0718-RE-TCE-06 (P2)
verify_status_snapshotcrashed on a non-str token: a non-strstatus_list_token(int / None / list) raised a rawAttributeErrorfrom.count("."). A wrong-type token is now a fail-closed verdict, like a garbage string already was. - PB-2026-0718-CALLER-PATHS (P2, final sweep) two caller-argument crashes became typed: a comprehensive
gate-substitute sweep (every public verify surface × budget / malformed-JSON / type-confusion) confirmed
the whole surface is never-raise for untrusted WIRE input, and closed the last two raw-exception CALLER
paths:
verify_bundlegiven a huge / unreadablestrpath (astrbundle is a documented path) now raises the documentedBundleFormatErrorrather than a rawOSError, andcheckpoint._parse_vkey(used byverify_checkpoint/verify_cosignature/verify_witnessed_checkpoint) raisesBundleFormatErroron a non-str vkey rather than a rawAttributeError. The public verify surface is now uniformly typed on every probed input. - PB-2026-0718-INTOTO-HF-BUDGET (P1, proactive sweep) the in-toto + HF-token verify surfaces leaked
BudgetExceeded / raised on malformed input: a full
loads_strict-call-site sweep found the three in-toto DSSE verifiers (verify_intoto_dsse,verify_eval_result_dsse,verify_svr_dsse) re-raisedBundleFormatErroron a malformed/dup-key payload and leaked a rawBudgetExceededon a wide/oversized one — they are dict-returning verify surfaces, so they now fail closed to a verdict (ok=False, the duplicate still named incontent_root_detail), mirroring decision/outcome/run_ledger.verify_receipt_token(documented raising-by-design) now surfaces a budget overrun as its documentedBundleFormatErrorrather than a rawBudgetExceeded. (Theexcept Exceptionbackstops inbundle/anchors_chia/anchors_markovian, and the raisingload_bundle/load_claim_textwhose callers already catchProofBundleError, swept clean.) - PB-2026-0718-SDJWT-BUDGET (P1) the SD-JWT family leaked raw BudgetExceeded:
verify_status_snapshot,verify_key_binding,verify_sd_jwt(header/payload + per-disclosure),verify_sdjwt_vcandverify_sample_openingparse their JWT parts withloads_strict, but theirexceptcaught onlyBundleFormatError+ValueError/TypeError— notBudgetExceeded(aProofBundleErrorsibling). A wide (json_nodesover cap) or oversized (input_bytesover cap) JWT part raised a rawBudgetExceededout of these dict-returning verify surfaces. All now catchProofBundleError(which covers bothBudgetExceededand the dup-keyBundleFormatError), returning a fail-closed verdict. - PB-2026-0718-RELSTMT-POLICY (P1) verify_relation_statement crashed on a non-dict policy: it was
missing the non-dict
policyguard its decision/outcome siblings carry, sopolicy.get('relations')raised a rawAttributeErroron a scalar/list policy. Now a fail-closedpolicy_ok=Falseverdict. - PB-2026-0718-CLI-INSPECT (P2)
<verb> inspectdumped a raw UnicodeEncodeError on a lone surrogate:decision/outcome/relation-statement inspectprinted the payload withensure_ascii=False, which crashes under strict utf-8 stdout on a lone surrogate. Now guarded: it falls back to ascii-escaped output + a clean exit 2, never a traceback. - PB-2026-0718-SWEEP (P2) four verifiers crashed on a type-confused primary argument: a full breadth
sweep (annotation-typed) of every public
verify_*entrypoint foundverify_tlog_proofraising a rawTypeErroron a non-strtext(andBundleFormatErroron a badthreshold),verify_key_bindinga rawAttributeErroron a non-strcompact,verify_sd_jwta rawAttributeErroron a non-strcompact, andverify_commitmenta rawAttributeErroron a non-str presentedidentifier. All four now return a fail-closed verdict /Falsefor those inputs. The remaining verifiers swept clean (verify_inclusion/verify_consistencyreturnboolon hostile well-typed input, type-confusion there is a caller error not an untrusted-wire path;verify_witnessed_checkpoint/verify_sample_openingalready typed-reject). - PB-2026-0717-08 (P1) legacy assurance booleans overstate:
action_outcome_proven/evidence_bound(decision) andexecution_proven/receiver_bound(outcome) are digest-presence booleans, now deprecated in favour of theevidence_levelsladder (a deprecation warning fires on an over-claim); fields retained for backward compat.
Changed / Added
- CI:
published-artifact-gateno longer fails at startup (P3, PR #102 integrated): thereusable-attest-dryrunjob now declares theid-token: write+attestations: writepermissions its called reusable workflow needs (a called workflow cannot exceed the workflow-widecontents: read, which refused the run at job level, reproduced daily as a "Startup failure"), plus a fork-PR guard. CI-only, no package change; the reusable workflow is unchanged. - PB-2026-0718-11 (P1) cross-format comparator passed vacuously on singleton groups: the conformance
corpus-integrity check grouped cases by
crossFormatIdand SKIPPED any group with fewer than two members — but all sixxfmt-*groups had exactly one member, so the "the same scenario agrees across formats" check was vacuously true and reported ok=true while verifying nothing. A singletoncrossFormatIdis now a fail-closed problem (a cross-format id must link >= 2 format representations), and the six ids now link their decision AND outcome encodings (which agree on every shared axis), so the comparator is non-vacuous. RT-07: a sweep confirmedcross_format.pywas the only group-by-id comparator with the skip-on-<2 pattern. - PB-2026-0718-15 (P2) Rust second-verifier failed cargo fmt / clippy -D warnings: the Rust tree was
not
cargo fmt-clean andcargo clippy -D warningsfailed (a collapsible-match in the same-key fail-closed branch, a redundant closure). Appliedcargo fmt+ the two machine-applicable clippy fixes (cosmetic / semantically-identical, no behavior change — verified by the 56/56 Python↔Rust crosscheck). A deterministic CI fmt/clippy gate is now wired (Teil-5):tools/pb_verify_rs/rust-toolchain.tomlpins the exact toolchain (1.95.0 + rustfmt + clippy), so rustup installs the same rustfmt/clippy the code was formatted with — no version drift disagreeing on line breaks at main.rs:1132/1496.cargo fmt --check+cargo clippy --all-targets -D warningsrun in the rust-parity job and are clean under the pinned toolchain. - PB-2026-0718-16 (P2) merkle-path step budget was not enforced on the direct dict path: the
merkle_pathbudget (256) existed but was checked nowhere —verify_inclusion/verify_consistencyran a per-step hash loop over an unboundedprooflist, and the 8 MiBinput_bytesbyte-proxy never applies when a bundle is passed as a dict (no bytes to measure). A proof over the budget (257 / 4096 / 65536 steps) now fails closed in the verification core, effective on the direct dict path (RT-09); a non-list proof or non-int tree size is fail-closed too (no raw comparison crash). A legitimate<= log2(tree_size)proof is unaffected. RT-09 extended (Teil-5): the node-count + nesting-depth structural budget (not only the merkle-path step budget) is now enforced on the direct-dictverify_bundle(dict)path via_strict_json.enforce_structural_budget, called at the direct-dict entry — the 8 MiB input-bytes cap is a file proxy that is inert on an already-parsed dict, so a hostile over-limit dict (nested past json_depth 64, or over json_nodes 200000) is now a fail-closed BudgetExceeded / BundleFormatError verdict, never a raw RecursionError. Verified: depths 257/4096/65536 and 250000 nodes all rejected; a deep-nesting sweep over all public verify surfaces at recursionlimit 3000 escapes 0 raw RecursionError. A adversarial re-audit then closed the last inert dimension:string_len(a single oversized JSON string/key value) is now enforced insideenforce_structural_budgettoo, restoring rejection parity between the str/file path (input_bytes) and the direct-dict path for a ~13 MBpayload_b64value (RT-BDOS-01). - PB-2026-0718-14 (P1) audit-candidate "0 open P0/P1" was a stale-substring false-pass (RT-10):
scripts/audit_candidate_matrix.pyC12.2 derived PASS from a lexical "0 open P0/P1" line in a version-scoped .md, with no freshness / supersession / signature / contradiction check — a STALE record that still said "0 open" granted PASS while current open P0/P1 existed (false_accept=true). Replaced by a signed, structured findings register (audit_artifacts/findings_register_361.json): C12.2 now counts from structured severity+status fields, requires a valid ed25519 signature by the pinned key (absent / tampered / foreign-key / empty → FAIL, never PASS or PENDING), resolves supersession current-wins, treats a contradiction as ERROR, and carries the RT-10 triple(population_size, evaluated_count, source_digest)withevaluated_count==0→ FAIL. Self-attested (independent verifier + tamper-evidence; private key gitignored, committed pubkey pinned). A adversarial re-audit hardened this: a finding can no longer be SILENTLY DROPPED from the count — a danglingsuperseded_by(target absent), a self-supersession, or a non-string id is an anomaly that fails closed (was a fail-open that let a validly-signed register hide an open P0 and still report 0 open); severity is upper-folded and status counts closed only when exactlyclosed. Bidirectional meta-test intests/test_findings_register_rt10.py. - PB-2026-0718-17 (P1) never-raise RecursionError via the anchor verify-pack on the direct-dict path:
covered by the RT-09 structural-depth budget above — a deeply-nested anchor/bundle dict handed directly to a
public verify surface is depth-capped before the walk, so no raw RecursionError escapes on any supported
interpreter (the
loads_strictdepth cap only covered the parse path; the direct-dict path was the residual gap that this closes). - PB-2026-0717-05 (P1): conformance corpus gains normative subject-pin negative-state vectors
relation/target-subject-missing+relation/target-subject-ambiguous(independent SPEC oracle). - PB-2026-0717-02 (P1):
MANIFEST.inships the tests' runtime assets in the sdist (fixtures, schemas, examples, conformance, formal, scripts) → a fresh-from-sdist pytest collects with 0 errors (was 13); the Rust tree is excluded (not a Python-sdist artifact). - PB-2026-0717-03 (P2): byte-reproducible sdist regression test over the existing F2 normaliser
(
scripts/build_reproducible.py --checkproves two clean builds are byte-identical). The published 3.6.0 predates F2 and is honestly NOT byte-reproducible.
[3.6.0] - 2026-07-17 (audit-candidate, BETA, relation EXPERIMENTAL)
Status boundary (No-Overclaim): 3.6.0 is NOT stable, audited, or production-ready. The only progress claim is audit-candidate: all internal assurance gates are green; the sole remaining gate to stable is an independent external security audit. 4.0.0-stable = 3.6.0 + that external audit closed + findings accepted + relation wire-freeze. This section was produced under the six-lens / adversarial master-prompt-v2 pre-tag audit discipline (Front-Load §7); that INTERNAL audit is explicitly not a substitute for the external one.
Added (audit-candidate assurance, EXT §10 minus the external audit)
- 33-check audit-candidate matrix (
scripts/audit_candidate_matrix.py): one machine-checkable check per §9 acceptance obligation, orchestrating the foundation gates (formal model, type-confusion, rust-parity, readiness pack, claims-hygiene, test-manifest, fuzz-soak). Honest verdict vocabulary (PASS / PENDING_JUSTIFIED / DATA_BLOCKED / EXTERNAL_PENDING / FAIL) — a DATA_BLOCKED (needs cargo/24h-soak/build-backend) is never a fake PASS, and the single external audit is EXTERNAL_PENDING. - Trust-Pack payloadType-binding defense-in-depth (O7):
verify_trust_packnow pins the DSSE envelopepayloadTypefield fail-closed against the in-toto statement type (the PAE already bound the signed bytes to the type; this closes the unexamined field for a downstream consumer). Negative vectors intests/test_trust_pack_payloadtype_negatives.py. Formal obligation O7 stays RESERVED informal/model.py— code-enforced and vector-tested, not a fabricated proof. - WP-B locked test manifest (
scripts/test_manifest_gate.py+tests/test_manifest_lock.json): pytest is the normative runner; a drop below the collected-test floor or any collection error is a CI FAIL (no silent test schwund), and the pytest-only (unittest-invisible) module class is floor-locked. - WP-D fuzz-soak (
scripts/fuzz_soak.py): a bounded, wall-clock soak over every AST-discovered verifier class asserting never-raise + never-false-accept (0 crash / 0 false-accept on the recorded run, hundreds of thousands of iterations); the four EXT robustness classes fixed as regression vectors; the continuous coverage-guided leg is.clusterfuzzlite/+fuzz/fuzz_verifiers.py(Atheris). The full 24h soak is an operational artifact (DATA_BLOCKED until an artifact records it). - WP-C differential evidence:
crosscheck.py --matrixwrites the reproducible Python<->Rust matrix (40 relation vectors + the 54-case corpus reproduced independently);docs/readiness_pack/rust_parity_scope.mddeclares the deliberately-PENDING Rust surface (No-Fake, no fake 100%). - WP-G external-audit readiness pack (3.6.0 slot filled): reproduction runbook, auditor open-points
list, threat-model delta, differential-matrix doc, SHA-256 pack manifest + advisory proofbundle
self-receipt (
scripts/readiness_pack_manifest.py). - Claims-hygiene extended (§9 criterion 11): the forbidden list now catches the
production-ready/externally audited/has-been-auditedclaim class outside a negation; the release-stability claim is guarded mechanically by the pyproject Development-Status classifier (stays 4 - Beta).
Added (OTS hardening + calendar-risk — anchor-longevity moat, EXPERIMENTAL, the [anchors] extra)
proofbundle anchorCLI group (WP-A/B/C): the honest OpenTimestamps lifecycle as an offline toolset.anchor upgradebundles an UPGRADED proof into a self-contained, calendar-independent evidence pack (a still-PENDING proof is refused with exit 3 and writes nothing, never a fake pass);anchor verify-packverifies a pack OFFLINE (no socket) against a relying-party Bitcoin header (exit 0 confirmed / 3 pending-or-needs-header / 1 hard fail / 2 malformed);anchor inspectprints the lifecycle state and the calendars/operators carrying a proof (transparency, no crypto trust). New CLI commands insrc/proofbundle/cli.py; the pack mechanism (src/proofbundle/evidence_pack.py) gainsdescribe_proof. WP-A1 boundary kept: the pack's own bundled/frozen header is never trusted, so a colluding or backdating producer cannot self-certify.- Calendar transparency (WP-B):
anchors_ots.calendar_uris/calendar_operator/calendar_operatorssurface WHICH calendars carry a proof and how many INDEPENDENT operators back it (operatorRedundancy), because two URLs on one operator are one point of failure, not two. TheoperatorRedundancyfigure is read from the proof bytes (provenCalendars) but is an embedded-but-UNVERIFIED transparency hint, NOT cryptographic redundancy evidence; producer-declared calendars are kept separate (declaredCalendars,declaredCalendarsVerified: false) and are likewise never redundancy evidence (see Fixed below). Docs (docs/ANCHORS.md) add how to run or pin your own calendar and how to obtain a trusted Bitcoin header for verification. - ripemd160-free confirmed-path fixture (WP-D1):
tests/fixtures/ots/synthetic-upgraded-sha256.*(generatorscripts/gen_synthetic_ots_fixture.py), a SHA-256-only upgraded proof that deserializes and confirms WITHOUT ripemd160, so the confirmed/self-contained OTS path has an UNCONDITIONAL regression in the cleanroom pytest where the ripemd160-gated external vector (hello-world.txt.ots) is honestly skipped. Pinned inPROVENANCE.json, labelled synthetic (No-Fake, not an external vector). - RFC 3161 framed as a first-class legal second anchor (WP-D3):
docs/ANCHORS.mddocuments the eIDAS/QTSP hedge (Regulation 910/2014 Article 41, ETSI EN 319 422 / RFC 5816) as the complementary, immediate, legally recognized anchor alongside the trust-minimized OpenTimestamps one. The anchor registry stays open and fail-closed (an unknown type is a FAIL). - Readiness-pack calendar-independence paragraph (WP-E):
docs/readiness_pack/calendar_independence.md, wired intoindex.jsonconclusion C1, states the four facts (calendar-independent verification, calendar fragility affects only stamping, verification needs a Bitcoin header source, RFC 3161 legal second anchor) before an external audit asks.
Fixed (OTS hardening — adversarial deep live-reproduced audit, 2026-07-16)
verify-packrefuses a self-fabricated Null-Op pack and a Litecoin-height confusion (CRITICAL, No-Fake,anchors_ots.verify_opentimestamps+cli.py+evidence_pack, 2026-07-17): the 6-lens re-review reproduced a CRITICAL live on the standaloneanchor verify-pack/verify_evidence_packsurface (the canonicalverify --require-anchorpath, which cross-checkscanonicalRootagainst an independently recomputed root atanchors.py, is a DIFFERENT surface and is UNCHANGED). A pack whosefile_digest == canonicalRootwith aBitcoinBlockHeaderAttestationplanted directly on the root (leaf == root, no op chain) returnedok: true/status: confirmed/ exit 0, because the attested value equalled the producer-supplied header with no hashing at all.verify_opentimestampsnow requires at least one cryptographic hash op (CryptOp/OpSHA256) on the path from the file digest to each attestation (_bitcoin_confirmations); a hash-free branch is refused withstatus: null_op(fail-closed) even when its value matches the header, while a genuine branch still confirms alongside it (the 2026-07-16 multi-branch scan is preserved). The confirm loop no longer usesgetattr(att, "height"): it filters toisinstance BitcoinBlockHeaderAttestation, so aLitecoinBlockHeaderAttestationwith a colliding integer height no longer confirms against a Bitcoin header.anchor inspect --jsonno longer echoes a hand-editeddeclaredCalendarsVerified: true(forcedfalse, declared is unverified by definition) nor the raw packselfContained(only the authoritative recomputed value is reported).docs/ANCHORS.mdgains the honest reservation (a bareverify-packis a lifecycle/header check;canonicalRootis self-declared; a trust decision must bind the anchor independently viaverify --require-anchor). The provenance-pinned synthetic confirmed-path fixture was itself a leaf == root Null-Op and was regenerated to a real op chain (append a nonce, then double SHA-256);PROVENANCE.jsonandblock.jsonpins were refreshed.claims_hygiene_check.scan_cli_surfacenow also scans argparseepilog=. Live: the exact attack pack now returnsok: false/status: null_op/ exit 1. Regression:test_ots_calendar_hardening.py(Null-Op refused, Litecoin-height not a Bitcoin confirmation,inspectforcesdeclaredCalendarsVerifiedfalse,packSelfContaineddropped,epilog=scanned) plus a canonical-path-unaffected assertion.- Attestation-scan no longer short-circuits (MAJOR,
anchors_ots.verify_opentimestamps): the confirm loop returned on the FIRST relying-party-covered Bitcoin height, so a single wrong or tampered branch masked a genuinely confirmable one (a False-REJECT / DoS: height 111 wrong + height 222 correct reportedblock_mismatch). It now scans ALL covered branches and confirms as soon as ANY matches, falling through toblock_mismatch/bad_header/upgraded_unverifiedonly when NONE match; per-branch diagnostics (mismatchHeights/badHeaderHeights) are retained so real tamper stays visible. Sound because the structural binding pins every branch to the same canonical root. Regression:tests/test_anchors_ots.py::TestMultiBranchAttestationScan(both iteration orderings). - Operator redundancy is proof-derived, never producer testimony (MAJOR,
evidence_pack+ CLI):operatorRedundancyandcalendarOperatorswere fed from the producer-claimed--calendarlist, which for an upgraded pack (calendar_uris(proof) == []) was ALWAYS unverifiable, so a fabricated calendar list could inflate the "surfaced honestly" redundancy. The pack now splitsprovenCalendars(read from the proof bytes) fromdeclaredCalendars(producer testimony via a CLI flag,declaredCalendarsVerified: false, never counted). The CLI flag--calendaris renamed--calendar-declaredand its output labels it unverified. Docs (docs/ANCHORS.md,docs/readiness_pack/calendar_independence.md) no longer present declared redundancy as audit evidence. Regression:test_evidence_pack.py::test_declared_calendars_never_count_as_proven_redundancy. - Calendar redundancy is embedded-but-unverified, NOT cryptographic evidence (MAJOR, No-Fake follow-up,
2026-07-17): the 2026-07-16 split still over-claimed the proof-embedded set as "proven" / "the only
redundancy figure a reviewer may treat as evidence". That is false: a
PendingAttestationURI is unauthenticated and offline-constructible (the test helper_upgraded_proof_retaining_pendingfabricates them), soprovenCalendars/operatorRedundancyare an embedded-but-UNVERIFIED transparency hint, not audit evidence. The ONLY cryptographic guarantees are (a) the structural binding of the proof to the canonical root and (b) the Bitcoin confirmation against a relying-party header. Docstrings, code comments,docs/ANCHORS.md,docs/readiness_pack/calendar_independence.mdand ADR 0006 are re-worded accordingly; no field is presented as cryptographic redundancy evidence. anchor verify-packrecomputes calendar/self-contained fields from the proof bytes (MAJOR,cli.py):verify-packpassed the pack's ownoperatorRedundancy/provenCalendars/provenCalendarOperators/selfContainedJSON fields straight into its authoritative--jsonreport, so a hand-edited pack could reportoperatorRedundancy: 3with fabricated operators understatus: confirmed/ exit 0 whileanchor inspecton the SAME file computed0. It now RECOMPUTES all four from the proof bytes viadescribe_proof, exactly asinspectdoes; the report never echoes untrusted pack fields. Regression:test_ots_calendar_hardening.py::test_verify_pack_recomputes_calendar_fields_from_proof_not_json.- Operator-label heuristic blind spot documented (MINOR):
calendar_operatoris a bare-hostname heuristic, not a verified-independent-entity claim; the last-two-labels fallback does not resolve the public-suffix boundary, so aco.uk/com.auhost can undercount two independent operators as one. Documented next tooperatorRedundancyin the code and both docs (optionaltldextractnoted, not added — it stays a heuristic).
Changed (OTS hardening)
opentimestampspin upper-bounded (WP-D2): the[anchors]extra now requiresopentimestamps>=0.4.5,<0.5(the consensus-criticalpython-opentimestampsLIBRARY on the 0.4.x line), so a future 0.5 wire/API change is a deliberate opt-in, not a silent break. Documented that theopentimestamps-clientCLI tool (0.7.x) is a SEPARATE package and not a proofbundle dependency.
Added (relation-statement/v0.1 3.5.0 — standalone profile + Rust parity, still EXPERIMENTAL)
relation-statement/v0.1standalone profile (WP-A): a DSSE-signed statement OVER a target receipt, carrying EXACTLY ONE typed edge and no decision/outcome payload of its own — the retroactive case the in-receipt edges cannot express (declaring a foreign or older receipt retracted / superseded / amended without emitting a successor and without touching the original). predicateType.../relation-statement/v0.1; new modulesrc/proofbundle/relation_statement.pyand CLIproofbundle relation-statement init|emit|verify|inspect(exit contract 0/1/2/3 identical to the decision/outcome paths). The edge validation, lineage resolution and therelationstrust-policy gate REUSE the in-receipt functions (relation.validate_relationships/verify_relationship_edges/evaluate_relations_policy) — no second implementation. Status-as-a-separate-object precedent: W3C Bitstring Status List v1.0, CT/OCSP revocation, SCITT protected-object-binding.relations.reject_retractedtrust-policy pin (WP-A): a relying party who knows BOTH the target and a verified retracts statement of a pinned/authorized signer can treat continued automated use of the target as an exit-3 block (LINEAGE_REQUIREMENT_FAILED);reject_supersededextends to the successor relations. Without the policy the verified statement is pure visibility. Honesty boundary (verbatim): a relation statement proves the issuer DECLARED the relation over exact bytes; it does not retract the target's cryptographic validity, and whether the issuer may declare it is a relying-party policy decision.lineagenever feedscryptoValid(lattice monotonicity).- Rust parity of the relation profile (WP-B): the independent Rust verifier
(
tools/pb_verify_rs) now carries the profile — new subcommandsverify-relation(in-receipt decision/outcome edges) andverify-relation-statement(standalone), with its OWN parser (serde_json + serde_jcs, sharing NO canonicalizer/parser with Python).crosscheck.pydrives ALL 40 relation vectors — decision, outcome and standalone, positive AND negative (incl. the 3.4.0 decoy-parent / subject-mismatch / signer / t1 vectors and the wrong-payloadType vector) — through BOTH implementations and asserts they land on the same common-vocabulary label (exit class + lineage) on every vector. Differential AGREEMENT on these vectors, not a correctness proof of either implementation. The parity registry (scripts/rust_parity_registry.json) is raised from PENDING to COVERED forrelation.verify_relationship_edgesand the newrelation_statement.verify_relation_statement, AST-verified byscripts/rust_parity_gate.py. The Vector × {Python, Rust} matrix is exported with an environment freeze (audit_artifacts/rust_relation_differential_matrix.json). - Conformance + tests: six new standalone vectors under
conformance/relation/(retracts verified+blocked, retracts visible, retracts unauthorized, retracts declared-unresolved, supersedes verified, malformed), a newrelation_statementrunner kind, property + never-raise + exit-contract tests (tests/test_relation_statement.py) and the Rust differential gate (tests/test_relation_statement_rust_parity.py). Existing 3.3.0/3.4.0 vectors and behaviour are unchanged (pure additivity, no wire break); the profile stays EXPERIMENTAL through 4.0.
Fixed (relation-statement/v0.1 3.5.0 — pre-release audit)
- Rust payloadType fail-open (BLOCKER, security): the independent Rust verifier's
verify_dssederived the PAE from the envelope's OWNpayloadTypewithout pinning an expected type, so the relation paths (run_verify_relationcrypto check andload_related) would AUTHENTICATE a same-key envelope presented under the WRONG payloadType — where Python pinsapplication/vnd.in-toto+jsonand rejects it.verify_dssenow takes anexpected_payload_typeand the relation paths pin the in-toto type, mirroring Python fail-closed; the genericverify-dssesubcommand stays deliberately type-agnostic. A new same-key wrong-payloadType differential vector proves BOTH implementations now reject it (exit 2, lineage FAIL). policy lintmissedreject_retracted(MAJOR):explain_policyhad noreject_retractedbranch, solint_policywrongly called areject_retracted-only policy a vacuous pass while the verify path enforces it at exit 3.explain_policynow lists it (explain⟺enforce parity).- Docs: the roadmap
O6_RETRACTS_NEVER_RAISESnote corrected to reserved (code-enforced + tested + mutation-killed, not yet a formal proof); the deadRELATION_STATEMENT_SCHEMA_VERSIONconstant removed; thereject_supersededdouble meaning cross-referenced inevaluate_relations_policy.
Added (relation/v0.1 3.4.0 — three lineage pins, still EXPERIMENTAL)
relation_signertrust-policy pin (WP-A, WHO may replace): a new fail-closedrelations.relation_signermap — per relation{"mode":"same-key"}or{"mode":"pinned","keys":[<b64>,…]}. The SUCCESSOR's issuer key must satisfy the rule (byte membership of the raw Ed25519 key, never a keyId alias). Unmet →RELATION_SIGNER_UNAUTHORIZED, exit 3. CLIdecision/outcome verify --with-related PATH --related-pub B64(position-paired) enables cross-issuer chains; the check runs against the key the target ACTUALLY verified under (verified_under), never a claim. In-toto layout/functionaries precedent (authorized keys per step); flat set, no thresholds (YAGNI).require_relation_target+targetSubjectDigestenforcement (WP-A2, WHICH parent — KERNFUND F1/O1/O2, from Loek Verdonk / No Silent Landing's byte-verified adversarial review):relations.require_relation_targetpins the expected parent content root(s) per relation; a supersedes-like edge that resolves to any OTHER (even valid) parent →RELATION_TARGET_MISMATCH, exit 3, on EVERY such edge, the accept path (T2) included — closing the decoy-parent gap whererequire_relation_resolutionalone only proved SOME edge resolves. The previously dormanttargetSubjectDigestedge field is now binding when PRESENT (gegengeprueft against the resolved target's subject; mismatch →RELATION_TARGET_SUBJECT_MISMATCH, lineage FAIL, exit 2).- Outcome-path relations gate (WP-B):
verify_outcome_receipt(..., policy=…)/outcome verify --policyenforce therelationssection identically to the decision path (require_relation_resolution / reject_superseded / relation_signer / require_relation_target), same exit-code contract and blocker names. trust_pack role auth is separate and unchanged. - Automation-surface consistency (WP-A3 / F5):
referencesResolvedis no longertruewhile a REQUESTED lineage relation is unresolved/failed;policy explainlists the two new pins (explain⟺enforce parity). - Conformance corpus: the shared
relations-policy evaluator (relation.evaluate_relations_policy, cut as its own function for the future relation-statement verifier) and new vectors for relation_signer, the decoy-parent fix (target-mismatch + must-pass gegenprobe + accept-path- documented no-pin old behavior,
crossFormatId xfmt-t3-decoy), thetargetSubjectDigestO2 gegenpruefung, a JCS-canonical invalid-signature vector (F2), and anoutcome_relationharness mirror. Lattice monotonicity preserved throughout: relation_signer / target-mismatch change ONLY the policy verdict, nevercryptoValid. relation/v0.1 stays EXPERIMENTAL.
- documented no-pin old behavior,
- Honest follow-ups (3.5.0): Rust differential parity for the new surface (NOT_RUN until the Rust core carries the profile); threshold signer sets and DID/VC identity indirection remain DELIBERATELY out of scope.
[3.3.0] - 2026-07-16
Added (relation/v0.1 lineage profile — EXPERIMENTAL)
relation/v0.1lineage/relationship profile (EXPERIMENTAL): an optional, SIGNEDrelationships: [edge, …]field on the decision-receipt and action-outcome predicates — typed edges (supersedes/revises/corrects/retracts/renews/derivedFrom/amends, closed vocabulary) onto a predecessor'sjcs-sha256-v1content root, with an EXPLICIT, never-defaulteddigestAlgorithm. Change is expressed by a declared, signed back-edge — never by mutation; the old receipt stays valid for its bytes forever. Docs:docs/predicates/relation.md; interop mapping to W3C PROV and the SCITT relationship draft checked against the draft-nobuo-00 FULL TEXT (noamendsexists there — stated honestly, not bent). New modulesrc/proofbundle/relation.py(fail-closed, never-raise validators; pure offline chain walk with per-path cycle detection and depth cap 32).- Lineage verdict in
verify_decision_receipt/verify_outcome_receipt(additiverelated=kwarg +lineageresult field with the four honest states VERIFIED / DECLARED_UNRESOLVED / FAIL / NOT_EVALUATED): computed only over authenticated bytes; lattice monotonicity proven by test — lineage never flips the crypto verdict in either direction, and DECLARED_UNRESOLVED never reads as a pass. - CLI
decision verify --with-related PATH(repeatable, offline, same-key contract): attached targets are verified standalone and keyed by their computed content root; an attached-but-unverified target FAILS lineage (present-and-wrong beats absent); a REQUESTED lineage check that FAILs exits 2, never a silent 0.lineageemitted in the--jsonreport projections. - Trust-policy v0.2
relationssection (require_relation_resolution,reject_superseded) enforced on the decision verify path with the LIVE automation blockerLINEAGE_REQUIREMENT_FAILED;policy explainlists both pins (explain⟺enforce parity); retracts-then-use blocked underreject_superseded. - Conformance corpus
conformance/relation/: 15 vectors end-to-end through the real CLI (new harness kinddecision_relation), the five skeleton vectors carryingcrossFormatIdxfmt-c0/t1–t4per the No Silent Landing shared-vector convention, plus the internal superset incl. the F6 malformed-digest never-raise vector (DSSE-crafted — the honest emitter refuses to emit it) and depth-exceeded (real hash cycles are impossible under content-root addressing; documented). - 3 new relation-specific mutation operators (
scripts/mutation_check.py: cycle-detection disabled, malformed-digest guard disabled, verified-flag laxened — each kill hand-verified), and a No-Fake aggregate fix (6-lens audit): a requested lineage FAIL is now visible in the libraryok/automation.safeForAutomationverdict via a derivedlineage_ok, not only at the CLI exit code (crypto verdict untouched — lattice monotonicity preserved). - New verify surface honestly registered PENDING in the Rust parity registry (differential
= NOT_RUN until the core carries the profile). Documented follow-ups:
relation_signer(pinned-set), outcome-path policy gate,relation-statement/v0.1standalone profile, and the still-unwritten SPEC.md profile chapter + GLOSSARY entry (the canonical prose lives indocs/predicates/relation.mdfor now).
[3.2.3] - 2026-07-15
Second remediation wave of the six-lens post-3.2.2 audit (Findings 01, 03, 11, 12, 15b, 16, 14a, 17, 18, 19, 20). Additive/non-breaking: no
existing result["ok"] field changes for any correct caller; every new field/param is opt-in with a
backward-compatible default — with ONE deliberate exception (honest, not opt-in): the Finding 15b
VerificationBudget DoS ceilings are enforced unconditionally, so an input that was previously accepted
but is over a generous limit (a DSSE envelope with >512 signature entries, a Trust Pack keys/role
keyIds map with >256 entries, a renewal sequence with >10,000 ArchiveTimeStamp entries, or a >8 MiB
DSSE payload) now fails closed. The ceilings sit far above any legitimate receipt/pack/sequence; they are
a DoS backstop, not a behavioural knob.
Security (combined-integration review hardening)
A four-lens combined-integration review of this wave, plus an orthogonal-refuter iteration that refused to rubber-stamp the first round of fixes, surfaced real fail-open/DoS gaps that each passed the per-finding tests but not adversarial cross-checking; all are fixed and covered by bidirectional, mutation-verified tests:
- DSSE / parse DoS (Finding 15b, extends the cap):
dsse.verify_envelope— the single chokepointdecision/outcome/verification_summary/run_ledgerverify funnel through — now caps the attacker-controlledsignatureslist BEFORE its verify loop; andloads_strict(the ONE parse chokepoint every verify path funnels through) now refuses raw input overbudget.input_bytesBEFOREjson.loads(an unbounded parse of a 50 MB envelope was a real pre-loop DoS the signature cap could not reach) and enforces the previously-deadbudget.json_nodesas a parsed-structure node-count cap.budget.signatureswas raised 64 → 512 so a legitimate two-stage rotation envelope (new-root threshold + old-root vouch reuse onesignatureslist) still verifies.trust_pack.verify_trust_packnow also fails closed with a cleanBundleFormatErroron a non-listsignatures(JSONtrue/ a huge dict), which previously skipped its cap or raised an uncaughtTypeError. A second refuter round further capped the raw base64payloadindsse._payload_bytesBEFORE it is decoded (the decode, run twice per verify, was a layer earlier thanloads_strict), and added the same pre-decode cap toanchors_markovian(mirroringanchors_chia). require_external_tokenfail-closed on absent token (Finding 14a):renewal.verify_sequence(..., require_external_token=True)now appends a FAILINGrenewal:external_tokencheck when the newest ATS carries noexternal_token_type. The external-token fields are deliberately outside the signed ATS bytes, so an attacker/MITM could strip them; previously the whole block was skipped and.okwas unaffected — a silent no-op "require".- Receiver independence is enforced, not just labeled (Finding 16):
assurance. classify_receiver_corroborationnow takesexecutor_key_id/receiver_key_idand reachesINDEPENDENTLY_ATTESTEDONLY when BOTH key ids are present AND differ.executor.keyIdis schema-optional and executor-controlled, so a one-sided check would be evaded by simply omitting one's own keyId; an absent executor key id now blocks promotion too, and both key ids must be STRINGS (a second refuter round found that a non-strreceiverKeyIdwrapping the executor's own id, e.g.["kid-exec"], is!=the str"kid-exec"and would read as "distinct"). Wired throughoutcome.verify_outcome_receipt. Honest inherent limit: two distinct keys can still belong to the same principal — principal-level independence needs theoutcomeReceiversTrust Pack role (an out-of-band trust binding), documented in the code.
Plus: decision/outcome verify --json now emit automation/evidence_levels/receiver fields (a
jq filter no longer gets null, indistinguishable from a real "not evaluated"), and assurance/
budget/automation_verdict are now top-level from proofbundle import … exports.
Added
-
Finding 01 — uniform automation-safety verdict: new
automation_verdict.automation_summarymirrorsbundle.py::root_authenticity_summary'ssafeForAutomation/automationBlockerspattern for the other five receipt-chain predicates. Each ofdecision.verify_decision_receipt,outcome.verify_outcome_receipt,trust_pack.verify_trust_pack,verification_summary.verify_verification_summaryandrun_ledger.verify_run_ledgernow stashes aresult["automation"]dict;safeForAutomationrequires the policy/authorization dimension to beTrueEXACTLY (never merelyis not False, unlike the permissiveokaggregate).outcome.pyalso gains an optionaltrust_packparameter (outcome.executor_trusted_by_role) that checks the executor'skeyIdagainst the Trust Pack'soutcomeExecutorsrole — closing the gap docs/predicates/action-outcome.md §7 listed as open/future work. -
Finding 03 — EvidenceLevel ladder: new
assurance.py(EvidenceLevel,classify_digest_evidence,evidence_ladder_summary/evidence_ladder_best) makes the STRENGTH of a digest-presence "proven"/"bound" claim explicit and orderable (CLAIMED < REFERENCE_WELL_FORMED < CONTENT_RESOLVED < RECEIPT_CRYPTO_VERIFIED < POLICY_AUTHORIZED < INDEPENDENTLY_ATTESTED < EFFECT_OBSERVED).decision.verify_decision_receiptandoutcome.verify_outcome_receiptgain an additiveresult["evidence_levels"]plus an optionalevidence_resolvercallable that, when supplied, wires the previously-unuseddecision.resolve_evidence_refprimitive into the actual verify path (a digest can now reachCONTENT_RESOLVED, not onlyREFERENCE_WELL_FORMED). The pre-existing booleanaction_outcome_proven/outcome_execution_proven/evidence_boundfields are UNCHANGED.EvidenceLevel.EFFECT_OBSERVEDis a real, orderable enum member that stays structurally unreachable (a real-world effect-observation channel is a separate, inherent limit outside this repo — see the Finding 16 entry below for what its self-fixable part DOES now reach:INDEPENDENTLY_ATTESTED) — an explicitEFFECT_OBSERVED_NOT_IMPLEMENTEDmarker documents this rather than silently omitting it. -
Finding 15b — VerificationBudget: new
budget.py(VerificationBudget,DEFAULT_BUDGET,BudgetExceeded) centralizes the DoS-guard pattern already used ad hoc bysdjwt._MAX_DISCLOSURES,statuslist._MAX_STATUS_LIST_BYTES,hf_evals._MAX_TOKEN_BYTESandanchors_chia._MAX_LAYERS/_MAX_PROOF_BYTES(which are unchanged and stay the authoritative caps for their own surfaces). Wired concretely into the two identified unguarded paths —trust_pack.validate_trust_pack_predicate'skeysmap / per-rolekeyIdscounts, andrenewal.verify_sequence's total ArchiveTimeStamp count across a whole sequence — plus a generousinput_bytescap on the raw DSSE payload bytes (checked BEFORE JSON parsing) on every one of the five receipt-chainverify_*entry points named above.BudgetExceededis aProofBundleErrorsubclass, so every existingexcept (ProofBundleError, ...)call site already handles it identically to any other malformed/over-limit input. -
Finding 16 — outcome receiver/observer corroboration (self-fixable part, additive): an optional
receiverRefs[]onaction-outcome/v0.1(digest-bound exactly likeevidenceRefs[]) plusassurance.classify_receiver_corroborationlet a genuinely independent, cryptographically verified receiver/observer statement reachEvidenceLevel.INDEPENDENTLY_ATTESTED(given a newreceiver_attestation_resolverparameter onoutcome.verify_outcome_receipt); an additiveoutcomeReceiversTrust Pack role (outcome.receiver_trusted_by_role, mirrorsoutcomeExecutors) lets a verifier check that party against a known list, deliberately advisory (never wired into the aggregateok, sincereceiverRefsis optional supplementary evidence). Also additive:sequence.{runId,seq}+outcome.detect_outcome_sequence_gapsfor spotting a suppressed outcome later in the same run, when the executor opts in. Fully backward compatible — a receipt with noreceiverRefs/sequenceis unaffected. Honest, INHERENT limit this increment does NOT close: proofbundle cannot itself make a downstream system SIGN a receiver acknowledgement (ecosystem adoption, outside this repo);EvidenceLevel. EFFECT_OBSERVEDstays structurally unreachable even with a verified receiver corroboration (still a receipt about the effect, never a live observation of it) — seeassurance.EFFECT_OBSERVED_NOT_IMPLEMENTED. -
Finding 14a — RFC-3161/OTS↔ArchiveTimeStamp integration glue + truncation detection (additive, ADR 0006 B3 OPEN items): an
ArchiveTimeStampmay now carry a DETACHEDexternal_token_type/external_token/external_token_frozen, verified by the newrenewal._verify_ats_external_tokenvia the ALREADY-HARDENED standaloneanchors_rfc3161.verify_rfc3161/anchors_ots.verify_opentimestamps— pure glue between two already-hardened modules, no new cryptography.renewal.verify_sequencegainsrp_trust(relying-party TSA-root/Bitcoin-header trust material, WP-A1 discipline) andrequire_external_token(demand the full verified state, not merely OTS-pending). Separately,verify_sequence(..., known_newest_token_digest=…)closes the "a stale prefix of a legitimately-renewed sequence still verifies" gap: when the relying party supplies the digest of the newest ATS it last observed (its own persisted state — noRelyingPartyStateStoreexists in this repo, so this is the additive-parameter fallback), a truncated/rolled-back sequence fails the newrenewal:no_rollbackcheck. None of the three additions are surfaced unless the caller opts in — fully backward compatible with every existingArchiveTimeStamp/sequence. Still OPEN (honest, unchanged): the full ASN.1/XMLERS export and a signature-algorithm staleness trigger inRenewalPolicy— seedocs/adr/0006-anchor-longevity.md. -
Finding 11 — Rust-parity honesty gate:
scripts/rust_parity_gate.pyAST-scans everysrc/proofbundle/*.pyfor a module-levelverify_*function (ground truth, rediscovered each run) and cross-checks it against the declarativescripts/rust_parity_registry.json. Every COVERED/PARTIAL claim is verified against REAL evidence — the claimedrust_subcommandmust be an actual match arm inmain.rs, appear in the built binary's self-declaredcoverage-report, and the claimed crosscheck call site must literally exist; a stale claim is caught (STALE_COVERED_CLAIM), a new untrackedverify_*isUNTRACKED, a danglingpython_refisORPHANED. Advisory by default,--strictexits 1 on a registry-integrity problem, never on an honestly-declared PENDING. First real portation:main.rs'sverify-trust-pack-threshold(root-of-trust threshold check, Ed25519 leg only, reported PARTIAL not COVERED; mldsa65/hybrid skipped-and-reported, never silently accepted). New advisory CIrust-parityjob (continue-on-error, non-blocking). -
Finding 12 — external-audit readiness package (NOT_SELF_FIXABLE, readiness only): no audit is performed or simulated.
docs/AUDIT_SCOPE.md(STABLE vs. EXPERIMENTAL module table cross-checked against docstrings/SPEC/CODEOWNERS/CHANGELOG, coupled to a format-freeze mechanism),docs/ AUDIT_READINESS.md(OSTIF-facing briefing of existing hardening evidence, honest current-state, no audit-completion claim),docs/adr/0007-crypto-agility-alg-dispatch.md(the alg-dispatch pattern trust_pack.py and renewal.py share), aRevision:header onTHREAT_MODEL.md, a dedicated "Security Audit"funding.jsonpurpose. Surfaces a CODEOWNERS gap (checkpoint.py/renewal.py/ anchors_chia_add.py missing from the review-required path list) without silently fixing it. -
Finding 18 — Evaluation Cards (P2, additive): optional
evaluation_card_sha256claim field +src/proofbundle/evalcard.py(evaluation_card_hash/verify_evaluation_card), mechanically identical toprereg_sha256; references the Hugging Face EvalEval Coalition's Evaluation Cards (arXiv:2606.09809) rather than inventing a proofbundle-specific format. CLIproofbundle evalcard <card> [--check RECEIPT]mirrorsprereg. -
Finding 19 — computation-correctness / enclave assurance wiring: the enclave RATS/EAT bridge was already implemented but README's roadmap misclassified it as not-yet-built (a No-Fake UNDERclaim) — README framing corrected. Real gap closed:
assurance_level=enclave_attestedwas an unverified string; newevalclaim.enclave_assurance_proven(claim, bundle, eat_jws=…, verifier_pubkey=…)(analogous todecision.action_outcome_proven) optionally corroborates the declared level against a real, receipt-bound Attestation Result (True/False/None), wired intoshow-eval --eat/--verifier-key/ --profile. Lazy function-local import keeps the ExperimentalWarning from firing on plainevalclaimimport; never force-promotes the signedassurance_level. -
Finding 17 — benchmark-hacking VISIBILITY (OPEN_BY_DESIGN): visibility only, no anti-hacking guarantee built or implied (BenchJack, arXiv:2605.12673, cited in THREAT_MODEL.md). Optional provenance sub-keys
run_attempts/aborted_runs(non-negative ints) andmethodology_sha256/benchjack_audit_report_sha256(plain sha256 references), wired viaadapters/_provenance.py; zero schema change (provenance is free-form) and zerointoto.pychange (to_test_result_statementalready copies the whole provenance dict verbatim — proven by a new regression test).
Deferred (tracked, not built this increment — the one deliberately BREAKING piece)
bundle.py's CLIverifyexit-code default is NOT changed by this increment.root_authenticity_summaryalready computessafeForAutomation/automationBlockerscorrectly (unaffected); a FUTURE v4 could add an opt-in--strict-automationCLI flag that gates the process exit code onsafeForAutomationinstead of the current crypto-only exit contract (aPOLICY_NOT_EVALUATEDreceipt would then exit non-zero even thoughCRYPTO: OK). That flip would be a REAL default-behavior change for any script parsing exit codes today, so it is explicitly NOT flipped as a default here — only the opt-in flag is a plausible v4 addition, and even that is not implemented in this increment (No-Fake: the capability described above IS built and IS additive; only the CLI default-exit-code change is the deferred, tracked item).
Added — SD-JWT VC interop, Finding 20 (issue #27)
- ES256 issuer-signature verification.
sdjwt.verify_sd_jwtnow verifies ECDSA P-256 (ES256, RFC 7518 §3.4) issuer signatures alongside EdDSA, dispatched strictly on the issuer JWT header's literalalgclaim — the algorithm the EUDI Digital Identity Wallet and the OAuth WG's own SD-JWT VC worked examples use, closing proofbundle's biggest SD-JWT VC interop gap (previously every real-world ES256 credential could only be checked structurally, never cryptographically). New primitivesignature.verify_ecdsa_p256(65-byte SEC1 uncompressed public key, RFC 7518 §3.4's fixed-width 64-byteR‖SJWS signature, converted to DER before calling intocryptography— never hand-rolled ECDSA math).bundle.py'ssd-jwt-issuer-identityfingerprint prefix is now alg-aware ("ed25519:"/"es256:") rather than hardcoded to EdDSA — a latent false-reject the new algorithm would otherwise have exposed for an ES256-signedsd_jwt_vcthat discloses proofbundle's ownissuerclaim format. - Trust-policy
sd_jwt.expected_vct. A relying party can now pin an exact requiredvctin the bundle trust policy (policy.py's_SDJWT_KEYS);evaluate_policyadds apolicy:expected_vctcheck, read ONLY from an issuer payload whose signature actually verified (mirrors the "verified vs. merely present" disciplinepolicy:nonce_presentalready established — an unverifiedvctclaim proves nothing). Complements, and is distinct from,sdjwt_vc.py's standalonevctAllowlist. - Real cryptographic external conformance.
tests/fixtures/sdjwtvc/now also vendors the ES256 issuer public key the OAuth WG's 5 worked SD-JWT VC examples are signed under (from the same pinned commit'sexamples/settings.yml, independently re-verified before vendoring);test_sdjwtvc_external_vectors.pycryptographically verifies the issuer signature end-to-end, not just the structural disclosure-commitment path (previously honestly out of scope — see the removedtest_all_examples_have_es256_issuer_alg_by_design_not_checked_hereboundary marker). No official NEGATIVE SD-JWT VC vectors were found upstream (checked oauth-wg/oauth-sd-jwt-vc, oauth-wg/oauth-selective-disclosure-jwt, and openwallet-foundation-labs/sd-jwt-python'stests/testcases/— every published example in all three is a positive structural variant), so the new negative tests adversarially mutate the vendored positive vectors in code instead (the established pattern this suite already used fortest_tampered_disclosure_is_rejected). - Docs:
docs/SD_JWT_VC_PROFILE.mdupdated to reflect the above against issue #27's roadmap;SPEC.md§6 documents the alg-keyedissuer_public_key_b64encoding and the alg-awaresd-jwt-issuer-identityfingerprint.
Backward compatible: EdDSA-signed SD-JWTs verify exactly as before (same primitive, same call
sites); the sd_jwt.expected_vct policy field is opt-in (absent = unchanged behavior).
[3.2.2] - 2026-07-15
Security and robustness hardening from a six-lens plus red-team audit of 3.2.1. Additive; no wire-format change and no change to a correct 3.2.1 caller's result unless the input was actually malformed or over-limit.
Security
- SD-JWT recursive-disclosure O(n^2) CPU-DoS fixed (CWE-400/407):
verify_sd_jwt's fixpoint resolution was quadratic under adversarially-ordered disclosures (n=4000 drove ~11s of CPU from a 520KB bundle, reachable viaverify_bundle). Rewritten as an O(n) BFS/worklist over disclosures grouped by digest, plus a fail-closed_MAX_DISCLOSURES = 256cap before any per-disclosure work.
Hardening
- Decision/outcome schema is a strict SSOT now:
decisionIdtype/non-empty enforced;actionOutcome/validitymust be JSON objects when present; emptyparametersRefrejected;additionalProperties:falseenforced on every sub-object of both*.schema.json; the existingnested_closure_violationsis wired into the validators; newtest_schema_parity.pyproves the hand validator and the docs schema agree on every golden example and 11 negative vectors. - Decision subject-rehang gate:
verify_decision_receiptclassifies the subject (opt-inrequire_derived_subject, CLI--require-derived-subject); a caller-overridden subject that does not derive from the predicate can be made fail-closed. Corrected an earlier CHANGELOG claim that overstated the outcome default as "fail-closed" (it is warned-by-default, fail-closed only opt-in). - Trust-pack crypto-agility: root and rotation keys carry an explicit
alg(ed25519default,mldsa65, orhybrid-ed25519-mldsa65), verified alg-aware via the existingpqsigML-DSA (FIPS 204) paths; a policy-declared hybrid key is not satisfied by an Ed25519-only signature (downgrade defense). Backward compatible foralg-less packs. - Renewal seed is verifiable, not a bare label:
renew_*accept an immutableVerifiedAnchorResultbound to the prior ATS digest (require_verified_prior); the self-assertedanchor_statuspath is taggedrenewal_seed_evidence_classrather than silently trusted. - Public-transparency consistency is typed and bound: a
ConsistencyVerificationResult(old/new origin, tree size and root, plus proof/verifier/policy digests) replaces the ungrounded boolean; the confirmed claim is re-bound to the checkpoint actually being evaluated (strict_consistency), catching wrong-pair and split-view inputs.
[3.2.1] - 2026-07-14
Anchor-longevity hardening release. A six-lens plus red-team audit of the 3.2.0 anchor modules (EXPERIMENTAL) found the core cryptographic verify paths solid: every direct forgery attempt was repelled. This release closes a cluster of defense-in-depth gaps in non-default, incomplete-caller and weak-input cases. Additive only; no wire-format change and no change to a correct 3.2.0 caller's result.
Security and correctness (fail-closed hardening)
require_pqis a verified-PQ floor, not a label.renewal.verify_sequence(..., require_pq=True)now passes only when the newest ArchiveTimeStamp's post-quantum signature was actually verified (authority-key mode). A post-quantum label onsig_algunder ananchor_verifieror unauthenticated anchor no longer satisfies the floor, because a label is not verification.- Future-dated ArchiveTimeStamp flagged.
evaluate_renewal_policyreports a newest ATS whose time is in the future as anomalous rather than perpetually fresh (its age went negative, which could otherwise evade the renewal-due signal). - Hash-strength surfaced.
verify_sequenceemits arenewal:current_hashcheck when the newest ATS uses a deprecated hash, so.oknever hides it, and fails closed under the newrequire_current_hash=True. - Trust-pack version chain enforced. A
version > 1pack with a nullprevVersionDigest(a "version-2 genesis") is rejected at validate time; it previously skipped two-stage rotation authorization. - Trust-pack expiry parses fractional seconds. A validator-legal RFC-3339
expireswith fractional seconds is now parsed correctly; a valid future expiry with a fraction was read as expired (a false-closed availability bug from a regex/parser divergence).
Conformance
- SD-JWT recursive disclosures (RFC 9901).
verify_sd_jwtresolves nested_sddigests committed inside a parent disclosure's value via a fixpoint, so valid official recursive-disclosure vectors no longer failstructure_ok. Security is unchanged: every disclosure must still be transitively rooted in the issuer-signed payload.
Tests
- New
tests/test_anchor_hardening_321.pypins each finding red to green; trust-pack version-chain and fractional-seconds tests; five new mutation operators inscripts/mutation_check.py(55 operators, 0 gaps). - Vendored external-vector test suites, each fixture provenance-pinned (source URL + commit + sha256) and
cryptographically self-verified rather than merely copied: NIST ACVP ML-DSA (FIPS 204) sigVer vectors
cross-checked against the official answer key (the
verify_mldsaexternal/pure/empty-context surface is 3 vectors per parameter set, an honest property of the source data); real OpenTimestamps fixtures from the opentimestamps examples (pending path unconditional; the confirmed Bitcoin-anchored path skips where the OpenSSL legacyripemd160provider is unavailable); C2SP signed-note checkpoint KATs (sum.golang.org vkey read from the pinned Go toolchain source, Rekor v2 key decoded from Sigstore's trusted root); SD-JWT-VC structure vectors from the OAuth-WG editor's copy (signature verification stays EdDSA-only by design).
[3.2.0] - 2026-07-14
The eval → decision → outcome chain, plus a trust root and transparency/credential layers. Everything in
this release is EXPERIMENTAL (a v3 preview: API and wire format may change without deprecation; do not
depend on it in production). Additive wire format; no change to the shipped eval-result / decision-receipt
verify paths. Each predicate carries a hand-rolled fail-closed validator (the JSON Schemas remain docs-only)
and states its non-claims as explicitly as its guarantees. Predicate docs under
docs/predicates/.
Added — anchor longevity (EXPERIMENTAL, ADR 0006)
- Long-term evidence mechanics so an anchor keeps its force as algorithms age. All EXPERIMENTAL, additive, fail-closed.
hashalg— an explicit hash-algorithm registry (RFC 6920 model, RFC 4998digestAlgorithmOIDs) with fail-closed resolution (no implicit SHA-256; deprecated/unknown rejected) and a dual-hash for new receipts.renewal— an RFC 4998ArchiveTimeStampSequence(timestamp + hash-tree renewal) with an offline end-to-end verify, plus aRenewalPolicy(watch-only-newest, no network). An ArchiveTimeStamp MAY carry a real time-authority signature (the RFC-4998 TimeStampToken role; unsigned/legacy stays valid) with the algorithm bound into the signed bytes (downgrade defense); renewal MIGRATES it ed25519 → hybrid → mldsa65 (B3↔B5);verify_sequence(authority_keys=…)checks the newest signature against the relying party's trusted keys, fail-closed by default with an optionalrequire_pqfloor. ASN.1/XMLERS export, a real external RFC-3161/OTS-token binding, and truncation/rollback detection stay OPEN.pqsig— ML-DSA (FIPS 204) verify/sign + a hybrid Ed25519+ML-DSA verify, wired intorenewal's signature migration. SLH-DSA (FIPS 205) is OPEN (PQUnavailable).evidence_pack— an offline OTS evidence pack (no network at verify); the WP-A1 boundary holds (a bundled header is producer evidence, never trust). A real confirmed-receipt pack is OPEN (needs a calendar submit).
Added — action-outcome/v0.1 predicate (EXPERIMENTAL, O1)
- A signed record that a specific executor carried out (or refused/failed) the action a Decision Receipt
permitted, bound by content root to that decision (
decisionRef), with the requested action and observed effect digest-bound.proofbundle outcome init|emit|verify|inspect. - Verify is fail-closed: crypto → predicate type →
hash_binding→decision_bound(embeddeddecisionRefequals the expected content root) →role_separation(executor ≠ decision maker) →execution_proven(status = executedonly proven when aneffectDigest/actualActionDigestbacks it, else self-asserted + warning) → audience/nonce. No-Overclaim: never proves the action was correct or safe. Doc:docs/predicates/action-outcome.md.
Added — trust-pack/v0.1 predicate (EXPERIMENTAL, O2)
- A TUF-inspired root of trust: roles →
{keyIds, threshold}, akeyId -> publicKeymap, an offlinerevokedlist, a monotoneversionwith aprevVersionDigestchain (rollback/freeze protection), andexpires.verify_trust_packcounts DISTINCT valid non-revoked root signatures against the root threshold; validation is dead-on-arrival aware (a root that can never meet threshold is rejected). Doc:docs/predicates/trust-pack.md.
Added — verification-summary/v0.1 predicate (EXPERIMENTAL, O4)
- A signed roll-up of a receipt chain: per level (eval/decision/outcome) the verified receipt content root,
status (
VERIFIED/FAILED/NOT_EVALUATED), and evidence class, with a mandatorynonClaimsblock. Thelevels_consistentrule is real (non-tautological): aVERIFIEDlevel MUST carry areceiptRef; aNOT_EVALUATEDlevel without one stays consistent. Doc:docs/predicates/verification-summary.md.
Added — run-ledger/v0.1 predicate (EXPERIMENTAL, O5)
- A signed, gap-free run history against best-of-many cherry-picking: a strictly monotone
seq(no gaps), aprevDigestchain (each run links the previous run'sresultDigest), aborted/failed runs kept VISIBLE, and arunBudgetdeclared up front. A selection that drops the bad runs cannot produce an intact chain. Doc:docs/predicates/run-ledger.md.
Added — public-transparency policy layer (EXPERIMENTAL, O3)
public_transparency.py::evaluate_public_transparencycomposes the existing C2SP checkpoint primitives into one relying-party verdict with named statuses (LOG_ORIGIN,CHECKPOINT_SIGNATURE,ROOT_BYTES_AUTHENTICITY,TREE_CONTEXT_AUTHENTICITY,CONSISTENCY,WITNESS_QUORUM,PUBLIC_TRANSPARENCY), fail-closed (a required-but-unevaluable check is FAIL; an optional un-requested check isNOT_EVALUATEDand stays visible). It is a library layer; wiring it into the--policyFILE of the reference CLI remains proposed (seedocs/PUBLIC_TRANSPARENCY_PROFILE.md).
Added — subject-binding + SD-JWT VC layers (EXPERIMENTAL, O6, O7)
subject_binding.py: classifies a Statement subject asDERIVED(SHA-256 over the RFC-8785 canonical predicate, re-derived and matched) vsEXTERNAL_ATTESTED(override/tamper). AnEXTERNAL_ATTESTEDsubject is warned by default on both the decision and outcome verify paths; it is only fail-closed when the caller opts in viarequire_derived_subject/decision verify --require-derived-subject/outcome verify --require-derived-subject. Plus nested schema closure. Doc:docs/SUBJECT_BINDING.md.sdjwt_vc.py: an SD-JWT VC relying-party profile (typ = dc+sd-jwt,vctallowlist, offline type-metadata integrity, holder-binding required). SSRF-safe by construction — no network I/O, a URLvctis an opaque identifier and never dereferenced. Doc:docs/SDJWT_VC_PROFILE.md.
Governance
- CODEOWNERS covers the seven new EXPERIMENTAL security modules (outcome, trust-pack, public-transparency, subject-binding, run-ledger, verification-summary, sdjwt-vc) — no self-merge for a security path.
[3.1.3] - 2026-07-13
Security hardening release: the remaining P0 findings of the 3.1.1 audit round (verified live
against 3.1.2). Additive wire format; one deliberate, security-motivated tightening of
safeForAutomation (see below). SPEC revision 2026-07-13.
Changed — safeForAutomation requires the ATOMIC (root, tree size) authentication (A-P0-1, security)
- The sharp audit vector: an RFC 6962 inclusion proof constrains
(leaf_index, tree_size)only up to path-shape equivalence — a real 2-leaf receipt (index 1) relabelled as(index 2, tree_size 3)verifies with the SAME payload, signature, root and proof. A root-BYTES pin cannot tell the two apart (both share the root), sorootAuthenticity: PASS+safeForAutomation: truewas reachable for a forged tree context. NowsafeForAutomationadditionally requiresTREE_CONTEXT_AUTHENTICITY: PASS: root AND tree size authenticated atomically from ONE source — a signed C2SP checkpoint (--trusted-checkpoint/--checkpoint-vkey, or a policymerkle.trusted_checkpointsentry) or an--expected-root+--expected-tree-sizePAIR. A naked root pin is reported asrootTrustLevel: ROOT_BYTES_ONLYand never automation-safe (new blockerTREE_CONTEXT_NOT_AUTHENTICATED). - New additive verdict keys:
rootBytesAuthenticity(the legacyrootAuthenticitykey stays as its wire-compat alias),treeContextAuthenticity,checkpointAuthenticity,rootTrustLevel(CHECKPOINT/ROOT_AND_TREE_SIZE_PINNED/ROOT_BYTES_ONLY/NONE). - New policy section
merkle.trusted_checkpoints[]: pins a SIGNED(origin, treeSize, root)triple (C2SP vkey + signature blob, optionalvalidUntil); signature-verified, expiry-checked,hashAlg-checked at evaluation — origin/size/root tamper invalidates the signature. A non-empty list enforces on its own, exactly liketrusted_roots.
Fixed — expired eval policy now FAILS the policy evaluation (A-P0-2, security)
- The decision path already rejected an expired policy (exit 3); the EVAL path did not — an expired
eval policy still produced
POLICY: OK/ exit 0 (onlysafeForAutomationwent false). Lifecycle is now part ofevaluate_policyitself:policy:not_template,policy:not_expired,policy:not_before(new additivevalid_fromfield) →POLICY: FAIL, exit 3, path parity. - Historical verification is explicit-only:
verify --verification-time <ISO-8601> --policy …evaluates the policy lifecycle AS OF that instant with labelled output (VERIFICATION_TIME: HISTORICAL,CURRENT_POLICY_STATUS,HISTORICAL_POLICY_STATUS); an expired-today policy keepssafeForAutomation: falseeven in historical mode. No silent backdating, no silent acceptance.
Fixed — decision validity.audience type confusion (found by the new regression corpus, security)
- A wrong-TYPE
validity.audience(a STRING instead of the required array) satisfied a requested audience binding via Python substring matching ("rp.example" in "rp.example"). The check now requires a real JSON array (fail-closed).
Added — policyPurpose binds a policy to ONE verifier path (A-P0-4)
- New additive field
policyPurpose∈eval/decision/outcome/trust-pack/public-transparency(the latter three reserved for the 3.2.0 verifiers). The eval verify path accepts onlyeval, the decision path onlydecision; the wrong purpose is exit 3. Policies without the field keep working (documented transitional default);policy lint --strictrequires it. All five shipped profiles now declare their purpose.
Added — hardened policy metadata (A-P0-5)
merkle.trusted_rootsentries are hard-validated at load (standard base64, exactly 32 bytes) with their OWN error — never a silent never-matches.- Reserved metadata (
deploymentReady,requiresIdentityOverlay,policyPurpose,schema,generatedFromTemplate) can no longer be set by an instantiate overlay (loudPolicyError);deploymentReadyis DERIVED from the final instance (identity pinned AND trust material valid AND purpose defined AND lifecycle valid AND not a template), and instances record theirgeneratedFromTemplateprovenance. Contradictory metadata (deploymentReady: true+requiresIdentityOverlay: true) is refused at load.
Hardened after the 6-lens adversarial review (folded back before release)
- Historical mode is present-tense-safe (was the one release-blocker).
safeForAutomationis a "safe to act on now" verdict, so its lifecycle and tree-context inputs are always evaluated at the REAL current time — even under--verification-time. Previously onlyvalid_untilhad a current-time backstop, so a not-yet-valid policy or an expired-todaytrusted_checkpointsentry read automation-safe when a past (or future) instant was supplied. Now:--verification-timeMUST be a past instant (a future one is exit 2); a newPOLICY_NOT_YET_VALIDblocker mirrorsPOLICY_EXPIRED; and the policy is evaluated twice in historical mode (the historical instant for the exit code + label, the current time forsafeForAutomation).CURRENT_POLICY_STATUSnow surfacesNOT_YET_VALID. - No
rootTrustLevel: CHECKPOINT/checkpointAuthenticity: PASSoverclaim.checkpointAuthenticitynow reports whether a checkpoint authenticated AND matched this bundle, not merely that some pinned checkpoint's signature verified; a verified-but-non-matching checkpoint reads FAIL and never labels a pair-derived contextCHECKPOINT. - A matching
trusted_checkpointspin satisfiesrequire_authenticated_root(a checkpoint cryptographically authenticates the root, strictly stronger than atrusted_rootsbyte-pin) — the checkpoint match is now evaluated before the authenticated-root check. treeSizeExpectationreportsFAIL(notNOT_REQUESTED) when a checkpoint was supplied but its signature did not verify.policyPurpose: nullnow loads and is treated exactly like absent (schema⟺parser parity).evaluate_policyfails closed (no traceback) on a non-stringcheckpointSignerin a raw dict that bypassedload_policy.policy explainlists the raw-template pin sopolicy lintno longer calls a minimal template vacuous.
Migration notes
- A
merkle.trusted_rootslist mixing a valid 32-byte root with a malformed entry now fails to LOAD (exit 2) instead of silently skipping the bad entry (A-P0-5 hard validation); fix the malformed pin. policy lint --strictnow FAILs any policy withoutpolicyPurpose(declare the verifier path); plainverifyand non-strictlintare unaffected.- Policy artifacts produced by 3.1.3
policy instantiatecarrypolicyPurpose/generatedFromTemplateand are refused (fail-closed unknown-field) by 3.1.2 verifiers. In a mixed-version fleet, upgrade verifiers to 3.1.3 before re-instantiating policies. Bundles and verify output stay additive. instantiate_template(..., overlay={...})now rejects the reserved keysdeploymentReady,requiresIdentityOverlay,policyPurpose,schema,generatedFromTemplate(they are derived or lifecycle-fixed); avalid_untiloverlay still works.
Regression corpus (A-P0-1 … A-P0-5, plus the A-P0-3 vectors closed in 3.1.2)
tests/test_tree_context_authenticity.py(relabel reproduction, checkpoint pin closes it, origin/ signer/expiry/hashAlg mismatch vectors, legacy-root-pin-never-tree-context, CLI checkpoint path),tests/test_policy_lifecycle_purpose.py(lifecycle, historical mode, purpose matrix, metadata hardening, and the named decision aud/nonce fail-closed vectors), andtests/test_lens_review_fixes_3_1_3.py(the historical fail-open, future-instant rejection, expired-today checkpoint, require-auth-root-by-checkpoint, lib robustness, explain parity, policyPurpose null). Note: decision-receipt/v0.1validitycarries onlyaudience+nonce— there is no predicate-level time window to expire; time-windowing lives on the policy (valid_from/valid_until), a predicate-level window would be a format change for the next breaking version.
[3.1.2] - 2026-07-13
Patch release: one fail-closed security fix on the decision-verify path. No new API, no wire-format change, fully backward-compatible.
Fixed — decision verify audience/nonce binding is fail-closed on an absent validity object (security)
- A relying party that supplies
--aud/--nonce(orexpected_audience/expected_nonce) is asking for RFC-9901-§7.3-style replay/audience binding. Previously, if the decision receipt carried novalidityobject (or a non-dict one), the checks were skipped entirely —audience_ok/nonce_okstayedNone, the CLI exit gate (None is not False) let it pass exit 0, and the requested binding was silently unenforced (a fail-OPEN downgrade). Now an absentvalidity/audience/nonceis a FAIL (audience_ok/nonce_ok=False, exit 2) — the requested binding is never silently dropped. This mirrors the eval-path F4 hardening and the 3.1.1 decision-path template/expiry gates. Regression:tests/test_decision_hardening.py::test_missing_validity_with_expected_aud_nonce_fails_closed.
[3.1.1] - 2026-07-13
Patch release: automation-safety hardening. Three additive gates plus one fail-closed security fix,
all backward-compatible at the wire-format level. The one behaviour change is deliberate and
security-motivated: safeForAutomation is now stricter (see the note below).
Changed — safeForAutomation is a stricter, global trust verdict (AP-1, behaviour change)
safeForAutomationis nowtrueonly when the crypto verdict passed, the Merkle root was affirmatively authenticated, a supplied trust policy PASSED (policy_ok is True— no policy, i.e.None, never qualifies), that policy actually pins a trusted signer, it carries no blocking warning, it is not expired, and no required anchor / public-transparency / replay gate FAILED. A verify that previously reportedsafeForAutomation: trueon a crypto-valid, root-pinned receipt without an evaluated, signer-pinning policy now reportsfalse. This is intended: the flag is a global "safe to act on automatically" verdict, not a crypto-only verdict.- New machine-readable
automationBlockersarray names every reason the flag is false (POLICY_NOT_EVALUATED,POLICY_FAILED,SIGNER_NOT_PINNED,TEMPLATE_NOT_INSTANTIATED,ROOT_NOT_AUTHENTICATED,POLICY_EXPIRED,POLICY_WARNINGS_PRESENT,ANCHOR_REQUIRED_FAILED,PUBLIC_TRANSPARENCY_REQUIRED_FAILED,REPLAY_BINDING_REQUIRED_FAILED,CRYPTO_FAILED).PUBLIC_TRANSPARENCY_REQUIRED_FAILEDandREPLAY_BINDING_REQUIRED_FAILEDare forward-compatible and dormant in this release (no reference call site supplies aFalsevalue yet); every other blocker is live. See SPEC.md "Enforcement status of the gate conditions". - New human
SAFE_FOR_AUTOMATION: YES/NOline with per-blocker reasons, derived from the same summary so the human and JSON forms can never disagree. - Migration:
MIGRATION_3.1.0_TO_3.1.1.md.
Added — trust-policy templates and instantiation (AP-2)
- The four
strict-*profiles are renamed*-template-v1and carrydeploymentReady: false+requiresIdentityOverlay: true. The old names remain resolvable as deprecated aliases (a deprecation line on stderr, no break);policy list-profilesmarks them. - New
proofbundle policy instantiate <template> --issuer-key <pub> [--expected-root-file <f>] --policy-id <id> [--valid-until <iso8601>] [--output <f>]turns a template into a deployment-ready org policy that pins your signer identity, offline. It isdeploymentReady: trueonly when every required field is filled; unknown overlay fields fail closed. policy lint --strictnow fails on a raw template (deploymentReady: false) and a still-setrequiresIdentityOverlay: truewith no signer pin. An expiredvalid_untilfailspolicy lintin BOTH modes (strict and non-strict) — it is a lifecycle failure, not a strictness preference.- New optional policy field
valid_until(ISO-8601 UTC lifecycle expiry). A raw template used productively can never yieldsafeForAutomation: true(AP-1 + AP-2 §6.2). schemas/trust_policy_v0_1.schema.jsongainsdeploymentReady,requiresIdentityOverlay,valid_until(kept in sync with the parser's allow-list).
Added — expected-tree-size machine-readable status (AP-3)
- Verify JSON now carries a
treeSizeExpectationobject (status: PASS|FAIL|NOT_REQUESTED,expected,actual), so an integrator never has to infer from a missing line whether the check ran. The check itself still runs INDEPENDENTLY of the root (a mismatch already fails the crypto verdict). - Added regressions for negative / zero / absurdly large expected values, the non-integer CLI usage
error, and the
NOT_REQUESTEDstatus when the flag is absent.
Fixed — unbindable eval SD-JWT graft refused fail-closed (N1, security)
- An eval SD-JWT that carries an eval-binding root commitment (a
receipt.root_b64string, the real cross-receipt substitution vector) grafted onto a non-eval-claim payload has nothing to bind to and is now refused fail-closed (sd-jwt-bundle-bindingFAIL → the whole bundle FAILs). The discriminator is the presence ofreceipt.root_b64, NOT a word-match onpassed/threshold/comparator/suite, so the guard catches a graft even when those facts are moved into selective disclosures and never false-refuses a generic SD-JWT-VC (iss/vct, noreceipt.root_b64) — which stays in scope (backward-compatible). Regression:tests/test_sdjwt_verify_binding.py::TestN1UnbindableEvalSdJwt.
Fixed — pre-land 6-lens audit hardening (2026-07-13, security + honesty)
- Decision path sibling gates (HIGH):
decision verifynow enforces the same AP-1/AP-2 guards as the eval path — a raw, un-instantiateddecision-receipt-template-v1(or anyrequiresIdentityOverlay:truepolicy) and an expired (valid_untilpast) decision policy no longer authorise a decision (both fail-closed → exit 3).decision verify --policyalso accepts a packaged profile name (parity with evalverify). Regressions:tests/test_decision_policy.py::TestDecisionPathTemplateAndExpiryGate. - Honest automation blocker (AP-1): a policy that DOES pin the signer but still carries an un-cleared
requiresIdentityOverlay:truenow reports the distinctTEMPLATE_NOT_INSTANTIATEDblocker instead of a factually-wrongSIGNER_NOT_PINNED. - N1 empty-root hardening: an always-open
receipt.root_b64: ""no longer evades the graft check.
[3.1.0] - 2026-07-13
Minor release: native Merkle root authenticity (relying-party root pinning + a trust-policy requirement + separate verdicts, ADR 0004), score-vs-threshold-verdict evidence classes (P0-B), named trust-policy profiles (WP3), claims-hygiene overclaim vocabulary, and the pre-release six-lens audit hardening below. All additive and backward-compatible.
Changed — six-lens audit hardening (2026-07-13, pre-release)
THREAT_MODEL.mdcorrected: the "Merkle-root / inclusion tampering → FAIL" row no longer overclaims. A coherent root rewrap (the same signed payload re-anchored under a different valid root) is now stated honestly asNOT_EVALUATEDby default, FAIL only under an authenticated-root policy /--expected-root.SPEC.md§7 verification order now documents the additiveroot-authenticity/tree-sizechecks and the separate verdicts, so a second implementation knows they exist.- Shipped profile
strict-eval-authenticated-root-v1— the coherent-rewrap protection is now reachable from a NAMED profile (setsmerkle.require_authenticated_root), not only a bespoke policy; the relying party supplies the authenticated root (--expected-root/trusted_roots). schemas/trust_policy_v0_1.schema.jsongainsmerkle.require_authenticated_root+trusted_roots(they were enforced by the parser but rejected by the schema — a second implementation would have rejected the policy the code accepts). Nested schema↔parser parity test added.- Claims-hygiene exception tightened: a genuine OUTER "signed Merkle/bundle root" or a first-party
"our own tree is append-only" overclaim co-located in a per-sample / Rekor section is no longer
over-exempted;
signed samples rootand external-logappend-onlystay exempt. - Cross-implementation corpus now carries the coherent-rewrap vectors (verifies without policy;
FAILs under
--expected-root), so the finding is cross-checked, not only asserted in unit tests. - Fixes:
verify --jsonerror path carries theroot_authenticitykey (was omitted → KeyError);verify_bundle(expected_tree_size=)rejects a float; a CLI-level root-authenticity + exit-code test; a decimal-precision evidence-class test. ADRdocs/adr/0005-eval-semantics-score-vs-threshold.md.
Added — score-vs-threshold evidence classes (P0-B, Hardening 3.0.1 §7)
proofbundle.evalclaim.eval_evidence_class— a receipt today signs a THRESHOLD VERDICT (passedagainst the signedcomparator/threshold); the exact score is used at emit to computepassedand then discarded, so no output may imply an exact score was verified. The classifier returns one ofTHRESHOLD_VERDICT_VERIFIED(the only class the frozen v0.1 schema produces),EXACT_SCORE_VERIFIED,SCORE_COMMITMENT_PRESENT(a binding, NOT a range proof),SCORE_WITHHELD, plus the always-presentMETHODOLOGY_NOT_EVALUATED. The last four are forward-compatible with the optional, additive exact-score profile (§7.2, EXPERIMENTAL, not in the frozen 3.x core).show-evalnow prints anevidenceline declaring the class and anoteline for methodology, so the CLI never implies an exact score. Docs:EVAL_CLAIM.md§1a. Tests:tests/test_eval_evidence_class.py. No schema / wire / API break (additive read-side classifier).
Added — native Merkle root authenticity (P0-A, Hardening 3.0.1 §6)
- The native Merkle root is NOT in the signature input, so the SAME signed payload verifies under
DIFFERENT roots (a coherent one-leaf rewrap, reproduced in
tests/test_root_authenticity.py). Merkle inclusion proves CONSISTENCY under the stated root, never its authenticity. verify_bundle(..., expected_root_b64=, expected_tree_size=)and CLI--expected-root/--expected-tree-size— relying-party root authentication, enforced bit-exactly; a mismatch FAILS.- Trust-policy
merkle.require_authenticated_root+trusted_roots— a policy can DEMAND an authenticated root; a stated root matching neither--expected-rootnor atrusted_rootsentry is a POLICY FAIL (exit 3, compared by bytes, malformed entries never match — fail-closed). root_authenticity_summary+ aROOT-AUTHENTICITYCLI line and JSONroot_authenticityfield — separatepayloadSignature/merkleConsistency/rootAuthenticity/publicTransparencyverdicts plussafeForAutomation(true only when the root was affirmatively authenticated).merkle-inclusionnow reads "Merkle-consistent under the STATED root". ADR:docs/adr/0004-native-root-authenticity.md.- Non-breaking: absent an expected root / policy, root authenticity is NOT_EVALUATED and every existing
verdict is unchanged.
expected_checkpoint/ public-log toggles are the separate §10 profile (a later minor).
Added — named trust-policy profiles (WP3, v2-audit)
src/proofbundle/policies/*.json— four packaged, loadable trust-policy profiles:research-preview-v1(baseline structural pins only),strict-eval-v1(assurance.minimum_level: reproduced,reject_self_attested_without_prereg, KB-JWT required whencnfpresent),strict-prereg-v1(v0.2, requires a confirmed — not merely pending — external time anchor stamping thepreRegistrationtarget), anddecision-receipt-v1(v0.2, pinsdecision_receiptstructural requirements). Every profile is a REAL policy: it loads,policy explainlists real pins, andpolicy lintpasses (non-strict) — seedocs/POLICY_PROFILES.mdfor the honest scope (no profile pins a signer identity, since that is inherently deployment-specific; each carries the expected "attributes to nobody" warning as shipped).proofbundle.policy_profiles(list_profiles,profile_path,resolve_policy_source) — the loader.resolve_policy_sourceletspolicy explain/policy lint/verify --policyaccept a bare orproofbundle-policy/-prefixed profile name anywhere a policy path is accepted; a real file on disk always wins over a same-named packaged profile (never silently shadowed).proofbundle policy list-profiles— a new CLI subcommand listing the shipped profiles.explain_policynow reports theanchorssection as a real pin (policy.py). Previously a policy whose ONLY pin wasanchors.require_anchor/require_anchor_targetlooked "wirkungslos" topolicy linteven thoughverify --policy's anchor-requirement reconciliation genuinely gates exit code 3 on it (_cmd_verifyreadspolicy["anchors"]directly) — a false vacuous-policy verdict for a pin that was, in fact, enforced.evaluate_policyitself (and the CLI's own anchor-requirement logic) is unchanged; only whatexplain/lintREPORT about an already-enforced pin was corrected. Tests:tests/test_policy_profiles.py.
Added — v2-audit documentation deliverables (WP5/WP6/WP7/WP9)
docs/PUBLIC_TRANSPARENCY_PROFILE.md— the distinction between a bundle's own local Merkle root and public transparency-log inclusion (already-implemented C2SP checkpoint/cosignature/tlog-proof support, SPEC.md §7c/§7d/§7e); documents the proposed (not implemented)public-log-required-v1trust-policy section honestly as a gap, not a shipped capability.docs/SD_JWT_VC_PROFILE.md(progresses issue #27) — the implemented SD-JWT core (RFC 9901) plus the 3.0.0 secure-by-default hardening (unsigned-fails, issuer-identity, bundle-binding), the emitted- but-unenforced SD-JWT VC syntactic markers (typ: dc+sd-jwt,vct, status-list pointer), and the three still-open items from issue #27 (type-metadata resolution, OAuth WG conformance vectors, avct-requiring verifier flag) — none of which are implemented in this change; scoped as a follow-up.docs/MIGRATION_EVAL_PREDICATE.md(progresses issue #26) — the content-root canonicalization migration (jcs-sha256-v1vs.legacy-sortkeys-json-v0, already released in 2.1.0/ADR 0002) as a practitioner migration guide, plus an honest status check on issue #26's literal ask (an official upstream in-toto eval predicate):in-toto/attestation#565remains open/unmerged, so there is no official type to migrate to yet; the vendoredpredicateTypeis unchanged.docs/adr/0003-hybrid-payload-signatures.md(WP9) — a forward-looking ADR: a decision to DEFER payload-level post-quantum signatures (not implemented), comparing four options (A: status quo Ed25519 + hash anchors, B: Ed25519+ML-DSA-44 hybrid, C: DSSE multi-signature, D: COSE/JWS profile) and sketching four future trust-policy modes (require_classical/require_pq/require_hybrid_both/allow_legacy_with_confirmed_hash_anchor) as a design record, not a schema change —policy.py'ssignaturesection is unchanged by this ADR.scripts/claims_hygiene_check.pyscan set gains the four new user-facing docs (33 docs scanned, was 29) — ADRs stay out of the scan set, matching 0001/0002 precedent.
Added — claims-hygiene overclaim vocabulary (P0-C, Hardening 3.0.1 §5.4)
scripts/claims_hygiene_check.pynow also bans, unless negated:signed (Merkle) root(the outer root is a commitment, not the signed object),publicly anchored,append-only,verified score/exact score verified,benchmark is secure,evaluation is correct,action was executed,<EU AI Act|AI Act|GDPR>-compliant, and<verifies|guarantees|certifies|…> truth.- Two precision exceptions keep the gate honest (a gate that cries wolf gets ignored):
the per-sample exception exempts
signed rootinside a section carryingper-sample/samples root/audit-challenge/prereg(the samples root IS a field of the signed eval-claim payload, docs/DEMO.md); the external-public-log exception exemptsappend-onlyinside a section discussing Rekor / a transparency log (it is a correct property there, an overclaim only for a lone issuer-local tree).truthbans the claim VERBS, never the idiomssource of truth/ground truth;compliantbans the regulatory sense, neverspec-/RFC 9162-/C2SP-compliant. Tests:tests/test_claims_hygiene.py(TestP0CAdditions, both directions).
[3.0.1] - 2026-07-12
Security — close the residual model-id oracle in the EEE digest (M2)
- The
every_eval_ever(EEE) digest strippedmodel_info.idand the top-levelevaluation_id, but left the per-resultevaluation_result_id(nested inevaluation_results[*]) inside the digest, while therun_idprovenance path already guards that same id. Anevaluation_result_idcan embed or correlate the cleartext model id, so a digest over it was a model-id confirmation/enumeration oracle, asymmetric to the guarded provenance path._model_id_strippednow also stripsevaluation_result_idfrom each result. Tamper-evidence over scores/timestamps/dataset is unchanged (a tampered score still changes the digest); the id stays available forrun_idprovenance with its own leak guard. This closes the gap that shipped in 3.0.0.
Documentation
- README: add PEP 740 (attestations) and SLSA build-provenance badges now that the first attested release is live.
- README: restructure for scannability (table of contents, deduplication, roadmap section).
- Erratum for the frozen 3.0.0 artifact: its
CHANGELOG.mdstated "811 tests" for the 3.0.0 line; the correct count is 817 (corrected onmainpost-tag). Tags are immutable, so the shipped 3.0.0 changelog keeps the typo; this 3.0.1 changelog carries the correction.
CI / release hygiene
- Add a version-and-changelog integrity gate (
.github/workflows+scripts/check_version_and_changelog.py): fails CI whenpyproject.toml,src/proofbundle/__init__.pyandCITATION.cffdisagree on the version, or when the top changelog heading does not match that version. Closes the "merged but never released / version drift" class that let the M2 fix and the 811-vs-817 typo sit unreleased.
[3.0.0] - 2026-07-12
Security (BREAKING) — SD-JWT disclosures must be signed AND bind their bundle (WP-C1/C2, 6-lens review)
- An
sd_jwt_vcblock lives OUTSIDEpayload_b64, so the bundle's Ed25519 signature does not cover it — only the issuer signature authenticates its disclosures. Two verify-path holes are now closed (secure-by-default; SPEC.md §6/§7 revision 2026-07-11):- Unsigned SD-JWT now FAILS (was null-and-warn). A bundle carrying an
sd_jwt_vcwith noissuer_public_key_b64previously verified with a warning and a nullsd_jwt_ok; its disclosures were unauthenticated yet the bundle passed. It now fails verification (exit 1) with a failing sd-jwt-issuer-signature check,sd_jwt_ok: false,sd_jwt_issuer_verified: false, reasonunsigned. There is no opt-out flag that lets an unsigned SD-JWT verify. - Cross-receipt substitution now FAILS (new sd-jwt-bundle-binding check). For a
proofbundle/eval-claim/v0.1payload, a validly issuer-signed SD-JWT whose always-open disclosures (passed/threshold/comparator/suite/issuer + committed merkle root) describe a different bundle — a receipt lifted and grafted on — now fails (exit 1,sd-jwt-bundle-binding: false,sd_jwt_ok: false, reasonunbound/mismatch). - Forged issuer identity now FAILS (new sd-jwt-issuer-identity check). A self-signed SD-JWT whose
issuer signature verifies under an attacker-chosen key while its always-open
issuerclaim names a trusted party now fails (exit 1,sd-jwt-issuer-signature: truebutsd-jwt-issuer-identity: false,sd_jwt_ok: false, reasonissuer-key-mismatch): the verifying key is bound to the disclosed issuer (fingerprint(issuer_public_key_b64) == issuer). - Migration. If you emit bundles with an
sd_jwt_vc, addsd_jwt_vc.issuer_public_key_b64(Base64 of the 32-byte raw Ed25519 issuer key) so verifiers can authenticate the disclosures, and ensure the SD-JWT's disclosed claims +receipt.root_b64match the bundle they ship in. Bundles that carry nosd_jwt_vcare unaffected. The three prior backward-compat tests are re-pinned as negative tests of the new secure behaviour; conformance corpus gainsbundle/sd-jwt-unsigned-unauthenticated,bundle/sd-jwt-signed-but-unboundandbundle/sd-jwt-forged-issuer-identity(all expect exit 1).
- Unsigned SD-JWT now FAILS (was null-and-warn). A bundle carrying an
Docs — No-Overclaim scope corrections from the 6-lens review (MED)
intoto.svr_properties/export_svr_dsse(WP-E1) — PROOFBUNDLE_PREREG_BOUND / PROOFBUNDLE_ANCHOR_VALID are emitted from the caller's flags (the function does not call verify_anchors) — caller-attested.decision.build_decision_statement(WP-E2) — a caller-supplied subject_sha256 is verbatim, not cross-checked against the predicate (nor re-derived at verify).merkle.verify_inclusion(WP-D2) — documented the RFC 6962 precondition: tree_size + root must come atomically from one authenticated source.policysd_jwt.max_iat_age_seconds (WP-C3) — bounds the eval claim timestamp, NOT the KB-JWT iat.
Security (BREAKING) — external time-anchor trust comes from the relying party, not the bundle (WP-A1)
- An external time anchor (
anchors[]) previously took its trust root from the bundle's ownfrozenblock:anchors_rfc3161fromfrozen.rootCertsDerB64,anchors_otsfromfrozen.bitcoinBlockHeaderMerkleRootsByHeight. That block is producer-controlled, so a malicious producer could freeze its OWN self-signed TSA root (or a self-committed backdated Bitcoin header) and self-certify a backdated timestamp —--require-anchorpassed on nothing but self-consistency. Trust now comes ONLY from the relying party (SPEC.md §7i Trust model, rev 2026-07-11):- rfc3161-tsa is verified against
--trusted-tsa-root(repeatable, DER/PEM) or policyanchors.trusted_tsa_roots; the frozen root is evidence (frozenEvidence), never trust. - opentimestamps is confirmed only against
--bitcoin-header HEIGHT:MERKLEROOT_HEX(internal byte order) or policyanchors.bitcoin_block_headers; the frozen header is never trusted. - Without relying-party trust material a time anchor is
needs_rp_trust(ok=False) and--require-anchoris unmet → exit 3, never a silent pass. Per-entry results carryrp_trusted,needs_rp_trust,frozenEvidence. - The same flags + policy
anchorstrust apply todecision verify(a statement time anchor on a decision receipt):verify_decision_receipt(..., rp_trust=...),decision verify --trusted-tsa-root / --bitcoin-header. - Migration. A relying party that used
--require-anchor(ordecision verify --anchors) on a TSA/OTS anchor MUST now supply the trust material (--trusted-tsa-root/--bitcoin-header, or the policyanchorssection). The bundle's frozen material stays in the format as evidence (TSA rotation) and is reported, so nothing is dropped; only its role as a trust source is removed. Third-party extension anchor verifiers keep working (backward-compatible dispatch); anchor tests are re-pinned; conformance gainsforged-anchor-own-frozen(exit 3). THREAT_MODEL.md names the backdating attack.
- rfc3161-tsa is verified against
Security — pre-auth DoS: bound oversized integer parsing (WP-D1, 6-lens review)
- Python caps
int(str)atsys.get_int_max_str_digits()(default 4300) and raises a rawValueErrorabove it (CWE-674 / CVE-2020-10735). A pre-auth parser that fed an unbounded decimal string toint()surfaced this as an uncaught traceback. Fixed at three sites:_strict_json.loads_strictmaps the int-conversionValueErrorfrom an oversized JSON integer literal toBundleFormatError(covers every JSON verify path — bundle / decision / in-toto / status-list / anchors);tlogproofandcheckpointbound the tree-size / index digit count (<= 20, i.e. 2**64) BEFOREint(); and the CLIverify-proofhandler catchesValueErroras a stopgap. Regression-tested; never a raw traceback.
Security — verify-path hardening from a 6-lens adversarial review (2026-07-11)
- Trust policy rejects a low-order / non-canonical pinned key (
policy.py) — the core verifier deliberately accepts low-order and non-canonical Ed25519 encodings (SPEC §4a). A policy that PINS such a key as a trusted issuer / decision-maker would accept a fixed(pub, sig)pair for many messages (for the identity encodings, ALL messages) with no private key — forgery of a trusted identity without a secret.load_policynow fail-closed rejects the whole class by the point's y-value (sign-independent, so no encoding variant slips past — an earlier hand-kept byte-string blocklist missed three) plus the non-canonical (y >= p) class, inallowed_issuersandtrusted_decision_makers; a low-order key is also refused at the evaluation layer (evaluate_policy/evaluate_decision_policy) as defense-in-depth, so a policy dict that skippedload_policygets no trust from it either. (Scope: a genuine full-order key from an honest keygen is accepted; MIXED-order keys are accepted and are not forgeable via this attack — a full prime-subgroup membership check is a follow-up.) verify_decision_receiptno longer reports trust fields over unauthenticated bytes (decision.py) — a forged/unsigned envelope previously leftaudience_ok/nonce_ok/evidence_boundcomputed (potentially True) with an emptyerrors[]. Now an aggregateokfield is the single verdict, the trust-derived fields stayNonewhencrypto_okis False (mirroring the anchors/policy gates), an error is recorded on a crypto failure, andevidence_boundisNone(not a vacuousall([])True) when there are no evidence refs.- Decision trust policy surfaces the "attributes to nobody" warning (
decision.py) — a decision policy that constrains the verdict/type but pins notrusted_decision_makersmeansPOLICY: OKproves integrity by an unknown signer.policy_warnings()(already decision-aware) is now wired into the decision verify path, matching the eval path. evalclaim.load_claim_textuses the shared strict parser (evalclaim.py) — it reimplemented duplicate-key rejection and did not mapRecursionError, so a pathologically deep-nested claim payload crasheddecode_eval_claimuncaught (CWE-674) — reachable from the batch verifierhf_evals.verify_eval_results_entry,policy.evaluate_policy, and CLIemit-eval. It now delegates toloads_strict(deep nesting and duplicate keys become a cleanEvalClaimError, never a raw traceback).
Docs — No-Overclaim corrections from the 6-lens review (2026-07-11)
hf_evals.to_eval_results_entrydocstring + THREAT_MODEL — the value↔verdict check was described as making the publishedvalue"match" a disclosed score and "stops 0.60 next to 0.99". The signed claim carriesthreshold/comparator/passed, not the exact score, so the check binds the value to the correct SIDE of the threshold, not to a true magnitude: an inflated value on the passing side (a true0.81published as99.9, both>= 0.80) still verifies. Docstring corrected and a value-magnitude boundary row added to THREAT_MODEL.docs/OPERATIONS_SECURITY.md— the[Owner]checklist items read as accomplished present-tense fact ("account on 2FA", "tags are protected", "Scorecard is enabled"), contradicting the document's own "does not assert they are done" preamble. The marker is now[Owner · to verify]on every line so the unverified status survives a reader skimming the list.
Added — native-bundle conformance vectors (WP-S1)
conformance/bundle/— four native proofbundle bundle cases (kindnative_bundle) checked against the CLI verify exit-code contract:valid-minimal(a valid bundle verifies, exit 0),duplicate-json-key(a bundle whose raw JSON carries a duplicate top-level key is rejected as malformed, exit 2 — locking the C1 Bishop-Fox parser-differential defense onto the conformance gate),tampered-payload(a valid bundle with one payload byte flipped fails the signature, exit 1), andcorrupted-signature(payload intact but the signature bytes corrupted, exit 1). The harnessnative_bundlehandler runsproofbundle verifyand asserts the exact exit code, with the same fail-closed floor (a case must declareexitCode). Anti-tautology regression tests: a wrong expected exit code fails, a missing exitCode fails, and the duplicate-key bundle is proven rejected.
Added — MAINTAINERS.md + TRADEMARK.md + OPERATIONS_SECURITY.md governance docs (WP-W5 phase 1-2)
MAINTAINERS.md— the conventional human-readable maintainer file: names the single maintainer, points toGOVERNANCE.md, the DEFAULT-DENYoss_maintainer_roles.json,.github/CODEOWNERS, andSECURITY.md. No delegated maintainers today.TRADEMARK.md— an honest use-of-name policy: the MIT-licensed code is free to use and fork; the "proofbundle" / "b7n0de" names are not registered trademarks (no ® claim) but should not be used to name a competing fork/package or imply official status. Protects the one thing the project cannot fork away: that a receipt under this name comes from the reviewed, gated releases.docs/OPERATIONS_SECURITY.md— the supply-chain posture checklist (accounts/2FA, PyPI trusted publishing, signing-key custody, SHA-pinned CI actions, fork-PR secret isolation, domain lock). It is a checklist, not a claim:[Owner]items are the maintainer's to verify and are not asserted done;[repo]items are enforced by files in the repo. Distinct fromSECURITY.md(which is about receiving vulnerability reports).docs/GRANT_MILESTONES.md— the public deliverable/status tracker for the funded independent security-review track (M1–M…), factual and linked to repo evidence, never aspirational.- All four docs are now in the
claims_hygiene_checkscanned set (29 docs), so they are held to the same No-Overclaim discipline as the rest of the documentation.
Added — offline conformance corpus with cross-implementation decision vectors (WP-W2)
conformance/— a versioned, digest-pinned corpus verified fully offline byconformance/run_conformance.py(make conformance). Each case declares what it proves AND what it does not, so a green run never overclaims. Two cross-implementation decision-receipt vectors from MarkovianProtocol/audit-anchor (credited, pure data):decision/crossimpl/confirmed-anchor-lifecycle— proves RFC 8785 canonicalization + content-root binding cross-implementation and a confirmed Bitcoin anchor at block 957504: the OTS proof's committed root matches the real block merkle root (independently fetched, frozen in the case, verified offline; a wrong frozen root is rejected —block_mismatch, covered bytest_anchors_ots.py). Does not provedecision-receipt/v0.1schema conformance (predicate reports 12 findings, expected-fail).decision/crossimpl/canonicalization-root-binding— proves canonicalization + root binding; anchor still pending and predicate not yet schema-conformant (both recorded as expected, not hidden). Anchor sub-checks run in theanchorsCI job ([anchors]extra,--require-anchors); the corpus's non-anchor checks run in every matrix leg. README §Interop precised: canonicalization interop proven, full decision-receipt conformance of the external fixture still pending. The harness is fail-closed by a required-expectations floor: adecision_crossimplcase that under-declares its bindings FAILS rather than passing green asserting nothing, and its defining checks (JCS byte-identity, content-root match, evidenceRef binding, anchor when a.otsships) run unconditionally; a missing fixture is a per-case FAIL, not a run-aborting crash. Hardened further after a 6-lens review: a missing case dir, a malformed case.json, or a case.json with nokindis now a per-case FAIL (the outer parse was outside the try before), and a native_bundleinputcannot escape its case directory.
Added — decision-receipt validator API hardening + cross-impl gap record (WP-W6 / WP-W1)
decision.require_valid_decision_predicate(pred)— a raising counterpart tovalidate_decision_predicate. The list-returning validator (empty list == valid, never raises) is easy to misuse astry: validate(...) ; except: ..., which silently passes every predicate: that idiom produced a public "passes the enforced v0.1 validator as-is" claim for an external cross-implementation fixture that in fact reported 12 findings. The wrapper raisesDecisionReceiptError(with the finding count) on an invalid predicate,Noneon a valid one.docs/predicates/decision-receipt.md§6.1 documents the list-vs-raise contract; a regression test (tests/test_decision_validator_api.py) pins that the naive try/except idiom wrongly passes.audit_artifacts/crossimpl_fixture_gap_20260711.md— No-Overclaim record for the MarkovianProtocol/audit-anchor decision-receipt fixture: the RFC 8785 canonicalization and content-root binding are proven byte-identical cross-implementation (evidence323adb18…, decisionff05e3e0…), but the external predicate does not yet satisfy the enforceddecision-receipt/v0.1schema (field mapping thread-prose → v0.1 included). Both statements are recorded so neither is overclaimed nor hidden.
Added — CODEOWNERS + roles registry, dead governance link fixed (WP-G2)
.github/CODEOWNERSfor the trusted core,SPEC.md,schemas/,docs/predicates,docs/adr, and the CI/release wiring — a change to those paths requires the maintainer's review ("more eyes, not weaker gates", GOVERNANCE.md). Single-maintainer today; co-maintainers are added per-person, never implicitly.oss_maintainer_roles.jsonat the repo root — the delegated-rights registry GOVERNANCE.md referenced but which pointed at a non-existentoffice/governance/path (a monorepo path that never shipped here). DEFAULT DENY: nobody holds merge/release/secret rights without an explicit entry. GOVERNANCE.md now links the real file and CODEOWNERS.- The project's first external contributor (@onxxdatas, issue #28 —
--versionprints the pinned spec revision) is recorded in the governance story and the roles registry (no delegated rights, like every contributor).
Added — HF entry verifier-side binding + EEE source digest (WP-I2 / WP-I3)
hf_evals.verify_eval_results_entry(entry)— the value↔verdict consistency was emit-side only: an.eval_resultsentry whose displayedvaluewas edited AFTER thepb1.token was minted verified fine (the token check covers only the embedded bundle, and a Hub reader sees the value, not the token). Now the verifier side checks token crypto ANDvalue <comparator> threshold == passedagainst the decoded, issuer-bound claim (fail-closed: a non-eval bundle or a non-finite value never judges as consistent). Documented replay boundary (module + THREAT_MODEL row): the entry'sdataset.id/task_idare NOT bound to the receipt's salted dataset commitment — that binding needs the salt opening; this function is a value check, never a repo-binding check.adapters.from_eee_datasetnow binds the receipt to its exact source record (it was the only adapter without a provenance binding):provenance.eee_record_sha256=sha256-jcs:<hex>over the RFC-8785-canonical record (labeledsha256-sortkeysfallback, mirroringadapters/_provenance.config_hash), plus the RESULT-levelevaluation_result_idasrun_id— guarded: dropped if a producer embedded the cleartext model id in it (the TOP-levelevaluation_idstays excluded for exactly that reason; digest-privacy consideration documented in the adapter).- Hardened after a Tier-1 review (2 P1 privacy findings): the
eee_record_sha256digest is now computed over a model-id-stripped record — an unsalted digest over a record embeddingmodel_info.idin cleartext was a model-id confirmation/enumeration oracle (the old "not enumerable" comment was an overclaim); it still binds scores/timestamps/dataset for tamper-evidence. Therun_idprivacy guard now drops the id on ANY model-name component (bare name, slug variants, case-insensitive), not only the fullorg/nameid.verify_eval_results_entryreturns fail-closed (not a raise) for a token-less entry (verifyToken is optional in the HF schema) and rejects a booleanvalue(the builder rejects bool too).
Added — anchor TARGET gate + structured trustedTime (WP-A1 / WP-A2 / WP-A7)
verify --anchor-target receipt|preRegistration|statement(implies--require-anchor) and the trust-policy v0.2anchorssection (require_anchor,require_anchor_target,allow_pending): the anchor requirement matched the TYPE only, so areceiptanchor stamped today satisfied a relying party who demanded backdating protection — existence-now proves nothing about existence-before-the-run. Matched is now ok ∧ ¬warn ∧ type ∧ target; a CLI/policy conflict is exit 2 (mirrorsexpected_aud), never a silent override.- Structured
trustedTimein per-anchor results (SPEC §7i):{source: rfc3161_gen_time, time, tz}from a verified token's own gen_time;{source: bitcoin_block, height}from a confirmed OTS attestation (native unit, no wall-clock guess); the markovian type carries the delegated OTS time through. Present ONLY when the proof carries it — never derived from the informativeanchoredAt(a tamperedanchoredAtchanges neither verdict nor trustedTime, pinned by regression test). Time-window policies oververify --jsonbecome buildable. - A7 regressions closed: a v0.1 bundle carrying
anchors[].target: "statement"is now rejected as malformed (exit 2) by the verifier itself — the docs promised it, the code never enforced it (statementis exclusively for DETACHED decision evidence); a non-stringanchoredAton a detached anchor fails closed; anchoredAt-tamper invariance is pinned.
Added — policy explain / policy lint + the vacuous-pass warning (WP-TP1)
- A policy that pins nothing no longer passes silently.
evaluate_policyreturnspolicy_ok = all(checks); with an empty/id-only policychecksis empty andall([])is True — a greenPOLICY: OKthat evaluated nothing. Now:proofbundle policy lint <policy>exits 1 on such a wirkungslose policy (--strictalso fails an attributes-to-nobody policy);proofbundle policy explain <policy>lists the effective pins (human +--json). verify --policymarks a PASSING policy that pins no signer inline —POLICY: OK (WARNING: attributes to nobody)— plus a machine-readablepolicy_warnings[]JSON field. Exit codes unchanged (a warning, never a new failure mode; fail-closed behavior of real policy violations untouched).- docs/TRUST_ANCHORS.md documents the new subcommands; +9 tests
(
tests/test_policy_explain_lint.py).
Fixed — predicateType enforcement on the in-toto verify paths (WP-I1)
verify_eval_result_dsse/verify_svr_dsse/verify_intoto_dssenow ENFORCE thepredicateType, not just return it. Previously a validly-signed envelope of one predicate type verifiedok=Truethrough the verify function of another (a swapped SVR accepted as an eval-result, a test-result as an SVR, …) — the decision-receipt layer already rejected such confusion, the eval/SVR/test-result layer did not. Each function now pins its own type by default (expected_predicate_type, opt out withNone), returnsok=False+ apredicate_type_okfield + a "confusion attack?" detail on a foreign type. Additive return field; the diagonal (matching type) verifies exactly as before.- Cross-predicate matrix test (
tests/test_predicate_type_enforcement.py): every emitted in-toto type signed and run through every verify function — only the diagonal verifies, every off-diagonal cell isok=False; plus explicit-expected-type pin, opt-out, and wrong-signature-still-fails. A mutation operator (disable the check ⇒ red).
Fixed — duplicate JSON keys rejected on the verify paths (WP-C1)
json.loadslast-wins duplicate keys are rejected fail-closed (new stdlib-onlyproofbundle._strict_json.loads_strict,object_pairs_hook, any nesting depth, clearduplicate JSON key '<k>'message). A duplicated key is a classic parser differential: two JSON implementations can disagree about whichroot_b64/sig_b64/predicateTypethey verified — for a signed status-list token that was a PROVEN VALID-vs-INVALID revocation split-brain. Converted: the native bundle (load_bundle; thepb1.HF receipt token), the DSSE statement verifiers (eval-result / test-result / SVR / decision), the trust-policy loader, the per-sample opening's committed disclosure record, the chia-datalayer and markovian anchor envelopes, the status-list token, the enclave EAT, and everyjson.loadin the CLI (verify-opening,intoto --verify,svr --verify,decision emit/verify/inspect,--anchors). Emit side too: a predicate file carrying a duplicate key is refused before anything is signed. SPEC §2 now makes duplicate-key rejection normative (an interoperating implementation that keeps either occurrence is non-conforming); THREAT_MODEL carries the parser-differential row.- Deliberate behavior deltas (each stricter, never looser):
to_eval_results_entrynow REFUSES a crypto-valid bundle whose payload carries a duplicate key (previously the entry was built last-wins — refusing to publish an unjudgeable value is the honest outcome);decision inspectexits 2 instead of risking a raw traceback on malformed/duplicated payloads. - Known residual (documented in
_strict_json): the SD-JWT/KB-JWT payload parses (sdjwt.py,kbjwt.py, thebundle._issuer_requires_holder_bindinghelper) — a naive conversion would INVERT a fail-closed direction (a rejectedcnfread must not read as "no holder binding required"); that group needs its own careful pass. Keys differing only by Unicode normalization or a BOM are distinct JSON keys by spec and stay distinct (a downstream-validator concern). - Negative tests
tests/test_dup_key_reject.py(native bundle signature/merkle/top-level, HF token, all four DSSE verify functions in BOTH content-root modes, decision library+CLI, emit-side refusal, policy/statuslist/persample/enclave/anchor-envelope rejects) + a mutation operator proving the tests kill a disabled guard.
Added — Ed25519 verify semantics decided, documented, pinned (WP-C2)
- SPEC.md gains §4a Verification semantics — the edge-case envelope: proofbundle's Ed25519
verification (via
cryptography/OpenSSL) matches the BoringSSL / Dalek (non-strict) row of the "Taming the Many EdDSAs" corpus exactly (ACCEPT {0,1,2,3,11}, REJECT {4,5,6,7,8,9,10}; eprint 2020/1244) — cofactorless, RFC 8032 S-bound enforced, non-canonical R rejected, non-canonical A partially accepted, small-order accepted; NEITHER Dalek-strict (rejects {0,1,2,11}) NOR ZIP-215 (additionally accepts {4,5,9,10}). Honest RFC 8032 signatures are unaffected; the cross-verifier-consensus consequence for crafted signatures is documented here and in THREAT_MODEL.md. - The 12-vector corpus is vendored byte-identical (
tests/fixtures/ed25519_speccheck_cases.json, from novifinancial/ed25519-speccheck commit5e4bfc4…, blob8686dcb…, Apache-2.0 — LICENSE + provenance README beside it) and pinned bytests/test_ed25519_semantics.py(content SHA-256 + per-vector verdict) — a fixture tamper OR a backing-library behavior change turns the repository's CI red, demanding a deliberate documented decision, never a silent drift. No behavior change; switching profiles would be a versioned, breaking change.
Fixed — claims-hygiene gate honesty (WP-N1)
scripts/claims_hygiene_check.pyno longer skips missing docs silently. Six of sixteen_DEFAULT_DOCSentries did not exist (four lacked thedocs/prefix;docs/MATURITY.mdanddocs/MIGRATION_2.0.mdnever existed), so the gate scanned only 10 docs while reporting PASS. A listed-but-missing path is now a FAIL (exit 1,missing[]in the JSON), the scan list matches the repository exactly, and six more user-facing docs are scanned (docs/NON_CLAIMS.md,docs/DEMO.md,docs/ANCHORS.md,docs/ANCHORS_MARKOVIAN.md,docs/REVIEWERS.md,docs/EXPERIMENTAL_ENCLAVE.md).- Soft-wrapped Markdown sentences are unwrapped before the negation check. A negation on the previous physical line of the same sentence ("… not a statement that a\n model is safe to deploy") was lost because every newline counted as a sentence boundary; block starts (blank line, heading, list item, quote, table row) remain boundaries.
- New forbidden phrasings (Gate 3, standard-track):
safe to deploy,safe model,verified result,correct decision,authorized action, and positivetrustless(the allowed wording is "trust-minimized (Bitcoin PoW time)", or an explicit negation).
Changed — wording and reference hygiene (WP-N2)
verifylabels the assurance source:ASSURANCE: <level> (issuer-declared)plus a machine-readableassurance_declared_by: "issuer"JSON field (null when the bundle is not an eval receipt, and null when crypto failed — no level to attribute) — the level is the issuer's own declaration, never an appraisal. Migration note: a consumer that matched the FULL line (e.g.^ASSURANCE: reproduced$) must accept the(issuer-declared)suffix;assertIn-style prefix matching keeps working.trustless→trust-minimized (Bitcoin PoW time)inanchors_markovian.pyanddocs/ANCHORS_MARKOVIAN.md(the Bitcoin time component is trust-minimized; nothing here is trust-free).docs/NON_CLAIMS.mdgains a Decision Receipts section (a verified ALLOW is a record, not an authorization/bearer token; against cross-context replay issue receipts withvalidity.audience/validity.nonceand verify with--aud/--nonce— a v0.2 policy'srequire_audience/require_nonceenforce their presence) and a TEE bridge section;decision verify --helpcarries the same boundary, including that--aud/--nonceonly bind a receipt that carries avalidityobject.- Reference fixes, pinned by
tests/test_docs_truth.py: ValiChord URL →github.com/ValiChord/ValiChord(INTEROP.md, INTEGRATIONS.md); SD-JWT VC citation → draft-ietf-oauth-sd-jwt-vc-17 (IESG "Publication Requested";dc+sd-jwtnot yet IANA-registered);docs/EXPERIMENTAL_ENCLAVE.mdinstall no longer pins the stale2.0.0b1beta.
Hardened after the six-lens adversarial review of this change set (2026-07-11)
- Gate: a listed-but-unreadable doc is now a FAIL like a missing one (it silently counted as
scanned + PASS); heading/table-row/fence/setext lines no longer merge forward into the next
paragraph (a negation inside a heading could exonerate the following prose); clause separators
(
;,:,—) now bound the negation window (a negation in an earlier, grammatically independent clause no longer exonerates a later positive claim); the scan set additionally covers INTEGRATIONS.md, EVAL_CLAIM.md, RELEASE.md, GOVERNANCE.md, CONTRIBUTING.md (25 docs). - Docs truth:
docs/ANCHORS.mdno longer asserts a positivetrustless("run your own and no third-party trust remains");docs/REVIEWERS.mddrops its stale hard-coded test/operator counts (683→ the suite had grown; 26→ the operator list lives inscripts/mutation_check.py); RELEASE.md's beta section is reframed as convention-for-future-pre-releases (the "v1.x stays the default" sentence was stale since 2.0.0 final); THREAT_MODEL.md quotes the newASSURANCE:line format; NON_CLAIMS.md says "digest-boundoutcomeRef" (the verifier checks the digest's presence and binding, not a signature on the outcome record). - Tests: content-violation ⇒ exit 1 pinned at the
main()level; unreadable-doc ⇒ FAIL pinned; the exit-2 error path is pinned to carry the FULLverify --jsonfield contract (incl.assurance_declared_by); the CLI-help assertion is terminal-width-independent; a line-number pin proves soft-unwrap keeps positions 1:1.
Verification discipline
- 817 tests (was 683 at 2.1.0) across the 3.10–3.14 CI matrix, all green. A pre-release audit
hardened the two anti-regression instruments so they actually cover the code this release adds:
the mutation gate (
scripts/mutation_check.py, Anti-Goodhart) now carries an operator for each of the four new breaking defenses — WP-C2 unsigned-fail, WP-C1 issuer-identity and bundle-binding, WP-A1 needs-rp-trust — so a future accidental revert of any of them goes red (the mutation CI job now installs[anchors]so the WP-A1 operators are exercised, not short-circuited atno_lib). The offline conformance corpus'ssd-jwt-unsigned-unauthenticatedvector is now cnf-free so it isolates WP-C2 (disabling that defense flips the vector to exit 0), instead of riding on the older v1.6 cnf-downgrade check. - SD-JWT / KB-JWT payloads now parse with
loads_strictlike every other verify path: a DUPLICATE JSON key (e.g. a secondcnfnaming an attacker holder key) is rejected fail-closed at the structure gate. The release-audit follow-up extended this to the last parse site of the same class, theevalclaim.sd_jwt_hidden_countdisclosure-transparency helper (a duplicate key now returnsNone, not a last-wins count), closing the documented parser-differential residual in full (regression:tests/test_sdjwt_duplicate_cnf.py).
Packaging
- The
Development Statusclassifier stays4 - Betafor 3.0.0 (Owner decision E1, 2026-07-12): stable is evidenced, not asserted. The move to5 - Production/Stableis a separate, audit-gated milestone that lands only after the funded external security review passes (tracked indocs/GRANT_MILESTONES.md), never claimed pre-audit — even for a breaking security release.
[2.1.0] - 2026-07-10
First release on the 2.x line after 2.0.0 final: a new vendored decision-receipt/v0.1 predicate for
agent decisions; a shared universal content root (jcs-sha256-v1) that the eval-result / test-result / SVR
export paths now adopt with an explicit declared legacy mode (every already-signed 2.0.0 receipt keeps
verifying byte-for-byte); and anchors v0.1 — a verify --require-anchor relying-party gate plus RFC 3161
policy-OID / certificate-expiry hardening over the experimental external-time-anchor layer. All three are
additive over 2.0.0; no released receipt is invalidated.
Added — Decision Receipt predicate decision-receipt/v0.1 (Phase D)
- A new vendored in-toto predicate for agent decisions:
https://b7n0de.com/proofbundle/predicates/decision-receipt/v0.1(ADR 0001). A Decision Receipt records who decided, what action was proposed, against which policy boundary, on which digest-bound evidence, what the verdict was, and what was explicitly not checked. It is a DSSE-signed in-toto Statement, verified over the exact signed bytes. - CLI:
proofbundle decision {init,emit,verify,inspect}.verifyfollows the Phase B exit contract (0 crypto+structure OK · 1 crypto failure · 2 malformed/predicateType-confusion · 3 crypto OK but a supplied--policywas not satisfied). Without--policythe output showsPOLICY: NOT_EVALUATED.--versionnow listspredicates: eval-result/v0.1 decision-receipt/v0.1. - Emission is RFC-8785 canonical (JCS); verify never re-serializes and fails closed if the received payload is not its own canonical form (hash-binding rule).
- Trust Policy v0.2 (additive): the v0.1 trust policy gains a
decision_receiptsection (trusted_decision_makers,accepted_predicate_types,allowed_decision_types/verdicts,required_evidence_relations,require_policy_digest,require_external_anchor/allow_pending). A v0.1 policy stays valid unchanged under the v0.2 parser. The signer is matched totrusted_decision_makersby public key —decisionMaker.idis never believed on the JSON claim alone. - Non-claims (unchanged boundary): a Decision Receipt does not prove the decision was correct, legal, safe,
or fully informed;
actionOutcome=executedwithout a separately signed outcome is self-assertion, reported asaction_outcome_proven=false. - Decision
anchors[]composition landed. Astatement-target anchor binds the SHA-256 content root over the exact signed payload bytes and is kept detached (outside the signed predicate — an anchor cannot live inside the bytes whose hash it commits; proofbundle#7 consensus, 2026-07-10).verifygains--anchors; with a policy'srequire_external_anchor/allow_pending, a pending (calendar-only) anchor is the absence of a time anchor → exit 3. Seedocs/ANCHORS.mdandtests/test_decision_anchors.py. - The tamper / replay / fuzz matrix landed. A systematic, deterministic sweep
(
tests/test_decision_fuzz.py— every signature byte, spread payload bytes, every required-field deletion, top-level type confusion, ten malformed-envelope classes, a wrong-key batch), plus audience/nonce replay gating (tests/test_decision_hardening.py,tests/test_decision_verify.py). - Still deferred (not in this core): independent cross-implementation worked vectors over a decision object (MarkovianProtocol's reference anchor), iterated on proofbundle#7.
Added — universal Statement content root jcs-sha256-v1, with a declared legacy mode (WP2, ADR 0002)
- A single shared content-root primitive now underlies both the decision-receipt path and the in-toto
eval-result / test-result / SVR export paths:
statement_content_root= SHA-256 over the RFC-8785 (JCS) canonical bytes of the full pre-signature Statement (_type,subject,predicateType,predicate). Signature/envelope bytes are never in the preimage, so a content root survives counter-signing and key rotation and a decision receipt composes byte-for-byte with an eval-result statement it cites. Exposed as the publicproofbundle.canonicalize_statement/proofbundle.statement_content_root(sharedcanonical.py). - The algorithm is a first-class versioned id (
contentRootAlg, defaultjcs-sha256-v1), declared inside the signed payload so it cannot be flipped after signing. A verifier re-serializes with exactly the declared algorithm to confirm the payload is its own canonical form (fail-closed), never re-canonicalizes to compute a root, and never falls back between algorithms. An unknown algorithm fails closed — the anti-algorithm-confusion rule already applied tomerkle.hash_alg. - Migration is a compatible evolution, not a cutover. The historic
json.dumps(sort_keys=True)wire is retained as an explicitly declared named modelegacy-sortkeys-json-v0. AbsentcontentRootAlg⇒ legacy (never silently JCS), so every already-signed 2.0.0 eval-result / test-result / SVR receipt keeps verifying byte-for-byte; legacy verification is stdlib-only, so those receipts still verify on a base install. New receipts default tojcs-sha256-v1; verifying JCS canonicality needs the emit-side[eval]extra and is fail-closed without it. - Honest scope (No-Overclaim): this is not a "universal migration complete." The eval-result /
test-result / SVR producers now default to the new algorithm and a P0 activation test pins the boundary
(
tests/test_intoto_content_root_migration.py: asort_keysroot offered asjcs-sha256-v1is rejected; genuine JCS bytes declared legacy are rejected; an unknown algorithm fails closed). Still deferred: a CLI flag to select the content-root algorithm from the command line, and independent cross-implementation (MarkovianProtocol) worked interop vectors.
Added — anchors v0.1: a verify --require-anchor relying-party gate + RFC 3161 hardening (WP4)
- The experimental external-time-anchor layer (
anchors[], shipped experimental-gated in 2.0.0) gains a relying-party gate:verify --require-anchor(optionally narrowed by--anchor-type <type>) turns "no verifying anchor (of that type)" into a failure — a gate layered OVER the crypto result, exit 3 when unmet (distinct from a crypto failure, exit 1), exactly like--policy. A pending anchor (an un-upgraded OpenTimestamps proof, a Merkle-only chia-datalayer level-i anchor) does NOT satisfy the gate unless--allow-pendingis given; the gate follows the matched anchor's own status, not the global aggregate. anchorsis now a KNOWN top-level bundle field (SPEC §7i, JSON Schema): formalized as EXPERIMENTAL and detached from the content root (an anchor attests about a receipt, never part of what it attests; thereceipttarget stamps the canonical root computed withanchorsexcluded). One-way compatibility is documented: a verifier built against an earlier revision lists noanchorsfield and, underadditionalProperties: false, rejects an anchored bundle as malformed (exit 2) rather than ignoring it.- RFC 3161 TSA hardening: the frozen TSA certificate chain is validated at the token's own
gen_time(not the current wall clock), so a frozen token stays verifiable after the TSA certificate expires or rotates, and a certificate not valid atgen_timefails closed; a relying party MAY pin the TSA policy OID viafrozen.policyOid, in which case a token whoseTSTInfo.policydiffers fails closed. New tests:tests/test_cli_require_anchor.py,tests/test_anchors_rfc3161.py,tests/test_anchors_generic.py.
Verification discipline
- 683 tests (was 550 at 2.0.0): the decision-receipt suite (emit/verify/inspect, the tamper/replay/fuzz
matrix in
tests/test_decision_fuzz.py/tests/test_decision_hardening.py,anchors[]composition), the universal content-root migration pins (tests/test_intoto_content_root_migration.py:jcs-sha256-v1vslegacy-sortkeys-json-v0, the algorithm-confusion red matrix), and the anchors v0.1 relying-party gate (tests/test_cli_require_anchor.py, RFC 3161 policy-OID / certificate-expiry). Mutation gate: 39 operators, all killed; the one documented-equivalent mutant still survives.
[2.0.0] - 2026-07-09
First 2.0.0 final. Consolidates the 2.0.0b1–b3 pre-release line (below) with the Phase B P0-core
hardening. Breaking changes, each with migration notes in its entry below: merkle.hash_alg is now
REQUIRED; verify's human output replaces the bare => OK with a labelled CRYPTO: / POLICY: /
ASSURANCE: / LIMITATIONS: block; exit code 3 is new (crypto OK but a supplied --policy was not
satisfied, distinct from a crypto failure). New: a machine-readable, fail-closed, offline trust
policy (verify --policy); an extended --version; a stable verify --json single-field contract.
The experimental TEE-attestation bridge and anchors[] stay experimental-gated as in the betas.
Added — trust policy v0.1 + verify --policy (WP-B3)
- A relying party's trust decision is now first-class and machine-readable.
verify receipt.json --policy trust_policy.jsonapplies a fail-closed, offline trust decision OVER the crypto result: the signer (matched by public key, kid is a hint only), signature alg, bundle schema, Merkle hash alg, SD-JWT audience/nonce/key-binding, eval-claim freshness, and assurance level / pre-registration. Without a policyverifymakes NO trust decision (POLICY: NOT_EVALUATED); a policy failure is the new exit 3 (crypto OK but policy unmet), distinct from a crypto failure (exit 1). A policy is never evaluated on bytes whose crypto failed. - Policy format
proofbundle/trust-policy/v0.1(schemas/trust_policy_v0_1.schema.json): snake_case, versioned, fail-closed (an unknown field is a parse error — a typo cannot silently weaken a policy), offline (no key is ever fetched). Worked example:examples/trust_policy_strict.json.verify --jsongainspolicy_ok,policy_id,policy_checks[]. - Honest v0.1 boundary: the
statussection is accepted so a policy can declare revocation intent, butverify --policyhas no status-snapshot input in v0.1 — an ENABLED status requirement fails closed with a clear reason (evaluate revocation separately withverify_status_snapshot). A--audflag that conflicts with the policy'ssd_jwt.expected_audis exit 2 (ambiguity, never a silent override). - Docs:
docs/TRUST_ANCHORS.mdgains the machine-readable policy profile; the README quickstart shows a policy example with the explicit note that verify makes no trust decision without one.
BREAKING — verify output separates CRYPTO / POLICY / ASSURANCE, and a new exit code 3 (WP-B2)
- The human
verifyoutput no longer prints a bare=> OK/=> FAILED. It now prints a context-labelled block so a crypto success can never be read as a policy pass or a truth verdict:CRYPTO: OK|FAILED(the only thing the offline core proves),POLICY: NOT_EVALUATED (no trust policy supplied),ASSURANCE: <issuer's verbatim self-declared level> | n/a, andLIMITATIONS:(the honest "what a signature does NOT mean" line). A script that grepsverify's stdout for=> OKmust switch toCRYPTO: OK(other subcommands —verify-proof,show-eval, etc. — keep their existing=> OKfor now). - New exit code 3. The
verifyexit-code contract is now0= crypto OK (and policy satisfied or none supplied),1= crypto/verification failure,2= malformed input,3= crypto OK but a supplied--policywas NOT satisfied.--policyitself lands with WP-B3; until then exit 3 cannot occur andPOLICY:always readsNOT_EVALUATED. Documented inproofbundle verify --help. verify --jsongains a stable single-field contract (additive; the existingok/checks/matrix/meaningkeys are unchanged):schema_ok,signature_ok,merkle_ok,sd_jwt_ok,sd_jwt_issuer_verified,key_binding_ok,audience_ok,nonce_ok,freshness_ok,anchor_ok,witness_ok,status_ok,assurance_policy_ok,crypto_ok,policy_ok,assurance,warnings[],limitations[]. A check that did not run in the offline core path isnull(not applicable), never silentlytrue— in particularsd_jwt_okisnull, nottrue, when an SD-JWT's issuer signature was not checked (no issuer key supplied), with a warning saying so.- Hardening (verify-lens review):
decode_eval_claimnow rejects an out-of-enumassurance_levelon the verify path (closing an ASSURANCE-line injection where a hand-signed claim could embed newlines to forge fakeCRYPTO:/POLICY:lines); deeply-nested JSON maps to the documented malformed exit (2) instead of a rawRecursionError; the error-path JSON carries the full field contract so integrators can always readcrypto_ok. - Migration: replace any
verify-stdout=> OKgrep withCRYPTO: OK; treat exit 3 as a new (policy) outcome distinct from 1 (crypto failure). No bundle format change.
BREAKING — merkle.hash_alg is now a REQUIRED field in SPEC.md and the JSON Schema (WP-B1)
- The verifier already rejected a missing
hash_algsince v1.6 (bundle.py_required it) — this closes the documentation/schema half of that gap.SPEC.md§5 now stateshash_algasrequired: yes(wasno, contradicting the code) with an explicit anti-algorithm-confusion MUST: a verifier MUST NOT silently default a missing value, and a future hashing algorithm MUST register its own distinct value.schemas/proofbundle_v0_1.schema.jsonaddshash_algtomerkle.requiredto match. - Who this actually breaks: any consumer that validated bundles against the JSON Schema only
(not
proofbundle verify) previously accepted a pre-v1.6 bundle missinghash_algthat the real verifier already rejected — that schema-only path is now correctly stricter, matching the code. Every bundle any proofbundle emitter has ever produced since v1.6 already carrieshash_alg, so this affects only hand-authored or archived pre-v1.6 bundles. - Migration: add
"hash_alg": "sha256-rfc6962"to the bundle'smerkleobject. The verifier's error message for a missing field now states this explicitly (bundle.py::_require_hash_alg, shared byverify_bundleandrecompute_merkle_root_b64so the two call sites cannot drift apart again). - Attribution correction: this entry is a SEPARATE breaking fix and does not close any tracked
issue. Issue #28 is scoped exclusively to
--versionprinting the pinned spec revision — see the entry directly below, which is the one that actually closes it.
BREAKING — proofbundle --version output is now multi-line (closes #28)
- Was a single line (
proofbundle <version>). Now four lines: package version, the pinnedSPEC.mdrevision (newSPEC_REVISIONconstant next to__version__, kept in sync with SPEC.md's ownRevision:header by a doc-truth test), the JSON Schema id, and a best-effort, fail-safe list of optional extras actually usable in this install (eval/sdjwt/anchors[beta]/pq/inspect/experimental— a missing/broken extra is silently omitted, never a traceback). A script that parsed--version's stdout expecting exactly one line must be updated; the exit code (0) and the first line'sproofbundle <version>prefix are unchanged.
[2.0.0b3] - 2026-07-06 (BETA / pre-release)
Added — external time / provenance anchors (the anchors[] layer, EXPERIMENTAL)
chia-datalayer/v1(first-party): a fail-closed offline verifier for a canonical root proven included under a published Chia DataLayer store root via a level-i Merkle inclusion path. Ships as a built-in anchor type; a level-i-only proof reportswarn(does not satisfy--require-anchor). Seedocs/ANCHORS.md.markovian-provenance/v1(third-party worked example, external contributor MarkovianProtocol, #18): a wallet-attributable, Bitcoin-anchored stamp registered throughregister_anchor_type. It binds the committed data to a wallet (merkle_root = sha256(data_hash:salt:wallet)) and delegates the Bitcoin time proof verbatim to the built-in OpenTimestamps verifier (compose, not reinvent). Opt-in viaregister(); not wired into the built-in set by design.- README now documents the
anchors[]extension layer and theregister_anchor_typebring-your-own-type interface (the[anchors]extra), with an honest "v2.0 beta" label.
Changed
- Repo hygiene: removed a committed
.venv-anchors/tree from tracking (cleared 59 OSSF-Scorecard HIGH alerts); enabled auto-delete-head-branches, Dependabot alerts/updates, and secret-scanning push protection. - Type checker (
mypy src) is clean again after the third-party anchor addition (narrowed envelope fields).
[2.0.0b2] - 2026-07-05 (BETA / pre-release)
Added — in-toto eval-result attestation export (PROPOSED; under discussion in-toto/attestation#565)
proofbundle intoto <receipt>exports an eval receipt as a DSSE-signed in-toto Statement v1 with the dedicatedeval-result/v0.1predicate (vendor namespacehttps://b7n0de.com/attestation/eval-result/v0.1for now — the migration path to anin-toto.ionamespace is documented and needs a redirect PR only there). The predicate extends the communitytest-resultshape with a threshold-basedclaims[], privacy-preserving salted-commitment subjects, and an optional binding to the external signed receipt. DSSEpayloadTypeis the canonicalapplication/vnd.in-toto+json; verification accepts standard and url-safe base64.- Subject profiles (
--subject-profile):receipt(default — binds without revealing the model),public-modelandrelease-gate(a disclosed artifact via--subject-name/--subject-sha256, the SLSA "deploy only if the eval passed" hook). Each profile documents what the subject IS. - Commitment-only guarantee: the export refuses a claim that still carries a plaintext identifier or a raw salt (fail-closed), is deterministic (byte-identical statement for identical input), and refuses an incomplete receipt. New adversarial tests + a salt-leak mutation operator.
- Status is PROPOSED, not standardized — see docs and the homepage label. No new runtime dependency; the export stays in the pure-Python DSSE path.
Added — in-toto SVR export (Summary Verification Result, svr/v0.1)
proofbundle svr <receipt>emits an in-toto SVR (https://in-toto.io/attestation/svr/v0.1) for a receipt — but ONLY after a real, passing verification. It carries only PASSING property strings (PROOFBUNDLE_SIGNATURE_VALID,PROOFBUNDLE_RECEIPT_UNCHANGED,PROOFBUNDLE_THRESHOLD_MET, and, when genuinely verified,PROOFBUNDLE_SAMPLE_ROOT_VALID/PROOFBUNDLE_PREREG_BOUND/PROOFBUNDLE_ANCHOR_VALID) — type-generic, never a vendor/service name. A missing check produces NO property.- No SVR on FAIL: the export refuses (fail-closed) if the receipt is not a valid eval receipt, does not
cryptographically verify, or did not pass its threshold. SVR has no FAILED form — a PASSED|FAILED verdict
would be a VSA, deliberately not implemented here (documented).
verifier.policy({uri, digest}) is the optional v0.1 extension field. WATCH: in-toto/attestation#551 (verifier.policies as required) is an open SVR-v0.2 risk. New adversarial tests + an SVR-passing-only mutation operator.
Added — external time-anchor layer (EXPERIMENTAL; the [anchors] extra)
proofbundle.anchors— a generic, fail-closed layer for external time anchors on a receipt. Two targets, never mixed:preRegistration(the commitment existed before the run — the in-toto#565 backdating point) andreceipt(existed from time T). Missing anchors → SKIP; present → a root mismatch, unknown type, or broken proof is a FAIL, never silent;--require-anchor <type|any>. The base install stays anchor-free (onlycryptography); a receipt with no anchors verifies unchanged.- RFC 3161 TSA anchor (
anchors_rfc3161): offline verify (rfc3161-client) against the TSA chain frozen into the anchor at emit time (a TSA can rotate — FreeTSA rotated March 2026). Proven against a real captured FreeTSA token fixture incl. the frozen-chain rotation test. - OpenTimestamps anchor (
anchors_ots): honest lifecycle — a PENDING proof is a WARN, never a full anchor; an upgraded proof needs a Bitcoin block header (a local pruned node) to verify offline, and without one it is reported as upgraded-unverified, never a silent pass. Pending vs upgraded are distinguished. - Extension mechanism (
register_anchor_type) for third-party anchor types with a fail-closed verify callable.docs/ANCHORS.md. A dedicated CIanchorsjob exercises the TSA + OTS tests.
Added — verify check matrix + honest meaning block
proofbundle verify --matrixprints the per-check status matrix plus an explicit "what=> OKproves / does NOT prove" block (authenticity + integrity of the bytes, never the truth of the result — seeNON_CLAIMS.md). The samemeaning/nonMeaningfields and amatrixarray are ALWAYS present inverify --json. Additive and non-breaking: the existingok/checkskeys are unchanged and the default human output is identical unless--matrixis passed.
[1.9.2] - 2026-07-05
Verify-path hardening from an independent six-lens review, plus a public-trust documentation pass. No wire-format change; no new features.
Fixed — verify-path completeness (both are stricter, never looser)
- Eval-claim field set enforced on the VERIFY path (
decode_eval_claim, review F3). The exact key set (_REQUIREDpresent, no unknown fields) was enforced only when emitting; a hand-signed claim missing a required field or carrying an unknown one decoded fine. It is now rejected fail-closed. SemVer note: claims that were previously accepted on decode despite a missing or unknown field are now rejected — this matches the documented_REQUIREDcontract, and every claimemit_eval_receiptproduces still decodes unchanged. New regression test + mutation operator. - Downgrade trap closed (
verify_bundle, review F4): when a relying party passesexpected_aud/expected_nonce(CLI--aud/--nonce) but the bundle carries no verifiable Key Binding JWT, verification now FAILs closed with ansd-jwt-key-bindingcheck instead of returning=> OK— the requested RFC 9901 §7.3 replay/audience binding could not be enforced. Backward compatible: verifiers that pass noexpected_*are unaffected. Test + mutation operator. show-evalno longer risks a raw traceback on a malformed claim (the F3 fix makes decode reject it first); regression test pins the "never a raw traceback" contract.
Added — CI gates
- Claims-hygiene gate (
scripts/claims_hygiene_check.py): fails when a forbidden marketing overclaim appears in the docs outside a negation (the exact phrase list lives in the script). - Doc-link gate (
scripts/doc_link_check.py): fails on a broken internal Markdown link.
Changed — public-trust documentation (truth pass)
- README leads with the receipt kernsatz + a plain-language section; the stale hardcoded test count
is gone (guarded). New
docs/INSPECT_HAPPY_PATH.md— the one Inspect-to-receipt walkthrough, every command verified against the real API. CITATION version synced + abstract bounded (with a version==pyproject test). SECURITY gains a coordinated-disclosure window. COMPLIANCE EU AI Act high-risk timeline updated for the Digital Omnibus (2027-12-02 / 2028-08-02). The 95% detection claim now states its externally-sourced-challenge condition. Internal review/outreach drafts archived out of the repo root.
[2.0.0b1] - 2026-07-02 (BETA / pre-release)
Added — TEE-attestation bridge (EXPERIMENTAL v2.0 preview; opt-in, unstable)
proofbundle.experimental.enclave(install extra[experimental]): makeassurance_level = enclave_attestedverifiable. Following the IETF RATS Passport model (RFC 9334), a Verifier appraises raw TEE evidence (Intel TDX / NVIDIA GPU) out of band and signs an EAT (RFC 9711, JSON/JWS, EdDSA);verify_enclave_attestationchecks it OFFLINE — signature under the Verifier key (a supplied trust anchor),typ/alg, andeat_nonce == enclave_binding_for(receipt)(the binding = base64url SHA-256 over the receipt's exact signed payload, which the enclave places in its quote user-data / TDXREPORTDATA/ GPU report nonce). The trustworthinesstieris REPORTED verbatim (stand-in for the still-draft AR4SI/EAR), never interpreted. Honest scope: proofbundle does not parse or appraise raw hardware evidence — that is the Verifier's role; it verifies the Verifier's signed result + the receipt binding. Standards- native (RFC 9334 + 9711), offline, vendor-neutral — vs proprietary certificate + ledger approaches. CLIproofbundle verify-enclave;docs/EXPERIMENTAL_ENCLAVE.md;examples/experimental_enclave.py.
Experimental gating (so nothing depends on a preview by accident)
- Everything lives under
proofbundle.experimental, is NOT re-exported from the top-level package (must be imported explicitly), and emits anExperimentalWarningonce on import. The stable v1.x trusted core imports none of it.
Beta-release discipline
- Version
2.0.0b1(PEP 440 pre-release —pip install proofbundlewill NOT pull it; use--preor an exact pin). The stable v1.x line remains the default; the experimental bridge is doubly gated (pre-release channel +[experimental]extra). No wire-format or behavior change to any v1 path. Promote toward2.0.0only after the preview stabilizes and, ideally, an external audit.
Verification discipline
- 320 tests (303 on the v1.9.1 base; +16 enclave and +1 EAT-verifier fuzz case: binding, verify roundtrip, freshness, and an adversarial red matrix — wrong verifier key, cross-receipt binding, typ/alg confusion, profile mismatch, claim tamper, garbage, string-exp — plus the experimental-gating pins). Mutation gate: 31 operators (+1 receipt-binding), all killed. Parser fuzz extended to the EAT verifier.
Notes
- Built on the byte-exact upstream v1.9.1 tag (which carried extra release-review hardening:
symmetric
self_issuedtype-guard, beacon flag mutual-exclusion + u64 round bound). - Preview roadmap: migrate
tierto AR4SI/EAR when they become RFCs; optional CWT/COSE encoding; reference Verifier profiles for TDX + GPU (kept out of the core — they pull vendor tooling).
[1.9.1] - 2026-07-02
Added — closing the last small review-backlog items
- Status-list trust-anchor separation (external review #8/#12):
verify_status_snapshotgains an optionalreceipt_issuer_pubkeyand reportsself_issued=Truewhen the status list is signed by the SAME key as the receipt — an issuer attesting its own "still valid" state carries no independent revocation assurance. Reported, not fatal (the relying party decides); a distinct, independently-operated status authority is the stronger anchor. New THREAT_MODEL row + statuslist docstring + tests + mutation operator. make coveragetarget (line coverage of the core over the suite; needscoverage).- docs/GLOSSARY.md — proofbundle in plain terms for a developer without a crypto background
(the review's Iteration-2 request): the 30-second picture, five steps in order, and a term list,
plus "what
=> OKmeans and doesn't". Linked from the README docs table.
Verification discipline
- 303 tests (was 299): +3 self-issued separation (not-asked → None, same-key → True, distinct-key → False). Mutation gate: 30 operators (+1 self_issued compare), all killed.
Notes
- No wire-format or verify-behavior change for existing callers —
self_issuedis a new optional report; omittingreceipt_issuer_pubkeybehaves exactly as before. - Remaining backlog is now owner-only (a binary inspect_ai
.evalfixture formake full-demo, README design assets, GitHub branch-protection /pypireviewer settings) or human actions (outreach, external audit, JOSS paper) — all tracked in REVIEW_v1.6.md and RELEASE.md.
[1.9.0] - 2026-07-02
Added — public-beacon audit mode + a rewritten README
- Public-randomness beacon audits (
proofbundle.beacon, CLIaudit-challenge --beacon-randomness/--beacon/--round): the third per-sample challenge mode (after auditor-nonce and self-challenge) is now formalized. Derive the challenge from a drand / NIST beacon pulse —nonce = SHA-256("proofbundle/v1.9/beacon-nonce" ‖ beacon_id ‖ round ‖ pulse)— so the audit is non-interactive (no live auditor) and publicly re-derivable (anyone re-fetches the same pulse and gets the same indices). A pulse from a round emitting after the receipt's signed timestamp cannot have been ground against (RFC 3797 pattern).AuditRequest.as_dict()publishes the beacon id + round + indices alongside the receipt. Offline-first: the relying party supplies the pulse bytes and validates the beacon's own signature + round timing out of band (stated honestly — this module does not verify the BLS/RSA beacon signature).examples/persample_audit.pygains a beacon variant. SPEC §7g. - README rewritten for humans (556 → ~130 lines): problem-first, a 60-second offline try, the "what it proves / does not prove" table up top, one architecture diagram, a features-at-a-glance list, and a docs table — the exhaustive standards enumeration and deep-dives moved to the linked SPEC/EVAL_CLAIM/INTEROP/FAQ docs. Closes the review's "a fresh reviewer gets lost / quickstart needs a checkout" finding.
Verification discipline
- 299 tests (was 289 test-methods upstream; +10 beacon, roundtrip/binding/red-matrix + CLI mode + a pinned nonce-construction KAT). Mutation gate: 29 operators (+1 beacon round-binding), all killed; the documented-equivalent survivor still survives.
Notes
- Built on the byte-exact upstream v1.8.0 tag (which carried release-review hardening — verify-
path TOCTOU single-read,
merkle.hash_algrequired, comparator/threshold enforcement indecode_eval_claim, HF value-check fail-closed, per-sample canonical-order with native-int compare,prereg --checkauthenticated, tlog-proof ASCII-digit guard). No wire-format change; the beacon mode is a new way to derive an existing challenge, not a format change.
[1.8.0] - 2026-07-02
Added — provenance, pre-registration, and credibility (external-review backlog P1/P2)
- Adapter provenance hardening (
adapters/_provenance.py): inspect_ai, lm-eval and promptfoo claims now record, where the framework exposes it, a stable run-id, a config-hash (<alg>:<hex>over canonical config JSON — RFC 8785 JCS when available, deterministic sort-keys fallback, labeled either way), and the log-native timestamp (inspecteval.created, lm-eval's Unix-floatdate, promptfooevaluationCreatedAt) instead of only the caller's timestamp — this ties the receipt's descriptive run_timestamp to the value the harness's own log recorded, narrowing (not eliminating) the "a self-attesting issuer can backdate" gap: a dishonest issuer who controls the log can still forge the log-native field. lm-eval also carries its nativetask_hash. proofbundle prereg <protocol>(prereg.py, CLI): commit to an eval protocol BEFORE the run — sha256 over the RAW file bytes (the accepted document-commitment convention: git blob, RFC 6962 leaf, in-toto DigestSet all hash raw bytes) → goes in the claim'sprereg_sha256.--check <receipt>verifies a disclosed protocol matches. This is the anti-cherry-picking mitigation for best-of-many runs (per-sample audit covers within-run doctoring).- HF value-consistency guard (
hf_evals.to_eval_results_entry): a publishedvaluethat contradicts the receipt's signed pass/fail verdict (value<comparator>threshold ≠passed) is refused unlessallow_value_mismatch=True— a Hub reader sees the value, not the token.
Added — security tooling & credibility docs
- CodeQL workflow (advanced setup, SHA-pinned to codeql-action v4.35.1 — default setup can't be pinned) and OpenSSF Scorecard workflow (scorecard-action v2.4.3, publishes results).
- Property-based parser fuzzing (
tests/test_fuzz_parsers.py, Hypothesis): every attacker-controlled parser (tlog-proof, checkpoint, cosignature, SD-JWT, KB-JWT, status-list) must return-or-raise-a-proofbundle-error on ANY input, never an uncaught crash. (Manually smoke-tested over ~16k hostile inputs where Hypothesis was unavailable.) - docs/FAQ.md (skeptics), docs/TRUST_ANCHORS.md (where every anchor comes from), docs/PROJECT_BRIEF.md (funding one-pager + 3 grant-abstract seeds), examples/README.md.
- COMPLIANCE.md: regulatory-safe wording, an 8-item "claims that must NEVER be made" list, an honest capability→concept→gap mapping, and a legal/governance FAQ — standards status verified 2026-07 (EU AI Act Art. 12 in force 2026-08-02; NIST AI RMF 1.0 + GenAI Profile; no eval-attestation standard exists).
- INTEROP.md: at-a-glance comparison tables vs Sigstore Rekor, Inspect logs, in-toto test-result, ValiChord, plus the ≤25-word niche + its explicit bound.
- EVAL_CLAIM.md: field table gains
provenanceandsamplesrows; stale "3.9-safe" comment removed.
Verification discipline
- 289 tests (was 263): +provenance (config-hash determinism, log-native timestamp, run-id per adapter), +prereg (raw-bytes hash, match/mismatch, CLI roundtrip, trailing-byte tamper), +HF value-consistency (consistent ok / inconsistent refused / override / non-eval skip), +parser fuzz module. Mutation gate: 28 operators (+2 for prereg + HF checks), all killed.
Notes
- No wire-format or verify-behavior change;
provenance/samplesare additive optional claim fields (already schema-additive since v1.4/v1.5). CodeQL/Scorecard SHAs are current as of 2026-07-02 — re-verify before relying on them (RELEASE.md).
[1.7.0] - 2026-07-02
Changed — release supply-chain hardening (review P1: attested artifact must equal published artifact)
release.ymlnow builds ONCE and publishes the attested bytes. Previously thepublish-pypijob ranpython -m builda second time, so the SLSA/PEP-740 provenance covered a different build than what landed on PyPI. Nowbuild-and-attestuploads the exactdist/viaactions/upload-artifact;publish-pypidownloads it and a sha256 gate fails the upload unless the bytes equal the attested subject digests. ASHA256SUMSfile is attached to the GitHub Release. This closes the single most important supply-chain gap for a tool whose whole premise is provenance.pypiGitHub Environment now carries aurl:and is documented to require reviewer approval (RELEASE.md) so av*tag cannot publish unreviewed; top-level workflowpermissionsreduced tocontents: readwith per-job escalation (least privilege).- All new actions SHA-pinned (
upload-artifactv4.6.2,download-artifactv4.3.0).
Added
- RELEASE.md — one-time setup (Trusted Publishing,
pypienvironment reviewers, branch protection, assets, badge gating) + per-release checklist + a "verify a published release" recipe (gh attestation verify). - docs/REVIEWERS.md — a 30-minute adversarial audit path: the trusted-core map, the two external correctness anchors (RFC 6962 vectors + real Rekor proof), the mutation gate, and an explicit "where the bodies are buried" list of invitations to attack.
- CI
crypto-floorjob — installscryptography==42.*(the declared floor) and runs the suiteproofbundle demo, proving the lower bound actually works, not just the latest.
- External-review issue template (
.github/ISSUE_TEMPLATE/external_review.md).
Fixed — scope-honesty (review Lens 1)
- Badges that render broken/false before the first PyPI release (PyPI version/pyversions/downloads, SLSA, PEP 740) are commented out with a note to enable them on first publish (RELEASE.md).
- SECURITY.md attestation language moved to conditional ("once the first release is published, each release WILL carry…") — no release exists yet, so present-tense claims were premature.
Notes
- No wire-format change. NOTE: the released v1.7.0 additionally carried pre-release-review security fixes
(decode_eval_claim TOCTOU single-read, verify-side comparator/threshold validation, persample native-id ordering)
that DO change verify-path behavior vs v1.6.1 — see the v1.8.0 section and commits. This is a
release-engineering + docs release. The
pypienvironment reviewers and branch protection are GitHub settings the maintainer must apply (documented, not code).
[1.6.1] - 2026-07-02
Added — developer experience (review backlog P0-DX; no security or format change)
proofbundle demo— a pip-only, offline, in-memory demo: an honest receipt verifies, six independent tampers (payload rewrite, signature graft, public-key swap, Merkle-root swap, leaf-index shift, droppedhash_alg) each verify FAILED, and the per-sample audit catches a swapped sample. Exits non-zero if any guarantee breaks, so it doubles as a fail-closed smoke test.--jsonfor machine output. No files, no network, no optional extras. Closes the "quickstart requires a git checkout" gap — the README quickstart now works after a barepip install.examples/persample_audit.py— the v1.5 per-sample feature finally has a runnable example: build a 1000-sample tree, sign the root into a receipt, auditor challenges 20 random indices with a fresh nonce, all openings verify, a swapped-sample opening is rejected.scripts/demo_tamper.sh+ Makefile targetsdemo,tamper-demo,persample-demo,full-demo(the old real-log demo),mutation,examples.- docs/DEMO.md — three tiers (pip-only / checkout / extras), each with expected output and the reviewer forced-random-sample-check CLI recipe.
Verification discipline
- 254 tests (was 251):
tests/test_demo.pypins that all six tampers are caught and none missed, in both text and JSON modes and via the CLI entry point.
[1.6.0] - 2026-07-02
Fixed — external Principal-Security review (6 lenses + orthogonal iterations); every fix
has a regression test and a mutation operator
- CRITICAL (P0) — bearer-downgrade via issuer-key omission (
bundle.py): the holder-binding check was gated on issuer-signature verification, so an attacker could strip the KB-JWT AND dropsd_jwt_vc.issuer_public_key_b64to silently downgrade acnf-bound credential to a passing bearer token. Now acnf-carrying SD-JWT whose issuer cannot be verified is REFUSED (sd-jwt-key-binding= False), fail-closed. Plain SD-JWTs withoutcnf/KB keep the documented no-key path. Proven closed by an executed attack (test_bundle_cnf_bound_no_issuer_key_fails_closed)- backward-compat pin.
- P1 — verify-side invariants (
evalclaim.decode_eval_claim): thesamples.n == n,leaf_algand 32-byte-root checks (previously only in the emitter) now run on the VERIFY path — a hand-signed claim that lies about the committed tree size is rejected. Newdecode_eval_claim(bundle, *, expected_context=...)enforces the signedcontext_binding(cross-context replay guard); it was signed but never checked. - P1 — status-list freshness (
statuslist.py): a token with neitherexpnorttlis no longer reported "fresh forever" —freshisNone(cannot judge) so a stale pre-revocation snapshot cannot masquerade as current;exp/ttlmust be integers when present (a string that looks like an expiry but never enforces is rejected, not silently ignored). - P1 —
merkle.hash_algis now REQUIRED (bundle.py): a silently-defaulted algorithm contradicted the "reject anything non-canonical" posture and would mask alg-confusion in a future multi-alg version. - Docs/honesty: softened the Rekor v2 witnessing claim to "is integrating" (matches the
Sigstore GA post, which says witnessing is coming, not shipped); quickstart notes that
examples/ships in the repo, not the wheel; SECURITY.md0.x→1.x; persample module docstrings de-drifted from "(v2.0)" to "(v1.5)" (wire constants unchanged).
Changed
- Development Status classifier → 4 - Beta (was Alpha): SemVer-committed, 251 tests, stable lazy public API. COMPLIANCE.md still says do not rely on it as a sole compliance control.
Verification discipline
- 251 tests (was 242): +9 for the fixes above (P0 attack + backward-compat, verify-side samples matrix, context_binding enforcement, status freshness/typing). Mutation gate: 26 operators (+4 for the v1.6 fixes), all killed; the one documented-equivalent mutant still survives.
- A full REVIEW_v1.6.md accompanies this release: executive verdict, top-10 weaknesses, P0/P1/P2 plans, README-rewrite proposal, ≥20-row test matrix, 20-issue backlog, outreach pack, pitches.
Not yet done (tracked in REVIEW_v1.6.md issue backlog, honest)
make tamper-demo+proofbundle demo(pip-only) + a per-sample example are DESIGNED and specified in the review but not yet shipped in this patch (they are P0 DX, not security).- Release supply-chain: attested artifact must equal published artifact (
release.ymlrebuilds);pypienvironment reviewers; badges gated behind first publish. Specified, not yet wired.
[1.5.0] - 2026-07-02
Added — per-sample receipts (the THREAT_MODEL's named gap, closed; design verified against
TRUCE arXiv:2403.00393, RFC 9901, RFC 6962/9162, RFC 3797, PoR literature)
proofbundle.persample:build_sample_treecommits every individual sample of a run into an RFC 6962 SHA-256 Merkle tree (leaf = 0x00-domain-separated hash over a base64url disclosure[salt, record]— the RFC 9901 digest mechanic, so verification never canonicalizes JSON). Canonical leaf order with the positionidxembedded INSIDE each committed record; per-leaf ≥128-bit salts derived HMAC-SHA-256-as-PRF from ONE holder-kepttree_secret(never in the receipt; one shared salt would be burned by the first opening — eval answer spaces are tiny).- Signed
samplesclaim field{root_b64, n, leaf_alg}(schema: additive optional;samples.nMUST equal the claim'sn). Measured, documented finding: an RFC 6962 inclusion proof binds n only up to path-shape equivalence (index 4 of a 10-leaf tree verifies under any claimed n′ ∈ [9..16]) — the SIGNATURE is the size-truth anchor, and the test suite pins the coincidence window so it stays measured fact, not folklore. SPEC §7g. - Openings + audit protocol:
sample_opening/verify_sample_opening(inclusion under the signed root, disclosure decode,record.idx == indexreplay guard — the case where the lie sits inside a validly-committed leaf, i.e. a lying PRODUCER, is red-tested);audit_challengederives k distinct indices via SHA-256 domain-separated seed + HMAC counter expansion + rejection sampling (_map_drawisolated as a pure function because the rejection branch fires with p≈1e-19 and can only be tested in isolation). Modes: auditor nonce (grinding-impossible), public beacon (RFC 3797-style), self-challenge (sanity only — re-salting grinding bound ≈ g·(1−m/n)^k stated, never hidden; the CLI warns actively). PoR soundness table in docs (k=300 → 95% @ m=1%, k=459 → 99%). CLI:audit-challenge,verify-opening. The protocol domain strings are pinned atproofbundle/v2/*(protocol identifiers, independent of the package version). - Sample extractors (
adapters.samples): lm-evaluation-harness--log_samplesJSONL (consumes its nativedoc_hash/prompt_hash/target_hash— wrapped INSIDE the salted leaf, since upstream hashes are unsalted and dictionary-attackable alone) and promptfoo v3 rows. Two-layer hiding: leaves carry content hashes/compact results, never benchmark plaintext.
Verification discipline
- 33 new tests (209 → 242): byte-exact pins (salt derivation, RFC 6962 leaf prefix, independent challenge re-derivation, rejection sampling in isolation), roundtrips, and an adversarial matrix incl. the lying-producer embedded-idx forgery, disclosure/proof tamper, root/n confusion, and the pinned n′ shape-equivalence window; 6 new mutation operators (22 total, all as expected).
- Mutation-gate hardening after a real incident: a same-size mutation on a coarse-mtime
filesystem left a stale
.pycthat silently survived restoration and skewed three measurements; the runner now purges__pycache__, runs with-B/PYTHONDONTWRITEBYTECODE, and force-touches source mtimes (existing caches are READ even under-B).
Notes
- Versioning: per-sample receipts are strictly additive (no API or format break; v1.4 receipts verify unchanged) — hence a MINOR release per SemVer, deliberately NOT a marketing-major.
- Honest residuals (THREAT_MODEL updated): best-of-many full runs remain undetectable without pre-registration; opened samples are burned (auditor-directed openings only).
[1.4.0] - 2026-07-02
Added — distribution (formats verified against primary sources, 2026-07-02)
- promptfoo adapter (
proofbundle.adapters.from_promptfoo_results): reads a promptfooeval -o results.json(summary version 3, verified against promptfoo mainsrc/types/index.tsOutputFile/EvaluateSummaryV3/EvaluateStats) into apass_ratereceipt — successes/(successes+failures+errors) as a fixed-point decimal, model commitment over the sorted provider-id set, dataset commitment over canonicalconfig.testsJSON (the test suite IS the dataset; promptfoo's internal datasetId is not exported). File-based, no promptfoo import. Legacy v1/v2 summaries (a differenttableshape) are REJECTED with a clear message — never half-parsed; "v4" is promptfoo's storage version and never appears in output files. Committed realistic fixture. - Hugging Face Community Evals bridge (
proofbundle.hf_evals, CLIproofbundle hf-token):receipt_token(bundle)packs a receipt aspb1.+ base64url(zlib(bundle JSON)) — the token IS the receipt, verified offline byverify_receipt_token(zip-bomb-capped, fail-closed);to_eval_results_entry+eval_results_yamlemit schema-faithful.eval_results/*.yamlentries (spec: hub-docs eval_results.yaml), refusing non-verifying receipts, with a strict purpose-built YAML serializer (JSON-escaped scalars — dates and tokens cannot be misparsed). Honesty boundary, stated in code and docs: HF's verified badge is decided server-side by HF (HF Jobs + inspect-ai); its token format is not public. Thepb1.token is proofbundle-verifiable and schema-valid in theverifyTokenfield — it is NOT presented as HF-endorsed, and the receipt link belongs insource.url/noteseither way. - INTEGRATIONS.md: promptfoo + HF sections;
OUTREACH_pr_every_eval_ever.md— a draft upstream PR description offering the EEE→receipt converter (shipped since v0.9) to evaleval/every_eval_ever (the human submits, per that project's contribution norms).
Changed — BREAKING (deliberate, roadmap item)
- Python floor is now 3.10 (
requires-python >= 3.10): Python 3.9 reached end-of-life 2025-10-31; the ecosystem (NumPy, inspect_ai, current cryptography features) has moved. The redundantpython_version >= "3.10"markers on the inspect extras are gone; CI drops the 3.9 lane (matrix is now 3.10–3.14). Code changes: none required — the codebase was already 3.9-clean, the floor change is packaging metadata + CI.
Verification discipline
- 21 new tests (188 → 209): promptfoo green fixture → verified receipt, data-minimization pin
(no exact score in the claim), dataset-commitment sensitivity, version-gate red tests,
zero/negative/bool count guards;
pb1.token roundtrip, tamper-inside-token, garbage/zip-bomb/ non-dict red matrix, YAML structure + JSON-scalar parseability pins, broken-receipt refusal. - 4 new mutation operators (16 total, all as expected): HF broken-receipt guard off, token-verify fake OK, promptfoo version gate off, failures dropped from pass_rate.
[1.3.0] - 2026-07-02
Security & correctness hardening (full 6-lens re-audit of the whole tool before tag, 2026-07-02)
- CRITICAL — holder-binding downgrade closed. A credential issued with a
cnfholder key now FAILS verification if the KB-JWT is stripped (RFC-9901-legal no-key-binding form) — previously a bearer replay of a proof-of-possession credential verified OK. Bundles withoutcnfstay backward-compatible. - HIGH — RFC 9901 §7.3 audience/replay binding reachable through the public API.
verify_bundle(and CLIverify --aud/--nonce) now accept and enforceexpected_aud/expected_nonce; before, the aud/nonce enforcement existed only on the internalverify_key_bindinghelper no public caller could reach. - HIGH — holder-binding check requires a verified issuer signature. The
sd-jwt-key-bindingcheck now runs only whensd_jwt_vc.issuer_public_key_b64was supplied and the issuer signature verified — otherwise thecnfholder key (declared inside the issuer-signed JWT) is unauthenticated and a forged SD-JWT could report a valid-looking holder binding. - HIGH — witness quorum counts distinct KEY MATERIAL, not names in BOTH verifiers.
verify_witnessed_checkpointANDverify_tlog_proofnow sharecheckpoint.witness_quorum, deduping on the DECODED key bytes (Ed25519 + ML-DSA); one physical key under N names no longer satisfiesthreshold=Nin either path. - HIGH — no raw tracebacks on malformed input: a non-string
sd_jwt_vc.compactnow raisesBundleFormatError(wasAttributeError); CLIverify/show-evalcatch file/JSON errors cleanly. - MEDIUM: KB-JWT
audrestricted to a single string (RFC 9901 §4.3); C2SP tree-size rejects non-ASCII digits;present_with_key_bindinghashessd_hashwith the SD-JWT's OWN declared_sd_alg(read from the presented compact, not a module constant); the lm-eval adapter formats scores as fixed-point (no scientific-notation drop);sign_checkpointvalidates keyname; origin/witness names reject all Unicode whitespace;recompute_merkle_root_b64validateshash_algand shows the stated root canonically; the ML-DSA verify path builds its signed message inside the fail-closed guard; the status-list zlib decompression is size-bounded (CWE-409);verify_tlog_proofaccepts an optionalexpected_origin. - 188 tests (adds regressions for every item above, incl. one-key-under-many-names in tlog-proof, and a holder-binding check skipped when the issuer signature is unverified).
Added — the portable proof (spec-verified against primary sources, 2026-07-02)
- C2SP tlog-proof (
proofbundle.tlogproof, new CLIproofbundle verify-proof): emit and verify.tlog-prooffiles — index + RFC 6962 inclusion proof + verbatim (co)signed checkpoint, the C2SP "transparent signature" envelope (c2sp.org/tlog-proof@v1).tlog_proof_for_bundlerefuses a checkpoint that disagrees with the bundle's root/size (No-Fake at build time); the verifier recomputes the leaf hash from the exact payload bytes, never trusts the file, treatsextraas unauthenticated, and reports log/witness/inclusion sub-verdicts with a conjunction verdict. Rekor v2 institutionalizes exactly this persist-your-proof model. SPEC §7e. - ML-DSA-44 witness cosignatures (C2SP type 0x06, FIPS 204 — the spec's SHOULD for new
witness deployments):
cosign_checkpoint_mldsa/cosign_vkey_mldsa;verify_cosignaturenow dispatches on the vkey algorithm byte (0x04 Ed25519 / 0x06 ML-DSA-44 — a 0x01 LOG key is still never a witness). Signed message = the C2SPcosigned_messagestruct (label"subtree/v1\n\0", name-committing, RFC 8446 serialization) — pinned byte-exact by a KAT test, not just a roundtrip. Optional extraproofbundle[pq](=cryptography>=48, PQ in default wheels since 2026-05); on builds without ML-DSA a configured 0x06 witness raises UnsupportedError — fail-closed, never a silent False. Ed25519 stays the default; primary signatures unchanged. SPEC §7d. - Token Status List snapshot (
proofbundle.statuslist): offline revocation per draft-ietf-oauth-status-list (RFC-Editor queue, format frozen at -21).status_claim(uri, idx)goes into the receipt SD-JWT;verify_status_snapshotchecks a supplied signedstatuslist+jwt(EdDSA,sub↔uribinding, bits ∈ {1,2,4,8}, zlib bit-array) and reads the status. Freshness (iat/exp/ttl) is reported, and judged ONLY when the caller suppliesnow— no wall-clock assumptions in an offline verifier. Bundle format v0.1 unchanged: the snapshot is a separate verifier input. SPEC §7f. - SD-JWT VC markers (
sdjwt_issue): issuer headertyp: dc+sd-jwt, avcttype URI (defaulthttps://b7n0de.com/proofbundle/vct/eval-receipt/v1), optionalstatusclaim — the four stable interop markers of draft-ietf-oauth-sd-jwt-vc (pre-IESG; full VC conformance stays deferred, type-metadata resolution deliberately not implemented). - COMPLIANCE.md — an honest, non-legal mapping of receipts onto EU AI Act Article 12 record-keeping (applies to high-risk systems from 2026-08-02), the GPAI Code of Practice Model Report evidence, NIST AI RMF MEASURE, and prEN 18229-1 / ISO/IEC DIS 24970 — including the anti-patterns section (what NOT to claim).
Verification discipline
scripts/mutation_check.py+ a CImutationjob — the orthogonal mutation suite is now a repeatable repo gate (12 operators across kbjwt/bundle/checkpoint/tlogproof/statuslist/CLI), differential against the baseline; documented-equivalent mutants are asserted to SURVIVE so a stale equivalence argument also fails the gate. The suite immediately earned its keep: the ML-DSA domain-separation-label mutant survived the first run (emit+verify shared the constant — a self-consistency tautology) and is now killed by a byte-exactcosigned_messageKAT.- 44 new tests (133 → 177 in-tree): green roundtrips + red matrices (wrong leaf/log key/index, proof-hash tamper, unauthenticated-extra probes, quorum shortfall, ML-DSA name-commitment forgery, timestamp/body tamper, status-list signature/uri/typ/index attacks, bit-flip needs-resign) + the ML-DSA KAT pins.
- CI matrix extended to Python 3.13 / 3.14.
Notes
- Still deferred, stated honestly: full SD-JWT VC conformance +
vcttype metadata (pre-IESG), per-sample Merkle receipts (v2.0 direction, THREAT_MODEL's named gap), an official in-toto eval predicate (proposal path via OpenSSF/CoSAI), Python-3.10 floor.
[1.2.0] - 2026-07-02
Added — holder binding + witness quorum (verified against primary sources)
- Key Binding JWT verification (
proofbundle.kbjwt, closes #1): RFC 9901 §4.3, fully offline — headertypMUST bekb+jwt(alg EdDSA), payload MUST carryiat/aud/nonce/sd_hash,sd_hashrecomputed over the US-ASCII bytes of the presentedJWT~disclosures…~with the SD-JWT's_sd_alg(binds the presented disclosure set — dropping or swapping a disclosure after signing is detected), signature verified under the issuer-boundcnf.jwkholder key (RFC 7800; a supplied holder key is the fallback, the issuer's binding wins).expected_aud/expected_noncefor relying-party policy;iatfreshness stays caller policy (offline verifier, no trusted clock). SPEC §6/§7. - KB-JWT issuance/presentation (
sdjwt_issue):issue_sd_jwt(..., holder_public_key=...)embedscnf.jwk(OKP/Ed25519); newpresent_with_key_binding(compact, holder_signer, aud=, nonce=, iat=)builds the holder presentation. Explicitiat— the library never samples wall clocks for signatures. - New bundle check
sd-jwt-key-binding— fail-closed: a KB-JWT that is present must verify; previously a trailing KB-JWT was silently ignored, a downgrade risk (a bundle carrying holder binding verifiedOKwithout the binding being checked). Bundles without a KB-JWT are untouched — no new check, behavior identical to v1.1. SPEC §7 order gains step 5. - C2SP tlog-cosignature, Ed25519 cosignature/v1 (
proofbundle.checkpoint):cosign_checkpoint/verify_cosignature/verify_witnessed_checkpoint(..., threshold=)— witness key ID algorithm byte 0x04 (domain-separated from the log's 0x01 by construction), signature blobkeyID[4]‖u64-BE-timestamp‖sig[64](exactly 76 bytes), signed message"cosignature/v1\n" + "time <ts>\n" + note body. Verifying a witness quorum rules out a split view by the log operator, offline — the pattern Rekor v2 (GA 2025-10) institutionalizes. The log's own signature stays required (witnesses attest consistency, they don't replace the log). SPEC §7d. - CLI
proofbundle verify --verbose(closes #2): prints the recomputed Merkle root next to the stated root (also under--jsonasmerkle_root.{stated_b64,recomputed_b64}), via the new publicrecompute_merkle_root_b64. Debugging inclusion-proof failures no longer needs a REPL.
Verification discipline
- 37 new tests: green roundtrips plus an adversarial red matrix per feature (disclosure drop/swap after
KB signing,
typ/algconfusion, missing required claims, fail-open probes, cosignature timestamp/body tamper, log-vkey-as-witness type confusion, quorum double-count, oversized signature blob). An orthogonal mutation suite (9 operators across kbjwt/bundle/checkpoint/CLI) kills 8/9 mutants; the survivor is provably equivalent (oversized blobs already die atverify_ed25519's hard 64-byte signature length check).
Notes
- Python floor stays 3.9 in this release (no floor change in a minor); 3.9 is EOL since 2025-10 — bumping to 3.10 is a deliberate follow-up decision.
- Still deferred, stated honestly: SD-JWT VC conformance /
vcttype metadata (draft-ietf-oauth-sd-jwt-vc-16, RFC expected ~Q4 2026), Token Status List verification (draft-21 in the RFC-Editor queue; frozen bit-array+zlib format — a good candidate as a bundled snapshot), ML-DSA-44 cosignatures (C2SP SHOULD for new deployments; needs an ML-DSA dependency).
[1.1.0] - 2026-07-02
Added — trust hardening: the honest foundation (authorship + integrity, stated precisely)
- Signed
assurance_level(required field, enumself_attested|third_party|reproduced|enclave_attested, defaultself_attested) in the eval claim + schema + EVAL_CLAIM.md. The 1.0 integrations emit self_attested.show-evalalways displays it; the level is signed (tamper-evident, issuer-declared) — a third party cannot alter it, though a dishonest issuer can self-declare a higher level (the signature binds who claimed it, not that it is true). Schema keeps assurance_level OPTIONAL for v1.0 backward-compat; the code always emits it (setdefault). Pre-1.1 claim JSONs default to self_attested. - THREAT_MODEL.md — what
verifycatches (tampering, issuer swap, model swap, filtered disclosure, replay, weak-assurance-masking) and what it structurally cannot (dishonest self-attested issuer, publish-best-of-many without pre-registration, suite validity, per-sample sub-sampling = roadmap). claim_warnings— warns on the weakest combination (self_attested with noprereg_sha256); shown byshow-eval.verify_commitment(identifier, salt, commitment)— checks a presented model/dataset identifier against the salted commitment, so a model swap is visible.check_freshness(claim, max_age_seconds)— reports receipt age (replay protection); the timestamp was carried but never judged before.sd_jwt_hidden_count— surfaces the number of withheld SD-JWT fields, so omission is visible.tests/test_adversarial.py— actively forges receipts: invented-numbers-with-valid-signature (PASS is EXPECTED + warned — binds authorship not truth), tampered payload (FAIL), withheld-field count, model swap (mismatch), replay (detectable), honest receipt still verifies.- A consolidated "What a receipt proves (and what it does not)" section high in the README + assurance table.
Note
- Terminology hygiene throughout: tamper-evident signed evidence, not proof; authenticity and integrity, not correctness of the computation.
[1.0.0] - 2026-07-02
Added — distribution: opt-in framework integrations (the 1.0 milestone: usable with zero third-party wiring)
- inspect_ai end-of-task hook (
proofbundle._inspect_registryvia theinspect_aientry-point): auto- emits a signed receipt from the eval log at task end. Requiresinspect_ai>=0.3.112.data.logis the EvalLog (no re-read for a normaleval(); header-onlyeval_set()falls back to reading the log). - pytest plugin (
proofbundle.pytest_pluginvia thepytest11entry-point): auto-emits a signed receipt of the run (metricpass_rateover UNIQUE tests, per-outcome counts + exit status in provenance) fromterminalreporter.stats. New optional[pytest]extra. - OPT-IN SAFETY (the top rule): both integrations emit ONLY when explicitly enabled (
PROOFBUNDLE_EMIT=1orpytest --proofbundle) — never silently write a file, never fail the host run, crypto imported lazily. - Composite GitHub Action prepared under
action/action.yml(SHA-pinned, env-indirect command) +INTEGRATIONS.mdwith a complementaryattest-build-provenancerecipe. - The package
__init__is now lazy (PEP 562): loading the plugin/hook no longer pulls the crypto core until a public name is actually used, keeping framework startup light.
Changed
- README leads with the integration story; fair demarcation from ai-audit-trail (runtime agent Decision Receipts) and ValiChord (which builds attestation bundles from inspect_ai logs post-hoc — its v1 library is unsigned; signatures are v2 scope). Honest novelty: proofbundle auto-emits an Ed25519-signed receipt of an inspect_ai eval / pytest run via the framework's native plugin, a capability not found in a comparable released tool at the time of writing.
- The inspect_ai adapter renders metric scores as fixed-point decimals (not
repr), so tiny/large values (e.g.1e-05) no longer fail the claim's decimal format.
[0.9.0] - 2026-07-02
Added — the standards moat (verified against primary sources)
- DSSE-signed in-toto test-result export (
proofbundle.intoto.export_intoto_dsse+verify_intoto_dsse, newproofbundle.dsse): a receipt as a DSSE envelope over the GENERIC in-tototest-result/v0.1predicate (result PASSED/FAILED,configurationResourceDescriptors with real digests, metrics inannotations). PAE is signed over the RAW Statement bytes (never base64), payloadType is pinned. SPEC §7b. - C2SP tlog-checkpoint (
proofbundle.checkpoint): a signed note over the RFC 6962 Merkle root (origin / tree size / standard-base64 root; EM DASH U+2014 signature line; keyID = SHA-256(name‖0x0A‖0x01‖pubkey)[:4]; vkey encoding). Raw note bytes signed, no PAE. SPEC §7c. - Every Eval Ever converter (
proofbundle.adapters.from_eee_dataset): reads an EEE v0.2.2 aggregate JSON into a signed receipt, validated against the vendored EEE schema, with NO runtime import ofevery_eval_ever(it needs Python 3.12; proofbundle stays 3.9+). The EEEevaluation_id(which embeds the model id) is deliberately NOT copied into provenance — the receipt keeps the model a salted commitment. - Examples for all three (
examples/intoto_dsse_export.py,checkpoint_example.py,eee_receipt.py).
Changed — standards-native repositioning
- README tagline + "How it fits" name the neighbours fairly (Every Eval Ever, OpenSSF Model Signing, ValiChord, Attestable Audits) with the honesty guardrail visible; INTEROP.md gains a ValiChord section.
- SD-JWT digest mechanic re-verified against RFC 9901 §4.2.3 (Nov 2025) + the sd-jwt-python reference.
[0.8.1] - 2026-07-01
Fixed
make demo/scripts/demo.sh/Makefilenow invokepython3(overridable viaPYTHON=...), not a barepython, so the documented demo works on systems where onlypython3is on PATH (PEP 394).
[0.8.0] - 2026-07-01
Added
- Offline demonstrator:
make demo/scripts/demo.sh+Makefileturn genuine eval logs (an inspect_aimockllm.evaland an lm-eval--model dummyresults.json, committed fixtures generated offline) into signed, Merkle-anchored receipts and verify them — no network, API key, or GPU.examples/inspect_receipt.pyadded; a "Demo" README section makes it prominent. - Honesty guardrail (README + SPEC): a receipt attests authenticity + integrity of a claimed result, not the correctness of the computation nor the absence of cherry-picking (TEE audits target that, different trust model). Demarcated from a bare hash (ref inspect_evals PR #1610) and from TEE approaches.
- INTEROP.md: Every Eval Ever (integration target, converter bridge) + Attestable Audits (TEE, different trust model) sections; SECURITY.md notes the SLSA v1.2 attestation model.
- Engagement drafts (
OUTREACH_issue_inspect_evals.md+ updated outreach note) — clearly marked draft-only; the human posts and replies personally per the inspect_evals AI-use policy.
Note
- v0.6/v0.7 already delivered the lm-eval + inspect_ai adapters, INTEROP.md, PEP 740 docs and CITATION.cff; this release skipped those and added only the open points (demo, guardrail, outreach), per the update.
[0.7.1] - 2026-07-01
Fixed
A holistic 6-lens review of the whole integration (v0.1-v0.7) found robustness/conformance/CI gaps the per-version reviews missed; all fixed here:
- Verifier robustness:
verify_bundlenow rejects malformed input with aBundleFormatError(the documented malformed path) instead of a raw traceback - type-confusedleaf_index/tree_size(non-int/float), a non-objectsignature/merkle, a missinginclusion_proof_b64(required per SPEC), and unknown top-level/nested fields (SPEC additionalProperties:false, previously unenforced). - Eval-claim schema conformance:
build_eval_claimrejects values that fail its own published schema - negativen, and non-plain-decimalthreshold/score(1e2,Infinity,+5, spaces). - CI on Python 3.9:
inspect_ai(requires Python >=3.10) is gated by apython_version >= "3.10"marker in theinspect/devextras, sopip install .[dev]/[inspect]no longer fails on 3.9. - inspect_ai provenance parity: the inspect adapter now captures run provenance (git commit, harness
version, task version) into
provenance, matching the lm-eval adapter. - mypy is now run in CI (declared but never enforced); fixed two real mypy errors in
intoto.py. A clear error names the missing[eval]extra ifrfc8785is absent on the emit path.
Changed (docs)
- Zenodo DOI wording made aspirational (no DOI assigned yet). INTEROP.md updated to CycloneDX v1.7 + C2PA ~v2.4. Corrected the arXiv:2507.06893 attribution (inspect_evals maintainers, Arcadia Impact, UK-AISI- funded). Refreshed stale CONTRIBUTING/PR/issue-template wording.
[0.7.0] - 2026-07-01
Added
- CITATION.cff now carries the author ORCID (0009-0006-8947-6065); a Zenodo DOI placeholder is marked in the README + CITATION.cff (a DOI is assigned once Zenodo archives a release; none exists yet — human checklist).
docs/in_toto_predicate_proposal.md— a draft proposing an ML eval-result predicate upstream to in-toto/attestation (no registered ML-eval predicate exists yet); the human decides whether to submit.
Unchanged (already delivered in v0.6, verified, not rebuilt)
- inspect_ai adapter (non-deprecated
results.scores[*].metrics[name].valuepath), lm-evaluation-harness adapter (realacc,noneformat + provenance), INTEROP.md, PEP 740 attestations + badge fixes. This release re-confirmed each is present and correct rather than duplicating it.
[0.6.0] - 2026-07-01
Added
- Second eval adapter, EleutherAI lm-evaluation-harness —
proofbundle.adapters.from_lm_eval_resultsreads a realresults_*.json(nolm_evalimport), handling the genuine 0.4.x format: metric keys with a filter suffix (acc,none) and the siblingacc_stderr,none. Captures run provenance (git_hash, task version, n-shot, stderr) into the receipt's optionalprovenancefield. Validated against a committed real fixture (tests/fixtures/lm_eval_arc_easy_real.json, harness 0.4.12) +examples/lm_eval_receipt.py. - INTEROP.md — honest mapping to OpenSSF Model Signing (complement, not eval), CycloneDX ML-BOM v1.6 (can reference a receipt), in-toto test-result/v0.1 (the open ML-eval niche), C2PA (out of scope).
- CITATION.cff so the repo shows a "Cite this repository" button.
- Optional additive
provenancefield on the eval claim (backward-compatible, schema string unchanged).
Changed
- inspect_ai adapter confirmed on the non-deprecated
results.scores[*].metrics[name].valuepath with a None-guard (already correct since v0.5; documented). - README/SPEC positioned as the verification layer for trustworthy eval logs; PEP 740 attestations documented (verified present on PyPI via the Integrity API, publisher = GitHub Trusted Publishing).
- Badges: python-version badge cache-buster (
?cacheSeconds=3600) + a pepy downloads badge.
Deferred (not built)
- No CycloneDX / C2PA / OMS re-implementation, no
lm_evalruntime dependency, no.zenodo.json(would shadow CITATION.cff), no official in-toto predicate PR (drafted for the human to submit).
[0.5.0] - 2026-07-01
Added
- SD-JWT issuance (RFC 9901) —
proofbundle.sdjwt_issue.issue_sd_jwt: issue an eval receipt so a holder can disclosepassed+thresholdwhile withholding the exact score and the identifier openings. The signed bundle payload is the source of truth; the SD-JWT is a derived view, binds the bundle merkle root (receipt.root_b64), and is signed with the same Ed25519 key asissuer. Digest byte-chain exactly per RFC 9901 §4.2.4.1 (over the base64url-encoded disclosure string). Verified by proofbundle's own verifier and the openwallet-foundation-labs/sd-jwt-python reference; divergence + tamper red-tests. - in-toto Statement v1 view —
proofbundle.intoto.to_intoto_statement: self-hosted predicate typehttps://b7n0de.com/proofbundle/eval-receipt/v0.1. The subject digest is a salted commitment under a custom keyproofbundleModelCommitV1(NOTsha256, which would imply an artifact hash). Validated against the in-toto Statement-v1 JSON schema via jsonschema. See PREDICATE.md. - inspect_ai adapter via the stable
read_eval_log(header_only=True)API (lazy import, optional extraproofbundle[inspect]pinned>=0.3.100,<0.4), with a real committed.evalfixture.
Changed
- The inspect_ai adapter now uses the stable API instead of parsing the
.evalfile (robust across versions). The lm-eval adapter still readsresults.jsonwithout importing the framework.
Deferred (explicitly not in v0.5)
- SD-JWT VC conformance +
vcttype metadata, Key-Binding JWT, status lists / revocation, an official in-toto/attestation PR, a DSSE envelope or full in-toto verification client.
[0.4.1] - 2026-07-01
Fixed
- Removed a dead v0.3
emit_eval_receiptroadmap stub fromemit.pythat contradicted the real emitter now inevalclaim.py. - Corrected the RFC 9901 publication date to November 2025 (was "December 2025") in the
README,
sdjwt.py, and this changelog. - Doc staleness: test count and version wording in the README.
- Release workflow: the PyPI publish step is now idempotent (
skip-existing) so a re-tagged release does not fail on an already-uploaded file.
[0.4.0] - 2026-07-01
Added
- Eval-receipt emitter (
src/proofbundle/evalclaim.py): turn a reproducible eval run into a signed, Merkle-anchored receipt that proves suite Scomparatorthreshold T, passed while carrying only salted commitments to the model and dataset identifiers (never the weights, data, or plaintext names). Built onemit_bundle, so the existingverify_bundleverifies a receipt unchanged.build_eval_claimcomputespasseditself;emit_eval_receiptbinds the receipt to the signer (issuerfield in the signed payload);decode_eval_claimverifies the bundle and the issuer binding.- RFC 8785 JCS canonicalization on the emit path only (UTF-16 key sort, NFC, duplicate- key + Python-float rejection, safe-int range); the verify path checks stored bytes, so the verifier stays dependency-free.
- File-based framework adapters (
proofbundle.adapters.from_lm_eval_results,from_inspect_ai_log) that read exported result JSON without importing the framework. - CLI:
proofbundle emit-evalandproofbundle show-eval. EVAL_CLAIM.md(normative claim spec + data-minimization) andschemas/eval_claim_v0_1.schema.jsonwith a validation test.- Optional extras:
proofbundle[eval](RFC 8785 canonicalizer, emit side),proofbundle[adapters].
[0.3.0] - 2026-07-01
Added
- External RFC 6962 conformance: verifies canonical inclusion vectors vendored from transparency-dev/merkle (tests/fixtures/rfc6962_vectors.json) — proven RFC-conformant, not merely self-consistent. Plus Hypothesis property tests (inclusion + consistency) for trees up to several hundred leaves.
- Sigstore Rekor interop:
examples/rekor_interop.pyverifies a real Sigstore Rekor inclusion proof (logIndex 25579, tree size 4.16M) fully offline, with a committed fixture and a field-mapping doc (Rekor bundle / C2SP checkpoint). - SD-JWT is an optional extra:
pip install "proofbundle[sdjwt]"(core stays cryptography-only). - Normative format specification
SPEC.md(fields, encodings, RFC 6962 hashing, verification order), consistent with the JSON Schema. .github/dependabot.yml(github-actions + pip).- PyPI Trusted Publishing (OIDC) publish job in the release workflow.
Changed
- All GitHub Actions pinned to full commit SHAs (post tj-actions incident).
- SD-JWT docstrings/README cite RFC 9901 (SD-JWT core, November 2025); clarify SD-JWT VC is still an IETF draft.
[0.2.0] - 2026-07-01
Added
- Bundle emitter:
emit_bundlesigns a payload with Ed25519 and anchors it as the last leaf of an RFC 6962 Merkle tree, producing a bundle thatverify_bundleaccepts — the offline counterpart to the verifier. - Signing-key helpers
generate_signer,save_signer,load_signer(raw 32 byte Ed25519 seeds). proofbundle emitcommand line interface (--payload-file,--new-key/--key,--out).- Emit-then-verify round-trip tests, including prior-leaf anchoring, tamper detection and key save/load.
Notes
- No new runtime dependency; the emitter reuses the existing Merkle logic and
cryptography. The v0.3 eval-receipt emitter remains a roadmap stub.
[0.1.0] - 2026-07-01
Added
- Offline evidence bundle verifier (
proofbundle/v0.1schema). - Published JSON Schema (
schemas/proofbundle_v0_1.schema.json) with a validation test,py.typedmarker and community files (Code of Conduct, issue and pull-request templates). - RFC 6962 / RFC 9162 Merkle inclusion and consistency proof verification.
- Ed25519 signature verification via
cryptography. - Minimal SD-JWT selective-disclosure verification (EdDSA issuer signatures, disclosure-digest commitment check).
proofbundle verifycommand line interface with human and JSON output.- Example bundle generator (
examples/make_example.py) and a real example bundle. - Full unit test suite (Merkle round-trip across sizes, signature, bundle, CLI).
- Emitter roadmap stub for v0.2 (bundle emission) and v0.3 (eval receipts).