Lossless Changelog
component

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.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 5 (1M context)
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.md

Why

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.