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

459 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| `"\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/*` требует:
```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:<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 заглушки.