Summary
current_tenant_id is a bare ContextVar with no accompanying helper for setting and resetting it safely. TenantMiddleware gets this right internally (set in a try, reset in finally), but nothing ships that pattern for the many other places a module needs to bind a tenant outside a request — CLI commands, deferred jobs, health checks, seed scripts. A bare current_tenant_id.set(...) with no matching reset leaks into whatever task runs next, which under httpx.ASGITransport in tests is the test's own task — a forgotten reset in one test can silently poison tenant state in the next.
Observed
simple_module_db/listeners.py:23:
current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
Nothing else in simple_module_db sets or resets it — every set/reset pairing is left to the caller.
Minimal reproduction (mirrors the spike's test_1f_contextvar_propagation):
import asyncio
from simple_module_db.listeners import current_tenant_id
async def naive_binding():
current_tenant_id.set("LEAK") # no matching reset — easy to write, nothing warns
async def main():
assert current_tenant_id.get() is None
await naive_binding()
leaked = current_tenant_id.get()
print(leaked) # 'LEAK' — visible in the CALLER's task, not just naive_binding's
assert leaked == "LEAK"
asyncio.run(main())
Confirmed on Postgres in the spike (FACT 1f, test_spike_orm_tenancy.py::test_1f_contextvar_propagation): "a bare current_tenant_id.set() in awaited code (same task) is still 'LEAK' after it returns: every set must be paired with reset". The same file also confirms the contextvar correctly reaches AsyncSession.run_sync's sync greenlet and copies into asyncio.create_task children — so propagation itself is fine; only the set/reset discipline is unowned.
Expected
A single framework-provided helper for binding current_tenant_id for the duration of a block, used everywhere the framework itself needs to bind a tenant outside request middleware (CLI, jobs, tests), so "always reset" isn't a convention every module has to remember and re-implement.
Why a module cannot work around it
A module can write its own context manager (records' design does, as tenant_scope()), but the contextvar itself is simple_module_db's, and every other module doing the same thing multiplies the chance one of them gets it wrong — exactly the class of bug FACT 1f demonstrates.
Proposed API
Ship tenant_scope(tenant_id: str) in simple_module_db (or simple_module_hosting) as a context manager that sets current_tenant_id, always resets on exit (even on exception), and documents "never call current_tenant_id.set() directly" as the supported contract.
Context
Found while making the records module (antosubash/smpy_modules#37) multi-tenant; design doc docs/plans/2026-09-23-records-multitenancy.md there.
Summary
current_tenant_idis a bareContextVarwith no accompanying helper for setting and resetting it safely.TenantMiddlewaregets this right internally (setin atry,resetinfinally), but nothing ships that pattern for the many other places a module needs to bind a tenant outside a request — CLI commands, deferred jobs, health checks, seed scripts. A barecurrent_tenant_id.set(...)with no matchingresetleaks into whatever task runs next, which underhttpx.ASGITransportin tests is the test's own task — a forgotten reset in one test can silently poison tenant state in the next.Observed
simple_module_db/listeners.py:23:Nothing else in
simple_module_dbsets or resets it — every set/reset pairing is left to the caller.Minimal reproduction (mirrors the spike's
test_1f_contextvar_propagation):Confirmed on Postgres in the spike (FACT 1f,
test_spike_orm_tenancy.py::test_1f_contextvar_propagation): "a barecurrent_tenant_id.set()in awaited code (same task) is still'LEAK'after it returns: every set must be paired with reset". The same file also confirms the contextvar correctly reachesAsyncSession.run_sync's sync greenlet and copies intoasyncio.create_taskchildren — so propagation itself is fine; only the set/reset discipline is unowned.Expected
A single framework-provided helper for binding
current_tenant_idfor the duration of a block, used everywhere the framework itself needs to bind a tenant outside request middleware (CLI, jobs, tests), so "always reset" isn't a convention every module has to remember and re-implement.Why a module cannot work around it
A module can write its own context manager (
records' design does, astenant_scope()), but the contextvar itself issimple_module_db's, and every other module doing the same thing multiplies the chance one of them gets it wrong — exactly the class of bug FACT 1f demonstrates.Proposed API
Ship
tenant_scope(tenant_id: str)insimple_module_db(orsimple_module_hosting) as a context manager that setscurrent_tenant_id, always resets on exit (even on exception), and documents "never callcurrent_tenant_id.set()directly" as the supported contract.Context
Found while making the
recordsmodule (antosubash/smpy_modules#37) multi-tenant; design docdocs/plans/2026-09-23-records-multitenancy.mdthere.