thread-archive 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
thread_archive/_api.py ADDED
@@ -0,0 +1,2235 @@
1
+ """The internal coordination layer for thread-archive.
2
+
3
+ Private, like every underscore-prefixed module — the CLI, the MCP servers, and
4
+ the web viewer are built on top of these functions; nothing outside the package
5
+ may import them. Each call
6
+ opens the archive — resolves the home, ensures its directories, pins it for the
7
+ process, and initializes the SQLite engine + schema — then dispatches into the
8
+ store / importers / search / truth / watch layers. Internal imports are lazy so
9
+ ``import thread_archive`` stays light and pulls in no server backends.
10
+
11
+ One archive per process: ``open_archive`` pins ``$THREAD_ARCHIVE_HOME`` so the
12
+ engine (index.db), the truth log (truth/), and search all resolve to the same
13
+ home. To switch archives, call :func:`close` first.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from ._config import ENV_HOME, ArchivePaths, resolve_paths
24
+
25
+ # (st_dev, st_ino) of index.db at the last open_archive — swap detection.
26
+ _index_ident: Optional[tuple[int, int]] = None
27
+
28
+
29
+ def _reconnect_if_swapped(paths: ArchivePaths) -> None:
30
+ """Dispose pooled connections when ``index.db`` was atomically replaced.
31
+
32
+ ``reindex`` publishes by renaming a freshly built database over ``index.db``;
33
+ another process's pooled connections keep the *old inode* open — their reads are
34
+ frozen at the pre-swap state and their commits write a file nothing else can see.
35
+ Every API call passes through :func:`open_archive`, so comparing the file identity
36
+ here makes long-lived processes (the librarian MCP, the web app) converge on the
37
+ new index on their next call."""
38
+ global _index_ident
39
+ try:
40
+ st = os.stat(paths.index_path)
41
+ except OSError:
42
+ _index_ident = None
43
+ return
44
+ ident = (st.st_dev, st.st_ino)
45
+ if _index_ident is not None and ident != _index_ident:
46
+ from ._store import get_engine
47
+
48
+ get_engine().dispose()
49
+ _index_ident = ident
50
+
51
+
52
+ def open_archive(home: Optional[str] = None) -> ArchivePaths:
53
+ """Open (and initialize) the archive at ``home`` (env / default if None).
54
+
55
+ Switching homes within a process repoints everything atomically: the engine is
56
+ rebuilt (``init_engine``) and the JSONL truth-log's cached append handles are
57
+ dropped, so a second ``open_archive`` to a different home can't leave SQLite
58
+ writing one store while JSONL appends resolve to another.
59
+ """
60
+ paths = resolve_paths(home).ensure()
61
+ target = paths.sqlalchemy_url
62
+ from ._store import active_dsn, init_db, init_engine
63
+
64
+ if active_dsn() is not None and active_dsn() != target:
65
+ from ._truth import reset_handles
66
+
67
+ reset_handles() # stale handles point at the previous home's files
68
+ # Pin the home so engine + truth + search resolve consistently for the process.
69
+ os.environ[ENV_HOME] = str(paths.home)
70
+ init_engine(target) # rebuilds when the DSN changed
71
+ _reconnect_if_swapped(paths)
72
+ init_db()
73
+ return paths
74
+
75
+
76
+ def close() -> None:
77
+ """Dispose the engine so a different archive can be opened."""
78
+ from ._store import close_engine
79
+ from ._truth import reset_handles
80
+
81
+ reset_handles()
82
+ close_engine()
83
+
84
+
85
+ # ── operational health records (<home>/health.json) ──────────────────────────
86
+ # When verify / backup last ran and how they went — the staleness signal that
87
+ # tells a dead scheduled job apart from a healthy one. Deliberately OUTSIDE the
88
+ # truth dir: this is install-local operational state, so backups don't mirror it
89
+ # (a restored truth must not claim the source install's health history) and the
90
+ # backup can't dirty the tree it is mirroring.
91
+ def _health_path() -> Path:
92
+ return resolve_paths().home / "health.json"
93
+
94
+
95
+ def _read_health() -> dict:
96
+ try:
97
+ return json.loads(_health_path().read_text(encoding="utf-8"))
98
+ except (OSError, ValueError):
99
+ return {}
100
+
101
+
102
+ def _record_health(key: str, record: dict) -> None:
103
+ """Set ``key`` to ``record`` (stamped with ``at``) — a locked read-modify-
104
+ write (exclusive flock on ``health.json.lock``, same discipline as the
105
+ manifest's): concurrent completions (a verify racing a backup, the nightly's
106
+ stages) each own different keys, and an unlocked whole-file replace would
107
+ lose whichever writer published first. Advisory data, so a failed write
108
+ logs and never breaks the operation it describes."""
109
+ import fcntl
110
+ from datetime import datetime, timezone
111
+
112
+ try:
113
+ p = _health_path()
114
+ p.parent.mkdir(parents=True, exist_ok=True)
115
+ fd = os.open(p.with_name(f"{p.name}.lock"), os.O_RDWR | os.O_CREAT, 0o644)
116
+ try:
117
+ fcntl.flock(fd, fcntl.LOCK_EX)
118
+ health = _read_health()
119
+ health[key] = {"at": datetime.now(timezone.utc).isoformat(), **record}
120
+ tmp = p.with_name(f"{p.name}.tmp.{os.getpid()}")
121
+ tmp.write_text(json.dumps(health, indent=2), encoding="utf-8")
122
+ os.replace(tmp, p)
123
+ finally:
124
+ os.close(fd) # closing the fd releases the flock
125
+ except OSError:
126
+ import logging
127
+
128
+ logging.getLogger(__name__).exception("could not record %s in health.json", key)
129
+
130
+
131
+ def search(
132
+ query: str,
133
+ *,
134
+ home: Optional[str] = None,
135
+ limit: int = 20,
136
+ thread_id: Optional[int] = None,
137
+ content_types: Optional[list[str]] = None,
138
+ exclude_content_types: Optional[list[str]] = None,
139
+ since: Optional[str] = None,
140
+ until: Optional[str] = None,
141
+ tool_name: Optional[str] = None,
142
+ source: Optional[list[str]] = None,
143
+ startswith: Optional[str] = None,
144
+ sort: Optional[str] = None,
145
+ output: Optional[str] = None,
146
+ context_lines: int = 2,
147
+ context_events: Optional[str] = None,
148
+ rerank: Optional[bool] = None,
149
+ ) -> list[dict]:
150
+ """Federated search over conversation events (lexical FTS5 + optional semantic
151
+ vectors → RRF fusion → weighted rank → optional cross-encoder re-rank). Returns
152
+ enriched event-hit dicts. ``source`` restricts to threads of the named
153
+ provider(s); ``startswith`` does a structural prefix scan; ``sort='oldest'``
154
+ returns the pool chronologically; ``output`` ('count'/'linkable') and
155
+ ``context_lines`` / ``context_events`` shape what each hit carries; ``rerank``
156
+ forces the cross-encoder stage (else auto-gated to conceptual queries when the
157
+ ``[embeddings]`` extra is present)."""
158
+ open_archive(home)
159
+ from ._retrieval import search as _search
160
+
161
+ return _search(
162
+ query,
163
+ limit=limit,
164
+ thread_id=thread_id,
165
+ content_types=content_types,
166
+ exclude_content_types=exclude_content_types,
167
+ since=since,
168
+ until=until,
169
+ tool_name=tool_name,
170
+ source=source,
171
+ startswith=startswith,
172
+ sort=sort,
173
+ output=output,
174
+ context_lines=context_lines,
175
+ context_events=context_events,
176
+ rerank=rerank,
177
+ )
178
+
179
+
180
+ def read_thread(
181
+ thread_id: int | str,
182
+ *,
183
+ home: Optional[str] = None,
184
+ limit: int = 200,
185
+ offset: int = 0,
186
+ summary: bool | str = False,
187
+ mode: Optional[str] = None,
188
+ user_only: Optional[bool] = None,
189
+ tool_results: bool = False,
190
+ max_chars: int = 0,
191
+ after_event: Optional[int] = None,
192
+ ) -> str:
193
+ """Reconstruct a conversation thread as a readable transcript.
194
+
195
+ ``thread_id`` is the archive's integer thread id or a provider **session id**
196
+ (the uuid/source_id a tool knows the conversation by). ``mode`` picks the view —
197
+ ``user`` (default), ``chat``, or ``full`` — and the read is turn-paginated +
198
+ size-budgeted (``max_chars``, default ~48k). ``tool_results`` (default off) adds
199
+ tool output under each call in ``full``. ``summary`` swaps in a summary view:
200
+ ``True``/``'toc'`` = compact TOC, ``'short'`` / ``'indexed'`` = the stored thread
201
+ summaries; see :func:`thread_archive._retrieval.read_thread` for the full contract."""
202
+ open_archive(home)
203
+ from ._retrieval import read_thread as _read
204
+
205
+ return _read(
206
+ thread_id,
207
+ limit=limit,
208
+ offset=offset,
209
+ summary=summary,
210
+ mode=mode,
211
+ user_only=user_only,
212
+ tool_results=tool_results,
213
+ max_chars=max_chars,
214
+ after_event=after_event,
215
+ )
216
+
217
+
218
+ def read_thread_structured(
219
+ thread_id: int | str,
220
+ *,
221
+ home: Optional[str] = None,
222
+ include_thinking: bool = True,
223
+ include_tools: bool = True,
224
+ ) -> dict:
225
+ """Reconstruct a thread as structured messages (typed render blocks) for the web
226
+ viewer. ``thread_id`` accepts an integer thread id or a provider session id.
227
+ Returns ``{thread_id, title, source, messages}``; see
228
+ :func:`thread_archive._retrieval.read_thread_structured`."""
229
+ open_archive(home)
230
+ from ._retrieval import read_thread_structured as _read
231
+
232
+ return _read(thread_id, include_thinking=include_thinking, include_tools=include_tools)
233
+
234
+
235
+ def import_path(path, *, home: Optional[str] = None, provider: str = "claude-code", source_id: Optional[str] = None):
236
+ """Import a transcript (line-stream providers) or scan a DB (cursor/opencode).
237
+
238
+ Each thread's metadata record and events are written to its own truth file as
239
+ the import commits, so the per-thread files are already a complete restore set.
240
+ The maintenance pass afterward just keeps the shard layout balanced and advances
241
+ the manifest watermark — conversation import never changes the cross-thread
242
+ overlays, so it does not rewrite those snapshots.
243
+ """
244
+ open_archive(home)
245
+ from ._importers import DB_SCANNERS, LINE_STREAM_IMPORTERS
246
+ from ._truth import checkpoint as _checkpoint
247
+ from ._truth import shared_ingest_lock
248
+
249
+ p = Path(path)
250
+ # Held shared across the truth append AND the SQLite commit: an unlocked import
251
+ # racing a reindex can land in the truth after the rebuild's read point and
252
+ # commit into the inode the swap replaces. Blocks (bounded by one rebuild)
253
+ # rather than skipping — a one-shot import has no later pass to retry on.
254
+ with shared_ingest_lock():
255
+ if provider in LINE_STREAM_IMPORTERS:
256
+ result = LINE_STREAM_IMPORTERS[provider](p, source_id or p.stem)
257
+ elif provider in DB_SCANNERS:
258
+ result = DB_SCANNERS[provider](p)
259
+ else:
260
+ raise ValueError(f"unknown provider {provider!r}")
261
+
262
+ _checkpoint(snapshots=False)
263
+ return result
264
+
265
+
266
+ def reindex(*, home: Optional[str] = None, vectors: bool = False, salvage: bool = False) -> dict:
267
+ """Rebuild index.db (relational + FTS) from the JSONL truth directory.
268
+
269
+ Fails closed when the rebuild would lose committed records the current index
270
+ holds (damaged truth lines, a deleted thread file) — the old index stays live
271
+ and the error names the loss; ``salvage=True`` publishes the lossy rebuild
272
+ anyway. Crash artifacts (torn lines that never committed) never block."""
273
+ open_archive(home)
274
+ from ._truth import reindex as _reindex
275
+
276
+ return _reindex(vectors=vectors, salvage=salvage)
277
+
278
+
279
+ def embed(
280
+ *,
281
+ home: Optional[str] = None,
282
+ rebuild: bool = False,
283
+ max_events: Optional[int] = None,
284
+ newest_first: bool = False,
285
+ ) -> dict:
286
+ """Embed the user/text events that are missing a vector (incremental anti-join).
287
+
288
+ The catch-up counterpart to the watcher's live cohost: ``reindex(vectors=True)``
289
+ rebuilds the whole vector index, this just fills the gap. ``rebuild=True``
290
+ re-embeds everything; ``max_events`` caps one call; ``newest_first`` drains the
291
+ freshest gap first (recent threads become semantically findable soonest). No-op
292
+ without the ``[embeddings]`` extra. Returns ``{'embedded': n}``."""
293
+ open_archive(home)
294
+ from ._retrieval.vectors import index_events_local
295
+
296
+ n = index_events_local(rebuild=rebuild, max_events=max_events, newest_first=newest_first)
297
+ return {"embedded": n}
298
+
299
+
300
+ def checkpoint(*, home: Optional[str] = None) -> dict:
301
+ """Snapshot the mutable authored tables (threads) to the JSONL truth log."""
302
+ open_archive(home)
303
+ from ._truth import checkpoint as _checkpoint
304
+
305
+ return _checkpoint()
306
+
307
+
308
+ def watch(*, home: Optional[str] = None, interval: float = 5.0, once: bool = False):
309
+ """Watch local AI-tool stores and import incrementally. Blocks unless ``once``."""
310
+ open_archive(home)
311
+ from ._truth import shared_ingest_lock
312
+ from ._watcher import Watcher
313
+
314
+ watcher = Watcher(home=home, interval=interval)
315
+ if once:
316
+ # run() takes the shared reindex lock per pass; a one-shot poll needs the
317
+ # same coverage (blocking — it has no next pass to retry on).
318
+ with shared_ingest_lock():
319
+ return watcher.poll_once()
320
+ watcher.run()
321
+ return None
322
+
323
+
324
+ # Delete-sync sanity bound: refuse to delete more than this fraction of the
325
+ # destination's files (once past the absolute floor). A mass-wipe of the source —
326
+ # a bug emptying truth/, an accidental rm — must not propagate to the last backup;
327
+ # a legitimate mass-move (shard rebalance) re-homes files, so the copies land
328
+ # first and the stale paths deleted stay a bounded fraction only when the copy
329
+ # half of the mirror actually ran.
330
+ _MIRROR_DELETE_FLOOR = 64
331
+ _MIRROR_DELETE_MAX_FRACTION = 0.25
332
+
333
+ # Destination-side generation snapshots: hardlink copies of the mirror's state,
334
+ # taken before each backup run overwrites it. See _snapshot_generation.
335
+ _GENERATIONS_SUBDIR = ".generations"
336
+ _GEN_KEEP_RECENT = 7
337
+ _GEN_KEEP_MONTHS = 6
338
+
339
+
340
+ def _is_append_only_truth(rel: Path) -> bool:
341
+ """True for truth files that only ever grow in normal operation: the per-thread
342
+ files and the curatorial event log. The cross-thread overlay snapshots and
343
+ ``import_state.jsonl`` are full rewrites and may legitimately shrink."""
344
+ return (
345
+ rel.parts[:1] == ("threads",) or rel.name == "kg_events.jsonl"
346
+ ) and rel.suffix == ".jsonl"
347
+
348
+
349
+ def _atomic_copy(sp: Path, dp: Path, *, trim_to_newline: bool) -> None:
350
+ """Copy ``sp`` over ``dp`` with no destructive window: the bytes land in a
351
+ same-directory temp file, fsynced, then an atomic rename publishes them — a
352
+ crash or disk-full mid-copy can never leave ``dp`` partial or destroy its
353
+ previous good copy. ``copy2`` under the hood, so mtime rides along and the
354
+ mirror's unchanged-skip keeps working.
355
+
356
+ ``trim_to_newline`` (append-only truth files) drops an unterminated final
357
+ fragment from the copy: the mirror doesn't quiesce writers, so a copy racing
358
+ a live append can catch half a line — trimmed to the last newline, every
359
+ published backup copy is a clean, parseable prefix of its source, and the
360
+ full line arrives with the next run."""
361
+ import shutil
362
+
363
+ tmp = dp.parent / f".{dp.name}.tmp-{os.getpid()}"
364
+ try:
365
+ shutil.copy2(sp, tmp)
366
+ with open(tmp, "rb+") as fh:
367
+ size = fh.seek(0, os.SEEK_END)
368
+ if trim_to_newline and size:
369
+ fh.seek(-1, os.SEEK_END)
370
+ if fh.read(1) != b"\n":
371
+ pos, last_nl = size, -1
372
+ while pos > 0:
373
+ step = min(65536, pos)
374
+ fh.seek(pos - step)
375
+ idx = fh.read(step).rfind(b"\n")
376
+ if idx >= 0:
377
+ last_nl = pos - step + idx
378
+ break
379
+ pos -= step
380
+ fh.truncate(last_nl + 1)
381
+ os.fsync(fh.fileno())
382
+ os.replace(tmp, dp)
383
+ except BaseException:
384
+ tmp.unlink(missing_ok=True)
385
+ raise
386
+
387
+
388
+ def _mirror_dir(
389
+ src: Path, dest: Path, *, delete: bool = False, allow_shrink: bool = False
390
+ ) -> dict:
391
+ """Incrementally mirror ``src`` into ``dest`` (skip files unchanged by size +
392
+ mtime). Each file is published atomically (:func:`_atomic_copy`), preserving
393
+ mtime so a re-run copies only what changed — the truth dir is append-mostly,
394
+ so a periodic backup moves little.
395
+
396
+ ``delete=True`` makes it a true mirror: destination files with no source
397
+ counterpart are removed (and emptied directories pruned). Without it the mirror
398
+ is additive, and a shard rebalance — which *moves* thread files to new paths —
399
+ leaves the backup holding both layouts. Deletion is bounded: when the planned
400
+ deletions exceed both the absolute floor and the fraction cap of the
401
+ destination's files, they are skipped (``deletions_skipped``) — the mirror
402
+ stays additive rather than letting a gutted source strip the backup.
403
+
404
+ **Re-homed twins are exempt from that bound.** A rebalance moves every thread
405
+ file at once, so the stale old-layout copies at the destination can vastly
406
+ exceed the cap — and skipping them forever leaves the backup double-sized and
407
+ holding records a restore must not see. A doomed ``threads/**`` file whose
408
+ thread has a file at the source's canonical shard depth — present at *both*
409
+ ends, with the destination's canonical copy at least as large as the stale
410
+ one — is provably superseded (the sweep moves/merges, never truncates), so it
411
+ is deleted (``rehomed_twins_deleted``) regardless of the cap. Everything else
412
+ stays capped.
413
+
414
+ **Shrink guard.** An append-only truth file (``threads/**``, ``kg_events.jsonl``)
415
+ whose source copy is *smaller* than its backup copy means the source lost data —
416
+ truncation, corruption, an accidental overwrite. Copying it would destroy the
417
+ last good copy, so the guard keeps the destination file and counts the skip
418
+ (``shrinks_skipped`` + sample); the trailing size check reports it as divergent
419
+ until it is investigated. ``allow_shrink=True`` is the deliberate override for
420
+ an understood re-emit (``rebuild_truth_from_store`` legitimately rewrites files
421
+ smaller)."""
422
+ from ._truth.jsonl_log import _fsync_dir
423
+
424
+ copied = total = deleted = skipped = shrinks = 0
425
+ shrink_sample: list[str] = []
426
+ synced_dirs: set[Path] = set()
427
+ for sp in src.rglob("*"):
428
+ if sp.is_dir():
429
+ continue
430
+ rel = sp.relative_to(src)
431
+ dp = dest / rel
432
+ if dp.exists():
433
+ ss, ds = sp.stat(), dp.stat()
434
+ if ss.st_size == ds.st_size and int(ss.st_mtime) <= int(ds.st_mtime):
435
+ continue
436
+ if (
437
+ not allow_shrink
438
+ and ss.st_size < ds.st_size
439
+ and _is_append_only_truth(rel)
440
+ ):
441
+ shrinks += 1
442
+ if len(shrink_sample) < 10:
443
+ shrink_sample.append(str(rel))
444
+ continue
445
+ dp.parent.mkdir(parents=True, exist_ok=True)
446
+ _atomic_copy(sp, dp, trim_to_newline=_is_append_only_truth(rel))
447
+ copied += 1
448
+ total += dp.stat().st_size
449
+ synced_dirs.add(dp.parent)
450
+ for sd in synced_dirs:
451
+ _fsync_dir(sd) # the renames that published this pass's copies must stick
452
+ twins_deleted = 0
453
+ if delete:
454
+ # The generations subtree is destination-only state (hardlink snapshots
455
+ # of prior mirror runs) — never a deletion candidate, and never counted
456
+ # toward the deletion cap's denominator.
457
+ dest_files = [
458
+ dp for dp in dest.rglob("*")
459
+ if not dp.is_dir() and dp.relative_to(dest).parts[0] != _GENERATIONS_SUBDIR
460
+ ]
461
+ doomed = [dp for dp in dest_files if not (src / dp.relative_to(dest)).exists()]
462
+ if doomed:
463
+ twins, doomed = _split_rehomed_twins(src, dest, doomed)
464
+ for dp in twins:
465
+ dp.unlink()
466
+ twins_deleted += 1
467
+ deleted += 1
468
+ limit = max(_MIRROR_DELETE_FLOOR, int(len(dest_files) * _MIRROR_DELETE_MAX_FRACTION))
469
+ if len(doomed) > limit:
470
+ skipped = len(doomed)
471
+ else:
472
+ for dp in doomed:
473
+ dp.unlink()
474
+ deleted += 1
475
+ if deleted:
476
+ for dp in sorted((p for p in dest.rglob("*") if p.is_dir()), reverse=True):
477
+ try:
478
+ dp.rmdir() # only succeeds when empty
479
+ except OSError:
480
+ pass
481
+ return {
482
+ "files_copied": copied,
483
+ "bytes_copied": total,
484
+ "files_deleted": deleted,
485
+ "rehomed_twins_deleted": twins_deleted,
486
+ "deletions_skipped": skipped,
487
+ "shrinks_skipped": shrinks,
488
+ "shrink_sample": shrink_sample,
489
+ }
490
+
491
+
492
+ def _split_rehomed_twins(src: Path, dest: Path, doomed: list[Path]) -> tuple[list[Path], list[Path]]:
493
+ """Partition planned mirror deletions into provably-superseded rebalance
494
+ twins and everything else (see :func:`_mirror_dir`). A twin qualifies only
495
+ when its thread's canonical-depth file exists at the source *and* the
496
+ destination's copy of that canonical file is at least as large as the stale
497
+ one — the rebalance sweep moves/merges whole files, so a genuine re-home can
498
+ never leave the canonical copy smaller."""
499
+ from ._truth.jsonl_log import THREADS_SUBDIR, _shard_depth, _thread_relpath
500
+
501
+ depth = _shard_depth(src)
502
+ twins: list[Path] = []
503
+ rest: list[Path] = []
504
+ for dp in doomed:
505
+ rel = dp.relative_to(dest)
506
+ if rel.parts[0] == THREADS_SUBDIR and dp.suffix == ".jsonl":
507
+ try:
508
+ tid = int(dp.stem)
509
+ except ValueError:
510
+ tid = None
511
+ if tid is not None:
512
+ canonical = _thread_relpath(tid, depth)
513
+ try:
514
+ if (
515
+ canonical != rel
516
+ and (src / canonical).exists()
517
+ and (dest / canonical).stat().st_size >= dp.stat().st_size
518
+ ):
519
+ twins.append(dp)
520
+ continue
521
+ except OSError: # canonical dest copy missing/unreadable — stay capped
522
+ pass
523
+ rest.append(dp)
524
+ return twins, rest
525
+
526
+
527
+ def _snapshot_generation(dest: Path) -> dict:
528
+ """Hardlink-snapshot the mirror's current state into
529
+ ``<dest>/.generations/<UTC stamp>/`` — taken *before* the mirror run
530
+ overwrites it, so each generation is the destination as the previous run
531
+ left it. The rolling mirror alone propagates destruction: same-size
532
+ corruption, a mistaken ``--allow-shrink``, or a bad repair overwrites the
533
+ only other copy on the next nightly run. Generations make that recoverable.
534
+
535
+ Hardlinks make a generation nearly free (the truth is append-mostly, and
536
+ the mirror only ever publishes destination files via whole-file rename —
537
+ ``_atomic_copy`` — so a snapshot's linked inodes are never mutated by later
538
+ runs; deletions just unlink the mirror's name). One generation per run —
539
+ every overwrite of the mirror has a pre-state snapshot, so a second (bad)
540
+ backup on the same day can't destroy the day's only pre-state. The
541
+ snapshot is built under a dot-tmp name and renamed into place (the gens
542
+ dir fsynced after), so a killed run never leaves a directory that looks
543
+ like a complete generation and a published one survives power loss.
544
+
545
+ Retention coalesces by day, never within one: every generation from the
546
+ newest ``_GEN_KEEP_RECENT`` distinct UTC days is kept (pruning a same-day
547
+ sibling would discard exactly the pre-bad-run state generations exist
548
+ for), plus the newest generation of each distinct month until
549
+ ``_GEN_KEEP_MONTHS`` months are covered. Failure to snapshot degrades to
550
+ the pre-generations behavior (reported, never blocks the mirror itself)."""
551
+ import logging
552
+ import shutil
553
+ from datetime import datetime, timezone
554
+
555
+ out: dict = {"generation_created": None, "generations_pruned": 0}
556
+ if not (dest / "manifest.json").exists():
557
+ return out # first run: nothing at the destination to preserve
558
+ gens = dest / _GENERATIONS_SUBDIR
559
+ gens.mkdir(exist_ok=True)
560
+ for stale in gens.glob(".tmp-*"): # a killed snapshot's half-built tree
561
+ shutil.rmtree(stale, ignore_errors=True)
562
+ now = datetime.now(timezone.utc)
563
+ names = sorted((p.name for p in gens.iterdir() if p.is_dir()), reverse=True)
564
+ name = now.strftime("%Y%m%dT%H%M%SZ")
565
+ while name in names: # two runs within a second — still one gen per run
566
+ name += "x"
567
+ tmp = gens / f".tmp-{name}"
568
+ try:
569
+ linked = 0
570
+ for sp in dest.rglob("*"):
571
+ if sp.is_dir():
572
+ continue
573
+ rel = sp.relative_to(dest)
574
+ if rel.parts[0] == _GENERATIONS_SUBDIR:
575
+ continue
576
+ gp = tmp / rel
577
+ gp.parent.mkdir(parents=True, exist_ok=True)
578
+ try:
579
+ os.link(sp, gp)
580
+ except OSError: # filesystem without hardlinks — take the copy cost
581
+ shutil.copy2(sp, gp)
582
+ linked += 1
583
+ os.rename(tmp, gens / name)
584
+ from ._truth.jsonl_log import _fsync_dir
585
+
586
+ _fsync_dir(gens) # the publishing rename itself must survive power loss
587
+ out["generation_created"] = name
588
+ out["generation_files"] = linked
589
+ names.insert(0, name)
590
+ names.sort(reverse=True)
591
+ except OSError as e: # snapshot is protection, not the backup itself
592
+ shutil.rmtree(tmp, ignore_errors=True)
593
+ out["generation_error"] = str(e)
594
+ logging.getLogger(__name__).exception("backup: generation snapshot failed")
595
+ # Coalesce by day, never within one (see docstring).
596
+ days: list[str] = []
597
+ for n in names: # newest first
598
+ if n[:8] not in days:
599
+ days.append(n[:8])
600
+ keep = {n for n in names if n[:8] in set(days[:_GEN_KEEP_RECENT])}
601
+ months = {n[:6] for n in keep}
602
+ for n in names:
603
+ if n not in keep and n[:6] not in months and len(months) < _GEN_KEEP_MONTHS:
604
+ keep.add(n)
605
+ months.add(n[:6])
606
+ for n in names:
607
+ if n not in keep:
608
+ shutil.rmtree(gens / n, ignore_errors=True)
609
+ out["generations_pruned"] += 1
610
+ out["generations_kept"] = len(keep)
611
+ return out
612
+
613
+
614
+ def backup(
615
+ dest: str,
616
+ *,
617
+ home: Optional[str] = None,
618
+ allow_shrink: bool = False,
619
+ verify_first: bool = True,
620
+ ) -> dict:
621
+ """Back the archive up by mirroring its JSONL truth directory to ``dest``.
622
+
623
+ A ``cp``/``rsync`` of the truth dir *is* the backup (index.db is rebuildable from
624
+ it), so this checkpoints first — flushing the cross-thread overlays + any thread-
625
+ metadata updates so the on-disk truth is a complete restore set — then mirrors
626
+ ``truth/`` into ``dest`` incrementally. Point ``dest`` at a *different disk /
627
+ machine*: for pre-retention history the truth log is the only copy.
628
+
629
+ The source is verified before it is mirrored (``verify_first``): a truth dir
630
+ that fails the shallow integrity check (parse errors, index ⊃ truth drift) is
631
+ still backed up — a flawed copy beats no copy — but *additively*: delete-sync
632
+ is disabled for the run so a sick source can't strip the last good backup, and
633
+ ``verify_ok=False`` in the result flags the run for investigation.
634
+
635
+ The mirror holds the rebalance lock so a shard sweep can't move files under it
636
+ (which could otherwise leave a moved file in *neither* layout in the backup for
637
+ a whole cycle), and it finishes with a structural check — per-file size parity
638
+ of every source ``.jsonl`` against its destination copy (``mirror_complete``).
639
+ Live appends between the copy pass and the check make a file *larger* at the
640
+ source; that isn't a mirror failure, so the check tolerates dest ≤ src growth
641
+ on files it copied and only flags missing or divergent copies. Append-only
642
+ truth files are additionally shrink-guarded (see :func:`_mirror_dir`);
643
+ ``allow_shrink=True`` overrides after a deliberate truth re-emit.
644
+
645
+ Before the mirror touches anything, the destination's current state is
646
+ preserved as a hardlink generation under ``<dest>/.generations/``
647
+ (:func:`_snapshot_generation`) — the recovery margin for destruction the
648
+ in-run guards can't see. ``archive restore-drill`` proves the mirror (or a
649
+ generation) actually restores.
650
+ """
651
+ open_archive(home)
652
+ from ._truth import checkpoint as _checkpoint
653
+ from ._truth.jsonl_log import _truth_write_lock, _try_rebalance_lock
654
+
655
+ _checkpoint() # full: overlays + metadata-update backstop → truth is a complete restore set
656
+ # Verify AFTER the checkpoint, so verify_ok describes the tree the mirror
657
+ # actually copies — a verdict on the pre-checkpoint state could bless (or
658
+ # smear) a different truth than the one being backed up.
659
+ verify_ok = True
660
+ if verify_first:
661
+ verify_ok = bool(verify(home=home)["ok"])
662
+
663
+ paths = resolve_paths(home)
664
+ # Refresh the durable vector cache so live-embedded vectors (the watcher's
665
+ # cohost writes them into index.db only) survive an index loss and ride the
666
+ # mirror. No-op when the store holds no vectors — an empty save never
667
+ # replaces a populated sidecar.
668
+ from ._retrieval.vectors import save_vectors_sidecar
669
+
670
+ vectors_cached = save_vectors_sidecar(paths.truth_dir)
671
+ dest_path = Path(dest).expanduser()
672
+ dest_path.mkdir(parents=True, exist_ok=True)
673
+ # A destination on the same filesystem as the truth dir protects against a
674
+ # bad write, not against the disk: one device failure (or a stolen machine)
675
+ # takes source, mirror, and every hardlink generation together. Report-only
676
+ # — a same-disk mirror still beats none — but the flag rides the result and
677
+ # the health record so `archive status` and anything watching health.json
678
+ # can keep saying so until a real second copy exists.
679
+ try:
680
+ same_device = os.stat(dest_path).st_dev == os.stat(paths.truth_dir).st_dev
681
+ except OSError:
682
+ same_device = None
683
+ # Delete-sync only when the source looks like a real, checkpointed truth dir —
684
+ # a mirror of an empty/foreign source must never strip a good backup — and only
685
+ # when it verified clean (a sick source mirrors additively; see docstring).
686
+ delete = (paths.truth_dir / "manifest.json").exists() and verify_ok
687
+ # Preserve the destination's pre-run state as a hardlink generation BEFORE
688
+ # the mirror overwrites it — the recovery margin for anything the guards
689
+ # can't see (same-size corruption, a mistaken --allow-shrink, a bad repair).
690
+ generations = _snapshot_generation(dest_path)
691
+ with _try_rebalance_lock() as held:
692
+ # If a rebalance sweep is mid-flight, mirror additively (no deletions):
693
+ # copies of both layouts are safe; stale-path deletion waits for the next run.
694
+ #
695
+ # The truth-write mutex is held for the whole traversal so the mirror is a
696
+ # drain-consistent snapshot: no append batch can land in (or be rolled back
697
+ # out of) a truth file between the mirror reading one file and the next.
698
+ # Without it the copy can capture a mid-drain partial batch — one thread's
699
+ # file with a transaction's rows and another's without — or a pre-rollback
700
+ # append that the drain then truncates away, which the shrink guard would
701
+ # afterwards pin in the mirror as a divergent file. Writers pause for the
702
+ # traversal; incremental runs copy little, so the pause is brief.
703
+ with _truth_write_lock():
704
+ result = _mirror_dir(
705
+ paths.truth_dir, dest_path, delete=delete and held, allow_shrink=allow_shrink
706
+ )
707
+ result.update(generations)
708
+
709
+ # Structural completeness: every source .jsonl must exist at the destination,
710
+ # at ≥ its size at copy time (append-only files may have grown since).
711
+ missing = divergent = 0
712
+ for sp in paths.truth_dir.rglob("*.jsonl"):
713
+ dp = dest_path / sp.relative_to(paths.truth_dir)
714
+ try:
715
+ ds = dp.stat()
716
+ except OSError:
717
+ missing += 1
718
+ continue
719
+ if ds.st_size > sp.stat().st_size:
720
+ divergent += 1 # dest larger than source: divergent copy, not growth
721
+ result["mirror_complete"] = missing == 0 and divergent == 0
722
+ result["dest_missing_files"] = missing
723
+ result["dest_divergent_files"] = divergent
724
+
725
+ # Record the outcome in the home's health file (surfaced by `archive status`):
726
+ # a backup agent that quietly stops running is indistinguishable from a
727
+ # healthy one by its log files alone — the record's age is the signal.
728
+ _record_health("backup_last", {
729
+ "dest": str(dest_path),
730
+ "ok": bool(verify_ok and result["mirror_complete"] and not result["deletions_skipped"]),
731
+ "verify_ok": verify_ok,
732
+ "mirror_complete": result["mirror_complete"],
733
+ "files_copied": result["files_copied"],
734
+ "same_device": same_device,
735
+ })
736
+ _stamp_heartbeat()
737
+ return {
738
+ "truth_dir": str(paths.truth_dir),
739
+ "dest": str(dest_path),
740
+ "vectors_cached": vectors_cached,
741
+ "verify_ok": verify_ok,
742
+ "same_device": same_device,
743
+ **result,
744
+ }
745
+
746
+
747
+ def restore_drill(
748
+ dest: str, *, home: Optional[str] = None, keep_home: bool = False
749
+ ) -> dict:
750
+ """Prove the backup actually restores: rebuild a full index from the mirror
751
+ in a throwaway home and check what materialized against the mirror's own scan.
752
+
753
+ ``verify --backup`` parses and counts the mirror; this is the missing last
754
+ step — an end-to-end rehearsal of the recovery path (copy the truth dir,
755
+ ``reindex`` from it: schema, per-thread load, kg replay, FTS, the vector
756
+ sidecar restore, quick_check). ``ok`` means the mirror parsed clean, the
757
+ rebuilt index materialized exactly the mirror's effective counts, the
758
+ restored archive covers the live one (``coverage`` = rebuilt events / live
759
+ events ≥ 0.98 — the mirror is minutes old when the scheduled drill runs, so
760
+ materially lower means the backup restores to less than the archive it is
761
+ supposed to protect), and a smoke pass (:func:`_drill_smoke`) proved the
762
+ rebuilt archive actually *reads and searches*, not just materializes.
763
+ Heavy (a full index build) — sized for the nightly 04:00 window, where
764
+ ``archive nightly`` runs it after every backup.
765
+
766
+ The drill home is a temp directory (``keep_home=True`` keeps it for
767
+ inspection, e.g. to point a reader at the restored index); the live archive
768
+ is reopened before returning, and the outcome lands in ``health.json``
769
+ (``restore_drill_last``) so a drill that stops running looks stale."""
770
+ import shutil
771
+ import tempfile
772
+ import time
773
+
774
+ paths = open_archive(home)
775
+ from ._truth import scan_truth_counts
776
+
777
+ dest_path = Path(dest).expanduser()
778
+ if not (dest_path / "threads").exists():
779
+ return {"dest": str(dest_path), "ok": False, "error": "not a truth mirror"}
780
+ live_home = str(paths.home)
781
+ started = time.monotonic()
782
+ from sqlalchemy import func, select
783
+
784
+ from ._store import Event, get_session
785
+
786
+ with get_session() as s:
787
+ live_events = s.execute(select(func.count()).select_from(Event)).scalar() or 0
788
+ scan = scan_truth_counts(truth_dir=dest_path)
789
+ result: dict = {"dest": str(dest_path), "mirror": scan, "live_events": int(live_events)}
790
+ drill_home = Path(tempfile.mkdtemp(prefix="thread-archive-restore-drill-"))
791
+ try:
792
+ # The generations subtree and any half-published mirror temp files are
793
+ # destination bookkeeping, not truth — the drill restores the mirror.
794
+ shutil.copytree(
795
+ dest_path, drill_home / "truth",
796
+ ignore=shutil.ignore_patterns(_GENERATIONS_SUBDIR, ".*.tmp-*"),
797
+ )
798
+ open_archive(str(drill_home))
799
+ from ._truth import reindex as _reindex
800
+
801
+ try:
802
+ counts = _reindex()
803
+ except RuntimeError as e: # a refused/failed rebuild IS the drill's finding
804
+ result.update({"ok": False, "error": str(e)})
805
+ counts = None
806
+ if counts is not None:
807
+ result["rebuilt"] = counts
808
+ result["coverage"] = round(counts["events"] / (live_events or 1), 6)
809
+ result["smoke"] = _drill_smoke(
810
+ str(drill_home), expect_content=counts["events"] > 0
811
+ )
812
+ result["ok"] = (
813
+ scan["parse_errors"] == 0
814
+ and counts["events"] == scan["events_effective"]
815
+ and counts["threads"] == scan["threads"]
816
+ and result["coverage"] >= 0.98
817
+ and result["smoke"]["ok"]
818
+ )
819
+ finally:
820
+ close()
821
+ if keep_home:
822
+ result["drill_home"] = str(drill_home)
823
+ else:
824
+ shutil.rmtree(drill_home, ignore_errors=True)
825
+ open_archive(live_home)
826
+ result["seconds"] = round(time.monotonic() - started, 1)
827
+ _record_health("restore_drill_last", {
828
+ "dest": str(dest_path),
829
+ "ok": bool(result.get("ok")),
830
+ "events": scan["events_effective"],
831
+ "coverage": result.get("coverage"),
832
+ "seconds": result["seconds"],
833
+ })
834
+ _stamp_heartbeat()
835
+ return result
836
+
837
+
838
+ # Age gates for the escalated verify tiers `nightly` folds in on top of its
839
+ # nightly backup + shallow verify + restore drill.
840
+ _DEEP_EVERY_DAYS = 7
841
+ _HASHES_EVERY_DAYS = 30
842
+
843
+
844
+ def _health_is_due(key: str, every_days: float) -> bool:
845
+ """True when health record ``key`` is missing, unparseable, stale, or was
846
+ not ok — the age gate that replaces weekday/day-of-month schedule math: a
847
+ missed (machine off) or failed escalated pass makes the *next* nightly run
848
+ pick it up, instead of waiting for the calendar to come around again."""
849
+ from datetime import datetime, timezone
850
+
851
+ rec = _read_health().get(key)
852
+ try:
853
+ at = datetime.fromisoformat(rec["at"])
854
+ except (TypeError, KeyError, ValueError):
855
+ return True
856
+ if not rec.get("ok"):
857
+ return True
858
+ if at.tzinfo is None:
859
+ at = at.replace(tzinfo=timezone.utc)
860
+ return (datetime.now(timezone.utc) - at).total_seconds() >= every_days * 86400
861
+
862
+
863
+ # Each pipeline stage, mapped to the health record that a later, out-of-band run
864
+ # of that same stage writes. This is what lets a stage's failure be *retired* by
865
+ # evidence rather than only by another full nightly.
866
+ _STAGE_RECORD = {
867
+ "backup": "backup_last",
868
+ "verify": "verify_last",
869
+ "restore-drill": "restore_drill_last",
870
+ }
871
+
872
+
873
+ def _parse_at(value: object) -> Optional[float]:
874
+ """Epoch seconds from a health/heartbeat ``at`` stamp; None if absent or bad."""
875
+ from datetime import datetime, timezone
876
+
877
+ if not isinstance(value, str):
878
+ return None
879
+ try:
880
+ at = datetime.fromisoformat(value)
881
+ except ValueError:
882
+ return None
883
+ if at.tzinfo is None:
884
+ at = at.replace(tzinfo=timezone.utc)
885
+ return at.timestamp()
886
+
887
+
888
+ def _stage_recovered(stage: str, nightly: dict, health: dict) -> bool:
889
+ """Has ``stage`` — which failed in the last nightly — since been proven good?
890
+
891
+ True only when that stage's own health record is green, **postdates** the
892
+ nightly that failed, and came from a check **at least as strong** as the one
893
+ that failed.
894
+
895
+ Strength is what makes this safe, and it matters for verify alone: the
896
+ nightly escalates verify on age gates (``deep``, ``hashes``, and the mirror
897
+ parse-scan, which rides ``--backup`` exactly when deep is due). A later
898
+ *basic* verify passing says nothing about a deep tier that failed, so
899
+ clearing on it would be a false green — the cheap check laundering the
900
+ expensive red. Requiring ≥ strength is the whole guard; without it this
901
+ function is a bug, not a feature.
902
+ """
903
+ rec = health.get(_STAGE_RECORD.get(stage, ""))
904
+ if not isinstance(rec, dict) or not rec.get("ok"):
905
+ return False
906
+ when, nightly_at = _parse_at(rec.get("at")), _parse_at(nightly.get("at"))
907
+ if when is None or nightly_at is None or when <= nightly_at:
908
+ return False
909
+ if stage != "verify":
910
+ return True
911
+ # The nightly's verify parse-scans the mirror exactly when its deep tier is
912
+ # due, so `deep` gates the backup-scan requirement as well as its own.
913
+ deep = bool(nightly.get("deep"))
914
+ for tier, required in (("deep", deep), ("hashes", bool(nightly.get("hashes"))),
915
+ ("backup", deep)):
916
+ if required and not rec.get(tier):
917
+ return False
918
+ return True
919
+
920
+
921
+ def _pipeline_verdict(health: Optional[dict] = None) -> dict:
922
+ """The pipeline's state **now** — not merely a transcript of the last nightly.
923
+
924
+ The last nightly's failed stages, minus every stage a later at-least-as-strong
925
+ run has since proven good.
926
+
927
+ Without this, the *only* thing that can retire a fault is another full nightly
928
+ (~1h, restore-drill dominated). An operator who fixes the cause and proves it
929
+ fixed — at a stronger tier than the one that failed — still faces a board
930
+ asserting the archive is unprotected until 04:00 comes around. That is a stale
931
+ alarm on the one signal that says whether Ella's memory is recoverable, and a
932
+ signal that keeps crying after the fire is out is one that stops being read.
933
+ """
934
+ health = _read_health() if health is None else health
935
+ nightly = health.get("nightly_last") or {}
936
+ failed = [s for s in (nightly.get("failed_stages") or []) if isinstance(s, str)]
937
+ recovered = [s for s in failed if _stage_recovered(s, nightly, health)]
938
+ unresolved = [s for s in failed if s not in recovered]
939
+ return {
940
+ "ran": bool(nightly),
941
+ "ok": not unresolved,
942
+ "failed_stages": unresolved,
943
+ "recovered_stages": recovered,
944
+ "nightly_at": nightly.get("at"),
945
+ "dest": nightly.get("dest"),
946
+ }
947
+
948
+
949
+ def _stamp_heartbeat() -> None:
950
+ """Publish the pipeline verdict to the family-monitor heartbeat.
951
+
952
+ thread-monitor freshness-checks periodic jobs via ``~/.thread/logs`` (the
953
+ shared heartbeat ground — it never reads sibling products' private stores, so
954
+ health.json alone is invisible to it). This file is therefore the entire
955
+ contract, and it carries the two independent facts the monitor needs:
956
+
957
+ - ``nightly_at`` — when the nightly last *ran*. The monitor anchors its
958
+ staleness check on this field, **not** on the file's mtime, because every
959
+ out-of-band stage run below rewrites the file: mtime would report "a run
960
+ happened" on a box whose nightly job has been dead for a week.
961
+ - ``ok`` / ``failed_stages`` — the verdict with recovered stages retired, so a
962
+ proven out-of-band fix clears the board without waiting out another pipeline.
963
+
964
+ Stamped on every nightly completion whatever the outcome, and again whenever a
965
+ stage is re-run on its own. Fail-soft, and skipped entirely when the dir does
966
+ not exist (an install outside the thread family has no monitor to feed) or when
967
+ no nightly has ever run (the monitor reads an absent heartbeat as "awaiting
968
+ first run" — a lone stage run must not pre-empt that). The env override exists
969
+ so tests never stamp the real box's heartbeat.
970
+ """
971
+ from datetime import datetime, timezone
972
+
973
+ hb_dir = Path(
974
+ os.environ.get("THREAD_ARCHIVE_HEARTBEAT_DIR")
975
+ or Path.home() / ".thread" / "logs"
976
+ )
977
+ if not hb_dir.is_dir():
978
+ return
979
+ verdict = _pipeline_verdict()
980
+ if not verdict["ran"]:
981
+ return
982
+ try:
983
+ (hb_dir / "archive-nightly.heartbeat").write_text(
984
+ json.dumps({
985
+ "at": datetime.now(timezone.utc).isoformat(),
986
+ "nightly_at": verdict["nightly_at"],
987
+ "ok": verdict["ok"],
988
+ "failed_stages": verdict["failed_stages"],
989
+ "recovered_stages": verdict["recovered_stages"],
990
+ "dest": verdict["dest"],
991
+ }) + "\n",
992
+ encoding="utf-8",
993
+ )
994
+ except OSError:
995
+ pass
996
+
997
+
998
+ def _notify(url: str, message: str) -> Optional[str]:
999
+ """POST a notification (lab's ``/api/notify`` shape: ``{title, message}``).
1000
+ Fail-soft — returns an error string instead of raising: health.json and the
1001
+ job log are the durable record; the push is best-effort."""
1002
+ import urllib.request
1003
+
1004
+ body = json.dumps({"title": "thread-archive", "message": message}).encode()
1005
+ req = urllib.request.Request(
1006
+ url, data=body, headers={"Content-Type": "application/json"}
1007
+ )
1008
+ try:
1009
+ with urllib.request.urlopen(req, timeout=5):
1010
+ return None
1011
+ except Exception as e:
1012
+ return f"{type(e).__name__}: {e}"
1013
+
1014
+
1015
+ def nightly(
1016
+ dest: str,
1017
+ *,
1018
+ home: Optional[str] = None,
1019
+ notify_url: Optional[str] = None,
1020
+ allow_shrink: bool = False,
1021
+ drill: bool = True,
1022
+ ) -> dict:
1023
+ """The scheduled protection pipeline, as one command: ``backup`` → ``verify``
1024
+ (with age-gated escalation) → ``restore_drill`` — every night.
1025
+
1026
+ Replaces a shell chain of the three commands. The differences that matter:
1027
+
1028
+ - **Every stage runs** (no ``&&`` short-circuit): a failed backup must not
1029
+ also cost the night's integrity check and drill — each stage's outcome is
1030
+ recorded separately (its own health.json record plus ``nightly_last``),
1031
+ so an alert can say *which* stage broke while the others' green stays
1032
+ visible.
1033
+ - **Escalation is age-gated, not calendar-gated**: the deep verify (+ the
1034
+ mirror parse-scan) folds in when ``verify_deep_last`` is missing, older
1035
+ than ``_DEEP_EVERY_DAYS``, or failed; ``--hashes`` likewise on
1036
+ ``_HASHES_EVERY_DAYS``. A machine that was off on the scheduled day runs
1037
+ the escalated pass on its next nightly instead of a month later.
1038
+ - **The drill is nightly.** The restore path is code and the code changes
1039
+ daily; a restore-path regression must surface the next morning, not up
1040
+ to a month later. ~10 min of nice'd 4 a.m. work at current size.
1041
+ - **Failure notifies** (``notify_url``, lab's ``/api/notify`` shape) with
1042
+ the failed stage names. The "never ran at all" case is the monitor's to
1043
+ catch, from the staleness of the health.json records this writes.
1044
+
1045
+ Returns per-stage results plus ``ok`` / ``failed_stages``.
1046
+ """
1047
+ open_archive(home)
1048
+ failed: list[str] = []
1049
+ result: dict = {"dest": str(Path(dest).expanduser())}
1050
+
1051
+ try:
1052
+ b = backup(dest, home=home, allow_shrink=allow_shrink)
1053
+ backup_ok = bool(
1054
+ b["verify_ok"] and b["mirror_complete"] and not b["deletions_skipped"]
1055
+ )
1056
+ except Exception as e:
1057
+ b, backup_ok = {"error": f"{type(e).__name__}: {e}"}, False
1058
+ result["backup"] = b
1059
+ if not backup_ok:
1060
+ failed.append("backup")
1061
+
1062
+ deep_due = _health_is_due("verify_deep_last", _DEEP_EVERY_DAYS)
1063
+ hashes_due = _health_is_due("verify_hashes_last", _HASHES_EVERY_DAYS)
1064
+ result["escalations"] = {"deep": deep_due, "hashes": hashes_due}
1065
+ try:
1066
+ v = verify(
1067
+ home=home, deep=deep_due, hashes=hashes_due,
1068
+ backup=str(dest) if deep_due else None,
1069
+ )
1070
+ verify_ok = bool(v["ok"])
1071
+ except Exception as e:
1072
+ v, verify_ok = {"error": f"{type(e).__name__}: {e}"}, False
1073
+ result["verify"] = v
1074
+ if not verify_ok:
1075
+ failed.append("verify")
1076
+
1077
+ if drill:
1078
+ try:
1079
+ d = restore_drill(dest, home=home)
1080
+ drill_ok = bool(d.get("ok"))
1081
+ except Exception as e:
1082
+ d, drill_ok = {"error": f"{type(e).__name__}: {e}"}, False
1083
+ result["drill"] = d
1084
+ if not drill_ok:
1085
+ failed.append("restore-drill")
1086
+
1087
+ result["ok"] = not failed
1088
+ result["failed_stages"] = failed
1089
+ _record_health("nightly_last", {
1090
+ "dest": result["dest"],
1091
+ "ok": result["ok"],
1092
+ "failed_stages": failed,
1093
+ "deep": deep_due,
1094
+ "hashes": hashes_due,
1095
+ "drill": drill,
1096
+ })
1097
+ # Publish the verdict to the family-monitor heartbeat (see _stamp_heartbeat):
1098
+ # stamped on every completion whatever the outcome, and the `nightly_last`
1099
+ # record written just above is what it anchors its freshness field on.
1100
+ _stamp_heartbeat()
1101
+ if failed and notify_url:
1102
+ result["notify_error"] = _notify(
1103
+ notify_url,
1104
+ f"nightly backup pipeline FAILED at: {', '.join(failed)} — "
1105
+ "see `archive status`, health.json, and ~/.thread/archive/logs/backup-*.log",
1106
+ )
1107
+ return result
1108
+
1109
+
1110
+ def _drill_smoke(home: str, *, expect_content: bool) -> dict:
1111
+ """Exercise the restored archive the way a reader would — the last gap
1112
+ between "the index materialized" and "the archive is usable." Reads the
1113
+ newest indexed thread and searches for a token drawn from the FTS shadow's
1114
+ own stored content (the exact corpus ``event_search`` matches against, so a
1115
+ hit is guaranteed when the search surface works). A rebuilt index whose
1116
+ read path or search surface is broken must fail the drill here, not on the
1117
+ first real restore. Runs against the drill home while it is still open."""
1118
+ import re as _re
1119
+
1120
+ from sqlalchemy import text as _sa_text
1121
+
1122
+ from ._store import get_session as _get_session
1123
+
1124
+ out: dict = {"ok": not expect_content, "read_ok": False, "search_ok": False}
1125
+ if not expect_content:
1126
+ return out
1127
+ try:
1128
+ with _get_session() as s:
1129
+ rows = s.execute(_sa_text(
1130
+ "SELECT thread_id, content FROM events_fts "
1131
+ "WHERE content != '' ORDER BY event_id DESC LIMIT 50"
1132
+ )).fetchall()
1133
+ tid = token = None
1134
+ for thread_id, content in rows:
1135
+ token = next(iter(_re.findall(r"[A-Za-z]{4,}", content or "")), None)
1136
+ if token:
1137
+ tid = thread_id
1138
+ break
1139
+ if tid is None:
1140
+ # No sampleable token (empty FTS surface would already fail the
1141
+ # count checks; all-non-Latin content just isn't sampleable here).
1142
+ out["ok"] = True
1143
+ out["skipped"] = "no sampleable token in the newest FTS rows"
1144
+ return out
1145
+ text = read_thread(tid, home=home, mode="chat", limit=20)
1146
+ out["read_ok"] = bool(text and text.strip())
1147
+ out["token"] = token
1148
+ out["search_ok"] = bool(search(token, home=home, limit=5, rerank=False))
1149
+ out["ok"] = out["read_ok"] and out["search_ok"]
1150
+ except Exception as e: # a crash in the read/search path IS the finding
1151
+ out["error"] = f"{type(e).__name__}: {e}"
1152
+ out["ok"] = False
1153
+ return out
1154
+
1155
+
1156
+ def verify(
1157
+ *,
1158
+ home: Optional[str] = None,
1159
+ deep: bool = False,
1160
+ hashes: bool = False,
1161
+ backup: Optional[str] = None,
1162
+ ) -> dict:
1163
+ """Integrity check: the JSONL truth parses cleanly and matches the SQLite index.
1164
+
1165
+ Scans every per-thread truth file and compares to the projection's counts.
1166
+ The index is compared against ``events_effective`` — the truth's line count
1167
+ after collapsing superseded lines (re-appended ids, same-content twins), which
1168
+ is exactly what a reindex materializes; the raw line count and the superseded
1169
+ remainder are reported alongside. The curatorial log gets the same daily
1170
+ treatment: ``kg_events.jsonl`` is parse-scanned and its distinct-id count
1171
+ compared to the ``kg_events`` table below a kg watermark. ``ok`` is True only
1172
+ when the effective counts align (events, threads, and kg events), nothing
1173
+ failed to parse, the search surface is in parity (shadow ↔
1174
+ FTS5 row counts match with no orphan rows — silently unsearchable content is
1175
+ loss in effect, so it's checked on this daily cadence too), and the live
1176
+ index carries the full declared schema (:func:`_verify_schema` — ``create_all``
1177
+ never retrofits columns/indexes/constraints onto existing tables, so an
1178
+ under-enforced index must be seen and reindexed). A negative event
1179
+ drift (truth > index) is the *safe* direction — ``archive reindex`` rebuilds
1180
+ the index from truth; a positive drift (index > truth) or any parse error is a
1181
+ real integrity problem. Parse errors split into ``parse_errors_torn_tail``
1182
+ (the residue of a crash mid-append) and ``parse_errors_interior``; either kind
1183
+ is cleared by ``archive repair``, which quarantines the damaged lines and
1184
+ restores any committed content they shadowed from the index.
1185
+
1186
+ **A red verify names its cause and keeps its evidence.** The result carries
1187
+ ``failed_components`` (exactly which checks failed — ``ok`` is derived from
1188
+ it), the health record carries the same list, and the *full* result of any
1189
+ failing run is appended to ``<home>/verify-failures.jsonl`` — samples
1190
+ included — so a red observed hours later is diagnosable from what it wrote,
1191
+ not from re-running an expensive pass against a store that has moved on.
1192
+
1193
+ Every run records its outcome (``verify_last``: timestamp, ok, drift, the
1194
+ failed components) in ``<home>/health.json``, surfaced by ``archive
1195
+ status`` — an integrity check that silently stops running must look stale,
1196
+ not healthy. The escalated tiers' records (``verify_deep_last`` /
1197
+ ``verify_hashes_last``) carry each tier's *own* verdict, so the nightly age
1198
+ gate re-runs the tier that actually failed rather than both.
1199
+
1200
+ ``deep=True`` adds an id-level comparison (both directions, below a stable id
1201
+ watermark so in-flight ingest can't false-alarm), dedup_key parity for ids on
1202
+ both sides, knowledge-layer parity, and dangling-reference checks. Slower — it
1203
+ re-reads the whole truth directory (twice) and queries the index per thread —
1204
+ but it sees what count parity can't: missing content masked by compensating
1205
+ errors, and exactly which events drifted.
1206
+
1207
+ ``hashes=True`` adds content-level self-validation on both stores: each
1208
+ event's ``dedup_key`` ends in a hash of its payload's semantic content, so
1209
+ re-hashing the stored payload and comparing detects silent payload corruption
1210
+ (bit rot, a bad write) with no extra state. The hash covers only the payload's
1211
+ *semantic content keys* (``_DEDUP_CONTENT_KEYS``) — corruption in other payload
1212
+ fields (model names, metadata) is invisible to it. Events with no dedup_key
1213
+ at all carry nothing to self-validate against, so the same pass also runs a
1214
+ **cross-store payload parity** check: for every event id present on both
1215
+ sides, the truth line's payload and the index row's payload are canonically
1216
+ hashed and compared (``cross``). Writes flow one way (truth before index;
1217
+ nothing mutates a payload after commit), so the two stores are redundant
1218
+ copies and any disagreement is corruption on one side — this is what makes
1219
+ rot in an *unkeyed* payload detectable at all, instead of parsing clean,
1220
+ passing every count, and being promoted over the good index row by the next
1221
+ reindex. A *new* mismatch (key-hash or cross-store) fails
1222
+ ``ok``: each run's counts are persisted in ``manifest.json`` and diffed
1223
+ against the previous run's baseline — an increase (or any mismatch on a
1224
+ baseline-less first run) means content changed underneath its key since the
1225
+ last look, and health must go red until it's seen. The stamped baseline
1226
+ absorbs the count, so an acknowledged (e.g. legitimately-repaired-in-place)
1227
+ mismatch fails exactly one run rather than pinning verify red forever — and
1228
+ the failure ledger keeps its samples either way.
1229
+ CPU-heavy (re-hashes every payload on both stores).
1230
+
1231
+ ``backup`` scans a backup mirror of the truth directory with the same
1232
+ parse-and-count pass as the live truth (no watermark bound — the mirror is a
1233
+ point-in-time copy) and reports its counts against the live ones. The backup
1234
+ check fails ``ok`` on parse errors in the mirror, and on a *shrinking
1235
+ mirror*: each run's effective count is recorded in health, and a count lower
1236
+ than the previous run's for the same destination means the mirror lost
1237
+ content between looks (the drill's coverage floor only catches drops ≥2%; a
1238
+ slow leak needs the run-over-run diff). A lower count than the *live* truth
1239
+ is expected staleness (the mirror ages between runs) and is reported as
1240
+ ``coverage`` for trending. Combined with ``hashes``, the mirror gets the
1241
+ content-level hash scan too — an unchanged destination file is never
1242
+ re-copied, so rot at rest is otherwise invisible forever. A mirror mismatch
1243
+ count above the previous run's for the same destination (or any mismatch on
1244
+ a first, baseline-less look) fails ``ok`` (``backup_hashes``), with the same
1245
+ fails-once absorption as the live scan.
1246
+ ``restore_drill`` is the step beyond this: actually rebuild an index from
1247
+ the mirror.
1248
+
1249
+ The SQLite file itself gets a ``PRAGMA quick_check`` — page-level index
1250
+ corruption is otherwise invisible until a query happens to touch a bad page.
1251
+ On the ``hashes`` cadence this upgrades to the full ``integrity_check``,
1252
+ which also verifies b-tree index content against the tables (the only check
1253
+ that catches a corrupted index silently returning wrong query results).
1254
+ The index is rebuildable, so a failure here means ``archive reindex``, not
1255
+ data loss — but it must be *seen*.
1256
+
1257
+ The shallow comparison is watermark-bounded on both sides too (ids at or
1258
+ below the index maxima captured up front), so verify can run against a live
1259
+ watcher without racing its ingest. An *empty* index gets no bound — a
1260
+ restored-but-not-yet-reindexed archive must show its full drift, not a
1261
+ vacuous OK.
1262
+ """
1263
+ open_archive(home)
1264
+ from sqlalchemy import func, select
1265
+
1266
+ from ._store import Event, KgEvent, Thread, get_session
1267
+ from ._truth import scan_truth_counts
1268
+
1269
+ with get_session() as s:
1270
+ watermark = s.execute(select(func.max(Event.id))).scalar() or 0
1271
+ thread_watermark = s.execute(select(func.max(Thread.id))).scalar() or 0
1272
+ kg_watermark = s.execute(select(func.max(KgEvent.id))).scalar() or 0
1273
+ truth = scan_truth_counts(
1274
+ event_id_max=watermark or None, thread_id_max=thread_watermark or None,
1275
+ kg_event_id_max=kg_watermark or None,
1276
+ )
1277
+ with get_session() as s:
1278
+ thread_q = select(func.count()).select_from(Thread)
1279
+ if thread_watermark:
1280
+ thread_q = thread_q.where(Thread.id <= thread_watermark)
1281
+ idx_threads = s.execute(thread_q).scalar() or 0
1282
+ event_q = select(func.count()).select_from(Event)
1283
+ if watermark:
1284
+ event_q = event_q.where(Event.id <= watermark)
1285
+ idx_events = s.execute(event_q).scalar() or 0
1286
+ kg_q = select(func.count()).select_from(KgEvent)
1287
+ if kg_watermark:
1288
+ kg_q = kg_q.where(KgEvent.id <= kg_watermark)
1289
+ idx_kg = s.execute(kg_q).scalar() or 0
1290
+ drift_threads = int(idx_threads) - truth["threads"]
1291
+ drift_events = int(idx_events) - truth["events_effective"]
1292
+ drift_kg = int(idx_kg) - truth["kg_events"]
1293
+ # Self-check of the index file. The daily form is quick_check (reads every
1294
+ # page but skips index-content verification — a torn page still shows); the
1295
+ # ``hashes`` cadence upgrades to the full integrity_check, which also
1296
+ # verifies b-tree index content against the tables — the only check that
1297
+ # catches a corrupted index silently returning wrong query results.
1298
+ #
1299
+ # The pragma runs on its own just-opened connection, never a pooled one.
1300
+ # FTS5's integrity check consults per-connection segment-structure state,
1301
+ # and a connection that has lived across another process's writes (the
1302
+ # watcher rewrites ``event_search`` segments continuously) can spuriously
1303
+ # report "malformed inverted index" for a healthy index — the same pragma
1304
+ # on a fresh connection to the same file passes. A private connection
1305
+ # checks the same committed bytes without that hazard; it reads the file
1306
+ # the engine is actually bound to, so it judges the same database every
1307
+ # other component of this verify ran against.
1308
+ check_pragma = "integrity_check(10)" if hashes else "quick_check(10)"
1309
+ import sqlite3
1310
+
1311
+ from ._store import get_engine
1312
+
1313
+ index_file = get_engine().url.database
1314
+ qconn = sqlite3.connect(index_file, timeout=5.0)
1315
+ try:
1316
+ # Some page-level damage comes back as result rows, some as a raised
1317
+ # DatabaseError — both are the finding, not a crash: the check must
1318
+ # fail closed with the message, or verify dies on exactly the state
1319
+ # it exists to report. OperationalError (locked / can't open) stays
1320
+ # an error: environmental trouble must not impersonate corruption.
1321
+ try:
1322
+ qc_rows = qconn.execute(f"PRAGMA {check_pragma}").fetchall()
1323
+ except sqlite3.OperationalError:
1324
+ raise
1325
+ except sqlite3.DatabaseError as exc:
1326
+ qc_rows = [(str(exc),)]
1327
+ finally:
1328
+ qconn.close()
1329
+ quick_check = "ok" if [r[0] for r in qc_rows] == ["ok"] else "; ".join(
1330
+ str(r[0]) for r in qc_rows
1331
+ )
1332
+ # Search-surface parity, on the daily cadence (deep re-checks it with more
1333
+ # detail): the FTS shadow and the FTS5 table commit in the same transaction
1334
+ # as their events, so below the watermark the two row counts must match and
1335
+ # no shadow row may point at a missing event. Both are index-internal drift
1336
+ # — a reindex rebuilds them — but silently unsearchable content is loss in
1337
+ # effect, so it must be *seen* daily, not only on the deep cadence.
1338
+ fts_shadow = fts5 = fts_orphans = 0
1339
+ with get_session() as s:
1340
+ conn = s.connection().connection
1341
+ has_fts = conn.execute(
1342
+ "SELECT count(*) FROM sqlite_master WHERE name IN ('events_fts', 'event_search')"
1343
+ ).fetchone()[0] == 2
1344
+ if has_fts and watermark:
1345
+ fts_shadow = conn.execute(
1346
+ "SELECT count(*) FROM events_fts WHERE event_id <= ?", (watermark,)
1347
+ ).fetchone()[0]
1348
+ fts5 = conn.execute(
1349
+ "SELECT count(*) FROM event_search WHERE event_id <= ?", (watermark,)
1350
+ ).fetchone()[0]
1351
+ fts_orphans = conn.execute(
1352
+ "SELECT count(*) FROM events_fts f WHERE f.event_id <= ? "
1353
+ "AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = f.event_id)",
1354
+ (watermark,),
1355
+ ).fetchone()[0]
1356
+ # Declared-schema parity (cheap PRAGMA introspection): a live index that
1357
+ # predates a model change runs under-enforced until a reindex — that gap
1358
+ # must be seen on the daily cadence, not discovered from its consequences.
1359
+ schema = _verify_schema()
1360
+ # ``ok`` is derived from this list at the end — the components and the
1361
+ # verdict cannot disagree, and a red run always names its cause.
1362
+ failed: list[str] = []
1363
+ if drift_threads != 0:
1364
+ failed.append("drift_threads")
1365
+ if drift_events != 0:
1366
+ failed.append("drift_events")
1367
+ if drift_kg != 0:
1368
+ failed.append("drift_kg_events")
1369
+ if truth["parse_errors"]:
1370
+ failed.append("parse_errors")
1371
+ if quick_check != "ok":
1372
+ failed.append("integrity_check" if hashes else "quick_check")
1373
+ if fts_shadow != fts5 or fts_orphans:
1374
+ failed.append("fts_parity")
1375
+ if not schema["ok"]:
1376
+ failed.append("schema")
1377
+ result = {
1378
+ "ok": not failed,
1379
+ "schema": schema,
1380
+ "truth": truth,
1381
+ "index": {
1382
+ "threads": int(idx_threads),
1383
+ "events": int(idx_events),
1384
+ "kg_events": int(idx_kg),
1385
+ "quick_check": quick_check,
1386
+ "check": "integrity_check" if hashes else "quick_check",
1387
+ },
1388
+ "drift": {"threads": drift_threads, "events": drift_events, "kg_events": drift_kg},
1389
+ "fts": {
1390
+ "shadow_rows": int(fts_shadow),
1391
+ "fts5_rows": int(fts5),
1392
+ "orphan_rows": int(fts_orphans),
1393
+ },
1394
+ }
1395
+ if deep:
1396
+ result["deep"] = _verify_deep(watermark)
1397
+ if not result["deep"]["ok"]:
1398
+ failed.append("deep")
1399
+ new_mismatches = False
1400
+ if hashes:
1401
+ result["hashes"] = _verify_hashes(watermark)
1402
+ # Detected corruption must fail health, not just be reported: any *new*
1403
+ # mismatch since the previous baseline (or any mismatch at all on a
1404
+ # first, baseline-less run) fails ``ok``. The baseline this run stamps
1405
+ # absorbs the count, so the failure fires once and the delta signal
1406
+ # stays meaningful — a legitimately-repaired payload's stale hash
1407
+ # doesn't keep verify red forever, but it is *seen* red once (and the
1408
+ # failure ledger below keeps its samples).
1409
+ h, delta = result["hashes"], result["hashes"].get("delta")
1410
+ if delta is not None:
1411
+ new_mismatches = any(v > 0 for v in delta.values())
1412
+ else:
1413
+ new_mismatches = bool(
1414
+ h["truth"]["mismatched"] or h["index"]["mismatched"]
1415
+ or h["cross"]["mismatched"]
1416
+ )
1417
+ result["hashes"]["new_mismatches"] = new_mismatches
1418
+ if new_mismatches:
1419
+ failed.append("hashes")
1420
+ if backup is not None:
1421
+ result["backup"] = _verify_backup(Path(backup).expanduser(), truth)
1422
+ if not result["backup"]["ok"]:
1423
+ failed.append("backup")
1424
+ if hashes and "scan" in result["backup"]:
1425
+ # Content-level rot detection on the mirror too: an unchanged
1426
+ # destination file is never re-copied (size+mtime skip), so silent
1427
+ # corruption at rest would otherwise persist forever while the
1428
+ # parse-and-count scan stays green. No watermark — the mirror is a
1429
+ # point-in-time copy. Same baseline-delta semantics as the live
1430
+ # hashes pass: a mismatch count *above* the previous run's for this
1431
+ # destination (or any mismatch on a baseline-less first run) fails
1432
+ # ``ok``; recording the new count absorbs it, so the failure fires
1433
+ # once and the ledger keeps its samples.
1434
+ dest_path = Path(backup).expanduser()
1435
+ bh = _hash_scan_truth_dir(dest_path, watermark=None)
1436
+ result["backup"]["hashes"] = bh
1437
+ prev = _read_health().get("backup_hashes_last") or {}
1438
+ prev_count = (
1439
+ prev.get("mismatched") if prev.get("dest") == str(dest_path) else None
1440
+ )
1441
+ bh["new_mismatches"] = (
1442
+ bh["mismatched"] > int(prev_count)
1443
+ if prev_count is not None else bool(bh["mismatched"])
1444
+ )
1445
+ if bh["new_mismatches"]:
1446
+ failed.append("backup_hashes")
1447
+ _record_health("backup_hashes_last", {
1448
+ "dest": str(dest_path), "mismatched": bh["mismatched"],
1449
+ })
1450
+ result["ok"] = not failed
1451
+ result["failed_components"] = failed
1452
+ if failed:
1453
+ # Keep the evidence: the counts and samples of a failing run exist only
1454
+ # in this dict, and health records booleans. Without the ledger, a red
1455
+ # observed later is undiagnosable except by re-running the whole pass
1456
+ # against a store that has moved on.
1457
+ ledger = _append_verify_failure(result)
1458
+ if ledger:
1459
+ result["failure_log"] = ledger
1460
+
1461
+ # Record the outcome in the home's health file so `archive status` (and
1462
+ # anything watching it) can see when integrity was last checked and how it
1463
+ # went — a verify that silently stops running is indistinguishable from a
1464
+ # healthy one otherwise. Staleness of the timestamp is the primary signal: a
1465
+ # crash mid-verify leaves the previous record standing, and its age says so.
1466
+ # `deep` / `hashes` / `backup` are the run's TIER, not decoration: they are
1467
+ # what `_stage_recovered` compares against the tier of a verify that failed,
1468
+ # so a basic pass can never retire a deep-tier red. `backup` records whether
1469
+ # the mirror was parse-scanned — the check a bare `archive verify` skips.
1470
+ _record_health("verify_last", {
1471
+ "ok": bool(result["ok"]),
1472
+ "deep": bool(deep),
1473
+ "hashes": bool(hashes),
1474
+ "backup": bool(backup),
1475
+ "drift_events": drift_events,
1476
+ "drift_threads": drift_threads,
1477
+ "parse_errors": truth["parse_errors"],
1478
+ "failed": failed,
1479
+ })
1480
+ # The escalated tiers get their own records: ``verify_last`` is overwritten
1481
+ # by every shallow run, so these are what age-gated schedulers (``nightly``)
1482
+ # read to know when a deep / hashes pass last actually happened. Each
1483
+ # carries its own tier's verdict — a red caused by another component must
1484
+ # not force the expensive tiers to re-run every night.
1485
+ if deep:
1486
+ _record_health("verify_deep_last", {"ok": bool(result["deep"]["ok"])})
1487
+ if hashes:
1488
+ _record_health("verify_hashes_last", {"ok": not new_mismatches})
1489
+ # A verify run on its own is how a failed nightly's verify stage gets retired
1490
+ # — republish the verdict so a proven fix reaches the monitor now, not at 04:00.
1491
+ _stamp_heartbeat()
1492
+ return result
1493
+
1494
+
1495
+ def _append_verify_failure(result: dict) -> Optional[str]:
1496
+ """Append the full result of a failing verify to ``<home>/verify-failures.jsonl``
1497
+ — the durable evidence a red run leaves behind (health.json holds booleans;
1498
+ the counts and samples live only in the result dict). Append-only and
1499
+ fail-soft: the ledger is advisory, so a write error is logged, never raised.
1500
+ Returns the ledger path, or None when it could not be written."""
1501
+ from datetime import datetime, timezone
1502
+
1503
+ p = resolve_paths().home / "verify-failures.jsonl"
1504
+ try:
1505
+ with open(p, "a", encoding="utf-8") as fh:
1506
+ fh.write(json.dumps(
1507
+ {"at": datetime.now(timezone.utc).isoformat(), **result},
1508
+ default=str,
1509
+ ))
1510
+ fh.write("\n")
1511
+ return str(p)
1512
+ except OSError:
1513
+ import logging
1514
+
1515
+ logging.getLogger(__name__).exception("could not append to verify-failures.jsonl")
1516
+ return None
1517
+
1518
+
1519
+ def _verify_schema() -> dict:
1520
+ """Live index schema vs the declared models — the under-enforcement check.
1521
+
1522
+ Startup schema provisioning is ``create_all`` only: it creates *missing
1523
+ tables* but never retrofits new columns, indexes, or constraints onto
1524
+ existing ones, so a live index created before a model change can silently
1525
+ run under-enforced (e.g. the ``(thread_id, dedup_key)`` unique index absent
1526
+ → DB-level dedup off) until the next reindex. Introspects the live SQLite
1527
+ schema against ``Base.metadata`` directly — no stored version stamp to
1528
+ drift — and reports missing columns, named indexes, and unique constraints
1529
+ (matched by column set; SQLite realizes them as auto-named unique indexes).
1530
+ Extra live-side objects are ignored: an older/foreign index must still
1531
+ open. Anything missing fails ``verify``'s ``ok`` — the fix is ``archive
1532
+ reindex``, which builds a fresh index with the full declared schema."""
1533
+ from sqlalchemy import UniqueConstraint as _UC
1534
+
1535
+ from ._store import Base, get_session
1536
+
1537
+ missing_tables: list[str] = []
1538
+ missing_columns: list[str] = []
1539
+ missing_indexes: list[str] = []
1540
+ missing_uniques: list[str] = []
1541
+ with get_session() as s:
1542
+ conn = s.connection().connection # raw sqlite3
1543
+ live_tables = {
1544
+ r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
1545
+ }
1546
+ for table in Base.metadata.tables.values():
1547
+ if table.name not in live_tables:
1548
+ missing_tables.append(table.name)
1549
+ continue
1550
+ live_cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table.name})")}
1551
+ missing_columns += [
1552
+ f"{table.name}.{c.name}" for c in table.columns if c.name not in live_cols
1553
+ ]
1554
+ index_list = conn.execute(f"PRAGMA index_list({table.name})").fetchall()
1555
+ live_index_names = {r[1] for r in index_list}
1556
+ missing_indexes += [
1557
+ str(idx.name) for idx in table.indexes if idx.name not in live_index_names
1558
+ ]
1559
+ live_unique_colsets = {
1560
+ frozenset(
1561
+ r[2] for r in conn.execute(f"PRAGMA index_info({row[1]})") if r[2]
1562
+ )
1563
+ for row in index_list
1564
+ if row[2] # unique flag
1565
+ }
1566
+ for uc in table.constraints:
1567
+ if not isinstance(uc, _UC):
1568
+ continue
1569
+ cols = frozenset(c.name for c in uc.columns)
1570
+ if cols not in live_unique_colsets:
1571
+ missing_uniques.append(f"{table.name}({', '.join(sorted(cols))})")
1572
+ return {
1573
+ "ok": not (missing_tables or missing_columns or missing_indexes or missing_uniques),
1574
+ "missing_tables": missing_tables,
1575
+ "missing_columns": missing_columns,
1576
+ "missing_indexes": missing_indexes,
1577
+ "missing_unique_constraints": missing_uniques,
1578
+ }
1579
+
1580
+
1581
+ def _verify_backup(dest: Path, live_truth: dict) -> dict:
1582
+ """Scan a backup mirror of the truth dir and compare it to the live scan.
1583
+
1584
+ The restore-drill primitive: the same parse-and-count pass ``verify`` runs on
1585
+ the live truth, pointed at the mirror. Zero parse errors is the hard
1586
+ requirement (a mirror that doesn't parse doesn't restore); the effective-count
1587
+ ratio against the live truth (``coverage``) quantifies staleness — it should
1588
+ hover near 1.0 and only ever *rise* between backup runs.
1589
+
1590
+ A mirror that *shrank* fails too: each run records the mirror's effective
1591
+ count in health (``backup_scan_last``), and a count below the previous run's
1592
+ for the same destination means the backup lost content between looks —
1593
+ something deleted or truncated mirror files out-of-band. The restore drill's
1594
+ coverage floor only catches drops ≥2% of the archive; the run-over-run diff
1595
+ is what sees a slow leak. Recording the new count absorbs the drop, so —
1596
+ like the hashes baseline — a deliberate shrink (an ``--allow-shrink``
1597
+ re-emit after repair) fails exactly one run instead of pinning verify red."""
1598
+ from ._truth import scan_truth_counts
1599
+
1600
+ if not (dest / "manifest.json").exists() and not (dest / "threads").exists():
1601
+ return {"dest": str(dest), "ok": False, "error": "not a truth mirror"}
1602
+ scan = scan_truth_counts(truth_dir=dest)
1603
+ live_effective = live_truth["events_effective"] or 1
1604
+ prev = _read_health().get("backup_scan_last") or {}
1605
+ prev_effective = (
1606
+ prev.get("events_effective") if prev.get("dest") == str(dest) else None
1607
+ )
1608
+ out = {
1609
+ "dest": str(dest),
1610
+ "ok": scan["parse_errors"] == 0,
1611
+ "scan": scan,
1612
+ "coverage": round(scan["events_effective"] / live_effective, 6),
1613
+ }
1614
+ if prev_effective is not None and scan["events_effective"] < int(prev_effective):
1615
+ out["ok"] = False
1616
+ out["effective_drop"] = {
1617
+ "previous": int(prev_effective),
1618
+ "previous_at": prev.get("at"),
1619
+ "current": scan["events_effective"],
1620
+ }
1621
+ _record_health("backup_scan_last", {
1622
+ "dest": str(dest),
1623
+ "events_effective": scan["events_effective"],
1624
+ })
1625
+ return out
1626
+
1627
+
1628
+ def _hash_key_check(payload: object, dedup_key: str) -> Optional[bool]:
1629
+ """True = the payload re-hashes to the content hash embedded in its own
1630
+ ``dedup_key`` (the last ``:``-segment; see
1631
+ ``thread_archive._thread_import.event_builder.compute_dedup_key``); False = mismatch;
1632
+ None = the key carries no hash tail (nothing to validate against)."""
1633
+ from ._truth.jsonl_log import _hash_key_check as _impl
1634
+
1635
+ return _impl(payload, dedup_key)
1636
+
1637
+
1638
+ def _hash_scan_truth_dir(truth_dir: Path, watermark: Optional[int]) -> dict:
1639
+ """Re-hash every event payload in a truth directory against its dedup_key's
1640
+ embedded content hash. The scan half of ``verify --hashes``, reusable
1641
+ against a backup mirror (``watermark=None`` — a point-in-time copy has no
1642
+ in-flight ingest to bound out). ``no_key`` counts events with no dedup_key
1643
+ at all: they carry nothing to validate against, so they are invisible to
1644
+ this check — the count keeps that coverage boundary visible."""
1645
+ from ._truth.jsonl_log import THREADS_SUBDIR, _iter_jsonl
1646
+
1647
+ checked = mismatched = skipped = no_key = 0
1648
+ sample: list[int] = []
1649
+ threads_dir = truth_dir / THREADS_SUBDIR
1650
+ if threads_dir.exists():
1651
+ for path in threads_dir.rglob("*.jsonl"):
1652
+ for rec in _iter_jsonl(path):
1653
+ if rec.get("type", "event") != "event":
1654
+ continue
1655
+ ev_id, key = rec.get("id"), rec.get("dedup_key")
1656
+ if ev_id is None or (watermark is not None and ev_id > watermark):
1657
+ continue
1658
+ if not key:
1659
+ no_key += 1
1660
+ continue
1661
+ verdict = _hash_key_check(rec.get("payload"), key)
1662
+ if verdict is None:
1663
+ skipped += 1
1664
+ continue
1665
+ checked += 1
1666
+ if not verdict:
1667
+ mismatched += 1
1668
+ if len(sample) < 10:
1669
+ sample.append(int(ev_id))
1670
+ return {
1671
+ "checked": checked, "mismatched": mismatched,
1672
+ "unhashed_keys": skipped, "no_key": no_key, "mismatch_sample": sample,
1673
+ }
1674
+
1675
+
1676
+ def _payload_fingerprint(event_type: object, payload: object) -> str:
1677
+ """Canonical content fingerprint of one stored event — the cross-store
1678
+ comparator. Both stores' copies are parsed to Python objects first, so
1679
+ serializer differences (key order, ascii escaping) can't false-positive;
1680
+ timestamps are deliberately excluded (the two stores format them
1681
+ differently)."""
1682
+ import hashlib
1683
+
1684
+ blob = json.dumps([event_type, payload], sort_keys=True, default=str, ensure_ascii=False)
1685
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
1686
+
1687
+
1688
+ def _verify_hashes(watermark: int) -> dict:
1689
+ """Content-level validation of every stored payload, walking truth and index
1690
+ together one thread at a time.
1691
+
1692
+ Two independent checks per event:
1693
+
1694
+ * **Key-hash self-validation** (``truth`` / ``index``): re-hash the stored
1695
+ payload against the content hash embedded in its own ``dedup_key`` (its
1696
+ last ``:``-segment; see ``thread_archive._thread_import.event_builder.compute_dedup_key``).
1697
+ A mismatch means the payload changed since its key was computed —
1698
+ corruption, or an in-place payload repair that didn't recompute the key.
1699
+ Events with a key whose tail isn't a hash are skipped and counted
1700
+ (``unhashed_keys``); events with no dedup_key at all are counted too
1701
+ (``no_key``).
1702
+ * **Cross-store payload parity** (``cross``): for every id present on both
1703
+ sides, compare a canonical fingerprint of (event_type, payload) between
1704
+ the truth line (last line per id — what a reindex materializes) and the
1705
+ index row. Writes flow one way and nothing mutates payloads after commit,
1706
+ so the stores are redundant copies: disagreement is corruption on one
1707
+ side. This is the only check that can see rot in an *unkeyed* payload —
1708
+ a majority of any archive that predates dedup keys — which would
1709
+ otherwise parse clean, pass every count, and be promoted over the good
1710
+ index row by the next reindex.
1711
+
1712
+ The caller (``verify``) fails ``ok`` when any mismatch count *increased*
1713
+ since the previous baseline (see ``new_mismatches``); the counts themselves
1714
+ are informational."""
1715
+ import json as _json
1716
+
1717
+ from ._store import get_session
1718
+ from ._truth.jsonl_log import (
1719
+ THREADS_SUBDIR,
1720
+ _iter_jsonl,
1721
+ _shard_depth,
1722
+ _thread_file,
1723
+ log_dir,
1724
+ )
1725
+
1726
+ d = log_dir()
1727
+ threads_dir = d / THREADS_SUBDIR
1728
+ depth = _shard_depth(d)
1729
+
1730
+ truth_checked = truth_mismatched = truth_unhashed = truth_no_key = 0
1731
+ truth_sample: list[int] = []
1732
+ index_checked = index_mismatched = index_skipped = 0
1733
+ index_sample: list[int] = []
1734
+ cross_compared = cross_mismatched = 0
1735
+ cross_sample: list[int] = []
1736
+
1737
+ files_by_stem: dict[str, list[Path]] = {}
1738
+ if threads_dir.exists():
1739
+ for path in threads_dir.rglob("*.jsonl"):
1740
+ files_by_stem.setdefault(path.stem, []).append(path)
1741
+ truth_tids: set[int] = set()
1742
+ for stem in files_by_stem:
1743
+ try:
1744
+ truth_tids.add(int(stem))
1745
+ except ValueError: # pragma: no cover — stray file
1746
+ continue
1747
+
1748
+ with get_session() as s:
1749
+ conn = s.connection().connection # raw sqlite3 — stream, don't materialize
1750
+ index_no_key = conn.execute(
1751
+ "SELECT count(*) FROM events WHERE dedup_key IS NULL AND id <= ?",
1752
+ (watermark,),
1753
+ ).fetchone()[0]
1754
+ idx_tids = {
1755
+ int(r[0]) for r in conn.execute(
1756
+ "SELECT DISTINCT thread_id FROM events WHERE id <= ?", (watermark,)
1757
+ )
1758
+ }
1759
+ for tid in sorted(idx_tids | truth_tids):
1760
+ # Truth side: every line is key-checked; the last line per id wins
1761
+ # the fingerprint (canonical-depth file last, matching reindex).
1762
+ fingerprints: dict[int, str] = {}
1763
+ paths = sorted(files_by_stem.get(str(tid), []))
1764
+ paths.sort(key=lambda p: p == _thread_file(d, tid, depth))
1765
+ for path in paths:
1766
+ for rec in _iter_jsonl(path):
1767
+ if rec.get("type", "event") != "event":
1768
+ continue
1769
+ ev_id, key = rec.get("id"), rec.get("dedup_key")
1770
+ if ev_id is None or ev_id > watermark:
1771
+ continue
1772
+ payload = rec.get("payload")
1773
+ if not key:
1774
+ truth_no_key += 1
1775
+ else:
1776
+ verdict = _hash_key_check(payload, key)
1777
+ if verdict is None:
1778
+ truth_unhashed += 1
1779
+ else:
1780
+ truth_checked += 1
1781
+ if not verdict:
1782
+ truth_mismatched += 1
1783
+ if len(truth_sample) < 10:
1784
+ truth_sample.append(int(ev_id))
1785
+ fingerprints[int(ev_id)] = _payload_fingerprint(
1786
+ rec.get("event_type"), payload
1787
+ )
1788
+ # Index side of the same thread, plus the cross-store comparison.
1789
+ for ev_id, key, etype, payload_text in conn.execute(
1790
+ "SELECT id, dedup_key, event_type, payload FROM events "
1791
+ "WHERE thread_id = ? AND id <= ?", (tid, watermark)
1792
+ ):
1793
+ try:
1794
+ payload = (
1795
+ _json.loads(payload_text)
1796
+ if isinstance(payload_text, str) else payload_text
1797
+ )
1798
+ except ValueError:
1799
+ payload = None
1800
+ if key:
1801
+ verdict = _hash_key_check(payload, key)
1802
+ if verdict is None:
1803
+ index_skipped += 1
1804
+ else:
1805
+ index_checked += 1
1806
+ if not verdict:
1807
+ index_mismatched += 1
1808
+ if len(index_sample) < 10:
1809
+ index_sample.append(int(ev_id))
1810
+ truth_fp = fingerprints.get(int(ev_id))
1811
+ if truth_fp is not None:
1812
+ cross_compared += 1
1813
+ if truth_fp != _payload_fingerprint(etype, payload):
1814
+ cross_mismatched += 1
1815
+ if len(cross_sample) < 10:
1816
+ cross_sample.append(int(ev_id))
1817
+
1818
+ result = {
1819
+ "truth": {
1820
+ "checked": truth_checked, "mismatched": truth_mismatched,
1821
+ "unhashed_keys": truth_unhashed, "no_key": truth_no_key,
1822
+ "mismatch_sample": truth_sample,
1823
+ },
1824
+ "index": {
1825
+ "checked": index_checked, "mismatched": index_mismatched,
1826
+ "unhashed_keys": index_skipped, "no_key": int(index_no_key),
1827
+ "mismatch_sample": index_sample,
1828
+ },
1829
+ "cross": {
1830
+ "compared": cross_compared, "mismatched": cross_mismatched,
1831
+ "mismatch_sample": cross_sample,
1832
+ },
1833
+ }
1834
+ # The signal is the mismatch count *jumping* between runs, so persist this
1835
+ # run's counts in the manifest and surface the previous run's for comparison.
1836
+ # Locked read-modify-write: a checkpoint stamping its own keys concurrently
1837
+ # must not lose this baseline, nor vice versa.
1838
+ from datetime import datetime, timezone
1839
+
1840
+ from ._truth.jsonl_log import update_manifest
1841
+
1842
+ baseline = {
1843
+ "at": datetime.now(timezone.utc).isoformat(),
1844
+ "truth_mismatched": truth_mismatched,
1845
+ "index_mismatched": index_mismatched,
1846
+ "cross_mismatched": cross_mismatched,
1847
+ }
1848
+ previous: dict = {}
1849
+
1850
+ def _stamp(m: dict) -> None:
1851
+ previous.update(m.get("hashes_baseline") or {})
1852
+ m["hashes_baseline"] = baseline
1853
+
1854
+ update_manifest(log_dir(), _stamp)
1855
+ if previous:
1856
+ result["previous"] = previous
1857
+ result["delta"] = {
1858
+ "truth_mismatched": truth_mismatched - int(previous.get("truth_mismatched", 0)),
1859
+ "index_mismatched": index_mismatched - int(previous.get("index_mismatched", 0)),
1860
+ "cross_mismatched": cross_mismatched - int(previous.get("cross_mismatched", 0)),
1861
+ }
1862
+ return result
1863
+
1864
+
1865
+ def _verify_deep(watermark: int) -> dict:
1866
+ """Id-level truth↔index comparison plus knowledge-layer checks.
1867
+
1868
+ Only events with ``id <= watermark`` (committed before the scan began) are
1869
+ compared: the truth line for any such event was written *before* its commit
1870
+ (the staging invariant), so at any later read it must be present — and any
1871
+ index row at or below the watermark must have a truth line. Everything above
1872
+ the watermark is in-flight ingest and skipped.
1873
+
1874
+ Truth-only ids are split into two classes: **superseded** (a same-content twin
1875
+ — equal ``dedup_key`` — exists in the index under another id; the benign
1876
+ residue of a lost-commit re-import, collapsed on reindex) and **missing**
1877
+ (no twin: content the index genuinely lacks — recoverable via reindex).
1878
+ Index-only ids are the forbidden direction and always fail.
1879
+
1880
+ For ids present on both sides, the stored ``dedup_key`` values are compared
1881
+ (``events_key_mismatch``): a mismatch means the two stores disagree on an
1882
+ event's content identity — an index-only mutation the truth never received
1883
+ (a reindex would rewrite it) or corruption on one side. Reported with a
1884
+ sample, and it fails ``ok``.
1885
+
1886
+ The search surface is checked too (both FTS tables are written in the same
1887
+ transaction as their events): orphan shadow rows and a shadow↔FTS5 row-count
1888
+ mismatch fail. Indexable events with no shadow row are re-extracted: one whose
1889
+ payload yields no searchable text legitimately has no row (reported as
1890
+ ``empty_extract_events``); one whose extraction yields text today is silently
1891
+ unfindable (``unindexed_events``) and fails — ``archive reindex`` rebuilds the
1892
+ surface.
1893
+
1894
+ Thread *metadata* parity (title/description/summary between the winning truth
1895
+ record and the index row) is report-only: an in-flight metadata commit can
1896
+ legitimately race the scan, but a persistent mismatch means a missed re-stage —
1897
+ and the next reindex would revert the index to the stale truth record.
1898
+
1899
+ The knowledge layer gets the same treatment as events: the kg log and table
1900
+ are id-diffed below a kg watermark captured before any file is read (so a
1901
+ live librarian write can't false-alarm), and ids on both sides are
1902
+ content-compared (``kg.content_mismatch``) — the log is small enough to
1903
+ fingerprint whole, and it is the curation history's only truth.
1904
+ """
1905
+ import json as _json
1906
+
1907
+ from sqlalchemy import text as sa_text
1908
+
1909
+ from ._store import get_session
1910
+ from ._truth.jsonl_log import (
1911
+ KG_EVENTS_FILE,
1912
+ THREADS_SUBDIR,
1913
+ _iter_jsonl,
1914
+ log_dir,
1915
+ thread_file_load_order,
1916
+ )
1917
+
1918
+ d = log_dir()
1919
+ threads_dir = d / THREADS_SUBDIR
1920
+
1921
+ # The kg watermark, captured before any file is read: the kg id-diff below
1922
+ # is otherwise unbounded, and a librarian write landing mid-scan (its truth
1923
+ # line is durable before its commit, but this pass may read the file first)
1924
+ # would false-alarm ``kg_index_only`` against a perfectly healthy archive.
1925
+ with get_session() as s0:
1926
+ kg_watermark = s0.execute(
1927
+ sa_text("SELECT coalesce(max(id), 0) FROM kg_events")
1928
+ ).scalar() or 0
1929
+
1930
+ # Pass 1 — truth ids per thread (≤ watermark), which files hold each thread,
1931
+ # and the winning (last, in reindex's load order — canonical-depth file
1932
+ # last) thread record per id.
1933
+ truth_ids: dict[int, set[int]] = {}
1934
+ thread_files: dict[int, list] = {}
1935
+ truth_meta: dict[int, dict] = {}
1936
+ if threads_dir.exists():
1937
+ for path in thread_file_load_order(d):
1938
+ with open(path, encoding="utf-8", errors="replace") as fh:
1939
+ for line in fh:
1940
+ line = line.strip()
1941
+ if not line:
1942
+ continue
1943
+ try:
1944
+ rec = _json.loads(line)
1945
+ except ValueError:
1946
+ continue
1947
+ if rec.get("type", "event") == "thread":
1948
+ if rec.get("id") is not None:
1949
+ truth_meta[int(rec["id"])] = rec
1950
+ continue
1951
+ if rec.get("type", "event") != "event":
1952
+ continue
1953
+ ev_id, tid = rec.get("id"), rec.get("thread_id")
1954
+ if ev_id is None or tid is None or ev_id > watermark:
1955
+ continue
1956
+ truth_ids.setdefault(int(tid), set()).add(int(ev_id))
1957
+ files = thread_files.setdefault(int(tid), [])
1958
+ if path not in files:
1959
+ files.append(path)
1960
+
1961
+ # Pass 2 — per-thread diff against the index.
1962
+ index_only: list[int] = []
1963
+ missing: list[int] = []
1964
+ key_mismatch: list[int] = []
1965
+ superseded = 0
1966
+ with get_session() as s:
1967
+ idx_tids = {
1968
+ r[0] for r in s.execute(sa_text(
1969
+ "SELECT DISTINCT thread_id FROM events WHERE id <= :wm"), {"wm": watermark})
1970
+ }
1971
+ for tid in sorted(set(truth_ids) | idx_tids):
1972
+ rows = s.execute(sa_text(
1973
+ "SELECT id, dedup_key FROM events WHERE thread_id = :t AND id <= :wm"),
1974
+ {"t": tid, "wm": watermark}).all()
1975
+ idx_map = {r[0]: r[1] for r in rows}
1976
+ t_set = truth_ids.get(tid, set())
1977
+ index_only.extend(sorted(set(idx_map) - t_set))
1978
+ if not t_set:
1979
+ continue
1980
+ # Re-read this thread's files for every truth id's dedup_key: the last
1981
+ # line for an id wins, matching what a reindex would materialize.
1982
+ truth_keys: dict[int, str | None] = {}
1983
+ for path in thread_files.get(tid, []):
1984
+ for rec in _iter_jsonl(path):
1985
+ if rec.get("type", "event") == "event" and rec.get("id") in t_set:
1986
+ truth_keys[rec["id"]] = rec.get("dedup_key")
1987
+ idx_keys = {v for v in idx_map.values() if v}
1988
+ for ev_id in sorted(t_set - set(idx_map)):
1989
+ if truth_keys.get(ev_id) and truth_keys[ev_id] in idx_keys:
1990
+ superseded += 1
1991
+ else:
1992
+ missing.append(ev_id)
1993
+ for ev_id in sorted(t_set & set(idx_map)):
1994
+ if truth_keys.get(ev_id) != idx_map[ev_id]:
1995
+ key_mismatch.append(ev_id)
1996
+
1997
+ # Thread-metadata parity: the winning truth record vs the index row, on the
1998
+ # stable text fields. Report-only — the truth line for a metadata update is
1999
+ # staged before its commit, so a commit landing between pass 1's file read
2000
+ # and this query legitimately shows index-newer-than-truth; a *persistent*
2001
+ # mismatch means a re-stage was missed, and the next reindex would silently
2002
+ # revert the index to the stale truth record.
2003
+ meta_mismatch: list[int] = []
2004
+ meta_rows = s.execute(sa_text(
2005
+ "SELECT id, title, description, summary FROM threads")).all()
2006
+ for tid, *idx_fields in meta_rows:
2007
+ rec = truth_meta.get(int(tid))
2008
+ if rec is None:
2009
+ continue # count parity (shallow verify) owns missing records
2010
+ for field, idx_val in zip(("title", "description", "summary"), idx_fields):
2011
+ if (rec.get(field) or None) != (idx_val or None):
2012
+ meta_mismatch.append(int(tid))
2013
+ break
2014
+
2015
+ # Knowledge layer: the kg truth log vs its table, by id — both sides
2016
+ # bounded by the kg watermark captured up front, so a live librarian
2017
+ # write can't false-alarm — plus content parity for ids on both sides
2018
+ # (the kg analogue of the events cross-store check; the log is small
2019
+ # enough to compare whole).
2020
+ kg_recs: dict[int, dict] = {}
2021
+ for rec in _iter_jsonl(d / KG_EVENTS_FILE):
2022
+ rid = rec.get("id")
2023
+ if rid is not None and int(rid) <= kg_watermark:
2024
+ kg_recs[int(rid)] = rec # last line per id wins, like reindex
2025
+ kg_rows = {
2026
+ int(r[0]): tuple(r) for r in s.execute(sa_text(
2027
+ "SELECT id, event_type, entity_type, entity_id, payload "
2028
+ "FROM kg_events WHERE id <= :wm"), {"wm": kg_watermark})
2029
+ }
2030
+ kg_index_only = len(set(kg_rows) - set(kg_recs))
2031
+ kg_truth_only = len(set(kg_recs) - set(kg_rows))
2032
+ kg_content_mismatch = 0
2033
+ for rid in set(kg_recs) & set(kg_rows):
2034
+ rec, row = kg_recs[rid], kg_rows[rid]
2035
+ row_payload = row[4]
2036
+ if isinstance(row_payload, str):
2037
+ try:
2038
+ row_payload = _json.loads(row_payload)
2039
+ except ValueError:
2040
+ row_payload = None
2041
+ truth_fp = _payload_fingerprint(
2042
+ [rec.get("event_type"), rec.get("entity_type"), rec.get("entity_id")],
2043
+ rec.get("payload"),
2044
+ )
2045
+ if truth_fp != _payload_fingerprint([row[1], row[2], row[3]], row_payload):
2046
+ kg_content_mismatch += 1
2047
+
2048
+ # Dangling references.
2049
+ dangling_links = s.execute(sa_text(
2050
+ "SELECT count(*) FROM thread_links l WHERE "
2051
+ "NOT EXISTS(SELECT 1 FROM threads t WHERE t.id = l.source_thread_id) "
2052
+ "OR NOT EXISTS(SELECT 1 FROM threads t WHERE t.id = l.target_thread_id)"
2053
+ )).scalar() or 0
2054
+ dangling_citations = s.execute(sa_text(
2055
+ "SELECT count(*) FROM topic_messages m "
2056
+ "WHERE m.archived_at IS NULL " # tombstoned evidence is history, not a live ref
2057
+ "AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = m.event_id)"
2058
+ )).scalar() or 0
2059
+ # A live citation whose recorded thread disagrees with the cited event's
2060
+ # actual thread: the column is derived from the event, so disagreement
2061
+ # means an unvalidated write or a stale snapshot seed. Reindex realigns
2062
+ # these (citation reconciliation), so a persistent count means rot.
2063
+ citation_thread_mismatch = s.execute(sa_text(
2064
+ "SELECT count(*) FROM topic_messages m JOIN events e ON e.id = m.event_id "
2065
+ "WHERE m.archived_at IS NULL AND m.thread_id != e.thread_id"
2066
+ )).scalar() or 0
2067
+ dangling_events = s.execute(sa_text(
2068
+ "SELECT count(*) FROM events e "
2069
+ "WHERE NOT EXISTS(SELECT 1 FROM threads t WHERE t.id = e.thread_id)"
2070
+ )).scalar() or 0
2071
+ dup_pairs = s.execute(sa_text(
2072
+ "SELECT count(*) FROM (SELECT 1 FROM events WHERE dedup_key IS NOT NULL "
2073
+ "GROUP BY thread_id, dedup_key HAVING count(*) > 1)"
2074
+ )).scalar() or 0
2075
+
2076
+ # Search-surface parity. The FTS shadow (events_fts) and the FTS5 table
2077
+ # (event_search) are written in the same transaction as their events, so
2078
+ # below the watermark: no shadow row may point at a missing event (orphans),
2079
+ # and the two surfaces must hold the same row count. Coverage — indexable
2080
+ # events with no shadow row — is re-extracted event by event to split the
2081
+ # legitimately-empty (a payload that yields no searchable text has no row
2082
+ # by design) from the genuinely unindexed (extraction yields text today,
2083
+ # so the event is silently unfindable — real drift; `archive reindex`
2084
+ # rebuilds the surface). The gap set is small on a healthy archive, so
2085
+ # re-extracting only it stays cheap where re-extracting the corpus isn't.
2086
+ fts_orphans = fts_shadow_rows = fts5_rows = fts_empty_extract = 0
2087
+ fts_unindexed: list[int] = []
2088
+ has_fts = s.execute(sa_text(
2089
+ "SELECT count(*) FROM sqlite_master WHERE name IN ('events_fts', 'event_search')"
2090
+ )).scalar() == 2
2091
+ if has_fts:
2092
+ fts_orphans = s.execute(sa_text(
2093
+ "SELECT count(*) FROM events_fts f WHERE f.event_id <= :wm "
2094
+ "AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = f.event_id)"),
2095
+ {"wm": watermark}).scalar() or 0
2096
+ fts_shadow_rows = s.execute(sa_text(
2097
+ "SELECT count(*) FROM events_fts WHERE event_id <= :wm"),
2098
+ {"wm": watermark}).scalar() or 0
2099
+ fts5_rows = s.execute(sa_text(
2100
+ "SELECT count(*) FROM event_search WHERE event_id <= :wm"),
2101
+ {"wm": watermark}).scalar() or 0
2102
+ from ._retrieval._extract import INDEXABLE_EVENT_TYPES, extract_fts_content
2103
+
2104
+ types = ", ".join(f"'{t}'" for t in INDEXABLE_EVENT_TYPES)
2105
+ cur = s.connection().connection.execute( # raw sqlite3 — stream the gap set
2106
+ f"SELECT e.id, e.event_type, e.payload FROM events e " # noqa: S608 — types from INDEXABLE_EVENT_TYPES
2107
+ f"WHERE e.id <= ? AND e.event_type IN ({types}) "
2108
+ "AND NOT EXISTS(SELECT 1 FROM events_fts f WHERE f.event_id = e.id)",
2109
+ (watermark,),
2110
+ )
2111
+ for ev_id, etype, payload_text in cur:
2112
+ try:
2113
+ payload = _json.loads(payload_text) if isinstance(payload_text, str) else payload_text
2114
+ except ValueError:
2115
+ payload = None
2116
+ if isinstance(payload, dict) and extract_fts_content(etype, payload):
2117
+ fts_unindexed.append(int(ev_id))
2118
+ else:
2119
+ fts_empty_extract += 1
2120
+
2121
+ ok = (
2122
+ not index_only and not missing and not key_mismatch
2123
+ and kg_index_only == 0 and kg_content_mismatch == 0
2124
+ and dangling_links == 0 and dangling_citations == 0 and dangling_events == 0
2125
+ and citation_thread_mismatch == 0
2126
+ and fts_orphans == 0 and fts_shadow_rows == fts5_rows and not fts_unindexed
2127
+ )
2128
+ return {
2129
+ "ok": ok,
2130
+ "watermark": watermark,
2131
+ "events_index_only": len(index_only),
2132
+ "index_only_sample": index_only[:10],
2133
+ "events_missing_from_index": len(missing),
2134
+ "missing_sample": missing[:10],
2135
+ "events_key_mismatch": len(key_mismatch),
2136
+ "key_mismatch_sample": key_mismatch[:10],
2137
+ "events_superseded_twins": superseded,
2138
+ "thread_meta_mismatch": len(meta_mismatch),
2139
+ "thread_meta_sample": meta_mismatch[:10],
2140
+ "kg": {
2141
+ "index_only": kg_index_only, "truth_only": kg_truth_only,
2142
+ "content_mismatch": kg_content_mismatch, "watermark": int(kg_watermark),
2143
+ },
2144
+ "dangling": {
2145
+ "link_endpoints": int(dangling_links),
2146
+ "citation_events": int(dangling_citations),
2147
+ "citation_thread_mismatch": int(citation_thread_mismatch),
2148
+ "event_threads": int(dangling_events),
2149
+ },
2150
+ "duplicate_content_pairs_index": int(dup_pairs),
2151
+ "fts": {
2152
+ "orphan_rows": int(fts_orphans),
2153
+ "shadow_rows": int(fts_shadow_rows),
2154
+ "fts5_rows": int(fts5_rows),
2155
+ "unindexed_events": len(fts_unindexed),
2156
+ "unindexed_sample": fts_unindexed[:10],
2157
+ "empty_extract_events": int(fts_empty_extract),
2158
+ },
2159
+ }
2160
+
2161
+
2162
+ def status(*, home: Optional[str] = None) -> dict:
2163
+ """Archive health: paths + thread/event/topic/link/FTS counts, plus the
2164
+ operational records — when the last checkpoint, verify, and backup ran and
2165
+ how they went (``last_verify`` / ``last_backup`` from ``<home>/health.json``,
2166
+ written by :func:`verify` / :func:`backup`). Staleness here is the signal
2167
+ that a scheduled integrity job quietly stopped running."""
2168
+ paths = open_archive(home)
2169
+ from sqlalchemy import func, select
2170
+
2171
+ from ._retrieval import fts_status
2172
+ from ._store import Event, Thread, ThreadLink, get_session
2173
+
2174
+ with get_session() as s:
2175
+ threads = s.execute(select(func.count()).select_from(Thread)).scalar() or 0
2176
+ events = s.execute(select(func.count()).select_from(Event)).scalar() or 0
2177
+ topics = s.execute(
2178
+ select(func.count()).select_from(Thread).where(Thread.thread_type == "topic")
2179
+ ).scalar() or 0
2180
+ links = s.execute(select(func.count()).select_from(ThreadLink)).scalar() or 0
2181
+ from ._retrieval.vectors import get_status as _vec_status
2182
+ from ._truth.jsonl_log import _read_manifest
2183
+
2184
+ health = _read_health()
2185
+ return {
2186
+ "home": str(paths.home),
2187
+ "truth_dir": str(paths.truth_dir),
2188
+ "index_path": str(paths.index_path),
2189
+ "threads": int(threads),
2190
+ "events": int(events),
2191
+ "topics": int(topics),
2192
+ "links": int(links),
2193
+ "fts_indexed": fts_status()["indexed"],
2194
+ "vectors_indexed": _vec_status().get("indexed", 0),
2195
+ "last_checkpoint_at": _read_manifest(paths.truth_dir).get("last_checkpoint_at"),
2196
+ "last_verify": health.get("verify_last"),
2197
+ "last_backup": health.get("backup_last"),
2198
+ "last_restore_drill": health.get("restore_drill_last"),
2199
+ "last_nightly": health.get("nightly_last"),
2200
+ "last_watch_errors": health.get("watch_errors_last"),
2201
+ }
2202
+
2203
+
2204
+ def repair(*, home: Optional[str] = None, dry_run: bool = False) -> dict:
2205
+ """Quarantine unparseable truth lines and restore committed rows the truth
2206
+ lacks from the live index — the sanctioned path from a red ``verify`` back to
2207
+ green. See :func:`thread_archive._truth.repair.repair_truth`."""
2208
+ open_archive(home)
2209
+ from ._truth import repair_truth
2210
+
2211
+ return repair_truth(dry_run=dry_run)
2212
+
2213
+
2214
+ def knowledge_status(*, home: Optional[str] = None) -> dict:
2215
+ """Topic-graph status: node/community/component counts (empty until topics exist)."""
2216
+ open_archive(home)
2217
+ from ._knowledge import get_status
2218
+
2219
+ return get_status()
2220
+
2221
+
2222
+ def bridge_topics(*, home: Optional[str] = None, limit: int = 20) -> list[dict]:
2223
+ """Highest-betweenness topics — the structural bridges between communities."""
2224
+ open_archive(home)
2225
+ from ._knowledge import get_bridge_topics
2226
+
2227
+ return get_bridge_topics(limit=limit)
2228
+
2229
+
2230
+ def topic_peers(thread_id: int, *, home: Optional[str] = None, limit: int = 5) -> list[dict]:
2231
+ """Topics in the same community as ``thread_id``, highest-pagerank first."""
2232
+ open_archive(home)
2233
+ from ._knowledge import get_community_peers
2234
+
2235
+ return get_community_peers(thread_id, limit=limit)