@tpsdev-ai/flair 0.22.0 → 0.23.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.
@@ -11,12 +11,16 @@
11
11
  * distinguishes internal/agent/anonymous explicitly.
12
12
  */
13
13
  import { databases } from "@harperfast/harper";
14
- import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
14
+ import { resolveAgentAuth } from "./agent-auth.js";
15
15
  import { invalidEntitiesResponse } from "./entity-vocab.js";
16
- const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
17
- const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
16
+ import { makeAuthGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
17
+ import { RECORD_TYPES } from "./record-types.js";
18
+ // See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
19
+ // prototype method below, never a class-field assignment — Harper's
20
+ // relationship-traversal RBAC path reads allowRead off the prototype.
21
+ const orgEventAuthGate = makeAuthGate();
18
22
  export class OrgEvent extends databases.flair.OrgEvent {
19
- allowRead() { return allowVerified(this.getContext?.()); }
23
+ allowRead() { return orgEventAuthGate.call(this); }
20
24
  _auth() {
21
25
  return resolveAgentAuth(this.getContext?.());
22
26
  }
@@ -24,19 +28,21 @@ export class OrgEvent extends databases.flair.OrgEvent {
24
28
  const auth = await this._auth();
25
29
  if (auth.kind === "anonymous")
26
30
  return UNAUTH();
27
- // No-forge attribution: a non-admin agent's events are ALWAYS attributed to
28
- // its authenticated identity (from the Ed25519 signature), never the body
29
- // an agent can only publish AS itself. We overwrite `authorId` rather than
30
- // 403'ing a mismatch so a CLI client never has to echo its own id into the
31
- // body (mirrors A2A message/send's "sender must match params.agentId" guard
32
- // and Presence's "agentId from signature, NOT from body"). Admin agents may
33
- // publish on behalf of another agent (body authorId honored, else their own).
34
- if (auth.kind === "agent" && !auth.isAdmin) {
35
- content.authorId = auth.agentId;
36
- }
37
- else if (auth.kind === "agent" && auth.isAdmin) {
38
- content.authorId ||= auth.agentId;
39
- }
31
+ // No-forge attribution mode/field drawn from RECORD_TYPES.OrgEvent
32
+ // (record-types slice 2, flair#520) rather than hand-typed literals.
33
+ // "stamp-default" (see record-type-kit.ts's stampAttribution doc): a
34
+ // non-admin agent's events are ALWAYS attributed to its authenticated
35
+ // identity (from the Ed25519 signature), never the body — an agent can
36
+ // only publish AS itself. We overwrite `authorId` rather than 403'ing a
37
+ // mismatch so a CLI client never has to echo its own id into the body
38
+ // (mirrors A2A message/send's "sender must match params.agentId" guard
39
+ // and Presence's "agentId from signature, NOT from body"). Admin agents
40
+ // may publish on behalf of another agent (body authorId honored, else
41
+ // their own).
42
+ // "stamp-default" never denies (no rejection branch for non-admin) —
43
+ // the forbiddenMessage arg is dead for this mode, passed for signature
44
+ // completeness only.
45
+ stampAttribution(auth, content, RECORD_TYPES.OrgEvent.ownerField, RECORD_TYPES.OrgEvent.attribution.post, "forbidden: unreachable for stamp-default");
40
46
  if (!content.id)
41
47
  content.id = `${content.authorId}-${new Date().toISOString()}`;
42
48
  content.createdAt = new Date().toISOString();
@@ -53,9 +59,13 @@ export class OrgEvent extends databases.flair.OrgEvent {
53
59
  const auth = await this._auth();
54
60
  if (auth.kind === "anonymous")
55
61
  return UNAUTH();
56
- if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
57
- return FORBIDDEN("forbidden: authorId must match authenticated agent");
58
- }
62
+ // No-forge attribution mode/field drawn from RECORD_TYPES.OrgEvent.
63
+ // "validate-strict" (see record-type-kit.ts's stampAttribution doc):
64
+ // rejects a mismatch INCLUDING when authorId is absent (a bare `!==`
65
+ // compare, no truthy guard).
66
+ const attr = stampAttribution(auth, content, RECORD_TYPES.OrgEvent.ownerField, RECORD_TYPES.OrgEvent.attribution.put, "forbidden: authorId must match authenticated agent");
67
+ if (attr.denied)
68
+ return attr.denied;
59
69
  // attention-plane vocabulary gate (flair#675) — same as post() above.
60
70
  const entitiesError = invalidEntitiesResponse(content.entities);
61
71
  if (entitiesError)
@@ -63,16 +73,17 @@ export class OrgEvent extends databases.flair.OrgEvent {
63
73
  return databases.flair.OrgEvent.put(content);
64
74
  }
65
75
  async delete(id, context) {
66
- const auth = await this._auth();
67
- if (auth.kind === "anonymous")
68
- return UNAUTH();
69
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
76
+ // Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
77
+ // three-way branch Memory.ts/Relationship.ts/WorkspaceState.ts use.
78
+ const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
79
+ if (gate.kind === "denied")
80
+ return gate.response;
81
+ if (gate.kind === "unfiltered")
70
82
  return super.delete(id, context);
71
- }
72
83
  const record = await this.get(id);
73
84
  if (!record)
74
85
  return super.delete(id, context);
75
- if (record.authorId !== auth.agentId) {
86
+ if (record.authorId !== gate.agentId) {
76
87
  return FORBIDDEN("forbidden: cannot delete events authored by another agent");
77
88
  }
78
89
  return super.delete(id, context);
@@ -1,11 +1,22 @@
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 { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
4
  import { localInstanceId } from "./instance-identity.js";
5
- import { buildProvenance } from "./provenance.js";
6
- const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
7
- const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
8
- const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
5
+ import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
6
+ import { RECORD_TYPES } from "./record-types.js";
7
+ // Parameterized from RECORD_TYPES.Relationship (record-types slice 2,
8
+ // flair#520) rather than a hand-typed "owner-only" literal the registry is
9
+ // now the single source of truth this class draws its read-scope mode from.
10
+ // Exported solely so test/unit/record-types-registry.test.ts's drift
11
+ // tripwire can introspect the composed resolver's tagged `.mode`/
12
+ // `.ownerField` against RECORD_TYPES.Relationship — not for any other
13
+ // runtime consumer.
14
+ export const relationshipReadScope = makeReadScope(RECORD_TYPES.Relationship.readScope, RECORD_TYPES.Relationship.ownerField);
15
+ const relationshipByIdReadGate = makeByIdReadGate(relationshipReadScope);
16
+ // See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
17
+ // prototype method below, never a class-field assignment — Harper's
18
+ // relationship-traversal RBAC path reads allowRead off the prototype.
19
+ const relationshipAuthGate = makeAuthGate();
9
20
  /**
10
21
  * Relationship resource — entity-to-entity relationships with temporal validity.
11
22
  *
@@ -28,13 +39,14 @@ export class Relationship extends databases.flair.Relationship {
28
39
  * ownership scoping happens in get() below; the collection scope is still
29
40
  * in search().
30
41
  */
31
- allowRead() { return allowVerified(this.getContext?.()); }
42
+ allowRead() { return relationshipAuthGate.call(this); }
32
43
  /**
33
44
  * Override get() to scope by-id reads the same way search() scopes
34
45
  * collection reads (memory-soul-read-gate family fix). Never distinguishes
35
46
  * "doesn't exist" from "exists but not yours" — both return 404, never
36
47
  * 403, so a denied caller can't use get() to enumerate other agents'
37
- * relationship ids.
48
+ * relationship ids. Wired through record-type-kit.ts's makeByIdReadGate,
49
+ * scoped "owner-only" — same dispatch shape Memory.ts's get() uses.
38
50
  */
39
51
  async get(target) {
40
52
  // Collection / query reads arrive as a RequestTarget with
@@ -47,39 +59,23 @@ export class Relationship extends databases.flair.Relationship {
47
59
  if (!target || (typeof target === "object" && target.isCollection)) {
48
60
  return this.search(target);
49
61
  }
50
- const auth = await resolveAgentAuth(this.getContext?.());
51
- // Anonymous by-id read is already blocked at the allowRead() gate (403);
52
- // this is defense-in-depth if get() is ever reached directly.
53
- if (auth.kind === "anonymous") {
54
- return NOT_FOUND();
55
- }
56
- // Trusted internal call or admin agent — unfiltered, unchanged behavior.
57
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
58
- return super.get(target);
59
- }
60
- // Non-admin agent: only its own relationships.
61
- const record = await super.get(target);
62
- if (!record)
63
- return NOT_FOUND();
64
- if (record.agentId !== auth.agentId)
65
- return NOT_FOUND();
66
- return record;
62
+ return relationshipByIdReadGate.call(this, target, (t) => super.get(t));
67
63
  }
68
64
  async search(query) {
69
- const auth = await resolveAgentAuth(this.getContext?.());
65
+ const ctx = this.getContext?.();
70
66
  // Anonymous HTTP must NOT read relationships (previously `!authAgent` was
71
- // treated as unfiltered — the anonymous-read leak).
72
- if (auth.kind === "anonymous") {
73
- return new Response(JSON.stringify({ error: "authentication required" }), {
74
- status: 401, headers: { "content-type": "application/json" },
75
- });
76
- }
77
- // Trusted internal call or admin agent → unfiltered.
78
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
67
+ // treated as unfiltered — the anonymous-read leak). Trusted internal call
68
+ // or admin agent → unfiltered. Dispatch shape shared via
69
+ // record-type-kit.ts's resolveAuthGate same three-way branch Memory.ts/
70
+ // WorkspaceState.ts's search() use.
71
+ const gate = await resolveAuthGate(ctx, UNAUTH());
72
+ if (gate.kind === "denied")
73
+ return gate.response;
74
+ if (gate.kind === "unfiltered")
79
75
  return super.search(query);
80
- }
81
76
  // Non-admin agent: scope to own relationships.
82
- const agentCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
77
+ const scope = await relationshipReadScope(gate.agentId);
78
+ const agentCondition = scope.condition;
83
79
  if (!query?.conditions) {
84
80
  return super.search({ conditions: [agentCondition], ...(query || {}) });
85
81
  }
@@ -130,16 +126,15 @@ export class Relationship extends databases.flair.Relationship {
130
126
  if (auth.kind === "anonymous") {
131
127
  return UNAUTH();
132
128
  }
133
- if (auth.kind === "agent") {
134
- if (!auth.isAdmin) {
135
- if (content?.agentId && content.agentId !== auth.agentId) {
136
- return FORBIDDEN("cannot write a relationship owned by another agent");
137
- }
138
- content.agentId = auth.agentId;
139
- }
140
- // admin: content.agentId left as provided (unfiltered) — see doc above.
141
- }
142
- // internal: content.agentId left as provided (unfiltered) — see doc above.
129
+ // No-forge attribution mode/field drawn from RECORD_TYPES.Relationship
130
+ // (record-types slice 2, flair#520) rather than a hand-typed literal.
131
+ // "stamp-strict" (see record-type-kit.ts's stampAttribution doc): reject
132
+ // a PRESENT, mismatched agentId, else unconditionally stamp with the
133
+ // verified identity. Admin/internal: content.agentId left as provided
134
+ // (unfiltered) — see doc above.
135
+ const attr = stampAttribution(auth, content, RECORD_TYPES.Relationship.ownerField, RECORD_TYPES.Relationship.attribution.put, "cannot write a relationship owned by another agent");
136
+ if (attr.denied)
137
+ return attr.denied;
143
138
  if (!content.agentId || typeof content.agentId !== "string") {
144
139
  return new Response(JSON.stringify({ error: "agentId is required" }), {
145
140
  status: 400, headers: { "content-type": "application/json" },
@@ -190,6 +185,11 @@ export class Relationship extends databases.flair.Relationship {
190
185
  // pre-existing row with no provenance field reads back `undefined`,
191
186
  // unchanged behavior (migration-equivalence gate).
192
187
  content.provenance = buildProvenance(auth, content.createdAt, content);
188
+ // flair#718 authorship-provenance — same contract as resources/Memory.ts's
189
+ // post()/put(): `claimedClient` is a write-body-only passthrough, already
190
+ // folded into `provenance.claimed.client` above. Strip it so it is NEVER
191
+ // persisted as a row field.
192
+ delete content.claimedClient;
193
193
  // Write-time originatorInstanceId stamp (federation-edge-hardening slice
194
194
  // 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
195
195
  // full contract. No-op if already set (never fires for a genuine local
@@ -214,17 +214,16 @@ export class Relationship extends databases.flair.Relationship {
214
214
  */
215
215
  async delete(_) {
216
216
  const ctx = this.getContext?.();
217
- const auth = await resolveAgentAuth(ctx);
218
- if (auth.kind === "anonymous") {
219
- return UNAUTH();
220
- }
221
- // Trusted internal call or admin agent — unfiltered, unchanged behavior.
222
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
217
+ // Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
218
+ // three-way branch put()/get()/search() above use.
219
+ const gate = await resolveAuthGate(ctx, UNAUTH());
220
+ if (gate.kind === "denied")
221
+ return gate.response;
222
+ if (gate.kind === "unfiltered")
223
223
  return super.delete(_);
224
- }
225
224
  // Non-admin agent: verify ownership before delete.
226
225
  const existing = await super.get();
227
- if (existing?.agentId && existing.agentId !== auth.agentId) {
226
+ if (existing?.agentId && existing.agentId !== gate.agentId) {
228
227
  return FORBIDDEN("cannot delete another agent's relationship");
229
228
  }
230
229
  return super.delete(_);
@@ -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);
@@ -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.