diff --git a/openspec/changes/unified-normalization-tp/proposal.md b/openspec/changes/unified-normalization-tp/proposal.md
new file mode 100644
index 0000000..208e49e
--- /dev/null
+++ b/openspec/changes/unified-normalization-tp/proposal.md
@@ -0,0 +1,40 @@
+## Why
+
+Формы ББ/ОФР из Excel разбираются собственным конвейером tabular-parser (раскладка, отбор строк, значения, проверки §7,
+статус), отличным от конвейера scan-gateway; ядро не доверяет ни одному и пересчитывает результат по косвенным признакам
+(строки без кода молча теряются, повтор кода теряет сумму, предупреждения — плоские строки). Change `unified-statement-normalization`
+в arm-ka-docs (issue RSHB/arm-ka-docs#116, дизайн с решениями Р-1…Р-24) приводит оба сервиса к одной цепочке шагов 1–9:
+шаг 1 (кандидат в форму) пишет адаптер источника, шаги 2–8 выполняет общий пакет `arm_statements`, результат и конверт —
+контракт v2.0. Этот change переносит требования в tabular-parser; реализация — план 4
+(`plans/2026-10-18-plan-4-tabular-parser.md` в docs), issue RSHB/arm-tabular-parser#54.
+
+## What Changes
+
+- **statement-parsing:**
+ - **MODIFIED** «Листы форм и их продолжения находятся по содержимому» — классификация ОСВ → форма → продолжение →
+ прочее, слабое продолжение только сразу после листа формы, лист «прочее» даёт `SHEET_OTHER`;
+ - **MODIFIED** «Шапка документа» — поля шапки блока как `Cell` с `confidence = 100` в границах блока, разбор текста — общим кодом;
+ - **MODIFIED** «Склейка листов-продолжений и статус документа» — склейка до раскладки, `segment_breaks`,
+ `CONTINUATION_TYPE_CONFLICT`, `SHEET_NOT_JOINED`; статус документа считает функция пакета;
+ - **MODIFIED** «Словари форм, координаты, golden» — словари форм живут в пакете, координат в коде нет, golden и манифест;
+ - **REMOVED** «Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту», «Строки с кодом и значения»,
+ «Соотношения §7 — данные, проверка по колонкам» — шаги 2–8 выполняет копия `arm_statements`;
+ - **ADDED** «Адаптер Excel шага 1», «Ячейка Excel», «Строки без кода и неизвестные колонки в Excel»,
+ «Манифест ожиданий на реальных формах», «Статус книги».
+- **tabular-recognition-service:** **MODIFIED** «Результат по контракту» — файл результата и конверт v2.0, структурные
+ `metrics.warnings`, `service.statements_version`, ack `processing` — `schema_version = "2.0"`, запрос остаётся 1.0.
+- **osv-parsing:** **MODIFIED** «Контрольные суммы определяют статус» (`status` листа и `warnings[]` с кодами `OSV_*` и
+ `severity = info` в документе, строк `sheet=<имя> …` в `metrics.warnings` нет) и «Форматы, лимиты, время»
+ (несоответствие `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` `info`). Остальной разбор ОСВ не меняется.
+- **BREAKING** для потребителей: файл результата и конверт — MAJOR 2.0 (`line_code` nullable, структурные
+ предупреждения, нет `checks.duplicate_line_codes`); слияние PR — только после выкладки ядра, принимающего 2.0.
+
+## Impact
+
+- Код: `api/src/parser/statements/{cells,forms,header_cells,adapter,pipeline}.py` (новые), `grid.py`, `detect.py`,
+ `book.py`, `readers/`, `osv.py`, `checks.py`, `osv_warnings.py`; `api/src/contract/**` (модели v2.0), `api/contracts/**`
+ (копия схем v2.0), `api/src/core/{worker,config}.py`, `api/src/app.py`; удаляются `statements/{checks,columns,detect,header,lines,merge,statement}.py`
+ и `api/rules/statement_{headers,periods,checks}.yaml`.
+- Копия пакета `api/arm_statements/` (раскладывает `sync-statements`) — не правится.
+- Тесты: манифест `api/tests/statements_manifest.yaml`; golden `fixtures/golden/expected_{osv,statement}_parse/**` (отдельный PR fixtures, arm-tabular-parser#54).
+- Спеки: `statement-parsing`, `tabular-recognition-service`, `osv-parsing`. Контракт — `docs/contracts/` v2.0.
diff --git a/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md
new file mode 100644
index 0000000..2f88dde
--- /dev/null
+++ b/openspec/changes/unified-normalization-tp/specs/osv-parsing/spec.md
@@ -0,0 +1,132 @@
+## MODIFIED Requirements
+
+### Requirement: Контрольные суммы определяют статус
+Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
+turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
+`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у
+родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
+а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки CURRENCY/QUANTITY в суммирование не входят и сами
+родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (пустая ОСВ,
+R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
+`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root,
+balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы
+§1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое
+нарушение → `status = needs_check` документа и запись причины в `warnings[]` документа. Предупреждения листа SHALL быть
+структурами `{code, severity, message}` с `severity = info` и кодом с префиксом `OSV_` (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`,
+`OSV_HEADER`, `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`,
+`OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять
+структуру под сходимость.
+
+Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут
+только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml`
+(`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если
+код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом
+(`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель»
+сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у
+родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно.
+
+Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
+- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
+ (перестановка сторон при совпавшем нетто) не проверяется.
+- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна
+ сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало.
+- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт →
+ субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
+- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
+ строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
+- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
+ непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
+ а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
+- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
+ (закреплено тестом как осознанное поведение R-21).
+- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
+ `group_type` строк ветки; суммы и проверки это не затрагивает.
+
+#### Scenario: Все реальные файлы сходятся
+- **WHEN** разбирается каждый файл `fixtures/osv/**`
+- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты
+
+#### Scenario: Испорченная сумма
+- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1
+- **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
+ `row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк
+
+#### Scenario: Сальдо родителя свёрнуто нетто
+- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
+- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
+ или расхождение оборотов — нарушение
+
+#### Scenario: Счёт 91 — аналитика только с оборотами
+- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
+ субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
+- **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а
+ расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением
+
+#### Scenario: «Итого» не найдена
+- **WHEN** в таблице нет строки с текстом из словаря «Итого»
+- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings[]` — `OSV_TOTAL` (`info`) «строка «Итого» не найдена»
+
+#### Scenario: Пустая ОСВ
+- **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2
+ квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет
+- **THEN** `status = done` документа, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL),
+ `hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение
+ `OSV_NO_DATA_ROWS` «нет строк данных»
+
+#### Scenario: Предупреждение ОСВ
+- **WHEN** на листе ОСВ есть строка без родителя
+- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6;
+ в `metrics.warnings` конверта строки `sheet=…` нет
+
+### Requirement: Форматы, лимиты, время
+Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен —
+каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение файла `FILE_TYPE_MISMATCH` (`severity = info`) в `metrics.warnings`.
+Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова
+библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
+объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
+суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
+потоковым разбором `xml.parsers.expat` всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге,
+без отбора по имени или началу содержимого; ошибка разбора части не `*.xml` лишь заканчивает её скан; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
+префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении);
+для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
+`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше
+`LARGE_MERGED_COUNT_LIMIT` (1000). Книга не открывается (в том числе битый XML части
+`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING);
+превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
+таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
+непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный
+риск, решение платформы — лимит памяти контейнера, `defusedxml`): объём самих ячеек, `sharedStrings` и стилей в
+пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл
+`fixtures/osv/**` SHALL разбираться быстрее 5 с.
+
+#### Scenario: XLSB
+- **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7)
+- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения документа `OSV_XLSB_NO_OUTLINE`
+ («xlsb: outline level недоступен», `info`); `hierarchy=indent`
+
+#### Scenario: Расширение не совпадает с содержимым
+- **WHEN** запрос говорит `XLSX`, а байты — BIFF8
+- **THEN** файл прочитан как xls, в `metrics.warnings` — `FILE_TYPE_MISMATCH` (`info`), статус по содержимому
+
+#### Scenario: Превышен лимит
+- **WHEN** передан файл в 51 МиБ
+- **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается
+
+#### Scenario: Бомба объединённых ячеек
+- **WHEN** передан xlsx в несколько килобайт с `` (5,2 млн ячеек)
+- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается
+
+#### Scenario: Обходы скана объединённых ячеек
+- **WHEN** тот же диапазон спрятан: лист вне `xl/worksheets/` или с расширением не `.xml` (цель связи в
+ `workbook.xml.rels`), `ref` записан ссылками на символы (`A1:Z200000`, `A1:…`), перед `ref` стоит атрибут
+ с префиксом (`a:ref="A1"`), в значении другого атрибута есть `>`, элемент записан с префиксом (`x:mergeCell`)
+- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается; тот же вариант с
+ малым диапазоном читается, и объединение видно в листе
+
+#### Scenario: Объединение на весь столбец в xls
+- **WHEN** в книге xls есть законный merged-диапазон на весь столбец (больше `grid.MAX_INDEXED_AREA`)
+- **THEN** книга читается: после-чтения проверка его не считает (индекс листа его по ячейкам не раскладывает)
+
+#### Scenario: Распакованный zip больше лимита
+- **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES`
+- **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается
diff --git a/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md b/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md
new file mode 100644
index 0000000..43436c7
--- /dev/null
+++ b/openspec/changes/unified-normalization-tp/specs/statement-parsing/spec.md
@@ -0,0 +1,228 @@
+## MODIFIED Requirements
+
+### Requirement: Листы форм и их продолжения находятся по содержимому
+Классификация листа SHALL различать `OSV`, `STATEMENT`, `STATEMENT_CONTINUATION` и `OTHER` и выполняться в порядке:
+признак ОСВ → форма → продолжение → прочее. Признак ОСВ SHALL проверяться первым. Лист SHALL считаться `STATEMENT`, если
+в любой строке до первой строки заголовка таблицы формы или первой строки с кодом формы есть заголовок формы или код
+ОКУД из словаря заголовков пакета `arm_statements`. Лист без заголовка формы SHALL считаться `STATEMENT_CONTINUATION`,
+если на нём есть маркер продолжения («Форма 0710001 с.2»), либо строка заголовка таблицы формы (ячейка «Код» и правее
+ячейки-периоды), либо колонка, в которой не меньше 60 % непустых значений — коды строк форм; слабое продолжение (столбец
+кодов найден только ослабленным поиском пакета, не меньше `min_codes_continuation` кодов) SHALL учитываться только сразу
+после листа формы. Лист без признаков ОСВ, формы и продолжения, без `type_hint` и кодов SHALL считаться `OTHER`: документа
+нет, предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`). Тип формы (`type_hint`) SHALL определяться
+правилом пакета: ОКУД по словарю (0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx → `DISCLOSURE`), иначе заголовок
+по словарю («Пояснения», «Расшифровка» → `DISCLOSURE`), иначе `null`. Титул «Бухгалтерская отчетность» MUST NOT задавать
+тип: тип берётся из ближайшего следующего заголовка. Два заголовка форм на одном листе SHALL давать два блока и два
+документа, второй — со строки своего заголовка.
+
+#### Scenario: Заголовок формы после титульного блока
+- **WHEN** разбирается `statements-xlsx/npz/2025-Q3/Бухгалтерская отчетность … .xlsx`, лист «Бухгалтерский баланс. Лист 1»,
+ где заголовок «Бухгалтерский баланс» стоит ниже титула «Бухгалтерская отчетность за январь - сентябрь 2025 г.»
+- **THEN** лист классифицирован как `STATEMENT`, документ `BALANCE`, `header.okud = "0710001"`
+
+#### Scenario: Лист-продолжение без заголовка
+- **WHEN** разбирается лист «Бухгалтерский баланс. Лист 2» той же книги (первая строка — «в том числе:», далее 11901, 1100…)
+- **THEN** лист классифицирован как `STATEMENT_CONTINUATION` и не даёт отдельного документа
+
+#### Scenario: Маркер «с.2» в 1С-выгрузке
+- **WHEN** разбирается `statements-xlsx/unagrande-co/2024-FY/Б.Б. Лист 2.xls` (первая строка «Форма 0710001 с.2»)
+- **THEN** лист классифицирован как `STATEMENT_CONTINUATION`, тип по ОКУД маркера — `BALANCE`
+
+#### Scenario: Две формы на одном листе
+- **WHEN** разбирается синтетический лист `st01_both_forms_one_sheet` (баланс, затем ОФР)
+- **THEN** результат содержит два документа — `BALANCE` и `INCOME` с одинаковым `locator` листа, строки ОФР не попали в баланс
+
+#### Scenario: Расшифровка
+- **WHEN** разбирается лист с заголовком «Пояснения к бухгалтерскому балансу и отчёту о финансовых результатах»
+- **THEN** документ `DISCLOSURE` с `locator`, без `columns` и `lines`, со статусом `done`; статус книги от него не зависит
+
+#### Scenario: Лист «прочее»
+- **WHEN** в книге лист «Примечания» без заголовка формы, ОКУД и кодов
+- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"`
+
+### Requirement: Шапка документа
+Адаптер SHALL собирать шапку каждого блока формы в границах этого блока: поля `organization_name`, `inn`, `report_date`,
+`units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — каждое `Cell` или `null`, найденные поля с `confidence =
+100`. Метки поля SHALL искаться по словарю заголовков пакета в области от начала блока до строки заголовка таблицы
+(у блока без кодов — во всём блоке); значение — ближайшая непустая ячейка правее метки, иначе первая непустая ячейка
+следующей строки. ИНН SHALL браться у метки «ИНН», причём строки после метки аудитора MUST NOT использоваться.
+`organization_name` SHALL браться после метки «Организация», а при пустом значении — из ближайшей строки выше с признаком
+ОПФ. Разбор текста `Cell` (ИНН — только цифры длиной 10 или 12, дата отчёта по словарю периодов, единицы по словарю
+единиц или коду ОКЕИ) SHALL выполнять общий код пакета; единицы MUST NOT нормализоваться адаптером. Шапка другого блока
+или другого листа MUST NOT использоваться. У документа без шапки все поля SHALL быть `null`, кроме `form_name`, который
+ставит пакет по заголовку.
+
+#### Scenario: Полная шапка 1С
+- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls`
+- **THEN** `organization_name = 'Акционерное общество "Унагранде Компани"'`, `inn = "3249004697"` (не ИНН аудитора
+ 7716021332), `okpo = "47888105"`, `okud = "0710001"`, `units = "в тыс. рублей"`, `report_date = 2024-12-31`,
+ `report_date_text = "на 31 декабря 2024 г."`, `address` начинается с «242440»; `*_confidence = 100`
+
+#### Scenario: Организация над меткой, ОФР за период
+- **WHEN** разбирается лист «Отчет о финансовых результата» книги НПЗ
+- **THEN** `organization_name = 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'`, `inn = null` (на листе ОФР метки «ИНН» нет, а
+ шапка другого листа не используется), `okpo = "16098811"`, `report_date = 2025-09-30`, `units = "тыс. руб."`; у
+ документа `BALANCE` той же книги `inn = "2315995868"` из титульного блока над заголовком формы
+
+#### Scenario: Дата из тройки чисел
+- **WHEN** разбирается `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` (ячейки 30 | 9 | 2025 как числа)
+- **THEN** `report_date = 2025-09-30`, `inn = "7016006635"`, `okpo = "85262693"`
+
+### Requirement: Склейка листов-продолжений и статус документа
+Листы книги SHALL обрабатываться по порядку; лист-продолжение SHALL дописываться в сетку открытой формы адаптером до
+раскладки, граница листа — в `segment_breaks`. Открытая форма — последний кандидат с `type_hint` `BALANCE` или `INCOME`.
+Лист с маркером «Форма … с.N» присоединяется по типу маркера; расхождение типа маркера с `type_hint` формы SHALL давать
+отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT` (`error`). Лист без маркера и шапки SHALL
+присоединяться, только если его коды в диапазоне открытой формы и, при трёх и более кодах, число колонок сумм совпадает с
+формой; иначе — отдельный кандидат и предупреждение `SHEET_NOT_JOINED` (`error`). `locator` SHALL перечислять имена листов
+через запятую. Продолжение без открытой формы SHALL давать отдельный кандидат без предупреждения адаптера (пакет выдаёт
+`NO_ORGANIZATION_NO_INN`), документ — `needs_check`. Статус документа SHALL вычислять функция пакета (одна для сервисов и
+ядра); `checks` и условие `done` — по спеке `statement-normalization`. `metrics.rows` книги SHALL быть суммой строк данных
+ОСВ (без `TOTAL`) и строк `ok` документов форм (строки `detail` и без кода — отдельно). Если книга завершилась ошибкой
+листа ОСВ, ответ — ошибка этого листа; список неразобранных листов форм не добавляется.
+
+#### Scenario: Баланс на пяти листах
+- **WHEN** разбирается книга НПЗ
+- **THEN** два документа: `BALANCE` с `locator = "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2,…,Лист 5"`,
+ одной сеткой и `segment_breaks` по границам листов, строки от 1105 до 1700 включительно, `INCOME` на трёх листах (лист
+ отчёта с единственной строкой 2400 присоединяется как продолжение), строка 2400 присутствует; `1600 = 1700` и
+ `2100`/`2200`/`2300` — в `soft_relations_failed` как округление (OQ-138); статус книги `done`
+
+#### Scenario: Лист-продолжение с одной кодовой строкой
+- **WHEN** **сразу** после листа формы идёт лист без шапки с единственной строкой 2400 (код в диапазоне формы)
+- **THEN** лист присоединяется к открытой форме (кодов меньше трёх — число колонок не сверяется), строка присутствует,
+ граница листа — в `segment_breaks`
+
+#### Scenario: Лист другой ширины с тремя кодами
+- **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы
+- **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error`
+
+#### Scenario: Тип маркера против формы
+- **WHEN** после листа ОФР идёт лист с маркером «Форма 0710001 с.2»
+- **THEN** лист — отдельный кандидат `BALANCE`, предупреждение `CONTINUATION_TYPE_CONFLICT` уровня `error`
+
+#### Scenario: Одинокая страница 2
+- **WHEN** разбирается `unagrande-group/2024-FY/ОФР. Лист 2.xls` (стр. 1 в пакете нет)
+- **THEN** один документ `INCOME` со строками 2510–2910, пустой шапкой, статус `needs_check`
+
+#### Scenario: Страница 1 без продолжения
+- **WHEN** разбирается `unagrande-co/2024-FY/Б.Б Лист 1.xls` (только актив)
+- **THEN** документ `BALANCE` со строками 1110–1600, шапка полная, `1600 = 1100 + 1200` выполнено, `1600 = 1700`
+ нарушено как «непустая цель при пустых членах» → `needs_check`
+
+#### Scenario: Смесь ОСВ и форм в одной книге
+- **WHEN** разбирается синтетическая книга `st04_mixed_book` (лист ОСВ 60 и лист баланса)
+- **THEN** результат содержит `osvDocument` и `statementDocument`, статус книги по обоим
+
+### Requirement: Словари форм, координаты, golden
+Словари форм (заголовки и ОКУД, периоды, соотношения §7, единицы, перечни кодов) SHALL жить в пакете `arm_statements` и в
+tabular-parser MUST NOT дублироваться: файлы `api/rules/statement_*.yaml` удалены. Код `src/parser/statements/**` и
+`src/parser/book.py` MUST NOT содержать литералов координат (линтер `test_no_coordinates`); `ref` ячейки собирается из
+индексов функцией `grid.cell_ref`. Снимки `../fixtures/golden/expected_statement_parse/**` SHALL покрывать все файлы
+`statements-xlsx/**` и обновляться только `make golden-update`; `service.statements_version` — волатильное поле снимка.
+Property-тест по манифесту `tests/statements_manifest.yaml` SHALL проверять тип, ИНН, ожидаемый статус, сходимость
+жёстких соотношений для полных форм и, где задано, множество кодов предупреждений `error` документа.
+
+#### Scenario: Новый синоним без релиза кода
+- **WHEN** в словарь заголовков пакета `arm-statements` добавлен синоним заголовка «Баланс предприятия» и копия пакета
+ обновлена скриптом `sync-statements`
+- **THEN** лист классифицирован как `BALANCE` без изменения кода tabular-parser; собственного словаря форм в сервисе нет
+
+#### Scenario: Мёртвая запись словаря
+- **WHEN** запись словаря форм не срабатывает ни на одном файле
+- **THEN** это проверяет тест покрытия словарей пакета `arm-statements` (внутри пакета, на его данных); тест покрытия
+ tabular-parser словари форм не охватывает
+
+#### Scenario: Регрессия по снимкам
+- **WHEN** результат разбора любого файла `statements-xlsx/**` отличается от снимка
+- **THEN** golden-тест падает с diff; обновление — только `make golden-update` и отдельный коммит в fixtures
+
+## REMOVED Requirements
+
+### Requirement: Заголовок таблицы, колонка кодов и колонки-периоды определяются по тексту
+**Reason**: Шаг 2 (раскладка: столбец кодов, строка заголовка, колонки-периоды) выполняет копия `arm_statements`.
+**Migration**: Требования — спека `statement-normalization` (docs): «Шаг 2 — столбец кодов», «Шаг 2 — строка заголовка,
+нумерация и роли строк», «Шаг 2 — подпись и дата колонки».
+
+### Requirement: Строки с кодом и значения
+**Reason**: Шаги 3–5 (строки данных, значение ячейки, строка формы, единицы) выполняет копия `arm_statements`.
+**Migration**: Требования — спека `statement-normalization` (docs): «Шаг 3 — строки данных и их статусы», «Шаг 4 —
+значение ячейки», «Шаг 4 — единицы и дробные значения», «Шаг 5 — строка формы».
+
+### Requirement: Соотношения §7 — данные, проверка по колонкам
+**Reason**: Шаг 7 (самопроверки §7, «посчитанное» соотношение, округление) выполняет копия `arm_statements`, словарь
+соотношений — в пакете.
+**Migration**: Требование — спека `statement-normalization` (docs): «Шаг 7 — самопроверки §7».
+
+## ADDED Requirements
+
+### Requirement: Адаптер Excel шага 1
+tabular-parser SHALL оставить у себя чтение книги (лимиты `PARSE_MAX_BYTES`, `PARSE_TIMEOUT_SECONDS`, формат по
+сигнатуре, лимиты zip и объединённых ячеек, `UNREADABLE`), классификацию листов (ОСВ — отдельный путь; форма;
+продолжение; прочее) и адаптер «блок формы на листе → `FormCandidate`» общего кода; шаги 2–8 по формам SHALL
+выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без
+кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах
+(организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`;
+заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!` (имя листа в одинарных
+кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать
+листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа,
+диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в
+`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без
+признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`),
+документа нет.
+
+#### Scenario: Книга ББ + ОФР
+- **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР»
+- **THEN** адаптер выдаёт два кандидата: `BALANCE` с `locator = sheet:Баланс,Баланс (2)` и одной сеткой из двух листов
+ (`segment_breaks` — первая строка второго листа) и `INCOME` с `locator = sheet:ОФР`
+
+#### Scenario: Ссылки ячеек
+- **WHEN** код строки стоит в ячейке C27 листа «Баланс»
+- **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27`
+
+### Requirement: Ячейка Excel
+Адаптер SHALL превращать ячейку Excel в `Cell`: `text` — как показывает числовой формат ячейки (tp#42), `value` — число
+или дата, если источник их знает, `confidence = 100`, `ref = <лист>!` по правилу Excel (имя листа в одинарных
+кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры; апостроф внутри имени удваивается).
+Сетка кандидата — плотная: строки блока целиком, пустая позиция — `null`; объединённая ячейка — только в позиции левой
+верхней. Значения ББ/ОФР из Excel SHALL приниматься пакетом по `value`; ОСВ числовой формат не применяет.
+
+#### Scenario: Excel-ячейка с числовым форматом
+- **WHEN** в Excel ячейка хранит 106311116.46 с форматом `#,##0,`
+- **THEN** у `Cell` `text = "106 311"`, `value = 106311`, `confidence = 100`, `ref = "Баланс!D27"` (лист и адрес ячейки)
+
+#### Scenario: Имя листа с пробелом
+- **WHEN** код строки стоит в ячейке C14 листа «Бухгалтерский баланс. Лист 1»
+- **THEN** `ref = "'Бухгалтерский баланс. Лист 1'!C14"`
+
+### Requirement: Строки без кода и неизвестные колонки в Excel
+tabular-parser SHALL отдавать строки без кода с содержимым со статусами `detail` / `missing` / `unreadable` (Р-8; ранее
+не отдавались, OQ-36 / OQ-141), числовые колонки с нераспознанной подписью — колонкой без даты и `error` (ранее
+пропускались), повторы кодов — всеми строками без `duplicate_line_codes`.
+
+#### Scenario: НПЗ «Реализация иного имущества»
+- **WHEN** в ОФР ФСБУ 4/2023 строка «Реализация иного имущества…» без кода с суммами стоит под строкой 2340
+- **THEN** в результате строка `detail` с `parent_ref` строки 2340; ранее она отбрасывалась со счётчиком
+
+### Requirement: Манифест ожиданий на реальных формах
+Манифест `tabular-parser/api/tests/statements_manifest.yaml` SHALL быть обновлён в PR перехода на 2.0 по правилу
+«посчитанного» соотношения §7 (пустая цель при непустых членах — нарушение): `failed_relations` страниц 2 баланса без
+1600 получают `["1600 = 1700"]` — `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`,
+`unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только
+актив» (`Б.Б Лист 1.xls`, `ББ. Лист 1 .xls`, `ББ. Лист 1 3кв.25.xls`) сохраняют `["1600 = 1700"]` (теперь как «непустая
+цель при пустых членах»). Статусы `done` (Межениновская ББ и ОФР, НПЗ ББ и ОФР, ОФР «Лист 1» Унагранде ×2) MUST NOT
+меняться: их строки без кода уходят в `detail`, строки шапки выше заголовка — в `title`. Остальные записи манифеста
+пересчитываются прогоном и правятся только с объяснением в описании PR.
+
+#### Scenario: Страница 2 баланса
+- **WHEN** разбирается `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (есть 1700, нет 1600)
+- **THEN** документ `needs_check`, в `checks.relations_failed` — `1600 = 1700` с пустой целью
+
+### Requirement: Статус книги
+Статус конверта tabular-parser SHALL вычисляться по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений
+файла уровня `error`; книга без листов ОСВ и форм — `error INVALID_DOCUMENT`. Порядок документов — сначала ОСВ в
+порядке листов, затем формы.
+
+#### Scenario: Книга только из пояснений
+- **WHEN** книга содержит только листы пояснений (0710003, «Пояснения»)
+- **THEN** документы `DISCLOSURE` со статусом `done`, конверт `done`
diff --git a/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md b/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md
new file mode 100644
index 0000000..95dd741
--- /dev/null
+++ b/openspec/changes/unified-normalization-tp/specs/tabular-recognition-service/spec.md
@@ -0,0 +1,37 @@
+## MODIFIED Requirements
+
+### Requirement: Результат по контракту
+После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата
+(по `recognition-result.schema.json` v2.0, `schema_version = "2.0"`) в `results/{file_id}/{job_id}/result.json` и
+опубликовать конверт результата (по `recognition-result-message.schema.json` v2.0, `result_schema_version = "2.0"`) с
+заголовком `Nats-Msg-Id = result::`. Ack `processing` SHALL нести `schema_version = "2.0"`; запрос
+`recognition-request` остаётся v1.0. `service.statements_version` файла результата SHALL быть коммитом копии пакета
+`arm_statements` (файл `VERSION` копии; сервис без него не стартует) и записываться при наличии `statementDocument`.
+`metrics.warnings` конверта SHALL содержать предупреждения уровня файла структурами `{code, severity, message, locator?}`,
+не строками. Статус конверта — по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня
+`error`. При неудаче публикации сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку.
+
+#### Scenario: Запрос с заглушкой парсера (Срез 0)
+- **WHEN** воркеру явно передана заглушка `NullParser` (только тесты воркера; реализация по умолчанию — реальный парсер),
+ запрос подтверждён и объект существует в `uploads/`
+- **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id`
+ из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать
+
+#### Scenario: Запрос с реальным парсером
+- **WHEN** запрос подтверждён и объект в `uploads/` существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB)
+- **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = UNSUPPORTED_FORMAT` (повреждённая
+ книга — `UNREADABLE`), `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать;
+ корректная ОСВ в том же потоке даёт `done` (см. «Парсер — заменяемый интерфейс»)
+
+#### Scenario: Результат и ack версии 2.0
+- **WHEN** обработан запрос `schema_version = "1.0"` с книгой, содержащей баланс
+- **THEN** ack `processing` и конверт результата несут `schema_version = "2.0"`, `result_schema_version = "2.0"`, файл
+ результата проходит схему v2.0, `service.statements_version` равен коммиту копии `arm_statements`
+
+#### Scenario: Предупреждение файла структурой
+- **WHEN** в книге лист «Примечания» без признаков ОСВ и формы
+- **THEN** `metrics.warnings` конверта содержит `{code: "SHEET_OTHER", severity: "info", message, locator: "sheet:Примечания"}`
+
+#### Scenario: Повтор публикации
+- **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно
+- **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется
diff --git a/openspec/changes/unified-normalization-tp/tasks.md b/openspec/changes/unified-normalization-tp/tasks.md
new file mode 100644
index 0000000..1588dd9
--- /dev/null
+++ b/openspec/changes/unified-normalization-tp/tasks.md
@@ -0,0 +1,21 @@
+## 1. Change и подготовка
+
+- [ ] 1.1 OpenSpec change `unified-normalization-tp`: proposal, tasks, дельты `statement-parsing`, `tabular-recognition-service`, `osv-parsing` (задача 1)
+- [ ] 1.2 Подготовка хоста: сверка копии `arm_statements` с планом 2 и её свежесть (`make statements-check`) (задача 2)
+
+## 2. Адаптер Excel шага 1
+
+- [ ] 2.1 Ячейка Excel → `Cell`: `ref = <лист>!`, текст по числовому формату, плотная сетка (задача 3)
+- [ ] 2.2 Блоки форм на листе, вид листа, шапка блока на словарях пакета (задача 4)
+- [ ] 2.3 Адаптер книги → `FormCandidate`: классификация листов, склейка продолжений, `segment_breaks`, предупреждения (задача 5)
+
+## 3. ОСВ и переход на контракт v2.0
+
+- [ ] 3.1 Предупреждения ОСВ с кодами `OSV_*` (вывод пока прежний) (задача 6)
+- [ ] 3.2 Переключение на общий код и контракт v2.0: модели, копия схем, сборка книги, путь ОСВ, воркер, `statements_version` (задача 7)
+
+## 4. Регрессия и очистка
+
+- [ ] 4.1 Golden и манифест реальных форм (задача 8)
+- [ ] 4.2 Удаление собственного кода шагов 2–8 и словарей форм (задача 9)
+- [ ] 4.3 Полная проверка `make check-full`, PR tabular-parser и PR fixtures (задача 10)