@tpsdev-ai/flair 0.27.0 → 0.27.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.
@@ -60,6 +60,47 @@
60
60
  * known, narrow gap this bounded query cannot see;
61
61
  * resources/migration-boot.ts mitigates the common case by waiting for the
62
62
  * embeddings engine to settle before running migrations at all.
63
+ *
64
+ * flair#807 — completion-gate false-negative on 0.10-era / cross-version
65
+ * stores, root-caused against the installed `@harperfast/harper@5.1.22`
66
+ * source (node_modules/@harperfast/harper/resources/search.ts): for an
67
+ * INDEXED attribute (`embeddingModel` IS `@indexed` — schemas/memory.graphql),
68
+ * a non-negated `not_equal`/`ne` leaf condition takes `searchByIndex`'s
69
+ * `index && !skipIndex` branch, which reconstructs a SYNTHETIC partial record
70
+ * from the SECONDARY INDEX KEY alone (`recordMatcher = { [attribute_name]:
71
+ * key }`, resources/search.ts ~L418) and filters against THAT — never reading
72
+ * the live record. On a store whose rows were written under flair 0.10.0 /
73
+ * Harper 5.0.x and later rewritten via `PUT /Memory` (crossing a Harper
74
+ * 5.0.21→5.1.22 boot), the secondary index for `embeddingModel` can lag the
75
+ * live, on-disk value — the filter then evaluates a STALE key that never
76
+ * equals `getCurrentModelId()`, matching every row regardless of its true
77
+ * (already-current) stamp.
78
+ *
79
+ * The fix: use `comparator: "not_equals"` (the `not_` PREFIX form, distinct
80
+ * from the legacy `not_equal`/`ne` ALIAS — see search.ts's
81
+ * `resolveComparator`/`NEGATABLE_BASE_COMPARATORS`) for the not-current leg.
82
+ * `prepareConditions` resolves this to `{comparator: "equals", negated:
83
+ * true}`, which flips `searchByIndex`'s `skipIndex` flag
84
+ * (`searchCondition.negated && index && !isPrimaryKey`) — this is Harper's
85
+ * OWN documented mechanism for "bypass the secondary index, iterate the
86
+ * primary store directly" (see that file's comment: "we need to consider
87
+ * records whose attribute value is missing from the index... so we bypass
88
+ * the secondary index"). That path reads the LIVE record unconditionally,
89
+ * immune to any index/record divergence regardless of cause (this migration,
90
+ * a future one, or a Harper-internal index-rebuild timing issue). Strictly
91
+ * MORE reliable than the index-assisted path in every case — a genuinely
92
+ * stale row still matches (full scan over live values), so this changes
93
+ * nothing about the "3/3 success" happy path, only closes the false-positive
94
+ * gap on migrated stores. The `equals: null` leg is unaffected (not a
95
+ * negated comparator — no analogous bypass exists or is needed; its
96
+ * behavior was already verified against real Harper per the doc above).
97
+ *
98
+ * `recheckPending()` below is a SEPARATE, generic defense-in-depth layer
99
+ * (wired into resources/migrations/runner.ts's completion gate) — even a
100
+ * migration whose countPending() query is airtight can be defeated by some
101
+ * OTHER, not-yet-understood counting artifact; this lets the runner PROVE
102
+ * (via `.get()`, a primary-key lookup — always live, never index-assisted)
103
+ * that a nonzero countPending() result is real before halting on it.
63
104
  */
64
105
  import { databases } from "@harperfast/harper";
65
106
  import { getModelId } from "../embeddings-provider.js";
@@ -115,16 +156,24 @@ async function regenViaHttpPut(id, existing, fetchImpl) {
115
156
  */
116
157
  export function createEmbeddingStampMigration(getTable = defaultMemoryTable, getCurrentModelId = getModelId, regen = (id, existing) => regenViaHttpPut(id, existing, fetch)) {
117
158
  function staleCondition() {
118
- // OR-combined: `not_equal <current>` catches a stale non-null model
159
+ // OR-combined: `not_equals <current>` catches a stale non-null model
119
160
  // string; `equals null` catches the explicit-null state this
120
161
  // migration's own writes leave behind on a failed regen (see the
121
162
  // module doc above — Harper's index never sees a TRULY ABSENT
122
163
  // property, only an explicit null).
164
+ //
165
+ // "not_equals" (NOT the legacy "not_equal" alias — see the flair#807
166
+ // addendum in the module doc) is load-bearing: it's the `not_` PREFIX
167
+ // form Harper's query engine resolves to a `negated: true` leaf, which
168
+ // bypasses the (on some stores, stale/desynced) secondary index and
169
+ // reads the live record directly. Reverting this to "not_equal" would
170
+ // reopen #807 on any store where the embeddingModel index lags the
171
+ // on-disk value.
123
172
  return [
124
173
  {
125
174
  operator: "or",
126
175
  conditions: [
127
- { attribute: "embeddingModel", comparator: "not_equal", value: getCurrentModelId() },
176
+ { attribute: "embeddingModel", comparator: "not_equals", value: getCurrentModelId() },
128
177
  { attribute: "embeddingModel", comparator: "equals", value: null },
129
178
  ],
130
179
  },
@@ -174,5 +223,37 @@ export function createEmbeddingStampMigration(getTable = defaultMemoryTable, get
174
223
  }
175
224
  return { processed: touchedIds.length, touchedIds };
176
225
  },
226
+ /**
227
+ * flair#807 completion-gate safety net (resources/migrations/runner.ts
228
+ * calls this ONLY when a nonzero countPending() would otherwise halt the
229
+ * migration). Re-derives up to `limit` currently-"pending" ids via the
230
+ * SAME staleCondition() search countPending() uses, then re-checks each
231
+ * one by a DIRECT `.get()` — a primary-key lookup, never index-assisted,
232
+ * so it can't be fooled by the same secondary-index divergence that can
233
+ * make the search-based count wrong. Returns counts only (never ids —
234
+ * matches this codebase's structural-only-disclosure discipline, e.g.
235
+ * resources/migrations/ledger.ts's module doc), so the runner can log a
236
+ * loud, actionable WARN without ever needing to know which rows.
237
+ */
238
+ async recheckPending(limit) {
239
+ const table = getTable();
240
+ const current = getCurrentModelId();
241
+ const ids = [];
242
+ for await (const row of table.search({ conditions: staleCondition(), limit })) {
243
+ const id = String(row.id ?? "");
244
+ if (id)
245
+ ids.push(id);
246
+ }
247
+ let falsePositives = 0;
248
+ for (const id of ids) {
249
+ const existing = await table.get(id);
250
+ // A row that vanished since the search, OR whose live embeddingModel
251
+ // still doesn't match current, is a GENUINE pending row (or a
252
+ // concurrent delete) — never counted as a false positive.
253
+ if (existing && existing.embeddingModel === current)
254
+ falsePositives++;
255
+ }
256
+ return { sampled: ids.length, falsePositives };
257
+ },
177
258
  };
178
259
  }
@@ -44,6 +44,17 @@ import { join } from "node:path";
44
44
  * production deployment (gate off) can never alter the real 100ms throttle.
45
45
  */
46
46
  export const TEST_BATCH_DELAY_ENV = "FLAIR_MIGRATION_TEST_BATCH_DELAY_MS";
47
+ /**
48
+ * flair#807 completion-gate safety net: the maximum `finalRemaining` count
49
+ * the gate will EXHAUSTIVELY re-verify (every claimed-pending row, never a
50
+ * probabilistic subset) via `migration.recheckPending()` before declaring a
51
+ * halt. Bounded so this stays "cheap" (a per-boot gate check, not a bulk
52
+ * job) and so the net can only ever flip a gate from fail→pass when it has
53
+ * proven — not sampled — that the WHOLE remaining set is a counting
54
+ * artifact. A `finalRemaining` above this threshold skips the net entirely
55
+ * and halts exactly as before (a partial recheck is not proof for the rest).
56
+ */
57
+ const GATE_SAFETY_NET_MAX_RECHECK = 50;
47
58
  function resolveTestBatchDelayMs(env = process.env) {
48
59
  if (!shouldRegisterSyntheticMigration(env))
49
60
  return undefined;
@@ -356,6 +367,39 @@ async function runOneMigration(migration, envelope, deps, lock) {
356
367
  }
357
368
  let hashEnvelopeMatch = null;
358
369
  let gateOk = finalRemaining === 0;
370
+ // ── flair#807 safety net: a nonzero finalRemaining can itself be a
371
+ // COUNTING ARTIFACT (e.g. countPending()'s search-based query reading a
372
+ // stale/desynced secondary index on a migrated store — see
373
+ // resources/migrations/embedding-stamp.ts's flair#807 doc for the
374
+ // concrete mechanism that motivated this) rather than real pending work.
375
+ // Only engages when finalRemaining is small enough to verify
376
+ // EXHAUSTIVELY — every claimed-pending row, not a subset — via a DIRECT
377
+ // per-row read (migration.recheckPending(), opt-in per Migration type).
378
+ // If every single one comes back already correct on direct read, the
379
+ // gate's nonzero count was the bug: log loudly and PASS with a WARN
380
+ // instead of halting forever on an artifact a real re-run can never
381
+ // clear (countPending() would report the same wrong number next boot).
382
+ if (!gateOk &&
383
+ finalRemaining > 0 &&
384
+ finalRemaining <= GATE_SAFETY_NET_MAX_RECHECK &&
385
+ typeof migration.recheckPending === "function") {
386
+ try {
387
+ const recheck = await migration.recheckPending(finalRemaining);
388
+ if (recheck.sampled === finalRemaining && recheck.falsePositives === recheck.sampled) {
389
+ console.warn(`[migrations] ${migration.id}: completion gate countPending() reported ${finalRemaining} pending, ` +
390
+ `but a direct per-record read of ALL ${recheck.sampled} claimed-pending rows found every one already ` +
391
+ `correct. Treating this as a counting artifact (search/index divergence), NOT real pending work — ` +
392
+ `passing the gate with this WARN instead of halting.`);
393
+ gateOk = true;
394
+ }
395
+ }
396
+ catch (err) {
397
+ // The safety net itself must never crash or worsen the gate outcome —
398
+ // a failure here just leaves gateOk as countPending() computed it.
399
+ console.warn(`[migrations] ${migration.id}: completion-gate safety net recheckPending() threw (` +
400
+ `${err?.message ?? String(err)}) — proceeding with the original gate result.`);
401
+ }
402
+ }
359
403
  if (gateOk && posture.gate === "count+full-envelope") {
360
404
  try {
361
405
  const postEnvelope = await computeCorpusEnvelope(deps.getTable, deps.now);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",