Skip to content
ONTO

Multi-Tenant Memory: Scoping AI Agents for SaaS

If you're running an agent SDK in a multi-tenant SaaS, memory leakage between tenants is the bug that ends your company. Here's the architecture that prevents it structurally.

· 9 min read · ONTO team

Multi-tenant SaaS has one bug nobody recovers from: tenant A sees tenant B’s data. Customers tolerate slow days, broken features, and bad design. They do not tolerate “we leaked your competitor’s information into your agent’s prompt.”

If your agent has memory, this risk is real, and it’s structurally different from API-level multi-tenancy. The memory layer has to enforce the boundary, and it has to do it at every read and every write, on every code path.

This post is about how to design that boundary so it doesn’t depend on every code change being correct.

The four leak vectors

Where can tenant A’s data end up in tenant B’s context?

  1. Direct read. Code reads memory without filtering by tenant_id.
  2. Vector retrieval. Embeddings retrieve cross-tenant chunks because the index isn’t tenant-scoped.
  3. Background worker confusion. A summarization job runs against the wrong tenant’s data because the job spec didn’t carry tenant_id.
  4. Shared cache. A response cache keyed only on prompt content returns tenant A’s cached response to tenant B.

Each of these has happened in production at companies you’ve heard of. The fix in every case is the same: tenant_id is a required field on every memory operation, enforced by the runtime, not by convention.

The structural fix

In ONTO, every Assertion carries tenant_id. Every query requires it. Every Tool that reads memory declares which tenant_id it operates on. Cross-tenant operations require an explicit cross-tenant consent scope that you grant in narrow contexts.

# Write — must carry tenant_id.
onto.memory.add_assertion(
    tenant_id="acme",
    subject="customer_42",
    predicate="tier",
    object="gold",
    consent_scopes=["memory:write"],
)

# Read — must carry tenant_id.
asserts = onto.memory.query(
    tenant_id="acme",
    subject="customer_42",
    consent_scopes=["memory:read"],
)

# Cross-tenant read — refused unless explicit scope.
try:
    onto.memory.query(
        tenant_id=None,           # request across tenants
        subject="customer_42",
    )
except ConsentDenied as e:
    # e.code == "tenant_mismatch"
    pass

The runtime refuses any cross-tenant operation that lacks the special scope. The denial is logged. You can grep for denials weekly to confirm nobody is trying.

The agent loop

A multi-tenant agent loop passes tenant_id through every call:

async def handle_request(tenant_id: str, user_id: str, message: str):
    result = await onto.run(
        message,
        tenant_id=tenant_id,
        user_id=user_id,
        consent_scopes=["memory:read", "memory:write", "support:read"],
    )
    return result.output

Every tool the agent calls inherits the tenant_id from the run. If a tool tries to read memory without it, the runtime refuses. There’s no fall-through to “default tenant” or “any tenant” — you either have a tenant_id or you don’t operate.

The vector store

Vector search needs the same discipline. Each vector carries tenant_id metadata; queries filter on it:

SELECT *, vector <-> $query AS distance
FROM documents
WHERE tenant_id = $tenant
ORDER BY distance
LIMIT 10;

The query plan should use a partial index on (tenant_id, vector) so the filter pushes down before the ANN search runs. Performance is good up to very large fanout.

For tenants with vast document corpora, partition the vector table or move to per-tenant collections in a vector database. The application code doesn’t change — only the storage strategy.

The background worker problem

Background workers (summarization, conflict detection, batch extraction) need tenant_id baked into the job spec:

@background_job
def summarize_session(tenant_id: str, session_id: str):
    # Loaded with tenant_id; can't accidentally pull from another tenant.
    session = onto.memory.query(tenant_id=tenant_id, session_id=session_id)
    summary = llm.summarize(session)
    onto.memory.add_assertion(
        tenant_id=tenant_id,
        subject=session.user_id,
        predicate="session_summary",
        object=summary,
    )

# Enqueue with tenant_id required.
queue.enqueue(summarize_session, tenant_id="acme", session_id="s_2026_04_12")

If the function signature requires tenant_id, the caller must provide it. The runtime checks it. The bug class “background worker ran without tenant context” disappears.

The cache problem

Prompt response caching is a real performance win — same prompt, same model, same response. But the cache key must include tenant_id, or the cache leaks:

# BAD — cache key is just prompt content.
cache_key = hash(prompt)

