# 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` — объект, OQ-77), по одному json на распознанный документ, таймаут 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) -> list[AnswerFile] | None` (`AnswerFile(name, data)`), `healthy() -> bool`. `poll` возвращает `None`, пока ответ не готов, и все `*.json` каталога `//` по возрастанию имени, когда готов (OQ-78: ИС кладёт все распознанные документы задания одновременно, по одному json на документ; другие файлы каталога игнорируются). Готовность — по стабилизации: есть хотя бы один `*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (2) — защита от недописанного набора. `LocalHotFolder(root)` работает с обычным каталогом (dev, compose-том `hotfolder`, unit-тесты через `tmp_path`); `SmbHotFolder(host, path, login, password)` — через `smbclient` шаблона (UNC-пути). Подкаталоги `SCAN_INPUT_DIR`/`SCAN_OUTPUT_DIR` по умолчанию `Input`/`Output` (как в старом коде); если в ТЗ/проде они называются «Входящие»/«Исходящие» — меняется настройка, не код. Временный файл атомарной записи (`.part`) лежит не в `Input`, а в служебном каталоге `SCAN_TMP_DIR` (`Tmp`) рядом с `Input`/`Output` на той же шаре/ФС, чтобы `rename` в `Input` оставался атомарным, а ИС без маски не забрала недописанный файл (OQ-79); каталог создаётся при первой записи. Режим — `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)`; ответ принимается, когда `poll` вернул готовый набор файлов (settle, D-1). При таймауте (в т.ч. если набор так и не стабилизировался) — результат `TIMEOUT`, файл во входящем каталоге не удаляется (ИС может ответить позже; «поздний» ответ никто не опрашивает; уборка каталога — задача эксплуатации, вне среза). Очередь заданий, ждущих слот, не ограничена — ядро не публикует больше, чем `queued` заданий. ### D-4. Сырой ответ — склейка всех файлов, в S3 `raw/{job_id}.json`, в конверте `raw_s3_key` По DBML v5.13 п. 5 сырой ответ в БД не регистрируется; ключ передаётся в конверте для разбора инцидентов (контракт v1.0 не меняется: `raw_s3_key` — по-прежнему одна строка `raw/{job_id}.json`). Объект — **склеенный** ответ ИС: `{"job_id": "", "files": [{"name": "<имя файла>", "response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}`, `files` — все `*.json` каталога ответа по возрастанию имени. Каждый файл разбирается `json.loads` независимо: не JSON → `response = null`, `parse_error` заполнен (задание не падает и файл не перечитывается — к моменту склейки набор уже стабилен). Сохраняется **до** нормализации, чтобы при ошибке нормализатора сырые данные уже были в S3. ### D-5. Нормализатор — интерфейс; в Срезе 0 сводит ошибки по файлам и отвечает `NOT_IMPLEMENTED` `Normalizer.normalize(raw: object) -> NormalizeOutcome(status, documents, error, metrics)` (`raw` — склеенный ответ, разобранная структура D-4; JSON каждого файла разобран при склейке, не-объект на входе -> `INVALID_FORMAT`). `NullNormalizer` относит файл к «ошибочным», если `parse_error` не `null` либо `response.error.code` — непустая строка; **успех файла — `error.code == ""`** (реальный успешный ответ ИС несёт `error = {"code": "", "message": ""}`, поэтому проверка «`error` не `null`» неверна; отсутствующий или `null` `error` ошибкой не считается). Если ошибочны **все** файлы — `status = error`, `error.code` — код первого ошибочного файла по имени (коды Content AI совпадают с перечнем контракта; для `parse_error` — `INVALID_FORMAT`; код вне перечня — `UNKNOWN_ERROR` с исходным кодом в сообщении), `error.message` — его сообщение; `metrics.warnings` — по строке на каждый ошибочный файл вида `<имя>: <код>: <сообщение>`. Если есть хотя бы один успешный файл — `status = error`, `error.code = NOT_IMPLEMENTED` с сообщением «нормализация ответа ИС Сканирование реализуется в Срезе 3», ошибочные файлы — в `metrics.warnings` так же. Срез 3 подменяет реализацию (§11, OQ-13/14), транспорт не меняется. ### D-6. Эмулятор — тот же репозиторий и образ, другой entrypoint `python -m src.scan_emulator`: цикл по `Input/` каждую секунду; новый файл `_.` → набор фикстур из `SCAN_FIXTURES_DIR`: все `.json` и `_*.json` (не префикс `*`: `aep_3` не должен забирать `aep_30*`) → через `SCAN_EMULATOR_DELAY` запись набора в `Output//` под именами `.json` для `.json` и `_.json` для `_.json`, с подстановкой `file_uid = `. Набор появляется в `Output//` атомарно целиком: пишется во временный каталог эмулятора, затем каталог переименовывается. Нет пары → один ответ 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 → `parse_error` файла (D-4), а если ошибочны все файлы — `INVALID_FORMAT` (D-5); прочее → `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` (в `Input` нет `.part`; `poll` — нет каталога, один файл, два файла, дописываемый файл, посторонний файл); склейка и `NullNormalizer` — на реальных `aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`; эмулятор запускается в том же процессе (его функция «один проход») — end-to-end «put → эмулятор → poll (с settle) → склейка» без 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_3.pdf` → ack, затем `NOT_IMPLEMENTED` с `metrics.warnings` про `aep_3_ofr.json` и `raw/{job_id}.json` с двумя файлами; `aep_6.pdf` → `NOT_IMPLEMENTED` без warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/` в MinIO. Синтетический успешный ответ (R-10) не нужен: реальные успешные образцы `aep_3.json`, `aep_6.json` добавлены человеком 04.10.2026 (OQ-76). ### 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+. - [Эмулятор знает только пары из `fixtures/ocr`] → ошибка `INVALID_DOCUMENT` для остальных — тоже тестовый сценарий; новые пары и новые файлы набора (`_*.json`) добавляются в `fixtures/ocr` без изменения кода. - [ИС пишет набор с паузой больше `SCAN_SETTLE_SECONDS` между файлами] → шлюз склеит неполный набор, недостающие документы потеряны (повторов нет, OQ-15) → значение настраивается; проверить на стенде в Срезе 3. - [`Tmp` и `Input` на разных ФС/шарах] → `rename` перестаёт быть атомарным → `SCAN_TMP_DIR` обязан лежать на той же шаре. ## Migration Plan Нет данных. Удаление каталогов — отдельным первым коммитом. Откат — revert. ## Open Questions Общие с родительским change. Дополнительно (не блокирует): фактические имена входящего/исходящего/служебного каталогов, путь SMB и подходящее `SCAN_SETTLE_SECONDS` на стенде ИС Сканирование — нужны к Срезу 3. OQ-76/77/78/79 решены человеком 04.10.2026 (см. `open-questions-proposal.md`). ## 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` **[частично заменено №57–58]** | честный статус; коды Content AI совпадают с контрактом | нет | | 6 | 2026-10-03 | D-6: эмулятор в этом репозитории, тот же образ, поиск фикстуры по имени/префиксу, `.done` после обработки **[частично заменено №61–62]** | общие соглашения горячих папок; `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` **[частично заменено №59: `.part` — в `SCAN_TMP_DIR`]** | неоднозначность плана («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//`) **[заменено №54–55 и №59]** | спека; реальная ИС может забирать файлы без маски — риск вынесен в `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` — объект с пустыми полями (`form_name` пусто, `confidence 0.0`) и `table_structure: []`), поэтому путь «успех -> `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` **[запись ответа — частично заменено №62: набор целиком через временный каталог]** | контейнеру эмулятора не нужны переменные 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` = отсутствует (текст по умолчанию) **[частично заменено №57]** | 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 **[в части перечитывания не-JSON ответа заменено №56]** | экономия ИС; недописанный ответ на шаре без атомарного 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) | | 49 | 2026-10-04 | R-21: финальная волна исправлений = ровно список финального ревью (C-1, I-1, T12.1, T12.4, T7.3+T7.5, T2.2, неточности документов, строки в предложении для docs); I-2 (compose-фрагмент не применён) и I-3 (replay с `attempt+1` для `job_id` из LRU) оставлены человеку | второй волны нет; I-2 и I-3 меняют спеку/другой репозиторий | остальные находки — follow-up в `open-questions-proposal.md` | | 50 | 2026-10-04 | C-1: пакет `src` не создаёт приложение при импорте (`api/src/__init__.py` без импортов; `run_server.py` импортирует `src.app`); эмулятор стартует без `OPENBAO__*` и завершается кодом 2 при отсутствии корня горячей папки | R-11: контейнеру эмулятора не нужны настройки шлюза; обнаружено финальным ревью, тесты работали с полным `.env.test` | любой код, импортировавший `src:app`, должен перейти на `src.app:app` | | 51 | 2026-10-04 | I-1: `make up` после `up -d --wait postgres nats minio` выполняет `run --rm minio-init` (идемпотентно) | на свежем томе бакет `arm-ka` иначе не создаётся и `make check-full` падает на `NoSuchBucket` | `tabular-parser` — тот же пробел (чужой репозиторий, в предложении) | | 52 | 2026-10-04 | T12.1: `SCHEMA_MISMATCH` не публикуется для `job_id` (нормализованного через `uuid.UUID`), который в работе или в LRU | тот же `Nats-Msg-Id`, что у настоящего результата, привёл бы к потере настоящего результата при дедупликации ядром | повторный битый запрос того же задания остаётся без ответа (лог warning) | | 53 | 2026-10-04 | T7.3: `inbox_name` укорачивает основу имени так, чтобы `._<имя>.part` ≤ 240 байт UTF-8 (по code points, расширение ≤ 16 символов сохраняется, `surrogatepass`); `put` подавляет `OSError` при удалении `.part` | ENAMETOOLONG на ext4/NTFS давал ложный `RECOGNITION_FAILED` на допустимое имя; имя для ИС значения не имеет | эмулятор не найдёт фикстуру по усечённому имени (> ~200 байт — нереалистично) | | 54 | 2026-10-04 | Готовность набора ответов: `poll` отдаёт список, когда есть хотя бы один `*.json` и набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (2); таймаут задания по-прежнему `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`, ненастоявшийся набор на дедлайне — `TIMEOUT` | OQ-78 (решение человека: читать все json, готовность — по стабилизации) не задаёт момента готовности; ИС кладёт файлы без общего маркера завершения | ИС пишет набор с паузой > 2 с — часть документов потеряна, повторов нет (OQ-15); значение настраивается | | 55 | 2026-10-04 | Склеенный ответ `{"job_id", "files": [{"name", "response", "parse_error"}]}`, файлы по возрастанию имени, в `files` — все `*.json` каталога (другие игнорируются); `raw/{job_id}.json` = склеенный ответ; `raw_s3_key` остаётся одной строкой, контракт v1.0 не меняется | OQ-78 требует «склеить в один ответ»; структура — наша; сохранение имён файлов нужно для разбора инцидентов и `metrics.warnings` | Срез 3 нормализатор читает склеенную структуру, а не одиночный ответ ИС — единственный вход, переделка локальна | | 56 | 2026-10-04 | R-24: файл, не разобранный `json.loads` после настаивания набора, не перечитывается и не роняет задание: `response = null`, `parse_error` заполнен; при ошибочности всех файлов — `INVALID_FORMAT` | набор уже стабилен — повторное чтение не изменит файл; перечитывание «не-JSON» из решения №46 имело смысл для единственного файла без атомарного rename и заменено settle | недописанный, но стабильный 2 с файл будет принят как `parse_error`, а не дождётся конца записи | | 57 | 2026-10-04 | Успех файла — `error.code == ""`; ошибочный — непустой `error.code` либо `parse_error`; отсутствующий/`null` `error` ошибкой не считается; не-объект на корне склейки → `INVALID_FORMAT`; код вне перечня контракта → `UNKNOWN_ERROR` с исходным кодом в сообщении (R-12 сохранён) | образцы `aep_3.json`, `aep_6.json` (OQ-77): у успеха `error = {"code": "", "message": ""}`; проверка `error != null` принимала успех за ошибку | ИС, возвращающая `error` строкой/другого типа, будет трактоваться по правилу «нет `error.code` — успех» | | 58 | 2026-10-04 | Сводка по файлам в `NullNormalizer`: все файлы ошибочны → `status = error`, `error.code`/`message` первого ошибочного файла по имени (для `parse_error` — `INVALID_FORMAT`); есть хотя бы один успешный → `error.code = NOT_IMPLEMENTED`; каждый ошибочный файл — строка `<имя>: <код>: <сообщение>` в `metrics.warnings` в обоих случаях | OQ-78: ошибка одного документа не отменяет остальные; `NOT_IMPLEMENTED` — единственный честный статус, пока нормализации нет (Срез 3); `metrics.warnings` уже есть в контракте (`array`), расширять контракт не нужно | потребитель, ждущий `error.code` ошибочного файла при частичном успехе, увидит `NOT_IMPLEMENTED` — до Среза 3 различия не нужны | | 59 | 2026-10-04 | `SCAN_TMP_DIR` (`Tmp`) — служебный каталог на корне горячей папки; `.part` создаётся в `<корень>/Tmp/`, каталог создаётся при записи, `rename` в `Input/`; обе реализации (`LocalHotFolder`, `SmbHotFolder`) | OQ-79 (решение человека: временный файл — вне `Input`); корень остаётся единственной шарой, чтобы `rename` был атомарным | `Tmp` на другой ФС/шаре — `rename` не атомарен; ИС с маской, захватывающей `Tmp`, — неизвестно, проверить на стенде | | 60 | 2026-10-04 | `Normalizer.normalize(raw)` получает склеенный ответ (D-4), а `json.loads` каждого файла выполняет функция склейки `merge_answers` (на стороне воркера); воркер больше не отображает «не JSON» в `INVALID_FORMAT` сам — это делает нормализатор по `parse_error` | R-12 писал вход как «ответ ИС» (один файл); с несколькими файлами решение «ошибка или нет» принимается по набору | нет | | 61 | 2026-10-04 | Правило выбора фикстур эмулятора: `.json` и `_*.json` (не префикс `*`); имена в каталоге ответа — `.json` и `_.json`; `aep_8.pdf` → `aep_8_form4.json` покрывается `_*` | `*` забрал бы `aep_30*` при запросе `aep_3`; набор из нескольких файлов нужен для `aep_3.json` + `aep_3_ofr.json` | фикстура с именем вида `-x.json` не будет найдена | | 62 | 2026-10-04 | R-26: набор ответов эмулятор записывает во временный каталог и переименовывает каталог в `Output//` целиком; «готовность по settle» в шлюзе проверяется отдельным тестом (файл дописывается во время опроса) | шлюз не должен видеть частичный набор от эмулятора; поведение settle всё равно нужно тестировать на подмене | rename каталога на нестандартной ФС (compose-том) может быть неатомарным — тогда набор виден по частям, settle закрывает |