Spec version 3.0 — drafted 2026-08-03. This document is the canonical source of truth for the v3 build. Where it and the code disagree, that is a bug in one of them; say which.
In scope: the foundation refactor, and Phase 2 (provenance & lateral reading).
Dropped, on evidence: Phase 3, media forensics. Crabe assesses text only, and says so on every result. The reasoning is in §7 — it is a decision record, not an omission.
Explicitly deferred: Phase 4, the multi-agent orchestrator. It merges axes into a synthesis, which is a decision that binds Crabe’s output contract to Spotter’s gate model — so it is to be co-designed with Spotter rather than settled unilaterally here (claude-technique.md §4.6 ↔ §4.7). Half-answered 2026-08-03: Spotter has stated its side — it wants the raw vector and composes itself, and a composite may only ever come in addition, never instead (COORD). What remains open is whether Crabe ships a composite at all.
Not a goal: a single global reliability number. See §3.
v2 emits two 0–5 scores. One (content) is derived from text the model was given. The other (provenance) is derived from what the model happens to remember about a publisher — PROVENANCE_PROMPT asks it to judge “based on your knowledge”. Nothing is fetched. Nothing is checked. Both scores render identically.
A tool whose purpose is to make epistemic status visible must not present unverifiable recall in the same visual register as measurement. That is the defect v3 exists to fix, and it is why the evidence tier below is a primitive rather than a display detail.
Every claim Crabe emits carries a tier. Tiers are ordered by what would have to be true for the claim to be wrong.
| Tier | Meaning | Wrong only if |
|---|---|---|
verified |
A deterministic artifact was fetched and checked. An HTTP status, a field in the page’s own markup, a resolved redirect chain. | The fetch or the parse is wrong. |
retrieved |
External sources were fetched and are cited with real, user-visitable URLs. | The sources are wrong, or the model mis-summarised them — but the user can go look. |
assessed |
Model judgement about material supplied to it that the user can also see — the page’s text, an image on the page. | The judgement is wrong; the input is in front of you. |
recalled |
Model parametric knowledge. Unverifiable from inside the extension. | The model’s memory is wrong, and nothing here can tell. |
absent |
The thing that would have carried evidence is not there. No author in the metadata; no publication date; no citations on the page. | — |
assessed and recalled are separated deliberately, and the separation was what the first agent forced. “This passage uses emotionally charged language” is checkable by reading the passage. “This publisher has a poor record” is not checkable from inside the extension at all. Rendering both as one tier would reproduce, one level down, exactly the flattening described in §1.
Three rules follow, and they are invariants, not guidelines:
absent is not bad. A page with no byline means most of the web, not concealment. Absence is reported as absence and never scored as a negative signal.recalled claim that an external fetch later agrees with becomes a separate retrieved claim; the recalled one does not get promoted.Crabe emits an axis vector. Each axis carries its own findings, its own tier distribution, and its own uncertainty. There is no imposed global number.
| Axis | Kind | Default tier | Phase |
|---|---|---|---|
content |
contribution | assessed |
shipped |
provenance |
contribution | recalled |
shipped |
lateral |
contribution | retrieved |
2 |
citations |
gate | verified |
2 |
Composition is a policy the user owns. Weights live in settings. The default view is the vector; a composite is opt-in and always reversible to its parts. This matches Spotter’s model (gates multiply and only ever demote; contributions add), so the eventual Crabe→Spotter contract needs no translation layer.
An axis that did not run is not_run, never 0. A disabled network capability, a failed fetch, and a genuine zero are three different states and must remain three different states all the way to the UI.
The hard constraint is architectural: local (Ollama) mode means data does not leave the machine. Phase 2 needs outbound requests. Both are true, so the resolution is explicit consent per capability, not a global toggle.
Two independent, separately consented capabilities, both off by default:
lateralReading — sends a query about the page’s source (publisher, author, claim) to the web-search tool of the LLM provider the user already chose. One counterparty, already accepted by choosing that provider. Returns real citations.citationChecking — fetches the links already present on the page to check they resolve, where they land, and whether they say what the page claims. Deterministic and genuinely verifiable. Counterparties are the sites the page already points at.Rules:
lateralReading is unavailable — the provider has no search tool. The axis reports not_run with the reason stated. It does not quietly fall back to a cloud provider.The two cloud providers return citations of genuinely different kinds, and flattening them would be the §1 defect again in miniature.
vertexaisearch.cloud.google.com whose title is only the publisher’s domain, which expires in roughly 30 days, and which Google’s terms forbid resolving by automated means — the stated penalty for automated access is losing redirect URIs altogether.So EvidenceSource carries directLink, and isProgrammaticallyResolvable() is the gate every fetch path must pass. A Google grounding link is something the user may click and nothing else may touch. This is not a limitation to work around; the workaround is the thing the terms prohibit.
Decided 2026-08-04 (Céline). Google’s terms also require displaying the returned Search Suggestions widget alongside grounded results, and state that the licensee “will not modify, or intersperse any other content with, the Grounded Results or Search Suggestions”. The question this raised is not Crabe’s to answer: Crabe assesses, it does not re-present retrieved results as a destination. A grounded call here yields sources that license a retrieved tier for a judgement about the page in front of the user; nothing in Crabe proposes those links as things to go and read instead. The clause bites on a product that surfaces retrieved results as the offer — which is Spotter, and Spotter has already settled it negatively: a link it cannot resolve and show is not proposed, so Gemini grounding is out of its retrieval path (COORD, 2026-08-03).
What that decision does not relax: directLink and isProgrammaticallyResolvable() stand exactly as above. They are a terms-of-service constraint on fetching, independent of how anything is displayed.
Residual, stated rather than smoothed: the lateral axis does show its citations, because a retrieved claim the user cannot go and check is not retrieved in any meaningful sense. That display is evidence for a verdict, not a reading recommendation — which is the distinction the decision rests on. If a future surface starts offering those links as destinations, the question reopens.
v2 has six near-identical fetch functions (3 providers × 2 analyses). Each new analysis multiplies by three. v3 inverts this: providers implement a narrow interface, agents are written once.
interface Provider {
readonly id: ModelType
readonly capabilities: { structuredOutput: boolean; webSearch: boolean; local: boolean }
complete<T>(req: CompletionRequest<T>): Promise<CompletionResult<T>>
}
responseSchema; Claude has output_config.format on current models; Ollama has format: 'json'. Where none is available the provider falls back to prompt-plus-parse (including the existing markdown-fence strip) — but that is the provider’s problem, invisible to agents.webSearch: true can serve the lateral axis.structuredOutput: false is a statement about shape, not about syntax. Ollama’s format: 'json' guarantees the answer parses. It guarantees nothing about what is in it, which is why the local provider declares the capability false.
The local provider sizes the context window, because Ollama’s default silently starves the model. Ollama caps the context at 4,096 tokens whatever the model can hold — qwen2.5:7b holds 32,768 — and when a prompt overflows it keeps roughly num_ctx/2 of the tail. The system message is at the head, and it is the only place the required output shape is described. So the model does not fail the task: it never receives it, sees an article and nothing else, and does the obvious thing — it summarises. Valid JSON, coherent, answering a question nobody asked.
Measured 2026-08-09, qwen2.5:7b, one corpus page truncated progressively:
| page text | prompt_eval_count |
result |
|---|---|---|
| 19,000 chars | 3,963 | assessed correctly |
| 20,000 chars | 2,050 | summarised |
| 22,000 chars | 2,050 | summarised |
25,399 chars, num_ctx=16384 |
5,551 | assessed correctly |
prompt_eval_count collapsing to the same 2,050 on inputs of different sizes is the truncation signature. Raising the window fixed every failing page in the corpus: content ok 4 · not_run 0 · error 0. The model was never the problem, and “use a better model” was the wrong remedy — it is the right one only in the last case below.
Three consequences, all implemented:
context_length read from /api/show rather than assumed. Over-provisioning costs a little KV-cache memory; under-provisioning costs the instruction. A short page still uses the default, so nothing gets more expensive than it needs to be.prompt_eval_count is compared against a conservative lower bound on the input’s true token count — an estimate that can only under-report, so it invents no truncation. Below it, tokens went missing and the provider throws rather than return a verdict. This is the case that matters: an assessment of part of a page, presented as an assessment of the page, is exactly the claim the evidence tiers exist to prevent, and nothing downstream could detect it. Every truncated page in the corpus happened to fail loudly, but that is a property of this corpus and of Ollama’s current cut order, not a guarantee.The wait is derived from the page, not from a constant — and every model-driven failure ends with what to try next. Sizing the window traded a wrong-shape failure for a timeout on very long articles: the flat 120-second ceiling inherited from v2 has no relationship to the size of the job. Measured 2026-08-09 on a desktop GPU, qwen2.5:7b evaluates the prompt at 142 tok/s in a 4k window and 98 tok/s at 32k, so a 38,000-character article needs 125 seconds and failed by four — while doing exactly what it was asked. The local provider now derives its timeout from the prompt against a deliberately pessimistic 25 tok/s floor, keeping the old 120 s as a minimum and 600 s as a ceiling, because unbounded patience is not honesty, it is a panel that never returns.
That still leaves failures, and they must point somewhere. Every axis error from an agent that consulted the model carries a remedy, on any machine — withModelHint() in the orchestrator, applied at the single point where an agent’s throw becomes an error axis. Two properties hold it honest. It names both directions — a larger model holds the task better on a difficult page, a smaller one gets through a long one faster — because stating only one is precisely how the two diagnoses above went wrong. And it is never attached to an axis the model did not touch: Agent.usesModel is false on citations, which fetches links and reads status codes, so a remedy there would send the user to change a component that was not involved.
Recorded because the first diagnosis was wrong and the record should show it: the corpus split cleanly at 19,581 characters, which read as “small models lose the instruction on long pages” and pointed at the model. It pointed at a default. The length was a symptom of the cap, not a property of the model.
Which local model the user picks changes the verdict, and the product says so. Measured 2026-08-09 through the real provider path over the four-page corpus, same prompt, same code: qwen2.5:3b, qwen2.5:7b and mistral:latest agreed on two pages of four, and the disagreement fell on exactly the two where the genre carries the meaning. On the satirical page one said Opinion, one said Factual, and none said Fiction — satire read as fact, which is the most damaging answer this extension can produce. Size is not the axis either: the 7B model was the one that read the satire as factual and the 3B was not.
This is a different failure class from everything else in §5. A truncated prompt, a wrong shape, a timeout — all announce themselves. A confident wrong classification does not, and the tier system cannot catch it: assessed claims only “judgement about material the user can also see”, which stays true whichever model said it. Observed once outside the corpus: a local model classified a first-person essay as Fiction and justified it at 5/5 with “the text includes explicit labels such as ‘fiction’“, evidence it appears to have supplied itself. The contract held; the answer was still wrong.
Nothing in the code can detect this, so the response is the one this project uses everywhere else — say it where the decision is made, and stop it being dropped quietly. The settings page carries the measurement with its date and sample size, names the worst case, and refuses the shortcut that a larger model is safe; five invariants hold it in place, including that it sits inside the local-model section rather than beside the cloud keys. Left open, because it changes the output contract and Spotter consumes it: whether a classification produced by a local model should carry a caveat in the result rather than only in settings.
Model IDs must be current. v2 pins claude-sonnet-4-5-20250929 — a legacy model that supports neither structured outputs nor the current web-search tool. v3 defaults to claude-opus-5 and exposes the model as a user setting (with claude-sonnet-5 and claude-haiku-4-5 offered), because the user pays for it and the choice is theirs.
An agent is one axis’s worth of work:
interface Agent {
readonly axis: AxisId
readonly requires: { capabilities: Capability[]; consent: ConsentKey[] }
run(ctx: AgentContext): Promise<AxisResult>
}
Where this runs — corrected 2026-08-03, against the first draft of this spec.
The first draft said orchestration moves to the background service worker, so that closing the panel would not cancel a run. That is wrong on Chrome: the service worker is terminated when any single fetch takes more than 30 seconds, and it takes every other in-flight request down with it. A grounded LLM call routinely exceeds 30s — the provider pauses while the search runs — so the background is the one context where this work cannot live.
So: agents run in the side panel, which is an ordinary document with no such ceiling, and which is also the only context that can call chrome.permissions.request() (that needs a user gesture). The background keeps extraction and persisted state. The orchestrator itself takes its dependencies as arguments and does not know where it is running, so this is a wiring decision rather than a structural one.
The cost is real and is accepted for now: closing the panel cancels the run. The upgrade path, when that becomes worth its complexity, is an offscreen document — document lifetime, no 30s cap, survives the panel closing. It is deliberately not in this version.
Firefox is unaffected: it uses event pages, which have no equivalent cap.
Agents run concurrently and fail independently — the Promise.allSettled posture of v2, generalised. One axis erroring degrades that axis and nothing else.
Phase 3 was scoped, researched, and dropped. The research is the reason, so it is kept here rather than deleted with the feature.
The deterministic layer does not pay. C2PA appears on under 1% of news imagery — the most motivated category there is. Measured survival across a social platform’s CDN: 0% (n = 10,217). Strip-by-default at Mastodon, Bluesky, Meta, Cloudflare, Fastly, Cloudinary and next/image. No traditional camera manufacturer is in the conformance programme. And the security literature (UMBC/NSA, April 2026) concludes the specifications “do not achieve any of their claimed security goals” — AI images signed with a revoked certificate still validate. The cost was ~3 MB of gzipped WASM plus an offscreen document, and the current library only starts inside an extension by way of an acknowledged, unpatched Chromium gap. Two of the markers the first draft of this section named — xmp:CreatorTool and EXIF Software — were measured empty for DALL·E 3, Firefly and Imagen 3. They were wrong.
The reasoned layer cannot be made to hold. Detection by implausibility measures the gap between what a generator produces and what is physically coherent — and “good generator” means exactly “no gap”. The ceiling falls as generation improves. A vision model does have asymmetric value (a named inconsistency is checkable by looking; silence means nothing), but that is a supplement, never an axis.
And the target was wrong anyway. Authenticity is not a property of pixels; it is relational — where something came from, who vouches, where it appeared before. The dominant harm is not synthesis but miscontextualisation: a genuine photograph under a false date or caption, which no forensic method catches because the image is real.
The retarget was investigated and also fails, for a supply reason rather than a technical one. Exactly one API in the world returns image matches with dates (TinEye — verified live, 85.3 billion images, crawl dates back to 2008, and it matches crops). Every other option — Google Vision, Yandex, every Lens reseller — returns matches with zero temporal information, structurally: Lens is a visual-shopping product whose result entries carry price and in_stock. Bing’s API is retired. The C2PA registries are credential-gated, require a pre-decoded watermark, and postdate 2022 so cannot speak to an older photograph at all.
That leaves a single supplier with no free tier. Building the axis would mean promoting a paid service and dressing dateless alternatives as fallbacks when they cannot answer the question. That is the same unearned claim §1 exists to refuse, so the axis is dropped rather than shipped weak.
SCOPE_NOTICE is exported from core/types.ts and set on every AnalysisVector. It is data, not chrome, for two reasons: a limitation that lives only in a template is one a redesign silently drops, and any downstream consumer of a vector inherits the caveat with the data instead of having to know to re-add it. An invariant asserts it is present, that it names images, video and audio specifically, and that no axis exists that would imply media coverage.
Images are not extracted from the page at all. Collecting them with nothing to do with them would be collection without purpose.
JUDGEMENT_NOTICE is the scope notice’s sibling — same file, same mechanism, set on every AnalysisVector, rendered from the vector and never from the constant so a stored or exported result shows the reservation it was produced under.
It exists because §2 has a boundary this document did not previously state. The tiers describe how strong a claim’s evidence is; they say nothing about whether the model reasoned well from it. A judgement about text the reader can also see is assessed whether it is acute or absurd — that contract held perfectly on 2026-08-09 while a local model classified a satirical article as Factual, and while another justified calling a first-person essay Fiction with evidence it appears to have supplied itself. No test can catch this. §1’s defect was unverifiable recall dressed as measurement, and it was fixable in code; this one is not, so it is stated rather than engineered.
Two properties are held by invariants because both are easy to lose. It says the assessment itself is to be read critically, not only the page — the softer version would leave Crabe’s output exempt, which is precisely the deference the tool exists to interrupt. And it names no provider: scoping it to local models would imply a cloud model settles the matter, and on this corpus both were wrong in different places.
Céline’s decision and her framing, 2026-08-09, in answer to an open question left at the end of §5. My proposal was narrower and worse — a caveat attached to local-model classifications. Hers generalises correctly: the risk is not that some models are weak, it is that a verdict invites deference, and Crabe’s whole argument is against that.
Construction and validation stay apart. Tests that assert a desired outcome pressure the system into producing it.
test/invariants.* — asserts only what must hold to be faithful to this spec, regardless of outcome. Pass/fail. Examples: no outbound request is issued without its consent key (enforced at the network boundary, not by inspection); a tier never rises; not_run is never coerced to 0; score bounds; agent isolation (one failing agent cannot fail another); schema conformance of every provider’s output.
test/observe.* — runs a fixed corpus and reports. Judges nothing. Tier distribution per axis, agreement between recalled provenance and retrieved lateral reading, citation-resolution rates. Céline interprets; the harness does not.
The privacy invariant belongs in the first harness on purpose: no request without consent is a hard barrier verified mechanically, not a promise in a README.
lateral axis’s own reliability is assessed. Lateral reading returns sources; those sources have reliability too, and Crabe is a reliability tool. Recursing is obviously wrong; ignoring it is also wrong. Unresolved.citations should be a gate or a contribution. Currently specified as a gate (it only demotes). A page with no citations at all is absent, not a demotion — but a page whose citations are broken is a real negative signal. The asymmetry is deliberate and may need revisiting once observed.