ADR-0010 -- Cross-adapter strategy unification for Phase 4B
Status: Accepted.
Date: 2026-05-17.
Deciders: Jhoelperaltap (Owner), Tech Lead (this codebase).
Supersedes: None.
Superseded by: None.
Related: Decision 4-A (top-level tenantshield.strategies module),
Decision 5-B (3 strategies + HostStrategy analog), Decision 6-A
(in-place Django refactor + re-export shim), Sub-fase 4B sub-decisions
(i) module structure per-file, (ii-B) TenantExtractionError retention
in tenantshield.strategies, (iii-A) adapter wrappers at adapter
level, DR-033 through DR-036 (Sub-fase 4B decisions), ADR-0007
(event-based enforcement; foundational architecture), ADR-0008 (Phase
3B middleware lifecycle; Phase 3B Django-bound strategy assumption
superseded here per BLOCKER #30 resolution), BLOCKER #30 (Phase 2B
Django-bound strategies; deferral closed empirically in Tarea 4B.5).
Context
Sub-phase 2B introduced the four extraction strategies (HeaderStrategy,
JWTStrategy, SubdomainStrategy, CallableStrategy) inside the
Django adapter. These were Django-specific by construction: typed
against django.http.HttpRequest, accessing request.META / .get_host()
directly, and registered under
tenantshield.adapters.django.middleware.strategies.
Sub-fase 3B's SQLAlchemy middleware took a different approach by
explicit decision: a callable-resolver pattern (the adopter supplies a
function that maps an ASGI/WSGI request-like object to a tenant id).
BLOCKER #30 (raised in Sub-fase 3B Tarea 3B.0 empirical exploration)
documented the architectural choice: Phase 2B strategy classes could
not be reused from the SA adapter without significant refactoring,
because the Django coupling was per-call (.META lookups, get_host
method) rather than just a type annotation. Three resolution paths
were considered; Path (c) was ratified: defer strategy
unification, ship Sub-fase 3B with the callable-only resolver, and
revisit in a dedicated sub-fase.
Phase 4 ratified that dedicated sub-fase as Sub-fase 4B. The problem this ADR addresses is the architecture for cross-adapter strategy unification: where strategies live, how they bridge to framework-specific request types, and how Phase 2B Django adopters preserve their import paths and contract.
Decision
Phase 4B introduces a new top-level core module,
tenantshield.strategies, hosting framework-agnostic strategies that
operate on a minimal RequestProtocol abstraction. Adapter-specific
request wrappers (DjangoRequestAdapter in the Django adapter,
AsgiRequestAdapter in the SQLAlchemy adapter) bridge framework
request types to the protocol; the strategies themselves are unaware
of either framework.
Architectural pillars
-
RequestProtocolminimal surface (Decision 4-A; DR-033). A two-methodtyping.Protocol:get_header(name) -> str | Noneandget_host() -> str. Tarea 4B.0 empirical inspection confirmed these two methods cover all four built-in strategies' requirements. The protocol is@runtime_checkablefor ergonomic isinstance checks in tests and adapter wrapper conformance verification. -
Adapter wrappers at adapter level (Decision iii-A). Framework bridging code lives where the framework is known. The Django adapter owns
DjangoRequestAdapter(wrappingHttpRequest); the SA adapter ownsAsgiRequestAdapter(wrapping the ASGI scope dict). Core strategies have no framework imports. -
HostStrategygeneric replaces Django-specificSubdomainStrategy(Decision 5-B; DR-036). The Phase 2B subdomain extraction logic (split on:for port, then on.for labels, leftmost label as tenant) is HTTP-standard and not Django-specific; generalizing throughRequestProtocol.get_host()produces a strategy reusable across adapters. Phase 2B Django adopters keep theSubdomainStrategyname via subclass alias (Decision 6-A). -
In-place Django refactor with re-export shim (Decision 6-A; DR-034). The four Phase 2B Django strategies become thin subclasses of the core strategies. Each subclass overrides
extract(request: HttpRequest) -> TenantIdto: (a) wrap theHttpRequestin aDjangoRequestAdapter, (b) callsuper().extract(adapter), (c) translate the core's two-tier contract (returnNonefor missing) back to the Phase 2B single-tier contract (raiseTenantExtractionError). Adopter imports (from tenantshield.adapters.django.middleware.strategies import HeaderStrategy) and Phase 2B raise-on-missing behavior are preserved exactly; Tarea 4B.2 verified 117/117 existing Django strategy tests pass unchanged. -
Adopter-facing callable surfaces preserve framework-native types. The Django
CallableStrategysubclass deliberately does NOT pass theDjangoRequestAdapterto adopter callables; it passes the rawHttpRequest. Phase 2B adopters wrote callables expecting the full Django API (request.GET,request.session, etc.), and those callables would break if substituted with a protocol-only wrapper. Architectural principle (captured as Pool 4B datapoint #5): protocol abstractions are internal to the strategy class; adopter- facing surfaces speak the adopter's native idiom. The cross-adapter coreCallableStrategy, in contrast, passes theRequestProtocol- conforming object to the callable, because cross-adapter adopters write callables against the protocol. -
Cross-adapter
resolve_strategy()factory (Decision 4-A). A single factory function intenantshield.strategiesproduces strategies usable via both adapters. Misconfiguration raisesValueError(Python-idiomatic). The Django adapter's pre-existingresolve_strategyis retained separately withImproperlyConfiguredsemantics per the Phase 2B / DPRJ-2 contract; Tarea 4B.4 verified the Django factory continues to raise the Django-idiomatic error for missingjwt_secret. Two factories coexist; a future post-1.0 consolidation may merge them with an adapter-specific error-translation shim. -
TenantExtractionErrorintenantshield.strategies(Decision ii-B). The coreTenantExtractionErrorlives with the classes that raise it. The Django adapter keeps its owntenantshield.adapters.django.exceptions.TenantExtractionError(distinct class) with the Phase 2B positional constructor; the Django strategy subclasses translate core errors to the Django- namespaced class viafrom excchaining per Rule 62.
Empirical validation
Tarea 4B.5 demonstrated end-to-end:
- A single
HeaderStrategy()/HostStrategy()/JWTStrategy(...)/CallableStrategy(...)instance extracts identical tenant values via bothDjangoRequestAdapter(django_request)andAsgiRequestAdapter(asgi_scope). Same Python object, identical output across adapter boundaries (8 integration tests). resolve_strategy()output strategies work via both adapters (3 integration tests covering header / host / JWT factory cases).- Error parity:
JWTStrategyraisingTenantExtractionErrorfor invalid signature behaves identically across adapter paths (1 parity test).
Alternatives considered
Alternative A -- New async-specific strategy hierarchy
Define AsyncTenantExtractionStrategy protocol with
async def extract(...). Adapters implement async variants paralelo to
the synchronous strategies.
Rejected because:
- Header/host/JWT/callable extraction is fundamentally synchronous work (string parsing, dict lookup, signature verification on bytes in-process); no benefit from async signatures.
- Async resolver requirement for tenant lookup against external
systems is already handled by
TenantSessionMiddleware's dual-mode resolver (Tarea 4A.5); adopters needing async tenant resolution from a database compose an async callable intoresolve_tenant, not into the strategy class itself. - Duplicating the strategy hierarchy doubles the maintenance surface.
Alternative B -- Adapter-only strategies (no cross-adapter core)
Keep each adapter's strategies fully framework-specific. Cross-adapter adopters write per-framework strategy code or use callable resolvers.
Rejected because:
- BLOCKER #30 deferral was explicitly raised to be closed in a dedicated sub-fase; deferring indefinitely accumulates architectural debt.
- Multi-framework adopters (the canonical use case for TenantShield) lose cross-adapter strategy coherence; same conceptual extraction (header, host, JWT) requires N implementations for N adapters.
- Future adapters (Tortoise, Peewee, etc.) would inherit a fragmented strategy infrastructure.
Alternative C -- Strategies on raw framework request, runtime dispatch
Strategies accept Any request, use isinstance / hasattr to dispatch
to framework-specific extraction code per call.
Rejected because:
- Runtime branching inside every strategy invocation; performance and readability cost.
- Strategy code becomes a switchboard of
isinstance(req, HttpRequest)/isinstance(req, dict)checks; defeats the abstraction's purpose. - Adding a new adapter requires modifying every strategy class.
Consequences
Positive
- BLOCKER #30 deferral closed. Empirically verified end-to-end by Tarea 4B.5.
- Phase 2B Django adopters: zero migration cost. All Phase 2B imports + contract preserved through subclass shim (117/117 existing Django strategy tests pass unchanged, Tarea 4B.2).
- Phase 3B SA adopters: additive gain. The callable-resolver
pattern continues to work unchanged; strategy classes are now
available as an alternative composing through
AsgiRequestAdapter. - Future adapters inherit unified strategies. A future Tortoise /
Peewee adapter only needs its own
*RequestAdapterwrapper to reuse the four core strategies +resolve_strategy()factory. - Cross-adapter contract clarity. Protocols, error classes, and
factory function semantics are documented in a single place
(
tenantshield.strategies).
Negative
- Adapter wrapper overhead per request.
DjangoRequestAdapterandAsgiRequestAdapterare instantiated once per request before strategy invocation. Cost is one Python object allocation; empirically negligible but documented. - Type-system divergence between mypy and pyright on
callable()narrowing. Theresolve_strategy()dispatch pathif callable(extraction): return CallableStrategy(extraction)is fully type-safe under mypy but requires an explicitcast("Callable[[RequestProtocol], str]", extraction)under pyright strict mode (Pool 4B datapoint #6). - Two
resolve_strategy()implementations coexist. Core (ValueError) and Django adapter (ImproperlyConfigured) factories serve different error idioms. A future consolidation may merge them with an adapter-side error-translation shim; preserving Phase 2B contract took precedence in Sub-fase 4B. - Two
TenantExtractionErrorclasses coexist. Core (kwarg-only constructor,strategy_name: reasonmessage format) and Django adapter (positional constructor, reason-only message). Phase 2B adopter tests catch the Django-namespaced class; cross-adapter consumers catch the core class. Django strategy subclasses translate viafrom excchaining per Rule 62.
Cross-adapter pattern alignment
| Aspect | Django adapter | SQLAlchemy adapter |
|---|---|---|
| Adapter request wrapper | DjangoRequestAdapter(HttpRequest) |
AsgiRequestAdapter(ASGI scope dict) |
| Strategy import (Phase 2B/3B legacy) | tenantshield.adapters.django.middleware.strategies.* |
(Phase 3B used callable resolver only) |
| Strategy import (Phase 4B canonical) | Re-exports legacy path via subclass shim | Re-exports from tenantshield.strategies |
extract() contract |
raises TenantExtractionError on missing (Phase 2B) |
returns None on missing (core contract) |
| Callable adopter param | raw HttpRequest (Phase 2B preserved) |
RequestProtocol-conforming wrapper |
resolve_strategy error class |
ImproperlyConfigured (Django idiom, DPRJ-2 preserved) |
n/a -- adopters use core resolve_strategy (ValueError) |
SubdomainStrategy alias |
yes (subclass of HostStrategy) |
no (use HostStrategy directly) |
Empirical evidence
Sub-fase 4B Tareas 4B.0-4B.5 empirically validated:
- 4-strategy Django coupling depth audit (Tarea 4B.0) -- minimal refactor scope per strategy (1-line API swaps + signature widening).
RequestProtocolnatural conformance: DjangoHttpRequestdoes NOT haveget_header(name)method; adapter wrapper required (Tarea 4B.0).tenantshield.strategiesmodule foundation (Tarea 4B.1): 27 unit tests covering 4 strategies + protocols + top-level re-exports.- Django strategies subclass shim +
DjangoRequestAdapter(Tarea 4B.2): 117/117 existing Django strategy tests pass unchanged + 6 new adapter conformance tests. - SA adapter
AsgiRequestAdapter+ cross-adapter strategy re-exports (Tarea 4B.3): 12 unit tests covering wrapper + strategy integration. - Cross-adapter
resolve_strategy()factory (Tarea 4B.4): 9 unit tests covering dispatch + 3-path symbol identity (core / SA adapter / top-leveltenantshield). - Cross-adapter integration empirical proof (Tarea 4B.5): 8 integration tests demonstrating same strategy instance + same factory output extracting identical results via both adapter wrappers.
References
- Decisions (i), (ii-B), (iii-A) ratified in Tarea 4B.0 follow-up.
- Decision 4-A, 5-B, 6-A from the Phase 4 kickoff Mass A ratification.
- DR-033 (
RequestProtocolminimal surface). - DR-034 (in-place Django strategy refactor preserves public API).
- DR-035 (SA strategy class parity scope: re-exports only).
- DR-036 (
HostStrategygeneric host parser replacesSubdomainStrategy). - ADR-0007 (event-based enforcement) -- foundational architecture enabling cross-adapter strategies as orthogonal layer.
- ADR-0008 (Phase 3B middleware lifecycle) -- Phase 3B callable-only resolver context; this ADR closes BLOCKER #30 deferral per Path (c) resolution promise.
- BLOCKER #30 (Phase 2B Django-bound strategies) -- deferral closed empirically in Tarea 4B.5.
- Tarea 4B.0 empirical findings (Django strategy coupling depth audit;
RequestProtocolcross-adapter feasibility). - Rule 60 (ADR forward-reference cleanup) -- applied via the ADR-0008 reference update accompanying this ADR.
- Rule 62 (exception chaining via
raise X from exc) -- applied in DjangoJWTStrategysubclass translating core errors to Django- namespaced errors.
End of ADR-0010.