@pyxmate/memory 0.16.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 {
|
|
@@ -390,6 +405,15 @@ interface MemorySearchParams {
|
|
|
390
405
|
* admin API.
|
|
391
406
|
*/
|
|
392
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[];
|
|
393
417
|
}
|
|
394
418
|
interface MemorySearchResult {
|
|
395
419
|
entries: MemoryEntry[];
|
|
@@ -533,6 +557,13 @@ interface GraphRelationship {
|
|
|
533
557
|
type: string;
|
|
534
558
|
properties: Record<string, unknown>;
|
|
535
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;
|
|
536
567
|
}
|
|
537
568
|
interface GraphTraversalResult {
|
|
538
569
|
nodes: GraphNode[];
|
|
@@ -656,6 +687,111 @@ interface IngestErrorEvent {
|
|
|
656
687
|
}
|
|
657
688
|
type IngestEvent = IngestProgressEvent | IngestHeartbeatEvent | IngestResultEvent | IngestErrorEvent;
|
|
658
689
|
|
|
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
|
+
|
|
659
795
|
/**
|
|
660
796
|
* Caller identity for ReBAC authorization.
|
|
661
797
|
*
|
|
@@ -703,4 +839,4 @@ interface PrincipalContext {
|
|
|
703
839
|
/** Sentinel tenant ID used in single-tenant deployments. */
|
|
704
840
|
declare const SINGLE_TENANT_ID = "_single";
|
|
705
841
|
|
|
706
|
-
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 PrincipalContext, RAGStrategy, SINGLE_TENANT_ID, SensitivityLevel, type StoreInput, StoreTarget, type TemporalQueryFilters, type TenantScopeOptions, type Timestamp, VectorProvider };
|
|
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",
|
|
@@ -46,6 +52,24 @@ var StoreTarget = {
|
|
|
46
52
|
GRAPH: "graph"
|
|
47
53
|
};
|
|
48
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
|
+
|
|
49
73
|
// ../shared/src/types/principal.ts
|
|
50
74
|
var SINGLE_TENANT_ID = "_single";
|
|
51
75
|
export {
|
|
@@ -54,6 +78,8 @@ export {
|
|
|
54
78
|
MemoryClient,
|
|
55
79
|
MemoryServerError,
|
|
56
80
|
MemoryType,
|
|
81
|
+
MoveFailureReason,
|
|
82
|
+
NamespaceIsolation,
|
|
57
83
|
RAGStrategy,
|
|
58
84
|
SINGLE_TENANT_ID,
|
|
59
85
|
SensitivityLevel,
|
package/package.json
CHANGED
|
@@ -452,8 +452,125 @@ await memory.store({
|
|
|
452
452
|
});
|
|
453
453
|
```
|
|
454
454
|
|
|
455
|
+
HTTP ingest accepts the same resource coordinate on JSON and file uploads:
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
curl -X POST "$MEMORY_URL/api/memory/ingest" \
|
|
459
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
460
|
+
-H "X-Tenant-Id: tenant-acme" \
|
|
461
|
+
-H "X-Namespace-Id: <NS_ID>" \
|
|
462
|
+
-H "Content-Type: application/json" \
|
|
463
|
+
-d '{"content":"Q4 revenue projections","type":"long-term"}'
|
|
464
|
+
|
|
465
|
+
curl -N -X POST "$MEMORY_URL/api/memory/ingest/file" \
|
|
466
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
467
|
+
-H "X-Tenant-Id: tenant-acme" \
|
|
468
|
+
-H "X-Namespace-Id: <NS_ID>" \
|
|
469
|
+
-F "file=@report.pdf"
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
`X-Namespace-Id` is canonical when both header and body/form field are sent; conflicting values return `400 namespace_id_conflict`. A missing or cross-tenant namespace returns `404 namespace_not_found`.
|
|
473
|
+
|
|
455
474
|
No bulk migration is required — legacy data keeps working unchanged.
|
|
456
475
|
|
|
476
|
+
### Migrating legacy entries (v0.16.1)
|
|
477
|
+
|
|
478
|
+
When you _do_ want to move pre-ReBAC entries (or reorganize an existing namespace tree), the admin entry-move API coordinates SQLite + vector + graph for you:
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
# Single entry: move one row to a target namespace.
|
|
482
|
+
curl -X POST "$ENDPOINT/api/admin/entries/$ENTRY_ID/move" \
|
|
483
|
+
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
484
|
+
-H "X-Tenant-Id: $TENANT" \
|
|
485
|
+
-H "Content-Type: application/json" \
|
|
486
|
+
-d '{"namespaceId": "<NS_ID>"}'
|
|
487
|
+
# null reverts to tenant-root (the legacy / pre-ReBAC bucket).
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Batch moves accept a filter and return cursor-paginated `MoveResult` rows. Always preview with `dryRun: true` first — the same filter + cursor drives both the dryRun preview and the execute pass, so what you see in dryRun is exactly what executes:
|
|
491
|
+
|
|
492
|
+
```bash
|
|
493
|
+
# Dry-run: see which rows would move, without touching any store.
|
|
494
|
+
curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
|
|
495
|
+
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
496
|
+
-H "X-Tenant-Id: $TENANT" \
|
|
497
|
+
-H "Content-Type: application/json" \
|
|
498
|
+
-d '{
|
|
499
|
+
"filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
|
|
500
|
+
"target": { "namespaceId": "<NS_ID>" },
|
|
501
|
+
"dryRun": true,
|
|
502
|
+
"limit": 100
|
|
503
|
+
}'
|
|
504
|
+
# Response includes results[] + nextCursor for paging.
|
|
505
|
+
|
|
506
|
+
# Execute: same body without dryRun (or dryRun: false).
|
|
507
|
+
curl -X POST "$ENDPOINT/api/admin/entries/move-batch" \
|
|
508
|
+
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
509
|
+
-H "X-Tenant-Id: $TENANT" \
|
|
510
|
+
-H "Content-Type: application/json" \
|
|
511
|
+
-d '{
|
|
512
|
+
"filter": { "fromNamespaceId": null, "source": "legacy-import.csv" },
|
|
513
|
+
"target": { "namespaceId": "<NS_ID>" },
|
|
514
|
+
"limit": 100
|
|
515
|
+
}'
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
What the move guarantees:
|
|
519
|
+
|
|
520
|
+
- **3-store coordination.** SQLite + vector + graph (when registered) all see the new `namespace_id`. On per-store failure the earlier writes are reverted in reverse order so partial states do not survive — the `MoveResult.failureReason` tells you which store rejected the move (`vector_update_failed`, `graph_update_failed`, etc.).
|
|
521
|
+
- **Cross-tenant safety.** A move whose source entry belongs to a different tenant is rejected with `cross_tenant_forbidden`. A move whose target namespace belongs to a different tenant is rejected with `target_namespace_not_found` — same code as a missing namespace, deliberately, to avoid telling the caller "this namespace exists but somewhere else" (existence-disclosure guard).
|
|
522
|
+
- **Authz revision bump.** Every successful move bumps the per-tenant AuthzPlan revision, so any cached plan keyed on `(principal, revision)` invalidates immediately.
|
|
523
|
+
- **Footgun guard on batch.** `filter.fromNamespaceId` is required — there is no "move everything I can see" shorthand by design.
|
|
524
|
+
|
|
525
|
+
`MoveFailureReason.compensation_failed` is the only deterministic-manual-recovery case: the forward write succeeded but the rollback failed. The response includes both error messages so an operator has enough detail to inspect each store directly.
|
|
526
|
+
|
|
527
|
+
### Strict topology isolation (v0.17.0)
|
|
528
|
+
|
|
529
|
+
By default a namespace is `shared` — its edges remain visible across the tenant whenever AuthzPlan grants access via any path. The KG layer dedupes nodes globally so edges from different namespaces co-exist on the same node, and `shared` lets that cross-namespace visibility through.
|
|
530
|
+
|
|
531
|
+
Set a namespace to `strict` and its edges become invisible to any principal who does not hold an explicit grant on it, even when the underlying KG node is reachable through a shared neighbor. KG dedupe at the node stays intact — the strictness is enforced edge-by-edge in the AuthzPlan + graph traversal WHERE, not by splitting nodes.
|
|
532
|
+
|
|
533
|
+
```bash
|
|
534
|
+
# Flip a namespace to strict.
|
|
535
|
+
curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
|
|
536
|
+
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
537
|
+
-H "X-Tenant-Id: $TENANT" \
|
|
538
|
+
-H "Content-Type: application/json" \
|
|
539
|
+
-d '{"isolation":"strict"}'
|
|
540
|
+
# {"namespace": {... "isolation": "strict"}, "changed": true}
|
|
541
|
+
|
|
542
|
+
# Flip back to shared.
|
|
543
|
+
curl -X POST "$ENDPOINT/api/admin/namespaces/$NS_ID/isolation" \
|
|
544
|
+
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
545
|
+
-H "X-Tenant-Id: $TENANT" \
|
|
546
|
+
-H "Content-Type: application/json" \
|
|
547
|
+
-d '{"isolation":"shared"}'
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Both flips bump the per-tenant AuthzPlan revision so cached plans invalidate on the next request — no manual cache invalidation step. A no-op flip (already in the requested mode) returns `changed: false` and leaves the revision untouched.
|
|
551
|
+
|
|
552
|
+
What strict guarantees:
|
|
553
|
+
|
|
554
|
+
- **Edge-level scoping.** SQLiteGraph WHERE clause + Neo4j post-filter drop edges whose `namespace_id` is in `AuthzPlan.forbiddenStrictNamespaceIds`. Legacy NULL-namespace edges (pre-v0.16.1 ingest) keep tenant-root visibility regardless.
|
|
555
|
+
- **Bulk endpoint visibility.** `/api/memory/graph/relationships` filters at the HTTP boundary too. `/api/memory/graph/nodes` keeps only nodes that have at least one visible memoryEntryId, intersects the projection's memoryEntryIds with the visible set, and strips provenance properties (`source`, `sourceUrl`, `sourceTitle`, etc.) so a cross-namespace node cannot leak originating-namespace metadata via property bag.
|
|
556
|
+
- **Hybrid RAG fail-loud.** Graph + community retrieval no longer swallow backend errors. A graph-store outage propagates as `MemorySearchError` so the operator sees it at the request boundary instead of as drifting RRF gaps.
|
|
557
|
+
|
|
558
|
+
### Idempotent admin mutations (v0.17.0)
|
|
559
|
+
|
|
560
|
+
The audit-outbox sweeper retries 5xx mutations until each lands. Set `X-Idempotency-Key` on every admin mutation request so a retry on the same key returns the recorded response without re-executing the handler:
|
|
561
|
+
|
|
562
|
+
```bash
|
|
563
|
+
KEY=$(uuidgen)
|
|
564
|
+
curl -X POST "$ENDPOINT/api/admin/namespaces" \
|
|
565
|
+
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
566
|
+
-H "X-Tenant-Id: $TENANT" \
|
|
567
|
+
-H "X-Idempotency-Key: $KEY" \
|
|
568
|
+
-H "Content-Type: application/json" \
|
|
569
|
+
-d '{"name":"engineering"}'
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
24h TTL. GET / OPTIONS bypass the wrapper entirely (read-only methods are already safe to retry). Pre-existing entries are dedupe-keyed by `key` (not by request body), so a sweeper retry with the original payload + key replays the original response even if the underlying state has since changed.
|
|
573
|
+
|
|
457
574
|
### Why not metadata + post-filter?
|
|
458
575
|
|
|
459
576
|
The pre-PR-D pattern was "tag entries with `metadata.allowedTeams`, filter results in your gateway". That breaks RAG retrieval:
|
|
@@ -24,8 +24,8 @@ const client = new MemoryClient('http://localhost:7822', process.env.MEMORY_API_
|
|
|
24
24
|
|--------|----------|-------------|
|
|
25
25
|
| GET | `/health` | Public health check (status only — no internals exposed) |
|
|
26
26
|
| GET | `/admin/health` | Admin health check (version, uptime, embedding provider, memory stats) |
|
|
27
|
-
| POST | `/api/memory/ingest` | Store a memory (JSON: `{ content, type, metadata, agentId?, sessionId?, targets?, entities?, relationships?, importance?, source?, eventTime?, id?, parentId?, ingestTime?, pinned? }`) |
|
|
28
|
-
| POST | `/api/memory/ingest/file` | Upload file (multipart, 100MB limit). For PDFs with images, returns an `enrichment` block with HMAC token and image metadata for two-phase enrichment. |
|
|
27
|
+
| POST | `/api/memory/ingest` | Store a memory (JSON: `{ content, type, metadata, namespaceId?, agentId?, sessionId?, targets?, entities?, relationships?, importance?, source?, eventTime?, id?, parentId?, ingestTime?, pinned? }`) |
|
|
28
|
+
| POST | `/api/memory/ingest/file` | Upload file (multipart, 100MB limit; optional `namespaceId` field or `X-Namespace-Id` header). For PDFs with images, returns an `enrichment` block with HMAC token and image metadata for two-phase enrichment. |
|
|
29
29
|
| GET | `/api/memory/files/download/:filename` | Download uploaded file binary by filename. Returns the original file with proper Content-Type. Images served inline, documents as attachment. |
|
|
30
30
|
| GET | `/api/memory/files/:fileId/images/:imageId?token=...` | Fetch an extracted PDF image (binary). Requires HMAC token from ingest response. |
|
|
31
31
|
| POST | `/api/memory/files/:fileId/enrich` | Submit image descriptions + entities after LLM vision processing. Requires `X-Enrichment-Token` header. |
|
|
@@ -82,6 +82,92 @@ Fine-grained access control inside a tenant — namespaces, principals, group me
|
|
|
82
82
|
|
|
83
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
84
|
|
|
85
|
+
## Namespaced Ingest (v0.17.1)
|
|
86
|
+
|
|
87
|
+
`POST /api/memory/ingest` accepts `namespaceId` either in the JSON body or the `X-Namespace-Id` header. `POST /api/memory/ingest/file` accepts the same header or a multipart `namespaceId` field. The header is canonical; sending both channels with different values returns `400 namespace_id_conflict`. Sending JSON `namespaceId: null` is invalid; omit the field to keep the legacy NULL namespace bucket.
|
|
88
|
+
|
|
89
|
+
When a namespace is supplied, the server resolves it once against the caller tenant before storing. Missing or cross-tenant namespaces return `404 namespace_not_found` so callers cannot distinguish "does not exist" from "exists in another tenant".
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
curl -X POST {{ENDPOINT}}/api/memory/ingest \
|
|
93
|
+
-H "Authorization: Bearer {{API_KEY}}" \
|
|
94
|
+
-H "X-Tenant-Id: tenant-acme" \
|
|
95
|
+
-H "X-Namespace-Id: ns-engineering" \
|
|
96
|
+
-H "Content-Type: application/json" \
|
|
97
|
+
-d '{"content":"Q4 revenue projections","type":"long-term"}'
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Strict Topology Isolation (v0.17.0)
|
|
101
|
+
|
|
102
|
+
| Method | Endpoint | Description |
|
|
103
|
+
|--------|----------|-------------|
|
|
104
|
+
| 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. |
|
|
105
|
+
|
|
106
|
+
Effect of `strict`:
|
|
107
|
+
|
|
108
|
+
- Graph traversal (`/api/memory/graph/query` and indirect uses inside `/api/memory/search`) drops edges whose `namespace_id` matches `AuthzPlan.forbiddenStrictNamespaceIds`.
|
|
109
|
+
- `/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`).
|
|
110
|
+
- Effect propagates immediately — the toggle bumps the per-tenant AuthzPlan revision, so cached plans pick up `forbiddenStrictNamespaceIds` on the next request.
|
|
111
|
+
|
|
112
|
+
## Idempotent Admin Mutations (v0.17.0)
|
|
113
|
+
|
|
114
|
+
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:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
curl -X POST "$ENDPOINT/api/admin/namespaces" \
|
|
118
|
+
-H "Authorization: Bearer $ADMIN_API_KEY" \
|
|
119
|
+
-H "X-Tenant-Id: $TENANT" \
|
|
120
|
+
-H "X-Idempotency-Key: $(uuidgen)" \
|
|
121
|
+
-H "Content-Type: application/json" \
|
|
122
|
+
-d '{"name":"engineering"}'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
## Entry Move Admin (v0.16.1)
|
|
128
|
+
|
|
129
|
+
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.
|
|
130
|
+
|
|
131
|
+
| Method | Endpoint | Description |
|
|
132
|
+
|--------|----------|-------------|
|
|
133
|
+
| POST | `/api/admin/entries/:id/move` | Single move (JSON: `{ namespaceId: string \| null }`) |
|
|
134
|
+
| POST | `/api/admin/entries/move-batch` | Batch move with cursor + dryRun (JSON: `{ filter, target, limit?, dryRun? }`) |
|
|
135
|
+
|
|
136
|
+
**Single response** — `200 OK` with the standard `{ success: true, data: T }` envelope wrapping a `MoveResult`:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"success": true,
|
|
141
|
+
"data": {
|
|
142
|
+
"entryId": "entry-abc",
|
|
143
|
+
"success": true,
|
|
144
|
+
"fromNamespaceId": null,
|
|
145
|
+
"toNamespaceId": "ns-engineering"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
**Batch body shape**:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"filter": {
|
|
157
|
+
"fromNamespaceId": "ns-old" | null,
|
|
158
|
+
"entryIds": ["..."],
|
|
159
|
+
"agentId": "...", "teamId": "...", "userId": "...", "source": "...",
|
|
160
|
+
"limit": 100,
|
|
161
|
+
"cursor": "<opaque>"
|
|
162
|
+
},
|
|
163
|
+
"target": { "namespaceId": "ns-new" | null },
|
|
164
|
+
"dryRun": true,
|
|
165
|
+
"limit": 100
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`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.
|
|
170
|
+
|
|
85
171
|
## File Ingestion (Images + Documents)
|
|
86
172
|
|
|
87
173
|
`POST /api/memory/ingest/file` accepts multipart/form-data with:
|
|
@@ -90,6 +176,7 @@ Cross-tenant safety: every route verifies the referenced resource belongs to the
|
|
|
90
176
|
|-------|------|----------|-------------|
|
|
91
177
|
| `file` | File | Yes | The file to ingest (max 100MB) |
|
|
92
178
|
| `description` | string | No | Agent-provided description (e.g., from LLM vision). Used instead of parser output for images. |
|
|
179
|
+
| `namespaceId` | string | No | ReBAC namespace to stamp on every chunk. `X-Namespace-Id` header is preferred when both are present. |
|
|
93
180
|
|
|
94
181
|
**Supported formats**: `.txt`, `.md`, `.csv`, `.tsv`, `.log`, `.pdf`, `.docx`, `.xlsx`, `.pptx`, `.json`, `.jsonl`, `.html`, `.htm`, `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.bmp`, `.tiff`, `.svg`
|
|
95
182
|
|