diff --git a/editorial/production/README.md b/editorial/production/README.md index d02e16c..423a498 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 247 из 358 созданных материалов. Остальные 111 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 250 из 358 созданных материалов. Остальные 108 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2024-12-draft.md b/editorial/reviews/2024-12-draft.md new file mode 100644 index 0000000..8410b15 --- /dev/null +++ b/editorial/reviews/2024-12-draft.md @@ -0,0 +1,133 @@ +# P82 — декабрь 2024: maintenance retro + +## Scope и граница выпуска + +Пакет заменяет только три декабрьские overlay-ревизии: + +- editorial-2024-12-practice-maintenance-retro +- editorial-2024-12-mechanism-maintenance-retro +- editorial-2024-12-field-maintenance-retro + +Все owners, evidence, cases, scores, costs, timelines и outcomes в материале +являются versioned fixed synthetic in-memory literals из +synthetic-maintenance-retro/2024-12-v1. Скрипт не читает filesystem, Git, CI, +network, telemetry, incident archive, clock, team history или production state. +Он не выполняет deployment, deletion, rollback или любой внешний change. + +## Досье источников + +Все URL ниже проверены обычным HTTPS GET 2026-07-31: HTTP 200, без +аутентификации и TLS bypass. Это источники, существовавшие до декабря 2024. + +| Источник, дата или версия | Точная поддерживаемая мысль | Ограничение источника | +| --- | --- | --- | +| [Google SRE Book, Chapter 15: Postmortem Culture: Learning from Failure](https://sre.google/sre-book/postmortem-culture/), первое издание 2016 | Postmortem фиксирует воздействие, причины, response и follow-up; триггеры и owner action item должны быть определены, а обучение не равно поиску виноватого. | Не задаёт maintenance backlog score, не подтверждает историю читателя и не санкционирует change. | +| [Google SRE Book, Chapter 5: Eliminating Toil](https://sre.google/sre-book/eliminating-toil/), первое издание 2016 | Повторяемую ручную операционную работу полезно отличать от устойчивой инженерной ценности; источник toil стоит устранять осознанно. | Не говорит, что любая ручная проверка является долгом, и не даёт универсальную оценку времени или денег. | +| [NIST SP 800-30 Rev. 1: Guide for Conducting Risk Assessments](https://csrc.nist.gov/pubs/sp/800/30/r1/final), final September 2012 | Risk assessment служит информацией для выбора курса действий; контекст и uncertainty нельзя подменять одним числом. | Не утверждает, что формула из этой статьи — вероятность, стандартная шкала или финансовый прогноз. | +| [NIST SP 800-40 Rev. 4: Guide to Enterprise Patch Management Planning](https://csrc.nist.gov/pubs/sp/800/40/r4/final), final April 2022 | Preventive maintenance различает identification, prioritization, implementation и verification. | Не заменяет локальные ownership rules, approval, tests, change window или проверку конкретной системы. | + +Покрытие на статью: + +- Practice использует минимум Google postmortem и Google toil; NIST SP 800-30 + и SP 800-40 уточняют границу оценки и preventive maintenance. +- Mechanism использует минимум NIST SP 800-30 и SP 800-40; Google toil + объясняет, почему повторяемость не равна любой ручной работе. +- Field использует минимум Google postmortem и NIST SP 800-40; Google toil и + NIST SP 800-30 ограничивают выводы из повторяемости и score. + +## Проход 1 — факты и техника + +Проверено вручную: + +- Во всех трёх текстах первые два абзаца называют ситуацию и цену ошибки. + Нет утверждения о реальном incident, team history, customer, metric, saving + или production outcome. +- Practice отделяет symptom, risk, cost class, owner role, evidence boundary и + one next experiment. Ценой названы только учебные классы: interrupt, delay + review и recheck, а не деньги. +- Mechanism прямо называет formula + impact * repeatability + uncertainty * 2 - evidenceStrength учебной. + Heatmap задаёт только review order; score не назван вероятностью, бюджетом, + прогнозом или разрешением на change. +- Field имеет три fixed synthetic case, T0–T3 timeline, recheck loop, + stop rule, restore question и next-quarter plan. Stop path отбрасывает + draft; это не production rollback. +- Контракт модели требует exact keys и canonical fixed report/draft. Fixture + покрывает unknown keys, missing fields, forged score, dense arrays, sparse + nested array, cyclic report, cyclic draft, mutated actions и safe stop path. +- Техническая проверка не нашла imports, fetch, filesystem, child process или + HTTP client в overlay script. Внешние URL находятся только в опубликованном + source list и не являются I/O модели. + +После этого прохода уточнена формулировка «явная граница доказательств» и +убраны неясные смешанные фразы, чтобы reader не принимал synthetic artefact за +наблюдение реальной системы. + +## Проход 2 — редактура и голос + +Прочитаны полные тела статей до списка источников: + +| Статья | Объём основного текста | Редакторский вывод | +| --- | ---: | --- | +| Practice | 10 020 знаков | Уплотнена вокруг review-card: symptom → причина → проверка → один experiment. Есть таблицы, code example, figure, ограничения и следующий шаг. | +| Mechanism | 10 259 знаков | Score сначала ограничен evidence types и priority boundary. Из текста убраны обещания точной стоимости и «математического» решения. | +| Field | 11 665 знаков | Три case не превращены в выдуманное ретро. Timeline отделяет draft, recheck и отдельный human-approved change. | + +Голос соответствует декабрю 2024: системный практик показывает наблюдаемый +артефакт, цену решения, ownership и путь проверки, но не пишет от лица +универсальной платформенной стратегии 2027 года. Термины раскрываются рядом с +сценарием, а абзацы следуют цепочке symptom → причина → проверка → действие. + +## Проход 3 — визуал и выпуск + +Проверено вручную: + +- SVG открыты и просмотрены после Sharp-render на ширине 375 px: + heatmap — 375×271, change timeline — 375×240, review loop — 375×260. + Основные заголовки, карточки, направления и boundary остаются читаемыми; + HTML-подписи дополняют схему без скрытого смысла. +- У каждой статьи есть meaningful alt, figcaption, минимум одна HTML table, + compact reproducible code example, numbered action sequence, source section, + limitations и concrete next step. +- XML проходит xmllint. Safety scan не нашёл script, foreignObject, + javascript URI, data:image или event handler. +- node --check проходит. +- node scripts/upgrade-2024-12.mjs --verify-fixture проходит: + PASS fixture: 31/31 assertions. +- npm run audit:draft -- scripts/upgrade-2024-12.mjs проходит для всех трёх + slug. + +До интеграции registry, README, очередь, articles.json, staging, commit и push +намеренно не выполнялись: они были вне разрешённого scope P82. + +## Вердикт + +P82 готов как самостоятельный draft-пакет. Он может быть интегрирован отдельным +выпускным шагом после review общего registry; текущий пакет сам ничего не +публикует и не изменяет внешний state. + +## Выпуск после независимой приёмки + +Основной редактор провёл отдельные три прохода уже после интеграции: + +1. **Факты и техника.** Официальная глава Google о postmortem подтверждает + запись воздействия, причин, действий и follow-up, а также необходимость + заранее определить triggers. Глава о toil отделяет повторяемую ручную работу + от работы с устойчивой ценностью. Официальные страницы NIST подтверждают + final-статус SP 800-30 Rev. 1 (сентябрь 2012) и SP 800-40 Rev. 4 (апрель + 2022); вторая явно различает identifying, prioritizing, installing и + verifying. Эти источники не расширены до утверждений о реальной системе. + `node --check` и fixture повторно прошли: **31/31**. +2. **Редактура и голос.** Полные тексты перечитаны после интеграции. В ранних + абзацах раскрыты рабочие англоязычные термины: `review`, `card`, + `evidence boundary`, `score`, `timeline`, `recheck` и `stop`. Это оставляет + язык коротким для инженера 2024 года, но не заставляет читателя угадывать + смысл терминов. `audit:draft` и целевой `audit:articles` подтвердили объём, + по рисунку, коду и минимум две таблицы в каждой статье. +3. **Визуал и выпуск.** Все три SVG снова отрисованы Sharp на 375 px и + просмотрены: heatmap — 375×271, timeline — 375×240, loop — 375×260; + подписи, карточки и стрелки остаются читаемыми. XML и safety scan чисты. + Реестр содержит **241 уникальную** ревизию. `git diff --check` чист, а + `npm run build` успешно собрал **374** статические страницы. + +Вердикт: три декабрьские статьи готовы к отдельному публикационному коммиту. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 631f832..3058a44 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -78,6 +78,7 @@ import { revisions as august2024Revisions } from '../scripts/upgrade-2024-08.mjs import { revisions as september2024Revisions } from '../scripts/upgrade-2024-09.mjs'; import { revisions as october2024Revisions } from '../scripts/upgrade-2024-10.mjs'; import { revisions as november2024Revisions } from '../scripts/upgrade-2024-11.mjs'; +import { revisions as december2024Revisions } from '../scripts/upgrade-2024-12.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -161,4 +162,5 @@ export const editorialRevisions = [ ...september2024Revisions, ...october2024Revisions, ...november2024Revisions, + ...december2024Revisions, ]; diff --git a/web/public/assets/editorial/2024/maintenance-retro-2024-change-timeline.svg b/web/public/assets/editorial/2024/maintenance-retro-2024-change-timeline.svg new file mode 100644 index 0000000..4d5ef40 --- /dev/null +++ b/web/public/assets/editorial/2024/maintenance-retro-2024-change-timeline.svg @@ -0,0 +1,41 @@ + + Synthetic maintenance review timeline + Горизонтальная последовательность T0 capture symptom, T1 draft one experiment, T2 recheck boundary, T3 decide stop continue or revise. Это учебный timeline без production change. + + + Maintenance timeline: знание до change + T0–T3 — fixed teaching points, не история реальной системы + + + + + + + + T0 + T1 + T2 + T3 + + + capture symptom + + draft one + bounded experiment + + recheck evidence + and scope boundary + + stop, continue + or revise + + + + + + + + Stop: + discard draft when scope grows or evidence is unproven. + No production rollback is implied. + diff --git a/web/public/assets/editorial/2024/maintenance-retro-2024-debt-risk-heatmap.svg b/web/public/assets/editorial/2024/maintenance-retro-2024-debt-risk-heatmap.svg new file mode 100644 index 0000000..e611255 --- /dev/null +++ b/web/public/assets/editorial/2024/maintenance-retro-2024-debt-risk-heatmap.svg @@ -0,0 +1,49 @@ + + Synthetic maintenance priority heatmap + Матрица impact и repeatability с тремя учебными карточками. Цвет показывает порядок review, а не вероятность или бюджет. + + + Приоритет maintenance item: только порядок review + impact × repeatability; uncertainty и evidence отдельно + + impact + high + medium + low + + + + + + + + + + + + + + A + + B + + C + + low + medium + high + repeatability + + + Cards + A runbook + 3 × 3 + B owner + 3 × 2 + C recheck + 2 × 2 + + + Boundary: review order, not forecast. + uncertainty и strength evidence остаются отдельными полями карточки. + diff --git a/web/public/assets/editorial/2024/maintenance-retro-2024-review-loop.svg b/web/public/assets/editorial/2024/maintenance-retro-2024-review-loop.svg new file mode 100644 index 0000000..dae26bd --- /dev/null +++ b/web/public/assets/editorial/2024/maintenance-retro-2024-review-loop.svg @@ -0,0 +1,37 @@ + + Maintenance review loop with a human decision boundary + Пять шагов образуют loop: observe symptom, write card, run one bounded experiment, recheck evidence, human decision continue stop revise. Внешний контур запрещает выполнять production change внутри модели. + + + Maintenance review loop + один symptom → один evidence boundary → один experiment + + + 1. observe symptom + + 2. write card + risk, owner, boundary + + 3. one experiment + review artefact only + + 4. recheck evidence + known and unknown + + 5. human decision + continue, stop or revise + + + + + + + + + + + + + + Review artefact only · no production change inside this loop. + diff --git a/web/scripts/upgrade-2024-12.mjs b/web/scripts/upgrade-2024-12.mjs new file mode 100644 index 0000000..45e5916 --- /dev/null +++ b/web/scripts/upgrade-2024-12.mjs @@ -0,0 +1,878 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const p = (text) => '

