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:Clientsarray 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 notOidcClientOptionshappens to carry a property of that name. ui_configlevel — 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.
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
Program.cs
- 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.
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. OneInformation line
names the whole schema:
Debug and every declaration is printed with its boundaries — the line for
the key above reads:
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.