@remnic/core 9.3.724 → 9.3.726

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 (170) hide show
  1. package/dist/access-boundary.js +26 -25
  2. package/dist/access-cli.js +64 -62
  3. package/dist/access-cli.js.map +1 -1
  4. package/dist/access-http.js +33 -32
  5. package/dist/access-mcp.js +30 -29
  6. package/dist/access-operations-batch.js +27 -26
  7. package/dist/access-operations.js +29 -28
  8. package/dist/access-schema.js +3 -3
  9. package/dist/access-service.js +25 -24
  10. package/dist/active-recall.js +1 -1
  11. package/dist/adapters/index.js +4 -4
  12. package/dist/adapters/registry.js +2 -2
  13. package/dist/briefing.js +6 -5
  14. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-GWVG7LGC.js} +2 -2
  15. package/dist/causal-consolidation.js +7 -6
  16. package/dist/causal-consolidation.js.map +1 -1
  17. package/dist/{chunk-C6BWLYW5.js → chunk-2NBP25BG.js} +7 -7
  18. package/dist/{chunk-YUYPWJD3.js → chunk-3MS3QVX6.js} +2 -2
  19. package/dist/{chunk-FVZ2ISIX.js → chunk-3TUT4PWB.js} +2 -2
  20. package/dist/{chunk-HCVWF657.js → chunk-53EEQYE5.js} +7 -7
  21. package/dist/{chunk-TAPSNIMA.js → chunk-5CEJH5ZN.js} +4 -4
  22. package/dist/{chunk-D7EBTQ24.js → chunk-5NWYQUQC.js} +2 -2
  23. package/dist/{chunk-NT36GU6X.js → chunk-5OOA7SGX.js} +2 -2
  24. package/dist/{chunk-KQAFEZQX.js → chunk-5W7CTZ2E.js} +4 -4
  25. package/dist/{chunk-QR67FU5G.js → chunk-6AFVIOVZ.js} +2 -2
  26. package/dist/{chunk-UBHVAGO7.js → chunk-6WAJQKBS.js} +2 -2
  27. package/dist/{chunk-6CWGS2LY.js → chunk-7NS7AWMZ.js} +3 -3
  28. package/dist/{chunk-T55EGZSJ.js → chunk-BB57OZWC.js} +2 -2
  29. package/dist/{chunk-YQMPUYHE.js → chunk-BBIE7W52.js} +2 -2
  30. package/dist/{chunk-WAFDHKES.js → chunk-BEUCACTV.js} +2 -2
  31. package/dist/{chunk-4SF232E6.js → chunk-BIA7ELIB.js} +17 -5
  32. package/dist/chunk-BIA7ELIB.js.map +1 -0
  33. package/dist/{chunk-GZQXOQQ2.js → chunk-DCKNMLEQ.js} +4 -4
  34. package/dist/{chunk-ZUDM75KG.js → chunk-DCWIQFNA.js} +4 -4
  35. package/dist/{chunk-VI5SGGXB.js → chunk-DQXPJ6HD.js} +4 -4
  36. package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
  37. package/dist/{chunk-Z3FGC3QE.js → chunk-EZNDNMRH.js} +139 -30
  38. package/dist/chunk-EZNDNMRH.js.map +1 -0
  39. package/dist/{chunk-FACTVMXQ.js → chunk-FO3HO52S.js} +2 -2
  40. package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
  41. package/dist/{chunk-JJJCVCCQ.js → chunk-JPUT5NGH.js} +2 -2
  42. package/dist/{chunk-5OPYYPJN.js → chunk-K4ZNARDO.js} +2 -2
  43. package/dist/{chunk-4WZ3J7M6.js → chunk-KJR66XOM.js} +2 -2
  44. package/dist/{chunk-LD53WPMU.js → chunk-LQ6JI4VH.js} +4 -4
  45. package/dist/chunk-LTHVM4XN.js +109 -0
  46. package/dist/chunk-LTHVM4XN.js.map +1 -0
  47. package/dist/{chunk-4R3CGCI5.js → chunk-MQVKHFIU.js} +16 -13
  48. package/dist/chunk-MQVKHFIU.js.map +1 -0
  49. package/dist/{chunk-FXCIOLRB.js → chunk-NPL7VDM6.js} +2 -2
  50. package/dist/{chunk-KLFYKC3K.js → chunk-OAHOL3ZC.js} +112 -72
  51. package/dist/chunk-OAHOL3ZC.js.map +1 -0
  52. package/dist/{chunk-2NLLXCJG.js → chunk-OWHERGF2.js} +2 -2
  53. package/dist/{chunk-I2HL5M6T.js → chunk-PLG6JHQS.js} +42 -42
  54. package/dist/{chunk-KGZF2VO3.js → chunk-Q6D75LVH.js} +15 -15
  55. package/dist/{chunk-SC2YAWRF.js → chunk-RRHHQYJS.js} +2 -2
  56. package/dist/{chunk-J334N6QV.js → chunk-TA3Y6GEX.js} +2 -2
  57. package/dist/{chunk-FN6QP7JQ.js → chunk-TQQH2FTX.js} +4 -4
  58. package/dist/{chunk-DF3I6JGP.js → chunk-UKTDXTWX.js} +15 -15
  59. package/dist/{chunk-ARV3AUOM.js → chunk-UNNDFYUX.js} +5 -5
  60. package/dist/{chunk-ZK2L72VN.js → chunk-UQSVUEN2.js} +2 -2
  61. package/dist/{chunk-X7Y7WX73.js → chunk-UVYI6VIX.js} +1 -1
  62. package/dist/{chunk-H7HUQIY7.js → chunk-V7CMKMHM.js} +3 -3
  63. package/dist/chunk-W5H6ZRPT.js +14 -0
  64. package/dist/chunk-W5H6ZRPT.js.map +1 -0
  65. package/dist/{chunk-TT5U3UDO.js → chunk-WGTWGDR5.js} +5 -5
  66. package/dist/{chunk-YQGHW5ML.js → chunk-WRFKZEO6.js} +4 -4
  67. package/dist/{chunk-WM7DNKBD.js → chunk-YTZ3VGC7.js} +2 -2
  68. package/dist/cli.js +54 -53
  69. package/dist/compounding/engine.js +6 -5
  70. package/dist/config.js +1 -1
  71. package/dist/connectors/codex-materialize-runner.js +6 -5
  72. package/dist/connectors/index.js +6 -5
  73. package/dist/content-hash.d.ts +14 -0
  74. package/dist/content-hash.js +10 -0
  75. package/dist/content-hash.js.map +1 -0
  76. package/dist/conversation-index/backend.js +2 -2
  77. package/dist/dashboard-runtime.js +2 -2
  78. package/dist/entity-retrieval.js +6 -5
  79. package/dist/extraction-redaction-rules.d.ts +33 -0
  80. package/dist/extraction-redaction-rules.js +14 -0
  81. package/dist/extraction-redaction-rules.js.map +1 -0
  82. package/dist/extraction.js +2 -2
  83. package/dist/{forget-OEE5OELK.js → forget-WYLR4ZFV.js} +2 -2
  84. package/dist/index.js +102 -100
  85. package/dist/index.js.map +1 -1
  86. package/dist/lcm/index.js +3 -3
  87. package/dist/maintenance/memory-governance.js +6 -5
  88. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +6 -5
  89. package/dist/maintenance/rebuild-memory-projection.js +7 -6
  90. package/dist/namespaces/migrate.js +16 -15
  91. package/dist/namespaces/search.js +9 -9
  92. package/dist/namespaces/storage.js +6 -5
  93. package/dist/operator-toolkit.js +24 -23
  94. package/dist/orchestration/maintenance.js +8 -7
  95. package/dist/orchestrator.js +48 -46
  96. package/dist/resume-bundles.js +2 -2
  97. package/dist/search/factory.js +8 -8
  98. package/dist/search/index.js +12 -12
  99. package/dist/search/lancedb-backend.js +2 -2
  100. package/dist/search/meilisearch-backend.js +2 -2
  101. package/dist/search/orama-backend.js +2 -2
  102. package/dist/semantic-consolidation.js +7 -6
  103. package/dist/semantic-rule-promotion.js +6 -5
  104. package/dist/semantic-rule-verifier.js +6 -5
  105. package/dist/storage.d.ts +2 -2
  106. package/dist/storage.js +6 -5
  107. package/dist/transfer/backup.js +2 -2
  108. package/dist/transfer/capsule-export.js +2 -2
  109. package/dist/transfer/capsule-import.js +2 -2
  110. package/dist/verified-recall.js +6 -5
  111. package/package.json +2 -2
  112. package/src/config.test.ts +50 -0
  113. package/src/config.ts +21 -4
  114. package/src/content-hash.ts +24 -0
  115. package/src/correction/correction-access-wiring.ts +104 -19
  116. package/src/correction/correction-contract.ts +68 -10
  117. package/src/correction/correction-executor.test.ts +112 -2
  118. package/src/correction/correction-executor.ts +65 -5
  119. package/src/correction/correction-planner.test.ts +47 -0
  120. package/src/correction/correction-planner.ts +40 -1
  121. package/src/correction/correction-storage-integration.test.ts +183 -0
  122. package/src/extraction-redaction-rules.test.ts +213 -0
  123. package/src/extraction-redaction-rules.ts +208 -0
  124. package/src/orchestrator.ts +52 -1
  125. package/src/storage.ts +6 -10
  126. package/dist/chunk-4R3CGCI5.js.map +0 -1
  127. package/dist/chunk-4SF232E6.js.map +0 -1
  128. package/dist/chunk-KLFYKC3K.js.map +0 -1
  129. package/dist/chunk-Z3FGC3QE.js.map +0 -1
  130. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  131. /package/dist/{chunk-C6BWLYW5.js.map → chunk-2NBP25BG.js.map} +0 -0
  132. /package/dist/{chunk-YUYPWJD3.js.map → chunk-3MS3QVX6.js.map} +0 -0
  133. /package/dist/{chunk-FVZ2ISIX.js.map → chunk-3TUT4PWB.js.map} +0 -0
  134. /package/dist/{chunk-HCVWF657.js.map → chunk-53EEQYE5.js.map} +0 -0
  135. /package/dist/{chunk-TAPSNIMA.js.map → chunk-5CEJH5ZN.js.map} +0 -0
  136. /package/dist/{chunk-D7EBTQ24.js.map → chunk-5NWYQUQC.js.map} +0 -0
  137. /package/dist/{chunk-NT36GU6X.js.map → chunk-5OOA7SGX.js.map} +0 -0
  138. /package/dist/{chunk-KQAFEZQX.js.map → chunk-5W7CTZ2E.js.map} +0 -0
  139. /package/dist/{chunk-QR67FU5G.js.map → chunk-6AFVIOVZ.js.map} +0 -0
  140. /package/dist/{chunk-UBHVAGO7.js.map → chunk-6WAJQKBS.js.map} +0 -0
  141. /package/dist/{chunk-6CWGS2LY.js.map → chunk-7NS7AWMZ.js.map} +0 -0
  142. /package/dist/{chunk-T55EGZSJ.js.map → chunk-BB57OZWC.js.map} +0 -0
  143. /package/dist/{chunk-YQMPUYHE.js.map → chunk-BBIE7W52.js.map} +0 -0
  144. /package/dist/{chunk-WAFDHKES.js.map → chunk-BEUCACTV.js.map} +0 -0
  145. /package/dist/{chunk-GZQXOQQ2.js.map → chunk-DCKNMLEQ.js.map} +0 -0
  146. /package/dist/{chunk-ZUDM75KG.js.map → chunk-DCWIQFNA.js.map} +0 -0
  147. /package/dist/{chunk-VI5SGGXB.js.map → chunk-DQXPJ6HD.js.map} +0 -0
  148. /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
  149. /package/dist/{chunk-FACTVMXQ.js.map → chunk-FO3HO52S.js.map} +0 -0
  150. /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
  151. /package/dist/{chunk-JJJCVCCQ.js.map → chunk-JPUT5NGH.js.map} +0 -0
  152. /package/dist/{chunk-5OPYYPJN.js.map → chunk-K4ZNARDO.js.map} +0 -0
  153. /package/dist/{chunk-4WZ3J7M6.js.map → chunk-KJR66XOM.js.map} +0 -0
  154. /package/dist/{chunk-LD53WPMU.js.map → chunk-LQ6JI4VH.js.map} +0 -0
  155. /package/dist/{chunk-FXCIOLRB.js.map → chunk-NPL7VDM6.js.map} +0 -0
  156. /package/dist/{chunk-2NLLXCJG.js.map → chunk-OWHERGF2.js.map} +0 -0
  157. /package/dist/{chunk-I2HL5M6T.js.map → chunk-PLG6JHQS.js.map} +0 -0
  158. /package/dist/{chunk-KGZF2VO3.js.map → chunk-Q6D75LVH.js.map} +0 -0
  159. /package/dist/{chunk-SC2YAWRF.js.map → chunk-RRHHQYJS.js.map} +0 -0
  160. /package/dist/{chunk-J334N6QV.js.map → chunk-TA3Y6GEX.js.map} +0 -0
  161. /package/dist/{chunk-FN6QP7JQ.js.map → chunk-TQQH2FTX.js.map} +0 -0
  162. /package/dist/{chunk-DF3I6JGP.js.map → chunk-UKTDXTWX.js.map} +0 -0
  163. /package/dist/{chunk-ARV3AUOM.js.map → chunk-UNNDFYUX.js.map} +0 -0
  164. /package/dist/{chunk-ZK2L72VN.js.map → chunk-UQSVUEN2.js.map} +0 -0
  165. /package/dist/{chunk-X7Y7WX73.js.map → chunk-UVYI6VIX.js.map} +0 -0
  166. /package/dist/{chunk-H7HUQIY7.js.map → chunk-V7CMKMHM.js.map} +0 -0
  167. /package/dist/{chunk-TT5U3UDO.js.map → chunk-WGTWGDR5.js.map} +0 -0
  168. /package/dist/{chunk-YQGHW5ML.js.map → chunk-WRFKZEO6.js.map} +0 -0
  169. /package/dist/{chunk-WM7DNKBD.js.map → chunk-YTZ3VGC7.js.map} +0 -0
  170. /package/dist/{forget-OEE5OELK.js.map → forget-WYLR4ZFV.js.map} +0 -0
