arm-ka-docs/docs/superpowers/plans/2026-10-13-nats-env-config.md
Andrey Epifancev e197bd53e3 План nats-env-config: путь к venv сервиса
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:38:50 +04:00

782 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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) по четырём репозиториям, таблица находок с маркерами и статусами.