@tpsdev-ai/flair 0.17.0 → 0.19.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,10 +1,317 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
3
- import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
3
+ import { isAdmin, resolveAgentAuth, allowVerified } 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
+ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
11
+ /**
12
+ * Owner ids a non-admin agent may READ: itself, plus any owner who has
13
+ * granted it a "read" or "search" scoped MemoryGrant. Shared by search()
14
+ * (collection scoping) and get() (per-id ownership check, memory-soul-read-
15
+ * gate fix) so the two paths cannot drift apart — this was previously
16
+ * computed inline only inside search().
17
+ */
18
+ async function resolveAllowedOwners(authAgentId) {
19
+ const allowedOwners = [authAgentId];
20
+ try {
21
+ for await (const grant of databases.flair.MemoryGrant.search({
22
+ conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgentId }],
23
+ })) {
24
+ if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
25
+ allowedOwners.push(grant.ownerId);
26
+ }
27
+ }
28
+ }
29
+ catch { /* MemoryGrant table not yet populated — ignore */ }
30
+ return allowedOwners;
31
+ }
32
+ /**
33
+ * ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
34
+ *
35
+ * NEVER SUPPRESSES A WRITE. This gate only computes a SIGNAL — the caller
36
+ * (Memory.post / Memory.put) always proceeds to write the new record. When a
37
+ * conservative match is found, the signal is attached to the RESPONSE only
38
+ * (deduplicated/matchedId/matchConfidence). There must be no code path where
39
+ * finding a match causes the write to be skipped: that was the #526 bug (two
40
+ * topically-close but DISTINCT findings — one about replication
41
+ * route-directionality, one about DDL/schema replication — and the SECOND was
42
+ * silently dropped because the old client-side gate returned the existing
43
+ * record instead of writing).
44
+ *
45
+ * Conservative match = raw cosine >= cosineThreshold AND Jaccard token-overlap
46
+ * >= lexicalThreshold, checked ONLY against the SINGLE top-cosine candidate
47
+ * (scoped to the same agentId as the write — never cross-agent). If that one
48
+ * candidate fails either gate, there is no match; we do not fall back to the
49
+ * 2nd-most-similar candidate.
50
+ *
51
+ * Previously this lived client-side (packages/flair-client/src/client.ts,
52
+ * pre-fix) and only ran for callers that opted into `dedup:true` over HTTP
53
+ * PUT — the Model-2 /mcp handler (resources/mcp-tools.ts), which calls
54
+ * Memory.post() directly, got ZERO dedup checking. Moving the gate here makes
55
+ * it apply uniformly regardless of transport (HTTP PUT vs in-process post()).
56
+ *
57
+ * NOTE on HTTP verbs: the Memory schema only exposes PUT over HTTP (a raw
58
+ * HTTP POST /Memory returns "Memory does not have a post method implemented"
59
+ * — see src/cli.ts's `flair test` command and commit 2fa6d22 / ops-pj5).
60
+ * `Memory.post()` IS reachable, but only via an in-process resource
61
+ * instantiation (as resources/mcp-tools.ts does) — never via the real HTTP
62
+ * POST route. Because flair-client's write() (used by flair-mcp, the CLI, and
63
+ * every other integration package) issues an HTTP PUT, the actual
64
+ * field-observed bug (#526) flows through Memory.put(), not Memory.post().
65
+ * The gate below is therefore a SHARED helper invoked from both post() and
66
+ * put() — anchored in the same place the design calls out (Memory.post), but
67
+ * wired into put() too so the write path real callers actually use is
68
+ * protected. See memory-integrity-fix report for the full writeup of this
69
+ * deviation from a literal "gate lives only in Memory.post" reading.
70
+ */
71
+ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding, cosineThreshold, lexicalThreshold) {
72
+ if (!agentId || !embedding || embedding.length === 0)
73
+ return null;
74
+ try {
75
+ const query = {
76
+ sort: { attribute: "embedding", target: embedding, distance: "cosine" },
77
+ conditions: [
78
+ { attribute: "agentId", comparator: "equals", value: agentId },
79
+ { attribute: "archived", comparator: "not_equal", value: true },
80
+ ],
81
+ select: ["id", "content", "$distance"],
82
+ limit: 1,
83
+ };
84
+ let top = null;
85
+ // Detach ctx.transaction around this search — same rationale as
86
+ // Memory.search()/SemanticSearch.ts: a drained search generator can leave
87
+ // a CLOSED transaction in ctx's chain that the subsequent WRITE
88
+ // (super.post/super.put, right after this gate runs) would otherwise
89
+ // inherit. Detaching here protects that write, not this read.
90
+ const results = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
91
+ for await (const record of results) {
92
+ top = record;
93
+ break; // single top-cosine candidate only — never fall back further
94
+ }
95
+ if (!top)
96
+ return null;
97
+ const cosine = 1 - (top.$distance ?? 1);
98
+ const confidence = computeMatchConfidence(contentText, top.content, cosine);
99
+ if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
100
+ return null;
101
+ }
102
+ return { matchedId: top.id, cosine: confidence.cosine, lexical: confidence.lexical };
103
+ }
104
+ catch {
105
+ // Dedup-check failures (embedding engine down, search error, etc.) must
106
+ // NEVER block or alter the write — treat as "no match found".
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Run the dedup gate for a create-shaped write. Mutates `content.embedding` /
112
+ * `content.embeddingModel` when it computes a fresh embedding, so the
113
+ * existing "generate embedding if missing" step later in post()/put() reuses
114
+ * it instead of recomputing. Always strips the client-forwarded hint fields
115
+ * (dedup / dedupThreshold / lexicalThreshold) so they never persist onto the
116
+ * stored record — they are passthrough tuning hints, not schema fields.
117
+ *
118
+ * Returns the match signal, or null (no match / gate not applicable).
119
+ */
120
+ async function runDedupGate(ctx, content) {
121
+ const cosineThreshold = typeof content.dedupThreshold === "number" ? content.dedupThreshold : DEDUP_COSINE_THRESHOLD_DEFAULT;
122
+ const lexicalThreshold = typeof content.lexicalThreshold === "number" ? content.lexicalThreshold : DEDUP_LEXICAL_THRESHOLD_DEFAULT;
123
+ delete content.dedup;
124
+ delete content.dedupThreshold;
125
+ delete content.lexicalThreshold;
126
+ if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
127
+ return null;
128
+ }
129
+ let embedding = Array.isArray(content.embedding) ? content.embedding : null;
130
+ if (!embedding) {
131
+ try {
132
+ embedding = await getEmbedding(content.content);
133
+ }
134
+ catch {
135
+ embedding = null;
136
+ }
137
+ if (embedding) {
138
+ content.embedding = embedding;
139
+ content.embeddingModel = getModelId();
140
+ }
141
+ }
142
+ if (!embedding)
143
+ return null;
144
+ return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
145
+ }
146
+ /** Build the final write response: always `written: true`, always includes
147
+ * `id`, and layers the dedup collision signal on top when present. Never a
148
+ * code path where a match suppresses these base fields. */
149
+ function buildWriteResponse(content, result, dedupMatch) {
150
+ const base = result && typeof result === "object" && !Array.isArray(result) ? result : {};
151
+ const response = {
152
+ id: content.id,
153
+ ...base,
154
+ written: true,
155
+ deduplicated: !!dedupMatch,
156
+ };
157
+ if (dedupMatch) {
158
+ response.matchedId = dedupMatch.matchedId;
159
+ response.matchConfidence = { cosine: dedupMatch.cosine, lexical: dedupMatch.lexical };
160
+ }
161
+ return response;
162
+ }
163
+ /**
164
+ * Read-modify-write close of a superseded record, with the SAME transaction
165
+ * detachment discipline as findConservativeDedupMatch (each discrete Harper
166
+ * call individually wrapped — see withDetachedTxn's doc for why a single
167
+ * wrap around a multi-await async function would not protect the later
168
+ * call). Does NOT swallow failures (ops-a4t5 fix) — throws so the caller can
169
+ * log it. Never called before the new record is already written.
170
+ */
171
+ async function closeSupersededRecord(ctx, oldId, patch) {
172
+ const existing = await withDetachedTxn(ctx, () => databases.flair.Memory.get(oldId));
173
+ if (!existing) {
174
+ throw new Error(`supersede-close: record ${oldId} not found`);
175
+ }
176
+ await withDetachedTxn(ctx, () => databases.flair.Memory.put({ ...existing, ...patch }));
177
+ }
178
+ /** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
179
+ * pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
180
+ * here for the "write" scope that gates cross-agent supersede. */
181
+ async function hasWriteGrant(granteeId, ownerId) {
182
+ try {
183
+ for await (const grant of databases.flair.MemoryGrant.search({
184
+ conditions: [
185
+ { attribute: "granteeId", comparator: "equals", value: granteeId },
186
+ { attribute: "ownerId", comparator: "equals", value: ownerId },
187
+ ],
188
+ })) {
189
+ if (grant.scope === "write")
190
+ return true;
191
+ }
192
+ }
193
+ catch {
194
+ /* MemoryGrant table not yet populated — no grant */
195
+ }
196
+ return false;
197
+ }
198
+ /**
199
+ * Shared by post() AND put(): the Memory schema only exposes a working HTTP
200
+ * PUT route (a raw HTTP POST /Memory 404s with "Memory does not have a post
201
+ * method implemented" — see src/cli.ts's `flair test` command / commit
202
+ * 2fa6d22 / ops-pj5). Memory.post() IS reachable, but only via an in-process
203
+ * resource instantiation (resources/mcp-tools.ts does this). Since
204
+ * flair-client's write()/update() — used by flair-mcp, the CLI, and every
205
+ * other integration package — issue HTTP PUT, `supersedes` must be fully
206
+ * handled (validated, authorized, and closed) from BOTH entry points for the
207
+ * real-world write path to actually get the fix, not just the in-process one.
208
+ *
209
+ * Validates the `supersedes` field's shape and, for a cross-agent supersede,
210
+ * requires a "write" MemoryGrant from the target's owner (reuses the existing
211
+ * agent-auth/grant machinery — no parallel auth logic). Returns a Response to
212
+ * short-circuit with (400/403), or null to continue.
213
+ */
214
+ async function validateAndAuthorizeSupersedes(content, auth) {
215
+ if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
216
+ return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
217
+ status: 400, headers: { "Content-Type": "application/json" },
218
+ });
219
+ }
220
+ if (content.supersedes && auth.kind === "agent" && !auth.isAdmin) {
221
+ const target = await databases.flair.Memory.get(content.supersedes).catch(() => null);
222
+ if (target && target.agentId !== auth.agentId) {
223
+ if (!(await hasWriteGrant(auth.agentId, target.agentId))) {
224
+ return FORBIDDEN("forbidden: cannot supersede a memory owned by another agent without a write grant");
225
+ }
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+ /**
231
+ * Close the superseded record — called AFTER the new record has already been
232
+ * written (write-new-BEFORE-close-old, ops-a4t5 fix). Safe failure state is
233
+ * two active records (recoverable), never a tombstoned-old-with-lost-new.
234
+ * Failure is logged (observable), never silently swallowed. No-op if
235
+ * `content.supersedes` is not set.
236
+ */
237
+ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
238
+ if (!content.supersedes)
239
+ return;
240
+ try {
241
+ await closeSupersededRecord(ctx, content.supersedes, {
242
+ validTo: content.validFrom ?? content.createdAt,
243
+ updatedAt: content.createdAt ?? content.updatedAt,
244
+ });
245
+ }
246
+ catch (err) {
247
+ // Constant format string + structured data: memory ids are agent-controlled,
248
+ // so interpolating them into console.error's format position (with a trailing
249
+ // `err` arg) would let an id containing %s/%o consume/hide the real error
250
+ // (semgrep unsafe-formatstring). Keep all dynamic values in the data object.
251
+ console.error("Memory.closeSuperseded: failed to close superseded record after writing new record " +
252
+ "(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 });
253
+ }
254
+ }
7
255
  export class Memory extends databases.flair.Memory {
256
+ /**
257
+ * Self-authorize now that the global gate is non-rejecting. Closes the P0
258
+ * leak: Harper routes `GET /Memory/<id>` to get() and the collection
259
+ * describe (`GET /Memory`) to a path outside search() — neither was gated
260
+ * before this fix, so an anonymous caller got a 200 with full record
261
+ * content / schema even though search() (and the write paths) correctly
262
+ * 401/403'd. Per-record ownership/grant scoping happens in get() below;
263
+ * the collection scope is still in search().
264
+ *
265
+ * allowCreate/allowUpdate/allowDelete are deliberately NOT added here:
266
+ * post()/put()/delete() already self-enforce per-agent ownership inline
267
+ * (resolveAgentAuth + explicit agentId checks in post()/put(), and the
268
+ * isAdmin durability check in delete()). Adding allow* on top of that,
269
+ * unverified, risks regressing owner writes/deletes on a P0 security fix
270
+ * that is scoped to the read leak — left as-is on purpose.
271
+ */
272
+ allowRead() { return allowVerified(this.getContext?.()); }
273
+ /**
274
+ * Override get() to scope by-id reads the same way search() scopes
275
+ * collection reads (memory-soul-read-gate fix). Never distinguishes
276
+ * "doesn't exist" from "exists but not yours" — both return 404, never
277
+ * 403, so a denied caller can't use get() to enumerate other agents'
278
+ * memory ids.
279
+ */
280
+ async get(target) {
281
+ // Collection / query reads — the `GET /Memory/?<query>` form and the bare
282
+ // collection — arrive as a RequestTarget with `isCollection === true`, and
283
+ // are governed by search() (same owner/grant scoping). Only a genuine by-id
284
+ // get is ownership-checked below. Without this guard, get() would receive
285
+ // the query's RequestTarget, super.get() would return the (truthy) result
286
+ // set, the single-record check would find no `.agentId` on it, and a valid
287
+ // authenticated self-query would 404 (regression caught by the auth-
288
+ // middleware e2e "TPS-Ed25519 on GET /Memory/?agentId=X → 200"). A by-id
289
+ // get (RequestTarget with isCollection false, or a bare id) falls through.
290
+ if (!target || (typeof target === "object" && target.isCollection)) {
291
+ return this.search(target);
292
+ }
293
+ const ctx = this.getContext?.();
294
+ const auth = await resolveAgentAuth(ctx);
295
+ // Anonymous by-id read is already blocked at the allowRead() gate (403);
296
+ // this is defense-in-depth if get() is ever reached directly.
297
+ if (auth.kind === "anonymous") {
298
+ return NOT_FOUND();
299
+ }
300
+ // Trusted internal call or admin agent — unfiltered, unchanged behavior.
301
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
302
+ return super.get(target);
303
+ }
304
+ // Non-admin agent: only its own memories, or an owner's memories it holds
305
+ // a read/search MemoryGrant for (same owner-set as search() — shared
306
+ // resolveAllowedOwners helper so the two paths cannot drift).
307
+ const record = await super.get(target);
308
+ if (!record)
309
+ return NOT_FOUND();
310
+ const allowedOwners = await resolveAllowedOwners(auth.agentId);
311
+ if (!allowedOwners.includes(record.agentId))
312
+ return NOT_FOUND();
313
+ return record;
314
+ }
8
315
  /**
9
316
  * Override search() to scope collection GETs by authenticated agent.
10
317
  *
@@ -32,17 +339,7 @@ export class Memory extends databases.flair.Memory {
32
339
  }
33
340
  // Non-admin agent: scope to own + granted owners.
34
341
  const authAgent = auth.agentId;
35
- const allowedOwners = [authAgent];
36
- try {
37
- for await (const grant of databases.flair.MemoryGrant.search({
38
- conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgent }],
39
- })) {
40
- if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
41
- allowedOwners.push(grant.ownerId);
42
- }
43
- }
44
- }
45
- catch { /* MemoryGrant table not yet populated — ignore */ }
342
+ const allowedOwners = await resolveAllowedOwners(authAgent);
46
343
  // Build the agentId scope condition
47
344
  const agentIdCondition = allowedOwners.length === 1
48
345
  ? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
@@ -78,20 +375,17 @@ export class Memory extends databases.flair.Memory {
78
375
  // resolveAgentAuth (reads the gate's tpsAgent annotation) — NOT context.user
79
376
  // .username, which is the fallback "admin" super_user while de-elevation is
80
377
  // dormant and would wrongly 403 every agent's own write. internal/admin → pass.
378
+ let auth;
81
379
  {
82
- const auth = await resolveAgentAuth(ctx);
380
+ auth = await resolveAgentAuth(ctx);
83
381
  // Anonymous HTTP must NOT write. Pre-flip the global gate rejected no-auth
84
382
  // upstream; with the non-rejecting gate, each write path self-enforces (same
85
383
  // rule search() applies to reads).
86
384
  if (auth.kind === "anonymous") {
87
- return new Response(JSON.stringify({ error: "authentication required" }), {
88
- status: 401, headers: { "Content-Type": "application/json" },
89
- });
385
+ return UNAUTH();
90
386
  }
91
387
  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
- });
388
+ return FORBIDDEN("forbidden: cannot write memory owned by another agent");
95
389
  }
