Lossless Changelog
product

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.

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

  1. forward_to_sidecar requires repoPath in the request body (it uses it to lazy-spawn the sidecar) and strips it before forwarding, because the FastAPI models don't declare extra=allow.

  2. That applies to GETs too — repoPath travels 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.