' + text + '

'; +const h2 = (text) => '

' + text + '

'; +const code = (text) => '
' + escapeHtml(text) + '
'; +const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; +const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); +} + +const sources = [ + { + title: 'Google SRE Book, Chapter 15: Postmortem Culture: Learning from Failure, first edition 2016', + url: 'https://sre.google/sre-book/postmortem-culture/', + note: 'Первичный официальный текст Google. Он описывает postmortem как запись влияния, причин, действий и follow-up, а также требует заранее определять триггеры. Он не предписывает конкретный backlog score, состав команды или частоту годового review.', + }, + { + title: 'Google SRE Book, Chapter 5: Eliminating Toil, first edition 2016', + url: 'https://sre.google/sre-book/eliminating-toil/', + note: 'Первичный официальный текст Google. Он отличает повторяемую ручную операционную работу от устойчивой инженерной ценности и показывает, почему источник toil полезно устранять, а не только фиксировать. Он не даёт универсальной оценки цены одного maintenance item.', + }, + { + title: 'NIST SP 800-30 Rev. 1: Guide for Conducting Risk Assessments, final September 2012', + url: 'https://csrc.nist.gov/pubs/sp/800/30/r1/final', + note: 'Официальная финальная публикация NIST. Она задаёт риск-оценку как вход для выбора курса действий и требует учитывать контекст и неопределённость. Она не утверждает, что произведение нескольких баллов является достоверной вероятностью.', + }, + { + title: 'NIST SP 800-40 Rev. 4: Guide to Enterprise Patch Management Planning, final April 2022', + url: 'https://csrc.nist.gov/pubs/sp/800/40/r4/final', + note: 'Официальная финальная публикация NIST. Она описывает preventive maintenance как идентификацию, приоритизацию, внедрение и проверку обновлений. Она не заменяет локальные ownership rules, change approval или проверку конкретной системы.', + }, +]; + +function sourceList() { + return ''; +} + +function revision(meta, parts) { + const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) { + throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength); + } + return Object.freeze({ ...meta, contentHtml, proseLength }); +} + +const MODEL_VERSION = 'synthetic-maintenance-retro/2024-12-v1'; +const FIXTURE_SCOPE = 'synthetic-maintenance-retro-2024-12'; +const FIXTURE_MODE = 'fixed-memory-only'; +const FIXTURE_LIMIT = 'versioned-fixed-synthetic-js-literals-in-memory-only; no filesystem, git, CI, network, production, telemetry, cloud account, incident archive, clock, team history or live change'; + +const INPUT_KEYS = Object.freeze(['kind', 'synthetic', 'scope', 'modelVersion', 'mode', 'caseId']); +const REPORT_KEYS = Object.freeze([ + 'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'scope', 'caseId', + 'card', 'risk', 'evidence', 'decision', 'stopCondition', 'nextHumanQuestion', 'evidenceBoundary', + 'productionEffect', +]); +const DRAFT_KEYS = Object.freeze([ + 'kind', 'drafted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'sourceReport', + 'decision', 'actions', 'nextQuarterPlan', 'stopCondition', 'returnPoint', 'actualSystem', + 'telemetry', 'ci', 'network', 'filesystem', 'productionEffect', +]); +const STOP_KEYS = Object.freeze([ + 'kind', 'stopped', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'sourceDraft', + 'returnPoint', 'realChange', 'telemetry', 'ci', 'network', 'filesystem', 'productionEffect', +]); + +function isPlainRecord(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactKeys(value, expected) { + if (!isPlainRecord(value)) return false; + const keys = Object.keys(value); + return keys.length === expected.length + && expected.every((key) => Object.hasOwn(value, key)) + && keys.every((key) => expected.includes(key)); +} + +function hasDenseArray(value) { + if (!Array.isArray(value)) return false; + const ownKeys = Reflect.ownKeys(value); + return ownKeys.length === value.length + 1 + && ownKeys.includes('length') + && Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean); +} + +function canonicalJson(value) { + const visited = new WeakSet(); + + function visit(current) { + if (current === null) return 'null'; + if (typeof current === 'string' || typeof current === 'boolean') return JSON.stringify(current); + if (typeof current === 'number') return Number.isFinite(current) ? JSON.stringify(current) : null; + + if (Array.isArray(current)) { + if (!hasDenseArray(current) || visited.has(current)) return null; + visited.add(current); + const values = current.map((item) => visit(item)); + visited.delete(current); + return values.every((item) => item !== null) ? '[' + values.join(',') + ']' : null; + } + + if (!isPlainRecord(current) || visited.has(current)) return null; + visited.add(current); + const keys = Object.keys(current).sort(); + const pairs = keys.map((key) => { + const nested = visit(current[key]); + return nested === null ? null : JSON.stringify(key) + ':' + nested; + }); + visited.delete(current); + return pairs.every((item) => item !== null) ? '{' + pairs.join(',') + '}' : null; + } + + return visit(value); +} + +function hasSameCanonicalJson(left, right) { + const leftJson = canonicalJson(left); + const rightJson = canonicalJson(right); + return leftJson !== null && rightJson !== null && leftJson === rightJson; +} + +function freezeJson(value) { + if (Array.isArray(value)) return Object.freeze(value.map((item) => freezeJson(item))); + if (isPlainRecord(value)) { + return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, freezeJson(item)]))); + } + return value; +} + +function fixedCard(card) { + return freezeJson(card); +} + +const fixedCards = Object.freeze({ + 'repeated-runbook-gap': fixedCard({ + kind: 'synthetic-maintenance-card/v1', + cardVersion: MODEL_VERSION, + label: 'Repeated handoff returns to a missing diagnostic boundary', + review: { + symptom: 'the same synthetic handoff needs a manual explanation on three review points', + risk: 'a later change can repeat the delay because the boundary is not recorded', + cost: 'fixed teaching cost class: repeated interrupt and slower review, not money or actual elapsed time', + ownerRole: 'synthetic service owner', + evidenceBoundary: 'only fixed labels are present; there is no log, ticket, repository, production trace or team history', + }, + evidence: { + known: ['fixed runbook section is absent in the synthetic card', 'fixed owner role is named'], + leadingSignals: ['review card still lacks one diagnostic boundary', 'next change proposal cannot name a check'], + laggingSignals: ['the fixed symptom appears again in the next synthetic review point'], + unknown: 'real frequency, user impact, caller population and implementation effort are intentionally unknown', + }, + score: { impact: 3, repeatability: 3, uncertainty: 2, evidenceStrength: 2 }, + experiment: { + id: 'synthetic-runbook-boundary-v1', + action: 'write one diagnostic boundary and rehearse it against a fixed failing path', + expectedEvidence: 'a reviewer can name symptom, boundary, check and stop condition without opening a real system', + }, + stop: { + condition: 'the proposed note expands into a broad redesign or claims evidence outside the fixed card', + returnPoint: 'keep the current synthetic card and discard the new draft', + }, + timeline: [ + { point: 'T0', event: 'repeat is recorded as a symptom, not as a generic debt label' }, + { point: 'T1', event: 'one bounded runbook experiment is drafted' }, + { point: 'T2', event: 'review checks whether the boundary is now explicit' }, + { point: 'T3', event: 'next quarter either keeps, revises or stops the experiment' }, + ], + }), + 'ownerless-compatibility-risk': fixedCard({ + kind: 'synthetic-maintenance-card/v1', + cardVersion: MODEL_VERSION, + label: 'Compatibility decision has a risk but no named decision owner', + review: { + symptom: 'a synthetic compatibility note names a risk but leaves the deciding role blank', + risk: 'a change can cross a contract boundary without a person responsible for accepting the remaining uncertainty', + cost: 'fixed teaching cost class: delayed review and repeated clarification, not a financial saving or a real incident', + ownerRole: 'synthetic API steward', + evidenceBoundary: 'the role is a teaching label; no customer, access record, release or historical decision is read', + }, + evidence: { + known: ['fixed card contains one contract boundary', 'fixed card contains an unnamed residual-risk question'], + leadingSignals: ['proposal has no accepting role', 'scope grows from one operation to a service-wide statement'], + laggingSignals: ['a later synthetic review reopens the same compatibility question'], + unknown: 'actual consumers, authorization, traffic, rollout state and change cost are not represented', + }, + score: { impact: 3, repeatability: 2, uncertainty: 3, evidenceStrength: 1 }, + experiment: { + id: 'synthetic-owner-boundary-v1', + action: 'name one role that can accept or reject the residual-risk statement for one contract boundary', + expectedEvidence: 'the review card has one owner role, one question and one stop condition; it does not approve a live change', + }, + stop: { + condition: 'the card tries to infer real consumer absence from the synthetic score', + returnPoint: 'preserve unknown as unknown and return to the unapproved teaching card', + }, + timeline: [ + { point: 'T0', event: 'risk is separated from the missing owner role' }, + { point: 'T1', event: 'one acceptance boundary is written for the fixed contract' }, + { point: 'T2', event: 'review rejects a score that pretends unknown means zero' }, + { point: 'T3', event: 'next quarter rechecks whether the role and boundary still match' }, + ], + }), + 'recheck-before-removal': fixedCard({ + kind: 'synthetic-maintenance-card/v1', + cardVersion: MODEL_VERSION, + label: 'A cleanup proposal needs a recheck before removal is discussed', + review: { + symptom: 'a synthetic cleanup item has a plausible action but only old fixed evidence labels', + risk: 'an apparently small removal can be treated as reversible when its compatibility boundary was not rechecked', + cost: 'fixed teaching cost class: extra review pass and postponed cleanup, not measured outage cost or savings', + ownerRole: 'synthetic change reviewer', + evidenceBoundary: 'no source tree, deployment, feature state, telemetry, release record or rollback is inspected', + }, + evidence: { + known: ['fixed proposal has a removal-shaped action', 'fixed card preserves a restore question'], + leadingSignals: ['evidence age label reaches the next review point', 'restore boundary is still a sentence rather than a tested procedure'], + laggingSignals: ['the synthetic card returns to review because recheck was skipped'], + unknown: 'real dependency graph, data compatibility, authorization and rollback success are not known', + }, + score: { impact: 2, repeatability: 2, uncertainty: 1, evidenceStrength: 3 }, + experiment: { + id: 'synthetic-recheck-gate-v1', + action: 'write a recheck gate and a stop rule before a removal proposal can move to human review', + expectedEvidence: 'the card distinguishes proposal, recheck and separate authorized change', + }, + stop: { + condition: 'the next action is described as deletion, deployment or rollback instead of a review artifact', + returnPoint: 'discard the proposal and retain the last documented synthetic boundary', + }, + timeline: [ + { point: 'T0', event: 'cleanup is recorded as a proposal, not as an executed change' }, + { point: 'T1', event: 'a recheck gate and restore question are added' }, + { point: 'T2', event: 'review decides whether uncertainty blocks the next quarter plan' }, + { point: 'T3', event: 'a separate human-approved change may be considered outside this model' }, + ], + }), +}); + +function createRisk(card) { + const impact = card.score.impact; + const repeatability = card.score.repeatability; + const uncertainty = card.score.uncertainty; + const evidenceStrength = card.score.evidenceStrength; + const priorityScore = impact * repeatability + uncertainty * 2 - evidenceStrength; + const priorityBoundary = priorityScore >= 10 + ? 'review-now' + : priorityScore >= 6 + ? 'plan-next-quarter' + : 'watch-and-recheck'; + + return freezeJson({ + formula: 'impact * repeatability + uncertainty * 2 - evidenceStrength', + impact, + repeatability, + uncertainty, + evidenceStrength, + priorityScore, + priorityBoundary, + heatmapCell: impact + 'x' + repeatability, + meaning: 'fixed teaching priority only; it is neither a probability nor a financial forecast', + }); +} + +function createDecision(card, risk) { + const decision = risk.priorityBoundary === 'review-now' + ? 'draft-one-bounded-experiment' + : risk.priorityBoundary === 'plan-next-quarter' + ? 'keep-one-quarter-plan-with-recheck' + : 'keep-visible-and-recheck-before-expansion'; + + return freezeJson({ + code: decision, + ownerRole: card.review.ownerRole, + nextExperiment: card.experiment, + priorityBoundary: risk.priorityBoundary, + approvalBoundary: 'human review is required before any real change; this model cannot authorize a change', + }); +} + +function createEvidence(card) { + return freezeJson({ + known: card.evidence.known, + leadingSignals: card.evidence.leadingSignals, + laggingSignals: card.evidence.laggingSignals, + unknown: card.evidence.unknown, + boundary: card.review.evidenceBoundary, + }); +} + +function rejectInspection(reason) { + return freezeJson({ + kind: 'synthetic-maintenance-retro-report/v1', + accepted: false, + syntheticOnly: true, + reason, + modelVersion: MODEL_VERSION, + modelLimit: FIXTURE_LIMIT, + scope: FIXTURE_SCOPE, + caseId: 'not-accepted', + card: 'not-read', + risk: 'not-calculated', + evidence: 'not-read', + decision: 'stop-before-human-review', + stopCondition: 'input-not-accepted', + nextHumanQuestion: 'which fixed in-memory input field failed the contract?', + evidenceBoundary: 'no external state was opened', + productionEffect: 'not-attempted', + }); +} + +function acceptedInput(input) { + return hasExactKeys(input, INPUT_KEYS) + && input.kind === 'synthetic-maintenance-retro-input/v1' + && input.synthetic === true + && input.scope === FIXTURE_SCOPE + && input.modelVersion === MODEL_VERSION + && input.mode === FIXTURE_MODE + && typeof input.caseId === 'string' + && Object.hasOwn(fixedCards, input.caseId) + && hasSameCanonicalJson(input, createFixedSyntheticMaintenanceInput(input.caseId)); +} + +export function createFixedSyntheticMaintenanceInput(caseId) { + return freezeJson({ + kind: 'synthetic-maintenance-retro-input/v1', + synthetic: true, + scope: FIXTURE_SCOPE, + modelVersion: MODEL_VERSION, + mode: FIXTURE_MODE, + caseId, + }); +} + +export function inspectSyntheticMaintenance(input) { + if (!acceptedInput(input)) return rejectInspection('input-is-not-an-exact-fixed-synthetic-case'); + + const card = fixedCards[input.caseId]; + const risk = createRisk(card); + const evidence = createEvidence(card); + const decision = createDecision(card, risk); + + return freezeJson({ + kind: 'synthetic-maintenance-retro-report/v1', + accepted: true, + syntheticOnly: true, + reason: 'fixed-case-inspected-without-external-io', + modelVersion: MODEL_VERSION, + modelLimit: FIXTURE_LIMIT, + scope: FIXTURE_SCOPE, + caseId: input.caseId, + card, + risk, + evidence, + decision, + stopCondition: card.stop, + nextHumanQuestion: 'does this bounded synthetic experiment describe one observable review artifact and its evidence boundary?', + evidenceBoundary: 'fixed literals only; no production, history, logs, files, Git, CI, network, telemetry or authorization were consulted', + productionEffect: 'not-attempted', + }); +} + +function rejectDraft(reason) { + return freezeJson({ + kind: 'synthetic-maintenance-retro-draft/v1', + drafted: false, + syntheticOnly: true, + reason, + modelVersion: MODEL_VERSION, + modelLimit: FIXTURE_LIMIT, + sourceReport: 'not-accepted', + decision: 'stop-before-human-review', + actions: [], + nextQuarterPlan: 'not-created', + stopCondition: 'report-not-accepted', + returnPoint: 'keep-current-fixed-card', + actualSystem: 'not-read', + telemetry: 'not-read', + ci: 'not-run', + network: 'not-used', + filesystem: 'not-read', + productionEffect: 'not-attempted', + }); +} + +function expectedReportFor(caseId) { + return inspectSyntheticMaintenance(createFixedSyntheticMaintenanceInput(caseId)); +} + +export function planSyntheticMaintenanceReview(report) { + if (!hasExactKeys(report, REPORT_KEYS) || report.accepted !== true || report.syntheticOnly !== true) { + return rejectDraft('report-shape-is-not-accepted'); + } + + if (typeof report.caseId !== 'string' || !Object.hasOwn(fixedCards, report.caseId)) { + return rejectDraft('report-case-is-not-accepted'); + } + + const expected = expectedReportFor(report.caseId); + if (!hasSameCanonicalJson(report, expected)) { + return rejectDraft('report-is-not-canonical-fixed-synthetic-evidence'); + } + + const card = fixedCards[report.caseId]; + const actions = freezeJson([ + { + kind: 'review-card-action/v1', + action: 'record ' + card.review.symptom, + boundary: 'write only a review artifact; do not alter a live system', + }, + { + kind: 'review-card-action/v1', + action: card.experiment.action, + boundary: 'validate the expected evidence in a human review, not by an external query', + }, + { + kind: 'review-card-action/v1', + action: 'reassess the fixed uncertainty before the next-quarter decision', + boundary: 'unknown remains unknown until a separately authorized method produces evidence', + }, + ]); + + return freezeJson({ + kind: 'synthetic-maintenance-retro-draft/v1', + drafted: true, + syntheticOnly: true, + reason: 'canonical-fixed-report-allows-a-review-draft-only', + modelVersion: MODEL_VERSION, + modelLimit: FIXTURE_LIMIT, + sourceReport: expected, + decision: expected.decision, + actions, + nextQuarterPlan: { + keep: 'keep one bounded review card visible', + stop: card.stop.condition, + recheck: 'recheck evidence boundary and priority boundary before any scope expansion', + }, + stopCondition: card.stop, + returnPoint: card.stop.returnPoint, + actualSystem: 'not-read', + telemetry: 'not-read', + ci: 'not-run', + network: 'not-used', + filesystem: 'not-read', + productionEffect: 'not-attempted', + }); +} + +function rejectStop(reason) { + return freezeJson({ + kind: 'synthetic-maintenance-retro-stop/v1', + stopped: false, + syntheticOnly: true, + reason, + modelVersion: MODEL_VERSION, + modelLimit: FIXTURE_LIMIT, + sourceDraft: 'not-accepted', + returnPoint: 'not-created', + realChange: 'not-attempted', + telemetry: 'not-read', + ci: 'not-run', + network: 'not-used', + filesystem: 'not-read', + productionEffect: 'not-attempted', + }); +} + +export function stopSyntheticMaintenanceReview(draft) { + if (!hasExactKeys(draft, DRAFT_KEYS) || draft.drafted !== true || draft.syntheticOnly !== true) { + return rejectStop('draft-shape-is-not-accepted'); + } + + const caseId = draft.sourceReport?.caseId; + if (typeof caseId !== 'string' || !Object.hasOwn(fixedCards, caseId)) { + return rejectStop('draft-case-is-not-accepted'); + } + + const expected = planSyntheticMaintenanceReview(expectedReportFor(caseId)); + if (!hasSameCanonicalJson(draft, expected)) { + return rejectStop('draft-is-not-canonical-fixed-synthetic-review'); + } + + return freezeJson({ + kind: 'synthetic-maintenance-retro-stop/v1', + stopped: true, + syntheticOnly: true, + reason: 'draft-discarded-before-any-real-change', + modelVersion: MODEL_VERSION, + modelLimit: FIXTURE_LIMIT, + sourceDraft: expected, + returnPoint: expected.returnPoint, + realChange: 'not-attempted', + telemetry: 'not-read', + ci: 'not-run', + network: 'not-used', + filesystem: 'not-read', + productionEffect: 'not-attempted', + }); +} + +function reverseOwnKeys(record) { + return Object.fromEntries(Object.entries(record).reverse()); +} + +export function runMaintenanceRetroFixture() { + const firstInput = createFixedSyntheticMaintenanceInput('repeated-runbook-gap'); + const firstReport = inspectSyntheticMaintenance(firstInput); + const firstDraft = planSyntheticMaintenanceReview(firstReport); + const firstStop = stopSyntheticMaintenanceReview(firstDraft); + + const secondReport = inspectSyntheticMaintenance( + createFixedSyntheticMaintenanceInput('ownerless-compatibility-risk'), + ); + const secondDraft = planSyntheticMaintenanceReview(secondReport); + const thirdReport = inspectSyntheticMaintenance( + createFixedSyntheticMaintenanceInput('recheck-before-removal'), + ); + const thirdDraft = planSyntheticMaintenanceReview(thirdReport); + + const reorderedReport = reverseOwnKeys(firstReport); + const reorderedDraft = planSyntheticMaintenanceReview(reorderedReport); + + const extraInput = { ...firstInput, telemetrySnapshot: 'forged' }; + const wrongModeInput = { ...firstInput, mode: 'external-io' }; + const unknownCaseInput = createFixedSyntheticMaintenanceInput('unknown-case'); + const missingReportField = { ...firstReport }; + delete missingReportField.evidence; + const extraReportField = { ...firstReport, actualIncident: 'forged' }; + const forgedScore = { + ...firstReport, + risk: { ...firstReport.risk, priorityScore: firstReport.risk.priorityScore + 1 }, + }; + const sparseReport = { + ...firstReport, + evidence: { ...firstReport.evidence, known: [...firstReport.evidence.known] }, + }; + delete sparseReport.evidence.known[0]; + const cyclicReport = { + ...firstReport, + decision: { ...firstReport.decision }, + }; + cyclicReport.decision.loop = cyclicReport; + + const extraDraft = { ...firstDraft, deployment: 'forged' }; + const sparseDraft = { ...firstDraft, actions: [...firstDraft.actions] }; + delete sparseDraft.actions[1]; + const cyclicDraft = { + ...firstDraft, + nextQuarterPlan: { ...firstDraft.nextQuarterPlan }, + }; + cyclicDraft.nextQuarterPlan.loop = cyclicDraft; + const changedActionDraft = { + ...firstDraft, + actions: firstDraft.actions.map((item, index) => index === 0 + ? { ...item, action: 'forged action' } + : item), + }; + + const assertions = freezeJson({ + exactInputAccepted: firstReport.accepted === true, + firstReportHasExactKeys: hasExactKeys(firstReport, REPORT_KEYS), + firstReportIsSyntheticOnly: firstReport.syntheticOnly === true, + firstReportUsesFixedLimit: firstReport.modelLimit === FIXTURE_LIMIT, + firstPriorityIsReviewNow: firstReport.risk.priorityBoundary === 'review-now', + firstDraftAccepted: firstDraft.drafted === true, + firstDraftHasExactKeys: hasExactKeys(firstDraft, DRAFT_KEYS), + firstDraftHasThreeDenseActions: hasDenseArray(firstDraft.actions) && firstDraft.actions.length === 3, + firstStopAccepted: firstStop.stopped === true, + firstStopHasExactKeys: hasExactKeys(firstStop, STOP_KEYS), + firstStopHasNoProductionEffect: firstStop.productionEffect === 'not-attempted', + secondReportAccepted: secondReport.accepted === true, + secondPriorityIsReviewNow: secondReport.risk.priorityBoundary === 'review-now', + secondDraftAccepted: secondDraft.drafted === true, + thirdReportAccepted: thirdReport.accepted === true, + thirdPriorityIsWatch: thirdReport.risk.priorityBoundary === 'watch-and-recheck', + thirdDraftAccepted: thirdDraft.drafted === true, + canonicalKeyOrderIsAccepted: reorderedDraft.drafted === true, + extraInputRejected: inspectSyntheticMaintenance(extraInput).accepted === false, + wrongModeRejected: inspectSyntheticMaintenance(wrongModeInput).accepted === false, + unknownCaseRejected: inspectSyntheticMaintenance(unknownCaseInput).accepted === false, + missingReportRejected: planSyntheticMaintenanceReview(missingReportField).drafted === false, + extraReportRejected: planSyntheticMaintenanceReview(extraReportField).drafted === false, + forgedScoreRejected: planSyntheticMaintenanceReview(forgedScore).drafted === false, + sparseNestedArrayRejected: planSyntheticMaintenanceReview(sparseReport).drafted === false, + cyclicReportRejectedWithoutThrow: planSyntheticMaintenanceReview(cyclicReport).drafted === false, + extraDraftRejected: stopSyntheticMaintenanceReview(extraDraft).stopped === false, + sparseActionsRejected: stopSyntheticMaintenanceReview(sparseDraft).stopped === false, + cyclicDraftRejectedWithoutThrow: stopSyntheticMaintenanceReview(cyclicDraft).stopped === false, + changedActionRejected: stopSyntheticMaintenanceReview(changedActionDraft).stopped === false, + rejectedInputDoesNotReadExternalState: inspectSyntheticMaintenance(extraInput).productionEffect === 'not-attempted', + }); + + return freezeJson({ + modelVersion: MODEL_VERSION, + scope: FIXTURE_SCOPE, + assertions, + }); +} + +const practice = revision({ + slug: 'editorial-2024-12-practice-maintenance-retro', + title: 'Долг — не список неприятностей: как провести maintenance review', + categories: ['Надёжность', 'Процессы'], + cover: '/assets/editorial/2024/maintenance-retro-2024-review-loop.svg', + excerpt: 'Повторяемый симптом, риск, цена, владелец и граница доказательств: короткая карточка maintenance review, которая приводит к одному проверяемому эксперименту, а не к бесконечному списку долгов.', + readingMinutes: 13, +}, [ + p('К концу года backlog часто выглядит убедительно: в нём есть старые задачи, раздражающие ручные шаги и несколько слов про риск. Но через неделю список перестаёт отвечать на главный вопрос: что именно повторяется и почему это опаснее других пунктов. Цена такой записи не в красной метке «technical debt». Команда либо берёт случайную задачу с громким заголовком, либо переносит всё ещё на квартал, потому что у карточек нет проверяемой границы.'), + p('Maintenance review нужен не для красивой ретроспективы. Он нужен, когда один симптом возвращается, а изменение каждый раз обсуждают с нуля. Тогда долг — это не неприятность и не обвинение автора прежнего решения. Это повторяемый риск с владельцем, явной границей доказательств и одним следующим экспериментом. Если этих частей нет, пункт backlog нельзя честно сравнить с другим пунктом.'), + p('Дальше использую несколько рабочих терминов. Maintenance review — короткая проверка списка задач сопровождения; backlog item — одна такая задача; evidence boundary — запись о том, что подтверждено и что остаётся неизвестным; owner role — роль, принимающая следующий вопрос; experiment — ограниченная проверка, а не запуск изменения. Английские названия оставлены только для устойчивых терминов и кода.'), + h2('Симптом сначала, название долга потом'), + p('Начинать с названия вроде «улучшить поддержку» опасно: в нём нет наблюдаемого факта. Гораздо полезнее написать: «на каждом review приходится заново объяснять, где заканчивается диагностический шаг». Это уже симптом. Он не доказывает частоту в реальной системе и не сообщает стоимость в деньгах, зато даёт предмет проверки: есть ли в runbook одна граница, по которой другой инженер может остановиться.'), + p('После симптома указываем причину только на том уровне, который можно защитить. В учебной карточке причиной может быть отсутствующая диагностическая граница. В настоящем review причиной не становится «плохой код», пока нет артефакта: trace, теста, записи проверки или другого согласованного доказательства. Такая осторожность сохраняет время: обсуждение не превращается в поиск виноватого и не обещает точного диагноза раньше проверки.'), + table('Минимальная карточка maintenance item', ['Поле', 'Что в нём должно быть', 'Чего в нём нет'], [ + ['Повторяемый symptom', 'одна наблюдаемая ситуация: ручное объяснение, повторный вопрос, повторный stop', 'ярлыка «сложно поддерживать» без примера'], + ['Risk', 'какая граница может быть пройдена ошибочно и чем это опасно', 'прогноза инцидента или обещания потерь'], + ['Cost class', 'видимые усилия: interrupt, задержка review, повторная проверка', 'выдуманной экономии или счёта конкретной команды'], + ['Owner', 'роль, которая принимает следующий вопрос или эскалацию', 'имени человека без его согласия и полномочий'], + ['Evidence boundary', 'что подтверждено и чего не читали', 'слова «все пользователи», если scope не проверен'], + ['One next experiment', 'один артефакт и критерий его review', 'скрытого плана большого рефакторинга'], + ]), + h2('Маршрут review: symptom → причина → проверка → действие'), + ol([ + 'Зафиксируйте один симптом. Он должен помещаться в предложение и иметь границу: route, runbook step, contract или review question.', + 'Назовите риск. Не «система хрупкая», а «изменение может пересечь compatibility boundary без принимающего owner».', + 'Опишите цену классом. Достаточно «повторный interrupt» или «ещё один review pass». Деньги, часы и проценты пишут только при воспроизводимой методике и разрешённом источнике.', + 'Поставьте owner role. Владелец не обязан сразу исправить всё; он обязан принять следующий узкий вопрос или явно передать его.', + 'Отделите evidence от unknown. Список известного не становится полным только потому, что он красиво оформлен.', + 'Сформулируйте один experiment. Его результатом должен быть новый проверяемый артефакт, а не deploy, удаление или «разобраться».', + ]), + figure('/assets/editorial/2024/maintenance-retro-2024-review-loop.svg', 'Цикл maintenance review: наблюдаемый symptom переходит в ограниченную карточку, затем в один experiment, recheck и решение continue, stop или revise. Внешний контур помечен как human review; схема не выполняет change.', 'Цикл полезен тем, что останавливает рост scope. Повторяемость делает item видимым, но право на реальное изменение остаётся за отдельным human review.'), + h2('Карточка должна быть контрактом review, а не мини-отчётом'), + p('У карточки есть граница ответственности. Автор карточки фиксирует факт, который действительно видел или получил из разрешённого доказательства. Owner role отвечает за дальнейший вопрос. Reviewer проверяет, что experiment не выдаёт учебную модель за production verdict. Никто из них не получает право назвать feature, consumer или incident существующим только потому, что такой пример хорошо объясняет метод.'), + p('Это особенно важно в годовом обзоре. Память легко склеивает несколько похожих эпизодов в «весь год было тяжело». Для планирования полезнее три отдельные строки с разными границами: повторяемый runbook gap, risk без owner и cleanup без recheck. Они могут иметь похожий приоритет, но требуют разных действий. Общий список теряет это различие.'), + table('Три учебных backlog item и разные следующие шаги', ['Synthetic item', 'Наблюдаемый symptom', 'Граница риска', 'Один experiment'], [ + ['repeated-runbook-gap', 'объяснение одного diagnostic step повторяется', 'review не может назвать stop condition', 'написать одну boundary и проверить её на fixed path'], + ['ownerless-compatibility-risk', 'риск записан, а принимающая роль пуста', 'contract change может остаться без решения по residual risk', 'указать role и один acceptance question'], + ['recheck-before-removal', 'cleanup proposal опирается на старые labels', 'removal обсуждают до recheck compatibility boundary', 'добавить recheck gate и restore question'], + ]), + h2('Компактный воспроизводимый пример'), + p('Ниже не находится настоящий долг и не делает запрос к системе. Он берёт один versioned fixed card из памяти, рассчитывает только учебную priority boundary и создаёт review draft. Это полезно как проверка формы: extra key, sparse array или cyclic object должны остановить draft раньше, чем кто-то прочитает красивый summary как разрешение на действие.'), + code([ + "import {", + " createFixedSyntheticMaintenanceInput,", + " inspectSyntheticMaintenance,", + " planSyntheticMaintenanceReview,", + "} from './upgrade-2024-12.mjs';", + '', + "const input = createFixedSyntheticMaintenanceInput('repeated-runbook-gap');", + 'const report = inspectSyntheticMaintenance(input);', + 'const draft = planSyntheticMaintenanceReview(report);', + '', + 'console.log(report.risk.priorityBoundary); // review-now', + 'console.log(draft.actions.length); // 3', + 'console.log(draft.productionEffect); // not-attempted', + ].join('\n')), + p('Этот example намеренно слабее реального процесса. В нём нет files, Git, CI, network, telemetry, incident archive, clock или production state. Значит, результат review-now не говорит «срочно менять систему». Он говорит только, что заданный набор fixed literals проходит выбранную учебную формулу и может быть оформлен в review card.'), + h2('Evidence boundary защищает от ложной полноты'), + p('Граница доказательств — не юридическая приписка в конце. Она влияет на действие. Если card содержит только documentation gap, нельзя из неё вывести число затронутых клиентов. Если известен один contract, нельзя объявить, что других нет. Если owner role назван, это не означает, что человек уже принял риск. Эти различия мешают review превратиться в формат «мы почти уверены».'), + p('Практичный способ — разделить evidence на known, leading, lagging и unknown. Known отвечает, какой артефакт уже есть. Leading signal показывает, что риск растёт до повторения: например, proposal не может назвать check. Lagging signal фиксирует то, что уже вернулось: такой же вопрос снова попал на review. Unknown остаётся отдельным полем; его не переводят в ноль ради удобства таблицы.'), + h2('Один experiment лучше программы исправлений'), + p('Следующий experiment должен менять знание, а не просто создавать активность. Для runbook gap это одна диагностическая граница и короткая репетиция. Для ownership gap — одна роль и вопрос, который она может принять или отклонить. Для cleanup — recheck gate, не сам removal. У каждого варианта есть stop condition: как только draft расширяется до redesign, удаления или обещания данных, которых нет, его останавливают.'), + p('Такой stop не означает провал. Он показывает, что первоначальный item был уже, чем предполагаемое решение. Сохраняем текущую карточку, возвращаемся к последней документированной границе и открываем отдельный review для большего scope. Google описывает postmortem как документирование причин и follow-up, а не как автоматическое выполнение любого action item; это полезная рамка и для maintenance review.'), + h2('Что не стоит класть в годовой список'), + p('Не стоит склеивать в один item повторный вопрос, миграцию и будущую архитектуру. Не стоит писать «сэкономим N» без метода, диапазона и разрешённых данных. Не стоит заменять owner именем, если непонятно его полномочие. И не стоит называть числовой score вероятностью. NIST SP 800-30 описывает оценку риска как помощь в выборе курса действий; учебный score может лишь сделать обсуждение последовательным, но не снимает неопределённость.'), + p('Если item не проходит этот фильтр, его не обязательно удалять. Можно оставить его как hypothesis с явным unknown и назначить меньшее исследование. Но тогда он не конкурирует с хорошо описанной maintenance work на тех же условиях. Честный backlog допускает незнание, а не прячет его за приоритетом.'), + h2('Ограничения и следующий проверяемый шаг'), + p('Все labels, roles, costs, timelines, evidence, scores и outcomes в этой статье — fixed synthetic teaching values внутри одного JS module. Они не описывают реальную команду, систему, incident, customer, деньги или историю года. Источники объясняют подход к preventive maintenance, risk assessment, postmortem и toil, но не доказывают корректность конкретной карточки читателя.'), + p('Следующий шаг: возьмите один пункт своего backlog и перепишите только шесть полей из первой таблицы. Затем попросите reviewer назвать one next experiment и stop condition, не открывая новый scope. Ожидаемый результат: item либо становится коротким проверяемым review card, либо честно возвращается в hypothesis с неизвестной границей.'), + h2('Историческая граница декабря 2024'), + p('Использованы официальные главы первого издания Google SRE Book (2016) и финальные публикации NIST SP 800-30 Rev. 1 от сентября 2012 года и SP 800-40 Rev. 4 от апреля 2022 года. На декабрь 2024 эти материалы уже существовали. Они не дают универсального backlog template и не заменяют локальный change process. Учебная модель намеренно не читает внешний state.'), +]); + +const mechanism = revision({ + slug: 'editorial-2024-12-mechanism-maintenance-retro', + title: 'Как связывать риск, стоимость и повторяемость без фальшивой точности', + categories: ['Надёжность', 'Архитектура'], + cover: '/assets/editorial/2024/maintenance-retro-2024-debt-risk-heatmap.svg', + excerpt: 'Evidence types, leading и lagging signals, uncertainty и priority boundary: как использовать компактную synthetic heatmap для порядка review, не выдавая score за вероятность, цену или готовое решение.', + readingMinutes: 14, +}, [ + p('Maintenance backlog становится недостоверным в тот момент, когда ему приписывают точность, которой в нём нет. Число 8,7 выглядит как ответ на вопрос о приоритете, но не говорит, откуда взялись значения, что осталось неизвестным и кто принимает остаточный риск. Цена ошибки двойная: тихий повторяемый symptom может проиграть эффектному числу, а спорная задача получает вид «рассчитанной», хотя никто не проверял её границу.'), + p('Нужна не идеальная формула, а явная priority boundary. Она отвечает на более скромный вопрос: какой maintenance item нужно вынести в review сейчас, какой оставить в план следующего квартала, а какой только перепроверить. Для этого сначала отделяем evidence от оценки, а стоимость — от выдуманной денежной экономии. Потом используем score как фильтр разговора, не как доказательство будущего.'), + p('Здесь evidence — данные, которые можно показать reviewer; score — учебный балл приоритета; priority boundary — правило, по которому карточка попадает в ближайшую проверку, план или повторную проверку. Leading и lagging signal означают соответственно ранний признак и уже отмеченное повторение. Эти слова не заменяют причинный анализ и не делают формулу прогнозом.'), + h2('Четыре типа evidence нельзя складывать в один счётчик'), + p('Evidence отвечает не только на вопрос «есть ли факт». Важно, когда он полезен и что он ограничивает. Leading signal показывает, что review может остановиться до повторения: отсутствует owner, proposal не называет check, scope уже растёт. Lagging signal показывает уже случившееся повторение: тот же вопрос вернулся на следующий review point. Оба полезны, но lagging не доказывает причину, а leading не доказывает, что проблема неизбежна.'), + table('Evidence types для maintenance review', ['Тип', 'Что можно сказать', 'Что нельзя заключать', 'Вопрос reviewer'], [ + ['Known artifact', 'есть конкретный runbook, contract, test или зафиксированная карточка', 'что artifact покрывает всех consumers или все paths', 'какая именно граница описана?'], + ['Leading signal', 'до изменения виден признак роста риска или scope', 'что failure уже произошёл', 'какой stop condition сработает раньше?'], + ['Lagging signal', 'повторение уже отмечено после review point', 'что известна первопричина или будущая частота', 'что в предыдущем action не изменило знание?'], + ['Unknown', 'данных недостаточно либо method не authorized', 'что риск равен нулю или score можно уточнить догадкой', 'какой отдельный method мог бы изменить статус?'], + ]), + p('Такое разделение соответствует практической интонации NIST SP 800-30: risk assessment даёт лицам, принимающим решения, информацию о course of action, а не заменяет решение. В maintenance review artefact должен сохранять контекст и uncertainty. Если неизвестное исчезает при переносе строки в таблицу, следующая формула уже работает с ложными исходными данными.'), + h2('Повторяемость и цена: только наблюдаемый класс'), + p('Повторяемость — это не «кажется, мы часто об этом говорим». Нужен заранее выбранный repeat boundary: тот же diagnostic step, та же compatibility question или тот же recheck gate. Если boundary меняется между неделями, нельзя складывать события. Поэтому статья предлагает только labels: repeated interrupt, delayed review, extra recheck. Они показывают вид издержки, но не приписывают системе часы, бюджет или экономию.'), + p('Такое ограничение не обедняет решение. Для первого review достаточно увидеть, что one manual explanation возвращается и мешает одинаковому check. Чтобы вычислять деньги, нужны scope, период, метод, права на данные и проверяемый источник. Пока их нет, денежная колонка создаёт ложное сравнение: точное число у одной задачи побеждает честное неизвестное у другой.'), + h2('Synthetic heatmap: порядок, а не прогноз'), + figure('/assets/editorial/2024/maintenance-retro-2024-debt-risk-heatmap.svg', 'Heatmap из девяти клеток: по вертикали impact, по горизонтали repeatability. Три fixed synthetic cases помещены в клетки 3x3, 3x2 и 2x2; рядом показана отдельная поправка uncertainty. Подпись отмечает, что результат — порядок review, а не вероятность или бюджет.', 'Heatmap разделяет две оси, которые часто смешивают: ущерб границы и повторяемость symptom. Отдельно от клетки указаны uncertainty и strength evidence, чтобы цвет не скрывал пробелы в данных.'), + p('В учебной модели impact и repeatability дают клетку heatmap. Затем добавляется небольшая поправка на uncertainty и вычитается strength evidence. Формула impact * repeatability + uncertainty * 2 - evidenceStrength выбрана не потому, что открывает математическую истину. Она намеренно короткая, чтобы reviewer мог спорить не с «алгоритмом», а с каждым входом и с границей, где score меняет действие.'), + table('Priority boundary в fixed synthetic model', ['Score band', 'Смысл', 'Разрешённое действие', 'Запрещённый вывод'], [ + ['10 и выше', 'review-now', 'оформить один bounded experiment и отправить в human review', 'немедленно делать deploy, rewrite или removal'], + ['6–9', 'plan-next-quarter', 'оставить видимый plan с recheck condition', 'объявить риск принятым навсегда'], + ['ниже 6', 'watch-and-recheck', 'сохранить card и проверить boundary перед расширением scope', 'удалить item как несущественный'], + ]), + h2('Компактный воспроизводимый прогон'), + p('Скрипт ниже не читает real metric. Он запускает одну fixed in-memory card и показывает, как различаются heatmap cell и priority boundary. Это воспроизводимо именно потому, что все literals versioned: другой reader получит тот же report, но не должен переносить число в свой production dashboard.'), + code([ + "import {", + " createFixedSyntheticMaintenanceInput,", + " inspectSyntheticMaintenance,", + "} from './upgrade-2024-12.mjs';", + '', + "const report = inspectSyntheticMaintenance(", + " createFixedSyntheticMaintenanceInput('ownerless-compatibility-risk'),", + ');', + '', + 'console.log(report.risk.heatmapCell); // 3x2', + 'console.log(report.risk.priorityScore); // 11', + 'console.log(report.risk.priorityBoundary); // review-now', + 'console.log(report.evidence.unknown); // fixed limitation text', + ].join('\n')), + p('Второй fixed case получает высокий score не потому, что synthetic contract уже сломан. Он получает его потому, что impact, uncertainty и слабое evidence в заранее заданной карточке пересекают выбранную boundary. Это повод сформулировать owner question. Если добавить к report реальный traffic, incident или money field, fixture отклонит object: модель не умеет его читать и не должна превращать такой field в решение.'), + h2('Неопределённость — отдельная ось решения'), + p('Частая ошибка — думать, что uncertainty всегда понижает приоритет. Иногда она действительно требует остановиться: нельзя выносить remove proposal на основании неизвестного dependency graph. Иногда она требует раньше провести review: нет owner, а compatibility boundary уже затронута. Поэтому в модели uncertainty не маскируется «средним баллом». Она явно прибавляет осторожность, а final decision всё равно ограничен одним experiment и human review.'), + p('Это похоже на preventive maintenance в описании NIST SP 800-40: идентифицировать, приоритизировать, внедрить и проверить — разные шаги. В нашем узком material модель доходит только до первого и второго: фиксирует item и order review. Она ничего не внедряет и не проверяет живую систему. Даже хорошая heatmap не может заменить approval, tests, change window или rollback plan.'), + h2('Leading и lagging signal ведут в разные действия'), + p('Когда leading signal говорит «proposal не называет check», полезное действие — сократить proposal до одного testable artefact. Когда lagging signal говорит «тот же вопрос вернулся», полезно открыть previous card и проверить, что именно не было изменено: owner, boundary, evidence или stop condition. Ошибка — одинаково лечить оба сигнала общим призывом «автоматизировать». Automation может быть дальнейшим решением, но прежде нужно понять, какой повторяемый ручной шаг она заменяет.'), + p('Google в главе об eliminating toil описывает работу, которая повторяется и не создаёт устойчивой ценности. Это не формула для всех maintenance cases: некоторый manual review нужен по смыслу и не является долгом. Но глава даёт полезный вопрос: уменьшается ли повторяемая нагрузка после experiment, или команда лишь перенесла её из одного канала в другой? Ответ должен опираться на наблюдаемый review artefact, а не на настроение по итогам года.'), + h2('Проверка модели должна быть строже красивого результата'), + p('Fixture проверяет exact input keys, dense arrays, unknown keys, canonical report и canonical draft. Отдельно есть cases для forged score, cyclic JSON и sparse nested array. Причина не в том, что эти дефекты обязательно появятся в review document. Причина в контракте: если учебный report принимает незнакомое поле, он постепенно начинает изображать систему, которой не видел. Строгая форма оставляет человеку право добавить внешний факт только в отдельном authorized process.'), + p('Canonical comparison также полезен для обычной редактуры. Порядок ключей не должен менять смысл. Но добавленный actualIncident, переписанный action или missing evidence — это уже другой документ. Такой report нужно рассматривать заново, а не пропускать из-за того, что верхняя строка и score выглядят знакомо.'), + h2('Как проводить review без арифметического театра'), + ol([ + 'Покажите raw card. До score reviewer читает symptom, risk, cost class, owner role и evidence boundary.', + 'Назовите signal type. Leading, lagging, known и unknown нельзя сворачивать в один флажок.', + 'Проверьте входы формулы. Каждое число является teaching label; если оно спорно, спорим о label, а не о десятых долях.', + 'Смотрите на boundary. Переход из watch в plan или review-now меняет только следующий review artefact.', + 'Оставьте stop rule. Большой scope, ложная полнота или попытка выполнить change останавливают draft.', + ]), + h2('Ограничения и следующий проверяемый шаг'), + p('Heatmap, score, evidence labels, owner roles и все три учебных case — fixed synthetic objects. В них нет реальных p95, SLO, support queues, users, расходов, production changes или результатов команды. Официальные источники помогают различить preventive maintenance, risk assessment, postmortem follow-up и toil; они не подтверждают выбранные коэффициенты и не обещают, что score предскажет incident.'), + p('Следующий шаг: возьмите один реальный item только как предмет внутреннего review и пока не ставьте число. Сначала заполните four evidence types и назовите priority boundary словами. Если после этого всё ещё нужен score, зафиксируйте формулу, owner и stop condition отдельно. Ожидаемый результат: таблица покажет, что именно неизвестно, прежде чем число начнёт управлять очередью.'), + h2('Историческая граница декабря 2024'), + p('Эта статья использует официальные главы первого издания Google SRE Book (2016) и финальные NIST SP 800-30 Rev. 1 (сентябрь 2012) и SP 800-40 Rev. 4 (апрель 2022). Они существовали до декабря 2024 и доступны по официальным страницам. Ни один из них не утверждает, что эта synthetic formula является стандартом, вероятностью или финансовым калькулятором.'), +]); + +const field = revision({ + slug: 'editorial-2024-12-field-maintenance-retro', + title: 'Год сопровождения: что остановить, что продолжить, что перепроверить', + categories: ['Надёжность', 'Практика'], + cover: '/assets/editorial/2024/maintenance-retro-2024-change-timeline.svg', + excerpt: 'Три fixed synthetic maintenance case: повторяемый runbook gap, риск без owner и cleanup с recheck gate. Как построить timeline, остановить ложный rollback и подготовить следующий квартал без выдуманного ретро.', + readingMinutes: 14, +}, [ + p('Год сопровождения легко превратить в историю с аккуратным финалом: «мы нашли долг, исправили процесс и стали устойчивее». Такая фраза опасна, если под ней нет timeline, evidence boundary и отдельного решения, что именно остановили. Цена ошибки — следующий квартал начинает с ложной уверенности: cleanup уже считают безопасным, ownership считают назначенным, а повторяемый symptom считают закрытым, хотя была только договорённость на встрече.'), + p('Поэтому ниже нет ретроспективы неизвестной команды и нет выдуманных incidents, savings или метрик. Вместо этого три fixed synthetic cases. Они нужны как полевая тренировка decision flow: один case требует продолжить маленький runbook experiment, второй — остановиться на owner boundary, третий — перепроверить cleanup до разговора об удалении. Все даты в timeline — T0–T3, а не история реальной системы.'), + p('В этой статье timeline — последовательность точек T0–T3, а не календарь команды; case — учебная карточка; recheck — повторная проверка границы; stop — прекращение черновика до отдельного решения. Fixed synthetic означает, что примеры записаны в коде и не описывают реальный сервис. Термины нужны только для различения состояний одной проверки.'), + h2('Сначала соберите timeline изменения, а не список впечатлений'), + p('Timeline отвечает на четыре простых вопроса: что было замечено, какое ограниченное изменение знания предложено, где его нужно перепроверить и что происходит, если scope растёт. Без этих точек «в прошлом квартале решили» становится доказательством само по себе. С timeline видно, что decision был только draft, а не выполненный deploy; recheck был условием, а не подтверждённым результатом.'), + figure('/assets/editorial/2024/maintenance-retro-2024-change-timeline.svg', 'Горизонтальный timeline T0–T3: capture symptom, draft one experiment, recheck evidence boundary, decide stop, continue or revise for the next quarter. Под всеми точками стоит запрет превращать synthetic review в production change.', 'Timeline показывает порядок знаний, а не хронологию реальных событий. Каждая точка имеет отдельный смысл: наблюдение, draft, recheck и решение.'), + table('Три fixed synthetic case для годового review', ['Case', 'Что зафиксировано', 'Что не известно', 'Вердикт следующего шага'], [ + ['repeated-runbook-gap', 'повторяемый manual explanation и отсутствующая diagnostic boundary', 'реальная частота, пользовательский эффект и усилие реализации', 'continue: один bounded runbook experiment'], + ['ownerless-compatibility-risk', 'risk у contract boundary и пустая принимающая роль', 'реальные consumers, traffic и право на принятие риска', 'stop scope growth: сначала owner question'], + ['recheck-before-removal', 'cleanup-shaped proposal и сохранённый restore question', 'dependency graph, data compatibility и успешность rollback', 'recheck before human removal review'], + ]), + h2('Case 1. Продолжить: repeated runbook gap'), + p('Первый case не говорит, что runbook в реальном сервисе плохой. В fixed card есть только observation: один diagnostic step приходится снова объяснять на трёх synthetic review points. Риск узкий — reviewer может не знать, где прекращать проверку. Цена выражена классом: repeated interrupt и slower review. Этого достаточно, чтобы продолжить one experiment, но недостаточно для решения о переписывании support process.'), + p('Действие также узкое: записать один diagnostic boundary и прогнать его по fixed failing path. Ожидаемый evidence — другой reviewer может назвать symptom, boundary, check и stop condition без обращения к реальной системе. Если note разрастается до redesign или начинает утверждать, что он уменьшит actual support load, срабатывает stop condition. Draft возвращается к текущей карточке, а новый scope оформляется отдельно.'), + h2('Case 2. Остановить: риск есть, owner boundary нет'), + p('Во втором case важен не score, а отсутствие роли, которая принимает residual risk. У карточки есть compatibility boundary и фиксированное описание опасности. Но не существует реального списка consumers и нет права угадать их отсутствие. Если команда на этой точке пишет «продолжаем миграцию», она заменяет решение по риску намерением.'), + p('Правильное действие — остановить расширение scope и задать один question: какая role может accept или reject statement о residual risk для одного contract boundary? Это не перекладывание работы на формального owner. Это способ отделить исследование от решения. Пока role не названа или не может принять вопрос, timeline не должен переходить в removal, rollout или обещание совместимости.'), + h2('Case 3. Перепроверить: cleanup ещё не rollback plan'), + p('Третий case выглядит спокойнее: fixed score попадает в watch-and-recheck, есть action, похожий на cleanup, и сохранена restore question. Именно здесь легко назвать карточку «низким риском» и пропустить recheck. Но синтетическая карта прямо говорит другое: evidence labels старые, а real dependency graph, data compatibility и rollback success неизвестны.'), + p('Поэтому результатом является recheck gate. Он спрашивает: какая boundary будет повторно проверена, какой факт остановит proposal и куда вернуться до реального change? Ответ «у нас есть rollback» не проходит, пока неизвестно, кто восстановит route, какие data/schema conditions сохранятся и как проверить client recovery. В этой статье stop operation только отбрасывает draft; она не откатывает deployment и не читает систему.'), + h2('Компактный учебный прогон stop path'), + p('Следующий пример показывает именно эту разницу. В нём draft создаётся из canonical fixed report, затем безопасно останавливается. Это не test production rollback. Его задача — проверить форму review: change proposal не должен получить скрытое право на deploy, delete или network request.'), + code([ + "import {", + " createFixedSyntheticMaintenanceInput,", + " inspectSyntheticMaintenance,", + " planSyntheticMaintenanceReview,", + " stopSyntheticMaintenanceReview,", + "} from './upgrade-2024-12.mjs';", + '', + "const report = inspectSyntheticMaintenance(", + " createFixedSyntheticMaintenanceInput('recheck-before-removal'),", + ');', + 'const draft = planSyntheticMaintenanceReview(report);', + 'const stopped = stopSyntheticMaintenanceReview(draft);', + '', + 'console.log(draft.decision.code); // keep-visible-and-recheck-before-expansion', + 'console.log(stopped.stopped); // true', + 'console.log(stopped.realChange); // not-attempted', + ].join('\n')), + p('Fixture покрывает отрицательные ветки: extra keys, missing report field, forged score, sparse nested array, cyclic report, sparse actions и cyclic draft. Все такие objects должны быть rejected without throw. Это редакторская защита от очень знакомого сбоя: summary выглядит тем же, но внутри уже появился факт или action, которые модель не умеет обосновать.'), + h2('Reassessment loop: не все продолжения одинаковы'), + p('После T2 задача не получает автоматически статус done. Есть три допустимых исхода. Continue означает оставить одну bounded experiment до следующего review. Revise означает изменить card, потому что evidence boundary или owner question стали точнее. Stop означает отбросить draft до отдельного решения, потому что scope вырос или need for external evidence стал очевиден. None of these is a production outcome. Это только порядок работы с knowledge.'), + table('Decision после recheck', ['Состояние', 'Что сохраняем', 'Что останавливаем', 'План следующего квартала'], [ + ['Continue', 'one experiment, owner question и current boundary', 'рост scope за пределы card', 'проверить ожидаемый artefact на следующем review point'], + ['Revise', 'исходный symptom и known evidence', 'старую формулировку риска или action', 'переписать card, затем снова пройти human review'], + ['Stop', 'last documented boundary и explicit unknown', 'draft, который обещает change или полноту данных', 'открыть отдельное authorized investigation или сохранить compatibility'], + ]), + h2('Что взять из года, а что не переносить'), + p('Продолжать стоит только то, что оставляет воспроизводимый artefact: короткий runbook boundary, owner question, recheck gate, stop condition. Останавливать стоит ложную точность: всеобщую формулировку «долг закрыт», округлённый score без inputs, обещание rollback без restore boundary. Перепроверять стоит место, где evidence стареет или scope меняется: contract, config, data migration, dependency или decision owner.'), + p('Здесь полезна рамка preventive maintenance из NIST SP 800-40: идентификация, приоритизация, внедрение и verification не должны склеиваться в один глагол «исправить». Наша timeline признаёт только первые два и preparation for recheck. Реальные внедрение и verification требуют иного контекста: права, тесты, change controls, зависимости и измерения. Нельзя добавлять их задним числом в retrospective paragraph.'), + h2('План следующего квартала должен быть уже прошлогоднего списка'), + p('Хороший next-quarter plan короче исходного maintenance list. В него входят one card per boundary, owner role, next experiment, recheck condition и stop condition. Если в плане есть «улучшить платформу», «устранить риски» или «сделать cleanup», он ещё не готов. План должен позволять на следующем review увидеть разницу: появилась ли boundary, принят ли question, описан ли recheck.'), + table('Шаблон plan на следующий квартал', ['Поле', 'Короткая форма', 'Критерий готовности'], [ + ['Card', 'один symptom и один boundary', 'reader не додумывает scope из заголовка'], + ['Owner role', 'role для одного decision question', 'role может принять, отклонить или передать вопрос'], + ['Experiment', 'один review artefact', 'результат не требует live deployment'], + ['Recheck', 'one observable condition', 'понятно, когда card вернётся на review'], + ['Stop', 'one fact that blocks scope growth', 'draft можно отбросить без ложного rollback claim'], + ]), + h2('Короткий порядок закрытия года'), + ol([ + 'Сузьте scope. Выберите одну карточку и не объединяйте её с архитектурной программой или миграцией.', + 'Восстановите T0–T3. На каждой точке оставьте только known artefact и явно назовите unknown.', + 'Сверьте stop rule. Если draft обещает deployment, deletion или полноту данных, остановите его до следующей встречи.', + 'Выберите один исход. Continue, revise или stop должны менять только план review, а не состояние живой системы.', + 'Запишите следующий recheck. Он должен проверять конкретную boundary, owner question или evidence label в следующем квартале.', + ]), + h2('Почему maintenance retro не заменяет postmortem'), + p('Google описывает postmortem как запись события, его воздействия, причин, ответа и follow-up. Maintenance retro похож тем, что фиксирует обучение и следующий шаг, но не должен притворяться incident analysis. Если не было подтверждённого incident, нельзя писать последствия. Если нет timeline с evidence, нельзя придумывать recovery. Мы берём структуру ответственности и follow-up, а не чужую историю и цифры.'), + p('То же относится к toil. Повторяемая ручная работа может быть кандидатом на устранение источника, но не всякая ручная проверка является waste. Некоторые ручные проверки нужны именно потому, что boundary зависит от риска и полномочий. Поэтому fixed model предлагает не «автоматизировать всё», а сначала установить, какая часть действия повторяется без создания нового знания.'), + h2('Ограничения и следующий проверяемый шаг'), + p('Три cases, T0–T3, labels, roles, scores, actions, stop rules и expected outcomes существуют только как versioned fixed in-memory objects. Они не используют исходный код, историю команды, deployment, production telemetry, tickets, logs, customer data, incident archive, Git, files, CI или network. Источники не подтверждают, что какая-либо конкретная система имеет такие же риски или что любой rollback сработает.'), + p('Следующий шаг: выберите одну завершённую или остановленную maintenance task и восстановите только четыре точки T0–T3 из памяти и разрешённых artefacts. В каждой точке разделите known от unknown. Если action обещает real change, добавьте stop condition и вынесите его в отдельный authorized plan. Ожидаемый результат: следующий квартал получает не праздничный список, а одну короткую проверяемую границу для каждого выбранного item.'), + h2('Историческая граница декабря 2024'), + p('В материале использованы официальные главы первого издания Google SRE Book (2016), NIST SP 800-30 Rev. 1 от сентября 2012 года и NIST SP 800-40 Rev. 4 от апреля 2022 года. Все источники появились до декабря 2024. Они поддерживают дисциплину learning, risk-informed action и preventive maintenance, но не доказывают synthetic timeline и не дают разрешения на реальное изменение.'), +]); + +export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); + +function verifyFixture() { + const report = runMaintenanceRetroFixture(); + const failed = Object.entries(report.assertions) + .filter(([, value]) => value !== true) + .map(([key]) => key); + + if (failed.length > 0) { + process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); + process.exitCode = 1; + return; + } + + const count = Object.keys(report.assertions).length; + process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n'); +} + +if (process.argv.includes('--verify-fixture')) verifyFixture(); +if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');