Files
bare/docs/decisions/034-incoming-message-id.md
T
mayatnikovandClaude Opus 5 63a7a1ef52 Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и 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
2026-08-22 18:18:04 +03:00

23 lines
3.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-034: Входящее сообщение с известным id не перезаписывает запись
## Контекст
`docs/storage.md` описывал повтор доставки одной строкой: «`put` с тем же `id`, без дублей». Подразумевался тот же самый конверт — сервер выдаёт очередь заново при каждом подключении и вправе прислать конверт дважды (ADR-017). Клиент так и делал: писал `put` по `id` безусловно.
`id` — открытое поле конверта, собеседник видит его сразу, как получает сообщение. Истории идентификаторов сервер не хранит: это противоречило бы ADR-008, — поэтому `POST /api/messages` с чужим `id` он принимает и раскладывает по очередям как любой другой. AAD сходится: в него входят `id`, `chat`, `from` и `keyId`, а `from` сервер ставит из сессии — конверт собеседника валиден и расшифровывается. Дальше `put` перезаписывал мою строку: в ленте вместо моего сообщения оказывался чужой текст, исходное исчезало, и фан-аут разносил подмену на остальные мои устройства. Тем же приёмом собеседник стирал и то, что прислал сам, — это прямо противоречит модели угроз: «„Удалить у всех“ после доставки не существует».
Вторая половина того же места — счётчик. Все чтения `messages` выпускались до первого `put`, поэтому две копии одного конверта в одной пачке обе считались новыми и `unread` рос дважды. Такая пачка — не гипотеза: конверт, попавший в очередь в момент подключения, приходит и выдачей очереди, и живым событием.
## Решение
- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных. Строку, которая уже лежит на устройстве, входящий конверт не трогает.
- Повторный `id` внутри одной пачки учитывается один раз.
- Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`, где `id` свой и запись своя.
- Правило записано строкой в `docs/storage.md`.
## Следствия
- Знание `id` не даёт собеседнику ничего: подменяющий конверт у получателя молча пропадает.
- Нерасшифрованное чинится не повторной доставкой, а полем `raw` — оно для этого и хранится (`docs/storage.md`).
- Дубль доставки, разрешённый ADR-017, больше не двигает счётчик непрочитанных.