Merge pull request 'NATS: subject'ы и JWT по окружению из OpenBao (OPENBAO__NATS__*) (#80)' (#99) from feat/80-nats-env-config into main

Reviewed-on: #99
This commit is contained in:
aep 2026-10-13 12:55:24 +03:00
commit c6bfbdd11d
17 changed files with 1132 additions and 31 deletions

View File

@ -64,9 +64,13 @@
| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` | Схема |
|---|---|---|---|---|---|
| запрос | `files.tabular_uploaded` / `files.img_uploaded` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` | [request](../contracts/recognition-request.schema.json) |
| ack | `files.tabular_parsed` | tabular-parser / scan-gateway | конверт со `schema_version`, `status = processing`, `job_id`, `file_id`, `service {name, version}` | `ack:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json) |
| результат | `files.tabular_parsed` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `service {name, version}`, `metrics` (опц.), `raw_s3_key` (опц., только scan-gateway); при `done` / `needs_check` обязательны `result_s3 {bucket, key}` и `result_schema_version`, при `error` — `error {code, message}` | `result:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) |
| запрос | `files.tabular_uploaded` / `files.img_uploaded` (умолчания) | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` | [request](../contracts/recognition-request.schema.json) |
| ack | `files.tabular_parsed` (умолчание) | tabular-parser / scan-gateway | конверт со `schema_version`, `status = processing`, `job_id`, `file_id`, `service {name, version}` | `ack:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json) |
| результат | `files.tabular_parsed` (умолчание) | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `service {name, version}`, `metrics` (опц.), `raw_s3_key` (опц., только scan-gateway); при `done` / `needs_check` обязательны `result_s3 {bucket, key}` и `result_schema_version`, при `error` — `error {code, message}` | `result:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) |
Имена `files.*` на схеме и в таблице выше — умолчания: subject'ы являются настройками окружения
`OPENBAO__NATS__NATS_{TABULAR_REQUEST,IMAGE_REQUEST,RESULT}_SUBJECT` (`files.tabular_uploaded`, `files.img_uploaded`,
`files.tabular_parsed`), одинаковыми у всех трёх сервисов. Queue group'ы — константы.
`group_schema` в конверт не входит — ядро берёт его из `documents[].group_schema` файла результата при маппинге.
@ -303,7 +307,9 @@ FastAPI» платформы РАИСА/aurora): дерево импортиро
- `pydantic-settings`, файл `api/.env`, вложенность через `__`, лишние переменные игнорируются. Все инфраструктурные
секреты — под префиксом `OPENBAO` (в проде заполняет OpenBao): `OPENBAO__DATABASE__DATABASE_URL`,
`OPENBAO__MINIO__{MINIO_URL, MINIO_ADMIN_LOGIN, MINIO_ADMIN_PASSWORD, MINIO_BUCKET}`,
`OPENBAO__NATS__{NATS_URL, NATS_SEED, NATS_JWT}`, `OPENBAO__SMB__{SMB_HOST, SMB_PATH, SMB_LOGIN, SMB_PASSWORD}`.
`OPENBAO__NATS__{NATS_URL, NATS_SEED, NATS_JWT, NATS_TABULAR_REQUEST_SUBJECT, NATS_IMAGE_REQUEST_SUBJECT,
NATS_RESULT_SUBJECT}` (во всех трёх сервисах; `NATS_SEED` и `NATS_JWT` — `SecretStr`, три subject'а — настройки
с умолчаниями `files.*`), `OPENBAO__SMB__{SMB_HOST, SMB_PATH, SMB_LOGIN, SMB_PASSWORD}`.
Прочее: `DEBUG`, `ROOT_PATH`, `VERSION`, `SERVER__HOST`, `SERVER__PORT`.
- Платформа РАИСА передаёт в под только секреты OpenBao, поэтому любая настройка верхнего уровня `Settings` в контуре
задаётся как `OPENBAO__APP__<ИМЯ>` (приоритет: обычный env → `OPENBAO__APP__*` → `.env` → умолчание; неизвестное
@ -334,6 +340,9 @@ FastAPI» платформы РАИСА/aurora): дерево импортиро
`files.img_uploaded`, `files.tabular_parsed`, `files.img_parsed`; используем три — запросы в `files.tabular_uploaded`
и `files.img_uploaded`, ack и результаты обоих сервисов в общий `files.tabular_parsed`; `files.img_parsed` не
используется. Старый АРМ гасится, общих подписчиков на этих subject'ах у нас с ним не будет.
Прод и тест — свои наборы: JWT, seed и три subject'а окружения задаются одинаково всем трём сервисам; JWT окружения
должен давать publish/subscribe на эти subject'ы. Разъехавшиеся настройки — тишина на шине (dlq по NO_ACK); сверять
`/healthcheck.subjects` ядра и сервисов (решение владельца 12.10.2026).
- **SMB** (горячие папки ИС Сканирование) — `smbprotocol`/`smbclient`, сессия регистрируется в lifespan; нужен только `scan-gateway`.
- **Внешняя аутентификация** — пакет `raisa_fastapi_protected_api` из внутреннего Nexus (`AuthorizationMiddleware`,
`ProtectedSettings`, …), импортируется только при `DEBUG=false` (в контуре временно `DEBUG=true`, см. «Аутентификация и авторизация», п. 6); фронтовая пара — `raisa-fastapi-protected-web`.

View File

@ -88,7 +88,7 @@
| OQ-80 | `make up` расходится с эталоном (scan-gateway и `dev/Makefile.common`): в `tabular-parser` нет `minio-init` — на свежем томе бакета `arm-ka` нет, `test_s3_integration` даёт `NoSuchBucket`, хотя README сервиса обещает бакет; в `arm-core` `up -d` без `--wait` и без `--project-name` (имя в compose фиксировано, безвредно). Также `check-full` в arm-core и tabular-parser — через пререквизиты (порядок при `make -j` не гарантирован), в scan-gateway — через `$(MAKE)`; `lint` arm-core включает `mypy scripts`. Подключить `include ../docs/dev/Makefile.common` в сервисах? | tabular-parser: `Makefile` (цель `up`); scan-gateway: п. 18 таблицы предложений (I-1); docs: O-1 и O-2 (задача 1 фазы 2), O-4 и O-5 (задача 2 фазы 2), `dev/checks.md`, `dev/Makefile.common` | срез 0 (процесс), все сервисы | допущение принято: эталон — scan-gateway, расхождения описаны в `dev/checks.md`, Makefile сервисов не правились (design #22, #24); выровнять — правкой Makefile сервисов (follow-up) |
| OQ-81 | Переменные `ARM_KA_DOCS` / `ARM_KA_FIXTURES` поддержаны неравномерно: `arm-core` — обе; `tabular-parser` — только `ARM_KA_DOCS`; `scan-gateway` — ни одной (жёстко `../docs`, `../../fixtures/ocr`). Текст причины skip («fixtures недоступен: положите репозиторий…») не содержит строки «ARM_KA_FIXTURES недоступен» из сценария «Нет фикстур» spec `quality-checks`; для docs-теста текст тоже без `ARM_KA_DOCS`. Нарушение spec — править сервисы? | scan-gateway и tabular-parser (тесты, Makefile); docs: O-3 (задача 1 фазы 2), `dev/checks.md`; spec `quality-checks` | срез 0 (процесс), все сервисы | допущение принято: расхождение описано в `dev/checks.md`; правка тестов и Makefile в scan-gateway и tabular-parser — follow-up человеку (сервисы не правились); **частично закрыт (Срез 2a, tabular-parser):** хелпер `tests/fixtures_helpers.py` поддерживает `ARM_KA_FIXTURES` (текст skip называет переменную и путь), `ARM_KA_DOCS` поддержан в тесте идентичности контракта; `scan-gateway` — по-прежнему ни одной переменной, остаётся follow-up человеку |
| OQ-82 | `.dockerignore` в `arm-core` не исключает `api/.env.test`: файл попадает в образ (у `tabular-parser` и `scan-gateway` исключён). Исправлять? | arm-core `.dockerignore`; docs: T3.2 (задача 3 фазы 2) | срез 0 (образы), прод-сборка | **закрыт реализацией (arm-core PR #10, техдолг Среза 1, 05.10.2026):** `.dockerignore` исключает `api/.env.*` (включая `.env.test` и `.env.example`); настройки контейнера — только из окружения |
| OQ-83 | Контейнеры профиля `services` подписаны на те же subject и queue group общего NATS, что и экземпляры в интеграционных тестах сервисов (`recognition.result` / `arm-core`, `*.request`): NATS может отдать сообщение теста контейнеру и наоборот; интеграционные тесты `scan-gateway` и `arm-core` одновременно тоже делят NATS. Сделать queue group и subject-префикс настраиваемыми для тестов (например `NATS_TEST_PREFIX`) или поднимать отдельный NATS на тесты? | scan-gateway: п. 11 таблицы предложений (T14.1, T11.8); docs: O-11 (задача 3 фазы 2; подтверждено ревью для tabular-parser и scan-gateway), `dev/README.md` | срез 0 (процесс), интеграционные тесты | допущение принято: в `dev/README.md` предупреждение и команда `stop` для контейнеров профиля; не гонять `make test-integration` двух сервисов одновременно |
| OQ-83 | Контейнеры профиля `services` подписаны на те же subject и queue group общего NATS, что и экземпляры в интеграционных тестах сервисов (`recognition.result` / `arm-core`, `*.request`): NATS может отдать сообщение теста контейнеру и наоборот; интеграционные тесты `scan-gateway` и `arm-core` одновременно тоже делят NATS. Сделать queue group и subject-префикс настраиваемыми для тестов (например `NATS_TEST_PREFIX`) или поднимать отдельный NATS на тесты? | scan-gateway: п. 11 таблицы предложений (T14.1, T11.8); docs: O-11 (задача 3 фазы 2; подтверждено ревью для tabular-parser и scan-gateway), `dev/README.md` | срез 0 (процесс), интеграционные тесты | допущение принято: в `dev/README.md` предупреждение и команда `stop` для контейнеров профиля; не гонять `make test-integration` двух сервисов одновременно. 13.10.2026 (nats-env-config): subject'ы — настройки; интеграционные тесты tabular-parser и scan-gateway идут на уникальных subject'ах прогона и не пересекаются со стендом. Для `make e2e` ядра вопрос остаётся: ему нужен контейнер tabular-parser стенда на умолчаниях, конфликт — общая queue group `arm-core` (design R-7). Статус: открыт (частично) |
| OQ-84 | Запуск сервисов из venv без Nexus (`make venv` + `python run_server.py` из `api/` с `.env` из `docs/dev/.env.example`, healthcheck по `localhost`) в Срезе 0 не проверялся ни в одном из трёх сервисов. Известно по чтению конфигов (design #44): `.env.example` в нынешнем виде отвергнут `scan-gateway` (`OPENBAO__DATABASE__*`) и `tabular-parser` (`OPENBAO__DATABASE__*`, `OPENBAO__SMB__*` — `extra_forbidden`), в нём нет `OPENBAO__MINIO__MINIO_SECURE=false` (по умолчанию `true` — https к http-MinIO). Прогнать сценарий, зафиксировать набор переменных и портов (`SERVER__PORT`, `SCAN_HOTFOLDER_PATH` для `scan-gateway`), решить, нужен ли отдельный `.env.example` на сервис, и снять пометку «черновик» с раздела `dev/README.md`? | spec `dev-environment`, сценарий «Запуск из venv против compose без Nexus»; docs: design #43, `dev/README.md` («Запуск сервисов из venv без Nexus»); финальное ревью фазы 2: I-1 | срез 0 (процесс, окружение разработки); кандидат в follow-up issue | вопрос: допущение Среза 0 — раздел README остаётся черновиком, сценарий не подтверждён (проверка требовала `.venv` внутри сервисных репозиториев, в фазе 2 доступных только для чтения) |
| OQ-85 | **Возможен ли JetStream на платформе** (серверный дедуп по `Nats-Msg-Id`, durable consumer'ы, redelivery вместо ack и повторной публикации на уровне приложения)? Сейчас NATS без JetStream (ответ платформы 03.10.2026); Срез 0 строит доставку на ack `processing`, повторной публикации ядром и дедупе по `recognition_job.result_msg_id`. Если JetStream подключить можно — меняются только publisher/subscriber (`core/nats.py` в трёх сервисах), outbox/inbox и контракт остаются; исчезают потеря результата при простое ядра и риск повторной обработки. | решение человека 04.10.2026: «узнать у платформы и проверить позже»; `design.md` slice-0-skeleton (D-3, Open Questions п. 4); OQ-72, OQ-73 | инфраструктура NATS (платформа); `core/nats.py`, воркеры сервисов | **вопрос к платформе (поставить напоминание, проверить после ревью Среза 0 / до Среза 3)**: допущение — без JetStream. 06.10.2026: `RSHB/armka_file_worker` (PDFWorker старого АРМ, проверен в контуре) тоже работает на core NATS (`subscribe`/`publish` + `flush`, без `jetstream()`), один JWT на все сервисы АРМ с правами по subject'ам; возможность включить JetStream по-прежнему не выяснена |
| OQ-86 | Фильтр видимости по ЗО (`business_segment`): сегмент есть только у `app_user`, у ГК/ЮЛ поля нет (DBML v5.14). Откуда брать сегмент ГК (из ЕЦП CRM? из РФ?), нужен ли вообще фильтр по ЗО в первой очереди? | §10.1 правил; DBML v5.14 | срез 1 (policy), DBML v5.15 | допущение: в Срезе 1 ЗО не фильтрует; видимость — только по филиалу (ГО видит всё) |

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-request/v1",
"title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0",
"description": "Тело сообщения на subject files.tabular_uploaded или files.img_uploaded; subject должен соответствовать полю pipeline (tabular → files.tabular_uploaded, image → files.img_uploaded). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
"description": "Тело сообщения на subject запроса; subject — настройка окружения и должен соответствовать полю pipeline (tabular → NATS_TABULAR_REQUEST_SUBJECT, по умолчанию files.tabular_uploaded; image → NATS_IMAGE_REQUEST_SUBJECT, по умолчанию files.img_uploaded). Заголовок Nats-Msg-Id = <job_id>:<attempt> (не поле тела). Один файл (stored_file) → один job.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"],

View File

@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result-message/v1",
"title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0",
"description": "Тело сообщения на subject files.tabular_parsed. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:<job_id>:<attempt> — для status = processing; result:<job_id>:<attempt> — для done, needs_check и error. Форма запроса <job_id>:<attempt> описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "status", "service"],

View File

@ -47,6 +47,11 @@ OPENBAO__MINIO__MINIO_BUCKET=arm-ka
OPENBAO__NATS__NATS_URL=nats://localhost:4222
OPENBAO__NATS__NATS_SEED=
OPENBAO__NATS__NATS_JWT=
# Subject'ы окружения — одинаковые у arm-core, tabular-parser и scan-gateway (значения ниже — умолчания).
# Прод и тест имеют свои наборы; JWT должен давать publish/subscribe на эти subject'ы.
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=files.tabular_uploaded
OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=files.img_uploaded
OPENBAO__NATS__NATS_RESULT_SUBJECT=files.tabular_parsed
# SMB — заглушки (в Срезе 0 не используется); блок необязателен для arm-core и scan-gateway (режим local).
# Блок OPENBAO__DATABASE__* знает только arm-core: scan-gateway и tabular-parser неизвестные ключи OPENBAO__* отвергают

View File

@ -101,9 +101,9 @@ docker compose --profile services ps -a
# scan-emulator Up 8000/tcp (healthcheck нет: процесс без HTTP)
# scan-gateway Up (healthy) 127.0.0.1:8002->8000/tcp
# tabular-parser Up (healthy) 127.0.0.1:8001->8000/tcp
curl -s localhost:8000/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok"}
curl -s localhost:8001/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok"}
curl -s localhost:8002/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok","hotfolder":"ok"}
curl -s localhost:8000/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok","subjects":{"tabular_request":"…","image_request":"…","result":"…"}}
curl -s localhost:8001/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok","subjects":{"tabular_request":"…","result":"…"}}
curl -s localhost:8002/healthcheck # {"uptime":"…","version":"1.0.0","nats":"ok","hotfolder":"ok","subjects":{"image_request":"…","result":"…"}}
docker compose --profile services logs arm-core-migrate # Running upgrade -> 0001_dbml_v5_13 …; Seed применён …
docker compose --profile services logs scan-emulator # … корень /data/hotfolder …; Фикстуры: /fixtures/ocr, фикстур: 2
```
@ -323,12 +323,19 @@ MinIO; полная очистка — `docker compose --profile services down -
| `OPENBAO__NATS__NATS_URL` | `nats://localhost:4222` | адрес NATS |
| `OPENBAO__NATS__NATS_SEED` | пусто | nkey seed; пусто — подключение без nkeys |
| `OPENBAO__NATS__NATS_JWT` | пусто | JWT NATS; пусто — подключение без nkeys |
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | subject запросов на разбор таблиц (ядро публикует, tabular-parser слушает) |
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | subject запросов на распознавание сканов (ядро публикует, scan-gateway слушает) |
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | subject ack и результатов обоих сервисов (слушает ядро) |
| `OPENBAO__SMB__SMB_HOST` | `localhost` | заглушка SMB (в Срезе 0 не используется); блок необязателен для `arm-core` и для `scan-gateway` в режиме `SCAN_HOTFOLDER_MODE=local` (по умолчанию); `tabular-parser` его не знает и отвергнет (см. ниже) |
| `OPENBAO__SMB__SMB_PATH` | `share` | заглушка SMB |
| `OPENBAO__SMB__SMB_LOGIN` | `user` | заглушка SMB |
| `OPENBAO__SMB__SMB_PASSWORD` | `password` | заглушка SMB |
| `NATS_ENABLED` | `true` | включает подключение arm-core к NATS; без него `/healthcheck` показывает `nats=off` |
Три subject'а одинаковы у всех трёх сервисов (непустые, без пробелов, `*`, `>` и пустых токенов, попарно различны).
`NATS_JWT` и `NATS_SEED` — `SecretStr`. Прод и тест — свои наборы; разъехавшиеся настройки дают тишину на шине, сверяйте `/healthcheck.subjects`.
Два блока описывают одни и те же ресурсы: **при смене порта, пароля, пользователя или бакета меняйте обе части.**
## Сброс окружения
@ -374,12 +381,13 @@ curl -s localhost:<порт сервиса>/healthcheck
одновременно или сервис из venv рядом с профилем `services` требуют разных `SERVER__PORT`. Для `arm-core` в ответе
`/healthcheck` ожидаются `version` и `nats = ok` — при `NATS_ENABLED=true` (задан в `.env.example`; без него
`nats = off`). Поля `/healthcheck` в контейнерах: `tabular-parser` — `uptime`, `version`, `nats`; `scan-gateway` — ещё
`hotfolder` (шлюзу нужен `SCAN_HOTFOLDER_PATH`). Набор переменных сервисов для запуска из venv не проверялся (см. черновик выше и OQ-84). По чтению конфигов: `scan-gateway` и `tabular-parser` отвергают неизвестные ключи `OPENBAO__*`, а поле DATABASE есть только у `arm-core`. Поэтому в `.env` `scan-gateway` нужно удалить строки `OPENBAO__DATABASE__*` (SMB в режиме `local` допустим), а в `.env` `tabular-parser` — `OPENBAO__DATABASE__*` и `OPENBAO__SMB__*`; для MinIO по `http` нужен `OPENBAO__MINIO__MINIO_SECURE=false` (в `.env.example` его нет, по умолчанию `true`).
`hotfolder` (шлюзу нужен `SCAN_HOTFOLDER_PATH`). Во всех трёх ответах есть объект `subjects` с фактическими subject'ами сервиса (при любом состоянии NATS): `arm-core` — `{tabular_request, image_request, result}`, `tabular-parser` — `{tabular_request, result}`, `scan-gateway` — `{image_request, result}`. Набор переменных сервисов для запуска из venv не проверялся (см. черновик выше и OQ-84). По чтению конфигов: `scan-gateway` и `tabular-parser` отвергают неизвестные ключи `OPENBAO__*`, а поле DATABASE есть только у `arm-core`. Поэтому в `.env` `scan-gateway` нужно удалить строки `OPENBAO__DATABASE__*` (SMB в режиме `local` допустим), а в `.env` `tabular-parser` — `OPENBAO__DATABASE__*` и `OPENBAO__SMB__*`; для MinIO по `http` нужен `OPENBAO__MINIO__MINIO_SECURE=false` (в `.env.example` его нет, по умолчанию `true`).
## Интеграционные тесты сервисов против compose (черновик)
> Черновик: состав целей и порядок проверок описаны в [`checks.md`](checks.md); открытые вопросы — OQ-83 (общий NATS
> у контейнеров и тестов) и OQ-84.
> у контейнеров и тестов; закрыто частично: интеграционные тесты `tabular-parser` и `scan-gateway` идут на своих
> subject'ах прогона, `make e2e` ядра по-прежнему открыт, design R-7) и OQ-84.
Общий набор целей (`up`, `test`, `test-integration`, `lint`, `check`, `check-full`) есть во всех сервисных репозиториях
(`arm-core`, `tabular-parser`, `scan-gateway`); семантика, состав `check-full`, порядок перед PR и пропуск тестов без
@ -392,18 +400,15 @@ postgres nats minio`, затем `make test-integration` (или `make check-ful
эмулятор ИС Сканирование поднимается в процессе интеграционного теста; контейнер `scan-emulator` профиля `services`
тестами не используется. Общее описание проверок — [`checks.md`](checks.md).
> **Не запускайте интеграционные тесты `tabular-parser` и `scan-gateway` при поднятом профиле `services`.** Их
> сквозные тесты (`api/tests/integration/test_roundtrip.py`, у `scan-gateway` ещё `test_nats_minio_integration.py`)
> публикуют запросы в боевые `files.tabular_uploaded` / `files.img_uploaded`, а экземпляр сервиса из теста
> подписан в ту же queue group (`tabular-parser` / `scan-gateway`), что и контейнер. NATS отдаёт каждое сообщение группы
> одному подписчику — запрос теста может уйти контейнеру, и тест упадёт по таймауту ожидания ack (так прямо сказано в
> docstring теста `scan-gateway`). Риск подтверждён чтением кода тестов, тесты при поднятом профиле не запускались.
> Интеграционные тесты `arm-core` от контейнеров изолированы: временная БД на сессию (рабочая `armka` не используется)
> и уникальный subject результатов на тест. Перед тестами сервисов:
> `docker compose --profile services stop tabular-parser scan-gateway scan-emulator`; проверить, что чужих подписчиков
> нет: `curl -s 'http://127.0.0.1:8222/subsz?subs=1' | grep -o '"subject": *"recognition[^"]*"'` — строк
> `files.tabular_uploaded` / `files.img_uploaded` быть не должно (`files.tabular_parsed` — подписка контейнера
> `arm-core`, тестам `arm-core` не мешает).
> Интеграционные тесты `tabular-parser` и `scan-gateway` (`api/tests/integration/`) идут на уникальных subject'ах
> прогона (префиксы `itest.` / `test-<run>.`), поэтому не пересекаются с контейнерами профиля `services` и друг с
> другом: **останавливать контейнеры стенда больше не нужно.** Интеграционные тесты `arm-core` от контейнеров
> изолированы: временная БД на сессию и уникальный subject ответа на тест; публикации запросов в тестах ядра
> (`test_recognition_exchange.py`, `test_api_files_upload.py`) идут на умолчательные subject'ы запросов и достигают
> контейнеров стенда. Исключение — `make e2e` ядра
> (`tests/e2e/`): он идёт на subject'ах по умолчанию (design R-7, OQ-83), поэтому ядро стенда (`arm-core` на
> `:8000`) на время прогона нужно остановить — общая queue group `arm-core`; контейнер `tabular-parser` стенда на
> умолчаниях ему, наоборот, нужен.
## Проверка контракта

