Симптом после первой замены обманчив: старый и новый endpoint возвращают одинаковый status, JSON похож, demo проходит. Через неделю клиент повторяет запрос, а в новом пути появляется второй effect, теряется категория validation-ошибки или меняется порядок значимых полей. Команда спорит, достаточно ли похоже, потому что до разработки не зафиксировала, что означает parity. Цена — регрессия, которую нельзя локализовать: ответ уже ушёл consumer, а причина спрятана между transport, domain rule и побочным действием.
Parity не требует сравнивать каждый байт. Он требует заранее выбрать свойства, для которых различие недопустимо. У операции preview важно, что invalid input не выглядит как successful quote, обязательные поля не исчезают и repeat не создаёт объявленный effect дважды. Для другой операции важнее порядок событий или конкретная кодировка ошибки. Список выбирает владелец доменного контракта, а не test framework. Если свойства названы после расхождения, это расследование, а не доказательство совместимости.
Форма API — полезная, но неполная граница
OAS 3.1.0 определяет способ описать HTTP-интерфейс без доступа к исходному коду и трафику: paths, operations, request/response structures, schemas. Такой документ помогает сделать видимыми обязательные поля, статусы и версию. Для перехода это сильный первый слой: consumer и implementer перестают угадывать форму. Но OAS не обещает, что два одинаковых документа выполняют одинаковую бизнес-операцию. Schema Object описывает структуру, а значение статуса, порядок effects, правило повтора и момент чтения состояния остаются в договоре команды.
Compatibility contract лучше разделить на четыре слоя. Transport — method, path, status category и headers, если они значимы. Payload — обязательные поля, type, семантика null/absence и порядок там, где consumer зависит от него. Effect — что объявлено как запись, публикация сообщения, cache invalidation или запрет повтора. Time — что значит тот же input: идентичный request, idempotency key, business key или момент состояния. Один test snapshot почти всегда покрывает только часть transport и payload.
| Слой | Инвариант | Evidence для реальной проверки | Опасная подмена |
|---|---|---|---|
| Transport | success и validation не меняют категорию status | recorded request/response из согласованного контура | сравнить только HTTP 200 |
| Payload | обязательные поля и их смысл сохранены | contract case с expected result | сравнить размер JSON или порядок всех ключей |
| Effect | repeat не дублирует объявленное действие | трасса, журнал или тест с доступом к effect | решить по одинаковому ответу |
| Time | состояние читается в оговорённый момент | scenario с известной подготовкой данных | назвать любой repeat тем же input |
| Ownership | изменение правила утверждает domain owner | decision record и review | дать parser-у схемы право решать semantics |
Сначала классифицировать различие, затем выбирать реакцию
Не всякое различие означает дефект, но любое различие должно получить класс. Cosmetic — пробел, порядок незначимых ключей, новый optional label, если consumer действительно от него не зависит. Compatible extension — дополнительное поле, которое old consumer игнорирует по документированному правилу. Behavioral mismatch — изменился status, обязательное поле, validation, amount, idempotency или effect. Unknown — наблюдение есть, но команда пока не знает, значимо ли поле или порядок. Только первые два случая можно принять без расширения scope, и то после evidence о consumer.
Классификатор защищает и от обратной крайности — попытки скопировать legacy bug без вопроса. Иногда старое поведение выглядит ошибкой, но клиент уже построил вокруг него workflow. Тогда существуют три решения: сохранить bug временно ради compatibility, изменить contract с версией и migration path или остановить замену до согласования. Нельзя решить это выражением expected equals actual. Оно отвечает на синтаксическое равенство и ничего не говорит о том, кто понесёт цену изменения. M7 здесь — не больше тестов, а явная развилка с owner и стоимостью каждого варианта.
Три case лучше ста невыбранных
Для первого шва достаточно трёх cases. Valid case показывает основной результат с объявленными полями. Invalid case проверяет, что новый путь не превращает ошибку в success или generic 500. Repeat case покрывает повтор с тем же ключом или input и объясняет ожидаемый effect. Это не test strategy всей системы. Это минимальный набор, который заставляет команду назвать поведение в местах, где одинаковый JSON маскирует риск. После реального расхождения добавляется ещё один case с источником evidence; не нужно заранее строить каталог из сотни случайных fixtures.
Каждый case содержит не только input и expected output. Добавьте precondition, источник данных, критерий сопоставления и границу. Если outcome зависит от времени, курса или внешнего provider, отметьте это. Если effect нельзя наблюдать без доступа к журналу, не пишите «эффект совпадает». Напишите unknown и спланируйте доступ или изоляцию. Неприятная ячейка unknown полезнее зелёного чекбокса: она показывает, что новый путь не готов к этому классу трафика, а не прячет отсутствие прав за термином parity.
Почему snapshot и schema не спасают от side effect
Snapshot полезен как evidence формы, если в нём есть версия contract и понятная подготовка данных. Он плохо отвечает на вопрос, что произошло после ответа. Два обработчика могут вернуть один payload, но один записывает только preview, а второй публикует событие. Здесь parity не в копировании всей базы, а в конкретном инварианте effect: в preview нет publication, repeat с тем же ключом не создаёт вторую запись, failed validation не меняет status. Для каждого инварианта нужен способ наблюдения в реальном test/staging контуре; fixture этой статьи специально такого способа не имеет.
То же относится к error mapping. Новый язык, library или gateway может упростить исключения до internal error. Для consumer это не техническая деталь, если он различает validation, retryable и forbidden. Сначала фиксируется внешняя категория и необходимый payload, затем внутренняя реализация может меняться. Это дешевле, чем сохранять stack trace и текст legacy-ошибки. Но нельзя назвать категории совместимыми только потому, что они логичнее. Нужны owners и evidence, что потребитель не зависит от старой формы.
Учебный fixture запрещает ложное доказательство
Fixture пакета создаёт три synthetic cases: valid, invalid и repeat. У каждого observed равно not-executed; у contract realParity равно not-executed-or-claimed. Код отвергает case с real-test-passed, попытку назвать parity executed, урезанный список effect-rules и claim coverage. Это не mock endpoint и не contract test framework. Его задача — показать, что decision record не должен объявлять green parity, пока он не содержит отдельного запуска и evidence из доступной среды.
node web/scripts/upgrade-2024-01.mjs --verify-fixture
PASS означает, что immutable synthetic record удерживает три вида rules, три case и границы доказательства. Он не читает legacy code/history, не вызывает API, не запускает test runner, CI или сеть и не смотрит на coverage или latency. У него нет customer data, database state, времени и очередей. Он не сообщает, сохранён ли порядок вызовов, как consumer обрабатывает new error или совпали ли реальные результаты. Для этой статьи это защита от красивого, но пустого слова parity.
Маршрут: симптом → причина → проверка → действие
- Симптом. Новый путь возвращает похожий payload, но команда не объясняет разницу при invalid input или repeat.
- Причина. Contract зафиксировал schema, но не semantics, effect и time. Проверка сравнивает сериализацию вместо обещания consumer-у.
- Проверка contract. Разложите операцию на transport, payload, effect и time. Для слоя оставьте различия, от которых зависит известный consumer.
- Проверка evidence. Для valid, invalid и repeat укажите источник подготовки, место наблюдения и owner. Unknown остаётся unknown до проверки.
- Действие. Behavioral mismatch блокирует расширение шва; compatible extension требует record; cosmetic отличие фиксируется вместе с причиной независимости consumer.
- Повтор. После расхождения добавляйте один case и один критерий, а не переписывайте contract задним числом под текущий implementation.
Как не превратить parity в бесконечный проект
Scope parity заканчивается на выбранном шве. Если для него понадобилось доказать поведение десяти соседних команд, это сигнал отступить и выбрать более тонкую границу или сначала формализовать dependency contract. Замерять «процент parity coverage» без определения веса cases бессмысленно: одно число поставит рядом основной платёжный сценарий и косметическую подпись. Вместо этого полезнее назвать незакрытые классы: time-dependent, effectful, external-provider, access-denied. Каждый получает решение: взять следующим, оставить на legacy или согласовать отдельную migration.
Parity не отменяет эволюцию. Когда продукту нужна новая semantics, старая и новая операции расходятся явно: новый version contract, новая operation или назначенный период coexistence. Самый дорогой вариант — спрятать изменение внутри migration и надеяться, что все consumers воспримут его как bugfix. Тогда команда не может ни проверить, что сохранила legacy, ни объяснить, почему изменила его. Contract ценен тем, что делает выбор проверяемым и обсуждаемым до rollout.
Ограничения и следующий шаг
Эта статья не утверждает, что OpenAPI покрывает business behavior, что трёх cases достаточно для всех доменов или что существующий legacy bug надо сохранять. В ней нет production data, readiness CI, отчёта coverage, замера performance и результата parity test. Источники доступны к январю 2024: OAS 3.1.0 описывает interface, Fowler — риск cut-over, Google — оценку ограниченного rollout. Они не поставляют готовую матрицу вашей команды и не дают автоматическую власть над архитектурным решением.
Следующий шаг: для одного шва заполните матрицу из четырёх слоёв и трёх cases. В каждой строке добавьте owner, evidence source и одно из значений compatible, mismatch, unknown. Не пишите общий verdict parity passed, пока unknown свойство может изменить effect или категорию ошибки. Когда карта готова, она позволяет выбрать цену: сохранить старое временно, версионировать новое или отложить rollout. Это точнее, чем спорить о близости двух JSON.
Историческая граница января 2024
К январю 2024 OAS 3.1.0 уже была стабильной спецификацией 2021 года, исходная заметка Fowler датирована 2004 годом, а Workbook Google — изданием 2018 года. Текст не ссылается на поздние contract-платформы и не имитирует результаты чужих parity runs. M7 проявляется в разделении стоимости compatibility, owner и критериев решения; прагматичный тон не заменяет реальное evidence схемой.
Проверяемые источники
- Martin Fowler: Original Strangler Fig Application, 29.06.2004 — Первичный текст автора метафоры: критическое cut-over переписывание оказывается сложнее ожидаемого и рискованно; постепенное вытеснение может раньше дать ценность. Это не готовая инструкция для чужого домена, базы данных или маршрутизатора.
- OpenAPI Specification v3.1.0, 15.02.2021 — Официальная спецификация описывает language-agnostic интерфейс HTTP API, paths, operations и Schema Object. Она помогает фиксировать форму интерфейса, но не доказывает runtime parity, порядок эффектов или поведение неизвестного legacy-модуля.
- Google SRE Workbook: Canarying Releases, copyright 2018 — Официальная глава определяет canary как частичное и ограниченное по времени развёртывание с оценкой перед продолжением, требует сравнивать canary и control и называет границы synthetic load. Она не задаёт процент, метрики, полномочия или rollback для этого пакета.