@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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * upgrade-migrations.ts — flair#1439
3
+ *
4
+ * Version-keyed migrations that `flair upgrade` applies automatically during
5
+ * the post-restart verification step. These are distinct from the Harper data
6
+ * migrations in `resources/migrations/`: those run inside the server and
7
+ * transform stored data. These run in the CLI and bring the LOCAL dev-env
8
+ * (hook files, config snippets) up to the state a fresh `flair init` would
9
+ * produce for the harnesses already wired on this machine.
10
+ *
11
+ * ## Consent model
12
+ *
13
+ * A migration runs when ALL of the following hold:
14
+ * 1. The installed harness/artifact is ALREADY present on this machine
15
+ * (the user consented to that integration when they ran `flair init`).
16
+ * 2. The target version (`toVersion`) introduces the artifact for the first
17
+ * time (`toAtLeast`), AND the previous version did not have it
18
+ * (`fromBefore`).
19
+ * 3. The artifact is currently absent (idempotent: nothing to do if it's
20
+ * already there).
21
+ *
22
+ * Because (1) is the consent gate, no extra flag or TTY interaction is
23
+ * required. The user already said "wire Codex" — the migration just applies
24
+ * the piece their old init couldn't know about.
25
+ *
26
+ * ## Adding a new migration
27
+ *
28
+ * 1. Push a new entry onto UPGRADE_MIGRATIONS.
29
+ * 2. Set `fromBefore` to the first version that includes the new artifact.
30
+ * 3. Set `toAtLeast` to the same version.
31
+ * 4. Implement `apply(ctx)` — return ok:false (surfaced as a warning) on
32
+ * partial failure; throw only on hard failure that should be logged.
33
+ *
34
+ * Migrations run in order; later entries may assume earlier ones completed.
35
+ */
36
+ import { installHook, hookSettingsPath, SUPPORTED_HARNESSES, } from "../hook-install.js";
37
+ import { checkSessionStartHook, readClientMcpBlock } from "../doctor-client.js";
38
+ import { parseSemverCore } from "../fabric-upgrade.js";
39
+ // ─── Semver helpers ───────────────────────────────────────────────────────────
40
+ /** True if version `a` is strictly less than `b`. */
41
+ function semverLt(a, b) {
42
+ const pa = parseSemverCore(a);
43
+ const pb = parseSemverCore(b);
44
+ if (!pa || !pb)
45
+ return false;
46
+ if (pa[0] !== pb[0])
47
+ return pa[0] < pb[0];
48
+ if (pa[1] !== pb[1])
49
+ return pa[1] < pb[1];
50
+ return pa[2] < pb[2];
51
+ }
52
+ /** True if version `a` is greater than or equal to `b`. */
53
+ function semverGte(a, b) {
54
+ return !semverLt(a, b);
55
+ }
56
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
57
+ /**
58
+ * Determine if a harness is "already wired" — the user previously ran
59
+ * `flair init` for it and the MCP block (config.toml / mcpServers JSON) is
60
+ * present. The hook itself is what we're migrating IN, so we deliberately
61
+ * do NOT check for the hook's presence here.
62
+ *
63
+ * Uses readClientMcpBlock (which temporarily overrides HOME) so the homeDir
64
+ * arg is honoured correctly in tests.
65
+ */
66
+ function isHarnessWired(homeDir, harness) {
67
+ const clientId = harness === "codex" ? "codex" : "claude-code";
68
+ const block = readClientMcpBlock(clientId, homeDir);
69
+ return block.present;
70
+ }
71
+ /**
72
+ * Install the SessionStart hook for a harness, reading the agent id and flair
73
+ * URL from the existing MCP block (same as `resolveUpgradeHookInstall` in
74
+ * doctor-run.ts, but self-contained to avoid a circular dep).
75
+ */
76
+ function applySessionStartHookForHarness(homeDir, harness, port) {
77
+ const id = `session-start-hook@0.50.0/${harness}`;
78
+ // Already present → idempotent no-op.
79
+ const settingsPath = hookSettingsPath(homeDir, harness);
80
+ const existing = checkSessionStartHook(homeDir, settingsPath);
81
+ if (existing.present) {
82
+ return { id, message: `SessionStart hook (${harness}): already present — no-op`, ok: true, wrote: false };
83
+ }
84
+ // Not wired at all → skip (don't wire a harness the user never had).
85
+ if (!isHarnessWired(homeDir, harness)) {
86
+ return { id, message: `SessionStart hook (${harness}): harness not wired — skip`, ok: true, wrote: false };
87
+ }
88
+ // Resolve agentId + flairUrl from the existing MCP block.
89
+ const clientId = harness === "codex" ? "codex" : "claude-code";
90
+ const block = readClientMcpBlock(clientId, homeDir);
91
+ const agentId = (typeof process.env.FLAIR_AGENT_ID === "string" && process.env.FLAIR_AGENT_ID) ||
92
+ block.agentId;
93
+ const flairUrl = (typeof process.env.FLAIR_URL === "string" && process.env.FLAIR_URL) ||
94
+ block.flairUrl ||
95
+ `http://127.0.0.1:${port}`;
96
+ if (!agentId) {
97
+ return {
98
+ id,
99
+ message: `SessionStart hook (${harness}): no agent id found in MCP config — run: flair hook install --harness ${harness}`,
100
+ ok: false,
101
+ wrote: false,
102
+ };
103
+ }
104
+ const result = installHook({ homeDir, harness, agentId, flairUrl });
105
+ return {
106
+ id,
107
+ message: result.message,
108
+ ok: result.ok,
109
+ wrote: result.ok,
110
+ };
111
+ }
112
+ // ─── Migration registry ───────────────────────────────────────────────────────
113
+ /**
114
+ * Ordered list of all CLI upgrade migrations.
115
+ *
116
+ * To add a migration: push a new entry, set fromBefore/toAtLeast, implement
117
+ * apply(). The runner calls apply() only when the version window matches.
118
+ */
119
+ export const UPGRADE_MIGRATIONS = [
120
+ {
121
+ id: "session-start-hook@0.50.0",
122
+ description: "Install missing SessionStart hook(s) for harnesses already wired in a pre-0.50.0 init",
123
+ fromBefore: "0.50.0",
124
+ toAtLeast: "0.50.0",
125
+ apply(ctx) {
126
+ // Only act on detected hook-capable harnesses already wired on this machine.
127
+ const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h) && isHarnessWired(ctx.homeDir, h));
128
+ if (harnesses.length === 0)
129
+ return [];
130
+ return harnesses.map((h) => applySessionStartHookForHarness(ctx.homeDir, h, ctx.port));
131
+ },
132
+ },
133
+ ];
134
+ /**
135
+ * Run all CLI upgrade migrations whose version window matches
136
+ * `fromVersion → toVersion`. Migrations whose window is not matched are
137
+ * silently skipped (they're not pending for this upgrade pair).
138
+ *
139
+ * @param fromVersion The previously installed version (null → unknown, skip
140
+ * migrations that need a version gate).
141
+ * @param toVersion The newly installed version (null → unknown, skip all).
142
+ * @param ctx Runtime context (homeDir, port, detectedClientIds).
143
+ */
144
+ export function applyUpgradeMigrations(fromVersion, toVersion, ctx,
145
+ // Injectable for tests (e.g. the throwing-migration case); production callers
146
+ // use the real registry.
147
+ migrations = UPGRADE_MIGRATIONS) {
148
+ const applied = [];
149
+ if (!fromVersion || !toVersion) {
150
+ // Cannot gate on version — skip all version-keyed migrations.
151
+ return { applied, allOk: true };
152
+ }
153
+ for (const migration of migrations) {
154
+ const pending = semverLt(fromVersion, migration.fromBefore) &&
155
+ semverGte(toVersion, migration.toAtLeast);
156
+ if (!pending)
157
+ continue;
158
+ // A migration must never CRASH the upgrade: a thrown apply() is strictly
159
+ // worse than the pre-migration doctor failure it was meant to prevent — no
160
+ // summary, partial writes, and the doctor catalog never runs. Catch it,
161
+ // surface it as a non-fatal ok:false result, and let the catalog proceed
162
+ // (flair#1439, per Kern review).
163
+ let results;
164
+ try {
165
+ results = migration.apply(ctx);
166
+ }
167
+ catch (err) {
168
+ results = [{
169
+ id: migration.id,
170
+ message: `${migration.id}: migration failed (${err instanceof Error ? err.message : String(err)}) — run \`flair doctor\` to check the current state`,
171
+ ok: false,
172
+ wrote: false,
173
+ }];
174
+ }
175
+ if (results.length > 0) {
176
+ applied.push({ migration, results });
177
+ }
178
+ }
179
+ const allOk = applied.every((a) => a.results.every((r) => r.ok));
180
+ return { applied, allOk };
181
+ }
@@ -5,8 +5,8 @@ import { allowAdmin } from "./agent-auth.js";
5
5
  import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
