# 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/` (они на `main`, хук guard-main заблокирует всю сессию). - В каждом сервисном worktree перед первой задачей: `make -C $W/ venv` (uv, Python 3.11). - Перед изменением кода — `codegraph_explore` с `projectPath` = абсолютный путь worktree сервиса. - Коммиты на русском, с трейлером `Co-Authored-By: Claude Opus 5.5 `. - Инфраструктура для интеграционных тестов — 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/ check-full` в трёх сервисах (без `| tail`: код возврата не терять). Для `make e2e` ядра — остановить ядро стенда на время прогона (R-7). - Финальное ревью ветки (opus) по четырём репозиториям, таблица находок с маркерами и статусами.