@pyxmate/memory 0.15.3 → 0.17.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/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { StoreInput as StoreInput$1, MemoryEntry as MemoryEntry$1, MemorySearchParams as MemorySearchParams$1, MemorySearchResult as MemorySearchResult$1, MemoryType as MemoryType$1, MemoryStats as MemoryStats$1, ExtractedImageMeta as ExtractedImageMeta$1, IngestEntity as IngestEntity$1, IngestRelationship as IngestRelationship$1, IngestEvent as IngestEvent$1, GraphNode as GraphNode$1, GraphTraversalResult as GraphTraversalResult$1 } from '@pyx-memory/shared';
1
+ import { StoreInput as StoreInput$1, MemoryEntry as MemoryEntry$1, MemorySearchParams as MemorySearchParams$1, MemorySearchResult as MemorySearchResult$1, MemoryType as MemoryType$1, PrincipalContext as PrincipalContext$1, MemoryStats as MemoryStats$1, ExtractedImageMeta as ExtractedImageMeta$1, IngestEntity as IngestEntity$1, IngestRelationship as IngestRelationship$1, IngestEvent as IngestEvent$1, GraphNode as GraphNode$1, GraphTraversalResult as GraphTraversalResult$1 } from '@pyx-memory/shared';
2
2
 
3
3
  /** Parameters for paginated entry listing. */
