@tpsdev-ai/flair 0.30.0 → 0.31.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.
Files changed (60) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1434 -288
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/AdminPrincipals.js +10 -1
  15. package/dist/resources/Agent.js +105 -11
  16. package/dist/resources/AgentSeed.js +10 -2
  17. package/dist/resources/MemoryBootstrap.js +7 -8
  18. package/dist/resources/MemoryUsage.js +18 -0
  19. package/dist/resources/Presence.js +8 -1
  20. package/dist/resources/SemanticSearch.js +17 -45
  21. package/dist/resources/abstention.js +1 -1
  22. package/dist/resources/agent-admin.js +149 -0
  23. package/dist/resources/agent-auth.js +98 -5
  24. package/dist/resources/auth-middleware.js +92 -19
  25. package/dist/resources/embeddings-boot.js +10 -12
  26. package/dist/resources/embeddings-provider.js +10 -7
  27. package/dist/resources/health.js +24 -19
  28. package/dist/resources/in-process.js +234 -0
  29. package/dist/resources/mcp-handler.js +14 -4
  30. package/dist/resources/mcp-tools.js +23 -17
  31. package/dist/resources/migration-boot.js +80 -10
  32. package/dist/resources/migrations/data-dir.js +205 -0
  33. package/dist/resources/migrations/progress.js +33 -0
  34. package/dist/resources/migrations/runner.js +29 -2
  35. package/dist/resources/migrations/state.js +13 -2
  36. package/dist/resources/models-dir.js +18 -9
  37. package/dist/resources/presence-internal.js +6 -1
  38. package/dist/resources/record-owner-guard.js +149 -0
  39. package/dist/resources/semantic-retrieval-core.js +5 -4
  40. package/dist/src/lib/scheduler-platform.js +128 -0
  41. package/dist/src/lib/xml-escape.js +54 -0
  42. package/dist/src/rem/scheduler.js +35 -87
  43. package/docs/deploying-on-fabric.md +267 -0
  44. package/docs/deployment.md +5 -0
  45. package/docs/embedding-in-a-harper-app.md +303 -0
  46. package/docs/federation.md +61 -4
  47. package/docs/integrations.md +3 -0
  48. package/docs/mcp-clients.md +16 -7
  49. package/docs/quickstart.md +80 -54
  50. package/docs/releasing.md +72 -38
  51. package/docs/supply-chain-policy.md +36 -0
  52. package/docs/troubleshooting.md +24 -0
  53. package/docs/upgrade.md +99 -4
  54. package/package.json +1 -11
  55. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  56. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  57. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  58. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  59. package/dist/resources/rerank-provider.js +0 -569
  60. package/docs/rerank-provisioning.md +0 -101
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
- import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
2
+ import { resolveAgentAuth, allowVerified, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
3
+ import { agentRecordIsAdmin, reconcileAdminFields } from "./agent-admin.js";
3
4
  import { localInstanceId } from "./instance-identity.js";
4
5
  /**
5
6
  * Agent resource — serves as the Principal table in 1.0.
@@ -34,10 +35,18 @@ export class Agent extends databases.flair.Agent {
34
35
  content.kind ||= "agent";
35
36
  content.status ||= "active";
36
37
  content.displayName ||= content.name;
37
- content.admin ??= false;
38
- // Trust tier defaults per kind
38
+ // flair#941 — the two admin fields are reconciled BEFORE anything derives
39
+ // from them, so `role` and `admin` agree on disk whichever one the caller
40
+ // used. Replaces `content.admin ??= false`, which defaulted the mirror
41
+ // without ever consulting the authority: a caller who passed role:"admin"
42
+ // got a record that was an admin at the gate and a non-admin to every
43
+ // reporter. allowCreate() is allowAdmin(), so this path is admin-only.
44
+ reconcileAdminFields(content);
45
+ // Trust tier defaults per kind — derived from the SAME predicate the gate
46
+ // uses, so an admin principal cannot land on the non-admin default just
47
+ // because the caller spelled admin the other way.
39
48
  if (!content.defaultTrustTier) {
40
- content.defaultTrustTier = content.admin ? "endorsed" : "unverified";
49
+ content.defaultTrustTier = agentRecordIsAdmin(content) ? "endorsed" : "unverified";
41
50
  }
42
51
  content.createdAt = now;
43
52
  content.updatedAt = now;
@@ -51,7 +60,25 @@ export class Agent extends databases.flair.Agent {
51
60
  }
52
61
  return super.post(content, context);
53
62
  }
54
- async put(content) {
63
+ /**
64
+ * Authorization shared by BOTH mutation paths (PUT → put, PATCH → patch).
65
+ *
66
+ * It lives in one place because it previously lived only in put(), and PATCH
67
+ * does not route through put() — so every rule written here was enforced on
68
+ * one verb and not the other. Returns a Response to send, or null to proceed.
69
+ *
70
+ * Two rules:
71
+ * 1. Only an admin principal may modify a principal OTHER than itself.
72
+ * 2. Only an admin principal may change a principal's ADMIN STATUS — on any
73
+ * record, including the caller's own. Rule 1 alone never covered this:
74
+ * an agent editing its own record is inside its rights for ordinary
75
+ * fields (runtime, displayName, subjects) and must not be for the fields
76
+ * that decide whether it is an administrator.
77
+ *
78
+ * `internal` (in-process maintenance, federation merge) and admin agents pass
79
+ * through unchanged.
80
+ */
81
+ async authorizePrincipalWrite(content) {
55
82
  const auth = await resolveAgentAuth(this.getContext?.());
56
83
  // Anonymous denied (defense-in-depth alongside allowUpdate; the old check read
57
84
  // tpsAgent and treated a missing agent as trusted, so anonymous slipped through).
@@ -60,24 +87,91 @@ export class Agent extends databases.flair.Agent {
60
87
  status: 401, headers: { "content-type": "application/json" },
61
88
  });
62
89
  }
