# Design: Срез 0 — скелет scan-gateway ## Context См. `proposal.md`. Исходное состояние — копия каркаса `arm-core` (`a9159aa`). Протокол ИС Сканирование (FNR02, `reference-old-arm/api/src/core/PDFWorker`): файл с префиксом UUID кладётся во входящий каталог SMB-шары, ответ появляется в исходящем каталоге в подкаталоге с тем же UUID, формат ответа — Content AI (`file_uid`, `error`, `data_ocr[]`), таймаут 5 мин без повторов (ТЗ 3, OQ-15). Старый код держал задания в in-memory кэше и опрашивал шару двумя поллерами в одном процессе с ядром — это и есть то, от чего уходит DECISION-4. Платформа: Python 3.11, NATS без JetStream, Nexus недоступен до прода, CI в Gitea нет. ## Goals / Non-Goals **Goals:** конвейер «NATS → ack → S3 → горячая папка → ответ → S3 → NATS» работает против эмулятора в compose и в unit-тестах без Docker; таймаут и ошибки ИС всегда превращаются в результат; сырой ответ сохраняется для разбора инцидентов. **Non-Goals:** см. proposal. ## Decisions ### D-1. Горячие папки — протокол с двумя реализациями, имена каталогов настраиваемы `HotFolder.put(name, data)`, `poll(job_id) -> bytes | None`, `healthy() -> bool`. `LocalHotFolder(root)` работает с обычным каталогом (dev, compose-том `hotfolder`, unit-тесты через `tmp_path`); `SmbHotFolder(host, path, login, password)` — через `smbclient` шаблона (UNC-пути). Подкаталоги `SCAN_INPUT_DIR`/`SCAN_OUTPUT_DIR` по умолчанию `Input`/`Output` (как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Режим — `SCAN_HOTFOLDER_MODE = local | smb`. ### D-2. Без реестра заданий на диске: состояние — в процессе и в горячей папке, ack — сразу NATS без JetStream: сообщение доставляется один раз, повторы делает ядро, не получив ack за 30 с. Поэтому по получении запроса шлюз сразу публикует ack `processing` и держит process-local множество `job_id` «в работе/обработано» (LRU, `DONE_CACHE_SIZE=1000`): повторный запрос (ядро не дождалось ack) → повторный ack, второй файл в ИС не уходит. Падение инстанции после ack → ядро закроет задание по `reply_deadline` (`timeout`); повторной отправки в ИС не будет (OQ-15, 0 повторов). *Альтернатива* — хранить задания в Redis/файле: лишняя инфраструктура ради ситуации, которую ядро уже обрабатывает. ### D-3. Параллелизм — `SCAN_MAX_INFLIGHT` задач, каждая ждёт свой ответ Подписка `queue="scan-gateway"`; `on_request` валидирует, публикует ack и кладёт задание во внутреннюю очередь; семафор `SCAN_MAX_INFLIGHT` (4) ограничивает одновременные обработки; ожидание — опрос `poll(job_id)` каждые `SCAN_POLL_SECONDS` (5) до `deadline = min(request.reply_deadline, start + SCAN_TIMEOUT_SECONDS)`. При таймауте — результат `TIMEOUT`, файл во входящем каталоге не удаляется (ИС может ответить позже; «поздний» ответ никто не опрашивает; уборка каталога — задача эксплуатации, вне среза). Очередь заданий, ждущих слот, не ограничена — ядро не публикует больше, чем `queued` заданий. ### D-4. Сырой ответ — в S3 `raw/{job_id}.json`, в конверте `raw_s3_key` По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов. Сохраняется **до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3. ### D-5. Нормализатор — интерфейс; в Срезе 0 пробрасывает ошибки ИС и отвечает `NOT_IMPLEMENTED` на успех `Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics)` (`raw` — уже разобранный JSON ответа ИС: `json.loads` и отображение ошибки разбора в `INVALID_FORMAT` — на стороне воркера, не-объект JSON -> `INVALID_FORMAT` в нормализаторе). `NullNormalizer`: если в ответе `error` не `null` — результат `status = error` с кодом из ответа (коды Content AI совпадают с перечнем контракта); иначе — `NOT_IMPLEMENTED` с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3». Срез 3 подменяет реализацию (§11, OQ-13/14), транспорт не меняется. ### D-6. Эмулятор — тот же репозиторий и образ, другой entrypoint `python -m src.scan_emulator`: цикл по `Input/` каждую секунду; новый файл `_.` → поиск `SCAN_FIXTURES_DIR/*.json` (точное имя, затем префикс) → через `SCAN_EMULATOR_DELAY` запись `Output//.json` (с подстановкой `file_uid = `); нет пары → ответ Content AI `{"file_uid": uuid, "error": {"code": "INVALID_DOCUMENT", "message": "..."}, "data_ocr": []}`. Обработанные входящие файлы переименовываются в `_..done`, чтобы не обрабатываться повторно. Эмулятор использует `LocalHotFolder` — тем самым тестируя и его. ### D-7. Ошибки транспорта — результат, не молчание Запрос не по схеме / `MAJOR` ≠ 1 → `SCHEMA_MISMATCH` (без ack); S3 get упал → `FILE_READ_ERROR`; `put` в горячую папку упал → `RECOGNITION_FAILED` (сообщение: «горячая папка недоступна»); ответ не JSON → `INVALID_FORMAT`; прочее → `UNKNOWN_ERROR`. Конверт публикуется всегда с повторами публикации до `PUBLISH_RETRIES=3`; не удалось — лог error, ядро закроет по `reply_deadline`. ### D-8. NATS — как у ядра и парсера, без JetStream Без учётных данных при пустых `NATS_SEED/NATS_JWT`, иначе nkeys/JWT шаблона; автопереподключение `nats-py`; подписка queue group восстанавливается клиентом; `/healthcheck` отдаёт `nats` и `hotfolder`. ### D-9. Тесты Unit: `LocalHotFolder` на `tmp_path`; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end «put → эмулятор → poll» без Docker; обработчик воркера с фейками S3/NATS — ack до обработки, повторный запрос, все коды D-7 и таймаут (с укороченными настройками). `SmbHotFolder` — unit с подменённым `smbclient`. Интеграционный (маркер `integration`): настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (R-7; контейнера `scan-emulator` в compose пока нет) — `request-image.json` для `aep_5.pdf` → ack, затем ошибка ИС `INVALID_DOCUMENT` с `raw_s3_key`; для синтетического успешного ответа (`ok_doc.pdf`, R-10) — конверт `NOT_IMPLEMENTED` с `raw_s3_key`; объект `raw/` в MinIO. ### D-10. Dev без Nexus, Python 3.11 `Dockerfile` — `python:3.11-slim`; `make venv` ставит `requirements.txt` без строк `raisa_*`. Конфигураций CI нет: `make check` / `make check-full`. ## Risks / Trade-offs - [SMB в проде ведёт себя иначе, чем локальный каталог (права, кодировки имён)] → `SmbHotFolder` минимален и повторяет вызовы старого `PDFWorker`; первый прогон на реальной шаре — отдельная задача Среза 3 с доступом к стенду. - [Имена каталогов ИС отличаются от `Input/Output`] → настройки, не код. - [Поздний ответ ИС после таймаута теряется] → приемлемо по ТЗ 3 (0 повторов); сырой файл остаётся на шаре. - [Падение шлюза после ack теряет задание до `reply_deadline`] → осознанно: без JetStream иначе нельзя без своей очереди; ядро переводит в `timeout`, replay — Срез 3+. - [Эмулятор знает только две пары фикстур] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые пары добавляются в `fixtures/ocr` без изменения кода. ## Migration Plan Нет данных. Удаление каталогов — отдельным первым коммитом. Откат — revert. ## Open Questions Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего каталогов и путь SMB на стенде ИС Сканирование — нужны к Срезу 3. ## Decisions made without discussion | № | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | 1 | 2026-10-03 | D-1: протокол `HotFolder`, режимы `local`/`smb`, имена каталогов `Input`/`Output` по умолчанию | dev без SMB; имена из старого кода | смена настройки | | 2 | 2026-10-03 | D-2: queue group `scan-gateway`, ack сразу, process-local LRU `job_id`, без реестра на диске | NATS без JetStream (ответ 03.10); OQ-15 | добавить хранилище состояния, если понадобятся повторы OCR | | 3 | 2026-10-03 | D-3: `SCAN_MAX_INFLIGHT = 4`, опрос 5 с, таймаут 300 с | ТЗ 3 (5 мин) | смена настроек | | 4 | 2026-10-03 | D-4: сырой ответ сохраняется до нормализации | данные для инцидентов даже при падении нормализатора | нет | | 5 | 2026-10-03 | D-5: `NullNormalizer` пробрасывает ошибки ИС, успех → `NOT_IMPLEMENTED` | честный статус; коды Content AI совпадают с контрактом | нет | | 6 | 2026-10-03 | D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, `.done` после обработки | общие соглашения горячих папок; `aep_8.pdf` ↔ `aep_8_form4.json` | нет | | 7 | 2026-10-03 | D-7: таблица транспортных ошибок, публикация с 3 повторами | ядро не ждёт таймаута, если шлюз жив | нет | | 8 | 2026-10-03 | `external_task_id` в ядро не передаётся (идентификатор ИС = `job_id` в имени файла) | у протокола горячих папок нет отдельного id задания | поле DBML остаётся NULL | | 9 | 2026-10-03 | D-10: `python:3.11-slim`, `make check`/`check-full` вместо CI | ответы пользователя | смена тега | | 10 | 2026-10-04 | R-1: `.env.example` создаёт задача 1.2 (минимальный рабочий local-режим), задача 2.2 дополняет и добавляет `.env.test` | проверка 1.2 требует `.env.example`, а план создаёт его в 2.2 | переделка файла | | 11 | 2026-10-04 | R-2: из `app.py` убирается модульный код nkeys/NATS (переезжает в `src/core/nats.py`); `/healthcheck` в 1.2 отдаёт `uptime`+`version`, поля `nats`/`hotfolder` добавляет 5.3 | пустые `NATS_SEED/NATS_JWT` не должны ломать импорт (spec «NATS без JetStream») | нет | | 12 | 2026-10-04 | R-3: задача 1.3 (README/CLAUDE.md) выполняется после 6.1, до 6.2 | проверка «упомянутые файлы существуют» невыполнима до появления файлов | нет | | 13 | 2026-10-04 | R-4: `LocalHotFolder` открывает `root`, `input_dir`, `output_dir`, `answer_path(job_id)`; эмулятор работает с ними обычными операциями ФС, протокол `HotFolder` остаётся `put/poll/healthy` | у протокола нет листинга входящих и записи ответа, нужных эмулятору (D-6) | рефакторинг эмулятора | | 14 | 2026-10-04 | R-5: `put(name, data)` пишет файл `name` как есть, имя `_` собирает вызывающий (`inbox_name`); запись атомарна (`.part` + rename), `Input/` создаётся, корень — нет; `healthy()` = корень существует; эмулятор игнорирует dot-файлы и `.done` | неоднозначность плана («put пишет _»); спека требует `RECOGNITION_FAILED` при отсутствии корня | нет | | 15 | 2026-10-04 | R-6: фабрика `create_hotfolder(settings)` в `src/gateway/__init__.py` (задача 4.2), smb импортируется лениво | ни одна задача не владела фабрикой; в `local` smb-модуль не должен импортироваться | нет | | 16 | 2026-10-04 | R-7: интеграционный тест 6.1 запускает воркер шлюза и эмулятор в процессе теста против compose NATS/MinIO с tmp-каталогом; точный фрагмент compose для `scan-emulator` — в `open-questions-proposal.md` | контейнера `scan-emulator` в compose пока нет (его добавит docs в фазе 2) | контейнерный путь не проверен | | 17 | 2026-10-04 | R-8: `argon2-cffi==23.1.0` и `argon2-cffi-bindings==25.1.0` остаются в `requirements.txt` (транзитивные зависимости `miniopy-async`) | спека запрещает только SQLAlchemy/asyncpg/alembic/raisa_fastapi_protected_api; формулировка плана «без argon2-cffi» убрала бы фиксацию версий в freeze-файле (T2.1) | две лишние строки в requirements | | 18 | 2026-10-04 | Задача 1.2: вложенные модели `OPENBAO` остаются `BaseSettings`, блок SMB — `SMBSettings()` с пустыми значениями по умолчанию, полноту проверяет `Settings` в режиме `smb` | иначе pydantic-settings не разбирает вложенные ENV-ключи | нет | | 19 | 2026-10-04 | Задача 1.2: добавлено `MINIO_SECURE` (в `.env.example` `false`); 5.2 передаёт `secure=` в `Minio(...)` | MinIO в compose работает по http | нет | | 20 | 2026-10-04 | Задача 1.2: `SCAN_POLL_SECONDS`/`SCAN_TIMEOUT_SECONDS` — `PositiveFloat` | укороченные таймауты в тестах (дробные секунды) | нет | | 21 | 2026-10-04 | Задача 1.2: из lifespan убраны S3 и NATS (вернутся в 5.2/5.3) | подключение переезжает в `src/core/nats.py`/воркер (R-2) | нет | | 22 | 2026-10-04 | Задача 1.2: `app.py` — фабрика `create_app(settings)`, модульный `settings` убран; конфиг читается лениво (`get_settings()` с `lru_cache`) | тесты без побочных эффектов при импорте | нет | | 23 | 2026-10-04 | Задача 2.1: `Makefile` в корне репозитория (рецепты `cd api && …`, как в arm-core); строгие флаги mypy заданы заранее для `src.contract`, `src.gateway`, `src.scan_emulator`, код шаблона — нестрогий; `httpx` не дублируется в `requirements-dev.txt` (приходит из `requirements.txt`) | единообразие с arm-core; новые пакеты пишутся строго с первого дня | нет | | 24 | 2026-10-04 | Задача 2.2: строка `.dockerignore` убрана из `.gitignore` (как в arm-core), файл закоммичен; `conftest.py` берёт окружение из `.env.test` (`ENV_FILE=.env.test` в `pytest.ini`), `setdefault` убран; тесты запускаются из `api/` | шаблонная строка в `.gitignore` не давала закоммитить `.dockerignore`; единый источник окружения тестов | тесты зависят от cwd=`api/` | | 25 | 2026-10-04 | Задача 3.1: модели `src/contract` скопированы из arm-core (включая модели ОСВ и tabular-подписки, не используемые шлюзом); `extra="ignore"` вместо `forbid` | схемы не содержат `additionalProperties: false`, MINOR добавляет необязательные поля; спека требует валидировать все примеры docs; близость к arm-core упрощает синхронизацию | при расхождении arm-core копию синхронизируют вручную | | 26 | 2026-10-04 | Задача 3.1: конструкторы конвертов `build_ack_message/build_error_message/build_done_message` принимают `ServiceInfo` параметром (`ServiceInfo(name=settings.SERVICE_NAME, version=settings.VERSION)`); `is_supported_major` — `fullmatch` по шаблону схемы без `int()` | конструкторы не зависят от окружения; `int()` падает на сверхдлинной строке (T6.1) | нет | | 27 | 2026-10-04 | R-9: методы протокола `HotFolder` синхронные; воркер вызывает их через `asyncio.to_thread` | блокирующие вызовы ФС/`smbclient`; общий контракт для двух реализаций | обернуть в async позже | | 28 | 2026-10-04 | Задача 4.1: `answer_path` бросает `ValueError` для небезопасного `job_id`; общие `validate_name`/`validate_job_id` в `hotfolder.py`; `put` отвергает имена с разделителями, NUL, точкой в начале | `job_id` и `original_name` приходят из запроса NATS | воркер не должен принимать `ValueError` за недоступность ИС | | 29 | 2026-10-04 | Задача 4.2: `inbox_name` заменяет символы, недопустимые в Windows/SMB (`: < > " \| ? *`, управляющие), на `_` и отбрасывает завершающие точки/пробелы; `put` остаётся строгим (T8.1) | допустимое имя пользователя (`Отчёт: 2024.pdf`) не должно проваливать задание; для ИС имя ничего не значит | имя во входящем каталоге отличается от загруженного | | 30 | 2026-10-04 | Задача 4.2: `SMB_PATH` — относительный (`share/dir`, склеивается с хостом) или полный UNC; учётные данные передаются в каждый вызов `smbclient`, если задан логин; `poll` возвращает `None` на `STATUS_SHARING_VIOLATION`/`STATUS_DELETE_PENDING`; монопольное открытие ответа сохранено; `mkdir` существующего `Input` с `ACCESS_DENIED` не роняет `put`, если каталог существует | повторяет вызовы старого `PDFWorker`; ИС может ещё писать ответ; урезанные ACL шары | проверить на реальной шаре в Срезе 3 | | 31 | 2026-10-04 | Задача 4.2: временный файл `.part` лежит прямо в `Input`; читается один `.json` (старый код читал все файлы `Output//`) | спека; реальная ИС может забирать файлы без маски — риск вынесен в `open-questions-proposal.md` | запись `.part` в служебный каталог, чтение всех файлов подкаталога | | 32 | 2026-10-04 | R-10 (наблюдение O-1): оба реальных ответа `fixtures/ocr/aep_5.json` и `aep_8_form4.json` сами являются ответами-ошибками ИС (`error = INVALID_DOCUMENT`, пустой `data_ocr`), поэтому путь «успех -> `NOT_IMPLEMENTED`» проверяется на синтетическом успешном ответе (копия `aep_5.json` с `error = null` и одной строкой таблицы) — в unit-тестах 5.1 и интеграционном тесте 6.1 (временный каталог фикстур с `ok_doc.json`); реальные фикстуры используются для сценария «ошибка ИС проброшена» | невозможно получить успешный сценарий из имеющихся фикстур; спека не меняется | если владелец ждёт, что `aep_5.json` — успешный образец, нужны реальные успешные ответы (открытый вопрос Среза 3) | | 33 | 2026-10-04 | R-11: эмулятор читает обычные переменные окружения через собственные настройки (`SCAN_HOTFOLDER_PATH`, `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR`, `SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_DELAY`, `SCAN_EMULATOR_POLL_SECONDS`), а не `Settings` шлюза; `process_once` синхронный, `delay` спится один раз за проход, ответ пишется атомарно (`.part` + rename), затем входящий файл переименовывается в `.done` | контейнеру эмулятора не нужны переменные MinIO/NATS | нет | | 34 | 2026-10-04 | Задача 4.3: без аргументов `from_env` подмешивает `.env`/`ENV_FILE` под `os.environ` (чтобы `make emulator` работал с `api/.env`); сообщение «нет пары» — `<имя без uuid-префикса, с расширением>: нет эталонного ответа в фикстурах`; битая фикстура -> ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре, вход получает `.done`; нет `Input/` — не ошибка, нет корня при старте — код выхода 2, нет каталога фикстур — warning | удобство dev; защита от зацикливания | нет | | 35 | 2026-10-04 | R-12: `Normalizer.normalize(raw: object)` получает уже разобранный JSON ответа ИС; `json.loads` и отображение ошибки разбора в `INVALID_FORMAT` — на стороне воркера (D-7); `NullNormalizer` возвращает `INVALID_FORMAT` для не-объекта JSON, пробрасывает код ошибки ИС, если он входит в `ErrorCode` контракта (регистр значим), иначе `UNKNOWN_ERROR` с исходным кодом в сообщении; пустой `message` = отсутствует (текст по умолчанию) | D-5 писал `raw: dict`, а «не JSON» из плана 5.1 нельзя разобрать, не имея байтов | если воркер захочет отдать декодирование нормализатору — перенести `json.loads` | | 36 | 2026-10-04 | Задача 5.1: `NormalizeOutcome` (pydantic, frozen) — инвариант: `done`/`needs_check` требуют непустой `documents`, `error` запрещает `documents`, `error` обязателен тогда и только тогда, когда `status = error` | повторяет `minItems: 1` схемы `recognition-result` для успешных статусов; ошибка конструктора раньше, чем ошибка сборки `result.json` | нет | | 37 | 2026-10-04 | R-13: `PUBLISH_RETRIES=3` — число повторов после первой попытки (до 4 попыток всего); `PublishFailed.attempts` — фактическое число попыток; tabular-parser должен толковать так же | буквальный текст спеки «повторить до трёх раз» и D-7 | одна попытка больше/меньше | | 38 | 2026-10-04 | Задача 5.2: если задан только один из `NATS_SEED`/`NATS_JWT` — `NatsConfigError` (как в arm-core); шаблонный `nats_callback.py` удалён; ошибки S3: `S3ObjectNotFoundError` ⊂ `S3ReadError` ⊂ `S3StorageError`, `S3WriteError`; после `publish`/`subscribe` вызывается `flush`; `connect()` блокирует до доступности NATS — вызывать в фоновой задаче | fail-fast на неполные учётные данные; без flush nats-py буферизует публикации при переподключении | нет | | 39 | 2026-10-04 | R-14: исключения `hotfolder.poll` логируются и опрос продолжается до дедлайна; если все попытки опроса до дедлайна завершились исключением — `RECOGNITION_FAILED` («горячая папка недоступна»), иначе `TIMEOUT` | ошибки SMB (в т.ч. не-ENOENT) переходящие | выбор кода при постоянной ошибке | | 40 | 2026-10-04 | R-15: `ValueError` из `inbox_name` (непригодное `original_name`) -> `RECOGNITION_FAILED` «недопустимое имя файла» | в D-7 нет кода для этого случая | выбор кода | | 41 | 2026-10-04 | R-16: запрос не по схеме / MAJOR ≠ 1 / `pipeline` ≠ `image` -> `SCHEMA_MISMATCH` без ack; конверт ошибки публикуется только если из сырого JSON извлекаются валидные `job_id` и `file_id` (`attempt` из тела, если целое ≥ 1, иначе 1), иначе — лог и отбрасывание | схема конверта требует `job_id`/`file_id`; ядро закроет задание по `reply_deadline` | ядро не узнает об ошибке неразбираемого тела | | 42 | 2026-10-04 | R-17: ключ дедупликации — `job_id` (по спеке); повторный запрос публикует ack с `attempt` повторного запроса, второй файл не пишется; LRU размером `DONE_CACHE_SIZE` | спека, D-2, OQ-15 | более высокий `attempt` того же `job_id` не перезапускает обработку | | 43 | 2026-10-04 | R-18: ошибка записи `raw/{job_id}.json` -> `UNKNOWN_ERROR` без `raw_s3_key`; ответ не JSON — сырые байты сохраняются как есть, затем `INVALID_FORMAT` с `raw_s3_key`; исключение нормализатора -> `UNKNOWN_ERROR` с `raw_s3_key` | D-4, D-7, сценарий «Нормализатор упал» | нет | | 44 | 2026-10-04 | R-19: один бакет (глобальное ограничение): `request.s3.bucket` ≠ настроенного -> `FILE_READ_ERROR` после ack; `raw/` и `results/` — в настроенном бакете | «один бакет S3» | развёртывания с несколькими бакетами отвергаются | | 45 | 2026-10-04 | R-20: дедлайн = `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, `старт` — момент записи файла в горячую папку (после получения слота); `on_request` не блокируется: валидирует, регистрирует `job_id` и запускает задачу задания (ack -> семафор -> обработка) | ожидание слота входит в `reply_deadline` через `min`; колбэк NATS должен возвращаться быстро (T11.4) | нет | | 46 | 2026-10-04 | Задача 5.3 (подробно — 15 решений в отчёте исполнителя): задание, получившее слот после `reply_deadline`, отвечает `TIMEOUT` без записи файла и чтения S3 (ack уже опубликован); ответ ИС, не являющийся JSON, перечитывается через `SCAN_POLL_SECONDS` и принимается, когда стал JSON или перестал меняться (на дедлайне — последний прочитанный; затем `INVALID_FORMAT`); дедупликация в двух структурах (`_active` не вытесняется, `_done` — LRU `DONE_CACHE_SIZE`); `SCHEMA_MISMATCH` не регистрирует `job_id`; `error.message` ≤ 4000 символов, суррогаты заменяются; `raw/` пишется с `application/json` (не-JSON — `application/octet-stream`); нормализатор вызывается через `to_thread`; `stop()` не публикует конверты отменённых заданий (D-2); сбой SMB-сессии виден через `healthy()` (listdir с теми же учётными данными), без флага; проба `hotfolder`: таймаут 2 с, кэш 5 с, одна проба за раз; NATS подключается в фоновой задаче lifespan, ошибка `connect`/`subscribe` -> `nats = down` до перезапуска; порядок остановки: отмена подключения -> `worker.stop()` -> drain NATS -> S3 -> SMB; `/healthcheck` всегда 200 | экономия ИС; недописанный ответ на шаре без атомарного rename; ограничения размера публикации | поздний результат отброшен; неподвижная ошибка NATS требует перезапуска пода; зависший `poll` по SMB (до 60 с) не прерывается дедлайном (T12.2) | | 47 | 2026-10-04 | Задача 5.4: общая обвязка тестов воркера — `api/tests/worker_helpers.py` и `api/tests/unit/conftest.py`; ответ ИС для освобождения одного слота пишет тест (не `process_once`); отсутствие утечки слотов проверяется поведением (повторный `start()`); отмена воркера во время `to_thread(hotfolder.put)` не прерывает запись в потоке (файл уже в ИС, конверта нет, D-2) | эмулятор ответил бы на все четыре файла сразу; образец arm-core | возможна повторная отправка в ИС после рестарта (OQ-15) | | 48 | 2026-10-04 | Задача 6.1: интеграционный тест поднимает настоящий lifespan `create_app` и эмулятор в процессе теста против compose NATS/MinIO (tmp-каталог горячей папки); синтетический успешный ответ `ok_doc.json` строится по форме §11 `05-domain-rules.md`; сценарии 1–2 пропускаются без `../fixtures`, недоступная инфраструктура — падение; тест ждёт подписку шлюза по фоновой задаче `nats-connect`; `ResourceWarning` aiohttp о теле > 1 МиБ отфильтрован в одном модуле (загрузка скана 1,29 МБ) | контейнера `scan-emulator` ещё нет (R-7); fixtures не содержат успешных ответов (R-10) | интеграционный тест может забрать чужие запросы из общей queue group `scan-gateway` на общем NATS (T14.1) |