# Закрытый контур: офлайн-лицензия и аттестация поставки

Документ описывает две возможности издания enterprise, которые появились в
ПРОМТ-И8: `offline-license` (операция `license_offline`) и `attestation`
(операция `attest_bundle`). Обе объявлены данными слоя изданий
(`src/ragraf/edition_baseline.json`); до этапа И8 `edition` честно называл их в
разделе `missing` как «объявлено, но в сборке нет операций».

## 1. Зачем это нужно

| Возможность | Какую задачу закрывает | Кто её видит |
|---|---|---|
| офлайн-лицензия | выдать и ПРИНЯТЬ лицензию там, где сети нет: закрытый контур, изолированный стенд | владелец установки и поставщик лицензии |
| аттестация поставки | предъявить внешнему аудитору подписанный набор доказательств и дать ему самому пересчитать хэши | аудитор, служба безопасности |

Обе возможности бесполезны по отдельности. Лицензия без аттестации — это
«поверьте, что мы собрали то, что обещали». Аттестация без лицензии не решает,
что этой сборке разрешено.

## 2. Офлайн-лицензия

### 2.1 Три шага и одна страховка

```
шаг 1  request   установка пишет заявку: отпечаток + что просит
шаг 2  issue     поставщик подписывает лицензию для ЭТОЙ заявки
шаг 3  accept    установка принимает: подпись, отпечаток, срок
       transfer  отдельная заявка на перенос (не действующая лицензия)
```

Сети не требует ни один шаг: модуль `src/ragraf/license_offline.py` не делает
внешних вызовов вовсе.

### 2.2 Формат — тот же, что у обычной лицензии

Файл лицензии читается `edition.read_license`, то есть тем же кодом, что и любая
другая лицензия продукта: подпись Ed25519 (`src/ragraf/signing.py`), затем
закрытый список изданий, затем срок. Второй формат означал бы вторую правду о
правах, поэтому его нет.

Поля файла: `schema`, `kind`, `product`, `version`, `edition`, `licensee`,
`issued_at`, `expires_at`, `install`, `request_id` (плюс `transfer` у переноса).

### 2.3 Привязка к установке

`install` — отпечаток: продукт, версия, имя хоста, вид машины
(`система/архитектура`) и ХЭШ корневого каталога установки. Каталог входит
хэшем, а не путём: файл лицензии уезжает на другую машину и не должен нести
машино-локальный путь.

Границы отпечатка названы прямо:

* это не аппаратный идентификатор: перенос диска на такую же машину с тем же
  путём отпечаток сохранит;
* переименование каталога установки меняет отпечаток — для продукта это другая
  установка;
* имя хоста входит в отпечаток, поэтому переименование машины тоже требует
  перевыпуска.

### 2.4 Отказ всегда назван

| Состояние | Что означает | Что видит вызывающий |
|---|---|---|
| `absent` | файла лицензии нет | путь, которого нет |
| `valid` | подпись, авторство, срок в порядке | издание и лицензиат |
| `forged` | содержимое правили после подписи или подпись чужим ключом | что именно не сошлось |
| `expired` | срок истёк | дата истечения |
| `untrusted` | целостность есть, доверенного ключа нет | «авторство не подтверждено» |
| `unverifiable` | нет PyNaCl, проверить подпись нечем | причина, а не «лицензия верна» |
| `broken` | подписанный файл не разбирается или издание вне списка | имя поля или имя издания |
| `unbound` | в лицензии нет отпечатка установки | «привязки нет» |
| `foreign-install` | лицензия для другой установки | ожидаемый И полученный отпечаток |
| `foreign-request` | лицензия выдана по другой заявке | оба `request_id` |
| `request-broken` | заявка изменена после создания | что не совпало |

Выпуск без ключа подписи — отказ `SigningUnavailable` с путём, по которому ключа
нет. Файл лицензии при отказе НЕ создаётся: «как будто выпущено» здесь дороже
отказа, потому что установка получила бы файл, который продукт отвергнет.

### 2.5 Перенос — отдельный явный шаг

`transfer` пишет заявку, в которой назван прежний отпечаток (`from_digest`,
`from_host`) и содержится предупреждение: заявка подписывается заново, а прежняя
лицензия на новой установке не действует. Одна заявка прав не даёт — это
проверено тестом: `accept` на новой машине остаётся `foreign-install`.

### 2.6 Заявка защищена от правки

`request_id` — sha256 канонической записи заявки. При выпуске он
пересчитывается: изменённая заявка отвергается с названным расхождением, а не
подписывается молча.

### 2.7 Ловушка гейта и что с ней сделано

`license_offline` — возможность enterprise, поэтому на Community гейт отказывает
операции. Но заявка нужна ДО лицензии: установка без лицензии не смогла бы даже
попросить её. Измерено на первом прогоне.

Решение: заявка и приёмка доступны ещё и отдельным входом
`python tools/offline_license.py` (`--mode request|issue|accept|transfer`). Он не
проходит через фасад и поэтому не гейтован — но и прав не даёт: приёмка только
проверяет подпись и отпечаток, а издание даёт подписанный файл, который читает
`edition.read_license`. Обходом гейта это не является: платное по-прежнему
включает лицензия, а не команда.