# GOOD — cache key includes tenant.
cache_key = hash((tenant_id, prompt, model_id))

This is so obvious that it’s the bug people make anyway. A cache decorator that doesn’t take tenant_id into its key is the kind of code review issue that gets missed in a hurry.

The structural fix: a cache wrapper that requires tenant_id in its function signature. The compiler / type checker enforces the call site.

The cross-tenant read use case

Sometimes you need cross-tenant operations:

  • Global analytics (“how many active tenants this month”).
  • Anonymized model evals (“performance across all production traffic”).
  • Cross-tenant deduplication (“this email appears in many tenants — likely public”).

For these, declare a cross-tenant consent scope and grant it only to the specific code path:

# Analytics service — gets explicit cross-tenant scope.
analytics_run = onto.run(
    "Compute monthly active tenants.",
    consent_scopes=["cross-tenant:read", "analytics:write"],
)

The audit log records every cross-tenant operation. A monthly review confirms the only cross-tenant operations were the legitimate analytics jobs.

The audit story

Two queries you should be able to run any time:

-- Any cross-tenant operation in the last 30 days?
SELECT count(*), code
FROM audit_log
WHERE event_type = 'consent_denied'
  AND code IN ('tenant_mismatch', 'cross_tenant_unauthorized')
  AND timestamp > now() - interval '30 days'
GROUP BY code;

-- Any successful cross-tenant read?
SELECT *
FROM audit_log
WHERE event_type = 'tool_call'
  AND 'cross-tenant:read' = ANY(consent_scopes)
  AND timestamp > now() - interval '30 days';

The first answers “were there leak attempts.” The second answers “were there legitimate cross-tenant operations.” Together they answer the security review.

The cost meter angle

Multi-tenant cost attribution is the other side of this story. You want to bill or analyze per tenant:

cost = onto.cost(tenant_id="acme")
# {
#   foreground: { tokens: 1_234_567, dollars: 4.92 },
#   background: { tokens: 8_456_021, dollars: 1.08 },
#   tools: { "search": 200_000, "calendar": 50_000 },
# }

This lets you turn “the AI bill was $5000 last month” into “tenant X accounted for $420, tenant Y for $300, …” Which means you can charge them, throttle them, or investigate them.

ONTO’s cost meter splits foreground vs background because the background work uses cheaper models — accounting that detail honestly matters for SaaS pricing.

A test before you ship

Run an integration test:

def test_no_cross_tenant_leak():
    onto.memory.add_assertion(tenant_id="A", subject="x", predicate="p", object="A_secret")
    onto.memory.add_assertion(tenant_id="B", subject="x", predicate="p", object="B_secret")

    a_results = onto.memory.query(tenant_id="A", subject="x")
    assert all(r.object == "A_secret" for r in a_results)
    assert not any(r.object == "B_secret" for r in a_results)

    b_results = onto.memory.query(tenant_id="B", subject="x")
    assert all(r.object == "B_secret" for r in b_results)
    assert not any(r.object == "A_secret" for r in b_results)

    with pytest.raises(ConsentDenied):
        onto.memory.query(tenant_id=None, subject="x")

If this test passes, your multi-tenant isolation is at least one structural level beyond “trust the code.”

Summary

Multi-tenant memory isolation is a bug class you can engineer out. Make tenant_id a required field on every memory operation. Make the runtime check it. Make cross-tenant operations require an explicit scope. Log every denial. Test the leak case.

The cost of building this in upfront is small. The cost of fixing it after a leak is your company.


The customer support industry page walks through a multi-tenant agent end-to-end. The features page covers consent scopes.

Frequently asked questions

Can I use a single Postgres for many tenants?

Yes — with row-level security or strict tenant_id filtering at every query. ONTO's runtime checks tenant_id on every memory read and write; the database is shared but logically isolated. For very large tenants, schema-per-tenant or DB-per-tenant is a valid further step.

What about the vector index?

Same model. Vectors are tagged with tenant_id; queries filter by it. pgvector + a tenant_id index gives good performance up to large fanout.

How do I audit that no cross-tenant read happened?

Every refusal goes into the audit log with a structured 'tenant_mismatch' code. A weekly query against this log is your evidence.

Build with ONTO

The agent SDK where humans drive the state.

Plan Mode, typed memory, per-call consent scopes, and open-weight defaults. Open source under MIT or Apache-2.0.