@remnic/core 9.51.0 → 9.52.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 (234) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -5
  2. package/dist/access-admin-ops-surface.js +16 -16
  3. package/dist/access-authorization-probe.d.ts +6 -5
  4. package/dist/access-authorization-probe.js +17 -17
  5. package/dist/access-boundary.d.ts +6 -5
  6. package/dist/access-boundary.js +16 -16
  7. package/dist/access-cli.js +39 -39
  8. package/dist/access-extraction-force-flush.d.ts +6 -5
  9. package/dist/access-extraction-force-flush.js +16 -16
  10. package/dist/access-http-lcm-compaction.d.ts +6 -5
  11. package/dist/access-http-lifecycle-flush.d.ts +6 -5
  12. package/dist/access-http-offline-stream.d.ts +6 -5
  13. package/dist/access-http-query.js +16 -16
  14. package/dist/access-http.d.ts +6 -5
  15. package/dist/access-http.js +22 -22
  16. package/dist/access-identity-continuity-surface.d.ts +5 -4
  17. package/dist/access-identity-continuity-surface.js +16 -16
  18. package/dist/access-lcm-surface.d.ts +6 -5
  19. package/dist/access-lcm-surface.js +16 -16
  20. package/dist/access-mcp.d.ts +6 -5
  21. package/dist/access-mcp.js +20 -20
  22. package/dist/access-namespace-preflight.js +16 -16
  23. package/dist/access-observe-write-surface.d.ts +6 -5
  24. package/dist/access-observe-write-surface.js +16 -16
  25. package/dist/access-offline-manifest.d.ts +5 -4
  26. package/dist/access-operations-batch.js +17 -17
  27. package/dist/access-operations.d.ts +9 -8
  28. package/dist/access-operations.js +19 -19
  29. package/dist/access-recall-concurrency.d.ts +6 -5
  30. package/dist/access-recall-concurrency.js +16 -16
  31. package/dist/access-recall-response.d.ts +6 -5
  32. package/dist/access-recall-response.js +16 -16
  33. package/dist/access-recall-surface.d.ts +6 -5
  34. package/dist/access-recall-surface.js +16 -16
  35. package/dist/access-schema.d.ts +88 -88
  36. package/dist/{access-service-BY0KJ44c.d.ts → access-service-Cbk3bNbD.d.ts} +3 -3
  37. package/dist/access-service-helpers.d.ts +6 -5
  38. package/dist/access-service.d.ts +6 -5
  39. package/dist/access-service.js +16 -16
  40. package/dist/access-surface-catalog.d.ts +6 -5
  41. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  42. package/dist/bootstrap.d.ts +5 -4
  43. package/dist/briefing.d.ts +1 -1
  44. package/dist/briefing.js +7 -7
  45. package/dist/buffer.d.ts +1 -1
  46. package/dist/{capsule-merge-XMFBXVDP.js → capsule-merge-HCJJMLPS.js} +4 -4
  47. package/dist/causal-consolidation.js +8 -8
  48. package/dist/{chunk-PWN32TAG.js → chunk-2ASAS3CQ.js} +2 -2
  49. package/dist/{chunk-WQM326GS.js → chunk-2LY6GIIE.js} +2 -2
  50. package/dist/{chunk-JL43BVDE.js → chunk-2OIQDTDC.js} +2 -2
  51. package/dist/{chunk-33SV3E43.js → chunk-3OY7KXHM.js} +3 -3
  52. package/dist/{chunk-AIUBBHSF.js → chunk-5UZ6UCOZ.js} +3 -3
  53. package/dist/chunk-5WITETWU.js +591 -0
  54. package/dist/chunk-5WITETWU.js.map +1 -0
  55. package/dist/{chunk-UT5BV5OG.js → chunk-7HXZJAID.js} +2 -2
  56. package/dist/{chunk-M7BXDKPR.js → chunk-7M6NYQWB.js} +3 -3
  57. package/dist/{chunk-TPTECFWR.js → chunk-B6LZ4IBH.js} +3 -3
  58. package/dist/{chunk-J5LLX6IL.js → chunk-BF6SXNQJ.js} +10 -10
  59. package/dist/{chunk-4PC4PRJE.js → chunk-BKIFTXHR.js} +2 -2
  60. package/dist/{chunk-VVG2BOKA.js → chunk-BMEYMKWX.js} +2 -2
  61. package/dist/{chunk-DCZBSE4F.js → chunk-D5YOZ66N.js} +18 -18
  62. package/dist/{chunk-BHXYAVUM.js → chunk-DLG5V6ND.js} +2 -2
  63. package/dist/{chunk-52UKFTUK.js → chunk-F4EU6VUC.js} +2 -2
  64. package/dist/{chunk-M4A44LUA.js → chunk-GSTIBXBW.js} +2 -2
  65. package/dist/{chunk-W5XVQFLM.js → chunk-GXFSQ6SD.js} +2 -2
  66. package/dist/{chunk-XADV5IZ3.js → chunk-HDIUYMSB.js} +5 -5
  67. package/dist/{chunk-FXTYIA46.js → chunk-J272WKX2.js} +2 -2
  68. package/dist/{chunk-ENTCVTQR.js → chunk-JJRVPWFY.js} +11 -1
  69. package/dist/chunk-JJRVPWFY.js.map +1 -0
  70. package/dist/{chunk-JYL3BVVZ.js → chunk-JSEJH2YQ.js} +2 -2
  71. package/dist/{chunk-5UDMFE3Q.js → chunk-MLXZD4WM.js} +2 -2
  72. package/dist/{chunk-PUSLMZBQ.js → chunk-NOEMGE56.js} +2 -2
  73. package/dist/{chunk-C5QLI33R.js → chunk-QINJITDS.js} +2 -2
  74. package/dist/{chunk-45BRYON6.js → chunk-QPOHMO6M.js} +2 -2
  75. package/dist/{chunk-ZQOCRIGA.js → chunk-QYN5PXDZ.js} +3 -3
  76. package/dist/{chunk-PA45XCUD.js → chunk-RANKJI5T.js} +2 -2
  77. package/dist/{chunk-ZHY5TMJG.js → chunk-RMHMUUBW.js} +2392 -628
  78. package/dist/chunk-RMHMUUBW.js.map +1 -0
  79. package/dist/{chunk-37ZO6LLJ.js → chunk-RYLEDKQ2.js} +2 -2
  80. package/dist/{chunk-CV6BBVYV.js → chunk-TEMSWHQD.js} +2 -2
  81. package/dist/{chunk-QGR6OVGT.js → chunk-VFSAWRMZ.js} +2 -2
  82. package/dist/{chunk-NLK2VZYZ.js → chunk-VXCNQYTC.js} +2 -2
  83. package/dist/{chunk-AZ2QBGYB.js → chunk-W7Y5JDVB.js} +1 -1
  84. package/dist/{chunk-VBHPOWBJ.js → chunk-WD5B5AIE.js} +1051 -591
  85. package/dist/chunk-WD5B5AIE.js.map +1 -0
  86. package/dist/{chunk-OYESWJ43.js → chunk-WLRD2TXF.js} +15 -10
  87. package/dist/chunk-WLRD2TXF.js.map +1 -0
  88. package/dist/{chunk-4F6X3RXW.js → chunk-X57M2Q4I.js} +2 -2
  89. package/dist/{chunk-6MTSFX7A.js → chunk-X7XEDOSL.js} +8 -8
  90. package/dist/{chunk-UEVLYVNW.js → chunk-YXKVLYXU.js} +2 -2
  91. package/dist/{chunk-OQAL6SIR.js → chunk-ZMSZVISA.js} +2 -2
  92. package/dist/{cli-CnOkH9hA.d.ts → cli-DMAFI0qt.d.ts} +3 -3
  93. package/dist/cli.d.ts +7 -6
  94. package/dist/cli.js +33 -33
  95. package/dist/compounding/engine.d.ts +1 -1
  96. package/dist/compounding/engine.js +7 -7
  97. package/dist/connectors/codex-materialize-runner.js +7 -7
  98. package/dist/connectors/index.js +7 -7
  99. package/dist/consolidation-provenance-check.d.ts +1 -1
  100. package/dist/consolidation-provenance-check.js +4 -4
  101. package/dist/consolidation-undo.d.ts +1 -1
  102. package/dist/consolidation-undo.js +3 -3
  103. package/dist/contradiction/index.d.ts +1 -1
  104. package/dist/convergence-refresh.d.ts +1 -1
  105. package/dist/convergence-refresh.js +9 -9
  106. package/dist/corpus-watermark.js +9 -9
  107. package/dist/dependency-propagation-delivery-D63r24pH.d.ts +41 -0
  108. package/dist/entity-retrieval.d.ts +1 -1
  109. package/dist/entity-retrieval.js +7 -7
  110. package/dist/explicit-capture.d.ts +5 -4
  111. package/dist/explicit-capture.js +4 -4
  112. package/dist/external-wiki-access.d.ts +6 -5
  113. package/dist/external-wiki-access.js +17 -17
  114. package/dist/external-wiki-mcp-tools.d.ts +6 -5
  115. package/dist/extraction.js +7 -7
  116. package/dist/{forget-UJDJHG6R.js → forget-HDS4N6CG.js} +3 -3
  117. package/dist/harmonic-retrieval.js +1 -1
  118. package/dist/index.d.ts +687 -686
  119. package/dist/index.js +39 -39
  120. package/dist/maintenance/memory-governance.js +7 -7
  121. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  122. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  123. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  124. package/dist/maintenance/rebuild-memory-projection.js +8 -8
  125. package/dist/{maintenance-CKwxpdRx.d.ts → maintenance-kBII2qUI.d.ts} +3 -2
  126. package/dist/mcp-memory-inspector-app.d.ts +6 -5
  127. package/dist/memory-worth-outcomes.d.ts +1 -1
  128. package/dist/namespaces/migrate.d.ts +1 -1
  129. package/dist/namespaces/migrate.js +8 -8
  130. package/dist/namespaces/storage.d.ts +1 -1
  131. package/dist/namespaces/storage.js +7 -7
  132. package/dist/operator-doctor-corpus.js +10 -10
  133. package/dist/operator-toolkit.d.ts +1 -1
  134. package/dist/operator-toolkit.js +15 -15
  135. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  136. package/dist/orchestration/maintenance.d.ts +2 -2
  137. package/dist/orchestration/maintenance.js +12 -12
  138. package/dist/{orchestrator-BshGam-o.d.ts → orchestrator-DHy5LtTy.d.ts} +20 -35
  139. package/dist/orchestrator.d.ts +5 -4
  140. package/dist/orchestrator.js +39 -39
  141. package/dist/page-versioning.js +3 -3
  142. package/dist/schemas.d.ts +138 -138
  143. package/dist/semantic-consolidation.js +8 -8
  144. package/dist/semantic-rule-promotion.js +7 -7
  145. package/dist/semantic-rule-verifier.js +7 -7
  146. package/dist/{service-BIgVM7fL.d.ts → service-PT2_z7xm.d.ts} +1 -1
  147. package/dist/shared-context/manager.d.ts +8 -8
  148. package/dist/source-agent-qualifier.js +7 -7
  149. package/dist/{storage-CVfGWqCI.d.ts → storage-BmVudhk4.d.ts} +46 -27
  150. package/dist/storage.d.ts +1 -1
  151. package/dist/storage.js +6 -6
  152. package/dist/temporal-supersession.d.ts +51 -26
  153. package/dist/temporal-supersession.js +3 -1
  154. package/dist/tier-migration.d.ts +1 -1
  155. package/dist/transfer/capsule-import.js +4 -4
  156. package/dist/transfer/types.d.ts +66 -66
  157. package/dist/verified-recall.js +7 -7
  158. package/package.json +2 -2
  159. package/src/lifecycle/tombstones.ts +30 -0
  160. package/src/orchestration/consolidation-run.ts +324 -106
  161. package/src/orchestration/contradiction-linking-coordinator.ts +237 -64
  162. package/src/orchestration/dependency-propagation-delivery.test.ts +2353 -0
  163. package/src/orchestration/dependency-propagation-delivery.ts +1143 -0
  164. package/src/orchestration/dependency-propagation-queue-state.ts +220 -0
  165. package/src/orchestration/dependency-propagation-recovery-replay.ts +237 -0
  166. package/src/orchestration/dependency-propagation-replay-reconciliation.ts +73 -0
  167. package/src/orchestration/dependency-propagation-triggers.test.ts +1069 -11
  168. package/src/orchestration/dependency-propagation.test.ts +82 -31
  169. package/src/orchestration/dependency-propagation.ts +55 -17
  170. package/src/orchestration/extraction-persist-deps.ts +2 -2
  171. package/src/orchestration/extraction-persist.ts +14 -15
  172. package/src/orchestration/extraction-queue-coordinator.ts +23 -1
  173. package/src/orchestration/extraction-shutdown.ts +29 -0
  174. package/src/orchestration/maintenance.ts +13 -7
  175. package/src/orchestration/turn-ingestion.ts +5 -1
  176. package/src/orchestrator-background-writes.test.ts +265 -1
  177. package/src/orchestrator-extraction-queue.test.ts +39 -0
  178. package/src/orchestrator.ts +59 -58
  179. package/src/storage/committed-invalidation.ts +34 -0
  180. package/src/storage/deletion-revision-store.ts +529 -0
  181. package/src/storage/supersession-audit.ts +41 -0
  182. package/src/storage/supersession-side-effects.ts +52 -25
  183. package/src/storage-contract/failure-semantics.test.ts +12 -0
  184. package/src/storage-contract/round-trip.test.ts +184 -2
  185. package/src/storage-deletion-revisions.test.ts +213 -1
  186. package/src/storage-frontmatter-cas.test.ts +141 -0
  187. package/src/storage-memory-lifecycle-ledger.test.ts +372 -0
  188. package/src/storage-replay.test.ts +149 -0
  189. package/src/storage.ts +345 -358
  190. package/src/temporal-supersession.test.ts +431 -2
  191. package/src/temporal-supersession.ts +567 -107
  192. package/src/testing/subjects/dependency-propagation.test.ts +179 -38
  193. package/dist/chunk-ENTCVTQR.js.map +0 -1
  194. package/dist/chunk-EWMTOVMY.js +0 -484
  195. package/dist/chunk-EWMTOVMY.js.map +0 -1
  196. package/dist/chunk-OYESWJ43.js.map +0 -1
  197. package/dist/chunk-VBHPOWBJ.js.map +0 -1
  198. package/dist/chunk-ZHY5TMJG.js.map +0 -1
  199. /package/dist/{capsule-merge-XMFBXVDP.js.map → capsule-merge-HCJJMLPS.js.map} +0 -0
  200. /package/dist/{chunk-PWN32TAG.js.map → chunk-2ASAS3CQ.js.map} +0 -0
  201. /package/dist/{chunk-WQM326GS.js.map → chunk-2LY6GIIE.js.map} +0 -0
  202. /package/dist/{chunk-JL43BVDE.js.map → chunk-2OIQDTDC.js.map} +0 -0
  203. /package/dist/{chunk-33SV3E43.js.map → chunk-3OY7KXHM.js.map} +0 -0
  204. /package/dist/{chunk-AIUBBHSF.js.map → chunk-5UZ6UCOZ.js.map} +0 -0
  205. /package/dist/{chunk-UT5BV5OG.js.map → chunk-7HXZJAID.js.map} +0 -0
  206. /package/dist/{chunk-M7BXDKPR.js.map → chunk-7M6NYQWB.js.map} +0 -0
  207. /package/dist/{chunk-TPTECFWR.js.map → chunk-B6LZ4IBH.js.map} +0 -0
  208. /package/dist/{chunk-J5LLX6IL.js.map → chunk-BF6SXNQJ.js.map} +0 -0
  209. /package/dist/{chunk-4PC4PRJE.js.map → chunk-BKIFTXHR.js.map} +0 -0
  210. /package/dist/{chunk-VVG2BOKA.js.map → chunk-BMEYMKWX.js.map} +0 -0
  211. /package/dist/{chunk-DCZBSE4F.js.map → chunk-D5YOZ66N.js.map} +0 -0
  212. /package/dist/{chunk-BHXYAVUM.js.map → chunk-DLG5V6ND.js.map} +0 -0
  213. /package/dist/{chunk-52UKFTUK.js.map → chunk-F4EU6VUC.js.map} +0 -0
  214. /package/dist/{chunk-M4A44LUA.js.map → chunk-GSTIBXBW.js.map} +0 -0
  215. /package/dist/{chunk-W5XVQFLM.js.map → chunk-GXFSQ6SD.js.map} +0 -0
  216. /package/dist/{chunk-XADV5IZ3.js.map → chunk-HDIUYMSB.js.map} +0 -0
  217. /package/dist/{chunk-FXTYIA46.js.map → chunk-J272WKX2.js.map} +0 -0
  218. /package/dist/{chunk-JYL3BVVZ.js.map → chunk-JSEJH2YQ.js.map} +0 -0
  219. /package/dist/{chunk-5UDMFE3Q.js.map → chunk-MLXZD4WM.js.map} +0 -0
  220. /package/dist/{chunk-PUSLMZBQ.js.map → chunk-NOEMGE56.js.map} +0 -0
  221. /package/dist/{chunk-C5QLI33R.js.map → chunk-QINJITDS.js.map} +0 -0
  222. /package/dist/{chunk-45BRYON6.js.map → chunk-QPOHMO6M.js.map} +0 -0
  223. /package/dist/{chunk-ZQOCRIGA.js.map → chunk-QYN5PXDZ.js.map} +0 -0
  224. /package/dist/{chunk-PA45XCUD.js.map → chunk-RANKJI5T.js.map} +0 -0
  225. /package/dist/{chunk-37ZO6LLJ.js.map → chunk-RYLEDKQ2.js.map} +0 -0
  226. /package/dist/{chunk-CV6BBVYV.js.map → chunk-TEMSWHQD.js.map} +0 -0
  227. /package/dist/{chunk-QGR6OVGT.js.map → chunk-VFSAWRMZ.js.map} +0 -0
  228. /package/dist/{chunk-NLK2VZYZ.js.map → chunk-VXCNQYTC.js.map} +0 -0
  229. /package/dist/{chunk-AZ2QBGYB.js.map → chunk-W7Y5JDVB.js.map} +0 -0
  230. /package/dist/{chunk-4F6X3RXW.js.map → chunk-X57M2Q4I.js.map} +0 -0
  231. /package/dist/{chunk-6MTSFX7A.js.map → chunk-X7XEDOSL.js.map} +0 -0
  232. /package/dist/{chunk-UEVLYVNW.js.map → chunk-YXKVLYXU.js.map} +0 -0
  233. /package/dist/{chunk-OQAL6SIR.js.map → chunk-ZMSZVISA.js.map} +0 -0
  234. /package/dist/{forget-UJDJHG6R.js.map → forget-HDS4N6CG.js.map} +0 -0
