Citations domain
Date: 2026-08-22
Status: implemented 2026-08-23 in 80c1a4f. Deviations: CSL rendering
was already out of scope here and remains unbuilt; the web reader queries
CrossRef only (arXiv and Semantic Scholar would be additional readers behind
the same port, and nothing needed them yet).
Roadmap: spec 2 of 2026-08-21-architecture-roadmap.md
Depends on: nothing. See "What changed since the roadmap".
What changed since the roadmap
The roadmap justified this spec twice: as a domain worth having, and as "the proof that spec 1's boundaries hold — if adding a domain is hard, spec 1 was wrong." The second justification is void. Spec 1 (onion refactor) was superseded on 2026-08-21 after two reviews found that a repository whose method count tracks its call-site count is a rename, not an abstraction. There is no repository layer to prove anything about.
What survived spec 1 is what this spec actually builds against: the four
namespaced MCP modules (mcp/feed.py, mcp/knowledge.py, mcp/provenance.py,
mcp/symbolic.py), the @tool decorator in mcp/_tool.py that owns
connection, user lookup and both envelopes, and ports.py's rule for when a
Protocol is earned. This spec inherits those and adds nothing structural.
Problem
claims.py can verify that a number in prose matches a run in the ledger. It
cannot verify that a citation in prose points at a real paper, and it has no
way to express "this claim is supported by someone else's published result"
as distinct from "this claim is supported by my run".
Meanwhile the knowledge graph is built from tags on feed items — things the
user read. What the user cited is a stronger signal than what they read, and
it is sitting in a .bib file or a Zotero library that this project cannot
see.
Both gaps are the same missing thing: a reference has no representation here.
Scope
One CitationPort, four readers, and a resolver that records where each record
came from.
The port
@runtime_checkable
class CitationPort(Protocol):
"""A source of bibliographic records, keyed by citation key or identifier."""
name: str # "zotero" | "bibtex" | "web" — recorded on every record it returns
def lookup(self, key: str) -> Reference | None:
"""One record by its key, or None if this source does not have it."""
...
def all(self) -> Iterator[Reference]:
"""Every record this source can enumerate. Network readers raise
NotImplementedError: you cannot enumerate CrossRef."""
...
ports.py states the rule: "A protocol earns its place when a second
implementation genuinely exists." Three do here, with genuinely different
backends (a SQLite file, a text format, an HTTP API), and the reason to name
the shape is that the resolver must treat them uniformly while recording which
one answered. That is a real abstraction, not a rename.
all() returning Iterator rather than list is load-bearing: a Zotero
library of 8,000 items should not be materialised to answer "is this key
present".
Reference
@dataclass(frozen=True)
class Reference:
key: str # citation key: "vaswani2017attention"
title: str
authors: list[str]
year: int | None
doi: str | None = None
arxiv_id: str | None = None
url: str | None = None
source: str = "" # which reader produced this — never blank in practice
fetched_at: str | None = None # ISO date; None for offline sources
source and fetched_at are the provenance pair. A record from disk has
source="bibtex" and fetched_at=None; a record from CrossRef has
source="web" and a date. This is the mechanism that makes the offline
exception inspectable rather than merely documented.
The four readers
| reader | backend | network | notes |
|---|---|---|---|
zotero |
~/Zotero/zotero.sqlite, read-only |
no | Also the local HTTP API at 127.0.0.1:23119 when the app is running |
bibtex |
.bib files on disk |
no | Read and write. Pairs with claims.py |
web |
arXiv, CrossRef, Semantic Scholar by DOI/arXiv ID | yes | Opt-in, default off. See below |
csl |
Pandoc/CSL rendering | no | Not a reader. See below |
Zotero must open its SQLite read-only and must tolerate a locked file.
Zotero holds an exclusive lock while running. Opening with
file:...?mode=ro&immutable=1 reads a live library without corrupting it; if
that fails, fall back to the HTTP API, and if that also fails, return no
records rather than raising — a missing Zotero is an absent source, not an
error. On this machine there is no Zotero install, so this reader ships with
a synthetic fixture and is verified against the documented schema
(items/itemData/itemDataValues/fields), not against a real library.
That limitation goes in the reader's docstring, because a reader tested
only against a fixture its own author wrote is exactly the failure mode
CLAUDE.md records for this repo ("tests that pass against the bug they were
written to catch").
CSL is not a reader and does not implement the port. The roadmap listed it
as a fourth reader; it renders, it does not resolve. Rendering is a
presentation concern and lives at the presentation edge — a CLI subcommand, not
a CitationPort. Listing it as a reader would have put formatting inside the
domain.
The offline-guarantee exception
CLAUDE.md states: "Local models via Ollama; nothing leaves the machine."
The web reader breaks that. The terms:
- Default off. Enabled only by
ATTEST_CITATION_WEB=1, checked at reader construction, not at call time — so a disabled reader cannot be coaxed into one request by an unusual code path. CLAUDE.mdgains an explicit note naming every tool that can reach the network. A guarantee with a documented exception is honest; a guarantee that quietly stopped holding is not.- Per-record provenance, via
sourceandfetched_atabove. Any answer can be asked where it came from. - A disk cache, so a DOI is fetched once. Cached records keep their
original
fetched_at— the cache does not launder a network record into something that looks local.
Open questions from the roadmap, now answered
Do citations become KG nodes or a sibling store? Sibling store. The graph
is built by kg.build_graph(assignments), which is a pure function over
(item_id, tag) pairs — CLAUDE.md records that purity as load-bearing, and
kg_nodes/kg_edges/kg_meta were deleted on 2026-08-21 precisely because
nothing read them. Putting references into the graph would mean either
inventing tags for them (polluting a vocabulary derived from read items) or
adding a second node type (ending build_graph's single-type purity). A
references table with an optional item_id FK gets the join without either
cost: "papers I cited that I also read" is a query, not a graph rewrite.
Does claims.py gain a cite-key verdict? Yes, one new VerdictKind:
UNCITED. VerdictKind is already a closed StrEnum and check_claim
already returns a Verdict carrying matched, so the shape exists. UNCITED
means "this claim names a citation key that no configured source has" — a
lint, matching how coverage() lints numbers no claim covers. It is
deliberately NOT "this claim contradicts the cited paper": comparing a prose
assertion against a paper's contents needs a model, and every verdict in this
module is currently deterministic. Keep it that way.
Cache policy. Content-addressed by DOI/arXiv ID, never expiring. A published paper's metadata does not change, and an expiring cache would turn one network call into a recurring one — the opposite of the guarantee.
Tool surface
Four tools under a new cite.* namespace, following the existing rules from
test_architecture.py: a tool never repeats its namespace, and every tool is
registered through @mcp.tool(name=...).
| tool | returns |
|---|---|
cite.lookup |
one Reference by key or DOI, with its source |
cite.search |
references matching a title/author string, offline sources only |
cite.check |
claim keys with no matching reference — the UNCITED lint |
cite.sources |
which readers are configured, and which are network-backed |
cite.sources exists because the answer to "did this leave my machine" must be
askable from the same surface that did the leaving.
Which surface gets them, and the one that splits. AGENT_SURFACES' own
comment states the rule that decides this: "Claims live with runs, not with
the graph. runs.claims_check verifies numbers in Markdown AGAINST recorded
runs; separating them would put a claim checker in a session that cannot see
what it checks against."
cite.lookup, cite.search and cite.sources are reference resolution and
belong with knowledge — they pair with the reading graph, and a knowledge
session is the exploratory read-only one.
cite.check is a claim checker and by that rule belongs with provenance,
beside runs.claims_check. A session that can lint uncited claims but cannot
see the runs the other claims are checked against would report half a
document's problems and look complete. So cite.check registers under both
knowledge and provenance — the surface table already supports a tool
appearing in two surfaces (knowledge contains feed.search for exactly this
reason), and this is the same case: one tool a second surface genuinely needs.
Success criteria
ATTEST_CITATION_WEBunset ⇒ no socket is opened. Verified by a test that monkeypatcheshttpxto raise on any call and drives the full surface.- Every
Referencereturned carries a non-emptysource. - Zotero reader returns
[]against a locked/absent library, never raises. claims.checkgainsUNCITEDwithout changing any existing verdict.cite.lookup/cite.search/cite.sourcesregister underATTEST_TOOLS=knowledgeonly;cite.checkunderknowledgeandprovenance, matching howfeed.searchalready crosses a surface.- Full pre-commit gate green.
Out of scope
Writing to Zotero. Rendering bibliographies (that is the CSL CLI subcommand,
specified separately if wanted). Any model call — this domain is deterministic
end to end, and the UNCITED verdict is scoped narrowly to keep it so.