Settings
Declared, typed runtime settings resolved through one layered resolver (default, store, configuration pin), with secrets protected at rest and an IConfiguration projection.
Elarion's settings subsystem gives applications runtime-changeable configuration. Settings are declared
once as typed definitions; one effective-value resolver layers a default, a value stored at runtime and an
optional deployment-configuration pin; everything that reads settings — your code, the admin describe call, the
IConfiguration projection — goes through it. See
ADR-0078
(and ADR-0011 for the store/change-source split).
Two backends ship: an in-process store (the zero-dependency default) and an EF Core database
store (Elarion.Settings.EntityFrameworkCore). Change notification is in-process by default; add
Elarion.Settings.PostgreSql (PostgreSQL LISTEN/NOTIFY) to propagate changes across all nodes.
The EF Core store writes on your injected DbContext, so a SetAsync/ResetAsync made inside a transaction
commits or rolls back with it — see persistence & transactions.
Declaring settings
A static partial class marked [SettingDefinitions] holds one static partial SettingDefinition<T> property
per setting. The attribute carries the key and metadata; a generator implements the property and verifies the
declarations at build time.
[SettingDefinitions]
public static partial class AppSettings {
[Setting("app:title", Default = "Untitled", Pinnable = true, Description = "The application title.")]
public static partial SettingDefinition<string> Title { get; }
[Setting("app:smtp", DefaultFactory = nameof(DefaultSmtp), Pinnable = true)]
public static partial SettingDefinition<SmtpSettings> Smtp { get; }
[Setting("app:smtp:password", Secret = true)]
public static partial SettingDefinition<string> SmtpPassword { get; }
[Setting("ui:theme", Scopes = ["global", "user"], Default = "light")]
public static partial SettingDefinition<string> Theme { get; }
private static SmtpSettings DefaultSmtp() => new() { Host = "localhost" };
}[Setting] option | Meaning |
|---|---|
key | Hierarchical key with : separators; unique case-insensitively (it doubles as the IConfiguration key). |
Scopes | Scope kinds the setting may be used in. Default: global only. |
Secret | Protected at rest, never described, never projected into IConfiguration. No default allowed. |
Pinnable | Deployment configuration may pin the value (global scope only). |
Default / DefaultFactory | A constant for scalars, or a static factory (lazy, once) for records. |
Description | Text for admin tooling. |
Build-time diagnostics: ELSDEF001 invalid container (must be a top-level static partial class), ELSDEF002
invalid member, ELSDEF003 invalid key, ELSDEF004 duplicate key, ELSDEF005 secret with a default,
ELSDEF006 pinnable without the global scope, ELSDEF007 invalid default.
Register the definitions on the host. [assembly: UseElarion] (or [assembly: GenerateSettingDefinitionCatalog])
emits ElarionSettingDefinitions.All, aggregating this assembly and the public containers of referenced module
assemblies:
builder.Services.ConfigureElarionJson(o => o.TypeInfoResolvers.Add(AppSettingsJsonContext.Default));
builder.Services.AddElarionSettings(o => o.AddDefinitions(ElarionSettingDefinitions.All));
[JsonSerializable(typeof(SmtpSettings))]
[JsonSerializable(typeof(string))]
internal sealed partial class AppSettingsJsonContext : JsonSerializerContext;Values serialize through the canonical IElarionJsonSerialization options —
source-generated JsonTypeInfo<T>, no reflection — so the value types must be in a JSON context.
An undeclared setting cannot be named: the manager takes definitions, not strings. A definition that is not
registered, or used in a scope it does not allow, throws InvalidOperationException (a programming error).
Scopes
A setting belongs to a SettingsScope: Global (system-wide), User(userId), or the CurrentUser
placeholder resolved against the ambient ICurrentUser. A scope is an open (Kind, Owner) value, so more kinds
can be added without changing the contracts or the store schema; a definition lists the kinds it allows.
User-scoped access fails closed: outside an authenticated context, CurrentUser throws rather than reading
global data.
Reading and writing
public sealed class SendEmailHandler(ISettingsManager settings) : IHandler<SendEmail, Result<Unit>> {
public async ValueTask<Result<Unit>> HandleAsync(SendEmail request, CancellationToken ct) {
var smtp = await settings.GetAsync(AppSettings.Smtp, cancellationToken: ct);
var password = await settings.GetAsync(AppSettings.SmtpPassword, cancellationToken: ct);
var theme = await settings.GetAsync(AppSettings.Theme, SettingsScope.CurrentUser, ct);
// ...
return Result.Success();
}
}
var write = await settings.SetAsync(AppSettings.Title, "Inbox", cancellationToken: ct);
if (!write.IsSuccess && write.Error.Code == SettingErrorCodes.Pinned)
// deployment configuration pins the title; the UI should show it read-onlyGetAsync returns the effective value. GetResolvedAsync returns it together with its SettingSource and store
Version (SettingValue<T>; IsSet is false while the default applies), so "unset" is distinguishable from
"explicitly stored the default" without a nullable value type or a DescribeAsync call. SetAsync returns Result<SettingWrite> (the new version);
ResetAsync removes the stored value so the setting reverts to its default. Failures are AppErrors with a stable
code (ADR-0080)
and a SettingWriteFailure(Key) payload: settings.pinned (SettingErrorCodes.Pinned, a BusinessRule — the
stored value would be ignored) and settings.concurrency_conflict (SettingErrorCodes.ConcurrencyConflict, a
Conflict). A handler that returns them declares them, so the schema and the generated client type them:
[ProducesError(SettingErrorCodes.Pinned, ErrorKind.BusinessRule, typeof(SettingWriteFailure))]. Concurrency is opt-in: an unconditional write is
last-write-wins; pass expectedVersion for optimistic concurrency.
The effective-value resolver
ISettingResolver layers, lowest to highest:
- the definition's default (
default(T)when it declares none), - the store value,
- a configuration value — only for
Pinnabledefinitions in the global scope. A configuration value pins the definition: it wins, and writes are refused.
A configuration value pins only when it is present and neither empty nor whitespace, so a blank environment
variable or an unfilled template placeholder does not freeze a setting. Set
o.EmptyConfigurationValuesPin = true when an empty string is a legitimate pinned value.
The configuration text of a string setting is the string itself; any other type uses its JSON form (2525,
true, a JSON document such as {"host":"mail"} for a record), and bare words work for enums and string-like
types. A malformed pinned value fails loudly on read. Only non-projection configuration providers are consulted
(see below), so the projection cannot pin.
The pin check itself is ISettingPins — IsPinned(definition, scope), GetPinnedText(definition) — a
singleton that needs only the catalog and IConfiguration, never the store. Inject it where the scoped
resolver is not available (a singleton, middleware, options setup).
The resolver stack (ISettingResolver, ISettingPins, ISettingsManager, ISettingNormalizer, ISettingReprotector, ISettingDefinitionCatalog) is exposed as interfaces only; the implementations are internal so their names never collide with application types. Replace one by registering your own implementation of the interface before AddElarionSettings().
Unreadable and legacy rows
A stored row that cannot be read — a secret that cannot be decrypted, or a value that is not valid for the
definition's type — never takes the other settings down. Bulk resolution (ResolveAllAsync, DescribeAsync, the
configuration projection) flags it (IsUnreadable plus an UnreadableReason that never contains the value) and
carries on; a single GetAsync of that setting still throws. The configuration projection skips the row and the
refresher logs its key and reason once.
Rows written before a definition existed typically hold the raw text (smtp.example.com, 30, True, Red)
instead of its JSON form. Reads do not guess — 42 is a legal string and a legal number — so convert them once
with ISettingNormalizer.NormalizeAsync(scope, options?). For every definition it keeps a value that already is
valid JSON for the type and otherwise attempts one well-defined coercion of the raw text:
| Definition type | Raw text accepted |
|---|---|
string | anything, verbatim |
bool | true / false, any case, trimmed |
integer types, decimal, double, float | invariant-culture number, trimmed (" 30 ", 030); out-of-range is not coerced |
| enum | a member name, case-insensitive (or the number of a defined member) |
Guid, TimeSpan, DateTimeOffset, Uri, other string-serialized scalars | the trimmed text, when the type accepts it |
| records, collections | never coerced — only text that already is valid JSON for the type |
The report lists one entry per examined key — Rewritten, AlreadyCanonical, Unreadable (with a reason that never
contains the value), Removed or Skipped (changed concurrently) — so an application can log exactly which keys were
changed or need attention. SettingNormalizationOptions.KeyPrefix limits the run to a subtree;
RemoveUnrecoverable = true deletes rows whose readable text cannot be coerced, so the default applies again
(the default is to leave them Unreadable). The run is idempotent and every write is guarded by the entry's version.
A secret row is normalized in place of its plaintext representation and keeps its protection state: a protected row
is protected again with the same scheme, a legacy plaintext row stays plaintext until ISettingReprotector protects
it. Normalization never changes a row's scheme behind the application's back, and a row whose protection cannot be
undone (other scheme, lost key, no protector) is reported Unreadable and never removed.
Upgrading a store written before 0.2.8. Releases before 0.2.8 accepted raw text on read; from 0.2.8 on, such a
row reads as Unreadable (GetAsync throws, the projection skips it and the refresher logs it). Nothing runs the
normalizer for you — a write behind the application's back on every start would be the wrong default — so run it
once, after the schema migrations and before the host starts. With the migration plan,
make it a code step so it runs exactly once per database:
public sealed class NormalizeLegacySettings(ILogger<NormalizeLegacySettings> logger) : ICodeMigration {
public string Version => "20261007120000"; // after the migration that created the settings table
public string Description => "normalize legacy settings rows";
public bool UseTransaction => false; // the store writes on its own connection; the run is idempotent
public async Task ExecuteAsync(MigrationStepContext context, CancellationToken ct) {
var normalizer = context.Services.GetRequiredService<ISettingNormalizer>();
var report = await normalizer.NormalizeAsync(SettingsScope.Global, cancellationToken: ct);
foreach (var entry in report.Where(SettingNormalizationOutcome.Unreadable))
logger.LogWarning("Setting {Key} was left unchanged: {Reason}", entry.Key, entry.Reason);
}
}A host that migrates by other means calls the same NormalizeAsync from its startup code between the migration and
app.RunAsync(); it is idempotent, so running it on every start is harmless. Per-user rows need one run per user
scope.
Secrets
Definitions with Secret = true are protected at rest through an ISettingValueProtector. Add the Data
Protection implementation:
builder.Services.AddElarionSettingsDataProtection(); // Elarion.Settings.DataProtection- The payload is bound to scope kind, owner and key (renaming a secret key requires writing it again).
- The protection scheme is store metadata —
SettingEntry.Protection, the nullableprotectioncolumn on the EF row — not a prefix inside the value. Add the column to an existing table with a migration. - Fail closed: a secret definition with no
ISettingValueProtectorregistered fails host startup. There is no unprotected fallback; development hosts use Data Protection's default key ring. DescribeAsyncnever returns a secret's value, and secrets are never projected intoIConfiguration(it is routinely bound, logged and dumped). Read secrets throughISettingsManager, or bind one onto options (below).ISettingReprotector.ReprotectAsync(scope, prefix?)converges legacy plaintext, rotated keys and older schemes; it is idempotent and version-guarded.AddElarionSettingsDataProtectionalso runs it once at start for the global scope (ReprotectOnStartup).- Persist and share the Data Protection key ring in production; a lost key ring makes protected settings unrecoverable.
Secrets on options
An adapter whose options are bound from configuration (an SMTP client reading Email:Smtp) cannot receive a secret
through IConfiguration. BindSecretSetting (in Elarion.Settings.Configuration) sets just the secret member from
the stored value and leaves the rest of the section bound from configuration:
builder.AddElarionSettingsConfiguration();
builder.Services.AddOptions<SmtpOptions>()
.BindConfiguration("Email:Smtp") // host, port, user
.BindSecretSetting(AppSettings.SmtpPassword, static (o, password) => o.Password = password);The value comes from the resolution the projection refresher already performs (so AddElarionSettingsConfiguration
is required), only the bound definitions are kept in memory, and IOptionsMonitor<T>/IOptionsSnapshot<T> reload
when the secret changes. The callback is skipped while the secret is unset or unreadable, so the member keeps its
configured or default value. Read the options through IOptionsMonitor<T> or IOptionsSnapshot<T>: the refresher
loads at host start, after which an IOptions<T> value built earlier would stay stale. A non-secret definition is
rejected — it is already in IConfiguration.
Describing settings for an admin UI
IReadOnlyList<SettingDescription> all = await settings.DescribeAsync(cancellationToken: ct);Each entry has Key, ValueType, Scope, AllowedScopes, IsSecret, IsPinnable, IsPinned, HasValue,
Source (Default, Store or Configuration), ValueJson (never for secrets), Version (usable as
expectedVersion), IsUnreadable / UnreadableReason (a stored entry that cannot be decrypted or is not a valid
value of its type) and Description. Elarion ships
no handlers for it; map it to whatever your host exposes.
Watching for changes
ISettingsManager.Watch(definition) and Watch(prefix) return an IChangeToken that fires when the effective
value may have changed: a store change, or — for pinnable global definitions — a deployment-configuration
reload. Tokens are one-shot; re-watch or use ChangeToken.OnChange.
ChangeToken.OnChange(
() => settings.Watch(AppSettings.Smtp),
() => _logger.LogInformation("SMTP settings changed; reloading."));Using the EF Core database store
Elarion.Settings.EntityFrameworkCore persists settings to a relational database via your DbContext:
// In your DbContext's OnModelCreating:
modelBuilder.UseElarionSettings(); // maps the elarion_settings table
// In Program.cs (replaces the in-process store):
builder.Services.AddElarionSettingsEntityFrameworkCore<AppDbContext>();Writes are change-tracker-free and immediate (they never flush the caller's unrelated tracked changes),
with a version column backing optimistic concurrency. Generate and apply an EF migration for the
elarion_settings table; the framework ships no migrations.
Database providers. The store is provider-neutral EF Core: table and column names are resolved from your
model, and the two raw statements it issues (the create-path INSERT and the last-write-wins
UPDATE … RETURNING) are built from that model rather than hard-coded for one dialect. PostgreSQL and
SQLite are both exercised by the test suite; any relational provider whose SQL supports RETURNING should
behave the same (SQLite has supported it since 3.35 — every bundled Microsoft.Data.Sqlite native build is
far newer). On SQLite, pair the store with the default in-process change source: a SQLite database file
implies a single application node, and in-process notification already reaches every watcher in that process.
The multi-node change source below is deliberately PostgreSQL-only — with SQLite there is no second node to
notify.
Change notification and transactions. A store write enlists in the context's ambient transaction when one
is open, and the default in-process notifier is commit-gated to match: a write made outside a transaction
is announced immediately (it is already durable), while a write made inside a caller-owned transaction is
deferred and announced only when that transaction commits — and dropped on rollback, so a watcher never reloads
a value a rollback discards. This works out of the box — AddElarionSettingsEntityFrameworkCore<TContext>
auto-attaches an EF Core transaction interceptor to TContext — so single-node live reload of
IConfiguration/IOptionsMonitor<T> and the scheduler applies as soon as the command that changed the setting
commits (a setting changed from a command handler no longer needs a restart to take effect). The one thing
the in-process notifier cannot do is cross process boundaries; the PostgreSQL change source below extends the
same commit-gated delivery to every node.
Single-instance notification. With only the shipped in-process source, a settings change on one node is
not observed by another node's watchers or scheduler until that node restarts. When the EF Core store is
paired with the in-process source, AddElarionSettingsEntityFrameworkCore registers a startup service that
logs a Warning making this limitation visible at runtime; registering the PostgreSQL source below silences
it. In a deliberately single-node deployment — a SQLite-backed host, say — the warning is informational and
safe to ignore: there is no other node whose watchers could go stale.
Multi-node change notification (PostgreSQL LISTEN/NOTIFY)
Elarion.Settings.PostgreSql makes change notification cross-instance over the database the settings already
live in — no extra infrastructure:
// In Program.cs, next to the EF Core store (either order):
builder.Services.AddElarionSettingsEntityFrameworkCore<AppDbContext>();
builder.Services.AddElarionPostgreSqlSettingsChanges(connectionString);
// or, if the host already manages an NpgsqlDataSource:
builder.Services.AddElarionPostgreSqlSettingsChanges(dataSource);A write on any node now fires IChangeToken watchers on every node — ISettingsManager.Watch, the
settings IConfiguration projection, IOptionsMonitor<T>, and the scheduler's ${...} live rescheduling all
follow. How it works:
- A hosted listener holds one dedicated
LISTENconnection per node and fires the matching watch tokens for each received notification, reconnecting with exponential backoff if the connection drops. PostgreSQL does not queue notifications for absent listeners, so after a reconnect the listener fires all watches — a spurious re-read is cheap and always converges. - Watch tokens fire only from the notification loop: a local write loops back through the database like a remote one, so every node — including the writer — observes changes in commit order through one path.
- The EF Core store's writes are announced with
pg_notifyon the store's own connection, which PostgreSQL makes transactional: a write inside a caller-owned transaction is announced only when that transaction commits, and never on rollback. Transactional writes are therefore fully notified with this backend. - The channel (
elarion_settings_changedby default) and the reconnect backoff are configurable viaPostgreSqlSettingsChangeOptions; two applications sharing one database should use distinct channels.
The store's DbContext must target the same PostgreSQL database as the connection string / data source given
to AddElarionPostgreSqlSettingsChanges. See
ADR-0024 for the design and alternatives.
UseElarionSettings (which also maps the nullable protection column) takes optional tableName and schema parameters to rename the table or place it in
a non-default schema, plus a snakeCase toggle (default true; false switches to PascalCase names — the
default table becomes ElarionSettings): modelBuilder.UseElarionSettings("app_settings", "app").
On a [GenerateDbSets] context you can skip the hand-written call: annotate the context with
[GenerateElarionSettings] (optionally with SnakeCase/TableName/Schema) and the bundled generator
emits the DbSet<Setting> and applies the same model configuration through the EF generator's model-config
seam (ELSET001 if [GenerateDbSets] is missing).
IConfiguration projection
Elarion.Settings.Configuration projects the resolver's effective global values into IConfiguration with
live reload, for IOptionsMonitor<T> and the scheduler's ${...} variable substitution:
builder.AddElarionSettingsConfiguration(); // after the other configuration sourcesIt is a projection, not a second source of truth: it is derived from the resolver and the resolver never reads
it back, so it cannot pin a definition and there is no store-versus-configuration precedence switch. Scalar
definitions become one key; object values are flattened to key:property (and key:0, key:1 for arrays) so a
section binds to your options type. Secrets are never projected, values pinned by configuration are skipped (they
are already there), and only the global scope is surfaced. A stored value is always projected; a declared
default only when it is a scalar — a structured (record or collection) default would add a key per property that
nobody set, and the options type already carries its own defaults. An unreadable row is skipped and reported, never
fatal (see above). Register it after your other sources so a later
provider does not mask the projected effective value.
A declared default never masks deployment configuration: when appsettings or the environment already supply a
non-blank value at a definition's key, the default is not projected, so IConfiguration keeps the deployment value
until an administrator stores one (a stored value is projected and wins). This makes "appsettings is the fallback,
the settings store overrides it" work for options bound from configuration, with or without a declared default.
Only the projection behaves this way — the resolver reads deployment configuration for Pinnable definitions only,
so ISettingsManager returns the declared default for such a key. Declare the definition Pinnable when the
deployment value should be authoritative for every reader (and freeze writes).
Because IConfiguration is built before the DI container, the background refresher performs the initial load in
its StartAsync, before later-registered hosted services (notably the scheduler) start. The load is time-bounded
so a slow store cannot hang startup. The refresher reprojects on store changes and when the rest of the
configuration reloads (a pin may have appeared); the provider reloads only when its data actually changed.
Before the host is built
Configuration consumed while the builder is still being assembled (server endpoints, authentication options) cannot
wait for the refresher. SettingsConfigurationSnapshot.LoadAsync(definitions, store, serialization, deploymentConfiguration) runs the same resolver and projection against a store you construct yourself and returns a
SettingsProjection (Data, Problems); SettingsConfigurationProjection.Flatten is the public decode helper.
Pass the snapshot to builder.AddElarionSettingsConfiguration(snapshot) so the live provider starts with it
(one provider, so a value that is later reset cannot linger), or add it to a plain IConfigurationBuilder with
AddElarionSettingsSnapshot(snapshot). Secret definitions are skipped.
Scheduler integration
Settings flow into the scheduler through variable substitution: a job's
${...} schedule variables resolve from the (config-backed) variable source, and the settings
IConfiguration adapter is observable, so changing a setting reschedules affected recurring jobs live
(not just on their next fire). No scheduler-specific wiring is needed.
Roadmap
The foundation, the EF Core database store, the IConfiguration/IOptionsMonitor adapter, the scheduler
integration, and the cross-instance PostgreSQL LISTEN/NOTIFY change source ship today. A Redis pub/sub
change source is a possible future provider over the same contracts. See
ADR-0011 for the full design and phasing and
ADR-0024 for the multi-node backend.
Audit trail
Declarative, transport-neutral audit records — who performed which action on which resource, with field-level change capture, success records committed atomically with the business transaction.
Persistence & transactions
How Elarion's EF Core stores and the event buses participate in the caller's database transaction — what commits and rolls back together, and what is delivered after commit.