21 KiB
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. Приоритет правил
Перед классификацией текст нормализуется. Правила применяются строго в порядке:
- validation/auth: invalid DTO или service token обрабатываются до бизнес-правил;
- нормализация;
- если первый Unicode code point нормализованного текста — кириллическая
филиФ, вернуть403 deny; - иначе если первый code point — десятичная цифра, создать task и вернуть
203 pending; - любой иной текст, включая пустой после допустимой нормализации, вернуть
200 allow.
Таким образом, после нормализации строка не может одновременно начинаться и с ф/Ф, и с цифры. Rule ф/Ф записан раньше для явности. Для file-only request без текста default — 200 allow; заглушка не сканирует файл.
5. Нормализация
Детерминированный pipeline:
- требовать JSON UTF-8;
- заменить
CRLF/CRнаLF; - Unicode normalization
NFKC; - удалить leading Unicode whitespace (
lstrip); - не менять регистр всей строки и не удалять punctuation;
- ограничить текст 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/* требует:
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 заглушки.