@remnic/core 9.3.699 → 9.3.701

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 (285) hide show
  1. package/dist/access-boundary.d.ts +5 -5
  2. package/dist/access-boundary.js +14 -10
  3. package/dist/access-cli.js +30 -28
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +5 -5
  6. package/dist/access-http.js +18 -14
  7. package/dist/access-mcp.d.ts +5 -5
  8. package/dist/access-mcp.js +17 -13
  9. package/dist/access-operations.d.ts +5 -5
  10. package/dist/access-operations.js +16 -12
  11. package/dist/access-schema.js +3 -3
  12. package/dist/{access-service-DftqtNUy.d.ts → access-service-CGVWK6lZ.d.ts} +2 -2
  13. package/dist/access-service.d.ts +5 -5
  14. package/dist/access-service.js +13 -9
  15. package/dist/access-surface-catalog.d.ts +5 -5
  16. package/dist/action-confidence.d.ts +1 -1
  17. package/dist/action-confidence.js +2 -2
  18. package/dist/active-memory-bridge.d.ts +1 -1
  19. package/dist/active-recall.d.ts +1 -1
  20. package/dist/active-recall.js +1 -1
  21. package/dist/behavior-learner.d.ts +1 -1
  22. package/dist/behavior-signals.d.ts +1 -1
  23. package/dist/bootstrap.d.ts +4 -4
  24. package/dist/briefing.d.ts +1 -1
  25. package/dist/briefing.js +7 -3
  26. package/dist/buffer-surprise-report.d.ts +1 -1
  27. package/dist/buffer.d.ts +1 -1
  28. package/dist/calibration.d.ts +1 -1
  29. package/dist/capabilities.d.ts +1 -1
  30. package/dist/{catalog-D7YDNNF7.d.ts → catalog-DBIghceA.d.ts} +27 -53
  31. package/dist/causal-behavior.d.ts +1 -1
  32. package/dist/causal-consolidation.d.ts +1 -1
  33. package/dist/causal-consolidation.js +8 -4
  34. package/dist/causal-consolidation.js.map +1 -1
  35. package/dist/{chunk-E6GOVHHJ.js → chunk-27LQPUMZ.js} +3 -3
  36. package/dist/{chunk-XJNBEDFE.js → chunk-3FAMU5TX.js} +31 -74
  37. package/dist/chunk-3FAMU5TX.js.map +1 -0
  38. package/dist/chunk-3JJWNZTT.js +446 -0
  39. package/dist/chunk-3JJWNZTT.js.map +1 -0
  40. package/dist/{chunk-JYVD2XZ4.js → chunk-4HIAWLA2.js} +58 -25
  41. package/dist/chunk-4HIAWLA2.js.map +1 -0
  42. package/dist/{chunk-A62RAIBN.js → chunk-6W2D6FGG.js} +2 -2
  43. package/dist/{chunk-COEZR6F5.js → chunk-7TAQEPLE.js} +2 -2
  44. package/dist/{chunk-TIVZ4MCG.js → chunk-D75JXBV4.js} +3 -3
  45. package/dist/{chunk-YY2HGKWR.js → chunk-DEDQXIDL.js} +2 -2
  46. package/dist/{chunk-XFG3PVZE.js → chunk-FUCJAZ25.js} +8 -8
  47. package/dist/{chunk-EZWQZYLK.js → chunk-HF4N43Q7.js} +2 -2
  48. package/dist/{chunk-HZ5DRA4Q.js → chunk-HXHKLVAS.js} +20 -20
  49. package/dist/{chunk-AC5LO7IU.js → chunk-I4WBNM3M.js} +7 -1
  50. package/dist/chunk-I4WBNM3M.js.map +1 -0
  51. package/dist/{chunk-QVTSLRKT.js → chunk-IJEZMWKA.js} +3 -3
  52. package/dist/{chunk-ZXUOAFUG.js → chunk-IKNQAGBV.js} +1 -1
  53. package/dist/chunk-IKNQAGBV.js.map +1 -0
  54. package/dist/{chunk-RC3CNIPK.js → chunk-IO5NQEGZ.js} +2 -2
  55. package/dist/{chunk-2ULWWAQH.js → chunk-ISLJ5WIM.js} +2 -2
  56. package/dist/{chunk-2KJG6ZZK.js → chunk-IYOPIG3E.js} +2 -2
  57. package/dist/{chunk-M4DQWUKX.js → chunk-JKOKX3PS.js} +62 -163
  58. package/dist/chunk-JKOKX3PS.js.map +1 -0
  59. package/dist/{chunk-SJQ4HY3E.js → chunk-JO3E5VGS.js} +2 -2
  60. package/dist/{chunk-3LSSFCB4.js → chunk-K4DWSPMW.js} +11 -1
  61. package/dist/chunk-K4DWSPMW.js.map +1 -0
  62. package/dist/{chunk-IZ5E6ZTK.js → chunk-KF4TXW7Z.js} +6 -6
  63. package/dist/{chunk-CQ4PGFMC.js → chunk-KS7WQ4BZ.js} +4 -4
  64. package/dist/chunk-LTJAMRGI.js +286 -0
  65. package/dist/chunk-LTJAMRGI.js.map +1 -0
  66. package/dist/{chunk-PJIHCOGV.js → chunk-MNU5G4TK.js} +2 -2
  67. package/dist/{chunk-2K64VH66.js → chunk-ODTWHSY2.js} +69 -45
  68. package/dist/chunk-ODTWHSY2.js.map +1 -0
  69. package/dist/{chunk-LIWU4QI6.js → chunk-OLOYQZFB.js} +5 -5
  70. package/dist/{chunk-MRFVMSTR.js → chunk-OMNTQPZO.js} +20 -1
  71. package/dist/{chunk-MRFVMSTR.js.map → chunk-OMNTQPZO.js.map} +1 -1
  72. package/dist/{chunk-JI6HWBYL.js → chunk-Q4O3ET6F.js} +2 -2
  73. package/dist/{chunk-N55RJT4N.js → chunk-QP37KL5H.js} +2 -2
  74. package/dist/{chunk-LPIWEKZ3.js → chunk-SDPDU2PM.js} +3 -3
  75. package/dist/{chunk-RIC5U67B.js → chunk-SFMRLXIV.js} +232 -4
  76. package/dist/chunk-SFMRLXIV.js.map +1 -0
  77. package/dist/{chunk-RTN2BLZM.js → chunk-T5QAZIBO.js} +2 -2
  78. package/dist/{chunk-ANZLT74L.js → chunk-TFVVONWD.js} +2 -2
  79. package/dist/{chunk-SIDSEXUG.js → chunk-WFEZUGU5.js} +2 -2
  80. package/dist/{chunk-FSEQXHEZ.js → chunk-XTIRCSIH.js} +2 -2
  81. package/dist/{chunk-XL5RSHZP.js → chunk-YO4MBK3I.js} +2 -2
  82. package/dist/{chunk-AH2JUU6X.js → chunk-Z2YFLXHU.js} +2 -2
  83. package/dist/{chunk-MRX6S22R.js → chunk-ZT7B64BE.js} +2 -2
  84. package/dist/{chunk-UDDSC6PO.js → chunk-ZYNMX6IU.js} +54 -6
  85. package/dist/chunk-ZYNMX6IU.js.map +1 -0
  86. package/dist/{cli-DUMkkdLl.d.ts → cli-D3XeenwN.d.ts} +3 -3
  87. package/dist/cli.d.ts +6 -6
  88. package/dist/cli.js +32 -28
  89. package/dist/compounding/engine.d.ts +1 -1
  90. package/dist/compounding/engine.js +7 -3
  91. package/dist/compounding/preference-consolidator.d.ts +1 -1
  92. package/dist/compression-optimizer.d.ts +1 -1
  93. package/dist/config.d.ts +1 -1
  94. package/dist/config.js +1 -1
  95. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  96. package/dist/connectors/codex-materialize-runner.js +7 -3
  97. package/dist/connectors/codex-materialize.d.ts +1 -1
  98. package/dist/connectors/index.d.ts +1 -1
  99. package/dist/connectors/index.js +7 -3
  100. package/dist/consolidation-provenance-check.d.ts +1 -1
  101. package/dist/consolidation-undo.d.ts +1 -1
  102. package/dist/contradiction/index.d.ts +1 -1
  103. package/dist/conversation-index/backend.d.ts +1 -1
  104. package/dist/conversation-index/chunker.d.ts +1 -1
  105. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  106. package/dist/conversation-index/indexer.d.ts +1 -1
  107. package/dist/conversation-index/search.d.ts +1 -1
  108. package/dist/day-summary.d.ts +1 -1
  109. package/dist/delinearize.d.ts +1 -1
  110. package/dist/direct-answer-wiring.d.ts +1 -1
  111. package/dist/direct-answer.d.ts +1 -1
  112. package/dist/embedding-fallback.d.ts +1 -1
  113. package/dist/enrichment/index.d.ts +1 -1
  114. package/dist/entity-retrieval.d.ts +1 -1
  115. package/dist/entity-retrieval.js +7 -3
  116. package/dist/entity-schema.d.ts +1 -1
  117. package/dist/explicit-capture.d.ts +6 -6
  118. package/dist/extraction-faithfulness.d.ts +1 -1
  119. package/dist/extraction-judge-telemetry.d.ts +1 -1
  120. package/dist/extraction-judge-training.d.ts +1 -1
  121. package/dist/extraction-judge.d.ts +1 -1
  122. package/dist/extraction.d.ts +1 -1
  123. package/dist/fallback-llm.d.ts +1 -1
  124. package/dist/{forget-PLR6J5DN.js → forget-6SOIPUMQ.js} +32 -1
  125. package/dist/forget-6SOIPUMQ.js.map +1 -0
  126. package/dist/identity-continuity.d.ts +1 -1
  127. package/dist/importance.d.ts +1 -1
  128. package/dist/index.d.ts +21 -10
  129. package/dist/index.js +65 -43
  130. package/dist/index.js.map +1 -1
  131. package/dist/intent.d.ts +1 -1
  132. package/dist/lcm/engine.d.ts +1 -1
  133. package/dist/lcm/index.d.ts +1 -1
  134. package/dist/lcm/tools.d.ts +1 -1
  135. package/dist/lifecycle.d.ts +1 -1
  136. package/dist/live-connectors-runner.d.ts +1 -1
  137. package/dist/local-llm.d.ts +1 -1
  138. package/dist/maintenance/memory-governance.d.ts +1 -1
  139. package/dist/maintenance/memory-governance.js +7 -3
  140. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -3
  141. package/dist/maintenance/rebuild-memory-projection.js +8 -4
  142. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  143. package/dist/memory-action-policy.d.ts +1 -1
  144. package/dist/memory-cache.d.ts +1 -1
  145. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  146. package/dist/memory-projection-store.d.ts +1 -1
  147. package/dist/memory-provenance.d.ts +1 -1
  148. package/dist/memory-provenance.js +1 -1
  149. package/dist/memory-worth-outcomes.d.ts +1 -1
  150. package/dist/models-json.d.ts +1 -1
  151. package/dist/namespaces/migrate.d.ts +2 -2
  152. package/dist/namespaces/migrate.js +8 -4
  153. package/dist/namespaces/principal.d.ts +1 -1
  154. package/dist/namespaces/search.d.ts +1 -1
  155. package/dist/namespaces/storage.d.ts +32 -3
  156. package/dist/namespaces/storage.js +7 -3
  157. package/dist/native-knowledge.d.ts +1 -1
  158. package/dist/operator-toolkit.d.ts +19 -2
  159. package/dist/operator-toolkit.js +17 -11
  160. package/dist/orchestration/maintenance.d.ts +2 -2
  161. package/dist/orchestration/maintenance.js +9 -5
  162. package/dist/{orchestrator-Dv8JQu3-.d.ts → orchestrator-BzMCZlKn.d.ts} +3 -3
  163. package/dist/orchestrator.d.ts +4 -4
  164. package/dist/orchestrator.js +22 -20
  165. package/dist/patterns-cli.d.ts +1 -1
  166. package/dist/policy-runtime.d.ts +1 -1
  167. package/dist/provenance.d.ts +1 -1
  168. package/dist/qmd-recall-cache.d.ts +1 -1
  169. package/dist/qmd.d.ts +1 -1
  170. package/dist/recall-disclosure-escalation.d.ts +1 -1
  171. package/dist/recall-explain-renderer.d.ts +1 -1
  172. package/dist/recall-explain-renderer.js +4 -4
  173. package/dist/recall-planner-llm.d.ts +1 -1
  174. package/dist/recall-state.d.ts +1 -1
  175. package/dist/recall-tag-filter.d.ts +1 -1
  176. package/dist/recall-xray-cli.d.ts +1 -1
  177. package/dist/recall-xray-cli.js +5 -5
  178. package/dist/recall-xray-renderer.d.ts +1 -1
  179. package/dist/recall-xray-renderer.js +4 -4
  180. package/dist/recall-xray.d.ts +1 -1
  181. package/dist/recall-xray.js +3 -3
  182. package/dist/resolve-auth-token.d.ts +1 -1
  183. package/dist/resume-bundles.js +2 -2
  184. package/dist/retrieval-agents.d.ts +1 -1
  185. package/dist/retrieval-tiers.d.ts +1 -1
  186. package/dist/routing/engine.d.ts +1 -1
  187. package/dist/routing/store.d.ts +1 -1
  188. package/dist/schemas.d.ts +28 -28
  189. package/dist/search/embed-helper.d.ts +1 -1
  190. package/dist/search/factory.d.ts +1 -1
  191. package/dist/search/index.d.ts +1 -1
  192. package/dist/search/lancedb-backend.d.ts +1 -1
  193. package/dist/search/meilisearch-backend.d.ts +1 -1
  194. package/dist/search/noop-backend.d.ts +1 -1
  195. package/dist/search/orama-backend.d.ts +1 -1
  196. package/dist/search/port.d.ts +1 -1
  197. package/dist/search/remote-backend.d.ts +1 -1
  198. package/dist/{semantic-consolidation-CgREi9E0.d.ts → semantic-consolidation-BtUfv-AL.d.ts} +1 -1
  199. package/dist/semantic-consolidation.d.ts +2 -2
  200. package/dist/semantic-consolidation.js +8 -4
  201. package/dist/semantic-rule-promotion.js +7 -3
  202. package/dist/semantic-rule-verifier.d.ts +1 -1
  203. package/dist/semantic-rule-verifier.js +7 -3
  204. package/dist/session-observer-bands.d.ts +1 -1
  205. package/dist/session-observer-state.d.ts +1 -1
  206. package/dist/shared-context/manager.d.ts +1 -1
  207. package/dist/signal.d.ts +1 -1
  208. package/dist/storage.d.ts +306 -2
  209. package/dist/storage.js +6 -2
  210. package/dist/summarizer.d.ts +1 -1
  211. package/dist/summarizer.js +3 -2
  212. package/dist/summary-snapshot.d.ts +1 -1
  213. package/dist/summary-snapshot.js +2 -1
  214. package/dist/temporal-supersession.d.ts +1 -1
  215. package/dist/temporal-supersession.js +1 -1
  216. package/dist/temporal-validity.d.ts +1 -1
  217. package/dist/threading.d.ts +1 -1
  218. package/dist/tier-migration.d.ts +1 -1
  219. package/dist/tier-routing.d.ts +1 -1
  220. package/dist/topics.d.ts +1 -1
  221. package/dist/transcript.d.ts +1 -1
  222. package/dist/transfer/types.d.ts +12 -12
  223. package/dist/{types-CeZYSC-E.d.ts → types-PuiPZ9iE.d.ts} +34 -1
  224. package/dist/types.d.ts +1 -1
  225. package/dist/types.js +1 -1
  226. package/dist/utility-runtime.d.ts +1 -1
  227. package/dist/utils/serialize-mutations.js +5 -280
  228. package/dist/utils/serialize-mutations.js.map +1 -1
  229. package/dist/verified-recall.js +7 -3
  230. package/package.json +2 -2
  231. package/src/config.ts +10 -0
  232. package/src/lifecycle/tombstones.test.ts +355 -0
  233. package/src/lifecycle/tombstones.ts +807 -0
  234. package/src/maintenance/forget.ts +41 -0
  235. package/src/maintenance/pattern-reinforcement.ts +47 -0
  236. package/src/memory-provenance.ts +12 -0
  237. package/src/namespaces/catalog.test.ts +222 -14
  238. package/src/namespaces/catalog.ts +54 -187
  239. package/src/namespaces/storage.ts +120 -80
  240. package/src/operator-toolkit.ts +77 -0
  241. package/src/orchestrator.ts +16 -0
  242. package/src/review/index.ts +48 -3
  243. package/src/storage.ts +329 -2
  244. package/src/summary-snapshot.test.ts +63 -1
  245. package/src/summary-snapshot.ts +61 -80
  246. package/src/temporal-supersession.ts +38 -0
  247. package/src/types.ts +34 -0
  248. package/src/utils/serialize-mutations.ts +10 -6
  249. package/dist/chunk-2K64VH66.js.map +0 -1
  250. package/dist/chunk-3LSSFCB4.js.map +0 -1
  251. package/dist/chunk-AC5LO7IU.js.map +0 -1
  252. package/dist/chunk-JYVD2XZ4.js.map +0 -1
  253. package/dist/chunk-M4DQWUKX.js.map +0 -1
  254. package/dist/chunk-RIC5U67B.js.map +0 -1
  255. package/dist/chunk-UDDSC6PO.js.map +0 -1
  256. package/dist/chunk-XJNBEDFE.js.map +0 -1
  257. package/dist/chunk-ZXUOAFUG.js.map +0 -1
  258. package/dist/forget-PLR6J5DN.js.map +0 -1
  259. /package/dist/{chunk-E6GOVHHJ.js.map → chunk-27LQPUMZ.js.map} +0 -0
  260. /package/dist/{chunk-A62RAIBN.js.map → chunk-6W2D6FGG.js.map} +0 -0
  261. /package/dist/{chunk-COEZR6F5.js.map → chunk-7TAQEPLE.js.map} +0 -0
  262. /package/dist/{chunk-TIVZ4MCG.js.map → chunk-D75JXBV4.js.map} +0 -0
  263. /package/dist/{chunk-YY2HGKWR.js.map → chunk-DEDQXIDL.js.map} +0 -0
  264. /package/dist/{chunk-XFG3PVZE.js.map → chunk-FUCJAZ25.js.map} +0 -0
  265. /package/dist/{chunk-EZWQZYLK.js.map → chunk-HF4N43Q7.js.map} +0 -0
  266. /package/dist/{chunk-HZ5DRA4Q.js.map → chunk-HXHKLVAS.js.map} +0 -0
  267. /package/dist/{chunk-QVTSLRKT.js.map → chunk-IJEZMWKA.js.map} +0 -0
  268. /package/dist/{chunk-RC3CNIPK.js.map → chunk-IO5NQEGZ.js.map} +0 -0
  269. /package/dist/{chunk-2ULWWAQH.js.map → chunk-ISLJ5WIM.js.map} +0 -0
  270. /package/dist/{chunk-2KJG6ZZK.js.map → chunk-IYOPIG3E.js.map} +0 -0
  271. /package/dist/{chunk-SJQ4HY3E.js.map → chunk-JO3E5VGS.js.map} +0 -0
  272. /package/dist/{chunk-IZ5E6ZTK.js.map → chunk-KF4TXW7Z.js.map} +0 -0
  273. /package/dist/{chunk-CQ4PGFMC.js.map → chunk-KS7WQ4BZ.js.map} +0 -0
  274. /package/dist/{chunk-PJIHCOGV.js.map → chunk-MNU5G4TK.js.map} +0 -0
  275. /package/dist/{chunk-LIWU4QI6.js.map → chunk-OLOYQZFB.js.map} +0 -0
  276. /package/dist/{chunk-JI6HWBYL.js.map → chunk-Q4O3ET6F.js.map} +0 -0
  277. /package/dist/{chunk-N55RJT4N.js.map → chunk-QP37KL5H.js.map} +0 -0
  278. /package/dist/{chunk-LPIWEKZ3.js.map → chunk-SDPDU2PM.js.map} +0 -0
  279. /package/dist/{chunk-RTN2BLZM.js.map → chunk-T5QAZIBO.js.map} +0 -0
  280. /package/dist/{chunk-ANZLT74L.js.map → chunk-TFVVONWD.js.map} +0 -0
  281. /package/dist/{chunk-SIDSEXUG.js.map → chunk-WFEZUGU5.js.map} +0 -0
  282. /package/dist/{chunk-FSEQXHEZ.js.map → chunk-XTIRCSIH.js.map} +0 -0
  283. /package/dist/{chunk-XL5RSHZP.js.map → chunk-YO4MBK3I.js.map} +0 -0
  284. /package/dist/{chunk-AH2JUU6X.js.map → chunk-Z2YFLXHU.js.map} +0 -0
  285. /package/dist/{chunk-MRX6S22R.js.map → chunk-ZT7B64BE.js.map} +0 -0
