Provenance and citations
Provenance is Futuros' competitive moat. Anyone can show figures; what is rare is that every figure traces back to a specific primary-source record in two clicks, and that the system fails at build time if it can't. This document explains the machinery behind that contract. It serves both Pillar 1 — Data Trust (provenance is what makes the corpus verifiable) and Pillar 2 — Sovereign Model (facts live in citations, never baked into weights).
The two-click contract
Every on-screen figure carries its citation on the figure itself: a light underline, not a [WB] chip after the number. One click opens the reference (title, publisher, year); a second click takes you to the exact record at the source, the precise country and indicator, not a generic landing page. That specificity is a build rule: the check-source-links gate fails the build if a link points to a generic source instead of the country + indicator record.
The exception is explicit, not tacit: the gate itself carries a LANDING_OK list of sources whose citations legitimately resolve to a landing or methodology page, curated and internal layers (gov-, event-, calc-, agg-, idx-), document layers (EXA, GDELT, RSS, gazettes, signals) and a few sources with no per-record URL (OECD benchmarks, IDEA, UNODC, V-Dem, FSI, INFORM, IMF PortWatch, FAOSTAT), each with its justification commented in the code. It is a conscious, auditable opt-out, not a hole: anything not on that list must carry a deep link or the build does not compile. If a data point cannot be traced this way, it is not published as a value.
Two more nets close the gaps. GENERIC_URLS enumerates "generic dataset" pages that differ from the root only by a path or a fragment (the FSI dashboard, the empty USGS search form…): a non-exempt citation on one of them is a violation just like a bare landing page. And regional aggregates (-latam, -world, -subregion, -oecd in the id) are exempt because no country+indicator record exists for a supranational average. The design is fail-closed: a new source without a deep-link template falls back to its url_root and the build dies in prebuild; the error message prints the two valid ways out, write the url_template and run fix-source-deeplinks.ts, or document the exemption in LANDING_OK with a one-line justification.
citation_id conventions
The central citation registry is public/data/citations.json: 22,291 records of the form {id, source, title, url, retrieved_at, year}. Data cells and components point to a record by that id (the field cells call citation_id). The id carries a prefix that encodes its origin: wb- (World Bank, the most numerous), oecd-, who-, gov- (government source), event-, web- (external web search), etc.; corr- (correlation) exists as a reserved convention in the code, currently with no live citations. The prefix is not cosmetic: it is what makes it possible to resolve a citation to its source even when the citation is not in the in-memory registry (see synthetic reconstruction).
The id grammar is mechanical, and therefore parseable: <prefix>-<geo>-<year>, consumed right to left by parseMultiId(): if the last token is 4 digits, it is the year; if the next one is a lowercase ISO3 (or latam, the regional average derived by Futuros), it is the geography; whatever remains is the prefix. extractYear() applies the same convention with the regex -(\d{4})(?:[-_]|$). A real example from the registry: wb-si-pov-gini-arg-2024 → source WB, indicator SI.POV.GINI, Argentina, 2024.
A series merged by year can resolve to two records. The indicator's citation_id is the tip's. Years that only the earlier series contributes carry their citation_id on the point, and both ids stay in the cell's citation set. For Peru monetary poverty (inei_per_pobreza), 2016-2025 resolve to the gob.pe informe inei-per-pobreza-per-2025 and 2014-2015 to the INEI cube inei-per-pobreza-per-2024.
The source registry
src/lib/source-registry.ts maps more than 200 SourceKeys (acronyms like WB, WHO, IMF) to their SourceMeta: acronym, full name, agency, jurisdiction, URL root, URL template, cadence, methodology (with _en / _pt variants), tier and license. The key pieces:
inferSourceKey()resolves acitation_idto its source by prefix.deepLinkFor()builds the link to the specific record.- 43
url_templatefunctions reconstruct the per-indicator, per-country deep link from the id alone: that is, given acitation_idthe system can regenerate the exact source URL without depending on anyone having saved it by hand.
This is what guarantees the contract's second click: the deep link is not stored, it is derived, and therefore it cannot drift out of date relative to the id. Two details of the resolver matter in practice:
- Prefix order is semantic.
inferSourceKey()is a chain ofstartsWithrules where the first match wins, so longer prefixes go first (mepyd-must precedemep-; the comment in the code warns about it). Central banks illustrate the naming discipline:bcb-is Brazil,bcbol-Bolivia,bch-Honduras,bcch-Chile,bccr-Costa Rica, unique tokens ending in a hyphen so none shadows another. An unmapped prefix returnsUNKNOWN, which is deliberately not exempt in the deep-link gate: a new unregistered prefix is a regression, not an allowed case. - The license travels with the source.
SourceMeta.license(open,cc-by,non-commercial,share-alike,cite-only; absent ⇒ open/permissive) gates the bake: acite-onlysource can be cited without redistributing its values.
The vintage travels with the figure: the two-year rule
Provenance answers where this number came from. The vintage rule answers the other half: when it is from. No published observation may be more than two years old, and the threshold lives exactly once — STALE_AFTER_YEARS in src/lib/vintage.ts — so the badge the visitor sees and the gates that fail the build cannot disagree about what "two years" means.
vintageOf() resolves a cell's year in decreasing order of trust: explicit vintage_year → explicit year → the last observation in series → a year encoded in the citation_id. A record with no year in any of those places returns null and is not rendered as stale: "undated" is a different defect from "old", and claiming a staleness we cannot substantiate would be as dishonest as hiding it. VintageBadge prints that year beside the figure, amber past the rule, with the year in the tooltip.
At build time the same rule is applied at two scales: check-vintage counts individual figures in parameter-cache/ under a ratchet that may only go down, and check-freshness measures each dataset family's newest observation against 730 days. See the prebuild gates.
When the publisher itself has nothing newer, the answer is never to invent a number or dress up the date. Either the data is refreshed from a current source covering the same concept, or the publisher's frontier is declared with its evidence and a re-verification date that fails the build when it lapses. Costa Rica's eight oldest indicators — from the Programa Estado de la Nación, between 2015 and 2020 — are exactly that: the last observation PEN publishes for the country, checked against its API on 2026-09-09, served with its year in plain sight.
CiteRef markers
src/components/citations/CiteRef.tsx is the citation anchor across the platform: the cited figure or phrase is the link, with a light underline. There are no [CEPAL] / [INEC] / [WB] chips after the number. Hover (or focus / tap) shows the same popover as before (title, publisher, year, and the retrieval date: the citation's retrieved_at or, failing that, the source's last_refreshed from the freshness ledger; when neither exists no date is shown rather than invented); click goes to the external source. If one span has several sources, the popover lists them. Resolution follows this order:
Resolution (letter F / replayable provenance): only a real catalog record registered in memory counts. CiteRef no longer fabricates a syntheticCitation(id) at render time — an id with no record renders as a dangling marker ("Reference not available in current data") instead of inventing a title/URL/retrieved_at. Synthetic reconstruction still exists in src/lib/source-registry.ts for embeds and tools that call it explicitly, never as a silent chip fallback.
Chat registers the citation records the server emits during a turn. After a reload, chatStore rehydrates turns + citations from sessionStorage, and ChatPanel re-registers those citations into CitationRegistryProvider (the same path as a shared #ask= link). Without that step, the corpus sat in the session while the tooltip said "unavailable". At end of turn the server also salvages against citations.json any cited [[cite:id]] that had not yet been emitted as a record.
Letter F removed the synthetic render fallback: a miss is no longer dressed up with an invented title/URL. Surfaces that know their citations were registered (indicator pages with strict) and chat (after session rehydrate) depend on the real registry; an orphan id with no citations.json row is declared as such. When an id resolves to a source but not to a useful external URL, the click opens the SourceTrace drawer with the methodology. Every click on a marker also emits the source_click telemetry event with {citation, source, year}.
Around CiteRef live CitationChip (a wrapper that requires the text to underline), CitationDrawer and SourceTrace (a fallback drawer with methodology and the falsifications log). The external-evidence mark is not a separate component: it lives inside CiteRef itself, which paints a gold dashed underline when the citation_id carries the web- prefix. The end-to-end journey is: baked value → citation_id → CiteRef → registry → deep link.
The SourceTrace drawer carries two distinct date stamps, never interchangeable. retrieved is that individual citation's retrieved_at, judged against a flat clock. source refreshed is the source's last_refreshed from the freshness ledger (sources["series:<KEY>"] in refresh-meta.json), and it is judged against the source's own cadence, not that clock: the ledger's next_refresh_due, or cadence_months when it has to be derived. A flat clock was the wrong ruler for this stamp in both directions: CEPALSTAT and Banxico publish annually, so a pull from 100 days ago is exactly on schedule and calling it stale invented an alarm; IBGE, INEGI, DANE, INDEC and BCB publish monthly, so that same pull has missed three cycles and the flat clock showed nothing. The verdict is on cadence until it falls due, refresh due within one cycle of grace, and refresh overdue past that cycle; when the row declares no cadence (cadence_basis: "unavailable") the date and its age show with no verdict, because there is no rule to judge them against. Neither stamp speaks to the age of the observation itself: that is the vintage rule, with its own threshold (STALE_AFTER_YEARS) and its own gates (check-vintage, check-freshness).
Link grade
linkGradeFor(citationId, recordUrl) in src/lib/cite-link-grade.ts classifies the link that can be offered without dressing a landing page up as the record:
record: the record carries its own URL, non-empty and not#.deep: there is no record URL, but the source'surl_templatedeclares parameters (Function.length > 0) anddeepLinkForbuilds a per-id link.portal: what can be reconstructed is a landing page (url_root, or a constant template of the form() => "https://…").
In resolveCiteView (CiteRef.tsx) a dangling view, with no catalog record, floors to grade portal. drawerOnly is also true when the grade is portal: the click opens the SourceTrace drawer instead of a homepage in a new tab.
resolveTraceSource in SourceTrace.tsx builds the drawer's Source section. The URL is cite?.url || deepLinkFor(id): an empty url falls through to the reconstructed link and does not remain an href="". With no record, the drawer shows the no record tag and the note that the metadata is reconstructed from the identifier. A portal-grade link carries the portal label above it. The publisher shown is the record's own source when the source-registry key is a generic bucket (CURATED, INTERNAL, UNKNOWN) or CURATED_LAW; otherwise it is the source's institutional name.
Signals: two lineages, one honesty
The signals layer (public/data/signals/) has two lineages with distinct provenance contracts, and the check-signals gate tells them apart by the type field:
- LLM-extracted signals (six types:
policy_announcement,funding,incident,milestone,risk,contradiction). Their provenance is a corpus document:source_urlmust exist in the sibling filedocuments/<axis>__<ISO3>.jsonand everyfiguremust be a verbatim substring of that document's excerpt. When the document rotates out of the 365-day window, the signal is pruned (with an entry instate/pruned-signals.jsonl): a signal never outlives its document. earth_obssignals (deterministic, no LLM). They are emitted bybake-earthobs-signals.tsquerying machine-readable Earth-observation sources: Climate TRACE v7 (monthly emissions by country; CC BY 4.0 per its terms and the inventory's Zenodo record, concept DOI10.5281/zenodo.15014790) and INPE / TerraBrasilis (monthly DETER and annual PRODES; CC-BY-SA 4.0 per its citation and license page). Heresource_urlis the exact GET that produced the value (the queried month travels in the URL itself) and there is no sibling document, so the gate applies a structural contract to them: an allowlisted https host (exactly the ones the generator emits:api.climatetrace.organdterrabrasilis.dpi.inpe.br; Zenodo/DOI is deliberately left out, because Zenodo hosts arbitrary uploads and admitting it would allow citing a self-published record), a fixed cell (medio-ambiente-clima, with the file matchingparameter×geography_id), asource_namewithin the known set, a full ISO date, and thefigureverbatim inside its ownclaim_es, with the same digit sequence present inclaim_en/claim_pt(no language may assert a different number). The mandatory attribution of both licenses is satisfied on screen:source_name("Climate TRACE", "INPE / DETER", "INPE / PRODES") is the anchor of the link to the source.
The earth_obs type cannot be born from a model: the LLM extractor's VALID_TYPES list excludes it, so a model that hallucinates that type sees its signal discarded by the deterministic validator. And the pruning passes (the bake's orphan pruning and prune-stale-signals) exempt earth_obs from the sibling-document rule (without that exemption, every refresh of the document corpus would erase them), but still prune them if they stop passing the structural gate.
That exemption is bounded by a per-source age cap (EARTH_OBS_MAX_AGE_DAYS), because the sibling document is what expires an LLM signal and without a cap the earth-obs path would be the one path that never expires: a downed upstream would leave its last reading on screen forever. The cap is per source because the caps are the sources' own cadences, not a house number: Climate TRACE and DETER are monthly (365 days; the generator itself only searches 8 months back for a published month), while for PRODES the date is the end of the reference period (July 31) and the consolidated rate publishes around November and stands until its successor lands a year later, so a current PRODES signal legitimately reaches ~16 months (a 550-day cap). A flat ~400-day cap would have deleted live, correct data: on 8 September 2026 the current PRODES signal was 404 days old. Adding a new source without declaring its cadence does not compile: the cap table is keyed by the source_name allowlist itself, so a source with no cap is a type error rather than a silent inheritance of the default (365 days, safe only for a monthly feed and lethal for an annual one). The cap is applied in prune-stale-signals (with a ledger entry), not in check-signals: the build gate does not depend on the clock, so no build goes red because data aged while nobody touched it. In the interface, each country's climate strip shows the most recent earth_obs signal as the "latest alert", with its date and its linked source.
Synthetic reconstruction for embeds
Embeddable surfaces (/embed/stat, /embed/chart, /embed/board) do not always load the full citation registry, it would weigh too much. That is what syntheticCitation() is for: it reconstructs the citation from the citation_id and the source registry (prefix → SourceKey → url_template). Result: a card embedded on a third-party site keeps its citation and its deep link, without loading the registry's megabytes. Provenance travels with the data, not with the page.
Three honesty invariants in the synthetic: it returns null if the prefix resolves to UNKNOWN (it never invents an institution); retrieved_at stays empty (it does not fabricate a retrieval date that never happened); and the year is only included if it is derivable from the id itself.
Badging of external web citations
Not every fact comes from the baked corpus. When the assistant uses web search as a last resort (see The assistant), those citations carry the web- prefix and are visibly marked as external: the figure's underline is gold and dashed, and the hover card prefixes the unverified-data tag. The reason is honesty: a fact fetched from the open web must never be confused with a datum from the verified corpus. The badging separates the two classes of evidence in the interface itself, so the reader always knows how firm the ground is under each figure.
Falsifications log
public/data/falsifications.json is the record of claims that were put to the test, the reverse of "every citation is a source": each falsification is an auditable commitment that the platform corrects whatever turns out not to hold. SourceTrace exposes it alongside the source's methodology. It is part of the honesty contract detailed in Methodology and honesty.
Provenance in Futuros is derivable, not promised: the deep link is reconstructed from the id, the synthetic citation travels to the embed, and the build refuses to compile if any link outside the documented opt-out (LANDING_OK) points to a generic source. Continue with The assistant to see how the chat inherits this same discipline, every figure anchored to a [[cite:id]]. And for the next frontier (from derivable provenance to provenance cryptographically verifiable by third parties), see the technical optimization roadmap.