22 KiB
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_statusDBML («сервис подтвердил взятие в работу»). Ядро ставитprocessing,started_at,service_name/version. - Если ack не пришёл за
ACK_TIMEOUT_SECONDS(30) — ядро публикует запрос повторно с тем жеNats-Msg-Id(<job_id>:<attempt>, тот же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 собирает сервисы из ../../<repo> (работает и из .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, схемы создавать можно. Остаются (не блокируют старт):
- Версия Node для
web/(покаnode:22). - JWT РАИСА: алгоритм, источник ключей, имена claim'ов, issuer/audience — нужны к первому деплою; до этого dev-эмитент.
- Имена входящего/исходящего каталогов и путь SMB на стенде ИС Сканирование — к Срезу 3.
- Появится ли 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 порты хоста не нужны |