@@ -263,25 +263,50 @@ export function validateRedactionPattern(pattern: string): string {
263
263
  `redaction_rule.pattern exceeds the ${REDACTION_PATTERN_MAX}-character bound.`,
264
264
  );
265
265
  }
266
- // Reject patterns that could match an unbounded string (catastrophic /
267
- // overly-broad). A literal or a bounded regex is fine; a bare `.*` / `.`
268
- // regex is not. We do NOT execute the regex (ReDoS); we only inspect shape.
269
- if (isRegexLike(trimmed) && isOverlyBroadRegex(trimmed)) {
266
+ // Reject patterns that could match an unbounded string (overly-broad) OR
267
+ // exhibit catastrophic backtracking (nested quantifiers like `(a+)+`). We do
268
+ // NOT execute the regex (that would be ReDoS itself); we only inspect shape.
269
+ if (isRegexLike(trimmed) && isUnsafeRedactionRegex(trimmed)) {
270
270
  throw new CorrectionContractError(
271
- "redaction_rule.pattern is too broad — use a bounded literal or a more specific pattern.",
271
+ "redaction_rule.pattern is unsafe — use a bounded literal or a regex without nested quantifiers / overlapping alternation.",
272
272
  );
273
273
  }
274
+ // #1669 P2: reject zero-width regex (empty body or matches empty string)
275
+ // that would withhold every fact.
276
+ if (isRegexLike(trimmed)) {
277
+ const regexBody = trimmed.slice(1, -1);
278
+ if (regexBody.length === 0) {
279
+ throw new CorrectionContractError("redaction_rule.pattern body is empty — use a non-empty regex.");
280
+ }
281
+ try {
282
+ if (new RegExp(regexBody).test("")) {
283
+ throw new CorrectionContractError(
284
+ "redaction_rule.pattern matches the empty string — it would withhold every fact.",
285
+ );
286
+ }
287
+ } catch (e) {
288
+ if (e instanceof CorrectionContractError) throw e;
289
+ // Compilation failed — the extraction-time defense handles it.
290
+ }
291
+ }
274
292
  return trimmed;
275
293
  }