96
390
  }
97
391
  content.durability ||= "standard";
@@ -111,23 +405,16 @@ export class Memory extends databases.flair.Memory {
111
405
  catch { }
112
406
  }
113
407
  }
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
- }
408
+ // supersedes: optional reference to the ID of the memory this one
409
+ // replaces. Validates shape + cross-agent-write authorization (shared
410
+ // with put() see validateAndAuthorizeSupersedes doc).
411
+ const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
412
+ if (supersedesError)
413
+ return supersedesError;
120
414
  // 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
415
  if (!content.validFrom) {
123
416
  content.validFrom = content.createdAt;
124
417
  }
125
- if (content.supersedes) {
126
- patchRecord(databases.flair.Memory, content.supersedes, {
127
- validTo: content.validFrom,
128
- updatedAt: content.createdAt,
129
- }).catch(() => { });
130
- }
131
418
  if (content.durability === "ephemeral" && !content.expiresAt) {
132
419
  const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
133
420
  content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
@@ -147,7 +434,22 @@ export class Memory extends databases.flair.Memory {
147
434
  content._safetyFlags = safety.flags;
148
435
  }
149
436
  }
150
- // Generate embedding from content text
437
+ // Server-side conservative-duplicate gate (memory-integrity fix). A
438
+ // supersede write is an intentional version-link, not an ambiguous "is
439
+ // this a duplicate of something else" situation — bypass the gate for it
440
+ // (this also gives memory_update's preserveHistory mode dedup-bypass for
441
+ // free, without a separate flag). NEVER suppresses the write either way.
442
+ let dedupMatch = null;
443
+ if (!content.supersedes) {
444
+ dedupMatch = await runDedupGate(ctx, content);
445
+ }
446
+ else {
447
+ delete content.dedup;
448
+ delete content.dedupThreshold;
449
+ delete content.lexicalThreshold;
450
+ }
451
+ // Generate embedding from content text (no-op if the dedup gate above
452
+ // already computed one for this content).
151
453
  if (content.content && !content.embedding) {
152
454
  const vec = await getEmbedding(content.content);
153
455
  if (vec) {
@@ -155,7 +457,16 @@ export class Memory extends databases.flair.Memory {
155
457
  content.embeddingModel = getModelId();
156
458
  }
157
459
  }
158
- return super.post(content);
460
+ // ── Write the new record FIRST ──────────────────────────────────────────
461
+ const result = await super.post(content);
462
+ // ── THEN close the superseded record (ops-a4t5 fix) ─────────────────────
463
+ // Write-new-BEFORE-close-old: the previous order (close-old via a fire-
464
+ // and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
465
+ // old record and then lose the new one if the write failed afterward.
466
+ // Now the safe failure state is two active records (recoverable), never
467
+ // a lost write — and the failure is logged, never silently swallowed.
468
+ await closeSupersededIfNeeded(ctx, content, "post");
469
+ return buildWriteResponse(content, result, dedupMatch);
159
470
  }
