Derived-Store Coherence Audit — 2026-06-30
The tasks board drift (a deleted task file leaving a ghost row in the panel) is one
instance of a general question: across the 58-table coding-os.db, which derived
stores prune when their source-of-record disappears, and which append-only stores
are bounded? This audit answers both, names the gaps, and fixes them.
Two invariants every derived store must obey
- Prune-on-delete — when the source-of-record (a file, a parent row) is removed, the
derived rows must follow, via FK
ON DELETE CASCADE, anAFTER DELETEtrigger, or an event/reconcile sweep. - Retention — every append-only event/telemetry store must be bounded (cap, TTL, or rollup-then-truncate) so it cannot grow without limit.
Reference implementation: the graph
graph_os is the model the rest of the store should follow. A deleted file is pruned
immediately by auto-graph-reconcile-shell.sh (catches rm/mv/git rm) →
prune_deleted_path.py → DELETE FROM graph_nodes → FK CASCADE to edges/evidence. A
removed symbol is pruned by prune-before-reindex (delete_nodes_for_file). A full
cos graph-reindex reconciles stale paths; cos_graph_doctor --fix sweeps residual
orphans. Result: no orphan/ghost class.
Findings
Class 1 — orphan-on-delete
| Store | On source delete | Verdict |
|---|---|---|
| graph nodes / edges / evidence | rm hook + FK CASCADE + reconcile + doctor | ok (reference) |
| FTS ×4 (tasks / observations / doc_chunks / graph_nodes) | AFTER DELETE triggers |
ok |
| embedding_outbox | drain-time existence check | ok |
| document_chunks (edit path) | delete-then-insert | ok |
| tasks | sync_all is upsert-only — never prunes |
gap |
| document_chunks (file-delete path) | pruned only by a full index_docs; the single-file reindex hook and the nightly do not |
gap |
| embeddings | no FK / trigger; memory_gc() hunts them but is not scheduled |
gap |
| task_status_history / task_outcomes / task_edit_history | no FK, no trigger | gap (orphan once a task row is deleted) |
| graph_vec | tolerates orphans; harmless (rebuilt DROP+CREATE) | minor |
Class 2 — unbounded growth
log_events, agent_metrics, retrievals, outcome_history, retrieval_router_log,
project_trajectory, session_summaries, formula_dispatches, backtrack_events have
no retention. observations is partially managed (working memory expires via decay.py).
All are small today (150–9,000 rows); SQLite handles this for years. Deferred — see below.
Fix plan (in flight: TASK-715)
- Tasks prune-on-delete.
sync_allprunes rows whose file is absent — guarded to a full, uncapped walk only (a single-file sync must never prune its siblings; thecos graph-reindexlesson). The same migration addsAFTER DELETEtriggers ontask_status_history,task_outcomes,task_edit_history(mirroring the existingtasks_deps_adtrigger) so a pruned task row cascades. - Doc-chunk reconcile. A nightly task invokes the existing
_delete_orphaned_chunksso chunks for deleted docs are pruned without waiting for a manualmake docs-index. - Schedule
memory_gc(). The existing, tested GC (orphan embeddings + trash observations + stale edges) runs in the nightly. Reuse, not new triggers. - error_sweep
event_class. Log events carry anevent_class(fault | policy | audit); hook BLOCK events arepolicy; the sweep files onlyfault. Hook BLOCKs are policy enforcement succeeding, not bugs — they must never become board tasks.
Deliberately deferred (anti-overengineering)
- Unified retention framework. The unbounded stores are tiny and not near any limit;
a config-driven retention registry now is speculation. Revisit per-store if one actually
approaches a real bound. Fix 4 already curbs the fastest grower (
log_events). - Dedicated
auto-task-reconcilerm/mv hook. The full-sync prune (fix 1) covers the rare hand-delete; an instant per-delete hook is unearned while tasks are archived, not deleted. - Store registry +
cos doctor --stores. Elegant, but four targeted fixes close the current gaps; the registry is a framework for a problem already solved.