## 3. Аттестация поставки

### 3.1 Состав набора

```
<out>/attest.json                    манифест: документы, размеры, sha256,
                                     пределы, полнота, подпись
<out>/documents/version.json         версия продукта, Python, платформа
<out>/documents/index.json           ревизия корпуса и её сумма
<out>/documents/supply.json          хэши поставки: сводка и агрегат
<out>/documents/supply-sha256.txt    хэши поставки: построчный список
<out>/documents/release-verify.json  отчёт проверяльщика «правило → наблюдений»
<out>/documents/editions.json        карта изданий: что публикуется, что удержано
<out>/documents/sbom.json            SBOM CycloneDX (если запрошен)
<out>/documents/gate*                отчёт гейта порч (если передан)
```

Модуль: `src/ragraf/attest.py`. Сборка — `collect()`, проверка — `verify()`.

### 3.2 Почему проверка не может проверять сама себя

Манифест утверждает, каким файл должен быть. Проверка читает ФАЙЛ и сравнивает
его sha256 с подписанным значением. Подмена байта в любом документе набора
ловится по имени файла — это проверено по каждому документу по очереди, а не на
одном примере. Подделка самого манифеста ловится подписью: она считается по
канонической записи содержимого, и любое поле входит в неё.

Перечень документов закрыт: файл, которого нет в манифесте, — нарушение, а не
дополнение.

### 3.3 Чего аттестация НЕ доказывает

Раздел `limits` лежит в самом наборе: аудитор читает его вместе с хэшами.

* подтверждается совпадение байтов и подпись, но не качество продукта;
* отсутствие дефектов и уязвимостей не утверждается: это не отчёт об испытаниях;
* хэши действительны на момент сбора; правки дерева после сбора в набор не
  попадают;
* в наборе нет значений секретов, содержимого индексов и персональных данных —
  модуль их не читает;
* подпись говорит, что набор собран владельцем ключа, и ничего не говорит о
  среде сборки;
* набор без подписи остаётся читаемым, но проверенным не считается
  (`signed=false`, состояние `unsigned`).

### 3.4 Полнота набора

`complete: false` и список `missing` называют, чего не хватило: `supply`,
`release-verify`, `editions`, `sbom`, `gate`. Аттестация на неполной поставке не
должна выглядеть полной — тот же принцип, что у офлайн-набора
(`tools/release/airgap.py`).

Ключ подписи остаётся вне поставки (`catalog/audit-signing.key` или
`RAGRAF_SIGNING_KEY`): подписывать аттестацию ключом, который лежит в ней же,
значило бы снять смысл подписи.

### 3.5 Машинный отчёт проверяльщика

`tools/release/verify_release.py --report <файл>` пишет JSON: применённые
правила, по каждому — число наблюдений, число нарушений и примеры, итог и
пределы. Отчёт аддитивен: правила `presence`, `hash`, `coverage`, `excluded`,
`syntax`, `secrets`, `encoding`, `sources`, `paths` не менялись, а сам отчёт
нужен аттестации — без него набор доказательств был бы текстом для человека.

## 4. Где что

| Что | Файл |
|---|---|
| офлайн-лицензия | `src/ragraf/license_offline.py` |
| аттестация | `src/ragraf/attest.py` |
| вход без гейта | `tools/offline_license.py` |
| операции фасада | `src/ragraf/facade.py` — `op_license_offline`, `op_attest_bundle` |
| инструменты MCP | `src/ragraf/mcp_server.py` — `ragraf_license_offline`, `ragraf_attest_bundle` |
| отчёт проверяльщика | `tools/release/verify_release.py --report` |
| проверки | `tests/test_offline_attest.py` (78 свойств), 29-я проверка чекера |

## 5. Порядок отката

1. `.ragraf/backup-i8/orig-<метка>/` — копии всех правимых файлов ДО правки;
   `.ragraf/backup-i8/new-<метка>/` — копии после.
2. Удалить `src/ragraf/license_offline.py`, `src/ragraf/attest.py`,
   `tools/offline_license.py`, `tests/test_offline_attest.py`, этот документ.
3. Снять `op_license_offline` и `op_attest_bundle` из `src/ragraf/facade.py`:
   возможности сами вернутся в `missing` — это решает `edition.decide()`, а не
   правка данных.
4. Снять инструменты `ragraf_license_offline` и `ragraf_attest_bundle` из
   `src/ragraf/mcp_server.py` (три места: `TOOLS`, `_TOOL_META`, обработчики) и
   операции из `src/ragraf/cli.py` (`OPS`, `MODE_CHOICES`,
   `MODE_DEFAULT_BY_OP`, ветви диспетчера, аргументы).
5. Снять `'Test-OfflineAttestation'` из `$script:CheckFunctions` в
   `tools/Invoke-ProjectDoctrine.ps1` и саму функцию.
6. Вернуть числа в документах: 46 → 44 операции, 56 → 54 инструмента, 29 → 28
   проверок (`Test-DocumentedNumbers` покажет расхождение).
7. `python tools/ragraf.py -p config/ragraf.yaml reindex`.

Схема существующих хранилищ и нумерация ревизий не затрагивались: операция
аттестации пишет только в указанный каталог, лицензия — только файл лицензии и
свою подпись.
