@tpsdev-ai/flair 0.50.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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * doctor-run.ts — the enumerable install-health runner (flair#1439).
3
+ *
4
+ * `flair doctor` and `flair upgrade` used to keep two definitions of
5
+ * "healthy". Upgrade's success line (`renderVerifiedSummary`) could only
6
+ * qualify a ✅ by the facts in its signature — version plus launchd — so
7
+ * every other doctor check was invisible and rendered green. The previous
8
+ * fix special-cased one known-unhealthy form (launchd detach). That is a
9
+ * blacklist: the next unmeasured fact (a Codex SessionStart hook 0.49.0
10
+ * never wrote) printed the same unqualified success marker.
11
+ *
12
+ * This module is the single entry point that runs the named install-health
13
+ * checks. Adding a check to `DOCTOR_CHECK_IDS` automatically widens what
14
+ * upgrade claims. Success is asserted POSITIVELY: every catalog id must
15
+ * have been executed and none may have failed. An `unrun` member is never
16
+ * treated as a pass — if a reachable state cannot produce "unrun ⇒ no ✅",
17
+ * the enumeration is decorative and we have rebuilt the same guard.
18
+ *
19
+ * Skip (N/A) is not unrun. Linux has no launchd; a machine without Codex
20
+ * does not owe a Codex hook. Those checks execute and return `skip`.
21
+ */
22
+ import { existsSync, readdirSync } from "node:fs";
23
+ import { checkClaudeMdBootstrap, checkSessionStartHook, effectiveFlairUrl, hookCommandIsSilenced, isFlairHookCommand, partitionKeyIds, planAgentIterations, readClientMcpBlock, } from "../doctor-client.js";
24
+ import { hookInstallHint, hookSettingsPath, installHook, SUPPORTED_HARNESSES, } from "../hook-install.js";
25
+ import { isDetached, renderDetachedWarning, } from "./launchd-management.js";
26
+ /**
27
+ * The catalog. This is the contract upgrade asserts against.
28
+ *
29
+ * A new doctor install-health check is added HERE (id + runner). An id
30
+ * without a runner is emitted as `unrun` and blocks the success marker —
31
+ * that is the point, not a fallback.
32
+ *
33
+ * Flint's six named client-integration checks plus launchd (the previous
34
+ * special-case, now a catalog member rather than a side channel).
35
+ */
36
+ export const DOCTOR_CHECK_IDS = [
37
+ "mcp-block",
38
+ "flair-url",
39
+ "claude-md",
40
+ "session-start-hook",
41
+ "verified-read",
42
+ "keys-prune",
43
+ "launchd-management",
44
+ ];
45
+ const MCP_CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
46
+ function result(id, label, status, extra = {}) {
47
+ return { id, label, status, ...extra };
48
+ }
49
+ function runMcpBlock(ctx) {
50
+ const id = "mcp-block";
51
+ const label = "MCP server block";
52
+ const mcp = ctx.detectedClientIds.filter((c) => MCP_CLIENT_IDS.includes(c));
53
+ if (mcp.length === 0) {
54
+ return result(id, label, "skip", { detail: "no MCP client detected" });
55
+ }
56
+ const missing = [];
57
+ for (const clientId of mcp) {
58
+ const block = readClientMcpBlock(clientId, ctx.homeDir);
59
+ if (!block.present)
60
+ missing.push(`${clientId} (${block.configPath})`);
61
+ }
62
+ if (missing.length > 0) {
63
+ return result(id, label, "fail", {
64
+ detail: `no Flair MCP server configured: ${missing.join(", ")}`,
65
+ remedy: "flair doctor --fix",
66
+ });
67
+ }
68
+ return result(id, label, "pass", { detail: `configured for ${mcp.join(", ")}` });
69
+ }
70
+ function runFlairUrl(ctx) {
71
+ const id = "flair-url";
72
+ const label = "FLAIR_URL";
73
+ const mcp = ctx.detectedClientIds.filter((c) => MCP_CLIENT_IDS.includes(c));
74
+ if (mcp.length === 0) {
75
+ return result(id, label, "skip", { detail: "no MCP client detected" });
76
+ }
77
+ // Presence of a working URL (explicit or client-defaulted) is the check.
78
+ // Unreachable is a doctor warn, not an install-health failure — the
79
+ // instance probe already covered liveness on the upgrade path.
80
+ const present = mcp.filter((clientId) => readClientMcpBlock(clientId, ctx.homeDir).present);
81
+ if (present.length === 0) {
82
+ return result(id, label, "skip", { detail: "no MCP block present to take a URL from" });
83
+ }
84
+ const urls = present.map((clientId) => {
85
+ const block = readClientMcpBlock(clientId, ctx.homeDir);
86
+ const eff = effectiveFlairUrl(block);
87
+ return `${clientId}=${eff.url}${eff.defaulted ? " (client default)" : ""}`;
88
+ });
89
+ return result(id, label, "pass", { detail: urls.join(", ") });
90
+ }
91
+ function runClaudeMd(ctx) {
92
+ const id = "claude-md";
93
+ const label = "CLAUDE.md bootstrap";
94
+ if (!ctx.detectedClientIds.includes("claude-code")) {
95
+ return result(id, label, "skip", { detail: "Claude Code not detected" });
96
+ }
97
+ const check = checkClaudeMdBootstrap(ctx.cwd, ctx.homeDir);
98
+ if (!check.present) {
99
+ return result(id, label, "fail", {
100
+ detail: "bootstrap instruction not found",
101
+ remedy: "flair doctor --fix",
102
+ });
103
+ }
104
+ return result(id, label, "pass", { detail: check.path ?? undefined });
105
+ }
106
+ function runSessionStartHook(ctx) {
107
+ const id = "session-start-hook";
108
+ const label = "SessionStart hook";
109
+ const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h));
110
+ if (harnesses.length === 0) {
111
+ return result(id, label, "skip", { detail: "no hook-capable client detected" });
112
+ }
113
+ const missing = [];
114
+ const loud = [];
115
+ for (const harness of harnesses) {
116
+ const path = hookSettingsPath(ctx.homeDir, harness);
117
+ const hook = checkSessionStartHook(ctx.homeDir, path);
118
+ if (!hook.present) {
119
+ missing.push(harness);
120
+ continue;
121
+ }
122
+ if (hook.command && isFlairHookCommand(hook.command) && !hookCommandIsSilenced(hook.command)) {
123
+ loud.push(harness);
124
+ }
125
+ }
126
+ if (missing.length > 0) {
127
+ const harness = missing[0];
128
+ const path = hookSettingsPath(ctx.homeDir, harness);
129
+ const detail = missing.length === 1
130
+ ? `SessionStart hook (${harness}): not found in ${path}`
131
+ : `SessionStart hook: not found for ${missing.join(", ")} (${missing.map((h) => hookSettingsPath(ctx.homeDir, h)).join("; ")})`;
132
+ return result(id, label, "fail", {
133
+ detail,
134
+ remedy: missing.map((h) => hookInstallHint(h)).join(" ; "),
135
+ missingHarnesses: missing,
136
+ });
137
+ }
138
+ if (loud.length > 0) {
139
+ const harness = loud[0];
140
+ return result(id, label, "fail", {
141
+ detail: `SessionStart hook (${harness}): a failure would print an error on every session`,
142
+ remedy: hookInstallHint(harness),
143
+ });
144
+ }
145
+ return result(id, label, "pass", {
146
+ detail: `wired for ${harnesses.join(", ")}`,
147
+ });
148
+ }
149
+ function runVerifiedRead(ctx) {
150
+ const id = "verified-read";
151
+ const label = "per-agent verified-read";
152
+ if (ctx.keyAgentIds === undefined) {
153
+ return result(id, label, "skip", { detail: "no keys enumeration in context" });
154
+ }
155
+ const plan = planAgentIterations(ctx.keyAgentIds, ctx.agentFlag);
156
+ if (plan.length === 0) {
157
+ return result(id, label, "skip", { detail: "no agents to iterate" });
158
+ }
159
+ return result(id, label, "pass", { detail: `iterate ${plan.join(", ")}` });
160
+ }
161
+ function runKeysPrune(ctx) {
162
+ const id = "keys-prune";
163
+ const label = "keys prune classification";
164
+ if (!ctx.keysDir) {
165
+ return result(id, label, "skip", { detail: "no keys dir in context" });
166
+ }
167
+ if (!existsSync(ctx.keysDir)) {
168
+ return result(id, label, "skip", { detail: "keys dir absent" });
169
+ }
170
+ const ids = readdirSync(ctx.keysDir)
171
+ .filter((f) => f.endsWith(".key"))
172
+ .map((f) => f.replace(/\.key$/, ""));
173
+ const { agentKeyIds, nodeKeyIds } = partitionKeyIds(ids, ctx.keysDir);
174
+ return result(id, label, "pass", {
175
+ detail: `classified ${agentKeyIds.length} agent / ${nodeKeyIds.length} node key(s)`,
176
+ });
177
+ }
178
+ function runLaunchdManagement(ctx) {
179
+ const id = "launchd-management";
180
+ const label = "launchd management";
181
+ if (!ctx.launchd) {
182
+ return result(id, label, "skip", { detail: "launchd not observed" });
183
+ }
184
+ const m = ctx.launchd;
185
+ if (isDetached(m)) {
186
+ return result(id, label, "fail", {
187
+ detail: m.detail,
188
+ remedy: m.remedy?.join(" && "),
189
+ launchd: m,
190
+ });
191
+ }
192
+ if (m.state === "not-applicable" || m.state === "no-service") {
193
+ return result(id, label, "skip", { detail: m.detail, launchd: m });
194
+ }
195
+ return result(id, label, "pass", { detail: m.detail, launchd: m });
196
+ }
197
+ /** Default implementations. An id in DOCTOR_CHECK_IDS missing from this
198
+ * list is emitted as `unrun` — that is a reachable incomplete state. */
199
+ export const DOCTOR_CHECKS = [
200
+ { id: "mcp-block", label: "MCP server block", run: runMcpBlock },
201
+ { id: "flair-url", label: "FLAIR_URL", run: runFlairUrl },
202
+ { id: "claude-md", label: "CLAUDE.md bootstrap", run: runClaudeMd },
203
+ { id: "session-start-hook", label: "SessionStart hook", run: runSessionStartHook },
204
+ { id: "verified-read", label: "per-agent verified-read", run: runVerifiedRead },
205
+ { id: "keys-prune", label: "keys prune classification", run: runKeysPrune },
206
+ { id: "launchd-management", label: "launchd management", run: runLaunchdManagement },
207
+ ];
208
+ /**
209
+ * Run every catalog id. Missing implementations and thrown runners become
210
+ * `unrun`, never `pass`.
211
+ */
212
+ export function runDoctorChecks(ctx, opts = {}) {
213
+ const catalogIds = opts.catalogIds ?? DOCTOR_CHECK_IDS;
214
+ const defs = new Map((opts.checks ?? DOCTOR_CHECKS).map((c) => [c.id, c]));
215
+ const results = [];
216
+ for (const id of catalogIds) {
217
+ const stub = opts.stubs?.[id];
218
+ const def = defs.get(id);
219
+ if (stub) {
220
+ try {
221
+ results.push(normalizeResult(id, def?.label ?? id, stub(ctx)));
222
+ }
223
+ catch {
224
+ results.push(unrunResult(id, def?.label ?? id, "check threw before returning"));
225
+ }
226
+ continue;
227
+ }
228
+ if (!def) {
229
+ results.push(unrunResult(id, id, "check was not executed — no runner registered"));
230
+ continue;
231
+ }
232
+ try {
233
+ results.push(normalizeResult(id, def.label, def.run(ctx)));
234
+ }
235
+ catch {
236
+ results.push(unrunResult(id, def.label, "check threw before returning"));
237
+ }
238
+ }
239
+ return summarizeDoctorRunResults(results, catalogIds);
240
+ }
241
+ function normalizeResult(id, label, r) {
242
+ return { ...r, id, label: r.label || label };
243
+ }
244
+ function unrunResult(id, label, detail) {
245
+ return { id, label, status: "unrun", detail };
246
+ }
247
+ /**
248
+ * Positive assertion over an already-collected result list. Any catalog id
249
+ * absent from `results` is filled in as `unrun` so a partial list cannot
250
+ * look complete.
251
+ */
252
+ export function summarizeDoctorRunResults(results, catalogIds = DOCTOR_CHECK_IDS) {
253
+ const byId = new Map(results.map((r) => [r.id, r]));
254
+ const complete = catalogIds.map((id) => byId.get(id) ?? unrunResult(id, id, "check was not executed"));
255
+ const incomplete = complete.some((r) => r.status === "unrun");
256
+ const failed = complete.some((r) => r.status === "fail");
257
+ return {
258
+ results: complete,
259
+ healthy: catalogIds.length > 0 && !incomplete && !failed,
260
+ incomplete,
261
+ };
262
+ }
263
+ /**
264
+ * The final status line of a successful `flair upgrade`.
265
+ *
266
+ * Success is asserted from the enumerated doctor run — never from "facts I
267
+ * gathered, minus known-bad cases." The unqualified `✅ verified: healthy`
268
+ * marker is emitted only when `run.healthy` is true. An unrun member, a
269
+ * failed member, or an empty catalog all withhold it.
270
+ */
271
+ export function renderVerifiedSummary(version, run, opts) {
272
+ // `authenticated` is a probe fact, not a doctor-check special case. The
273
+ // healthy-unverified upgrade branch could not read /HealthDetail; claiming
274
+ // "authenticated" there contradicts the next line. Default stays true so
275
+ // the ok path is unchanged.
276
+ const authed = opts?.authenticated !== false;
277
+ const facts = `healthy${authed ? ", authenticated" : ""}${version ? `, running ${version}` : ""}`;
278
+ if (run.healthy) {
279
+ return { degraded: false, lines: [`✅ verified: ${facts}`] };
280
+ }
281
+ const lines = [];
282
+ const launchdFail = run.results.find((r) => r.id === "launchd-management" && r.status === "fail");
283
+ if (launchdFail?.launchd && isDetached(launchdFail.launchd)) {
284
+ lines.push(...renderDetachedWarning(launchdFail.launchd, `upgrade landed (${facts}) but the instance is NOT running under launchd.`));
285
+ }
286
+ else if (run.incomplete) {
287
+ const unrun = run.results.filter((r) => r.status === "unrun");
288
+ lines.push(`⚠️ upgrade landed (${facts}) but install checks are incomplete — ${unrun.length} check(s) were not run.`);
289
+ }
290
+ else {
291
+ const failed = run.results.filter((r) => r.status === "fail");
292
+ lines.push(`⚠️ upgrade landed (${facts}) but doctor found ${failed.length} issue${failed.length === 1 ? "" : "s"}.`);
293
+ }
294
+ for (const r of run.results) {
295
+ if (r.status === "unrun") {
296
+ lines.push(` UNRUN: ${r.label} — not yet checked (cannot treat as passing)`);
297
+ }
298
+ else if (r.status === "fail" && r.id !== "launchd-management") {
299
+ lines.push(` ✗ ${r.detail ?? r.label}`);
300
+ if (r.remedy)
301
+ lines.push(` Fix: ${r.remedy}`);
302
+ }
303
+ }
304
+ return { degraded: true, lines };
305
+ }
306
+ /**
307
+ * Whether upgrade may write a SessionStart hook. Installing something that
308
+ * executes at every session start is consent-bearing — the flag is the
309
+ * documented non-interactive consent; a TTY prompt is the interactive one.
310
+ * Silence (no flag, no TTY) never writes.
311
+ */
312
+ export function resolveHookInstallConsent(opts) {
313
+ if (opts.installHooksFlag)
314
+ return "install";
315
+ if (opts.interactive && opts.promptAccepted === undefined)
316
+ return "prompt";
317
+ if (opts.interactive && opts.promptAccepted)
318
+ return "install";
319
+ if (opts.interactive)
320
+ return "skip-declined";
321
+ return "skip-noninteractive";
322
+ }
323
+ /**
324
+ * Interactive upgrade prompt for missing SessionStart hooks.
325
+ *
326
+ * Names EVERY missing harness (and its path) before asking. A yes after
327
+ * this prompt is consent to write each named hook — not an unnamed extra.
328
+ * Codex-only rationale is attached only when Codex is in the set.
329
+ */
330
+ export function missingHookPromptLines(missing, homeDir) {
331
+ const named = missing.length > 0 ? [...missing] : ["codex"];
332
+ const preamble = [];
333
+ for (const h of named) {
334
+ preamble.push(`SessionStart hook (${h}) is not installed at ${hookSettingsPath(homeDir, h)}.`);
335
+ }
336
+ if (named.includes("codex")) {
337
+ preamble.push("Without the Codex hook, memory never bootstraps (no CLAUDE.md alternative on Codex).");
338
+ }
339
+ if (named.length > 1) {
340
+ preamble.push(`A yes installs the hook for ${named.join(" and ")} — each executes at session start.`);
341
+ }
342
+ const question = named.length === 1
343
+ ? " Install the flair-session-start hook now? [y/N] "
344
+ : ` Install the flair-session-start hook for ${named.join(" and ")} now? [y/N] `;
345
+ return { preamble, question };
346
+ }
347
+ /** Plain-language lines when upgrade will not write the hook. */
348
+ export function missingHookWithoutConsentLines(harness, path) {
349
+ return [
350
+ `SessionStart hook (${harness}) is not installed at ${path}.`,
351
+ "This hook runs at every session start — a consent-bearing write, so upgrade will not install it unprompted.",
352
+ `Install with: flair upgrade --install-hooks`,
353
+ ` or: ${hookInstallHint(harness)}`,
354
+ ];
355
+ }
356
+ /** True when the SessionStart-hook check failed because a hook file is missing. */
357
+ export function sessionStartHookMissing(run) {
358
+ const hook = run.results.find((r) => r.id === "session-start-hook");
359
+ return hook?.status === "fail" && (hook.missingHarnesses?.length ?? 0) > 0;
360
+ }
361
+ /**
362
+ * Inputs for a consented `installHook` after upgrade. Prefers the missing
363
+ * harness's own MCP block, then env, then the provided fallbacks.
364
+ */
365
+ export function resolveUpgradeHookInstall(homeDir, harness, fallbacks) {
366
+ const clientId = harness === "codex" ? "codex" : "claude-code";
367
+ const block = readClientMcpBlock(clientId, homeDir);
368
+ const agentId = (typeof process.env.FLAIR_AGENT_ID === "string" && process.env.FLAIR_AGENT_ID) ||
369
+ block.agentId ||
370
+ fallbacks.agentId;
371
+ const flairUrl = (typeof process.env.FLAIR_URL === "string" && process.env.FLAIR_URL) ||
372
+ block.flairUrl ||
373
+ fallbacks.flairUrl ||
374
+ (fallbacks.port ? `http://127.0.0.1:${fallbacks.port}` : "http://127.0.0.1:9926");
375
+ if (!agentId) {
376
+ return { error: "no agent id known — pass --agent or set FLAIR_AGENT_ID so the hook can be wired" };
377
+ }
378
+ return { agentId, flairUrl };
379
+ }
380
+ /**
381
+ * How many catalog members block doctor / withhold upgrade's ✅.
382
+ * `fail` and `unrun` both count — an unrun member is never a pass.
383
+ */
384
+ export function catalogBlockingCount(run) {
385
+ return run.results.filter((r) => r.status === "fail" || r.status === "unrun").length;
386
+ }
387
+ /**
388
+ * Found/fixed delta for `flair doctor --fix`. `found` is the pre-fix
389
+ * catalog blocking count; `fixed` is how many of those cleared after.
390
+ */
391
+ export function catalogIssueDelta(before, after) {
392
+ const found = catalogBlockingCount(before);
393
+ const remaining = catalogBlockingCount(after);
394
+ return { found, fixed: Math.max(0, found - remaining) };
395
+ }
396
+ /** Compact catalog lines for doctor's Install health section. */
397
+ export function renderCatalogDoctorLines(run) {
398
+ return run.results.map((r) => {
399
+ const icon = r.status === "pass" || r.status === "skip" ? "ok" : r.status === "unrun" ? "warn" : "error";
400
+ const word = r.status === "skip" ? "n/a" : r.status;
401
+ const detail = r.detail ? ` — ${r.detail}` : "";
402
+ return { icon, line: `${r.label}: ${word}${detail}` };
403
+ });
404
+ }
405
+ /**
406
+ * Consent → write composition for missing SessionStart hooks after a
407
+ * catalog run. Silence (no flag, not interactive) never creates a hook
408
+ * file. `--install-hooks` or an accepted prompt writes every named
409
+ * missing harness via `installHook` (or `writeHook`).
410
+ *
411
+ * This is the path `flair upgrade` actually uses. Testing
412
+ * `resolveHookInstallConsent` alone would stay green if the caller
413
+ * always wrote anyway.
414
+ */
415
+ export function applyUpgradeHookConsent(opts) {
416
+ const homeDir = opts.homeDir;
417
+ const empty = {
418
+ run: opts.run,
419
+ consent: "skip-declined",
420
+ missing: [],
421
+ written: [],
422
+ writes: [],
423
+ messages: [],
424
+ };
425
+ if (!sessionStartHookMissing(opts.run)) {
426
+ return { ...empty, consent: "install" };
427
+ }
428
+ const missing = opts.run.results.find((r) => r.id === "session-start-hook")?.missingHarnesses ?? [];
429
+ const consent = resolveHookInstallConsent({
430
+ installHooksFlag: opts.installHooksFlag,
431
+ interactive: opts.interactive,
432
+ promptAccepted: opts.promptAccepted,
433
+ });
434
+ const prompt = missingHookPromptLines(missing, homeDir);
435
+ if (consent === "prompt") {
436
+ return { ...empty, consent, missing, prompt };
437
+ }
438
+ if (consent === "install") {
439
+ const write = opts.writeHook ?? installHook;
440
+ const writes = [];
441
+ const written = [];
442
+ const messages = [];
443
+ for (const harness of missing) {
444
+ const inputs = resolveUpgradeHookInstall(homeDir, harness, {
445
+ port: opts.port,
446
+ agentId: opts.agentId,
447
+ flairUrl: opts.flairUrl,
448
+ });
449
+ if ("error" in inputs) {
450
+ messages.push(inputs.error);
451
+ writes.push({ harness, ok: false, message: inputs.error });
452
+ continue;
453
+ }
454
+ const installed = write({
455
+ homeDir,
456
+ harness,
457
+ agentId: inputs.agentId,
458
+ flairUrl: inputs.flairUrl,
459
+ });
460
+ writes.push({ harness, ok: installed.ok, message: installed.message });
461
+ messages.push(installed.message);
462
+ if (installed.ok)
463
+ written.push(harness);
464
+ }
465
+ return {
466
+ run: runDoctorChecks(opts.ctx),
467
+ consent,
468
+ missing,
469
+ written,
470
+ writes,
471
+ messages,
472
+ };
473
+ }
474
+ const messages = [];
475
+ if (consent === "skip-noninteractive") {
476
+ for (const harness of missing) {
477
+ messages.push(...missingHookWithoutConsentLines(harness, hookSettingsPath(homeDir, harness)));
478
+ }
479
+ }
480
+ return { ...empty, consent, missing, messages };
481
+ }
@@ -300,29 +300,10 @@ export function renderDetachedWarning(m, headline) {
300
300
  lines.push(` Fix: ${m.remedy.join(" && ")}`);
301
301
  return lines;
302
302
  }
