@pentatonic-ai/ai-agent-sdk 0.7.10 → 0.7.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -4,6 +4,8 @@ import {
4
4
  engineStore,
5
5
  engineSearch,
6
6
  engineForget,
7
+ composeArena,
8
+ composeArenas,
7
9
  DEFAULT_ENGINE_URL,
8
10
  } from "../engine.js";
9
11
 
@@ -82,7 +84,7 @@ describe("engine HTTP client", () => {
82
84
  });
83
85
 
84
86
  describe("engineStore", () => {
85
- it("builds canonical /store body with arena=clientId", async () => {
87
+ it("tenant-wide by default when no userId", async () => {
86
88
  mockOk({ id: "abc", content: "hello", layerId: "ml_acme_episodic" });
87
89
  await engineStore("https://e", {
88
90
  clientId: "acme",
@@ -104,6 +106,39 @@ describe("engine HTTP client", () => {
104
106
  });
105
107
  });
106
108
 
109
+ it("user-scoped by default when userId provided", async () => {
110
+ mockOk({ id: "x", content: "x", layerId: "ml_acme_episodic" });
111
+ await engineStore("https://e", {
112
+ clientId: "acme",
113
+ userId: "user-42",
114
+ content: "x",
115
+ });
116
+ const body = JSON.parse(calls[0].init.body);
117
+ expect(body.metadata.arena).toBe("acme:user-42");
118
+ });
119
+
120
+ it("scope=tenant overrides user-scoped default", async () => {
121
+ mockOk({ id: "x", content: "x", layerId: "ml_acme_episodic" });
122
+ await engineStore("https://e", {
123
+ clientId: "acme",
124
+ userId: "user-42",
125
+ scope: "tenant",
126
+ content: "x",
127
+ });
128
+ const body = JSON.parse(calls[0].init.body);
129
+ expect(body.metadata.arena).toBe("acme");
130
+ });
131
+
132
+ it("scope=user without userId throws", async () => {
133
+ await expect(
134
+ engineStore("https://e", {
135
+ clientId: "acme",
136
+ scope: "user",
137
+ content: "x",
138
+ })
139
+ ).rejects.toThrow(/scope=user requires userId/);
140
+ });
141
+
107
142
  it("omits layer_type and actor_user_id when not provided", async () => {
108
143
  mockOk({ id: "x", content: "x", layerId: "ml_acme_episodic" });
109
144
  await engineStore("https://e", { clientId: "acme", content: "x" });
@@ -116,7 +151,6 @@ describe("engine HTTP client", () => {
116
151
  await engineStore("https://e", {
117
152
  clientId: "acme",
118
153
  content: "x",
119
- // attempted hostile arena spoof:
120
154
  metadata: { arena: "tenant-b" },
121
155
  });
122
156
  const body = JSON.parse(calls[0].init.body);
@@ -135,7 +169,7 @@ describe("engine HTTP client", () => {
135
169
  });
136
170
 
137
171
  describe("engineSearch", () => {
138
- it("builds canonical /search body and forwards arena/limit/min_score", async () => {
172
+ it("tenant-only arenas list when no userId", async () => {
139
173
  mockOk({ results: [] });
140
174
  await engineSearch("https://e", {
141
175
  clientId: "acme",
@@ -147,12 +181,26 @@ describe("engine HTTP client", () => {
147
181
  expect(calls[0].url).toBe("https://e/search");
148
182
  expect(body).toEqual({
149
183
  arena: "acme",
184
+ arenas: ["acme"],
150
185
  query: "hello",
151
186
  limit: 5,
152
187
  min_score: 0.5,
153
188
  });
154
189
  });
155
190
 
191
+ it("tenant + user-scope arenas list when userId provided", async () => {
192
+ mockOk({ results: [] });
193
+ await engineSearch("https://e", {
194
+ clientId: "acme",
195
+ userId: "user-42",
196
+ query: "hi",
197
+ });
198
+ const body = JSON.parse(calls[0].init.body);
199
+ expect(body.arenas).toEqual(["acme", "acme:user-42"]);
200
+ // single-arena field kept for back-compat — points at tenant-wide
201
+ expect(body.arena).toBe("acme");
202
+ });
203
+
156
204
  it("includes metadata_filter only when non-empty", async () => {
157
205
  mockOk({ results: [] });
158
206
  await engineSearch("https://e", {
@@ -183,6 +231,90 @@ describe("engine HTTP client", () => {
183
231
  });
184
232
  });
185
233
 
234
+ describe("opts.headers passthrough (CF Access)", () => {
235
+ const cfAccess = {
236
+ "CF-Access-Client-Id": "tes-worker.id",
237
+ "CF-Access-Client-Secret": "shh-it-secret",
238
+ };
239
+
240
+ it("fetchEngine merges opts.headers on top of the default content-type", async () => {
241
+ mockOk({});
242
+ await fetchEngine("https://e", "/store", { a: 1 }, { headers: cfAccess });
243
+ const sent = calls[0].init.headers;
244
+ expect(sent["content-type"]).toBe("application/json");
245
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
246
+ expect(sent["CF-Access-Client-Secret"]).toBe("shh-it-secret");
247
+ });
248
+
249
+ it("engineStore forwards opts.headers", async () => {
250
+ mockOk({ id: "x", content: "x", layerId: "ml_acme_episodic" });
251
+ await engineStore("https://e", {
252
+ clientId: "acme",
253
+ content: "x",
254
+ headers: cfAccess,
255
+ });
256
+ const sent = calls[0].init.headers;
257
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
258
+ });
259
+
260
+ it("engineSearch forwards opts.headers", async () => {
261
+ mockOk({ results: [] });
262
+ await engineSearch("https://e", {
263
+ clientId: "acme",
264
+ query: "x",
265
+ headers: cfAccess,
266
+ });
267
+ const sent = calls[0].init.headers;
268
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
269
+ expect(sent["CF-Access-Client-Secret"]).toBe("shh-it-secret");
270
+ });
271
+
272
+ it("engineForget forwards opts.headers", async () => {
273
+ mockOk({ deleted: 0 });
274
+ await engineForget("https://e", {
275
+ clientId: "acme",
276
+ id: "abc",
277
+ headers: cfAccess,
278
+ });
279
+ const sent = calls[0].init.headers;
280
+ expect(sent["CF-Access-Client-Id"]).toBe("tes-worker.id");
281
+ });
282
+
283
+ it("no headers sent when opts.headers omitted (back-compat)", async () => {
284
+ mockOk({});
285
+ await engineStore("https://e", { clientId: "acme", content: "x" });
286
+ const sent = calls[0].init.headers;
287
+ expect(Object.keys(sent)).toEqual(["content-type"]);
288
+ });
289
+ });
290
+
291
+ describe("composeArena", () => {
292
+ it("tenant scope by default when no userId", () => {
293
+ expect(composeArena("acme")).toBe("acme");
294
+ });
295
+ it("user scope by default when userId present", () => {
296
+ expect(composeArena("acme", "u-1")).toBe("acme:u-1");
297
+ });
298
+ it("explicit scope=tenant overrides", () => {
299
+ expect(composeArena("acme", "u-1", "tenant")).toBe("acme");
300
+ });
301
+ it("scope=user without userId throws", () => {
302
+ expect(() => composeArena("acme", null, "user")).toThrow(/userId/);
303
+ });
304
+ it("missing clientId throws", () => {
305
+ expect(() => composeArena("")).toThrow(/clientId/);
306
+ });
307
+ });
308
+
309
+ describe("composeArenas", () => {
310
+ it("tenant only when no userId", () => {
311
+ expect(composeArenas("acme")).toEqual(["acme"]);
312
+ });
313
+ it("tenant + user-scope when userId present", () => {
314
+ expect(composeArenas("acme", "u-1")).toEqual(["acme", "acme:u-1"]);
315
+ });
316
+ });
317
+
186
318
  describe("engineForget", () => {
187
319
  it("forwards id when provided", async () => {
188
320
  mockOk({ deleted: 1 });
@@ -308,6 +308,11 @@ export function hostedAdapter(config, opts = {}) {
308
308
  * @param {string} config.engineUrl - e.g. "http://localhost:8099"
309
309
  * @param {string} [config.arena] - tenant scope; defaults to "default"
310
310
  * @param {string} [config.apiKey] - optional Authorization: Bearer
311
+ * @param {string} [config.cfAccessClientId] - CF Access service token id;
312
+ * sent as `CF-Access-Client-Id` when the engine is locked behind a
313
+ * Cloudflare Access policy.
314
+ * @param {string} [config.cfAccessClientSecret] - paired secret; sent as
315
+ * `CF-Access-Client-Secret`.
311
316
  * @param {object} [opts]
312
317
  * @param {number} [opts.timeoutMs=30000]
313
318
  * @returns {{ingestChunk, deleteByCorpusFile, init}}
@@ -319,11 +324,15 @@ export function engineAdapter(config, opts = {}) {
319
324
  }
320
325
  const arena = config.arena || "default";
321
326
  const apiKey = config.apiKey || null;
327
+ const cfAccessId = config.cfAccessClientId || null;
328
+ const cfAccessSecret = config.cfAccessClientSecret || null;
322
329
  const timeoutMs = opts.timeoutMs ?? 30000;
323
330
 
324
331
  function headers() {
325
332
  const h = { "content-type": "application/json" };
326
333
  if (apiKey) h["authorization"] = `Bearer ${apiKey}`;
334
+ if (cfAccessId) h["CF-Access-Client-Id"] = cfAccessId;
335
+ if (cfAccessSecret) h["CF-Access-Client-Secret"] = cfAccessSecret;
327
336
  return h;
328
337
  }
329
338
 
@@ -99,6 +99,13 @@ function readPluginConfig() {
99
99
  }
100
100
 
101
101
  function buildAdapterOrFail() {
102
+ // CF Access service token (optional). When the engine domain is
103
+ // locked behind a CF Access policy, callers need these headers or
104
+ // the edge returns 403 before traffic reaches the tunnel. Env-var
105
+ // names match the canonical Cloudflare Access convention.
106
+ const cfAccessClientId = process.env.CF_ACCESS_CLIENT_ID || null;
107
+ const cfAccessClientSecret = process.env.CF_ACCESS_CLIENT_SECRET || null;
108
+
102
109
  // 1. Env-var override (CI / scripts / explicit). Highest precedence.
103
110
  const envEngineUrl =
104
111
  process.env.MEMORY_ENGINE_URL || process.env.PENTATONIC_ENGINE_URL || null;
@@ -114,6 +121,8 @@ function buildAdapterOrFail() {
114
121
  engineUrl: envEngineUrl,
115
122
  arena,
116
123
  apiKey: process.env.MEMORY_ENGINE_API_KEY || null,
124
+ cfAccessClientId,
125
+ cfAccessClientSecret,
117
126
  }),
118
127
  };
119
128
  }
@@ -124,7 +133,12 @@ function buildAdapterOrFail() {
124
133
  const arena = pluginConfig.client_id || "default";
125
134
  return {
126
135
  tenant: { source: `plugin-config (${pluginConfig._path})`, engineUrl: pluginConfig.memory_url, arena },
127
- adapter: engineAdapter({ engineUrl: pluginConfig.memory_url, arena }),
136
+ adapter: engineAdapter({
137
+ engineUrl: pluginConfig.memory_url,
138
+ arena,
139
+ cfAccessClientId,
140
+ cfAccessClientSecret,
141
+ }),
128
142
  };
129
143
  }
130
144
 
@@ -67,19 +67,31 @@ export const DEFAULT_MIN_SCORE = 0.3;
67
67
  * @param {string} engineUrl - engine base URL (no trailing slash)
68
68
  * @param {string} path - "/store" | "/search" | "/forget" | "/health" | "/store-batch"
69
69
  * @param {object} body - JSON body, serialised verbatim
70
+ * @param {object} [opts]
71
+ * @param {Record<string,string>} [opts.headers] - additional request
72
+ * headers; merged on top of the default `content-type`. Canonical
73
+ * use case is Cloudflare Access service tokens
74
+ * (`CF-Access-Client-Id` + `CF-Access-Client-Secret`) when the
75
+ * engine domain is locked behind a CF Access policy. Any auth
76
+ * scheme can flow through this — the helper makes no assumptions
77
+ * about header names.
70
78
  * @returns {Promise<object>} parsed JSON response
71
79
  * @throws {Error} - "engine_<status>" with `.detail` set to response text
72
80
  * on HTTP non-2xx, or "engine_network: <msg>" on
73
81
  * transport failure.
74
82
  */
75
- export async function fetchEngine(engineUrl, path, body) {
83
+ export async function fetchEngine(engineUrl, path, body, opts = {}) {
76
84
  const base = engineUrl || DEFAULT_ENGINE_URL;
77
85
  const url = `${base}${path}`;
86
+ // Default content-type first so callers can override it via opts.headers
87
+ // if they ever need to (none do today, but the spread makes the rule
88
+ // explicit: caller wins on conflict).
89
+ const headers = { "content-type": "application/json", ...(opts.headers || {}) };
78
90
  let res;
79
91
  try {
80
92
  res = await fetch(url, {
81
93
  method: "POST",
82
- headers: { "content-type": "application/json" },
94
+ headers,
83
95
  body: JSON.stringify(body),
84
96
  });
85
97
  } catch (err) {
@@ -99,68 +111,139 @@ export async function fetchEngine(engineUrl, path, body) {
99
111
  return res.json();
100
112
  }
101
113
 
114
+ /**
115
+ * Compose the engine arena for a (clientId, userId, scope) triple.
116
+ *
117
+ * tenant scope: clientId (e.g. "acme")
118
+ * user scope: clientId + ":" + userId (e.g. "acme:user-42")
119
+ *
120
+ * Default scope: "user" when userId is supplied, "tenant" otherwise.
121
+ * Multi-tenant search composes arena lists from this same vocabulary.
122
+ *
123
+ * @param {string} clientId
124
+ * @param {string|null|undefined} userId
125
+ * @param {"tenant"|"user"} [scope]
126
+ * @returns {string} the arena value to stamp on /store metadata
127
+ */
128
+ export function composeArena(clientId, userId, scope) {
129
+ if (!clientId) throw new Error("composeArena: clientId required");
130
+ const effectiveScope = scope || (userId ? "user" : "tenant");
131
+ if (effectiveScope === "user") {
132
+ if (!userId) throw new Error("composeArena: scope=user requires userId");
133
+ return `${clientId}:${userId}`;
134
+ }
135
+ return clientId;
136
+ }
137
+
138
+ /**
139
+ * Compose the arenas list a search should span for a given user.
140
+ *
141
+ * no userId: [clientId] (tenant-wide only)
142
+ * with userId: [clientId, clientId + ":" + userId] (tenant-wide + own user-scope)
143
+ *
144
+ * Order is informational; the engine treats it as a set. Callers passing
145
+ * `userId` get visibility into both their own user-scoped memories and
146
+ * the shared tenant-wide memories — never another user's user-scoped data.
147
+ *
148
+ * @param {string} clientId
149
+ * @param {string|null|undefined} userId
150
+ * @returns {string[]}
151
+ */
152
+ export function composeArenas(clientId, userId) {
153
+ if (!clientId) throw new Error("composeArenas: clientId required");
154
+ return userId ? [clientId, `${clientId}:${userId}`] : [clientId];
155
+ }
156
+
102
157
  /**
103
158
  * Store a single memory in the engine.
104
159
  *
105
- * Builds the canonical /store body: `arena = clientId` is set on
106
- * metadata so the engine's multi-tenant scoping works. Caller-supplied
107
- * metadata fields take precedence on conflict.
160
+ * Builds the canonical /store body. By default the row is **user-scoped**
161
+ * (`arena = clientId:userId`) when `userId` is supplied, otherwise
162
+ * **tenant-wide** (`arena = clientId`). Pass `scope: "tenant"` explicitly
163
+ * to write a shared row from a user-context (e.g. a super-admin uploading
164
+ * a doc that should be visible to every user in the tenant).
165
+ *
166
+ * The arena value is fixed by the SDK after the caller's metadata, so a
167
+ * resolver can't be tricked into spoofing arena via metadata.
108
168
  *
109
169
  * @param {string} engineUrl
110
170
  * @param {object} opts
111
- * @param {string} opts.clientId tenant id (becomes engine arena)
112
- * @param {string} opts.content
113
- * @param {object} [opts.metadata] extra metadata; merged into engine body
114
- * @param {string} [opts.layerType] "episodic" | "semantic" | "procedural" | "working"
115
- * @param {string} [opts.actorUserId] passes through as metadata.actor_user_id
171
+ * @param {string} opts.clientId tenant id
172
+ * @param {string} [opts.userId] user id within the tenant; controls default scope
173
+ * @param {"tenant"|"user"} [opts.scope] override the default scope. "user" requires userId.
174
+ * @param {string} opts.content
175
+ * @param {object} [opts.metadata] extra metadata; merged into engine body
176
+ * @param {string} [opts.layerType] "episodic" | "semantic" | "procedural" | "working"
177
+ * @param {string} [opts.actorUserId] passes through as metadata.actor_user_id
178
+ * @param {Record<string,string>} [opts.headers] forwarded HTTP headers
179
+ * (e.g. CF Access service token pair when the engine domain is
180
+ * locked behind a Cloudflare Access policy).
116
181
  * @returns {Promise<EngineStoreResult>}
117
182
  */
118
183
  export async function engineStore(engineUrl, opts) {
119
184
  const {
120
185
  clientId,
186
+ userId,
187
+ scope,
121
188
  content,
122
189
  metadata = {},
123
190
  layerType,
124
191
  actorUserId,
192
+ headers,
125
193
  } = opts || {};
126
194
  if (!clientId) throw new Error("engineStore: clientId required");
127
195
  if (typeof content !== "string") throw new Error("engineStore: content required");
196
+ const arena = composeArena(clientId, userId, scope);
128
197
  const body = {
129
198
  content,
130
199
  metadata: {
131
200
  ...metadata,
132
- arena: clientId,
201
+ arena,
133
202
  ...(layerType ? { layer_type: layerType } : {}),
134
203
  ...(actorUserId !== undefined ? { actor_user_id: actorUserId } : {}),
135
204
  },
136
205
  };
137
- return fetchEngine(engineUrl, "/store", body);
206
+ return fetchEngine(engineUrl, "/store", body, { headers });
138
207
  }
139
208
 
140
209
  /**
141
210
  * Search the engine, scoped to a tenant.
142
211
  *
212
+ * When `userId` is supplied the search spans **both** the tenant-wide
213
+ * arena (`clientId`) and the user's own scope (`clientId:userId`) — so a
214
+ * caller sees their own memories plus shared tenant memories, never
215
+ * another user's. Without `userId` the search is tenant-wide only.
216
+ *
143
217
  * @param {string} engineUrl
144
218
  * @param {object} opts
145
- * @param {string} opts.clientId
146
- * @param {string} opts.query
147
- * @param {number} [opts.limit=10]
148
- * @param {number} [opts.minScore=0.3]
149
- * @param {object} [opts.metadataFilter] arbitrary equality filter on result metadata
219
+ * @param {string} opts.clientId
220
+ * @param {string} [opts.userId]
221
+ * @param {string} opts.query
222
+ * @param {number} [opts.limit=10]
223
+ * @param {number} [opts.minScore=0.3]
224
+ * @param {object} [opts.metadataFilter] arbitrary equality filter on result metadata
225
+ * @param {Record<string,string>} [opts.headers] forwarded HTTP headers
226
+ * (e.g. CF Access service token pair).
150
227
  * @returns {Promise<{results: EngineSearchHit[]}>}
151
228
  */
152
229
  export async function engineSearch(engineUrl, opts) {
153
230
  const {
154
231
  clientId,
232
+ userId,
155
233
  query,
156
234
  limit = DEFAULT_LIMIT,
157
235
  minScore = DEFAULT_MIN_SCORE,
158
236
  metadataFilter,
237
+ headers,
159
238
  } = opts || {};
160
239
  if (!clientId) throw new Error("engineSearch: clientId required");
161
240
  if (typeof query !== "string") throw new Error("engineSearch: query required");
241
+ const arenas = composeArenas(clientId, userId);
162
242
  const body = {
163
- arena: clientId,
243
+ arenas,
244
+ // Single-arena field kept for callers / engines that haven't been
245
+ // upgraded to the arenas-list shape. The list is authoritative.
246
+ arena: arenas[0],
164
247
  query,
165
248
  limit,
166
249
  min_score: minScore,
@@ -168,7 +251,7 @@ export async function engineSearch(engineUrl, opts) {
168
251
  ? { metadata_filter: metadataFilter }
169
252
  : {}),
170
253
  };
171
- return fetchEngine(engineUrl, "/search", body);
254
+ return fetchEngine(engineUrl, "/search", body, { headers });
172
255
  }
173
256
 
174
257
  /**
@@ -181,10 +264,12 @@ export async function engineSearch(engineUrl, opts) {
181
264
  * @param {string} opts.clientId
182
265
  * @param {string} [opts.id] forget a single record by engine id
183
266
  * @param {object} [opts.metadataContains] forget all records matching every key=value pair
267
+ * @param {Record<string,string>} [opts.headers] forwarded HTTP headers
268
+ * (e.g. CF Access service token pair).
184
269
  * @returns {Promise<{deleted: number}>}
185
270
  */
186
271
  export async function engineForget(engineUrl, opts) {
187
- const { clientId, id, metadataContains } = opts || {};
272
+ const { clientId, id, metadataContains, headers } = opts || {};
188
273
  if (!clientId) throw new Error("engineForget: clientId required");
189
274
  if (!id && !metadataContains) {
190
275
  throw new Error("engineForget: provide id or metadataContains");
@@ -194,5 +279,5 @@ export async function engineForget(engineUrl, opts) {
194
279
  ...(id ? { id } : {}),
195
280
  ...(metadataContains ? { metadata_contains: metadataContains } : {}),
196
281
  };
197
- return fetchEngine(engineUrl, "/forget", body);
282
+ return fetchEngine(engineUrl, "/forget", body, { headers });
198
283
  }
@@ -102,12 +102,15 @@ class SearchRequest(BaseModel):
102
102
  query: str
103
103
  limit: Optional[int] = 10
104
104
  min_score: Optional[float] = 0.001
105
- # Tenant scope. Required for multi-tenant deployments. Forwarded to
106
- # layers that support arena filtering natively (L6); applied as a
107
- # post-filter on the shim for layers that don't yet (L2, L4, L5).
108
- # When unset, search is global — same behaviour as v0.7.x; safe for
109
- # single-tenant deployments. Multi-tenant callers MUST set this.
105
+ # Tenant scope (single arena). Back-compat shape single-arena callers
106
+ # can keep sending this. Treated as a one-element `arenas` list.
110
107
  arena: Optional[str] = None
108
+ # Multi-arena scope. Used by callers that want to span both a tenant-
109
+ # wide arena ("acme") and a user-scoped arena ("acme:user-42") in one
110
+ # search — the SDK helper composes this list automatically when a
111
+ # `userId` is supplied. Authoritative when both `arena` and `arenas`
112
+ # are present; engine treats it as a set.
113
+ arenas: Optional[list[str]] = None
111
114
  # Arbitrary metadata equality filters, applied as a post-filter on
112
115
  # the shim. Useful for `kind`, `layer_type`, `source_repo`, etc.
113
116
  # Keys not present on a result's metadata are treated as no-match.
@@ -545,29 +548,49 @@ async def store_batch(req: StoreBatchRequest):
545
548
  }
546
549
 
547
550
 
551
+ def _arenas_for(req: SearchRequest) -> list[str]:
552
+ """Normalize req's single-arena + multi-arena fields into one list.
553
+
554
+ `arenas` is authoritative when set; otherwise `arena` is treated as
555
+ a one-element list; otherwise empty (= search is unscoped, dev/test).
556
+ """
557
+ if req.arenas:
558
+ return [a for a in req.arenas if a]
559
+ if req.arena:
560
+ return [req.arena]
561
+ return []
562
+
563
+
548
564
  def _apply_metadata_filters(results: list[dict[str, Any]], req: SearchRequest) -> list[dict[str, Any]]:
549
- """Post-filter results by arena + arbitrary metadata equality.
565
+ """Post-filter results by arena set + arbitrary metadata equality.
550
566
 
551
567
  Many layer searches don't yet honour arena/metadata at the storage
552
568
  level, so the shim enforces tenant isolation here as defence in
553
569
  depth. Even if the underlying layer leaks across arenas, the shim
554
- drops cross-tenant rows before returning.
570
+ drops cross-arena rows before returning.
571
+
572
+ Multi-arena rule: a row passes if its arena tag is in the request's
573
+ arena set. So a user-scoped search (arenas=[acme, acme:u-42]) sees
574
+ both tenant-wide rows (arena=acme) and that user's own user-scoped
575
+ rows (arena=acme:u-42), but never another user's user-scoped rows
576
+ (arena=acme:u-99).
555
577
  """
556
- arena = req.arena
578
+ arenas = _arenas_for(req)
557
579
  extra = req.metadata_filter or {}
558
- if not arena and not extra:
580
+ if not arenas and not extra:
559
581
  return results
582
+ arena_set = set(arenas)
560
583
  out: list[dict[str, Any]] = []
561
584
  for item in results:
562
585
  meta = item.get("metadata") or {}
563
- if arena:
586
+ if arena_set:
564
587
  row_arena = meta.get("arena") or item.get("arena")
565
- if row_arena and row_arena != arena:
588
+ if row_arena and row_arena not in arena_set:
566
589
  continue
567
590
  # If row has no arena tag at all, drop on multi-tenant
568
591
  # safety: a row without arena predates the multi-tenant
569
592
  # plumbing and could belong to anyone.
570
- if arena and not row_arena:
593
+ if not row_arena:
571
594
  continue
572
595
  ok = True
573
596
  for k, v in extra.items():
@@ -587,7 +610,7 @@ def _search_overfetch(req: SearchRequest) -> int:
587
610
  between accuracy and latency.
588
611
  """
589
612
  base = req.limit or 10
590
- return base * 5 if (req.arena or req.metadata_filter) else base * 3
613
+ return base * 5 if (_arenas_for(req) or req.metadata_filter) else base * 3
591
614
 
592
615
 
593
616
  @app.post("/search")
@@ -624,16 +647,17 @@ async def search(req: SearchRequest):
624
647
  import asyncio
625
648
  async def _q_l6(query: str):
626
649
  try:
627
- params: dict[str, Any] = {
628
- "q": query,
629
- "limit": _search_overfetch(req),
630
- "method": "hybrid",
631
- }
632
- if req.arena:
633
- # L6 supports arena natively (l6-document-store.py:837).
634
- # Forward it so the underlying Milvus query and FTS
635
- # query both filter to this tenant before returning.
636
- params["arena"] = req.arena
650
+ params: list = [
651
+ ("q", query),
652
+ ("limit", str(_search_overfetch(req))),
653
+ ("method", "hybrid"),
654
+ ]
655
+ # L6 supports arena natively (l6-document-store.py).
656
+ # Forward all arenas in the search scope; L6 expands the
657
+ # filter to `arena IN (...)`. Multiple `arenas` query
658
+ # params on the wire = list-shaped server side.
659
+ for a in _arenas_for(req):
660
+ params.append(("arenas", a))
637
661
  r = await _client().get(
638
662
  f"{L6_DOC_URL}/search",
639
663
  params=params,
@@ -741,10 +765,14 @@ async def search(req: SearchRequest):
741
765
  # then trim to the requested limit.
742
766
  out_results = _apply_metadata_filters(out_results, req)
743
767
  return {"results": out_results[: req.limit or 10]}
768
+ arenas = _arenas_for(req)
744
769
  try:
745
- get_params: dict[str, Any] = {"q": req.query, "limit": _search_overfetch(req)}
746
- if req.arena:
747
- get_params["arena"] = req.arena
770
+ get_params: list = [
771
+ ("q", req.query),
772
+ ("limit", str(_search_overfetch(req))),
773
+ ]
774
+ for a in arenas:
775
+ get_params.append(("arenas", a))
748
776
  r = await _client().get(
749
777
  f"{L2_PROXY_URL}/search",
750
778
  params=get_params,
@@ -760,8 +788,8 @@ async def search(req: SearchRequest):
760
788
  "limit": _search_overfetch(req),
761
789
  "min_score": req.min_score or 0.001,
762
790
  }
763
- if req.arena:
764
- post_body["arena"] = req.arena
791
+ if arenas:
792
+ post_body["arenas"] = arenas
765
793
  r = await _client().post(
766
794
  f"{L2_PROXY_URL}/v1/search",
767
795
  json=post_body,
@@ -772,11 +800,14 @@ async def search(req: SearchRequest):
772
800
  except Exception as exc2:
773
801
  last_err = exc2
774
802
  try:
775
- params: dict[str, Any] = {"q": req.query, "limit": _search_overfetch(req)}
776
- # L6 supports arena natively; forward it on the
777
- # last-resort fallback path too.
778
- if req.arena:
779
- params["arena"] = req.arena
803
+ params: list = [
804
+ ("q", req.query),
805
+ ("limit", str(_search_overfetch(req))),
806
+ ]
807
+ # L6 supports arena natively; forward all in the search
808
+ # scope on the last-resort fallback path too.
809
+ for a in arenas:
810
+ params.append(("arenas", a))
780
811
  r = await _client().get(
781
812
  f"{L6_DOC_URL}/search",
782
813
  params=params,
@@ -719,17 +719,18 @@ L0_MEMORY_DB = Path(os.environ.get(
719
719
  str(Path.home() / ".pentatonic" / "memory" / "main.sqlite"),
720
720
  ))
721
721
 
722
- def search_l0_bm25(query: str, limit: int = 6, arena: str = None) -> List[Dict]:
722
+ def search_l0_bm25(query: str, limit: int = 6, arena: str = None,
723
+ arenas: List[str] = None) -> List[Dict]:
723
724
  """Search native BM25 index over workspace memory files.
724
725
 
725
726
  Covers chunks from daily notes, memory files, people profiles,
726
727
  infrastructure docs, project files — corpus that L3-L6 don't index.
727
728
  Sub-millisecond local SQLite reads, zero network overhead.
728
729
 
729
- arena (optional): when set, filter to paths under bench/<arena>/.
730
- Records stored via the compat shim land under that prefix per
731
- _stash_all_keys; this is the L0 path-based equivalent of the
732
- arena dynamic-field filter on L5/L6.
730
+ arena / arenas: when set, filter to paths under bench/<arena>/.
731
+ Multi-arena queries (e.g. tenant-wide + user-scoped in one search)
732
+ use OR'd path-prefix LIKE clauses. `arenas` wins when both are
733
+ supplied; `arena` is treated as a one-element list for back-compat.
733
734
  """
734
735
  if not L0_MEMORY_DB.exists():
735
736
  return []
@@ -744,6 +745,9 @@ def search_l0_bm25(query: str, limit: int = 6, arena: str = None) -> List[Dict]:
744
745
  return []
745
746
  fts_query = " OR ".join(f'"{t}"' for t in meaningful)
746
747
 
748
+ # Normalize single+multi arena inputs into one list.
749
+ arena_list = list(arenas) if arenas else ([arena] if arena else [])
750
+
747
751
  conn = sqlite3.connect(str(L0_MEMORY_DB), timeout=2)
748
752
  conn.execute("PRAGMA journal_mode=WAL")
749
753
  sql = """
@@ -755,9 +759,10 @@ def search_l0_bm25(query: str, limit: int = 6, arena: str = None) -> List[Dict]:
755
759
  AND path NOT LIKE '%-backup-%'
756
760
  """
757
761
  params: list = [fts_query]
758
- if arena:
759
- sql += " AND path LIKE ?"
760
- params.append(f"bench/{arena}/%")
762
+ if arena_list:
763
+ clauses = " OR ".join(["path LIKE ?"] * len(arena_list))
764
+ sql += f" AND ({clauses})"
765
+ params.extend([f"bench/{a}/%" for a in arena_list])
761
766
  sql += " ORDER BY rank ASC LIMIT ?"
762
767
  params.append(limit * 2)
763
768
  rows = conn.execute(sql, params).fetchall()
@@ -800,17 +805,21 @@ def search_l0_bm25(query: str, limit: int = 6, arena: str = None) -> List[Dict]:
800
805
 
801
806
  L5_API_URL = os.environ.get("PME_L5_URL", "http://127.0.0.1:8034")
802
807
 
803
- def search_l5_communications(query: str, limit: int = 6, arena: str = None) -> List[Dict]:
808
+ def search_l5_communications(query: str, limit: int = 6, arena: str = None,
809
+ arenas: List[str] = None) -> List[Dict]:
804
810
  """Search L5 Communications Context via L5 API (emails, chats, calendar).
805
811
 
806
- arena (optional): forwarded to L5; filters Milvus by the arena
807
- dynamic field. Records id is included in the result so callers
808
- can attach metadata via the shim's _META_CACHE.
812
+ arena / arenas (optional): forwarded to L5; filters Milvus by the
813
+ arena dynamic field. Multi-arena calls become a Milvus
814
+ `arena IN ["X","Y"]` filter expression on the L5 side.
809
815
  """
810
816
  try:
811
- params: dict = {"q": query, "limit": limit}
812
- if arena:
813
- params["arena"] = arena
817
+ # Build a list of (key, value) tuples so multi-valued query
818
+ # params (?arenas=A&arenas=B) wire-shape correctly.
819
+ arena_list = list(arenas) if arenas else ([arena] if arena else [])
820
+ params: list = [("q", query), ("limit", str(limit))]
821
+ for a in arena_list:
822
+ params.append(("arenas", a))
814
823
  resp = requests.get(
815
824
  f"{L5_API_URL}/search",
816
825
  params=params,
@@ -857,16 +866,23 @@ def search_l5_communications(query: str, limit: int = 6, arena: str = None) -> L
857
866
  # L6: Document Store Search
858
867
  L6_URL = os.environ.get("PME_L6_URL", "http://localhost:8037")
859
868
 
860
- def search_l6_documents(query: str, limit: int = 6, arena: str = None) -> List[Dict]:
869
+ def search_l6_documents(query: str, limit: int = 6, arena: str = None,
870
+ arenas: List[str] = None) -> List[Dict]:
861
871
  """Search L6 Document Store (research, legal, financial, project docs).
862
872
 
863
- arena (optional): forwarded to L6 — L6 already supports arena
873
+ arena / arenas (optional): forwarded to L6 — L6 supports multi-arena
864
874
  natively (see l6-document-store.py search_vector / search_fts).
865
875
  """
866
876
  try:
867
- params: dict = {"q": query, "method": "hybrid", "limit": limit, "rerank": "true"}
868
- if arena:
869
- params["arena"] = arena
877
+ arena_list = list(arenas) if arenas else ([arena] if arena else [])
878
+ params: list = [
879
+ ("q", query),
880
+ ("method", "hybrid"),
881
+ ("limit", str(limit)),
882
+ ("rerank", "true"),
883
+ ]
884
+ for a in arena_list:
885
+ params.append(("arenas", a))
870
886
  resp = requests.get(
871
887
  f"{L6_URL}/search",
872
888
  params=params,
@@ -914,19 +930,22 @@ def search_l6_documents(query: str, limit: int = 6, arena: str = None) -> List[D
914
930
  return []
915
931
 
916
932
 
917
- def sequential_hybridrag_search(query: str, limit: int = 16, arena: str = None) -> List[Dict]:
933
+ def sequential_hybridrag_search(query: str, limit: int = 16,
934
+ arena: str = None,
935
+ arenas: List[str] = None) -> List[Dict]:
918
936
  """Main HybridRAG processing: L0 BM25 → L1 System Files → L2 HybridRAG (L3 Graph + L4 Vector + L5 Comms + L6 Docs).
919
937
 
920
- arena (optional): tenant scope. Forwarded to L0 (path-prefix
921
- filter), L5 (Milvus dynamic-field filter), L6 (native arena).
922
- L4 vector and L3 graph don't yet support native arena filtering;
923
- the compat shim post-filter catches those before they leak out.
938
+ arena / arenas (optional): tenant + user scope. Multi-arena lets a
939
+ user's search span tenant-wide rows + their own user-scoped rows in
940
+ a single hybrid pass. Forwarded to L0, L5, L6 native filters; L4
941
+ and L3 still rely on the compat shim post-filter.
924
942
  """
943
+ arena_list = list(arenas) if arenas else ([arena] if arena else [])
925
944
  start_time = time.time()
926
- log.info(f"Starting sequential HybridRAG search for: '{query}' arena={arena!r}")
945
+ log.info(f"Starting sequential HybridRAG search for: '{query}' arenas={arena_list!r}")
927
946
 
928
947
  # L0: BM25 workspace memory (keyword search — complements semantic layers)
929
- l0_results = search_l0_bm25(query, limit=6, arena=arena)
948
+ l0_results = search_l0_bm25(query, limit=6, arenas=arena_list)
930
949
  log.info(f"L0 BM25 workspace: {len(l0_results)} results")
931
950
 
932
951
  # L1: System Files (HIGHEST PRIORITY)
@@ -947,11 +966,11 @@ def sequential_hybridrag_search(query: str, limit: int = 16, arena: str = None)
947
966
  log.info(f"L4 Vector search: {len(vector_results)} results (HyDE={'on' if hyde_query != query else 'off'})")
948
967
 
949
968
  # L5: Communications Context (emails, chats, calendar) — also use HyDE
950
- l5_results = search_l5_communications(hyde_query, limit=6, arena=arena)
969
+ l5_results = search_l5_communications(hyde_query, limit=6, arenas=arena_list)
951
970
  log.info(f"L5 Communications: {len(l5_results)} results")
952
971
 
953
972
  # L6: Document Store (research, legal, financial, project docs)
954
- l6_results = search_l6_documents(hyde_query, limit=6, arena=arena)
973
+ l6_results = search_l6_documents(hyde_query, limit=6, arenas=arena_list)
955
974
  log.info(f"L6 Documents: {len(l6_results)} results")
956
975
 
957
976
  # L2: HybridRAG fusion (combines all layers with L1 priority)
@@ -1012,10 +1031,11 @@ async def search_endpoint(request: Request) -> dict:
1012
1031
  query = body.get("query", "")
1013
1032
  limit = body.get("limit", 16)
1014
1033
  arena = body.get("arena") or None
1034
+ arenas = body.get("arenas") or None
1015
1035
  if not query:
1016
1036
  raise HTTPException(status_code=400, detail="query is required")
1017
1037
 
1018
- results = sequential_hybridrag_search(query, limit=limit, arena=arena)
1038
+ results = sequential_hybridrag_search(query, limit=limit, arena=arena, arenas=arenas)
1019
1039
 
1020
1040
  # Also return raw graph entities for context enrichment
1021
1041
  entities = extract_query_entities(query)
@@ -449,12 +449,15 @@ def index_memory(client):
449
449
 
450
450
  # --- Search ---
451
451
 
452
- def search(query: str, collection: str = None, limit: int = 10, arena: str = None):
452
+ def search(query: str, collection: str = None, limit: int = 10,
453
+ arena: str = None, arenas=None):
453
454
  """Search across collections.
454
455
 
455
- arena (optional): when set, filter to records whose arena dynamic
456
- field matches. Records indexed before arena was added carry no
457
- arena field those are dropped under multi-tenant safety.
456
+ arena / arenas (optional): when set, filter rows whose `arena`
457
+ dynamic field matches. Multi-arena uses Milvus `in [...]` so a
458
+ single-pass user-scoped search (tenant + own user) returns rows
459
+ from both buckets. Records without an arena tag are dropped under
460
+ multi-tenant safety.
458
461
  """
459
462
  client = get_client()
460
463
  vectors = embed_texts([query])
@@ -465,11 +468,20 @@ def search(query: str, collection: str = None, limit: int = 10, arena: str = Non
465
468
  collections = [collection] if collection else ["chats", "emails", "contacts", "memory"]
466
469
  all_results = []
467
470
 
471
+ # Normalize arenas list and build the Milvus filter expression.
472
+ if arenas is None:
473
+ arena_list = [arena] if arena else []
474
+ else:
475
+ arena_list = [a for a in arenas if a]
468
476
  filter_expr = ""
469
- if arena:
470
- # Escape double quotes; Milvus filter syntax for dynamic fields.
471
- safe = str(arena).replace('"', '\\"')
477
+ if len(arena_list) == 1:
478
+ safe = str(arena_list[0]).replace('"', '\\"')
472
479
  filter_expr = f'arena == "{safe}"'
480
+ elif len(arena_list) > 1:
481
+ quoted = ", ".join(
482
+ '"{}"'.format(str(a).replace('"', '\\"')) for a in arena_list
483
+ )
484
+ filter_expr = f'arena in [{quoted}]'
473
485
 
474
486
  for coll in collections:
475
487
  if not client.has_collection(coll):
@@ -562,8 +574,12 @@ def serve(port=8034):
562
574
 
563
575
  @api.get("/search")
564
576
  def api_search(q: str = Query(...), collection: str = None, limit: int = 10,
565
- arena: str = None):
566
- results = search(q, collection=collection, limit=limit, arena=arena)
577
+ arena: str = None, arenas: list = Query(default=[])):
578
+ # `arenas` (repeated query param) wins when both are present.
579
+ results = search(
580
+ q, collection=collection, limit=limit,
581
+ arena=arena, arenas=arenas or None,
582
+ )
567
583
  return {"query": q, "results": results, "count": len(results)}
568
584
 
569
585
  @api.get("/stats")
@@ -303,9 +303,25 @@ def get_milvus() -> MilvusClient:
303
303
 
304
304
 
305
305
  def search_vector(client: MilvusClient, query_vec: List[float], limit: int = 20,
306
- arena: Optional[str] = None) -> List[Dict]:
307
- """Vector similarity search."""
308
- filter_expr = f'arena == "{arena}"' if arena else ""
306
+ arena: Optional[str] = None,
307
+ arenas: Optional[List[str]] = None) -> List[Dict]:
308
+ """Vector similarity search.
309
+
310
+ Multi-arena: pass `arenas=[...]` to span more than one tenant scope
311
+ (e.g. tenant-wide + a single user-scope). Builds an `arena IN [...]`
312
+ Milvus filter. `arena` is treated as a single-element list when set.
313
+ """
314
+ arena_list = list(arenas) if arenas else ([arena] if arena else [])
315
+ if len(arena_list) == 1:
316
+ safe = str(arena_list[0]).replace('"', '\\"')
317
+ filter_expr = f'arena == "{safe}"'
318
+ elif len(arena_list) > 1:
319
+ quoted = ", ".join(
320
+ '"{}"'.format(str(a).replace('"', '\\"')) for a in arena_list
321
+ )
322
+ filter_expr = f'arena in [{quoted}]'
323
+ else:
324
+ filter_expr = ""
309
325
  results = client.search(
310
326
  collection_name=COLLECTION_NAME,
311
327
  data=[query_vec],
@@ -386,15 +402,26 @@ def get_fts_db() -> sqlite3.Connection:
386
402
 
387
403
 
388
404
  def search_fts(conn: sqlite3.Connection, query: str, limit: int = 20,
389
- arena: Optional[str] = None) -> List[Dict]:
390
- """BM25 keyword search via FTS5."""
405
+ arena: Optional[str] = None,
406
+ arenas: Optional[List[str]] = None) -> List[Dict]:
407
+ """BM25 keyword search via FTS5.
408
+
409
+ Multi-arena: pass `arenas=[...]` to OR multiple `c.arena = ?` clauses,
410
+ so a single search can span tenant-wide + own user-scope.
411
+ """
391
412
  # Escape FTS5 special chars
392
413
  safe_query = re.sub(r'[^\w\s]', ' ', query).strip()
393
414
  if not safe_query:
394
415
  return []
395
416
 
396
- arena_filter = f"AND c.arena = ?" if arena else ""
397
- params = [safe_query, limit] if not arena else [safe_query, arena, limit]
417
+ arena_list = list(arenas) if arenas else ([arena] if arena else [])
418
+ if arena_list:
419
+ placeholders = ", ".join(["?"] * len(arena_list))
420
+ arena_filter = f"AND c.arena IN ({placeholders})"
421
+ params = [safe_query, *arena_list, limit]
422
+ else:
423
+ arena_filter = ""
424
+ params = [safe_query, limit]
398
425
 
399
426
  sql = f"""
400
427
  SELECT c.*, bm25(chunks_fts) as rank
@@ -690,19 +717,28 @@ def _parse_entities_json(s: str) -> List[str]:
690
717
  # ---------------------------------------------------------------------------
691
718
 
692
719
  def search(query: str, method: str = "hybrid", limit: int = 10,
693
- arena: Optional[str] = None, enable_rerank: bool = True) -> List[Dict]:
694
- """Search documents with specified method."""
720
+ arena: Optional[str] = None,
721
+ arenas: Optional[List[str]] = None,
722
+ enable_rerank: bool = True) -> List[Dict]:
723
+ """Search documents with specified method.
724
+
725
+ arena / arenas: pass either; multi-arena lets a single query span
726
+ multiple tenant scopes (tenant-wide + user-scope). Forwarded
727
+ natively to both the vector path (Milvus `arena IN [...]`) and the
728
+ BM25 path (SQLite `c.arena IN (...)`).
729
+ """
730
+ arena_list = list(arenas) if arenas else ([arena] if arena else [])
695
731
 
696
732
  if method == "vector":
697
733
  vec = embed_text(query)
698
- results = search_vector(get_milvus(), vec, limit=limit, arena=arena)
734
+ results = search_vector(get_milvus(), vec, limit=limit, arenas=arena_list)
699
735
  elif method == "bm25":
700
- results = search_fts(get_fts_db(), query, limit=limit, arena=arena)
736
+ results = search_fts(get_fts_db(), query, limit=limit, arenas=arena_list)
701
737
  else:
702
738
  # Hybrid: RRF fusion
703
739
  vec = embed_text(query)
704
- vector_results = search_vector(get_milvus(), vec, limit=20, arena=arena)
705
- bm25_results = search_fts(get_fts_db(), query, limit=20, arena=arena)
740
+ vector_results = search_vector(get_milvus(), vec, limit=20, arenas=arena_list)
741
+ bm25_results = search_fts(get_fts_db(), query, limit=20, arenas=arena_list)
706
742
  results = rrf_fuse(vector_results, bm25_results)
707
743
 
708
744
  # Rerank if enabled
@@ -812,9 +848,14 @@ def serve(port: int = DEFAULT_PORT):
812
848
  method: str = Q("hybrid", description="hybrid|vector|bm25"),
813
849
  limit: int = Q(10, ge=1, le=50),
814
850
  arena: Optional[str] = Q(None),
851
+ arenas: List[str] = Q(default=[]),
815
852
  rerank: bool = Q(True),
816
853
  ):
817
- results = search(q, method=method, limit=limit, arena=arena, enable_rerank=rerank)
854
+ results = search(
855
+ q, method=method, limit=limit,
856
+ arena=arena, arenas=arenas or None,
857
+ enable_rerank=rerank,
858
+ )
818
859
  return {"query": q, "method": method, "results": results, "count": len(results)}
819
860
 
820
861
  @api.post("/search")
@@ -823,10 +864,15 @@ def serve(port: int = DEFAULT_PORT):
823
864
  method: str = "hybrid",
824
865
  limit: int = 10,
825
866
  arena: Optional[str] = None,
867
+ arenas: Optional[List[str]] = None,
826
868
  rerank: bool = True,
827
869
  ):
828
870
  """POST version of search for compatibility."""
829
- results = search(q, method=method, limit=limit, arena=arena, enable_rerank=rerank)
871
+ results = search(
872
+ q, method=method, limit=limit,
873
+ arena=arena, arenas=arenas,
874
+ enable_rerank=rerank,
875
+ )
830
876
  return {"query": q, "method": method, "results": results, "count": len(results)}
831
877
 
832
878
  @api.post("/index")
@@ -125,6 +125,66 @@ print("yes" if ok and data else "no")')
125
125
  [ "$all_match" = "yes" ] && ok "metadata_filter scopes to probe + arena" \
126
126
  || fail "metadata_filter let other rows through"
127
127
 
128
+ # ---------------------------------------------------------------------------
129
+ # User-scope vs tenant-wide arenas — proves the multi-arena search model.
130
+ #
131
+ # tenant-wide row arena=acme (visible to every user in acme)
132
+ # user-A's row arena=acme:user-a (only user-A retrieves it)
133
+ # user-B's row arena=acme:user-b (only user-B retrieves it)
134
+ #
135
+ # A user-scoped search sends arenas=[acme, acme:userX] so the user sees
136
+ # tenant-wide AND own user-scope, but never another user's user-scope.
137
+ # ---------------------------------------------------------------------------
138
+
139
+ echo ""
140
+ echo "=== user-scope vs tenant-wide ==="
141
+ post '{"content":"acme tenant-wide rules of engagement","metadata":{"arena":"acme","probe":"e2e-arena"}}' >/dev/null
142
+ post '{"content":"alice private note about Project Mercury","metadata":{"arena":"acme:alice","probe":"e2e-arena"}}' >/dev/null
143
+ post '{"content":"bob private note about Project Saturn","metadata":{"arena":"acme:bob","probe":"e2e-arena"}}' >/dev/null
144
+ sleep 3
145
+
146
+ # Search as alice: arenas=[acme, acme:alice] — should see tenant-wide + own
147
+ SAlice=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
148
+ -d '{"query":"Project rules note","limit":20,"arenas":["acme","acme:alice"]}')
149
+
150
+ alice_sees_tenant=$(echo "$SAlice" | python3 -c '
151
+ import json,sys
152
+ data=json.load(sys.stdin).get("results",[])
153
+ print("yes" if any("tenant-wide" in r.get("content","") for r in data) else "no")')
154
+ alice_sees_own=$(echo "$SAlice" | python3 -c '
155
+ import json,sys
156
+ data=json.load(sys.stdin).get("results",[])
157
+ print("yes" if any("Mercury" in r.get("content","") for r in data) else "no")')
158
+ alice_leak_bob=$(echo "$SAlice" | python3 -c '
159
+ import json,sys
160
+ data=json.load(sys.stdin).get("results",[])
161
+ print(sum(1 for r in data if "Saturn" in r.get("content","")))')
162
+
163
+ [ "$alice_sees_tenant" = "yes" ] && ok "alice: tenant-wide visible" \
164
+ || fail "alice: missing tenant-wide row"
165
+ [ "$alice_sees_own" = "yes" ] && ok "alice: own user-scope visible" \
166
+ || fail "alice: missing own user-scope row"
167
+ [ "$alice_leak_bob" = "0" ] && ok "alice: no leakage of bob's user-scope" \
168
+ || fail "alice leaked $alice_leak_bob bob rows (cross-user!)"
169
+
170
+ # Search as bob: arenas=[acme, acme:bob] — should see tenant-wide + own
171
+ SBob=$(curl -sf -X POST "$BASE/search" -H "Content-Type: application/json" \
172
+ -d '{"query":"Project rules note","limit":20,"arenas":["acme","acme:bob"]}')
173
+
174
+ bob_sees_own=$(echo "$SBob" | python3 -c '
175
+ import json,sys
176
+ data=json.load(sys.stdin).get("results",[])
177
+ print("yes" if any("Saturn" in r.get("content","") for r in data) else "no")')
178
+ bob_leak_alice=$(echo "$SBob" | python3 -c '
179
+ import json,sys
180
+ data=json.load(sys.stdin).get("results",[])
181
+ print(sum(1 for r in data if "Mercury" in r.get("content","")))')
182
+
183
+ [ "$bob_sees_own" = "yes" ] && ok "bob: own user-scope visible" \
184
+ || fail "bob: missing own user-scope row"
185
+ [ "$bob_leak_alice" = "0" ] && ok "bob: no leakage of alice's user-scope" \
186
+ || fail "bob leaked $bob_leak_alice alice rows (cross-user!)"
187
+
128
188
  # ---------------------------------------------------------------------------
129
189
  # Same content across two arenas — proves the arena-aware id derivation.
130
190
  # Pre-v0.7.8, identical content collapsed to one row in L4/L5/L6 because