@remnic/core 9.69.88 → 9.69.89

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 (228) hide show
  1. package/dist/access-admin-ops-surface.js +28 -27
  2. package/dist/access-authorization-probe.js +29 -28
  3. package/dist/access-boundary.js +28 -27
  4. package/dist/access-cli.js +77 -76
  5. package/dist/access-cli.js.map +1 -1
  6. package/dist/access-extraction-force-flush.js +28 -27
  7. package/dist/access-http-briefing.js +29 -28
  8. package/dist/access-http-query.js +30 -29
  9. package/dist/access-http.js +47 -46
  10. package/dist/access-identity-continuity-surface.js +28 -27
  11. package/dist/access-lcm-surface.js +28 -27
  12. package/dist/access-mcp.js +37 -36
  13. package/dist/access-namespace-preflight.js +28 -27
  14. package/dist/access-observe-write-category.js +8 -7
  15. package/dist/access-observe-write-surface.js +28 -27
  16. package/dist/access-offline-manifest.js +5 -4
  17. package/dist/access-operations-batch.js +29 -28
  18. package/dist/access-operations.js +36 -35
  19. package/dist/access-recall-concurrency.js +28 -27
  20. package/dist/access-recall-response.js +28 -27
  21. package/dist/access-recall-surface.js +28 -27
  22. package/dist/access-schema.js +7 -6
  23. package/dist/access-service.js +28 -27
  24. package/dist/access-surface-catalog.js +30 -29
  25. package/dist/access-surface-catalog.js.map +1 -1
  26. package/dist/active-recall.js +9 -8
  27. package/dist/active-recall.js.map +1 -1
  28. package/dist/adapters/index.js +4 -4
  29. package/dist/adapters/registry.js +2 -2
  30. package/dist/briefing.js +6 -6
  31. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-GWVG7LGC.js} +2 -2
  32. package/dist/causal-consolidation.js +7 -7
  33. package/dist/{chunk-S2GE2K36.js → chunk-22LMCQ5B.js} +41 -41
  34. package/dist/{chunk-2DLWLNO4.js → chunk-2FX374UI.js} +3 -3
  35. package/dist/{chunk-P2ODTYOF.js → chunk-3AGHSQPM.js} +2 -2
  36. package/dist/{chunk-LW3HKRL7.js → chunk-3QKO4QN7.js} +3 -3
  37. package/dist/{chunk-BOZJD5PK.js → chunk-3YD44CH2.js} +2 -2
  38. package/dist/{chunk-STEOT4DI.js → chunk-4HUCP4R4.js} +2 -2
  39. package/dist/{chunk-Z6EZCN6Z.js → chunk-5FNZ3G6X.js} +5 -5
  40. package/dist/{chunk-CZJA2EQE.js → chunk-5STPRPE7.js} +2 -2
  41. package/dist/{chunk-FKD656D2.js → chunk-64ZUALXK.js} +2 -2
  42. package/dist/{chunk-M6C7SOR6.js → chunk-6MNKDTDI.js} +2 -2
  43. package/dist/{chunk-BGI4NUAO.js → chunk-7D7OUO5W.js} +2 -2
  44. package/dist/{chunk-AEU4EQGV.js → chunk-7QE46QXI.js} +1 -1
  45. package/dist/{chunk-C7ZDKWPS.js → chunk-7WBSMXGC.js} +3 -3
  46. package/dist/{chunk-X5SHE2O3.js → chunk-A6RVIHHK.js} +2 -2
  47. package/dist/{chunk-D5BSQR63.js → chunk-AEJ3TE5H.js} +2 -2
  48. package/dist/{chunk-K6T33R4Q.js → chunk-AXGJMDBS.js} +156 -7
  49. package/dist/chunk-AXGJMDBS.js.map +1 -0
  50. package/dist/{chunk-GZDTVYK2.js → chunk-AZIOEOK4.js} +2 -2
  51. package/dist/{chunk-VDX2J7OX.js → chunk-BUKK5SWA.js} +2 -2
  52. package/dist/{chunk-5X4MXNEU.js → chunk-C6HPNPNK.js} +3 -3
  53. package/dist/{chunk-QB57WIS6.js → chunk-CALBQ2NG.js} +35 -27
  54. package/dist/chunk-CALBQ2NG.js.map +1 -0
  55. package/dist/{chunk-5MNUBT7T.js → chunk-DO4C4AXD.js} +2 -2
  56. package/dist/{chunk-MXCAJKMA.js → chunk-EAQQ2FD3.js} +2 -2
  57. package/dist/{chunk-4KN2JLQV.js → chunk-FC52YWAO.js} +2 -2
  58. package/dist/{chunk-CRXRZA3G.js → chunk-FEIXG5FA.js} +2 -2
  59. package/dist/{chunk-VVX5PZFS.js → chunk-H36X4NRH.js} +2 -2
  60. package/dist/{chunk-ZZZ4I455.js → chunk-IIARQTZ2.js} +2 -2
  61. package/dist/{chunk-VALKBYBN.js → chunk-IKDRGVVJ.js} +2 -2
  62. package/dist/{chunk-OCFXVDBZ.js → chunk-IVEHY2VT.js} +54 -123
  63. package/dist/chunk-IVEHY2VT.js.map +1 -0
  64. package/dist/{chunk-2NAD4YCI.js → chunk-IYIBGSHJ.js} +2 -2
  65. package/dist/{chunk-HLB7GKGG.js → chunk-J27JML5O.js} +2 -2
  66. package/dist/{chunk-NYKIUQL6.js → chunk-JB56XYHP.js} +2 -2
  67. package/dist/{chunk-OK52IY3W.js → chunk-JBH26P62.js} +5 -5
  68. package/dist/{chunk-IOSZHHJC.js → chunk-JUT3OR2L.js} +4 -4
  69. package/dist/{chunk-LU7E2MXE.js → chunk-JYM4Y6SO.js} +14 -14
  70. package/dist/{chunk-Z5LP6ILP.js → chunk-JZ4TJHR2.js} +2 -2
  71. package/dist/{chunk-BLVR4WYU.js → chunk-KTKHO3UI.js} +2 -2
  72. package/dist/{chunk-W3XXFPRI.js → chunk-MGZMC6WN.js} +32 -32
  73. package/dist/chunk-MSAI3Q7D.js +372 -0
  74. package/dist/chunk-MSAI3Q7D.js.map +1 -0
  75. package/dist/{chunk-H5BJWPUI.js → chunk-NJVR724O.js} +2 -2
  76. package/dist/{chunk-AWG3TIAY.js → chunk-NMZVDRVP.js} +2 -2
  77. package/dist/{chunk-JVPWRNHA.js → chunk-NOL7SEWN.js} +2 -2
  78. package/dist/{chunk-BXLOS5AJ.js → chunk-OWHERGF2.js} +2 -2
  79. package/dist/{chunk-YJ7MW627.js → chunk-PMNLL2TL.js} +2 -2
  80. package/dist/{chunk-WKY7N3L7.js → chunk-PULDSMCJ.js} +7 -7
  81. package/dist/{chunk-AE5U5NMS.js → chunk-QOXNTKXB.js} +2 -2
  82. package/dist/{chunk-AEHVZY3X.js → chunk-QZLCVO2V.js} +2 -2
  83. package/dist/{chunk-LV5H6FTN.js → chunk-RJLV4BKR.js} +2 -2
  84. package/dist/{chunk-C6U57KG4.js → chunk-RMDGAAZF.js} +2 -2
  85. package/dist/{chunk-4OQET73E.js → chunk-SLEAJ67D.js} +4 -4
  86. package/dist/{chunk-FJLQXMTU.js → chunk-ST2RQUXC.js} +2 -2
  87. package/dist/{chunk-6UQFJULG.js → chunk-SZG4WFAG.js} +2 -2
  88. package/dist/{chunk-SWBC3UER.js → chunk-TPEZ2SHV.js} +2 -2
  89. package/dist/{chunk-PEBHPCX2.js → chunk-UQDC3W77.js} +2 -2
  90. package/dist/{chunk-DQEMWVMT.js → chunk-UVYI6VIX.js} +1 -1
  91. package/dist/{chunk-QAW55ZHJ.js → chunk-VA4O3U7U.js} +3 -3
  92. package/dist/{chunk-A4VSOUCB.js → chunk-VDO32NLK.js} +3 -3
  93. package/dist/{chunk-3SGTV7RD.js → chunk-VHDKPGMG.js} +6 -6
  94. package/dist/{chunk-5E6RYRYW.js → chunk-WNRTXEWW.js} +4 -4
  95. package/dist/{chunk-Q4AUIFUA.js → chunk-XAITGGX5.js} +4 -4
  96. package/dist/{chunk-GWM6T5EQ.js → chunk-YPI4RQWV.js} +2 -2
  97. package/dist/{chunk-4MP7XR5J.js → chunk-YSWJXOOL.js} +2 -2
  98. package/dist/{chunk-QI7K7BZ5.js → chunk-ZG5DWWKK.js} +2 -2
  99. package/dist/cli.js +60 -59
  100. package/dist/coding/export-okf-codegraph.js +5 -5
  101. package/dist/compounding/engine.js +6 -6
  102. package/dist/config.js +9 -8
  103. package/dist/connectors/codex-materialize-runner.js +6 -6
  104. package/dist/connectors/index.js +6 -6
  105. package/dist/convergence-refresh.js +8 -8
  106. package/dist/corpus-watermark.js +8 -8
  107. package/dist/deep-recall-http-glue.js +8 -7
  108. package/dist/entity-retrieval.js +6 -6
  109. package/dist/external-wiki-access.js +29 -28
  110. package/dist/extraction.js +6 -6
  111. package/dist/{first-start-migration-7OATHJKB.js → first-start-migration-GAT7G733.js} +4 -4
  112. package/dist/harmonic-retrieval.js +1 -1
  113. package/dist/index.js +115 -114
  114. package/dist/lcm/engine.js +2 -2
  115. package/dist/lcm/index.js +5 -5
  116. package/dist/location/index.js +8 -8
  117. package/dist/maintenance/memory-governance.js +6 -6
  118. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +6 -6
  119. package/dist/maintenance/rebuild-memory-projection.js +7 -7
  120. package/dist/memory-browse-operations.js +29 -28
  121. package/dist/namespaces/migrate.js +7 -7
  122. package/dist/namespaces/search.js +4 -4
  123. package/dist/namespaces/storage.js +6 -6
  124. package/dist/offline-sync-changeset-generations.d.ts +82 -0
  125. package/dist/offline-sync-changeset-generations.d.ts.map +1 -0
  126. package/dist/offline-sync-changeset-generations.js +21 -0
  127. package/dist/offline-sync-changeset-generations.js.map +1 -0
  128. package/dist/offline-sync-embedding-generation.d.ts +50 -0
  129. package/dist/offline-sync-embedding-generation.d.ts.map +1 -1
  130. package/dist/offline-sync-embedding-generation.js +4 -2
  131. package/dist/offline-sync-snapshot-stream.js +5 -4
  132. package/dist/offline-sync.d.ts +12 -1
  133. package/dist/offline-sync.d.ts.map +1 -1
  134. package/dist/offline-sync.js +6 -5
  135. package/dist/operator-doctor-corpus.js +9 -9
  136. package/dist/operator-toolkit.js +18 -17
  137. package/dist/orchestration/maintenance.js +14 -14
  138. package/dist/orchestrator.js +78 -77
  139. package/dist/recall-navigation-operations.js +29 -28
  140. package/dist/recall-navigation.js +28 -27
  141. package/dist/resume-bundles.js +9 -8
  142. package/dist/search/factory.js +3 -3
  143. package/dist/search/index.js +7 -7
  144. package/dist/semantic-consolidation.js +7 -7
  145. package/dist/semantic-rule-promotion.js +6 -6
  146. package/dist/semantic-rule-verifier.js +6 -6
  147. package/dist/source-agent-qualifier.js +6 -6
  148. package/dist/standup/build.js +6 -6
  149. package/dist/storage.js +5 -5
  150. package/dist/support-passport/index.js +31 -30
  151. package/dist/support-passport/offline-sync-guard.d.ts +6 -2
  152. package/dist/support-passport/offline-sync-guard.d.ts.map +1 -1
  153. package/dist/transfer/backup.js +2 -2
  154. package/dist/transfer/capsule-export.js +2 -2
  155. package/dist/transfer/capsule-import.js +2 -2
  156. package/dist/transfer/export-okf.js +6 -6
  157. package/dist/verified-recall.js +6 -6
  158. package/dist/who-knows-cli.js +7 -7
  159. package/dist/who-knows.js +6 -6
  160. package/package.json +2 -2
  161. package/src/offline-sync-changeset-generations.test.ts +962 -0
  162. package/src/offline-sync-changeset-generations.ts +549 -0
  163. package/src/offline-sync-embedding-generation.ts +272 -4
  164. package/src/offline-sync.ts +75 -162
  165. package/src/support-passport/offline-sync-guard.ts +16 -2
  166. package/dist/chunk-K6T33R4Q.js.map +0 -1
  167. package/dist/chunk-OCFXVDBZ.js.map +0 -1
  168. package/dist/chunk-QB57WIS6.js.map +0 -1
  169. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  170. /package/dist/{chunk-S2GE2K36.js.map → chunk-22LMCQ5B.js.map} +0 -0
  171. /package/dist/{chunk-2DLWLNO4.js.map → chunk-2FX374UI.js.map} +0 -0
  172. /package/dist/{chunk-P2ODTYOF.js.map → chunk-3AGHSQPM.js.map} +0 -0
  173. /package/dist/{chunk-LW3HKRL7.js.map → chunk-3QKO4QN7.js.map} +0 -0
  174. /package/dist/{chunk-BOZJD5PK.js.map → chunk-3YD44CH2.js.map} +0 -0
  175. /package/dist/{chunk-STEOT4DI.js.map → chunk-4HUCP4R4.js.map} +0 -0
  176. /package/dist/{chunk-Z6EZCN6Z.js.map → chunk-5FNZ3G6X.js.map} +0 -0
  177. /package/dist/{chunk-CZJA2EQE.js.map → chunk-5STPRPE7.js.map} +0 -0
  178. /package/dist/{chunk-FKD656D2.js.map → chunk-64ZUALXK.js.map} +0 -0
  179. /package/dist/{chunk-M6C7SOR6.js.map → chunk-6MNKDTDI.js.map} +0 -0
  180. /package/dist/{chunk-BGI4NUAO.js.map → chunk-7D7OUO5W.js.map} +0 -0
  181. /package/dist/{chunk-AEU4EQGV.js.map → chunk-7QE46QXI.js.map} +0 -0
  182. /package/dist/{chunk-C7ZDKWPS.js.map → chunk-7WBSMXGC.js.map} +0 -0
  183. /package/dist/{chunk-X5SHE2O3.js.map → chunk-A6RVIHHK.js.map} +0 -0
  184. /package/dist/{chunk-D5BSQR63.js.map → chunk-AEJ3TE5H.js.map} +0 -0
  185. /package/dist/{chunk-GZDTVYK2.js.map → chunk-AZIOEOK4.js.map} +0 -0
  186. /package/dist/{chunk-VDX2J7OX.js.map → chunk-BUKK5SWA.js.map} +0 -0
  187. /package/dist/{chunk-5X4MXNEU.js.map → chunk-C6HPNPNK.js.map} +0 -0
  188. /package/dist/{chunk-5MNUBT7T.js.map → chunk-DO4C4AXD.js.map} +0 -0
  189. /package/dist/{chunk-MXCAJKMA.js.map → chunk-EAQQ2FD3.js.map} +0 -0
  190. /package/dist/{chunk-4KN2JLQV.js.map → chunk-FC52YWAO.js.map} +0 -0
  191. /package/dist/{chunk-CRXRZA3G.js.map → chunk-FEIXG5FA.js.map} +0 -0
  192. /package/dist/{chunk-VVX5PZFS.js.map → chunk-H36X4NRH.js.map} +0 -0
  193. /package/dist/{chunk-ZZZ4I455.js.map → chunk-IIARQTZ2.js.map} +0 -0
  194. /package/dist/{chunk-VALKBYBN.js.map → chunk-IKDRGVVJ.js.map} +0 -0
  195. /package/dist/{chunk-2NAD4YCI.js.map → chunk-IYIBGSHJ.js.map} +0 -0
  196. /package/dist/{chunk-HLB7GKGG.js.map → chunk-J27JML5O.js.map} +0 -0
  197. /package/dist/{chunk-NYKIUQL6.js.map → chunk-JB56XYHP.js.map} +0 -0
  198. /package/dist/{chunk-OK52IY3W.js.map → chunk-JBH26P62.js.map} +0 -0
  199. /package/dist/{chunk-IOSZHHJC.js.map → chunk-JUT3OR2L.js.map} +0 -0
  200. /package/dist/{chunk-LU7E2MXE.js.map → chunk-JYM4Y6SO.js.map} +0 -0
  201. /package/dist/{chunk-Z5LP6ILP.js.map → chunk-JZ4TJHR2.js.map} +0 -0
  202. /package/dist/{chunk-BLVR4WYU.js.map → chunk-KTKHO3UI.js.map} +0 -0
  203. /package/dist/{chunk-W3XXFPRI.js.map → chunk-MGZMC6WN.js.map} +0 -0
  204. /package/dist/{chunk-H5BJWPUI.js.map → chunk-NJVR724O.js.map} +0 -0
  205. /package/dist/{chunk-AWG3TIAY.js.map → chunk-NMZVDRVP.js.map} +0 -0
  206. /package/dist/{chunk-JVPWRNHA.js.map → chunk-NOL7SEWN.js.map} +0 -0
  207. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-OWHERGF2.js.map} +0 -0
  208. /package/dist/{chunk-YJ7MW627.js.map → chunk-PMNLL2TL.js.map} +0 -0
  209. /package/dist/{chunk-WKY7N3L7.js.map → chunk-PULDSMCJ.js.map} +0 -0
  210. /package/dist/{chunk-AE5U5NMS.js.map → chunk-QOXNTKXB.js.map} +0 -0
  211. /package/dist/{chunk-AEHVZY3X.js.map → chunk-QZLCVO2V.js.map} +0 -0
  212. /package/dist/{chunk-LV5H6FTN.js.map → chunk-RJLV4BKR.js.map} +0 -0
  213. /package/dist/{chunk-C6U57KG4.js.map → chunk-RMDGAAZF.js.map} +0 -0
  214. /package/dist/{chunk-4OQET73E.js.map → chunk-SLEAJ67D.js.map} +0 -0
  215. /package/dist/{chunk-FJLQXMTU.js.map → chunk-ST2RQUXC.js.map} +0 -0
  216. /package/dist/{chunk-6UQFJULG.js.map → chunk-SZG4WFAG.js.map} +0 -0
  217. /package/dist/{chunk-SWBC3UER.js.map → chunk-TPEZ2SHV.js.map} +0 -0
  218. /package/dist/{chunk-PEBHPCX2.js.map → chunk-UQDC3W77.js.map} +0 -0
  219. /package/dist/{chunk-DQEMWVMT.js.map → chunk-UVYI6VIX.js.map} +0 -0
  220. /package/dist/{chunk-QAW55ZHJ.js.map → chunk-VA4O3U7U.js.map} +0 -0
  221. /package/dist/{chunk-A4VSOUCB.js.map → chunk-VDO32NLK.js.map} +0 -0
  222. /package/dist/{chunk-3SGTV7RD.js.map → chunk-VHDKPGMG.js.map} +0 -0
  223. /package/dist/{chunk-5E6RYRYW.js.map → chunk-WNRTXEWW.js.map} +0 -0
  224. /package/dist/{chunk-Q4AUIFUA.js.map → chunk-XAITGGX5.js.map} +0 -0
  225. /package/dist/{chunk-GWM6T5EQ.js.map → chunk-YPI4RQWV.js.map} +0 -0
  226. /package/dist/{chunk-4MP7XR5J.js.map → chunk-YSWJXOOL.js.map} +0 -0
  227. /package/dist/{chunk-QI7K7BZ5.js.map → chunk-ZG5DWWKK.js.map} +0 -0
  228. /package/dist/{first-start-migration-7OATHJKB.js.map → first-start-migration-GAT7G733.js.map} +0 -0
