@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyxmate/memory",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "type": "module",
5
5
  "description": "SDK for pyx-memory — Memory as a Service for AI agents",
6
6
  "license": "MIT",
@@ -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