@remnic/core 9.26.0 → 9.28.0

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 (440) hide show
  1. package/dist/access-admin-ops-surface.d.ts +13 -10
  2. package/dist/access-admin-ops-surface.js +43 -38
  3. package/dist/access-audit.js +2 -2
  4. package/dist/access-authorization-probe.d.ts +13 -10
  5. package/dist/access-authorization-probe.js +44 -39
  6. package/dist/access-boundary.d.ts +13 -10
  7. package/dist/access-boundary.js +43 -38
  8. package/dist/access-cli.js +100 -94
  9. package/dist/access-cli.js.map +1 -1
  10. package/dist/access-health-types.d.ts +10 -1
  11. package/dist/access-http-lcm-compaction.d.ts +13 -10
  12. package/dist/access-http.d.ts +13 -10
  13. package/dist/access-http.js +53 -48
  14. package/dist/access-identity-continuity-surface.d.ts +9 -8
  15. package/dist/access-identity-continuity-surface.js +43 -38
  16. package/dist/access-lcm-surface.d.ts +13 -10
  17. package/dist/access-lcm-surface.js +43 -38
  18. package/dist/access-mcp.d.ts +13 -10
  19. package/dist/access-mcp.js +48 -43
  20. package/dist/access-memory-search-fanout.d.ts +2 -2
  21. package/dist/{access-namespace-preflight-wRSZEO5V.d.ts → access-namespace-preflight-BiQ_-iUJ.d.ts} +1 -1
  22. package/dist/access-namespace-preflight.d.ts +3 -3
  23. package/dist/access-namespace-preflight.js +43 -38
  24. package/dist/access-observe-write-surface.d.ts +13 -10
  25. package/dist/access-observe-write-surface.js +43 -38
  26. package/dist/access-operations-batch.js +45 -40
  27. package/dist/access-operations.d.ts +16 -13
  28. package/dist/access-operations.js +47 -42
  29. package/dist/access-recall-concurrency.d.ts +13 -10
  30. package/dist/access-recall-concurrency.js +43 -38
  31. package/dist/access-recall-response.d.ts +13 -10
  32. package/dist/access-recall-response.js +43 -38
  33. package/dist/access-recall-surface.d.ts +13 -10
  34. package/dist/access-recall-surface.js +43 -38
  35. package/dist/access-schema.d.ts +80 -80
  36. package/dist/access-schema.js +4 -4
  37. package/dist/{access-service-DL8FJnTj.d.ts → access-service-CXkIgcGh.d.ts} +12 -8
  38. package/dist/access-service.d.ts +13 -10
  39. package/dist/access-service.js +43 -38
  40. package/dist/access-surface-catalog.d.ts +13 -10
  41. package/dist/access-wearables-meetings-surface.d.ts +4 -3
  42. package/dist/action-confidence.d.ts +1 -1
  43. package/dist/active-memory-bridge.d.ts +1 -1
  44. package/dist/active-recall.d.ts +1 -1
  45. package/dist/active-recall.js +4 -2
  46. package/dist/active-recall.js.map +1 -1
  47. package/dist/adapters/index.js +7 -7
  48. package/dist/adapters/registry.js +3 -3
  49. package/dist/behavior-learner.d.ts +1 -1
  50. package/dist/behavior-signals.d.ts +1 -1
  51. package/dist/bootstrap.d.ts +9 -8
  52. package/dist/briefing.d.ts +3 -2
  53. package/dist/briefing.js +12 -10
  54. package/dist/buffer-surprise-report.d.ts +1 -1
  55. package/dist/buffer.d.ts +3 -2
  56. package/dist/bulk-import/index.d.ts +3 -3
  57. package/dist/calibration.d.ts +1 -1
  58. package/dist/capabilities.d.ts +1 -1
  59. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-7FJQINUR.js} +2 -2
  60. package/dist/{catalog-BLLxZ5L5.d.ts → catalog-Bouj8Gfg.d.ts} +1 -1
  61. package/dist/causal-behavior.d.ts +1 -1
  62. package/dist/causal-consolidation.d.ts +1 -1
  63. package/dist/causal-consolidation.js +13 -11
  64. package/dist/causal-consolidation.js.map +1 -1
  65. package/dist/causal-trajectory-graph.d.ts +1 -1
  66. package/dist/{chunk-7RZJWZ46.js → chunk-227UFSEQ.js} +5 -5
  67. package/dist/{chunk-VGEH3STZ.js → chunk-2BEJ4XCF.js} +2 -2
  68. package/dist/{chunk-LIIQPDFN.js → chunk-2FYNLDKJ.js} +24 -3
  69. package/dist/chunk-2FYNLDKJ.js.map +1 -0
  70. package/dist/{chunk-BXLOS5AJ.js → chunk-2NLLXCJG.js} +2 -2
  71. package/dist/{chunk-6NNNURLK.js → chunk-2PHX3I5C.js} +8 -8
  72. package/dist/{chunk-YN4AUGNF.js → chunk-2TQ2UFKH.js} +65 -60
  73. package/dist/chunk-2TQ2UFKH.js.map +1 -0
  74. package/dist/{chunk-XAAIUXJO.js → chunk-3BSJBIZ3.js} +2 -2
  75. package/dist/{chunk-JCVQV6QX.js → chunk-3UOSVTFR.js} +11 -11
  76. package/dist/{chunk-AJ7QCSIS.js → chunk-7CPOZH6Y.js} +2 -2
  77. package/dist/{chunk-EXM2Q555.js → chunk-AHFZXUZM.js} +7 -5
  78. package/dist/chunk-AHFZXUZM.js.map +1 -0
  79. package/dist/{chunk-M2KGXXTL.js → chunk-ALTP3MFS.js} +2 -2
  80. package/dist/{chunk-77CNV7DY.js → chunk-BLAKS3CP.js} +4 -4
  81. package/dist/{chunk-FE5FKTCI.js → chunk-BR4UFMLO.js} +2 -2
  82. package/dist/chunk-CVILPI77.js +432 -0
  83. package/dist/chunk-CVILPI77.js.map +1 -0
  84. package/dist/{chunk-H2PBM562.js → chunk-DKQM5Q6Q.js} +2 -2
  85. package/dist/{chunk-CSXHRAJJ.js → chunk-EG64LXKL.js} +3 -3
  86. package/dist/{chunk-PTN6UAUA.js → chunk-ES36HTSC.js} +4 -4
  87. package/dist/{chunk-WMDRLTNA.js → chunk-EZWWCN7I.js} +2 -2
  88. package/dist/{chunk-XKGPIWGI.js → chunk-FBZTWUK5.js} +5 -5
  89. package/dist/{chunk-GJJBPI6I.js → chunk-FOCEI3TN.js} +6 -3
  90. package/dist/chunk-FOCEI3TN.js.map +1 -0
  91. package/dist/{chunk-Y6E37WRG.js → chunk-GJPCP5CA.js} +2 -2
  92. package/dist/{chunk-IHMXPG4P.js → chunk-GQSMYXKL.js} +6 -4
  93. package/dist/{chunk-IHMXPG4P.js.map → chunk-GQSMYXKL.js.map} +1 -1
  94. package/dist/{chunk-TGQ2NTWH.js → chunk-IJ6WHMFB.js} +16 -2
  95. package/dist/{chunk-TGQ2NTWH.js.map → chunk-IJ6WHMFB.js.map} +1 -1
  96. package/dist/{chunk-VDX2J7OX.js → chunk-IPLYGWQF.js} +6 -6
  97. package/dist/{chunk-XNUBF6RF.js → chunk-IYT632D5.js} +3 -3
  98. package/dist/{chunk-2QSZNTDO.js → chunk-JBPKEARU.js} +7 -7
  99. package/dist/{chunk-67DSDRUB.js → chunk-JIF3VRKQ.js} +8 -8
  100. package/dist/{chunk-V25OWXAC.js → chunk-JQ7T5VDC.js} +1 -1
  101. package/dist/chunk-JQ7T5VDC.js.map +1 -0
  102. package/dist/{chunk-MIFF37NN.js → chunk-KKBTBUVT.js} +2 -2
  103. package/dist/{chunk-ZHP4SUPB.js → chunk-LIHC3T43.js} +2 -2
  104. package/dist/{chunk-PX7VDW7R.js → chunk-MJPV4LBH.js} +79 -79
  105. package/dist/{chunk-PX7VDW7R.js.map → chunk-MJPV4LBH.js.map} +1 -1
  106. package/dist/{chunk-UGH6VPPO.js → chunk-MWTMTW2T.js} +2 -2
  107. package/dist/{chunk-F6LSNPPB.js → chunk-MXJ3VP6S.js} +6 -6
  108. package/dist/{chunk-KPWLZBXZ.js → chunk-NBNDVNJ2.js} +186 -25
  109. package/dist/chunk-NBNDVNJ2.js.map +1 -0
  110. package/dist/{chunk-M5EPMNS4.js → chunk-P2CLYN6H.js} +2 -2
  111. package/dist/{chunk-7XXCIO6X.js → chunk-PXQM2HOG.js} +13 -6
  112. package/dist/{chunk-7XXCIO6X.js.map → chunk-PXQM2HOG.js.map} +1 -1
  113. package/dist/{chunk-UZK7VVPD.js → chunk-Q5VJUNXA.js} +1858 -272
  114. package/dist/chunk-Q5VJUNXA.js.map +1 -0
  115. package/dist/{chunk-KGTD2KZJ.js → chunk-Q6YJW6KE.js} +2 -2
  116. package/dist/{chunk-X3SQYAPP.js → chunk-R372P2EH.js} +2 -2
  117. package/dist/{chunk-HBVOJNV4.js → chunk-RKXZ7P4V.js} +3 -3
  118. package/dist/chunk-RMMVQQA6.js +70 -0
  119. package/dist/chunk-RMMVQQA6.js.map +1 -0
  120. package/dist/{chunk-E6EAB4J6.js → chunk-S24E4MBQ.js} +14 -14
  121. package/dist/chunk-S24E4MBQ.js.map +1 -0
  122. package/dist/{chunk-RYHWZ52A.js → chunk-S2BBISHU.js} +4 -4
  123. package/dist/{chunk-LLISELSY.js → chunk-TBMRQZDY.js} +2 -2
  124. package/dist/{chunk-FMLKOL2Z.js → chunk-TC3VNKGX.js} +4 -4
  125. package/dist/{chunk-7K5Q6COX.js → chunk-TVVEYCNW.js} +4 -4
  126. package/dist/{chunk-2XP3PWL3.js → chunk-UR5SIN7H.js} +53 -35
  127. package/dist/chunk-UR5SIN7H.js.map +1 -0
  128. package/dist/{chunk-NKDVXEFU.js → chunk-VFNI3RPT.js} +2 -2
  129. package/dist/chunk-VNA7W7QF.js +134 -0
  130. package/dist/chunk-VNA7W7QF.js.map +1 -0
  131. package/dist/{chunk-PQPSZICJ.js → chunk-VYYNBJXH.js} +5 -5
  132. package/dist/chunk-WNIUPHWV.js +152 -0
  133. package/dist/chunk-WNIUPHWV.js.map +1 -0
  134. package/dist/chunk-WRJVB3C2.js +118 -0
  135. package/dist/chunk-WRJVB3C2.js.map +1 -0
  136. package/dist/{chunk-SN3ZESWQ.js → chunk-WVVK2RFM.js} +6 -6
  137. package/dist/{chunk-DQEMWVMT.js → chunk-X7Y7WX73.js} +1 -1
  138. package/dist/{chunk-4KKV7W62.js → chunk-YOBNCD3Z.js} +6 -6
  139. package/dist/chunk-YXZG5TVN.js +40 -0
  140. package/dist/chunk-YXZG5TVN.js.map +1 -0
  141. package/dist/{chunk-4DJQYKMN.js → chunk-ZIKENA2C.js} +2 -2
  142. package/dist/chunk-ZIKENA2C.js.map +1 -0
  143. package/dist/{chunk-SK3EYPKC.js → chunk-ZN4UZWMT.js} +2 -2
  144. package/dist/{cli-BoouX6LX.d.ts → cli-DJbkaZBU.d.ts} +5 -5
  145. package/dist/cli.d.ts +14 -12
  146. package/dist/cli.js +79 -74
  147. package/dist/compounding/engine.d.ts +3 -2
  148. package/dist/compounding/engine.js +12 -10
  149. package/dist/compounding/preference-consolidator.d.ts +1 -1
  150. package/dist/compression-optimizer.d.ts +1 -1
  151. package/dist/config.d.ts +1 -1
  152. package/dist/config.js +4 -2
  153. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  154. package/dist/connectors/codex-materialize-runner.js +12 -10
  155. package/dist/connectors/codex-materialize.d.ts +1 -1
  156. package/dist/connectors/index.d.ts +1 -1
  157. package/dist/connectors/index.js +12 -10
  158. package/dist/consolidation-provenance-check.d.ts +3 -2
  159. package/dist/consolidation-undo.d.ts +3 -2
  160. package/dist/contradiction/index.d.ts +3 -2
  161. package/dist/conversation-index/backend.d.ts +1 -1
  162. package/dist/conversation-index/backend.js +2 -2
  163. package/dist/conversation-index/chunker.d.ts +1 -1
  164. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  165. package/dist/conversation-index/indexer.d.ts +1 -1
  166. package/dist/conversation-index/search.d.ts +1 -1
  167. package/dist/corpus-watermark.d.ts +15 -2
  168. package/dist/corpus-watermark.js +25 -21
  169. package/dist/day-summary.d.ts +1 -1
  170. package/dist/delinearize.d.ts +1 -1
  171. package/dist/direct-answer-wiring.d.ts +1 -1
  172. package/dist/direct-answer.d.ts +1 -1
  173. package/dist/embedding-fallback.d.ts +1 -1
  174. package/dist/enrichment/index.d.ts +1 -1
  175. package/dist/entity-id-normalization.d.ts +4 -0
  176. package/dist/entity-id-normalization.js +10 -0
  177. package/dist/entity-id-normalization.js.map +1 -0
  178. package/dist/entity-retrieval-boundaries.d.ts +3 -0
  179. package/dist/entity-retrieval-boundaries.js +8 -0
  180. package/dist/entity-retrieval-boundaries.js.map +1 -0
  181. package/dist/entity-retrieval.d.ts +3 -2
  182. package/dist/entity-retrieval.js +13 -10
  183. package/dist/entity-schema.d.ts +1 -1
  184. package/dist/entity-schema.js +1 -1
  185. package/dist/explicit-capture.d.ts +9 -8
  186. package/dist/extraction-error-classification.d.ts +1 -1
  187. package/dist/extraction-faithfulness.d.ts +1 -1
  188. package/dist/extraction-judge-telemetry.d.ts +1 -1
  189. package/dist/extraction-judge-training.d.ts +1 -1
  190. package/dist/extraction-judge.d.ts +1 -1
  191. package/dist/extraction-liveness.d.ts +1 -1
  192. package/dist/extraction.d.ts +1 -1
  193. package/dist/fallback-llm.d.ts +1 -1
  194. package/dist/{first-start-migration-LEXL3XYL.js → first-start-migration-BKNGOFED.js} +4 -4
  195. package/dist/graph-dashboard-diff.d.ts +1 -1
  196. package/dist/graph-dashboard-key.d.ts +1 -1
  197. package/dist/graph-dashboard-parser.d.ts +1 -1
  198. package/dist/graph-edge-reinforcement.d.ts +1 -1
  199. package/dist/graph-snapshot.d.ts +1 -1
  200. package/dist/graph.d.ts +1 -1
  201. package/dist/importance.d.ts +1 -1
  202. package/dist/importers/index.d.ts +1 -1
  203. package/dist/in-flight-reads.d.ts +1 -1
  204. package/dist/index.d.ts +409 -407
  205. package/dist/index.js +147 -141
  206. package/dist/intent.d.ts +1 -1
  207. package/dist/lcm/engine.d.ts +1 -1
  208. package/dist/lcm/engine.js +3 -3
  209. package/dist/lcm/index.d.ts +1 -1
  210. package/dist/lcm/index.js +9 -9
  211. package/dist/lcm/tools.d.ts +1 -1
  212. package/dist/lifecycle.d.ts +1 -1
  213. package/dist/live-connectors-runner.d.ts +1 -1
  214. package/dist/local-llm.d.ts +1 -1
  215. package/dist/local-model-endpoint.d.ts +1 -1
  216. package/dist/maintenance/memory-governance.d.ts +1 -1
  217. package/dist/maintenance/memory-governance.js +12 -10
  218. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +3 -2
  219. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +12 -10
  220. package/dist/maintenance/rebuild-memory-projection.d.ts +3 -2
  221. package/dist/maintenance/rebuild-memory-projection.js +13 -11
  222. package/dist/{maintenance-DD22oIx9.d.ts → maintenance-DveW6azo.d.ts} +3 -3
  223. package/dist/mcp-memory-inspector-app.d.ts +13 -10
  224. package/dist/memory-action-policy.d.ts +1 -1
  225. package/dist/memory-cache.d.ts +1 -1
  226. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  227. package/dist/memory-projection-mutations.d.ts +4 -0
  228. package/dist/memory-projection-mutations.js +15 -0
  229. package/dist/memory-projection-mutations.js.map +1 -0
  230. package/dist/memory-projection-store.d.ts +1 -1
  231. package/dist/memory-provenance.d.ts +1 -1
  232. package/dist/memory-worth-outcomes.d.ts +3 -2
  233. package/dist/models-json.d.ts +1 -1
  234. package/dist/namespaces/migrate.d.ts +4 -3
  235. package/dist/namespaces/migrate.js +22 -20
  236. package/dist/namespaces/principal.d.ts +1 -1
  237. package/dist/namespaces/search.d.ts +1 -1
  238. package/dist/namespaces/search.js +9 -9
  239. package/dist/namespaces/storage.d.ts +4 -3
  240. package/dist/namespaces/storage.js +12 -10
  241. package/dist/native-knowledge.d.ts +1 -1
  242. package/dist/offline-sync-impression-drain.d.ts +1 -1
  243. package/dist/offline-sync-impression-drain.js +3 -3
  244. package/dist/operator-doctor-corpus.d.ts +22 -3
  245. package/dist/operator-doctor-corpus.js +32 -22
  246. package/dist/operator-doctor-replica.d.ts +52 -0
  247. package/dist/operator-doctor-replica.js +15 -0
  248. package/dist/operator-doctor-replica.js.map +1 -0
  249. package/dist/operator-toolkit.d.ts +7 -3
  250. package/dist/operator-toolkit.js +33 -27
  251. package/dist/orchestration/compression-guideline-coordinator.d.ts +3 -2
  252. package/dist/orchestration/maintenance.d.ts +5 -4
  253. package/dist/orchestration/maintenance.js +17 -15
  254. package/dist/{orchestrator-CFJ_TG6D.d.ts → orchestrator-Cvlgpe6I.d.ts} +7 -7
  255. package/dist/orchestrator.d.ts +9 -8
  256. package/dist/orchestrator.js +99 -93
  257. package/dist/patterns-cli.d.ts +1 -1
  258. package/dist/{pipeline-Bq5zU6Bp.d.ts → pipeline-BveYVaz7.d.ts} +1 -1
  259. package/dist/policy-runtime.d.ts +1 -1
  260. package/dist/proactive-contention.d.ts +1 -1
  261. package/dist/provenance.d.ts +1 -1
  262. package/dist/{qmd-3ILJkC70.d.ts → qmd-CLWDsbYR.d.ts} +1 -1
  263. package/dist/qmd-preflight.d.ts +2 -2
  264. package/dist/qmd-recall-cache.d.ts +1 -1
  265. package/dist/qmd.d.ts +2 -2
  266. package/dist/recall-concurrency-config.d.ts +1 -1
  267. package/dist/recall-disclosure-escalation.d.ts +1 -1
  268. package/dist/recall-explain-renderer.d.ts +1 -1
  269. package/dist/recall-explain-renderer.js +3 -3
  270. package/dist/recall-memory-map.d.ts +1 -1
  271. package/dist/recall-planner-llm.d.ts +1 -1
  272. package/dist/recall-state.d.ts +1 -1
  273. package/dist/recall-state.js +2 -2
  274. package/dist/recall-tag-filter.d.ts +1 -1
  275. package/dist/recall-timings.d.ts +1 -1
  276. package/dist/recall-xray-cli.d.ts +1 -1
  277. package/dist/recall-xray-cli.js +4 -4
  278. package/dist/recall-xray-renderer.d.ts +1 -1
  279. package/dist/recall-xray-renderer.js +3 -3
  280. package/dist/recall-xray.d.ts +1 -1
  281. package/dist/recall-xray.js +2 -2
  282. package/dist/replay/normalizers/chatgpt.d.ts +1 -1
  283. package/dist/replay/normalizers/claude.d.ts +1 -1
  284. package/dist/replay/normalizers/openclaw.d.ts +1 -1
  285. package/dist/replay/normalizers/shared.d.ts +1 -1
  286. package/dist/replay/runner.d.ts +1 -1
  287. package/dist/replay/types.d.ts +1 -1
  288. package/dist/replica-divergence.d.ts +279 -0
  289. package/dist/replica-divergence.js +28 -0
  290. package/dist/replica-divergence.js.map +1 -0
  291. package/dist/replica-peers-config.d.ts +5 -0
  292. package/dist/replica-peers-config.js +13 -0
  293. package/dist/replica-peers-config.js.map +1 -0
  294. package/dist/resolve-auth-token.d.ts +21 -2
  295. package/dist/resolve-auth-token.js +5 -1
  296. package/dist/resume-bundles.js +5 -3
  297. package/dist/retrieval-agents.d.ts +2 -2
  298. package/dist/retrieval-tiers.d.ts +1 -1
  299. package/dist/routing/engine.d.ts +1 -1
  300. package/dist/routing/store.d.ts +1 -1
  301. package/dist/salvage-envelope.d.ts +1 -1
  302. package/dist/schemas.d.ts +76 -76
  303. package/dist/{scope-profiles-D0-ff4xT.d.ts → scope-profiles-BVf3DZ7z.d.ts} +1 -1
  304. package/dist/search/embed-helper.d.ts +1 -1
  305. package/dist/search/factory.d.ts +1 -1
  306. package/dist/search/factory.js +8 -8
  307. package/dist/search/index.d.ts +1 -1
  308. package/dist/search/index.js +13 -13
  309. package/dist/search/lancedb-backend.d.ts +1 -1
  310. package/dist/search/lancedb-backend.js +2 -2
  311. package/dist/search/meilisearch-backend.d.ts +1 -1
  312. package/dist/search/meilisearch-backend.js +2 -2
  313. package/dist/search/noop-backend.d.ts +1 -1
  314. package/dist/search/orama-backend.d.ts +1 -1
  315. package/dist/search/orama-backend.js +2 -2
  316. package/dist/search/port.d.ts +1 -1
  317. package/dist/search/remote-backend.d.ts +1 -1
  318. package/dist/{semantic-consolidation-4Tfh7c1g.d.ts → semantic-consolidation-DXLV47y2.d.ts} +1 -1
  319. package/dist/semantic-consolidation.d.ts +2 -2
  320. package/dist/semantic-consolidation.js +13 -11
  321. package/dist/semantic-rule-promotion.js +12 -10
  322. package/dist/semantic-rule-verifier.d.ts +1 -1
  323. package/dist/semantic-rule-verifier.js +12 -10
  324. package/dist/{service-Z8uzoAST.d.ts → service-DbgPTeMJ.d.ts} +2 -2
  325. package/dist/session-observer-bands.d.ts +1 -1
  326. package/dist/session-observer-state.d.ts +1 -1
  327. package/dist/shared-context/manager.d.ts +9 -9
  328. package/dist/signal.d.ts +1 -1
  329. package/dist/{storage-BZVMn8yJ.d.ts → storage-DOzTm8wG.d.ts} +13 -22
  330. package/dist/storage.d.ts +3 -2
  331. package/dist/storage.js +13 -10
  332. package/dist/summarizer.d.ts +1 -1
  333. package/dist/summary-snapshot.d.ts +1 -1
  334. package/dist/temporal-supersession.d.ts +3 -2
  335. package/dist/temporal-timeline-recall.d.ts +1 -1
  336. package/dist/temporal-validity.d.ts +1 -1
  337. package/dist/threading.d.ts +1 -1
  338. package/dist/tier-migration.d.ts +3 -2
  339. package/dist/tier-routing.d.ts +1 -1
  340. package/dist/topics.d.ts +1 -1
  341. package/dist/transcript.d.ts +1 -1
  342. package/dist/transfer/autodetect.js +1 -1
  343. package/dist/transfer/backup.js +2 -2
  344. package/dist/transfer/capsule-export.js +3 -3
  345. package/dist/transfer/capsule-import.js +2 -2
  346. package/dist/transfer/types.d.ts +66 -66
  347. package/dist/trust-score-stage.d.ts +1 -1
  348. package/dist/trust-score.d.ts +1 -1
  349. package/dist/{types-DHBxIF0s.d.ts → types-BA7W-_xy.d.ts} +53 -4
  350. package/dist/types.d.ts +1 -1
  351. package/dist/types.js +1 -1
  352. package/dist/utility-runtime.d.ts +1 -1
  353. package/dist/verified-recall.js +12 -10
  354. package/dist/write-envelope.d.ts +1 -1
  355. package/package.json +2 -2
  356. package/src/access-health-types.ts +8 -0
  357. package/src/access-http.test.ts +4 -0
  358. package/src/access-service-health.test.ts +23 -0
  359. package/src/access-service.ts +27 -4
  360. package/src/cli.ts +9 -7
  361. package/src/config.ts +2 -1
  362. package/src/corpus-watermark.test.ts +21 -0
  363. package/src/corpus-watermark.ts +25 -3
  364. package/src/entity-id-normalization.ts +55 -0
  365. package/src/entity-retrieval-boundaries.ts +134 -0
  366. package/src/entity-retrieval.ts +253 -27
  367. package/src/entity-schema.ts +2 -1
  368. package/src/memory-projection-mutations.ts +132 -0
  369. package/src/namespaces/storage.ts +5 -3
  370. package/src/operator-doctor-corpus.ts +41 -0
  371. package/src/operator-doctor-replica.test.ts +261 -0
  372. package/src/operator-doctor-replica.ts +129 -0
  373. package/src/operator-toolkit.ts +5 -4
  374. package/src/orchestration/orchestrator-init.ts +2 -2
  375. package/src/replica-divergence.test.ts +1475 -0
  376. package/src/replica-divergence.ts +910 -0
  377. package/src/replica-peers-config.ts +250 -0
  378. package/src/resolve-auth-token.ts +38 -0
  379. package/src/storage/entity-canonical-id-migration-adapter.ts +72 -0
  380. package/src/storage/entity-canonical-id-migration-runner.ts +68 -0
  381. package/src/storage/entity-canonical-id-migration.ts +904 -0
  382. package/src/storage/entity-store.ts +44 -0
  383. package/src/storage/memory-frontmatter-metadata.ts +17 -0
  384. package/src/storage/memory-migration-serialization.ts +37 -0
  385. package/src/storage/memory-read-store.ts +3 -2
  386. package/src/storage/profile-header.ts +898 -0
  387. package/src/storage-profile-timestamp.test.ts +3358 -0
  388. package/src/storage.ts +73 -77
  389. package/src/testing/subjects/profile-header-write.test.ts +71 -0
  390. package/src/types.ts +3 -3
  391. package/dist/chunk-2XP3PWL3.js.map +0 -1
  392. package/dist/chunk-4DJQYKMN.js.map +0 -1
  393. package/dist/chunk-E6EAB4J6.js.map +0 -1
  394. package/dist/chunk-EXM2Q555.js.map +0 -1
  395. package/dist/chunk-GJJBPI6I.js.map +0 -1
  396. package/dist/chunk-KPWLZBXZ.js.map +0 -1
  397. package/dist/chunk-LIIQPDFN.js.map +0 -1
  398. package/dist/chunk-UZK7VVPD.js.map +0 -1
  399. package/dist/chunk-V25OWXAC.js.map +0 -1
  400. package/dist/chunk-YN4AUGNF.js.map +0 -1
  401. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
  402. /package/dist/{chunk-7RZJWZ46.js.map → chunk-227UFSEQ.js.map} +0 -0
  403. /package/dist/{chunk-VGEH3STZ.js.map → chunk-2BEJ4XCF.js.map} +0 -0
  404. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-2NLLXCJG.js.map} +0 -0
  405. /package/dist/{chunk-6NNNURLK.js.map → chunk-2PHX3I5C.js.map} +0 -0
  406. /package/dist/{chunk-XAAIUXJO.js.map → chunk-3BSJBIZ3.js.map} +0 -0
  407. /package/dist/{chunk-JCVQV6QX.js.map → chunk-3UOSVTFR.js.map} +0 -0
  408. /package/dist/{chunk-AJ7QCSIS.js.map → chunk-7CPOZH6Y.js.map} +0 -0
  409. /package/dist/{chunk-M2KGXXTL.js.map → chunk-ALTP3MFS.js.map} +0 -0
  410. /package/dist/{chunk-77CNV7DY.js.map → chunk-BLAKS3CP.js.map} +0 -0
  411. /package/dist/{chunk-FE5FKTCI.js.map → chunk-BR4UFMLO.js.map} +0 -0
  412. /package/dist/{chunk-H2PBM562.js.map → chunk-DKQM5Q6Q.js.map} +0 -0
  413. /package/dist/{chunk-CSXHRAJJ.js.map → chunk-EG64LXKL.js.map} +0 -0
  414. /package/dist/{chunk-PTN6UAUA.js.map → chunk-ES36HTSC.js.map} +0 -0
  415. /package/dist/{chunk-WMDRLTNA.js.map → chunk-EZWWCN7I.js.map} +0 -0
  416. /package/dist/{chunk-XKGPIWGI.js.map → chunk-FBZTWUK5.js.map} +0 -0
  417. /package/dist/{chunk-Y6E37WRG.js.map → chunk-GJPCP5CA.js.map} +0 -0
  418. /package/dist/{chunk-VDX2J7OX.js.map → chunk-IPLYGWQF.js.map} +0 -0
  419. /package/dist/{chunk-XNUBF6RF.js.map → chunk-IYT632D5.js.map} +0 -0
  420. /package/dist/{chunk-2QSZNTDO.js.map → chunk-JBPKEARU.js.map} +0 -0
  421. /package/dist/{chunk-67DSDRUB.js.map → chunk-JIF3VRKQ.js.map} +0 -0
  422. /package/dist/{chunk-MIFF37NN.js.map → chunk-KKBTBUVT.js.map} +0 -0
  423. /package/dist/{chunk-ZHP4SUPB.js.map → chunk-LIHC3T43.js.map} +0 -0
  424. /package/dist/{chunk-UGH6VPPO.js.map → chunk-MWTMTW2T.js.map} +0 -0
  425. /package/dist/{chunk-F6LSNPPB.js.map → chunk-MXJ3VP6S.js.map} +0 -0
  426. /package/dist/{chunk-M5EPMNS4.js.map → chunk-P2CLYN6H.js.map} +0 -0
  427. /package/dist/{chunk-KGTD2KZJ.js.map → chunk-Q6YJW6KE.js.map} +0 -0
  428. /package/dist/{chunk-X3SQYAPP.js.map → chunk-R372P2EH.js.map} +0 -0
  429. /package/dist/{chunk-HBVOJNV4.js.map → chunk-RKXZ7P4V.js.map} +0 -0
  430. /package/dist/{chunk-RYHWZ52A.js.map → chunk-S2BBISHU.js.map} +0 -0
  431. /package/dist/{chunk-LLISELSY.js.map → chunk-TBMRQZDY.js.map} +0 -0
  432. /package/dist/{chunk-FMLKOL2Z.js.map → chunk-TC3VNKGX.js.map} +0 -0
  433. /package/dist/{chunk-7K5Q6COX.js.map → chunk-TVVEYCNW.js.map} +0 -0
  434. /package/dist/{chunk-NKDVXEFU.js.map → chunk-VFNI3RPT.js.map} +0 -0
  435. /package/dist/{chunk-PQPSZICJ.js.map → chunk-VYYNBJXH.js.map} +0 -0
  436. /package/dist/{chunk-SN3ZESWQ.js.map → chunk-WVVK2RFM.js.map} +0 -0
  437. /package/dist/{chunk-DQEMWVMT.js.map → chunk-X7Y7WX73.js.map} +0 -0
  438. /package/dist/{chunk-4KKV7W62.js.map → chunk-YOBNCD3Z.js.map} +0 -0
  439. /package/dist/{chunk-SK3EYPKC.js.map → chunk-ZN4UZWMT.js.map} +0 -0
  440. /package/dist/{first-start-migration-LEXL3XYL.js.map → first-start-migration-BKNGOFED.js.map} +0 -0