276
294
 
277
- /** Heuristic: treat `/.../` or presence of regex metacharacters as regex. */
295
+ /** Only `/.../`-wrapped patterns are treated as regex; everything else is a
296
+ * literal (review thread P1 — consistent with extraction-redaction-rules.ts). */
278
297
  function isRegexLike(pattern: string): boolean {
279
- if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length >= 2) return true;
280
- return /[\\^$.|?*+()[\]{}]/.test(pattern);
298
+ return pattern.startsWith("/") && pattern.endsWith("/") && pattern.length >= 2;
281
299
  }
282
300
 
283
- /** Reject a regex that would match an arbitrary-length run of any character. */
284
- function isOverlyBroadRegex(pattern: string): boolean {
301
+ /**
302
+ * Reject a regex that would either match an arbitrary-length run of any
303
+ * character (overly-broad) OR exhibit catastrophic backtracking (ReDoS).
304
+ * Mirrors the safe-regex heuristic in extraction-redaction-rules.ts so the
305
+ * apply-time chokepoint and the extraction-time defense agree on what is
306
+ * pathological — intentionally not imported to keep the two modules decoupled
307
+ * (contract owns validation; extraction owns consultation).
308
+ */
309
+ function isUnsafeRedactionRegex(pattern: string): boolean {
285
310
  const body = pattern.startsWith("/") && pattern.endsWith("/")
286
311
  ? pattern.slice(1, -1)
287
312
  : pattern;
@@ -289,6 +314,39 @@ function isOverlyBroadRegex(pattern: string): boolean {
289
314
  if (/(?:^|[^\\])\(\.\*\)|(?:^|[^\\])\.\*|(?:^|[^\\])\.\+|^\.([^*+]?)$/.test(body)) {
290
315
  return true;
291
316
  }
317
+ // Nested quantifier: a group (...) whose body ends with a quantifier and
318
+ // which is itself quantified → exponential blowup on near-miss inputs
319
+ // (classic ReDoS shapes: (a+)+, (a*)*, (a?)+). Also catches overlapping
320
+ // alternation under repetition: (a|a)+ where branches share a prefix.
321
+ if (body.length > 512) return true;
322
+ for (let i = 0; i < body.length; i++) {
323
+ if (body[i] !== "(") continue;
324
+ let depth = 1;
325
+ let j = i + 1;
326
+ while (j < body.length && depth > 0) {
327
+ if (body[j] === "\\") { j += 2; continue; }
328
+ if (body[j] === "(") depth++;
329
+ else if (body[j] === ")") depth--;
330
+ j++;
331
+ }
332
+ if (depth !== 0) continue;
333
+ const afterGroup = body[j];
334
+ if (afterGroup !== "+" && afterGroup !== "*" && afterGroup !== "{") continue;
335
+ const groupBody = body.slice(i + 1, j - 1);
336
+ if (/[+*?]$/.test(groupBody) || /\{\d+,?\d*\}[+*?]?$/.test(groupBody)) {
337
+ return true;
338
+ }
339
+ if (groupBody.includes("|")) {
340
+ const branches = groupBody.split("|");
341
+ if (branches.length >= 2) {
342
+ const firstChars = new Set(branches.map((b) => b[0]).filter(Boolean));
343
+ if (firstChars.size < branches.filter((b) => b.length > 0).length) {
344
+ return true;
345
+ }
346
+ }
347
+ }
348
+ i = j;
349
+ }
292
350
  return false;
293
351
  }
294
352
 
@@ -27,7 +27,7 @@ import {
27
27
  type CorrectionPlan,
28
28
  } from "./correction-contract.js";
29
29
  import { CorrectionPlanner, type PlannerCandidate, type PlannerDeps } from "./correction-planner.js";
30
- import { CorrectionExecutor, type ExecutorDeps, type ExecutorMemory } from "./correction-executor.js";
30
+ import { CorrectionExecutor, CORRECTION_ERROR_MAX, sanitizeErrorMessage, type ExecutorDeps, type ExecutorMemory } from "./correction-executor.js";
31
31
 
32
32
  // ---------------------------------------------------------------------------
33
33
  // Fakes
@@ -36,7 +36,7 @@ import { CorrectionExecutor, type ExecutorDeps, type ExecutorMemory } from "./co
36
36
  interface FakeState {
37
37
  /** memoryId → current memory state. */
38
38
  memories: Map<string, ExecutorMemory & { status: string; supersededBy?: string; validUntil?: string }>;
39
- tombstones: Array<{ reason: string; sourceMemoryId: string; rawContent: string }>;
39
+ tombstones: Array<{ reason: string; sourceMemoryId: string; rawContent: string; supersessionKey?: string; supersessionKeys?: string[]; contentHash?: string }>;
40
40
  redactionRules: string[];
41
41
  auditRecords: Array<{ planId: string; outcome: CorrectionOutcome }>;
42
42
  replacementFailOnLoserId?: string;
@@ -740,3 +740,113 @@ test("OgIqt: plan marked `applying` before mutations; markConsumed failure leave
740
740
  );
741
741
  });
742
742
  });
