# DM: доставка сообщений и файлов Личные беседы принадлежат `src/dm/`. Общие CHAT-группы используются для знакомства, проверки ролей и маршрутизации. Подгруппа личных устройств — отдельная идентичность, не CHAT-канал. Согласованные требования: [dm_devgroup_plan.txt](dm_devgroup_plan.txt). ## Реализовано - История и постоянная исходящая очередь сообщений в общей SQLite ядра. - Доставка через живой маршрут router, включая BGP-транзит. - При отсутствии маршрута или ACK в течение 5 секунд — PUT суперузлу общей группы сторон. - Приём после проверки подписи и расшифровки, подтверждение только после commit. - Доставка с суперузла после возвращения получателя, независимо от отправителя. - Точное удаление промежуточного сообщения и сохранение квитанции для поздних повторов. - Подготовка, шифрование, проверка и durable публикация медиа в рабочих потоках. - Прямая передача файлов либо custody на отдельном M, доставка после restart и подтверждённое удаление. - TTL и квота M; вложения и статусы доставки в Qt и Android. - Голосовые Opus и видео MP4 с зашифрованными метаданными; общий ввод и отображение с каналами. ## Идентичность и шифрование Два режима: DEVICE закрепляет конкретные два устройства, GROUPS закрепляет подгруппу каждой стороны независимо, с fallback на устройство при отсутствии подгруппы. Смена состава/профиля не переключает существующую беседу в другой режим. Действие на заголовке подгруппы открывает GROUPS; на устройстве — DEVICE. Если своя подгруппа есть, а у выбранного устройства её нет, UI предлагает оба варианта. DEVICE между собственными устройствами сохраняется. Сторона кодируется как `kind:1,node:LE64,ed:32,x:32`. Подгруппа имеет node=0; её ключи и удостоверения устройств проверяются по подписанным devgroup-записям. GROUPS ID — хеш домена и отсортированных полных сторон; DEVICE сохраняет прежний ID: ```text conv_hash = SHA256("utun_dm_v" || min(node_a,node_b) || max(node_a,node_b)) conv_id = native_u64(conv_hash[0:8]) & 0x7FFFFFFFFFFFFFFF content_key = SHA256(X25519(my_private, target_public) || "utun_dm_content") msg_nonce = SHA256(conv_id || author || seq)[..13] media_nonce = SHA256("utun_dm_media" || author || media_id || part)[..13] ``` Префикс conv_hash — 9 ASCII-байт без NUL. ID узлов подаются в хеш в порядке байтов хоста; native_u64 читает первые 8 байт хеша в том же порядке. Нулевой conv_id заменяется на 1. Для текста ключ выводится из X25519 закреплённых сторон. Для каждого нового файла создаётся собственная пара X25519 F. HPKE export-only X25519/HKDF-SHA256 выводит файловый AES-ключ; F_private независимо упакован HPKE AES-256-GCM для каждой стороны. `DM_FILE_KEYS_SIZE=224`: F_public32, file_enc32, wrap_sender80, wrap_recipient80. Info привязывает ключи к полным сторонам, conv, автору, UUID и назначению упаковки. В metadata сообщения: `DMKEY001:8`, блок ключей224, затем attachment_info. Обе упаковки защищены подписью сообщения; серверы не получают секретов. Все источники передают один и тот же ciphertext. Старые сохранённые DEVICE-файлы без блока ключей читаются прежним content_key; новые создаются с отдельной парой F. AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная порция plaintext — 65535 байт; порция медиа — 32768 байт. UUID медиа новый для каждой подготовки. Повтор доставки использует неизменяемый ciphertext. Повторная подготовка после ошибки требует нового UUID. Каноническое тело сообщения: ```text [conv:8][seq:8][ts:8][author:8][ct_len:1][ct][enc_len:2][ciphertext+tag][author_sig:64] ``` Автор подписывает все поля до sig. conv связывает автора с получателем. В GROUPS старший бит enc_len установлен: после enc_len идёт контекст `version:1,party_sender:73,party_recipient:73,count:LE16,targets[count]:137`. Target: `node:LE64,side:1,x:32,ed:32,member_sig:64`; список сортируется по node. Низшие 15 бит enc_len задают длину ciphertext. Нулевой ct_len остаётся допустимым для сохранённого DEVICE-тела. Единственный parser находится в dm_protocol. Каждое сообщение фиксирует все известные источнику проверенные устройства обеих сторон, включая офлайн и собственные. Максимум128; превышение отклоняет всю отправку. Новые найденные устройства не получают старую историю автоматически. Своя реплика показывается исходящей, но не создаёт новую очередь рассылки. Для `ct=file` перед подписью добавляется descriptor `[UUID:16][plaintext_size:8][SHA256(ciphertext):32]`. В зашифрованном теле находится `attachment_info`; пути хранения строятся только из UUID. Размер data в API отправки ограничен 1024 байтами; для file сюда входит открытый descriptor (56 байт), поэтому на зашифрованные метаданные остаётся до 968 байт. Парсер входящего wire допускает до 1024 байт ciphertext plaintext отдельно от descriptor. seq — положительное int64, ts — миллисекунды NTP-времени instance. ```text [kind:1][name_len:2][duration_ms:4][width:2][height:2][UTF8_name:name_len][waveform:100, только voice] ``` Поля метаданных многобайтной длины имеют big-endian формат. kind: 1=file, 2=voice, 3=video, 4=JPEG, 5=PNG. Имя — basename до 255 байт, без разделителей, управляющих символов и некорректного UTF-8. FILE имеет нулевые duration/dimensions; VOICE — положительную длительность и 100 символов уровней waveform; VIDEO — длительность и размеры. JPEG/PNG имеют размеры с учётом EXIF, нулевую длительность и пустой waveform; MIME `image/jpeg`/`image/png` определяется kind и не зависит от имени файла. Desktop и Android проверяют формат и размеры средствами своего декодера до постановки в `attachment_send`; исходные байты изображения сохраняются без перекодирования. В сообщении показаны превью, разрешение и размер, нажатие открывает просмотр с масштабированием. Длительность ограничена 24 часами, размеры — 16384. Метаданные проверяются до локального commit и до подтверждения получателем; S/M видят только ciphertext. Имя собеседника ограничено `DM_PEER_NAME_MAX` (63 байта без NUL): создание, загрузка и снимок бесед сохраняют только целые UTF-8 code points. Для 32 букв «Я» сохраняется 31 буква (62 байта). При повреждённой кодировке, включая NUL внутри SQL TEXT, остаётся корректный префикс; WARN содержит conv_id, смещение и длину. Снимок нормализует также имя из nodes при пустом peer_name. Чтение не меняет БД; при следующей штатной записи беседы нормализованное имя сохраняется вместе с ней. Подробности исправления: [dm_audit_findings.txt](dm_audit_findings.txt), раздел9. Медиа ciphertext — последовательность порций с отдельным tag каждой порции. Последняя порция короче; пустой файл содержит один tag. Размер plaintext и SHA256 ciphertext должны входить в подписанный manifest. Потоковая расшифровка проверяет все tags, точный размер и хеш; владелец публикует временный файл только после успеха и собственного durable сохранения. ## Владение и данные - `dm_core`: беседы, история, outbox и подтверждение получателя. - `dm_history`: поколения, hidden, точные отпечатки удалённых сообщений и манифест локальных путей. - `dm_mailbox`: промежуточные сообщения суперузла и квитанции. - `dm_crypto`: вывод conv_id, content_key и nonce сообщений без БД/сети. - `media_crypto`: общие CCM и потоковые файловые примитивы для DM и групповых вложений. - `dm_media`: файлы endpoint, загрузки и отдельная custody M. - `dm_mailbox_media`: durable задания S, выбор M и повтор удаления до DELETE_ACK. - `file_transfer`: подписанная передача байтов, backpressure и отдельный CM handle каждой операции. - `attachment`: формат, валидация и тип метаданных, без сети и БД. - `voice_file`: общий Opus-кодек отправки; `attachment_send` готовит файл в worker и передаёт результат отдельно в `chat_core` либо `dm_media`. ```text dm_conversations(conv_id, peer_node_id, peer_x25519, peer_ed25519, peer_name, group_id, last_out_seq, last_in_seq, created_at, last_ts, mode, local_party, peer_party, local_key, closed) dm_messages(conv_id, author, seq, dir, ts, ct, data, sig, body) dm_outbox(conv_id, seq, body, retry_at) dm_delivery(conv_id, author, seq, recipient, receipt, first_attempt, direct_until, retry_at, supernode) dm_sequence(node_id, value) dm_pending_messages(recipient, sender, seq, msg, retry_at) dm_mail_receipts(recipient, sender, seq, receipt) dm_history(conv_id, generation, hidden) dm_deleted_messages(conv_id, author, seq, body_hash, media_id, media_receipt) dm_local_files(path, conv_id, generation, busy, retry_at) ``` Запись исходящего сообщения, увеличение seq и запись неизменяемого body в outbox — одна транзакция. При ошибке откатывается всё, сообщение не выходит в сеть. Приём, создание/обновление беседы и история — также одна транзакция. Dedup — точный ключ (conv,author,seq) с проверкой полного подписанного тела. last_in_seq — статистика максимального номера, не курсор подтверждения: seq=99 после seq=100 сохраняется. Тело с другой подписью для уже сохранённого seq отклоняется. Локальное удаление (`dm_history_delete`) сохраняет запись беседы, ключи и счётчики, скрывает её (`dm_history.hidden`) и увеличивает постоянное `generation`. В одной транзакции удаляются история, outbox и локальные media-записи, а SHA256 точных удалённых тел сохраняется в `dm_deleted_messages` по `(conv_id,author,seq)`. Файловая квитанция сохраняется только если она уже была получена; удаление само её не создаёт. После commit отменяются незавершённые операции; физическая уборка файлов выполняется отдельно. Это локальная операция: история адресата и промежуточные копии ею не отзываются. Точный входящий удалённый повтор получает квитанцию сообщения без повторного создания истории/файла и без открытия скрытой беседы. Иное тело с тем же ключом удалённой записи отклоняется. Впервые доставленное сообщение, включая меньший seq, открывает беседу; явный `dm_start` также снимает hidden без смены поколения. Отложенные отправки проверяют generation, чтобы старый запрос не попал в повторно открытую беседу. Счётчик автора общий для его бесед: mailbox `(recipient,author,seq)` остаётся однозначным, когда те же устройства одновременно используют DEVICE и GROUPS. Каждый адресат подтверждается отдельно; outbox удаляется только после всех ACK. Выход из подгруппы атомарно закрывает старые GROUPS-беседы и удаляет исходящие очереди. Старый групповой секрет не архивируется. Уже сохранённая история читается ключом беседы, упакованным для собственного устройства; скачанные файлы остаются. Новые приём/отправка/скачивание закрыты, незавершённые workers не публикуют результат. Сам выход не ротирует ключи остальных устройств; это не криптографический отзыв секрета у скомпрометированного устройства. Загрузка беседы различает `FOUND`, `NOT_FOUND` и `ERROR`: отсутствие означает только успешно завершённый SELECT без строки. Ошибка SQL, повреждённые ключи или отказ X25519 прекращают открытие/приём/отправку/приглашение без пересоздания беседы и сброса счётчиков. Полный снимок выдаётся только после успешной загрузки. Исправление и отказные проверки описаны в [dm_audit_findings.txt](dm_audit_findings.txt). Таймеры работают раз в 1с: outbox A и текстовая очередь S обрабатывают до 32 задач; локальные файлы endpoint, custody M и media jobs S — до 8 задач каждый. Обычный retry_at — через 5с; файлы повторяют неподтверждённые факты независимо от текста, неудачная уборка локального пути повторяется через 30с. Это время допуска следующей попытки, не гарантия доставки за указанный срок. Перед отправкой проверяется backpressure router. Потеря пути сохраняет durable задачу; stop отменяет таймеры, но сохраняет БД. При старте медиа сбрасывает retry_at/downloading; текстовые очереди используют сохранённый retry_at. Все DM API вызываются в uasync-потоке. Порядок запуска в `chat_service_start`: mailbox (включая media coordinator), core/history, media/file_transfer; к этому времени SQLite, router и media_async уже живы. Порядок остановки: media_quiesce прекращает новые задачи и закрывает transfer/CM handles; media_async_destroy завершает workers и callbacks с живыми owners; затем уничтожаются media, core и mailbox, после них группы/чат. SQLite принадлежит ядру и остановкой DM не закрывается. `dm_media_destroy` при живой работе логирует ошибку и оставляет owner; заменять drain таким вызовом нельзя. ## Подтверждения и протокол Сервис `ETCP_RT_ID_DM=0x34`: MSG=1, RECEIPT=2. ```text ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(message):32][recipient_sig:64] ``` Квитанцию создаёт только получатель после commit. Подпись охватывает первые 72 байта. GROUPS использует домен `DMACKG01`; получатель обязан быть в подписанном snapshot. Отправитель удаляет outbox только после проверки подписи и точного хеша тела. История беседы сохраняется. Подтверждение транспорта router не заменяет квитанцию. Сервис `ETCP_RT_ID_DM_MAILBOX=0x35`: ```text PUT=1 [recipient:8][message] DELIVER=2 [message] RECEIPT=3 [receipt:136] STORED=4 [conv:8][seq:8][recipient:8] MEDIA_REQUEST=5 [recipient:8][file-message] ``` Все пакеты идут с подписью router; unsigned control отклоняется. PUT принимается только от автора, при роли supernode=yes у локального узла в допущенной группе. Маршрут A/S и маршрут S/B выбираются независимо; роль S проверяется среди допущенных общих групп проверяющей стороны и S. Роль берётся из подписанного adm_tags; node_type=4 — производная сетевой классификации, для авторизации не используется. storage=yes само по себе не даёт права хранить сообщения. STORED подтверждает durable запись суперузлом, outbox до квитанции получателя остаётся у автора. Суперузел повторяет DELIVER при появлении живого маршрута. RECEIPT проверяется по точному сохранённому телу; запись квитанции и удаление копии — одна транзакция. Квитанция пересылается автору. Поздний PUT возвращает её и не возрождает задачу. dm_mail_receipts содержит только служебные квитанции; доставленное содержимое с промежуточного узла удаляется. Ранее использовавшийся dm_mail/PULL/HELLO и диапазонные ACK не поддерживаются: это devel, совместимость не сохраняется. TTL текстовых сообщений не введён; недельный TTL из pm.txt относится к медиа. MEDIA_REQUEST независимо регистрирует файловое задание; PUT занимается только текстом. ## Доставка медиа по pm.txt 1. Общая передача байтов: явные peer/group/file/size, backpressure и CM handle на всю активную операцию. Владение handle сохраняется после DOWN/TIMEOUT. Используется `ETCP_RT_ID_FILE_TRANSFER=0x37`. Один callback читает/отправляет не более 8 КиБ, продолжение через `call_soon`; inflight ограничен 16 пакетами. 2. Отдельный owner dm_media: подготовка ciphertext, manifest, отправка/скачивание, временные файлы, TTL и лимит хранения. Каталоги dm_media/ и dm_pending/ вне группового обхода очистки orphan-файлов; групповые объявления HAVE_BLOCK не используются. 3. Автор сначала предлагает файл напрямую всем адресатам. Если через 5с остаются неподтверждённые устройства, включая свои, отдельно заказывает хранение у S. Автозагрузка ограничена storage_autoload и storage_unit_size; ручная команда снимает ограничение автозагрузки для конкретного файла. S фиксирует media job и выбирает M. M скачивает ciphertext у A и сообщает S подтверждённое хранение, место и expiry. 4. FILE_RECEIVED подтверждает полную проверенную загрузку, TAKEN — только явное успешное открытие пользователем. Превью, экспорт и автозагрузка не дают TAKEN. S хранит задачу удаления у M до DELETE_ACK либо завершения подтверждённого TTL назначения; поздний STORED не возрождает доставку. 5. TTL медиа `dm_media_ttl_sec` по умолчанию 604800 с; квота `dm_media_storage_mb` по умолчанию 1024 МиБ. Настройки на M. Новая custody отклоняется при недостатке квоты; уже подтверждённые копии не вытесняются молча раньше срока. 6. Stop снимает новые задачи и закрывает transfer/CM handles, затем ждёт media_async с живыми owner-контекстами. Группы уничтожаются после owners. При проверке квоты M ошибки SQL чтения занятого места логируются и прекращают обработку STORE без `REJECT` и регистрации custody. Назначение M сохраняется на S; следующий STORE приходит через существующий таймер повторов. Квота сравнивается только после получения строки SUM и успешного finalize. Реальный недостаток места вызывает сохранённый `REJECT` для этого assignment_id. Через 60с S пробует другой M, а при отсутствии нового кандидата — прежний M с новым assignment_id. Исправление и регрессии: [dm_audit_findings.txt](dm_audit_findings.txt), раздел6. Освобождение назначения на S по EXPIRED/REJECT и сброс текущего holder выполняются в одной транзакции. Ошибка prepare/bind/step/finalize/commit откатывает оба изменения. Подтверждённое назначение завершается также таймером по сохранённому expires: потерянный EXPIRED и offline B не оставляют stored/holder навсегда. После restart таймер берёт те же durable факты. Если STORE ещё не подтверждён, S продолжает повторять его; M возвращает STORED/EXPIRED из custody/tombstone. Квитанция B сохраняет приоритет над TTL; старый control другого M не сбрасывает новое текущее назначение. Текстовая история, подписи и сроки хранения M этим переходом не меняются. Подробности и регрессии: [dm_audit_findings.txt](dm_audit_findings.txt), раздел11. Control `ETCP_RT_ID_DM_MEDIA=0x38`: перед body идёт один байт команды. Все многобайтные поля DM/control имеют native-endian порядок; исключение — зашифрованный attachment_info с big-endian полями, описанный выше. | Команда | Body | | --- | --- | | OFFER=1 | recipient:8, holder:8, expires:8, file-message | | STORE=2 | assignment_id:16, recipient:8, file-message | | STORED=3 | assignment_id:16, UUID:16, expires:8, delete_on_open:1 | | RECEIPT=4 | файловая квитанция:152 | | DELETE=5 | assignment_id:16, файловая квитанция:152 | | DELETE_ACK=6 / EXPIRED=7 | assignment_id:16, UUID:16 | | REJECT=8 | assignment_id:16, UUID:16, reason:1 (1 — недостаток квоты) | | RESERVED=9 | assignment_id:16, UUID:16, expires:8, delete_on_open:1; место принято, ciphertext ещё не готов | | HAVE=10 / HAVE_ACK=11 | UUID:16, cipher_hash:32, probe_token:8, cipher_size:8 | | SOURCE_GONE=12 | UUID:16, SHA256(file-message):32, holder:8 | Подпись router обязательна. Media receipt содержит домен, conv, seq, author, устройство, UUID и SHA256 полного подписанного сообщения; устройство подписывает первые88 байт. FILE_RECEIVED: `DMFILE01`/`DMFILEG1`, TAKEN: `DMTAKE01`/`DMTAKEG1` для DEVICE/GROUPS. TAKEN разрешён только стороне получателя. `dm_file_targets(id,node,side,received,taken)` хранит отдельные факты; источник уже имеет оригинал и исключён из целей файла. Отдельные таблицы `dm_files`, `dm_custody`, `dm_custody_done`, `dm_media_jobs`, `dm_media_holders` сохраняют состояния и tombstones. `dm_custody_requests` сохраняет решение M для каждой пары (S, assignment_id), включая отказ по квоте. Несколько координаторов ссылаются на одну физическую копию; у копии нет единственного S. Повтор принятого STORE возвращает её текущий статус без продления TTL. Повтор отказа остаётся отказом даже после освобождения места. Конфликт assignment_id/UUID/тела отклоняется. S сверяет ответ с сохранёнными M и assignment_id; старый ответ не меняет новую попытку. DELETE передаёт подписанный факт получателя: assignment_id не разрешает произвольное удаление. A предлагает собственный файл с holder=A и expires=0. S выбирает M с storage=yes в допущенной общей группе S/M. Роль ищется независимо от текущего пути пакета. Каждая пара A/S, S/M, M/A, S/B и M/B использует свою доступную общую CHAT-группу; общая группа всех участников или автора и получателя не требуется. В pending/receipts/files/sources/custody/jobs/holders нет постоянного group_id. Случайный 128-битный assignment_id фиксируется до STORE и сохраняется при смене пути/рестарте. S/M и endpoint/M могут совмещаться. Local control обрабатывается синхронно, локальное копирование ciphertext — worker-ом. Endpoint-кеш и custody имеют отдельные пути; отзыв передач по пути не отменяет передачу другой копии того же UUID. Успешная передача control в router или локальный обработчик не означает durable успех. Политика задаётся на M в `[chatserver]`: ```ini dm_media_ttl_sec=604800 dm_media_storage_mb=1024 dm_media_delete_on_open=yes ``` Первый STORE фиксирует expiry и политику; дубли и смена настройки не продлевают срок и не меняют уже принятую политику, в том числе после restart. При yes первый TAKEN от устройства получателя либо TTL завершает хранение. При no — TTL либо FILE_RECEIVED от ВСЕХ устройств получательской стороны исходного snapshot. Свои устройства автора не задерживают удаление. Факты открытия/скачивания нельзя подменять ACK сообщения. При выполненном условии M сначала устойчиво ставит terminal, отменяет выдачу этой копии, удаляет файл, фиксирует tombstone и лишь затем отправляет DELETE_ACK. Поздние STORE, STORED и worker callbacks не возрождают терминальную копию. Получатель параллельно отправляет HAVE известным онлайн-устройствам обеих сторон и известным M. Окно ответов1с; измеряется время запрос→ответ у получателя, затем выбирается минимальный RTT. Один источник передаёт файл; при отказе используются оставшиеся ответы. При смене источника текущая реализация начинает скачивание с0. Проверенный ciphertext сохраняется у каждого endpoint для последующей выдачи авторизованным участникам. UUID сам по себе не даёт доступа. После удаления M доступность зависит от оставшихся копий; отсутствующий файл без источников не восстанавливается. EXPIRED завершает конкретное назначение S/M. Для endpoint используется SOURCE_GONE: он исключает только указанный holder с точным UUID/hash и не удаляет личный файл. Другой источник, оригинал автора и готовая локальная копия остаются доступными. Отзыв группы закрывает её FILE_TRANSFER, очереди и HAVE-пробы; поздний ответ пробы не возобновляет отменённую попытку. Сохранённые объекты и назначения остаются, следующий поиск использует другой допущенный путь. Отсутствие пути не означает истечение TTL. Worker проверяет локальные байты независимо от группы; callback повторно проверяет поколение истории, состояние копии и срок перед публикацией READY. Готовый файл A/B остаётся в локальной истории до явного удаления беседы. При удалении busy-счётчик каждого пути удерживает его до callback worker/transfer; уборщик работает по старым поколениям, использует busy=-1 для своего захвата и синхронизирует каталог после удаления. Пользовательский исходник не входит в уборку, если он не был явно передан как временный файл приложения. Qt и Android получают историю через события ядра: 41 — `[conv_len:1][conv:decimal][generation:8][JSON сообщений]`, 45 — JSON бесед. Запрос истории с ненулевым generation читается только для текущей видимой беседы; 0 захватывает текущее поколение в uasync. UI отбрасывает снимок другого поколения. Снимки ограничены 200 записями. Отправка вложения вызывает `dm_send_file` в uasync; длительная обработка выполняется в `media_async`. Android копирует выбранный URI и сохраняет полученный файл через `Dispatchers.IO`; Qt сохраняет файл из контекстного меню в отдельном потоке. Выбор «как файл» сохраняет оригинал; «как видео» в Qt использует общий FFmpeg worker и метаданные конечного MP4. Android записывает MP4 через CameraX; импорт MP4 проходит через платформенный MediaMetadataRetriever в IO. Другие контейнеры на Android отправляются как файлы. Голосовая запись отдаёт worker собственный PCM либо компрессор без копирования в GUI/uasync; worker выполняет flush, Opus-кодирование и вычисление waveform. Кодирование Qt и Android использует один `voice_file`. Адресат фиксируется до записи, выбора URI и подготовки; завершение не читает текущую беседу GUI. Подготовленный PM-файл и Android cache удаляются после копирования, ошибки или отмены; пользовательский исходник Qt остаётся во владении пользователя. Событие 46 `[target_len:1][target][job_UUID:16][state:1]` сообщает начало (0), готовность (1) и ошибку (2). Target канала — числовой ID, PM — `dm:`. Подготовка, доставка и воспроизведение имеют независимые состояния. UI отображает только задания текущей беседы. Состояния файла и delivery receipt приходят в JSON 41; `content_type`, duration, dimensions и waveform преобразуются в общие роли сообщения. Канальные скачивания вызываются контроллером только для каналов; PM-файлы скачиваются через `dm_media` по настройке либо явной команде пользователя. JSON различает pending/downloading/ready/expired/unavailable и возвращает счётчики доставки сообщения, загрузок и открытий. Qt/Android сохраняют `(conv,author,seq,generation)` до запуска просмотрщика; TAKEN отправляется после его успеха. Копирование в файл и построение превью не считаются открытием. Android использует общие `ConversationComposer`/`ConversationMessages` для обеих бесед; Qt — `InputBar` и `MessageDelegate`. Превью Qt создаются в общем пуле с двумя workers; декодирование длинного Opus-файла выполняется вне GUI. Оба плеера проверяют заголовок, пакеты и end marker; Qt ограничивает PCM буфер 256 МиБ. ## Проверки Расширение от 03.11.2026: `test_dm_identity`, `test_dm_schema`, `test_dm_groups` проверяют удостоверения, канонический контекст, лимит адресатов, миграцию, рассылку A1/A2/B1/B2, собственные реплики, независимые номера авторов, две упаковки ключа файла, per-device факты и закрытие при выходе с rollback. `test_dm_media` дополнен реальным endpoint=M: ручной получатель, локальная custody, перезапуск с другой настройкой, FILE_RECEIVED без удаления, TAKEN с удалением только custody и восстановление утраченного файла автора с реплики получателя. Результаты окончательных сборок/проверок зафиксированы в разделе 10 плана и doc/tasks.md. Аудит контрактов DM завершён 27.10.2026: все семь заголовков `src/dm/`, этот документ, `pm.txt` и `pm_invites.md` сверены с реализациями owners и относящимися к ним callers. Это проверка соответствия документации; свежая интерактивная проверка клиентов и новые платформенные сборки не выполнялись. Подробности и границы проверки — [dm_audit_findings.txt](dm_audit_findings.txt), раздел 12. Первоначальная проверка отказа SQLite выявила сброс счётчиков; дефект исправлен. `test_dm_load` проверяет 79 отказных сценариев: чтение, bind/step/finalize, короткие ключи и X25519; открытие/приём/отправку/JSON/invite до и после удаления; отдельные отказы подготовки файла, отсутствие беседы, restart и продолжение seq. При отказе сравниваются все поля DM-таблиц и отсутствие SQL-записей/событий успеха. `test_dm_names`: 68 случаев с точным сравнением имени в SQL и JSON при создании, загрузке, штатной записи, fallback из nodes и перезапуске. ASCII и 2/3/4-байтовые символы проверяются до/на/за границей 63 байт; отдельно — overlong, surrogate, значения выше U+10FFFF, неверные/оборванные последовательности, NUL и JSON-экранирование. Снимки не меняют SQL; 273 полных JSON сохраняются для независимого строгого парсера. `test_dm`: симметрия ключа target, CCM round-trip, пустое тело, пределы длины, неверный ключ/tag, разделение nonce; потоковые файлы на границах 32 КиБ, неверные author/UUID/hash, обрезание и лишние байты. `test_dm_e2e`: реальные A–C–B соединения и BGP; нет физического A–B линка. Проверяет out-of-order, dedup/conflict, подписи, SQL rollback при приёме и enqueue, ACK чужого тела, отказ mailbox без роли, точное удаление и поздний PUT. Первый сценарий передаёт 40 offline-сообщений (больше порции pump) и перезапускает источник с pending outbox; второй перезапускает суперузел и доставляет первое сообщение при выключенном источнике. `test_dm_media`: реальные A/B/S/M с раздельными ролями supernode и storage; прямая передача, custody, restart S/M при выключенном A, точный файл на B, удаление копии и тела S, поздние STORE/STORED, TTL, отказ по квоте, неверная квитанция, SQL rollback регистрации, состояния JSON и отмена операции с CM handle. Голосовое передаётся напрямую; метаданные видео сохраняются после offline custody и restart S/M при выключенном источнике. В Linux дополнительно проверяются prepare/step/finalize и неожиданный SQLITE_DONE при подсчёте места, отсутствие REJECT/STORED и SQL-записей при ошибке; три сбоя в первых трёх STORE, успешный четвёртый STORE на том же M и последующая доставка после restart. Проверка настоящей квоты требует REJECT до регистрации custody; повтор того же assignment_id возвращает REJECT и после увеличения квоты. Дополнительно — 18 отказов освобождения EXPIRED/REJECT: prepare/bind/step/finalize обоих UPDATE и commit, точный rollback назначения/job, успешный повтор, дубликат и поздний STORED. Сетевой сценарий теряет EXPIRED, блокирует SQL-сброс, перезапускает S и проверяет восстановление из сохранённого expires, состояние M и отсутствие ложной квитанции. На M проверяются 11 SQL-отказов retirement: rollback сохраняет custody и не выдаёт ACK; после restart M/S потерянный DELETE_ACK восстанавливается по tombstone и повтору DELETE. После EXPIRED и возврата A проверяются точный оригинал на B, настоящая файловая квитанция, очистка S и невлияние позднего SOURCE_GONE на готовый файл. `test_dm_group_routes`: четыре ядра и девять попарных групп, 21 проверка. Прямой маршрут A/B отключён; mailbox и побайтно проверенный файл проходят через разные группы каждой пары. Проверяются независимый поиск роли, отказ unsigned/ отозванному PUT, повтор через другую группу, отзыв HAVE и маршрута S/M при живом ETCP, отсутствие пути без потери копии, два S на одном M, неизменные срок/политика, durable отказ по квоте, новый assignment_id на том же M и поздний отказ прежней попытки. `test_dm_schema` проверяет транзакционный переход схемы без потери очереди/квитанций/ копии, rollback ошибки ALTER и неизменность выданного assignment_id при повторном init. `test_dm_worker`: повторное шифрование 8 МиБ не менее 0,5 с и проверка расшифровки; work выполняется вне uasync, done в его потоке, heartbeat продолжает работать. Destroy отменяет уже posted completion, callback вызывается ровно один раз. Linux `test_dm_media_cleanup`: 13 отказов SQL при чтении назначений, записи ACK и очистке body; сохранность квитанции, повтор, restart S, потерянный/повторный/чужой ACK, несколько M и поздний STORED. Сеть в этом component-тесте перехвачена; подпись B и реальное удаление M проверяет `test_dm_media`. Ошибка finalize после успешного autocommit не означает rollback уже выполненной одношаговой записи. `test_attachment`: строгий формат, повреждения и граничные длины, реальное кодирование/декодирование 50 Opus-кадров, длительность и waveform. `test_attachment_send`: выбранная беседа после смены текущего target, освобождение PCM в worker, остановка до completion без регистрации и удаление отклонённого cache. Qt `test_voice_file`: реальные PCM/Opus, повреждённые файлы и отмена async decoder. Заключительный прогон 27.10.2026: штатные `test_dm`, `test_dm_history`, `test_dm_names`, `test_dm_load`, `test_dm_e2e`, `test_dm_invite`, `test_dm_media`, `test_dm_worker` и Linux `test_dm_media_cleanup` — 9/9 PASS. Отдельный строгий JSON-парсер прочитал все 273 снимка имён. Заголовки проверены в C99/C++20; в заключительном этапе менялись только документация, комментарии и форматирование деклараций. Результаты старых сборок ниже относятся к указанной дате, а не к этому прогону. Проверка 01.10.2026: чистые сборки Qt и Android APK; полный `check.sh` — 117 PASS, 0 FAIL, 1 SKIP (auto_socket_dynamic требует root), proxy/burst/load PASS. Qt CTest: 7 PASS, 0 FAIL; Android APK собран после `clean`. В тесте worker максимальный интервал heartbeat составил 10,3 мс. Интерактивная проверка GUI на телефоне и десктопе не выполнялась.