@tpsdev-ai/flair 0.29.0 → 0.31.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 (117) hide show
  1. package/README.md +194 -377
  2. package/dist/cli-shim.cjs +1 -1
  3. package/dist/cli.js +1567 -366
  4. package/dist/deploy.js +241 -43
  5. package/dist/fabric-upgrade.js +77 -24
  6. package/dist/federation/scheduler.js +500 -0
  7. package/dist/fleet-verify.js +3 -3
  8. package/dist/install/clients.js +111 -53
  9. package/dist/lib/mcp-enable.js +2 -2
  10. package/dist/lib/mcp-spec.js +128 -0
  11. package/dist/lib/safe-snapshot-extract.js +231 -0
  12. package/dist/lib/scheduler-platform.js +128 -0
  13. package/dist/lib/xml-escape.js +54 -0
  14. package/dist/rem/scheduler.js +212 -55
  15. package/dist/rem/snapshot.js +13 -0
  16. package/dist/replication-convergence.js +505 -0
  17. package/dist/resources/A2AAdapter.js +1 -1
  18. package/dist/resources/Admin.js +1 -1
  19. package/dist/resources/AdminConnectors.js +1 -1
  20. package/dist/resources/AdminDashboard.js +1 -1
  21. package/dist/resources/AdminIdp.js +1 -1
  22. package/dist/resources/AdminInstance.js +1 -1
  23. package/dist/resources/AdminMemory.js +1 -1
  24. package/dist/resources/AdminPrincipals.js +1 -1
  25. package/dist/resources/Agent.js +1 -1
  26. package/dist/resources/AgentCard.js +1 -1
  27. package/dist/resources/AgentSeed.js +1 -1
  28. package/dist/resources/AttentionQuery.js +1 -1
  29. package/dist/resources/Credential.js +1 -1
  30. package/dist/resources/Federation.js +1 -1
  31. package/dist/resources/Instance.js +1 -1
  32. package/dist/resources/Integration.js +1 -1
  33. package/dist/resources/MCPClientMetadata.js +1 -1
  34. package/dist/resources/Memory.js +1 -1
  35. package/dist/resources/MemoryBootstrap.js +8 -9
  36. package/dist/resources/MemoryCandidate.js +168 -0
  37. package/dist/resources/MemoryConsolidate.js +1 -1
  38. package/dist/resources/MemoryDedupStats.js +1 -1
  39. package/dist/resources/MemoryFeed.js +1 -1
  40. package/dist/resources/MemoryGrant.js +1 -1
  41. package/dist/resources/MemoryMaintenance.js +1 -1
  42. package/dist/resources/MemoryReflect.js +2 -2
  43. package/dist/resources/MemoryReindex.js +1 -1
  44. package/dist/resources/MemoryUsage.js +1 -1
  45. package/dist/resources/OAuth.js +1 -1
  46. package/dist/resources/OAuthClient.js +1 -1
  47. package/dist/resources/OrgEvent.js +1 -1
  48. package/dist/resources/OrgEventCatchup.js +1 -1
  49. package/dist/resources/OrgEventMaintenance.js +1 -1
  50. package/dist/resources/PairingToken.js +1 -1
  51. package/dist/resources/Peer.js +1 -1
  52. package/dist/resources/Presence.js +3 -3
  53. package/dist/resources/RecordUsage.js +1 -1
  54. package/dist/resources/Relationship.js +1 -1
  55. package/dist/resources/SemanticSearch.js +18 -46
  56. package/dist/resources/SkillScan.js +1 -1
  57. package/dist/resources/Soul.js +1 -1
  58. package/dist/resources/SoulFeed.js +1 -1
  59. package/dist/resources/WorkspaceLatest.js +1 -1
  60. package/dist/resources/WorkspaceState.js +1 -1
  61. package/dist/resources/XAA.js +1 -1
  62. package/dist/resources/a2a-url.js +1 -1
  63. package/dist/resources/abstention.js +1 -1
  64. package/dist/resources/agent-auth.js +1 -1
  65. package/dist/resources/agentcard-fields.js +1 -1
  66. package/dist/resources/auth-middleware.js +1 -1
  67. package/dist/resources/embeddings-boot.js +14 -16
  68. package/dist/resources/embeddings-provider.js +15 -12
  69. package/dist/resources/federation-cleanup.js +5 -5
  70. package/dist/resources/federation-nonce-store.js +5 -5
  71. package/dist/resources/health.js +53 -25
  72. package/dist/resources/in-process.js +225 -0
  73. package/dist/resources/instance-identity.js +1 -1
  74. package/dist/resources/mcp-client-metadata-fields.js +1 -1
  75. package/dist/resources/mcp-handler.js +1 -1
  76. package/dist/resources/mcp-oauth.js +2 -2
  77. package/dist/resources/mcp-tools.js +23 -17
  78. package/dist/resources/memory-read-scope.js +1 -1
  79. package/dist/resources/memory-reflect-lib.js +3 -3
  80. package/dist/resources/memory-visibility.js +3 -3
  81. package/dist/resources/migration-boot.js +81 -11
  82. package/dist/resources/migrations/data-dir.js +205 -0
  83. package/dist/resources/migrations/embedding-stamp.js +3 -3
  84. package/dist/resources/migrations/graph-heal.js +2 -2
  85. package/dist/resources/migrations/ledger.js +1 -1
  86. package/dist/resources/migrations/progress.js +33 -0
  87. package/dist/resources/migrations/registry.js +14 -5
  88. package/dist/resources/migrations/runner.js +93 -13
  89. package/dist/resources/migrations/state.js +13 -2
  90. package/dist/resources/migrations/synthetic-test-migration.js +1 -1
  91. package/dist/resources/migrations/visibility-backfill.js +232 -0
  92. package/dist/resources/models-dir.js +18 -9
  93. package/dist/resources/record-types.js +28 -0
  94. package/dist/resources/semantic-retrieval-core.js +6 -5
  95. package/dist/resources/usage-recording.js +1 -1
  96. package/dist/src/lib/scheduler-platform.js +128 -0
  97. package/dist/src/lib/xml-escape.js +54 -0
  98. package/dist/src/rem/scheduler.js +397 -0
  99. package/docs/deploying-on-fabric.md +267 -0
  100. package/docs/deployment.md +6 -0
  101. package/docs/embedding-in-a-harper-app.md +299 -0
  102. package/docs/federation.md +61 -4
  103. package/docs/integrations.md +3 -0
  104. package/docs/mcp-clients.md +16 -7
  105. package/docs/quickstart.md +80 -54
  106. package/docs/releasing.md +72 -38
  107. package/docs/supply-chain-policy.md +40 -4
  108. package/docs/troubleshooting.md +24 -0
  109. package/docs/upgrade.md +100 -5
  110. package/package.json +4 -14
  111. package/schemas/memory.graphql +9 -1
  112. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  113. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  114. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  115. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  116. package/dist/resources/rerank-provider.js +0 -569
  117. package/docs/rerank-provisioning.md +0 -101