63
- // Only admin principals can modify OTHER principals; an agent updates its own.
64
- if (auth.kind === "agent" && !auth.isAdmin) {
65
- const existing = await super.get();
66
- if (existing && existing.id !== auth.agentId) {
67
- return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
90
+ if (auth.kind !== "agent" || auth.isAdmin)
91
+ return null;
92
+ const existing = await Promise.resolve(super.get()).catch(() => null);
93
+ // 1. Only admin principals can modify OTHER principals.
94
+ if (existing && existing.id !== auth.agentId) {
95
+ return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
96
+ status: 403, headers: { "content-type": "application/json" },
97
+ });
98
+ }
99
+ // 2. Only admin principals can change admin status. Compare the RESULTING
100
+ // status against the stored one so an ordinary self-update that simply
101
+ // doesn't mention either field is unaffected, and a no-op restatement of
102
+ // the caller's existing status is not a spurious denial.
103
+ const touchesPrivilegeFields = content != null && typeof content === "object" && ("role" in content || "admin" in content);
104
+ if (touchesPrivilegeFields) {
105
+ const merged = { ...(existing ?? {}), ...content };
106
+ const wouldBeAdmin = agentRecordIsAdmin(merged) || merged.admin === true;
107
+ const isAdminNow = agentRecordIsAdmin(existing) || (existing?.admin === true);
108
+ if (wouldBeAdmin !== isAdminNow) {
109
+ return new Response(JSON.stringify({ error: "only admin principals can change a principal's admin status" }), {
68
110
  status: 403, headers: { "content-type": "application/json" },
69
111
  });
70
112
  }
71
113
  }
