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 mode | Caught by | Code |
|---|---|---|
| Fabricated or subtly re-worded quote | Excerpt re-slice mismatch | EXCERPT_MISMATCH |
| Invented source | Unknown source_id | UNKNOWN_SOURCE |
| Stale evidence recycled from an old run or a wiki note | The fresh-fetch rule | NO_FRESH_FETCH |
| Tampered snapshot | Hash recomputation | SNAPSHOT_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:
| Guarantee | Mechanism |
|---|---|
| Write-once | Created with O_EXCL. There is no update path. A correction is a new snapshot with a new id; the old one stays. |
| Content-addressed identity | source_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 hash | content_hash = "sha256:" + sha256_hex(utf8(text)), over the text alone, so a body edit is detectable without reference to the URL. |
| Verified on every read | source.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 offsets | text 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 duplication | PDFs 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.
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:
| Type | Means |
|---|---|
fetch | A real retrieval performed in this run |
local_pdf | A hash-checked user-supplied PDF |
register | Text obtained elsewhere in the pipeline, folded into the store without a new retrieval |
read | A 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"
}
startinclusive,endexclusive. The excerpt is exactlytext[start:end].- Offsets are character offsets into the snapshot's decoded text — Python
strindices, i.e. Unicode code points. Not bytes, not UTF-8 byte counts, not UTF-16 code units, not offsets into a re-wrapped or markdown-rendered view. This is the single ambiguity most likely to produce silent, plausible-looking corruption, which is why it is nailed down here and in the schema. - Maximum 2000 characters per span. Over-length is a failure, never a truncation.
- Multiple spans per claim are ORed evidence. They may come from different snapshots, may overlap, and are never merged or length-summed by the assembler.
claimis a label in the agent's own words — never a transcription of the source.accessis copied from the snapshot, not chosen.
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.
| Check | Code |
|---|---|
source_id is known | UNKNOWN_SOURCE |
| Snapshot hashes recompute | SNAPSHOT_HASH_MISMATCH |
| Span is in range | SPAN_OUT_OF_RANGE |
| Span ≤ 2000 characters | SPAN_TOO_LONG |
| Excerpt equals the re-slice | EXCERPT_MISMATCH |
| Record carries the spans it owes | NO_SPANS |
| Cited URL was retrieved for this artifact | URL_NOT_RETRIEVED |
| Supported verdict has a fresh retrieval | NO_FRESH_FETCH |
| No unsupported verifier verdict | UNSUPPORTED_VERDICT |
| Synthesis introduces no source outside accepted branch evidence | SOURCE_OUTSIDE_ACCEPTED |
| Literature claim traces to a PMID/DOI/PMCID | NO_PAPER_ID |
| User-supplied PDF bytes still hash correctly | ASSET_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:
- Re-read every snapshot referenced by the concepts to be written.
- Recompute
content_hashandsource_id; reject on any mismatch. - Re-slice every excerpt from
start:endand compare against whatresult.jsoncarries. - Reject if an agent-written excerpt differs from the source text.
- Verify each evidence item's PMID/DOI/PMCID against the snapshot's
papermetadata. - Verify user-supplied PDF bytes hash to the recorded
asset.sha256. - 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 happened | This 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-fetching | Step (e) raises NO_FRESH_FETCH |
Someone edited text in the snapshot file | Step (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