@tpsdev-ai/flair 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1811 -221
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +455 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +167 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * POST /MemoryReflect
3
3
  *
4
- * Gathers recent memories for an agent and returns a structured reflection
5
- * prompt. The agent feeds the prompt + memories to its LLM and writes
6
- * insights back as persistent memories (with derivedFrom linking).
4
+ * Gathers recent memories for an agent and either (a) returns a structured
5
+ * reflection prompt for a human/agent to run through their own LLM, or (b)
6
+ * with execute:true, runs the distillation server-side via Harper's models
7
+ * facade and stages the results as MemoryCandidate rows for review. See
8
+ * specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md §3A (issue #707).
7
9
  *
8
10
  * Request:
9
11
  * agentId string — which agent to reflect on
@@ -12,22 +14,33 @@
12
14
  * maxMemories number? — cap (default: 50)
13
15
  * focus string? — "lessons_learned" | "patterns" | "decisions" | "errors" (default: "lessons_learned")
14
16
  * tag string? — required when scope="tagged"
17
+ * execute boolean? — default false. When true, distill server-side and
18
+ * stage MemoryCandidate rows instead of returning a prompt.
15
19
  *
16
- * Response:
20
+ * Response (execute: false, default — unchanged from pre-#707 behavior):
17
21
  * memories Memory[] — source memories included in the prompt
18
22
  * prompt string — structured LLM prompt
19
23
  * suggestedTags string[] — tags Flair detected in the source set
20
24
  * count number — number of memories included
25
+ *
26
+ * Response (execute: true):
27
+ * candidates MemoryCandidate[] — staged rows (rationalePrompt omitted — see below)
28
+ * count number
29
+ * model string — resolved model id (see generatedBy note below)
30
+ *
31
+ * The pure logic behind execute mode (prompt building, actor resolution,
32
+ * generate+validate+retry, dedup) lives in ./memory-reflect-lib.ts — see that
33
+ * file's header for why: importing Resource/databases/models here pulls in
34
+ * the Harper runtime and can't be unit-tested directly (Harper injects
35
+ * `Resource` as a runtime global; bun's ESM linker rejects `import {
36
+ * Resource }` outright — see test/unit/resource-allow.test.ts). This
37
+ * resource is a thin orchestrator over the lib's tested functions.
21
38
  */
22
- import { Resource, databases } from "@harperfast/harper";
39
+ import { Resource, databases, models, logger } from "@harperfast/harper";
40
+ import { randomBytes } from "node:crypto";
23
41
  import { isAdmin, allowVerified } from "./agent-auth.js";
24
42
  import { patchRecordSilent } from "./table-helpers.js";
25
- const FOCUS_PROMPTS = {
26
- lessons_learned: "Review these memories and identify concrete lessons learned. For each lesson: what happened, what you learned, and how it should change future behavior. Write atomic memories with durability=persistent.",
27
- patterns: "Identify recurring patterns across these memories. What themes, approaches, or outcomes appear multiple times? Extract each pattern as a persistent memory.",
28
- decisions: "Catalog the key decisions made and their outcomes. For each: what was decided, why, and what resulted. Promote important decisions to persistent.",
29
- errors: "Extract errors, bugs, and failures. For each: what failed, root cause, and fix applied. These are high-value persistent memories.",
30
- };
43
+ import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, } from "./memory-reflect-lib.js";
31
44
  export class ReflectMemories extends Resource {
32
45
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
33
46
  // admin elevation). Any verified agent may reflect; the isAdmin checks in post()
@@ -36,7 +49,7 @@ export class ReflectMemories extends Resource {
36
49
  return allowVerified(this.getContext?.());
37
50
  }
38
51
  async post(data) {
39
- const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
52
+ const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, execute = false, } = data || {};
40
53
  // Authenticated identity comes from getContext().request, not this.request
41
54
  // (see SemanticSearch / MemoryBootstrap for the same bug class). The prior
42
55
  // check was silently bypassed — bob could reflect on alice's memories and
@@ -46,13 +59,13 @@ export class ReflectMemories extends Resource {
46
59
  const actorId = request?.tpsAgent;
47
60
  const callerIsAdmin = request?.tpsAgentIsAdmin === true
48
61
  || (actorId ? await isAdmin(actorId) : false);
49
- if (!bodyAgentId && !actorId) {
50
- return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
51
- }
52
- if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
53
- return new Response(JSON.stringify({ error: "forbidden: can only reflect on own memories" }), { status: 403 });
62
+ // Same actor rules for both modes (spec §3A item 9) — resolved once via
63
+ // the shared, tested helper before either branch runs.
64
+ const actorResolution = resolveReflectActor({ bodyAgentId, actorId, callerIsAdmin });
65
+ if (actorResolution.error) {
66
+ return new Response(JSON.stringify(actorResolution.error.body), { status: actorResolution.error.status });
54
67
  }
55
- const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
68
+ const agentId = actorResolution.agentId;
56
69
  const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
57
70
  const memories = [];
58
71
  for await (const record of databases.flair.Memory.search()) {
@@ -83,38 +96,81 @@ export class ReflectMemories extends Resource {
83
96
  for (const t of m.tags ?? [])
84
97
  tagSet.add(t);
85
98
  }
86
- // Build prompt
87
- const focusText = FOCUS_PROMPTS[focus] ?? FOCUS_PROMPTS.lessons_learned;
88
- const memorySummary = memories
89
- .map((m, i) => `[${i + 1}] (${m.id}) ${m.createdAt?.slice(0, 10) ?? "?"}: ${m.content.slice(0, 300)}`)
90
- .join("\n");
91
- const prompt = `# Memory Reflection — ${agentId}
92
- Focus: ${focus}
93
- Scope: ${scope} (since ${sinceDate.toISOString()})
94
- Memories: ${memories.length}
95
-
96
- ## Task
97
- ${focusText}
98
-
99
- ## Source Memories
100
- ${memorySummary || "(none)"}
101
-
102
- ## Instructions
103
- For each insight:
104
- 1. Write a new memory with durability=persistent
105
- 2. Set derivedFrom=[<source memory ids>]
106
- 3. Set tags from the source memories where relevant
107
- 4. Keep each memory atomic — one insight per record`;
108
- // Update lastReflected on source memories (read-modify-write to preserve embeddings)
109
- const now = new Date().toISOString();
99
+ // Update lastReflected on source memories (read-modify-write to preserve
100
+ // embeddings). Unconditional for both modes — calling /ReflectMemories at
101
+ // all means these memories were considered, regardless of what happens next.
102
+ const nowISO = new Date().toISOString();
110
103
  for (const m of memories) {
111
- patchRecordSilent(databases.flair.Memory, m.id, { lastReflected: now });
104
+ patchRecordSilent(databases.flair.Memory, m.id, { lastReflected: nowISO });
105
+ }
106
+ const promptInputs = memories.map((m) => ({ id: m.id, createdAt: m.createdAt, content: m.content }));
107
+ if (!execute) {
108
+ const prompt = buildReflectionPrompt({ agentId, focus, scope, sinceISO: sinceDate.toISOString(), memories: promptInputs });
109
+ return {
110
+ memories,
111
+ prompt,
112
+ suggestedTags: [...tagSet].slice(0, 20),
113
+ count: memories.length,
114
+ };
115
+ }
116
+ // ── execute mode (spec §3A) ─────────────────────────────────────────────
117
+ const executePrompt = buildExecutePrompt({ agentId, focus, scope, sinceISO: sinceDate.toISOString(), memories: promptInputs });
118
+ const gatheredMemoryIds = new Set(promptInputs.map((m) => m.id));
119
+ const configuredModel = process.env.FLAIR_REM_MODEL || undefined;
120
+ const outcome = await generateCandidates({
121
+ prompt: executePrompt,
122
+ model: configuredModel,
123
+ gatheredMemoryIds,
124
+ generate: (input, opts) => models.generate(input, opts),
125
+ });
126
+ if (!outcome.ok) {
127
+ if (outcome.reason === "no_backend") {
128
+ // Static body (K&S) — never echo Harper version, backend lists, or endpoints.
129
+ return new Response(JSON.stringify({ error: "No generative backend configured. See the models configuration docs." }), { status: 503 });
130
+ }
131
+ return new Response(JSON.stringify({ error: "distillation_failed", detail: "model output did not validate after one retry" }), { status: 502 });
132
+ }
133
+ if (outcome.usedJsonFallback) {
134
+ logger.warn?.(`MemoryReflect: json-fallback path active for agent ${agentId} (schema-mode output failed validation)`);
135
+ }
136
+ // Dedup against this agent's existing pending candidates (spec §3A item 4).
137
+ const existingPendingClaims = [];
138
+ for await (const c of databases.flair.MemoryCandidate.search({})) {
139
+ if (c.agentId !== agentId)
140
+ continue;
141
+ if (c.status !== "pending")
142
+ continue;
143
+ existingPendingClaims.push(c.claim);
144
+ }
145
+ const toStage = dedupeCandidates(outcome.candidates, existingPendingClaims);
146
+ // generatedBy: GenerateResult in the pinned @harperfast/harper 5.1.17 has
147
+ // no model/backend-id field (content/finishReason/usage/toolCalls/trace
148
+ // only) — the "from the generate result if available" branch is
149
+ // unreachable in this version, so this always falls back to the
150
+ // configured logical name, matching Harper's own default routing name.
151
+ const resolvedModel = configuredModel ?? "default";
152
+ const generatedAt = new Date().toISOString();
153
+ const staged = [];
154
+ for (const c of toStage) {
155
+ const row = {
156
+ id: `cand_${randomBytes(8).toString("hex")}`,
157
+ agentId,
158
+ claim: c.claim,
159
+ sourceMemoryIds: c.sourceMemoryIds,
160
+ rationalePrompt: executePrompt,
161
+ generatedBy: resolvedModel,
162
+ generatedAt,
163
+ status: "pending",
164
+ };
165
+ await databases.flair.MemoryCandidate.put(row);
166
+ staged.push(row);
112
167
  }
113
- return {
114
- memories,
115
- prompt,
116
- suggestedTags: [...tagSet].slice(0, 20),
117
- count: memories.length,
118
- };
168
+ // Response omits rationalePrompt (spec §3A item 5: "no prompt field") —
169
+ // it's identical across every row in this batch and already persisted
170
+ // for audit on the MemoryCandidate row itself; echoing it back per
171
+ // candidate would just repeat the same large string N times. Matches
172
+ // `flair rem candidates`' own listing, which doesn't surface it either.
173
+ const responseCandidates = staged.map(({ rationalePrompt, ...rest }) => rest);
174
+ return { candidates: responseCandidates, count: responseCandidates.length, model: resolvedModel };
119
175
  }
120
176
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * resources/MemoryUsage.ts — the dedup ledger for the usage-feedback signal
3
+ * (flair#683). One row per (agentId, memoryId) contribution; see
4
+ * schemas/memory.graphql's MemoryUsage type doc for the full field/design
5
+ * rationale.
6
+ *
7
+ * This file is a LOCKED-DOWN table guard, not the public write surface: the
8
+ * actual "record that a memory was used" action lives in
9
+ * resources/RecordUsage.ts (POST /RecordUsage) — see that file's module doc
10
+ * for why the action lives on a SEPARATE, non-table-backed resource (a
11
+ * confirmed-live Harper gotcha: the base TableResource has no default
12
+ * `post()` for a static-style raw-table call outside an
13
+ * `isCollection`-instantiated resource — resources/Memory.ts documents the
14
+ * HTTP-facing shape of this same limitation for the Memory table).
15
+ * RecordUsage writes ledger rows via `.put()` on the RAW table object
16
+ * (`databases.flair.MemoryUsage` — an upsert against the deterministic
17
+ * composite id, not a `.post()`), the same "call the other table directly,
18
+ * bypass its resource class's own auth wrapper" pattern resources/Memory.ts
19
+ * already uses for MemoryGrant (hasWriteGrant()) — so nothing below is
20
+ * bypassed by that internal path; it only gates the DIRECT `/MemoryUsage`
21
+ * HTTP route.
22
+ *
23
+ * Why agents get READ but NOT UPDATE/DELETE here (mirrored in
24
+ * src/cli.ts's FLAIR_AGENT_PERMISSION native-role grant): the ledger IS the
25
+ * dedup/anti-gaming primitive — Sherlock's "(agent, memory) contributes ≤ 1"
26
+ * rule is enforced by RecordUsage checking for an EXISTING ledger row before
27
+ * bumping usageCount. If an agent could DELETE its own row over HTTP, it
28
+ * could re-trigger RecordUsage for the same memory indefinitely (create row
29
+ * → count once → delete row → count again → repeat), completely defeating
30
+ * the dedup cap. Locking put()/delete() to admin/internal here is therefore
31
+ * LOAD-BEARING, not just defense in depth — it's the only thing enforcing
32
+ * this in an environment where the native Harper role hasn't been
33
+ * (re-)provisioned yet (auth-middleware.ts's documented pre-migration
34
+ * admin-fallback — and every ephemeral test Harper spawned via
35
+ * test/helpers/harper-lifecycle.ts, which never runs `flair init`'s role
36
+ * provisioning at all).
37
+ *
38
+ * Read scope is deliberately narrower than Memory's "open-within-org" model:
39
+ * an agent sees only ITS OWN contributions (or admin sees everything). The
40
+ * ledger is an audit trail, not a shared surface — there is no product need
41
+ * to expose "which agent used which memory" cross-agent, and narrowing this
42
+ * costs nothing.
43
+ */
44
+ import { databases } from "@harperfast/harper";
45
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
46
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
47
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
48
+ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
49
+ export class MemoryUsage extends databases.flair.MemoryUsage {
50
+ /** Self-authorize now that the global gate is non-rejecting — same pattern
51
+ * as every other table resource in this codebase (Memory.ts/MemoryGrant.ts
52
+ * etc.). Per-record scoping happens in get()/search() below. */
53
+ allowRead() { return allowVerified(this.getContext?.()); }
54
+ async get(target) {
55
+ if (!target || (typeof target === "object" && target.isCollection)) {
56
+ return this.search(target);
57
+ }
58
+ const auth = await resolveAgentAuth(this.getContext?.());
59
+ if (auth.kind === "anonymous")
60
+ return NOT_FOUND();
61
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
62
+ return super.get(target);
63
+ const record = await super.get(target);
64
+ if (!record || record.agentId !== auth.agentId)
65
+ return NOT_FOUND();
66
+ return record;
67
+ }
68
+ async search(query) {
69
+ const auth = await resolveAgentAuth(this.getContext?.());
70
+ if (auth.kind === "anonymous")
71
+ return UNAUTH();
72
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
73
+ return super.search(query);
74
+ const scope = { attribute: "agentId", comparator: "equals", value: auth.agentId };
75
+ if (query && typeof query === "object" && !Array.isArray(query)) {
76
+ const existing = query.conditions ?? [];
77
+ query.conditions = Array.isArray(existing) ? [scope, ...existing] : [scope, existing];
78
+ return super.search(query);
79
+ }
80
+ const conditions = Array.isArray(query) && query.length > 0 ? [scope, ...query] : [scope];
81
+ return super.search(conditions);
82
+ }
83
+ // Append-only ledger: rows are created via RecordUsage's RAW table call
84
+ // (bypasses this class entirely — see module doc), never via this
85
+ // instance-level HTTP route, for non-admin callers.
86
+ async post(content) {
87
+ const auth = await resolveAgentAuth(this.getContext?.());
88
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
89
+ return super.post(content);
90
+ return FORBIDDEN("forbidden: MemoryUsage rows are written by the /RecordUsage endpoint, not directly");
91
+ }
92
+ async put(content) {
93
+ const auth = await resolveAgentAuth(this.getContext?.());
94
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
95
+ return super.put(content);
96
+ return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
97
+ }
98
+ async delete(id) {
99
+ const auth = await resolveAgentAuth(this.getContext?.());
100
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
101
+ return super.delete(id);
102
+ return FORBIDDEN("forbidden: MemoryUsage rows cannot be deleted by non-admins (dedup-integrity invariant — see module doc)");
103
+ }
104
+ }
@@ -216,7 +216,14 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
216
216
  status: 400, headers: { "content-type": "application/json" },
217
217
  });
218
218
  }
219
- if (action === "deny") {
219
+ // flair#610 mint an authorization code ONLY on an explicit approve. The
220
+ // consent form rendered by get() submits action=approve|deny and nothing
221
+ // else, so treating every non-"approve" value (deny, missing, empty, or
222
+ // garbage) as a denial is safe — and it closes the previous loose
223
+ // `action !== "deny"` check, which minted a code for a malformed or absent
224
+ // action. redirect_uri was already validated above, so this access_denied
225
+ // redirect can only target the allowed URI.
226
+ if (action !== "approve") {
220
227
  const params = new URLSearchParams({ error: "access_denied", state });
221
228
  return redirectTo(`${redirectUri}?${params}`);
222
229
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { databases } from "@harperfast/harper";
14
14
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
15
+ import { invalidEntitiesResponse } from "./entity-vocab.js";
15
16
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
16
17
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
17
18
  export class OrgEvent extends databases.flair.OrgEvent {
@@ -39,6 +40,12 @@ export class OrgEvent extends databases.flair.OrgEvent {
39
40
  if (!content.id)
40
41
  content.id = `${content.authorId}-${new Date().toISOString()}`;
41
42
  content.createdAt = new Date().toISOString();
43
+ // attention-plane vocabulary gate (flair#675): `entities`, if present,
44
+ // must be well-formed vocabulary strings — see resources/entity-vocab.ts.
45
+ // Field is additive/optional; absent entities is not an error.
46
+ const entitiesError = invalidEntitiesResponse(content.entities);
47
+ if (entitiesError)
48
+ return entitiesError;
42
49
  // Harper 5: table resources use put() for create/upsert (post() removed).
43
50
  return databases.flair.OrgEvent.put(content);
44
51
  }
@@ -49,6 +56,10 @@ export class OrgEvent extends databases.flair.OrgEvent {
49
56
  if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
50
57
  return FORBIDDEN("forbidden: authorId must match authenticated agent");
51
58
  }
59
+ // attention-plane vocabulary gate (flair#675) — same as post() above.
60
+ const entitiesError = invalidEntitiesResponse(content.entities);
61
+ if (entitiesError)
62
+ return entitiesError;
52
63
  return databases.flair.OrgEvent.put(content);
53
64
  }
54
65
  async delete(id, context) {