# B-3R: завершить реконсиляцию от фактического состояния

## Репозиторий и ветка

- Репозиторий: https://github.com/ochenstarik-ui/server-monitor-manager
- Рабочая копия: `C:\Users\Ochenstarik\projects\server-monitor-manager-task3-b3`
- Ветка: `hermes/task3-b3-fact-reconciliation`, **изменения не закоммичены** — 24 файла, около +1276/−141
- База: `b11c277ac7f79a18670932eca4622982d9ff48e0`
- Ветки на GitHub нет; коммит и push ещё не выполнялись

Нормативные документы задания: `B3R_SPEC.md` и исходное ТЗ B-3 — оба в `C:\Users\Ochenstarik\Сюда\Panel control\Task3-B3-2026-08-04\`. Они остаются в силе целиком; ниже — только то, что осталось не сделано.

## Зачем

Проход реконсиляции стал fact-first: сначала один `link-list`, потом сверка с базой. Но верификация результата мутации осталась пер-линковой, а классификация результата неполна. Из-за этого два дефекта, оба на пути kill switch:

- удаление правила может быть засчитано без доказательства, что правило исчезло;
- результат, не попавший ни в `Converged`, ни в `Failed`, позволяет фоновому сервису потребить marker внеочередной реконсиляции при неподтверждённом исходе.

## Что уже закрыто — проверено по коду, переделывать не нужно

- **R3 полностью.** `lookup_node_ip` читает четвёртое поле `nodes.tsv` и требует `active`; проверка статуса стоит раньше проверки IP, поэтому `reserved` + валидный адрес даёт exit 80, а `active` + битый адрес даёт 78.
- **R1 на уровне отдельной мутации.** Появился `VerifyExactFactualCountAsync`, считающий записи через `ListRulesAsync`, то есть через `link-list`, а не `link-status`. Условие `if (persisted)` вокруг верификации снято — DB-less orphan теперь тоже проверяется. Синтетическое присваивание `ActualState` выполняется после доказанного нуля записей, а не вместо доказательства.
- `list_rules` разбирает только managed-comment, проверяет арность, node-паттерны, протокол и диапазон порта, подделки отбрасывает в stderr, чужие правила не выводит.
- `disconnect_rule` больше не вызывает `lookup_node_ip` — удаление правила не зависит от `nodes.tsv`. Это верно, сохранить.

## Работы

### 1. Пакетная финальная верификация

Сейчас `VerifyExactFactualCountAsync` вызывается на каждую мутацию: проход с `k` мутациями делает `1 + k` вызовов `link-list`.

Требуется: один стартовый `link-list` и, при наличии мутаций, **ровно один финальный** `link-list`, по которому сверяются все затронутые ключи разом. Проход без расхождений — один привилегированный вызов, проход с мутациями — ровно два.

Проверяется счётчиком вызовов фейкового applier и журналом вызовов в интеграционном тесте.

### 2. Класс `Deferred` и инвариант классификации

Ввести третий класс результата и инвариант:

```
Converged + Failed + Deferred == Examined
```

Инвариант проверять в коде — нарушение это `LogError` с идентификаторами политик — и отдельным тестом на смешанном наборе.

Решение, которое нужно реализовать именно так:

- `PendingActivation` — это `Deferred`, а не `Failed`. Node зарезервирован, `peer-add` не выполнен, состояние может держаться сутками и ошибкой не является.
- `Deferred` **не блокирует** потребление marker. Иначе один зарезервированный Node навсегда удержит marker и вернёт бесконечный полный проход каждые 30 секунд — ровно то, что закрывали в B-2 и B3-3.
- Marker блокирует только `Failed`, и на него уже действует ограничение в три попытки.
- `Deferred` обязан быть видимым: событие `link.pending-node-activation`, отдельная подпись в Desktop, отражение в журнале прохода. Ошибкой не называется.

`LinkFullReconciliationResult` и `LinkReconciliationResult` дополнить полем `Deferred` и списком `DeferredPolicyIds` по образцу `FailedPolicyIds`.

### 3. Source-generated сериализация orphan audit (R4)

В `ControlStore.cs` запись audit для orphan-правила сериализуется анонимным типом через reflection, тогда как остальной Control последовательно использует source-generated `JsonTypeInfo`.

Отказ произойдёт **после** мутации firewall и **после** публикации `link.orphan-removed`, дав противоречивую телеметрию: правило удалено, событие отправлено, проход помечен неуспешным.

- ввести именованный DTO, добавить его в source-generated контекст Control, сериализовать через `Context.Default.<Type>`;
- зафиксировать порядок: запись audit до публикации финального события успеха;
- проверить на **опубликованном** `linux-x64 PublishTrimmed` артефакте, прогнав orphan-путь и сверив содержимое audit-записи. Приложить вывод к отчёту.

### 4. Linux integration test на fact-first протокол (R5)

`tests/ServerMonitorManager.Control.Tests/LinkPolicyApplierIntegrationTests.cs` остался на протоколе `link-status`-first: fake helper не реализует `link-list`, ожидаемый журнал вызовов начинается с `link-status`.

На Windows тест выходит по `OperatingSystem.IsLinux() == false`, поэтому локальные прогоны его не показывают. `linux-control-agent.yml` гоняет Control-тесты на Ubuntu, где пропуска не будет.

- реализовать `link-list` в fake helper;
- переписать ожидаемый журнал на fact-first протокол;
- проверить: no-drift → ровно один `link-list`; отсутствующая таблица классифицируется прямо на `link-list` без промежуточной мутации; мутация сопровождается одной финальной верификацией; DB-less raw disconnect проходит верификацию;
- **прогнать Control suite на Linux.** Windows-прогон этот тест не выполняет и заявленные локальные результаты дефект не выявляют.

### 5. Убрать fail-open default в интерфейсе (R6)

`ILinkPolicyApplier.ListRulesAsync` имеет тело по умолчанию, возвращающее пустой список. Забытая реализация молча сообщает «firewall пуст» — худший возможный дефолт для источника истины о факте, и он уже позволил старым test doubles скомпилироваться без нового протокола.

Сделать `ListRulesAsync` и raw `ApplyDisconnectAsync(LinkRule, …)` обязательными членами интерфейса, все test doubles обновить явно.

### 6. Читаемость вложенных scope (R7)

`LinkService.cs`, участки обработки persisted-кандидатов и factual-orphans: тела `try` визуально на уровне внешнего scope, порядок блокировок `sorted node locks → per-Link gate` восстанавливается только разбором отступов. Переформатировать либо вынести обработку одного tuple и одного orphan в приватные методы без дублирования логики сходимости.

### 7. Мусор в поле статуса `nodes.tsv`

Сейчас любое не-`active` значение даёт exit 80 и уходит в `Deferred` навсегда. Разграничить: известные значения (`reserved` и подобные) → 80; значение, не проходящее валидацию формата, → 78. Повреждённое состояние — это отказ, который должен всплыть, а не ожидаемое состояние, которое можно откладывать бесконечно.

## Тесты, без которых задание не принимается

1. DB-less orphan: helper сообщает успех удаления, но правило осталось → классифицируется как `Failed`, не как успех.
2. Persisted `Disabled`, узел отсутствует в `nodes.tsv` → удаление подтверждается через `link-list`, состояние `Disabled`, не `PendingActivation`.
3. Marker не потребляется при наличии `Failed`; потребляется, если остались только `Converged` и `Deferred`.
4. Инвариант `Converged + Failed + Deferred == Examined` на наборе из шести политик со всеми тремя исходами.
5. Проход с `k` мутациями выполняет ровно два вызова `link-list`.
6. Production-shape фикстура `nodes.tsv` из четырёх полей: `reserved` + валидный IP → 80; `active` + валидный IP → успех; `active` + невалидный IP → 78; мусор в статусе → 78.
7. Зарезервированный Node на десяти последовательных проходах: каждый раз `Deferred`, marker потребляется, число проходов не превышает расписание.

## Критерий приёмки

- все семь пунктов работ закрыты;
- семь тестов выше зелёные;
- Control suite прогнан **на Linux**, а не только на Windows;
- orphan audit проверен на опубликованном trimmed-артефакте;
- все критерии исходного ТЗ B-3 по-прежнему выполняются;
- PR создан, CI зелёный — впервые для этой ветки;
- независимый review получил полный текст этого задания, `B3R_SPEC.md` и исходное ТЗ B-3.

## Границы

- не трогать файлы вне области реконсиляции Links; изменения в Desktop ограничены отображением `Deferred`;
- не менять порядок блокировок и не вводить новых классов блокировок;
- не ослаблять fail-closed поведение: неизвестная ошибка `nft` остаётся отказом, а не «правил нет»;
- `TreatWarningsAsErrors` в этой задаче не включать.

## Отчётность

Отдельно перечислить: что запущено локально, что в CI, что не запускалось и почему. Физический acceptance (`SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1 tests/acceptance/three-server-mesh.sh`) выполнить нельзя — не выданы topology inputs; contract- и mock-тесты за него не выдавать.

Статус после merge — `merged / verified, physical acceptance pending`, а не «выполнено».
