Elarion

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] optionMeaning
keyHierarchical key with : separators; unique case-insensitively (it doubles as the IConfiguration key).
ScopesScope kinds the setting may be used in. Default: global only.
SecretProtected at rest, never described, never projected into IConfiguration. No default allowed.
PinnableDeployment configuration may pin the value (global scope only).
Default / DefaultFactoryA constant for scalars, or a static factory (lazy, once) for records.
DescriptionText 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-only

GetAsync 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:

  1. the definition's default (default(T) when it declares none),
  2. the store value,
  3. a configuration value — only for Pinnable definitions 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 typeRaw text accepted
stringanything, verbatim
booltrue / false, any case, trimmed
integer types, decimal, double, floatinvariant-culture number, trimmed (" 30 ", 030); out-of-range is not coerced
enuma member name, case-insensitive (or the number of a defined member)
Guid, TimeSpan, DateTimeOffset, Uri, other string-serialized scalarsthe trimmed text, when the type accepts it
records, collectionsnever 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 nullable protection column 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 ISettingValueProtector registered fails host startup. There is no unprotected fallback; development hosts use Data Protection's default key ring.
  • DescribeAsync never returns a secret's value, and secrets are never projected into IConfiguration (it is routinely bound, logged and dumped). Read secrets through ISettingsManager, or bind one onto options (below).
  • ISettingReprotector.ReprotectAsync(scope, prefix?) converges legacy plaintext, rotated keys and older schemes; it is idempotent and version-guarded. AddElarionSettingsDataProtection also 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 LISTEN connection 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_notify on 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_changed by default) and the reconnect backoff are configurable via PostgreSqlSettingsChangeOptions; 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 sources

It 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.

On this page