> ## Documentation Index
> Fetch the complete documentation index at: https://veriqa.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Declare your own configuration keys

> An extension you ship states its settings on core, application and ui_config levels through the same declaration catalog the core itself uses.

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.

<Note>
  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.
</Note>

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

```csharp AcmeConfigKeys.cs theme={null}
using Veriqa.Core.Configuration;

public static class AcmeConfigKeys
{
    // Initialized before the declarations that fill it — the order the initializers run in.
    private static readonly ConfigKeyCatalog Declared = new();

    /// <summary>Section of the host configuration the core level of these keys lives in.</summary>
    public const string CoreSectionName = "Acme:Approvals";

    public static ConfigKey<int> MaxAttempts { get; } = Declared
        .Of<int>("Acme.Approvals.MaxAttempts")
        .At(ConfigLevel.Core, CoreSectionName + ":MaxAttempts")
        .At(ConfigLevel.Application, "AcmeMaxAttempts")
        .At(ConfigLevel.UiConfig, "acme_max_attempts")
        .Default(3)
        .Declare();

    public static ConfigKey<int> WindowSeconds { get; } = Declared
        .Of<int>("Acme.Approvals.WindowSeconds")
        .At(ConfigLevel.Core, CoreSectionName + ":WindowSeconds")
        .At(ConfigLevel.Application, "AcmeWindowSeconds")
        .Default(60)
        .Declare();

    // What the registrar of the deployment reads.
    public static ConfigKeyCatalog Catalog => Declared;
}
```

`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

| Form | What it means |
| - | - |
| `.At(ConfigLevel.Application, ConfigLevel.UiConfig)` | The address **follows from the name**: the dots become path separators, so `Acme.Approvals.MaxAttempts` is read at `Acme:Approvals:MaxAttempts` inside the record of each level. |
| `.At(ConfigLevel.UiConfig, "acme_max_attempts")` | The address is written **explicitly** — where the deployed form of the configuration does not match the name of the key. |
| `.AtIdentity(ConfigLevel.Tenant)` | The level has **no path**: it is addressed by the identity of the key alone. That is what a store keeping rows "level × owner × key × dimensions" needs — there is no document to walk into. |

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.

<Note>
  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.
</Note>

## 2. Register the catalog from your own composition

```csharp AcmeServiceCollectionExtensions.cs theme={null}
public static IServiceCollection AddAcmeApprovals(
    this IServiceCollection services,
    IConfiguration configuration)
{
    // Your keys travel with your composition — the one moment that works for an assembly
    // no composition root can name.
    services.AddVeriqaConfigKeyCatalogs(configuration, AcmeConfigKeys.Catalog);

    // ... the rest of your registrations
    return services;
}
```

Called from the host as one line beside the rest:

```csharp Program.cs theme={null}
builder.Services.AddVeriqaAuthServer(builder.Configuration);
builder.Services.AddAcmeApprovals(builder.Configuration);
```

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

```csharp theme={null}
public sealed class AcmeApprovalGate(IConfigurationResolver resolver)
{
    public async ValueTask<int> MaxAttemptsAsync(
        string? applicationId,
        CancellationToken cancellationToken)
    {
        var resolved = await resolver.ResolveAsync(
            AcmeConfigKeys.MaxAttempts,
            ResolutionContext.Of(tenantId: null, applicationId, uiConfigSelector: null),
            ConfigDimensionValues.None,
            cancellationToken);

        // resolved.SourceLevel says which level answered — useful in your own diagnostics.
        return resolved.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:

```csharp theme={null}
using var scope = new ConfigResolutionScope(ResolutionContext.ForTenant(tenantId));

var attempts = await resolver.ResolveAsync(
    AcmeConfigKeys.MaxAttempts, scope, ConfigDimensionValues.None, cancellationToken);
var window = await resolver.ResolveAsync(
    AcmeConfigKeys.WindowSeconds, scope, ConfigDimensionValues.None, cancellationToken);
```

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

| Chain member | Semantics |
| - | - |
| *(nothing)* | **Value** — the upper level overrides the lower one freely. |
| `.Set(intersect)` | **Set** — the effective value is the intersection of the levels, so a lower level cannot widen what an upper one allows. |
| `.ProtectiveCeiling(stricter)` | **Protective ceiling** — downwards only stricter; a relaxation needs a gate opened by an upper level. |
| `.GatedValue(ConfigLevel.Application)` | **Controlled override** — the listed levels override only where the level below opened a gate; the rest override freely. |

**Domain of the value and what a value outside it costs:**

```csharp theme={null}
public static ConfigKey<int> MaxAttempts { get; } = Declared
    .Of<int>("Acme.Approvals.MaxAttempts")
    .At(ConfigLevel.Core, CoreSectionName + ":MaxAttempts")
    .Domain(new ConfigValueDomain<int>(
        admits: static value => value is > 0 and <= 10,
        boundary: "1…10 attempts",
        correctAt: "Acme:Approvals:MaxAttempts",
        onRejected: ConfigValueRejectionPolicy.FailStart))
    .Default(3)
    .Declare();
```

`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:**

| Member | What it states |
| - | - |
| `.Default(value)` | What the **core** level answers where its record states nothing — the value the product ships with. It is data, not a hook, so the schema and the snapshot report print it too. |
| `.Required()` | Some level must state a value. |
| `.Secret()` | The value is a secret: the schema carries the fact of an answer and never its text. |
| `.Cached(ConfigCachePolicy.ExternalStore)` | Caching policy — a 30-second TTL for a level behind an external store; `ConfigCachePolicy.None`, or a `Ttl` of your own. |
| `.BlankIsUnstated()` | A level stating a blank text states nothing at all — a half-finished edit is not an override. Text keys only. |
| `.PresenceMeans(value)` | What a level answers by the mere existence of its record. |
| `.EnumTokens<TEnum>()` | The value is written as the tokens of an enum. |
| `.Parse(...)` | Reading of one step by hand — for one value standing over several members of a record (a path and its SRI hash), or a step reading a different leaf than its neighbour. |
| `.Stated(...)` | Every address the level may state this setting at, for the walk of a configuration snapshot. Changes no resolution. |
| `.NotWalked()` | The walk of a snapshot cannot reproduce this value; the report names it as standing outside instead of reporting half of it. |

## 5. Check that it took

The start-up report of the mechanism prints what the deployment declares. One `Information` line
names the whole schema:

```
Configuration schema declares 68 keys: Acme.Approvals.MaxAttempts, AuthPage.Instruction, ...
```

Raise the mechanism to `Debug` and every declaration is printed with its boundaries — the line for
the key above reads:

```
Configuration key Acme.Approvals.MaxAttempts (Value): levels UiConfig, Application, Core, gated none, dimensions none, cache none, domain 1…10 attempts.
```

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](/docs/guides/custom-channel-adapter) 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

<CardGroup cols={2}>
  <Card title="Configuration reference" icon="sliders" href="/docs/reference/configuration">
    Every section and key of the product, and the table of declared keys.
  </Card>

  <Card title="Build your own channel adapter" icon="puzzle-piece" href="/docs/guides/custom-channel-adapter">
    The other half of the extension path — the channel SPI.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.