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

# Свои ключи конфигурации

> Расширение, которое вы поставляете, объявляет свои настройки на уровнях ядра, приложения и ui_config через тот же каталог объявлений, которым пользуется само ядро.

Veriqa резолвит настройку одним механизмом: ключ **объявляется** один раз — имя, тип значения,
уровни, на которых он живёт, и адрес на каждом из них, — а любое чтение идёт через единственный
резолвер, который применяет порядок уровней и правила гейтов этого объявления.

Механизм не зарезервирован за ядром. Расширение, которое поставляете вы — свой канальный адаптер,
сток аудита, внешняя проверка допустимости, что угодно, приезжающее отдельной сборкой, — объявляет
**свои** ключи ровно так же, на тех же уровнях и с той же протективной моделью. Эта страница —
минимальный рабочий путь.

<Note>
  API объявления живёт в `Veriqa.Core.Configuration`. Это часть MPL-2.0-ядра, которое ваш хост и так
  запускает (пакет приезжает с `Veriqa.Core.AuthServer` и с пакетами канальных адаптеров), а не MIT-пакета
  контрактов — поэтому ссылайтесь на него из сборки, которая объявляет, либо объявляйте в хосте.
  Всё, что описано на этой странице, — публичный API.
</Note>

## Уровни приложения и `ui_config` открыты расширениям

Читатели записей уровней — и тот, который достаёт запись OIDC-клиента, и тот, который достаёт запись
`ui_config`, — `internal`, но расширению они и не нужны. Владельцу ключа нужен **адрес внутри**
записи, а не сама запись: вы указываете, где в записи лежит ваше значение, а читатель отдаёт запись
резолюции, не зная, что из неё прочитали.

Обе записи принимают члены, которых заранее никто не объявлял:

* **Уровень приложения** — элемент массива `Veriqa:OpenIddict:Clients` в конфигурации хоста. Он
  читается **как узел, по пути**: любой член, который вы впишете в запись клиента, читается по
  адресу, объявленному вашим ключом, независимо от того, есть ли у `OidcClientOptions` свойство с
  таким именем.
* **Уровень `ui_config`** — динамическая запись. Неизвестные ей поля сохраняются при чтении и записи
  через `[JsonExtensionData]`, поэтому ваше поле переживает каждый цикл чтения-записи записи.

Чего расширение **не может** — заменить сам способ доставать запись уровня, например положить запись
клиента в свой стор. Это решение развёртывания, а не расширения: у контракта резолюции одна
реализация, а её заменяемые части перечислены явно точками расширения самой регистрации. Обслуживание
уровня из другого стора — одна из них, и заявляет её хост, который этим стором владеет (см. README
пакета `Veriqa.Core.Configuration`), а не библиотека, объявляющая ключи.

## 1. Свой каталог

Каталог держит объявления **одного владельца** — общего каталога на все расширения нет.

Заполняйте его из статического инициализатора своего класса ключей и выставляйте наружу:

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

public static class AcmeConfigKeys
{
    // Инициализируется раньше объявлений, которые его наполняют, — в этом порядке идут инициализаторы.
    private static readonly ConfigKeyCatalog Declared = new();

    /// <summary>Секция конфигурации хоста, в которой живёт уровень ядра этих ключей.</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();