@@ -0,0 +1,910 @@
1
+ /**
2
+ * Replica divergence detection (issue #2149).
3
+ *
4
+ * The corpus-watermark primitive (corpus-watermark.ts, PR #2156) gives each
5
+ * daemon a cheap, comparable fingerprint of its own memory corpus. This module
6
+ * is the OTHER half the issue asks for: a daemon configured with peer URLs polls
7
+ * each peer's authenticated `/health`, compares the peer's watermark set against
8
+ * the local one PER NAMESPACE, and reports drift (file-count delta, watermark
9
+ * age delta, digest mismatch) so a months-long silent split-brain becomes a
10
+ * same-day alert. Detection ONLY — reconciliation is issue #2150.
11
+ *
12
+ * Design invariants (from AGENTS.md review-prevention patterns):
13
+ * - §22 error-result conflation: a peer that times out / refuses / returns
14
+ * non-2xx / omits `corpus` is a DISTINCT `unreachable`/`unknown` state, never
15
+ * folded into `converged`. A monitor must tell "peer agrees" from "we could
16
+ * not ask". The outcome is a discriminated union, not a boolean + empty array.
17
+ * - §5 state scoping: poll state lives on a per-instance {@link
18
+ * ReplicaDivergenceMonitor}, never a bare module global.
19
+ * - §1/§17/§24/§39 input validation: {@link parseReplicaPeersConfig} rejects
20
+ * invalid input (bad url, non-array peers, fractional/non-positive intervals)
21
+ * rather than silently defaulting, and coerces string booleans/numbers.
22
+ * - Peer tokens are secrets: resolved through the SAME `resolveAgentAccessAuthToken`
23
+ * indirection as `agentAccessHttp.authToken`, NEVER logged, NEVER echoed into a
24
+ * report/health/doctor payload. Peer identity is redacted to `host:port`
25
+ * (userinfo/path/query stripped) so a credential embedded in a URL cannot leak.
26
+ * - Polling never runs inline on the health request path: it reuses the corpus
27
+ * stale-while-revalidate / single-flight idiom, serving the last result with
28
+ * its timestamp and refreshing in the background.
29
+ */
30
+
31
+ import {
32
+ capabilityAllowsNamespace,
33
+ isCapabilityRestricted,
34
+ isValidNamespaceValue,
35
+ type TokenCapabilities,
36
+ } from "./access-token-capabilities.js";
37
+ import type { CorpusWatermark } from "./corpus-watermark.js";
38
+ import { resolveReplicaPeersConfig } from "./replica-peers-config.js";
39
+ import type { ReplicaPeerConfig, ReplicaPeersConfig } from "./replica-peers-config.js";
40
+ import { resolveAgentAccessAuthToken, type ResolveSecretRefFn } from "./resolve-auth-token.js";
41
+
42
+ /** Cap concurrent peer fetches so a large fleet cannot fan out unbounded network work at once. */
43
+ const DEFAULT_MAX_CONCURRENT_PEER_FETCHES = 4;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Report shape
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /** Per-peer verdict. `unreachable`/`unknown` are NEVER conflated with `converged` (§22). */
50
+ export type ReplicaPeerState = "converged" | "diverged" | "unreachable" | "unknown";
51
+
52
+ /** Whether a namespace is present on both sides, or only one (its own divergence outcome). */
53
+ export type ReplicaNamespacePresence = "both" | "local_only" | "peer_only";
54
+
55
+ export interface ReplicaNamespaceDelta {
56
+ namespace: string;
57
+ presence: ReplicaNamespacePresence;
58
+ localFileCount: number | null;
59
+ peerFileCount: number | null;
60
+ /** |local - peer| when present on both sides, else null. */
61
+ fileCountDelta: number | null;
62
+ localNewestWriteAt: string | null;
63
+ peerNewestWriteAt: string | null;
64
+ /** |local.newestWriteAt - peer.newestWriteAt| in ms when both are dated, else null. */
65
+ writeAgeDeltaMs: number | null;
66
+ /** local.digest === peer.digest when present on both sides, else null. */
67
+ digestMatch: boolean | null;
68
+ diverged: boolean;
69
+ /** Concrete, token-free reasons a namespace diverged (numbers included for the operator). */
70
+ reasons: string[];
71
+ }
72
+
73
+ export interface ReplicaPeerReport {
74
+ /** Redacted `host:port` — never the token, never userinfo/path/query. */
75
+ peer: string;
76
+ state: ReplicaPeerState;
77
+ polledAt: string;
78
+ /**
79
+ * Per-namespace deltas. Empty only for a fetch-level `unreachable`/`unknown`
80
+ * (no comparison ran); a comparison that resolves to `unknown` (an ambiguous
81
+ * local-only namespace) still carries its deltas.
82
+ */
83
+ namespaces: ReplicaNamespaceDelta[];
84
+ divergedNamespaceCount: number;
85
+ /** Stable reason code for a non-comparison state (e.g. "timeout", "http_500", "missing_corpus"). */
86
+ reason?: string;
87
+ }
88
+
89
+ /** Local watermark set plus whether every configured namespace was scanned. */
90
+ export interface LocalCensus {
91
+ watermarks: CorpusWatermark[];
92
+ complete: boolean;
93
+ }
94
+
95
+ export interface ReplicaDivergenceStatus {
96
+ enabled: boolean;
97
+ /**
98
+ * False when the local corpus census dropped namespaces (a per-namespace scan
99
+ * failed, or enumeration was still warming). A peer can only be certified
100
+ * `converged` against a COMPLETE local set — otherwise an unscanned tenant
101
+ * never enters the comparison and its divergence is invisible. Mirrors the
102
+ * doctor check's `localCensusComplete` gate (round 4, cursor).
103
+ */
104
+ censusComplete?: boolean;
105
+ /**
106
+ * True when the feature is enabled with peers configured but no poll has
107
+ * completed yet (warming, or a persistently failing local-watermark scan).
108
+ * Distinguishes that in-progress/failed state from the "enabled but no peers
109
+ * configured" case, which reports `pending: false` with an empty `peers` list
110
+ * (review round 1).
111
+ */
112
+ pending: boolean;
113
+ /** ISO timestamp of the last completed poll cycle, or null when never polled / disabled. */
114
+ polledAt: string | null;
115
+ peers: ReplicaPeerReport[];
116
+ }
117
+
118
+ /** Discriminated fetch outcome — the heart of the §22 empty-vs-failed distinction. */
119
+ export type PeerFetchOutcome =
120
+ | { readonly kind: "ok"; readonly corpus: CorpusWatermark[] }
121
+ | { readonly kind: "unreachable"; readonly reason: string }
122
+ | { readonly kind: "unknown"; readonly reason: string };
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Comparison (pure)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function writeAgeDeltaMs(local: string | null, peer: string | null): number | null {
129
+ if (local === null || peer === null) return null;
130
+ const a = Date.parse(local);
131
+ const b = Date.parse(peer);
132
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
133
+ return Math.abs(a - b);
134
+ }
135
+
136
+ /**
137
+ * THE replica certification ladder (issue #2149, review round 6). `converged` is
138
+ * an affirmative "these replicas agree" claim, so it is returned ONLY from
139
+ * evidence that can support it (AGENTS.md §22); every other outcome defaults to
140
+ * the safe non-converged side. The monitor, the doctor, and the capability
141
+ * filter all decide state HERE so they cannot drift. Precedence:
142
+ * 1. any diverged namespace -> `diverged` (a positive finding wins);
143
+ * 2. any local-only namespace -> `unknown`/`namespace_scope_unverifiable`
144
+ * (a scoped peer token hides it, and a genuine peer loss omits it the same
145
+ * way — unprovable in either direction);
146
+ * 3. NO namespace present on both -> `unknown`/`no_shared_namespaces` (an
147
+ * empty local corpus, a peer token that scoped out every namespace, or a
148
+ * capability filter that removed every visible delta all leave zero shared
149
+ * evidence, which cannot certify agreement);
150
+ * 4. otherwise -> `converged`.
151
+ * A genuinely empty single-namespace deployment still shares that namespace on
152
+ * both sides (0 files == 0 files) and converges; only a comparison with NO
153
+ * overlapping namespace at all is `no_shared_namespaces`.
154
+ */
155
+ function verdictFromDeltas(namespaces: readonly ReplicaNamespaceDelta[]): {
156
+ state: "converged" | "diverged" | "unknown";
157
+ reason?: string;
158
+ } {
159
+ if (namespaces.some((delta) => delta.diverged)) return { state: "diverged" };
160
+ if (namespaces.some((delta) => delta.presence === "local_only")) {
161
+ return { state: "unknown", reason: "namespace_scope_unverifiable" };
162
+ }
163
+ if (!namespaces.some((delta) => delta.presence === "both")) {
164
+ return { state: "unknown", reason: "no_shared_namespaces" };
165
+ }
166
+ return { state: "converged" };
167
+ }
168
+
169
+ /**
170
+ * Compare a local watermark set against a peer's, per namespace. A namespace on
171
+ * only one side is its own outcome (never silently skipped):
172
+ * - `peer_only` is divergence — the peer holds data we lack.
173
+ * - `local_only` is AMBIGUOUS — a namespace-restricted peer token hides
174
+ * namespaces it cannot see (not divergence), but an unrestricted peer that
175
+ * genuinely lost the namespace omits it the SAME way, so it cannot be
176
+ * certified converged either. It resolves the peer to `unknown` (codex P1).
177
+ * Digest mismatch flags divergence ONLY at EQUAL total file counts — the same
178
+ * number of files distributed differently across `<tier>:<category>/<day>`
179
+ * buckets (a distribution split-brain). The digest hashes per-bucket COUNTS, so
180
+ * it does NOT catch two replicas whose buckets hold equal counts but different
181
+ * file contents. Any nonzero count delta already perturbs the digest, so
182
+ * flagging digest there too would make `maxFileCountDelta` unreachable (codex
183
+ * P2); within tolerance the count delta is the sole signal.
184
+ */
185
+ export function compareReplicaWatermarks(
186
+ local: readonly CorpusWatermark[],
187
+ peer: readonly CorpusWatermark[],
188
+ thresholds: Pick<ReplicaPeersConfig, "maxFileCountDelta" | "maxWatermarkAgeDeltaMs">,
189
+ ): { state: "converged" | "diverged" | "unknown"; reason?: string; namespaces: ReplicaNamespaceDelta[]; divergedNamespaceCount: number } {
190
+ const byLocal = new Map(local.map((watermark) => [watermark.namespace, watermark]));
191
+ const byPeer = new Map(peer.map((watermark) => [watermark.namespace, watermark]));
192
+ const allNamespaces = [...new Set([...byLocal.keys(), ...byPeer.keys()])].sort((a, b) =>
193
+ a < b ? -1 : a > b ? 1 : 0,
194
+ );
195
+
196
+ const namespaces = allNamespaces.map((namespace): ReplicaNamespaceDelta => {
197
+ const localWatermark = byLocal.get(namespace);
198
+ const peerWatermark = byPeer.get(namespace);
199
+
200
+ // A namespace present locally but absent from the peer's RESPONSE is
201
+ // advisory, not divergence: a namespace-restricted peer token intentionally
202
+ // hides namespaces it cannot see, so treating local_only as diverged would
203
+ // report permanent false divergence against a scoped token (review round 1).
204
+ // It is still reported so an operator with an unrestricted peer token can
205
+ // act on it; peer_only below stays divergence (the peer holds data we lack).
206
+ if (localWatermark && !peerWatermark) {
207
+ return {
208
+ namespace,
209
+ presence: "local_only",
210
+ localFileCount: localWatermark.memoryFileCount,
211
+ peerFileCount: null,
212
+ fileCountDelta: null,
213
+ localNewestWriteAt: localWatermark.newestWriteAt,
214
+ peerNewestWriteAt: null,
215
+ writeAgeDeltaMs: null,
216
+ digestMatch: null,
217
+ diverged: false,
218
+ reasons: ["namespace_absent_from_peer_response"],
219
+ };
220
+ }
221
+ if (!localWatermark && peerWatermark) {
222
+ return {
223
+ namespace,
224
+ presence: "peer_only",
225
+ localFileCount: null,
226
+ peerFileCount: peerWatermark.memoryFileCount,
227
+ fileCountDelta: null,
228
+ localNewestWriteAt: null,
229
+ peerNewestWriteAt: peerWatermark.newestWriteAt,
230
+ writeAgeDeltaMs: null,
231
+ digestMatch: null,
232
+ diverged: true,
233
+ reasons: ["namespace_absent_locally"],
234
+ };
235
+ }
236
+
237
+ // Present on both sides.
238
+ const l = localWatermark as CorpusWatermark;
239
+ const p = peerWatermark as CorpusWatermark;
240
+ const fileCountDelta = Math.abs(l.memoryFileCount - p.memoryFileCount);
241
+ const digestMatch = l.digest === p.digest;
242
+ const ageDelta = writeAgeDeltaMs(l.newestWriteAt, p.newestWriteAt);
243
+ const reasons: string[] = [];
244
+ if (fileCountDelta > thresholds.maxFileCountDelta) {
245
+ reasons.push(`file_count_delta=${fileCountDelta}`);
246
+ } else if (fileCountDelta === 0 && !digestMatch) {
247
+ // Equal counts, different digest = equal size / different content: the
248
+ // split-brain signal. A nonzero delta within tolerance is NOT flagged on
249
+ // digest, since it necessarily perturbs the digest anyway (codex P2).
250
+ reasons.push("digest_mismatch");
251
+ }
252
+ if (ageDelta !== null && ageDelta > thresholds.maxWatermarkAgeDeltaMs) {
253
+ reasons.push(`write_age_delta_ms=${ageDelta}`);
254
+ }
255
+ // One side has a dated newest write while the other reports null: with matching
256
+ // counts+digests that is inconsistent (a shared bucket census implies the same
257
+ // hot day-partitions), so it is asymmetric/corrupt telemetry, not agreement — a
258
+ // missing measurement cannot prove convergence (round 6, codex).
259
+ if ((l.newestWriteAt === null) !== (p.newestWriteAt === null)) {
260
+ reasons.push("newest_write_presence_mismatch");
261
+ }
262
+ return {
263
+ namespace,
264
+ presence: "both",
265
+ localFileCount: l.memoryFileCount,
266
+ peerFileCount: p.memoryFileCount,
267
+ fileCountDelta,
268
+ localNewestWriteAt: l.newestWriteAt,
269
+ peerNewestWriteAt: p.newestWriteAt,
270
+ writeAgeDeltaMs: ageDelta,
271
+ digestMatch,
272
+ diverged: reasons.length > 0,
273
+ reasons,
274
+ };
275
+ });
276
+
277
+ const divergedNamespaceCount = namespaces.filter((delta) => delta.diverged).length;
278
+ const verdict = verdictFromDeltas(namespaces);
279
+ return { state: verdict.state, reason: verdict.reason, namespaces, divergedNamespaceCount };
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Peer fetch
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Dual-prefix health probe order: try the path THIS server actually registers
288
+ * first (access-http.ts serves `/engram/v1/health` only), then the `/remnic/v1`
289
+ * prefix as forward-compat fallback. Probing an unregistered path first would
290
+ * make every same-version peer pay a 404 (round 6, codex P2).
291
+ */
292
+ const HEALTH_PATHS = ["/engram/v1/health", "/remnic/v1/health"] as const;
293
+
294
+ export type FetchLike = (input: string, init?: { headers?: Record<string, string>; signal?: AbortSignal }) => Promise<{
295
+ ok: boolean;
296
+ status: number;
297
+ json(): Promise<unknown>;
298
+ /**
299
+ * Raw body stream when the transport exposes one (the global `fetch`
300
+ * Response does). Present so a peer payload can be size-bounded while it is
301
+ * read; a test double that omits it falls back to `json()`.
302
+ */
303
+ body?: unknown;
304
+ }>;
305
+
306
+ /**
307
+ * Cap on a peer's `/health` payload. A configured peer is a trust boundary: a
308
+ * compromised or malfunctioning one could otherwise stream an unbounded corpus
309
+ * that `json()` buffers whole, and up to `maxConcurrent` peers are polled at
310
+ * once, so the exposure multiplies (round 7, codex P2). Generous enough for a
311
+ * fleet-sized namespace census, small enough that four of them cannot exhaust
312
+ * the daemon.
313
+ */
314
+ export const MAX_PEER_RESPONSE_BYTES = 8 * 1024 * 1024;
315
+
316
+ class PeerResponseTooLarge extends Error {}
317
+
318
+ /** Read + parse a peer body, refusing to buffer more than the cap. */
319
+ async function readBoundedJson(response: { json(): Promise<unknown>; body?: unknown }): Promise<unknown> {
320
+ const stream = response.body as { getReader?: () => ReadableStreamDefaultReader<Uint8Array> } | null | undefined;
321
+ if (!stream || typeof stream.getReader !== "function") {
322
+ // No stream to meter (test double, or a transport without one).
323
+ return response.json();
324
+ }
325
+ const reader = stream.getReader();
326
+ const chunks: Uint8Array[] = [];
327
+ let total = 0;
328
+ try {
329
+ for (;;) {
330
+ const { done, value } = await reader.read();
331
+ if (done) break;
332
+ if (!value) continue;
333
+ total += value.byteLength;
334
+ if (total > MAX_PEER_RESPONSE_BYTES) throw new PeerResponseTooLarge();
335
+ chunks.push(value);
336
+ }
337
+ } finally {
338
+ await reader.cancel().catch(() => undefined);
339
+ }
340
+ const merged = new Uint8Array(total);
341
+ let offset = 0;
342
+ for (const chunk of chunks) {
343
+ merged.set(chunk, offset);
344
+ offset += chunk.byteLength;
345
+ }
346
+ return JSON.parse(new TextDecoder().decode(merged));
347
+ }
348
+
349
+ export interface FetchPeerOptions {
350
+ timeoutMs: number;
351
+ resolveSecretRef?: ResolveSecretRefFn | null;
352
+ /** Injectable for tests; defaults to global fetch. */
353
+ fetchImpl?: FetchLike;
354
+ /**
355
+ * Max age (ms) a peer census `computedAt` may reach before the peer is treated
356
+ * as `unknown`/`peer_census_stale` — a snapshot older than this predates
357
+ * changes it may not reflect, so it cannot certify convergence (round 6, codex
358
+ * P1). Reuses the caller's `maxWatermarkAgeDeltaMs`; when unset OR non-positive,
359
+ * no staleness gate is applied (a 0 bound is the strictest DIVERGENCE mode, not
360
+ * a 0ms freshness gate — round 6, cursor).
361
+ */
362
+ maxCensusAgeMs?: number;
363
+ /** Wall clock (ms) the staleness gate measures `computedAt` against; defaults to now. */
364
+ nowMs?: number;
365
+ }
366
+
367
+ /** Redact a peer URL to `host:port` — strips userinfo/path/query so a credential in a URL cannot leak. */
368
+ export function redactPeerUrl(url: string): string {
369
+ try {
370
+ return new URL(url).host;
371
+ } catch {
372
+ return "peer";
373
+ }
374
+ }
375
+
376
+ /** Reduce a network error to a stable, host-free reason code (never leaks a URL or token). */
377
+ function networkReason(error: unknown): string {
378
+ if (error && typeof error === "object") {
379
+ if ("name" in error && error.name === "TimeoutError") return "timeout";
380
+ if ("name" in error && error.name === "AbortError") return "aborted";
381
+ if (
382
+ "cause" in error &&
383
+ error.cause &&
384
+ typeof error.cause === "object" &&
385
+ "code" in error.cause &&
386
+ typeof error.cause.code === "string"
387
+ ) {
388
+ return `network_${error.cause.code}`;
389
+ }
390
+ if ("code" in error && typeof error.code === "string") return `network_${error.code}`;
391
+ }
392
+ return "unreachable";
393
+ }
394
+
395
+ function coerceCorpusWatermark(raw: unknown): CorpusWatermark | null {
396
+ if (!raw || typeof raw !== "object") return null;
397
+ const record = raw as Record<string, unknown>;
398
+ // A peer's namespace key crosses a trust boundary: a noncanonical value
399
+ // ("default ", a path separator, a control character) would compare as a
400
+ // DISTINCT namespace and produce phantom local_only/peer_only deltas for the
401
+ // same logical tenant, and doctor interpolates it into terminal output. Use
402
+ // the same validator the rest of the namespace boundary uses (round 7).
403
+ if (!isValidNamespaceValue(record.namespace)) return null;
404
+ // A file count must be a nonnegative integer: a negative/fractional count is
405
+ // corrupted telemetry, not a valid corpus (codex P2). Rejecting it here routes
406
+ // the whole peer response to `unknown` via the malformed_corpus guard.
407
+ if (
408
+ typeof record.memoryFileCount !== "number" ||
409
+ !Number.isInteger(record.memoryFileCount) ||
410
+ record.memoryFileCount < 0
411
+ ) {
412
+ return null;
413
+ }
414
+ if (typeof record.digest !== "string" || record.digest.length === 0) return null;
415
+ // `newestWriteAt` is either absent/null or a parseable timestamp; an
416
+ // unparseable string is corrupted telemetry, not "undated" (codex P2).
417
+ let newestWriteAt: string | null = null;
418
+ if (record.newestWriteAt !== undefined && record.newestWriteAt !== null) {
419
+ if (typeof record.newestWriteAt !== "string" || !Number.isFinite(Date.parse(record.newestWriteAt))) return null;
420
+ newestWriteAt = record.newestWriteAt;
421
+ }
422
+ // `computedAt` must be a present, parseable timestamp: an empty/unparseable
423
+ // value is corrupted telemetry, and the staleness gate (fetchPeerWatermarks)
424
+ // needs a real instant. A missing/bad computedAt routes the whole peer to
425
+ // `malformed_corpus`, never a certified convergence (round 6, codex P1).
426
+ if (typeof record.computedAt !== "string" || !Number.isFinite(Date.parse(record.computedAt))) {
427
+ return null;
428
+ }
429
+ return {
430
+ namespace: record.namespace,
431
+ memoryFileCount: record.memoryFileCount,
432
+ newestPartition: typeof record.newestPartition === "string" ? record.newestPartition : null,
433
+ newestWriteAt,
434
+ digest: record.digest,
435
+ computedAt: record.computedAt,
436
+ };
437
+ }
438
+
439
+ /** Sentinel rejection for {@link withDeadline} timeouts (distinct from a resolver error). */
440
+ const DEADLINE_TIMEOUT = Symbol("deadline_timeout");
441
+ /**
442
+ * Bound a promise by an absolute deadline: reject with {@link DEADLINE_TIMEOUT} if
443
+ * it has not settled by `deadlineMs`. Keeps a stalling peer-token resolver from
444
+ * hanging the whole per-peer fetch (round 6, codex). The wrapped promise is
445
+ * abandoned on timeout; the race keeps its later settlement handled, so it never
446
+ * surfaces as an unhandled rejection.
447
+ */
448
+ async function withDeadline<T>(promise: Promise<T>, deadlineMs: number): Promise<T> {
449
+ const { promise: expiry, reject } = Promise.withResolvers<never>();
450
+ // The deadline timer is NOT unref'd: it is the mechanism enforcing the bound,
451
+ // so it must fire even when the wrapped promise (e.g. a stalled token resolver)
452
+ // holds nothing else on the event loop. It is always cleared below on settle.
453
+ const timer = setTimeout(() => reject(DEADLINE_TIMEOUT), Math.max(1, deadlineMs - Date.now()));
454
+ try {
455
+ return await Promise.race([promise, expiry]);
456
+ } finally {
457
+ clearTimeout(timer);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Fetch a peer's authenticated `/health` corpus with a bounded timeout, trying
463
+ * the remnic prefix then the legacy engram prefix. Never throws — every failure
464
+ * mode maps to a discriminated `unreachable`/`unknown` outcome (§22).
465
+ */
466
+ export async function fetchPeerWatermarks(
467
+ peer: ReplicaPeerConfig,
468
+ options: FetchPeerOptions,
469
+ ): Promise<PeerFetchOutcome> {
470
+ const doFetch = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
471
+ // ONE deadline for the whole peer, not one per prefix: a preferred path that
472
+ // 404s just under the limit would otherwise hand the legacy fallback a fresh
473
+ // full budget and double the documented per-peer bound (round 5, codex P2).
474
+ // Token resolution runs UNDER this deadline too: a SecretRef whose host
475
+ // resolver stalls would otherwise hang this call forever, wedging the monitor's
476
+ // single-flight refresh and blocking doctor's whole peer batch (round 6, codex).
477
+ const deadline = Date.now() + options.timeoutMs;
478
+ let token: string | undefined;
479
+ try {
480
+ token = await withDeadline(
481
+ resolveAgentAccessAuthToken(peer.token, { resolveSecretRef: options.resolveSecretRef }),
482
+ deadline,
483
+ );
484
+ } catch (error) {
485
+ // No resolver, a resolution failure, or a resolver that stalls past the
486
+ // deadline all degrade to a per-peer failure — never a throw or a hang
487
+ // (review round 1; round 6 adds the stall/timeout case).
488
+ return { kind: "unreachable", reason: error === DEADLINE_TIMEOUT ? "timeout" : "token_error" };
489
+ }
490
+ const base = peer.url.replace(/\/+$/, "");
491
+
492
+ for (let i = 0; i < HEALTH_PATHS.length; i += 1) {
493
+ const path = HEALTH_PATHS[i];
494
+ const isLastPath = i === HEALTH_PATHS.length - 1;
495
+ let response: { ok: boolean; status: number; json(): Promise<unknown> };
496
+ try {
497
+ response = await doFetch(`${base}${path}`, {
498
+ headers: token ? { authorization: `Bearer ${token}` } : undefined,
499
+ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
500
+ });
501
+ } catch (error) {
502
+ // A network error means the host is unreachable regardless of path — no
503
+ // point trying the other prefix against the same host.
504
+ return { kind: "unreachable", reason: networkReason(error) };
505
+ }
506
+ // Only a 404 warrants trying the legacy prefix; any other non-2xx is a real failure.
507
+ if (response.status === 404 && !isLastPath) continue;
508
+ if (!response.ok) return { kind: "unreachable", reason: `http_${response.status}` };
509
+ let body: unknown;
510
+ try {
511
+ body = await readBoundedJson(response);
512
+ } catch (error) {
513
+ if (error instanceof PeerResponseTooLarge) {
514
+ return { kind: "unknown", reason: "response_too_large" };
515
+ }
516
+ // A peer that sent headers then stalled mid-body aborts the reader. That
517
+ // is a timeout, not malformed JSON — monitoring must be able to tell a
518
+ // body-phase stall from corrupt telemetry (round 9, codex P2).
519
+ const name = (error as { name?: unknown } | null)?.name;
520
+ if (name === "TimeoutError" || name === "AbortError") {
521
+ return { kind: "unreachable", reason: networkReason(error) };
522
+ }
523
+ return { kind: "unknown", reason: "invalid_json" };
524
+ }
525
+ if (!body || typeof body !== "object" || !("corpus" in body) || !Array.isArray(body.corpus)) {
526
+ return { kind: "unknown", reason: "missing_corpus" };
527
+ }
528
+ // If any entry is malformed the peer's telemetry is unusable: report
529
+ // `unknown` rather than silently shrinking to a smaller (or empty) corpus
530
+ // that could read as converged or false divergence (review round 1). An
531
+ // empty array is a valid empty corpus and stays `ok`.
532
+ const corpus = body.corpus
533
+ .map(coerceCorpusWatermark)
534
+ .filter((watermark): watermark is CorpusWatermark => watermark !== null);
535
+ if (corpus.length !== body.corpus.length) {
536
+ return { kind: "unknown", reason: "malformed_corpus" };
537
+ }
538
+ // Duplicate namespace keys are malformed too: the comparison builds a Map,
539
+ // so a later entry silently wins and a mismatching watermark followed by a
540
+ // matching one would certify `converged` (round 3, codex P2).
541
+ if (new Set(corpus.map((watermark) => watermark.namespace)).size !== corpus.length) {
542
+ return { kind: "unknown", reason: "malformed_corpus" };
543
+ }
544
+ // A peer that ADVERTISES an incomplete census omitted corpus entries whose
545
+ // scan failed or whose cache is warming. Its partial array must not be read
546
+ // as a complete one: a namespace that exists only on the peer but was
547
+ // omitted would leave the comparison seeing agreement (round 7, codex P1).
548
+ // A peer that does not advertise the field at all predates it — documented
549
+ // as a mixed-version limitation rather than pinning every older peer to
550
+ // `unknown` forever.
551
+ // Prefer `corpusComplete`, which describes THIS response's corpus array;
552
+ // `replica.censusComplete` came from the peer's independently-cached
553
+ // monitor scan and could disagree with the array it shipped (round 8).
554
+ const peerBody = body as { corpusComplete?: unknown; replica?: { censusComplete?: unknown } };
555
+ // Absent is compatibility (an older peer); PRESENT but non-boolean is
556
+ // corrupt telemetry and must not fall through to a weaker signal (round 9).
557
+ if (peerBody.corpusComplete !== undefined && typeof peerBody.corpusComplete !== "boolean") {
558
+ return { kind: "unknown", reason: "malformed_corpus" };
559
+ }
560
+ const legacyComplete = peerBody.replica?.censusComplete;
561
+ // The legacy flag needs the same boolean gate as its replacement: a string
562
+ // "false" bypasses the `=== false` branch below and certifies a partial
563
+ // census (round 10). Absent stays compatibility; present-but-wrong is corrupt.
564
+ if (peerBody.corpusComplete === undefined && legacyComplete !== undefined && typeof legacyComplete !== "boolean") {
565
+ return { kind: "unknown", reason: "malformed_corpus" };
566
+ }
567
+ const peerComplete = typeof peerBody.corpusComplete === "boolean" ? peerBody.corpusComplete : legacyComplete;
568
+ if (peerComplete === false) {
569
+ return { kind: "unknown", reason: "peer_census_incomplete" };
570
+ }
571
+ // A peer census whose `computedAt` is too far from the poll time in EITHER
572
+ // direction cannot certify convergence — treat it as `unknown`, not health
573
+ // (round 6, codex). Too OLD predates changes it may not reflect; too far in
574
+ // the FUTURE is corrupt/clock-skewed telemetry (e.g. a 9999 timestamp) that
575
+ // would otherwise read as indefinitely fresh. `computedAt` is validated
576
+ // parseable above. A non-positive bound disables the gate: maxWatermarkAgeDeltaMs=0
577
+ // is the strictest DIVERGENCE mode ("flag any write-age gap"), NOT a 0ms
578
+ // census-freshness bound that would mark every peer stale (round 6, cursor).
579
+ const maxCensusAgeMs = options.maxCensusAgeMs;
580
+ if (maxCensusAgeMs !== undefined && maxCensusAgeMs > 0) {
581
+ const now = options.nowMs ?? Date.now();
582
+ if (corpus.some((watermark) => Math.abs(now - Date.parse(watermark.computedAt)) > maxCensusAgeMs)) {
583
+ return { kind: "unknown", reason: "peer_census_stale" };
584
+ }
585
+ }
586
+ return { kind: "ok", corpus };
587
+ }
588
+ return { kind: "unreachable", reason: "http_404" };
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // Poll all peers
593
+ // ---------------------------------------------------------------------------
594
+
595
+ async function mapWithConcurrency<T, R>(
596
+ items: readonly T[],
597
+ limit: number,
598
+ worker: (item: T, index: number) => Promise<R>,
599
+ ): Promise<R[]> {
600
+ const results = new Array<R>(items.length);
601
+ let cursor = 0;
602
+ const runners = new Array(Math.min(Math.max(1, limit), items.length || 1)).fill(null).map(async () => {
603
+ while (cursor < items.length) {
604
+ const index = cursor;
605
+ cursor += 1;
606
+ results[index] = await worker(items[index], index);
607
+ }
608
+ });
609
+ await Promise.all(runners);
610
+ return results;
611
+ }
612
+
613
+ export interface PollReplicaPeersOptions {
614
+ config: ReplicaPeersConfig;
615
+ localWatermarks: readonly CorpusWatermark[];
616
+ now?: Date;
617
+ resolveSecretRef?: ResolveSecretRefFn | null;
618
+ fetchImpl?: FetchLike;
619
+ maxConcurrent?: number;
620
+ /** Optional sink for a single deduped warn line per non-converged poll cycle (never contains tokens). */
621
+ log?: (line: string) => void;
622
+ }
623
+
624
+ /**
625
+ * Poll every configured peer once, compare each against the local watermark set,
626
+ * and assemble a report. Disabled or peerless → no network at all (returns an
627
+ * empty report). Never throws: a single peer failure degrades only that peer.
628
+ */
629
+ export async function pollReplicaPeers(options: PollReplicaPeersOptions): Promise<ReplicaDivergenceStatus> {
630
+ const { config, localWatermarks } = options;
631
+ if (!config.enabled || config.peers.length === 0) {
632
+ return { enabled: config.enabled, pending: false, polledAt: null, peers: [] };
633
+ }
634
+ const polledAt = (options.now ?? new Date()).toISOString();
635
+ const thresholds = {
636
+ maxFileCountDelta: config.maxFileCountDelta,
637
+ maxWatermarkAgeDeltaMs: config.maxWatermarkAgeDeltaMs,
638
+ };
639
+
640
+ const peers = await mapWithConcurrency(
641
+ config.peers,
642
+ options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_PEER_FETCHES,
643
+ async (peer): Promise<ReplicaPeerReport> => {
644
+ const label = redactPeerUrl(peer.url);
645
+ try {
646
+ const outcome = await fetchPeerWatermarks(peer, {
647
+ timeoutMs: config.requestTimeoutMs,
648
+ resolveSecretRef: options.resolveSecretRef,
649
+ fetchImpl: options.fetchImpl,
650
+ maxCensusAgeMs: config.maxWatermarkAgeDeltaMs,
651
+ nowMs: options.now?.getTime(),
652
+ });
653
+ if (outcome.kind !== "ok") {
654
+ return { peer: label, state: outcome.kind, polledAt, namespaces: [], divergedNamespaceCount: 0, reason: outcome.reason };
655
+ }
656
+ const comparison = compareReplicaWatermarks(localWatermarks, outcome.corpus, thresholds);
657
+ const peerReport: ReplicaPeerReport = {
658
+ peer: label,
659
+ state: comparison.state,
660
+ polledAt,
661
+ namespaces: comparison.namespaces,
662
+ divergedNamespaceCount: comparison.divergedNamespaceCount,
663
+ };
664
+ // A comparison that resolves to `unknown` (ambiguous local-only, or no
665
+ // shared namespace) carries a token-free reason so /health and doctor
666
+ // show WHY it is not certified converged (round 6).
667
+ if (comparison.reason) peerReport.reason = comparison.reason;
668
+ return peerReport;
669
+ } catch {
670
+ // Defense in depth: no per-peer error may reject the whole poll (§22).
671
+ return { peer: label, state: "unreachable", polledAt, namespaces: [], divergedNamespaceCount: 0, reason: "error" };
672
+ }
673
+ },
674
+ );
675
+
676
+ const report: ReplicaDivergenceStatus = { enabled: true, pending: false, polledAt, peers };
677
+ logDivergence(options.log, report);
678
+ return report;
679
+ }
680
+
681
+ function logDivergence(log: PollReplicaPeersOptions["log"], report: ReplicaDivergenceStatus): void {
682
+ if (!log) return;
683
+ const flagged = report.peers.filter((peer) => peer.state !== "converged");
684
+ if (flagged.length === 0) return;
685
+ const detail = flagged
686
+ .map((peer) => (peer.state === "diverged" ? `${peer.peer}=diverged(${peer.divergedNamespaceCount}ns)` : `${peer.peer}=${peer.state}`))
687
+ .join(", ");
688
+ log(`replica divergence: ${flagged.length} of ${report.peers.length} peer(s) flagged: ${detail}`);
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Capability filtering (read-time)
693
+ // ---------------------------------------------------------------------------
694
+
695
+ /**
696
+ * Filter a full report to a presenting token's namespace capabilities, exactly
697
+ * as the corpus `/health` field is filtered (issue #2156 finding B): a
698
+ * namespace-restricted token must not learn about namespaces it cannot see. A
699
+ * peer's reachability state carries no namespace data and is preserved; a
700
+ * comparison peer's visible state is recomputed from its visible deltas so
701
+ * divergence in a hidden namespace never leaks as a "diverged" verdict. The
702
+ * census gate is then re-applied to the FILTERED view: filtering can hide a real
703
+ * shared divergence and leave a peer_only delta that must not read as a false
704
+ * split-brain to a restricted caller under an incomplete census (round 6, codex).
705
+ */
706
+ export function filterReplicaReportByCaps(
707
+ report: ReplicaDivergenceStatus,
708
+ caps: TokenCapabilities | null | undefined,
709
+ ): ReplicaDivergenceStatus {
710
+ if (!isCapabilityRestricted(caps ?? undefined)) return report;
711
+ const peers = report.peers.map((peer): ReplicaPeerReport => {
712
+ // Fetch-level states (unreachable, or unknown with no comparison) carry no
713
+ // namespace data — preserve verbatim. A comparison peer (converged/diverged,
714
+ // or comparison-`unknown` from an ambiguous local-only namespace) has deltas
715
+ // that MUST be filtered so a restricted token never learns a hidden namespace.
716
+ if (peer.namespaces.length === 0) return peer;
717
+ const namespaces = peer.namespaces.filter((delta) => capabilityAllowsNamespace(caps ?? undefined, delta.namespace));
718
+ const divergedNamespaceCount = namespaces.filter((delta) => delta.diverged).length;
719
+ // A census-level `unknown` is NOT namespace-scoped: it says the local set was
720
+ // partial, which no amount of capability filtering can make safe. It survives
721
+ // the recompute verbatim (round 5, cursor).
722
+ if (peer.state === "unknown" && peer.reason === "local_census_incomplete") {
723
+ return { ...peer, namespaces, divergedNamespaceCount };
724
+ }
725
+ // Otherwise re-certify from the VISIBLE deltas through the one shared ladder.
726
+ // A token that hid EVERY namespace of this peer now sees zero shared
727
+ // evidence, which `verdictFromDeltas` resolves to `unknown` — never a
728
+ // convergence claim derived from nothing (round 6, coderabbit).
729
+ const verdict = verdictFromDeltas(namespaces);
730
+ const filtered: ReplicaPeerReport = { ...peer, namespaces, divergedNamespaceCount, state: verdict.state };
731
+ if (verdict.reason) filtered.reason = verdict.reason;
732
+ else delete filtered.reason;
733
+ return filtered;
734
+ });
735
+ // Re-apply the census gate to the FILTERED view: hiding a real shared-namespace
736
+ // divergence can leave a visible peer_only delta that would falsely read as
737
+ // diverged to a restricted caller when the local census was incomplete. The
738
+ // gate is a no-op for a complete census (round 6, codex).
739
+ return gateReportByCensus({ ...report, peers }, report.censusComplete !== false);
740
+ }
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // Local-census completeness gate (shared by monitor + doctor)
744
+ // ---------------------------------------------------------------------------
745
+
746
+ /**
747
+ * Overlay the LOCAL-census half of the certification rule onto a whole report:
748
+ * a peer is `converged` only when the local census scanned EVERY configured
749
+ * namespace — a dropped local tenant never enters the comparison, so its
750
+ * divergence would be invisible (round 4). Applied identically by the
751
+ * background monitor (/health) and `summarizeReplicaDivergence` (doctor) so the
752
+ * two surfaces cannot disagree about an incomplete census (round 6). Under an
753
+ * incomplete census every `peer_only` delta is NEUTRALIZED (an incomplete local
754
+ * scan cannot tell a namespace we genuinely lack from one we merely failed to
755
+ * read — a false split-brain), so it stops counting as divergence. A peer then
756
+ * stays `diverged` ONLY if a REAL shared-namespace divergence remains; otherwise
757
+ * (converged, or peer_only-only) it is `unknown`/`local_census_incomplete`.
758
+ * unreachable/unknown already carry a truthful state and stand.
759
+ */
760
+ export function gateReportByCensus(
761
+ report: ReplicaDivergenceStatus,
762
+ censusComplete: boolean,
763
+ ): ReplicaDivergenceStatus {
764
+ if (censusComplete) return { ...report, censusComplete: true };
765
+ return {
766
+ ...report,
767
+ censusComplete: false,
768
+ peers: report.peers.map((peer) => {
769
+ if (peer.state === "unreachable" || peer.state === "unknown") return peer;
770
+ // Neutralize every `peer_only` delta: against a partial local set a namespace
771
+ // we failed to scan is indistinguishable from one we genuinely lack, so it
772
+ // must not count as divergence or claim "absent locally" (round 6, codex).
773
+ // A REAL shared-namespace divergence still stands.
774
+ const namespaces = peer.namespaces.map((delta) =>
775
+ delta.presence === "peer_only" && delta.diverged
776
+ ? { ...delta, diverged: false, reasons: ["namespace_absent_locally_unverified"] }
777
+ : delta,
778
+ );
779
+ const divergedNamespaceCount = namespaces.filter((delta) => delta.diverged).length;
780
+ const hasSharedDivergence = namespaces.some((delta) => delta.presence === "both" && delta.diverged);
781
+ if (hasSharedDivergence) return { ...peer, namespaces, divergedNamespaceCount };
782
+ return { ...peer, namespaces, divergedNamespaceCount, state: "unknown" as const, reason: "local_census_incomplete" };
783
+ }),
784
+ };
785
+ }
786
+
787
+ // ---------------------------------------------------------------------------
788
+ // Background monitor (SWR / single-flight — corpus idiom, per-instance)
789
+ // ---------------------------------------------------------------------------
790
+
791
+ export interface ReplicaDivergenceMonitorOptions {
792
+ clock?: () => number;
793
+ resolveSecretRef?: ResolveSecretRefFn | null;
794
+ fetchImpl?: FetchLike;
795
+ maxConcurrent?: number;
796
+ log?: (line: string) => void;
797
+ }
798
+
799
+ /**
800
+ * Instance-scoped (§5) stale-while-revalidate monitor of peer divergence. {@link
801
+ * getReport} NEVER awaits the poll: it returns the last completed report (stale
802
+ * allowed, or a never-polled placeholder) and single-flights a background poll
803
+ * when the entry is missing or older than `pollIntervalMs`. So a `/health` probe
804
+ * is always O(1) and polling never runs inline on the request path. Disabled or
805
+ * peerless config short-circuits with zero network work.
806
+ */
807
+ export class ReplicaDivergenceMonitor {
808
+ private cached: { report: ReplicaDivergenceStatus; expiresAt: number } | undefined;
809
+ /** Earliest clock() at which a failed poll may retry — enforces backoff (round 6). */
810
+ private nextAttemptAt = 0;
811
+ private inFlight: Promise<void> | undefined;
812
+ private readonly clock: () => number;
813
+ private readonly resolveSecretRef: ResolveSecretRefFn | null | undefined;
814
+ private readonly fetchImpl: FetchLike | undefined;
815
+ private readonly maxConcurrent: number | undefined;
816
+ private readonly log: ((line: string) => void) | undefined;
817
+
818
+ constructor(options: ReplicaDivergenceMonitorOptions = {}) {
819
+ this.clock = options.clock ?? Date.now;
820
+ this.resolveSecretRef = options.resolveSecretRef;
821
+ this.fetchImpl = options.fetchImpl;
822
+ this.maxConcurrent = options.maxConcurrent;
823
+ this.log = options.log;
824
+ }
825
+
826
+ getReport(input: {
827
+ /**
828
+ * Raw `replicaPeers` block. An absent/partial/loosely-typed block (a host
829
+ * adapter or an older persisted config that bypassed `parseConfig`) resolves
830
+ * to the documented default — disabled, no peers, no polling — so `/health`
831
+ * stays answerable instead of throwing (issue #2155 read-boundary pattern).
832
+ */
833
+ config: ReplicaPeersConfig | undefined;
834
+ /** Fresh local watermark set for comparison; invoked only during a background refresh. */
835
+ computeLocalWatermarks: () => Promise<LocalCensus>;
836
+ caps?: TokenCapabilities | null;
837
+ /**
838
+ * Completeness of the census THIS request is presenting alongside the
839
+ * report. A cached poll was gated by the census that existed when it ran,
840
+ * so a since-degraded scan could ship `converged` peers next to an
841
+ * incomplete corpus (round 9, cursor). Gating on the way out keeps the one
842
+ * response self-consistent without poisoning the shared cache.
843
+ */
844
+ localCensusComplete?: boolean;
845
+ }): ReplicaDivergenceStatus {
846
+ const config = resolveReplicaPeersConfig(input.config);
847
+ if (!config.enabled) return { enabled: false, pending: false, polledAt: null, peers: [] };
848
+ if (config.peers.length === 0) return { enabled: true, pending: false, polledAt: null, peers: [] };
849
+ const fresh = this.cached !== undefined && this.clock() < this.cached.expiresAt;
850
+ // A failed poll backs off for one interval: refreshing again on the very
851
+ // next probe would re-run a full local corpus scan + peer fan-out per
852
+ // request (round 6, coderabbit). Serve the last good report if any, else the
853
+ // pending placeholder — both truthful, neither `converged`.
854
+ if (!fresh && this.clock() >= this.nextAttemptAt) this.refresh(config, input.computeLocalWatermarks);
855
+ if (this.cached) {
856
+ const gated =
857
+ input.localCensusComplete === false ? gateReportByCensus(this.cached.report, false) : this.cached.report;
858
+ return filterReplicaReportByCaps(gated, input.caps ?? null);
859
+ }
860
+ // Enabled with peers but no completed poll yet — distinct from "no peers".
861
+ return { enabled: true, pending: true, polledAt: null, peers: [] };
862
+ }
863
+
864
+ private refresh(config: ReplicaPeersConfig, computeLocalWatermarks: () => Promise<LocalCensus>): void {
865
+ if (this.inFlight) return;
866
+ this.inFlight = (async () => {
867
+ const census = await computeLocalWatermarks();
868
+ const localWatermarks = census.watermarks;
869
+ const report = await pollReplicaPeers({
870
+ config,
871
+ localWatermarks,
872
+ resolveSecretRef: this.resolveSecretRef,
873
+ fetchImpl: this.fetchImpl,
874
+ maxConcurrent: this.maxConcurrent,
875
+ log: this.log,
876
+ });
877
+ // Expiry is measured from when the poll FINISHES, not when it starts: a
878
+ // poll slower than pollIntervalMs would otherwise store an already-expired
879
+ // entry and re-poll on every probe (round 2, cursor). The census overlay
880
+ // (an incomplete local set cannot certify convergence) is the SAME shared
881
+ // gate the doctor applies, so /health and doctor cannot disagree (round 6).
882
+ const gated = gateReportByCensus(report, census.complete);
883
+ // A poll interval longer than the freshness bound would keep certifying
884
+ // telemetry the freshness check would now reject (round 10). Cache for the
885
+ // shorter of the two so a cached `converged` can never outlive it. Zero is
886
+ // the documented "compare write ages strictly, no staleness gate" mode, NOT
887
+ // a zero-length cache - that would re-scan the corpus on every probe
888
+ // (round 11, §17 zero footgun).
889
+ const freshnessCap = config.maxWatermarkAgeDeltaMs > 0 ? config.maxWatermarkAgeDeltaMs : Number.POSITIVE_INFINITY;
890
+ const ttl = Math.min(config.pollIntervalMs, freshnessCap);
891
+ this.cached = { report: gated, expiresAt: this.clock() + ttl };
892
+ this.nextAttemptAt = 0; // a successful poll clears any failure backoff
893
+ })()
894
+ .catch(() => {
895
+ // A failed poll caches no REPORT, but MUST consume the interval: else
896
+ // every probe reschedules a full local corpus scan + peer fan-out on a
897
+ // corpus this feature exists to protect (round 6, coderabbit). Until
898
+ // nextAttemptAt, getReport serves the last good report or `pending`.
899
+ this.nextAttemptAt = this.clock() + config.pollIntervalMs;
900
+ })
901
+ .finally(() => {
902
+ this.inFlight = undefined;
903
+ });
904
+ }
905
+
906
+ /** Await any in-flight background poll (deterministic tests / shutdown). */
907
+ async whenIdle(): Promise<void> {
908
+ while (this.inFlight) await this.inFlight;
909
+ }
910
+ }