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

8 lines
22 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": 64,
"slug": "editorial-2026-03-field-data-contracts",
"title": "Контракты данных: как не принять похожее поле за совместимое",
"excerpt": "Один consumer прочитал новый JSON, но это не доказывает совместимость всей схемы. Разбираем смысл полей, политику reader и fail-closed проверку producer → consumer.",
"contentHtml": "<p>После изменения JSON один сервис продолжает читать сообщения, и команда ставит контракту зелёный статус. Затем другой consumer начинает отбрасывать тот же объект: он запрещает неизвестные поля. Третий ждёт <code>status: paid</code> как признак завершённой оплаты, а producer использует <code>paid</code> для промежуточного состояния после авторизации. Форма объекта похожа, sample проходит, а решение всё равно ломает процесс.</p>\n<p>Цена такой ошибки — не только исключение в логах. Событие может попасть в очередь, сохраниться в архиве и быть обработано позже уже другой версией reader. Поэтому совместимость нельзя приписать JSON «вообще». Её проверяют для конкретной пары <code>producer → consumer</code>, версии, направления передачи и набора правил чтения.</p>\n<h2>Что именно является контрактом</h2>\n<p>Контракт данных — это не перечень ключей. Он отвечает как минимум на пять вопросов: какие поля обязательны, какие типы и единицы измерения допустимы, какие значения имеют деловой смысл, разрешены ли дополнительные поля и как долго сообщение остаётся читаемым. Если в карточке изменения написано только «добавили <code>status</code>», consumer не получил достаточного описания.</p>\n<p>В учебном кейсе producer отправляет заказ. В первой версии поле <code>status</code> означает жизненный цикл заказа: <code>new</code>, <code>paid</code>, <code>cancelled</code>. Другой сервис использует такое же имя для результата платежной операции: <code>authorized</code>, <code>captured</code>, <code>refunded</code>. Оба объекта валидны как JSON, но их значения нельзя смешивать. Совпадение имени не создаёт общей семантики.</p>\n<figure><img src=\"/assets/editorial/2026/data-contracts-2026-producer-consumer-matrix.svg\" alt=\"Матрица совместимости связывает candidate schema с tolerant и strict reader: добавленное поле проходит у tolerant reader, останавливает strict reader, а неизвестная пара требует уточнения связи.\" loading=\"lazy\" /><figcaption>Одна candidate schema даёт разные результаты для разных reader. Поэтому verdict относится к названной связи, а не ко всей системе без исключений.</figcaption></figure>\n<h2>Сначала разделяем форму и смысл</h2>\n<p>Удобно проверять контракт слоями. Первый слой — форма: объект, обязательные поля, типы, вложенность и ограничения размера. Второй — значения: enum, единицы измерения, часовой пояс и переходы между состояниями. Третий — политика consumer: что он делает с неизвестным полем, новым значением enum и отсутствующим необязательным полем. Четвёртый — жизненный цикл: какая версия producer ещё существует и может ли старое сообщение прийти после релиза.</p>\n<p>JSON Schema хорошо описывает первый слой. В спецификации есть <code>type</code>, <code>required</code>, <code>enum</code> и <code>additionalProperties</code>. Но сама структурная проверка не знает, означает ли <code>paid</code> захват денег, постановку операции в очередь или лишь успешную проверку реквизитов. Семантическое правило должно жить в контракте приложения и проверяться отдельным тестом.</p>\n<pre><code>{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"type\": \"object\",\n \"additionalProperties\": false,\n \"required\": [\"orderId\", \"status\", \"occurredAt\"],\n \"properties\": {\n \"orderId\": {\"type\": \"string\", \"minLength\": 1},\n \"status\": {\"enum\": [\"new\", \"paid\", \"cancelled\"]},\n \"occurredAt\": {\"type\": \"string\", \"format\": \"date-time\"}\n }\n}</code></pre>\n<p>В этом фрагменте <code>additionalProperties: false</code> — сознательная строгая политика для данного объекта. Она защищает от опечатки и скрытого расширения, но делает добавление поля изменением, которое требует координации. В расширяемом событии можно выбрать другую политику: разрешать дополнительные поля, документировать их и не использовать их как обязательные для старого reader.</p>\n<h2>Почему один успешный consumer ничего не доказывает</h2>\n<p>Совместимость — отношение, а не свойство producer. У одного события может быть HTTP-клиент, обработчик очереди, архиватор, аналитический загрузчик и старое мобильное приложение. Tolerant reader проигнорирует новое поле, strict reader вернёт ошибку, а аналитический consumer может принять JSON, но неверно посчитать показатель из-за другой трактовки <code>status</code>.</p>\n<p>Отдельно проверяйте направление. Старый producer → новый consumer и новый producer → старый consumer — разные случаи. Добавление необязательного поля часто безопасно для старого tolerant reader, но не для старого strict reader. Удаление обязательного поля опасно в обратную сторону: новый consumer может ожидать его у сообщений, которые ещё пишет старый producer.</p>\n<div class=\"table-scroll\"><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>Добавили необязательное поле</td><td>Strict reader отвергает extra property</td><td>Политику неизвестных полей у каждого consumer</td><td>Мигрировать reader, не добавлять поле или выпускать версию</td></tr><tr><td>Удалили обязательное поле</td><td>Reader не может собрать объект</td><td>Все producer и накопленные сообщения старой версии</td><td>Сначала период совместной поддержки, затем удаление</td></tr><tr><td>Переименовали <code>status</code> в <code>phase</code></td><td>Это удаление и добавление, а не косметика</td><td>Ссылки на старое имя и смысл каждого значения</td><td>Добавить новое поле с явной миграцией или новый контракт</td></tr><tr><td>Добавили значение enum</td><td>Код consumer может иметь закрытый switch</td><td>Поведение на неизвестном значении и запасную ветку</td><td>Расширить reader до отправки нового значения</td></tr><tr><td>Изменили секунды на миллисекунды</td><td>Число валидно, результат неверен в 1000 раз</td><td>Единицу измерения, диапазон и тест на границе</td><td>Назвать единицу в поле или выпустить отдельную форму</td></tr></tbody></table></div>\n<h2>Воспроизводимый fail-closed gate</h2>\n<p>Ниже — минимальный gate без сторонних пакетов. Он не пытается обнаружить всех consumers автоматически: список reader подаётся явно. Скрипт сравнивает обязательные поля и типы, проверяет новые значения <code>status</code> и учитывает политику неизвестных полей. Если связь не названа, он останавливается, а не угадывает.</p>\n<pre><code>// contract-gate.mjs\nconst baseline = {\n required: new Set(['orderId', 'status']),\n types: { orderId: 'string', status: 'string' },\n values: { status: new Set(['new', 'paid', 'cancelled']) },\n properties: new Set(['orderId', 'status'])\n};\n\nconst candidate = {\n required: new Set(['orderId', 'status']),\n types: { orderId: 'string', status: 'string', priority: 'integer' },\n values: { status: new Set(['new', 'paid', 'cancelled']) },\n properties: new Set(['orderId', 'status', 'priority'])\n};\n\nfunction check({ producer, consumer, direction, reader }) {\n if (!producer || !consumer || !direction) {\n return 'STOP relation is not named';\n }\n\n for (const field of baseline.required) {\n if (!candidate.required.has(field)) {\n return `STOP required field removed: ${field}`;\n }\n }\n\n for (const [field, type] of Object.entries(baseline.types)) {\n if (candidate.types[field] !== type) {\n return `STOP type changed: ${field}`;\n }\n }\n\n const added = [...candidate.properties]\n .filter((field) =&gt; !baseline.properties.has(field));\n if (added.length &amp;&amp; !reader.allowUnknownProperties) {\n return `STOP unknown fields: ${added.join(', ')}`;\n }\n\n const oldStatuses = baseline.values.status;\n const newStatuses = candidate.values.status;\n const introduced = [...newStatuses].filter((value) =&gt; !oldStatuses.has(value));\n if (introduced.some((value) =&gt; !reader.statusValues.has(value))) {\n return `STOP unknown status value: ${introduced.join(', ')}`;\n }\n\n return 'PASS compatible for this named reader';\n}\n\nconst strictReader = {\n allowUnknownProperties: false,\n statusValues: new Set(['new', 'paid', 'cancelled'])\n};\nconst tolerantReader = {\n allowUnknownProperties: true,\n statusValues: new Set(['new', 'paid', 'cancelled'])\n};\n\nconsole.log(check({\n producer: 'orders-api',\n consumer: 'billing-worker',\n direction: 'producer-writes-consumer-reads',\n reader: strictReader\n}));\nconsole.log(check({\n producer: 'orders-api',\n consumer: 'analytics-loader',\n direction: 'producer-writes-consumer-reads',\n reader: tolerantReader\n}));\nconsole.log(check({ producer: 'orders-api', consumer: '', direction: '', reader: strictReader }));</code></pre>\n<p>Сохраните код в <code>contract-gate.mjs</code> и выполните:</p>\n<pre><code>node --check contract-gate.mjs\nnode contract-gate.mjs</code></pre>\n<p>Ожидаемый вывод:</p>\n<pre><code>STOP unknown fields: priority\nPASS compatible for this named reader\nSTOP relation is not named</code></pre>\n<p>Это не готовый schema registry и не доказательство успешной доставки. Gate демонстрирует порядок: сначала связь, затем breaking change, затем политика reader. В реальном проекте типы и значения следует брать из версионируемой схемы, а список reader — из inventory интеграций, конфигурации маршрутов и consumer contract tests.</p>\n<h2>Отдельно проверяем жизненный цикл status</h2>\n<p>Структура прошла — это только половина проверки. Для состояний задайте таблицу переходов: кто имеет право перевести заказ, какие события допустимы повторно и какое состояние считается финальным. Например, <code>paid → new</code> может быть запрещённым переходом, а повторное <code>paid</code> — допустимым при повторной доставке события. Эти правила не выводятся из JSON Schema.</p>\n<p>Не прячьте единицу времени в описании команды. Поля <code>occurredAt</code> и <code>receivedAt</code> отвечают на разные вопросы, а число без единицы измерения не даёт воспроизводимого контракта. Для времени зафиксируйте формат, часовой пояс и правило сравнения. Для денег зафиксируйте валюту, масштаб и способ округления. Число, строка и валидный ISO 8601 сами по себе не гарантируют деловой корректности.</p>\n<p>Для бинарных схем риск может выглядеть иначе. В Protocol Buffers номер поля участвует в wire format и не должен меняться или переиспользоваться; удалённые номера и имена рекомендуется резервировать, чтобы позднее не создать конфликт. В Avro reader и writer schema сопоставляются по правилам schema resolution. Это полезные модели, но их правила нельзя механически переносить на произвольный JSON API.</p>\n<h2>Порядок проверки изменения</h2>\n<ol><li><strong>Назовите отношение.</strong> Запишите contract family, producer, consumer, направление, транспорт и версию. Если consumer неизвестен, результат — остановка и поиск владельца.</li><li><strong>Сохраните baseline.</strong> Зафиксируйте обязательные поля, типы, enum, единицы измерения, ограничения и примеры до изменения.</li><li><strong>Опишите candidate.</strong> Разделите сохранённые, удалённые, переименованные и добавленные поля. Переименование рассматривайте как изменение формы и смысла, пока обратное не доказано.</li><li><strong>Проверьте reader policy.</strong> Узнайте, принимает ли consumer дополнительные поля и значения enum. Не выводите это из того, что один sample однажды прочитался.</li><li><strong>Проверьте отрицательные случаи.</strong> Удалите обязательное поле, добавьте неизвестное поле, передайте новое значение enum, смените единицу времени и отправьте сообщение в обратном направлении.</li><li><strong>Проверьте накопленные данные.</strong> Очередь, retry, архив и offline-клиенты могут доставить старую форму после релиза. Укажите срок совместного чтения и способ удаления старой версии.</li><li><strong>Сохраните адресный verdict.</strong> Запишите не только PASS или STOP, но и reader, входную версию, причину отказа и следующее действие. Общий итог можно вычислять только после результатов всех названных пар.</li></ol>\n<h2>Границы применимости</h2>\n<p>Описанный gate проверяет ограниченную структурную и перечислимую часть контракта. Он не обнаруживает скрытых consumers, не анализирует код всех клиентов, не проверяет права доступа, дедупликацию, порядок сообщений, нагрузку или корректность бизнес-переходов. Для этого нужны отдельные тесты, наблюдаемость и владелец интеграции.</p>\n<p>Даже строгая JSON Schema не делает API безопасным от неверного смысла. Спецификация описывает валидацию экземпляра; она не знает, что <code>status</code> относится к заказу, а не к платежу. Поле <code>format</code> также нельзя считать сетевой проверкой: в JSON Schema 2020-12 режим annotation и режим assertion различаются, а поддержка зависит от реализации и её конфигурации.</p>\n<p>Не объявляйте добавление поля безопасным без проверки политики reader. Не объявляйте удаление поля безопасным без проверки отложенных сообщений. Не объявляйте два объекта совместимыми только из-за одинаковых ключей. Если не хватает данных о producer, consumer или направлении, честный результат — STOP с конкретным запросом к владельцу.</p>\n<h2>Критерий готовности</h2>\n<p>Изменение можно передавать дальше, когда для каждой известной пары сохранены baseline и candidate, описаны обязательные поля и значения, подтверждена политика reader, пройдены положительные и отрицательные тесты, а старые сообщения имеют срок совместной поддержки. В отчёте видны отдельные результаты для каждого consumer. Ни один общий зелёный статус не скрывает strict reader, неизвестное значение или неописанную связь.</p>\n<p>Практическая проверка готовности занимает несколько минут: возьмите новый объект, удалите из него обязательное поле, добавьте неизвестное поле и замените одно значение <code>status</code>. Скрипт должен остановиться на каждой нарушенной границе. Если он пропускает <code>status: refunded</code> для reader, который его не знает, проверка касается только формы. Если он пропускает пустое направление, результат нельзя связывать с конкретным контрактом.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-validation.html\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema Draft 2020-12: Validation</a> — определяет <code>type</code>, <code>required</code>, <code>enum</code>, объектные ограничения и различие format-annotation/format-assertion.</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 не являются универсальным verdict для JSON API.</li><li><a href=\"https://protobuf.dev/programming-guides/proto3/\" target=\"_blank\" rel=\"noopener noreferrer\">Protocol Buffers: Language Guide, proto3</a> — фиксирует ограничения на изменение и повторное использование номеров и имён полей; они относятся к protobuf-сообщениям и их форматам.</li></ul>"
}