package/src/storage.ts CHANGED
@@ -4,26 +4,27 @@ import {
4
4
  readdir,
5
5
  readFile,
6
6
  realpath,
7
- rename,
8
7
  stat,
9
8
  writeFile,
10
9
  mkdir,
11
10
  unlink,
12
11
  appendFile,
13
12
  open,
14
- type FileHandle,
15
13
  } from "node:fs/promises";
16
- import { appendFileSync, createReadStream, mkdirSync, readFileSync, statSync, type Dirent } from "node:fs";
17
- import { createHash, randomUUID } from "node:crypto";
14
+ import { appendFileSync, createReadStream, mkdirSync, readFileSync, statSync } from "node:fs";
15
+ import { createHash } from "node:crypto";
18
16
  import { normalizeContent, computeContentHash } from "./content-hash.js";
19
17
  import { raceAbort } from "./abort-error.js";
20
18
  import { checkCorpusReadAbort, type CorpusReadOptions } from "./corpus-read-cancellation.js";
21
19
  import { selectArtifactMatches, type ArtifactSearchOptions } from "./artifact-search.js";
22
20
  import path from "node:path";
21
+ import { resolveSafeStoragePath } from "./storage-paths.js";
23
22
  import { log } from "./logger.js";
24
23
  import { createMemorySnapshot } from "./memory-snapshot.js";
