@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
@@ -1,21 +1,21 @@
1
1
  import path from "node:path";
2
- import { randomUUID } from "node:crypto";
3
2
  import type { Dirent } from "node:fs";
4
3
  import {
5
4
  appendFile,
6
5
  lstat,
7
6
  mkdir,
8
- open,
9
7
  readdir,
10
8
  readFile,
11
9
  realpath,
12
10
  rename,
13
11
  stat,
14
- unlink,
15
- utimes,
16
12
  writeFile,
17
13
  } from "node:fs/promises";
18
14
  import type { PluginConfig } from "../types.js";
15
+ import {
16
+ MutationSerializer,
17
+ withHeldFileLock,
18
+ } from "../utils/serialize-mutations.js";
19
19
  import { isSafeRouteNamespace } from "../routing/engine.js";
20
20
  import { namespaceIdentityFromToken, namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
21
21
  import { resolveDefaultNamespaceRoot, resolveNamespaceStorageRoot } from "./storage.js";
@@ -430,15 +430,16 @@ export class NamespaceCatalog {
430
430
  private readonly stateDir: string;
431
431
  private readonly catalogPath: string;
432
432
  private readonly rebuildLockPath: string;
433
- // Per-INSTANCE lock owner id (round 6, codex P2 — NBsGP). The rebuild lock
434
- // file records this id, not just `process.pid`, so two NamespaceCatalog
435
- // instances in the SAME process sharing a memoryDir are NOT mistaken for each
436
- // other: a touch on instance B must still wait for instance A's rebuild lock
437
- // (different owner id, same PID) instead of skipping as "self-held".
438
- private readonly lockOwnerId: string = randomUUID();
439
- // Serialized write chain that recovers from rejection (CLAUDE.md rule #40)
440
- // so a single failed append cannot permanently poison subsequent writes.
441
- private writeChain: Promise<void> = Promise.resolve();
433
+ // In-process serialization for catalog mutations (issue #1524 adoption).
434
+ // Replaces the bespoke `writeChain` field: every touch/rebuild runs through
435
+ // this serializer so a single failed section never poisons subsequent ones
436
+ // (CLAUDE.md rule #40 recovery is the util's contract, not re-implemented
437
+ // here). Cross-process identity (the lock file's owner-uuid) is now per-CALL
438
+ // inside the shared util, which is STRONGER than the previous per-instance
439
+ // `lockOwnerId` two calls on the SAME instance get different ids, so
440
+ // neither mistakes the other's lock for self-held (round 6, codex P2 — NBsGP
441
+ // invariant preserved and tightened).
442
+ private readonly criticalSection = new MutationSerializer();
442
443
  // Test-only seam (round 7 — NEZkA): fires inside a touch's HELD-lock critical
443
444
  // section, after the lock is acquired but BEFORE the read→merge→append. A
444
445
  // deterministic concurrency test installs a hook here to widen the (otherwise
@@ -1826,24 +1827,23 @@ export class NamespaceCatalog {
1826
1827
  * their respective critical sections — closing the check-then-append gap where a
1827
1828
  * polled-only touch could append into a rebuild's load→rename window.
1828
1829
  *
1829
- * Acquisition is atomic via `open(..., "wx")`. A lock older than
1830
- * `REBUILD_LOCK_STALE_MS` is treated as a crashed holder and broken. After
1831
- * `REBUILD_LOCK_MAX_WAIT_MS` of contention we proceed best-effort WITHOUT the
1832
- * lock rather than block forever. The lock is always released in `finally`.
1830
+ * Issue #1524 adoption: this is now a thin delegation to the shared
1831
+ * `withHeldFileLock` utility. The acquire loop, mtime heartbeat, stale-break
1832
+ * (NG7Bg replacement-safe), and ownership-checked release (NCzT6) all live in
1833
+ * ONE place the util so this module no longer re-implements them. The
1834
+ * catalog's `REBUILD_LOCK_*` constants and the `onBeforeBreakStaleUnlinkForTest`
1835
+ * seam flow straight through. The util generates a per-CALL owner uuid, which
1836
+ * is stricter than the previous per-instance `lockOwnerId` (two calls on the
1837
+ * same instance get different ids, so neither mistakes the other's lock as
1838
+ * self-held — the NBsGP invariant, preserved and tightened).
1833
1839
  *
1834
1840
  * IN-PROCESS SAFETY: every caller invokes this from inside (or wrapping) the
1835
1841
  * per-process `queueCritical` chain, which serializes all catalog mutations in
1836
- * THIS process. So within one process only one logical holder attempts OS-lock
1837
- * acquisition at a time — the file lock is never self-contended in-process, and
1838
- * the lock is acquired and released within a single in-process turn. The file
1839
- * lock adds only the missing CROSS-process exclusion.
1840
- *
1841
- * HEARTBEAT (round 5, cursor/codex Medium/P2): while WE hold the lock a timer
1842
- * refreshes its mtime every `REBUILD_LOCK_HEARTBEAT_MS`, so a legitimately long
1843
- * holder (> `REBUILD_LOCK_STALE_MS`) is not treated as a crashed holder and
1844
- * unlinked by another process — which would let overlapping windows lose
1845
- * appends. Heartbeat failures are swallowed; the timer is always cleared in
1846
- * `finally`.
1842
+ * THIS process (now via `MutationSerializer`). So within one process only one
1843
+ * logical holder attempts OS-lock acquisition at a time — the file lock is never
1844
+ * self-contended in-process, and the lock is acquired and released within a
1845
+ * single in-process turn. The file lock adds only the missing CROSS-process
1846
+ * exclusion.
1847
1847
  *
1848
1848
  * ACQUISITION RESULT (round 6, codex P2 — NBPmY): `fn` receives whether WE
1849
1849
  * actually hold the lock. When acquisition TIMED OUT (another holder is active),
@@ -1852,152 +1852,20 @@ export class NamespaceCatalog {
1852
1852
  * caller uses `acquired` to run compute-only (rebuild) or DROP the append
1853
1853
  * (touch) when unlocked.
1854
1854
  */
1855
- private async withHeldCatalogLock<T>(fn: (acquired: boolean) => Promise<T>): Promise<T> {
1856
- const acquired = await this.acquireRebuildLock();
1857
- let heartbeat: ReturnType<typeof setInterval> | undefined;
1858
- if (acquired) {
1859
- heartbeat = setInterval(() => {
1860
- const now = new Date();
1861
- // Refresh mtime so age-based stale detection sees an active holder.
1862
- utimes(this.rebuildLockPath, now, now).catch(() => undefined);
1863
- }, REBUILD_LOCK_HEARTBEAT_MS);
1864
- // Don't keep the event loop alive solely for the heartbeat.
1865
- heartbeat.unref?.();
1866
- }
1867
- try {
1868
- return await fn(acquired);
1869
- } finally {
1870
- if (heartbeat) clearInterval(heartbeat);
1871
- if (acquired) {
1872
- try {
1873
- // Release ONLY the lock still owned by THIS instance (round 6, codex
1874
- // P2 — NCzT6). If this rebuild paused long enough that another process
1875
- // treated our lock as stale, unlinked it, and acquired a REPLACEMENT,
1876
- // an unconditional unlink here would delete that other holder's active
1877
- // lock — letting writers/another rebuild proceed during its load/rename
1878
- // window and recreating the lost-append race. Verify ownership first.
1879
- if (await this.rebuildLockHeldBySelf()) {
1880
- await unlink(this.rebuildLockPath);
1881
- }
1882
- } catch {
1883
- // Best-effort release; a stale lock will be broken on next rebuild.
1884
- }
1885
- }
1886
- }
1887
- }
1888
-
1889
- /** Try to acquire the rebuild lock; returns true if WE created it. */
1890
- private async acquireRebuildLock(): Promise<boolean> {
1891
- const deadline = Date.now() + REBUILD_LOCK_MAX_WAIT_MS;
1892
- await mkdir(this.stateDir, { recursive: true });
1893
- for (;;) {
1894
- try {
1895
- const handle = await open(this.rebuildLockPath, "wx");
1896
- try {
1897
- // Record PID, this instance's owner id, and a timestamp. The owner id
1898
- // distinguishes same-process instances (NBsGP).
1899
- await handle.writeFile(
1900
- `${process.pid} ${this.lockOwnerId} ${new Date().toISOString()}\n`,
1901
- "utf8",
1902
- );
1903
- } catch {
1904
- // Ignore write failures — the exclusive create already gave us the lock.
1905
- } finally {
1906
- await handle.close();
1907
- }
1908
- return true;
1909
- } catch (err) {
1910
- if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") {
1911
- // Unexpected FS error — proceed best-effort without the lock.
1912
- return false;
1913
- }
1914
- // Lock exists: break it if stale, otherwise wait briefly.
1915
- await this.breakStaleRebuildLock();
1916
- if (Date.now() >= deadline) return false;
1917
- await new Promise((r) => setTimeout(r, REBUILD_LOCK_POLL_MS));
1918
- }
1919
- }
1920
- }
1921
-
1922
- /**
1923
- * Remove the lock file if its mtime is older than the stale threshold.
1924
- *
1925
- * REPLACEMENT-SAFE (NG7Bg, codex P2): a plain `stat` → `unlink` has a TOCTOU
1926
- * window — two processes can both observe the SAME stale lock; one removes it and
1927
- * creates a FRESH lock, and the other's later `unlink` then deletes that fresh
1928
- * holder's ACTIVE lock based on the stale identity it read earlier, leaving the
1929
- * fresh holder running its critical section with no visible lock and reopening the
1930
- * lost-update race the mutex prevents. We therefore capture the lock's IDENTITY
1931
- * (its full content line: `<pid> <owner-uuid> <iso>`) when we judge it stale, then
1932
- * RE-READ immediately before unlinking and only remove it when the content is
1933
- * byte-identical AND still stale. A replacement lock has a different owner id /
1934
- * timestamp, so its content differs and we leave it untouched. We never unlink a
1935
- * lock whose mtime is now fresh (a heartbeat refreshed it) or whose identity
1936
- * changed (a replacement was created). This is best-effort: any mismatch/vanish
1937
- * simply skips the break and the caller polls again.
1938
- */
1939
- private async breakStaleRebuildLock(): Promise<void> {
1940
- let staleIdentity: string;
1941
- try {
1942
- const info = await stat(this.rebuildLockPath);
1943
- if (Date.now() - info.mtimeMs <= REBUILD_LOCK_STALE_MS) {
1944
- // Not stale (e.g. a live holder's heartbeat keeps it fresh) — leave it.
1945
- return;
1946
- }
1947
- // Capture the exact identity we judged stale, so we can confirm it has not
1948
- // been replaced before we unlink.
1949
- staleIdentity = await readFile(this.rebuildLockPath, "utf8");
1950
- } catch {
1951
- // Lock vanished (released by holder) or stat/read failed — nothing to do.
1952
- return;
1953
- }
1954
- // Test-only seam: simulate a replacement lock being created in the race window
1955
- // between the staleness judgment and the unlink (NG7Bg). No-op in production.
1956
- if (this.onBeforeBreakStaleUnlinkForTest) {
1957
- await this.onBeforeBreakStaleUnlinkForTest();
1958
- }
1959
- try {
1960
- // Re-validate immediately before unlinking: the lock must still carry the
1961
- // SAME identity AND still be stale. If a replacement lock was created in the
1962
- // window (different owner/timestamp) or a heartbeat refreshed the mtime, do
1963
- // NOT unlink — that would delete another process's ACTIVE lock.
1964
- const current = await readFile(this.rebuildLockPath, "utf8");
1965
- if (current !== staleIdentity) return; // replaced — leave the fresh lock
1966
- const recheck = await stat(this.rebuildLockPath);
1967
- if (Date.now() - recheck.mtimeMs <= REBUILD_LOCK_STALE_MS) return; // refreshed
1968
- await unlink(this.rebuildLockPath).catch(() => undefined);
1969
- } catch {
1970
- // The lock changed/vanished between checks — another process handled it.
1971
- }
1972
- }
1973
-
1974
- /**
1975
- * Whether the rebuild lock file was written by THIS instance (round 6, codex
1976
- * P2 — NBsGP). Matches the per-instance owner id, NOT just `process.pid`: two
1977
- * NamespaceCatalog instances in the same process share a PID, so a PID-only
1978
- * check would wrongly treat instance A's lock as self-held by instance B and
1979
- * let B's touch skip the wait and append into A's rebuild window. Falls back to
1980
- * the legacy PID-only form for lock files written before owner ids existed.
1981
- */
1982
- private async rebuildLockHeldBySelf(): Promise<boolean> {
1983
- try {
1984
- const body = await readFile(this.rebuildLockPath, "utf8");
1985
- const parts = body.trim().split(/\s+/);
1986
- const pid = Number.parseInt(parts[0] ?? "", 10);
1987
- const ownerId = parts[1];
1988
- // New format: "<pid> <uuid> <iso>". A UUID at parts[1] uniquely identifies
1989
- // the writing INSTANCE; only the same instance is self. The strict UUID
1990
- // shape avoids mistaking a legacy "<pid> <iso>" timestamp (also hyphenated)
1991
- // for an owner id.
1992
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1993
- if (ownerId && UUID_RE.test(ownerId)) {
1994
- return ownerId === this.lockOwnerId;
1995
- }
1996
- // Legacy format: "<pid> <iso>" (no owner id). Best-effort PID match.
1997
- return Number.isFinite(pid) && pid === process.pid;
1998
- } catch {
1999
- return false;
2000
- }
1855
+ private withHeldCatalogLock<T>(fn: (acquired: boolean) => Promise<T>): Promise<T> {
1856
+ return withHeldFileLock(
1857
+ this.rebuildLockPath,
1858
+ {
1859
+ staleMs: REBUILD_LOCK_STALE_MS,
1860
+ maxWaitMs: REBUILD_LOCK_MAX_WAIT_MS,
1861
+ pollMs: REBUILD_LOCK_POLL_MS,
1862
+ heartbeatMs: REBUILD_LOCK_HEARTBEAT_MS,
1863
+ // NG7Bg seam: fires inside the util's breakStaleLock after it judges the
1864
+ // lock stale and captures its identity, before the atomic rename+verify.
1865
+ onBeforeBreakStaleUnlinkForTest: this.onBeforeBreakStaleUnlinkForTest,
1866
+ },
1867
+ fn,
1868
+ );
2001
1869
  }
2002
1870
 
2003
1871
  /**
@@ -2071,21 +1939,20 @@ export class NamespaceCatalog {
2071
1939
 
2072
1940
  /**
2073
1941
  * Serialize an arbitrary read-modify-write critical section through the single
2074
- * write chain. Every catalog mutation (touch read+merge+append, full rewrite)
2075
- * runs through this so they are mutually exclusive: a touch always reads the
2076
- * latest persisted state before appending, and a rebuild rewrite cannot
2077
- * interleave with a touch's append. The chain recovers from rejection
2078
- * (CLAUDE.md rule #40) — one failed section never poisons subsequent ones —
2079
- * while still surfacing the error to that section's awaited promise.
1942
+ * per-instance chain. Every catalog mutation (touch read+merge+append, full
1943
+ * rewrite) runs through this so they are mutually exclusive: a touch always
1944
+ * reads the latest persisted state before appending, and a rebuild rewrite
1945
+ * cannot interleave with a touch's append.
1946
+ *
1947
+ * Issue #1524 adoption: delegates to the shared `MutationSerializer` (stored
1948
+ * as `criticalSection`). The util owns the rejection-recovery invariant
1949
+ * (CLAUDE.md rule #40 — one failed section never poisons subsequent ones, but
1950
+ * the failing section's error still surfaces to ITS awaited promise) and the
1951
+ * no-unbounded-growth cleanup. The key is constant: the catalog has ONE
1952
+ * logical mutation queue (touches and rebuilds mutually exclude in-process).
2080
1953
  */
2081
1954
  private queueCritical<T>(fn: () => Promise<T>): Promise<T> {
2082
- const run = this.writeChain.then(fn);
2083
- // Keep the chain alive after a rejection so later sections still run.
2084
- this.writeChain = run.then(
2085
- () => undefined,
2086
- () => undefined,
2087
- );
2088
- return run;
1955
+ return this.criticalSection.serialize("catalog", fn);
2089
1956
  }
2090
1957
 
2091
1958
  /**
@@ -6,6 +6,7 @@ import type { PluginConfig } from "../types.js";
6
6
  import { ALL_CATEGORY_DIRS } from "../utils/category-dir.js";
7
7
  import { namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
8
8
  import type { NamespaceCatalog } from "./catalog.js";
9
+ import { MutationSerializer } from "../utils/serialize-mutations.js";
9
10
 
10
11
  async function exists(p: string): Promise<boolean> {
11
12
  try {
@@ -209,21 +210,22 @@ export class NamespaceStorageRouter {
209
210
  // rebuilds. We fire the hook only when the (namespace, storageDir) pair is new
210
211
  // or its dir changed, so a steady-state cache hit is a no-op for the catalog.
211
212
  private readonly notifiedResolved = new Map<string, string>();
212
- // In-flight resolve-hook dedup (NFJV-, codex P2). The catalog's `onResolve`
213
- // hook is ASYNC (it returns `registerResolved(...)`), so `notifiedResolved` is
214
- // only set after the hook's promise SETTLES. Without tracking the in-flight
215
- // window, a burst of `storageFor()` cache hits for the SAME namespace before
216
- // the first registration finishes would each pass the `notifiedResolved` guard
217
- // and fire their OWN `onResolve` queueing N duplicate catalog touches + lock
218
- // acquisitions despite the once-per-namespace intent. We therefore record the
219
- // (namespace storageDir) being registered BEFORE awaiting the hook so a
220
- // concurrent call for the same pair skips firing. On SUCCESS the pair is
221
- // promoted to `notifiedResolved` (future calls skip permanently); on `false`
222
- // (dropped touch e.g. rebuild-lock timeout) OR rejection the in-flight marker
223
- // is CLEARED so a later `storageFor()` can RETRY the dropped registration. The
224
- // entry is always removed when the promise settles, so the map cannot grow
225
- // unbounded (one transient entry per concurrently-resolving namespace).
226
- private readonly inFlightResolved = new Map<string, string>();
213
+ // Instance-scoped serializer for the resolve hook (issue #1524 adoption).
214
+ // Replaces the bespoke `inFlightResolved` marker-then-clear pattern: the
215
+ // serializer's per-key chain strictly orders concurrent notifications for the
216
+ // SAME namespace, and the task re-checks `notifiedResolved` once it runs, so a
217
+ // burst of cache hits before the first hook settles collapses to a single
218
+ // hook invocation (the once-per-namespace intent). Recovery is preserved
219
+ // one rejected hook never poisons subsequent notifications for that key.
220
+ private readonly resolveSerializer = new MutationSerializer();
221
+ // (namespace, storageDir) pairs whose resolve hook is currently pending. Set SYNCHRONOUSLY in
222
+ // notifyResolved (before queueing) so a burst of concurrent storageFor()
223
+ // cache hits collapses onto the one in-flight hook instead of each enqueuing
224
+ // its own task. Cleared when the queued hook settles. Without this, a dropped
225
+ // hook (returns false on a rebuild-lock timeout) leaves notifiedResolved
226
+ // unset, so every queued sibling task would re-run the hook — N serial lock
227
+ // waits (cursor Medium 06f58a7c, codex P2).
228
+ private readonly inFlightResolveHooks = new Set<string>();
227
229
  // Tracks every in-flight resolve-hook promise so callers can deterministically
228
230
  // await the fire-and-forget registrations that `storageFor()` kicks off (see
229
231
  // `whenResolveHooksSettled`). Entries are removed as each hook settles, so the
@@ -297,6 +299,9 @@ export class NamespaceStorageRouter {
297
299
  // #1522: install the post-write catalog touch at the chokepoint — every
298
300
  // successful write on this StorageManager records the namespace touch.
299
301
  this.bindCatalogWriteHook(sm, ns);
302
+ // #1579: apply the tombstone non-resurrection config so every namespace
303
+ // storage enforces the invariant at its own writeMemory chokepoint.
304
+ this.applyTombstonesConfig(sm, ns);
300
305
  this.cache.set(ns, sm);
301
306
  this.notifyResolved(ns, root);
302
307
  return sm;
@@ -305,77 +310,82 @@ export class NamespaceStorageRouter {
305
310
  /**
306
311
  * Fire the resolve hook defensively. A hook failure (e.g. a catalog write
307
312
  * error) MUST NOT crash storage resolution — see CLAUDE.md gotcha #13.
313
+ *
314
+ * Issue #1524 adoption: hook invocations for the SAME namespace are now
315
+ * strictly ordered through the shared `MutationSerializer` rather than the
316
+ * bespoke `inFlightResolved` marker-then-clear pattern. The serializer
317
+ * guarantees one in-flight hook per namespace; a concurrent burst of
318
+ * `storageFor()` cache hits collapses to a single hook invocation because
319
+ * each queued task re-checks `notifiedResolved` before invoking the hook. A
320
+ * dropped (`false`) or rejected hook leaves `notifiedResolved` unset so the
321
+ * next `storageFor()` retries — the serializer recovers from the rejection,
322
+ * so a failed hook never poisons later notifications.
308
323
  */
309
324
  private notifyResolved(namespace: string, storageDir: string): void {
310
325
  const hook = this.hooks.onResolve;
311
326
  if (!hook) return;
312
- // Skip when we've already SUCCESSFULLY notified this exact (namespace,
313
- // storageDir) a steady-state cache hit must not re-append to the catalog
314
- // log (NCNL2). A changed dir (rare: migration/realignment) still re-fires
315
- // once. We mark the pair as notified ONLY AFTER the hook succeeds, and CLEAR
316
- // it on failure, so a dropped registration (e.g. rebuild-lock timeout) is
317
- // RETRIED on the next cache hit instead of being suppressed forever (round 6,
318
- // cursor Medium — ND3EJ).
327
+ // Permanent dedup: skip once we've SUCCESSFULLY notified this exact
328
+ // (namespace, storageDir). A changed dir (rare: migration/realignment)
329
+ // still re-fires once. The mark is set ONLY AFTER the hook succeeds, so a
330
+ // dropped registration (e.g. rebuild-lock timeout) is RETRIED on the next
331
+ // cache hit instead of being suppressed forever (round 6, cursor Medium —
332
+ // ND3EJ).
319
333
  if (this.notifiedResolved.get(namespace) === storageDir) return;
320
- // In-flight dedup (NFJV-, codex P2): if a registration for this exact
321
- // (namespace, storageDir) is already AWAITING its async hook, do not fire a
322
- // second one. Without this, concurrent cache-hit bursts before the first
323
- // append settles each pass the `notifiedResolved` guard above and queue
324
- // duplicate catalog touches/lock acquisitions. A pair with a DIFFERENT
325
- // in-flight dir (rare mid-migration realignment) still fires once.
326
- if (this.inFlightResolved.get(namespace) === storageDir) return;
327
- try {
328
- // Handle BOTH synchronous throws and asynchronous rejections (round 6,
329
- // codex P2 NDo8C). The hook may be `async`; its rejected promise would
330
- // bypass this try/catch and, where unhandled rejections are fatal, crash
331
- // storage resolution. Mark the dedup pair as notified ONLY when the hook
332
- // resolves to a PERSISTED result (round 6, codex P2 — NEFoX): a result of
333
- // `false` means the registration was dropped/no-op (e.g. rebuild-lock
334
- // timeout), so we must NOT suppress its retry. `void`/`undefined` is treated
335
- // as success for legacy hooks. On rejection we leave it un-notified to retry.
336
- //
337
- // Record the in-flight marker BEFORE awaiting so concurrent calls for the
338
- // same pair skip (NFJV-). It is always cleared once the promise settles, so
339
- // the map holds at most one transient entry per concurrently-resolving
340
- // namespace and cannot grow unbounded.
341
- this.inFlightResolved.set(namespace, storageDir);
342
- const hookResult = Promise.resolve(hook(namespace, storageDir));
343
- // Track the in-flight promise so `whenResolveHooksSettled()` can await it.
344
- this.pendingResolveHooks.add(hookResult);
345
- hookResult.then(
346
- (persisted) => {
347
- // Clear the in-flight marker ONLY if it is still ours (a newer resolve
348
- // for a different dir may have replaced it).
349
- if (this.inFlightResolved.get(namespace) === storageDir) {
350
- this.inFlightResolved.delete(namespace);
351
- }
352
- if (persisted !== false) {
353
- this.notifiedResolved.set(namespace, storageDir);
354
- }
355
- // On `false` (dropped touch) we intentionally do NOT mark notified, so
356
- // a later `storageFor()` retries the registration. Clearing the
357
- // in-flight marker above is what re-enables that retry.
358
- this.pendingResolveHooks.delete(hookResult);
359
- },
360
- () => {
361
- // Registration failed clear in-flight AND do NOT mark as notified, so
362
- // it is retried on the next cache hit.
363
- if (this.inFlightResolved.get(namespace) === storageDir) {
364
- this.inFlightResolved.delete(namespace);
365
- }
366
- if (this.notifiedResolved.get(namespace) === storageDir) {
367
- this.notifiedResolved.delete(namespace);
368
- }
369
- this.pendingResolveHooks.delete(hookResult);
370
- },
371
- );
372
- } catch {
373
- // Synchronous throw: clear any in-flight marker we just set and leave the
374
- // pair un-notified so a later resolve retries.
375
- if (this.inFlightResolved.get(namespace) === storageDir) {
376
- this.inFlightResolved.delete(namespace);
334
+ // In-flight dedup (cursor Medium 06f58a7c, codex P2): if a hook for THIS
335
+ // namespace is already pending, collapse this call onto it instead of
336
+ // enqueueing another task. The serializer strictly orders queued tasks, but
337
+ // ordering alone is not enough — when the first hook returns `false`
338
+ // (dropped touch, e.g. rebuild-lock timeout), `notifiedResolved` stays
339
+ // unset, so each queued sibling task would pass its re-check and re-run the
340
+ // hook. With the real catalog hook each retry can spend the full lock wait,
341
+ // so N cache hits during a rebuild leave N serial background lock attempts.
342
+ // Collapsing here means at most ONE hook runs per in-flight registration;
343
+ // its result (set `notifiedResolved` on success, leave unset on drop)
344
+ // decides whether a LATER `storageFor()` retries collapsing loses
345
+ // nothing because the drop is already retried on the next cache hit.
346
+ // Keyed by the composite (namespace, storageDir) so a CHANGED dir
347
+ // (migration/realignment) for the same namespace still gets its own hook —
348
+ // it is NOT collapsed onto the old dir's pending registration (cursor
349
+ // Medium, codex P2).
350
+ const inFlightKey = namespace + "\u0000" + storageDir;
351
+ if (this.inFlightResolveHooks.has(inFlightKey)) return;
352
+ this.inFlightResolveHooks.add(inFlightKey);
353
+ // Queue through the serializer. Concurrent calls for the same namespace
354
+ // strictly order here; the 2nd call's task runs only after the 1st's hook
355
+ // settles, by which point `notifiedResolved` is either set (no-op) or still
356
+ // unset (retry). The returned promise is tracked for
357
+ // `whenResolveHooksSettled()`. Rejections from the task body are caught so
358
+ // they never reach the caller (best-effort hook contract); the serializer's
359
+ // recovered tail still lets subsequent notifications run.
360
+ const task = async (): Promise<void> => {
361
+ // Re-check after queueing: a prior task in this same chain may have just
362
+ // marked the pair as notified.
363
+ if (this.notifiedResolved.get(namespace) === storageDir) return;
364
+ try {
365
+ // Hook may be sync or async; Promise.resolve normalizes both. A result
366
+ // of `false` means the registration was dropped/no-op (e.g. rebuild-lock
367
+ // timeout) — leave notifiedResolved UNSET so the next storageFor retries
368
+ // (round 6, codex P2 — NEFoX). `void`/`undefined` is success for legacy
369
+ // hooks. A rejection leaves notifiedResolved unset for the same reason.
370
+ const persisted = await Promise.resolve(hook(namespace, storageDir));
371
+ if (persisted !== false) {
372
+ this.notifiedResolved.set(namespace, storageDir);
373
+ }
374
+ } catch {
375
+ // Best-effort: a hook failure MUST NOT crash storage resolution. Leave
376
+ // notifiedResolved unset so a later storageFor retries the registration.
377
377
  }
378
- }
378
+ };
379
+ const queued = this.resolveSerializer.serialize(namespace, task);
380
+ this.pendingResolveHooks.add(queued);
381
+ // Clear the in-flight marker when the hook settles so a subsequent
382
+ // storageFor() can retry after a drop, or short-circuit via notifiedResolved
383
+ // after a success.
384
+ const cleanup = (): void => {
385
+ this.inFlightResolveHooks.delete(inFlightKey);
386
+ this.pendingResolveHooks.delete(queued);
387
+ };
388
+ void queued.then(cleanup, cleanup);
379
389
  }
380
390
 
381
391
  /**
@@ -387,6 +397,36 @@ export class NamespaceStorageRouter {
387
397
  sm.onCatalogWrite = () => this.touchCatalogWrite(namespace, sm.dir);
388
398
  }
389
399
 
400
+ /**
401
+ * Install the tombstone config on an externally-constructed StorageManager
402
+ * (issue #1579). Mirrors `bindCatalogWriteHook` — used for the legacy
403
+ * default-namespace storage that bypasses the router. Router-created
404
+ * storages are wired inline in `storageFor()` via `applyTombstonesConfig`.
405
+ */
406
+ bindTombstonesConfig(
407
+ sm: StorageManager,
408
+ namespace: string,
409
+ config: { enabled: boolean; semanticMatch: boolean; semanticThreshold: number },
410
+ ): void {
411
+ this.tombstonesGlobalConfig = { ...config };
412
+ sm.setTombstonesConfig({ ...config, namespace });
413
+ }
414
+
415
+ private tombstonesGlobalConfig: {
416
+ enabled: boolean;
417
+ semanticMatch: boolean;
418
+ semanticThreshold: number;
419
+ } = { enabled: false, semanticMatch: false, semanticThreshold: 0.9 };
420
+
421
+ /**
422
+ * Apply the tombstone config to a router-created StorageManager. Called
423
+ * inline in `storageFor()` so every namespace storage enforces the
424
+ * non-resurrection invariant, namespace-scoped (rule 42).
425
+ */
426
+ private applyTombstonesConfig(sm: StorageManager, namespace: string): void {
427
+ sm.setTombstonesConfig({ ...this.tombstonesGlobalConfig, namespace });
428
+ }
429
+
390
430
  /**
391
431
  * Post-write catalog touch (issue #1522 chokepoint). Called by every
392
432
  * StorageManager's post-write hook AFTER a successful write. Best-effort
@@ -1326,6 +1326,12 @@ export async function runOperatorDoctor(options: OperatorDoctorOptions): Promise
1326
1326
  details: namespaceMaintenanceHealth,
1327
1327
  });
1328
1328
 
1329
+ // Tombstone non-resurrection invariant (issue #1579). Reports active
1330
+ // tombstone count, last append, corrupted-line count, and rebuild
1331
+ // staleness so operators can verify the invariant is enforced and know
1332
+ // when a rebuild is warranted. Informational: never errors on its own.
1333
+ checks.push(await summarizeTombstoneStatus(options.orchestrator.storage));
1334
+
1329
1335
  const summary = checks.reduce(
1330
1336
  (acc, check) => {
1331
1337
  acc[check.status] += 1;
@@ -1871,6 +1877,77 @@ export async function summarizeObservationThroughput(
1871
1877
  }
1872
1878
  }
1873
1879
 
1880
+ /**
1881
+ * Tombstone non-resurrection invariant status for `remnic doctor`
1882
+ * (issue #1579).
1883
+ *
1884
+ * Reports:
1885
+ * - active tombstone count (excluding revoked)
1886
+ * - revoked count (re-allowed via the review queue)
1887
+ * - last append timestamp
1888
+ * - corrupted-line count (skipped, not crashed — rule 34/18)
1889
+ * - rebuild staleness (whether the in-memory index loaded cleanly)
1890
+ *
1891
+ * Always informational: a disabled or empty tombstone store is the expected
1892
+ * cold-install state and is never an error. A non-zero corrupted-line count
1893
+ * surfaces as `warn` so operators know a rebuild (`remnic doctor
1894
+ * --rebuild-tombstones`) will repair the log.
1895
+ */
1896
+ export async function summarizeTombstoneStatus(
1897
+ storage: StorageManager,
1898
+ ): Promise<OperatorDoctorCheck> {
1899
+ try {
1900
+ const stats = await storage.getTombstoneStats();
1901
+ if (stats === null) {
1902
+ return {
1903
+ key: "tombstones",
1904
+ status: "ok",
1905
+ summary: "Tombstone non-resurrection invariant is disabled (tombstonesEnabled=false).",
1906
+ details: { enabled: false },
1907
+ };
1908
+ }
1909
+ const corrupted = stats.corruptedLines > 0;
1910
+ const parts = [
1911
+ `${stats.count} active tombstone(s)`,
1912
+ ...(stats.revoked > 0 ? [`${stats.revoked} revoked`] : []),
1913
+ ];
1914
+ if (stats.lastAppendAt) {
1915
+ parts.push(`last append ${stats.lastAppendAt}`);
1916
+ }
1917
+ if (corrupted) {
1918
+ parts.push(`${stats.corruptedLines} corrupted line(s) skipped`);
1919
+ }
1920
+ return {
1921
+ key: "tombstones",
1922
+ status: corrupted ? "warn" : "ok",
1923
+ summary:
1924
+ `Tombstone invariant enabled. ${parts.join(", ")}. ` +
1925
+ (corrupted
1926
+ ? "Run `remnic doctor --rebuild-tombstones` to repair the log."
1927
+ : "Re-observation of retired facts is blocked at the storage chokepoint."),
1928
+ remediation: corrupted
1929
+ ? "Rebuild the tombstone log from retired memories on disk."
1930
+ : undefined,
1931
+ details: {
1932
+ enabled: true,
1933
+ count: stats.count,
1934
+ revoked: stats.revoked,
1935
+ lastAppendAt: stats.lastAppendAt,
1936
+ corruptedLines: stats.corruptedLines,
1937
+ loaded: stats.loaded,
1938
+ },
1939
+ };
1940
+ } catch (err) {
1941
+ return {
1942
+ key: "tombstones",
1943
+ status: "warn",
1944
+ summary: "Could not read tombstone store stats.",
1945
+ remediation: "Ensure the memory state directory is readable and rerun `remnic doctor`.",
1946
+ details: { error: String(err) },
1947
+ };
1948
+ }
1949
+ }
1950
+
1874
1951
  export async function summarizeConsolidationProvenance(
1875
1952
  storage: StorageManager,
1876
1953
  config: Pick<PluginConfig, "memoryDir"> & { versioningSidecarDir?: string },