@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.
- package/README.md +194 -377
- package/dist/cli.js +1434 -288
- package/dist/deploy.js +212 -24
- package/dist/fabric-upgrade.js +16 -1
- package/dist/federation/scheduler.js +500 -0
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-spec.js +128 -0
- package/dist/lib/safe-snapshot-extract.js +231 -0
- package/dist/lib/scheduler-platform.js +128 -0
- package/dist/lib/xml-escape.js +54 -0
- package/dist/rem/scheduler.js +35 -87
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryBootstrap.js +7 -8
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/SemanticSearch.js +17 -45
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- package/dist/resources/embeddings-boot.js +10 -12
- package/dist/resources/embeddings-provider.js +10 -7
- package/dist/resources/health.js +24 -19
- package/dist/resources/in-process.js +234 -0
- package/dist/resources/mcp-handler.js +14 -4
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/migration-boot.js +80 -10
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/runner.js +29 -2
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- package/dist/resources/semantic-retrieval-core.js +5 -4
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +35 -87
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +5 -0
- package/docs/embedding-in-a-harper-app.md +303 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +36 -0
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +99 -4
- package/package.json +1 -11
- package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
- package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
- package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
- package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
- package/dist/resources/rerank-provider.js +0 -569
- package/docs/rerank-provisioning.md +0 -101
package/dist/resources/Agent.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
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
|
|
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
|
-
|
|
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/
|
|
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.
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
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
|
|
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
|
+
}
|