@remnic/core 9.35.1 → 9.35.3

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 (224) 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 +43 -42
  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 +5 -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-2NUC473W.js → chunk-AKELMJO2.js} +220 -48
  57. package/dist/{chunk-2NUC473W.js.map → chunk-AKELMJO2.js.map} +1 -1
  58. package/dist/{chunk-FBUE6PD3.js → chunk-BEQOZOYZ.js} +21 -4
  59. package/dist/chunk-BEQOZOYZ.js.map +1 -0
  60. package/dist/{chunk-SA3WJJF2.js → chunk-BGLDKAYY.js} +2 -2
  61. package/dist/{chunk-OPRP6Q4Z.js → chunk-DKKZ6SO2.js} +3 -3
  62. package/dist/{chunk-6TAUHL33.js → chunk-DLVPUX7U.js} +10 -2
  63. package/dist/chunk-DLVPUX7U.js.map +1 -0
  64. package/dist/{chunk-QNWXCJ76.js → chunk-ENTCVTQR.js} +18 -1
  65. package/dist/{chunk-QNWXCJ76.js.map → chunk-ENTCVTQR.js.map} +1 -1
  66. package/dist/{chunk-AEPYA55F.js → chunk-FE7MUX7M.js} +2 -2
  67. package/dist/{chunk-NQUF7XKR.js → chunk-FT4RF53O.js} +2 -2
  68. package/dist/{chunk-GHR7WHZX.js → chunk-GORHSN5C.js} +2 -2
  69. package/dist/{chunk-7TEZUUGM.js → chunk-HUS2XN3T.js} +2 -2
  70. package/dist/{chunk-XWDZQIYU.js → chunk-IJAQCBGV.js} +2 -2
  71. package/dist/{chunk-TJLLYV2X.js → chunk-IMEXE4XS.js} +7 -7
  72. package/dist/{chunk-ZCPP5OW6.js → chunk-JQSBRLJ6.js} +31 -27
  73. package/dist/chunk-JQSBRLJ6.js.map +1 -0
  74. package/dist/{chunk-IZXTZ7BF.js → chunk-JYLN6KHH.js} +6 -6
  75. package/dist/{chunk-HEVWWPTL.js → chunk-LH3KXIBJ.js} +17 -17
  76. package/dist/{chunk-EH5U2YCX.js → chunk-M3DBB2ND.js} +2 -2
  77. package/dist/{chunk-XABS455Y.js → chunk-NBAEGFXT.js} +5 -5
  78. package/dist/{chunk-DP7ZV2II.js → chunk-OA6PIPDE.js} +2 -2
  79. package/dist/{chunk-T4HH5Q3U.js → chunk-OJRLTZQM.js} +3 -3
  80. package/dist/{chunk-7ZHGPX56.js → chunk-PRP3M5Y3.js} +2 -2
  81. package/dist/{chunk-Y2ZD2RVG.js → chunk-PYTUBWAS.js} +2 -2
  82. package/dist/{chunk-4TGZCYHH.js → chunk-Q3CVY7LV.js} +1954 -1642
  83. package/dist/chunk-Q3CVY7LV.js.map +1 -0
  84. package/dist/{chunk-PH3ZWL3W.js → chunk-Q53RLEW7.js} +2 -2
  85. package/dist/{chunk-Y6OWJ3V5.js → chunk-RY2XZTFN.js} +2 -2
  86. package/dist/{chunk-2SHIWGY7.js → chunk-RY7CHVGE.js} +2 -2
  87. package/dist/{chunk-7VUAG236.js → chunk-T3FVAHM7.js} +4 -4
  88. package/dist/{chunk-2LTDWSR6.js → chunk-TSH56VPU.js} +2 -2
  89. package/dist/{chunk-J6SLNQRO.js → chunk-UBWOCQ3I.js} +6 -2
  90. package/dist/chunk-UBWOCQ3I.js.map +1 -0
  91. package/dist/{chunk-5FHE4JI7.js → chunk-VEUVON5I.js} +2 -2
  92. package/dist/{chunk-KEWUNL7P.js → chunk-WET4M3R6.js} +2 -2
  93. package/dist/chunk-WKDFJXW5.js +158 -0
  94. package/dist/chunk-WKDFJXW5.js.map +1 -0
  95. package/dist/{chunk-SLPLPGJQ.js → chunk-WL2S2UBB.js} +5 -5
  96. package/dist/{chunk-AHZYNPLX.js → chunk-YVMVVE4B.js} +2 -2
  97. package/dist/{chunk-5HTAJCTO.js → chunk-ZB3YODID.js} +15 -15
  98. package/dist/{cli-mFX-QQvx.d.ts → cli-jkmzdT8P.d.ts} +3 -3
  99. package/dist/cli.d.ts +6 -6
  100. package/dist/cli.js +34 -33
  101. package/dist/compounding/engine.d.ts +1 -1
  102. package/dist/compounding/engine.js +10 -9
  103. package/dist/connectors/codex-materialize-runner.js +10 -9
  104. package/dist/connectors/index.js +10 -9
  105. package/dist/consolidation-provenance-check.d.ts +1 -1
  106. package/dist/consolidation-provenance-check.js +8 -2
  107. package/dist/consolidation-undo.d.ts +1 -1
  108. package/dist/consolidation-undo.js +10 -1
  109. package/dist/consolidation-undo.js.map +1 -1
  110. package/dist/contradiction/index.d.ts +1 -1
  111. package/dist/corpus-watermark.js +12 -11
  112. package/dist/entity-retrieval.d.ts +1 -1
  113. package/dist/entity-retrieval.js +10 -9
  114. package/dist/explicit-capture.d.ts +4 -4
  115. package/dist/explicit-capture.js +6 -3
  116. package/dist/extraction.js +13 -12
  117. package/dist/{forget-4UY2EOKH.js → forget-DNAJ66ZD.js} +2 -2
  118. package/dist/index.d.ts +398 -398
  119. package/dist/index.js +43 -42
  120. package/dist/local-llm-helpers.d.ts +63 -1
  121. package/dist/local-llm-helpers.js +10 -1
  122. package/dist/local-llm.d.ts +4 -1
  123. package/dist/local-llm.js +2 -2
  124. package/dist/maintenance/memory-governance.js +10 -9
  125. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  126. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -9
  127. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  128. package/dist/maintenance/rebuild-memory-projection.js +11 -10
  129. package/dist/{maintenance-70Wq118b.d.ts → maintenance-Ha9Dg93M.d.ts} +1 -1
  130. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  131. package/dist/memory-worth-outcomes.d.ts +1 -1
  132. package/dist/namespaces/migrate.d.ts +1 -1
  133. package/dist/namespaces/migrate.js +11 -10
  134. package/dist/namespaces/storage.d.ts +1 -1
  135. package/dist/namespaces/storage.js +10 -9
  136. package/dist/offline-sync-impression-drain.js +3 -3
  137. package/dist/operator-doctor-corpus.js +13 -12
  138. package/dist/operator-toolkit.d.ts +1 -1
  139. package/dist/operator-toolkit.js +17 -16
  140. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  141. package/dist/orchestration/maintenance.d.ts +2 -2
  142. package/dist/orchestration/maintenance.js +15 -14
  143. package/dist/{orchestrator-BH8LT-NX.d.ts → orchestrator-DV_u9bfx.d.ts} +3 -3
  144. package/dist/orchestrator.d.ts +4 -4
  145. package/dist/orchestrator.js +43 -42
  146. package/dist/page-versioning.js +7 -1
  147. package/dist/schemas.d.ts +76 -76
  148. package/dist/semantic-consolidation.js +11 -10
  149. package/dist/semantic-rule-promotion.js +10 -9
  150. package/dist/semantic-rule-verifier.js +10 -9
  151. package/dist/{service-CrxXHz5T.d.ts → service-JxXXY-Eh.d.ts} +1 -1
  152. package/dist/shared-context/manager.d.ts +8 -8
  153. package/dist/source-agent-qualifier.js +10 -9
  154. package/dist/{storage-CyWa1Jm6.d.ts → storage-BbH4HDUS.d.ts} +30 -26
  155. package/dist/storage.d.ts +1 -1
  156. package/dist/storage.js +9 -8
  157. package/dist/summarizer.js +3 -3
  158. package/dist/temporal-supersession.d.ts +1 -1
  159. package/dist/tier-migration.d.ts +1 -1
  160. package/dist/transfer/capsule-import.js +8 -2
  161. package/dist/transfer/types.d.ts +66 -66
  162. package/dist/verified-recall.js +10 -9
  163. package/package.json +2 -2
  164. package/src/binary-lifecycle/pipeline.ts +52 -1
  165. package/src/consolidation-undo.ts +4 -0
  166. package/src/curation/index.ts +41 -5
  167. package/src/lifecycle/tombstones.ts +40 -0
  168. package/src/local-llm-helpers.ts +171 -0
  169. package/src/local-llm.ts +34 -30
  170. package/src/maintenance/memory-governance.ts +4 -0
  171. package/src/page-versioning.ts +16 -1
  172. package/src/review/index.ts +97 -13
  173. package/src/spaces/index.ts +127 -6
  174. package/src/storage/citation-hash-source.ts +97 -0
  175. package/src/storage/entity-canonical-id-lock.ts +33 -0
  176. package/src/storage/entity-canonical-id-migration-adapter.ts +8 -4
  177. package/src/storage/entity-canonical-id-migration.ts +129 -77
  178. package/src/storage/entity-canonical-id-references.ts +557 -0
  179. package/src/storage/entity-ref-repair.ts +171 -0
  180. package/src/storage/entity-store.ts +163 -2
  181. package/src/storage/tombstone-blocked-capture-sync.ts +18 -0
  182. package/src/storage.ts +363 -396
  183. package/src/transfer/capsule-import.ts +91 -3
  184. package/src/transfer/capsule-merge.ts +128 -10
  185. package/dist/capsule-merge-2H2IUTBT.js.map +0 -1
  186. package/dist/chunk-4RNZMRAV.js +0 -60
  187. package/dist/chunk-4RNZMRAV.js.map +0 -1
  188. package/dist/chunk-4TGZCYHH.js.map +0 -1
  189. package/dist/chunk-65ZPVYTA.js.map +0 -1
  190. package/dist/chunk-6TAUHL33.js.map +0 -1
  191. package/dist/chunk-FBUE6PD3.js.map +0 -1
  192. package/dist/chunk-J6SLNQRO.js.map +0 -1
  193. package/dist/chunk-ZCPP5OW6.js.map +0 -1
  194. /package/dist/{chunk-EO7X2LDK.js.map → chunk-4TAD3B56.js.map} +0 -0
  195. /package/dist/{chunk-QHYB4MJV.js.map → chunk-5ZGHUWTT.js.map} +0 -0
  196. /package/dist/{chunk-TCIWHK3N.js.map → chunk-6D5D5A4M.js.map} +0 -0
  197. /package/dist/{chunk-BIXWNHIL.js.map → chunk-6TF4M2LB.js.map} +0 -0
  198. /package/dist/{chunk-SA3WJJF2.js.map → chunk-BGLDKAYY.js.map} +0 -0
  199. /package/dist/{chunk-OPRP6Q4Z.js.map → chunk-DKKZ6SO2.js.map} +0 -0
  200. /package/dist/{chunk-AEPYA55F.js.map → chunk-FE7MUX7M.js.map} +0 -0
  201. /package/dist/{chunk-NQUF7XKR.js.map → chunk-FT4RF53O.js.map} +0 -0
  202. /package/dist/{chunk-GHR7WHZX.js.map → chunk-GORHSN5C.js.map} +0 -0
  203. /package/dist/{chunk-7TEZUUGM.js.map → chunk-HUS2XN3T.js.map} +0 -0
  204. /package/dist/{chunk-XWDZQIYU.js.map → chunk-IJAQCBGV.js.map} +0 -0
  205. /package/dist/{chunk-TJLLYV2X.js.map → chunk-IMEXE4XS.js.map} +0 -0
  206. /package/dist/{chunk-IZXTZ7BF.js.map → chunk-JYLN6KHH.js.map} +0 -0
  207. /package/dist/{chunk-HEVWWPTL.js.map → chunk-LH3KXIBJ.js.map} +0 -0
  208. /package/dist/{chunk-EH5U2YCX.js.map → chunk-M3DBB2ND.js.map} +0 -0
  209. /package/dist/{chunk-XABS455Y.js.map → chunk-NBAEGFXT.js.map} +0 -0
  210. /package/dist/{chunk-DP7ZV2II.js.map → chunk-OA6PIPDE.js.map} +0 -0
  211. /package/dist/{chunk-T4HH5Q3U.js.map → chunk-OJRLTZQM.js.map} +0 -0
  212. /package/dist/{chunk-7ZHGPX56.js.map → chunk-PRP3M5Y3.js.map} +0 -0
  213. /package/dist/{chunk-Y2ZD2RVG.js.map → chunk-PYTUBWAS.js.map} +0 -0
  214. /package/dist/{chunk-PH3ZWL3W.js.map → chunk-Q53RLEW7.js.map} +0 -0
  215. /package/dist/{chunk-Y6OWJ3V5.js.map → chunk-RY2XZTFN.js.map} +0 -0
  216. /package/dist/{chunk-2SHIWGY7.js.map → chunk-RY7CHVGE.js.map} +0 -0
  217. /package/dist/{chunk-7VUAG236.js.map → chunk-T3FVAHM7.js.map} +0 -0
  218. /package/dist/{chunk-2LTDWSR6.js.map → chunk-TSH56VPU.js.map} +0 -0
  219. /package/dist/{chunk-5FHE4JI7.js.map → chunk-VEUVON5I.js.map} +0 -0
  220. /package/dist/{chunk-KEWUNL7P.js.map → chunk-WET4M3R6.js.map} +0 -0
  221. /package/dist/{chunk-SLPLPGJQ.js.map → chunk-WL2S2UBB.js.map} +0 -0
  222. /package/dist/{chunk-AHZYNPLX.js.map → chunk-YVMVVE4B.js.map} +0 -0
  223. /package/dist/{chunk-5HTAJCTO.js.map → chunk-ZB3YODID.js.map} +0 -0
  224. /package/dist/{forget-4UY2EOKH.js.map → forget-DNAJ66ZD.js.map} +0 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * Write-boundary surface of the entity canonical-id migration (issue #2213).
3
+ *
4
+ * The migration itself (entity-canonical-id-migration.ts) renames legacy
5
+ * entity files and rewrites references ONCE, to convergence, then retires.
6
+ * Everything here exists so nothing can re-introduce a legacy reference
7
+ * afterwards without the migration having to rescan the corpus:
8
+ *
9
+ * - store-mediated writes canonicalize the caller-supplied `entityRef`
10
+ * ({@link canonicalizeEntityRefOption});
11
+ * - bulk writers that persist raw record bytes (capsule import/merge)
12
+ * canonicalize the frontmatter line ({@link canonicalizeEntityRefFrontmatter});
13
+ * - raw-byte writers that CANNOT parse what they write (offline sync,
14
+ * consolidation-undo, governance restores) request one bounded
15
+ * reconciliation pass instead ({@link requestEntityCanonicalIdReconcile}).
16
+ */
17
+ import path from "node:path";
18
+ import {
19
+ closeSync,
20
+ constants,
21
+ fstatSync,
22
+ lstatSync,
23
+ realpathSync,
24
+ openSync,
25
+ readFileSync,
26
+ renameSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { randomUUID } from "node:crypto";
31
+ import { log } from "../logger.js";
32
+ import { isErrnoCode } from "../utils/errno.js";
33
+ import { RECALL_FALLBACK_DIRS } from "../utils/category-dir.js";
34
+ import { withEntityCanonicalMutationLock } from "./entity-canonical-id-lock.js";
35
+ import { normalizeSupersessionKey } from "../temporal-supersession.js";
36
+
37
+ export const ENTITY_CANONICAL_ID_MIGRATION_FILE = "entity-canonical-id-migration-v1.json";
38
+
39
+ /**
40
+ * Parse a journal document into a mapping table. A document without a valid
41
+ * `mappings` object is a LEGITIMATELY empty table; an unreadable/unparsable
42
+ * document THROWS so callers can distinguish "empty" from "failed".
43
+ */
44
+ function parseJournalMappings(raw: string): Readonly<Record<string, string>> {
45
+ const parsed: unknown = JSON.parse(raw);
46
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !("mappings" in parsed)) return {};
47
+ const mappings = parsed.mappings;
48
+ if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) return {};
49
+ // Null prototype: journal-supplied ids like `constructor` must be own
50
+ // entries, never collide with Object.prototype (Codex P2, round 20).
51
+ const cleaned: Record<string, string> = Object.create(null) as Record<string, string>;
52
+ for (const [legacyId, canonicalId] of Object.entries(mappings)) {
53
+ if (legacyId.length > 0 && typeof canonicalId === "string" && canonicalId.length > 0) {
54
+ cleaned[legacyId] = canonicalId;
55
+ }
56
+ }
57
+ return cleaned;
58
+ }
59
+
60
+ type JournalRead =
61
+ | { kind: "ok"; raw: string; key: string }
62
+ | { kind: "missing" }
63
+ | { kind: "refused" }
64
+ | { kind: "error" };
65
+
66
+ /**
67
+ * Open the journal WITHOUT following symlinks and read content + identity
68
+ * from the same opened inode (repo rule: reject symlink traversal from
69
+ * memory directories). O_NOFOLLOW makes the open refuse a symlinked FINAL
70
+ * component (ELOOP); the parent `state/` directory is verified AFTER the
71
+ * open, together with a path↔fd identity pairing, so a parent swapped
72
+ * around the open cannot hold: a swap still in place fails the parent
73
+ * lstat, and a swap reverted fails the dev/ino pairing against the real
74
+ * journal. Platforms without O_NOFOLLOW get the same post-open checks.
75
+ */
76
+ function readJournalFileNoFollow(statePath: string): JournalRead {
77
+ let fd: number;
78
+ try {
79
+ fd = openSync(statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
80
+ } catch (error) {
81
+ if (isErrnoCode(error, "ENOENT")) return { kind: "missing" };
82
+ if (isErrnoCode(error, "ELOOP")) return { kind: "refused" };
83
+ return { kind: "error" };
84
+ }
85
+ try {
86
+ const s = fstatSync(fd);
87
+ if (!s.isFile()) return { kind: "refused" };
88
+ const parent = lstatSync(path.dirname(statePath));
89
+ if (parent.isSymbolicLink() || !parent.isDirectory()) return { kind: "refused" };
90
+ const l = lstatSync(statePath);
91
+ if (l.isSymbolicLink() || l.dev !== s.dev || l.ino !== s.ino) return { kind: "refused" };
92
+ return {
93
+ kind: "ok",
94
+ raw: readFileSync(fd, "utf-8"),
95
+ key: `${s.dev}:${s.ino}:${s.mtimeMs}:${s.ctimeMs}:${s.size}`,
96
+ };
97
+ } catch {
98
+ return { kind: "error" };
99
+ } finally {
100
+ closeSync(fd);
101
+ }
102
+ }
103
+
104
+ export function loadHistoricalEntityCanonicalIds(stateDir: string): Readonly<Record<string, string>> {
105
+ const result = readJournalFileNoFollow(path.join(stateDir, ENTITY_CANONICAL_ID_MIGRATION_FILE));
106
+ if (result.kind !== "ok") return {};
107
+ try {
108
+ return parseJournalMappings(result.raw);
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Append a tombstone with its identity canonicalized against the CURRENT
116
+ * journal, then recheck: a peer publishing a mapping inside the
117
+ * resolve-to-append window would leave the guard under the legacy id while
118
+ * lookups canonicalize. Each identity change appends the replacement FIRST
119
+ * (AGENTS.md §14 — the retired fact must never be without an active guard),
120
+ * then revokes the superseded one — a park makes the prior canonical
121
+ * claimant a distinct entity, and a stale keyed guard under it would
122
+ * suppress that entity's facts. When the journal will not settle, one final
123
+ * replacement runs under the entity MUTATION lock (parks publish under it).
124
+ */
125
+ export async function appendCanonicalizedTombstone(
126
+ stateDir: string,
127
+ input: { entityRef?: string; supersessionKey?: string },
128
+ currentIds: () => Readonly<Record<string, string>>,
129
+ append: (identity: TombstoneIdentity) => Promise<string | null>,
130
+ revoke: (tombstoneId: string) => Promise<unknown>,
131
+ label: string,
132
+ ): Promise<string | null> {
133
+ let refIds = currentIds();
134
+ let identity = canonicalizeTombstoneIdentity(input.entityRef, input.supersessionKey, refIds);
135
+ let result = await append(identity);
136
+ const replaceIfChanged = async (next: TombstoneIdentity): Promise<boolean> => {
137
+ if (next.entityRef === identity.entityRef && next.supersessionKey === identity.supersessionKey) {
138
+ return false;
139
+ }
140
+ identity = next;
141
+ const superseded = result;
142
+ result = await append(next);
143
+ if (superseded !== null) {
144
+ try {
145
+ await revoke(superseded);
146
+ } catch (err) {
147
+ log.warn(`failed to revoke superseded tombstone ${superseded} for ${label}: ${err}`);
148
+ }
149
+ }
150
+ return true;
151
+ };
152
+ for (let attempt = 0; attempt < 3; attempt += 1) {
153
+ const fresh = currentIds();
154
+ if (fresh === refIds) return result;
155
+ refIds = fresh;
156
+ if (!(await replaceIfChanged(canonicalizeTombstoneIdentity(input.entityRef, input.supersessionKey, fresh)))) {
157
+ return result;
158
+ }
159
+ }
160
+ await withEntityCanonicalMutationLock(stateDir, async () => {
161
+ await replaceIfChanged(canonicalizeTombstoneIdentity(input.entityRef, input.supersessionKey, currentIds()));
162
+ });
163
+ return result;
164
+ }
165
+
166
+ /**
167
+ * Journal mapping table keyed by the journal FILE's identity, so a long-lived
168
+ * StorageManager never canonicalizes against a stale snapshot after ANY other
169
+ * writer changes the journal — a peer process completing a migration, or
170
+ * `pruneBlocked()` parking a contested mapping (which rewrites the journal
171
+ * without bumping any shared version). `writeState` publishes via
172
+ * temp-file-plus-rename, so every journal write swaps the inode and the key
173
+ * always moves — the identity is taken from the fstat of the SAME opened
174
+ * inode the content is read from (no stat→read window). Reload cost is one
175
+ * open per lookup and one journal parse per actual change.
176
+ *
177
+ * Failure semantics: last-known data is served ONLY for the SAME state dir
178
+ * (module-level instances can face several stores — another store's table
179
+ * must never leak in), and a failed read/parse never commits the journal's
180
+ * identity, so the next lookup retries instead of caching an empty table
181
+ * under a valid key.
182
+ */
183
+ export class HistoricalEntityCanonicalIdCache {
184
+ private mappings: Readonly<Record<string, string>> = {};
185
+ private key: string | null = null;
186
+ private stateDir: string | null = null;
187
+
188
+ get(stateDir: string): Readonly<Record<string, string>> {
189
+ const lastKnown = this.stateDir === stateDir ? this.mappings : {};
190
+ const result = readJournalFileNoFollow(path.join(stateDir, ENTITY_CANONICAL_ID_MIGRATION_FILE));
191
+ if (result.kind === "refused") {
192
+ // Symlinked/non-regular journal: never follow it, never adopt its
193
+ // identity — serve this store's last-known table. If it later becomes
194
+ // a regular file its identity differs from the stored key and reloads.
195
+ log.warn("ignoring non-regular entity canonical-id journal (symlink refused)");
196
+ return lastKnown;
197
+ }
198
+ if (result.kind === "error") {
199
+ // A TRANSIENT open/read failure (EACCES/EIO) must not dump a valid
200
+ // table for {}: a write during the outage would skip canonicalization
201
+ // AND its post-write identity check would compare equal. Serve this
202
+ // store's last-known table; only a genuine ENOENT means "no journal".
203
+ return lastKnown;
204
+ }
205
+ const key = result.kind === "missing" ? "missing" : result.key;
206
+ if (key !== this.key || stateDir !== this.stateDir) {
207
+ let table: Readonly<Record<string, string>> = {};
208
+ if (result.kind === "ok") {
209
+ try {
210
+ table = parseJournalMappings(result.raw);
211
+ } catch {
212
+ // Parse failed under a VALID identity: do not commit the key —
213
+ // the next lookup retries instead of pinning an empty table.
214
+ return lastKnown;
215
+ }
216
+ }
217
+ this.mappings = table;
218
+ this.key = key;
219
+ this.stateDir = stateDir;
220
+ }
221
+ return this.mappings;
222
+ }
223
+ }
224
+
225
+ export function resolveHistoricalEntityCanonicalId(
226
+ normalized: string,
227
+ mappings: Readonly<Record<string, string>>,
228
+ ): string {
229
+ let current = normalized;
230
+ const seen = new Set<string>();
231
+ while (!seen.has(current)) {
232
+ seen.add(current);
233
+ // Own properties only: an unmapped id like `constructor` must not read
234
+ // Object.prototype and return a function (Codex P2, round 20).
235
+ const next = Object.hasOwn(mappings, current) ? mappings[current] : undefined;
236
+ if (!next || next === current) break;
237
+ current = next;
238
+ }
239
+ return current;
240
+ }
241
+
242
+ /**
243
+ * Canonicalize the `entityRef` a memory-write caller supplied (issue #2213).
244
+ *
245
+ * Extraction and capture callers pass whatever id the LLM or user produced,
246
+ * which can name a legacy id this migration already renamed. Resolving at the
247
+ * WRITE boundary means store-mediated writes can never re-introduce legacy
248
+ * references — which is what let the completed migration retire its recurring
249
+ * full-corpus reference rewrite. It also keeps write-time tombstone lookups on
250
+ * the same id space as migrated tombstones. Unknown ids pass through verbatim.
251
+ */
252
+ export function canonicalizeEntityRefOption<T extends { entityRef?: string }>(
253
+ options: T,
254
+ mappings: Readonly<Record<string, string>>,
255
+ ): T {
256
+ // Non-strings (absent, or a JS caller's null/junk) pass through untouched:
257
+ // this boundary canonicalizes ids, it does not take over input validation
258
+ // the write path never performed — serialization already drops falsy refs.
259
+ if (typeof options.entityRef !== "string") return options;
260
+ return { ...options, entityRef: resolveHistoricalEntityCanonicalId(options.entityRef, mappings) };
261
+ }
262
+
263
+ /**
264
+ * Canonicalize the effective `entityRef:` line inside a raw memory record's
265
+ * leading frontmatter block (issue #2213). Bulk writers that persist record
266
+ * bytes verbatim — capsule import/merge — are a write boundary too: a capsule
267
+ * can carry pre-migration memories whose refs the target's completed journal
268
+ * already renamed, and no later reconciliation pass exists to absorb them.
269
+ *
270
+ * Line selection mirrors the frontmatter parser and the migration's own
271
+ * serializer: the LAST `entityRef` key wins (indentation tolerated), its
272
+ * indent is preserved, and CRLF records keep their line endings. The closing
273
+ * delimiter must be a standalone `---` line. Non-frontmatter content, records
274
+ * without an `entityRef` line, and ids the journal does not map all pass
275
+ * through byte-identical.
276
+ */
277
+ export function canonicalizeEntityRefFrontmatter(
278
+ content: string,
279
+ mappings: Readonly<Record<string, string>>,
280
+ ): string {
281
+ if (Object.keys(mappings).length === 0) return content;
282
+ const loc = locateEntityRefLine(content);
283
+ if (loc === null) return content;
284
+ const canonical = resolveHistoricalEntityCanonicalId(loc.value, mappings);
285
+ if (canonical === loc.value) return content;
286
+ const line = loc.lines[loc.index]!;
287
+ const indent = /^\s*/.exec(line)?.[0] ?? "";
288
+ loc.lines[loc.index] = `${indent}entityRef: ${canonical}${line.endsWith("\r") ? "\r" : ""}`;
289
+ return loc.lines.join("\n") + content.slice(loc.closeIndex);
290
+ }
291
+
292
+ /** The raw `entityRef` value in a record's leading frontmatter, or null. */
293
+ export function readEntityRefFromFrontmatter(content: string): string | null {
294
+ return locateEntityRefLine(content)?.value ?? null;
295
+ }
296
+
297
+ function locateEntityRefLine(
298
+ content: string,
299
+ ): { lines: string[]; index: number; value: string; closeIndex: number } | null {
300
+ if (!/^---\r?\n/.test(content)) return null;
301
+ const close = /\r?\n---(?:\r?\n|$)/g;
302
+ close.lastIndex = content.indexOf("\n") + 1;
303
+ const closeMatch = close.exec(content);
304
+ if (!closeMatch) return null;
305
+ const lines = content.slice(0, closeMatch.index).split("\n");
306
+ let index = -1;
307
+ for (let i = 1; i < lines.length; i += 1) {
308
+ if (/^\s*entityRef\s*:/.test(lines[i] ?? "")) index = i;
309
+ }
310
+ if (index === -1) return null;
311
+ const line = lines[index]!;
312
+ const value = line.slice(line.indexOf(":") + 1).replace(/\r$/, "").trim();
313
+ if (value.length === 0) return null;
314
+ return { lines, index, value, closeIndex: closeMatch.index };
315
+ }
316
+
317
+ /**
318
+ * Reconcile-pending marker (issue #2213). Raw-byte memory writers that CANNOT
319
+ * canonicalize inline — offline-sync file replication (opaque, possibly
320
+ * encrypted buffers), consolidation-undo restores, governance-run restores —
321
+ * touch this marker instead. The next migration invocation honors it by
322
+ * running ONE bounded reference-reconciliation pass over the completed
323
+ * journal's retained mappings, then clears it. This replaces the retired
324
+ * every-run corpus rewrite with a signal that fires only when a raw writer
325
+ * actually landed unvetted bytes.
326
+ */
327
+ export const ENTITY_CANONICAL_ID_RECONCILE_MARKER = "entity-canonical-id-reconcile.pending";
328
+ /** The generation a migration run renamed aside for consumption; a crash can strand it. */
329
+ export const ENTITY_CANONICAL_ID_RECONCILE_CONSUMING_MARKER = `${ENTITY_CANONICAL_ID_RECONCILE_MARKER}.consuming`;
330
+
331
+ export function requestEntityCanonicalIdReconcileSync(stateDir: string): void {
332
+ const markerPath = path.join(stateDir, ENTITY_CANONICAL_ID_RECONCILE_MARKER);
333
+ const temporary = `${markerPath}.${process.pid}.${randomUUID()}.tmp`;
334
+ try {
335
+ // Never write THROUGH a symlinked marker OR a symlinked state dir (repo
336
+ // rule: reject symlink traversal from memory directories). The parent is
337
+ // lstat-verified as a real directory — direct restore/import callers can
338
+ // reach this helper without validateRoots() — and temp-file-plus-rename
339
+ // handles the final component: rename() replaces a planted link ITSELF
340
+ // rather than following it, with no O_NOFOLLOW dependency.
341
+ const parent = lstatSync(stateDir);
342
+ if (parent.isSymbolicLink() || !parent.isDirectory()) {
343
+ log.warn(`refusing to write entity canonical-id reconcile marker: ${stateDir} is not a real directory`);
344
+ return;
345
+ }
346
+ const parentRealPath = realpathSync(stateDir);
347
+ writeFileSync(temporary, `${new Date().toISOString()}\n`, { mode: 0o600 });
348
+ // Revalidate the parent between the temp write and publication (Codex
349
+ // P2, round 20): a state-dir swapped for a symlink after the check above
350
+ // would have landed the temp file through the link. Node has no openat,
351
+ // so the residual window is the realpath→rename gap — this recheck
352
+ // reduces check-then-use to the platform primitive's own atomicity.
353
+ const recheck = lstatSync(stateDir);
354
+ if (recheck.isSymbolicLink() || !recheck.isDirectory() || realpathSync(stateDir) !== parentRealPath) {
355
+ rmSync(temporary, { force: true });
356
+ log.warn(`refusing to publish entity canonical-id reconcile marker: ${stateDir} changed during write`);
357
+ return;
358
+ }
359
+ renameSync(temporary, markerPath);
360
+ } catch (error) {
361
+ rmSync(temporary, { force: true });
362
+ // Best effort by design: a marker failure must not fail the restore/sync
363
+ // that requested it (AGENTS.md §4). The reference stays legacy-but-readable
364
+ // until the next mapping change.
365
+ log.warn(`could not request entity canonical-id reconcile: ${error}`);
366
+ }
367
+ }
368
+
369
+ export async function requestEntityCanonicalIdReconcile(stateDir: string): Promise<void> {
370
+ requestEntityCanonicalIdReconcileSync(stateDir);
371
+ }
372
+
373
+ /**
374
+ * Post-persist TOCTOU guard (issue #2213). A writer resolves `entityRef`
375
+ * against one journal generation, then awaits (snapshots, locks, fsyncs)
376
+ * before its bytes land — a peer migration can publish AND finish its final
377
+ * reference scan inside that window, leaving the just-written file behind.
378
+ * Callers pass the mapping table captured at resolve time plus a fresh cache
379
+ * read taken AFTER the write: the cache returns the identical object while
380
+ * the journal file is unchanged, so an identity mismatch means the journal
381
+ * moved mid-write and one bounded reconcile pass is requested.
382
+ */
383
+ export function reconcileIfJournalMovedSync(
384
+ stateDir: string,
385
+ idsAtResolve: Readonly<Record<string, string>>,
386
+ idsAfterWrite: Readonly<Record<string, string>>,
387
+ ): void {
388
+ if (idsAtResolve === idsAfterWrite) return;
389
+ requestEntityCanonicalIdReconcileSync(stateDir);
390
+ }
391
+
392
+ export async function reconcileIfJournalMoved(
393
+ stateDir: string,
394
+ idsAtResolve: Readonly<Record<string, string>>,
395
+ idsAfterWrite: Readonly<Record<string, string>>,
396
+ ): Promise<void> {
397
+ reconcileIfJournalMovedSync(stateDir, idsAtResolve, idsAfterWrite);
398
+ }
399
+
400
+ /**
401
+ * Classify a raw write target for entity-reference handling (issue #2213):
402
+ * - `"memory"` — hot recall / cold / archive record whose FRONTMATTER
403
+ * `entityRef` the writer must canonicalize at the write (and repair after).
404
+ * - `"entity"` — an `entities/` page. Its migrated surface is relationship
405
+ * TARGETS in the body, which only `rewriteRelationshipTargets` understands;
406
+ * raw writers persist bytes faithfully and request the bounded reconcile
407
+ * pass instead of attempting a frontmatter rewrite.
408
+ * - `"outside"` — transcripts, profiles, runtime state, and every other file
409
+ * outside the migration's scan scope: write byte-identical, no marker.
410
+ */
411
+ export type EntityRefWritePathKind = "memory" | "entity" | "outside";
412
+
413
+ export function classifyEntityRefWritePath(baseDir: string, filePath: string): EntityRefWritePathKind {
414
+ if (!filePath.endsWith(".md")) return "outside";
415
+ const rel = path.relative(baseDir, filePath);
416
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return "outside";
417
+ const top = rel.split(path.sep)[0] ?? "";
418
+ if (top === "entities") return "entity";
419
+ return RECALL_FALLBACK_DIRS.includes(top) || top === "cold" || top === "archive" ? "memory" : "outside";
420
+ }
421
+
422
+ /** True when `filePath` is in the migration's scan scope at all. */
423
+ export function pathMayCarryEntityRefs(baseDir: string, filePath: string): boolean {
424
+ return classifyEntityRefWritePath(baseDir, filePath) !== "outside";
425
+ }
426
+
427
+ /**
428
+ * Canonicalize a tombstone's identity fields at the append chokepoint
429
+ * (issue #2213): emitters (forget, temporal supersession, pattern
430
+ * reinforcement, supersede) build inputs from records that may have been
431
+ * read before a peer migration completed. The supersession key embeds the
432
+ * NORMALIZED entity segment (`entity::attr`), so it is re-prefixed when the
433
+ * ref moved — otherwise the keyed guard lands in the legacy id space while
434
+ * write-time lookups canonicalize, and a paraphrase resurrects.
435
+ */
436
+ export interface TombstoneIdentity {
437
+ entityRef: string | undefined;
438
+ supersessionKey: string | undefined;
439
+ }
440
+
441
+ export function canonicalizeTombstoneIdentity(
442
+ entityRef: string | undefined,
443
+ supersessionKey: string | undefined,
444
+ refIds: Readonly<Record<string, string>>,
445
+ ): TombstoneIdentity {
446
+ if (typeof entityRef !== "string") return { entityRef, supersessionKey };
447
+ const canonical = resolveHistoricalEntityCanonicalId(entityRef, refIds);
448
+ if (canonical === entityRef) return { entityRef, supersessionKey };
449
+ if (supersessionKey) {
450
+ const stalePrefix = `${normalizeSupersessionKey(entityRef)}::`;
451
+ if (supersessionKey.startsWith(stalePrefix)) {
452
+ supersessionKey = `${normalizeSupersessionKey(canonical)}::${supersessionKey.slice(stalePrefix.length)}`;
453
+ }
454
+ }
455
+ return { entityRef: canonical, supersessionKey };
456
+ }
457
+
458
+ /**
459
+ * Post-persist repair (issue #2213): when the journal moved across a persist,
460
+ * re-resolve the caller's ORIGINAL ref against the fresh table and rewrite
461
+ * the file in place (bounded). When the journal will not settle, take the
462
+ * entity MUTATION lock — parks/removals (the direction the reconcile pass
463
+ * cannot repair, because the removed mapping is gone from the table) publish
464
+ * under it — settle once, then request the marker for additive moves only.
465
+ */
466
+ export async function repairEntityRefAfterJournalMove(options: {
467
+ stateDir: string;
468
+ currentIds: () => Readonly<Record<string, string>>;
469
+ idsAtResolve: Readonly<Record<string, string>>;
470
+ rawRef: string;
471
+ frontmatter: { entityRef?: string };
472
+ rewrite: () => Promise<void>;
473
+ }): Promise<void> {
474
+ let refIds = options.idsAtResolve;
475
+ for (let attempt = 0; attempt < 3; attempt += 1) {
476
+ const fresh = options.currentIds();
477
+ if (fresh === refIds) return;
478
+ refIds = fresh;
479
+ const desired = resolveHistoricalEntityCanonicalId(options.rawRef, fresh);
480
+ if (desired === options.frontmatter.entityRef) return;
481
+ options.frontmatter.entityRef = desired;
482
+ await options.rewrite();
483
+ }
484
+ await withEntityCanonicalMutationLock(options.stateDir, async () => {
485
+ const desired = resolveHistoricalEntityCanonicalId(options.rawRef, options.currentIds());
486
+ if (desired !== options.frontmatter.entityRef) {
487
+ options.frontmatter.entityRef = desired;
488
+ await options.rewrite();
489
+ }
490
+ });
491
+ await requestEntityCanonicalIdReconcile(options.stateDir);
492
+ }
493
+
494
+ /**
495
+ * Content-form counterpart of {@link repairEntityRefAfterJournalMove} for
496
+ * async writers that persist raw record bytes (capsule import/merge,
497
+ * binary-lifecycle redirects): same bounded loop, same locked settle.
498
+ */
499
+ export async function repairContentAfterJournalMove(options: {
500
+ stateDir: string;
501
+ cache: HistoricalEntityCanonicalIdCache;
502
+ idsAtWrite: Readonly<Record<string, string>>;
503
+ rawContent: string;
504
+ lastWritten: string;
505
+ rewrite: (content: string) => Promise<void>;
506
+ }): Promise<void> {
507
+ let refIds = options.idsAtWrite;
508
+ let written = options.lastWritten;
509
+ for (let attempt = 0; attempt < 3; attempt += 1) {
510
+ const fresh = options.cache.get(options.stateDir);
511
+ if (fresh === refIds) return;
512
+ refIds = fresh;
513
+ const desired = canonicalizeEntityRefFrontmatter(options.rawContent, fresh);
514
+ if (desired === written) return;
515
+ written = desired;
516
+ await options.rewrite(desired);
517
+ }
518
+ await withEntityCanonicalMutationLock(options.stateDir, async () => {
519
+ const desired = canonicalizeEntityRefFrontmatter(options.rawContent, options.cache.get(options.stateDir));
520
+ if (desired !== written) {
521
+ written = desired;
522
+ await options.rewrite(desired);
523
+ }
524
+ });
525
+ await requestEntityCanonicalIdReconcile(options.stateDir);
526
+ }
527
+
528
+ /**
529
+ * Sync variant (space promotion, curation, review actions). Sync callers
530
+ * cannot take the async mutation lock, so exhaustion surfaces a retryable
531
+ * error after requesting the marker — a silent return could leave the bytes
532
+ * on a parked canonical claimant the reconcile pass cannot restore.
533
+ */
534
+ export function repairContentAfterJournalMoveSync(options: {
535
+ stateDir: string;
536
+ cache: HistoricalEntityCanonicalIdCache;
537
+ idsAtWrite: Readonly<Record<string, string>>;
538
+ rawContent: string;
539
+ lastWritten: string;
540
+ rewrite: (content: string) => void;
541
+ }): void {
542
+ let refIds = options.idsAtWrite;
543
+ let written = options.lastWritten;
544
+ for (let attempt = 0; attempt < 3; attempt += 1) {
545
+ const fresh = options.cache.get(options.stateDir);
546
+ if (fresh === refIds) return;
547
+ refIds = fresh;
548
+ const desired = canonicalizeEntityRefFrontmatter(options.rawContent, fresh);
549
+ if (desired === written) return;
550
+ written = desired;
551
+ options.rewrite(desired);
552
+ }
553
+ requestEntityCanonicalIdReconcileSync(options.stateDir);
554
+ throw new Error(
555
+ "entity canonical-id journal kept changing during post-write repair; retry the write",
556
+ );
557
+ }