@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
|
-
|
|
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
|
@@ -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', '
|
|
17
|
-
'
|
|
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,
|
|
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
|
-
- [
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
422
|
+
Reversing later is the same — delete the tuple, visibility flips back the next request.
|
|
349
423
|
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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.
|