@remnic/core 9.35.2 → 9.35.4

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 (208) hide show
  1. package/dist/access-admin-ops-surface.d.ts +5 -5
  2. package/dist/access-admin-ops-surface.js +19 -18
  3. package/dist/access-authorization-probe.d.ts +5 -5
  4. package/dist/access-authorization-probe.js +20 -19
  5. package/dist/access-boundary.d.ts +5 -5
  6. package/dist/access-boundary.js +19 -18
  7. package/dist/access-cli.js +39 -38
  8. package/dist/access-cli.js.map +1 -1
  9. package/dist/access-extraction-force-flush.d.ts +5 -5
  10. package/dist/access-extraction-force-flush.js +19 -18
  11. package/dist/access-http-lcm-compaction.d.ts +5 -5
  12. package/dist/access-http-lifecycle-flush.d.ts +5 -5
  13. package/dist/access-http.d.ts +11 -5
  14. package/dist/access-http.js +24 -23
  15. package/dist/access-identity-continuity-surface.d.ts +4 -4
  16. package/dist/access-identity-continuity-surface.js +19 -18
  17. package/dist/access-lcm-surface.d.ts +5 -5
  18. package/dist/access-lcm-surface.js +19 -18
  19. package/dist/access-mcp.d.ts +5 -5
  20. package/dist/access-mcp.js +22 -21
  21. package/dist/access-namespace-preflight.js +19 -18
  22. package/dist/access-observe-write-surface.d.ts +5 -5
  23. package/dist/access-observe-write-surface.js +19 -18
  24. package/dist/access-operations-batch.js +20 -19
  25. package/dist/access-operations.d.ts +8 -8
  26. package/dist/access-operations.js +21 -20
  27. package/dist/access-recall-concurrency.d.ts +5 -5
  28. package/dist/access-recall-concurrency.js +19 -18
  29. package/dist/access-recall-response.d.ts +5 -5
  30. package/dist/access-recall-response.js +19 -18
  31. package/dist/access-recall-surface.d.ts +5 -5
  32. package/dist/access-recall-surface.js +19 -18
  33. package/dist/access-schema.d.ts +88 -88
  34. package/dist/{access-service-D9zIBlpp.d.ts → access-service-C2WYT1pO.d.ts} +3 -3
  35. package/dist/access-service-helpers.d.ts +5 -5
  36. package/dist/access-service.d.ts +5 -5
  37. package/dist/access-service.js +19 -18
  38. package/dist/access-surface-catalog.d.ts +5 -5
  39. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  40. package/dist/bootstrap.d.ts +4 -4
  41. package/dist/briefing.d.ts +1 -1
  42. package/dist/briefing.js +10 -9
  43. package/dist/buffer.d.ts +1 -1
  44. package/dist/{capsule-merge-2H2IUTBT.js → capsule-merge-IDWJPCWK.js} +77 -8
  45. package/dist/capsule-merge-IDWJPCWK.js.map +1 -0
  46. package/dist/causal-consolidation.js +11 -10
  47. package/dist/causal-consolidation.js.map +1 -1
  48. package/dist/{chunk-65ZPVYTA.js → chunk-3CE77VAP.js} +55 -5
  49. package/dist/chunk-3CE77VAP.js.map +1 -0
  50. package/dist/chunk-4J7G5SY4.js +338 -0
  51. package/dist/chunk-4J7G5SY4.js.map +1 -0
  52. package/dist/{chunk-EO7X2LDK.js → chunk-4TAD3B56.js} +2 -2
  53. package/dist/{chunk-QHYB4MJV.js → chunk-5ZGHUWTT.js} +2 -2
  54. package/dist/{chunk-TCIWHK3N.js → chunk-6D5D5A4M.js} +5 -5
  55. package/dist/{chunk-BIXWNHIL.js → chunk-6TF4M2LB.js} +5 -5
  56. package/dist/{chunk-FBUE6PD3.js → chunk-BEQOZOYZ.js} +21 -4
  57. package/dist/chunk-BEQOZOYZ.js.map +1 -0
  58. package/dist/{chunk-SA3WJJF2.js → chunk-BGLDKAYY.js} +2 -2
  59. package/dist/{chunk-OPRP6Q4Z.js → chunk-DKKZ6SO2.js} +3 -3
  60. package/dist/{chunk-6TAUHL33.js → chunk-DLVPUX7U.js} +10 -2
  61. package/dist/chunk-DLVPUX7U.js.map +1 -0
  62. package/dist/{chunk-QNWXCJ76.js → chunk-ENTCVTQR.js} +18 -1
  63. package/dist/{chunk-QNWXCJ76.js.map → chunk-ENTCVTQR.js.map} +1 -1
  64. package/dist/{chunk-AEPYA55F.js → chunk-FE7MUX7M.js} +2 -2
  65. package/dist/{chunk-GHR7WHZX.js → chunk-GORHSN5C.js} +2 -2
  66. package/dist/{chunk-7TEZUUGM.js → chunk-HUS2XN3T.js} +2 -2
  67. package/dist/{chunk-SLPLPGJQ.js → chunk-IE2CS5BM.js} +17 -10
  68. package/dist/chunk-IE2CS5BM.js.map +1 -0
  69. package/dist/{chunk-XWDZQIYU.js → chunk-IJAQCBGV.js} +2 -2
  70. package/dist/{chunk-TJLLYV2X.js → chunk-IMEXE4XS.js} +7 -7
  71. package/dist/{chunk-IZXTZ7BF.js → chunk-JYLN6KHH.js} +6 -6
  72. package/dist/{chunk-EH5U2YCX.js → chunk-M3DBB2ND.js} +2 -2
  73. package/dist/{chunk-XABS455Y.js → chunk-NBAEGFXT.js} +5 -5
  74. package/dist/{chunk-T4HH5Q3U.js → chunk-OJRLTZQM.js} +3 -3
  75. package/dist/{chunk-7ZHGPX56.js → chunk-PRP3M5Y3.js} +2 -2
  76. package/dist/{chunk-Y2ZD2RVG.js → chunk-PYTUBWAS.js} +2 -2
  77. package/dist/{chunk-4TGZCYHH.js → chunk-Q3CVY7LV.js} +1954 -1642
  78. package/dist/chunk-Q3CVY7LV.js.map +1 -0
  79. package/dist/{chunk-PH3ZWL3W.js → chunk-Q53RLEW7.js} +2 -2
  80. package/dist/{chunk-Y6OWJ3V5.js → chunk-RY2XZTFN.js} +2 -2
  81. package/dist/{chunk-2SHIWGY7.js → chunk-RY7CHVGE.js} +2 -2
  82. package/dist/{chunk-7VUAG236.js → chunk-T3FVAHM7.js} +4 -4
  83. package/dist/{chunk-2LTDWSR6.js → chunk-TSH56VPU.js} +2 -2
  84. package/dist/{chunk-J6SLNQRO.js → chunk-UBWOCQ3I.js} +6 -2
  85. package/dist/chunk-UBWOCQ3I.js.map +1 -0
  86. package/dist/{chunk-5FHE4JI7.js → chunk-VEUVON5I.js} +2 -2
  87. package/dist/{chunk-KEWUNL7P.js → chunk-WET4M3R6.js} +2 -2
  88. package/dist/{chunk-HEVWWPTL.js → chunk-Y2UUEB4I.js} +17 -17
  89. package/dist/{chunk-AHZYNPLX.js → chunk-YVMVVE4B.js} +2 -2
  90. package/dist/{chunk-5HTAJCTO.js → chunk-ZB3YODID.js} +15 -15
  91. package/dist/{chunk-FHYGLITW.js → chunk-ZJMVZLPK.js} +217 -45
  92. package/dist/{chunk-FHYGLITW.js.map → chunk-ZJMVZLPK.js.map} +1 -1
  93. package/dist/{cli-mFX-QQvx.d.ts → cli-jkmzdT8P.d.ts} +3 -3
  94. package/dist/cli.d.ts +6 -6
  95. package/dist/cli.js +34 -33
  96. package/dist/compounding/engine.d.ts +1 -1
  97. package/dist/compounding/engine.js +10 -9
  98. package/dist/connectors/codex-materialize-runner.js +10 -9
  99. package/dist/connectors/index.js +10 -9
  100. package/dist/consolidation-provenance-check.d.ts +1 -1
  101. package/dist/consolidation-provenance-check.js +8 -2
  102. package/dist/consolidation-undo.d.ts +1 -1
  103. package/dist/consolidation-undo.js +10 -1
  104. package/dist/consolidation-undo.js.map +1 -1
  105. package/dist/contradiction/index.d.ts +1 -1
  106. package/dist/corpus-watermark.js +12 -11
  107. package/dist/entity-retrieval.d.ts +1 -1
  108. package/dist/entity-retrieval.js +10 -9
  109. package/dist/explicit-capture.d.ts +4 -4
  110. package/dist/explicit-capture.js +6 -3
  111. package/dist/extraction.js +10 -9
  112. package/dist/{forget-4UY2EOKH.js → forget-DNAJ66ZD.js} +2 -2
  113. package/dist/index.d.ts +396 -396
  114. package/dist/index.js +39 -38
  115. package/dist/maintenance/memory-governance.js +10 -9
  116. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  117. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -9
  118. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  119. package/dist/maintenance/rebuild-memory-projection.js +11 -10
  120. package/dist/{maintenance-70Wq118b.d.ts → maintenance-Ha9Dg93M.d.ts} +1 -1
  121. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  122. package/dist/memory-worth-outcomes.d.ts +1 -1
  123. package/dist/namespaces/migrate.d.ts +1 -1
  124. package/dist/namespaces/migrate.js +11 -10
  125. package/dist/namespaces/storage.d.ts +1 -1
  126. package/dist/namespaces/storage.js +10 -9
  127. package/dist/offline-sync-impression-drain.js +3 -3
  128. package/dist/operator-doctor-corpus.js +13 -12
  129. package/dist/operator-toolkit.d.ts +1 -1
  130. package/dist/operator-toolkit.js +17 -16
  131. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  132. package/dist/orchestration/maintenance.d.ts +2 -2
  133. package/dist/orchestration/maintenance.js +15 -14
  134. package/dist/{orchestrator-BH8LT-NX.d.ts → orchestrator-DV_u9bfx.d.ts} +3 -3
  135. package/dist/orchestrator.d.ts +4 -4
  136. package/dist/orchestrator.js +39 -38
  137. package/dist/page-versioning.js +7 -1
  138. package/dist/schemas.d.ts +54 -54
  139. package/dist/semantic-consolidation.js +11 -10
  140. package/dist/semantic-rule-promotion.js +10 -9
  141. package/dist/semantic-rule-verifier.js +10 -9
  142. package/dist/{service-CrxXHz5T.d.ts → service-JxXXY-Eh.d.ts} +1 -1
  143. package/dist/shared-context/manager.d.ts +8 -8
  144. package/dist/source-agent-qualifier.js +10 -9
  145. package/dist/{storage-CyWa1Jm6.d.ts → storage-BbH4HDUS.d.ts} +30 -26
  146. package/dist/storage.d.ts +1 -1
  147. package/dist/storage.js +9 -8
  148. package/dist/temporal-supersession.d.ts +1 -1
  149. package/dist/tier-migration.d.ts +1 -1
  150. package/dist/transfer/capsule-import.js +8 -2
  151. package/dist/transfer/types.d.ts +66 -66
  152. package/dist/verified-recall.js +10 -9
  153. package/package.json +2 -2
  154. package/src/access-http.ts +14 -9
  155. package/src/binary-lifecycle/pipeline.ts +52 -1
  156. package/src/consolidation-undo.ts +4 -0
  157. package/src/curation/index.ts +41 -5
  158. package/src/lifecycle/tombstones.ts +40 -0
  159. package/src/maintenance/memory-governance.ts +4 -0
  160. package/src/page-versioning.ts +16 -1
  161. package/src/review/index.ts +97 -13
  162. package/src/spaces/index.ts +127 -6
  163. package/src/storage/citation-hash-source.ts +97 -0
  164. package/src/storage/entity-canonical-id-lock.ts +33 -0
  165. package/src/storage/entity-canonical-id-migration-adapter.ts +8 -4
  166. package/src/storage/entity-canonical-id-migration.ts +129 -77
  167. package/src/storage/entity-canonical-id-references.ts +557 -0
  168. package/src/storage/entity-ref-repair.ts +171 -0
  169. package/src/storage/entity-store.ts +163 -2
  170. package/src/storage/tombstone-blocked-capture-sync.ts +18 -0
  171. package/src/storage.ts +363 -396
  172. package/src/transfer/capsule-import.ts +91 -3
  173. package/src/transfer/capsule-merge.ts +128 -10
  174. package/dist/capsule-merge-2H2IUTBT.js.map +0 -1
  175. package/dist/chunk-4TGZCYHH.js.map +0 -1
  176. package/dist/chunk-65ZPVYTA.js.map +0 -1
  177. package/dist/chunk-6TAUHL33.js.map +0 -1
  178. package/dist/chunk-FBUE6PD3.js.map +0 -1
  179. package/dist/chunk-J6SLNQRO.js.map +0 -1
  180. package/dist/chunk-SLPLPGJQ.js.map +0 -1
  181. /package/dist/{chunk-EO7X2LDK.js.map → chunk-4TAD3B56.js.map} +0 -0
  182. /package/dist/{chunk-QHYB4MJV.js.map → chunk-5ZGHUWTT.js.map} +0 -0
  183. /package/dist/{chunk-TCIWHK3N.js.map → chunk-6D5D5A4M.js.map} +0 -0
  184. /package/dist/{chunk-BIXWNHIL.js.map → chunk-6TF4M2LB.js.map} +0 -0
  185. /package/dist/{chunk-SA3WJJF2.js.map → chunk-BGLDKAYY.js.map} +0 -0
  186. /package/dist/{chunk-OPRP6Q4Z.js.map → chunk-DKKZ6SO2.js.map} +0 -0
  187. /package/dist/{chunk-AEPYA55F.js.map → chunk-FE7MUX7M.js.map} +0 -0
  188. /package/dist/{chunk-GHR7WHZX.js.map → chunk-GORHSN5C.js.map} +0 -0
  189. /package/dist/{chunk-7TEZUUGM.js.map → chunk-HUS2XN3T.js.map} +0 -0
  190. /package/dist/{chunk-XWDZQIYU.js.map → chunk-IJAQCBGV.js.map} +0 -0
  191. /package/dist/{chunk-TJLLYV2X.js.map → chunk-IMEXE4XS.js.map} +0 -0
  192. /package/dist/{chunk-IZXTZ7BF.js.map → chunk-JYLN6KHH.js.map} +0 -0
  193. /package/dist/{chunk-EH5U2YCX.js.map → chunk-M3DBB2ND.js.map} +0 -0
  194. /package/dist/{chunk-XABS455Y.js.map → chunk-NBAEGFXT.js.map} +0 -0
  195. /package/dist/{chunk-T4HH5Q3U.js.map → chunk-OJRLTZQM.js.map} +0 -0
  196. /package/dist/{chunk-7ZHGPX56.js.map → chunk-PRP3M5Y3.js.map} +0 -0
  197. /package/dist/{chunk-Y2ZD2RVG.js.map → chunk-PYTUBWAS.js.map} +0 -0
  198. /package/dist/{chunk-PH3ZWL3W.js.map → chunk-Q53RLEW7.js.map} +0 -0
  199. /package/dist/{chunk-Y6OWJ3V5.js.map → chunk-RY2XZTFN.js.map} +0 -0
  200. /package/dist/{chunk-2SHIWGY7.js.map → chunk-RY7CHVGE.js.map} +0 -0
  201. /package/dist/{chunk-7VUAG236.js.map → chunk-T3FVAHM7.js.map} +0 -0
  202. /package/dist/{chunk-2LTDWSR6.js.map → chunk-TSH56VPU.js.map} +0 -0
  203. /package/dist/{chunk-5FHE4JI7.js.map → chunk-VEUVON5I.js.map} +0 -0
  204. /package/dist/{chunk-KEWUNL7P.js.map → chunk-WET4M3R6.js.map} +0 -0
  205. /package/dist/{chunk-HEVWWPTL.js.map → chunk-Y2UUEB4I.js.map} +0 -0
  206. /package/dist/{chunk-AHZYNPLX.js.map → chunk-YVMVVE4B.js.map} +0 -0
  207. /package/dist/{chunk-5HTAJCTO.js.map → chunk-ZB3YODID.js.map} +0 -0
  208. /package/dist/{forget-4UY2EOKH.js.map → forget-DNAJ66ZD.js.map} +0 -0
