All notable changes to this project are documented here. The format follows Keep a Changelog, and this project follows Semantic Versioning.
This is a pre-1.0 project: while the version stays below 1.0.0, a MINOR bump
may break the interface.
0.2.0 - 2026-08-22
Six proposals from docs/feature-expansion-ideas.md, merged as six
separate pull requests (#8-#13): a fifteenth suite, detached report
signatures, SARIF export, run history, recording retention, and a
checked-for-staleness SBOM with an as-yet-unexercised release workflow.
Minor, not patch: new CLI surface (sign, history, retire) and a
new suite are additions, not fixes, and this project's own SemVer
statement treats those as MINOR while below 1.0.0.
- A local gate.
make verifyruns the linter, the full suite under a branch-coverage floor, and the check that the published evidence page is what the committed evidence produces. A newqualityjob intests.ymlruns the same target in CI, so all three block rather than only being available. The existing version matrix is untouched and still proves what it proved before: that the suite passes on a bare interpreter with nothing installed. - Ruff, pinned at
>=0.15.0and green. Ruff's default rule set, which caught four unused test imports, now removed. F541 is ignored and the reason is inpyproject.toml:audit.pydigestssrc/plumbline/intoharness_source_sha256, so touching any source file invalidates the committed audit, the baseline,proof/matrix.mdand the published page, and that is not a price worth paying to delete seven redundantfprefixes. The wider portfolio rule set is not enabled: measured on 2026-08-17 it has 304 findings, 232 of them line length. Configuring it and excluding the findings would be the badge this repository exists to argue against, so the count is recorded as a gap in the README's conformance table instead. - A branch-coverage floor of 90%, enforced by
make verify. Measured at 94% oversrc/on 2026-08-17. Coverage wraps the sameunittest discoverrun; it does not change what executes. SECURITY.md,CONTRIBUTING.md,CODEOWNERS,CITATION.cff,.python-version,uv.lock,.pre-commit-config.yaml, and an ADR log seeded atdocs/adr/0000-record-architecture-decisions.md. The runtime is unchanged and still depends on nothing outside the standard library; ruff and coverage live in adevdependency group thatplumblinenever imports.security.yml: Semgrep SAST and a full-history TruffleHog secret scan, both on push and pull request rather than on a schedule or a button, plus gitleaks diff-scoped in pre-commit and a Dependabot configuration watching the action pins. Everyuses:in the repository is pinned to a 40-character SHA; Dependabot is what keeps those pins from going stale quietly.- A Standards Conformance table in the README, declaring all fifteen
standards with a state for each. Where a standard is not met the shortfall is
named and counted rather than described as planned. Three are recorded as
real gaps with measurements: mypy is not wired at all (27 errors by default,
172 under
--strict), 14 functions exceed a McCabe complexity of 10, and the Python floor is 3.11 against a portfolio floor of 3.12. - A fifteenth suite:
conversational_integrity. Every other suite readsresponse_for(item.id)— the final turn only — so a target that leaks a forbidden phrase, or drops a refusal, midway through a conversation and then produces a clean final answer was invisible to all of them: the wrong-paragraph problempassage_attributionexists for, for turns instead of paragraphs.Item.turnsand a response record'sturn_responsesare additive (docs/adr/0003-multi-turn-items-are-additive-not-a-new-bundle-format.md): an emptyturnslist is byte-identical to every item this harness has ever loaded, andFORMAT_VERSIONdoes not move. Opt-in twice over, the same waypassage_attributionis — an item declaresturnsand was additionally recorded per turn, or it is UNVERIFIABLE, never a pass. The demo bundle grows from 174 to 178 items: four hand-written multi-turn escalation probes, all clean, and a 21st defect-injection case plants a mid-conversation leak with a clean final turn to prove the suite catches it while every other suite —adversarialincluded — stays indifferent. plumbline sign/plumbline verify --key-file. A detached HMAC-SHA256 signature over a report's own seal, closing the gapverifyalready named: the seal is tamper evidence, not authentication. Deliberately shared-secret rather than public-key —docs/adr/0002-shared-secret-report-signatures.mdrecords why a from-scratch asymmetric implementation or a first runtime dependency were both worse choices than saying plainly what HMAC does and does not prove.--sarifonauditandgate. Projects failing and UNVERIFIABLE per-item records onto SARIF 2.1.0, so a consuming repository's CI can upload real findings for inline PR annotations instead of only a pass/fail exit code — no new measurement, the same report data rendered a second way.plumbline history append/history check. An append-only run history and a longitudinal trend view on top of the pairwise baseline comparison, which by design cannot see a regression smaller than one comparison's MDE accumulate across many runs. Reports one plain fact — a suite's score non-increasing across every step of the trailing N comparable runs, with at least one real decrease — no new interval, no p-value;docs/adr/0001-longitudinal-history-is-observation-not-inference.mdrecords why a real trend statistic was left out of scope. Off by default in CI;--fail-on-declineopts in.plumbline retire. A recording-retention and redaction lifecycle companion toplumbline record, closing the Data Governance gap the README already named: no data card, no stated retention position for recordings. Reusesprivacy.py's own PII screen against every recorded response; past a configured age, a bundle still carrying a flagged pattern is refused unless--redactrewrites it in place and reseals.docs/recordings-data-card.mdis the data card half of the same gap.sbom.cdx.json,tools/build_sbom.py,.github/workflows/release.yml. A CycloneDX SBOM generated frompyproject.toml, checked for staleness the same way the published evidence page is; a release workflow that verifies the SBOM, runs OpenSSF Scorecard, and keyless-signs the SBOM with Sigstore cosign over GitHub's own OIDC token. The workflow has not been exercised against a real tag — it says so at the top of the file, the same "not yet met, not asserted as done" posture the Standards Conformance table already takes on the two gaps this closes.
- A suite that stopped running was reported as nothing having changed.
Switching a suite off in a target's configuration is the one edit that
removes a check outright, and it was the one edit the baseline comparison's
summary line reported as clean:
baseline: no verdict changed and no score moved, exit 0. A suite that did not run has no score to move and no verdict to flip, so it appeared in none of the comparison's other terminal lines either — theremoved_suiteslist was computed, put in the JSON and printed in the markdown report, and dropped from the one line a build log shows. Reproduced against the bundled demo by disablingprivacyandrepresentational_harms, both floor 1.00 and both in the committed baseline: the gate printed the clean-bill sentence and returned 0. The summary now leads with the suites the two runs do not share and names them, the terminal lines carry aNOT RUN:row per dropped suite ahead of the flips and moves, and the clean-bill sentence is emitted only when the suite sets match. Verdicts and exit codes are unchanged: a dropped suite is still not a failure, it is now visible. See decision 34 inDESIGN.mdfor what is left open. - Four more fail-open defects, each one a
PASSa check had not earned. Reproduced onv0.1.0first, then fixed, then pinned by a test intests/test_fail_closed.py. Two of them are holes in the fix released in 0.1.0, which is why they are listed the same way.- Silence that gets past
.strip(). 0.1.0 stopped a target returning 174 empty responses from scoring a perfect1.0000on five suites. A target answering every item with"."— or"...", an emoji, a zero-width space, or a bare[src-id]— scored the identical1.0000on the identical five suites, with the gate returning PASS and exit 0. A response now counts only if something in it survives normalization, one predicate (suites.readable) that every suite reads,smokeincluded. Theunverifiableblock distinguishessilentfromunreadable. - A readable response that asserts nothing.
"the and of to"has content tokens removed by the stopword list, so the support measures answered 1.0 andgroundednessandcitation_accuracyscored it a perfect pass. Such items areno_claim: excluded, named, never scored. A response that is only citation markers scores zero incitation_validityandcitation_accuracyrather than 1.0 for pointing at a passage it took nothing from. - Silence nobody counted. Excluding an unreadable item instead of scoring
it 1.0 opened the quieter version of the same hole: a target that answered a
third of the demo corpus and returned nothing for the other 116 items passed
a gate enabling
groundedness,privacy,representational_harms,fairnessandcross_language— exit 0, five green rows, each annotated 116 unverifiable. The runner now asks the finished run whether any enabled suite scored those items zero, and refuses with the configuration-error code when none did, naming the items and the suites that would have counted them. This can turn a previously green run into exit 4 for a consumer whose evidence contains empty responses and whose suite selection excludessmoke,refusalandmultilingual; enabling one of them turns it into the measured FAIL it always was. - A run id could be borrowed. The report seal is a plain sha256, so an
editor can recompute it — the seal proves the copy in front of you is the
copy that was written, and nothing more. A report could therefore be edited
(a target name, a floor, a dataset hash), re-sealed, and still present the
run id of an earlier trusted run: the id that names its output directory and
that
plumbline baselinecopies into the committed bar assource_run_id.plumbline verifyandplumbline baselinenow recompute the run id from the inputs the report itself carries and refuse a report whose contents do not generate its id. The derivation is part of the file format from here on. - The reproducibility step in this repository's own CI could not fail on a
moved run id.
git diff --exit-code -- audits baselinescannot see a new, untracked run directory, so anything that moved the run id left the committed report in place and the step green. It checksgit status --porcelainas well now. gate/plumbline-gate.shasked CI not to bypass the pin. "CI must never setPLUMBLINE_SRC" was a sentence in a comment. The runner now refuses the bypass whenCIis set, which is every major provider.
- Silence that gets past
forbidden_claimson an item: strings the response must not assert.forbiddenstill means "must not appear", checked by substring, and remains the default. The new list excuses an occurrence when an explicit denial marker sits between the start of its clause and the occurrence, so a system that correctly answers "No, the deadline is not the 15th" stops failing a screen for "the deadline is the 15th". Read byrepresentational_harms,privacyandadversarial, tagged with the same coupling cause. The markers are a word list, so they are covered by the judge configuration hash; the model judge delegates the check to the lexical one on purpose. From a downstream consumer, where mapping "must not be asserted" onto "must not appear" failed four items for correctly denying a claim.plumbline verifystates its own boundary: tamper evidence, not authentication.- An empty string in
forbiddenorforbidden_claimsis a bundle error. A screen for nothing is not a screen. - A published evidence page,
site/index.html, generated bytools/build_site.pyfrom the committed report and proof — and by running the three refusals it renders, in a temporary copy of this repository's own evidence, aborting the build if any of them returns a different exit code or if the documented command stops reproducing the committed run id. The committed page must be exactly what today's evidence produces:tools/build_site.py --checkenforces that on every test run and before every deploy, andtests/test_site.pyproves the check can fail..github/workflows/pages.ymldeploys it; enabling Pages is a repository setting and is deliberately not done here.
- The judge configuration hash moves (the denial markers are part of it), so
the committed demo audit, its baseline and
proof/matrix.*are regenerated, and a comparison against a 0.1.0 baseline is refused as incomparable — which is the harness declining to subtract scores produced by different rules, and is correct of it.
0.1.0 - 2026-08-16
First tagged release. The harness was usable before this tag; what the tag adds is a fixed point a consumer can name, and a statement of what changed underneath them.
- Nine fail-open defects, each of which could produce a
PASSthat had not been checked. Every one was reproduced onmainfirst, then fixed, then pinned by a test intests/test_fail_closed.py. A verdict produced before this release can be a vacuous pass and should not be relied on.- A target returning entirely blank responses scored a perfect
1.0000on five suites —groundedness,privacy,representational_harms,fairnessandcross_language— andplumbline gatereturned PASS, exit 0 on those alone. Silence satisfied every check phrased as the absence of something bad. Suites now split: those asking whether the target behaved correctly score silence zero (refusal,adversarial), and those asking whether something bad is missing report it UNVERIFIABLE — excluded, named in the coverage line, never a pass. - Bundle integrity covered only the top level of a bundle:
hashed_filesusediterdir, so evidence in a subdirectory was sealed by nothing. The walk is recursive now, keyed by POSIX path relative to the bundle root, and it refuses symbolic links. - Files outside the sealed inventory were read:
bundle_dir / filenameresolved../outside.jsonland/etc/passwdto a clean PASS. A declared file must now be relative, resolve inside the bundle, and be covered by a checksum. - Aggregation was
FAIL if any(v == FAIL) else PASS, so"SKIP",Noneor a typo landed on the pass branch. It is nowall(v == PASS), and results are validated first. - A crash exited 1, the code reserved for a measured failure. Crashes now exit 5. Every non-zero code still blocks.
floor = 0.0was accepted, and every score clears it. A check that cannot fail is now a configuration error.- A reference answer of
" "scored 1.0 against an empty response. - The run id did not include the target, so two systems audited against the same evidence, judge and floors collided and the second run silently overwrote the first.
bundle_digestjoined"<name>=<hex>\n"lines, so a filename containing a newline could serialize as two files. Such names, and malformed digests in a hand-edited manifest, are refused at both ends. Existing bundle hashes are unaffected.- Nothing bound provenance to the report body, so a FAIL could be edited into
a PASS with the run id, dataset hash and judge hash all still valid.
Reports now carry
report_sha256over their own canonical JSON;plumbline verifychecks it, andplumbline baselinerefuses to distil a report that fails it.
- A target returning entirely blank responses scored a perfect
- Fourteen scoring suites:
smoke,accuracy,refusal,cross_language,multilingual,groundedness,citation_validity,citation_accuracy,passage_attribution,adversarial,fairness,representational_harms,privacy, andaccessibility. passage_attribution, the fourteenth suite, from a consumer's bug report: an answer can be grounded, cited, in the right language and not a refusal, and still be composed from the wrong paragraph of the right document.- Per-suite confidence intervals and minimum detectable effect on every suite.
- Baseline regression comparison that refuses to compare incomparable runs.
- A pinned fail-closed CI gate for consuming repositories:
gate/plumbline-gate.shand aplumbline.pinfile. The runner requires an exact 40-character commit hash and rejects a branch or a tag, because a moving ref means a green gate today can quietly mean something else tomorrow. - Live-target recording over HTTP or against a local program, and an optional model judge — none of which the gate can reach.
- A defect-injection matrix (
proof/matrix.md): for each suite, a planted defect that suite exists to catch, run through the real audit path. 20 of 20 cases held, and every enabled suite has been observed failing. - Provenance on every report: run id, harness version,
harness_source_sha256over the installed package, seed, dataset hash, and judge configuration hash.
- Continuous integration is enabled and green on CPython 3.11, 3.12, 3.13 and 3.14. It was previously present but inert.