# Audit 2026-07 — where citadel stands, and what is still missing

An extensive point-in-time audit (2026-07-20, v0.4.0 + unreleased `refresh`): an internal
code-level review, a survey of comparable implementations (LLM-maintained knowledge bases and
memory systems), and a scan of the surrounding tooling landscape (MCP servers, local retrieval,
agentic ingest) — synthesized into one ranked gap backlog.

**TL;DR.** Citadel's quadrant — *verified, cited, human-readable synthesized knowledge with
explicit maintenance lifecycles* — was nearly empty until Karpathy's viral "LLM Knowledge Bases"
post (2026-04) spawned a wave of direct implementations, the largest at ~15k stars within months.
Even so, no surveyed system combines source-grounded ingestion, machine-enforced per-fact locator
citations, deterministic offline verification, and staged all-or-nothing promotion — that stack
remains citadel's moat. The exposure is on the **access** side, not the knowledge side: ranked
retrieval (the FTS5 swap already scoped in `store_core.search()`), a conversational-capture
bridge, and cost observability are the three gaps most likely to dominate any side-by-side
comparison — and each has a design that closes it without compromising the provenance guarantees.

---

## 1. Internal audit

### 1.1 What the repo already tracks as open

- **The search upgrade is the one explicit "Future" marker in the code** —
  `citadel/store_core.py:185` scopes the swap ("replace this body with SQLite FTS5 bm25 — signature +
  MCP surface stay identical") behind the single `search()` seam. Nothing has been started.
  *✅ Resolved 2026-07-23 — backlog #1 shipped (see § 4).*
- **Unfilled model-benchmark cells** — the README's Opus/Sonnet/Haiku matrix: leuchtfeuer,
  pemberley, and clockwork have never been benchmarked on any tier; beverages/flurfunk lack
  Opus/Sonnet runs. Each filled cell is a single non-deterministic ingest (self-flagged).
- **`refresh` is unreleased** — in CHANGELOG `[Unreleased]` while `__version__` is 0.4.0; a
  release PR is a pending act.
  *✅ Resolved 2026-07-25 — released in 0.5.0 together with backlog #1–#15 (see § 4).*
- **The verify-corpus "two optimization lanes" backlog is ephemeral** — every grading miss is
  routed into a creation lane (rules/prompts) or a retrieval lane (search), but the lane findings
  live only in per-run grade reports; nothing is persisted in the repo, so misses that didn't
  become fixes are lost.
  *✅ Resolved 2026-07-24 — backlog #7 shipped (see § 4): `docs/verify-corpus-backlog.md` is the
  committed per-run ledger both grading skills now append to.*
- **Documented, accepted trade-offs** (missing by design, not oversights): chunked-source
  all-or-nothing (a failure at segment N discards N−1 segments' work — *the WORK half was closed
  2026-07-24 by backlog #9's resume checkpoints; the promotion half stays all-or-nothing by
  design*), no self-update command,
  `curate`/`refresh`/`view` CLI-only, no offline image reader (*the PDF half of this line closed
  2026-07-24 with backlog #10 — a PDF's text layer now extracts offline through the bundled
  pypdf; images stay agent-read*), best-effort OLE salvage,
  best-effort wiki-git, provider-side rate limiting.

### 1.2 Code-level findings

1. **Contradiction "detection" is marker-counting, not detection.** `citadel/lint.py` counts existing
   `> [!CONTRADICTION]` callouts, and curate's `contradiction` reason plans work only for
   already-flagged callouts. An unflagged semantic contradiction between two pages is invisible
   to every offline layer — surfacing it depends entirely on the ingest agent noticing at write
   time (exactly the miss the Haiku benchmark shows).
2. **Locators into PDF/Office/image sources are never offline-verified.** `lint.check_locators`
   skips all non-text extensions (`citadel/lint.py:103` `_NON_TEXT_EXTS`), and `wiki_raw` raises
   "has no offline text extraction — the ingest agent reads it directly" for PDFs/images
   (`citadel/rawsource.py:116-120`). For that whole source class, "every locator verifiable"
   silently degrades to "trust the agent".
   *(The new audio class — backlog #3, 2026-07-23 — deliberately avoids joining this list: its
   locators verify offline against the cached whisper transcript.)*
   *✅ PDF half resolved 2026-07-24 — backlog #10 shipped (see § 4): pypdf is a bundled runtime
   dep, so PDFs ingest via a cached text-layer extraction by default and their `lines A-B` locators
   verify offline exactly like audio's. Office (`p. N`) and image locators stay agent-verified —
   the remaining, now much smaller, slice of this finding.*
3. **Token/cost usage is discarded.** `citadel/llm.py:630-643` parses the claude `--output-format json`
   envelope only for `is_error`; its usage/cost fields are dropped. `refresh` is *marketed* as
   budget management, yet the `--limit` unit is sessions, not tokens or cost, and nothing records
   what a session actually cost.
   *✅ Resolved 2026-07-23 — backlog #2 shipped (see § 4): sessions now report their
   backend-stated cost/usage, stamped per source into the manifest and totaled on every report.*
4. **`CITADEL_INGEST_MODEL` only applies to the `claude` backend** — silently inert on
   copilot/gemini, and `doctor` does not warn about the combination.
   *✅ Resolved 2026-07-24 — backlog #6 shipped (see § 4): `doctor` now WARNs when the knob is
   explicitly set on a copilot/gemini backend, naming `COPILOT_MODEL`/`GEMINI_MODEL` as the real
   selectors; the knob itself stays claude-only by design.*
5. **Config is frozen at import and process-global.** A long-lived `citadel serve` never sees
   `.env` edits; one process = one workspace; and ingest's staging redirect mutates
   `config.WIKI_DIR` + `os.environ` in place — safe under the strictly-serial design, a hard
   blocker for any future parallel ingest or multi-workspace server.
   *✅ The parallel-ingest blocker resolved 2026-07-25 — backlog #11 shipped (see § 4): the wiki
   redirect is a `ContextVar` behind `config.wiki_dir()`/`index_path()`/… and child processes get
   their wiki through an explicit per-spawn env, so N sources stage concurrently in one process.
   The other two halves stay open by design-choice, not by blocker: settings are still read once at
   import (a `serve` process does not see `.env` edits), and one process still serves one
   workspace.*
6. **MCP read tools reload the entire wiki from disk on every call** (`store.load()` per call,
   no cache) — see § 1.3 for the scale impact.
   *✅ Resolved 2026-07-24 — backlog #15 shipped (see § 4): `citadel/pagecache.py` keeps a
   long-lived reader's last load in memory behind a stat-only revalidation walk (~4 ms vs ~700 ms
   at 1000 pages), with search's per-page term-frequency tables memoized on the same snapshot.
   Nothing is persisted, and the mutating lifecycles still read every byte from disk.*
