Namespace Isolation
Serve thousands of tenants from a single Dakera instance with absolute data isolation. Namespace isolation enforces separation at the storage layer — not just the application layer — so a misconfigured API call or a compromised key cannot access another tenant's data. This is the foundation of any multi-tenant AI memory architecture.
Set Up Tenant Isolation →- Running Dakera server (see Quickstart)
- Admin API key with namespace management permissions
- Per-tenant scoped API keys for production use
Problem
In multi-tenant or multi-user applications, data isolation is not optional — it is a hard requirement for security and compliance. The failure modes when isolation is absent are severe:
- Cross-tenant data leakage: User A's recall query returns results from User B's memory store due to a missing filter parameter or a bug in application-layer access checks
- Noisy recall: A single large tenant's memory volume dominates search results for smaller tenants sharing the same namespace
- Compliance failures: GDPR right-to-erasure, HIPAA data segmentation, or SOC2 access control requirements cannot be met if all tenants share a single memory partition
- Cascading deletions: A "delete user data" operation that queries by tag instead of namespace may accidentally affect other users if tags collide
Dakera namespaces eliminate these risks by providing hard storage-level partitioning. Each namespace is its own independent memory store, knowledge graph, and search index. A key scoped to tenant-a cannot query tenant-b data regardless of what parameters are passed in the API call.
Many teams implement tenant isolation by passing a user_id filter in every query. This relies on correct code in every call site — one bug, one missing parameter, one junior developer using the wrong query method, and data leaks across tenants. Namespace isolation enforces the boundary at the infrastructure level. There is no code path that can cross it.
Architecture
Each tenant gets an isolated namespace containing its own complete memory stack:
- Isolated vector index — semantic search operates only within the tenant's namespace
- Isolated knowledge graph — entity relationships are tenant-specific
- Isolated decay configuration — each namespace can have its own memory lifecycle policy
- Scoped API keys — one key per tenant, restricted at the API gateway to that namespace only
Namespace Architecture Diagram
Step-by-Step Implementation
-
Choose your namespace naming conventionPick a consistent pattern before you start. Common choices:
tenant-{tenant_id}for SaaS (e.g.,tenant-acme-corp),user-{user_id}for per-user isolation (e.g.,user-u_8f3k2j), ororg-{org_id}for organizational boundaries. Avoid using human-readable names that could be guessed — use IDs. -
Create a namespace on tenant signupHook your user/tenant onboarding flow to call
create_namespace()with the admin key. This should happen synchronously during signup — the tenant's namespace must exist before their first memory write. Keep the admin key server-side only; never expose it to clients. -
Issue a scoped API key per tenantAfter creating the namespace, issue a Dakera API key scoped exclusively to that namespace. Store this key securely (environment variable or secrets manager) and provide it to your backend service for that tenant's requests. Rotate keys on schedule and on any suspected compromise.
-
Route all memory operations through the scoped keyYour API layer should instantiate a Dakera client using the tenant's scoped key for every request. Use a per-request client instantiation or a keyed client pool — never share a single admin-key client across tenants. The namespace routing is enforced by the key, so no additional filtering code is needed.
-
Implement namespace deletion on tenant offboardingWhen a tenant deletes their account (or triggers a GDPR erasure request), call
delete_namespace()with the admin key. This atomically destroys all memories, sessions, knowledge graphs, and embeddings for that tenant. No manual cleanup required — one call covers the entire data footprint. -
Audit namespace access with list_namespacesPeriodically call
list_namespaces()to audit which namespaces exist, compare against your user database, and detect orphaned namespaces from deactivated accounts. Automate this as a weekly cron job that flags any namespace with no corresponding active tenant record.
Implementation
# --- Tenant onboarding: create isolated namespace ---
curl -X POST http://localhost:3300/v1/namespaces \
-H "Authorization: Bearer dk-admin-key" \
-H "Content-Type: application/json" \
-d '{"name": "tenant-acme-corp"}'
# --- List all namespaces (admin audit) ---
curl http://localhost:3300/v1/namespaces \
-H "Authorization: Bearer dk-admin-key"
# --- Tenant A: store confidential data (scoped key) ---
curl -X POST http://localhost:3300/v1/memory/store \
-H "Authorization: Bearer dk-tenant-acme-corp-key" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "tenant-acme-corp",
"content": "Acme Corp Q3 roadmap: launch Omega feature in September. Team of 4 engineers.",
"importance": 0.9,
"tags": ["roadmap", "confidential"]
}'
# --- Tenant A: recall their own data ---
curl "http://localhost:3300/v1/memory/recall?agent_id=tenant-acme-corp&query=Q3+roadmap&top_k=5" \
-H "Authorization: Bearer dk-tenant-acme-corp-key"
# --- Tenant B: cannot access Tenant A's namespace ---
# This returns HTTP 403 Forbidden — enforced at storage level
curl "http://localhost:3300/v1/memory/recall?agent_id=tenant-acme-corp&query=roadmap" \
-H "Authorization: Bearer dk-tenant-betacorp-key"
# Response: {"error":"forbidden","message":"Key not authorized for namespace tenant-acme-corp"}
# --- Tenant offboarding: delete entire namespace atomically ---
curl -X DELETE http://localhost:3300/v1/namespaces/tenant-acme-corp \
-H "Authorization: Bearer dk-admin-key"
# Deletes ALL memories, sessions, graphs, and embeddings for this tenant
from dakera import DakeraClient
import os
# Admin client — server-side only, never exposed to tenants
admin = DakeraClient(
base_url="http://localhost:3300",
api_key=os.environ["DAKERA_ADMIN_KEY"]
)
class TenantMemoryService:
"""
Service layer for multi-tenant memory operations.
Each tenant gets their own Dakera namespace and scoped key.
"""
def __init__(self, admin_client: DakeraClient):
self._admin = admin_client
self._tenant_clients: dict = {}
def provision_tenant(self, tenant_id: str) -> str:
"""Create namespace on tenant signup. Returns the namespace name."""
ns_name = f"tenant-{tenant_id}"
self._admin.create_namespace(ns_name)
print(f"Provisioned namespace: {ns_name}")
return ns_name
def get_client(self, tenant_id: str) -> DakeraClient:
"""Get (or create) a scoped client for a tenant."""
if tenant_id not in self._tenant_clients:
# In production: look up per-tenant scoped API key from secrets manager
scoped_key = os.environ.get(f"DAKERA_KEY_TENANT_{tenant_id.upper()}")
if not scoped_key:
raise ValueError(f"No scoped key found for tenant {tenant_id}")
self._tenant_clients[tenant_id] = DakeraClient(
base_url="http://localhost:3300",
api_key=scoped_key
)
return self._tenant_clients[tenant_id]
def store_memory(self, tenant_id: str, content: str, **kwargs):
client = self.get_client(tenant_id)
return client.store_memory(
agent_id=f"tenant-{tenant_id}",
content=content,
**kwargs
)
def recall(self, tenant_id: str, query: str, top_k: int = 5):
client = self.get_client(tenant_id)
return client.recall(
agent_id=f"tenant-{tenant_id}",
query=query,
top_k=top_k
)
def list_namespaces(self) -> list:
"""Admin: list all namespaces for audit."""
return self._admin.list_namespaces()
def offboard_tenant(self, tenant_id: str):
"""GDPR erasure / account deletion: atomically removes all tenant data."""
ns_name = f"tenant-{tenant_id}"
self._admin.delete_namespace(ns_name)
self._tenant_clients.pop(tenant_id, None)
print(f"Deleted namespace and all data for tenant {tenant_id}")
def get_memory_policy(self, tenant_id: str) -> dict:
"""Check the decay/lifecycle policy for a tenant's namespace."""
return self._admin.get_memory_policy(f"tenant-{tenant_id}")
# --- Usage ---
service = TenantMemoryService(admin)
# Tenant signup
service.provision_tenant("acme-corp")
service.provision_tenant("betacorp")
# Tenant A stores confidential data
service.store_memory(
"acme-corp",
content="Q3 roadmap: launch Omega feature in September. 4 engineers assigned.",
importance=0.9,
tags=["roadmap", "confidential"]
)
# Tenant A recalls their own data
results = service.recall("acme-corp", "Q3 product roadmap")
for r in results:
print(f"[{r['importance']:.2f}] {r['content']}")
# Audit: list all namespaces
namespaces = service.list_namespaces()
print(f"Active namespaces: {[n['name'] for n in namespaces]}")
# Account deletion (GDPR erasure)
service.offboard_tenant("acme-corp")
import { DakeraClient } from '@dakera-ai/dakera';
const admin = new DakeraClient({
baseUrl: 'http://localhost:3300',
apiKey: process.env.DAKERA_ADMIN_KEY!
});
class TenantMemoryService {
private adminClient: DakeraClient;
private tenantClients = new Map<string, DakeraClient>();
constructor(admin: DakeraClient) {
this.adminClient = admin;
}
async provisionTenant(tenantId: string): Promise<string> {
const ns = `tenant-${tenantId}`;
await this.adminClient.createNamespace(ns);
console.log(`Provisioned namespace: ${ns}`);
return ns;
}
private getClient(tenantId: string): DakeraClient {
if (!this.tenantClients.has(tenantId)) {
const key = process.env[`DAKERA_KEY_TENANT_${tenantId.toUpperCase()}`];
if (!key) throw new Error(`No scoped key for tenant ${tenantId}`);
this.tenantClients.set(tenantId, new DakeraClient({ baseUrl: 'http://localhost:3300', apiKey: key }));
}
return this.tenantClients.get(tenantId)!;
}
async storeMemory(tenantId: string, content: string, opts: Record<string, unknown> = {}) {
const client = this.getClient(tenantId);
return client.storeMemory(`tenant-${tenantId}`, { content, ...opts });
}
async recall(tenantId: string, query: string, topK = 5) {
const client = this.getClient(tenantId);
return client.recall(`tenant-${tenantId}`, query, { top_k: topK });
}
async listNamespaces() {
return this.adminClient.listNamespaces();
}
async offboardTenant(tenantId: string) {
await this.adminClient.deleteNamespace(`tenant-${tenantId}`);
this.tenantClients.delete(tenantId);
console.log(`Deleted all data for tenant ${tenantId}`);
}
}
// --- Usage ---
const service = new TenantMemoryService(admin);
await service.provisionTenant('acme-corp');
await service.provisionTenant('betacorp');
// Tenant A stores confidential data
await service.storeMemory('acme-corp', 'Q3 roadmap: Omega feature in September', {
importance: 0.9,
tags: ['roadmap', 'confidential']
});
// Tenant A recalls
const results = await service.recall('acme-corp', 'Q3 product roadmap');
console.log(results.map(r => `[${r.importance}] ${r.content}`));
// Audit
const namespaces = await service.listNamespaces();
console.log('Active:', namespaces.map(n => n.name));
// GDPR erasure
await service.offboardTenant('acme-corp');
use dakera_rs::{Client, CreateNamespaceRequest, StoreMemoryRequest, RecallRequest};
use std::collections::HashMap;
struct TenantService {
admin: Client,
tenant_clients: HashMap<String, Client>,
}
impl TenantService {
fn new(admin: Client) -> Self {
Self { admin, tenant_clients: HashMap::new() }
}
async fn provision_tenant(&self, tenant_id: &str) -> anyhow::Result<()> {
let ns = format!("tenant-{}", tenant_id);
self.admin.create_namespace(&ns, CreateNamespaceRequest::default()).await?;
println!("Provisioned namespace: {}", ns);
Ok(())
}
fn get_client(&mut self, tenant_id: &str) -> &Client {
if !self.tenant_clients.contains_key(tenant_id) {
let key = std::env::var(format!("DAKERA_KEY_TENANT_{}", tenant_id.to_uppercase()))
.expect("Scoped key not found");
self.tenant_clients.insert(
tenant_id.to_string(),
Client::new("http://localhost:3300", &key)
);
}
&self.tenant_clients[tenant_id]
}
async fn store_memory(&mut self, tenant_id: &str, content: &str, importance: f64) -> anyhow::Result<()> {
let ns = format!("tenant-{}", tenant_id);
self.get_client(tenant_id).store_memory(&ns, StoreMemoryRequest {
content: content.to_string(),
importance: Some(importance),
..Default::default()
}).await?;
Ok(())
}
async fn recall(&mut self, tenant_id: &str, query: &str, top_k: u32) -> anyhow::Result<Vec<serde_json::Value>> {
let ns = format!("tenant-{}", tenant_id);
let results = self.get_client(tenant_id).recall(&ns, RecallRequest {
query: query.to_string(),
top_k: Some(top_k),
..Default::default()
}).await?;
Ok(results)
}
async fn offboard_tenant(&self, tenant_id: &str) -> anyhow::Result<()> {
let ns = format!("tenant-{}", tenant_id);
self.admin.delete_namespace(&ns).await?;
println!("Deleted namespace {}", ns);
Ok(())
}
}
package main
import (
"context"
"fmt"
"os"
dakera "github.com/dakera-ai/dakera-go"
)
type TenantService struct {
admin *dakera.Client
tenantClients map[string]*dakera.Client
}
func NewTenantService(adminKey string) *TenantService {
return &TenantService{
admin: dakera.NewClient("http://localhost:3300", adminKey),
tenantClients: make(map[string]*dakera.Client),
}
}
func (s *TenantService) ProvisionTenant(ctx context.Context, tenantID string) error {
ns := fmt.Sprintf("tenant-%s", tenantID)
if err := s.admin.CreateNamespace(ctx, ns, nil); err != nil {
return err
}
fmt.Printf("Provisioned namespace: %s
", ns)
return nil
}
func (s *TenantService) getClient(tenantID string) *dakera.Client {
if _, ok := s.tenantClients[tenantID]; !ok {
key := os.Getenv(fmt.Sprintf("DAKERA_KEY_TENANT_%s", tenantID))
s.tenantClients[tenantID] = dakera.NewClient("http://localhost:3300", key)
}
return s.tenantClients[tenantID]
}
func (s *TenantService) StoreMemory(ctx context.Context, tenantID, content string, importance float64) error {
ns := fmt.Sprintf("tenant-%s", tenantID)
_, err := s.getClient(tenantID).StoreMemory(ctx, ns, dakera.StoreMemoryRequest{
Content: content,
Importance: importance,
})
return err
}
func (s *TenantService) Recall(ctx context.Context, tenantID, query string, topK int) ([]dakera.Memory, error) {
ns := fmt.Sprintf("tenant-%s", tenantID)
return s.getClient(tenantID).Recall(ctx, ns, dakera.RecallRequest{
Query: query,
TopK: topK,
})
}
func (s *TenantService) OffboardTenant(ctx context.Context, tenantID string) error {
ns := fmt.Sprintf("tenant-%s", tenantID)
return s.admin.DeleteNamespace(ctx, ns)
}
// Usage
func main() {
svc := NewTenantService(os.Getenv("DAKERA_ADMIN_KEY"))
ctx := context.Background()
svc.ProvisionTenant(ctx, "acme-corp")
svc.StoreMemory(ctx, "acme-corp", "Q3 roadmap: Omega feature in September", 0.9)
results, _ := svc.Recall(ctx, "acme-corp", "Q3 roadmap", 5)
for _, r := range results {
fmt.Printf("[%.2f] %s
", r.Importance, r.Content)
}
}
Building a multi-tenant AI product?
Dakera's namespace API handles tenant isolation out of the box — one namespace per tenant, one API call to provision.
Before / After Memory State
-- Single namespace: default --
[user_id: acme] Q3 roadmap: Omega in Sept
[user_id: acme] Hiring 4 ML engineers
[user_id: betacorp] Competitor analysis: Acme
[user_id: betacorp] Patent filing for widget
[user_id: acme] Budget: $2.4M allocated
# A recall without user_id filter returns:
recall(query="company plans") ->
[acme] Q3 roadmap: Omega in Sept
[betacorp] Competitor analysis: Acme ← LEAK
[acme] Budget: $2.4M allocated
[betacorp] Patent filing for widget ← LEAK
One missing filter parameter leaks Betacorp's confidential data to Acme. Application-layer bugs become security incidents.
-- namespace: tenant-acme-corp --
[0.9] Q3 roadmap: Omega feature in Sept
[0.8] Hiring 4 ML engineers
[0.85] Budget: $2.4M allocated
-- namespace: tenant-betacorp --
[0.9] Competitor analysis: Acme Corp
[0.95] Patent filing for widget X
# Betacorp key can ONLY query tenant-betacorp
recall(key=betacorp-key, query="company plans") ->
[0.9] Competitor analysis...
[0.95] Patent filing...
(acme data is architecturally unreachable)
No code path can cross namespace boundaries. Even with a bug that omits filters, cross-tenant access is impossible at the storage layer.
Common Namespace Architectures
Per-User Isolation
Each user gets their own namespace (user-{user_id}) with a key scoped to that namespace only. Total isolation — no cross-user leakage possible. Suitable for consumer apps with strict privacy requirements.
Per-Tenant with Sub-User Namespaces
Organization-level namespace (org-acme) for team-shared knowledge, plus per-user namespaces (org-acme-user-alice, org-acme-user-bob) for individual preferences. Alice's key: read/write to her user namespace, read-only to the org namespace. Suitable for B2B SaaS.
Per-Agent Isolation (see Multi-Agent Pattern)
Each agent role has a private namespace (agent-researcher, agent-writer) plus a shared collaboration namespace (team-shared). Combines isolation with collaboration. See the Multi-Agent Shared Memory pattern for full details.
Hierarchical Org Namespaces
Enterprise deployments with multiple business units: corp-{corp_id} > dept-{dept_id} > user-{user_id}. Each level has its own namespace and keys with appropriate scope. Admin key has cross-namespace audit access. Department keys have read-only to corp, read/write to dept. User keys isolated to their namespace only.
SDK Reference
| Operation | Python | TypeScript | Purpose |
|---|---|---|---|
| Create namespace | create_namespace("tenant-xyz") | createNamespace("tenant-xyz") | Provision new tenant |
| List namespaces | list_namespaces() | listNamespaces() | Admin audit of all tenants |
| Get policy | get_memory_policy("tenant-xyz") | getMemoryPolicy("tenant-xyz") | Check decay config per namespace |
| Store in namespace | store_memory("tenant-xyz", content, ...) | storeMemory("tenant-xyz", {content, ...}) | Write to isolated tenant store |
| Recall from namespace | recall("tenant-xyz", query, top_k=5) | recall("tenant-xyz", query, {top_k: 5}) | Search only within tenant |
| Delete namespace | delete_namespace("tenant-xyz") | deleteNamespace("tenant-xyz") | GDPR erasure / offboarding |
| Batch forget | batch_forget(request) | batchForget(request) | Bulk delete within namespace |
Real-World Example: SaaS Customer Support Platform
A customer support SaaS platform serves 500 business customers. Each business's conversations, customer profiles, and support knowledge base must be completely isolated from every other business. Here is how the namespace architecture maps to the product:
- Namespace per business:
tenant-{business_id}created on account signup. Contains all conversation histories, customer profiles, and KB articles. - Scoped key per business: The platform's backend uses this key for all Dakera API calls on behalf of that business. The key is stored in the platform's secrets manager, associated with the business_id.
- Customer sub-namespacing via agent_id: Within a business's namespace, individual customers are distinguished by
agent_id(e.g.,customer-c_9k2m). This provides logical separation within the tenant namespace without requiring a separate namespace per customer. - GDPR compliance: When a customer requests data deletion, the platform calls
batch_forget()for all memories withagent_id=customer-c_9k2m. When a business cancels, the platform callsdelete_namespace()to atomically erase all their data in a single API call.
When you call delete_namespace(), Dakera atomically removes the namespace from the routing table first, making it immediately inaccessible to any in-flight requests. The underlying data deletion is then completed asynchronously. This means you can call delete and immediately confirm to the user that their data is gone — no waiting for background jobs.
Request Routing: Namespace Access Control Flow
This diagram shows how every API request is validated against namespace permissions before any data operation is performed — ensuring tenant isolation is enforced at the infrastructure level.
Performance Considerations
Namespace routing overhead is sub-millisecond — key validation and namespace routing add less than 1ms to every request. Dakera supports 10,000+ namespaces per instance with no degradation. Namespace creation is synchronous and completes in under 100ms, suitable for real-time user signup flows.
The main performance consideration is index size per namespace. Since each namespace has its own vector index, a namespace with 1M memories will have higher per-query latency than one with 10K. Consider setting decay policies at the namespace level to age out low-importance memories and keep each tenant's index lean.
Edge Cases
1. Namespace creation race on concurrent signups
Two users sign up simultaneously with the same organization, triggering two concurrent create_namespace("org-acme") calls. The second call will fail with a conflict error. Fix: treat namespace creation as idempotent — catch the conflict error and proceed. Or use a distributed lock on the namespace name in your signup flow.
2. Admin key exposure
The admin key has cross-namespace access. If it leaks, all tenant data is accessible. Fix: the admin key should never be used at runtime for tenant operations. Use it only for provisioning. Store it in a hardware security module (HSM) or secrets manager. Rotate it on any suspected compromise.
3. Orphaned namespaces from failed offboarding
Your offboarding flow fails after the user record is deleted but before delete_namespace() is called. The namespace persists with data but no corresponding user. Fix: use an event-driven architecture — publish a user.deleted event and have a Dakera cleanup consumer handle the namespace deletion with retry logic and a dead-letter queue for failures.
4. Namespace enumeration attack
An attacker who obtains a low-privilege API key tries to enumerate other tenant namespaces by guessing names. The 403 response leaks the existence of the namespace. Fix: use opaque UUIDs for namespace names rather than human-readable identifiers. tenant-550e8400-e29b-41d4-a716 is not guessable; tenant-acme-corp is.
5. Memory import between namespaces
A legitimate business request: migrate tenant data from tenant-acme-old to tenant-acme-new after an account restructure. There is no direct namespace-to-namespace copy API. Fix: use the admin key to read from the old namespace via recall() with a broad query, then re-store into the new namespace. For large datasets, use batch_recall() to paginate through all memories efficiently.
It is tempting to use a single namespace with different agent_id values to separate tenants (e.g., agent_id="tenant-acme" vs agent_id="tenant-beta"). This is not isolation. Any key with access to the namespace can query any agent_id within it. Use real namespaces with scoped keys for true tenant isolation.
Advanced Configuration — Per-Namespace Decay Policies & Rate Limiting
Per-Tenant Decay Configuration
Different tenants may need different memory lifecycle policies:
from dakera import DakeraClient
admin = DakeraClient(base_url="http://localhost:3300", api_key="dk-admin-key")
# Enterprise tenant: slow decay, long retention
admin.set_memory_policy("tenant-enterprise-corp", {
"decay_rate": 0.01, # very slow importance decay
"min_importance": 0.1, # keep everything above 0.1
"ttl_default": None # no default TTL
})
# Free-tier tenant: aggressive decay to save storage
admin.set_memory_policy("tenant-free-user-123", {
"decay_rate": 0.1, # fast decay
"min_importance": 0.4, # only keep important memories
"ttl_default": 2592000 # 30-day default TTL
})
# Check a tenant's current policy
policy = admin.get_memory_policy("tenant-enterprise-corp")
print(policy)
Bulk Namespace Provisioning
import asyncio
from dakera import DakeraClient
admin = DakeraClient(base_url="http://localhost:3300", api_key="dk-admin-key")
async def provision_tenants(tenant_ids: list[str]):
"""Provision multiple tenants in parallel."""
tasks = [admin.create_namespace(f"tenant-{tid}") for tid in tenant_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
for tid, result in zip(tenant_ids, results):
if isinstance(result, Exception):
print(f"Failed to provision {tid}: {result}")
else:
print(f"Provisioned: tenant-{tid}")
Namespace Inventory Reconciliation
def reconcile_namespaces(admin, active_tenant_ids: set):
"""Find and flag orphaned namespaces."""
namespaces = admin.list_namespaces()
tenant_namespaces = {
ns["name"]: ns for ns in namespaces
if ns["name"].startswith("tenant-")
}
# Find namespaces without active tenants
for ns_name, ns_info in tenant_namespaces.items():
tenant_id = ns_name.replace("tenant-", "")
if tenant_id not in active_tenant_ids:
print(f"ORPHANED: {ns_name} (created: {ns_info.get('created_at')})")
# admin.delete_namespace(ns_name) # uncomment to auto-clean
Implement Tenant Isolation Today
Dakera namespaces give you SOC2-ready data isolation in under 30 minutes. One namespace per tenant, zero cross-tenant leakage — guaranteed at the storage layer.
Set Up Tenant Isolation →