Срез 0 (issue #1): раздел «Соглашения шаблона» (что оставлено от шаблона команды, что вырезается, что добавляется); change openspec/changes/slice-0-skeleton — proposal, design с таблицей «Decisions made without discussion», спеки и tasks; в openspec/config.yaml строки с двоеточием взяты в кавычки (YAML разбирал их как словари). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 KiB
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(конверты сообщений, файл результата,Nats-Msg-Id). - Инварианты CLAUDE.md: права — данные; единственный писатель в БД — ядро; append-only аудит; обмен только через
recognition_job.
Goals / Non-Goals
Goals: рабочая схема БД и миграции; пользователь с правами из БД через JWT; outbox/inbox, доказанные интеграционным тестом; CI, который ловит расхождение 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). downgrade() удаляет индексы, таблицы,
enum'ы (DROP TYPE) и оставляет пустую схему. Автогенерация используется как черновик, итог правится руками и
проверяется CI-шагом «upgrade → downgrade → upgrade» и check_dbml_vs_orm.py.
D-3. check_dbml_vs_orm.py — pydbml + сравнение метаданных, notes читаются по шаблонам
Парсер — библиотека pydbml (dev-зависимость). Сравниваются: множество таблиц; для каждой — колонки (имя, тип по таблице
соответствий D-1, nullability, PK, unique, default), FK (целевая таблица.колонка), индексы (колонки, unique), enum'ы (имя и
упорядоченный список значений). Notes интерпретируются только по трём шаблонам: partial UNIQUE WHERE <expr>,
NULLS NOT DISTINCT, [pk] на составных индексах; остальное — текст. Вывод — список расхождений таблица.колонка: ожидалось / фактически, код возврата 1 при любом расхождении. Скрипт также запускается как pytest-тест (tests/test_dbml_parity.py).
Если pydbml недоступен в Nexus — 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 <role>@armka.local). python -m scripts.seed [--dev] — идемпотентно: upsert по
code/inn/email/(statement_type, rule_no, version_no); удаление не делает. Коды прав — <OBJECT>_<ACTION>
(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 — фоновые задачи в lifespan, JetStream через nats-py
src/core/nats.py: подключение (пустые NATS_SEED/NATS_JWT → без учётных данных; иначе nkeys/JWT как в шаблоне),
ensure_stream() при NATS_MANAGE_STREAM=true (stream RECOGNITION, subjects recognition.>, duplicate_window 2 мин),
durable pull-consumer arm-core-results (recognition.result, max_deliver 5, ack_wait 60 с).
src/services/recognition_exchange.py: publish_pending() — SELECT … FROM recognition_job WHERE status='queued' FOR UPDATE SKIP LOCKED LIMIT 50, сборка запроса из stored_file ⋈ s3_object, js.publish(subject, payload, headers={"Nats-Msg-Id": f"{job_id}:{attempt}"}), при ack → published, published_at, request_msg_id, request_subject;
при ошибке — остаётся queued (повтор следующим циклом). handle_result(msg) — конверт → pydantic; job не найден → лог +
ack; result_msg_id == Nats-Msg-Id → ack (дубликат); иначе s3_object (роль results, created_by = service.name) →
обновление job (status, service_*, finished_at, metrics, error_*, result_schema_version, result_s3_object_id,
result_msg_id) → commit → ack; ошибка БД → nak; num_delivered ≥ max_deliver → status = dlq + ack. expire_deadlines() —
published|processing с reply_deadline < now() → timeout. Период опроса OUTBOX_POLL_SECONDS=5, вахтёр — 30 с.
Запуск — asyncio.create_task в lifespan при DEBUG=false или NATS_ENABLED=true (в dev NATS нужен, шаблонный
DEBUG его выключал — добавлен явный флаг). Маппинг документов — протокол ResultMapper с реализацией NoopMapper
(Срез 2 заменит). Альтернатива — отдельный процесс-воркер: отвергнуто для Среза 0 (одна инстанция; SKIP LOCKED делает
многоинстансность безопасной позже без изменений).
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
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.
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.
D-10. API — префикс /api/v1, формат ошибок шаблона
Все новые роутеры под APIRouter(prefix="/api/v1") в api/router.py; ответы ошибок — {"detail": …} по-русски, как
в шаблонных обработчиках; 401 — нет/невалидный токен, 403 — пользователь не найден/неактивен/нет права.
Risks / Trade-offs
- [PostgreSQL банка < 15 →
NULLS NOT DISTINCTнедоступен] → миграция падает явно; fallback — уникальный индекс поCOALESCE(osv_account_code, '')(одно место, помечено комментарием в миграции). - [Автогенерация Alembic пропускает partial-индексы и enum-изменения] → финальная ревизия пишется руками; CI-шаг «upgrade → downgrade → upgrade» и сверка с DBML.
- [
pydbmlне разбирает какую-то конструкцию DBML (кириллические enum-значения в кавычках)] → проверка на первом шаге задачи 2.4; при провале — собственный парсер (D-3). - [Dev-эмитент попадёт в прод] → роутер подключается только при
AUTH_DEV_ISSUER=true and DEBUG=true; тест проверяет, что приDEBUG=falseмаршрута нет. - [Consumer ack'ает до commit'а] → порядок: commit → ack; при падении между ними — повтор и дедуп по
result_msg_id. - [Шаблон создаёт
settingsпри импорте] → тесты всегда идут с.env.test;ENV_FILEчитается вSettings.model_config.
Migration Plan
Пустая БД → alembic upgrade head → python -m scripts.seed [--dev]. Откат — alembic downgrade base (все объекты
удаляются). Данных для переноса нет. В проде seed core выполняется при каждом деплое (идемпотентен), --dev — никогда.
Open Questions
Все — в родительском change (../docs/openspec/changes/slice-0-skeleton/design.md, «Open Questions»): версии
Python/PostgreSQL/NATS, параметры JWT РАИСА, доступ к Nexus, кто создаёт stream. Ни один не меняет структуру задач:
до ответов работает dev-эмитент и NATS_MANAGE_STREAM=true.
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: коды прав <OBJECT>_<ACTION>, 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: фоновые задачи в lifespan, pull-consumer, флаг NATS_ENABLED для dev |
шаблонный DEBUG выключал NATS целиком |
вынести в отдельный процесс — перенос двух функций |
| 7 | 2026-10-03 | D-6: stored_file.status в Срезе 0 не меняется, только recognition_job |
жизненный цикл файла — Срез 2 | нет |
| 8 | 2026-10-03 | D-7: тесты только на PostgreSQL, транзакция с откатом | SQLite не воспроизводит схему | нет |
| 9 | 2026-10-03 | D-8: ruff + mypy вместо black/isort/pylint из бейджей README | один инструмент, black-совместимый формат | замена конфига и шага CI |
| 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 |
нет |