@remnic/core 9.3.700 → 9.3.702

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 (323) hide show
  1. package/dist/access-boundary.d.ts +7 -7
  2. package/dist/access-boundary.js +17 -14
  3. package/dist/access-cli.js +40 -37
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +7 -7
  6. package/dist/access-http.js +20 -17
  7. package/dist/access-mcp.d.ts +7 -7
  8. package/dist/access-mcp.js +19 -16
  9. package/dist/access-operations.d.ts +7 -7
  10. package/dist/access-operations.js +18 -15
  11. package/dist/{access-service-Cte3ol0W.d.ts → access-service-COCzhgEL.d.ts} +397 -19
  12. package/dist/access-service.d.ts +6 -6
  13. package/dist/access-service.js +16 -13
  14. package/dist/access-surface-catalog.d.ts +7 -7
  15. package/dist/action-confidence.d.ts +1 -1
  16. package/dist/active-memory-bridge.d.ts +1 -1
  17. package/dist/active-memory-bridge.js +2 -2
  18. package/dist/active-recall.d.ts +1 -1
  19. package/dist/active-recall.js +7 -6
  20. package/dist/active-recall.js.map +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 +5 -5
  24. package/dist/briefing.d.ts +1 -1
  25. package/dist/briefing.js +8 -7
  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-DN1PzThs.d.ts → catalog-DxCzhjE6.d.ts} +28 -54
  31. package/dist/causal-behavior.d.ts +1 -1
  32. package/dist/causal-consolidation.d.ts +1 -1
  33. package/dist/causal-consolidation.js +9 -8
  34. package/dist/causal-consolidation.js.map +1 -1
  35. package/dist/{chunk-XJNBEDFE.js → chunk-3FAMU5TX.js} +31 -74
  36. package/dist/chunk-3FAMU5TX.js.map +1 -0
  37. package/dist/{chunk-PCZR32VL.js → chunk-3JJWNZTT.js} +2 -2
  38. package/dist/{chunk-YXLT4EMM.js → chunk-4463LUIE.js} +12 -2
  39. package/dist/chunk-4463LUIE.js.map +1 -0
  40. package/dist/{chunk-R5DB26G6.js → chunk-4SBW47WK.js} +9 -23
  41. package/dist/chunk-4SBW47WK.js.map +1 -0
  42. package/dist/{chunk-CCOXIDRM.js → chunk-5VXVXJH6.js} +149 -6
  43. package/dist/{chunk-CCOXIDRM.js.map → chunk-5VXVXJH6.js.map} +1 -1
  44. package/dist/{chunk-PQG4T5V3.js → chunk-64XXOQRW.js} +49 -45
  45. package/dist/chunk-64XXOQRW.js.map +1 -0
  46. package/dist/{chunk-YXIFA36P.js → chunk-6TAETM63.js} +2 -2
  47. package/dist/{chunk-6JDGADXK.js → chunk-6VVP6NK7.js} +2 -2
  48. package/dist/{chunk-FN2SM5SN.js → chunk-ABCGDW3A.js} +75 -12
  49. package/dist/chunk-ABCGDW3A.js.map +1 -0
  50. package/dist/{chunk-CHM274U6.js → chunk-AMNLZ6SF.js} +2 -2
  51. package/dist/{chunk-JX3YZVII.js → chunk-CZJ6QSKG.js} +3 -3
  52. package/dist/{chunk-GYVVQYA3.js → chunk-EOOVJK2U.js} +3 -3
  53. package/dist/{chunk-JKW5XSWC.js → chunk-EYJD6KIO.js} +2 -2
  54. package/dist/{chunk-XY4WJTEX.js → chunk-EZR35XHX.js} +2 -2
  55. package/dist/{chunk-UU6MVCJ6.js → chunk-FIOYURII.js} +32 -25
  56. package/dist/chunk-FIOYURII.js.map +1 -0
  57. package/dist/{chunk-2NWHLAXX.js → chunk-FVI5B7DE.js} +2 -2
  58. package/dist/{chunk-X74FJSW7.js → chunk-GFIARMA7.js} +16 -8
  59. package/dist/chunk-GFIARMA7.js.map +1 -0
  60. package/dist/{chunk-PONNZ54D.js → chunk-GY3SKOS4.js} +4 -4
  61. package/dist/{chunk-U33LWTQQ.js → chunk-HV57RHMD.js} +4 -4
  62. package/dist/{chunk-RC3CNIPK.js → chunk-IO5NQEGZ.js} +2 -2
  63. package/dist/{chunk-IKNQAGBV.js → chunk-IX3UQT4H.js} +1 -1
  64. package/dist/{chunk-IKNQAGBV.js.map → chunk-IX3UQT4H.js.map} +1 -1
  65. package/dist/{chunk-HDLC75NX.js → chunk-IX72AAMZ.js} +2 -2
  66. package/dist/{chunk-DR2JTSLZ.js → chunk-JMA4RYRN.js} +62 -163
  67. package/dist/chunk-JMA4RYRN.js.map +1 -0
  68. package/dist/{chunk-G5PKTQ5J.js → chunk-JTKFZMZ7.js} +2 -2
  69. package/dist/{chunk-YMTGXDN6.js → chunk-KC6TCAWV.js} +5 -5
  70. package/dist/{chunk-ROZJACKP.js → chunk-KKK7YTYN.js} +4 -1
  71. package/dist/chunk-KKK7YTYN.js.map +1 -0
  72. package/dist/{chunk-EC2AYKRX.js → chunk-L6W77GWW.js} +10 -24
  73. package/dist/chunk-L6W77GWW.js.map +1 -0
  74. package/dist/{chunk-ED35D32I.js → chunk-LQ4J7ELC.js} +2 -2
  75. package/dist/{chunk-YPR7DOPD.js → chunk-LTJAMRGI.js} +4 -4
  76. package/dist/{chunk-YPR7DOPD.js.map → chunk-LTJAMRGI.js.map} +1 -1
  77. package/dist/chunk-LUPVCGYK.js +63 -0
  78. package/dist/chunk-LUPVCGYK.js.map +1 -0
  79. package/dist/{chunk-RJ2THZ4H.js → chunk-MBUM2Y3L.js} +2 -2
  80. package/dist/{chunk-T5QAZIBO.js → chunk-MOXFPLD6.js} +3 -3
  81. package/dist/{chunk-O54DY26V.js → chunk-MXEWQKM7.js} +2 -2
  82. package/dist/{chunk-33L6XHU2.js → chunk-NUIJEGVD.js} +6 -6
  83. package/dist/{chunk-3E5WRQNQ.js → chunk-OXEAMU42.js} +529 -9
  84. package/dist/chunk-OXEAMU42.js.map +1 -0
  85. package/dist/{chunk-EOBJRBLC.js → chunk-QGJAGC2J.js} +2 -2
  86. package/dist/{chunk-SMIVW7XC.js → chunk-QIMFOCSH.js} +2 -2
  87. package/dist/{chunk-IJEZMWKA.js → chunk-SFOAQQDJ.js} +3 -3
  88. package/dist/{chunk-HRUULBBV.js → chunk-SHRRWOVY.js} +81 -4
  89. package/dist/chunk-SHRRWOVY.js.map +1 -0
  90. package/dist/{chunk-NINRTFSV.js → chunk-SINGJCUR.js} +5 -5
  91. package/dist/{chunk-ZPQVJEVQ.js → chunk-SK2CR6MW.js} +146 -2
  92. package/dist/chunk-SK2CR6MW.js.map +1 -0
  93. package/dist/{chunk-SEWF2O74.js → chunk-TGAHHCB6.js} +2 -2
  94. package/dist/{chunk-D75JXBV4.js → chunk-WN4GHSDH.js} +2 -2
  95. package/dist/{chunk-K4DWSPMW.js → chunk-WRGPE6AW.js} +2 -2
  96. package/dist/{chunk-GA5A6MJH.js → chunk-XGMCUY5P.js} +62 -116
  97. package/dist/chunk-XGMCUY5P.js.map +1 -0
  98. package/dist/{chunk-SDPDU2PM.js → chunk-YTMDF6S7.js} +2 -2
  99. package/dist/{chunk-ZDK2IW5F.js → chunk-Z7XEIAV4.js} +2 -2
  100. package/dist/{chunk-NHBEO3F3.js → chunk-ZCEI242W.js} +24 -24
  101. package/dist/{cli--yVN9yEV.d.ts → cli-BM4xQPp4.d.ts} +3 -3
  102. package/dist/cli.d.ts +7 -7
  103. package/dist/cli.js +35 -34
  104. package/dist/compounding/engine.d.ts +1 -1
  105. package/dist/compounding/engine.js +8 -7
  106. package/dist/compounding/preference-consolidator.d.ts +1 -1
  107. package/dist/compression-optimizer.d.ts +1 -1
  108. package/dist/config.d.ts +1 -1
  109. package/dist/config.js +4 -2
  110. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  111. package/dist/connectors/codex-materialize-runner.js +8 -7
  112. package/dist/connectors/codex-materialize.d.ts +1 -1
  113. package/dist/connectors/index.d.ts +1 -1
  114. package/dist/connectors/index.js +9 -8
  115. package/dist/consolidation-provenance-check.d.ts +1 -1
  116. package/dist/consolidation-undo.d.ts +1 -1
  117. package/dist/contradiction/index.d.ts +2 -2
  118. package/dist/conversation-index/backend.d.ts +1 -1
  119. package/dist/conversation-index/chunker.d.ts +1 -1
  120. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  121. package/dist/conversation-index/indexer.d.ts +1 -1
  122. package/dist/conversation-index/search.d.ts +1 -1
  123. package/dist/day-summary.d.ts +1 -1
  124. package/dist/delinearize.d.ts +1 -1
  125. package/dist/direct-answer-wiring.d.ts +1 -1
  126. package/dist/direct-answer.d.ts +1 -1
  127. package/dist/embedding-fallback.d.ts +1 -1
  128. package/dist/enrichment/index.d.ts +1 -1
  129. package/dist/entity-retrieval.d.ts +1 -1
  130. package/dist/entity-retrieval.js +8 -7
  131. package/dist/entity-schema.d.ts +1 -1
  132. package/dist/event-order-recall.js +2 -1
  133. package/dist/explicit-capture.d.ts +5 -5
  134. package/dist/explicit-capture.js +2 -2
  135. package/dist/explicit-cue-recall.js +2 -1
  136. package/dist/extraction-faithfulness.d.ts +1 -1
  137. package/dist/extraction-judge-telemetry.d.ts +1 -1
  138. package/dist/extraction-judge-training.d.ts +1 -1
  139. package/dist/extraction-judge.d.ts +1 -1
  140. package/dist/extraction.d.ts +14 -1
  141. package/dist/extraction.js +5 -3
  142. package/dist/fallback-llm.d.ts +1 -1
  143. package/dist/{forget-BEXG5PQC.js → forget-6SOIPUMQ.js} +3 -3
  144. package/dist/identity-continuity.d.ts +1 -1
  145. package/dist/importance.d.ts +1 -1
  146. package/dist/index.d.ts +123 -123
  147. package/dist/index.js +57 -56
  148. package/dist/index.js.map +1 -1
  149. package/dist/intent.d.ts +1 -1
  150. package/dist/lcm/engine.d.ts +1 -1
  151. package/dist/lcm/index.d.ts +1 -1
  152. package/dist/lcm/tools.d.ts +1 -1
  153. package/dist/lifecycle.d.ts +1 -1
  154. package/dist/live-connectors-runner.d.ts +1 -1
  155. package/dist/local-llm.d.ts +1 -1
  156. package/dist/maintenance/memory-governance.d.ts +1 -1
  157. package/dist/maintenance/memory-governance.js +8 -7
  158. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -7
  159. package/dist/maintenance/rebuild-memory-projection.js +9 -8
  160. package/dist/mcp-memory-inspector-app.d.ts +7 -7
  161. package/dist/memory-action-policy.d.ts +1 -1
  162. package/dist/memory-cache.d.ts +1 -1
  163. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  164. package/dist/memory-projection-store.d.ts +1 -1
  165. package/dist/memory-provenance.d.ts +1 -1
  166. package/dist/memory-worth-outcomes.d.ts +1 -1
  167. package/dist/models-json.d.ts +1 -1
  168. package/dist/namespaces/migrate.d.ts +2 -2
  169. package/dist/namespaces/migrate.js +9 -8
  170. package/dist/namespaces/principal.d.ts +1 -1
  171. package/dist/namespaces/search.d.ts +1 -1
  172. package/dist/namespaces/storage.d.ts +14 -3
  173. package/dist/namespaces/storage.js +8 -7
  174. package/dist/native-knowledge.d.ts +1 -1
  175. package/dist/operator-toolkit.d.ts +1 -1
  176. package/dist/operator-toolkit.js +14 -13
  177. package/dist/orchestration/maintenance.d.ts +2 -2
  178. package/dist/orchestration/maintenance.js +10 -9
  179. package/dist/{orchestrator-CJI4xdqV.d.ts → orchestrator-C9CDWAm6.d.ts} +4 -4
  180. package/dist/orchestrator.d.ts +5 -5
  181. package/dist/orchestrator.js +33 -31
  182. package/dist/patterns-cli.d.ts +1 -1
  183. package/dist/policy-runtime.d.ts +1 -1
  184. package/dist/provenance.d.ts +36 -2
  185. package/dist/provenance.js +5 -1
  186. package/dist/qmd-recall-cache.d.ts +1 -1
  187. package/dist/qmd.d.ts +1 -1
  188. package/dist/recall-disclosure-escalation.d.ts +1 -1
  189. package/dist/recall-explain-renderer.d.ts +1 -1
  190. package/dist/recall-explain-renderer.js +3 -3
  191. package/dist/recall-pipeline-stages.d.ts +15 -0
  192. package/dist/recall-pipeline-stages.js +3 -56
  193. package/dist/recall-pipeline-stages.js.map +1 -1
  194. package/dist/recall-planner-llm.d.ts +1 -1
  195. package/dist/recall-state.d.ts +1 -1
  196. package/dist/recall-tag-filter.d.ts +1 -1
  197. package/dist/recall-xray-cli.d.ts +1 -1
  198. package/dist/recall-xray-cli.js +4 -4
  199. package/dist/recall-xray-renderer.d.ts +1 -1
  200. package/dist/recall-xray-renderer.js +3 -3
  201. package/dist/recall-xray.d.ts +1 -1
  202. package/dist/recall-xray.js +2 -2
  203. package/dist/resolve-auth-token.d.ts +1 -1
  204. package/dist/response-guidance-recall.js +2 -1
  205. package/dist/resume-bundles.js +7 -5
  206. package/dist/retrieval-agents.d.ts +1 -1
  207. package/dist/retrieval-tiers.d.ts +1 -1
  208. package/dist/routing/engine.d.ts +1 -1
  209. package/dist/routing/store.d.ts +1 -1
  210. package/dist/schemas.d.ts +19 -0
  211. package/dist/schemas.js +1 -1
  212. package/dist/search/embed-helper.d.ts +1 -1
  213. package/dist/search/factory.d.ts +1 -1
  214. package/dist/search/index.d.ts +1 -1
  215. package/dist/search/lancedb-backend.d.ts +1 -1
  216. package/dist/search/meilisearch-backend.d.ts +1 -1
  217. package/dist/search/noop-backend.d.ts +1 -1
  218. package/dist/search/orama-backend.d.ts +1 -1
  219. package/dist/search/port.d.ts +1 -1
  220. package/dist/search/remote-backend.d.ts +1 -1
  221. package/dist/{semantic-DJR8_DMQ.d.ts → semantic-SLAa_prH.d.ts} +1 -1
  222. package/dist/{semantic-consolidation-BtUfv-AL.d.ts → semantic-consolidation-DgFXyALl.d.ts} +1 -1
  223. package/dist/semantic-consolidation.d.ts +2 -2
  224. package/dist/semantic-consolidation.js +9 -8
  225. package/dist/semantic-rule-promotion.js +8 -7
  226. package/dist/semantic-rule-verifier.d.ts +1 -1
  227. package/dist/semantic-rule-verifier.js +8 -7
  228. package/dist/session-observer-bands.d.ts +1 -1
  229. package/dist/session-observer-state.d.ts +1 -1
  230. package/dist/shared-context/manager.d.ts +1 -1
  231. package/dist/signal.d.ts +1 -1
  232. package/dist/storage.d.ts +13 -1
  233. package/dist/storage.js +7 -6
  234. package/dist/summarizer.d.ts +1 -1
  235. package/dist/summarizer.js +3 -2
  236. package/dist/summary-snapshot.d.ts +1 -1
  237. package/dist/summary-snapshot.js +2 -1
  238. package/dist/targeted-fact-recall.js +2 -1
  239. package/dist/temporal-supersession.d.ts +1 -1
  240. package/dist/temporal-validity.d.ts +1 -1
  241. package/dist/threading.d.ts +1 -1
  242. package/dist/tier-migration.d.ts +1 -1
  243. package/dist/tier-routing.d.ts +1 -1
  244. package/dist/topics.d.ts +1 -1
  245. package/dist/transcript.d.ts +1 -1
  246. package/dist/transcript.js +2 -2
  247. package/dist/{types-PuiPZ9iE.d.ts → types-MWKPZnM0.d.ts} +25 -1
  248. package/dist/types.d.ts +1 -1
  249. package/dist/types.js +1 -1
  250. package/dist/utility-runtime.d.ts +1 -1
  251. package/dist/utils/serialize-mutations.js +1 -1
  252. package/dist/verified-recall.js +8 -7
  253. package/package.json +2 -2
  254. package/src/access-http.ts +162 -0
  255. package/src/access-service.ts +215 -0
  256. package/src/admin/admin-surfaces.test.ts +458 -0
  257. package/src/admin/admin-surfaces.ts +819 -0
  258. package/src/event-order-recall.test.ts +53 -0
  259. package/src/event-order-recall.ts +61 -37
  260. package/src/explicit-cue-recall.ts +21 -1
  261. package/src/extraction.ts +91 -6
  262. package/src/namespaces/catalog.test.ts +222 -14
  263. package/src/namespaces/catalog.ts +54 -187
  264. package/src/namespaces/storage.ts +87 -80
  265. package/src/orchestrator.ts +46 -5
  266. package/src/provenance-extraction.test.ts +838 -0
  267. package/src/provenance.ts +413 -0
  268. package/src/recall-pipeline-parity.test.ts +288 -0
  269. package/src/recall-pipeline-stages.test.ts +43 -0
  270. package/src/recall-pipeline-stages.ts +22 -0
  271. package/src/response-guidance-recall.ts +32 -36
  272. package/src/schemas.ts +7 -0
  273. package/src/storage.ts +23 -0
  274. package/src/summary-snapshot.test.ts +63 -1
  275. package/src/summary-snapshot.ts +61 -80
  276. package/src/targeted-fact-recall.ts +25 -32
  277. package/src/types.ts +24 -0
  278. package/src/utils/serialize-mutations.ts +10 -6
  279. package/dist/chunk-3E5WRQNQ.js.map +0 -1
  280. package/dist/chunk-DR2JTSLZ.js.map +0 -1
  281. package/dist/chunk-EC2AYKRX.js.map +0 -1
  282. package/dist/chunk-FN2SM5SN.js.map +0 -1
  283. package/dist/chunk-GA5A6MJH.js.map +0 -1
  284. package/dist/chunk-HRUULBBV.js.map +0 -1
  285. package/dist/chunk-PQG4T5V3.js.map +0 -1
  286. package/dist/chunk-R5DB26G6.js.map +0 -1
  287. package/dist/chunk-ROZJACKP.js.map +0 -1
  288. package/dist/chunk-UU6MVCJ6.js.map +0 -1
  289. package/dist/chunk-X74FJSW7.js.map +0 -1
  290. package/dist/chunk-XJNBEDFE.js.map +0 -1
  291. package/dist/chunk-YXLT4EMM.js.map +0 -1
  292. package/dist/chunk-ZPQVJEVQ.js.map +0 -1
  293. /package/dist/{chunk-PCZR32VL.js.map → chunk-3JJWNZTT.js.map} +0 -0
  294. /package/dist/{chunk-YXIFA36P.js.map → chunk-6TAETM63.js.map} +0 -0
  295. /package/dist/{chunk-6JDGADXK.js.map → chunk-6VVP6NK7.js.map} +0 -0
  296. /package/dist/{chunk-CHM274U6.js.map → chunk-AMNLZ6SF.js.map} +0 -0
  297. /package/dist/{chunk-JX3YZVII.js.map → chunk-CZJ6QSKG.js.map} +0 -0
  298. /package/dist/{chunk-GYVVQYA3.js.map → chunk-EOOVJK2U.js.map} +0 -0
  299. /package/dist/{chunk-JKW5XSWC.js.map → chunk-EYJD6KIO.js.map} +0 -0
  300. /package/dist/{chunk-XY4WJTEX.js.map → chunk-EZR35XHX.js.map} +0 -0
  301. /package/dist/{chunk-2NWHLAXX.js.map → chunk-FVI5B7DE.js.map} +0 -0
  302. /package/dist/{chunk-PONNZ54D.js.map → chunk-GY3SKOS4.js.map} +0 -0
  303. /package/dist/{chunk-U33LWTQQ.js.map → chunk-HV57RHMD.js.map} +0 -0
  304. /package/dist/{chunk-RC3CNIPK.js.map → chunk-IO5NQEGZ.js.map} +0 -0
  305. /package/dist/{chunk-HDLC75NX.js.map → chunk-IX72AAMZ.js.map} +0 -0
  306. /package/dist/{chunk-G5PKTQ5J.js.map → chunk-JTKFZMZ7.js.map} +0 -0
  307. /package/dist/{chunk-YMTGXDN6.js.map → chunk-KC6TCAWV.js.map} +0 -0
  308. /package/dist/{chunk-ED35D32I.js.map → chunk-LQ4J7ELC.js.map} +0 -0
  309. /package/dist/{chunk-RJ2THZ4H.js.map → chunk-MBUM2Y3L.js.map} +0 -0
  310. /package/dist/{chunk-T5QAZIBO.js.map → chunk-MOXFPLD6.js.map} +0 -0
  311. /package/dist/{chunk-O54DY26V.js.map → chunk-MXEWQKM7.js.map} +0 -0
  312. /package/dist/{chunk-33L6XHU2.js.map → chunk-NUIJEGVD.js.map} +0 -0
  313. /package/dist/{chunk-EOBJRBLC.js.map → chunk-QGJAGC2J.js.map} +0 -0
  314. /package/dist/{chunk-SMIVW7XC.js.map → chunk-QIMFOCSH.js.map} +0 -0
  315. /package/dist/{chunk-IJEZMWKA.js.map → chunk-SFOAQQDJ.js.map} +0 -0
  316. /package/dist/{chunk-NINRTFSV.js.map → chunk-SINGJCUR.js.map} +0 -0
  317. /package/dist/{chunk-SEWF2O74.js.map → chunk-TGAHHCB6.js.map} +0 -0
  318. /package/dist/{chunk-D75JXBV4.js.map → chunk-WN4GHSDH.js.map} +0 -0
  319. /package/dist/{chunk-K4DWSPMW.js.map → chunk-WRGPE6AW.js.map} +0 -0
  320. /package/dist/{chunk-SDPDU2PM.js.map → chunk-YTMDF6S7.js.map} +0 -0
  321. /package/dist/{chunk-ZDK2IW5F.js.map → chunk-Z7XEIAV4.js.map} +0 -0
  322. /package/dist/{chunk-NHBEO3F3.js.map → chunk-ZCEI242W.js.map} +0 -0
  323. /package/dist/{forget-BEXG5PQC.js.map → forget-6SOIPUMQ.js.map} +0 -0