303
- /**
304
- * The final status line of a successful `flair upgrade`, given what the probe
305
- * found AND what launchd is doing.
306
- *
307
- * This is the reported defect reduced to a function, on purpose. The bug was
308
- * never in the probe — `healthy, authenticated, running <version>` was true
309
- * it was that those three facts were rendered as an unqualified ✅ while a
310
- * fourth, unmeasured fact (the instance had been dropped out of its process
311
- * manager) was the one that mattered. Deciding it here rather than inline in
312
- * the command means the decision is testable without performing an upgrade,
313
- * which on the darwin path nothing else can do: no CI lane runs it.
314
- *
315
- * The verified facts are still reported in the degraded case. The upgrade DID
316
- * land, and hiding that would swap one misleading summary for another; what
317
- * changes is the marker and the sentence around it.
318
- */
319
- export function renderVerifiedSummary(version, m) {
320
- const facts = `healthy, authenticated${version ? `, running ${version}` : ""}`;
321
- if (!isDetached(m)) {
322
- return { degraded: false, lines: [`✅ verified: ${facts}`] };
323
- }
324
- return {
325
- degraded: true,
326
- lines: renderDetachedWarning(m, `upgrade landed (${facts}) but the instance is NOT running under launchd.`),
327
- };
328
- }
303
+ // renderVerifiedSummary used to live here and qualify ✅ from version +
304
+ // LaunchdManagement alone (flair#1022). That signature was the ceiling on
305
+ // what it could notice a blacklist of one known-unhealthy form. The
306
+ // enumerated doctor runner in doctor-run.ts is now the source of the
307
+ // success marker (flair#1439). renderDetachedWarning stays: it is the
308
+ // wording for a detached launchd check, used by the runner and by
309
+ // `flair restart`.
@@ -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;
@@ -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) {