# remem-ai Codebase Audit Report

> Audit date: 2026-05-29
> Target: `/Users/apple/Desktop/code/AI/tool/remem` (crate `remem-ai` v0.4.5, lib `remem`)
> Tech stack: Rust 2021 · clap CLI · rmcp MCP server · axum HTTP API · rusqlite + sqlcipher · reqwest (LLM) · tokio
> Scope: ~49K LOC, 358 `.rs` files
> Method: 4 parallel opus agents (data-integrity / security / architecture / persistence), each read-only, every finding cites `file:line`. Top findings independently spot-verified against live code before publication.

Findings follow the W-11 fact/inference/suggestion separation. **Fact** = traceable to `file:line`. **Inference** = reasoning + confidence. **Suggestion** = fix + prerequisite.

---

## Summary

| Level | Count | Key areas |
|-------|-------|-----------|
| **Critical** | 5 | Dual incompatible DB schema; silent plaintext DB; stale-memory FTS blackhole; raw-archive turn dropping; auto-promote dead gate |
| **High** | 7 | Silent context-load degradation (U-29); unauthenticated localhost API; topic-key reactivation gap; dual capture pipeline; memory-type vocab drift; migration integrity; status-vocab drift |
| **Medium** | 11 | god objects; connection-per-call; env-var data race; warn-level data loss; FNV collisions; latent SQL interpolation; etc. |

**Cross-agent confirmation (raises confidence):**
- The **dual-database / two-baseline** problem was independently found by all 4 agents (DI-6 = ARCH-1 = CFG-1 = CFG-8).
- The **sqlcipher silent-plaintext** problem was found by 3 (SEC-1 = CFG-2 = CFG-3).
- **Memory-type vocabulary drift** found by 2 (DI-3/DI-5 = ARCH-3).

This audit is a direct test of the two failure modes the project's own `CLAUDE.md` records: *(1) the capture/extraction channel silently producing empty memory*, and *(2) memories that look empty because a path was cut.* Several findings below are live recurrences of exactly those classes.

---

## Critical (fix immediately)

### C1 — Two incompatible database schemas + two migration engines for the same data
**Severity: Critical** · cross-confirmed by 4 agents · **Fact** (high confidence)

There are two parallel database lineages:

| | Live runtime DB | Dormant second DB |
|---|---|---|
| File | `~/.remem/remem.db` | `~/.remem/schema.sqlite` |
| Migrator | `crate::migrate::run_migrations` (v001–v018) — `src/migrate/run.rs:8` | `db::schema::migrate::run_migrations` (single baseline) — `src/db/schema/migrate.rs:8` |
| Baseline | `src/migrations/v001_baseline.sql` | `src/migrations/schema_001_baseline.sql` |
| `memories` shape | `content`, `title`, `branch`, `scope`, `project` (v001_baseline.sql:74-88) | `text`, `project_id`, `evidence_event_ids NOT NULL`, `confidence NOT NULL` (schema_001_baseline.sql:147-161) |

- `open_db()` (used by 87 sites across MCP/API/CLI/worker) runs the v001–v018 chain — `src/db/core.rs:76`.
- `db::schema::open_at()` (reachable via `admin reset-schema`, schema status reporting, and the dormant schema import helper) runs `schema_001_baseline.sql` — `src/db/schema.rs:32`, `src/db/schema/import.rs:25`.

**Impact:** `remem admin reset-schema` and the `db::schema` module maintain `schema.sqlite`, which the MCP server / worker / API never read. The public `remem import backup` path now writes into the runtime DB through `crate::db::open_db()` (`src/cli/actions/import.rs:21`), so it is not the current invisible-import path; the remaining C1 risk is the parallel schema/database lineage itself. The reader code (`MEMORY_COLS`, `map_memory_row` at `src/memory/types.rs:50,65`) reads `content`/`title`/`branch`/`scope` — incompatible with the `schema_001` `memories` table, so any future cross-read mis-maps or errors. The two baselines also disagree on nullability of `evidence_event_ids`/`confidence` (CFG-8: v009 adds them nullable; schema_001 declares them `NOT NULL`), so a `row.get::<_, String>` against a promoted `remem.db` row can panic/error.

