# 05 — Ingestion API: контракт приёма событий (v1, принят)

Договор о том, как сервисы экосистемы (bugtrail, gntodo, smart-home, Navi-инстансы, gnexus-auth, ad-hoc скрипты) шлют события в Synapse.

## Инвариант

**Источник знает только «что случилось у меня».** В конверте события нет получателей, каналов и подписок — выбор «кому, куда и каким каналом» целиком принадлежит Routing Engine (правилам в админке). Клиент, который «знает про других клиентов» — признак ошибки проектирования. Когда-то понадобятся адресные алерты конкретному человеку (например assignee bugtrail) — путь через payload-переменную в шаблоне (`{{ payload.assignee_email }}`), не через поля конверта.

## Endpoint

```
POST /api/v1/events
Authorization: Bearer syn_<token>       # единый паттерн экосистемы (Bearer)
Content-Type: application/json
```

Для пачек: `POST /api/v1/events/batch` — тот же конверт в массиве, ответ — список id (частичная валидация честная).

## Конверт

```json
{
  "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-каналы (#29) конкретному пользователю.

Формат значения: непустая строка (число тоже примем — приведётся к строке). Всё прочее (массив, объект, 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`:

```json
{
  "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.

## Типы и правила (админка) — Routing Engine

### Реестр типов

Тройка `(source, subject, action)`, опционально JSON Schema payload'а (валидация мягкая, в воркере). Заводит админ Synapse в UI.

### Правило маршрутизации

Условия (`conditions` JSONB):

```json
{
  "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`). Событие попадает пользователю в личный лог (`/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`).

## Доставка s2s: подпись и верификация

Когда правило направляет событие в системный 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))
```

Проверка на принимающей стороне:

1. Вычислить ожидаемую строку `t=…,v1=…` из **raw body** (как оно пришло, до каких-либо парсингов) и своего секрета.
2. Сравнить через **константное время** (PHP `hash_equals`, Python `hmac.compare_digest`) — не через `==`.
3. Свежесть: `|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-доставки — конверт события без изменений (описание источника в него **не входит**):

```json
{
  "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-инстансы на другом стеке — по этой же спецификации).

## Таблицы БД (постановка #34)

`api_keys` (хэш, привязка к source) · `notification_types(source, subject, action)` · `routing_rules` (условия, действия, шаблон) · `events` (конверт + payload + статус) · `deliveries` (канал, цель, статус, попытки, ошибки) · `channel_targets`.