Why
Branding has no DB table of its own (service.py:1-6): the app name, colors, logo/favicon file ids and footer live entirely in one app.state.branding.settings object hydrated once from settings's SYSTEM scope, and every Inertia page (including anonymous ones) and the two public asset routes read that single process-wide object — under tenancy, "per-tenant theme" (per the design doc) means this whole read path has to change from a boot-time singleton to a per-request, per-tenant lookup; there's no mixin to adopt because there's no row per branding config today.
Tables
- None exist in this module. Adopt
MultiTenantMixin: n/a — branding piggybacks on settings.Setting (already addressed in the settings issue). The actual change needed here is: branding must start writing/reading its fields at SCOPE_TENANT (with the active tenant as scope_id) instead of exclusively SCOPE_SYSTEM, and needs a resolved per-tenant BrandingSettings instead of the single app.state.branding.settings.
Code paths
- Everything is a process singleton today:
BrandingService.current()/apply() (service.py:43-69) reads/writes self.app.state.branding.settings directly — one object, shared by every tenant in the process. branding_shared_props() (shared_props.py:65-74) is registered on app.state.inertia_shared_providers and reads that same singleton for every Inertia page render, authenticated or guest (shared_props.py:1-6 docstring) — under multi-tenancy this must resolve the request's active tenant and read a per-tenant BrandingSettings, falling back to the system/default theme when no tenant is resolved (e.g. the platform's own marketing pages).
_swap_asset/_reap (service.py:71-124) — logo/favicon files are file_storage ids; once file_storage.StoredFile is tenant-scoped (see that issue), a tenant's branding upload must go through that tenant's file_storage context, and _reap's self.storage.delete(uuid.UUID(file_id)) (service.py:102) needs to run under the same tenant context as the upload it's replacing, not the acting admin's.
- Anonymous asset routes are the concrete tenant-resolution gap the design doc calls out:
endpoints/assets.py (serve_logo, serve_logo_dark, serve_favicon, registered via register_public_routes at module.py:56) are unauthenticated by design and read request.app.state.branding.settings (assets.py:39-41) with no tenant resolution at all — this is exactly the "public anonymous requests currently have NO tenant resolution" gap in the design doc. A guest hitting /branding/logo.png on a multi-tenant install with per-tenant branding needs a resolver (subdomain/custom-domain, same mechanism pagebuilder will need) before this can serve the right tenant's logo instead of always the system default.
register_settings/on_startup-style hydration (module.py:34) presumably follows the standard register_module_settings pattern — per the framework CLAUDE.md's "Settings are read twice per boot" note, this hydration happens at boot, which is fundamentally incompatible with per-tenant values that must be resolved per request; branding needs to move from "hydrate once into app.state.branding.settings" to "resolve per request from SettingService.get_resolved_value(..., tenant_id=<active>)" for any field that becomes tenant-overridable, likely with a short-TTL cache keyed by tenant id (there is currently no cache to fix — one needs to be added correctly, tenant-keyed from the start, rather than retrofitted).
Migration
- No new table. If/when tenant-level branding overrides are wanted, they ride on
settings.Setting at SCOPE_TENANT — no separate migration in this module, but see the settings module issue for the scope_id trust bug that must be fixed first (otherwise any tenant could set another tenant's logo).
- Decide scope of this work: system-wide branding (current behavior, no tenant resolution needed) vs. true per-tenant branding (needs the anonymous-route resolver above). The design doc lists branding as a "candidate" for per-tenant theme, not a committed decision — flag this explicitly before implementing.
Tests
- If per-tenant branding ships: two tenants can set different logos/app names and each sees only their own via
branding_shared_props on an authenticated page.
- Anonymous asset routes serve the correct tenant's logo/favicon once a resolver exists (subdomain/domain-based), and serve the system default when no tenant resolves (bare platform domain).
_reap deletes the replaced file under the correct tenant's file_storage context, not the acting admin's, when they differ (e.g. a platform admin editing a tenant's branding on their behalf).
- Regression: with
multi_tenant off (single-tenant install), branding behaves exactly as today — no per-request DB hit added to every page render as a side effect of this work.
Out of scope / open questions
- Whether branding becomes per-tenant at all in v1, or only the platform-wide login/marketing chrome stays and tenants get a separate, more limited theming surface — this is a product decision the design doc leaves open ("candidate").
- The anonymous tenant-resolution mechanism itself (subdomain/custom-domain) is not branding's to build; it's shared infrastructure also needed by
pagebuilder (smpy_modules) and should be designed once, not per-module.
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
Branding has no DB table of its own (service.py:1-6): the app name, colors, logo/favicon file ids and footer live entirely in one
app.state.branding.settingsobject hydrated once fromsettings's SYSTEM scope, and every Inertia page (including anonymous ones) and the two public asset routes read that single process-wide object — under tenancy, "per-tenant theme" (per the design doc) means this whole read path has to change from a boot-time singleton to a per-request, per-tenant lookup; there's no mixin to adopt because there's no row per branding config today.Tables
MultiTenantMixin: n/a — branding piggybacks onsettings.Setting(already addressed in thesettingsissue). The actual change needed here is: branding must start writing/reading its fields atSCOPE_TENANT(with the active tenant asscope_id) instead of exclusivelySCOPE_SYSTEM, and needs a resolved per-tenantBrandingSettingsinstead of the singleapp.state.branding.settings.Code paths
BrandingService.current()/apply()(service.py:43-69) reads/writesself.app.state.branding.settingsdirectly — one object, shared by every tenant in the process.branding_shared_props()(shared_props.py:65-74) is registered onapp.state.inertia_shared_providersand reads that same singleton for every Inertia page render, authenticated or guest (shared_props.py:1-6 docstring) — under multi-tenancy this must resolve the request's active tenant and read a per-tenantBrandingSettings, falling back to the system/default theme when no tenant is resolved (e.g. the platform's own marketing pages)._swap_asset/_reap(service.py:71-124) — logo/favicon files arefile_storageids; oncefile_storage.StoredFileis tenant-scoped (see that issue), a tenant's branding upload must go through that tenant'sfile_storagecontext, and_reap'sself.storage.delete(uuid.UUID(file_id))(service.py:102) needs to run under the same tenant context as the upload it's replacing, not the acting admin's.endpoints/assets.py(serve_logo,serve_logo_dark,serve_favicon, registered viaregister_public_routesat module.py:56) are unauthenticated by design and readrequest.app.state.branding.settings(assets.py:39-41) with no tenant resolution at all — this is exactly the "public anonymous requests currently have NO tenant resolution" gap in the design doc. A guest hitting/branding/logo.pngon a multi-tenant install with per-tenant branding needs a resolver (subdomain/custom-domain, same mechanismpagebuilderwill need) before this can serve the right tenant's logo instead of always the system default.register_settings/on_startup-style hydration (module.py:34) presumably follows the standardregister_module_settingspattern — per the framework CLAUDE.md's "Settings are read twice per boot" note, this hydration happens at boot, which is fundamentally incompatible with per-tenant values that must be resolved per request; branding needs to move from "hydrate once intoapp.state.branding.settings" to "resolve per request fromSettingService.get_resolved_value(..., tenant_id=<active>)" for any field that becomes tenant-overridable, likely with a short-TTL cache keyed by tenant id (there is currently no cache to fix — one needs to be added correctly, tenant-keyed from the start, rather than retrofitted).Migration
settings.SettingatSCOPE_TENANT— no separate migration in this module, but see thesettingsmodule issue for thescope_idtrust bug that must be fixed first (otherwise any tenant could set another tenant's logo).Tests
branding_shared_propson an authenticated page._reapdeletes the replaced file under the correct tenant'sfile_storagecontext, not the acting admin's, when they differ (e.g. a platform admin editing a tenant's branding on their behalf).multi_tenantoff (single-tenant install), branding behaves exactly as today — no per-request DB hit added to every page render as a side effect of this work.Out of scope / open questions
pagebuilder(smpy_modules) and should be designed once, not per-module.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.