@tpsdev-ai/flair 0.49.0 → 0.51.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 (39) hide show
  1. package/README.md +15 -4
  2. package/dist/bridges/runtime/roundtrip.js +91 -2
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli.js +565 -171
  5. package/dist/deploy.js +20 -3
  6. package/dist/doctor-client.js +62 -34
  7. package/dist/federation/scheduler.js +24 -3
  8. package/dist/hook-install.js +45 -13
  9. package/dist/install/clients.js +28 -17
  10. package/dist/lib/doctor-run.js +481 -0
  11. package/dist/lib/launchd-management.js +7 -26
  12. package/dist/lib/scheduler-platform.js +132 -10
  13. package/dist/lib/scratch-owner.js +49 -0
  14. package/dist/rem/scheduler.js +23 -5
  15. package/dist/resources/Federation.js +42 -20
  16. package/dist/resources/MemoryBootstrap.js +8 -4
  17. package/dist/resources/RecordUsage.js +13 -6
  18. package/dist/resources/SemanticSearch.js +8 -1
  19. package/dist/resources/federation-classify.js +90 -0
  20. package/dist/resources/health.js +51 -7
  21. package/dist/resources/mcp-tools.js +10 -6
  22. package/dist/resources/search-readiness.js +123 -0
  23. package/dist/resources/semantic-retrieval-core.js +48 -21
  24. package/dist/resources/sort-comparators.js +45 -0
  25. package/dist/resources/usage-ids.js +63 -0
  26. package/dist/src/lib/scheduler-platform.js +132 -10
  27. package/dist/src/rem/scheduler.js +23 -5
  28. package/docs/auth.md +5 -0
  29. package/docs/deepseek-harness.md +1 -1
  30. package/docs/federation.md +11 -0
  31. package/docs/hosted-on-fabric.md +2 -0
  32. package/docs/integrations.md +53 -1
  33. package/docs/mcp-clients.md +67 -15
  34. package/docs/quickstart-fabric.md +1 -1
  35. package/docs/supply-chain-policy.md +1 -1
  36. package/docs/troubleshooting.md +25 -0
  37. package/docs/upgrade.md +17 -1
  38. package/package.json +3 -3
  39. package/schemas/federation.graphql +1 -1
@@ -20,9 +20,9 @@
20
20
  * in `verifyFirstRun()`: success may not be claimed until the thing the
21
21
  * operator asked for has been observed to happen once.
22
22
  */
23
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
24
- import { resolve, dirname, isAbsolute } from "node:path";
25
- import { platform } from "node:os";
23
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
24
+ import { resolve, dirname, isAbsolute, basename } from "node:path";
25
+ import { platform, userInfo } from "node:os";
26
26
  import { spawnSync } from "node:child_process";
27
27
  /**
28
28
  * 30s ceiling on launchctl/systemctl invocations so a hung service manager
@@ -90,20 +90,71 @@ export function interpretActiveResult(plat, code, stdout, stderr) {
90
90
  return null; // spawn itself failed — inconclusive
91
91
  return false; // covers the no-bus case: empty stdout, nonzero/failed exit
92
92
  }
93
+ /** True when this session already has the env `systemctl --user` needs. */
94
+ export function sessionHasUserBusEnv(env = process.env) {
95
+ return Boolean(env.XDG_RUNTIME_DIR?.trim() && env.DBUS_SESSION_BUS_ADDRESS?.trim());
96
+ }
97
+ /**
98
+ * Reads whether lingering is already enabled for the current user.
99
+ * `loginctl show-user … Linger=yes` is the official answer; the stamp file
100
+ * `loginctl enable-linger` creates is the fallback when loginctl is missing
101
+ * or inconclusive. A failed probe is `null`, never linger-off — inventing
102
+ * linger-off would repeat the linger remedy after it already ran (#1107).
103
+ */
104
+ export function probeUserLingerEnabled(opts = {}) {
105
+ const run = opts.run ?? spawnReport;
106
+ const lingerStampExists = opts.lingerStampExists ?? ((u) => existsSync(`/var/lib/systemd/linger/${u}`));
107
+ let user = "";
108
+ try {
109
+ user = userInfo().username;
110
+ }
111
+ catch {
112
+ user = process.env.USER || process.env.LOGNAME || "";
113
+ }
114
+ if (!user)
115
+ return null;
116
+ const r = run(["loginctl", "show-user", user, "--property=Linger"], STATUS_CHECK_TIMEOUT_MS);
117
+ const m = /^Linger=(yes|no)\s*$/m.exec(r.stdout ?? "");
118
+ if (m)
119
+ return m[1] === "yes";
120
+ if (lingerStampExists(user))
121
+ return true;
122
+ return null;
123
+ }
93
124
  /**
94
- * Human remedy text for a failed scheduler-load attempt (flair#850). Covers
95
- * the one root cause traced so far: a missing systemd user session bus,
96
- * which blocks `systemctl --user` entirely in ssh-without-lingering,
97
- * container, and CI contexts. Returns null when the failure doesn't match a
98
- * known patternthe caller already prints the raw stderr, so the operator
99
- * still has something to go on.
125
+ * Human remedy text for a failed scheduler-load attempt (flair#850, #1107).
126
+ * Covers the traced "no systemd user session bus" failure, which blocks
127
+ * `systemctl --user` entirely in ssh-without-lingering, container, and CI
128
+ * contexts. Two cases that used to share one remedy:
129
+ * (a) lingering genuinely off print `loginctl enable-linger`
130
+ * (b) linger already on, this session has no user-bus env — print the
131
+ * `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` export lines
132
+ * Repeating (a) after the operator has applied it is the #1107 lie.
133
+ * Returns null when the failure doesn't match a known pattern — the caller
134
+ * already prints the raw stderr, so the operator still has something to go on.
100
135
  *
101
136
  * `enableCommand` is the caller's own enable invocation, named in the remedy
102
137
  * so the operator is told to re-run the command they actually ran.
103
138
  */