6
6
  import { initFederationCleanup } from "./federation-cleanup.js";
7
7
  import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
8
- import { classifyRecord } from "./federation-classify.js";
9
- export { classifyRecord } from "./federation-classify.js";
8
+ import { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, } from "./federation-classify.js";
9
+ export { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, recordSignatureVersion, PRINCIPAL_OWNING_TABLES, FEDERATION_TABLE_POLICY, FEDERATION_SYNC_TABLES, } from "./federation-classify.js";
10
10
  // Module-level nonce store for federation anti-replay.
11
11
  // Shared across FederationPair + FederationSync — nonces are globally unique
12
12
  // (generated by signBodyFresh per request with 128-bit random nonces).
@@ -37,6 +37,17 @@ export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodyS
37
37
  function requireRecordSignatures() {
38
38
  return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_SIGNATURES ?? "").toLowerCase() === "true";
39
39
  }
40
+ /**
41
+ * Phase 3 of flair#1416 — skip leftover v:1 records on principal-owning
42
+ * tables that lack principalId. Default OFF. Flip only once every paired
43
+ * peer is emitting v:2 (check SyncLog for unsigned / v:1 Memory). Same
44
+ * operator-decision pattern as requireRecordSignatures() — never
45
+ * auto-flipped. v:2 Memory is already mandatory-principal regardless of
46
+ * this flag (see checkPrincipalEntitlement).
47
+ */
48
+ function requireRecordPrincipal() {
49
+ return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL ?? "").toLowerCase() === "true";
50
+ }
40
51
  // ─── Conflict resolution ─────────────────────────────────────────────────────
