# module-05. Проектная спецификация заглушки `message-safety` > Статус: целевая спецификация тестовой заглушки MVP, строго реализующей правила данного задания. > Источники: [`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), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-04-redis.md`](module-04-redis.md). ## 1. Назначение и ограничение Сервис — internal stub для проверки orchestration `api-backend`, а не реальный moderation/antivirus engine. Он доступен только в Docker network и реализует канонические пути arch-02: - `POST /internal/safety/v1/messages/check`; - `GET /internal/safety/v1/messages/tasks/{task_id}`; - `GET /health/live`; - `GET /health/ready`. Сервис не публикуется через nginx, не получает JWT пользователя, не перемещает S3 objects, не отправляет сообщения в Bitrix и не хранит бизнес-историю. ## 2. Главное отличие тестовой заглушки По базовой архитектуре final deny у Message Safety обычно `403`. Для этой заглушки пользователь явно задал особый task-контракт: `GET task` независимо возвращает примерно с равной вероятностью `203`, `200` или **`400`**. Здесь `400` на валидном `GET task` — **финальный отрицательный verdict/error заглушки**, а не malformed HTTP request. `api-backend` обязан трактовать его как terminal safety rejection и отображать публично как `422 message_blocked`, выставляя `safety_status=blocked`, `delivery_status=rejected`, без вызова Bitrix. Клиенту raw internal `400` не проксируется. Это намеренное test-only расширение текущей таблицы arch-02 (`200/203/403`). Перед использованием не как заглушки arch-02 и contract tests должны быть обновлены либо `400` должен быть заменён на канонический `403`. Существующие arch-файлы в рамках этой задачи не изменяются. ## 3. Технологический профиль - Python 3.12+, FastAPI, Pydantic v2, Uvicorn. - Redis asyncio client, DB2. - OpenTelemetry, JSON logging. - pytest/anyio, HTTPX ASGI client, real Redis integration tests. - Без PostgreSQL и S3 для этой stub-реализации; их будущая интеграция находится вне scope. ## 4. Приоритет правил Перед классификацией текст нормализуется. Правила применяются строго в порядке: 1. validation/auth: invalid DTO или service token обрабатываются до бизнес-правил; 2. нормализация; 3. если первый Unicode code point нормализованного текста — кириллическая `ф` или `Ф`, вернуть `403 deny`; 4. иначе если первый code point — десятичная цифра, создать task и вернуть `203 pending`; 5. любой иной текст, включая пустой после допустимой нормализации, вернуть `200 allow`. Таким образом, после нормализации строка не может одновременно начинаться и с `ф/Ф`, и с цифры. Rule `ф/Ф` записан раньше для явности. Для file-only request без текста default — `200 allow`; заглушка не сканирует файл. ## 5. Нормализация Детерминированный pipeline: 1. требовать JSON UTF-8; 2. заменить `CRLF/CR` на `LF`; 3. Unicode normalization `NFKC`; 4. удалить leading Unicode whitespace (`lstrip`); 5. не менять регистр всей строки и не удалять punctuation; 6. ограничить текст max length до значения internal DTO (ориентир 10 000 code points). Примеры: | Вход | После нормализации | Результат | |---|---|---| | `"Файл"` | `"Файл"` | 403 | | `" фраза"` | `"фраза"` | 403 | | `"\u00a07 дней"` | `"7 дней"` | 203 + task | | `"+7..."` | `"+7..."` | 200 | | `"документ"` | `"документ"` | 200 | | `"abc"` | `"abc"` | 200 | | `""`/whitespace | `""` | 200 | «Цифра» означает Unicode category `Nd` после NFKC, не только ASCII `[0-9]`. ## 6. Authentication и common headers Каждый `/internal/safety/v1/*` требует: ```text X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN} X-Request-ID: UUID/ULID (если нет — сервис создаёт) traceparent: optional W3C ``` Token сравнивается constant-time. Missing/invalid token → `401` или `403` internal auth error; выбран единый `401 service_unauthorized`, без подсказки о значении. Health не требует token внутри network либо использует отдельную ops policy. ## 7. DTO `POST .../check` Stub принимает минимальный versioned DTO, совместимый с потребностями api-backend: ```json { "message_id": "uuid", "content_kind": "text", "text": "Фраза", "attachment": null } ``` Для file: ```json { "message_id": "uuid", "content_kind": "file", "text": "", "attachment": { "attachment_id": "uuid", "quarantine_object_key": "opaque", "mime_type": "application/pdf", "size_bytes": 12345, "checksum": "sha256:..." } } ``` Неизвестные поля запрещены. `content_kind=text` требует text field (пустой разрешён именно stub default); `file` допускает attachment metadata, но не читает S3. `message_id` нужен для correlation/idempotency, не для выбора verdict. ## 8. Ответы `POST .../check` ### `200 allow` ```json { "verdict": "allow", "rule_id": "stub.default_allow", "rules_version": "2026-01-01" } ``` ### `403 deny` для `ф/Ф` ```json { "verdict": "deny", "rule_id": "stub.starts_with_cyrillic_ef", "reason_code": "stub_blocked", "rules_version": "2026-01-01" } ``` ### `203 pending` для цифры ```json { "verdict": "pending", "task_id": "uuid", "poll_after_ms": 2000, "expires_at": "2026-07-10T12:15:00Z", "rules_version": "2026-01-01" } ``` Все три — нормальные domain outcomes. `403` не участвует в circuit breaker failure count. ## 9. Task storage Redis DB2 Ключ: ```text han:safety:task:{task_id} ``` HASH/JSON v1: ```json { "schema_version": 1, "message_id": "uuid", "created_at_ms": 0, "poll_count": 0, "rng_context": "optional-test-only", "rules_version": "2026-01-01" } ``` TTL `MESSAGE_SAFETY_TASK_TTL_SEC`, default 900 seconds, должен быть больше `MESSAGE_SAFETY_TASK_POLL_MAX_SEC` (300) плюс network/recovery margin. Текст, attachment key и checksum в Redis не нужны. Создание task и TTL атомарны. Коллизия UUID повторяется bounded. `message_id → task_id` dedup key допустим для идемпотентного повторного POST: ```text han:safety:task-by-message:{message_id} -> task_id ``` с тем же TTL; reserve обоих keys выполняется Lua. Повтор одинакового check возвращает тот же active task. Если fingerprint изменился для того же message id — `409 safety_request_conflict`. ## 10. `GET .../tasks/{task_id}` Сначала проверяются token, UUID и существование task. Затем **на каждый GET независимо** выбирается один из трёх outcomes с вероятностью примерно 1/3: - `203 pending`; - `200 allow`; - `400 stub_final_error` (terminal deny/error). Предыдущий `200` или `400` не фиксируется как sticky verdict в Redis по буквальному требованию «дальнейший GET случайно и независимо». Следовательно, повторный GET того же task после terminal ответа теоретически может вернуть другой outcome. `api-backend` обязан прекратить polling на первом terminal `200/400`, поэтому противоречие снаружи не возникает. Это поведение специально тестовое и не годится для production moderation. Для безопасной recovery production service должен сохранять sticky final verdict; переход потребует изменения режима/контракта. ### Ответы `203`: ```json {"verdict":"pending","task_id":"uuid","poll_after_ms":2000} ``` `200`: ```json {"verdict":"allow","task_id":"uuid","rule_id":"stub.random_allow"} ``` `400` terminal: ```json { "verdict":"deny", "task_id":"uuid", "error":{ "code":"stub_final_error", "message":"Stub task returned a final negative verdict", "request_id":"uuid", "details":{"terminal":true} } } ``` Для malformed `task_id` используется `400 validation_error`, но его envelope имеет `verdict` отсутствующий и `details.terminal` отсутствует/false. Для неизвестного/expired task — `404 task_not_found`. Api-backend различает terminal stub `400` строго по schema/code, а не по одному HTTP status. ## 11. Worker/poll model Реальный worker не требуется. Task создаётся сразу, а GET эмулирует состояние worker случайным outcome. Контракт остаётся таким же, как для async orchestration: check создаёт `task_id`, api-backend poll-ит GET внутри исходного user POST. Опциональный `SAFETY_STUB_WORKER_MODE=emulated_on_poll` — единственный режим MVP. Будущий worker mode не должен менять endpoint/DTO, но final verdict тогда становится sticky. Api-backend: ```text POST check 200 -> allow 403 -> deny -> public 422 message_blocked 203 -> poll GET GET 203 -> continue GET 200 -> allow GET 400 + code=stub_final_error + terminal=true -> deny -> public 422 message_blocked other 400 -> dependency contract error, not message verdict timeout/5xx/redis unavailable -> public 503/504 ``` ## 12. Randomness и deterministic testing Production-like stub default использует криптографически достаточный process RNG либо `random.Random` с entropy seed; распределение не является security decision. RNG внедряется через интерфейс `VerdictRng.choice()`. Test implementations: - sequence RNG: `pending, allow, final_error`; - seeded RNG через `SAFETY_STUB_RNG_SEED` только при `APP_ENV=test`; - forced outcome через dependency override, не public header. В production-like env seed/forced mode вызывает startup failure, чтобы внешний caller не управлял verdict. Статистический test на большой выборке проверяет каждую долю в допустимом диапазоне (например, 0.30–0.36), но основные tests используют sequence RNG и не flaky. «Независимо» означает новый RNG draw на каждый валидный GET; poll count/предыдущий outcome не влияют на draw. ## 13. Error semantics Internal envelope: ```json { "error": { "code": "validation_error", "message": "Request is invalid", "request_id": "uuid", "details": {} } } ``` | HTTP | Code | Retry/смысл | |---|---|---| | 400 | `validation_error` | malformed, не terminal verdict | | 400 | `stub_final_error` + verdict deny | terminal task verdict, не malformed | | 401 | `service_unauthorized` | не retry без исправления secret | | 403 | domain `deny` POST | terminal safety verdict | | 404 | `task_not_found` | expired/unknown, dependency contract failure | | 409 | `safety_request_conflict` | message id с другим fingerprint | | 429 | `rate_limit_exceeded` | retry по `Retry-After` | | 500 | `internal_error` | retry/circuit | | 503 | `redis_unavailable` | retry/circuit | Domain `403` и terminal stub `400` не считаются infrastructure failure circuit breaker. ## 14. Idempotency и concurrency POST fingerprint = SHA-256 canonical normalized DTO без request-id/token. Lua reserve обеспечивает один task на `(message_id,fingerprint)` в TTL. Concurrent duplicate получает тот же task id. GET атомарно проверяет существование и увеличивает `poll_count`; RNG draw выполняется независимо. Удалять task после terminal нельзя, иначе повтор получил бы 404 и нарушил независимый test behavior. TTL выполняет cleanup. ## 15. Health `GET /health/live`: только process/event loop, всегда без Redis call. `GET /health/ready` проверяет: - env/token/rules version валидны; - Redis DB2 auth, PING и короткий SET/GET/DEL с TTL; - RNG provider доступен; - OpenAPI schema загружена. Redis down → `503 {"status":"not_ready","components":{"redis":"down"}}`. Текстовые sync rules технически вычислимы, но service целиком not-ready, а digit check возвращает 503, чтобы не выдавать task без storage. ## 16. Observability JSON fields: timestamp, level, `service.name=message-safety`, module, event, request_id, trace_id/span_id, route, status, duration, rule_id, verdict, task_age_bucket, poll_count bucket, error_code. Не логируются service token, message text, attachment key/name, checksum, DTO body или PII. Разрешены message/task UUID при принятой retention либо их hash. Metrics: - requests/latency/errors по route/status; - check outcomes allow/deny/pending; - task GET outcomes pending/allow/final_error; - observed distribution; - task create/dedup/conflict/not-found/expired; - Redis latency/error/pool; - auth rejects, rate limit; - RNG mode как low-cardinality info; - readiness. Trace связывается с api-backend через `traceparent`, `X-Request-ID` возвращается. ## 17. Security - только Docker backend network, без nginx/public route и host port; - constant-time token compare, secret только env/secret mount; - strict JSON schema/max body/max text; - no dynamic code/rules from request; - Redis ACL только DB2 prefixes; - non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities; - OpenAPI docs UI production отключён, committed YAML остаётся; - CORS не нужен internal service; - rate limit по service identity/network защищает от accidental loops; - error response не раскрывает internal host/stack/secret. ## 18. Docker и env ```text message-safety/ app/ main.py api/{routes,schemas,errors,auth}.py application/{classifier,tasks}.py infrastructure/{redis,rng,observability}.py settings.py tests/{unit,integration,contract}/ openapi.yaml Dockerfile docker-compose.yml ``` Compose: `expose: 8080`, networks `backend`,`observability`, без `ports`, depends_on Redis health, собственный retry startup. Env: ```text APP_ENV=production-like MESSAGE_SAFETY_PORT=8080 MESSAGE_SAFETY_REDIS_URL=redis://message_safety:@redis:6379/2 MESSAGE_SAFETY_SERVICE_TOKEN= MESSAGE_SAFETY_RULES_VERSION=2026-01-01 MESSAGE_SAFETY_TASK_TTL_SEC=900 MESSAGE_SAFETY_POLL_AFTER_MS=2000 SAFETY_STUB_WORKER_MODE=emulated_on_poll SAFETY_STUB_RNG_SEED= OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 ``` Новые env (`TASK_TTL`, `POLL_AFTER`, stub mode/seed) требуют внесения в arch-04 перед реализацией production config; здесь они зафиксированы как предложение. ## 19. OpenAPI `message-safety/openapi.yaml` OpenAPI 3.1 обязателен и включает: - security scheme `X-Service-Token`; - check request union text/file; - exact 200/203/403 responses POST; - exact 200/203/400/404 responses GET; - discriminator между malformed 400 и terminal stub 400; - common request/trace headers; - examples, max lengths, UUID/checksum formats; - health endpoints. Generated/runtime schema сравнивается с committed artifact. Contract test api-backend отдельно закрепляет mapping terminal `400 stub_final_error → 422 message_blocked`. ## 20. Тестовая матрица ### Unit - NFKC/whitespace/Unicode `Nd`; - `ф`, `Ф`, fullwidth variants, punctuation/default; - exact rule priority; - DTO union/limits; - injected sequence and seeded RNG; - error discrimination and log redaction. ### Integration - Redis DB2 task/dedup/TTL/atomic concurrency; - same message same/different fingerprint; - task expiration; - Redis outage/reconnect; - ACL rejection outside prefix; - poll count concurrency. ### Contract - POST `документ`/default 200, `ф/Ф` 403, digit 203; - GET independent 203/200/400; - terminal 400 schema versus malformed 400; - auth missing/wrong/correct; - request id/trace propagation; - api-backend mapping to public 422 and no Bitrix call; - OpenAPI runtime parity. ### Statistical/failure - 30k+ GET draws approximately 1/3 each with non-flaky tolerance; - prior outcome does not influence next seeded sequence; - API sync wait terminates on first 200/400; - repeated 203 reaches timeout behavior; - Redis restart loses ephemeral task safely and API returns dependency error; - no text/token/object key in logs. ## 21. Definition of Done - канонические endpoint paths arch-02 реализованы; - правило normalized `ф/Ф → 403`, digit → `203 task`, others → `200` покрыто; - каждый valid task GET независимо даёт 203/200/terminal 400 примерно 1/3; - distinction terminal vs malformed 400 формально задано; - api-backend contract mapping terminal 400 → public 422 проверен; - Redis DB2 atomic task/dedup/TTL и degraded behavior готовы; - RNG injected, deterministic tests не flaky, prod seed запрещён; - service token/network/ACL/container hardening проверены; - health, JSON logs, metrics/traces без PII/secrets; - OpenAPI 3.1 committed и contract tests зелёные; - контейнер запускается в root Compose без published port; - intentional divergence с arch-02 либо принята как stub exception, либо arch-02 обновлён до production implementation. ## 22. Решения, допущения и TBD **Решения:** normalizer NFKC+lstrip; Unicode `Nd`; default allow; emulation on GET без worker; independent non-sticky outcomes; `400 stub_final_error` terminal и преобразуется API в 422. **Допущения:** пустой/file-only text попадает в default 200; `message_id` передаётся internal DTO; Redis task TTL 900 секунд достаточен для MVP tests. **TBD:** - S1 формально обновить arch-02 для test-only terminal 400 или вернуть production 403; - S2 окончательный internal DTO/fingerprint в OpenAPI; - S3 добавить новые env в arch-04; - S4 точный Redis task TTL относительно extended recovery module-01; - S5 sticky final verdict при переходе от stub к реальному Safety; - S6 реальные file/link checks, PostgreSQL schema и S3 read-only — вне scope заглушки.