@tpsdev-ai/flair 0.16.1 → 0.18.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.
@@ -22,7 +22,12 @@ import { layout, htmlResponse, esc } from "./admin-layout.js";
22
22
  */
23
23
  export class AdminMemory extends Resource {
24
24
  async get() {
25
- const request = this.request;
25
+ // Harper v5 does not populate this.request on Resource subclasses —
26
+ // getContext() is the only reliable path (ops-sal4: the previous
27
+ // `(this as any).request` read was always undefined, so query params
28
+ // (?id=, ?q=, ?subject=, ?limit=) were always ignored).
29
+ const ctx = this.getContext?.();
30
+ const request = ctx?.request ?? ctx;
26
31
  const url = new URL(request?.url ?? "http://localhost", "http://localhost");
27
32
  const id = url.searchParams.get("id") ?? "";
28
33
  if (id) {
@@ -39,7 +39,14 @@ export class AgentSeed extends Resource {
39
39
  return allowAdmin(this.getContext?.());
40
40
  }
41
41
  async post(data) {
42
- const actorId = this.request?.tpsAgent;
42
+ // Harper v5 does not populate this.request on Resource subclasses —
43
+ // getContext() is the only reliable path (ops-sal4: the previous
44
+ // `(this as any).request` read was always undefined, so actorId was always
45
+ // undefined and this belt-and-suspenders check fail-closed every request,
46
+ // even from a real admin already verified by allowCreate()).
47
+ const ctx = this.getContext?.();
48
+ const request = ctx?.request ?? ctx;
49
+ const actorId = request?.tpsAgent;
43
50
  if (!actorId || !(await isAdmin(actorId))) {
44
51
  return new Response(JSON.stringify({ error: "forbidden: admin only" }), { status: 403 });
45
52
  }
@@ -63,7 +63,13 @@ export class IngestEvents extends Resource {
63
63
  return true;
64
64
  }
65
65
  async post(body, context) {
66
- const request = this.request;
66
+ // Harper v5 does not populate this.request on Resource subclasses —
67
+ // getContext() is the only reliable path (ops-sal4: the previous
68
+ // `(this as any).request` read was always undefined, so authHeader was
69
+ // always undefined and every request 401'd before reaching the office
70
+ // Ed25519 signature check below).
71
+ const ctx = this.getContext?.();
72
+ const request = ctx?.request ?? ctx;
67
73
  const authHeader = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
68
74
  // Parse and validate body
69
75
  let payload;
@@ -4,6 +4,232 @@ import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
4
4
  import { getEmbedding, getModelId } from "./embeddings-provider.js";
5
5
  import { scanFields, isStrictMode } from "./content-safety.js";
6
6
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
7
+ import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, isConservativeMatch, } from "./dedup.js";
8
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
9
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
10
+ /**
11
+ * ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
12
+ *
13
+ * NEVER SUPPRESSES A WRITE. This gate only computes a SIGNAL — the caller
14
+ * (Memory.post / Memory.put) always proceeds to write the new record. When a
15
+ * conservative match is found, the signal is attached to the RESPONSE only
16
+ * (deduplicated/matchedId/matchConfidence). There must be no code path where
17
+ * finding a match causes the write to be skipped: that was the #526 bug (two
18
+ * topically-close but DISTINCT findings — one about replication
19
+ * route-directionality, one about DDL/schema replication — and the SECOND was
20
+ * silently dropped because the old client-side gate returned the existing
21
+ * record instead of writing).
22
+ *
23
+ * Conservative match = raw cosine >= cosineThreshold AND Jaccard token-overlap
24
+ * >= lexicalThreshold, checked ONLY against the SINGLE top-cosine candidate
25
+ * (scoped to the same agentId as the write — never cross-agent). If that one
26
+ * candidate fails either gate, there is no match; we do not fall back to the
27
+ * 2nd-most-similar candidate.
28
+ *
29
+ * Previously this lived client-side (packages/flair-client/src/client.ts,
30
+ * pre-fix) and only ran for callers that opted into `dedup:true` over HTTP
31
+ * PUT — the Model-2 /mcp handler (resources/mcp-tools.ts), which calls
32
+ * Memory.post() directly, got ZERO dedup checking. Moving the gate here makes
33
+ * it apply uniformly regardless of transport (HTTP PUT vs in-process post()).
34
+ *
35
+ * NOTE on HTTP verbs: the Memory schema only exposes PUT over HTTP (a raw
36
+ * HTTP POST /Memory returns "Memory does not have a post method implemented"
37
+ * — see src/cli.ts's `flair test` command and commit 2fa6d22 / ops-pj5).
38
+ * `Memory.post()` IS reachable, but only via an in-process resource
39
+ * instantiation (as resources/mcp-tools.ts does) — never via the real HTTP
40
+ * POST route. Because flair-client's write() (used by flair-mcp, the CLI, and
41
+ * every other integration package) issues an HTTP PUT, the actual
42
+ * field-observed bug (#526) flows through Memory.put(), not Memory.post().
43
+ * The gate below is therefore a SHARED helper invoked from both post() and
44
+ * put() — anchored in the same place the design calls out (Memory.post), but
45
+ * wired into put() too so the write path real callers actually use is
46
+ * protected. See memory-integrity-fix report for the full writeup of this
47
+ * deviation from a literal "gate lives only in Memory.post" reading.
48
+ */
49
+ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding, cosineThreshold, lexicalThreshold) {
50
+ if (!agentId || !embedding || embedding.length === 0)
51
+ return null;
52
+ try {
53
+ const query = {
54
+ sort: { attribute: "embedding", target: embedding, distance: "cosine" },
55
+ conditions: [
56
+ { attribute: "agentId", comparator: "equals", value: agentId },
57
+ { attribute: "archived", comparator: "not_equal", value: true },
58
+ ],
59
+ select: ["id", "content", "$distance"],
60
+ limit: 1,
61
+ };
62
+ let top = null;
63
+ // Detach ctx.transaction around this search — same rationale as
64
+ // Memory.search()/SemanticSearch.ts: a drained search generator can leave
65
+ // a CLOSED transaction in ctx's chain that the subsequent WRITE
66
+ // (super.post/super.put, right after this gate runs) would otherwise
67
+ // inherit. Detaching here protects that write, not this read.
68
+ const results = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
69
+ for await (const record of results) {
70
+ top = record;
71
+ break; // single top-cosine candidate only — never fall back further
72
+ }
73
+ if (!top)
74
+ return null;
75
+ const cosine = 1 - (top.$distance ?? 1);
76
+ const confidence = computeMatchConfidence(contentText, top.content, cosine);
77
+ if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
78
+ return null;
79
+ }
80
+ return { matchedId: top.id, cosine: confidence.cosine, lexical: confidence.lexical };
81
+ }
82
+ catch {
83
+ // Dedup-check failures (embedding engine down, search error, etc.) must
84
+ // NEVER block or alter the write — treat as "no match found".
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * Run the dedup gate for a create-shaped write. Mutates `content.embedding` /
90
+ * `content.embeddingModel` when it computes a fresh embedding, so the
91
+ * existing "generate embedding if missing" step later in post()/put() reuses
92
+ * it instead of recomputing. Always strips the client-forwarded hint fields
93
+ * (dedup / dedupThreshold / lexicalThreshold) so they never persist onto the
94
+ * stored record — they are passthrough tuning hints, not schema fields.
95
+ *
96
+ * Returns the match signal, or null (no match / gate not applicable).
97
+ */
98
+ async function runDedupGate(ctx, content) {
99
+ const cosineThreshold = typeof content.dedupThreshold === "number" ? content.dedupThreshold : DEDUP_COSINE_THRESHOLD_DEFAULT;
100
+ const lexicalThreshold = typeof content.lexicalThreshold === "number" ? content.lexicalThreshold : DEDUP_LEXICAL_THRESHOLD_DEFAULT;
101
+ delete content.dedup;
102
+ delete content.dedupThreshold;
103
+ delete content.lexicalThreshold;
104
+ if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
105
+ return null;
106
+ }
107
+ let embedding = Array.isArray(content.embedding) ? content.embedding : null;
108
+ if (!embedding) {
109
+ try {
110
+ embedding = await getEmbedding(content.content);
111
+ }
112
+ catch {
113
+ embedding = null;
114
+ }
115
+ if (embedding) {
116
+ content.embedding = embedding;
117
+ content.embeddingModel = getModelId();
118
+ }
119
+ }
120
+ if (!embedding)
121
+ return null;
122
+ return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
123
+ }
124
+ /** Build the final write response: always `written: true`, always includes
125
+ * `id`, and layers the dedup collision signal on top when present. Never a
126
+ * code path where a match suppresses these base fields. */
127
+ function buildWriteResponse(content, result, dedupMatch) {
128
+ const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
129
+ const response = {
130
+ id: content.id,
131
+ ...base,
132
+ written: true,
133
+ deduplicated: !!dedupMatch,
134
+ };
135
+ if (dedupMatch) {
136
+ response.matchedId = dedupMatch.matchedId;
137
+ response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
138
+ }
139
+ return response;
140
+ }
141
+ /**
142
+ * Read-modify-write close of a superseded record, with the SAME transaction
143
+ * detachment discipline as findConservativeDedupMatch (each discrete Harper
144
+ * call individually wrapped — see withDetachedTxn's doc for why a single
145
+ * wrap around a multi-await async function would not protect the later
146
+ * call). Does NOT swallow failures (ops-a4t5 fix) — throws so the caller can
147
+ * log it. Never called before the new record is already written.
148
+ */
149
+ async function closeSupersededRecord(ctx, oldId, patch) {
150
+ const existing = await withDetachedTxn(ctx, () => databases.flair.Memory.get(oldId));
151
+ if (!existing) {
152
+ throw new Error(`supersede-close: record ${oldId} not found`);
153
+ }
154
+ await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...existing, ...patch }));
155
+ }
156
+ /** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
157
+ * pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
158
+ * here for the "write" scope that gates cross-agent supersede. */
159
+ async function hasWriteGrant(granteeId, ownerId) {
160
+ try {
161
+ for await (const grant of databases.flair.MemoryGrant.search({
162
+ conditions: [
163
+ { attribute: "granteeId", comparator: "equals", value: granteeId },
164
+ { attribute: "ownerId", comparator: "equals", value: ownerId },
165
+ ],
166
+ })) {
167
+ if (grant.scope === "write")
168
+ return true;
169
+ }
170
+ }
171
+ catch {
172
+ /* MemoryGrant table not yet populated — no grant */
173
+ }
174
+ return false;
175
+ }
176
+ /**
177
+ * Shared by post() AND put(): the Memory schema only exposes a working HTTP
178
+ * PUT route (a raw HTTP POST /Memory 404s with "Memory does not have a post
179
+ * method implemented" — see src/cli.ts's `flair test` command / commit
180
+ * 2fa6d22 / ops-pj5). Memory.post() IS reachable, but only via an in-process
181
+ * resource instantiation (resources/mcp-tools.ts does this). Since
182
+ * flair-client's write()/update() — used by flair-mcp, the CLI, and every
183
+ * other integration package — issue HTTP PUT, `supersedes` must be fully
184
+ * handled (validated, authorized, and closed) from BOTH entry points for the
185
+ * real-world write path to actually get the fix, not just the in-process one.
186
+ *
187
+ * Validates the `supersedes` field's shape and, for a cross-agent supersede,
188
+ * requires a "write" MemoryGrant from the target's owner (reuses the existing
189
+ * agent-auth/grant machinery — no parallel auth logic). Returns a Response to
190
+ * short-circuit with (400/403), or null to continue.
191
+ */
192
+ async function validateAndAuthorizeSupersedes(content, auth) {
193
+ if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
194
+ return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
195
+ status: 400, headers: { "Content-Type": "application/json" },
196
+ });
197
+ }
198
+ if (content.supersedes && auth.kind === "agent" && !auth.isAdmin) {
199
+ const target = await databases.flair.Memory.get(content.supersedes).catch(() => null);
200
+ if (target && target.agentId !== auth.agentId) {
201
+ if (!(await hasWriteGrant(auth.agentId, target.agentId))) {
202
+ return FORBIDDEN("forbidden: cannot supersede a memory owned by another agent without a write grant");
203
+ }
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+ /**
209
+ * Close the superseded record — called AFTER the new record has already been
210
+ * written (write-new-BEFORE-close-old, ops-a4t5 fix). Safe failure state is
211
+ * two active records (recoverable), never a tombstoned-old-with-lost-new.
212
+ * Failure is logged (observable), never silently swallowed. No-op if
213
+ * `content.supersedes` is not set.
214
+ */
215
+ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
216
+ if (!content.supersedes)
217
+ return;
218
+ try {
219
+ await closeSupersededRecord(ctx, content.supersedes, {
220
+ validTo: content.validFrom ?? content.createdAt,
221
+ updatedAt: content.createdAt ?? content.updatedAt,
222
+ });
223
+ }
224
+ catch (err) {
225
+ // Constant format string + structured data: memory ids are agent-controlled,
226
+ // so interpolating them into console.error's format position (with a trailing
227
+ // `err` arg) would let an id containing %s/%o consume/hide the real error
228
+ // (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
229
+ console.error("Memory.closeSuperseded: failed to close superseded record after writing new record " +
230
+ "(ops-a4t5 — observable, not silent; new record is safely written, old record remains active until retried)", { method: methodLabel, supersededId: content.supersedes, newRecordId: content.id, err });
231
+ }
232
+ }
7
233
  export class Memory extends databases.flair.Memory {
8
234
  /**
9
235
  * Override search() to scope collection GETs by authenticated agent.
@@ -78,20 +304,17 @@ export class Memory extends databases.flair.Memory {
78
304
  // resolveAgentAuth (reads the gate's tpsAgent annotation) — NOT context.user
79
305
  // .username, which is the fallback "admin" super_user while de-elevation is
80
306
  // dormant and would wrongly 403 every agent's own write. internal/admin → pass.
307
+ let auth;
81
308
  {
82
- const auth = await resolveAgentAuth(ctx);
309
+ auth = await resolveAgentAuth(ctx);
83
310
  // Anonymous HTTP must NOT write. Pre-flip the global gate rejected no-auth
84
311
  // upstream; with the non-rejecting gate, each write path self-enforces (same
85
312
  // rule search() applies to reads).
86
313
  if (auth.kind === "anonymous") {
87
- return new Response(JSON.stringify({ error: "authentication required" }), {
88
- status: 401, headers: { "Content-Type": "application/json" },
89
- });
314
+ return UNAUTH();
90
315
  }
91
316
  if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
92
- return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
93
- status: 403, headers: { "Content-Type": "application/json" },
94
- });
317
+ return FORBIDDEN("forbidden: cannot write memory owned by another agent");
95
318
  }
96
319
  }
97
320
  content.durability ||= "standard";
@@ -111,23 +334,16 @@ export class Memory extends databases.flair.Memory {
111
334
  catch { }
112
335
  }
113
336
  }
114
- // supersedes: optional reference to the ID of the memory this one replaces
115
- if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
116
- return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
117
- status: 400, headers: { "Content-Type": "application/json" },
118
- });
119
- }
337
+ // supersedes: optional reference to the ID of the memory this one
338
+ // replaces. Validates shape + cross-agent-write authorization (shared
339
+ // with put() see validateAndAuthorizeSupersedes doc).
340
+ const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
341
+ if (supersedesError)
342
+ return supersedesError;
120
343
  // Temporal validity: validFrom defaults to now, validTo left null for active facts.
121
- // When a memory supersedes another, close the superseded memory's validity window.
122
344
  if (!content.validFrom) {
123
345
  content.validFrom = content.createdAt;
124
346
  }
125
- if (content.supersedes) {
126
- patchRecord(databases.flair.Memory, content.supersedes, {
127
- validTo: content.validFrom,
128
- updatedAt: content.createdAt,
129
- }).catch(() => { });
130
- }
131
347
  if (content.durability === "ephemeral" && !content.expiresAt) {
132
348
  const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
133
349
  content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
@@ -147,7 +363,22 @@ export class Memory extends databases.flair.Memory {
147
363
  content._safetyFlags = safety.flags;
148
364
  }
149
365
  }
150
- // Generate embedding from content text
366
+ // Server-side conservative-duplicate gate (memory-integrity fix). A
367
+ // supersede write is an intentional version-link, not an ambiguous "is
368
+ // this a duplicate of something else" situation — bypass the gate for it
369
+ // (this also gives memory_update's preserveHistory mode dedup-bypass for
370
+ // free, without a separate flag). NEVER suppresses the write either way.
371
+ let dedupMatch = null;
372
+ if (!content.supersedes) {
373
+ dedupMatch = await runDedupGate(ctx, content);
374
+ }
375
+ else {
376
+ delete content.dedup;
377
+ delete content.dedupThreshold;
378
+ delete content.lexicalThreshold;
379
+ }
380
+ // Generate embedding from content text (no-op if the dedup gate above
381
+ // already computed one for this content).
151
382
  if (content.content && !content.embedding) {
152
383
  const vec = await getEmbedding(content.content);
153
384
  if (vec) {
@@ -155,7 +386,16 @@ export class Memory extends databases.flair.Memory {
155
386
  content.embeddingModel = getModelId();
156
387
  }
157
388
  }
158
- return super.post(content);
389
+ // ── Write the new record FIRST ──────────────────────────────────────────
390
+ const result = await super.post(content);
391
+ // ── THEN close the superseded record (ops-a4t5 fix) ─────────────────────
392
+ // Write-new-BEFORE-close-old: the previous order (close-old via a fire-
393
+ // and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
394
+ // old record and then lose the new one if the write failed afterward.
395
+ // Now the safe failure state is two active records (recoverable), never
396
+ // a lost write — and the failure is logged, never silently swallowed.
397
+ await closeSupersededIfNeeded(ctx, content, "post");
398
+ return buildWriteResponse(content, result, dedupMatch);
159
399
  }
160
400
  async put(content) {
161
401
  // Reindex migration bypass: admin-only escape hatch used by the
@@ -181,19 +421,16 @@ export class Memory extends databases.flair.Memory {
181
421
  // write memories it owns, via resolveAgentAuth (gate annotation), not
182
422
  // context.user.username (the dormant-de-elevation fallback is "admin").
183
423
  // The _reindex admin path above bypasses this.
424
+ const ctx = this.getContext?.();
425
+ let auth;
184
426
  {
185
- const octx = this.getContext?.();
186
- const auth = await resolveAgentAuth(octx);
427
+ auth = await resolveAgentAuth(ctx);
187
428
  // Anonymous HTTP must NOT write (non-rejecting gate → self-enforce here).
188
429
  if (auth.kind === "anonymous") {
189
- return new Response(JSON.stringify({ error: "authentication required" }), {
190
- status: 401, headers: { "Content-Type": "application/json" },
191
- });
430
+ return UNAUTH();
192
431
  }
193
432
  if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
194
- return new Response(JSON.stringify({ error: "forbidden: cannot write memory owned by another agent" }), {
195
- status: 403, headers: { "Content-Type": "application/json" },
196
- });
433
+ return FORBIDDEN("forbidden: cannot write memory owned by another agent");
197
434
  }
198
435
  }
199
436
  const now = new Date().toISOString();
@@ -201,6 +438,16 @@ export class Memory extends databases.flair.Memory {
201
438
  // Set defaults that post() sets — put() is also used for new records via CLI
202
439
  content.archived = content.archived ?? false;
203
440
  content.createdAt = content.createdAt ?? now;
441
+ // supersedes: optional reference to the ID of the memory this one
442
+ // replaces. Validates shape + cross-agent-write authorization (shared
443
+ // with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
444
+ // this too: it's the only HTTP-reachable create path).
445
+ const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
446
+ if (supersedesError)
447
+ return supersedesError;
448
+ if (content.supersedes && !content.validFrom) {
449
+ content.validFrom = content.createdAt;
450
+ }
204
451
  // Content safety scan on updated content + summary (ops-i2jb).
205
452
  if (content.content || content.summary) {
206
453
  const safety = scanFields(content, ["content", "summary"]);
@@ -219,7 +466,36 @@ export class Memory extends databases.flair.Memory {
219
466
  content._safetyFlags = null;
220
467
  }
221
468
  }
222
- // Re-generate embedding if content changed
469
+ // Server-side conservative-duplicate gate (memory-integrity fix). PUT is
470
+ // an upsert: only run the gate for a FRESH create (target id does not yet
471
+ // exist) that is NOT a supersede-link write. An update of an EXISTING id
472
+ // (memory_update's default same-id overwrite path) is an intentional,
473
+ // explicit overwrite, and a supersede-link write is an intentional
474
+ // version-link — neither is an ambiguous "is this a duplicate of
475
+ // something else" write, so both are dedup-bypassed automatically, no
476
+ // separate flag needed. NEVER suppresses the write either way.
477
+ let dedupMatch = null;
478
+ if (content.supersedes) {
479
+ delete content.dedup;
480
+ delete content.dedupThreshold;
481
+ delete content.lexicalThreshold;
482
+ }
483
+ else if (content.id) {
484
+ const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
485
+ if (!preExisting) {
486
+ dedupMatch = await runDedupGate(ctx, content);
487
+ }
488
+ else {
489
+ delete content.dedup;
490
+ delete content.dedupThreshold;
491
+ delete content.lexicalThreshold;
492
+ }
493
+ }
494
+ else {
495
+ dedupMatch = await runDedupGate(ctx, content);
496
+ }
497
+ // Re-generate embedding if content changed (no-op if the dedup gate above
498
+ // already computed one for this content).
223
499
  if (content.content && !content.embedding) {
224
500
  const vec = await getEmbedding(content.content);
225
501
  if (vec) {
@@ -240,7 +516,11 @@ export class Memory extends databases.flair.Memory {
240
516
  if (content.promotionStatus === "approved") {
241
517
  content.durability = "permanent";
242
518
  }
243
- return super.put(content);
519
+ // ── Write the new/updated record FIRST ──────────────────────────────────
520
+ const result = await super.put(content);
521
+ // ── THEN close the superseded record (ops-a4t5 fix; see post()) ────────
522
+ await closeSupersededIfNeeded(ctx, content, "put");
523
+ return buildWriteResponse(content, result, dedupMatch);
244
524
  }
245
525
  async delete(id) {
246
526
  const record = await this.get(id);
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
2
2
  import { allowVerified } from "./agent-auth.js";
3
3
  import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { wrapUntrusted } from "./content-safety.js";
5
+ import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
5
6
  /**
6
7
  * POST /MemoryBootstrap
7
8
  *
@@ -13,6 +14,9 @@ import { wrapUntrusted } from "./content-safety.js";
13
14
  * 4. Task-relevant memories (semantic search if currentTask provided)
14
15
  * 5. Relationship context (active relationships for mentioned entities)
15
16
  * 6. Predicted context (based on channel/surface/subject hints)
17
+ * 7. Team roster (other active agents in this office + a search-first nudge —
18
+ * bootstrap only ever loads the caller's own memories, so this is the one
19
+ * place agents learn teammates' findings exist without a separate call)
16
20
  *
17
21
  * Prediction: when context signals (channel, surface, subjects) are provided,
18
22
  * the bootstrap loads more aggressively — Flair is fast enough that the
@@ -80,6 +84,7 @@ export class BootstrapMemories extends Resource {
80
84
  const sections = {
81
85
  soul: [],
82
86
  skills: [],
87
+ team: [],
83
88
  permanent: [],
84
89
  recent: [],
85
90
  predicted: [],
@@ -172,6 +177,32 @@ export class BootstrapMemories extends Resource {
172
177
  sections.skills.push(line);
173
178
  }
174
179
  }
180
+ // --- 1c. Team roster + cross-agent search nudge ---
181
+ // Bootstrap only ever loads the caller's OWN soul/memories (every query
182
+ // below filters record.agentId === agentId). Nothing here tells the agent
183
+ // that teammates exist or that their findings are one memory_search away
184
+ // — so agents never think to check before re-investigating something a
185
+ // teammate already solved. This section is fixed-cost (no query text to
186
+ // format per agent) so it's cheap enough to always include, not budgeted.
187
+ //
188
+ // Permissive kind/status checks are DELIBERATE: Agent.ts registration
189
+ // defaults both (`kind ||= "agent"`, `status ||= "active"`), so pre-1.0
190
+ // records missing either field are legacy agents/active — a strict
191
+ // `!== "agent"` check would silently drop them. Assumes single-tenant
192
+ // (one instance = one office); grant-filtered roster is the multi-tenant follow-up.
193
+ try {
194
+ const teammateIds = [];
195
+ for await (const record of databases.flair.Agent.search()) {
196
+ if (isTeammate(record, agentId))
197
+ teammateIds.push(record.id);
198
+ }
199
+ const line = formatTeamLine(teammateIds);
200
+ if (line)
201
+ sections.team.push(line);
202
+ }
203
+ catch {
204
+ // Agent table may not exist in older / standalone deployments
205
+ }
175
206
  // --- 2. Permanent memories (always included, highest priority) ---
176
207
  const allMemories = [];
177
208
  for await (const record of databases.flair.Memory.search()) {
@@ -379,6 +410,9 @@ export class BootstrapMemories extends Resource {
379
410
  if (sections.skills.length > 0) {
380
411
  parts.push("## Active Skills\n" + sections.skills.join("\n"));
381
412
  }
413
+ if (sections.team.length > 0) {
414
+ parts.push("## Team\n" + sections.team.join("\n"));
415
+ }
382
416
  if (sections.permanent.length > 0) {
383
417
  parts.push("## Core Principles\n" + sections.permanent.join("\n"));
384
418
  }
@@ -405,6 +439,7 @@ export class BootstrapMemories extends Resource {
405
439
  sections: {
406
440
  soul: sections.soul.length,
407
441
  skills: sections.skills.length,
442
+ team: sections.team.length,
408
443
  permanent: sections.permanent.length,
409
444
  recent: sections.recent.length,
410
445
  predicted: sections.predicted.length,
@@ -1,6 +1,7 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { handleJwtBearerGrant } from "./XAA.js";
4
+ import { resolveAgentAuth } from "./agent-auth.js";
4
5
  /**
5
6
  * OAuth 2.1 Authorization Server for Flair.
6
7
  *
@@ -121,7 +122,12 @@ export class OAuthAuthorize extends Resource {
121
122
  async get() {
122
123
  // In 1.0, this returns a simple HTML consent page.
123
124
  // The user (Nathan) approves or denies, which POSTs back.
124
- const request = this.request;
125
+ // Harper v5 does not populate this.request on Resource subclasses —
126
+ // getContext() is the only reliable path (ops-sal4: the previous
127
+ // `(this as any).request` read was always undefined, so query params were
128
+ // always empty).
129
+ const ctx = this.getContext?.();
130
+ const request = ctx?.request ?? ctx;
125
131
  const url = new URL(request?.url ?? "http://localhost", "http://localhost");
126
132
  const clientId = url.searchParams.get("client_id") ?? "";
127
133
  const redirectUri = url.searchParams.get("redirect_uri") ?? "";
@@ -193,9 +199,29 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
193
199
  const params = new URLSearchParams({ error: "access_denied", state });
194
200
  return Response.redirect(`${redirectUri}?${params}`, 302);
195
201
  }
196
- // Determine authenticated principal
197
- const request = this.request;
198
- const principalId = request?.tpsAgent ?? "admin";
202
+ // Determine authenticated principal. Harper v5 does not populate
203
+ // this.request on Resource subclasses, so `(this as any).request?.tpsAgent`
204
+ // was always undefined and this ALWAYS fell back to the hardcoded "admin"
205
+ // every approved consent grant was minted for the admin principal
206
+ // regardless of who actually approved it (ops-sal4 identity spoof).
207
+ // resolveAgentAuth(getContext()) resolves Basic super_user/admin auth to
208
+ // { kind: "agent", agentId: username, isAdmin: true } (see agent-auth.ts).
209
+ // We do NOT silently fall back to "admin" on an unresolved principal — if
210
+ // no principal can be resolved, that's an error state, not an admin grant.
211
+ //
212
+ // SECURITY REVIEW (Sherlock): is resolving the approving principal via
213
+ // resolveAgentAuth the correct policy for "who may approve OAuth consent"
214
+ // in 1.0 — i.e. must the approver be Basic-authenticated as super_user/
215
+ // admin, or should any verified (non-admin) agent be able to approve its
216
+ // own consent grant? The previous code always resolved to "admin" for
217
+ // every caller, so this is a genuine policy decision, not just a bug fix.
218
+ const auth = await resolveAgentAuth(this.getContext?.());
219
+ if (auth.kind !== "agent") {
220
+ return new Response(JSON.stringify({ error: "authentication required" }), {
221
+ status: 401, headers: { "content-type": "application/json" },
222
+ });
223
+ }
224
+ const principalId = auth.agentId;
199
225
  // Generate authorization code
200
226
  const code = randomBytes(32).toString("base64url");
201
227
  const now = nowISO();
@@ -10,7 +10,7 @@
10
10
  * Limit 50 events max.
11
11
  */
12
12
  import { Resource, databases } from "@harperfast/harper";
13
- import { allowVerified } from "./agent-auth.js";
13
+ import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
14
14
  export class OrgEventCatchup extends Resource {
15
15
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
16
16
  // admin elevation). Any verified agent may catch up; participant scoping is in
@@ -21,9 +21,12 @@ export class OrgEventCatchup extends Resource {
21
21
  }
22
22
  // HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
23
23
  async get(pathInfo) {
24
- const request = this.request;
25
- const callerAgent = request?.tpsAgent;
26
- const callerIsAdmin = request?.tpsAgentIsAdmin === true;
24
+ // Harper v5 does not populate this.request on Resource subclasses —
25
+ // getContext() is the only reliable path to the gate's tpsAgent/
26
+ // tpsAgentIsAdmin annotations (ops-sal4: the previous `(this as
27
+ // any).request` read was always undefined, so the ownership check below
28
+ // never ran — fail-open cross-agent read).
29
+ const auth = await resolveAgentAuth(this.getContext?.());
27
30
  // Harper routes /OrgEventCatchup/{id} with pathInfo.id as the path segment
28
31
  const participantId = (typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
29
32
  (typeof pathInfo === "string" ? pathInfo : null) ??
@@ -32,8 +35,13 @@ export class OrgEventCatchup extends Resource {
32
35
  if (!participantId) {
33
36
  return new Response(JSON.stringify({ error: "participantId required in path: GET /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
34
37
  }
35
- // Auth: requesting agent must match participantId (or admin)
36
- if (callerAgent && !callerIsAdmin && callerAgent !== participantId) {
38
+ // Auth: internal calls and admins pass unfiltered; a verified agent may only
39
+ // fetch its own catchup feed; anonymous is denied. allowRead() already
40
+ // blocks anonymous HTTP, but this handler must fail closed on its own too.
41
+ if (auth.kind === "anonymous") {
42
+ return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
43
+ }
44
+ if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== participantId) {
37
45
  return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
38
46
  }
39
47
  // Harper parses query params into pathInfo.conditions array: