# 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` = `:` #### 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::` совпал бы с настоящим результатом, и ядро отбросило бы настоящий как дубликат); запрос записывается в лог и отбрасывается #### 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::`. Ядро 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::` #### 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 `<лист>!`, имя листа в кавычках по правилу 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` на каждое сообщение (запрос `:`, ack `ack::`, результат `result::`). Поскольку серверного дедупа нет, ядро 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`); пара — `docs/contracts/<имя>.v.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/<имя>.json`, если такой файл есть, иначе `docs/contracts/examples/<имя>.json`; - **прочие файлы копии** (`<имя>.v.schema.json`, `examples/v/*.json` — их держит ядро на окне) — с одноимённым файлом docs; нет его в docs — расхождение; - **набор:** каждой схеме и каждому примеру верхнего уровня docs (`contracts/*.schema.json` без `.v`, `contracts/examples/*.json`) SHALL соответствовать файл копии; файлы docs `*.v.schema.json` и `examples/v/` требуются в копии, только если копия держит этот `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`) — поэтому порядок «сначала ядро» обязателен