# Design: Срез 0 — скелет ядра arm-core ## Context См. `proposal.md` — Why. Что задано и не обсуждается здесь: - Шаблон команды (`../docs/context/03-architecture.md`, «Соглашения шаблона команды»): раскладка `api/src/`, pydantic-settings с `OPENBAO__*`, схема `dkb`, Alembic с URL из настроек, DI ручными `Depends`, loguru, обработчики ошибок `{"detail": …}`, SPA из `api/web/`, `DEBUG=true` отключает NATS/РАИСА. - DBML v5.13 — источник имён и структур; поля вне DBML не вводятся. - Контракт v1.0 — родительский change в `docs` (конверты сообщений, ack `processing`, файл результата, `Nats-Msg-Id`). - Платформа (ответы 03.10.2026): Python **3.11**, PostgreSQL **16**, **NATS без JetStream**, Nexus недоступен до прода, CI в Gitea нет — пайплайн платформы вне репозитория, параметры JWT РАИСА неизвестны. - Инварианты CLAUDE.md: права — данные; единственный писатель в БД — ядро; append-only аудит; обмен только через `recognition_job`. ## Goals / Non-Goals **Goals:** рабочая схема БД и миграции; пользователь с правами из БД через JWT; outbox/inbox на core NATS, доказанные интеграционным тестом (включая потерю запроса и повторную публикацию); `make check-full`, который ловит расхождение ORM ↔ DBML и невозвратные миграции. **Non-Goals:** см. proposal. Дополнительно: не оптимизируем производительность outbox (одна инстанция, 5-секундный опрос достаточно для Среза 0–3); не проектируем multi-tenant. ## Decisions ### D-1. ORM: модуль на часть DBML, enum'ы отдельно, имена — буквально из DBML `src/models/`: `enums.py` (27 `enum.Enum` + фабрика `pg_enum(name)` → `sqlalchemy.Enum(..., name=…, schema="dkb", native_enum=True, values_callable=lambda e: [m.value for m in e])`, значения — строки DBML, включая кириллические `"ЮЛ"/"ИП"`), далее `directories.py` (часть 01), `access.py` (02), `accounting.py` (03), `files.py` (04), `osv.py` (05), `statements.py` (06), `consolidation.py` (07), `kfo.py` (08), `audit_ex.py` (09 + `required_document_rule`). Имена классов — CamelCase от имени таблицы (`RegionalOffice`), `__tablename__` — как в DBML. Типы: `uuid` → `UUID(as_uuid=True)`, `varchar` → `String` (без длины, как в DBML), `text` → `Text`, `numeric(p,s)` → `Numeric(p, s)`, `timestamptz` → `DateTime(timezone=True)`, `date` → `Date`, `smallint/int/bigint` → `SmallInteger/Integer/BigInteger`, `boolean` → `Boolean`, `jsonb` → `JSONB`. Nullability и `default` — буквально из DBML (`default` → `server_default`). Константа `DBML_VERSION = "5.13"` в `models/__init__.py`. *Альтернатива* — генерировать ORM из DBML скриптом: отвергнуто, DBML содержит notes-семантику (partial unique, NULLS NOT DISTINCT), которую проще выразить руками и проверять скриптом. ### D-2. Миграция 0001 — одна ревизия, с нуля, откат до пустой схемы `alembic/versions/0001_dbml_v5_13.py`: `CREATE SCHEMA IF NOT EXISTS dkb` (в `env.py`, до миграций), enum'ы → таблицы → FK → индексы. Циклическая связь `stored_file.current_recognition_job_id ↔ recognition_job.file_id` — FK с `use_alter=True`. Частичные уникальные индексы — `Index(..., unique=True, postgresql_where=text("is_current"))`; `document` — `Index(..., unique=True, postgresql_nulls_not_distinct=True)` (PostgreSQL ≥ 15; на платформе 16). `downgrade()` удаляет индексы, таблицы, enum'ы (`DROP TYPE`) и оставляет пустую схему. Автогенерация используется как черновик, итог правится руками и проверяется шагом «upgrade → downgrade → upgrade» и `check_dbml_vs_orm.py` в `make check-full`. ### D-3. `check_dbml_vs_orm.py` — pydbml + сравнение метаданных, notes читаются по шаблонам Парсер — библиотека `pydbml` (dev-зависимость). Сравниваются: множество таблиц; для каждой — колонки (имя, тип по таблице соответствий D-1, nullability, PK, unique, default), FK (целевая таблица.колонка), индексы (колонки, unique), enum'ы (имя и упорядоченный список значений). Notes интерпретируются только по трём шаблонам: `partial UNIQUE WHERE `, `NULLS NOT DISTINCT`, `[pk]` на составных индексах; остальное — текст. Вывод — список расхождений `таблица.колонка: ожидалось / фактически`, код возврата 1 при любом расхождении. Скрипт также запускается как pytest-тест (`tests/test_dbml_parity.py`). *Если `pydbml` окажется неустановим* — fallback: собственный парсер подмножества DBML (Table/Enum/Indexes/Ref), ~200 строк. ### D-4. Seed — данные в файлах, upsert по натуральному ключу, разделение prod/dev `api/seed/core/`: `regional_office.yaml` (только ГО), `roles.yaml`, `permissions.yaml`, `role_permissions.yaml`, `chart_of_accounts.csv` (code, parent_code, name, kind, row_type, is_off_balance), `elimination_rules_v1.yaml` (правило + счета сторон + исключения), `required_document_rules.yaml`. `api/seed/dev/`: `regional_offices.yaml` (2 РФ), `users.yaml` (по одному на роль, email `@armka.local`). `python -m scripts.seed [--dev]` — идемпотентно: upsert по `code`/`inn`/`email`/`(statement_type, rule_no, version_no)`; удаление не делает. Коды прав — `_` (`DIRECTORY_EDIT`, `KFO_VERIFY`), при совпадении объекта и действия — одно слово (`EXPORT`). `role_permission.granted_by = NULL` (системное действие, как в DBML для `*_by`). `elimination_rule.valid_from = 2024-01-01` — покрывает период фикстур. ### D-5. Аутентификация: `TokenVerifier` + dev-эмитент, РАИСА — каркас до ответа платформы `src/core/auth.py`: `Claims(subject, email, exp)`; протокол `TokenVerifier.verify(token) -> Claims`; `DevHs256Verifier` (секрет `AUTH_DEV_SECRET`, включается `AUTH_DEV_ISSUER=true`) и `JwksVerifier` (`AUTH_JWKS_URL` | `AUTH_PUBLIC_KEY`, `AUTH_ISSUER`, `AUTH_AUDIENCE`, `AUTH_EMAIL_CLAIM` = `email`; алгоритмы RS256/ES256) на `PyJWT`. Зависимости в `api/dependencies/auth.py`: `current_user` (Bearer → verify → `app_user` по email, `is_active`, иначе 403), `require(code)` (403, если `code ∉ effective_permissions`). `effective_permissions` — один запрос по `user_role ⋈ role_permission ⋈ permission`. Dev-эмитент — `POST /api/v1/dev/token {email}` → HS256 JWT на 8 часов; роутер подключается только при `AUTH_DEV_ISSUER=true` и `DEBUG=true` (в проде невозможен). Шаблонный middleware `raisa_fastapi_protected_api` остаётся как в шаблоне (импорт при `DEBUG=false`; пакет недоступен вне банка до прода) — он защищает периметр, наш слой извлекает личность. *Альтернатива* — только middleware РАИСА: отвергнуто, пакет недоступен и его API неизвестен. ### D-6. Outbox/inbox на core NATS — фоновые задачи в lifespan, ack и повторы на уровне приложения JetStream на платформе нет: нет durable consumer'ов, серверного дедупа, redelivery, `max_deliver`. Схема: - `src/core/nats.py`: подключение (пустые `NATS_SEED/NATS_JWT` → без учётных данных; иначе nkeys/JWT как в шаблоне), `publish(subject, payload, msg_id)` с заголовком `Nats-Msg-Id`, подписка `recognition.result` с queue group `arm-core` (несколько инстанций ядра получают результат по одному разу). Stream'ов и consumer'ов нет; переподключение — встроенное в `nats-py` (`max_reconnect_attempts=-1`, `reconnect_time_wait=2`). - `src/services/recognition_exchange.py`: - `publish_pending()` — `SELECT … FROM recognition_job WHERE status='queued' OR (status='published' AND published_at < now() - ACK_TIMEOUT_SECONDS) FOR UPDATE SKIP LOCKED LIMIT 50`; для `queued` — первая публикация (`published`, `published_at`, `request_msg_id = :`, `request_subject`); для `published` без ack — повторная публикация с тем же `Nats-Msg-Id` и обновлением `published_at`, счётчик повторов — в `metrics.republish_count` (jsonb, поле контракта метрик ядра); при `republish_count ≥ OUTBOX_MAX_REPUBLISH` → `status = dlq`, `error_code = NO_ACK`. `attempt` при этом не растёт — обработка не начиналась. - `handle_message(msg)` — конверт → pydantic; `status = processing` → задание `published → processing`, `started_at`, `service_name/version` (повторный ack игнорируется; ack для задания не в `published` — лог); иначе результат: job не найден → лог; `result_msg_id == Nats-Msg-Id` → дубликат, игнор; иначе `s3_object` (роль `results`, `created_by = service.name`) → обновление job (`status`, `finished_at`, `metrics`, `error_*`, `result_schema_version`, `result_s3_object_id`, `result_msg_id`) → commit. Ошибка БД — лог и повтор по следующему сообщению не предусмотрен (брокер не переотправит): задание дойдёт до `timeout` по `reply_deadline` и будет replay'нуто — осознанный компромисс без JetStream. - `expire_deadlines()` — `processing` (и `published`) с `reply_deadline < now()` → `timeout`. - Период опроса `OUTBOX_POLL_SECONDS=5`, `ACK_TIMEOUT_SECONDS=30`, `OUTBOX_MAX_REPUBLISH=3`, вахтёр — 30 с. - Запуск — `asyncio.create_task` в lifespan при `NATS_ENABLED=true` (в dev NATS нужен, шаблонный `DEBUG` его выключал — добавлен явный флаг). Маппинг документов — протокол `ResultMapper` с реализацией `NoopMapper` (Срез 2 заменит). *Альтернатива* — отдельный процесс-воркер: отвергнуто для Среза 0 (одна инстанция; SKIP LOCKED делает многоинстансность безопасной позже без изменений). *Если JetStream появится* — `publish` и подписка переезжают на JS API, остальное без изменений. ### D-7. Тесты — PostgreSQL/NATS/MinIO из compose, маркеры `integration` и `fixtures` `tests/conftest.py`: engine на `OPENBAO__DATABASE__DATABASE_URL` из `.env.test`; схема создаётся миграциями один раз на сессию; каждый тест — в транзакции с откатом (`SAVEPOINT`-паттерн SQLAlchemy). `domain/` — без БД. Маркеры: `integration` (нужны NATS/MinIO), `fixtures` (нужен `ARM_KA_FIXTURES`; пропуск с причиной). SQLite не используется: enum'ы, partial unique, jsonb, `NULLS NOT DISTINCT`. ### D-8. Линтеры и проверки: ruff + mypy в `api/pyproject.toml`, `make check` / `make check-full` `ruff`: `line-length = 120`, правила `E,F,I,UP,B,PL` (подмножество pylint), формат по умолчанию (black-совместимый). `mypy`: `strict = false` на старте, `check_untyped_defs = true`, плагин pydantic; `src/models` и `src/domain` — `strict = true`. Шаблон декларировал black/isort/pylint без конфигов; откат — замена файла конфига. CI в Gitea нет: `make check` (без Docker) и `make check-full` (compose, миграции, DBML, `web`) — по `../docs/dev/checks.md`; `make check-full` перед PR. ### D-9. План счетов для seed — стандартный 94н + типовые субсчета 1С БП 3.0 Полный план конкретной конфигурации клиента недоступен (fixtures README: «план счетов из бандла макета» отсутствует). Seed содержит синтетические счета 01–99 и забалансовые 001–011 (Приказ 94н) с `kind` по плану и все субсчета, на которые ссылаются §4 (правила), §6 (шахматка) и `required_document_rule`; `row_type` — по глубине кода; `parent_code` — префикс. Редкие субсчета клиентов добавляет администратор (ARCH-004, v5.6). Проверка: все счета `elimination_rules_v1.yaml` и `required_document_rules.yaml` резолвятся в `chart_of_account` (OQ-47). ### D-10. API — префикс `/api/v1`, формат ошибок шаблона Все новые роутеры под `APIRouter(prefix="/api/v1")` в `api/router.py`; ответы ошибок — `{"detail": …}` по-русски, как в шаблонных обработчиках; 401 — нет/невалидный токен, 403 — пользователь не найден/неактивен/нет права. ### D-11. Dev без Nexus: Python 3.11, фильтр `raisa_*`, `optionalDependencies` `Dockerfile` — `python:3.11-slim`; `make venv` — `python3.11 -m venv` + `grep -v '^raisa_' requirements.txt | pip install -r /dev/stdin` + `requirements-dev.txt`; `Dockerfile` ставит так же. `requirements.txt` для платформы не меняется. `web/package.json`: `raisa-ui`, `raisa-fastapi-protected-web` → `optionalDependencies` (с Nexus ставятся, без — пропускаются). ## Risks / Trade-offs - [Без JetStream результат, опубликованный при простое ядра, теряется] → `reply_deadline` → `timeout` → replay (Срез 3+); для Среза 0 это задокументированный компромисс, покрыт интеграционным тестом «ядро не слушало». - [Повторная публикация без ack дублирует запрос работающему сервису] → сервисы держат множество `job_id` в работе и отвечают повторным ack; тот же `Nats-Msg-Id`. - [Автогенерация Alembic пропускает partial-индексы и enum-изменения] → финальная ревизия пишется руками; шаг «upgrade → downgrade → upgrade» и сверка с DBML в `make check-full`. - [`pydbml` не разбирает какую-то конструкцию DBML (кириллические enum-значения в кавычках)] → проверка на первом шаге задачи 2.5; при провале — собственный парсер (D-3). - [Dev-эмитент попадёт в прод] → роутер подключается только при `AUTH_DEV_ISSUER=true and DEBUG=true`; тест проверяет, что при `DEBUG=false` маршрута нет. - [Шаблон создаёт `settings` при импорте] → тесты всегда идут с `.env.test`; `ENV_FILE` читается в `Settings.model_config`. - [Нет CI — проверки можно забыть] → `make check-full` в DoD каждой задачи и в описании PR. ## Migration Plan Пустая БД → `alembic upgrade head` → `python -m scripts.seed [--dev]`. Откат — `alembic downgrade base` (все объекты удаляются). Данных для переноса нет. В проде seed `core` выполняется при каждом деплое (идемпотентен), `--dev` — никогда. ## Open Questions Остались (в родительском change): версия Node, параметры JWT РАИСА (нужны к первому деплою; до этого dev-эмитент), появится ли JetStream (меняет только `core/nats.py`). Ни один не меняет структуру задач. ## Decisions made without discussion | № | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | 1 | 2026-10-03 | D-1: раскладка ORM по частям DBML, `varchar` → `String` без длины | DBML не задаёт длины; меньше шума в сверке | добавить длины — правка типов в одном месте + миграция | | 2 | 2026-10-03 | D-2: одна миграция 0001 на весь DBML, FK-цикл через `use_alter` | срез 0 стартует с пустой БД | нет — дальнейшие изменения идут новыми ревизиями | | 3 | 2026-10-03 | D-3: сверка через `pydbml`, notes по трём шаблонам | писать полный парсер дорого | собственный парсер ~200 строк | | 4 | 2026-10-03 | D-4: коды прав `_`, `EXPORT` для совпадения; `valid_from` правил = 2024-01-01; seed разделён на core/dev | предсказуемость кодов; фикстуры 2024 г. | переименование seed-данных до первого деплоя бесплатно | | 5 | 2026-10-03 | D-5: dev-эмитент HS256 за двумя флагами; РАИСА — конфигурируемый JWKS-каркас | пакет РАИСА недоступен, параметры неизвестны | дописать `RaisaVerifier` под реальные claims | | 6 | 2026-10-03 | D-6: core NATS — queue group `arm-core`, ack `processing`, повторная публикация без ack 30 с × 3 → `dlq` с `error_code = NO_ACK`, счётчик в `metrics.republish_count`, `attempt` не растёт; ошибка БД при приёме результата не ретраится (доходит до `timeout`) | JetStream нет (ответ 03.10); DBML не имеет колонки для счётчика повторов | при JetStream — замена publisher/subscriber; при необходимости — колонка в DBML v5.14 | | 7 | 2026-10-03 | D-6: `stored_file.status` в Срезе 0 не меняется, только `recognition_job` | жизненный цикл файла — Срез 2 | нет | | 8 | 2026-10-03 | D-7: тесты только на PostgreSQL 16, транзакция с откатом | SQLite не воспроизводит схему | нет | | 9 | 2026-10-03 | D-8: ruff + mypy вместо black/isort/pylint из бейджей README; `make check`/`make check-full` вместо CI | один инструмент, black-совместимый формат; CI в Gitea нет | замена конфига; YAML под пайплайн платформы при необходимости | | 10 | 2026-10-03 | D-9: план счетов — 94н + типовые субсчета 1С, не полный план клиента | полного плана нет; ARCH-004 | дозагрузка CSV администратором или новым seed | | 11 | 2026-10-03 | ASSUMPTION(OQ-46): все ОСВ расширенного подхода `is_mandatory = true` | иначе EXTENDED ничем не отличается от BASIC в чек-листе | правка одного yaml | | 12 | 2026-10-03 | `Settings` читает `ENV_FILE`; `requirements-dev.txt` → `-r requirements.txt` + инструменты | шаблонный pytest.ini уже ссылается на `ENV_FILE`; dev-пины конфликтовали с prod | нет | | 13 | 2026-10-03 | D-11: Python 3.11, фильтр `raisa_*` при dev-установке, npm `raisa-*` → `optionalDependencies` | ответы пользователя: 3.11, Nexus недоступен до прода | вернуть две строки в `dependencies` | | 14 | 2026-10-03 | R-1: `Makefile` в корне arm-core (backend-цели `cd api`, web-цели `cd web`), venv — `api/.venv` | `check-full` включает `docker build` и `web`; spec quality-checks требует выполнения команд из `api/` — соблюдено | перенос файла и правка путей | | 15 | 2026-10-03 | R-2: шаблонный код автоформатируется/чинится ruff отдельным коммитом (без смены поведения), остаточные правила — точечный `noqa`/per-file-ignores | `make lint` должен быть зелёным на каркасе | правка конфига | | 16 | 2026-10-03 | R-3: подключение NATS переезжает из `app.py` в `src/core/nats.py` в задаче 5.3; `core/nats_callback.py` удаляется в 5.5 | не дублировать connect-логику между задачами 1.4 и 5.3 | небольшой рефакторинг | | 17 | 2026-10-03 | R-4: `required_document_rule` — в `models/audit_ex.py` (D-1, секция DBML «ПРОЧЕЕ»), а не в `accounting.py` | design D-1 — источник; план в этом месте расходился | перенос класса | | 18 | 2026-10-03 | R-5: seed — функция `run_seed(session, *, dev)` без commit, CLI коммитит | тесты идут в транзакции с откатом (D-7) | нет | | 19 | 2026-10-03 | R-6: пути `api/dependencies/`, `api/endpoints/` из tasks.md = `src/api/dependencies/`, `src/api/endpoints/` | раскладка шаблона | нет | | 20 | 2026-10-03 | R-7: обмен `recognition_job` не пишет `audit_event` в Срезе 0 | в `audit_action` нет действия для статусов задания; spec обмена аудит не требует | добавить действие в DBML v5.14 и вызовы | | 21 | 2026-10-03 | R-8: правка `../docs` (`07-open-questions.md`) не выполняется исполнителем; текст допущений — в отчёте для PR в docs | `../docs` ведёт другой контроллер | отдельный PR в docs | | 22 | 2026-10-03 | R-11: `PyJWT[crypto]` добавлен в `requirements.txt` (прод-зависимость), а не только в dev | `core/auth.py` импортирует `jwt` в проде | платформа ставит лишний пакет; обратное — падение при импорте | | 23 | 2026-10-03 | R-12: подписка (5.4) вызывает `handle_message`, заглушку до 5.5 | порядок задач: lifespan раньше inbox | нет |