@@ -11,6 +11,7 @@ import {
11
11
  resolveDefaultNamespaceRoot,
12
12
  resolveNamespaceStorageRoot,
13
13
  } from "./storage.js";
14
+ import { withHeldFileLock } from "../utils/serialize-mutations.js";
14
15
 
15
16
  function makeConfig(memoryDir: string, overrides: Partial<PluginConfig> = {}): PluginConfig {
16
17
  return {
@@ -2171,8 +2172,16 @@ test("registerConfiguredNamespaces skips an unsafe configured name without abort
2171
2172
 
2172
2173
  // ── Round 7 (codex P2 — NBsGP): two catalog instances in the SAME process
2173
2174
  // sharing a memoryDir must not treat each other's rebuild lock as self-held. A
2174
- // touch on instance B must DROP its append while instance A holds the lock,
2175
- // instead of skipping the wait (same PID) and appending into A's window.
2175
+ // touch on instance B must DROP its append while a foreign lock is held,
2176
+ // instead of skipping the wait (same PID) and appending into the holder's
2177
+ // window.
2178
+ //
2179
+ // Issue #1524 note: the catalog now delegates to the shared withHeldFileLock
2180
+ // utility, which generates a per-CALL owner uuid (stronger than the previous
2181
+ // per-instance lockOwnerId). ANY foreign lock — including one written by
2182
+ // another call on the SAME instance — is therefore not self-held. The test
2183
+ // seeds a foreign lock with an arbitrary uuid + a heartbeat that keeps it
2184
+ // fresh, then asserts a touch on a fresh instance waits then DROPS.
2176
2185
  test("a same-process second instance does not treat another instance's lock as self-held", async () => {
2177
2186
  const memoryDir = await mkMemoryDir();
2178
2187
  try {
@@ -2183,10 +2192,12 @@ test("a same-process second instance does not treat another instance's lock as s
2183
2192
  await mkdir(stateDir, { recursive: true });
2184
2193
  const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
2185
2194
 
2186
- // Instance A writes a lock with ITS OWN owner id (a UUID) same PID as B.
2187
- const instanceA = new NamespaceCatalog(makeConfig(memoryDir));
2188
- const aOwnerId = (instanceA as unknown as { lockOwnerId: string }).lockOwnerId;
2189
- await writeFile(lockPath, `${process.pid} ${aOwnerId} ${new Date().toISOString()}\n`, "utf8");
2195
+ // A foreign held lock: a different PID + a real UUID owner id (so it is
2196
+ // NEVER mistaken for self by any catalog instance in any process) + a fresh
2197
+ // mtime. A heartbeat keeps it fresh so it is never broken as stale during
2198
+ // the touch's bounded wait.
2199
+ const foreignOwner = "00000000-0000-4000-8000-000000000000";
2200
+ await writeFile(lockPath, `999999 ${foreignOwner} ${new Date().toISOString()}\n`, "utf8");
2190
2201
  const hb = setInterval(() => {
2191
2202
  const now = new Date();
2192
2203
  utimes(lockPath, now, now).catch(() => undefined);
@@ -2194,17 +2205,17 @@ test("a same-process second instance does not treat another instance's lock as s
2194
2205
  hb.unref?.();
2195
2206
 
2196
2207
  try {
2197
- // Instance B (different owner id, same PID) must NOT consider A's lock
2198
- // self-held; its touch waits then DROPS on timeout (no append).
2199
- const instanceB = new NamespaceCatalog(makeConfig(memoryDir));
2208
+ // The catalog instance must NOT consider the foreign lock self-held; its
2209
+ // touch waits then DROPS on timeout (no append).
2210
+ const instance = new NamespaceCatalog(makeConfig(memoryDir));
2200
2211
  const started = Date.now();
2201
- await instanceB.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
2212
+ await instance.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
2202
2213
  const waited = Date.now() - started;
2203
- assert.ok(waited >= 4_000, "instance B must wait on instance A's lock, not skip it as self");
2214
+ assert.ok(waited >= 4_000, "the catalog must wait on the foreign lock, not skip it as self");
2204
2215
  assert.equal(
2205
- await instanceB.getNamespaceRecord(ns),
2216
+ await instance.getNamespaceRecord(ns),
2206
2217
  null,
2207
- "instance B's touch must DROP while instance A's lock is held (no overwrite race)",
2218
+ "the touch must DROP while the foreign lock is held (no overwrite race)",
2208
2219
  );
2209
2220
  } finally {
2210
2221
  clearInterval(hb);
@@ -2609,6 +2620,98 @@ test("a dropped resolve registration (hook returns false) is retried on a later
2609
2620
  }
2610
2621
  });
2611
2622
 
2623
+ // ── In-flight dedup under a DROPPED hook (cursor Medium 06f58a7c, codex P2).
2624
+ // The serializer strictly orders queued tasks, but ordering alone does not
2625
+ // collapse a burst when the hook returns `false` (dropped registration, e.g. a
2626
+ // rebuild-lock timeout): `notifiedResolved` stays unset, so every queued sibling
2627
+ // task passes its re-check and re-invokes the hook — N serial lock waits. The
2628
+ // `inFlightResolveHooks` marker (set synchronously before queueing) collapses the
2629
+ // burst to a single hook invocation; the drop stays retryable on a LATER
2630
+ // storageFor(). This test PROVE-FAILS without the marker (calls === N) and PASSES
2631
+ // with it (calls === 1).
2632
+ test("a burst of concurrent storageFor() with a DROPPED hook fires it ONCE (not once per queued task)", async () => {
2633
+ const memoryDir = await mkMemoryDir();
2634
+ try {
2635
+ let calls = 0;
2636
+ // Gated hook that DROPS (returns false) once released. This is the real
2637
+ // scenario the reviewers flagged: the catalog's onResolve waits on the
2638
+ // rebuild lock (slow), times out, and returns false. While that hook is
2639
+ // in-flight, a burst of cache hits must collapse to the one in-flight
2640
+ // registration instead of each queueing its own serial lock wait.
2641
+ let release!: () => void;
2642
+ const gate = new Promise<void>((r) => {
2643
+ release = r;
2644
+ });
2645
+ const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
2646
+ onResolve: async () => {
2647
+ calls += 1;
2648
+ await gate;
2649
+ return false; // dropped (rebuild-lock timeout analogue)
2650
+ },
2651
+ });
2652
+
2653
+ // A burst of concurrent cache hits while the hook is IN-FLIGHT. Without the
2654
+ // in-flight dedup marker each enqueued task would re-run the dropped hook
2655
+ // once the first settles (N serial lock waits); with it they collapse.
2656
+ const N = 8;
2657
+ await Promise.all(Array.from({ length: N }, () => router.storageFor("project-origin-burst-drop")));
2658
+ // Let the in-flight hook settle as DROPPED.
2659
+ release();
2660
+ await router.whenResolveHooksSettled();
2661
+ assert.equal(calls, 1, "a dropped hook fires ONCE under a burst, not once per queued task");
2662
+
2663
+ // The drop must remain retryable: a later storageFor() re-fires the hook.
2664
+ await router.storageFor("project-origin-burst-drop");
2665
+ await router.whenResolveHooksSettled();
2666
+ assert.equal(calls, 2, "a dropped registration is retried on the next storageFor()");
2667
+ } finally {
2668
+ await rm(memoryDir, { recursive: true, force: true });
2669
+ }
2670
+ });
2671
+
2672
+ // ── Composite-key in-flight dedup (cursor Medium, codex P2): the in-flight
2673
+ // marker must be keyed by (namespace, storageDir), NOT namespace alone. A
2674
+ // CHANGED storageDir (migration/realignment) for the same namespace while
2675
+ // another dir's hook is pending must still get its OWN hook invocation — it is
2676
+ // not collapsed onto the old dir's pending registration. A namespace-only key
2677
+ // would silently drop the new-dir notification.
2678
+ test("a CHANGED storageDir for the same namespace is not collapsed onto a pending hook", async () => {
2679
+ const memoryDir = await mkMemoryDir();
2680
+ try {
2681
+ const seen: string[] = [];
2682
+ let release!: () => void;
2683
+ const gate = new Promise<void>((r) => {
2684
+ release = r;
2685
+ });
2686
+ const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
2687
+ onResolve: async (_ns, dir) => {
2688
+ seen.push(dir);
2689
+ await gate;
2690
+ },
2691
+ });
2692
+ // notifyResolved is private; reach it via the same cast pattern other tests
2693
+ // use for router internals, so we can drive two distinct dirs directly.
2694
+ const internals = router as unknown as {
2695
+ notifyResolved(namespace: string, storageDir: string): void;
2696
+ };
2697
+ const dirA = path.join(memoryDir, "dir-a");
2698
+ const dirB = path.join(memoryDir, "dir-b");
2699
+ // dirA's hook is IN-FLIGHT (gated). dirB is a DIFFERENT dir for the same
2700
+ // namespace — it must NOT be collapsed onto dirA's pending registration.
2701
+ internals.notifyResolved("project-origin-dir-change", dirA);
2702
+ internals.notifyResolved("project-origin-dir-change", dirB);
2703
+ release();
2704
+ await router.whenResolveHooksSettled();
2705
+ assert.deepEqual(
2706
+ seen.sort(),
2707
+ [dirA, dirB].sort(),
2708
+ "both distinct dirs fire their own hook; the new dir is not collapsed onto the pending one",
2709
+ );
2710
+ } finally {
2711
+ await rm(memoryDir, { recursive: true, force: true });
2712
+ }
2713
+ });
2714
+
2612
2715
  // ── Round 7 (codex P2 — NDxiS): a configured non-default namespace must be seeded
2613
2716
  // with the ROUTER-resolved root, not a blanket tokenized dir. When a legacy raw
2614
2717
  // root (`namespaces/<rawname>`) already exists, the router serves it, so the
@@ -2777,8 +2880,35 @@ class SeamCatalog extends NamespaceCatalog {
2777
2880
  (this as unknown as { onBeforeBreakStaleUnlinkForTest?: () => Promise<void> }).onBeforeBreakStaleUnlinkForTest =
2778
2881
  fn;
2779
2882
  }
2883
+ /**
2884
+ * Drive the catalog's break-stale path through the shared util (issue #1524
2885
+ * adoption). The catalog no longer owns a private breakStaleRebuildLock; the
2886
+ * util's breakStaleLock fires inside its acquire loop, which is what
2887
+ * withHeldCatalogLock now invokes. We trigger that path with a SHORT maxWaitMs
2888
+ * so a surviving replacement lock is observed quickly (the production
2889
+ * REBUILD_LOCK_MAX_WAIT_MS would force a 5s wait on the NG7Bg-replacement
2890
+ * case). The seam is forwarded exactly as production does.
2891
+ */
2780
2892
  async callBreakStaleRebuildLock(): Promise<void> {
2781
- await (this as unknown as { breakStaleRebuildLock: () => Promise<void> }).breakStaleRebuildLock();
2893
+ const seam = (this as unknown as { onBeforeBreakStaleUnlinkForTest?: () => Promise<void> })
2894
+ .onBeforeBreakStaleUnlinkForTest;
2895
+ // Match the catalog's lock config (stale/heartbeat/poll); only maxWaitMs
2896
+ // is shortened for test speed. The break-stale invariant does not depend
2897
+ // on maxWaitMs — the seam fires inside breakStaleLock regardless.
2898
+ await withHeldFileLock(
2899
+ (this as unknown as { rebuildLockPath: string }).rebuildLockPath,
2900
+ {
2901
+ staleMs: 30_000,
2902
+ maxWaitMs: 200,
2903
+ pollMs: 10,
2904
+ heartbeatMs: 10_000,
2905
+ onBeforeBreakStaleUnlinkForTest: seam,
2906
+ },
2907
+ async () => {
2908
+ // No-op: we only need the acquire loop to invoke breakStaleLock so the
2909
+ // seam fires and the replacement/stale-lock invariant is exercised.
2910
+ },
2911
+ );
2782
2912
  }
2783
2913
  }
2784
2914
 
@@ -3354,3 +3484,81 @@ test("listNamespaces prefers configured token owners over stale literal token al
3354
3484
  await rm(memoryDir, { recursive: true, force: true });
3355
3485
  }
3356
3486
  });
3487
+
3488
+ // ── Issue #1524 adoption prove-fail: catalog mutations route through the
3489
+ // shared MutationSerializer (instance-scoped `criticalSection`). The defect
3490
+ // class is a naive bare-.then(fn) chain that silently drops subsequent sections
3491
+ // after a rejection — exactly the poison-chain bug the shared util prevents.
3492
+ // We force the FIRST serialized section to reject, then assert the SECOND
3493
+ // section STILL runs (its record lands in the catalog). Pre-fix (a poison
3494
+ // chain) the second section would be skipped.
3495
+ test("catalog queueCritical recovers after a prior section rejects (issue #1524 poison-chain prove-fail)", async () => {
3496
+ const memoryDir = await mkMemoryDir();
3497
+ try {
3498
+ const catalog = new NamespaceCatalog(makeConfig(memoryDir));
3499
+ // Reach the private serializer to inject a failing section ahead of a real
3500
+ // one. Both target the SAME key ("catalog") so they share a chain.
3501
+ const serializer = (catalog as unknown as {
3502
+ criticalSection: {
3503
+ serialize<T>(key: string, task: () => Promise<T>): Promise<T>;
3504
+ };
3505
+ }).criticalSection;
3506
+
3507
+ let secondRan = false;
3508
+ const [, second] = await Promise.allSettled([
3509
+ serializer.serialize("catalog", async () => {
3510
+ throw new Error("intentional first-section failure");
3511
+ }),
3512
+ serializer.serialize("catalog", async () => {
3513
+ secondRan = true;
3514
+ }),
3515
+ ]);
3516
+ assert.equal(second.status, "fulfilled", "second section settled (ran or skipped?)");
3517
+ assert.equal(secondRan, true, "second section MUST run after the first rejected (chain recovered)");
3518
+ // And a real catalog op still works through the same chain after the failure.
3519
+ await catalog.markWrite("project-origin-poison-recovery", { discoveredBy: "write" });
3520
+ const record = await catalog.getNamespaceRecord("project-origin-poison-recovery");
3521
+ assert.ok(record, "the catalog is fully usable after a rejected section (chain not poisoned)");
3522
+ } finally {
3523
+ await rm(memoryDir, { recursive: true, force: true });
3524
+ }
3525
+ });
3526
+
3527
+ // ── Issue #1524 adoption prove-fail: NamespaceStorageRouter resolve-hooks
3528
+ // route through the shared MutationSerializer (`resolveSerializer`). Same
3529
+ // defect class — a poison chain would skip the second hook after the first
3530
+ // rejected. We fire two notifications for the SAME namespace; the first hook
3531
+ // rejects, the second MUST still run.
3532
+ test("router resolve-hook serializer recovers after a prior hook rejects (issue #1524 poison-chain prove-fail)", async () => {
3533
+ const memoryDir = await mkMemoryDir();
3534
+ try {
3535
+ let secondCalls = 0;
3536
+ let firstCalls = 0;
3537
+ let rejectNext = true;
3538
+ const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
3539
+ onResolve: async () => {
3540
+ if (rejectNext) {
3541
+ firstCalls += 1;
3542
+ rejectNext = false;
3543
+ throw new Error("intentional first-hook failure");
3544
+ }
3545
+ secondCalls += 1;
3546
+ },
3547
+ });
3548
+ // Two storageFor calls for the same namespace. The first triggers the hook
3549
+ // (which rejects); the second queues behind it through the serializer.
3550
+ // Both calls themselves must resolve (the rejection is best-effort inside
3551
+ // the hook wrapper, never surfaced to the storage caller).
3552
+ await router.storageFor("project-origin-router-poison");
3553
+ await router.whenResolveHooksSettled();
3554
+ await router.storageFor("project-origin-router-poison");
3555
+ await router.whenResolveHooksSettled();
3556
+ assert.ok(firstCalls >= 1, "the first (rejecting) hook fired");
3557
+ assert.ok(
3558
+ secondCalls >= 1,
3559
+ "the second hook MUST fire after the first rejected (serializer recovered, not poisoned)",
3560
+ );
3561
+ } finally {
3562
+ await rm(memoryDir, { recursive: true, force: true });
3563
+ }
3564
+ });
@@ -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
  /**