# ТЗ: Block B-3 — реконсиляция от факта и техдолг Link-политик

Репозиторий: `ochenstarik-ui/server-monitor-manager`
База: `main` @ `b11c277ac7f79a18670932eca4622982d9ff48e0`
Предшествующие блоки: B (PR #11), B-2 (PR #12) — оба merged, physical acceptance pending.

Этот документ самодостаточен. Всё, что нужно исполнителю, здесь; предыдущие документы читать не обязательно.

---

## 1. Контекст

Control уже умеет приводить Link-политики к желаемому состоянию: есть `LinkReconciliationBackgroundService` (немедленный проход при старте + периодический по `Control__LinkReconciliationSeconds`), единый `ConvergeAsync`, типизированное состояние `mesh.firewall-unavailable` с backoff и generation-маркер внеочередной реконсиляции от `ochenstarik-smm-emergency`.

Проход устроен **от базы к факту**: берётся список действующих политик, и для каждой вызывается helper — `link-status`, затем при расхождении `link-connect`/`link-disconnect`, затем повторный `link-status`. Отсюда два системных ограничения, которые и закрывает этот блок.

---

## 2. Scope

### B3-1 — `link-list` в policy-helper (высокий приоритет)

Новое действие в `deploy/ochenstarik-smm-policy-apply`, возвращающее **все** правила управляемой цепочки одним вызовом.

- Арность: ровно 1 аргумент (`link-list`), проверяется до всего остального, как у `reconcile-status`.
- Источник: та же цепочка `inet ochenstarik_smm links`, через существующий `inspect_firewall` — то есть недоступная таблица по-прежнему даёт exit 79 и точный маркер `mesh.firewall-unavailable` в stderr, а неизвестная ошибка `nft` остаётся fail-closed с exit 78.
- Разбирать только правила с комментарием вида `smm:<source>:<target>:<proto>:<port>`; чужие правила в цепочке игнорировать и **никогда** не трогать.
- Формат вывода — стабильный, машиночитаемый, по одной записи в строке: `source<TAB>target<TAB>protocol<TAB>port`. Порядок не гарантируется, дубликаты допустимы (одинаковый comment может встретиться несколько раз — это тоже подлежит вычистке).
- Пустая цепочка — пустой вывод и exit 0. Это отличается от «таблицы нет» (exit 79).
- Значения из comment валидировать теми же паттернами, что и аргументы действий (`node_pattern`, `tcp|udp`, порт 1…65535). Строка, не прошедшая валидацию, не выводится, но фиксируется в stderr как диагностика — она означает, что кто-то подделал comment.
- Режим `SMM_POLICY_TESTING=1` поддержать по образцу существующих действий, чтобы контрактный тест работал без root и без nftables.

`sudoers` менять не нужно — шаблон `$POLICY_HELPER *` уже покрывает новое действие.

### B3-2 — развернуть проход реконсиляции на факт → база (высокий приоритет)

`LinkService.ReconcileAllAsync` переписать так, чтобы источником истины о факте был один вызов `link-list`, а не `2N` вызовов `link-status`.

Алгоритм прохода:

1. Один `link-list` → множество фактических правил.
2. Один `ListEffectiveLinksAsync` → множество действующих политик.
3. Сверка множеств по ключу `(source, target, protocol, port)`:
   - **правилу не соответствует ни одна политика с `DesiredState=Active`** (политики нет вовсе, либо она `Disabled`) → удалить правило, событие `link.orphan-removed`, запись в audit; если политика в БД есть — привести её `ActualState` к `Disabled`;
   - **политика `DesiredState=Active`, правила нет** → применить, проверить фактом, событие `link.reapplied`;
   - **совпало** → мутаций нет, `ActualState` при необходимости синхронизировать без вызова helper.
4. Дубликаты одного и того же правила схлопывать до одного при `Active` и удалять полностью при `Disabled`.

Требования:

- **Снять ограничение `IsEligibleForFullReconciliation`.** Сейчас политика в состоянии `Disabled/Disabled` из прохода исключена, поэтому accept-правило для полностью отключённой политики не обнаруживается никогда — а это инвариант kill switch. После разворота прохода фильтр по состоянию в БД больше не нужен: orphan находится по факту, независимо от того, что записано в базе.
- Порядок блокировок сохранить существующий: отсортированные node-локи → per-Link gate. Новых классов блокировок не вводить.
- Единый `ConvergeAsync` остаётся единственной реализацией сходимости; отдельной копии логики для нового прохода быть не должно.
- Поведение при `mesh.firewall-unavailable` не меняется: проход прерывается, публикуется одно агрегированное событие, работает bounded backoff, маркер не потребляется.
- Стоимость прохода без расхождений: **один** привилегированный вызов. Это проверяемое требование, см. тесты.

### B3-3 — ограниченный backoff для marker-пути (высокий приоритет)

В `LinkReconciliationBackgroundService` наличие маркера полностью снимает регулярный throttle (`_nextRegularAt`), а сам маркер потребляется только при `Failed == 0`. `_backoffUntil` взводится лишь для `FirewallUnavailable`. Следствие: одна стабильно падающая политика — например Node, выпавший из `nodes.tsv`, где `lookup_node_ip` даёт exit 78, — навсегда удерживает маркер, и полный проход начинает выполняться на каждом poll-тике (30 с) без ограничения.

Сделать:

- счётчик неуспешных prompt-проходов подряд; после 3 попыток маркер перестаёт снимать регулярный throttle и проходы возвращаются к обычному интервалу;
- одно предупреждение в журнал при переходе в это состояние, с идентификаторами упавших политик, без повторов на каждом тике;
- счётчик сбрасывается при первом проходе с `Failed == 0` (тогда же маркер потребляется штатно);
- маркер по-прежнему **не** потребляется, пока проход не завершился без отказов.

### B3-4 — M2: семантика результата реконсиляции (средний)

`LinkReconciliationResult.Reconciled` сейчас означает «рассмотрено», а не «приведено в порядок»: тест ожидает `(1, 0)` при нуле вызовов helper. Развести на три поля — `Examined`, `Converged`, `Failed` — и обновить всех потребителей, включая журнал фонового сервиса и `Program.cs`. `LinkFullReconciliationResult` привести к той же схеме.

### B3-5 — M4: Links-страница и retention (средний)

Фильтр на странице Links снят в Block B, retention для таблицы `links` нет (в `ControlMaintenance` чистятся только `metric_samples`, `idempotency`, `audit` и токены). Страница превращается в журнал всех политик за всё время.

Сделать **оба**:

- Desktop: фильтр по умолчанию «действующие + расхождения» с явным переключателем «показать историю»; счётчики в заголовке считать по отображаемому набору и подписывать однозначно.
- Control: retention для завершённых политик — `DesiredState=Disabled` и `ActualState=Disabled`, старше настраиваемого срока; новая настройка в `ControlOptions` с валидацией, значение в `appsettings.json` и в `control.env` bootstrap, по образцу `LinkReconciliationSeconds`. Действующие политики и политики с расхождением не удалять никогда.

### B3-6 — M5: не активированный в mesh Node (средний)

`lookup_node_ip` не различает «Node зарезервирован, но `peer-add` ещё не выполнен» и «helper сломан»: в обоих случаях exit 78, и политика уходит в `Failed` на каждом проходе. Ввести отдельный код возврата и типизированное состояние: политика к неактивированному Node не является отказом применения, это ожидаемое промежуточное состояние. В Desktop показывать его отдельной формулировкой, а не ошибкой.

### B3-7 — мелочи (низкий)

- Helper: `reconcile-status` возвращает `complete`, если каталог `/var/lib/ochenstarik-server-monitor-manager/mesh` отсутствует. Сейчас `exec 9>"$RECONCILE_LOCK"` в несуществующем каталоге падает под `set -e`, и на Hub, где `mesh-init` ещё не выполнялся, Control пишет предупреждение каждые 30 секунд.
- Helper: явная проверка `[[ -x /usr/sbin/nft ]]` с отдельным сообщением. Сейчас отсутствующий бинарь даёт ту же строку `No such file or directory`, что и отсутствующая таблица, и классифицируется как `mesh.firewall-unavailable`.
- `tests/acceptance/three-server-mesh.sh`: переформатировать блок `probe_factual_status` / `expect_factual_status` — перемешаны отступы 4 и 2 пробела, закрывающая скобка внесена внутрь тела. Поведение верное, `bash -n` проходит; вопрос читаемости.

---

## 3. Тесты

**Обязательные, без них блок не принимается:**

1. Accept-правило существует, соответствующая политика в состоянии `Disabled/Disabled` → проход удаляет правило и публикует `link.orphan-removed`. Это тот случай, который сегодня не обнаруживается вовсе.
2. Accept-правило существует, политики в БД нет ни в каком виде → правило удалено.
3. Проход без расхождений выполняет **ровно один** привилегированный вызов (`link-list`) и ноль мутаций. Проверять по счётчику вызовов фейкового applier либо по журналу вызовов в интеграционном тесте.
4. Правило с чужим comment в той же цепочке не трогается ни при каких условиях.
5. Дубликат правила с одинаковым comment: при `Active` остаётся один, при `Disabled` не остаётся ни одного.
6. Политика, падающая всегда, при висящем маркере не вызывает более одного прохода за интервал после исчерпания попыток; маркер при этом не потребляется.
7. `link-list` при отсутствующей таблице → exit 79, проход прерван, одно агрегированное событие, ноль мутаций, маркер сохранён.
8. Retention удаляет завершённую Disabled-политику старше срока и **не** удаляет действующую и политику с расхождением.

**Контрактные (`tests/bootstrap/test-bootstrap-contract.sh`):** арность `link-list`, формат вывода на фикстуре, игнорирование чужого comment, отказ на подделанном comment, `reconcile-status` при отсутствующем каталоге mesh.

**Acceptance (`tests/acceptance/three-server-mesh.sh`):** новый шаг под `SMM_ACCEPT_RESTORE=1` — вручную добавить на Hub accept-правило для отключённой политики (`link-connect` через helper напрямую), дождаться прохода, убедиться, что правило снято и `expect_blocked` для соответствующего Node выполняется. Шаг ставить рядом с существующим шагом firewall-restore.

---

## 4. Definition of Done

- [ ] Проход реконсиляции обнаруживает и снимает accept-правило, которому не соответствует действующая политика `Active`, **включая случай `Disabled/Disabled`**
- [ ] `IsEligibleForFullReconciliation` удалён, а не расширен
- [ ] Проход без расхождений — один привилегированный вызов; подтверждено тестом
- [ ] Чужие правила в цепочке не затрагиваются; подтверждено тестом
- [ ] Стабильно падающая политика не вызывает более одного прохода за интервал
- [ ] `Examined` / `Converged` / `Failed` разведены, потребители обновлены
- [ ] Links-страница читаема при сотнях исторических политик; retention настраивается и не трогает действующие политики
- [ ] Неактивированный в mesh Node не отображается как ошибка применения политики
- [ ] Логика сходимости существует в одном экземпляре
- [ ] Порядок блокировок не изменён, новых классов не введено
- [ ] B3-7 закрыты
- [ ] `docs/roadmap.md` обновлён: B-3 отмечен, M2/M4/M5 сняты из отложенных
- [ ] CI зелёный на PR

---

## 5. Вне scope

- Блок C (подписанный manifest, совместимость версий, пиннинг Actions) — отдельная задача после B-3.
- Провиженинг, роль Monitor в bootstrap, MVVM-рефакторинг Desktop, локализация — задачи следующего уровня.
- Physical acceptance — внешний блокер, см. раздел 7.

---

## 6. Формат сдачи

Как в предыдущих блоках:

- `REPORT.md` — статус, база, коммиты, PR, ссылки, что реализовано, что отложено и почему;
- `TEST_EVIDENCE.md` — фактические результаты прогонов и честный список невыполненного;
- патч и/или zip изменённых файлов;
- `CI_CHECKS.txt` — имена, длительности и URL всех проверок;
- `INDEPENDENT_REVIEW.md`;
- `SHA256SUMS` — считать **последним действием**, после всех правок отчётов, и включать все файлы пакета.

Независимому review передавать **полный текст этого ТЗ**, а не только диф. На двух предыдущих блоках это дало находки того класса, который при diff-only ревью не ищется вовсе: пропущенные требования, а не ошибки в написанном коде.

Статус блока по итогам merge формулировать как `merged / verified`, а не «выполнено», если хоть один критерий выражен через фактическую связность и не проверялся на реальной топологии.

---

## 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` без перезапуска Node. До прогона пункт «выполнить физический acceptance» Этапа 3 roadmap и критерий B6 остаются открытыми независимо от состояния CI. Исполнитель закрыть этот пункт не может.
