# ТЗ: 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` пишет строку `node<TAB>10.77.0.x<TAB>-<TAB>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.<Type>`. Мотив не в предупреждениях компилятора, а в том, что отказ произойдёт **после** мутации 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`.