743
+
744
+ // ---------------------------------------------------------------------------
745
+ // #1672 / #1678 regression tests
746
+ // ---------------------------------------------------------------------------
747
+
748
+ test("#1672 item 4 (executor): retract forwards supersessionKeys + contentHash into the tombstone dep", async () => {
749
+ await withTempDir(async (dir) => {
750
+ const candidates = new Map<string, PlannerCandidate>([
751
+ ["mem-s", { memoryId: "mem-s", path: "facts/mem-s.md", content: "structured", excerpt: "structured", score: 1 }],
752
+ ]);
753
+ const state: FakeState = {
754
+ memories: new Map([
755
+ ["mem-s", fakeMemory({
756
+ memoryId: "mem-s",
757
+ content: "structured",
758
+ supersessionKeys: ["entity-deploy::day", "entity-deploy::week"],
759
+ contentHash: "canonical-hash-xyz",
760
+ })],
761
+ ]),
762
+ tombstones: [],
763
+ redactionRules: [],
764
+ auditRecords: [],
765
+ propagateCalls: 0,
766
+ writtenReplacements: [],
767
+ };
768
+ const plannerDeps: PlannerDeps = {
769
+ ...makePlannerDeps(dir, candidates),
770
+ classifyAndDraft: async () => ({
771
+ classification: "outdated",
772
+ confidence: 0.9,
773
+ actions: [{ kind: "retract", memoryId: "mem-s" }],
774
+ relevance: [{ memoryId: "mem-s", why: "stale" }],
775
+ warnings: [],
776
+ }),
777
+ };
778
+ const planner = new CorrectionPlanner(plannerDeps);
779
+ const plan = await planner.plan({ text: "structured", targetIds: ["mem-s"] }, ["default"]);
780
+ const executor = new CorrectionExecutor(makeExecutorDeps(state), planner);
781
+ const outcome = await executor.apply("default", plan.planId, { confirm: true });
782
+ assert.equal(outcome.status, "applied");
783
+ assert.equal(state.tombstones.length, 1, "exactly one tombstone dep call for the retract");
784
+ assert.deepEqual(state.tombstones[0].supersessionKeys, ["entity-deploy::day", "entity-deploy::week"],
785
+ "the tombstone dep must receive the FULL supersessionKeys set");
786
+ assert.equal(state.tombstones[0].contentHash, "canonical-hash-xyz",
787
+ "the tombstone dep must receive the canonical contentHash (exact tier)");
788
+ });
789
+ });
790
+
791
+ test("#1678: rescope outcome reports the DESTINATION memory id, not the source", async () => {
792
+ await withTempDir(async (dir) => {
793
+ const candidates = new Map<string, PlannerCandidate>([
794
+ ["mem-r", { memoryId: "mem-r", path: "facts/mem-r.md", content: "fact", excerpt: "fact", score: 1 }],
795
+ ]);
796
+ const state: FakeState = {
797
+ memories: new Map([["mem-r", fakeMemory({ memoryId: "mem-r", content: "fact" })]]),
798
+ tombstones: [],
799
+ redactionRules: [],
800
+ auditRecords: [],
801
+ propagateCalls: 0,
802
+ writtenReplacements: [],
803
+ };
804
+ const plannerDeps: PlannerDeps = {
805
+ ...makePlannerDeps(dir, candidates),
806
+ classifyAndDraft: async () => ({
807
+ classification: "wrong_scope",
808
+ confidence: 0.9,
809
+ actions: [{ kind: "rescope", memoryId: "mem-r", toNamespace: "other-ns" }],
810
+ relevance: [{ memoryId: "mem-r", why: "wrong scope" }],
811
+ warnings: [],
812
+ }),
813
+ };
814
+ const planner = new CorrectionPlanner(plannerDeps);
815
+ const plan = await planner.plan({ text: "fact", targetIds: ["mem-r"] }, ["default"]);
816
+ const executor = new CorrectionExecutor(makeExecutorDeps(state), planner);
817
+ const outcome = await executor.apply("default", plan.planId, { confirm: true });
818
+ const result = outcome.results.find((r) => r.action.kind === "rescope");
819
+ assert.equal(result!.status, "applied");
820
+ // The fake rescopeMemory returns `rescoped-${memoryId}`. The outcome must
821
+ // report THAT destination id — not the source (which is archived by the move).
822
+ assert.equal(result!.memoryId, "rescoped-mem-r",
823
+ "rescope outcome must report the destination id, not the archived source");
824
+ });
825
+ });
826
+
827
+ test("#1678: sanitizeErrorMessage strips absolute paths and caps length", () => {
828
+ // POSIX absolute path is replaced with <path>.
829
+ const posix = sanitizeErrorMessage("failed to read /Users/josh/secrets/key.pem during apply");
830
+ assert.ok(!posix.includes("/Users/josh"), "POSIX abs path must be stripped: " + posix);
831
+ assert.ok(posix.includes("<path>"), "stripped path must leave a <path> placeholder: " + posix);
832
+ // Windows absolute path is replaced with <path>.
833
+ const win = sanitizeErrorMessage("cannot open C:\\Users\\josh\\vault\\token.txt");
834
+ assert.ok(!/C:\\Users/i.test(win), "Windows abs path must be stripped: " + win);
835
+ // Length cap.
836
+ const long = sanitizeErrorMessage("x".repeat(CORRECTION_ERROR_MAX + 500));
837
+ assert.ok(long.length <= CORRECTION_ERROR_MAX + 1, "over-long message must be capped (cap + ellipsis)");
838
+ assert.ok(long.endsWith("…"), "capped message must end with an ellipsis");
839
+ // A clean message passes through unchanged.
840
+ assert.equal(sanitizeErrorMessage("memory not found: mem-42"), "memory not found: mem-42");
841
+ // Paths starting with a dot component (/.config/remnic/token) — previously
842
+ // leaked because the regex required the first path char to be [A-Za-z].
843
+ const dotPath = sanitizeErrorMessage("ENOENT: /.config/remnic/token not found");
844
+ assert.ok(!dotPath.includes("/.config"), "dot-leading path must be stripped: " + dotPath);
845
+ assert.ok(!dotPath.includes("token"), "path suffix must be consumed: " + dotPath);
846
+ assert.ok(dotPath.includes("<path>"), "must leave <path> placeholder: " + dotPath);
847
+ // Paths containing special chars like @ in component names.
848
+ const atPath = sanitizeErrorMessage("cannot stat /tmp/foo@bar/secret.txt");
849
+ assert.ok(!atPath.includes("/tmp/foo@bar"), "@-path must be stripped: " + atPath);
850
+ assert.ok(!atPath.includes("secret.txt"), "path suffix must be consumed: " + atPath);
851
+ assert.ok(atPath.includes("<path>"), "must leave <path> placeholder: " + atPath);
852
+ });
@@ -47,8 +47,22 @@ export interface ExecutorMemory {
47
47
  category: string;
48
48
  /** True when the memory has frontmatter provenance (#1575 sourceQuote). */
49
49
  sourceQuote?: string;
50
- /** Structured-attribute supersession key, when one exists. */
50
+ /** Structured-attribute supersession key, when one exists (legacy single-key). */
51
51
  supersessionKey?: string;
52
+ /**
53
+ * ALL derived supersession keys for the memory's structuredAttributes
54
+ * (#1672 item 4). A paraphrased re-observation may place the tombstoned
55
+ * attribute at a different position in structuredAttributes; without an
56
+ * entry for every matched key the keyed tier misses and the fact
57
+ * resurrects. Mirrors temporal-supersession's per-key emission.
58
+ */
59
+ supersessionKeys?: string[];
60
+ /**
61
+ * Canonical contentHash from the retired memory's frontmatter (#1672 item
62
+ * 4 / #1579). Carried into the tombstone so the exact tier matches
63
+ * re-extraction rather than recomputing from a citation-annotated body.
64
+ */
65
+ contentHash?: string;
52
66
  entityRef?: string;
53
67
  /** Raw content used for the tombstone hash (rule 23). */
54
68
  rawContent: string;
@@ -117,6 +131,15 @@ export interface ExecutorDeps {
117
131
  rawContent: string;
118
132
  entityRef?: string;
119
133
  supersessionKey?: string;
134
+ /**
135
+ * ALL derived supersession keys (#1672 item 4). When present, the
136
+ * implementation emits one tombstone per key so a paraphrased
137
+ * re-observation placing the attribute at a different key position is
138
+ * still blocked at the keyed tier.
139
+ */
140
+ supersessionKeys?: string[];
141
+ /** Canonical contentHash from the retired memory's frontmatter (#1672 item 4). */
142
+ contentHash?: string;
120
143
  },
121
144
  ): Promise<string | null>;