package/src/storage.ts CHANGED
@@ -34,6 +34,8 @@ import { selfDeps } from "./orchestration/self-deps.js";
34
34
  import { EntityStore } from "./storage/entity-store.js";
35
35
  import { IdentityContinuityStore } from "./storage/identity-continuity-store.js";
36
36
  import * as entityMigration from "./storage/entity-canonical-id-migration.js";
37
+ import * as entityRefs from "./storage/entity-canonical-id-references.js";
38
+ import { EntityRefRepair } from "./storage/entity-ref-repair.js";
37
39
  import {
38
40
  runLegacyEntityCanonicalIdMigration,
39
41
  type EntityCanonicalIdMigrationHost,
@@ -104,6 +106,10 @@ import {
104
106
  stripCitationForTemplate,
105
107
  DEFAULT_CITATION_FORMAT,
106
108
  } from "./source-attribution.js";
109
+ import {
110
+ stripCitationMarkersForHashRemoval,
111
+ stripDefaultCitationMarkersWithoutRegex,
112
+ } from "./storage/citation-hash-source.js";
107
113
  import {
108
114
  TombstoneStore,
109
115
  collectRetiredMemoriesForRebuild,
@@ -245,6 +251,8 @@ const ARTIFACT_SEARCH_STOPWORDS = new Set([
245
251
  "with",
246
252
  ]);
247
253
 
254
+ type SharedVersionKind = "memory-status" | "artifact-write" | "cold-write" | "memory-corpus" | "entity-mutation";
255
+
248
256
  type OfflineSyncDigestCacheEntry = {
249
257
  statBytes: number;
250
258
  mtimeMs: number;
@@ -311,92 +319,6 @@ function normalizeMemoryWriteTimestamp(field: string, value: string | undefined)
311
319
  return new Date(parsed).toISOString();
312
320
  }
313
321
 
314
- function trimTrailingSpacesAndTabs(value: string): string {
315
- let end = value.length;
316
- while (end > 0 && (value[end - 1] === " " || value[end - 1] === "\t")) {
317
- end -= 1;
318
- }
319
- return end === value.length ? value : value.slice(0, end);
320
- }
321
-
322
- function trimLeadingSpacesAndTabs(value: string): string {
323
- let start = 0;
324
- while (start < value.length && (value[start] === " " || value[start] === "\t")) {
325
- start += 1;
326
- }
327
- return start === 0 ? value : value.slice(start);
328
- }
329
-
330
- function stripDefaultCitationMarkersWithoutRegex(value: string): string {
331
- return stripCitationMarkersForHashRemoval(value, DEFAULT_CITATION_FORMAT);
332
- }
333
-
334
- function citationTemplateLiteralParts(template: string): string[] {
335
- const parts: string[] = [];
336
- let cursor = 0;
337
- while (cursor < template.length) {
338
- const open = template.indexOf("{", cursor);
339
- if (open === -1) {
340
- parts.push(template.slice(cursor));
341
- break;
342
- }
343
- parts.push(template.slice(cursor, open));
344
- const close = template.indexOf("}", open + 1);
345
- if (close === -1) {
346
- cursor = open + 1;
347
- } else {
348
- cursor = close + 1;
349
- }
350
- }
351
- return parts.filter((part) => part.length > 0);
352
- }
353
-
354
- function stripCitationMarkersForHashRemoval(value: string, template: string): string {
355
- const parts = citationTemplateLiteralParts(template);
356
- if (parts.length === 0) return value;
357
- const first = parts[0]!;
358
- const lowerValue = value.toLowerCase();
359
- const lowerFirst = first.toLowerCase();
360
- const lowerParts = parts.map((part) => part.toLowerCase());
361
- if (!lowerValue.includes(lowerFirst)) return value;
362
-
363
- let result = "";
364
- let cursor = 0;
365
- let removed = false;
366
- while (cursor < value.length) {
367
- const markerStart = lowerValue.indexOf(lowerFirst, cursor);
368
- if (markerStart === -1) {
369
- result += value.slice(cursor);
370
- break;
371
- }
372
- const boundedEnd = first.startsWith("[") ? value.indexOf("]", markerStart + first.length) : -1;
373
- if (first.startsWith("[") && boundedEnd === -1) {
374
- result += value.slice(cursor);
375
- break;
376
- }
377
- const searchLimit = boundedEnd === -1 ? value.length : boundedEnd + 1;
378
- let markerEnd = markerStart + first.length;
379
- let matched = true;
380
- for (let i = 1; i < lowerParts.length; i += 1) {
381
- const partIndex = lowerValue.indexOf(lowerParts[i]!, markerEnd);
382
- if (partIndex === -1 || partIndex + parts[i]!.length > searchLimit) {
383
- matched = false;
384
- break;
385
- }
386
- markerEnd = partIndex + parts[i]!.length;
387
- }
388
- if (!matched) {
389
- result += value.slice(cursor);
390
- break;
391
- }
392
- result += trimTrailingSpacesAndTabs(value.slice(cursor, markerStart));
393
- cursor = markerEnd;
394
- removed = true;
395
- }
396
-
397
- return removed ? trimLeadingSpacesAndTabs(result) : value;
398
- }
399
-
400
322
  function serializeFrontmatter(fm: MemoryFrontmatter): string {
401
323
  assertMemoryFrontmatterId(fm);
402
324
  const lines = [
@@ -1136,23 +1058,6 @@ const FACT_HASH_INDEX_REBUILD_RETRY_BASE_MS = 50;
1136
1058
  // Attribute normalization helper
1137
1059
  // ---------------------------------------------------------------------------
1138
1060
 
1139
- /**
1140
- * Render a structured-attributes map into a stable, canonical string fragment
1141
- * suitable for appending to enriched memory content before hashing.
1142
- *
1143
- * Normalization rules:
1144
- * - Keys are trimmed and lowercased (values are trimmed but preserve case)
1145
- * - Key-value pairs are sorted alphabetically by normalized key
1146
- * - Pairs are joined with "; " and rendered as "key: value"
1147
- *
1148
- * Using this helper at BOTH the write path (enrichedContent) and the
1149
- * dedup-lookup path (dedupContent) guarantees identical output regardless of
1150
- * the insertion order or casing used by the caller.
1151
- *
1152
- * @example
1153
- * normalizeAttributePairs({ foo: "bar", BAZ: "qux" })
1154
- * // → "baz: qux; foo: bar"
1155
- */
1156
1061
  // `stripAttributesSuffix` now lives in ./structured-attributes.ts (shared with
1157
1062
  // the coding surfaces + wearable service). Imported here so internal callers
1158
1063
  // (snapshotBeforeWrite, snapshotForProvenance) resolve, and re-exported to
@@ -1839,6 +1744,8 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
1839
1744
  // every plain create). In-process fallback when the on-disk sentinel is
1840
1745
  // unavailable; canonical source is state/.memory-corpus-version.log.
1841
1746
  private static readonly memoryCorpusVersionByDir = new Map<string, number>();
1747
+ // Entity-mutation sentinel: see getEntityMutationVersion.
1748
+ private static readonly entityMutationVersionByDir = new Map<string, number>();
1842
1749
  private static readonly secureStoreEntityCacheKeyIds = new WeakMap<Buffer, number>();
1843
1750
  private static nextSecureStoreEntityCacheKeyId = 1;
1844
1751
  // In-process fallback for the cold-write sentinel (used when the disk file
@@ -2305,7 +2212,6 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2305
2212
  // loadAliases() explicitly — every creation path must still get the
2306
2213
  // store's own aliases, never an empty or foreign table.
2307
2214
  this.loadAliasesSync();
2308
- this.loadHistoricalEntityCanonicalIdsSync();
2309
2215
  }
2310
2216
 
2311
2217
  /** Set the process-wide hot-memories cache default (issue #1902). The
@@ -2404,20 +2310,12 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2404
2310
  return path.join(workspaceDir, `IDENTITY.${safeNamespace}.md`);
2405
2311
  }
2406
2312
 
2407
- private versionFilePath(kind: "memory-status" | "artifact-write" | "cold-write" | "memory-corpus"): string {
2408
- const fileName =
2409
- kind === "memory-status"
2410
- ? ".memory-status-version.log"
2411
- : kind === "artifact-write"
2412
- ? ".artifact-write-version.log"
2413
- : kind === "cold-write"
2414
- ? ".cold-write-version.log"
2415
- : ".memory-corpus-version.log";
2416
- return path.join(this.stateDir, fileName);
2313
+ private versionFilePath(kind: SharedVersionKind): string {
2314
+ return path.join(this.stateDir, `.${kind}-version.log`);
2417
2315
  }
2418
2316
 
2419
2317
  private bumpSharedVersion(
2420
- kind: "memory-status" | "artifact-write" | "cold-write" | "memory-corpus",
2318
+ kind: SharedVersionKind,
2421
2319
  fallbackMap: Map<string, number>
2422
2320
  ): number {
2423
2321
  const filePath = this.versionFilePath(kind);
@@ -2435,7 +2333,7 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2435
2333
  }
2436
2334
 
2437
2335
  private readSharedVersion(
2438
- kind: "memory-status" | "artifact-write" | "cold-write" | "memory-corpus",
2336
+ kind: SharedVersionKind,
2439
2337
  fallbackMap: Map<string, number>
2440
2338
  ): number {
2441
2339
  const filePath = this.versionFilePath(kind);
@@ -2448,16 +2346,14 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2448
2346
 
2449
2347
  protected bumpMemoryStatusVersion(): void {
2450
2348
  this.bumpSharedVersion("memory-status", StorageManager.memoryStatusVersionByDir);
2451
- // Also bump the corpus sentinel so peer processes rescan the hot-memories
2452
- // cache after status/lifecycle/bulk mutations (issue #1902). invalidateAllForDir
2453
- // below drops the hot layer locally; the corpus bump handles cross-process.
2349
+ // Corpus sentinel too: peer processes rescan the hot-memories cache
2350
+ // after status/lifecycle/bulk mutations (#1902); invalidateAllForDir
2351
+ // below drops the hot layer locally.
2454
2352
  this.bumpSharedVersion("memory-corpus", StorageManager.memoryCorpusVersionByDir);
2455
- // Invalidation chokepoint (issue #1535): status/entity mutations funnel
2456
- // through this bump several of them (supersedeMemory, archiveMemories,
2457
- // writeEntity, cleanExpiredCommitments) do NOT call
2458
- // invalidateAllMemoriesCache(), so the chokepoint must fire here as well.
2459
- // Version bumps only invalidate the version-checked layers lazily; the
2460
- // TTL-based QMD caches need this eager clear.
2353
+ // Invalidation chokepoint (#1535): several status/entity mutations
2354
+ // (supersede, archiveMemories, writeEntity, commitment cleanup) never
2355
+ // call invalidateAllMemoriesCache; the TTL-based QMD caches need this
2356
+ // eager clear.
2461
2357
  invalidateAllForDir(this.baseDir);
2462
2358
  }
2463
2359
 
@@ -2466,38 +2362,43 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
2466
2362
  }
2467
2363
 
2468
2364
  /**
2469
- * Corpus version sentinel for the hot-memories result cache (issue #1902).
2470
- * Distinct from memory-status: it bumps on EVERY memory-file mutation
2471
- * (create/update/delete/bulk) so the version-keyed hot cache stays coherent
2472
- * across processes, WITHOUT bumping memory-status — which would needlessly
2473
- * invalidate the version-keyed entity cache on every plain fact create (the
2474
- * ki-spike regression the sibling perf work removes). memory-status keeps its
2475
- * lifecycle/status/entity semantics unchanged.
2365
+ * Entity-mutation sentinel: advanced only by entity-store content writes.
2366
+ * The migration fingerprint keys on this instead of memory-status, so
2367
+ * supersede/archive/delete no longer re-trigger discovery scans (#2213).
2368
+ */
2369
+ getEntityMutationVersion(): number {
2370
+ return this.readSharedVersion("entity-mutation", StorageManager.entityMutationVersionByDir);
2371
+ }
2372
+
2373
+ protected bumpEntityMutationVersion(): void {
2374
+ this.bumpSharedVersion("entity-mutation", StorageManager.entityMutationVersionByDir);
2375
+ }
2376
+
2377
+ /**
2378
+ * Corpus version sentinel for the hot-memories result cache (#1902).
2379
+ * Distinct from memory-status: bumps on EVERY memory-file mutation so the
2380
+ * version-keyed hot cache stays coherent across processes, WITHOUT
2381
+ * invalidating the version-keyed entity cache on every plain fact create.
2476
2382
  */
2477
2383
  getMemoryCorpusVersion(): number {
2478
2384
  return this.readSharedVersion("memory-corpus", StorageManager.memoryCorpusVersionByDir);
2479
2385
  }
2480
2386
 
2481
2387
  protected bumpMemoryCorpusVersion(): void {
2482
- // Bump only — unlike bumpMemoryStatusVersion this must NOT invalidateAllForDir
2483
- // (that would drop the very hot entry the write path just patched).
2388
+ // Bump only — NOT invalidateAllForDir (that would drop the very hot
2389
+ // entry the write path just patched).
2484
2390
  this.bumpSharedVersion("memory-corpus", StorageManager.memoryCorpusVersionByDir);
2485
- // Also drop the in-flight readAllMemories slot (Cursor Medium, #1902): after
2486
- // the sentinel advances, a concurrent read must not attach to a scan that
2487
- // began BEFORE this mutation and receive a pre-mutation corpus. Clearing the
2488
- // slot forces such a read to start a fresh scan of current disk state. (The
2489
- // patch path uses bumpMemoryCorpusVersionExclusive and clears the slot
2490
- // itself; this covers the per-item supersede/archive/TTL-cleanup bumps.)
2391
+ // Drop the in-flight readAllMemories slot (#1902): a concurrent read
2392
+ // must not attach to a scan that began BEFORE this mutation. (The patch
2393
+ // path uses bumpMemoryCorpusVersionExclusive and clears the slot itself.)
2491
2394
  deleteInFlightReadsForDir(this.baseDir);
2492
2395
  }
2493
2396
 
2494
2397
  /**
2495
- * Corpus bump that reports whether THIS process's append was the only one
2496
- * since the pre-bump size (issue #1902, Codex P1). appendFileSync is atomic
2497
- * per write, but a peer process can append concurrently; the `exclusive`
2498
- * flag lets patchHotMemoriesCache refuse to re-key its locally patched
2499
- * (peer-incomplete) corpus at a version that already reflects a peer's
2500
- * still-unread write.
2398
+ * Corpus bump reporting whether THIS process's append was the only one
2399
+ * since the pre-bump size (#1902, Codex P1): the `exclusive` flag lets
2400
+ * patchHotMemoriesCache refuse to re-key its locally patched corpus at a
2401
+ * version already reflecting a peer's still-unread concurrent append.
2501
2402
  */
2502
2403
  private bumpMemoryCorpusVersionExclusive(): { produced: number; exclusive: boolean } {
2503
2404
  const filePath = this.versionFilePath("memory-corpus");
@@ -3147,16 +3048,20 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3147
3048
  if (!this.tombstonesConfig.enabled) return null;
3148
3049
  try {
3149
3050
  const store = await this.getTombstoneStore();
3150
- // Chokepoint citation strip (#1579 review): strip citation annotations
3151
- // from the raw body so BOTH the exact-tier hash (when the caller omits
3152
- // contentHash and the store computes it from rawContent) AND the
3153
- // normalized-text tier match re-extraction, which hashes/normalizes the
3154
- // citation-stripped contentHashSource. Idempotent on already-stripped
3155
- // text, so callers that pre-strip (e.g. recordSupersession) are unaffected.
3051
+ // Chokepoint citation strip (#1579 review): both the exact-tier hash
3052
+ // and the normalized-text tier must match re-extraction, which hashes
3053
+ // the citation-stripped contentHashSource. Idempotent on pre-stripped
3054
+ // text (e.g. recordSupersession).
3156
3055
  const strippedRawContent = stripCitationForTemplate(input.rawContent, this.citationTemplate);
3157
- // The store records its own mtime after the append (markWritten) so the
3158
- // staleness probe does not treat this write as a peer append (#1579).
3159
- return await store.appendTombstone({ ...input, rawContent: strippedRawContent });
3056
+ // Canonicalization + post-append recheck (#2213) live in the helper.
3057
+ return await entityRefs.appendCanonicalizedTombstone(
3058
+ this.stateDir,
3059
+ input,
3060
+ () => this.currentHistoricalIds(),
3061
+ (identity) => store.appendTombstone({ ...input, rawContent: strippedRawContent, ...identity }),
3062
+ (tombstoneId) => store.revoke(tombstoneId, input.createdBy),
3063
+ input.sourceMemoryId,
3064
+ );
3160
3065
  } catch (err) {
3161
3066
  log.warn(`tombstone append failed (memory=${input.sourceMemoryId}): ${err}`);
3162
3067
  return null;
@@ -3522,16 +3427,38 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3522
3427
  * store loaded last rewrite every other store's canonical entity ids.
3523
3428
  */
3524
3429
  private userAliases: Record<string, string> = {};
3525
- private historicalEntityCanonicalIds: Readonly<Record<string, string>> = {};
3430
+ private readonly historicalEntityCanonicalIds = new entityRefs.HistoricalEntityCanonicalIdCache();
3431
+ private currentHistoricalIds(): Readonly<Record<string, string>> {
3432
+ return this.historicalEntityCanonicalIds.get(this.stateDir);
3433
+ }
3434
+ /** Post-persist repair subsystem (issue #2213) — storage/entity-ref-repair.ts. */
3435
+ private _entityRefRepair: EntityRefRepair | undefined;
3436
+ private get entityRefRepair(): EntityRefRepair {
3437
+ if (!this._entityRefRepair) {
3438
+ this._entityRefRepair = new EntityRefRepair({
3439
+ stateDir: this.stateDir,
3440
+ baseDir: this.baseDir,
3441
+ currentHistoricalIds: () => this.currentHistoricalIds(),
3442
+ serializeFrontmatter,
3443
+ writeTombstoneBlockedMemory: (pathname, fileContent, frontmatter, content) =>
3444
+ this.writeTombstoneBlockedMemory(pathname, fileContent, frontmatter, content),
3445
+ invalidateAllMemoriesCache: () => this.invalidateAllMemoriesCache(),
3446
+ getTombstoneStore: () => this.getTombstoneStore(),
3447
+ tombstonesEnabled: () => this.tombstonesConfig.enabled,
3448
+ tombstonesNamespace: () => this.tombstonesConfig.namespace,
3449
+ });
3450
+ }
3451
+ return this._entityRefRepair;
3452
+ }
3526
3453
  private readonly entityCanonicalIdMigration = new EntityCanonicalIdMigrationRunner(
3527
3454
  () => !(this._secureStoreRequired && !this.isSecureStoreUnlocked()),
3528
3455
  () => this.runLegacyEntityCanonicalIdMigration(),
3529
- () => entityMigration.getFingerprint(this.baseDir, this.entitiesDir, () => this.getCorpusScanVersion()),
3456
+ () => entityMigration.getFingerprint(this.baseDir, this.entitiesDir, () => String(this.getEntityMutationVersion())),
3530
3457
  );
3531
3458
  normalizeEntityName(raw: string, type: string): string {
3532
- return entityMigration.resolveHistoricalEntityCanonicalId(
3459
+ return entityRefs.resolveHistoricalEntityCanonicalId(
3533
3460
  this.entityStore.normalizeEntityName(raw, type),
3534
- this.historicalEntityCanonicalIds,
3461
+ this.currentHistoricalIds(),
3535
3462
  );
3536
3463
  }
3537
3464
 
@@ -3579,18 +3506,13 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3579
3506
  log.debug("no config/aliases.json found — using built-in aliases only");
3580
3507
  }
3581
3508
  }
3582
- private loadHistoricalEntityCanonicalIdsSync(): void {
3583
- this.historicalEntityCanonicalIds = entityMigration.loadHistoricalEntityCanonicalIds(this.stateDir);
3584
- }
3585
-
3586
- private async runLegacyEntityCanonicalIdMigration(): Promise<string> {
3509
+ private async runLegacyEntityCanonicalIdMigration(): Promise<string | undefined> {
3587
3510
  const completionFingerprint = await runLegacyEntityCanonicalIdMigration(
3588
3511
  this as unknown as EntityCanonicalIdMigrationHost,
3589
3512
  (content) => parseEntityFile(content, this.entitySchemas),
3590
3513
  (entity) => serializeEntityFile(entity, this.entitySchemas),
3591
3514
  createMemoryEntityRefSerializer(serializeFrontmatter),
3592
3515
  );
3593
- this.loadHistoricalEntityCanonicalIdsSync();
3594
3516
  return completionFingerprint;
3595
3517
  }
3596
3518
 
@@ -3676,6 +3598,9 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3676
3598
  options: WriteMemoryOptions = {}
3677
3599
  ): Promise<MemoryWriteResult> {
3678
3600
  await this.ensureDirectories();
3601
+ const rawEntityRef = options.entityRef;
3602
+ let refIds = typeof options.entityRef === "string" ? this.currentHistoricalIds() : null;
3603
+ if (refIds) options = entityRefs.canonicalizeEntityRefOption(options, refIds);
3679
3604
  const now = new Date();
3680
3605
  const today = now.toISOString().slice(0, 10);
3681
3606
  const id = `${category}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
@@ -3779,62 +3704,41 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3779
3704
  }
3780
3705
 
3781
3706
  // ── Non-resurrection chokepoint (issue #1579) ────────────────────────
3782
- // Before a new fact becomes active, consult the tombstone index. If a
3783
- // retired fact matches (exact / normalized / keyed / semantic), persist
3784
- // the candidate as pending_review + blockedBy instead VISIBLE, never a
3785
- // silent drop (rule 34) and skip the active dedup/index registration
3786
- // (rule 44). This is the SINGLE storage persist path: every write
3787
- // (extraction, import, consolidation, dreams, pattern-reinforcement)
3788
- // funnels through writeMemory, so the five resurrection paths are blocked
3789
- // here without per-path code (rule 43).
3707
+ // A match persists the fact as pending_review + blockedBy VISIBLE,
3708
+ // never a silent drop (rule 34) and skips active dedup registration
3709
+ // (rule 44). Pre-gate revalidation (#2213): re-resolve from the caller's
3710
+ // ORIGINAL ref so the lookup and the persisted ref share one id space.
3711
+ if (refIds && typeof rawEntityRef === "string" && this.currentHistoricalIds() !== refIds) {
3712
+ refIds = this.currentHistoricalIds();
3713
+ fm.entityRef = entityRefs.resolveHistoricalEntityCanonicalId(rawEntityRef, refIds);
3714
+ }
3790
3715
  let tombstoneBlocked = false;
3791
- if (
3792
- category === "fact" &&
3793
- this.tombstonesConfig.enabled &&
3794
- factHashSourceForTombstone !== null &&
3795
- // Block facts that would become ACTIVE or are already pending_review
3796
- // (issue #1579 thread ObteQ: wearable/native imports write fact
3797
- // candidates with status: pending_review; without checking them here,
3798
- // promoteWearableMemory could later flip a retired fact active without
3799
- // a tombstone check). A caller that explicitly requests a terminal
3800
- // non-active status (rejected/retracted/superseded) is respected.
3801
- (fm.status === undefined || fm.status === "active" || fm.status === "pending_review")
3802
- ) {
3803
- try {
3804
- const tombstoneStore = await this.getTombstoneStore();
3805
- // Derive the keyed-tier supersession key from structured attributes
3806
- // using the SAME helper write-time supersession uses (one helper).
3807
- const keyedSupersession =
3808
- options.entityRef && options.structuredAttributes
3809
- ? supersessionKeysForFact({
3810
- entityRef: options.entityRef,
3811
- structuredAttributes: options.structuredAttributes,
3812
- })
3813
- : [];
3814
- // Issue #1579 thread Ociag/Oci-W: pass EVERY key — emitters register one tombstone per key, so the block can be on any later key.
3815
- const match = tombstoneStore.lookup({
3816
- contentHash: fm.contentHash,
3817
- normalizedText: ContentHashIndex.normalizeContent(factHashSourceForTombstone),
3818
- ...(options.entityRef ? { entityRef: options.entityRef } : {}),
3819
- ...(keyedSupersession.length > 0 ? { supersessionKeys: keyedSupersession } : {}),
3820
- namespace: this.tombstonesConfig.namespace,
3821
- });
3822
- if (match) {
3823
- tombstoneBlocked = true;
3824
- fm.status = "pending_review";
3825
- fm.blockedBy = match.tombstoneId;
3826
- fm.tombstoneBlockTier = match.matchedTier;
3827
- log.info(
3828
- `tombstone: blocked resurrection of fact ${id} (tier=${match.matchedTier}, tombstone=${match.tombstoneId}, reason=${match.reason})`
3829
- );
3830
- }
3831
- } catch (err) {
3832
- // Fail-open (rule 34 spirit): a tombstone lookup error must not block
3833
- // the write. The fact persists as active; the next lookup after the
3834
- // store recovers will catch a subsequent re-extraction.
3835
- log.warn(`tombstone lookup failed for fact ${id} (fail-open): ${err}`);
3716
+ let gateRef: string | undefined;
3717
+ let statusBeforeBlock: MemoryFrontmatter["status"];
3718
+ // Closure so the post-persist repair can RE-RUN it when a journal move
3719
+ // changed the final entityRef: a verdict is only valid for the ref it was
3720
+ // computed under, so reset and re-evaluate (parked mappings fall BACK to
3721
+ // the legacy claimant; entity-independent tiers re-block in the lookup).
3722
+ const applyTombstoneGate = async (): Promise<void> => {
3723
+ if (tombstoneBlocked) {
3724
+ if (fm.entityRef === gateRef) return;
3725
+ tombstoneBlocked = false;
3726
+ fm.status = statusBeforeBlock;
3727
+ delete fm.blockedBy;
3728
+ delete fm.tombstoneBlockTier;
3836
3729
  }
3837
- }
3730
+ // Status semantics (thread ObteQ: pending_review candidates included,
3731
+ // terminal statuses respected) live in applyTombstoneResurrectionGate.
3732
+ if (category !== "fact" || factHashSourceForTombstone === null) return;
3733
+ const statusBefore = fm.status;
3734
+ const match = await this.entityRefRepair.gate(fm, factHashSourceForTombstone, options.structuredAttributes);
3735
+ if (match) {
3736
+ gateRef = fm.entityRef;
3737
+ statusBeforeBlock = statusBefore;
3738
+ tombstoneBlocked = true;
3739
+ }
3740
+ };
3741
+ await applyTombstoneGate();
3838
3742
 
3839
3743
  const fileContent = `${serializeFrontmatter(fm)}\n\n${sanitized.text}\n`;
3840
3744
 
@@ -3864,6 +3768,38 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3864
3768
  duplicateOf: duplicateBlocked.frontmatter.id,
3865
3769
  };
3866
3770
  }
3771
+ if (refIds && typeof rawEntityRef === "string") {
3772
+ const refBeforeRepair = fm.entityRef;
3773
+ try {
3774
+ await entityRefs.repairEntityRefAfterJournalMove({
3775
+ stateDir: this.stateDir,
3776
+ currentIds: () => this.currentHistoricalIds(),
3777
+ idsAtResolve: refIds,
3778
+ rawRef: rawEntityRef,
3779
+ frontmatter: fm,
3780
+ rewrite: async () => {
3781
+ // Re-gate under the final ref, then rewrite THROUGH the blocked-
3782
+ // capture surface so any verdict change keeps the index consistent.
3783
+ await applyTombstoneGate();
3784
+ await this.writeTombstoneBlockedMemory(
3785
+ filePath,
3786
+ `${serializeFrontmatter(fm)}\n\n${sanitized.text}\n`,
3787
+ fm,
3788
+ sanitized.text,
3789
+ async () => this.invalidateAllMemoriesCache(),
3790
+ );
3791
+ this.invalidateAllMemoriesCache();
3792
+ },
3793
+ });
3794
+ } catch (err) {
3795
+ // Remove the just-created file before propagating (§14): it would
3796
+ // linger un-bumped/un-indexed and a retry would duplicate it.
3797
+ await unlink(filePath).catch(() => undefined);
3798
+ this.invalidateAllMemoriesCache();
3799
+ throw err;
3800
+ }
3801
+ await this.entityRefRepair.syncProjection(id, refBeforeRepair, fm.entityRef);
3802
+ }
3867
3803
  await this.patchHotMemoriesCache({ addedPath: filePath }, "memory-create");
3868
3804
  this.notifyMemoryWrite(filePath);
3869
3805
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.writeMemory", {
@@ -3878,16 +3814,14 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
3878
3814
  ],
3879
3815
  });
3880
3816
  if (category === "fact" && !tombstoneBlocked) {
3881
- // Rule 44 (#1579): a tombstone-blocked fact MUST NOT be registered as an
3882
- // active dedup/index entry otherwise the block is invisible to dedup
3883
- // and the content is silently banned on the next extraction. Only active
3884
- // (un-blocked) facts enter the hash index.
3817
+ // Rule 44 (#1579): only active (un-blocked) facts enter the hash
3818
+ // index — a blocked entry would silently ban the content on the next
3819
+ // extraction.
3885
3820
  try {
3886
3821
  const factHashIndex = await this.getFactHashIndex();
3887
- // When the caller provides a separate contentHashSource (e.g. the raw
3888
- // fact text before citation annotation), index THAT string so that
3889
- // hasFactContentHash(rawFact) returns true on subsequent extractions.
3890
- // Otherwise fall back to the sanitized persisted body as before.
3822
+ // Index the caller's contentHashSource (raw fact text before
3823
+ // citation annotation) when provided, so hasFactContentHash(rawFact)
3824
+ // matches subsequent extractions; else the sanitized persisted body.
3891
3825
  const hashText =
3892
3826
  options.contentHashSource !== undefined && options.contentHashSource.length > 0
3893
3827
  ? sanitizeMemoryContent(options.contentHashSource).text
@@ -4890,8 +4824,53 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
4890
4824
  return path.join(root, dir, this.resolveMemoryDateDir(memory), `${memory.frontmatter.id}.md`);
4891
4825
  }
4892
4826
  private async writeMemoryFileAtomic(targetPath: string, memory: MemoryFile): Promise<void> {
4893
- const fileContent = `${serializeFrontmatter(memory.frontmatter)}\n\n${memory.content}\n`;
4894
- await writeMaybeEncryptedFile(targetPath, fileContent, this.resolveWriteKey(), {}, this.baseDir);
4827
+ // Whole-record rewrite (tier moves) — #2213. Repair, not just detect:
4828
+ // the source is unlinked after the move, so a mapping parked across this
4829
+ // write would strand the moved copy with nothing left to reconcile from.
4830
+ const refIdsAtWrite = this.currentHistoricalIds();
4831
+ const rawRef = memory.frontmatter.entityRef;
4832
+ const frontmatter = entityRefs.canonicalizeEntityRefOption(memory.frontmatter, refIdsAtWrite);
4833
+ const persist = (): Promise<void> =>
4834
+ writeMaybeEncryptedFile(
4835
+ targetPath,
4836
+ `${serializeFrontmatter(frontmatter)}\n\n${memory.content}\n`,
4837
+ this.resolveWriteKey(),
4838
+ {},
4839
+ this.baseDir,
4840
+ );
4841
+ // Snapshot pre-write destination bytes (raw — encryption-agnostic) so a
4842
+ // repair failure restores them or removes a fresh copy (§14): a retained
4843
+ // destination would duplicate the record across tiers under a stale ref.
4844
+ const priorDest = await readFile(targetPath).catch(() => null);
4845
+ await persist();
4846
+ if (typeof rawRef === "string") {
4847
+ const refBeforeRepair = frontmatter.entityRef;
4848
+ try {
4849
+ await entityRefs.repairEntityRefAfterJournalMove({
4850
+ stateDir: this.stateDir,
4851
+ currentIds: () => this.currentHistoricalIds(),
4852
+ idsAtResolve: refIdsAtWrite,
4853
+ rawRef,
4854
+ frontmatter,
4855
+ rewrite: persist,
4856
+ });
4857
+ } catch (err) {
4858
+ if (priorDest === null) {
4859
+ await unlink(targetPath).catch(() => undefined);
4860
+ } else {
4861
+ await writeFile(targetPath, priorDest).catch(() => undefined);
4862
+ }
4863
+ this.invalidateAllMemoriesCache();
4864
+ throw err;
4865
+ }
4866
+ await this.entityRefRepair.syncProjection(memory.frontmatter.id, refBeforeRepair, frontmatter.entityRef);
4867
+ }
4868
+ // Sync the caller's record with the ref the file now carries: tier-move
4869
+ // callers keep using the passed MemoryFile, and a stale legacy ref would
4870
+ // mis-route later attribution/projection work (Cursor Medium, round 21).
4871
+ if (memory.frontmatter.entityRef !== frontmatter.entityRef) {
4872
+ memory.frontmatter.entityRef = frontmatter.entityRef;
4873
+ }
4895
4874
  this.invalidateAllMemoriesCache();
4896
4875
  this.notifyCatalogWrite();
4897
4876
  }
@@ -4969,15 +4948,39 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
4969
4948
  const today = now.toISOString().slice(0, 10);
4970
4949
  const destDir = path.join(this.archiveDir, today);
4971
4950
  await mkdir(destDir, { recursive: true });
4972
- const updatedFm: MemoryFrontmatter = {
4973
- ...current.frontmatter,
4974
- status: "archived",
4975
- archivedAt: now.toISOString(),
4976
- updated: now.toISOString(),
4977
- };
4951
+ // Whole-record rewrite inherited-entityRef rule (issue #2213).
4952
+ const refIdsAtWrite = this.currentHistoricalIds();
4953
+ const updatedFm: MemoryFrontmatter = entityRefs.canonicalizeEntityRefOption(
4954
+ {
4955
+ ...current.frontmatter,
4956
+ status: "archived",
4957
+ archivedAt: now.toISOString(),
4958
+ updated: now.toISOString(),
4959
+ },
4960
+ refIdsAtWrite,
4961
+ );
4978
4962
  const fileContent = `${serializeFrontmatter(updatedFm)}\n\n${current.content}\n`;
4979
4963
  const destPath = path.join(destDir, path.basename(current.path));
4964
+ // Snapshot a pre-existing destination (retried archive) so a repair
4965
+ // failure restores it instead of deleting the only archived copy (§14).
4966
+ const priorDest = await this.readStorageSecureFile(destPath).catch(() => null);
4980
4967
  await this.writeStorageSecureFile(destPath, fileContent);
4968
+ if (typeof current.frontmatter.entityRef === "string") {
4969
+ try {
4970
+ await this.entityRefRepair.repair(
4971
+ destPath, updatedFm, current.frontmatter.entityRef, refIdsAtWrite, current.content,
4972
+ );
4973
+ } catch (err) {
4974
+ // Restore/remove the destination before reporting failure (§14):
4975
+ // a retained fresh copy would surface the memory in BOTH scans.
4976
+ if (priorDest === null) {
4977
+ await unlink(destPath).catch(() => undefined);
4978
+ } else {
4979
+ await this.writeStorageSecureFile(destPath, priorDest).catch(() => undefined);
4980
+ }
4981
+ throw err;
4982
+ }
4983
+ }
4981
4984
  await unlink(current.path);
4982
4985
  markDurable();
4983
4986
  markProjectedMemoryPathInvalid(this.baseDir, current.frontmatter.id);
@@ -5008,13 +5011,9 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5008
5011
  return await this.runTombstoneBlockedArchive(memory, archiveCurrent);
5009
5012
  }
5010
5013
 
5014
+ /** Alias of listEntityNames (kept for legacy callers; sorted). */
5011
5015
  async readEntities(): Promise<string[]> {
5012
- try {
5013
- const entries = await readdir(this.entitiesDir);
5014
- return entries.filter((e) => e.endsWith(".md")).map((e) => e.replace(".md", ""));
5015
- } catch {
5016
- return [];
5017
- }
5016
+ return this.entityStore.listEntityNames();
5018
5017
  }
5019
5018
 
5020
5019
  async readEntity(name: string): Promise<string> {
@@ -5109,15 +5108,20 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5109
5108
  (v, i, a) => a.indexOf(v) === i
5110
5109
  ); // dedupe
5111
5110
 
5112
- const updated: MemoryFrontmatter = {
5113
- ...memory.frontmatter,
5114
- updated: new Date().toISOString(),
5115
- supersedes: options?.supersedes ?? memory.frontmatter.supersedes,
5116
- lineage: mergedLineage.length > 0 ? mergedLineage : undefined,
5117
- ...(memory.frontmatter.sourceConnector === undefined && options?.sourceConnector
5118
- ? { sourceConnector: options.sourceConnector }
5119
- : {}),
5120
- };
5111
+ // Whole-record rewrite inherited-entityRef rule (issue #2213).
5112
+ const refIdsAtWrite = this.currentHistoricalIds();
5113
+ const updated: MemoryFrontmatter = entityRefs.canonicalizeEntityRefOption(
5114
+ {
5115
+ ...memory.frontmatter,
5116
+ updated: new Date().toISOString(),
5117
+ supersedes: options?.supersedes ?? memory.frontmatter.supersedes,
5118
+ lineage: mergedLineage.length > 0 ? mergedLineage : undefined,
5119
+ ...(memory.frontmatter.sourceConnector === undefined && options?.sourceConnector
5120
+ ? { sourceConnector: options.sourceConnector }
5121
+ : {}),
5122
+ },
5123
+ refIdsAtWrite,
5124
+ );
5121
5125
  const sanitized = sanitizeMemoryContent(newContent);
5122
5126
  if (!sanitized.clean) {
5123
5127
  log.warn(`updated memory content sanitized for ${id}; violations=${sanitized.violations.join(", ")}`);
@@ -5126,6 +5130,12 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5126
5130
  await this.writeTombstoneBlockedUpdate(memory, fileContent, updated, sanitized.text, async () => {
5127
5131
  this.invalidateAllMemoriesCache();
5128
5132
  });
5133
+ if (typeof memory.frontmatter.entityRef === "string") {
5134
+ await this.entityRefRepair.repair(
5135
+ memory.path, updated, memory.frontmatter.entityRef, refIdsAtWrite, sanitized.text,
5136
+ { onFailRestore: memory },
5137
+ );
5138
+ }
5129
5139
  await this.patchHotMemoriesCache({ addedPath: memory.path });
5130
5140
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.updateMemory", {
5131
5141
  memoryId: id,
@@ -5153,10 +5163,14 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5153
5163
  lifecycle?: MemoryLifecycleEventWriteOptions
5154
5164
  ): Promise<boolean> {
5155
5165
  const beforeStatus = memory.frontmatter.status ?? "active";
5156
- const updated: MemoryFrontmatter = {
5157
- ...memory.frontmatter,
5158
- ...patch,
5159
- };
5166
+ // Canonicalize the EFFECTIVE merged entityRef (issue #2213) — an
5167
+ // unrelated patch must not rewrite an inherited legacy ref back out.
5168
+ const resolveIds = this.currentHistoricalIds();
5169
+ const updated: MemoryFrontmatter = entityRefs.canonicalizeEntityRefOption(
5170
+ { ...memory.frontmatter, ...patch },
5171
+ resolveIds,
5172
+ );
5173
+ const refIds = typeof updated.entityRef === "string" ? resolveIds : null;
5160
5174
  const afterStatus = updated.status ?? "active";
5161
5175
 
5162
5176
  const fileContent = `${serializeFrontmatter(updated)}\n\n${memory.content}\n`;
@@ -5167,6 +5181,13 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
5167
5181
  this.invalidateColdMemoriesCache();
5168
5182
  }
5169
5183
  });
5184
+ const rawMergedRef = typeof patch.entityRef === "string" ? patch.entityRef : memory.frontmatter.entityRef;
5185
+ if (refIds && typeof rawMergedRef === "string") {
5186
+ await this.entityRefRepair.repair(
5187
+ memory.path, updated, rawMergedRef, refIds, memory.content,
5188
+ { onFailRestore: memory },
5189
+ );
5190
+ }
5170
5191
  await this.patchHotMemoriesCache({ addedPath: memory.path });
5171
5192
  if (memory.path.includes(`${path.sep}cold${path.sep}`)) {
5172
5193
  this.invalidateColdMemoriesCache();
@@ -6057,82 +6078,18 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6057
6078
  log.debug(`appended namespace-local reflection to ${this.identityReflectionsPath}`);
6058
6079
  }
6059
6080
 
6060
- // ---------------------------------------------------------------------------
6061
- // Entity mutation helpers (Knowledge Graph v7.0)
6062
- // ---------------------------------------------------------------------------
6063
-
6064
- /**
6065
- * Add a relationship to an entity file.
6066
- * Deduplicates by target+label.
6067
- */
6081
+ // Entity-file mutators live on EntityStore (issue #2213): journal-resolved
6082
+ // with legacy-file fallback, under the entity-mutation lock.
6068
6083
  async addEntityRelationship(name: string, rel: EntityRelationship): Promise<void> {
6069
- const filePath = path.join(this.entitiesDir, `${name}.md`);
6070
- let entity: EntityFile;
6071
- try {
6072
- const content = await this.readStorageSecureFile(filePath);
6073
- entity = parseEntityFile(content, this.entitySchemas);
6074
- } catch (err) {
6075
- if (err instanceof SecureStoreLockedError) throw err;
6076
- if (!isErrnoCode(err, "ENOENT")) throw err;
6077
- log.debug(`addEntityRelationship: entity file ${name}.md not found`);
6078
- return;
6079
- }
6080
-
6081
- // Dedupe by target+label
6082
- const exists = entity.relationships.some((r) => r.target === rel.target && r.label === rel.label);
6083
- if (exists) return;
6084
-
6085
- entity.relationships.push(rel);
6086
- entity.updated = new Date().toISOString();
6087
- await this.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.entitySchemas));
6088
- this.invalidateKnowledgeIndexCache();
6084
+ return this.entityStore.addEntityRelationship(name, rel);
6089
6085
  }
6090
6086
 
6091
- /**
6092
- * Add an activity entry to an entity file.
6093
- * Prepends to the beginning, prunes oldest entries beyond maxEntries.
6094
- */
6095
6087
  async addEntityActivity(name: string, entry: EntityActivityEntry, maxEntries: number): Promise<void> {
6096
- const filePath = path.join(this.entitiesDir, `${name}.md`);
6097
- let entity: EntityFile;
6098
- try {
6099
- const content = await this.readStorageSecureFile(filePath);
6100
- entity = parseEntityFile(content, this.entitySchemas);
6101
- } catch (err) {
6102
- if (err instanceof SecureStoreLockedError) throw err;
6103
- if (!isErrnoCode(err, "ENOENT")) throw err;
6104
- log.debug(`addEntityActivity: entity file ${name}.md not found`);
6105
- return;
6106
- }
6107
-
6108
- entity.activity.unshift(entry);
6109
- if (entity.activity.length > maxEntries) {
6110
- entity.activity = entity.activity.slice(0, maxEntries);
6111
- }
6112
- entity.updated = new Date().toISOString();
6113
- await this.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.entitySchemas));
6114
- this.invalidateKnowledgeIndexCache();
6088
+ return this.entityStore.addEntityActivity(name, entry, maxEntries);
6115
6089
  }
6116
6090
 
6117
6091
  async addEntityAlias(name: string, alias: string): Promise<void> {
6118
- const filePath = path.join(this.entitiesDir, `${name}.md`);
6119
- let entity: EntityFile;
6120
- try {
6121
- const content = await this.readStorageSecureFile(filePath);
6122
- entity = parseEntityFile(content, this.entitySchemas);
6123
- } catch (err) {
6124
- if (err instanceof SecureStoreLockedError) throw err;
6125
- if (!isErrnoCode(err, "ENOENT")) throw err;
6126
- log.debug(`addEntityAlias: entity file ${name}.md not found`);
6127
- return;
6128
- }
6129
-
6130
- if (entity.aliases.includes(alias)) return;
6131
- entity.aliases.push(alias);
6132
- entity.updated = new Date().toISOString();
6133
- await this.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.entitySchemas));
6134
- this.invalidateKnowledgeIndexCache();
6135
- this.bumpMemoryStatusVersion();
6092
+ return this.entityStore.addEntityAlias(name, alias);
6136
6093
  }
6137
6094
 
6138
6095
  async updateEntitySynthesis(
@@ -6150,24 +6107,13 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6150
6107
  return this.entityStore.updateEntitySynthesis(name, synthesis, options);
6151
6108
  }
6152
6109
 
6153
- /**
6154
- * Backward-compatible alias for legacy callers/tests.
6155
- */
6110
+ /** Backward-compatible alias for legacy callers/tests. */
6156
6111
  async updateEntitySummary(name: string, summary: string): Promise<void> {
6157
6112
  const updatedAt = new Date().toISOString();
6158
- let synthesisTimelineCount: number | undefined;
6159
- try {
6160
- const filePath = path.join(this.entitiesDir, `${name}.md`);
6161
- const content = await this.readStorageSecureFile(filePath);
6162
- synthesisTimelineCount = parseEntityFile(content, this.entitySchemas).timeline.length;
6163
- } catch (err) {
6164
- if (err instanceof SecureStoreLockedError) throw err;
6165
- if (!isErrnoCode(err, "ENOENT")) throw err;
6166
- synthesisTimelineCount = undefined;
6167
- }
6113
+ const raw = await this.readEntity(name);
6168
6114
  await this.updateEntitySynthesis(name, summary, {
6169
6115
  entityUpdatedAt: updatedAt,
6170
- synthesisTimelineCount,
6116
+ synthesisTimelineCount: raw ? parseEntityFile(raw, this.entitySchemas).timeline.length : undefined,
6171
6117
  updatedAt,
6172
6118
  });
6173
6119
  }
@@ -6243,28 +6189,8 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6243
6189
  );
6244
6190
  }
6245
6191
 
6246
- async migrateEntityFilesToCompiledTruthTimeline(): Promise<{
6247
- total: number;
6248
- migrated: number;
6249
- }> {
6250
- const entityNames = await this.listEntityNames();
6251
- let migrated = 0;
6252
- for (const entityName of entityNames) {
6253
- const raw = await this.readEntity(entityName);
6254
- if (!raw) continue;
6255
- const serialized = serializeEntityFile(parseEntityFile(raw, this.entitySchemas), this.entitySchemas);
6256
- if (raw.trimEnd() === serialized.trimEnd()) continue;
6257
- await this.writeStorageSecureFile(path.join(this.entitiesDir, `${entityName}.md`), serialized);
6258
- migrated += 1;
6259
- }
6260
- if (migrated > 0) {
6261
- this.invalidateKnowledgeIndexCache();
6262
- this.bumpMemoryStatusVersion();
6263
- }
6264
- return {
6265
- total: entityNames.length,
6266
- migrated,
6267
- };
6192
+ async migrateEntityFilesToCompiledTruthTimeline(): Promise<{ total: number; migrated: number }> {
6193
+ return this.entityStore.migrateEntityFilesToCompiledTruthTimeline();
6268
6194
  }
6269
6195
 
6270
6196
  // ---------------------------------------------------------------------------
@@ -6408,19 +6334,24 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6408
6334
  : memoryMap.get(entry.memoryId);
6409
6335
  if (!memory) continue;
6410
6336
 
6411
- const newFm: MemoryFrontmatter = {
6412
- ...memory.frontmatter,
6413
- accessCount: entry.newCount,
6414
- lastAccessed: entry.lastAccessed,
6415
- };
6337
+ const rowIds = this.currentHistoricalIds();
6338
+ const newFm: MemoryFrontmatter = entityRefs.canonicalizeEntityRefOption(
6339
+ { ...memory.frontmatter, accessCount: entry.newCount, lastAccessed: entry.lastAccessed },
6340
+ rowIds,
6341
+ );
6416
6342
 
6417
6343
  try {
6418
6344
  const fileContent = `${serializeFrontmatter(newFm)}\n\n${memory.content}\n`;
6419
6345
  await this.writeStorageSecureFile(memory.path, fileContent);
6420
- // Patch the hot corpus cache entry in place with the new accessCount/
6421
- // lastAccessed (issue #1902). The shared sentinel is bumped once after
6422
- // the loop for cross-process coherence; the re-key below keeps this
6423
- // process's patched entries warm.
6346
+ if (typeof memory.frontmatter.entityRef === "string") {
6347
+ // onFailRestore (§14): the count patch was never reported applied.
6348
+ await this.entityRefRepair.repair(
6349
+ memory.path, newFm, memory.frontmatter.entityRef, rowIds, memory.content,
6350
+ { onFailRestore: memory },
6351
+ );
6352
+ }
6353
+ // Patch the hot corpus cache entry in place (#1902); the shared
6354
+ // sentinel is bumped once after the loop for cross-process coherence.
6424
6355
  if (warm) {
6425
6356
  updateCacheOnWrite(this.baseDir, { ...memory, frontmatter: newFm }, keyId);
6426
6357
  }
@@ -6684,6 +6615,9 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6684
6615
  } = {}
6685
6616
  ): Promise<string> {
6686
6617
  await this.ensureDirectories();
6618
+ const rawEntityRef = options.entityRef;
6619
+ let refIds = typeof options.entityRef === "string" ? this.currentHistoricalIds() : null;
6620
+ if (refIds) options = entityRefs.canonicalizeEntityRefOption(options, refIds);
6687
6621
  const now = new Date();
6688
6622
  const today = now.toISOString().slice(0, 10);
6689
6623
  const id = `${parentId}-chunk-${chunkIndex}`;
@@ -6727,11 +6661,24 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6727
6661
  if (!sanitized.clean) {
6728
6662
  log.warn(`chunk content sanitized for ${id}; violations=${sanitized.violations.join(", ")}`);
6729
6663
  }
6730
- const fileContent = `${serializeFrontmatter(fm)}\n\n${sanitized.text}\n`;
6731
-
6732
6664
  const filePath = await this.resolveCategoryWritePath(category, id, today);
6665
+ const fileContent = `${serializeFrontmatter(fm)}\n\n${sanitized.text}\n`;
6666
+ // A retried chunk write lands on the SAME deterministic path, so the
6667
+ // target can already hold a valid prior chunk: snapshot it, and a repair
6668
+ // failure restores it — unlink only a file THIS invocation created
6669
+ // (Codex P2, round 22).
6670
+ let priorChunk: MemoryFile | null = null;
6671
+ try {
6672
+ const priorRaw = await readMaybeEncryptedFile(filePath, this._secureStoreKey, this.baseDir);
6673
+ const parsedPrior = parseFrontmatter(priorRaw);
6674
+ if (parsedPrior) {
6675
+ priorChunk = { path: filePath, frontmatter: parsedPrior.frontmatter, content: parsedPrior.content };
6676
+ }
6677
+ } catch {
6678
+ priorChunk = null;
6679
+ }
6733
6680
 
6734
- return await this.writeTombstoneBlockedChunk(
6681
+ const written = await this.writeTombstoneBlockedChunk(
6735
6682
  filePath,
6736
6683
  fileContent,
6737
6684
  fm,
@@ -6744,6 +6691,18 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6744
6691
  log.debug(`wrote chunk ${id} (${chunkIndex + 1}/${chunkTotal}) to ${filePath}`);
6745
6692
  }
6746
6693
  );
6694
+ // Repair only when THIS chunk persisted: a tombstone-dedupe result
6695
+ // returns an existing id without writing filePath, and an unconditional
6696
+ // repair rewrite would create a stray second chunk from scratch. The
6697
+ // rewrite RE-GATES under the final ref (Bugbot): a journal move into
6698
+ // tombstone-blocked identity space must not leave a fact chunk active.
6699
+ if (written === id && refIds && typeof rawEntityRef === "string") {
6700
+ await this.entityRefRepair.repair(filePath, fm, rawEntityRef, refIds, sanitized.text, {
6701
+ regateFact: true,
6702
+ ...(priorChunk ? { onFailRestore: priorChunk } : { onFailRemove: filePath }),
6703
+ });
6704
+ }
6705
+ return written;
6747
6706
  }
6748
6707
 
6749
6708
  /**
@@ -6771,18 +6730,25 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6771
6730
  if (!oldMemory) return false;
6772
6731
 
6773
6732
  const now = new Date().toISOString();
6774
- const updatedFm: MemoryFrontmatter = {
6775
- ...oldMemory.frontmatter,
6776
- status: "superseded",
6777
- supersededBy: newMemoryId,
6778
- supersededAt: now,
6779
- updated: now,
6780
- };
6733
+ // Whole-record rewrite inherited-entityRef rule (issue #2213).
6734
+ const refIdsAtWrite = this.currentHistoricalIds();
6735
+ const updatedFm: MemoryFrontmatter = entityRefs.canonicalizeEntityRefOption(
6736
+ { ...oldMemory.frontmatter, status: "superseded", supersededBy: newMemoryId, supersededAt: now, updated: now },
6737
+ refIdsAtWrite,
6738
+ );
6781
6739
 
6782
6740
  const fileContent = `${serializeFrontmatter(updatedFm)}\n\n${oldMemory.content}\n`;
6783
6741
 
6784
6742
  try {
6785
6743
  await this.writeStorageSecureFile(oldMemory.path, fileContent);
6744
+ if (typeof oldMemory.frontmatter.entityRef === "string") {
6745
+ // onFailRestore (§14): a record left superseded on disk would skip
6746
+ // its non-resurrection tombstone while caches expose the old state.
6747
+ await this.entityRefRepair.repair(
6748
+ oldMemory.path, updatedFm, oldMemory.frontmatter.entityRef, refIdsAtWrite, oldMemory.content,
6749
+ { onFailRestore: oldMemory },
6750
+ );
6751
+ }
6786
6752
  // Advance the corpus sentinel immediately after the on-disk write, BEFORE
6787
6753
  // the awaited lifecycle append (Cursor Medium, #1902). Otherwise a warm
6788
6754
  // hot-memories cache keeps serving the pre-supersede snapshot during the
@@ -6802,20 +6768,17 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6802
6768
  this.bumpMemoryStatusVersion();
6803
6769
  log.debug(`superseded memory ${oldMemoryId} by ${newMemoryId}: ${reason}`);
6804
6770
 
6805
- // Issue #1579 — emit a tombstone so the superseded fact cannot
6806
- // resurrect. Contradiction resolution verbs (keep-a/keep-b/merge) all
6807
- // funnel through supersedeMemory, so emitting here covers every verb
6808
- // exactly once (rule 22). Only facts participate. Thread Oci-Y: emit
6809
- // one tombstone PER derived supersession key (buildRetiredFactTombstoneInputs)
6810
- // so a paraphrased re-write is caught on the keyed tier — a single
6811
- // entityRef-only record missed and the fact resurrected until rebuild.
6771
+ // #1579 — every contradiction verb (keep-a/keep-b/merge) funnels here,
6772
+ // so emitting covers each exactly once (rule 22); facts only. One
6773
+ // tombstone PER derived supersession key (thread Oci-Y) so a
6774
+ // paraphrased re-write is caught on the keyed tier.
6812
6775
  if (oldMemory.frontmatter.category === "fact") {
6813
6776
  for (const input of buildRetiredFactTombstoneInputs(
6814
6777
  {
6815
6778
  id: oldMemoryId,
6816
6779
  content: stripCitationForTemplate(oldMemory.content, this.citationTemplate),
6817
6780
  contentHash: oldMemory.frontmatter.contentHash,
6818
- entityRef: oldMemory.frontmatter.entityRef,
6781
+ entityRef: updatedFm.entityRef,
6819
6782
  structuredAttributes: oldMemory.frontmatter.structuredAttributes,
6820
6783
  },
6821
6784
  {
@@ -6901,20 +6864,24 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
6901
6864
  if (!memory) continue;
6902
6865
 
6903
6866
  const now = new Date().toISOString();
6904
- const updatedFm: MemoryFrontmatter = {
6905
- ...memory.frontmatter,
6906
- status: "archived",
6907
- archivedAt: now,
6908
- updated: now,
6909
- };
6867
+ const rowIds = this.currentHistoricalIds();
6868
+ const updatedFm: MemoryFrontmatter = entityRefs.canonicalizeEntityRefOption(
6869
+ { ...memory.frontmatter, status: "archived", archivedAt: now, updated: now },
6870
+ rowIds,
6871
+ );
6910
6872
 
6911
6873
  try {
6912
6874
  const fileContent = `${serializeFrontmatter(updatedFm)}\n\n${memory.content}\n`;
6913
6875
  await this.writeStorageSecureFile(memory.path, fileContent);
6914
- // Corpus sentinel bump per file write, BEFORE the awaited lifecycle
6915
- // append (Cursor Medium, #1902): the end-of-loop bumpMemoryStatusVersion
6916
- // fires only after the whole batch, so without this a warm cache serves
6917
- // pre-archive state for every file during the loop's await windows.
6876
+ if (typeof memory.frontmatter.entityRef === "string") {
6877
+ // onFailRestore (§14): the method reports the row NOT archived.
6878
+ await this.entityRefRepair.repair(
6879
+ memory.path, updatedFm, memory.frontmatter.entityRef, rowIds, memory.content,
6880
+ { onFailRestore: memory },
6881
+ );
6882
+ }
6883
+ // Per-file corpus bump BEFORE the awaited lifecycle append (#1902):
6884
+ // the end-of-loop status bump fires only after the whole batch.
6918
6885
  this.bumpMemoryCorpusVersion();
6919
6886
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.archiveMemories", {
6920
6887
  memoryId: id,