1386 lines
70 KiB
Markdown
1386 lines
70 KiB
Markdown
# module-01. Проектная спецификация `api-backend`
|
||
|
||
> Статус: целевая спецификация реализации MVP.
|
||
> Язык реализации: Python, FastAPI.
|
||
> Канонические источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md).
|
||
|
||
## 1. Назначение и приоритет
|
||
|
||
Документ определяет внутреннее устройство, модель данных, алгоритмы, эксплуатационные требования и Definition of Done сервиса `api-backend`. Он детализирует, но не изменяет зафиксированные архитектурные контракты.
|
||
|
||
При конфликте применяются приоритеты из `README.md`: канонические имена и семантика — `arch-00`, границы и сценарии — `arch-01`, HTTP-контракты — `arch-02`, инфраструктура — `arch-03`, настройки — `arch-04`, процесс — `arch-05`. Любое необходимое изменение внешнего контракта сначала вносится в `arch-02`, а не скрыто реализуется в модуле.
|
||
|
||
Все публичные пользовательские endpoint имеют префикс **`/api/v1`**. Все Open Lines internal endpoint, которыми владеет или которые вызывает `api-backend`, имеют префикс **`/internal/openlines/v1`**. Internal API не публикуется через `nginx`.
|
||
|
||
## 2. Ответственность и границы
|
||
|
||
### 2.1. Сервис отвечает за
|
||
|
||
- публичные настройки и контент frontend;
|
||
- проверку access token Keycloak по OIDC discovery/JWKS;
|
||
- локальный `find-or-create` пользователя после OTP, согласия и минимальный профиль;
|
||
- аналитические `UxSession`;
|
||
- чтение профиля и документов;
|
||
- создание и чтение диалогов и сообщений;
|
||
- авторизацию доступа к каждой пользовательской сущности через текущий `user_id`;
|
||
- upload lifecycle вложений: init, presigned PUT в S3-quarantine, complete, проверка metadata, promote/delete;
|
||
- orchestration Message Safety: check, sync-poll `task_id`, checkpoint и recovery;
|
||
- надежную и идемпотентную доставку разрешённых сообщений в `bitrix-local-app`;
|
||
- приём нормализованного inbox Open Lines и сохранение сообщений оператора;
|
||
- WebSocket realtime и REST polling fallback;
|
||
- API-level rate limiting;
|
||
- аудит, метрики, трассировку, health и readiness;
|
||
- чтение `app_settings`, `text_resources`, `popular_questions`;
|
||
- internal settings bridge для Keycloak SPI.
|
||
- Notification Center G/P: каталог, public/JWT/internal API, lifecycle, документы, realtime и фоновые задачи.
|
||
|
||
### 2.2. Сервис не отвечает за
|
||
|
||
- ввод, отправку и проверку OTP, refresh token grant и хранение IdP-сессий;
|
||
- Keycloak Admin API в hot path;
|
||
- выбор sync/async способа проверки Message Safety и сам анализ содержимого;
|
||
- OAuth Bitrix24, connector setup, `ONIMCONNECTOR*`, `dialog_sessions`;
|
||
- CRM Contact mapping и двустороннюю CRM-синхронизацию;
|
||
- ручное создание задач CRM sync: их создают только PostgreSQL-триггеры;
|
||
- хранение надежных бизнес-событий только в Redis;
|
||
- проксирование байтов upload/download через API;
|
||
- доставку документов компании из Bitrix24 в MVP;
|
||
- редактирование профиля через публичный API.
|
||
|
||
### 2.3. Зависимости
|
||
|
||
| Зависимость | Использование | Допустимая деградация |
|
||
|---|---|---|
|
||
| Managed PostgreSQL, схема `han_app` | источник прикладных данных и checkpoint | без БД сервис not-ready |
|
||
| Redis DB0 | rate limit, idempotency cache | write API fail-closed; read API ограниченно деградирует |
|
||
| Redis DB1 | realtime coordination | REST работает, WS/publish деградирует |
|
||
| Keycloak discovery/JWKS | JWT validation | cached JWKS разрешён до истечения cache; без валидного ключа protected API fail-closed |
|
||
| `message-safety` | проверка сообщений клиента | отправка сообщений недоступна, чтение работает |
|
||
| `bitrix-local-app` | Open Lines delivery/status | сообщение фиксируется как `failed`, recovery по outbox |
|
||
| Selectel S3 | вложения и документы | файловые операции недоступны, текстовый чат продолжает работать |
|
||
| OTEL Collector | telemetry export | не блокирует бизнес-запросы |
|
||
|
||
## 3. Технологический профиль
|
||
|
||
- Python 3.12+.
|
||
- FastAPI + Pydantic v2.
|
||
- ASGI server: Uvicorn; production — один контейнер с настраиваемым числом workers.
|
||
- SQLAlchemy 2 async + `asyncpg`.
|
||
- Alembic для миграций.
|
||
- `httpx.AsyncClient` для internal HTTP.
|
||
- S3-compatible async client или вызовы boto3 через ограниченный thread pool.
|
||
- Redis asyncio client.
|
||
- OpenTelemetry instrumentation для ASGI, HTTP client, SQLAlchemy и Redis.
|
||
- `structlog` либо стандартный logging с JSON formatter.
|
||
- Тесты: pytest, pytest-asyncio/anyio, HTTPX ASGI client, Testcontainers либо выделенная тестовая managed PostgreSQL-схема.
|
||
|
||
**Решение M1:** доменная логика не размещается в router-функциях и ORM-моделях. Router выполняет parsing/auth/dependency injection; use case задаёт транзакционную границу; repository работает с хранилищем; integration adapter инкапсулирует внешний протокол.
|
||
|
||
## 4. Структура FastAPI-компонентов
|
||
|
||
```text
|
||
api-backend/
|
||
app/
|
||
main.py
|
||
bootstrap.py
|
||
api/
|
||
dependencies.py
|
||
errors.py
|
||
middleware.py
|
||
routers/
|
||
public.py
|
||
auth.py
|
||
analytics.py
|
||
consents.py
|
||
profile.py
|
||
dialogs.py
|
||
attachments.py
|
||
realtime.py
|
||
internal_openlines.py
|
||
internal_settings.py
|
||
health.py
|
||
schemas/
|
||
common.py
|
||
auth.py
|
||
chat.py
|
||
profile.py
|
||
public.py
|
||
internal.py
|
||
realtime.py
|
||
application/
|
||
auth_bootstrap.py
|
||
consent_service.py
|
||
session_service.py
|
||
dialog_service.py
|
||
message_service.py
|
||
attachment_service.py
|
||
inbound_openlines.py
|
||
delivery_recovery.py
|
||
safety_recovery.py
|
||
settings_service.py
|
||
realtime_service.py
|
||
domain/
|
||
entities.py
|
||
enums.py
|
||
policies.py
|
||
errors.py
|
||
infrastructure/
|
||
db/
|
||
models.py
|
||
repositories/
|
||
unit_of_work.py
|
||
auth/
|
||
jwks.py
|
||
principal.py
|
||
clients/
|
||
message_safety.py
|
||
openlines.py
|
||
redis/
|
||
idempotency.py
|
||
rate_limit.py
|
||
realtime.py
|
||
s3/
|
||
client.py
|
||
keys.py
|
||
observability/
|
||
logging.py
|
||
metrics.py
|
||
tracing.py
|
||
workers/
|
||
delivery_outbox.py
|
||
safety_recovery.py
|
||
quarantine_cleanup.py
|
||
inbound_attachment.py
|
||
notification_expire.py
|
||
notification_draft_cleanup.py
|
||
settings.py
|
||
alembic/
|
||
tests/
|
||
unit/
|
||
integration/
|
||
contract/
|
||
e2e/
|
||
openapi.yaml
|
||
Dockerfile
|
||
docker-compose.yml
|
||
pyproject.toml
|
||
```
|
||
|
||
### 4.1. Middleware, порядок
|
||
|
||
1. trusted proxy middleware — принимает forwarded headers только от `nginx`;
|
||
2. request-id — валидирует/принимает `X-Request-ID` либо генерирует UUID/ULID;
|
||
3. W3C trace context;
|
||
4. structured access logging;
|
||
5. CORS из `security.cors.allowed_origins`;
|
||
6. error mapper в единый envelope;
|
||
7. metrics;
|
||
8. route dependencies: JWT, ownership, rate limit, idempotency.
|
||
|
||
Middleware не читает request body повторно и не логирует body, Authorization, query token WS или presigned URL.
|
||
|
||
## 5. API conventions
|
||
|
||
### 5.1. Заголовки
|
||
|
||
| Заголовок | Правило |
|
||
|---|---|
|
||
| `Authorization: Bearer <access_token>` | обязателен для protected REST |
|
||
| `X-Request-ID` | опционален от клиента; всегда присутствует в ответе |
|
||
| `X-Ux-Session-Id` | UUID, рекомендуется во всех JWT-запросах; не является auth |
|
||
| `Idempotency-Key` | обязателен для создания диалога и сообщения |
|
||
| `traceparent` | принимается и передаётся downstream |
|
||
| `Retry-After` | возвращается при retryable `429`, иногда `503` |
|
||
|
||
Все даты — RFC 3339 UTC. Публичные id — UUID. Неизвестные поля request DTO запрещаются (`extra="forbid"`). Размер строк, массивов и query limit ограничивается схемой.
|
||
|
||
### 5.2. Error envelope
|
||
|
||
```json
|
||
{
|
||
"error": {
|
||
"code": "not_found",
|
||
"message": "Resource was not found",
|
||
"request_id": "01J00000000000000000000000",
|
||
"details": {}
|
||
}
|
||
}
|
||
```
|
||
|
||
Обязательный каталог:
|
||
|
||
| HTTP | `code` |
|
||
|---|---|
|
||
| 400 | `validation_error`, `phone_claim_missing`, `mixed_content_not_allowed`, `empty_message`, `too_many_attachments`, `attachment_not_completed`, `attachment_checksum_mismatch` |
|
||
| 401 | `unauthorized`, `token_expired` |
|
||
| 403 | `consents_required`, `forbidden` |
|
||
| 404 | `not_found` |
|
||
| 409 | `idempotency_key_reused`, `resource_state_conflict` |
|
||
| 422 | `message_blocked` |
|
||
| 429 | `rate_limit_exceeded` |
|
||
| 503 | `dependency_unavailable` |
|
||
| 504 | `dependency_timeout` |
|
||
|
||
Pydantic `422` преобразуется в `400 validation_error`, чтобы соответствовать каноническому каталогу. `details` содержит только безопасные имена полей/ограничения. Для чужой сущности возвращается `404`, а не `403`.
|
||
|
||
### 5.3. Pagination
|
||
|
||
- dialogs: keyset cursor по `(updated_at DESC, id DESC)`;
|
||
- messages: keyset cursor по `(created_at ASC, id ASC)`;
|
||
- cursor — base64url от versioned JSON + HMAC, frontend его не разбирает;
|
||
- default `limit=50`, max `100`;
|
||
- `after` в polling означает строго позже позиции cursor.
|
||
|
||
## 6. Public и protected REST endpoints
|
||
|
||
### 6.1. Публичные read-only
|
||
|
||
#### `GET /api/v1/public/app-config`
|
||
|
||
Ответ — строгий DTO, не dump `app_settings`:
|
||
|
||
```json
|
||
{
|
||
"auth": {"phone_enabled": true, "password_enabled": false},
|
||
"operator": {"call_phone": "+74999591007"},
|
||
"messages": {"max_text_length": 4000},
|
||
"consents": {
|
||
"personal_data": {
|
||
"required": true,
|
||
"document_url": "https://www.han0107.ru/privacy/persdata-agree-mobile",
|
||
"privacy_policy_document_url": "https://www.han0107.ru/privacy",
|
||
"version": "2026-06-10"
|
||
},
|
||
"user_agreement": {"required": true, "document_url": "https://...", "version": "2026-06-10"},
|
||
"marketing": {"required": false, "document_url": "https://www.han0107.ru/privacy/ads-agree", "version": "2026-06-10"}
|
||
},
|
||
"attachments": {
|
||
"allowed_extensions": ["jpg", "jpeg", "png", "webp", "heic", "heif", "pdf"],
|
||
"allowed_mime_types": ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "application/pdf"],
|
||
"max_size_mb": 5
|
||
},
|
||
"ux": {"idle_timeout_minutes": 30}
|
||
}
|
||
```
|
||
|
||
`Cache-Control: public, max-age=<security.public_cache.max_age_seconds>`, ETag по версии cache. Rate limit per IP.
|
||
|
||
#### `GET /api/v1/public/content`
|
||
|
||
Возвращает только active `text_resources` и active `popular_questions`, отсортированные по `sort_order`.
|
||
|
||
```json
|
||
{
|
||
"locale": "ru",
|
||
"texts": {"home.welcome.title": "string"},
|
||
"popular_questions": [{"id": "uuid", "mnemonic": "string", "text": "string"}],
|
||
"version": "opaque"
|
||
}
|
||
```
|
||
|
||
**Допущение A1:** MVP принимает необязательный `locale`, но поддерживает только `ru`; иной locale нормализуется к `ru`. Это сохраняет будущую расширяемость без заявления о мультиязычности.
|
||
|
||
### 6.2. Auth bootstrap и согласия
|
||
|
||
#### `POST /api/v1/auth/bootstrap`
|
||
|
||
JWT обязателен. Request:
|
||
|
||
```json
|
||
{
|
||
"consents": {
|
||
"personal_data": {"accepted": true, "version": "2026-06-10"},
|
||
"user_agreement": {"accepted": true, "version": "2026-06-10"},
|
||
"marketing": {"accepted": false, "version": "2026-06-10"}
|
||
},
|
||
"device": {"platform": "ios", "app_version": "1.0.0", "device_id": "opaque"}
|
||
}
|
||
```
|
||
|
||
Response `200`: `{"user_id":"uuid","profile_ready":true}`.
|
||
|
||
Алгоритм в одной SERIALIZABLE-повторяемой либо READ COMMITTED + unique/upsert транзакции:
|
||
|
||
```text
|
||
principal = validate_jwt()
|
||
phone = canonical_phone_claim(principal)
|
||
if phone absent/invalid E.164: phone_claim_missing
|
||
validate consent versions against active app_settings
|
||
require required consents accepted
|
||
|
||
BEGIN
|
||
INSERT UserIdentity(keycloak_sub, phone_number, last_login_at)
|
||
ON CONFLICT(keycloak_sub) DO UPDATE phone_number, last_login_at
|
||
INSERT ClientProfile(user_id, russian_phone=phone) ON CONFLICT(user_id) DO NOTHING
|
||
INSERT immutable UserConsent rows for submitted versions
|
||
INSERT audit auth.bootstrap
|
||
COMMIT
|
||
return stable user_id
|
||
```
|
||
|
||
Повторный bootstrap безопасен: уникальный ключ согласия не создаёт дубль; `last_login_at` обновляется. Триггеры на `UserIdentity`/`ClientProfile` создают `contact.map_or_create`/`contact.update` в `sync_queue`; application code задач не вставляет.
|
||
|
||
#### `POST /api/v1/consents`
|
||
|
||
JWT, существующий пользователь. Сохраняет новые immutable записи согласий. Обязательные актуальные версии должны быть приняты. Response `201` с `recorded_at` и версиями.
|
||
|
||
### 6.3. UX session
|
||
|
||
#### `POST /api/v1/analytics/session-start`
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"start_reason": "first_launch",
|
||
"device": {"platform": "web", "app_version": "1.0.0", "device_id": "opaque"}
|
||
}
|
||
```
|
||
|
||
`start_reason`: `first_launch | cold_start | idle_timeout`. Response `201`:
|
||
|
||
```json
|
||
{"ux_session_id":"uuid","started_at":"2026-07-08T12:00:00Z"}
|
||
```
|
||
|
||
Создаёт `UxSession` и audit `session_start` атомарно. Не влияет на auth. Параметры устройства, включая исходный `device_id`, сохраняются в `UxSession` и в snapshot `UserConsent.device_json`; в audit/log значение не копируется.
|
||
|
||
### 6.4. Profile и documents
|
||
|
||
- `GET /api/v1/me` → блочный readonly профиль.
|
||
- `GET /api/v1/me/documents` → cursor list; в MVP обычно пустой.
|
||
- `GET /api/v1/documents/{document_id}` → metadata, только owner.
|
||
- `GET /api/v1/documents/{document_id}/download-url` → короткий presigned GET + audit.
|
||
|
||
`GET /api/v1/me`:
|
||
|
||
```json
|
||
{
|
||
"user_id": "uuid",
|
||
"profile": {
|
||
"personal_data": {
|
||
"full_name": null,
|
||
"citizenship": null,
|
||
"russian_phone": "+79991234567",
|
||
"foreign_phone": null,
|
||
"email": null
|
||
},
|
||
"documents": {"count": 0}
|
||
}
|
||
}
|
||
```
|
||
|
||
PATCH/PUT профиля в MVP отсутствует.
|
||
|
||
### 6.5. Dialogs
|
||
|
||
- `POST /api/v1/dialogs` — JWT + `Idempotency-Key`; `201` новый либо `200` существующий active.
|
||
- `GET /api/v1/dialogs` — история.
|
||
- `GET /api/v1/dialogs/{dialog_id}` — карточка.
|
||
- `GET /api/v1/dialogs/{dialog_id}/messages?after=&limit=` — история/polling.
|
||
|
||
`DialogSummary`:
|
||
|
||
```json
|
||
{
|
||
"dialog_id": "uuid",
|
||
"status": "waiting_for_company",
|
||
"last_message_preview": "string-or-null",
|
||
"unread_count": 0,
|
||
"created_at": "2026-07-09T12:00:00Z",
|
||
"updated_at": "2026-07-09T12:01:00Z"
|
||
}
|
||
```
|
||
|
||
Один active dialog на пользователя обеспечивается partial unique index. Создание:
|
||
|
||
```text
|
||
BEGIN
|
||
SELECT active dialog WHERE user_id=:current_user FOR UPDATE
|
||
if found: return 200
|
||
INSERT Dialog(id=uuid, user_id, status='open')
|
||
COMMIT
|
||
return 201
|
||
```
|
||
|
||
Конфликт concurrent INSERT перехватывается, после rollback читается победившая active запись и возвращается `200`.
|
||
|
||
### 6.6. Send message
|
||
|
||
#### `POST /api/v1/dialogs/{dialog_id}/messages`
|
||
|
||
JWT + ownership + required `Idempotency-Key`.
|
||
|
||
Text request: `{"content_kind":"text","text":"Здравствуйте"}`.
|
||
File request: `{"content_kind":"file","attachment_id":"uuid","checksum":"sha256:<hex>"}`.
|
||
|
||
Success `201` возвращает финальный `MessageResponse`:
|
||
|
||
```json
|
||
{
|
||
"message_id": "uuid",
|
||
"dialog_id": "uuid",
|
||
"sender_type": "client",
|
||
"content_kind": "text",
|
||
"text": "Здравствуйте",
|
||
"attachments": [],
|
||
"safety_status": "allowed",
|
||
"delivery_status": "delivered",
|
||
"created_at": "2026-07-09T12:00:00Z"
|
||
}
|
||
```
|
||
|
||
На safety deny — `422 message_blocked`; blocked message допустимо сохранять для аудита, но его текст должен храниться по политике минимизации данных (см. решение M8). В той же транзакции backend создаёт отдельную локальную `company`-реплику с безопасным бизнес-текстом для сообщения или документа; эта реплика публикуется в realtime, но не отправляется в Open Lines. На dependency failure — `503/504`; если Message уже создан, его `delivery_status=failed`.
|
||
|
||
### 6.7. Attachments
|
||
|
||
- `POST /api/v1/dialogs/{dialog_id}/attachments/init`;
|
||
- `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete`;
|
||
- `GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url`.
|
||
|
||
Init request:
|
||
|
||
```json
|
||
{"file_name":"scan.pdf","mime_type":"application/pdf","size_bytes":12345}
|
||
```
|
||
|
||
Init response `201`:
|
||
|
||
```json
|
||
{
|
||
"attachment_id":"uuid",
|
||
"upload_url":"https://presigned...",
|
||
"upload_headers":{"Content-Type":"application/pdf"},
|
||
"expires_at":"2026-07-09T12:10:00Z"
|
||
}
|
||
```
|
||
|
||
Complete request: `{"checksum":"sha256:<64-lowercase-hex>"}`. Response `200` возвращает metadata и `scan_status=pending`.
|
||
|
||
Init и complete должны быть idempotent по состоянию attachment; повтор complete с тем же checksum возвращает прежний результат, с другим — `409 resource_state_conflict`.
|
||
|
||
### 6.8. Notification Center
|
||
|
||
Контракты путей и DTO — arch-02 и `notification-requirements.md`. Реализация читает `notification_types` и реестры CTA/кнопок/цветов; ветвление по `notification_type` запрещено. Home/center/counter применяют серверные лимиты 7/15, эффективный приоритет `COALESCE(priority_override, type.priority)` и ownership.
|
||
|
||
Действие скрытия всегда ставит `visibility='hidden'`. Если `date_expired` уже задано, оно сохраняется; TTL вида/default устанавливает `date_expired=now()+N days` только при `NULL`. Первое скачивание любого связанного документа при `hide_on_document_download=true` атомарно применяет этот эффект один раз.
|
||
|
||
`install_app_prompt` возвращает только внешнее действие открытия `instruction_url` в новой вкладке. Режимы iframe/modal и allow-list для них отсутствуют.
|
||
|
||
## 7. Internal endpoints
|
||
|
||
### 7.1. Inbox Open Lines
|
||
|
||
#### `POST /internal/openlines/v1/inbox`
|
||
|
||
Владелец — `api-backend`; caller — `bitrix-local-app`. Защита: private network + `Authorization: Bearer <BITRIX_API_INBOX_TOKEN>`, constant-time compare.
|
||
|
||
```json
|
||
{
|
||
"event_id":"stable-opaque",
|
||
"event_type":"message.new",
|
||
"external_chat_id":"uuid",
|
||
"bitrix_message_id":"string",
|
||
"occurred_at":"2026-07-09T12:00:00Z",
|
||
"message":{
|
||
"text":"Ответ",
|
||
"files":[{
|
||
"name":"scan.pdf",
|
||
"mime_type":"application/pdf",
|
||
"size_bytes":12345,
|
||
"download_url":"https://..."
|
||
}]
|
||
}
|
||
}
|
||
```
|
||
|
||
Поддерживаемые MVP события:
|
||
|
||
- `message.new`;
|
||
- `dialog.closed`.
|
||
|
||
Результаты:
|
||
|
||
- `201` — событие впервые применено;
|
||
- `200` или `204` — duplicate-ack;
|
||
- `400` — invalid normalized payload;
|
||
- `404` — неизвестный `external_chat_id`, retry допустим ограниченно;
|
||
- `503/504` — временная зависимость; local app повторяет.
|
||
|
||
Idempotency `message.new`: unique `(external_chat_id, bitrix_message_id)`. Для `dialog.closed`, где message id отсутствует, используется `event_id`; **допущение A2:** OpenAPI internal inbox должен сделать `event_id` обязательным для всех событий, сохранив `bitrix_message_id` обязательным для `message.new`. До уточнения upstream допустимый fallback fingerprint — SHA-256 от стабильных полей события.
|
||
|
||
### 7.2. Settings bridge
|
||
|
||
#### `GET /internal/settings/v1/otp`
|
||
|
||
Private network + Bearer `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`.
|
||
|
||
```json
|
||
{
|
||
"max_send_attempts_per_24h":3,
|
||
"min_seconds_between_attempts":30,
|
||
"version":"2026-07-10T08:00:00Z",
|
||
"cache_ttl_seconds":60
|
||
}
|
||
```
|
||
|
||
Не содержит PII или secrets. ETag/`If-None-Match` поддерживаются. Если обязательные настройки отсутствуют, endpoint возвращает `503`, а readiness — false.
|
||
|
||
### 7.3. Исходящие Open Lines calls
|
||
|
||
`api-backend` вызывает только:
|
||
|
||
- `POST /internal/openlines/v1/messages`;
|
||
- `GET /internal/openlines/v1/dialogs/{external_chat_id}`;
|
||
- опционально для readiness — `GET /internal/openlines/v1/status`.
|
||
|
||
Все вызовы: private network, `Authorization: Bearer <BITRIX_LOCAL_APP_INTERNAL_TOKEN>`, `X-Request-ID`, `traceparent`, idempotency по `message_id`.
|
||
|
||
### 7.4. Internal Notifications
|
||
|
||
- `POST /internal/notifications/v1/notifications` — Create;
|
||
- `POST /internal/notifications/v1/notifications/cancel` — Cancel.
|
||
|
||
Bearer token отдельный для каждого `source`; secret приходит из `NOTIFICATIONS_TOKEN_<SOURCE>`, в `notification_sources` хранится только hash, сравнение constant-time. `producer_test`/`NOTIFICATIONS_TOKEN_PRODUCER_TEST` служат smoke API. `(source, external_id)` уникальна бессрочно: одинаковый fingerprint → `200`, другой → `409 notification_conflict`.
|
||
|
||
## 8. JWT, JWKS и phone claims
|
||
|
||
### 8.1. Validation
|
||
|
||
Для каждого protected REST/WS:
|
||
|
||
1. извлечь Bearer token;
|
||
2. декодировать header, разрешить только настроенные asymmetric algorithms (`RS256` по умолчанию), запретить `none` и symmetric algorithms;
|
||
3. выбрать ключ по `kid` из JWKS cache;
|
||
4. при неизвестном `kid` выполнить один controlled refresh JWKS (single-flight);
|
||
5. проверить подпись, `iss == KEYCLOAK_PUBLIC_URL/realms/<KEYCLOAK_REALM>`, audience `KEYCLOAK_AUDIENCE`, `exp`, `nbf` с малым clock skew;
|
||
6. требовать непустой `sub`;
|
||
7. сформировать immutable `Principal`.
|
||
|
||
JWKS cache имеет positive TTL и short negative TTL для неизвестного `kid`; stale cached keys разрешены только в ограниченном grace window при временной недоступности Keycloak. Token и claims целиком не логируются.
|
||
|
||
### 8.2. Phone claim
|
||
|
||
Канонический claim задаётся realm mapping. Порядок MVP:
|
||
|
||
1. `phone_number`;
|
||
2. fallback `preferred_username` только если значение валидно как E.164.
|
||
|
||
Телефон нормализуется библиотекой libphonenumber и сохраняется в E.164. Body никогда не является источником auth-телефона. Отсутствие/невалидность при bootstrap → `400 phone_claim_missing`.
|
||
|
||
**Решение M2:** список phone claim names не вводится как новая бизнес-настройка. Это realm/infra contract; реализация фиксирует приоритет выше и покрывает его contract test. Если realm изменится, сначала обновляются архитектура/OpenAPI и realm config.
|
||
|
||
### 8.3. User resolution
|
||
|
||
После bootstrap `sub` разрешается в active `UserIdentity`. Protected endpoint, кроме bootstrap, при отсутствии локального пользователя возвращает `409 resource_state_conflict` с безопасным сообщением «bootstrap required». **TBD-1:** arch-02 допускает `404/409` только для consents; перед публикацией OpenAPI выбрать единый код. До решения используется `409`.
|
||
|
||
## 9. PostgreSQL: схема `han_app`
|
||
|
||
### 9.1. Общие правила
|
||
|
||
- UUID: PostgreSQL `uuid`, генерируется приложением UUIDv7 либо `gen_random_uuid()`.
|
||
- timestamps: `timestamptz`, UTC.
|
||
- все основные прикладные сущности: `id`, `record_status`, `status_changed_at`, `status_change_reason`, `created_at`, `updated_at`, `updater_user_id`;
|
||
- soft delete: `A`/`D`; физическое удаление прикладных строк запрещено;
|
||
- технические queue/checkpoint таблицы удаляются/архивируются по retention, что является разрешённым исключением;
|
||
- FK по умолчанию `ON DELETE RESTRICT`;
|
||
- строковые enum — PostgreSQL `varchar` + CHECK, чтобы миграция enum не блокировала rollout;
|
||
- все SQL параметризованы;
|
||
- DB role `han_app` имеет least privilege только на схему `han_app`;
|
||
- RLS в MVP не является единственным механизмом доступа; ownership всегда проверяет repository query.
|
||
|
||
### 9.2. `user_identities`
|
||
|
||
| Поле | Тип/ограничение |
|
||
|---|---|
|
||
| `id` | uuid PK |
|
||
| `keycloak_sub` | varchar(255) NOT NULL UNIQUE |
|
||
| `phone_number` | varchar(32) NOT NULL |
|
||
| `last_login_at` | timestamptz NOT NULL |
|
||
| common fields | обязательны |
|
||
|
||
Индексы: unique `keycloak_sub`; index на normalized `phone_number` для CRM trigger/map. Телефон не объявляется unique: миграция/merge IdP может временно дать конфликт, а identity master — Keycloak.
|
||
|
||
### 9.3. `ux_sessions`
|
||
|
||
`id` = `ux_session_id`; `user_id` FK; `start_reason` CHECK; `platform`, `app_version`, `device_id`; legacy `device_id_hash` nullable; `started_at`; common fields.
|
||
|
||
Индексы: `(user_id, started_at DESC)`, `(started_at)` для retention.
|
||
|
||
### 9.4. `user_consents`
|
||
|
||
`id`, `user_id`, `ux_session_id NULL`, `consent_type`, `document_version`, `accepted`, `accepted_at`, `client_ip` (inet), `user_agent_hash`, common fields.
|
||
|
||
CHECK consent type: `personal_data | user_agreement | marketing`. Unique `(user_id, consent_type, document_version)`. Запись immutable; исправление — новая версия или audit-backed administrative action. Обязательные согласия определяются текущим `app_settings`.
|
||
|
||
### 9.5. `client_profiles`
|
||
|
||
`id`, `user_id` UNIQUE FK, `bitrix_contact_id NULL`, `full_name`, `citizenship`, `russian_phone`, `foreign_phone`, `email`, `source_updated_at`, common fields.
|
||
|
||
Индексы: unique active `user_id`; partial unique `bitrix_contact_id WHERE bitrix_contact_id IS NOT NULL AND record_status='A'`; `updated_at`. PII поля не включаются в логи и generic audit payload.
|
||
|
||
### 9.6. `dialogs`
|
||
|
||
`id` = `dialog_id` = `external_chat_id`; `user_id`; `status`; `last_message_at`; `closed_at`; common fields.
|
||
|
||
CHECK status: `open | waiting_for_company | waiting_for_client | closed`.
|
||
|
||
Индексы:
|
||
|
||
```sql
|
||
CREATE UNIQUE INDEX uq_dialog_one_active_per_user
|
||
ON han_app.dialogs(user_id)
|
||
WHERE record_status='A' AND status IN ('open','waiting_for_company','waiting_for_client');
|
||
|
||
CREATE INDEX ix_dialogs_user_updated
|
||
ON han_app.dialogs(user_id, updated_at DESC, id DESC)
|
||
WHERE record_status='A';
|
||
```
|
||
|
||
Переходы:
|
||
|
||
- new → `open`;
|
||
- delivered client message → `waiting_for_company`;
|
||
- saved company message → `waiting_for_client`;
|
||
- `dialog.closed` → `closed`;
|
||
- из `closed` обратный переход запрещён.
|
||
|
||
### 9.7. `messages`
|
||
|
||
Поля: `id`, `dialog_id`, `sender_type`, `content_kind`, `text`, `safety_status`, `delivery_status`, `external_message_id NULL`, `client_idempotency_key NULL`, `occurred_at`, common fields.
|
||
|
||
CHECK:
|
||
|
||
- sender: `client | company`;
|
||
- content: `text | file`;
|
||
- safety: `pending | allowed | blocked` (`needs_review` зарезервирован, не создаётся);
|
||
- delivery: `accepted | processing | delivered | rejected | failed`;
|
||
- text message: `text <> ''`;
|
||
- file message: `text = ''`;
|
||
- company message: `safety_status='allowed'`;
|
||
- rejected → blocked; delivered → allowed.
|
||
|
||
Индексы: `(dialog_id, created_at, id) WHERE record_status='A'`; `(delivery_status, updated_at)` для recovery; unique `(dialog_id, external_message_id)` where external id not null; unique `(dialog_id, client_idempotency_key)` where not null.
|
||
|
||
**Решение M3:** исходящее сообщение создаётся до safety со статусами `pending/accepted`, чтобы `safety_tasks` всегда имел FK и crash checkpoint. При начале poll delivery может стать `processing`; клиенту этот промежуточный ответ не отдаётся.
|
||
|
||
### 9.8. `message_attachments`
|
||
|
||
Поля: `id`, `dialog_id`, `message_id NULL`, `owner_user_id`, `direction` (`client_upload | company_inbound`), `original_file_name`, `safe_file_name`, `mime_type`, `size_bytes`, `checksum_sha256`, `scan_status`, `storage_bucket`, `object_key`, `quarantine_object_key NULL`, `upload_expires_at`, `completed_at`, common fields.
|
||
|
||
Ограничения:
|
||
|
||
- `size_bytes > 0`;
|
||
- SHA-256 — 64 lowercase hex;
|
||
- scan: `pending | clean | infected | failed`;
|
||
- до allow client file находится только в quarantine;
|
||
- attachment связывается максимум с одним message;
|
||
- для MVP у message максимум одно active attachment: unique partial `message_id`.
|
||
|
||
Индексы: `(owner_user_id, id)`, `(dialog_id, created_at)`, `(scan_status, updated_at)`, unique active object keys.
|
||
|
||
### 9.9. `documents`
|
||
|
||
Reserved MVP table: `id`, `user_id`, `name`, `mime_type`, `size_bytes`, `checksum_sha256`, `storage_bucket`, `object_key`, `sent_at`, common fields. Индекс `(user_id, sent_at DESC)`; unique `(storage_bucket, object_key)`. Заполнение внешней доставкой — post-MVP.
|
||
|
||
### 9.10. `safety_tasks`
|
||
|
||
Технический durable checkpoint:
|
||
|
||
- `id`, `task_id` UNIQUE;
|
||
- `message_id` UNIQUE;
|
||
- `attachment_id NULL`;
|
||
- `quarantine_object_key NULL`;
|
||
- `status`: `polling | finalizing | completed | failed`;
|
||
- `deadline_at`, `next_poll_at`, `attempt_count`, `last_error_code`;
|
||
- `locked_at`, `locked_by`;
|
||
- timestamps.
|
||
|
||
Индексы: `(status, next_poll_at)`, `(deadline_at)`. Worker забирает `FOR UPDATE SKIP LOCKED`. Это не очередь анализа и не заменяет `message-safety`.
|
||
|
||
### 9.11. `delivery_outbox`
|
||
|
||
Durable намерение App → Open Lines:
|
||
|
||
- `id`, `message_id` UNIQUE;
|
||
- `external_chat_id`;
|
||
- `payload_json` с versioned internal DTO, без service token/presigned permanent secrets;
|
||
- `status`: `pending | processing | delivered | retry | dead_letter`;
|
||
- `attempt_count`, `next_attempt_at`, `last_error_code`;
|
||
- `locked_at`, `locked_by`, timestamps.
|
||
|
||
Индексы: `(status, next_attempt_at)`, `(locked_at)`. Message + outbox создаются/финализируются в одной транзакции после safety allow.
|
||
|
||
### 9.12. `openlines_inbox_receipts`
|
||
|
||
Durable idempotency приёма local app:
|
||
|
||
- `id`, `event_id`, `external_chat_id`, `bitrix_message_id NULL`;
|
||
- `event_type`, `payload_fingerprint`;
|
||
- `status`: `received | processing | applied | failed`;
|
||
- `message_id NULL`, `last_error_code`, timestamps.
|
||
|
||
Unique `event_id`; unique `(external_chat_id, bitrix_message_id)` where not null. Сам retry inbox принадлежит схеме `bitrix_local`; эта таблица — receipt/application checkpoint `api-backend`, а не дублирующая очередь local app.
|
||
|
||
### 9.13. `idempotency_records`
|
||
|
||
PostgreSQL durable fallback для завершённых mutating operations:
|
||
|
||
- `scope`, `user_id`, `idempotency_key`;
|
||
- `request_fingerprint`;
|
||
- `status`: `in_progress | completed | failed`;
|
||
- `response_status`, `response_body_json`;
|
||
- `resource_type`, `resource_id`;
|
||
- `expires_at`, timestamps;
|
||
- PK/unique `(scope, user_id, idempotency_key)`.
|
||
|
||
Redis остаётся быстрым слоем по arch-02. Durable row нужна, чтобы потеря Redis не повторила side effect. **Решение M4:** это уточнение надежности, не изменение внешнего контракта.
|
||
|
||
### 9.14. `audit_events`
|
||
|
||
Append-only: `id`, `event_type`, `actor_type`, `user_id NULL`, `ux_session_id NULL`, `request_id`, `trace_id`, `resource_type`, `resource_id`, `ip`, `user_agent_hash`, `outcome`, `metadata_json`, `created_at`.
|
||
|
||
Индексы: `(user_id, created_at DESC)`, `(resource_type, resource_id)`, `(event_type, created_at DESC)`, BRIN `created_at` при росте. Запрещены raw OTP, token, PII, file contents и presigned URL.
|
||
|
||
### 9.15. Настройки и контент
|
||
|
||
- `app_settings` — поля и правила из arch-04;
|
||
- `text_resources`: `id`, `mnemonic`, `locale`, `text_value`, `sort_order`, common fields; unique active `(mnemonic, locale)`;
|
||
- `popular_questions`: `id`, `mnemonic`, `locale`, `question_text`, `sort_order`, common fields; unique active `(mnemonic, locale)`;
|
||
- `sync_queue`, `entity_external_mapping` — shared contract с `bitrix-sync`.
|
||
|
||
## 10. Alembic и транзакции
|
||
|
||
### 10.1. Migration policy
|
||
|
||
- schema-qualified DDL;
|
||
- отдельная migration DB role с DDL, runtime role без DDL;
|
||
- `alembic upgrade head` — отдельный deploy step до старта новой версии;
|
||
- миграции backward-compatible по expand/migrate/contract;
|
||
- destructive migration только после отдельного согласования и backup;
|
||
- seed `app_settings` idempotent и versioned;
|
||
- PostgreSQL triggers CRM sync создаются миграциями владельца App DB;
|
||
- migration smoke test с пустой БД и upgrade от предыдущей release;
|
||
- downgrade не обещается для data migration; вместо него forward-fix.
|
||
|
||
### 10.2. Транзакционные границы
|
||
|
||
Одна DB-транзакция не держится во время HTTP/S3 вызовов. Используется prepare → external call → finalize:
|
||
|
||
1. короткая транзакция создаёт message/checkpoint;
|
||
2. external safety poll выполняется без DB lock;
|
||
3. короткая транзакция блокирует message и применяет verdict идемпотентно;
|
||
4. S3 promote выполняется идемпотентно между checkpoint states;
|
||
5. message + delivery outbox фиксируются атомарно;
|
||
6. worker выполняет Open Lines call без открытой DB-транзакции;
|
||
7. результат фиксируется под row lock.
|
||
|
||
Isolation default READ COMMITTED. Для конкурентных state transitions — `SELECT ... FOR UPDATE`; для очередей — `SKIP LOCKED`; deadlock/serialization failure повторяется ограниченно с jitter.
|
||
|
||
## 11. Redis DB0/DB1
|
||
|
||
### 11.1. DB0: rate limits и idempotency
|
||
|
||
Ключи не содержат телефон, token или raw PII.
|
||
|
||
| Key | Value | TTL |
|
||
|---|---|---|
|
||
| `han:api:rl:user:{user_id}:{route}:{window}` | counter/token bucket | длина окна + jitter |
|
||
| `han:api:rl:ip:{ip_hash}:{route}:{window}` | counter | длина окна + jitter |
|
||
| `han:api:rl:dialog:{dialog_id}:message:{window}` | counter | длина окна + jitter |
|
||
| `han:api:rl:service:{service}:{route}:{window}` | counter | длина окна + jitter |
|
||
| `han:api:idem:{scope}:{user_id}:{key_hash}` | state, fingerprint, response | 24 часа |
|
||
| `han:api:idemlock:{scope}:{user_id}:{key_hash}` | owner token | 30 секунд, продлевается heartbeat |
|
||
| `han:api:jwks:negative:{kid_hash}` | marker | 30 секунд |
|
||
|
||
Rate limit выполняется atomic Lua script. При превышении возвращаются `429`, `Retry-After` и audit только для значимых/повторных abuse случаев.
|
||
|
||
### 11.2. DB1: realtime/coordination
|
||
|
||
| Key/channel | Назначение | TTL |
|
||
|---|---|---|
|
||
| `han:rt:user:{user_id}:connections` | set connection ids | heartbeat 90 сек |
|
||
| `han:rt:conn:{connection_id}` | user, subscriptions, server instance | 90 сек |
|
||
| `han:rt:dialog:{dialog_id}` | Pub/Sub channel | сообщения не сохраняются |
|
||
| `han:rt:user:{user_id}` | Pub/Sub channel | сообщения не сохраняются |
|
||
| `han:coord:lock:safety-recovery:{task_id}` | distributed lock | 30 сек |
|
||
| `han:coord:lock:delivery:{message_id}` | distributed lock | 30 сек |
|
||
| `han:settings:snapshot:{version}` | optional serialized public snapshot | 5 минут |
|
||
|
||
Redis Pub/Sub — ускоритель, не durable event bus. После reconnect клиент обязательно выполняет REST polling. Потеря DB1 не теряет сообщения.
|
||
|
||
## 12. Idempotency
|
||
|
||
Fingerprint = SHA-256 от canonical method + route template + normalized path params + canonical JSON body + authenticated user id. Authorization, request-id и UX-session не входят.
|
||
|
||
Алгоритм:
|
||
|
||
```text
|
||
validate key syntax/length
|
||
lookup Redis record
|
||
if completed and fingerprint equal: replay stored response
|
||
if fingerprint differs: 409 idempotency_key_reused
|
||
acquire short lock
|
||
lookup/insert durable idempotency_records
|
||
if durable completed: warm Redis and replay
|
||
if in_progress: wait briefly or return safe 409/503 Retry-After
|
||
execute use case
|
||
commit resource + durable response atomically where possible
|
||
store sanitized response in Redis for 24h
|
||
release lock
|
||
```
|
||
|
||
Не кэшируются transient `500/503/504` как окончательный результат, но уже созданный resource связывается с key, чтобы retry продолжил recovery вместо создания дубля. Ответ хранится без presigned URL; для init повтор генерирует новый URL к тому же attachment.
|
||
|
||
## 13. Message Safety: sync, poll, recovery
|
||
|
||
### 13.1. Основной алгоритм
|
||
|
||
```text
|
||
authorize dialog + current user
|
||
validate current consents and content union
|
||
enforce rate limits and idempotency
|
||
for file: lock attachment, require completed/pending and checksum equality
|
||
create Message(pending, accepted)
|
||
call POST /internal/safety/v1/messages/check
|
||
|
||
if 200 allow:
|
||
finalize_allow()
|
||
elif 403 deny:
|
||
finalize_deny()
|
||
elif 203 pending:
|
||
persist safety_tasks(task_id, deadline)
|
||
while monotonic_now < request_deadline:
|
||
sleep(backoff_with_jitter)
|
||
poll GET /internal/safety/v1/messages/tasks/{task_id}
|
||
if 200 allow: finalize_allow() and return
|
||
if 403 deny: finalize_deny() and return
|
||
if 400 and code=stub_final_error and verdict=deny and details.terminal=true:
|
||
finalize_deny() and return
|
||
if other 4xx/5xx: return mapped dependency error
|
||
mark failed, retain checkpoint/quarantine
|
||
return 504
|
||
else:
|
||
mark failed
|
||
return mapped dependency error
|
||
```
|
||
|
||
Polling interval начинается с `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`, допускает capped exponential backoff и jitter, но не превышает общий `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`. Клиенту не возвращается `203`. Terminal `400 stub_final_error` — намеренное test-only расширение `module-05`; оно преобразуется в публичный `422 message_blocked`, не считается infrastructure failure и не смешивается с malformed `400`.
|
||
|
||
### 13.2. Final allow
|
||
|
||
Для text: `safety_status=allowed`. Для file:
|
||
|
||
1. проверить checkpoint;
|
||
2. copy quarantine object в attachments bucket с conditional/idempotent key;
|
||
3. HeadObject destination, сверить checksum/size;
|
||
4. в транзакции изменить attachment на `clean`, storage location на S3-data; message на `allowed/accepted`; создать delivery outbox;
|
||
5. удалить quarantine object best-effort; при сбое cleanup повторит;
|
||
6. попытаться синхронно доставить outbox, чтобы исходный POST вернул финальный `delivered`.
|
||
|
||
### 13.3. Final deny
|
||
|
||
В транзакции: `blocked/rejected`, attachment `infected`; затем delete quarantine best-effort. В Open Lines ничего не отправляется. Realtime `message.status` публикуется, если message уже мог быть виден этому клиенту.
|
||
|
||
### 13.4. Timeout/crash recovery
|
||
|
||
Фоновый worker:
|
||
|
||
```text
|
||
SELECT due safety_tasks FOR UPDATE SKIP LOCKED
|
||
claim with lease
|
||
poll safety by task_id
|
||
if pending before recovery deadline: schedule next_poll_at
|
||
if allow: idempotent promote + delivery checkpoint
|
||
if deny (canonical 403 or test-only terminal 400): idempotent delete + reject
|
||
if budget exhausted: mark task failed, message failed, preserve audit
|
||
```
|
||
|
||
HTTP disconnect не отменяет durable recovery. Клиентский retry с тем же idempotency key получает восстановленный результат либо текущую dependency error. Recovery не принимает решение о типе анализа.
|
||
|
||
**Решение M5:** после client-facing timeout recovery budget продолжается ещё 15 минут как техническая константа модуля; до production-load test значение должно быть вынесено в infra env и добавлено в arch-04. Пока это **TBD-2**, код обязан иметь безопасный default и метрику.
|
||
|
||
## 14. S3 attachment lifecycle
|
||
|
||
Object keys не содержат original filename или PII:
|
||
|
||
```text
|
||
quarantine/users/{user_uuid}/dialogs/{dialog_uuid}/{attachment_uuid}
|
||
attachments/dialogs/{dialog_uuid}/{attachment_uuid}
|
||
documents/users/{user_uuid}/{document_uuid}
|
||
```
|
||
|
||
Lifecycle:
|
||
|
||
1. `init`: allow-list extension + declared MIME + size; create metadata; presign exact key, MIME, max size, TTL;
|
||
2. direct PUT client → S3-quarantine;
|
||
3. `complete`: HeadObject, size/MIME/checksum metadata; checksum при отсутствии trustworthy S3 checksum вычисляется safety service при scan;
|
||
4. message send: attachment ownership/state/checksum;
|
||
5. allow: copy + verify + DB finalize + quarantine delete;
|
||
6. deny: quarantine delete + infected metadata;
|
||
7. abandoned/failed: cleanup only if expired and no active safety task;
|
||
8. download: owner check → audit commit → short presigned GET.
|
||
|
||
Extension и MIME оба должны быть разрешены; server normalizes filename and sets safe `Content-Disposition`. S3 credentials never reach frontend.
|
||
|
||
**Допущение A3:** Selectel S3 может не предоставлять SHA-256 в `HeadObject`; `complete` сверяет клиентский checksum с signed metadata, а authoritative checksum подтверждает Message Safety. Если storage поддерживает checksum header, он обязателен.
|
||
|
||
Inbound operator file:
|
||
|
||
- validate count/size/MIME and URL scheme/host policy;
|
||
- protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming;
|
||
- download with timeout to temporary stream, never local persistent disk;
|
||
- optional antivirus policy; Message Safety outbound pipeline не вызывается;
|
||
- upload directly to S3-data attachments;
|
||
- only then atomically save attachment/message and ack inbox.
|
||
|
||
## 15. Open Lines outbox/inbox и delivery
|
||
|
||
### 15.1. App → local app
|
||
|
||
Outbox worker и synchronous first attempt используют один dispatcher:
|
||
|
||
1. claim `delivery_outbox` lease;
|
||
2. build `/internal/openlines/v1/messages` DTO;
|
||
3. `Idempotency-Key = message_id`;
|
||
4. file message: generate short S3-data signed URL immediately before call; URL не сохранять в outbox/log;
|
||
5. call with circuit/timeout;
|
||
6. success/duplicate ack → outbox delivered, Message delivered, Dialog `waiting_for_company`;
|
||
7. transient failure → Message failed, outbox retry with exponential backoff;
|
||
8. permanent invalid payload → dead_letter + audit/alert.
|
||
|
||
Повторная доставка безопасна только при подтверждённой idempotency local app по `message_id`. До contract test этого свойства автоматический retry после ambiguous timeout ограничивается; выполняется reconciliation через `GET /internal/openlines/v1/dialogs/{external_chat_id}` и статус local app.
|
||
|
||
### 15.2. Local app → App
|
||
|
||
`bitrix-local-app` владеет durable inbox/retry/DLQ в `bitrix_local`. `api-backend`:
|
||
|
||
1. аутентифицирует service token;
|
||
2. резервирует receipt по event id/fingerprint;
|
||
3. duplicate applied → 200/204;
|
||
4. проверяет dialog;
|
||
5. для message.new сохраняет text/files, Message `company/allowed/delivered`, Dialog `waiting_for_client`;
|
||
6. для dialog.closed переводит Dialog в `closed`;
|
||
7. commit receipt applied;
|
||
8. после commit публикует realtime;
|
||
9. local app только после ack вызывает `imconnector.send.status.delivery`.
|
||
|
||
Публикация realtime после commit; сбой publish не откатывает сообщение, polling восстановит состояние.
|
||
|
||
## 16. CRM sync — только триггеры
|
||
|
||
`api-backend` не вызывает `bitrix-sync` по HTTP в пользовательском flow и не пишет `sync_queue` вручную.
|
||
|
||
Миграции создают triggers:
|
||
|
||
- insert active `UserIdentity`/`ClientProfile` без mapping → `contact.map_or_create`;
|
||
- изменение tracked profile/auth-phone fields → `contact.update`;
|
||
- trigger строит deterministic dedup key;
|
||
- при `current_setting('han.sync_suppress', true)='true'` задача не создаётся;
|
||
- trigger и business update находятся в одной транзакции.
|
||
|
||
`bitrix-sync` получает ограниченные GRANT. Ошибка CRM не откатывает bootstrap и chat. Open Lines не зависит от CRM mapping.
|
||
|
||
## 17. Realtime
|
||
|
||
### 17.1. WebSocket contract
|
||
|
||
`WS /api/v1/realtime`, только WSS через nginx. **Решение M6:** JWT передаётся через WebSocket subprotocol, а query `access_token` поддерживается временно для совместимости, но удаляется из access logs. Предпочтительный формат: `Sec-WebSocket-Protocol: han.jwt.<base64url-token>`.
|
||
|
||
После auth:
|
||
|
||
```json
|
||
{"type":"connected","server_time":"2026-07-09T12:00:00Z"}
|
||
{"type":"subscribe","dialog_ids":["uuid"],"notifications":true}
|
||
{"type":"subscribed","dialog_ids":["uuid"],"notifications":true}
|
||
```
|
||
|
||
Events:
|
||
|
||
- `message.new` с `MessageResponse`;
|
||
- `message.status`;
|
||
- `dialog.status`;
|
||
- `notification.created`, `notification.updated`, `notification.closed` через `han:rt:user:{user_id}`, включая эхо инициатору;
|
||
- `ping`; client отвечает `pong`.
|
||
|
||
`notifications` опционально и по умолчанию `false`. Каждый subscribe проверяет ownership всех dialogs; чужие id не раскрываются. Лимиты: max connections/user, max subscriptions/connection, max frame bytes, subscribe rate. Slow consumer: bounded queue; при переполнении connection закрывается с retryable code, клиент восстанавливается polling.
|
||
|
||
### 17.2. Delivery semantics
|
||
|
||
WS — at-most-once best effort. DB — source of truth. Событие содержит `event_id` и `occurred_at` как расширение DTO реализации; **TBD-3:** добавить эти поля в OpenAPI без изменения event types. Client после reconnect:
|
||
|
||
1. refresh token при auth error;
|
||
2. reconnect с backoff 1,2,4…30s;
|
||
3. resubscribe;
|
||
4. запросить messages после последнего REST cursor;
|
||
5. если WS недоступен >30s — polling.
|
||
|
||
На одной replica возможен in-memory hub; DB1 Pub/Sub обязателен при более одной replica. G12 остаётся post-MVP для полной backpressure/sticky-session стратегии.
|
||
|
||
## 18. Settings
|
||
|
||
### 18.1. Startup
|
||
|
||
Pydantic Settings читает только infra env. `SettingsService` загружает обязательные `app_settings`, type-checks и строит immutable snapshot. Отсутствующий/невалидный обязательный ключ делает readiness false.
|
||
|
||
Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый snapshot заменяется атомарно. Ошибка refresh сохраняет last-known-good и поднимает metric. Public config ETag меняется с snapshot version.
|
||
|
||
### 18.2. Business settings
|
||
|
||
Используются все ключи seed arch-04: auth, OTP bridge, operator, consent, attachments, rate limits, UX, CORS/cache. Hardcode запрещён, кроме технических безопасных defaults, явно отмеченных TBD.
|
||
|
||
### 18.3. Infra env `api-backend`
|
||
|
||
Обязательные:
|
||
|
||
- `APP_ENV`, `API_PORT`, `LOG_LEVEL`;
|
||
- `DATABASE_URL`;
|
||
- `REDIS_URL`, `REDIS_REALTIME_URL`;
|
||
- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_AUDIENCE`;
|
||
- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`;
|
||
- `MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`, `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`;
|
||
- `MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`;
|
||
- `BITRIX_LOCAL_APP_BASE_URL`, `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, `BITRIX_API_INBOX_TOKEN`;
|
||
- `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`, `BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD`, `BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC`;
|
||
- `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`;
|
||
- `SELECTEL_S3_ENDPOINT_URL`, три bucket names, write access key/secret;
|
||
- `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||
- `NOTIFICATIONS_TOKEN_PRODUCER_TEST` и последующие `NOTIFICATIONS_TOKEN_<SOURCE>`; plaintext не сохраняется в БД/логах.
|
||
|
||
`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04.
|
||
|
||
## 19. Rate limiting
|
||
|
||
Два слоя обязательны: nginx edge и API Redis.
|
||
|
||
| Endpoint/group | Identity |
|
||
|---|---|
|
||
| public config/content | IP hash |
|
||
| bootstrap/consents/session | user + IP |
|
||
| create dialog/message | user + dialog + IP |
|
||
| attachment init/complete | user + dialog |
|
||
| download URL | user + resource group |
|
||
| notifications read/action/upload | user + IP / user / user |
|
||
| notifications public | IP hash |
|
||
| WS connect/subscribe | user + IP |
|
||
| internal inbox/settings | service identity + source network |
|
||
|
||
Значения user-facing лимитов — `app_settings`. Алгоритм — sliding window/token bucket через Lua. При Redis unavailable:
|
||
|
||
- message send, attachment init, download URL — fail-closed `503`;
|
||
- public GET — локальный conservative limiter с bounded memory;
|
||
- profile/history GET — допускается fail-open с метрикой и edge protection;
|
||
- internal inbox не отклоняется только из-за Redis: PostgreSQL idempotency остаётся.
|
||
|
||
OTP counters API не ведёт.
|
||
|
||
## 20. Resilience и circuit breakers
|
||
|
||
Для `message-safety` и `bitrix-local-app` отдельные breakers: closed/open/half-open, настройки arch-04. Считаются timeout, connect failure и 5xx; 4xx domain result не считается infrastructure failure.
|
||
|
||
Timeout budget:
|
||
|
||
- safety POST: `MESSAGE_SAFETY_POST_TIMEOUT_SEC`;
|
||
- safety poll GET: 2s на попытку, не больше общего poll budget;
|
||
- Open Lines: `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`;
|
||
- S3 operation: bounded connect/read timeout;
|
||
- inbound URL download: отдельный bounded timeout и max bytes.
|
||
|
||
Retry:
|
||
|
||
- GET/idempotent internal call — exponential backoff + full jitter;
|
||
- POST Open Lines — только с idempotency key;
|
||
- S3 copy/delete — по deterministic object key;
|
||
- не повторять safety check POST после ambiguous response без stable request/message id; сначала recovery/reconciliation.
|
||
|
||
Graceful shutdown прекращает принимать новые requests, закрывает WS, перестаёт claim worker rows, завершает текущие операции в grace period и освобождает leases.
|
||
|
||
## 21. Observability и audit
|
||
|
||
### 21.1. JSON logs
|
||
|
||
Обязательные поля: `timestamp`, `level`, `service.name=api-backend`, `module`, `event`, `request_id`, `trace_id`, `span_id`, `ux_session_id` (если передан), `route`, `method`, `status_code`, `duration_ms`, `dependency`, `error_code`.
|
||
|
||
Не логировать:
|
||
|
||
- Authorization/cookies/tokens/raw JWT;
|
||
- raw OTP;
|
||
- полный phone/email/name;
|
||
- request/response body чата;
|
||
- filenames при наличии PII;
|
||
- S3 credentials, object signed query, presigned URLs;
|
||
- Bitrix download URL;
|
||
- stack trace в публичном ответе.
|
||
|
||
### 21.2. Metrics
|
||
|
||
- request count/latency/error by route template;
|
||
- JWT/JWKS cache hit/refresh/failure;
|
||
- DB pool saturation/transaction latency;
|
||
- rate limit rejects;
|
||
- idempotency replay/conflict/in-progress;
|
||
- safety verdict/poll duration/timeout/recovery backlog;
|
||
- delivery outbox depth/age/retries/dead letter;
|
||
- inbox duplicate/apply/failure;
|
||
- S3 init/complete/promote/delete/cleanup failure;
|
||
- WS active/reconnect/publish/drop/slow consumer;
|
||
- settings snapshot age/refresh failure;
|
||
- circuit state/transitions;
|
||
- readiness dependency state.
|
||
|
||
Нельзя использовать user_id/dialog_id как metric labels.
|
||
|
||
### 21.3. Audit events
|
||
|
||
Минимум:
|
||
|
||
- `auth.bootstrap`;
|
||
- `consent.recorded`;
|
||
- `session_start`;
|
||
- `dialog.created`;
|
||
- `message.submitted`, `message.blocked`, `message.delivered`, `message.failed`;
|
||
- `attachment.upload_initialized/completed/promoted/rejected`;
|
||
- `attachment.download_url_issued`;
|
||
- `document.download_url_issued`;
|
||
- `notification.created/read/hidden/cta_invoked/button_pressed/closed`;
|
||
- `notification.document.download_url_issued`, `notification.documents.submitted`, `notification.expired_batch`;
|
||
- `openlines.inbox_applied`;
|
||
- повторные severe rate limit violations.
|
||
|
||
Audit записывается в той же транзакции с критическим изменением либо через durable outbox. Download URL выдаётся только после успешной audit записи.
|
||
|
||
## 22. Health
|
||
|
||
### `GET /health/live`
|
||
|
||
Только состояние event loop/process; не обращается к зависимостям. `200 {"status":"live"}`.
|
||
|
||
### `GET /health/ready`
|
||
|
||
Проверяет с малым timeout:
|
||
|
||
- PostgreSQL schema/revision и `SELECT 1`;
|
||
- Redis DB0 и DB1;
|
||
- наличие валидного JWKS cache/discovery;
|
||
- обязательный settings snapshot;
|
||
- S3 permissions для presign/Head/copy/delete через безопасную capability check без создания orphan;
|
||
- Message Safety readiness;
|
||
- worker heartbeat/backlog thresholds.
|
||
|
||
Open Lines недоступность отображается как component `degraded`, но не обязательно делает весь API not-ready, чтобы чтение продолжало работать. **Решение M7:** readiness HTTP `200` при доступных DB/auth/settings и `status=degraded` для Bitrix/S3 partial failure; `503` — когда сервис не способен безопасно обслуживать большинство protected API. Compose healthcheck оценивает HTTP code, мониторинг — component details.
|
||
|
||
Ответ не содержит secrets/internal credentials:
|
||
|
||
```json
|
||
{"status":"ready","components":{"postgres":"ok","redis":"ok","jwks":"ok","safety":"ok","openlines":"degraded","s3":"ok"}}
|
||
```
|
||
|
||
## 23. Security
|
||
|
||
- TLS только через nginx, internal HTTP только Docker backend network.
|
||
- Доверять proxy headers только от известных proxy CIDR.
|
||
- JWT validation fail-closed; алгоритм pinning; JWKS SSRF невозможен — URL строится из configured issuer/discovery.
|
||
- Service tokens сравниваются constant-time; rotation поддерживает current + previous token в короткое окно только после документирования env.
|
||
- Ownership predicate включён непосредственно в SQL (`id=:id AND user_id=:current_user AND record_status='A'`).
|
||
- CORS exact allow-list; wildcard с credentials запрещён.
|
||
- CSRF не требуется для Bearer API без cookie auth; если web перейдёт на cookies — обязателен CSRF token.
|
||
- Pydantic strict validation, max lengths, Unicode normalization для текста.
|
||
- SQL injection предотвращается ORM/parameterized SQL; dynamic sort только allow-list.
|
||
- SSRF защита inbound files; URL никогда не вызывается без проверки.
|
||
- S3 presign least privilege, short TTL, exact object key/content constraints.
|
||
- Content-Disposition безопасный; MIME sniffing protection.
|
||
- Secrets только env, не в repository/App DB/log.
|
||
- Dependency versions pin/lock, image scanning, non-root container, read-only filesystem, tmpfs `/tmp`.
|
||
- `docs`/OpenAPI UI в production либо отключены, либо доступны только ops; статический `openapi.yaml` остаётся артефактом.
|
||
- Error messages не раскрывают existence чужих ресурсов, SQL, hostnames и stack traces.
|
||
- Data retention и право удаления требуют отдельной legal policy; soft delete не заменяет обязательное уничтожение PII.
|
||
|
||
**Решение M8:** blocked message text не нужен продукту после deny. В `messages.text` хранится пустая строка/безопасный redacted marker, а audit хранит только rule/verdict id и hash содержимого. Если регуляторно требуется исходный текст, это отдельное согласованное изменение retention/security.
|
||
|
||
## 24. Docker/runtime
|
||
|
||
Service compose:
|
||
|
||
- `build: ./api-backend`, `expose: 8000`, без `ports`;
|
||
- networks: `backend`, `observability`;
|
||
- env только через `${VAR}` из root `.env`;
|
||
- healthcheck `/health/live` для процесса; root orchestration учитывает readiness;
|
||
- depends_on health для Redis/Keycloak/message-safety, но приложение само retry startup dependencies;
|
||
- managed PostgreSQL вне compose, TLS обязателен;
|
||
- stateless container, без persistent volume;
|
||
- init process для signal forwarding;
|
||
- non-root UID, dropped Linux capabilities, `no-new-privileges`;
|
||
- resource limits и ulimits задаются ops-профилем.
|
||
|
||
Startup:
|
||
|
||
1. parse/validate infra env;
|
||
2. configure structured logging/OTEL;
|
||
3. connect DB and verify Alembic revision;
|
||
4. connect Redis DB0/DB1;
|
||
5. load settings/content snapshot;
|
||
6. warm discovery/JWKS;
|
||
7. validate S3 bucket access;
|
||
8. start workers;
|
||
9. mark ready.
|
||
|
||
Notification expire запускается ежедневно в `notification.expire_job.run_at` с PostgreSQL advisory lock и set-based update; массовые WS-события не публикует. Cleanup удаляет просроченные drafts и соответствующие S3-объекты идемпотентно. Отдельные Compose-процессы используют зарегистрированные scripts `han-notification-expire-worker` и `han-notification-draft-cleanup-worker`; общий `han-cleanup-worker` сохраняет прежнюю очистку quarantine.
|
||
|
||
Nginx маршрутизирует `/api/*`, включая WS `/api/v1/realtime`. Для message POST `proxy_read_timeout >= MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s`. Internal paths наружу не маршрутизируются.
|
||
|
||
## 25. Ключевые user flows
|
||
|
||
### 25.1. Первый вопрос неавторизованного пользователя
|
||
|
||
1. public config/content;
|
||
2. пользователь выбирает вопрос/вводит текст;
|
||
3. frontend показывает согласия;
|
||
4. Keycloak OTP + PKCE;
|
||
5. bootstrap с consent body, identity из JWT;
|
||
6. session-start;
|
||
7. create dialog с idempotency;
|
||
8. send message с idempotency;
|
||
9. safety → Open Lines;
|
||
10. финальный MessageResponse.
|
||
|
||
Популярный вопрос не имеет отдельного backend flow: его text отправляется как обычное сообщение.
|
||
|
||
### 25.2. Возврат пользователя
|
||
|
||
Frontend выполняет refresh token grant. API не обновляет token. При новой UX-сессии — session-start; затем profile/history/chat. Успешный token refresh сам по себе новую UX session не создаёт.
|
||
|
||
### 25.3. Файловое сообщение
|
||
|
||
create/reuse dialog → init → direct PUT quarantine → complete → send file message → safety `203` poll → allow promote → delivery outbox → Open Lines → delivered. При deny quarantine удаляется, Bitrix не вызывается.
|
||
|
||
### 25.4. Ответ оператора
|
||
|
||
Bitrix event → local app durable inbox → `POST /internal/openlines/v1/inbox` → API receipt + DB message → commit → realtime → local app delivery ack. При WS failure frontend polling получает сообщение.
|
||
|
||
## 26. Тестовая стратегия
|
||
|
||
### 26.1. Unit
|
||
|
||
- JWT claims/phone normalization;
|
||
- Pydantic discriminated union text/file;
|
||
- status transition policies;
|
||
- canonical fingerprint/idempotency conflict;
|
||
- cursor encode/decode/tamper;
|
||
- rate-limit keying;
|
||
- circuit state;
|
||
- S3 object key generation;
|
||
- settings parsing/fail-fast;
|
||
- safe error/log redaction.
|
||
|
||
### 26.2. Integration
|
||
|
||
- Alembic empty upgrade and previous-version upgrade;
|
||
- unique active dialog under concurrency;
|
||
- bootstrap upsert + immutable consents + trigger-generated sync task;
|
||
- `han.sync_suppress` prevents echo;
|
||
- ownership queries and soft delete;
|
||
- outbox atomicity with message;
|
||
- inbox duplicate race;
|
||
- safety task `SKIP LOCKED` leases;
|
||
- Redis Lua limits and 24h idempotency;
|
||
- S3 init/complete/promote/delete with S3-compatible test endpoint;
|
||
- JWKS rotation/unknown kid/outage cache.
|
||
|
||
### 26.3. Contract
|
||
|
||
- generated FastAPI OpenAPI matches committed `api-backend/openapi.yaml`;
|
||
- Message Safety POST `200/203/403`, task poll `203/200/403` и test-only terminal `400 stub_final_error`; проверены различение malformed `400` и mapping terminal `400` → public `422`;
|
||
- Open Lines message idempotency and inbox schemas;
|
||
- settings bridge DTO/token;
|
||
- common request-id/trace propagation;
|
||
- error envelope for every 4xx/5xx.
|
||
|
||
### 26.4. E2E/failure
|
||
|
||
- first login → popular question delivered;
|
||
- silent refresh flow assumptions from frontend contract;
|
||
- text and file happy paths;
|
||
- file deny and cleanup;
|
||
- safety pending then allow/deny;
|
||
- API crash during poll and recovery;
|
||
- client disconnect during poll;
|
||
- Redis loss without duplicate message;
|
||
- local app timeout before/after accepting message;
|
||
- inbound event duplicate and file SSRF rejection;
|
||
- WS disconnect/reconnect/poll gap recovery;
|
||
- concurrent sends/idempotency;
|
||
- foreign user ids always 404;
|
||
- rate limits and Retry-After;
|
||
- dependency circuit open/half-open;
|
||
- no PII/secrets/presigned URLs in logs.
|
||
|
||
### 26.5. Performance targets
|
||
|
||
**TBD-4:** финальные SLO/RPS определяются load profile до production. Минимальные acceptance checks:
|
||
|
||
- public/profile/history p95 без внешних dependencies измеряется отдельно;
|
||
- text send p95 включает safety + Open Lines;
|
||
- file send допускает до poll max budget;
|
||
- 1 slow file poll не блокирует другие requests;
|
||
- DB pool и worker concurrency не исчерпываются при long polls;
|
||
- WS slow consumer не увеличивает память без границ.
|
||
|
||
## 27. Definition of Done
|
||
|
||
Модуль готов, когда:
|
||
|
||
- реализованы все перечисленные `/api/v1` и owned internal endpoints;
|
||
- committed `api-backend/openapi.yaml` соответствует runtime OpenAPI 3.1;
|
||
- пути Open Lines в коде/тестах используют только `/internal/openlines/v1`;
|
||
- schema `han_app`, constraints, indexes, triggers и seed созданы Alembic;
|
||
- migration upgrade проверен на пустой и предыдущей схеме;
|
||
- JWT/JWKS, phone claim, ownership и consent checks покрыты;
|
||
- idempotency переживает потерю Redis без повторного side effect;
|
||
- Message Safety sync/poll/recovery и S3 lifecycle покрыты failure tests;
|
||
- Open Lines outbox/inbox duplicate delivery покрыты contract/E2E tests;
|
||
- CRM задачи создают только DB triggers;
|
||
- realtime и polling не имеют gap при reconnect;
|
||
- business settings не hardcoded и не продублированы в env;
|
||
- rate limits работают на nginx и API уровнях;
|
||
- circuit breakers, timeout, graceful shutdown реализованы;
|
||
- JSON logs/metrics/traces/audit соответствуют требованиям и не содержат PII/secrets;
|
||
- `/health/live` и `/health/ready` проверены;
|
||
- контейнер non-root запускается в едином root Docker Compose без published port;
|
||
- `ruff check`, format check, mypy/pyright, unit/integration/contract/E2E tests успешны;
|
||
- dependency/security scan не имеет unresolved critical/high;
|
||
- подготовлены runbooks: migration, outbox/safety backlog, DLQ, quarantine cleanup, JWKS outage, token rotation;
|
||
- все допущения/TBD ниже закрыты либо явно приняты владельцем продукта/архитектуры.
|
||
|
||
## 28. Явные решения, допущения и TBD
|
||
|
||
### Зафиксированные решения модуля
|
||
|
||
- **M1:** layered FastAPI, транзакции в use cases, внешние адаптеры отдельно.
|
||
- **M2:** phone claim `phone_number`, fallback `preferred_username` только E.164.
|
||
- **M3:** Message создаётся до safety для durable crash checkpoint.
|
||
- **M4:** Redis idempotency дополнен durable PostgreSQL record.
|
||
- **M5:** recovery продолжается после client timeout; точный extended budget — TBD.
|
||
- **M6:** WS subprotocol предпочтительнее query token.
|
||
- **M7:** readiness различает critical not-ready и partial degraded.
|
||
- **M8:** denied text редактируется/не хранится в открытом виде.
|
||
|
||
### Допущения
|
||
|
||
- **A1:** locale API зарезервирован, MVP фактически `ru`.
|
||
- **A2:** inbox получит стабильный `event_id`; временно возможен deterministic fingerprint.
|
||
- **A3:** authoritative SHA-256 может подтверждаться Message Safety, если S3 HeadObject его не отдаёт.
|
||
|
||
### Требуют согласования
|
||
|
||
- **TBD-1:** единый код для protected endpoint до bootstrap (`409` предложен).
|
||
- **TBD-2:** extended recovery budget после `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`.
|
||
- **TBD-3:** добавить `event_id`/`occurred_at` в WS events и `event_id` в inbox OpenAPI.
|
||
- **TBD-4:** production SLO, RPS, concurrency, RPO/RTO и retention.
|
||
- **TBD-5:** antivirus policy для файлов оператора, которые не проходят outbound Message Safety.
|
||
- **TBD-6:** legal retention/erasure для PII, audit, blocked messages и S3-data.
|
||
- **TBD-7:** точный max WS connections/subscriptions/frame и queue size.
|
||
- **TBD-8:** G10 — окончательный DTO/mapping public app-config при оформлении OpenAPI.
|
||
- **TBD-9:** G11 — API/WS deprecation policy до публичного релиза.
|
||
|
||
Ни один TBD не разрешает менять канонические endpoint, auth, enum или service boundaries без обновления соответствующего `arch-*`.
|