@@ -151,7 +151,21 @@ async function runCycleLocked(deps, lock) {
151
151
  const candidates = [];
152
152
  for (const migration of deps.registry.list()) {
153
153
  if (isShortCircuited(state, migration.id, deps.runningVersion)) {
154
- setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "completed" });
154
+ // flair#812: a short-circuit is an ASSERTION READ OFF A FILE, not an
155
+ // observation of the corpus — and that file is hand-editable (the
156
+ // documented remediation for a stuck migration is to correct it by
157
+ // hand). Reporting it as plain "completed" made a hand-written entry
158
+ // indistinguishable from a migration this boot actually verified.
159
+ // The skip itself is unchanged (it is the documented cheap path); it
160
+ // now just says what it is, so `flair doctor` never presents an
161
+ // unverified claim as a verified one.
162
+ setMigrationProgress({
163
+ id: migration.id,
164
+ rowsDone: 0,
165
+ rowsRemaining: 0,
166
+ state: "completed",
167
+ reason: `recorded complete at ${deps.runningVersion} in ${deps.statePath} — not re-verified this boot`,
168
+ });
155
169
  continue;
156
170
  }
157
171
  setMigrationProgress({ id: migration.id, rowsDone: 0, rowsRemaining: 0, state: "checking" });
@@ -160,12 +174,18 @@ async function runCycleLocked(deps, lock) {
160
174
  pending = await migration.detect();
161
175
  }
162
176
  catch (err) {
177
+ // flair#812: a throwing detect() removes this migration from the
178
+ // candidate set, so a cycle where EVERY detect() throws used to reach
179
+ // the `nothing pending` return and look like a clean no-op. Log it
180
+ // with the standard marker as well as recording it in progress.
181
+ const reason = `detect() threw: ${err?.message ?? String(err)}`;
182
+ console.error(`[flair-migrations] ${migration.id}: ${reason}`);
163
183
  setMigrationProgress({
164
184
  id: migration.id,
165
185
  rowsDone: 0,
166
186
  rowsRemaining: 0,
167
187
  state: "failed",
168
- reason: `detect() threw: ${err?.message ?? String(err)}`,
188
+ reason,
169
189
  });
170
190
  continue;
171
191
  }
@@ -176,11 +196,20 @@ async function runCycleLocked(deps, lock) {
176
196
  candidates.push(migration);
177
197
  }
178
198
  if (candidates.length === 0) {
199
+ // flair#812: mark the cycle done even on the no-op path. "Nothing was
200
+ // pending" and "the cycle never ran" are opposite conclusions that used
201
+ // to produce an IDENTICAL `/HealthDetail` reading (`cyclePhase: "idle",
202
+ // lastCycleAt: null`), which is exactly why a totally-skipped boot cycle
203
+ // looked healthy for months. A cycle that reached this line has done its
204
+ // job, and now says so.
205
+ setCyclePhase("done");
179
206
  return { ran: false, reason: "nothing pending" };
180
207
  }
