541 lines
72 KiB
JavaScript
541 lines
72 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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>|$)/, ''));
|
||
}
|
||
|
||
const sources = {
|
||
googleCulture: {
|
||
title: 'Google SRE Book: Postmortem Culture: Learning from Failure, 2017',
|
||
url: 'https://sre.google/sre-book/postmortem-culture/',
|
||
note: 'официальная публикация Google, доступная до ноября 2023. Она описывает опыт и практику Google: документирование инцидента, разбор contributing causes, review и action items. Это не универсальное доказательство эффекта для другой команды.',
|
||
},
|
||
googleExample: {
|
||
title: 'Google SRE Book: Example Postmortem, 2017',
|
||
url: 'https://sre.google/sre-book/example-postmortem/',
|
||
note: 'официальный учебный пример с timeline, impact, action items и lessons learned. Он показывает форму артефакта, но не является данными этого fixture или причиной какого-либо чужого сбоя.',
|
||
},
|
||
nist: {
|
||
title: 'NIST SP 800-61 Revision 2: Computer Security Incident Handling Guide, август 2012',
|
||
url: 'https://doi.org/10.6028/NIST.SP.800-61r2',
|
||
note: 'официальное руководство по обработке компьютерных security-инцидентов, включая phase post-incident lessons learned. Его область — incident response в security, а не готовый шаблон для любого продуктового или эксплуатационного разбора.',
|
||
},
|
||
cisa: {
|
||
title: 'CISA: Federal Government Cybersecurity Incident and Vulnerability Response Playbooks, ноябрь 2021',
|
||
url: 'https://www.cisa.gov/sites/default/files/publications/Federal_Government_Cybersecurity_Incident_and_Vulnerability_Response_Playbooks_508C_1.pdf',
|
||
note: 'официальный федеральный playbook США, опубликованный до ноября 2023. Он относится к FCEB cybersecurity response; статья не переносит его обязательства, сроки или полномочия в иной контур.',
|
||
},
|
||
};
|
||
|
||
function sourceList(items) {
|
||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function revision(meta, parts, sourceItems) {
|
||
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(sourceItems);
|
||
const proseLength = bodyText(contentHtml).length;
|
||
if (proseLength < 8000 || proseLength > 10500) {
|
||
throw new Error(meta.slug + ': основной текст должен занимать 8 000–10 500 знаков, сейчас ' + proseLength);
|
||
}
|
||
return Object.freeze({ ...meta, contentHtml, proseLength });
|
||
}
|
||
|
||
const SYNTHETIC_KIND = 'synthetic-postmortem-records-v1';
|
||
const SYNTHETIC_INCIDENT_ID = 'synthetic-incident-2023-11-A';
|
||
const MODEL_LIMIT = 'fixed-synthetic-records-in-memory; no-real-incident-data-logs-traces-alerts-users-ci-network-or-production-runtime';
|
||
|
||
/**
|
||
* Единственный вход fixture. Все пять record objects заданы в коде и существуют
|
||
* только в памяти процесса. Они не читают incident data, logs, traces, alerts,
|
||
* users, CI, сеть, часы или production runtime; не создают и не отправляют
|
||
* никаких таких данных. Значения намеренно synthetic и не описывают реальный
|
||
* инцидент, виновника, причину либо эффект изменения.
|
||
*/
|
||
export const syntheticIncidentRecords = Object.freeze({
|
||
kind: SYNTHETIC_KIND,
|
||
synthetic: true,
|
||
incidentId: SYNTHETIC_INCIDENT_ID,
|
||
records: Object.freeze([
|
||
Object.freeze({
|
||
id: 'synthetic-fact-01',
|
||
recordType: 'fact',
|
||
occurredAt: '2023-11-17T10:00:00Z',
|
||
statement: 'synthetic scenario marked a delivery route as review-required',
|
||
evidenceRef: 'synthetic-evidence-card-01',
|
||
observation: 'synthetic-not-observed',
|
||
inference: 'not-a-cause-claim',
|
||
}),
|
||
Object.freeze({
|
||
id: 'synthetic-fact-02',
|
||
recordType: 'fact',
|
||
occurredAt: '2023-11-17T10:03:00Z',
|
||
statement: 'synthetic scenario recorded a version marker before review',
|
||
evidenceRef: 'synthetic-evidence-card-02',
|
||
observation: 'synthetic-not-observed',
|
||
inference: 'not-a-cause-claim',
|
||
}),
|
||
Object.freeze({
|
||
id: 'synthetic-fact-03',
|
||
recordType: 'fact',
|
||
occurredAt: '2023-11-17T10:06:00Z',
|
||
statement: 'synthetic scenario listed a reversible review checkpoint',
|
||
evidenceRef: 'synthetic-evidence-card-03',
|
||
observation: 'synthetic-not-observed',
|
||
inference: 'not-a-cause-claim',
|
||
}),
|
||
Object.freeze({
|
||
id: 'synthetic-decision-01',
|
||
recordType: 'decision-at-the-time',
|
||
occurredAt: '2023-11-17T10:08:00Z',
|
||
action: 'pause-synthetic-change-review',
|
||
availableFactIds: Object.freeze(['synthetic-fact-01', 'synthetic-fact-02', 'synthetic-fact-03']),
|
||
rationale: 'synthetic-uncertainty-bounded-before-next-step',
|
||
evaluation: 'not-judged',
|
||
}),
|
||
Object.freeze({
|
||
id: 'synthetic-experiment-01',
|
||
recordType: 'preventive-experiment',
|
||
occurredAt: '2023-11-17T10:15:00Z',
|
||
hypothesis: 'synthetic-check-that-a-reversible-review-checkpoint-is-explicit',
|
||
method: 'review-fixed-synthetic-record-contract',
|
||
successCriterion: 'all-required-record-boundaries-present',
|
||
expectedEffect: 'not-evaluated',
|
||
rollback: 'restore-synthetic-record-snapshot',
|
||
}),
|
||
]),
|
||
});
|
||
|
||
function hasText(value) {
|
||
return typeof value === 'string' && value.trim().length > 0;
|
||
}
|
||
|
||
function isSyntheticTimestamp(value) {
|
||
return typeof value === 'string' && /^2023-11-17T10:(?:00|03|06|08|15):00Z$/.test(value);
|
||
}
|
||
|
||
function frozenSnapshot(records) {
|
||
return Object.freeze(records.map((record) => Object.freeze({ ...record, availableFactIds: record.availableFactIds ? Object.freeze([...record.availableFactIds]) : undefined })));
|
||
}
|
||
|
||
function rejectSyntheticPostmortem(reason) {
|
||
return Object.freeze({ accepted: false, reason, syntheticOnly: true, modelLimit: MODEL_LIMIT });
|
||
}
|
||
|
||
function hasExactExpectedKeys(record, keys) {
|
||
if (!record || typeof record !== 'object' || Array.isArray(record)) return false;
|
||
const prototype = Object.getPrototypeOf(record);
|
||
if (prototype !== Object.prototype && prototype !== null) return false;
|
||
return Object.keys(record).length === keys.length
|
||
&& keys.every((key) => Object.hasOwn(record, key));
|
||
}
|
||
|
||
function hasExactArrayItems(value, length) {
|
||
return Array.isArray(value)
|
||
&& value.length === length
|
||
&& Object.keys(value).length === length
|
||
&& Array.from({ length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
|
||
}
|
||
|
||
function isValidFact(record, index) {
|
||
const expected = [
|
||
['synthetic-fact-01', '2023-11-17T10:00:00Z', 'synthetic-evidence-card-01', 'synthetic scenario marked a delivery route as review-required'],
|
||
['synthetic-fact-02', '2023-11-17T10:03:00Z', 'synthetic-evidence-card-02', 'synthetic scenario recorded a version marker before review'],
|
||
['synthetic-fact-03', '2023-11-17T10:06:00Z', 'synthetic-evidence-card-03', 'synthetic scenario listed a reversible review checkpoint'],
|
||
][index];
|
||
return Boolean(expected)
|
||
&& hasExactExpectedKeys(record, ['id', 'recordType', 'occurredAt', 'statement', 'evidenceRef', 'observation', 'inference'])
|
||
&& record.id === expected[0]
|
||
&& record.recordType === 'fact'
|
||
&& record.occurredAt === expected[1]
|
||
&& isSyntheticTimestamp(record.occurredAt)
|
||
&& hasText(record.statement)
|
||
&& record.statement === expected[3]
|
||
&& record.evidenceRef === expected[2]
|
||
&& record.observation === 'synthetic-not-observed'
|
||
&& record.inference === 'not-a-cause-claim';
|
||
}
|
||
|
||
function isValidDecision(record, factIds) {
|
||
return hasExactExpectedKeys(record, ['id', 'recordType', 'occurredAt', 'action', 'availableFactIds', 'rationale', 'evaluation'])
|
||
&& record.id === 'synthetic-decision-01'
|
||
&& record.recordType === 'decision-at-the-time'
|
||
&& record.occurredAt === '2023-11-17T10:08:00Z'
|
||
&& record.action === 'pause-synthetic-change-review'
|
||
&& hasExactArrayItems(record.availableFactIds, factIds.length)
|
||
&& record.availableFactIds.every((id, index) => id === factIds[index])
|
||
&& record.rationale === 'synthetic-uncertainty-bounded-before-next-step'
|
||
&& record.evaluation === 'not-judged';
|
||
}
|
||
|
||
function isValidExperiment(record) {
|
||
return hasExactExpectedKeys(record, ['id', 'recordType', 'occurredAt', 'hypothesis', 'method', 'successCriterion', 'expectedEffect', 'rollback'])
|
||
&& record.id === 'synthetic-experiment-01'
|
||
&& record.recordType === 'preventive-experiment'
|
||
&& record.occurredAt === '2023-11-17T10:15:00Z'
|
||
&& record.hypothesis === 'synthetic-check-that-a-reversible-review-checkpoint-is-explicit'
|
||
&& record.method === 'review-fixed-synthetic-record-contract'
|
||
&& record.successCriterion === 'all-required-record-boundaries-present'
|
||
&& record.expectedEffect === 'not-evaluated'
|
||
&& record.rollback === 'restore-synthetic-record-snapshot';
|
||
}
|
||
|
||
/**
|
||
* Собирает учебную карточку разбора, только если в ней есть три fact records,
|
||
* решение с известными в тот момент фактами и один обратимый experiment record.
|
||
* Функция не выясняет причину, не выбирает виновника и не оценивает реальный
|
||
* effect. Она не делает I/O и не меняет переданный input.
|
||
*/
|
||
export function assembleSyntheticPostmortem(input) {
|
||
if (!hasExactExpectedKeys(input, ['kind', 'synthetic', 'incidentId', 'records'])) {
|
||
return rejectSyntheticPostmortem('synthetic-input-shape-required');
|
||
}
|
||
if (!input || input.synthetic !== true || input.kind !== SYNTHETIC_KIND) {
|
||
return rejectSyntheticPostmortem('synthetic-kind-required');
|
||
}
|
||
if (input.incidentId !== SYNTHETIC_INCIDENT_ID || !hasExactArrayItems(input.records, 5)) {
|
||
return rejectSyntheticPostmortem('fixed-synthetic-record-set-required');
|
||
}
|
||
if (input.records.some((record) => !record || Object.hasOwn(record, 'culprit') || Object.hasOwn(record, 'rootCause') || Object.hasOwn(record, 'realEffect'))) {
|
||
return rejectSyntheticPostmortem('culprit-cause-or-real-effect-claim-rejected');
|
||
}
|
||
|
||
const [firstFact, secondFact, thirdFact, decision, experiment] = input.records;
|
||
if (![firstFact, secondFact, thirdFact].every((record, index) => isValidFact(record, index))) {
|
||
return rejectSyntheticPostmortem('fixed-fact-contract-rejected');
|
||
}
|
||
const factIds = [firstFact.id, secondFact.id, thirdFact.id];
|
||
if (!isValidDecision(decision, factIds)) return rejectSyntheticPostmortem('decision-context-contract-rejected');
|
||
if (!isValidExperiment(experiment)) return rejectSyntheticPostmortem('preventive-experiment-contract-rejected');
|
||
if (!(firstFact.occurredAt < secondFact.occurredAt && secondFact.occurredAt < thirdFact.occurredAt && thirdFact.occurredAt < decision.occurredAt && decision.occurredAt < experiment.occurredAt)) {
|
||
return rejectSyntheticPostmortem('timeline-order-required');
|
||
}
|
||
|
||
const snapshot = frozenSnapshot(input.records);
|
||
return Object.freeze({
|
||
accepted: true,
|
||
kind: 'synthetic-postmortem-card-v1',
|
||
syntheticOnly: true,
|
||
modelLimit: MODEL_LIMIT,
|
||
incident: Object.freeze({ id: input.incidentId, status: 'synthetic-not-observed', realIncident: 'not-read-or-asserted' }),
|
||
facts: Object.freeze([firstFact, secondFact, thirdFact].map((record) => Object.freeze({
|
||
id: record.id,
|
||
occurredAt: record.occurredAt,
|
||
statement: record.statement,
|
||
evidenceRef: record.evidenceRef,
|
||
conclusion: 'not-a-cause-claim',
|
||
}))),
|
||
decision: Object.freeze({
|
||
id: decision.id,
|
||
occurredAt: decision.occurredAt,
|
||
action: decision.action,
|
||
availableFactIds: Object.freeze([...decision.availableFactIds]),
|
||
evaluation: decision.evaluation,
|
||
actor: 'intentionally-not-modeled',
|
||
}),
|
||
experiment: Object.freeze({
|
||
id: experiment.id,
|
||
hypothesis: experiment.hypothesis,
|
||
method: experiment.method,
|
||
successCriterion: experiment.successCriterion,
|
||
expectedEffect: experiment.expectedEffect,
|
||
conclusion: 'not-a-real-change-conclusion',
|
||
}),
|
||
evidence: Object.freeze({
|
||
permittedConclusion: 'record-boundaries-present-in-fixed-synthetic-model',
|
||
prohibitedConclusion: 'no-culprit-cause-or-real-change-effect-is-established',
|
||
observed: 'not-observed',
|
||
}),
|
||
rollback: Object.freeze({ action: experiment.rollback, snapshot }),
|
||
});
|
||
}
|
||
|
||
export function rollbackSyntheticPostmortem(scenario) {
|
||
if (!scenario || scenario.accepted !== true || !scenario.rollback?.snapshot) {
|
||
return Object.freeze({ restored: false, reason: 'no-accepted-synthetic-card', syntheticOnly: true });
|
||
}
|
||
return Object.freeze({
|
||
restored: true,
|
||
reason: 'synthetic-record-snapshot-restored',
|
||
syntheticOnly: true,
|
||
snapshot: scenario.rollback.snapshot,
|
||
externalIO: 'not-attempted',
|
||
productionEffect: 'not-attempted',
|
||
});
|
||
}
|
||
|
||
export function runPostmortemFixture() {
|
||
const valid = assembleSyntheticPostmortem(syntheticIncidentRecords);
|
||
const nonSynthetic = assembleSyntheticPostmortem({ ...syntheticIncidentRecords, synthetic: false });
|
||
const unmodeledTopLevel = assembleSyntheticPostmortem({ ...syntheticIncidentRecords, unmodeledField: 'synthetic-but-not-contractual' });
|
||
const missingEvidence = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-fact-02' ? { ...record, evidenceRef: '' } : record),
|
||
});
|
||
const decisionWithoutFact = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-decision-01' ? { ...record, availableFactIds: ['synthetic-fact-01'] } : record),
|
||
});
|
||
const prematureDecision = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-decision-01' ? { ...record, occurredAt: '2023-11-17T10:06:00Z' } : record),
|
||
});
|
||
const namedCulprit = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-decision-01' ? { ...record, culprit: 'synthetic-person' } : record),
|
||
});
|
||
const claimedCause = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-fact-03' ? { ...record, rootCause: 'synthetic-cause' } : record),
|
||
});
|
||
const unmodeledFactStatement = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-fact-01' ? { ...record, statement: 'unmodeled external incident detail' } : record),
|
||
});
|
||
const missingRollback = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-experiment-01' ? { ...record, rollback: '' } : record),
|
||
});
|
||
const claimedEffect = assembleSyntheticPostmortem({
|
||
...syntheticIncidentRecords,
|
||
records: syntheticIncidentRecords.records.map((record) => record.id === 'synthetic-experiment-01' ? { ...record, realEffect: 'synthetic-success' } : record),
|
||
});
|
||
const restored = rollbackSyntheticPostmortem(valid);
|
||
const rejectedRollback = rollbackSyntheticPostmortem(namedCulprit);
|
||
|
||
return Object.freeze({
|
||
assertions: Object.freeze({
|
||
acceptsFixedSyntheticRecordSet: valid.accepted === true && valid.kind === 'synthetic-postmortem-card-v1',
|
||
keepsThreeFactsSeparateFromDecision: valid.facts.length === 3 && valid.decision.availableFactIds.length === 3,
|
||
marksFactsAsNonCausal: valid.facts.every((record) => record.conclusion === 'not-a-cause-claim'),
|
||
preservesDecisionTimeContext: valid.decision.occurredAt === '2023-11-17T10:08:00Z' && valid.decision.evaluation === 'not-judged',
|
||
removesActorFromModel: valid.decision.actor === 'intentionally-not-modeled',
|
||
keepsExperimentAsHypothesis: valid.experiment.expectedEffect === 'not-evaluated' && valid.experiment.conclusion === 'not-a-real-change-conclusion',
|
||
declaresNoRealIncidentConclusion: valid.evidence.prohibitedConclusion.includes('no-culprit-cause-or-real-change-effect'),
|
||
declaresSyntheticObservationBoundary: valid.incident.status === 'synthetic-not-observed' && valid.evidence.observed === 'not-observed',
|
||
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-kind-required',
|
||
rejectsUnmodeledTopLevelField: unmodeledTopLevel.accepted === false && unmodeledTopLevel.reason === 'synthetic-input-shape-required',
|
||
rejectsMissingFactEvidence: missingEvidence.accepted === false && missingEvidence.reason === 'fixed-fact-contract-rejected',
|
||
rejectsDecisionWithPartialFacts: decisionWithoutFact.accepted === false && decisionWithoutFact.reason === 'decision-context-contract-rejected',
|
||
rejectsDecisionBeforeFactSet: prematureDecision.accepted === false && prematureDecision.reason === 'decision-context-contract-rejected',
|
||
rejectsNamedCulprit: namedCulprit.accepted === false && namedCulprit.reason === 'culprit-cause-or-real-effect-claim-rejected',
|
||
rejectsCauseClaim: claimedCause.accepted === false && claimedCause.reason === 'culprit-cause-or-real-effect-claim-rejected',
|
||
rejectsUnmodeledFactStatement: unmodeledFactStatement.accepted === false && unmodeledFactStatement.reason === 'fixed-fact-contract-rejected',
|
||
rejectsExperimentWithoutRollback: missingRollback.accepted === false && missingRollback.reason === 'preventive-experiment-contract-rejected',
|
||
rejectsRealEffectClaim: claimedEffect.accepted === false && claimedEffect.reason === 'culprit-cause-or-real-effect-claim-rejected',
|
||
rollbackRestoresOnlySyntheticSnapshot: restored.restored === true && restored.snapshot.length === 5 && restored.externalIO === 'not-attempted',
|
||
rollbackDoesNotClaimProductionEffect: restored.productionEffect === 'not-attempted' && !Object.hasOwn(restored, 'incident'),
|
||
rejectedCardCannotRollback: rejectedRollback.restored === false,
|
||
}),
|
||
samples: Object.freeze({ valid, nonSynthetic, unmodeledTopLevel, missingEvidence, decisionWithoutFact, prematureDecision, namedCulprit, claimedCause, unmodeledFactStatement, missingRollback, claimedEffect, restored, rejectedRollback }),
|
||
});
|
||
}
|
||
|
||
const syntheticExample = `import {
|
||
assembleSyntheticPostmortem,
|
||
runPostmortemFixture,
|
||
syntheticIncidentRecords,
|
||
} from './upgrade-2023-11.mjs';
|
||
|
||
const card = assembleSyntheticPostmortem(syntheticIncidentRecords);
|
||
if (!card.accepted) throw new Error(card.reason);
|
||
|
||
const report = runPostmortemFixture();
|
||
if (!Object.values(report.assertions).every(Boolean)) throw new Error('fixture failed');
|
||
|
||
console.log(card.evidence.permittedConclusion);
|
||
// record-boundaries-present-in-fixed-synthetic-model
|
||
|
||
// Только fixed synthetic records в памяти. Нет I/O, сети, CI,
|
||
// logs, traces, alerts, users или production runtime.`;
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2023-11-practice-postmortem',
|
||
title: 'Postmortem без поиска виноватого: собрать факты до объяснения',
|
||
categories: ['Надёжность', 'Командная работа'],
|
||
cover: '/assets/editorial/2023/postmortem-2023-fact-timeline.svg',
|
||
excerpt: 'Как начать ретроспективу с проверяемой timeline, не выдать решение в моменте за ошибку человека и не назвать профилактическую гипотезу уже доказанным исправлением.',
|
||
readingMinutes: 11,
|
||
}, [
|
||
p('После сбоя команда часто помнит одно: кто нажал кнопку и какой фрагмент лога тревожил. Встреча быстро превращается в поиск автора неверного действия. Цена не только в конфликте. Из документа исчезают условия, при которых решение казалось разумным, и следующий дежурный получает не проверяемый маршрут, а мораль из прошлого случая.'),
|
||
p('Практическая проблема не решается заменой слова «виноват» на «бескорыстно». Нужна другая форма записи. Сначала фиксируем факт с источником и временем. Затем отдельно записываем решение, доступные в тот момент факты и обратимость. После этого формулируем профилактический эксперимент с критерием проверки. Такое разложение не доказывает причину и не оправдывает любое действие; оно делает границу неизвестного видимой и оставляет материал для следующей проверки.'),
|
||
h2('Что считать фактом, а что — объяснением'),
|
||
p('Факт в ретроспективе — узкое утверждение, которое можно связать с артефактом: отметкой времени, снимком состояния, версией конфигурации, сообщением runbook или разрешённой записью наблюдения. «В 10:03 была зафиксирована версия» — факт, если рядом есть ссылка на его источник. «Версия сломала маршрут» — уже объяснение. Вторую фразу нельзя прятать в timeline: у неё могут быть альтернативы, а источника в одной строке обычно недостаточно.'),
|
||
table('Три записи, которые нельзя склеивать', ['Тип записи', 'Вопрос', 'Минимальные поля', 'Недопустимый вывод'], [
|
||
['Факт', 'что зафиксировано и где?', 'время, формулировка, evidence reference', 'кто виноват и почему это произошло'],
|
||
['Решение в моменте', 'какое действие выбрали при известных данных?', 'время, действие, доступные факты, оценка неизвестна', 'что решение было ошибкой без контекста'],
|
||
['Профилактический эксперимент', 'что хотим проверить до следующего изменения?', 'гипотеза, метод, критерий, rollback', 'что эффект уже получен'],
|
||
['Action item', 'кто и когда доведёт работу?', 'владелец роли, срок, ссылка на проверку', 'что задача устраняет все риски'],
|
||
]),
|
||
figure('/assets/editorial/2023/postmortem-2023-fact-timeline.svg', 'Учебная timeline: три synthetic факта идут раньше synthetic решения, за ним расположен обратимый профилактический эксперимент. Стрелки показывают порядок records, а не причину, вину или эффект настоящего инцидента.', 'Timeline отделяет наблюдаемые поля от решения в моменте и будущего эксперимента. Это учебная схема fixed synthetic records, не лог, alert, trace, incident report или выгрузка production-системы.'),
|
||
h2('Сначала сужаем карточку факта'),
|
||
p('Начните с одной строки: когда и какой артефакт был доступен. Не добавляйте в неё оценку человека, пересказ диалога и гипотезу о механизме. Если источник нельзя назвать из-за доступа или чувствительности, укажите тип источника и статус «не проверено», а не заполняйте пробел правдоподобным текстом. Важна не литературная полнота, а возможность через неделю отличить сохранённое свидетельство от воспоминания участника.'),
|
||
p('В учебной модели ниже есть три fixed synthetic fact records. У каждого заранее задан `occurredAt`, `statement` и `evidenceRef`, а поле `inference` жёстко равно `not-a-cause-claim`. Это не способ хранить настоящий инцидент и не пример корректного schema для вашей базы. Он нужен, чтобы на ревью заметить подмену: когда в поле факта появляется причина, фамилия, чужая деталь или обещание эффекта, модель отклоняет запись. Отрицательная ветка полезнее красивой timeline: она показывает, что именно нельзя честно вывести.'),
|
||
h2('Решение нужно читать во времени'),
|
||
p('Следующая запись отвечает не на вопрос «кто ошибся», а на вопрос «какой выбор был сделан с теми данными, которые уже были». Для решения полезны действие, timestamp и список fact IDs. Этот список не доказывает правильность действия. Он показывает границу информации: какие записи могли повлиять на выбор, а какие появились позже. После инцидента у нас больше контекста, поэтому задним числом легко назвать очевидным то, чего в моменте никто ещё не видел.'),
|
||
p('Если решение было необратимым, не смягчайте это словом «оперативное». Напишите, что именно нельзя быстро вернуть, кто владеет точкой возврата и какой сигнал подтверждает остановку. Если действие обратимо, всё равно нужен критерий: «вернуть конфигурацию до версии X» недостаточно без способа проверить, что новая ветка больше не применяется. Google в своём примере postmortem показывает timeline и action items; это хороший ориентир формы, но не разрешение приписывать своей системе чужой результат или срок.'),
|
||
h2('Профилактика — это эксперимент, а не приговор'),
|
||
p('После разбора хочется написать «добавить проверку, чтобы такого больше не было». Такая задача слишком широка. Она не называет, что проверяем, на каком входе, какой результат будет достаточным и как остановиться, если изменение ухудшит ситуацию. Вместо этого формулируйте эксперимент: гипотеза, минимальный метод, критерий успеха, владелец роли и обратимый rollback. До прогона это намерение проверить, а не доказательство, что инцидент больше не повторится.'),
|
||
p('Например, можно проверить только наличие reversible checkpoint в одном runbook. Успехом будет не «система стала надёжнее», а «перед действием перечислены точка возврата, владелец и наблюдаемый критерий остановки». Если checkpoint не помещается в карточку, он ещё не готов к автоматизации. Такое ограничение намеренно скромное: оно не измеряет availability, не читает настоящие alerts и не делает вывод о влиянии на пользователей.'),
|
||
h2('Прогоните маленький контракт до встречи'),
|
||
p('Код создаёт фиксированный synthetic набор в памяти и проверяет двадцать одну assertion. Он принимает только полный набор известных top-level полей, три exact fact records, одно decision record с полным набором известных фактов и один experiment record с rollback. Отдельные отрицательные ветки отклоняют лишнее поле, неполный evidence reference, чужой текст факта, решение с частью фактов, именованного виновника, утверждение root cause, обещание реального эффекта и experiment без точки возврата. PASS означает только то, что учебный контракт не перепутал типы записей.'),
|
||
code(syntheticExample + '\n\nnode web/scripts/upgrade-2023-11.mjs --verify-fixture\n\n# PASS не читает и не отправляет реальные incident data, logs, traces, alerts, users, CI или сеть.\n# Он не устанавливает виновника, причину или эффект настоящего изменения.'),
|
||
p('В результате корректная формулировка звучит так: «в fixed synthetic модели три факта были доступны до решения; experiment имеет rollback и критерий». Некорректная: «мы нашли виновника» или «проверка уже предотвратила следующий сбой». Разница важна для редактора документа: первая фраза оставляет следующий шаг, вторая закрывает вопрос без evidence.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Черновик содержит имена людей и оценки, но не показывает, какие записи были доступны до действия.',
|
||
'<strong>Причина.</strong> Факт, решение и профилактика лежат в одной хронологической строке, поэтому позднее знание маскируется под знание в моменте.',
|
||
'<strong>Проверка.</strong> Для каждого предложения поставьте метку: факт с источником, решение с набором доступных фактов или проверяемый experiment. Строку без метки вынесите в вопрос.',
|
||
'<strong>Действие.</strong> Оставьте timeline из фактов; привяжите решение к fact IDs; превратите профилактику в маленькую гипотезу с критерием и rollback.',
|
||
'<strong>Проверка вывода.</strong> На ревью спросите, есть ли в документе утверждение о причине, человеке или эффекте, которое не имеет отдельного evidence. Если есть — смените статус на «не проверено».',
|
||
'<strong>Следующий шаг.</strong> Выберите один action item и заранее договоритесь, какой артефакт подтвердит его результат в разрешённой среде.',
|
||
]),
|
||
h2('Когда остановиться и как откатить'),
|
||
p('Остановите редактуру, если команда начинает спорить о мотивах, а не о данных в моменте. Это не запрет на ответственность: последствия действия и владелец следующей проверки остаются в документе. Просто мотивацию нельзя подменять evidence. Вернитесь к последнему факту с источником, зафиксируйте открытый вопрос и договоритесь, кто может добавить разрешённый артефакт. Не пытайтесь закрыть пробел реконструкцией из памяти одного участника.'),
|
||
p('Rollback этого fixture узкий: `rollbackSyntheticPostmortem()` возвращает snapshot synthetic records и помечает `externalIO=not-attempted`. Он не отменяет rollout, не удаляет реальные данные, не выключает alert, не меняет CI и не исправляет production runtime. В реальном разборе rollback должен быть отдельным операционным планом с системой, владельцем, границей воздействия и наблюдаемым критерием завершения. Учебная функция лишь не позволяет назвать имитацию операционным действием.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Пакет не содержит настоящего incident data, logs, traces, alerts, users, CI, сетевых запросов или production runtime. Времена, факт-карточки, действие и experiment полностью synthetic. Он не устанавливает виновника, root cause, impact, доступность, пользовательский эффект или результат изменения. Наличие структуры не делает разбор законченным: настоящая команда должна отдельно проверить доступ, privacy, retention, юридические границы, правила disclosure и способ связывать evidence с источником.'),
|
||
p('Следующий шаг — взять один завершённый, разрешённый для обучения случай и сначала заполнить только колонки «факт» и «источник». Лишь потом добавить решения в моменте и один обратимый experiment. Если до встречи неизвестно, где лежит evidence, это уже результат: не пишите причину, пока не определите владельца и допустимый способ проверки. Так короткая ретроспектива становится техническим документом, а не стенограммой поиска виноватого.'),
|
||
h2('Историческая граница ноября 2023'),
|
||
p('К ноябрю 2023 были доступны Google SRE Book с разделом о blameless postmortems и примером timeline/action items, NIST SP 800-61 Rev. 2 с post-incident lessons learned, а также CISA playbooks 2021 для федерального cybersecurity response. Здесь из них взята дисциплина документировать факт, review и follow-up. Материал не утверждает, что подход Google или требования американского security-контура автоматически подходят любой команде, и не называет их доказательством конкретного эффекта.'),
|
||
], [sources.googleCulture, sources.googleExample, sources.nist, sources.cisa]);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2023-11-mechanism-postmortem',
|
||
title: 'Факт, решение, эксперимент: контракт postmortem без заднего знания',
|
||
categories: ['Архитектура', 'Надёжность'],
|
||
cover: '/assets/editorial/2023/postmortem-2023-decision-record.svg',
|
||
excerpt: 'Почему timeline без типов записей подменяет факт объяснением, как связать решение с доступной информацией и превратить профилактику в обратимую проверку.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('Дорогой дефект postmortem — не отсутствие шаблона, а смешение времён. Строка «релиз вызвал ошибку, поэтому инженер откатил его» содержит разные утверждения: был ли релиз, когда появился симптом, что было известно до отката и есть ли доказательство связи. В одной фразе позднее объяснение выглядит как факт, доступный во время решения.'),
|
||
p('Цена этой подмены — плохой следующий change. Команда берёт самый заметный элемент из истории, объявляет его причиной и добавляет защиту именно вокруг него. Если связь была случайной, новая проверка создаёт работу, но не улучшает способность заметить похожее состояние. Механизм без поиска виноватого проще: каждое утверждение получает тип, тип задаёт допустимые поля и не разрешает сделать больший вывод, чем дают записи.'),
|
||
h2('Контракт из трёх типов records'),
|
||
p('Первый тип — факт. Он хранит минимальное описание и evidence reference, но не root cause. Второй — решение в моменте. Оно хранит действие, время и set фактов, доступных перед выбором; поле оценки остаётся `not-judged`, пока команда не проведёт отдельную проверку. Третий — профилактический experiment. Он описывает гипотезу, метод, success criterion и rollback. Его `expectedEffect` остаётся `not-evaluated`, потому что задача ещё не стала результатом.'),
|
||
table('Контракт полей и граница вывода', ['Record', 'Граница ответственности', 'Обязательная связь', 'Что запрещено в модели'], [
|
||
['fact', 'зафиксировать предмет и источник', 'timestamp + evidence reference', 'culprit, root cause, real effect'],
|
||
['decision-at-the-time', 'показать выбор при известной информации', 'ordered list of fact IDs', 'имя человека как объяснение, оценка задним числом'],
|
||
['preventive-experiment', 'проверить одну управляемую гипотезу', 'criterion + rollback', 'обещание предотвращения реального инцидента'],
|
||
['evidence card', 'назвать разрешённый вывод', 'ссылки на все три record type', 'подмена модели production observation'],
|
||
]),
|
||
figure('/assets/editorial/2023/postmortem-2023-decision-record.svg', 'Учебная карточка решения: три synthetic fact IDs входят в решение, а выходом становится один профилактический experiment с критерием и rollback. Отдельная перечёркнутая ветка показывает, что имя человека не является полем объяснения.', 'Схема показывает границы контракта records. Она не реконструирует реальный incident, не отражает чат, alert, лог, trace, роль человека или влияние production-изменения.'),
|
||
h2('Факт не обязан объяснять механизм'),
|
||
p('Инженеру бывает неудобно оставить в документе фразу «причина не подтверждена». Кажется, будто работа не сделана. Но факт-карточка полезна именно тем, что переживает смену гипотезы. Если позже выяснится другой механизм, timestamp и ссылка на исходный артефакт останутся валидными; ложное объяснение придётся вычёркивать вместе со всеми задачами, которые на нём выросли. Поэтому `fact` в модели имеет поле `inference=not-a-cause-claim`.'),
|
||
p('Это не призыв собирать бесконечный архив. Для каждой строки спросите: помогает ли она связать симптом с моментом решения или проверить следующий experiment? Если нет, текст можно вынести в приложение либо удалить. Контракт должен быть небольшим, чтобы review замечал отсутствие источника. В fixture второй fact отклоняется, если `evidenceRef` пуст. Такая проверка не подтверждает существование артефакта, но не разрешает сделать вид, что источник указан.'),
|
||
h2('Решение — снимок доступной информации'),
|
||
p('У решения есть направление во времени. `availableFactIds` ссылаются только на facts, которые стоят раньше `occurredAt` решения. Это важно технически: полная timeline после разбора может содержать новые логи, комментарии или результаты эксперимента, но они не должны доказывать, что исходный выбор был неразумным. Нельзя изменить прошлую границу знаний добавлением удобного факта в список. Fixture специально отклоняет decision с неполным expected set и вариант с временем до третьего fact.'),
|
||
p('Модель не содержит `actor`: это не потому, что действия происходят сами, а потому, что имя человека не объясняет состояние системы. В реальном документе владелец action item нужен, а персональные данные и доступ к ним требуют отдельного правила. Но для вопроса «какие условия сделали решение возможным?» полезнее поля: какая версия была известна, какой сигнал доступен, какая инструкция существовала, какое действие было обратимо. Они дают основу для change в системе, runbook или интерфейсе, а не ярлык для участника.'),
|
||
h2('Experiment отделяет профилактику от обещания'),
|
||
p('Профилактическая задача часто начинается с глагола «добавить»: alert, тест, лимит, checklist. Этого недостаточно для проверки. В контракте experiment должен отвечать: какую неопределённость мы хотим уменьшить, каким минимальным методом, что будет считаться прохождением и как вернуть предыдущую точку. Например, гипотеза может касаться только явного reversible checkpoint в one runbook. Она не заявляет, что это устранит все outage или изменит реальный metric.'),
|
||
p('Критерий стоит сделать бинарным и связанным с артефактом: «у каждого опасного действия есть owner role, stop criterion и rollback reference». Такой критерий можно review-ить без доступа к production. Если он не проходит, результат — не «команда не справилась», а конкретное отсутствие полей. Затем можно выбрать меньший experiment либо перенести работу туда, где доступна настоящая среда. CISA и NIST полезны здесь как дисциплина follow-up в incident response, а не как разрешение объявить любую задачу достаточной защитой.'),
|
||
h2('Выполните fixture как проверку формы'),
|
||
p('`assembleSyntheticPostmortem()` не получает JSON-файл, API response или вывод CI. Он берёт экспортированный constant из того же модуля. Функция проверяет fixed IDs, порядок пяти synthetic records, полное множество fact IDs, отсутствие полей `culprit`, `rootCause` и `realEffect`, а также rollback у experiment. На valid branch строится immutable card, где permitted conclusion ограничен формой records. Это намеренно не schema validator для вашего формата и не engine root-cause analysis.'),
|
||
code(syntheticExample + '\n\nnode web/scripts/upgrade-2023-11.mjs --verify-fixture\n\n# Fixture не запускает сборку, CI, сеть, production runtime или внешние инструменты.\n# Его PASS не является причиной, impact assessment или результатом профилактики.'),
|
||
p('Полезный результат fixture — отказ. В нём видна конкретная граница: например, `decision-context-contract-rejected` означает, что у решения нет ожидаемого набора facts, а не что человек сделал неверный выбор. `culprit-cause-or-real-effect-claim-rejected` означает, что в учебную модель добавили вывод, который модель обещала не делать. Это даёт редактору короткую причину вернуть абзац на доработку без психологической оценки участников.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Одна фраза postmortem одновременно называет событие, виновника, причину и профилактику.',
|
||
'<strong>Причина.</strong> В документе нет типов records и временной границы знания; поздний факт выглядит доступным в моменте.',
|
||
'<strong>Проверка.</strong> Разделите предложение на fact, decision и experiment. Для decision перечислите только предшествующие fact IDs; для experiment назовите criterion и rollback.',
|
||
'<strong>Действие.</strong> Сохраните facts в неизменяемой timeline, decision привяжите к доступной информации, а профилактику перепишите как ограниченный эксперимент.',
|
||
'<strong>Проверка вывода.</strong> Найдите слова «вызвал», «виноват», «предотвратит» и потребуйте отдельный источник либо смените формулировку на hypothesis или unknown.',
|
||
'<strong>Следующий шаг.</strong> Добавьте один review gate: новый action item не появляется в списке, пока не имеет owner role, проверяемый результат и обратимый путь.',
|
||
]),
|
||
h2('Обратимость относится к изменению, не к тексту'),
|
||
p('Иногда rollback описывают так, будто документ сам способен вернуть систему назад. Это опасная метафора. Ретроспектива может зафиксировать, что нужно откатить, и спросить про критерий завершения; она не знает, выполнено ли действие, пока не получит разрешённое evidence. В модели rollback — значение `restore-synthetic-record-snapshot`. Оно возвращает только копию локального набора и явно не создаёт внешнего эффекта.'),
|
||
p('В production-решении rollback должен быть точнее: target configuration, безопасная последовательность, владелец роли, stop criterion, способ проверить отсутствие дальнейшего воздействия и границы доступа. Если хотя бы один пункт неизвестен, его лучше оставить открытым. Открытый риск — не поражение postmortem; хуже спрятать его под словом «откат» и обнаружить несогласованность во время следующего инцидента.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Это не реальная incident database и не спецификация хранения. Fixture не читает, не отправляет и не производит настоящие incident data, logs, traces, alerts, users, CI, сеть или production runtime. Все identifiers, timestamps, sources, decisions и experiments fixed synthetic. Ни одна assertion не говорит о виновнике, причине, impact, пользователе, метрике, доступности, безопасности или эффекте настоящего изменения. Она проверяет только различение типов внутри учебной формы.'),
|
||
p('Следующий шаг — провести design review одного будущего postmortem template. Уберите из поля facts интерпретации, добавьте к decision список доступной информации и обязуйте preventive task иметь success criterion plus rollback. Затем на одном разрешённом учебном случае проверьте, хватает ли этих полей читателю, который не был на incident call. Если не хватает, добавляйте не историю о людях, а недостающий артефакт, вопрос или границу ответственности.'),
|
||
h2('Историческая граница ноября 2023'),
|
||
p('Материал ограничен официальными источниками, доступными до конца ноября 2023: Google SRE Book 2017 описывает postmortem как документ с impact, действиями и follow-up; его example содержит timeline и action items. NIST SP 800-61 Rev. 2 (2012) и CISA playbooks (2021) относятся к cybersecurity incident response и follow-up. Из них не следует универсальный schema, юридическая обязанность или доказанный эффект именно этой модели; contract выше — учебная инженерная декомпозиция.'),
|
||
], [sources.googleCulture, sources.googleExample, sources.nist, sources.cisa]);
|
||
|
||
const field = revision({
|
||
slug: 'editorial-2023-11-field-postmortem',
|
||
title: 'Полевой разбор сбоя без поиска виноватого: от unknown к проверке',
|
||
categories: ['Диагностика', 'Надёжность'],
|
||
cover: '/assets/editorial/2023/postmortem-2023-experiment-loop.svg',
|
||
excerpt: 'Маршрут ведущего ретроспективы: остановить спор о людях, восстановить доступные факты, привязать решение к моменту и вывести один обратимый профилактический эксперимент.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('На полевой ретроспективе проблема обычно возникает раньше, чем открывают документ. Один участник говорит «надо было сразу откатывать», другой вспоминает иной сигнал, третий объясняет мотивацию коллеги. У команды появляется версия истории, но не способ её проверить. Цена — следующая дежурная смена повторяет выбор без понимания условий и ограничений конкретного момента.'),
|
||
p('Не нужно выигрывать спор о версии событий. Нужен маршрут, который уменьшает область утверждений. Ведущий возвращает разговор к наблюдаемому: что зафиксировано, откуда запись, в каком порядке она стала известна. Затем он отделяет решение при этом наборе данных от гипотезы о профилактике. Такой порядок не отменяет ответственность за follow-up: у action item остаются владелец роли, проверка и точка возврата.'),
|
||
h2('Первые пятнадцать минут: восстановить границу знания'),
|
||
p('Начните с одной цели разбора: не «узнать, кто допустил ошибку», а «понять, какие факты были доступны перед решением и какой риск проверить следующим». Заведите три колонки. В facts попадают timestamped statements с source reference. В decisions — действия и набор facts на тот момент. В experiments — будущие проверки с остановкой. Unknown не маскируйте: строка «источник не найден» полезнее пересказа.'),
|
||
table('Маршрут ведущего и ожидаемый артефакт', ['Шаг', 'Вопрос к группе', 'Артефакт на выходе', 'Стоп-сигнал'], [
|
||
['1. Факты', 'что именно зафиксировано?', 'fact card с временем и source reference', 'в строке появилась причина или имя человека'],
|
||
['2. Решение', 'что было известно до действия?', 'decision card + ordered fact IDs', 'добавлен факт, появившийся позже'],
|
||
['3. Unknown', 'какой пробел мешает выводу?', 'вопрос, owner role, способ проверки', 'пробел заполняют догадкой'],
|
||
['4. Experiment', 'что проверим до следующего change?', 'гипотеза, criterion, rollback', 'задача обещает устранить все повторения'],
|
||
]),
|
||
figure('/assets/editorial/2023/postmortem-2023-experiment-loop.svg', 'Учебный цикл: synthetic факты ведут к decision record, неизвестное превращается в ограниченный preventive experiment, его criterion определяет review, а rollback возвращает synthetic snapshot. Ветка поиска виновника перечёркнута.', 'Цикл показывает порядок диагностики и review в учебной модели. Он не является реальным incident workflow, alerting process, evidence collection, системой задач или подтверждением эффекта production-изменения.'),
|
||
h2('Остановите фразу, которая звучит как причина'),
|
||
p('Фразы «это из-за релиза», «дежурный не заметил», «runbook подвёл» могут быть хорошими вопросами, но плохими facts. Ведущий не обязан спорить с ними. Достаточно попросить разделить фразу: какое событие зафиксировано, какой источник это подтверждает, какая часть остаётся hypothesis. Если источника нет, запишите `unknown` и назначьте безопасную проверку. Это снимает давление с человека в комнате и одновременно повышает требовательность к документу.'),
|
||
p('Особенно осторожно с chat-историей. Сообщение в канале помогает восстановить порядок, но само по себе не подтверждает технический механизм. У него есть автор, время и контекст, но нет статуса root cause. Для таких материалов отдельно определяют доступ, redaction, retention и круг читателей. В статье нет настоящих сообщений, логов или пользователей; речь только о форме вопроса до публикации выводов.'),
|
||
h2('Проверьте решение по доступным фактам'),
|
||
p('Когда facts упорядочены, возьмите одно решение. Спросите: какой сигнал был доступен, какое действие выбрали, какие альтернативы были реально обратимы, что стало известно позже. Не спрашивайте «почему человек сделал это», пока не назвали границу его информации и управления. Решение может оказаться плохим даже при хороших намерениях, но это проверяется через условия: отсутствующий dashboard, неоднозначный runbook, слишком долгий deploy, права доступа, неясный owner, неверный stop criterion.'),
|
||
p('В fixed synthetic fixture решение `pause-synthetic-change-review` ссылается на три fact IDs. Оно не имеет `actor`, не получает оценку «правильно» или «ошибка» и не знает причин настоящего события. Такая искусственная строгость помогает провести live review: если участники добавляют к карточке человека как объяснение, нужно спросить, какое системное условие мы пытаемся изменить. Если ответа нет, это ещё не preventive action, а наблюдение о процессе, которому требуется отдельный evidence.'),
|
||
h2('Сделайте один experiment меньше, чем хочется'),
|
||
p('После восстановления timeline у команды часто десяток идей: новый мониторинг, больше тестов, запреты в CI, переписывание сервиса. Не принимайте их единым списком. Выберите один управляемый риск и сформулируйте experiment. Внутри него должны быть гипотеза, минимальный scope, criterion, владелец роли, дата review и rollback. Если criterion звучит как «ничего больше не сломается», задачу надо сузить: он не показывает, когда проверка завершена и что делать при отрицательном результате.'),
|
||
p('Хороший первый experiment может быть документальным и всё равно техническим. Например: для одного опасного действия в runbook добавить explicit stop criterion и rollback reference, затем провести review с человеком, который не участвовал в исходном сценарии. Успех — он может назвать границу остановки по документу; неуспех — поля неполны. Это не измерение reliability и не доказательство предотвращения outage. Зато оно даёт короткий feedback loop, который можно повторить до изменения production конфигурации.'),
|
||
h2('Используйте fixture как чек-лист против самообмана'),
|
||
p('Fixture держит пять fixed synthetic incident records в памяти. Он не читает файл, environment, clock, CI, сеть, incident storage, logs, traces, alerts или users. `runPostmortemFixture()` проверяет девятнадцать условий: полноту facts, порядок, связь decision с facts, явную границу actor, experiment с rollback и отрицательные ветки для culprit, root cause и real effect. Он не автоматизирует встречу, не делает расследование и не знает, что произошло в настоящем контуре.'),
|
||
code(syntheticExample + '\n\nnode web/scripts/upgrade-2023-11.mjs --verify-fixture\n\n# PASS означает только согласованность fixed synthetic records in memory.\n# Он не назначает виновника и не подтверждает фактическую причину, impact или production effect.'),
|
||
p('После прогона не переносите `PASS` в заголовок postmortem. Корректнее написать: «черновик выдержал проверку формы; источники и выводы требуют отдельного review». Если fixture отклонил `rootCause`, это не значит, что причин не существует. Значит, причина не должна появиться в наборе, который обязан описывать только fact, decision и experiment. Причинная гипотеза может жить отдельно, с уровнем уверенности, evidence и способом опровержения.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Встреча застряла на воспоминаниях, оценках людей и споре о том, что было очевидно.',
|
||
'<strong>Причина.</strong> У документа нет границы между source-backed fact, решением в моменте и future experiment; позднее знание смешалось с исходным контекстом.',
|
||
'<strong>Проверка.</strong> По очереди маркируйте записи. Для fact потребуйте timestamp и evidence reference; для decision — только предшествующие facts; для experiment — criterion и rollback.',
|
||
'<strong>Действие.</strong> Остановите персональную оценку, заведите unknown как отдельную задачу и выберите один обратимый experiment вместо списка обещаний.',
|
||
'<strong>Проверка вывода.</strong> Попросите независимого читателя назвать из карточки, что известно, что было решено и что ещё только проверят. Если ответ смешан, переразметьте текст.',
|
||
'<strong>Следующий шаг.</strong> На следующем review посмотрите только на артефакт experiment: сделан ли критерий, не изменился ли scope, сохранён ли rollback и какой evidence допустим.',
|
||
]),
|
||
h2('Что делать при споре и при необходимости rollback'),
|
||
p('Если спор продолжается, не голосуйте за самую убедительную версию. Зафиксируйте competing hypotheses отдельно от facts и спросите, какое evidence могло бы различить их. Бывает, что такого evidence уже нет. Тогда честный результат — «не установлено», а следующий шаг — изменить instrument или retention для будущего случая. Это защита от решения, которое строится на уверенности без наблюдения.'),
|
||
p('Rollback реального change не выводится из одной ретроспективы. Он требует scope, доступа, операционного владельца, безопасного действия и наблюдаемой проверки. В fixture rollback возвращает лишь frozen synthetic snapshot и сообщает `productionEffect=not-attempted`. Не используйте этот код как operational procedure. Его роль — напомнить: действие нельзя считать обратимым, пока не названы точка возврата и способ увидеть, что обратная операция закончилась.'),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Здесь нет реального incident data, logs, traces, alerts, users, CI, network access или production runtime. Timeline и experiment — fixed synthetic records. Статья не делает вывода о людях, причинах, security impact, доступности, финансовом ущербе, пользователях или эффекте настоящего изменения. Она также не заменяет incident command, HR-процесс, legal review, privacy policy и требования к disclosure. Эти границы необходимо определить для своего окружения отдельно.'),
|
||
p('Следующий шаг — провести короткий rehearsal на synthetic карточке: ведущий, независимый читатель и reviewer action item. Цель — проверить язык документа: видны ли facts, граница решения, unknown, criterion и rollback. Затем адаптируйте template под допустимые источники и роли команды. Если адаптация требует выдуманных цифр или «идеального» героя, вернитесь к меньшему эксперименту и наблюдаемому артефакту.'),
|
||
h2('Историческая граница ноября 2023'),
|
||
p('До конца ноября 2023 официально были опубликованы Google SRE Book 2017, включая главу о blameless postmortem и example с timeline, а также NIST SP 800-61 Rev. 2 (2012) и CISA federal playbooks (2021) для cybersecurity incident response. Они поддерживают идею documented follow-up и lessons learned в своих областях. Автор не приписывает им универсальную организационную модель, не заявляет, что этот маршрут проверен на конкретной команде, и не переносит поздние инструменты или требования в 2023 год.'),
|
||
], [sources.googleCulture, sources.googleExample, sources.nist, sources.cisa]);
|
||
|
||
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
|
||
|
||
function verifyFixture() {
|
||
const report = runPostmortemFixture();
|
||
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
|
||
if (failed.length) {
|
||
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
process.stdout.write('PASS fixture: ' + Object.keys(report.assertions).length + '/' + Object.keys(report.assertions).length + ' assertions\n');
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) verifyFixture();
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|