Andrey Epifancev f12ce87346 docs: соглашения шаблона в CLAUDE.md и OpenSpec change slice-0-skeleton
Срез 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>
2026-10-03 02:43:34 +04:00

151 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 | нет |