Carrying the tenant across a hop¶
docs/tenancy.md covers the tenant in-process: a contextvar, bound at a
boundary, read by everything below. This page covers what happens when the work leaves the
process — to a broker, a peer, a tool server, a durable workflow — where a contextvar is
worth nothing and the tenant is whatever the producer put in the message.
Left to itself every integration invents its own field. One transport says x-tenant,
a workflow input says tenantId, a tool's metadata says org. A shared service cannot
honour three spellings, and the one it does not know about is the one that silently runs
under the consuming worker's own tenant. So there is one contract.
The contract¶
| Header | adk-tenant, lower-case |
| Payload key | adk_tenant, for carriers whose message is a flat string map |
| Version | adk/1, leading the value |
| Encoding | adk/1 tenant=acme;user=ada;locale=en-GB, values percent-encoded |
| Ceiling | 1024 bytes; optional fields are shed to fit and the result marked partial |
Percent-encoding is not decoration: without it a user containing ;tenant=globex
rewrites the field beside it.
Sending¶
from tesserix_adk.core import carried, current_tenant
await client.post(url, headers={**headers, **carried(current_tenant())})
For work whose message is its input rather than a request with headers — a queued item, a workflow argument — the same context goes in the payload:
from tesserix_adk.core import current_tenant, in_payload
await queue.enqueue(
WorkItem(id=job_id, tenant=tenant, payload=in_payload(current_tenant(), {"booking": "AB-1"}))
)
WorkItem.tenant already carries the tenant for the queue's own isolation. The payload
carries the rest of the context — the acting principal, the locale, the correlation id —
which is what an audit entry on the far side needs and what the queue's own field cannot
express.
Receiving¶
from tesserix_adk.core import arriving
async def handle(message):
with arriving(message.headers, authenticated=message.peer_tenant):
await do_the_work() # reads the producer's tenant from the context
arriving refuses before it binds anything. restored and of_payload do the reading
without binding, for a consumer that needs the context as a value.
What is refused, and why¶
Every refusal is a TenantContextError carrying a reason, so a consumer can branch on a
value rather than on message text.
reason |
When | Why not something softer |
|---|---|---|
missing |
No header, no payload key | The consumer's own tenant is not a default. A default is one typo away from being every tenant. |
malformed |
Unreadable, or no tenant named | Half-reading a context yields work attributed to nobody. |
version |
A version this build does not know | Reading unknown fields by position is how a tenant becomes a locale. Refuse, and let the deploy skew be visible. |
contradicted |
The context names a different tenant than the caller authenticated as | The payload never outranks the credential. Overriding silently would hide the finding; the disagreement is the finding. |
oversized |
Even the tenant alone exceeds the ceiling | Truncating is not an option — half a tenant name is a different tenant. |
missing and malformed are dead-letter cases. contradicted is an authorization event
worth alerting on. version is a deploy-skew signal.
A consumer that already holds a different tenant gets TenantCrossingError from
tenant_scope, not a silent rebind: a worker with its own tenant bound picking up
another's work is the exact failure this exists to catch.
Durable and delayed work¶
The context is recorded as input, never taken from ambient state. That is what makes a replay deterministic: a workflow replayed on another worker a week later reconstructs the tenant it started under instead of inheriting the replayer's. Redelivery after a retry or out of a dead-letter queue reads the same bytes and lands on the same tenant.
A batched or multiplexed frame carrying several tenants is several messages. Bind each one separately; one scope over the batch is one tenant's data leaking into another's handler.
Transports with a header ceiling¶
Where the context does not fit, optional fields are dropped least-load-bearing first —
crossing, correlation_id, region, locale, then user — and the result carries
partial=1. The far side sees context.partial is True and can tell a field that was
absent from one that was lost. The tenant is never shed.
Proving your transport carries it¶
from tesserix_adk.testing import TenantPropagationConformance
class TestNatsCarriesTheTenant(TenantPropagationConformance):
def round_trip(self, headers):
return published_and_received(headers)
The suite checks that the whole context survives, that header case is not load-bearing, that a ceiling still delivers an intact tenant, and that consecutive messages on one connection do not bleed into each other.
Known limitations¶
- Propagation is not universally automatic. MCP, the Tesserix peer protocol, and durable workflow primitives now exist and carry their own typed authority/context records. A custom transport must still adopt this carrier or an equivalent authenticated mapping. The official A2A card/client adapter does not inject tenant headers by itself; bind tenant in the authenticated gateway or official client/server interceptor.
- Egress is explicit.
carried(current_tenant())is a call the producer makes. A transport that forgets it produces a message the consumer refuses — loudly, at the far end, rather than quietly at the near one. - The ceiling applies to payloads too.
in_payloaduses the same encoder, so a payload sheds fields it did not need to. One encoding is worth more than the bytes.