@@ -0,0 +1,807 @@
1
+ // ---------------------------------------------------------------------------
2
+ // lifecycle/tombstones.ts — Tombstone store + non-resurrection invariant
3
+ // (issue #1579)
4
+ //
5
+ // A "tombstone" records that a fact has been retired (corrected, superseded,
6
+ // or retracted) so that the SAME fact cannot silently come back to life through
7
+ // any of the five resurrection paths:
8
+ //
9
+ // 1. Re-extraction (session replay, re-observation)
10
+ // 2. Importers (capsule / import-* payloads)
11
+ // 3. Consolidation merges
12
+ // 4. Dreams (REM re-derivation)
13
+ // 5. Pattern reinforcement (duplicate promotion)
14
+ //
15
+ // The invariant is enforced at the SINGLE storage persist path
16
+ // (`StorageManager.writeMemory` — the same chokepoint that records catalog
17
+ // writes, issue #1522). Every write path funnels through it, so paths (a)–(e)
18
+ // are blocked WITHOUT per-path code.
19
+ //
20
+ // File-first / rebuildable (the repo's storage philosophy): the JSONL at
21
+ // `<stateDir>/tombstones.jsonl` is a cache of truth. The authoritative sources
22
+ // are the memory files themselves (status: superseded/retracted) and the
23
+ // `corrections/` records. `rebuildTombstonesFromStorage` reconstructs the
24
+ // JSONL from those sources.
25
+ //
26
+ // Design rules honored (issue #1579 design section):
27
+ // - Hash `rawContent` exactly as the dedup index does — one helper
28
+ // (`ContentHashIndex.computeHash` / `normalizeContent`), never a second
29
+ // (checklist §13; rule 23).
30
+ // - Append-only log — never rewrite history; a later `kind: "revocation"`
31
+ // entry re-allows (rule 25).
32
+ // - Instance-scoped, never module-level (rule 11).
33
+ // - Namespace-scoped — a tombstone in namespace A never blocks namespace B
34
+ // (rule 42).
35
+ // - Serialized appends with rejection recovery (rule 40).
36
+ // - Never silent drop — a blocked write lands as pending_review + blockedBy
37
+ // (rule 34).
38
+ // - Do NOT register a blocked fact as an active dedup/index entry (rule 44).
39
+ // - Off = pre-feature behavior for rollback safety (rule 30).
40
+ // - Semantic tier (4) ships dark, off by default (rule 48).
41
+ // ---------------------------------------------------------------------------
42
+
43
+ import { serializeMutations } from "../utils/serialize-mutations.js";
44
+
45
+ /** Why a tombstone was emitted. */
46
+ export type TombstoneReason =
47
+ | "correction"
48
+ | "supersession"
49
+ | "retraction"
50
+ | "contradiction_resolution";
51
+
52
+ /** Who emitted the tombstone. */
53
+ export type TombstoneCreatedBy =
54
+ | "user_correction"
55
+ | "contradiction_resolution"
56
+ | "supersession"
57
+ | "chat";
58
+
59
+ /** Tier that matched on lookup. */
60
+ export type TombstoneMatchTier = "exact" | "normalized" | "keyed" | "semantic";
61
+
62
+ /**
63
+ * A single append-only tombstone log entry.
64
+ *
65
+ * `kind: "tombstone"` blocks; `kind: "revocation"` re-allows. The log is
66
+ * never rewritten — the NEWEST matching entry wins at lookup (rule 25).
67
+ */
68
+ export interface TombstoneEntry {
69
+ /** Stable tombstone id (`tomb-<ts>-<rand>`). */
70
+ id: string;
71
+ kind: "tombstone" | "revocation";
72
+ reason: TombstoneReason;
73
+ /** The memory that was retired. */
74
+ sourceMemoryId: string;
75
+ /** sha256 of the retired memory's rawContent (rule 23). */
76
+ contentHash: string;
77
+ /** `ContentHashIndex.normalizeContent(rawContent)` — the pre-hash form. */
78
+ normalizedText: string;
79
+ entityRef?: string;
80
+ /** Structured-attribute supersession key when one existed. */
81
+ supersessionKey?: string;
82
+ /** Namespace scope (rule 42). */
83
+ namespace: string;
84
+ createdAt: string;
85
+ createdBy: TombstoneCreatedBy;
86
+ /** For `kind: "revocation"`: the tombstone id being revoked. */
87
+ revokes?: string;
88
+ }
89
+
90
+ /** A positive block decision returned by `TombstoneStore.lookup`. */
91
+ export interface TombstoneMatch {
92
+ tombstoneId: string;
93
+ matchedTier: TombstoneMatchTier;
94
+ reason: TombstoneReason;
95
+ }
96
+
97
+ /** Inputs to a lookup. At least one discriminator must be present.
98
+ *
99
+ * Issue #1579 thread Ociag/Oci-W: `supersessionKeys` (plural) lets the write
100
+ * chokepoint check EVERY derived key, not just the first. Emitters register
101
+ * one tombstone per matched key (temporal-supersession, rebuild), so a block
102
+ * can live on any later key; querying only `supersessionKeys[0]` missed it
103
+ * and the retired fact resurrected as active. `supersessionKey` (singular)
104
+ * remains for direct/unit callers; `lookup` checks the union of both. */
105
+ export interface TombstoneLookupQuery {
106
+ contentHash?: string;
107
+ normalizedText?: string;
108
+ entityRef?: string;
109
+ /** Single supersession key (direct/unit callers). */
110
+ supersessionKey?: string;
111
+ /** All derived supersession keys (write chokepoint). The keyed tier is
112
+ * checked for each; the first active match wins. */
113
+ supersessionKeys?: string[];
114
+ namespace: string;
115
+ }
116
+
117
+ /** Configuration for a tombstone store instance. */
118
+ export interface TombstoneStoreOptions {
119
+ enabled: boolean;
120
+ semanticMatch: boolean;
121
+ semanticThreshold: number;
122
+ /** sha256 of raw content — wired to `ContentHashIndex.computeHash`. */
123
+ hashContent: (raw: string) => string;
124
+ /** Normalize raw content — wired to `ContentHashIndex.normalizeContent`. */
125
+ normalizeText: (raw: string) => string;
126
+ /**
127
+ * Optional cosine similarity in [0, 1] for the semantic tier. When
128
+ * undefined or when `semanticMatch` is false, the semantic tier is
129
+ * skipped entirely.
130
+ */
131
+ semanticSimilarity?: (a: string, b: string) => number;
132
+ }
133
+
134
+ /** Injected file I/O — the StorageManager wires its secure-store-aware
135
+ * implementations so tombstones are encrypted at rest alongside other state.
136
+ * `stat` is optional; when provided the store tracks the file mtime itself and
137
+ * runs a cross-process staleness probe on each access (#1579). */
138
+ export interface TombstoneFileIo {
139
+ read: (filePath: string) => Promise<string>;
140
+ append: (filePath: string, content: string) => Promise<void>;
141
+ write: (filePath: string, content: string) => Promise<void>;
142
+ stat?: (filePath: string) => { mtimeMs: number };
143
+ }
144
+
145
+ /** Aggregate stats for `remnic doctor`. */
146
+ export interface TombstoneStats {
147
+ count: number;
148
+ revoked: number;
149
+ lastAppendAt: string | null;
150
+ corruptedLines: number;
151
+ /** Whether the in-memory index matches the on-disk file (rebuild check). */
152
+ loaded: boolean;
153
+ }
154
+
155
+ const TOMBSTONE_PREFIX = "tomb";
156
+
157
+ function newTombstoneId(): string {
158
+ return `${TOMBSTONE_PREFIX}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
159
+ }
160
+
161
+ /** Deterministic key for the keyed tier (namespace + entityRef + supersessionKey).
162
+ *
163
+ * The namespace is part of the discriminator so two namespace-scoped stores
164
+ * that share the same backing `tombstones.jsonl` (namespaces disabled, or the
165
+ * same directory used with different namespace configs) cannot overwrite each
166
+ * other's index entries — a later tombstone for namespace B with the same
167
+ * entity/key no longer evicts namespace A's entry (issue #1579 thread Ocs-O). */
168
+ function keyedTierKey(namespace: string, entityRef: string, supersessionKey: string): string {
169
+ return `${namespace}\0${entityRef}\0${supersessionKey}`;
170
+ }
171
+
172
+ /**
173
+ * Parse one JSONL line into a TombstoneEntry, validating the discriminated
174
+ * union. Returns `null` for malformed lines (rule 34 — skip with a counter,
175
+ * never crash).
176
+ */
177
+ export function parseTombstoneLine(line: string): TombstoneEntry | null {
178
+ const trimmed = line.trim();
179
+ if (trimmed.length === 0) return null;
180
+ let parsed: unknown;
181
+ try {
182
+ parsed = JSON.parse(trimmed);
183
+ } catch {
184
+ return null;
185
+ }
186
+ if (!parsed || typeof parsed !== "object") return null;
187
+ const e = parsed as Record<string, unknown>;
188
+ if (typeof e.id !== "string" || e.id.length === 0) return null;
189
+ if (e.kind !== "tombstone" && e.kind !== "revocation") return null;
190
+ if (
191
+ e.reason !== "correction" &&
192
+ e.reason !== "supersession" &&
193
+ e.reason !== "retraction" &&
194
+ e.reason !== "contradiction_resolution"
195
+ ) {
196
+ return null;
197
+ }
198
+ if (typeof e.sourceMemoryId !== "string") return null;
199
+ if (typeof e.contentHash !== "string") return null;
200
+ if (typeof e.normalizedText !== "string") return null;
201
+ if (typeof e.namespace !== "string") return null;
202
+ if (typeof e.createdAt !== "string") return null;
203
+ if (
204
+ e.createdBy !== "user_correction" &&
205
+ e.createdBy !== "contradiction_resolution" &&
206
+ e.createdBy !== "supersession" &&
207
+ e.createdBy !== "chat"
208
+ ) {
209
+ return null;
210
+ }
211
+ const out: TombstoneEntry = {
212
+ id: e.id,
213
+ kind: e.kind,
214
+ reason: e.reason,
215
+ sourceMemoryId: e.sourceMemoryId,
216
+ contentHash: e.contentHash,
217
+ normalizedText: e.normalizedText,
218
+ namespace: e.namespace,
219
+ createdAt: e.createdAt,
220
+ createdBy: e.createdBy,
221
+ };
222
+ if (typeof e.entityRef === "string") out.entityRef = e.entityRef;
223
+ if (typeof e.supersessionKey === "string") out.supersessionKey = e.supersessionKey;
224
+ if (typeof e.revokes === "string") out.revokes = e.revokes;
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * The instance-scoped tombstone store. One per StorageManager (rule 11),
230
+ * namespace-scoped via `namespace` + the on-disk path (rule 42).
231
+ *
232
+ * The in-memory index is built lazily on first access and invalidated on
233
+ * append + by the StorageManager's cache-invalidation chokepoint (rule 25).
234
+ */
235
+ export class TombstoneStore {
236
+ private entries: TombstoneEntry[] = [];
237
+ /** contentHash → tombstone id (newest) — exact tier. */
238
+ private readonly byHash = new Map<string, string>();
239
+ /** normalizedText → tombstone id (newest) — normalized tier. */
240
+ private readonly byNormalized = new Map<string, string>();
241
+ /** entityRef\0supersessionKey → tombstone id (newest) — keyed tier. */
242
+ private readonly byKey = new Map<string, string>();
243
+ /** Tombstone ids that have a later revocation entry. */
244
+ private readonly revokedIds = new Set<string>();
245
+ /** id → entry index (newest only, for revocation lookups). */
246
+ private readonly byId = new Map<string, TombstoneEntry>();
247
+ private loaded = false;
248
+ private loadPromise: Promise<void> | null = null;
249
+ private corruptedLines = 0;
250
+ private lastAppendAt: string | null = null;
251
+ /**
252
+ * Last-seen mtime of the JSONL file (cross-process staleness probe, #1579).
253
+ * Tracked inside the store so the StorageManager wiring stays thin. When the
254
+ * file's mtime advances past this value between accesses, the in-memory index
255
+ * is stale (a peer process appended) and is reloaded before the next lookup.
256
+ */
257
+ private fileMtimeMs = 0;
258
+
259
+ constructor(
260
+ private readonly filePath: string,
261
+ private readonly namespace: string,
262
+ private readonly options: TombstoneStoreOptions,
263
+ private readonly io: TombstoneFileIo,
264
+ ) {}
265
+
266
+ /** Lazy load + build the in-memory index. Idempotent. */
267
+ async load(): Promise<void> {
268
+ if (this.loaded) return;
269
+ if (this.loadPromise) return this.loadPromise;
270
+ this.loadPromise = this.loadInternal();
271
+ try {
272
+ await this.loadPromise;
273
+ } finally {
274
+ this.loadPromise = null;
275
+ }
276
+ }
277
+
278
+ private async loadInternal(): Promise<void> {
279
+ // Record the file's mtime so the staleness probe does not immediately
280
+ // invalidate on the next access (fileMtimeMs starts at 0; without this,
281
+ // any non-zero mtime would trigger a spurious reload). Done before the
282
+ // read so an ENOENT still records 0.
283
+ this.recordFileMtime();
284
+ let raw: string;
285
+ try {
286
+ raw = await this.io.read(this.filePath);
287
+ } catch (err) {
288
+ // ENOENT is fine — fresh store. Other errors leave the store empty
289
+ // rather than crashing the write path (rule 34 spirit: degrade to
290
+ // "no tombstones known" rather than blocking all writes).
291
+ const code = (err as NodeJS.ErrnoException)?.code;
292
+ if (code !== "ENOENT") {
293
+ // Swallow — the write path must not crash on a corrupt tombstone file.
294
+ // Rebuild will repair on the next doctor/maintenance run.
295
+ }
296
+ this.loaded = true;
297
+ return;
298
+ }
299
+ this.resetIndex();
300
+ let corrupted = 0;
301
+ for (const line of raw.split("\n")) {
302
+ const entry = parseTombstoneLine(line);
303
+ if (!entry) {
304
+ if (line.trim().length > 0) corrupted += 1;
305
+ continue;
306
+ }
307
+ this.indexEntry(entry);
308
+ }
309
+ this.corruptedLines = corrupted;
310
+ this.loaded = true;
311
+ }
312
+
313
+ private resetIndex(): void {
314
+ this.entries = [];
315
+ this.byHash.clear();
316
+ this.byNormalized.clear();
317
+ this.byKey.clear();
318
+ this.revokedIds.clear();
319
+ this.byId.clear();
320
+ }
321
+
322
+ /**
323
+ * Index an entry. Later entries (higher array index = newer createdAt)
324
+ * OVERRIDE earlier ones at each tier key — the newest matching entry wins
325
+ * (rule 25). Revocation entries mark their target as revoked.
326
+ */
327
+ private indexEntry(entry: TombstoneEntry): void {
328
+ this.entries.push(entry);
329
+ this.byId.set(entry.id, entry);
330
+ if (entry.createdAt > (this.lastAppendAt ?? "")) {
331
+ this.lastAppendAt = entry.createdAt;
332
+ }
333
+ if (entry.kind === "revocation") {
334
+ if (entry.revokes) this.revokedIds.add(entry.revokes);
335
+ return;
336
+ }
337
+ // Tombstone entries populate the lookup maps. The namespace is part of
338
+ // every discriminator key (issue #1579 thread Ocs-O): when two
339
+ // namespace-scoped stores share the same backing file, a later tombstone
340
+ // for namespace B with identical content must NOT evict namespace A's map
341
+ // entry — otherwise A's lookup finds B's id, rejects it on namespace
342
+ // mismatch, and misses its own still-active tombstone (resurrection).
343
+ const ns = entry.namespace;
344
+ if (entry.contentHash) this.byHash.set(`${ns}\0${entry.contentHash}`, entry.id);
345
+ if (entry.normalizedText) this.byNormalized.set(`${ns}\0${entry.normalizedText}`, entry.id);
346
+ if (entry.entityRef && entry.supersessionKey) {
347
+ this.byKey.set(keyedTierKey(ns, entry.entityRef, entry.supersessionKey), entry.id);
348
+ }
349
+ }
350
+
351
+ /** Invalidate the in-memory cache (rule 25). The next access reloads. */
352
+ invalidate(): void {
353
+ this.loaded = false;
354
+ this.loadPromise = null;
355
+ this.resetIndex();
356
+ }
357
+
358
+ /**
359
+ * Cross-process staleness probe (#1579). If the file's mtime advanced since
360
+ * the last load/own-write, a peer process appended and our in-memory index is
361
+ * stale — invalidate AND reload in place so the next lookup sees the new
362
+ * entries. Best-effort: a stat error is swallowed (the loaded index is
363
+ * retained rather than crashing the write path). No-op when no `stat` was
364
+ * injected or when the mtime is unchanged. After our own append/revoke/rebuild
365
+ * `markWritten` records the new mtime, so this probe does not fire for our
366
+ * own writes (only for peer-process appends).
367
+ */
368
+ async ensureFreshAgainstDisk(): Promise<void> {
369
+ if (!this.io.stat) return;
370
+ let mtimeMs: number;
371
+ try {
372
+ mtimeMs = Math.floor(this.io.stat(this.filePath).mtimeMs);
373
+ } catch {
374
+ return; // ENOENT / permission — keep the loaded index (fail-open).
375
+ }
376
+ if (mtimeMs === this.fileMtimeMs && this.loaded) return;
377
+ this.fileMtimeMs = mtimeMs;
378
+ this.invalidate();
379
+ await this.load();
380
+ }
381
+
382
+ /**
383
+ * Record the file mtime after THIS process writes (append / revoke / rebuild)
384
+ * so `ensureFreshAgainstDisk` does not treat our own write as a peer append
385
+ * and throw away the just-updated in-memory index (#1579 — avoids a needless
386
+ * invalidate+reload in the hot write path).
387
+ */
388
+ private markWritten(): void {
389
+ this.recordFileMtime();
390
+ }
391
+
392
+ private recordFileMtime(): void {
393
+ if (!this.io.stat) return;
394
+ try {
395
+ this.fileMtimeMs = Math.floor(this.io.stat(this.filePath).mtimeMs);
396
+ } catch {
397
+ // ENOENT — fresh store with no file yet; mtime stays at its current value.
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Append a tombstone entry. Serialized via `serializeMutations` keyed by
403
+ * file path so concurrent appends do not interleave, with rejection recovery
404
+ * (rule 40 — a single failed append never poisons the chain). The in-memory
405
+ * index is updated AFTER the durable append succeeds.
406
+ *
407
+ * Returns the new tombstone id.
408
+ */
409
+ async appendTombstone(input: {
410
+ reason: TombstoneReason;
411
+ createdBy: TombstoneCreatedBy;
412
+ sourceMemoryId: string;
413
+ rawContent: string;
414
+ entityRef?: string;
415
+ supersessionKey?: string;
416
+ createdAt?: string;
417
+ /**
418
+ * Pre-computed canonical contentHash from the retired memory's frontmatter.
419
+ * When provided, used directly so the tombstone's exact tier matches the
420
+ * hash writeMemory computes on re-extraction (issue #1579 review: cited
421
+ * facts must not slip past the chokepoint because the emitter hashed the
422
+ * citation-annotated body instead of the canonical source).
423
+ */
424
+ contentHash?: string;
425
+ }): Promise<string> {
426
+ await this.load();
427
+ const createdAt = input.createdAt ?? new Date().toISOString();
428
+ const id = newTombstoneId();
429
+ const entry: TombstoneEntry = {
430
+ id,
431
+ kind: "tombstone",
432
+ reason: input.reason,
433
+ sourceMemoryId: input.sourceMemoryId,
434
+ contentHash: input.contentHash ?? this.options.hashContent(input.rawContent),
435
+ normalizedText: this.options.normalizeText(input.rawContent),
436
+ ...(input.entityRef ? { entityRef: input.entityRef } : {}),
437
+ ...(input.supersessionKey ? { supersessionKey: input.supersessionKey } : {}),
438
+ namespace: this.namespace,
439
+ createdAt,
440
+ createdBy: input.createdBy,
441
+ };
442
+ await this.serializeAppend(entry);
443
+ this.indexEntry(entry);
444
+ this.markWritten();
445
+ return id;
446
+ }
447
+
448
+ /**
449
+ * Append a revocation entry referencing `tombstoneId`. The log is
450
+ * append-only; lookup treats a revoked tombstone as re-allowed (rule 25).
451
+ */
452
+ async revoke(tombstoneId: string, createdBy: TombstoneCreatedBy): Promise<string> {
453
+ await this.load();
454
+ const createdAt = new Date().toISOString();
455
+ const id = newTombstoneId();
456
+ const entry: TombstoneEntry = {
457
+ id,
458
+ kind: "revocation",
459
+ reason: "correction",
460
+ sourceMemoryId: "",
461
+ contentHash: "",
462
+ normalizedText: "",
463
+ namespace: this.namespace,
464
+ createdAt,
465
+ createdBy,
466
+ revokes: tombstoneId,
467
+ };
468
+ await this.serializeAppend(entry);
469
+ this.indexEntry(entry);
470
+ this.markWritten();
471
+ return id;
472
+ }
473
+
474
+ private serializeAppend(entry: TombstoneEntry): Promise<void> {
475
+ const line = JSON.stringify(entry) + "\n";
476
+ // serializeMutations recovers after rejection (rule 40): a failed append
477
+ // surfaces to THIS caller but the next append is not poisoned.
478
+ return serializeMutations(`tombstone:${this.filePath}`, () =>
479
+ this.io.append(this.filePath, line),
480
+ );
481
+ }
482
+
483
+ /**
484
+ * Look up whether `query` is blocked by an active (non-revoked) tombstone.
485
+ * Tiers are checked in order: exact (contentHash) → normalized → keyed →
486
+ * semantic (off by default). Returns the first active match, or `null`.
487
+ *
488
+ * Namespace isolation (rule 42): only entries with `namespace === query.namespace`
489
+ * match. The store is per-namespace, so this is belt-and-suspenders.
490
+ */
491
+ lookup(query: TombstoneLookupQuery): TombstoneMatch | null {
492
+ // We intentionally do NOT await load() here — lookup is called from the
493
+ // hot write path. Callers MUST ensure the store is loaded before lookup
494
+ // (StorageManager does this in ensureTombstoneStoreLoaded). If not loaded,
495
+ // lookup returns null (fail-open: a missing tombstone check is preferable
496
+ // to crashing every write — see rule 34 / the "degrade gracefully" note).
497
+ if (!this.options.enabled) return null;
498
+ if (!this.loaded) return null;
499
+
500
+ // Tier 1: exact contentHash. Namespace is part of the map key (thread
501
+ // Ocs-O), so the lookup finds only this namespace's tombstone even when
502
+ // the backing file is shared. The namespace equality check below is kept
503
+ // as defense-in-depth.
504
+ const ns = query.namespace;
505
+ if (query.contentHash) {
506
+ const id = this.byHash.get(`${ns}\0${query.contentHash}`);
507
+ if (id && !this.revokedIds.has(id)) {
508
+ const entry = this.byId.get(id);
509
+ if (entry && entry.namespace === ns) {
510
+ return { tombstoneId: id, matchedTier: "exact", reason: entry.reason };
511
+ }
512
+ }
513
+ }
514
+ // Tier 2: normalized text.
515
+ if (query.normalizedText) {
516
+ const id = this.byNormalized.get(`${ns}\0${query.normalizedText}`);
517
+ if (id && !this.revokedIds.has(id)) {
518
+ const entry = this.byId.get(id);
519
+ if (entry && entry.namespace === ns) {
520
+ return { tombstoneId: id, matchedTier: "normalized", reason: entry.reason };
521
+ }
522
+ }
523
+ }
524
+ // Tier 3: keyed (entityRef + supersessionKey). Issue #1579 thread
525
+ // Ociag/Oci-W: check EVERY supplied key, not just the first — emitters
526
+ // append one tombstone per matched supersession key, so the active block
527
+ // can live on any later key. The union of `supersessionKey` (singular,
528
+ // direct callers) and `supersessionKeys` (array, write chokepoint) is
529
+ // checked; the first active match wins (tiers are equality-based, so
530
+ // ordering across keys does not affect correctness).
531
+ if (query.entityRef) {
532
+ const keysToCheck: string[] = [];
533
+ if (query.supersessionKey) keysToCheck.push(query.supersessionKey);
534
+ if (query.supersessionKeys) {
535
+ for (const k of query.supersessionKeys) {
536
+ if (!keysToCheck.includes(k)) keysToCheck.push(k);
537
+ }
538
+ }
539
+ for (const key of keysToCheck) {
540
+ const id = this.byKey.get(keyedTierKey(ns, query.entityRef, key));
541
+ if (id && !this.revokedIds.has(id)) {
542
+ const entry = this.byId.get(id);
543
+ if (entry && entry.namespace === ns) {
544
+ return { tombstoneId: id, matchedTier: "keyed", reason: entry.reason };
545
+ }
546
+ }
547
+ }
548
+ }
549
+ // Tier 4: semantic (off by default, rule 48).
550
+ if (this.options.semanticMatch && this.options.semanticSimilarity && query.normalizedText) {
551
+ const threshold = this.options.semanticThreshold;
552
+ let best: { id: string; reason: TombstoneReason; score: number } | null = null;
553
+ for (const entry of this.entries) {
554
+ if (entry.kind !== "tombstone") continue;
555
+ if (entry.namespace !== query.namespace) continue;
556
+ if (this.revokedIds.has(entry.id)) continue;
557
+ if (!entry.normalizedText) continue;
558
+ const score = this.options.semanticSimilarity(query.normalizedText, entry.normalizedText);
559
+ if (score >= threshold && (!best || score > best.score)) {
560
+ best = { id: entry.id, reason: entry.reason, score };
561
+ }
562
+ }
563
+ if (best) {
564
+ return { tombstoneId: best.id, matchedTier: "semantic", reason: best.reason };
565
+ }
566
+ }
567
+ return null;
568
+ }
569
+
570
+ /** Aggregate stats for the doctor / x-ray surfaces. */
571
+ stats(): TombstoneStats {
572
+ let active = 0;
573
+ for (const entry of this.entries) {
574
+ if (entry.kind === "tombstone" && !this.revokedIds.has(entry.id)) active += 1;
575
+ }
576
+ return {
577
+ count: active,
578
+ revoked: this.revokedIds.size,
579
+ lastAppendAt: this.lastAppendAt,
580
+ corruptedLines: this.corruptedLines,
581
+ loaded: this.loaded,
582
+ };
583
+ }
584
+
585
+ /** Read-only snapshot of all entries (for rebuild + tests). */
586
+ snapshot(): readonly TombstoneEntry[] {
587
+ return this.entries;
588
+ }
589
+
590
+ /**
591
+ * Rebuild the in-memory index + JSONL from the supplied retired-memory
592
+ * records + existing entries (preserving revocations). Byte-stable: entries
593
+ * are sorted by (createdAt, id) before write (rule 38).
594
+ *
595
+ * Returns the count of tombstone entries written.
596
+ */
597
+ async rebuild(retiredMemories: ReadonlyArray<RetiredMemoryRecord>): Promise<number> {
598
+ // Preserve existing revocations (all namespaces — ids are globally
599
+ // unique) so a rebuild does not silently un-revoke.
600
+ const existingRevocations = this.entries.filter((e) => e.kind === "revocation");
601
+ // Preserve tombstone entries from OTHER namespaces when the backing file
602
+ // is shared (issue #1579 thread Oc2MJ). rebuild rewrites the entire file;
603
+ // without preserving foreign entries, rebuilding namespace A would
604
+ // silently delete namespace B's tombstones, allowing resurrection in B.
605
+ const foreignTombstones = this.entries.filter(
606
+ (e) => e.kind === "tombstone" && e.namespace !== this.namespace,
607
+ );
608
+ // Reuse existing tombstone ids for source-equivalent entries so a prior
609
+ // revocation (which references the tombstone id) survives rebuild — minting
610
+ // fresh ids would orphan the revocation and silently un-revoke the content.
611
+ // Issue #1579 thread Oci-T: key the reuse map by (sourceMemoryId,
612
+ // supersessionKey), not just sourceMemoryId. A retired fact with multiple
613
+ // structured-attribute keys emits one record per key (see
614
+ // collectRetiredMemoriesForRebuild); keying only by sourceMemoryId made
615
+ // every rebuilt record share one id, so a revocation of key A silently
616
+ // revoked key B (over-revoke) — or, if a prior single-key tombstone was
617
+ // revoked, the new multi-key records all inherited a revoked id and
618
+ // silently un-blocked (orphan). Including the supersession-key
619
+ // discriminator keeps each keyed tombstone's id (and thus its revocation)
620
+ // independent. Records without a supersession key fall back to a stable
621
+ // empty-string discriminator so they still reuse by sourceMemoryId alone.
622
+ const existingBySource = new Map<string, string>();
623
+ for (const e of this.entries) {
624
+ if (e.kind === "tombstone") {
625
+ existingBySource.set(`${e.sourceMemoryId}\u{0000}${e.supersessionKey ?? ""}`, e.id);
626
+ }
627
+ }
628
+ const rebuilt: TombstoneEntry[] = retiredMemories.map((m) => ({
629
+ id:
630
+ existingBySource.get(`${m.memoryId}\u{0000}${m.supersessionKey ?? ""}`) ??
631
+ newTombstoneId(),
632
+ kind: "tombstone" as const,
633
+ reason: m.reason,
634
+ sourceMemoryId: m.memoryId,
635
+ contentHash: m.contentHash ?? this.options.hashContent(m.rawContent),
636
+ normalizedText: this.options.normalizeText(m.rawContent),
637
+ ...(m.entityRef ? { entityRef: m.entityRef } : {}),
638
+ ...(m.supersessionKey ? { supersessionKey: m.supersessionKey } : {}),
639
+ namespace: this.namespace,
640
+ createdAt: m.createdAt,
641
+ createdBy: m.createdBy,
642
+ }));
643
+ // Sort deterministically (rule 38): createdAt, then id for stability.
644
+ rebuilt.sort((a, b) =>
645
+ a.createdAt === b.createdAt
646
+ ? a.id < b.id ? -1 : a.id > b.id ? 1 : 0
647
+ : a.createdAt < b.createdAt ? -1 : 1,
648
+ );
649
+ const all = [...rebuilt, ...existingRevocations, ...foreignTombstones].sort((a, b) =>
650
+ a.createdAt === b.createdAt
651
+ ? a.id < b.id ? -1 : a.id > b.id ? 1 : 0
652
+ : a.createdAt < b.createdAt ? -1 : 1,
653
+ );
654
+ const serialized = all.map((e) => JSON.stringify(e)).join("\n") + (all.length > 0 ? "\n" : "");
655
+ await serializeMutations(`tombstone-rebuild:${this.filePath}`, () =>
656
+ this.io.write(this.filePath, serialized),
657
+ );
658
+ this.resetIndex();
659
+ for (const entry of all) this.indexEntry(entry);
660
+ this.corruptedLines = 0;
661
+ this.loaded = true;
662
+ this.markWritten();
663
+ return rebuilt.length;
664
+ }
665
+ }
666
+
667
+ /** A retired memory projected into the shape `TombstoneStore.rebuild` consumes. */
668
+ export interface RetiredMemoryRecord {
669
+ memoryId: string;
670
+ rawContent: string;
671
+ entityRef?: string;
672
+ supersessionKey?: string;
673
+ reason: TombstoneReason;
674
+ createdBy: TombstoneCreatedBy;
675
+ createdAt: string;
676
+ /** Canonical contentHash from the retired memory's frontmatter (#1579). */
677
+ contentHash?: string;
678
+ }
679
+
680
+ /**
681
+ * Project a corpus of memories into the retired-memory records `rebuild`
682
+ * consumes. Pure (no I/O) so the StorageManager wiring stays thin (#1579,
683
+ * #1520 god-file ratchet). Only superseded / forgotten / rejected FACTS
684
+ * participate — entities, questions, and artifacts have their own lifecycle
685
+ * (issue pitfall). The supersession key is derived from structured attributes
686
+ * via the injected helper so there is one keyed-tier definition (rule 23).
687
+ */
688
+ export function collectRetiredMemoriesForRebuild(
689
+ memories: ReadonlyArray<{
690
+ frontmatter: {
691
+ id: string;
692
+ status?: string;
693
+ category?: string;
694
+ contentHash?: string;
695
+ entityRef?: string;
696
+ structuredAttributes?: Record<string, string>;
697
+ updated?: string;
698
+ created?: string;
699
+ };
700
+ content: string;
701
+ }>,
702
+ deps: {
703
+ /** Strip citation annotations from the body before hashing/normalizing. */
704
+ stripCitation: (text: string) => string;
705
+ /** Derive the keyed-tier supersession key (one helper, rule 23). */
706
+ supersessionKeysForFact: (spec: {
707
+ entityRef?: string;
708
+ structuredAttributes?: Record<string, string>;
709
+ }) => string[];
710
+ },
711
+ ): RetiredMemoryRecord[] {
712
+ const retired: RetiredMemoryRecord[] = [];
713
+ for (const m of memories) {
714
+ const status = m.frontmatter.status;
715
+ if (status !== "superseded" && status !== "rejected" && status !== "forgotten") continue;
716
+ if (m.frontmatter.category !== "fact") continue;
717
+ const reason: TombstoneReason =
718
+ status === "superseded" ? "supersession" : status === "forgotten" ? "retraction" : "correction";
719
+ const createdBy: TombstoneCreatedBy =
720
+ reason === "supersession" ? "supersession" : "user_correction";
721
+ const entityRef = m.frontmatter.entityRef;
722
+ const keys =
723
+ entityRef && m.frontmatter.structuredAttributes
724
+ ? deps.supersessionKeysForFact({
725
+ entityRef,
726
+ structuredAttributes: m.frontmatter.structuredAttributes,
727
+ })
728
+ : [];
729
+ // Issue #1579 thread Ocgjz: emit one record per matched supersession key
730
+ // (not just keys[0]) so rebuild reproduces the same keyed tombstones as
731
+ // live temporal-supersession (which now appends one per key — thread ObteS).
732
+ // Without this, a rebuild would under-rebuild the JSONL and keyed-tier
733
+ // blocks would disappear until rediscovered.
734
+ const keysToEmit = keys.length > 0 ? keys : [undefined];
735
+ for (const key of keysToEmit) {
736
+ retired.push({
737
+ memoryId: m.frontmatter.id,
738
+ rawContent: deps.stripCitation(m.content),
739
+ contentHash: m.frontmatter.contentHash,
740
+ ...(entityRef ? { entityRef } : {}),
741
+ ...(key ? { supersessionKey: key } : {}),
742
+ reason,
743
+ createdBy,
744
+ createdAt: m.frontmatter.updated || m.frontmatter.created || new Date().toISOString(),
745
+ });
746
+ }
747
+ }
748
+ return retired;
749
+ }
750
+
751
+ /**
752
+ * Build the live-emission tombstone inputs for a single retired FACT — one
753
+ * input per derived supersession key (or a single keyless record when no
754
+ * structured attributes are present). Pure (no I/O) so the emitters in
755
+ * `StorageManager.supersedeMemory` (contradiction) and `forgetMemory`
756
+ * (retraction) stay thin (#1579, #1520 god-file ratchet). Mirrors the
757
+ * temporal-supersession emitter and `collectRetiredMemoriesForRebuild` so
758
+ * every retire path emits the same keyed-tombstone shape (issue #1579
759
+ * threads Oci-Y / OchiF: without per-key tombstones, a paraphrased
760
+ * re-observation missed the keyed tier and the fact resurrected active).
761
+ */
762
+ export function buildRetiredFactTombstoneInputs(
763
+ memory: {
764
+ id: string;
765
+ content: string;
766
+ contentHash?: string;
767
+ entityRef?: string;
768
+ structuredAttributes?: Record<string, string>;
769
+ },
770
+ opts: {
771
+ reason: TombstoneReason;
772
+ createdBy: TombstoneCreatedBy;
773
+ createdAt: string;
774
+ supersessionKeysForFact: (spec: {
775
+ entityRef?: string;
776
+ structuredAttributes?: Record<string, string>;
777
+ }) => string[];
778
+ },
779
+ ): Array<{
780
+ reason: TombstoneReason;
781
+ createdBy: TombstoneCreatedBy;
782
+ sourceMemoryId: string;
783
+ rawContent: string;
784
+ contentHash?: string;
785
+ entityRef?: string;
786
+ supersessionKey?: string;
787
+ createdAt: string;
788
+ }> {
789
+ const keys =
790
+ memory.entityRef && memory.structuredAttributes
791
+ ? opts.supersessionKeysForFact({
792
+ entityRef: memory.entityRef,
793
+ structuredAttributes: memory.structuredAttributes,
794
+ })
795
+ : [];
796
+ const keysToEmit = keys.length > 0 ? keys : [undefined];
797
+ return keysToEmit.map((key) => ({
798
+ reason: opts.reason,
799
+ createdBy: opts.createdBy,
800
+ sourceMemoryId: memory.id,
801
+ rawContent: memory.content,
802
+ ...(memory.contentHash ? { contentHash: memory.contentHash } : {}),
803
+ ...(memory.entityRef ? { entityRef: memory.entityRef } : {}),
804
+ ...(key ? { supersessionKey: key } : {}),
805
+ createdAt: opts.createdAt,
806
+ }));
807
+ }