181
- // ── ONE shared async pre-hash for the whole cycle (K&S: computed once,
182
- // after ready, before any migration's first write; failure → halt all
183
- // pending candidates rather than guessing which ones are safe). ──
208
+ // ── ONE shared async pre-hash at cycle start (K&S: computed after ready,
209
+ // before any migration's first write; failure → halt all pending
210
+ // candidates rather than guessing which ones are safe). Re-baselined
211
+ // below iff an earlier migration's own writes stale it for a later
212
+ // full-envelope gate — see the staleness comment on the loop. ──
184
213
  setCyclePhase("pre-hash");
185
214
  let envelope;
186
215
  try {
@@ -195,8 +224,49 @@ async function runCycleLocked(deps, lock) {
195
224
  return { ran: false, reason };
196
225
  }
197
226
  setCyclePhase("running");
227
+ // The cycle-start envelope is a valid gate BASELINE only until a migration
228
+ // in this same cycle writes rows: a legitimate, already-gated, ledgered
229
+ // write by an EARLIER migration (e.g. visibility-backfill stamping
230
+ // `visibility` — a SOURCE field) would otherwise read as corruption to a
231
+ // later migration's count+full-envelope gate and spuriously halt it
232
+ // ("completion gate failed: rowsRemaining=0, hash envelope mismatch" — the
233
+ // CI-observed failure on PR #845). That gate exists to catch writes the
234
+ // GATED migration didn't declare, not its predecessors' own completed
235
+ // work. So: track staleness conservatively (ANY rows written by any
236
+ // earlier migration, source-field or not — one cheap recompute beats
237
+ // enumerating which fields each migration touches), and re-baseline with a
238
+ // fresh computeCorpusEnvelope immediately BEFORE a full-envelope consumer
239
+ // starts — still before that migration's own first write, which is exactly
240
+ // the invariant-IV wording ("computed before first write and after
241
+ // completion; must match"). Cycles with no full-envelope consumer after a
242
+ // writing migration (every production cycle today — no schema-additive
243
+ // migration is registered outside CI) never pay for a second corpus walk.
244
+ let envelopeStale = false;
198
245
  for (const migration of candidates) {
199
- await runOneMigration(migration, envelope, deps, lock);
246
+ if (envelopeStale && postureFor(migration.riskClass).gate === "count+full-envelope") {
247
+ try {
248
+ envelope = await computeCorpusEnvelope(deps.getTable, deps.now);
249
+ envelopeStale = false;
250
+ }
251
+ catch (err) {
252
+ // Same failure class as the cycle-start pre-hash, but scoped: only
253
+ // THIS migration consumes the baseline, so only it halts (the
254
+ // cycle-start variant halts every candidate because nothing has run
255
+ // yet and a failing corpus walk taints them all equally). Halted ≠
256
+ // bricked: it is retried on the next boot's cycle.
257
+ setMigrationProgress({
258
+ id: migration.id,
259
+ rowsDone: 0,
260
+ rowsRemaining: 0,
261
+ state: "halted",
262
+ reason: `pre-flight integrity check failed: ${err?.message ?? String(err)}`,
263
+ });
264
+ continue;
265
+ }
266
+ }
267
+ const wroteRows = await runOneMigration(migration, envelope, deps, lock);
268
+ if (wroteRows)
269
+ envelopeStale = true;
200
270
  }
201
271
  setCyclePhase("done");
202
272
  return { ran: true };
@@ -243,6 +313,15 @@ async function haltMigration(migration, reason, ctx) {
243
313
  /* best-effort — the in-memory progress + ledger event already recorded the halt */
244
314
  }
245
315
  }
316
+ /**
317
+ * Returns whether this migration MAY have written any rows — the cycle
318
+ * loop's envelope-staleness signal (see runCycleLocked). Deliberately
319
+ * conservative: `true` whenever rows were processed OR run() threw
320
+ * mid-batch (a partial batch may have landed before the throw); a false
321
+ * `true` merely costs one extra corpus walk before the next full-envelope
322
+ * gate, while a false `false` would let that gate compare against a stale
323
+ * baseline and spuriously halt.
324
+ */
246
325
  async function runOneMigration(migration, envelope, deps, lock) {
247
326
  const startedAt = deps.now().toISOString();
248
327
  const state = readMigrationState(deps.statePath);
@@ -262,7 +341,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
262
341
  hashEnvelopeMatch: null,
263
342
  state,
264
343
  });
265
- return;
344
+ return false; // halted before any write
266
345
  }
267
346
  const estSnapshot = estimateSnapshotBytes(posture.snapshotScope, initialRemaining);
268
347
  const estWorkingSet = estimateWorkingSetBytes(migration.riskClass, initialRemaining);
@@ -282,7 +361,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
282
361
  hashEnvelopeMatch: null,
283
362
  state,
284
363
  });
285
- return;
364
+ return false; // halted before any write
286
365
  }
287
366
  // ── Ladder step 3 (+ step 4 fallback): risk-scoped snapshot, or a
288
367
  // content-only export if the snapshot mechanism itself fails. ──
@@ -307,7 +386,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
307
386
  }
308
387
  catch (expErr) {
309
388
  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 });
310
- return;
389
+ return false; // halted before any write
311
390
  }
312
391
  }
313
392
  // ── Execution: throttled batches, resumable via per-row markers ──