    // То, что читает регистратор развёртывания.
    public static ConfigKeyCatalog Catalog => Declared;
}
```

`Of<T>(name)` открывает цепочку для значения любого типа, `Text(name)` — для текстовой формы, в
которой живёт большинство настроек, а `Declare()` её закрывает: собирает ключ, кладёт объявление в
каталог и отдаёт ключ обратно. **Незакрытая цепочка не объявляет ничего** — такого ключа не
существует, и ни один его уровень не привязан.

### Адреса по уровням

| Форма | Что означает |
| - | - |
| `.At(ConfigLevel.Application, ConfigLevel.UiConfig)` | Адрес **следует из имени**: точки становятся разделителями пути, поэтому `Acme.Approvals.MaxAttempts` читается по `Acme:Approvals:MaxAttempts` внутри записи каждого уровня. |
| `.At(ConfigLevel.UiConfig, "acme_max_attempts")` | Адрес написан **явно** — там, где развёрнутая форма конфигурации не совпадает с именем ключа. |
| `.AtIdentity(ConfigLevel.Tenant)` | У уровня **нет пути**: он адресуется одной идентичностью ключа. Это то, что нужно стору, который держит значения строками «уровень × владелец × ключ × измерения», — там нет документа, внутрь которого можно пройти. |

Одно правило решает здесь больше ошибок, чем все остальные: **из корня конфигурации хоста адресуется
только уровень ядра.** Его запись — это и есть конфигурация приложения, поэтому
`Acme:Approvals:MaxAttempts` там полный путь. Выше ядра адрес относителен **записи своего уровня** —
записи клиента, записи `ui_config`, — и объявление поэтому не называет никакого стора. Адрес выше
ядра, открывающийся секцией конфигурации хоста, **останавливает старт** сообщением, в котором названы
ключ, уровень и адрес.

<Note>
  Ключ **составного** типа читается из поддерева по своему адресу строгим биндингом: член, которого
  тип не несёт, — остаток прошлой версии, случайный ключ, «комментарий», написанный полем, — делает
  нечитаемым всё поддерево, и шаг отрабатывает ровно как значение, отбракованное доменом (выше
  уровня ядра запись выбрасывается, на уровне ядра ключ с `FailStart` останавливает хост). Это
  принятая цена гарантии: без неё элемент набора с опечаткой приезжает как набор поменьше, с виду
  совершенно корректный. Ключей со скалярным значением это не касается — как и членов, которые вы
  дописали рядом со своим ключом в чужую запись: запись клиента вашим поддеревом не является.
</Note>

## 2. Зарегистрируйте каталог из своей композиции

```csharp AcmeServiceCollectionExtensions.cs theme={null}
public static IServiceCollection AddAcmeApprovals(
    this IServiceCollection services,
    IConfiguration configuration)
{
    // Ваши ключи едут вместе с вашей композицией — единственный момент, который работает для
    // сборки, которую не может назвать ни один корень композиции.
    services.AddVeriqaConfigKeyCatalogs(configuration, AcmeConfigKeys.Catalog);

    // ... остальные ваши регистрации
    return services;
}
```

В хосте это одна строка рядом с прочими:

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

Три свойства этого вызова стоит назвать прямо — именно они делают безопасной композицию независимых
расширений:

* **Он идемпотентен, покаталожно.** Каталог, который контейнер уже держит, пропускается **в
  одиночку**. И композиция, вызванная дважды, и две композиции, назвавшие один каталог, оставляют
  ровно одну регистрацию.
* **Он ничего не подавляет.** Ваш вызов регистрирует названное вами и пропускает уже имеющееся — ни
  один владелец не может выбить каталоги другого, и ни один не регистрируется дважды.
* **Порядок не важен.** Каждый регистратор объявляет свои ключи *до* того, как зарегистрирован
  первый биндинг, поэтому от того, отработала ваша композиция раньше или позже композиции ядра, не
  зависит, найдёт ли биндинг свой ключ.

Читатель каталогов приезжает **вместе** с этой регистрацией, а не ожидается от хоста: каталог,
который никто не читает, не объявляет ничего — его ключей нет в схеме.

`AddVeriqaConfigKeyCatalogs` принимает любое число каталогов, `AddVeriqaConfigKeyCatalog` регистрирует
один. Поставляемый код идёт обоими путями: общий пакет канальных адаптеров
`Veriqa.Core.ChannelAdapter` заявляет свои три каталога одним вызовом, а каждый канальный адаптер —
свой на собственной регистрации, поэтому развёртывание объявляет креденшелы тех каналов, которые
добавило, и ничьи больше.

## 3. Прочитайте значение

```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 говорит, какой уровень ответил, — полезно в собственной диагностике.
        return resolved.Value;
    }
}
```

`IConfigurationResolver` — единственный вход резолюции, и он асинхронный: уровень, обслуживаемый
внешним стором, читается асинхронно, а синхронная обёртка над ним была бы sync-over-async.
`ResolutionContext.ForTenant(tenantId)` закрывает случай «только тенант», `ResolutionContext.Core` —
спрашивает один уровень ядра.

Резолвите несколько ключей, которые обслуживает одна запись? Откройте `ConfigResolutionScope` над
контекстом и передавайте **его** вместо контекста — запись тогда читается **один раз** на всё, что
резолвится внутри:

```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. Что может заявить объявление

Ваши ключи получают ту же модель, что и ключи ядра, а не урезанную.

**Семантика сведения уровней.** По умолчанию — простое значение: верхний уровень свободно
переопределяет нижний.

| Член цепочки | Семантика |
| - | - |
| *(ничего)* | **Value** — верхний уровень свободно переопределяет нижний. |
| `.Set(intersect)` | **Set** — эффективное значение есть пересечение уровней, поэтому нижний уровень не может расширить то, что разрешил верхний. |
| `.ProtectiveCeiling(stricter)` | **Протективный потолок** — вниз только строже; послабление требует гейта, открытого верхним уровнем. |
| `.GatedValue(ConfigLevel.Application)` | **Контролируемое переопределение** — перечисленные уровни переопределяют только там, где уровень ниже открыл гейт; остальные переопределяют свободно. |

**Домен значения и цена значения вне него:**

```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` (по умолчанию) оставляет провинившийся шаг незаданным, и резолюция уходит на уровень ниже;
`FailStart` отказывает старт на уровне ядра и выбрасывает запись выше него. Текст `boundary` —
это то, что оператор читает в отчёте, а `correctAt` — где ему сказано это исправить. Само значение ни
в одно из сообщений не попадает. Ключ перечисления получает домен своих членов и без этого вызова.