160
471
  async put(content) {
161
472
  // Reindex migration bypass: admin-only escape hatch used by the
@@ -181,19 +492,16 @@ export class Memory extends databases.flair.Memory {
181
492
  // write memories it owns, via resolveAgentAuth (gate annotation), not
182
493
  // context.user.username (the dormant-de-elevation fallback is "admin").
183
494
  // The _reindex admin path above bypasses this.
495
+ const ctx = this.getContext?.();
496
+ let auth;
184
497
  {
185
- const octx = this.getContext?.();
186
- const auth = await resolveAgentAuth(octx);
498
+ auth = await resolveAgentAuth(ctx);
187
499
  // Anonymous HTTP must NOT write (non-rejecting gate → self-enforce here).
188
500
  if (auth.kind === "anonymous") {
189
- return new Response(JSON.stringify({ error: "authentication required" }), {
190
- status: 401, headers: { "Content-Type": "application/json" },
191
- });
501
+ return UNAUTH();
192
502
  }
193
503
  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
- });
504
+ return FORBIDDEN("forbidden: cannot write memory owned by another agent");
197
505
  }
198
506
  }
199
507
  const now = new Date().toISOString();
@@ -201,6 +509,16 @@ export class Memory extends databases.flair.Memory {
201
509
  // Set defaults that post() sets — put() is also used for new records via CLI
202
510
  content.archived = content.archived ?? false;
203
511
  content.createdAt = content.createdAt ?? now;
512
+ // supersedes: optional reference to the ID of the memory this one
513
+ // replaces. Validates shape + cross-agent-write authorization (shared
514
+ // with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
515
+ // this too: it's the only HTTP-reachable create path).
516
+ const supersedesError = await validateAndAuthorizeSupersedes(content, auth);
517
+ if (supersedesError)
518
+ return supersedesError;
519
+ if (content.supersedes && !content.validFrom) {
520
+ content.validFrom = content.createdAt;
521
+ }
204
522
  // Content safety scan on updated content + summary (ops-i2jb).
205
523
  if (content.content || content.summary) {
206
524
  const safety = scanFields(content, ["content", "summary"]);
@@ -219,7 +537,36 @@ export class Memory extends databases.flair.Memory {
219
537
  content._safetyFlags = null;
220
538
  }
221
539
  }
222
- // Re-generate embedding if content changed
540
+ // Server-side conservative-duplicate gate (memory-integrity fix). PUT is
541
+ // an upsert: only run the gate for a FRESH create (target id does not yet
542
+ // exist) that is NOT a supersede-link write. An update of an EXISTING id
543
+ // (memory_update's default same-id overwrite path) is an intentional,
544
+ // explicit overwrite, and a supersede-link write is an intentional
545
+ // version-link — neither is an ambiguous "is this a duplicate of
546
+ // something else" write, so both are dedup-bypassed automatically, no
547
+ // separate flag needed. NEVER suppresses the write either way.
548
+ let dedupMatch = null;
549
+ if (content.supersedes) {
550
+ delete content.dedup;
551
+ delete content.dedupThreshold;
552
+ delete content.lexicalThreshold;
553
+ }
554
+ else if (content.id) {
555
+ const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
556
+ if (!preExisting) {
557
+ dedupMatch = await runDedupGate(ctx, content);
558
+ }
559
+ else {
560
+ delete content.dedup;
561
+ delete content.dedupThreshold;
562
+ delete content.lexicalThreshold;
563
+ }
564
+ }
565
+ else {
566
+ dedupMatch = await runDedupGate(ctx, content);
567
+ }
568
+ // Re-generate embedding if content changed (no-op if the dedup gate above
569
+ // already computed one for this content).
223
570
  if (content.content && !content.embedding) {
224
571
  const vec = await getEmbedding(content.content);
225
572
  if (vec) {
@@ -240,10 +587,22 @@ export class Memory extends databases.flair.Memory {
240
587
  if (content.promotionStatus === "approved") {
241
588
  content.durability = "permanent";
242
589
  }
243
- return super.put(content);
590
+ // ── Write the new/updated record FIRST ──────────────────────────────────
591
+ const result = await super.put(content);
592
+ // ── THEN close the superseded record (ops-a4t5 fix; see post()) ────────
593
+ await closeSupersededIfNeeded(ctx, content, "put");
594
+ return buildWriteResponse(content, result, dedupMatch);
244
595
  }
245
596
  async delete(id) {
246
- const record = await this.get(id);
597
+ // Use super.get(id), NOT this.get(id): the new get() override above 404s
598
+ // (a truthy Response) for a non-owner/non-granted id, which would
599
+ // otherwise short-circuit the `record.durability === "permanent"` check
600
+ // below (a Response has no .durability) and silently bypass the
601
+ // admin-only permanent-delete guard for cross-agent deletes. This keeps
602
+ // delete()'s own pre-existing ownership/admin logic exactly as it was
603
+ // before the read-gate fix — the read-scoping override must not leak
604
+ // into delete()'s internal record lookup.
605
+ const record = await super.get(id);
247
606
  if (!record)
248
607
  return super.delete(id);
249
608
  if (record.durability === "permanent") {
@@ -1,7 +1,8 @@
1
1
  import { databases } from "@harperfast/harper";
2
- import { resolveAgentAuth } from "./agent-auth.js";
2
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
3
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
4
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
5
+ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
5
6
  /**
6
7
  * MemoryGrant — an agent (ownerId) grants another (granteeId) scoped access to its
7
8
  * memories. Self-authorizes now that the global gate is non-rejecting (previously a
@@ -16,6 +17,52 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
16
17
  _auth() {
17
18
  return resolveAgentAuth(this.getContext?.());
18
19
  }
20
+ /**
21
+ * Self-authorize now that the global gate is non-rejecting (memory-soul-
22
+ * read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
23
+ * WorkspaceState.ts/Relationship.ts/Integration.ts). Closes the same P0
24
+ * leak: Harper routes `GET /MemoryGrant/<id>` to get() and the collection
25
+ * describe (`GET /MemoryGrant`) outside search(), so neither was gated
26
+ * before this fix — an anonymous caller got a 200 with full grant content.
27
+ * Per-record owner/grantee scoping happens in get() below; the collection
28
+ * scope is still in search().
29
+ */
30
+ allowRead() { return allowVerified(this.getContext?.()); }
31
+ /**
32
+ * Override get() to scope by-id reads the same way search() scopes
33
+ * collection reads (memory-soul-read-gate family fix). A grant is visible
34
+ * to either party (ownerId OR granteeId), mirroring search()'s owner-OR-
35
+ * grantee scope. Never distinguishes "doesn't exist" from "exists but not
36
+ * yours" — both return 404, never 403, so a denied caller can't use get()
37
+ * to enumerate other agents' grant ids.
38
+ */
39
+ async get(target) {
40
+ // Collection / query reads arrive as a RequestTarget with
41
+ // `isCollection === true`, and are governed by search() (same owner/
42
+ // grantee scoping). Only a genuine by-id get is ownership-checked below
43
+ // — see Memory.ts's get() for the full rationale (same bug class).
44
+ if (!target || (typeof target === "object" && target.isCollection)) {
45
+ return this.search(target);
46
+ }
47
+ const auth = await this._auth();
48
+ // Anonymous by-id read is already blocked at the allowRead() gate (403);
49
+ // this is defense-in-depth if get() is ever reached directly.
50
+ if (auth.kind === "anonymous") {
51
+ return NOT_FOUND();
52
+ }
53
+ // Trusted internal call or admin agent — unfiltered, unchanged behavior.
54
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
55
+ return super.get(target);
56
+ }
57
+ // Non-admin agent: visible if it's the owner OR the grantee (parity with
58
+ // search()'s owner-OR-grantee scope).
59
+ const record = await super.get(target);
60
+ if (!record)
61
+ return NOT_FOUND();
62
+ if (record.ownerId !== auth.agentId && record.granteeId !== auth.agentId)
63
+ return NOT_FOUND();
64
+ return record;
65
+ }
19
66
  async search(query) {
20
67
  const auth = await this._auth();
21
68
  if (auth.kind === "anonymous")
@@ -59,7 +106,13 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
59
106
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
60
107
  return super.delete(id, context);
61
108
  }
62
- const record = await this.get(id);
109
+ // Use super.get(id), NOT this.get(id): the new get() override above 404s
110
+ // (a truthy Response) for a non-owner/non-grantee id, which would
111
+ // otherwise defeat the `if (!record)` check below and mis-route a
112
+ // genuinely-missing record into the FORBIDDEN branch instead of a clean
113
+ // super.delete(id, context) no-op. Mirrors Memory.ts's delete() — same
114
+ // rationale, same fix.
115
+ const record = await super.get(id);
63
116
  if (!record)
64
117
  return super.delete(id, context);
65
118
  if (record.ownerId !== auth.agentId) {