122
145
  /**
@@ -342,8 +365,12 @@ export class CorrectionExecutor {
342
365
  continue;
343
366
  }
344
367
  const destId = await this.deps.rescopeMemory(namespace, action.memoryId, action.toNamespace);
345
- results.push({ action, status: "applied", memoryId: action.memoryId });
346
- appliedTouched.push(action.memoryId);
368
+ // #1678 (thread OiiV6): report the DESTINATION memory id, not the
369
+ // source. The source is archived by the move; callers that re-fetch
370
+ // the reported id need the live (destination) memory, otherwise they
371
+ // observe an archived fact and the correction looks like a no-op.
372
+ results.push({ action, status: "applied", memoryId: destId });
373
+ appliedTouched.push(action.memoryId); // source id for source-side propagation
347
374
  // Propagate the destination memory in its namespace too (review
348
375
  // thread: propagate-rescoped-destination) — best-effort.
349
376
  try {
@@ -448,6 +475,15 @@ export class CorrectionExecutor {
448
475
  rawContent: memory.rawContent,
449
476
  ...(memory.entityRef ? { entityRef: memory.entityRef } : {}),
450
477
  ...(memory.supersessionKey ? { supersessionKey: memory.supersessionKey } : {}),
478
+ // #1672 item 4: carry the canonical contentHash (exact tier) and the
479
+ // full supersession-key set (keyed tier) so paraphrased re-observations
480
+ // of the same supersession identity are blocked, not just the literal
481
+ // body. Without these the tombstone projected from a structured fact
482
+ // dropped both fields and the fact could resurrect.
483
+ ...(memory.contentHash ? { contentHash: memory.contentHash } : {}),
484
+ ...(memory.supersessionKeys && memory.supersessionKeys.length > 0
485
+ ? { supersessionKeys: memory.supersessionKeys }
486
+ : {}),
451
487
  });
452
488
  await this.deps.retireMemory(namespace, memoryId, {
453
489
  status: reason === "supersession" ? "superseded" : "retracted",
@@ -467,7 +503,31 @@ export class CorrectionExecutor {
467
503
  }
468
504
  }
469
505
 
506
+ /**
507
+ * Sanitize an error message before it lands in a CorrectionOutcome result
508
+ * (#1678). Outcome results are surfaced to MCP/HTTP/CLI callers and may be
509
+ * persisted in the audit memory, so they must not leak absolute filesystem
510
+ * paths (host layout / usernames / secrets in paths) or unbounded verbosity.
511
+ * Strip absolute paths and cap length; never reveal more than the message's
512
+ * short-form. The original error is still thrown/logged in full internally.
513
+ */
514
+ export const CORRECTION_ERROR_MAX = 500;
515
+ export function sanitizeErrorMessage(raw: string): string {
516
+ // Collapse any absolute POSIX path (/Users/foo/..., /.config/..., /tmp/a@b/...) and
517
+ // Windows path (C:\Users\...) to a neutral <path> placeholder.
518
+ // Capture the leading delimiter (start/space/quote) in group 1 so the path
519
+ // itself is consumed and replaced — the prior non-capturing prefix bound $1
520
+ // to the path, which survived and had <path> appended AFTER it.
521
+ const stripped = raw
522
+ .replace(/(^|[\s:'"(])\/(?:[^\s'">)\\]+\/)+[^\s'">)\\]*/g, "$1<path>")
523
+ .replace(/(^|[\s:'"(])[A-Za-z]:\\[^\s'">)\\]+(?:\\[^\s'">) ]*)*/g, "$1<path>");
524
+ const trimmed = stripped.trim();
525
+ return trimmed.length > CORRECTION_ERROR_MAX
526
+ ? `${trimmed.slice(0, CORRECTION_ERROR_MAX)}…`
527
+ : trimmed;
528
+ }
529
+
470
530
  function errMsg(err: unknown): string {
471
- if (err instanceof Error) return err.message;
472
- return String(err);
531
+ const raw = err instanceof Error ? err.message : String(err);
532
+ return sanitizeErrorMessage(raw);
473
533
  }
