Repository navigation
v3.0.0 - Evidence that could not reproduce itself, and a hash that hid nothing
The evidence subsystem shipped in v1.53.0 and, until this week, nobody had composed a pack outside its own tests. The first real use — a verify safe-backfill --after-write against a live PostgreSQL — came back not_verified on data that was correct, and the audit that followed found three more defects of the same kind: an evidence pack whose digest covered a random UUID, so the same claims never produced the same pack twice; a receipt "fingerprint" that was an unsalted SHA-256 over eight possible values; and a blacklist comparison with no identifier boundaries, so a protected column named id refused any claim containing the word "identifier". Fixing them changes both published formats, which is what makes this a major release: packs written by 2.x will fail validation under 3.0.0, and observation.fingerprint no longer exists. The reversal that authorized the repairs — known defects get fixed whether or not anyone is using the code — is recorded in docs/adr/0012-known-defects-get-fixed-whether-or-not-anyone-is-using-the-code.md, superseding ADR 0011.
Changed
-
BREAKING: an evidence pack's digest now covers only its content, so equivalent input produces the same pack. The digest was taken over the whole pack including
id, which wasevp_${randomUUID()}, and a millisecondcreatedAt— so composing the same claims twice yielded two unrelated digests and cross-run comparison was not merely hard but undefined. The digest now coversversion/subject/claims;idis derived from its first 32 characters, so equivalent input yields an identical pack down to the identifier, andparsechecks that the two agree.createdAtsits outside the digest and is documented, in the type and inreference.md, as the one field that can be restamped without breaking verification — leaving that unsaid would be selling tamper-evidence that isn't.canonicalizeWithoutDigestwasJSON.stringify, so "canonical" rested on the build and parse paths hand-maintaining the same key insertion order; it is now a real canonicalization that sorts keys recursively. The format is changed in place with no v2 fallback and no compatibility layer: existing packs fail validation, which is correct, because their digests were computed under rules that no longer hold (#116). -
BREAKING: a receipt states its observation instead of hashing it.
observation.fingerprintwas an unsalted SHA-256 over a preimage space of eight values forverify(fourVerificationStatus× anartifactPersistedboolean) and 2^(n+1) for anassertwith n checks, in a fixed public serialization — a dictionary attack measured in milliseconds. Everything it covered already appeared in plaintext inoutcome, so the only thing it protected was the per-check pass bit pattern, and the only reader it stopped was an honest one; being deterministic, it also let receipts be grouped by result across files, which is the property the old test suite was asserting.verifynow records{ kind: 'verify-outcome', status }andassertrecords{ kind: 'assert-verdict', checksPassed, checksTotal }— counts, not positions, so which check failed still does not leave the receipt, and less leaks than before, since anyone willing to invert the old hash recovered the full bit pattern.parseObservationvalidates the field shape per operation and rejectschecksPassed > checksTotal(#118). -
evidenceno longer means three incompatible things.VerificationStatus(verified/not_verified/indeterminate/blocked),EvidenceItem.status(ok/no-data/skipped/error/timeout) andWorkloadEvidence.state(available/absent/invalid/unavailable) are a verdict on a subject, whether a diagnostic ran, and whether a source file is usable — three vocabularies that cannot be mapped onto each other, all filed under one word, all string unions the type system cannot keep apart.EvidenceItemis nowReportFindingandWorkloadEvidenceisWorkloadSource(withWorkloadEvidenceState,LoadWorkloadEvidenceOptions,loadWorkloadEvidenceand the command layer'sloadObservedWorkloadEvidencerenamed to match). The values are unchanged andVerificationStatusis untouched, being part of a published JSON contract.CONTEXT.mdgains an "Outcome vocabularies" section stating that they do not map — but the rename is what stops the mistake at the call site, since nobody readsCONTEXT.mdwhile writing a comparison (#114).
Added
scripts/check-plan-acceptance.tsmakes an acceptance criterion say whether anything proved it. The 2026-08-08 backlog's eight tickets shipped with v1.53.0 and still readStatus: Proposed, and two of their criteria described behavior the code structurally could not produce. Nothing caught either, because plan documents are prose and prose drifts silently. Every numbered acceptance criterion underdocs/plans/*.mdmust now end in— covered by:(naming a test file that exists),— unverified:(admitting nothing proves it), or— known deviation:(deliberately unmet, with a reason). The gate forces disclosure, not coverage — marking all thirty criteriaunverifiedwould pass — because a gate demanding real tests gets nagged into a rubber stamp, while the count stays visible in review, and eighteen of thirty unverified is its own pressure. The one substantive check is that a cited test file exists, that being the half that is cheap to check and the failure mode the gate exists to stop; prose criteria with no numbered list count as a violation, so reformatting cannot route around it.docs/plans/done/is not scanned, since rewriting closed plans to suit a later convention destroys the record this protects.PLAN_ACCEPTANCE_EXEMPTIONSis a ratchet shaped aftercheck-core-no-stdout.ts: it may only shrink, and a contract test fails when an entry stops being needed (#113).
Removed
- The
coveragefield is gone from the evidence pack rather than made writable. Both writers hardcoded an empty gap list and the parser rejected a non-empty one, so the ticket's promise that an expired reference produces a coverage gap could never fire. A pack is immutable and a reference expires after composition, so the value could not be written back even in principle;evidence validatealready reports staleness, and that is where a reader can act on it. A field that can never change says nothing (#116).
Fixed
-
value == countwas never true on PostgreSQL, soverify --after-writecould not reportverifiedthere.firstScalarreturned the driver's value as-is andcompareused===, but pg returnsbigint/numeric/int8as strings, so"0" === 0was permanently false — and a read-back after a write is almost always a count. The asymmetry is what made it hard to see:>and<went through JS coercion and passed, so on the same columnvalue > 5worked whilevalue == 6did not, with the output cheerfully printingexpected: "value == 6",actual: "6",pass: false. Found by dogfooding a real backfill on PostgreSQL 16, where six rows were correctly updated and the assertion still said no (#115). -
Blacklist matching in evidence content is bounded to whole identifiers, and its refusal says where. Blocked terms were compared with
includes()— no boundary, no minimum length — so a protected column namedidrefused any claim containing "identifier", "considered" or "valid", and the message was one line readingevidence content contains a blocked identifier, naming neither the field nor what it hit. A term now matches only when neither neighbour is a letter, digit or underscore, soidstill hitsorders.idand a bareidbut notidentifier, and regex metacharacters in a term are escaped, soa.cno longer matchesabc. The keys ofblacklist.columns— table names — were never collected into the term list at all, a silent hole in the other direction, and are now included. The error names the offending field (subject.kind,claim 2 text) and still does not name the term, since printing a protected identifier into a message the author may paste elsewhere is the thing the blacklist exists to prevent; claims are located by ordinal because the id may itself be the blocked string. Two limits are documented rather than papered over:secret_customerwritten as "secret customer" still gets through, which identifier matching against free prose cannot honestly promise to catch, and reference fields (auditcommand, receiptpath) are not checked at all yet (#117). -
The Elasticsearch integration suite had never asserted anything, on any machine. It reported "container not running on port 9201" against a healthy container answering
200: Elasticsearch'sGET /carries no CORS headers, happy-dom'sfetchdrops a header-less response under the same-origin policy,beforeAllswallowed the error and every test returned early. It is the only adapter over HTTP, so it was the only casualty. Removing the global preload was tried and reverted —tests/integration/ui-render-smoke.test.tsxdepends on it, and a per-file import leaks across the files Bun runs in one process, making the outcome depend on file order.setup-happy-dom.tsnow saves the runtimefetchbeforeGlobalRegistrator.register()and restores it after, exportingruntimeFetchso a test can pin the contract. Onlyfetchis restored: happy-dom'swindow/document/Elementhave no runtime counterpart to shadow, and no adapter touchesRequestorXMLHttpRequest(#109, #110). -
A negative test asserting that extra fields are rejected no longer breaks
typecheck:tests. The object deliberately carrying a field the validator must refuse was written against a type that does not admit it, whichbun testnever sees and CI'stscstep fails on across all ten matrix jobs (#118).
Documentation
-
ADR 0005's
deferreddescribed the feature, not the decision. Of ten ADRs it was the only one notaccepted, so every inventory picked it back up as an open question — while its content is a settled fail-closed policy with a reopen checklist and a falsification condition already attached. What is deferred is SQD-05 / SQD-06. It is nowaccepted, with a paragraph separating "the policy is settled" from "provider generation is authorized", the latter having not happened — without it, a reader seeingremain deferredin the title over anacceptedstatus would plausibly "fix" the status (#111). -
The 2026-08-08 evidence backlog now matches what shipped. Eight tickets read
Status: Proposedunder a spec header saying no implementation was authorized, a week after all of it went out in v1.53.0. Checking all thirty acceptance criteria against the test files found eleven genuinely asserted, eighteen unproven — mostly asserted halfway — and one structurally impossible. Tickets are marked Delivered with their known deviations and unverified criteria listed per line, andCONTEXT.mdgainsKnown deviation(deliberately unmet) andUnverified(nobody proved it) as distinct terms, because merging them dilutes the first into "everything imperfect" (#112).