@tpsdev-ai/flair 0.22.1 → 0.24.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.
@@ -1,24 +1,32 @@
1
1
  import { databases } from "@harperfast/harper";
2
- import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
2
+ import { resolveAgentAuth } from "./agent-auth.js";
3
3
  import { localInstanceId } from "./instance-identity.js";
4
- const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
5
- const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
4
+ import { makeAuthGate, stampAttribution, UNAUTH } from "./record-type-kit.js";
5
+ import { RECORD_TYPES } from "./record-types.js";
6
6
  /**
7
7
  * Deny anonymous; enforce per-agent write ownership for non-admin agents.
8
8
  * The previous header-based check only fired when an agent WAS present (it read
9
9
  * x-tps-agent), so an anonymous request — which carries no x-tps-agent — slipped
10
10
  * through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
11
11
  * distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
12
+ *
13
+ * No-forge attribution — mode/field drawn from RECORD_TYPES.Soul (record-
14
+ * types slice 2, flair#520) rather than hand-typed literals. "validate-
15
+ * truthy" (see record-type-kit.ts's stampAttribution doc) — rejects a
16
+ * PRESENT, mismatched agentId; passes through untouched when absent. Same
17
+ * idiom as Memory.post()/put().
12
18
  */
13
19
  async function enforceWriteAuth(self, data) {
14
20
  const auth = await resolveAgentAuth(self.getContext?.());
15
21
  if (auth.kind === "anonymous")
16
22
  return UNAUTH();
17
- if (auth.kind === "agent" && !auth.isAdmin && data?.agentId && data.agentId !== auth.agentId) {
18
- return FORBIDDEN("forbidden: agentId must match authenticated agent");
19
- }
20
- return null;
23
+ const attr = stampAttribution(auth, data, RECORD_TYPES.Soul.ownerField, RECORD_TYPES.Soul.attribution.post, "forbidden: agentId must match authenticated agent");
24
+ return attr.denied ?? null;
21
25
  }