25
24
  import { assertMemoryFrontmatterId, warnProjectionFallback } from "./storage-guards.js";
26
25
  import { MemoryReadStore } from "./storage/memory-read-store.js";
26
+ import { hasSupersessionAudit } from "./storage/supersession-audit.js";
27
+ import { runCommittedInvalidation } from "./storage/committed-invalidation.js";
27
28
  import { renderProfileWithLastUpdated } from "./storage/profile-header.js";
28
29
  import { readMaybeEncryptedLines, readMemoryActionEventRowsFromLines } from "./storage/secure-line-reader.js";
29
30
  import {
@@ -40,6 +41,7 @@ import {
40
41
  } from "./storage/memory-lifecycle-ledger-access.js";
41
42
  import { selfDeps } from "./orchestration/self-deps.js";
42
43
  import { EntityStore } from "./storage/entity-store.js";
44
+ import { DeletionRevisionStore, invalidationCommitFingerprint } from "./storage/deletion-revision-store.js";
43
45
  import { IdentityContinuityStore } from "./storage/identity-continuity-store.js";
44
46
  import * as entityMigration from "./storage/entity-canonical-id-migration.js";
45
47
  import * as entityRefs from "./storage/entity-canonical-id-references.js";
@@ -57,7 +59,6 @@ export { normalizeEntityName } from "./entity-id-normalization.js";
57
59
  import { isErrnoCode } from "./utils/errno.js";
58
60
  import { getCategoryDir, categoryDirName } from "./utils/category-dir.js";
59
61
  import { decodeYamlScalar } from "./utils/yaml-scalar.js";
60
- import { withHeldFileLock, type HeldFileLockController } from "./utils/serialize-mutations.js";
61
62
  import { qmdCollectionPathParts, qmdResultPathCandidates } from "./orchestration/qmd-result-resolver.js";
62
63
  import {
63
64
  clearMemoryCache,
@@ -250,36 +251,6 @@ type OfflineSyncDigestCacheEntry = {
250
251
  bytes: number;
251
252
  };
252
253
 
253
- type DeletionRevisionMetadata = {
254
- version: 1;
255
- deletions: Array<{ path: string; mtimeMs: number }>;
256
- };
257
-
258
- const DELETION_REVISION_MAX_MTIME_MS = 8_640_000_000_000_000;
259
- const DELETION_REVISION_LOCK_STALE_MS = 60_000;
260
- const DELETION_REVISION_LOCK_MAX_WAIT_MS = 120_000;
261
-
262
- function isValidDeletionRevisionPath(value: unknown): value is string {
263
- return (
264
- typeof value === "string" &&
265
- value.length > 0 &&
266
- !value.includes("\\") &&
267
- !value.includes("\0") &&
268
- !path.posix.isAbsolute(value) &&
269
- path.posix.normalize(value) === value &&
270
- value !== "." &&
271
- value !== ".." &&
272
- !value.startsWith("../")
273
- );
274
- }
275
-
276
- function deletionRevisionPathIdentity(value: string): string {
277
- return value
278
- .normalize("NFC")
279
- .toUpperCase()
280
- .toLowerCase()
281
- .replace(/\u00df/g, "ss");
282
- }
283
254
 
284
255
  export interface ReextractJobRequest {
285
256
  memoryId: string;
@@ -1757,6 +1728,7 @@ export type SealedWriteExtras = Omit<
1757
1728
 
1758
1729
  export class StorageManager extends TombstoneBlockedCaptureIndexHost {
1759
1730
  private knowledgeIndexCache: { result: string; builtAt: number } | null = null;
1731
+ private readonly deletionRevisionStore: DeletionRevisionStore;
1760
1732
  private artifactIndexCache: { memories: MemoryFile[]; loadedAtMs: number; writeVersion: number } | null = null;
1761
1733
  private projectionLedgerLagManager = new ProjectionLedgerLagManager();
1762
1734
  private static readonly loadedMemorySnapshots = new WeakMap<MemoryFile, string>();
@@ -2227,6 +2199,13 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2227
2199
  hotMemoriesCacheEnabledOverride?: boolean
2228
2200
  ) {
2229
2201
  super();
2202
+ this.deletionRevisionStore = new DeletionRevisionStore({
2203
+ baseDir,
2204
+ deletionRevisionMetadataPath: path.join(baseDir, ".offline-sync", "deletion-revisions.v1.json"),
2205
+ invalidationCommitMetadataPath: path.join(baseDir, ".offline-sync", "invalidation-commits.v1.json"),
2206
+ deletionRevisionLockPath: path.join(baseDir, ".offline-sync", "deletion-revisions.v1.json.lock"),
2207
+ assertManagedStoragePath: (filePath, method) => this.assertManagedStoragePath(filePath, method),
2208
+ });
2230
2209
  this.hotMemoriesCacheEnabled =
2231
2210
  hotMemoriesCacheEnabledOverride ??
2232
2211
  StorageManager.hotMemoriesCacheDefaultByDir.get(baseDir) ??
@@ -2644,6 +2623,26 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2644
2623
  private readStorageSecureFile(filePath: string): Promise<string> {
2645
2624
  return readMaybeEncryptedFile(filePath, this._secureStoreKey, this.baseDir);
2646
2625
  }
2626
+ async readDependencyPropagationQueueFile(filePath: string): Promise<string> {
2627
+ const target = await resolveSafeStoragePath(
2628
+ this.baseDir,
2629
+ path.relative(
2630
+ this.baseDir,
2631
+ this.assertManagedStoragePath(filePath, "storage.readDependencyPropagationQueueFile"),
2632
+ ),
2633
+ );
2634
+ return this.readStorageSecureFile(target);
2635
+ }
2636
+ async writeDependencyPropagationQueueFile(filePath: string, content: string): Promise<void> {
2637
+ const target = await resolveSafeStoragePath(
2638
+ this.baseDir,
2639
+ path.relative(
2640
+ this.baseDir,
2641
+ this.assertManagedStoragePath(filePath, "storage.writeDependencyPropagationQueueFile"),
2642
+ ),
2643
+ );
2644
+ return this.writeStorageSecureFile(target, content);
2645
+ }
2647
2646
  protected writeStorageSecureFile(filePath: string, content: string | Buffer, forceEncrypt = false): Promise<void> {
2648
2647
  const writeKey = this.resolveWriteKey(forceEncrypt);
2649
2648
  return writeMaybeEncryptedFile(filePath, content, writeKey, {}, this.baseDir).then(() => {
@@ -2664,216 +2663,30 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2664
2663
  }
2665
2664
  return resolved;
2666
2665
  }
2667
-
2668
- private parseDeletionRevisionMetadata(raw: string): Map<string, number> {
2669
- let parsed: unknown;
2670
- try {
2671
- parsed = JSON.parse(raw);
2672
- } catch {
2673
- throw new Error("Deletion revision metadata is invalid.");
2674
- }
2675
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2676
- throw new Error("Deletion revision metadata is invalid.");
2677
- }
2678
- const root = parsed as Record<string, unknown>;
2679
- if (
2680
- Object.keys(root).sort().join(",") !== "deletions,version" ||
2681
- root.version !== 1 ||
2682
- !Array.isArray(root.deletions)
2683
- ) {
2684
- throw new Error("Deletion revision metadata is invalid.");
2685
- }
2686
- const revisions = new Map<string, number>();
2687
- const pathByIdentity = new Map<string, string>();
2688
- for (const rawEntry of root.deletions) {
2689
- if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
2690
- throw new Error("Deletion revision metadata is invalid.");
2691
- }
2692
- const entry = rawEntry as Record<string, unknown>;
2693
- if (
2694
- Object.keys(entry).sort().join(",") !== "mtimeMs,path" ||
2695
- !isValidDeletionRevisionPath(entry.path) ||
2696
- typeof entry.mtimeMs !== "number" ||
2697
- !Number.isFinite(entry.mtimeMs) ||
2698
- entry.mtimeMs < 0 ||
2699
- entry.mtimeMs > DELETION_REVISION_MAX_MTIME_MS ||
2700
- revisions.has(entry.path)
2701
- ) {
2702
- throw new Error("Deletion revision metadata is invalid.");
2703
- }
2704
- const identity = deletionRevisionPathIdentity(entry.path);
2705
- if (pathByIdentity.has(identity)) {
2706
- throw new Error("Deletion revision metadata is invalid.");
2707
- }
2708
- pathByIdentity.set(identity, entry.path);
2709
- revisions.set(entry.path, entry.mtimeMs);
2710
- }
2711
- return new Map([...revisions.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
2712
- }
2713
-
2714
- private async readDeletionRevisionMetadata(): Promise<Map<string, number>> {
2715
- let raw: string;
2716
- try {
2717
- raw = await readFile(this.deletionRevisionMetadataPath, "utf8");
2718
- } catch (error) {
2719
- if (isErrnoCode(error, "ENOENT")) return new Map();
2720
- throw new Error("Deletion revision metadata is unavailable.");
2721
- }
2722
- return this.parseDeletionRevisionMetadata(raw);
2666
+ async readDeletionRevisions(): Promise<ReadonlyMap<string, number>> {
2667
+ return this.deletionRevisionStore.readDeletionRevisions();
2723
2668
  }
2724
-
2725
- private async writeDeletionRevisionMetadata(
2726
- revisions: ReadonlyMap<string, number>,
2727
- lock: HeldFileLockController
2728
- ): Promise<void> {
2729
- const deletions = [...revisions.entries()]
2730
- .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
2731
- .map(([entryPath, mtimeMs]) => ({ path: entryPath, mtimeMs }));
2732
- const metadata: DeletionRevisionMetadata = { version: 1, deletions };
2733
- const temporaryPath = `${this.deletionRevisionMetadataPath}.${process.pid}.${randomUUID()}.tmp`;
2734
- let handle: FileHandle | null = null;
2735
- try {
2736
- handle = await open(temporaryPath, "wx", 0o600);
2737
- await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8");
2738
- await handle.sync();
2739
- await handle.close();
2740
- handle = null;
2741
- if (!(await lock.refresh())) {
2742
- throw new Error("Deletion revision metadata lock was lost.");
2743
- }
2744
- await rename(temporaryPath, this.deletionRevisionMetadataPath);
2745
- } finally {
2746
- if (handle !== null) await handle.close().catch(() => undefined);
2747
- await unlink(temporaryPath).catch((error: unknown) => {
2748
- if (!isErrnoCode(error, "ENOENT")) throw error;
2749
- });
2750
- }
2669
+ async hasCommittedInvalidation(
2670
+ memory: Pick<MemoryFile, "content" | "frontmatter">,
2671
+ ): Promise<boolean> {
2672
+ return this.deletionRevisionStore.hasCommittedInvalidation(memory);
2751
2673
  }
2752
-
2753
- private async withDeletionRevisionLock<T>(task: (lock: HeldFileLockController) => Promise<T>): Promise<T> {
2754
- return withHeldFileLock(
2755
- this.deletionRevisionLockPath,
2756
- {
2757
- staleMs: DELETION_REVISION_LOCK_STALE_MS,
2758
- maxWaitMs: DELETION_REVISION_LOCK_MAX_WAIT_MS,
2759
- },
2760
- async (acquired, lock) => {
2761
- if (!acquired) throw new Error("Deletion revision metadata lock is unavailable.");
2762
- return task(lock);
2763
- }
2764
- );
2674
+ private async recordCommittedInvalidation(memory: MemoryFile): Promise<void> {
2675
+ return this.deletionRevisionStore.recordCommittedInvalidation(memory);
2765
2676
  }
2766
-
2767
- async readDeletionRevisions(): Promise<ReadonlyMap<string, number>> {
2768
- return this.withDeletionRevisionLock(async () => this.readDeletionRevisionMetadata());
2677
+ private async clearCommittedInvalidation(
2678
+ memory: Pick<MemoryFile, "content" | "frontmatter">,
2679
+ ): Promise<void> {
2680
+ return this.deletionRevisionStore.clearCommittedInvalidation(memory);
2769
2681
  }
2770
-
2771
2682
  async recordReplicatedDeletionRevision(filePath: string, mtimeMs: number): Promise<void> {
2772
- const target = this.assertManagedStoragePath(filePath, "storage.recordReplicatedDeletionRevision");
2773
- if (
2774
- typeof mtimeMs !== "number" ||
2775
- !Number.isFinite(mtimeMs) ||
2776
- mtimeMs < 0 ||
2777
- mtimeMs > DELETION_REVISION_MAX_MTIME_MS
2778
- ) {
2779
- throw new Error("Deletion revision timestamp is invalid.");
2780
- }
2781
- const relativePath = path.relative(this.baseDir, target).split(path.sep).join("/");
2782
- if (!isValidDeletionRevisionPath(relativePath)) {
2783
- throw new Error("Deletion revision path is invalid.");
2784
- }
2785
- await this.withDeletionRevisionLock(async (lock) => {
2786
- const revisions = await this.readDeletionRevisionMetadata();
2787
- const identity = deletionRevisionPathIdentity(relativePath);
2788
- let existingPath: string | undefined;
2789
- for (const candidatePath of revisions.keys()) {
2790
- if (deletionRevisionPathIdentity(candidatePath) === identity) {
2791
- existingPath = candidatePath;
2792
- break;
2793
- }
2794
- }
2795
- const existingMtimeMs = existingPath === undefined ? undefined : revisions.get(existingPath);
2796
- if (existingMtimeMs !== undefined && existingMtimeMs >= mtimeMs) return;
2797
-
2798
- const updated = new Map(revisions);
2799
- if (existingPath !== undefined) updated.delete(existingPath);
2800
- updated.set(relativePath, mtimeMs);
2801
- await this.writeDeletionRevisionMetadata(updated, lock);
2802
- });
2683
+ return this.deletionRevisionStore.recordReplicatedDeletionRevision(filePath, mtimeMs);
2803
2684
  }
2804
-
2805
2685
  protected async writeManagedStorageFile(filePath: string, write: () => Promise<void>): Promise<void> {
2806
- const target = this.assertManagedStoragePath(filePath, "storage.writeManagedStorageFile");
2807
- const relativePath = path.relative(this.baseDir, target).split(path.sep).join("/");
2808
- if (!isValidDeletionRevisionPath(relativePath)) {
2809
- throw new Error("Deletion revision path is invalid.");
2810
- }
2811
- await this.withDeletionRevisionLock(async (lock) => {
2812
- const before = await this.readDeletionRevisionMetadata();
2813
- const identity = deletionRevisionPathIdentity(relativePath);
2814
- const existingPath = [...before.keys()].find(
2815
- (candidatePath) => deletionRevisionPathIdentity(candidatePath) === identity
2816
- );
2817
- await write();
2818
- if (existingPath === undefined) return;
2819
- const updated = new Map(before);
2820
- updated.delete(existingPath);
2821
- await this.writeDeletionRevisionMetadata(updated, lock);
2822
- });
2686
+ return this.deletionRevisionStore.writeManagedStorageFile(filePath, write);
2823
2687
  }
2824
-
2825
2688
  protected async deleteManagedStorageFile(filePath: string, deletionMtimeMs?: number | null): Promise<boolean> {
2826
- const target = this.assertManagedStoragePath(filePath, "storage.deleteManagedStorageFile");
2827
- if (
2828
- deletionMtimeMs !== undefined &&
2829
- deletionMtimeMs !== null &&
2830
- (typeof deletionMtimeMs !== "number" ||
2831
- !Number.isFinite(deletionMtimeMs) ||
2832
- deletionMtimeMs < 0 ||
2833
- deletionMtimeMs > DELETION_REVISION_MAX_MTIME_MS)
2834
- ) {
2835
- throw new Error("Deletion revision timestamp is invalid.");
2836
- }
2837
- return this.withDeletionRevisionLock(async (lock) => {
2838
- try {
2839
- await lstat(target);
2840
- } catch (error) {
2841
- if (isErrnoCode(error, "ENOENT")) return false;
2842
- throw error;
2843
- }
2844
- const relativePath = path.relative(this.baseDir, target).split(path.sep).join("/");
2845
- if (!isValidDeletionRevisionPath(relativePath)) {
2846
- throw new Error("Deletion revision path is invalid.");
2847
- }
2848
- const revision = deletionMtimeMs === null ? undefined : (deletionMtimeMs ?? Date.now());
2849
- const before = await this.readDeletionRevisionMetadata();
2850
- const identity = deletionRevisionPathIdentity(relativePath);
2851
- let existingPath: string | undefined;
2852
- for (const candidatePath of before.keys()) {
2853
- if (deletionRevisionPathIdentity(candidatePath) === identity) {
2854
- existingPath = candidatePath;
2855
- break;
2856
- }
2857
- }
2858
- const existing = existingPath === undefined ? undefined : before.get(existingPath);
2859
- const changed =
2860
- (existingPath !== undefined && existingPath !== relativePath) ||
2861
- (revision === undefined ? existing !== undefined : existing !== revision);
2862
- if (changed) {
2863
- const updated = new Map(before);
2864
- if (existingPath !== undefined) updated.delete(existingPath);
2865
- if (revision !== undefined) updated.set(relativePath, revision);
2866
- await this.writeDeletionRevisionMetadata(updated, lock);
2867
- }
2868
- try {
2869
- await unlink(target);
2870
- return true;
2871
- } catch (error) {
2872
- if (changed) await this.writeDeletionRevisionMetadata(before, lock);
2873
- if (isErrnoCode(error, "ENOENT")) return false;
2874
- throw error;
2875
- }
2876
- });
2689
+ return this.deletionRevisionStore.deleteManagedStorageFile(filePath, deletionMtimeMs);
2877
2690
  }
2878
2691
 
2879
2692
  async readOfflineSyncFile(filePath: string): Promise<Buffer> {
@@ -3159,12 +2972,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3159
2972
  private get offlineSyncDigestCachePath(): string {
3160
2973
  return path.join(this.baseDir, ".offline-sync", "digest-cache.v1.json");
3161
2974
  }
3162
- private get deletionRevisionMetadataPath(): string {
3163
- return path.join(this.baseDir, ".offline-sync", "deletion-revisions.v1.json");
3164
- }
3165
- private get deletionRevisionLockPath(): string {
3166
- return `${this.deletionRevisionMetadataPath}.lock`;
3167
- }
3168
2975
  private get entitySynthesisQueuePath(): string {
3169
2976
  return path.join(this.stateDir, "entity-synthesis-queue.json");
3170
2977
  }
@@ -3279,8 +3086,8 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3279
3086
  rawContent: string;
3280
3087
  entityRef?: string;
3281
3088
  supersessionKey?: string;
3089
+ operationKey?: string;
3282
3090
  createdAt?: string;
3283
- /** Canonical contentHash from the retired memory's frontmatter (#1579). */
3284
3091
  contentHash?: string;
3285
3092
  }): Promise<string | null> {
3286
3093
  if (!this.tombstonesConfig.enabled) return null;
@@ -3305,6 +3112,23 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3305
3112
  return null;
3306
3113
  }
3307
3114
  }
3115
+ async hasExactTombstone(input: {
3116
+ sourceMemoryId: string;
3117
+ contentHash?: string;
3118
+ entityRef?: string;
3119
+ supersessionKey?: string;
3120
+ createdAt?: string;
3121
+ operationKey?: string;
3122
+ }): Promise<boolean> {
3123
+ if (!this.tombstonesConfig.enabled) return false;
3124
+ try {
3125
+ const store = await this.getTombstoneStore();
3126
+ return await store.hasExactEntry(input);
3127
+ } catch (err) {
3128
+ log.warn(`exact tombstone proof failed (memory=${input.sourceMemoryId}): ${err}`);
3129
+ return false;
3130
+ }
3131
+ }
3308
3132
 
3309
3133
  /**
3310
3134
  * Revoke a tombstone (re-allow the content). Used by the review-queue
@@ -4597,22 +4421,17 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
4597
4421
  clearInFlightReads();
4598
4422
  StorageManager.questionsCache.clear();
4599
4423
  StorageManager.coldMemoriesCache.clear(); // also wipe the cold-scan TTL cache
4600
- // Also clear the module-level memory-cache layers (hot-memories result cache
4601
- // + entity/derived/QMD layers) so the reset seam is authoritative for tests
4602
- // that write files directly (issue #1902).
4424
+ StorageManager.artifactWriteVersionByDir.clear();
4425
+ StorageManager.memoryStatusVersionByDir.clear();
4426
+ StorageManager.memoryCorpusVersionByDir.clear();
4427
+ StorageManager.entityMutationVersionByDir.clear();
4428
+ StorageManager.coldWriteVersionByDir.clear();
4603
4429
  clearMemoryCache();
4604
- // Reset the hot-cache config statics to construction defaults so each test
4605
- // starts from a clean slate (issue #1902). resetStaticCaches() runs before
4606
- // every contract test; without this the per-dir maps and the first-writer
4607
- // process-wide seed would leak across tests.
4608
4430
  StorageManager.hotMemoriesCacheDefaultByDir.clear();
4609
4431
  StorageManager.hotMemoriesCacheTtlByDir.clear();
4610
4432
  StorageManager.hotMemoriesCacheDefault = true;
4611
4433
  StorageManager.hotMemoriesCacheTtlMs = 60_000;
4612
4434
  StorageManager.hotMemoriesCacheProcessDefaultSeeded = false;
4613
- // Reset the #1904 scope-invalidation gate so a legacy-mode (=false) test
4614
- // dir cannot leak its setting into a later test constructing over a reused
4615
- // temp path.
4616
4435
  StorageManager.scopedCacheInvalidationByDir.clear();
4617
4436
  }
4618
4437
 
@@ -5047,15 +4866,18 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5047
4866
  );
5048
4867
  }
5049
4868
  } catch (err) {
5050
- // Re-throw store-locked errors a locked encrypted store
5051
- // must fail loudly, not silently return an empty archive.
4869
+ // A bad archive member must not hide readable siblings. A locked
4870
+ // secure store remains authoritative even for one member.
5052
4871
  if (err instanceof SecureStoreLockedError) throw err;
5053
- // Skip other unreadable files (ENOENT, parse failures, etc.)
4872
+ if (!isErrnoCode(err, "ENOENT")) {
4873
+ log.warn(`skipping unreadable archived memory ${fullPath}: ${err}`);
4874
+ }
5054
4875
  }
5055
4876
  }
5056
4877
  }
5057
- } catch {
5058
- // Directory doesn't exist yet
4878
+ } catch (error) {
4879
+ if (error instanceof SecureStoreLockedError) throw error;
4880
+ if (!isErrnoCode(error, "ENOENT")) throw error;
5059
4881
  }
5060
4882
  };
5061
4883
 
@@ -5383,39 +5205,50 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5383
5205
  return null;
5384
5206
  }
5385
5207
 
5386
- async invalidateMemory(id: string): Promise<boolean> {
5387
- const memories = await this.readAllMemories();
5388
- const memory = memories.find((candidate) => candidate.frontmatter.id === id);
5389
- if (!memory) return false;
5208
+ async invalidateMemory(
5209
+ id: string,
5210
+ expectedSnapshot?: Pick<MemoryFile, "content" | "frontmatter"> & Partial<Pick<MemoryFile, "path">>,
5211
+ options?: { recordCommitProof?: boolean },
5212
+ ): Promise<boolean> {
5213
+ const memory = expectedSnapshot?.path
5214
+ ? await this.readMemoryByPath(expectedSnapshot.path)
5215
+ : (await this.readAllMemories()).find((candidate) => candidate.frontmatter.id === id) ?? null;
5216
+ if (!memory || memory.frontmatter.id !== id) return false;
5390
5217
 
5391
5218
  return await this.runTombstoneBlockedInvalidation(memory, async (current, rebuildMarker, markDurable) => {
5392
- const deleted = await archive.recordArchiveDelete(this.baseDir, current.path, () =>
5393
- this.deleteManagedStorageFile(current.path));
5394
- if (!deleted) return false;
5395
- markDurable();
5396
- markProjectedMemoryPathInvalid(this.baseDir, id);
5397
- this.invalidateAllMemoriesCache();
5398
- await this.rebuildTombstoneBlockedCaptureAfterInvalidation(rebuildMarker);
5399
- this.bumpMemoryStatusVersion();
5400
- log.debug(`invalidated memory ${id}`);
5401
- return true;
5219
+ if (
5220
+ expectedSnapshot &&
5221
+ invalidationCommitFingerprint(current) !== invalidationCommitFingerprint(expectedSnapshot)
5222
+ ) {
5223
+ return false;
5224
+ }
5225
+ return runCommittedInvalidation({
5226
+ memoryId: id,
5227
+ recordProof: options?.recordCommitProof === true ? () => this.recordCommittedInvalidation(current) : undefined,
5228
+ clearProof: options?.recordCommitProof === true ? () => this.clearCommittedInvalidation(current) : undefined,
5229
+ deleteMemory: () => archive.recordArchiveDelete(this.baseDir, current.path, () =>
5230
+ this.deleteManagedStorageFile(current.path)),
5231
+ afterDelete: async () => {
5232
+ markDurable();
5233
+ markProjectedMemoryPathInvalid(this.baseDir, id);
5234
+ this.invalidateAllMemoriesCache();
5235
+ if (this.isColdOrArchiveTierPath(current.path)) this.invalidateColdMemoriesCache();
5236
+ await this.rebuildTombstoneBlockedCaptureAfterInvalidation(rebuildMarker);
5237
+ this.bumpMemoryStatusVersion();
5238
+ log.debug(`invalidated memory ${id}`);
5239
+ },
5240
+ });
5402
5241
  });
5403
5242
  }
5404
5243
 
5405
- async updateMemory(
5406
- id: string,
5244
+ private async updateMemoryFromCurrent(
5245
+ memory: MemoryFile,
5407
5246
  newContent: string,
5408
- options?: { supersedes?: string; lineage?: string[]; actor?: string; sourceConnector?: string }
5247
+ options?: { supersedes?: string; lineage?: string[]; actor?: string; sourceConnector?: string },
5409
5248
  ): Promise<boolean> {
5410
- const memories = await this.readAllMemories();
5411
- const memory = memories.find((m) => m.frontmatter.id === id);
5412
- if (!memory) return false;
5413
-
5414
5249
  const mergedLineage = [...(memory.frontmatter.lineage ?? []), ...(options?.lineage ?? [])].filter(
5415
5250
  (v, i, a) => a.indexOf(v) === i
5416
- ); // dedupe
5417
-
5418
- // Whole-record rewrite — inherited-entityRef rule (issue #2213).
5251
+ );
5419
5252
  const refIdsAtWrite = this.currentHistoricalIds();
5420
5253
  const updated: MemoryFrontmatter = entityRefs.canonicalizeEntityRefOption(
5421
5254
  {
@@ -5431,11 +5264,12 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5431
5264
  );
5432
5265
  const sanitized = sanitizeMemoryContent(newContent);
5433
5266
  if (!sanitized.clean) {
5434
- log.warn(`updated memory content sanitized for ${id}; violations=${sanitized.violations.join(", ")}`);
5267
+ log.warn(`updated memory content sanitized for ${memory.frontmatter.id}; violations=${sanitized.violations.join(", ")}`);
5435
5268
  }
5436
5269
  const fileContent = `${serializeFrontmatter(updated)}\n\n${sanitized.text}\n`;
5437
5270
  await this.writeTombstoneBlockedUpdate(memory, fileContent, updated, sanitized.text, async () => {
5438
5271
  this.invalidateAllMemoriesCache();
5272
+ if (this.isColdOrArchiveTierPath(memory.path)) this.invalidateColdMemoriesCache();
5439
5273
  });
5440
5274
  if (typeof memory.frontmatter.entityRef === "string") {
5441
5275
  await this.entityRefRepair.repair(
@@ -5449,7 +5283,7 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5449
5283
  }
5450
5284
  await this.patchHotMemoriesCache({ addedPath: memory.path });
5451
5285
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.updateMemory", {
5452
- memoryId: id,
5286
+ memoryId: memory.frontmatter.id,
5453
5287
  eventType: "updated",
5454
5288
  timestamp: updated.updated,
5455
5289
  actor: options?.actor ?? "storage.updateMemory",
@@ -5460,10 +5294,48 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5460
5294
  ...(updated.lineage ?? []).filter(Boolean),
5461
5295
  ],
5462
5296
  });
5463
- log.debug(`updated memory ${id}`);
5297
+ log.debug(`updated memory ${memory.frontmatter.id}`);
5464
5298
  return true;
5465
5299
  }
5466
5300
 
5301
+ async updateMemory(
5302
+ id: string,
5303
+ newContent: string,
5304
+ options?: { supersedes?: string; lineage?: string[]; actor?: string; sourceConnector?: string }
5305
+ ): Promise<boolean> {
5306
+ const memory = (await this.readAllMemories()).find((candidate) => candidate.frontmatter.id === id);
5307
+ if (!memory) return false;
5308
+ return await this.updateMemoryFromCurrent(memory, newContent, options);
5309
+ }
5310
+
5311
+ async updateMemoryIfUnchanged(
5312
+ expected: MemoryFile,
5313
+ newContent: string,
5314
+ options?: { supersedes?: string; lineage?: string[]; actor?: string; sourceConnector?: string }
5315
+ ): Promise<boolean> {
5316
+ const sanitized = sanitizeMemoryContent(newContent);
5317
+ const oldIdentity = buildExplicitCaptureDedupKey(
5318
+ expected.content,
5319
+ expected.frontmatter.category,
5320
+ expected.frontmatter.sourceConnector
5321
+ );
5322
+ const newIdentity = buildExplicitCaptureDedupKey(
5323
+ sanitized.text,
5324
+ expected.frontmatter.category,
5325
+ expected.frontmatter.sourceConnector
5326
+ );
5327
+ return await this.withTombstoneBlockedCaptureWriteLock(
5328
+ async () => {
5329
+ const current = await this.readMemoryByPath(expected.path);
5330
+ if (!current || current.frontmatter.id !== expected.frontmatter.id) return false;
5331
+ if (invalidationCommitFingerprint(current) !== invalidationCommitFingerprint(expected)) return false;
5332
+ return await this.updateMemoryFromCurrent(current, newContent, options);
5333
+ },
5334
+ [buildCapturePathLockIdentity(expected.path), oldIdentity, newIdentity]
5335
+ );
5336
+ }
5337
+
5338
+
5467
5339
  /**
5468
5340
  * Update frontmatter fields without changing memory content.
5469
5341
  * Returns false when the memory is not found.
@@ -5537,6 +5409,22 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5537
5409
  }
5538
5410
  return true;
5539
5411
  }
5412
+ /**
5413
+ * Update frontmatter only when the persisted memory still matches the
5414
+ * expected semantic snapshot.
5415
+ */
5416
+ async writeMemoryFrontmatterIfUnchanged(
5417
+ expected: MemoryFile,
5418
+ patch: Partial<MemoryFrontmatter>,
5419
+ lifecycle?: MemoryLifecycleEventWriteOptions
5420
+ ): Promise<boolean> {
5421
+ return await this.withTombstoneBlockedMemoryPathLock(expected.path, async (current) => {
5422
+ if (!current) return false;
5423
+ if (invalidationCommitFingerprint(current) !== invalidationCommitFingerprint(expected)) return false;
5424
+ return await this.writeMemoryFrontmatter(current, patch, lifecycle);
5425
+ });
5426
+ }
5427
+
5540
5428
 
5541
5429
  /**
5542
5430
  * Update frontmatter by memory ID.
@@ -7008,42 +6896,118 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7008
6896
  .sort((a, b) => (a.frontmatter.chunkIndex ?? 0) - (b.frontmatter.chunkIndex ?? 0));
7009
6897
  }
7010
6898
 
7011
- async supersedeMemory(oldMemoryId: string, newMemoryId: string, reason: string): Promise<boolean> {
7012
- const memories = await this.readAllMemories();
7013
- let oldMemory = memories.find(
7014
- (memory) =>
7015
- memory.frontmatter.id === oldMemoryId &&
7016
- inferMemoryStatus(memory.frontmatter, toMemoryPathRel(this.baseDir, memory.path)) === "active",
7017
- );
7018
- if (!oldMemory) {
7019
- oldMemory = (await this.readAllColdMemories()).find(
7020
- (memory) =>
7021
- memory.frontmatter.id === oldMemoryId &&
7022
- inferMemoryStatus(memory.frontmatter, toMemoryPathRel(this.baseDir, memory.path)) === "active",
7023
- );
6899
+ async supersedeMemory(
6900
+ oldMemoryId: string,
6901
+ newMemoryId: string,
6902
+ reason: string,
6903
+ supersessionMetadata?: Pick<MemoryFrontmatter, "supersessionCause" | "invalidatedBy">,
6904
+ options: {
6905
+ requireActive?: boolean;
6906
+ acceptExactReplay?: boolean;
6907
+ expectedSnapshot?: Pick<MemoryFile, "content" | "frontmatter"> & Partial<Pick<MemoryFile, "path">>;
6908
+ } = {},
6909
+ ): Promise<boolean> {
6910
+ const matchesSupersession = (memory: MemoryFile): boolean =>
6911
+ memory.frontmatter.status === "superseded" &&
6912
+ memory.frontmatter.supersededBy === newMemoryId &&
6913
+ (supersessionMetadata === undefined ||
6914
+ (memory.frontmatter.supersessionCause === supersessionMetadata.supersessionCause &&
6915
+ memory.frontmatter.invalidatedBy === supersessionMetadata.invalidatedBy));
6916
+ const isExactReplay = (memory: MemoryFile): boolean =>
6917
+ options.acceptExactReplay === true && matchesSupersession(memory);
6918
+ let oldMemory: MemoryFile | undefined;
6919
+ if (options.expectedSnapshot?.path) {
6920
+ const selected = await this.readMemoryByPath(options.expectedSnapshot.path);
6921
+ if (!selected || selected.frontmatter.id !== oldMemoryId) {
6922
+ return false;
6923
+ }
6924
+ if (
6925
+ invalidationCommitFingerprint(selected) !== invalidationCommitFingerprint(options.expectedSnapshot) &&
6926
+ !isExactReplay(selected)
6927
+ ) {
6928
+ return false;
6929
+ }
6930
+ oldMemory = selected;
6931
+ } else {
6932
+ const memories = [
6933
+ ...(await this.readAllMemories()),
6934
+ ...(await this.readAllColdMemories()),
6935
+ ...(await this.readArchivedMemories()),
6936
+ ];
6937
+ const candidates = memories.filter((memory) => memory.frontmatter.id === oldMemoryId);
6938
+ oldMemory =
6939
+ candidates.find(
6940
+ (memory) =>
6941
+ inferMemoryStatus(memory.frontmatter, toMemoryPathRel(this.baseDir, memory.path)) === "active",
6942
+ ) ??
6943
+ candidates.find(isExactReplay) ??
6944
+ candidates[0];
6945
+ if (
6946
+ oldMemory &&
6947
+ options.expectedSnapshot &&
6948
+ invalidationCommitFingerprint(oldMemory) !== invalidationCommitFingerprint(options.expectedSnapshot) &&
6949
+ !isExactReplay(oldMemory)
6950
+ ) {
6951
+ return false;
6952
+ }
7024
6953
  }
7025
6954
  if (!oldMemory) return false;
7026
-
7027
- const now = new Date().toISOString();
6955
+ const requireActiveAtWrite =
6956
+ options.requireActive === true ||
6957
+ inferMemoryStatus(oldMemory.frontmatter, toMemoryPathRel(this.baseDir, oldMemory.path)) === "active";
6958
+
6959
+ const operationId = createHash("sha256")
6960
+ .update(
6961
+ JSON.stringify({
6962
+ oldMemoryId,
6963
+ newMemoryId,
6964
+ reason,
6965
+ supersessionMetadata: supersessionMetadata ?? null,
6966
+ }),
6967
+ )
6968
+ .digest("hex");
6969
+ const initialNow = new Date().toISOString();
7028
6970
  let currentBefore = oldMemory;
7029
6971
  let updatedFm = oldMemory.frontmatter;
6972
+ let exactReplay = false;
7030
6973
 
7031
- const written = await this.withTombstoneBlockedMemoryPathLock(oldMemory.path, async (current) => {
6974
+ try {
6975
+ const written = await this.withTombstoneBlockedMemoryPathLock(oldMemory.path, async (current) => {
7032
6976
  if (current?.frontmatter.id !== oldMemoryId) return false;
6977
+ if (matchesSupersession(current)) {
6978
+ currentBefore = current;
6979
+ updatedFm = current.frontmatter;
6980
+ return options.acceptExactReplay === true ? "exact-replay" : false;
6981
+ }
6982
+ if (
6983
+ options.expectedSnapshot &&
6984
+ invalidationCommitFingerprint(current) !== invalidationCommitFingerprint(options.expectedSnapshot)
6985
+ ) {
6986
+ return false;
6987
+ }
7033
6988
  if (
7034
6989
  current.frontmatter.supersededBy !== undefined ||
7035
- inferMemoryStatus(current.frontmatter, toMemoryPathRel(this.baseDir, current.path)) !== "active"
6990
+ (requireActiveAtWrite &&
6991
+ inferMemoryStatus(current.frontmatter, toMemoryPathRel(this.baseDir, current.path)) !== "active")
7036
6992
  ) {
7037
6993
  return false;
7038
6994
  }
7039
6995
  currentBefore = current;
7040
6996
  const refIdsAtWrite = this.currentHistoricalIds();
7041
6997
  updatedFm = entityRefs.canonicalizeEntityRefOption(
7042
- { ...current.frontmatter, status: "superseded", supersededBy: newMemoryId, supersededAt: now, updated: now },
6998
+ {
6999
+ ...current.frontmatter,
7000
+ ...supersessionMetadata,
7001
+ status: "superseded",
7002
+ supersededBy: newMemoryId,
7003
+ supersededAt: initialNow,
7004
+ updated: initialNow,
7005
+ },
7043
7006
  refIdsAtWrite
7044
7007
  );
7045
7008
  const fileContent = `${serializeFrontmatter(updatedFm)}\n\n${current.content}\n`;
7046
7009
  await this.writeTombstoneBlockedFrontmatter(current, fileContent, updatedFm);
7010
+ if (this.isColdOrArchiveTierPath(current.path)) this.invalidateColdMemoriesCache();
7047
7011
  if (typeof current.frontmatter.entityRef === "string") {
7048
7012
  await this.entityRefRepair.repair(
7049
7013
  current.path,
@@ -7055,49 +7019,92 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7055
7019
  );
7056
7020
  }
7057
7021
  return true;
7058
- }).catch((err) => { log.error(`failed to supersede memory ${oldMemoryId}:`, err); return false; });
7059
- if (!written) return false;
7060
- await runSupersessionSideEffects({
7061
- oldMemoryId,
7062
- newMemoryId,
7063
- reason,
7064
- now,
7065
- currentBefore,
7066
- updatedFm,
7067
- citationTemplate: this.citationTemplate,
7068
- isColdOrArchiveTierPath: (memoryPath) => this.isColdOrArchiveTierPath(memoryPath),
7069
- invalidateColdMemoriesCache: () => this.invalidateColdMemoriesCache(),
7070
- bumpMemoryCorpusVersion: () => this.bumpMemoryCorpusVersion(),
7071
- appendLifecycleEvent: () =>
7072
- this.appendGeneratedMemoryLifecycleEventFailOpen("storage.supersedeMemory", {
7073
- memoryId: oldMemoryId,
7074
- eventType: "superseded",
7075
- timestamp: now,
7076
- actor: "storage.supersedeMemory",
7077
- reasonCode: reason,
7078
- before: this.summarizeLifecycleState(currentBefore.frontmatter, currentBefore.path),
7079
- after: this.summarizeLifecycleState(updatedFm, currentBefore.path),
7080
- relatedMemoryIds: [newMemoryId],
7081
- }),
7082
- bumpMemoryStatusVersion: () => this.bumpMemoryStatusVersion(),
7083
- appendTombstone: (input) => this.appendTombstone(input),
7084
- writeSealedMemory: (envelope, extras) => this.writeSealedMemory(envelope, extras),
7085
- });
7022
+ });
7023
+ if (!written) return false;
7024
+ exactReplay = written === "exact-replay";
7025
+ if (exactReplay && typeof updatedFm.entityRef === "string") {
7026
+ await this.entityRefRepair.repair(
7027
+ currentBefore.path,
7028
+ updatedFm,
7029
+ updatedFm.entityRef,
7030
+ this.currentHistoricalIds(),
7031
+ currentBefore.content,
7032
+ { onFailRestore: currentBefore },
7033
+ );
7034
+ }
7035
+ const supersededAt = updatedFm.supersededAt ?? initialNow;
7036
+ const beforeState = this.summarizeLifecycleState(
7037
+ currentBefore.frontmatter,
7038
+ currentBefore.path,
7039
+ );
7040
+ const afterState = this.summarizeLifecycleState(updatedFm, currentBefore.path);
7041
+ const sideEffectsComplete = await runSupersessionSideEffects({
7042
+ oldMemoryId,
7043
+ newMemoryId,
7044
+ reason,
7045
+ now: supersededAt,
7046
+ operationId,
7047
+ exactReplay,
7048
+ currentBefore,
7049
+ updatedFm,
7050
+ citationTemplate: this.citationTemplate,
7051
+ correctionsDir: this.correctionsDir,
7052
+ readMemoryByPath: (filePath) => this.readMemoryByPath(filePath),
7053
+ isColdOrArchiveTierPath: (memoryPath) => this.isColdOrArchiveTierPath(memoryPath),
7054
+ invalidateColdMemoriesCache: () => this.invalidateColdMemoriesCache(),
7055
+ invalidateAllMemoriesCache: () => this.invalidateAllMemoriesCache(),
7056
+ bumpMemoryCorpusVersion: () => this.bumpMemoryCorpusVersion(),
7057
+ appendLifecycleEvent: async () => {
7058
+ let lifecycleEvents: MemoryLifecycleEvent[] = [];
7059
+ try {
7060
+ lifecycleEvents = await this.getMemoryTimeline(oldMemoryId, 200);
7061
+ } catch (error) {
7062
+ log.warn(`supersession lifecycle replay check failed for ${oldMemoryId}: ${error}`);
7063
+ }
7064
+ if (
7065
+ lifecycleEvents.some(
7066
+ (event) =>
7067
+ event.memoryId === oldMemoryId &&
7068
+ event.eventType === "superseded" &&
7069
+ (event.correlationId === operationId ||
7070
+ (event.correlationId === undefined &&
7071
+ event.reasonCode === reason &&
7072
+ event.timestamp === supersededAt &&
7073
+ event.relatedMemoryIds?.includes(newMemoryId))),
7074
+ )
7075
+ ) {
7076
+ return;
7077
+ }
7078
+ await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.supersedeMemory", {
7079
+ memoryId: oldMemoryId,
7080
+ eventType: "superseded",
7081
+ timestamp: supersededAt,
7082
+ actor: "storage.supersedeMemory",
7083
+ reasonCode: reason,
7084
+ before: exactReplay ? { ...beforeState, status: "active" } : beforeState,
7085
+ after: afterState,
7086
+ relatedMemoryIds: [newMemoryId],
7087
+ correlationId: operationId,
7088
+ });
7089
+ },
7090
+ bumpMemoryStatusVersion: () => this.bumpMemoryStatusVersion(),
7091
+ hasExactTombstone: (input) => this.hasExactTombstone(input),
7092
+ appendTombstone: (input) => this.appendTombstone(input),
7093
+ writeSealedMemory: (envelope, extras) => this.writeSealedMemory(envelope, extras),
7094
+ });
7095
+ return sideEffectsComplete || options.acceptExactReplay !== true;
7086
7096
 
7087
- return true;
7097
+ } catch (err) {
7098
+ log.error(`failed to supersede memory ${oldMemoryId}:`, err);
7099
+ return false;
7100
+ }
7088
7101
  }
7089
7102
 
7090
- // ---------------------------------------------------------------------------
7091
- // Memory Summarization (Phase 4A)
7092
- // ---------------------------------------------------------------------------
7093
7103
 
7094
7104
  private get summariesDir(): string {
7095
7105
  return path.join(this.baseDir, "summaries");
7096
7106
  }
7097
7107
 
7098
- /**
7099
- * Write a memory summary.
7100
- */
7101
7108
  async writeSummary(summary: MemorySummary): Promise<void> {
7102
7109
  await mkdir(this.summariesDir, { recursive: true });
7103
7110
  const filePath = path.join(this.summariesDir, `${summary.id}.json`);
@@ -7105,9 +7112,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7105
7112
  log.debug(`wrote summary ${summary.id}`);
7106
7113
  }
7107
7114
 
7108
- /**
7109
- * Get all summaries.
7110
- */
7111
7115
  async readSummaries(): Promise<MemorySummary[]> {
7112
7116
  try {
7113
7117
  const files = await readdir(this.summariesDir);
@@ -7128,9 +7132,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7128
7132
  }
7129
7133
  }
7130
7134
 
7131
- /**
7132
- * Archive memories (mark as archived, not delete).
7133
- */
7134
7135
  async archiveMemories(memoryIds: string[], summaryId: string): Promise<number> {
7135
7136
  const memories = await this.readAllMemories();
7136
7137
  const memoryMap = new Map(memories.map((m) => [m.frontmatter.id, m]));
@@ -7194,13 +7195,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7194
7195
  return archived;
7195
7196
  }
7196
7197
 
7197
- // ---------------------------------------------------------------------------
7198
- // Topic Extraction (Phase 4B)
7199
- // ---------------------------------------------------------------------------
7200
-
7201
- /**
7202
- * Save topic scores to meta.json.
7203
- */
7204
7198
  async saveTopics(topics: TopicScore[]): Promise<void> {
7205
7199
  const metaPath = path.join(this.stateDir, "topics.json");
7206
7200
  await mkdir(this.stateDir, { recursive: true });
@@ -7211,9 +7205,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7211
7205
  log.debug(`saved ${topics.length} topic scores`);
7212
7206
  }
7213
7207
 
7214
- /**
7215
- * Load topic scores from meta.json.
7216
- */
7217
7208
  async loadTopics(): Promise<{ topics: TopicScore[]; updatedAt: string | null }> {
7218
7209
  const metaPath = path.join(this.stateDir, "topics.json");
7219
7210
  try {
@@ -7226,9 +7217,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7226
7217
  }
7227
7218
  }
7228
7219
 
7229
- /**
7230
- * Add links to an existing memory.
7231
- */
7232
7220
  async addLinksToMemory(
7233
7221
  memoryId: string,
7234
7222
  links: MemoryLink[],
@@ -7241,7 +7229,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
7241
7229
  const existingLinks = memory.frontmatter.links ?? [];
7242
7230
  const mergedLinks = [...existingLinks];
7243
7231
 
7244
- // Add new links, avoiding duplicates
7245
7232
  for (const link of links) {
7246
7233
  if (!mergedLinks.some((l) => l.targetId === link.targetId && l.linkType === link.linkType)) {
7247
7234
  mergedLinks.push(link);