Summary
TenantMiddleware resolves the tenant from the signed-in user's tenant_id first and falls back to the configured header, then stores only the result in request.state.tenant_id. A module serving a cacheable read (a public API with ETag and Cache-Control: public) has to declare that the response varies on the tenant header. But when the tenant came from the user's account, the response depends on the session cookie, not the header, and Vary: <header> is wrong: a shared cache keyed on the header would serve a signed-in user's tenant to an anonymous caller with no header. The module cannot tell the two cases apart from request.state.
Observed
simple_module_hosting/middleware.py:185-196 (0.0.26):
user = getattr(request.state, "user", None)
if user is not None:
tenant_id = getattr(user, "tenant_id", None)
if tenant_id is None and self.header:
header_value = Headers(scope=scope).get(self.header)
if header_value:
tenant_id = header_value
request.state.tenant_id = tenant_id
Nothing records which branch produced tenant_id.
Expected
The request state says how the tenant was resolved, so a module can emit Vary: <header> for header-resolved tenants and Cache-Control: private for user-resolved ones.
Why a module cannot work around it
The records module currently re-derives it: it treats any request with request.state.user set as user-resolved and marks the response private, and everything else as header-resolved. That duplicates the middleware's rule and breaks silently if the rule changes (for example if #358 is fixed by consulting the header only for anonymous requests, which is the same outcome, or by a setting, which is not).
Proposed API
Either request.state.tenant_source: Literal["user", "header"] | None, or have TenantMiddleware append Vary: <header> itself whenever it consulted the header, so modules never have to know the header name.
Context
Found while making the records module (antosubash/smpy_modules#37) multi-tenant; design doc docs/plans/2026-09-23-records-multitenancy.md there, gap L17.
Summary
TenantMiddlewareresolves the tenant from the signed-in user'stenant_idfirst and falls back to the configured header, then stores only the result inrequest.state.tenant_id. A module serving a cacheable read (a public API withETagandCache-Control: public) has to declare that the response varies on the tenant header. But when the tenant came from the user's account, the response depends on the session cookie, not the header, andVary: <header>is wrong: a shared cache keyed on the header would serve a signed-in user's tenant to an anonymous caller with no header. The module cannot tell the two cases apart fromrequest.state.Observed
simple_module_hosting/middleware.py:185-196(0.0.26):Nothing records which branch produced
tenant_id.Expected
The request state says how the tenant was resolved, so a module can emit
Vary: <header>for header-resolved tenants andCache-Control: privatefor user-resolved ones.Why a module cannot work around it
The records module currently re-derives it: it treats any request with
request.state.userset as user-resolved and marks the responseprivate, and everything else as header-resolved. That duplicates the middleware's rule and breaks silently if the rule changes (for example if #358 is fixed by consulting the header only for anonymous requests, which is the same outcome, or by a setting, which is not).Proposed API
Either
request.state.tenant_source: Literal["user", "header"] | None, or haveTenantMiddlewareappendVary: <header>itself whenever it consulted the header, so modules never have to know the header name.Context
Found while making the records module (antosubash/smpy_modules#37) multi-tenant; design doc
docs/plans/2026-09-23-records-multitenancy.mdthere, gap L17.