Tiered configuration¶
Tiered configuration lets one setting hold different values for different callers. A tenant can raise a limit, a workspace can pick another model, a user can override a preference, and a single request can override a few keys (if the module allows it). The merge order is fixed, so the effective value is always predictable.
The pieces live in msflib.core.config (settings classes and the merge) and in msflib-workspace-config (database storage of the per-tenant, per-workspace and per-user values).
Precedence¶
From lowest to highest:
- Field defaults on the settings class.
- Environment variables (read when the settings object is created). They reach the merge only if the base you pass was built by your host settings (
settings.scope("CHAT")in the subclassed layout,settings.CHATin the composed layout). A bareChatSettings()carries class defaults only. - Tenant values.
- Workspace values.
- User values.
- Request override.
Later layers win. Mappings merge deeply, so a tenant that sets one key inside a nested dict leaves the other keys alone.
Module settings and namespaces¶
Each module declares its settings as a ModuleSettingsBase subclass with a namespace. The namespace is what ties together env vars, the settings.scope(...) lookup and the storage rows.
from typing import ClassVar
from msflib.core.config import ModuleSettingsBase, SettingsBase
class ChatSettings(ModuleSettingsBase):
namespace: ClassVar[str] = "CHAT"
request_override_allowlist: ClassVar[set[str]] = {"MAX_TOKENS"}
MAX_TOKENS: int = 1000
MODEL: str = "small"
ModuleSettingsBase also defines ENABLED: bool = True on every module. settings.scope("CHAT") returns a view of that namespace, which is how module code reads its own settings.
Subclassed or composed¶
A host puts the module settings classes it uses into one Settings class in one of two ways. Both are supported, both work with scope(), and the rest of the docs show them side by side.
Inherit the module settings classes together with SettingsBase. Fields are top-level and environment variables use the plain field names.
class Settings(ChatSettings, SettingsBase):
pass
Settings().MODEL # CHAT.MODEL; MODEL=medium sets it
Declare one nested field per namespace (or generate the class with compose_settings_model). Environment variables use a double underscore.
class Settings(SettingsBase):
CHAT: ChatSettings = ChatSettings()
Settings().CHAT.MODEL # CHAT__MODEL=medium sets it
How they differ in practice:
- Environment variable names. Plain field names when subclassed,
NAMESPACE__KEYwhen composed. The wrong style is silently ignored (#280). - Same field name in several modules. When subclassed, modules that define the same field name share one field (every module defines
ENABLED). When composed, each namespace keeps its own nested field. - Changing defaults in code. When subclassed, assign the field in the class body. When composed, declare the namespace field with a default instance on
SettingsBase, as in the example above; environment variables still apply. A subclass that overrides a default it inherits from acompose_settings_modelresult disables that namespace's environment variables (#276). - Code that reads top-level attributes. Works when subclassed. When composed it needs a matching top-level attribute, as with the auth router's
SECRET_KEY(#287).
Modules can declare flat_aliases so older flat env var names such as ENABLE_X still map into the nested path.
CoreSettings is the same kind of class with namespace CORE. The other modules follow the same pattern, for example TENANCY, WORKSPACE_CONFIG and AI_CORE.
Resolving without a database¶
resolve_tiered merges layers you already hold and returns a new, validated instance of the same class. It does not touch the database.
base = ChatSettings()
resolved = base.resolve_tiered(
tenant_config={"MAX_TOKENS": 2000, "MODEL": "tenant-model"},
user_config={"MAX_TOKENS": 3000},
request_override={"MAX_TOKENS": 50},
)
print(resolved.MAX_TOKENS, resolved.MODEL) # 50 tenant-model
There is no separate workspace parameter. Callers fold workspace values into tenant_config first (workspace wins over tenant). merge_config_layers is the underlying function if you need all four layers separately.
Resolving from stored values¶
ScopedConfigService from msflib.workspace_config.service stores one JSON mapping per namespace per scope, and resolves a module's settings from them.
from msflib.workspace_config.models import ConfigScopeType
from msflib.workspace_config.service import ScopedConfigService
service = ScopedConfigService()
service.set_value(session, ConfigScopeType.tenant, "CHAT", "MODEL", "tenant-model", tenant_id=1)
service.set_value(session, ConfigScopeType.workspace, "CHAT", "MAX_TOKENS", 4000, workspace_id=10)
service.set_value(
session, ConfigScopeType.user, "CHAT", "MAX_TOKENS", 5000, workspace_id=10, account_id=25
)
resolved = service.resolve_module_settings(
session,
ChatSettings(),
tenant_id=1,
workspace_id=10,
account_id=25,
)
print(resolved.MAX_TOKENS, resolved.MODEL) # 5000 tenant-model
The ids 1, 10 and 25 are examples; on PostgreSQL these rows must exist in the tenant, workspace and account tables, because the stored rows have foreign keys to them.
Which ids each scope needs:
| Scope | Required ids |
|---|---|
tenant |
tenant_id |
workspace |
workspace_id |
user |
workspace_id and account_id |
A user-tier row belongs to a user inside a workspace. A tier is skipped when its ids are not supplied, so a call without account_id resolves tenant and workspace only.
Rows are stored with a key of the form scope_type:NAMESPACE:tenant:workspace:account, with - for an empty position (for example tenant:CHAT:1:-:-). The ScopedConfigEntry table has foreign keys to the tenant, workspace and account tables, so those modules' models must be imported before you create the table.
Request overrides¶
A request override is a last, per-call layer. It comes in two accepted shapes, which normalize_request_override flattens:
{"MAX_TOKENS": 50}
{"overrides": {"MAX_TOKENS": 50}}
An envelope with any key other than overrides raises ValueError.
Because overrides often originate from client input, restrict them. Set request_override_allowlist (a set[str]) on the settings class. When it is None, which is the default, every key is allowed. When set, ScopedConfigService.resolve_module_settings raises ValueError("Unsupported request override keys: ...") for anything outside it. Keep secrets and security-sensitive keys out of the allowlist.
Pitfalls¶
ModuleSettingsBase.resolve_tiereddoes not normalize the override envelope or check the allowlist; see the known issue below.- The allowlist is only enforced for the request tier. Tenant, workspace and user values are trusted, so check who is allowed to write them in your admin endpoints.
- Resolution validates the merged result against the settings class. A stored value of the wrong type fails at resolve time, not when it is stored.
SettingsBasereads environment variables when the settings object is created. Tiered values are read from the database on each resolve.- Resolve per request, with the caller's ids. Do not cache a resolved settings instance across tenants.
Known issue (#276)
ModuleSettingsBase.resolve_tiered does not normalize the {"overrides": ...} envelope and does not check the allowlist. Passing the envelope shape directly to it silently drops the override (the overrides key is just an unknown field). Call normalize_request_override and validate_request_override_allowlist yourself, or go through ScopedConfigService or PolicyResolutionService, which do both.