Договор о том, как сервисы экосистемы (bugtrail, gntodo, smart-home, Navi-инстансы, gnexus-auth, ad-hoc скрипты) шлют события в Synapse.
Источник знает только «что случилось у меня». В конверте события нет получателей, каналов и подписок — выбор «кому, куда и каким каналом» целиком принадлежит Routing Engine (правилам в админке). Клиент, который «знает про других клиентов» — признак ошибки проектирования. Когда-то понадобятся адресные алерты конкретному человеку (например assignee bugtrail) — путь через payload-переменную в шаблоне ({{ payload.assignee_email }}), не через поля конверта.
POST /api/v1/events Authorization: Bearer syn_<token> # единый паттерн экосистемы (Bearer) Content-Type: application/json
Для пачек: POST /api/v1/events/batch — тот же конверт в массиве, ответ — список id (частичная валидация честная).
{
"source": "monitoring",
"subject": "container",
"action": "down",
"priority": "critical",
"payload": { "container": "nomin-web", "host": "melody", "exit_code": 137 },
"dedup_key": "container-nomin-web-down-2026-10-03",
"ttl_seconds": 3600,
"scheduled_at": null
}
| Поле | Обяз. | Смысл | |||
|---|---|---|---|---|---|
source |
да | Явное имя источника (см. «Источник и ключ»). Правила маршрутизации матч имя, не токен — ротация ключа не трогает правила. | |||
subject |
да | ЧТО: сущность события — issue, container, build, motion… |
|||
action |
да | Что с ней случилось: created, down, resolved… |
|||
priority |
нет | `low \ | normal \ | high \ | critical, дефолтnormal. Фиксированная шкала, не число.critical` в будущем = обход шумодавов, отдельная очередь. |
payload |
нет | Любой JSON. Опциональная JSON Schema типа enforced'ится воркером (не на приёме). Шаблоны каналов должны быть устойчивы к отсутствию полей. | |||
dedup_key |
нет | Ретраи источника: тот же ключ в TTL-окне → deduplicated: true, id: <первый>. Дубли в каналах раздражают сильнее всего. |
|||
ttl_seconds |
нет | Событие «контейнер упал» бесполезно через час: воркер не маршрутизирует истёкшее (статус expired), ретеншн удалит (см. «Ретеншн»). Дефолт — «вечно», поле обязательно с первого дня — ретрофит дороже. |
|||
scheduled_at |
резерв | Не реализуется в MVP; поле зарезервировано. |
Не существует и не появится в клиентском контракте: recipients, topics, channel — получатели/каналы/подписки живут только внутри Synapse (правила в админке). tags из обсуждений убраны из v1; вернутся, когда появится реальное правило, требующее тега (решение обсуждалось 2026-10-03).
payload.user_id (соглашение 2026-10-03)Если событие связано с конкретным пользователем, источник кладёт его id gnexus-auth (sub) в payload.user_id — это часть «что случилось», а не получатель: конверт по-прежнему ничего не знает о получателях. Что Synapse делает с этой привязкой — решают правила: событие попадает в личный лог пользователя (/api/v1/me/events) только если подошло правило маршрутизации с действием канала user (цель не задаётся); правила без такого действия — «событие только для админа». Канал push (web-push, docs/06) работает по той же семантике — доставляется в подписки пользователя из payload.user_id (см. так же открытый вопрос №3 в CLAUDE.md: привязка tg chat_id/email остаётся открытой).
Формат значения: непустая строка (число тоже примем — приведётся к строке). Всё прочее (массив, объект, bool) — при маршрутизации записывается как skipped с причиной в /admin/deliveries: доставка «в никуда» обязана быть видимой, не тихой. Внутри s2s-тела payload уходит как есть — получатель-сервис получает payload.user_id без специальных полей.
| Код | Когда |
|---|---|
401 |
Нет/битый ключ |
403 |
Ключ валиден, но не принадлежит source из тела (анти-спуфинг) |
422 |
Неизвестная тройка (source, subject, action) — «тип не зарегистрирован». Payload при приёме не валидируется — deep-валидация в воркере, её исход — Delivery Log, не откат приёма. |
202 |
Принято → {"id": "uuid", "status": "queued", "deduplicated": false} |
Валидация конверта жёсткая и мгновенная, payload — мягкая. Схема «принял мгновенно → разберусь» ломается, если требовать на входе знание всех payload-схем.
GET /api/v1/events/{id} — только источнику события, чужой id → 404:
{
"id": "uuid", "status": "delivered",
"deliveries": [
{"channel": "telegram", "target": "infra-squad", "status": "delivered", "attempts": 1, "error": null},
{"channel": "internal_log", "status": "delivered", "attempts": 1}
]
}
Обратный канал (webhook об изменении статуса) позже; контракт поллинга стабилен — подписки докинутся сверху. Для MVP источники либо fire-and-forget, либо поллят этот GET.
Тройка (source, subject, action), опционально JSON Schema payload'а (валидация мягкая, в воркере). Заводит админ Synapse в UI.
Условия (conditions JSONB):
{
"source": "monitoring", // null/отсутствует = любой источник
"subjects": ["container"], // [] = любые
"actions": ["down", "restarting"], // [] = любые
"priority_min": "high", // шкала low/normal/high/critical
"payload": {"container": "nomin-web"} // опц.: точный матч по top-level
// ключам payload'а; значение-список
// = any-of («хост melody или pilar»)
}
Действия — по одному на строку: канал + цель (telegram:infra-alerts, internal_log, позднее email, s2s:navi-rei) + опциональный шаблон (Jinja2, {{ payload.x }}; шаблон действия переопределяет шаблон правила). Шаблон обязан быть устойчив к отсутствию полей payload.
Каналы без цели: internal_log (журнал Synapse), user (личный лог пользователя — адресат из payload.user_id; событие без этого поля пишется как skipped с причиной, админ видит её в /admin/deliveries) и push (web-push в подписки того же адресата; без payload.user_id — skipped, без VAPID/подписок — pending с ошибкой и ретраями). Событие попадает пользователю в личный лог (/api/v1/me/events) только через правило с действием user.
Задаёт настройка ROUTING_MATCH_MODE:
all (дефолт) — все подошедшие правила; каждое добавляет свои доставки. «Контейнеру вниз → TG», «critical → Navi» и «всё от monitoring → лог» работают одновременно.first — только первое по weight (меньше — раньше). Суровые сценарии «одно правило на тип».throttle_seconds)Правило может ограничить частоту: если в ту же цель (channel, target) уже была создана доставка (pending/delivered) за последние N секунд — новая записывается со статусом skipped (аудит: rendered_message хранит, что отправили бы). Окно продлевают только реальные доставки — непрерывный шторм срезанный не продлевает. priority: critical проходит шумодав всегда — это часть смысла критичности (обещано в конверте). low-интервалы типа «контейнер перезапускается 20 раз» — тот же механизм.
Провал доставки (сеть, 5xx провайдера) → обратно-экспоненциальная пауза: 30 c → 2 м → 10 м → 30 м, всего 5 попыток → failed. Скан due-доставок — Celery beat раз в минуту (synapse.retry_due). Пока канал не реализован (#29/#28), доставки честно проходят этот цикл и падают в failed — таблица сразу показывает поведение ретраев.
Удаление — beat-задачей synapse.expire_events (ежечасно):
ttl_seconds воркер не маршрутизирует (статус expired), ретеншн удаляет его; доставки падают каскадом;RETENTION_DAYS (дни, дефолт 30) удаляются независимо от ttl; 0 — окно по возрасту отключено (ttl продолжит работать).Следствие: /api/v1/me/events и разделы админки читают текущее состояние, а не архив — удалили событие, пропали и записи о нём (включая личный лог канала user).
Когда правило направляет событие в системный webhook (Navi и другие сервисы), Synapse подписывает доставку. Схема — ровно та же, что у вебхуков gnexus-auth (WebhookSignature.php), так что принимающий код в экосистеме один и тот же.
Заголовки (как у gnexus-auth, плюс один):
X-Gnexus-Event-Id: <uuid события Synapse> X-Gnexus-Event-Type: monitoring.container.down # "<source>.<subject>.<action>" X-Gnexus-Event-Timestamp: 1759483200 X-Gnexus-Signature: t=1759483200,v1=<hex> X-Synapse-Source: monitoring # доп. заголовок Synapse
Подпись — HMAC-SHA256 по «сырому телу» запроса (то, что уйдёт в сеть, байт в байт):
sig = "t=" + unix_timestamp + ",v1=" + hex(hmac_sha256(unix_timestamp + "." + raw_body, secret))
Проверка на принимающей стороне:
t=…,v1=… из raw body (как оно пришло, до каких-либо парсингов) и своего секрета.hash_equals, Python hmac.compare_digest) — не через ==.|now - t| в пределах допуска (gnexus-auth — 5 минут) → защита от replay.Секрет — свой у каждой цели (per-target, поле token_ref в channel_targets.config), значения в .env/gnexus-creds (переменная S2S_SECRET_<token_ref в UPPER_SNAKE>), в БД только ссылки. Ротация секрета цели не затрагивает правила маршрутизации.
Цепочка доверия: получатель проверил подпись ⇒ целостность и авторство Synapse. Синтезировать чужое событие Synapse не может — на приёме его ключ и source сверились бы с реестром (403), а ретрансляция чужого ключом источника невозможна, поскольку событие должно нести source, совпадающий с ключом (анти-спуфинг).
Тело s2s-доставки — конверт события без изменений (описание источника в него не входит):
{
"event_id": "uuid",
"source": "monitoring",
"subject": "container", "action": "down", "priority": "critical",
"payload": { "container": "nomin-web", "host": "melody" }
}
description — атрибут записи источника в реестре Synapse (админка), а не поля события: конверт и s2s-вебхук его не несут. Получатель видит имя источника в X-Synapse-Source; зачем оно — смотрит в админке Synapse, где у каждого источника/цели прописано описание («что это за сервис» для человека и ИИ-агента, не гадать по названию).
Отправка: первая попытка — сразу при маршрутизации события в воркере; провал → общий ретрай-цикл (30 с → 2 м → 10 м → 30 м). Референс-код Synapse: app/signature.py (подпись/проверка — зеркало php-класса выше), app/worker/senders.py (отправщик). Эту пару модулей можно заимствовать в принимающем сервисе (только Python; Navi-инстансы на другом стеке — по этой же спецификации).
api_keys (хэш, привязка к source) · notification_types(source, subject, action) · routing_rules (условия, действия, шаблон) · events (конверт + payload + статус) · deliveries (канал, цель, статус, попытки, ошибки) · channel_targets.