# Приёмка Task 3 / B-2 и Задача 4

Базовая ревизия: `main` @ `b11c277` (PR #12, squash merge, 21 файл, 1113/172).
Предшествующие документы: `smm-improvement-plan-2026-07-30.md`, `smm-task-2-2026-07-31.md`, `smm-task2-blockB-review-2026-08-03.md`, `smm-task-3-2026-08-03.md`.

---

## 0. Приёмка B-2: **принято**

Проверял по `origin/main`, а не по отчёту. Все семь контрольных сумм сходятся, CI 14/14 на PR #12, `PATCH_VERIFICATION.txt` подтверждает применимость патча к чистой базе.

| Требование задачи 3 | Проверка в `main` @ `b11c277` |
|---|---|
| B2-1 фоновый сервис, старт + интервал | `Program.cs:63` — `AddHostedService<LinkReconciliationBackgroundService>`; `do { RunOnceAsync } while (timer.WaitForNextTickAsync)` — первый проход немедленный ✅ |
| `Control__LinkReconciliationSeconds` | `ControlOptions.cs:27` (=300), валидация `Program.cs:49` (30…3600), `appsettings.json:13`, `control.env` в bootstrap ✅ |
| Проход по всем действующим политикам | `ReconcileAllAsync` через `ListEffectiveLinksAsync`, не по одному Node ✅ |
| Порядок блокировок сохранён | sorted node locks → per-Link gate, новых классов не введено ✅ |
| `link.reapplied` / `link.orphan-removed` | `LinkService.cs:309` ✅ |
| Единая логика сходимости | `ConvergeAsync` — используется create/disable/reconnect/full/TTL/retry. Три копии сведены к одной ✅ |
| B2-2 «таблицы нет» ≠ «правил ноль» | helper: exit 79 + точный маркер `mesh.firewall-unavailable` в stderr, `LC_ALL=C`; типизированное исключение; проход прерывается; агрегированное событие; bounded backoff; событие восстановления `mesh.firewall-available` ✅ |
| B2-3 маркер внеочередной реконсиляции | `firewall-restore` и `mesh-enable` публикуют маркер атомарно под общим root-flock с UUID-поколением; helper потребляет **только точное поколение** ✅ |
| B2-4 тесты | `LinkReconciliationTests.cs` (338 строк), контрактные проверки в bootstrap-тесте, интеграционный тест последовательности вызовов helper ✅ |
| Acceptance: firewall-restore без перезапуска Node | новый шаг `[8/12]` с retry-циклом по фактическому статусу **и** реальной связности ✅ |
| M1 `GetLinkAsync`, L1 CR/LF | оба исправлены ✅ (в `REPORT.md` метки M1 и L1 перепутаны местами — сами правки на месте) |
| M2, M4, M5 | явно перенесены в B-3 с обоснованием в `docs/roadmap.md` — это и требовалось по DoD ✅ |
| `docs/linux-bootstrap.md` | приведён в соответствие с реализацией ✅ |

Отдельно отмечу два места, где сделано **лучше**, чем было в задании:

- Я просил просто маркер. Реализовано поколение-UUID под общим `flock` с потреблением ровно прочитанного поколения — то есть закрыта гонка «пока шёл проход, пришёл новый запрос», о которой в задании не было ни слова.
- Маркер обходит только регулярный интервал, но **не** обходит backoff недоступного firewall. Это правильное разделение, и оно покрыто тестом.

Независимый review в этот раз проводился против полного текста задачи, а не только дифа — рекомендация из прошлого документа принята и сработала: его находки (маркер против throttle, слепое удаление чужого поколения, выход acceptance по первому несовпадению, баннер при нуле SSH-профилей, локализованный stderr `nft`) — это ровно тот класс дефектов, который в прошлый раз никто не искал.

**Статус блока:** `merged / verified, physical acceptance pending`. Формулировка в отчёте корректна, B6 не объявлен закрытым. Это правильно и так и должно остаться до прогона на реальной тройке.

---

## 1. Находки по B-2 (не блокирующие merge, в работу)

### N1 — MEDIUM. Незавершаемый маркер даёт полный проход каждые 30 секунд бесконечно

`LinkReconciliationBackgroundService.RunOnceAsync`:

```csharp
if (requestGeneration is null && _nextRegularAt is not null && now < _nextRegularAt) return null;
...
else { _nextRegularAt = now + interval;
       if (requestGeneration is not null && result.Failed == 0)
           await applier.CompleteReconciliationAsync(requestGeneration, ct); }
```

Маркер потребляется только при `Failed == 0`, а наличие маркера полностью снимает регулярный throttle. `_backoffUntil` взводится **только** для `FirewallUnavailable`, обычные отказы под него не попадают.

Следствие: одна политика, которая падает стабильно и не связана с firewall — например Node удалён из `nodes.tsv`, и `lookup_node_ip` возвращает 78, — навсегда удерживает маркер. Полный проход начинает выполняться на каждом poll-тике (`min(interval, 30)` = 30 с) без ограничения, каждый проход — до двух `sudo`+`nft` на каждую подходящую политику. При полусотне Link это сотня привилегированных вызовов каждые полминуты на неопределённый срок.

**Сделать:** применить ограниченный backoff и к marker-пути. Например: счётчик неуспешных prompt-проходов, после 3–4 попыток маркер перестаёт снимать регулярный throttle, в журнал пишется одно предупреждение с идентификаторами упавших политик. Тест: политика, падающая всегда, при висящем маркере не должна вызывать более N проходов за интервал.

### N2 — MEDIUM. Orphan-правило для полностью сошедшейся Disabled-политики не обнаруживается

```csharp
private static bool IsEligibleForFullReconciliation(LinkPolicy link)
    => link.DesiredState == "Active"
        || (link.DesiredState == "Disabled" && link.ActualState != "Disabled");
```

Политика в состоянии `Disabled/Disabled` из полного прохода исключена — то есть если accept-правило с её комментарием окажется в цепочке, никто этого не заметит. А это ровно инвариант kill switch.

**Это моя недоработка в постановке, а не отклонение исполнителя** — фильтр в задаче 3 сформулирован мной дословно так же. Отмечаю, чтобы не потерялось.

Реалистичные пути появления такого правила: восстановление сохранённого ruleset `nft -f` поверх, частично применённый backup, обрыв между успешным `link-connect` и записью в БД, отказ на середине `link.orphan-removed`.

**Правильное решение — развернуть направление прохода.** Сейчас идём БД → факт, по одной политике, двумя вызовами helper на каждую. Нужно наоборот: один вызов `link-list`, возвращающий **все** правила цепочки с комментарием `smm:*`, и сверка множеств:

- правило есть, политики в БД нет либо `DesiredState=Disabled` → удалить, `link.orphan-removed`;
- политика `DesiredState=Active`, правила нет → применить, `link.reapplied`;
- совпало → ничего.

Это одновременно закрывает N2, снимает зависимость от `IsEligibleForFullReconciliation`, и превращает стоимость прохода из `2N` привилегированных вызовов в один. `link-list` был в исходной постановке Block B (пункт B1) и до сих пор не реализован — сейчас есть только пер-линковый `link-status`.

Связано с M4: пока нет retention для завершённых Disabled-политик, обход БД→факт растёт неограниченно. Обход факт→БД от этого не зависит.

### N3 — LOW. На Hub без `mesh-init` предупреждение каждые 30 секунд

`install_control` создаёт `$STATE_DIR` и `$STATE_DIR/backups`, но не `$STATE_DIR/mesh`; каталог появляется только в `mesh_init` / `reserve_node_address`. Helper в `reconcile_status` делает `exec 9>"$RECONCILE_LOCK"` в несуществующем каталоге — под `set -e` это отказ. В `RunOnceAsync` он ловится и пишется `LogWarning`, то есть в journal попадает предупреждение каждые 30 секунд до первого `mesh-init`.

**Сделать:** helper возвращает `complete`, если каталог mesh отсутствует (Mesh ещё не инициализирован — запросов быть не может), либо создаёт каталог при первом обращении.

### N4 — LOW. Отсутствующий бинарь `nft` неотличим от отсутствующей таблицы

`inspect_firewall` берёт объединённый вывод `2>&1` и классифицирует по подстроке `No such file or directory`. Если `/usr/sbin/nft` не установлен, эту же строку выдаёт сам bash — и ситуация классифицируется как `mesh.firewall-unavailable` (79). Поведение fail-closed и мутаций не будет, так что дефект не опасный, но оператор увидит «Mesh firewall не загружен» вместо «nftables не установлен».

**Сделать:** явная проверка `[[ -x /usr/sbin/nft ]]` с отдельным сообщением до вызова.

### N5 — LOW. Форматирование в `three-server-mesh.sh`

В блоке `probe_factual_status` / `expect_factual_status` перемешаны отступы 4 и 2 пробела, закрывающая `}` вынесена внутрь тела, определение `expect_factual_status() {` смещено на два пробела. `bash -n` проходит, поведение верное, но остальной файл написан с отступом в 2 пробела. Похоже на тот же артефакт инструмента, что в прошлый раз дал `(char)13`. Переформатировать.

### N6 — nit. `REPORT.md` меняет местами метки M1 и L1

Написано «M1 (CR/LF compaction) и L1 (direct Link lookup)»; в задаче M1 — это `GetLinkAsync`, L1 — CR/LF. Обе правки сделаны, перепутаны только подписи.

---

## 2. Задача 4

### Блок B-3 (первым)

Переносится из roadmap плюс находки выше.

| ID | Что | Приоритет |
|---|---|---|
| N2 + B1 | `link-list` в policy-helper и разворот прохода реконсиляции на факт → БД; удаление orphan-правил независимо от состояния в БД; один привилегированный вызов на проход вместо `2N` | высокий |
| N1 | Ограниченный backoff для marker-пути; тест на политику, падающую всегда | высокий |
| M2 | Развести `LinkReconciliationResult` на `Examined` / `Converged` / `Failed`; сейчас «1 реконсилирован» при нуле мутаций | средний |
| M4 | Фильтр по умолчанию на Links-странице («действующие + расхождения», с переключателем) и/или retention завершённых Disabled-политик в `ControlMaintenance` | средний |
| M5 | Типизировать «Node зарезервирован, но не активирован в mesh» — сейчас `lookup_node_ip` даёт отказ применения и Link уходит в `Failed` на каждом проходе | средний |
| N3 | Helper возвращает `complete` при отсутствующем каталоге mesh | низкий |
| N4 | Явная проверка наличия `nft` с отдельным сообщением | низкий |
| N5 | Переформатировать `three-server-mesh.sh` | низкий |

**Критерий выхода B-3:** проход реконсиляции обнаруживает и снимает accept-правило, которому не соответствует ни одна действующая политика, **включая случай `Disabled/Disabled`**; стабильно падающая политика не вызывает более одного прохода за интервал; Links-страница остаётся читаемой при сотнях исторических политик.

### Блок C — доверенная поставка

Без изменений относительно `smm-task-2-2026-07-31.md`, раздел 3, и `smm-task-3-2026-08-03.md`, раздел 3:

- **C1** manifest v2: хэши всех артефактов, версии Control/Agent/helper/Desktop, минимальные совместимые версии, `helper_protocol`.
- **C2** подпись (`cosign sign-blob` keyless либо `minisign`); публичный ключ/issuer вшит константой в bootstrap и Desktop, не скачивается вместе с релизом; отсутствие инструмента проверки — отказ, не предупреждение.
- **C3** `verify_archive` сверяет хэш с manifest, а не с соседним `.sha256`; действие `verify-manifest`; отказ `update-*` при несовместимых версиях; `PROGRAM_VERSION` из тега вместо `0.2.0-dev`.
- **C4** пиннинг Actions по commit SHA, `dependabot.yml` (github-actions + nuget), сужение `permissions: contents: write` до job'а публикации, SBOM в артефакты.
- **C5** негативные тесты в CI: изменённый байт архива, подменённый хэш в manifest, manifest без подписи — все три отвергаются.

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

Пока он не выполнен, B6 и вся Этап 3 roadmap остаются открытыми, независимо от того, сколько раз CI будет зелёным. Сейчас harness готов и включает шаг `firewall-restore` без перезапуска Node — то есть сценарий, ради которого делались Block B и B-2, впервые проверяем по-настоящему. Это единственный пункт списка, который не может закрыть исполнитель.

---

## 3. Порядок

1. **B-3**, приоритет по таблице: N2+B1 и N1 в одном PR (они оба про сервис реконсиляции), M2/M4/M5 вторым, N3/N4/N5 попутно.
2. **Physical acceptance** — как только выданы topology inputs. По результату закрыть B6 и соответствующие пункты Этапа 3 и 5 roadmap.
3. **Блок C**.

## 4. Процесс — работает, оставить

Практика «независимый review получает полный текст задачи, а не только диф» подтвердилась: пять находок этого цикла относятся к классу, который в прошлый раз не искали вовсе. Сохранить и для B-3, и для Блока C — для Блока C особенно, там корректность дифа и корректность модели угроз расходятся ещё сильнее.

Формулировка статуса `merged / verified, physical acceptance pending` вместо «выполнено» — тоже оставить.
