# ТЗ: B-3R — репарация Block B-3 до пригодного к merge состояния Репозиторий реализации: `C:\Users\Ochenstarik\projects\server-monitor-manager-task3-b3` Ветка: `hermes/task3-b3-fact-reconciliation`, база `b11c277`, изменения не закоммичены. Исходное ТЗ: `smm-task-B3-spec-2026-08-03.md` — остаётся в силе целиком, этот документ его не заменяет, а добавляет условия приёмки. Пакет `Task3-B3-2026-08-04` — промежуточный (`STATUS-WIP.md` + первичный review), кода в нём нет. Проверку проводил по рабочей копии напрямую. --- ## 1. Что подтверждено независимо Обе блокирующие находки первичного review — **реальны и в текущем состоянии рабочей копии не закрыты**. Проверял по коду, а не по отчёту. ### B1 подтверждён `src/ServerMonitorManager.Control/LinkService.cs`: ```csharp :416 if (persisted) :418 await VerifyFactualStateAsync(current, expectedConnected, cancellationToken); :431 else if (!persisted) :433 current = current with { ActualState = completedState, LastError = null }; ``` DB-less orphan удаляется сырым `link-disconnect`, после чего успех **присваивается синтетическому объекту без единой проверки факта**. Доказательства, что все handles с этим comment исчезли, нет. Для persisted-политики проверка есть, но идёт через `link-status`, а `status_rule` по-прежнему вызывает `lookup_node_ip` для обоих узлов. Если Node отсутствует или не активирован, helper возвращает 80 **уже после успешного удаления правила**, и `ConvergeAsync:444-455` записывает `PendingActivation` / `mesh.node-not-activated`. Дальше в `ReconcileAllAsync:274-284` классификация такова, что `PendingActivation` не попадает ни в `Converged`, ни в `Failed`. Возможен результат `Examined=1, Converged=0, Failed=0`, при котором фоновый сервис видит `Failed == 0` и **потребляет marker**, хотя фактический результат не подтверждён. ### B2 подтверждён `deploy/ochenstarik-smm-policy-apply:37-44`: ```bash lookup_node_ip() { record="$(awk -F '\t' -v node="$node_id" '$1 == node { print; exit }' "$STATE_FILE")" [[ -n "$record" ]] || node_not_activated ip="$(awk -F '\t' '{ print $2 }' <<<"$record")" [[ "$ip" =~ $ipv4_pattern ]] || fail "node has no valid mesh address: $node_id" ``` Читается только второе поле. Четвёртое — статус — не читается никогда. `reserve_node_address` пишет строку `node10.77.0.x-reserved`, `peer-add` меняет статус на `active`. Значит зарезервированный, но не активированный Node проходит проверку, и `link-connect` создаёт accept-правило. Exit 80 возникает только при полном отсутствии строки — случай, которого в реальной топологии не бывает, потому что строку создаёт сам `node-code`. Новый тест моделирует именно отсутствующую строку и поэтому дефект не ловит. Воспроизведение ревьюера на production-shaped фикстуре считаю корректным. ### Что уже сделано правильно и ломать не надо - `disconnect_rule` больше не вызывает `lookup_node_ip` — удаление правила перестало зависеть от `nodes.tsv`. Это верное направление, сохранить. - `list_rules` разбирает только managed-comment, проверяет арность 5 полей, node-паттерны, протокол и диапазон порта, отбрасывает подделки в stderr, чужие правила не выводит. - `reconcile-status` возвращает `complete` при отсутствующем каталоге mesh; добавлена явная проверка `-x /usr/sbin/nft`; режим `SMM_POLICY_LISTING_FILE` для контрактных тестов. - `Examined` / `Converged` / `Failed` разведены, `FailedPolicyIds` пробрасывается. --- ## 2. Обязательные исправления ### R1 — верификация факта только через `link-list` (закрывает B1) Корень дефекта: проход стал fact-first, а верификация осталась per-link через `link-status`. Отсюда и пропуск проверки для DB-less, и зависимость от `nodes.tsv`, и лишние привилегированные вызовы. Сделать: 1. `VerifyFactualStateAsync` переписать на `link-list`: считать число записей с ключом `(source, target, protocol, port)`. - ожидается `Active` → **ровно 1**; - ожидается `Disabled` или DB-less orphan → **ровно 0**; - любое иное число — отказ применения с явным кодом. 2. Убрать условие `if (persisted)` вокруг верификации. DB-less orphan проверяется теми же правилами, что и persisted. 3. Убрать присваивание `current with { ActualState = completedState }` без предшествующей проверки. Синтетический объект имеет право получить `Disabled` только после подтверждённого нуля записей. 4. Верификация удаления **не должна** зависеть от наличия узла в `nodes.tsv`. `link-list` этого не требует — после перехода зависимость исчезает сама. 5. Оптимизация, которую даёт этот же переход: в проходе выполнять **один** финальный `link-list` после всех мутаций и сверять по нему все затронутые ключи разом, вместо вызова на каждую мутацию. Требование «проход без расхождений — один привилегированный вызов» из B3-2 сохраняется; для прохода с `k` мутациями допускается ровно два вызова (стартовый и верифицирующий). ### R2 — исчерпывающая классификация результата (закрывает вторую половину B1) Ввести явный третий класс — `Deferred` — и инвариант: ``` Converged + Failed + Deferred == Examined ``` Инвариант проверять в коде (нарушение — исключение либо `LogError` с идентификаторами) и отдельным тестом. Ни одно состояние не должно молча выпадать из классификации. **Решение, которое нужно принять явно, потому что иначе оно будет принято неправильно:** - `PendingActivation` — это `Deferred`, а не `Failed`. Это ожидаемое устойчивое состояние: Node зарезервирован, `peer-add` ещё не выполнен, и так может продолжаться сутками. - `Deferred` **не блокирует** потребление marker. Иначе один зарезервированный Node навсегда удержит маркер и вернёт ровно тот бесконечный цикл проходов каждые 30 секунд, который был закрыт в B-2 и B3-3. - Marker блокирует только `Failed`, и на него уже действует ограничение в три попытки. - `Deferred` обязан быть видимым: отдельное событие (`link.pending-node-activation` уже есть), отдельная подпись в Desktop, отражение в журнале прохода. Ошибкой не называется. `LinkFullReconciliationResult` и `LinkReconciliationResult` дополнить полем `Deferred` и списком `DeferredPolicyIds` по образцу `FailedPolicyIds`. ### R3 — читать статус узла в `lookup_node_ip` (закрывает B2) Разбирать минимум два поля — адрес и статус — и различать три исхода: | Строка в `nodes.tsv` | Результат | |---|---| | статус `active`, валидный IPv4 | успех | | статус `reserved` (или любой не-`active`) | exit **80**, точный маркер `mesh.node-not-activated` | | статус `active`, IP пустой или невалидный | exit **78** — повреждённое состояние, fail-closed | | строки нет вовсе | exit **80** | Неизвестное значение статуса трактовать как не-`active` только если оно проходит валидацию формата; мусор в поле — это exit 78, а не 80. Fail-closed при сомнении. Тесты обязаны использовать **production-shape** фикстуру из четырёх полей, разделённых табуляцией: ``` source 10.77.0.2 key-source active target 10.77.0.3 - reserved ``` Три случая: `reserved` + валидный IP → 80; `active` + валидный IP → успех; `active` + невалидный IP → 78. Тест на полностью отсутствующую строку сохранить, но он не заменяет перечисленные. ### R4 — source-generated сериализация orphan audit (H1) Заменить анонимный тип в `ControlStore.cs:1376-1398` именованным DTO, добавить его в source-generated JSON-контекст Control и сериализовать через `Context.Default.`. Мотив не в предупреждениях компилятора, а в том, что отказ произойдёт **после** мутации firewall и **после** публикации `link.orphan-removed`, дав противоречивую телеметрию. Дополнительно к тому, что предложил review: проверить не локально, а прогоном опубликованного `linux-x64 PublishTrimmed` артефакта по orphan-пути и сверкой содержимого audit-записи. Разово, руками, с приложением вывода в `TEST_EVIDENCE.md`. Порядок операций зафиксировать явно: запись audit — до публикации финального события успеха. ### R5 — Linux integration test на новый протокол (H2) `tests/ServerMonitorManager.Control.Tests/LinkPolicyApplierIntegrationTests.cs` остался на протоколе `link-status`-first: fake helper не реализует `link-list`, ожидаемый журнал вызовов начинается с `link-status`. На Windows тест выходит по `OperatingSystem.IsLinux() == false`, поэтому заявленные локальные 85/85 дефект не показывают, а `linux-control-agent.yml` гоняет Control-тесты на Ubuntu, где пропуска не будет. Сделать: - реализовать `link-list` в fake helper; - переписать ожидаемый журнал на fact-first протокол; - проверить: no-drift → ровно один `link-list`; отсутствующая таблица классифицируется прямо на `link-list` без промежуточной мутации; мутация сопровождается финальной верификацией через `link-list`; DB-less raw disconnect проходит верификацию; - прогнать Control suite **на Linux**, а не только на Windows. Пока это не сделано, утверждение «CI зелёный» не имеет оснований: PR не создан, Linux-прогон не выполнялся. ### R6 — убрать fail-open default в интерфейсе (M1) `ILinkPolicyApplier.ListRulesAsync` с телом по умолчанию, возвращающим пустой список, означает, что забытая реализация молча сообщает «firewall пуст» — для источника истины о факте это худший из возможных дефолтов, и он уже позволил старым test doubles скомпилироваться без нового протокола. Сделать `ListRulesAsync` и raw `ApplyDisconnectAsync(LinkRule, …)` обязательными членами интерфейса, все test doubles обновить явно. ### R7 — читаемость вложенных scope (L1) Переформатировать `LinkService.cs:181-229` и `240-293` либо вынести обработку одного tuple и одного orphan в приватные методы без дублирования логики сходимости. Порядок блокировок `sorted node locks → per-Link gate` должен быть виден глазами, а не восстанавливаться разбором отступов. --- ## 3. Дополнительные тесты к обязательным из исходного ТЗ 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 `reserved` → exit 80 (см. R3). 7. Зарезервированный Node в течение десяти последовательных проходов: `Deferred` каждый раз, marker потребляется, число проходов не превышает расписание. --- ## 4. Definition of Done B-3R - [ ] Верификация факта выполняется только через `link-list`, счётом записей, одинаково для persisted и DB-less - [ ] Синтетический успех без проверки факта отсутствует в коде - [ ] Верификация удаления не зависит от `nodes.tsv` - [ ] `Converged + Failed + Deferred == Examined` выполняется и проверяется тестом - [ ] `PendingActivation` классифицирован как `Deferred`, marker им не блокируется, состояние видно в UI и журнале - [ ] `lookup_node_ip` читает статус; `reserved` → 80, `active` + битый IP → 78; тесты на production-shape фикстуре - [ ] orphan audit сериализуется через source-generated контекст; проверено на опубликованном trimmed артефакте - [ ] Linux integration test переведён на fact-first протокол и **прогнан на Linux** - [ ] `ListRulesAsync` и raw disconnect — обязательные члены интерфейса - [ ] Все пункты DoD исходного ТЗ B-3 по-прежнему выполняются - [ ] PR создан, CI зелёный — впервые для этой ветки - [ ] Финальный независимый review получает **полный текст исходного ТЗ B-3 и этот документ**, а не только диф --- ## 5. Формат сдачи Как в предыдущих блоках: `REPORT.md`, `TEST_EVIDENCE.md`, патч и/или ZIP, `CI_CHECKS.txt`, финальный `INDEPENDENT_REVIEW.md`, `SHA256SUMS` — считать последним действием, после всех правок отчётов, включая все файлы пакета. Промежуточный пакет из двух файлов, как сейчас, — нормальная практика для отчёта о ходе работ, если он так и назван. Финальный обязан быть полным. Статус по итогам merge формулировать как `merged / verified, physical acceptance pending`. --- ## 6. Оценка первичного review Обе блокирующие находки верны, воспроизведение B2 корректно, разбор границ безопасности содержательный. Ревьюер честно указал, что `dotnet` в его окружении отсутствует и заявленные 85/85 он не воспроизводил — и именно из этого сделал правильный вывод про H2, что Linux-прогон обязателен. Это ровно тот способ работы, который нужен. Практика передачи ревьюеру полного текста ТЗ продолжает окупаться: B2 и H2 — пропущенные требования, а не ошибки в написанном коде, при diff-only ревью они не находятся. --- ## 7. Внешний блокер, без изменений Physical acceptance не выполнялся ни разу за четыре блока. Нужны `HUB_SSH_HOST`, `HUB_SSH_USER`, `SOURCE_SSH_HOST`, `SOURCE_SSH_USER`, `HOME_WG_IP`, `SECOND_WG_IP`, `SSH_IDENTITY_FILE` и прогон ```bash SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1 tests/acceptance/three-server-mesh.sh ``` Harness готов и содержит и firewall-restore, и инъекцию orphan-правила. Критерий B6 и пункт «выполнить физический acceptance» Этапа 3 roadmap до этого прогона остаются открытыми при любом состоянии CI. Исполнитель закрыть их не может. Следующий блок после B-3R — **Блок C**, доверенная поставка, по разделу 3 документа `smm-task-4-2026-08-03.md`.