View File

@ -183,9 +183,14 @@ issue #22; файл `api/src/core/settings_sources.py` одинаков в тр
| Сервис | Секреты `OPENBAO__<ГРУППА>__*` | Настройки `OPENBAO__APP__*` |
|---|---|---|
| arm-core | `OPENBAO__DATABASE__DATABASE_URL` (asyncpg, схема `dkb`), `OPENBAO__MINIO__{MINIO_URL, MINIO_ADMIN_LOGIN, MINIO_ADMIN_PASSWORD, MINIO_BUCKET}` (`MINIO_SECURE=true` по умолчанию), `OPENBAO__NATS__{NATS_URL, NATS_SEED, NATS_JWT}` | `NATS_ENABLED=true` (по умолчанию `false`: без него NATS не подключается и обмен с сервисами распознавания не работает), `DEBUG=true`, `AUTH_DEV_ISSUER=true`, `AUTH_DEV_SECRET=<случайная строка от 32 байт>` (поле верхнего уровня `Settings`, поэтому тоже через `OPENBAO__APP__`), `FORWARDED_ALLOW_IPS=<адреса прокси платформы>` (иначе IP в аудите — адрес прокси); `CORS_ORIGINS` при `DEBUG=true` не нужен. `SERVER__HOST` не задавать (платформа задаёт сама). **Временно, на период тестов:** `CONSOLIDATION_PERIOD_FROM_VERSION=true` — ОСВ, загруженная в консолидацию, берёт её период (arm-core#103, OQ-161; `qa-guide.md` §7.4); после тестов убрать |
| scan-gateway | `OPENBAO__MINIO__*`, `OPENBAO__NATS__*`, `OPENBAO__SMB__{SMB_HOST, SMB_PATH, SMB_LOGIN, SMB_PASSWORD}` | `SCAN_HOTFOLDER_MODE=smb` (`NATS_ENABLED` по умолчанию `true`, задавать не нужно). `SCAN_HOTFOLDER_PATH` в контуре не нужен (только режим `local`; режим `local` и эмулятор `make emulator` — для разработки) |
| tabular-parser | `OPENBAO__MINIO__*`, `OPENBAO__NATS__*` | сверх секретов ничего (`NATS_ENABLED` по умолчанию `true`); при необходимости `PARSE_TIMEOUT_SECONDS` (и `PARSE_MAX_BYTES`) |
| arm-core | `OPENBAO__DATABASE__DATABASE_URL` (asyncpg, схема `dkb`), `OPENBAO__MINIO__{MINIO_URL, MINIO_ADMIN_LOGIN, MINIO_ADMIN_PASSWORD, MINIO_BUCKET}` (`MINIO_SECURE=true` по умолчанию), `OPENBAO__NATS__{NATS_URL, NATS_SEED, NATS_JWT, NATS_TABULAR_REQUEST_SUBJECT, NATS_IMAGE_REQUEST_SUBJECT, NATS_RESULT_SUBJECT}` | `NATS_ENABLED=true` (по умолчанию `false`: без него NATS не подключается и обмен с сервисами распознавания не работает), `DEBUG=true`, `AUTH_DEV_ISSUER=true`, `AUTH_DEV_SECRET=<случайная строка от 32 байт>` (поле верхнего уровня `Settings`, поэтому тоже через `OPENBAO__APP__`), `FORWARDED_ALLOW_IPS=<адреса прокси платформы>` (иначе IP в аудите — адрес прокси); `CORS_ORIGINS` при `DEBUG=true` не нужен. `SERVER__HOST` не задавать (платформа задаёт сама). **Временно, на период тестов:** `CONSOLIDATION_PERIOD_FROM_VERSION=true` — ОСВ, загруженная в консолидацию, берёт её период (arm-core#103, OQ-161; `qa-guide.md` §7.4); после тестов убрать |
| scan-gateway | `OPENBAO__MINIO__*`, `OPENBAO__NATS__*` (включая три subject'а), `OPENBAO__SMB__{SMB_HOST, SMB_PATH, SMB_LOGIN, SMB_PASSWORD}` | `SCAN_HOTFOLDER_MODE=smb` (`NATS_ENABLED` по умолчанию `true`, задавать не нужно). `SCAN_HOTFOLDER_PATH` в контуре не нужен (только режим `local`; режим `local` и эмулятор `make emulator` — для разработки) |
| tabular-parser | `OPENBAO__MINIO__*`, `OPENBAO__NATS__*` (включая три subject'а) | сверх секретов ничего (`NATS_ENABLED` по умолчанию `true`); при необходимости `PARSE_TIMEOUT_SECONDS` (и `PARSE_MAX_BYTES`) |
Для тестового контура — свой набор JWT, seed и трёх subject'ов (`NATS_TABULAR_REQUEST_SUBJECT` по умолчанию
`files.tabular_uploaded`, `NATS_IMAGE_REQUEST_SUBJECT` — `files.img_uploaded`, `NATS_RESULT_SUBJECT` —
`files.tabular_parsed`): одинаковый у всех трёх сервисов; JWT должен давать publish/subscribe на эти subject'ы.
Разъехавшиеся настройки — тишина на шине (dlq по NO_ACK); сверяйте `/healthcheck.subjects`.
В отличие от arm-core, в `scan-gateway` и `tabular-parser` `NATS_ENABLED=true` по умолчанию, а `DEBUG` влияет только на
уровень логов; платформенной аутентификации у них нет (внутренние сервисы без HTTP-входа для пользователей).

View File

@ -68,7 +68,7 @@
| Токен в проде | SSO РАИСА; параметры неизвестны (OQ-54) — держите слой «получить токен» отдельно от слоя «использовать токен» |
| Dev-пользователи | `admin@`, `ka_go@`, `curator_go@`, `expert@`, `ka_rf@`, `rka@`, `client_manager@armka.local` — по одному на роль (таблица — `frontend-auth-guide.md` §3.1) |
| Схема и Swagger | `/openapi.json`, `/docs`, `/redoc` — **только** при `DEBUG=true`; в проде 404 |
| Живость | `GET /healthcheck` (без префикса и без токена) → `{"uptime": "<время старта процесса, ISO>", "version": "1.0.0", "nats": "ok" \| "down" \| "off"}` — это не признак «вошёл» |
| Живость | `GET /healthcheck` (без префикса и без токена) → `{"uptime": "<время старта процесса, ISO>", "version": "1.0.0", "nats": "ok" \| "down" \| "off", "subjects": {"tabular_request": "…", "image_request": "…", "result": "…"}}` (`subjects` — служебное поле для диагностики, фронту не нужно) — это не признак «вошёл» |
| CORS | при `DEBUG=true` — `*` без credentials; в проде — источники из `CORS_ORIGINS` |
| Стенд целиком | `qa-guide.md` (docker compose, сиды, запуск бэка и фронта) |

View File

@ -952,6 +952,7 @@ Gitea: бэкенд и API — `RSHB/arm-core`, экраны — `RSHB/arm-web`,
| PDF завис в «Обработка» | не запущены `scan-gateway`/`scan-emulator` (раздел 8, предусловия) |
| PDF в статусе «Ошибка» (подсказка `INVALID_DOCUMENT`) | эмулятор знает только PDF из `fixtures/ocr` с JSON-ответом (6.8); свой PDF так и должен падать |
| Импорт падает на записи файла в S3 | MinIO не поднят или не создан бакет: `docker compose logs minio-init` |
| `/healthcheck.subjects` ядра и сервиса различаются (или файл при `nats: ok` стоит в `UPLOADED`, а через ~2 мин становится `ERROR` — ack не пришёл) | у сервисов разные `OPENBAO__NATS__NATS_*_SUBJECT`; выровняйте настройки у всех трёх |
| `/healthcheck` показывает `nats: down` | NATS не поднят; на сценарии Среза 1 не влияет, но загрузка ОСВ (6.6) без NATS не дойдёт до парсера |
| Файл завис в `UPLOADED`/`PROCESSING` | не запущен tabular-parser (`curl localhost:8001/healthcheck`) или ядро запущено без `NATS_ENABLED=true`; без ack файл через ~2 мин станет `ERROR` |

View File

@ -0,0 +1,781 @@
# Subject'ы и JWT NATS по окружению (OpenBao) — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** subject'ы обмена NATS и учётные данные NATS задаются секретами `OPENBAO__NATS__*` по окружению (прод/тест) во
всех трёх сервисах; subject'ы видны в логе старта и в `/healthcheck`.
**Architecture:** в `NATSSettings` каждого сервиса — три поля subject'ов с умолчаниями из констант
`contract/subjects.py`, валидация при старте, `SecretStr` для JWT/seed, `hide_input_in_errors` во всех классах настроек.
Код публикации и подписок берёт имена из настроек; функция `used_subjects(nats_settings)` в `core/nats.py` каждого
сервиса — единый источник для лога и `/healthcheck.subjects`. Контракт (`schema_version`) не меняется.
**Tech Stack:** Python 3.11, pydantic v2 / pydantic-settings, nats-py (core NATS), FastAPI, pytest.
**Spec:** `docs/openspec/changes/nats-env-config/` (proposal, design, `specs/recognition-contract/spec.md`) и дочерние
change'и `nats-env-config` в `arm-core/`, `tabular-parser/`, `scan-gateway/` (`openspec/changes/nats-env-config/`).
## Рабочее место
Workspace-worktree: `/Users/andrey.epifancev/projects/awp-ca/.wt/nats-env/` (дальше `$W`).
| Репозиторий | Путь | Ветка | Issue |
|---|---|---|---|
| docs | `$W/docs` | `feat/80-nats-env-config` | RSHB/arm-ka-docs#80 |
| arm-core | `$W/arm-core` | `feat/106-nats-env-config` | RSHB/arm-core#106 |
| tabular-parser | `$W/tabular-parser` | `feat/31-nats-env-config` | RSHB/arm-tabular-parser#31 |
| scan-gateway | `$W/scan-gateway` | `feat/32-nats-env-config` | RSHB/arm-scan-gateway#32 |
- git — только `git -C <абсолютный путь worktree>`; никогда не `cd` в основные checkout'ы `awp-ca/<repo>` (они на `main`,
хук guard-main заблокирует всю сессию).
- В каждом сервисном worktree перед первой задачей: `make -C $W/<repo> venv` (uv, Python 3.11).
- Перед изменением кода — `codegraph_explore` с `projectPath` = абсолютный путь worktree сервиса.
- Коммиты на русском, с трейлером `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Инфраструктура для интеграционных тестов — compose-проект `arm-ka` (`make -C $W/docs/dev up` или уже поднятый стенд).
## Global Constraints
- Ключи (одинаковые в трёх сервисах): `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` (умолчание `files.tabular_uploaded`),
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` (умолчание `files.img_uploaded`), `OPENBAO__NATS__NATS_RESULT_SUBJECT`
(умолчание `files.tabular_parsed`).
- Каждый сервис проверяет все три имени: непустое, без пробельных символов, без `*` и `>`, без пустых токенов
(`a..b`, ведущая/завершающая точка); три имени попарно различны; нарушение — сервис не стартует, ошибка называет ключ.
- `NATS_JWT`, `NATS_SEED` — `SecretStr`; значения не попадают в логи, `repr` настроек и тексты ошибок валидации.
- `/healthcheck` — новый объект `subjects`: arm-core `{tabular_request, image_request, result}`, tabular-parser
`{tabular_request, result}`, scan-gateway `{image_request, result}`; отдаётся при любом состоянии NATS.
- Queue group'ы (`arm-core`, `tabular-parser`, `scan-gateway`) — константы, не меняются.
- `schema_version` контракта не меняется; копии схем в сервисах — побайтно равны `docs/contracts/`.
- `contract/subjects.py` — одинаковый модуль в трёх репозиториях: менять одинаково.
- Пароли MinIO/SMB в `SecretStr` не переводить (design R-5).
- e2e ядра (`tests/e2e/`) остаётся на subject'ах по умолчанию (design R-7).
- Язык комментариев, docstring'ов, сообщений об ошибках — русский; идентификаторы — английские.
## Review Focus
1. **Утечка JWT через ошибку «группа не полная».** Задан `OPENBAO__NATS__NATS_JWT`, но нет `NATS_URL` (или нет группы
`MINIO`) → pydantic печатает `input_value` всей группы. Ожидание: ни JWT, ни seed в `str(ValidationError)`.
Тест — в задачах 2, 4, 6 (`test_validation_error_hides_nats_secrets`).
2. **Пробелы по краям значения из OpenBao** (`"files.x "` — частая ошибка копирования в секрет). Ожидание: отказ старта
с именем ключа, а не тихая подписка на несуществующий subject. Тест — параметр `"files.tabular_uploaded "` в
`test_invalid_subject_rejected` (задачи 2, 4, 6).
3. **Совпадение subject'а ответа с subject'ом запроса** — сервис получал бы собственные ответы. Ожидание: отказ старта,
в сообщении оба ключа. Тест — `test_equal_subjects_rejected` (задачи 2, 4, 6).
4. **Ядро после смены секретов повторно публикует задание в новый subject**, а `recognition_job.request_subject`
отражает фактический subject публикации. Тест — `test_publish_uses_configured_request_subject` (задача 3).
5. **`/healthcheck` при выключенном или упавшем NATS** всё равно показывает `subjects` и не показывает JWT. Тест —
`test_healthcheck_subjects_*` (задачи 3, 5, 7).
---
## Раздел A. Общий код настроек NATS (одинаковый в трёх сервисах)
Задачи 2, 4, 6 вставляют этот код дословно (отличаются только окружающие файлы).
**A.1. Импорты в начало `api/src/core/config.py`** (добавить к существующим; `re` — если ещё нет):
```python
import re
from pydantic import SecretStr, ValidationInfo, field_validator, model_validator
from src.contract.subjects import IMAGE_REQUEST_SUBJECT, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT
```
**A.2. Регулярное выражение** (на уровне модуля, рядом с прочими константами модуля):
```python
# Subject NATS без шаблонов: непустые токены через точку, без пробельных символов, `*` и `>` (docs nats-env-config)
NATS_SUBJECT_RE = re.compile(r"[^\s.*>]+(?:\.[^\s.*>]+)*")
NATS_SUBJECT_FIELDS = ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT")
```
**A.3. Класс `NATSSettings`** (заменяет существующий целиком):
```python
class NATSSettings(BaseSettings):
"""Подключение и subject'ы NATS — секреты окружения `OPENBAO__NATS__*` (прод и тест — свои наборы).
Пустые `NATS_SEED` и `NATS_JWT` — подключение без nkeys (локальная разработка, тесты). Subject'ы по умолчанию —
имена контракта v1.0; у ядра и сервисов одного окружения они обязаны совпадать (docs, change nats-env-config).
"""
model_config = SettingsConfigDict(hide_input_in_errors=True)
NATS_URL: str
NATS_SEED: SecretStr = SecretStr("")
NATS_JWT: SecretStr = SecretStr("")
NATS_TABULAR_REQUEST_SUBJECT: str = TABULAR_REQUEST_SUBJECT
NATS_IMAGE_REQUEST_SUBJECT: str = IMAGE_REQUEST_SUBJECT
NATS_RESULT_SUBJECT: str = RESULT_SUBJECT
@field_validator(*NATS_SUBJECT_FIELDS)
@classmethod
def _check_subject(cls, value: str, info: ValidationInfo) -> str:
"""Конкретный subject без шаблонов: иначе подписка молча слушала бы чужой трафик или ничего."""
if not NATS_SUBJECT_RE.fullmatch(value):
raise ValueError(
f"{info.field_name}={value!r}: нужен subject NATS из непустых токенов через точку, "
"без пробелов, '*' и '>'"
)
return value
@model_validator(mode="after")
def _check_distinct_subjects(self) -> "NATSSettings":
"""Три subject'а попарно различны: совпадение запроса и ответа — сервис получает свои ответы, двух запросов —
каждый запрос получают оба сервиса."""
seen: dict[str, str] = {}
for field in NATS_SUBJECT_FIELDS:
subject = getattr(self, field)
if subject in seen:
raise ValueError(f"{seen[subject]} и {field} совпадают ({subject!r}): subject'ы NATS должны различаться")
seen[subject] = field
return self
def request_subjects(self) -> dict[str, str]:
"""Subject запроса по `pipeline` (`tabular`, `image`) — аргумент `request_subject` контракта."""
return {"tabular": self.NATS_TABULAR_REQUEST_SUBJECT, "image": self.NATS_IMAGE_REQUEST_SUBJECT}
```
**A.4. `hide_input_in_errors=True`** — в `model_config` **каждого** класса настроек `config.py` (`MinioSettings`,
`OpenBAOSettings`, `DBSettings`/`SMBSettings`/`ServerSettings` — какие есть, и `Settings`). У классов без
`model_config` добавить `model_config = SettingsConfigDict(hide_input_in_errors=True)`; у `Settings` — дописать ключ в
существующий `SettingsConfigDict(...)`. Причина: ошибка «нет поля» печатает `input_value` всей группы, а в ней JWT.
**A.5. `contract/subjects.py` — `request_subject` с картой subject'ов** (заменить функцию; docstring модуля не трогать):
```python
def request_subject(pipeline: str, subjects: Mapping[str, str] = REQUEST_SUBJECTS) -> str:
"""Subject запроса по pipeline. `subjects` — карта `pipeline → subject` окружения
(`NATSSettings.request_subjects()`); по умолчанию — имена контракта: `tabular` → `files.tabular_uploaded`,
`image` → `files.img_uploaded`."""
try:
return subjects[pipeline]
except KeyError:
raise ValueError(f"неизвестный pipeline {pipeline!r}: допустимы {', '.join(get_args(Pipeline))}") from None
```
**A.6. Колбэки nkeys** — в `build_nats_auth` (`api/src/core/nats.py`) читать значения через `get_secret_value()`:
`seed = settings.NATS_SEED.get_secret_value()` и `jwt = settings.NATS_JWT.get_secret_value()` (в scan-gateway
сохранить существующий `.strip()`: `settings.NATS_SEED.get_secret_value().strip()`). Остальная логика функции не меняется.
**A.7. Общие тесты настроек** — добавить в тест-файл настроек сервиса (имя файла и фабрика настроек — в задаче).
Фабрика `make(**env)` в каждом репозитории своя; в коде ниже она называется `settings_from_env`:
```python
JWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.secret-payload.sig"
SEED = "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY"
def test_nats_subjects_default_to_contract_names(settings_from_env) -> None:
nats = settings_from_env().OPENBAO.NATS
assert nats.NATS_TABULAR_REQUEST_SUBJECT == "files.tabular_uploaded"
assert nats.NATS_IMAGE_REQUEST_SUBJECT == "files.img_uploaded"
assert nats.NATS_RESULT_SUBJECT == "files.tabular_parsed"
assert nats.request_subjects() == {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"}
def test_nats_subjects_from_openbao(settings_from_env) -> None:
nats = settings_from_env(
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded",
OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT="test.files.img_uploaded",
OPENBAO__NATS__NATS_RESULT_SUBJECT="test.files.tabular_parsed",
).OPENBAO.NATS
assert nats.request_subjects() == {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"}
assert nats.NATS_RESULT_SUBJECT == "test.files.tabular_parsed"
@pytest.mark.parametrize(
"value", ["", "files.*", "files.>", ">", "files..tabular", ".files", "files.", "files tabular", "files.tabular_uploaded "]
)
def test_invalid_subject_rejected(settings_from_env, value: str) -> None:
with pytest.raises(ValidationError, match="NATS_RESULT_SUBJECT"):
settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT=value)
def test_equal_subjects_rejected(settings_from_env) -> None:
with pytest.raises(ValidationError, match="NATS_TABULAR_REQUEST_SUBJECT и NATS_RESULT_SUBJECT совпадают"):
settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT="files.tabular_uploaded")
def test_nats_secrets_hidden_in_repr(settings_from_env) -> None:
settings = settings_from_env(OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED)
assert settings.OPENBAO.NATS.NATS_JWT.get_secret_value() == JWT
assert JWT not in repr(settings) and SEED not in repr(settings)
assert JWT not in str(settings) and SEED not in str(settings)
MINIO_KEYS = ("MINIO_URL", "MINIO_ADMIN_LOGIN", "MINIO_ADMIN_PASSWORD", "MINIO_BUCKET", "MINIO_SECURE")
@pytest.mark.parametrize(
"missing",
[
("OPENBAO__NATS__NATS_URL",), # нет поля в группе NATS: в ошибке — вход группы NATS (с JWT)
tuple(f"OPENBAO__MINIO__{key}" for key in MINIO_KEYS), # нет группы MINIO: в ошибке — вход всего OPENBAO
],
)
def test_validation_error_hides_nats_secrets(settings_from_env, missing: tuple[str, ...]) -> None:
"""Ошибка «нет поля» печатает вход всей группы — JWT и seed туда попадать не должны (Review Focus 1)."""
with pytest.raises(ValidationError) as exc_info:
settings_from_env(OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED, **dict.fromkeys(missing))
assert JWT not in str(exc_info.value)
assert SEED not in str(exc_info.value)
```
Проверка теста на честность (обязательна): временно убрать `hide_input_in_errors` из всех классов — оба параметра
должны упасть (JWT виден в тексте ошибки). Если какой-то параметр проходит и без скрытия — тест не ловит утечку,
доработать параметр под фактическое поведение pydantic, а не ослаблять проверку.
Семантика фабрики `settings_from_env(**env)`: собирает `Settings(_env_file=None)` из минимального набора обязательных
`OPENBAO__*` сервиса, поверх — `env`; значение `None` означает «удалить переменную». Фабрика строится на `monkeypatch`
и существующих фикстурах тест-файла (см. задачу).
---
### Task 1: docs — описания subject'ов в схемах контракта
**Model:** haiku (готовый текст).
**Files:**
- Modify: `$W/docs/contracts/recognition-request.schema.json` (поле верхнего уровня `description`)
- Modify: `$W/docs/contracts/recognition-result-message.schema.json` (поле верхнего уровня `description`)
**Interfaces:**
- Produces: новые байты двух схем — задачи 2, 4, 6 копируют их в сервисы (`api/contracts/`).
- [ ] **Step 1: request.** В `description` заменить начало
`Тело сообщения на subject files.tabular_uploaded или files.img_uploaded; subject должен соответствовать полю pipeline (tabular → files.tabular_uploaded, image → files.img_uploaded).`
на
`Тело сообщения на subject запроса; subject — настройка окружения и должен соответствовать полю pipeline (tabular → NATS_TABULAR_REQUEST_SUBJECT, по умолчанию files.tabular_uploaded; image → NATS_IMAGE_REQUEST_SUBJECT, по умолчанию files.img_uploaded).`
Остальной текст `description`, `$id`, `schema_version` — без изменений.
- [ ] **Step 2: result-message.** В `description` заменить `Тело сообщения на subject files.tabular_parsed.` на
`Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed).`
- [ ] **Step 3: проверка.** Run: `make -C $W/docs check`. Expected: зелёный (JSON валиден, примеры проходят схемы).
- [ ] **Step 4: Commit.**
```bash
git -C $W/docs add contracts/recognition-request.schema.json contracts/recognition-result-message.schema.json
git -C $W/docs commit -m "contracts: subject'ы запроса и ответа — настройки окружения (nats-env-config)"
```
---
### Task 2: arm-core — настройки NATS, контракт, nkeys
**Model:** haiku (код в разделе A).
**Files:**
- Modify: `$W/arm-core/api/src/core/config.py` (A.1–A.4)
- Modify: `$W/arm-core/api/src/contract/subjects.py` (A.5)
- Modify: `$W/arm-core/api/src/core/nats.py` — `build_nats_auth` (A.6)
- Modify: `$W/arm-core/api/contracts/recognition-request.schema.json`, `recognition-result-message.schema.json`
(копия из `$W/docs/contracts/`, Task 1)
- Test: `$W/arm-core/api/tests/test_config.py`, `$W/arm-core/api/tests/test_contract.py`,
`$W/arm-core/api/tests/test_nats_core.py`
**Interfaces:**
- Consumes: схемы из Task 1.
- Produces: `NATSSettings.NATS_TABULAR_REQUEST_SUBJECT/NATS_IMAGE_REQUEST_SUBJECT/NATS_RESULT_SUBJECT: str`,
`NATSSettings.request_subjects() -> dict[str, str]`, `NATS_SEED/NATS_JWT: SecretStr`,
`request_subject(pipeline: str, subjects: Mapping[str, str] = REQUEST_SUBJECTS) -> str`.
- [ ] **Step 1: фабрика и тесты.** В `tests/test_config.py` добавить фикстуру на основе существующей `clean_env` и
словаря `required` из `test_defaults` (вынести словарь в константу модуля `REQUIRED_ENV`):
```python
@pytest.fixture
def settings_from_env(clean_env: pytest.MonkeyPatch):
def make(**env: str | None) -> Settings:
for key, value in {**REQUIRED_ENV, **env}.items():
if value is None:
clean_env.delenv(key, raising=False)
else:
clean_env.setenv(key, value)
return Settings(_env_file=None) # type: ignore[call-arg]
return make
```
и тесты A.7 дословно. Существующие `assert s.OPENBAO.NATS.NATS_SEED == ""` / `NATS_JWT == ""` заменить на
`.get_secret_value() == ""`. В `tests/test_contract.py` добавить:
```python
def test_request_subject_uses_environment_map() -> None:
subjects = {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"}
assert request_subject("tabular", subjects) == "test.files.tabular_uploaded"
assert request_subject("image", subjects) == "test.files.img_uploaded"
```
В `tests/test_nats_core.py` хелпер `NATSSettings(NATS_URL=..., NATS_SEED=seed, NATS_JWT=jwt)` оставить (pydantic
примет `str` в `SecretStr`).
- [ ] **Step 2: тесты падают.** Run: `cd $W/arm-core/api && .venv/bin/pytest tests/test_config.py tests/test_contract.py -q`
Expected: FAIL (`NATS_RESULT_SUBJECT` нет, `request_subject` не принимает второй аргумент).
- [ ] **Step 3: реализация.** Применить A.1–A.6 к файлам задачи; скопировать схемы:
`cp $W/docs/contracts/recognition-request.schema.json $W/docs/contracts/recognition-result-message.schema.json $W/arm-core/api/contracts/`.
- [ ] **Step 4: тесты проходят.** Run: `make -C $W/arm-core lint test` Expected: PASS, ruff/mypy чистые.
- [ ] **Step 5: Commit.**
```bash
git -C $W/arm-core add api/src/core/config.py api/src/contract/subjects.py api/src/core/nats.py api/contracts api/tests
git -C $W/arm-core commit -m "NATS: subject'ы и JWT из OPENBAO__NATS__*, SecretStr, скрытие входа в ошибках (#106)"
```
---
### Task 3: arm-core — публикация, подписка, лог и `/healthcheck` по настройкам
**Model:** sonnet (интеграция в существующий код).
**Files:**
- Modify: `$W/arm-core/api/src/core/nats.py` — `NatsConnection.__init__`, новая `used_subjects`
- Modify: `$W/arm-core/api/src/services/recognition_exchange.py` — `_process_one`, `publish_pending`,
`run_publisher_loop`, `ExchangeTasks.start`
- Modify: `$W/arm-core/api/src/services/primary_files.py:483` — `request_subject` при создании задания
- Modify: `$W/arm-core/api/src/app.py` — `lifespan` (лог), `healthcheck`, docstring модуля
- Modify: `$W/arm-core/api/.env.example`, `$W/arm-core/README.md` (раздел переменных NATS / healthcheck — найти grep'ом
`NATS_JWT` и `healthcheck`)
- Test: `$W/arm-core/api/tests/test_nats_core.py`, `$W/arm-core/api/tests/test_recognition_exchange.py`,
`$W/arm-core/api/tests/test_app.py`, `$W/arm-core/api/tests/test_api_files_upload.py`
**Interfaces:**
- Consumes: Task 2 (`NATSSettings`, `request_subjects()`, `request_subject(pipeline, subjects)`).
- Produces: `used_subjects(settings: NATSSettings) -> dict[str, str]` в `src/core/nats.py` (ключи
`tabular_request`, `image_request`, `result`); `publish_pending(..., request_subjects: Mapping[str, str] = REQUEST_SUBJECTS)`;
`NatsConnection(settings, *, result_subject: str | None = None, ...)` — `None` → `settings.NATS_RESULT_SUBJECT`.
- [ ] **Step 1: тесты.**
`tests/test_nats_core.py`:
```python
def test_used_subjects_core() -> None:
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_RESULT_SUBJECT="test.files.tabular_parsed")
assert used_subjects(nats) == {
"tabular_request": "files.tabular_uploaded",
"image_request": "files.img_uploaded",
"result": "test.files.tabular_parsed",
}
def test_connection_subscribes_to_configured_result_subject() -> None:
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_RESULT_SUBJECT="test.files.tabular_parsed")
assert NatsConnection(nats).result_subject == "test.files.tabular_parsed"
assert NatsConnection(nats, result_subject="override").result_subject == "override"
```
`tests/test_recognition_exchange.py` — по образцу существующего теста первой публикации (строки ~235–262: фикстуры
`jobs`, `RequestSubscriber`, `publish_pending`), но `RequestSubscriber` подписывается на свои subject'ы:
добавить в `RequestSubscriber.__init__` параметр `subjects: tuple[str, ...] = REQUEST_SUBJECTS` и использовать его в
`start()`; новый тест:
```python
async def test_publish_uses_configured_request_subject(jobs: CommittedJobs, nats_conn) -> None:
"""Review Focus 4: subject — из настроек окружения, `request_subject` задания — фактический subject публикации."""
run = uuid.uuid4().hex[:8]
subjects = {"tabular": f"test-{run}.files.tabular_uploaded", "image": f"test-{run}.files.img_uploaded"}
subscriber = RequestSubscriber(subjects=tuple(subjects.values()))
await subscriber.start()
try:
job_id = await jobs.create(pipeline=RecognitionPipeline.tabular)
await publish_pending(jobs.session_factory, nats_conn, ack_timeout=ACK, max_republish=MAX_REPUBLISH,
request_subjects=subjects)
(msg,) = await subscriber.wait_for(job_id, 1)
finally:
await subscriber.stop()
assert msg.subject == subjects["tabular"]
assert (await jobs.get(job_id)).request_subject == subjects["tabular"]
```
Имена фикстур (`jobs`, соединения NATS, метода создания задания) взять из существующих тестов файла — если они
называются иначе, использовать существующие, не создавая новых.
`tests/test_app.py`:
```python
async def test_healthcheck_subjects_when_nats_off(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(app_module.settings, "NATS_ENABLED", False)
body = (await client.get("/healthcheck")).json()
assert body["subjects"] == {
"tabular_request": app_module.settings.OPENBAO.NATS.NATS_TABULAR_REQUEST_SUBJECT,
"image_request": app_module.settings.OPENBAO.NATS.NATS_IMAGE_REQUEST_SUBJECT,
"result": app_module.settings.OPENBAO.NATS.NATS_RESULT_SUBJECT,
}
assert "NATS_JWT" not in str(body) and "jwt" not in str(body).lower()
```
и такой же `test_healthcheck_subjects_when_nats_down` с `NATS_ENABLED=True` без соединения.
`tests/test_api_files_upload.py` — тест создания задания с subject'ами окружения: подменить
`settings.OPENBAO.NATS` на `NATSSettings(..., NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded")` через
`monkeypatch.setattr` на объекте настроек, которым пользуется сервис загрузки в этом тест-файле, и проверить
`job.request_subject == "test.files.tabular_uploaded"` (по образцу существующей проверки на строке ~251).
- [ ] **Step 2: тесты падают.** Run: `make -C $W/arm-core test` и
`cd $W/arm-core/api && .venv/bin/pytest tests/test_recognition_exchange.py -q -k configured` (нужен NATS стенда).
Expected: FAIL (`used_subjects` нет, `publish_pending` не принимает `request_subjects`, нет `subjects` в healthcheck).
- [ ] **Step 3: реализация.**
`src/core/nats.py`:
```python
def used_subjects(settings: NATSSettings) -> dict[str, str]:
"""Subject'ы ядра для лога старта и `/healthcheck.subjects`: публикует оба запроса, подписано на ответ."""
return {
"tabular_request": settings.NATS_TABULAR_REQUEST_SUBJECT,
"image_request": settings.NATS_IMAGE_REQUEST_SUBJECT,
"result": settings.NATS_RESULT_SUBJECT,
}
```
В `NatsConnection.__init__`: `result_subject: str | None = None`, `self.result_subject = result_subject or
settings.NATS_RESULT_SUBJECT`; импорт `RESULT_SUBJECT` из `src.contract.subjects` убрать, если больше не используется;
docstring класса и модуля: «по умолчанию — `NATS_RESULT_SUBJECT` настроек».
`src/services/recognition_exchange.py`: `_process_one(..., request_subjects: Mapping[str, str])` →
`subject = request_subject(row.pipeline.value, request_subjects)`; `publish_pending(..., request_subjects:
Mapping[str, str] = REQUEST_SUBJECTS)` передаёт в `_process_one`; `run_publisher_loop(..., request_subjects:
Mapping[str, str] = REQUEST_SUBJECTS)` передаёт в `publish_pending`; `ExchangeTasks.start` передаёт
`request_subjects=settings.OPENBAO.NATS.request_subjects()`. `REQUEST_SUBJECTS` импортировать из `src.contract`.
`src/services/primary_files.py:483`: `request_subject=request_subject(detected.pipeline.value,
self._settings.OPENBAO.NATS.request_subjects())`.
`src/app.py`: в `lifespan` перед `_register_smb()` —
`logger.info("Subject'ы NATS. {subjects}", subjects=used_subjects(settings.OPENBAO.NATS))`; в `healthcheck` —
`return {"uptime": UP_TIME, "version": VERSION, "nats": nats_status, "subjects": used_subjects(app_settings.OPENBAO.NATS)}`;
docstring модуля: healthcheck `(uptime, version, nats: ok|down|off, subjects)`.
`.env.example`: после `OPENBAO__NATS__NATS_JWT=` три строки с умолчаниями и комментарием
`# subject'ы обмена — по окружению (прод/тест), у трёх сервисов одинаковые; умолчания — имена контракта`.
README: в таблицу/список переменных NATS — три ключа; в описание `/healthcheck` — поле `subjects`.
- [ ] **Step 4: тесты проходят.** Run: `make -C $W/arm-core lint test test-integration` Expected: PASS.
- [ ] **Step 5: Commit.**
```bash
git -C $W/arm-core add api/src api/tests api/.env.example README.md
git -C $W/arm-core commit -m "NATS: публикация, подписка и /healthcheck.subjects по настройкам окружения (#106)"
```
---
### Task 4: tabular-parser — настройки NATS, контракт, nkeys
**Model:** haiku (код в разделе A).
**Files:**
- Modify: `$W/tabular-parser/api/src/core/config.py` (A.1–A.4)
- Modify: `$W/tabular-parser/api/src/contract/subjects.py` (A.5)
- Modify: `$W/tabular-parser/api/src/core/nats.py` — `build_nats_auth` (A.6)
- Modify: `$W/tabular-parser/api/contracts/recognition-request.schema.json`, `recognition-result-message.schema.json`
(копия из `$W/docs/contracts/`)
- Test: `$W/tabular-parser/api/tests/unit/test_config.py`, `$W/tabular-parser/api/tests/unit/test_contract.py`
(или файл, где сейчас тестируется `request_subject`; найти `grep -rn request_subject tests`)
**Interfaces:**
- Consumes: Task 1.
- Produces: то же, что Task 2, в tabular-parser.
- [ ] **Step 1: фабрика и тесты.** В `tests/unit/test_config.py` фабрика `settings_from_env` поверх существующей
фикстуры `minimal_settings` (conftest): если `minimal_settings(**env)` уже принимает переопределения — обёртка
`settings_from_env = minimal_settings` с поддержкой `None` → `monkeypatch.delenv`; иначе — по образцу Task 2 Step 1 с
обязательными для tabular-parser `OPENBAO__MINIO__*` и `OPENBAO__NATS__NATS_URL`. Тесты A.7 дословно; существующие
`NATS_SEED == ""`/`NATS_JWT == ""` → `.get_secret_value() == ""`. Тест `request_subject(pipeline, subjects)` — как в
Task 2 Step 1 (`test_request_subject_uses_environment_map`).
- [ ] **Step 2: тесты падают.** Run: `make -C $W/tabular-parser test` Expected: FAIL.
- [ ] **Step 3: реализация.** A.1–A.6; `cp $W/docs/contracts/recognition-request.schema.json
$W/docs/contracts/recognition-result-message.schema.json $W/tabular-parser/api/contracts/`.
- [ ] **Step 4: тесты проходят.** Run: `make -C $W/tabular-parser lint test` Expected: PASS (тест идентичности
контракта видит `../docs` = `$W/docs`).
- [ ] **Step 5: Commit.**
```bash
git -C $W/tabular-parser add api/src/core/config.py api/src/contract/subjects.py api/src/core/nats.py api/contracts api/tests
git -C $W/tabular-parser commit -m "NATS: subject'ы и JWT из OPENBAO__NATS__*, SecretStr, скрытие входа в ошибках (#31)"
```
---
### Task 5: tabular-parser — подписка, публикация, лог, `/healthcheck`, roundtrip на своих subject'ах
**Model:** sonnet.
**Files:**
- Modify: `$W/tabular-parser/api/src/core/nats.py` — `subscribe_requests` (умолчание subject'а), новая `used_subjects`
- Modify: `$W/tabular-parser/api/src/core/worker.py` — `RecognitionWorker.__init__` (`result_subject`), публикация
(`:467`)
- Modify: `$W/tabular-parser/api/src/app.py` — `_build_worker`, `lifespan` (лог), `healthcheck`, docstring модуля
- Modify: `$W/tabular-parser/api/.env.example`, `$W/tabular-parser/README.md`
- Test: `$W/tabular-parser/api/tests/unit/test_nats.py`, `tests/unit/test_worker.py`, `tests/unit/test_app.py`,
`tests/integration/test_roundtrip.py`
**Interfaces:**
- Consumes: Task 4.
- Produces: `used_subjects(settings: NATSSettings) -> dict[str, str]` (ключи `tabular_request`, `result`);
`RecognitionWorker(..., result_subject: str = RESULT_SUBJECT)`;
`NatsConnection.subscribe_requests(cb, *, queue=REQUEST_QUEUE_GROUP, subject: str | None = None)` — `None` →
`settings.NATS_TABULAR_REQUEST_SUBJECT`.
- [ ] **Step 1: тесты.**
`tests/unit/test_nats.py`:
```python
def test_used_subjects_tabular_parser() -> None:
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded")
assert used_subjects(nats) == {"tabular_request": "test.files.tabular_uploaded", "result": "files.tabular_parsed"}
```
и тест, что `subscribe_requests(cb)` без `subject` подписывается на `NATS_TABULAR_REQUEST_SUBJECT` настроек — по
образцу существующего unit-теста подписки в этом файле (фейковый клиент, проверка аргумента `subscribe`).
`tests/unit/test_worker.py` — по образцу существующего теста публикации ack/результата (фейковый publisher с
записью `(subject, payload, msg_id)`): воркер с `result_subject="test.files.tabular_parsed"` публикует ack и
результат в этот subject.
`tests/unit/test_app.py` — `test_healthcheck_subjects_when_nats_off` / `_down` по образцу существующих тестов
healthcheck файла: `body["subjects"] == {"tabular_request": ..., "result": ...}` из `app_module.settings`, JWT в
ответе нет.
`tests/integration/test_roundtrip.py` — уникальные subject'ы на прогон (OQ-83): в фикстуре `service` перед
`lifespan_context`:
```python
RUN = uuid.uuid4().hex[:8]
TEST_SUBJECTS = {
"NATS_TABULAR_REQUEST_SUBJECT": f"test-{RUN}.files.tabular_uploaded",
"NATS_IMAGE_REQUEST_SUBJECT": f"test-{RUN}.files.img_uploaded",
"NATS_RESULT_SUBJECT": f"test-{RUN}.files.tabular_parsed",
}
# в фикстуре service:
monkeypatch.setattr(app_module.settings.OPENBAO, "NATS", app_module.settings.OPENBAO.NATS.model_copy(update=TEST_SUBJECTS))
```
(`RUN` и `TEST_SUBJECTS` — константы модуля); класс `Core` публикует в `TEST_SUBJECTS["NATS_TABULAR_REQUEST_SUBJECT"]`,
фикстура `core` подписывается на `TEST_SUBJECTS["NATS_RESULT_SUBJECT"]`; docstring модуля — убрать оговорку про общую
шину, если она есть, добавить «subject'ы прогона уникальны — контейнер стенда сообщения теста не получает (OQ-83)».
- [ ] **Step 2: тесты падают.** Run: `make -C $W/tabular-parser test` Expected: FAIL.
- [ ] **Step 3: реализация.**
`src/core/nats.py`:
```python
def used_subjects(settings: NATSSettings) -> dict[str, str]:
"""Subject'ы сервиса для лога старта и `/healthcheck.subjects`: подписан на запрос `tabular`, публикует ответ."""
return {"tabular_request": settings.NATS_TABULAR_REQUEST_SUBJECT, "result": settings.NATS_RESULT_SUBJECT}
```
`subscribe_requests`: параметр `subject: str | None = None`, в теле `subject = subject or
self._settings.NATS_TABULAR_REQUEST_SUBJECT` (имя атрибута настроек — как в классе); импорт
`TABULAR_REQUEST_SUBJECT` убрать, если не используется; docstring модуля — «подписка на subject запроса `tabular`
из настроек (по умолчанию `files.tabular_uploaded`)».
`src/core/worker.py`: `RecognitionWorker.__init__(..., result_subject: str = RESULT_SUBJECT)` →
`self._result_subject = result_subject`; строка `:467` — `await self._publisher.publish(self._result_subject, payload, msg_id)`.
`src/app.py`: в `_build_worker` — `RecognitionWorker(..., result_subject=settings.OPENBAO.NATS.NATS_RESULT_SUBJECT)`;
в `lifespan` первым действием — `logger.info("Subject'ы NATS. {subjects}", subjects=used_subjects(settings.OPENBAO.NATS))`;
`healthcheck` — `{"uptime": ..., "version": ..., "nats": ..., "subjects": used_subjects(settings.OPENBAO.NATS)}`.
`.env.example`, README — как в Task 3 Step 3 (три ключа, поле `subjects`).
- [ ] **Step 4: тесты проходят.** Run: `make -C $W/tabular-parser lint test test-integration` Expected: PASS
(интеграционные — при поднятом compose `arm-ka`; контейнер `tabular-parser` стенда останавливать не нужно).
- [ ] **Step 5: Commit.**
```bash
git -C $W/tabular-parser add api/src api/tests api/.env.example README.md
git -C $W/tabular-parser commit -m "NATS: подписка, публикация и /healthcheck.subjects по настройкам; roundtrip на своих subject'ах (#31)"
```
---
### Task 6: scan-gateway — настройки NATS, контракт, nkeys
**Model:** haiku (код в разделе A).
**Files:**
- Modify: `$W/scan-gateway/api/src/core/config.py` (A.1–A.4; у scan-gateway ещё `SMBSettings`, `ServerSettings`)
- Modify: `$W/scan-gateway/api/src/contract/subjects.py` (A.5)
- Modify: `$W/scan-gateway/api/src/core/nats.py` — `build_nats_auth` (A.6, сохранить `.strip()`)
- Modify: `$W/scan-gateway/api/contracts/recognition-request.schema.json`, `recognition-result-message.schema.json`
- Test: `$W/scan-gateway/api/tests/unit/test_config.py`, `tests/unit/test_contract.py`, `tests/unit/test_nats.py`
**Interfaces:**
- Consumes: Task 1.
- Produces: то же, что Task 2, в scan-gateway.
- [ ] **Step 1: фабрика и тесты.** В `tests/unit/test_config.py` фабрика `settings_from_env` поверх существующих
`_clean_env(monkeypatch)` и фикстуры `minimal_env_file` (сначала `_clean_env`, затем `setenv`/`delenv` по `env`,
затем `get_settings.cache_clear()` если `get_settings` кэширован, и `Settings()`); тесты A.7 дословно (для
`missing="OPENBAO__MINIO__MINIO_URL"` переменная должна отсутствовать и в `minimal_env_file` — если она задаётся
файлом, тест строит `Settings(_env_file=None)` с явным набором env). Существующие сравнения `NATS_SEED == ""` /
`NATS_JWT == ""` → `.get_secret_value() == ""`. Тест `request_subject(pipeline, subjects)` — как в Task 2.
- [ ] **Step 2: тесты падают.** Run: `make -C $W/scan-gateway test` Expected: FAIL.
- [ ] **Step 3: реализация.** A.1–A.6; копия схем в `$W/scan-gateway/api/contracts/`.
- [ ] **Step 4: тесты проходят.** Run: `make -C $W/scan-gateway lint test` Expected: PASS.
- [ ] **Step 5: Commit.**
```bash
git -C $W/scan-gateway add api/src/core/config.py api/src/contract/subjects.py api/src/core/nats.py api/contracts api/tests
git -C $W/scan-gateway commit -m "NATS: subject'ы и JWT из OPENBAO__NATS__*, SecretStr, скрытие входа в ошибках (#32)"
```
---
### Task 7: scan-gateway — подписка, публикация, лог, `/healthcheck`, интеграционные тесты на своих subject'ах
**Model:** sonnet.
**Files:**
- Modify: `$W/scan-gateway/api/src/core/nats.py` — `NatsGateway.subscribe_requests` (`:211–237`), `publish_result`
(`:294–298`), новая `used_subjects`
- Modify: `$W/scan-gateway/api/src/core/health.py` — `snapshot`
- Modify: `$W/scan-gateway/api/src/app.py` — лог при старте, docstring модуля
- Modify: `$W/scan-gateway/api/.env.example`, `$W/scan-gateway/README.md`
- Test: `$W/scan-gateway/api/tests/unit/test_nats.py`, `tests/unit/test_health.py`, `tests/unit/test_app.py`,
`tests/integration/test_nats_minio_integration.py`, `tests/integration/test_roundtrip.py`
**Interfaces:**
- Consumes: Task 6.
- Produces: `used_subjects(settings: NATSSettings) -> dict[str, str]` (ключи `image_request`, `result`);
`NatsGateway` подписывается на `settings.OPENBAO.NATS.NATS_IMAGE_REQUEST_SUBJECT` и публикует в
`settings.OPENBAO.NATS.NATS_RESULT_SUBJECT`.
- [ ] **Step 1: тесты.**
`tests/unit/test_nats.py`:
```python
def test_used_subjects_scan_gateway() -> None:
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_IMAGE_REQUEST_SUBJECT="test.files.img_uploaded")
assert used_subjects(nats) == {"image_request": "test.files.img_uploaded", "result": "files.tabular_parsed"}
```
и по образцу существующих unit-тестов `NatsGateway` с фейковым клиентом: при
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=test.files.img_uploaded` и `OPENBAO__NATS__NATS_RESULT_SUBJECT=test.files.tabular_parsed`
(`make_settings(monkeypatch, ...)`) `subscribe_requests` вызывает `subscribe("test.files.img_uploaded", queue="scan-gateway", ...)`,
а `publish_result` публикует в `"test.files.tabular_parsed"`.
`tests/unit/test_health.py` — `snapshot()` содержит `"subjects": {"image_request": ..., "result": ...}` при
`nats_status` `ok`, `down` и `disabled`.
`tests/unit/test_app.py` — `/healthcheck` отдаёт `subjects`, JWT в ответе нет.
Интеграционные (`tests/integration/`): по образцу Task 5 Step 1 — константы `RUN`, `TEST_SUBJECTS` и настройки
тестовых `NatsGateway` с этими subject'ами (через `make_settings`/`monkeypatch.setenv` на `OPENBAO__NATS__NATS_*_SUBJECT`
до создания `Settings`), публикация запросов и подписка на результат — в `TEST_SUBJECTS`; убрать фильтрацию
«чужие сообщения на общем subject пропускаем», если после перехода она больше не нужна (оставить, если сообщения
фильтруются по `job_id` внутри теста по другим причинам).
- [ ] **Step 2: тесты падают.** Run: `make -C $W/scan-gateway test` Expected: FAIL.
- [ ] **Step 3: реализация.**
`src/core/nats.py`:
```python
def used_subjects(settings: NATSSettings) -> dict[str, str]:
"""Subject'ы сервиса для лога старта и `/healthcheck.subjects`: подписан на запрос `image`, публикует ответ."""
return {"image_request": settings.NATS_IMAGE_REQUEST_SUBJECT, "result": settings.NATS_RESULT_SUBJECT}
```
`subscribe_requests`: `subject = self._settings.OPENBAO.NATS.NATS_IMAGE_REQUEST_SUBJECT` вместо
`IMAGE_REQUEST_SUBJECT` (в сообщении `NatsNotConnectedError`, в `subscribe`, в логе); `publish_result`:
`await self.publish(self._settings.OPENBAO.NATS.NATS_RESULT_SUBJECT, payload, ...)`; неиспользуемые импорты констант
убрать; docstring'и — «subject из настроек (по умолчанию …)».
`src/core/health.py`: `snapshot()` возвращает `{"nats": ..., "hotfolder": ..., "subjects": self._subjects}`, где
`self._subjects` — новый параметр `__init__` `subjects: Mapping[str, str]` (передать в `app.py` при создании
`Health...`: `subjects=used_subjects(settings.OPENBAO.NATS)`).
`src/app.py`: лог `logger.info("Subject'ы NATS. {subjects}", subjects=used_subjects(settings.OPENBAO.NATS))` в начале
lifespan; docstring модуля — `/healthcheck` отдаёт `uptime`, `version`, `nats`, `hotfolder`, `subjects`.
`.env.example`, README — как в Task 3 Step 3.
- [ ] **Step 4: тесты проходят.** Run: `make -C $W/scan-gateway lint test test-integration` Expected: PASS.
- [ ] **Step 5: Commit.**
```bash
git -C $W/scan-gateway add api/src api/tests api/.env.example README.md
git -C $W/scan-gateway commit -m "NATS: подписка, публикация и /healthcheck.subjects по настройкам; интеграционные тесты на своих subject'ах (#32)"
```
---
### Task 8: docs — документы контекста и окружения разработки
**Model:** sonnet.
**Files:**
- Modify: `$W/docs/context/03-architecture.md` — схема/таблица потоков (строки ~17, ~68–76), раздел секретов
(~306), раздел NATS платформы (~334)
- Modify: `$W/docs/context/07-open-questions.md` — OQ-83 (строка ~91)
- Modify: `$W/docs/dev/README.md` — таблица переменных (~323–325), раздел интеграционных тестов/e2e (~405),
описание `/healthcheck`
- Modify: `$W/docs/dev/.env.example`, `$W/docs/dev/contour-transfer-guide.md` (~180–188),
`$W/docs/dev/qa-guide.md` (~955: `/healthcheck`), `$W/docs/dev/frontend-api-guide.md` (если описан ответ `/healthcheck`)
- Modify: `$W/docs/repo-templates/*.CLAUDE.md` — только если там названы константы subject'ов как неизменные
- Modify: `$W/docs/openspec/changes/nats-env-config/tasks.md` — отметить выполненные пункты
**Interfaces:**
- Consumes: итог задач 1–7 (имена ключей, поле `subjects`, поведение тестов).
- [ ] **Step 1: `03-architecture.md`.** В таблице потоков и на схеме имена `files.*` оставить, пометив «subject'ы —
настройки окружения `OPENBAO__NATS__NATS_{TABULAR_REQUEST,IMAGE_REQUEST,RESULT}_SUBJECT`, здесь — умолчания».
В перечне секретов — три ключа рядом с `NATS_URL/NATS_SEED/NATS_JWT` для всех трёх сервисов. В разделе NATS
платформы — абзац: «Прод и тест — свои наборы: JWT, seed и три subject'а окружения задаются одинаково всем трём
сервисам; JWT окружения должен давать publish/subscribe на эти subject'ы. Разъехавшиеся настройки — тишина на шине
(dlq по NO_ACK); сверять `/healthcheck.subjects` ядра и сервисов.» (решение владельца 12.10.2026).
- [ ] **Step 2: `07-open-questions.md`, OQ-83.** В колонку решения дописать: «13.10.2026 (nats-env-config): subject'ы —
настройки; интеграционные тесты tabular-parser и scan-gateway идут на уникальных subject'ах прогона и не пересекаются
со стендом. Для `make e2e` ядра вопрос остаётся: ему нужен контейнер tabular-parser стенда на умолчаниях, конфликт —
общая queue group `arm-core` (design R-7)». Статус строки — открыт (частично).
- [ ] **Step 3: `dev/README.md`, `dev/.env.example`, `contour-transfer-guide.md`, `qa-guide.md`,
`frontend-api-guide.md`.** Три ключа в таблицах переменных (умолчание, смысл); в `contour-transfer-guide.md` — в
перечень секретов каждого сервиса и пункт «для тестового контура — свой набор JWT/seed/subject'ов»; ответ
`/healthcheck` — с полем `subjects` там, где он описан; в README — интеграционные тесты сервисов больше не требуют
останавливать контейнеры стенда, `make e2e` ядра — по-прежнему требует (R-7).
- [ ] **Step 4: проверка.** Run: `make -C $W/docs check` и `cd $W/docs && openspec validate nats-env-config --strict`.
Expected: зелёный / valid.
- [ ] **Step 5: Commit.**
```bash
git -C $W/docs add context dev repo-templates openspec/changes/nats-env-config/tasks.md
git -C $W/docs commit -m "docs: subject'ы и JWT NATS по окружению — архитектура, окружение, OQ-83 (#80)"
```
---
## После задач (контроллер)
- Отметить пункты `tasks.md` дочерних change'ей в трёх сервисах (каждый — коммитом в своей ветке).
- `make -C $W/<repo> check-full` в трёх сервисах (без `| tail`: код возврата не терять). Для `make e2e` ядра —
остановить ядро стенда на время прогона (R-7).
- Финальное ревью ветки (opus) по четырём репозиториям, таблица находок с маркерами и статусами.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12

View File

@ -0,0 +1,111 @@
## Context
Решение владельца 12.10.2026: «топики можно выставлять через секреты и jwt токен тоже, т.к. у нас будут различные наборы
топиков и разные jwt для прод и тест окружения». Выбран подход 1 из трёх обсуждённых (12.10.2026):
1. **Выбран:** три ключа в группе `OPENBAO__NATS__`, одинаковые в трёх сервисах.
2. `OPENBAO__APP__*` (`settings_sources.py`) — отвергнут: разносит JWT и subject'ы, на которые он даёт права, по разным
группам.
3. Один префикс `NATS_SUBJECT_PREFIX` — отвергнут: работает, только если тестовые subject'ы платформы отличаются от
боевых префиксом.
Образец — `RSHB/armka_file_worker` (проверен в контуре): `NATSSettings` с `NATS_URL`, `NATS_JWT: SecretStr`,
`NATS_SEED: SecretStr`, `NATS_COMMAND_SUBJECT`, `NATS_EVENT_SUBJECT`; `Settings(hide_input_in_errors=True)`.
| Ключ | Умолчание | arm-core | tabular-parser | scan-gateway |
|---|---|---|---|---|
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | публикует | подписан (queue `tabular-parser`) | — |
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | публикует | — | подписан (queue `scan-gateway`) |
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | подписан (queue `arm-core`) | публикует | публикует |
## Goals / Non-Goals
Goals: subject'ы и учётные данные NATS задаются секретами окружения; разъехавшиеся настройки видны в `/healthcheck`;
тесты не делят subject'ы со стендом. Non-goals: см. proposal.
## Decisions
1. **Ключи одинаковые во всех трёх сервисах, проверяются все три.** На окружение — один набор секретов, который
платформа кладёт всем сервисам; сервис использует только свои subject'ы, но валидирует все три, чтобы ошибка набора
проявилась в любом сервисе одинаково.
2. **Валидация при старте.** Имя subject'а — непустое, без пробельных символов, без шаблонов `*` и `>`, без пустых
токенов (`a..b`, ведущая или завершающая точка). Три имени попарно различны: совпадение запроса и ответа — сервис
получает собственные ответы; совпадение двух запросов — каждый запрос получают оба сервиса. Ошибка — сервис не
стартует (ошибка конфигурации, как у `DB_SCHEMA`).
3. **Константы `contract/subjects.py` — только умолчания.** Код публикации и подписок получает имена из настроек;
функция выбора subject'а по `pipeline` принимает настройки (форма — на усмотрение плана сервиса).
4. **Секреты.** `NATS_JWT` и `NATS_SEED` — `SecretStr`; в колбэки nkeys значение передаётся через
`get_secret_value()`; `Settings` с `hide_input_in_errors=True`, чтобы ошибка валидации конфигурации не печатала
входные значения.
5. **`/healthcheck`.** Новый объект `subjects` с subject'ами, которые использует сервис: arm-core —
`tabular_request`, `image_request`, `result`; tabular-parser — `tabular_request`, `result`; scan-gateway —
`image_request`, `result`. Отдаётся всегда, в том числе при `nats = off | down` (это конфигурация, не состояние).
Решение владельца 12.10.2026: «пусть будет в хэлзчеке».
6. **Лог при старте.** Сервис пишет свои subject'ы одной строкой при старте (существующая строка «Подписка NATS
активна» сохраняется).
7. **Тесты (OQ-83).** Интеграционные тесты `tabular-parser` и `scan-gateway`, где обе стороны обмена — в самом тесте,
задают уникальные subject'ы через настройки и не пересекаются с контейнерами стенда. e2e ядра (`make e2e`) остаётся
на subject'ах по умолчанию: он гоняет настоящий контейнер `tabular-parser` стенда, который слушает умолчания, а
конфликт там — в общей queue group `arm-core` на subject'е ответа; требование останавливать ядро стенда сохраняется
(R-7). Интеграционные тесты NATS ядра идут на уникальном subject'е ответа; публикации запросов в тестах ядра
(`test_recognition_exchange.py`, `test_api_files_upload.py`) идут на умолчательные subject'ы запросов и достигают
контейнеров стенда.
## Risks / Trade-offs
- Разъехавшиеся настройки ядра и сервиса — тишина на шине: ядро переведёт задания в `dlq` по `NO_ACK`. Смягчение:
`subjects` в `/healthcheck` и лог при старте; в `03-architecture.md` — требование задавать набор всем трём сервисам.
- JWT окружения без прав на заданные subject'ы — ошибка прав NATS при подписке/публикации (в логе сервиса); смягчение
— перечень секретов окружения в `03-architecture.md` и `contour-transfer-guide.md`.
- `/healthcheck` публичен и теперь раскрывает имена subject'ов. Имена не секретны (без JWT ими не воспользоваться),
JWT и seed в ответ не попадают.
## Decisions made without discussion
1. 12.10.2026 — **R-1:** имена ключей `NATS_TABULAR_REQUEST_SUBJECT`, `NATS_IMAGE_REQUEST_SUBJECT`,
`NATS_RESULT_SUBJECT` (по образцу FileWorker — префикс `NATS_`, суффикс `_SUBJECT`), а не `NATS_COMMAND_SUBJECT` /
`NATS_EVENT_SUBJECT` FileWorker: у нас два subject'а запроса. Имена показаны владельцу в дизайне и приняты вместе
с подходом. Цена ошибки: низкая — переименование ключа до выката в контур.
2. 12.10.2026 — **R-2:** `schema_version` не меняется: тела сообщений те же, subject'ы — окружение (как R-2 в
`nats-files-subjects`). Цена ошибки: низкая.
3. 12.10.2026 — **R-3:** проверяются все три subject'а в каждом сервисе, включая неиспользуемые им (решение 1).
Цена ошибки: низкая — лишний отказ старта при кривом наборе, который всё равно сломал бы обмен.
4. 12.10.2026 — **R-4:** `subjects` в `/healthcheck` отдаётся и при `nats = off`. Цена ошибки: низкая.
5. 12.10.2026 — **R-5:** пароли MinIO/SMB в `SecretStr` не переводятся — вне запроса владельца; `hide_input_in_errors`
всё равно скрывает их в ошибках валидации. Цена ошибки: пароли видны в `repr(settings)` до отдельной задачи.
6. 12.10.2026 — **R-6:** маршрут — OpenSpec change `nats-env-config` в каждом из четырёх репозиториев, один план SDD на
все четыре (больше пяти задач — SDD-контроллер). Цена ошибки: низкая, процессная.
7. 12.10.2026 — **R-7:** e2e ядра не переводится на свои subject'ы (решение 7): ему нужен контейнер `tabular-parser`
стенда на умолчаниях, а развести ядро теста и ядро стенда может только своя queue group, которая в этом change
не настраивается. OQ-83 для `make e2e` остаётся открытым. Цена ошибки: перед `make e2e` по-прежнему останавливать
ядро стенда.
8. 13.10.2026 — **R-8:** задачи 2/4/6 (настройки сервисов) и затем 3/5/7 выполнялись параллельно по репозиториям
(общих файлов нет); ревью — по каждой задаче отдельно. Цена ошибки: нулевая (разные репозитории).
9. 13.10.2026 — **R-9:** трейлер коммитов — `Co-Authored-By: Claude Opus 5.5` по вводной; у двух коммитов
исполнителей (haiku) был Haiku 5.5 — исправлено `commit --amend` до публикации ветки. Цена ошибки: нулевая.
10. 13.10.2026 — **R-10:** minor-находки задач, усиливающие тесты (T2.1, T2.2, T3.1, T3.2, T4.1, T5.1–T5.3, T5.6, T6.1,
T6.3, T8.2, T8.3), исправлены одной волной после задач 1–8; косметика (T7.1 — позже исправлена, T7.2, T5.4 —
позже исправлена) и T8.1 отложены/отклонены в ledger. Тест T2.2 (утечка JWT при невалидном subject) не ловит
отсутствие `hide_input_in_errors` (ошибка field-validator не печатает вход группы) — оставлен как регрессионный;
защиту несёт `test_validation_error_hides_nats_secrets`. Цена ошибки: низкая.
11. 13.10.2026 — **R-11:** по финальному ревью (opus) исправлены I-1 (повторная публикация обновляет
`recognition_job.request_subject`), M-1 (примеры `/healthcheck`), M-2 (формулировка про интеграционные тесты ядра —
запросы тестов ядра идут на умолчательные subject'ы), M-3 (docstring'и и логи не называют умолчание фактическим
subject'ом), M-5 (пометки «по умолчанию» в CLAUDE.md и шаблонах). **M-4** (базовые спеки с `SHALL … files.*`
формально противоречат ADDED-требованию) — отложено: решение при архивации (MODIFIED) или отдельным change'ем;
**M-7** (ключи в `.env.example` сервисов закомментированы, у ядра — нет) отклонено: у ядра паритет
`.env.example`/`.env.test` проверяет тест, в сервисах умолчание работает без ключей. Цена ошибки M-4: после
архивации в базовых спеках остаются два формально разных требования о subject'ах; M-7: нулевая.
12. 13.10.2026 — **R-12:** `make check` в docs запускался с `CHANGE=nats-env-config` — дефолт `CHANGE ?= slice-0-skeleton`
указывает на архивный change (O-2, вне объёма). Цена ошибки: нулевая.
13. 13.10.2026 — **R-13:** `check-full` в tabular-parser проверен на временной ветке `main` + feat/31: ветка отстаёт от
main (tp#33), общий `fixtures/` уже содержит golden после fixtures#18; на ветке без merge main 2 теста
`tests/fixtures` красные, после merge — 2313 passed. Ветку не перебазировал. Цена ошибки: перед merge PR — тот же
расклад, решение (rebase/merge main) за владельцем.
14. 13.10.2026 — **R-14:** после отчёта контроллера ветки docs `feat/80` и tabular-parser `feat/31` (ещё не
опубликованные) перебазированы на свежий `main` (docs#98, tp#33) — снимает расклад R-13 без merge-коммита; force-push
не нужен. `check-full` сервисов и `make check` docs перезапущены на перебазированных ветках. Цена ошибки: нулевая.
15. 13.10.2026 — **R-15:** отложенные контроллером RM-1 (лог `file_mismatch` с литералом `files.tabular_parsed`) и RM-2
(переносы docstring'ов в arm-core) исправлены сразу — правка в три строки, follow-up не нужен. T8.1 сверено с кодом
ядра: без ack задание после повторных публикаций уходит в `dlq`, файл — `UPLOADED` → `ERROR`; строка `qa-guide.md`
уточнена. Цена ошибки: нулевая.

View File

@ -0,0 +1,60 @@
## Why
Прод и тест будут работать с разными наборами subject'ов NATS и разными JWT. Платформа РАИСА передаёт в под только
переменные `OPENBAO__*`, поэтому окружение должно настраиваться только секретами OpenBao, без пересборки и релиза
сервисов. Сейчас JWT и seed уже приходят как `OPENBAO__NATS__NATS_JWT` / `NATS_SEED`, а subject'ы зашиты константами
`api/src/contract/subjects.py` трёх сервисов (решение R-4 в #74) и уже раз менялись (`recognition.*` → `files.*`,
08.10.2026). Решение владельца 12.10.2026: subject'ы и JWT задаются по окружению секретами группы `OPENBAO__NATS__` —
так устроен `RSHB/armka_file_worker`, проверенный в контуре (`NATS_COMMAND_SUBJECT`, `NATS_EVENT_SUBJECT` рядом с
`NATS_JWT`).
Срез: вне плана срезов `09-delivery-plan.md` — подготовка к контуру (инфраструктура NATS платформы). Issue
RSHB/arm-ka-docs#80.
## What Changes
- Три настройки окружения, одинаковые ключи во всех трёх сервисах, умолчания — текущие имена:
`OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` (`files.tabular_uploaded`),
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` (`files.img_uploaded`),
`OPENBAO__NATS__NATS_RESULT_SUBJECT` (`files.tabular_parsed`).
- Контракт: subject'ы — конфигурация окружения; у ядра и сервисов одного окружения значения MUST совпадать; имена
проверяются при старте (непустые, без пробелов и шаблонов `*`/`>`, три разных).
- `NATS_JWT` / `NATS_SEED` — секреты: значения не попадают в `repr`, логи и тексты ошибок валидации конфигурации.
- Каждый сервис пишет свои subject'ы в лог при старте и отдаёт их в `/healthcheck` (объект `subjects`).
- Интеграционные тесты `tabular-parser` и `scan-gateway` берут собственные subject'ы через настройки и не делят их с
контейнерами стенда (OQ-83 в части этих тестов; e2e ядра — без изменений, design R-7).
- Документы: `context/03-architecture.md` (NATS платформы, перечень секретов), `context/07-open-questions.md` (OQ-83),
`dev/README.md`, `dev/contour-transfer-guide.md`, `dev/qa-guide.md`, `dev/frontend-api-guide.md` (ответ
`/healthcheck`, если описан), `dev/.env.example`.
Читают и пишут контракт: `arm-core` (публикует запросы в оба subject'а запроса, подписан на subject ответа),
`tabular-parser` (подписан на subject запроса `tabular`, публикует в subject ответа), `scan-gateway` (подписан на
subject запроса `image`, публикует в subject ответа). Дочерние change'и `nats-env-config`: RSHB/arm-core#106,
RSHB/arm-tabular-parser#31, RSHB/arm-scan-gateway#32.
## Non-goals
- `schema_version` контракта не меняется: тела сообщений, `Nats-Msg-Id`, файл результата — те же.
- Queue group'ы (`arm-core`, `tabular-parser`, `scan-gateway`) остаются константами.
- Механизм аутентификации NATS не меняется (nkeys seed + JWT, оба пусты — без учётных данных).
- Пароли MinIO и SMB в `SecretStr` не переводятся.
- Subject'ы не настраиваются через `OPENBAO__APP__*`: это группа `NATS`, а не настройки верхнего уровня.
- DBML не поднимается; заметки v5.17 про subject'ы — follow-up RSHB/arm-ka-docs#79 (R-3).
- Отдельный NATS для тестов не поднимается.
## Capabilities
### New Capabilities
### Modified Capabilities
- `recognition-contract`: subject'ы запроса и ответа — настройки окружения с умолчаниями `files.*`.
## Impact
- Сервисы: `arm-core`, `tabular-parser`, `scan-gateway` — `core/config.py` (`NATSSettings`), `contract/subjects.py`,
`core/nats.py`, публикация и подписки, `/healthcheck`, тесты, `.env.example`, спеки, README.
- Выкат обратно совместим: без новых ключей всё работает на `files.*`. Для теста платформа кладёт свои `NATS_JWT`,
`NATS_SEED` и три subject'а одинаково всем трём сервисам; JWT окружения должен давать права publish/subscribe на эти
subject'ы.
- Ядро вычисляет subject при каждой публикации по `pipeline`, поэтому после смены настроек повторная публикация
заданий в работе уходит в новый subject; `recognition_job.request_subject` записывает фактический subject.

View File

@ -0,0 +1,96 @@
## ADDED Requirements
### Requirement: Subject'ы NATS — настройки окружения
Subject'ы обмена SHALL задаваться секретами группы `OPENBAO__NATS__` с одинаковыми ключами во всех трёх сервисах:
`NATS_TABULAR_REQUEST_SUBJECT` (по умолчанию `files.tabular_uploaded`), `NATS_IMAGE_REQUEST_SUBJECT` (по умолчанию
`files.img_uploaded`), `NATS_RESULT_SUBJECT` (по умолчанию `files.tabular_parsed`). У ядра и сервисов одного окружения
значения MUST совпадать. Каждый сервис SHALL при старте проверять все три имени: непустое, без пробельных символов, без
шаблонов `*` и `>`, без пустых токенов, три имени попарно различны; при нарушении сервис MUST NOT стартовать.
Учётные данные NATS (`OPENBAO__NATS__NATS_JWT`, `OPENBAO__NATS__NATS_SEED`) SHALL задаваться по окружению теми же
секретами, и их значения MUST NOT попадать в логи, `repr` настроек и тексты ошибок валидации конфигурации. Каждый
сервис SHALL писать используемые subject'ы в лог при старте и отдавать их в `/healthcheck` объектом `subjects`
(ключи `tabular_request`, `image_request`, `result` — только те, что использует сервис) независимо от состояния NATS.
Queue group'ы и `schema_version` от настроек subject'ов не зависят.
#### Scenario: Умолчания
- **WHEN** сервис стартует без ключей `OPENBAO__NATS__NATS_*_SUBJECT`
- **THEN** обмен идёт по `files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`
#### Scenario: Набор тестового окружения
- **WHEN** всем трём сервисам заданы одинаковые `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`,
`NATS_IMAGE_REQUEST_SUBJECT`, `NATS_RESULT_SUBJECT`, `NATS_JWT`, `NATS_SEED` тестового окружения
- **THEN** ядро публикует запросы и получает ack и результаты только по этим subject'ам; subject'ы по умолчанию не
используются, `recognition_job.request_subject` содержит subject тестового окружения
#### Scenario: Недопустимый набор
- **WHEN** `NATS_RESULT_SUBJECT` совпадает с одним из subject'ов запроса, или имя содержит `*`, `>` или пробел
- **THEN** сервис не стартует с ошибкой конфигурации, называющей ключ
#### Scenario: Разъехавшиеся настройки видны
- **WHEN** у ядра и сервиса заданы разные subject'ы ответа
- **THEN** `/healthcheck.subjects.result` ядра и сервиса различаются; JWT и seed в ответе `/healthcheck` отсутствуют
#### Scenario: Ошибка конфигурации не раскрывает JWT
- **WHEN** сервис не стартует из-за ошибки валидации настроек при заданных `NATS_JWT` и `NATS_SEED`
- **THEN** текст ошибки и лог не содержат значений JWT и seed
## MODIFIED Requirements
### Requirement: Сообщение запроса распознавания
Тело сообщения на subject запроса `tabular` или `image` (настройки окружения `NATS_TABULAR_REQUEST_SUBJECT` /
`NATS_IMAGE_REQUEST_SUBJECT`, по умолчанию `files.tabular_uploaded` / `files.img_uploaded`) SHALL соответствовать
`docs/contracts/recognition-request.schema.json` v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id` (uuid =
`recognition_job.id`), `file_id` (uuid = `stored_file.id`), `pipeline` (`tabular` | `image`), `file_type`
(`XLS` | `XLSX` | `XLSB` | `PDF` | `PNG` | `JPG`), `original_name`, `s3 {bucket, key}` исходника в роли `uploads`,
`reply_deadline` (date-time, UTC), `attempt` (≥ 1). Subject SHALL соответствовать `pipeline`.
#### Scenario: Публикация запроса ядром
- **WHEN** ядро переводит `recognition_job` из `queued` в `published`
- **THEN** в subject, соответствующий `recognition_job.pipeline`, публикуется сообщение, проходящее схему запроса, с
заголовком `Nats-Msg-Id` = `<job_id>:<attempt>`
#### Scenario: Сообщение не по схеме
- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`), но из тела извлекаются
корректные `job_id` и `file_id`
- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` **без предшествующего ack
`processing`** и не падает; `attempt` для `Nats-Msg-Id` результата берётся из тела запроса, при его отсутствии
реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
`scan-gateway` подставляет 1) — см. OQ-71
#### Scenario: Нечитаемый запрос
- **WHEN** сервис получает сообщение, из которого нельзя извлечь `job_id` (не JSON, не объект, `job_id` отсутствует или
не uuid), а значит нельзя собрать валидный конверт
- **THEN** сервис ответа не публикует: запрос записывается в лог и отбрасывается; ядро не получит ack, повторит
публикацию `OUTBOX_MAX_REPUBLISH` раз и переведёт задание в `dlq` (`error_code = NO_ACK`)
#### Scenario: Невалидный повтор запроса, уже находящегося в работе
- **WHEN** сервис получает запрос, не проходящий схему, но с `job_id`, который уже в работе или обработан этим процессом
- **THEN** конверт `SCHEMA_MISMATCH` не публикуется (его `Nats-Msg-Id` `result:<job_id>:<attempt>` совпал бы с настоящим
результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается
#### Scenario: Примеры контракта — иллюстрации
- **WHEN** запрос берётся из `docs/contracts/examples/request-*.json` (в `request-image.json` `reply_deadline` лежит в
прошлом)
- **THEN** он проходит схему: схема проверяет для `reply_deadline` лишь формат `date-time`, но не сравнивает срок с
текущим временем; пример — иллюстрация формы сообщения, а не готовое к исполнению задание; проверка срока — дело
сервиса (`scan-gateway` отвечает `error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование)
### Requirement: Сообщение результата распознавания
Тело сообщения на subject ответа (настройка окружения `NATS_RESULT_SUBJECT`, по умолчанию `files.tabular_parsed`)
SHALL соответствовать `docs/contracts/recognition-result-message.schema.json`
v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id`, `file_id`, `status` (`processing` | `done` | `needs_check` |
`error`), `service {name, version}`; при `status ∈ {done, needs_check}` обязательны `result_s3 {bucket, key}` и
`result_schema_version`; при `status = error` обязателен `error {code, message}`; опциональны `raw_s3_key`
(только `scan-gateway`) и `metrics`. Поле `group_schema` в конверт NOT включается — источник `recognition_job.group_schema`
— файл результата.
#### Scenario: Успешный результат
- **WHEN** сервис завершил обработку файла
- **THEN** файл результата записан в S3 по ключу `results/{file_id}/{job_id}/result.json`, а затем опубликован конверт
со `status = done | needs_check`, `result_s3` указывает на этот файл, заголовок `Nats-Msg-Id` = `result:<job_id>:<attempt>`
#### Scenario: Ошибка обработки
- **WHEN** сервис не смог обработать файл (нечитаем, таймаут внешней системы, тип не поддерживается)
- **THEN** публикуется конверт со `status = error` и `error.code` из перечня схемы (`UNSUPPORTED_FORMAT`, `UNREADABLE`,
`TIMEOUT`, `SCHEMA_MISMATCH`, `NOT_IMPLEMENTED`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`,
`RECOGNITION_FAILED`, `UNKNOWN_ERROR`); `result_s3` может отсутствовать

View File

@ -0,0 +1,26 @@
## 1. Контракт (docs)
- [x] 1.1 Дельта спеки `recognition-contract` (новое требование «Subject'ы NATS — настройки окружения», два изменённых);
Purpose спеки при архивации: subject'ы — настройки с умолчаниями `files.*`
- [x] 1.2 `contracts/recognition-request.schema.json`, `contracts/recognition-result-message.schema.json`: в описаниях
subject'ов — «настройка окружения, по умолчанию …» (только `description`, `schema_version` не меняется; копии в
сервисах обновляются побайтно)
## 2. Сервисы (дочерние change'и `nats-env-config`)
- [x] 2.1 RSHB/arm-core#106: `NATSSettings` (3 subject'а, `SecretStr`, валидация), `hide_input_in_errors`, публикация и
подписка по настройкам, `/healthcheck.subjects`, лог при старте, unit-тесты (e2e — на умолчаниях, design R-7), `.env.example`, спека `recognition-job-exchange`, README
- [x] 2.2 RSHB/arm-tabular-parser#31: то же для подписки `tabular_request` и публикации `result`, спека
`tabular-recognition-service`
- [x] 2.3 RSHB/arm-scan-gateway#32: то же для подписки `image_request` и публикации `result`, спека
`image-recognition-gateway`
## 3. Документы контекста
- [x] 3.1 `context/03-architecture.md`: subject'ы — настройки окружения (таблица потоков, NATS платформы), перечень
секретов `OPENBAO__NATS__*` по сервисам, требование одинакового набора для всех трёх сервисов
- [x] 3.2 `dev/README.md` (таблица переменных, `/healthcheck`, интеграционные тесты), `dev/.env.example`,
`dev/contour-transfer-guide.md` (секреты окружения), `dev/qa-guide.md` и `dev/frontend-api-guide.md` (ответ
`/healthcheck`, если описан), `repo-templates/*.CLAUDE.md` при упоминании констант subject'ов
- [x] 3.3 `context/07-open-questions.md`: OQ-83 — интеграционные тесты `tabular-parser`/`scan-gateway` на своих
subject'ах; для e2e ядра вопрос остаётся (общая queue group `arm-core`, design R-7)

View File

@ -7,7 +7,7 @@
(временный `.part` — в `Tmp`), забрать все `Исходящие/<job_id>/*.json` (по одному json на документ), сохранить склеенный
сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить горячую папку (только при `SCAN_CLEANUP=true`),
нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument`), опубликовать
`files.tabular_parsed`. Таймаут 5 мин → `TIMEOUT`. **Нет доступа к БД.**
`files.tabular_parsed` (по умолчанию; задаётся OPENBAO__NATS__NATS_*_SUBJECT). Таймаут 5 мин → `TIMEOUT`. **Нет доступа к БД.**
## Правила
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14): confidence → 0–100,

View File

@ -4,8 +4,8 @@
## Что это
Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам (§1 правил) и ББ/ОФР из Excel (§12). Вход — NATS
`files.tabular_uploaded` + файл в S3 `uploads/`; выход — JSON по `../docs/contracts/recognition-result.schema.json`
в S3 `results/` + NATS `files.tabular_parsed`. **Нет доступа к БД.** Не знает про организации, периметры, план счетов —
`files.tabular_uploaded` (по умолчанию; задаётся OPENBAO__NATS__NATS_*_SUBJECT) + файл в S3 `uploads/`; выход — JSON по `../docs/contracts/recognition-result.schema.json`
в S3 `results/` + NATS `files.tabular_parsed` (по умолчанию; задаётся OPENBAO__NATS__NATS_*_SUBJECT). **Нет доступа к БД.** Не знает про организации, периметры, план счетов —
возвращает структуру файла; нормализация и резолв — в arm-core.
## Главное правило