7. **`citadel/extract.py`/`citadel/extract_ole.py` swallow everything** — `extract_text` returns `""` on ANY
   error, so an encrypted or corrupt Office file is indistinguishable from a truly text-free one;
   both land as `unreadable` with the same generic detail.
8. **Two divergent search implementations** — the Python `search()` is OR-semantics token
   overlap; the viewer's JS search is AND across fields with `tag:`/`type:` operators. The same
   query can hit in the viewer and miss over MCP/CLI.
   *✅ Resolved 2026-07-23 — the Python `search()` now speaks the viewer's query grammar
   (AND + `tag:`/`type:` operators; backlog #1).*
9. **`log.md` is unbounded and re-read on every append** — O(log size) per source ingested,
   forever.

### 1.3 Retrieval-layer assessment

Today's `search()` is purely lexical bag-of-words presence: a one-suffix-layer stemmer, field
weights (title 3.0 → body 1.0), smoothed per-query IDF, a flat substring bonus. **No term
frequency, no length normalization, no BM25 saturation, no phrase/proximity, no fuzzy match, no
embeddings, no persisted index** — and every query re-walks and re-parses the whole wiki from
disk. Findability at showcase scale (tens of pages) is graded and good; at 1000+ pages both
latency (O(corpus disk I/O) per MCP call) and ranking will degrade first. The intended fix (FTS5
BM25 behind the unchanged seam) is scoped in-code but unstarted.
*✅ Ranking resolved 2026-07-23 — real BM25 (saturation, length normalization, smoothed IDF)
behind the unchanged seam; see the backlog #1 status note for why it is pure-Python rather than
FTS5. **Latency resolved 2026-07-24 (backlog #15)**: the per-call corpus reload — finding 6's
cache gap, and the last open piece of this assessment — is now a stat-only revalidation of an
in-memory snapshot, measured at 1000 pages as `load()` ~700 ms → ~9 ms and a full
`wiki_search` ~1.4 s → ~50 ms, with no persisted index. What remains here is not latency but
recall: oblique-phrasing retrieval (§ 2.2 #1) stays lexical-only by design.*

### 1.4 Test-coverage map (thin spots only)

Coverage is broad (44 test files, ~1:1 with modules, codecov patch gate 80%). The relative gaps:

| Surface | State |
|---|---|
| `citadel/extract_ole.py` (222-line hand-rolled CFBF binary parser) | 2 offline tests; real coverage is the kontor corpus, which needs a live LLM run |
| `viewer/app.js` (~2k lines: search, graph layout, popovers, keyboard nav) | zero automated tests — Python build/bundle only |
| `citadel/lint.py` | no dedicated test file; functionally covered but scattered across four files |
| `citadel/store_core.py` `neighbors_text`, `append_log`, `citadel/catalogs.py`, `citadel/linkgraph.py` | indirect/thin only |

*✅ Rows 1–2 resolved 2026-07-24 — backlog #8 shipped (see § 4): a CFBF-writer fixture pins the
container machinery (mini-FAT, DIFAT, multi-sector chains, cycle/truncation guards) offline, and
a headless-Chromium smoke test drives the built viewer's JS in CI.*

### 1.5 Operational gaps

- **Per-source staging cost is O(wiki size) × pending sources** — each source (and curate
  cluster) full-copies and re-hashes the live wiki; a 100-source ingest of a 1000-page wiki
  copies the wiki ~200×. Nothing (hardlinks, dirty-set copies) mitigates it yet.
- **Strictly serial** — no intra-run parallelism knob (blocked by finding 5), while the per-source
  staging copy is already exactly the isolation primitive parallel ingest would need.
  *✅ Resolved 2026-07-25 — backlog #11 shipped (see § 4): `citadel ingest --jobs N` /
  `CITADEL_JOBS` runs N sources at once on exactly that primitive, with promotion serialized and
  base-aware. The default stays 1 — the trade-off it buys back is cross-linking, not safety.*
- **No watch mode / scheduler** — `refresh --min-age-days` is designed for external cron, but
  scheduling is entirely the user's problem; no file-watcher exists.
  *✅ Recipe half closed 2026-07-24 — backlog #14 shipped (see § 4): `docs/recipes.md` documents
  cron/systemd/Task-Scheduler/scheduled-agent setups driving `ingest` + `refresh --min-age-days`.
  A file-watcher stays deliberately unbuilt — the page states the schedule-beats-watcher stance.*
- **`wiki_ingest` over MCP blocks the stdio loop** for the whole multi-source run, with no
  progress or async-job surface.
- **No privacy story beyond local-model recipes** — wiki, raw, manifest, and unredacted session
  transcripts (`CITADEL_LLM_LOG_DIR`) are plaintext.
- **Recovery is best-effort** — wikigit is advisory, no restore path, no manifest backup.
  *(The chunked-source half — "loses all paid-for segments on a late failure" — closed 2026-07-24
  with backlog #9; the wikigit/restore/manifest-backup gaps stay open.)*

---

## 2. Landscape: comparable implementations

Surveyed: the Karpathy-lineage implementations (below), basic-memory, khoj, mem0, Letta
(MemGPT), cognee, Graphiti/Zep, txtai, LlamaIndex, Obsidian ecosystem (Copilot, Smart
Connections, Bases, vault MCP bridges), Logseq, Notion AI, Tana, Capacities, Mem, Anthropic
memory (app, API memory tool, Claude Code auto-memory), ChatGPT/Gemini memory, and the MCP
memory-server registry landscape. *(The Karpathy-lineage, basic-memory/khoj, and
assistant-memory/MCP-server lanes were verified against primary sources on 2026-07-20; the
mem0/Letta/cognee/Graphiti/commercial-PKM lane is synthesized from project knowledge current
through early 2026 — spot-check fast-moving details there.)*

**Key finding: the pattern now has direct competitors.** Karpathy's "LLM Knowledge Bases" post
(2026-04-03, [tweet](https://x.com/karpathy/status/2039805659525644595)) and his follow-up
[idea gist](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f) — three layers
(raw → wiki → schema), three operations (ingest/query/lint), "compile knowledge once and keep it
current" instead of RAG — went viral and spawned implementations within months:

- **[nashsu/llm_wiki](https://github.com/nashsu/llm_wiki)** (~15k stars): Tauri desktop app;
  ingests PDF/Office/EPUB/images/web clips (Chrome extension); per-page `sources:[]` frontmatter
  + numbered citations; tokenized search + optional LanceDB vectors + 4-signal graph expansion;
  **human review queue**, community detection, cascade deletion, lint tools; MCP server;
  Obsidian-compatible.
- **[lucasastorian/llmwiki](https://github.com/lucasastorian/llmwiki)** (~1.4k stars): MCP-first
  (full-text search + citation-graph tools), Chrome clipper, footnote citations, Next.js viewer;
  distinctive: **scheduled nightly Claude Routines** autonomously fold new sources in.
- **[Astro-Han/karpathy-llm-wiki](https://github.com/Astro-Han/karpathy-llm-wiki)**: an
  agent-skills package (raw→wiki, ingest/query/lint, cited answers) — the "share the idea, not
  the app" lane. Plus a ["v2" design gist](https://gist.github.com/rohitg00/2067ab416f7bbe447c1977edaaa681e2)
  adding decay, supersession links, and hybrid retrieval past ~200 pages, and several smaller
  entrants (MindBase, Eva, okf-gem, …).

The rest of the landscape still splits into (a) memory layers extracting facts from
*conversations* (mem0, Letta, ChatGPT/Claude memory), (b) RAG/search over *unmodified* notes
(khoj, Obsidian plugins, txtai), and (c) markdown KBs written incrementally *during chats*
(basic-memory — which, note, ships **hybrid FTS5+FastEmbed-vector retrieval by default**).
Maintenance-over-time machinery exists only in the Karpathy lineage — and none of the new
entrants has machine-enforced citation validation, a locator grammar, transactional staged
promotion, deletion-propagation, budgeted refresh, or graded evaluation corpora. Practitioner
consensus from the gist thread names **staleness/drift as the #1 failure mode** — exactly what
citadel's reconcile+refresh already address.

### 2.1 Where citadel is ahead

1. **Per-fact cited provenance with locators, machine-enforced** — no surveyed system stores
   knowledge where every fact carries a verifiable citation (line ranges / § headings) into an
   immutable source, with the `[^sN]`/`[^llmN]` raw-vs-model distinction and a validation gate.
   The new Karpathy-lineage tools cite at page/footnote granularity but nothing *checks* the
   citations; Graphiti's edge→episode links lack locators and human readability; LlamaIndex
   proves locator citations only at *answer* time — citadel does it at *storage* time.
2. **Deterministic offline verification** (check/lint with CI exit codes) — memory quality
   elsewhere is vibes + benchmarks.
3. **Four explicit maintenance lifecycles** (reconcile, deletion-propagation, curate, budgeted
   refresh) — mem0's ADD/UPDATE/DELETE loop, Letta's sleep-time agents, and Graphiti's temporal
   fact-invalidation are each a partial analogue; none combine all four, none staged with rollback.
4. **Transactional ingestion** (staging, diff-by-hash, promote-once, run locks) — competitors
   mutate a DB in place.
5. **No API key / BYO agent CLI; zero-infra portability.**
6. **Human-readable, git-diffable knowledge as the artifact** — shared only with basic-memory and
   the Obsidian world, neither of which synthesizes.
7. **Adversarial-injection resistance as a graded corpus** — no surveyed competitor publishes
   prompt-injection hardening tests for its ingestion path, despite it being a live attack class.

### 2.2 Common in the landscape, absent in citadel (ranked)

1. **Semantic / hybrid retrieval** — near-universal (khoj, mem0, cognee, Graphiti, txtai,
   basic-memory, Copilot, Smart Connections, mcp-memory-service, nashsu/llm_wiki's optional
   vectors). Citadel is lexical-only. Partially mitigated
   by curated aliases/cross-links and the query-iterating agent consumer, but oblique-phrasing
   recall is the single biggest measurable gap (and a verify-corpus lane).
2. **Automatic capture from conversations** — mem0, Letta, basic-memory, ChatGPT/Claude memory
   all grow the KB as a side effect of chatting; citadel only eats files dropped in `raw/`. A
   conversation-transcript→`raw/` bridge or a `wiki_remember`-style attributed capture tool would
   close it without breaking provenance.
   *✅ Closed 2026-07-24 — backlog #5 shipped both halves (see § 4): the `wiki_capture` tool +
   `citadel capture` CLI twin, and the documented transcript→`raw/` convention
   (docs/capture.md).*
3. **Chat-with-your-KB end-user UI** — citadel's "any MCP client is the UI" is defensible in
   2026, but the offline viewer is browse-only.
4. **Connector-based ingestion** (Notion/Slack/email/web-clipper, watch folders) — even a generic
   "fetcher writes into raw/" convention would slot into the existing lifecycle.
5. **Sync / mobile / multi-device** — markdown+git makes DIY sync easy; it is undocumented as a
   recipe.
   *✅ Closed 2026-07-24 — backlog #13 shipped (see § 4): `docs/recipes.md` documents the
   one-writer-many-readers rule, the git lanes (wiki-history remote + whole-workspace), the
   sync-service scoping, and three phone-reading paths.*
6. **Voice / meeting capture** — entirely absent (see § 3.3: transcript ingest is the natural
   next format).
   *✅ Closed 2026-07-23 — backlog #3 shipped whisper transcript ingest for audio/video files
   dropped into `raw/`; live meeting capture (recording) stays out of scope — citadel eats files.*
7. **Formal temporal validity model** — Graphiti's bi-temporal `valid_at`/`invalid_at` preserves
   "what was believed when"; citadel handles supersession in prose + Open Points, not queryably.
8. **Hosted / multi-user / permissions** — a real landscape norm, likely out of scope by
   philosophy.
9. **Always-on related-content surfacing** (Smart Connections' pane) vs. on-demand `neighbors`.
10. **Decay/forgetting policies** — refresh re-verifies but nothing ages out; arguably a feature.

---

## 3. Tooling landscape

### 3.1 MCP surface

Modern peers (cyanheads' Obsidian server, Basic Memory, the reference memory server, Notion's
hosted server) ship capabilities citadel's stdio-tools-only server lacks: **prompts** (packaged
workflows exposed as slash-command-like entries), **resources** with `listChanged`/subscribe
notifications (pages as addressable URIs), **Streamable HTTP** transport (+ auth) for
claude.ai/web/multi-device reach, elicitation, and experimental long-running tasks (the natural
fit for a non-blocking `wiki_ingest`). Citadel already does two modern things most peers skip:
tool annotations and `initialize.instructions`.
*✅ Prompts + resources shipped 2026-07-24 — backlog #4 (see § 4); ✅ **Streamable HTTP shipped
2026-07-25 — backlog #12**: `citadel serve --http` (token-mandatory, loopback-default,
optional read-only) puts the same surface on an HTTP endpoint a hosted client can reach.
subscribe/`listChanged`, elicitation, and long-running tasks stay open — the last of which is still
the natural fit for a non-blocking `wiki_ingest` (§ 1.5), now that a transport exists that could
carry one.* Every serious memory product now leads with an
MCP server — the MCP-first design is squarely on trend, and the 12-tool read surface is richer
than most. Peers also all have a write surface; if citadel ever adds one, it should go through
the `[^llmN]` provenance lane (e.g. an append-only observation/open-point tool the next curate
pass folds in properly), never raw file edits.

### 3.2 Retrieval without a vector DB

The 2026 evidence *validates* citadel's lexical bet: Anthropic replaced vector search in Claude
Code with grep-style agentic search in 2025, most coding agents followed, and agentic keyword
search measures near-RAG faithfulness with zero vector store. Since citadel's consumer is always
an agent that iterates queries, embeddings matter less than they look — but **ranked** search
still beats substring matching on first-shot hit rate. The pragmatic ladder:

1. **SQLite FTS5** — zero new dependencies (stdlib `sqlite3`), BM25 ranking, phrase/prefix/NEAR,
   per-column weighting (boost title/aliases over body). At personal-wiki scale, an in-memory
   FTS5 table built at `load()` keeps "the wiki IS the database" literally true — no on-disk
   index, no staleness. Slots behind the existing `search()` seam exactly as scoped.
2. **A lightweight reranker** (FlashRank-class: no torch, ~4 MB, sub-20 ms for 50 candidates)
   over the top lexical hits — quality bump with no index at all.
3. **Hybrid as an opt-in extra at most** — `pip install cite-citadel[semantic]` → fastembed +
   sqlite-vec + reciprocal-rank fusion, only if the verify-corpus oblique-query lane proves
   lexical+aliases insufficient. Never a required dep. Avoid rank-bm25 (strictly worse than
   FTS5) and BM25S (static-corpus assumption fights the incremental lifecycle).

### 3.3 Agentic ingest patterns

- **Cost/metrics surface**: claude's `--output-format json` returns `total_cost_usd` + usage;
  gemini has `--session-summary`. Parsing these per session and stamping cost into the manifest
  (surfaced in `status`/reports) is near-zero code and answers "what does my wiki cost to
  maintain" — directly serving refresh/curate's budget framing (finding 1.2.3).
  *✅ Done 2026-07-23 — backlog #2.*
- **Hermetic sessions**: claude's `--bare` skips user hooks/CLAUDE.md/MCP discovery — today a
  user's `~/.claude` config leaks into citadel's agent sessions; `--bare` + explicit flags is the
  reproducibility fix.
  *✅ Done 2026-07-24 — backlog #6: `CITADEL_HERMETIC` (default on) appends `--bare` whenever the
  installed claude binary advertises it in `--help` (probed once per binary, exactly like the
  `--session-summary` probe); copilot/gemini expose no equivalent flag today.*
- **Resumability**: session-ID capture (`--resume`) could make chunked large-source ingest
  resumable at segment N instead of the current retry-from-segment-1 trade-off.
  *✅ Done 2026-07-24 — backlog #9, though the scoped mechanism turned out to be the wrong one:
  `--resume` is claude-only AND cannot work here, because the staging tree the conversation was
  editing is deleted on every failure path (a resumed conversation would act on files that no
  longer exist). Persisting the segment's file DELTA is both backend-agnostic and strictly better —
  it keeps the work rather than re-running it.*
- **Parallelism**: per-source staging is already the isolation primitive; a bounded `--jobs N`
  (promotes serialized under the run lock) is the credible throughput win — blocked today by the
  process-global config mutation (finding 1.2.5).
  *✅ Done 2026-07-25 — backlog #11, with one correction to the scoped design: serializing promotes
  is necessary but NOT sufficient. A promote also has to know WHICH wiki it started from — the
  original prune ("delete whatever live has and staging does not") would have deleted every page a
  concurrent source had just added. The shipped promote is base-aware, and a page that moved under
  a session sends that source to a serial re-run instead of letting a stale session win.*
- **Audio/video is the largest un-ingestable content class left**, and fits the no-SDK philosophy
  perfectly: a `CITADEL_AUDIO_SUPPORT` knob shelling out to a whisper-CLI binary on PATH (exactly
  like the agent-CLI seam), caching the transcript as the text the session reads, plus a
  `formats/transcripts.md` brief (timestamps as locators, speaker attribution per the flurfunk
  rules).
  *✅ Done 2026-07-23 — backlog #3. "Timestamps as locators" resolved without a new locator form
  (which `genres/transcript.md` rightly forbids): every transcript line carries its `[HH:MM:SS]`
  stamp, citations use ordinary `lines A-B`, and the content-addressed cache doubles as the
  offline verification text for lint/`wiki_raw`.*
- **PDF text-layer pre-pass**: optional pypdf (pure Python, BSD) extraction would give real
  `lines A-B` locators and correct chunker sizing while keeping agent-native reading for figures;
  avoid AGPL/model-heavy parsers as deps. Citadel's hand-the-PDF-to-the-agent design is otherwise
  the 2026-idiomatic "VLM does the OCR" pattern.
  *✅ Done 2026-07-24 — backlog #10. One deviation from the scoped "optional extra": pypdf ships
  as a BUNDLED runtime dep, because PDFs are among the commonest `raw/` classes, so the
  offline-verifiable path is on by default rather than behind an opt-in install. pypdf's
  pure-Python/BSD/no-transitive-deps profile keeps that cheap; the fallback path is still the
  pre-existing agent-native behavior when the pre-pass is off or a PDF has no text layer.*

---

## 4. Consolidated backlog — "was fehlt uns noch", ranked by leverage ÷ effort

| # | Gap | Why now | Where it lands | Effort | Status |
|---|-----|---------|----------------|--------|--------|
| 1 | **FTS5/BM25 ranked search** behind the `search()` seam (in-memory table at `load()`; port the viewer's AND-semantics + `tag:`/`type:` operators so the two search implementations converge) | Scoped in-code since the seam was written; biggest measurable retrieval gap; zero new deps | `citadel/store_core.py` | S–M | ✅ 2026-07-23 — shipped as in-memory **pure-Python BM25** (AND + stopword exemption + OR rescue, `tag:`/`type:` operators, field ladder, phrase bonus). FTS5 was built first and rejected on measurement: SQLite's `bm25()` clamps IDF to ~0 for any term in >half the corpus, so a topical wiki's topic word degenerated every ranking; Lucene-smoothed IDF in Python fixes it with the same zero deps. |
| 2 | **Per-session cost accounting** — parse the claude/gemini result envelopes, stamp cost/usage into the manifest, surface in `status` + run reports | The product argues in budgets (`refresh --limit`) while discarding the data; near-zero code | `citadel/llm.py`, `citadel/manifest.py`, `citadel/status.py` | S | ✅ 2026-07-23 — shipped: claude's envelope (`total_cost_usd` + tokens incl. cache traffic) and gemini's `--session-summary` tokens (flag appended only when the binary's `--help` advertises it — an older CLI is never handed an unknown flag) are parsed per session, summed per source (chunked segments combined), stamped into the manifest (`cost_usd`/`tokens_in`/`tokens_out`, carried across moves/re-stamps like `ingested_at`), and surfaced on the ingest/refresh/curate reports (`LLM usage:`, failed sessions included) + `citadel status` (per-source column, `Recorded LLM cost` total, `--json` `cost_usd_total`). Strictly passive — no usage path can fail a session; "no data" stays None, never `$0.00`. copilot emits no machine-readable stats (`-s` trims them) — nothing to parse there. |
| 3 | **Transcript ingest via a whisper-CLI seam** (`CITADEL_AUDIO_SUPPORT` + `formats/transcripts.md`) | Largest un-ingestable content class; the landscape's voice/meeting-capture gap; pure shell-out, on-philosophy | `citadel/ingest.py`, `citadel/llm.py`, rules | M | ✅ 2026-07-23 — shipped: opt-in `CITADEL_AUDIO_SUPPORT` routes recognized audio/video files (extension + magic) through a new `citadel/transcribe.py` seam — one shell-out per content to a `CITADEL_WHISPER_CLI` binary (openai-whisper flag convention; whisper-ctranslate2/mlx_whisper drop-in), the `[HH:MM:SS]`-per-line transcript cached content-addressed in `.citadel_transcripts/` beside the wiki — folded in by new `audio`/`audio-reconcile` kinds under `formats/transcripts.md` (cite the ORIGINAL media file; unlabeled voices stay roles). One deliberate divergence from the scoped design: no timestamp-locator form was added — the cached transcript IS the offline verification text, so plain `lines A-B` locators into audio sources are lint/`wiki_raw`/viewer-verifiable (a guarantee PDFs still lack), and timestamps live in the cited lines themselves. Whisper failures are retryable per-source errors, never permanent unreadable marks; long recordings ride the existing chunking; `doctor` gained the audio advisory. |
| 4 | **MCP prompts + resources** (workflow prompts; pages/indexes as subscribable resources) | Cheap modern-surface parity every peer has; pure FastMCP additions | `citadel/server.py` | S | ✅ 2026-07-24 — shipped as pure `citadel/server.py` additions: four workflow **prompts** packaging the recommended tool flows as slash-command-like entries (`wiki_answer` — orient → search → read → cite with the `wiki_raw` spot-check; `wiki_verify` — per-citation supported/unsupported/unreadable verdicts over one page; `wiki_capture_note` — ONE attributed statement through `wiki_capture`; `wiki_health` — `wiki_status` + `wiki_lint` + the next maintenance action), plus the wiki's documents as `wiki://` **resources**: `wiki://index`/`wiki://sources`/`wiki://tags` byte-identical to their tool twins and a `wiki://page/{folder}/{name}` template serving any page's full uncapped text (an OKF rel_path is always exactly `folder/name.md`), all sharing the tools' never-raise contract (missing page / unsafe path → an `error: …` body, never a crashed server). One deviation from the scoped "subscribable": subscribe/`listChanged` notifications are deliberately omitted — FastMCP's registry is static and the wiki only changes through staged ingest/curate runs, so re-read-after-`wiki_ingest` is the refresh model; they move to the § 3.1 remainder alongside Streamable HTTP (#12). |
| 5 | **Conversational capture bridge** — a documented transcript→`raw/` convention and/or a mediated append-only capture tool through the `[^llmN]` lane | The #2 landscape gap; closable without touching the provenance guarantees | docs + `citadel/server.py` | S–M | ✅ 2026-07-24 — shipped BOTH halves, with one deliberate upgrade over the scoped design: the capture tool routes through **raw/, not the `[^llmN]` lane** — `wiki_capture` (the 13th MCP tool, second mutating one) / `citadel capture` append one dated, attributed note to a monthly `raw/captures/YYYY-MM.md` log (append-only, atomic, never touching the wiki), which the NORMAL staged lifecycle then ingests/reconciles — so captured statements land as attributed claims with real, offline-verifiable `[^sN]` line locators (the tool reports the appended line range) instead of model-attributed facts; § 3.1's mediation requirement holds a fortiori (the wiki stays written only by the staged agent session). Empty/oversized texts are refused loudly; a log outside the `CITADEL_RAW_DIRS` walk roots warns with the explicit-ingest hint. The transcript half is docs/capture.md's `raw/conversations/` convention, governed by the existing chat/transcript genre briefs — no rules change was needed. |
| 6 | **Hermetic agent sessions** (`--bare`-class flags per CLI) + a `doctor` warning when `CITADEL_INGEST_MODEL` is set on copilot/gemini | Reproducibility + an honest knob | `citadel/llm.py`, `citadel/doctor.py` | S | ✅ 2026-07-24 — shipped: sessions run hermetic by default (`CITADEL_HERMETIC=1`) — a per-CLI isolation-flag table appends claude's `--bare` (skip user hooks/CLAUDE.md/MCP discovery) whenever the installed binary ADVERTISES it in `--help` (the same once-per-binary exact-token probe as gemini's `--session-summary`, so an older claude is never handed an unknown flag and runs byte-for-byte as before); `CITADEL_HERMETIC=0` deliberately re-admits the personal config. copilot/gemini document no equivalent flag today, so their table entry stays absent and nothing is probed or passed. `doctor` gained the honest-knob advisory: WARN when `CITADEL_INGEST_MODEL` is explicitly set while the backend is copilot/gemini (only claude is passed `--model`; the WARN names `COPILOT_MODEL`/`GEMINI_MODEL` as the real selectors — the untouched claude-only default never trips it). |
| 7 | **Persist the verify-corpus lane backlog** (a committed misses file per grading run) | Today's grading insight evaporates; the two optimization lanes need durable input | skill + `docs/` | S | ✅ 2026-07-24 — shipped as `docs/verify-corpus-backlog.md`, a committed append-per-run ledger (one run sub-block under its `## Runs` section per grading run, stamped with corpus/mode/model/`rules_version`, one `VCB-NNN` row per miss with lane + defect + concrete fix route; reproduced misses bump `last seen` instead of duplicating; resolved rows move to § Resolved as `fixed (PR)`, never deleted — so which insights became fixes stays auditable). Both grading skills now end on the ledger: verify-corpus's Grading output persists every non-immediately-fixed miss (clean runs record `misses: none` — a green streak is signal too), and bench-model routes its discriminative-tier defects there with the model noted per row (a weaker-model-only miss is a rules-gap finding, not a reference-model defect). Hard fails stay out by design — they block the causing change immediately; the ledger is for the soft misses that previously evaporated with the sandbox. Starts empty (pre-existing runs' sandboxes are gone, so their misses cannot be re-verified — no retro-import). |
| 8 | **Test the two risky hand-rolled surfaces** — CFBF fixtures for `citadel/extract_ole.py`, a headless-browser smoke test for the viewer JS | Thinnest coverage vs. risk in the repo | `tests/` | M | ✅ 2026-07-24 — shipped both halves. CFBF: `tests/test_extract_ole.py` grew a spec-shaped MS-CFB **writer** fixture (`_build_cfbf` — FAT, DIFAT sector chain past the header's 109 slots, multi-sector directory, mini-FAT/mini-stream), pinning offline every container path the kontor corpus previously reached only through a live LLM run: byte-exact round-trips (mini + multi-sector big streams), the BIFF5 `Book` fallback + `Workbook` preference, and the corruption guards — FAT/mini-FAT cycles terminate, an out-of-file chain, truncation, or a bad sector shift degrade to whole-file salvage, a rootless container yields empty text, never a crash (writing the tests confirmed one nuance: a chain pointing past the file aborts the parse into salvage rather than ending the chain — now pinned as intended behavior). Viewer: `tests/test_viewer_browser.py` loads the real built document in headless Chromium and drives boot/sidebar, search filter-and-recover, hash-routed page open, the citation hover popover, and `/`-focus, asserting **zero JS page errors** throughout — opt-in via a new `browser` dependency group (self-skips without playwright; the default suite stays offline), run in CI as the dedicated ubuntu `viewer-smoke` job, with `CITADEL_TEST_BROWSER` for pre-provisioned chromium builds. |
| 9 | **Resumable chunked ingest** (capture session IDs, retry from segment N) | Converts the documented discard-N−1-segments trade-off into saved spend | `citadel/ingest.py`, `citadel/llm.py` | M | ✅ 2026-07-24 — shipped, but NOT via session IDs: the scoped `claude --resume` route cannot work here (the staging tree the conversation edited is rm-tree'd on every failure path, so a resumed conversation would believe it had written pages that no longer exist — and copilot/gemini expose no equivalent flag at all). Instead a new `citadel/resume.py` banks, after each completed segment, the **delta that segment set would have promoted** — computed with the promote's own file-level view (`_content_files` + byte equality), so `_repair_renames`' link rewrites in untouched pages and any non-`.md` file are in it, which the per-segment page diffs are not — into `.citadel_resume/` beside the wiki (`CITADEL_RESUME`, default on; only a CHUNKED source ever writes one). The next run replays that delta into its FRESH staging copy and opens at segment N; a promote that failed *after* the last segment replays with zero sessions. Promote-once is untouched: still exactly once, still only after the last segment, still nothing partial on a failure — the guarantee did not move, only the bill. Guards, all offline and all BEFORE the first resumed session: identity (source sha — a null sha opts the source out, since `None == None` must never read as "unchanged" — model, rules version, the segment plan's CONTENT rather than its temp paths, and the prompt-shaping knobs `rules_version` misses: `CITADEL_WIKI_LANG`/`STYLE_PROFILES`/`PDF_MODE`/`IMAGE_SUPPORT`, so a language flip can't merge German segments into English pages), blob integrity, per-page live base state (a page another source changed in between is never clobbered; a banked DELETION carries a mandatory base hash, so it can never destroy work a different source has since done under that path), re-validation of every replayed page plus a no-NEW-broken-links check (a raw file deleted under a checkpointed page drops the checkpoint instead of promoting a `bad_source` page), an attempt cap (a deterministically poisonous segment N would otherwise wedge the source into failing cheaply forever), and `runlock.owned()` on every write (new predicate, extracted from `heartbeat`) so a stalled run never overwrites the run that reclaimed its lock. **Every guard failure falls back to a full restart at segment 1 in the SAME run** — the pre-resume behavior is the floor, never the failure mode. Cost stays honest on both sides: the run report counts only what that run spent, the manifest stamps the source's whole import cost across the runs that paid it. `doctor` lists what is waiting; `.gitignore`/SECURITY.md/recipes register the new plaintext sidecar. No rules edit was needed (a resumed segment sees exactly the wiki an unbroken run's segment would have), so no `rules_version` bump and no corpus re-grade. |
| 10 | **pypdf text-layer pre-pass** for PDFs (real line locators + correct chunking; keeps images mode for figures) | Fixes the locator-verifiability hole for the PDF class (finding 1.2.2), pure-Python dep | `citadel/ingest.py`, `citadel/lint.py` | M | ✅ 2026-07-24 — shipped as the audio pattern applied to PDFs: a new `citadel/pdftext.py` seam (pypdf as a BUNDLED runtime dep — PDFs are a common `raw/` class, so the offline path is on by default, not an opt-in extra; `CITADEL_PDF_TEXT`, default auto = on-when-importable) extracts a genuine PDF's text layer once per content into a `[p. N]`-page-marked, line-stable text, cached content-addressed in `.citadel_pdftext/` beside the wiki and pruned with the source. New `pdf`/`pdf-reconcile` kinds have the agent read the extraction (formats/pdf.md rewritten for both setups) while citing the original `.pdf` with `lines A-B` locators that `lint`/curate, `wiki_raw`, and the viewer now verify offline against the same cache; large PDFs chunk as line windows over ONE full extraction (numbering never rebases — the audio contract). Strictly best-effort: scanned / encrypted / corrupt / CITADEL_PDF_TEXT=0 / pypdf force-removed → per-source fallback to the pre-existing agent-native read (`p. N`, agent-verified — still the rule for figure-only facts in images mode, which keeps having the agent open the original PDF's figures); no failure of the pre-pass can cost a session. `doctor` gained the "PDF text" advisory. |
| 11 | **Bounded parallel ingest (`--jobs N`)** — requires first un-globalizing the config mutation (finding 1.2.5) | Throughput at real-corpus scale; staging already isolates | `citadel/config.py`, `citadel/ingest.py` | L | ✅ 2026-07-25 — shipped in two steps, the unblock and the feature. **Unblock (finding 1.2.5):** the per-source staging redirect no longer ASSIGNS `config.WIKI_DIR`/`INDEX_PATH`/`LOG_PATH`/`MANIFEST_PATH` and `os.environ["CITADEL_WIKI_DIR"]` — the wiki path is read through accessors (`config.wiki_dir()` and friends) backed by a `ContextVar`, which is per-thread by construction, so N workers each hold their own redirect while the main thread still sees the live wiki; unset, the accessors return the module attributes verbatim, so every read path, every CLI command, the MCP server — and every test that monkeypatches the layout — behave exactly as before. The agent session's child processes get their staging wiki through an explicit per-spawn `env` instead of a global mutation. **Feature:** `citadel ingest --jobs N` (`CITADEL_JOBS`, default 1 = the serial behavior line for line) folds N sources in at once, each on its own staging copy. Every guarantee is unmoved — one promote per source, all-or-nothing, nothing partial on the live wiki, the manifest still saved per completed source — because what is SHARED is serialized rather than raced: ONE lock guards the two moments that touch the live wiki (the clone, taken together with a hash snapshot of what was cloned, and the promote), while the minutes-long sessions run entirely outside it. Two design points moved during implementation, and they are the substance of the work: (a) serializing promotes is not enough — the promote has to be **base-aware**, pruning only what its OWN clone had and its staging lacks, since the original "prune whatever live has and staging does not" would have deleted every page a concurrent source had just promoted (the pinned regression test); (b) two sessions that wrote the same page must not be resolved by last-writer-wins — the second promote is REFUSED before it writes a byte (`_ConcurrentChange`), and that source is **re-run serially** at the end of its group, where it sees the winner's page and merges into it, i.e. the result a serial run would have produced. Those re-runs are surfaced on the report (*Re-run serially*) because they are the one place parallelism costs a session serial ingest would not have spent — a corpus that races a lot wants a lower `--jobs`. Report/manifest/failures bookkeeping stays on the main thread (worker threads only plan, stage, run and promote), so those writes are as single-threaded as before; an interrupt cancels queued sources, lets in-flight ones roll themselves back, and still records work that was already promoted. The default stays 1 on purpose: the real cost is **cross-linking**, not safety — concurrent sessions cannot see each other's new pages, so they link less richly and can duplicate a topic (`curate` is the designed cleanup). One further rule fell out of an ADVERSARIAL CROSS-REVIEW (a second model re-reviewing the concurrency): a CHUNKED source's resume checkpoint measured its delta against the CURRENT live wiki, so a page a concurrent source created read as a deletion this source had made, and one it rewrote read as this source's change with stale bytes — and since a checkpoint is durable, that delta outlived the parallel run and could prune a fully-ingested source's page in a later, even strictly serial, run (both manifestations reproduced offline before the fix, and pinned since). The delta, and the base state a replay is guarded against, now both come from the source's own clone snapshot — exactly the promote's rule — so a page another source touched fails the guard instead of passing it, and a raced chunked source really re-runs instead of replaying a stale checkpoint with zero sessions. Three defects the parallel path exposed were fixed at the root rather than papered over: the promote's leftover-temp sweep deleted HIDDEN `*.citadeltmp` files — another writer's in-flight atomic manifest/failures save — `config.atomic_write_text`'s temp name was unique per PID but not per thread, and the transcript log's sequence counter was bumped without a lock while its own comment promised concurrent files never collide. 17 offline tests drive the concurrency for real (fake sessions synchronize on a `threading.Barrier`, so a test that requires two sources in flight at once cannot pass on a serial run) and pin each safety property to the failure it prevents. |
| 12 | **Streamable HTTP transport (opt-in, token-guarded)** | Reach from claude.ai/phones; FastMCP supports it | `citadel/server.py`, docs | M | ✅ 2026-07-25 — shipped as a new `citadel/httpserve.py` (the transport only — the tool/prompt/resource surface stays `server.py`'s, shared byte-for-byte with stdio) behind `citadel serve --http`. Stdio remains the default and is untouched. Because this is the ONLY network surface citadel has, every default is the strict one: the bearer token is **mandatory** (`CITADEL_HTTP_TOKEN`, ≥16 chars — `serve --http` refuses to start without one, so there is no "unauthenticated for a minute" mode), checked in a ~30-line ASGI wrapper BEFORE the MCP session layer (constant-time `hmac.compare_digest`, 401 + `WWW-Authenticate`, and the same answer for a missing and a wrong token — an unauthenticated caller cannot even open a session); the bind is loopback by default and a public bind WARNs, because the transport is plain HTTP and the intended remote path is a TLS-terminating tunnel (cloudflared / tailscale / `ssh -R`), not a raw port; the SDK's DNS-rebinding protection is enabled against the bound host (+ its loopback aliases), so a page in the user's browser cannot drive the server. One addition beyond the scoped design: `--read-only` (`CITADEL_HTTP_READ_ONLY`) has the two mutating tools REFUSE while the eleven readers keep working — the remote-exposure asymmetry is real (`wiki_ingest` spawns the coding-agent CLI on the serving host, which is a bigger promise than "read my wiki") — and it declines to ACT without changing the ADVERTISED tool list, so a client's surface never silently changes shape. The scoped OAuth-shaped route was declined on purpose: citadel has no user model, so the shared token IS the user, and the SDK's `TokenVerifier`/`AuthSettings` machinery would add an issuer/resource-server story with nothing behind it. Zero new dependencies (starlette + uvicorn already ship with `mcp`), the HTTP server opts into the page cache exactly like stdio, an HTTP flag passed to a stdio run is a usage error rather than a silently ignored setting, and `doctor` gained an "HTTP serve" line (where it would listen, whether the writers are exposed, WARN on a short token or a public bind — the refusal you would otherwise meet only at start-up). 70+ offline tests drive the whole stack in-process through starlette's TestClient (no port is ever bound in the suite): the 401/421/403 paths, a real `initialize` + `tools/list` over the transport, the read-only refusals, and the CLI wiring. One design point moved during review: the Host/Origin admission policy is citadel's own (in the same ASGI wrapper), not the SDK's transport-security middleware, because that middleware derives its allowlist from the BIND address — which is exactly wrong for the deployments this feature exists for. A tunnel forwards its own hostname and a wildcard bind is a name no client ever sends, so the shipped design would have answered `421` to every request it was built to serve. Now: `CITADEL_HTTP_ALLOWED_HOSTS` names the hosts (a tunnel's, a proxy's; `*` when something in front already filters), a wildcard bind with no allowlist REFUSES to start (naming the knob) instead of binding happily and rejecting everything, and `CITADEL_HTTP_ALLOWED_ORIGINS` — empty by default, so any browser-`Origin` request is refused — is what admits a browser-based client. |
| 13 | **Sync recipe docs** (markdown+git multi-device patterns) | Landscape-norm parity for the cost of a docs page | `docs/` | S | ✅ 2026-07-24 — shipped with #14 as one page, `docs/recipes.md` (linked from the docs hub): the **one-writer-many-readers** rule up front (the run lock protects one filesystem, not a sync service — so mutating lifecycles stay on one machine and every synced copy is read-only), then the recipes — wiki-history git + a private remote as the recommended lane (`git init` in `wiki/` + `CITADEL_WIKI_GIT_REMOTE`; the in-wiki manifest means a pulled mirror answers `status`/`search`/`serve` correctly), whole-workspace git for provenance portability (with the exact `.gitignore` — `.env`, lock, regenerable caches, viewer artifact — and the note that this layout commits via the scheduled run, since wikigit only auto-commits a wiki that is its own repo), file-sync services scoped to what they fit (capture inbox + read-only mirrors, never multi-writer), and the three phone-reading lanes (git host UI, Obsidian mobile, the one-file `citadel view` artifact). |
| 14 | **Scheduled-ingest recipe** (cron/systemd/Claude-Routines examples driving `ingest` + `refresh --min-age-days`) | llmwiki's nightly autonomous fold-in is a headline feature; citadel already has the self-limiting knobs, just no documented recipe | `docs/` | S | ✅ 2026-07-24 — shipped with #13 in `docs/recipes.md`: leads with *why unattended runs are safe by design* (sha-idempotent no-op nights, `--min-age-days` self-limiting refresh, run-lock overlap fails loud, all-or-nothing promotion, exit-code + cost-stamp auditability), names the canonical pair (nightly `ingest --quiet`, weekly `refresh --limit 5 --min-age-days 30`), then gives four concrete schedulers: cron (with the real gotcha — minimal environment — solved via the auto-loaded `.env`'s `*_CLI_PATH` overrides instead of fighting cron's PATH), a systemd user timer pair (`Persistent=true`, linger), Windows Task Scheduler on the portable invocation, and the llmwiki-parity **"smart cron"** lane: a scheduled agent session (e.g. a Claude Code Routine) running the same commands and then triaging the report — clean nesting guaranteed by the hermetic-session default (#6). Closes with the deliberate no-watch-mode stance (schedule + sha idempotency beats a watcher daemon). |
| 15 | **Serve-side page cache** — stop re-walking and re-parsing the whole wiki on every MCP call (finding 1.2.6; the latency half of § 1.3) | The one open piece of the retrieval story after #1 shipped ranking: at 1000 pages every read tool pays ~0.7 s of disk+parse before scoring, and `citadel serve` pays it per call, forever | `citadel/pagecache.py`, `citadel/store_core.py`, `citadel/server.py` | S–M | ✅ 2026-07-24 — shipped as a new `citadel/pagecache.py` behind the unchanged `store_core.load()`: the last load is held in memory and re-validated on **every** consult by a stat-only `os.scandir` fingerprint — `(rel_path, size, mtime_ns, ctime_ns)` over exactly the files `load()` parses, mirroring its traversal (hidden dirs skipped, symlinked dirs not entered, file symlinks stat'd through to their target) — plus a per-snapshot memo for search's per-page term-frequency tables, the other half of a query's cost. Measured at 1000 pages: `load()` ~700 ms → ~9 ms, `wiki_search` ~1.4 s → ~50 ms, `tag:`/`type:` queries ~630 ms → ~11 ms. **Nothing is persisted** — the audit's own "the wiki IS the database" line holds literally: no index file, nothing to go stale, and the filesystem re-answers "did anything change?" on every single call. The FTS5-shaped alternative (a persisted or rebuilt-at-load index) was not revisited: #1 already settled that ranking question, and this is a pure I/O problem. Staleness is designed out rather than hoped away, and every guard is cheap and offline: the fingerprint is taken BEFORE and AFTER the load and must match (a page rewritten mid-load would otherwise pair old content with a new stamp — the one race that could stale forever), a snapshot whose newest stamp is younger than a 2 s settle window is never stored (FAT-class timestamp granularity can hide a same-length same-tick rewrite; a network share whose clock runs ahead simply never caches, which costs speed and never correctness), the single slot is keyed by wiki directory (ingest's `config.WIKI_DIR` staging redirect can never be served the live wiki, and the unbounded stream of staging dirs cannot accumulate entries), `write_page`/`delete_page` invalidate directly, any walk error means "cannot vouch for this" → uncached, and `get()` hands out a fresh list per call so a caller sorting or clearing it cannot corrupt the snapshot. The memo is keyed by `id()` but only for pages the snapshot itself keeps alive, so a foreign page can never collide with a recycled address. **The mutating lifecycles do not participate**: `ingest()` and `curate()` wear `@pagecache.bypass` (re-entrant), so the staged diff-by-hash that decides what to promote always reads truth from disk — no speed-up on a path whose unit of work is a multi-minute agent session is worth that risk. Off by default (`CITADEL_PAGE_CACHE=auto`): `citadel serve` opts in — it is the long-lived reader the finding is about — `1` enables it wherever citadel reads the wiki, `0` restores the pre-cache behavior everywhere. 25 offline tests pin the hit, every way a wiki can change (edit, same-length rewrite, add, delete, rename, symlink target), and every guard. |

**State of the backlog (2026-07-25): all 15 ranked items are shipped.** What is still open lives
outside the table and is tracked where it was found: § 1.2's remaining findings — #1 (contradiction
"detection" is marker-counting), #2's Office/image slice (their locators stay agent-verified), #5's
other two halves (settings are read once at import; one process = one workspace), #7 (`extract.py`
swallows every error, so an encrypted file is indistinguishable from a text-free one), #9 (`log.md`
is unbounded and re-read per append) — plus § 1.5's operational gaps (per-source staging is O(wiki
size) × sources, a blocking `wiki_ingest` over MCP, plaintext-everything, no restore path), § 2.2's
landscape gaps 1 and 3-10, and § 3.1's MCP remainder (subscribe/`listChanged`, elicitation,
long-running tasks — the last being the natural fit for a non-blocking `wiki_ingest`). Ranking those
into a second backlog is a fresh audit's job, not this one's.

**Legitimate declines** (the evidence supports saying no): required embeddings/vector DB, hosted
OAuth serving, heavyweight PDF parsers (AGPL/torch), memory-decay policies, multi-user
permissions. These are the SaaS/framework lane, not citadel's local-first, provenance-first lane.
