## Context Сквозной дизайн — change docs `unified-statement-normalization` (`design.md`, `specs/arm-core/spec.md`, план `plans/2026-10-18-plan-3-arm-core.md`). Здесь — решения уровня ядра для части Б; решения части А — в `openspec/changes/unified-normalization-core-a/design.md` (D-1…, A1-1…A5-6, P3-1…P3-24). Схема данных — `../docs/model/data-model-v5.19.dbml`; код части А уже в `main`. ## Goals / Non-Goals Goals и Non-goals — см. `proposal.md`. ## Decisions - **D-1. Единственная точка входа в пакет.** `import arm_statements` — только `api/src/services/statements_engine.py` (протокол `StatementsEngine` и его реализация) и `api/src/domain/stored_form.py`; остальной код ядра работает с протоколом. - **D-2. §7 хранится только как `document_issue`.** `GET …/statement` и `POST …/confirm` читают нарушения из записей; временный расчёт на лету (P3-14 части А) удаляется в B6. - **D-3. Архивация вместе с частью А (R-7 части А).** `unified-normalization-core-b` архивируется только вместе с `unified-normalization-core-a` и только после выкладки обеих частей; сначала архивируется А, затем Б (дельты Б MODIFIED опираются на требования, которые А добавляет). - **D-4. Дельта «копия словаря §7 ядра» оформлена как MODIFIED.** Требования с таким именем в `openspec/specs/` нет: упоминание копии живёт в «Соотношения §7 по колонкам»; поэтому удаление выражено правкой этого требования (и пунктом «Словари ядра» в `documents-api`), а не `REMOVED`. ## Decisions made without discussion Решения, принятые без обсуждения с человеком на этапе плана и исполнения части Б (маркеры `P3-n` — из плана 3, `R-n` — рулинги контроллера, `Р-n` — решения владельца). Таблица дополняется задачами B1–B6. | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---| | 2026-10-18 | **R-1 (Р-40).** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для кодов из ровно четырёх цифр; пятизначные подстроки Excel не проверяются. **P3-7 плана в этой части заменён** (запись на код документа сохраняется, пятизначные коды — нет) | решение владельца 18.10.2026 (Р-40) перед исполнением Б; закрывает O-3 (поток `info` у пятизначных) | пропущенный `info` у нестандартного пятизначного кода; восстановление — снять условие длины кода в маппере 2.0 и пересчёте | | 2026-10-18 | **R-2 (Р-34).** Подтверждение кода строки — флаг `is_confirmed`; `line_code_confidence` и `line_code_modified` не меняются (P3-16 части А) | решение владельца 18.10.2026; в пересчёте Б правило `is_confirmed ⇒ needs_review строки = false` берётся из P3-16 | нет — поведение уже реализовано в части А | | 2026-10-18 | **R-3.** Удаление копии словаря §7 в дельте оформлено как MODIFIED «Соотношения §7 по колонкам», а не REMOVED (см. D-4); «Ответ `GET …/statement`» и порядок 409 оформлены новыми ADDED-требованиями, так как в `openspec/specs/` и дельтах части А одноимённых нет | `openspec archive` требует, чтобы имя REMOVED/MODIFIED существовало; иначе архив упадёт | при архивации — правка имён заголовков дельт | | 2026-10-18 | **P3-25 (B1).** Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника | `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов | форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form` | | 2026-10-18 | **P3-26 (B1, O-3 часть А).** Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs | второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py` | | 2026-10-18 | **P3-27 (B1).** `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный | как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`) | редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition` | | 2026-10-18 | **P3-28 (B2).** `StatementService` получает `StatementsEngine` через зависимость `get_statements_engine` (тесты подменяют её `FakeEngine`); `FailedRelation`, `StatementChecks`, `PeriodKind` реэкспортируются из `services/statements_engine.py`, чтобы `import arm_statements` оставался только в двух файлах | единственная точка импорта пакета (common.md); подмена без патчей | нет | | 2026-10-18 | **P3-29 (B2).** Статус после правки — `form_status_v2`: `service_done = (result.status == DONE)`, `file_error_warning` — `metrics.warnings` задания, `names_ok` — нет `NAME_MISMATCH` пересчёта, `duplicate_in_file` — `duplicate_period_forms` по формам файла (`file_form_periods`); `statement_status` D-6 с пути правки убран | один источник статуса с приёмом `2.0` (Р-16, Р-18, Р-19) | форма с повтором периода в файле после правки остаётся `NEEDS_CHECK` — как при приёме | | 2026-10-18 | **P3-30 (B2).** Пустая цель при непустых членах — нарушение с `expected = NULL` (P1-21); тесты части А «очищенная 1700 не цель» и «цель потерянной строки не проверяется» переписаны под это; потерявшая строку 1700 форма блокирует подтверждение до правки или акцепта | следствие P1-21 пакета; спека («посчитанное соотношение») | форма, потерявшая итоговую строку, теперь требует действия аналитика — если владелец против, правится правило пакета (план 1), не ядро | | 2026-10-18 | **P3-31 (B2).** `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают записи `relation`; акцептованная форма не считается нарушенной (`statement_hard_failures` пуст при `checks_accepted_at`), поэтому повторный акцепт — 409 `NOTHING_TO_ACCEPT`, как в части А | сохранение поведения А; буква брифа («нет записей `relation`») допускает оба прочтения | повторный акцепт с новым комментарием станет возможен, если снять условие | | 2026-10-18 | **P3-32 (B2).** «Эффективная дата» для `relative:*` колонок при правке `header.report_date` — новое значение `meta.report_date` (в том числе `NULL` ⇒ `period_date = NULL`), не `document.reporting_date`; изменение дат колонок идёт в аудит ключом `column:` без признака правки данных | период документа сам выводится из даты отчёта и дат колонок (§9.5 п. 2), поэтому циклическая зависимость недопустима | колонка при пустой дате отчёта теряет дату; аналитик ставит её вручную | | 2026-10-18 | **P3-33 (B2).** Запись `relation` / `soft` несёт и `column_id` (колонка найдена по `column_key`: `str(column_id)`, затем `str(column_index)`), и `column_index` нарушения; ответ берёт индекс из колонки, иначе из записи, иначе `-1` | ключ колонки B4 для `2.0` — индекс | нет | | 2026-10-18 | **P3-34 (B2).** `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате | R-1 и спека (редакция документа — `edition` пакета) | пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными | | 2026-10-18 | **P3-35 (B2).** Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета | задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет | | 2026-10-18 | **P3-36 (B3).** `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое) | задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже | форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED` | | 2026-10-18 | **P3-37 (fix1, T2.1).** Аудит `lines[]` довносит смену `parent_row_index` после пересчёта: у правленных строк значение заменяется итоговым, у остальных появляется запись `line:` с одним ключом `parent_row_index` (`{"parent_row_index": старый}` → `{…: новый}`) | родителей пишет `recompute_form` уже после снимка строк правки; полный снимок для не правленных строк не нужен | в событии `RECOGNIZED_DATA_EDIT` у не правленных строк неполный набор ключей | | 2026-10-18 | **P3-38 (fix1, T1.1/T1.2/T2.4/T2.6/T2.8).** Паритет редакции приёма и пересчёта для строки с `line_code_confidence < 100` и `code_fixed` подтверждён тестом (расхождения нет, код не менялся); `edition_codes` пропускает строку `ok` без кода; `column_index` нарушения — общим помощником `issue_column_index` (запись, иначе колонка по `column_id`) в ответе и в `detail` отказа; `_unconfirmed_needs_review` и присвоения `needs_review` в `_apply_item` удалены (флаг пишет только пересчёт, он всегда идёт после правки строк); неизвестный `relative:*` в `_resolve_relative_columns` пропускается без ошибки | замечания ревью B1+B2 | колонка с неизвестным `relative:*` не следует за датой отчёта | | 2026-10-18 | **P3-39 (B4).** Проверки ядра `info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `code_issues`, `name_issues`, `version_issue`, `name_check_lines`) вынесены из `services/statement_recompute.py` в `domain/statement_issues.py`; единая функция `core_info_issues(lines, *, form, edition: str \| None, order: Collection[str] \| None, catalog) -> CoreInfo(drafts, mismatched)` используется приёмом `2.0` и `recompute_form` (поведение пересчёта не изменилось, P3-34). Домен пакет не импортирует: редакцию (`engine.edition`) и перечень кодов (`engine.code_order`) считает сервис; `statement_recompute` реэкспортирует прежние имена. Вместо сигнатуры плана `core_info_issues(*, lines, edition, form_variant, catalog, engine)` | домен не должен знать `StatementsEngine`/`arm_statements` (D-1); одно правило для приёма и пересчёта | нет; импортёры прежних имён из `statement_recompute` работают | | 2026-10-18 | **P3-40 (B4).** Приём форм `2.0` — отдельный сервис `services/statement_intake_v2.py` (`map_statement_docs_v2`, `insert_forms_v2`); `map_file` выбирает маппер по типу результата через `_plan_forms` / `_insert_forms` (иначе функция превышала лимит ruff по операторам) и получил необязательный параметр `engine`. `_load_result` и тип в `process_once` остаются под `1.x` до B5 | размер `result_mapping.py` и лимиты линтера; граница B4/B5 из брифов | нет | | 2026-10-18 | **P3-41 (B4).** `DISCLOSURE` в `2.0` — по-прежнему один документ на файл со статусом `RECOGNIZED` (D-3), `file_ordinal` первого; предупреждения всех объединённых документов результата пишутся в его `document_issue` источником `service`. Статус сервиса и предупреждения файла уровня `error` на него не влияют | спека условие `RECOGNIZED` относится к формам ББ/ОФР; у `DISCLOSURE` нет данных | `DISCLOSURE` с `needs_check` сервиса автоподтверждается (данных нет — подтверждать нечего) | | 2026-10-18 | **P3-42 (B4).** `STATEMENTS_VERSION_MISMATCH` пишется и при приёме `2.0` (`version_issue(job.statements_version, engine.version)`), а не только при пересчёте после правки | иначе запись появлялась бы только после первой правки; один набор записей `core` у приёма и пересчёта | лишний `info` на формах, принятых копией иной версии | | 2026-10-18 | **P3-43 (B4).** Результат с повтором `row_index` или `ref` у строк: вторая и далее строка пропускается с `MAPPING_WARNING`; `detail` с `parent_ref`, которого нет, хранится с `parent_row_index = null` и тем же предупреждением | уникальность `(meta_id, source_ref)` и ключа строки при вставке иначе роняет приём (файл `ERROR` после попыток); схема уникальности `ref`/`row_index` не проверяет | потерянная строка дубля видна только в предупреждении | | 2026-10-18 | **P3-44 (B4).** Запись `relation` / `soft` из `checks` результата несёт `column_id` (колонка с таким `column_index`, если есть) и всегда `column_index` (как P3-33); запись `warning` с `column_index` — либо `column_id` (колонка есть), либо `column_index` (колонки нет) | спека «Пропущенная колонка» и ответ API, читающий индекс из записи | нет | | 2026-10-18 | **P3-45 (B4).** `is_current` колонок `2.0` — как у `1.x` (колонка с самой поздней датой, при равенстве меньший `column_index`; нет дат — `NULL`), в том числе у колонок `relative:*` с датой; значение, которого нет в результате, пишется с `is_recognized = NULL`, остальными полями пустыми и `needs_review = false` | §9.5 п. 2; спека «Хранение результата формы v2.0» | колонка `relative:*` без даты не станет текущей | | 2026-10-18 | **P3-46 (B5).** `_load_result` выбирает модель по MAJOR через `contract.versions.parse_result`; неизвестный MAJOR, неверный формат версии и любое нарушение схемы — `MappingFatal` с итогом проверки схемы (`результат не соответствует схеме recognition-result: ошибок N, первая: …`), файл `ERROR`, без повтора | спека «Приём файла результата»; D-2 | файл с MAJOR 3 не мапится, а не обрабатывается молча как `1.x` | | 2026-10-18 | **P3-47 (B5, T5.2 части А).** Нечитаемый JSON (`JSONDecodeError`, не UTF-8) в `_load_result` — тоже `MappingFatal` с текстом «результат не соответствует схеме recognition-result: файл не является JSON» (текст исключения парсера пользователю не показывается; M-1); не-объект (`[]`, `null`) отсекается моделью как нарушение схемы | иначе `ValueError` из `parse_result` уходил бы в повторяемые ошибки и файл `ERROR` получал бы только после `MAPPING_MAX_ATTEMPTS` попыток | ошибка JSON не отличается в тексте от прочих ошибок схемы | | 2026-10-18 | **P3-48 (B5).** Статус ОСВ `2.0` — `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings)`; «предупреждения маппинга листа» = повтор счёта + сироты + «нарастающая по данным Q1» (вход `document_status` `1.x` тот же); у `1.x` статус как прежде (§9.3, статус задания). Предупреждение уровня `error` ищется в `metrics.warnings` результата (`file_warnings`), а не в `recognition_job.metrics` | Р-28; единый источник с формами | ОСВ с `error` в метриках файла — `NEEDS_CHECK` | | 2026-10-18 | **P3-49 (B5).** `osvDocument.warnings` пишутся в `document_issue` как `warning` / `service` с `severity` из предупреждения (по контракту `info`, `error` не отбрасывается); `line_refs` у ОСВ не сохраняются (строк формы нет), `column_index` и `field` — как пришли. Записи сервиса идут раньше записей маппинга ядра. Дубль листа (повтор счёта) своих предупреждений/статуса не вносит — документом остаётся первый лист | у ОСВ нет `line_ids`/колонок формы; повтор — D-7 | `error`-предупреждение листа ОСВ в `issues` не влияет на статус само по себе (статус — по `status` листа) | | 2026-10-18 | **P3-50 (B6).** `FailedCheck.codes` в ядре остаётся (вопреки плану, который удалял поле): оно нужно межформенной проверке 1370↔2400 (`codes = ("1370", "2400")`) для `codes` / `line_ids` в `checks.cross_form` ответа; нарушения §7 по колонкам читаются из `document_issue`, где коды хранятся сами | межформенную проверку пакет не считает (она по двум документам), без `codes` подсветка строк в ответе пропала бы | у `cross_form` не было бы `codes`/`line_ids`, фронт не подсвечивал бы строки 1370 и 2400 | | 2026-10-18 | **P3-51 (B6).** `checks_report` уже брал допуск у `engine.tolerance()` (с B2), поэтому правки сервиса в B6 нет; константа допуска ядра удалена вместе с `load_check_rules`. Состав и порядок редакций справочника имён `statement_lines.yaml` не переписывается на `engine.editions()` в коде: согласованность проверяет тест `test_name_dictionary_codes_in_code_orders` (редакции справочника = редакции пакета в том же порядке; каждый код справочника есть в перечне редакции пакета, любой вариант формы) | справочник остаётся данными имён и состава экрана; перечни кодов — единственный источник в пакете, расхождение ловит тест, а не молчаливая подмена в рантайме | при добавлении редакции в пакет без справочника имён тест красный; в рантайме новая редакция дала бы форму без шаблонных строк | | 2026-10-18 | **P3-52 (B6).** `edition_for` (граница 66n/fsbu4 по дате для шаблона экрана) в `domain/statement_mapping.py` не тронут: переход на `engine.edition(...)` требует кодов формы и варианта и относится к ответу формы (B7/доработка), а не к удалению копии словаря | не расширять B6 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено | | 2026-10-18 | **P3-60 (fix-волна, I-5).** В ответах нарушение §7 может иметь `expected` / `actual` = `null` (пустая цель при непустых членах, P1-21). Индекс колонки при её отсутствии различается: `checks.failed[]` ответа `GET …/statement` отдаёт `-1` (`FailedCheckOut.column_index: int`, межформенная `cross_form` всегда `-1`), а `detail.failures[]` отказа 409 `CHECKS_NOT_PASSED` — `null` (`issue_column_index`). Спека описывает поведение как есть; код не правился. **Вопрос на выравнивание (`-1` или `null`) — план 6 / фронт (arm-web)** | фиксация фактического контракта до решения владельца; тип поля у фронта (arm-web) нужно сверить | фронт, ожидающий число, получит `null` в `detail.failures[].column_index` (только в 409, когда у записи нет ни `column_id`, ни `column_index`) | | 2026-10-18 | **P3-61 (fix-волна, C-1, M-1).** Дельты B переписаны под `openspec archive`: MODIFIED «Пересчёт после правки», «Словари ядра и достройка шаблонных строк», «Формы результата 1.x — §7 при приёме» теперь несут полный текст требования части А со всеми сценариями плюс уточнения Б (иначе архивация падает: «scenario(s) not present in the modified block»); добавлен MODIFIED «Хранение результата формы v2.0 (DBML v5.19)» и «Форма ББ/ОФР для экрана проверки» (сценарий «Строка, потерянная источником» приведён к P3-30). ADDED «Маппер результата 2.0 для форм» заменён MODIFIED «Хранение…» (устранено противоречие «первое остаётся» vs «без повторной дедупликации») и ADDED «Проверки ядра при приёме результата 2.0»; ADDED «`CHECKS_NOT_PASSED` из `document_issue`» и «Блок `checks`…» оставлены только с тем, чего нет в А (форма элемента нарушения, `null`), дубль порядка 409 убран. Проверено архивацией А, затем Б на копии `openspec/`. **Дополняет R-3 и D-4** | архивация обязана проходить; дубли и противоречия между А и Б вводят в заблуждение | если архивация А и Б падает — править заголовки и сценарии дельт Б; при будущей правке требований А в самой А тексты MODIFIED Б нужно обновить синхронно |