Skip to main content

Configuration

CMDX settings that administrators can change at run time are declared in code as config keys and stored in the database by scope. This page lists the parts: the schema registry in core.config.lib.registry, the stored entries in the config_entries table, and the audit trail in config_audit_log.

Settings read only from the environment at start-up, such as database URLs, are covered on the environment variables page for administrators.

Summary​

PartWhereWhat it does
Schema registryget_config_registry()Holds every key's type, default, allowed scopes and validation rules. Filled at start-up.
Key definitionConfigSchemaDefinitionDeclares one key.
Stored entriesconfig_entries tableOne row per key, scope and scope ID.
Audit trailconfig_audit_log tableOne row per create, update or delete of a stored entry.
Data functionscore.config.serviceRead and write stored entries through the DB facade. Every write adds an audit row.

Keys and namespaces​

A key is a dotted name: <namespace>.<name>, for example developer.log_level. A namespace groups the keys of one module or plugin.

  • A namespace is lowercase letters, digits and underscores. It starts with a letter and is at most 50 characters.
  • Every key starts with its namespace and a dot.
  • A key belongs to exactly one namespace. Registering the same key under a second namespace raises DuplicateKeyError.

Namespaces declared today:

NamespaceRegistered byKeys
developerDeveloper settings module, at start-upListed on Logging and developer settings
featuresDeveloper settings module, at start-upfeatures.new_course_editor, features.ai_assistant_v2, features.enhanced_analytics, features.maintenance_mode_banner, features.siem_audit_export. No backend code turns anything on or off with them yet.
calendarCalendar module. It declares the key but doesn't register the namespace at start-up; it passes the definition to resolve_setting() directly.calendar.calendar_enabled, listed on Feature flags
<plugin settings namespace>The plugin loader, when it loads a pluginListed in the plugin's own documentation

Key definition fields​

ConfigSchemaDefinition is a Pydantic model. Plugins import it from core.plugin_api.

FieldTypeDefaultDescription
keystrrequiredFull key name. Must start with <namespace>.
namespacestrrequiredNamespace the key belongs to.
value_typeConfigValueTyperequiredOne of the value types below.
defaultanyNoneCode default, used when no other source has a value.
descriptionstrrequiredShown to administrators.
allowed_scopeslist[ConfigScope][GLOBAL]Scopes at which the key can be stored.
is_requiredboolFalseThe key must resolve to a value.
is_sensitiveboolFalseThe value is a secret. Always True for the secret type.
is_hot_reloadableboolTrueFalse means a change takes effect after a restart.
enum_choiceslist[str]NoneAllowed values. Required for the enum type.
validation_regexstrNonePattern a string value must match.
env_var_mappingstrNoneEnvironment variable that can supply the value.
file_path_mappingstrNoneFile whose contents can supply the value.
metadatadict{}Free-form extra data.

The model checks itself when it is created. A key that doesn't start with its namespace, or an enum key without enum_choices, raises ValueError.

Value types​

ConfigValueType sets how a value is checked when it is written. Stored values are always strings.

TypeAccepts
stringAny text.
integerA whole number, such as 250.
floatA decimal number, such as 0.25.
booleantrue, 1, yes, on or false, 0, no, off, in any case.
jsonA JSON object or array, or a string that parses as one.
secretAny text. The key is marked sensitive.
enumOne of the key's enum_choices, in any case. It is stored as the listed choice, so info becomes INFO.
urlText that starts with http:// or https://.
emailText with an @ and a dot in the domain.
durationA number and a unit: 30s, 15m, 24h or 7d.

After the type check, the value must match validation_regex if one is set. It then passes through any custom validators registered for the key.

Scopes​

ConfigScope sets where a stored value applies. A key can be stored only at a scope in its allowed_scopes.

Scopescope_idApplies to
globalnoneThe whole installation.
tenantThe tenant's IDOne tenant.
component<component-type>:<component-id>, for example course:<course-id>One component, such as a course. No key registered today allows this scope.

Resolution order​

When code reads a key, the first source that has a value wins:

  1. A stored entry: the tenant's, when the request has a tenant and the key allows the tenant scope, then the global one. Only active entries count.
  2. The environment variable in env_var_mapping.
  3. The file in file_path_mapping.
  4. The code default.

If no source has a value, the key resolves to None. A value that fails the key's type check is logged and skipped, and the next source is tried. Sensitive values are never logged.

note

resolve_setting() in core.config.resolve reads keys in this order for every module. It doesn't read component entries.

Stored entries​

Each row in config_entries holds one value. The key, scope and scope ID together are unique for tenant and component entries. Global entries have no scope ID, and PostgreSQL doesn't enforce uniqueness on an empty value, so a key can have more than one global row. resolve_setting() reads the newest active one.