114
+ return null;
115
+ }
116
+ async put(content) {
117
+ const denial = await this.authorizePrincipalWrite(content);
118
+ if (denial)
119
+ return denial;
72
120
  content.updatedAt = new Date().toISOString();
73
121
  // Protect immutable fields
74
122
  delete content.createdAt;
75
123
  delete content.publicKey; // key rotation goes through dedicated endpoint
124
+ // Keep the two admin fields agreeing on disk — see resources/agent-admin.ts.
125
+ // Only an admin can have reached here with a privilege change (see
126
+ // authorizePrincipalWrite), so this normalises an authorized intent; it
127
+ // never manufactures one.
128
+ reconcileAdminFields(content);
76
129
  // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
77
130
  // stampOriginatorInstanceId doc. No-op if already set.
78
131
  if (content.originatorInstanceId == null) {
79
132
  content.originatorInstanceId = await localInstanceId();
80
133
  }
81
- return super.put(content);
134
+ const result = await super.put(content);
135
+ invalidateAdminCache();
136
+ return result;
137
+ }
138
+ /**
139
+ * PATCH → Harper's partial update (`Resource.patch(data, query)`; see
140
+ * harper/dist/server/REST.js's method switch and Resource.js's static patch).
141
+ *
142
+ * This override exists because there was none. Every per-record authorization
143
+ * rule this resource enforces was written in put(), and PATCH does not route
144
+ * through put() — so none of those rules ran on a PATCH. allowUpdate() is
145
+ * allowVerified(), which means the only check a PATCH ever met was "are you
146
+ * some verified agent"; the rules that make the principal table safe (you may
147
+ * only edit yourself; you may not change your own admin status) were not
148
+ * among them.
149
+ *
150
+ * That is the shape flair#941 keeps running into: a check that reads as
151
+ * complete because it exists, and simply is not on the path the caller took.
152
+ * Both verbs now share authorizePrincipalWrite().
153
+ */
154
+ async patch(content, query) {
155
+ const denial = await this.authorizePrincipalWrite(content);
156
+ if (denial)
157
+ return denial;
158
+ if (content != null && typeof content === "object") {
159
+ // A partial update must not be able to leave the record's two admin
160
+ // fields disagreeing, so reconcile against the MERGED result rather than
161
+ // the patch alone — patching only `role` has to carry the mirror with it.
162
+ if ("role" in content || "admin" in content) {
163
+ const existing = await Promise.resolve(super.get()).catch(() => null);
164
+ const merged = reconcileAdminFields({ ...(existing ?? {}), ...content });
165
+ content.role = merged.role;
166
+ content.admin = merged.admin;
167
+ }
168
+ // Immutable fields, matching put().
169
+ delete content.createdAt;
170
+ delete content.publicKey;
171
+ content.updatedAt = new Date().toISOString();
172
+ }
173
+ const result = await super.patch(content, query);
174
+ invalidateAdminCache();
175
+ return result;
82
176
  }
83
177
  }
@@ -17,7 +17,8 @@
17
17
  * Auth: admin only.
18
18
  */
19
19
  import { Resource, databases } from "harper";