@@ -444,3 +444,50 @@ test("OgIql: untargeted search uses tokenized keyword matching (not exact-prefix
444
444
  assert.equal(classifyCandidates[0].memoryId, "mem-db");
445
445
  });
446
446
  });
447
+
448
+ test("#1678: never_store plan redacts the request text in the persisted pending-plan file", async () => {
449
+ await withTempDir(async (dir) => {
450
+ const secret = "my API key is sk-secret-DO-NOT-STORE-12345";
451
+ const state: StubState = {
452
+ candidatesById: new Map([["mem-s", makeCandidate({ memoryId: "mem-s", content: secret })]]),
453
+ llmResult: {
454
+ classification: "never_store",
455
+ confidence: 0.95,
456
+ actions: [{ kind: "redaction_rule", pattern: "sk-secret-\\d+" }],
457
+ relevance: [],
458
+ warnings: [],
459
+ },
460
+ writeReplacementCalls: 0,
461
+ propagateCalls: 0,
462
+ retireCalls: 0,
463
+ };
464
+ const planner = new CorrectionPlanner(makeDeps(dir, state));
465
+ // The in-memory plan keeps the original text (the executor's audit body
466
+ // re-applies its own redaction).
467
+ const plan = await planner.plan({ text: secret, targetIds: ["mem-s"] }, ["default"]);
468
+ assert.equal(plan.request.text, secret, "in-memory plan keeps the original request text");
469
+ assert.equal(plan.classification, "never_store");
470
+
471
+ // The persisted file MUST NOT contain the secret.
472
+ const filePath = path.join(dir, "state", "corrections", "pending", `${plan.planId}.json`);
473
+ const raw = await readFile(filePath, "utf-8");
474
+ assert.ok(!raw.includes("sk-secret-DO-NOT-STORE"),
475
+ "the persisted pending-plan file must NOT contain the never-store secret (request.text is redacted)");
476
+ assert.ok(raw.includes("redacted"),
477
+ "the persisted request text must be the redaction placeholder");
478
+ // The redaction_rule.pattern is NOT redacted on disk: the executor's apply
479
+ // flow reloads via loadPlan and needs the real pattern to call
480
+ // registerRedactionRule. Redacting it would register a placeholder (#vZln).
481
+ // The pattern's transient exposure is bounded by the pending-plan TTL +
482
+ // consumed-on-apply lifecycle.
483
+ assert.ok(raw.includes("sk-secret"),
484
+ "the redaction_rule.pattern survives on disk for the apply flow (bounded by pending-plan TTL)");
485
+
486
+ // loadPlan reads from disk → the reloaded plan has the redacted text.
487
+ const reloaded = await planner.loadPlan("default", plan.planId);
488
+ assert.ok(reloaded, "plan round-trips");
489
+ assert.notEqual(reloaded!.request.text, secret,
490
+ "loaded plan must carry the redacted text, not the original secret");
491
+ assert.ok(reloaded!.request.text.includes("redacted"));
492
+ });
493
+ });
@@ -129,6 +129,13 @@ export interface LlmClassificationResult {
129
129
  fallback?: boolean;
130
130
  }
131
131
 
