A Source-Approval Surface in Memopop Native — Curation Before the Run, Not After
Adds an `approving_sources` stage to the native app's flow machine, between defining the deal and generating the memo, plus the surface that fills it: approve / deny-with-reason / re-search / preview / add-by-paste, backed by SearXNG candidate discovery. Curation is frontloaded because a memo written against an unconstrained corpus cannot be fixed by approving sources afterwards. Also wires six new routes through the Rust dispatcher, which is an allowlist — new sidecar endpoints are invisible to the webview until they are added to it.
new(native): FlowStage 'approving_sources' between create_deal and
ready_to_run, with startApprovingSources() / sourcesSettled()
new(native): src/lib/stores/sources.svelte.ts — approval state, verdicts,
search, preview, recovery, commit
new(native): src/lib/components/SourceApproval.svelte — the surface
new(rust): six routes added to the api_dispatch allowlist, all forwarding
to the sidecar
edit(native): DealCreationModal gains "Review sources first" beside
"Generate memo"
Executes context-v/loops/Frontloaded-Source-Approval-Loop.md tickets 7-10.
Orchestrator half (tickets 1-6) is logged in
apps/memopop-orchestrator/changelog/2026-08-06_01.md.Why frontloaded
The orchestrator already halts mid-pipeline for curation
(source_aggregator.py:204 prints 🛑 HALTING PIPELINE), but it halts
after the research band has run — by which point a language model has
already proposed the candidate list. Per operator intent:
"What happens now is Perplexity just invents sources, and going through them takes forever."
Two failures in one sentence: the candidates are fabricated, and there are too many. Both follow from an LLM sitting in the candidate path. Moving approval to the moment the deal is defined, and sourcing candidates from a search index instead, removes both at once — a URL returned by SearXNG came out of an index, so it cannot be hallucinated. The property is structural.
That also meant the two-job split an earlier draft of the plan called for became unnecessary: if candidates don't come from the pipeline, curation is standalone and the run is never interrupted.
The surface
One stage, one full-width view (not a modal — approving 40 sources is list work and a dialog that size fights the task). Per source: Open in the system browser, Preview inline via Jina, Approve, Deny with a reason, and Re-search.
Re-search is the third option, not a variant of deny: the article is real,
the link is not. It calls attempt_url_recovery and shows the matched
title and the similarity score alongside the recovered URL, because the
analyst is being asked to accept a URL swap and needs the evidence, not
just a link. Verified live — a fabricated dead URL with a real title
recovered the actual page at a 100% title match.
Deny reasons (dead / paywalled / wrong entity / low quality / off topic /
not-a-source) are captured rather than discarded. They are the training
data for tuning verdicts later, and rejected entries are written through to
Sources.md so a later session sees what was already turned down instead
of re-reviewing it.
Add-by-paste is a single field and a single button at the top of the view. It is the action reached for most often, and it competes directly with letting an agent guess.
Search results already in the list are flagged already in your list
rather than hidden — hiding them looks like a search failure.
The Rust dispatcher is an allowlist
Worth recording, because it is not obvious and it silently breaks new work:
api_dispatch in src-tauri/src/api/router.rs is a match (method, path)
whose fallback is _ => Err(not_found). A new FastAPI endpoint is
invisible to the webview until a match arm is added — the request never
reaches Python at all.
Two further constraints on anything added there:
forward_to_sidecarrequiresrepoPathin the request body (it uses it to lazy-spawn the sidecar) and strips it before forwarding, because the FastAPI models don't declareextra=allow.That applies to GETs too —
repoPathtravels in the body, not a query string.withRepo()in the sources store centralizes this so a future call can't forget it.
Verification
svelte-check: zero errors and zero warnings in the new files (the one
reported error is a pre-existing @ts-expect-error in vite.config.js).
cargo check clean. bun run build clean.
End-to-end against the live sidecar with the exact payloads the UI sends: reading the real avalanche/ChromaDB deal returned 33 codified sources with titles; deck-derived search returned real candidates and correctly flagged trychroma.com and the chroma-core GitHub repo as already-known; free-text search returned real market-research URLs; URL recovery returned a live match. No firm-private data was written.
Not yet done: a click-through in the actual Tauri window. Playwright MCP isn't wired in this repo, and MCP servers only load in the following session. Everything above is verified at the type, compile, build, and HTTP layers — the button-by-button walk is the outstanding rung.
