Stores attributable, content-addressed process observations immutably, and judges them purely against a pinned expectation to issue a local-fixture Verdict.
Status: "Accepted for trusted local fixtures" (build review): immutable content identity, corruption, concurrent publication, crash durability, bounded special-file reads and strict provenance. Receipts: build receipt (independent evidence review by gpt-6-astra at xhigh; module commit d832c69; its final_sources hashes for the four files below matched the current files on 28 September 2026) and review receipt (the docs clarity revision, document assurance only and not a code review of this module; its final_sources hash for docs/design.md also matched on that date, and that glossary defines the Evidence and Verdict terms used here). Local development only: no release, production or customer-value claim, and all 27 epics remain open.
Source: src/evidence.ts, src/assurance.ts. Tests: test/evidence.test.ts (12 tests), test/assurance.test.ts (13 tests).
Intelligence: none — A Verdict is a pure fold over trusted collector observations; a model's opinion is not evidence, only a producer 'passed' flag by another name.
What it hides
- Record format and identity. One self-contained file per observation:
sf-evidence/1\n<metadata byte length>\n<canonical metadata JSON><observed bytes>. Its name is the SHA-256 hex of the whole record. Canonical JSON has sorted keys and no whitespace, so key order never changes identity. - Atomic, never-replacing publication. Each record is created exclusively as
<root>/tmp/<id>.<uuid>.tmpwith mode0o444, written and fsynced, then hard-linked to<root>/objects/<id>, andobjects/is fsynced. The link never replaces an existing name. - Fail-closed reads.
getre-derives everything from the bytes on disk: id format, file type, size, digest, header, length line, strict metadata and canonical re-encoding. - Verdict rules. A three-valued fold (Verified / Failed / Inconclusive) over per-check outcomes, with a provenance qualification that callers cannot satisfy with hand-built objects.
Public interface
src/evidence.ts (all fields readonly; unmarked fields are string):
MAX_EVIDENCE_BYTES = 16 * 1024 * 1024(observed bytes) andMAX_METADATA_BYTES = 16 * 1024(canonical metadata JSON). Unexported limits: text fields 1–256 printable ASCII characters;collectorErrora well-formed Unicode string of 1–1024 UTF-16 code units (length); a stored object at most 16,793,620 bytes (MAX_RECORD_BYTES, evidence.ts:32).interface EvidencePins { candidateDigest; scenarioRevision; recipeRevision; runId; attemptId; epoch: number; environment; collector }.PIN_FIELDSis the frozen tuple of those eight names in that order (evidence.ts:56).interface ProcessObservation { exitCode: number | null; signal: string | null; timedOut: boolean; collectorError: string | null }. It deliberately has no "passed" flag.interface EvidenceMetadata { pins: EvidencePins; check: string; collectedAt: string; observation: ProcessObservation }interface EvidenceRecord { id: string; metadata: EvidenceMetadata; bytes: Uint8Array }type EvidenceErrorCode = "invalid-input" | "invalid-id" | "missing" | "corrupt" | "conflict"andclass EvidenceError extends Error { readonly code: EvidenceErrorCode; constructor(code, message) }withname"EvidenceError".class EvidenceStore(evidence.ts:109):constructor(root: string): resolves the path and does no I/O; a non-string or empty root throwsinvalid-input.put(metadata: EvidenceMetadata, bytes: Uint8Array): Promise<string>: returns the 64-hex id.get(id: string): Promise<EvidenceRecord>
isStoreRecord(value: unknown): value is EvidenceRecord: true only for objects returned byEvidenceStore.getin this process.parsePins(value: unknown): EvidencePinsandparseCheckName(value: unknown): string: validate and throwEvidenceErrorinvalid-input;parsePinsreturns a frozen copy.
src/assurance.ts (same conventions):
MAX_REQUIRED_CHECKS = 256,MAX_EVIDENCE_REFERENCES = 1024.interface Expectation { pins: EvidencePins; requiredChecks: readonly string[]; expectedExitCode: number }type Gathered = { ref; record: EvidenceRecord } | { ref; fault: EvidenceErrorCode; reason: string }type CheckOutcome = "passed" | "failed" | "inconclusive";interface CheckJudgement { check; outcome: CheckOutcome; evidence: readonly string[]; reason };interface RejectedEvidence { ref; reason }interface Verdict { result: "Verified" | "Failed" | "Inconclusive"; scope: "local-fixture"; expectation: Expectation; checks: readonly CheckJudgement[]; rejected: readonly RejectedEvidence[]; reason: string }gatherEvidence(store: EvidenceStore, refs: readonly string[]): Promise<readonly Gathered[]>: reads the refs sequentially throughstore.get(assurance.ts:66).judge(expectation: Expectation, gathered: readonly Gathered[]): Verdict: pure (assurance.ts:83).
Invariants and guarantees
- The id is
sha256(record)and the record is canonical: identical metadata and bytes give an identical id in any store, whatever the key order (test "identity is the SHA-256…"). putvalidates and copies its inputs before its firstawait, so later caller mutation cannot change what is stored (test "the store copies inputs before its first await").- An existing name is never replaced.
EEXISTwith identical bytes returns the same id; different or unreadable existing bytes throwconflictand are left untouched (tests "duplicate and concurrent…", "conflicting contents…"). - Identical puts, concurrent or repeated, converge on one object and, in the tests, leave
tmp/empty. Temp-file cleanup is best effort:puttries to unlink its own temp file and ignores any error from doing so. - An id acknowledged by
putsurvives the writer being killed with SIGKILL. Readers never consulttmp/, and leftovers there are never readable as objects (tests "a process killed mid-write…", "missing evidence and crash leftovers under tmp…"). getchecks the id against/^[0-9a-f]{64}$/before any filesystem access, so no path traversal is possible (test "ids that are not plain lowercase…").getopens withO_RDONLY | O_NOFOLLOW | O_NONBLOCKand requires a regular file of at mostMAX_RECORD_BYTES. A symbolic link, directory or FIFO iscorrupt; the FIFO case is tested to fail promptly rather than block the open (test "a FIFO in place of an object…").getfails closed on a digest mismatch, a wrong header, a missing, malformed or leading-zero length line, truncated metadata, invalid metadata, oversized bytes, a non-canonical encoding, or growth afterstat. A valid record placed under another record's name iscorrupt(tests "corrupt, truncated…", "content matching its name but not in canonical record form…").- Returned records, their metadata, pins and observation are frozen.
bytesis a fresh copy on everyget, so mutating it never affects the store. - Metadata is strict.
metadata,pinsandobservationmust be plain objects that own every listed field and have no other enumerable string-keyed field, so an extra field such aspassedis rejected; non-enumerable and symbol-keyed properties are ignored. Text is 1–256 printable ASCII characters with no surrounding spaces.candidateDigestissha256:plus 64 lowercase hex characters;epochis a non-negative safe integer;collectedAtmust matchYYYY-MM-DDTHH:MM:SS.mmmZand round-trip exactly throughtoISOString;exitCodeis null or a safe integer;signalis null or/^SIG[A-Z0-9]{1,16}$/;timedOutis a boolean;collectorErroris null or a well-formed string of 1–1024 UTF-16 code units. Invalid input writes nothing:objects/is not even created (test "oversized, malformed or self-asserted metadata…"). judgeis pure: repeated calls on the same inputs are deep-equal. Its only process-local dependency is theisStoreRecordWeakSet.- A gathered entry qualifies only if it is an object without an own
faultproperty whoserecordpassesisStoreRecord, hasrecord.id === ref, matches all eightPIN_FIELDSexactly (!==) and names a required check. Anything else goes torejectedwith a reason (an entry whoserefis not a string is reported as<invalid reference>). Hand-built, spread-copied, relabelled or JSON round-tripped records therefore never qualify (test "hand-built or relabelled records are not proof"). - Each qualifying observation is classified in this order:
collectorErrorset →inconclusive;timedOut→inconclusive; anysignal→inconclusive, because a signal may come from the environment;exitCodenull →inconclusive;exitCode !== expectedExitCode→failed; otherwisepassed. A check with no qualifying observation isinconclusivewith reason"no qualifying evidence". With several observations of one check,failedwins overinconclusive, which wins overpassed, and the reason is prefixed with the decisive ref; a repeated ref counts once. - The Verdict is
Failedif any required check failed, even when others are missing or rejected; otherwiseInconclusiveif any check is inconclusive or any supplied reference was rejected; otherwiseVerified. Verified therefore needs every supplied reference to qualify. - The Verdict and all its parts are frozen, and its
expectationis the validated, frozen copy.checksfollowsrequiredChecksorder andrejectedfollowsgatheredorder.reasonstrings are deterministic.
Failure semantics
EvidenceErrorcodes:invalid-input: an invalid root, metadata or bytes, bytes overMAX_EVIDENCE_BYTES, or canonical metadata overMAX_METADATA_BYTES.invalid-id: a malformed id passed toget.missing:ENOENT.corrupt: any integrity or format fault, including a symbolic link (ELOOP) or a non-regular file.conflict: the name exists with different or unreadable contents.
- Other filesystem errors, such as
EACCES,ENOSPCandEIO, propagate raw fromputandgetand are notEvidenceErrors, with two exceptions: an existing publication target that cannot be read becomesconflict, and any error from unlinking the temp file is ignored. putis idempotent and safe to retry after any failure, including a lost acknowledgement: a retry re-links or finds the identical object and fsyncsobjects/again. Directory preparation is memoised and reset on failure.gatherEvidenceturns onlyEvidenceErrors into{ ref, fault, reason }entries (a non-string ref becomesfault: "invalid-id"withref: String(ref)); these are inputs tojudge, not errors. Any other error rejects the whole call. A non-array or more than 1024 refs rejects withTypeError.judgethrowsTypeError"invalid expectation: …"for an invalid expectation. The expectation must be a non-null object whose sorted own enumerable string keys, joined with commas, equalexpectedExitCode,pins,requiredChecks(assurance.ts:138); this comparison does not enforce an exact key set, since comma-containing keys can collide and the three field values can be inherited. The values are then validated: 1–256 unique valid check names with no array holes, a safe-integer exit code and valid pins.judgealso throws aTypeErrorwhengatheredis not an array or has more than 1024 entries. Ordinary evidence problems never throw: they land inrejected, and the Verdict is then at best Inconclusive.judgeis not an exception-isolating boundary for arbitrary objects, though: a caller-supplied getter, proxy ortoStringthat throws propagates its own error.- Unknown is never a pass and never a known failure. A timeout, signal or collector error makes that observation
inconclusive; each check then folds its qualifying observations withfailed>inconclusive>passed, so a timed-out rerun beside a failed one still givesfailed. A missing or unreadable reference is rejected and touches no check's outcome. Any failed required check makes the VerdictFailed; otherwise an inconclusive check or a rejected reference makes itInconclusive.
Trust scope
Established, for trusted local fixtures on a POSIX local filesystem with hard links; tested on macOS with Node 26.8.1:
- detection of corruption, truncation, misplaced objects, special files and overwrite attempts;
- never-replacing publication that converges under concurrent writers;
- acknowledged objects that survive a process kill;
- strict provenance pins;
- Verdicts that producer flags and hand-built records cannot forge.
Not established:
- It is not a sandbox, an access-control boundary or a cryptographic authority. Anything that can write the store directory can write well-formed evidence, and
collectorattributes an observation without authenticating it (evidence.ts:3). - Power-loss durability. Process-crash tests do not establish it.
- Completeness.
judgecannot detect deliberately omitted references; the composition must own the full reference set and persist the Verdict it issues (local development). - Anything beyond
scope: "local-fixture". A Verdict is never a production, release, public-assurance or customer-value claim, and it is only as trustworthy as the collector and the store. - Time.
collectedAtis caller-supplied and is not checked against a clock. - Housekeeping. There is no delete, retention or garbage-collection API. Each
putonly tries to unlink its own temp file; crash leftovers undertmp/stay until removed by hand, which is safe only while noputis running.
Composition
- Depends on:
evidence.tsuses onlynode:crypto,node:fs,node:fs/promisesandnode:path.assurance.tsimports onlyevidence.ts. src/local-worker.ts:LocalFixtureWorkerrequires anEvidenceStoreinstance.run()andrunGuarded()callstore.put(...)once for the process they observed: anobservedresult references that one record, and a failedputgivesstorage-failedinrun().runGuarded()skips storage when ownership ended after the process exited, and re-checks ownership after theputsucceeds or fails: lost ownership wins and returnsownership-lost, withevidenceIdset only if the record was stored;storage-failedis returned only while ownership still holds.parseRequestusesparsePinsandparseCheckName, requirespins.collectorto equalLOCAL_FIXTURE_COLLECTORinrun()orGUARDED_FIXTURE_COLLECTORinrunGuarded(), and rewrapsEvidenceErrorasTypeError. The worker observes and never judges.src/factory-support.ts:observationProblem(store, expectation, maxOutputBytes, result)requires exactly one evidence reference, re-gathers and re-judges it, compares the canonical JSON of the new Verdict with the stored one, and checks the termination fields against the stored observation.src/guarded-verification.ts:expectationFor(...)builds one-check expectations (requiredChecks: [recipe.check],expectedExitCode: recipe.expectedExitCode);verdictOf(...)judges only when the source digests before and after the run equal the pinned digest, and otherwise returnsnull;recheck(...)re-judgesattempt.evidencefor current validity;judgeReport(...)callsobservationProblem;dispatchOne(...)passesctx.storeto the worker.src/local-factory.ts:withState(...)constructsnew EvidenceStore(<state-dir>/evidence);legacyRecheck(...)re-judges and compares canonically;legacyReportProblem(...)callsobservationProblem.src/repository-assurance.ts(Repository Assurance): importsPIN_FIELDS,isStoreRecordand theEvidencePins,EvidenceRecordandProcessObservationtypes fromevidence.ts, and only types fromassurance.ts. It never callsjudge, and neither judge accepts the other's records. The Product gate and delivery records importassurance.ts'sGatheredtype only.src/repository-collector.ts(Repository collector, step 1, increment 3: accepted, not composed): requires anEvidenceStoreinstance and callsstore.put(...)once per repository check withsf-repository-observation/1bytes; a failedputisstorage-failedand stops the collection. It never callsjudgeorget.- Tests elsewhere:
test/helpers/factory-harness.tsmonkey-patchesEvidenceStore.prototype.putfor fault injection;test/confined-executor-boundaries.test.tsuses a real store as a protected target.
Changing it safely
- Run
node --test test/evidence.test.ts test/assurance.test.tsandnpm run typecheck, then the dependent suites:test/local-worker*.test.ts,test/guarded-*.test.ts,test/local-factory*.test.ts,test/fixture-manifest.test.tsandtest/confined-executor-boundaries.test.ts.npm run checkruns everything. - Editing
evidence.tschanges the guarded fixture manifest digest, because it is listed inGUARDED_FIXTURE_SOURCESinsrc/fixture-manifest.ts; Runs pinned to the old digest report source drift.assurance.tsis not in that list. - Record format or canonicalisation changes break existing records. An id is the hash of the bytes as stored, so existing records keep their ids, but
getre-encodes and compares, so it rejects them ascorruptunless thesf-evidence/1decoding and canonical validation are kept exactly; the same content would also get a different id under the new encoding. Add a new format version rather than changing this one. Never relax acorruptcheck. - Verdict shape, ordering and
reasonwording are compared canonically against stored Verdicts byobservationProblem,recheckandlegacyRecheck. Changing them makes earlier issued Verdicts fail re-validation. - Keep
put's signature: the factory harness patches it. - Reviewers check that no path lets a non-store record, a
passedflag, a pin mismatch, a signal, a timeout or a missing check produce Verified; that failures stayFailedand unknowns stayInconclusive; and that validation still happens before the firstawait. - After a change, get a fresh independent review, issue a new successor receipt (the build receipt and its hashes stay unchanged), and update the build review row. The rows in module studies and local development must stay true.
