Every Value Has a Receipt
The lineage and validation architecture behind Autumn's output: how a fetched page becomes a citation, and how every cited value gets checked before it ships.
Building a research agent that returns a table is easy. Building one that returns a table you can audit, field by field, back to the sentence on the page that supports it, is an architecture problem.
Two decisions do most of the work. Provenance lives inside the value instead of beside it, and citations are minted by the retrieval layer instead of written by the model. Quote alignment, semantic judging, bounded repair, honest downgrades: all of it sits on top of those two.
The cell is the unit
An Autumn output row is not a map of field to scalar. Every field is a cell, and the cell carries its own provenance.
1"headquarters": {2 "value": "Austin, TX",3 "reasoning": "The contact page lists the head office at 600 Congress Ave, Austin.",4 "source_id": "a41f2",5 "source_ids": ["a41f2", "9c03d"],6 "evidence": [7 ["a41f2", "Our head office is located at 600 Congress Ave, Austin, TX."]8 ]9}source_id is the primary citation. source_ids holds corroborating sources, primary first, deduplicated, order preserved. evidence is the strong form: pairs of source ID and quote, where the quote is text the agent says appears on that page. One invariant ties them together. The source set is always a superset of every ID mentioned in evidence, re-synced on write, so an evidence pair can never point at a source the cell doesn't cite.
Keeping provenance in the value means there is no join to get wrong and no second table to fall out of sync. Rename a column, merge a row, export a subset, and the citation comes along for free.
Absence works the same way:
1"twitter_url": {2 "absent": true,3 "reasoning": "Neither the site footer nor the team page links a Twitter account.",4 "source_id": "77b0e"5}An absent cell still has to cite the page we checked. That gives you the difference between "confirmed absent" and "never looked", which most enrichment output flattens into a null and leaves you to rediscover.
The source registry
Every retrieval appends to a lineage log. Each entry stores the source, the tool that fetched it, a timestamp, and the text the agent actually read.
1LineageEntry(2 source_id="a41f2",3 source="https://example.com/contact",4 output="...retrieved page text...",5 tool="fetch",6 created_at=1753...,7)IDs are content addressed on the source string: a truncated SHA-1 prefix, rehashed with a salt if it collides. Two useful things follow. The same URL always maps to the same ID, so re-fetching a page to get a fresh citation is pointless. And IDs stay stable for the whole task, so a cell saved on turn 12 can cite a page read on turn 1.
Re-fetching a source upgrades the stored text in place. Page readers replace the body outright. Other tools append with a timestamped separator, up to a cap. There is one guard that took a production bug to earn: a re-read that comes back with substantially less text than we already have gets refused. That shrinkage is the fingerprint of a bot wall or a truncated response, and letting a login page overwrite good retrieved text would quietly destroy the evidence for every cell citing it.
Storing the retrieved text is what makes verification possible. Checks run against what the agent saw, not against a fresh fetch. Otherwise every check would race live page edits, and a value that was right when we read it would fail an hour later because someone updated a listing.
Capture is automatic
The agent does not write citations. A wrapper around tool dispatch registers lineage on every successful call, whether that's a search, a page fetch, a PDF parse, a structured API, a batch fetch, or a file you uploaded, and stamps the resulting IDs onto the rows that come back.
Choosing the source for each row is a precedence walk. An explicit url or source_url wins, then a typed identity URL like linkedin_url, github_url, or website, then any URL-shaped key, then any HTTP value sitting in the row, and finally the request URL of the call itself. For the body we prefer offloaded page content, then a content or text field, then a snippet, then a JSON snapshot of the row. Even a structured API response ends up with something quotable behind it.
So by the time the agent saves a value, the IDs already exist and all it can do is reference them. It cannot type a plausible looking citation, because citations aren't prose it authors. They're keys into a registry it never writes to.
Two recovery paths turned out to matter more than we expected. Tools accept a source_id anywhere a URL goes and resolve it to the exact cached source, so re-visiting a page is exact instead of retyped. And when a fetch fails on a malformed URL, we check it against the registry by prefix. If it looks like a truncated version of a page we already have, the error comes back with a suggested_source_id pointing at the real one.
Uploaded files get registered too, under an upload:// scheme, so a derived value can cite your own spreadsheet.
The save-time contract
Saving is where the rules bite. A populated cell with no source_id, no source_ids, and no evidence gets rejected. Not stored with a warning. Not stored.
Two rules decide which IDs are the right ones.
- Cite the source you actually read this value from. Not a page you remember, not a search URL you assembled, not a nearby page that probably says the same thing.
- Cite per field, not per row. A league table states a team's rank and points. It says nothing about the coach's date of birth. Blanketing one
source_idacross a whole row is a named failure mode, not a shortcut.
Schemas are also blocked from declaring their own source_url or evidence_urls columns. Provenance is structural here, so duplicating it as data would recreate the drift the cell design removes.
Every save materializes the row: canonicalize rule fields, demote verdict words that leaked into fact columns, widen corroboration, then walk the cells, resolve each ID back to a URL, and write a _sources map of field to URL list. That map is what the API and the UI read. It is derived, never authored.
An unsourced value is not a value. It never reaches storage.
Layer one: deterministic checks
Validation runs after every agent step, not as one hopeful pass at the end. The first tier uses no model and costs nothing. Per cell:
- Format. The value matches its declared semantic type.
linkedin_urlis anchored to real profile and company paths.phonerejects date lookalikes.date,int,float,bool,email,domain, andlist[T]all have real shapes. - Source existence. A populated cell carries at least one ID.
- Source resolution. Those IDs resolve to a source with retrieved text on hand. A cell whose only citation is a scratch file the agent wrote itself fails with "cite the source URL, not a workspace file".
- Absence. An
absentcell cites the page that was checked.
The results collapse into a compact record on the row:
1"_validation": {2 "headquarters": { "s": "ok" },3 "employee_count": { "s": "fail", "r": "source unresolved" },4 "founded_year": { "s": "gap" }5}A gap is an empty cell, tracked as coverage rather than error. A fail is a cell making a claim it hasn't earned.
Layer two: quote alignment
A quote is only evidence if it exists. So we go find it in the stored text and score the match.
Exact substring matching falls over immediately. Retrieved text is full of markdown artifacts, collapsed whitespace, and navigation junk, so a sentence the agent read faithfully almost never matches byte for byte. Normalization runs with a character level map back into raw offsets. The comparison happens on cleaned text, but a hit resolves to a real position in the original document, which is what lets us quote surrounding context to a judge later.
Alignment tries an exact normalized substring first, then falls back to token anchored sequence matching. The score is the usual similarity ratio:
with matched characters and the combined length of both strings. A quote counts as verified at .
That threshold is doing something specific. A near quote with dropped punctuation or a clipped clause clears 0.85 without trouble. An invented sentence about the right company does not, because it shares little beyond function words with anything on the page. This is why the agent is told a near quote is fine. There's no reason for it to fake precision, and fabrications still land as [UNVERIFIED]. A verdict resting only on quotes we couldn't find gets thrown out.
Around the best match we cut a reading window of roughly a thousand characters either side, capped per source and in total, chosen to cover both the matched passage and the passage that names the row's subject.
A claim survives only if the sentence it rests on actually exists in the text we retrieved.
Layer three: semantic judging
A real quote can still fail to support the claim. The third tier hands the cell to a separate model, not the agent that did the research, and asks whether the cited sources actually establish this value for this entity.
The payload is assembled rather than dumped in: the field and value, the agent's reasoning, the row's entity anchor, and up to three labeled source blocks holding the alignment windows, with quotes inline and marked verified or not. Capping sources keeps the judgment tight. The anchor is what makes wrong-entity support visible at all.
Three rubrics apply, depending on the kind of cell.
- Facts. Paraphrase and direct implication are fine. Proxy evidence is not. A founder's location does not establish company headquarters, and showing up in a directory does not establish an attribute the directory never states. If the reasoning itself concedes the source doesn't say it, that's a fail.
- Rule verdicts. Inference is allowed, since these are judgments rather than transcriptions. Quantity claims get a specific guard: a search result count is not an enumeration, so "5+ publications" cannot rest on a result tally.
- Graph edges. For reporting relationships we check direction. Reversed, or a different manager, is a fail.
Two things keep this affordable. Verdicts cache under a key hashing the source IDs, value, reasoning, evidence, a digest of the retrieved text, and the anchor, so an unchanged cell with unchanged evidence is never judged twice. And cells flatten across all pending rows into one batched pass, chunked by count and character budget and grouped by rubric, instead of going row by row.
Plenty of cells skip the judge entirely.
- Values from a file you uploaded. You're the authority on your own data.
- Values from structured APIs, which have no prose to quote.
- URL-typed fields, checked as identifiers rather than claims.
- Dates, which get a deterministic shortcut. We generate every plausible surface form (
2026-03-15,March 15, 2026,15 March 2026,Mar 15) and require one to appear literally in the source near the anchor. Cheaper than a model and stricter too.
Bounded repair, then honest settling
A failed check returns a structured instruction: row, field, value, reason, and where we can find one, a suggested_source_id from scanning already retrieved text for the literal value. Most repairs are just attaching the citation the agent already had.
Attempts are capped at two for priority fields and one for the rest. The cap keys on a signature over the cell's value, sources, and evidence, so resubmitting an unchanged cell doesn't burn an attempt. Only a real revision does.
When the attempts run out, the cell settles honestly instead of passing.
- A rule verdict is forced to
unclear, keeping the original asdowngraded_fromplus a reason for what couldn't be confirmed. - A fact gets relabeled unverified with detail, and stays visible carrying that badge.
- A graph edge claimed as stated drops to inferred and loses its evidence.
Rule cells are three way by construction: match, miss, or unclear. A match or miss needs at least one evidence quote. An unclear needs evidence or honest reasoning. Placeholder deferrals like "not yet verified" are detected and refused as final values. A cell the agent hasn't genuinely tried yet doesn't burn an attempt either. It stays open as unfinished work.
The anti-gaming rule is written into the agent's instructions in plain language. If you catch yourself thinking "no source states X, but I'll cite Y and frame it as X to pass", write unclear or miss instead. We want the verdict a human would agree with, not the one that clears the check.
Finishing is gated the same way. A task can't complete while validation failures are open, while the target count is unmet and rows are still findable, or while saved rows have schema gaps. The failure detail goes back into the turn and the agent goes back to repair. If only 340 of 500 requested rows survive verification, you get 340 rows and a straight statement of the gap, because fewer verified rows beat more unverified ones.
Every value is one of four things: verified, confirmed absent, an honest gap, or explicitly flagged unverified. Nothing is quietly asserted.
Merge semantics and corroboration
Re-saving an entity is a merge, and what happens depends on whether the value moved.
- Value unchanged: source sets union, evidence pairs union. Corroboration builds up on the cell.
- Value changed: the cell is replaced and the old sources are dropped.
That second rule looks lossy until you hit the bug it fixes. Union merging on a changed value resurrects citations for a value that no longer exists, and dropped list entries come back from the dead.
Whether two saves are the same entity comes down to normalized identity tokens. Strong identifiers win first (email, LinkedIn, GitHub, Twitter), then any schema field marked unique, then a content signature when there's nothing identifying at all. Normalization strips scheme, www., query, fragment, trailing slash, and a leading @, so two spellings of the same URL don't fork one company into two rows.
Corroboration also widens on its own. We take the union of a row's sources as a pool, and any pooled source whose retrieved text mentions the row's anchor gets attached to under-cited rule cells, up to three. Free corroboration from pages already in hand, no extra requests.
Deduplication happens at seven layers, and each one catches something the others miss: at mint time, one ID per source string; inside the cell, ordered dedup of IDs and whole-pair dedup of evidence; at row identity, via normalized tokens; at the corroboration cap; at the API, collapsing canonical URLs by lowercasing the host and dropping www., fragments, and trailing slashes, keeping the newest and ranking by recency; at render time in the table; and in the sources timeline, by source and tool, grouped by domain.
What counts as corroboration
Counting sources is not the same as counting evidence, and this is the part that changes results most.
- A search snippet locates a page. Only the fetched page proves the claim.
- High confidence needs at least two corroborating sources. One page saying something loudly is still one page.
- Subject-authored sources (someone's own profile, personal site, resume) are authoritative for working out who a person is. They cannot corroborate that person's own claims.
- Aggregators corroborate nothing. An aggregator republishing a profile is the same source wearing a different domain.
For identity work those rules drive a rating computed in code from typed evidence counts, not by asking a model for a confidence number. A subject reads as consistent only when identity resolved with at least one independent crumb and every claim has independent corroboration. Contradicted claims or mismatched anchors force inconsistent. Everything else is mixed. Named flags call out the specific weakness, things like self_corroborated_only, single_source_claim, and identity_single_source, so "corroborated" can't quietly mean the subject said it twice.
Structural integrity gets checked in code too, because graph properties are exactly what language models are shaky on. We walk for reporting cycles, catch self-reporting edges and managers who don't exist in the chart, flag span-of-control outliers against the chart median, and warn when a high confidence rating cites fewer than two sources.
Keeping the grader honest
One separation runs through all of this. The system that verifies facts and the system that judges output quality are not the same system.
The validator owns truth. The quality judge, used only in evaluation, runs on a different model from a different family than the agent, compares whole outputs, and is forbidden from re-verifying facts. It takes the validator's per-row findings as ground truth and weighs flagged rows against the output, so a nicely formatted table with sourcing failures loses to a plainer one that's correct. Judgments run in both orderings and get flagged when position, not substance, drove the answer. Confidence is discounted by how much the two orderings disagree. Ratings come out of a Bradley-Terry fit reported with uncertainty attached. And the self-improvement loop can't edit the evaluation harness, so it cannot tune its own grader. How we spend human review inside that system is its own post: Fewer Human Evals, Better Model Rankings.
What you get back
All of this shows up as one interaction: click a cell. You get the value, the reasoning, the verification label, and the sources, each linking to the page the value came from. A sources timeline lists every page the task touched, grouped by domain.
Programmatically you get both views. The SDKs hand you a flattened row of plain scalars for normal use, and the raw row with _sources and _validation intact when you want to audit. CSV export unwraps cells to scalars and appends a deduplicated row-level source column.
The receipt was always the point. Data you can act on is data you can check, and checking it should cost one click, not a second research project.
Hand off a goal at platform.autumn.ai, or start with the docs.