DarkRiDDeR14 мин

Schema change без устной координации: практический маршрут для контракта данных

ДанныеИнженерная практика

Проблема появляется до самой схемы: producer добавляет поле, consumer узнаёт о нём в сообщении или на созвоне, а проверка сводится к фразе «поле же необязательное». После deploy такая договорённость ломается там, где reader закрывает объект или ждёт старое обязательное поле. Цена ошибки — не только откат. Команда тратит время на восстановление того, что именно было обещано, кто это прочитал и почему изменение вообще считали безопасным.

Рабочий выход — не собирать больше согласований, а заменить устный маршрут маленькой карточкой сравнения. В ней есть baseline schema, candidate schema, направление backward-проверки, producer, один именованный consumer и список внесённых полей. Затем gate возвращает ограниченный verdict. Действие простое: не передавать change дальше, пока карточка не может назвать сравниваемую пару и цену каждого добавленного поля.

У схемы есть форма, но у изменения есть адресат

Сама по себе JSON-схема описывает форму объекта. Она не отвечает, чей reader должен принять новый объект и в какую сторону читать историю. Поэтому change нельзя свести к diff двух файлов. Для решения нужны как минимум четыре сущности: исходная форма, новая форма, producer, который формирует candidate, и consumer, который должен его разобрать. Пятая сущность — направление. В этой статье backward означает строго одно: может ли named consumer, работавший с baseline, получить candidate без нарушения зафиксированных условий.

Это определение специально уже, чем привычное «совместимо». Оно не говорит о всех consumer, не делает предположение о реальном registry и не выпускает ничего в production. Узкая формулировка полезна потому, что у stop есть точная причина. Если отсутствует state, проблема в сохранении обязательной поверхности. Если появился routingHint, которого нет в manifest, проблема в документации change. Если reader строгий, проблема не в абстрактной версии, а в его заявленной границе дополнительных полей.

Три карточки показывают эволюцию fixed схемы work item от версии 1.0 к 1.1 с необязательным полем priority. Между схемами расположен compatibility gate, который требует явное направление и manifest, а скрытое поле routingHint отправляет по красной ветке в stop.
Эволюция становится проверяемой, когда новая форма, заявленный diff и конкретный reader находятся на одной карточке review.
Карточка change перед compatibility gate
ЧастьЧто в ней назватьFixed примерЧто нельзя подразумевать
baselineкакая форма была точкой отсчётаfixed-work-item-v1любую прежнюю схему из памяти
candidateкакая форма предлагаетсяfixed-work-item-v1-1latest без версии
manifestкакие поля добавлены или удаленыadded: priorityскрытый routingHint
направлениекто читает чей результатbackward producer → consumerпохожая форма значит compatible
consumerкакой reader проверяетсяfixed-tolerant-reader-v1все возможные читатели
границачто даёт положительный outputsynthetic review hand-offdeploy, migration или реестр

Сначала назвать минимальный контракт, который нельзя потерять

В fixed baseline три поля: обязательные id и state, а также необязательный note. Такой маленький набор выбран не как модель реального домена, а как способ увидеть механизм. Когда candidate добавляет необязательный priority, gate может перечислить ровно одно новое поле. Когда candidate заменяет state на phase, разница уже не выглядит косметической: baseline required field исчез. Отдельное имя для change снимает ложную дискуссию о том, достаточно ли похожи слова state и phase.

Важна и граница закрытости. Не каждый reader обязан отвергать добавления, но его поведение нельзя угадывать по типу данных. В synthetic наборе tolerant reader прямо говорит, что принимает declared added fields. Strict reader прямо говорит обратное. Это не характеристика человека или сервиса, а поле учебной карточки. Gate не пытается «уговорить» strict reader. Он возвращает stop, потому что нам не разрешено превратить отдельную потребность в общее обещание без нового review.

Исполняемая проверка additive change

import { createFixedDataContractCase, inspectFixedSchemaChange, reviewFixedDataContractCompatibility } from './upgrade-2026-03.mjs';

const item = createFixedDataContractCase('compatible-additive-v2');
const change = inspectFixedSchemaChange(item);
const report = reviewFixedDataContractCompatibility(item);
console.log({ added: change.addedFields, status: report.status, effect: report.effect });
// { added: ['priority'], status: 'synthetic-compatibility-review-hand-off', effect: 'no-system-change' }

Фрагмент вызывает только public exports. Все схемы, версии, producer, consumer и boundary data уже лежат в named fixed literal; ни один объект не считывается извне. Output не утверждает, что новый формат развернут или что реальный читатель обработал данные. Он говорит значительно меньше и поэтому полезнее: один заранее названный synthetic pair прошёл правила этой карточки, а следующий шаг — hand-off независимому reviewer.

Manifest делает незаметное поле видимым решением

Полезная дисциплина manifest очень проста: каждое новое поле candidate должно находиться в declaredAddedFields, а объявленное поле должно действительно присутствовать в candidate. Это не замена документации типа и не собственная спецификация формата. Это контроль связи между намерением и diff. В строке review можно увидеть, что priority добавили намеренно. Если в candidate есть routingHint, но manifest о нём молчит, gate завершает проверку stop-undocumented-schema-field.

