The evidence-proof layer

Agent-written text is never itself evidence.

An agent may say what it concluded and where it read it. It may not supply the reading. Every excerpt that reaches a report, an evidence table, or a wiki concept is re-sliced from an immutable snapshot by a script — twice, once at assembly and once at publish. This is the layer that turns "hopefully caught in review" into "mechanically rejected".

The Problem It Solves

An agent writing "the trial reported a 41% reduction (Smith 2024, p. 7)" has produced a sentence, not a proof. The quotation marks are free. The page number is free. Both survive review because nobody re-opens the PDF.

So the kernel takes the reading away from the agent. Of everything in an evidence record, exactly three things are agent-contributed: a claim label, a source_id, and a start/end pair. URL, title, access level, PMID/DOI/PMCID, excerpt text, section, and page are all derived from the snapshot.

Failure modeCaught byCode
Fabricated or subtly re-worded quoteExcerpt re-slice mismatchEXCERPT_MISMATCH
Invented sourceUnknown source_idUNKNOWN_SOURCE
Stale evidence recycled from an old run or a wiki noteThe fresh-fetch ruleNO_FRESH_FETCH
Tampered snapshotHash recomputationSNAPSHOT_HASH_MISMATCH

This layer does not replace acquisition. eutils.py, fulltext.py, and library.py keep fetching; whenever they obtain usable text they register it into the snapshot store, and from that moment it is addressable by offset.

Snapshots

One file per snapshot at <run>/sources/src-<sha256>.json, written by source.py on top of store.py. Six guarantees:

GuaranteeMechanism
Write-onceCreated with O_EXCL. There is no update path. A correction is a new snapshot with a new id; the old one stays.
Content-addressed identitysource_id = "src-" + sha256_hex(utf8(url) + NUL + utf8(text)). Same URL and text ⇒ same id ⇒ same file, so re-fetching unchanged text is a no-op. Different text ⇒ different id, always.
Independent body hashcontent_hash = "sha256:" + sha256_hex(utf8(text)), over the text alone, so a body edit is detectable without reference to the URL.
Verified on every readsource.py read/spans, assemble.py, verify.py, and okf.py promote all recompute both digests and compare. A mismatch is a hard error, never a warning.
Stable offsetstext is stored decoded-UTF-8 and never normalized — no NFC/NFD pass, no whitespace collapsing, no newline rewriting, on write or read. Offsets under an existing claim never drift.
No PDF duplicationPDFs stay in the shared library. The snapshot records asset = {path, sha256, bytes} with a root-relative path. There is no per-run assets directory.

Two closed enums describe every snapshot. access says what the text can support; origin says where it came from:

access:  full_text | abstract | preprint | guideline | web
origin:  pubmed | pmc | europepmc | unpaywall | oa-pdf | user-supplied-pdf | web

Where a snapshot and a record disagree, the snapshot wins and the artifact is rejected — never silently corrected, because a silent correction hides the fact that an agent asserted something false.

Source identity is not evidence identity. One study (evidence_id) may have several snapshots: a PubMed abstract, a PMC full text, a user-supplied PDF. The relation is many-to-one. A span must name a snapshot registered for its own evidence_id, and the snapshot's paper triple must agree with that evidence_id.

The Event Log And The Fresh-Fetch Rule

<run>/events.jsonl, append-only, four event types:

TypeMeans
fetchA real retrieval performed in this run
local_pdfA hash-checked user-supplied PDF
registerText obtained elsewhere in the pipeline, folded into the store without a new retrieval
readA bounded window read by a subagent — audit only

A supported verdict counts only if every source backing the claim has a fresh retrieval logged in this run.

Concretely, a source is fresh iff events.jsonl holds a line with that source_id, a type of fetch or local_pdf, fresh: true, an at timestamp at or after the run's created_at, and a sha256 that still matches when recomputed now.

What deliberately does not count: a register event, a read event, a cache-only fetch, an event copied in from another run, a search snippet, an abstract already sitting in corpus.jsonl, or an existing wiki note. This is why a reused extraction is reported as non-fresh by C-FRESH-FETCH while still passing C-SNAPSHOT and C-SPAN: the content is locally present and hash-verified, but it was not retrieved here.

The user-supplied-PDF exception