26
+ // See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
27
+ // prototype method below, never a class-field assignment — Harper's
28
+ // relationship-traversal RBAC path reads allowRead off the prototype.
29
+ const soulAuthGate = makeAuthGate();
22
30
  export class Soul extends databases.flair.Soul {
23
31
  /**
24
32
  * Self-authorize now that the global gate is non-rejecting. Closes the P0
@@ -30,7 +38,7 @@ export class Soul extends databases.flair.Soul {
30
38
  * verified agent — same posture as Agent.ts's allowRead. Write ownership
31
39
  * is unaffected — enforceWriteAuth() below already gates post()/put().
32
40
  */
33
- allowRead() { return allowVerified(this.getContext?.()); }
41
+ allowRead() { return soulAuthGate.call(this); }
34
42
  async post(content, context) {
35
43
  const denied = await enforceWriteAuth(this, content);
36
44
  if (denied)
@@ -8,11 +8,23 @@
8
8
  * Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
9
9
  */
10
10
  import { databases } from "@harperfast/harper";
11
- import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
11
+ import { resolveAgentAuth } from "./agent-auth.js";
12
12
  import { invalidEntitiesResponse } from "./entity-vocab.js";
13
- const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
14
- const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
15
- const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
13
+ import { makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
14
+ import { RECORD_TYPES } from "./record-types.js";
15
+ // Parameterized from RECORD_TYPES.WorkspaceState (record-types slice 2,
16
+ // flair#520) rather than a hand-typed "owner-only" literal — the registry is
17
+ // now the single source of truth this class draws its read-scope mode from.
18
+ // Exported solely so test/unit/record-types-registry.test.ts's drift
19
+ // tripwire can introspect the composed resolver's tagged `.mode`/
20
+ // `.ownerField` against RECORD_TYPES.WorkspaceState — not for any other
21
+ // runtime consumer.
22
+ export const workspaceReadScope = makeReadScope(RECORD_TYPES.WorkspaceState.readScope, RECORD_TYPES.WorkspaceState.ownerField);
23
+ const workspaceByIdReadGate = makeByIdReadGate(workspaceReadScope);
24
+ // See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
25
+ // prototype method below, never a class-field assignment — Harper's
26
+ // relationship-traversal RBAC path reads allowRead off the prototype.
27
+ const workspaceAuthGate = makeAuthGate();
16
28
  export class WorkspaceState extends databases.flair.WorkspaceState {
17
29
  /** Auth verdict from the request context. internal = trusted in-process call;
18
30
  * agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
@@ -29,13 +41,15 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
29
41
  * full record content. Per-record ownership scoping happens in get() below;
30
42
  * the collection scope is still in search().
31
43
  */
32
- allowRead() { return allowVerified(this.getContext?.()); }
44
+ allowRead() { return workspaceAuthGate.call(this); }
33
45
  /**
34
46
  * Override get() to scope by-id reads the same way search() scopes
35
47
  * collection reads (memory-soul-read-gate family fix). Never distinguishes
36
48
  * "doesn't exist" from "exists but not yours" — both return 404, never
37
49
  * 403, so a denied caller can't use get() to enumerate other agents'
38
- * workspace-state ids.
50
+ * workspace-state ids. Wired through record-type-kit.ts's
51
+ * makeByIdReadGate, scoped "owner-only" — same dispatch shape
52
+ * Memory.ts/Relationship.ts's get() use.
39
53
  */
40
54
  async get(target) {
41
55
  // Collection / query reads — the `GET /WorkspaceState/?<query>` form and
@@ -49,23 +63,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
49
63
  if (!target || (typeof target === "object" && target.isCollection)) {
50
64
  return this.search(target);
51
65
  }
52
- const auth = await this._auth();
53
- // Anonymous by-id read is already blocked at the allowRead() gate (403);
54
- // this is defense-in-depth if get() is ever reached directly.
55
- if (auth.kind === "anonymous") {
56
- return NOT_FOUND();
57
- }
58
- // Trusted internal call or admin agent — unfiltered, unchanged behavior.
59
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
60
- return super.get(target);
61
- }
62
- // Non-admin agent: only its own workspace-state records.
63
- const record = await super.get(target);
64
- if (!record)
65
- return NOT_FOUND();
66
- if (record.agentId !== auth.agentId)
67
- return NOT_FOUND();
68
- return record;
66
+ return workspaceByIdReadGate.call(this, target, (t) => super.get(t));
69
67
  }
70
68
  /**
71
69
  * Override search() to scope collection GETs to the authenticated agent's own
@@ -73,13 +71,15 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
73
71
  * (previously `!authAgent` was treated as unfiltered — the anonymous-read leak).
74
72
  */
75
73
  async search(query) {
76
- const auth = await this._auth();
77
- if (auth.kind === "anonymous")
78
- return UNAUTH();
79
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
74
+ // Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
75
+ // three-way branch Memory.ts/Relationship.ts's search() use.
76
+ const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
77
+ if (gate.kind === "denied")
78
+ return gate.response;
79
+ if (gate.kind === "unfiltered")
80
80
  return super.search(query);
81
- }
82
- const agentIdCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
81
+ const scope = await workspaceReadScope(gate.agentId);
82
+ const agentIdCondition = scope.condition;
83
83
  // Harper passes `query` as a request target object (pathname, id, isCollection…).
84
84
  // Inject the scope condition into its `.conditions` array.
85
85
  if (query && typeof query === "object" && !Array.isArray(query)) {
@@ -100,19 +100,20 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
100
100
  // there was no authenticated agent, so anonymous could write any record).
101
101
  if (auth.kind === "anonymous")
102
102
  return UNAUTH();
103
- // No-forge: a non-admin agent's workspace record is ALWAYS attributed to the
104
- // authenticated identity (from the Ed25519 signature), never the body. We do
105
- // NOT trust `content.agentId` overwriting it (rather than 403'ing a
106
- // mismatch) mirrors Presence.post(): "agentId from signature, NOT from body".
107
- // An admin may write on behalf of another agent (content.agentId honored if
108
- // present, else defaults to the admin's own id). Internal in-process callers
109
- // keep whatever agentId they pass.
110
- if (auth.kind === "agent" && !auth.isAdmin) {
111
- content.agentId = auth.agentId;
112
- }
113
- else if (auth.kind === "agent" && auth.isAdmin) {
114
- content.agentId ||= auth.agentId;
115
- }
103
+ // No-forge attribution mode/field drawn from RECORD_TYPES.WorkspaceState
104
+ // (record-types slice 2, flair#520) rather than hand-typed literals.
105
+ // "stamp-default" (see record-type-kit.ts's stampAttribution doc): a
106
+ // non-admin agent's workspace record is ALWAYS attributed to the
107
+ // authenticated identity (from the Ed25519 signature), never the body.
108
+ // We do NOT trust `content.agentId` overwriting it (rather than
109
+ // 403'ing a mismatch) mirrors Presence.post(): "agentId from signature,
110
+ // NOT from body". An admin may write on behalf of another agent
111
+ // (content.agentId honored if present, else defaults to the admin's own
112
+ // id). Internal in-process callers keep whatever agentId they pass.
113
+ // "stamp-default" never denies (no rejection branch for non-admin)
114
+ // the forbiddenMessage arg is dead for this mode, passed for signature
115
+ // completeness only.
116
+ stampAttribution(auth, content, RECORD_TYPES.WorkspaceState.ownerField, RECORD_TYPES.WorkspaceState.attribution.post, "forbidden: unreachable for stamp-default");
116
117
  content.createdAt = new Date().toISOString();
117
118
  content.timestamp ||= content.createdAt;
118
119
  // attention-plane vocabulary gate (flair#675): `entities`, if present,
@@ -127,9 +128,13 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
127
128
  const auth = await this._auth();
128
129
  if (auth.kind === "anonymous")
129
130
  return UNAUTH();
130
- if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
131
- return FORBIDDEN("forbidden: cannot write workspace state for another agent");
132
- }
131
+ // No-forge attribution mode/field drawn from RECORD_TYPES.WorkspaceState.
132
+ // "validate-strict" (see record-type-kit.ts's stampAttribution doc):
133
+ // rejects a mismatch INCLUDING when agentId is absent (a bare `!==`
134
+ // compare, no truthy guard).
135
+ const attr = stampAttribution(auth, content, RECORD_TYPES.WorkspaceState.ownerField, RECORD_TYPES.WorkspaceState.attribution.put, "forbidden: cannot write workspace state for another agent");
136
+ if (attr.denied)
137
+ return attr.denied;
133
138
  // attention-plane vocabulary gate (flair#675) — same as post() above.
134
139
  const entitiesError = invalidEntitiesResponse(content.entities);
135
140
  if (entitiesError)
@@ -137,15 +142,14 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
137
142
  return super.put(content);
138
143
  }
139
144
  async delete(id) {
140
- const auth = await this._auth();
141
- // Anonymous must NOT delete (previously `!agentId → super.delete` let anonymous
142
- // delete any record).
143
- if (auth.kind === "anonymous")
144
- return UNAUTH();
145
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
145
+ // Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
146
+ // three-way branch get()/search() above use.
147
+ const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
148
+ if (gate.kind === "denied")
149
+ return gate.response;
150
+ if (gate.kind === "unfiltered")
146
151
  return super.delete(id);
147
- }
148
- // Use super.get(id), NOT this.get(id): the new get() override above 404s
152
+ // Use super.get(id), NOT this.get(id): the get() override above 404s
149
153
  // (a truthy Response) for a non-owner id, which would otherwise defeat
150
154
  // the `if (!record)` check below and mis-route a genuinely-missing
151
155
  // record into the FORBIDDEN branch instead of a clean super.delete(id)
@@ -153,7 +157,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
153
157
  const record = await super.get(id);
154
158
  if (!record)
155
159
  return super.delete(id);
156
- if (record.agentId !== auth.agentId) {
160
+ if (record.agentId !== gate.agentId) {
157
161
  return FORBIDDEN("forbidden: cannot delete workspace state for another agent");
158
162
  }
159
163
  return super.delete(id);
@@ -16,7 +16,7 @@
16
16
  * 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
17
17
  */
18
18
  import { databases } from "@harperfast/harper";
19
- import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
19
+ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
20
20
  /**
21
21
  * Shared Harper user that verified Ed25519 agents resolve to (least-privilege
22
22
  * `flair_agent` role), replacing the old admin super_user elevation. Single
@@ -56,15 +56,14 @@ async function getAdminAgents() {
56
56
  export async function isAdmin(agentId) {
57
57
  return (await getAdminAgents()).has(agentId);
58
58
  }
59
- const HEADER_RE = /^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/;
60
59
  async function doVerify(request) {
61
60
  const header = request?.headers?.get?.("authorization") ??
62
61
  request?.headers?.asObject?.authorization ??
63
62
  "";
64
- const m = HEADER_RE.exec(header);
65
- if (!m)
63
+ const parsed = parseTpsEd25519Header(header);
64
+ if (!parsed)
66
65
  return null;
67
- const [, agentId, tsRaw, nonce, signatureB64] = m;
66
+ const { agentId, tsRaw, nonce, signatureB64 } = parsed;
68
67
  const ts = Number(tsRaw);
69
68
  const now = Date.now();
70
69
  if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
@@ -2,7 +2,7 @@ import { patchRecord } from "./table-helpers.js";
2
2
  import { server, databases } from "@harperfast/harper";
3
3
  import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
- import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
5
+ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
7
  // --- Admin credentials ---
8
8
  // Admin auth is sourced exclusively from Harper's own environment variables
@@ -230,8 +230,8 @@ server.http(async (request, nextLayer) => {
230
230
  return nextLayer(request);
231
231
  }
232
232
  // ── Ed25519 agent auth ────────────────────────────────────────────────────
233
- const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
234
- if (!m) {
233
+ const parsed = parseTpsEd25519Header(header);
234
+ if (!parsed) {
235
235
  // For browser-accessible admin pages, emit `WWW-Authenticate: Basic` so
236
236
  // the browser shows a native auth dialog instead of a bare 401 page.
237
237
  // JSON API endpoints don't get this — they should keep the structured
@@ -260,7 +260,7 @@ server.http(async (request, nextLayer) => {
260
260
  catch { /* frozen headers — annotation on the request object still applies */ }
261
261
  return nextLayer(request);
262
262
  }
263
- const [, agentId, tsRaw, nonce, signatureB64] = m;
263
+ const { agentId, tsRaw, nonce, signatureB64 } = parsed;
264
264
  const ts = Number(tsRaw);
265
265
  const now = Date.now();
266
266
  if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
@@ -42,6 +42,43 @@ export { b64ToArrayBuffer };
42
42
  * stated "plugin-shaped, config via env" design intent.
43
43
  */
44
44
  export const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
45
+ // ─── Auth header parsing (single shared implementation) ────────────────────
46
+ //
47
+ // One parser for the `Authorization: TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`
48
+ // header, used by all 3 call sites (auth-middleware.ts, agent-auth.ts,
49
+ // Presence.ts) so the grammar and its input bounds can't drift.
50
+ /**
51
+ * Upper bound on the accepted Authorization header length. A well-formed
52
+ * TPS-Ed25519 header (`TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`) is a few hundred
53
+ * chars at most; anything materially larger is malformed. The header is
54
+ * untrusted client input, so we cap its length before running the regex,
55
+ * keeping parse cost bounded regardless of the input's shape.
56
+ */
57
+ export const MAX_AUTH_HEADER_LEN = 4096;
58
+ /**
59
+ * TPS-Ed25519 auth header grammar.
60
+ *
61
+ * The two colon-delimited text captures use `[^:\s]+` (not `[^:]+`) so they are
62
+ * disjoint from the preceding `\s+`: with no character shared between the two
63
+ * adjacent quantifiers there is exactly one way to split any input, so matching
64
+ * is linear-time on every input (including long, degenerate ones). A real
65
+ * agentId / nonce / signature never contains whitespace, so excluding it is
66
+ * behavior-preserving for well-formed headers.
67
+ */
68
+ export const TPS_ED25519_HEADER_RE = /^TPS-Ed25519\s+([^:\s]+):(\d+):([^:\s]+):(.+)$/;
69
+ /**
70
+ * Parse a TPS-Ed25519 `Authorization` header value into its fields, or return
71
+ * null if it is over `MAX_AUTH_HEADER_LEN` or doesn't match the grammar.
72
+ * Callers treat null exactly as a header that carries no valid agent auth.
73
+ */
74
+ export function parseTpsEd25519Header(header) {
75
+ if (!header || header.length > MAX_AUTH_HEADER_LEN)
76
+ return null;
77
+ const m = TPS_ED25519_HEADER_RE.exec(header);
78
+ if (!m)
79
+ return null;
80
+ return { agentId: m[1], tsRaw: m[2], nonce: m[3], signatureB64: m[4] };
81
+ }
45
82
  // ─── Replay guard (single shared instance) ─────────────────────────────────
46
83
  //
47
84
  // nonceSeen is the ONE module-level singleton — the whole point of this
@@ -87,6 +87,41 @@
87
87
  import { resolveModelsDir } from "./embeddings-provider.js";
88
88
  const LOGICAL_NAME = "default";
89
89
  const MODEL_NAME = "nomic-embed-text";
90
+ /**
91
+ * Pooling declaration (HFE 0.5.0+, harper-fabric-embeddings' `init()`
92
+ * `pooling` option). Verification, not override: the native addon has no
93
+ * pooling knob of its own — llama.cpp always pools by whatever the GGUF's
94
+ * own `<arch>.pooling_type` metadata says. Declaring the expectation makes
95
+ * HFE assert it at init and fail loudly on absent/mismatched metadata,
96
+ * instead of a metadata-less or mismatched conversion silently pooling the
97
+ * wrong way (see node_modules/harper-fabric-embeddings/README.md's `init()`
98
+ * table for the exact contract this PR bumps to).
99
+ *
100
+ * "mean" is nomic-embed-text-v1.5's actual pooling type — NOT assumed from
101
+ * the model's reputation, directly confirmed against the shipped GGUF:
102
+ * `node_modules/.bin/node-llama-cpp inspect gguf
103
+ * ~/.flair/data/models/nomic-embed-text-v1.5.Q4_K_M.gguf` reports
104
+ * `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
105
+ * `enum llama_pooling_type` maps 1 -> `LLAMA_POOLING_TYPE_MEAN` (0 = none,
106
+ * 1 = mean, 2 = cls, 3 = last, 4 = rank). nomic-embed-text is a
107
+ * NomicBertModel architecture, not Qwen3 (last-token) — flair registers no
108
+ * Qwen3-class embedding model today (the Qwen3-Reranker-0.6B in
109
+ * resources/rerank-provider.ts is a SEPARATE code path that calls
110
+ * node-llama-cpp directly for generative yes/no scoring, never goes through
111
+ * HFE's register()/init(), and has no embedding-pooling context at all — see
112
+ * that file's header). If a Qwen3-class (last-token-pooling) embedding model
113
+ * is ever registered here, it must declare `pooling: "last"`, not "mean".
114
+ *
115
+ * Applies to the bench-only `modelPath` override too (`benchModelPathOverride()`
116
+ * below): `FLAIR_RECALL_HARNESS_MODEL_PATH` lets an operator point this
117
+ * registration at an arbitrary GGUF for a Q4/Q8 bakeoff, and this constant
118
+ * assumes whatever file lands there is still nomic-family (mean-pooling) —
119
+ * true for every bakeoff run to date (same base model, different quant).
120
+ * HFE 0.5.0's verification is exactly the safety net that turns "someone
121
+ * points --model-file at a non-nomic, non-mean-pooling GGUF" into a loud
122
+ * boot-time failure instead of a silently-wrong pooling pass.
123
+ */
124
+ const EMBEDDING_POOLING = "mean";
90
125
  /**
91
126
  * BENCH-ONLY escape hatch — NOT a production feature flag, never documented
92
127
  * as an operator setting, and never read anywhere else in this codebase.
@@ -131,7 +166,9 @@ export async function registerEmbeddingsBackend() {
131
166
  await register({
132
167
  logicalName: LOGICAL_NAME,
133
168
  kind: "embedding",
134
- config: modelPath ? { modelPath } : { modelName: MODEL_NAME, modelsDir: resolveModelsDir() },
169
+ config: modelPath
170
+ ? { modelPath, pooling: EMBEDDING_POOLING }
171
+ : { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING },
135
172
  });
136
173
  }
137
174
  catch (err) {
@@ -65,12 +65,21 @@ function jitProvisionEnabled() {
65
65
  * XAA's ID-JAG path uses (resources/XAA.ts resolveOrCreatePrincipal) — one
66
66
  * identity model, keyed on the IdP subject.
67
67
  *
68
+ * `clientId` (flair#718 authorship-provenance) is NOT part of sub resolution —
69
+ * it rides along from the verified token's `client_id` claim (see
70
+ * handleToolCall's stamp-site comment for why `client_id` and never
71
+ * `client_name`) and is copied onto the resolved agent unchanged so downstream
72
+ * write tools (memory_store/memory_update) can thread it into `claimedClient`.
73
+ * Omitted from the returned object entirely when absent — never stamped as
74
+ * `undefined` — so existing callers/tests that don't pass one see the exact
75
+ * same `{ agentId, isAdmin }` shape as before this field existed.
76
+ *
68
77
  * Returns:
69
- * - `{ agentId, isAdmin }` when a Credential maps the sub to an Agent.
78
+ * - `{ agentId, isAdmin, clientId? }` when a Credential maps the sub to an Agent.
70
79
  * - null when no Credential maps the sub AND JIT-provisioning is disabled or
71
80
  * failed → the handler denies the tool call (sub is unresolvable).
72
81
  */
73
- export async function resolveAgentFromSub(sub) {
82
+ export async function resolveAgentFromSub(sub, clientId) {
74
83
  if (!sub)
75
84
  return null;
76
85
  // 1. Existing IdP credential → its principalId is the Agent id.
@@ -87,7 +96,10 @@ export async function resolveAgentFromSub(sub) {
87
96
  await databases.flair.Credential.put({ ...cred, lastUsedAt: new Date().toISOString() });
88
97
  }
89
98
  catch { /* non-fatal */ }
90
- return { agentId: String(cred.principalId), isAdmin: await isAgentAdmin(cred.principalId) };
99
+ const resolved = { agentId: String(cred.principalId), isAdmin: await isAgentAdmin(cred.principalId) };
100
+ if (clientId)
101
+ resolved.clientId = clientId;
102
+ return resolved;
91
103
  }
92
104
  }
93
105
  }