41
52
  /**
42
53
  * Field-level Last-Write-Wins merge.
@@ -359,7 +370,10 @@ export class FederationSync extends Resource {
359
370
  skipped++;
360
371
  skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
361
372
  }
362
- // Table name → Harper database table mapping
373
+ // Table name → Harper database table mapping.
374
+ // Typed against FEDERATION_TABLE_POLICY so adding a federated table
375
+ // without deciding principalOwning is a type error, not a silent
376
+ // default (flair#1416 — refuse by whitelist, never by field presence).
363
377
  const tableMap = {
364
378
  Memory: databases.flair.Memory,
365
379
  Soul: databases.flair.Soul,
@@ -369,7 +383,9 @@ export class FederationSync extends Resource {
369
383
  const knownTables = new Set(Object.keys(tableMap));
370
384
  for (const record of records) {
371
385
  try {
372
- const table = tableMap[record.table];
386
+ const table = (record.table in tableMap)
387
+ ? tableMap[record.table]
388
+ : undefined;
373
389
  const local = table ? await table.get(record.id) : null;
374
390
  const decision = classifyRecord(record, peer.role, instanceId, local, knownTables);
375
391
  if (decision.action === "skip") {
@@ -403,22 +419,15 @@ export class FederationSync extends Resource {
403
419
  recordSkip("unknown_originator_key");
404
420
  continue;
405
421
  }
406
- // CONTRACT — must match src/cli.ts runFederationSyncOnce's signing
407
- // payload byte-for-byte: keys { v, table, id, data, updatedAt,
408
- // originatorInstanceId }. canonicalize() sorts keys, so field ORDER
409
- // doesn't matter, but the field SET and values do. `v: 1` versions
410
- // the canonical form itself bump it on BOTH sides together if the
411
- // signed field set ever changes, so an old signature fails closed
412
- // instead of silently mis-verifying under a new form.
413
- const signatureValid = verifyBodySignature({
414
- v: 1,
415
- table: record.table,
416
- id: record.id,
417
- data: record.data,
418
- updatedAt: record.updatedAt,
419
- originatorInstanceId: originator,
420
- signature: record.signature,
421
- }, originatorPublicKey);
422
+ // CONTRACT — reconstruct from the record (v defaults to 1 when
423
+ // absent v is NOT on the wire today). Must match
424
+ // reconstructRecordVerifyBody / src/cli.ts's signing payload.
425
+ // A hardcoded { v: 1, table, id, data, updatedAt,
426
+ // originatorInstanceId } field set can only ever verify one
427
+ // shape; building from the record is what makes v:2 (principalId
428
+ // in the signed body) verifiable without breaking existing
429
+ // records. See flair#1416.
430
+ const signatureValid = verifyBodySignature(reconstructRecordVerifyBody(record, originator), originatorPublicKey);
422
431
  if (!signatureValid) {
423
432
  recordSkip("invalid_signature");
424
433
  continue;
@@ -431,6 +440,19 @@ export class FederationSync extends Resource {
431
440
  recordSkip("missing_signature");
432
441
  continue;
433
442
  }
443
+ // ── Per-record principal entitlement (flair#1416 / slice 3a) ──
444
+ // After signature verification, before table.put. Scoped by the
445
+ // explicit PRINCIPAL_OWNING_TABLES set (Memory), never by whether
446
+ // principalId happens to be present — absent Memory principalId
447
+ // is a skip, not an accept. Soul/Agent/Relationship are not in
448
+ // the set and are not consulted. No Agent.get.
449
+ const principalSkip = checkPrincipalEntitlement(record, {
450
+ enforceV1Principal: requireRecordPrincipal(),
451
+ });
452
+ if (principalSkip) {
453
+ recordSkip(principalSkip);
454
+ continue;
455
+ }
434
456
  const mergedData = mergeRecord(local, record);
435
457
  mergedData._originatorInstanceId = decision.originator;
436
458
  mergedData._syncedFrom = instanceId;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * MemoryArchive.ts — user-facing archive action (flair#1472, Deliverable A).
3
+ *
4
+ * POST /MemoryArchive — sets or clears the `archived` visibility flag on a
5
+ * memory by id:
6
+ * - `action: "basement"` → archived=true + stamps archivedAt (and archivedBy)
7
+ * - `action: "restore"` → archived=false + clears archivedAt/archivedBy
8
+ *
9
+ * `archived` is a VISIBILITY flag, not a deletion: basementing removes a
10
+ * memory from bootstrap + default search but leaves the row, its provenance,
11
+ * and its history fully intact (still retrievable via memory_get and
12
+ * memory_search(includeArchived:true)). Restore is the deliberate, GLOBAL
13
+ * inverse — it un-retires the memory for EVERY session, not a session-local
14
+ * view (per-session reuse is drawers, Deliverable B, which does not exist
15
+ * yet). The CLI help text must make that global scope explicit.
16
+ *
17
+ * Own-lane scope: the read uses Memory.get()'s read-scope gate and the write
18
+ * uses Memory.put()'s ownership gate (stampAttribution), so a caller can
19
+ * neither read nor write another agent's memory here. Anonymous HTTP is
20
+ * denied (401).
21
+ *
22
+ * Registered automatically at /MemoryArchive via config.yaml's
23
+ * `jsResource: files: dist/resources/*.js` (named export → export name).
24
+ */
25
+ import { Resource } from "harper";
26
+ import { Memory } from "./Memory.js";
27
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
28
+ function json(status, body) {
29
+ return new Response(JSON.stringify(body), {
30
+ status,
31
+ headers: { "content-type": "application/json" },
32
+ });
33
+ }
34
+ /** Unwrap a Harper Response (has .json() + .status) into a plain object, else pass through. */
35
+ async function unwrap(value) {
36
+ if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
37
+ try {
38
+ const body = await value.json();
39
+ return { ...body, status: value.status };
40
+ }
41
+ catch {
42
+ return { error: "request failed", status: value.status };
43
+ }
44
+ }
45
+ return value;
46
+ }
47
+ export class MemoryArchive extends Resource {
48
+ /** POST requires auth — an agent acting on its own memories (or admin). */
49
+ async allowCreate() {
50
+ return allowVerified(this.getContext?.());
51
+ }
52
+ async post(data) {
53
+ const { id, action } = data || {};
54
+ if (!id)
55
+ return json(400, { error: "id required" });
56
+ if (action !== "basement" && action !== "restore") {
57
+ return json(400, { error: "action must be 'basement' or 'restore'" });
58
+ }
59
+ const ctx = this.getContext?.();
60
+ const auth = await resolveAgentAuth(ctx);
61
+ if (auth.kind === "anonymous")
62
+ return json(401, { error: "authentication required" });
63
+ if (auth.kind !== "agent")
64
+ return json(403, { error: "forbidden" });
65
+ // Read the existing record — Memory.get()'s read-scope gate applies (own +
66
+ // org-non-private only). A non-readable id returns a 404 Response.
67
+ const existing = await Memory.get(id, ctx);
68
+ const record = await unwrap(existing);
69
+ if (!record || typeof record !== "object" || !record.id) {
70
+ return json(404, { error: "memory not found" });
71
+ }
72
+ const archived = action === "basement";
73
+ const merged = {
74
+ ...record,
75
+ archived,
76
+ updatedAt: new Date().toISOString(),
77
+ };
78
+ if (archived) {
79
+ // archivedBy is set by the caller (Memory.put() stamps archivedAt when
80
+ // archived===true). The content is unchanged, so the existing embedding
81
+ // stays valid — do NOT clear it (clearing would force a needless re-embed
82
+ // and, if the embedding engine is unavailable, silently drop the vector).
83
+ merged.archivedBy = auth.agentId;
84
+ }
85
+ else {
86
+ delete merged.archivedAt;
87
+ delete merged.archivedBy;
88
+ }
89
+ // Write back — Memory.put()'s ownership gate applies (stampAttribution), so
90
+ // a non-admin caller cannot flip another agent's memory (403).
91
+ const result = await Memory.put(merged, ctx);
92
+ return unwrap(result);
93
+ }
94
+ }
@@ -697,6 +697,7 @@ export class BootstrapMemories extends Resource {
697
697
  conditions: [
698
698
  { attribute: "agentId", comparator: "equals", value: agentId },
699
699
  { attribute: "durability", comparator: "equals", value: "permanent" },
700
+ { attribute: "archived", comparator: "not_equal", value: true },
700
701
  ],
701
702
  select: OWN_SELECT,
702
703
  }));