For origin: "user-supplied-pdf" there is nothing to re-fetch — the paper arrived as bytes, and there may be no lawful URL to retrieve it from. The immutable, hash-checked local file is the fresh source: such a snapshot is fresh iff a local_pdf event exists for it in this run and the file at asset.path still hashes to asset.sha256. A missing file, a changed file, or a local_pdf event on a snapshot with asset: null fails with ASSET_HASH_MISMATCH.

This exception exists because a user legitimately handing over a PDF is a real acquisition route — not because retrieval rules are negotiable. No part of it permits credentials, proxies, or paywall circumvention.

The Span Contract

{
  "claim": "The trial reported lower exacerbation rates.",
  "evidence_id": "pmid:12345678",
  "source_id": "src-3f9a1c...",
  "start": 10422,
  "end": 10610,
  "access": "full_text"
}

Where spans are required

Every extraction outcome with a non-null effect, ci_low, ci_high, or p_value; every non-null narrative factual field of an extraction; and every appraisal domain judgement that is not unclear.

An unclear grounded in absent reporting takes spans: [] — there is nothing to point at, and saying so is the honest record. Records written before this layer keep working: no spans means unverified, diagnosed as NO_SPANS, never accepted, never able to back a promoted concept.

quotes[] is derived, not written. The assembler produces an extraction's quotes from its spans. An agent never types them. The appraisal record has no quotes field at all.

The Assembler Gate

assemble.py reads the extractions, appraisals, report, snapshots, event log, and corpus, then writes outputs/result.json: accepted[] for artifacts that survived every check, and diagnostics.unresolved[] for those that did not, each with a closed reason code.

CheckCode
source_id is knownUNKNOWN_SOURCE
Snapshot hashes recomputeSNAPSHOT_HASH_MISMATCH
Span is in rangeSPAN_OUT_OF_RANGE
Span ≤ 2000 charactersSPAN_TOO_LONG
Excerpt equals the re-sliceEXCERPT_MISMATCH
Record carries the spans it owesNO_SPANS
Cited URL was retrieved for this artifactURL_NOT_RETRIEVED
Supported verdict has a fresh retrievalNO_FRESH_FETCH
No unsupported verifier verdictUNSUPPORTED_VERDICT
Synthesis introduces no source outside accepted branch evidenceSOURCE_OUTSIDE_ACCEPTED
Literature claim traces to a PMID/DOI/PMCIDNO_PAPER_ID
User-supplied PDF bytes still hash correctlyASSET_HASH_MISMATCH

Ordering

assemble.py  →  verify.py  →  render.py / html_report.py  →  okf.py promote --check  →  okf.py promote

result.json does not replace verification.json. It is the evidence ledger that the verifier and the publisher both read.

Gate default: off — with two exceptions

The gate is built, wired, and always reported, but without gates.evidence_kernel / --gate it does not block Stage 8. result.json always records gate.verdict, and the coordinator always surfaces it. Two things are never downgraded by that flag: C-SNAPSHOT and C-SPAN tamper failures block OKF promotion regardless.

Publisher Integrity Preflight

okf.py promote --check validates without writing anything, and is mandatory before the real promote. Before any concept file is created it must:

  1. Re-read every snapshot referenced by the concepts to be written.
  2. Recompute content_hash and source_id; reject on any mismatch.
  3. Re-slice every excerpt from start:end and compare against what result.json carries.
  4. Reject if an agent-written excerpt differs from the source text.
  5. Verify each evidence item's PMID/DOI/PMCID against the snapshot's paper metadata.
  6. Verify user-supplied PDF bytes hash to the recorded asset.sha256.
  7. Write with exclusive creation / atomic replace.

Promotion fails closed: any failure aborts the whole promotion — never a partial bundle, never a half-written concept. Already-generated run outputs, outputs/report.md above all, are preserved. The run is not lost because a wiki write was refused.

Worked Example: One Claim, Snapshot To Concept

Does group CBT reduce depressive symptoms in adolescents at 12 weeks?

(a) Retrieval

fulltext.py reaches PMC for PMID 12345678. The text goes to source.py fetch, which writes <run>/sources/src-3f9a1cb8…json with the PMC article URL, access: "full_text", origin: "pmc", the paper triple, asset: null (an XML route stored no file), and 61 042 characters of text. Then it appends:

{"schema_version":1,"event_id":"ev-0007","type":"fetch",
 "source_id":"src-3f9a1c...","url":"https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/",
 "at":"2026-09-08T12:07:44Z","fresh":true,"sha256":"b7e05f...","actor":"main",
 "detail":"pmc full text, 61042 chars, http 200"}

(b) Extraction

The extraction subagent receives a source_id, an access level, and two commands — not a file of prose. It runs a bounded window read, gets back text stamped with offsets, and writes:

python3 scripts/source.py spans --run-dir <run> --source-id src-3f9a1c... --query "CDI-2"
# → window start=10380 end=10900
{ "name": "CDI-2 total score", "timepoint": "12 weeks", "effect_measure": "SMD",
  "effect": -0.41, "ci_low": -0.68, "ci_high": -0.14, "p_value": 0.003,
  "direction": "favors_intervention",
  "spans": [ { "claim": "CDI-2 at 12 weeks, SMD -0.41 (95% CI -0.68 to -0.14), p=0.003.",
               "evidence_id": "pmid:12345678", "source_id": "src-3f9a1c...",
               "start": 10422, "end": 10610, "access": "full_text" } ] }

quotes is []. The subagent returns one receipt and no paper text.

(c) Appraisal

The appraisal subagent points its Missing outcome data = high judgement at start=12880 end=13044, and leaves Selection of the reported result = unclear with spans: [], because the paper cites no protocol and there is nothing to point at.

(d) Report

Synthesis writes: "Group CBT reduced CDI-2 scores at 12 weeks (SMD −0.41, 95% CI −0.68 to −0.14) in one RCT of 240 adolescents." — with a footnote keyed to the source id.

(e) Assembly

assemble.py recomputes the snapshot's hashes (match), checks 0 ≤ 10422 < 10610 ≤ 61042 (in range), checks 10610 − 10422 = 188 ≤ 2000 (in bounds), re-slices text[10422:10610], finds ev-0007 fresh for this run, confirms the paper triple agrees with the evidence_id, and writes into accepted[]:

{ "claim": "CDI-2 at 12 weeks, SMD -0.41 (95% CI -0.68 to -0.14), p=0.003.",
  "field": "outcomes[0]", "source_id": "src-3f9a1c...",
  "start": 10422, "end": 10610, "access": "full_text",
  "excerpt": "At 12 weeks the intervention group showed a greater reduction in CDI-2 total score than controls (SMD -0.41, 95% CI -0.68 to -0.14; p=0.003).",
  "section": "Results", "page": null }

The excerpt was produced here, by slicing. No agent typed it.

(f) Verification

verify.py reads result.json: C-SNAPSHOT pass, C-SPAN pass (214 spans checked), C-FRESH-FETCH pass, C-ASSEMBLER pass — alongside C-CITE-RESOLVE, C-FULLTEXT, and C-HYPOTHESIS-WALL.

(g) Promotion

okf.py promote --check re-reads the snapshot from disk, recomputes both digests, re-slices text[10422:10610] a second time, compares it to the excerpt in result.json, and checks the PMID against the snapshot's paper. All pass, so promotion writes the concept with a resolvable source and an evidence footnote whose quoted text is the re-sliced excerpt.

(h) The counterfactual

Had this happenedThis is where it dies
The extractor wrote the quote by hand as "…a significant reduction in CDI-2 scores…" while the snapshot reads "a greater reduction in CDI-2 total score"Step (e) raises EXCERPT_MISMATCH, the artifact lands in diagnostics.unresolved[], C-SPAN reports it, and step (g) refuses the concept
The run reused a snapshot from an earlier run without re-fetchingStep (e) raises NO_FRESH_FETCH
Someone edited text in the snapshot fileStep (a)'s hash no longer recomputes and every span against it dies at C-SNAPSHOT — which no flag can downgrade

Reading The Store Yourself

python3 scripts/source.py fetch --run-dir <run> --url <url>          # register text
python3 scripts/source.py read  --run-dir <run> --source-id <id>     # verified read
python3 scripts/source.py spans --run-dir <run> --source-id <id> --query "..."
python3 scripts/source.py local --run-dir <run> --file <pdf>         # user-supplied PDF