@@ -346,7 +425,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
346
425
  hashEnvelopeMatch: null,
347
426
  state,
348
427
  });
349
- return;
428
+ return true; // conservative: a partial batch may have landed before the throw
350
429
  }
351
430
  // ── Completion gate — strictness per risk class ──
352
431
  setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completing" });
@@ -363,7 +442,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
363
442
  hashEnvelopeMatch: null,
364
443
  state,
365
444
  });
366
- return;
445
+ return rowsDone > 0;
367
446
  }
368
447
  let hashEnvelopeMatch = null;
369
448
  let gateOk = finalRemaining === 0;
@@ -417,7 +496,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
417
496
  hashEnvelopeMatch,
418
497
  state,
419
498
  });
420
- return;
499
+ return rowsDone > 0;
421
500
  }
422
501
  }
423
502
  else if (gateOk && posture.gate === "count+old-row-envelope+new-row-presence") {
@@ -459,7 +538,7 @@ async function runOneMigration(migration, envelope, deps, lock) {
459
538
  hashEnvelopeMatch,
460
539
  state,
461
540
  });
462
- return;
541
+ return rowsDone > 0;
463
542
  }
464
543
  // ── Success: ledger → state → prune ──
465
544
  const evt = {
@@ -496,4 +575,5 @@ async function runOneMigration(migration, envelope, deps, lock) {
496
575
  }
497
576
  pruneMigrationSnapshots(deps.snapshotRoot);
498
577
  setMigrationProgress({ id: migration.id, rowsDone, rowsRemaining: 0, state: "completed" });
578
+ return rowsDone > 0;
499
579
  }
@@ -30,11 +30,22 @@ export function readMigrationState(path) {
30
30
  if (!existsSync(path))
31
31
  return {};
32
32
  const raw = JSON.parse(readFileSync(path, "utf-8"));
33
- return raw && typeof raw === "object" ? raw : {};
33
+ if (raw && typeof raw === "object")
34
+ return raw;
35
+ console.warn(`[flair-migrations] ${path} is not a JSON object — ignoring it and re-detecting every migration this boot`);
36
+ return {};
34
37
  }
35
- catch {
38
+ catch (err) {
36
39
  // Corrupt/unreadable state — treat as "nothing known yet", never throw.
37
40
  // Worst case this costs one extra (cheap, bounded) detect() call.
41
+ //
42
+ // flair#812: but SAY SO. This file is hand-edited in practice (the
43
+ // documented remediation for a stuck migration), and a fat-fingered
44
+ // edit silently discarding every recorded outcome is precisely the
45
+ // class of quiet failure this issue is about. The behaviour is
46
+ // unchanged and still safe — only the silence is fixed.
47
+ console.warn(`[flair-migrations] could not read ${path} (${err?.message ?? String(err)}) — ` +
48
+ `treating migration state as empty and re-detecting every migration this boot`);
38
49
  return {};
39
50
  }
40
51
  }
@@ -27,7 +27,7 @@
27
27
  * encodes the migration id, so "is this row done" is answered entirely by
28
28
  * re-reading the row itself (invariant IV: state lives IN the data).
29
29
  */
30
- import { databases } from "@harperfast/harper";
30
+ import { databases } from "harper";
31
31
  export const SYNTHETIC_MIGRATION_ID = "synthetic-ci-schema-stamp";
32
32
  export const RESERVED_TEST_AGENT_ID = "__flair_migration_synthetic_test_agent__";
33
33
  export const SYNTHETIC_TARGET_MARKER = `${SYNTHETIC_MIGRATION_ID}-done`;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * visibility-backfill.ts — backfills the `visibility` field on Memory rows