104
- export function describeLoadFailure(plat, loadResult, enableCommand) {
139
+ export function describeLoadFailure(plat, loadResult, enableCommand, session) {
105
140
  const stderr = loadResult.stderr || "";
106
141
  if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
142
+ if (session?.lingerEnabled === true) {
143
+ const env = session.env ?? process.env;
144
+ if (!sessionHasUserBusEnv(env)) {
145
+ return ("No systemd user session bus is available in this session. Lingering is already enabled — " +
146
+ "do not re-run `loginctl enable-linger`. The remaining gap is this session's user-bus environment. " +
147
+ "Export:\n" +
148
+ " export XDG_RUNTIME_DIR=/run/user/$(id -u)\n" +
149
+ " export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus\n" +
150
+ ` then re-run \`${enableCommand}\`.`);
151
+ }
152
+ return ("No systemd user session bus is available in this session. Lingering is already enabled and " +
153
+ "this session already has XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS — " +
154
+ "do not re-run `loginctl enable-linger` or re-export those variables. " +
155
+ "Check that `$XDG_RUNTIME_DIR/bus` exists (the systemd --user instance may not be running), " +
156
+ `then re-run \`${enableCommand}\`.`);
157
+ }
107
158
  return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
108
159
  "in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
109
160
  `— then re-run \`${enableCommand}\`.`);
@@ -172,6 +223,77 @@ export function resolveNodeBin(explicit) {
172
223
  "enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
173
224
  "at run time. Install node (or put it on PATH for this shell) and re-run enable.");
174
225
  }
