@tpsdev-ai/flair 0.21.0 → 0.22.1

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 (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1811 -221
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +455 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +167 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
@@ -0,0 +1,455 @@
1
+ /**
2
+ * runner.ts — the migration cycle orchestrator. One call to
3
+ * `runMigrationCycle()` = one full pass: detect pending migrations → (if
4
+ * any) one shared async pre-hash → per-migration pre-flight ladder →
5
+ * risk-scoped snapshot → throttled batches with per-row markers →
6
+ * risk-scoped completion gate → post-hash comparison → ledger OrgEvent →
7
+ * state-file update → snapshot prune.
8
+ *
9
+ * Halt-don't-brick everywhere (flair#695 invariant
10
+ * II): this function NEVER throws — every failure mode (space-blocked,
11
+ * snapshot-failed, pre-hash-failed, gate-failed, an unexpected exception)
12
+ * resolves to a halted/failed progress entry + (where applicable) a ledger
13
+ * event, and the loop moves on to the next migration rather than
14
+ * propagating. The caller (resources/MigrationBoot.ts) is the boot path —
15
+ * an exception escaping this function would risk destabilizing an
16
+ * already-serving process, which is exactly what invariant II forbids.
17
+ */
18
+ import { acquireMigrationLock } from "./lock.js";
19
+ import { checkSpace, defaultSpaceProbe } from "./space.js";
20
+ import { createMigrationSnapshot, pruneMigrationSnapshots } from "./snapshot.js";
21
+ import { createContentOnlyExport } from "./export.js";
22
+ import { computeCorpusEnvelope } from "./envelope.js";
23
+ import { hashSourceFields, sourceFieldsFor } from "./source-fields.js";
24
+ import { postureFor } from "./risk-policy.js";
25
+ import { readMigrationState, writeMigrationStateEntry, isShortCircuited, defaultStatePath } from "./state.js";
26
+ import { writeLedgerEvent } from "./ledger.js";
27
+ import { setCyclePhase, setMigrationProgress, seedIdleProgress } from "./progress.js";
28
+ import { shouldRegisterSyntheticMigration } from "./synthetic-test-migration.js";
29
+ import { join } from "node:path";
30
+ /**
31
+ * Test-only batch-throttle override, mirroring space.ts's
32
+ * FLAIR_MIGRATION_TEST_FREE_BYTES pattern (real Harper is a spawned child
33
+ * process in the integration tests — an env var propagated at spawn time is
34
+ * the only injection lever available there). Used by
35
+ * test/integration/migrations-resume-after-kill.test.ts to WIDEN the
36
+ * per-batch delay so the migration's running phase spans seconds instead of
37
+ * a few hundred milliseconds, making "kill it mid-flight" deterministic
38
+ * rather than a poll-timing race (the CI failure mode this fixes: on a slow
39
+ * shared runner, the whole running phase fit between two health polls).
40
+ *
41
+ * DOUBLE-GATED: honored ONLY when the synthetic CI-migration gate
42
+ * (FLAIR_ENABLE_TEST_MIGRATIONS === "1", the same exact-match opt-in that
43
+ * admits the synthetic migration itself) is active — a stray env var on a
44
+ * production deployment (gate off) can never alter the real 100ms throttle.
45
+ */
46
+ export const TEST_BATCH_DELAY_ENV = "FLAIR_MIGRATION_TEST_BATCH_DELAY_MS";
47
+ function resolveTestBatchDelayMs(env = process.env) {
48
+ if (!shouldRegisterSyntheticMigration(env))
49
+ return undefined;
50
+ const raw = env[TEST_BATCH_DELAY_ENV];
51
+ if (raw === undefined)
52
+ return undefined;
53
+ const n = Number(raw);
54
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
55
+ }
56
+ function resolveDeps(deps) {
57
+ const dataDir = deps.dataDir;
58
+ return {
59
+ registry: deps.registry,
60
+ getTable: deps.getTable,
61
+ dataDir,
62
+ runningVersion: deps.runningVersion,
63
+ statePath: deps.statePath ?? defaultStatePath(dataDir),
64
+ lockPath: deps.lockPath ?? join(dataDir, ".migrations", "lock"),
65
+ snapshotRoot: deps.snapshotRoot ?? join(dataDir, ".migrations", "snapshots"),
66
+ exportRoot: deps.exportRoot ?? join(dataDir, ".migrations", "exports"),
67
+ spaceProbe: deps.spaceProbe ?? defaultSpaceProbe,
68
+ now: deps.now ?? (() => new Date()),
69
+ sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
70
+ ledgerDeps: deps.ledgerDeps ?? {},
71
+ batchDelayMs: deps.batchDelayMs ?? resolveTestBatchDelayMs() ?? 100,
72
+ initiator: deps.initiator ?? "auto",
73
+ };
74
+ }
75
+ // ─── space/snapshot sizing heuristics (overridable via RunnerDeps in future if ever needed) ──
76
+ function estimateSnapshotBytes(scope, pendingCount) {
77
+ switch (scope) {
78
+ case "metadata-only":
79
+ return 4096;
80
+ case "schema+metadata":
81
+ return 8192;
82
+ case "pointers+metadata":
83
+ return 256 * Math.max(pendingCount, 1);
84
+ }
85
+ }
86
+ /**
87
+ * Kern verdict: "Space estimate includes HNSW rebuild overhead (re-embed
88
+ * rewrites every vector → index rebuild can be 2–3× raw vector size)." Uses
89
+ * a fixed per-row estimate (~6KB raw nomic-embed-text vector * 3) — a real
90
+ * per-corpus vector-size probe would be more precise but isn't needed for a
91
+ * conservative pre-flight gate; overestimating just makes the halt fire
92
+ * slightly earlier, which is the safe direction.
93
+ */
94
+ function estimateWorkingSetBytes(riskClass, pendingCount) {
95
+ switch (riskClass) {
96
+ case "derived-only":
97
+ return 18 * 1024 * pendingCount; // ~3x a 768-dim float32 vector (~6KB) per touched row
98
+ case "schema-additive":
99
+ return 0; // additive-only — no row rewrites of source fields
100
+ case "content-transform":
101
+ return 4 * 1024 * pendingCount; // old+new temporarily coexist for touched rows
102
+ }
103
+ }
104
+ /**
105
+ * Runs one full migration cycle. Safe to call repeatedly (e.g. once per
106
+ * boot) — a cycle with nothing pending returns immediately without taking
107
+ * the lock's file-write cost or computing the envelope.
108
+ */
109
+ export async function runMigrationCycle(rawDeps) {
110
+ const deps = resolveDeps(rawDeps);
111
+ const allIds = deps.registry.list().map((m) => m.id);
112
+ seedIdleProgress(allIds);
113
+ let lock;
114
+ try {
115
+ lock = acquireMigrationLock({ lockPath: deps.lockPath });
116
+ }
117
+ catch (err) {
118
+ // Even the lock's own filesystem calls must never crash the boot path.
119
+ return { ran: false, reason: `lock error: ${err?.message ?? String(err)}` };
120
+ }
121
+ if (!lock.acquired) {
122
+ return { ran: false, reason: `single-flight: ${lock.reason}` };
123
+ }
124
+ try {
125
+ return await runCycleLocked(deps, lock);
126
+ }
127
+ catch (err) {
128
+ // Belt-and-suspenders: runCycleLocked already halts individual
129
+ // migrations internally, but an error escaping it entirely (e.g. a bug)
130
+ // must still never propagate out of the boot path.
131
+ setCyclePhase("done", `unexpected cycle error: ${err?.message ?? String(err)}`);
132
+ return { ran: false, reason: `unexpected error: ${err?.message ?? String(err)}` };
133
+ }
134
+ finally {
135
+ lock.release();
136
+ }
137
+ }
138
+ async function runCycleLocked(deps, lock) {
139
+ const state = readMigrationState(deps.statePath);
140
+ const candidates = [];
141
+ for (const migration of deps.registry.list()) {
142
+ if (isShortCircuited(state, migration.id, deps.runningVersion)) {
143
+ setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "completed" });
144
+ continue;
145
+ }
146
+ setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "checking" });
147
+ let pending;
148
+ try {
149
+ pending = await migration.detect();
150
+ }
151
+ catch (err) {
152
+ setMigrationProgress({
153
+ id: migration.id,
154
+ rowsDone: 0,
155
+ rowsRemaining: 0,
156
+ state: "failed",
157
+ reason: `detect() threw: ${err?.message ?? String(err)}`,
158
+ });
159
+ continue;
160
+ }
161
+ if (!pending) {
162
+ setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "completed" });
163
+ continue;
164
+ }
165
+ candidates.push(migration);
166
+ }
167
+ if (candidates.length === 0) {
168
+ return { ran: false, reason: "nothing pending" };
169
+ }
170
+ // ── ONE shared async pre-hash for the whole cycle (K&S: computed once,
171
+ // after ready, before any migration's first write; failure → halt all
172
+ // pending candidates rather than guessing which ones are safe). ──
173
+ setCyclePhase("pre-hash");
174
+ let envelope;
175
+ try {
176
+ envelope = await computeCorpusEnvelope(deps.getTable, deps.now);
177
+ }
178
+ catch (err) {
179
+ const reason = `pre-flight integrity check failed: ${err?.message ?? String(err)}`;
180
+ for (const migration of candidates) {
181
+ setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "halted", reason });
182
+ }
183
+ setCyclePhase("done", reason);
184
+ return { ran: false, reason };
185
+ }
186
+ setCyclePhase("running");
187
+ for (const migration of candidates) {
188
+ await runOneMigration(migration, envelope, deps, lock);
189
+ }
190
+ setCyclePhase("done");
191
+ return { ran: true };
192
+ }
193
+ async function haltMigration(migration, reason, ctx) {
194
+ const { deps, startedAt, rowsDone, rowsRemaining, hashEnvelopeMatch, state } = ctx;
195
+ const endedAt = deps.now().toISOString();
196
+ setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining, state: "halted", reason });
197
+ const fromVersion = state[migration.id]?.completedAtVersion ?? "unknown";
198
+ const evt = {
199
+ migrationId: migration.id,
200
+ initiator: deps.initiator,
201
+ fromVersion,
202
+ toVersion: deps.runningVersion,
203
+ scope: "full",
204
+ startedAt,
205
+ endedAt,
206
+ outcome: "halted",
207
+ rowsProcessed: rowsDone,
208
+ rowsRemaining,
209
+ hashEnvelopeMatch,
210
+ error: reason,
211
+ };
212
+ try {
213
+ await writeLedgerEvent(evt, deps.ledgerDeps);
214
+ }
215
+ catch {
216
+ // A ledger-write failure must not compound the halt — the migration is
217
+ // already safely stopped on the pre-migration shape either way.
218
+ }
219
+ // Deliberately NOT calling writeMigrationStateEntry with lastOutcome
220
+ // "halted" as completedAtVersion-bearing — isShortCircuited() only fires
221
+ // on lastOutcome === "success", so a halted migration is retried on every
222
+ // subsequent boot until it clears, never permanently stuck.
223
+ try {
224
+ writeMigrationStateEntry(deps.statePath, migration.id, {
225
+ lastOutcome: "halted",
226
+ reason,
227
+ rowsProcessed: rowsDone,
228
+ rowsRemaining,
229
+ });
230
+ }
231
+ catch {
232
+ /* best-effort — the in-memory progress + ledger event already recorded the halt */
233
+ }
234
+ }
235
+ async function runOneMigration(migration, envelope, deps, lock) {
236
+ const startedAt = deps.now().toISOString();
237
+ const state = readMigrationState(deps.statePath);
238
+ const posture = postureFor(migration.riskClass);
239
+ setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "preflight" });
240
+ // ── Ladder step 1: space check (with HNSW/working-set overhead) ──
241
+ let initialRemaining;
242
+ try {
243
+ initialRemaining = await migration.countPending();
244
+ }
245
+ catch (err) {
246
+ await haltMigration(migration, `countPending() threw: ${err?.message ?? String(err)}`, {
247
+ deps,
248
+ startedAt,
249
+ rowsDone: 0,
250
+ rowsRemaining: 0,
251
+ hashEnvelopeMatch: null,
252
+ state,
253
+ });
254
+ return;
255
+ }
256
+ const estSnapshot = estimateSnapshotBytes(posture.snapshotScope, initialRemaining);
257
+ const estWorkingSet = estimateWorkingSetBytes(migration.riskClass, initialRemaining);
258
+ let space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
259
+ if (!space.ok) {
260
+ // ── Ladder step 2: prune snapshots, then retry ──
261
+ pruneMigrationSnapshots(deps.snapshotRoot);
262
+ space = checkSpace({ dataDir: deps.dataDir, estimatedSnapshotBytes: estSnapshot, estimatedWorkingSetBytes: estWorkingSet }, deps.spaceProbe);
263
+ }
264
+ if (!space.ok) {
265
+ // ── Ladder step 5: no safe path → halt-don't-brick ──
266
+ await haltMigration(migration, `blocked on disk: ${space.reason}`, {
267
+ deps,
268
+ startedAt,
269
+ rowsDone: 0,
270
+ rowsRemaining: initialRemaining,
271
+ hashEnvelopeMatch: null,
272
+ state,
273
+ });
274
+ return;
275
+ }
276
+ // ── Ladder step 3 (+ step 4 fallback): risk-scoped snapshot, or a
277
+ // content-only export if the snapshot mechanism itself fails. ──
278
+ setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: initialRemaining, state: "snapshotting" });
279
+ try {
280
+ createMigrationSnapshot({
281
+ migrationId: migration.id,
282
+ scope: posture.snapshotScope,
283
+ rowCounts: { [migration.affectsTables[0] ?? "Memory"]: initialRemaining },
284
+ fromVersion: state[migration.id]?.completedAtVersion ?? "unknown",
285
+ toVersion: deps.runningVersion,
286
+ }, { snapshotRoot: deps.snapshotRoot, now: deps.now });
287
+ }
288
+ catch (snapErr) {
289
+ try {
290
+ const table = migration.affectsTables[0] ?? "Memory";
291
+ const accessor = deps.getTable(table);
292
+ const rows = [];
293
+ for await (const row of accessor.search({}))
294
+ rows.push(row);
295
+ createContentOnlyExport({ migrationId: migration.id, table, rows, fromVersion: state[migration.id]?.completedAtVersion ?? "unknown" }, { exportRoot: deps.exportRoot, now: deps.now });
296
+ }
297
+ catch (expErr) {
298
+ await haltMigration(migration, `snapshot failed (${snapErr?.message ?? String(snapErr)}) and the content-only export fallback also failed (${expErr?.message ?? String(expErr)})`, { deps, startedAt, rowsDone: 0, rowsRemaining: initialRemaining, hashEnvelopeMatch: null, state });
299
+ return;
300
+ }
301
+ }
302
+ // ── Execution: throttled batches, resumable via per-row markers ──
303
+ setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: initialRemaining, state: "running" });
304
+ let rowsDone = 0;
305
+ const oldRowHashes = new Map();
306
+ const newRowIds = [];
307
+ try {
308
+ for (;;) {
309
+ const result = await migration.run(posture.batchSize);
310
+ rowsDone += result.processed;
311
+ if (result.oldRowSourceHashes) {
312
+ for (const [k, v] of Object.entries(result.oldRowSourceHashes))
313
+ oldRowHashes.set(k, v);
314
+ }
315
+ if (result.newRowIds)
316
+ newRowIds.push(...result.newRowIds);
317
+ setMigrationProgress({
318
+ id: migration.id,
319
+ rowsDone,
320
+ rowsRemaining: Math.max(0, initialRemaining - rowsDone),
321
+ state: "running",
322
+ });
323
+ lock.touch();
324
+ if (result.processed === 0)
325
+ break;
326
+ await deps.sleep(deps.batchDelayMs);
327
+ }
328
+ }
329
+ catch (err) {
330
+ await haltMigration(migration, `run() threw mid-batch: ${err?.message ?? String(err)}`, {
331
+ deps,
332
+ startedAt,
333
+ rowsDone,
334
+ rowsRemaining: Math.max(0, initialRemaining - rowsDone),
335
+ hashEnvelopeMatch: null,
336
+ state,
337
+ });
338
+ return;
339
+ }
340
+ // ── Completion gate — strictness per risk class ──
341
+ setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completing" });
342
+ let finalRemaining;
343
+ try {
344
+ finalRemaining = await migration.countPending();
345
+ }
346
+ catch (err) {
347
+ await haltMigration(migration, `post-batch countPending() threw: ${err?.message ?? String(err)}`, {
348
+ deps,
349
+ startedAt,
350
+ rowsDone,
351
+ rowsRemaining: 0,
352
+ hashEnvelopeMatch: null,
353
+ state,
354
+ });
355
+ return;
356
+ }
357
+ let hashEnvelopeMatch = null;
358
+ let gateOk = finalRemaining === 0;
359
+ if (gateOk && posture.gate === "count+full-envelope") {
360
+ try {
361
+ const postEnvelope = await computeCorpusEnvelope(deps.getTable, deps.now);
362
+ hashEnvelopeMatch = postEnvelope.corpusHash === envelope.corpusHash;
363
+ gateOk = gateOk && hashEnvelopeMatch;
364
+ }
365
+ catch (err) {
366
+ hashEnvelopeMatch = false;
367
+ gateOk = false;
368
+ await haltMigration(migration, `post-hash computation failed: ${err?.message ?? String(err)}`, {
369
+ deps,
370
+ startedAt,
371
+ rowsDone,
372
+ rowsRemaining: finalRemaining,
373
+ hashEnvelopeMatch,
374
+ state,
375
+ });
376
+ return;
377
+ }
378
+ }
379
+ else if (gateOk && posture.gate === "count+old-row-envelope+new-row-presence") {
380
+ let allMatch = true;
381
+ for (const [key, preHash] of oldRowHashes) {
382
+ const sepIdx = key.indexOf(":");
383
+ const table = key.slice(0, sepIdx);
384
+ const id = key.slice(sepIdx + 1);
385
+ let current = null;
386
+ try {
387
+ current = await deps.getTable(table).get(id);
388
+ }
389
+ catch {
390
+ allMatch = false;
391
+ break;
392
+ }
393
+ const currentHash = current ? hashSourceFields(current, sourceFieldsFor(table)) : null;
394
+ if (currentHash !== preHash) {
395
+ allMatch = false;
396
+ break;
397
+ }
398
+ }
399
+ const presenceOk = newRowIds.length === oldRowHashes.size;
400
+ hashEnvelopeMatch = allMatch && presenceOk;
401
+ gateOk = gateOk && hashEnvelopeMatch;
402
+ }
403
+ // "count+marker" (derived-only): gateOk already reflects finalRemaining === 0;
404
+ // no content-hash comparison — recomputable by definition (Kern verdict).
405
+ const endedAt = deps.now().toISOString();
406
+ if (!gateOk) {
407
+ const reasonParts = [`rowsRemaining=${finalRemaining}`];
408
+ if (hashEnvelopeMatch === false)
409
+ reasonParts.push("hash envelope mismatch");
410
+ await haltMigration(migration, `completion gate failed: ${reasonParts.join(", ")}`, {
411
+ deps,
412
+ startedAt,
413
+ rowsDone,
414
+ rowsRemaining: finalRemaining,
415
+ hashEnvelopeMatch,
416
+ state,
417
+ });
418
+ return;
419
+ }
420
+ // ── Success: ledger → state → prune ──
421
+ const evt = {
422
+ migrationId: migration.id,
423
+ initiator: deps.initiator,
424
+ fromVersion: state[migration.id]?.completedAtVersion ?? "unknown",
425
+ toVersion: deps.runningVersion,
426
+ scope: "full",
427
+ startedAt,
428
+ endedAt,
429
+ outcome: "success",
430
+ rowsProcessed: rowsDone,
431
+ rowsRemaining: 0,
432
+ hashEnvelopeMatch,
433
+ };
434
+ try {
435
+ await writeLedgerEvent(evt, deps.ledgerDeps);
436
+ }
437
+ catch {
438
+ // A ledger-write failure after a genuinely successful migration must not
439
+ // flip the outcome to failed — the data-safety work already completed.
440
+ }
441
+ try {
442
+ writeMigrationStateEntry(deps.statePath, migration.id, {
443
+ completedAtVersion: deps.runningVersion,
444
+ completedAt: endedAt,
445
+ lastOutcome: "success",
446
+ rowsProcessed: rowsDone,
447
+ rowsRemaining: 0,
448
+ });
449
+ }
450
+ catch {
451
+ /* best-effort — worst case this migration's detect() runs (cheaply) again next boot */
452
+ }
453
+ pruneMigrationSnapshots(deps.snapshotRoot);
454
+ setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completed" });
455
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * snapshot.ts — risk-scoped pre-flight snapshot (ladder step 3) + retention
3
+ * pruning (ladder... well, invariant III's retention rule, applied after a
4
+ * successful completion gate).
5
+ *
6
+ * This is a LOGICAL, in-process snapshot — deliberately NOT the same
7
+ * mechanism as `flair snapshot create` (src/cli.ts's createDataSnapshot),
8
+ * which takes a byte-exact tar.gz of the whole data directory but requires
9
+ * STOPPING Harper first for consistency. The zero-touch runner executes
10
+ * INSIDE the live-serving Harper process (boot-keyed, server ready first —
11
+ * see resources/MigrationBoot.ts), so stopping the server to snapshot
12
+ * itself is a non-starter; this writes a small, risk-class-scoped JSON/JSONL
13
+ * manifest instead, using the same file-safety discipline (0700 dirs, 0600
14
+ * files, stat-verified — see dir-safety.ts) as the rest of Flair's snapshot
15
+ * machinery.
16
+ *
17
+ * Scope per risk class (flair#695, space-pressure
18
+ * step 3 / resources/migrations/risk-policy.ts):
19
+ * - metadata-only: manifest only (row counts, versions) — no corpus
20
+ * snapshot needed; derived data is recomputable by
21
+ * definition (invariant I).
22
+ * - schema+metadata: manifest + a schema summary (table/field names,
23
+ * never row data) — no row rewrites happen, so
24
+ * there's nothing to snapshot beyond proving what
25
+ * shape existed before the migration.
26
+ * - pointers+metadata: manifest + a JSONL of POINTER fields only (id,
27
+ * supersedes, validFrom, validTo) for touched rows
28
+ * — never content. Content-transform migrations use
29
+ * native supersession (old rows retained in-store,
30
+ * never mutated), so the corpus itself doesn't need
31
+ * snapshotting; only the pointer state that proves
32
+ * which rows were touched and how they chain.
33
+ */
34
+ import { statSync, readdirSync, rmSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { ensureSecureDir, writeSecureFile } from "./dir-safety.js";
37
+ function sanitizeIdPart(s) {
38
+ return s.replace(/[^a-zA-Z0-9_-]/g, "_");
39
+ }
40
+ /** Creates the risk-scoped snapshot under `deps.snapshotRoot/<migrationId>-<iso>/`. */
41
+ export function createMigrationSnapshot(opts, deps) {
42
+ const now = deps.now();
43
+ const iso = now.toISOString().replace(/[:.]/g, "-");
44
+ const dir = join(deps.snapshotRoot, `${sanitizeIdPart(opts.migrationId)}-${iso}`);
45
+ ensureSecureDir(dir);
46
+ const manifest = {
47
+ migrationId: opts.migrationId,
48
+ scope: opts.scope,
49
+ createdAt: now.toISOString(),
50
+ fromVersion: opts.fromVersion,
51
+ toVersion: opts.toVersion,
52
+ rowCounts: opts.rowCounts,
53
+ };
54
+ const manifestPath = join(dir, "manifest.json");
55
+ writeSecureFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", dir);
56
+ let bytes = statSync(manifestPath).size;
57
+ if (opts.scope === "schema+metadata" && opts.schema) {
58
+ const p = join(dir, "schema.json");
59
+ writeSecureFile(p, JSON.stringify(opts.schema, null, 2) + "\n", dir);
60
+ bytes += statSync(p).size;
61
+ }
62
+ if (opts.scope === "pointers+metadata" && opts.pointers) {
63
+ const p = join(dir, "pointers.jsonl");
64
+ const body = opts.pointers.map((r) => JSON.stringify(r)).join("\n") + (opts.pointers.length ? "\n" : "");
65
+ writeSecureFile(p, body, dir);
66
+ bytes += statSync(p).size;
67
+ }
68
+ return { dir, manifestPath, bytes };
69
+ }
70
+ function listMigrationSnapshotDirs(snapshotRoot) {
71
+ let names;
72
+ try {
73
+ names = readdirSync(snapshotRoot);
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ const out = [];
79
+ for (const name of names) {
80
+ const path = join(snapshotRoot, name);
81
+ try {
82
+ const st = statSync(path);
83
+ if (st.isDirectory())
84
+ out.push({ path, mtimeMs: st.mtimeMs });
85
+ }
86
+ catch {
87
+ /* vanished between readdir and stat — skip */
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ export const DEFAULT_SNAPSHOT_KEEP_LAST = 3;
93
+ export const DEFAULT_SNAPSHOT_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
94
+ /**
95
+ * Retention (Kern verdict): "keep-last-3 floor AND 30-day age ceiling, more
96
+ * permissive wins." Implemented as a UNION of both rules — a snapshot
97
+ * survives if EITHER "among the 3 most recent" OR "younger than 30 days" is
98
+ * true; only a snapshot that fails BOTH gets pruned. This is the
99
+ * more-permissive (keeps more, never less) interpretation: the last-3 floor
100
+ * alone would prune a 2-day-old 4th snapshot; the union keeps it.
101
+ */
102
+ export function pruneMigrationSnapshots(snapshotRoot, opts = {}) {
103
+ const keepLast = opts.keepLast ?? DEFAULT_SNAPSHOT_KEEP_LAST;
104
+ const maxAgeMs = opts.maxAgeMs ?? DEFAULT_SNAPSHOT_MAX_AGE_MS;
105
+ const nowMs = (opts.now ?? Date.now)();
106
+ const all = listMigrationSnapshotDirs(snapshotRoot).sort((a, b) => b.mtimeMs - a.mtimeMs);
107
+ const keep = new Set();
108
+ for (const e of all.slice(0, keepLast))
109
+ keep.add(e.path);
110
+ for (const e of all) {
111
+ if (nowMs - e.mtimeMs <= maxAgeMs)
112
+ keep.add(e.path);
113
+ }
114
+ const removed = [];
115
+ for (const e of all) {
116
+ if (!keep.has(e.path)) {
117
+ try {
118
+ rmSync(e.path, { recursive: true, force: true });
119
+ removed.push(e.path);
120
+ }
121
+ catch {
122
+ /* best-effort — a prune failure must never fail an otherwise-successful migration */
123
+ }
124
+ }
125
+ }
126
+ return removed;
127
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * source-fields.ts — SOURCE_FIELDS enumerations + the integrity envelope
3
+ * hash (flair#695 invariant I + Kern verdict).
4
+ *
5
+ * "SOURCE_FIELDS constant per resource — enumerate source vs derived per
6
+ * table, mechanically checked by the hash gate." The exact enumerations
7
+ * below are copied verbatim from the Kern verdict (2026-07-11):
8
+ *
9
+ * Memory source = content, agentId, durability, visibility, tags,
10
+ * createdAt, supersedes
11
+ * (derived: embedding, embeddingModel, retrievalCount, usageCount,
12
+ * lastRetrieved, provenance, originatorInstanceId)
13
+ * Relationship source = subject, predicate, object, agentId, validFrom,
14
+ * validTo
15
+ *
16
+ * "Pre+post content-hash ENVELOPE ... full-corpus hash-of-source-field-hashes
17
+ * computed before first write and after completion; must match." — hashCorpus
18
+ * below is exactly that: a per-row hash over ONLY the source fields, combined
19
+ * (sorted by id, so iteration order never perturbs the result) into one
20
+ * corpus-level hash.
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ export const MEMORY_SOURCE_FIELDS = [
24
+ "content",
25
+ "agentId",
26
+ "durability",
27
+ "visibility",
28
+ "tags",
29
+ "createdAt",
30
+ "supersedes",
31
+ ];
32
+ /** Not hashed — recomputable/mutable-by-design; listed for documentation + the mechanical-check tests. */
33
+ export const MEMORY_DERIVED_FIELDS = [
34
+ "embedding",
35
+ "embeddingModel",
36
+ "retrievalCount",
37
+ "usageCount",
38
+ "lastRetrieved",
39
+ "provenance",
40
+ "originatorInstanceId",
41
+ ];
42
+ export const RELATIONSHIP_SOURCE_FIELDS = [
43
+ "subject",
44
+ "predicate",
45
+ "object",
46
+ "agentId",
47
+ "validFrom",
48
+ "validTo",
49
+ ];
50
+ export function sourceFieldsFor(table) {
51
+ return table === "Memory" ? MEMORY_SOURCE_FIELDS : RELATIONSHIP_SOURCE_FIELDS;
52
+ }
53
+ /**
54
+ * Deterministic canonicalization: sorts object keys recursively so the same
55
+ * logical value always serializes identically regardless of property
56
+ * insertion order (Harper doesn't guarantee field order is stable across
57
+ * reads). `undefined` normalizes to `null` — a missing/undefined source
58
+ * field and an explicit `null` must hash identically (both mean "no value"),
59
+ * otherwise a legitimate no-op re-fetch could flip the corpus hash.
60
+ */
61
+ function canonicalize(value) {
62
+ if (Array.isArray(value))
63
+ return value.map(canonicalize);
64
+ if (value && typeof value === "object") {
65
+ const out = {};
66
+ for (const k of Object.keys(value).sort()) {
67
+ out[k] = canonicalize(value[k]);
68
+ }
69
+ return out;
70
+ }
71
+ return value === undefined ? null : value;
72
+ }
73
+ /** Hash of ONLY the listed fields off one row. Order-independent, missing-vs-null-independent. */
74
+ export function hashSourceFields(row, fields) {
75
+ const picked = {};
76
+ for (const f of fields)
77
+ picked[f] = row[f] ?? null;
78
+ return createHash("sha256").update(JSON.stringify(canonicalize(picked))).digest("hex");
79
+ }
80
+ /**
81
+ * Hash-of-source-field-hashes over a corpus (or any subset of rows a caller
82
+ * passes in — the content-transform gate calls this over just the touched
83
+ * old rows, not the whole table). Rows are sorted by `id` before combining
84
+ * so the result is independent of read/iteration order — two reads of the
85
+ * same logical corpus in different order must hash identically.
86
+ */
87
+ export function hashCorpus(rows, fields) {
88
+ const perRow = rows
89
+ .map((r) => ({ id: String(r.id ?? ""), h: hashSourceFields(r, fields) }))
90
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
91
+ const combined = perRow.map((r) => `${r.id}:${r.h}`).join("|");
92
+ return createHash("sha256").update(combined).digest("hex");
93
+ }