@@ -767,6 +768,7 @@ export class BootstrapMemories extends Resource {
767
768
  conditions: [
768
769
  { attribute: "agentId", comparator: "equals", value: agentId },
769
770
  { attribute: "durability", comparator: "not_equal", value: "permanent" },
771
+ { attribute: "archived", comparator: "not_equal", value: true },
770
772
  ],
771
773
  select: OWN_SELECT,
772
774
  sort: { attribute: "createdAt", descending: true },
@@ -952,7 +954,7 @@ export class BootstrapMemories extends Resource {
952
954
  const candidatePoolK = Math.min(MAX_CANDIDATE_POOL, Math.max(3 * expectedFill, 5 * teammateIds.length, MIN_CANDIDATE_POOL));
953
955
  const candidates = await retrieveCandidates({
954
956
  queryEmbedding,
955
- conditions: [scope.condition],
957
+ conditions: [scope.condition, { attribute: "archived", comparator: "not_equal", value: true }],
956
958
  limit: candidatePoolK,
957
959
  // flair#1246 — ONE RANKER, ONE SCALE: this pass now invokes the
958
960
  // core in the SAME mode memory_search does (hybrid + q via the
@@ -97,6 +97,7 @@ import { Resource } from "harper";
97
97
  import { resolveAgentAuth } from "./agent-auth.js";
98
98
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
99
99
  import { recordUsageContribution, MAX_USAGE_IDS_PER_CALL } from "./usage-recording.js";
100
+ import { resolveRecordUsageIds } from "./usage-ids.js";
100
101
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
101
102
  const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
102
103
  // flair#744 slice A: sourced from the shared module (./usage-recording.ts)
@@ -159,14 +160,20 @@ export class RecordUsage extends Resource {
159
160
  const rl = checkRateLimit(agentId, "usage");
160
161
  if (!rl.allowed)
161
162
  return rateLimitResponse(rl.retryAfterMs, "usage");
162
- const rawIds = data?.memoryIds ?? (typeof data?.memoryId === "string" ? [data.memoryId] : undefined);
163
- if (!Array.isArray(rawIds) || rawIds.length === 0 || !rawIds.every((id) => typeof id === "string" && id.length > 0)) {
163
+ // flair#1410: MERGE memoryId + memoryIds (union, then dedupe). The
164
+ // previous `data?.memoryIds ?? [data?.memoryId]` preferred the plural
165
+ // and silently dropped the singular — quiet data loss. Unioning HERE
166
+ // means a client that POSTs both fields straight through (without
167
+ // flattening first) still credits both. Native `/mcp` also unions
168
+ // before calling this; the endpoint is the guarantee, not the client.
169
+ const resolved = resolveRecordUsageIds(data, MAX_IDS_PER_CALL);
170
+ if (!resolved.ok) {
171
+ if (resolved.error === "cap") {
172
+ return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
173
+ }
164
174
  return BAD_REQUEST("memoryIds must be a non-empty array of memory id strings");
165
175
  }
166
- if (rawIds.length > MAX_IDS_PER_CALL) {
167
- return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
168
- }
169
- const memoryIds = [...new Set(rawIds)]; // dedupe within THIS call too
176
+ const memoryIds = resolved.ids;
170
177
  const attribution = sanitizeAttribution(data?.attribution);
171
178
  const now = new Date().toISOString();
172
179
  for (const memoryId of memoryIds) {
@@ -61,7 +61,7 @@ export class SemanticSearch extends Resource {
61
61
  // recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
62
62
  // before reconsidering this default if the compositeScore formula or
63
63
  // corpus changes.
64
- const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false } = data || {};
64
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false, includeLegs = false, includeArchived = false } = data || {};
65
65
  // Authenticated identity lives on the Harper Resource context (getContext().request).
66
66
  // `this.request` is NOT populated on Harper v5 Resources — prior reads here
67
67
  // silently returned undefined and the defense-in-depth scope check below
@@ -160,7 +160,12 @@ export class SemanticSearch extends Resource {
160
160
  }
161
161
  // Exclude archived records. Use "not_equal" (Harper v5 comparator) instead of
162
162
  // "equals false" so records without the archived field are included.
163
- conditions.push({ attribute: "archived", comparator: "not_equal", value: true });
163
+ // flair#1472 `includeArchived` opts back IN to the basement: when true the
164
+ // archived predicate is omitted entirely, so basemented memories are returned
165
+ // under the SAME read-scope gate as a normal search (never a wider scope).
166
+ if (!includeArchived) {
167
+ conditions.push({ attribute: "archived", comparator: "not_equal", value: true });
168
+ }
164
169
  if (tag) {
165
170
  conditions.push({ attribute: "tags", comparator: "equals", value: tag });
166
171
  }
@@ -215,6 +220,7 @@ export class SemanticSearch extends Resource {
215
220
  // composite re-scoring headroom to reorder before the final slice.
216
221
  const candidateLimit = limit * CANDIDATE_MULTIPLIER;
217
222
  const ctx = this.getContext?.();
223
+ let legs;
218
224
  const filteredResults = await retrieveCandidates({
219
225
  queryEmbedding: qEmb,
220
226
  q,
@@ -230,6 +236,7 @@ export class SemanticSearch extends Resource {
230
236
  isAllowed: scope?.isAllowed,
231
237
  hybrid,
232
238
  ctx,
239
+ onLegs: includeLegs ? (l) => { legs = l; } : undefined,
233
240
  // flair#744 slice 1: the trust block needs `provenance`, which the
234
241
  // default projection omits. Widen the select ONLY when the caller opts
235
242
  // in — passing undefined otherwise keeps the default (no `provenance`)
@@ -330,6 +337,11 @@ export class SemanticSearch extends Resource {
330
337
  if (!qEmb && q && getMode() === "none") {
331
338
  response._warning = "semantic search unavailable — results are keyword-only";
332
339
  }
340
+ // flair#1358: opt-in per-leg candidate ids for the bench instrument.
341
+ // Default OFF ⇒ response is byte-identical (no `legs` key). The ranked
342
+ // `results` slice is unchanged either way — this is observation only.
343
+ if (includeLegs && legs)
344
+ response.legs = legs;
333
345
  return response;
334
346
  }
335
347
  }
@@ -5,6 +5,96 @@
5
5
  * spinning up Harper's database module. The same SkipReason names are used
6
6
  * in SyncLog.skippedReasons so operators can grep for them.
7
7
  */
8
+ /**
9
+ * Static policy for every table FederationSync will merge.
10
+ *
11
+ * Lives next to SkipReason / SyncRecord so the principal-owning decision is
12
+ * one visible list, not a condition scattered through the apply path.
13
+ * `Federation.ts` types its `tableMap` as `Record<FederationSyncTable, …>`,
14
+ * so adding a federated table without deciding `principalOwning` here is a
15
+ * type error rather than a silent default.
16
+ *
17
+ * Scope the principalId requirement by TABLE, never by field presence.
18
+ * Memory carries agentId / a provenance stamp; Soul, Agent, and
19
+ * Relationship do not, and will legitimately have no principalId.
20
+ */
21
+ export const FEDERATION_TABLE_POLICY = {
22
+ Memory: { principalOwning: true },
23
+ Soul: { principalOwning: false },
24
+ Agent: { principalOwning: false },
25
+ Relationship: { principalOwning: false },
26
+ };
27
+ export const FEDERATION_SYNC_TABLES = Object.keys(FEDERATION_TABLE_POLICY);
28
+ export const PRINCIPAL_OWNING_TABLES = new Set(FEDERATION_SYNC_TABLES.filter((t) => FEDERATION_TABLE_POLICY[t].principalOwning));
29
+ /** Wire `v` when present; assume 1 when absent (today's records omit it). */
30
+ export function recordSignatureVersion(record) {
31
+ return record.v ?? 1;
32
+ }
33
+ /**
34
+ * Rebuild the object FederationSync verifies against the originator's key.
35
+ *
36
+ * Load-bearing details, both of which fail every existing record if missed:
37
+ *
38
+ * 1. `v` is not on the wire today. The push side signs a body containing
39
+ * `v: 1` but sends a SyncRecord without it. Verification works today
40
+ * only because the receiver pinned the same literal. Default it.
41
+ * Apply `v` AFTER the spread so an absent/undefined `record.v` cannot
42
+ * overwrite the default.
43
+ *
44
+ * 2. `principalId` IS on the wire today for some records, but it is
45
+ * attached AFTER signing (informational). Spreading it into a v:1
46
+ * verify body changes the field set and fails those records. v:1
47
+ * therefore strips it; v:2 signs it, so it stays.
48
+ *
49
+ * `originatorInstanceId` is the classifyRecord originator (same override
50
+ * the pre-3a hardcoded reconstruction used), not a blind spread of the
51
+ * wire field.
52
+ */
53
+ export function reconstructRecordVerifyBody(record, originator) {
54
+ const v = recordSignatureVersion(record);
55
+ const { signature, v: _wireV, principalId, ...payload } = record;
56
+ const verifyPayload = v >= 2 && principalId !== undefined ? { ...payload, principalId } : payload;
57
+ return {
58
+ ...verifyPayload,
59
+ v,
60
+ originatorInstanceId: originator,
61
+ signature,
62
+ };
63
+ }
64
+ /**
65
+ * Per-record principal entitlement — apply-site check, DB-free.
66
+ *
67
+ * Table in PRINCIPAL_OWNING_TABLES → principalId is mandatory on v:2
68
+ * (absent is a skip, mismatched is a skip). Table not in the set →
69
+ * principalId is not consulted at all.
70
+ *
71
+ * Does not load Agent, does not read originatorInstanceId off an Agent
72
+ * row. The record's own stamp is the binding.
73
+ *
74
+ * `enforceV1Principal` is Phase 3 (FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL):
75
+ * skip leftover v:1 records on principal-owning tables that lack
76
+ * principalId. Off by default — v:1 Memory keeps merging until an
77
+ * operator flips the flag after the fleet is on v:2.
78
+ */
79
+ export function checkPrincipalEntitlement(record, opts = {}) {
80
+ if (!PRINCIPAL_OWNING_TABLES.has(record.table)) {
81
+ return null;
82
+ }
83
+ const v = recordSignatureVersion(record);
84
+ if (v >= 2) {
85
+ if (typeof record.principalId !== "string" ||
86
+ record.principalId.length === 0 ||
87
+ record.principalId !== record.data?.agentId) {
88
+ return "principal_mismatch";
89
+ }
90
+ return null;
91
+ }
92
+ if (opts.enforceV1Principal &&
93
+ (typeof record.principalId !== "string" || record.principalId.length === 0)) {
94
+ return "principal_mismatch";
95
+ }
96
+ return null;
97
+ }
8
98
  export function classifyRecord(record, peerRole, receiverInstanceId, local, knownTables, now = new Date()) {
9
99
  if (!knownTables.has(record.table)) {
10
100
  return { action: "skip", reason: "unknown_table" };
@@ -121,22 +121,18 @@ export class Health extends Resource {
121
121
  }
122
122
  }
123
123
  /** Same sources /Health and /HealthDetail consult so they cannot disagree. */
124
- let _warnedMissingRegistry = false;
125
- function currentSearchReadiness() {
126
- // Shipped Harper launch: this Resource is already registered, so
127
- // server.resources is populated. The null skip is a stated fail-open
128
- // (Sherlock on #1406) for the injectable/test path — not an accident.
124
+ export function currentSearchReadiness() {
125
+ // Fail-open when the registry is missing (Sherlock on #1406 / flair#1411):
126
+ // do not 503 forever. resolveSearchReadiness warns once and names the
127
+ // degradation; we do not treat "registry should always be here" as a given.
129
128
  const resources = server.resources ?? null;
130
- if (!resources && !_warnedMissingRegistry) {
131
- _warnedMissingRegistry = true;
132
- logger.warn?.("Health: server.resources is absent — skipping the search-route mount check (table-only fail-open). Shipped Harper launch always exposes the registry.");
133
- }
134
129
  return resolveSearchReadiness({
135
130
  resources,
136
131
  memoryTable: db.flair?.Memory,
137
132
  bm25: bm25IndexStatus(),
138
133
  hybridEnabled: hybridEnabled(),
139
134
  bm25IndexEnabled: bm25IndexEnabled(),
135
+ warn: (message) => { logger.warn?.(message); },
140
136
  });
141
137
  }
142
138
  /**
@@ -168,7 +164,10 @@ export class HealthDetail extends Resource {
168
164
  // and a warning name the lag so `flair status` / operators can see it.
169
165
  const readiness = currentSearchReadiness();
170
166
  stats.searchReady = readiness.searchReady;
171
- if (readiness.searchReadyReason) {
167
+ // Public/detail shape is unchanged: searchReadyReason stays a lag signal
168
+ // (present iff !searchReady). Ready-path verification constants stay on
169
+ // the decision object (flair#1411).
170
+ if (!readiness.searchReady && readiness.searchReadyReason) {
172
171
  stats.searchReadyReason = readiness.searchReadyReason;
173
172
  warnings.push({ level: "warn", message: readiness.searchReadyReason });
174
173
  }