renor-engine 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +73 -0
  3. package/README.md +105 -2
  4. package/dist/BUILD.json +6 -0
  5. package/dist/api/apiAuth.js +43 -0
  6. package/dist/api/server.js +172 -0
  7. package/dist/buildIdentity.js +132 -0
  8. package/dist/cli-extractors.js +46 -0
  9. package/dist/cli.js +491 -0
  10. package/dist/config.js +65 -0
  11. package/dist/context/composer.js +320 -0
  12. package/dist/context/readHonesty.js +478 -0
  13. package/dist/context/rowStore.js +238 -0
  14. package/dist/conversation/formatter.js +81 -0
  15. package/dist/conversation/parser.js +189 -0
  16. package/dist/conversation/types.js +2 -0
  17. package/dist/conversation/writer.js +42 -0
  18. package/dist/db/client.js +147 -0
  19. package/dist/db/indexSalvage.js +81 -0
  20. package/dist/db/migrations.js +642 -0
  21. package/dist/db/readCache.js +109 -0
  22. package/dist/db/schema.js +5 -0
  23. package/dist/db/statements.js +89 -0
  24. package/dist/digests/digestCache.js +142 -0
  25. package/dist/digests/digests.js +597 -0
  26. package/dist/digests/worker.js +348 -0
  27. package/dist/embeddings/caching.js +337 -0
  28. package/dist/embeddings/coalescing.js +177 -0
  29. package/dist/embeddings/interactive.js +103 -0
  30. package/dist/embeddings/local.js +68 -0
  31. package/dist/embeddings/openai.js +105 -0
  32. package/dist/embeddings/rebuild.js +166 -0
  33. package/dist/embeddings/simdScan.js +258 -0
  34. package/dist/embeddings/space.js +21 -0
  35. package/dist/embeddings/types.js +14 -0
  36. package/dist/embeddings/vectorIndex.js +675 -0
  37. package/dist/embeddings/vertex.js +224 -0
  38. package/dist/eval/artifacts.js +42 -0
  39. package/dist/eval/cacheReport.js +139 -0
  40. package/dist/eval/comparison.js +182 -0
  41. package/dist/eval/context700-candidate.js +145 -0
  42. package/dist/eval/evalCache.js +525 -0
  43. package/dist/eval/hashEmbedding.js +35 -0
  44. package/dist/eval/longmemeval.js +56 -0
  45. package/dist/eval/metrics.js +181 -0
  46. package/dist/eval/renorbenchBaseline.js +486 -0
  47. package/dist/eval/renorbenchDocuments.js +134 -0
  48. package/dist/eval/renorbenchProduct.js +182 -0
  49. package/dist/eval/runLedger.js +279 -0
  50. package/dist/eval/runManifest.js +479 -0
  51. package/dist/eval/scoring.js +197 -0
  52. package/dist/evidence/attach.js +267 -0
  53. package/dist/evidence/background.js +147 -0
  54. package/dist/evidence/evidenceCache.js +182 -0
  55. package/dist/evidence/gate.js +37 -0
  56. package/dist/evidence/keyPool.js +85 -0
  57. package/dist/evidence/ollamaChat.js +84 -0
  58. package/dist/evidence/optin.js +165 -0
  59. package/dist/evidence/options.js +130 -0
  60. package/dist/evidence/secondPass.js +456 -0
  61. package/dist/extraction/classifyCapture.js +34 -0
  62. package/dist/extraction/decision-model.js +174 -0
  63. package/dist/extraction/eventExtractor.js +113 -0
  64. package/dist/extraction/extractionClient.js +122 -0
  65. package/dist/extraction/factExtractor.js +46 -0
  66. package/dist/extraction/factsWritePath.js +179 -0
  67. package/dist/extraction/preferenceExtractor.js +153 -0
  68. package/dist/extraction/secretGuard.js +176 -0
  69. package/dist/extraction/secretScanner.js +665 -0
  70. package/dist/extraction/typedFacts.js +224 -0
  71. package/dist/extraction/writePathAdjudication.js +110 -0
  72. package/dist/importing/apply.js +644 -0
  73. package/dist/importing/bundle-write.js +80 -0
  74. package/dist/importing/cli.js +407 -0
  75. package/dist/importing/existing.js +49 -0
  76. package/dist/importing/facts.js +123 -0
  77. package/dist/importing/forgetImported.js +79 -0
  78. package/dist/importing/hosted.js +467 -0
  79. package/dist/importing/index.js +29 -0
  80. package/dist/importing/journalLog.js +96 -0
  81. package/dist/importing/markdown.js +98 -0
  82. package/dist/importing/plan.js +206 -0
  83. package/dist/importing/secretScreen.js +113 -0
  84. package/dist/importing/serialize.js +188 -0
  85. package/dist/importing/source.js +463 -0
  86. package/dist/importing/types.js +6 -0
  87. package/dist/indexer/chunker.js +319 -0
  88. package/dist/indexer/dailyEntries.js +63 -0
  89. package/dist/indexer/hasher.js +10 -0
  90. package/dist/indexer/laneRuns.js +48 -0
  91. package/dist/indexer/o200k.js +481 -0
  92. package/dist/indexer/o200k.json +8 -0
  93. package/dist/indexer/outsideRoot.js +77 -0
  94. package/dist/indexer/parser.js +167 -0
  95. package/dist/indexer/pipeline.js +1168 -0
  96. package/dist/indexer/staleness.js +201 -0
  97. package/dist/indexer/tokenizer.js +77 -0
  98. package/dist/indexer/walker.js +184 -0
  99. package/dist/mcp/hive.js +88 -0
  100. package/dist/mcp/requestErrors.js +47 -0
  101. package/dist/mcp/server.js +396 -0
  102. package/dist/memory/agentLease.js +43 -0
  103. package/dist/memory/confinedWrite.js +315 -0
  104. package/dist/memory/controls.js +73 -0
  105. package/dist/memory/dailyNote.js +265 -0
  106. package/dist/memory/durableAppend.js +57 -0
  107. package/dist/memory/factConflicts.js +203 -0
  108. package/dist/memory/factTerms.js +321 -0
  109. package/dist/memory/hiveNameHeader.cjs +10 -0
  110. package/dist/memory/inventory.js +55 -0
  111. package/dist/memory/invisibleText.js +20 -0
  112. package/dist/memory/lifecycle.js +1756 -0
  113. package/dist/memory/limits.js +13 -0
  114. package/dist/memory/requests.js +183 -0
  115. package/dist/memory/sourceName.js +200 -0
  116. package/dist/memory/whatsNew.js +158 -0
  117. package/dist/memory/writer.js +175 -0
  118. package/dist/memory/writerConflicts.js +85 -0
  119. package/dist/memorymap/memoryMap.js +1568 -0
  120. package/dist/memorymap/replacedJournal.js +58 -0
  121. package/dist/memorymap/replacements.js +405 -0
  122. package/dist/mvp/activity.js +86 -0
  123. package/dist/mvp/agentMemory.js +1744 -0
  124. package/dist/mvp/auth.js +531 -0
  125. package/dist/mvp/cli.js +73 -0
  126. package/dist/mvp/client-store.js +462 -0
  127. package/dist/mvp/compileCache.js +249 -0
  128. package/dist/mvp/compileSeed.js +27 -0
  129. package/dist/mvp/config.js +288 -0
  130. package/dist/mvp/connectionStatus.js +80 -0
  131. package/dist/mvp/contextRelevance.js +413 -0
  132. package/dist/mvp/engine.js +1065 -0
  133. package/dist/mvp/exportAll.js +359 -0
  134. package/dist/mvp/factLanePack.js +77 -0
  135. package/dist/mvp/factMarkers.js +49 -0
  136. package/dist/mvp/factsLane.js +203 -0
  137. package/dist/mvp/hiddenSpans.js +585 -0
  138. package/dist/mvp/history.js +413 -0
  139. package/dist/mvp/hitDates.js +31 -0
  140. package/dist/mvp/hitFloor.js +36 -0
  141. package/dist/mvp/identity.js +18 -0
  142. package/dist/mvp/idle.js +113 -0
  143. package/dist/mvp/memoryDir.js +98 -0
  144. package/dist/mvp/posthogMcp.js +210 -0
  145. package/dist/mvp/readScope.js +104 -0
  146. package/dist/mvp/readUsefulness.js +222 -0
  147. package/dist/mvp/sanitize.js +48 -0
  148. package/dist/mvp/schemaRejections.js +91 -0
  149. package/dist/mvp/scope.js +127 -0
  150. package/dist/mvp/server.js +2089 -0
  151. package/dist/mvp/serverCard.js +29 -0
  152. package/dist/mvp/storedSize.js +139 -0
  153. package/dist/mvp/telemetry.js +441 -0
  154. package/dist/retrieval/adviceLines.js +195 -0
  155. package/dist/retrieval/adviceQuestion.js +44 -0
  156. package/dist/retrieval/conversationShape.js +44 -0
  157. package/dist/retrieval/dailyEntryTime.js +55 -0
  158. package/dist/retrieval/entityLookup.js +113 -0
  159. package/dist/retrieval/factsFirst.js +188 -0
  160. package/dist/retrieval/keyword.js +84 -0
  161. package/dist/retrieval/ledgerEventTime.js +51 -0
  162. package/dist/retrieval/ledgerLiveness.js +83 -0
  163. package/dist/retrieval/ledgerPresentation.js +161 -0
  164. package/dist/retrieval/noteIdentity.js +57 -0
  165. package/dist/retrieval/orchestrator.js +1086 -0
  166. package/dist/retrieval/queryHelpers.js +142 -0
  167. package/dist/retrieval/questionSpan.js +475 -0
  168. package/dist/retrieval/readerEvents.js +690 -0
  169. package/dist/retrieval/readerLayout.js +503 -0
  170. package/dist/retrieval/relativeTimeHints.js +113 -0
  171. package/dist/retrieval/semantic.js +67 -0
  172. package/dist/router/routerCache.js +106 -0
  173. package/dist/router/sessionRouter.js +726 -0
  174. package/dist/toolRefusal.js +120 -0
  175. package/dist/watcher/watcher.js +75 -0
  176. package/package.json +120 -4
