Why
FeatureFlagOverride (modules/feature_flags/feature_flags/models.py:32) already has a working tenant scope and per-tenant admin endpoints (endpoints/api.py:87-137), and unlike settings those endpoints are correctly gated behind PERM_FEATURE_FLAGS_MANAGE/VIEW as a platform-admin, cross-tenant screen — so the schema and admin surface need no change. What's missing is any FastAPI dependency that resolves is_enabled(name, tenant_id=<active tenant>) from a request, so as soon as any module wants to gate behavior on a per-tenant flag at request time, there is no supported way to do it correctly (a naive call would forget tenant_id and silently fall back to system-only).
Tables
FeatureFlagOverride (models.py:32) — adopt MultiTenantMixin: no, same reasoning as settings.Setting: it is a self-scoping (scope, scope_id, name) table by design (models.py:8-10 docstring), and scope_id already carries the tenant id when scope="tenant".
UniqueConstraint("scope", "scope_id", "name", name=UQ_OVERRIDE_SCOPE_NAME) (models.py:36) — already correct per-tenant; no change needed, not an SM024 case.
Code paths
FeatureFlagRegistry.is_enabled(name, tenant_id=None) (framework/core/simple_module_core/feature_flags.py:50-52, tenant_override at :72-74) is an in-memory, process-global registry keyed by (name, tenant_id) — correct data structure, but no call site in the codebase passes a request-resolved tenant_id (confirmed: is_enabled( only appears in this module's own tests and the framework's own unit test, framework/core/tests/test_feature_flags.py). There is no RequiresFeatureFlag-style FastAPI dependency analogous to RequiresPermission that reads app.state.tenant_resolver's result and calls is_enabled(name, tenant_id=<resolved>). Any module that starts gating a route on a flag today would have to thread the active tenant through itself, and would silently get system-only behavior if it forgot — a fail-open in the feature sense (not the tenancy-isolation sense) rather than a hard error, which is easy to miss in review.
- Admin endpoints (
list_flags_for_tenant, set_tenant_override, clear_tenant_override — endpoints/api.py:87-136) take tenant_id from the URL path and are gated by PERM_FEATURE_FLAGS_MANAGE/VIEW; confirm (out of this module) that those permissions are platform-admin-only, since a tenant-scoped admin holding a module-local "manage settings" style permission must not be able to toggle other tenants' flags through this path the way settings' bug currently allows for Setting.
hydrate_registry (service.py:180-189) loads every override (all scopes, all tenants) into the process-global registry at boot via list_overrides() (service.py:54-63) — this is correctly cross-tenant (registry must hold every tenant's overrides to serve is_enabled for any of them) and needs no all_tenants() wrapper change, but should be called out as an intentional platform-wide read so a future reviewer doesn't "fix" it into a tenant-scoped query and break every other tenant's flags.
- No file/object storage paths, no caches keyed without tenant (the registry dict is already tenant-keyed), no public/anonymous routes.
Migration
- None. No schema change;
scope_id already holds tenant ids for scope="tenant" rows and the unique constraint is already per-tenant.
Tests
- Add a
RequiresFeatureFlag-equivalent dependency (or document the pattern) and test that a route gated on a tenant-scoped flag: (a) resolves the active tenant from the request, (b) returns the tenant's own override when set, (c) falls back to system value, then default, when no tenant override exists, matching list_flags's documented precedence (service.py:75-110).
- Cross-tenant admin isolation: verify (independent of this module, but exercised through it) that a tenant-scoped caller cannot call
PUT /tenant/{other_tenant_id}/{name} — this depends on how PERM_FEATURE_FLAGS_MANAGE is scoped elsewhere; add the test here regardless so a future permission-scoping regression is caught at the endpoint level.
- Registry hydration at boot still loads every tenant's overrides correctly under
multi_tenant=True with no request context (exercises all_tenants()-equivalent boot-time behavior).
Out of scope / open questions
- Whether
PERM_FEATURE_FLAGS_MANAGE should be split into a platform-admin variant and a tenant-admin variant (so a tenant admin could manage only their own tenant's flags through a tenant-facing screen) is a product question outside this module's current single-permission model.
- No
RequiresFeatureFlag dependency exists anywhere in the framework today — creating one is arguably a simple_module_hosting framework change, filed here because feature_flags owns the registry it would wrap.
Context: part of the SaaS tenancy work in #370 (fail-closed isolation, tenant_context()/all_tenants(), the tenants module). Design: docs/plans/2026-09-27-saas-tenancy-design.md and docs/framework/multi-tenancy.md on that branch.
Why
FeatureFlagOverride(modules/feature_flags/feature_flags/models.py:32) already has a workingtenantscope and per-tenant admin endpoints (endpoints/api.py:87-137), and unlikesettingsthose endpoints are correctly gated behindPERM_FEATURE_FLAGS_MANAGE/VIEWas a platform-admin, cross-tenant screen — so the schema and admin surface need no change. What's missing is any FastAPI dependency that resolvesis_enabled(name, tenant_id=<active tenant>)from a request, so as soon as any module wants to gate behavior on a per-tenant flag at request time, there is no supported way to do it correctly (a naive call would forgettenant_idand silently fall back to system-only).Tables
FeatureFlagOverride(models.py:32) — adoptMultiTenantMixin: no, same reasoning assettings.Setting: it is a self-scoping(scope, scope_id, name)table by design (models.py:8-10 docstring), andscope_idalready carries the tenant id whenscope="tenant".UniqueConstraint("scope", "scope_id", "name", name=UQ_OVERRIDE_SCOPE_NAME)(models.py:36) — already correct per-tenant; no change needed, not an SM024 case.Code paths
FeatureFlagRegistry.is_enabled(name, tenant_id=None)(framework/core/simple_module_core/feature_flags.py:50-52,tenant_overrideat :72-74) is an in-memory, process-global registry keyed by(name, tenant_id)— correct data structure, but no call site in the codebase passes a request-resolvedtenant_id(confirmed:is_enabled(only appears in this module's own tests and the framework's own unit test, framework/core/tests/test_feature_flags.py). There is noRequiresFeatureFlag-style FastAPI dependency analogous toRequiresPermissionthat readsapp.state.tenant_resolver's result and callsis_enabled(name, tenant_id=<resolved>). Any module that starts gating a route on a flag today would have to thread the active tenant through itself, and would silently get system-only behavior if it forgot — a fail-open in the feature sense (not the tenancy-isolation sense) rather than a hard error, which is easy to miss in review.list_flags_for_tenant,set_tenant_override,clear_tenant_override— endpoints/api.py:87-136) taketenant_idfrom the URL path and are gated byPERM_FEATURE_FLAGS_MANAGE/VIEW; confirm (out of this module) that those permissions are platform-admin-only, since a tenant-scoped admin holding a module-local "manage settings" style permission must not be able to toggle other tenants' flags through this path the waysettings' bug currently allows forSetting.hydrate_registry(service.py:180-189) loads every override (all scopes, all tenants) into the process-global registry at boot vialist_overrides()(service.py:54-63) — this is correctly cross-tenant (registry must hold every tenant's overrides to serveis_enabledfor any of them) and needs noall_tenants()wrapper change, but should be called out as an intentional platform-wide read so a future reviewer doesn't "fix" it into a tenant-scoped query and break every other tenant's flags.Migration
scope_idalready holds tenant ids forscope="tenant"rows and the unique constraint is already per-tenant.Tests
RequiresFeatureFlag-equivalent dependency (or document the pattern) and test that a route gated on a tenant-scoped flag: (a) resolves the active tenant from the request, (b) returns the tenant's own override when set, (c) falls back to system value, then default, when no tenant override exists, matchinglist_flags's documented precedence (service.py:75-110).PUT /tenant/{other_tenant_id}/{name}— this depends on howPERM_FEATURE_FLAGS_MANAGEis scoped elsewhere; add the test here regardless so a future permission-scoping regression is caught at the endpoint level.multi_tenant=Truewith no request context (exercisesall_tenants()-equivalent boot-time behavior).Out of scope / open questions
PERM_FEATURE_FLAGS_MANAGEshould be split into a platform-admin variant and a tenant-admin variant (so a tenant admin could manage only their own tenant's flags through a tenant-facing screen) is a product question outside this module's current single-permission model.RequiresFeatureFlagdependency exists anywhere in the framework today — creating one is arguably asimple_module_hostingframework change, filed here because feature_flags owns the registry it would wrap.Context: part of the SaaS tenancy work in #370 (fail-closed isolation,
tenant_context()/all_tenants(), thetenantsmodule). Design:docs/plans/2026-09-27-saas-tenancy-design.mdanddocs/framework/multi-tenancy.mdon that branch.