3
+ * written before flair#509 (the durability-keyed default-visibility slice),
4
+ * which never stamped `visibility` at all. riskClass 'derived-only':
5
+ * `visibility` here is a derived stamp recomputable at any time from
6
+ * `durability` (which remains on the row, untouched, forever) — never the
7
+ * only copy of anything (invariant I) — and this migration only ever writes
8
+ * into a field that is CURRENTLY EMPTY, so the cheapest posture applies:
9
+ * metadata-only snapshot, no content-hash gate (row-count + stamp
10
+ * convergence only, same as `embedding-stamp`/`graph-heal`).
11
+ *
12
+ * WHY THIS EXISTS: `resources/Memory.ts`'s `defaultVisibilityForDurability`
13
+ * (flair#509) only stamps `visibility` on a WRITE — a row that has sat
14
+ * untouched since before that slice shipped has no `visibility` field at
15
+ * all. `src/cli.ts`'s federation push filter (`isFederationPrivateVisibility`)
16
+ * excludes only `visibility === "private"` — by design, per
17
+ * `resources/memory-visibility.ts`'s migration invariant, an ABSENT
18
+ * `visibility` field is NOT private and must keep syncing exactly as
19
+ * before (so today's single-org fleet never silently stops replicating
20
+ * pre-#509 rows). That is correct while every federation peer IS this same
21
+ * org's own fleet. A cross-organization hub pairing is coming, and at that
22
+ * point an absent-visibility row that should have been private (a
23
+ * `standard`/`ephemeral` note, never intended to leave the instance) would
24
+ * cross an org boundary it never should. This migration closes that gap
25
+ * proactively: once every row carries an explicit `visibility`, "absent"
26
+ * becomes the empty set, and the existing filter (unchanged, zero new
27
+ * behavior in it) is correct as-is for the cross-org case too.
28
+ *
29
+ * THE RULE (flair#509 — reused, not reinvented): `permanent`/`persistent`
30
+ * durability -> `shared`; `standard`/`ephemeral` durability, OR durability
31
+ * absent/unrecognised -> `private` (fail-safe: never widen access on a row
32
+ * whose intent this migration cannot actually determine).
33
+ * `deriveVisibilityFromDurability` below mirrors
34
+ * `resources/Memory.ts`'s (unexported) `defaultVisibilityForDurability`
35
+ * byte-for-byte. Deliberately DUPLICATED rather than imported: importing
36
+ * `resources/Memory.ts` here would drag in its module-top-level
37
+ * `export class Memory extends (databases as any).flair.Memory` — a class
38
+ * EXTENDS that only resolves once a live Harper has already registered the
39
+ * base `Memory` resource, which unit tests (no real Harper; see
40
+ * `test/unit/migrations-visibility-backfill.test.ts`) cannot satisfy. Same
41
+ * reasoning, same shape, as `src/cli.ts`'s `isFederationPrivateVisibility`
42
+ * duplicating `resources/memory-visibility.ts`'s `isPrivateVisibility`
43
+ * rather than importing it (see that file's comment) — both are one-line,
44
+ * pure, and load-bearing enough that a comment pointing at the canonical
45
+ * definition is the safer contract than a shared import with a landmine
46
+ * behind it. If this rule ever changes, both copies must change together.
47
+ *
48
+ * NEVER OVERWRITES an existing `visibility` value — that is an explicit
49
+ * author decision (their own call, even if it disagrees with what this
50
+ * migration would have derived). Only rows where `visibility` is
51
+ * null/undefined are eligible, and `run()` below re-verifies that on a
52
+ * freshly-read record immediately before writing (the actual enforcement
53
+ * of "never overwrite" — see the write-gate comment there), independent of
54
+ * how precisely the candidate-selection query below narrowed things.
55
+ *
56
+ * THE CANDIDATE QUERY — why it looks different from `embedding-stamp`'s:
57
+ * `visibility` is declared in `schemas/memory.graphql` but NOT `@indexed`
58
+ * (unlike `embeddingModel`, which flair#807 needed indexed for its own
59
+ * pending query). Every condition against it is therefore already a
60
+ * full-table-scan filter (`resources/search.ts`'s `filterByType`), whether
61
+ * or not the comparator is negated — the `not_equals`-forces-an-index-bypass
62
+ * mechanism `embedding-stamp.ts` depends on (flair#807) is simply moot for
63
+ * an attribute with no index to be stale in the first place. What DOES
64
+ * still matter, and is the same lesson flair#807 taught: a strict-equals
65
+ * filter (`recordValue === value`) never matches a row whose property was
66
+ * NEVER SET (`recordValue` reads back as `undefined`, and `undefined !==
67
+ * null`), so `{comparator: "equals", value: null}` alone would silently
68
+ * miss every truly-absent row — exactly the rows this migration exists to
69
+ * find. A NEGATED comparator does match them (`undefined !== "private"` is
70
+ * true), so the pending condition here is `visibility not_equals "private"
71
+ * AND visibility not_equals "shared"` — no separate `equals: null` leg
72
+ * needed (unlike `embedding-stamp`, whose write path can leave an
73
+ * intermediate explicit-null state on a failed HTTP regen; this migration's
74
+ * write is a single in-process read-modify-write that either fully
75
+ * succeeds or leaves the row in its exact prior state, so there is no
76
+ * intermediate state to add a leg for). This exact `not_equal`-covers-
77
+ * absent-and-null-and-nothing-else-with-the-value-itself semantics is
78
+ * already relied on, in production, by `resources/memory-read-scope.ts`'s
79
+ * cross-agent read-scope condition on this SAME field (`visibility !=
80
+ * 'private'` — "which INCLUDES records missing the field entirely") — this
81
+ * migration's query is not a new risk, it is the same proven mechanic.
82
+ *
83
+ * A garbage third value (something other than "private"/"shared"/absent)
84
+ * would also match this AND-of-two-`not_equals` condition — Harper's query
85
+ * algebra has no "attribute is absent" primitive to exclude that case at
86
+ * the query level. No code path in this codebase ever writes such a value
87
+ * today (confirmed by grep — every write site either omits `visibility`,
88
+ * defaults it via `defaultVisibilityForDurability`, or passes an operator-
89
+ * supplied string straight through with no allowlist, so nothing rules it
90
+ * out categorically either). The write-gate in `run()` is what actually
91
+ * protects against this, not the query: a row is only ever written to if a
92
+ * freshly-read `.get()` shows `visibility` is STILL null/undefined at write
93
+ * time. A genuinely garbage-valued row would be pulled in as a candidate,
94
+ * re-checked, found to already carry an explicit (if unexpected) value, and
95
+ * skipped — untouched, exactly like any other already-set row. Worst case
96
+ * this makes `countPending()` overcount relative to what `run()` will ever
97
+ * actually touch, which would show up as a real, visible completion-gate
98
+ * halt (`resources/migrations/runner.ts`'s "completion gate failed:
99
+ * rowsRemaining=N") rather than a silent skip or a false success — halt-
100
+ * don't-brick (invariant II), and worth surfacing loudly if it ever
101
+ * happens, since a garbage `visibility` value is itself a bug worth seeing.
102
+ *
103
+ * THE WRITE PATH — deliberately NOT `embedding-stamp`'s loopback-HTTP
104
+ * `PUT /Memory/:id` trick. That mechanism exists ONLY because
105
+ * `embedding-stamp` needs `resources/Memory.ts`'s SUBCLASS override (the
106
+ * `content.content && !content.embedding` regen branch) to actually fire,
107
+ * and the raw `databases.flair.Memory` reference (what this migration and
108
+ * that one both get via their table accessor) resolves to the RAW
109
+ * underlying table, silently skipping every subclass override — see that
110
+ * file's module doc for the full, empirically-confirmed mechanism. This
111
+ * migration needs NO subclass business logic (no dedup, no regen, no
112
+ * provenance stamping, no rate limiting) — it is a narrow, single-field
113
+ * patch, which is exactly the shape `resources/table-helpers.ts`'s
114
+ * `patchRecord` documents and this codebase already uses directly against
115
+ * the RAW table for the same kind of narrow metadata write:
116
+ * `resources/auth-middleware.ts`'s `backfillEmbedding()` (patches
117
+ * `embedding`), `resources/MemoryReflect.ts` and `resources/Memory.ts`
118
+ * itself (both patch `lastReflected` this exact way). Per that file's own
119
+ * warning, Harper's raw `.put()` is FULL RECORD REPLACEMENT — a partial
120
+ * object silently deletes every field not included — so `run()` below
121
+ * always spreads the freshly-read `existing` record before adding
122
+ * `visibility`, the same read-modify-write shape `patchRecord` implements
123
+ * (inlined here rather than calling that helper directly only because
124
+ * `run()` already needs its own `.get()` for the write-gate check above,
125
+ * and re-using that same read avoids a second, redundant round-trip).
126
+ *
127
+ * NO `recheckPending()`: that hook exists specifically to catch a stale
128
+ * SECONDARY INDEX diverging from live values (flair#807, `embedding-stamp`
129
+ * doc above) — a failure mode that requires an index to go stale in the
130
+ * first place. `visibility` has no index at all (every read here is
131
+ * already a live-record scan), so there is no analogous divergence for a
132
+ * safety net to guard against. Same reasoning `graph-heal.ts` (also
133
+ * `derived-only`, also unindexed-field-adjacent) already applies by also
134
+ * omitting it.
135
+ */
136
+ import { databases } from "harper";
137
+ function defaultMemoryTable() {
138
+ return databases.flair.Memory;
139
+ }
140
+ export const VISIBILITY_BACKFILL_ID = "visibility-backfill";
141
+ /**
142
+ * flair#509's rule, reused verbatim (see module doc for why this is a
143
+ * deliberate duplicate of `resources/Memory.ts`'s
144
+ * `defaultVisibilityForDurability` rather than an import). Total function —
145
+ * every input maps to exactly one of the two valid outputs, and the
146
+ * fail-safe branch (`private`) is the ELSE of the allowlist check, so any
147
+ * absent/null/unrecognised/wrong-typed `durability` value falls into it by
148
+ * construction, never needs its own case.
149
+ */
150
+ export function deriveVisibilityFromDurability(durability) {
151
+ return durability === "permanent" || durability === "persistent" ? "shared" : "private";
152
+ }
153
+ /**
154
+ * Rows this migration still needs to touch: `visibility` is neither
155
+ * "private" nor "shared" — see module doc for why this AND-of-two-
156
+ * `not_equals` correctly matches both a truly-absent property and an
157
+ * explicit `null`, without a separate `equals: null` leg.
158
+ */
159
+ function pendingCondition() {
160
+ return [
161
+ { attribute: "visibility", comparator: "not_equals", value: "private" },
162
+ { attribute: "visibility", comparator: "not_equals", value: "shared" },
163
+ ];
164
+ }
165
+ /**
166
+ * `getTable` is injectable so unit tests exercise this migration's full
167
+ * detect/countPending/run logic against an in-memory fake table (matching
168
+ * `embedding-stamp.ts`'s DI style) — no real Harper needed. Unlike
169
+ * `embedding-stamp`, there is no separate HTTP mechanism to inject: the
170
+ * write IS the table's own `.put()`, already covered by the same fake.
171
+ */
172
+ export function createVisibilityBackfillMigration(getTable = defaultMemoryTable) {
173
+ return {
174
+ id: VISIBILITY_BACKFILL_ID,
175
+ riskClass: "derived-only",
176
+ affectsTables: ["Memory"],
177
+ async detect() {
178
+ const table = getTable();
179
+ for await (const _row of table.search({ conditions: pendingCondition(), limit: 1 })) {
180
+ return true;
181
+ }
182
+ return false;
183
+ },
184
+ async countPending() {
185
+ const table = getTable();
186
+ let n = 0;
187
+ for await (const _row of table.search({ conditions: pendingCondition() }))
188
+ n++;
189
+ return n;
190
+ },
191
+ async run(batchSize) {
192
+ const table = getTable();
193
+ const candidates = [];
194
+ for await (const row of table.search({ conditions: pendingCondition(), limit: batchSize })) {
195
+ candidates.push(row);
196
+ }
197
+ const touchedIds = [];
198
+ for (const row of candidates) {
199
+ const id = String(row.id ?? "");
200
+ if (!id)
201
+ continue;
202
+ const existing = await table.get(id);
203
+ if (!existing)
204
+ continue; // deleted since the search above — nothing to fix
205
+ // The write-gate: this is what actually enforces "never overwrite an
206
+ // existing visibility value", independent of how precisely the
207
+ // candidate query above narrowed things (see module doc — a
208
+ // garbage third value would still be pulled in as a candidate).
209
+ // Re-checks the FRESHLY-READ record, not the (possibly stale)
210
+ // search-result row, and doubles as the idempotency/concurrent-
211
+ // writer guard `embedding-stamp.ts` gets from its own analogous
212
+ // pre-write check.
213
+ if (existing.visibility !== undefined && existing.visibility !== null)
214
+ continue;
215
+ const derived = deriveVisibilityFromDurability(existing.durability);
216
+ // Never-widen invariant, asserted, not just tested: the only two
217
+ // values `deriveVisibilityFromDurability` can ever produce are
218
+ // "private"/"shared" (see its own doc), so this can never fire
219
+ // today — it exists to fail loudly (halting this migration via the
220
+ // runner's mid-batch-throw path, never a silent bad write) if a
221
+ // future edit to that function ever widens its return type or is
222
+ // bypassed via a type-unsafe call.
223
+ if (derived !== "private" && derived !== "shared") {
224
+ throw new Error(`visibility-backfill: derived an invalid visibility for row ${id} — refusing to write`);
225
+ }
226
+ await table.put({ ...existing, visibility: derived });
227
+ touchedIds.push(id);
228
+ }
229
+ return { processed: touchedIds.length, touchedIds };
230
+ },
231
+ };
232
+ }
@@ -1,25 +1,34 @@
1
1
  /**
2
2
  * models-dir.ts — the ONE place that decides where model GGUFs live.
3
3
  *
4
- * Extracted from embeddings-provider.ts (flair#815) so the reranker
5
- * (rerank-provider.ts) can share the exact same resolution WITHOUT importing
4
+ * Extracted from embeddings-provider.ts (flair#815) so a second model
5
+ * consumer could share the exact same resolution WITHOUT importing
6
6
  * embeddings-provider: several unit-isolated tests `mock.module()` the whole
7
7
  * embeddings-provider module (with only the named exports THEY consume), and
8
8
  * bun's module cache is process-wide — so any new cross-module import of
9
9
  * embeddings-provider from another resource makes those mocks incomplete and
10
10
  * kills unrelated test files at module load. This module is tiny, pure
11
- * node-stdlib, and never mocked, so both providers (and any future model
12
- * consumer) can depend on it safely.
11
+ * node-stdlib, and never mocked, so any model consumer can depend on it
12
+ * safely.
13
+ *
14
+ * The second consumer that motivated the split (the cross-encoder reranker)
15
+ * has since been removed (flair#893), leaving the embedding engine as the
16
+ * only caller. The module stays split anyway, and deliberately: the
17
+ * `mock.module()` hazard above is a property of embeddings-provider, not of
18
+ * the reranker, so folding this back in would re-arm it for whatever the next
19
+ * model consumer turns out to be. It is also the single documented source of
20
+ * truth for the models dir, independently tested as such.
13
21
  */
14
22
  import { existsSync } from "node:fs";
15
23
  import { homedir } from "node:os";
16
24
  import { join } from "node:path";
17
25
  /**
18
- * Resolve the directory model GGUFs live in / download into — shared by the
19
- * embedding engine (embeddings-boot.ts's `register()` options) and the
20
- * cross-encoder reranker (rerank-provider.ts, flair#815 it used to hardcode
21
- * `<cwd>/models`, silently failing open wherever Harper's cwd wasn't the
22
- * models location).
26
+ * Resolve the directory model GGUFs live in / download into — used by the
27
+ * embedding engine (embeddings-boot.ts's `register()` options), and the
28
+ * resolution any future model consumer should use rather than re-deriving its
29
+ * own. flair#815 exists because a consumer that hardcoded `<cwd>/models`
30
+ * silently failed open wherever Harper's cwd wasn't the models location; the
31
+ * fix was to give the decision exactly one home, which is this function.
23
32
  *
24
33
  * Resolution order (everything writable, never the read-only package dir):
25
34
  * 1. FLAIR_MODELS_DIR — explicit operator/docker override.
@@ -315,6 +315,34 @@ export const RECORD_TYPES = {
315
315
  federation: "included",
316
316
  mcp: { toolPrefix: "soul", readVerbs: ["get"], writeVerbs: ["store"] },
317
317
  },
318
+ // MemoryCandidate — flair#849. A candidate is an unreviewed draft
319
+ // distillation staged by the FLAIR-NIGHTLY-REM cycle (docs/rem.md);
320
+ // `readScope: "owner-only"` (not "open-within-org") is deliberate: a
321
+ // candidate must not be org-readable before a human/agent reviewer
322
+ // explicitly promotes or rejects it — this is the standard-review-bar
323
+ // choice per this file's header doc's review-gate tiering (an
324
+ // "open-within-org" or "none" entry would additionally require Sherlock's
325
+ // explicit security sign-off, which this entry does not carry). No
326
+ // `embedding` — the claim text has no dedicated recall surface (it isn't
327
+ // Memory.content). No `mcp` — no MCP tool exposes this table today.
328
+ MemoryCandidate: {
329
+ table: "MemoryCandidate",
330
+ ownerField: "agentId",
331
+ identity: "gated",
332
+ readScope: "owner-only",
333
+ attribution: { post: "validate-truthy", put: "validate-truthy" },
334
+ // No provenance field on the schema (schemas/memory.graphql) — nobody
335
+ // stamps buildProvenance() for this table.
336
+ provenance: false,
337
+ remEligible: false,
338
+ // Not in Federation.ts's push table map nor src/cli.ts's
339
+ // runFederationSyncOnce hardcoded list (`["Memory", "Soul", "Agent",
340
+ // "Relationship"]`) — per this file's header doc's federation section,
341
+ // a new type's registration must not silently inherit whatever that
342
+ // filter does or doesn't already exclude, so this is "excluded" by the
343
+ // same reasoning, not by omission.
344
+ federation: "excluded",
345
+ },
318
346
  };
319
347
  // ─── Composite MCP tools (the second, and only other, reviewed chokepoint) ─
320
348
  //
@@ -3,8 +3,8 @@
3
3
  // Extracted from resources/SemanticSearch.ts's post() (Kern-approved
4
4
  // refactor, flair#695). Before this module existed,
5
5
  // SemanticSearch.post() was one function entangling auth resolution,
6
- // rate-limiting, HNSW/BM25 retrieval, post-retrieval filtering, the
7
- // cross-encoder reranker, AND retrievalCount/lastRetrieved hit-tracking side
6
+ // rate-limiting, HNSW/BM25 retrieval, post-retrieval filtering, AND
7
+ // retrievalCount/lastRetrieved hit-tracking side
8
8
  // effects — so the ONLY way for MemoryBootstrap (resources/MemoryBootstrap.ts)
9
9
  // to get bounded, HNSW-pushed-down candidates was to duplicate the retrieval
10
10
  // logic or trip the side effects (an internal bootstrap call spuriously
@@ -16,7 +16,7 @@
16
16
  // the HNSW leg query construction (sort/select/conditions/limit), the BM25 +
17
17
  // union-RRF hybrid fusion, the per-record temporal/expiry/supersede filters,
18
18
  // and the scope.isAllowed() defense-in-depth re-check. It does NOT own: auth
19
- // resolution, rate-limiting, the reranker, or the retrievalCount/lastRetrieved
19
+ // resolution, rate-limiting, or the retrievalCount/lastRetrieved
20
20
  // hit-tracking side effects — those stay in SemanticSearch.post()'s wrapper
21
21
  // (resources/SemanticSearch.ts) so an internal caller (bootstrap) never trips
22
22
  // them.
@@ -32,11 +32,12 @@
32
32
  // Returns results AFTER all filters, sorted best-first by `_score` — bounded
33
33
  // ONLY by the `limit` the caller chose to push down (the core never
34
34
  // multiplies `limit` internally; any overfetch policy — SemanticSearch's
35
- // CANDIDATE_MULTIPLIER, rerank-topN widening — is the CALLER's decision, made
35
+ // CANDIDATE_MULTIPLIER, MemoryBootstrap's K formula — is the CALLER's
36
+ // decision, made
36
37
  // before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
37
38
  // legacy HNSW-only vs. keyword-only fallback) produced a given result — the
38
39
  // output shape is identical regardless of `hybrid`.
39
- import { databases } from "@harperfast/harper";
40
+ import { databases } from "harper";
40
41
  import { withDetachedTxn } from "./table-helpers.js";
41
42
  import { wrapUntrusted } from "./content-safety.js";
42
43
  import { cosineSimilarity } from "./dedup.js";
@@ -45,7 +45,7 @@
45
45
  * memories it cannot see). So the scope gate lives HERE, not retrofitted
46
46
  * onto recordUsageContribution() — do not "unify" the two surfaces.
47
47
  */
48
- import { databases } from "@harperfast/harper";
48
+ import { databases } from "harper";
49
49
  import { withDetachedTxn } from "./table-helpers.js";
50
50
  import { resolveReadScope } from "./memory-read-scope.js";
51
51
  /**