revise March 2026 data contract articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 19:01:51 +03:00
parent 9812aa0c63
commit ec4cfbbd60
7 changed files with 1019 additions and 1 deletions
+2
View File
@@ -93,6 +93,7 @@ import { revisions as november2025Revisions } from '../scripts/upgrade-2025-11.m
import { revisions as december2025Revisions } from '../scripts/upgrade-2025-12.mjs';
import { revisions as january2026Revisions } from '../scripts/upgrade-2026-01.mjs';
import { revisions as february2026Revisions } from '../scripts/upgrade-2026-02.mjs';
import { revisions as march2026Revisions } from '../scripts/upgrade-2026-03.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -191,4 +192,5 @@ export const editorialRevisions = [
...december2025Revisions,
...january2026Revisions,
...february2026Revisions,
...march2026Revisions,
];
@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 720" role="img" aria-labelledby="title desc">
<title id="title">Цикл automated compatibility gate</title>
<desc id="desc">Пять шагов проводят fixed schema case через явное сравнение, required surface, manifest и capability consumer. У каждой ошибки есть отдельная красная ветка возврата, положительная ветка заканчивается ограниченным synthetic hand-off.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#24415d"/>
</marker>
<marker id="redArrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#b23a48"/>
</marker>
<style>
.bg { fill: #f6f8fb; }
.step { fill: #ffffff; stroke: #24415d; stroke-width: 3; }
.ok { fill: #e7f3ef; stroke: #237a57; stroke-width: 3; }
.stop { fill: #fdecee; stroke: #b23a48; stroke-width: 3; }
.line { stroke: #24415d; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.red { stroke: #b23a48; stroke-width: 3; fill: none; marker-end: url(#redArrow); }
.title { font: 700 34px Arial, sans-serif; fill: #18324a; }
.label { font: 700 22px Arial, sans-serif; fill: #18324a; }
.text { font: 18px Arial, sans-serif; fill: #294860; }
.good { font: 700 18px Arial, sans-serif; fill: #126244; }
.bad { font: 700 16px Arial, sans-serif; fill: #8d2632; }
</style>
</defs>
<rect class="bg" width="960" height="720" rx="28"/>
<text class="title" x="56" y="68">У каждого stop есть своя причина</text>
<rect class="step" x="58" y="142" width="164" height="126" rx="18"/>
<text class="label" x="84" y="184">fixed case</text>
<text class="text" x="84" y="216">baseline</text>
<text class="text" x="84" y="242">candidate + reader</text>
<path class="line" d="M224 205 L286 205"/>
<rect class="step" x="292" y="142" width="164" height="126" rx="18"/>
<text class="label" x="318" y="184">relation</text>
<text class="text" x="318" y="216">direction</text>
<text class="text" x="318" y="242">family + versions</text>
<path class="line" d="M458 205 L520 205"/>
<rect class="step" x="526" y="142" width="164" height="126" rx="18"/>
<text class="label" x="552" y="184">schema diff</text>
<text class="text" x="552" y="216">required fields</text>
<text class="text" x="552" y="242">type changes</text>
<path class="line" d="M692 205 L754 205"/>
<rect class="step" x="760" y="142" width="150" height="126" rx="18"/>
<text class="label" x="784" y="184">manifest</text>
<text class="text" x="784" y="216">declared</text>
<text class="text" x="784" y="242">additions</text>
<path class="line" d="M835 270 L835 338"/>
<rect class="step" x="742" y="344" width="168" height="126" rx="18"/>
<text class="label" x="768" y="386">consumer</text>
<text class="text" x="768" y="418">required fields</text>
<text class="text" x="768" y="444">addition policy</text>
<path class="line" d="M740 407 L654 407"/>
<rect class="ok" x="432" y="344" width="216" height="126" rx="18"/>
<text class="good" x="458" y="388">SYNTHETIC</text>
<text class="good" x="458" y="418">REVIEW HAND-OFF</text>
<text class="text" x="458" y="446">no deploy effect</text>
<path class="red" d="M374 270 L374 330 L274 330 L274 518"/>
<rect class="stop" x="58" y="522" width="206" height="112" rx="16"/>
<text class="bad" x="80" y="560">implicit comparison</text>
<text class="text" x="80" y="590">name relation</text>
<path class="red" d="M608 270 L608 518 L508 518"/>
<rect class="stop" x="286" y="522" width="210" height="112" rx="16"/>
<text class="bad" x="308" y="560">backward break</text>
<text class="text" x="308" y="590">repair surface</text>
<path class="red" d="M836 270 L836 304 L724 304 L724 518"/>
<rect class="stop" x="520" y="522" width="190" height="112" rx="16"/>
<text class="bad" x="542" y="560">hidden field</text>
<text class="text" x="542" y="590">repair manifest</text>
<path class="red" d="M826 470 L826 506 L826 518"/>
<rect class="stop" x="734" y="522" width="176" height="112" rx="16"/>
<text class="bad" x="756" y="560">strict reader</text>
<text class="text" x="756" y="590">separate decision</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 720" role="img" aria-labelledby="title desc">
<title id="title">Эволюция fixed контракта данных через compatibility gate</title>
<desc id="desc">Слева baseline schema версии 1.0, справа candidate schema версии 1.1 с полем priority. Между ними gate проверяет direction, manifest и consumer. Скрытое поле routingHint ведёт к отдельному stop.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#24415d"/>
</marker>
<marker id="stopArrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#b23a48"/>
</marker>
<style>
.bg { fill: #f6f8fb; }
.card { fill: #ffffff; stroke: #24415d; stroke-width: 3; }
.gate { fill: #e7f3ef; stroke: #237a57; stroke-width: 3; }
.stop { fill: #fdecee; stroke: #b23a48; stroke-width: 3; }
.line { stroke: #24415d; stroke-width: 4; fill: none; marker-end: url(#arrow); }
.stop-line { stroke: #b23a48; stroke-width: 4; fill: none; marker-end: url(#stopArrow); }
.title { font: 700 34px Arial, sans-serif; fill: #18324a; }
.label { font: 700 24px Arial, sans-serif; fill: #18324a; }
.text { font: 20px Arial, sans-serif; fill: #294860; }
.small { font: 18px Arial, sans-serif; fill: #294860; }
.good { font: 700 19px Arial, sans-serif; fill: #126244; }
.bad { font: 700 18px Arial, sans-serif; fill: #8d2632; }
.mono { font: 18px "Courier New", monospace; fill: #18324a; }
</style>
</defs>
<rect class="bg" width="960" height="720" rx="28"/>
<text class="title" x="64" y="68">Изменение схемы — это пара reader и writer</text>
<rect class="card" x="56" y="128" width="232" height="360" rx="20"/>
<text class="label" x="82" y="172">baseline v1.0</text>
<text class="small" x="82" y="206">fixed-work-item</text>
<line x1="82" y1="226" x2="260" y2="226" stroke="#b9cad8" stroke-width="2"/>
<text class="mono" x="82" y="268">id: string *</text>
<text class="mono" x="82" y="310">state: string *</text>
<text class="mono" x="82" y="352">note: string</text>
<text class="small" x="82" y="420">* required surface</text>
<text class="small" x="82" y="452">reader knows this</text>
<path class="line" d="M290 308 C322 308 332 308 356 308"/>
<rect class="gate" x="362" y="150" width="246" height="320" rx="20"/>
<text class="label" x="392" y="194">compatibility gate</text>
<line x1="392" y1="214" x2="578" y2="214" stroke="#91c9af" stroke-width="2"/>
<text class="good" x="392" y="252">1. direction named</text>
<text class="good" x="392" y="292">2. required stays</text>
<text class="good" x="392" y="332">3. manifest matches</text>
<text class="good" x="392" y="372">4. consumer accepts</text>
<text class="small" x="392" y="426">output: review hand-off</text>
<path class="line" d="M610 308 C642 308 654 308 680 308"/>
<rect class="card" x="686" y="128" width="220" height="360" rx="20"/>
<text class="label" x="712" y="172">candidate v1.1</text>
<text class="small" x="712" y="206">fixed-work-item</text>
<line x1="712" y1="226" x2="878" y2="226" stroke="#b9cad8" stroke-width="2"/>
<text class="mono" x="712" y="268">id: string *</text>
<text class="mono" x="712" y="310">state: string *</text>
<text class="mono" x="712" y="352">note: string</text>
<text class="mono" x="712" y="394">priority: integer</text>
<text class="good" x="712" y="448">declared addition</text>
<path class="stop-line" d="M796 492 C796 536 754 550 704 550 L610 550"/>
<rect class="stop" x="274" y="530" width="330" height="126" rx="18"/>
<text class="bad" x="302" y="574">routingHint вне manifest</text>
<text class="bad" x="302" y="610">stop-undocumented-schema-field</text>
<text class="small" x="302" y="638">сначала назвать или убрать поле</text>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 720" role="img" aria-labelledby="title desc">
<title id="title">Матрица compatibility review для producer и consumer</title>
<desc id="desc">Таблица сравнивает fixed candidate changes и profiles consumer. Tolerant reader допускает объявленное поле priority, strict reader останавливает review, скрытое поле и неявное сравнение получают собственные статусы.</desc>
<defs>
<style>
.bg { fill: #f6f8fb; }
.header { fill: #24415d; }
.rowhead { fill: #e8eef4; }
.ok { fill: #e7f3ef; }
.stop { fill: #fdecee; }
.unknown { fill: #fff4d6; }
.grid { stroke: #ffffff; stroke-width: 4; }
.title { font: 700 34px Arial, sans-serif; fill: #18324a; }
.head { font: 700 21px Arial, sans-serif; fill: #ffffff; }
.label { font: 700 20px Arial, sans-serif; fill: #18324a; }
.text { font: 18px Arial, sans-serif; fill: #294860; }
.good { font: 700 18px Arial, sans-serif; fill: #126244; }
.bad { font: 700 17px Arial, sans-serif; fill: #8d2632; }
.warn { font: 700 17px Arial, sans-serif; fill: #9a6815; }
</style>
</defs>
<rect class="bg" width="960" height="720" rx="28"/>
<text class="title" x="56" y="68">Один schema diff не даёт один verdict</text>
<text class="text" x="56" y="104">Статус живёт в пересечении named producer, change и capability reader.</text>
<rect class="header" x="56" y="154" width="266" height="92" rx="14"/>
<rect class="header" x="326" y="154" width="190" height="92" rx="14"/>
<rect class="header" x="520" y="154" width="190" height="92" rx="14"/>
<rect class="header" x="714" y="154" width="190" height="92" rx="14"/>
<text class="head" x="80" y="194">candidate change</text>
<text class="head" x="80" y="222">и relation</text>
<text class="head" x="348" y="194">tolerant</text>
<text class="head" x="348" y="222">reader</text>
<text class="head" x="544" y="194">strict</text>
<text class="head" x="544" y="222">reader</text>
<text class="head" x="738" y="194">unnamed</text>
<text class="head" x="738" y="222">relation</text>
<rect class="rowhead" x="56" y="250" width="266" height="118" rx="14"/>
<rect class="ok" x="326" y="250" width="190" height="118" rx="14"/>
<rect class="stop" x="520" y="250" width="190" height="118" rx="14"/>
<rect class="unknown" x="714" y="250" width="190" height="118" rx="14"/>
<text class="label" x="80" y="290">v1.1 adds</text>
<text class="label" x="80" y="318">priority (declared)</text>
<text class="good" x="346" y="294">HAND-OFF</text>
<text class="text" x="346" y="326">accepts additions</text>
<text class="bad" x="542" y="294">STOP</text>
<text class="bad" x="542" y="324">incompatible</text>
<text class="warn" x="736" y="294">STOP</text>
<text class="warn" x="736" y="324">implicit relation</text>
<rect class="rowhead" x="56" y="372" width="266" height="118" rx="14"/>
<rect class="stop" x="326" y="372" width="190" height="118" rx="14"/>
<rect class="stop" x="520" y="372" width="190" height="118" rx="14"/>
<rect class="unknown" x="714" y="372" width="190" height="118" rx="14"/>
<text class="label" x="80" y="412">v2 removes</text>
<text class="label" x="80" y="440">required state</text>
<text class="bad" x="346" y="416">STOP</text>
<text class="bad" x="346" y="446">backward break</text>
<text class="bad" x="542" y="416">STOP</text>
<text class="bad" x="542" y="446">backward break</text>
<text class="warn" x="736" y="416">NO REVIEW</text>
<text class="warn" x="736" y="446">pair is unnamed</text>
<rect class="rowhead" x="56" y="494" width="266" height="118" rx="14"/>
<rect class="stop" x="326" y="494" width="578" height="118" rx="14"/>
<text class="label" x="80" y="534">v1.1 adds</text>
<text class="label" x="80" y="562">routingHint (hidden)</text>
<text class="bad" x="352" y="538">STOP — undocumented schema field</text>
<text class="text" x="352" y="572">consumer policy cannot approve a field absent from manifest</text>
<text class="text" x="56" y="664">Зелёная ячейка не означает deploy: она передаёт только fixed synthetic compatibility review.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+684
View File
@@ -0,0 +1,684 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function deepFreeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.values(value).forEach(deepFreeze);
Object.freeze(value);
}
return value;
}
function cloneFixed(value) {
return JSON.parse(JSON.stringify(value));
}
const REFERENCES = deepFreeze({
jsonSchema: {
title: 'JSON Schema Core, draft-bhutton-json-schema-01',
url: 'https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-01',
version: 'draft-bhutton-json-schema-01, published 10 June 2022, immutable versioned IETF draft',
},
jtd: {
title: 'RFC 8927: JSON Type Definition',
url: 'https://www.rfc-editor.org/rfc/rfc8927.html',
version: 'RFC 8927, November 2020, immutable RFC publication',
},
avro: {
title: 'Apache Avro Specification 1.12.0, exact source commit',
url: 'https://github.com/apache/avro/blob/8c27801dc8d42ccc00997f25c0b8f45f8d4a233e/doc/content/en/docs/%2B%2Bversion%2B%2B/Specification/_index.md#schema-resolution',
version: 'Apache Avro 1.12.0, commit 8c27801dc8d42ccc00997f25c0b8f45f8d4a233e, release tag dated 5 August 2024, immutable commit pin',
},
});
function sourceList(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const reference = REFERENCES[key];
return '<li><a href="' + reference.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(reference.title) + '</a> — версия: ' + escapeHtml(reference.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
const FIXED_DATA_CONTRACT_CASES = deepFreeze({
'compatible-additive-v2': {
id: 'compatible-additive-v2',
baselineSchema: {
id: 'fixed-work-item-v1',
contractFamily: 'fixed-work-item',
version: '1.0.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
],
},
candidateSchema: {
id: 'fixed-work-item-v1-1',
contractFamily: 'fixed-work-item',
version: '1.1.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
{ name: 'priority', type: 'integer', required: false },
],
},
changeManifest: {
baselineSchemaId: 'fixed-work-item-v1',
candidateSchemaId: 'fixed-work-item-v1-1',
declaredAddedFields: ['priority'],
declaredRemovedFields: [],
declaredTypeChanges: [],
},
producer: {
id: 'fixed-producer-v1-1',
schemaId: 'fixed-work-item-v1-1',
},
consumer: {
id: 'fixed-tolerant-reader-v1',
contractFamily: 'fixed-work-item',
requiredFields: ['id', 'state'],
acceptsDeclaredAddedFields: true,
supportedCandidateVersions: ['1.1.0'],
},
comparison: {
direction: 'backward-producer-to-consumer',
contractFamily: 'fixed-work-item',
baselineVersion: '1.0.0',
candidateVersion: '1.1.0',
consumerId: 'fixed-tolerant-reader-v1',
},
boundary: 'fixed synthetic schemas, versions, producer, consumer, manifest and boundary data in memory; no registry, network, filesystem, Git, CI, clock, telemetry, production data, API or deployment',
},
'backward-incompatible-v2': {
id: 'backward-incompatible-v2',
baselineSchema: {
id: 'fixed-work-item-v1',
contractFamily: 'fixed-work-item',
version: '1.0.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
],
},
candidateSchema: {
id: 'fixed-work-item-v2',
contractFamily: 'fixed-work-item',
version: '2.0.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'phase', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
],
},
changeManifest: {
baselineSchemaId: 'fixed-work-item-v1',
candidateSchemaId: 'fixed-work-item-v2',
declaredAddedFields: ['phase'],
declaredRemovedFields: ['state'],
declaredTypeChanges: [],
},
producer: {
id: 'fixed-producer-v2',
schemaId: 'fixed-work-item-v2',
},
consumer: {
id: 'fixed-tolerant-reader-v1',
contractFamily: 'fixed-work-item',
requiredFields: ['id', 'state'],
acceptsDeclaredAddedFields: true,
supportedCandidateVersions: ['2.0.0'],
},
comparison: {
direction: 'backward-producer-to-consumer',
contractFamily: 'fixed-work-item',
baselineVersion: '1.0.0',
candidateVersion: '2.0.0',
consumerId: 'fixed-tolerant-reader-v1',
},
boundary: 'fixed synthetic schemas, versions, producer, consumer, manifest and boundary data in memory; no registry, network, filesystem, Git, CI, clock, telemetry, production data, API or deployment',
},
'undocumented-schema-field-v2': {
id: 'undocumented-schema-field-v2',
baselineSchema: {
id: 'fixed-work-item-v1',
contractFamily: 'fixed-work-item',
version: '1.0.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
],
},
candidateSchema: {
id: 'fixed-work-item-v1-1-with-hidden-field',
contractFamily: 'fixed-work-item',
version: '1.1.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
{ name: 'priority', type: 'integer', required: false },
{ name: 'routingHint', type: 'string', required: false },
],
},
changeManifest: {
baselineSchemaId: 'fixed-work-item-v1',
candidateSchemaId: 'fixed-work-item-v1-1-with-hidden-field',
declaredAddedFields: ['priority'],
declaredRemovedFields: [],
declaredTypeChanges: [],
},
producer: {
id: 'fixed-producer-v1-1-with-hidden-field',
schemaId: 'fixed-work-item-v1-1-with-hidden-field',
},
consumer: {
id: 'fixed-tolerant-reader-v1',
contractFamily: 'fixed-work-item',
requiredFields: ['id', 'state'],
acceptsDeclaredAddedFields: true,
supportedCandidateVersions: ['1.1.0'],
},
comparison: {
direction: 'backward-producer-to-consumer',
contractFamily: 'fixed-work-item',
baselineVersion: '1.0.0',
candidateVersion: '1.1.0',
consumerId: 'fixed-tolerant-reader-v1',
},
boundary: 'fixed synthetic schemas, versions, producer, consumer, manifest and boundary data in memory; no registry, network, filesystem, Git, CI, clock, telemetry, production data, API or deployment',
},
'incompatible-consumer-v2': {
id: 'incompatible-consumer-v2',
baselineSchema: {
id: 'fixed-work-item-v1',
contractFamily: 'fixed-work-item',
version: '1.0.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
],
},
candidateSchema: {
id: 'fixed-work-item-v1-1',
contractFamily: 'fixed-work-item',
version: '1.1.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
{ name: 'priority', type: 'integer', required: false },
],
},
changeManifest: {
baselineSchemaId: 'fixed-work-item-v1',
candidateSchemaId: 'fixed-work-item-v1-1',
declaredAddedFields: ['priority'],
declaredRemovedFields: [],
declaredTypeChanges: [],
},
producer: {
id: 'fixed-producer-v1-1',
schemaId: 'fixed-work-item-v1-1',
},
consumer: {
id: 'fixed-strict-reader-v1',
contractFamily: 'fixed-work-item',
requiredFields: ['id', 'state'],
acceptsDeclaredAddedFields: false,
supportedCandidateVersions: ['1.1.0'],
},
comparison: {
direction: 'backward-producer-to-consumer',
contractFamily: 'fixed-work-item',
baselineVersion: '1.0.0',
candidateVersion: '1.1.0',
consumerId: 'fixed-strict-reader-v1',
},
boundary: 'fixed synthetic schemas, versions, producer, consumer, manifest and boundary data in memory; no registry, network, filesystem, Git, CI, clock, telemetry, production data, API or deployment',
},
'implicit-comparison-v2': {
id: 'implicit-comparison-v2',
baselineSchema: {
id: 'fixed-work-item-v1',
contractFamily: 'fixed-work-item',
version: '1.0.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
],
},
candidateSchema: {
id: 'fixed-work-item-v1-1',
contractFamily: 'fixed-work-item',
version: '1.1.0',
closed: true,
fields: [
{ name: 'id', type: 'string', required: true },
{ name: 'state', type: 'string', required: true },
{ name: 'note', type: 'string', required: false },
{ name: 'priority', type: 'integer', required: false },
],
},
changeManifest: {
baselineSchemaId: 'fixed-work-item-v1',
candidateSchemaId: 'fixed-work-item-v1-1',
declaredAddedFields: ['priority'],
declaredRemovedFields: [],
declaredTypeChanges: [],
},
producer: {
id: 'fixed-producer-v1-1',
schemaId: 'fixed-work-item-v1-1',
},
consumer: {
id: 'fixed-tolerant-reader-v1',
contractFamily: 'fixed-work-item',
requiredFields: ['id', 'state'],
acceptsDeclaredAddedFields: true,
supportedCandidateVersions: ['1.1.0'],
},
comparison: {
direction: 'shape-looks-close',
contractFamily: 'fixed-work-item',
baselineVersion: '',
candidateVersion: '1.1.0',
consumerId: '',
},
boundary: 'fixed synthetic schemas, versions, producer, consumer, manifest and boundary data in memory; no registry, network, filesystem, Git, CI, clock, telemetry, production data, API or deployment',
},
});
function isKnownFixed(value, collection) {
return Object.values(collection).some((candidate) => JSON.stringify(candidate) === JSON.stringify(value));
}
function fieldMap(schema) {
return new Map(schema.fields.map((field) => [field.name, field]));
}
function includesAll(values, required) {
return required.every((item) => values.includes(item));
}
function explicitComparison(item) {
const { comparison, baselineSchema, candidateSchema, consumer } = item;
return comparison.direction === 'backward-producer-to-consumer'
&& comparison.contractFamily === baselineSchema.contractFamily
&& comparison.contractFamily === candidateSchema.contractFamily
&& comparison.contractFamily === consumer.contractFamily
&& comparison.baselineVersion === baselineSchema.version
&& comparison.candidateVersion === candidateSchema.version
&& comparison.consumerId === consumer.id;
}
export function createFixedDataContractCase(id) {
const item = FIXED_DATA_CONTRACT_CASES[id];
return item ? deepFreeze(cloneFixed(item)) : null;
}
export function inspectFixedSchemaChange(item) {
if (!isKnownFixed(item, FIXED_DATA_CONTRACT_CASES)) {
return deepFreeze({
accepted: false,
status: 'stop-unknown-fixed-contract-case',
reasons: ['case-is-not-a-named-fixed-literal'],
productionEffect: 'not-attempted',
});
}
const baselineFields = fieldMap(item.baselineSchema);
const candidateFields = fieldMap(item.candidateSchema);
const addedFields = item.candidateSchema.fields
.filter((field) => !baselineFields.has(field.name))
.map((field) => field.name);
const removedRequiredFields = item.baselineSchema.fields
.filter((field) => field.required && !candidateFields.has(field.name))
.map((field) => field.name);
const changedRequiredFields = item.baselineSchema.fields
.filter((field) => field.required && candidateFields.has(field.name) && candidateFields.get(field.name).type !== field.type)
.map((field) => field.name);
const undocumentedFields = addedFields.filter((name) => !item.changeManifest.declaredAddedFields.includes(name));
const invalidManifestFields = item.changeManifest.declaredAddedFields
.filter((name) => !candidateFields.has(name) || baselineFields.has(name));
return deepFreeze({
accepted: true,
status: 'fixed-schema-change-inspected',
addedFields: deepFreeze(addedFields),
removedRequiredFields: deepFreeze(removedRequiredFields),
changedRequiredFields: deepFreeze(changedRequiredFields),
undocumentedFields: deepFreeze(undocumentedFields),
invalidManifestFields: deepFreeze(invalidManifestFields),
baseline: item.baselineSchema.id,
candidate: item.candidateSchema.id,
boundary: item.boundary,
productionEffect: 'not-attempted',
});
}
export function reviewFixedDataContractCompatibility(item) {
if (!isKnownFixed(item, FIXED_DATA_CONTRACT_CASES)) {
return deepFreeze({
accepted: false,
status: 'stop-unknown-fixed-contract-case',
reasons: ['case-is-not-a-named-fixed-literal'],
nextAction: 'use-a-named-fixed-contract-case',
productionEffect: 'not-attempted',
});
}
const inspection = inspectFixedSchemaChange(item);
const candidateFields = fieldMap(item.candidateSchema);
const reasons = [];
if (!explicitComparison(item)) {
reasons.push('implicit-comparison');
}
if (inspection.undocumentedFields.length || inspection.invalidManifestFields.length) {
reasons.push('undocumented-schema-field');
}
if (inspection.removedRequiredFields.length || inspection.changedRequiredFields.length) {
reasons.push('backward-incompatible-schema');
}
if (!item.consumer.supportedCandidateVersions.includes(item.candidateSchema.version)) {
reasons.push('consumer-does-not-name-candidate-version');
}
if (!includesAll([...candidateFields.keys()], item.consumer.requiredFields)) {
reasons.push('consumer-required-field-is-absent');
}
if (inspection.addedFields.length && !item.consumer.acceptsDeclaredAddedFields) {
reasons.push('incompatible-consumer');
}
let status = 'synthetic-compatibility-review-hand-off';
let nextAction = 'hand-off-named-fixed-compatibility-review';
if (reasons.includes('implicit-comparison')) {
status = 'stop-implicit-comparison';
nextAction = 'name-direction-baseline-candidate-and-consumer';
} else if (reasons.includes('undocumented-schema-field')) {
status = 'stop-undocumented-schema-field';
nextAction = 'declare-each-added-field-or-remove-it';
} else if (reasons.includes('backward-incompatible-schema')) {
status = 'stop-backward-incompatible-schema';
nextAction = 'retain-required-baseline-field-or-name-a-separate-migration';
} else if (reasons.includes('incompatible-consumer') || reasons.includes('consumer-required-field-is-absent') || reasons.includes('consumer-does-not-name-candidate-version')) {
status = 'stop-incompatible-consumer';
nextAction = 'adapt-named-consumer-or-hold-candidate-from-this-handoff';
}
return deepFreeze({
accepted: reasons.length === 0,
status,
reasons: deepFreeze(reasons),
nextAction,
producer: item.producer.id,
consumer: item.consumer.id,
comparison: item.comparison.direction,
addedFields: inspection.addedFields,
removedRequiredFields: inspection.removedRequiredFields,
changedRequiredFields: inspection.changedRequiredFields,
undocumentedFields: inspection.undocumentedFields,
boundary: item.boundary,
productionEffect: 'not-attempted',
});
}
export function runFixedDataContractFixture() {
const compatible = reviewFixedDataContractCompatibility(createFixedDataContractCase('compatible-additive-v2'));
const backward = reviewFixedDataContractCompatibility(createFixedDataContractCase('backward-incompatible-v2'));
const undocumented = reviewFixedDataContractCompatibility(createFixedDataContractCase('undocumented-schema-field-v2'));
const strictConsumer = reviewFixedDataContractCompatibility(createFixedDataContractCase('incompatible-consumer-v2'));
const implicit = reviewFixedDataContractCompatibility(createFixedDataContractCase('implicit-comparison-v2'));
const inspected = inspectFixedSchemaChange(createFixedDataContractCase('compatible-additive-v2'));
const unknown = reviewFixedDataContractCompatibility({ id: 'invented-fixed-contract-case' });
return deepFreeze({
assertions: deepFreeze({
positivePathHandsOffOnly: compatible.status === 'synthetic-compatibility-review-hand-off' && compatible.productionEffect === 'not-attempted',
positivePathIsAccepted: compatible.accepted,
positivePathNamesAddedField: JSON.stringify(compatible.addedFields) === JSON.stringify(['priority']),
inspectionNamesSchemas: inspected.baseline === 'fixed-work-item-v1' && inspected.candidate === 'fixed-work-item-v1-1',
inspectionHasNoHiddenField: inspected.undocumentedFields.length === 0,
backwardIncompatibilityStops: backward.status === 'stop-backward-incompatible-schema',
backwardIncompatibilityNamesRemovedField: backward.removedRequiredFields.includes('state'),
undocumentedFieldStops: undocumented.status === 'stop-undocumented-schema-field',
undocumentedFieldNamesRoutingHint: undocumented.undocumentedFields.includes('routingHint'),
incompatibleConsumerStops: strictConsumer.status === 'stop-incompatible-consumer',
incompatibleConsumerNamesReason: strictConsumer.reasons.includes('incompatible-consumer'),
implicitComparisonStops: implicit.status === 'stop-implicit-comparison',
implicitComparisonNamesReason: implicit.reasons.includes('implicit-comparison'),
unknownInputStops: unknown.status === 'stop-unknown-fixed-contract-case',
fixtureIsFrozen: Object.isFrozen(FIXED_DATA_CONTRACT_CASES),
}),
});
}
function revision(meta, parts, sources) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + sourceList(sources);
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': body length ' + proseLength);
}
return deepFreeze({ ...meta, contentHtml, proseLength });
}
const practice = revision({
slug: 'editorial-2026-03-practice-data-contracts',
title: 'Schema change без устной координации: практический маршрут для контракта данных',
categories: ['Данные', 'Инженерная практика'],
cover: '/assets/editorial/2026/data-contracts-2026-contract-evolution.svg',
excerpt: 'Практический маршрут для изменения схемы: зафиксировать направление сравнения, явный change manifest, потребителя и synthetic compatibility gate до hand-off.',
readingMinutes: 14,
}, [
p('Проблема появляется до самой схемы: producer добавляет поле, consumer узнаёт о нём в сообщении или на созвоне, а проверка сводится к фразе «поле же необязательное». После deploy такая договорённость ломается там, где reader закрывает объект или ждёт старое обязательное поле. Цена ошибки — не только откат. Команда тратит время на восстановление того, что именно было обещано, кто это прочитал и почему изменение вообще считали безопасным.'),
p('Рабочий выход — не собирать больше согласований, а заменить устный маршрут маленькой карточкой сравнения. В ней есть baseline schema, candidate schema, направление backward-проверки, producer, один именованный consumer и список внесённых полей. Затем gate возвращает ограниченный verdict. Действие простое: не передавать change дальше, пока карточка не может назвать сравниваемую пару и цену каждого добавленного поля.'),
h2('У схемы есть форма, но у изменения есть адресат'),
p('Сама по себе JSON-схема описывает форму объекта. Она не отвечает, чей reader должен принять новый объект и в какую сторону читать историю. Поэтому change нельзя свести к diff двух файлов. Для решения нужны как минимум четыре сущности: исходная форма, новая форма, producer, который формирует candidate, и consumer, который должен его разобрать. Пятая сущность — направление. В этой статье backward означает строго одно: может ли named consumer, работавший с baseline, получить candidate без нарушения зафиксированных условий.'),
p('Это определение специально уже, чем привычное «совместимо». Оно не говорит о всех consumer, не делает предположение о реальном registry и не выпускает ничего в production. Узкая формулировка полезна потому, что у stop есть точная причина. Если отсутствует <code>state</code>, проблема в сохранении обязательной поверхности. Если появился <code>routingHint</code>, которого нет в manifest, проблема в документации change. Если reader строгий, проблема не в абстрактной версии, а в его заявленной границе дополнительных полей.'),
figure('/assets/editorial/2026/data-contracts-2026-contract-evolution.svg', 'Три карточки показывают эволюцию fixed схемы work item от версии 1.0 к 1.1 с необязательным полем priority. Между схемами расположен compatibility gate, который требует явное направление и manifest, а скрытое поле routingHint отправляет по красной ветке в stop.', 'Эволюция становится проверяемой, когда новая форма, заявленный diff и конкретный reader находятся на одной карточке review.'),
table('Карточка change перед compatibility gate', ['Часть', 'Что в ней назвать', 'Fixed пример', 'Что нельзя подразумевать'], [
['baseline', 'какая форма была точкой отсчёта', 'fixed-work-item-v1', 'любую прежнюю схему из памяти'],
['candidate', 'какая форма предлагается', 'fixed-work-item-v1-1', 'latest без версии'],
['manifest', 'какие поля добавлены или удалены', 'added: priority', 'скрытый routingHint'],
['направление', 'кто читает чей результат', 'backward producer → consumer', 'похожая форма значит compatible'],
['consumer', 'какой reader проверяется', 'fixed-tolerant-reader-v1', 'все возможные читатели'],
['граница', 'что даёт положительный output', 'synthetic review hand-off', 'deploy, migration или реестр'],
]),
h2('Сначала назвать минимальный контракт, который нельзя потерять'),
p('В fixed baseline три поля: обязательные <code>id</code> и <code>state</code>, а также необязательный <code>note</code>. Такой маленький набор выбран не как модель реального домена, а как способ увидеть механизм. Когда candidate добавляет необязательный <code>priority</code>, gate может перечислить ровно одно новое поле. Когда candidate заменяет <code>state</code> на <code>phase</code>, разница уже не выглядит косметической: baseline required field исчез. Отдельное имя для change снимает ложную дискуссию о том, достаточно ли похожи слова state и phase.'),
p('Важна и граница закрытости. Не каждый reader обязан отвергать добавления, но его поведение нельзя угадывать по типу данных. В synthetic наборе tolerant reader прямо говорит, что принимает declared added fields. Strict reader прямо говорит обратное. Это не характеристика человека или сервиса, а поле учебной карточки. Gate не пытается «уговорить» strict reader. Он возвращает stop, потому что нам не разрешено превратить отдельную потребность в общее обещание без нового review.'),
h2('Исполняемая проверка additive change'),
code("import { createFixedDataContractCase, inspectFixedSchemaChange, reviewFixedDataContractCompatibility } from './upgrade-2026-03.mjs';\n\nconst item = createFixedDataContractCase('compatible-additive-v2');\nconst change = inspectFixedSchemaChange(item);\nconst report = reviewFixedDataContractCompatibility(item);\nconsole.log({ added: change.addedFields, status: report.status, effect: report.productionEffect });\n// { added: ['priority'], status: 'synthetic-compatibility-review-hand-off', effect: 'not-attempted' }"),
p('Фрагмент вызывает только public exports. Все схемы, версии, producer, consumer и boundary data уже лежат в named fixed literal; ни один объект не считывается извне. Output не утверждает, что новый формат развернут или что реальный читатель обработал данные. Он говорит значительно меньше и поэтому полезнее: один заранее названный synthetic pair прошёл правила этой карточки, а следующий шаг — hand-off независимому reviewer.'),
h2('Manifest делает незаметное поле видимым решением'),
p('Полезная дисциплина manifest очень проста: каждое новое поле candidate должно находиться в <code>declaredAddedFields</code>, а объявленное поле должно действительно присутствовать в candidate. Это не замена документации типа и не собственная спецификация формата. Это контроль связи между намерением и diff. В строке review можно увидеть, что <code>priority</code> добавили намеренно. Если в candidate есть <code>routingHint</code>, но manifest о нём молчит, gate завершает проверку <code>stop-undocumented-schema-field</code>.'),
p('Такой stop не доказывает, что routingHint вреден. Возможно, поле нужно отдельному процессу. Но сейчас у команды нет права подменить неизвестность словом optional. Поле может попасть в строгий parser, логику сравнения или новую схему потребителя; это уже другой вопрос. Сначала его нужно назвать, выбрать направление и указать, кто будет читать candidate. Лишь после этого обсуждается, является ли поле additive, отдельным контрактом или поводом перенести изменение в другую миграцию.'),
h2('Почему version не выполняет работу gate'),
p('Номер версии полезен как координата, но не как verdict. Он позволяет связать baseline, candidate и карточку потребителя во времени. Он не сообщает, удалено ли обязательное поле, может ли reader получить дополнительные значения или неявно ли вообще задано сравнение. Попытка заменить diff только строкой 1.1.0 создаёт ровно ту же ручную координацию, только с более аккуратным названием. Поэтому version в fixed module проверяется вместе с family, direction и consumerId, а не отдельно.'),
p('Это соответствует взрослому компромиссу: карточка чуть длиннее одного сообщения, зато повторяема. В ней нет требования описать каждый будущий интеграционный путь. Есть требование не называть текущий путь безопасным, пока объект сравнения не определён. Если в новом change нет named consumer, можно вернуть stop implicit comparison и поставить задачу на уточнение. Неприятный короткий ответ дешевле уверенного, но ненаблюдаемого разрешения.'),
h2('Последовательность перед synthetic hand-off'),
ol([
'<strong>Выбрать baseline.</strong> Зафиксировать schema id, version и обязательные поля, от которых зависит рассматриваемый reader.',
'<strong>Описать candidate.</strong> Добавить новую форму отдельной карточкой; не менять смысл baseline задним числом.',
'<strong>Собрать manifest.</strong> Перечислить additions, removals и type changes; поле вне списка считать неоформленным.',
'<strong>Назвать направление.</strong> Записать producer, consumer, contract family и relation backward producer to consumer.',
'<strong>Проверить две границы.</strong> Сначала сохранение required surface, затем способность этого reader принять declared additions.',
'<strong>Передать ограниченно.</strong> Сохранить status, reasons и next action; положительный результат остаётся synthetic compatibility-review hand-off.',
]),
h2('Граничные данные проверяют не красивый объект, а ветку решения'),
p('Для такой карточки особенно ценны короткие boundary cases. В module есть удачное additive изменение, удаление <code>state</code>, неоформленный <code>routingHint</code>, строгий reader и неявное сравнение. Они не изображают production payload и не покрывают реальную систему. Их цель скромнее: доказать, что gate не принимает объект только потому, что он похож на удачный. Каждая ошибка получает собственный status и следующее действие.'),
p('Например, удаление state не надо прятать в общую ошибку consumer. Gate сначала видит backward incompatibility: required field baseline отсутствует в candidate. Это устраняет соблазн исправить только профиль reader и оставить сам разрыв схемы. Напротив, strict consumer останавливает уже полностью описанный additive candidate. Разные причины должны оставаться разными, иначе следующая встреча снова будет обсуждать симптомы вместо контракта.'),
h2('Ограничения и следующий шаг'),
p('Этот overlay не подключён к schema registry, не читает файлы, не вызывает сеть, не использует CI, Git, telemetry, clock или реальные данные. Он не умеет доказывать совместимость всех будущих readers и не создаёт migration plan. Источники ниже описывают vocabularies и reader-writer resolution, но не подтверждают успешность именно этого synthetic gate или какой-либо deployment. Нельзя переносить его output в production как сертификат.'),
p('Следующий разумный шаг — взять один будущий change и составить карточку без произвольной автоматизации: baseline, candidate, manifest, direction и одного конкретного consumer. Если до этой точки неизвестно, кто читает форму, зафиксируйте unknown как результат исследования, а не как пустой список рисков. Когда пара названа, её можно прогнать через обычный compatibility review и получить либо ограниченный hand-off, либо конкретную причину остановки.'),
], [
{ key: 'jsonSchema', use: 'Draft 2020-12 описывает применение properties и additionalProperties к object instance, поэтому помогает отделить известные и дополнительные поля в vocabulary статьи.', boundary: 'Документ не задаёт правила данного fixed gate, поведение любого consumer или результат deploy.' },
{ key: 'jtd', use: 'RFC 8927 различает required properties, optionalProperties и режим additionalProperties, что подтверждает необходимость явно говорить о дополнительных полях.', boundary: 'RFC не определяет contract family, manifest или verdict synthetic review.' },
{ key: 'avro', use: 'Pinned Avro specification описывает reader and writer schemas и schema resolution, поэтому подтверждает, что направление чтения является техническим вопросом, а не только номером версии.', boundary: 'Avro не доказывает совместимость fixed JSON-like literals и не заменяет named consumer comparison.' },
]);
const mechanism = revision({
slug: 'editorial-2026-03-mechanism-data-contracts',
title: 'Backward совместимость как направление: механизм compatibility gate для схемы данных',
categories: ['Архитектура', 'Данные'],
cover: '/assets/editorial/2026/data-contracts-2026-compatibility-gate-loop.svg',
excerpt: 'Разбор механизма compatibility gate: почему version не является verdict, как разделить schema diff, direction и consumer capability и где fail-closed остановить неявное сравнение.',
readingMinutes: 15,
}, [
p('Поломка схемы часто маскируется под обновление версии: в карточке написано v2, поля похожи, а значит якобы можно двигаться дальше. Техническая ошибка в другом месте — не задано отношение между старой формой, новой формой и reader. Цена такой неопределённости высока: любое позднее несовпадение превращается в спор о трактовке слова compatible, а не в проверку конкретного условия. Compatibility gate нужен, чтобы вернуть сравнению направление и наблюдаемые причины отказа.'),
p('Механизм начинается с fail-closed правила: если direction, baseline, candidate или consumer не названы, сравнение не выполняется. Нельзя вычислять совместимость по пересечению имён или по удачному serialisation sample. Дальше gate разнимает три вопроса, которые обычно склеивают: сохранил ли candidate required surface baseline, оформлены ли все новые поля и может ли этот consumer принять declared additions. Только после этих ответов возможен synthetic hand-off.'),
h2('Backward — не направление стрелки в changelog'),
p('Слово backward звучит знакомо, но без субъекта оно пустое. В этом модуле оно значит: fixed producer создаёт candidate, а fixed consumer, чья точка отсчёта baseline, получает эту форму. Отношение несимметрично. Можно отдельно исследовать, способен ли новый reader разобрать старые данные; это будет другая карточка с другой семантикой. Склеить оба вопроса в один boolean удобно для отчёта, но опасно для решения: неизвестно, что именно можно сохранить при stop.'),
p('Поэтому comparison содержит пять значений: direction, contract family, baseline version, candidate version и consumerId. Family отсекает случайное сравнение одинаковых JSON-объектов, версии закрепляют две точки, consumerId запрещает заменить проверяемого участника во время обсуждения. Если хотя бы одно значение не совпадает с карточкой, status становится <code>stop-implicit-comparison</code>. Это не syntax error и не слабая форма false. Это признание, что механизм пока не знает, какое отношение он должен вычислять.'),
figure('/assets/editorial/2026/data-contracts-2026-compatibility-gate-loop.svg', 'Диаграмма цикла compatibility gate: fixed case проходит явное направление, diff обязательных полей, manifest additions и capability consumer. Зелёная ветка заканчивается synthetic hand-off, четыре красные ветки возвращают разные stop statuses к уточнению карточки.', 'Gate удерживает причины раздельно: сначала точность сравнения, затем схема, затем declared fields и только потом capability named consumer.'),
table('Слои механизма и их отдельные вердикты', ['Слой', 'Вопрос', 'Удачный fixed ответ', 'Fail-closed status'], [
['отношение', 'что и в какую сторону сопоставляем?', 'backward producer → consumer', 'stop-implicit-comparison'],
['required surface', 'сохранился ли baseline required field?', 'id и state на месте', 'stop-backward-incompatible-schema'],
['manifest', 'названо ли каждое новое поле?', 'priority указан', 'stop-undocumented-schema-field'],
['capability', 'принимает ли reader declared additions?', 'tolerant reader: да', 'stop-incompatible-consumer'],
['выход', 'какое право даёт результат?', 'synthetic hand-off', 'не deploy и не migration'],
]),
h2('Diff схемы должен сохранять тип и обязательность'),
p('Первый технический слой строит индексы полей baseline и candidate. Затем он ищет две опасные разницы: required field baseline исчез в candidate или остался с другим type. В fixed case <code>state</code> заменяют на <code>phase</code>. Человек может увидеть близкий смысл, но gate не интерпретирует семантику имён. Для reader, которому нужен state, поле отсутствует. Статус <code>stop-backward-incompatible-schema</code> даёт короткий следующий шаг: восстановить required field либо честно назвать отдельную migration, а не обновлять таблицу версий.'),
p('Почему проверять именно required baseline fields, а не всё подряд? Потому что цель этой карточки узкая. Необязательное поле может иметь собственный риск, но его отсутствие не должно автоматически приравниваться к нарушению обязательной поверхности. Если команде нужно защищать и optional semantic contracts, это надо добавить новым явным правилом и тестом. Механизм не становится надёжнее от безымянной строгости. Он становится надёжнее, когда каждое правило можно указать в report и воспроизвести на fixed case.'),
h2('Исполняемая остановка на разрушенной поверхности'),
code("import {\n reviewFixedDataContractCompatibility as review,\n createFixedDataContractCase as fixedCase,\n} from './upgrade-2026-03.mjs';\n\nconst item = fixedCase('backward-incompatible-v2');\nconst report = review(item);\nconsole.log({ status: report.status, removed: report.removedRequiredFields, next: report.nextAction });\n// { status: 'stop-backward-incompatible-schema', removed: ['state'], next: 'retain-required-baseline-field-or-name-a-separate-migration' }"),
p('Здесь нет сериализации, registry client или внешнего schema file. Literal содержит обе формы и named consumer, а exported function только сравнивает их по правилам module. Это намеренно ограничивает доказательство. Мы можем буквально выполнить ветку fail-closed и увидеть, что исчезновение state не проходит как простое rename. Мы не можем из этого вывода делать заявление о совместимости реального формата или о результате какого-либо release.'),
h2('Почему необязательное поле всё ещё требует consumer review'),
p('Следующий слой кажется парадоксальным. Candidate с необязательным <code>priority</code> не удаляет id и state, значит structural check проходит. Но producer может всё равно передать объект, где priority присутствует. Tolerant reader заранее объявил готовность к declared additions; strict reader объявил, что такие additions не принимает. Это не противоречие между двумя версиями schema. Это два разных требования к границе consumer, которые нельзя вывести из одного лишь слова optional.'),
p('Именно здесь появляется отдельный status <code>stop-incompatible-consumer</code>. Gate не изменяет candidate, не пытается удалить поле на лету и не предполагает адаптер. Он возвращает, что для данной named пары positive hand-off невозможен. Возможны разные инженерные ответы: изменить reader, разделить форму, задержать candidate или завести отдельную migration. Выбор остаётся за следующим решением. Качество gate в том, что он не маскирует эту развилку под зелёный version badge.'),
h2('Manifest связывает фактический diff с намерением'),
p('Поле можно добавить двумя способами: как declared element change и как побочный след реализации. Для формата это одинаковые байты или ключи. Для контракта это разные состояния знания. Manifest не пытается предсказать смысл priority; он лишь перечисляет, что команда сознательно добавила priority. При сравнении candidate с hidden <code>routingHint</code> обнаруживается поле, которого нет в manifest. Gate возвращает <code>stop-undocumented-schema-field</code> до проверки consumer capability.'),
p('Этот порядок важен. Если сначала спросить tolerant reader, он мог бы сказать, что дополнительные поля допустимы, и скрытое изменение получило бы ложный положительный знак. Но acceptability consumer не заменяет обязательство producer объяснить новую поверхность. Сначала field становится предметом change, затем мы спрашиваем, может ли конкретный reader его получить. Получается небольшая, но полезная последовательность ответственности: producer называет изменение; review проверяет diff; consumer задаёт границу принятия.'),
h2('Внутренний порядок gate'),
ol([
'<strong>Проверить известность case.</strong> Модуль принимает только clone named fixed literal; неизвестный объект получает stop unknown fixed contract case.',
'<strong>Проверить relation.</strong> Сверить direction, family, версии и consumerId с обеими schema cards и профилем reader.',
'<strong>Построить field maps.</strong> Вычислить additions, отсутствующие required baseline fields и type changes без сетевых или файловых зависимостей.',
'<strong>Сверить manifest.</strong> Остановить case, если candidate содержит addition вне declaredAddedFields или manifest указывает несуществующее новое поле.',
'<strong>Проверить capability.</strong> Сопоставить required fields consumer, supported candidate version и policy declared additions.',
'<strong>Вернуть строго ограниченный output.</strong> Хранить reasons и next action; accepted result означает только synthetic compatibility-review hand-off.',
]),
h2('Почему gate не должен вычислять процент совместимости'),
p('Процент быстро сглаживает нужную информацию. В одной корзине оказываются неизвестное направление, удалённый required field, скрытый addition и strict reader. У каждого состояния другой владелец следующего шага и другой риск. Если сказать «совместимо на 75 процентов», никто не понимает, можно ли уточнить manifest, восстановить field, завести migration или просто изменить consumer policy. Число выглядит нейтрально, но фактически стирает причины.'),
p('Для M9-практики полезнее один небольшой status на один единичный review. Это не означает, что в реальном процессе нельзя агрегировать итоговые данные. Но агрегировать следует после того, как сохраняется исходная структура: family, direction, baseline, candidate, consumer, reason. Иначе дашборд успокаивает команду ровно в тот момент, когда ей нужна конкретная карточка работы. Synthetic module намеренно не имеет общего счётчика и не измеряет успех.'),
h2('Граница источников и следующий механизм'),
p('JSON Schema и JTD дают vocabulary для описания object fields и additional properties. Avro формулирует relation writer and reader schemas и правила resolution. Ни один из этих документов не определяет statuses этого overlay и не обещает, что конкретный parser перенесёт change. Поэтому код не объявляет себя реализацией стандарта. Он показывает минимальный механизм принятия решения: различить форму, намерение и capability reader, а неизвестность остановить раньше успешного hand-off.'),
p('Следующий шаг для команды — явно выбрать, какой второй direction требуется отдельно: новый consumer читает baseline или old consumer читает candidate. Не пытайтесь расширить текущую функцию без новой карточки и fixtures. Сначала назовите relation, затем добавьте один fixed boundary case, status и next action. Так compatibility gate растёт как контракт собственных решений, а не как накопление неявных if вокруг версий.'),
], [
{ key: 'avro', use: 'Exact Avro 1.12.0 source distinguishes writer schema from reader schema and describes schema resolution, supporting the article distinction between directions of comparison.', boundary: 'Pinned source does not define the overlay statuses, synthetic field map or a result for any external schema registry.' },
{ key: 'jsonSchema', use: 'Draft 2020-12 defines vocabulary for object properties and additionalProperties, which is used only to explain why a field boundary must be explicit.', boundary: 'The dated document does not establish backward compatibility for this fixed comparison or a named reader policy.' },
{ key: 'jtd', use: 'RFC 8927 describes properties, optionalProperties and additionalProperties as distinct schema concepts, supporting the separate treatment of required and added fields.', boundary: 'RFC 8927 does not supply a producer inventory, migration decision or deployment approval.' },
]);
const field = revision({
slug: 'editorial-2026-03-field-data-contracts',
title: 'Не один verdict на всех: полевой цикл producer и consumer для контракта данных',
categories: ['Платформы', 'Данные'],
cover: '/assets/editorial/2026/data-contracts-2026-producer-consumer-matrix.svg',
excerpt: 'Полевой цикл compatibility gate: вести матрицу named producer и consumer, отделять unknown comparison от incompatible reader и передавать только synthetic review hand-off.',
readingMinutes: 14,
}, [
p('В полевой работе с контрактом данных самая дорогая ошибка — объявить один schema change совместимым «для всех», потому что один consumer прочитал sample. Остальные могут ожидать другой набор полей, запрещать additions или вообще относиться к другой contract family. Цена общего verdict — поздний поиск владельца и ручное исправление уже после того, как решение разошлось между командами. Нужна не длинная рассылка, а матрица, где каждая строка фиксирует одну пару producer и consumer.'),
p('Практический цикл строится вокруг простого правила: неизвестный consumer не получает зелёный статус по умолчанию. Сначала карточка называет family, baseline, candidate, direction и capability reader. Затем gate возвращает один из раздельных результатов: hand-off, incompatible consumer, undocumented field, backward break или implicit comparison. Действие для техлида — сохранить именно эту причину рядом с парой, не превращая stop в общий риск без адреса.'),
h2('Матрица начинается с единицы решения, а не со списка систем'),
p('Список интеграций обычно полезен для владения, но слишком широк для compatibility review. Здесь нужна минимальная единица: один fixed producer создаёт один candidate schema, а один fixed consumer принимает или не принимает конкретную границу этой формы. В карточке consumer достаточно нескольких свойств: family, required fields, supported candidate version и policy для declared additions. Все значения в overlay — учебные literals. Они не изображают реальных сервисов, пользователей, сообщений или наблюдаемость.'),
p('Такая узость снимает лишнюю претензию к gate. Он не строит полный граф компании и не обещает найти каждый hidden reader. Он даёт команде способ не потерять уже известную пару. Tolerant reader в matrix соглашается на described addition priority; strict reader останавливается на том же candidate; profile с другой family вообще не должен быть включён в это сравнение. Даже отсутствующая карточка лучше воспринимается как work item, а не как доказательство отсутствия риска.'),
figure('/assets/editorial/2026/data-contracts-2026-producer-consumer-matrix.svg', 'Матрица producer и consumer: одна fixed candidate schema с declared полем priority сравнивается с tolerant reader, strict reader и неизвестной парой. Зелёная ячейка ведёт к synthetic hand-off, красная — к incompatible consumer, серая — к отдельному уточнению relation.', 'Матрица показывает, что один schema diff не создаёт один общий verdict: результат зависит от явно названной capability reader и от того, существует ли сравнение.'),
table('Матрица полевого review до общего сообщения', ['Пара', 'Что известно', 'Вердикт gate', 'Куда вернуть работу'], [
['producer 1.1 → tolerant reader', 'priority declared, reader принимает additions', 'synthetic compatibility-review hand-off', 'в независимый hand-off'],
['producer 1.1 → strict reader', 'priority declared, reader запрещает additions', 'stop-incompatible-consumer', 'к границе reader или отдельной migration'],
['producer 2.0 → tolerant reader', 'baseline state удалён', 'stop-backward-incompatible-schema', 'к candidate required surface'],
['producer 1.1 → unnamed relation', 'нет direction или consumerId', 'stop-implicit-comparison', 'к карточке сравнения'],
['producer 1.1 hidden field', 'routingHint отсутствует в manifest', 'stop-undocumented-schema-field', 'к change manifest'],
]),
h2('Inventory consumer хранит условия чтения, а не репутацию команды'),
p('Профиль consumer не должен звучать как оценка: «старый», «сложный», «привередливый». Такие слова не помогают выполнить проверку. Вместо них нужны наблюдаемые условия. Required fields показывают минимальную поверхность, без которой reader не может принять решение. Policy additions показывает, допускает ли он именно описанные новые поля. Supported candidate version делает временную точку явной. Family не даёт сравнить read contract с другой операцией только потому, что обе стороны используют JSON.'),
p('В fixed cases strict reader не является ошибкой. Он говорит понятное правило: declared additions не принимаются. Gate не вправе объявить его плохим участником или изменить его policy. Он должен сохранить <code>stop-incompatible-consumer</code> и следующий шаг. Это полезно и для людей: вместо спора о скорости команды видно, какой контрактный выбор надо сделать. Возможно, producer удержит addition, возможно, consumer расширит границу, возможно, появится отдельная форма. Пока решение не принято, красный status честнее зелёной надежды.'),
h2('Исполняемый triage для strict consumer'),
code("import { createFixedDataContractCase, reviewFixedDataContractCompatibility, runFixedDataContractFixture } from './upgrade-2026-03.mjs';\n\nconst item = createFixedDataContractCase('incompatible-consumer-v2');\nconst report = reviewFixedDataContractCompatibility(item);\nconst fixture = runFixedDataContractFixture();\nconsole.log({ status: report.status, reason: report.reasons[0], assertions: Object.keys(fixture.assertions).length });\n// { status: 'stop-incompatible-consumer', reason: 'incompatible-consumer', assertions: 15 }"),
p('Это настоящий запуск public functions данного module. Он берёт named fixed case, а fixture проверяет пять независимых веток. При этом код не пишет в registry, не посылает sample, не читает environment и не получает данные о внешнем consumer. Положительный case в той же fixture заканчивается только hand-off. Такой предел защищает от подмены: успешное упражнение не становится основанием сообщить, что что-то уже опубликовано или работает за границей учебной модели.'),
h2('Почему gate loop должен возвращаться к карточке, а не к общей очереди'),
p('У хорошего stop есть адрес возврата. Backward break возвращается к candidate schema: пропал required baseline field. Undocumented field возвращается к manifest: новая поверхность не была названа. Incompatible consumer возвращается к capability named reader. Implicit comparison возвращается к relation: не задано, что и в какую сторону сравнивают. Если все четыре причины превратить в «нужно договориться», команда вернётся к исходной ручной координации, только с более формальным заголовком.'),
p('Поэтому loop в visual не имеет линии deploy. После зелёной ячейки он передаёт limited review hand-off, после красной — конкретное уточнение. Даже hand-off не означает, что gate распоряжается выпуском. Следующий участник может потребовать дополнительные основания, а реальная система может иметь условия вне этой модели. Роль compatibility gate ограничена: сделать вопрос о форме и reader проверяемым, удержать конкретную причину и не потерять границу полномочий.'),
h2('Один глобальный verdict скрывает разные владельцы решения'),
p('Когда у change есть пять consumer, хочется свернуть матрицу в один статус. Делать это можно только после определения цели агрегирования. Для release note достаточно перечислить пары и их состояния. Для приоритизации можно посчитать очереди stop по причинам. Но нельзя присвоить candidate строку compatible, если хотя бы один известный reader требует отдельного решения. Это не бюрократия. Это различие между «какая-то проверка прошла» и «все названные контракты покрыты утверждением».'),
p('Отдельно храните incomparable или implicit relation. Нулевая информация о consumer — не tolerance. Профиль другой family — не incompatible, пока не выяснено, существует ли связь. В данном module не создаётся отдельный profile другой family, потому что user story ограничена четырьмя обязательными stop. Но правило остаётся: прежде чем считать поля, нужно подтвердить ось сравнения. Это дешевле, чем строить огромную matrix, где половина ячеек имеет красиво окрашенный, но бессмысленный verdict.'),
h2('Полевой порядок работы с change'),
ol([
'<strong>Завести одну строку.</strong> Взять один producer, baseline, candidate и одного consumer вместо массового статуса для схемы.',
'<strong>Собрать capability.</strong> Записать contract family, required fields, policy declared additions и candidate version без догадки о будущих сценариях.',
'<strong>Определить relation.</strong> Указать direction и обе точки schema; незаполненная связь должна остановить review.',
'<strong>Прогнать structural слой.</strong> Проверить required baseline fields, типы и manifest additions раньше consumer policy.',
'<strong>Сохранить раздельный verdict.</strong> Не заменять reason общей фразой; вернуть её в именно ту часть карточки, которая требует решения.',
'<strong>Передать только область.</strong> При зелёном status отдать named pair в следующий review, не объявляя registry, CI или deploy завершёнными.',
]),
h2('Граничные данные нужны для чужой проверки правил'),
p('Тестовый набор gate обычно соблазняются наполнить красивыми объектами. В этой задаче полезнее обратное: несколько коротких случаев, которые обязаны остановиться. Case с удалённым state не даёт перепутать rename и сохранение required surface. Case с routingHint не даёт tolerant consumer узаконить скрытое поле. Case со strict reader не даёт structural diff выдать за полную совместимость. Case с неявным direction не даёт sample превратить в отношение.'),
p('Эти данные фиксированы в памяти, поэтому reviewer может повторить результат без доступа к production. Они не заменяют реальные boundary payload и не дают сигнал о нагрузке, retention, access control или времени доставки. Но именно в их ограничении есть польза для документации: каждый выход виден, каждое поле имеет известное происхождение, а код можно буквально выполнить из article snippet. При расширении gate новый rule обязан принести свой fixed case и своё fail-closed ожидание.'),
h2('Ограничения и следующий шаг'),
p('Изолированный overlay не обслуживает реальный schema registry и не умеет обнаруживать неизвестных consumers. Он не подключается к network, filesystem, Git, CI, clock, telemetry, API или production data. Reference documents ниже объясняют schema vocabulary и reader-writer direction, но не подтверждают output этой матрицы, отсутствие инцидентов или успех какого-либо deployment. Разумно воспринимать её как форму инженерного review, а не как гарантию системы.'),
p('Следующий шаг — не масштабировать matrix сразу. Выберите одну известную связку, у которой сегодня есть ручное сообщение о change, и запишите capability reader в четырёх полях. Если relation ещё нельзя назвать, оставьте explicit stop и назначьте владельца уточнения. Если relation читается, добавьте fixed case в локальный набор. Так data contract перестаёт жить только в памяти producer и становится точкой, которую consumer может проверить до следующего deploy.'),
], [
{ key: 'jtd', use: 'RFC 8927 явно разделяет required properties, optionalProperties и additional properties, поэтому используется как первичный vocabulary для матрицы field boundaries.', boundary: 'Experimental RFC не устанавливает policy конкретного reader, ownership matrix или outcome synthetic gate.' },
{ key: 'avro', use: 'Pinned Avro specification называет writer and reader schemas и описывает resolution, что подтверждает необходимость хранить направление relation рядом с участниками.', boundary: 'Avro source не сообщает о существовании named fixed consumers и не подтверждает release decision.' },
{ key: 'jsonSchema', use: 'Dated Draft 2020-12 описывает object-property vocabulary, применяемую здесь только для ясного разговора о declared additions.', boundary: 'Specification не даёт единого compatibility verdict для произвольной группы consumer.' },
]);
export const revisions = deepFreeze([practice, mechanism, field]);
if (process.argv.includes('--verify-fixture')) {
const result = runFixedDataContractFixture();
const failed = Object.entries(result.assertions)
.filter(([, value]) => value !== true)
.map(([key]) => key);
if (failed.length) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
} else {
const count = Object.keys(result.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
}
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions) + '\n');
}