# Протокол проверяемых розыгрышей, версия 1

Статус: v1, ревизия 10 от 18.10.2026. Ревизия 10 уточняет текст по отчёту независимой утилиты на Python (Ф3), новой
логики проверки в ней нет: форма записей `sigs` (§1.4, вектор `sigs_entries_of_any_form_are_skipped`), копии
сервиса без истории (§7.5), входы проверки квитанции (§9), строгий DER и строка подписи заметки (§10.2), отзыв
выведенного ключа (§14), чтение файлов по сети и документ правил (§18), поиск последнего раунда и канал (§17.2),
следствия §17.4; после второго прогона утилиты дописаны путь включения квитанции, поиск последнего раунда L и копии
чекпоинта для тела записи Rekor. Ревизия 9 добавила §17.4: как находки раскладываются на четыре строки страницы и утилиты; статусы
и коды не менялись. Ревизия 8 (после шестого и седьмого проходов красной команды и мини-прогонов по ревизиям 6 и 7)
закрыла атаки 170-174: ключ не из истории судится по набору, который первым ввёл его в журнал; в передаче набора
записано явное неравенство; сказано, чего старая сборка не берёт из копий; история ищется по паре `key_id` и роль;
повтор ключа в наборе закреплён вектором. Она же внесла формулировки мини-прогона r6: оговорка `evidence_from_copies`
по точному списку (в том числе чекпоинт, которого нет в файлах, и запись Rekor, которой нет среди засчитанных отметок
файлов), отдельный чекпоинт копии только ключом, который действует для его размера. Ревизия 7 (после шестого прохода)
закрыла атаку 160: проверяется каждая запись каждого набора ключей, повтор `key_id` или открытого ключа в наборе
запрещён формой.
Документ открыт (Apache 2.0, `LICENSE-OPEN`). По нему пишется любая независимая проверка розыгрыша: страница проверки,
утилита на Python, программа аудитора. Эталонная реализация на TypeScript лежит в `packages/protocol`, тестовые векторы
в `packages/protocol/vectors/`, схемы условий в `docs/schemas/`. Если текст и код расходятся, ошибка в одном из них,
и решается она правкой документа и новыми векторами, а не подгонкой.

Слова "должен", "нельзя", "можно" нормативные. Имена полей, типов и кодов проверок на английском, как в файлах.
Все времена в формулах в миллисекундах, если не сказано иное. Где правило зависит от времени, неравенство записано
явно; слова "до" и "после" без неравенства ненормативны.

Оглавление: 1 общие правила, 2 сеть случайности, 3 условия, 4 секретное число, 5 псевдоним участника, 6 листья
журнала розыгрыша, 7 журналы, 8 чекпоинт, 9 квитанция, 10 внешняя привязка, 11 номер раунда, 12 формула draw-v1,
13 итог, 14 ключи сервиса, 15 сборки страницы проверки, 16 отчёт о сходимости, 17 проверка розыгрыша,
18 файлы розыгрыша, 19 версии, 20 тестовые векторы, 21 чего проверка не ловит.

---

## 1. Общие правила

### 1.1. Канонический JSON и разбор файлов

Каждый объект протокола хэшируется и подписывается в канонической форме RFC 8785 (JCS), закодированной в UTF-8:

- ключи объекта сортируются по кодовым единицам UTF-16 (не по байтам UTF-8);
- строки сериализуются как `JSON.stringify` в ECMAScript: экранируются `"` и `\`, управляющие символы U+0000..U+001F
  пишутся как `\b \t \n \f \r` или `\u00xx` строчными; всё остальное, включая кириллицу, идёт как есть в UTF-8;
- пробелов между лексемами нет.

Файл протокола это байты UTF-8. Сначала байты декодируются строго: невалидная последовательность UTF-8 это ошибка
(а не замена на U+FFFD), BOM в начале файла это ошибка (а не молча срезанный префикс). В браузере так декодирует
`new TextDecoder('utf-8', {fatal: true, ignoreBOM: true})` с проверкой, что первый символ не U+FEFF; декодер
по умолчанию принимает и то, и другое, и страница разошлась бы с утилитой.

Затем текст разбирается строгим парсером. Он отвергает:

| Что | Почему |
|---|---|
| число, записанное не как `0` или `[1-9][0-9]*` (знак, точка, экспонента, ведущий ноль), и число больше 2^53 − 1 | `1.0` в одном языке целое, в другом дробное; правило на уровне текста убирает расхождение |
| повторный ключ в объекте; ключи сравниваются после раскрытия экранирования (`"a"` и `"a"` это один ключ) | разные парсеры берут разное значение |
| одиночный суррогат, сырой или через `\u`, и несимволы Unicode: U+FDD0..U+FDEF и любая кодовая точка, оканчивающаяся на FFFE или FFFF | вход обязан быть I-JSON (RFC 7493 §2.1) |
| что угодно после значения, кроме пробельных символов JSON (пробел, `\t`, `\n`, `\r`) | хвостовые пробелы и переводы строк по RFC 8259 часть текста, остальное нет |

Ключ `__proto__` это обычный ключ. В объектах протокола нет дробных и отрицательных чисел вовсе; дробные числа есть
только в отчёте о сходимости (§16), он не хэшируется. Какой код получает файл, который не прошёл декодер или парсер,
сказано в §17.2, шаг 0.

Отпечаток объекта `hash(x) = SHA-256(UTF-8(JCS(x)))`, 64 строчных шестнадцатеричных символа.

### 1.2. Кодировки

| Что | Формат |
|---|---|
| Байты в JSON | шестнадцатеричная строка, только строчные `0-9a-f`, чётная длина. Заглавные буквы это ошибка формата |
| Время | строка `YYYY-MM-DDTHH:MM:SS.mmmZ`, UTC, ровно три знака миллисекунд, год от 2000 до 9999. После разбора время обязано печататься обратно той же строкой, поэтому 31 февраля и секунда 60 это ошибка |
| Идентификаторы (`draw`, `service`, `component`) | `^[a-z0-9][a-z0-9-]{0,62}$` |
| `ref` события | печатный ASCII `^[\x21-\x7e]{1,128}$`; без персональных данных (§5) |
| Длина строки в схемах | в кодовых точках Unicode, как в JSON Schema |
| Отсутствующее значение | `null`; поле при этом присутствует |
| Base64 | только в полях Rekor (§10) и в векторах (документ правил, байты файлов): стандартный алфавит, `=` обязательны, без пробелов и переводов строк, неиспользуемые биты последнего символа нулевые (повторное кодирование даёт ту же строку) |

Шаблоны этого раздела и схем условий применяются ко всей строке и только к символам ASCII. В Python это
`re.fullmatch(шаблон, строка, re.ASCII)`: с `re.match` и `$` строка `"abc\n"` прошла бы шаблон идентификатора,
а `\d` совпал бы с цифрами других письменностей. В ECMAScript `$` без флага `m` и так стоит только в конце строки.

Сравнения времени: "раньше X" значит строго меньше, "не позже X" значит меньше или равно. `integratedTime` Rekor
дан в целых секундах `s`, поэтому момент отметки известен только с точностью до секунды: он лежит в
[`s·1000`, `s·1000 + 999`]. Каждое утверждение об отметке проверяется худшим для него концом:

| Утверждение | Верно, если |
|---|---|
| отмечено раньше X | `s·1000 + 999 < X` |
| отмечено не позже X | `s·1000 + 999 <= X` |
| отмечено позже X | `s·1000 > X` |

Временем отметки для показа и для "сейчас" (§17.1) служит `s·1000`. Для T_R (целая секунда) первые два правила
совпадают с простым сравнением `s·1000 < X`.

### 1.3. Тегированный хэш

```
tagged(tag, p1, p2, ...) = SHA-256(UTF-8(JCS([tag, p1, p2, ...])))
```

Все части строки. Массив JCS снимает двусмысленность склейки: `("vkl", "ive1")` и `("vk", "live1")` дают разные хэши.

### 1.4. Подписи

Подписи Ed25519 по RFC 8032. Подписанный объект несёт поле `sigs`:

```
"sigs": [ { "alg": "ed25519", "key_id": "<hex 32 байт>", "sig": "<hex 64 байт>" } ]
```

Подписываются байты `JCS(объект без поля sigs)`. `key_id = SHA-256(открытый ключ, 32 байта)`, проверяющий вычисляет
его сам. Объект подписан ключом K, если в `sigs` есть хотя бы одна запись с `alg = "ed25519"`, `key_id` ключа K
и верной подписью. Записей с одним `key_id` может быть несколько, и битая запись не отменяет верную. Записи с другими
`alg` пропускаются: это место для второй подписи по ГОСТ.

Форма `sigs` проверяется только как массив. Запись засчитывается, если это объект с `alg = "ed25519"`, строками
`key_id` и `sig` и верной подписью; лишние поля такой записи не мешают. Всё остальное пропускается и находкой
не становится: записи с другим `alg` и любыми полями, значения, которые не объекты, битые записи. Иначе будущая
вторая подпись со своим набором полей ломала бы форму чекпоинтов в старых сборках проверки (вектор
`sigs_entries_of_any_form_are_skipped`).

Подпись `R || S` верна, только если выполнено всё сразу:

1. `R` и `A` закодированы канонически: y меньше p, а при x = 0 знаковый бит равен нулю; `S < L`;
2. открытый ключ `A` не малого порядка;
3. точка `R` не малого порядка (восемь точек подгруппы кручения, единица среди них);
4. кофакторное уравнение `[8][S]B = [8]R + [8][k]A`;
5. бескофакторное уравнение: кодировка точки `[S]B − [k]A` побайтно равна `R`.

`k = SHA-512(dom || R || A || M') mod L`, где для Ed25519 `dom` пустой, `M' = M`; для Ed25519ph
`dom = "SigEd25519 no Ed25519 collisions" || 0x01 || 0x00`, `M' = SHA-512(M)`. RFC 8032 разрешает проверять любым
из двух уравнений, но владелец ключа (а это сервис) может собрать подпись с R смешанного порядка, которую одно
уравнение примет, а другое отвергнет; страница и утилита тогда разошлись бы. Подпись с R малого порядка (например,
R равна единице и S = k·a) проходит оба уравнения, её собирает только владелец ключа; libsodium такую подпись
отвергает, протокол тоже. Честная подпись проходит все пять проверок. Векторы: `04-checkpoint.json`,
`mixed_order_R_crafted_by_key_owner`, `R_identity_crafted_by_key_owner`, `S_plus_L`, `R_not_canonical_y_ge_p`,
`two_entries_same_key_first_broken`.

Каждый подписанный объект содержит `type` из закрытого списка (`checkpoint`, `receipt`, `result`), у квитанций
и чекпоинтов разные ключи (§14). K_checkpoint подписывает и якорь в Rekor в режиме Ed25519ph (§10), RFC 8032 §8.6
это разрешает. Любое исключение при проверке это отказ проверки, а не ошибка программы.

---

## 2. Сеть случайности: drand quicknet

Параметры сети зашиты в проверяющего; условия обязаны повторить их в точности (это часть схемы условий, код
`conditions_schema`).

| Параметр | Значение |
|---|---|
| `chain_hash` | `52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971` |
| `public_key` (G2, 96 байт) | `83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a` |
| `scheme` | `bls-unchained-g1-rfc9380` |
| `period` | 3 (секунды) |
| `genesis` | 1692803367 (секунды Unix) |
| DST | `BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_` |

Подпись раунда R (48 байт, G1) проверяется так: сообщение `m = SHA-256(uint64_be(R))`, точка `H = hash_to_G1(m, DST)`
по RFC 9380 (набор `BLS12381G1_XMD:SHA-256_SSWU_RO_`, строка DST из draft-irtf-cfrg-bls-signature), проверка
`e(sig, g2) = e(H, public_key)`. Подпись и ключ обязаны декодироваться в точки на кривой, в нужной подгруппе и не
в бесконечности. Испорченная подпись в некоторых библиотеках вызывает исключение (@noble/curves 2.4.0: "Cannot find
square root", py_ecc: "not on G1"): исключение это отказ.

`randomness = SHA-256(signature)` показывается людям, формула берёт подпись. Читать раунды по номеру, путём v1
`/<chain_hash>/public/<round>`, с нескольких зеркал: `api.drand.sh`, `api2.drand.sh`, `api3.drand.sh`,
`drand.cloudflare.com` (на Cloudflare пути v2 нет). Подпись самопроверяемая: годится раунд из любого источника,
прошедший проверку.

---

## 3. Условия (`conditions`)

Условия описывают розыгрыш целиком и фиксируются до начала приёма: `conditions_hash = hash(conditions)` стоит в листе
`commit` общего журнала (§7.3). Схемы JSON Schema 2020-12 по видам лежат в `docs/schemas/conditions.<kind>.schema.json`,
они нормативны вместе с этим текстом; везде `additionalProperties: false`, поле другого вида в условиях не существует.
Схема выбирается по полю `kind`; вид не из списка это `conditions_schema`.

### 3.1. Виды

| `kind` | `surface` | Что это |
|---|---|---|
| `free_draw` | `stream` | бесплатный розыгрыш среди зрителей: `participation: "free"`, `prize_provider: "streamer"` |
| `bookmaker_promo` | `operator` | акция букмекера; поля ст. 9 38-ФЗ: `rules_url`, `rules_sha256`, `prize`, `prize_delivery`, `prize_provider: "organizer"`, `participation_condition` (`free`, `qualifying_bet`, `coupon`) |
| `brand_promo` | `operator` или `brand` | розыгрыш бренда, те же поля, `participation_condition` (`free`, `purchase`) |
| `donation_wheel` | `stream` | колесо по донату, черновик для Ф7, в v1 не проверяется (§12.6) |
| `points_prediction` | | резерв, ни один экземпляр не проходит схему |
| `bookmaker_minigame` | | выключен, ни один экземпляр не проходит схему |

### 3.2. Поля розыгрыша (`free_draw`, `bookmaker_promo`, `brand_promo`)

| Поле | Что значит |
|---|---|
| `v`, `type` | `1`, `"conditions"` |
| `service`, `draw`, `version` | сервис, идентификатор розыгрыша, номер версии условий (с 1, §3.4) |
| `organizer`, `title` | кто проводит, название; строки 1..200 кодовых точек |
| `rules_ack` | идентификаторы принятых организатором правил, 1..20 строк по 1..64 кодовые точки |
| `network` | пять параметров §2, ровно как зашито |
| `open_at`, `close_at` | начало приёма и плановое закрытие |
| `margin_s` | запас до раунда, от 30 до 86400 с; по умолчанию 60 на стриме, 300 у букмекера |
| `round`, `round_time` | R = round(close_at + margin_s·1000) + 1 и time(R), §11 |
| `commitment` | обязательство секретного числа, §4 |
| `reveal_deadline` | срок раскрытия, от time(R) + 30 с до time(R) + 24 ч; на стримах по умолчанию time(R) + 120 с |
| `salt_commit` | обязательство соли, §5 |
| `min_participants` | 1..1 000 000; если записей N меньше, розыгрыш не состоялся |
| `min_duration_s` | приём нельзя закрыть раньше `open_at + min_duration_s` |
| `chances_max` | потолок шансов одной записи, 1..1 000 000 |
| `formula` | `{"name":"draw-v1","population":"entries","winners":k,"alternates":m,"one_prize_per_subject":true\|false}`, k 1..1000, m 0..1000 |
| `prize` | `{"text": 1..500 кодовых точек, "count": k}`: число призов равно числу победителей |
| `rules_url`, `rules_sha256` (акции) | адрес правил `https://...` и SHA-256 байтов документа правил в момент фиксации |
| `prize_delivery` (акции) | как и где получить приз, 1..1000 кодовых точек |

Проверяющий сверх схемы проверяет: время в каждом поле разбирается по §1.2; `open_at < close_at` и
`open_at + min_duration_s·1000 <= close_at`; `round` равен
формуле §11; `round_time = time(round)`; `time(R) + 30 000 <= reveal_deadline <= time(R) + 86 400 000`;
`prize.count = formula.winners`; у акций SHA-256 опубликованного документа правил равен `rules_sha256`, а без документа
акция не получает "совпало" (§17.2, шаг 7).

Ник, сумма доната, текст сообщения и любые персональные данные в условия и журналы не попадают.

### 3.3. Поля колеса (`donation_wheel`, черновик)

`open_at`, `close_at` это границы сессии, `margin_s` от 1 до 600 с (6 по умолчанию). `sectors: [{"label", "weight"}]`
от 2 до 64, веса целые 1..1 000 000; `weights_fixed_at_start: true`, `spins_per_donation: 1`,
`launch_threshold_rub` целое или `null`; `formula = {"name":"draw-v1","population":"sectors"}`. Полей `round`,
`round_time`, `commitment`, `reveal_deadline`, `salt_commit`, `min_participants`, `min_duration_s`, `chances_max`,
`prize`, `prize_provider`, `participation` у колеса нет; полей "вес от суммы", "повороты от суммы", "приз донатеру"
нет ни у какого вида.

### 3.4. Версии условий

Пока приём не открыт, условия можно исправить: это новая версия и новый лист `commit`. Действует только последняя
версия. Правила:

1. Лист `commit` версии 1 несёт `prev_commit: null`; лист версии v > 1 несёт в `prev_commit` индекс листа `commit`
   версии v − 1 в общем журнале.
2. Отрезок общего журнала (§7.5) начинается с листа `commit` версии 1. Листья `commit` этого розыгрыша в отрезке
   идут версиями 1, 2, ... по порядку, каждая называет предыдущую. Последняя из них это показанные условия.
3. Между листом версии 1 и листом последней версии нет ни одного другого листа этого розыгрыша (`close`, `reveal`,
   `outcome`, `failure`): новая версия после начала приёма прежней это перезапуск под тем же именем.
4. Лист последней версии отмечен в Rekor раньше её `open_at` (§10.3).
5. Файлы несут условия всех прежних версий (`earlier_conditions.json`, §18), каждая с отпечатком своего листа `commit`.
   Лист версии v + 1 отмечен раньше `open_at` версии v (правило секунды §1.2): новая версия появилась, пока приём
   прежней ещё не мог открыться.

Нарушение п. 1-3 и 5 это `mismatch:commit`. Если фиксация версии не успела получить отметку вовремя, следующая версия
этого не исправит (её лист тоже обязан быть отмечен раньше `open_at` прежней): выход только новый розыгрыш с новым
идентификатором. Что идентификатор розыгрыша не встречается в журнале второй раз с новой
версией 1, видно только по полному журналу: это проверяет монитор (§7.4).

---

## 4. Секретное число: обязательство и раскрытие

Сервис генерирует `secret`, 32 случайных байта. В условия до старта идёт только обязательство:

```
commitment = tagged("notarius/commit/v1", draw, secret_hex)
```

После time(R) сервис раскрывает секрет листом `reveal` общего журнала и сразу отмечает его в Rekor (§10.1).
Раскрывает отдельный процесс по расписанию, без человека: как только получен раунд R с проверенной подписью, но
не позже T_R + 15 с по сверенным часам (§7.3), даже если раунда нет: раунд нужен для итога, а не для раскрытия.
Часы, ушедшие вперёд от drand, ловит сторож часов (§7.3): пока он не в порядке, ни один лист, и `reveal` тоже,
не пишется. Если drand встанет, розыгрыш кончится срывом `round_timeout`, а не `not_revealed`.
Раньше T_R по сверенным часам демон не раскрывает никогда: отметка раскрытия в секунду до T_R это `reveal_early`. Раскрывается только розыгрыш с листом `close`, не остановленный
по правилам (§17.2, шаг 10): у отменённого и незакрытого розыгрыша секрет не раскрывается, лист `reveal` без `close`
это `mismatch:leaf_order`.

Раскрытие вовремя, если выполнено всё сразу:

- `time(R) <= reveal.t <= reveal_deadline`;
- лист отмечен в Rekor не позже `reveal_deadline + 10 мин` (допуск `REVEAL_TOLERANCE`, правило секунды §1.2).

Раскрытие раньше time(R) (по `reveal.t` или по отметке, поставленной раньше T_R) это `mismatch:reveal_early`: оно
снимает единственную пользу секрета. Раскрытие позже срока
или без отметки к `reveal_deadline + 10 мин` значит, что розыгрыш не состоялся (`not_held:not_revealed`). Пока допуск
не вышел, раскрытие без отметки в состоянии ожидания (`pending:reveal_not_anchored`). Допуск покрывает только задержку
Rekor: `t` листьев общего журнала не убывает (§7.3), поэтому лист задним числом за уже записанными не встанет.

Лист `outcome` сервис пишет только после засчитанной отметки раскрытия. `failure:not_revealed` сервис пишет не раньше
`reveal_deadline + 10 мин + 60 с` и только если Rekor не знает отметки чекпоинта, покрывающего `reveal`, со временем
не позже `reveal_deadline + 10 мин`: ответ Rekor может прийти позже срока, а время в нём быть в срок, и тогда раскрытие
вовремя. Сервис ищет такую отметку сам (ответ на запрос или поиск записи по SHA-512 байтов чекпоинта), а `outcome`
в этом случае не пишет. Повтор возможен только новым розыгрышем с новыми условиями.
Каждый срыв сервис записывает листом `failure` и постом в канал; счётчики срывов выводятся из общего журнала
и публичны.

Зачем секрет при открытом раунде drand: он защищает на случай, если большинство узлов drand узнает раунд заранее.
Исход зависит и от секрета, и от раунда: повлиять на него можно, только если против участников одновременно действуют
сервис и большинство узлов drand. Секрет же даёт сервису возможность не раскрыть его; это вето видно всем как срыв
и считается в счётчиках.

---

## 5. Псевдоним участника (`subject`) и соль

```
subject = tagged("notarius/subject/v1", salt_hex, platform, user_ref)
salt_commit = tagged("notarius/salt/v1", draw, salt_hex)
```

`salt` это 32 случайных байта на розыгрыш, `platform` короткое имя площадки (например `vklive`), `user_ref` строковый
идентификатор пользователя у площадки. Соль не публикуется никогда, поэтому по списку нельзя перебрать ники или id
словарём. На стримах соль не покидает подписывающий сервис (signer); для акций букмекера она выдаётся площадке
по API, чтобы площадка сверила список с тем, что отправляла, и проверила соль по `salt_commit`.

Ограничение: соль у сервиса, значит, сервис технически может сопоставить записи с аккаунтами. Наружу соль не выходит.

Внутренний постоянный псевдоним `HMAC-SHA256(k_service, platform ":" id)` нужен для раздела "Мои розыгрыши" и метрики
возвратов. Он никогда не публикуется, не попадает в файлы розыгрыша и публично не проверяется.

`ref` в листах это идентификатор события у площадки (пари, купон, донат). Площадка обязана не класть в `ref`
идентификатор пользователя и другие персональные данные.

---

## 6. Листья журнала розыгрыша

### 6.1. Запись участника (`entry`)

```
{ "v": 1, "type": "entry", "draw": "<id>", "seq": <номер с 0>, "t": "<время приёма>",
  "subject": "<hex 32>", "chances": <от 1>, "ref": <ref или null>,
  "entry_commit": "<hex 32>", "claim_commit": "<hex 32>" }
```

Ровно эти поля. `t` это время приёма записи по часам сервиса (`received_at`); сервис выдаёт `t = max(t предыдущей
записи, сейчас)`, поэтому `t` не убывает по `seq`. `ref` у зрителя стрима `null`.

`entry_id` (16 случайных байт) это предъявительский код квитанции. В лист он не попадает, только обязательство:
`entry_commit = tagged("notarius/entry/v1", draw, entry_id_hex)`. Так опубликованный список не раскрывает кодов
квитанций, а каждый лист всё равно пересчитывается из полей.

`claim_id` (16 случайных байт) это код приза, в листе стоит `claim_commit = tagged("notarius/claim/v1", draw,
claim_id_hex)`. Победитель предъявляет код, стример и любой зритель сверяют его со списком на странице проверки без
нашей панели. Код по возможности генерирует сам участник (браузер в нашем виджете или приложение площадки) и присылает
только `claim_commit`: тогда кода не знает никто, кроме участника. Если площадка этого не умеет (донат по вебхуку),
код генерирует сервис, хранит его зашифрованным под KEK до итога, показывает только победителю в его сессии, никогда
в пути URL, и стирает после вручения; публичный текст тогда говорит, что код знает и сервис. Формат листа в обоих
случаях один.

### 6.2. Поворот колеса (`spin`, черновик)

```
{ "v": 1, "type": "spin", "draw": "<id колеса>", "seq": <номер>, "t": "<время записи>",
  "round": <R_spin>, "ref": "<ref события>" }
```

`round = round(t + margin_s·1000) + 1` (§11). Колесо в v1 не проверяется, §12.6.

### 6.3. Правило действительности записи

N и `closed_at` берутся из листа `close` (§7.3). Каждый лист опубликованного списка получает не больше одной находки:
внутри [0, N) это `mismatch:invalid_leaf`, за N это `mismatch:excluded_leaf`. Проверки по порядку, находка по первой
нарушенной:

1. форма §6.1, `type = "entry"`, `draw` свой, `seq` равен позиции в списке;
2. `t` не меньше `t` предыдущего листа (сравнивается с ближайшим предыдущим листом правильной формы, даже если у того
   своя находка); нарушение вешается на более поздний лист. Лист правильной формы это лист формы §6.1 с `type = "entry"`;
3. внутри [0, N): `chances <= chances_max`, `open_at <= t`, `t < closed_at`;
4. за N: `t >= closed_at` (запись, принятая до закрытия, не может остаться за N);
5. непустой `ref` не равен `ref` ни одного более раннего листа правильной формы (в том же смысле, что в п. 2).

Решение (ж): недействительный лист не отфильтровывается, а проваливает розыгрыш. Листья с `seq >= N` и
`t >= closed_at` в розыгрыш не входят. Закрытие по расписанию пишет `closed_at = close_at`, а запись с `t >= close_at`
сервис в этот розыгрыш не принимает.

### 6.4. Хэш листа

Для листьев обоих журналов: `leaf_hash = SHA-256(0x00 || JCS(leaf))`, хэш листа RFC 6962 §2.1.

---

## 7. Журналы

### 7.1. Дерево

Оба журнала это деревья Меркла RFC 6962 §2.1:

```
MTH({}) = SHA-256("")
MTH({d0}) = SHA-256(0x00 || d0)
MTH(D[n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n])),  k = наибольшая степень двойки, меньшая n
```

Путь включения по §2.1.1, доказательство согласованности по §2.1.2; проверка по алгоритмам RFC 9162 §2.1.3.2
и §2.1.4.2. Каждый хэш ровно 32 байта, иначе отказ. Согласованность размера m с размером n: при m = n доказательство
пустое и корни равны; при 0 < m < n доказательство непустое; от размера 0 доказательства не бывает и не требуется.

### 7.2. Журнал розыгрыша

Лог `draw/<draw>`. Листья это записи участников в порядке `seq`; корень на размере N при закрытии это `list_root`,
он идёт во вход формулы.

### 7.3. Общий журнал

Лог `global/<service>`, один на сервис, публикуется целиком на `verify.<домен>/global/` (персональных данных в нём
нет). Листья, у всех есть `v`, `type`, `service` (равен `service` условий) и `t`, время записи листа. Сервис выдаёт
`t = max(t предыдущего листа, сейчас)`: `t` не убывает по индексу, нарушение это `mismatch:global_leaf`.

Перед каждым листом сервис сверяет свои часы с последним раундом drand с проверенной подписью (по монотонным часам
от момента, когда раунд получен): если часы впереди времени раунда больше чем на 4 с или позади больше чем на 2 с,
лист не пишется, это инцидент. Иначе один лист, записанный с часами на сутки вперёд, поднял бы планку `t` для всех
следующих розыгрышей. Проверка тоже ограничивает ущерб: лист, у которого `t > A(i) + 999 + SKEW` (позже своей отметки
больше чем на допуск расхождения часов сервиса и Rekor, SKEW = 5 000 мс, §17.2), записан с часами впереди. Такой лист этого розыгрыша это
`mismatch:global_leaf`, лист другого розыгрыша или лист без розыгрыша (`keys`, `build`) это оговорка `clock_ahead`,
и в обоих случаях он не поднимает планку
"не убывает" для следующих листьев. Сервис считает свою планку так же: лист, отметка которого показала часы впереди,
для следующих `t = max(предыдущее, сейчас)` не учитывается. Пороги сторожа (4 с и 2 с) строже допуска SKEW, но точность
у сторожа по раундам ограничена: живые зеркала держат запрос следующего раунда, пока тот не выйдет (около 1,5 с), поэтому
часы впереди меньше чем примерно на 6 с сторож может не увидеть. Часы честного сервиса держит синхронизация времени
(NTP), сторож ловит грубый сбой; с синхронизированными часами под допуск SKEW честный сервис не попадает.

| `type` | Поля | Когда |
|---|---|---|
| `keys` | `keyset_hash` | публикация набора ключей (§14) |
| `commit` | `draw`, `version` (целое от 1), `prev_commit` (у версии 1 `null`, у версии больше 1 целое: индекс, §3.4), `conditions_hash`, `keyset_hash` | фиксация условий; `keyset_hash` это набор, которым подписывается розыгрыш; пара `version` и `prev_commit` это часть формы листа любого розыгрыша |
| `close` | `draw`, `closed_at` (время), `size` (N), `root` (`list_root`), `latest_round` (целые и hex 32) | закрытие приёма |
| `reveal` | `draw`, `secret` | раскрытие |
| `outcome` | `draw`, `result_hash` | итог (§13) |
| `failure` | `draw`, `reason` | срыв: `not_revealed`, `round_timeout`, `closed_after_round`, `too_few_participants`, `cancelled`, `key_revoked` (§17.3) |
| `build` | `component`, `version` (строка 1..64 кодовые точки), `hash` | выпуск страницы проверки (§15) |

Ровно эти поля у каждого типа; `reason` не из списка это ошибка формы листа. У листа `close` два времени: `closed_at`
это граница приёма, по ней судят записи (§6.3), а `t` это момент, когда лист записан; `closed_at <= t`, иначе ошибка
формы. Закрытие по расписанию пишет `closed_at = close_at`. Досрочное закрытие бывает только на стриме (стример нажал
кнопку), лист пишет время нажатия и должен быть отмечен не позже `closed_at + 10 000` (§10.3). У акций
(`bookmaker_promo`, `brand_promo`) досрочного закрытия нет: `closed_at = close_at`, иначе `mismatch:close_early`.

`latest_round` это наибольший номер раунда с проверенной подписью (§2) среди ответов зеркал к моменту записи листа;
номер без подписи или с неверной подписью не учитывается и поднимает инцидент, иначе одно испорченное зеркало отменило
бы любой розыгрыш. Номер ищется запросами по номеру вокруг round(сейчас), не путём `/latest`.
Сервис пишет `close` после `close_at`, как только ответило хоть одно зеркало. Если к `close_at + margin_s·500`
(половина запаса) не ответило ни одно, лист `close` не пишется вовсе, вместо него не позже чем через 10 с идёт
`failure:cancelled`: розыгрыш
не состоялся по факту, без выдуманного номера раунда. `latest_round` это самоотчёт сервиса: он защищает от ушедших часов
честного сервиса, а не от сервиса, который сам лжёт. Если `latest_round >= R` или лист `close` записан при
`t >= time(R)`, приём закрыт после выхода раунда (`not_held:closed_after_round`).

После последней версии `commit` у розыгрыша не больше одного листа `close`, `reveal` и `outcome`
(`mismatch:duplicate_leaf`); `reveal` стоит после `close`, `outcome` после `reveal`; `reveal` без `close` и `outcome`
без `reveal` это тоже `mismatch:leaf_order`.

### 7.4. Одна история, раздвоение и откат

Два чекпоинта общего журнала, подписанные ключом этого розыгрыша, одного размера с разными корнями, или два чекпоинта,
между которыми не сходится доказательство согласованности, это доказательство раздвоения журнала. Его может предъявить
кто угодно. В файлах розыгрыша между соседними по размеру чекпоинтами общего журнала обязано сходиться доказательство
согласованности (`mismatch:global_consistency`).

Откат: засчитанная отметка чекпоинта меньшего размера позже засчитанной отметки большего больше чем на 1 с
(`integratedTime` дан в секундах, соседние отметки одной секунды откатом не считаются). Честный сервис отмечает
чекпоинты общего журнала по одному, в порядке роста размера, и никогда не отмечает чекпоинт меньше уже отмеченного.
Откат в файлах и в отметках ранних копий (§7.5) это `mismatch:anchor_rollback`. Откат между записями Rekor, часть которых в файлы не попала, файлы
не покажут (так сервис мог бы обрезать отрезок перед раскрытием и отметить обрезанный чекпоинт заново, вектор
`segment_cut_fresh_anchor`).

Раздвоение, при котором каждая ветка честно отмечена в Rekor, и откат ловит монитор: утилита в режиме аудита собирает
все записи Rekor с открытым ключом K_checkpoint (поиск Rekor по открытому ключу), сверяет каждую с полным общим журналом,
порядок размеров с порядком `integratedTime` и единственность версии 1 у каждого идентификатора розыгрыша. Полноту
поиска Rekor Sigstore не гарантирует, поэтому до свидетеля ко-подписи (у площадки в пилоте, для стримов внешний)
раздвоение и откат ловятся задним числом. Это сказано и в §21.

### 7.5. Отрезок общего журнала

Чтобы отсутствие листа было доказуемо, файлы розыгрыша несут не выборку, а непрерывный отрезок общего журнала:

```
segment = { "start": a, "left": [hex ...], "leaves": [ лист a, лист a+1, ... лист S_end−1 ] }
```

`a` это индекс листа `commit` версии 1 (§3.4), он первый в отрезке; отрезок не пуст. `S_end = a + число листьев`.
Отрезок обязан доходить до самого большого чекпоинта общего журнала в файлах: `S_end` не меньше размера любого
принятого чекпоинта, и чекпоинт размера `S_end` в файлах есть (`mismatch:global_segment`). Публикатор всегда доводит
отрезок до последнего подписанного чекпоинта.

`left` это левая граница: корни совершенных поддеревьев, покрывающих [0, a), по двоичным разрядам a от старшего
к младшему (у a = 5 это поддеревья [0, 4) и [4, 5)); элементов в `left` столько, сколько единиц в двоичной записи a.
Проверка: положить в стек узлы `left` с их размерами; для каждого листа отрезка положить его хэш с размером 1 и, пока
два верхних элемента одного размера, заменить их узлом `SHA-256(0x01 || левый || правый)`; в конце свернуть стек
справа налево тем же правилом. Получившийся корень обязан равняться корню чекпоинта размера S_end.

Что это доказывает: среди первых S_end листьев общего журнала есть все листья [a, S_end), и листа, которого в отрезке
нет, среди них нет. Вывод "к сроку X листа не было" сильнее: он засчитывается, только если самая ранняя засчитанная
отметка чекпоинта ровно размера S_end позже X (§17.2, шаг 6). Отметки других чекпоинтов для него не годятся.
Подделать такой вывод можно только свежей отметкой обрезанного чекпоинта, а это откат, его ловит монитор (§7.4).
Если отрезок не отмечен достаточно поздно, отсутствие не доказано: два часа это ожидание, дальше сервис не выложил
журнал (`not_held:log_withheld`, по вине сервиса, в счётчик).

Засчитанная отметка годится из любого источника: она сама себя доказывает (тело, SET, включение, заметка шарда).
Поэтому сервис не может задним числом ухудшить статус, выложив файлы беднее прежних, если у проверяющего есть ранняя
копия, а сборка проверки знает ключи, которыми копия подписана (про старую сборку ниже). Проверяющий принимает вместе
с файлами ранние копии: `global.json`, `later_keysets.json` и `drand.json`, какими они были выложены (страница
проверки хранит прошлые копии, утилита принимает несколько наборов, у зеркала площадки они свои). Копию может
подложить кто угодно, поэтому доказательство из копии одно: отметка. Чекпоинт копии годится, только если в копии есть
его отметка, которая проходит все пять шагов §10.2 в известном журнале и сделана ключом чекпоинтов этого розыгрыша
(набора фиксации или позднего набора, названного листом `keys` в отрезке), который в момент отметки ещё действовал
(§14 п. 6). Поздний набор из копии годится, только если `service` есть в истории ключей сборки и все ключи набора есть
в истории с той же ролью, а сам набор проходит форму §14 и проверки шага 2 без находок; лист `keys` такого набора
ищется в отрезке этой копии. Иначе сверять набор не с чем, и посторонний подложил бы копию со своим набором, своим
листом `keys` и своей отметкой. Для сервиса без истории ключей копия не добавляет ничего, кроме подписи раунда: без
истории не узнать, действовал ли ключ в момент отметки, и владелец старого ключа такого сервиса продлил бы или
раздвоил его журнал. Поэтому в шаге 4 копии такого сервиса не рассматриваются вовсе: ни продления отрезка, ни
чекпоинтов, ни правила п. 3 об отдельном чекпоинте; из них берётся только `drand.json`. Такие чекпоинты называются отмеченными. Остальное в копии, в том числе копия, которую не удалось
прочесть, молча пропускается и никогда не становится находкой. По порядку:

1. копия рассматривается, если у неё те же `start` и `left`, а все листья её отрезка проходят форму §7.3 и несут
   `service` условий; п. 2-4, в том числе отдельный чекпоинт п. 3, относятся только к такой копии;
2. отрезок копии, который продолжает отрезок лист за листом (в канонической форме), продлевает его: из отмеченных
   чекпоинтов копии сначала отбираются те, чей корень равен корню префикса отрезка копии этого размера, а `t` не раньше
   последнего покрытого листа, и отрезок продлевается до самого большого из отобранных; так повторяется, пока находится
   продление; продлённый отрезок дальше проверяется как отрезок файлов;
3. копия, которая расходится с получившимся отрезком хотя бы в одном листе, доказывает две истории журнала
   (`mismatch:global_consistency`, стоп), только если в ней есть отмеченный чекпоинт, покрывающий этот лист, с корнем
   префикса её отрезка своего размера; без такого чекпоинта она ничего не доказывает. Так же доказывает две истории
   любой отдельный чекпоинт копии (в том числе над её собственными несогласными листьями) размера, для которого корень
   известен (размер больше `start` и не больше конца отрезка, или размер принятого чекпоинта), с другим корнем, если
   его подписал ключ розыгрыша, который ещё действует и действует для этого размера (§14 п. 5), или он отмечен (вор со
   старым ключом этого сделать не может);
4. из копий, согласных с отрезком, добавляются отмеченные чекпоинты размера больше `start` и не больше конца отрезка
   с корнем префикса этого размера и `t` не раньше последнего покрытого листа (доказательство согласованности для них
   не нужно), их отметки и поздние наборы ключей, названные листом `keys` в отрезке.

`drand.json` любой копии годится, если в нём `round = R` и подпись верна для R: подпись раунда проверяет сама себя (§2),
что бы ни было в остальной копии, и поля сверх `round` и `signature` здесь не мешают. Оговорка `evidence_from_copies`
ставится один раз на проверку, если копии добавили хотя бы одно из: листья отрезка; чекпоинт, которого нет в файлах;
засчитанную отметку, то есть запись Rekor (`logID` и `logIndex`), которой нет среди засчитанных отметок файлов;
поздний набор, которого нет в файлах; подпись R, когда её нет ни в `drand.json` файлов, ни в `result.input`,
прошедшем форму §12.1, ни у своих зеркал. Оговорка значит "в файлах этого не было", а не "статус изменился". Отметки копий участвуют во всех правилах
шага 6, в том числе в правиле отката, и находку дают только по этому правилу.

Сборка проверки, которая не знает ключа, введённого после закрытия, не может воспользоваться копией, подписанной этим
ключом: поздний набор с этим ключом из копии не годится (выше), а без него не засчитываются и отметки этого ключа.
Того, что держится на этом ключе, старая сборка из копии не восстанавливает и показывает то, что дают текущие файлы;
если сервис урезал файлы, статус у неё хуже прежнего. Лечится это обновлением проверки: текущая сборка по той же копии
возвращает прежний статус (у честного розыгрыша `match` с оговоркой `evidence_from_copies`; векторы
`copy_after_cut_old_build_restores_nothing` и `copy_after_cut_current_build_restores`).

Листья до отрезка (лист `keys` набора фиксации и любые другие) предъявляются по одному с путём включения; их `t`
с отрезком не сравнивается (лист до отрезка мог быть записан с часами впереди, это не дело этого розыгрыша). Отрезок
не пуст: в нём есть хотя бы лист `commit` версии 1.

---

## 8. Чекпоинт

```
{ "v": 1, "type": "checkpoint", "log": "<draw/... или global/...>", "size": <n>, "root": "<hex 32>",
  "t": "<время>", "prev": "<hex 32 или null>", "sigs": [ ... ] }
```

Подпись K_checkpoint. `checkpoint_hash = hash(чекпоинт без sigs)`; `prev` это `checkpoint_hash` предыдущего
чекпоинта того же лога, у первого `null`. `t` чекпоинта не раньше `t` последнего листа, который он покрывает; сервис
ставит `t = max(сейчас, t покрытых листьев, t предыдущего чекпоинта)`. Канонические
байты чекпоинта (`JCS` без `sigs`) это то, что уходит во внешнюю привязку (§10).

Журнал розыгрыша: цепочка начинается чекпоинтом пустого списка (размер 0) в момент открытия приёма, поэтому квитанция
записи с `seq = 0` ссылается на него. Дальше чекпоинт строится раз в секунду под нагрузкой и сразу при простое; пока
приём открыт, чекпоинт целиком, с подписью, уходит в открытый канал каждые 30 с. В файлах розыгрыша лежат все
чекпоинты журнала розыгрыша одной цепочкой (`draw_checkpoints.json`): первый размера 0 с `prev = null`, у каждого
следующего `prev` указывает на предыдущий, размер и время не убывают, корень равен корню префикса опубликованного
списка, `t` не раньше `t` последнего покрытого листа, ни один чекпоинт с `t < closed_at` не покрывает больше N листьев, ни один чекпоинт с `t >= closed_at`
не покрывает меньше N листьев (хвост, дописанный после закрытия, не спрятать), есть прошедший все проверки чекпоинт
размера N с корнем `close.root`. Чекпоинты из канала проверяющий читает в канале сервиса сам; чекпоинт розыгрыша
оттуда, подписанный ключом розыгрыша, обязан быть одним из цепочки в файлах (сравнение по `checkpoint_hash`).
Пост в канал может сделать только сервис, а читает их проверяющий сам, поэтому подписанный чекпоинт оттуда связывает
сервис, даже если ключ потом выведен или отозван. При закрытии сервис сразу публикует закрывающий чекпоинт журнала
розыгрыша в канал и на плашку стрима. Нарушение любого правила это `mismatch:draw_checkpoint`.

Общий журнал: если журнал простаивает, сервис строит новый чекпоинт того же размера и корня с новым `t` и отмечает
его (Rekor второй раз ту же запись не примет). Таких чекпоинтов в файлах может быть несколько; отметка называет
чекпоинт по `checkpoint_hash` (§18). Цепочку `prev` общего журнала проверяет монитор по полному журналу; проверка
одного розыгрыша полагается на доказательства согласованности (§7.4).

---

## 9. Квитанция участника

```
{ "v": 1, "type": "receipt", "draw": "<id>", "leaf": { лист entry }, "leaf_hash": "<hex>",
  "entry_id": "<hex 16>", "issued": "<время>", "checkpoint": { "size": <s>, "root": "<hex>" },
  "sigs": [ подпись K_receipt ] }
```

Квитанция выдаётся сразу при приёме: это подписанное обещание включить запись (как SCT в RFC 6962 §3.2). `checkpoint`
это последний чекпоинт журнала розыгрыша на момент выдачи.

Проверка квитанции:

1. подпись K_receipt из набора ключей, чей отпечаток стоит в листе `commit` розыгрыша (§14), а сам `commit` входит
   в отмеченный чекпоинт общего журнала (у последнего `commit` есть засчитанная отметка, `A(commit)` §10.3);
2. `leaf_hash = SHA-256(0x00 || JCS(leaf))`, лист проходит форму §6.1, `leaf.draw = draw`;
3. `tagged("notarius/entry/v1", draw, entry_id) = leaf.entry_commit`;
4. `checkpoint.size <= leaf.seq`, и `checkpoint` это один из чекпоинтов `draw_checkpoints.json`, подписанных
   любым ключом чекпоинтов розыгрыша (как в шаге 10 §17.2, правило размера §14 п. 5 здесь не применяется), с теми же
   размером и корнем;
5. корень первых `checkpoint.size` листьев опубликованного списка равен `checkpoint.root`, лист на позиции `leaf.seq`
   равен листу квитанции;
6. если `leaf.seq >= N` и `leaf.t < closed_at`: запись принята до закрытия и не включена, это нарушение сервиса;
7. если есть путь включения, он сходится. Путь идёт к чекпоинту журнала розыгрыша, который покрывает лист
   (`leaf.seq < size`), а не к чекпоинту внутри квитанции. Сервис отдаёт его в ответе
   `POST /v1/draws/{draw}/receipts/lookup` вместе с этим чекпоинтом: `{"receipt", "inclusion": {"index", "size",
   "path"}, "checkpoint"}`; корень берётся из этого чекпоинта, а сам чекпоинт обязан быть одним из цепочки
   `draw_checkpoints.json`. В объекте квитанции и в файлах розыгрыша пути нет. Без пути пункт не проверяется: пункт 5
   и так сверяет лист с опубликованным списком.

Итог проверки квитанции это список нарушенных пунктов; пустой список значит, что квитанция верна. Пункт 6 в списке
означает нарушение сервиса, а не подделку квитанции (вектор `05-receipt.json`, `accepted_before_close_but_excluded`).

Что это доказывает: если квитанция верна, а в опубликованном списке на её месте другой лист, списка не хватает или
запись осталась за N, сервис принял запись и не включил её. Чего не доказывает: что запись ваша (без соли `subject`
не сверить) и что площадка отправила все события. При краже K_receipt квитанции подделываются (вор видит список
и вписывает настоящий префикс); защита участника тогда правило §14 п. 4. Хранить квитанцию надо файлом: копия
в браузере на iPhone живёт около недели без захода на сайт.

---

## 10. Внешняя привязка: Rekor и канал

### 10.1. Что кладём в Rekor

Чекпоинт общего журнала уходит в Rekor v1 записью `hashedrekord` версии `0.0.1`:

```
M = JCS(чекпоинт без sigs)
{ "apiVersion": "0.0.1", "kind": "hashedrekord",
  "spec": { "data": { "hash": { "algorithm": "sha512", "value": hex(SHA-512(M)) } },
            "signature": { "content": base64(Ed25519ph(K_checkpoint, M)),
                           "publicKey": { "content": base64(PEM открытого ключа K_checkpoint) } } } }
```

Ed25519ph по RFC 8032: предхэш SHA-512(M), пустой контекст. Ключ Ed25519 Rekor проверяет только в режиме Ed25519ph
и только с SHA-512: запись с `sha256` он отвергает ("unsupported hash algorithm"), запись с обычной подписью Ed25519
тоже. PEM это ровно байты `"-----BEGIN PUBLIC KEY-----\n" || base64(SPKI DER) || "\n-----END PUBLIC KEY-----\n"`, где SPKI DER
это `302a300506032b6570032100 || ключ` (44 байта, base64 одной строкой из 60 символов), переводы строк `\n`, в конце
перевод строки. Что публичный Rekor примет такую запись с ключом Ed25519, на стенде проверено на rekor-server 1.5.4;
до первой боевой отметки это проверяется чтением публичной записи hashedrekord с ключом Ed25519.
Почему тот же ключ, а не отдельный ECDSA: ADR-0003. Ключ записи это ключ, которым подписан чекпоинт и который действует
для розыгрыша (§14); после смены ключа отметки ставит новый ключ.

Отметка ставится при каждой фиксации условий, при закрытии, при раскрытии, при каждом срыве, при публикации ключей
и сборок, в эфире раз в 10 минут, иначе раз в час. Отмечается всегда последний подписанный чекпоинт, отметки идут
по одной, в порядке роста размера (§7.4). Сервис не открывает приём, пока не получил отметку фиксации. Дев, CI и стенд
пишут только в свой локальный Rekor, в публичный пишет только прод.

### 10.2. Как проверять запись Rekor

Из ответа Rekor (`GET /api/v1/log/entries?logIndex=<n>`) нужны `body`, `integratedTime`, `logID`, `logIndex`,
`verification.signedEntryTimestamp` и `verification.inclusionProof`. Запись, у которой `body` или `logID` не строка
или `verification` не объект, это не запись этого вида: одна находка `mismatch:anchor` (а не неизвестный журнал).
`inclusionProof` без поля или `null` значит "без включения"; поле другого вида (`false`, `0`, строка, массив) это
ошибка шага 4.

1. Тело. `body` это строгий base64 тела записи. Байты декодируются и разбираются строго, как файл протокола (§1.1):
   повтор ключа, BOM и невалидный UTF-8 это ошибка тела. Проверить `kind = "hashedrekord"`, `apiVersion = "0.0.1"`,
   `data.hash.algorithm = "sha512"` и `value = hex(SHA-512(M))` для нашего чекпоинта; строгий base64 из
   `publicKey.content` побайтно равен PEM §10.1 ключа, который подписал этот чекпоинт (любую его копию с тем же
   `checkpoint_hash`) и действует для размера этого чекпоинта (§14 п. 5); PEM не разбирается, а сравнивается; Ed25519ph над M верна (§1.4). `integratedTime` целое неотрицательное. Тело
   проверяется всегда, даже если журнал Rekor неизвестен; исключение в любой из этих проверок это ошибка тела.
   Копии чекпоинта с тем же `checkpoint_hash` (у них могут быть разные `sigs`) берутся среди принятых чекпоинтов
   шага 3 §17.2 и чекпоинтов, добавленных из ранних копий в шаге 4; чекпоинты копий, которые шаг 4 не принял, сюда
   не идут. Когда шаг 4 решает о чекпоинте копии по его отметке, к принятым добавляется сам этот чекпоинт (в том виде,
   в каком он лежит в копии), другие его версии из той же копии не добавляются.
2. Ключ журнала. Открытые ключи Rekor зашиты в проверяющего (ECDSA P-256, SPKI). `logID` обязан равняться
   `SHA-256(SPKI DER ключа)`. Сборка для прода держит только ключ публичного `rekor.sigstore.dev`:
   ```
   -----BEGIN PUBLIC KEY-----
   MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwr
   kBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==
   -----END PUBLIC KEY-----
   ```
   (`logID` `c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d`). Ключи локального Rekor и тестового
   Rekor векторов допустимы только в сборке с пометкой "стенд" на экране; список ключей входит в хэш сборки (§15).
   Скаляр тестового Rekor §20 публичен, поэтому в боевой сборке его журнал неизвестен при любом входе.
   Неизвестный `logID`: время не засчитывается (`warning:anchor_unknown_log`), есть включение или нет.
3. SET. Подпись ECDSA P-256 с SHA-256 над `JCS({"body": body, "integratedTime": ..., "logID": ..., "logIndex": ...})`,
   где `logIndex` глобальный (сквозной по шардам), а не индекс внутри шарда. SET приходит в DER; WebCrypto принимает
   только r‖s (64 байта), поэтому DER надо перевести. DER строгий, как в Go и в OpenSSL: минимальные длины, у `r`
   и `s` нет лишних ведущих нулей и знака минус, после структуры ничего нет, `0 < r, s < n`; иначе подпись неверна.
   Подпись с большим S допустима (Rekor на Go её не нормализует). То же правило DER для подписей заметки в п. 5.
   Нет SET или он не верен: ошибка.
4. Включение. Хэш листа Rekor `SHA-256(0x00 || base64decode(body))`; путь `inclusionProof.hashes` проверяется по §7.1
   с индексом `inclusionProof.logIndex` (внутри шарда) и размером `treeSize` к `rootHash`.
5. Чекпоинт шарда. `inclusionProof.checkpoint` это подписанная заметка: строки `<origin>`, `<size>`, `<root base64>`,
   возможно ещё строки, пустая строка, затем строки подписей, каждая начинается с символа U+2014 и пробела, дальше
   имя и `base64(4 байта подсказки || DER-подпись)`; имя от base64 отделяет последний пробел строки. Подсказка это
   первые 4 байта `logID`. Подписываются все строки
   до пустой, каждая со своим переводом строки (`"<origin>\n<size>\n<root>\n"` без строк-расширений), ECDSA P-256
   с SHA-256. Достаточно одной верной строки подписи с подсказкой этого журнала; строки после пустой, которые
   не начинаются с U+2014 и пробела, и подписи с другой подсказкой пропускаются.
   `size` и `root` заметки обязаны совпасть с `treeSize` и `rootHash`. Нет поля `checkpoint` при наличии включения:
   ошибка.

Время отметки засчитывается, только если прошли все пять шагов. Без `inclusionProof` (одного SET мало) время
не засчитывается (`warning:anchor_incomplete`). Ошибка в любом шаге это одна находка `mismatch:anchor` на отметку:
кто-то предъявил отметку, которая не отмечает этот чекпоинт.

### 10.3. Время отметки и свидетели

Лист общего журнала с индексом i отмечен в момент `A(i) = min(integratedTime·1000)` по всем засчитанным отметкам
чекпоинтов размера больше i (правило секунды §1.2).

Секрет генерирует сам сервис, поэтому после выхода раунда R он может посчитать исход для любого списка. Помешать
ему может только внешняя отметка раньше time(R) (обозначение T_R). Поэтому:

| Что | Если нарушено |
|---|---|
| фиксация (лист последней версии) отмечена раньше `open_at` | `not_held:commit_not_witnessed`; не отмечена вовсе: при `сейчас < T_R` это `pending:commit_not_anchored`, иначе `not_held:commit_not_witnessed` |
| закрытие отмечено раньше T_R | `unwitnessed:close_anchor_after_round`; не отмечено вовсе: при `сейчас < T_R` это `pending:close_not_anchored`, иначе `unwitnessed:close_not_anchored` |
| досрочное закрытие (`closed_at < close_at`) отмечено не позже `closed_at + 10 000` | `unwitnessed:close_early_not_witnessed`: момент досрочного закрытия мог быть выбран позже |
| закрытие по расписанию отмечено не позже `closed_at + 10 000` (правило секунды) | оговорка `close_anchor_delay` |
| раскрытие отмечено не позже `reveal_deadline + 10 мин` | §4 |

Почему фиксация и закрытие разные. Без отметки фиксации до старта сервис мог бы подобрать секрет под нужного
победителя уже после выхода раунда, а честный сервис туда не попадает вовсе: он не открывает приём без отметки
фиксации (§10.1). Поэтому розыгрыш с неотмеченной фиксацией не состоялся. Закрытие без отметки до T_R честный сервис
получает, если Rekor лёг в окне запаса; статус `unwitnessed` ("нет внешней отметки до раунда"): раунд не меняется,
итог считается и показывается, но страница никогда не пишет "совпало" и пишет, что сервис мог дописать записи после
выхода раунда, рядом 16 символов `list_root` и время закрытия для сверки с записью эфира. Это следует правилу плана 2.6
("Rekor недоступен: раунд не меняется, честная пометка времени"). Принимать ли такой итог, решает площадка по своим
правилам. Свидетель времени в v1 один: Rekor. Штамп RFC 3161 и ко-подпись свидетеля добавятся отдельным полем отметки
(решение оператора сервиса).

Пост в канале (Telegram) быстрый свидетель, но не криптографический: время ставит мессенджер, засчитанным временем
отметки он не считается. В момент закрытия плашка на стриме показывает первые 16 символов `list_root` и индекс записи
Rekor: запись эфира становится ещё одним свидетелем, а индекс у каждой ветки журнала свой.

---

## 11. Номер раунда

```
round(t) = floor((t − genesis·1000) / (period·1000)) + 1,  t >= genesis·1000
time(R)  = genesis + (R − 1)·period   (секунды; в JSON как время с .000Z)
R        = round(close_at + margin_s·1000) + 1
```

`round(t)` это последний раунд, чьё расчётное время не позже t. R это первый раунд строго после `close_at + margin`.
Проверка 2026-10-16T12:00:00.000Z даёт 33116212.

Если `close.latest_round >= R` или `close.t >= time(R)`, розыгрыш не состоялся (`not_held:closed_after_round`).
Вывод "раунд не вышел" (`not_held:round_timeout`) делает только проверяющий, чьи зеркала ответили: последний раунд
L с проверенной подписью, который он сам получил от зеркал, меньше R, `сейчас >= T_R + 24 ч`, а верной подписи R нет
ни в файлах, ни в итоге сервиса. Если ни одно зеркало не ответило, вывода нет: `pending:no_round` с текстом
"проверьте с доступом к сети". Вывод снимается, как только подпись найдётся. Перенос на другую сеть запрещён.

---

## 12. Формула draw-v1

### 12.1. Вход

```
DrawInput = { "alg": "draw-v1", "draw": "<id>", "chain": "<chain_hash>", "round": R,
              "signature": "<hex 48, подпись раунда R>", "secret": "<hex 32 или null>",
              "list_root": "<hex 32>", "n": <размер популяции>, "ref": <ref или null> }
```

Все девять полей есть всегда, пустые равны `null` (`ref` у розыгрыша `null`), hex строчными (решение (з)). Объект
с лишним, пропущенным полем или заглавными буквами отвергается, а не исправляется.

Для розыгрыша: `secret` из листа `reveal` (или из `result.input`, если он сходится с обязательством), `list_root`
из листа `close`, `n` = сумма `chances` записей [0, N), `ref = null`. `result.input` служит источником подписи раунда
и секрета, только если сам проходит форму этого раздела.

### 12.2. Поток блоков

```
seed     = SHA-256(UTF-8(JCS(DrawInput)))
block(i) = SHA-256(seed || uint32_be(i)),  i = 0, 1, 2, ...
```

Счётчик i один на весь розыгрыш: общий для победителей и резервных, начинается с 0 и растёт на 1 при каждом взятом
блоке, в том числе отклонённом (решение (б)).

### 12.3. Равномерное число в [0, n)

```
uniform(n):  T = 2^64 − (2^64 mod n)
             повторять: b = block(i); i = i + 1; x = uint64_be(b[0..8])
                        если x < T: вернуть x mod n
```

x это первые 8 байт блока как беззнаковое целое big-endian (решение (а)). n от 1 до 2^53 − 1. При n = 1 блок всё равно
берётся. Вероятность отклонения меньше 2^−11; вектор `rejection_takes_next_block` показывает отклонение на большом n.

### 12.4. Популяция `entries`

Места [0, n) раздаются записям [0, N) по порядку `seq`: запись с `chances = c` занимает c подряд идущих мест,
n = сумма `chances` (решение (в)). Пример: шансы 1, 3, 2 дают места {0}, {1, 2, 3}, {4, 5}, n = 6; в векторах это
записано списками.

Выбор `k = winners` победителей и `m = alternates` резервных, позиции 0..k+m−1 по очереди:

1. если свободных мест не осталось, эта позиция и все следующие равны `null` (решение (е));
2. иначе `j = uniform(число свободных мест)`; выбрано j-е по возрастанию свободное место, счёт с нуля;
3. позиция получает `{ "place": <номер места>, "seq": <seq записи>, "subject": <subject записи> }`;
4. если `one_prize_per_subject = true`, из свободных удаляются все места всех записей с этим `subject`; если `false`,
   удаляется только выбранное место, и та же запись может занять ещё одну позицию (решение (г)).

Первые k позиций это `winners`, следующие m это `alternates`. Трасса в векторах: для каждой позиции
`{position, remaining, blocks, j, place, seq}` или `{position, remaining: 0}`.

### 12.5. Популяция `sectors`

Веса секторов целые положительные, порядок как в условиях, W = сумма весов. `x = uniform(W)`; выбран сектор i,
для которого `cum(i−1) <= x < cum(i)`, `cum(i) = w0 + ... + wi`, `cum(−1) = 0` (решение (д)). Веса 50/25/15/9/1:
x от 0 до 49 сектор 0, 50..74 сектор 1, 75..89 сектор 2, 90..98 сектор 3, 99 сектор 4.

### 12.6. Поворот колеса: черновик для Ф7, в v1 не входит

Колесо выпускается отдельной версией в Ф7. Красная команда показала, что первый вариант (вход формулы через хэш листа
поворота) оставлял сервису выбор `t` и `seq` уже после выхода раунда. Черновик входа:

```
DrawInput = { "alg": "draw-v1", "draw": <id колеса>, "chain": chain_hash, "round": R_spin,
              "signature": <подпись R_spin>, "secret": null,
              "list_root": tagged("notarius/spin/v1", draw, conditions_hash, ref), "n": W, "ref": ref }
```

`ref` уникален в сессии. Открыто к Ф7: как привязать R_spin ко времени, которое сервис не выбирает (время события
у платформы, показ R_spin на плашке до выхода раунда), и проверка сессии колеса целиком (фиксация до `open_at`,
закрытие сессии с числом поворотов и корнем). До этого проверяющий v1 отвечает на колесо `mismatch:wrong_verifier`.

---

## 13. Итог (`result`)

```
{ "v": 1, "type": "result", "draw": "<id>", "conditions_hash": "<hex>", "input": DrawInput,
  "winners": [ позиция или null, ... ], "alternates": [ ... ], "sigs": [ подпись K_checkpoint ] }
```

`result_hash = hash(итог без sigs)` стоит в листе `outcome`. Итог это расчёт проверяющего: опубликованный итог сервиса
обязан совпасть с ним в канонической форме (`JCS` итога без `sigs` равен `JCS` пересчитанного; форматирование файла
не важно) и быть подписан ключом K_checkpoint этого розыгрыша (из набора фиксации или более позднего, §14). Если
сервис итог не опубликовал, при `сейчас <= reveal_deadline + 1 ч` это `pending:no_result`, позже оговорка
`no_service_result`; статус от этого не страдает.

---

## 14. Ключи сервиса

| Ключ | Тип | Что подписывает |
|---|---|---|
| K_receipt | Ed25519 | квитанции |
| K_checkpoint | Ed25519 | чекпоинты обоих журналов, итоги; якорь в Rekor в режиме Ed25519ph |
| KEK, k_service, соли | симметричные, 32 байта | наружу не выходят и в протоколе не проверяются |

Набор ключей (`keyset.json`), ровно эти поля:

```
{ "v": 1, "type": "keyset", "service": "<id>",
  "keys": [ { "key_id": "<hex>", "role": "receipt|checkpoint", "alg": "ed25519",
              "public_key": "<hex 32>", "status": "active|retired|revoked" } ] }
```

Правила:

1. `keyset_hash = hash(keyset)` публикуется листом `keys` общего журнала раньше листа `commit` любого розыгрыша,
   который им подписывается.
2. Лист `commit` называет `keyset_hash`. Каждый `key_id` и каждый открытый ключ встречаются в наборе один раз (это
   часть формы набора). В наборе ровно один ключ со статусом `active` на роль; это условие в форму не входит, шаг 2
   §17.2 проверяет его после записей. У каждой записи `key_id` равен SHA-256 открытого ключа, ключ закодирован
   канонически, не малого порядка и лежит в подгруппе простого порядка (`[L]A = O`); единица удовлетворяет второму
   условию, но не первому.
3. Тестовые ключи §20 (их seed публичен) в наборе боевой сборки это `mismatch:keyset`.
4. Отзыв ключа после фиксации розыгрыша его не отменяет: квитанции и чекпоинты остаются действительными для розыгрышей,
   зафиксированных раньше листа `keys` с отзывом, какое бы время компрометации ни заявил сервис. Риск кражи ключа несёт
   сервис, а не участник. Если K_receipt скомпрометирован до закрытия, сервис пишет `failure:key_revoked` раньше
   `close` у каждого розыгрыша с открытым приёмом.
5. Чекпоинты розыгрыша подписывает действующий K_checkpoint: ключ набора фиксации или активный ключ более позднего
   набора, чей лист `keys` стоит в отрезке после фиксации (файл `later_keysets.json`, §18). Ключ позднего набора
   действует для чекпоинтов размера больше индекса своего листа `keys`. Квитанции проверяются только ключом
   K_receipt набора фиксации.
6. История ключей зашита в проверяющего так же, как ключи Rekor, и входит в файлы сборки (§15): для каждого известного
   `service` список `key_id`, роль, время вывода `retired_at` и время отзыва `revoked_at` (время или `null`), и время,
   на которое история составлена (`history_as_of`). Время вывода это самая ранняя засчитанная отметка листа `keys`,
   где ключ перестал быть `active`; время отзыва это такая же отметка листа, где ключ впервые помечен `revoked`.
   Ключ не действует с более раннего из двух времён. Правила:
   - фиксация, отмеченная позже того, как активный ключ её набора перестал действовать (`A(commit) >` этого времени),
     это `mismatch:keyset`;
   - отметка, сделанная ключом позже того, как он перестал действовать (`integratedTime·1000 >` этого времени), это
     `mismatch:anchor`;
   - конец приёма это отметка листа `close`, а без него отметка листа `failure` (`cancelled` или `key_revoked`),
     остановившего розыгрыш, а без них T_R; если K_receipt набора фиксации перестал действовать раньше конца приёма
     (или конец не отмечен), у розыгрыша обязан быть `failure:key_revoked` раньше `close` (или без `close`), иначе
     `mismatch:keyset`: квитанции такого розыгрыша может подделать посторонний. Вывод ключа той же отметкой, что
     и закрытие, нарушением не считается;
   - ключ ищется в истории парой `key_id` и роль (K_receipt, названный ключом чекпоинтов, в истории не найден);
     время вывода и время отзыва берутся из записи этой пары, запись того же `key_id` под другой ролью не в счёт;
   - для известного `service` ключ не из истории судится по набору, который первым ввёл его в журнал: из наборов
     файлов, где запись этого ключа прошла проверки п. 2 и 3, берётся набор с наименьшим индексом листа `keys`;
     порядок наборов в файлах значения не имеет. Запись, которая не прошла п. 2 или 3, даёт находку `mismatch` шага 2,
     и проверка останавливается на шаге 4: до шага 7 такой ключ не доходит, и условие про проверки п. 2 и 3 только
     исключает такие записи из рассуждения. Если этот лист записан позже `history_as_of` (по `t` листа) и набор
     передан ключом, который сборка знает и который тогда ещё действовал, это `pending:verifier_outdated` ("обновите
     проверку"), иначе `mismatch:keyset`. Повторы ключа в более поздних наборах (например, в наборе с отзывом старого
     ключа) новой передачи не требуют; проверки п. 2 проходит каждая запись. Передан значит: принятый чекпоинт,
     покрывающий лист `keys`, несёт верную подпись ключом чекпоинтов из истории (открытый ключ берётся из наборов файлов
     по `key_id`); если история показывает этот ключ выведенным или отозванным, нужна ещё засчитанная отметка этого же
     чекпоинта с `integratedTime·1000 <=` этого времени, иначе вор со старым ключом передал бы поддельный набор.
     Правила секунды §1.2 здесь нет: время вывода само есть отметка Rekor, у честной смены это отметка того же
     передающего чекпоинта. Поэтому в переходный период после смены ключа сервис подписывает чекпоинты обоими ключами.
     `pending` никогда не даёт "совпало": после обновления проверки ключ не из истории станет `mismatch:keyset`.
   Неизвестный `service` даёт оговорку `service_unknown` ("сторонний сервис"): статус считается по тем же правилам,
   но знак нашего сервиса страница не показывает.

Смена ключей:

- Плановая смена K_checkpoint: сначала выходит сборка проверяющего с новым ключом в истории, потом лист `keys`
  с новым набором (новый ключ `active`, старый `retired`), потом новый ключ подписывает чекпоинты и итоги и ставит
  отметки. Розыгрыши, зафиксированные со старым набором, принимают новый ключ по п. 5. В переходный период (пока
  в ходу старые сборки проверки) чекпоинты подписываются обоими ключами (две записи в `sigs`), отметки ставит новый.
- K_receipt не меняется, пока у какого-нибудь розыгрыша открыт приём: квитанции проверяются набором фиксации.
- Компрометация: лист `keys` с новым набором, где старый ключ `revoked`, новая сборка проверяющего с `revoked_at`;
  все зафиксированные и ещё не закрытые розыгрыши набора с украденным K_receipt получают `failure:key_revoked` до
  закрытия, закрытые розыгрыши доводит новый K_checkpoint по п. 5. Отозванным ключом сервис больше не подписывает
  ничего. Порядок листов: сначала лист `keys` с отзывом, потом `failure:key_revoked`, набор с отзывом обязательно
  в `later_keysets.json`. `failure:key_revoked` засчитывается, только если действующий ключ набора фиксации (K_receipt
  или K_checkpoint) действительно отозван: по истории ключей не позже отметки листа `failure` или поздним набором,
  опубликованным в отрезке раньше листа `failure`, где ключ стоит со статусом `revoked`; такой набор доказывает отзыв,
  какие бы наборы ни стояли до него (статус `retired` и отсутствие ключа в наборе сами отзывом не считаются и отзыв
  в более позднем наборе не отменяют); время истории сравнивается с отметкой листа `failure`, а не с его `t`. Отзыв
  ключа, который в наборе фиксации не был активным (выведен раньше фиксации), срыва не даёт. Ключ, который был
  активным в наборе фиксации, потом выведен и затем отозван, срыв обосновывает: вывод отзыва не отменяет.
- K_receipt не меняется и в плановом порядке, пока есть зафиксированный и не закрытый розыгрыш его набора.

---

## 15. Сборки страницы проверки

Каждый выпуск страницы проверки это лист `build` общего журнала: `component` (`verify`), `version` и
`hash = SHA-256(JCS({"files": [{"path": ..., "sha256": ...}, ...]}))`, файлы по возрастанию `path`, с отметкой в Rekor.
Список зашитых ключей Rekor и история ключей сервиса с `history_as_of` (§14 п. 6) входят в файлы сборки. Скрипты подключаются с SRI.
Страница показывает свою версию, но проверить саму себя не может: сверку хэша сборки с журналом делают утилита
на Python и зеркало у площадки. Порядок сверки: взять `build.json` выпуска с хоста проверки, скачать перечисленные
в нём файлы, пересчитать `hash` по формуле выше и найти лист `build` с этим `hash` в общем журнале (`/global/`).
В утилите v1 этого режима нет (docs/RUNBOOK.md, выпуск страницы проверки), как нет и режима монитора §7.4.
Сборка из одного коммита с тем же Node и lockfile даёт тот же `hash` (проверено 18.10.2026 на macOS и в образе
`node:24.22.0-alpine3.24`).

---

## 16. Отчёт о сходимости

Для одной версии условий колеса (один `conditions_hash`) за период: ожидаемые доли секторов `p_i = w_i / W` против
частот по журналу.

```
chi2 = сумма (O_i − E_i)^2 / E_i,  E_i = n · p_i;   df = число секторов − 1
p_value = Q(df / 2, chi2 / 2)      (регуляризованная верхняя неполная гамма-функция)
Wilson 95%: z = 1.959963984540054;  центр = (p + z^2/(2n)) / (1 + z^2/n);
            полуширина = z · sqrt(p(1−p)/n + z^2/(4n^2)) / (1 + z^2/n)
N_min = ceil(5 / p_min)
```

Ниже N_min пишется "данных мало", показываются только интервалы. При `n >= N_min` и `p_value < 0,01` сервис сам
открывает разбор и пишет о нём в канал. Оговорки под таблицей: расхождение это повод проверить код, а не доказательство
вмешательства; отчёт видит перекос в среднем, а не выбор отдельных поворотов; он не видит событий, которых площадка
не прислала; много периодов дают ложные тревоги. Векторы сравниваются с допуском 1e−9.

---

## 17. Проверка розыгрыша

### 17.1. Итог проверки

| Статус | На странице | Значит |
|---|---|---|
| `match` | совпало | всё сошлось, фиксация отмечена до старта, закрытие до раунда |
| `mismatch` | не совпало | хотя бы одна проверка не прошла |
| `not_held` | не состоялся | розыгрыш сорвался по правилу условий, и это подтверждают файлы |
| `unwitnessed` | нет внешней отметки до раунда | итог посчитан, но закрытие не отмечено снаружи раньше time(R) или досрочное закрытие отмечено поздно |
| `pending` | ожидание | чего-то ещё нет, но срок не вышел (или проверку надо обновить) |

Приоритет: `mismatch`, `not_held`, `unwitnessed`, `pending`, `match`. Оговорки (`warning`) статус не меняют.
Каждая находка это `уровень:код`; на один объект (лист, чекпоинт, отметку, ключ, лист `failure`) в одном шаге не больше
одной находки, подробности в тексте находки. Ключ это пара `key_id` и роль: ключ, записанный в нескольких наборах
(в наборе фиксации и в позднем), один объект для находок. Проверки §14 п. 2 при этом проходит каждая запись каждого
набора: правило об одном объекте схлопывает повторную находку, но не отменяет этих проверок. Ключ не из истории шаг 7
судит один раз, по набору, который первым ввёл его в журнал (§14 п. 6). В шаге 7 правило о фиксации
(ключ перестал действовать до `A(commit)`) и правило о конце приёма (K_receipt перестал действовать раньше конца
приёма) это разные объекты: лист `commit` и приём, у одного ключа там могут быть две находки. Условия, набор ключей, итог, лист `close` и лист
`commit` последней версии это не один объект, а несколько проверок: каждое перечисленное в шагах 5-8 и 15 правило для
них даёт свою находку (у `close` до четырёх: время закрытия, длительность, раунд, отметка). Нормативны статус и мультимножество
кодов; тексты находок и порядок кодов внутри шага ненормативны.

"Сейчас" это часы проверяющего с тремя поправками, строго в таком порядке: сначала, если его собственные зеркала
ответили последним раундом L с проверенной подписью, "сейчас" не позже `time(L) + 3 000` (часы впереди не дадут
преждевременного "не состоялся"); затем, если есть верная подпись R из любого источника, "сейчас" не раньше T_R; затем
"сейчас" не раньше самой поздней засчитанной отметки (`integratedTime·1000`). Единственное исключение: вывод "раунд
не вышел" (§11) и строка `round_timeout` в §17.3 сравнивают с T_R + 24 ч часы проверяющего без первой поправки
(вторая и третья остаются), ведь зеркала, остановившиеся до R, это и есть его случай. Телефон с отставшими часами не покажет "ожидание" там, где файлы уже доказывают обратное.

### 17.2. Порядок проверки

Вход: файлы розыгрыша (§18), зашитые параметры сети, ключи Rekor, история ключей сервиса и время, на которое она
составлена, часы; от своих зеркал (если есть): подпись раунда R и последний раунд L с проверенной подписью (L ищется
запросами по номеру от R0 = round("сейчас" проверяющего до поправок) вниз: R0, R0 − 1, R0 − 2, затем с удвоением
шага (R0 − 4, R0 − 8, ...) до первого номера с верной подписью и делением пополам до наибольшего такого номера,
не глубже 2^26 раундов. Ответ зеркала на номер бывает трёх видов: верная подпись этого номера (номер есть), 404 или
425 ("ещё нет"), всё остальное, в том числе 200 с неверной подписью или нечитаемым телом и коды 5xx, ответом
не считается. Номер есть, если хоть одно зеркало дало верную подпись; "ещё нет", если верной подписи нет, но хоть одно
зеркало ответило 404 или 425, и тогда поиск идёт дальше; иначе запрос без ответа, он останавливает поиск, и тогда
L нет, даже если какой-то номер уже найден); чекпоинты розыгрыша, прочитанные в канале сервиса (проверяющий может канал
не читать, тогда вход пуст; страница и утилита v1 его не читают); ранние копии файлов (`global.json`, `later_keysets.json`, `drand.json`). R ниже это `conditions.round`,
T_R = time(R), D = `reveal_deadline`, TOL = 600 000 (10 мин), GRACE = 7 200 000 (2 ч), SKEW = 5 000. Шаги строго
по порядку; "стоп" значит "дальше не проверять, статус по уже найденному". Любое исключение в проверке, не описанное
шагами, это одна находка `mismatch:format` и стоп. Элемент пути включения, доказательства согласованности или левой
границы отрезка, который не 32 байта строчного hex, это не исключение: такое доказательство просто не сходится,
и находку даёт его шаг (`global_consistency`, `global_leaf`, `global_segment`).

0. Файлы. Каждый файл декодируется строго (§1.1) и разбирается строгим парсером; у `entries.jsonl` каждая строка
   ровно `JCS(лист)`, строки через `\n`, в конце `\n`, пустой файл это пустой список. Нет файла, отказ декодера
   или парсера: одна находка с кодом первого такого файла по порядку таблицы ниже, стоп. `later_keysets.json`
   и `earlier_conditions.json` могут отсутствовать
   (смены ключей не было, версия одна). Затем каркас: `conditions.json` и `keyset.json` объекты, `earlier_conditions.json`
   и `later_keysets.json` массивы объектов, `draw_checkpoints.json` массив, `global.json` объект с массивами `leaves`,
   `checkpoints`, `consistency`, `anchors` и объектом `segment` (целое `start`, массив строк `left`, массив `leaves`),
   у `global.json` и у `segment` ровно эти поля; элементы `leaves` ровно `{index, leaf, size, path}` с целыми `index` и `size` и массивом `path`,
   `consistency` ровно `{from, to, path}` с целыми `from`, `to` и массивом `path`, `anchors` ровно
   `{checkpoint_hash, rekor, channel}` со строкой `checkpoint_hash` и `rekor` объектом или `null`; вид `leaf`, элементов
   `path`, полей внутри `rekor` и `channel` здесь не проверяется (лист и путь проверяют шаги 4 и 6, поля записи Rekor
   шаг 6 по §10.2, лишние поля внутри `rekor` не мешают, туда кладут ответ Rekor целиком; пост канала не проверяется
   вовсе);
   `drand.json` это `null` или ровно `{round, signature}` с целым `round` и строкой `signature`; `result.json` это
   `null` или объект. Не тот вид значения или лишнее поле это находка с кодом файла, стоп.

   | Файл | Код |
   |---|---|
   | `conditions.json` | `conditions_schema` |
   | `earlier_conditions.json` | `commit` |
   | `keyset.json`, `later_keysets.json` | `keyset` |
   | `entries.jsonl` | `list` |
   | `draw_checkpoints.json` | `draw_checkpoint` |
   | `global.json` | `global_segment` |
   | `drand.json` | `drand_signature` |
   | `result.json` | `result` |

1. Условия. Схема вида (одна находка `mismatch:conditions_schema`, стоп). Вид `donation_wheel` это
   `mismatch:wrong_verifier`, стоп. Время в `open_at`, `close_at`, `round_time`, `reveal_deadline` разбирается
   по §1.2, иначе `mismatch:conditions_schema`, стоп. `open_at` раньше genesis сети: `mismatch:conditions_times`, стоп
   (номеров раундов до genesis нет). Дальше: `open_at < close_at` и
   `open_at + min_duration_s·1000 <= close_at` (одна находка `mismatch:conditions_times`); `round` по §11
   (`mismatch:round`); `round_time = time(round)` (`mismatch:round_time`); `T_R + 30 000 <= D <= T_R + 86 400 000`
   (`mismatch:reveal_deadline`); `prize.count = formula.winners` (`mismatch:conditions_prizes`). У акции с документом
   правил SHA-256 документа не равен `rules_sha256`: `mismatch:rules`. Поправки "сейчас" по зеркалам и по подписи R
   (§17.1); `result.input` годится в источники, только если проходит форму §12.1.
2. Ключи. Каждый набор (набор фиксации, затем поздние по порядку файла) проходит форму §14, иначе `mismatch:keyset`,
   стоп. Если в истории ключей нет `service` условий, оговорка `service_unknown`. Набор чужого `service`:
   `mismatch:keyset` на набор. Каждая запись ключа в каждом наборе проверяется; на ключ (пару `key_id` и роль) одна
   находка `mismatch:keyset` по первой причине: `key_id` не SHA-256 ключа этой записи или ключ непригоден (§14 п. 2);
   тестовый ключ в боевой сборке. Ключ известного `service`, которого нет в истории
   с той же ролью: без `history_as_of` это `mismatch:keyset` сразу, иначе решается на шаге 7. Ровно один активный
   ключ на роль в каждом наборе, иначе `mismatch:keyset`, стоп. Ключи чекпоинтов розыгрыша: активный K_checkpoint
   набора фиксации и каждого позднего набора. В боевой сборке ключ тестового Rekor §20 из списка ключей Rekor
   не действует.
3. Чекпоинты общего журнала. Форма §8, лог `global/<service>` и подпись одним из ключей чекпоинтов розыгрыша, иначе
   `mismatch:global_checkpoint` на чекпоинт; прошедшие это принятые. Принятые одного размера с разными корнями:
   `mismatch:global_consistency` на размер, стоп. Между соседними принятыми размерами m < n (при m >= 1) хотя бы
   одно доказательство из `global.consistency` с `from = m`, `to = n` сходится, иначе `mismatch:global_consistency`
   на пару.
4. Отрезок. Листья до отрезка, одна находка `mismatch:global_leaf` на лист: форма §7.3, свой `service`, индекс меньше
   `start`, есть принятый чекпоинт указанного размера, путь включения к нему сходится, `t` листа не позже самого
   раннего `t` чекпоинтов этого размера. Отрезок не пуст и пересобирает корень
   принятого чекпоинта размера S_end, и S_end не меньше любого принятого размера, иначе `mismatch:global_segment`, стоп.
   Листья отрезка: форма и свой `service` (`mismatch:global_leaf` на лист). Каждый принятый чекпоинт размера
   s > `start`: `t` чекпоинта не раньше `t` листа s − 1 (`mismatch:global_checkpoint`). Если с начала проверки уже
   есть любой `mismatch`, стоп. Затем ранние копии по §7.5: из них добавляется только то, что держится на отмеченных
   чекпоинтах (продление отрезка, чекпоинты с корнем префикса, их отметки, поздние наборы ключей сервиса из истории;
   для сервиса без истории ничего из этого), и `drand.json` любой копии с верной подписью R; копия с отмеченным чекпоинтом над другим листом и отдельный чекпоинт
   копии с другим корнем по п. 3 §7.5 это `mismatch:global_consistency`, стоп. Дальше "отрезок", S_end,
   принятые чекпоинты, поздние наборы и подпись R считаются с этими добавлениями.
5. Фиксация и порядок. Первый лист отрезка это `commit` этого розыгрыша версии 1, иначе `mismatch:commit`, стоп.
   Листья `commit` этого розыгрыша идут версиями 1, 2, ... и называют предыдущую по §3.4, иначе `mismatch:commit`, стоп.
   Последний из них несёт `conditions_hash` и `version` показанных условий, иначе `mismatch:commit`, стоп. Между первым
   и последним `commit` есть другой лист этого розыгрыша: `mismatch:commit`, стоп. Условия прежних версий из
   `earlier_conditions.json` есть все, по порядку версий, проходят схему, время в них разбирается по §1.2, и отпечаток
   каждой равен `conditions_hash` её листа `commit`, иначе `mismatch:commit`, стоп. `keyset_hash` последнего `commit` равен отпечатку `keyset.json`
   (`mismatch:keyset`), лист `keys` с отпечатком `keyset.json` стоит раньше последнего `commit` (`mismatch:keyset`);
   это две разные находки. Каждый
   поздний набор назван листом `keys` в отрезке после последнего `commit` (`mismatch:keyset`). Каждый принятый
   чекпоинт подписан хотя бы одним действующим для его размера ключом (§14 п. 5), иначе `mismatch:global_checkpoint`.
   Среди листьев розыгрыша после последнего `commit`: не больше одного `close`, `reveal`, `outcome`
   (`mismatch:duplicate_leaf` на тип); `reveal` после `close`, `outcome` после `reveal` (`mismatch:leaf_order`).
6. Отметки (§10.2). Отметка без записи Rekor (только канал) не проверяется вовсе. Отметка файлов с записью называет
   `checkpoint_hash` принятого чекпоинта, иначе `mismatch:anchor`. Ошибки тела, `integratedTime`, известного журнала
   (шаги 2-5 §10.2) и, для известного журнала, отметка ключом, который к её времени перестал действовать (§14 п. 6),
   дают одну находку `mismatch:anchor`. Без ошибок: неизвестный журнал `warning:anchor_unknown_log`; известный без
   включения `warning:anchor_incomplete`; иначе отметка засчитана со временем `integratedTime·1000`. Отметки ранних
   копий уже проверены на шаге 4 и находок не дают, кроме правила отката ниже; одна запись Rekor (один `logID`
   и `logIndex`) засчитывается один раз. Если копии что-то добавили (§7.5), оговорка `evidence_from_copies`. Засчитанная отметка
   размера m позже засчитанной отметки размера n > m больше чем на 1 000: `mismatch:anchor_rollback` на отметку.
   "Сейчас" не раньше самой поздней засчитанной отметки. Дальше `A(i)` по §10.3. Листья отрезка по порядку: лист с
   `A(i)` и `t > A(i) + 999 + SKEW` записан с часами впереди (лист этого розыгрыша `mismatch:global_leaf`, другого
   `warning:clock_ahead`) и планку не поднимает; остальные: `t` не меньше планки, иначе `mismatch:global_leaf`; `t`
   каждого такого листа, даже с находкой, становится новой планкой. `E` это самое раннее время засчитанных отметок чекпоинтов ровно размера S_end или "нет";
   "отсутствие доказано к X", если E есть и `E > X`; "не доказано к X": при `сейчас > X + GRACE` это
   `not_held:log_withheld`, иначе `pending:log_incomplete`.
7. Фиксация снаружи. `A(commit)` для последнего `commit`: нет, значит, при `сейчас < T_R` `pending:commit_not_anchored`,
   иначе `not_held:commit_not_witnessed`; есть, но не "отмечено раньше `open_at`" (§1.2), значит,
   `not_held:commit_not_witnessed`. Для каждой версии v + 1 > 1: если её лист отмечен и не "отмечено раньше `open_at`
   версии v", это `mismatch:commit`. Активный ключ набора фиксации, переставший действовать до `A(commit)`
   (§14 п. 6): `mismatch:keyset` на ключ. K_receipt набора фиксации перестал действовать раньше конца приёма (§14 п. 6),
   а `failure:key_revoked` до `close` нет: `mismatch:keyset`. Ключи не из истории (шаг 2): каждый судится по набору,
   который первым ввёл его в журнал (наименьший индекс листа `keys`, §14 п. 6), а не по порядку файла; если лист `keys`
   этого набора записан позже `history_as_of` и набор передан по §14 п. 6, одна находка `pending:verifier_outdated`,
   иначе `mismatch:keyset` на ключ. Акция без документа правил: при `сейчас < open_at + 86 400 000`
   `pending:rules_not_published`, иначе `mismatch:rules_not_published`.
8. Закрытие. Отмена засчитана, если есть `failure` с `reason` `cancelled` (или `key_revoked` с доказанным отзывом,
   §17.3), а `close` нет или `failure` стоит раньше `close`; необоснованный `key_revoked` розыгрыш не останавливает. Листа `close` нет: если отмены нет, то при `сейчас < T_R` `pending:not_closed`,
   при доказанном к T_R отсутствии `not_held:not_closed`, иначе "не доказано к T_R"; затем срывы (шаг 13), стоп.
   Лист есть, одна находка о времени закрытия: `closed_at > close_at` это `mismatch:close_late`; у акции
   `closed_at < close_at` это `mismatch:close_early`. Дальше `closed_at >= open_at + min_duration_s·1000`
   (`mismatch:min_duration`); `latest_round >= R` или `close.t >= T_R` даёт `not_held:closed_after_round`. Отметка
   закрытия, одна находка: `A(close)` нет, значит, при `сейчас < T_R` `pending:close_not_anchored`, иначе
   `unwitnessed:close_not_anchored`; не "отмечено раньше T_R" это `unwitnessed:close_anchor_after_round`; досрочное
   закрытие (`closed_at < close_at`) не "отмечено не позже `closed_at + 10 000`" это
   `unwitnessed:close_early_not_witnessed`; иначе не "отмечено не позже `closed_at + 10 000`" это
   `warning:close_anchor_delay`.
9. Список. Листьев меньше N: `mismatch:list`, стоп. Корень первых N листьев равен `close.root` (`mismatch:list_root`).
   Каждый лист по §6.3.
10. Чекпоинты журнала розыгрыша по §8, одна находка `mismatch:draw_checkpoint` на чекпоинт; подпись любым ключом
    чекпоинтов розыгрыша; нет прошедшего проверки закрывающего чекпоинта, тоже `mismatch:draw_checkpoint`. Чекпоинт
    из канала с формой §8, логом `draw/<draw>` и подписью ключа чекпоинтов розыгрыша, которого нет среди прошедших
    проверку чекпоинтов цепочки: `mismatch:draw_checkpoint`. N < `min_participants`:
    `not_held:too_few_participants`. Розыгрыш остановлен по правилам, если есть `closed_after_round`,
    `too_few_participants` или засчитанная отмена (шаг 8): тогда находок об отсутствии раунда и раскрытия нет.
11. Раунд R. `drand.json` не `null`: номер не R, значит, `mismatch:drand_round`; подпись не верна,
    `mismatch:drand_signature`. Подпись R берётся из `drand.json`, иначе из `result.input`, иначе со своих зеркал,
    если она верна для R. Верной подписи нет, с начала проверки нет ни одного `mismatch` и розыгрыш не остановлен:
    если свои зеркала дали
    последний раунд L < R и `сейчас >= T_R + 86 400 000`, то `not_held:round_timeout`, иначе `pending:no_round`.
12. Раскрытие. Один вердикт, общий с шагом 13: лист `reveal` есть и обязательство не сходится: "обязательство";
    `reveal.t < T_R` или `A(reveal)` "отмечено раньше T_R": "рано"; `reveal.t > D`: "поздно"; `A(reveal)` есть:
    "вовремя", если "отмечено не позже D + TOL", иначе "поздно"; `A(reveal)` нет: при `сейчас <= D + TOL`
    "ожидание", иначе "поздно". Листа нет: при `сейчас <= D` "ожидание"; при доказанном к D + TOL отсутствии
    "поздно"; при `сейчас > D + TOL + GRACE` "поздно"; иначе "ожидание". Находки: "обязательство" это всегда
    `mismatch:reveal`; если розыгрыш не остановлен: "рано" это `mismatch:reveal_early`, "поздно" это
    `not_held:not_revealed` (листа нет и прошло GRACE без доказательства: `not_held:log_withheld`), "ожидание" это
    `pending:reveal_not_anchored` (лист есть), `pending:not_revealed_yet` (листа нет, `сейчас <= D`) или
    `pending:log_incomplete`. Секрет для формулы: из листа `reveal`, если обязательство сходится, иначе из
    `result.input`, если он сходится с обязательством.
13. Срывы (§17.3).
14. Если N = 0, есть `mismatch`, нет подписи R или нет секрета, стоп.
15. Формула §12 и итог §13: нет итога сервиса, значит, при `сейчас > D + 3 600 000` `warning:no_service_result`, иначе
    `pending:no_result`; итог есть: совпадает в канонической форме (`mismatch:result`), подписан ключом чекпоинтов
    розыгрыша (`mismatch:result_signature`). Лист `outcome`, если есть, несёт `result_hash` пересчитанного итога
    (`mismatch:outcome`). Формула проверяется при любых находках `not_held`, `unwitnessed` и `pending`: срыв не прячет
    подложный итог.

### 17.3. Срывы по правилам

Каждый лист `failure` этого розыгрыша после последнего `commit` получает не больше одной находки. Лист после `outcome`
это всегда `mismatch:failure_unfounded`. Остальные:

| `reason` | Находка |
|---|---|
| `cancelled` | `close` нет или `failure` раньше `close`: `not_held:failure_cancelled`; если `close` нет, а `failure.t > close_at + margin_s·500 + 10 000`, или (при `сейчас >= T_R`, с `close` или без) лист не "отмечен раньше T_R", это поздняя отмена `not_held:failure_cancelled_late` (сервис мог уже знать раунд и секрет, в счётчиках это его срыв); `failure` после `close`: `mismatch:failure_unfounded` |
| `key_revoked` | `close` нет или `failure` раньше `close`, и действующий ключ набора фиксации (K_receipt или K_checkpoint) отозван (по истории не позже отметки листа `failure` или поздним набором, опубликованным в отрезке раньше листа `failure`): `not_held:failure_key_revoked`, а если лист поздний (как у отмены), `not_held:failure_key_revoked_late`; иначе `mismatch:failure_unfounded` |
| `too_few_participants` | есть `close` и N < `min_participants`: `not_held:failure_too_few_participants`; иначе `mismatch:failure_unfounded` |
| `closed_after_round` | есть `close` и `latest_round >= R` или `close.t >= T_R`: `not_held:failure_closed_after_round`; иначе `mismatch:failure_unfounded` |
| `not_revealed` | вердикт шага 12 "поздно", "рано" или "обязательство": `not_held:failure_not_revealed`; "вовремя": `mismatch:failure_unfounded`; "ожидание": находки нет, срок ещё не определён |
| `round_timeout` | у проверяющего есть верная подпись R: оговорка `service_round_timeout` ("сервис не получил раунд"), на исход не влияет; подписи нет, свои зеркала дали L < R и часы вывода "раунд не вышел" (§17.1) не раньше `T_R + 86 400 000`: `not_held:failure_round_timeout`; иначе находки нет (шаг 11 уже пишет `pending:no_round`) |

Отмена после закрытия невозможна: после закрытия допустима только отметка "приз не вручён", которая на журнал
не влияет.

### 17.4. Четыре строки

Под статусом страница проверки и утилита показывают четыре строки. Они раскладывают находки §17.2 по смыслу,
а статус и коды от них не зависят.

| Строка | Ключ | Шаги | Коды |
|---|---|---|---|
| Условия те же, что до старта | `conditions` | 1, 2, 5, 7 | `conditions_schema`, `wrong_verifier`, `conditions_times`, `round`, `round_time`, `reveal_deadline`, `conditions_prizes`, `rules`, `rules_not_published`, `keyset`, `service_unknown`, `commit`, `commit_not_anchored`, `commit_not_witnessed`, `verifier_outdated` |
| Список тот же, что при закрытии | `list` | 3, 4, 6, 8, 9, 10 | `global_checkpoint`, `global_consistency`, `global_leaf`, `global_segment`, `clock_ahead`, `evidence_from_copies`, `anchor`, `anchor_unknown_log`, `anchor_incomplete`, `anchor_rollback`, `log_withheld`, `log_incomplete`, `duplicate_leaf`, `leaf_order`, `not_closed`, `close_late`, `close_early`, `min_duration`, `closed_after_round`, `close_not_anchored`, `close_anchor_after_round`, `close_early_not_witnessed`, `close_anchor_delay`, `list`, `list_root`, `invalid_leaf`, `excluded_leaf`, `draw_checkpoint`, `too_few_participants` |
| Число drand настоящее | `round` | 11, 12, 13 | `drand_round`, `drand_signature`, `no_round`, `round_timeout`, `reveal`, `reveal_early`, `not_revealed`, `not_revealed_yet`, `reveal_not_anchored`, `service_round_timeout` и все коды `failure_...` |
| Победитель посчитан верно | `winner` | 14, 15 | `result`, `result_signature`, `outcome`, `no_result`, `no_service_result` |

Строку задаёт код, а не шаг, на котором дана находка: `keyset` шагов 2, 5 и 7 идёт в `conditions`, `log_withheld`
шага 12 в `list`. Находка шага 0 несёт код файла (таблица шага 0) и попадает в строку этого кода. У `format` строки нет.
Ключ строки `round` и код находки `round` разные вещи: код `round` (номер раунда не по §11) идёт в строку `conditions`.

Состояние строки, по порядку:

1. у строки есть находки уровнем выше оговорки: самый высокий из них по приоритету §17.1;
2. иначе, если проверка дошла до конца строки: `match`. Оговорки строки показываются рядом и состояние не меняют.
   Проверка дошла до конца строки, если стопа не было или стоп случился на шаге с номером больше последнего шага
   строки (7 у `conditions`, 10 у `list`, 13 у `round`, 15 у `winner`). Номер стопа это номер шага §17.2, где сказано
   "стоп": стоп шага 8 ("затем срывы (шаг 13), стоп") это 8, стоп шага 14 это 14, стоп шага 0 и `format` это 0;
3. иначе `unchecked` ("не проверено"), если статус проверки `mismatch`, а при другом статусе сам статус проверки.

Слова: `match` совпало, `mismatch` не совпало, `not_held` не состоялся, `unwitnessed` нет внешней отметки до раунда,
`pending` ожидание, `unchecked` не проверено.

Следствие правила 2: у розыгрыша, отменённого до закрытия (стоп шага 8), строки `list`, `round` и `winner` без своих
находок показывают статус проверки, например "не состоялся", а не "совпало": до их последних шагов проверка не дошла
(векторы `failure_cancelled_instead_of_close`, `cancel_on_time_with_slack`). Так и должно быть, показывать там
"совпало" нельзя.

---

## 18. Файлы розыгрыша

Публикуются на `verify.<домен>/d/<draw>/`, хост без кук, CORS открыт. Все файлы это байты UTF-8 без BOM (§1.1).

| Файл | Содержимое |
|---|---|
| `index.html` | статус и время со слов сервиса, читается без JS; проверку делает скрипт страницы |
| `manifest.json` | `{"v":1,"type":"manifest","protocol":1,"draw":"<id>","files":[{"name","sha256"}]}`, справочный, не подписан |
| `conditions.json` | условия последней версии |
| `earlier_conditions.json` | массив условий версий 1..k−1 по порядку, если показана версия k > 1 (§3.4); нет, если версия одна |
| `rules.<расширение>` | документ правил акции, байты которого дают `rules_sha256` (только акции); такой файл ровно один, `manifest.json` называет его. Имя ничего не доказывает: решает сверка байтов с `rules_sha256`. Без `manifest.json` (локальная папка) документ это единственный файл `rules.*` набора |
| `keyset.json` | набор ключей из листа `commit` |
| `later_keysets.json` | массив наборов, опубликованных листами `keys` в отрезке после фиксации; нет, если смены ключей не было |
| `entries.jsonl` | список: каждая строка это ровно `JCS(лист)`, строки через `\n`, в конце `\n`; при N = 0 и пустом списке файл пустой (0 байт); некаконическая строка это отказ |
| `draw_checkpoints.json` | все чекпоинты журнала розыгрыша одной цепочкой (§8) |
| `global.json` | `{"leaves":[{"index","leaf","size","path"}], "segment":{"start","left","leaves"}, "checkpoints":[...], "consistency":[{"from","to","path"}], "anchors":[{"checkpoint_hash","rekor","channel"}]}` |
| `drand.json` | `{"round": R, "signature": "<hex>"}` или `null` |
| `result.json` | подписанный итог или `null` |
| `disputes.json` | справочный, не в манифесте и не заморожен: предъявленные квитанции розыгрыша, `[{"seq","outcome","presented_at"}]`, где `outcome` это `in_list` (запись на своём месте) или `missing` (квитанция подлинная, записи нет: нарушение сервиса). Проверка розыгрыша его не читает |

Поле `rekor` отметки это запись Rekor как в §10.2 (или `null`, если есть только пост), `channel` это
`{"chat","message_id","t"}` или `null`.

В векторах `13-rules.json` всё собрано в один объект `bundle` с полями `conditions`, `earlier_conditions`
(необязательно), `keyset`, `later_keysets` (необязательно), `entries` (массив листьев), `draw_checkpoints`, `global`,
`drand`, `result`, `rules_document` (у акций base64 документа правил или `null`; у других видов поля нет). В `16-files.json` файлы лежат байтами в base64 под их именами, документ правил
под именем `rules`.

Чтение по сети. Проверяющий запрашивает каждый файл, который называет `manifest.json`, и, кроме них, все имена этой
таблицы, даже не названные (манифест не подписан). Ответ 404 значит, что файла нет; любой другой отказ после повторов
это отказ прогона, а не находка: итога у такого прогона нет. Файлы читаются одним набором: если байты расходятся
с `manifest.json` (в том числе файл, который манифест называет, а сервер отвечает 404), набор вместе с манифестом
читается заново хотя бы один раз (публикатор меняет набор целиком); расхождение, которое держится, показывается
отдельно и статус не меняет.

Квитанции в файлах не публикуются: они у участников. Предъявительские коды передаются только во фрагменте URL
(`/r/<draw>/<seq>#<entry_id>`) или в теле POST, никогда в пути.

---

## 19. Версии

Номер протокола стоит в `v` каждого объекта и в имени формулы (`draw-v1`). Любое изменение форматов, хэшей, статусов
или формулы после первого публичного розыгрыша это новая версия и новый раздел этого документа. Розыгрыш проверяется
версией, с которой его зафиксировали; страница и утилита держат все версии. Ревизии до первого публичного розыгрыша
версию не меняют.

---

## 20. Тестовые векторы

Файлы `packages/protocol/vectors/*.json`, у каждого `group`, `protocol`, `description`, `cases`. Генератор
`scripts/make-vectors.ts` детерминирован, тесты проверяют, что файлы совпадают с ним байт в байт. Нормативны значения,
статусы и мультимножества кодов; тексты ошибок и порядок кодов нет. Дробные числа есть только в `11-convergence.json`,
они сравниваются с допуском 1e−9.

| Файл | Что проверяет |
|---|---|
| `01-jcs.json` | пример RFC 8785, порядок ключей UTF-16, условия с кириллицей, строгий разбор (`1.0`, `1e2`, повтор ключа в том числе после раскрытия `\u`, BOM, суррогат, несимволы, хвостовые пробелы), канонические строки `entries.jsonl` |
| `02-merkle.json` | хэши листьев и корни на опорных листьях transparency-dev, пути и доказательства RFC 6962 §2.1.3, отрезки с левой границей |
| `03-leaf.json` | листья `entry`, `spin`, `commit`, `close`; отказ на `entry_id` в листе, заглавный hex, не-ASCII и длинный `ref`, размер строкой, `closed_at` позже `t`, версия 2 без `prev_commit`, год 0000, 31 февраля, перевод строки в идентификаторе, неизвестный `reason` |
| `04-checkpoint.json` | подпись чекпоинта, `prev`, испорченный корень и подпись, R смешанного порядка, R равная единице, S + L, R с y >= p, две записи одного ключа, запись другого `alg`; пригодность ключа набора (малый и смешанный порядок, неканоническая кодировка) |
| `05-receipt.json` | квитанция с путём включения; seq вне дерева, чекпоинт не префикс, чужой `entry_id`, чужой ключ, запись за N |
| `06-drand.json` | живые раунды quicknet, испорченный байт, подпись соседнего раунда, короткая подпись |
| `07-round.json` | round(t), time(R), R для запаса 60 с и 5 мин, граница периода |
| `08-commitment.json` | обязательства секрета, соли, `entry_id`, кода приза; subject; отсутствие двусмысленности склейки |
| `09-draw.json` | одна запись, семь записей стрима с трассой, отклонённый блок, subject с двумя листьями, сектора (черновик колеса) |
| `10-anchor.json` | предлагаемая запись Rekor, тестовый Rekor, чужой чекпоинт, реальные записи публичного Rekor, первая отметка в Rekor стенда |
| `11-convergence.json` | критические значения хи-квадрат, честное и перекошенное колесо, мало данных |
| `12-decisions.json` | по случаю на решения (а)-(и) карточки Ф1 |
| `13-rules.json` | целые розыгрыши, по правилу на случай: статус и коды §17.2, в том числе ранние копии §7.5 (поддельная копия стороннего сервиса, продление до чекпоинта с ранним `t`, раунд из несогласной копии), передача набора ключей и вывод "раунд не вышел" с полом по отметкам |
| `14-schemas.json` | схемы условий: лишнее поле, чужая поверхность, другая сеть, запас меньше 30 с, резервные виды |
| `15-spin.json` | черновик поворота колеса |
| `16-files.json` | файлы байтами: шаг 0 (BOM, невалидный UTF-8, несимвол, перевод строки в идентификаторе, 31 февраля, год 0000, хвостовые пробелы, пропавший файл, пустой `entries.jsonl`, две версии с `earlier_conditions.json` и без него) и дальше §17 |

У случаев `13-rules.json` и `16-files.json` есть параметры проверяющего: `now`, `rekor_keys` (зашитые ключи Rekor),
`allow_test_keys` (только сборки стенда), `service_keys` (история ключей по `service` с `retired_at` и `revoked_at`;
`null` значит, что сборка не знает ни одного сервиса), `history_as_of` (на какое время составлена история или
`null`), `relays_latest` (последний раунд с проверенной подписью от своих зеркал или `null`, если ни одно
не ответило), `beacon` (подпись R со своих зеркал: `{"round": R, "signature": "<hex 48>"}` или `null`),
`channel_checkpoints` (чекпоинты розыгрыша из канала, массив), `evidence` (ранние копии файлов, массив объектов
`{"global", "later_keysets", "drand"}`, последние два необязательны). У случаев `16-files.json` эти поля есть все, с пустыми значениями, если не нужны.

Тестовые ключи и значения получены из публичных строк, любая подпись в векторах воспроизводима:

| Что | Как получено |
|---|---|
| K_receipt, K_checkpoint, второй K_checkpoint (смена ключа), чужой ключ | seed = SHA-256(UTF-8 "notarius/test-key/receipt"), "notarius/test-key/checkpoint", "notarius/test-key/checkpoint-2", "notarius/test-key/other" |
| тестовый Rekor (ECDSA P-256) | скаляр = SHA-256(UTF-8 "notarius/test-key/vector-rekor") как целое big-endian; имя заметки `vector-rekor - 1`; `logID` `1d7442fe773f82a57bbaee0babfffc81321a2d2ec1b0dbc08dd21dad42a94796` |
| секрет и соль розыгрыша D | SHA-256(UTF-8 "notarius/vector-secret/D"), SHA-256(UTF-8 "notarius/vector-salt/D") |
| `entry_id` и `claim_id` записи с номером seq | первые 16 байт SHA-256(UTF-8 "notarius/vector-entry/D/<seq>") и "notarius/vector-claim/D/<seq>" |

Тестовый Rekor подписывает SET и чекпоинт шарда так же, как настоящий. Подписи раундов в векторах настоящие,
с зеркал quicknet.

---

## 21. Чего проверка не ловит

Полный внутренний список с мерами: `docs/THREAT-MODEL.md`. Публично:

- журнал доказывает, что записанное не меняли, но сам не доказывает, что записали всех: площадка может не отправить
  часть событий;
- квитанция доказывает, что запись принята, но не что она ваша;
- одна запись на аккаунт площадки, а не на человека;
- раздвоение журнала, при котором каждая ветка отмечена в Rekor, и откат (обрезанный отрезок со свежей отметкой)
  файлы одного розыгрыша не покажут; их ловит монитор всех записей с ключом сервиса, задним числом, пока нет свидетеля
  ко-подписи; сохранённая копия файлов с прежним статусом опровергает новый (у старой сборки с оговоркой следующего
  пункта);
- сервис может позже выложить файлы без части отметок или с обрезанным отрезком: без сохранённой копии статус
  станет хуже, с копией проверка вернёт прежний и покажет оговорку "доказательства из ранних копий"; старая сборка
  проверки, которая не знает ключа, введённого после закрытия, копией с этим ключом не воспользуется и покажет то,
  что дают текущие файлы: нужно обновить проверку (§7.5);
- статус "нет внешней отметки до раунда": сервис мог дописать записи после выхода раунда; сверьте 16 символов корня
  с записью эфира;
- сервис может не раскрыть секрет: это срыв, он виден всем и считается;
- если код приза сгенерировал сервис, а не участник, сервис этот код знает; вручение приза сервис не доказывает;
- страница проверки отдаётся сервисом; независимая проверка это утилита и зеркало у площадки;
- исход может знать заранее только тот, кто одновременно управляет сервисом и большинством узлов drand.