@@ -98,7 +110,10 @@ export async function resolveAgentFromSub(sub) {
98
110
  try {
99
111
  const principalId = await jitProvisionPrincipal(sub);
100
112
  // A JIT-provisioned principal is a fresh, non-admin agent by construction.
101
- return { agentId: principalId, isAdmin: false };
113
+ const resolved = { agentId: principalId, isAdmin: false };
114
+ if (clientId)
115
+ resolved.clientId = clientId;
116
+ return resolved;
102
117
  }
103
118
  catch {
104
119
  return null;
@@ -221,7 +236,16 @@ async function handleToolCall(request, id, params) {
221
236
  if (!sub) {
222
237
  return rpcError(id, -32001, "unauthorized: no verified token subject");
223
238
  }
224
- const agent = await resolveAgentFromSub(String(sub));
239
+ // flair#718 authorship-provenance, Sherlock's binding refinement: source
240
+ // the authorship stamp from `client_id` (the server-generated
241
+ // `flair_cl_...` machine id — resources/OAuth.ts, an RS256-verified JWT
242
+ // claim, not a secret) — NEVER `client_name` (a free-text label the client
243
+ // supplied at Dynamic Client Registration time and fully controls). Do NOT
244
+ // "helpfully" switch this to `client_name` for a prettier label; that would
245
+ // turn a server-verified, stable id into caller-forgeable data landing in
246
+ // stored provenance. Absent/non-string client_id → omitted, not invented.
247
+ const clientId = typeof request?.mcp?.client_id === "string" ? request.mcp.client_id : undefined;
248
+ const agent = await resolveAgentFromSub(String(sub), clientId);
225
249
  if (!agent) {
226
250
  // Sub verified by the AS but not mapped to a flair Agent (and JIT disabled /
227
251
  // failed). Deny — do NOT fall back to anonymous or admin.
@@ -123,13 +123,20 @@ async function memoryStore(agent, args) {
123
123
  // agentId is the RESOLVED agent — Memory.post also re-checks ownership via
124
124
  // resolveAgentAuth, so a mismatched body agentId would 403 anyway; we set it
125
125
  // to the verified id so the write is correctly owned.
126
- return unwrap(await h.post({
126
+ const body = {
127
127
  agentId: agent.agentId,
128
128
  content: args?.content,
129
129
  type: args?.type ?? "session",
130
130
  durability: args?.durability ?? "standard",
131
131
  tags: args?.tags,
132
- }));
132
+ };
133
+ // flair#718 authorship-provenance: forward the resolved OAuth client_id
134
+ // (never a tool argument — no forging) as claimedClient; Memory.post()
135
+ // folds it into provenance.claimed.client and strips it from the row.
136
+ // Omitted entirely when the token carried no client_id.
137
+ if (agent.clientId)
138
+ body.claimedClient = agent.clientId;
139
+ return unwrap(await h.post(body));
133
140
  }
134
141
  /**
135
142
  * memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
@@ -175,12 +182,20 @@ async function memoryUpdate(agent, args) {
175
182
  delete record.validFrom;
176
183
  delete record.validTo;
177
184
  delete record.archivedAt;
185
+ // flair#718 authorship-provenance — see memoryStore's comment: forward
186
+ // the resolved OAuth client_id (never forgeable via args) so the NEW
187
+ // version's provenance records which client authored this update.
188
+ if (agent.clientId)
189
+ record.claimedClient = agent.clientId;
178
190
  h.isCollection = true;
179
191
  return unwrap(await h.post(record));
180
192
  }
181
193
  const merged = { ...existing, content, updatedAt: new Date().toISOString() };
182
194
  delete merged.embedding;
183
195
  delete merged.embeddingModel;
196
+ // flair#718 authorship-provenance — see memoryStore's comment above.
197
+ if (agent.clientId)
198
+ merged.claimedClient = agent.clientId;
184
199
  return unwrap(await h.put(merged));
185
200
  }
186
201
  async function memoryGet(agent, args) {
@@ -287,6 +302,38 @@ async function recordUsage(agent, args) {
287
302
  : typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
288
303
  return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
289
304
  }
305
+ /**
306
+ * Verb→tool-name overrides — the three naming quirks where the shipped tool
307
+ * name isn't record-types.ts's default `${toolPrefix}_${verb}` shape (see
308
+ * that module's `mcp` header doc, and RECORD_TYPES.<Table>.mcp itself,
309
+ * record-types slice 3, flair#520). Keyed by table name + verb; consumed by
310
+ * `mcpToolName()` below and by test/unit/mcp-surface-tripwire.test.ts to
311
+ * compute the expected tool name for every declared registry verb.
312
+ *
313
+ * Placement here (not in record-types.ts) is deliberate — per Kern/
314
+ * Sherlock's unanimous slice-3 verdict, the registry declares WHAT is
315
+ * exposed (capability: toolPrefix + verbs); this map declares HOW that
316
+ * capability's tool is actually named (presentation). Coupling names into
317
+ * the registry would mix the policy layer with the presentation layer,
318
+ * exactly what the registry's separation is meant to avoid.
319
+ */
320
+ export const TOOL_NAME_OVERRIDES = {
321
+ Soul: { store: "soul_set" },
322
+ WorkspaceState: { store: "flair_workspace_set" },
323
+ OrgEvent: { store: "flair_orgevent" },
324
+ };
325
+ /**
326
+ * Resolve the actual `TOOLS` key for a declared (table, verb) pair: the
327
+ * `TOOL_NAME_OVERRIDES` entry if one exists, else the default
328
+ * `${toolPrefix}_${verb}` shape. `toolPrefix` is passed in (rather than
329
+ * looked up from RECORD_TYPES here) so this stays a pure naming function —
330
+ * both this module and the tripwire test call it with the registry's own
331
+ * `toolPrefix` value, keeping the naming rule in exactly one place.
332
+ */
333
+ export function mcpToolName(table, toolPrefix, verb) {
334
+ const override = TOOL_NAME_OVERRIDES[table]?.[verb];
335
+ return override ?? `${toolPrefix}_${verb}`;
336
+ }
290
337
  export const TOOLS = {
291
338
  memory_search: {
292
339
  def: {
@@ -489,7 +536,7 @@ export const TOOLS = {
489
536
  impl: recordUsage,
490
537
  },
491
538
  };
492
- /** The tool definitions for a tools/list response (exactly the 9 curated tools). */
539
+ /** The tool definitions for a tools/list response (exactly the 12 curated tools). */
493
540
  export function listToolDefs() {
494
541
  return Object.values(TOOLS).map((t) => t.def);
495
542
  }
@@ -1,5 +1,36 @@
1
1
  /**
2
- * ─── Write-time provenance stamp (memory-provenance slice 1) ────────────────
2
+ * Sanitize an optional, unverified `claimed.*` passthrough value (memory-
3
+ * provenance slice 1 + flair#718 authorship-provenance). Shared by BOTH
4
+ * `claimed.model` and `claimed.client` — same authority level, same
5
+ * discipline, one implementation so they can't drift:
6
+ *
7
+ * 1. Must be a `string` — anything else (number, object, array) is dropped.
8
+ * 2. Control characters (C0 + DEL, `\x00`-`\x1F`,`\x7F`) are stripped —
9
+ * this is caller-supplied, unverified data landing in a stored JSON
10
+ * blob; no newlines/nulls smuggled into logs or downstream renders.
11
+ * 3. Trimmed.
12
+ * 4. Length-capped at 200 chars (truncated, not rejected — a label this
13
+ * long is almost certainly malformed, but the write must never fail
14
+ * because of it).
15
+ * 5. Dropped (returns `undefined`) if empty after the above — an
16
+ * all-control-chars or all-whitespace input is treated as absent, not
17
+ * stamped as `""`.
18
+ *
19
+ * Sherlock flair#718 review: `claimed.model` previously had only a
20
+ * truthiness check (no cap, no sanitize) — folded into this same function
21
+ * "while touching the same code" per that review's non-blocking recommendation.
22
+ */
23
+ function sanitizeClaim(value) {
24
+ if (typeof value !== "string")
25
+ return undefined;
26
+ const cleaned = value.replace(/[\x00-\x1F\x7F]/g, "").trim();
27
+ if (cleaned.length === 0)
28
+ return undefined;
29
+ return cleaned.length > 200 ? cleaned.slice(0, 200) : cleaned;
30
+ }
31
+ /**
32
+ * ─── Write-time provenance stamp (memory-provenance slice 1; claimed.client
33
+ * added by flair#718 authorship-provenance) ──────────────────────────────────
3
34
  *
4
35
  * Foundational capture for an emergent-trust model: every write gets a
5
36
  * structured, versioned `provenance` JSON blob recording what the server can
@@ -8,7 +39,7 @@
8
39
  *
9
40
  * { v: 1,
10
41
  * verified: { agentId: <string|null>, timestamp: <ISO string> },
11
- * claimed?: { model: <string> } }
42
+ * claimed?: { model?: <string>, client?: <string> } }
12
43
  *
13
44
  * - `verified.agentId` comes from the ALREADY-RESOLVED auth verdict
14
45
  * (resolveAgentAuth) — never from anything the caller can forge on the
@@ -23,10 +54,25 @@
23
54
  * `embeddingModel = getModelId()` stamp in resources/Memory.ts.
24
55
  * - `claimed.model` is an OPTIONAL, UNVERIFIED passthrough: included only
25
56
  * when the incoming write payload itself already carries a non-empty
26
- * string `model` field. No client/CLI sets one today — this just means the
27
- * server won't discard it if/when a future write path does. Never
28
- * invented, never defaulted, and the `claimed` key is omitted entirely
29
- * (not stamped as `{}`) when absent.
57
+ * string `model` field (sanitized via sanitizeClaim above). Never
58
+ * invented, never defaulted.
59
+ * - `claimed.client` (flair#718) is the SAME kind of OPTIONAL, UNVERIFIED
60
+ * passthrough, sourced from `content.claimedClient` (a deliberately
61
+ * distinct body-field name from the output key — see the write paths in
62
+ * resources/Memory.ts / resources/Relationship.ts, which strip this field
63
+ * from the row after calling buildProvenance so it is NEVER persisted
64
+ * outside this provenance blob). Records WHICH CLIENT authored a write
65
+ * under one shared principal (the personal deployment shape — see
66
+ * docs/auth.md "Deployment shapes"). `claimed` — never `verified` —
67
+ * because this is self-reported by an authenticated principal, not
68
+ * independently corroborated: it MUST grant zero authority anywhere
69
+ * (never read for access control, attribution weighting, or dedup
70
+ * decisions — Sherlock flair#718 binding refinement). On the native /mcp
71
+ * OAuth path, the caller is required to source this from the verified
72
+ * `client_id` token claim, never the user-controlled `client_name` — see
73
+ * resources/mcp-handler.ts's handleToolCall for that stamp site.
74
+ * - The `claimed` key is omitted entirely (not stamped as `{}`) when both
75
+ * `model` and `client` are absent.
30
76
  *
31
77
  * Originally introduced in resources/Memory.ts (Memory.post()/Memory.put());
32
78
  * extracted here so Relationship.ts (and any future write path) can reuse the
@@ -43,8 +89,14 @@ export function buildProvenance(auth, createdAt, content) {
43
89
  timestamp: createdAt,
44
90
  },
45
91
  };
46
- if (typeof content?.model === "string" && content.model.length > 0) {
47
- provenance.claimed = { model: content.model };
92
+ const model = sanitizeClaim(content?.model);
93
+ const client = sanitizeClaim(content?.claimedClient);
94
+ if (model !== undefined || client !== undefined) {
95
+ provenance.claimed = {};
96
+ if (model !== undefined)
97
+ provenance.claimed.model = model;
98
+ if (client !== undefined)
99
+ provenance.claimed.client = client;
48
100
  }
49
101
  return JSON.stringify(provenance);
50
102
  }