@pyxmate/memory 0.16.0 → 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 {
@@ -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.0",
4
4
  "type": "module",
5
5
  "description": "SDK for pyx-memory — Memory as a Service for AI agents",
6
6
  "license": "MIT",
@@ -454,6 +454,104 @@ await memory.store({
454
454
 
455
455
  No bulk migration is required — legacy data keeps working unchanged.
456
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
+ }'
497
+ ```
498
+
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
+
457
555
  ### Why not metadata + post-filter?
458
556
 
459
557
  The pre-PR-D pattern was "tag entries with `metadata.allowedTeams`, filter results in your gateway". That breaks RAG retrieval:
@@ -82,6 +82,77 @@ 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
+ ## 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
+
85
156
  ## File Ingestion (Images + Documents)
86
157
 
87
158
  `POST /api/memory/ingest/file` accepts multipart/form-data with: