23 KiB
merkle_sync — протокол 4
Сверка merkle_sync.h и этого документа с реализацией завершена 26.10.2026. Проверены общий движок, адаптер MEMBERS, зависимые состояния FEEDBACK и перенос истории MESSAGES; найденные дефекты исправлены. Остальные модули чата проверяются отдельно. Объём, результаты и ограничения: отчёт, разделы 6, 9 и 13.
Разделение ответственности
- merkle_tree: производный индекс SQLite, хеши листьев и родителей.
- merkle_sync: обмен, backpressure и жизненный цикл сессий.
- member_sync: формат участников, подписи, версии, применение страниц.
- chat_feedback: подписанные состояния реакций/ответов, ver, отмены и применение страниц.
- db_sync: подписанная история MESSAGES, первоначальная проверка SQLite, страницы и PUSH/ACK.
Движок не знает CHAT-групп. Namespace — каноническая десятичная строка uint64_t.
Все операции выполняются в одном потоке uasync экземпляра.
Сессия определяется тройкой (peer, dataset, namespace). MEMBERS=0, FEEDBACK=1, MESSAGES=2.
В индексе SQLite namespace хранится как dataset:channel_id; корни наборов независимы.
Дерево и транзакции
Корень имеет уровень 0. Уровни 1–5 разбивают старшие 25 бит ключа на группы по 5 бит.
Полный ключ курсора — 32 байта с лексикографическим порядком; ветвь задаётся первыми 8 байтами.
Для участника ключ — BE64(node_id) и 24 нулевых байта. Для feedback — SHA256 идентичности записи, для истории — message_id.
Совпадение первых 64 бит не объединяет записи и не пропускает их при пагинации.
SHA256 листа: 0x4d || level:1 || item_hashes, записи упорядочены по unsigned key.
SHA256 родителя: 0x4d || level:1 || child_hash[0] || ... || child_hash[31].
Хеш пустого поддерева — 32 нулевых байта; строка индекса для него отсутствует.
Пересчитывается один лист и его предки, до первого неизменившегося хеша.
Индекс создаётся заново при init и заполняется моделью из исходных записей. Модель меняет данные и вызывает recompute_path в одной транзакции, затем changed после commit. Без внешней транзакции recompute_path создаёт savepoint и уведомляет самостоятельно. Ошибки не заменяются пустыми страницами или нулевыми хешами.
API
init принимает service и data_ops начальной модели MEMBERS. register добавляет другие наборы. Каждая модель задаёт update_bucket_hash, get_page и способ применения страницы, необязательные validate_peer, can_run, on_result и finish. update_bucket_hash добавляет хеши одного листа в переданный SHA256-контекст. get_page получает ёмкость буфера и курсор; возвращает размер, последний ключ и флаг продолжения. apply_items проверяет весь формат и применяет страницу вместе с индексом атомарно. Вместо apply_items можно задать apply_page: он также получает prefix, after, next и more и проверяет принадлежность фактических записей запрошенному листу/диапазону. validate_peer разрешает обмен только при возврате 1; проверяется и перед отправкой. can_run задаёт политику работы модели; без него peer_sleep_phase откладывает обмен. История разрешает sound_on для спящего пира. recheck_peer завершает запрещённые попытки, освобождая пакет/waiter/таймер и сохраняя подписку для последующего start/changed. on_result получает результаты всех раундов, включая автоматические, до пользовательских callbacks; он может отменить сессию. Указатели и список пользовательских callbacks захватываются заранее. finish проверяет отложенные зависимости модели перед подтверждением корня.
Вместо непосредственного apply_items модель может задать все четыре callback: begin_pull, stage_page, commit_pull, abort_pull. begin_pull создаёт ненулевой контекст; stage_page получает также prefix и оба курсора страницы и накапливает данные. commit_pull публикует результат после завершения своего прохода, до TURN/CHECK. abort_pull освобождает контекст как после commit, так и при restart/cancel/ошибке. В этом режиме apply_items необязателен; атомарность публикации обеспечивает модель.
start подключает (dataset, namespace) к текущему соединению и запрашивает контрольную точку. Повторные start объединяются, обход не сбрасывается, каждый принятый callback вызывается один раз. После подключения изменения синхронизируются автоматически. Broadcast/send-back не требуются. MT_OK означает совпадение корней конкретного раунда, а не отсутствие будущих изменений.
cancel отменяет только заданный dataset/peer/namespace. cancel_ns отменяет все datasets канала, cancel_peer — все datasets пира. cancel_dataset_ns/cancel_dataset_peer ограничивают отмену выбранным набором. Все cancel и destroy освобождают запросы без callback. DOWN/DELETE/REINIT завершают запросы ошибкой и освобождают сессию. После восстановления соединения интеграционный слой повторяет start. Callback может отменить сессию; уничтожение всего экземпляра следует отложить через call_soon.
Обмен
Меньший node_id ведёт раунд; второй запрашивает его через WAKE. WAKE также перезапускает незавершённый раунд после cancel/start второго узла.
- BEGIN(new round) → READY.
- Ведущий запрашивает хеши детей и обходит различающиеся ветви в глубину.
- Лист запрашивается страницами с возрастающим курсором.
- TURN меняет направление: второй узел выполняет такой же pull.
- Второй проверяет finish и отправляет CHECK(root).
- Ведущий сверяет корень, проверяет finish и отправляет DONE(root).
- Второй сверяет DONE со своим текущим корнем.
Ведущий сообщает MT_OK после передачи DONE в надёжный транспорт, если локальная ревизия не изменилась после проверки. Второй сообщает MT_OK после проверки полученного DONE. Изменения во время обмена вызывают следующий раунд. Два завершённых раунда с одной и той же несовпадающей парой корней дают MT_ERR_CONFLICT. Детерминированное слияние обеспечивает модель; движок не выбирает победителя конфликта.
Ожидается только один ответ QUERY. Стек — максимум шесть кадров с сохранёнными соседними ветвями. Номера запросов проверяются, ответы другого раунда игнорируются. Повторную передачу обеспечивает ETCP, отдельных таймеров повторов нет. На исходящий пакет (включая ожидание backpressure) и последующее ожидание ответа взводится таймер 30 секунд; постановка следующего пакета заменяет таймер. Истечение завершает ожидания MT_ERR_TIMEOUT; по одному таймеру на активную сессию. Периодического bg_check и отдельного WAL-таймера в текущем merkle_sync нет.
При отзыве допуска модели revalidate_peers завершает ожидания этого пира MT_ERR_DATA, освобождает подготовленный пакет/таймер/waiter, но сохраняет подписку в FAILED. После восстановления changed запускает новый раунд: ждать ответа на ранее отброшенный пакет не нужно. Вызовы выполняются после commit; done callback может отменить сессию без use-after-free.
Wire
Заголовок v4: service:1 | namespace:8 | type:1 | round:8 | request:4 | dataset:1 | round_id:16 | accept_id:16 (55 байт).
round — диагностический счётчик. Свежий round_id создаёт инициатор BEGIN, свежий accept_id добавляет ответчик READY.
Все дальнейшие пакеты проверяют оба идентификатора, номер запроса и фазу. После cancel/start старый BEGIN может получить
новый challenge, но прежние PAGE/DONE/ERROR не соответствуют новому accept_id. Дубликат BEGIN не меняет текущий challenge.
WAKE содержит нулевые идентификаторы, BEGIN — нулевой accept_id. Старый wire не поддерживается.
Ошибка генератора случайных байтов завершает запрос MT_ERR_IO до установки нового раунда.
Дубликат READY с тем же accept_id не перезапускает текущий обход.
Все целые big-endian. Максимальный пакет вместе с заголовком — 16384 байта.
Типы 0x10–0x19: WAKE, BEGIN, READY, QUERY, HASHES, PAGE, TURN, CHECK, DONE, ERROR.
| Тип | Payload |
|---|---|
| WAKE, BEGIN, READY, TURN | пустой; request=0; у WAKE также round=0 |
| QUERY | level:1, prefix:8, after_valid:1, after:32 |
| HASHES | 32 хеша детей по 32 байта в порядке индексов |
| PAGE | more:1, next:32, model_data |
| CHECK, DONE | корень 32 байта; request=0 |
| ERROR | положительный код ошибки 4 байта; request=0 |
Курсор продолжения должен возрастать и принадлежать запрошенному листу. Длины, состояние, тип, префикс и номер запроса проверяются до применения. Формат model_data определяет адаптер. Старый протокол не поддерживается.
Очереди, диагностика, тесты
Сессия удерживает ETCP_CONN и исходную очередь. У неё один подготовленный пакет, один waiter и один отложенный вызов. Передача идёт только после разрешения waiter. Изменения объединяются флагом dirty; неограниченной out-очереди нет. Отмена снимает ожидающий и отложенный callback waiter независимо от links_up.
Категория member_sync: INFO — attach, начало/завершение раунда, detach; DEBUG — TX/RX, номера запросов, размеры, состояние; ERROR — причина отказа. Ведущий выводит счётчики переданных и полученных байтов при подтверждении.
test_merkle_sync проверяет индекс, односторонний start, двустороннее слияние, несколько узлов, параллельные изменения, большой лист с пагинацией и автоматическое обновление. test_merkle_protocol проверяет ошибочные пакеты/курсоры, сохранение соседних ветвей, отмену waiter, ошибки apply/SQLite, backpressure, disconnect и повторное подключение, изоляцию datasets и пагинацию разных ключей с одинаковым 64-битным префиксом. Дополнительные регрессии: cancel/start и старые PAGE/DONE/ERROR, дубликаты BEGIN/READY, отказ RNG обеих сторон, политика спящего пира и восстановление допуска на прежнем соединении. История и её ограниченные транзакции описаны в db_sync_arch.md. test_chat_feedback проверяет подписи, монотонный ver, отмены, старые записи, конфликты одного ver, атомарность страниц, восстановление дерева и удаление канала.
Подписанные состояния feedback
Таблица feedback_ хранит message_id, node_id, kind, ver, data и signature.
Идентичность записи — (message_id, node_id, kind); kind=1 — reaction, kind=2 — answer.
ver положителен, максимум INT64_MAX. Меньшая версия игнорируется.
При одинаковом ver выбирается лексикографически большее data; конфликт логируется.
Пустое data — сохраняемая отмена, её нельзя удалять TTL-очисткой.
Подпись покрывает domain utun-chat-feedback-v1, BE64(channel_id) и wire-тело без подписи.
Wire-запись: message_id:32, node_id:8, kind:1, ver:8, data_len:2, data, signature:64.
Страница: count:2 и count записей; все числа big-endian, data максимум 2048 байт.
Хеш листа включает канонические тела без signature: разные корректные подписи одного тела
не создают расхождения корней. Подписи проверяются перед включением записи в активный набор.
Членство проверяется через подписи дерева приглашений и join, привязывающий Ed25519 к узлу.
Для init/hash/page используется общий отбор: только author_key=ACTIVE, с повторной проверкой
подписанного состояния и его record_key/bucket. INACTIVE/PENDING/INVALID не включаются в набор.
SQLite/OpenSSL-ошибки не кодируются пустым листом или отсутствующим автором.
Исключённый/отсутствующий автор и ожидающий ключ сохраняются в БД с прежними ver и отменами,
не блокируют startup, но не входят в корень, выдачу и результаты. После возврата они проверяются заново.
Новые состояния INACTIVE/PENDING не сохраняются; такая запись устаревшей страницы
не мешает применению остальных корректных записей. Локальное действие требует ACTIVE даже при no-op.
Изменение удостоверения/deleted/общего ключа атомарно пересчитывает feedback автора и записей, зависящих от его ключа как signed_by, во всех каналах. Отложенный ключ не блокирует пачку MEMBERS; его поступление, включая другой канал, включает ранее сохранённые допустимые состояния. Отдельного сохраняемого флага активности нет: проверка принадлежит member_sync. После внешнего commit обновляются сессии и адресные FEEDBACK_CHANGED; профиль без изменения удостоверения/допуска не требует такого пересчёта и уведомлений. Формат не содержит подписанной границы отзыва: после восстановления применяются обычные ver/data, без утверждения, что подпись была создана до исключения. Слой feedback хранит подписанные байты независимо от наличия исходного сообщения; семантику выбранных пунктов и эмодзи проверяет вызывающий модуль до учёта результата.
Опросы и реакции
chat_poll_codec задаёт общий каталог 16 эмодзи и канонические кодеки.
Реакция: count=1 и номер эмодзи; автор выбирает только один смайлик на сообщение.
Выбор другого смайлика заменяет предыдущий с новым ver; повторное нажатие отменяет реакцию.
Ответ: count:1, отсортированные уникальные номера пунктов, custom_len:2, UTF-8 текст.
Пустой набор/ответ кодируется нулевой длиной. Собственный текст — максимум 1024 байта.
Структурно неверные состояния не применяются; семантически неверные подписанные ответы
сохраняются, но исключаются из подсчёта, чтобы порядок получения опроса не менял Merkle-корень.
Опрос — подписанное сообщение content_type=poll, data=b64: + URL-safe base64.
Wire v1: version:1=1, mode:1 (single=1/multiple=2), allow_custom_answer:1, option_count:1,
затем вопрос и варианты как length:2 + UTF-8. Числа big-endian, весь poll максимум 2048 байт.
Вопрос — максимум 512 байт, 2–32 разных непустых варианта по 256 байт.
Порядок вариантов неизменяем; номер варианта — индекс с нуля.
single разрешает один вариант либо собственный текст, multiple — варианты и необязательный текст.
Изменение ответа заменяет его целиком с ver+1; нулевой ответ отменяет голос.
message_id сохраняется в msg_ с UNIQUE-индексом:
SHA256(utun-chat-message-v2 || BE64(channel_id) || BE64(timestamp) || BE64(author) || nonce[16]).
ID не меняется при правке текста; MESSAGES заменяет запись только при большем ver.
Конверт и подпись версии описаны в db_sync_arch.md.
Этот ID одинаков на всех узлах и возвращается API сообщений и результатом публикации опроса.
Получение исходного сообщения повторно проверяет отложенные ответы. Пользовательские результаты
вычисляются по последним допустимым непустым состояниям; проценты — от числа ответивших.
При нескольких вариантах сумма процентов может превышать 100%.
Команды poll_create, vote, react, feedback, reaction_catalog разбираются единым
chat_feedback_command в потоке ядра. Desktop и Android получают адресные события FEEDBACK_CHANGED
и коррелированные результаты FEEDBACK_RESULT; GUI-поток не пишет подписанные данные.
При старте чата запускается member_sync по известным онлайн-пирам, затем feedback sync:
это нужно и при restart поверх сохранившегося UP-транспорта, когда нового conn_up не будет.
test_chat_poll проверяет пользовательский API, кодеки, ограничения, отмены и отложенный опрос. test_chat_poll_network проверяет A ↔ B ↔ C, Unicode-ответ, реакцию, остановку/запуск C, исключение A, позднее подключение D без неактивной истории и возврат A. После возврата D получает сохранённые состояния, A передаёт новую отмену через прежний UP-транспорт; результаты и корни активного набора сходятся. test_chat_feedback проверяет также purge, межканальную зависимость ключа, отсутствие уведомлений от профиля и rollback SQL/index/commit.