Правки от GPT

This commit is contained in:
mi
2026-07-10 12:23:18 +03:00
parent 50fc053979
commit aa8761d1b3
11 changed files with 6188 additions and 1 deletions
+458
View File
@@ -0,0 +1,458 @@
# 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 заглушки.