@@ -0,0 +1,1756 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LegacyIdentityAmbiguityError = exports.LifecycleInputError = exports.FIELD_LIMITS = exports.DEFAULT_LEDGER_REL = exports.LIFECYCLE_NOTE_PATH = void 0;
7
+ exports.resolveLedgerPath = resolveLedgerPath;
8
+ exports.appendLedgerEvent = appendLedgerEvent;
9
+ exports.unterminatedFinalEventStart = unterminatedFinalEventStart;
10
+ exports.parseLedgerEvents = parseLedgerEvents;
11
+ exports.replayLedger = replayLedger;
12
+ exports.remember = remember;
13
+ exports.factEventId = factEventId;
14
+ exports.retireFact = retireFact;
15
+ exports.restoreFact = restoreFact;
16
+ exports.listRetiredFacts = listRetiredFacts;
17
+ exports.scopeFilterFor = scopeFilterFor;
18
+ exports.listLifecycleSubjects = listLifecycleSubjects;
19
+ exports.factsFor = factsFor;
20
+ const promises_1 = __importDefault(require("fs/promises"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const crypto_1 = __importDefault(require("crypto"));
23
+ const entityLookup_1 = require("../retrieval/entityLookup");
24
+ const queryHelpers_1 = require("../retrieval/queryHelpers");
25
+ const scope_1 = require("../mvp/scope");
26
+ const readCache_1 = require("../db/readCache");
27
+ const statements_1 = require("../db/statements");
28
+ const factTerms_1 = require("./factTerms");
29
+ const limits_1 = require("./limits");
30
+ const confinedWrite_1 = require("./confinedWrite");
31
+ const invisibleText_1 = require("./invisibleText");
32
+ const sourceName_1 = require("./sourceName");
33
+ const writerConflicts_1 = require("./writerConflicts");
34
+ const durableAppend_1 = require("./durableAppend");
35
+ const secretGuard_1 = require("../extraction/secretGuard");
36
+ const toolRefusal_1 = require("../toolRefusal");
37
+ /** Reserved synthetic note path owning lifecycle entities (never a real vault file). */
38
+ exports.LIFECYCLE_NOTE_PATH = '_mcp_';
39
+ /** Default relative ledger path inside the vault. */
40
+ exports.DEFAULT_LEDGER_REL = 'System/Memory Events Ledger.md';
41
+ const EVENT_ID_RE = /^mem_[A-Za-z0-9]+$/i;
42
+ const EVENT_HEADER_RE = /^## (mem_[A-Za-z0-9]+) · (.+?)[ \t]*$/i;
43
+ // A field value is the rest of its physical line, and lines are split on '\n' alone. dotAll, because
44
+ // `.` otherwise refuses U+2028/U+2029 (and a lone \r), which JavaScript calls line terminators and this
45
+ // ledger does not: one such character in a value used to make the whole ledger unreadable at startup.
46
+ const FIELD_RE = /^- ([A-Za-z][\w-]*): (.*)$/s;
47
+ const KNOWN_FIELDS = new Set(['kind', 'subject', 'predicate', 'object', 'closed_event', 'source', 'scope', 'statement', 'writer', 'target']);
48
+ const REQUIRED_FIELDS = ['subject', 'predicate', 'object'];
49
+ /**
50
+ * retire takes one fact out of recall and restore brings it back (retireFact, restoreFact). Each names the fact's own
51
+ * event as its target and repeats its subject and predicate so the ledger reads on its own, but never its value: the
52
+ * value is in the target event already, and a second copy would be one more place a value someone asked to forget
53
+ * is written.
54
+ */
55
+ const RETIREMENT_KINDS = new Set(['retire', 'restore']);
56
+ const KNOWN_KINDS = new Set(['store', 'supersede', ...RETIREMENT_KINDS]);
57
+ const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
58
+ exports.FIELD_LIMITS = { subject: 512, predicate: 256, object: 8192, source: 256, scope: 80, statement: limits_1.MAX_STATEMENT_LENGTH, writer: 256 };
59
+ /**
60
+ * Characters JSON.stringify leaves literal although a line-oriented reader may read them as a line
61
+ * end or a control: DEL, the C1 controls (U+0085 NEXT LINE among them), U+2028 LINE SEPARATOR and
62
+ * U+2029 PARAGRAPH SEPARATOR. JSON itself already escapes every C0 control and any unpaired surrogate.
63
+ */
64
+ const JSON_LEAVES_LITERAL = /[\p{Cc}\p{Zl}\p{Zp}]/gu;
65
+ /**
66
+ * The user's statement is free text (newlines, '## mem_…', '- key: …' lines), and the ledger is
67
+ * line-framed, so it is written as ONE JSON string literal and nothing else. The value starts with a
68
+ * quote, so no statement can open a heading or a field line; between the quotes there is no control
69
+ * character and no line or paragraph separator, so it is one line to every reader (this parser, an
70
+ * editor, a script that splits on Unicode line rules). Every escape is plain JSON, so any JSON parser
71
+ * reads the value back exactly, while text in any script stays readable and searchable as written.
72
+ */
73
+ function encodeStatement(statement) {
74
+ return JSON.stringify(statement)
75
+ .replace(JSON_LEAVES_LITERAL, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
76
+ }
77
+ /**
78
+ * Strict on purpose: anything that is not exactly one JSON string literal is corruption. JSON allows
79
+ * the separators the encoder now escapes to appear literal inside the quotes, so a ledger written
80
+ * before they were escaped still reads back exactly.
81
+ */
82
+ function decodeStatement(raw, id) {
83
+ let value;
84
+ try {
85
+ value = JSON.parse(raw);
86
+ }
87
+ catch {
88
+ value = undefined;
89
+ }
90
+ if (typeof value !== 'string') {
91
+ throw new Error(`ledger corruption in event ${id}: statement is not a JSON string literal`);
92
+ }
93
+ return value;
94
+ }
95
+ function newEventId() {
96
+ const t = Date.now().toString(36);
97
+ const r = Math.random().toString(36).slice(2, 8);
98
+ return `mem_${t}${r}`.toUpperCase();
99
+ }
100
+ /**
101
+ * A lifecycle field value was rejected by validation.
102
+ *
103
+ * On the write path every field is validated before the ledger is touched, so this class means
104
+ * "the request was wrong", never "the process is unhealthy": a caller-side type/length/character
105
+ * error must be answered 4xx by an HTTP surface, not 500. Reporting it as 500 misclassifies a
106
+ * client bug as a server fault and pages whoever watches the error budget for a request nobody
107
+ * can fix. Genuine engine faults — an unreadable ledger, a reused event id, a clock regression,
108
+ * a ledger path that escapes the vault — keep throwing plain Errors and stay 5xx.
109
+ *
110
+ * The message text is unchanged except for naming the received type, so log greps and existing
111
+ * assertions still match; only the class carries the new distinction.
112
+ */
113
+ class LifecycleInputError extends Error {
114
+ field;
115
+ /**
116
+ * The refusal code telemetry carries, from the closed set in src/toolRefusal.ts, is recorded out of band
117
+ * (markErrorReason): the class gains no field, so its class, `name`, `message`, enumerable keys and
118
+ * `instanceof` are exactly what they were before. The third argument is optional, so an error built
119
+ * elsewhere keeps its two-argument shape.
120
+ */
121
+ constructor(field, message, reason) {
122
+ super(message);
123
+ this.name = 'LifecycleInputError';
124
+ this.field = field;
125
+ if (reason !== undefined)
126
+ (0, toolRefusal_1.markErrorReason)(this, reason);
127
+ }
128
+ }
129
+ exports.LifecycleInputError = LifecycleInputError;
130
+ /** The received type, spelled the way JSON Schema spells it, for error messages. */
131
+ function describeType(value) {
132
+ if (value === null)
133
+ return 'null';
134
+ if (Array.isArray(value))
135
+ return 'array';
136
+ return typeof value;
137
+ }
138
+ function validateField(field, value, required) {
139
+ if (typeof value !== 'string')
140
+ throw new LifecycleInputError(field, `lifecycle ${field} must be a string (received ${describeType(value)})`, 'invalid_argument');
141
+ if (required && value.trim() === '')
142
+ throw new LifecycleInputError(field, `lifecycle ${field} must be a non-empty string`, 'invalid_argument');
143
+ if (CONTROL_CHARS.test(value)) {
144
+ throw new LifecycleInputError(field, `lifecycle ${field} contains control characters (newlines/tabs are rejected to keep ledger framing strict): ${JSON.stringify(value.slice(0, 40))}`, 'invalid_text');
145
+ }
146
+ const limit = exports.FIELD_LIMITS[field];
147
+ if (limit !== undefined && value.length > limit) {
148
+ throw new LifecycleInputError(field, `lifecycle ${field} exceeds the maximum length of ${limit} (got ${value.length})`, 'field_too_long');
149
+ }
150
+ return value;
151
+ }
152
+ function validateLifecycleInput(args) {
153
+ validateField('subject', args.subject, true);
154
+ validateField('predicate', args.predicate, true);
155
+ validateField('object', args.object, true);
156
+ if (args.source !== undefined)
157
+ validateField('source', args.source, false);
158
+ if (args.statement !== undefined) {
159
+ // The user's own words are kept as given, so unlike the structured fields they may hold control
160
+ // characters (the encoding makes them safe); only the type and the size are checked. The encoding
161
+ // is the reason the control-character rule in memory/controls.ts does not apply here: a statement
162
+ // is written as ONE JSON string literal, so every C0 control is escaped on disk and no statement
163
+ // can break the ledger's framing — while the note writer, which stores text raw, does refuse them.
164
+ if (typeof args.statement !== 'string')
165
+ throw new LifecycleInputError('statement', `lifecycle statement must be a string (received ${describeType(args.statement)})`, 'invalid_argument');
166
+ const limit = exports.FIELD_LIMITS.statement;
167
+ if (args.statement.length > limit)
168
+ throw new LifecycleInputError('statement', `lifecycle statement exceeds the maximum length of ${limit} (got ${args.statement.length})`, 'field_too_long');
169
+ }
170
+ if (args.scope !== undefined) {
171
+ // Normalize throws on invalid; re-label it as the caller error it is, so every rejection
172
+ // from this function leaves as a LifecycleInputError and an HTTP surface can answer 4xx.
173
+ // Also bound via FIELD_LIMITS via validateField on the normalized form.
174
+ let n;
175
+ try {
176
+ n = (0, scope_1.normalizeScope)(args.scope);
177
+ }
178
+ catch (err) {
179
+ // normalizeScope names invalid_scope itself (src/mvp/scope.ts); the re-label keeps that code.
180
+ throw new LifecycleInputError('scope', err instanceof Error ? err.message : String(err), (0, toolRefusal_1.refusalReason)(err) ?? 'invalid_scope');
181
+ }
182
+ validateField('scope', n, false);
183
+ }
184
+ }
185
+ // Write-only: replay runs validateField over every stored event, so a value written before this check must still load.
186
+ function refuseUnstorableFields(args) {
187
+ for (const field of ['subject', 'predicate', 'object']) {
188
+ const value = args[field];
189
+ if ((0, invisibleText_1.isBlankOrInvisible)(value))
190
+ throw new LifecycleInputError(field, `lifecycle ${field} must be a non-empty string`, 'invalid_argument');
191
+ if (/\p{Cs}/u.test(value))
192
+ throw new LifecycleInputError(field, `lifecycle ${field} contains an unpaired UTF-16 surrogate, which cannot be stored as text`, 'invalid_text');
193
+ }
194
+ }
195
+ /**
196
+ * S1 canonical timestamp contract: ONE sortable format everywhere — the exact
197
+ * UTC-ISO representation the writer emits (new Date().toISOString():
198
+ * YYYY-MM-DDTHH:mm:ss.sssZ). With this single format, lexicographic string
199
+ * order == instant order, so the apply/live string comparisons, the
200
+ * reconciliation's instant comparisons and the SQL textual ordering behind
201
+ * asOf/current reads all agree without any format migration or digest change
202
+ * (canonical events keep byte-identical at strings, so digests are unchanged).
203
+ * Other representations — e.g. '2026-09-08T23:30:00.000-01:00', which is a
204
+ * LATER instant sorting EARLIER lexically — are an UNSUPPORTED legacy
205
+ * representation, not a malicious input: the engine writer has never emitted
206
+ * them, and they are rejected consistently, BEFORE any mutation, with a clear
207
+ * message naming the event. Recovery is repairing the ledger entry.
208
+ */
209
+ const CANONICAL_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
210
+ function isCanonicalTimestamp(at) {
211
+ if (!CANONICAL_TS_RE.test(at))
212
+ return false;
213
+ // Exact writer format: the string IS the canonical serialization of its instant.
214
+ const d = new Date(at);
215
+ return !Number.isNaN(d.getTime()) && d.toISOString() === at;
216
+ }
217
+ /** Normalize any parseable ISO instant to the canonical format (query inputs only). */
218
+ // S1.1: datetime input must carry an explicit timezone (trailing Z or a
219
+ // numeric ±HH:MM offset). Timezone-less datetimes such as
220
+ // '2026-09-09T00:30:00' are parsed against the HOST timezone by Date, so the
221
+ // canonicalized instant silently depended on the host's TZ — ambiguous input
222
+ // is rejected clearly instead. Date-only 'YYYY-MM-DD' remains accepted: ISO
223
+ // 8601 defines date-only as UTC midnight, deterministically. Valid zoned
224
+ // instants normalize exactly as before.
225
+ const ZONED_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:\d{2})$/;
226
+ const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
227
+ function toCanonicalInstant(value) {
228
+ if (typeof value !== 'string'
229
+ || (!ZONED_INSTANT_RE.test(value) && !DATE_ONLY_RE.test(value))) {
230
+ throw new Error(`invalid timestamp ${JSON.stringify(value)}: expected an ISO 8601 instant with an explicit timezone (e.g. 2026-09-09T00:30:00Z or 2026-09-09T00:30:00-03:00, or date-only YYYY-MM-DD which is UTC midnight); timezone-less datetimes are ambiguous and rejected`);
231
+ }
232
+ const d = new Date(value);
233
+ if (Number.isNaN(d.getTime())) {
234
+ throw new Error(`invalid timestamp ${JSON.stringify(value)}: expected a parseable ISO 8601 instant`);
235
+ }
236
+ return d.toISOString();
237
+ }
238
+ function validateLedgerEvent(ev) {
239
+ if (!EVENT_ID_RE.test(ev.id))
240
+ throw new Error(`ledger event id "${ev.id}" is malformed`);
241
+ if (!isCanonicalTimestamp(ev.at)) {
242
+ throw new Error(`ledger event ${ev.id} has an unsupported timestamp representation ${JSON.stringify(ev.at)}: the canonical format emitted by the writer is exact UTC ISO (YYYY-MM-DDTHH:mm:ss.sssZ, e.g. 2026-09-09T00:00:00.000Z); repair the ledger entry`);
243
+ }
244
+ if (!KNOWN_KINDS.has(ev.kind))
245
+ throw new Error(`ledger corruption in event ${ev.id}: unknown kind "${ev.kind}"`);
246
+ const retirement = RETIREMENT_KINDS.has(ev.kind);
247
+ validateField('subject', ev.subject, true);
248
+ validateField('predicate', ev.predicate, true);
249
+ // A retirement carries no value (RETIREMENT_KINDS); a fact event always does.
250
+ if (retirement) {
251
+ if (ev.object !== '')
252
+ throw new Error(`ledger corruption in event ${ev.id}: a ${ev.kind} event carries no object`);
253
+ }
254
+ else {
255
+ validateField('object', ev.object, true);
256
+ }
257
+ validateField('source', ev.source, false);
258
+ if (ev.writer !== undefined)
259
+ validateField('writer', ev.writer, true);
260
+ if (retirement) {
261
+ if (ev.target === undefined || !EVENT_ID_RE.test(ev.target)) {
262
+ throw new Error(`ledger corruption in event ${ev.id}: a ${ev.kind} event needs the id of the fact event it ${ev.kind}s (target)`);
263
+ }
264
+ if (ev.closedEvent != null || ev.statement !== undefined) {
265
+ throw new Error(`ledger corruption in event ${ev.id}: a ${ev.kind} event closes nothing and keeps no statement`);
266
+ }
267
+ }
268
+ else if (ev.target !== undefined) {
269
+ throw new Error(`ledger corruption in event ${ev.id}: only a retire or restore event names a target`);
270
+ }
271
+ if (ev.statement !== undefined)
272
+ validateLifecycleInput({ subject: ev.subject, predicate: ev.predicate, object: ev.object, statement: ev.statement });
273
+ if (ev.scope !== undefined && ev.scope !== null && ev.scope !== '') {
274
+ const n = (0, scope_1.normalizeScope)(ev.scope);
275
+ validateField('scope', n, false);
276
+ ev.scope = n;
277
+ }
278
+ else {
279
+ ev.scope = 'global';
280
+ }
281
+ if (ev.closedEvent != null && !EVENT_ID_RE.test(ev.closedEvent)) {
282
+ throw new Error(`ledger corruption in event ${ev.id}: malformed closed_event id "${ev.closedEvent}"`);
283
+ }
284
+ }
285
+ function eventDigest(ev) {
286
+ const parts = [
287
+ ev.id, ev.at, ev.kind, ev.subject, ev.predicate, ev.object, ev.closedEvent ?? '', ev.source ?? 'ledger',
288
+ ];
289
+ // Digest-stable for legacy/global: only append scope when non-global so existing
290
+ // applied_ledger_events digests keep matching after migration 8.
291
+ const scope = (0, scope_1.normalizeScope)(ev.scope);
292
+ if (scope !== 'global')
293
+ parts.push(scope);
294
+ // Same rule for the statement: absent => digest identical to pre-statement events.
295
+ if (ev.statement !== undefined)
296
+ parts.push(`statement:${ev.statement}`);
297
+ if (ev.writer !== undefined)
298
+ parts.push(`writer:${ev.writer}`);
299
+ if (ev.target !== undefined)
300
+ parts.push(`target:${ev.target}`);
301
+ return crypto_1.default.createHash('sha256').update(JSON.stringify(parts)).digest('hex');
302
+ }
303
+ /**
304
+ * S2 shared ledger path resolver, rooted at the VAULT ROOT and used by BOTH
305
+ * append and replay (and the remember receipt). The old containment check
306
+ * rooted its base at dirname(ledgerRelPath), so '../outside/ledger' passed a
307
+ * check whose base was already outside the vault; replay had no confinement
308
+ * at all. Confinement is lexical (path.resolve + path.relative) and happens
309
+ * BEFORE any filesystem access — an escaping path never creates or reads
310
+ * anything outside the vault. Absolute ledger paths are rejected fail-closed
311
+ * (previously they silently double-joined through path.join). Normal relative
312
+ * paths and the '.md'-suffix behavior are unchanged. This is NOT symlink or
313
+ * concurrent-writer protection — hosted isolation remains Gate 1B scope.
314
+ *
315
+ * S2.1: escape detection matches an actual parent component ('..' exactly, or
316
+ * '..' followed by a path separator) — NOT a mere '../..' string prefix, which
317
+ * wrongly rejected harmless in-vault names like '..safe/ledger' or
318
+ * '..ledger.md'. All traversal defenses are preserved: any resolved path
319
+ * outside the vault root, and any absolute ledger path, still fails closed.
320
+ */
321
+ function resolveLedgerPath(vaultPath, ledgerRelPath) {
322
+ const root = path_1.default.resolve(vaultPath);
323
+ let rel = ledgerRelPath;
324
+ if (typeof rel !== 'string' || rel.length === 0) {
325
+ throw new Error(`ledger path escapes the vault root: ${JSON.stringify(ledgerRelPath)}`);
326
+ }
327
+ if (path_1.default.isAbsolute(rel)) {
328
+ // The ledger is addressed RELATIVE to the vault; an absolute path is a
329
+ // configuration error (previously it silently double-joined through
330
+ // path.join) and is rejected fail-closed even if it points inside the vault.
331
+ throw new Error(`absolute ledger path is not vault-relative: ${JSON.stringify(ledgerRelPath)}`);
332
+ }
333
+ // S2.1: check the ORIGINAL request before any '.md' suffix mangling — the
334
+ // vault root itself ('.'/empty resolution) and any REAL parent component
335
+ // ('..' exactly, or '..' + separator) fail closed here, so '..' can never
336
+ // be laundered into a literal '..md' name by the suffix rule.
337
+ const originalRelFromRoot = path_1.default.relative(root, path_1.default.resolve(root, rel));
338
+ const originalEscapes = originalRelFromRoot === ''
339
+ || originalRelFromRoot === '..'
340
+ || originalRelFromRoot.startsWith('..' + path_1.default.sep)
341
+ || path_1.default.isAbsolute(originalRelFromRoot);
342
+ if (originalEscapes) {
343
+ throw new Error(`ledger path escapes the vault root: ${JSON.stringify(ledgerRelPath)}`);
344
+ }
345
+ if (!rel.endsWith('.md'))
346
+ rel += '.md';
347
+ const resolved = path_1.default.resolve(root, rel);
348
+ const relFromRoot = path_1.default.relative(root, resolved);
349
+ const escapes = relFromRoot === ''
350
+ || relFromRoot === '..'
351
+ || relFromRoot.startsWith('..' + path_1.default.sep)
352
+ || path_1.default.isAbsolute(relFromRoot);
353
+ if (escapes) {
354
+ throw new Error(`ledger path escapes the vault root: ${JSON.stringify(ledgerRelPath)}`);
355
+ }
356
+ return resolved;
357
+ }
358
+ async function appendLedgerEvent(vaultPath, ledgerRelPath, ev) {
359
+ validateLedgerEvent(ev);
360
+ const resolved = resolveLedgerPath(vaultPath, ledgerRelPath);
361
+ const lines = [
362
+ `\n## ${ev.id} · ${ev.at}`,
363
+ `- kind: ${ev.kind}`,
364
+ `- subject: ${ev.subject}`,
365
+ `- predicate: ${ev.predicate}`,
366
+ ];
367
+ if (ev.target !== undefined)
368
+ lines.push(`- target: ${ev.target}`);
369
+ else
370
+ lines.push(`- object: ${ev.object}`);
371
+ if (ev.closedEvent)
372
+ lines.push(`- closed_event: ${ev.closedEvent}`);
373
+ const scope = (0, scope_1.normalizeScope)(ev.scope);
374
+ if (scope !== 'global')
375
+ lines.push(`- scope: ${scope}`);
376
+ if (ev.statement !== undefined)
377
+ lines.push(`- statement: ${encodeStatement(ev.statement)}`);
378
+ if (ev.writer !== undefined)
379
+ lines.push(`- writer: ${ev.writer}`);
380
+ lines.push(`- source: ${ev.source}`, ``);
381
+ // Confined and durable in one append (confinedWrite.ts): a link, a hard link or a FIFO planted in the vault is refused,
382
+ // never followed, and a failed write is cut back, so the ledger never keeps a fragment of an event whose save the caller
383
+ // was told failed. A new ledger starts with its header.
384
+ await (0, confinedWrite_1.appendConfined)(vaultPath, path_1.default.relative(path_1.default.resolve(vaultPath), resolved), lines.join('\n'), LEDGER_HEADER);
385
+ return resolved;
386
+ }
387
+ const LEDGER_HEADER = `---\ntype: memory-ledger\ntitle: Memory Events Ledger\n---\n\n# Memory Events Ledger\n\nAppend-only durable record of memory events. Replayed on rebuild.\n`;
388
+ /** A field line a complete event may carry (writer output): a known key, `- key: value`. */
389
+ function isLedgerFieldLine(line) {
390
+ const m = FIELD_RE.exec(line.replace(/\r$/, ''));
391
+ return m !== null && KNOWN_FIELDS.has(m[1]);
392
+ }
393
+ /**
394
+ * Where the FINAL ledger event starts when a write cut it short, or -1 when the ledger ends in whole events. The writer
395
+ * emits `- kind:` first and `- source:` last, so a final event that has a kind line but no terminated source line is
396
+ * unterminated: a crash or a full disk left it short, whether it was cut mid-line (no final newline) or after a line
397
+ * (missing its source). Such a block is well-formed up to the cut: its complete lines are a header and known fields, and
398
+ * its partial last line is a header or field prefix. Anything else is not reported here, so the strict parser refuses
399
+ * it. A final block with no kind line and a final newline (a legacy block replayed as store, or a malformed one) is left
400
+ * exactly as it was. Ordinary prose after a complete event never matches.
401
+ */
402
+ function unterminatedFinalEventStart(text) {
403
+ const headers = [...text.matchAll(/^## mem_/gim)];
404
+ if (headers.length === 0)
405
+ return -1;
406
+ const start = headers[headers.length - 1].index;
407
+ const block = text.slice(start);
408
+ if (/^- source: [^\n]*\n/m.test(block))
409
+ return -1;
410
+ const lines = block.split('\n');
411
+ const partial = lines.pop(); // what follows the last newline: '' when the event ends in one
412
+ const hasKind = lines.some(line => /^- kind: /.test(line.replace(/\r$/, '')));
413
+ // A header with no field line after it is a cut too: the writer writes kind on the line after the header.
414
+ if (partial === '' && !hasKind && lines.length > 1)
415
+ return -1;
416
+ if (lines.length > 0) {
417
+ if (!EVENT_HEADER_RE.test(lines[0].replace(/\r$/, '')))
418
+ return -1;
419
+ if (!lines.slice(1).every(isLedgerFieldLine))
420
+ return -1;
421
+ if (partial !== '' && partial !== '-' && !partial.startsWith('- '))
422
+ return -1;
423
+ }
424
+ return start;
425
+ }
426
+ function finalEventState(bytes, isProjected) {
427
+ const valid = utf8PrefixLength(bytes);
428
+ const text = bytes.subarray(0, valid).toString('utf8');
429
+ if (!Buffer.from(text, 'utf8').equals(bytes.subarray(0, valid)))
430
+ return { kind: 'none' };
431
+ const start = unterminatedFinalEventStart(text);
432
+ if (start < 0)
433
+ return { kind: 'none' };
434
+ const id = /^## (\S+)/.exec(text.slice(start))?.[1];
435
+ if (id !== undefined && isProjected(id.toUpperCase()))
436
+ return { kind: 'known' };
437
+ return { kind: 'torn', offset: Buffer.byteLength(text.slice(0, start), 'utf8') };
438
+ }
439
+ /** How many leading bytes end on a complete UTF-8 character: a multi-byte character cut at the end is not counted. */
440
+ function utf8PrefixLength(bytes) {
441
+ for (let back = 1; back <= Math.min(3, bytes.length); back++) {
442
+ const b = bytes[bytes.length - back];
443
+ if ((b & 0xc0) === 0x80)
444
+ continue; // a continuation byte: its lead byte is further back
445
+ const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
446
+ return need > back ? bytes.length - back : bytes.length;
447
+ }
448
+ return bytes.length;
449
+ }
450
+ /**
451
+ * Moves an unterminated final event out of the ledger: its exact bytes go to `<ledger>.torn-<timestamp>` (created and synced
452
+ * by createConfined), then the ledger is cut back to the last whole event (synced). Only that tail moves; every complete
453
+ * event stays where it is. Both files are reached through confined handles (confinedWrite.ts), so a link planted at the
454
+ * ledger, a hard link to it or a link on its directory is refused before anything is copied or cut. Refuses when the ledger
455
+ * changed since it was read.
456
+ */
457
+ async function moveTornTailAside(vaultPath, relFile, ledger, bytes, offset) {
458
+ const tornBytes = bytes.subarray(offset);
459
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
460
+ let tornPath = '';
461
+ for (let n = 0;; n++) {
462
+ try {
463
+ tornPath = await (0, confinedWrite_1.createConfined)(vaultPath, `${relFile}.torn-${stamp}${n ? `-${n}` : ''}`, tornBytes);
464
+ break;
465
+ }
466
+ catch (err) {
467
+ if (err.code !== 'EEXIST' || n >= 99)
468
+ throw err;
469
+ }
470
+ }
471
+ // The cut is only right for the bytes it was decided on: a writer that appended since would lose its event.
472
+ const now = (await ledger.stat()).size;
473
+ if (now !== bytes.length) {
474
+ throw new Error(`ledger ${path_1.default.resolve(vaultPath, relFile)} changed while its unterminated tail was being set aside (${bytes.length} -> ${now} bytes); not cut. Restart the engine.`);
475
+ }
476
+ await ledger.truncate(offset);
477
+ await ledger.sync();
478
+ console.error(`[memory] ledger ${path_1.default.resolve(vaultPath, relFile)}: its final event was cut short (a crash or a full disk); moved its ${tornBytes.length} byte(s) to ${tornPath} and kept every complete event. If that file holds a hand-written event that was never replayed, restore it by adding its \`- source:\` line and a final newline (if it has none), appending its bytes to the ledger, and restarting; a block still without that line is set aside again.`);
479
+ }
480
+ /**
481
+ * The ledger's text for replay, and whether its unterminated final event (if any) is one the index already holds. An
482
+ * unterminated final event is set aside only when `setTornTailAside` is set (engine start-up): a live process that is still
483
+ * appending (an import, a rebuild in another process) must never have its ledger cut from under it, so every other caller
484
+ * refuses and leaves the bytes alone. The decision is taken again under the append lock and through a confined handle, because
485
+ * an append may have finished since the first read, and a link planted at the ledger must be refused, not read or cut.
486
+ */
487
+ async function readLedgerForReplay(vaultPath, filePath, isProjected, setTornTailAside) {
488
+ const bytes = await promises_1.default.readFile(filePath);
489
+ const first = finalEventState(bytes, isProjected);
490
+ if (first.kind !== 'torn' || !setTornTailAside) {
491
+ return { content: bytes.toString('utf8'), finalEventKnown: first.kind === 'known' };
492
+ }
493
+ const relFile = path_1.default.relative(path_1.default.resolve(vaultPath), filePath);
494
+ return (0, durableAppend_1.withAppendLock)(filePath, () => (0, confinedWrite_1.withConfinedFile)(vaultPath, relFile, async (ledger) => {
495
+ const again = await ledger.readFile();
496
+ const now = finalEventState(again, isProjected);
497
+ if (now.kind !== 'torn')
498
+ return { content: again.toString('utf8'), finalEventKnown: now.kind === 'known' };
499
+ await moveTornTailAside(vaultPath, relFile, ledger, again, now.offset);
500
+ return { content: again.subarray(0, now.offset).toString('utf8'), finalEventKnown: false };
501
+ }));
502
+ }
503
+ /**
504
+ * Strict line-oriented parser for the ledger format. Returns the events in
505
+ * append order. Throws (never silently skips) on event-shaped corruption:
506
+ * malformed headers, malformed/unknown/duplicate fields, missing required
507
+ * fields, unknown kinds, invalid timestamps. Ordinary Markdown prose (the
508
+ * frontmatter header, human sections) outside event blocks is tolerated.
509
+ */
510
+ function parseLedgerEvents(content, options = {}) {
511
+ // A cut final event is refused, never read as whole: its last field (the object of a fact) may be a prefix of the value.
512
+ // Engine start-up sets such a tail aside before it parses (readLedgerForReplay); any other caller refuses.
513
+ if (options.finalEventKnown !== true && unterminatedFinalEventStart(content) >= 0) {
514
+ throw new Error('ledger corruption: its final event is unterminated (a write was cut short by a crash or a full disk); the next engine start sets that tail aside');
515
+ }
516
+ const events = [];
517
+ // Line-ending tolerance: a hand-edited CRLF ledger is valid content with a
518
+ // Windows editor's line endings, not corruption. Normalize \r\n to \n BEFORE
519
+ // splitting so field values never carry a stray trailing \r. Lone \r inside
520
+ // a line remains a control character and still fails loudly.
521
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
522
+ let i = 0;
523
+ while (i < lines.length) {
524
+ const line = lines[i];
525
+ if (!line.startsWith('## ')) {
526
+ i++;
527
+ continue;
528
+ }
529
+ const m = EVENT_HEADER_RE.exec(line);
530
+ if (!m) {
531
+ if (/^## mem_/i.test(line)) {
532
+ throw new Error(`ledger corruption at line ${i + 1}: malformed event header: ${JSON.stringify(line)}`);
533
+ }
534
+ i++;
535
+ continue; // ordinary Markdown heading — not a lifecycle event
536
+ }
537
+ const id = m[1].toUpperCase();
538
+ const at = m[2].trim();
539
+ if (!Number.isFinite(Date.parse(at))) {
540
+ throw new Error(`ledger corruption in event ${id} (line ${i + 1}): invalid timestamp ${JSON.stringify(at)}`);
541
+ }
542
+ i++;
543
+ const fields = {};
544
+ while (i < lines.length && lines[i].startsWith('- ')) {
545
+ const fm = FIELD_RE.exec(lines[i]);
546
+ if (!fm)
547
+ throw new Error(`ledger corruption in event ${id} at line ${i + 1}: malformed field line ${JSON.stringify(lines[i])}`);
548
+ const key = fm[1];
549
+ if (!KNOWN_FIELDS.has(key))
550
+ throw new Error(`ledger corruption in event ${id} at line ${i + 1}: unknown field "${key}"`);
551
+ if (Object.prototype.hasOwnProperty.call(fields, key))
552
+ throw new Error(`ledger corruption in event ${id} at line ${i + 1}: duplicate field "${key}"`);
553
+ fields[key] = fm[2];
554
+ i++;
555
+ }
556
+ const kind = fields.kind ?? 'store';
557
+ for (const req of REQUIRED_FIELDS) {
558
+ if (req === 'object' && RETIREMENT_KINDS.has(kind))
559
+ continue; // a retirement carries no value
560
+ if (fields[req] === undefined || fields[req] === '') {
561
+ throw new Error(`ledger corruption in event ${id}: missing required field "${req}"`);
562
+ }
563
+ }
564
+ const ev = {
565
+ id, at,
566
+ ...(fields.statement !== undefined ? { statement: decodeStatement(fields.statement, id) } : {}),
567
+ ...(fields.target !== undefined ? { target: fields.target } : {}),
568
+ kind,
569
+ subject: fields.subject,
570
+ predicate: fields.predicate,
571
+ object: fields.object ?? '',
572
+ closedEvent: fields.closed_event ?? null,
573
+ source: fields.source ?? 'ledger',
574
+ scope: fields.scope ? (0, scope_1.normalizeScope)(fields.scope) : 'global',
575
+ ...(fields.writer !== undefined ? { writer: fields.writer } : {}),
576
+ };
577
+ validateLedgerEvent(ev); // bounds, kind, id/timestamp/closed_event shape
578
+ events.push(ev);
579
+ }
580
+ return events;
581
+ }
582
+ /**
583
+ * A subject whose identity key matches SEVERAL lifecycle entities and whose open
584
+ * current facts disagree. Two spellings of one person (`José` and `JOSÉ`, or an NFC
585
+ * and an NFD form) could be split by a pre-fix write path, and there is no unique
586
+ * index on `entities` and no merge path, so nothing at the DB level forbids it.
587
+ *
588
+ * On the live write path there is no canonical order to consult (row ids are
589
+ * disposable cache artifacts, never consulted), so picking one entity would silently
590
+ * decide which of two conflicting values is current. Fail closed instead.
591
+ *
592
+ * Recovery is documented, not guessed: replaying the canonical ledger in order into a
593
+ * fresh index reconciles duplicate identities by event position (the later event
594
+ * wins), which is exactly the fix's behavior on a rebuild. Stored rows and histories
595
+ * are never deleted or rewritten by this error.
596
+ */
597
+ class LegacyIdentityAmbiguityError extends Error {
598
+ subject;
599
+ entityIds;
600
+ canonicalNames;
601
+ streams;
602
+ constructor(args) {
603
+ super(`legacy identity ambiguity for subject ${JSON.stringify(args.subject)}: `
604
+ + `${args.entities.length} lifecycle entities share one identity `
605
+ + `(${args.entities.map(e => `#${e.id} ${JSON.stringify(e.canonicalName)}`).join(', ')}) `
606
+ + `and their current facts disagree on ${args.streams.join(', ')}. `
607
+ + 'Refusing to guess which value is current; nothing was written. '
608
+ + 'Recovery: rebuild the index from the canonical ledger (an in-order replay reconciles the duplicate identities by event position).');
609
+ this.name = 'LegacyIdentityAmbiguityError';
610
+ this.subject = args.subject;
611
+ this.entityIds = args.entities.map(e => e.id);
612
+ this.canonicalNames = args.entities.map(e => e.canonicalName);
613
+ this.streams = args.streams;
614
+ }
615
+ }
616
+ exports.LegacyIdentityAmbiguityError = LegacyIdentityAmbiguityError;
617
+ /** One replay-local scan; buckets retain the legacy lowest-row-id ordering. */
618
+ function replayEntityIndex(db) {
619
+ const index = new Map();
620
+ const rows = (0, factTerms_1.prepareOnce)(db, `SELECT id, canonical_name FROM entities WHERE source_note = ? ORDER BY id`).all(exports.LIFECYCLE_NOTE_PATH);
621
+ for (const row of rows)
622
+ addEntityToIndex(index, row);
623
+ return index;
624
+ }
625
+ function addEntityToIndex(index, row) {
626
+ const key = (0, queryHelpers_1.identityKey)(row.canonical_name);
627
+ const matches = index.get(key) ?? [];
628
+ matches.push({ id: row.id, canonicalName: row.canonical_name });
629
+ index.set(key, matches);
630
+ }
631
+ /**
632
+ * Per database, for the reads and writes outside a replay: the same index, with how many lifecycle entities there
633
+ * were and the newest one's id when it was read. Lifecycle entity rows are only ever added, under ids that only grow
634
+ * (AUTOINCREMENT), and never renamed, so those two numbers say whether the table still holds what the index was read
635
+ * from: rows added since are read and added to it (by id, so each bucket keeps its lowest-id-first order), and any
636
+ * other change (a row gone) reads the table again. Only committed rows are read into it, so inside a transaction it
637
+ * is used only while it is current, and the table is scanned instead, as before.
638
+ */
639
+ const liveEntityIndexes = new WeakMap();
640
+ function liveEntityIndex(db) {
641
+ const { count, newest } = (0, factTerms_1.prepareOnce)(db, `SELECT COUNT(*) AS count, COALESCE(MAX(id), 0) AS newest FROM entities WHERE source_note = ?`).get(exports.LIFECYCLE_NOTE_PATH);
642
+ const cached = liveEntityIndexes.get(db);
643
+ if (cached && cached.count === count && cached.newest === newest)
644
+ return cached.index;
645
+ if (db.inTransaction)
646
+ return null;
647
+ if (cached && count > cached.count && newest > cached.newest) {
648
+ const added = (0, factTerms_1.prepareOnce)(db, `SELECT id, canonical_name FROM entities WHERE source_note = ? AND id > ? ORDER BY id`).all(exports.LIFECYCLE_NOTE_PATH, cached.newest);
649
+ if (cached.count + added.length === count) {
650
+ for (const row of added)
651
+ addEntityToIndex(cached.index, row);
652
+ cached.count = count;
653
+ cached.newest = newest;
654
+ return cached.index;
655
+ }
656
+ }
657
+ const index = replayEntityIndex(db);
658
+ liveEntityIndexes.set(db, { count, newest, index });
659
+ return index;
660
+ }
661
+ /**
662
+ * Lifecycle-owned entities whose identity key matches `subject`, ordered by row id.
663
+ *
664
+ * `identityKey` is the single subject-identity primitive (NFC + case fold, explicit
665
+ * aliases unified), so the lifecycle no longer needs SQL `COLLATE NOCASE` — which
666
+ * folds ASCII A–Z only and therefore split `José`/`JOSÉ`, `Пётр`/`пётр` and every
667
+ * NFC/NFD pair into two entities with two open currents. The comparison happens in JS
668
+ * BEFORE the query result is used, so a normalization form can never leak into the
669
+ * equality test. The fold is not expressible in SQLite without ICU, so the
670
+ * matches come from an index keyed by identity: replay builds one for itself, and
671
+ * live reads and writes keep one per database (liveEntityIndex), instead of folding
672
+ * every `_mcp_` entity's name on every save.
673
+ */
674
+ function lifecycleEntityMatches(db, subject, index) {
675
+ const key = (0, queryHelpers_1.identityKey)(subject);
676
+ if (index)
677
+ return index.get(key) ?? [];
678
+ const live = liveEntityIndex(db);
679
+ if (live)
680
+ return live.get(key) ?? [];
681
+ const rows = (0, factTerms_1.prepareOnce)(db, `SELECT id, canonical_name FROM entities WHERE source_note = ? ORDER BY id`).all(exports.LIFECYCLE_NOTE_PATH);
682
+ return rows
683
+ .filter(row => (0, queryHelpers_1.identityKey)(row.canonical_name) === key)
684
+ .map(row => ({ id: row.id, canonicalName: row.canonical_name }));
685
+ }
686
+ /** Streams (`predicate @ scope`) where the matched entities' open currents disagree. */
687
+ function conflictingLegacyStreams(db, entities) {
688
+ if (entities.length < 2)
689
+ return [];
690
+ const placeholders = entities.map(() => '?').join(',');
691
+ const rows = (0, factTerms_1.prepareOnce)(db, `SELECT predicate, COALESCE(scope, 'global') AS scope, object_text
692
+ FROM facts WHERE subject_entity_id IN (${placeholders}) AND valid_to IS NULL`).all(...entities.map(e => e.id));
693
+ const values = new Map();
694
+ for (const row of rows) {
695
+ const key = `${row.predicate}\u0000${row.scope}`;
696
+ const set = values.get(key) ?? new Set();
697
+ set.add(row.object_text);
698
+ values.set(key, set);
699
+ }
700
+ return [...values.entries()]
701
+ .filter(([, objects]) => objects.size > 1)
702
+ .map(([key]) => {
703
+ const [predicate, scope] = key.split('\u0000');
704
+ return `${predicate} @ ${scope}`;
705
+ });
706
+ }
707
+ /**
708
+ * Resolve the lifecycle-owned entity for a subject (source_note '_mcp_').
709
+ *
710
+ * `preferEntityId` is used by the already-projected replay branch: the event's own
711
+ * projected owner is canonical for that event, so reconciliation targets it instead of
712
+ * re-deciding identity (which would either re-open the split or refuse a replay the
713
+ * service needs at startup). `strict` is used by the live write path, which fails
714
+ * closed on a conflicting legacy split before anything is published.
715
+ */
716
+ function resolveLifecycleEntity(db, subject, at, create, opts = {}) {
717
+ const matches = lifecycleEntityMatches(db, subject, opts.index);
718
+ if (matches.length > 0) {
719
+ if (opts.preferEntityId !== undefined && matches.some(m => m.id === opts.preferEntityId)) {
720
+ return opts.preferEntityId;
721
+ }
722
+ if (matches.length === 1)
723
+ return matches[0].id;
724
+ if (opts.strict) {
725
+ const streams = conflictingLegacyStreams(db, matches);
726
+ if (streams.length > 0)
727
+ throw new LegacyIdentityAmbiguityError({ subject, entities: matches, streams });
728
+ }
729
+ // Several entities, no conflicting current value: deterministic lowest id, so a
730
+ // repeat write/replay never alternates between identities. Nothing is deleted.
731
+ return matches[0].id;
732
+ }
733
+ if (!create)
734
+ return null;
735
+ (0, factTerms_1.prepareOnce)(db, `INSERT OR IGNORE INTO notes (path, title, kind, note_hash, modified_at, frontmatter_json) VALUES (?, 'Memory Lifecycle', 'system', ?, ?, '{}')`).run(exports.LIFECYCLE_NOTE_PATH, exports.LIFECYCLE_NOTE_PATH, at);
736
+ const id = (0, factTerms_1.prepareOnce)(db, `INSERT INTO entities (type, canonical_name, aliases_json, source_note, confidence, updated_at) VALUES ('concept', ?, '[]', ?, 1.0, ?) RETURNING id`).get(subject, exports.LIFECYCLE_NOTE_PATH, at).id;
737
+ // New rows have increasing ids, and an empty bucket is the only creation
738
+ // path. On any failed event replay throws and discards this local index;
739
+ // rolled-back ids can never escape into a later replay/live write.
740
+ opts.index?.set((0, queryHelpers_1.identityKey)(subject), [{ id, canonicalName: subject }]);
741
+ return id;
742
+ }
743
+ function currentFact(db, entityId, predicate, scope = 'global') {
744
+ return (0, factTerms_1.prepareOnce)(db, `SELECT id, object_text, event_id, valid_from, scope, author FROM facts
745
+ WHERE subject_entity_id = ? AND predicate = ? AND scope = ? AND valid_to IS NULL
746
+ ORDER BY valid_from DESC, id DESC LIMIT 1`).get(entityId, predicate, scope);
747
+ }
748
+ /**
749
+ * One lifecycle stream per scope: a named scope is its slug (scopeSpellings), so the stream of (entity, predicate) in
750
+ * `scope` is its facts under every spelling of the slug, and a write closes the stream's current value whatever
751
+ * spelling either was saved with. These are that stream's current facts, newest first: one, or two when values were
752
+ * saved under two spellings before writes closed across them (each spelling holds at most one, idx_facts_current).
753
+ */
754
+ function streamCurrentFacts(db, entityId, predicate, scope) {
755
+ return (0, scope_1.scopeSpellings)(scope)
756
+ .map(spelling => currentFact(db, entityId, predicate, spelling))
757
+ .filter((row) => row !== undefined)
758
+ .sort((a, b) => (a.valid_from === b.valid_from ? b.id - a.id : a.valid_from < b.valid_from ? 1 : -1));
759
+ }
760
+ /**
761
+ * The fact an event closes under the other spelling of its slug: the one its closed_event names, when that is the
762
+ * current value of the same entity and predicate saved under another spelling of the event's scope. A write closes
763
+ * across spellings only by naming the fact it closes, and before it did, closed_event only ever named the current
764
+ * value under the event's own spelling, so a ledger written then replays exactly as it did: values saved under two
765
+ * spellings stay current, and a read names them as a conflict until a write closes them.
766
+ */
767
+ function currentFactClosedAcrossSpellings(db, ev, entityId, scope) {
768
+ if (!ev.closedEvent)
769
+ return undefined;
770
+ const named = (0, factTerms_1.prepareOnce)(db, `SELECT id, object_text, event_id, valid_from, scope, author FROM facts
771
+ WHERE event_id = ? AND subject_entity_id = ? AND predicate = ? AND valid_to IS NULL`).get(ev.closedEvent.toUpperCase(), entityId, ev.predicate);
772
+ return named && named.scope !== scope && (0, scope_1.savedScopeMatches)(named.scope, scope) ? named : undefined;
773
+ }
774
+ /**
775
+ * Re-home an already-projected legacy canonical fact from a disposable
776
+ * note-derived entity onto the stable lifecycle-owned entity (Astra A2 /
777
+ * Kimi R2, Kimi K9). The row keeps its event_id, timestamps and history —
778
+ * only ownership moves, and nothing is dropped or duplicated.
779
+ *
780
+ * Constraint-safe ordering (K9): idx_facts_current is a PARTIAL unique index
781
+ * and fires per STATEMENT. The naive move-then-close sequence made the moved
782
+ * row a second open current for one statement and threw UNIQUE before the
783
+ * reconciliation close could run, bricking upgrade replay on mixed-owner
784
+ * states. Instead, the winner is decided BEFORE any move and the statements
785
+ * are ordered so no intermediate state ever has two open currents:
786
+ *
787
+ * - no competing lifecycle current, or the moving row is historical
788
+ * -> plain move (never enters the partial index scope as a second open row);
789
+ * - the existing lifecycle current is the canonical winner -> the moving
790
+ * row is closed AND moved in ONE statement (valid_to set in the same
791
+ * UPDATE, so the index never sees it as an open duplicate);
792
+ * - the moving row is the canonical winner -> competing lifecycle currents
793
+ * are closed first, then the row moves in as the only open current.
794
+ *
795
+ * Winner rule (K12): the CANONICAL LEDGER position of each row's event decides
796
+ * — the event that appears later in the ledger wins, exactly matching what a
797
+ * fresh in-order replay would produce. SQLite row ids are disposable cache
798
+ * artifacts (insertion order of a legacy projection) and are NEVER consulted.
799
+ * A competing open row with an event_id that is ABSENT from the ledger is an
800
+ * index-vs-ledger contradiction: it fails explicitly rather than being ordered
801
+ * by guesswork. A NULL-event row (base vault_store_fact wrote those, bypassing
802
+ * the ledger) is non-canonical by construction and never wins against a
803
+ * ledger event. Residual interval flags are canonicalized afterwards by
804
+ * reconcileStreamCurrent, so this function only has to make the MOVE safe.
805
+ */
806
+ function rehomeLifecycleFact(db, ev, factId, positions, lifecycleId) {
807
+ const tx = db.transaction(() => {
808
+ const moving = (0, factTerms_1.prepareOnce)(db, 'SELECT id, valid_from, valid_to FROM facts WHERE id = ?').get(factId);
809
+ const existing = currentFact(db, lifecycleId, ev.predicate, (0, scope_1.normalizeScope)(ev.scope));
810
+ if (existing && moving.valid_to === null) {
811
+ const movingPos = requireLedgerPosition(positions, ev.id);
812
+ let movingWins;
813
+ if (existing.event_id == null) {
814
+ // Non-event-backed row: never canonical against a ledger event.
815
+ movingWins = true;
816
+ }
817
+ else {
818
+ const existingPos = requireLedgerPosition(positions, existing.event_id);
819
+ movingWins = movingPos > existingPos; // later canonical event wins (row ids never consulted)
820
+ }
821
+ if (movingWins) {
822
+ // Close the competing lifecycle current(s) BEFORE the move, so the
823
+ // moving row is never a second open current. The valid_to value here
824
+ // is provisional; reconcileStreamCurrent canonicalizes it afterwards.
825
+ (0, factTerms_1.prepareOnce)(db, `UPDATE facts SET valid_to = ?, updated_at = ? WHERE subject_entity_id = ? AND predicate = ? AND scope = ? AND valid_to IS NULL AND id != ?`).run(moving.valid_from, moving.valid_from, lifecycleId, ev.predicate, (0, scope_1.normalizeScope)(ev.scope), moving.id);
826
+ (0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET subject_entity_id = ? WHERE id = ?').run(lifecycleId, factId);
827
+ }
828
+ else {
829
+ // The existing lifecycle current wins canonically: close AND move the
830
+ // row in one statement. With valid_to set in the same UPDATE, the
831
+ // partial index (valid_to IS NULL) never sees the moved row as open.
832
+ (0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET subject_entity_id = ?, valid_to = ?, updated_at = ? WHERE id = ?')
833
+ .run(lifecycleId, existing.valid_from, existing.valid_from, factId);
834
+ }
835
+ }
836
+ else {
837
+ // No competing open current, or the moving row is historical: a plain
838
+ // move cannot violate the partial index.
839
+ (0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET subject_entity_id = ? WHERE id = ?').run(lifecycleId, factId);
840
+ }
841
+ });
842
+ tx();
843
+ }
844
+ /** Later of two timestamps by instant comparison (raw strings preserved). */
845
+ function maxInstant(a, b) {
846
+ return Date.parse(a) >= Date.parse(b) ? a : b;
847
+ }
848
+ /** Canonical position of a ledger event, or an explicit refusal to guess. */
849
+ function requireLedgerPosition(positions, eventId) {
850
+ const pos = positions.get(eventId);
851
+ if (pos === undefined) {
852
+ throw new Error(`index holds a lifecycle fact for event ${eventId} which is absent from the ledger; refusing to guess canonical order — restore the ledger or deliberately rebuild the derived index`);
853
+ }
854
+ return pos;
855
+ }
856
+ /**
857
+ * Canonical stream reconciliation (K12): recompute the current/valid_to chain
858
+ * for every event-backed lifecycle fact of (lifecycle entity, predicate) from
859
+ * the CANONICAL ledger positions, exactly mirroring what a fresh in-order
860
+ * replay produces — the event that appears latest in the ledger is the
861
+ * current fact; each earlier event's row is closed at its canonical
862
+ * successor's valid_from (never before its own valid_from, so no negative
863
+ * intervals). Stale winner flags left behind by earlier candidate reconciliations
864
+ * (including migration 6's interim duplicate repair, which cannot read the
865
+ * ledger) are repaired here, before readiness. Non-event-backed open rows are
866
+ * non-canonical (base vault_store_fact bypassed the ledger): they are closed
867
+ * at the start of the canonical record and KEPT in history — never dropped.
868
+ * Event-backed rows whose event is absent from the ledger are an
869
+ * index-vs-ledger contradiction and fail explicitly. Unrelated (non '_mcp_')
870
+ * facts are not touched. Idempotent: re-running produces no further writes.
871
+ *
872
+ * The stream is the scope's slug (streamCurrentFacts): the rows of every
873
+ * spelling are replayed together, in ledger order, as applyEvent applies them.
874
+ * Each event closes the current row of its own spelling and the row its
875
+ * closed_event names under another spelling (currentFactClosedAcrossSpellings;
876
+ * `closedBy` holds each event's closed_event). Without such a link the
877
+ * spellings never touch, so each spelling's chain is exactly the per-spelling
878
+ * chain this function computed before scopes were one slug.
879
+ */
880
+ function reconcileStreamCurrent(db, lifecycleId, predicate, positions, scope = 'global', closedBy = new Map()) {
881
+ (0, statements_1.transactionOnce)(db, reconcileStreamCurrentBody)(lifecycleId, predicate, positions, scope, closedBy);
882
+ }
883
+ /** The body of reconcileStreamCurrent's transaction: a module-level function so replay wraps it once per connection (transactionOnce). */
884
+ function reconcileStreamCurrentBody(db, lifecycleId, predicate, positions, scope, closedBy) {
885
+ const inStream = (0, scope_1.savedScopeSqlPredicate)(scope, 'scope');
886
+ // A retired fact keeps its place in the stream (retireFact): it is hidden, not undone, so the value it closed stays
887
+ // closed and the value saved after it closes it, exactly as if it had never been retired.
888
+ const rows = (0, factTerms_1.prepareOnce)(db, `SELECT id, event_id, valid_from, valid_to, scope, 0 AS retired FROM facts
889
+ WHERE subject_entity_id = ? AND predicate = ? AND ${inStream.sql} AND source_path = '_mcp_'
890
+ UNION ALL
891
+ SELECT id, event_id, valid_from, valid_to, scope, 1 AS retired FROM retired_facts
892
+ WHERE subject_entity_id = ? AND predicate = ? AND ${inStream.sql}`).all(lifecycleId, predicate, ...inStream.params, lifecycleId, predicate, ...inStream.params);
893
+ const table = (row) => (row.retired ? 'retired_facts' : 'facts');
894
+ const canonical = [];
895
+ for (const r of rows) {
896
+ if (r.event_id == null)
897
+ continue;
898
+ canonical.push({ ...r, event_id: r.event_id, pos: requireLedgerPosition(positions, r.event_id) });
899
+ }
900
+ canonical.sort((a, b) => a.pos - b.pos);
901
+ // The replay, in ledger order: the current row of each spelling, and the valid_to each closed row gets.
902
+ // Full equivalence with a fresh replay (K12): the fresh-apply path rejects
903
+ // an event whose time precedes the current fact it would close; the same
904
+ // rule applies to an already-projected chain, so an invalid ledger is
905
+ // rejected by BOTH upgrade replay and fresh rebuild, never repaired into
906
+ // a state a fresh replay could not produce. (Equal timestamps are legal.)
907
+ const current = new Map();
908
+ const closeAt = new Map();
909
+ for (const row of canonical) {
910
+ const named = closedBy.get(row.event_id);
911
+ const across = named === undefined ? undefined
912
+ : [...current.values()].find(r => r.scope !== row.scope && r.event_id === named);
913
+ for (const closed of [current.get(row.scope), across]) {
914
+ if (!closed)
915
+ continue;
916
+ if (Date.parse(row.valid_from) < Date.parse(closed.valid_from)) {
917
+ throw new Error(`ledger event ${row.event_id} (${row.valid_from}) is older than the current fact for the same subject+predicate (valid_from ${closed.valid_from}, event ${closed.event_id}); refusing to apply out-of-order`);
918
+ }
919
+ closeAt.set(closed.id, maxInstant(row.valid_from, closed.valid_from));
920
+ current.delete(closed.scope);
921
+ }
922
+ current.set(row.scope, row);
923
+ }
924
+ // Pass 1 (constraint-safe): apply all NON-NULL valid_to targets first, so
925
+ // at most one open row per spelling remains before the NULL (current) updates.
926
+ for (const row of canonical) {
927
+ const want = closeAt.get(row.id);
928
+ if (want !== undefined && (row.valid_to ?? null) !== want) {
929
+ (0, factTerms_1.prepareOnce)(db, `UPDATE ${table(row)} SET valid_to = ?, updated_at = ? WHERE id = ?`).run(want, want, row.id);
930
+ }
931
+ }
932
+ for (const spelling of (0, scope_1.scopeSpellings)(scope)) {
933
+ const chain = canonical.filter(row => row.scope === spelling);
934
+ if (chain.length === 0)
935
+ continue;
936
+ const last = current.get(spelling);
937
+ if (last && last.valid_to !== null) {
938
+ (0, factTerms_1.prepareOnce)(db, `UPDATE ${table(last)} SET valid_to = NULL, updated_at = ? WHERE id = ?`).run(last.valid_from, last.id);
939
+ }
940
+ // Non-event-backed open rows never stay current against canonical events.
941
+ const firstCanonicalFrom = chain[0].valid_from;
942
+ for (const r of rows) {
943
+ if (r.scope === spelling && r.event_id == null && r.valid_to === null) {
944
+ const want = maxInstant(firstCanonicalFrom, r.valid_from);
945
+ (0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET valid_to = ?, updated_at = ? WHERE id = ?').run(want, want, r.id);
946
+ }
947
+ }
948
+ }
949
+ }
950
+ /**
951
+ * The transaction applyEvent runs for an event that is already projected: re-home a fact still attached to a disposable entity,
952
+ * then reconcile its stream. A module-level function so replay wraps it once per connection (transactionOnce).
953
+ */
954
+ function reconcileProjectedEvent(db, ev, factId, rehome, positions, lifecycleId, closedBy) {
955
+ if (rehome) {
956
+ rehomeLifecycleFact(db, ev, factId, positions, lifecycleId);
957
+ }
958
+ // Do not preserve stale winner/valid_to flags merely because the event
959
+ // is already projected: recompute the chain from canonical ledger order
960
+ // so the upgraded cache is equivalent to a fresh replay of the ledger.
961
+ reconcileStreamCurrent(db, lifecycleId, ev.predicate, positions, (0, scope_1.normalizeScope)(ev.scope), closedBy);
962
+ }
963
+ /**
964
+ * Apply one memory event to SQLite inside a single transaction:
965
+ * dedup check → entity resolution → close current → insert fact → receipt.
966
+ * Idempotent by event id + digest; a reused id with a conflicting payload is
967
+ * explicit corruption; a failed insert can never leave a partial supersession.
968
+ */
969
+ function applyEvent(db, ev, positions, index, closedBy) {
970
+ validateLedgerEvent(ev);
971
+ const digest = eventDigest(ev);
972
+ const seen = (0, factTerms_1.prepareOnce)(db, 'SELECT digest FROM applied_ledger_events WHERE event_id = ?').get(ev.id);
973
+ if (seen && seen.digest !== digest) {
974
+ throw new Error(`ledger event id ${ev.id} was reused with a conflicting payload; refusing to apply (corruption)`);
975
+ }
976
+ if (RETIREMENT_KINDS.has(ev.kind)) {
977
+ applyRetirement(db, ev, digest, seen !== undefined);
978
+ return { applied: seen === undefined, factId: null };
979
+ }
980
+ // A retired fact is projected too: its row waits in retired_facts.
981
+ const liveRow = (0, factTerms_1.prepareOnce)(db, 'SELECT id, subject_entity_id FROM facts WHERE event_id = ?').get(ev.id);
982
+ const factRow = liveRow
983
+ ?? (0, factTerms_1.prepareOnce)(db, 'SELECT id, subject_entity_id FROM retired_facts WHERE event_id = ?').get(ev.id);
984
+ if (factRow) {
985
+ // Event already projected — but a pre-Gate1A index can hold this canonical
986
+ // fact attached to a DISPOSABLE note-derived entity (the base engine
987
+ // resolved subjects via generic lookupEntity). Re-home it onto the stable
988
+ // lifecycle-owned entity BEFORE readiness and later writes, or note
989
+ // reindexing would cascade-delete it (Astra A2 / Kimi R2 loss window).
990
+ // K12: reconciliation of the already-projected stream is CANONICAL — it
991
+ // needs the ledger event positions and refuses to guess without them
992
+ // (this branch is unreachable from the live write path, whose event ids
993
+ // are always fresh).
994
+ if (!positions) {
995
+ throw new Error(`canonical ledger order is unavailable while reconciling already-projected event ${ev.id}; refusing to guess — reconciliation requires replay context`);
996
+ }
997
+ const lifecycleId = resolveLifecycleEntity(db, ev.subject, ev.at, true, { preferEntityId: factRow.subject_entity_id, index });
998
+ const owner = (0, factTerms_1.prepareOnce)(db, 'SELECT source_note FROM entities WHERE id = ?').get(factRow.subject_entity_id);
999
+ // Re-home and stream reconciliation are ONE atomic unit (nested
1000
+ // transactions become savepoints): a rejected ledger (out-of-order chain,
1001
+ // absent-from-ledger contradiction) rolls the reconciliation back and
1002
+ // leaves the cache exactly as it was held — never partially reconciled.
1003
+ (0, statements_1.transactionOnce)(db, reconcileProjectedEvent)(ev, factRow.id, liveRow !== undefined && owner?.source_note !== exports.LIFECYCLE_NOTE_PATH, positions, lifecycleId, closedBy);
1004
+ // If it predates the digest table (migration 6), backfill the digest so
1005
+ // future replays can tell identical dedup from conflicts (the legacy
1006
+ // payload itself can no longer be verified).
1007
+ if (!seen) {
1008
+ (0, factTerms_1.prepareOnce)(db, 'INSERT OR IGNORE INTO applied_ledger_events (event_id, digest, applied_at) VALUES (?, ?, ?)')
1009
+ .run(ev.id, digest, new Date().toISOString());
1010
+ }
1011
+ return { applied: false, factId: null }; // byte-identical event already committed — safe dedup
1012
+ }
1013
+ // If a digest row exists but the fact is missing, the index lost the
1014
+ // projection (e.g. a partial wipe): re-apply — replay repairs the index.
1015
+ const tx = db.transaction(() => {
1016
+ // create=true always resolves or creates — never null here.
1017
+ const entityId = resolveLifecycleEntity(db, ev.subject, ev.at, true, { index });
1018
+ const closedFactIds = [];
1019
+ // The value this write replaces (the newest one it closes): the writer displacements it leaves open are read from it.
1020
+ let replaced = null;
1021
+ // The current fact under the event's own spelling, then the one it names under the other spelling of its slug.
1022
+ const scope = (0, scope_1.normalizeScope)(ev.scope);
1023
+ for (const cur of [currentFact(db, entityId, ev.predicate, scope), currentFactClosedAcrossSpellings(db, ev, entityId, scope)]) {
1024
+ if (!cur)
1025
+ continue;
1026
+ // Monotonicity validation: an event older than the current fact it would
1027
+ // close is arbitrary corrupt ordering — fail loudly, never silently
1028
+ // last-write-wins mask it. (Equal timestamps are legal: base-engine race
1029
+ // chains share millisecond timestamps.)
1030
+ if (ev.at < cur.valid_from) {
1031
+ throw new Error(`ledger event ${ev.id} (${ev.at}) is older than the current fact for the same subject+predicate (valid_from ${cur.valid_from}, fact id ${cur.id}); refusing to apply out-of-order`);
1032
+ }
1033
+ // K12 (replay context only): a current row whose event id is absent from
1034
+ // the ledger is an index-vs-ledger contradiction — closing it silently
1035
+ // would order canonical truth by guesswork. Fail explicitly instead.
1036
+ // (The live write path passes no positions and keeps its existing
1037
+ // behavior; the next replay of the ledger fails closed on the same row.)
1038
+ if (positions && cur.event_id != null && !positions.has(cur.event_id)) {
1039
+ requireLedgerPosition(positions, cur.event_id); // throws the explicit contradiction error
1040
+ }
1041
+ // Explicit reconciliation policy: 'supersede' closes the current fact by
1042
+ // intent. A 'store' finding an open current is NOT normal modern flow —
1043
+ // remember() writes supersede whenever it can see the current value —
1044
+ // so it can only arise from pre-Gate1A legacy chains: base-engine B2
1045
+ // races appended concurrent kind:store events, and the legacy cascade
1046
+ // loss window (Kimi R4) let a user-repair store be appended while the
1047
+ // prior projection was invisible. The prior fact is CLOSED (timestamped
1048
+ // with this event's time) and full history is preserved — nothing is
1049
+ // dropped or duplicated, and the digest/id conflict checks above still
1050
+ // fail loudly on contradictory reuse.
1051
+ (0, factTerms_1.prepareOnce)(db, `UPDATE facts SET valid_to = ?, updated_at = ? WHERE id = ?`).run(ev.at, ev.at, cur.id);
1052
+ closedFactIds.push(cur.id);
1053
+ if (replaced === null || cur.valid_from > replaced.validFrom) {
1054
+ replaced = { eventId: cur.event_id, object: cur.object_text, validFrom: cur.valid_from, source: cur.author };
1055
+ }
1056
+ }
1057
+ // A retired value that was current under this spelling is closed as if it had never been retired
1058
+ // (reconcileStreamCurrent replays it the same way), so restoring it later brings it back as history.
1059
+ // The stream is the scope's slug, not one spelling of it (scopeSpellings): a value retired under scope:alpha is
1060
+ // closed by a save under project:alpha too, or the retired one stays open and a later restore leaves two currents.
1061
+ for (const hidden of streamOpenRetiredFacts(db, entityId, ev.predicate, scope)) {
1062
+ if (ev.at < hidden.valid_from) {
1063
+ throw new Error(`ledger event ${ev.id} (${ev.at}) is older than the retired fact it would close (valid_from ${hidden.valid_from}, event ${hidden.event_id}); refusing to apply out-of-order`);
1064
+ }
1065
+ (0, factTerms_1.prepareOnce)(db, 'UPDATE retired_facts SET valid_to = ?, updated_at = ? WHERE id = ?').run(ev.at, ev.at, hidden.id);
1066
+ }
1067
+ const row = (0, factTerms_1.prepareOnce)(db, `INSERT INTO facts (subject_entity_id, predicate, object_text, source_path, confidence, valid_from, valid_to, updated_at, event_id, scope, author, statement_words)
1068
+ VALUES (?, ?, ?, '_mcp_', 1.0, ?, NULL, ?, ?, ?, ?, ?) RETURNING id`).get(entityId, ev.predicate, ev.object, ev.at, ev.at, ev.id, scope, ev.source, (0, factTerms_1.statementTerms)(ev.statement));
1069
+ // Who saved it, and the writer displacements it leaves open (writerConflicts.ts); a replay writes the same row.
1070
+ const writer = ev.writer ?? ev.source;
1071
+ (0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO fact_writers (event_id, writer, displaced) VALUES (?, ?, ?)')
1072
+ .run(ev.id, writer, JSON.stringify((0, writerConflicts_1.displacementsAfter)(db, replaced, { eventId: ev.id, writer, object: ev.object })));
1073
+ // Searchable by its words from the moment it exists: the index row is written by this transaction,
1074
+ // under the subject name a read shows (the entity's, which may be an earlier spelling of ev.subject).
1075
+ (0, factTerms_1.indexFactTerms)(db, row.id, scope, { subject: (0, factTerms_1.canonicalSubject)(db, entityId), predicate: ev.predicate, object: ev.object });
1076
+ (0, factTerms_1.prepareOnce)(db, `INSERT INTO write_events (note_path, action, summary, created_at, author, scope) VALUES ('_memory_ledger_', ?, ?, ?, ?, ?)`)
1077
+ .run(ev.kind, `${ev.subject} ${ev.predicate} → ${ev.object}${closedFactIds.length ? ` (closed fact${closedFactIds.length > 1 ? 's' : ''} ${closedFactIds.join(', ')})` : ''}`, ev.at, ev.source, ev.scope ?? 'global');
1078
+ (0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO applied_ledger_events (event_id, digest, applied_at) VALUES (?, ?, ?)')
1079
+ .run(ev.id, digest, ev.at);
1080
+ return row.id;
1081
+ });
1082
+ const factId = tx();
1083
+ return { applied: true, factId };
1084
+ }
1085
+ /** The retired fact that was the current value of a stream spelling when it was retired, if one still is. */
1086
+ function openRetiredFact(db, entityId, predicate, scope) {
1087
+ return (0, factTerms_1.prepareOnce)(db, `SELECT id, event_id, valid_from, object_text, scope FROM retired_facts
1088
+ WHERE subject_entity_id = ? AND predicate = ? AND scope = ? AND valid_to IS NULL
1089
+ ORDER BY valid_from DESC, id DESC LIMIT 1`).get(entityId, predicate, scope);
1090
+ }
1091
+ /**
1092
+ * The retired facts that were the current value of a stream spelling when they were retired, one per spelling at most,
1093
+ * newest first. The stream is the scope's slug (scopeSpellings): a save closes the retired current whatever spelling it
1094
+ * was retired under, exactly as it closes the live current across spellings (streamCurrentFacts). Closing it at save
1095
+ * time is what lets a later restore bring it back as history instead of as a second current value.
1096
+ */
1097
+ function streamOpenRetiredFacts(db, entityId, predicate, scope) {
1098
+ return (0, scope_1.scopeSpellings)(scope)
1099
+ .map(spelling => openRetiredFact(db, entityId, predicate, spelling))
1100
+ .filter((row) => row !== undefined)
1101
+ .sort((a, b) => (a.valid_from === b.valid_from ? b.id - a.id : a.valid_from < b.valid_from ? 1 : -1));
1102
+ }
1103
+ const FACT_COLUMNS = 'id, subject_entity_id, predicate, object_text, confidence, valid_from, valid_to, updated_at, event_id, scope, author, statement_words';
1104
+ /**
1105
+ * Moves a lifecycle fact's row out of `facts`, id and all, into retired_facts: no read of `facts` (current values,
1106
+ * history, as_of, search, the facts lane, memory_map, the subject lists) returns it after this, with nothing to filter.
1107
+ * False when no such row is in `facts` (it is already retired, or no fact has that event).
1108
+ */
1109
+ function moveFactOut(db, eventId, retire) {
1110
+ const row = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS} FROM facts WHERE event_id = ? AND source_path = ?`)
1111
+ .get(eventId, exports.LIFECYCLE_NOTE_PATH);
1112
+ if (!row)
1113
+ return false;
1114
+ (0, factTerms_1.prepareOnce)(db, `INSERT INTO retired_facts (${FACT_COLUMNS}, retire_event_id, retired_at, retired_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.subject_entity_id, row.predicate, row.object_text, row.confidence, row.valid_from, row.valid_to, row.updated_at, row.event_id, row.scope, row.author, row.statement_words, retire.id, retire.at, retire.source);
1115
+ (0, factTerms_1.unindexFactTerms)(db, row.id, row.scope, { subject: (0, factTerms_1.canonicalSubject)(db, row.subject_entity_id), predicate: row.predicate, object: row.object_text });
1116
+ (0, factTerms_1.prepareOnce)(db, 'DELETE FROM facts WHERE id = ?').run(row.id);
1117
+ return true;
1118
+ }
1119
+ /**
1120
+ * A restatement can supersede an identical value only because it kept different user words. When that current
1121
+ * restatement is retired, reopen the exact value it directly replaced. Different values remain historical: retiring a
1122
+ * correction is not itself another correction. This projection is derived from the save chain, so replay makes the
1123
+ * same choice without adding a second value to the retire event.
1124
+ */
1125
+ function reviveIdenticalPredecessor(db, eventId) {
1126
+ const retired = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS} FROM retired_facts WHERE event_id = ?`).get(eventId);
1127
+ if (!retired || retired.valid_to !== null)
1128
+ return; // Retiring history never changes the current value.
1129
+ const inStream = (0, scope_1.savedScopeSqlPredicate)((0, scope_1.normalizeScope)(retired.scope), 'scope');
1130
+ const prior = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS} FROM facts
1131
+ WHERE subject_entity_id = ? AND predicate = ? AND object_text = ? AND valid_to = ?
1132
+ AND source_path = ? AND ${inStream.sql}
1133
+ ORDER BY valid_from DESC, id DESC LIMIT 1`).get(retired.subject_entity_id, retired.predicate, retired.object_text, retired.valid_from, exports.LIFECYCLE_NOTE_PATH, ...inStream.params);
1134
+ if (!prior)
1135
+ return;
1136
+ (0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET valid_to = NULL, updated_at = ? WHERE id = ?').run(retired.updated_at, prior.id);
1137
+ }
1138
+ /** Puts a retired fact's row back into `facts` as it was. False when it is not retired. */
1139
+ function moveFactBack(db, eventId) {
1140
+ const row = (0, factTerms_1.prepareOnce)(db, `SELECT ${FACT_COLUMNS}, retired_at FROM retired_facts WHERE event_id = ?`).get(eventId);
1141
+ if (!row)
1142
+ return false;
1143
+ let validTo = row.valid_to;
1144
+ if (validTo === null) {
1145
+ // A later value anywhere in the same stream closes the retired one when it is saved (applyEvent), so this finds one
1146
+ // only in an index written otherwise; it is resolved the same way, the later value staying current. The stream is
1147
+ // the scope's slug, not one spelling of it (scopeSpellings): a value saved under project:alpha replaces a value
1148
+ // retired under scope:alpha, so looking only at the row's own spelling left two current values in one stream.
1149
+ const open = streamCurrentFacts(db, row.subject_entity_id, row.predicate, (0, scope_1.normalizeScope)(row.scope))[0];
1150
+ if (open) {
1151
+ if (open.object_text === row.object_text && open.valid_from < row.valid_from) {
1152
+ // The older identical value was revived when this restatement was retired. Restoring the restatement keeps it
1153
+ // as history instead of manufacturing a second current value or replacing the explicitly revived one.
1154
+ validTo = row.retired_at;
1155
+ }
1156
+ else if (open.valid_from < row.valid_from) {
1157
+ throw new Error(`cannot restore fact event ${eventId}: the current value (event ${open.event_id}) is older than it; rebuild the index from the ledger`);
1158
+ }
1159
+ else
1160
+ validTo = open.valid_from;
1161
+ }
1162
+ }
1163
+ (0, factTerms_1.prepareOnce)(db, `INSERT INTO facts (${FACT_COLUMNS}, source_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.subject_entity_id, row.predicate, row.object_text, row.confidence, row.valid_from, validTo, validTo === row.valid_to ? row.updated_at : validTo, row.event_id, row.scope, row.author, row.statement_words, exports.LIFECYCLE_NOTE_PATH);
1164
+ (0, factTerms_1.indexFactTerms)(db, row.id, row.scope, { subject: (0, factTerms_1.canonicalSubject)(db, row.subject_entity_id), predicate: row.predicate, object: row.object_text });
1165
+ (0, factTerms_1.prepareOnce)(db, 'DELETE FROM retired_facts WHERE id = ?').run(row.id);
1166
+ return true;
1167
+ }
1168
+ /**
1169
+ * Applies a retire or restore event. Every one is listed in fact_retirements, so a search never shows its ledger text.
1170
+ * A replay leaves the ones it already applied to settleRetirements, which sets each fact to the state its LAST
1171
+ * retirement event left it in; anything else moves the fact now. Moving a fact that is already where the event puts
1172
+ * it changes nothing (a retry, or a replay over an index that already holds it); a target that is no fact is
1173
+ * corruption.
1174
+ */
1175
+ function applyRetirement(db, ev, digest, alreadyApplied) {
1176
+ const target = ev.target.toUpperCase();
1177
+ db.transaction(() => {
1178
+ (0, factTerms_1.prepareOnce)(db, 'INSERT OR IGNORE INTO fact_retirements (event_id, kind, target, at, source) VALUES (?, ?, ?, ?, ?)')
1179
+ .run(ev.id, ev.kind, target, ev.at, ev.source);
1180
+ if (alreadyApplied)
1181
+ return;
1182
+ const moved = ev.kind === 'retire' ? moveFactOut(db, target, ev) : moveFactBack(db, target);
1183
+ if (ev.kind === 'retire')
1184
+ reviveIdenticalPredecessor(db, target);
1185
+ const already = ev.kind === 'retire'
1186
+ ? (0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM retired_facts WHERE event_id = ?').get(target)
1187
+ : (0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM facts WHERE event_id = ?').get(target);
1188
+ if (!moved && !already)
1189
+ throw new Error(`ledger corruption in event ${ev.id}: its target ${target} is no saved fact`);
1190
+ // The value stays out of the log, as it stays out of the event: memory_map lists this event.
1191
+ (0, factTerms_1.prepareOnce)(db, 'INSERT INTO write_events (note_path, action, summary, created_at, author, scope) VALUES (?, ?, ?, ?, ?, ?)')
1192
+ .run('_memory_ledger_', ev.kind, JSON.stringify({ id: `fact:${target}`, title: `${ev.subject} ${ev.predicate}` }), ev.at, ev.source, ev.scope ?? 'global');
1193
+ (0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO applied_ledger_events (event_id, digest, applied_at) VALUES (?, ?, ?)')
1194
+ .run(ev.id, digest, ev.at);
1195
+ })();
1196
+ }
1197
+ /**
1198
+ * After a replay: each fact the ledger retired or restored is where its last such event left it, and a fact retired in
1199
+ * the index that the ledger never retired (its event was removed by hand) is back. Facts move whole, so the chain a
1200
+ * replay just reconciled stays as it is.
1201
+ */
1202
+ function settleRetirements(db, events) {
1203
+ const last = new Map();
1204
+ for (const ev of events)
1205
+ if (RETIREMENT_KINDS.has(ev.kind))
1206
+ last.set(ev.target.toUpperCase(), ev);
1207
+ const retired = (0, factTerms_1.prepareOnce)(db, 'SELECT event_id FROM retired_facts').all();
1208
+ db.transaction(() => {
1209
+ for (const [target, ev] of last) {
1210
+ if (ev.kind === 'retire') {
1211
+ moveFactOut(db, target, ev);
1212
+ reviveIdenticalPredecessor(db, target);
1213
+ }
1214
+ else
1215
+ moveFactBack(db, target);
1216
+ }
1217
+ for (const { event_id } of retired)
1218
+ if (!last.has(event_id))
1219
+ moveFactBack(db, event_id);
1220
+ })();
1221
+ }
1222
+ /** Replay the ledger into facts. Idempotent by event id + digest. Throws on corruption. */
1223
+ async function replayLedger(db, vaultPath, ledgerRelPath, options = {}) {
1224
+ const filePath = resolveLedgerPath(vaultPath, ledgerRelPath); // shared confinement, before any read
1225
+ let content;
1226
+ let finalEventKnown = false;
1227
+ try {
1228
+ ({ content, finalEventKnown } = await readLedgerForReplay(vaultPath, filePath, eventId => (0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM applied_ledger_events WHERE event_id = ?').get(eventId) !== undefined, options.setTornTailAside === true));
1229
+ }
1230
+ catch (err) {
1231
+ if (err?.code === 'ENOENT') {
1232
+ // Only an absent ledger means "known-new empty vault: nothing recorded yet".
1233
+ // If the index already projects canonical events, the authoritative ledger
1234
+ // is GONE — silently treating that as an empty vault would let startup go
1235
+ // ready on a stale index whose source of truth no longer exists (and the
1236
+ // next rebuild would wipe every canonical fact). Fail closed instead;
1237
+ // recovery is restoring the ledger or deliberately wiping the derived
1238
+ // index.
1239
+ // ALL reliable evidence of prior canonical projection counts (Kimi K10 /
1240
+ // Astra A3.1): digest rows, event_id-backed facts (a base-v5 DB carries
1241
+ // these with an empty digest table — digests are only backfilled BY a
1242
+ // successful replay, which needs the ledger), and lifecycle write_events.
1243
+ const projected = (0, factTerms_1.prepareOnce)(db, 'SELECT COUNT(*) c FROM applied_ledger_events').get().c
1244
+ + (0, factTerms_1.prepareOnce)(db, 'SELECT COUNT(*) c FROM facts WHERE event_id IS NOT NULL').get().c
1245
+ + (0, factTerms_1.prepareOnce)(db, `SELECT COUNT(*) c FROM write_events WHERE note_path = '_memory_ledger_'`).get().c;
1246
+ if (projected > 0) {
1247
+ throw new Error(`ledger file is missing at ${filePath} but ${projected} canonical event(s) are already projected in the index; refusing to treat this as an empty vault — restore the ledger or deliberately rebuild from scratch`);
1248
+ }
1249
+ return 0;
1250
+ }
1251
+ // EISDIR / EACCES / EIO / anything else is a real I/O failure: block
1252
+ // readiness (B7 fail-closed), never "replayed 0 events".
1253
+ throw new Error(`ledger read failed (${err?.code ?? 'unknown'}) at ${filePath}: ${err?.message ?? err}`);
1254
+ }
1255
+ let applied = 0;
1256
+ const events = parseLedgerEvents(content, { finalEventKnown });
1257
+ // Canonical append order of the ledger (K12): the ONLY ordering input for
1258
+ // reconciliation of already-projected streams. Byte-identical duplicate ids
1259
+ // dedup and conflicting reuse throws in applyEvent; the first occurrence
1260
+ // fixes each event's canonical position.
1261
+ const positions = new Map();
1262
+ // Each event's closed_event: reconciling a stream replays the facts closed across spellings (reconcileStreamCurrent).
1263
+ const closedBy = new Map();
1264
+ for (let i = 0; i < events.length; i++) {
1265
+ if (positions.has(events[i].id))
1266
+ continue;
1267
+ positions.set(events[i].id, i);
1268
+ const closed = events[i].closedEvent;
1269
+ if (closed)
1270
+ closedBy.set(events[i].id, closed.toUpperCase());
1271
+ // A retirement names a fact event written before it.
1272
+ const target = events[i].target?.toUpperCase();
1273
+ if (target !== undefined) {
1274
+ const at = positions.get(target);
1275
+ if (at === undefined || RETIREMENT_KINDS.has(events[at].kind)) {
1276
+ throw new Error(`ledger corruption in event ${events[i].id}: its target ${target} is no fact event before it in the ledger`);
1277
+ }
1278
+ }
1279
+ }
1280
+ // GLOBAL preflight (K13): every event-backed canonical projection in the
1281
+ // index must be explainable by the COMPLETE parsed ledger — not just the
1282
+ // streams this replay happens to touch. The stream-scoped refusal (re-home
1283
+ // competition, per-stream reconcile) cannot see a stream whose ONLY event
1284
+ // was removed from the ledger; such an orphaned row would survive as a
1285
+ // served current fact with false ledger provenance, silently diverging from
1286
+ // a clean rebuild of the identical ledger. Fail closed BEFORE any cache
1287
+ // mutation, with the same explicit contradiction error as the stream-scoped
1288
+ // checks. Unrelated (non '_mcp_') and NULL-event rows are not touched; no
1289
+ // history is dropped or invented — the operator restores the ledger or
1290
+ // deliberately rebuilds the derived index.
1291
+ const projectedIds = (0, factTerms_1.prepareOnce)(db, `SELECT DISTINCT event_id FROM facts WHERE event_id IS NOT NULL AND source_path = '_mcp_'
1292
+ UNION SELECT event_id FROM retired_facts`).all();
1293
+ const orphaned = projectedIds
1294
+ .map(r => r.event_id)
1295
+ .filter(id => !positions.has(id.toUpperCase()));
1296
+ if (orphaned.length > 0) {
1297
+ const shown = orphaned.slice(0, 3).join(', ');
1298
+ throw new Error(`index holds lifecycle facts for ${orphaned.length} event(s) absent from the ledger (${shown}${orphaned.length > 3 ? ', …' : ''}); refusing to guess canonical order — restore the ledger or deliberately rebuild the derived index`);
1299
+ }
1300
+ const index = replayEntityIndex(db);
1301
+ for (const ev of events) {
1302
+ if (applyEvent(db, ev, positions, index, closedBy).applied)
1303
+ applied++;
1304
+ }
1305
+ settleRetirements(db, events);
1306
+ backfillStatementWords(db, events);
1307
+ return applied;
1308
+ }
1309
+ const STATEMENT_WORDS_BACKFILL = 'statement_words_backfilled';
1310
+ /**
1311
+ * Facts saved before migration 25 have no `statement_words`, and replay skips their events (applied_ledger_events holds
1312
+ * them), so the words are derived here once per database from the ledger event that saved each fact. This writes derived
1313
+ * index rows only; the ledger is read, never written. Facts saved without a statement stay null.
1314
+ */
1315
+ function backfillStatementWords(db, events) {
1316
+ if ((0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM index_state WHERE key = ?').get(STATEMENT_WORDS_BACKFILL) !== undefined)
1317
+ return;
1318
+ const live = (0, factTerms_1.prepareOnce)(db, 'UPDATE facts SET statement_words = ? WHERE event_id = ? AND statement_words IS NULL');
1319
+ const retired = (0, factTerms_1.prepareOnce)(db, 'UPDATE retired_facts SET statement_words = ? WHERE event_id = ? AND statement_words IS NULL');
1320
+ for (const ev of events) {
1321
+ const words = (0, factTerms_1.statementTerms)(ev.statement);
1322
+ if (words === null)
1323
+ continue;
1324
+ live.run(words, ev.id);
1325
+ retired.run(words, ev.id);
1326
+ }
1327
+ (0, factTerms_1.prepareOnce)(db, 'INSERT OR REPLACE INTO index_state (key, value) VALUES (?, ?)').run(STATEMENT_WORDS_BACKFILL, '1');
1328
+ }
1329
+ // In-process single-writer chain: serializes the full lifecycle decision +
1330
+ // ledger append + projection so concurrent remember() calls for the same (or
1331
+ // any) subject+predicate cannot interleave. Cross-process/crash durability
1332
+ // (OS-level writer lock, fsync barriers) is deliberately Gate 1B scope.
1333
+ let writeChain = Promise.resolve();
1334
+ function serializeLifecycleWrite(fn) {
1335
+ const result = writeChain.then(fn, fn);
1336
+ writeChain = result.then(() => undefined, () => undefined);
1337
+ return result;
1338
+ }
1339
+ /** The principal write verb. Ledger-first, then SQLite. Idempotent on identical current value. */
1340
+ async function remember(db, vaultPath, ledgerRelPath, args) {
1341
+ return serializeLifecycleWrite(() => rememberCritical(db, vaultPath, ledgerRelPath, args));
1342
+ }
1343
+ // A ledger larger than this is read and parsed whole on every repeat check, and nothing about it is kept.
1344
+ const MAX_REPEAT_LEDGER_BYTES = 256 * 1024;
1345
+ let repeatIndex;
1346
+ const sha256 = (bytes) => crypto_1.default.createHash('sha256').update(bytes).digest('hex');
1347
+ /** Keeps `events` as the index of the ledger's bytes, when a later append can be parsed on its own (the bytes end in a line break). */
1348
+ function keepRepeatIndex(file, content, events) {
1349
+ const endsInLine = content.byteLength === 0 || content[content.byteLength - 1] === 0x0a;
1350
+ repeatIndex = endsInLine ? { file, length: content.byteLength, digest: sha256(content), events } : undefined;
1351
+ }
1352
+ /**
1353
+ * The first event with this id, as parseLedgerEvents over the whole ledger would give it. When the ledger still starts with
1354
+ * the bytes it was parsed from, a write has only appended whole events since, so only those are parsed. Any other change,
1355
+ * and any error in the appended part, parses the whole ledger, which raises what it always raised.
1356
+ */
1357
+ function ledgerEventNamed(file, content, id) {
1358
+ if (content.byteLength > MAX_REPEAT_LEDGER_BYTES) {
1359
+ repeatIndex = undefined;
1360
+ return parseLedgerEvents(content.toString('utf8')).find(e => e.id === id);
1361
+ }
1362
+ const held = repeatIndex;
1363
+ if (held && held.file === file && held.length <= content.byteLength && sha256(content.subarray(0, held.length)) === held.digest) {
1364
+ if (held.length === content.byteLength)
1365
+ return held.events.get(id);
1366
+ const appended = content.subarray(held.length).toString('utf8');
1367
+ // The appended part must hold an event header, and must not open with a field line, which would belong to the last
1368
+ // event already held. Then it parses exactly as it would at the end of the whole ledger.
1369
+ if (/^## mem_/im.test(appended) && !appended.startsWith('- ')) {
1370
+ try {
1371
+ const events = held.events;
1372
+ for (const e of parseLedgerEvents(appended))
1373
+ if (!events.has(e.id))
1374
+ events.set(e.id, e);
1375
+ keepRepeatIndex(file, content, events);
1376
+ return events.get(id);
1377
+ }
1378
+ catch {
1379
+ // Parsed whole below, so the error is the one the whole ledger gives.
1380
+ }
1381
+ }
1382
+ }
1383
+ const events = new Map();
1384
+ for (const e of parseLedgerEvents(content.toString('utf8')))
1385
+ if (!events.has(e.id))
1386
+ events.set(e.id, e);
1387
+ keepRepeatIndex(file, content, events);
1388
+ return events.get(id);
1389
+ }
1390
+ /**
1391
+ * Does this write repeat the event behind the current fact, in everything the caller supplied?
1392
+ *
1393
+ * A caller that supplies the user's words must repeat them exactly as the current event holds them:
1394
+ * the same statement, and the subject spelled the same. The entity lookup that found the current fact
1395
+ * folds case and Unicode form, so on its own it would take `exactsubject` for a repeat of
1396
+ * `ExactSubject` and the new spelling would never reach the ledger. A different sentence or spelling
1397
+ * for the same value is still the user's own input, so it is written as a new event.
1398
+ *
1399
+ * A write without a statement (save_memory) has no words to keep and dedupes on the value alone.
1400
+ * When the event cannot be read (a legacy fact written without one, an absent ledger) nothing can be
1401
+ * confirmed, so the write goes ahead rather than assuming it is a repeat.
1402
+ */
1403
+ async function repeatsCurrentEvent(vaultPath, ledgerRelPath, eventId, args) {
1404
+ if (args.statement === undefined)
1405
+ return true;
1406
+ if (!eventId)
1407
+ return false;
1408
+ const file = resolveLedgerPath(vaultPath, ledgerRelPath);
1409
+ let content;
1410
+ try {
1411
+ content = await promises_1.default.readFile(file);
1412
+ }
1413
+ catch (err) {
1414
+ if (err?.code === 'ENOENT')
1415
+ return false;
1416
+ throw err;
1417
+ }
1418
+ // Always read first: the bytes say whether the events held from the last check are still the ledger's.
1419
+ const event = ledgerEventNamed(file, content, eventId.toUpperCase());
1420
+ return event !== undefined && event.subject === args.subject && event.statement === args.statement;
1421
+ }
1422
+ /**
1423
+ * RENOR_SECRET_GUARD (default ON): refuse a write whose text looks like a secret, before the ledger or the
1424
+ * index is touched. The subject, predicate and object are scanned as one line, "subject predicate: object",
1425
+ * because a credential is usually stated across them (predicate wifi_password, object the password) and
1426
+ * neither field carries the cue on its own.
1427
+ */
1428
+ async function refuseSecrets(args) {
1429
+ try {
1430
+ await (0, secretGuard_1.assertStorable)([
1431
+ { field: 'statement', text: args.statement },
1432
+ { field: 'subject, predicate and object', text: `${args.subject} ${args.predicate}: ${args.object}` },
1433
+ { field: 'source', text: args.source },
1434
+ ]);
1435
+ }
1436
+ catch (err) {
1437
+ // A refusal is the caller's input, like every rejection above it, so it leaves as a LifecycleInputError (4xx).
1438
+ // A guard that could not decide is not: it stays the plain Error it is.
1439
+ if (err instanceof secretGuard_1.SecretRefusedError && err.reason !== 'unavailable')
1440
+ throw new LifecycleInputError(err.field, err.message);
1441
+ throw err;
1442
+ }
1443
+ }
1444
+ async function rememberCritical(db, vaultPath, ledgerRelPath, sent) {
1445
+ // What is stored is the source's name, not what was sent (sourceName.ts): a path or a URL is cut down to what it ends in,
1446
+ // a long name is cut and control characters are dropped, before anything is judged, so the checks below and the secret
1447
+ // guard see the form that is stored and neither a long path nor a stray newline loses the whole save. A source with no
1448
+ // name left in it (blank, or only separators or controls) is stored as the default, like one that was not sent. A source
1449
+ // that is not a string is left for validateField. The import prefix is an import's alone (savedSource).
1450
+ const args = typeof sent.source === 'string' ? { ...sent, source: (0, sourceName_1.savedSource)(sent.source) } : sent;
1451
+ validateLifecycleInput(args);
1452
+ refuseUnstorableFields(args);
1453
+ if ((0, secretGuard_1.secretGuardEnabled)())
1454
+ await refuseSecrets(args);
1455
+ const at = new Date().toISOString();
1456
+ const scope = (0, scope_1.normalizeScope)(args.scope);
1457
+ // The source the event stores, and the writer the save is recorded under: the authenticated identity when the caller
1458
+ // passed one, else the source itself. The event names the writer only where it differs from the source.
1459
+ const source = args.source || 'agent';
1460
+ const writer = args.writer ?? source;
1461
+ // Current-value decision is made against the lifecycle-owned entity only,
1462
+ // so a same-named disposable note entity can never influence or inherit it.
1463
+ // Strict identity: a legacy split whose open currents disagree fails closed HERE,
1464
+ // before the ledger append below, so the canonical bytes stay untouched.
1465
+ const entityId = resolveLifecycleEntity(db, args.subject, at, false, { strict: true });
1466
+ // The current value of the scope's stream, whatever spelling of the scope it was saved with (streamCurrentFacts).
1467
+ const currents = entityId ? streamCurrentFacts(db, entityId, args.predicate, scope) : [];
1468
+ // nochange only when nothing the caller said is new: the scope holds this value and no other AND, when they
1469
+ // supply the user's words, the same subject spelling and statement as the current event (see repeatsCurrentEvent).
1470
+ // A writer that owes a confirmation (its value was replaced) is recorded even when it repeats the current value: that
1471
+ // save is the confirmation, and only the ledger keeps it.
1472
+ if (currents.length > 0 && currents.every(c => c.object_text === args.object)
1473
+ && !(currents[0].event_id !== null && (0, writerConflicts_1.hasOpenDisplacementBy)(db, currents[0].event_id, writer))
1474
+ && await repeatsCurrentEvent(vaultPath, ledgerRelPath, currents[0].event_id, args)) {
1475
+ return { ok: true, eventId: 'none', action: 'nochange', subject: args.subject, predicate: args.predicate, closed: null,
1476
+ created: { factId: currents[0].id, object: args.object, validFrom: 'existing' }, ledger: resolveLedgerPath(vaultPath, ledgerRelPath) };
1477
+ }
1478
+ // What the write closes, as applyEvent closes it: the current value saved under the write's own spelling, and the
1479
+ // one saved under the other spelling of the slug, which the event names in closed_event (a value with no event,
1480
+ // written before the ledger, cannot be named: it stays current beside the new one, and a read names the conflict).
1481
+ // A retired value keeps its place in the stream, so the write closes the retired current of either spelling too; only
1482
+ // the one under the OTHER spelling is named in closed_event, because reconcileStreamCurrent closes the own-spelling
1483
+ // one by order but cannot close a cross-spelling one without the name. The own-spelling retired value stays unnamed
1484
+ // (the write is a 'store' when no live value was replaced), exactly as it was before scopes became one slug.
1485
+ const own = currents.find(c => c.scope === scope);
1486
+ const across = currents.find(c => c.scope !== scope && c.event_id != null);
1487
+ const hiddens = entityId ? streamOpenRetiredFacts(db, entityId, args.predicate, scope) : [];
1488
+ const named = across ?? hiddens.find(h => h.scope !== scope) ?? own;
1489
+ // Pre-publish monotonicity validation (Kimi K11): the SAME rule the replay
1490
+ // enforces, but checked BEFORE the ledger append. Without this, a transient
1491
+ // backward clock step (NTP correction, manual clock change) made the live
1492
+ // write throw AFTER the append — leaving the self-rejected event in the
1493
+ // ledger and poisoning every future replay/rebuild/startup with the
1494
+ // identical error. A write that would be rejected must never reach the
1495
+ // ledger: the canonical bytes stay byte-identical on rejection.
1496
+ // A retired value that was current under the write's spelling is closed by it too (applyEvent), by the same rule,
1497
+ // and the stream is the scope's slug, so the retired current of either spelling is checked (streamOpenRetiredFacts).
1498
+ // The refusal lasts while the clock stays behind the newest of them, so it names that instant: the wait the user has.
1499
+ const behind = [own, across, ...hiddens].filter((c) => c !== undefined && at < c.valid_from);
1500
+ if (behind.length > 0) {
1501
+ const wait = behind.reduce((latest, c) => (c.valid_from > latest.valid_from ? c : latest));
1502
+ throw new Error(`lifecycle write rejected without publishing: event time ${at} is older than the current fact (valid_from ${wait.valid_from}, fact id ${wait.id}) for the same subject+predicate. Writes are accepted again once the clock reaches ${wait.valid_from}. The ledger was not modified`);
1503
+ }
1504
+ const id = newEventId();
1505
+ const kind = named ? 'supersede' : 'store';
1506
+ const ev = {
1507
+ id, at, kind,
1508
+ subject: args.subject, predicate: args.predicate, object: args.object,
1509
+ closedEvent: named?.event_id ?? null,
1510
+ source,
1511
+ scope,
1512
+ ...(args.statement !== undefined ? { statement: args.statement } : {}),
1513
+ ...(writer !== source ? { writer } : {}),
1514
+ };
1515
+ // Durable first: if the SQLite apply fails, the event is still in the ledger
1516
+ // and the next replay repairs the index. The apply itself is one transaction,
1517
+ // so it either fully lands or leaves no partial supersession behind.
1518
+ const ledger = await appendLedgerEvent(vaultPath, ledgerRelPath, ev);
1519
+ const { factId } = applyEvent(db, ev);
1520
+ return {
1521
+ ok: true, eventId: id, action: kind, subject: args.subject, predicate: args.predicate,
1522
+ closed: named ? { factId: named.id, object: named.object_text, validTo: at } : null,
1523
+ ...(across && own ? { alsoClosed: { factId: own.id, object: own.object_text, validTo: at } } : {}),
1524
+ created: { factId, object: args.object, validFrom: at }, ledger,
1525
+ };
1526
+ }
1527
+ const FACT_ID_RE = /^fact:(mem_[A-Za-z0-9]+)$/i;
1528
+ /** The event id in a fact id (`fact:MEM_…`), or null for any other memory id. */
1529
+ function factEventId(id) {
1530
+ return FACT_ID_RE.exec(id)?.[1].toUpperCase() ?? null;
1531
+ }
1532
+ function factTitle(subject, predicate, object) {
1533
+ return `${subject} ${predicate}: ${object}`;
1534
+ }
1535
+ /**
1536
+ * Takes one saved fact out of recall, reversibly: a retire event goes into the ledger (who asked, when, and which fact),
1537
+ * then the fact's row leaves `facts` (moveFactOut). The ledger keeps the fact's own event, so its history is never lost
1538
+ * and restoreFact brings it back. Retiring the current value revives only the directly replaced value when its object is
1539
+ * identical (a differently worded restatement); a genuinely different replaced value stays historical.
1540
+ */
1541
+ async function retireFact(db, vaultPath, ledgerRelPath, id, source) {
1542
+ return serializeLifecycleWrite(async () => {
1543
+ const target = factEventId(id);
1544
+ if (target === null)
1545
+ throw new LifecycleInputError('id', `not a fact id: ${JSON.stringify(id)} (a fact's id is fact: and its eventId)`, 'invalid_argument');
1546
+ const row = (0, factTerms_1.prepareOnce)(db, `SELECT f.predicate, f.object_text, f.valid_to, f.scope, e.canonical_name AS subject, e.source_note
1547
+ FROM facts f JOIN entities e ON e.id = f.subject_entity_id
1548
+ WHERE f.event_id = ? AND f.source_path = ?`).get(target, exports.LIFECYCLE_NOTE_PATH);
1549
+ if (!row) {
1550
+ if ((0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM retired_facts WHERE event_id = ?').get(target)) {
1551
+ throw new LifecycleInputError('id', 'that fact is already retired (restore_memory brings it back)', 'memory_already_retired');
1552
+ }
1553
+ throw new LifecycleInputError('id', 'no saved fact has that id', 'memory_not_found');
1554
+ }
1555
+ // A startup replay moves every lifecycle fact onto its lifecycle subject, so this only refuses a fact that has not been.
1556
+ if (row.source_note !== exports.LIFECYCLE_NOTE_PATH)
1557
+ throw new Error(`fact ${target} is not attached to its lifecycle subject yet; restart the engine to replay the ledger first`);
1558
+ const ev = {
1559
+ id: newEventId(), at: new Date().toISOString(), kind: 'retire',
1560
+ subject: row.subject, predicate: row.predicate, object: '', closedEvent: null,
1561
+ source: (source === undefined ? '' : (0, sourceName_1.savedSource)(source)) || 'agent', scope: row.scope, target,
1562
+ };
1563
+ await appendLedgerEvent(vaultPath, ledgerRelPath, ev);
1564
+ applyEvent(db, ev);
1565
+ return { ok: true, id: `fact:${target}`, action: 'retired', title: factTitle(row.subject, row.predicate, row.object_text), at: ev.at, eventId: ev.id, current: row.valid_to === null };
1566
+ });
1567
+ }
1568
+ /**
1569
+ * Brings a retired fact back exactly as it was: the same row, value and dates. It is the current value again unless a
1570
+ * value saved for the same subject, predicate and scope after it replaced it, as that save would have replaced it had it
1571
+ * never been retired; it is then history. RENOR_SECRET_GUARD (default ON) screens it as a save.
1572
+ */
1573
+ async function restoreFact(db, vaultPath, ledgerRelPath, id, source, validateAfterWrite) {
1574
+ return serializeLifecycleWrite(async () => {
1575
+ const target = factEventId(id);
1576
+ if (target === null)
1577
+ throw new LifecycleInputError('id', `not a fact id: ${JSON.stringify(id)} (a fact's id is fact: and its eventId)`, 'invalid_argument');
1578
+ const row = (0, factTerms_1.prepareOnce)(db, `SELECT r.predicate, r.object_text, r.scope, e.canonical_name AS subject
1579
+ FROM retired_facts r JOIN entities e ON e.id = r.subject_entity_id WHERE r.event_id = ?`).get(target);
1580
+ if (!row) {
1581
+ if ((0, factTerms_1.prepareOnce)(db, 'SELECT 1 FROM facts WHERE event_id = ?').get(target))
1582
+ throw new LifecycleInputError('id', 'that fact is not retired', 'memory_not_found');
1583
+ throw new LifecycleInputError('id', 'no retired fact has that id', 'memory_not_found');
1584
+ }
1585
+ if ((0, secretGuard_1.secretGuardEnabled)())
1586
+ await refuseSecrets({ subject: row.subject, predicate: row.predicate, object: row.object_text });
1587
+ const ev = {
1588
+ id: newEventId(), at: new Date().toISOString(), kind: 'restore',
1589
+ subject: row.subject, predicate: row.predicate, object: '', closedEvent: null,
1590
+ source: (source === undefined ? '' : (0, sourceName_1.savedSource)(source)) || 'agent', scope: row.scope, target,
1591
+ };
1592
+ const ledgerPath = resolveLedgerPath(vaultPath, ledgerRelPath);
1593
+ const priorBytes = await promises_1.default.stat(ledgerPath).then((stat) => stat.size, () => 0);
1594
+ await appendLedgerEvent(vaultPath, ledgerRelPath, ev);
1595
+ db.exec('SAVEPOINT restore_storage_limit');
1596
+ try {
1597
+ applyEvent(db, ev);
1598
+ await validateAfterWrite?.();
1599
+ db.exec('RELEASE SAVEPOINT restore_storage_limit');
1600
+ }
1601
+ catch (err) {
1602
+ db.exec('ROLLBACK TO SAVEPOINT restore_storage_limit; RELEASE SAVEPOINT restore_storage_limit');
1603
+ await promises_1.default.truncate(ledgerPath, priorBytes);
1604
+ throw err;
1605
+ }
1606
+ const back = (0, factTerms_1.prepareOnce)(db, 'SELECT valid_to FROM facts WHERE event_id = ?').get(target);
1607
+ return { ok: true, id: `fact:${target}`, action: 'restored', title: factTitle(row.subject, row.predicate, row.object_text), at: ev.at, eventId: ev.id, current: back.valid_to === null };
1608
+ });
1609
+ }
1610
+ /** Every retired fact, newest retirement first. */
1611
+ function listRetiredFacts(db) {
1612
+ const rows = (0, factTerms_1.prepareOnce)(db, `SELECT r.event_id, e.canonical_name AS subject, r.predicate, r.object_text, r.scope, r.valid_from, r.retired_at, r.retired_by
1613
+ FROM retired_facts r JOIN entities e ON e.id = r.subject_entity_id
1614
+ ORDER BY r.retired_at DESC, r.id DESC`).all();
1615
+ return rows.map(r => ({
1616
+ id: `fact:${r.event_id}`, eventId: r.event_id, subject: r.subject, predicate: r.predicate, object: r.object_text,
1617
+ scope: r.scope, title: factTitle(r.subject, r.predicate, r.object_text), validFrom: r.valid_from,
1618
+ retiredAt: r.retired_at, retiredBy: r.retired_by,
1619
+ }));
1620
+ }
1621
+ /**
1622
+ * The one scope a read is restricted to: the explicit scope, normalized (an empty one means global), or
1623
+ * null for every scope. Every read path derives its filter here, so none can answer for a wider scope than
1624
+ * it was asked about.
1625
+ */
1626
+ function scopeFilterFor(scope) {
1627
+ return scope !== undefined ? (0, scope_1.normalizeScope)(scope) : null;
1628
+ }
1629
+ /**
1630
+ * Names of the subjects that lifecycle facts are about (current or superseded), alphabetical. Given a scope,
1631
+ * only subjects that have a fact in that scope (any spelling of it, savedScopeSqlPredicate): a scoped read must
1632
+ * not learn of subjects that live elsewhere.
1633
+ */
1634
+ function listLifecycleSubjects(db, opts = {}) {
1635
+ const scope = scopeFilterFor(opts.scope);
1636
+ const inScope = scope === null ? null : (0, scope_1.savedScopeSqlPredicate)(scope, 'f.scope');
1637
+ const rows = (0, factTerms_1.prepareOnce)(db, `SELECT DISTINCT e.canonical_name AS name FROM entities e
1638
+ WHERE e.source_note = ?
1639
+ AND EXISTS (SELECT 1 FROM facts f WHERE f.subject_entity_id = e.id${inScope ? ` AND ${inScope.sql}` : ''})
1640
+ ORDER BY e.canonical_name COLLATE NOCASE`).all(exports.LIFECYCLE_NOTE_PATH, ...(inScope?.params ?? []));
1641
+ return rows.map(r => r.name);
1642
+ }
1643
+ // A partial or path match on a lifecycle row names another subject: only the identity match in factsFor may name one.
1644
+ const NOT_LIFECYCLE_ENTITY = { sql: 'entities.source_note IS NOT ?', params: [exports.LIFECYCLE_NOTE_PATH] };
1645
+ /**
1646
+ * The newest workspace observation in the scope being read. Lifecycle writes keep their stamps in facts and
1647
+ * retired_facts; note indexing keeps its clock in note_index_clock, independently of rows that reindexing or
1648
+ * retirement can delete. Their maximum survives retire/restore and backward-clock reindexing, including a
1649
+ * workspace with no lifecycle writes. Index observations are not content dates: a future-dated note still keeps
1650
+ * its valid_from/valid_to window. The migration seeds existing indexes from the indexer's updated_at stamps.
1651
+ * Read once until the next database write, including a write by another connection (readUntilWrite).
1652
+ */
1653
+ function workspaceReadInstant(db, scope) {
1654
+ const cacheKey = `workspace-read-instant${scope === null ? '' : `:${scope}`}`;
1655
+ return (0, readCache_1.readUntilWrite)(db, cacheKey, () => {
1656
+ const inScope = scope === null ? null : (0, scope_1.savedScopeSqlPredicate)(scope, 'scope');
1657
+ // Each observation source carries its own scope predicate and parameter spellings.
1658
+ const scopeSql = inScope ? ` AND ${inScope.sql}` : '';
1659
+ const scopeParams = inScope ? inScope.params : [];
1660
+ return (0, factTerms_1.prepareOnce)(db, `SELECT MAX(newest) AS newest FROM (`
1661
+ + ` SELECT valid_from AS newest FROM facts WHERE source_path = ?${scopeSql}`
1662
+ + ` UNION ALL`
1663
+ + ` SELECT valid_from AS newest FROM retired_facts${inScope ? ` WHERE ${inScope.sql}` : ''}`
1664
+ + ` UNION ALL`
1665
+ + ` SELECT observed_at AS newest FROM note_index_clock${inScope ? ` WHERE ${inScope.sql}` : ''}`
1666
+ + `)`).get(exports.LIFECYCLE_NOTE_PATH, ...scopeParams, ...scopeParams, ...scopeParams)?.newest ?? null;
1667
+ });
1668
+ }
1669
+ /**
1670
+ * The instant a note-derived fact is judged at by the default read: the wall clock, or the newest workspace observation
1671
+ * when that is newer. The workspace has already observed that instant, so the read judges the content-date window
1672
+ * at the observation rather than losing the note to a regressed machine clock (workspaceReadInstant).
1673
+ */
1674
+ function noteReadInstant(db, scope) {
1675
+ const at = new Date().toISOString();
1676
+ const newest = workspaceReadInstant(db, scope);
1677
+ return newest !== null && newest > at ? newest : at;
1678
+ }
1679
+ /**
1680
+ * The default read's currency rule, keyed on the row's origin. A lifecycle fact
1681
+ * (source_path '_mcp_') is current exactly when it is open — the same rule the
1682
+ * write path uses (streamCurrentFacts), so reads and writes agree after a
1683
+ * backward clock step. A note-derived fact keeps its content-date window: a
1684
+ * date in the future still means "not yet", but the window is bounded below by
1685
+ * the newest workspace observation (workspaceReadInstant), so a machine clock that
1686
+ * stepped backwards cannot hide a note the vault already holds.
1687
+ * Two `?` parameters, the read instant, are the note-derived window bounds.
1688
+ */
1689
+ const DEFAULT_CURRENT_SQL = `((source_path = '${exports.LIFECYCLE_NOTE_PATH}' AND valid_to IS NULL)`
1690
+ + ` OR ((source_path IS NULL OR source_path <> '${exports.LIFECYCLE_NOTE_PATH}')`
1691
+ + ` AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?)))`;
1692
+ /**
1693
+ * Current facts only by default; asOf gives point-in-time; includeHistory gives
1694
+ * everything. The default read judges a lifecycle fact by open rows (see
1695
+ * DEFAULT_CURRENT_SQL), so a backward clock step cannot hide the current value;
1696
+ * a note-derived fact is judged at the wall clock clamped up to the newest
1697
+ * workspace observation (workspaceReadInstant), so the same step cannot hide it
1698
+ * either; asOf keeps the exact point-in-time window. Lifecycle-owned facts are
1699
+ * authoritative: if a lifecycle entity exists for the name, its facts are
1700
+ * returned; otherwise the generic (possibly note-derived) entity lookup is used.
1701
+ *
1702
+ * When a name still matches SEVERAL lifecycle entities (a split a pre-fix write path
1703
+ * could create, which nothing reconciles in place), facts from ALL of them are
1704
+ * returned. A read must not throw — a startup or dashboard read would become an outage
1705
+ * — and it must not hide half the memory behind a silent pick, so the ambiguity is
1706
+ * disclosed instead. New writes refuse the same state (LegacyIdentityAmbiguityError).
1707
+ */
1708
+ function factsFor(db, entityName, opts = {}) {
1709
+ let entityIds = [];
1710
+ for (const alias of (0, queryHelpers_1.identityNames)(entityName)) {
1711
+ entityIds = lifecycleEntityMatches(db, alias).map(match => match.id);
1712
+ if (entityIds.length > 0)
1713
+ break;
1714
+ }
1715
+ if (entityIds.length === 0) {
1716
+ const entity = (0, entityLookup_1.lookupEntity)(db, entityName, NOT_LIFECYCLE_ENTITY);
1717
+ if (!entity)
1718
+ return [];
1719
+ entityIds = [entity.id];
1720
+ }
1721
+ const placeholders = entityIds.map(() => '?').join(',');
1722
+ // Without scope: return across all scopes (current MVP read behavior).
1723
+ // With scope: filter to that normalized scope, under any spelling of it (savedScopeSqlPredicate).
1724
+ const scopeFilter = scopeFilterFor(opts.scope);
1725
+ const inScope = scopeFilter ? (0, scope_1.savedScopeSqlPredicate)(scopeFilter, 'scope') : null;
1726
+ if (opts.includeHistory) {
1727
+ if (inScope) {
1728
+ return db.prepare(`SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${inScope.sql} ORDER BY valid_from DESC, id DESC`).all(...entityIds, ...inScope.params);
1729
+ }
1730
+ return db.prepare(`SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) ORDER BY valid_from DESC, id DESC`).all(...entityIds);
1731
+ }
1732
+ // S1: asOf is an INSTANT. It is normalized to the canonical format so the
1733
+ // SQL textual comparison is exactly the instant comparison for every
1734
+ // supported input (including offsets); garbage fails with a clear error
1735
+ // instead of a silently wrong answer. An explicit point-in-time read keeps
1736
+ // the window unchanged: it asks what was true AT that instant, not what is
1737
+ // true now.
1738
+ if (opts.asOf !== undefined) {
1739
+ const t = toCanonicalInstant(opts.asOf);
1740
+ if (inScope) {
1741
+ return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${inScope.sql} AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY valid_from DESC, id DESC`).all(...entityIds, ...inScope.params, t, t);
1742
+ }
1743
+ return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY valid_from DESC, id DESC`).all(...entityIds, t, t);
1744
+ }
1745
+ // The default read judges a lifecycle fact (source_path '_mcp_') the way the
1746
+ // write path does (streamCurrentFacts): current exactly when it is open, so a
1747
+ // backward clock step can never hide the value the user already gave — the
1748
+ // repeat-twice failure. Note-derived facts keep their content-date window — a
1749
+ // future date still means "not yet" — bounded below by the newest workspace
1750
+ // observation, so the same step cannot hide a note the vault already holds either.
1751
+ const readInstant = noteReadInstant(db, scopeFilter);
1752
+ if (inScope) {
1753
+ return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${inScope.sql} AND ${DEFAULT_CURRENT_SQL} ORDER BY valid_from DESC, id DESC`).all(...entityIds, ...inScope.params, readInstant, readInstant);
1754
+ }
1755
+ return (0, factTerms_1.prepareOnce)(db, `SELECT * FROM facts WHERE subject_entity_id IN (${placeholders}) AND ${DEFAULT_CURRENT_SQL} ORDER BY valid_from DESC, id DESC`).all(...entityIds, readInstant, readInstant);
1756
+ }