@pyxmate/memory 0.20.4 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,586 +0,0 @@
1
- # Access Control Patterns
2
-
3
- Production patterns for restricting who can read and write which memories.
4
-
5
- ## Contents
6
- - [Quick Decision Tree](#quick-decision-tree)
7
- - [Pattern 10: Per-Agent Isolation](#pattern-10-per-agent-isolation)
8
- - [Pattern 11: Multi-Tenant with Team Scoping](#pattern-11-multi-tenant-with-team-scoping)
9
- - [Pattern 12: Sensitivity-Based Read Restriction](#pattern-12-sensitivity-based-read-restriction)
10
- - [Pattern 13: Read-Only vs Read-Write Access](#pattern-13-read-only-vs-read-write-access)
11
- - [Pattern 14: Full Production Stack](#pattern-14-full-production-stack)
12
- - [Pattern 15: Built-in ReBAC](#pattern-15-built-in-rebac-namespaces--tuples--authzplan) ← **start here for fine-grained per-user / per-team access**
13
-
14
- ---
15
-
16
- ## Quick Decision Tree
17
-
18
- ```
19
- Need to isolate agents from each other?
20
- → Use agentId scoping (Pattern 10)
21
-
22
- Need to isolate organizations/customers?
23
- → Use TENANT_MODE=multi + X-Tenant-Id (Pattern 11)
24
-
25
- Need fine-grained per-user / per-team / per-folder access INSIDE a tenant?
26
- → Use built-in ReBAC: namespaces + principals + authz_tuples (Pattern 15)
27
-
28
- Need to hide sensitive data from some users?
29
- → Use sensitivity classification + X-Caller-Access-Level (Pattern 12)
30
-
31
- Need read-only vs read-write roles?
32
- → Use API_KEY + ADMIN_API_KEY + an API gateway (Pattern 13)
33
-
34
- Need all of the above?
35
- → Pattern 14 (full production stack) + Pattern 15 (ReBAC)
36
- ```
37
-
38
- ---
39
-
40
- ## Pattern 10: Per-Agent Isolation
41
-
42
- Each agent sees only its own memories. Other agents' memories are invisible — not redacted, just absent from results.
43
-
44
- ```typescript
45
- // Agent A — can only see its own memories
46
- const agentAMemory = new Memory({
47
- dataDir: './data',
48
- agentId: 'agent-researcher',
49
- });
50
- await agentAMemory.initialize();
51
-
52
- // Anything agent A stores is tagged with agentId: 'agent-researcher'
53
- await agentAMemory.store({
54
- content: 'Found a bug in the auth module',
55
- type: 'long-term',
56
- metadata: {},
57
- });
58
-
59
- // Agent A's searches only return agent A's memories
60
- const results = await agentAMemory.search({ query: 'auth bug' });
61
- // ✓ Returns the entry above
62
-
63
- // Agent B — completely isolated
64
- const agentBMemory = new Memory({
65
- dataDir: './data',
66
- agentId: 'agent-writer',
67
- });
68
- await agentBMemory.initialize();
69
-
70
- const bResults = await agentBMemory.search({ query: 'auth bug' });
71
- // ✗ Returns nothing — agent A's memories are invisible to agent B
72
- ```
73
-
74
- **Sidecar mode**: Pass `agentId` in the request body or query params — the server filters by it.
75
-
76
- ```bash
77
- # Store as agent-researcher
78
- curl -X POST http://localhost:7822/api/memory/ingest \
79
- -H "Content-Type: application/json" \
80
- -d '{"content":"Agent-scoped fact","type":"long-term","metadata":{},"agentId":"agent-researcher"}'
81
-
82
- # Search as agent-researcher — only sees agent-researcher's memories
83
- curl 'http://localhost:7822/api/memory/search?query=fact&agentId=agent-researcher'
84
- ```
85
-
86
- **When to use**: Multiple AI agents sharing one memory instance, each needing their own knowledge base. Agents that should collaborate can omit `agentId` to share a global pool.
87
-
88
- ---
89
-
90
- ## Pattern 11: Multi-Tenant with Team Scoping
91
-
92
- For SaaS / multi-organization deployments where each customer's data must be completely isolated.
93
-
94
- ### Server config
95
-
96
- ```bash
97
- # docker-compose.yaml
98
- environment:
99
- - TENANT_MODE=multi # enforce X-Tenant-Id on ALL operations
100
- - API_KEY=your-key # always set API_KEY with multi-tenant
101
- ```
102
-
103
- ### Per-client setup
104
-
105
- ```typescript
106
- import { MemoryClient } from '@pyx-memory/client';
107
-
108
- // Organization A — engineering team
109
- const orgAEng = new MemoryClient('http://memory:7822', {
110
- apiKey: process.env.MEMORY_API_KEY,
111
- defaultHeaders: {
112
- 'X-Tenant-Id': 'org-acme',
113
- 'X-User-Id': 'alice',
114
- 'X-Team-Id': 'team-engineering',
115
- },
116
- });
117
-
118
- // Organization A — marketing team (same tenant, different team)
119
- const orgAMkt = new MemoryClient('http://memory:7822', {
120
- apiKey: process.env.MEMORY_API_KEY,
121
- defaultHeaders: {
122
- 'X-Tenant-Id': 'org-acme',
123
- 'X-User-Id': 'bob',
124
- 'X-Team-Id': 'team-marketing',
125
- },
126
- });
127
-
128
- // Organization B — completely isolated tenant
129
- const orgB = new MemoryClient('http://memory:7822', {
130
- apiKey: process.env.MEMORY_API_KEY,
131
- defaultHeaders: {
132
- 'X-Tenant-Id': 'org-globex',
133
- 'X-User-Id': 'charlie',
134
- },
135
- });
136
- ```
137
-
138
- ### What's enforced
139
-
140
- | Scope level | How it works |
141
- |-------------|-------------|
142
- | `tenantId` | **Hard isolation.** Tenant A never sees tenant B's data. Enforced server-side when `TENANT_MODE=multi`. |
143
- | `teamId` | **Soft scoping.** Stored on entries but not enforced by the server. Use application-layer filtering. |
144
- | `userId` | **Soft scoping.** Stored on entries but not enforced by the server. Use application-layer filtering. |
145
- | `agentId` | **Hard filtering.** When set in MemoryOptions or query params, only that agent's entries are returned. |
146
-
147
- **Key rule**: `TENANT_MODE=multi` enforces tenantId. userId and teamId are for your application to filter — pyx-memory stores them but does not enforce boundaries between users/teams within a tenant.
148
-
149
- ---
150
-
151
- ## Pattern 12: Sensitivity-Based Read Restriction
152
-
153
- Control which memories are visible based on the caller's access level. Entries classified above the caller's level are automatically redacted in search results.
154
-
155
- ### How sensitivity classification works
156
-
157
- Every `store()` automatically scans content and classifies it:
158
-
159
- | Level | Auto-detected when | Example |
160
- |-------|-------------------|---------|
161
- | `public` | No credentials or PII detected | "Prefer tabs over spaces" |
162
- | `internal` | PII found (email, phone, SSN, etc.) | "Alice's email is alice@acme.com" |
163
- | `secret` | Credentials found (API keys, tokens, passwords) | "The API key is sk-ant-..." |
164
-
165
- ### Restricting search results
166
-
167
- ```typescript
168
- // Embedded: use maxSensitivity search param
169
- const results = await memory.search({
170
- query: 'config',
171
- maxSensitivity: 'internal', // only public + internal; secret entries are redacted
172
- });
173
-
174
- // Sidecar: use X-Caller-Access-Level header
175
- const restricted = new MemoryClient('http://memory:7822', {
176
- apiKey: process.env.MEMORY_API_KEY,
177
- defaultHeaders: {
178
- 'X-Caller-Access-Level': 'internal', // this client never sees secret entries
179
- },
180
- });
181
- ```
182
-
183
- ### What "redacted" means
184
-
185
- When a search returns entries above the caller's access level, the content is replaced with `[REDACTED: sensitive content]`. The entry metadata (id, type, createdAt) is still visible so the caller knows something exists — they just can't read it.
186
-
187
- ### Sensitivity policy options
188
-
189
- | `SENSITIVITY_POLICY` | Behavior |
190
- |----------------------|----------|
191
- | `flag` (default) | Classify and store sensitivity level. No content modification. |
192
- | `redact` | Replace detected credentials with `[REDACTED]` before storage. |
193
- | `block` | Reject any ingest containing credentials (HTTP 400). |
194
- | `encrypt` | Encrypt `secret` entries at rest with AES-256-GCM. Transparent decrypt on read when key is available. |
195
-
196
- ```bash
197
- # Production config with encryption
198
- environment:
199
- - SENSITIVITY_POLICY=encrypt
200
- - ENCRYPTION_KEY=your-64-hex-char-key # 32 bytes as hex
201
- ```
202
-
203
- ---
204
-
205
- ## Pattern 13: Read-Only vs Read-Write Access
206
-
207
- pyx-memory has two auth tiers: `API_KEY` (read + write) and `ADMIN_API_KEY` (destructive ops). For finer read-only vs read-write control, use an API gateway or proxy layer in front.
208
-
209
- ### Built-in auth tiers
210
-
211
- | Key | Allowed operations |
212
- |-----|-------------------|
213
- | `API_KEY` | All read operations (search, get, list, stats) + write operations (store, ingestFileEvents) |
214
- | `ADMIN_API_KEY` | Destructive operations: DELETE, forget, decay, consolidate, reindex, deleteBySource |
215
-
216
- ```bash
217
- # Give agents API_KEY (read + write), give admin tools ADMIN_API_KEY
218
- environment:
219
- - API_KEY=agent-key-for-read-write
220
- - ADMIN_API_KEY=admin-key-for-destructive-ops
221
- ```
222
-
223
- ### Adding read-only access via a proxy
224
-
225
- pyx-memory doesn't have a native "read-only API key." To implement read-only roles, put a reverse proxy (nginx, Caddy, API gateway) in front:
226
-
227
- ```nginx
228
- # nginx example: read-only role blocks POST/PUT/DELETE
229
- location /api/memory/ {
230
- # Read-only key can only GET
231
- if ($http_authorization = "Bearer readonly-key") {
232
- limit_except GET {
233
- deny all;
234
- }
235
- }
236
- proxy_pass http://memory:7822;
237
- }
238
- ```
239
-
240
- Or implement it in your application layer:
241
-
242
- ```typescript
243
- // Application-level access control middleware
244
- function memoryAccessMiddleware(req: Request, userRole: string): boolean {
245
- const method = req.method;
246
- const isWrite = method === 'POST' || method === 'PUT' || method === 'DELETE';
247
-
248
- if (userRole === 'viewer' && isWrite) {
249
- throw new ForbiddenError('Read-only access — cannot modify memory');
250
- }
251
- if (userRole === 'editor' && req.url.includes('/consolidate')) {
252
- throw new ForbiddenError('Editors cannot run consolidation');
253
- }
254
- return true;
255
- }
256
- ```
257
-
258
- ---
259
-
260
- ## Pattern 14: Full Production Stack
261
-
262
- Combines all patterns for a production multi-tenant deployment with sensitivity, encryption, and the built-in two-tier API key model (`API_KEY` for read+write, `ADMIN_API_KEY` for destructive ops). For fine-grained per-user/per-namespace access inside a tenant, layer Pattern 15 (ReBAC) on top of this base.
263
-
264
- ### Server config
265
-
266
- ```yaml
267
- # docker-compose.yaml
268
- services:
269
- memory:
270
- image: ghcr.io/pyx-corp/pyx-memory-v1:latest
271
- environment:
272
- - DATA_DIR=/data
273
- - API_KEY=${MEMORY_API_KEY}
274
- - ADMIN_API_KEY=${MEMORY_ADMIN_KEY}
275
- - TENANT_MODE=multi
276
- - SENSITIVITY_POLICY=encrypt
277
- - ENCRYPTION_KEY=${ENCRYPTION_KEY}
278
- - PII_POLICY=flag
279
- - RATE_LIMIT_RPM=120
280
- - CORS_ORIGIN=https://app.example.com
281
- - NODE_ENV=production
282
- ```
283
-
284
- ### Client setup per role
285
-
286
- ```typescript
287
- import { MemoryClient } from '@pyx-memory/client';
288
-
289
- const MEMORY_URL = 'http://memory:7822';
290
-
291
- // Admin client — full access, destructive ops allowed
292
- function createAdminClient(tenantId: string) {
293
- return new MemoryClient(MEMORY_URL, {
294
- apiKey: process.env.MEMORY_ADMIN_KEY,
295
- defaultHeaders: {
296
- 'X-Tenant-Id': tenantId,
297
- 'X-Caller-Access-Level': 'secret',
298
- },
299
- });
300
- }
301
-
302
- // Agent client — read + write, scoped to agent + tenant
303
- function createAgentClient(tenantId: string, agentId: string) {
304
- return new MemoryClient(MEMORY_URL, {
305
- apiKey: process.env.MEMORY_API_KEY,
306
- defaultHeaders: {
307
- 'X-Tenant-Id': tenantId,
308
- 'X-Caller-Access-Level': 'internal', // can't see secret entries
309
- },
310
- });
311
- // Note: agentId is passed per-request, not via headers
312
- }
313
-
314
- // Dashboard client — read-only (enforce at application layer)
315
- function createDashboardClient(tenantId: string, userId: string) {
316
- return new MemoryClient(MEMORY_URL, {
317
- apiKey: process.env.MEMORY_API_KEY,
318
- defaultHeaders: {
319
- 'X-Tenant-Id': tenantId,
320
- 'X-User-Id': userId,
321
- 'X-Caller-Access-Level': 'public', // most restricted view
322
- },
323
- });
324
- }
325
- ```
326
-
327
- ### What each role sees
328
-
329
- | Role | tenantId | agentId | maxSensitivity | Write | Delete/Admin |
330
- |------|----------|---------|----------------|-------|-------------|
331
- | Admin | scoped | all | secret | yes | yes (ADMIN_API_KEY) |
332
- | Agent | scoped | scoped | internal | yes | no |
333
- | Dashboard | scoped | all | public | no (app-enforced) | no |
334
-
335
- ---
336
-
337
- ## Pattern 15: Built-in ReBAC (namespaces + tuples + AuthzPlan)
338
-
339
- For fine-grained access control inside a tenant — "alice can read project-X but not project-Y", "team-eng has editor on engineering/*", "revoke without entry rewrite" — pyx-memory ships first-class ReBAC primitives. No external policy service required for v1; the model is Zanzibar-aligned so you can later export to OpenFGA / SpiceDB without changing application code.
340
-
341
- ### The four primitives
342
-
343
- | Resource | Role |
344
- |----------|------|
345
- | `namespace` | The unit you grant access to (folders, projects, channels). Hierarchical via `parentId`. Granting on a parent transitively covers descendants. |
346
- | `principal` | A subject — `user`, `team`, `group`, `agent`, `service`, or per-tenant `everyone`. Identified by `(tenantId, kind, externalId)`. |
347
- | `principal_member` | Membership edge `member ∈ group`. Group-of-groups supported (cycles rejected, max-depth-8 namespaces). |
348
- | `authz_tuple` | The grant: `(subject, relation, object)` — e.g. `(user:alice, viewer, namespace:proj-acme)`. Built-in rewrite: `owner ⊇ editor ⊇ viewer`. |
349
-
350
- ### Server config
351
-
352
- ```yaml
353
- # docker-compose.yaml
354
- environment:
355
- - TENANT_MODE=multi
356
- - API_KEY=${MEMORY_API_KEY}
357
- - ADMIN_API_KEY=${MEMORY_ADMIN_KEY} # required to manage namespaces / tuples
358
- ```
359
-
360
- ### Manage namespaces, principals, and tuples (admin API)
361
-
362
- All `/api/admin/*` routes require `ADMIN_API_KEY` and `X-Tenant-Id`.
363
-
364
- ```bash
365
- # 1. Create a namespace (the resource you'll grant access to)
366
- curl -X POST http://memory:7822/api/admin/namespaces \
367
- -H "Authorization: Bearer $ADMIN_KEY" \
368
- -H "X-Tenant-Id: tenant-acme" \
369
- -H "Content-Type: application/json" \
370
- -d '{"name":"engineering"}'
371
- # → {"id":"<NS_ID>", ...}
372
-
373
- # 2. Register a principal (idempotent on tenant + kind + externalId)
374
- curl -X POST http://memory:7822/api/admin/principals \
375
- -H "Authorization: Bearer $ADMIN_KEY" \
376
- -H "X-Tenant-Id: tenant-acme" \
377
- -H "Content-Type: application/json" \
378
- -d '{"kind":"user","externalId":"alice","displayName":"Alice"}'
379
- # → {"id":"<ALICE_ID>", ...}
380
-
381
- # 3. Grant alice viewer on engineering
382
- curl -X POST http://memory:7822/api/admin/authz-tuples \
383
- -H "Authorization: Bearer $ADMIN_KEY" \
384
- -H "X-Tenant-Id: tenant-acme" \
385
- -H "Content-Type: application/json" \
386
- -d '{
387
- "subjectKind":"user","subjectId":"<ALICE_ID>",
388
- "relation":"viewer",
389
- "objectKind":"namespace","objectId":"<NS_ID>"
390
- }'
391
-
392
- # 4. Revoke (one row delete — no entry rewrite, takes effect immediately)
393
- curl -X DELETE http://memory:7822/api/admin/authz-tuples \
394
- -H "Authorization: Bearer $ADMIN_KEY" \
395
- -H "X-Tenant-Id: tenant-acme" \
396
- -H "Content-Type: application/json" \
397
- -d '{ ...same body as the grant... }'
398
- ```
399
-
400
- ### Make something public to the whole tenant
401
-
402
- ```bash
403
- # Resolve the per-tenant `everyone` principal (auto-created)
404
- curl -X POST http://memory:7822/api/admin/principals \
405
- -H "Authorization: Bearer $ADMIN_KEY" \
406
- -H "X-Tenant-Id: tenant-acme" \
407
- -H "Content-Type: application/json" \
408
- -d '{"kind":"everyone","externalId":"_everyone"}'
409
-
410
- # Grant `everyone` viewer on the announcements namespace
411
- curl -X POST http://memory:7822/api/admin/authz-tuples \
412
- -H "Authorization: Bearer $ADMIN_KEY" \
413
- -H "X-Tenant-Id: tenant-acme" \
414
- -H "Content-Type: application/json" \
415
- -d '{
416
- "subjectKind":"everyone","subjectId":"<EVERYONE_ID>",
417
- "relation":"viewer",
418
- "objectKind":"namespace","objectId":"<NS_ID>"
419
- }'
420
- ```
421
-
422
- Reversing later is the same — delete the tuple, visibility flips back the next request.
423
-
424
- ### Search-time enforcement
425
-
426
- Pass the calling principal on `Memory.search()`. The server computes an `AuthzPlan` (visible namespaces + cached revision) BEFORE any retrieval source fans out. SQLite/FTS, LanceDB vector search, and the graph engine all apply the plan as a native pre-filter — forbidden entries never enter RRF fusion or reranker scoring (which would skew normalization for everyone).
427
-
428
- ```typescript
429
- const result = await memory.search({
430
- query: 'Q4 revenue',
431
- strategy: 'hybrid',
432
- principal: {
433
- tenantId: 'tenant-acme',
434
- principalId: 'alice',
435
- kind: 'user',
436
- },
437
- });
438
- // result.entries contains only namespaces alice can `view`,
439
- // plus legacy NULL-namespace entries (tenant-root bucket).
440
- ```
441
-
442
- ### Legacy compatibility
443
-
444
- Entries with `namespace_id IS NULL` (the default before ReBAC) remain visible to anyone with tenant access. You opt entries into ReBAC by setting `namespaceId` at ingest:
445
-
446
- ```typescript
447
- await memory.store({
448
- content: 'Q4 revenue projections',
449
- type: 'long-term',
450
- metadata: {},
451
- namespaceId: '<NS_ID>',
452
- });
453
- ```
454
-
455
- HTTP ingest accepts the same resource coordinate on JSON and file uploads:
456
-
457
- ```bash
458
- curl -X POST "$MEMORY_URL/api/memory/ingest" \
459
- -H "Authorization: Bearer $API_KEY" \
460
- -H "X-Tenant-Id: tenant-acme" \
461
- -H "X-Namespace-Id: <NS_ID>" \
462
- -H "Content-Type: application/json" \
463
- -d '{"content":"Q4 revenue projections","type":"long-term"}'
464
-
465
- curl -N -X POST "$MEMORY_URL/api/memory/ingest/file" \
466
- -H "Authorization: Bearer $API_KEY" \
467
- -H "X-Tenant-Id: tenant-acme" \
468
- -H "X-Namespace-Id: <NS_ID>" \
469
- -F "file=@report.pdf"
470
- ```
471
-
472
- `X-Namespace-Id` is canonical when both header and body/form field are sent; conflicting values return `400 namespace_id_conflict`. A missing or cross-tenant namespace returns `404 namespace_not_found`.
473
-
474
- No bulk migration is required — legacy data keeps working unchanged.
475
-
476
- ### Migrating legacy entries (v0.16.1)
477
-
478
- When you _do_ want to move pre-ReBAC entries (or reorganize an existing namespace tree), the admin entry-move API coordinates SQLite + vector + graph for you:
479
-
480
- ```bash
481
- # Single entry: move one row to a target namespace.
482
- curl -X POST "$ENDPOINT/api/admin/entries/$ENTRY_ID/move" \
483
- -H "Authorization: Bearer $ADMIN_API_KEY" \
484
- -H "X-Tenant-Id: $TENANT" \
485
- -H "Content-Type: application/json" \
486
- -d '{"namespaceId": "<NS_ID>"}'
487
- # null reverts to tenant-root (the legacy / pre-ReBAC bucket).
488
- ```
489
-
490
- Batch moves accept a filter and return cursor-paginated `MoveResult` rows. Always preview with `dryRun: true` first — the same filter + cursor drives both the dryRun preview and the execute pass, so what you see in dryRun is exactly what executes:
491
-
492
- ```bash
493
- # Dry-run: see which rows would move, without touching any store.
494
- curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
495
- -H "Authorization: Bearer $ADMIN_API_KEY" \
496
- -H "X-Tenant-Id: $TENANT" \
497
- -H "Content-Type: application/json" \
498
- -d '{
499
- "filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
500
- "target": { "namespaceId": "<NS_ID>" },
501
- "dryRun": true,
502
- "limit": 100
503
- }'
504
- # Response includes results[] + nextCursor for paging.
505
-
506
- # Execute: same body without dryRun (or dryRun: false).
507
- curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
508
- -H "Authorization: Bearer $ADMIN_API_KEY" \
509
- -H "X-Tenant-Id: $TENANT" \
510
- -H "Content-Type: application/json" \
511
- -d '{
512
- "filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
513
- "target": { "namespaceId": "<NS_ID>" },
514
- "limit": 100
515
- }'
516
- ```
517
-
518
- What the move guarantees:
519
-
520
- - **3-store coordination.** SQLite + vector + graph (when registered) all see the new `namespace_id`. On per-store failure the earlier writes are reverted in reverse order so partial states do not survive — the `MoveResult.failureReason` tells you which store rejected the move (`vector_update_failed`, `graph_update_failed`, etc.).
521
- - **Cross-tenant safety.** A move whose source entry belongs to a different tenant is rejected with `cross_tenant_forbidden`. A move whose target namespace belongs to a different tenant is rejected with `target_namespace_not_found` — same code as a missing namespace, deliberately, to avoid telling the caller "this namespace exists but somewhere else" (existence-disclosure guard).
522
- - **Authz revision bump.** Every successful move bumps the per-tenant AuthzPlan revision, so any cached plan keyed on `(principal, revision)` invalidates immediately.
523
- - **Footgun guard on batch.** `filter.fromNamespaceId` is required — there is no "move everything I can see" shorthand by design.
524
-
525
- `MoveFailureReason.compensation_failed` is the only deterministic-manual-recovery case: the forward write succeeded but the rollback failed. The response includes both error messages so an operator has enough detail to inspect each store directly.
526
-
527
- ### Strict topology isolation (v0.17.0)
528
-
529
- By default a namespace is `shared` — its edges remain visible across the tenant whenever AuthzPlan grants access via any path. The KG layer dedupes nodes globally so edges from different namespaces co-exist on the same node, and `shared` lets that cross-namespace visibility through.
530
-
531
- Set a namespace to `strict` and its edges become invisible to any principal who does not hold an explicit grant on it, even when the underlying KG node is reachable through a shared neighbor. KG dedupe at the node stays intact — the strictness is enforced edge-by-edge in the AuthzPlan + graph traversal WHERE, not by splitting nodes.
532
-
533
- ```bash
534
- # Flip a namespace to strict.
535
- curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
536
- -H "Authorization: Bearer $ADMIN_API_KEY" \
537
- -H "X-Tenant-Id: $TENANT" \
538
- -H "Content-Type: application/json" \
539
- -d '{"isolation":"strict"}'
540
- # {"namespace": {... "isolation": "strict"}, "changed": true}
541
-
542
- # Flip back to shared.
543
- curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
544
- -H "Authorization: Bearer $ADMIN_API_KEY" \
545
- -H "X-Tenant-Id: $TENANT" \
546
- -H "Content-Type: application/json" \
547
- -d '{"isolation":"shared"}'
548
- ```
549
-
550
- Both flips bump the per-tenant AuthzPlan revision so cached plans invalidate on the next request — no manual cache invalidation step. A no-op flip (already in the requested mode) returns `changed: false` and leaves the revision untouched.
551
-
552
- What strict guarantees:
553
-
554
- - **Edge-level scoping.** SQLiteGraph WHERE clause + Neo4j post-filter drop edges whose `namespace_id` is in `AuthzPlan.forbiddenStrictNamespaceIds`. Legacy NULL-namespace edges (pre-v0.16.1 ingest) keep tenant-root visibility regardless.
555
- - **Bulk endpoint visibility.** `/api/memory/graph/relationships` filters at the HTTP boundary too. `/api/memory/graph/nodes` keeps only nodes that have at least one visible memoryEntryId, intersects the projection's memoryEntryIds with the visible set, and strips provenance properties (`source`, `sourceUrl`, `sourceTitle`, etc.) so a cross-namespace node cannot leak originating-namespace metadata via property bag.
556
- - **Hybrid RAG fail-loud.** Graph + community retrieval no longer swallow backend errors. A graph-store outage propagates as `MemorySearchError` so the operator sees it at the request boundary instead of as drifting RRF gaps.
557
-
558
- ### Idempotent admin mutations (v0.17.0)
559
-
560
- The audit-outbox sweeper retries 5xx mutations until each lands. Set `X-Idempotency-Key` on every admin mutation request so a retry on the same key returns the recorded response without re-executing the handler:
561
-
562
- ```bash
563
- KEY=$(uuidgen)
564
- curl -X POST "$ENDPOINT/api/admin/namespaces" \
565
- -H "Authorization: Bearer $ADMIN_API_KEY" \
566
- -H "X-Tenant-Id: $TENANT" \
567
- -H "X-Idempotency-Key: $KEY" \
568
- -H "Content-Type: application/json" \
569
- -d '{"name":"engineering"}'
570
- ```
571
-
572
- 24h TTL. GET / OPTIONS bypass the wrapper entirely (read-only methods are already safe to retry). Pre-existing entries are dedupe-keyed by `key` (not by request body), so a sweeper retry with the original payload + key replays the original response even if the underlying state has since changed.
573
-
574
- ### Why not metadata + post-filter?
575
-
576
- The pre-PR-D pattern was "tag entries with `metadata.allowedTeams`, filter results in your gateway". That breaks RAG retrieval:
577
-
578
- 1. **Ranking pollution** — RRF fusion and reranker interpolation normalize against the candidate set. If forbidden entries enter retrieval, they shift the scoring for the entries the caller IS allowed to see.
579
- 2. **Confidence corruption** — abstention scores depend on score variance over the candidate set. Hidden entries change the variance.
580
- 3. **Write amplification** — changing a role means rewriting metadata on every affected chunk.
581
-
582
- ReBAC tuples solve all three: pre-filter at the SQL/vector layer, single-row mutations, no entry rewrites.
583
-
584
- ### Next-scale: external PDP
585
-
586
- When you outgrow the in-process tuple store (~10M tuples, or you need cross-tenant sharing, or distributed enforcement), the tuple format is Zanzibar-compatible — export to OpenFGA or SpiceDB and swap the `AuthzPlan` compute path to call their `ListObjects` API. The retrieval-engine code does not change.