Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
7.9 KiB
ADR-036: Повтор отправки сохраняет ULID
Контекст
ADR-017 присваивает ULID в момент попытки отправки, а не набора: сервер принимает сообщение, только если время в идентификаторе расходится с его часами не больше чем на пять минут. docs/storage.md довёл это до правила «при каждой попытке отправки pending получает новый ULID»: старая запись удалялась, новая писалась.
У правила есть цена. POST /api/messages кладёт конверт в очередь и только потом отвечает 202. Ответ теряется: обрыв на мобильной сети, закрытая вкладка, 502 от прокси. Сервер сообщение принял и разослал, клиент считает его неотправленным, оставляет pending и после следующего ready шлёт заново — уже с другим идентификатором. Собеседник видит один и тот же текст дважды, двумя разными записями, и склеить их нечем: id у них разные. Обрыв сразу после отправки — обычное дело на телефоне, а дубль остаётся в истории навсегда.
Второй половины проблемы больше нет. ADR-034 заставил получателя игнорировать входящее с уже известным id целиком: ни записи, ни счётчика. Значит повтор с тем же идентификатором безвреден — сервер положит конверт в очередь (ON CONFLICT DO NOTHING либо новая строка, если прежнюю уже подтвердили), получатель его молча пропустит и подтвердит. Менять id нужно ровно тогда, когда прежний перестал годиться серверу.
У переиспользования есть своя цена. Возраст идентификатора клиент считает по своим часам, сервер — по своим. Клиент отстаёт на две минуты, сообщение пролежало pending три с половиной: клиент видит запас нетронутым, сервер видит пять с половиной и отвечает 400 clock_skew — тогда как прежнее правило дало бы свежий id с расхождением в две минуты и 202. Кнопка «повторить» при этом бесполезна первые минуты: она берёт тот же id и получает тот же отказ, пока возраст не перевалит за запас. Оставить это пользователю нельзя: часы отстают на пару минут у любого устройства, которое давно не сверялось со временем, а сообщение при этом не уходит вовсе.
Решение
- Повтор отправки идёт с прежним ULID. Запись не удаляется и не заводится заново: меняется только её состояние.
- Новый ULID берётся, когда время прежнего разошлось с текущим больше чем на четыре минуты. Тогда работает прежний порядок: старая запись удаляется, новая пишется.
- Запас — минута под серверным окном ±5 минут: за неё успевают шифрование, очередь работ клиента и сама сеть, так что дошедший запрос застаёт окно ещё открытым.
- Первая отправка ULID генерирует, как и раньше.
- Клиент сравнивает время идентификатора со своими часами: других у него нет, и первый ULID берётся из них же.
clock_skewна переиспользованном идентификаторе отменяет переиспользование: прежнийidснимается, попытка идёт второй раз со свежим. Ровно один раз — это та самая ситуация, ради которойidи меняется. Отказ на свежемidозначает, что часы врут по-настоящему: сообщение становитсяfailedс текстом про часы (ADR-033), второго круга нет.- Сохранённый
idоставляет и прежнееts: время показа идёт за идентификатором, пока202не принесёт серверное. - Правило записано строкой в
docs/storage.mdвместо прежнего.
Следствия
- Потерянный ответ на
POSTбольше не оборачивается дублем: повтор приходит собеседнику с тем жеidи молча пропадает у него по ADR-034. - Остаточный случай остаётся. Если ответ потерялся, а повтор случился позже окна — вкладку закрыли на час, устройство ушло в офлайн, — идентификатор сменится, и дубль появится. Иначе нельзя: сервер такое сообщение не примет вовсе. Вероятность теперь ничтожна, а раньше дубль давал любой обрыв.
- Экран не мигает.
removedв уведомлении пуст, лента находит сообщение по прежнемуidи перерисовывает одну строку вместо всей ленты. - Умеренно врущие часы пользователь не разбирает. Расхождение, которое сервер видит только из-за переиспользования, снимает вторая попытка со свежим
id; полоса про часы остаётся за настоящим расхождением — тем, что больше пяти минут и от идентификатора не зависит. - Уточняется ADR-017: «ULID присваивается в момент попытки отправки» верно для идентификатора старше запаса. Более свежий переживает попытку, и время в нём — время первой из них.
- Уточняется ADR-033: новая попытка заводит запись без поля
error, но не обязательно с новым ULID. - Уточняется ADR-035. Правило «неотправленное повторяет только владелец потока» остаётся, но причина мельчает: две вкладки послали бы одно и то же сообщение дважды с одним
id, а не два разных. - Сортировка исходящего перестаёт зависеть от числа попыток: сообщение остаётся на своём месте в ленте, а не переезжает в конец при каждом повторе.