Skip to content

MemPalace Repair & Durable-Concurrency Runbook

MemPalace Repair & Durable-Concurrency Runbook

Section titled “MemPalace Repair & Durable-Concurrency Runbook”

Status: Active. Owner: Liam (palace is local to the dev machine). Audience: the operator repairing a drifted/corrupt MemPalace, or hardening it against the chronic daily drift. Binding rulings: DR-009 (single-writer; recall read-only), DR-010 (the markdown decision-register is the system-of-record; MemPalace is a recall feed) and DR-098 (the auto-mine daemon runs supervised; the chromadb 1.5.9 segfault risk is accepted — supersedes DR-048’s disable mandate).

MemPalace stores drawers in a local ChromaDB palace at ~/.mempalace/palace (chroma.sqlite3, ~1.5 GB; collection mempalace_drawers). It drifts or corrupts roughly daily under multi-session use. This runbook covers the symptoms, the root cause, the interim single-writer posture, the repair recipes, and the durable fix.

SymptomWhere it shows
malformed inverted indexany FTS5 query / mempalace search aborts; CLI repair itself aborts pre-fix (upstream #1606)
HNSW vector index drift (#1665)semantic / wing-filtered mempalace_search errors Error finding id; FTS-only still works
Physical drift archival~/.mempalace/palace/<segment-uuid>.corrupt-<ts> and .drift-<ts> dirs accumulate (the daemon archives the bad segment and rebuilds)
Lock-dir bloat~/.mempalace/locks/ grows into the tens of thousands of files (one per operation under contended writers)
MCP connection timed out after 30000ms/mcp reports the server unreachable while every check below passes — the startup integrity gate outgrew the client’s connect budget. See §10
Search returns boilerplate over signalrecall surfaces agent-brief templates / machine dumps instead of decisions. Volume problem, not corruption. See §10.3

Quick triage (all read-only, lock-free — safe any time):

Terminal window
DB="$HOME/.mempalace/palace/chroma.sqlite3"
# FTS health: returns a count if the inverted index is intact, errors if 'malformed'.
sqlite3 "file:${DB}?mode=ro&immutable=1" \
"SELECT COUNT(*) FROM embedding_fulltext_search WHERE embedding_fulltext_search MATCH 'test';"
# Drift/corrupt segment archives + lock bloat:
ls -d "$HOME/.mempalace/palace/"*.drift-* "$HOME/.mempalace/palace/"*.corrupt-* 2>/dev/null | wc -l
ls "$HOME/.mempalace/locks/" 2>/dev/null | wc -l

The daily drift is an upstream chromadb Rust HNSW thread-safety bug under concurrent mempalace-mcp writers (multiple Claude sessions, each with its own MCP server, all opening a ChromaDB writer on the same on-disk palace). ChromaDB’s PersistentClient is single-writer/exclusive; concurrent writers race the HNSW index and corrupt it. This is the finding behind DR-009.

Until the durable fix lands, every access path obeys single-writer discipline:

  • Reads → lock-free, never a writer. Open chroma.sqlite3 with ?mode=ro&immutable=1 (FTS5 over sqlite). immutable=1 takes no locks and never blocks the live writer. Plain mode=ro fails when the palace is WAL-mode with no -wal present (checkpointed) — it would need to create -shm; use immutable=1. This is what the SessionStart recall hook (canonical:.claude/hooks/mempal-recall.sh) does.
  • Writes → exactly one writer. Route writes through a single process — the opt-in 3.5.0 daemon, or one MCP server — never two concurrently.
  • Subagents recall read-only, never write. Worktree subagents read mode=ro; they do not open a writer.
  • Do NOT blanket sandbox-allow ~/.mempalace writes. A blanket write-allow re-enables the concurrent-writer drift this posture prevents. Reads work with no allow via mode=ro&immutable=1; only a deliberate, quiesced repair (below) writes, with the allow granted just for that step.

Before ANY repair: quiesce + back up. Stop the daemon and every MCP writer (close other Claude sessions) so the repair is the only writer. Confirm the backup: cp ~/.mempalace/palace/chroma.sqlite3 ~/.mempalace/palace/chroma.sqlite3.bak-$(date +%Y%m%d-%H%M%S). These steps mutate ~/.mempalace, so run them with the sandbox disabled.

launchd caveat (§5g): the daemon is supervised by ~/Library/LaunchAgents/com.mempalace.daemon.plist with KeepAlive — a plain mempalace daemon stop gets auto-restarted within ~30s. To quiesce: launchctl bootout gui/$(id -u)/com.mempalace.daemon (stops it AND holds it down). Resume after the repair with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mempalace.daemon.plist.

The CLI repair aborts before it can fix this (it opens the corrupt FTS first — #1606), so rebuild the FTS5 inverted index directly with sqlite (the S431 fix that cleared the integrity failure):

Terminal window
DB="$HOME/.mempalace/palace/chroma.sqlite3"
# Rebuild the FTS5 inverted index from its content table.
sqlite3 "$DB" "INSERT INTO embedding_fulltext_search(embedding_fulltext_search) VALUES('rebuild');"
# Verify integrity, then a MATCH that previously aborted.
sqlite3 "$DB" "INSERT INTO embedding_fulltext_search(embedding_fulltext_search) VALUES('integrity-check');"
sqlite3 "$DB" "SELECT COUNT(*) FROM embedding_fulltext_search WHERE embedding_fulltext_search MATCH 'decision';"

FTS works without this; only restore the HNSW when semantic / wing-filtered search is needed. Rebuild the HNSW from the sqlite source of truth, archiving the drifted segment:

Terminal window
mempalace repair --mode from-sqlite --archive-existing
  • --archive-existing moves the drifted segment to a .drift-<ts> / .corrupt-<ts> dir rather than deleting it (recoverable).
  • The legacy rebuild mode has a crash history (#1238) — prefer from-sqlite, which treats sqlite as authoritative.
  • Re-verify: a mempalace_search (no wing filter — #1665 workaround) returns results without Error finding id.

5. Durable fix (S433 — mostly shipped; one leg upstream-blocked)

Section titled “5. Durable fix (S433 — mostly shipped; one leg upstream-blocked)”

The repairs above are reactive. The durable fix removes the recurrence. Status after S433 (2026-07-01) — all applied quiesced + backed up + sandbox-off, INLINE (not sub-agent):

~/.mempalace/config.jsonhooks.daemon: true (verify: MempalaceConfig().hook_use_daemon is True). The 3.5.0 daemon “serializes background mines, diary saves, and hook ingests through a single process”; with this flag the SessionEnd save/mine runs INSIDE the daemon (the single writer) instead of opening its own ChromaDB client — removing the frequent automated writer.

5b. MCP single-writer — UPSTREAM-BLOCKED in 3.5.0 (the residual driver)

Section titled “5b. MCP single-writer — UPSTREAM-BLOCKED in 3.5.0 (the residual driver)”

Each Claude session’s mempalace-mcp opens its OWN ChromaDB writer: mcp_server.py always builds a direct chroma backend (get_backend_for_palace); the backend registry is only chroma/qdrant/sqlite_exact/pgvector (no daemon/remote backend); mempalace-mcp exposes no daemon-client flag. So daemon + N live sessions = N+1 writers, unfixable by config. v3.5.0 is the LATEST release (checked git ls-remote — nothing routes the MCP through the daemon yet). Interim: one active Claude session during heavy mining; track upstream MemPalace/mempalace for a daemon-routed MCP (SSE/HTTP, #1646). 5a already removes the automated writer, so the residual is narrower than it looks. Tracked on bl-391.

Stale as of 27/07/2026:v3.5.0 is the LATEST release” was true at S433 (01/07) but is not now — 3.6.0 shipped 17/07 and lands mempalace serve (full MCP surface over HTTP, --read-only, bearer-token auth). That is the daemon-routed/remote MCP this section waits on, so re-evaluate 5b against 3.6.0 before treating the single-writer residual as unfixable. §10 covers the upgrade.

5c. Wing consolidation — CORE SHIPPED, subtopics deferred

Section titled “5c. Wing consolidation — CORE SHIPPED, subtopics deferred”

Two wing systems: DIARY uses wing_{agent} (wing_claude, already stable); FILE-MINING derives wing_{slug} from the cwd basename (post-#1675; older drawers used bare names). knowledge-hub == canonical (rebrand), so the identity duplicates were merged to wing_canonical — there is no official merge command, so quiesced direct SQL: UPDATE embedding_metadata SET string_value='wing_canonical' WHERE key='wing' AND string_value IN ('knowledge-hub','knowledge_hub','wing_hub'); then PRAGMA wal_checkpoint(TRUNCATE); (30,765 drawers → wing_canonical = 30,924; PRAGMA quick_check = ok). The knowledge-hub-<subtopic> wings (kpf/ui-ux/prod-readiness/…) are left as sub-scopes — recall is wing-agnostic (FTS + no-wing search), and #1665 breaks wing-filtered search anyway, so fragmentation doesn’t hurt recall. Going forward the cwd canonical derives wing_canonical.

Quiesced: find ~/.mempalace/locks -type f -delete (was 14,873) + rm -rf ~/.mempalace/palace/*.drift-* ~/.mempalace/palace/*.corrupt-* (was 105 dirs / 191 MB). Live segment dirs untouched. There is no official lock-prune (mempalace palace only has set-embedder).

5e. Embedder identity — SHIPPED (hardening)

Section titled “5e. Embedder identity — SHIPPED (hardening)”

mempalace palace set-embedder recorded minilm/384 (was unknown → a latent mismatch-drift hazard; sidecar mempalace_embedder.json). Embeddings are LOCAL ONNX only (minilm default / embeddinggemma, both 384-dim) — no API embedder; changing the model would require re-embedding the whole palace, so set-embedder must match the model the vectors were built with.

5f. LLM closet-enrichment — WIRED (mining quality, opt-in)

Section titled “5f. LLM closet-enrichment — WIRED (mining quality, opt-in)”

The mine’s closet step (miner.py build_closet_linescloset_llm.py) reads LLM_ENDPOINT / LLM_KEY / LLM_MODEL from env (no dotenv auto-load). With hooks.daemon: true the mine runs in the daemon, so start the daemon via ~/.mempalace/start-daemon-with-llm.sh — it sources the OpenRouter creds from ~/.gitnexus/config.json at runtime (single source of truth; never duplicated into a dotfile, never printed) and exports them before mempalace daemon start. MAX_OUTPUT_TOKENS = 1500; the gitnexus model is a reasoning model (z-ai/glm-5.2) — validated to emit valid closet JSON in ~554 tokens, but closet_llm.py:166 does content.strip() with no null-guard, so a model/budget returning content: null silently falls back to heuristic. Override LLM_MODEL with a cheaper non-reasoning model if closets come back thin.

5g. Daemon durability — launchd supervision (02/07/2026)

Section titled “5g. Daemon durability — launchd supervision (02/07/2026)”

The §5f wrapper alone was not durable: the S433 daemon (started 01/07 00:18) was found DEAD the next day while three mempalace-mcp writers ran — and with hooks.daemon: true the SessionEnd hook silently falls back to spawning its own writer when the daemon is unreachable (hooks_cli.py _daemon_available), so a dead daemon quietly reverts the whole single-writer posture. Fix: ~/Library/LaunchAgents/com.mempalace.daemon.plist runs start-daemon-with-llm.sh --foreground (the CLI’s documented process-supervisor mode) with RunAtLoad + KeepAlive + 30s throttle; stdout/err land in ~/.mempalace/daemon/launchd.{out,err}.log. The wrapper still sources the OpenRouter creds from ~/.gitnexus/config.json at runtime, so LLM closet enrichment survives restarts. Quiesce/resume via launchctl bootout / bootstrap (see §4 caveat) — a bare mempalace daemon stop is auto-restarted by design.

All of the above are destructive ~/.mempalace mutations; run quiesced (single-writer via launchctl bootout gui/$(id -u)/com.mempalace.daemon + no live mempalace-mcp), backed up, sandbox-off. bl-391 tracks the residual (5b) + the deferred subtopic wings (5c).

  • HNSW health (read-only, no chromadb client): mempalace repair-status — sqlite vs HNSW counts + verdict (OK / flush-lag / rebuild-needed). Prefer this over opening a search to check drift. A large divergence labelled “within flush-lag tolerance” is not corruption.
  • FTS: the read-only count in §1 returns a number (no malformed).
  • HNSW search: mempalace search "<query>" (no wing filter — #1665) returns ranked results without Error finding id.
  • Embedder identity: mempalace search no longer warns EmbedderIdentityUnknownWarning (sidecar mempalace_embedder.json records minilm/384).
  • Single writer: ps aux | grep mempalace shows only mempalace.daemon serve (no stray mempalace-mcp writers) after quiescing.
  • LLM wiring (5f): daemon started via start-daemon-with-llm.sh; a closet-shaped chat call to $LLM_ENDPOINT/chat/completions returns non-null content (give reasoning models ≥1500 max_tokens).
  • SessionEnd mine: mempalace hook run --hook session-end --harness claude-code writes end-to-end without error (the 3.5.0 CLI contract; pinned in S430).
  • Recall hook: a fresh session startup injects a bounded MemPalace digest (the mempal-recall.sh SessionStart hook).
  • Daemon supervision (§5g): launchctl print gui/$(id -u)/com.mempalace.daemon shows state = running, and mempalace daemon status reports the same PID after a kill test (KeepAlive restarts it within ~30s).

7. Quiesced window — EXECUTED 02/07/2026 (procedure retained for future windows)

Section titled “7. Quiesced window — EXECUTED 02/07/2026 (procedure retained for future windows)”

Outcome: all steps ran clean. Wing fold: 19,803 knowledge-hub-* drawers → wing_canonical (quick_check ok). Rebuild: repair --mode from-sqlite --archive-existing --yes (note the --yes — the staged command below omits it and the run aborts on an interactive confirm otherwise) rebuilt a FRESH palace: drawers 104,786/104,786 and closets 52,957/52,957, divergence 0 — the closets UNKNOWN state is gone. Embedder identity must be RE-RECORDED after a rebuild (the sidecar does not carry over): mempalace palace set-embedder. Old palace + all four prior backups archived to ~/.mempalace/palace.pre-rebuild-20260702-160307/; the four .bak files were pruned (~5.9 GB reclaimed) and the archived full palace (1.6 GB) kept as the single rollback — prune it at the next clean window. Daemon resumed under launchd.

Motivation (02/07 probes): drawers HNSW is frozen at 50,169 vs 104,304 sqlite rows (flush never catches up — semantic search covers only the older half), and the closets collection (52,958 rows) has no flushed HNSW metadata at all (repair-status: status UNKNOWN, “HNSW capacity unavailable”). Also folds the remaining knowledge-hub-* subtopic wings (~19.8k drawers: -kpf/-ui-ux/-prod-readiness/-admin-dedup/-eval/-memory/ -content-architecture) into wing_canonical — the S433 identity-merge default, wing fold BEFORE the rebuild so the fresh index reflects final metadata. Run sandbox-off, no live Claude sessions that need MemPalace (quiescing severs their MCP tools until restart).

Terminal window
# 1. Quiesce (launchd-aware — §4 caveat) + verify zero writers
launchctl bootout gui/$(id -u)/com.mempalace.daemon
pkill -f mempalace-mcp # or close the owning sessions first
ps aux | grep -E "mempalace" | grep -v grep # expect: nothing
# 2. Fresh backup (byte-verify)
cp ~/.mempalace/palace/chroma.sqlite3 \
~/.mempalace/palace/chroma.sqlite3.bak-$(date +%Y%m%d-%H%M%S)
# 3. Subtopic-wing fold (identity hygiene; skip if the owner overrides)
sqlite3 ~/.mempalace/palace/chroma.sqlite3 \
"UPDATE embedding_metadata SET string_value='wing_canonical'
WHERE key='wing' AND string_value LIKE 'knowledge-hub-%';"
sqlite3 ~/.mempalace/palace/chroma.sqlite3 \
"PRAGMA wal_checkpoint(TRUNCATE); PRAGMA quick_check;" # expect: ok
# 4. HNSW + closets rebuild from the sqlite source of truth (LONG — 1.5 GB palace)
mempalace repair --mode from-sqlite --archive-existing
mempalace repair-status # expect drawers hnsw ≈ sqlite count; closets no longer UNKNOWN
# 5. Wing-name normalisation sweep (harmless; apply only if the preview is sane)
mempalace migrate-wings --dry-run
# 6. Resume the supervised daemon + verify
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mempalace.daemon.plist
mempalace daemon status && mempalace search "decision register" # no 'Error finding id'
# 7. On a clean §6 verification pass, prune the superseded backups (~4.4 GB):
# bak-20260630-pre-fts-rebuild, bak-20260701-005349, bak-20260701-011800-pre-wingmerge
# (keep the fresh step-2 backup until the next window).

8. 24/07/2026 window — {164.5} closed; two posture regressions found

Section titled “8. 24/07/2026 window — {164.5} closed; two posture regressions found”

The 21/07 Intent-session rebuild FAILED at 37k/245k upserts (MineAlreadyRunning — a SessionEnd hook writer grabbed the palace mid-rebuild; log ~/.mempalace/repair-20260721-235854.log). Root causes found 24/07: hooks.daemon had reverted to false (§5a undone — hooks spawning their own writers again) and the launchd job was left in the DISABLED list after the 21/07 quiesce (launchctl bootout disables; bootstrap alone then fails with Input/output error 5 — you must launchctl enable gui/$(id -u)/com.mempalace.daemon first). Both fixed; embedder identity re-recorded (minilm/384). Drawers verified healthy without a rebuild (215,107 = 215,107, divergence 0; semantic search clean). Residuals: closets = 0 rows (already 0 in the 21/07 archive; regenerates only via future daemon mines with LLM env), and five palace.pre-rebuild-* archives (~6 GB each, 03/07–21/07) pending owner prune — the 21/07 one is the only copy of a 245,062-drawer snapshot (live holds 215,107 after dedupe).

Docs-site re-mine (manual, on demand — after major docs landings): unchanged files dedupe, new/changed files get LLM closets (OpenRouter cost).

Corrected 27/07/2026: this section previously read “last full mine was 15/06/2026”. That is stale — the docs-site was re-mined 24/07/2026 (newest filed_at 2026-07-24T19:03:16; 109,232 docs-site drawers live, including 1,310 from ledgers/retros/). Recent retros, decisions and continuation prompts are in the palace. Verify before repeating the claim:

Terminal window
DB="$HOME/.mempalace/palace/chroma.sqlite3"; U="file:${DB}?mode=ro&immutable=1"
sqlite3 "$U" "SELECT MAX(m2.string_value) FROM embedding_metadata m1
JOIN embedding_metadata m2 ON m1.id=m2.id AND m2.key='filed_at'
WHERE m1.key='source_file' AND m1.string_value LIKE '%knowledge-hub-docs-site%';"
Terminal window
mempalace mine /Users/liamj/Documents/development/knowledge-hub-docs-site/src/content/docs \
--wing wing_canonical --daemon --background

9. 24/07/2026 archive-merge window — lost drawers restored, archives pruned

Section titled “9. 24/07/2026 archive-merge window — lost drawers restored, archives pruned”

The §8 loss (June-era recall degraded: the failed 21/07 rebuild died ~37k rows in and post-21/07 re-mining rebuilt July but never June) was repaired by merging the archives back into the live palace. Quiesced per §4/§7 (bootout + pkill, full palace/ dir backup), then a single-writer script upserted every archive drawer absent from live through mempalace’s own backend (mempalace.palace.get_collection(...).upsert — palace embedder minilm/384; chroma maintains FTS + HNSW incrementally, so no post-merge repair run was needed). Newest-archive-wins (21/07 > 09/07 > 03/07), §5c wing-fold applied defensively, content-derived drawer ids make the upsert idempotent; journal-file resumable; ~57 docs/s, 50 min for 169,823 drawers.

Outcome: live drawers 215,110 → 385,233 (= exactly the 170,123-id union of archive-only drawers; the 03/07 archive contributed 0 unique ids — fully contained in the newer two). repair-status: HNSW 385,233 / sqlite 385,233, divergence 0, OK. June-filed drawers 35,761 → 96,914. Semantic + FTS recall of June sessions verified end-to-end. Prune gate (every archive id present in live: 0 missing × 3 archives) passed, then all three palace.pre-rebuild-* archives + the s458 FTS .bak were pruned (~11.8 GB reclaimed). palace.pre-merge-20260724-171529/ (1.6 GB) kept as the single rollback — prune at the next clean window. Daemon resumed (launchctl enable before bootstrap — the §8 trap), hooks.daemon: true intact.

Gap composition (for the record): the lost drawers were conversation-transcript mines (~115k, ~/.claude/projects/**), the 15/06 docs-site content mine (~52.6k), and ~1.4k diary entries — no repo source code; intentional codebase deletions (e.g. the ledger stack) were unrelated to the gap. Drawers whose source docs were since retired remain in the palace as historical memory (DR-010: recall feed, not system-of-record).

Verification-methodology correction: the FTS table is fts5(..., tokenize='trigram') — phrase checks must use literal substrings of the document (≥3 chars, punctuation intact). Whitespace-normalised word-sequence phrases silently fail (~2% self-match even on known-present docs) — the earlier “0% of sampled phrases found” reads were that artifact. Corrected baseline: literal 40-char mid-doc slices self-match at ~86%; merged docs matched at 89% post-merge (vs 41% pre-merge — the true prior content gap, part-masked by overlapping chunks under other ids).

Post-merge FTS malformation (same evening): the 170k-row merge left the FTS5 trigram index in the malformed inverted index state — MATCH queries still answered, but the daemon’s mine-validation PRAGMA quick_check guard (correctly) failed every subsequent mine with FTS5/SQLite quick_check failed: 1 issue(s). Fixed with the §4a rebuild (quiesced, backed up): 385k docs rebuilt in ~2 min, quick_check ok, daemon resumed, mines re-queued (mines are dedupe-idempotent, so the partial pre-failure writes were harmless). Lesson: after any bulk sqlite-level upsert, run the §4a FTS rebuild before resuming the daemon — treat it as part of the merge, not an optional verify.

Residuals: closets collection still 0 rows (regenerates via future daemon mines with LLM env); repo-level mempalace.yaml/entities.json are intentionally absent — optional mempalace init artifacts; the miner’s no-yaml fallback derives wing_canonical from the canonical cwd (§5c posture) and entities live globally in ~/.mempalace/known_entities.json.

10. 27/07/2026 — MCP startup timeout + palace-noise audit

Section titled “10. 27/07/2026 — MCP startup timeout + palace-noise audit”

Two findings from one investigation: the MCP server stopped connecting, and the palace is ~30% raw machine dumps. Neither is corruption — §1’s triage passes clean throughout.

10.1 Symptom: connection timed out after 30000ms

Section titled “10.1 Symptom: connection timed out after 30000ms”

/mcp reports Failed to reconnect to plugin:mempalace:mempalace while the palace is provably healthy (quick_check = ok, FTS MATCH answering, 0 drift segments, 4 lock files, daemon state = running under launchd, hooks.daemon: true intact).

Cause. mempalace-mcp answers JSON-RPC initialize in 92.8s; Claude Code’s connect budget is 30s. The blocker is a mandatory startup gate added upstream in #1818:

mcp_server.py:4806_refresh_sqlite_integrity_status()repair.sqlite_integrity_errors()PRAGMA quick_check over the entire chroma.sqlite3.

Startup stage (3.5.0, 3.2 GB palace)Time
import mcp_server0.5s
_refresh_sqlite_integrity_status()80.2s
_refresh_vector_disabled_flag()1.1s
total to initialize response92.8s

ChromaDB is not implicated — PersistentClient + get_collection = 0.1s. Eager warmup is opt-in and unset in the plugin config ({"command": "mempalace-mcp"}, no env), so it is never the cause here. The gate cost is O(database size), so it crossed 30s as the palace grew; in 3.5.0 there is no env knob to skip or budget it (the only vars are MEMPALACE_MCP_ALLOW_PEER_WRITER, MEMPALACE_EAGER_WARMUP, MEMPALACE_MCP_IDLE_HOURS, MEMPALACE_BACKEND*, MEMPALACE_PALACE_PATH, MEMPALACE_LOG_FILE, MEMPALACE_MCP_HTTP_TOKEN).

Reproduce (read-only, ~90s — pipe an initialize request and time the reply):

Terminal window
# Raw gate cost on its own:
time sqlite3 "file:$HOME/.mempalace/palace/chroma.sqlite3?mode=ro&immutable=1" "PRAGMA quick_check;"

Why §1 misses it: every existing triage check is a data-health probe. This is a latency failure — the palace is fine and the client still cannot connect. Measure startup time, not just integrity.

Stopgap (applied 27/07): MCP_TIMEOUT: "180000" in ~/.claude/settings.jsonenv. Claude Code reads the env block at launch, so it takes effect on the next session, not the current one. MCP_TOOL_TIMEOUT is the separate per-tool-call budget; it is not involved here. This is a stopgap that degrades — the gate re-runs per MCP server, per session, and grows with the palace.

Durable: upgrade to 3.6.0 (uv tool upgrade mempalace). #1911/#1987 fix this directly — stdio answers initialize immediately while preflight runs on a background thread, and the startup SQLite probe is skipped entirely above MEMPALACE_STARTUP_INTEGRITY_MAX_MB (default 512 MB). Our palace is 3.2 GB, so the probe would not run at all. Repair preflights stay strict, so the safety property #1818 wanted is retained where it matters.

3.6.0 also carries several things this runbook has been working around:

  • #2010 — Claude Code tool-results/ sidecars excluded from conversation scans, “so raw machine dumps cannot flood the embedding space”. Directly addresses §10.3.
  • #1957 — appended/rewritten transcripts are purged and re-filed by mtime, instead of later turns being silently skipped. Reduces duplicate accumulation.
  • #1213/#1953exclude_patterns in mempalace.yaml for per-project mining exclusions.
  • #1890/#1891 — conversation drawers keep transcript time as authored_at; ties prefer the more recently authored drawer; list_drawers gains since/before. Idempotent, dry-run-first backfill script for existing palaces (no re-embedding).
  • #2016 — CLI search checks HNSW divergence before opening ChromaDB, routing a diverged palace to the SQLite BM25 fallback.
  • #1945/#2015/#2017 — integrity checks wait up to 15s for transient writer contention rather than calling a healthy palace corrupt; repair --mode from-sqlite now also rebuilds FTS5 and vacuums, and requires a clean final quick_check. This subsumes §9’s manual lesson about running the §4a FTS rebuild after a bulk upsert.

Treat the upgrade as a §4-class change: quiesce (launchctl bootout), back up, sandbox-off, and re-record embedder identity afterwards if anything rebuilds (§7 trap). Re-verify per §6.

10.3 Palace-noise audit (the “search is noisy” complaint)

Section titled “10.3 Palace-noise audit (the “search is noisy” complaint)”

Measured 27/07 on 445,848 drawers. This is a volume/precision problem, not corruption:

SliceDrawersShare
sessions wing (transcript mines)308,08969%
tool-results/ sidecars (844 distinct files)93,15621%
workflow-eval events.jsonl machine dumps42,43010%
exact-duplicate document content (redundant copies)89,05320%
wing_canonical (mined repo + docs)113,89326%
wing_claude (diary — highest-value recall)3040.07%

The duplicate-content head is agent-brief and dispatch-template boilerplate mined 156–468× each, plus <local-command-caveat> blocks at 468×. ingest_mode is convos for 328,320 drawers. So the highest-signal surface (the diary) is outnumbered ~1000:1 by raw transcript, and identical template text outranks it on lexical match.

Count duplicate load (read-only):

Terminal window
DB="$HOME/.mempalace/palace/chroma.sqlite3"; U="file:${DB}?mode=ro&immutable=1"
# Redundant copies (rows beyond the first in each identical-content group):
sqlite3 "$U" "SELECT SUM(cnt), SUM(cnt-1) FROM (SELECT COUNT(*) cnt FROM embedding_metadata
WHERE key='chroma:document' GROUP BY string_value HAVING cnt>1);"
# Machine-dump drawers:
sqlite3 "$U" "SELECT COUNT(*) FROM embedding_metadata WHERE key='source_file'
AND string_value LIKE '%/tool-results/%';"

Cleanup levers. There is still no dedupe commandmempalace repair rebuilds indexes, it does not merge or prune drawers. The available tools:

  • mempalace_delete_by_source (MCP tool, ships in 3.5.0 — available now). Exact-match, dry-run by default, removes every drawer for one source_file and its closet/AAAK index entries. This is the lever for the 844 tool-results/ files. Also exposed as the delete_by_source MCP tool; it counts as a mutation for peer-writer purposes, so run it single-writer.
  • mempalace sync [dir] --wing W --apply (#1252) — prunes drawers whose source files are gitignored, deleted, or moved. Dry-run is the default; --apply requires --wing or a project root. Good for drawers orphaned by repo churn, but note §9: drawers whose sources were intentionally retired are kept deliberately as historical memory (DR-010). DO NOT RUN --apply on this palace — see §10.4.
  • mempalace compress --wing W --dry-run — AAAK-dialect compression, ~30× reduction. Shrinks storage; does not remove duplicate drawers.
  • 3.6.0 exclude_patterns — the durable prevention. Stops the machine dumps being mined again rather than deleting them after the fact.

Deleting 30% of the palace is an owner decision, not a maintenance default — these drawers are recoverable history, and DR-010 makes the palace a recall feed rather than a system-of-record. Recommended order: upgrade to 3.6.0 first (which stops new sidecar ingest), then dry-run delete_by_source over the sidecar files, then re-verify recall quality before pruning further.

Note on the §9 verification method: the FTS table is fts5(tokenize='trigram'), so any phrase check here must use literal substrings — see §9’s methodology correction before drawing conclusions from match rates.

10.3a Why bulk writes cannot get a clean window on a live machine (27/07)

Section titled “10.3a Why bulk writes cannot get a clean window on a live machine (27/07)”

Four consecutive attempts to bulk-delete the §10.3 sidecar drawers all deadlocked, even after launchctl bootout of the daemon and pkill -f mempalace-mcp. The blocker is not the daemon and not the MCP servers:

~/.claude/plugins/cache/mempalace/mempalace/3.6.0/hooks/mempal-stop-hook.shmempalace hook run spawns a palace writer at the END OF EVERY TURN, in EVERY live Claude session.

So the writer set regenerates continuously while any session is open — including the session running the repair, whose own Stop hook fires between its turns. Symptom: the repair process sits at 0% CPU with ~0.01s CPU time, blocked inside mine_palace_lock acquisition, rather than erroring. MEMPALACE_MCP_ALLOW_PEER_WRITER=1 does not help — it bypasses the MCP peer-writer guard only, not the per-palace mine_palace_lock flock.

This is the same mechanism behind §8’s failed 21/07 rebuild (“MineAlreadyRunning — a SessionEnd hook writer grabbed the palace mid-rebuild”). Treat it as the general rule for any bulk mutation, not just rebuilds.

Diagnosis (identifies the real holder, not just “something has it”):

Terminal window
ps aux | grep -E "[m]empalace" # look for `mempalace hook run` + mempal-stop-hook.sh
ls -la ~/.mempalace/locks/mine_palace_*.lock
# a repair stuck at 0% CPU is BLOCKED on the flock, not working — check before waiting longer
ps -o pid,etime,time,%cpu -p <repair-pid>

Prerequisite for any bulk write window: close every other Claude Code session first, and run the mutation from a plain terminal (not from inside a Claude session, whose own Stop hook competes). Batch-hold the lock for the whole run — mempalace.palace.mine_palace_lock(palace) is process-wide re-entrant, so acquiring it once around the loop stops per-item releases from letting a hook-spawned writer steal the palace mid-batch. Per-item acquisition is what makes a long batch unfinishable here.

10.4 Gitignored-but-wanted sources — .lavish / .user-scratch

Section titled “10.4 Gitignored-but-wanted sources — .lavish / .user-scratch”

Owner ruling (27/07/2026): .lavish/ and .user-scratch/ carry rich working context and must be mined, despite being gitignored (canonical/.gitignore:142-143; both also exist in knowledge-hub, procurement, ims, knowledge-hub-docs-site, gh-security, re-brand-kick-off). Measured 27/07: 0 drawers from either — the miner’s gitignore guard has been silently excluding them all along. Volume is small and dense (canonical: 133 files, 2.7 MB), so this is a high-signal-per-drawer addition, the inverse of §10.3’s machine dumps.

Mine them with --include-ignored (present in 3.5.0 — no upgrade needed):

Terminal window
mempalace mine /Users/liamj/Documents/development/canonical \
--include-ignored .lavish,.user-scratch --wing wing_canonical --daemon --background

--include-ignored takes project-relative paths, repeatable or comma-separated. Do not reach for --no-gitignore, which disables the guard wholesale and would pull in node_modules/.next.

TRAP — mempalace sync --apply deletes exactly these drawers. sync.py:127 classifies any drawer whose source is gitignored as gitignored, and line 276 prunes that bucket alongside missing. sync has no include_ignored awareness — it cannot tell a deliberately included path from an orphan. So once .lavish / .user-scratch are mined, any sync --apply touching their wing silently destroys them. Treat sync --apply as banned on this palace while this ruling stands; use delete_by_source (exact-match, dry-run-first) for cleanup instead. sync --dry-run remains safe and is the way to preview.

10.5 Structural finding — the recall path is tuned in the hook, not in the tool

Section titled “10.5 Structural finding — the recall path is tuned in the hook, not in the tool”

The noise complaint has a specific mechanism, visible by comparing the two recall paths:

PathDiary rankingNoise filtersCorpus
mempal-recall.sh (SessionStart digest)room='diary' ranked first (line 109)filters CHECKPOINT:%, %Base directory for this skill%, topic='checkpoint' (lines 105-108)flat FTS, bounded to 6 rows
mempalace_search (MCP — what start-session §2a and recall-grounding actually mandate)nonenoneflat across all 445,848 drawers

The automatic digest is well-tuned; the tool the skills tell you to reach for is not. Those hand-rolled filters in the hook are themselves evidence that noise has been a chronic, reactively patched problem — and they exist in only one of the two paths.

Compounding it: wing_claude (the curated diary — the highest-signal surface, and the one the hook deliberately ranks first) holds 304 drawers, 0.07% of the palace, against 308,089 sessions drawers. The signal is outnumbered ~1000:1, and identical boilerplate outranks it on lexical match.

Implications worth acting on, in increasing order of effort:

  1. Client-side filtering belongs in recall-grounding. That skill already documents a client-side workaround for the #1665 wing-filter defect; it should carry the same diary-first / drop-CHECKPOINT: discipline the hook encodes, so MCP recall matches the digest’s precision. Currently that knowledge lives only in shell.
  2. The diary is the thing that works — feed it. Cross-session recall quality tracks diary volume far more than transcript volume. /handoff writing mempalace_diary_write at session close is the highest-leverage habit here; 304 drawers is undernourished.
  3. Prevention over pruning — 3.6.0 exclude_patterns (§10.2) stops the machine dumps being re-mined, which matters more than the one-off deletion.

Unrelated staleness surfaced during this review (not MemPalace, flagged for propagate-workflow-change): .dev-workflow/sdlc/.claude/agents/references/shared-discipline.md:131 and .dev-workflow/sdlc/.claude/skills/workflow-orchestration/SKILL.md:172 both instruct bun scripts/ledger-cli.ts get task <id> status. That script no longer exists — the ledger is ordna markdown files (tasks/id-N.md), per CLAUDE.md. Both cite it as the “cheap guard” for verifying live task status, so the guard is currently un-runnable.

11. 28/07/2026 — sidecar bulk delete: the DELETE path is broken; rebuild-as-delete works

Section titled “11. 28/07/2026 — sidecar bulk delete: the DELETE path is broken; rebuild-as-delete works”

SUPERSEDED by §12 (31/07/2026). delete_by_source works again — the pathology below was the poisoned WAL, eliminated by the 31/07 from-sqlite rebuild. Keep this section for the failure anatomy; do NOT follow its rebuild-as-delete recipe.

The §10.3 sidecar cleanup (800 tool-results/ files, 92,252 drawers, ~19% of the palace) executed S507 via the daemon mcp_tool route and surfaced a fourth chromadb 1.5.9 pathology: delete_by_source fails deterministically with Error in compaction: Failed to apply logs to the hnsw segment writer against the from-sqlite-rebuilt vector segment. Anatomy, verified on a 10-file pilot + 1-file canary before the full wave:

  • The metadata segment applies each delete (sqlite rows drop exactly); the HNSW vector segment refuses the same log entries, which stay pending in embeddings_queue. ADDs are unaffected (the same day’s mines were vector-searchable); reads stay healthy throughout.
  • Because sqlite is ground truth and repair --mode from-sqlite rebuilds into a fresh store, the rebuild IS the vector-side delete — and it eliminates the poisoned WAL by construction. Working bulk-delete recipe on this chromadb:
    1. lock-free sqlite manifest (per-file expected counts) + .backup snapshot;
    2. pilot dry-run via daemon, counts vs manifest;
    3. full metadata-side wave via daemon mcp_tool jobs (each job reports failed — expected; verify sqlite rows reach 0);
    4. one repair --mode from-sqlite in a §10.3a solo window, then §6 verify.
  • Upstream-report candidate (d): delete-compaction vs rebuilt segment.

Regrowth guard: 3.6.0’s convo miner already hard-skips tool-results/ (CONVO_SKIP_DIRS, convo_miner.py:76) so per-turn transcript ingestion cannot re-mine sidecars; the 844-file corpus was pre-3.6.0 legacy. The only regrowth route is a deliberate mine --mode projects over ~/.claude/projects — carry an explicit tool-results exclusion if one is ever run.


12. 31/07/2026 (S517) — the real cause of the recurring recall outage

Section titled “12. 31/07/2026 (S517) — the real cause of the recurring recall outage”

12.1 hnsw:sync_threshold — the whole failure, and a one-value fix

Section titled “12.1 hnsw:sync_threshold — the whole failure, and a one-value fix”

backends/chroma.py sets the divergence tolerance to 2 x hnsw:sync_threshold when the collection stores that key explicitly; without it, tolerance is max(2000, sqlite_count * 0.10). repair --mode from-sqlite writes sync_threshold: 2.

So a rebuilt palace tolerates 4 diverged drawers where an un-rebuilt one tolerates 39,765. Cross it and repair-status reports DIVERGED, MCP search sets vector_disabled: true and silently degrades to bm25_only_via_sqlite. Normal mining crosses 4 within hours. Every rebuild re-armed the trap it was run to clear.

Fix (ratified DR-110) — quiesced, ~1 minute, no rebuild:

Terminal window
# 0. rollback artefact FIRST
sqlite3 "file:$HOME/.mempalace/palace/chroma.sqlite3?mode=ro&immutable=1" \
"select c.name, m.key, m.int_value from collection_metadata m
join collections c on c.id=m.collection_id where m.key like 'hnsw:%';" \
> ~/.mempalace/collection_metadata.pre-threshold-fix.txt
# 1. quiesce (see 12.2 — bootout leaves a stale lock)
launchctl bootout gui/$(id -u)/com.mempalace.daemon
# 2. pin (1000 = mempalace's own _HNSW_DIVERGENCE_FALLBACK_FLOOR / 2, not arbitrary)
sqlite3 "$HOME/.mempalace/palace/chroma.sqlite3" <<'SQL'
PRAGMA busy_timeout=30000;
UPDATE collection_metadata SET int_value = 1000 WHERE key = 'hnsw:sync_threshold';
PRAGMA wal_checkpoint(TRUNCATE);
PRAGMA quick_check;
SQL
# 3. restore + verify: expect status OK at the SAME divergence
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mempalace.daemon.plist
mempalace repair-status

A from-sqlite rebuild resets this to 2. Re-pinning is a mandatory step of the rebuild recipe, not a tune.

12.2 Quiesce gotchas (all three cost real time on 31/07)

Section titled “12.2 Quiesce gotchas (all three cost real time on 31/07)”
  • launchctl bootout leaves a stale palace lock. It kills the daemon while it holds ~/.mempalace/locks/mine_palace_<hash>.lock; the file survives with a dead PID inside, and the next writer blocks on it forever. Symptom is §10.3a’s: 0% CPU, state S, no progress. Always verify the recorded PID is dead, then clear:
    Terminal window
    L=~/.mempalace/locks/mine_palace_d25074d397df1749.lock
    P=$(tr -d '\0' < "$L" | awk '{print $1}') # NUL-safe — see the defect note below
    if [ -z "$P" ]; then echo "UNPARSEABLE — inspect by hand, do not clear"
    elif ps -p "$P" >/dev/null 2>&1; then echo "ALIVE — do not clear"
    else rm -f "$L"; fi

    Defect, found S518: the version of this guard published before 01/08/2026 never fired. The lock file begins with a NUL byte, so awk '{print $1}' returned empty, ps -p "" always failed, and the || rm -f branch always ran — the lock was cleared unconditionally, live writer or not. The “verify-then-clear” safety property was never real. The NUL is invisible in the doc and in cat; the only way this surfaced was executing the recipe rather than reading it. Refusing on an empty parse is the fix.

  • The daemon holds that lock for its entire lifetime (not per-operation). Any second writer process blocks. Sequence bulk work as: bootout -> clear stale lock -> run -> bootstrap. Restarting the daemon before the work is the mistake.
  • auggie respawns its peer mempalace-mcp within a second of being killed (auggie --mcp --mcp-auto-workspace). Killing the child is futile; quiescing it means stopping auggie or removing mempalace from its MCP config.

12.3 Fifth chromadb 1.5.9 pathology — collection-access deadlock

Section titled “12.3 Fifth chromadb 1.5.9 pathology — collection-access deadlock”

The warm-vs-cold rule this section originally gave was WRONG and is withdrawn (S518). It read: “Route bulk deletes through a warm MCP server, never a fresh script.” S518 reproduced the same deadlock six ways — warm MCP search, warm MCP delete_by_source dry-run, cold client with the daemon up, cold client with zero palace holders, cold client with device=cpu, cold client with sync_threshold=2. Process warmth is not the variable. Neither is the DR-110 pin: reverting it 1000 → 2 changed nothing.

A client calling into the chroma collection on a palace in this state hangs before opening any palace file (flat RSS, 0% CPU, no .bin/sqlite fds). Stack:

main-thread -> chromadb_rust_bindings -> _pthread_cond_wait -> __psynch_cvwait

An internal condvar that never signals. sqlite stays healthy throughout (quick_check ok, FTS reads fine), so lock-free FTS is the only usable recall path while it lasts.

The actual variable is palace health, and the fix is a from-sqlite rebuild — proven S519, see §13. Two operational consequences that do still hold:

  • --dry-run never calls get_collection() (convo_miner.py: collection = get_collection(...) if not dry_run else None), so it is immune to the deadlock. When a subsystem hangs, find the code path that avoids the hanging resource and build against that — the whole S518 mining redesign was validated this way on a deadlocked palace.
  • Route bulk writes through the daemon (mcp_tool jobs), not a second writer process — for the single-writer reason (DR-009), not the warmth reason. See §13.

~/.mempalace/tools/mempalace-census.py — lock-free (mode=ro&immutable=1), never a peer writer, safe with everything live, ~9s over 400k drawers. Census (wings, rooms, source rollup, prune candidates, age histogram, redundancy) plus a --export markdown tree for human review.

~/.mempalace/tools/prune-stage.py is superseded — do not run it. It opens its own chroma client, which is a second writer beside the daemon (DR-009). The proven bulk-delete route is the daemon mcp_tool job loop in §13.2.

12.5 Hook ingest — what is actually being mined

Section titled “12.5 Hook ingest — what is actually being mined”
  • _ingest_transcript mines path.parent — the whole ~/.claude/projects/<slug>/ dir, not the transcript (hooks_cli.py:879). Canonical: 2,547 files / 948 MB per fire. This is deliberate: subagent transcripts live in a sibling subtree and are 66% of convo drawers — and are where the highest-signal reasoning lives. Do not blanket-exclude them.
  • hooks.auto_save gates Stop and SessionEnd and PreCompact (hooks_cli.py:1096/1277/1338). Flipping it false loses all transcript mining. Env override MEMPALACE_HOOKS_AUTO_SAVE beats config.json.
  • No config separates the CHECKPOINT: diary write from convo mining — they are adjacent in one function. exclude_patterns is projects-mode only (per-project mempalace.yaml, path-matched); the convo path never reads it.
  • authored_at is written by one line (convo_miner.py:555), convo path only. Diary (0/3,883) and project-mode (0/171,908) drawers never carry it, and the pre-3.6.0 convo corpus can never gain it (mtime-skip + normalize_version=2). ~70% of the palace is permanently undated — chronology must come from the register, ordna and git. (Narrower than it reads: source_mtime is populated on 99% of projects-mode drawers and content_date on 84%. authored_at specifically is the gap, not dating in general.)

13. 01/08/2026 (S519) — the rebuild clears the deadlock; the delete route that works

Section titled “13. 01/08/2026 (S519) — the rebuild clears the deadlock; the delete route that works”

13.1 Verdict: repair --mode from-sqlite DOES clear the collection-access deadlock

Section titled “13.1 Verdict: repair --mode from-sqlite DOES clear the collection-access deadlock”

The S518 unresolved question is answered. ~/repair-palace-s518.sh ran 09:32–10:47 on 01/08 (log ~/repair-palace-s518.log) and both smoke tests passed — semantic search and a delete_by_source dry-run, the two paths S518 could not get through at all.

  • Rebuild: sqlite 397,810 = HNSW 397,810, divergence 0; closets 4,611 = 4,611.
  • The DR-110 re-pin executed and was verified in the same run (mempalace_drawers|1000, mempalace_closets|1000). This is the step the older repair-palace-s506.sh lacks — running that script as-is re-arms the outage.
  • Embedder identity re-recorded (minilm, dim 384) — the §7 trap.
  • Durability held. Re-verified 5 hours later from a fresh session: mempalace_status answered, semantic search returned matched_via: drawer+closet with real cosine scores (not the bm25_only_via_sqlite degradation), sqlite_integrity.ok. S517’s rebuild re-broke within hours; this one did not.

One green repair-status is still not proof. What distinguishes this from S517 is the re-check on a later, separate process plus a vector-path assertion (matched_via), not the status line. Verify recall that way after any rebuild.

13.2 Bulk deletes: the daemon mcp_tool job loop (proven)

Section titled “13.2 Bulk deletes: the daemon mcp_tool job loop (proven)”

Supersedes §11’s rebuild-as-delete recipe and §12.3’s warm-MCP rule. Single-writer safe by construction — the write happens inside the daemon process, so it never races the daemon’s own mining (DR-009), and it needs no quiesce window.

# python3 = ~/.local/share/uv/tools/mempalace/bin/python3
from mempalace.hooks_cli import _submit_daemon_job
job = _submit_daemon_job(
"mcp_tool",
{"name": "mempalace_delete_by_source",
"arguments": {"source_file": path, "dry_run": True}}, # False to commit
priority=10, wait=True, timeout=300)
assert job["state"] == "succeeded" and job["result"]["success"]

mcp_tool accepts write-classified tools only (service.py:WRITE_TOOLS); read tools are refused so verbatim palace content never lands in the queue DB.

Procedure, as executed on the S355 stage orphans (44 files / 3,472 drawers):

  1. Build the manifest lock-freeselect distinct string_value from embedding_metadata where key='source_file' and string_value like '<pattern>' against file:…chroma.sqlite3?mode=ro&immutable=1.
  2. Export the drawers with content to a JSONL first. That, not a 3 GB palace copy, is what makes the delete reversible.
  3. Dry-run every file; sum match_count and assert it equals the manifest total before committing anything. Ours was 3,472 = 3,472, 0 failures.
  4. Commit. Note delete_by_source returns no deleted_count — do not read a count back from the result, verify externally.
  5. Verify both sides. The S507 pathology was metadata applying while HNSW refused, and it reported success: select count(*) … like '<pattern>' must reach 0, and mempalace repair-status must show divergence 0. Then run a real semantic search.

Result: sqlite 0 stage rows, drawers 394,967 = HNSW 394,967, divergence 0, search healthy. The S507 delete-compaction pathology is gone — it was the poisoned WAL, as S517 read it.

13.3 DR-111 registered — scoped SessionEnd mining replaces the per-turn mine

Section titled “13.3 DR-111 registered — scoped SessionEnd mining replaces the per-turn mine”

Landed this session, after the repair proved the vector layer:

  • ~/.mempalace/config.jsonhooks.auto_save: false (backup .bak-s519). This kills the plugin’s per-turn whole-slug mine and its CHECKPOINT: diary writes, which is intended — one of those CHECKPOINT jobs is what deadlocked the daemon on 31/07.
  • ~/.claude/settings.jsonSessionEnd runs bash $HOME/.mempalace/tools/canonical-session-end-mine.sh (backup .bak-s519).
  • Smoke-tested end to end with CANONICAL_MINE_DRY_RUN=1 before trusting it: OK slug=-Users-liamj-Documents-development-canonical, 69.3s, parents 206 / subagents 1,175, correct per-layer split.

/handoff Step 2c is now the diary’s single point of failure. It writes via the daemon queue, so a wedged daemon loses the entry silently — that is exactly how the S517 entry was lost. Always verify the job reached succeeded; submission is not landing:

Terminal window
Q=~/.mempalace/daemon/d25074d397df1749af7f58c4/queue.sqlite3
sqlite3 "file://$Q?mode=ro&immutable=1" \
"select id,kind,state,created_at,finished_at from jobs where kind='diary_write'
order by created_at desc limit 5;"

(The jobs table has no error column — don’t select one.)

13.4 DR-112 executed — the docs-site + ordna mine

Section titled “13.4 DR-112 executed — the docs-site + ordna mine”

Ran end to end S519. Final state: 1,770 files / 55,466 drawers, one wing (wing_canonical), nine rooms, zero general. Before: 1,996 files / 107,938 drawers, 100% general, four wings. Divergence 0 on both collections throughout.

roomdrawersfilesroomdrawersfiles
specs26,331591decisions2,572146
initiatives7,105121runbooks1,84240
archive6,830133retros1,634128
lineage4,668269reference64146
tasks3,843296

First-time entries: 116 Decision Register files (0 before), 296 ordna task files (0 before), 105 archive/ journals. Deleted: 943 manifest paths (68,536 drawers) plus 931 survivors, both waves residual rows: 0, 0 job failures.

Three defects in the design as written, all found by projecting the mine before running it. None were visible by reading.

  1. src/** + !src/content/docs/** re-includes nothing. gitignore never descends into an excluded directory, so the negation is unreachable — the first projection scanned zero docs files. The working form excludes a directory’s contents with dir/* and re-includes the wanted child before excluding one level down. A bare *.md compounded it by excluding markdown at every depth.
  2. The archive room’s DR-106 keywords are unreachable. detect_room walks path parts outermost-first and returns on the first part matching any room, so intended-architecture and phase-0-investigation are claimed by initiatives, and every _archive by its own family (specs, runbooks, reference, continuation-prompts). Only archive and product-functionality resolve. Confirmed post-mine: intended-architecture/01-vision.md and 03-tech-stack.md both land in room=initiatives. T3 as a path-routed tier does not work; discriminating those families is recall-path work (DR-112 Q6), keyed off source_file.
    • Corollary: DR-112 Q4 is a no-op. 01-vision.md was never going to land in archiveinitiatives matches four levels earlier. It was not moved.
    • Corollary: Q2 must land at the repo root, not tasks/archive/. Anything under tasks/ matches the tasks room at depth 1.
  3. Survivors are skipped, and skipping keeps them wrong. file_already_mined(..., check_mtime=True) skips any unchanged file, so the 931 previously-mined survivors would have kept room=general across four wings — the F5 incoherence the whole decision exists to close. §8’s “those files are deleted or re-mined into wing_canonical … no separate merge step needed” is wrong. Worse, make_drawer_id_from_chunk hashes the room into the drawer id, so a re-mine at a new room adds drawers rather than replacing them. The only self-consistent fix is delete-then-re-mine (owner-approved S519); patching room metadata in place would leave ids that no longer match their recipe, and the next edit to any such file would fork it into parallel drawers.

Step 9 of the design’s execution sequence — an owner-run repair --mode from-sqlite solo window between delete and mine — was dropped. Its stated rationale was that the rebuild is what makes vector-side deletion real (§11). §13.2 disproves that: deletes now apply vector-side unaided. Nothing in the plan needed a terminal.

Gotcha — a warm MCP server goes stale across a big out-of-process mutation. After the waves and mines (all run by the daemon), this session’s MCP server returned nearest-vector hits with wing: unknown, room: unknown and empty text — vectors found, content unresolvable — while repair-status read a clean divergence 0. mempalace_reconnect fixed it instantly. Call it after any bulk mutation made by another process, and never read an empty/degraded search as evidence about the palace.

Open, for the recall-path work (Q6): mempalace_search with room= returned 0 results for queries that match plainly when unfiltered — the #1665 filter defect the recall-grounding skill documents for wing. It applies to room too, and now that rooms are populated and meaningful, that defect is the thing standing between this mine and any observable recall improvement. Filter client-side until it is fixed.

13.5 01/08/2026 (S520) — room routing: the priority pre-pass, and two §12 corrections

Section titled “13.5 01/08/2026 (S520) — room routing: the priority pre-pass, and two §12 corrections”

#1665 is not a filter defect. room=/wing= are genuine ChromaDB pre-filtersbuild_where_filter feeds where straight into drawers_col.query (searcher.py:1214), and total_before_filter is only the n_results * 3 over-fetch, already filtered. The 0-result shape is _query_drawers_with_filter_fallback (searcher.py:1100): chromadb raises Error finding id on filtered queries when the HNSW index is inconsistent with the metadata store, and the code retries unfiltered + post-filters in Python. The remedy is repair + mempalace_reconnect, not a client-side workaround. S519 probed the filters minutes after its own bulk mine — i.e. in exactly the stale state §13.4 documents. Never judge a filter from a palace you have just mutated out-of-process.

Rooms may now be declared priority: true (DR-114). detect_room returns on the shallowest matching path part, so a parent family always beats a deeper one — that is why S519 found archive unreachable, and why reference as a decisions keyword swept the whole north-star set into room=decisions (452 of 2,572 drawers were actually DRs). A priority room is tested against every path part, in declaration order, before the depth-ordered walk; exclude_paths (fnmatch, project-relative) carves out exceptions so a directory marker is not all-or-nothing. Absent the flag, routing is byte-identical.

The patch lives in site-packages/mempalace/miner.pyuv tool upgrade re-arms the defect. Same standing hazard as DR-110’s hnsw:sync_threshold re-pin. Backup: miner.py.bak-s520. The daemon imports from site-packages, so it must be restarted before a mine picks the patch up. Upstream issue owed against github.com/MemPalace/mempalace.

Re-mine recipe (executed: 210 files / 7,993 drawers, residual 0, divergence 0):

Terminal window
P=/Users/liamj/.local/share/uv/tools/mempalace/bin/python
T=~/.mempalace/tools
$P $T/assert-room-routing-s520.py # PASS/FAIL per checkpoint, BEFORE anything mutates
$P $T/build-remine-manifest-s520.py manifest.tsv # lock-free diff: current room vs projected room
$P $T/export-affected-s520.py manifest.tsv out.jsonl # reversibility — content, not a 3 GB palace copy
$P $T/delete-affected-s520.py manifest.tsv # dry-run; asserts sum == manifest
$P $T/delete-affected-s520.py manifest.tsv --commit # then re-mine via a daemon `mine` job

match_count is drawers only — closets return closet_match_count separately, so a manifest counted from embedding_metadata (which spans both collections) must assert against the sum. On commit delete_by_source returns no count at all, so an assertion written against the dry-run shape reports a false failure on the irreversible pass.

Two §12.2 corrections, both from executing it rather than reading it:

  • “A stale lock file blocks the next writer” is FALSE. fcntl.flock is released by the OS when the holder dies; the file is residue. Verified by acquiring the lock left behind by S519’s dead SessionEnd hook (PID 50502). The NUL-safe parse is still right for diagnostics, but the verify-then-clear ritual is not protecting anything.
  • The daemon does NOT hold the mine lock for its entire lifetime. It was running, idle and unlocked. What blocks a writer is a live holder — usually a mine actually in flight. Restarting the daemon before bulk work is therefore fine, and is required when the work depends on a patched module.

14. 11/08/2026 (S552) — third recurrence; the wedge-spotting recipe; chunk-level deletes

Section titled “14. 11/08/2026 (S552) — third recurrence; the wedge-spotting recipe; chunk-level deletes”

14.1 Third full outage, same remedy — the recurrence is now a pattern

Section titled “14.1 Third full outage, same remedy — the recurrence is now a pattern”

Timeline: S504 (27/07) segfault outage → repair; S518 (31/07) collection-access deadlock → S519 repair; 05–06/08 segfault crash-loop (launchd err.log, ~20 crash reports) after which the restarted daemon deadlocked on its first job and sat wedged for 5.6 days — zero jobs completed 05→11/08, 155 queued behind it including every diary entry S535→S551. repair --mode from-sqlite (the patched s518 script) cleared it again: rebuild 381,762 = 381,762 divergence 0, DR-110 re-pin verified on BOTH collections, the formerly wedged job then succeeded on its final attempt, and the queue drained — 18 diary_write jobs landed within minutes of the restart.

Under mining load this palace loses its reader/writer paths roughly weekly on chromadb 1.5.9 (DR-098 accepted risk). Each repair is ~50 min plus a session’s attention. That run-rate is evidence for re-opening the deferred pgvector migration (id-299) — owner’s call, recorded here so the cost is visible.

14.2 How to SPOT the wedge (it does not look like an outage)

Section titled “14.2 How to SPOT the wedge (it does not look like an outage)”

mempalace daemon status answers normally while wedged. The discriminators:

  • succeeded count static across checks while queued grows — the S552 wedge showed the same succeeded total for a full day.
  • The active job’s started_at is days old (queue DB: ~/.mempalace/daemon/<id>/queue.sqlite3, open with mode=ro&immutable=1 — plain mode=ro fails on the WAL).
  • sample <daemon-pid> 3 shows every sample in the same chromadb_rust_bindings frames with _pthread_cond_wait hits (the §12.3 signature). A HEALTHY daemon mining burns 200%+ CPU; a wedged one sits near 0%.

14.3 Script + verification gotchas found by running it

Section titled “14.3 Script + verification gotchas found by running it”
  • Quiesce false-positive: pgrep -f mempalace matches any process whose command line merely CONTAINS the plugin-cache path (…/plugins/cache/mempalace/…) — chrome-devtools-mcp qualified, and the script failed its quiesce on a process holding no palace handle. Fixed in ~/repair-palace-s518.sh: match "bin/mempalace".
  • The post-repair smoke test races the queue drain. The restarted daemon starts mining the backlog immediately; a cold CLI search during that burst can fail with backfill request to compactor … Error deserializing pickle file: EOF — the §6/S506 transient under live-mine contention, NOT a failed repair. Re-run once the burst settles; S552’s search answered with real cosine scores minutes later.
  • immutable=1 reads can tear under heavy daemon writes (database disk image is malformed mid-query). Retry or wait for the drain; it is a snapshot artefact, not palace corruption.

14.4 Chunk-level deletes are supported (id-411 {411.2})

Section titled “14.4 Chunk-level deletes are supported (id-411 {411.2})”

mempalace_delete_drawer is in the daemon’s WRITE_TOOLS allowlist, so the §13.2 daemon-job loop works at drawer granularity — required for boilerplate classes that live as chunks INSIDE transcript files (CHECKPOINT rows, <local-command-caveat> blocks, skill-preamble echoes), where delete_by_source would destroy sibling argument chunks. Same discipline: lock-free manifest → JSONL export with content → assert export == manifest → submit at LOW priority (behind mines) → verify residuals externally. Chunk deletes need no closet cleanup — the file’s closet still summarises the remaining file.

14.5 Agreed trigger (owner, S552): the next recurrence reopens pgvector

Section titled “14.5 Agreed trigger (owner, S552): the next recurrence reopens pgvector”

If the segfault / collection-access-deadlock class recurs a fourth time, do not just repair and move on — reopen the pgvector-backend conversation. Owner-agreed at S552 close. There is no id-299 task file; mint the task at that point, citing §14.1’s economics (three full outages in six weeks, ~50 min repair plus a session’s attention each, all on chromadb 1.5.9’s accepted risk DR-098).