Files
han-app/modules/module-01-api-backend.md
T

66 KiB
Raw Blame History

module-01. Проектная спецификация api-backend

Статус: целевая спецификация реализации MVP.
Язык реализации: Python, FastAPI.
Канонические источники: README.md, arch-00-glossary.md, arch-01-system-architecture.md, arch-02-api-contracts.md, arch-03-docker-compose-blueprint.md, arch-04-settings-and-content.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.

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-компонентов

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
    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

{
  "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:

{
  "auth": {"phone_enabled": true, "password_enabled": false},
  "operator": {"call_phone": "+74999591007"},
  "consents": {
    "personal_data": {"required": true, "document_url": "https://...", "version": "2026-06-10"},
    "user_agreement": {"required": true, "document_url": "https://...", "version": "2026-06-10"},
    "marketing": {"required": false, "document_url": null, "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.

{
  "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:

{
  "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 транзакции:

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) 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:

{
  "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:

{"ux_session_id":"uuid","started_at":"2026-07-08T12:00:00Z"}

Создаёт UxSession и audit session_start атомарно. Не влияет на auth. device_id хешируется перед сохранением; raw значение в лог не попадает.

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:

{
  "user_id": "uuid",
  "profile": {
    "personal_data": {
      "full_name": null,
      "citizenship": null,
      "russian_phone": null,
      "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:

{
  "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. Создание:

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:

{
  "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). На 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:

{"file_name":"scan.pdf","mime_type":"application/pdf","size_bytes":12345}

Init response 201:

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

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.

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

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

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_hash; 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.

Индексы:

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.closedclosed;
  • из 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 не входят.

Алгоритм:

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. Основной алгоритм

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:

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:

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:

{"type":"connected","server_time":"2026-07-09T12:00:00Z"}
{"type":"subscribe","dialog_ids":["uuid"]}
{"type":"subscribed","dialog_ids":["uuid"]}

Events:

  • message.new с MessageResponse;
  • message.status;
  • dialog.status;
  • ping; client отвечает pong.

Каждый 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.

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
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;
  • 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:

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

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-*.