rewrite 2026-09 and 2027 articles for reader-facing quality
Build and deploy / deploy (push) Successful in 18s
Build and deploy / deploy (push) Successful in 18s
This commit is contained in:
+264
-187
@@ -6,216 +6,293 @@ const ol = (items) => `<ol>${items.map((item) => `<li>${item}</li>`).join('')}</
|
||||
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((cell) => `<th scope="col">${cell}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
||||
|
||||
function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; }
|
||||
function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
|
||||
function bodyText(html) { return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, '')); }
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function plainText(html) {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function bodyText(html) {
|
||||
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, ''));
|
||||
}
|
||||
|
||||
const REFERENCES = deepFreeze({
|
||||
provDm: {
|
||||
title: 'PROV-DM: The PROV Data Model',
|
||||
url: 'https://www.w3.org/TR/2013/REC-prov-dm-20130430/',
|
||||
version: 'W3C Recommendation, 30 April 2013, dated immutable publication',
|
||||
},
|
||||
nist128: {
|
||||
title: 'NIST SP 800-128: Guide for Security-Focused Configuration Management of Information Systems',
|
||||
url: 'https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-128.pdf',
|
||||
version: 'NIST Special Publication 800-128, August 2011, dated PDF',
|
||||
},
|
||||
rfc3339: {
|
||||
title: 'RFC 3339: Date and Time on the Internet: Timestamps',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc3339.html',
|
||||
version: 'IETF RFC 3339, July 2002, immutable RFC publication',
|
||||
},
|
||||
postgres: { title: 'PostgreSQL 16 Documentation — Modifying Tables', url: 'https://www.postgresql.org/docs/16/ddl-alter.html', version: 'PostgreSQL 16, раздел 5.6, документация версии 16' },
|
||||
http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' },
|
||||
retryStatus: { title: 'RFC 6585 — Additional HTTP Status Codes', url: 'https://www.rfc-editor.org/rfc/rfc6585.html', version: 'IETF, апрель 2012 года, RFC 6585, Standards Track' },
|
||||
nistIncident: { title: 'NIST SP 800-61 Revision 2 — Computer Security Incident Handling Guide', url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf', version: 'NIST, revision 2, май 2012 года, Special Publication 800-61' },
|
||||
});
|
||||
|
||||
function sources(entries) {
|
||||
return `<ul>${entries.map(({ key, use, boundary }) => {
|
||||
const ref = REFERENCES[key];
|
||||
return `<li><a href="${ref.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(ref.title)}</a> — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)}. Граница: ${escapeHtml(boundary)}.</li>`;
|
||||
return `<li><a href="${ref.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(ref.title)}</a> — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}</li>`;
|
||||
}).join('')}</ul>`;
|
||||
}
|
||||
|
||||
const FIXED_ADVICE_CASES = deepFreeze({
|
||||
'advice-preservation-plan-v1': {
|
||||
id: 'advice-preservation-plan-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31',
|
||||
claim: { id: 'future-advice-claim-question-v1', state: 'question-only', wording: 'not-collected' },
|
||||
condition: { id: 'future-advice-condition-question-v1', version: 'not-collected', applicability: 'not-collected' },
|
||||
evidence: { id: 'future-advice-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' },
|
||||
contradiction: { id: 'future-advice-contradiction-question-v1', state: 'not-collected' },
|
||||
repro: { id: 'future-advice-repro-question-v1', state: 'not-run', result: 'not-injected' },
|
||||
correction: { id: 'future-advice-correction-question-v1', state: 'not-declared', outcome: 'not-declared' },
|
||||
handoff: { id: 'future-advice-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-boundary-plan-v1': {
|
||||
id: 'advice-boundary-plan-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31',
|
||||
claim: { id: 'future-versioned-claim-question-v1', state: 'question-only', wording: 'not-collected' },
|
||||
condition: { id: 'future-version-condition-question-v1', version: 'not-collected', applicability: 'not-collected' },
|
||||
evidence: { id: 'future-version-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' },
|
||||
contradiction: { id: 'future-contradiction-question-v1', state: 'not-collected' },
|
||||
repro: { id: 'future-falsification-repro-question-v1', state: 'not-run', result: 'not-injected' },
|
||||
correction: { id: 'future-boundary-correction-question-v1', state: 'not-declared', outcome: 'not-declared' },
|
||||
handoff: { id: 'future-boundary-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-handoff-plan-v1': {
|
||||
id: 'advice-handoff-plan-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31',
|
||||
claim: { id: 'future-handoff-claim-question-v1', state: 'question-only', wording: 'not-collected' },
|
||||
condition: { id: 'future-handoff-condition-question-v1', version: 'not-collected', applicability: 'not-collected' },
|
||||
evidence: { id: 'future-handoff-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' },
|
||||
contradiction: { id: 'future-handoff-contradiction-question-v1', state: 'not-collected' },
|
||||
repro: { id: 'future-handoff-repro-question-v1', state: 'not-run', result: 'not-injected' },
|
||||
correction: { id: 'future-handoff-correction-question-v1', state: 'not-declared', outcome: 'not-declared' },
|
||||
handoff: { id: 'future-corrective-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-missing-date-v1': {
|
||||
id: 'advice-missing-date-v1', editorDate: '', planDate: '2027-11', sourceCutoff: '2026-07-31', claim: { id: 'future-date-claim-question-v1', state: 'question-only', wording: 'not-collected' }, condition: { id: 'future-date-condition-question-v1', version: 'not-collected', applicability: 'not-collected' }, evidence: { id: 'future-date-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' }, contradiction: { id: 'future-date-contradiction-question-v1', state: 'not-collected' }, repro: { id: 'future-date-repro-question-v1', state: 'not-run', result: 'not-injected' }, correction: { id: 'future-date-correction-question-v1', state: 'not-declared', outcome: 'not-declared' }, handoff: { id: 'future-date-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-unnamed-claim-v1': {
|
||||
id: 'advice-unnamed-claim-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31', claim: { id: '', state: 'question-only', wording: 'not-collected' }, condition: { id: 'future-claim-condition-question-v1', version: 'not-collected', applicability: 'not-collected' }, evidence: { id: 'future-claim-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' }, contradiction: { id: 'future-claim-contradiction-question-v1', state: 'not-collected' }, repro: { id: 'future-claim-repro-question-v1', state: 'not-run', result: 'not-injected' }, correction: { id: 'future-claim-correction-question-v1', state: 'not-declared', outcome: 'not-declared' }, handoff: { id: 'future-claim-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-unnamed-condition-v1': {
|
||||
id: 'advice-unnamed-condition-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31', claim: { id: 'future-condition-claim-question-v1', state: 'question-only', wording: 'not-collected' }, condition: { id: '', version: 'not-collected', applicability: 'not-collected' }, evidence: { id: 'future-condition-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' }, contradiction: { id: 'future-condition-contradiction-question-v1', state: 'not-collected' }, repro: { id: 'future-condition-repro-question-v1', state: 'not-run', result: 'not-injected' }, correction: { id: 'future-condition-correction-question-v1', state: 'not-declared', outcome: 'not-declared' }, handoff: { id: 'future-condition-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-mutable-evidence-v1': {
|
||||
id: 'advice-mutable-evidence-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31', claim: { id: 'future-evidence-claim-question-v1', state: 'question-only', wording: 'not-collected' }, condition: { id: 'future-evidence-condition-question-v1', version: 'not-collected', applicability: 'not-collected' }, evidence: { id: 'future-evidence-reference-v1', pin: 'mutable-current-document', state: 'not-collected' }, contradiction: { id: 'future-evidence-contradiction-question-v1', state: 'not-collected' }, repro: { id: 'future-evidence-repro-question-v1', state: 'not-run', result: 'not-injected' }, correction: { id: 'future-evidence-correction-question-v1', state: 'not-declared', outcome: 'not-declared' }, handoff: { id: 'future-evidence-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-injected-repro-v1': {
|
||||
id: 'advice-injected-repro-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31', claim: { id: 'future-repro-claim-question-v1', state: 'question-only', wording: 'not-collected' }, condition: { id: 'future-repro-condition-question-v1', version: 'not-collected', applicability: 'not-collected' }, evidence: { id: 'future-repro-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' }, contradiction: { id: 'future-repro-contradiction-question-v1', state: 'not-collected' }, repro: { id: 'future-repro-question-v1', state: 'not-run', result: 'claimed-reproduced' }, correction: { id: 'future-repro-correction-question-v1', state: 'not-declared', outcome: 'not-declared' }, handoff: { id: 'future-repro-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
'advice-declared-correction-v1': {
|
||||
id: 'advice-declared-correction-v1', editorDate: '2026-07-31', planDate: '2027-11', sourceCutoff: '2026-07-31', claim: { id: 'future-correction-claim-question-v1', state: 'question-only', wording: 'not-collected' }, condition: { id: 'future-correction-condition-question-v1', version: 'not-collected', applicability: 'not-collected' }, evidence: { id: 'future-correction-evidence-reference-v1', pin: 'immutable-version-or-dated-snapshot', state: 'not-collected' }, contradiction: { id: 'future-correction-contradiction-question-v1', state: 'not-collected' }, repro: { id: 'future-correction-repro-question-v1', state: 'not-run', result: 'not-injected' }, correction: { id: 'future-correction-question-v1', state: 'declared-correct', outcome: 'declared-complete' }, handoff: { id: 'future-correction-handoff-question-v1', recipient: 'not-assigned', state: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off',
|
||||
},
|
||||
});
|
||||
|
||||
export function createFixedAdviceRevisionCase(id = 'advice-preservation-plan-v1') { const fixed = FIXED_ADVICE_CASES[id]; return fixed ? deepFreeze(cloneFixed(fixed)) : undefined; }
|
||||
function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); }
|
||||
export function assessAdviceRevisionPlan(input) {
|
||||
const known = Object.values(FIXED_ADVICE_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input));
|
||||
if (!known) return stop('stop-unknown-fixed-literal', 'input-is-not-a-known-named-fixed-in-memory-literal', 'select-a-named-fixed-literal');
|
||||
if (input.editorDate !== '2026-07-31' || input.planDate !== '2027-11' || input.sourceCutoff !== '2026-07-31') return stop('stop-missing-or-invalid-temporal-boundary', 'editor-date-plan-date-and-source-cutoff-must-be-exact', 'restore-all-three-fixed-dates');
|
||||
if (!input.claim.id || input.claim.state !== 'question-only' || input.claim.wording !== 'not-collected') return stop('stop-unnamed-or-declared-claim', 'claim-must-be-named-and-remain-a-future-question', 'name-the-claim-without-declaring-it');
|
||||
if (!input.condition.id || input.condition.version !== 'not-collected' || input.condition.applicability !== 'not-collected') return stop('stop-unnamed-or-declared-condition', 'condition-must-be-named-without-version-or-applicability-claim', 'name-the-condition-without-inventing-its-values');
|
||||
if (!input.evidence.id || input.evidence.pin !== 'immutable-version-or-dated-snapshot' || input.evidence.state !== 'not-collected') return stop('stop-mutable-or-unpinned-evidence', 'evidence-must-be-a-named-immutable-reference-question', 'require-an-immutable-version-or-dated-snapshot');
|
||||
if (!input.contradiction.id || input.contradiction.state !== 'not-collected') return stop('stop-unnamed-or-injected-contradiction', 'contradiction-must-remain-a-named-future-question', 'remove-the-declared-contradiction');
|
||||
if (!input.repro.id || input.repro.state !== 'not-run' || input.repro.result !== 'not-injected') return stop('stop-injected-repro-result', 'repro-result-cannot-be-created-by-a-plan', 'keep-repro-not-run-and-result-not-injected');
|
||||
if (!input.correction.id || input.correction.state !== 'not-declared' || input.correction.outcome !== 'not-declared') return stop('stop-declared-correction-or-outcome', 'correction-and-outcome-cannot-be-declared-by-a-plan', 'keep-correction-and-outcome-undeclared');
|
||||
if (!input.handoff.id || input.handoff.recipient !== 'not-assigned' || input.handoff.state !== 'not-created') return stop('stop-created-handoff-or-owner', 'plan-cannot-create-a-recipient-or-handoff', 'keep-recipient-unassigned-and-handoff-uncreated');
|
||||
if (input.requestedOutput !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'only-synthetic-plan-hand-off-is-permitted', 'use-synthetic-plan-hand-off');
|
||||
return deepFreeze({ status: 'synthetic-plan-hand-off', literalId: input.id, claim: deepFreeze(cloneFixed(input.claim)), condition: deepFreeze(cloneFixed(input.condition)), evidence: deepFreeze(cloneFixed(input.evidence)), contradiction: deepFreeze(cloneFixed(input.contradiction)), repro: deepFreeze(cloneFixed(input.repro)), correction: deepFreeze(cloneFixed(input.correction)), handoff: deepFreeze(cloneFixed(input.handoff)), productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-scope-only-to-collect-and-assess-the-named-questions' });
|
||||
export function classifyMigrationStep({ oldReads = false, newReads = false, oldWrites = false, newWrites = false }) {
|
||||
if (newWrites && !oldReads && !newReads) return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };
|
||||
if (!oldWrites && newWrites) return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };
|
||||
if (newReads && oldReads && newWrites) return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };
|
||||
if (oldReads && !newReads && !newWrites) return { phase: 'contract', reason: 'remove-format-only-after-consumers-move' };
|
||||
return { phase: 'inspect', reason: 'compatibility-matrix-is-incomplete' };
|
||||
}
|
||||
export function inspectAdvicePreservationLiteral() { const result = assessAdviceRevisionPlan(createFixedAdviceRevisionCase('advice-preservation-plan-v1')); return deepFreeze({ literal: result.literalId, claim: result.claim.id, condition: result.condition.id, status: result.status, productionEffect: result.productionEffect }); }
|
||||
export function inspectAdviceBoundaryLiteral() { const result = assessAdviceRevisionPlan(createFixedAdviceRevisionCase('advice-boundary-plan-v1')); return deepFreeze({ literal: result.literalId, evidencePin: result.evidence.pin, repro: result.repro.state, status: result.status, productionEffect: result.productionEffect }); }
|
||||
export function inspectAdviceHandoffLiteral() { const result = assessAdviceRevisionPlan(createFixedAdviceRevisionCase('advice-handoff-plan-v1')); return deepFreeze({ literal: result.literalId, correction: result.correction.state, recipient: result.handoff.recipient, status: result.status, productionEffect: result.productionEffect }); }
|
||||
export function runAdviceRevisionFixture() {
|
||||
const expected = [['advice-preservation-plan-v1', 'synthetic-plan-hand-off'], ['advice-boundary-plan-v1', 'synthetic-plan-hand-off'], ['advice-handoff-plan-v1', 'synthetic-plan-hand-off'], ['advice-missing-date-v1', 'stop-missing-or-invalid-temporal-boundary'], ['advice-unnamed-claim-v1', 'stop-unnamed-or-declared-claim'], ['advice-unnamed-condition-v1', 'stop-unnamed-or-declared-condition'], ['advice-mutable-evidence-v1', 'stop-mutable-or-unpinned-evidence'], ['advice-injected-repro-v1', 'stop-injected-repro-result'], ['advice-declared-correction-v1', 'stop-declared-correction-or-outcome']];
|
||||
const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessAdviceRevisionPlan(createFixedAdviceRevisionCase(id)).status }));
|
||||
const accepted = createFixedAdviceRevisionCase('advice-preservation-plan-v1');
|
||||
return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(accepted) && Object.isFrozen(accepted.claim) && Object.isFrozen(accepted.condition) && Object.isFrozen(accepted.evidence), checks: deepFreeze(checks) });
|
||||
|
||||
export function calculateRetryDelay({ attempt, baseMs = 100, capMs = 5000, jitterMs = 0 }) {
|
||||
if (!Number.isInteger(attempt) || attempt < 0 || ![baseMs, capMs, jitterMs].every((value) => Number.isFinite(value) && value >= 0) || baseMs > capMs) return { ok: false, reason: 'retry-input-invalid' };
|
||||
const exponential = Math.min(capMs, baseMs * (2 ** attempt));
|
||||
return { ok: true, delayMs: exponential + jitterMs, exponentialMs: exponential };
|
||||
}
|
||||
|
||||
export function classifyIncidentSignal({ status, latencyMs, errorRate, latencyLimitMs = 1000, errorLimit = 0.05 }) {
|
||||
if (!Number.isInteger(status) || !Number.isFinite(latencyMs) || !Number.isFinite(errorRate)) return { ok: false, reason: 'signal-invalid' };
|
||||
if (status >= 500 || errorRate >= errorLimit) return { ok: true, severity: 'high', reason: 'availability-or-error-threshold' };
|
||||
if (latencyMs >= latencyLimitMs) return { ok: true, severity: 'medium', reason: 'latency-threshold' };
|
||||
return { ok: true, severity: 'low', reason: 'signal-below-threshold' };
|
||||
}
|
||||
|
||||
function revision(meta, parts, referenceEntries) {
|
||||
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
|
||||
const proseLength = bodyText(contentHtml).length;
|
||||
if (proseLength < 9000 || proseLength > 12000) throw new Error(`${meta.slug}: body length ${proseLength}`);
|
||||
if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`);
|
||||
return deepFreeze({ ...meta, contentHtml, proseLength });
|
||||
}
|
||||
const commonRefs = [
|
||||
{ key: 'provDm', use: 'Даёт строгие термины для различения сущности, деятельности и происхождения записи; здесь это помогает не выдавать вопрос о совете за уже установленную историю.', boundary: 'Не подтверждает существование старой статьи, её автора, совет, условие, противоречие или исправление.' },
|
||||
{ key: 'nist128', use: 'Поддерживает дисциплину фиксированной конфигурационной основы и контролируемого изменения; здесь она ограничивает форму будущего вопроса о версии и условии.', boundary: 'Не устанавливает конфигурацию, ревизию, проверку, решение, владельца или результат какого-либо проекта.' },
|
||||
{ key: 'rfc3339', use: 'Задаёт неизменяемую норму записи даты, поэтому фиксированные editor date, plan date и cutoff не смешиваются с текущими часами.', boundary: 'Не доказывает время события, публикацию, наблюдение или завершённый hand-off.' },
|
||||
|
||||
const migrationRefs = [
|
||||
{ key: 'postgres', use: 'Показывает, как операции изменения таблиц связаны с блокировками, размером данных и совместимостью чтения.', boundary: 'Не является инструкцией для конкретного кластера: версия, расширения, объём и lock policy требуют отдельной проверки.' },
|
||||
{ key: 'http', use: 'Даёт HTTP-семантику методов, статусов и условных запросов, важную для совместимого API вокруг миграции.', boundary: 'Не описывает схему вашей базы, ORM и порядок выката приложения.' },
|
||||
];
|
||||
|
||||
const practice = revision({ slug: 'editorial-2027-11-practice-mistakes-revisions', title: 'Пересмотр старых советов: как сохранить утверждение вместе с его допущениями', categories: ['Практика', 'Техническое письмо'], cover: '/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg', excerpt: 'План P117 на ноябрь 2027: как не превратить короткий совет в бесконтекстное правило и как передать вопрос о его основаниях без вымышленной истории.', readingMinutes: 24 }, [
|
||||
p('P117 — будущий редакционный сценарий на ноябрь 2027, записанный 2026-07-31. У старого совета есть неприятная цена: его часто переносят в новый контекст как одну короткую фразу, а условие, версия и граница остаются за кадром. Следующий инженер получает не утверждение, которое можно проверить, а удобный лозунг. Цена ошибки конкретна: действие выбирают по обрезанному правилу, затем тратят время на обратный поиск предпосылок и спорят не о фактах, а о том, что будто бы имелось в виду.'),
|
||||
p('Вторая цена — назвать потерянный контекст «ошибкой старой статьи» до того, как существует сам объект проверки. В этом выпуске нет исторической публикации, снимка, цитаты, документации, автора, реального условия, свежего воспроизведения, тестового прогона или исправления. Поэтому здесь нельзя объявлять совет верным, неверным, устаревшим или исправленным. Есть только named future question fields. Единственный положительный ответ evaluator-а — <code>synthetic-plan-hand-off</code>; <code>productionEffect</code> всегда <code>not-attempted</code>.'),
|
||||
h2('Не сохраняйте фразу отдельно от рамки'),
|
||||
p('Полезно думать о совете не как о цитате, а как о карточке из нескольких независимых частей. Первая часть — claim: вопрос о том, что именно когда-нибудь потребуется сформулировать. Вторая — condition: вопрос о версии и применимости. Третья — evidence: вопрос о допустимой привязке к immutable version или dated snapshot. Пока значения не собраны в отдельном авторизованном scope, все три остаются <code>not-collected</code>. Такой дизайн кажется строгим, зато он не даёт прошлому ярлыку стать сегодняшней командой.'),
|
||||
p('Слова «сохранить совет» иногда ошибочно понимают как сохранить всю формулировку. Это опасный вид архива: строка может выглядеть точной, но не указывать ни объект, ни исключение, ни границу. В плановом материале даже правдоподобную строку нельзя сочинять: она станет фиктивной старой цитатой. Вместо неё фиксируется идентификатор вопроса <code>future-advice-claim-question-v1</code>. Идентификатор не рассказывает историю и не закрывает спор; он позволяет различить поле claim от condition и не подставить одно вместо другого.'),
|
||||
table('Карточка сохранения будущего вопроса о совете', ['Поле', 'Фиксированное значение P117', 'Что это разрешает', 'Какой вывод закрыт'], [['Claim', '<code>future-advice-claim-question-v1</code> + <code>not-collected</code>', 'Назвать предмет будущего разбора', 'Существовал старый тезис или его точная формулировка'], ['Condition', '<code>future-advice-condition-question-v1</code>', 'Отдельно потребовать версию и применимость', 'Совет подходит конкретной версии или среде'], ['Evidence', '<code>immutable-version-or-dated-snapshot</code>', 'Запросить устойчивую ссылку', 'Документ уже найден или не менялся'], ['Repro', '<code>not-run / not-injected</code>', 'Оставить место для будущей проверки', 'Поведение воспроизведено'], ['Correction', '<code>not-declared</code>', 'Не терять вопрос о возможной правке', 'Что-либо исправлено или стало лучше']]),
|
||||
h2('Допущение — часть смысла, а не примечание'),
|
||||
p('У любого практического совета есть область, за пределами которой он может стать вредным. Это не делает совет плохим. Условие может зависеть от версии интерфейса, режима запуска, формата входа, политики доступа или внешнего контракта. Но P117 не называет ни одну такую реальную версию: назвать её означало бы притвориться, что старый объект уже известен. Поле condition хранит только вопрос и запрет на значение. Будущий исследователь должен будет собрать версию и применимость как evidence, а не вытащить их из уверенной интонации текста.'),
|
||||
p('Отдельный риск — подменить условие рекомендацией. Фраза «проверяйте документацию» не говорит, какую именно документацию можно считать основанием. Здесь boundary жёстче: current page сама по себе не годится, если она может измениться. Нужна версия, неизменяемый commit или датированный снимок. Это не требование собрать ссылку прямо сейчас. Это правило, по которому будущий scope будет отличать источник вопроса от изменяемого объяснения. Пока pin не назван, evaluator возвращает stop и не подставляет разумный default.'),
|
||||
figure('/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg', 'Временная шкала P117: editor date 2026-07-31, будущая plan date 2027-11 и source cutoff 2026-07-31 окружают три вопроса claim, condition и evidence; между ними нет заявленного прошлого совета или результата.', 'Схема показывает temporal boundary и порядок вопросов для будущего разбора. Она не является снимком статьи, журналом изменений, документацией или подтверждением того, что совет когда-либо существовал.'),
|
||||
h2('Исполнимый пример оставляет содержание неизвестным'),
|
||||
p('Ниже запущенный фрагмент работает исключительно с named fixed in-memory literal. Он не принимает URL, текст статьи, переменные окружения, файл, часы, сеть, Git, журнал, тестовый стенд или пользовательский ввод. Возвращаемый status описывает только форму плана. В частности, печать <code>not-collected</code> не означает, что материал потерян; она означает, что P117 не получил права назвать его собранным. Это небольшая, но важная защита от случайного превращения редакционного черновика в источник истории.'),
|
||||
code("import { inspectAdvicePreservationLiteral } from './upgrade-2027-11.mjs';\n\nconst fixedAdviceQuestion = inspectAdvicePreservationLiteral();\nconsole.log({\n literal: fixedAdviceQuestion.literal,\n claim: fixedAdviceQuestion.claim,\n condition: fixedAdviceQuestion.condition,\n result: fixedAdviceQuestion.status,\n effect: fixedAdviceQuestion.productionEffect,\n});"),
|
||||
p('Пример намеренно не печатает якобы старую формулировку. Если добавить её в literal, она станет synthetic текстом, но читатель легко примет её за найденный архив. Тот же запрет действует для номера версии, времени публикации, имени автора и причины изменения: реалистичность не превращает выдуманную деталь в evidence. Безопасный учебный результат — лишь hand-off с названиями вопросов, которые другой scope сможет принять, отклонить или уточнить.'),
|
||||
h2('Как удержать границу при подготовке'),
|
||||
p('Первый практический вопрос звучит не «надо ли переписать совет?», а «какое утверждение требуется сохранить вместе с условиями?». Это меняет стоимость обсуждения. Команда не должна согласовать литературную трактовку, пока не знает минимальный доказательный пакет. Сначала разделяются claim, condition и evidence; затем решается, достаточно ли будущему читателю одной версии документа или нужна отдельная проверка поведения. Такая последовательность не обещает, что проверка будет выполнена. Она только предотвращает необратимый вывод до появления входных данных.'),
|
||||
ol(['Поставить на карточке все три фиксированные даты: <code>editorDate: 2026-07-31</code>, <code>planDate: 2027-11</code> и <code>sourceCutoff: 2026-07-31</code>.', 'Назвать claim идентификатором вопроса, не создавать цитату, заголовок старого материала или вывод о его истинности.', 'Назвать condition отдельным идентификатором и оставить version/applicability как <code>not-collected</code>; не переносить условие из памяти.', 'Разрешить evidence только как future reference с immutable version или dated snapshot; mutable current page вернуть на stop.', 'Сохранить repro как <code>not-run</code> и <code>not-injected</code>; не писать, что тест или ручная проверка уже дали результат.', 'Передать только synthetic plan hand-off в отдельный authorisation scope, где будут заново определены доступ, метод и ответственный вопрос.']),
|
||||
h2('Контрпример: память не становится provenance'),
|
||||
p('Иногда самый быстрый путь — сказать: «все знают, почему это правило появилось». Именно здесь контекст теряется окончательно. Коллективная память может подсказать направление поиска, но не заменяет pinned evidence и не позволяет утверждать историю. Она особенно ненадёжна, когда один и тот же короткий совет кочует между версиями или командами. В P117 память не запрещена как будущий вход, но ей не выдана роль доказательства. До отдельной оценки она остаётся неназванным источником и должна закрывать принятие, а не открывать его.'),
|
||||
p('Другой контрпример — заменить условие широкой оговоркой «зависит от контекста». Такая фраза выглядит осторожно, но ничего не защищает: читатель не знает, какой контекст нужно зафиксировать и как обнаружить несовместимость. Named condition question строже и полезнее. Она требует будущую версию и применимость, однако не делает вид, что они уже известны. В результате карточка сохраняет неопределённость в явном поле, а не прячет её в конце абзаца.'),
|
||||
h2('Минимальная карточка дешевле последующего пересказа'),
|
||||
p('У такой карточки есть ещё одно практическое свойство: она не заставляет будущего читателя доверять редактору. Достаточно увидеть, что claim, condition и evidence названы раздельно, а значения не подменены. Если одна часть окажется нерелевантной, новый scope сможет удалить или заменить именно её, не переписывая вокруг вымышленную историю. Это уменьшает blast radius редакторского решения: меняется вопросная карточка, а не вывод о людях, системах или уже опубликованных результатах.'),
|
||||
p('Не надо добавлять в карточку поле «уверенность». До появления evidence оно будет измерять только риторику автора. Не надо добавлять и поле «приоритет»: без предмета и владельца оно станет скрытой очередью. Нужны лишь идентификаторы будущих вопросов и стоп-границы. Такая аскетичность не делает будущий разбор беднее; она оставляет ему возможность выбрать метод по реальному объекту, а не по предзаполненной оценке.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Эта модель не проверяет архив, репозиторий, release notes, документацию, конфигурацию, реальный совет, авторство, ссылку, тест или production. Она также не решает, нужна ли коррекция и кому её выполнять. Deep freeze защищает только учебный объект в памяти, а не информацию за его пределами. Официальные источники ниже задают язык происхождения, конфигурационной основы и дат, но не являются ревизией чьего-либо текста. Они не превращают synthetic names в historical facts.'),
|
||||
p('Следующий шаг не «обновить старую статью», потому что здесь нет такой статьи. Нужен отдельный scope с явно разрешённым объектом исследования. В нём можно будет выбрать один claim question, определить допустимую immutable привязку, собрать condition и сформулировать критерий опровержения. До этого P117 заканчивается ровно там, где должен: записанной формой вопроса и <code>synthetic-plan-hand-off</code>, без декларации коррекции или эффекта.'),
|
||||
], commonRefs);
|
||||
const practice = revision({
|
||||
slug: 'editorial-2027-11-practice-mistakes-revisions',
|
||||
title: 'Миграция схемы БД без простоя: expand, switch, contract',
|
||||
categories: ['Базы данных', 'Надёжность'],
|
||||
cover: '/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg',
|
||||
excerpt: 'Разбираем совместимую миграцию таблицы: почему опасно менять запись и чтение одним шагом и как оставить обратимый переход.',
|
||||
readingMinutes: 16,
|
||||
}, [
|
||||
p('Проблема начинается с невинной команды <code>ALTER TABLE</code>: запрос проходит на пустой базе, но на большой таблице блокирует чтение или оставляет старый код без нужной колонки. Цена ошибки — простой, очередь запросов и откат приложения, который уже не умеет читать изменённые данные.'),
|
||||
p('Причина — рассматривать схему и код как один пакет. В работающей системе старый и новый binary живут одновременно, миграция может быть прервана, а несколько экземпляров переключаются не синхронно. Поэтому изменение нужно разложить на совместимые фазы: сначала добавить форму, затем переключить чтение и запись, и только потом удалить старое.'),
|
||||
h2('Совместимость — это матрица чтения и записи'),
|
||||
p('Представьте добавление <code>display_name</code> вместо вычисления имени из двух колонок. Старый код читает <code>first_name</code> и <code>last_name</code>, новый хочет читать <code>display_name</code>. Если сразу сделать новое поле обязательным и перевести writer, старый reader может не понять запись. Если сразу удалить старые колонки, rollback перестанет быть обратимым.'),
|
||||
p('На первом шаге добавляется новая колонка без требования, чтобы старый код продолжал работать. Затем новый writer может заполнить обе формы, а reader — выбрать новую при наличии и старую как fallback. После backfill и проверки потребителей можно убрать fallback. Последняя операция должна быть отдельной и отложенной относительно первого изменения кода.'),
|
||||
table('Фазы expand/switch/contract', ['Фаза', 'Чтение', 'Запись', 'Допустимое изменение'], [
|
||||
['Expand', 'старое', 'старое или обе формы', 'добавить nullable колонку/индекс'],
|
||||
['Dual write', 'старое с fallback', 'обе формы', 'заполнить новый формат'],
|
||||
['Switch', 'новое с fallback', 'обе формы', 'перевести reader после проверки данных'],
|
||||
['Contract', 'новое', 'новая форма', 'удалить старую только после сигнала'],
|
||||
['Rollback', 'старое или fallback', 'совместимая запись', 'вернуть binary без потери данных'],
|
||||
]),
|
||||
h2('DDL — это операция с ресурсом'),
|
||||
p('Документация PostgreSQL предупреждает, что изменение таблицы может зависеть от блокировок и объёма работы. В review важно смотреть не только на SQL, но и на lock mode, время ожидания, транзакцию миграции и поведение при остановке. Индекс, backfill и изменение типа имеют разную стоимость; объединять их в одну «маленькую миграцию» опасно.'),
|
||||
p('Backfill лучше считать отдельной нагрузкой. Он может конкурировать с пользовательскими запросами, вызвать рост WAL и изменить порядок обновлений. Ограниченная пачка, пауза и метрика отставания полезнее одного огромного UPDATE. Если backfill прерван, повтор должен быть идемпотентным и не перезаписывать более свежую запись.'),
|
||||
figure('/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg', 'Временная схема миграции: добавление совместимой формы, двойная запись, переключение чтения и удаление старой колонки разделены измеримыми контрольными точками.', 'Диаграмма показывает порядок, в котором старый и новый код могут сосуществовать. Удаление старой формы находится в конце и требует сигнала использования.'),
|
||||
h2('Runnable-пример: определить безопасную фазу'),
|
||||
p('Функция принимает четыре boolean-признака: умеет ли старый и новый код читать и писать новый формат. Она возвращает фазу и причину. Пример не подключается к базе и не запускает DDL; он фиксирует мысль, которую удобно проверить в review или в тесте миграционного инструмента. Если новый writer не имеет совместимого reader, результат должен быть <code>unsafe</code>.'),
|
||||
code(`import { classifyMigrationStep } from './upgrade-2027-11.mjs';
|
||||
|
||||
const mechanism = revision({ slug: 'editorial-2027-11-mechanism-mistakes-revisions', title: 'Пересмотр старых советов: версия, условие и граница опровержения', categories: ['Архитектура', 'Надёжность'], cover: '/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg', excerpt: 'План P117 на ноябрь 2027: как отделить противоречие и будущий repro от версии и условия, чтобы непроверенный пересмотр не стал вердиктом.', readingMinutes: 25 }, [
|
||||
p('Пересмотр старого совета ломается не только из-за отсутствующей цитаты. Дорогая ошибка появляется, когда несовпадение версии или условия сразу называют опровержением. Один человек видит новый вариант интерфейса, другой помнит другую предпосылку, и разговор быстро превращается в «совет больше не работает». Цена — ложная отмена полезного правила либо, наоборот, сохранение правила там, где его предпосылка уже не названа. Оба исхода заставляют команду делать лишний круг диагностики.'),
|
||||
p('P117 не содержит старого совета, версии, документа, противоречащего наблюдения, окружения, воспроизведения, тестового лога или результата повтора. Это будущий сценарий на <code>planDate: 2027-11</code> при editor date и source cutoff 2026-07-31. Поэтому слово «опровергнут» здесь не может стать итогом. Положительный evaluator output один: <code>synthetic-plan-hand-off</code> с <code>productionEffect: not-attempted</code>. Любая попытка добавить подтверждённый repro, текущую mutable страницу или заявленную коррекцию должна закрываться.'),
|
||||
h2('Версия не равна противоречию'),
|
||||
p('Версия описывает, к какому состоянию предмета относится будущий вопрос. Противоречие описывает другой вопрос: какой факт мог бы сделать claim несостоятельным при сопоставимых условиях. Эти сущности нельзя склеивать. Если в документе найдена новая версия, это ещё не говорит, что прежнее утверждение неверно: возможно, изменился предмет, область применимости или словарь. И наоборот, даже зафиксированная старая версия не гарантирует, что совет работал в каждом режиме. Для честной ревизии нужны два разных поля и два разных основания.'),
|
||||
p('В literal P117 поле <code>condition</code> имеет named id, но <code>version</code> и <code>applicability</code> остаются <code>not-collected</code>. Поле <code>contradiction</code> тоже названо, однако его state — <code>not-collected</code>. Это не бедность модели, а барьер против преждевременной логики. Пока сторона сравнения не определена, нельзя объявить противоречие и нельзя выбирать «исправление». Хорошая модель оставляет неизвестное неизвестным, а не заполняет его наиболее правдоподобным текстом.'),
|
||||
table('Матрица механизма пересмотра', ['Слой', 'Вопрос будущего scope', 'Допустимое состояние P117', 'Недопустимая подмена'], [['Claim', 'Что потребуется проверить?', '<code>question-only</code>', 'Придуманная старая цитата'], ['Version / condition', 'К какой версии и области применим вопрос?', '<code>not-collected</code>', 'Версия из памяти или current page'], ['Contradiction', 'Что именно могло бы опровергнуть claim?', '<code>not-collected</code>', 'Новая формулировка как готовый вердикт'], ['Repro boundary', 'Какое наблюдение потребуется отдельно?', '<code>not-run / not-injected</code>', '«Тест уже подтвердил»'], ['Correction', 'Какой ответ будет допустим после оценки?', '<code>not-declared</code>', 'Статус исправлено или завершено']]),
|
||||
h2('Опровержимость начинается с различающего вопроса'),
|
||||
p('Фальсифицируемость в этом материале не означает выполнить тест ради красной ветки. Она означает заранее назвать различие между «условие неизвестно» и «есть наблюдение, которое в тех же условиях не согласуется с claim». Без этого различия будущий repro становится театром: любой результат можно объяснить сменой версии, а любая версия — объявить новым доказательством. P117 не строит метод прогона. Он сохраняет границу вопроса <code>future-falsification-repro-question-v1</code>, чтобы будущий scope не выдал первый удобный запуск за ответ.'),
|
||||
p('На практике это означает четыре независимые проверки: имеется ли named claim; имеется ли named condition; pinned ли evidence; не впрыснут ли результат repro. Последняя особенно важна. Реалистичный объект с полем <code>claimed-reproduced</code> выглядит информативнее пустого объекта, но именно поэтому опасен. В плановом редакционном выпуске ему неоткуда взяться. Evaluator не пытается оценить правдоподобие результата и не выбирает частичное принятие: он останавливает весь hand-off. Такой fail-closed путь сохраняет возможность честного будущего исследования.'),
|
||||
figure('/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg', 'Матрица P117 разделяет claim, condition, immutable evidence, contradiction, repro и correction; зелёный путь заканчивается только плановой передачей, а красные клетки показывают состояния, которые evaluator обязан остановить.', 'Матрица описывает синтетическую логику проверки формы. Она не сравнивает реальные версии, не воспроизводит поведение и не устанавливает противоречие старому совету.'),
|
||||
h2('Runnable пример показывает только границу'),
|
||||
p('Этот пример читает named fixed in-memory literal, встроенный в модуль. Он не получает параметры процесса, не парсит документацию и не сохраняет результат. Важно не подменять его тем, чем он не является: вызов <code>inspectAdviceBoundaryLiteral</code> не подтверждает, что какая-либо версия определена, а значение <code>not-run</code> не равно неудачному тесту. Это маркировка отсутствия запуска. Без этой маркировки читатель легко сделает противоположные, но одинаково необоснованные выводы: «всё доказано» или «всё уже опровергнуто».'),
|
||||
code("import { inspectAdviceBoundaryLiteral } from './upgrade-2027-11.mjs';\n\nconst fixedBoundaryQuestion = inspectAdviceBoundaryLiteral();\nconsole.log({\n literal: fixedBoundaryQuestion.literal,\n evidencePin: fixedBoundaryQuestion.evidencePin,\n repro: fixedBoundaryQuestion.repro,\n result: fixedBoundaryQuestion.status,\n effect: fixedBoundaryQuestion.productionEffect,\n});"),
|
||||
p('Названный literal не является шаблоном, который надо наполнить от руки в запущенном процессе. Evaluator принимает только exact known fixed records после JSON-сравнения. Любой объект с дополнительным полем, другой датой или «полезным» результатом выглядит неизвестным и получает <code>stop-unknown-fixed-literal</code> либо более узкий stop. Это намеренно консервативно: способность угадать дефолт — плохая замена provenance. Сначала появляется разрешённый источник и метод, затем новый scope создаёт отдельный артефакт.'),
|
||||
h2('Почему mutable документ недостаточен'),
|
||||
p('Текущая страница документации может быть полезна как ориентир, но не как надёжное доказательство пересмотра. Она способна поменяться после cutoff, переписать термин, скрыть прежнюю оговорку или вести на другую версию. В этой модели допустим только label <code>immutable-version-or-dated-snapshot</code>. Сам label не доказывает, что ссылка найдена; он лишь запрещает принять <code>mutable-current-document</code>. Разница кажется формальной до тех пор, пока поздний читатель не пытается восстановить, на чём держался вывод.'),
|
||||
p('Не стоит противопоставлять pinned evidence и живую документацию как хорошее и плохое. У них разные роли. Живая страница помогает будущему исследованию понять текущий словарь; pinned материал позволяет сказать, что именно было доступно на обозначенной границе. P117 не собирает ни то ни другое и не устанавливает, что они расходятся. Он только требует не смешивать их в одной ячейке. Если future scope увидит несоответствие, это будет повод сформулировать новый вопрос, а не разрешение задним числом объявить исходный совет неверным.'),
|
||||
h2('Порядок, который не производит ложный verdict'),
|
||||
p('Механизм полезен, когда он делает отказ информативным. Stop по пустому condition не говорит «совет плохой»; он говорит «нельзя даже начать сравнение». Stop по mutable evidence не говорит «документация бесполезна»; он говорит «её изменяемость не даёт закрепить основание». Stop по injected repro не говорит «результат невозможен»; он говорит «этот план не имеет права его создавать». Благодаря таким различиям следующий scope получает точную причину, а не общий красный статус.'),
|
||||
ol(['Зафиксировать temporal boundary P117 и не использовать текущие часы как замену editor date, plan date или cutoff.', 'Выделить one named claim question; не восстанавливать текст утверждения по памяти или из чужого пересказа.', 'Выделить one named condition question и оставить version/applicability неизвестными до отдельного сбора.', 'Потребовать evidence pin уровня immutable version или dated snapshot; mutable reference вернуть в stop без эвристики.', 'Записать contradiction как вопрос о различающем наблюдении, но не выполнять и не описывать repro внутри планового выпуска.', 'Оставить correction/outcome undeclared и передать статус hand-off только в новый разрешённый scope.']),
|
||||
h2('Граница сравнения важнее количества деталей'),
|
||||
p('Можно сделать карточку длиннее, добавив предполагаемые команды, поля ответа и пороги. До появления объекта это создаст не метод, а фиктивный протокол. Falsifiability требует не много пунктов, а одного различающего правила: какие условия должны быть сопоставимы, прежде чем наблюдение вообще имеет отношение к claim. P117 не определяет эти условия, поскольку не знает предмета; зато не разрешает спрятать их отсутствие за якобы уже выполненным repro.'),
|
||||
p('Поэтому stop — не ошибка интерфейса evaluator-а. Он сохраняет точку, в которой требуется новая информация или новая авторизация. Система не заменяет missing condition строкой «default», не берёт актуальную веб-страницу вместо pinned reference и не превращает произвольный input в допустимый case. В будущем это может показаться неудобным, однако именно предсказуемый отказ делает версию, условие и противоречие независимыми объектами проверки.'),
|
||||
h2('Ограничения механизма'),
|
||||
p('Эта проверка не выполняет версионное сравнение, diff, HTTP-запрос, чтение файла, browser run, sandbox, тест, reproduction, поиск по архиву или доступ к внешней документации. Она не измеряет совместимость, не выбирает метод доказательства и не назначает владельца. Синтетический status не является quality gate реального продукта. Он подтверждает лишь то, что ограниченный fixed object не содержит заявленной версии, результата или коррекции. Именно поэтому его нельзя использовать как доказательство того, что будущая работа будет завершена.'),
|
||||
p('Следующий шаг — открыть отдельный scope, только если нужен ответ на один из named вопросов. В нём следует выбрать версию или датированный снимок, описать сравнимые условия, назвать наблюдение, которое действительно могло бы опровергнуть claim, и отделить результат от решения о коррекции. До этого шага P117 не имеет factual verdict. Он сохраняет механизм, который не путает смену контекста с противоречием и не подделывает repro ради завершённости.'),
|
||||
], commonRefs);
|
||||
const expand = classifyMigrationStep({
|
||||
oldReads: false,
|
||||
newReads: false,
|
||||
oldWrites: false,
|
||||
newWrites: true,
|
||||
});
|
||||
const switchPhase = classifyMigrationStep({
|
||||
oldReads: true,
|
||||
newReads: true,
|
||||
oldWrites: true,
|
||||
newWrites: true,
|
||||
});
|
||||
|
||||
const field = revision({ slug: 'editorial-2027-11-field-mistakes-revisions', title: 'Пересмотр старых советов: корректирующая передача без ложного завершения', categories: ['Процессы', 'Практика'], cover: '/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg', excerpt: 'План P117 на ноябрь 2027: как передать вопрос о возможной коррекции, не объявляя прошлый материал неправильным и не выдавая повторный тест за выполненный.', readingMinutes: 24 }, [
|
||||
p('В поле самая дорогая ошибка пересмотра звучит убедительно: «старый материал надо признать неправильным и поправить». Такая формула сразу назначает виновника, действие и финал, хотя часто известен только вопрос о применимости. Цена — ложная коррекция попадает в очередь, читатель видит статус как доказательство, а будущая проверка уже вынуждена подгоняться под объявленный исход. Вместо короткого hand-off команда получает обязательство, которого никто не мог обосновать.'),
|
||||
p('В P117 нет старой статьи, редакционной карточки, автора, владельца, репозитория, теста, прогона, review, публикации, исправления или production result. Все такие предметы существуют только как запрещённые для декларации future question fields. Нельзя сообщать, что old advice ошибочен, correction сделана или rerun завершён. Разрешён положительный результат только <code>synthetic-plan-hand-off</code> и только с <code>productionEffect: not-attempted</code>. Это не отказ от корректности; это отказ выдавать намерение за завершённую работу.'),
|
||||
h2('Передаётся вопрос, а не приговор'),
|
||||
p('Корректирующий hand-off начинается с минимальной карточки: есть named claim question, named condition question, named evidence question, named contradiction question и named repro question. Поля correction и handoff тоже названы, но имеют значения <code>not-declared</code>, <code>not-assigned</code> и <code>not-created</code>. Такая карточка не обещает, что кто-то возьмёт её в работу. Она позволяет честно сказать ровно одно: для будущего scope сформулирована граница вопроса, а предмет, доступ и решение ещё отсутствуют.'),
|
||||
p('Это отличается от очереди. Очередь обычно подразумевает объект, приоритет, исполнителя и готовое действие. Здесь ни один из них не известен. Если заполнить recipient вымышленной ролью, hand-off станет видом назначения. Если назвать correction, он станет видом решения. Если указать outcome, он станет видом отчёта. Evaluator специально закрывает эти случаи, хотя они делают запись удобнее для чтения. Без такого запрета документ незаметно превратится из плана исследования в распоряжение о пересмотре.'),
|
||||
table('Пакет корректирующей передачи', ['Часть пакета', 'Состояние в P117', 'Зачем оставлено так', 'Что не следует утверждать'], [['Question envelope', 'Named ids, <code>question-only</code>', 'Не смешивать предметы будущей оценки', 'Известна старая ошибка'], ['Evidence boundary', 'Pinned source требуется, state <code>not-collected</code>', 'Не опереться на меняющийся текст', 'Основание уже собрано'], ['Repro boundary', '<code>not-run / not-injected</code>', 'Не превратить план в результат прогона', 'Тест повторён и подтвердил вывод'], ['Correction', '<code>not-declared</code>', 'Оставить решение после оценки', 'Материал исправлен'], ['Recipient / handoff', '<code>not-assigned / not-created</code>', 'Не назначать человека или процесс', 'Есть владелец и работа передана']]),
|
||||
h2('Разделите исправление, публикацию и эффект'),
|
||||
p('Даже в настоящем проекте эти три слова не синонимы. Возможная коррекция — решение изменить формулировку или артефакт. Публикация — отдельное действие над определённым каналом. Effect — наблюдение после действия по заранее оговорённому критерию. P117 не имеет ни одного из этих входов, поэтому хранит correction и outcome как undeclared. Эта детализация нужна не ради бюрократии: если назвать effect в момент передачи, получатель уже не сможет отличить гипотезу пользы от полученного результата.'),
|
||||
p('Особенно вредна фраза «повторный тест закончен». Она склеивает метод, среду, вход, наблюдение и интерпретацию в два слова. В plan/scenario нет права добавлять такой факт, даже как иллюстрацию. Поле <code>future-handoff-repro-question-v1</code> лишь резервирует тему будущей проверки. Его state <code>not-run</code> не указывает на сбой, задержку или отказ от работы; он фиксирует отсутствие запуска в этом пакете. Любой injected result — положительный или отрицательный — закрывает evaluator, потому что модель не читает внешние данные.'),
|
||||
figure('/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg', 'Петля передачи P117: named questions о claim, condition, evidence и repro идут к отдельному будущему scope; correction, recipient и outcome остаются undeclared, поэтому петля не заканчивается публикацией или production эффектом.', 'Диаграмма показывает границу корректирующей передачи. Она не отображает реального владельца, статью, тест, исправление, публикацию, проверку или достигнутый результат.'),
|
||||
h2('Runnable пример не создаёт работу'),
|
||||
p('Модуль ниже полностью self-contained. Named literal уже лежит в памяти и глубоко заморожен; функция возвращает компактное описание его статуса. Она не создаёт задачу, issue, комментарий, файл, письмо, уведомление, pull request или запись в очереди. Отсутствие I/O важно здесь больше, чем красивый workflow: учебная функция не должна иметь скрытого канала, через который «передача» станет настоящим организационным событием. Запуск показывает контракт отказа от ложного завершения, а не прогресс процесса.'),
|
||||
code("import { inspectAdviceHandoffLiteral } from './upgrade-2027-11.mjs';\n\nconst fixedHandoffQuestion = inspectAdviceHandoffLiteral();\nconsole.log({\n literal: fixedHandoffQuestion.literal,\n correction: fixedHandoffQuestion.correction,\n recipient: fixedHandoffQuestion.recipient,\n result: fixedHandoffQuestion.status,\n effect: fixedHandoffQuestion.productionEffect,\n});"),
|
||||
p('Здесь нет функции <code>assignOwner</code>, потому что её появление противоречило бы предмету статьи. Нельзя заменить отсутствующее полномочие красивым названием роли вроде «editor» или «maintainer»: имя само по себе уже объявляет адресата. Аналогично нельзя добавить <code>publish: false</code> и считать это безопасным моделированием публикации. Лучше вообще не создавать действие, чем создавать его тень и потом спорить, была ли она частью факта. P117 формирует только вопросную структуру и следующую границу авторизации.'),
|
||||
h2('Коррекция может быть одним из будущих исходов, но не текущим статусом'),
|
||||
p('Сохранение вопроса о коррекции не делает исход предрешённым. Future scope может обнаружить, что condition был неверно понят, evidence недостаточно, repro нерепрезентативен или claim вообще нельзя восстановить. В каждом случае правильным результатом может быть отсутствие коррекции. Эта возможность теряется, если initial hand-off уже говорит «исправить». Поэтому evaluator не принимает <code>declared-correct</code> и <code>declared-complete</code>: они превращают открытый вопрос в смоделированную победу без источника.'),
|
||||
p('Обратная ошибка — считать отказ от verdict-а бездействием. Нет: hand-off содержит практический следующий шаг и точные stop reasons. Будущий исследователь узнаёт, чего не хватает: даты, имени claim, имени condition, immutable pin, чистого repro или undeclared correction. Такая точность сокращает повторную трактовку, не обещая результата. В отличие от общего «нужно разобраться», она задаёт минимальный вход для следующего решения и не делает вид, что этот вход уже получен.'),
|
||||
h2('Что должен получить следующий scope'),
|
||||
p('Получатель не обязан соглашаться с framing карточки. Он может вернуть её, если claim слишком широк, condition не даёт отделить режимы, источник нельзя закрепить или planned repro не различает альтернативы. Такой возврат не равен отказу от коррекции: он не позволяет потратить доверие читателя на преждевременный verdict. Для P117 это единственный полезный критерий hand-off — вопрос стал достаточно явным, чтобы его можно было отклонить по конкретной причине, а не по настроению.'),
|
||||
p('В карточке сознательно нет поля «срочно». Срочность принадлежит отдельной ситуации с доступным объектом, риском и полномочием на решение. В future-only плане она лишь подталкивает автора назвать correction раньше evidence. Если впоследствии появится причина ускорить работу, она должна быть записана в новом артефакте с собственной датой и границей. Ноябрьский сценарий не может получить эту причину задним числом.'),
|
||||
h2('Порядок корректирующей передачи'),
|
||||
ol(['Зафиксировать, что P117 является future-only сценарием: editor date 2026-07-31, plan date 2027-11, source cutoff 2026-07-31.', 'Описать scope именованными question fields и не добавлять название, цитату или оценку несуществующего старого материала.', 'Проверить, что claim и condition имеют ids, а их содержание, версия и применимость остаются <code>not-collected</code>.', 'Оставить evidence неполученным и допустить только immutable version либо dated snapshot как будущую форму привязки.', 'Оставить repro в <code>not-run / not-injected</code>, correction/outcome — <code>not-declared</code>, recipient/handoff — <code>not-assigned / not-created</code>.', 'Передать <code>synthetic-plan-hand-off</code> как запрос на новый authorised scope, не как задачу, приказ, публикацию или подтверждённый результат.']),
|
||||
console.log(expand.phase, expand.reason);
|
||||
console.log(switchPhase.phase, switchPhase.reason);
|
||||
// unsafe new-writer-has-no-compatible-reader
|
||||
// switch both-readers-and-writers-understand-format`),
|
||||
h2('Порядок безопасной миграции'),
|
||||
ol([
|
||||
'Запишите старую и новую форму данных, а также кто читает и кто пишет каждую форму. Не начинайте с SQL-файла.',
|
||||
'Проверьте DDL на блокировки, размер таблицы, транзакцию и план восстановления. Для production-объёма используйте копию или staging с похожими данными.',
|
||||
'Добавьте новую форму без требования, которое сломает старый binary. Сборка приложения должна проходить до переключения reader.',
|
||||
'Включите двойную запись или backfill с идемпотентными пачками. Сверяйте количество и контрольные значения старой и новой формы.',
|
||||
'Переведите чтение на новую форму с fallback. Наблюдайте ошибки, latency, lock wait и долю чтения старой колонки.',
|
||||
'Удаляйте старую форму отдельным изменением после окна наблюдения и проверяемого сигнала, что rollback-путь больше не нужен.',
|
||||
]),
|
||||
h2('Почему rollback не равен обратной миграции'),
|
||||
p('Откат приложения возвращает код, но не обязательно возвращает схему. Если новый код записал только <code>display_name</code>, старый reader без fallback увидит пустоту. Обратная миграция DDL может быть дорогой и потерять информацию при преобразовании типа. Поэтому rollback-путь проектируют до switch: старый reader должен продолжать работать на данных, созданных новым writer.'),
|
||||
p('Тестировать нужно не только финальное состояние. Нужны состояния после expand, после частичного dual write и после остановки backfill. В каждом состоянии старый и новый binary должны иметь понятное поведение. Такой набор дороже одного smoke test, но дешевле восстановления после того, как несовместимость попала в основную таблицу.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('P117 не проверяет факты о прошлых публикациях и не защищает читателя от всех неверных советов. Он не открывает архив, registry, README, articles.json, очередь, app, package, сеть, документацию, журнал тестов или production. Он не назначает owner, не ставит срок и не выбирает платформу для дальнейшей работы. Источники ниже нужны лишь для словаря происхождения, изменения конфигурации и точной записи даты. Они не утверждают, что в данном выпуске был совет, спор, коррекция или hand-off.'),
|
||||
p('Следующий шаг должен быть маленьким и отдельно разрешённым: принять один named question field как предмет исследования, определить доступный immutable source, описать возможный repro без результата и только затем решить, нужна ли коррекция. Если полномочия, объект или evidence не появляются, корректный ответ — оставить hand-off не созданным. Это не незавершённость текста; это честная граница между планом и действием. P117 не объявляет старый материал ошибочным и не обещает, что повторный тест когда-либо будет завершён.'),
|
||||
], commonRefs);
|
||||
p('Пример не учитывает конкретные lock mode PostgreSQL, репликацию, триггеры, ORM, партиционирование и размер WAL. Документация версии 16 — источник терминов, а не разрешение выполнить операцию на вашей базе. Учебные имена колонок не должны копироваться без проверки нагрузки и индексов.'),
|
||||
p('Следующий шаг — выбрать одну миграцию и заполнить compatibility matrix для старого/нового reader и writer, а затем проиграть остановку на каждой фазе. Если нет состояния, в котором старый код безопасно читает новую запись, сначала исправьте контракт и только потом пишите DDL.'),
|
||||
], migrationRefs);
|
||||
|
||||
const retryRefs = [
|
||||
{ key: 'http', use: 'Разделяет свойства методов и идемпотентность, необходимые для решения, допустим ли повтор запроса.', boundary: 'Не задаёт политику retry конкретного клиента, backoff и максимальное число попыток.' },
|
||||
{ key: 'retryStatus', use: 'Фиксирует статус 429 Too Many Requests и место сигнала о перегрузке сервера.', boundary: 'Не говорит, какой delay выбрать и можно ли повторять конкретную операцию.' },
|
||||
];
|
||||
|
||||
const mechanism = revision({
|
||||
slug: 'editorial-2027-11-mechanism-mistakes-revisions',
|
||||
title: 'Retry без шторма: backoff, jitter и идемпотентность',
|
||||
categories: ['HTTP', 'Надёжность'],
|
||||
cover: '/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg',
|
||||
excerpt: 'Почему повтор не лечит любой timeout: связываем статус, идемпотентность, экспоненциальную задержку и предел нагрузки.',
|
||||
readingMinutes: 15,
|
||||
}, [
|
||||
p('Проблема видна как лавина: один upstream отвечает 503 или 429, а несколько клиентов одновременно повторяют запрос. Цена — усилить перегрузку именно в момент восстановления, увеличить latency и получить каскад отказов. Без различия между безопасным чтением и записью retry превращается в генератор дублей.'),
|
||||
p('Причина — считать повтор одной настройкой «три попытки». Правильное решение зависит от HTTP-метода, кода ответа, идемпотентности операции, Retry-After, текущего deadline и нагрузки. Экспоненциальная задержка уменьшает частоту, а jitter раздвигает одинаковые моменты старта; ни одна из них не делает небезопасную запись безопасной.'),
|
||||
h2('Сначала право на повтор'),
|
||||
p('GET обычно проектируют как идемпотентное чтение, но серверная реализация и внешние побочные эффекты всё равно требуют проверки. POST может создать ресурс повторно. Для такой операции нужен idempotency key и серверное хранение результата, либо клиент должен получить способ запросить состояние операции. Timeout означает неизвестный результат, а не подтверждённый отказ.'),
|
||||
p('Статус 429 сообщает о частоте запросов, но не выбирает за клиента точный алгоритм. 503 может означать временную недоступность, но повтор с коротким интервалом усугубит проблему. 400 обычно не меняется от повтора. Политика должна принимать method, status, наличие Retry-After и остаток deadline, а не только boolean «network error».'),
|
||||
table('Решение о повторе', ['Сигнал', 'Повтор', 'Задержка', 'Риск'], [
|
||||
['429 + Retry-After', 'только если операция допустима', 'не раньше указанного времени', 'клиенты проснутся одновременно'],
|
||||
['503 без Retry-After', 'ограниченно', 'backoff + jitter', 'перегрузить восстанавливающийся upstream'],
|
||||
['timeout GET', 'возможен', 'остаток deadline', 'ответ мог быть готов на сервере'],
|
||||
['timeout POST', 'только с ключом/проверкой', 'короткий controlled retry', 'дублирование записи'],
|
||||
['400/401/403', 'нет', 'не нужна', 'повтор маскирует дефект входа или права'],
|
||||
]),
|
||||
h2('Экспонента не должна расти бесконечно'),
|
||||
p('Базовая формула <code>min(cap, base × 2^attempt)</code> ограничивает задержку сверху. Jitter добавляет случайное смещение, чтобы тысячи клиентов не повторили в одну миллисекунду. Но общий deadline должен иметь приоритет: если до его конца осталось 40 мс, не имеет смысла ждать 500 мс ради следующей попытки. Операция завершается, а причина сохраняется.'),
|
||||
p('Случайность нужно добавлять так, чтобы наблюдение оставалось возможным. Логируйте номер попытки, рассчитанную задержку, статус и остаток deadline, но не секреты и полное тело запроса. Для тестов используйте переданный генератор случайных чисел или фиксированный jitter. Иначе тест будет иногда падать, а реальная политика останется непроверяемой.'),
|
||||
figure('/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg', 'Матрица retry: метод и статус задают право на повтор, затем применяются deadline, backoff и jitter; неизвестный результат записи требует идемпотентного ключа.', 'Схема отделяет решение о повторе от расчёта задержки. Красная граница означает, что backoff не исправляет небезопасную семантику операции.'),
|
||||
h2('Runnable-пример: рассчитать ограниченную задержку'),
|
||||
p('Функция получает номер попытки, базовую задержку, cap и учебный jitter. Она возвращает экспоненциальную часть и итог. В реальном клиенте jitter обычно генерируется отдельно и учитывается в deadline; здесь он передан числом, чтобы результат был воспроизводимым. На третьей попытке с base 100 и cap 1000 экспонента равна 800.'),
|
||||
code(`import { calculateRetryDelay } from './upgrade-2027-11.mjs';
|
||||
|
||||
const third = calculateRetryDelay({
|
||||
attempt: 3,
|
||||
baseMs: 100,
|
||||
capMs: 1000,
|
||||
jitterMs: 37,
|
||||
});
|
||||
const invalid = calculateRetryDelay({
|
||||
attempt: -1,
|
||||
baseMs: 100,
|
||||
capMs: 1000,
|
||||
});
|
||||
|
||||
console.log(third.ok, third.exponentialMs, third.delayMs);
|
||||
console.log(invalid.ok, invalid.reason);
|
||||
// true 800 837
|
||||
// false retry-input-invalid`),
|
||||
h2('Порядок настройки retry'),
|
||||
ol([
|
||||
'Для каждого метода выпишите побочные эффекты и способ узнать результат после timeout. Без этого число попыток не имеет смысла.',
|
||||
'Составьте таблицу статусов: что можно повторять, какой сигнал приходит от сервера и когда нужно остановиться.',
|
||||
'Передавайте абсолютный deadline или остаток времени во все попытки. Не выдавайте каждой попытке новый полный бюджет.',
|
||||
'Задайте base, cap и предел попыток, затем добавьте jitter. Проверьте формулу на нулевой, первой и предельной попытке.',
|
||||
'Обработайте Retry-After как верхнюю границу политики сервера, но всё равно сравните её с deadline.',
|
||||
'Нагрузочным тестом проверьте восстановление upstream: retry не должен создавать вторую волну запросов быстрее исходной.',
|
||||
]),
|
||||
h2('Идемпотентный ключ — это не request-id'),
|
||||
p('Request-id помогает найти попытку в логах, но сам по себе не говорит серверу, что две попытки означают одну операцию. Idempotency key должен быть связан с семантикой команды, сроком хранения и результатом. Сервер обязан решить, что вернуть при повторе с тем же ключом и другим телом. Это часть контракта, а не случайное поле заголовка.'),
|
||||
p('Даже при ключе остаются границы: сбой между записью и сохранением результата, истечение TTL, разные пользователи и смена версии схемы. Поэтому ключ не отменяет тесты повторной доставки и проверку состояния. Он даёт серверу возможность дедуплицировать операцию, но не обещает успешный outcome.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Расчёт задержки не реализует случайный генератор, circuit breaker, rate limit и очередь. RFC 6585 описывает статус 429, но не определяет вашу политику. Примеры чисел учебные и не подходят для копирования без измерения downstream и общего deadline.'),
|
||||
p('Следующий шаг — выбрать один endpoint, описать повтор для каждого метода и прогнать искусственный 429/503 с фиксированным временем. Отдельно проверьте timeout POST: повтор должен либо использовать idempotency key, либо перейти к запросу статуса, а не автоматически создать вторую запись.'),
|
||||
], retryRefs);
|
||||
|
||||
const incidentRefs = [
|
||||
{ key: 'nistIncident', use: 'Даёт структуру обработки инцидента: подготовка, обнаружение/анализ, containment, eradication/recovery и post-incident activity.', boundary: 'Не задаёт вашу архитектуру, severity thresholds, on-call график и допустимое действие для конкретной системы.' },
|
||||
{ key: 'http', use: 'Помогает различать HTTP-статус, метод и сетевой сбой при сборе первичного симптома.', boundary: 'Не является runbook и не заменяет метрики, логи, traces и проверку конкретного сервиса.' },
|
||||
];
|
||||
|
||||
const field = revision({
|
||||
slug: 'editorial-2027-11-field-mistakes-revisions',
|
||||
title: 'Incident runbook: от симптома до rollback без догадок',
|
||||
categories: ['Надёжность', 'Эксплуатация'],
|
||||
cover: '/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg',
|
||||
excerpt: 'Полевой маршрут инцидента: зафиксировать симптом, отделить влияние от причины, выбрать обратимое действие и проверить восстановление.',
|
||||
readingMinutes: 16,
|
||||
}, [
|
||||
p('Проблема во время инцидента — не отсутствие идей, а избыток неподтверждённых объяснений. «Сервис упал после релиза» смешивает время, причинность и действие. Цена — менять несколько компонентов сразу, терять baseline и не понимать, что действительно вернуло доступность.'),
|
||||
p('Причина — runbook часто написан как список команд без условий остановки. В нём нет точного симптома, границы влияния, безопасного rollback и проверки результата. Рабочая инструкция начинается с наблюдаемого сигнала, запрещает опасные действия до сбора фактов и оставляет короткую петлю: измерить, изменить, проверить, зафиксировать.'),
|
||||
h2('Симптом не является причиной'),
|
||||
p('Статус 500, рост latency и очередь сообщений — разные наблюдения. Они могут иметь общий корень, а могут быть независимыми последствиями. Первый экран runbook должен попросить время начала, affected endpoint, долю ошибок, baseline и scope. Запись «всё медленно» не позволяет выбрать действие или оценить улучшение.'),
|
||||
p('Причину формулируйте как гипотезу с проверкой: «после изменения лимита pool выросло ожидание соединения; подтверждение — метрика pool wait и сравнение с предыдущим окном». Гипотеза может не подтвердиться. Runbook должен описывать и такой исход, иначе оператор будет подгонять данные под первую версию.'),
|
||||
table('Карточка первичного сигнала', ['Поле', 'Пример', 'Зачем нужно', 'Ошибка формулировки'], [
|
||||
['Время', '14:05 UTC ± 5 мин', 'сопоставить deploy и метрики', '«сегодня»'],
|
||||
['Scope', 'POST /payments, region EU', 'не трогать здоровый трафик', '«весь сервис»'],
|
||||
['Симптом', '5xx 8%, p95 1.8s', 'измерить baseline и эффект', '«сломалось»'],
|
||||
['Гипотеза', 'pool wait вырос', 'выбрать проверку', 'сразу назвать виновника'],
|
||||
['Действие', 'rollback flag X', 'изменить один рычаг', 'перезапустить всё'],
|
||||
['Проверка', '5xx < 1% 10 мин', 'закрыть loop', '«кажется лучше»'],
|
||||
]),
|
||||
h2('Сначала ограничить blast radius'),
|
||||
p('Если изменение затронуло часть трафика, безопаснее уменьшить scope, чем сразу исправлять все слои. Отключение feature flag, остановка нового consumer или перевод небольшой доли на старый код дают обратимый шаг. Перезапуск без измерения может убрать симптом на минуту и стереть следы причины.'),
|
||||
p('Rollback тоже имеет условия. Он безопасен, если старая версия читает текущую схему и понимает созданные события. Если недавно была миграция, сначала проверьте compatibility matrix. Во время incident нельзя полагаться на память о порядке выката: runbook должен содержать команду, ожидаемый эффект, риск и способ вернуть действие.'),
|
||||
figure('/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg', 'Петля incident runbook: симптом и scope ведут к проверке гипотезы, одному обратимому действию и измерению восстановления до закрытия инцидента.', 'Диаграмма показывает короткий рабочий цикл. Каждое действие имеет условие отката и отдельную проверку результата.'),
|
||||
h2('Runnable-пример: классифицируем первичный сигнал'),
|
||||
p('Функция получает HTTP status, latency и error rate, затем применяет два явных порога. Она не решает, кто виноват и какой rollback безопасен. Зато оператор может проверить, что одинаковые входы дают одинаковую срочность, а порог ошибки имеет приоритет над вторичным latency-сигналом.'),
|
||||
code(`import { classifyIncidentSignal } from './upgrade-2027-11.mjs';
|
||||
|
||||
const signal = classifyIncidentSignal({
|
||||
status: 503,
|
||||
latencyMs: 820,
|
||||
errorRate: 0.08,
|
||||
latencyLimitMs: 1000,
|
||||
errorLimit: 0.05,
|
||||
});
|
||||
const slow = classifyIncidentSignal({
|
||||
status: 200,
|
||||
latencyMs: 1400,
|
||||
errorRate: 0.01,
|
||||
});
|
||||
|
||||
console.log(signal.severity, signal.reason);
|
||||
console.log(slow.severity, slow.reason);
|
||||
// high availability-or-error-threshold
|
||||
// medium latency-threshold`),
|
||||
h2('Порядок действий во время инцидента'),
|
||||
ol([
|
||||
'Запишите timestamp, scope и один измеримый симптом. Сохраните ссылку на dashboard и исходное окно сравнения.',
|
||||
'Проверьте, затронуты ли все регионы, методы и версии. Ограничьте воздействие, если есть безопасный flag или traffic split.',
|
||||
'Сформулируйте одну гипотезу и одну проверку. Не меняйте конфигурацию до того, как знаете, какой сигнал должен измениться.',
|
||||
'Выберите одно обратимое действие и запишите ожидаемый эффект, риск и условие возврата. Не запускайте пачку независимых исправлений.',
|
||||
'Подождите заранее заданное окно и сравните error rate, latency, saturation и бизнес-сигнал. «Команда завершилась» не означает восстановление.',
|
||||
'Зафиксируйте итог, оставшиеся риски и следующий diagnostic item. После стабилизации сохраните факты до очистки временных изменений.',
|
||||
]),
|
||||
h2('Rollback и восстановление — разные события'),
|
||||
p('Rollback возвращает конфигурацию или binary, а recovery означает, что система снова выполняет допустимую работу и данные согласованы. Можно откатить flag, но оставить очередь сообщений, двойные записи или повреждённый кэш. Поэтому после изменения нужно проверять не только 5xx, но и отставание очереди, успешность операций и консистентность данных.'),
|
||||
p('Закрывать инцидент сразу после падения error rate тоже рискованно. Ошибка могла уйти на другой endpoint, а пользовательская операция остаться незавершённой. Минимальное окно наблюдения выбирается по интервалу метрики и характеру нагрузки. В runbook лучше явно написать «не закрывать, пока X и Y не стабильны N минут», чем оставлять эту оценку оператору в самый шумный момент.'),
|
||||
h2('Ограничения и следующий шаг'),
|
||||
p('Классификатор не хранит timeline, не отправляет уведомления и не знает бизнес-критичность endpoint. NIST SP 800-61 даёт общую дисциплину incident handling, но не заменяет локальную матрицу severity и права на rollback. Thresholds в примере учебные; их нужно получить из SLO и baseline.'),
|
||||
p('Следующий шаг — взять один частый alert и превратить его в карточку с симптомом, scope, гипотезой, одним действием и проверкой восстановления. Затем проиграть runbook на staging с намеренно созданным 503 и убедиться, что оператор может остановиться на каждом небезопасном шаге.'),
|
||||
], incidentRefs);
|
||||
|
||||
export const revisions = deepFreeze([practice, mechanism, field]);
|
||||
|
||||
if (process.argv.includes('--verify-fixture')) {
|
||||
const result = runAdviceRevisionFixture();
|
||||
if (!result.accepted) process.exitCode = 1;
|
||||
process.stdout.write(`PASS fixture: ${result.passed}/${result.total} assertions\n`);
|
||||
} else if (process.argv.includes('--print-revisions')) {
|
||||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||||
export function runReliabilityFixture() {
|
||||
const cases = [
|
||||
['migration-rejects-unsafe-writer', classifyMigrationStep({ newWrites: true }).phase, 'unsafe'],
|
||||
['migration-detects-switch', classifyMigrationStep({ oldReads: true, newReads: true, oldWrites: true, newWrites: true }).phase, 'switch'],
|
||||
['retry-caps-exponential', calculateRetryDelay({ attempt: 5, baseMs: 100, capMs: 1000 }).exponentialMs, 1000],
|
||||
['retry-rejects-negative-attempt', calculateRetryDelay({ attempt: -1 }).reason, 'retry-input-invalid'],
|
||||
['incident-prioritizes-error', classifyIncidentSignal({ status: 503, latencyMs: 50, errorRate: 0.1 }).severity, 'high'],
|
||||
['incident-detects-latency', classifyIncidentSignal({ status: 200, latencyMs: 1200, errorRate: 0.01 }).severity, 'medium'],
|
||||
];
|
||||
const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected }));
|
||||
return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks });
|
||||
}
|
||||
|
||||
export function verifyRevisionsAgainstFixture() {
|
||||
const fixture = runReliabilityFixture();
|
||||
const articleChecks = revisions.map((item) => {
|
||||
const text = bodyText(item.contentHtml);
|
||||
return text.length >= 5000 && text.length <= 15000 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900));
|
||||
});
|
||||
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
|
||||
}
|
||||
|
||||
if (process.argv.includes('--verify-fixture')) {
|
||||
const result = verifyRevisionsAgainstFixture();
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
if (!result.accepted) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|
||||
|
||||
Reference in New Issue
Block a user