@tpsdev-ai/flair 0.50.0 → 0.51.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.
@@ -10,9 +10,10 @@
10
10
  * per-source scoping — see resources/AttentionQuery.ts's module doc), so the
11
11
  * MCP surface inherits the SAME security model as the signed-REST path. There
12
12
  * is no raw CRUD surface — the only way to reach the datastore through /mcp is
13
- * via one of these 12 semantic tools.
13
+ * via one of these 14 semantic tools.
14
14
  *
15
15
  * memory_search · memory_store · memory_update · memory_get · memory_delete ·
16
+ * memory_basement · memory_restore ·
16
17
  * bootstrap · soul_set · soul_get · flair_workspace_set · flair_orgevent ·
17
18
  * attention · record_usage
18
19
  *
@@ -37,6 +38,7 @@
37
38
  */
38
39
  import { resolveVersion } from "./version.js";
39
40
  import { agentContext, adminContext, collectionResource } from "./in-process.js";
41
+ import { RECORD_USAGE_ID_MERGE_CONTRACT, unionUsageMemoryIds } from "./usage-ids.js";
40
42
  const H = {};
41
43
  const LOADERS = {
42
44
  SemanticSearch: async () => (await import("./SemanticSearch.js")).SemanticSearch,
@@ -183,6 +185,10 @@ async function memorySearch(agent, args) {
183
185
  // requested so a plain search delegates a byte-identical body.
184
186
  if (args?.abstain === true)
185
187
  body.abstain = true;
188
+ // flair#1472 — opt-in basement inclusion. Forwarded ONLY when requested so a
189
+ // plain search delegates a byte-identical body (archived excluded by default).
190
+ if (args?.includeArchived === true)
191
+ body.includeArchived = true;
186
192
  return unwrap(await h.post(body));
187
193
  }
188
194
  async function memoryStore(agent, args) {
@@ -350,6 +356,56 @@ async function memoryUpdate(agent, args) {
350
356
  // regenerates the vector server-side); no-op when the response carries none.
351
357
  return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
352
358
  }
359
+ // ── flair#1472 memory_basement / memory_restore ──────────────────────────────
360
+ // The user-facing archive action. `archived` is a VISIBILITY flag, not a
361
+ // deletion: basementing a memory removes it from bootstrap + default search
362
+ // but leaves the row, its provenance, and its history fully intact (still
363
+ // retrievable via memory_get and memory_search(includeArchived:true)). Restore
364
+ // is the deliberate, GLOBAL inverse — it un-retires the memory for EVERY
365
+ // session, not a session-local view (that is drawers, Deliverable B, which does
366
+ // not exist yet). Both are writes scoped to the caller's own lane: the read
367
+ // uses Memory.get()'s read-scope gate and the write uses Memory.put()'s
368
+ // ownership gate (stampAttribution), so a caller can neither read nor write
369
+ // another agent's memory here.
370
+ async function memoryBasement(agent, args) {
371
+ const Cls = await handler("Memory");
372
+ const id = args?.id;
373
+ const existing = await unwrap(await Cls.get(id, delegationContext(agent)));
374
+ if (!existing || existing.error != null || existing.status === 404) {
375
+ return { error: "memory not found", status: 404 };
376
+ }
377
+ const merged = {
378
+ ...existing,
379
+ archived: true,
380
+ archivedBy: agent.agentId,
381
+ updatedAt: new Date().toISOString(),
382
+ };
383
+ // Memory.put() stamps archivedAt when archived===true (see Memory.ts). The
384
+ // content is unchanged, so the existing embedding stays valid — do NOT clear
385
+ // it (clearing would force a needless re-embed and, if the embedding engine
386
+ // is unavailable, would silently drop the vector).
387
+ if (agent.clientId)
388
+ merged.claimedClient = agent.clientId;
389
+ return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
390
+ }
391
+ async function memoryRestore(agent, args) {
392
+ const Cls = await handler("Memory");
393
+ const id = args?.id;
394
+ const existing = await unwrap(await Cls.get(id, delegationContext(agent)));
395
+ if (!existing || existing.error != null || existing.status === 404) {
396
+ return { error: "memory not found", status: 404 };
397
+ }
398
+ const merged = {
399
+ ...existing,
400
+ archived: false,
401
+ updatedAt: new Date().toISOString(),
402
+ };
403
+ delete merged.archivedAt;
404
+ delete merged.archivedBy;
405
+ if (agent.clientId)
406
+ merged.claimedClient = agent.clientId;
407
+ return stripInternalFields(await unwrap(await Cls.put(merged, delegationContext(agent))));
408
+ }
353
409
  async function memoryGet(agent, args) {
354
410
  const Cls = await handler("Memory");
355
411
  // flair#1181 — by-id reads MUST use the STATIC `Cls.get(id, context)` form,
@@ -538,9 +594,10 @@ async function attention(agent, args) {
538
594
  async function recordUsage(agent, args) {
539
595
  const Cls = await handler("RecordUsage");
540
596
  const h = new Cls(undefined, delegationContext(agent));
541
- const memoryIds = Array.isArray(args?.memoryIds)
542
- ? args.memoryIds
543
- : typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
597
+ // flair#1410: MERGE, do not prefer. The previous ternary dropped
598
+ // `memoryId` whenever `memoryIds` was an array (including `[]`).
599
+ const merged = unionUsageMemoryIds(args?.memoryId, args?.memoryIds);
600
+ const memoryIds = merged.length > 0 ? merged : undefined;
544
601
  return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
545
602
  }
546
603
  /**
@@ -633,6 +690,7 @@ export const TOOLS = {
633
690
  limit: { type: "number", description: "Max results (default 5)" },
634
691
  includeTrust: { type: "boolean", description: "Attach a per-result trust-evidence block (provenance, author, usage, freshness, supersession). Default false." },
635
692
  abstain: { type: "boolean", description: "Opt into first-class abstention: when the best match is below a global confidence threshold, return { abstained: true, reason, bestScore } with no weak matches instead of the N weakest results. Default false." },
693
+ includeArchived: { type: "boolean", description: "Include basemented (archived) memories in results. Default false — archived memories are excluded from normal search. When true, archived memories are returned under the SAME read-scope gate as a normal search (never a wider scope)." },
636
694
  },
637
695
  required: ["query"],
638
696
  },
@@ -710,6 +768,55 @@ export const TOOLS = {
710
768
  errorShape: { trigger: "updating a non-existent id", fields: ["error", "status"] },
711
769
  },
712
770
  },
771
+ memory_basement: {
772
+ def: {
773
+ name: "memory_basement",
774
+ description: "Send a memory to the basement (archive it). Sets archived=true and stamps archivedAt. " +
775
+ "The memory is removed from bootstrap and default search but remains retrievable via " +
776
+ "memory_get and memory_search(includeArchived:true). Deliberate and GLOBAL — this is a " +
777
+ "visibility flag, not a deletion: provenance and history are untouched. Scoped to your own memories only.",
778
+ inputSchema: {
779
+ type: "object",
780
+ properties: {
781
+ id: { type: "string", description: "ID of the memory to basement (archive)" },
782
+ },
783
+ required: ["id"],
784
+ },
785
+ },
786
+ impl: memoryBasement,
787
+ contract: {
788
+ summary: "Write echo of the archived record { id, archived:true, archivedAt, ... }. No internal embedding fields; the flip round-trips via memory_get.",
789
+ requiredFields: ["id", "archived"],
790
+ fieldTypes: { id: "string", archived: "boolean" },
791
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
792
+ invariants: { fullyResolved: true },
793
+ errorShape: { trigger: "basementing a non-existent or non-owned id", fields: ["error", "status"] },
794
+ },
795
+ },
796
+ memory_restore: {
797
+ def: {
798
+ name: "memory_restore",
799
+ description: "Restore a basemented (archived) memory. Clears archived and archivedAt. Deliberate and GLOBAL — " +
800
+ "this un-retires the memory for EVERY session, not a session-local view (per-session reuse is " +
801
+ "drawers, which do not exist yet). Scoped to your own memories only.",
802
+ inputSchema: {
803
+ type: "object",
804
+ properties: {
805
+ id: { type: "string", description: "ID of the memory to restore (un-archive)" },
806
+ },
807
+ required: ["id"],
808
+ },
809
+ },
810
+ impl: memoryRestore,
811
+ contract: {
812
+ summary: "Write echo of the restored record { id, archived:false, ... }. No internal embedding fields; the flip round-trips via memory_get.",
813
+ requiredFields: ["id", "archived"],
814
+ fieldTypes: { id: "string", archived: "boolean" },
815
+ forbiddenFields: INTERNAL_MEMORY_FIELDS,
816
+ invariants: { fullyResolved: true },
817
+ errorShape: { trigger: "restoring a non-existent or non-owned id", fields: ["error", "status"] },
818
+ },
819
+ },
713
820
  memory_get: {
714
821
  def: {
715
822
  name: "memory_get",
@@ -1005,12 +1112,13 @@ export const TOOLS = {
1005
1112
  name: "record_usage",
1006
1113
  description: "Report that one or more memories were actually USED — cited or relied on to ground an answer or decision. " +
1007
1114
  "Distinct from search (surfacing a memory is not usage). Drives the recall-quality usage signal; dedup'd " +
1008
- "(you can only count once per memory) and rate-limited.",
1115
+ "(you can only count once per memory) and rate-limited. " +
1116
+ RECORD_USAGE_ID_MERGE_CONTRACT,
1009
1117
  inputSchema: {
1010
1118
  type: "object",
1011
1119
  properties: {
1012
- memoryIds: { type: "array", items: { type: "string" }, description: "IDs of the memories that were used (max 20 per call)" },
1013
- memoryId: { type: "string", description: "Convenience alias for a single memory id (use memoryIds for multiple)" },
1120
+ memoryIds: { type: "array", items: { type: "string" }, description: "IDs of the memories that were used (max 20 per call). Merged with memoryId when both are supplied." },
1121
+ memoryId: { type: "string", description: "Convenience alias for a single memory id. Merged with memoryIds when both are supplied — not dropped." },
1014
1122
  attribution: { type: "string", description: "Optional free-text note on what used it (opaque — stored for audit only, max 500 chars)" },
1015
1123
  },
1016
1124
  },
@@ -361,8 +361,10 @@ export const RECORD_TYPES = {
361
361
  // bootstrap — Soul + Memory + predicted-context composite (BootstrapMemories)
362
362
  // attention — cross-table aggregate query (AttentionQuery.ts), not a table verb
363
363
  // record_usage — usage-signal resource (RecordUsage.ts), not a table verb
364
+ // memory_basement — archive action (flair#1472), not a table verb
365
+ // memory_restore — un-archive action (flair#1472), not a table verb
364
366
  //
365
- export const COMPOSITE_MCP_TOOLS = ["bootstrap", "attention", "record_usage"];
367
+ export const COMPOSITE_MCP_TOOLS = ["bootstrap", "attention", "record_usage", "memory_basement", "memory_restore"];
366
368
  // ─── Runtime immutability ───────────────────────────────────────────────────
367
369
  // Belt-and-suspenders backstop for the "static, compiled" invariant (see
368
370
  // header doc). TypeScript's `as const satisfies` already gives compile-time
@@ -28,6 +28,21 @@
28
28
  * warm would deadlock — health waits for the index, the index waits for
29
29
  * a search that never comes.
30
30
  */
31
+ /**
32
+ * How a ready result was verified. Constant strings — no interpolation
33
+ * (flair#1411). Public /Health still omits this field when searchReady is
34
+ * true; the values live on the decision object so a caller of
35
+ * resolveSearchReadiness can tell registry-verified from table-only.
36
+ */
37
+ export const SEARCH_READY_REASON_VERIFIED_VIA_ROUTE_REGISTRY = "verified via route registry";
38
+ export const SEARCH_READY_REASON_REGISTRY_UNAVAILABLE_TABLE_ONLY = "registry unavailable, table check only";
39
+ /** Once-warn when the route-mount check is skipped (flair#1411). */
40
+ export const MISSING_REGISTRY_WARN = "search route-mount verification is degraded; searchReady now rests on the table check alone";
41
+ let warnedMissingRegistry = false;
42
+ /** Test-only: forget the one-shot missing-registry warning. */
43
+ export function _resetMissingRegistryWarnForTests() {
44
+ warnedMissingRegistry = false;
45
+ }
31
46
  function routeMounted(resources, name) {
32
47
  const entry = resources.get?.(name) ?? resources.getMatch?.(name);
33
48
  return Boolean(entry?.Resource);
@@ -36,16 +51,14 @@ function routeMounted(resources, name) {
36
51
  * Decide whether search is actually usable, and whether /Health should claim
37
52
  * the process is healthy.
38
53
  *
39
- * `resources` is optional. Stated fail-open (Sherlock on #1406): when the
40
- * registry is missing we skip the route-mount check rather than 503 forever.
41
- * A table handle can exist while `/Memory` and `/SemanticSearch` still 404,
42
- * so this is weaker than the primary defense. In the shipped launch path
43
- * `/Health` is served by this Resource after Harper has registered us, so
44
- * `server.resources` is populated; the skip is the injectable/test path
45
- * (and a theoretical export without a registry). Health.ts logs once if
46
- * the live call site actually takes it.
54
+ * `resources` is optional. Stated fail-open (Sherlock on #1406 / flair#1411):
55
+ * when the registry is missing we skip the route-mount check rather than
56
+ * 503 forever. A table handle can exist while `/Memory` and `/SemanticSearch`
57
+ * still 404, so this is weaker than the primary defense. We do not fail
58
+ * closed. We warn once and name the degradation on `searchReadyReason`.
47
59
  */
48
60
  export function resolveSearchReadiness(opts) {
61
+ let readyReason = SEARCH_READY_REASON_REGISTRY_UNAVAILABLE_TABLE_ONLY;
49
62
  if (opts.resources) {
50
63
  const memoryMounted = routeMounted(opts.resources, "Memory");
51
64
  const searchMounted = routeMounted(opts.resources, "SemanticSearch");
@@ -56,6 +69,13 @@ export function resolveSearchReadiness(opts) {
56
69
  ].filter(Boolean).join(", ");
57
70
  return notServing(`search routes not mounted (${missing})`);
58
71
  }
72
+ readyReason = SEARCH_READY_REASON_VERIFIED_VIA_ROUTE_REGISTRY;
73
+ }
74
+ else {
75
+ if (!warnedMissingRegistry) {
76
+ warnedMissingRegistry = true;
77
+ opts.warn?.(MISSING_REGISTRY_WARN);
78
+ }
59
79
  }
60
80
  if (!opts.memoryTable || typeof opts.memoryTable.search !== "function") {
61
81
  return notServing("memory table not queryable");
@@ -78,7 +98,7 @@ export function resolveSearchReadiness(opts) {
78
98
  return namesLag("bm25 index not built (cold boot; first search scans the corpus)");
79
99
  }
80
100
  }
81
- return { searchReady: true, ok: true, status: 200 };
101
+ return { searchReady: true, ok: true, status: 200, searchReadyReason: readyReason };
82
102
  }
83
103
  function notServing(searchReadyReason) {
84
104
  return { searchReady: false, ok: false, status: 503, searchReadyReason };
@@ -94,7 +114,10 @@ export function buildPublicHealthBody(readiness, identity) {
94
114
  buildCommit: identity.buildCommit,
95
115
  searchReady: readiness.searchReady,
96
116
  };
97
- if (readiness.searchReadyReason)
117
+ // Public shape is unchanged: searchReadyReason is still present iff !searchReady.
118
+ // Ready-path verification constants stay on the decision object (flair#1411).
119
+ if (!readiness.searchReady && readiness.searchReadyReason) {
98
120
  body.searchReadyReason = readiness.searchReadyReason;
121
+ }
99
122
  return body;
100
123
  }
@@ -34,9 +34,11 @@
34
34
  // multiplies `limit` internally; any overfetch policy — SemanticSearch's
35
35
  // CANDIDATE_MULTIPLIER, MemoryBootstrap's K formula — is the CALLER's
36
36
  // decision, made
37
- // before calling in). Never exposes which internal leg (BM25+RRF hybrid vs.
38
- // legacy HNSW-only vs. keyword-only fallback) produced a given result — the
39
- // output shape is identical regardless of `hybrid`.
37
+ // before calling in). The result array never annotates which internal leg
38
+ // (BM25+RRF hybrid vs. legacy HNSW-only vs. keyword-only fallback) produced
39
+ // a given row — that output shape is identical regardless of `hybrid`.
40
+ // Per-leg membership is available only through the opt-in `onLegs`
41
+ // callback (flair#1358); unset ⇒ the return is byte-identical to before.
40
42
  //
41
43
  // ── SCORE CONTRACT (flair#985) ───────────────────────────────────────────────
42
44
  // `_score` under `scoring:"raw"` is ALWAYS an ABSOLUTE similarity (cosine of
@@ -61,6 +63,14 @@ import { buildBM25, fuseRrfNormalized, SEM_LIMIT } from "./bm25.js";
61
63
  import { isAllowedBm25Candidate } from "./bm25-filter.js";
62
64
  import { indexedBm25Ids } from "./bm25-index-service.js";
63
65
  import { byRecencyThenId } from "./sort-comparators.js";
66
+ /**
67
+ * The only way a row enters the ranked pool. `_rank` is a required
68
+ * argument — not an object property that `...any` can swallow — so a
69
+ * push site that omits it is a compile error (flair#1415).
70
+ */
71
+ function pushRanked(rows, row, _rank) {
72
+ rows.push({ ...row, _rank });
73
+ }
64
74
  // Convert HNSW cosine distance (1 - similarity) to similarity score.
65
75
  function distanceToSimilarity(distance) {
66
76
  return 1 - distance;
@@ -87,10 +97,12 @@ export const DEFAULT_SELECT = ["id", "agentId", "content", "contentHash", "visib
87
97
  "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject", "summary",
88
98
  "validFrom", "validTo", "_safetyFlags"];
89
99
  export async function retrieveCandidates(params) {
90
- const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, withSemSimilarity = false, } = params;
100
+ const { queryEmbedding: qEmb, q, conditions, limit, select = DEFAULT_SELECT, includeSuperseded = false, scoring = "raw", temporalBoost = 1.0, sinceDate = null, asOf, minScore = 0, agentId, isAllowed, hybrid, ctx, withSemSimilarity = false, onLegs, } = params;
91
101
  const passesAllowed = (record) => !isAllowed || isAllowed(record);
92
102
  const hnswSelect = [...select, "$distance"];
93
103
  const results = [];
104
+ const hnswLegIds = [];
105
+ const bm25LegIds = [];
94
106
  if (hybrid) {
95
107
  // ─── BM25 + union-RRF hybrid path ────────────────────────────────────
96
108
  // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
@@ -154,6 +166,7 @@ export async function retrieveCandidates(params) {
154
166
  }
155
167
  semRecords.push(record);
156
168
  semIds.push(record.id);
169
+ hnswLegIds.push(record.id);
157
170
  }
158
171
  }
159
172
  // ── (b) The BM25 lexical leg ─────────────────────────────────────────
@@ -260,6 +273,8 @@ export async function retrieveCandidates(params) {
260
273
  }
261
274
  bm25Ids = resolved;
262
275
  }
276
+ if (q)
277
+ bm25LegIds.push(...bm25Ids);
263
278
  // ── (d) No retrieval signal at all → full scoped listing ────────────
264
279
  if (!q && !qEmb) {
265
280
  for (const record of allowedById.values()) {
@@ -269,14 +284,13 @@ export async function retrieveCandidates(params) {
269
284
  finalScore *= temporalBoost;
270
285
  const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
271
286
  const source = record.agentId !== agentId ? record.agentId : undefined;
272
- results.push({
287
+ pushRanked(results, {
273
288
  ...record,
274
289
  content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
275
290
  _score: Math.round(finalScore * 1000) / 1000,
276
291
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
277
292
  _source: source,
278
- _rank: finalScore,
279
- });
293
+ }, finalScore);
280
294
  }
281
295
  }
282
296
  else {
@@ -324,22 +338,21 @@ export async function retrieveCandidates(params) {
324
338
  finalScore *= temporalBoost;
325
339
  const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
326
340
  const source = record.agentId !== agentId ? record.agentId : undefined;
327
- results.push({
341
+ // Ordering key: fused rank for raw mode; composite value for
342
+ // composite mode (composite ordering is unchanged by #985 — its
343
+ // rrfRaw input and result order are exactly the pre-#985 behavior).
344
+ pushRanked(results, {
328
345
  ...record,
329
346
  content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
330
347
  _score: Math.round(finalScore * 1000) / 1000,
331
348
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
332
349
  _source: source,
333
- // Ordering key: fused rank for raw mode; composite value for
334
- // composite mode (composite ordering is unchanged by #985 — its
335
- // rrfRaw input and result order are exactly the pre-#985 behavior).
336
- _rank: scoring === "raw" ? rrfRaw : finalScore,
337
350
  // flair#744 slice 2: the opt-in absolute-confidence field for the
338
351
  // abstention decision. Attach remains OPT-IN so non-abstain
339
352
  // responses stay byte-identical (the capture above is now
340
353
  // unconditional, but the response field is not).
341
354
  ...(withSemSimilarity && semSim !== undefined ? { _semSimilarity: semSim } : {}),
342
- });
355
+ }, scoring === "raw" ? rrfRaw : finalScore);
343
356
  }
344
357
  }
345
358
  }
@@ -401,15 +414,15 @@ export async function retrieveCandidates(params) {
401
414
  // flair#744 slice 2: the absolute cosine (`semanticScore`, pre keyword
402
415
  // bump) is the abstention confidence signal on this legacy/bootstrap
403
416
  // (HNSW-leg-only) path.
404
- results.push({
417
+ pushRanked(results, {
405
418
  ...rest,
406
419
  content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
407
420
  _score: Math.round(finalScore * 1000) / 1000,
408
421
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
409
422
  _source: source,
410
- _rank: finalScore,
411
423
  ...(withSemSimilarity ? { _semSimilarity: semanticScore } : {}),
412
- });
424
+ }, finalScore);
425
+ hnswLegIds.push(record.id);
413
426
  }
414
427
  }
415
428
  else {
@@ -447,14 +460,13 @@ export async function retrieveCandidates(params) {
447
460
  finalScore *= temporalBoost;
448
461
  const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
449
462
  const source = record.agentId !== agentId ? record.agentId : undefined;
450
- results.push({
463
+ pushRanked(results, {
451
464
  ...rest,
452
465
  content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
453
466
  _score: Math.round(finalScore * 1000) / 1000,
454
467
  _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
455
468
  _source: source,
456
- _rank: finalScore,
457
- });
469
+ }, finalScore);
458
470
  }
459
471
  }
460
472
  // Build superseded set and filter (unless caller opts in to see full
@@ -480,7 +492,9 @@ export async function retrieveCandidates(params) {
480
492
  }
481
493
  // Order by the internal ranking key (fused RRF rank on the hybrid raw path;
482
494
  // identical to `_score` everywhere else), then strip it — `_rank` is an
483
- // ordering key, never part of the response shape. Note the hybrid raw
495
+ // ordering key, never part of the response shape. Required on
496
+ // RetrievalRankedRow / pushRanked (flair#1415) so a missing value cannot
497
+ // silently NaN this comparator into the recency tail. Note the hybrid raw
484
498
  // ordering is deliberately NOT by `_score`: the recall win of hybrid
485
499
  // retrieval lives in the fused ORDER (a BM25 rank-1 rescue outranks weak
486
500
  // semantic hits), while `_score` carries the honest absolute evidence for
@@ -495,5 +509,10 @@ export async function retrieveCandidates(params) {
495
509
  filteredResults.sort((a, b) => (b._rank - a._rank) || byRecencyThenId(a, b));
496
510
  for (const r of filteredResults)
497
511
  delete r._rank;
512
+ onLegs?.({
513
+ hnsw: hnswLegIds,
514
+ bm25: bm25LegIds,
515
+ fused: filteredResults.map((r) => r.id),
516
+ });
498
517
  return filteredResults;
499
518
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * usage-ids.ts — which memory ids a usage-feedback call credits (flair#1410).
3
+ *
4
+ * `record_usage` / `POST /RecordUsage` accept both singular `memoryId` and
5
+ * plural `memoryIds`. MERGE, not prefer: a caller who supplies both means
6
+ * both. Preferring `memoryIds` (`data?.memoryIds ?? [data?.memoryId]`)
7
+ * silently dropped the singular id — quiet data loss, same class as
8
+ * #1206 / #1371.
9
+ *
10
+ * Pure: no Harper. Native `/mcp` (`resources/mcp-tools.ts`) and the HTTP
11
+ * endpoint (`resources/RecordUsage.ts`) both call this so the credited set
12
+ * does not depend on every client flattening first. Stdio `flair-mcp`
13
+ * merges independently in `buildRecordUsageBody`; the conformance test
14
+ * pins the two surfaces to the same set.
15
+ */
16
+ /** Stated on both MCP tool schemas so a caller can predict the merge without reading source. */
17
+ export const RECORD_USAGE_ID_MERGE_CONTRACT = "When both memoryId and memoryIds are supplied they are merged (union, then deduped) — a caller who passes both means both.";
18
+ /**
19
+ * Union `memoryId` + `memoryIds`, then dedupe. Empty / non-string entries
20
+ * in the plural list are skipped (same filter as stdio `buildRecordUsageBody`).
21
+ * Used by native `/mcp` to flatten the tool args before `RecordUsage.post`.
22
+ */
23
+ export function unionUsageMemoryIds(memoryId, memoryIds) {
24
+ const ids = [];
25
+ if (Array.isArray(memoryIds)) {
26
+ for (const id of memoryIds) {
27
+ if (typeof id === "string" && id.length > 0)
28
+ ids.push(id);
29
+ }
30
+ }
31
+ if (typeof memoryId === "string" && memoryId.length > 0) {
32
+ ids.push(memoryId);
33
+ }
34
+ return [...new Set(ids)];
35
+ }
36
+ /**
37
+ * HTTP-endpoint resolver: union both fields, then apply RecordUsage.post()'s
38
+ * existing validation (non-empty strings, per-call cap on the unique credited
39
+ * set). Cap after dedupe so an overlapping `memoryId` does not 400 a legal
40
+ * unique set that native `/mcp` and stdio already accept. A present-but-invalid
41
+ * `memoryIds` still 400s. The max-20 anti-gaming bound is unchanged — it
42
+ * limits unique ids credited, not raw concatenation length.
43
+ */
44
+ export function resolveRecordUsageIds(data, maxIds) {
45
+ const memoryIds = data?.memoryIds;
46
+ const memoryId = data?.memoryId;
47
+ if (memoryIds != null) {
48
+ if (!Array.isArray(memoryIds) || !memoryIds.every((id) => typeof id === "string" && id.length > 0)) {
49
+ return { ok: false, error: "invalid" };
50
+ }
51
+ }
52
+ const raw = [];
53
+ if (Array.isArray(memoryIds))
54
+ raw.push(...memoryIds);
55
+ if (typeof memoryId === "string" && memoryId.length > 0)
56
+ raw.push(memoryId);
57
+ if (raw.length === 0)
58
+ return { ok: false, error: "empty" };
59
+ const ids = [...new Set(raw)];
60
+ if (ids.length > maxIds)
61
+ return { ok: false, error: "cap" };
62
+ return { ok: true, ids };
63
+ }
@@ -171,6 +171,17 @@ Re-pairing an existing peer (same instance ID, same public key) does not require
171
171
 
172
172
  Spoke instances can only push records they originated. A spoke cannot overwrite records from another spoke or from the hub. The hub can relay records from any origin.
173
173
 
174
+ ### Per-record signatures and principalId
175
+
176
+ Each pushed record carries an Ed25519 signature over a versioned canonical body. `v` lives inside that body so versions are distinguishable: a `v: 1` signature cannot verify as `v: 2`.
177
+
178
+ - **`v: 1` (today's wire):** signed fields are `{ v, table, id, data, updatedAt, originatorInstanceId }`. Senders did not put `v` on the wire; receivers default absent `v` to `1`. `principalId` may appear on the record (from a Memory provenance stamp) but was not in the signed field set.
179
+ - **`v: 2`:** `principalId` is included in the signed payload when the row has a write-time provenance stamp (`provenance.verified.agentId`). `v` is on the wire.
180
+
181
+ On apply, after the signature checks, **Memory** (the only principal-owning federated table) requires `principalId` to be present and equal to `data.agentId`. Absent is a skip (`principal_mismatch`), not an accept. Soul, Agent, and Relationship are not in that set and still sync without a principal.
182
+
183
+ Receivers must be upgraded before senders. A Phase 1 receiver verifies both shapes in one batch. Old receivers cannot reconstruct a `v: 2` body and will skip those records (per-record, not a batch outage) until they upgrade. Optional `FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL=true` skips leftover `v: 1` Memory records that lack `principalId` once the fleet is on `v: 2`.
184
+
174
185
  ### Timestamp ceiling
175
186
 
176
187
  Records with `updatedAt` more than 5 minutes in the future are rejected. This prevents an attacker from using far-future timestamps to permanently win last-write-wins (LWW) merge conflicts.
@@ -151,7 +151,7 @@ Mirrors the CI test-unit gates locally so issues are caught at `git commit` time
151
151
  Runs three checks before each commit:
152
152
  - `check-workspace-deps.mjs` — workspace internal-dep version lockstep
153
153
  - `check-dep-ages.mjs` — supply-chain bake-time (≥7 days for external pinned deps)
154
- - `check-impl-term-leaks.sh` — no Bead refs / impl labels in user-facing docs
154
+ - `check-impl-term-leaks.sh` — no Bead refs / impl labels in user-facing docs, `CHANGELOG.md`, or `.changelog/`
155
155
 
156
156
  Each check matches a CI gate exactly so the local and remote outcomes can't drift. Bypass with `git commit --no-verify` when warranted (rare; CI will still catch you). Skip just the dep-ages check (the slowest one, ~2-5s of registry fetches) with `FLAIR_PRECOMMIT_SKIP_DEP_AGES=1 git commit`.
157
157
 
package/docs/upgrade.md CHANGED
@@ -65,7 +65,15 @@ actually running:
65
65
  - **Post-restart verification** (skip with `--no-verify`) confirms the
66
66
  restarted instance answers `/Health`, that an authenticated request
67
67
  round-trips, and that the reported running version matches what was just
68
- installed.
68
+ installed. It then runs the same enumerable doctor install-health checks
69
+ (`flair doctor`'s client-integration catalog, plus launchd management)
70
+ and prints `✅ verified: healthy` only when every check ran and none
71
+ failed. A missing Codex SessionStart hook — which `flair init` before
72
+ 0.50.0 never wrote — is one of those checks. Installing the hook is
73
+ consent-bearing (it executes at every session start): an interactive
74
+ upgrade prompts; a non-interactive upgrade states the gap and withholds
75
+ ✅. Pass `--install-hooks` to consent without a prompt, then `flair
76
+ doctor` exits 0.
69
77
  - **On a failed restart OR a failed verification**, `flair upgrade`
70
78
  automatically reinstalls the previously-running `@tpsdev-ai/flair` version,
71
79
  restarts again, and re-verifies — then exits nonzero with a clear report of
@@ -517,6 +525,14 @@ The backwards-boot refusal (flair#1049) catches this: the old binary refuses to
517
525
  naming both versions and the data directory, with recovery instructions. A pre-upgrade
518
526
  snapshot exists at the named path. Restoring it returns the store to a working state.
519
527
 
528
+ **Patch-level break inside 5.2:** Harper 5.2.7 writes LZ4-compressed RocksDB that
529
+ Harper 5.2.0 cannot open (`LZ4 not supported in this build`). Downgrade from a
530
+ 5.2.7-written store to the npm-published 5.2.0 pin is forward-only — same
531
+ recovery as the 5.1 → 5.2 break: `flair snapshot restore <path>`. The
532
+ `downgrade-boot` suite treats that Harper crash as the loud-refusal branch of
533
+ the flair#1050 invariant (it boots Harper via `startHarper`, so the CLI stamp
534
+ phrasing is not on that path).
535
+
520
536
  **As observed when this suite was added (2026-07-08):** the npm-published baseline
521
537
  (0.21.0) boots cleanly against data written by a HEAD build roughly 14 commits ahead of
522
538
  it (several security-hardening and CLI-behavior changes, no Flair schema migration, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.50.0",
3
+ "version": "0.51.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",
@@ -65,7 +65,7 @@
65
65
  "@harperfast/oauth": "2.5.0",
66
66
  "@types/js-yaml": "4.0.9",
67
67
  "commander": "14.0.3",
68
- "harper": "5.2.0",
68
+ "harper": "5.2.7",
69
69
  "harper-fabric-embeddings": "^0.5.0",
70
70
  "jose": "6.2.2",
71
71
  "js-yaml": "^4.3.1",
@@ -58,7 +58,7 @@ type SyncLog @table(database: "flair") {
58
58
  direction: String! @indexed # "push" | "pull"
59
59
  recordCount: Int # how many records merged in this batch
60
60
  skippedCount: Int # how many records skipped (sum of skippedReasons)
61
- skippedReasons: String # JSON: { unknown_table: N, non_originator: N, future_timestamp: N, no_op_same_hash: N, merge_error: N }
61
+ skippedReasons: String # JSON: { unknown_table: N, non_originator: N, future_timestamp: N, no_op_same_hash: N, merge_error: N, unknown_originator_key: N, invalid_signature: N, missing_signature: N, principal_mismatch: N }
62
62
  status: String @indexed # "success" | "partial" | "failed"
63
63
  error: String # human-readable summary if partial/failed (includes per-record error messages, capped)
64
64
  durationMs: Int # how long the sync took