**Fix (prerequisite: confirm whether the `schema.sqlite` normalization is meant to ship):**
- If abandoned (most likely — nothing in the hot path opens it): delete `src/db/schema/`, `schema_001_baseline.sql`, and the `admin`/schema-status references. ~6–8 files. Aligns with U-24 (no parallel system for one responsibility). Keep the public `remem import backup` path on the runtime DB; it is not the current invisible-import path.
- If intended: write an ExecPlan to migrate all 87 `open_db()` callers and retire `remem.db` + `migrate/`. Multi-day.
- **Do not keep both.** **Verify:** a test asserting exactly one `memories` DDL source and that every name in `MEMORY_COLS` exists in it.

### C2 — sqlcipher silently opens an UNENCRYPTED database; doctor/schema helpers never apply the key
**Severity: Critical** · cross-confirmed by 3 agents · **Fact** (high) + **Inference** (high)

`src/db/crypto.rs:23-29`:
```rust
pub(crate) fn apply_cipher_key_if_available(conn: &Connection) -> Result<bool> {
    if let Some(key) = load_cipher_key() {
        conn.pragma_update(None, "key", &key)?;
        return Ok(true);
    }
    Ok(false)   // no key -> proceed with a PLAINTEXT database, no error, no warning
}
```
`src/db/core.rs:70` calls it but **discards the bool** (`?` only propagates `Err`). The installer never generates a key, so the default install stores all captured memory (prompts, assistant turns, code, decisions) **in plaintext** at `~/.remem/remem.db`, despite the `bundled-sqlcipher` build + `remem encrypt` UX implying encryption-at-rest.

Two more sites open the DB **without** keying it at all:
- `src/doctor/schema.rs:14` — `Connection::open(&db_path)` then `dry_run_pending`. On an encrypted install this can't read page 1 → **`doctor` reports a false "Schema FAIL" on every encrypted install** (CFG-2), which may push users to run destructive `reset-schema`.
- `src/db/schema.rs:27` / `src/db/schema/import.rs:91` — the dormant schema import/helper path writes `schema.sqlite` **unencrypted** even when the user set a cipher key (CFG-3) → plaintext leak beside an encrypted main DB if that helper is used.

**Fix:** make encryption posture explicit and fail-closed. Act on the returned bool in `open_db()`; refuse to open plaintext unless `REMEM_ALLOW_PLAINTEXT_DB=1` (then `error`-log it). Make the recovery path fresh-install safe by either having `remem encrypt` generate `.key` and initialize an empty keyed runtime DB when `remem.db` is absent, or by adding a separate key-init command and pointing the fail-closed error at that command. Add `apply_cipher_key_if_available` to `doctor::schema.rs` and `db::schema::open_at` if those paths remain. Ensure doctor dry-run clones are keyed or in-memory so encrypted installs do not leave plaintext temp copies. Centralize PRAGMA+key setup in one `open_keyed(path)` helper. **Verify:** `REMEM_DATA_DIR=$(mktemp -d) cargo run -- status` fails without a key and its suggested recovery command succeeds on that empty data dir; doctor test that encrypts a temp DB and asserts `Status::Ok` without writing a plaintext temp clone.

