# Задача 3 — закрыть P0-2 по существу и перейти к доверенной поставке

Базовая ревизия: `main` @ `89ef2fd` (PR #11, Block B merged).
Предшествующие документы: `smm-improvement-plan-2026-07-30.md`, `smm-task-2-2026-07-31.md`, `smm-task2-blockB-review-2026-08-03.md`.

---

## 0. Результат перепроверки пакета Block B

**Процессная часть — принято.** `SHA256SUMS` сходится по всем семи файлам, репорт репрезентативен, CI 14/14 SUCCESS на `1580ec7` (включая Debian 12/13 x64+arm64 systemd restart и Ubuntu 22.04/24.04 native VM), PR #11 смержен как `89ef2fd`, временный verifier удалён. Замечание по контрольным суммам из прошлого ревью закрыто.

**Содержательная часть — не принято.** Проверял по `origin/main`, а не по отчёту. Диф совпадает с тем, что я ревьюил (551 против 550 additions), и главное замечание в него не вошло:

| Проверка в `main` @ `89ef2fd` | Результат |
|---|---|
| Хостед-сервисы в `Program.cs:61-62` | только `LinkExpirationBackgroundService` и `ControlMaintenanceBackgroundService` |
| Вызовы `ReconcileLinksForNodeAsync` | ровно один — `Program.cs:356`, обработчик heartbeat |
| `Control__LinkReconciliationSeconds` | отсутствует |
| `mesh.firewall-missing` / маркер от `firewall-restore` | отсутствуют |
| `(char)13` в `CompactError` | `LinkService.cs:339`, на месте |
| `ListLinksAsync().SingleOrDefault(...)` | `LinkService.cs:32`, `LinkService.cs:297` |

Условие запуска реконсиляции не изменилось:

```csharp
var reconnectThreshold = TimeSpan.FromSeconds(Math.Max(60, nextHeartbeatSeconds * 3)); // 90 с
var requiresReconciliation = previousStatus != "Online"
                             || previousLastSeenAt is null
                             || now - previousLastSeenAt >= reconnectThreshold;
```

Следовательно, в `main` по-прежнему открыты сценарии, ради которых Block B и делался:

1. `ochenstarik-smm-emergency firewall-restore` — правила стёрты, **ни один Node не уходил в offline**, реконсиляция не запускается. В Desktop: «Расхождение: нет». Фактически трафик заблокирован. `docs/linux-bootstrap.md:111` продолжает обещать переприменение, которого нет.
2. Ручной `systemctl restart ochenstarik-smm-firewall`.
3. Перезапуск Hub с простоем < 90 с (перезапуск сервиса, быстрый reboot VM, `update-control`).
4. Link, оба участника которого не переподключались.

**Критерий выхода B6 не выполнен. Block B остаётся открытым**, несмотря на merge. Раздел 1 этой задачи — его остаток, а не новая функциональность.

Отдельно: physical acceptance не выполнялся ни разу за две задачи, потому что не выданы `HUB_SSH_HOST`, `HUB_SSH_USER`, `SOURCE_SSH_HOST`, `SOURCE_SSH_USER`, `HOME_WG_IP`, `SECOND_WG_IP`, `SSH_IDENTITY_FILE`. Это внешний блокер, который снимается только вашим решением. Пока он не снят, ни один критерий, формулируемый через фактическую связность, закрыть нельзя — CI это не заменяет.

---

## 1. Блок B-2 — остаток Block B (обязательный)

### B2-1. Фоновая реконсиляция, не привязанная к переподключению Node

Новый `LinkReconciliationBackgroundService : BackgroundService`, регистрируется третьим в `Program.cs`.

- Настройка `Control__LinkReconciliationSeconds` в `ControlOptions`, валидация в цепочке `.Validate(...)` наравне с прочими (диапазон 30…3600, по умолчанию 300), значение в `appsettings.json`.
- Один проход при старте — после `store.InitializeAsync()`, до начала обслуживания запросов не обязателен, но первый тик должен быть немедленным, а не через интервал.
- Проход идёт по **всем действующим политикам** (`DesiredState = Active`, либо `DesiredState = Disabled` при `ActualState != "Disabled"`), а не по Link конкретного Node.
- Порядок блокировок сохранить существующий: node-локи (отсортированные) → per-Link gate. Никаких новых классов блокировок.
- Актор — `system:reconcile`.
- События: при фактическом восстановлении публиковать `link.reapplied`, при удалении правила без записи в БД — `link.orphan-removed`. Существующие `link.reconciling` / `link.active` / `link.disabled` / `link.failed` / `link.partial` сохранить.
- Не чаще одного полного прохода за интервал независимо от внешних триггеров.

**Обязательный сопутствующий рефакторинг.** Логика «прочитать текущее состояние → сравнить факт с desired → применить → проверить → сохранить» сейчас существует в трёх экземплярах: `CreateAsync`, `ReconcileLinksForNodeAsync`, `ConvergeDisabledCoreAsync`. Четвёртая копия в фоновом сервисе недопустима. Свести к одному методу вида

```csharp
private async Task<LinkPolicy> ConvergeAsync(
    LinkPolicy link, bool expectedConnected, string actor, CancellationToken ct)
```

и вызывать его из всех четырёх мест.

### B2-2. «Таблицы нет» ≠ «правил ноль»

Сейчас при отсутствии таблицы `link-status` завершается кодом 78, исключение ловится поштучно, и каждая политика по очереди получает `Failed`/`Partial` с текстом ошибки helper'а. Оператор видит N разных ошибок вместо одной причины.

1. `deploy/ochenstarik-smm-policy-apply`: отличать «таблица/цепочка недоступна» от «правило не найдено» — отдельный exit code (например 79) и стабильный маркер в stderr. Сохранить fail-closed поведение: неизвестная ошибка `nft` по-прежнему не должна читаться как «правил нет».
2. `LinkPolicyApplier`: типизированное `MeshFirewallUnavailableException` по этому коду.
3. Сервис реконсиляции: при таком исключении — **прервать проход**, один раз опубликовать `mesh.firewall-unavailable`, перевести затронутые политики в `Partial` с единым кодом ошибки, повторить с backoff. Никаких массовых попыток применения в несуществующую таблицу.
4. Desktop: один баннер «Mesh firewall не загружен» вместо N строк с ошибками.

### B2-3. Маркер внеочередной реконсиляции

1. `ochenstarik-smm-emergency firewall-restore` и `mesh-enable` создают `/var/lib/ochenstarik-server-monitor-manager/mesh/reconcile-requested` (root, `0600`).
2. Фоновый сервис на каждом тике проверяет маркер, при наличии выполняет внеочередной проход и удаляет маркер **только после успешного завершения**.
3. Контрактная проверка в `tests/bootstrap/test-bootstrap-contract.sh` — как для остальных инвариантов emergency-команды.

### B2-4. Тесты

- **Unit:** все правила стёрты (fake applier: `IsConnected == false` для всех), **ни одного heartbeat** → фоновый проход выполняет N connect и приводит `ActualState` к `Active`. Это тот самый тест, который ловит текущий пробел; без него блок не принимается.
- **Unit:** правило существует, `DesiredState = Disabled` → disconnect + `link.orphan-removed`.
- **Unit:** firewall unavailable → ноль мутаций, событие опубликовано один раз, затронутые в `Partial`.
- **Unit:** два прохода подряд без изменений → ноль вызовов helper во втором.
- **Integration** в стиле `LinkPolicyApplierIntegrationTests`: удалить маркер `connected`, выполнить проход, сверить точную последовательность вызовов helper.
- **Acceptance** (`tests/acceptance/three-server-mesh.sh`): новый шаг — на Hub выполнить `emergency firewall-restore` (или `nft delete table` + `systemctl start ochenstarik-smm-firewall`) **без перезапуска Node**, выждать интервал реконсиляции, затем `expect_reachable "$HOME_WG_IP"`, `expect_blocked "$SECOND_WG_IP"` и `expect_factual_status` для обоих.

### B2-5. Критерий приёмки блока

Правила nftables стёрты при полностью живых Node → связность по всем `DesiredState=Active` восстанавливается автоматически не позднее одного интервала реконсиляции; `Disabled` остаются заблокированными; правил без записи в БД не остаётся; расхождение видно в Desktop до момента устранения. Подтверждается unit-тестом **и** прогоном acceptance-скрипта на реальной тройке.

---

## 2. Блок B-3 — техдолг из ревью (в том же PR или следующем, до закрытия Block B)

| ID | Что | Где |
|---|---|---|
| M1 | Опубликовать `GetLinkAsync(string id, CancellationToken)` — приватный `ReadLinkAsync(id)` уже есть — и заменить им поиск по id полным перечислением. Сейчас O(N) чтений и десериализаций **под захваченной блокировкой**, что ударит по нагрузочному тесту Этапа 5. | `ControlStore.cs:1460`; `LinkService.cs:32`, `297`; цикл в `ReconcileLinksForNodeAsync` |
| M2 | `LinkReconciliationResult.Reconciled` теперь означает «рассмотрено», а не «приведено в порядок» (тест ожидает `(1, 0)` при нуле вызовов helper). Развести на `Examined` / `Converged` / `Failed`. | `LinkService.cs` |
| M4 | Фильтр на Links-странице снят, retention для таблицы `links` нет (чистятся только `metric_samples`, `idempotency`, `audit`, токены) — страница превращается в журнал. Нужен фильтр по умолчанию «действующие + расхождения» с переключателем либо retention для завершённых Disabled-политик. | `MainPage.xaml.cs`, `ControlMaintenance.cs` |
| M5 | Проба статуса для зарезервированного, но не активированного в mesh Node падает через `lookup_node_ip` и перебрасывает Link в `Failed` на каждом проходе. Отличать «Node ещё не активирован» от «helper сломан». | `ochenstarik-smm-policy-apply`, `LinkService` |
| L1 | `Split([(char)13, '\n'], …)` вернуть к `Split(['\r', '\n'], …)` — это то же самое, но нечитаемо; похоже на артефакт инструмента. | `LinkService.cs:339` |

M1 и L1 — дешёвые, делать сразу. M4 влияет на пригодность UI и требует решения о retention — вынести отдельным PR можно, но до объявления Block B закрытым.

---

## 3. Блок C — доверенная поставка (после закрытия B-2)

Полное описание — `smm-task-2-2026-07-31.md`, раздел 3. Кратко:

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

---

## 4. Порядок и Definition of Done

**Порядок:** B2-1 → B2-2 → B2-3 → B2-4 (в одном PR, это единая функция) → B-3 (M1, L1 в том же PR; M2, M4, M5 — отдельным) → physical acceptance → Блок C.

**DoD задачи 3:**

- [ ] Фоновая реконсиляция работает при старте и по интервалу, не завися от переподключения Node
- [ ] Unit-тест «правила стёрты, heartbeat не приходил → связность восстановлена» проходит
- [ ] Недоступный mesh firewall даёт одно событие и одно понятное состояние, а не N ошибок
- [ ] `emergency firewall-restore` приводит к внеочередной реконсиляции
- [ ] Логика сходимости существует в одном экземпляре, а не в четырёх
- [ ] `docs/linux-bootstrap.md:111` соответствует реализации
- [ ] M1, L1 исправлены; по M2, M4, M5 принято решение (сделать либо явно перенести с обоснованием)
- [ ] **Physical acceptance `three-server-mesh.sh` c `SMM_ACCEPT_RESTORE=1 SMM_ACCEPT_REBOOT=1` выполнен** — иначе B6 остаётся открытым
- [ ] Далее: Блок C по разделу 3

---

## 5. Процесс

Две вещи, которые стоит изменить в способе работы, — обе подтвердились на Block B.

**Независимому review отдавать текст задачи, а не только диф.** Оба review Block B были корректны по существу написанного кода и оба пропустили, что половина требований блока не реализована. Первый проверял «безопасно ли коммитить этот диф», второй — «закрыты ли две HIGH». Полноту относительно постановки не проверял никто, и блок ушёл в `main` с открытым основным критерием.

**Критерий выхода блока не равен merge.** Block B помечен выполненным на основании зелёного CI и APPROVE, тогда как его собственный критерий B6 сформулирован через фактическую связность и не проверялся. Пока physical acceptance заблокирован отсутствием topology inputs, блоки, чьи критерии формулируются через факт, следует помечать как `merged, acceptance pending`, а не как закрытые.
