{ "index": 65, "slug": "editorial-2026-03-mechanism-data-contracts", "title": "Совместимость схемы — это направление, а не номер версии", "excerpt": "Как compatibility gate отделяет направление чтения, обязательные поля, объявленные additions и возможности consumer — и почему неопределённость должна останавливать проверку.", "contentHtml": "

Симптом обычно выглядит безобидно: producer выпускает схему v2, consumer видит знакомые поля, а в ревью появляется короткое слово compatible. Затем старый reader получает данные с новым полем, не находит обязательный state или встречает поле другого типа. Ошибка проявляется уже на границе сервисов. Цена — отклонённые сообщения, неверные значения по умолчанию, ручная миграция и спор о том, что именно обещала версия.

\n

Тезис простой: совместимость нельзя вычислять по номеру версии, пересечению имён или удачному примеру сериализации. Сначала нужно назвать направление, две точки схемы и конкретного consumer. Затем отдельно проверить обязательную поверхность, объявленные additions и capability reader. Если хотя бы одна часть неизвестна, gate должен остановиться. Такой отказ полезнее зелёного статуса без объяснения.

\n

Что именно сравнивает gate

\n

Назовём baseline старой схемой и candidate новой схемой. В выбранном направлении фиксированный producer создаёт candidate, а фиксированный consumer читает эту форму, опираясь на baseline как на точку отсчёта. Это не единственное возможное направление. Новый reader может читать старые данные, но это уже другой вопрос и другая карточка сравнения.

\n

Минимальная запись отношения содержит пять значений: direction, family, baselineVersion, candidateVersion и consumerId. family не даёт сравнить случайные JSON-объекты только потому, что у них совпали ключи. Версии закрепляют обе точки. consumerId не позволяет заменить проверяемого reader абстрактным «клиентом». Пустое или изменённое значение даёт stop-implicit-comparison.

\n
\"Цикл
Gate проверяет отношение, форму данных, намерение producer и способность named consumer принять новую поверхность. Красная ветка сохраняет конкретную причину остановки.
\n

Три независимые проверки

\n

Первая проверка смотрит на обязательную поверхность baseline. Если required-поле исчезло из candidate или сменило тип, старый reader больше не получает обещанную форму. Например, замена state на phase может казаться переименованием с тем же смыслом. Gate не угадывает смысл имён. Для reader поле state отсутствует, поэтому результат — stop-backward-incompatible-schema.

\n

Вторая проверка смотрит на новые поля. Candidate может сохранить id и state, но добавить priority. Это не разрушает обязательную поверхность. Однако producer должен явно назвать addition в manifest. Скрытое routingHint, появившееся в candidate без записи в manifest, даёт stop-undocumented-schema-field. Gate сначала требует объяснить новую поверхность, а потом спрашивает, принимает ли её reader.

\n

Третья проверка смотрит на capability consumer. Tolerant reader может разрешать declared additions. Strict reader может отклонять неизвестные поля. Слово optional в схеме producer не меняет policy reader автоматически. Если strict consumer не принимает priority, результат — stop-incompatible-consumer. Gate не удаляет поле на лету и не придумывает adapter. Команда отдельно выбирает изменение reader, разделение формы, задержку candidate или миграцию.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Для двух схем написано compatible, но не указано направлениеОдин boolean склеил разные отношения producer и readerПроверить direction, family, baseline, candidate и consumerIdОстановить как stop-implicit-comparison и оформить relation
В candidate нет обязательного stateRequired-поле baseline удалили или переименовалиПостроить field map и сравнить required surface baselineВернуть поле или назвать отдельную migration
Новый routingHint есть в данных, но нет в описании измененияФактический diff шире declared manifestСверить additions candidate с declaredAddedFieldsОстановить как stop-undocumented-schema-field
Tolerant reader проходит, strict reader падаетCapability зависит от конкретного consumer, а не от версии producerПроверить policy declared additions у named readerИзменить reader, форму или завести migration
Отчёт говорит «75% совместимо»Агрегат скрыл разные причины и владельцев следующего шагаПроверить исходный status и reason одного сравненияСохранить конкретный stop-status вместо процента
\n

Учебный пример с фиксированными схемами

\n

Ниже — учебный JavaScript-подобный пример. Он не подключается к registry, сети, файловой системе, CI или production data. fixedCase возвращает заранее известный объект, а review выполняет только описанные проверки. Пример показывает форму решения, но не доказывает совместимость реального формата.

\n
const baseline = {\n  version: '1.0',\n  required: { id: 'string', state: 'string' }\n};\n\nconst candidate = {\n  version: '2.0',\n  required: { id: 'string', phase: 'string' },\n  additions: []\n};\n\nconst relation = {\n  direction: 'backward',\n  family: 'orders',\n  baselineVersion: '1.0',\n  candidateVersion: '2.0',\n  consumerId: 'orders-reader'\n};\n\nconst report = review({ baseline, candidate, relation });\nconsole.log(report);\n// {\n//   status: 'stop-backward-incompatible-schema',\n//   removedRequiredFields: ['state'],\n//   nextAction: 'retain-required-baseline-field-or-name-a-separate-migration'\n// }
\n

Важна не длина функции, а граница вывода. Gate обнаружил отсутствие state. Он не объявил новый phase эквивалентом, не выдал разрешение на deploy и не выбрал стратегию миграции. Следующий шаг зависит от владельца контракта и требований старого reader.

\n

Положительный учебный случай тоже ограничен. Если candidate сохраняет id и state, добавляет объявленный priority, а named reader допускает declared additions, gate может вернуть synthetic hand-off. Это означает только то, что фиксированная проверка закончилась положительно. Это не означает, что parser, registry, права, нагрузка и выпуск в реальной системе готовы.

\n

Почему порядок проверок имеет значение

\n

Если сначала спросить reader, принимает ли он неизвестные поля, tolerant policy может скрыть неописанное изменение producer. Поэтому gate сначала устанавливает отношение, затем проверяет required surface, потом сверяет manifest и только после этого проверяет capability.

\n

Так распределяется ответственность. Producer называет новую поверхность. Сравнение проверяет буквальную форму. Manifest связывает diff с намерением. Consumer описывает границу принятия. Ни один слой не подменяет другой. Если переставить шаги, зелёный результат может появиться раньше, чем команда поймёт, что именно она выпускает.

\n

Порядок действий

\n
  1. Выберите одну contract family и зафиксируйте baseline и candidate.
  2. Назовите направление: какой producer пишет, какой reader читает и относительно какой точки.
  3. Запишите consumerId и остановите сравнение при неизвестной или неполной связи.
  4. Постройте карты полей и проверьте исчезновение required-полей и смену их типов.
  5. Составьте manifest всех новых полей candidate; не выводите намерение из одного sample.
  6. Сверьте фактические additions с manifest и остановите скрытые поля.
  7. Проверьте capability именно named consumer: required fields, допустимую версию и policy дополнительных полей.
  8. Верните один status, reason и next action; положительный результат назовите только synthetic hand-off.
  9. Для обратного отношения заведите отдельное сравнение, а не расширяйте текущий boolean.
\n

Отрицательный путь

\n

Нельзя считать gate работающим только по зелёному fixed case. Передайте объект без direction. Ожидайте stop-implicit-comparison. Удалите state из candidate. Ожидайте stop-backward-incompatible-schema. Добавьте routingHint без manifest. Ожидайте stop-undocumented-schema-field. Замените tolerant reader на strict reader. Ожидайте stop-incompatible-consumer.

\n

Каждый отказ должен сохранять следующий шаг. Неизвестное отношение требует уточнить карточку. Удалённое required-поле требует вернуть поверхность или назвать миграцию. Скрытый addition требует обновить описание изменения или убрать поле. Несовместимый reader требует решения владельца consumer. Общий статус «не прошёл» не даёт команде достаточного действия.

\n

Ограничения механизма

\n

Этот gate проверяет узкое отношение между фиксированными описаниями. Он не извлекает схемы из registry, не знает все deployment-версии, не проверяет реальные payloads и не подтверждает, что consumer честно описал свои потребности. Он также не решает семантическое изменение: строка state=active может сохранить тип и имя, но начать означать другой бизнес-статус.

\n

Он не заменяет contract tests, миграцию данных, нагрузочную проверку, security review, SLA и план отката. JSON Schema, JTD и Avro дают полезные понятия для формы и чтения, но не определяют статусы этого gate. Поэтому результат нужно читать узко: механизм сделал одно сравнение явным и остановил неизвестность. Он не управляет релизом.

\n

Проверяемый критерий готовности

\n

Для выбранной пары есть заполненные family, direction, baseline, candidate и consumerId. Gate возвращает отдельные результаты для неизвестного отношения, разрушенной required surface, скрытого addition и несовместимого reader. Есть один положительный учебный случай и отрицательные случаи для каждой остановки. Каждый report содержит reason и next action. Положительный report прямо говорит synthetic hand-off и не выдаёт право на deploy.

\n

Если команда не может воспроизвести эти статусы на фиксированных входах или не знает, какой reader проверяется, критерий не выполнен. Номер версии и зелёный процент не заменяют evidence. Готовность здесь означает, что вопрос о совместимости имеет направление, named участников, отдельную причину и проверяемый следующий шаг.

\n

Проверяемые источники

" }