### C3 — `include_stale=true` returns zero stale/archived memories on the primary FTS path
**Severity: Critical** · **Fact** (high; corroborated by the project's own `dream/apply.rs` test)

The FTS triggers index **active rows only** (`src/migrations/v012_memory_search_context.sql:40,46,52,55,61` — every trigger body gated `WHERE … status='active'`), but the FTS query JOINs that index while asking for inactive rows:
```rust
// src/retrieval/memory_search/fts.rs:67-71
FROM memories m JOIN memories_fts ON memories_fts.rowid = m.id
WHERE m.status IN ('active','stale','archived')   // memory_status_filter_sql(include_inactive=true)
```
A stale/archived row was never inserted into `memories_fts`, so it can never satisfy the JOIN. MCP `search` defaults `include_stale=true` (`src/mcp/server/search_tools.rs:41`), so the default search **claims** to include stale/archived memories and silently returns none of them on the bm25 channel.

Compounding:
- **DI-7:** the LIKE fallback channel (`like.rs:47`) *does* honor status, so whether a stale memory appears depends on token length (≥3 chars → FTS-only → invisible; short → LIKE → visible). Non-deterministic recall.
- **DI-9:** `archive_stale_memories` flips memories `active→archived` purely by age (180 days, `src/memory/events/cleanup.rs:12`), removing them from FTS — old-but-valid architecture decisions become unsearchable, contradicting the "strongest memory" goal.

**Fix:** drop the `status='active'` gate from the FTS triggers and rely on the post-JOIN `m.status` filter for visibility, then rebuild `memories_fts` (a v019 migration); or make the FTS path fall back to a direct `memories` scan when `include_stale=true`. **Verify:** insert → mark `stale` → `search(q, include_stale=true)` via FTS and assert the row returns.

### C4 — Raw archive dedups across sessions, silently dropping turns it promises to keep
**Severity: Critical** · **Fact** (high) + **Inference** (high)

`src/migrations/v002_raw_messages.sql:14` — `UNIQUE(project, role, content_hash)` (no `session_id`, although `session_id TEXT NOT NULL` exists at :5). Insert uses `ON CONFLICT(project, role, content_hash) DO NOTHING` (`src/memory/raw_archive.rs:68-91`) with a **64-bit FNV** content hash (`src/db/core.rs:5-14`).

**Impact:** any user/assistant turn whose text exactly matches an earlier turn in the same project ("ok", "continue", "fix it", repeated snippets) is stored **once for the project's whole lifetime**. `search_raw` then can't recall it for the later session, and session attribution is wrong. This directly violates the documented guarantee ("every user/assistant turn captured at Stop time", "what was said remains searchable" — `raw_tools.rs:13`). FNV-64 collisions add a second silent-drop path for genuinely distinct content.

**Fix:** `UNIQUE(project, session_id, role, content_hash)` (per-session dedup = the stated intent) and switch the hash to SHA-256 (`sha2` is already a dependency). The migration must backfill existing `content_hash` values to the new algorithm, or version the hash and perform idempotence checks against both old and new hashes during the transition; leaving old FNV values unmigrated reintroduces duplicates when the same transcript is drained again. **Verify:** insert identical content under two `session_id`s; assert two rows and that `search_raw` returns both with correct attribution; re-drain an existing pre-migration message and assert it does not duplicate.

### C5 — Auto-promotion gate compares two non-aligned type vocabularies → candidates rot in a queue nobody drains
**Severity: Critical** · **Fact** (high)

Auto-promote requires a source observation whose type **equals** the candidate type (`src/memory_candidate.rs:386,399-403`). But the two vocabularies don't align:
- `AUTO_PROMOTE_TYPES = ["architecture","bugfix","decision","discovery"]` (`memory_candidate.rs:28`)
- `OBSERVATION_TYPES = ["bugfix","feature","refactor","discovery","decision","change"]` (`src/db/models.rs:3`)

`architecture` is auto-promotable but is **not a valid observation type** (unknown `<type>` is coerced to `discovery` at `src/memory/format/parse.rs:62`), so the `==` check at line 402 can never be true for it. Such candidates fall to `review_status='pending_review'` (`memory_candidate.rs:239`). Per the project's own documented disaster #2, Claude does not proactively drain review queues → these memories **silently never enter the curated store**. Same "capture channel produces nothing" failure class.

**Fix:** replace raw equality with a shared support table from candidate memory type to acceptable observation evidence types. For `architecture`, update both observation prompts and parser/model vocabulary so future `architecture` observations are preserved, and also treat the existing coerced `discovery` evidence path as eligible when the source observation otherwise supports the candidate. `error`-log any candidate diverted to review so it is not silent. **Verify:** feed a previously coerced `discovery` observation + an `architecture` candidate it supports; assert it can auto-promote or logs an explicit reason.

---

## High (fix this week)

### H1 — Primary context-injection memory channel silently swallows DB errors (U-29)
**Severity: High** · **Fact** (high) · related to open issue #232

`src/context/query.rs:93,101` (inside `load_project_memories`, the session-start context index):
```rust
let recent = memory::get_recent_project_memories_excluding_types(...).unwrap_or_default(); // :93 DB error -> [] silently, NO log
if let Ok(searched) = memory::search_project_memories_excluding_types(...) { ... }          // :101 Err arm absent
```
The sibling loaders in the **same file** (lessons/summaries/workstreams, `:41-64`) correctly `log::error` on failure. So when the primary memory query fails (FTS corruption, schema drift, lock), the user gets a context with the most important memories missing and **zero diagnostic signal** — exactly the "both channels silently break" disaster. The inconsistency proves it's an oversight. Issue #232 covers the *lessons* path; this is the *memory* path.

**Fix:** mirror the sibling pattern — `unwrap_or_else(|e| { log::error(...); Vec::new() })` at :93, and a `match` with an `Err(e) => log::error` arm at :101. **Verify:** unit test that injects a query error and asserts an ERROR log.

### H2 — Auto-injected context renders BLANK at `warn` level when the DB can't open (U-29)
**Severity: High** · **Fact** (high)

`src/context/render.rs:108-119` and `src/context/injection_gate.rs:80-88`: on `open_db()` failure the path logs at `warn` and returns an empty context as `Ok(...)`. Per U-29, blank user-visible output caused by an error must be `error`, not `warning`+fallback. Indistinguishable from "no memories yet."

**Fix:** upgrade the `open_db`-failure branches at `render.rs:111` and `injection_gate.rs:84` from `log::warn` to `log::error` (keep fail-open for the gate). **Verify:** `rg "open_db failed" src/context | rg "log::warn"` returns nothing.

### H3 — Unauthenticated mutating HTTP endpoint reachable via browser CSRF-to-localhost
**Severity: High** · **Fact** (high on config) + **Inference** (medium-high on exploitability)

`src/api/server.rs:15-44`: bound to `127.0.0.1` (good) but **no auth** on any route, and CORS allows **any** `http(s)://localhost[:port]` / `127.0.0.1` / `[::1]` origin with `Content-Type: application/json` allowed. `POST /api/v1/memories` (`src/api/handlers/save.rs:8`) writes with no token check; `GET /api/v1/search` reads the whole store. Any localhost-origin web page (or other local process) can inject poisoned memories or exfiltrate the private memory store. Injected memory later becomes agent context (SEC-18 territory).

> Note: the persistence agent labeled the bind+CORS "clean" because it's localhost-only; the security agent's point is that localhost-only is **not** an auth boundary on a multi-process desktop. Treat as a real finding.

**Fix:** generate a local token at install (`0600`, beside `.key`), require it via middleware on all routes, and tighten CORS to the specific official-client origin (or drop CORS if no browser client exists). If a browser client remains, allow the chosen auth header in CORS preflights. **Verify:** `curl -XPOST 127.0.0.1:<port>/api/v1/memories …` returns 401 without the token.

### H4 — Topic-key upsert never re-activates a stale/archived row → updated content stays invisible
**Severity: High** · **Fact** (high)

`src/memory/store/write.rs:75-102`: the upsert lookup ignores status and the UPDATE omits `status`. If a topic was previously superseded/archived and a new version is written with the same `topic_key`, the row's content updates but `status` stays `stale`/`archived` → it's excluded from active reads and (per C3) from FTS. The write "succeeds" (returns an id) but the memory silently doesn't come back.

**Fix:** scope/order the topic-key lookup so it updates the active/latest row, or inserts a new row when only stale/superseded rows match; if updating an existing row, set `status='active'` in the UPDATE because a write to a topic is a reassertion of currency. **Verify:** insert → archive/supersede → re-`insert_memory_full` same `(project, topic_key, scope)`; assert exactly one current active row is visible and FTS-searchable.

### H5 — Two coexisting capture/extraction pipelines (legacy `pending_observations` vs new `extraction_tasks`)
**Severity: High** · **Fact** (high) + **Inference** (medium)

The worker services **both** queues every iteration: `process_job(JobType::Observation)` → `observe::flush::flush_pending` (drains `pending_observations`, `src/worker.rs:135`) AND `extraction_worker::run_next` claims from `extraction_tasks` (`src/extraction_worker.rs:13`); both lease-recovery paths run (`worker.rs:187,194`). Two extraction front-ends exist (`src/observation_extract.rs` vs `src/observe/flush/batch.rs`). This is the second incomplete migration — events can be processed twice or fall between systems; matches the CLAUDE.md "two channels" hazard.

**Fix (prerequisite: trace which queue receives writes on a live capture — W-01):** pick `extraction_tasks` as the target, deprecate `pending_observations` writes, remove the `flush_pending` job arm + its lease recovery. ~8 files. **Must** verify with a live capture before deleting either side.

### H6 — Memory-type vocabulary duplicated across 7+ sites; `procedure` has label/order/core-policy drift
**Severity: High** · **Fact** (high)

`MEMORY_TYPES` (canonical, 8 types, `src/memory/types.rs:39`) is re-enumerated by hand in ≥7 places that don't import it: `context/format.rs:9` (label — **missing `procedure`** → renders "Other"), `context/sections/index.rs:53` (display order missing `procedure`; the fallback loop still renders unknown types, so this is ordering/label drift rather than a total index drop), `context/sections/core.rs:158`, `context/policy.rs:158`, `memory_candidate.rs:28`, `prompts/task_observation.txt:63`, plus the separate `OBSERVATION_TYPES`. Adding a type means editing 7+ files; `procedure` already has a live coverage gap.

**Fix:** centralize a `MemoryType` enum with `as_str`/`label`/`index_order`/`weight`/`auto_promote` methods; derive the scattered tables from it; add an exhaustiveness test. ~7 files + 1 module.

### H7 — Migration integrity: rollback failure swallowed (U-29) + partial version recording + non-universal FK PRAGMA
**Severity: High** · **Fact** (high) + **Inference** (medium)

- **CFG-6:** on migration error, if `ROLLBACK` also fails the code logs `warn` and returns only the *original* error (`src/migrate/run.rs:20-27`) → potential silent half-migrated DB. Must be `error` + chained context.
- **CFG-4:** legacy transition records only `mark_applied(conn, 1, …)` then applies v2..v18 SQL on a backfilled DB (`src/migrate/transition.rs:26`); `_schema_migrations` can claim "done" via a different code path than the SQL files. Safe only while every later migration stays idempotent.
- **CFG-5:** `user_version = max(current, OLD_BASELINE_VERSION-1+latest)` = 30 while `_schema_migrations` max = 18 (`src/migrate/run.rs:75`). Confusing/brittle; changing `OLD_BASELINE_VERSION` shifts every fresh DB's version → future false "downgrade detected".
- **CFG-7:** `PRAGMA foreign_keys=ON` is set in `open_db`/`open_at` but **not** in `db::crypto::encrypt_database` or `doctor::schema` — FK is per-connection, so cascades (`memory_lessons`, `git_commit_sessions`, `memory_facts ON DELETE …`) are silently inert on those connections.

**Fix:** `error`-log + chain rollback failures; transition logic must not mark post-baseline migrations as applied unless their SQL has run or an explicit equivalence check proves the target tables/indexes already exist. Make `user_version` track the latest migration only; centralize PRAGMA setup so every open is keyed + FK-on. **Verify:** test that opens a synthetic v12 DB, migrates, asserts required v002+ tables such as `raw_messages` and `event_blobs` exist and `_schema_migrations` only records versions that were actually applied/equivalent.

---

## Medium (plan to fix)

| # | Finding | Location | Note |
|---|---------|----------|------|
| M1 | `flush_pending` logs exhausted items at `warn`, reports `Drained` | `src/observe/flush/batch.rs:107-131` | Capture-quality loss under-signaled; `error` on attempt exhaustion |
| M2 | `data_dir()` falls back to `"."` on missing home → CWD-dependent data split | `src/db/core.rs:39-47` | `error` or hard-fail instead of silent `./.remem` |
| M3 | Summary→workstream persistence failure `warn`-swallowed while memory promote propagates `Err` | `src/summarize/summary_job/persist.rs:104-118` | Inconsistent handling in one fn |
| M4 | `unsafe std::env::set_var` to pass per-job executor in multi-threaded tokio | `src/worker.rs:59,71`; `observe/hook.rs:198` | Rust-2024 env data race; thread executor explicitly or pin current-thread rt |
| M5 | `sync_native_memory` swallows file-read errors (`Err(_) => Ok(())`) | `src/observe/native.rs:17-20` | Distinguish NotFound from real IO error; `error`-log the latter |
| M6 | `event_blobs` dedup by FNV-64 with `DO NOTHING` → wrong-blob cross-link on collision | `src/db/capture.rs:81,269` | SHA-256 (already a dep) |
| M7 | `encrypt_database` builds `ATTACH … KEY` via `format!` (escaped, NUL-checked) | `src/db/crypto.rs:154-165` | Use bound params: `ATTACH DATABASE ? AS encrypted KEY ?`; add key-with-quote test |
| M8 | `PRAGMA table_info({table})` interpolates table name (constant today) | `src/db/schema/import.rs:121`; `src/cli/actions/import.rs:154` | Latent SEC-01 footgun; identifier allowlist |
| M9 | `reset_at` recreates `schema.sqlite` unencrypted; `let _ = remove_file` ignores errors | `src/db/schema.rs:44-49` | Key the recreate; check removal except NotFound |
| M10 | `pending_observations` may reprocess a poison row forever (no terminal cap observed) | `src/db/pending/claim.rs:90` | Confirm `attempt_count` → terminal `failed` |
| M11 | `eval/e2e.rs` discards HTTP bodies with `unwrap_or_default()` → failed calls look empty (W-18) | `src/eval/e2e.rs:299,327` | eval-only; record as eval error |

**Architecture / tech-debt (P2, velocity not correctness):**
- God objects over/near the 800-line ceiling: `git_trace.rs` (833), `memory_candidate.rs` (808), `worker.rs` (783), `context/injection_gate.rs` (763), `db/extraction.rs` (654), `adapter/common.rs` (619) — split by responsibility (ARCH-6/7).
- Connection-per-call with no pool/handle: 87 `open_db()` sites; every MCP tool call + worker iteration re-runs migrations + vec-extension load (`src/db/core.rs:53-79`). Move one-time setup out of `open_db`; thread an owned connection (ARCH-2).
- Four parallel host enums + ~120 raw `"claude-code"`/`"codex-cli"` literals; host→executor map reimplemented in `worker.rs:78` and `summary_job/hook.rs:165` with different output (ARCH-4).
- Two context-output mechanisms (`injection_gate` inline vs `claude_memory` file) with duplicated rendering (ARCH-8); business logic in MCP/CLI/API handlers instead of the existing `memory/service` layer (ARCH-9); render-time clustering in `context/query.rs` duplicating `memory/dedup` (ARCH-11); prompt storage split between inline consts and `prompts/*.txt` with a 3rd type vocabulary (ARCH-10); `anyhow` stringified at the MCP boundary instead of typed errors (ARCH-13).

---

## Repair Roadmap

| Phase | Scope | Findings | Est. files |
|-------|-------|----------|-----------|
| **Phase 0 (urgent, this week)** | Stop silent data loss + plaintext | C2, C3, C4, C5, H1, H2, H4 | ~15 |
| **Phase 1 (next)** | Resolve the dual systems | C1 (delete or finish schema.sqlite), H5 (one capture pipeline), H7 (migration hardening) | ~20 |
| **Phase 2** | Security + correctness polish | H3 (API auth), M1–M11 | ~20 |
| **Phase 3 (ongoing)** | Architecture: centralize type/host vocabularies, connection handle, god-object splits | H6, ARCH-2/4/6/7/8/9/10/11/13 | large, staged |

**Sequencing notes:**
- C3 + H4 + DI-9 interact: fixing the FTS/status visibility (C3) is the keystone — do it first, then H4 and the archive policy fall out.
- C1 should be a deliberate decision (delete vs finish) before H7, since migration hardening depends on which schema is canonical.
- H5 requires a **live capture trace** before deleting either pipeline (W-01: reproduce before fixing).

---

## Verified clean (no issue found)

Documented so future readers don't re-investigate:
- SQL injection across the query layer — every dynamic site uses `?N` bind params; column names are string literals, not user input.
- FTS5 MATCH injection — user text bound as `?1` and additionally quoted/escaped by `sanitize_fts_query`.
- LLM API key handling — read from env, sent as `x-api-key`, never logged; rustls, no TLS bypass; empty responses raise.
- `local_path` path traversal — `confine_to_base` normalizes `..`, canonicalizes symlinks, enforces `starts_with(base)`; fails closed.
- Cipher key file hygiene — `O_CREAT|O_EXCL` + `0600`, dir `0700`, refuses overwrite.
- MCP tool registry — all 6 routers composed, all 11 `#[tool]` handlers reachable; no declared-but-unreachable tool.
- DB-path / env-var convergence (U-11..U-14) — all data paths resolve through `db::data_dir()`; uniform `REMEM_DATA_DIR`.
- Worker panic resilience — runs as `worker --once` one-shot subprocess, so a panic doesn't silently stop all capture.
- `observation_extract` empty-result handling — empty parse without the explicit `<no_observations>` marker **raises** (the U-29-correct behavior for the historical disaster path).