**Измерения.** `.Identity("channel")` заявляет измерение, без которого вопрос теряет смысл
(«настройка *какого* канала»), — оно адресуется на каждом шаге цепочки фолбэка и никогда из неё не
выпадает. `.Narrowing("theme", "surface")` заявляет сужающие атрибуты, самый значимый первым; они
выпадают из цепочки по одному, начиная с наименее значимого. `.Fallback(...)` заменяет
сгенерированную цепочку там, где ключ пробует лишь часть комбинаций.

**Остальная цепочка одним списком:**

| Член | Что заявляет |
| - | - |
| `.Default(value)` | Чем отвечает **уровень ядра** там, где его запись не сказала ничего, — значение, с которым продукт поставляется. Это данные, а не хук, поэтому его печатают и схема, и отчёт по снимку. |
| `.Required()` | Значение обязан заявить хоть какой-то уровень. |
| `.Secret()` | Значение — секрет: схема несёт факт ответа и никогда его текст. |
| `.Cached(ConfigCachePolicy.ExternalStore)` | Политика кэша — TTL 30 секунд для уровня за внешним стором; есть `ConfigCachePolicy.None` и свой `Ttl`. |
| `.BlankIsUnstated()` | Уровень, заявивший пустой текст, не заявил ничего — недоделанная правка не является переопределением. Только для текстовых ключей. |
| `.PresenceMeans(value)` | Чем отвечает уровень одним фактом существования своей записи. |
| `.EnumTokens<TEnum>()` | Значение пишется токенами перечисления. |
| `.Parse(...)` | Чтение одного шага вручную — для значения, стоящего над несколькими членами записи (путь и его SRI-хеш), или для шага, читающего не тот лист, что соседний. |
| `.Stated(...)` | Все адреса, по которым уровень может заявить эту настройку, — для обхода снимка конфигурации. Резолюцию не меняет. |
| `.NotWalked()` | Обход снимка не воспроизводит это значение; отчёт называет его стоящим вне обхода вместо того, чтобы показать половину. |

## 5. Проверьте, что объявление подхватилось

Старт-отчёт механизма печатает то, что объявляет развёртывание. Одна строка уровня `Information`
называет всю схему:

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

Поднимите механизм до `Debug` — и каждое объявление печатается со своими границами; строка ключа
выше выглядит так:

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

Там же названы два расхождения: ключи, объявленные без единого биндинга извлечения в этом
развёртывании (`Information` — из каких частей собрано развёртывание, решает оно само), и `Warning`
на каждую пару «ключ + уровень», где ключ объявил уровень, который обслуживает эта установка, а биндинг
на пару не зарегистрирован — такой уровень молча пуст. Ключ, которого в отчёте нет вовсе, не
зарегистрирован: обычная причина — незакрытая цепочка (нет `Declare()`) либо композиция, которая так
и не вызвала `AddVeriqaConfigKeyCatalogs`.

## Пример в поставляемом коде

`CorePageBrandingConfigKeys` и `LoginConfirmationConfigKeys` живут в `Veriqa.Core.ChannelAdapter` —
сборке, которая *не может* ссылаться на сервер аутентификации, это была бы обратная зависимость, — и
объявляют ключи на уровнях приложения и `ui_config`, записи которых достаёт как раз сервер
аутентификации. Свои каталоги они регистрируют из собственной композиции пакета канальных адаптеров,
ровно как описано в §2.

## Где этот путь нужен

* **Свой канальный адаптер** — его креденшелы, тексты и лимиты по тенантам.
  [Руководство по канальному адаптеру](/docs/ru/guides/custom-channel-adapter) резолвит настройки через
  `IConfigurationResolver`; эта страница — про то, откуда берётся резолвимый им ключ.
* **Сток аудита, экспортёр метрик, исходящий шлюз** — всё, что поставляется отдельной сборкой и что
  оператору нужно настраивать по приложениям или по тенантам.
* **Свой движок политик ограничения.** Собственные правила допустимости Veriqa — внутренний контракт,
  и точкой расширения они не публикуются: интегратор, которому нужен другой движок политик — Rego,
  Cedar, собственный DSL, — поставляет такую проверку сам, и она держит **свою** конфигурацию на
  **своих** ключах, объявленных ровно так, как описано выше.

## Дальше

<CardGroup cols={2}>
  <Card title="Справочник конфигурации" icon="sliders" href="/docs/ru/reference/configuration">
    Все секции и ключи продукта и таблица объявленных ключей.
  </Card>

  <Card title="Свой канальный адаптер" icon="puzzle-piece" href="/docs/ru/guides/custom-channel-adapter">
    Вторая половина пути расширения — канальный SPI.
  </Card>
</CardGroup>


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