@tpsdev-ai/flair 0.18.0 → 0.20.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.
package/dist/cli.js CHANGED
@@ -7123,7 +7123,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
7123
7123
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
7124
7124
  .option("--subject <text>", "one-line title / entity this memory is about")
7125
7125
  .option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
7126
- .option("--visibility <value>", "Memory visibility (sets Memory.visibility). Use 'office' to share office-wide with every team agent; omit (default) to keep it private to this agent (flair#509)")
7126
+ .option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to a grant-holder) or 'shared' (visible to owner + any agent holding a read/search MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509, ops-2dm3)")
7127
7127
  .action(async (contentArg, opts) => {
7128
7128
  const content = contentArg ?? opts.content;
7129
7129
  if (!content) {
@@ -1,12 +1,24 @@
1
1
  import { Resource } from "@harperfast/harper";
2
+ import { allowAdmin } from "./agent-auth.js";
2
3
  /**
3
4
  * GET /Admin — friendly redirect to /AdminDashboard.
4
5
  *
5
6
  * Operators bookmark or type the bare /Admin path. Without this resource
6
7
  * they hit a 404 and assume the admin UI is broken. The dashboard is the
7
8
  * canonical landing surface; redirect there.
9
+ *
10
+ * allowRead()=allowAdmin (ops-oox7 defense-in-depth): the /Admin* pathname
11
+ * gate in auth-middleware.ts only 401s when there's NO Authorization header
12
+ * at all (or Basic creds don't resolve to a real user) — a validly-verified
13
+ * TPS-Ed25519 agent that is NOT an admin, or a valid-but-non-super_user Basic
14
+ * user, currently sails through the middleware with no admin resource-level
15
+ * check at all. allowRead closes that gap the same way WorkspaceLatest.ts /
16
+ * MemoryReindex.ts already gate their own custom (non-@table) Resources.
8
17
  */
9
18
  export class Admin extends Resource {
19
+ async allowRead() {
20
+ return allowAdmin(this.getContext?.());
21
+ }
10
22
  async get() {
11
23
  return new Response("", {
12
24
  status: 302,
@@ -1,9 +1,15 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
+ import { allowAdmin } from "./agent-auth.js";
3
4
  /**
4
5
  * GET /AdminConnectors — OAuth clients and active sessions.
6
+ *
7
+ * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
5
8
  */
6
9
  export class AdminConnectors extends Resource {
10
+ async allowRead() {
11
+ return allowAdmin(this.getContext?.());
12
+ }
7
13
  async get() {
8
14
  const clients = [];
9
15
  try {
@@ -1,9 +1,18 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { layout, htmlResponse } from "./admin-layout.js";
3
+ import { allowAdmin } from "./agent-auth.js";
3
4
  /**
4
5
  * GET /AdminDashboard — admin home page with system overview.
6
+ *
7
+ * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts for why
8
+ * this closes a real gap, not just belt-and-suspenders — a verified
9
+ * non-admin agent could otherwise read full instance stats (principal/agent/
10
+ * memory/relationship counts) with no admin check at all.
5
11
  */
6
12
  export class AdminDashboard extends Resource {
13
+ async allowRead() {
14
+ return allowAdmin(this.getContext?.());
15
+ }
7
16
  async get() {
8
17
  let principalCount = 0;
9
18
  let memoryCount = 0;
@@ -1,9 +1,15 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
+ import { allowAdmin } from "./agent-auth.js";
3
4
  /**
4
5
  * GET /AdminIdp — enterprise IdP configuration management.
6
+ *
7
+ * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
5
8
  */
6
9
  export class AdminIdp extends Resource {
10
+ async allowRead() {
11
+ return allowAdmin(this.getContext?.());
12
+ }
7
13
  async get() {
8
14
  const idps = [];
9
15
  try {
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { allowAdmin } from "./agent-auth.js";
7
8
  /**
8
9
  * Resolve the public URL operators reach this Flair on.
9
10
  *
@@ -83,8 +84,13 @@ function resolveVersion() {
83
84
  }
84
85
  /**
85
86
  * GET /AdminInstance — instance info, public key, version.
87
+ *
88
+ * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
86
89
  */
87
90
  export class AdminInstance extends Resource {
91
+ async allowRead() {
92
+ return allowAdmin(this.getContext?.());
93
+ }
88
94
  async get() {
89
95
  const version = resolveVersion();
90
96
  // Pass the request through so URL resolution can derive from
@@ -1,5 +1,6 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
+ import { allowAdmin } from "./agent-auth.js";
3
4
  /**
4
5
  * GET /AdminMemory browse + search memories (list view)
5
6
  * GET /AdminMemory?id=<id> per-memory detail view with full provenance pane
@@ -19,8 +20,15 @@ import { layout, htmlResponse, esc } from "./admin-layout.js";
19
20
  * "memory that follows the agent across orchestrators" — the provenance pane
20
21
  * makes that legible: every memory shows where it came from, what it derived
21
22
  * from, what it superseded, and which peer it synced from.
23
+ *
24
+ * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts — this
25
+ * page surfaces full memory content across ALL agents, so the gap it closes
26
+ * matters more here than on the other Admin* pages.
22
27
  */
23
28
  export class AdminMemory extends Resource {
29
+ async allowRead() {
30
+ return allowAdmin(this.getContext?.());
31
+ }
24
32
  async get() {
25
33
  // Harper v5 does not populate this.request on Resource subclasses —
26
34
  // getContext() is the only reliable path (ops-sal4: the previous
@@ -334,7 +342,7 @@ export class AdminMemory extends Resource {
334
342
  <dl style="display:grid;grid-template-columns:200px 1fr;gap:6px;margin-top:8px;font-family:ui-monospace,SF Mono,monospace;font-size:0.85em">
335
343
  <dt style="color:#666">id</dt><dd>${esc(m.id)}</dd>
336
344
  <dt style="color:#666">contentHash</dt><dd>${esc(m.contentHash ?? "—")}</dd>
337
- <dt style="color:#666">visibility</dt><dd>${esc(m.visibility ?? "private")}</dd>
345
+ <dt style="color:#666">visibility</dt><dd>${esc(m.visibility ?? "shared (no field — pre-migration default)")}</dd>
338
346
  </dl>
339
347
  </div>
340
348
  `;
@@ -1,9 +1,15 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
+ import { allowAdmin } from "./agent-auth.js";
3
4
  /**
4
5
  * GET /AdminPrincipals — list all principals with kind, trust, status.
6
+ *
7
+ * allowRead()=allowAdmin (ops-oox7 defense-in-depth): see Admin.ts.
5
8
  */
6
9
  export class AdminPrincipals extends Resource {
10
+ async allowRead() {
11
+ return allowAdmin(this.getContext?.());
12
+ }
7
13
  async get() {
8
14
  const principals = [];
9
15
  try {
@@ -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
  * Integration records are agent-owned. Auth: the non-rejecting gate annotates the
7
8
  * request; this resource self-enforces (resolveAgentAuth → internal/agent/anonymous).
@@ -12,6 +13,50 @@ export class Integration extends databases.flair.Integration {
12
13
  _auth() {
13
14
  return resolveAgentAuth(this.getContext?.());
14
15
  }
16
+ /**
17
+ * Self-authorize now that the global gate is non-rejecting (memory-soul-
18
+ * read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
19
+ * WorkspaceState.ts/Relationship.ts). Closes the same P0 leak: Harper
20
+ * routes `GET /Integration/<id>` to get() and the collection describe
21
+ * (`GET /Integration`) outside search(), so neither was gated before this
22
+ * fix — an anonymous caller got a 200 with full record content. Per-record
23
+ * ownership scoping happens in get() below; the collection scope is still
24
+ * in search().
25
+ */
26
+ allowRead() { return allowVerified(this.getContext?.()); }
27
+ /**
28
+ * Override get() to scope by-id reads the same way search() scopes
29
+ * collection reads (memory-soul-read-gate family fix). Never distinguishes
30
+ * "doesn't exist" from "exists but not yours" — both return 404, never
31
+ * 403, so a denied caller can't use get() to enumerate other agents'
32
+ * integration ids.
33
+ */
34
+ async get(target) {
35
+ // Collection / query reads arrive as a RequestTarget with
36
+ // `isCollection === true`, and are governed by search() (same owner
37
+ // scoping). Only a genuine by-id get is ownership-checked below — see
38
+ // Memory.ts's get() for the full rationale (same bug class).
39
+ if (!target || (typeof target === "object" && target.isCollection)) {
40
+ return this.search(target);
41
+ }
42
+ const auth = await this._auth();
43
+ // Anonymous by-id read is already blocked at the allowRead() gate (403);
44
+ // this is defense-in-depth if get() is ever reached directly.
45
+ if (auth.kind === "anonymous") {
46
+ return NOT_FOUND();
47
+ }
48
+ // Trusted internal call or admin agent — unfiltered, unchanged behavior.
49
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
50
+ return super.get(target);
51
+ }
52
+ // Non-admin agent: only its own integrations.
53
+ const record = await super.get(target);
54
+ if (!record)
55
+ return NOT_FOUND();
56
+ if (record.agentId !== auth.agentId)
57
+ return NOT_FOUND();
58
+ return record;
59
+ }
15
60
  async search(query) {
16
61
  const auth = await this._auth();
17
62
  if (auth.kind === "anonymous")
@@ -66,7 +111,12 @@ export class Integration extends databases.flair.Integration {
66
111
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
67
112
  return super.delete(id);
68
113
  }
69
- const record = await this.get(id);
114
+ // Use super.get(id), NOT this.get(id): the new get() override above 404s
115
+ // (a truthy Response) for a non-owner id, which would otherwise defeat
116
+ // the `if (!record)` check below and mis-route a genuinely-missing
117
+ // record into the FORBIDDEN branch instead of a clean super.delete(id)
118
+ // no-op. Mirrors Memory.ts's delete() — same rationale, same fix.
119
+ const record = await super.get(id);
70
120
  if (!record)
71
121
  return super.delete(id);
72
122
  if (record.agentId !== auth.agentId) {
@@ -1,12 +1,26 @@
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";
7
+ import { resolveReadScope } from "./memory-read-scope.js";
8
+ import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
8
9
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
9
10
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
11
+ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
12
+ /**
13
+ * Owner ids a non-admin agent may READ (resolveAllowedOwners) and the full
14
+ * read-scope condition + private-exclusion predicate (resolveReadScope) now
15
+ * live in ./memory-read-scope.ts — the ONE centralized helper every
16
+ * cross-agent Memory read path (search()/get() here, SemanticSearch.ts,
17
+ * MemoryBootstrap.ts, auth-middleware.ts's by-id guard) resolves its scope
18
+ * through, so the scoping rule cannot drift per-path again (ops-nzxa: a
19
+ * SemanticSearch inline `visibility === "office"` OR-clause leaked office
20
+ * memories to any authenticated agent because the rule had scattered). See
21
+ * that module's doc for the migration invariant (no-visibility-field reads
22
+ * as "shared", never "private").
23
+ */
10
24
  /**
11
25
  * ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
12
26
  *
@@ -72,7 +86,59 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
72
86
  }
73
87
  if (!top)
74
88
  return null;
75
- const cosine = 1 - (top.$distance ?? 1);
89
+ // ─── ops-ume4: Harper's cosine-sort query omits $distance for a SINGLETON
90
+ // result set ─────────────────────────────────────────────────────────────
91
+ // Initial working theory was a per-agentId HNSW "cold-start" (first-ever
92
+ // query cold, second query warm) and the initially-recommended fix was a
93
+ // same-query retry. Empirically FALSIFIED: a plain retry of the identical
94
+ // query, 8x with 300ms delays (2.4s total), never recovered a `$distance`
95
+ // for a genuinely singleton candidate set (exactly one record matching
96
+ // `agentId equals X AND archived not_equal true`). The actual trigger,
97
+ // confirmed by direct probing: when this query's post-filter result set
98
+ // has exactly ONE matching record, `$distance` comes back `undefined` for
99
+ // it — regardless of how many prior queries have run for that agentId,
100
+ // how long you wait, or how many other agentIds/records already exist in
101
+ // the table. The moment a SECOND matching record exists, `$distance` is
102
+ // populated correctly on the very first query ever issued for that
103
+ // agentId — no warm-up needed. In practice the singleton case is exactly
104
+ // an agent's SECOND-ever memory (compared against their first) — the most
105
+ // common real-world trigger for this bug, and why it looked "permanent
106
+ // per-agent for the first near-dup query."
107
+ //
108
+ // Also NOT a query-shape/conditions issue: the `{operator:"or"}` wrap
109
+ // SemanticSearch.ts uses elsewhere is unrelated, and neither raising
110
+ // `limit` past 1 nor changing the conditions shape changes the result —
111
+ // confirmed empirically. Harper's SORT ordering is correct even in the
112
+ // singleton case (the right record comes back as `top`); only the
113
+ // numeric `$distance` annotation is missing. Also confirmed: selecting
114
+ // "embedding" directly on THIS sort-by-embedding query does not help
115
+ // either — it comes back as a bare scalar (Harper appears to special-case
116
+ // the sort attribute in `select`), not the stored vector.
117
+ //
118
+ // Fix: when `$distance` is undefined, fetch the ONE candidate's full
119
+ // record by id (a plain point lookup — not a vector-sort query, so
120
+ // unaffected by the quirk above) and compute cosine similarity ourselves
121
+ // in JS from its real stored `embedding` vector against this write's own
122
+ // `embedding` (this function's parameter), via the same math Harper would
123
+ // have used (dedup.ts's cosineSimilarity, Harper-free and unit-tested).
124
+ // This sidesteps the underlying engine quirk entirely rather than
125
+ // depending on its timing, and works identically whether this is the
126
+ // agent's first query ever or its thousandth. Never suppresses the write
127
+ // either way: if the candidate's embedding is somehow also missing (e.g.
128
+ // a legacy record written before embeddings existed), `cosineSimilarity`
129
+ // returns 0 — the same safe "no match" signal the pre-fix `?? 1`
130
+ // fallback produced.
131
+ let cosine;
132
+ if (top.$distance !== undefined) {
133
+ cosine = 1 - top.$distance;
134
+ }
135
+ else {
136
+ console.error("Memory.findConservativeDedupMatch: $distance undefined on a singleton cosine result (ops-ume4) — " +
137
+ "falling back to a manual cosine computation from the candidate's stored embedding", { agentId, candidateId: top.id });
138
+ const fullCandidate = await withDetachedTxn(ctx, () => databases.flair.Memory.get(top.id));
139
+ const candidateEmbedding = Array.isArray(fullCandidate?.embedding) ? fullCandidate.embedding : [];
140
+ cosine = cosineSimilarity(embedding, candidateEmbedding);
141
+ }
76
142
  const confidence = computeMatchConfidence(contentText, top.content, cosine);
77
143
  if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
78
144
  return null;
@@ -230,7 +296,81 @@ async function closeSupersededIfNeeded(ctx, content, methodLabel) {
230
296
  "(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
297
  }
232
298
  }
299
+ /**
300
+ * ─── Durability-keyed default visibility (ops-2dm3 Layer 1, part A) ─────────
301
+ *
302
+ * Writer intent: an explicit `visibility` on the write ALWAYS overrides this
303
+ * (callers check `content.visibility == null` before calling this). When
304
+ * unset, the default is keyed off durability — a durable write (the agent
305
+ * chose to make this stick around) defaults to shared; anything else
306
+ * defaults to private. "absent" durability (not yet defaulted by the caller)
307
+ * falls into the private branch, matching the spec's
308
+ * "standard|ephemeral|absent → private".
309
+ */
310
+ function defaultVisibilityForDurability(durability) {
311
+ return durability === "permanent" || durability === "persistent" ? "shared" : "private";
312
+ }
233
313
  export class Memory extends databases.flair.Memory {
314
+ /**
315
+ * Self-authorize now that the global gate is non-rejecting. Closes the P0
316
+ * leak: Harper routes `GET /Memory/<id>` to get() and the collection
317
+ * describe (`GET /Memory`) to a path outside search() — neither was gated
318
+ * before this fix, so an anonymous caller got a 200 with full record
319
+ * content / schema even though search() (and the write paths) correctly
320
+ * 401/403'd. Per-record ownership/grant scoping happens in get() below;
321
+ * the collection scope is still in search().
322
+ *
323
+ * allowCreate/allowUpdate/allowDelete are deliberately NOT added here:
324
+ * post()/put()/delete() already self-enforce per-agent ownership inline
325
+ * (resolveAgentAuth + explicit agentId checks in post()/put(), and the
326
+ * isAdmin durability check in delete()). Adding allow* on top of that,
327
+ * unverified, risks regressing owner writes/deletes on a P0 security fix
328
+ * that is scoped to the read leak — left as-is on purpose.
329
+ */
330
+ allowRead() { return allowVerified(this.getContext?.()); }
331
+ /**
332
+ * Override get() to scope by-id reads the same way search() scopes
333
+ * collection reads (memory-soul-read-gate fix). Never distinguishes
334
+ * "doesn't exist" from "exists but not yours" — both return 404, never
335
+ * 403, so a denied caller can't use get() to enumerate other agents'
336
+ * memory ids.
337
+ */
338
+ async get(target) {
339
+ // Collection / query reads — the `GET /Memory/?<query>` form and the bare
340
+ // collection — arrive as a RequestTarget with `isCollection === true`, and
341
+ // are governed by search() (same owner/grant scoping). Only a genuine by-id
342
+ // get is ownership-checked below. Without this guard, get() would receive
343
+ // the query's RequestTarget, super.get() would return the (truthy) result
344
+ // set, the single-record check would find no `.agentId` on it, and a valid
345
+ // authenticated self-query would 404 (regression caught by the auth-
346
+ // middleware e2e "TPS-Ed25519 on GET /Memory/?agentId=X → 200"). A by-id
347
+ // get (RequestTarget with isCollection false, or a bare id) falls through.
348
+ if (!target || (typeof target === "object" && target.isCollection)) {
349
+ return this.search(target);
350
+ }
351
+ const ctx = this.getContext?.();
352
+ const auth = await resolveAgentAuth(ctx);
353
+ // Anonymous by-id read is already blocked at the allowRead() gate (403);
354
+ // this is defense-in-depth if get() is ever reached directly.
355
+ if (auth.kind === "anonymous") {
356
+ return NOT_FOUND();
357
+ }
358
+ // Trusted internal call or admin agent — unfiltered, unchanged behavior.
359
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
360
+ return super.get(target);
361
+ }
362
+ // Non-admin agent: only its own memories (any visibility), or a granted
363
+ // owner's SHARED memories — never that owner's private ones (ops-2dm3
364
+ // Layer 1 private-exclusion). Centralized in resolveReadScope() so this
365
+ // and search() below cannot drift.
366
+ const record = await super.get(target);
367
+ if (!record)
368
+ return NOT_FOUND();
369
+ const scope = await resolveReadScope(auth.agentId);
370
+ if (!scope.isAllowed(record))
371
+ return NOT_FOUND();
372
+ return record;
373
+ }
234
374
  /**
235
375
  * Override search() to scope collection GETs by authenticated agent.
236
376
  *
@@ -256,23 +396,12 @@ export class Memory extends databases.flair.Memory {
256
396
  if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
257
397
  return super.search(query);
258
398
  }
259
- // Non-admin agent: scope to own + granted owners.
399
+ // Non-admin agent: scope to own (any visibility) + granted owners' SHARED
400
+ // memories only (ops-2dm3 Layer 1 private-exclusion). Centralized in
401
+ // resolveReadScope() so get() above and search() here cannot drift.
260
402
  const authAgent = auth.agentId;
261
- const allowedOwners = [authAgent];
262
- try {
263
- for await (const grant of databases.flair.MemoryGrant.search({
264
- conditions: [{ attribute: "granteeId", comparator: "equals", value: authAgent }],
265
- })) {
266
- if (grant.ownerId && (grant.scope === "read" || grant.scope === "search")) {
267
- allowedOwners.push(grant.ownerId);
268
- }
269
- }
270
- }
271
- catch { /* MemoryGrant table not yet populated — ignore */ }
272
- // Build the agentId scope condition
273
- const agentIdCondition = allowedOwners.length === 1
274
- ? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
275
- : { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
403
+ const scope = await resolveReadScope(authAgent);
404
+ const agentIdCondition = scope.condition;
276
405
  // Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
277
406
  // conditions array. For URL-based GET /Memory?... calls, URL params are no
278
407
  // longer translated to conditions here — callers should use
@@ -321,6 +450,16 @@ export class Memory extends databases.flair.Memory {
321
450
  content.createdAt = new Date().toISOString();
322
451
  content.updatedAt = content.createdAt;
323
452
  content.archived = content.archived ?? false;
453
+ // ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
454
+ // post() only ever creates a NEW record — patchRecord/supersede-close/
455
+ // retrievalCount bumps all route through put() instead (see put()'s
456
+ // pre-existing-record guard below), so there is no "don't overwrite an
457
+ // existing record's visibility" concern here. Explicit visibility on the
458
+ // write ALWAYS overrides; only stamp the default when the caller left it
459
+ // unset. permanent|persistent → shared; standard|ephemeral|absent → private.
460
+ if (content.visibility === undefined || content.visibility === null) {
461
+ content.visibility = defaultVisibilityForDurability(content.durability);
462
+ }
324
463
  // Validate derivedFrom source IDs exist (best-effort, non-blocking)
325
464
  if (Array.isArray(content.derivedFrom) && content.derivedFrom.length > 0) {
326
465
  const now = content.createdAt;
@@ -438,6 +577,26 @@ export class Memory extends databases.flair.Memory {
438
577
  // Set defaults that post() sets — put() is also used for new records via CLI
439
578
  content.archived = content.archived ?? false;
440
579
  content.createdAt = content.createdAt ?? now;
580
+ // Fetch the pre-existing record (if any) ONCE — reused below both to
581
+ // decide whether this PUT is a fresh create (dedup gate applies, default
582
+ // visibility stamped) or an update/patch (dedup-bypassed, visibility left
583
+ // untouched). See the dedup-gate block further down for why an existing
584
+ // id skips the gate; the SAME "does a record already exist" check gates
585
+ // the visibility default (ops-2dm3 Layer 1 part A): patchRecord/supersede-
586
+ // close/retrievalCount bumps all route through put() with a MERGED
587
+ // `{...existing, ...patch}` payload, and must never have their stored
588
+ // visibility overwritten by a default recomputed from that merged content
589
+ // — only a genuinely NEW id gets the default stamped.
590
+ const preExisting = content.id
591
+ ? await databases.flair.Memory.get(content.id).catch(() => null)
592
+ : null;
593
+ // ─── Default visibility (durability-keyed) — ops-2dm3 Layer 1, part A ────
594
+ // Explicit visibility on the write ALWAYS overrides; only stamp the
595
+ // default when the caller left it unset AND this is a fresh record.
596
+ // permanent|persistent → shared; standard|ephemeral|absent → private.
597
+ if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
598
+ content.visibility = defaultVisibilityForDurability(content.durability);
599
+ }
441
600
  // supersedes: optional reference to the ID of the memory this one
442
601
  // replaces. Validates shape + cross-agent-write authorization (shared
443
602
  // with post() — see validateAndAuthorizeSupersedes doc for why PUT needs
@@ -481,7 +640,6 @@ export class Memory extends databases.flair.Memory {
481
640
  delete content.lexicalThreshold;
482
641
  }
483
642
  else if (content.id) {
484
- const preExisting = await databases.flair.Memory.get(content.id).catch(() => null);
485
643
  if (!preExisting) {
486
644
  dedupMatch = await runDedupGate(ctx, content);
487
645
  }
@@ -523,7 +681,15 @@ export class Memory extends databases.flair.Memory {
523
681
  return buildWriteResponse(content, result, dedupMatch);
524
682
  }
525
683
  async delete(id) {
526
- const record = await this.get(id);
684
+ // Use super.get(id), NOT this.get(id): the new get() override above 404s
685
+ // (a truthy Response) for a non-owner/non-granted id, which would
686
+ // otherwise short-circuit the `record.durability === "permanent"` check
687
+ // below (a Response has no .durability) and silently bypass the
688
+ // admin-only permanent-delete guard for cross-agent deletes. This keeps
689
+ // delete()'s own pre-existing ownership/admin logic exactly as it was
690
+ // before the read-gate fix — the read-scoping override must not leak
691
+ // into delete()'s internal record lookup.
692
+ const record = await super.get(id);
527
693
  if (!record)
528
694
  return super.delete(id);
529
695
  if (record.durability === "permanent") {