Такой stop не доказывает, что routingHint вреден. Возможно, поле нужно отдельному процессу. Но сейчас у команды нет права подменить неизвестность словом optional. Поле может попасть в строгий parser, логику сравнения или новую схему потребителя; это уже другой вопрос. Сначала его нужно назвать, выбрать направление и указать, кто будет читать candidate. Лишь после этого обсуждается, является ли поле additive, отдельным контрактом или поводом перенести изменение в другую миграцию.

Почему version не выполняет работу gate

Номер версии полезен как координата, но не как verdict. Он позволяет связать baseline, candidate и карточку потребителя во времени. Он не сообщает, удалено ли обязательное поле, может ли reader получить дополнительные значения или неявно ли вообще задано сравнение. Попытка заменить diff только строкой 1.1.0 создаёт ровно ту же ручную координацию, только с более аккуратным названием. Поэтому version в fixed module проверяется вместе с family, direction и consumerId, а не отдельно.

Это соответствует взрослому компромиссу: карточка чуть длиннее одного сообщения, зато повторяема. В ней нет требования описать каждый будущий интеграционный путь. Есть требование не называть текущий путь безопасным, пока объект сравнения не определён. Если в новом change нет named consumer, можно вернуть stop implicit comparison и поставить задачу на уточнение. Неприятный короткий ответ дешевле уверенного, но ненаблюдаемого разрешения.

Последовательность перед synthetic hand-off

  1. Выбрать baseline. Зафиксировать schema id, version и обязательные поля, от которых зависит рассматриваемый reader.
  2. Описать candidate. Добавить новую форму отдельной карточкой; не менять смысл baseline задним числом.
  3. Собрать manifest. Перечислить additions, removals и type changes; поле вне списка считать неоформленным.
  4. Назвать направление. Записать producer, consumer, contract family и relation backward producer to consumer.
  5. Проверить две границы. Сначала сохранение required surface, затем способность этого reader принять declared additions.
  6. Передать ограниченно. Сохранить status, reasons и next action; положительный результат остаётся synthetic compatibility-review hand-off.

Граничные данные проверяют не красивый объект, а ветку решения

Для такой карточки особенно ценны короткие boundary cases. В module есть удачное additive изменение, удаление state, неоформленный routingHint, строгий reader и неявное сравнение. Они не изображают production payload и не покрывают реальную систему. Их цель скромнее: доказать, что gate не принимает объект только потому, что он похож на удачный. Каждая ошибка получает собственный status и следующее действие.

Например, удаление state не надо прятать в общую ошибку consumer. Gate сначала видит backward incompatibility: required field baseline отсутствует в candidate. Это устраняет соблазн исправить только профиль reader и оставить сам разрыв схемы. Напротив, strict consumer останавливает уже полностью описанный additive candidate. Разные причины должны оставаться разными, иначе следующая встреча снова будет обсуждать симптомы вместо контракта.

Ограничения и следующий шаг

Этот overlay не подключён к schema registry, не читает файлы, не вызывает сеть, не использует CI, Git, telemetry, clock или реальные данные. Он не умеет доказывать совместимость всех будущих readers и не создаёт migration plan. Источники ниже описывают vocabularies и reader-writer resolution, но не подтверждают успешность именно этого synthetic gate или какой-либо deployment. Нельзя переносить его output в production как сертификат.

Следующий разумный шаг — взять один будущий change и составить карточку без произвольной автоматизации: baseline, candidate, manifest, direction и одного конкретного consumer. Если до этой точки неизвестно, кто читает форму, зафиксируйте unknown как результат исследования, а не как пустой список рисков. Когда пара названа, её можно прогнать через обычный compatibility review и получить либо ограниченный hand-off, либо конкретную причину остановки.

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

  • JSON Schema Core, draft-bhutton-json-schema-01 — версия: draft-bhutton-json-schema-01, published 10 June 2022, immutable versioned IETF draft. Draft 2020-12 описывает применение properties и additionalProperties к object instance, поэтому помогает отделить известные и дополнительные поля в vocabulary статьи. Граница: Документ не задаёт правила данного fixed gate, поведение любого consumer или результат deploy.
  • RFC 8927: JSON Type Definition — версия: RFC 8927, November 2020, immutable RFC publication. RFC 8927 различает required properties, optionalProperties и режим additionalProperties, что подтверждает необходимость явно говорить о дополнительных полях. Граница: RFC не определяет contract family, manifest или verdict synthetic review.
  • Apache Avro Specification 1.12.0, exact source commit — версия: Apache Avro 1.12.0, commit 8c27801dc8d42ccc00997f25c0b8f45f8d4a233e, release tag dated 5 August 2024, immutable commit pin. Pinned Avro specification описывает reader and writer schemas и schema resolution, поэтому подтверждает, что направление чтения является техническим вопросом, а не только номером версии. Граница: Avro не доказывает совместимость fixed JSON-like literals и не заменяет named consumer comparison.