Skip to content

v3.0.0 - Evidence that could not reproduce itself, and a hash that hid nothing

Choose a tag to compare

@CarlLee1983 CarlLee1983 released this 17 Aug 01:15
· 383 commits to main since this release
d366ad8

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 was evp_${randomUUID()}, and a millisecond createdAt — so composing the same claims twice yielded two unrelated digests and cross-run comparison was not merely hard but undefined. The digest now covers version / subject / claims; id is derived from its first 32 characters, so equivalent input yields an identical pack down to the identifier, and parse checks that the two agree. createdAt sits outside the digest and is documented, in the type and in reference.md, as the one field that can be restamped without breaking verification — leaving that unsaid would be selling tamper-evidence that isn't. canonicalizeWithoutDigest was JSON.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.fingerprint was an unsalted SHA-256 over a preimage space of eight values for verify (four VerificationStatus × an artifactPersisted boolean) and 2^(n+1) for an assert with n checks, in a fixed public serialization — a dictionary attack measured in milliseconds. Everything it covered already appeared in plaintext in outcome, 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. verify now records { kind: 'verify-outcome', status } and assert records { 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. parseObservation validates the field shape per operation and rejects checksPassed > checksTotal (#118).

  • evidence no longer means three incompatible things. VerificationStatus (verified / not_verified / indeterminate / blocked), EvidenceItem.status (ok / no-data / skipped / error / timeout) and WorkloadEvidence.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. EvidenceItem is now ReportFinding and WorkloadEvidence is WorkloadSource (with WorkloadEvidenceState, LoadWorkloadEvidenceOptions, loadWorkloadEvidence and the command layer's loadObservedWorkloadEvidence renamed to match). The values are unchanged and VerificationStatus is untouched, being part of a published JSON contract. CONTEXT.md gains an "Outcome vocabularies" section stating that they do not map — but the rename is what stops the mistake at the call site, since nobody reads CONTEXT.md while writing a comparison (#114).

Added

  • scripts/check-plan-acceptance.ts makes an acceptance criterion say whether anything proved it. The 2026-08-08 backlog's eight tickets shipped with v1.53.0 and still read Status: 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 under docs/plans/*.md must 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 criteria unverified would 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_EXEMPTIONS is a ratchet shaped after check-core-no-stdout.ts: it may only shrink, and a contract test fails when an entry stops being needed (#113).

Removed

  • The coverage field 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 validate already reports staleness, and that is where a reader can act on it. A field that can never change says nothing (#116).

Fixed

  • value == count was never true on PostgreSQL, so verify --after-write could not report verified there. firstScalar returned the driver's value as-is and compare used ===, but pg returns bigint / numeric / int8 as strings, so "0" === 0 was 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 column value > 5 worked while value == 6 did not, with the output cheerfully printing expected: "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 named id refused any claim containing "identifier", "considered" or "valid", and the message was one line reading evidence 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, so id still hits orders.id and a bare id but not identifier, and regex metacharacters in a term are escaped, so a.c no longer matches abc. The keys of blacklist.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_customer written as "secret customer" still gets through, which identifier matching against free prose cannot honestly promise to catch, and reference fields (audit command, receipt path) 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's GET / carries no CORS headers, happy-dom's fetch drops a header-less response under the same-origin policy, beforeAll swallowed 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.tsx depends 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.ts now saves the runtime fetch before GlobalRegistrator.register() and restores it after, exporting runtimeFetch so a test can pin the contract. Only fetch is restored: happy-dom's window / document / Element have no runtime counterpart to shadow, and no adapter touches Request or XMLHttpRequest (#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, which bun test never sees and CI's tsc step fails on across all ten matrix jobs (#118).

Documentation

  • ADR 0005's deferred described the feature, not the decision. Of ten ADRs it was the only one not accepted, 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 now accepted, with a paragraph separating "the policy is settled" from "provider generation is authorized", the latter having not happened — without it, a reader seeing remain deferred in the title over an accepted status would plausibly "fix" the status (#111).

  • The 2026-08-08 evidence backlog now matches what shipped. Eight tickets read Status: Proposed under 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, and CONTEXT.md gains Known deviation (deliberately unmet) and Unverified (nobody proved it) as distinct terms, because merging them dilutes the first into "everything imperfect" (#112).