Andrey Epifancev 47e6372cbe chore(openspec): архив unified-statement-normalization (#116)
Спеки применены: 5 новых capability (arm-core, arm-web, scan-gateway,
statement-normalization, tabular-recognition-service) с Purpose, изменения
quality-checks и recognition-contract. Ссылки в 05-domain-rules, DBML v5.19,
entities, dev/checks.md и dev/qa-statements-v2.md ведут в архив.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:25:43 +04:00

338 lines
35 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.

# recognition-contract Specification
## Purpose
Контракт обмена между ядром `arm-core` и stateless-сервисами распознавания (`tabular-parser`, `scan-gateway`):
тела сообщений NATS на subject'ах запроса и ответа (настройки окружения `OPENBAO__NATS__NATS_*_SUBJECT`, по умолчанию
`files.tabular_uploaded`, `files.img_uploaded` и `files.tabular_parsed`), файл результата в S3 и правила версионирования и
идемпотентности. Сервисы не знают БД; ядро — единственный писатель (DECISION-4).
## Requirements
### Requirement: Сообщение запроса распознавания
Тело сообщения на subject запроса `tabular` или `image` (настройки окружения `NATS_TABULAR_REQUEST_SUBJECT` /
`NATS_IMAGE_REQUEST_SUBJECT`, по умолчанию `files.tabular_uploaded` / `files.img_uploaded`) SHALL соответствовать
`docs/contracts/recognition-request.schema.json` v1.0: обязательные поля `schema_version` (`"1.0"`), `job_id` (uuid =
`recognition_job.id`), `file_id` (uuid = `stored_file.id`), `pipeline` (`tabular` | `image`), `file_type`
(`XLS` | `XLSX` | `XLSB` | `PDF` | `PNG` | `JPG`), `original_name`, `s3 {bucket, key}` исходника в роли `uploads`,
`reply_deadline` (date-time, UTC), `attempt` (≥ 1). Subject SHALL соответствовать `pipeline`.
#### Scenario: Публикация запроса ядром
- **WHEN** ядро переводит `recognition_job` из `queued` в `published`
- **THEN** в subject, соответствующий `recognition_job.pipeline`, публикуется сообщение, проходящее схему запроса, с
заголовком `Nats-Msg-Id` = `<job_id>:<attempt>`
#### Scenario: Сообщение не по схеме
- **WHEN** сервис получает запрос, не проходящий схему (нет `s3.key`, неизвестный `file_type`), но из тела извлекаются
корректные `job_id` и `file_id`
- **THEN** сервис публикует результат со `status = error`, `error.code = SCHEMA_MISMATCH` **без предшествующего ack
`processing`** и не падает; `attempt` для `Nats-Msg-Id` результата берётся из тела запроса, при его отсутствии
реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
`scan-gateway` подставляет 1) — см. OQ-71
#### Scenario: Нечитаемый запрос
- **WHEN** сервис получает сообщение, из которого нельзя извлечь `job_id` (не JSON, не объект, `job_id` отсутствует или
не uuid), а значит нельзя собрать валидный конверт
- **THEN** сервис ответа не публикует: запрос записывается в лог и отбрасывается; ядро не получит ack, повторит
публикацию `OUTBOX_MAX_REPUBLISH` раз и переведёт задание в `dlq` (`error_code = NO_ACK`)
#### Scenario: Невалидный повтор запроса, уже находящегося в работе
- **WHEN** сервис получает запрос, не проходящий схему, но с `job_id`, который уже в работе или обработан этим процессом
- **THEN** конверт `SCHEMA_MISMATCH` не публикуется (его `Nats-Msg-Id` `result:<job_id>:<attempt>` совпал бы с настоящим
результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается
#### Scenario: Примеры контракта — иллюстрации
- **WHEN** запрос берётся из `docs/contracts/examples/request-*.json` (в `request-image.json` `reply_deadline` лежит в
прошлом)
- **THEN** он проходит схему: схема проверяет для `reply_deadline` лишь формат `date-time`, но не сравнивает срок с
текущим временем; пример — иллюстрация формы сообщения, а не готовое к исполнению задание; проверка срока — дело
сервиса (`scan-gateway` отвечает `error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование)
### Requirement: Подтверждение взятия в работу (ack без JetStream)
Получив корректный запрос (прошедший проверку схемы и `MAJOR`), сервис SHALL немедленно, до чтения файла из S3, опубликовать в `files.tabular_parsed` конверт со
`status = processing`, `job_id`, `file_id`, `service {name, version}` и заголовком `Nats-Msg-Id = ack:<job_id>:<attempt>`.
Ядро SHALL перевести задание `published → processing` и записать `started_at`, `service_name`, `service_version`. Если
ack не получен за `ACK_TIMEOUT_SECONDS` (по умолчанию 30), ядро SHALL опубликовать запрос повторно с тем же
`Nats-Msg-Id` и тем же `attempt`, не более `OUTBOX_MAX_REPUBLISH` (по умолчанию 3) раз, после чего перевести задание в `dlq`.
#### Scenario: Сервис жив
- **WHEN** запрос опубликован и сервис подписан
- **THEN** в течение `ACK_TIMEOUT_SECONDS` в `files.tabular_parsed` появляется конверт `processing`, задание получает
`status = processing` и `started_at`
#### Scenario: Сервис недоступен
- **WHEN** ни один инстанс сервиса не подписан на subject запроса
- **THEN** ядро публикует запрос повторно каждые `ACK_TIMEOUT_SECONDS`; после `OUTBOX_MAX_REPUBLISH` повторов задание
получает `status = dlq`, `request_msg_id` не меняется
#### Scenario: Повторный запрос пришёл в работающий сервис
- **WHEN** сервис получает запрос с `job_id`, который уже обрабатывается или обработан этим процессом
- **THEN** сервис публикует ack повторно и не начинает вторую обработку
### Requirement: Сообщение результата распознавания
Тело сообщения на subject ответа (настройка окружения `NATS_RESULT_SUBJECT`, по умолчанию `files.tabular_parsed`)
SHALL соответствовать `docs/contracts/recognition-result-message.schema.json`
v2.0: обязательные поля `schema_version` (`"2.0"`), `job_id`, `file_id`, `status` (`processing` | `done` | `needs_check` |
`error`), `service {name, version}`; при `status ∈ {done, needs_check}` обязательны `result_s3 {bucket, key}` и
`result_schema_version`; при `status = error` обязателен `error {code, message}`; опциональны `raw_s3_key`
(только `scan-gateway`) и `metrics`. `metrics.warnings[]` — структуры `warning` (как в файле результата), не строки.
Поле `group_schema` в конверт NOT включается — источник `recognition_job.group_schema` — файл результата.
Производитель v2.0 SHALL писать в конверт тот же MAJOR, что и в файл результата.
#### Scenario: Успешный результат
- **WHEN** сервис завершил обработку файла
- **THEN** файл результата записан в S3 по ключу `results/{file_id}/{job_id}/result.json`, а затем опубликован конверт
со `status = done | needs_check`, `result_s3` указывает на этот файл, `result_schema_version = "2.0"`, заголовок
`Nats-Msg-Id` = `result:<job_id>:<attempt>`
#### Scenario: Ошибка обработки
- **WHEN** сервис не смог обработать файл (нечитаем, таймаут внешней системы, тип не поддерживается)
- **THEN** публикуется конверт со `status = error` и `error.code` из перечня схемы (`UNSUPPORTED_FORMAT`, `UNREADABLE`,
`TIMEOUT`, `SCHEMA_MISMATCH`, `NOT_IMPLEMENTED`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`,
`RECOGNITION_FAILED`, `UNKNOWN_ERROR`); `result_s3` может отсутствовать
#### Scenario: Предупреждения в конверте
- **WHEN** у файла есть предупреждение уровня файла (ошибочный json набора ИС)
- **THEN** `metrics.warnings` конверта содержит объект `{code: "JSON_FAILED", severity: "error", message, locator}`
### Requirement: Файл результата
Файл результата SHALL соответствовать `docs/contracts/recognition-result.schema.json` v2.0: один файл (`stored_file`)
→ один `job_id` → массив `documents` из `osvDocument` и/или `statementDocument`; денежные значения — строки
`^-?\d+(\.\d{1,2})?$`; `confidence` — 0–100. Пустой массив `documents` допустим только при `status = error`.
Предупреждение уровня файла пишется там, где документа нет (потерянный json, таблица ИС, исключение разбора таблицы,
лист «прочее»).
`service.statements_version` — коммит копии общего кода `arm_statements`, которым посчитан результат (обязателен при
наличии `statementDocument`). `metrics.warnings[]` — предупреждения уровня файла (структура `warning` с `locator`).
Каждый документ (и `osvDocument`, и `statementDocument`) SHALL иметь `status` (`done` | `needs_check`) и `warnings[]` —
структуры `{code, severity, message, line_refs?, column_index?, field?}` (`line_refs` — только `ref` строк из `lines[]`);
у `osvDocument` все предупреждения — `info`, `status = done`, когда контрольные суммы §1.4 листа сошлись, иначе
`needs_check`. Остальное в `osvDocument` — как в v1.2: у строки `rows[]` необязательное поле `extra` — объект «заголовок
колонки → строка или null» для колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»); у
шапки — `period_start`, `period_end`, `is_from_year_start`.
`statementDocument` SHALL иметь `form_variant` (`full` | `simplified` | `null`); `header` — поля `organization_name`,
`inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` и для каждого `<поле>_text` и
`<поле>_confidence`, плюс `needs_review_fields[]`; `columns[]` — `column_index` (номер столбца в сетке кандидата: у OCR
— `column_index` ИС, у Excel — номер столбца листа с 0), `label`, `label_confidence`,
`period_date`, `period_kind` (`absolute` | `relative:current` | `relative:prev_year_end` |
`relative:prev_prev_year_end` | `relative:prev_period` | `none`); `lines[]` — `row_index`, `ref` (у Excel `<лист>!<A1>`,
имя листа в кавычках по правилу Excel), `line_status` (`ok` |
`unreadable` | `missing` | `detail`), `line_code` (строка 4–5 цифр или `null`; `null` ⇔ статус не `ok`),
`line_code_text`, `line_code_confidence`, `line_name` (строка или `null`), `line_name_confidence`, `parent_ref` (у
`detail`), `needs_review`, `values[]` — `column_index`, `amount`, `string_value`, `text`, `confidence`,
`is_recognized`, `needs_review`. `checks` SHALL присутствовать у `BALANCE` / `INCOME` со строками и отсутствовать у
документа без строк; `relations_failed` и `soft_relations_failed` — `{column_index, relation, codes[], expected,
actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `INCOME` | `DISCLOSURE` (`OTHER` в v2.0 не
порождается: лист «прочее» — предупреждение файла `SHEET_OTHER`, документа нет; значение остаётся только в схеме 1.x и в
`document_type` ядра для документов прежних результатов); `form_variant` обязателен (`null` у `DISCLOSURE`); у документа
без строк `columns` и `lines` — пустые массивы. `ref` уникален в документе.
#### Scenario: Результат валидируется примерами
- **WHEN** CI сервиса запускает тесты контракта
- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата v2.0, а все
`request-*.json` / `result-message-*.json` — схемы сообщений
#### Scenario: Лишняя колонка сохранена
- **WHEN** в ОСВ между ИНН и суммами есть колонка «КПП»
- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v2.0
#### Scenario: Форма из Excel с проверками
- **WHEN** tabular-parser разобрал книгу с балансом на двух листах, ОФР со строкой без кода под 2110 и пояснениями
(`examples/result-statement-excel.json`)
- **THEN** файл проходит схему v2.0: `BALANCE` с `locator` из двух листов, `status`, `checks` и мягким нарушением;
`INCOME` со строкой `detail` (`line_code = null`, `parent_ref`); `DISCLOSURE` с пустыми `columns`/`lines` и без `checks`
#### Scenario: Форма из OCR с сомнительными значениями
- **WHEN** scan-gateway разобрал ББ, где одна сумма собрана из грязного текста и покрыта соотношением, а одна строка
`unreadable` (`examples/result-statement-minimal.json`)
- **THEN** файл проходит схему v2.0; у покрытого значения `is_recognized = false`, `needs_review = false`, `text` —
сырой; у строки `unreadable` `line_code = null`, `line_code_text` — сырой текст; документ `needs_check`
#### Scenario: Границы периода ОСВ
- **WHEN** в шапке ОСВ период «Октябрь 2024 г. – Сентябрь 2025 г.»
- **THEN** `period_start = "2024-10-01"`, `period_end = "2025-09-30"`, `is_from_year_start = false`; документ ОСВ с
`status` и `warnings`, файл проходит схему v2.0
#### Scenario: Документ OTHER в v2.0
- **WHEN** в результате v2.0 `statementDocument` с `document_type = OTHER` или без `form_variant`
- **THEN** файл не проходит схему v2.0
#### Scenario: Строка без кода с кодом в поле
- **WHEN** у строки `line_status = unreadable` передан непустой `line_code`
- **THEN** файл не проходит схему v2.0
### Requirement: Версионирование контракта
Поле `schema_version` SHALL быть строкой `MAJOR.MINOR`. Добавление необязательных полей или значений enum —
`MINOR` (обратно совместимо); удаление/переименование полей, смена типов или семантики — `MAJOR`.
Сервис SHALL отвергать запрос с неизвестным `MAJOR` (`error.code = SCHEMA_MISMATCH`); ядро SHALL принимать результат
с известным `MAJOR` и любым известным или новым `MINOR`. Запрос `recognition-request` остаётся v1.0; файл результата и
конверт — v2.0 (`MAJOR` поднят: `line_code` стал nullable, `metrics.warnings` и `document.warnings` — структуры, удалён
`checks.duplicate_line_codes`, изменён смысл `is_recognized` у OCR). На окне перехода схемы прежнего `MAJOR` SHALL лежать
в `docs/contracts/` рядом с текущими как `recognition-result.v1.schema.json` и `recognition-result-message.v1.schema.json`
(байтовые копии 1.2 и 1.0), их примеры — в `docs/contracts/examples/v1/`; `scripts/validate_contracts.py` проверяет оба
набора. Они удаляются тем же изменением, что удаляет маппер `1.x` ядра.
#### Scenario: Несовместимая версия запроса
- **WHEN** сервис, поддерживающий запрос `1.x`, получает запрос `schema_version = "2.0"`
- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH` **без ack `processing`**: ответом на
запрос служит сам финальный конверт, ядро закрывает им задание (`published → error`), и повторной доставки запроса не
будет. Конверт публикуется только если из тела извлекаются корректные `job_id` и `file_id`; иначе — как для нечитаемого
запроса (ответа нет, ядро закроет задание по `NO_ACK` / `dlq`). `attempt` для `Nats-Msg-Id` берётся из тела запроса; при его
отсутствии реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает,
`scan-gateway` подставляет 1) — см. OQ-71
#### Scenario: Ядро получает MAJOR, которого не знает
- **WHEN** ядро, знающее `1.x` и `2.x`, получает конверт или файл результата `schema_version = "3.0"`
- **THEN** результат не проходит проверку схемы, файл получает `ERROR` с причиной в `metrics.mapping_error`, повтор не
выполняется (как для любого результата не по схеме)
### Requirement: Идемпотентность результатов без брокера
Публикующий SHALL ставить заголовок `Nats-Msg-Id` на каждое сообщение (запрос `<job_id>:<attempt>`, ack
`ack:<job_id>:<attempt>`, результат `result:<job_id>:<attempt>`). Поскольку серверного дедупа нет, ядро SHALL считать
результат дубликатом, если `recognition_job.result_msg_id` уже равен `Nats-Msg-Id` сообщения, и MUST NOT менять задание
повторно; потерянный результат SHALL обнаруживаться по `reply_deadline` (`timeout`).
#### Scenario: Результат доставлен дважды
- **WHEN** сервис опубликовал один и тот же результат два раза (повтор публикации после сетевой ошибки)
- **THEN** задание изменено один раз, второе сообщение проигнорировано
### Requirement: Повторы публикации ответов сервисом
Сервис SHALL публиковать ack и результат с заголовком `Nats-Msg-Id` и при временной ошибке публикации (потеря соединения,
таймаут подтверждения сервера) повторять публикацию с тем же `Nats-Msg-Id`: **одна первая попытка и до
`PUBLISH_RETRIES` повторов** (значение по умолчанию 3, то есть не более 4 попыток), с паузой между попытками; пауза —
деталь реализации. Повторы исчерпаны — сервис пишет error-лог и не публикует это сообщение снова; задание закрывается
на стороне ядра (не пришёл результат — `reply_deadline` → `timeout`; не пришёл ack — повтор запроса и `dlq`, но результат,
дошедший без ack, ядро принимает и в статусе `published`). Дубликаты, появившиеся из-за повтора,
безопасны: ядро дедуплицирует их по `Nats-Msg-Id`.
#### Scenario: Временный сбой публикации
- **WHEN** публикация результата упала с временной ошибкой, а следующая попытка успешна
- **THEN** сообщение опубликовано с прежним `Nats-Msg-Id`; ядро изменит задание один раз, даже если первая попытка тоже дошла
#### Scenario: Повторы исчерпаны
- **WHEN** все 1 + `PUBLISH_RETRIES` попыток публикации результата неуспешны
- **THEN** сервис пишет error-лог и продолжает работу (процесс не падает), а ядро закрывает задание по `reply_deadline`
### Requirement: Копия контракта в сервисе идентична источнику
Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check`
их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением).
Файл копии SHALL сверяться с файлом docs по правилу, одинаковому у ядра и сервисов и верному до, во время и после окна
перехода между `MAJOR`:
- **схема** `<имя>.schema.json` копии: `M` — номер из её `$id` (`…/v<M>`); пара — `docs/contracts/<имя>.v<M>.schema.json`,
**если такой файл есть**, иначе основной `docs/contracts/<имя>.schema.json` (у `recognition-request` файла `.v1` нет
— его копия `…/v1` сверяется с основным файлом; до слияния v2.0 в docs файлов `.v1` нет вовсе — все копии сверяются с
основными);
- **пример** `examples/<имя>.json` копии: вид — по префиксу имени (`request-` — запрос, `result-message-` — конверт,
`result-` — файл результата), `M` — `MAJOR` копии схемы этого вида; пара — `docs/contracts/examples/v<M>/<имя>.json`,
если такой файл есть, иначе `docs/contracts/examples/<имя>.json`;
- **прочие файлы копии** (`<имя>.v<N>.schema.json`, `examples/v<N>/*.json` — их держит ядро на окне) — с одноимённым
файлом docs; нет его в docs — расхождение;
- **набор:** каждой схеме и каждому примеру верхнего уровня docs (`contracts/*.schema.json` без `.v<N>`,
`contracts/examples/*.json`) SHALL соответствовать файл копии; файлы docs `*.v<N>.schema.json` и `examples/v<N>/`
требуются в копии, только если копия держит этот `MAJOR` (ядро на окне); файлы другого `MAJOR` сервисом не
сверяются.
Правило вводится в тест идентичности **всех трёх** потребителей (`arm-core`, `tabular-parser`, `scan-gateway`) до
слияния контракта v2.0 в docs (шаг 0 выкладки); иначе их `make check` упадёт на окне перехода. Ядро держит копии обоих
`MAJOR` с PR шага 3; при удалении `MAJOR` 1 (шаг 6) ядро сначала удаляет свои копии `.v1`, затем docs — файлы `.v1`.
#### Scenario: Копия устарела
- **WHEN** в `docs/contracts/` изменена схема, а копия в сервисе — нет
- **THEN** тест идентичности в `make check` сервиса падает с указанием имени файла
#### Scenario: Сервис ещё пишет 1.x после слияния v2.0 в docs
- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, а копия в `tabular-parser` — v1.2 (`$id` `…/v1`)
- **THEN** тест идентичности сверяет копию с `recognition-result.v1.schema.json` и проходит; посторонний PR в сервис на
окне перехода не краснеет
#### Scenario: Сервис перешёл на 2.0
- **WHEN** `scan-gateway` в PR шага 5 обновил копию до v2.0 (`$id` `…/v2`) и примеры до v2.0
- **THEN** тест сверяет схему с основным файлом docs (файла `.v2` нет), примеры `result-*` — с `examples/*.json`;
`examples/v1/` и `*.v1.schema.json` docs не сверяются
#### Scenario: Запрос v1.0 после слияния v2.0
- **WHEN** в docs уже есть `recognition-result.v1.schema.json`, а `recognition-request.v1.schema.json` нет; копия
запроса в сервисе — v1.0 (`$id` `…/v1`), примеры `request-image.json`, `request-tabular.json`
- **THEN** копия запроса сверяется с основным `recognition-request.schema.json`, примеры запроса — с
`examples/request-*.json` (в `examples/v1/` их нет); тест проходит
#### Scenario: Примеры прежнего MAJOR
- **WHEN** копия `tabular-parser` — v1.2, в ней `examples/result-statement-excel.json` версии 1.2, а в docs
`examples/result-statement-excel.json` уже 2.0, `examples/v1/result-statement-excel.json` — 1.2
- **THEN** пример копии сверяется с `examples/v1/result-statement-excel.json` и проходит
#### Scenario: Шаг 0 до слияния docs
- **WHEN** тест с новым правилом слит в `arm-core`, `tabular-parser` и `scan-gateway`, а docs ещё на v1.2 без файлов `.v1`
- **THEN** все копии сверяются с основными файлами и примерами верхнего уровня, тест проходит, как прежде
#### Scenario: Ядро на окне перехода
- **WHEN** копия ядра после шага 3 держит основные схемы v2.0, `*.v1.schema.json` и `examples/v1/`
- **THEN** основные сверяются с основными docs, `.v1` и `examples/v1/` — с одноимёнными файлами docs; набор совпадает
### Requirement: Subject'ы NATS — настройки окружения
Subject'ы обмена SHALL задаваться секретами группы `OPENBAO__NATS__` с одинаковыми ключами во всех трёх сервисах:
`NATS_TABULAR_REQUEST_SUBJECT` (по умолчанию `files.tabular_uploaded`), `NATS_IMAGE_REQUEST_SUBJECT` (по умолчанию
`files.img_uploaded`), `NATS_RESULT_SUBJECT` (по умолчанию `files.tabular_parsed`). У ядра и сервисов одного окружения
значения MUST совпадать. Каждый сервис SHALL при старте проверять все три имени: непустое, без пробельных символов, без
шаблонов `*` и `>`, без пустых токенов, три имени попарно различны; при нарушении сервис MUST NOT стартовать.
Учётные данные NATS (`OPENBAO__NATS__NATS_JWT`, `OPENBAO__NATS__NATS_SEED`) SHALL задаваться по окружению теми же
секретами, и их значения MUST NOT попадать в логи, `repr` настроек и тексты ошибок валидации конфигурации. Каждый
сервис SHALL писать используемые subject'ы в лог при старте и отдавать их в `/healthcheck` объектом `subjects`
(ключи `tabular_request`, `image_request`, `result` — только те, что использует сервис) независимо от состояния NATS.
Queue group'ы и `schema_version` от настроек subject'ов не зависят.
#### Scenario: Умолчания
- **WHEN** сервис стартует без ключей `OPENBAO__NATS__NATS_*_SUBJECT`
- **THEN** обмен идёт по `files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`
#### Scenario: Набор тестового окружения
- **WHEN** всем трём сервисам заданы одинаковые `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`,
`NATS_IMAGE_REQUEST_SUBJECT`, `NATS_RESULT_SUBJECT`, `NATS_JWT`, `NATS_SEED` тестового окружения
- **THEN** ядро публикует запросы и получает ack и результаты только по этим subject'ам; subject'ы по умолчанию не
используются, `recognition_job.request_subject` содержит subject тестового окружения
#### Scenario: Недопустимый набор
- **WHEN** `NATS_RESULT_SUBJECT` совпадает с одним из subject'ов запроса, или имя содержит `*`, `>` или пробел
- **THEN** сервис не стартует с ошибкой конфигурации, называющей ключ
#### Scenario: Разъехавшиеся настройки видны
- **WHEN** у ядра и сервиса заданы разные subject'ы ответа
- **THEN** `/healthcheck.subjects.result` ядра и сервиса различаются; JWT и seed в ответе `/healthcheck` отсутствуют
#### Scenario: Ошибка конфигурации не раскрывает JWT
- **WHEN** сервис не стартует из-за ошибки валидации настроек при заданных `NATS_JWT` и `NATS_SEED`
- **THEN** текст ошибки и лог не содержат значений JWT и seed
### Requirement: Переход на контракт v2.0 — «сначала ядро»
Ядро SHALL до перехода любого сервиса принимать конверты (включая ack `processing`) и файлы результата и `1.x`, и
`2.0`, с двумя мапперами файла результата. При приёме `1.x` статус документа формы SHALL считаться по прежнему правилу
D-6, где «проверки §7 сошлись» — по расчёту ядра копией `arm_statements` (полный набор), записанному в `document_issue`,
а поля v2.0 заполняться по умолчанию: `line_status = ok`, `row_index` — по порядку строк в результате с 0, `ref =
null`, `line_code_original_value = line_code`, `text = string_value`, `needs_review` значения = **НЕ** `values_ok`
(`values_ok` — `is_recognized` не `false` и `confidence` 100 или пуста: чистое значение — `needs_review = false`),
`needs_review` строки — `false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant` — по
структуре кодов (точка входа пакета для пересчёта, спека `arm-core`), `file_ordinal` — индекс в `documents[]`; строки `metrics.warnings` — в `recognition_job.metrics.warnings`
структурами `{code: "LEGACY_WARNING", severity: "info", message}`; предупреждения маппера по документу —
`document_issue` `MAPPING_WARNING` (`info`); `statements_version` задания — `null`. Сервисы SHALL переходить на `2.0`
только после выкладки ядра, принимающего `2.0`. Маппер `1.x` и схемы `*.v1.schema.json` удаляются после перехода обоих
сервисов отдельным изменением.
#### Scenario: Сервис ещё на v1.2
- **WHEN** после выкладки ядра tabular-parser присылает результат `1.2` с повтором кода и `checks.duplicate_line_codes`
- **THEN** ядро принимает его маппером `1.x`: из двух строк с одним кодом хранится первая (прежнее правило маппера
`1.x`), вторая не хранится, у документа `MAPPING_WARNING` о повторе; нарушения §7 — в `document_issue` от ядра, статус
документа — по D-6, файл не `ERROR`
#### Scenario: Чистое значение 1.x
- **WHEN** в результате `1.2` значение с `confidence = 100` и `is_recognized = true`
- **THEN** `needs_review = false`; значение с `confidence = 60` — `needs_review = true`
#### Scenario: Ack нового сервиса
- **WHEN** scan-gateway после перехода публикует ack `processing` со `schema_version = "2.0"`
- **THEN** ядро переводит задание в `processing`, как для ack `1.0`
#### Scenario: Порядок нарушен
- **WHEN** сервис прислал результат `2.0` с `line_code = null` ядру, знающему только `1.x`
- **THEN** результат не проходит проверку схемы, файл получает `ERROR` (`metrics.mapping_error`) — поэтому порядок
«сначала ядро» обязателен