20
- import { isAdmin, allowAdmin } from "./agent-auth.js";
20
+ import { isAdmin, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
21
+ import { reconcileAdminFields } from "./agent-admin.js";
21
22
  const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
22
23
  name: displayName,
23
24
  role,
@@ -62,8 +63,15 @@ export class AgentSeed extends Resource {
62
63
  const existingAgent = await databases.flair.Agent.get(agentId).catch(() => null);
63
64
  let agent = existingAgent;
64
65
  if (!existingAgent) {
65
- agent = { id: agentId, name, role, publicKey: "pending", createdAt: now, updatedAt: now };
66
+ // flair#941 this writes the RAW table, so resources/Agent.ts's post()
67
+ // never runs and its field reconciliation does not apply here. Seeding
68
+ // role:"admin" without the mirror was the one path in the product that
69
+ // produced a genuine administrator every reporter displayed as an
70
+ // ordinary agent. Admin-only path (allowCreate + the isAdmin re-check
71
+ // above), so this normalises an authorized intent.
72
+ agent = reconcileAdminFields({ id: agentId, name, role, publicKey: "pending", createdAt: now, updatedAt: now });
66
73
  await databases.flair.Agent.put(agent);
74
+ invalidateAdminCache();
67
75
  }
68
76
  // ── Soul entries ──────────────────────────────────────────────────────────
69
77
  const defaults = DEFAULT_SOUL_KEYS(agentId, name, role, now);
@@ -11,7 +11,7 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
11
11
  // The bounded HNSW candidate-pool retrieval for the task-relevant/teammate/
12
12
  // collision surfaces (flair-bootstrap-scale-fix) — the SAME pure core
13
13
  // SemanticSearch.ts's post() wraps, called bare here so an internal
14
- // bootstrap call never trips SemanticSearch's rate-limit/reranker/
14
+ // bootstrap call never trips SemanticSearch's rate-limit or
15
15
  // retrievalCount hit-tracking side effects (see resources/
16
16
  // semantic-retrieval-core.ts's module doc for the full boundary).
17
17
  import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
@@ -619,12 +619,11 @@ export class BootstrapMemories extends Resource {
619
619
  queryEmbedding,
620
620
  conditions: [scope.condition],
621
621
  limit: candidatePoolK,
622
- // HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion, no
623
- // reranker for bootstrap — a different cost profile (BM25 over the
624
- // org corpus for a one-shot session-load could be MORE expensive
625
- // than HNSW-only unless cached across sessions; the reranker is a
626
- // generative call per candidate). Both are explicit opt-in
627
- // follow-ons, gated on their own harness runs.
622
+ // HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion for
623
+ // bootstrap — a different cost profile, since BM25 over the org
624
+ // corpus for a one-shot session-load could be MORE expensive than
625
+ // HNSW-only unless cached across sessions. Turning it on is an
626
+ // explicit opt-in follow-on, gated on its own harness run.
628
627
  hybrid: false,
629
628
  // Per-set (this K-bounded pool only, never cross-applied to the
630
629
  // permanent/recent/predicted sets above) — see this function's
@@ -679,7 +678,7 @@ export class BootstrapMemories extends Resource {
679
678
  // `candidates` are already `_score`-sorted best-first, so filtering
680
679
  // preserves that order). `retrieveCandidates()`'s cosine similarity
681
680
  // replaces the raw JS dot product as the ranking signal (HNSW-only,
682
- // no BM25/rerank) — the K&S-ratified, closest-to-a-wash choice; the
681
+ // no BM25) — the K&S-ratified, closest-to-a-wash choice; the
683
682
  // recall harness gates any regression from this ranking-signal
684
683
  // change (magnitude-sensitive dot product → normalized cosine).
685
684
  const scored = candidates
@@ -95,6 +95,24 @@ export class MemoryUsage extends databases.flair.MemoryUsage {
95
95
  return super.put(content);
96
96
  return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
97
97
  }
98
+ /**
99
+ * PATCH — same rule as put(), because this ledger's invariant is IMMUTABILITY,
100
+ * not ownership.
101
+ *
102
+ * The shared record-ownership guard (resources/record-owner-guard.ts) covers
103
+ * this table for cross-agent writes, but it cannot express this rule: it
104
+ * permits an agent to modify a row it owns, and here even the OWNER must not.
105
+ * Measured before this override existed: the owning agent rewrote its own
106
+ * row's attribution with a PATCH and got 204, because Harper routes PATCH to
107
+ * patch() and put()'s check never ran. A resource whose rule is stricter than
108
+ * "you own it" still has to say so on every verb.
109
+ */
110
+ async patch(content, query) {
111
+ const auth = await resolveAgentAuth(this.getContext?.());
112
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
113
+ return super.patch(content, query);
114
+ return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
115
+ }
98
116
  async delete(id) {
99
117
  const auth = await resolveAgentAuth(this.getContext?.());
100
118
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
@@ -32,6 +32,7 @@ import { dirname, join } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { createRequire } from "node:module";
34
34
  import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
35
+ import { agentRecordIsAdmin } from "./agent-admin.js";
35
36
  import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
36
37
  // ─── Constants ────────────────────────────────────────────────────────────────
37
38
  const CURRENT_TASK_MAX_LENGTH = 200;
@@ -348,7 +349,13 @@ export class Presence extends databases.flair.Presence {
348
349
  const entry = {
349
350
  id: agentId,
350
351
  displayName: agent?.displayName ?? agent?.name ?? agentId,
351
- role: agent?.role ?? "agent",
352
+ // `role` is a human label on a PUBLIC roster (allowRead() is `true`),
353
+ // and it is also the field that decides administrator status
354
+ // (resources/agent-admin.ts). Publishing the admin sentinel would
355
+ // hand an unauthenticated reader the list of privileged principals —
356
+ // so the roster reports admins as ordinary agents. It is a display
357
+ // label here and nothing authorizes on it (flair#941).
358
+ role: agentRecordIsAdmin(agent) ? "agent" : (agent?.role ?? "agent"),
352
359
  runtime: agent?.runtime ?? null,
353
360
  // Current activity — only truthful while fresh; "idle" once decayed.
354
361
  activity: activityFresh ? rawActivity : "idle",
@@ -4,7 +4,6 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
4
4
  import { patchRecord } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
- import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
8
7
  // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
9
8
  // (imported from ./bm25 — Harper-free so it's unit-testable). Default is ON as
10
9
  // of 2026-07-08 (see ./bm25.ts's hybridEnabled() doc); set
@@ -15,7 +14,7 @@ import { hybridEnabled } from "./bm25.js";
15
14
  // supersede/isAllowed) now lives in the pure, side-effect-free
16
15
  // retrieveCandidates() core (flair-bootstrap-scale-fix, Kern-approved
17
16
  // extraction) — MemoryBootstrap.ts calls the SAME core bare, without
18
- // tripping this file's rate-limit/reranker/hit-tracking side effects. See
17
+ // tripping this file's rate-limit/hit-tracking side effects. See
19
18
  // resources/semantic-retrieval-core.ts's module doc for the full boundary.
20
19
  import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
21
20
  import { attachTrust } from "./trust-block.js";
@@ -181,29 +180,18 @@ export class SemanticSearch extends Resource {
181
180
  }
182
181
  }
183
182
  const hybrid = hybridEnabled();
184
- // When the reranker is on, widen the legacy HNSW fetch so it has a deeper
185
- // pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
186
- // from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
187
- // headroom. Scoped to the legacy (non-hybrid) vector path below — the
188
- // hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
189
- // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
190
- // reranker still applies to its output further down regardless of which
191
- // path produced `filteredResults`.
192
- const rerankOn = isRerankEnabled();
193
- const rerankTopN = getRerankTopN();
194
183
  // The overfetch policy (how many raw candidates to pull from the
195
184
  // HNSW/BM25 legs relative to what the caller ultimately wants) is THIS
196
185
  // wrapper's decision — retrieveCandidates() never multiplies its `limit`
197
186
  // param internally (see resources/semantic-retrieval-core.ts's doc), so
198
187
  // every caller (this one, and MemoryBootstrap's own K formula) computes
199
- // its own fetch depth. Hybrid's semantic leg is never rerank-widened
200
- // (matches the pre-extraction behavior: only the legacy path widened for
201
- // rerank).
202
- const candidateLimit = hybrid
203
- ? limit * CANDIDATE_MULTIPLIER
204
- : (rerankOn ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN) : limit * CANDIDATE_MULTIPLIER);
188
+ // its own fetch depth. Both the hybrid path (CANDIDATE_MULTIPLIER on the
189
+ // semantic leg + SEM_LIMIT on the BM25 leg, fused by RRF) and the legacy
190
+ // vector-only path overfetch by the same multiplier, which is what gives
191
+ // composite re-scoring headroom to reorder before the final slice.
192
+ const candidateLimit = limit * CANDIDATE_MULTIPLIER;
205
193
  const ctx = this.getContext?.();
206
- let filteredResults = await retrieveCandidates({
194
+ const filteredResults = await retrieveCandidates({
207
195
  queryEmbedding: qEmb,
208
196
  q,
209
197
  conditions,
@@ -233,10 +221,10 @@ export class SemanticSearch extends Resource {
233
221
  withSemSimilarity: abstain || includeTrust,
234
222
  });
235
223
  // ─── flair#744 slice 2 — first-class abstention ("no memory covers this")
236
- // Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the
237
- // reranker / final slice / hit-tracking, so an abstaining recall does no
238
- // rerank work and never bumps retrievalCount for memories it declines to
239
- // surface. The decision reads ONLY the best absolute semantic similarity
224
+ // Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the final
225
+ // slice / hit-tracking, so an abstaining recall never bumps retrievalCount
226
+ // for memories it declines to surface. The decision reads ONLY the best
227
+ // absolute semantic similarity
240
228
  // (never any principal/authority signal — abstention.ts is pure and
241
229
  // authority-free), against the single GLOBAL threshold. Default OFF ⇒ this
242
230
  // whole block is skipped and the response is byte-identical to pre-slice-2.
@@ -253,28 +241,12 @@ export class SemanticSearch extends Resource {
253
241
  };
254
242
  }
255
243
  }
256
- // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
257
- // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
258
- // can't do) and reorders before the final slice. Reorders whatever
259
- // `filteredResults` retrieveCandidates() produced legacy HNSW-only OR
260
- // the BM25+union-RRF hybrid path since both converge into the same
261
- // shape (retrieveCandidates never exposes which leg produced a result).
262
- // Still gated on `qEmb` (an embedding was actually generated); the pure
263
- // keyword-only fallback (no qEmb at all) is untouched either way. The
264
- // reranker overwrites `_score` with the rerank score (so margin
265
- // measurement reads it) and preserves the semantic score as `_semScore`;
266
- // `_rawScore` is left as-is so recall-bench's scoring:"raw" path stays
267
- // reproducible. On init failure, timeout, or any throw, rerankCandidates
268
- // returns the input UNCHANGED and we fall through to retrieveCandidates'
269
- // own vector-order sort — recall is never blocked. retrieveCandidates
270
- // already returns its output sorted best-first, so the non-rerank branch
271
- // needs no additional sort here.
272
- if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
273
- filteredResults = await rerankCandidates(String(q), filteredResults, {
274
- topN: rerankTopN,
275
- budgetMs: getRerankBudgetMs(),
276
- });
277
- }
244
+ // retrieveCandidates() already returns its output sorted best-first
245
+ // (whichever leg produced it — legacy HNSW-only, the BM25+union-RRF hybrid
246
+ // path, or the keyword-only fallback all converge into the same shape), so
247
+ // the final slice needs no additional sort. A cross-encoder rerank stage
248
+ // used to sit here and reorder the pool before this slice; it was removed
249
+ // in flair#893 after measuring Δp@3 = 0.000 at 4.1× query latency.
278
250
  const topResults = filteredResults.slice(0, limit);
279
251
  // Async hit tracking — don't block the response
280
252
  const now = new Date().toISOString();
@@ -100,7 +100,7 @@ export const STRONG_BAND = 0.55;
100
100
  * Reads ONLY the `_semSimilarity` number the retrieval core
101
101
  * (resources/semantic-retrieval-core.ts) attaches to each semantic-leg result
102
102
  * WHEN abstention is requested — an absolute cosine similarity in [0,1],
103
- * independent of the RRF normalization / rerank that make the ranking `_score`
103
+ * independent of the RRF normalization that makes the ranking `_score`
104
104
  * a *relative* signal (the top RRF-fused result is normalized to 1.0 regardless
105
105
  * of how weak the actual match is, so `_score` is unusable as a confidence
106
106
  * floor — this is why abstention reads the absolute similarity instead).
@@ -0,0 +1,149 @@
1
+ /**
2
+ * agent-admin.ts — the ONE answer to "is this principal an administrator?".
3
+ *
4
+ * The Agent (Principal) record carries two fields that both read as if they
5
+ * answered that question:
6
+ *
7
+ * role: String — administrator when the value is exactly "admin"
8
+ * admin: Boolean — "admin principals can manage other principals"
9
+ *
10
+ * They used to be consulted by DIFFERENT consumers, which is the whole defect
11
+ * (flair#941):
12
+ *
13
+ * - resources/agent-auth.ts's isAdmin() — the single gate behind allowAdmin(),
14
+ * and therefore behind every admin-only resource — searched `role` and
15
+ * ignored `admin` completely.
16
+ * - resources/mcp-handler.ts OR-ed the two together.
17
+ * - Every reporter (flair principal show/list, the admin dashboard) displayed
18
+ * `admin`, and every writer except AgentSeed wrote `admin`.
19
+ *
20
+ * So the field the product wrote and displayed was not the field the gate read.
21
+ * `flair principal add --admin` stored `admin: true` and granted nothing, while
22
+ * the dashboard confidently printed "admin: yes" for a principal that
23
+ * allowAdmin() rejects. Both directions are silent, and one of them is silent in
24
+ * the direction that flatters the operator.
25
+ *
26
+ * ─── The rule ────────────────────────────────────────────────────────────────
27
+ *
28
+ * `role === ADMIN_ROLE` is the AUTHORITY. `admin` is a SERVER-MAINTAINED MIRROR
29
+ * of it and is never read to reach an authorization decision again.
30
+ *
31
+ * Every decider and every reporter calls {@link agentRecordIsAdmin}, so no two
32
+ * surfaces can answer this question differently. Every write through a flair
33
+ * write path calls {@link reconcileAdminFields}, so a record that says one thing
34
+ * in one field and the opposite in the other cannot be STORED by any path flair
35
+ * offers.
36
+ *
37
+ * ─── Why `role` is the authority and not `admin` ─────────────────────────────
38
+ *
39
+ * `admin: Boolean` is honestly the better-shaped field: typed, indexed,
40
+ * unambiguous, and already the one every creation path and every UI uses.
41
+ * Making it the authority would nonetheless GRANT admin, on the primary HTTP
42
+ * gate, to every record that currently carries `admin: true` with a non-admin
43
+ * `role` — the records `flair principal add --admin` has been producing all
44
+ * along, which have never been admins. That is a widening of live access as a
45
+ * side effect of a consistency fix, decided by data nobody has audited. Keeping
46
+ * `role` as the authority changes no existing principal's rights on the gate:
47
+ * every principal that is an admin today is an admin after this change, and
48
+ * every principal that is not, is not.
49
+ *
50
+ * Switching the authority to `admin` is a reasonable follow-up, but it is a
51
+ * deliberate privilege migration with an audit of existing records — not
52
+ * something to slip in underneath a naming fix.
53
+ *
54
+ * ─── Records that already carry a mismatch ───────────────────────────────────
55
+ *
56
+ * Nothing is rewritten in bulk; no migration runs. What happens to each shape:
57
+ *
58
+ * role:"admin" + admin:false|absent — an admin today, an admin after. The
59
+ * mirror is stale, so the CLI and dashboard used to report "not an admin"
60
+ * for a principal holding admin rights; they now report the truth. The
61
+ * stored mirror is repaired the next time the record is written through the
62
+ * Agent resource.
63
+ *
64
+ * admin:true + role not "admin" — NOT an admin today on the gate, and not
65
+ * after. This is what `flair principal add --admin` produced. The CLI and
66
+ * dashboard used to report "admin: yes"; they now report the truth, which is
67
+ * how an operator finds out the grant never took. The remedy is to re-issue
68
+ * the grant (which now writes both fields). The one behaviour that does
69
+ * change is the native MCP surface, which used to honour this field on its
70
+ * own — see resources/mcp-handler.ts. That surface is gated behind
71
+ * FLAIR_MCP_OAUTH and is default-OFF, so no instance running the shipped
72
+ * defaults is affected.
73
+ *
74
+ * Deliberately dependency-free — pure predicates over a plain record, importing
75
+ * neither `harper` nor any resource, so the auth gate, the resources, the
76
+ * reporters and the tests can all share it without an import cycle.
77
+ */
78
+ /** The exact `role` value that denotes a flair administrator. */
79
+ export const ADMIN_ROLE = "admin";
80
+ /**
81
+ * THE admin predicate. Every authorization decision and every report of a
82
+ * principal's admin status resolves through this one function.
83
+ *
84
+ * Total over every field combination: a record with a contradictory `admin`
85
+ * mirror gets the SAME answer here as it does at the gate, so a contradiction
86
+ * can never produce two different answers on two different surfaces. It is
87
+ * deliberately NOT an OR over the two fields — see the module header.
88
+ *
89
+ * Note this covers Agent RECORDS only. `FLAIR_ADMIN_AGENTS` is a separate,
90
+ * env-configured admin source union-ed in by resources/agent-auth.ts's
91
+ * getAdminAgents(); it names agent ids and never touches a record.
92
+ */
93
+ export function agentRecordIsAdmin(record) {
94
+ return record?.role === ADMIN_ROLE;
95
+ }
96
+ /**
97
+ * Make a record about to be written self-consistent, so the two fields can
98
+ * never be STORED disagreeing.
99
+ *
100
+ * Admin is requested by EITHER spelling — `role: "admin"` or `admin: true` —
101
+ * because both have been documented and both are what an operator reaches for.
102
+ * Whichever they used, both fields come out of here agreeing. That is what
103
+ * makes `flair principal add --admin` finally do what it says: it writes the
104
+ * Boolean, and the Boolean now carries the record into the authority field.
105
+ *
106
+ * **Honouring `admin: true` here is not a privilege widening.** Every call site
107
+ * is already admin-gated — Agent.post() is allowCreate()=allowAdmin, Agent's
108
+ * update path refuses a privilege change from a non-admin, and AgentSeed is
109
+ * admin-only and re-checks. A caller that can reach this could have written
110
+ * `role: "admin"` directly; this only means they no longer have to know which
111
+ * of the two fields is the real one.
112
+ *
113
+ * A non-admin `role` is free text (a human label — "researcher", "COO") and is
114
+ * left exactly as supplied; only the admin sentinel is normalised.
115
+ *
116
+ * Mutates `content` in place and returns it, matching the surrounding
117
+ * defaults-stamping style in resources/Agent.ts's post().
118
+ */
119
+ export function reconcileAdminFields(content) {
120
+ if (!content || typeof content !== "object")
121
+ return content;
122
+ // Widened locally: `content` is generic so TypeScript will not accept writes
123
+ // to named properties on it, but the whole point here is to normalise those
124
+ // two properties in place and hand the caller back its own object.
125
+ const record = content;
126
+ if (record.role === ADMIN_ROLE || record.admin === true) {
127
+ record.role = ADMIN_ROLE;
128
+ record.admin = true;
129
+ }
130
+ else {
131
+ record.admin = false;
132
+ }
133
+ return content;
134
+ }
135
+ /**
136
+ * True when a stored record's two fields disagree — i.e. it was written by
137
+ * something other than a flair write path (a raw table write, an ops-API
138
+ * insert, a federation merge) and now claims one thing to a reader of `admin`
139
+ * and the opposite to the gate.
140
+ *
141
+ * Reporting-only. Nothing authorizes on this; it exists so a surface can SAY
142
+ * that a record is inconsistent instead of silently picking a side.
143
+ */
144
+ export function adminFieldsDisagree(record) {
145
+ const r = record;
146
+ if (!r || typeof r !== "object")
147
+ return false;
148
+ return agentRecordIsAdmin(r) !== (r.admin === true);
149
+ }