ColumnDescription
key, namespaceThe key and its namespace.
valueThe value as a string. Secret fields in site.* values are encrypted before they are stored.
scope, scope_idWhere the value applies.
is_activeFalse after the entry is deleted. Inactive entries are ignored when a key is resolved.
updated_at, updated_byWhen and by whom the entry last changed.

Deleting an entry sets is_active to False and keeps the row. The key then falls back to the next source in the resolution order.

Audit trail​

Every create, update and delete through core.config.service adds a row to config_audit_log:

ColumnDescription
key, scope, scope_idThe entry that changed.
actioncreated, updated or deleted.
old_value, new_valueThe stored value before and after.
changed_by, changed_atWho made the change and when.
change_reasonFree text. The developer settings endpoints require it.

Data functions​

core.config.service reads and writes stored entries. Each function takes the DB facade as its first argument. These functions aren't part of core.plugin_api.

FunctionDoes
get_config(dml, key, scope, scope_id)Returns one entry, or None. Raises if a key has more than one global row; resolve_setting() doesn't.
get_configs_by_namespace(dml, namespace, scope=, scope_id=, active_only=True)Returns a namespace's entries, ordered by key.
upsert_config(dml, data, changed_by=)Creates the entry, or updates it if one exists for the key, scope and scope ID.
delete_config(dml, key, scope, scope_id, changed_by=, change_reason=)Marks the entry inactive. Returns False if there was none.
get_audit_log(dml, key=, actor=, action=, tenant_id=, ...)Returns audit rows, newest first.

These functions don't check the registry. Validate a value with registry.validate_value() and its scope with registry.validate_scope() before you write it.

Config endpoints​

The /api/v1/config endpoints read and write stored entries over HTTP. Every endpoint needs the admin or platform_superadmin role, granted platform-wide.

EndpointDoes
GET /configLists entries, a page at a time. Filters: namespace, scope, is_active.
GET /config/countCounts entries. Takes the same filters.
GET /config/namespace/{namespace}Returns a namespace's entries. Filters: scope, scope_id.
GET /config/{key}Returns one entry. scope (default global) and scope_id pick the row.
POST /configCreates an entry. Returns 409 if one exists for the key, scope and scope ID.
PUT /config/{key}Updates an entry.
DELETE /config/{key}Marks the entry inactive. Takes an optional change_reason.
POST /config/bulkCreates or updates several entries.
GET /config/audit-logReturns audit rows. Filters: key, actor, action, tenant_id, from, to. format=csv or format=json exports them.

Writes are checked against the registry. A write that fails the check returns 422:

  • The key must be registered. A namespace, if sent, must be the key's namespace.
  • The scope must be one the key allows, with a scope_id for every scope except global.
  • The value must pass the key's type check.
  • Sensitive keys can't be written here. Set them in the environment.

A bulk write checks every entry before it writes any. An update that sends no new value, such as one that only changes is_active, isn't checked, so an entry can always be deactivated.

Values of sensitive keys appear as ******** in every response, including the old and new values in the audit log. Filtering the audit log by tenant_id needs a platform super administrator.

For keys that have their own settings API, such as developer.* and features.*, use that API. These endpoints write the stored entry only, without the side effects of the module's own API.

Examples​

Declare settings in a plugin​

A plugin names its namespace in its manifest's settings_namespace. It lists its keys in a module-level schemas list in settings_schema.py:

settings_schema.py
from core.plugin_api import ConfigSchemaDefinition, ConfigScope, ConfigValueType

schemas = [
ConfigSchemaDefinition(
key="badges.points_per_completion",
namespace="badges",
value_type=ConfigValueType.INTEGER,
default=100,
description="Points awarded for each course completion.",
allowed_scopes=[ConfigScope.GLOBAL, ConfigScope.TENANT],
),
]

The plugin loader registers the list when it loads the plugin. You don't call register_namespace() yourself.

Validate a value​

from core.plugin_api import ConfigScope, get_config_registry

registry = get_config_registry()
key = "badges.points_per_completion"

print(registry.validate_value(key, "250"))

try:
registry.validate_value(key, "lots")
except Exception as exc:
print(exc)

try:
registry.validate_scope(key, ConfigScope.COMPONENT)
except Exception as exc:
print(exc)

Output:

250
Validation failed for 'badges.points_per_completion': Cannot convert to integer: invalid literal for int() with base 10: 'lots'
Validation failed for 'badges.points_per_completion': Scope 'component' is not allowed for this key. Allowed scopes: ['global', 'tenant']

Both failures raise ConfigValidationError.