132
+ /**
133
+ * Placeholder substituted for the request text when persisting a never-store /
134
+ * redaction-rule plan (#1678 thread Oid8t). Mirrors the durable-audit
135
+ * redaction so the transient pending-plan file never holds the secret.
136
+ */
137
+ const REDACTED_REQUEST_TEXT = "[redacted — never-store/redaction correction text withheld from the pending-plan file]";
138
+
132
139
  // ---------------------------------------------------------------------------
133
140
  // Planner
134
141
  // ---------------------------------------------------------------------------
@@ -382,10 +389,31 @@ export class CorrectionPlanner {
382
389
  private async persist(plan: CorrectionPlan): Promise<CorrectionPlan> {
383
390
  const dir = await this.pendingDir(plan.namespace);
384
391
  const target = path.join(dir, `${plan.planId}.json`);
392
+ // #1678 (thread Oid8t): never-store/redaction corrections carry the very
393
+ // secret/pattern the user asked Remnic NOT to retain. The durable audit
394
+ // already withholds the text; the TRANSIENT pending-plan file must too,
395
+ // or the secret sits on disk (pending until apply/discard + TTL). Redact
396
+ // the request text in the persisted copy for never_store classifications
397
+ // or any redaction_rule action. The in-memory plan keeps the original
398
+ // text so the executor's audit body (which re-applies its own redaction)
399
+ // is unaffected; only the on-disk JSON is sanitized.
400
+ const sensitive =
401
+ plan.classification === "never_store" ||
402
+ plan.actions.some((a) => a.kind === "redaction_rule");
403
+ // #1678 (threads vMLN/vZln): redact request.text (the executor does not
404
+ // need the raw request text for redaction_rule actions). The pattern is
405
+ // NOT redacted: the executor's apply flow reloads via loadPlan (disk) and
406
+ // needs the real pattern to call registerRedactionRule. Redacting it would
407
+ // register a placeholder and the extraction redaction (#1669) would never
408
+ // block the intended content. The pattern's transient on-disk exposure is
409
+ // bounded by the pending-plan TTL + consumed-on-apply lifecycle.
410
+ const persistedPlan: CorrectionPlan = sensitive
411
+ ? { ...plan, request: { ...plan.request, text: REDACTED_REQUEST_TEXT } }
412
+ : plan;
385
413
  await serializeMutations(`correction-plan:${target}`, async () => {
386
414
  await mkdir(dir, { recursive: true });
387
415
  const tmp = `${target}.${process.pid}.${Date.now().toString(36)}.tmp`;
388
- await writeFile(tmp, `${JSON.stringify(plan)}\n`, "utf-8");
416
+ await writeFile(tmp, `${JSON.stringify(persistedPlan)}\n`, "utf-8");
389
417
  // rename() is atomic on POSIX for same-filesystem renames (rule 54).
390
418
  await rename(tmp, target);
391
419
  });
@@ -450,6 +478,17 @@ export class CorrectionPlanner {
450
478
  const plan = parsePlan(raw);
451
479
  if (!plan) return;
452
480
  plan.status = status;
481
+ // #1669 thread P1: scrub redaction_rule patterns from consumed plans so
482
+ // a never-store pattern does not persist on disk after apply/discard.
483
+ // The executor reloads via loadPlan only between "applying" (step 1) and
484
+ // "applied" (step 4), so scrubbing at the terminal status is safe.
485
+ if (status !== "applying") {
486
+ plan.actions = plan.actions.map((a) =>
487
+ a.kind === "redaction_rule"
488
+ ? { ...a, pattern: "[redacted — pattern applied]" }
489
+ : a,
490
+ );
491
+ }
453
492
  const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.tmp`;
454
493
  await writeFile(tmp, `${JSON.stringify(plan)}\n`, "utf-8");
455
494
  await rename(tmp, file);
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Correction storage-integration regression tests (#1672).
3
+ *
4
+ * Proves the three wiring-level fixes that prevent fact resurrection and
5
+ * stale-candidate planning:
6
+ * - item 2: isEligibleCorrectionCandidate excludes archived memories.
7
+ * - item 3: applyEditMemory recomputes contentHash from the patched body.
8
+ * - item 4: appendTombstoneFn emits one tombstone per derived supersession key.
9
+ */
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtemp, rm } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import path from "node:path";
15
+
16
+ import { StorageManager, ContentHashIndex } from "../storage.js";
17
+ import { sanitizeMemoryContent } from "../sanitize.js";
18
+ import {
19
+ isEligibleCorrectionCandidate,
20
+ applyEditMemory,
21
+ appendTombstoneFn,
22
+ } from "./correction-access-wiring.js";
23
+ import type { MemoryFile } from "../types.js";
24
+
25
+ async function makeStorage(prefix = "remnic-corr-wiring-") {
26
+ StorageManager.clearAllStaticCaches();
27
+ const baseDir = await mkdtemp(path.join(tmpdir(), prefix));
28
+ const storage = new StorageManager(baseDir);
29
+ await storage.ensureDirectories();
30
+ storage.invalidateAllMemoriesCacheForDir();
31
+ storage.setTombstonesConfig({
32
+ enabled: true,
33
+ semanticMatch: false,
34
+ semanticThreshold: 0.9,
35
+ namespace: "default",
36
+ });
37
+ return { storage, baseDir, cleanup: async () => { StorageManager.clearAllStaticCaches(); await rm(baseDir, { recursive: true, force: true }); } };
38
+ }
39
+
40
+ function fakeMemoryFile(over: Partial<MemoryFile["frontmatter"]> & { id: string }): MemoryFile {
41
+ const { id: _id, ...rest } = over;
42
+ return {
43
+ path: "facts/x.md",
44
+ content: over.id,
45
+ frontmatter: {
46
+ id: over.id,
47
+ category: "fact",
48
+ created: "2026-01-01T00:00:00.000Z",
49
+ updated: "2026-01-01T00:00:00.000Z",
50
+ source: "test",
51
+ confidence: 0.9,
52
+ ...rest,
53
+ } as MemoryFile["frontmatter"],
54
+ } as MemoryFile;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // item 2: lifecycle-aware candidate filter
59
+ // ---------------------------------------------------------------------------
60
+
61
+ test("#1672 item 2: isEligibleCorrectionCandidate accepts an active, non-archived memory", () => {
62
+ const m = fakeMemoryFile({ id: "m1", status: "active" });
63
+ assert.equal(isEligibleCorrectionCandidate(m), true);
64
+ });
65
+
66
+ test("#1672 item 2: isEligibleCorrectionCandidate rejects an archived memory even when status is active", () => {
67
+ const m = fakeMemoryFile({ id: "m2", status: "active", archivedAt: "2026-07-01T00:00:00.000Z" });
68
+ assert.equal(isEligibleCorrectionCandidate(m), false);
69
+ });
70
+
71
+ test("#1672 item 2: isEligibleCorrectionCandidate rejects a non-active status", () => {
72
+ const m = fakeMemoryFile({ id: "m3", status: "superseded" });
73
+ assert.equal(isEligibleCorrectionCandidate(m), false);
74
+ });
75
+
76
+ test("#1672 item 2: isEligibleCorrectionCandidate rejects archived memory with no explicit status", () => {
77
+ const m = fakeMemoryFile({ id: "m4", archivedAt: "2026-07-01T00:00:00.000Z" });
78
+ assert.equal(isEligibleCorrectionCandidate(m), false);
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // item 3: contentHash recomputed on edit
83
+ // ---------------------------------------------------------------------------
84
+
85
+ test("#1672 item 3: applyEditMemory recomputes contentHash from the patched body", async () => {
86
+ const { storage, cleanup } = await makeStorage();
87
+ try {
88
+ const wiring = { orchestrator: { getStorage: async () => storage } } as any;
89
+ const id = await storage.writeMemory("fact", "the database is MySQL", { source: "test" });
90
+ const before = await storage.getMemoryById(id);
91
+ assert.ok(before?.frontmatter.contentHash, "writeMemory must set an initial contentHash");
92
+ const oldHash = before!.frontmatter.contentHash!;
93
+
94
+ await applyEditMemory(wiring, "default", id, "the database is PostgreSQL");
95
+
96
+ const after = await storage.getMemoryById(id);
97
+ assert.ok(after, "edited memory must still exist");
98
+ const expectedHash = ContentHashIndex.computeHash(sanitizeMemoryContent("the database is PostgreSQL").text);
99
+ assert.equal(after!.frontmatter.contentHash, expectedHash,
100
+ "contentHash must match the PATCHED body (recomputed on edit)");
101
+ assert.notEqual(after!.frontmatter.contentHash, oldHash,
102
+ "contentHash must differ from the pre-edit hash");
103
+ assert.equal(after!.content, "the database is PostgreSQL");
104
+ } finally {
105
+ await cleanup();
106
+ }
107
+ });
108
+
109
+ test("#1672 item 3: applyEditMemory leaves non-fact contentHash untouched (only facts are hashed)", async () => {
110
+ const { storage, cleanup } = await makeStorage();
111
+ try {
112
+ const wiring = { orchestrator: { getStorage: async () => storage } } as any;
113
+ const id = await storage.writeMemory("preference", "likes dark mode", { source: "test" });
114
+ await applyEditMemory(wiring, "default", id, "likes light mode");
115
+ const after = await storage.getMemoryById(id);
116
+ assert.equal(after!.content, "likes light mode");
117
+ // Non-fact categories are not content-hash indexed, so no hash is forced.
118
+ assert.ok(!after!.frontmatter.contentHash, "non-fact edit must not synthesize a contentHash");
119
+ } finally {
120
+ await cleanup();
121
+ }
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // item 4: per-key tombstone emission
126
+ // ---------------------------------------------------------------------------
127
+
128
+ test("#1672 item 4: appendTombstoneFn emits one tombstone per supersession key, each carrying contentHash", async () => {
129
+ const { storage, cleanup } = await makeStorage();
130
+ try {
131
+ const wiring = { orchestrator: { getStorage: async () => storage } } as any;
132
+ const keys = ["entity-deploy::day", "entity-deploy::week"];
133
+ const firstId = await appendTombstoneFn(wiring, "default", {
134
+ reason: "supersession",
135
+ sourceMemoryId: "fact-structured",
136
+ rawContent: "deploys happen on Tuesdays",
137
+ supersessionKeys: keys,
138
+ contentHash: "canonical-hash-abc",
139
+ });
140
+ assert.ok(firstId, "appendTombstoneFn must return the first tombstone id");
141
+
142
+ // Read the tombstone store directly to count entries.
143
+ const store = (storage as any).tombstoneStore ?? await (storage as any).getTombstoneStore();
144
+ const all = store.snapshot?.() ?? store.all?.() ?? [];
145
+ const matching = Array.isArray(all)
146
+ ? all.filter((t: any) => t?.sourceMemoryId === "fact-structured")
147
+ : [];
148
+ assert.equal(matching.length, keys.length,
149
+ `emitted ${matching.length} tombstones, expected ${keys.length} (one per supersession key)`);
150
+ for (const t of matching) {
151
+ assert.equal(t.contentHash, "canonical-hash-abc",
152
+ "every per-key tombstone must carry the canonical contentHash (exact tier)");
153
+ }
154
+ const tombKeys = matching.map((t: any) => t.supersessionKey).sort();
155
+ assert.deepEqual(tombKeys, [...keys].sort(),
156
+ "the emitted supersession keys must cover every derived key");
157
+ } finally {
158
+ await cleanup();
159
+ }
160
+ });
161
+
162
+ test("#1672 item 4: appendTombstoneFn falls back to a single tombstone when no keys are derived", async () => {
163
+ const { storage, cleanup } = await makeStorage();
164
+ try {
165
+ const wiring = { orchestrator: { getStorage: async () => storage } } as any;
166
+ const firstId = await appendTombstoneFn(wiring, "default", {
167
+ reason: "retraction",
168
+ sourceMemoryId: "fact-plain",
169
+ rawContent: "a plain unstructured fact",
170
+ contentHash: "plain-hash",
171
+ });
172
+ assert.ok(firstId, "a content-only tombstone must still be emitted");
173
+ const store = (storage as any).tombstoneStore ?? await (storage as any).getTombstoneStore();
174
+ const all = store.snapshot?.() ?? store.all?.() ?? [];
175
+ const matching = Array.isArray(all)
176
+ ? all.filter((t: any) => t?.sourceMemoryId === "fact-plain")
177
+ : [];
178
+ assert.equal(matching.length, 1, "exactly one tombstone when no supersession keys");
179
+ assert.equal(matching[0].contentHash, "plain-hash");
180
+ } finally {
181
+ await cleanup();
182
+ }
183
+ });