Files
progcode/editorial/agent-rewrites/065.json
T

8 lines
20 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 65,
"slug": "editorial-2026-03-mechanism-data-contracts",
"title": "Совместимость схемы — это направление, а не номер версии",
"excerpt": "Как compatibility gate отделяет направление чтения, обязательные поля, объявленные additions и возможности consumer — и почему неопределённость должна останавливать проверку.",
"contentHtml": "<p>Сбой на границе сервисов часто начинается с безобидного изменения: producer выпускает JSON с новым полем, а consumer продолжает читать его как прежний. Один reader игнорирует неизвестный ключ, другой отклоняет объект целиком. Если при этом поле <code>state</code> заменили на похожее <code>phase</code>, ошибка становится ещё дороже: данные формально похожи, но старый код больше не видит обязательное значение.</p>\n<p>Номер версии не отвечает на вопрос о совместимости. Нужно знать, кто пишет, кто читает, относительно какой схемы сравнивается изменение и какие правила действуют у reader. В этой статье разберём узкий compatibility gate: он сравнивает две зафиксированные формы, проверяет направление <code>backward</code> и останавливается, если поле, намерение producer или способность consumer неизвестны.</p>\n<h2>Что означает совместимость</h2>\n<p>В статье используются четыре роли. <strong>Producer</strong> формирует новую запись. <strong>Consumer</strong> получает её. <strong>Writer schema</strong> описывает форму, с которой записали данные, а <strong>reader schema</strong> — форму, которую ожидает читатель. Для простого JSON API эти понятия могут находиться в одном OpenAPI-документе, в коде валидатора и в договорённости команды, но логика вопроса остаётся той же.</p>\n<p>Назовём старую форму <code>baseline</code>, новую форму <code>candidate</code>, а направление <code>backward</code> определим так: reader, рассчитанный на baseline, должен принять запись candidate. Это локальное определение политики, а не универсальный смысл слова для всех платформ. Для проверки обратного сценария — новый reader читает старые данные — нужна отдельная relation. Совместимость не является симметричным boolean.</p>\n<table><caption>Как читать направление изменения</caption><thead><tr><th scope=\"col\">Отношение</th><th scope=\"col\">Что проверяем</th><th scope=\"col\">Типичный риск</th><th scope=\"col\">Минимальное решение</th></tr></thead><tbody><tr><td><code>backward</code></td><td>Старый reader принимает новую запись</td><td>Удалено required-поле или reader отвергает новый ключ</td><td>Сохранить обязательную поверхность и проверить policy reader</td></tr><tr><td><code>forward</code></td><td>Новый reader принимает старую запись</td><td>Новый код ожидает поле, которого нет в старых данных</td><td>Задать default, миграцию или период двойного чтения</td></tr><tr><td><code>full</code></td><td>Оба отношения проходят</td><td>Одно направление проверили, второе подразумевали</td><td>Запустить две отдельные проверки и хранить их результаты</td></tr><tr><td>Неизвестно</td><td>Нельзя определить baseline, candidate или consumer</td><td>Зелёный статус скрывает невыбранное отношение</td><td>Остановить сравнение и запросить недостающую связь</td></tr></tbody></table>\n<h2>Схема не равна смыслу</h2>\n<p>JSON Schema действительно проверяет экземпляр по ограничениям вроде <code>type</code>, <code>required</code> и <code>enum</code>. Это полезный слой: валидатор может показать, что строка не стала числом или что обязательный ключ пропал. Но сама спецификация описывает валидность документа относительно схемы, а не направление миграции, список реальных consumer и значение слова <code>active</code> в конкретном бизнес-процессе.</p>\n<p>Есть и менее очевидная граница. В JSON Schema 2020-12 ключ <code>format</code> может быть аннотацией; обязательное assertion-поведение зависит от заявленного vocabulary и реализации. Поэтому <code>format: date-time</code> нельзя молча считать проверкой существования даты, часового пояса или корректности бизнес-операции. Эти свойства следует закрепить отдельным правилом приложения и проверять тем же reader, который будет использовать значение.</p>\n<p>В бинарных форматах правила могут быть другими. Avro сопоставляет writer schema и reader schema по своим правилам schema resolution. В Protocol Buffers номер поля участвует в wire format, поэтому удалённые номера нельзя бездумно переиспользовать. Эти документы полезны как официальные модели эволюции, но их ограничения нельзя переносить на произвольный JSON endpoint без проверки конкретного сериализатора.</p>\n<figure><img src=\"/assets/editorial/2026/data-contracts-2026-compatibility-gate-loop.svg\" alt=\"Схема проверки совместимости: baseline и candidate проходят через направление, обязательные поля, manifest и policy reader к ограниченному результату\" loading=\"lazy\" /><figcaption>Проверка идёт от зафиксированного отношения к форме, описанию добавлений и возможностям конкретного consumer. Красная ветка означает, что неизвестность сохраняется как причина остановки.</figcaption></figure>\n<h2>Четыре проверки перед положительным verdict</h2>\n<p><strong>Сначала зафиксируйте relation.</strong> В записи должны быть <code>family</code>, <code>direction</code>, <code>baselineVersion</code>, <code>candidateVersion</code>, producer и <code>consumerId</code>. Строка «совместимо с v2» недостаточна: неизвестно, с чьей точки зрения и для какого reader. Если любой идентификатор пуст, результат — остановка, а не допущение.</p>\n<p><strong>Затем сохраните required surface.</strong> В выбранной политике backward каждое required-поле baseline должно остаться в candidate с совместимым типом. Переименование <code>state</code> в <code>phase</code> рассматривайте как удаление и добавление, пока старый consumer не адаптирован. Совпадение смысла в обсуждении не меняет фактического имени ключа.</p>\n<p><strong>После этого сверяйте additions.</strong> Если candidate добавил <code>priority</code>, producer должен объявить его в manifest изменения. Нельзя выводить намерение из одного удачно прочитанного sample: фактический diff мог также содержать <code>routingHint</code>. Скрытое поле сначала нужно объяснить или убрать.</p>\n<p><strong>Последним проверяйте capability reader.</strong> Tolerant reader пропускает дополнительные ключи, strict reader запрещает их. То, что поле optional у producer, не меняет настройки consumer автоматически. Один прошедший сервис не доказывает совместимость остальных readers; verdict нужно хранить для каждой названной пары.</p>\n<h2>Воспроизводимый пример без внешних зависимостей</h2>\n<p>Ниже — минимальный Node.js-скрипт. Он не обращается к registry, сети, базе или реальным сообщениям. Для воспроизводимости сохраните блок во временный файл <code>compatibility-check.mjs</code> и запустите <code>node compatibility-check.mjs</code>. Функция проверяет только требуемые поля, типы, объявленные additions и способность reader принять дополнительные ключи.</p>\n<pre><code>const baseline = {\n required: ['id', 'state'],\n properties: { id: 'string', state: 'string' },\n};\n\nconst candidate = {\n required: ['id', 'state'],\n properties: { id: 'string', state: 'string', priority: 'number' },\n};\n\nconst relation = {\n direction: 'backward',\n family: 'orders',\n baselineVersion: '1.0',\n candidateVersion: '1.1',\n producer: 'orders-api',\n consumerId: 'billing-worker-v1',\n};\n\nfunction check({ baseline, candidate, relation, declaredAdded, acceptsAdditional }) {\n const relationFields = ['direction', 'family', 'baselineVersion',\n 'candidateVersion', 'producer', 'consumerId'];\n if (!relationFields.every((name) =&gt; relation[name])) {\n return { status: 'STOP', reason: 'relation-is-incomplete' };\n }\n\n const missing = baseline.required.filter(\n (name) =&gt; !candidate.required.includes(name),\n );\n const changedType = baseline.required.filter(\n (name) =&gt; candidate.properties[name] !== baseline.properties[name],\n );\n if (missing.length || changedType.length) {\n return { status: 'STOP', reason: 'required-surface-changed', missing, changedType };\n }\n\n const added = Object.keys(candidate.properties)\n .filter((name) =&gt; !Object.hasOwn(baseline.properties, name));\n const undocumented = added.filter((name) =&gt; !declaredAdded.includes(name));\n if (undocumented.length) {\n return { status: 'STOP', reason: 'addition-is-not-declared', undocumented };\n }\n if (added.length &amp;&amp; !acceptsAdditional) {\n return { status: 'STOP', reason: 'reader-rejects-additional-fields', added };\n }\n return { status: 'PASS', reason: 'synthetic-structural-check-only' };\n}\n\nconsole.log(check({\n baseline, candidate, relation,\n declaredAdded: ['priority'],\n acceptsAdditional: true,\n}));\n// { status: 'PASS', reason: 'synthetic-structural-check-only' }</code></pre>\n<p>У этого запуска четыре важных свойства. Отношение заполнено. <code>id</code> и <code>state</code> сохранились с теми же типами. <code>priority</code> назван в <code>declaredAdded</code>. Reader явно допускает дополнительные поля. Положительный результат поэтому означает только прохождение перечисленных структурных правил, а не разрешение на выпуск.</p>\n<h2>Отрицательные случаи важнее зелёного примера</h2>\n<p>Проверка считается полезной, когда она воспроизводимо останавливает опасные варианты. Для первого запуска удалите <code>state</code> из <code>candidate.required</code>. Ожидаемый результат: <code>reason: 'required-surface-changed'</code>. Скрипт не объявляет <code>phase</code> заменой, потому что имена и семантика не угадываются.</p>\n<p>Верните <code>state</code>, добавьте в <code>candidate.properties</code> поле <code>routingHint: 'string'</code>, но не внесите его в <code>declaredAdded</code>. Ожидается <code>addition-is-not-declared</code>. Это проверяет разницу между фактической формой и намерением изменения.</p>\n<p>Наконец, оставьте только <code>priority</code> и замените <code>acceptsAdditional: true</code> на <code>false</code>. Ожидается <code>reader-rejects-additional-fields</code>. Так видно, почему capability относится к named consumer, а не к номеру версии producer. Для неполной relation удалите <code>consumerId</code> и ожидайте <code>relation-is-incomplete</code>.</p>\n<h2>Порядок работы команды</h2>\n<ol><li>Выберите одну contract family и сохраните baseline с обязательными полями, типами, enum, единицами измерения и примером.</li><li>Опишите candidate как отдельную версию; разделите сохранённые, добавленные, удалённые и изменившие тип поля.</li><li>Назовите направление и каждого затронутого consumer. Не заменяйте список readers словом «клиенты».</li><li>Составьте manifest additions и сравните его с фактическим diff схем, DTO или OpenAPI-документов.</li><li>Запустите положительный и отрицательные примеры: удалённое required-поле, новый ключ без manifest, strict reader и неизвестное направление.</li><li>Проверьте отложенные данные: очередь, retry, кэш, архив и отключённый клиент могут доставить старую форму после публикации candidate.</li><li>Сохраните для каждой пары <code>status</code>, <code>reason</code> и <code>nextAction</code>. Общий итог вычисляйте только после адресных результатов.</li></ol>\n<h2>Границы применимости</h2>\n<p>Такой gate проверяет ограниченную структурную поверхность. Он не находит скрытых consumers, не доказывает, что владелец честно описал свои требования, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку, задержки, размер payload или откат. Для этих вопросов нужны contract tests, интеграционные тесты, наблюдаемость и план перехода.</p>\n<p>Он также не ловит семантическую несовместимость. Строка <code>state=active</code> может сохранить имя и тип, но начать означать «активна подписка» вместо «активен заказ». Аналогичный риск есть у времени, денег, идентификаторов и nullable-полей: нужно явно закрепить часовой пояс, валюту, масштаб, источник и правило округления. Формально валидный JSON не делает эти значения правильными.</p>\n<p>Нельзя переносить этот результат между технологиями без адаптации. Для Avro нужно учитывать schema resolution, для protobuf — номера и reserved-поля, для JSON — поведение конкретного parser и настройку дополнительных ключей. Если схема, reader policy или направление неизвестны, единственно честное решение — STOP с запросом к владельцу контракта.</p>\n<h2>Критерий готовности</h2>\n<p>Пара готова к следующему этапу, когда зафиксированы baseline, candidate, direction и named consumer; обязательная поверхность не разрушена; все additions объявлены; capability reader подтверждена; пройдены положительный и отрицательные случаи; накопленные старые данные учтены. Report должен объяснять причину и следующее действие, а не сводить разные ситуации к проценту «совместимости».</p>\n<p>Если проверка не различает <code>backward</code> и <code>forward</code>, принимает пустой <code>consumerId</code> или пропускает новое значение enum только потому, что тип остался строковым, она отвечает не на тот вопрос. Номер <code>1.1</code> может помочь найти две точки сравнения, но не заменяет их содержимое и policy чтения.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-validation\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Draft 2020-12: Validation</a> — официальная спецификация ограничений <code>type</code>, <code>enum</code>, <code>required</code> и <code>format</code>; она описывает валидность экземпляра, но не задаёт deployment-политику совместимости.</li><li><a href=\"https://avro.apache.org/docs/1.12.0/specification/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro 1.12.0: Specification</a> — официальные правила writer schema, reader schema и schema resolution для Avro; их нельзя считать правилами произвольного JSON API.</li><li><a href=\"https://protobuf.dev/programming-guides/proto3/\" target=\"_blank\" rel=\"noopener noreferrer\">Protocol Buffers: Language Guide, proto3</a> — официальные ограничения эволюции protobuf-полей, включая номера и reserved-значения; документ относится к protobuf wire format.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc8927.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8927: JSON Type Definition</a> — нормативное описание required, optional и additional properties в JTD; RFC не решает миграцию consumer и не выдаёт разрешение на выпуск.</li></ul>"
}