Mental Model: Multi-Tenant "Chat with Docs" SaaS¶
The tenant-isolation story. This use case exists to prove that the framework can safely serve many customers from one process without one tenant's data ever leaking into another's context — even under concurrent load, even when the in-process cache is warm, even when a stray retry middleware retries a request that a different tenant is also making.
Problem¶
You sell one product to many companies, and they all share one running process. Acme's documents and Globex's documents live in the same database, go through the same cache, and are answered by the same agent code. The only thing keeping them apart is that every lookup is tagged with whose data it is.
Get that tagging wrong in one place and Acme's agent cites a paragraph from Globex's contract. There is no error, no exception, no failed test — just a confident answer containing another customer's data. It is the single worst bug this kind of product can have, and it is usually a one-line omission.
Which is why isolation cannot be a promise the application layer remembers to keep; it has to be a property the framework enforces structurally. The threat model isn't primarily malice — the more common failure is a well-meaning optimization (a shared cache, a shared memoize middleware, a "helpful" global rate limiter) that reads or writes across the tenant axis by accident.
A tenant is identified by Scope(org_id, domain_id). Every layer that
touches persistent state (memory, cache, quota meter, audit log) must key
its state on that scope. The framework's job is to make that keying:
- Explicit — no primitive silently unpartitions across tenants.
- Ergonomic — the app doesn't thread scope by hand through every call;
it lives on
RunContext.scopeand every consumer reads from there. - Verifiable — a grep or a test can prove tenant isolation across every hop.
User experience¶
Company A signs up. Their SOC 2 evidence PDFs get uploaded and indexed under
Scope(org_id=1, domain_id=1). Company B does the same under Scope(2, 1).
At t=0, Ellen (org 1) asks "What's our incident-response protocol?" At
t=3ms, Priya (org 2) asks the exact same question. Both requests hit the
same server process, the same LLM adapter, the same InMemoryStore, the
same warm CachedMemory.
Ellen sees her company's real protocol, cited to her SOC 2 doc. Priya sees her company's protocol, cited to her SOC 2 doc. Neither sees a hint the other exists. Their token counts accrue to their own tenant Quota, not to each other's.
Ellen finishes her chat, refreshes, asks the same question again. This time CachedMemory has her result — instant reply, no LLM call, no wasted spend — but only for Ellen's tenant. Priya asking the same question a minute later still misses the cache in her partition.
How it actually works end-to-end¶
Walk through the internal state as one successful request flows through the
framework. Ellen's POST /chat arrives; the server has already extracted
her Scope(1, 1) from her JWT.
t=0ms — Request setup. The HTTP layer builds a RunContext:
ctx = RunContext(
correlation_id="chat-abc123",
scope=Scope(org_id=1, domain_id=1),
budget=Budget(max_cost_usd=0.10, max_calls=8),
services=Services(invoker=<shared>, observer=<per-request>,
trace=<per-request>),
meters=[quota_per_org], # tenant-partitioned by scope.key()
cancel=CancellationToken(),
autonomy="auto",
)
The invoker and quota_per_org are shared across all tenants — that's
what makes the framework cheap. What partitions is scope.
t=1ms — Agent.run entered. The Agent's ReActCognition begins its drive.
It resolves the RequestBuilder, deep-copies its termination condition (per
AKT34 — no cross-drive state), then hands off to _iterate.
t=2ms — Invoker.stream called. ReAct constructs a ChatRequest,
threads the output_adapter through meta={"output_adapter": None} (nothing
structured here), and calls ctx.invoker.stream(req, ctx, meta=...). The
Invoker wraps this into a Call("chat", req, ctx, meta={...}) and runs
the chat-middleware chain.
t=3ms — Meter guard fires. The meter() middleware runs
on_request. It iterates ctx.all_meters — which is [budget, quota] —
and calls guard(call) on each. Budget.guard checks the run-wide cost
ceiling. Quota.guard derives key = call.ctx.scope.key() = "org1:dom1"
and checks THAT partition's RPM / TPM / USD windows. If either meter is
over ceiling, MeterExceeded raises here, before any spend.
t=4ms — LLM stream begins. The chain terminates in the LLM adapter's
stream(...). Deltas start flowing back:
- Delta 1: text="Your", no tool_calls, no usage
- Delta 2: text=" incident", ...
- Delta N (terminal): text="", finish_reason="tool_calls",
tool_calls=(ToolCall("c1", "search_docs", {"query": "incident response protocol"}),)
Each text delta the cognition emits as StreamEvent("message_delta", text=...)
so the SSE handler can push it to Ellen's browser in real time.
t=~800ms — Assemble deltas, dispatch tool. assemble_deltas folds
the stream into an LLMResult(content="Your incident...", tool_calls=(tc,)).
Because the response has tool_calls, ReAct appends the assistant turn to
the transcript and dispatches the tool via ctx.invoker.invoke_tool(...).
t=~801ms — Tool executes. The search_docs FunctionTool's inner
callable reaches into memory:
memory = ScopedMemory( # tenant guard
CachedMemory( # in-proc TTL cache
VectorMemory(vector=...) # concrete backend (a VectorPort impl)
)
)
ScopedMemory._check(ctx)runs FIRST — readsctx.scope.org_id=1andctx.scope.domain_id=1, both truthy, returns True. If ANY layer forgot to thread scope, this is the choke point that raisesPermissionError.CachedMemory.query(query, k=5, ctx, where=None)computeskey = ("org1:dom1", "incident response protocol", 5, "{}")and looks it up. On the first call — miss. Delegates toinner.query.VectorMemory.search(scope=Scope(1,1), query="incident...", k=5)returns threeMemoryItemobjects from Ellen's tenant's index. Different vector backends implement scope differently (namespace, filter, separate index); the port contract just says "search within this scope".- CachedMemory stores
(now, list(items))— a defensive copy — under the scope-partitioned key. Returnslist(items)— another defensive copy — to the caller.
t=~850ms — Tool result flows back. The search_docs result becomes a
Message(role="tool", content="<chunks joined>", tool_call_id="c1") and
lands in the transcript. ReAct emits StreamEvent("tool_result", tool_call=..., tool_result=...).
t=~851ms — Next LLM turn. The transcript now has the tool result;
another invoker.stream(...) call. The LLM synthesizes a final answer citing
the chunks. This time there are no tool_calls — the terminal delta has
finish_reason="stop".
t=~1400ms — Charge + emit final. Meter middleware's on_response
fires: budget.charge(call, usage) and quota.charge(call, usage) each
land the spend under scope.key()="org1:dom1". budget.spent_usd for THIS
run goes up; quota._charges["org1:dom1"] gets a new entry. Ellen's meter
moved; the shared meter's other partitions are untouched.
t=~1401ms — Final event. ReAct yields
StreamEvent("final", result=AgentResult(output="Your incident response...", usage=<total>, ...), usage=<total>).
The SSE handler closes the stream.
Meanwhile, Priya's parallel request has been running through the same
Invoker, same LLM adapter, same CachedMemory instance — but with
ctx.scope=Scope(2, 1). Every scope-keyed lookup lands in a different
partition. Ellen's cache entries are invisible to Priya's queries.
Composition¶
UI ──▶ chat endpoint
│ scope = Scope(org_id=A, domain_id=…)
▼
Agent(name="assistant",
cognition=ReActCognition(tools=[search_docs]),
output=None)
│
▼
Middleware chain (shared Invoker; per-tenant Quota):
tracing → meter(budget + per-tenant Quota) →
retry → LLMPort
│
▼
search_docs = FunctionTool wrapping:
ScopedMemory( # tenant gate
CachedMemory( # in-proc TTL cache, scope-partitioned
VectorMemory( # concrete backend
vector=port # a VectorPort — tenant isolation
))) # comes from ctx.scope via the port
Adapter contract: LLM/Store adapters MUST NOT retain state keyed on anything other than the process/instance. Per-tenant isolation lives in scope, threaded through RunContext.child(), NOT inside adapters.
Where it can fail¶
Enumerated so a code reviewer can walk the list. Each is a real risk if the framework's invariants slip.
Framework-level failures (the framework's job to prevent)¶
- Cache key drops the scope axis. A refactor to
CachedMemory._keythat forgets thescope_keyargument → tenant B's identical query hits tenant A's cache entry. Locked bytest_cache_is_partitioned_by_scope. - Cache returns internal list by reference. A caller that mutates
the returned list corrupts the cache for the whole TTL window,
affecting subsequent hits from the same tenant. Locked by
test_cached_result_is_isolated_from_caller_mutation. wheredict with unhashable values crashes the lookup. Filters like{"tags": ["a", "b"]}would break the cache key. Locked bytest_cache_accepts_unhashable_where_values.- Meter middleware not iterating
ctx.all_meters. A regression that only guardsBudgetand skips per-tenantQuota→ runaway spend under load. Locked bytest_quota_isolates_tenants_end_to_end_through_meter_middleware. - Meter iteration order changes. Currently
Budgetis first, so its trip short-circuitsQuota.charge. A reordering could double-charge or under-charge. Locked bytest_budget_and_quota_both_engaged_through_meter_middleware. - ScopedMemory's enforce becomes bypassable. If
ScopedMemory._checkraised only warnings instead ofPermissionError, a scope-less run would silently reach VectorMemory. Locked bytest_scoped_memory_rejects_missing_scope. RunContext.child()doesn't inherit scope. A sub-agent (e.g., a Skill invoked as a tool) would run without a tenant identity → the ScopedMemory gate would fail loud, but only after the sub-run had already started. Structurally verified:child()setsscope=self.scope(shared reference).
Integration-level failures (the wiring's job to prevent)¶
Invokerwiring forgets the meter middleware. Nometer()inchat_middleware→ no Quota enforcement → tenant B's runaway consumption burns tenant A's budget. Not lockable at the framework layer; a wire-up smoke test in the SaaS product's tests is the right place.RunContext.scopeextracted from the wrong request field. JWT parsing bug puts tenant B's scope into tenant A'sRunContext. Not a framework concern; the SaaS product's auth layer.VectorMemorybackend doesn't honour the scope onsearch. A Pinecone / Weaviate namespace bug returns cross-tenant vectors even though the scope was correctly threaded. TheVectorPortcontract requires per-scope isolation; the adapter carries this contract.- Streaming transport crosses tenant sessions. The framework yields
StreamEvents; whichever transport (SSE, WebSocket) fans them out must not route tenant A's tokens onto tenant B's connection. Transport layer's job; the framework only guarantees per-run stream ordering.
Application-level failures (the app's job to prevent)¶
- Tools written that ignore
ctx. Asearch_docsimplementation that reads a global connection pool without threading scope would read across tenants. Only the tool author can prevent this — but the framework'sTool.run(args, ctx)signature at least surfacesctxso the author has the tenant context available. - Custom
MemorySourcedoesn't respect scope inwrite. TheMemorySource.writecontract asks the impl to key byctx.scope; a custom impl that forgets writes tenant A's items into the shared namespace. Same principle: the framework providesctx; the impl must use it. scope.org_id=0is treated as unscoped by_default_enforce(falsy int). If your tenant IDs start at 0, override the enforce callable or use non-zero IDs.
Invariants¶
Every row is a property the framework MUST hold. Each row names the test that locks it in — if you're changing code that touches these areas, verify the test still asserts what its name claims.
| Invariant | Concrete failure if violated | Locked by |
|---|---|---|
Scope partitions the cache key — (scope_key, query, k, where) |
Tenant B's identical query hits tenant A's cached items → cross-tenant read | test_cache_is_partitioned_by_scope in tests/source/unit/state/memory/test_cached_memory.py |
ScopedMemory.enforce cannot be bypassed at wire-time |
A caller layering CachedMemory(VectorMemory) without the outer guard reads every tenant's index |
test_scoped_memory_rejects_missing_scope in tests/source/unit/state/memory/test_scoped_memory.py |
where dict may contain unhashable values without crashing the cache |
A filter like {"tags": ["security","finance"]} blows up the key lookup, forcing repeated round-trips |
test_cache_accepts_unhashable_where_values |
| Cache reads return a defensive copy | UI dedupes the returned list → mutates the cache → next tenant's read sees the dedup | test_cached_result_is_isolated_from_caller_mutation |
| Cache key stable across dict ordering | Semantically-equal filters double-fetch → cost overrun + non-deterministic behaviour | test_cache_key_stable_across_where_dict_ordering |
| Per-tenant Quota clamps runaway spend | A stuck retry loop on tenant B burns tenant A's budget | test_quota_isolates_tenants_end_to_end_through_meter_middleware, test_budget_and_quota_both_engaged_through_meter_middleware in tests/source/unit/core/runtime/test_meter.py |
| Streaming per response is in-order | Interleaved responses corrupt one user's transcript with another's tokens | Framework-level: assemble_deltas reducer + property tests on stream operators |
RunContext.child() inherits scope |
A sub-agent runs without tenant identity; ScopedMemory then blocks it, but only after the child was already spawned | Structural — RunContext.child sets scope=self.scope |
Every retrieval tool declares caps=("private_data",) |
Adding an egress tool later doesn't auto-flag the emergent trifecta | Needs a lint check — currently a gap; a RunPolicy panel test would cover it |
Expected output on a successful run¶
After Ellen's chat completes cleanly, the framework produces the following concrete artifacts. Any deviation from these shapes on a green run is a signal.
The final AgentResult¶
AgentResult(
output="Your incident-response protocol at Company A begins with...",
usage=Usage(
input_tokens=1247, # prompt + tool result
output_tokens=312, # assistant tokens
cost_usd=0.0034, # computed by adapter
cache_read_tokens=0, # no prompt-cache hit on cold turn
cache_write_tokens=0,
),
partial=False, # ran to a stop, not a repair-exhausted fallback
evals={}, # no HITL suspend, no policy verdict attached
parsed=None, # no structured output adapter
prompt_version="researcher-chat-v1", # from the wired RequestBuilder
)
Key checks:
partial=False— a healthy run completes;partial=Truesignals amax_iterationsceiling, an unrepairedparsefailure, or a suspended HITL run. On a chat, onlyparsefailure would produce partial (and this use case doesn't wireparse).evalsis empty — this run had no HITL gates, no policy verdicts, no step-level errors surviving the loop.usage.cost_usdmatches the sum of every LLM turn's charge on this run.
The StreamEvent sequence¶
The stream that flows to the SSE handler (ordered):
StreamEvent(type="message_delta", text="Your")— first tokenStreamEvent(type="message_delta", text=" incident")— subsequent tokens- … (dozens of message_delta events until the model requests a tool call)
StreamEvent(type="tool_call", tool_call=ToolCall("c1", "search_docs", {"query": "..."}))StreamEvent(type="tool_result", tool_call=<same>, tool_result=<chunks joined>)StreamEvent(type="step", text="iteration:1")StreamEvent(type="message_delta", text="Your")— start of turn 2- … (more text deltas)
StreamEvent(type="final", result=AgentResult(...), usage=Usage(...))
If you saw an interrupt event, that would mean a requires_approval=True
tool was invoked. This use case wires no such tool.
Observer stream¶
Product-facing observations delivered to the wired ObserverPort:
Observation(kind="progress", render="chat run starting", agent="assistant", run_id="chat-abc123", ...)- Zero-or-more
Observation(kind="partial_result", ...)if the app emits them - Exactly one
Observation(kind="result", render="chat done", payload={"tokens": 312}, ...)
The run_id on every observation equals ctx.correlation_id="chat-abc123".
The trace_context field is set iff a tracer was wired and a span was open
at emit time.
Meter state after the run¶
Assuming tenants 1 and 2 both ran once:
budget.spent_usd == 0.0034 + ... # shared: the run-wide budget
budget.calls == 2 # both chat turns counted
quota._charges["org1:dom1"] # one entry for Ellen's run
quota._charges["org2:dom1"] # one entry for Priya's run
quota._charges MUST have two distinct keys with one entry each. If they
share a key or one is missing an entry, isolation broke.
CachedMemory state after the run¶
If Ellen's query hit the memory tool once with k=5:
cached_memory._cache
# {("org1:dom1", "incident response protocol", 5, "{}"): (now_ts, [<3 items>])}
One entry, keyed on the tenant partition. Priya's identical query would
add a second entry with ("org2:dom1", ...).
Verification protocol¶
How to actually check the design is working — not just "tests pass" but "the invariants hold structurally in the current code".
1. Automated: run the tests locked to the invariants¶
cd agentkit
uv run pytest tests/source/unit/state/memory/test_cached_memory.py \
tests/source/unit/state/memory/test_scoped_memory.py \
tests/source/unit/core/runtime/test_meter.py
Expected: all pass. Any failure is a live invariant violation.
2. Structural: grep for load-bearing scope threading¶
cd agentkit
# Every RunContext.child call must NOT drop scope.
grep -n "RunContext(" agentkit/core/runtime/context.py
# Expected: only one production construction; child() passes scope=self.scope.
# Every MemorySource impl reads ctx.scope in query/write.
grep -rn "async def query" agentkit/state/memory/
grep -rn "async def write" agentkit/state/memory/
# Expected: every impl's signature takes `ctx: Ctx` and uses it.
# CachedMemory._key includes scope_key.
grep -n "def _key" agentkit/state/memory/decorators.py
# Expected: signature has scope_key parameter and it's included in the tuple.
3. Adversarial: two-tenant smoke test¶
Build the smallest possible reproduction:
# Not committed — one-off verification script.
import asyncio
from agentkit.agents import Agent
from agentkit.core.runtime import Budget
from agentkit.core.contracts.types import Scope
from agentkit.state.memory import CachedMemory, ScopedMemory
from agentkit.core.runtime import Invoker, Quota, RunContext, Services
from agentkit.core.policies import meter, tracing
quota = Quota(max_usd=0.05, clock=lambda: 1000.0)
invoker = Invoker(llm=<FakeLLM>, chat_middleware=[meter()])
async def one_run(org_id: int):
ctx = RunContext(
correlation_id=f"run-{org_id}",
scope=Scope(org_id=org_id, domain_id=1),
services=Services(invoker=invoker),
meters=[quota],
)
# ... run an Agent with a memory tool ...
await asyncio.gather(one_run(1), one_run(2))
# Verify: quota._charges has two distinct keys ("org1:dom1" and "org2:dom1"),
# each with exactly the entries that tenant produced. No key overlap.
If this succeeds, the tenant-isolation story is holding structurally.
4. What "failing" would look like¶
quota._charges["org1:dom1"]has an entry that came from tenant 2 → cache key axis missing scope.CachedMemory._cachehas an entry keyed only on(query, k, where)with no scope prefix → key regression.- Tenant 2's Agent returns tenant 1's document text → VectorMemory scope filter regression OR ScopedMemory bypass.
- Both tenants exhaust the same quota window at the same time → per-tenant partitioning collapsed to a single shared bucket.
Correctness checklist (for future changes)¶
When touching any of these files, re-verify against this use case:
agentkit/state/memory/decorators.py—_key(...)must always include the scope axis. Grep:def _key, verify signature carries a scope arg.agentkit/state/memory/decorators.py—ScopedMemory._checkruns BEFORE any inner call inquery/write. Grep:self._check(ctx)at entry of both methods.agentkit/state/stores/*.py— anyget/setwhere key isn't scoped is a leak surface. AllStorePortwrites routed throughidempotency_key(scope, ...)or an equivalent prefix.agentkit/core/runtime/context.py—RunContext.child()inherits scope (verify), so a child agent operates on the same tenant.
The primitives it exercises¶
| Primitive | Role here | Why load-bearing |
|---|---|---|
RunContext.scope |
Tenant identity, threaded via ctx.scope on every call |
The only piece of state that tells any layer "who is this for" |
ScopedMemory |
Fail-loud tenant guard around VectorMemory | Refuses on missing scope; last line of defence if a caller forgets |
CachedMemory |
TTL cache for identical repeated queries | Cheap wins on repeated lookups within a session |
VectorMemory |
Backend semantic search, indexed per-tenant | Only surface that actually touches other tenants' bytes |
ReActCognition |
Tool-calling loop over the retrieval tool | Multi-turn refinement, "search more" reruns |
FunctionTool.caps |
("private_data",) on search_docs |
Feeds RunPolicy to catch trifecta later if egress tools get added |
Budget + per-tenant Quota meter |
Cost/tokens per tenant per window | Prevents one abusive tenant from spending the entire pool |
StreamEvent |
Token streaming to the UI | User-facing progressive rendering |
What it deliberately doesn't use¶
Checkpointer— chats are ephemeral; no crash-resume story.SignalChannel— single agent, no coordination.RunPolicyblock-mode — retrieval-only trifecta stays under threshold (no egress, no untrusted-content), so flag-mode is enough.Workflow— no deterministic graph; conversation is emergent.Skill.as_agent— this Agent is bespoke to the product, not a reusable recipe.
Design tensions to hold in mind¶
- Shared vs per-tenant Invoker: shared is cheaper (one middleware
chain, one httpx pool) but harder to reason about isolation. We
chose shared because
ctx.scopeis threaded end-to-end; if that invariant ever breaks, we lose the cheap-shared property. - Cache TTL vs eviction on write: writes invalidate ALL cache
entries currently (
CachedMemory.writeclears_cache). Fine for low-write patterns; if writes are hot, a per-tenant partition + scoped invalidation is the next step. - Streaming ordering when the LLM adapter is shared: assumes the
provider's SSE stream is per-request. Never share a Delta stream
between two Calls. Structurally enforced by
Callbeing one-per-request.
What this use case tests about agentkit (reverse view)¶
If this design breaks in production, the framework has a bug. The specific things this design proves the framework must support:
- Scope threading works end-to-end from HTTP entry → middleware → tool → memory.
- No in-process shared cache silently unpartitions tenants.
- The
MemorySourcedecorator stack is safe to compose in any order that makes semantic sense (ScopedMemoryoutermost,CachedMemoryinside). - The frozen
ToolCall.argumentspayload (aFrozenDict) round-trips cleanly through the tool boundary even when args carry per-tenant filters — and the freeze is DEEP, so a nested per-tenant filter dict cannot be edited by a tool impl on the way through either.
Verification snapshot¶
Last audited against the current tree:
- Automated invariant tests:
pytest tests/source/unit/state/memory/test_cached_memory.py tests/source/unit/state/memory/test_scoped_memory.py tests/source/unit/core/runtime/test_meter.py→ 29 passed, 0 failed. RunContext.child()inherits scope:scope=self.scope(shared reference, read-only) — verified inagentkit/core/runtime/context.py:child.CachedMemory._keysignature includes scope:_key(query, k, where, scope_key)— the scope axis is a required positional argument, not optional.ScopedMemory._checkfires on both entry points:queryat line 71 andwriteat line 75 both callself._check(ctx)before delegating to the inner source.
All four load-bearing structural checks pass. The design holds in the current tree.