4
4
  interface MemoryListParams {
@@ -12,6 +12,13 @@ interface MemoryListParams {
12
12
  agentId?: string;
13
13
  /** Filter by tenant ID for multi-tenant isolation. */
14
14
  tenantId?: string;
15
+ /**
16
+ * Calling principal. When supplied, list applies the AuthzPlan
17
+ * visibility filter so entries in forbidden namespaces never reach
18
+ * the response. Single-tenant / library-direct callers may omit it
19
+ * and get the legacy tenant-only scope.
20
+ */
21
+ principal?: PrincipalContext$1;
15
22
  }
16
23
  /** Result of a paginated entry listing. */
17
24
  interface MemoryListResult {
@@ -30,6 +37,14 @@ interface TemporalQueryFilters {
30
37
  /** Options for scoping operations to a specific tenant. */
31
38
  interface TenantScopeOptions {
32
39
  tenantId?: string;
40
+ /**
41
+ * Calling principal. When supplied alongside or instead of tenantId,
42
+ * the operation applies the AuthzPlan visibility filter — get/delete
43
+ * will treat entries in forbidden namespaces as if they did not exist.
44
+ * Single-tenant / library-direct callers may omit it and get the
45
+ * legacy tenant-only scope.
46
+ */
47
+ principal?: PrincipalContext$1;
33
48
  }
34
49
  /** Abstract interface for memory systems (local or remote). */
35
50
  interface MemoryInterface {
@@ -337,6 +352,15 @@ interface MemoryEntry {
337
352
  userId?: string;
338
353
  /** Team/group ID within the tenant. */
339
354
  teamId?: string;
355
+ /**
356
+ * Namespace ID — the primary protected resource for ReBAC authorization.
357
+ * `undefined` (NULL on the row) means the entry lives in the legacy
358
+ * "tenant-root" bucket and is visible to anyone with tenant access (the
359
+ * pre-ReBAC posture). Setting `namespaceId` opts the entry into AuthzPlan-
360
+ * based filtering, where visibility is computed from `authz_tuples` for
361
+ * the calling principal.
362
+ */
363
+ namespaceId?: string;
340
364
  /**
341
365
  * When true, consolidation leaves this entry alone — it is never archived by
342
366
  * decay and never merged (nor merged-into) by semantic deduplication. Use for
@@ -365,6 +389,31 @@ interface MemorySearchParams {
365
389
  userId?: string;
366
390
  /** Team/group ID within the tenant. */
367
391
  teamId?: string;
392
+ /**
393
+ * AuthzPlan-derived visibility list. Populated internally by `Memory.search`
394
+ * after computing the plan from the calling principal — callers should
395
+ * NOT set this directly. Empty array means "no granted namespaces" (only
396
+ * legacy NULL-namespace entries are visible). `undefined` skips the
397
+ * filter (single-tenant / pre-ReBAC compat).
398
+ *
399
+ * "Search within this specific folder" UX is intentionally NOT supported
400
+ * via a singular `namespaceId` field for v1 — adding it would force
401
+ * intersection logic with the visibility list (and confused-deputy risk
402
+ * if the singular value isn't validated against the plan). Callers that
403
+ * need it can compute the singleton intersection client-side and pass
404
+ * `namespaceIds: [chosenNamespace]` after verifying access via the
405
+ * admin API.
406
+ */
407
+ namespaceIds?: string[];
408
+ /**
409
+ * AuthzPlan-derived list of strict-mode namespaces in the caller's
410
+ * tenant the principal CANNOT see (v0.17.0). Graph traversal MUST
411
+ * drop edges whose `namespace_id` matches the supplied set even when
412
+ * the surrounding KG node is otherwise reachable. Populated
413
+ * internally by `Memory.search` from `AuthzPlan.forbiddenStrict
414
+ * NamespaceIds` — callers should NOT set this directly.
415
+ */
416
+ forbiddenStrictNamespaceIds?: string[];
368
417
  }
369
418
  interface MemorySearchResult {
370
419
  entries: MemoryEntry[];
@@ -474,6 +523,8 @@ interface MemoryIngestRequest {
474
523
  userId?: string;
475
524
  /** Team/group ID within the tenant. */
476
525
  teamId?: string;
526
+ /** Namespace ID — see `MemoryEntry.namespaceId`. */
527
+ namespaceId?: string;
477
528
  /**
478
529
  * Exclude this entry from consolidation (decay-archive and semantic dedup).
479
530
  * See `MemoryEntry.pinned` for full semantics.
@@ -506,6 +557,13 @@ interface GraphRelationship {
506
557
  type: string;
507
558
  properties: Record<string, unknown>;
508
559
  memoryEntryId?: string;
560
+ /**
561
+ * Namespace_id provenance carried from the originating MemoryEntry.
562
+ * `null` (or absent) = legacy / tenant-root bucket. Foundation for the
563
+ * v0.17.0 strict traversal filter (the AuthzPlan compares this value
564
+ * edge-by-edge so KG dedupe at the node level can stay intact).
565
+ */
566
+ namespaceId?: string | null;
509
567
  }
510
568
  interface GraphTraversalResult {
511
569
  nodes: GraphNode[];
@@ -629,4 +687,156 @@ interface IngestErrorEvent {
629
687
  }
630
688
  type IngestEvent = IngestProgressEvent | IngestHeartbeatEvent | IngestResultEvent | IngestErrorEvent;
631
689
 
632
- export { type AgentId, type ApiResponse, type ConsolidationRunResult, DEFAULTS, EmbeddingProviderName, type EnrichmentCallbacks, type ExtendedMemoryInterface, type GraphFailureMode, type GraphNode, type GraphRelationship, type GraphTraversalResult, type IngestEntity, type IngestErrorEvent, type IngestEvent, type IngestFileOptions, type IngestHeartbeatEvent, type IngestProgressEvent, type IngestRelationship, type IngestResultEvent, type IngestStage, type IngestionResult, MemoryClient, type MemoryClientOptions, type MemoryEntry, type MemoryIngestRequest, type MemoryInterface, type MemoryListParams, type MemoryListResult, type MemorySearchParams, type MemorySearchResult, MemoryServerError, type MemoryStats, MemoryType, RAGStrategy, SensitivityLevel, type StoreInput, StoreTarget, type TemporalQueryFilters, type TenantScopeOptions, type Timestamp, VectorProvider };
690
+ /**
691
+ * Namespace topology-isolation modes for v0.17.0.
692
+ *
693
+ * `shared` (default, pre-existing posture): a namespace's edges/nodes
694
+ * remain visible across the tenant whenever AuthzPlan grants access via
695
+ * any path. The KG layer dedupes nodes globally so edges from different
696
+ * namespaces can co-exist on the same node — `shared` lets that
697
+ * cross-namespace visibility through.
698
+ *
699
+ * `strict`: edges attributed to this namespace are invisible to any
700
+ * principal who does not hold an explicit grant on it, even when the
701
+ * underlying KG node is shared with other namespaces. The strictness is
702
+ * enforced at traversal time by AuthzPlan.forbiddenStrictNamespaceIds —
703
+ * the planning layer collects every strict namespace in the tenant the
704
+ * caller does NOT see, and the graph WHERE filter excludes edges whose
705
+ * `namespace_id` matches that set. KG dedupe at the node level stays
706
+ * intact (Codex round 3 architectural guarantee).
707
+ */
708
+ declare const NamespaceIsolation: {
709
+ readonly SHARED: "shared";
710
+ readonly STRICT: "strict";
711
+ };
712
+ type NamespaceIsolation = (typeof NamespaceIsolation)[keyof typeof NamespaceIsolation];
713
+
714
+ /**
715
+ * Move-related types for the entry-move admin API (v0.16.1).
716
+ *
717
+ * The admin API lets operators reassign existing entries to namespaces —
718
+ * needed for migrating legacy NULL-namespace data and for periodic
719
+ * reorganization. v0.16.1 is intra-tenant only; cross-tenant moves stay
720
+ * forbidden as a contract (see `MoveFailureReason.CROSS_TENANT_FORBIDDEN`).
721
+ */
722
+ /**
723
+ * Destination of a move. Used by both single and batch endpoints.
724
+ *
725
+ * Wrapping a single field in an interface (rather than a bare
726
+ * `string | null` alias) leaves room to extend the destination spec
727
+ * later (e.g., cascade options) without breaking call sites.
728
+ */
729
+ interface MoveTarget {
730
+ /**
731
+ * Target namespace ID. `null` = tenant-root (the legacy
732
+ * NULL-namespace bucket, visible to anyone with tenant access).
733
+ * A non-null string opts the entry into AuthzPlan filtering.
734
+ */
735
+ namespaceId: string | null;
736
+ }
737
+ /**
738
+ * Selection criteria for batch move. `fromNamespaceId` is required —
739
+ * forcing operators to narrow the source explicitly avoids the
740
+ * "move every entry I can see" footgun.
741
+ */
742
+ interface MoveEntriesFilter {
743
+ /** Source namespace. `null` = tenant-root (legacy NULL-namespace bucket). */
744
+ fromNamespaceId: string | null;
745
+ /** Restrict to this explicit set of entry IDs (within `fromNamespaceId`). */
746
+ entryIds?: string[];
747
+ /** Narrow further within `fromNamespaceId`. */
748
+ agentId?: string;
749
+ teamId?: string;
750
+ userId?: string;
751
+ source?: string;
752
+ /** Cap rows per call. Server enforces a hard max. */
753
+ limit?: number;
754
+ /** Opaque cursor returned by a prior call. */
755
+ cursor?: string;
756
+ }
757
+ /**
758
+ * Why a single-entry move failed. Set on `MoveResult.failureReason`
759
+ * when `success === false`.
760
+ */
761
+ declare const MoveFailureReason: {
762
+ /** Entry not found in the caller's tenant. */
763
+ readonly NOT_FOUND: "not_found";
764
+ /** Move would cross tenant boundary (always forbidden). */
765
+ readonly CROSS_TENANT_FORBIDDEN: "cross_tenant_forbidden";
766
+ /** Target namespace ID does not exist in the caller's tenant. */
767
+ readonly TARGET_NAMESPACE_NOT_FOUND: "target_namespace_not_found";
768
+ /** SQLite metadata update failed; no compensation needed. */
769
+ readonly SQLITE_UPDATE_FAILED: "sqlite_update_failed";
770
+ /** Vector store metadata update failed; SQLite reverted. */
771
+ readonly VECTOR_UPDATE_FAILED: "vector_update_failed";
772
+ /** Graph edge namespace update failed; SQLite + vector reverted. */
773
+ readonly GRAPH_UPDATE_FAILED: "graph_update_failed";
774
+ /** Compensation itself failed — manual intervention required. */
775
+ readonly COMPENSATION_FAILED: "compensation_failed";
776
+ };
777
+ type MoveFailureReason = (typeof MoveFailureReason)[keyof typeof MoveFailureReason];
778
+ /**
779
+ * Per-entry result of a move. Batch endpoints return one per input
780
+ * entry; the single endpoint returns exactly one.
781
+ */
782
+ interface MoveResult {
783
+ entryId: string;
784
+ success: boolean;
785
+ /** Source namespace before the move. `null` = tenant-root. */
786
+ fromNamespaceId?: string | null;
787
+ /** Destination namespace after the move. `null` = tenant-root. */
788
+ toNamespaceId?: string | null;
789
+ /** Present when `success === false`. */
790
+ failureReason?: MoveFailureReason;
791
+ /** Human-readable detail when `success === false`. */
792
+ error?: string;
793
+ }
794
+
795
+ /**
796
+ * Caller identity for ReBAC authorization.
797
+ *
798
+ * The canonical "subject" in Zanzibar terms — passed alongside requests so the
799
+ * memory layer can compute an AuthzPlan (visible namespaces + entry overrides)
800
+ * before any retrieval source fans out.
801
+ *
802
+ * Identity comes from authenticated request context (X-Tenant-Id / auth token
803
+ * claims), never from request bodies or multipart form fields. The legacy
804
+ * `userId` / `teamId` / `agentId` columns on MemoryEntry remain for audit and
805
+ * legacy filters but MUST NOT drive authorization decisions — they are
806
+ * collision-prone aliases (a service principal sharing an ID with a human
807
+ * user has no protection against confused-deputy attacks).
808
+ *
809
+ * Sensitivity / clearance is intentionally NOT on this object. It is a
810
+ * MAC-style classification, orthogonal to RBAC, and continues to be carried
811
+ * via the `X-Caller-Access-Level` header → `MemorySearchParams.maxSensitivity`.
812
+ * Conflating the two couples future changes (e.g. per-namespace classification
813
+ * rules) to identity propagation.
814
+ */
815
+ interface PrincipalContext {
816
+ /**
817
+ * Hard isolation boundary. Required even in single-tenant deployments —
818
+ * single-mode passes a stable sentinel (`SINGLE_TENANT_ID`) so authz code
819
+ * paths look identical regardless of mode.
820
+ */
821
+ tenantId: string;
822
+ /**
823
+ * Stable subject ID (within the tenant). For humans this is the userId;
824
+ * for AI runtimes it is the agentId; for system actors it is a service
825
+ * identifier. Combined with `kind`, forms the Zanzibar subject coordinate
826
+ * `<kind>:<principalId>`.
827
+ */
828
+ principalId: string;
829
+ /**
830
+ * Subject namespace. Distinguishes humans from AI agents from internal
831
+ * services so a userId/agentId/serviceId collision cannot grant
832
+ * unintended access.
833
+ * - `user`: human end user
834
+ * - `agent`: AI runtime acting on a user's behalf or autonomously
835
+ * - `service`: non-AI internal system (cron, ETL, admin tooling)
836
+ */
837
+ kind: 'user' | 'agent' | 'service';
838
+ }
839
+ /** Sentinel tenant ID used in single-tenant deployments. */
840
+ declare const SINGLE_TENANT_ID = "_single";
841
+
842
+ export { type AgentId, type ApiResponse, type ConsolidationRunResult, DEFAULTS, EmbeddingProviderName, type EnrichmentCallbacks, type ExtendedMemoryInterface, type GraphFailureMode, type GraphNode, type GraphRelationship, type GraphTraversalResult, type IngestEntity, type IngestErrorEvent, type IngestEvent, type IngestFileOptions, type IngestHeartbeatEvent, type IngestProgressEvent, type IngestRelationship, type IngestResultEvent, type IngestStage, type IngestionResult, MemoryClient, type MemoryClientOptions, type MemoryEntry, type MemoryIngestRequest, type MemoryInterface, type MemoryListParams, type MemoryListResult, type MemorySearchParams, type MemorySearchResult, MemoryServerError, type MemoryStats, MemoryType, type MoveEntriesFilter, MoveFailureReason, type MoveResult, type MoveTarget, NamespaceIsolation, type PrincipalContext, RAGStrategy, SINGLE_TENANT_ID, SensitivityLevel, type StoreInput, StoreTarget, type TemporalQueryFilters, type TenantScopeOptions, type Timestamp, VectorProvider };
package/dist/index.mjs CHANGED
@@ -10,6 +10,12 @@ var DEFAULTS = {
10
10
  MEMORY_SERVER_PORT: 7822
11
11
  };
12
12
 
13
+ // ../shared/src/types/isolation.ts
14
+ var NamespaceIsolation = {
15
+ SHARED: "shared",
16
+ STRICT: "strict"
17
+ };
18
+
13
19
  // ../shared/src/types/memory.ts
14
20
  var MemoryType = {
15
21
  SHORT_TERM: "short-term",
@@ -45,13 +51,37 @@ var StoreTarget = {
45
51
  VECTOR: "vector",
46
52
  GRAPH: "graph"
47
53
  };
54
+
55
+ // ../shared/src/types/move.ts
56
+ var MoveFailureReason = {
57
+ /** Entry not found in the caller's tenant. */
58
+ NOT_FOUND: "not_found",
59
+ /** Move would cross tenant boundary (always forbidden). */
60
+ CROSS_TENANT_FORBIDDEN: "cross_tenant_forbidden",
61
+ /** Target namespace ID does not exist in the caller's tenant. */
62
+ TARGET_NAMESPACE_NOT_FOUND: "target_namespace_not_found",
63
+ /** SQLite metadata update failed; no compensation needed. */
64
+ SQLITE_UPDATE_FAILED: "sqlite_update_failed",
65
+ /** Vector store metadata update failed; SQLite reverted. */
66
+ VECTOR_UPDATE_FAILED: "vector_update_failed",
67
+ /** Graph edge namespace update failed; SQLite + vector reverted. */
68
+ GRAPH_UPDATE_FAILED: "graph_update_failed",
69
+ /** Compensation itself failed — manual intervention required. */
70
+ COMPENSATION_FAILED: "compensation_failed"
71
+ };
72
+
73
+ // ../shared/src/types/principal.ts
74
+ var SINGLE_TENANT_ID = "_single";
48
75
  export {
49
76
  DEFAULTS,
50
77
  EmbeddingProviderName,
51
78
  MemoryClient,
52
79
  MemoryServerError,
53
80
  MemoryType,
81
+ MoveFailureReason,
82
+ NamespaceIsolation,
54
83
  RAGStrategy,
84
+ SINGLE_TENANT_ID,
55
85
  SensitivityLevel,
56
86
  StoreTarget,
57
87
  VectorProvider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyxmate/memory",
3
- "version": "0.15.3",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "SDK for pyx-memory — Memory as a Service for AI agents",
6
6
  "license": "MIT",
@@ -7,14 +7,17 @@ description: >
7
7
  files or images, or search for what was discussed before. Also use when
8
8
  integrating the pyx-memory SDK into code, working with MemoryClient, or using
9
9
  the HTTP API. Covers multi-tenant isolation (tenantId, TENANT_MODE),
10
+ fine-grained ReBAC (namespaces, principals, authz tuples, AuthzPlan),
10
11
  sensitivity classification, encryption at rest, and confidence/abstention.
11
12
  Triggers on: 'remember', 'recall', 'store memory', 'search memory',
12
13
  'ingest file', 'store this image', 'remember this document',
13
14
  'what did we decide', 'pyx-memory', 'memory', 'MemoryClient',
14
15
  'integrate memory', 'memory consolidation', 'multi-tenant',
15
16
  'tenant isolation', 'sensitivity', 'encryption', 'confidence',
16
- 'abstention', 'access control', 'RBAC', 'read-only', 'permissions',
17
- 'agent isolation', 'per-agent memory', 'role-based access'.
17
+ 'abstention', 'access control', 'RBAC', 'ReBAC', 'Zanzibar',
18
+ 'namespace', 'principal', 'authz tuple', 'permission', 'permissions',
19
+ 'role', 'role-based access', 'read-only', 'agent isolation',
20
+ 'per-agent memory'.
18
21
  allowed-tools: Read, Grep, Glob, Edit, Write, Bash(curl *)
19
22
  argument-hint: "[store|search|ingest] <content or query>"
20
23
  ---
@@ -126,7 +129,7 @@ For integrating pyx-memory into TypeScript/Bun projects, see the reference docs:
126
129
  |---|---|
127
130
  | Wire pyx-memory into a consumer project | [patterns/consumer.md](patterns/consumer.md) |
128
131
  | Set up embedded memory (full features) | [patterns/embedded.md](patterns/embedded.md) |
129
- | Multi-tenant, RBAC, sensitivity, encryption | [patterns/access-control.md](patterns/access-control.md) |
132
+ | Multi-tenant, ReBAC (namespaces + tuples), sensitivity, encryption | [patterns/access-control.md](patterns/access-control.md) |
130
133
  | SDK quick start, package map, DO/DON'T | [reference/sdk-guide.md](reference/sdk-guide.md) |
131
134
  | HTTP API endpoints | [reference/http-api.md](reference/http-api.md) |
132
135
  | Type signatures and interfaces | [reference/types.md](reference/types.md) |
@@ -9,7 +9,7 @@ Production patterns for restricting who can read and write which memories.
9
9
  - [Pattern 12: Sensitivity-Based Read Restriction](#pattern-12-sensitivity-based-read-restriction)
10
10
  - [Pattern 13: Read-Only vs Read-Write Access](#pattern-13-read-only-vs-read-write-access)
11
11
  - [Pattern 14: Full Production Stack](#pattern-14-full-production-stack)
12
- - [Architecture: Access Policy Layer](#architecture-access-policy-layer)
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
13
 
14
14
  ---
15
15
 
@@ -22,6 +22,9 @@ Need to isolate agents from each other?
22
22
  Need to isolate organizations/customers?
23
23
  → Use TENANT_MODE=multi + X-Tenant-Id (Pattern 11)
24
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
+
25
28
  Need to hide sensitive data from some users?
26
29
  → Use sensitivity classification + X-Caller-Access-Level (Pattern 12)
27
30
 
@@ -29,7 +32,7 @@ Need read-only vs read-write roles?
29
32
  → Use API_KEY + ADMIN_API_KEY + an API gateway (Pattern 13)
30
33
 
31
34
  Need all of the above?
32
- → Pattern 14 (full production stack)
35
+ → Pattern 14 (full production stack) + Pattern 15 (ReBAC)
33
36
  ```
34
37
 
35
38
  ---
@@ -256,7 +259,7 @@ function memoryAccessMiddleware(req: Request, userRole: string): boolean {
256
259
 
257
260
  ## Pattern 14: Full Production Stack
258
261
 
259
- Combines all patterns for a production multi-tenant deployment with sensitivity, encryption, and role-based access.
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.
260
263
 
261
264
  ### Server config
262
265
 
@@ -331,51 +334,234 @@ function createDashboardClient(tenantId: string, userId: string) {
331
334
 
332
335
  ---
333
336
 
334
- ## Architecture: Access Policy Layer
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
335
351
 
336
- For organizations that need true RBAC (role-based access control) with fine-grained per-entry permissions, the recommended architecture adds a policy layer between your application and pyx-memory:
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
+ ```
337
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... }'
338
398
  ```
339
- User/Agent Request
340
-
341
- [Your Application]
342
-
343
- [Access Policy Layer] ← checks role + scope before forwarding
344
-
345
- [pyx-memory] ← stores data, unaware of permissions
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
+ }'
346
420
  ```
347
421
 
348
- ### Why this is better than building ACL into the memory store
422
+ Reversing later is the same delete the tuple, visibility flips back the next request.
349
423
 
350
- 1. **Separation of concerns** — memory stays fast and simple (store/retrieve). Access logic lives in your application where business rules belong.
351
- 2. **Flexibility** — you can change access rules without migrating data or changing the memory schema.
352
- 3. **Auditability** — policy decisions are logged at the application layer, not buried in storage internals.
424
+ ### Search-time enforcement
353
425
 
354
- ### Implementation approach
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:
355
445
 
356
446
  ```typescript
357
- // Define scopes as metadata on memory entries
358
447
  await memory.store({
359
448
  content: 'Q4 revenue projections',
360
449
  type: 'long-term',
361
- metadata: {
362
- scope: 'finance:confidential', // your custom scope tag
363
- allowedTeams: ['finance', 'exec'], // who can read
364
- allowedRoles: ['analyst', 'admin'], // which roles
365
- },
450
+ metadata: {},
451
+ namespaceId: '<NS_ID>',
366
452
  });
453
+ ```
367
454
 
368
- // In your API gateway, filter results based on caller identity
369
- function filterByAccess(entries: MemoryEntry[], caller: CallerIdentity): MemoryEntry[] {
370
- return entries.filter(entry => {
371
- const meta = entry.metadata;
372
- if (!meta.allowedTeams) return true; // no restriction = public
373
- return (
374
- meta.allowedTeams.includes(caller.teamId) ||
375
- meta.allowedRoles.includes(caller.role)
376
- );
377
- });
378
- }
455
+ No bulk migration is required legacy data keeps working unchanged.
456
+
457
+ ### Migrating legacy entries (v0.16.1)
458
+
459
+ 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:
460
+
461
+ ```bash
462
+ # Single entry: move one row to a target namespace.
463
+ curl -X POST "$ENDPOINT/api/admin/entries/$ENTRY_ID/move" \
464
+ -H "Authorization: Bearer $ADMIN_API_KEY" \
465
+ -H "X-Tenant-Id: $TENANT" \
466
+ -H "Content-Type: application/json" \
467
+ -d '{"namespaceId": "<NS_ID>"}'
468
+ # null reverts to tenant-root (the legacy / pre-ReBAC bucket).
469
+ ```
470
+
471
+ 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:
472
+
473
+ ```bash
474
+ # Dry-run: see which rows would move, without touching any store.
475
+ curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
476
+ -H "Authorization: Bearer $ADMIN_API_KEY" \
477
+ -H "X-Tenant-Id: $TENANT" \
478
+ -H "Content-Type: application/json" \
479
+ -d '{
480
+ "filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
481
+ "target": { "namespaceId": "<NS_ID>" },
482
+ "dryRun": true,
483
+ "limit": 100
484
+ }'
485
+ # Response includes results[] + nextCursor for paging.
486
+
487
+ # Execute: same body without dryRun (or dryRun: false).
488
+ curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
489
+ -H "Authorization: Bearer $ADMIN_API_KEY" \
490
+ -H "X-Tenant-Id: $TENANT" \
491
+ -H "Content-Type: application/json" \
492
+ -d '{
493
+ "filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
494
+ "target": { "namespaceId": "<NS_ID>" },
495
+ "limit": 100
496
+ }'
379
497
  ```
380
498
 
381
- This approach is not built into pyx-memory because access policies are inherently application-specific. The memory store provides the building blocks (tenant isolation, sensitivity classification, metadata storage) — your application composes them into the access model that fits your organization.
499
+ What the move guarantees:
500
+
501
+ - **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.).
502
+ - **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).
503
+ - **Authz revision bump.** Every successful move bumps the per-tenant AuthzPlan revision, so any cached plan keyed on `(principal, revision)` invalidates immediately.
504
+ - **Footgun guard on batch.** `filter.fromNamespaceId` is required — there is no "move everything I can see" shorthand by design.
505
+
506
+ `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.
507
+
508
+ ### Strict topology isolation (v0.17.0)
509
+
510
+ 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.
511
+
512
+ 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.
513
+
514
+ ```bash
515
+ # Flip a namespace to strict.
516
+ curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
517
+ -H "Authorization: Bearer $ADMIN_API_KEY" \
518
+ -H "X-Tenant-Id: $TENANT" \
519
+ -H "Content-Type: application/json" \
520
+ -d '{"isolation":"strict"}'
521
+ # {"namespace": {... "isolation": "strict"}, "changed": true}
522
+
523
+ # Flip back to shared.
524
+ curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
525
+ -H "Authorization: Bearer $ADMIN_API_KEY" \
526
+ -H "X-Tenant-Id: $TENANT" \
527
+ -H "Content-Type: application/json" \
528
+ -d '{"isolation":"shared"}'
529
+ ```
530
+
531
+ 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.
532
+
533
+ What strict guarantees:
534
+
535
+ - **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.
536
+ - **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.
537
+ - **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.
538
+
539
+ ### Idempotent admin mutations (v0.17.0)
540
+
541
+ 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:
542
+
543
+ ```bash
544
+ KEY=$(uuidgen)
545
+ curl -X POST "$ENDPOINT/api/admin/namespaces" \
546
+ -H "Authorization: Bearer $ADMIN_API_KEY" \
547
+ -H "X-Tenant-Id: $TENANT" \
548
+ -H "X-Idempotency-Key: $KEY" \
549
+ -H "Content-Type: application/json" \
550
+ -d '{"name":"engineering"}'
551
+ ```
552
+
553
+ 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.
554
+
555
+ ### Why not metadata + post-filter?
556
+
557
+ The pre-PR-D pattern was "tag entries with `metadata.allowedTeams`, filter results in your gateway". That breaks RAG retrieval:
558
+
559
+ 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.
560
+ 2. **Confidence corruption** — abstention scores depend on score variance over the candidate set. Hidden entries change the variance.
561
+ 3. **Write amplification** — changing a role means rewriting metadata on every affected chunk.
562
+
563
+ ReBAC tuples solve all three: pre-filter at the SQL/vector layer, single-row mutations, no entry rewrites.
564
+
565
+ ### Next-scale: external PDP
566
+
567
+ 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.
@@ -60,6 +60,99 @@ const client = new MemoryClient('http://localhost:7822', process.env.MEMORY_API_
60
60
  | GET | `/api/memory/query-as-of?asOf=...` | Bi-temporal point-in-time query (asOf, type, agentId, source, limit) |
61
61
  | GET | `/api/memory/query-by-event-time?startTime=...&endTime=...` | Bi-temporal event time range query (startTime, endTime, type, agentId, source, limit) |
62
62
 
63
+ ## ReBAC Admin (11 endpoints)
64
+
65
+ Fine-grained access control inside a tenant — namespaces, principals, group membership, and authz tuples. All routes require `ADMIN_API_KEY` and an `X-Tenant-Id` header. See [`patterns/access-control.md`](../patterns/access-control.md) Pattern 15 for the full guide.
66
+
67
+ | Method | Endpoint | Description |
68
+ |--------|----------|-------------|
69
+ | POST | `/api/admin/namespaces` | Create namespace (JSON: `{ name, parentId?, metadata?, createdBy? }`) |
70
+ | GET | `/api/admin/namespaces` | List namespaces in this tenant |
71
+ | DELETE | `/api/admin/namespaces/:id` | Delete namespace + tuples referencing it (RESTRICT on children) |
72
+ | POST | `/api/admin/principals` | Upsert principal (JSON: `{ kind, externalId?, displayName?, metadata? }`) |
73
+ | GET | `/api/admin/principals` | List principals in this tenant |
74
+ | DELETE | `/api/admin/principals/:id` | Delete principal + cascade memberships |
75
+ | POST | `/api/admin/principal-members` | Add member to group (JSON: `{ groupId, memberId }`) — cycle-checked |
76
+ | DELETE | `/api/admin/principal-members/:groupId/:memberId` | Remove membership |
77
+ | POST | `/api/admin/authz-tuples` | Write tuple (JSON: `{ subjectKind, subjectId, subjectRelation?, relation, objectKind, objectId, createdBy? }`) |
78
+ | GET | `/api/admin/authz-tuples?objectId=&subjectId=` | List tuples (filterable) |
79
+ | DELETE | `/api/admin/authz-tuples` | Delete tuple (JSON: same body shape as POST) |
80
+
81
+ `kind`: `'user' | 'team' | 'group' | 'agent' | 'service' | 'everyone'`. `objectKind` is `'namespace'` only in v1. `relation` is freeform (built-in rewrite: `owner ⊇ editor ⊇ viewer`).
82
+
83
+ Cross-tenant safety: every route verifies the referenced resource belongs to the caller's tenant; mismatches return `404` (not `403`) to avoid existence disclosure.
84
+
85
+ ## Strict Topology Isolation (v0.17.0)
86
+
87
+ | Method | Endpoint | Description |
88
+ |--------|----------|-------------|
89
+ | POST | `/api/admin/namespaces/:id/isolation` | Toggle isolation. Body: `{ isolation: 'shared' \| 'strict' }`. Returns `{ namespace, changed }` — `changed: false` means the no-op path (already in the requested mode); revision unchanged. |
90
+
91
+ Effect of `strict`:
92
+
93
+ - Graph traversal (`/api/memory/graph/query` and indirect uses inside `/api/memory/search`) drops edges whose `namespace_id` matches `AuthzPlan.forbiddenStrictNamespaceIds`.
94
+ - `/api/memory/graph/nodes` and `/api/memory/graph/relationships` apply the AuthzPlan at the HTTP boundary. Node projection intersects `memoryEntryIds` with the visible set and strips provenance properties (`source`, `sourceUrl`, `sourceTitle`, `sourceUri`, `url`, `title`).
95
+ - Effect propagates immediately — the toggle bumps the per-tenant AuthzPlan revision, so cached plans pick up `forbiddenStrictNamespaceIds` on the next request.
96
+
97
+ ## Idempotent Admin Mutations (v0.17.0)
98
+
99
+ All `/api/admin/*` mutation routes (POST / DELETE / PUT) honor the `X-Idempotency-Key` request header. A replay with the same key — within the 24h TTL — returns the previously recorded response body + status without re-executing the handler. Set the header from the BFF / sweeper:
100
+
101
+ ```bash
102
+ curl -X POST "$ENDPOINT/api/admin/namespaces" \
103
+ -H "Authorization: Bearer $ADMIN_API_KEY" \
104
+ -H "X-Tenant-Id: $TENANT" \
105
+ -H "X-Idempotency-Key: $(uuidgen)" \
106
+ -H "Content-Type: application/json" \
107
+ -d '{"name":"engineering"}'
108
+ ```
109
+
110
+ GET / OPTIONS / HEAD bypass the wrapper entirely (read-only methods are safe to retry). The header is OPTIONAL — callers that don't set it get the legacy fire-and-forget behavior.
111
+
112
+ ## Entry Move Admin (v0.16.1)
113
+
114
+ Reassign existing entries to a target namespace — the migration path for legacy NULL-namespace data and any later reorganization. Both routes are gated by `ADMIN_API_KEY` and require `X-Tenant-Id`. See [`patterns/access-control.md`](../patterns/access-control.md) Pattern 15 § "Migrating legacy entries" for the workflow.
115
+
116
+ | Method | Endpoint | Description |
117
+ |--------|----------|-------------|
118
+ | POST | `/api/admin/entries/:id/move` | Single move (JSON: `{ namespaceId: string \| null }`) |
119
+ | POST | `/api/admin/entries/move-batch` | Batch move with cursor + dryRun (JSON: `{ filter, target, limit?, dryRun? }`) |
120
+
121
+ **Single response** — `200 OK` with the standard `{ success: true, data: T }` envelope wrapping a `MoveResult`:
122
+
123
+ ```json
124
+ {
125
+ "success": true,
126
+ "data": {
127
+ "entryId": "entry-abc",
128
+ "success": true,
129
+ "fromNamespaceId": null,
130
+ "toNamespaceId": "ns-engineering"
131
+ }
132
+ }
133
+ ```
134
+
135
+ The inner `success: false` flag carries `failureReason` ∈ `{ not_found, cross_tenant_forbidden, target_namespace_not_found, sqlite_update_failed, vector_update_failed, graph_update_failed, compensation_failed }`. The route never returns 2xx with a failed inner result — instead it maps the failureReason to HTTP status as: `not_found` → 404, `cross_tenant_forbidden` / `target_namespace_not_found` → 400, store failures (`sqlite_update_failed` / `vector_update_failed` / `graph_update_failed`) → 502, `compensation_failed` → 500. The error body uses the project's standard `{ success: false, error: string }` envelope.
136
+
137
+ **Batch body shape**:
138
+
139
+ ```json
140
+ {
141
+ "filter": {
142
+ "fromNamespaceId": "ns-old" | null,
143
+ "entryIds": ["..."],
144
+ "agentId": "...", "teamId": "...", "userId": "...", "source": "...",
145
+ "limit": 100,
146
+ "cursor": "<opaque>"
147
+ },
148
+ "target": { "namespaceId": "ns-new" | null },
149
+ "dryRun": true,
150
+ "limit": 100
151
+ }
152
+ ```
153
+
154
+ `filter.fromNamespaceId` is required (footgun guard — there is no "move every entry I can see" form). `target.namespaceId: null` reverts entries to tenant-root. `dryRun: true` returns the prospective `MoveResult[]` without touching any store; `dryRun: false` (default) executes. Response carries `nextCursor` when more rows match — feed it back via `filter.cursor` for the next page.
155
+
63
156
  ## File Ingestion (Images + Documents)
64
157
 
65
158
  `POST /api/memory/ingest/file` accepts multipart/form-data with:
@@ -185,9 +185,10 @@ interface MemoryEntry {
185
185
  source?: string; // filename, URL, session ID
186
186
  eventTime?: string; // when event happened (bi-temporal)
187
187
  ingestTime?: string; // when stored (bi-temporal)
188
- tenantId?: string; // multi-tenant isolation
189
- userId?: string; // user within tenant
190
- teamId?: string; // team/group within tenant
188
+ tenantId?: string; // multi-tenant isolation (hard boundary)
189
+ userId?: string; // user within tenant (legacy soft-scope)
190
+ teamId?: string; // team/group within tenant (legacy soft-scope)
191
+ namespaceId?: string; // ReBAC resource coordinate; NULL = legacy tenant-root bucket
191
192
  sensitivity?: SensitivityLevel; // auto-classified: 'public' | 'internal' | 'secret'
192
193
  encrypted?: boolean; // true when content is encrypted at rest
193
194
  pinned?: boolean; // exempt from consolidation (decay-archive + dedup-merge); use for stable anchors referenced by external systems
@@ -209,6 +210,11 @@ interface MemorySearchParams {
209
210
  tenantId?: string; // multi-tenant scoping
210
211
  userId?: string; // user-level scoping
211
212
  teamId?: string; // team-level scoping
213
+ /** Calling principal — drives ReBAC AuthzPlan. Search applies it as a
214
+ * native pre-filter on SQLite/FTS, vector, and graph layers; forbidden
215
+ * entries never enter RRF / reranker / abstention. Optional. */
216
+ principal?: PrincipalContext;
217
+ namespaceIds?: string[]; // populated internally from AuthzPlan — do not set directly
212
218
  maxSensitivity?: SensitivityLevel; // filter by max sensitivity level
213
219
  abstentionThreshold?: number; // 0-1, below this confidence → shouldAbstain=true (default: 0.3)
214
220
  }
@@ -223,6 +229,66 @@ interface SearchFilters {
223
229
  }
224
230
  ```
225
231
 
232
+ ## PrincipalContext + ReBAC Types (v0.16.0+)
233
+
234
+ The calling identity used to compute search-time AuthzPlan. Comes from
235
+ authenticated request context (`X-Tenant-Id` + `X-User-Id` / `X-Agent-Id`),
236
+ NEVER from request bodies.
237
+
238
+ ```typescript
239
+ interface PrincipalContext {
240
+ tenantId: string; // hard boundary; SINGLE_TENANT_ID sentinel in single mode
241
+ principalId: string; // userId | agentId | service identifier
242
+ kind: 'user' | 'agent' | 'service';
243
+ }
244
+
245
+ interface Namespace {
246
+ id: string;
247
+ tenantId: string;
248
+ name: string;
249
+ parentId?: string; // adjacency tree, max-depth 8
250
+ createdAt: string;
251
+ createdBy?: string;
252
+ metadata: Record<string, unknown>;
253
+ }
254
+
255
+ interface Principal {
256
+ id: string;
257
+ tenantId: string;
258
+ kind: 'user' | 'team' | 'group' | 'agent' | 'service' | 'everyone';
259
+ externalId?: string; // maps to userId / agentId / teamId on entries
260
+ displayName?: string;
261
+ createdAt: string;
262
+ metadata: Record<string, unknown>;
263
+ }
264
+
265
+ interface AuthzTuple {
266
+ tenantId: string;
267
+ subjectKind: PrincipalKind;
268
+ subjectId: string;
269
+ subjectRelation?: string; // for usersets like "members of team-eng"
270
+ relation: 'viewer' | 'editor' | 'owner' | string;
271
+ objectKind: 'namespace'; // v1 only supports namespace-level grants
272
+ objectId: string;
273
+ createdAt: string;
274
+ createdBy?: string;
275
+ }
276
+
277
+ interface AuthzPlan {
278
+ tenantId: string;
279
+ visibleNamespaceIds: string[]; // resolved + transitively expanded
280
+ visibleEntryOverrides: string[]; // reserved for v1.1
281
+ revision: string; // bumps on every tuple/principal/membership mutation
282
+ }
283
+ ```
284
+
285
+ Built-in relation rewrite: `owner ⊇ editor ⊇ viewer`. Adding new relations
286
+ is a code change, not a schema migration.
287
+
288
+ In-process API: `Memory.authz` exposes `AuthzStore` with `createNamespace`,
289
+ `upsertPrincipal`, `addMember`, `writeTuple`, `deleteTuple`, `buildAuthzPlan`
290
+ etc. Sidecar / HTTP API: see [`http-api.md`](./http-api.md) §ReBAC Admin.
291
+
226
292
  ## MemoryOptions Reference
227
293
 
228
294
  > **Embedding is internal** — pyx-memory uses `LocalEmbeddingProvider` with BGE-M3 (1024d) automatically. You do NOT pass an `embedder` option.