226
+ /**
227
+ * Resolves the path enable will bake as FLAIR_BIN, and whether that path is
228
+ * the stable public `flair` entry (flair#1279).
229
+ *
230
+ * Resolution order:
231
+ * 1. `explicit` — caller/test override. Relatives are resolved against cwd.
232
+ * 2. `hooks.argv1` / `process.argv[1]` — whatever launched enable.
233
+ * 3. The public `flair` on PATH, only when (1) and (2) are empty.
234
+ * Nothing absolute resolvable ⇒ throw. A bare `"flair"` is not an exec
235
+ * target under #1231's `exec <node> <script>` form (`node flair` looks in
236
+ * cwd, not PATH).
237
+ */
238
+ export function resolveFlairBin(explicit, hooks) {
239
+ const publicBin = hooks && "publicBin" in hooks ? (hooks.publicBin ?? null) : lookupPublicFlairBin();
240
+ const captured = explicit ?? hooks?.argv1 ?? process.argv[1];
241
+ let path;
242
+ if (typeof captured === "string" && captured.length > 0) {
243
+ path = isAbsolute(captured) ? captured : resolve(captured);
244
+ }
245
+ else if (publicBin) {
246
+ path = publicBin;
247
+ }
248
+ else {
249
+ throw new Error("unable to resolve an absolute path to the flair CLI (process.argv[1] was empty and `command -v flair` " +
250
+ "found nothing). The scheduler shim bakes this path in at enable time — refusing to install a shim " +
251
+ "whose exec target is unknown. Re-run enable via the `flair` command.");
252
+ }
253
+ return { path, publicBin, canonical: isCanonicalFlairBin(path, publicBin) };
254
+ }
255
+ /** True when `baked` is the public `flair` entry, not a working-tree capture. */
256
+ export function isCanonicalFlairBin(baked, publicBin) {
257
+ if (basename(baked) === "flair")
258
+ return true;
259
+ if (publicBin && pathsReferToSameFile(baked, publicBin))
260
+ return true;
261
+ return false;
262
+ }
263
+ /**
264
+ * The enable-report lines for a non-canonical FLAIR_BIN. Empty when the
265
+ * baked path is the public entry — callers should not print a warning then.
266
+ */
267
+ export function formatFlairBinWarning(baked, publicBin, enableCommand) {
268
+ if (isCanonicalFlairBin(baked, publicBin))
269
+ return [];
270
+ const lines = [
271
+ `⚠️ FLAIR_BIN is ${baked} — that is the process that ran enable, not a stable public entry.`,
272
+ ` A later blue/green directory swap, or deleting this working tree, will strand the scheduler unit.`,
273
+ ];
274
+ if (publicBin) {
275
+ lines.push(` Public \`flair\` on PATH: ${publicBin}. Re-run \`${enableCommand}\` as the \`flair\` command to bake that path instead.`);
276
+ }
277
+ else {
278
+ lines.push(` No \`flair\` on PATH. Re-run \`${enableCommand}\` via the installed \`flair\` command (or a stable symlink) so the baked path survives a tree swap.`);
279
+ }
280
+ return lines;
281
+ }
282
+ function lookupPublicFlairBin() {
283
+ const r = spawnReport(["/bin/sh", "-c", "command -v flair"], STATUS_CHECK_TIMEOUT_MS);
284
+ const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
285
+ if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
286
+ return found;
287
+ return null;
288
+ }
289
+ function pathsReferToSameFile(a, b) {
290
+ try {
291
+ return realpathSync(a) === realpathSync(b);
292
+ }
293
+ catch {
294
+ return resolve(a) === resolve(b);
295
+ }
296
+ }
175
297
  // ─── first-run verification (flair#1231) ────────────────────────────────────
176
298
  // A load/bootstrap command exiting 0 proves the service manager accepted the
