Skip to main content
Veriqa resolves a setting through one mechanism: a key is declared once — its name, value type, the levels it lives at and the address on each of them — and every read goes through the one resolver, which applies the level precedence and the gate rules of that declaration. That mechanism is not reserved for the core. An extension you ship — a channel adapter of your own, an audit sink, an external admissibility check, anything that arrives as a separate assembly — declares its own keys the same way, on the same levels, with the same protective model. This page is the minimal working path.
The declaration API lives in Veriqa.Core.Configuration. It is part of the MPL-2.0 core the host already runs (it arrives with Veriqa.Core.AuthServer and with the channel adapter packages), not of the MIT contracts package — so reference it from the assembly that declares, or declare in the host. Everything on this page is public API.

Application and ui_config levels are open to extensions

The readers of the level records — the one that fetches the OIDC client entry and the one that fetches the ui_config record — are internal, and an extension does not need them. The owner of a key needs an address inside the record, not the record itself: you state where in the record your value sits, and the reader hands the record to the resolution without learning what was read out of it. Both records take members nobody declared in advance:
  • Application level — an entry of the Veriqa:OpenIddict:Clients array in the host configuration. It is read as a node, by path: whatever member you write into the entry is readable at the address your key declares, whether or not OidcClientOptions happens to carry a property of that name.
  • ui_config level — the dynamic record. Fields it does not know are preserved round-trip through [JsonExtensionData], so your field survives every read and write of the record.
What an extension cannot do is replace the way a level’s record is fetched — put the client entry in a store of its own, for instance. That is a decision of the deployment, not of an extension: the resolution contract has one implementation, and the parts of it that are replaceable are named explicitly at registration. Serving a level from another store is one of them, and it is stated by the host that owns the store (see the package README of Veriqa.Core.Configuration), not by a library that declares keys.

1. A catalog of your own

A catalog holds the declarations of one owner — there is no shared catalog for all extensions. Fill it from the static initializer of your key class and expose it:
AcmeConfigKeys.cs
Of<T>(name) opens the chain for a value of any type, Text(name) for the text shape most settings have, and Declare() closes it: it builds the key, puts the declaration into the catalog and hands the key back. A chain that is never closed declares nothing — the key does not exist and no level of it is bound.

Addresses per level

One rule decides more mistakes than any other here: only the core level is addressed from the root of the host configuration. Its record is the application configuration, so Acme:Approvals:MaxAttempts is a full path there. Above the core, an address is relative to the record of its own level — the client entry, the ui_config record — and a declaration therefore names no store. An address above the core that opens at a section of the host configuration stops the start with a message naming the key, the level and the address.
A key whose value is a composite type is read from the subtree at its address with strict binding: a member the type does not carry — a leftover of an earlier version, a stray key, a “comment” written as a field — makes the whole subtree unreadable, and the step is then treated exactly like a value the domain rejected (the record is discarded above the core level; at the core level a key declaring FailStart stops the host). It is the accepted price of a guarantee: without it a misspelt element of a set arrives as a smaller set that looks perfectly valid. Keys of a scalar value are unaffected, and so are members you add next to your key inside a record you do not own — a client entry is not a subtree of yours.

2. Register the catalog from your own composition

AcmeServiceCollectionExtensions.cs
Called from the host as one line beside the rest:
Program.cs
Three properties of that call are worth stating outright, because they are what make composing independent extensions safe:
  • It is idempotent, per catalog. A catalog the container already holds is skipped alone. A composition called twice, and two compositions naming one catalog, both leave exactly one registration.
  • It suppresses nothing. Your call registers what you name and skips what is already there — no owner can knock out the catalogs of another, and none is registered twice.
  • Order does not matter. Every registrar declares its keys before the first binding is registered, so whether your composition ran before or after the core’s does not decide whether a binding finds its key.
The reader of catalogs comes with this registration rather than being expected of the host: a catalog nobody reads declares nothing, and its keys would be absent from the schema. AddVeriqaConfigKeyCatalogs takes any number of catalogs; AddVeriqaConfigKeyCatalog registers one. The shipped code takes both paths — the shared channel adapter package Veriqa.Core.ChannelAdapter states its three catalogs in one call, and each channel adapter states its own at its own registration, so a deployment declares the credentials of the channels it added and of no others.

3. Read the value

IConfigurationResolver is the only entry of resolution and is asynchronous: a level served by an external store is read asynchronously, and a synchronous wrapper over it would be sync-over-async. ResolutionContext.ForTenant(tenantId) covers the tenant-only case; ResolutionContext.Core asks the core level alone. Resolving several keys served by the same record? Open a ConfigResolutionScope over the context and pass it in place of the context — the record is then read once for everything resolved inside:

4. What a declaration can state

Your keys get the same model the core’s do, not a reduced one. Level-combining semantics. Plain value semantics is the default — an upper level overrides a lower one freely: Domain of the value and what a value outside it costs:
SkipStep (the default) leaves the offending step unset and the resolution moves on to the level below; FailStart refuses the start at the core level and throws the record out above it. The boundary text is what an operator reads in the report, and correctAt is where they are told to fix it. A value never enters either message. A key of an enum gets a domain of its members even without this call. Dimensions. .Identity("channel") states the dimension without which the question loses its meaning (“the setting of which channel”) — it is addressed at every step of the fallback chain and never drops out. .Narrowing("theme", "surface") states narrowing attributes, most significant first; they drop out of the chain one by one from the least significant. .Fallback(...) replaces the generated chain where a key tries only a subset of the combinations. The rest of the chain, in one list:

5. Check that it took

The start-up report of the mechanism prints what the deployment declares. One Information line names the whole schema:
Raise the mechanism to Debug and every declaration is printed with its boundaries — the line for the key above reads:
Two discrepancies are named there as well: keys declared without a single extraction binding in this deployment (Information — which parts a deployment composes is its own decision), and a Warning per pair “key + level” where the key declares a level this deployment serves and no binding was registered for it, so that level stays silently empty. A key that never appears in the report at all was not registered — the usual cause is a chain left unclosed (no Declare()), or a composition that never called AddVeriqaConfigKeyCatalogs.

An example in the shipped code

CorePageBrandingConfigKeys and LoginConfirmationConfigKeys live in Veriqa.Core.ChannelAdapter — an assembly that cannot reference the auth server, a reverse dependency — and they declare keys on the application and ui_config levels whose records the auth server fetches. They register their catalogs from the channel adapter package’s own composition, exactly as §2 above describes.

Where this path is needed

  • A channel adapter of your own — its credentials, texts and limits per tenant. The channel adapter guide resolves settings through IConfigurationResolver; this page is where the key it resolves comes from.
  • An audit sink, a metrics exporter, an outbound gateway — anything shipped as a separate assembly that an operator has to configure per application or per tenant.
  • Your own restriction policy engine. Veriqa’s own admissibility rules are an internal contract and are not published as an extension point: an integrator who needs another policy engine — Rego, Cedar, a DSL of their own — ships that check themselves, and it keeps its configuration on its keys, declared exactly as above.

Next steps

Configuration reference

Every section and key of the product, and the table of declared keys.

Build your own channel adapter

The other half of the extension path — the channel SPI.