@@ -0,0 +1,549 @@
1
+ // Changeset-side generation atomicity for offline sync (issue #3150).
2
+ // Split from offline-sync-embedding-generation.ts to respect the 1200-line
3
+ // new-file cap: the snapshot-side transaction machinery stays there, this
4
+ // module owns the CHANGESET protocol — the `embeddingGenerations` manifest
5
+ // (sender-side builder + receiver-side validation) and the routing that
6
+ // applies manifested generations through the shared locked atomic swap.
7
+ import { createHash } from "node:crypto";
8
+ import { lstat, readdir } from "node:fs/promises";
9
+ import {
10
+ EmbeddingIndexStorageError,
11
+ } from "./embedding-index-storage.js";
12
+ import {
13
+ EMBEDDING_SHARD_DIR_BASENAME,
14
+ applyEmbeddingGenerationRemoval,
15
+ applyEmbeddingGenerationTransaction,
16
+ embeddingGenerationMembership,
17
+ embeddingShardDirOf,
18
+ isEmbeddingGenerationDirPath,
19
+ type EmbeddingGenerationTransactionInput,
20
+ type EmbeddingGenerationTransactionIo,
21
+ type EmbeddingGenerationTransactionResult,
22
+ } from "./offline-sync-embedding-generation.js";
23
+ import { EMBEDDING_SHARD_FILE_PATTERN } from "./offline-sync-runtime-state.js";
24
+ import type {
25
+ OfflineSyncChange,
26
+ OfflineSyncConflict,
27
+ OfflineSyncFileDeleteTarget,
28
+ OfflineSyncFileStagingReadTarget,
29
+ OfflineSyncFileStagingWriteTarget,
30
+ OfflineSyncFileState,
31
+ } from "./offline-sync.js";
32
+ import { type SafeArchiveRoot, resolveSafeArchiveTarget } from "./transfer/fs-utils.js";
33
+
34
+ const EMBEDDING_MARKER_BASENAME = "embeddings.json";
35
+
36
+ async function pathExists(absPath: string): Promise<boolean> {
37
+ try {
38
+ await lstat(absPath);
39
+ return true;
40
+ } catch (err) {
41
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
42
+ throw err;
43
+ }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Changeset-side generation atomicity (issue #3150)
48
+ //
49
+ // Inline changesets are DIFFS, not complete generations: a push that rewrites
50
+ // two of five shards carries only those two records. To reuse the snapshot
51
+ // path's single atomic swap without sweeping unchanged shards, the sender
52
+ // attaches an `embeddingGenerations` manifest — the COMPLETE post-change
53
+ // membership of every touched generation — and the receiver assembles the
54
+ // full incoming set: changed members ride inline, unchanged members carry
55
+ // over from local bytes hash-verified against the manifest, removed members
56
+ // are swept by the swap. Everything below shares the generation lock and the
57
+ // secure-staging rules with the snapshot path.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface OfflineSyncChangesetGenerationMember {
61
+ path: string;
62
+ sha256: string;
63
+ bytes: number;
64
+ }
65
+
66
+ /** Complete post-change membership of ONE sharded generation. */
67
+ export interface OfflineSyncChangesetGeneration {
68
+ /** The generation dir, e.g. `state/embeddings`. */
69
+ shardDir: string;
70
+ members: OfflineSyncChangesetGenerationMember[];
71
+ }
72
+
73
+ type OfflineSyncUpsertChange = Extract<OfflineSyncChange, { type: "upsert" }>;
74
+
75
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
76
+
77
+ /**
78
+ * Validate the optional `embeddingGenerations` changeset field (receiver
79
+ * boundary). Returns undefined when the field is absent (legacy sender);
80
+ * throws on any incoherence — duplicate dirs or member paths, noncanonical
81
+ * dirs, members outside their dir, or a manifested generation that does not
82
+ * account for every upserted shard — so bad manifests fail closed BEFORE any
83
+ * apply-time write.
84
+ */
85
+ export function normalizeChangesetGenerations(
86
+ raw: unknown,
87
+ changes: readonly OfflineSyncChange[],
88
+ ): OfflineSyncChangesetGeneration[] | undefined {
89
+ if (raw === undefined) return undefined;
90
+ if (!Array.isArray(raw)) {
91
+ throw new Error("offline sync changeset embeddingGenerations must be an array");
92
+ }
93
+ const dirs = new Set<string>();
94
+ const generations: OfflineSyncChangesetGeneration[] = [];
95
+ for (const entry of raw) {
96
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
97
+ throw new Error("offline sync changeset embeddingGenerations entries must be objects");
98
+ }
99
+ const record = entry as Record<string, unknown>;
100
+ const shardDir = typeof record.shardDir === "string" ? record.shardDir : "";
101
+ if (!isEmbeddingGenerationDirPath(shardDir)) {
102
+ throw new Error(`offline sync changeset embeddingGenerations shardDir must be a canonical embeddings generation dir: ${shardDir}`);
103
+ }
104
+ if (dirs.has(shardDir)) {
105
+ throw new Error(`offline sync changeset embeddingGenerations has a duplicate shardDir: ${shardDir}`);
106
+ }
107
+ if (!Array.isArray(record.members) || record.members.length === 0) {
108
+ throw new Error(`offline sync changeset embeddingGenerations ${shardDir} must list its complete member set`);
109
+ }
110
+ const memberPaths = new Set<string>();
111
+ const members: OfflineSyncChangesetGenerationMember[] = [];
112
+ for (const rawMember of record.members) {
113
+ if (!rawMember || typeof rawMember !== "object" || Array.isArray(rawMember)) {
114
+ throw new Error(`offline sync changeset embeddingGenerations ${shardDir} members must be objects`);
115
+ }
116
+ const member = rawMember as Record<string, unknown>;
117
+ const memberPath = typeof member.path === "string" ? member.path : "";
118
+ if (embeddingShardDirOf(memberPath) !== shardDir) {
119
+ throw new Error(`offline sync changeset embeddingGenerations member ${memberPath} does not belong to generation ${shardDir}`);
120
+ }
121
+ if (memberPaths.has(memberPath)) {
122
+ throw new Error(`offline sync changeset embeddingGenerations ${shardDir} has a duplicate member: ${memberPath}`);
123
+ }
124
+ if (typeof member.sha256 !== "string" || !SHA256_PATTERN.test(member.sha256)) {
125
+ throw new Error(`offline sync changeset embeddingGenerations member ${memberPath} sha256 must be a lowercase sha256 hex digest`);
126
+ }
127
+ if (typeof member.bytes !== "number" || !Number.isInteger(member.bytes) || member.bytes < 0) {
128
+ throw new Error(`offline sync changeset embeddingGenerations member ${memberPath} bytes must be a non-negative integer`);
129
+ }
130
+ memberPaths.add(memberPath);
131
+ members.push({ path: memberPath, sha256: member.sha256, bytes: member.bytes });
132
+ }
133
+ dirs.add(shardDir);
134
+ generations.push({ shardDir, members });
135
+ }
136
+
137
+ // Coherence with the change list: a manifest-bearing sender must account
138
+ // for EVERY upserted shard, and no delete may target a surviving member.
139
+ const memberByPath = new Map<string, OfflineSyncChangesetGenerationMember>();
140
+ for (const generation of generations) {
141
+ for (const member of generation.members) memberByPath.set(member.path, member);
142
+ }
143
+ for (const change of changes) {
144
+ const membership = embeddingGenerationMembership(change.path);
145
+ if (!membership) continue;
146
+ const manifested = memberByPath.get(change.path);
147
+ if (change.type === "upsert") {
148
+ if (membership.kind !== "shard") continue;
149
+ if (!manifested) {
150
+ throw new Error(`offline sync changeset upserts of embedding shards require a manifest entry: ${change.path}`);
151
+ }
152
+ if (manifested.sha256 !== change.file.sha256 || manifested.bytes !== change.file.bytes) {
153
+ throw new Error(`offline sync changeset manifest member ${change.path} does not match the upserted record`);
154
+ }
155
+ continue;
156
+ }
157
+ if (manifested) {
158
+ throw new Error(`offline sync changeset delete of ${change.path} contradicts its manifest membership`);
159
+ }
160
+ }
161
+ return generations;
162
+ }
163
+
164
+ /** Change-list conflicts for a deferred generation: visible, never silent. */
165
+ function deferGenerationConflicts(
166
+ dirChanges: readonly OfflineSyncChange[],
167
+ currentMap: ReadonlyMap<string, OfflineSyncFileState>,
168
+ reason: OfflineSyncConflict["reason"],
169
+ ): { conflicts: OfflineSyncConflict[]; conflictedPaths: Set<string> } {
170
+ const conflicts: OfflineSyncConflict[] = [];
171
+ const conflictedPaths = new Set<string>();
172
+ for (const change of dirChanges) {
173
+ conflictedPaths.add(change.path);
174
+ conflicts.push({
175
+ path: change.path,
176
+ reason,
177
+ ...(change.type === "upsert" ? { incomingSha256: change.file.sha256 } : {}),
178
+ ...(change.baseSha256 !== undefined ? { baseSha256: change.baseSha256 } : {}),
179
+ localSha256: currentMap.get(change.path)?.sha256,
180
+ });
181
+ }
182
+ return { conflicts, conflictedPaths };
183
+ }
184
+
185
+ export interface ChangesetGenerationApplication {
186
+ /** Paths the per-file apply loop must skip (published, swept, or removed). */
187
+ transactionHandled: Set<string>;
188
+ /** Conflicts for deferred generations — surfaced so the push side never
189
+ * checkpoints a deferred generation as applied. */
190
+ conflicts: OfflineSyncConflict[];
191
+ conflictedPaths: Set<string>;
192
+ appliedUpserts: number;
193
+ appliedDeletes: number;
194
+ /** Result-map updates for published members. */
195
+ writtenStates: Map<string, OfflineSyncFileState>;
196
+ /** Result-map removals for swept/removed members. */
197
+ removedPaths: Set<string>;
198
+ }
199
+
200
+ export interface ChangesetGenerationApplyOptions {
201
+ root: SafeArchiveRoot;
202
+ /** NORMALIZED changeset (manifest validated by normalizeChangesetGenerations). */
203
+ changeset: { changes: readonly OfflineSyncChange[]; embeddingGenerations?: readonly OfflineSyncChangesetGeneration[] };
204
+ incomingBuffers: ReadonlyMap<string, Buffer>;
205
+ currentMap: ReadonlyMap<string, OfflineSyncFileState>;
206
+ io: EmbeddingGenerationTransactionIo;
207
+ /** True when the caller supplied ANY custom storage IO — the staging-pair
208
+ * fail-closed rule then applies (mirrors the snapshot apply). */
209
+ customIoPresent: boolean;
210
+ now: number;
211
+ }
212
+
213
+ /** Change-list maps scoped per generation dir. */
214
+ type OfflineSyncChangesByDir = Map<string, OfflineSyncChange[]>;
215
+
216
+ /**
217
+ * Route a changeset's embedding-generation changes through the shared locked
218
+ * transaction: manifested dirs publish atomically (changed members inline,
219
+ * unchanged members carried over, removed members swept), manifested
220
+ * shrink-deletes ride the same swap, full-evidence tombstones remove the
221
+ * whole generation through the removal transaction, and everything that
222
+ * cannot be proven (legacy manifest-less shard upserts, under-lock
223
+ * divergence, remote-only members) is DEFERRED as conflicts with the disk
224
+ * untouched — a mixed generation is never published and the push side never
225
+ * checkpoints a deferred generation as applied.
226
+ */
227
+ export async function applyChangesetEmbeddingGenerations(
228
+ options: ChangesetGenerationApplyOptions,
229
+ ): Promise<ChangesetGenerationApplication> {
230
+ const result: ChangesetGenerationApplication = {
231
+ transactionHandled: new Set(),
232
+ conflicts: [],
233
+ conflictedPaths: new Set(),
234
+ appliedUpserts: 0,
235
+ appliedDeletes: 0,
236
+ writtenStates: new Map(),
237
+ removedPaths: new Set(),
238
+ };
239
+ const manifest = new Map(
240
+ (options.changeset.embeddingGenerations ?? []).map((generation) => [generation.shardDir, generation]),
241
+ );
242
+ const shardUpsertChanges: Map<string, OfflineSyncUpsertChange[]> = new Map();
243
+ const markerUpsertChanges: Map<string, OfflineSyncUpsertChange[]> = new Map();
244
+ const deleteChangesByDir = new Map<string, OfflineSyncChange[]>();
245
+ const dirChanges = new Map<string, OfflineSyncChange[]>();
246
+ for (const change of options.changeset.changes) {
247
+ const membership = embeddingGenerationMembership(change.path);
248
+ if (!membership) continue;
249
+ const dir = membership.shardDir;
250
+ if (!dirChanges.has(dir)) dirChanges.set(dir, []);
251
+ dirChanges.get(dir)!.push(change);
252
+ if (change.type === "upsert") {
253
+ if (membership.kind === "shard") {
254
+ if (!shardUpsertChanges.has(dir)) shardUpsertChanges.set(dir, []);
255
+ shardUpsertChanges.get(dir)!.push(change);
256
+ } else {
257
+ if (!markerUpsertChanges.has(dir)) markerUpsertChanges.set(dir, []);
258
+ markerUpsertChanges.get(dir)!.push(change);
259
+ }
260
+ } else {
261
+ if (!deleteChangesByDir.has(dir)) deleteChangesByDir.set(dir, []);
262
+ deleteChangesByDir.get(dir)!.push(change);
263
+ }
264
+ }
265
+
266
+ // Fail closed BEFORE any publication: custom storage IO without the
267
+ // staging pair would stage the incoming generation in plaintext (same rule
268
+ // and message as the snapshot apply).
269
+ if (
270
+ (shardUpsertChanges.size > 0 || markerUpsertChanges.size > 0) &&
271
+ options.customIoPresent &&
272
+ !(options.io.writeStagingFile && options.io.readStagingFile)
273
+ ) {
274
+ throw new EmbeddingIndexStorageError(
275
+ "refusing to replace embedding generation: custom storage IO requires both writeStagingFile and readStagingFile hooks; unencrypted raw staging would plaintext-downgrade the index",
276
+ );
277
+ }
278
+
279
+ const runTransaction = async (
280
+ shardDirRel: string,
281
+ input: Omit<EmbeddingGenerationTransactionInput, "root" | "io" | "now">,
282
+ ): Promise<EmbeddingGenerationTransactionResult> => {
283
+ const transaction = await applyEmbeddingGenerationTransaction({
284
+ ...input,
285
+ root: options.root,
286
+ io: options.io,
287
+ now: options.now,
288
+ });
289
+ if (transaction.deferredLocalDivergence) {
290
+ const deferred = deferGenerationConflicts(
291
+ dirChanges.get(shardDirRel) ?? [],
292
+ options.currentMap,
293
+ "embedding_generation_diverged",
294
+ );
295
+ result.conflicts.push(...deferred.conflicts);
296
+ for (const conflicted of deferred.conflictedPaths) result.conflictedPaths.add(conflicted);
297
+ return transaction;
298
+ }
299
+ result.appliedUpserts += transaction.upserted;
300
+ result.appliedDeletes += transaction.deleted;
301
+ for (const [relPath, state] of transaction.writtenStates) {
302
+ result.writtenStates.set(relPath, state);
303
+ result.transactionHandled.add(relPath);
304
+ }
305
+ for (const relPath of transaction.removedPaths) {
306
+ result.removedPaths.add(relPath);
307
+ result.transactionHandled.add(relPath);
308
+ }
309
+ return transaction;
310
+ };
311
+
312
+ /** Incoming set + reconstructed shared base for ONE manifested generation. */
313
+ const manifestedInput = (
314
+ shardDirRel: string,
315
+ generation: OfflineSyncChangesetGeneration,
316
+ ): Omit<EmbeddingGenerationTransactionInput, "root" | "io" | "now"> => {
317
+ const upsertByPath = new Map(
318
+ (shardUpsertChanges.get(shardDirRel) ?? []).map((change: OfflineSyncUpsertChange) => [change.path, change]),
319
+ );
320
+ const memberByPath = new Map(generation.members.map((member) => [member.path, member]));
321
+ const incomingShardPaths = generation.members.map((member) => member.path).sort();
322
+ const tombstones = (deleteChangesByDir.get(shardDirRel) ?? [])
323
+ .filter((change): change is Extract<OfflineSyncChange, { type: "delete" }> => change.type === "delete");
324
+ const baseStates = new Map<string, { sha256: string }>();
325
+ for (const member of generation.members) {
326
+ const change = upsertByPath.get(member.path);
327
+ if (change) {
328
+ if (change.baseSha256 !== undefined) baseStates.set(member.path, { sha256: change.baseSha256 });
329
+ continue;
330
+ }
331
+ // Unchanged member: the manifest digest IS its shared-base evidence.
332
+ baseStates.set(member.path, { sha256: member.sha256 });
333
+ }
334
+ for (const change of tombstones) {
335
+ baseStates.set(change.path, { sha256: change.baseSha256 });
336
+ }
337
+ return {
338
+ shardDirRel,
339
+ incomingShardPaths,
340
+ incomingMarker: null,
341
+ incomingMarkerPresent: markerUpsertChanges.has(shardDirRel),
342
+ incomingShardStates: new Map(
343
+ incomingShardPaths.map((relPath) => [relPath, {
344
+ path: relPath,
345
+ sha256: upsertByPath.get(relPath)?.file.sha256 ?? memberByPath.get(relPath)!.sha256,
346
+ bytes: memberByPath.get(relPath)!.bytes,
347
+ mtimeMs: options.now,
348
+ } satisfies OfflineSyncFileState]),
349
+ ),
350
+ incomingBuffers: options.incomingBuffers,
351
+ baseStates,
352
+ // Manifested deletes are pre-authorized removals: the retry after a
353
+ // response loss must re-apply idempotently instead of diverging
354
+ // (#3150 review), and the swept members need their deletion revisions
355
+ // recorded after the swap (the per-file loop never sees them). The
356
+ // map is present whenever tombstones exist — even when none carries
357
+ // mtimeMs — so the recording pass runs and defaults to `now`.
358
+ justifiedRemovals: new Set(tombstones.map((change) => change.path)),
359
+ deletionMtimeByPath: tombstones.length === 0
360
+ ? undefined
361
+ : new Map(
362
+ tombstones
363
+ .filter((change) => change.mtimeMs !== undefined)
364
+ .map((change) => [change.path, change.mtimeMs as number]),
365
+ ),
366
+ // All-manifested members base-less = the sender claims a fresh
367
+ // creation; an existing local generation must match exactly or defer.
368
+ freshCreateClaim: baseStates.size === 0,
369
+ };
370
+ };
371
+
372
+ const stateDirSuffix = `/${EMBEDDING_SHARD_DIR_BASENAME}`;
373
+ const allDirs = new Set<string>([
374
+ ...shardUpsertChanges.keys(),
375
+ ...markerUpsertChanges.keys(),
376
+ ...deleteChangesByDir.keys(),
377
+ ]);
378
+ for (const shardDirRel of [...allDirs].sort()) {
379
+ const generation = manifest.get(shardDirRel);
380
+ if (shardUpsertChanges.has(shardDirRel) || (generation && deleteChangesByDir.has(shardDirRel))) {
381
+ if (!generation) {
382
+ // Legacy sender: shard upserts without a manifest cannot prove
383
+ // complete membership — defer instead of risking a mixed generation.
384
+ const deferred = deferGenerationConflicts(
385
+ dirChanges.get(shardDirRel) ?? [],
386
+ options.currentMap,
387
+ "embedding_generation_manifest_required",
388
+ );
389
+ result.conflicts.push(...deferred.conflicts);
390
+ for (const conflicted of deferred.conflictedPaths) result.conflictedPaths.add(conflicted);
391
+ continue;
392
+ }
393
+ await runTransaction(shardDirRel, manifestedInput(shardDirRel, generation));
394
+ continue;
395
+ }
396
+ if (markerUpsertChanges.has(shardDirRel)) {
397
+ // Cross-layout: an incoming legacy marker replaces the generation as a
398
+ // whole. No manifest exists (post-change membership is the marker), so
399
+ // base evidence comes from the marker change alone; a receiver-local
400
+ // sharded generation the marker base cannot account for defers via the
401
+ // transaction's under-lock census check rather than being swept.
402
+ const markerChange = (markerUpsertChanges.get(shardDirRel) ?? [])[0] as OfflineSyncUpsertChange | undefined;
403
+ if (!markerChange) continue;
404
+ const stateDirRel = shardDirRel.slice(0, -stateDirSuffix.length);
405
+ const markerRel = `${stateDirRel}/${EMBEDDING_MARKER_BASENAME}`;
406
+ const transaction = await runTransaction(shardDirRel, {
407
+ shardDirRel,
408
+ incomingShardPaths: [],
409
+ incomingMarker: {
410
+ path: markerRel,
411
+ sha256: markerChange.file.sha256,
412
+ buffer: options.incomingBuffers.get(markerRel) ?? null,
413
+ },
414
+ incomingMarkerPresent: false,
415
+ incomingShardStates: new Map(),
416
+ incomingBuffers: options.incomingBuffers,
417
+ baseStates: markerChange.baseSha256 === undefined
418
+ ? undefined
419
+ : new Map([[markerRel, { sha256: markerChange.baseSha256 }]]),
420
+ });
421
+ // The conversion consumes the marker change whole: on a published
422
+ // transaction the per-file loop must not re-write the legacy marker
423
+ // outside the atomic swap (#3150 review). On deferral the marker
424
+ // change is already in conflictedPaths, so it stays uncheckpointed.
425
+ if (!transaction.deferredLocalDivergence) result.transactionHandled.add(markerRel);
426
+ continue;
427
+ }
428
+ // Delete-only without a manifest: whole-generation removal ONLY when the
429
+ // changeset tombstones EVERY local member (complete evidence, mirrors the
430
+ // snapshot rule). Incomplete manifest-less deletion evidence defers the
431
+ // whole directory as `embedding_generation_manifest_required` conflicts;
432
+ // the complete published generation survives and is never broken apart by
433
+ // independent shard deletes. Only non-generation files keep per-file deletes.
434
+ const deletes = deleteChangesByDir.get(shardDirRel) ?? [];
435
+ const deletePaths = new Set(deletes.map((change) => change.path));
436
+ const stateDirRel = shardDirRel.slice(0, -stateDirSuffix.length);
437
+ const markerRel = `${stateDirRel}/${EMBEDDING_MARKER_BASENAME}`;
438
+ const localMembers: string[] = [];
439
+ if (await pathExists(await resolveSafeArchiveTarget(options.root, markerRel))) localMembers.push(markerRel);
440
+ let localShardNames: string[] = [];
441
+ try {
442
+ localShardNames = (await readdir(await resolveSafeArchiveTarget(options.root, shardDirRel)))
443
+ .filter((name) => EMBEDDING_SHARD_FILE_PATTERN.test(name));
444
+ } catch (err) {
445
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
446
+ }
447
+ for (const name of localShardNames) localMembers.push(`${shardDirRel}/${name}`);
448
+ if (localMembers.length === 0) continue;
449
+ if (!localMembers.every((relPath) => deletePaths.has(relPath))) {
450
+ // Incomplete deletion evidence: applying the evidenced subset would
451
+ // break the local generation apart. Defer the WHOLE dir as conflicts —
452
+ // the complete published generation survives and the push side cannot
453
+ // checkpoint the deletes as applied.
454
+ const deferred = deferGenerationConflicts(deletes, options.currentMap, "embedding_generation_manifest_required");
455
+ result.conflicts.push(...deferred.conflicts);
456
+ for (const conflicted of deferred.conflictedPaths) result.conflictedPaths.add(conflicted);
457
+ continue;
458
+ }
459
+ const tombstones = deletes.filter((change): change is Extract<OfflineSyncChange, { type: "delete" }> => change.type === "delete");
460
+ const removal = await applyEmbeddingGenerationRemoval({
461
+ root: options.root,
462
+ shardDirRel,
463
+ io: { deleteFile: options.io.deleteFile, readFile: options.io.readFile },
464
+ deletionMtimeByPath: new Map(
465
+ tombstones
466
+ .filter((change) => change.mtimeMs !== undefined)
467
+ .map((change) => [change.path, change.mtimeMs as number]),
468
+ ),
469
+ baseStates: new Map(tombstones.map((change) => [change.path, { sha256: change.baseSha256 }])),
470
+ now: options.now,
471
+ });
472
+ if (removal.deferredLocalDivergence) {
473
+ const deferred = deferGenerationConflicts(deletes, options.currentMap, "embedding_generation_diverged");
474
+ result.conflicts.push(...deferred.conflicts);
475
+ for (const conflicted of deferred.conflictedPaths) result.conflictedPaths.add(conflicted);
476
+ continue;
477
+ }
478
+ result.appliedDeletes += removal.deleted;
479
+ for (const relPath of removal.removedPaths) {
480
+ result.removedPaths.add(relPath);
481
+ result.transactionHandled.add(relPath);
482
+ }
483
+ }
484
+ return result;
485
+ }
486
+
487
+ export interface ChangesetGenerationManifestOptions {
488
+ changes: readonly OfflineSyncChange[];
489
+ /** The sender's complete current state (post-change generation members). */
490
+ currentFiles: readonly OfflineSyncFileState[];
491
+ baseFiles: readonly OfflineSyncFileState[];
492
+ /** The exact exclusion predicate the change walk used — any member it
493
+ * drops forces whole-generation omission (filter-to-omit, #3148). */
494
+ isExcluded: (relPath: string) => boolean;
495
+ }
496
+
497
+ /**
498
+ * Sender side: complete post-change membership for every generation touched
499
+ * by the changeset. A touched generation with ANY excluded member is omitted
500
+ * WHOLE — its changes are dropped by the caller contract below and no
501
+ * manifest is emitted — so a filtered generation can never look like a
502
+ * partial replacement on the receiver.
503
+ */
504
+ export function buildChangesetGenerationManifest(
505
+ options: ChangesetGenerationManifestOptions,
506
+ ): OfflineSyncChangesetGeneration[] {
507
+ const currentByPath = new Map(options.currentFiles.map((state) => [state.path, state]));
508
+ const baseByPath = new Map(options.baseFiles.map((state) => [state.path, state]));
509
+ const touchedDirs = new Map<string, OfflineSyncChange[]>();
510
+ for (const change of options.changes) {
511
+ const membership = embeddingGenerationMembership(change.path);
512
+ if (membership) {
513
+ if (!touchedDirs.has(membership.shardDir)) touchedDirs.set(membership.shardDir, []);
514
+ touchedDirs.get(membership.shardDir)!.push(change);
515
+ }
516
+ }
517
+ const generations: OfflineSyncChangesetGeneration[] = [];
518
+ for (const [shardDirRel, changes] of [...touchedDirs].sort(([left], [right]) => left.localeCompare(right))) {
519
+ const memberPaths = new Set<string>();
520
+ for (const relPath of currentByPath.keys()) {
521
+ const membership = embeddingGenerationMembership(relPath);
522
+ if (membership?.shardDir === shardDirRel) memberPaths.add(relPath);
523
+ }
524
+ for (const relPath of baseByPath.keys()) {
525
+ const membership = embeddingGenerationMembership(relPath);
526
+ if (membership?.shardDir === shardDirRel) memberPaths.add(relPath);
527
+ }
528
+ for (const change of changes) memberPaths.add(change.path);
529
+ // Filter-to-omit: one excluded member omits the whole generation.
530
+ let omitted = false;
531
+ for (const relPath of memberPaths) {
532
+ if (options.isExcluded(relPath)) {
533
+ omitted = true;
534
+ break;
535
+ }
536
+ }
537
+ if (omitted) continue;
538
+ const members = [...memberPaths]
539
+ .filter((relPath) => currentByPath.has(relPath))
540
+ .sort()
541
+ .map((relPath) => {
542
+ const state = currentByPath.get(relPath)!;
543
+ return { path: state.path, sha256: state.sha256, bytes: state.bytes };
544
+ });
545
+ if (members.length === 0) continue;
546
+ generations.push({ shardDir: shardDirRel, members });
547
+ }
548
+ return generations;
549
+ }