revise July 2026 migration playbook articles
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 19:43:02 +03:00
parent 0003133514
commit f312d7a598
7 changed files with 351 additions and 1 deletions
+278
View File
@@ -0,0 +1,278 @@
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 clone(value) { return JSON.parse(JSON.stringify(value)); }
function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; }
const REFERENCES = deepFreeze({
kube: {
title: 'Kubernetes Documentation: Deployments',
url: 'https://v1-33.docs.kubernetes.io/docs/concepts/workloads/controllers/deployment/',
version: 'Kubernetes v1.33 versioned documentation snapshot, 23 April 2025',
},
sre: {
title: 'Google SRE Book: Addressing Cascading Failures',
url: 'https://web.archive.org/web/20250123164219id_/https://sre.google/sre-book/addressing-cascading-failures/',
version: 'Wayback immutable capture, 23 January 2025 16:42:19 UTC',
},
rfc2119: {
title: 'RFC 2119: Key words for use in RFCs to Indicate Requirement Levels',
url: 'https://www.rfc-editor.org/rfc/rfc2119.txt',
version: 'RFC 2119, March 1997, immutable RFC text',
},
});
function sourceList(entries) {
return '<ul>' + entries.map(({ key, use, boundary }) => {
const source = REFERENCES[key];
return '<li><a href="' + source.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(source.title) + '</a> — версия: ' + escapeHtml(source.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
}).join('') + '</ul>';
}
const FIXED_MIGRATION_RECORDS = deepFreeze({
'fixed-migration-ready-v1': {
id: 'fixed-migration-ready-v1',
inventory: {
id: 'fixed-inventory-v1',
routes: [{ id: 'route-ledger-read', owner: 'fixed-ledger-team', dependencyIds: ['record-ledger-entry', 'contract-ledger-read-v1'] }],
records: [{ id: 'record-ledger-entry', writer: 'fixed-ledger-writer', reader: 'fixed-ledger-reader', transition: 'copy-then-verify' }],
limits: 'Named teaching inventory only; it does not describe a repository, client, service, route, database or environment.',
},
data: { id: 'fixed-data-state-v1', migrationState: 'copy-verified-reversible', sourceRecord: 'record-ledger-entry', targetRecord: 'fixed-target-ledger-entry', restoreState: 'fixed-source-write-resume-v1', reconciliation: 'fixed-count-and-key-set-v1' },
traffic: { id: 'fixed-traffic-slice-v1', control: { id: 'fixed-control-route-set-v1', share: 90, routeIds: ['route-ledger-read'] }, candidate: { id: 'fixed-candidate-route-set-v1', share: 10, routeIds: ['route-ledger-read'] }, observationWindow: { id: 'fixed-window-15-samples-v1', metricIds: ['fixed-error-ratio', 'fixed-contract-mismatch'] } },
rollback: { id: 'fixed-rollback-v1', trigger: { id: 'fixed-contract-mismatch-trigger-v1', condition: 'fixed-contract-mismatch-is-present-in-window' }, trafficState: 'restore-fixed-control-route-set-v1', dataState: 'resume-fixed-source-write-state-v1', decisionOwner: 'fixed-transition-reviewer' },
review: { id: 'fixed-migration-review-v1', nextAction: 'hand-off-fixed-synthetic-migration-review' },
boundary: 'All values are named fixed synthetic JavaScript literals in memory. No network, filesystem, clock, telemetry, production data, service, client, deployment, infrastructure, migration or rollback is contacted, performed or represented.',
},
'missing-inventory-v1': {
id: 'missing-inventory-v1', inventory: { id: 'fixed-inventory-v1', routes: [{ id: 'route-ledger-read', owner: '', dependencyIds: [] }], records: [], limits: 'synthetic' },
data: { id: 'fixed-data-state-v1', migrationState: 'copy-verified-reversible', sourceRecord: 'record-ledger-entry', targetRecord: 'fixed-target-ledger-entry', restoreState: 'fixed-source-write-resume-v1', reconciliation: 'fixed-count-and-key-set-v1' },
traffic: { id: 'fixed-traffic-slice-v1', control: { id: 'fixed-control-route-set-v1', share: 90, routeIds: ['route-ledger-read'] }, candidate: { id: 'fixed-candidate-route-set-v1', share: 10, routeIds: ['route-ledger-read'] }, observationWindow: { id: 'fixed-window-15-samples-v1', metricIds: ['fixed-error-ratio'] } },
rollback: { id: 'fixed-rollback-v1', trigger: { id: 'fixed-contract-mismatch-trigger-v1', condition: 'fixed-contract-mismatch-is-present-in-window' }, trafficState: 'restore-fixed-control-route-set-v1', dataState: 'resume-fixed-source-write-state-v1', decisionOwner: 'fixed-transition-reviewer' }, review: { id: 'fixed-migration-review-v1', nextAction: 'hand-off-fixed-synthetic-migration-review' }, boundary: 'synthetic',
},
'traffic-without-control-v1': {
id: 'traffic-without-control-v1', inventory: { id: 'fixed-inventory-v1', routes: [{ id: 'route-ledger-read', owner: 'fixed-ledger-team', dependencyIds: ['record-ledger-entry'] }], records: [{ id: 'record-ledger-entry', writer: 'fixed-ledger-writer', reader: 'fixed-ledger-reader', transition: 'copy-then-verify' }], limits: 'synthetic' },
data: { id: 'fixed-data-state-v1', migrationState: 'copy-verified-reversible', sourceRecord: 'record-ledger-entry', targetRecord: 'fixed-target-ledger-entry', restoreState: 'fixed-source-write-resume-v1', reconciliation: 'fixed-count-and-key-set-v1' },
traffic: { id: 'fixed-traffic-slice-v1', control: { id: '', share: 0, routeIds: [] }, candidate: { id: 'fixed-candidate-route-set-v1', share: 10, routeIds: ['route-ledger-read'] }, observationWindow: { id: 'fixed-window-15-samples-v1', metricIds: ['fixed-error-ratio'] } },
rollback: { id: 'fixed-rollback-v1', trigger: { id: 'fixed-contract-mismatch-trigger-v1', condition: 'fixed-contract-mismatch-is-present-in-window' }, trafficState: 'restore-fixed-control-route-set-v1', dataState: 'resume-fixed-source-write-state-v1', decisionOwner: 'fixed-transition-reviewer' }, review: { id: 'fixed-migration-review-v1', nextAction: 'hand-off-fixed-synthetic-migration-review' }, boundary: 'synthetic',
},
'data-without-reversible-state-v1': {
id: 'data-without-reversible-state-v1', inventory: { id: 'fixed-inventory-v1', routes: [{ id: 'route-ledger-read', owner: 'fixed-ledger-team', dependencyIds: ['record-ledger-entry'] }], records: [{ id: 'record-ledger-entry', writer: 'fixed-ledger-writer', reader: 'fixed-ledger-reader', transition: 'copy-then-verify' }], limits: 'synthetic' },
data: { id: 'fixed-data-state-v1', migrationState: 'copy-complete', sourceRecord: 'record-ledger-entry', targetRecord: 'fixed-target-ledger-entry', restoreState: '', reconciliation: 'fixed-count-and-key-set-v1' },
traffic: { id: 'fixed-traffic-slice-v1', control: { id: 'fixed-control-route-set-v1', share: 90, routeIds: ['route-ledger-read'] }, candidate: { id: 'fixed-candidate-route-set-v1', share: 10, routeIds: ['route-ledger-read'] }, observationWindow: { id: 'fixed-window-15-samples-v1', metricIds: ['fixed-error-ratio'] } },
rollback: { id: 'fixed-rollback-v1', trigger: { id: 'fixed-contract-mismatch-trigger-v1', condition: 'fixed-contract-mismatch-is-present-in-window' }, trafficState: 'restore-fixed-control-route-set-v1', dataState: '', decisionOwner: 'fixed-transition-reviewer' }, review: { id: 'fixed-migration-review-v1', nextAction: 'hand-off-fixed-synthetic-migration-review' }, boundary: 'synthetic',
},
'rollback-without-trigger-v1': {
id: 'rollback-without-trigger-v1', inventory: { id: 'fixed-inventory-v1', routes: [{ id: 'route-ledger-read', owner: 'fixed-ledger-team', dependencyIds: ['record-ledger-entry'] }], records: [{ id: 'record-ledger-entry', writer: 'fixed-ledger-writer', reader: 'fixed-ledger-reader', transition: 'copy-then-verify' }], limits: 'synthetic' },
data: { id: 'fixed-data-state-v1', migrationState: 'copy-verified-reversible', sourceRecord: 'record-ledger-entry', targetRecord: 'fixed-target-ledger-entry', restoreState: 'fixed-source-write-resume-v1', reconciliation: 'fixed-count-and-key-set-v1' },
traffic: { id: 'fixed-traffic-slice-v1', control: { id: 'fixed-control-route-set-v1', share: 90, routeIds: ['route-ledger-read'] }, candidate: { id: 'fixed-candidate-route-set-v1', share: 10, routeIds: ['route-ledger-read'] }, observationWindow: { id: 'fixed-window-15-samples-v1', metricIds: ['fixed-error-ratio'] } },
rollback: { id: 'fixed-rollback-v1', trigger: { id: '', condition: '' }, trafficState: 'restore-fixed-control-route-set-v1', dataState: 'resume-fixed-source-write-state-v1', decisionOwner: 'fixed-transition-reviewer' }, review: { id: 'fixed-migration-review-v1', nextAction: 'hand-off-fixed-synthetic-migration-review' }, boundary: 'synthetic',
},
});
export function createFixedMigrationRecord(id) { const value = FIXED_MIGRATION_RECORDS[id]; return value ? deepFreeze(clone(value)) : undefined; }
export function inspectFixedMigrationInventory(record) {
const route = record?.inventory?.routes?.[0]; const linkedRecord = record?.inventory?.records?.[0];
if (!route?.id || !route.owner || !route.dependencyIds?.length || !linkedRecord?.id || !linkedRecord.writer || !linkedRecord.reader) return deepFreeze({ status: 'stop-missing-inventory', reasons: deepFreeze(['inventory-route-owner-or-dependency-missing']), nextAction: 'name-route-owner-dependency-and-record-read-write-boundary' });
return deepFreeze({ status: 'fixed-inventory-complete', routeId: route.id, owner: route.owner, recordId: linkedRecord.id, dependencies: deepFreeze([...route.dependencyIds]) });
}
export function evaluateFixedTransitionState(record) {
const inventory = inspectFixedMigrationInventory(record); if (inventory.status !== 'fixed-inventory-complete') return inventory;
const traffic = record?.traffic; const sameRoute = traffic?.control?.routeIds?.length === 1 && traffic?.candidate?.routeIds?.length === 1 && traffic.control.routeIds[0] === traffic.candidate.routeIds[0];
if (!traffic?.control?.id || !traffic.control.share || !traffic?.candidate?.id || !traffic.candidate.share || !sameRoute || !traffic.observationWindow?.metricIds?.length) return deepFreeze({ status: 'stop-unbounded-traffic-slice', reasons: deepFreeze(['traffic-slice-requires-named-control-boundary']), nextAction: 'name-control-candidate-same-route-and-observation-window' });
const data = record?.data;
if (data?.migrationState !== 'copy-verified-reversible' || !data.restoreState || !data.reconciliation || !record?.rollback?.dataState) return deepFreeze({ status: 'stop-irreversible-data-state', reasons: deepFreeze(['data-migration-has-no-named-recovery-state']), nextAction: 'name-reversible-write-state-restore-and-reconciliation-boundary' });
const rollback = record.rollback;
if (!rollback?.trigger?.id || !rollback.trigger.condition || !rollback.trafficState || !rollback.decisionOwner) return deepFreeze({ status: 'stop-unnamed-rollback-trigger', reasons: deepFreeze(['rollback-trigger-is-not-named']), nextAction: 'name-trigger-condition-owner-and-traffic-data-return-state' });
return deepFreeze({ status: 'fixed-transition-state-complete', inventory, traffic: deepFreeze(clone(traffic)), data: deepFreeze(clone(data)), rollback: deepFreeze(clone(rollback)) });
}
export function reviewFixedMigrationPlaybook(record) {
const transition = evaluateFixedTransitionState(record); if (transition.status !== 'fixed-transition-state-complete') return transition;
if (record.review?.nextAction !== 'hand-off-fixed-synthetic-migration-review') return deepFreeze({ status: 'stop-disallowed-positive-result', reasons: deepFreeze(['positive-status-must-be-synthetic-migration-review-hand-off']), nextAction: 'replace-permission-language-with-bounded-review-hand-off' });
return deepFreeze({ status: 'synthetic-migration-review-hand-off', transition, nextAction: 'hand-off-fixed-synthetic-migration-review', productionEffect: 'not-attempted', boundary: record.boundary });
}
export function runFixedMigrationFixture() {
const ready = reviewFixedMigrationPlaybook(createFixedMigrationRecord('fixed-migration-ready-v1'));
const missingInventory = reviewFixedMigrationPlaybook(createFixedMigrationRecord('missing-inventory-v1'));
const noControl = reviewFixedMigrationPlaybook(createFixedMigrationRecord('traffic-without-control-v1'));
const irreversible = reviewFixedMigrationPlaybook(createFixedMigrationRecord('data-without-reversible-state-v1'));
const noTrigger = reviewFixedMigrationPlaybook(createFixedMigrationRecord('rollback-without-trigger-v1'));
const assertions = { readyHandsOffOnly: ready.status === 'synthetic-migration-review-hand-off' && ready.productionEffect === 'not-attempted', readyNamesRecovery: ready.transition.data.restoreState === 'fixed-source-write-resume-v1', inventoryStopsClosed: missingInventory.status === 'stop-missing-inventory', inventoryNamesReason: missingInventory.reasons.includes('inventory-route-owner-or-dependency-missing'), trafficStopsClosed: noControl.status === 'stop-unbounded-traffic-slice', trafficNamesReason: noControl.reasons.includes('traffic-slice-requires-named-control-boundary'), dataStopsClosed: irreversible.status === 'stop-irreversible-data-state', dataNamesReason: irreversible.reasons.includes('data-migration-has-no-named-recovery-state'), rollbackStopsClosed: noTrigger.status === 'stop-unnamed-rollback-trigger', rollbackNamesReason: noTrigger.reasons.includes('rollback-trigger-is-not-named'), literalsFrozen: Object.isFrozen(FIXED_MIGRATION_RECORDS) };
return deepFreeze({ assertions: deepFreeze(assertions), passed: Object.values(assertions).filter(Boolean).length, total: Object.keys(assertions).length, accepted: Object.values(assertions).every(Boolean) });
}
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-07-practice-migration-playbook', title: 'План миграции без прыжка: карта фаз, ворот и обратимого состояния', categories: ['Архитектура', 'Инженерная практика'], cover: '/assets/editorial/2026/migration-playbook-2026-phase-decision-gates.svg', excerpt: 'Практическая карточка миграции: сначала инвентарь, затем обратимое состояние данных, сопоставимый срез трафика и только потом узкий review hand-off.', readingMinutes: 21,
}, [
p('Проблема плана миграции в том, что он часто начинается с красивой целевой схемы и заканчивается словом «cutover». Между ними исчезают инвентарь, владелец маршрута, перевод трафика, состояние записей и решение об откате. Цена такого пропуска не абстрактный риск: в момент сбоя нельзя быстро ответить, что именно вернуть, кто принимает решение и какие новые записи уже нельзя трактовать как прежние. Архитектура остаётся на доске, а переход превращается в спор о памяти участников.'),
p('Рабочая альтернатива — не общий список шагов, а маленькая decision-gated карточка. Каждая фаза принимает named fixed synthetic literal, добавляет один проверяемый факт и либо отдаёт точный stop reason, либо делает возможным только <code>synthetic-migration-review-hand-off</code>. Это не разрешение начать, продолжить или завершить миграцию. Это дисциплина описания: следующий вопрос нельзя открыть, пока текущая граница не названа.'),
h2('Начать не с цели, а с карты того, что переходит'),
p('Целевая архитектура отвечает «куда». Migration card обязана сначала ответить «что сейчас связано». В учебном literal есть один route <code>route-ledger-read</code>, один owner, один record и две зависимые границы. Такой масштаб намеренно мал. Он не представляет репозиторий или сервис. Зато на нём видно правило: route без владельца и dependency — это не неполная таблица, а невозможность ответственно определить последствия перехода.'),
p('Инвентарь не должен изображать каталог файлов. Для перехода достаточно четырёх типов связей: входной route, запись или ресурс, читатель и писатель, внешняя зависимость. Если route обращается к record, но не известно, кто формирует запись, план не умеет описать data transition. Если известна запись, но нет читателя, нельзя формулировать evidence о совместимости. Стоп здесь экономит самый дорогой вид работы: позднее обнаружение обязательной связи внутри окна изменений.'),
figure('/assets/editorial/2026/migration-playbook-2026-phase-decision-gates.svg', 'Четыре карточки migration playbook: inventory, data state, traffic slice и review evidence. Красные ветки ведут к stop для отсутствующего инвентаря, необратимых данных и неназванного триггера rollback.', 'Фазу открывает связанное доказательство. Зелёная карточка означает только synthetic review hand-off; она не является разрешением на действие.'),
table('Минимальная карточка фазы', ['Фаза', 'Вход', 'Проверяемое условие', 'Точный отказ'], [
['inventory', 'route, owner, record, dependency', 'все имена связаны', 'stop-missing-inventory'],
['data state', 'copy, reconciliation, restore state', 'состояние обратимо в модели', 'stop-irreversible-data-state'],
['traffic slice', 'control, candidate, window', 'маршрут и сравнение названы', 'stop-unbounded-traffic-slice'],
['rollback', 'trigger, owner, traffic/data return', 'решение воспроизводимо', 'stop-unnamed-rollback-trigger'],
['review', 'полная карточка', 'вывод ограничен hand-off', 'stop-disallowed-positive-result'],
]),
h2('Фаза data не равна слову «копия»'),
p('Запись <code>copy-complete</code> говорит только о том, что в модели есть копия. Она не говорит, как вернуть дальнейшие записи, чем сверять границы и куда должен вернуться write state. Поэтому accepted literal требует <code>copy-verified-reversible</code>, named reconciliation и named restore state. Это не рецепт для конкретной базы данных. Это минимальная форма вопроса, который не позволяет выдать перенос байтов за обратимый переход данных.'),
p('Особенно опасна подмена: «трафик можно направить назад, значит rollback есть». Для read-only примера она может выглядеть правдоподобно, но в модели записи трафик и данные — разные оси. Возврат control route не отменяет новых записей и не создаёт их обратного преобразования. Kubernetes Documentation о Deployment полезна здесь именно как граница: rollback его revision относится к Pod template. Из этого не следует, что он отменяет эффект данных, внешний контракт или любой иной transition.'),
h2('Срез трафика требует контрольной стороны'),
p('Десять процентов candidate без control boundary — это число без вопроса. Нельзя понять, с чем его сопоставлять, какой маршрут остался прежним и одинаково ли определены условия наблюдения. В fixed record control и candidate имеют один и тот же route id, разные shares и один named observation window. Эти числа не изображают настоящий балансировщик и не выражают совет о долях. Они делают сравнение типизированным: у наблюдения есть две стороны, а не только интересный новый путь.'),
p('Наблюдаемость в карточке тоже не телеметрия. <code>fixed-error-ratio</code> и <code>fixed-contract-mismatch</code> — имена полей literal, не метрики системы. Их задача — не доказать здоровье, а не позволить написать «посмотрим внимательно». Когда окно и критерий не названы, проверка возвращает <code>stop-unbounded-traffic-slice</code>. Такой отказ полезнее зелёной галочки: он указывает ровно на отсутствующую control boundary.'),
h2('Исполняемая карточка фаз'),
code("import { createFixedMigrationRecord, reviewFixedMigrationPlaybook } from './upgrade-2026-07.mjs';\n\nconst record = createFixedMigrationRecord('fixed-migration-ready-v1');\nconst result = reviewFixedMigrationPlaybook(record);\nconsole.log({ status: result.status, route: result.transition.inventory.routeId, recovery: result.transition.data.restoreState });\n// { status: 'synthetic-migration-review-hand-off', route: 'route-ledger-read', recovery: 'fixed-source-write-resume-v1' }"),
p('Пример буквально читает immutable teaching object в памяти Node. Он не делает DNS change, не открывает соединение, не копирует record и не запрашивает метрики. Даже положительный status несёт <code>productionEffect: not-attempted</code>. Это важнее красивого имени функции: reader должен видеть в данных тот же запрет, который сформулирован в тексте. Code проверяет полноту модели, а не готовность среды.'),
h2('Rollback — отдельное решение, не хвост списка'),
p('У rollback есть четыре собственных имени: trigger, условие, return state трафика и return state данных; рядом стоит decision owner. Формулировка «при проблеме вернём назад» не содержит ни одной из них. Она не определяет, какая проблема достаточна, что именно возвращается и кто может различить fix-forward от возврата. Fixed trigger <code>fixed-contract-mismatch-is-present-in-window</code> узок специально: он не притворяется универсальным сигналом.'),
p('Google SRE Book описывает каскадный отказ как рост сбоя из положительной обратной связи. Из этого текста здесь берётся не production-вывод и не порог для команды, а инженерская осторожность: поздняя, расплывчатая реакция усиливает неопределённость. В учебной карте named trigger обрывает риторику «подождём ещё немного». Он позволяет фиксировать, что именно отсутствует или что именно переводится в stop state, не назначая действие в реальной среде.'),
h2('Последовательность, которую можно рецензировать'),
ol([
'Выбрать один named route и связать с ним owner, record, reader, writer и dependency; при пустом звене завершить review статусом stop-missing-inventory.',
'Назвать data transition, reconciliation и restore state; не считать copy достаточным доказательством обратимости.',
'Сформировать control и candidate с одинаковым route boundary, а затем назвать observation window и его поля.',
'Записать trigger, condition, owner и оба return states; определить только форму stop, не действие в окружении.',
'Проверить, что positive status остаётся synthetic hand-off, а открытый вопрос и productionEffect остаются видимы.',
]),
h2('Язык ворот должен быть точнее цели'),
p('RFC 2119 различает обязательность словом MUST и возможность словом MAY. В migration card это полезно не для игры в нормативность, а для редакторской проверки. «Нужно иметь rollback» слишком расплывчато. «Для hand-off MUST быть named trigger» можно проверить на поле literal. В то же время нельзя писать «система обязана перейти»: package не видит систему. Требование относится только к данным учебной карточки и должно завершаться механически видимым reason.'),
p('У каждой фазы своя ответственность. Автор карточки не подтверждает фактическое состояние сервисов; он отвечает, что имена не скрывают разные сущности. Рецензент инвентаря ищет отсутствующую связь. Рецензент перехода смотрит на control/candidate и data recovery. Рецензент решения следит, чтобы сильный глагол не вылез за evidence. Разделение ролей не создаёт бюрократию: оно не даёт одному «готово» заменить три разных вопроса.'),
h2('Пределы модели и следующий шаг'),
p('Этот playbook не измеряет задержку, не знает фактические схемы, не моделирует конкурентные записи и не валидирует deployment. Он не предлагает процент rollout, способ репликации или protocol rollback. Даже source о Kubernetes не делает package Kubernetes migration plan. Ограничение честное: fixed literals учат сохранять связи между решением и evidence, но не заменяют discovery, rehearsal или инженерное согласование в реальном контексте.'),
p('Следующий безопасный шаг — добавить один новый fixed counterexample, например route с двумя records или recovery state, который не соответствует transition. Затем снова выполнить public export и проверить точный stop reason. Не расширять accepted result до разрешения, не придумывать реальные показатели и не достраивать скрытый inventory. Хороший migration plan растёт по наблюдаемым связям, а не по объёму диаграммы.'),
], [
{ key: 'kube', use: 'Использован только узкий факт: revision и rollback Deployment относятся к Pod template, поэтому они не являются доказательством обратимости данных.', boundary: 'Не доказывает поведение конкретного кластера, rollout, data transition или rollback.' },
{ key: 'sre', use: 'Использована только идея положительной обратной связи у каскадного отказа как аргумент называть stop condition заранее.', boundary: 'Не задаёт пороги, SLO, нагрузку или свойства учебного literal.' },
{ key: 'rfc2119', use: 'Использовано определение требований MUST/MAY для различения проверяемого gate и необоснованного приказа среде.', boundary: 'Не превращает synthetic правила в внешний стандарт миграции.' },
]);
const mechanism = revision({
slug: 'editorial-2026-07-mechanism-migration-playbook', title: 'Переход как автомат состояний: критерии, трафик, данные и rollback без подмены', categories: ['Архитектура', 'Системное мышление'], cover: '/assets/editorial/2026/migration-playbook-2026-rollback-decision-table.svg', excerpt: 'Механика migration state machine: почему inventory, control boundary, reversible data state и named trigger должны отказывать по-разному.', readingMinutes: 22,
}, [
p('Ошибка в механике миграции обычно начинается с одного статуса «готово». В него складывают схему, копию данных, candidate traffic и кнопку rollback, хотя это разные состояния с разными отказами. Цена — ложная обратимость: команда может вернуть маршрут, но обнаружить, что изменения данных уже не имеют named recovery path; или увидеть candidate без control и назвать это сравнением. Один зелёный статус прячет несколько не заданных вопросов.'),
p('Ниже — не конечный автомат реальной платформы, а строгая модель fixed synthetic record. Она различает четыре условия: инвентарь complete, трафик comparable, data state reversible и rollback decidable. Функция выдаёт только первый нарушенный барьер или <code>synthetic-migration-review-hand-off</code>. Положительный выход не запускает переход. Он говорит, что структура одного учебного доказательства достаточно связна для дальнейшего review.'),
h2('Почему порядок отказов важнее счастливой ветки'),
p('В state machine порядок — это политика диагностики. Если inventory пуст, бессмысленно спорить о триггере rollback: неизвестно, на что он должен воздействовать. Если traffic не имеет control boundary, нельзя читать data state как достаточное основание для перехода. Если data необратимы, trigger уже не делает rollback осмысленным. Модель поэтому проверяет путь слева направо и не пытается собрать список всех возможных дефектов. Первый stop оставляет узкую следующую работу.'),
p('Такой порядок не доказывает, что остальные поля верны. Он делает причину отказа стабильной. Это принципиально для review: разные рецензенты на том же literal получают одинаковый status, а не набор зависимых от настроения замечаний. Стабильность полезна и для текста. Автор не пишет «план сырой», а указывает <code>inventory-route-owner-or-dependency-missing</code> или другую named boundary. Это уже можно исправить без угадывания скрытого смысла.'),
figure('/assets/editorial/2026/migration-playbook-2026-rollback-decision-table.svg', 'Таблица из четырёх synthetic cases. Только строка с control 90 процентов, candidate 10 процентов, обратимым data state и named trigger заканчивается hand-off; остальные строки останавливаются.', 'Матрица показывает, что возврат трафика не заменяет восстановление данных, а trigger не может быть неназванным наблюдением.'),
table('Состояния механизма', ['Состояние', 'Инвариант', 'Зачем он нужен', 'Чего не говорит'], [
['inventory complete', 'route, owner, record и dependency названы', 'есть объект перехода', 'инвентарь реальной системы полон'],
['traffic comparable', 'control/candidate имеют общий route', 'есть граница сравнения', 'доли отражают реальный трафик'],
['data reversible', 'restore и reconciliation видимы', 'копия не выдана за откат', 'восстановление технически выполнено'],
['rollback decidable', 'trigger, owner, return states именованы', 'стоп можно обсуждать', 'среда будет изменена'],
]),
h2('Inventory — тип входа, а не приложение к документу'),
p('Механизм принимает inventory как тип входа. Один route должен иметь id, owner и dependency; один record — id, reader и writer. Эта модель не пытается вывести связи по имени файла или URL. Вывод был бы внешним исследованием и нарушил бы границу package. Фиксированный объект позволяет спросить проще: достаточно ли информации, чтобы связать traffic slice с data transition? Если нет, ошибка называется до того, как появляются проценты и диаграммы.'),
p('В accepted case dependency <code>record-ledger-entry</code> соединяет route и record. Это искусственный, но полезный шов. Когда dependency отсутствует, engine не угадывает, что route «вероятно» читает record. Он возвращает <code>stop-missing-inventory</code>. Fail-closed здесь важнее удобства: оптимистичное допущение именно превращает целевую архитектуру в план без текущего состояния. В настоящем расследовании пробел нужно заполнять доказательством, а не значением по умолчанию.'),
h2('Traffic slice — отношение двух множеств'),
p('Контрольная граница — не просто остаток после candidate share. Она должна быть named set routes и совпадать с candidate route set в том вопросе, который обсуждает review. Если candidate читает route A, а control — route B, числа 10 и 90 не образуют сравнение. Если control пуст, candidate становится демонстрацией самого себя. Функция проверяет это как структуру массивов и останавливает состояние до анализа data или trigger.'),
p('Observation window содержит id и список metric ids. В реальности окно потребовало бы время, систему наблюдения и определение расчёта. Здесь их нет намеренно. <code>fixed-window-15-samples-v1</code> — имя синтетической границы, а не пятнадцать реальных измерений. Требование к имени спасает модель от фразы «после перевода посмотрим». Но оно не делает список имён телеметрией и не подтверждает какой-либо пользовательский эффект.'),
h2('Data state обязан иметь направление назад'),
p('Модель не использует bool <code>migrated</code>, потому что тот не говорит ничего о возвращении. У accepted case есть state <code>copy-verified-reversible</code>, source/target record, reconciliation и restore state. Любой пропуск переводит его в <code>stop-irreversible-data-state</code>. В частности, <code>copy-complete</code> не проходит. Это сознательная семантика: наличие target не эквивалентно возможности восстановить прежнее write condition.'),
p('Можно возразить, что некоторые переходы не требуют data rollback. Возможно; тогда это должно быть отдельным явно названным вариантом модели с другой задачей, а не дырой в текущем record. Механизм не обязан покрыть все архитектуры. Его обязанность — не позволить несовместимым смысловым случаям спрятаться за одной строкой. Прямое ограничение лучше универсального объекта, который принимает всё и не умеет объяснить, почему rollback оказался недоступен.'),
h2('Литеральное выполнение state machine'),
code("import { createFixedMigrationRecord, evaluateFixedTransitionState } from './upgrade-2026-07.mjs';\n\nconst record = createFixedMigrationRecord('traffic-without-control-v1');\nconst result = evaluateFixedTransitionState(record);\nconsole.log({ status: result.status, reasons: result.reasons, next: result.nextAction });\n// { status: 'stop-unbounded-traffic-slice', reasons: ['traffic-slice-requires-named-control-boundary'], next: 'name-control-candidate-same-route-and-observation-window' }"),
p('Этот snippet проверяет отрицательную ветку буквально. Он не создаёт traffic slice и не читает load balancer. Результат ценен тем, что имеет один named reason и одно named next action. Для технической речи это полезнее, чем «проверьте маршрутизацию»: reader видит, что следует добавить в record, и одновременно видит, чего функция не делает. Исполняемость здесь относится к детерминированной классификации literal, не к изменению инфраструктуры.'),
h2('Rollback decidable, когда заданы границы решения'),
p('Rollback складывается из условия, владельца и двух return states. Condition отвечает, какое наблюдение в рамках модели вызывает вопрос; owner — кто удерживает выбор как именованный предмет review; traffic/data states — что вообще подразумевается под «назад». Без trigger механизм возвращает <code>stop-unnamed-rollback-trigger</code>. Он не заменяет trigger словом «аномалия», потому что это возвращает нас к зелёному статусу, который зависит от непередаваемого контекста.'),
p('Это различие помогает не злоупотреблять аналогией с deployment rollback. Versioned Kubernetes documentation описывает управляемое изменение желаемого state у Deployment и отмечает, что откат ранней revision возвращает Pod template. В модели migration это внешний пример узкого scope, а не шаблон подмены. Мы не переносим Kubernetes semantics на record или traffic. Наоборот, используем источник, чтобы не назвать rollback одного controller отменой всех последствий перехода.'),
h2('Последовательность построения перехода'),
ol([
'Собрать inventory и проверить, что первый route связан с owner, dependency и record read/write boundary.',
'Построить два traffic sets с одним route boundary и дать observation window отдельное имя.',
'Описать data transition через source, target, reconciliation и reversible restore, не через флаг completed.',
'Назвать rollback trigger, condition, decision owner, traffic return и data return как разные поля.',
'Проверить allowed positive status: он должен быть hand-off и содержать productionEffect not-attempted.',
]),
h2('Критерии должны быть сильнее комментария'),
p('Фраза «данные синхронизированы» звучит сильнее, чем позволяет current object. Механизм вместо неё разрешает только <code>reconciliation: fixed-count-and-key-set-v1</code>. Это имя требует дальнейшего отдельного определения, но не притворяется его выполнением. Точно так же «candidate стабилен» в package заменяется на ссылку на named observation window. Уменьшение амбиции здесь не потеря качества: это отказ от статуса, который нельзя вывести из входа.'),
p('RFC 2119 полезен для самопроверки формулировок: MUST в этой статье относится к структуре record, а не к человеку, процессу или production system. Можно сказать: accepted branch MUST иметь restore state. Нельзя сказать: команда MUST переключить маршрут. В первом случае проверка делает statement наблюдаемым. Во втором текст играет роль оператора над внешним миром, хотя у него нет доступа ни к этому миру, ни к его последствиям.'),
h2('Ограничения автомата и следующий шаг'),
p('State machine не выражает длительность, concurrency, свойства конкретного хранилища, законодательные требования или стоимость простоя. Она не может различить хороший и плохой trigger, если оба именованы. Она также не оценивает, правильно ли выбран owner. Это намеренная цена формальности: machine защищает связность модели, но не заменяет инженерное суждение, discovery и отдельные evidence о среде.'),
p('Следующий безопасный шаг — добавить fixed case с несовпадающими control/candidate routes либо с конфликтом source/target record и проверить, что status остаётся точным. Такой рост тестирует границы, а не делает successful branch громче. Если новый случай требует иной семантики данных, его стоит выделить в отдельную карточку. Нельзя расширить существующий positive hand-off до «разрешено мигрировать»: это была бы другая функция с отсутствующими входами.'),
], [
{ key: 'kube', use: 'Использована граница rollback Deployment: он относится к Pod template; это контрпример попытке назвать rollback controller откатом данных.', boundary: 'Не выводит Kubernetes API, состояние кластера или стратегию перехода.' },
{ key: 'sre', use: 'Использована только формулировка положительной обратной связи для объяснения ценности раннего, именованного stop.', boundary: 'Не подтверждает конкретный failure mode или trigger synthetic record.' },
{ key: 'rfc2119', use: 'Использована терминология MUST/MAY для отделения инварианта JS model от приказа внешней среде.', boundary: 'Не создаёт нормативных требований к реальной миграции.' },
]);
const field = revision({
slug: 'editorial-2026-07-field-migration-playbook', title: 'Репетиция перехода: evidence loop, stop decision и hand-off без ложной готовности', categories: ['Полевые заметки', 'Инженерные решения'], cover: '/assets/editorial/2026/migration-playbook-2026-transition-evidence-loop.svg', excerpt: 'Полевая карточка migration review: как связать rehearsal evidence с контрольной границей, контрпримером и узким stop или hand-off.', readingMinutes: 22,
}, [
p('Самый опасный результат репетиции миграции — не ошибка, а уверенная запись «всё прошло». Без инвентаря, границы трафика, состояния данных и именованного триггера эта фраза не сообщает, что наблюдалось и как остановить переход. Цена — завышенная готовность: следующий участник воспринимает заметку как разрешение, хотя автор мог видеть лишь один удачный фрагмент. При первом отклонении приходится заново восстанавливать условия и ответственность.'),
p('Field note должна делать обратное: превращать наблюдение в маленькую цепочку <em>input → criterion → evidence → counterexample → decision</em>. Здесь каждый элемент — fixed synthetic literal. Есть только два допустимых финала: точный <code>stop-…</code> или <code>synthetic-migration-review-hand-off</code> с <code>productionEffect: not-attempted</code>. Ни один из них не разрешает переводить routes, переносить records, запускать rehearsal или менять инфраструктуру.'),
h2('Репетиция начинается с вопроса, а не с сценария'),
p('Фраза «отрепетировать миграцию» слишком велика. В поле полезнее спросить: какой один переход мы способны описать без скрытых предположений? Fixed case выбирает <code>route-ledger-read</code>, <code>record-ledger-entry</code> и named transition. Он не делает вид, что ими покрыт портфель приложений. Такое сужение не мешает глубине: оно заставляет показать, какой owner знает route, какой writer связан с record и какая dependency отделяет один шаг от другого.'),
p('Если любой из этих ответов отсутствует, evidence loop не должен переходить к метрикам. Иначе review внезапно начинает рассуждать о качестве candidate, не зная, чему принадлежат наблюдения. Статус <code>stop-missing-inventory</code> корректен даже при заполненной целевой диаграмме. Диаграмма отвечает на другой вопрос. Полевой review защищает переход от того, чтобы текущая неизвестность была спрятана за будущим желаемым состоянием.'),
figure('/assets/editorial/2026/migration-playbook-2026-transition-evidence-loop.svg', 'Кольцо из пяти узлов: input, criterion, evidence, контрпример или stop и synthetic hand-off. Красная стрелка возвращает к новому literal вместо действия в среде.', 'Evidence loop не превращает observation в разрешение. Новая попытка возможна только после нового именованного input.'),
table('Запись полевого решения', ['Поле', 'Что зафиксировать', 'Пример fixed literal', 'Запрещённая подмена'], [
['input', 'объект и граница', 'route-ledger-read', '«вся система»'],
['criterion', 'условие перехода', 'control и recovery state', '«выглядит готовым»'],
['evidence', 'узкое наблюдение', 'window с двумя metric ids', 'реальная телеметрия'],
['counterexample', 'что отменяет вывод', 'missing control boundary', '«бывает по-разному»'],
['decision', 'stop или hand-off', 'productionEffect not-attempted', 'разрешение на cutover'],
]),
h2('Evidence должно связывать четыре плоскости'),
p('Для миграции недостаточно одного успешного check. Evidence связывает inventory с route, traffic boundary с тем же route, data state с record и rollback trigger с обоими return states. Если связь порвана, текст не имеет права говорить «переход контролируемый». Он может назвать только отсутствие конкретного звена. Эта строгость выглядит медленной до первого спорного момента; затем она экономит часы, потому что reviewer не ищет в общем документе, к какой части относится наблюдение.'),
p('В fixed case evidence не хранит значения ошибок, время или реальные traces. Он хранит id окна и два metric ids. Поэтому корректная фраза: «в record названы поля наблюдения». Некорректная: «ошибок не было». Разница кажется стилистической, но она защищает решение. Первая фраза следует из literal. Вторая требует системы измерений, интервала и выборки, которых package принципиально не имеет. M9-голос выигрывает от такой краткости: меньше эффектных слов, больше проверяемых границ.'),
h2('Контрпример — часть успеха, не примечание к нему'),
p('Accepted branch полезен только если рядом виден случай, который его отменяет. Для traffic это candidate без control; для data — copy без restore state; для rollback — trigger без condition. Контрпример не утверждает, что переход опасен. Он доказывает более скромное: текущий вывод нельзя получить из текущего input. Такой stop не наказание автору. Это короткое описание следующего факта, который нужен, чтобы продолжить review без изменения модальности текста.'),
p('Особенно важен контрпример с данными. Возвращение трафика на прежний endpoint часто воспринимают как полный rollback, хотя у модели есть отдельное поле <code>rollback.dataState</code>. Если оно пусто, функция возвращает <code>data-migration-has-no-named-recovery-state</code>. Это не предписание о стратегии восстановления. Это запрет назвать обратимой операцию, для которой не описано, как связать новые данные с предыдущим write state.'),
h2('Буквальный stop decision'),
code("import { createFixedMigrationRecord, reviewFixedMigrationPlaybook } from './upgrade-2026-07.mjs';\n\nconst record = createFixedMigrationRecord('rollback-without-trigger-v1');\nconst result = reviewFixedMigrationPlaybook(record);\nconsole.log({ status: result.status, reason: result.reasons[0], next: result.nextAction });\n// { status: 'stop-unnamed-rollback-trigger', reason: 'rollback-trigger-is-not-named', next: 'name-trigger-condition-owner-and-traffic-data-return-state' }"),
p('Пример выполняется буквально и останавливается до любого действия. Он не создаёт incident, не открывает dashboard и не выбирает fix-forward. При этом он делает decision review пригодным для передачи: есть status, причина и точное следующее действие над моделью. Важно не смягчать output до «нужно подумать об откате». Такой совет не хранит границу и через день потребует устного пересказа. Named reason переносится вместе с literal.'),
h2('Стоп лучше постановочного green'),
p('Green status опасен, когда его нельзя воспроизвести. В этой карточке hand-off появляется только после трёх независимых проверок: inventory complete, transition state complete и allowed positive next action. Даже тогда итог формулируется скромно: fixed record можно передать на synthetic migration review. В нём остаётся boundary и поле <code>not-attempted</code>. Это не эстетическая перестраховка. Это контракт с будущим читателем, который может не знать контекста автора.'),
p('Такой подход согласуется с узкой границей rollback у Kubernetes Deployment documentation. Если даже controller rollback имеет определённый объект — Pod template, то в инженерском тексте нельзя расширять слово «rollback» без названия объекта. Для полевой заметки достаточно спросить: что возвращает traffic state, что возвращает data state и по какому наблюдению это вообще обсуждается? Пока нет всех ответов, review должен остановиться, а не компенсировать пробел опытом рецензента.'),
h2('Пять шагов evidence loop'),
ol([
'Зафиксировать один synthetic input и назвать его границу: route, record, owner и dependency.',
'Выбрать критерий, который проверяет структура: comparable traffic и reversible data, а не обещание результата.',
'Сохранить evidence как id окна и named fields; не приписывать им фактические значения или эффект.',
'Добавить контрпример с конкретным stop reason и проверить его public export так же буквально, как accepted case.',
'Выпустить только stop или synthetic hand-off, сохранив open question, boundary и productionEffect not-attempted.',
]),
h2('Ответственность держится на имени, а не на роли «команда»'),
p('Decision owner в fixed record — <code>fixed-transition-reviewer</code>. Это не реальный человек и не организационная рекомендация. Его функция — показать, что решение нельзя спрятать в безличное «мы откатим». Рядом есть условие trigger и два состояния возврата. Вместе они образуют предмет review. Если owner отсутствует, модель должна бы остановиться так же, как при пустом trigger; в текущем case этот дефект проверяется в одной ветке, чтобы не делать учебный пример притворной системой управления.'),
p('Слово cutover легко разрастается до обещания, которое input не поддерживает. Наш package не берёт его как доказательство, что route можно переключить. Он использует более строгую внутреннюю форму: route set является literal, а не вызовом к DNS или load balancer. Поэтому нельзя переносить название этапа в status. Решение определяется только тем, что явно лежит в input и что функция умеет вернуть.'),
h2('Как вычитывать текст перед hand-off'),
p('Сначала заменить в выводе все крупные слова полями literal. «Готовы к cutover» не выживает: такого поля нет. «Есть synthetic hand-off при complete record» выживает. Затем проверить, не утекло ли реальное время: у record нет clock, значит «в течение пятнадцати минут» будет ложным claim. Наконец, проверить глаголы: «перевели», «восстановили», «проверили сервис» запрещены. Package ничего не делает за пределами памяти Node.'),
p('RFC 2119 особенно полезен на этом проходе. MUST можно применить к обязательному полю review card; MAY — к следующему synthetic counterexample. Но слова не должны маскировать власть над окружением. Если правило нельзя проверить через public export на named literal, оно не должно звучать как gate. Эта редактура делает полевую заметку короче и надёжнее: читатель видит, где заканчивается наблюдение и начинается задача для другого, отдельно определённого процесса.'),
h2('Ограничения и следующий шаг'),
p('Evidence loop не заменяет репетицию с реальными системами, не подтверждает recovery procedure и не доказывает, что metrics достаточно. Он также не говорит, что выбранный trigger хороший, а route — важный. Google SRE Book, используемая здесь только для понятия положительной обратной связи, не превращает fixed mismatch в предсказание каскада. Модель учит остановиться на неизвестном, но не учит устранять неизвестное без дополнительных данных.'),
p('Следующий шаг — создать новый named literal с одним изменённым условием и выполнить тот же review. Например, сохранить control/candidate boundary, но удалить reconciliation, чтобы убедиться, что output остаётся fail-closed. Это расширяет библиотеку контрпримеров и делает language точнее. Не следует на основании accepted case начать миграцию, обещать rollback или публиковать real inventory: любой из этих актов требует иных входов, полномочий и evidence, которых в P101 нет.'),
], [
{ key: 'kube', use: 'Источник ограничивает смысл rollback Deployment его Pod template; это поддерживает требование называть объект rollback отдельно.', boundary: 'Не поддерживает claims о traffic, data, кластере или завершённой миграции.' },
{ key: 'sre', use: 'Источник используется только для понятия каскадной положительной обратной связи и пользы раннего stop condition.', boundary: 'Не устанавливает реальные условия инцидента или пороги evidence.' },
{ key: 'rfc2119', use: 'Источник определяет requirement keywords, применённые лишь к проверяемой структуре synthetic review card.', boundary: 'Не делает текст операционным распоряжением.' },
]);
export const revisions = [practice, mechanism, field];
export function verifyRevisionsAgainstFixture() {
const fixture = runFixedMigrationFixture();
const articleChecks = revisions.map((revision) => { const text = bodyText(revision.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(?:цен[аы]|стоимост|издержк)/i.test(text.slice(0, 900)) && /<table>/.test(revision.contentHtml) && /<figure>/.test(revision.contentHtml) && /<pre><code>/.test(revision.contentHtml) && /<ol>/.test(revision.contentHtml); });
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((revision) => [revision.slug, bodyText(revision.contentHtml).length])) });
}
if (process.argv.includes('--verify-fixture')) { const report = verifyRevisionsAgainstFixture(); console.log(JSON.stringify(report, null, 2)); if (!report.accepted) process.exitCode = 1; }
if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));