177
299
  // job — not that the job can run. The only vantage that exercises the real
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Owner stamp for harness scratch directories (flair#1032).
3
+ *
4
+ * Directory mtime is not a liveness signal: on Linux, appending to files
5
+ * inside subdirectories does not update the parent. The stamp records the
6
+ * creating process; a sweep may delete a tree only when that process is gone
7
+ * (and, for Harper trees, when `hdb.pid` is gone too).
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ export const SCRATCH_OWNER_FILE = ".flair-scratch-owner";
12
+ export function writeScratchOwnerStamp(dir, pid = process.pid) {
13
+ writeFileSync(join(dir, SCRATCH_OWNER_FILE), `${pid}\n`, { encoding: "utf-8" });
14
+ }
15
+ export function isPidAlive(pid) {
16
+ if (!Number.isInteger(pid) || pid <= 0)
17
+ return false;
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ function readPidFile(path) {
27
+ try {
28
+ const pid = Number(readFileSync(path, "utf-8").trim());
29
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function readScratchOwnerPid(dir) {
36
+ return readPidFile(join(dir, SCRATCH_OWNER_FILE));
37
+ }
38
+ export function hasScratchOwnerStamp(dir) {
39
+ return existsSync(join(dir, SCRATCH_OWNER_FILE));
40
+ }
41
+ /** True when the creating process is still alive. Unreadable stamp → not live. */
42
+ export function scratchOwnerIsLive(dir) {
43
+ const pid = readScratchOwnerPid(dir);
44
+ return pid !== null && isPidAlive(pid);
45
+ }
46
+ export function hdbPidIsLive(dir) {
47
+ const pid = readPidFile(join(dir, "hdb.pid"));
48
+ return pid !== null && isPidAlive(pid);
49
+ }
@@ -19,7 +19,7 @@ import { homedir } from "node:os";
19
19
  import { spawn } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { escapeXml } from "../lib/xml-escape.js";
22
- import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
22
+ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readTemplateFrom, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, resolveFlairBin, formatFlairBinWarning, verifyFirstRun, probeUserLingerEnabled, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
23
23
  // Re-exported so this module's public surface is unchanged by the extraction
24
24
  // into src/lib/scheduler-platform.ts (a second scheduler — `flair federation
25
25
  // sync enable` — needs the identical launchctl/systemctl interpretation, and
@@ -180,8 +180,8 @@ export async function queryActiveStateAsync(plat, timeoutMs = STATUS_CHECK_TIMEO
180
180
  * known pattern — the caller already prints the raw stderr, so the operator
181
181
  * still has something to go on.
182
182
  */
183
- export function describeLoadFailure(plat, loadResult) {
184
- return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable");
183
+ export function describeLoadFailure(plat, loadResult, session) {
184
+ return describeLoadFailureFor(plat, loadResult, "flair rem nightly enable", session);
185
185
  }
186
186
  /**
187
187
  * Formats the `flair rem nightly enable` report from an `EnableResult`.
@@ -199,6 +199,15 @@ export function describeLoadFailure(plat, loadResult) {
199
199
  * happen once. A missing `loadResult`/`firstRun` (test-only skipLoad shape)
200
200
  * therefore withholds the headline too, instead of being treated as success.
201
201
  */
202
+ function appendFlairBinWarning(lines, r) {
203
+ if (r.flairBinCanonical !== false || !r.flairBin)
204
+ return;
205
+ const warning = formatFlairBinWarning(r.flairBin, r.flairBinPublic ?? null, "flair rem nightly enable");
206
+ if (warning.length === 0)
207
+ return;
208
+ lines.push("");
209
+ lines.push(...warning);
210
+ }
202
211
  export function formatEnableReport(r, input) {
203
212
  const { hour, minute, agentId, flairUrl } = input;
204
213
  const scheduleTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
@@ -216,11 +225,15 @@ export function formatEnableReport(r, input) {
216
225
  ];
217
226
  if (lr.stderr)
218
227
  lines.push(` stderr: ${lr.stderr.trim()}`);
219
- const remedy = describeLoadFailure(r.platform, lr);
228
+ const lingerEnabled = input.lingerEnabled !== undefined
229
+ ? input.lingerEnabled
230
+ : (r.platform === "linux" ? (input.probeLinger ?? probeUserLingerEnabled)() : undefined);
231
+ const remedy = describeLoadFailure(r.platform, lr, { lingerEnabled, env: input.env });
220
232
  lines.push("");
221
233
  lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
222
234
  lines.push("");
223
235
  lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair rem nightly status`);
236
+ appendFlairBinWarning(lines, r);
224
237
  return { lines, ok: false };
225
238
  }
226
239
  if (!r.firstRunVerified) {
@@ -272,6 +285,7 @@ export function formatEnableReport(r, input) {
272
285
  }
273
286
  lines.push("");
274
287
  lines.push(` Check anytime with: flair rem nightly status`);
288
+ appendFlairBinWarning(lines, r);
275
289
  return { lines, ok: false };
276
290
  }
277
291
  const lines = [
@@ -288,6 +302,7 @@ export function formatEnableReport(r, input) {
288
302
  lines.push(` First run: completed through the service manager, exit 0`);
289
303
  lines.push("");
290
304
  lines.push(`Disable with \`flair rem nightly disable\`.`);
305
+ appendFlairBinWarning(lines, r);
291
306
  return { lines, ok: true };
292
307
  }
293
308
  /**
@@ -329,7 +344,8 @@ export function formatStatusReport(s) {
329
344
  */
330
345
  export function enableScheduler(opts) {
331
346
  const plat = detectPlatform(opts.platformOverride);
332
- const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
347
+ const resolvedFlair = resolveFlairBin(opts.flairBin);
348
+ const flairBin = resolvedFlair.path;
333
349
  const nodeBin = resolveNodeBin(opts.nodeBin);
334
350
  const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
335
351
  const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
@@ -383,6 +399,7 @@ export function enableScheduler(opts) {
383
399
  return {
384
400
  platform: plat, shimPath, schedulerPath: plistPath, loadCommand, loadResult,
385
401
  firstRunVerified: firstRun?.verified === true, firstRun,
402
+ flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
386
403
  };
387
404
  }
388
405
  // Linux: systemd user units.
@@ -408,6 +425,7 @@ export function enableScheduler(opts) {
408
425
  return {
409
426
  platform: plat, shimPath, schedulerPath: timerPath, loadCommand, loadResult,
410
427
  firstRunVerified: firstRun?.verified === true, firstRun,
428
+ flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
411
429
  };
412
430
  }
413
431
  /**
@@ -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;
@@ -502,16 +502,18 @@ export class BootstrapMemories extends Resource {
502
502
  skillAssignments.push(record);
503
503
  continue;
504
504
  }
505
- // flair#1182 — the raw soul container (key→value), independent of the
506
- // token-budgeted/priority-truncated `sections.soul` lines built below.
507
- soulMap[record.key] = record.value;
508
505
  const line = `**${record.key}:** ${record.value}`;
509
506
  const tokens = estimateTokens(line);
510
507
  const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
511
- soulEntries.push({ key: record.key, line, tokens, priority });
508
+ soulEntries.push({ key: record.key, value: record.value, line, tokens, priority });
512
509
  }
513
510
  // Sort by priority (lower = more important)
514
511
  soulEntries.sort((a, b) => a.priority - b.priority);
512
+ // flair#1371 — the structured `soul` map follows the admission decision.
513
+ // Filling it during the scan (flair#1182) shipped every key even when
514
+ // this loop dropped the entry, so sections.soul / soulTokens / the
515
+ // context pointer described N while `soul` delivered N+1 (delivered
516
+ // but not counted or charged — the #1206 mirror).
515
517
  for (const entry of soulEntries) {
516
518
  if (soulTokens + entry.tokens > soulMaxTokens) {
517
519
  // Skip large entries that exceed budget — truncate or skip
@@ -522,6 +524,7 @@ export class BootstrapMemories extends Resource {
522
524
  if (maxChars > 100) {
523
525
  const truncated = `**${entry.key}:** ${entry.line.slice(entry.key.length + 6, entry.key.length + 6 + maxChars)}…(truncated)`;
524
526
  sections.soul.push(truncated);
527
+ soulMap[entry.key] = entry.value;
525
528
  const cost = estimateTokens(truncated);
526
529
  soulTokens += cost;
527
530
  tokenBudget -= cost; // #1199 — soul draws from the shared budget
@@ -529,6 +532,7 @@ export class BootstrapMemories extends Resource {
529
532
  continue;
530
533
  }
531
534
  sections.soul.push(entry.line);
535
+ soulMap[entry.key] = entry.value;
532
536
  soulTokens += entry.tokens;
533
537
  tokenBudget -= entry.tokens; // #1199 — soul draws from the shared budget
534
538
  }
@@ -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 } = 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
@@ -215,6 +215,7 @@ export class SemanticSearch extends Resource {
215
215
  // composite re-scoring headroom to reorder before the final slice.
216
216
  const candidateLimit = limit * CANDIDATE_MULTIPLIER;
217
217
  const ctx = this.getContext?.();
218
+ let legs;
218
219
  const filteredResults = await retrieveCandidates({
219
220
  queryEmbedding: qEmb,
220
221
  q,
@@ -230,6 +231,7 @@ export class SemanticSearch extends Resource {
230
231
  isAllowed: scope?.isAllowed,
231
232
  hybrid,
232
233
  ctx,
234
+ onLegs: includeLegs ? (l) => { legs = l; } : undefined,
233
235
  // flair#744 slice 1: the trust block needs `provenance`, which the
234
236
  // default projection omits. Widen the select ONLY when the caller opts
235
237
  // in — passing undefined otherwise keeps the default (no `provenance`)
@@ -330,6 +332,11 @@ export class SemanticSearch extends Resource {
330
332
  if (!qEmb && q && getMode() === "none") {
331
333
  response._warning = "semantic search unavailable — results are keyword-only";
332
334
  }
335
+ // flair#1358: opt-in per-leg candidate ids for the bench instrument.
336
+ // Default OFF ⇒ response is byte-identical (no `legs` key). The ranked
337
+ // `results` slice is unchanged either way — this is observation only.
338
+ if (includeLegs && legs)
339
+ response.legs = legs;
333
340
  return response;
334
341
  }
335
342
  }
@@ -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" };