# Design: Срез 0 — сквозной скелет ## Context См. `proposal.md` — Why. Ограничения, формирующие решения: - Шаблон команды (`03-architecture.md`, «Соглашения шаблона команды»): один бакет S3, NATS с nkeys/JWT, настройки `OPENBAO__*`, `DEBUG=true` отключает внешние интеграции, `Dockerfile`/`docker-compose.yml` в `.gitignore` шаблона. - DECISION-4 DBML: сервисы распознавания без БД; обмен только через `recognition_job` (outbox/inbox), дедуп по `msg_id`, DLQ. - **Факты платформы (ответы пользователя 03.10.2026):** Python **3.11**; версия Node неизвестна; доступа к Nexus банка (`raisa_*`) не будет до деплоя в прод; CI в Gitea нет и не планируется — у платформы свой CI/CD, в него не лезем; **NATS без JetStream**; PostgreSQL **16**; создавать схемы можно; параметры JWT РАИСА пока неизвестны. - Фикстуры — отдельный приватный репозиторий `arm-ka-fixtures`; локально доступен как `../fixtures`. ## Goals / Non-Goals **Goals:** один файл контракта на каждое сообщение; один `docker compose up` для всего workspace; одна команда проверки качества, одинаковая для трёх репозиториев; документы контекста соответствуют реальному шаблону и реальной платформе. **Non-Goals:** см. proposal. Дополнительно: не проектируем prod-деплой (это платформа банка), не решаем ретеншн `results/` (ARCH-010), не пишем конфигурации CI ни для Gitea, ни для платформы. ## Decisions ### D-1. Контракт — JSON Schema в docs + вендоренная копия в каждом сервисе, без общего пакета `09-delivery-plan.md` оставлял выбор: пакет `arm-ka-contracts` или копия с проверкой идентичности. Выбрана **копия**: `docs/contracts/*.schema.json` — источник истины; каждый сервис хранит копию в `api/contracts/` и pydantic-модели в `api/src/contract/`; тест сервиса сравнивает копию с `../docs/contracts` байт в байт (пропускается с предупреждением, если `docs` недоступен и не задан `ARM_KA_DOCS`), а pydantic-модели валидируют `docs/contracts/examples/*.json` и генерируют JSON, проходящий схему. *Альтернатива* — публикуемый пакет: нет внутреннего PyPI, добавляет цикл релиза ради трёх потребителей. *Цена ошибки:* при переходе на пакет копии удаляются, тест идентичности заменяется пином — полдня. ### D-2. Конверт результата не дублирует `group_schema` `03-architecture.md` перечислял `group_schema` в сообщении `recognition.result`. В конверте остаются только поля для маршрутизации и статуса; `group_schema` ядро берёт из `documents[].group_schema` файла результата при маппинге (Срез 2). *Почему:* одна истина на результат, сообщение остаётся маленьким (лимит NATS 1 МБ не трогаем). ### D-3. Доставка без JetStream: app-level ack, повторная публикация ядром, дедуп в БД JetStream на платформе нет, значит нет durable consumer'ов, серверного дедупа по `Nats-Msg-Id`, redelivery и `max_deliver`. Схема на core NATS: - Запросы публикуются ядром из outbox (`recognition_job.status = queued → published`); сервисы подписаны **queue group** (`tabular-parser`, `scan-gateway`) — несколько инстанций делят нагрузку, одно сообщение получает одна. - Сервис **сразу по получении** публикует в `recognition.result` конверт со `status = processing` (ack: «взял в работу») — это значение уже есть в `recognition_status` DBML («сервис подтвердил взятие в работу»). Ядро ставит `processing`, `started_at`, `service_name/version`. - Если ack не пришёл за `ACK_TIMEOUT_SECONDS` (30) — ядро **публикует запрос повторно** с тем же `Nats-Msg-Id` (`:`, тот же `attempt`: попытка обработки не началась), не более `OUTBOX_MAX_REPUBLISH` (3) раз, затем `status = dlq` (DLQ-поведение DBML без брокера). `attempt`/`max_attempts` остаются для replay после `error`/`timeout` (ручной, Срез 3+). - Результат, потерянный при простое ядра, обнаруживается по `reply_deadline` → `timeout`; `result_msg_id UNIQUE` в `recognition_job` остаётся единственным дедупом результатов (`Nats-Msg-Id` сервисы проставляют сами). - Сервис держит process-local множество `job_id` в работе: повторный запрос с тем же `job_id` (пришёл повторно, пока первый обрабатывается или уже обработан в этом процессе) подтверждается ack'ом и не обрабатывается второй раз. *Альтернатива* — ждать JetStream или ставить свою очередь (Redis/БД-поллинг у сервисов): первое не в нашей власти, второе нарушает DECISION-4 (сервисы без БД). *Если JetStream появится:* меняются только publisher/subscriber (`js.publish`, durable consumer), outbox/inbox и контракт остаются. ### D-4. Subjects и группы Stream'ов и consumer'ов нет. Subjects: `recognition.tabular.request`, `recognition.image.request`, `recognition.result`. Queue group'ы: `tabular-parser`, `scan-gateway` — на запросах; `arm-core` — на результатах (несколько инстанций ядра получают результат по одному разу). Заголовки `Nats-Msg-Id` ставятся всегда (core NATS 2.2+ поддерживает заголовки) — для трассировки и будущего JetStream. ### D-5. Один бакет `arm-ka`, роли — префиксы Следствие шаблона (`MINIO_BUCKET`). Ключи: `uploads/{file_id}/{job_id}/{original_name}`, `results/{file_id}/{job_id}/result.json`, `raw/{job_id}.json`, `exports/…`, `archive/…`. Lifecycle в MinIO dev не настраиваем; в проде — по префиксам (ARCH-010 для `results/`). ### D-6. Эмулятор ИС Сканирование — часть `scan-gateway`, не отдельный репозиторий Эмулятор знает формат горячих папок и ответы Content AI так же, как сам шлюз; живёт в `scan-gateway/api/src/scan_emulator/`, в compose запускается тем же образом другой командой с общим volume `hotfolder` и смонтированным `../../fixtures/ocr`. *Альтернатива* — скрипт в `docs/dev/`: пришлось бы дублировать соглашения об именах `UUID_имя` и структуре `Output/UUID/UUID.json`. ### D-7. Compose на уровне workspace, Dockerfile — в каждом сервисе `docs/dev/docker-compose.yml` собирает сервисы из `../../` (работает и из `.wt/<срез>/docs/dev`). Для этого из `.gitignore` сервисов убирается строка `Dockerfile` (единственная правка шаблона); `docker-compose.yml` в репозиториях не появляется. Образ для прода собирает платформа по своим правилам — наш `Dockerfile` только для dev и `make check-full`. ### D-8. Проверки качества — локальная команда, без конфигураций CI CI в Gitea нет, пайплайн платформы — вне репозиториев. Каждый репозиторий получает одинаковые цели `Makefile`: `make check` (быстро, без Docker: `ruff check`, `ruff format --check`, `mypy`, `pytest -m "not integration"`, тест идентичности контракта) и `make check-full` (`make check` + интеграционные тесты против compose; в `arm-core` — ещё «`alembic upgrade head` → `downgrade base` → `upgrade head`» и `check_dbml_vs_orm.py`). `make check-full` обязателен перед PR, результат вписывается в описание PR. Правила — `docs/dev/checks.md`. Фикстуры берутся из `../fixtures` или `ARM_KA_FIXTURES`; при отсутствии тесты с маркером `fixtures` пропускаются с предупреждением. Что из этого попадёт в пайплайн платформы — решает платформа; мы даём ей готовые команды. ### D-9. Версии инструментов Python **3.11** (`python:3.11-slim` в Dockerfile; пины `requirements.txt` совместимы). PostgreSQL **16** в compose (код не использует ничего новее 15; `NULLS NOT DISTINCT` доступен). NATS **2.10 без `--jetstream`** — паритет с прод. Node — неизвестен; локально `node:22` LTS, только для сборки `web/` в compose; меняется одной строкой. ### D-10. Пакеты `raisa_*` без Nexus `raisa_fastapi_protected_api` остаётся в `requirements.txt` для сборки платформой; dev-установка (`make venv`, `Dockerfile`) ставит `requirements.txt` без строк `raisa_*` (фильтр `grep -v`), код импортирует пакет условно, как в шаблоне (`DEBUG=false`). В `web/package.json` `raisa-ui` и `raisa-fastapi-protected-web` переносятся в `optionalDependencies`: `npm install` без Nexus проходит, с Nexus — ставит их как раньше. *Цена ошибки:* если платформа требует их в `dependencies` — вернуть две строки. ## Risks / Trade-offs - [Без JetStream сообщение теряется при простое получателя] → запросы: ack + повторная публикация ядром (D-3); результаты: `reply_deadline` → `timeout` и replay; для OCR повторная публикация до ack не повторяет распознавание (сервис ещё не брал файл). - [Повторный запрос пришёл в сервис, который уже обрабатывает `job_id`] → process-local множество в работе; имя файла в горячей папке = `job_id`, повторный `put` перезаписывает тот же файл. - [Публичные образы PostgreSQL/NATS/MinIO отличаются от банковских] → версии зафиксированы по ответам (16 / 2.10 без JS). - [Шаблон инициализирует nkeys при импорте `app.py`] → задача сервисам: пустые `NATS_SEED/NATS_JWT` = соединение без учётных данных (dev); при заданных — как в шаблоне. - [Копии контракта расходятся] → тест идентичности в `make check` каждого сервиса. - [Эмулятор отвечает только для файлов, у которых есть пара `aep_N.json` (сейчас две)] → для остальных имён — ответ с `error.code = INVALID_DOCUMENT` в формате Content AI; это тоже проверяемый сценарий. - [Нет CI — проверки можно забыть] → `make check-full` в DoD каждой задачи и в описании PR; ревьюер требует вывод. ## Migration Plan Нет данных для миграции. Порядок внедрения: контракт → compose инфраструктуры → arm-core → сервисы → compose профиль `services` → проверки качества → документы. Откат — удаление ветки; `docs/contracts/recognition-result.schema.json` остаётся совместимым с текущим черновиком (добавлен только код ошибки). ## Open Questions Отвечено 03.10.2026: Python 3.11; Nexus недоступен до прода; CI в Gitea нет, пайплайн платформы не трогаем; JetStream нет; PostgreSQL 16, схемы создавать можно. Остаются (не блокируют старт): 1. Версия Node для `web/` (пока `node:22`). 2. JWT РАИСА: алгоритм, источник ключей, имена claim'ов, issuer/audience — нужны к первому деплою; до этого dev-эмитент. 3. Имена входящего/исходящего каталогов и путь SMB на стенде ИС Сканирование — к Срезу 3. 4. Появится ли JetStream на платформе позже — влияет только на publisher/subscriber (D-3). ## Decisions made without discussion | № | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | 1 | 2026-10-03 | D-1: контракт вендорится копией, общего пакета нет | нет внутреннего PyPI; три потребителя | полдня на переход к пакету | | 2 | 2026-10-03 | D-2: `group_schema` убран из конверта результата | одна истина в файле результата | правка одной таблицы в docs и одной pydantic-модели | | 3 | 2026-10-03 | D-3/D-4: без JetStream — ack `processing`, повторная публикация ядром (30 с, 3 раза), queue group'ы, дедуп по `result_msg_id` | ответ пользователя «JetStream нет»; DECISION-4 требует не трогать БД из сервисов | при появлении JetStream — замена publisher/subscriber | | 4 | 2026-10-03 | D-5: один бакет `arm-ka`, роли как префиксы | шаблон команды (`MINIO_BUCKET`) | `s3_object.bucket` хранится отдельно — смена на несколько бакетов не ломает модель | | 5 | 2026-10-03 | D-6: эмулятор ИС Сканирование живёт в `scan-gateway` | общие соглашения горячих папок | перенос каталога | | 6 | 2026-10-03 | D-7: compose в `docs/dev/`, `Dockerfile` разыгнорирован в сервисах | шаблон игнорирует Dockerfile, платформа собирает сама | вернуть строку в `.gitignore` | | 7 | 2026-10-03 | D-8: проверки качества — `make check` / `make check-full`, конфигураций CI нет | CI в Gitea нет, пайплайн платформы не наш | добавить YAML под нужный раннер, команды уже есть | | 8 | 2026-10-03 | В контракт v1.0 добавлен код ошибки `NOT_IMPLEMENTED` и статус конверта `processing` (ack) | заглушки Среза 0; доставка без JetStream | удалить значения enum в 1.1 | | 9 | 2026-10-03 | Справочники продукта для seed — в `arm-core/api/seed/`, не в `fixtures/directories/` | данные продукта нужны в проде; `fixtures` — приватные тестовые данные | перенос файлов и правка §6 `06-test-data.md` | | 10 | 2026-10-03 | D-9: `python:3.11-slim`, PostgreSQL 16, NATS 2.10 без JS, `node:22` до ответа | ответы пользователя; Node неизвестен | смена тега в compose/Dockerfile | | 11 | 2026-10-03 | Worktree среза — зеркальный workspace `.wt/<срез>/` с симлинками | относительные пути `../docs`, `../fixtures` и хуки должны работать в ветке | переместить worktree | | 12 | 2026-10-03 | D-10: `raisa_*` фильтруются при dev-установке, npm-пакеты `raisa-*` → `optionalDependencies` | Nexus недоступен до прода | вернуть две строки в `dependencies` | | 13 | 2026-10-03 | Файл результата: пустой `documents` допустим только при `status = error` — выражено в схеме условием `if/then` (`minItems: 1`) | требование spec `recognition-contract` «Файл результата»; текст задачи 1.1 его не перечислял | ослабить/убрать условие в схеме, пересобрать копии контракта в сервисах | | 14 | 2026-10-03 | Compose: фиксированное имя проекта `name: arm-ka`, порты хоста переопределяются через `.env` со значениями по умолчанию из spec | одинаковые тома/сети из любого worktree; порты 5432/4222 на машине разработчика могут быть заняты | смена имени проекта — потеря dev-томов (`down -v`), порты — правка `.env` | | 15 | 2026-10-03 | Чекбоксы плана и `tasks.md` отмечает контроллер SDD отдельным коммитом после чистого ревью задачи | исполнитель не должен отмечать выполненным то, что ещё не прошло ревью | отметки правятся вручную | | 16 | 2026-10-03 | Compose: MinIO берётся из неофициальных образов `alpine/minio:RELEASE.2025-10-15T17-29-55Z` и `bitnamilegacy/minio-client:2025.7.21` | `minio/minio` и `minio/mc` недоступны на Docker Hub (denied), на quay.io нужных тегов нет; для dev достаточно любого S3-совместимого MinIO нужной версии API | образы сторонние: при появлении доступа к банковскому реестру/официальным образам — заменить две строки `image:` в `dev/docker-compose.yml`; в прод эти образы не попадают | | 17 | 2026-10-03 | Compose: сервис `minio` запускается от `user: "0:0"` | образ `alpine/minio` по умолчанию работает от пользователя `minio` и падает на свежем именованном томе | только dev; удалить строку, когда образ/том позволят писать без root | | 18 | 2026-10-03 | `dev/.env.example` дополнен заглушками `OPENBAO__SMB__*` и `NATS_ENABLED=true` | `Settings` шаблона (arm-core) требует блок `OPENBAO.SMB`, без него сервис из venv не стартует; `NATS_ENABLED` по умолчанию `false`, и `/healthcheck` показывает `nats = off`; spec велит копировать именно `docs/dev/.env.example` | убрать две группы строк; сервисы с другим набором обязательных настроек получат лишние (игнорируемые) переменные | | 19 | 2026-10-03 | `schema_version` во всех трёх схемах: `const "1.0"` заменён на `type: string, pattern: ^1\.\d+$` (производитель v1.0 пишет `"1.0"`); в `$comment` и в 03-architecture.md добавлено правило «новый MINOR раскатывается сначала на потребителей» | spec «Версионирование контракта»: потребитель принимает тот же MAJOR и любой MINOR, сервис «поддерживающий 1.x» принимает 1.1 и отвергает только 2.0; с `const` это неисполнимо, а копии схем уйдут в сервисы побайтно (финальное ревью I-1) | вернуть `const "1.0"` и пересобрать копии схем в сервисах; побочный риск — копии в `arm-core`/`tabular-parser`/`scan-gateway` нужно обновить (тест идентичности контракта упадёт, пока копии не обновлены) | | 20 | 2026-10-03 | Compose: порты хоста публикуются на `${BIND_ADDR:-127.0.0.1}`, а не на 0.0.0.0 | пароли по умолчанию (`armka`, `minioadmin`); все сценарии идут через localhost; spec bind-адрес не оговаривает (финальное ревью M-6) | если нужен доступ с другой машины — `BIND_ADDR=0.0.0.0` в `.env`; для запуска профиля `services` порты хоста не нужны |