Правки от GPT
This commit is contained in:
@@ -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 |
|
||||
| `"\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:<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 заглушки.
|
||||
Reference in New Issue
Block a user