Files
han-app/architectory/module-05-message-safety.md
T
2026-07-10 12:23:18 +03:00

21 KiB
Raw Blame History

module-05. Проектная спецификация заглушки message-safety

Статус: целевая спецификация тестовой заглушки MVP, строго реализующей правила данного задания.
Источники: 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, module-01-api-backend.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
"\u00a0 дней" "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/* требует:

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:

{
  "message_id": "uuid",
  "content_kind": "text",
  "text": "Фраза",
  "attachment": null
}

Для file:

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

{
  "verdict": "allow",
  "rule_id": "stub.default_allow",
  "rules_version": "2026-01-01"
}

403 deny для ф/Ф

{
  "verdict": "deny",
  "rule_id": "stub.starts_with_cyrillic_ef",
  "reason_code": "stub_blocked",
  "rules_version": "2026-01-01"
}

203 pending для цифры

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

Ключ:

han:safety:task:{task_id}

HASH/JSON v1:

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

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:

{"verdict":"pending","task_id":"uuid","poll_after_ms":2000}

200:

{"verdict":"allow","task_id":"uuid","rule_id":"stub.random_allow"}

400 terminal:

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

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:

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

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:

APP_ENV=production-like
MESSAGE_SAFETY_PORT=8080
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<secret>@redis:6379/2
MESSAGE_SAFETY_SERVICE_TOKEN=<secret>
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 заглушки.