# 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 ` | обязателен для 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=`, 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:"}`. 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 `, 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 `, `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_`, в `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/`, 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.`. После 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_`; 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-*`.