Companion to the design spec (rev 2). Freezes per-strategy behavior, routing,
search_path and RLS hardening, pool safety, and failure modes. Hybrid is a
placement configuration, never a fourth strategy.
resolve tenant (control plane)
→ TenantContext
→ TenantRegistry.assert_servable(tenant) # active AND migration success AND current = desired AND relocation permits serving
→ SessionRouter.route(tenant)
placement.strategy: shared → control-plane's tenant-plane engine (default handle)
schema → engine + SET search_path TO <validated handle>
database → pool for SecretProvider.resolve(target_handle)
→ TenantSession(context, connection)
A connection never serves two targets: shared/schema strategies own a single
engine each, and the database strategy keys one cached engine per resolved
handle.
SessionRouter is a thin dispatcher: it selects the registered
IsolationStrategy for the tenant's placement and delegates; it owns no
pools of its own.
Pool caching lives in DatabasePerTenantStrategy: engines are keyed by
resolved handle, created lazily, and capped by max_targets (default 32). A
hybrid deployment with many dedicated tenants cannot exhaust connections
silently; cap exhaustion raises PoolCapacityError.
Eviction safety: eviction is LRU over fully idle pools only; a pool with any
checked-out connection is never evicted. When the cap is reached and every
candidate has checked-out connections, PoolCapacityError is raised instead
of closing a live pool (test: checked-out Pool A must survive cap pressure).
No strategy ever falls back to another target. Unresolvable placement raises
StrategyCapabilityError.
One tenant-plane schema; every tenant-scoped table has tenant_id in PK and
all reads/writes scoped by TenantSession.
Unique business keys are tenant-scoped ((tenant_id, slug)), so tenants
cannot collide.
Provisioning is a control-plane operation only (no DDL).
purge/destroy delete tenant-plane rows through TenantPlanePurger
(child-first, dependency-ordered delete_where over the registered
tenant-plane models in a system-privileged TenantSession); per-table counts
are audited as tenant_counts and archive deletes nothing.
RLS optional (section 6); when enabled, the tenant setting is re-asserted on
every transaction begin (section 6).
Failure mode: a scoping bug would expose data — this is why I1/I2 tests and
the metadata lint are phase gates.
One database; schema name = tenant_placements.target_handle, validated at
registration against ^t_[a-z0-9][a-z0-9_]{0,50}$.
search_path is re-asserted by the framework on every transaction begin
(after_begin listener): SET search_path TO <quoted handle>; identifiers
are quoted with driver-level quoting (SQLAlchemy's identifier preparer,
psycopg.sql.Identifier, or equivalent), never string interpolation. A
rollback or commit followed by another statement therefore stays bound to the
tenant schema, not the connection default.
Application code never sets or reads search_path; control-plane sessions use
schema-qualified metadata and never share a pool with tenant sessions.
On checkin, the pool reset listener issues RESET search_path (server
default, typically "$user", public); even so, correctness does not depend on
the reset because every transaction begins by setting the tenant schema.
Migration distribution: run the tenant-plane chain once per schema, bounded
concurrency (default 4) to protect the primary; each schema keeps its own
alembic_version.
Failure modes: missing schema → StrategyCapabilityError with handle; schema
exists but migration state diverged → blocked; partially created schema is
resumable by the idempotent provisioner.
Enabled by config rls: on (RlsConfig.enabled); install_rls/verify_rls
ship and are exercised by tests. The startup gate (requesting RLS with
schema/database placement raises StrategyCapabilityError before
serving) and the CLI wiring that runs install_rls/verify_rls at deploy
time are Plan 6 exit items.
Coverage contract: every tenant-routed model is RLS-eligible — framework
tenant-plane tables and application resources alike. Control-plane
tenant-owned tables (membership, role, role_binding, resource_grant,
invitation, api_key, service_account) are not tenant-plane RLS
targets; they are protected by ControlPlaneSession, framework-only access,
and explicit filtering/authorization. jdlib.persistence.strategies.rls
ships the entry points: rls_eligible_tables(metadata) and the sync
emit_rls_policies(connection, *, metadata, app_role) for consumer Alembic
envs, plus the async install_rls(engine, *, metadata, app_role) which emits
policies over a privileged connection and then runs verify_rls. Metadata
lint L13 fails the build if any tenant-routed table lacks a policy when RLS is
enabled, or if a control-plane table is included in tenant-plane RLS
emission.
Fail-closed predicate: when app.tenant_id is unset (or empty), NULLIF
yields NULL, the comparison yields NULL, and the policy permits nothing —
zero rows, zero writes. There is no SQL construct in the policy that can turn
an unset setting into an allow.
Runtime role: NOBYPASSRLS, not the table owner, not superuser; migrations run
under a separate owner role. The startup self-check
verify_rls(engine, app_role, *, metadata) fails closed unless the role
exists with NOBYPASSRLS and is not superuser, every eligible table has
pg_class.relrowsecurity AND relforcerowsecurity true, and a
tenant_isolation policy exists in pg_policies; table names resolve through
the connection's search_path (to_regclass), so verification runs against
the same schema the runtime will use. install_rls runs it automatically;
call it standalone when policies are managed by migrations.
Every transaction begins with the transaction-local
SELECT set_config('app.tenant_id', '<uuid>', true) (never a session-level
SET); the value comes from TenantContext. The shared strategy re-asserts
it on every transaction begin (after_begin), so a rollback or a commit
followed by further statements stays bound, and the setting is gone after
close/checkin.
Tests: pool-leakage (A → checkout → B → checkout), unset setting (zero
rows/writes), commit-then-continue re-binding and unset-after-close, consumer
TenantOwned table (tenant B invoice invisible to tenant A with RLS on),
install_rls → verify_rls self-check plus fail-closed negatives (missing
policy, NO FORCE, bypassing role), and the strategy matrix with RLS on and
off.