Control Protocol v2: общий контракт клиентов
Контракт зафиксирован, runtime еще не реализован
Редакция 2.0-draft.1, 10.09.2026. Это нормативная база этапа E0 для server,
web, native C++ и KMP, а не объявление работающего v2 в текущей поставке.
До реализации и явного согласования v2 действует текущий протокол v1.
Само добавление ID в JSON не реализует durable commit, resume или HA.
Область и источник истины
Здесь фиксируются negotiation, identities, request/result, resume, snapshots, events и media generations перспективной версии протокола. Доменные DTO и готовность runtime не следуют из наличия общего envelope. Реализация клиента должна соблюдать явно согласованную сервером версию; действующий v1 сохраняется.
MUST / MUST NOT означают обязательное правило для любой реализации; SHOULD допускает документированное исключение с тестом. При расхождении текста и машинной схемы изменение контракта блокируется до устранения противоречия. Runtime проверяет не только JSON shape, но и семантику/права.
- Машинная схема envelope и negotiation, JSON Schema draft-07.
- Положительные и отрицательные wire-примеры.
- Общие сценарии conformance.
- Проверка из корня репозитория:
node Tool/ProtocolContracts/verify-control-v2.cjsпослеnpm ci --prefix web-client.
Схема и fixtures общие для всех языков. Проверка этих файлов не является прохождением conformance реальными клиентами; таблица реализации ниже остается явной.
INV: обязательные инварианты
| ID | Правило |
|---|---|
| INV-01 | Ровно один command key верхнего уровня. Нельзя добавлять рядом _meta и рассчитывать на порядок JSON properties. |
| INV-02 | v2 используется только после положительного negotiation; отсутствующий negotiation означает v1, но не скрытое разрешение v2. |
| INV-03 | Личность, логическое подключение, физический socket, участие и публикация имеют разные ID. |
| INV-04 | Все namespace проверяются; client-supplied ID не является аутентификацией или правом. |
| INV-05 | Запись с неизвестным исходом повторяется с тем же command_id и тем же intent, не с новым ID. |
| INV-06 | received не означает committed; committed не означает, что внешний media effect уже applied. |
| INV-07 | Ответ сопоставляется по command_id, а не по имени или FIFO. |
| INV-08 | Gateway reconnect не является conference leave, новой авторегой или созданием устройств. |
| INV-09 | Старый binding не изменяет новый; старый call не изменяет новый запуск той же комнаты. |
| INV-10 | Snapshot/replay образуют согласованный барьер; частичный snapshot не применяется. |
| INV-11 | Пропуски определяются по recipient stream sequence, не по глобальной версии конференции. |
| INV-12 | publication_id, route generation и endpoint/negotiation identity проверяются до применения SDP/ICE/stop. |
| INV-13 | Совпадение user_id не означает «мое устройство»; различаются participant instances. |
| INV-14 | Timeout/503/quorum loss не означают удаленный аккаунт или отсутствие сообщения/устройства. |
| INV-15 | Явный leave/logout не отменяется старым resume; stale media не восстанавливает grants. |
| INV-16 | Transient VAD/typing имеет TTL; после expiry индикатор сбрасывается, сообщение не replay'ится как актуальное. |
Формат и числовые типы
UTF-8 JSON, один object с одним ключом команды. Duplicate JSON keys MUST отвергаться до обычной десериализации с last-wins; запрещены NaN/Infinity, float вместо ID и coercion "false" -> true. Межузловой и production client transport защищен TLS. Ограничение размера согласуется сервером и проверяется до выделения больших буферов.
В v2 новые ID всегда opaque strings; user_id соответствует текущему local client_id, но не парсится web-клиентом через Number. binding_generation, aggregate_version, stream_epoch, seq, route_generation передаются каноническими десятичными строками uint64: "0", "1", ..., "18446744073709551615"; без ведущих нулей и знака. Нельзя сравнивать их обычной строковой сортировкой. Web использует BigInt или сравнение длины/цифр, C++ проверяет overflow uint64_t, KMP использует проверенный ULong/decimal wrapper. Схема ограничивает форму; upper bound дополнительно проверяется семантически.
SSRC остается JSON integer uint32, port uint16, индексы частей и durations в миллисекундах являются ограниченными integer. Wall-clock timestamps не используются как ordering/fencing counter. Старые numeric поля v1 не переинтерпретируются задним числом; они проходят отдельный v1 adapter. Правило безопасного числового обмена основано на RFC 8259.
Неизвестное optional metadata в extensions может игнорироваться. Неизвестный required feature, тип события с влиянием на state или новая семантика команды не игнорируется: unsupported_feature / resync_required, без advancement cursor. Breaking change требует нового negotiated profile/version. Произвольные поля вне extensions не являются расширением контракта.
Negotiation на существующем login
Не вводим нераспознаваемое сообщение до connect_request. В него добавляется optional object control_protocol; старый сервер читает известные поля как раньше. Новый клиент не отправляет v2 frames до ответа. client_version остается версией приложения, не wire protocol.
{
"connect_request": {
"channel_type": 0,
"client_type": 0,
"access_token": "example-not-a-real-token",
"control_instance_id": "tab-a",
"control_protocol": {
"versions": [2, 1],
"require_version": 1,
"profiles": ["vg.control.v2.core"]
}
}
}
Profile vg.control.v2.core является неделимым набором INV-01..16 для поддерживаемого вертикального сценария. Сервер не рекламирует core, реализовав только echo command_id. Для enterprise endpoint клиент требует require_version: 2; fallback на v1 там запрещен. [2,1] с require_version:1 разрешен только для standalone compatibility, не восстанавливает HA-session через v1.
Успешный connect_response сохраняет существующий result:1 и получает:
{
"control_protocol": {
"version": 2,
"profile": "vg.control.v2.core",
"deployment_id": "deployment-a",
"tenant_id": "tenant-a",
"user_id": "228800001",
"auth_session_id": "auth-a",
"transport_id": "socket-new",
"limits": {
"max_frame_bytes": 1048576,
"max_inflight_commands": 32,
"max_sync_parts": 256,
"max_sync_bytes": 8388608,
"sync_timeout_ms": 15000,
"command_retry_window_ms": 86400000,
"resume_grace_ms": 60000
}
}
}
Это фрагмент тела, не самостоятельная команда. Конкретные limits в примере не SLA/default production. transport_id меняется при новом socket; binding логического подключения устанавливается только последующим sync. До sync клиент находится в Authenticated, не в Ready/Conferencing, сервер не шлет старые v1 conference pushes параллельно v2.
При несовместимости новый сервер использует connect_response.result=3 (UpdateRequired) и машинную причину protocol_unsupported, не InvalidCredentials. Если старый сервер успешно залогинил клиента, но не вернул negotiation, клиент с require_version=2 закрывает transport без доменных мутаций и объясняет несовместимость; не стирает сохраненный аккаунт. Нельзя переключить profile посередине открытого socket.
Идентичности и binding
| Поле | Lifetime и владелец |
|---|---|
deployment_id, tenant_id |
Проверенный auth namespace; не hostname/IP |
auth_session_id |
Отзываемая login/refresh family; refresh не создает новый аккаунт |
control_instance_id |
Существующее имя поля сохраняется для web/native/KMP. Уникален для активного экземпляра приложения/вкладки; не секрет |
logical_connection_id |
Серверный ID логического клиента; сохраняется при resume |
binding_generation |
Серверный fencing counter, повышается при успешном rebinding |
conference_id, conference_session_id |
Комната / конкретный созвон; tag является locator, не runtime identity |
participant_instance_id |
Участие данного logical connection, не аккаунта вообще |
publication_id |
Логическая публикация; не равен browser deviceId или SSRC |
Общий binding во всех post-sync frames содержит deployment_id, tenant_id, logical_connection_id, binding_generation. Сервер выводит principal из аутентифицированного socket, сверяет binding и актуальные grants. Нельзя принимать user_id/grants/owner_epoch из команды клиента как authority.
Клиент сохраняет control instance ID при реконнекте, но не делит его между двумя одновременно работающими вкладками/процессами. Дублирование вкладки требует обнаружить скопированный instance/resume state и выдать новый instance. Два устройства одного аккаунта не переиспользуют один resume token.
Command, result и неопределенный исход
{
"control_command": {
"binding": {
"deployment_id": "deployment-a", "tenant_id": "tenant-a",
"logical_connection_id": "connection-a", "binding_generation": "7"
},
"command_id": "cmd-stop-mic-a",
"target": {"kind": "conference_session", "id": "call-a"},
"name": "publication_set",
"body": {"participant_instance_id": "participant-a", "publication_id": "mic-a", "enabled": false},
"expected_version": "81",
"ttl_ms": 1500
}
}
Envelope резервирует name/body; точные DTO имен обязаны иметь собственную capability/schema перед включением. Пример publication_set задает intent, не существующий новый handler текущего сервера.
Idempotency scope: (deployment, tenant, auth_session, logical_connection, command_id). Intent включает target/name/body/expected_version/ttl; исключает transport и binding generation. Поэтому после resume тот же command ID отправляется с актуальным binding и тем же intent. Сервер хранит canonical semantic hash JSON, не хеш произвольного порядка properties. Same ID с другим intent: idempotency_conflict, ни одного нового эффекта.
ttl_ms отсчитывается сервером от первого durable admission и не продлевается повтором. Локальный timer клиента не является отменой. До admission результат может быть неизвестен; после reconnect клиент сначала узнает результат или повторяет тот же intent. Сервер объявляет retry window; после ее истечения нельзя превратить ID в новую операцию. Требуется outcome_unknown/сверка state либо durable operation ID. Клиент не хранит и не посылает бесконечную offline-очередь toggle-команд.
Baseline сохраняет минимальный tombstone (scope, command_id, intent_hash, terminal/outcome) до окончательного закрытия logical/auth session, даже если полный result уже удален по retry window. Уничтожение всей памяти о использованном ID при еще действующей сессии запрещено. После закрытия scope старый binding не принимается; клиент не переносит незавершенные команды в fresh scope автоматически. Компактация через sequence floor или server-issued idempotency window возможна только следующим явно специфицированным расширением, не скрытой эвристикой.
control_result содержит binding текущей доставки, command_id, status, optional aggregate/version/data. Состояния:
| Status | Значение |
|---|---|
received |
Принято в обработку; не доказательство durable commit |
committed |
Intent, result и значимые state/outbox подтверждены по persistence policy |
applied |
Подтвержден внешний эффект; для чистой DB-команды допустим финальный applied сразу |
rejected |
Команда не будет применена; error обязателен |
apply_failed |
Commit был, но внешний эффект не достигнут; error обязателен, нужен reconciliation |
outcome_unknown |
Сервер не может доказать итог; это не reject и не разрешение нового ID |
Результат не заменяет state event. Клиент не считает локальную optimistic модель источником правды. Для одного command status не регрессирует из terminal в received; поздние дубли безопасно игнорируются. Cancellation coroutine/request timeout в KMP или Promise timeout в web не означают server rollback. command_status является read-only v2 query с новым query ID и body.command_id исходной операции; неизвестный/истекший ID возвращает outcome_unknown, не «можно повторить как новую».
expected_version опционален и проверяется только там, где требуется CAS. Изменение несвязанного aggregate не является конфликтом. Серверный owner_epoch остается внутренним механизмом и не дает клиенту права выбирать writer.
События, курсоры и ACL
{
"control_event": {
"binding": {
"deployment_id": "deployment-a", "tenant_id": "tenant-a",
"logical_connection_id": "connection-a", "binding_generation": "7"
},
"event_id": "event-a",
"stream_id": "visible-call-a",
"stream_epoch": "2",
"seq": "19",
"aggregate": {"kind": "conference_session", "id": "call-a"},
"aggregate_version": "84",
"name": "publication_changed",
"body": {"publication_id": "mic-a", "enabled": false}
}
}
aggregate_version монотонна в доменном aggregate и может перескакивать у конкретного получателя: не все изменения видимы по ACL. stream_id + stream_epoch + seq описывают его разрешенную проекцию, непрерывно нумеруемую сервером. Это не счетчик WebSocket frames и не общий счетчик сервера. Несколько видимых событий одной транзакции могут иметь одинаковую aggregate version и разные seq.
Порядок применения:
- Проверить negotiated profile, auth namespace и актуальный binding; старый binding отбросить без изменения cursor.
- Проверить stream/epoch. Неизвестный stream или другой epoch требует sync; только snapshot/barrier может установить новый epoch.
seq <= applied_seq: duplicate/stale, не применять второй раз. Same seq с другим event ID/content является нарушением сервера и требует resync/диагностики.seq == applied_seq + 1: применить поддерживаемое событие и атомарно продвинуть cursor с его локальной моделью.seq > applied_seq + 1: приостановить изменения этого stream, ограниченно буферизовать и запросить sync. Другие streams могут продолжать работу.
Клиент без durable локальной модели не использует один сохраненный cursor для replay: запрашивает snapshot. При изменении ACL сервер перевыпускает projection epoch и snapshot/purge instructions; replay не раскрывает историю, на которую права уже отозваны. Старые результаты других tenant/call не применяются, даже если имена комнаты и пользователя совпадают.
Transient сообщения идут отдельным control_signal: binding, stream reference, source identity, signal_seq, ttl_ms, name/body. Они не создают дыр в durable seq, не поднимают unread и не включаются в snapshot/replay backlog. Sequencing/TTL scope: (stream, epoch, source_id, name). После истечения TTL от момента приема индикатор гасится; source removal/mute/leave сбрасывает его немедленно. Высокочастотный VAD не записывается в durable event log.
Sync и resume
Общий путь для первого входа и восстановления: connect_request -> connect_response -> control_sync -> snapshots/replay -> control_sync_result(ready).
stateDiagram-v2
[*] --> TransportConnecting
TransportConnecting --> Authenticating: WSS open
Authenticating --> Syncing: v2 selected
Syncing --> Ready: complete barrier applied
Ready --> Reconnecting: socket lost
Reconnecting --> Authenticating: other gateway / token refresh
Syncing --> ResumeRejected: expired or revoked logical session
ResumeRejected --> Syncing: explicit fresh sync, no automatic join
Ready --> Left: committed leave / logout
control_sync имеет request_id, control_instance_id, mode: fresh|resume|resync, cursors[]. Для resume обязательны ожидаемый namespace, logical connection ID, previous binding generation и resume_token. Если прошлый sync не получил ответа, resume.previous_request_id ссылается на него: сервер проверяет его outcome в том же auth/instance scope, чтобы безопасно восстановить фактическое поколение перед новым CAS. Без такой проверки нельзя молча принять любой устаревший generation. Для resync на живом соединении обязателен текущий binding. Fresh не имеет resume/binding; нельзя отправить fresh и незаметно восстановить старое участие.
resume_token является отдельным случайным секретом, связанным с auth session, instance и logical connection; не refresh token, не публичный ID. Требуются одновременно действующая авторизация и доказательство resume. Token хранится защищенно (native/KMP secure storage; web в ограниченном хранилище с тем же XSS trade-off, что текущий fallback), не передается media/bot и не логируется. Смена IP сама по себе не запрещает resume. Поиск по user_id вместо проверки proof запрещен.
Успешный resume делает CAS binding generation; старый socket fenced. Повтор того же sync request после потери ответа возвращает прежний outcome, а не увеличивает поколение повторно; для смены физического socket новый request ID и CAS/reconciliation необходимы. Если предыдущий ответ утрачен, сервер может восстановить outcome операции по proof/request ID без выдачи второму instance владения. Resume credential не ротируется неидемпотентно на каждом transient reconnect; logout/revoke делает его недействительным, TTL не продлевается бесконечно без auth.
Если логическая сессия утрачена/вышла за grace: control_sync_result.status=resume_rejected. Сохраненный login не удаляется. Клиент очищает stale runtime membership/devices, но не вступает и не включает mic/cam автоматически. Новый join только по действующему явному пользовательскому intent и актуальным правам. Переход на fresh означает новое логическое подключение и требует проверки admission quota.
Snapshot barrier
control_snapshot содержит request_id, новый binding, stream/epoch, snapshot_id, snapshot_seq, part_index (с нуля), part_count и state.
- Части имеют одинаковые snapshot identity, binding, stream/epoch/seq/count. Повтор части с другим content запрещен.
- Клиент ограничивает суммарные bytes/parts/time, собирает snapshot во временной модели и не показывает частичное membership.
- Сервер завершает операцию
control_sync_result(ready)со списком всех разрешенных streams и их barrier cursors; отсутствие stream в полном списке означает удаление его старой проекции. - До ready клиент должен иметь все snapshot parts и непрерывный replay до барьера.
control_event.request_id, если задан, относит событие к этому sync; клиент не смешивает параллельные sync attempts. - После готовности модель и cursors применяются атомарно. Сервер присылает последующие live events после барьера в порядке stream; клиент удерживает их до локального применения барьера.
- Смена ACL/epoch, quota overflow или истечение sync snapshot прерывают попытку. Сервер отвечает retry/resync-required, клиент отбрасывает staging, не заменяет модель пустой.
- На fresh без локальной базы нужен snapshot. Восстановление живой базы допускает replay; один cursor без модели не достаточен.
Нужны отдельные лимиты max_frame_bytes, max_sync_parts и bounded buffer на клиенте. Ready означает согласованный control state, не готовые RTC/audio permissions.
max_sync_bytes ограничивает всю staging-модель одного sync, sync_timeout_ms ограничивает время ее сборки. Клиент может иметь меньший локальный лимит и завершить попытку явной ошибкой, но не применить усеченные данные. Snapshot с новым binding допускается только внутри своего pending request_id, для проверенного deployment/tenant; произвольное событие с большим generation не перевязывает логическую сессию.
Publication и media generation
Локальная публикация и подписка принадлежат конкретным participant instances. V2 device/publication event обязан содержать namespace/call reference, participant instance, publication ID, device kind, desired/observed state. Numeric device_id, author_ssrc, receiver_ssrc, port остаются совместимыми transport bindings, но не заменяют identity.
Binding для signaling: conference_session_id, participant_instance_id текущего клиента, publication_id источника, route_generation, endpoint_id, negotiation_id, scope и rtc_node_id. SDP/ICE не сохраняются как будущая команда воспроизведения старого транспорта: live signaling envelope control_media, с тем же authenticated binding и обязательным media reference.
publication_idстабилен, пока существует логическая публикация; после настоящего delete новый publish получает новый ID.route_generationвыдает сервер; меняется при новом назначении маршрута. Client не увеличивает ее для получения прав.endpoint_idидентифицирует конкретный RTCPeerConnection; для нового peer всегда новый ID.negotiation_idидентифицирует конкретный offer/answer exchange, включая ICE restart на прежнем peer. Offer/answer/ICE одного обмена имеют одинаковые IDs; старый answer не применяется к новому offer.- При subscribe participant instance является получателем, owner источника хранится в publication state.
user_id == selfне запрещает подписку на другое устройство того же пользователя. - Candidate до answer можно временно буферизовать только для точного matching reference и с лимитом. Для удаленной/другой generation отбросить.
- Resume сохраняет исправный media path; route recovery выполняется только для несовпавшего/потерянного assignment. F5 без локального peer восстанавливает транспорт для существующей publication, но не делает повторный create.
- Domain stop/deaf/leave отменяет текущие bindings; старый SDP/ICE или delayed event не оживляет их. Route recovery не меняет выбранный динамик и не захватывает устройство без разрешения пользователя.
Основной WebRTC и существующая политика WSM fallback не меняются этим контрактом. Переключение route не дает права самовольно выбрать иной media transport. CAN workers не получают права пользователей из одного node_id; отдельный service-account scope сохраняется.
Матрица ошибок и обязательная реакция
| Code | Действие клиента |
|---|---|
protocol_unsupported / unsupported_feature |
Показать несовместимость; не повторять пароль/авторегу |
unauthenticated / token_expired |
Штатный refresh, затем reconnect; не менять call intent |
account_deleted |
Только подтвержденный authoritative account tombstone позволяет удалить именно этот локальный аккаунт |
permission_denied |
Применить актуальную политику; не обходить новым endpoint/другим gateway |
stale_binding / resume_rejected |
Sync/reconcile; не вызывать account deletion |
version_conflict |
Получить state, решение о новом intent принимается после сверки |
idempotency_conflict |
Ошибка клиента; новый payload не повторять под старым ID |
resync_required |
Новый snapshot; не дописывать события поверх неизвестной базы |
temporarily_unavailable / rate_limited |
Bounded backoff с jitter, учитывать retry_after_ms; не очищать session list |
deadline_exceeded / outcome_unknown |
Проверить итог исходной операции; не считать rollback |
В v1 числовой AccountNotFound=8 сохраняется. V2 account_deleted сервер использует только при доказанном удалении auth principal, не при недоступной БД или неизвестном foreign subject. Будущий federation auth context не может переиспользовать этот код для недоступного home deployment.
Вертикальный сценарий и миграция
Минимальный следующий срез: join -> publish -> command disconnect -> resume -> leave плюс два устройства одного аккаунта. Join определяется по conference locator и возвращает полный call/participant reference; все последующие мутации адресуются call ID. Leave сохраняет tombstone participation и не завершается закрытием WebSocket. Повтор join/leave дедуплицируется так же, как остальные команды. Точные domain DTO фиксируются до реализации handlers; envelope schema не валидирует их вместо feature schemas.
| Реализация | Сейчас | Обязательная работа перед объявлением v2 |
|---|---|---|
| Server C++ | Engine/Proto и Processor работают с v1; negotiation v2 отсутствует |
Negotiation gate, strict parser, domain context, durable dedup/result, projection streams, session proof/CAS, feature schemas |
| Web | ControlWS.ts извлекает имя команды; текущий stale restore и controllers |
Изолированный v2 adapter, pending map по ID, snapshot reducer, binding fencing; текущий v1 путь сохраняется |
| Native / services | Engine/Controller/Controller.cpp и Engine/Proto |
Типизированные envelopes/uint64, Ready после sync, отсутствие повторного publish при восстановлении |
| KMP | DefaultProtocolClient коррелирует одинаковые responses FIFO по имени |
Отдельный v2 pending map по command ID; timeout/disconnect как unknown; gate Transport -> Auth -> Sync -> Ready |
Состояние всех четырех строк: v2 не реализован и не проверен на runtime. Поддержку нельзя включать по версии приложения/ОС или по наличию одного поля в server response. Partial rollout: сначала server contract tests и single-process backend, затем клиенты с выключенным negotiation flag, затем interoperability стенд, после этого HA endpoint требует v2. Standalone v1 продолжает работать до отдельного решения о снятии поддержки.
Для web/native/KMP SHOULD существовать одинаковые fixture-driven tests из conformance.json. Network Connected не должен открывать бизнес-команды до Auth+Sync. Особо проверяются cold PWA/Android, multi-tab refresh, coroutine cancellation, late events и обратный порядок ответов одинакового типа.
Приемка контракта
JSON fixtures проверяют форму и границы чисел. Conformance scenarios проверяют поведение реализации: negotiation/downgrade, повторы после commit, чужой namespace, multi-device, stale binding, последовательность разрешенных events, snapshot barrier, late SDP/ICE, явный leave и ошибки auth. Серверные crash/partition тесты доказывают durable semantics отдельно.
Изменение поля/статуса/обязательного поведения требует одновременно обновить этот документ, schema, fixtures, conformance cases и affected adapters/tests. Для новой capability нельзя только дописать frontend type. Доставка на сайт и включение поддержки в сервере являются отдельными действиями.