Membership, Not Reachability — Constraining Memo Citations to an Approved Source Set
Every gate in the pipeline asked whether a URL resolves; none asked whether it was approved. That single missing predicate is why the researcher, enricher, and validator have been fighting each other for months — a fabricated URL that happens to be live passes every reachability check ever written. This lands the membership predicate in both cleanup gates, no-ops citation enrichment in codified mode, strips out-of-set sources from fact corrections, adds an LLM-free SearXNG candidate seeder, and exposes six sidecar endpoints for the approval surface. Also fixes a pre-existing bug that was carrying every dropped URL into the final draft.
new(curation): membership predicate — approved_urls(), is_approved_url(),
load_deal_sources(), deal_is_codified()
new(gate): UNAPPROVED verdict in remove_invalid_sources, gated on
is_codified() and MEMOPOP_SOURCE_ENFORCEMENT
new(codified): citation_enrichment no-ops; fact_corrector strips
out-of-set source URLs
new(curation): src/curation/candidates.py — LLM-free SearXNG candidate
discovery; searxng/settings.yml + docker-compose.yml
new(api): src/server/sources_api.py — six deal-scoped endpoints for
the approval surface
new(curation): src/curation/serialize.py — Sources.md writer extracted
from tools/curate_sources.py so tool and sidecar share one
new(cli): state_from_output_dir() — the standalone validator now runs
the membership check too, inferring the deal from its path
new(tools): tools/audit_source_membership.py — read-only leak report
fix(citations): dropped citations no longer orphan their definition body
into the final draft
fix(server): the sidecar loads .env; it previously loaded nothing, so every
key-dependent endpoint depended on the Tauri app's inherited
environment
fix(deps): declare pytest-asyncio, which pyproject already configured
Schema: SourceEntry gains title, publisher, published_date, verdict,
verdict_reason. verdict is promoted from a YAML comment to a real field.
Executes context-v/loops/Frontloaded-Source-Approval-Loop.md tickets 1-6
(the orchestrator band). Plan of record lives one level up at
memopop-ai/context-v/plans/Constraining-Memo-Writing-to-an-Approved-Source-Set.mdWhy
A graph trace of this repo turned up the mechanical reason source curation
never held: only four files import the curation module. The writer,
all eight post-gate enrichers, both cleanup gates, the citation validator,
and the fact corrector have no reference to Sources.md at all. The
analyst's approved set was a file one agent read — not a constraint the
pipeline enforced.
remove_invalid_sources.validate_url() runs three checks: a hallucination
regex, an HTTPS GET, a soft-404/paywall body sniff. All three ask does
this URL resolve? None asks was this URL approved?
That asymmetry is the whole conflict. A producer agent can emit any URL; the gate can only reject the ones that happen to be dead. A live-but-wrong URL — a real McKinsey report that says nothing about this company, a real article about a different Sava — passes cleanly. Inventing a live URL is easy, so the producers win by default. The validator was never losing an argument; it was answering a different question.
context-v/explorations/Curating-only-valid-Sources-across-Runs.md reached
the same conclusion from the other side on 2026-06-08 (65 fabricated
example.com URLs in a run where mode: codified was set). Its
prescription — the harvester/writer split — is still right and still
unbuilt. This is the cheap down-payment: rather than rebuilding retrieval
so bad URLs can't be produced, make the gate reject anything not on the
list. Invention becomes ineffective rather than impossible.
What landed
The predicate. approved_urls() returns a canonicalized set, reusing
best_sources.canonical_url so membership survives trailing slashes,
www., tracking params, and http/https drift. A fourth verdict,
UNAPPROVED (-5), joins the ladder in remove_invalid_sources — which is
the agent behind both cleanup_research and cleanup_sections, so both
gates inherited it at once.
Presence in a codified Sources.md is the approval; verdict is a
revocation field, not a grant. This is load-bearing: every Sources.md
written before the verdict field existed carries no verdict, and requiring
verdict == "approved" would have emptied the approved set for all three
existing codified deals and stripped every citation from each.
Enforcement is staged through MEMOPOP_SOURCE_ENFORCEMENT
(off | flag | enforce), defaulting to flag — the first real run
should show the size of the leak before anything starts deleting citations
out of drafted prose. An unrecognized value falls back to flag, never
off: a typo must not silently disable the check.
Ordering that matters. The membership check runs before the URL
recovery pass. Recovery swaps a drifted URL for a new one that is, by
construction, absent from Sources.md; checking membership afterwards
would mark every recovered URL unapproved and undo the rescue. Ordered
this way, recovery is attempted only for approved-but-broken URLs and
anything it finds inherits that approval — and a fabrication that is also
dead never reaches recovery at all, so we don't launder it into a live URL.
Closing the emitters, precisely. citation_enrichment full-no-ops in
codified mode: its documented job is asking Perplexity for NEW sources,
and it runs after the codified researcher finished obeying the set. This
resolves Decision Point #2 of context-v/plans/Citation-Coverage-Promoter.md,
open since May.
fact_corrector is not disabled — it keeps correcting facts, and only
its out-of-set source_url is stripped. Dropping the claim would discard
a correct fact fix over a sourcing problem; the correction stands on the
approved corpus instead. fact_verifier is deliberately left alone: it
only reports a proposed source, it never writes one into prose. The
corrector is the injection point, so that is where the filter belongs.
No LLM in the candidate path. Per operator intent — "Perplexity just
invents sources, and going through them takes forever" — candidates.py
derives search terms from the deck analysis by string manipulation and
fires them at SearXNG. A URL from a search index cannot be hallucinated;
the property is structural, not probabilistic. A test asserts no LLM SDK
appears in any import statement in that module, so the guarantee can't
rot. Results are capped per-term and globally and deduped by canonical
URL — volume is a first-class requirement here, not polish. Already-known
URLs are flagged rather than hidden.
The bug we found on the way
Reproduced with enforcement off, no Sources.md, and an ordinary hard
404: a dropped citation left its definition body behind.
remove_citation_references's Case-1 lookahead (?=[\s\.,;:\)\]]|$)
accepts the : that follows a definition marker, so [^2] was stripped
off the front of its own definition line. remove_citation_definitions
then couldn't recognize the line — it matches on [^N]: — and the orphan
was concatenated onto the previous definition during assembly:
[^1]: 2026, Jan 01. [Good](https://good.test/a).: 2026, Jan 01. [Dead](https://dead.test/gone).Every memo where any citation was ever dropped may carry the dropped URL
into the shipped draft, glued onto its neighbor. Definition lines are now
untouchable by the inline-reference pass. Worth spot-checking recent
exports for the .: signature.
Verification
103 passed, 1 skipped. Six new test modules. The regression that mattered
most — a non-codified run producing byte-identical output — is asserted
directly, as is a read-only check that all three real codified deals keep
a non-empty approved set (7, 33, 33 sources; every entry already carries
the title that URL recovery requires).
Live sidecar smoke test: boots, /healthz OK, search-sources degrades
correctly with SEARXNG_URL unset, and GET /firms/humain/deals/ImmuneCo/sources
reads the real aggregated worksheet. No firm-private data was written.
Two gaps found while writing the test instructions
The standalone validator skipped the gate entirely. The membership check
went into remove_invalid_sources_agent, which takes workflow state.
remove_invalid_sources_standalone takes only an output directory — and
that is how an analyst re-validates an existing memo. The gate would have
appeared to do nothing on the most common manual path. state_from_output_dir
now infers (firm, deal) from io/<firm>/deals/<deal>/outputs/… (and
_failed-runs/), returning {} for the legacy non-firm-scoped layout.
The sidecar never loaded .env. Only src/main.py and the standalone
CLI did. Every key-dependent endpoint in the server — brand fetch, memo
generation, and now URL recovery — silently depended on whatever
environment the Tauri app inherited, which contains none of these keys when
launched from Finder rather than a shell. run() now loads
<orchestrator-root>/.env the same way the CLI paths do. URL recovery went
from "TAVILY_API_KEY is not set" to a live 100%-title-match recovery
immediately after.
What's next
Tickets 7–10 (the memopop-native surface) are logged in this repo's
parent: memopop-ai/changelog/. The enforcement half shipped first on
purpose — a curation UI whose output nothing enforces has no observable
failure mode and cannot be tested.
Before flipping MEMOPOP_SOURCE_ENFORCEMENT=enforce on a real deal, note
the counterweight: Citation-Coverage-Promoter.md measured 7-of-33 curated
sources actually cited before any ceiling existed. Enforcement is the
ceiling; that plan is the floor. Expect coverage to look worse before it
looks better.
