# 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.`; - зафиксировать порядок: запись 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`, а не «выполнено».