Files
progcode/web/scripts/upgrade-2024-09.mjs
T
huncode c8bb90f435
Build and deploy / deploy (push) Successful in 17s
revise September 2024 ADR decision articles
2026-07-31 16:22:39 +03:00

975 lines
87 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'Michael Nygard: Documenting Architecture Decisions, snapshot 22.08.2024',
url: 'https://web.archive.org/web/20240822200738id_/https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions',
note: 'Датированный снимок первичного текста автора ADR: короткий record с context, decision, status и consequences, а также сохранение старого record при supersede. Snapshot закрепляет историческую версию до сентября 2024; это формат и аргументация, а не policy конкретной команды.',
},
{
title: 'Nygard ADR template mirror, immutable commit 93f7e465, 18.10.2023',
url: 'https://github.com/architecture-decision-record/architecture-decision-record/blob/93f7e465ad32f37091508674c5cba838c099b08a/locales/en/templates/decision-record-template-by-michael-nygard/index.md',
note: 'Неизменяемый Git snapshot template, который явно ссылается на формат Michael Nygard и фиксирует Title, Status, Context, Decision и Consequences. Это cross-check формы, а не первичный текст и не обязательный process команды.',
},
{
title: 'MADR 3.0.0 template, immutable commit 97fb8ed, 09.10.2022',
url: 'https://raw.githubusercontent.com/adr/madr/97fb8edec60b8dc70b8166ef62de34c4e26b46c0/template/adr-template.md',
note: 'Неизменяемый первичный артефакт Markdown ADR, доступный до сентября 2024. В template есть status, date, deciders, decision drivers, options, outcome, consequences и validation. Это пример формы, не обязательный набор полей и не измерение качества решения.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const MODEL_VERSION = 'synthetic-adr-decision-model-v1';
const INPUT_KIND = 'synthetic-adr-decision-input-v1';
const REPORT_KIND = 'synthetic-adr-decision-report-v1';
const DRAFT_KIND = 'synthetic-adr-record-draft-v1';
const REASSESSMENT_KIND = 'synthetic-adr-reassessment-v1';
const SYNTHETIC_SCOPE = 'p79-adr-decisions-2024-09';
const MODEL_LIMIT = 'fixed-in-memory-synthetic-adr-model-no-files-no-git-no-network-no-ci-no-production-no-interviews-no-metrics';
const SCORE_WEIGHTS = Object.freeze({
reversibility: 2,
evidenceFit: 3,
constraintFit: 4,
operatingCost: 1,
});
const CONTEXT_KEYS = Object.freeze([
'problem', 'cost', 'owner', 'assumptions', 'constraints', 'relatedArtifacts',
]);
const ALTERNATIVE_KEYS = Object.freeze([
'id', 'label', 'reversibility', 'evidenceFit', 'constraintFit', 'operatingCost', 'tradeoff',
]);
const SCORED_ALTERNATIVE_KEYS = Object.freeze([...ALTERNATIVE_KEYS, 'score']);
const DECISION_KEYS = Object.freeze(['id', 'title', 'score', 'rationale']);
const REASSESSMENT_RULE_KEYS = Object.freeze(['reviewBy', 'signals', 'priorAdr', 'expectedAction']);
const EVIDENCE_KEYS = Object.freeze([
'source', 'files', 'git', 'network', 'ci', 'production', 'interviews', 'metrics',
]);
const REPORT_KEYS = Object.freeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'scope',
'caseId', 'caseLabel', 'context', 'alternatives', 'scoreModel', 'decision',
'reassessmentRule', 'evidence', 'productionEffect',
]);
const DRAFT_KEYS = Object.freeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit',
'sourceReport', 'status', 'title', 'decision', 'actions', 'adrFile', 'code', 'git',
'files', 'network', 'ci', 'production', 'interviews', 'metrics', 'productionEffect',
]);
const REASSESSMENT_KEYS = Object.freeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit',
'caseId', 'decision', 'reviewBy', 'signals', 'previousAdr', 'previousStatus',
'successorStatus', 'action', 'adrFile', 'code', 'git', 'files', 'network', 'ci',
'production', 'metrics', 'productionEffect',
]);
const FIXED_CASES = Object.freeze({
'fixed-session-cache-boundary-v1': Object.freeze({
label: 'synthetic internal read path with a bounded freshness question',
context: Object.freeze({
problem: 'A hypothetical internal read path repeats source calls although its synthetic contract permits a bounded freshness interval.',
cost: 'Choosing a cache without a bound can hide stale data, add an ownerless invalidation path, and make rollback unclear.',
owner: 'synthetic-application-owner',
assumptions: Object.freeze([
'synthetic-data-class-is-not-personal',
'synthetic-source-contract-has-one-known-read-shape',
]),
constraints: Object.freeze([
'freshness-bound-must-be-visible',
'no-shared-state-is-created-by-the-example',
'rollback-must-return-to-direct-read-in-the-model',
]),
relatedArtifacts: Object.freeze([
'synthetic-read-contract-v1',
'synthetic-cache-boundary-checklist-v1',
]),
}),
alternatives: Object.freeze([
Object.freeze({
id: 'direct-source-read',
label: 'read directly from the synthetic source',
reversibility: 3,
evidenceFit: 1,
constraintFit: 1,
operatingCost: 1,
tradeoff: 'keeps state minimal but leaves repeated synthetic source work unresolved',
}),
Object.freeze({
id: 'bounded-bff-cache',
label: 'keep a bounded cache at the synthetic boundary',
reversibility: 2,
evidenceFit: 3,
constraintFit: 3,
operatingCost: 2,
tradeoff: 'adds expiry ownership but makes the freshness bound explicit and locally removable',
}),
Object.freeze({
id: 'shared-cross-service-cache',
label: 'introduce a synthetic shared cache',
reversibility: 1,
evidenceFit: 2,
constraintFit: 2,
operatingCost: 3,
tradeoff: 'can centralize reuse but enlarges ownership and invalidation boundaries',
}),
]),
expectedDecision: 'bounded-bff-cache',
expectedScore: 23,
reassessmentRule: Object.freeze({
reviewBy: 'synthetic-2025-03-31',
signals: Object.freeze([
'synthetic-source-contract-changed',
'synthetic-freshness-bound-no-longer-holds',
'synthetic-cache-owner-is-unknown',
]),
priorAdr: 'none',
expectedAction: 'reconfirm-or-propose-a-successor-after-authorized-evidence',
}),
}),
'fixed-webhook-intent-v1': Object.freeze({
label: 'synthetic partner event with acknowledgement and duplicate-delivery constraints',
context: Object.freeze({
problem: 'A hypothetical partner event must receive a bounded acknowledgement while duplicate delivery remains possible in the synthetic contract.',
cost: 'Forwarding work synchronously can turn a downstream wait into acknowledgement failure; adding a durable path without ownership can leave unreconciled intent.',
owner: 'synthetic-integration-owner',
assumptions: Object.freeze([
'synthetic-event-id-is-available',
'synthetic-downstream-result-is-not-known-at-acknowledgement-time',
]),
constraints: Object.freeze([
'duplicate-handling-must-be-explicit',
'acknowledgement-and-business-completion-are-distinct',
'the-example-must-not-contact-a-partner-or-queue',
]),
relatedArtifacts: Object.freeze([
'synthetic-event-envelope-v1',
'synthetic-idempotency-checklist-v1',
]),
}),
alternatives: Object.freeze([
Object.freeze({
id: 'synchronous-forward',
label: 'forward the synthetic event before acknowledgement',
reversibility: 3,
evidenceFit: 1,
constraintFit: 1,
operatingCost: 1,
tradeoff: 'is small to describe but couples acknowledgement to downstream completion',
}),
Object.freeze({
id: 'durable-intent-with-worker',
label: 'record synthetic intent, then process it separately',
reversibility: 2,
evidenceFit: 3,
constraintFit: 3,
operatingCost: 2,
tradeoff: 'requires an owner for idempotency and recovery, while separating acknowledgement from completion',
}),
Object.freeze({
id: 'shared-lock-before-forward',
label: 'guard the synthetic event with a shared lock first',
reversibility: 1,
evidenceFit: 1,
constraintFit: 1,
operatingCost: 3,
tradeoff: 'adds coordination without proving that the actual duplicate boundary is resolved',
}),
]),
expectedDecision: 'durable-intent-with-worker',
expectedScore: 23,
reassessmentRule: Object.freeze({
reviewBy: 'synthetic-2025-03-31',
signals: Object.freeze([
'synthetic-event-identity-contract-changed',
'synthetic-recovery-owner-is-unknown',
'synthetic-acknowledgement-bound-changed',
]),
priorAdr: 'none',
expectedAction: 'reconfirm-or-propose-a-successor-after-authorized-evidence',
}),
}),
'fixed-report-export-reassessment-v1': Object.freeze({
label: 'synthetic report export that no longer fits a previously recorded synchronous boundary',
context: Object.freeze({
problem: 'A hypothetical report export has a previous synthetic ADR for a small synchronous path, while a declared input bound is now marked as no longer holding.',
cost: 'Editing the old record hides why the original choice was reasonable; changing code without a successor record leaves reviewers unable to compare rollback and ownership.',
owner: 'synthetic-reporting-owner',
assumptions: Object.freeze([
'synthetic-request-shape-has-expanded',
'synthetic-result-must-remain-visible-to-a-caller',
]),
constraints: Object.freeze([
'old-record-must-stay-readable',
'new-path-needs-a-named-status-contract',
'the-example-must-not-measure-real-runtime-or-export-data',
]),
relatedArtifacts: Object.freeze([
'synthetic-adr-0012-synchronous-export',
'synthetic-export-status-contract-v1',
]),
}),
alternatives: Object.freeze([
Object.freeze({
id: 'keep-synchronous-path',
label: 'keep the previously synthetic synchronous export path',
reversibility: 3,
evidenceFit: 1,
constraintFit: 1,
operatingCost: 2,
tradeoff: 'preserves the old path but ignores the declared synthetic drift signal',
}),
Object.freeze({
id: 'queued-export-with-status',
label: 'propose a synthetic queued export with visible status',
reversibility: 2,
evidenceFit: 3,
constraintFit: 3,
operatingCost: 2,
tradeoff: 'adds a status and recovery contract while separating completion from the initial request',
}),
Object.freeze({
id: 'unbounded-background-export',
label: 'move export to an unbounded synthetic background path',
reversibility: 1,
evidenceFit: 1,
constraintFit: 1,
operatingCost: 3,
tradeoff: 'removes the caller wait but leaves status, owner and recovery undefined',
}),
]),
expectedDecision: 'queued-export-with-status',
expectedScore: 23,
reassessmentRule: Object.freeze({
reviewBy: 'synthetic-2025-03-31',
signals: Object.freeze([
'synthetic-request-bound-no-longer-holds',
'synthetic-status-contract-is-missing',
'synthetic-code-link-no-longer-matches-declared-boundary',
]),
priorAdr: 'synthetic-adr-0012-synchronous-export',
expectedAction: 'propose-successor-and-link-supersession-only-after-acceptance',
}),
}),
});
const actionsByCase = Object.freeze({
'fixed-session-cache-boundary-v1': Object.freeze([
'record-problem-bound-and-cache-owner-in-a-proposed-adr',
'name-the-expiry-and-rollback-check-before-implementation',
'set-a-review-date-and-evidence-question-without-claiming-production-data',
]),
'fixed-webhook-intent-v1': Object.freeze([
'record-acknowledgement-duplicate-and-recovery-boundaries-in-a-proposed-adr',
'name-the-idempotency-owner-before-selecting-an-implementation',
'set-a-review-date-and-evidence-question-without-contacting-a-partner',
]),
'fixed-report-export-reassessment-v1': Object.freeze([
'record-the-drift-signal-and-old-decision-link-in-a-proposed-successor',
'preserve-the-old-record-until-the-successor-is-accepted',
'separate-status-contract-validation-from-any-runtime-change',
]),
});
function hasExactKeys(value, keys) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return (prototype === Object.prototype || prototype === null)
&& Object.keys(value).length === keys.length
&& keys.every((key) => Object.hasOwn(value, key));
}
function hasDenseArray(value) {
return Array.isArray(value)
&& Object.keys(value).length === value.length
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
}
function hasSameCanonicalJson(actual, expected) {
try {
return JSON.stringify(actual) === JSON.stringify(expected);
} catch {
return false;
}
}
function rejectReport(reason) {
return Object.freeze({
kind: REPORT_KIND,
accepted: false,
syntheticOnly: true,
reason,
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
});
}
function scoreAlternative(alternative) {
return (alternative.reversibility * SCORE_WEIGHTS.reversibility)
+ (alternative.evidenceFit * SCORE_WEIGHTS.evidenceFit)
+ (alternative.constraintFit * SCORE_WEIGHTS.constraintFit)
- (alternative.operatingCost * SCORE_WEIGHTS.operatingCost);
}
function rankedAlternatives(fixed) {
return Object.freeze(
fixed.alternatives
.map((alternative) => Object.freeze({ ...alternative, score: scoreAlternative(alternative) }))
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)),
);
}
function decisionFor(fixed) {
const alternatives = rankedAlternatives(fixed);
const chosen = alternatives[0];
if (!chosen || chosen.id !== fixed.expectedDecision || chosen.score !== fixed.expectedScore) {
throw new Error('fixed synthetic ADR case is inconsistent: ' + fixed.expectedDecision);
}
return Object.freeze({
alternatives,
decision: Object.freeze({
id: chosen.id,
title: chosen.label,
score: chosen.score,
rationale: 'highest fixed synthetic score under declared weights; not production evidence or a universal policy',
}),
});
}
export function createFixedSyntheticAdrInput(caseId) {
if (!Object.hasOwn(FIXED_CASES, caseId)) {
return Object.freeze({ kind: 'unknown-synthetic-adr-decision-input', synthetic: false, caseId });
}
return Object.freeze({
kind: INPUT_KIND,
synthetic: true,
modelVersion: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
mode: 'fixed-memory-only',
caseId,
});
}
/**
* Inspects one embedded fixed synthetic decision case. It does not read ADR
* files, source code, Git history, CI, network, production data, interviews
* or metrics. Scores are teaching literals with fixed weights, not a decision
* engine for an external team.
*/
export function inspectSyntheticAdrDecision(input) {
if (!input || input.synthetic !== true || input.kind !== INPUT_KIND) {
return rejectReport('synthetic-fixed-input-required');
}
const inputKeys = ['kind', 'synthetic', 'modelVersion', 'scope', 'mode', 'caseId'];
if (!hasExactKeys(input, inputKeys)) return rejectReport('unexpected-input-field');
if (input.modelVersion !== MODEL_VERSION) return rejectReport('unexpected-model-version');
if (input.scope !== SYNTHETIC_SCOPE) return rejectReport('unexpected-synthetic-scope');
if (input.mode !== 'fixed-memory-only') return rejectReport('fixed-memory-mode-required');
if (!Object.hasOwn(FIXED_CASES, input.caseId)) return rejectReport('unknown-fixed-synthetic-case');
const fixed = FIXED_CASES[input.caseId];
const outcome = decisionFor(fixed);
return Object.freeze({
kind: REPORT_KIND,
accepted: true,
syntheticOnly: true,
reason: 'fixed-synthetic-adr-case-evaluated',
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
scope: SYNTHETIC_SCOPE,
caseId: input.caseId,
caseLabel: fixed.label,
context: fixed.context,
alternatives: outcome.alternatives,
scoreModel: Object.freeze({
version: 'fixed-weighted-synthetic-score-v1',
weights: SCORE_WEIGHTS,
limit: 'weights-rank-only-embedded-cases-and-do-not-measure-real-value',
}),
decision: outcome.decision,
reassessmentRule: fixed.reassessmentRule,
evidence: Object.freeze({
source: 'embedded-fixed-synthetic-records-only',
files: 'not-read',
git: 'not-read',
network: 'not-used',
ci: 'not-run',
production: 'not-contacted',
interviews: 'not-read',
metrics: 'not-read',
}),
productionEffect: 'not-attempted',
});
}
function canonicalReportFor(caseId) {
return inspectSyntheticAdrDecision(createFixedSyntheticAdrInput(caseId));
}
function isCanonicalSyntheticReport(report) {
if (!hasExactKeys(report, REPORT_KEYS)
|| report.kind !== REPORT_KIND
|| report.accepted !== true
|| report.syntheticOnly !== true
|| report.reason !== 'fixed-synthetic-adr-case-evaluated'
|| report.modelVersion !== MODEL_VERSION
|| report.modelLimit !== MODEL_LIMIT
|| report.scope !== SYNTHETIC_SCOPE
|| !Object.hasOwn(FIXED_CASES, report.caseId)
|| report.caseLabel !== FIXED_CASES[report.caseId].label
|| report.productionEffect !== 'not-attempted'
|| !hasExactKeys(report.context, CONTEXT_KEYS)
|| !hasDenseArray(report.context.assumptions)
|| !hasDenseArray(report.context.constraints)
|| !hasDenseArray(report.context.relatedArtifacts)
|| !hasDenseArray(report.alternatives)
|| !report.alternatives.every((item) => hasExactKeys(item, SCORED_ALTERNATIVE_KEYS))
|| !hasExactKeys(report.scoreModel, ['version', 'weights', 'limit'])
|| !hasExactKeys(report.scoreModel.weights, ['reversibility', 'evidenceFit', 'constraintFit', 'operatingCost'])
|| !hasExactKeys(report.decision, DECISION_KEYS)
|| !hasExactKeys(report.reassessmentRule, REASSESSMENT_RULE_KEYS)
|| !hasDenseArray(report.reassessmentRule.signals)
|| !hasExactKeys(report.evidence, EVIDENCE_KEYS)) return false;
return hasSameCanonicalJson(report, canonicalReportFor(report.caseId));
}
function makeSyntheticAdrDraft(fresh) {
const actions = actionsByCase[fresh.caseId];
return Object.freeze({
kind: DRAFT_KIND,
accepted: true,
syntheticOnly: true,
reason: 'canonical-fixed-synthetic-adr-draft',
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
sourceReport: fresh,
status: 'proposed',
title: 'Synthetic ADR: ' + fresh.decision.title,
decision: fresh.decision,
actions,
adrFile: 'not-read-or-written',
code: 'not-read',
git: 'not-read',
files: 'not-read-or-written',
network: 'not-used',
ci: 'not-run',
production: 'not-contacted',
interviews: 'not-read',
metrics: 'not-read',
productionEffect: 'not-attempted',
});
}
function isCanonicalSyntheticDraft(draft) {
if (!hasExactKeys(draft, DRAFT_KEYS)
|| draft.kind !== DRAFT_KIND
|| draft.accepted !== true
|| draft.syntheticOnly !== true
|| draft.reason !== 'canonical-fixed-synthetic-adr-draft'
|| draft.modelVersion !== MODEL_VERSION
|| draft.modelLimit !== MODEL_LIMIT
|| draft.status !== 'proposed'
|| !hasDenseArray(draft.actions)
|| draft.adrFile !== 'not-read-or-written'
|| draft.code !== 'not-read'
|| draft.git !== 'not-read'
|| draft.files !== 'not-read-or-written'
|| draft.network !== 'not-used'
|| draft.ci !== 'not-run'
|| draft.production !== 'not-contacted'
|| draft.interviews !== 'not-read'
|| draft.metrics !== 'not-read'
|| draft.productionEffect !== 'not-attempted'
|| !isCanonicalSyntheticReport(draft.sourceReport)) return false;
const fresh = canonicalReportFor(draft.sourceReport.caseId);
return hasSameCanonicalJson(draft, makeSyntheticAdrDraft(fresh));
}
/**
* Produces a proposed ADR draft after re-reading a canonical in-memory report.
* It does not create a Markdown file, link a pull request, change source code
* or query external evidence.
*/
export function planSyntheticAdrRecord(report) {
if (!report || report.kind !== REPORT_KIND || report.syntheticOnly !== true || report.accepted !== true) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
if (!hasExactKeys(report, REPORT_KEYS)) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'unexpected-report-field', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
if (report.scope !== SYNTHETIC_SCOPE || !Object.hasOwn(FIXED_CASES, report.caseId)) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'untrusted-synthetic-report-scope', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
const fresh = canonicalReportFor(report.caseId);
if (!isCanonicalSyntheticReport(report) || !hasSameCanonicalJson(fresh, report)) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-fixed-record', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
return makeSyntheticAdrDraft(fresh);
}
/**
* Prepares a synthetic reassessment result for a canonical draft. A previous
* ADR becomes superseded only in the proposal text after a human accepts the
* successor; this function cannot edit an ADR, repository or issue.
*/
export function reassessSyntheticAdrRecord(draft) {
if (!isCanonicalSyntheticDraft(draft)) {
return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'canonical-synthetic-adr-draft-required', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
const rule = draft.sourceReport.reassessmentRule;
const hasPriorAdr = rule.priorAdr !== 'none';
return Object.freeze({
kind: REASSESSMENT_KIND,
accepted: true,
syntheticOnly: true,
reason: 'canonical-synthetic-reassessment-prepared',
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
caseId: draft.sourceReport.caseId,
decision: draft.decision,
reviewBy: rule.reviewBy,
signals: rule.signals,
previousAdr: rule.priorAdr,
previousStatus: hasPriorAdr ? 'accepted-until-a-human-accepts-the-successor' : 'no-prior-adr',
successorStatus: 'proposed-only',
action: rule.expectedAction,
adrFile: 'not-read-or-written',
code: 'not-read',
git: 'not-read',
files: 'not-read-or-written',
network: 'not-used',
ci: 'not-run',
production: 'not-contacted',
metrics: 'not-read',
productionEffect: 'not-attempted',
});
}
/**
* Discards only a canonical in-memory draft. It never changes an ADR file or
* an external state, so it is not a rollback procedure for a real system.
*/
export function discardSyntheticAdrDraft(draft) {
if (!isCanonicalSyntheticDraft(draft)) {
return Object.freeze({ discarded: false, syntheticOnly: true, reason: 'no-canonical-synthetic-adr-draft' });
}
return Object.freeze({
discarded: true,
syntheticOnly: true,
reason: 'synthetic-adr-draft-discarded',
adrFile: 'not-read-or-written',
code: 'not-read',
git: 'not-read',
files: 'not-read-or-written',
ci: 'not-run',
network: 'not-used',
productionEffect: 'not-attempted',
});
}
export function runAdrDecisionsFixture() {
const cache = inspectSyntheticAdrDecision(createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'));
const webhook = inspectSyntheticAdrDecision(createFixedSyntheticAdrInput('fixed-webhook-intent-v1'));
const exportPath = inspectSyntheticAdrDecision(createFixedSyntheticAdrInput('fixed-report-export-reassessment-v1'));
const nonSynthetic = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), synthetic: false });
const unexpectedInput = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), owner: 'not-read' });
const fileInput = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), file: 'docs/adr/001.md' });
const gitInput = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), git: 'main' });
const networkInput = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), network: 'https://not-used.example' });
const ciInput = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), ci: 'pipeline-url' });
const productionInput = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), production: true });
const wrongVersion = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), modelVersion: 'v99' });
const wrongScope = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), scope: 'another-scope' });
const wrongMode = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), mode: 'read-git-history' });
const unknownCase = inspectSyntheticAdrDecision({ ...createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'), caseId: 'real-team-decision' });
const cacheDraft = planSyntheticAdrRecord(cache);
const webhookDraft = planSyntheticAdrRecord(webhook);
const exportDraft = planSyntheticAdrRecord(exportPath);
const reassessment = reassessSyntheticAdrRecord(exportDraft);
const forgedDecision = planSyntheticAdrRecord({ ...cache, decision: { ...cache.decision, id: 'shared-cross-service-cache' } });
const forgedScore = planSyntheticAdrRecord({
...webhook,
alternatives: webhook.alternatives.map((alternative) => (
alternative.id === webhook.decision.id ? { ...alternative, score: alternative.score + 1 } : alternative
)),
});
const unexpectedReport = planSyntheticAdrRecord({ ...cache, repositoryPath: '/not-read' });
const sparseAlternatives = planSyntheticAdrRecord({ ...cache, alternatives: new Array(cache.alternatives.length) });
const nestedUnknownAlternative = planSyntheticAdrRecord({
...cache,
alternatives: cache.alternatives.map((alternative) => (
alternative.id === cache.decision.id ? { ...alternative, hiddenEvidence: 'not-accepted' } : alternative
)),
});
const cyclicReport = { ...cache, evidence: { ...cache.evidence } };
cyclicReport.evidence.source = cyclicReport.evidence;
const cyclicReportPlan = planSyntheticAdrRecord(cyclicReport);
const discarded = discardSyntheticAdrDraft(cacheDraft);
const sparseDraft = discardSyntheticAdrDraft({ ...cacheDraft, actions: new Array(cacheDraft.actions.length) });
const unexpectedDraft = discardSyntheticAdrDraft({ ...cacheDraft, author: 'not-accepted' });
const forgedReassessment = reassessSyntheticAdrRecord({ ...exportDraft, status: 'accepted' });
const reportAsDraft = discardSyntheticAdrDraft(cache);
return Object.freeze({
assertions: Object.freeze({
acceptsClosedCacheCase: cache.accepted === true && cache.decision.id === 'bounded-bff-cache' && cache.decision.score === 23,
acceptsClosedWebhookCase: webhook.accepted === true && webhook.decision.id === 'durable-intent-with-worker' && webhook.decision.score === 23,
acceptsReassessmentCase: exportPath.accepted === true && exportPath.decision.id === 'queued-export-with-status' && exportPath.reassessmentRule.priorAdr === 'synthetic-adr-0012-synchronous-export',
keepsScoresSyntheticOnly: cache.scoreModel.limit.includes('not-measure-real-value') && cache.evidence.metrics === 'not-read' && cache.productionEffect === 'not-attempted',
preservesDenseAlternatives: hasDenseArray(cache.alternatives) && cache.alternatives.length === 3 && cache.alternatives.every((alternative) => Object.keys(alternative).length === SCORED_ALTERNATIVE_KEYS.length),
preservesDenseReassessmentSignals: hasDenseArray(exportPath.reassessmentRule.signals) && exportPath.reassessmentRule.signals.length === 3,
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-fixed-input-required',
rejectsUnexpectedInputField: unexpectedInput.accepted === false && unexpectedInput.reason === 'unexpected-input-field',
rejectsFileLikeInput: fileInput.accepted === false && fileInput.reason === 'unexpected-input-field',
rejectsGitLikeInput: gitInput.accepted === false && gitInput.reason === 'unexpected-input-field',
rejectsNetworkLikeInput: networkInput.accepted === false && networkInput.reason === 'unexpected-input-field',
rejectsCiLikeInput: ciInput.accepted === false && ciInput.reason === 'unexpected-input-field',
rejectsProductionLikeInput: productionInput.accepted === false && productionInput.reason === 'unexpected-input-field',
rejectsWrongModelVersion: wrongVersion.accepted === false && wrongVersion.reason === 'unexpected-model-version',
rejectsWrongScope: wrongScope.accepted === false && wrongScope.reason === 'unexpected-synthetic-scope',
rejectsReadMode: wrongMode.accepted === false && wrongMode.reason === 'fixed-memory-mode-required',
rejectsUnknownFixedCase: unknownCase.accepted === false && unknownCase.reason === 'unknown-fixed-synthetic-case',
createsOnlyProposedCacheDraft: cacheDraft.accepted === true && cacheDraft.status === 'proposed' && cacheDraft.actions.includes('name-the-expiry-and-rollback-check-before-implementation'),
createsOnlyProposedWebhookDraft: webhookDraft.accepted === true && webhookDraft.status === 'proposed' && webhookDraft.actions.includes('name-the-idempotency-owner-before-selecting-an-implementation'),
createsOnlyProposedSuccessorDraft: exportDraft.accepted === true && exportDraft.actions.includes('preserve-the-old-record-until-the-successor-is-accepted'),
draftHasNoExternalEffect: cacheDraft.adrFile === 'not-read-or-written' && cacheDraft.git === 'not-read' && cacheDraft.ci === 'not-run' && cacheDraft.production === 'not-contacted',
preparesSupersedeWithoutEditingHistory: reassessment.accepted === true && reassessment.previousStatus === 'accepted-until-a-human-accepts-the-successor' && reassessment.successorStatus === 'proposed-only',
rejectsForgedDecision: forgedDecision.accepted === false && forgedDecision.reason === 'report-does-not-match-fixed-record',
rejectsForgedScore: forgedScore.accepted === false && forgedScore.reason === 'report-does-not-match-fixed-record',
rejectsUnknownReportField: unexpectedReport.accepted === false && unexpectedReport.reason === 'unexpected-report-field',
rejectsSparseReportArray: sparseAlternatives.accepted === false && sparseAlternatives.reason === 'report-does-not-match-fixed-record',
rejectsUnknownNestedAlternativeField: nestedUnknownAlternative.accepted === false && nestedUnknownAlternative.reason === 'report-does-not-match-fixed-record',
rejectsCyclicReportWithoutThrowing: cyclicReportPlan.accepted === false && cyclicReportPlan.reason === 'report-does-not-match-fixed-record',
discardsOnlyCanonicalDraft: discarded.discarded === true && discarded.adrFile === 'not-read-or-written' && discarded.git === 'not-read',
rejectsSparseDraftArray: sparseDraft.discarded === false && sparseDraft.reason === 'no-canonical-synthetic-adr-draft',
rejectsUnknownDraftField: unexpectedDraft.discarded === false && unexpectedDraft.reason === 'no-canonical-synthetic-adr-draft',
rejectsForgedReassessmentDraft: forgedReassessment.accepted === false && forgedReassessment.reason === 'canonical-synthetic-adr-draft-required',
reportCannotBeDiscardedAsDraft: reportAsDraft.discarded === false && reportAsDraft.reason === 'no-canonical-synthetic-adr-draft',
}),
samples: Object.freeze({
cache, webhook, exportPath, nonSynthetic, unexpectedInput, fileInput, gitInput, networkInput,
ciInput, productionInput, wrongVersion, wrongScope, wrongMode, unknownCase, cacheDraft,
webhookDraft, exportDraft, reassessment, forgedDecision, forgedScore, unexpectedReport,
sparseAlternatives, nestedUnknownAlternative, cyclicReportPlan, discarded, sparseDraft,
unexpectedDraft, forgedReassessment, reportAsDraft,
}),
});
}
const fixtureExample = [
"import {",
" createFixedSyntheticAdrInput,",
" inspectSyntheticAdrDecision,",
" planSyntheticAdrRecord,",
" runAdrDecisionsFixture,",
"} from './upgrade-2024-09.mjs';",
'',
"const report = inspectSyntheticAdrDecision(",
" createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'),",
');',
'const draft = planSyntheticAdrRecord(report);',
'',
'if (!Object.values(runAdrDecisionsFixture().assertions).every(Boolean)) {',
" throw new Error('fixed synthetic ADR fixture failed');",
'}',
'',
'console.log(report.decision.id); // bounded-bff-cache',
'console.log(report.decision.score); // 23',
'console.log(draft.status); // proposed',
'',
'// Счёт и варианты встроены в этот учебный module.',
'// Фикстура не читает ADR file, code, Git, CI, сеть, production, интервью или метрики.',
].join('\n');
const fixtureCommand = fixtureExample + '\n\nnode web/scripts/upgrade-2024-09.mjs --verify-fixture\n\n# PASS подтверждает только согласованность fixed synthetic records и отрицательных веток.';
const practice = revision({
slug: 'editorial-2024-09-practice-adr-decisions',
title: 'ADR как контракт решения: как сохранить контекст без бюрократии',
categories: ['Архитектура', 'Документация'],
cover: '/assets/editorial/2024/adr-decisions-2024-decision-flow.svg',
excerpt: 'Практический способ превратить длинную переписку в короткий ADR: контекст, варианты, решение, последствия, владелец и дата пересмотра — с границей между записью решения и его реализацией.',
readingMinutes: 11,
}, [
p('После релиза в коде остаётся небольшой обходной путь: запрос к источнику идёт через отдельный слой, хотя прямой вызов выглядит проще. В обсуждении было несколько вариантов, один из них снимал риск устаревших данных, другой уменьшал число обращений. Через полгода PR уже закрыт, участники заняты другими задачами, а в коде виден только итог. Симптом — reviewer спрашивает «зачем это здесь», но ответ живёт в чате и памяти двух человек. Цена ошибки — не одна лишняя встреча. Можно удалить защиту, которая всё ещё покрывает ограничение, или оставить дорогую схему, хотя её исходная предпосылка давно исчезла.'),
p('ADR, architecture decision record, нужен не для того, чтобы объявить решение правильным. Это короткий контракт: какая ситуация наблюдалась, что сравнили, что решили, какие последствия приняли и кто вернётся к вопросу. Он отделяет причину выбора от реализации. Поэтому запись нельзя подменять ссылкой на ticket, а commit нельзя выдавать за аргументацию: оба могут быть полезными артефактами, но отвечают на другой вопрос.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В code review возникает спор о старом условии, queue или boundary, а авторы исходного change уже не помнят детали.',
'<strong>Причина.</strong> В переписке был контекст и альтернативы, но после принятия решения они не стали отдельной проверяемой записью.',
'<strong>Проверка.</strong> Найдите один выбор, который меняет структуру, интерфейс, зависимость, non-functional constraint или способ доставки. Сформулируйте, какой факт он должен сохранить для следующего человека.',
'<strong>Действие.</strong> Создайте proposed ADR до реализации: один context, две-три альтернативы, выбранный вариант, последствия, owner, status и дата пересмотра. После acceptance ссылка на ADR идёт рядом с code, но не заменяет test и operational evidence.',
]),
h2('Как отличить ADR от хорошей заметки в переписке'),
p('Переписка полезна, пока все участники в ней находятся. В ней могут быть raw logs, варианты формулировок, эмоции, неверные гипотезы и локальные договорённости. ADR делает из этого компактный результат. Он не пересказывает каждую реплику. Он отвечает на вопрос, который сможет повторить reviewer: какая сила или constraint столкнулась с какой; какие варианты действительно рассматривались; почему выбранный вариант получил статус; какая цена остаётся после выбора. Если на эти вопросы нельзя ответить без поиска по мессенджеру, записи ещё нет.'),
p('Michael Nygard описывал ADR как короткий record для значимых решений с context, decision, status и consequences. В versioned template MADR есть status, date, people involved, decision drivers, considered options, outcome, consequences и validation. Не нужно переносить в проект каждую строку template. Но пропускать owner и review boundary опасно: тогда record сохраняет прошлое, но не даёт понять, кто проверит, что прошлое ещё применимо.'),
table('Минимальные поля ADR и их проверяемый смысл', ['Поле', 'Что записать', 'Что проверить перед acceptance', 'Чего поле не доказывает'], [
['Контекст и проблема', 'наблюдаемый симптом, constraint и цена неверного выбора', 'факты отделены от предположений; scope ограничен одним решением', 'что implementation уже корректна'],
['Варианты', 'два-три реально обсуждаемых пути и отказ от невыбранных', 'каждый вариант сравним по одному набору criteria', 'что список исчерпывает будущее'],
['Решение и status', 'выбранный вариант, Proposed или Accepted, дата', 'есть явный автор или owner и понятный acceptor', 'что решение будет работать во всех окружениях'],
['Последствия', 'что станет проще, дороже, обратимее или требует нового контроля', 'названы отрицательные последствия и граница rollback', 'что цена уже оплачена или измерена'],
['Связи и review date', 'link на code, contract, metric question и дату пересмотра', 'ссылка ведёт к артефакту, а не заменяет его чтение', 'что текущий code соответствует ADR автоматически'],
]),
figure('/assets/editorial/2024/adr-decisions-2024-decision-flow.svg', 'Вертикальная схема ADR: симптом и цена ведут к контексту, вариантам и evidence question; затем идут proposed decision, human review, accepted status, link с кодом и отдельная ветка reassessment. Стрелка supersede создаёт новый record, а не редактирует прошлый.', 'Поток показывает порядок записи решения. Он не моделирует конкретную команду, approval system, repository, CI или production-состояние.'),
h2('Короткий ADR лучше длинного пересказа'),
p('Практический record можно удержать в одной-двух страницах, если в нём один вопрос. Например: где держать ограниченный cache для internal read path. Контекст не обязан рассказывать историю всего продукта. Достаточно назвать границу: source contract, допустимую freshness, data class, owner и то, что произойдёт, если hypothesis окажется неверной. Вариант «не делать cache» тоже должен быть записан. Иначе через месяц он вернётся в discussion как новая идея, хотя уже сравнивался.'),
code([
'# ADR-0042: держать bounded cache у BFF boundary',
'',
'Status: Proposed',
'Owner: application owner',
'Review by: 2025-03-31',
'',
'## Context',
'Synthetic read contract допускает bounded freshness. Прямой read повторяет вызовы.',
'',
'## Options',
'1. Direct source read.',
'2. Bounded cache at BFF boundary.',
'3. Shared cross-service cache.',
'',
'## Decision',
'Предлагаем вариант 2: expiry и rollback принадлежат application owner.',
'',
'## Consequences',
'Нужны явные freshness bound и проверка удаления cache. Это не approval production.',
].join('\n')),
p('Этот пример специально короткий и synthetic. В нём нет реального cache, repository, SQL, dashboard или service metric. Его цель — проверить форму: decision не прячет owner, consequences не выглядят как рекламный список плюсов, а review date не является обещанием, что кто-то автоматически выполнит проверку. Для настоящего ADR вместо слова synthetic должны появиться проверяемые ссылки и владелец, которому разрешено собирать evidence.'),
h2('Сначала записываем границу, затем implementation'),
p('Частая ошибка — писать ADR после merge. Тогда решение уже видно в diff, а record превращается в оправдание. Иногда так приходится восстанавливать старый контекст, но нормальный путь другой: owner создаёт proposed record, reviewers уточняют scope и alternatives, затем принимают решение и только после этого меняют code или configuration. Это не делает процесс медленным. Небольшой record уменьшает число вопросов в diff: reviewer видит, какой compromise обсуждается, и проверяет конкретное implementation against него.'),
p('Полезно написать в ADR явную пару «реализуем» и «не реализуем». Для cache это может быть: создаём bounded local state с named expiry; не создаём shared invalidation system и не называем fixture измерением hit ratio. Такая отрицательная часть защищает от постепенного расширения. Следующая команда сможет добавить новый вариант отдельным ADR, а не превратить незаметное поле configuration в новую policy.'),
h2('Последствия — это цена, а не обязательный раздел для галочки'),
p('Фраза «решение упрощает поддержку» ничего не позволяет проверить. Лучше назвать цену как действие: owner должен хранить expiry рядом с policy; rollback возвращает direct read только после проверки contract; source change должен открыть reassessment. У positive consequence тоже есть boundary: local cache может уменьшить повторение read в целевом сценарии, но не доказывает throughput, latency или экономию. Если нет разрешённого измерения, не добавляйте число. Это честнее и делает будущую проверку возможной.'),
p('Первичный текст Nygard объясняет ADR как разговор с будущим developer: record передаёт не только what, но и why. Важная оговорка: передать контекст не означает получить бессрочный запрет на изменение. Когда assumption перестаёт выполняться, именно record помогает сформулировать новый вопрос. Старый ADR остаётся историей принятого compromise, а successor объясняет, почему он больше не подходит.'),
h2('Порядок работы с одной перепиской'),
ol([
'<strong>Ограничьте вопрос.</strong> Не пишите «архитектура export». Напишите конкретнее: «как caller получает status, если synchronous boundary не выполняется».',
'<strong>Выпишите факты отдельно.</strong> Контракт, constraint, owner и evidence question идут в context. Догадки не маскируйте под факт.',
'<strong>Сравните варианты одинаково.</strong> Для каждого назовите reversibility, cost ownership, evidence gap и constraint fit. Не делайте выбранный вариант подробным, а остальные карикатурными.',
'<strong>Зафиксируйте статус.</strong> Proposed разрешает review и правки. Accepted фиксирует record, а не останавливает развитие code.',
'<strong>Добавьте последствия и stop condition.</strong> Назовите rollback, отсутствующее evidence и signal, который заставит вернуться к решению.',
'<strong>Свяжите, но не склеивайте.</strong> После implementation добавьте link на ADR в relevant code review или documentation. Проверка compliance живёт отдельным test, query или human review.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('ADR не заменяет design document, threat model, migration plan, benchmark, test plan, runbook или incident review. Он не превращает consensus в факт и не гарантирует, что reviewer увидит все альтернативы. Form из Nygard и MADR задаёт полезную структуру, но не определяет naming, retention, approval workflow и срок review для любой организации. Если decision имеет legal, security или data boundary, эти проверки остаются отдельными обязательствами с собственными владельцами.'),
p('Следующий проверяемый шаг: возьмите одну свежую technical discussion до merge и создайте proposed ADR на 20–30 минут. Заполните пять строк: symptom, cost, alternatives, chosen compromise, review signal. Попросите reviewer ответить не «нравится ли текст», а «какой fact или consequence отсутствует». Если в ответе появляется новый вариант или owner, record уже принёс пользу: он нашёл неопределённость до того, как она стала невидимой частью implementation.'),
h2('Историческая граница сентября 2024'),
p('Материал использует датированный snapshot первичного текста Nygard от 22 августа 2024, immutable Git snapshot его template и immutable commit MADR 3.0.0 от 9 октября 2022. Все ссылки закрепляют состояние, доступное к сентябрю 2024. Из них взята узкая идея: ADR сохраняет context, choice, status, consequences, alternatives и validation boundary. Синтетический пример, scores и outcomes в учебной fixture не являются историей команды, production data, интервью, Git history, CI result или измерением качества решения.'),
]);
const mechanism = revision({
slug: 'editorial-2024-09-mechanism-adr-decisions',
title: 'Как сравнивать альтернативы в ADR и не прятать цену',
categories: ['Архитектура', 'Документация'],
cover: '/assets/editorial/2024/adr-decisions-2024-alternatives-matrix.svg',
excerpt: 'Механика ADR для выбора между вариантами: одинаковые criteria, reversibility, evidence gap, constraint fit, status и безопасный supersede. Внутри — воспроизводимая, но строго синтетическая score-модель.',
readingMinutes: 12,
}, [
p('Варианты в ADR часто выглядят честно только на заголовках. Выбранный путь занимает страницу деталей, а второй описан фразой «слишком сложно». Через несколько месяцев такой record не помогает: невозможно понять, от чего именно отказались и какую цену приняли. Симптом — в review спорят о вкусе, потому что не видно criteria. Цена ошибки — необратимое изменение может выиграть по скорости первой реализации, хотя проигрывает по ownership, rollback или отсутствующему evidence.'),
p('Сравнение не требует притворяться, что инженерный выбор сводится к одному числу. Но оно требует одинаковых вопросов к каждому варианту. Для 2024 года полезна короткая матрица: какой constraint закрывает вариант, насколько он обратим, какое evidence уже есть, какое evidence отсутствует, кто владеет operational cost и что станет сигналом для reassessment. Score допустим как учебная дисциплина или как transparent tie-breaker, если его веса не выдают за данные production и рядом остаётся текстовое объяснение.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Один вариант в ADR называют «простым», другой — «правильным», но не видно, для какого constraint эти слова верны.',
'<strong>Причина.</strong> Alternatives собраны из разных уровней: один описывает implementation, второй — vendor, третий — будущую мечту. Их цену нельзя сравнить.',
'<strong>Проверка.</strong> Для каждого варианта заполните один набор: constraint fit, reversibility, evidence fit, operating cost, owner и explicit downside. Отдельно назовите, что таблица не измеряет.',
'<strong>Действие.</strong> Выберите вариант по записанному compromise, поставьте Proposed, назначьте review signal. Если новый факт меняет assumption, создайте successor ADR и только после acceptance отметьте старый как Superseded.',
]),
h2('Четыре criteria, которые стоит назвать явно'),
p('Constraint fit отвечает на самый конкретный вопрос: выполняет ли вариант зафиксированное условие сейчас. Для webhook это может быть разделение acknowledgement и business completion; для export — наличие caller-visible status; для cache — видимая freshness bound. Если constraint сформулирован словами «сделать надёжно», оценка неизбежно станет вкусовой. Сначала перепишите constraint в проверяемую форму, затем сравнивайте варианты.'),
p('Reversibility не означает «можно отменить любой commit». Она означает, что change имеет ограниченную область, named owner и понятный путь назад. Shared state, новый protocol или cross-team contract обычно расширяют границу отката. Это не автоматический запрет. Но ADR должен назвать эту цену рядом с преимуществом, иначе rollback вспоминают только после incident.'),
table('Criteria для alternatives: что именно сравниваем', ['Criterion', 'Вопрос к варианту', 'Нужный артефакт', 'Что нельзя выводить'], [
['Constraint fit', 'какой declared requirement выполняется и где граница', 'контракт, problem statement или testable acceptance question', 'что решение оптимально для всех requirements'],
['Reversibility', 'какой scope возврата, owner и stop condition', 'rollback outline и link на affected boundary', 'что rollback уже проверен в production'],
['Evidence fit', 'какой факт поддерживает choice и чего не хватает', 'source, controlled test plan или explicit unknown', 'что отсутствие evidence равно безопасному результату'],
['Operating cost', 'кто поддерживает expiry, status, retry, migration или review', 'owner и consequence в ADR', 'что cost измерен в деньгах или часах'],
['Reassessment', 'какой signal отменяет assumption и когда вернуться', 'review date, metric question или code-contract link', 'что дату кто-то выполнит автоматически'],
]),
figure('/assets/editorial/2024/adr-decisions-2024-alternatives-matrix.svg', 'Матрица из трёх синтетических вариантов сравнивает constraint fit, reversibility, evidence fit и operating cost. Выбранная строка сопровождается явно отмеченной ценой и review signal, а подпись указывает, что score не является production metric.', 'Матрица делает цену выбора видимой. Значения в ней иллюстративны: они не измеряют team velocity, reliability, cost или результат реального внедрения.'),
h2('Скоринговая модель полезна только вместе с её ограничением'),
p('Ниже учебная модель использует четыре fixed synthetic числа от 1 до 3. Weights заранее записаны: reversibility умножается на 2, evidence fit — на 3, constraint fit — на 4, operating cost вычитается с весом 1. Для synthetic cache boundary вариант bounded BFF cache получает 23, direct read — 12, shared cross-service cache — 13. Это воспроизводимо: любой reader может запустить fixture и увидеть те же числа. Но результат не даёт права включать cache. В модели нет traffic, data classification, source behaviour, SLO, team skill, logs, interviews или production cost.'),
code([
"import {",
" createFixedSyntheticAdrInput,",
" inspectSyntheticAdrDecision,",
" planSyntheticAdrRecord,",
" runAdrDecisionsFixture,",
"} from './upgrade-2024-09.mjs';",
'',
"const report = inspectSyntheticAdrDecision(",
" createFixedSyntheticAdrInput('fixed-session-cache-boundary-v1'),",
');',
'const draft = planSyntheticAdrRecord(report);',
'',
'if (!Object.values(runAdrDecisionsFixture().assertions).every(Boolean)) {',
" throw new Error('fixed synthetic ADR fixture failed');",
'}',
'',
'console.log(report.decision.id); // bounded-bff-cache',
'console.log(report.decision.score); // 23',
'console.log(draft.status); // proposed',
'',
'// Здесь нет read ADR file, source code, Git, CI, сети или production data.',
].join('\n')),
p('Воспроизводимость здесь проверяет не полезность чисел, а дисциплину контракта. Fixture принимает только case id из embedded records. Она отвергает extra field, похожий на ADR file, repository branch, CI URL, network endpoint или production flag. Report сравнивается с canonical fixed object; подмена decision, score или nested alternative field отклоняется. Sparse array и cyclic JSON тоже не проходят. Благодаря этому учебный пример не превращается в скрытый reader, crawler или decision service.'),
h2('Как читать score без самообмана'),
p('Сначала объясните qualitative compromise. В synthetic cache case локальный bounded cache выигрывает потому, что explicit freshness и rollback остаются у одного owner. Shared cache не «плохой»: у него другая цена — cross-service invalidation и более широкая ownership boundary. Direct read не «наивный»: он может быть верным, если freshness contract или cost boundary не требуют state. Затем покажите score как проверку того, что записанные weights соответствуют уже описанному выбору. Если текст и число спорят, исправлять нужно не число первым, а скрытый constraint или неполную alternative.'),
p('Хорошая матрица также хранит evidence gap. Например, для partner webhook можно знать, что acknowledgement и completion должны быть раздельны, но не знать реальную duplicate pattern. Тогда ADR может предложить durable intent with worker как вариант, который соответствует synthetic constraints, и одновременно записать: неизвестны actual delivery semantics, retention, operator path и recovery verification. Такой record честнее, чем «выбрали очередь, потому что она надёжная». Evidence не надо придумывать, но нужно назвать, до какого acceptance или implementation шага оно обязательно.'),
h2('Status — часть механизма, а не декоративная строка'),
p('Proposed означает, что context и alternatives ещё можно изменить после review. Accepted означает, что именно этот record фиксирует принятое решение. Deprecated или Superseded не означают, что прошлое было ошибкой. Они означают, что current context изменился и есть link на successor. Nygard явно предлагает сохранять старый record и помечать его superseded, когда решение reverses. Универсальный процесс здесь не требуется: lifecycle конкретного проекта согласуют отдельно. Здесь важна минимальная invariant: accepted history не переписывают задним числом.'),
p('Safe supersede состоит из двух разных действий. Сначала создаётся successor со своим context, alternatives, decision, evidence gap и status Proposed. Он должен ссылаться на old ADR и точно назвать drift: changed source contract, missing owner, changed access boundary или invalidated assumption. Затем люди принимают successor. Только после acceptance old record получает link и status Superseded. Если implementation нужно откатывать, это отдельный change plan. Supersede меняет документационный status, а не совершает rollback за систему.'),
h2('Матрица не заменяет доказательство'),
p('Число нельзя использовать как evidence. Если synthetic score говорит 21, это не p95, не incident rate, не результат experiment и не мнение реального customer. В то же время numeric table может сделать discussion короче: reviewer видит, что cost ownership был учтён, и может спросить ровно о неправдоподобном weight или пропущенном criterion. Когда нужна проверка реальной нагрузки, безопасности или миграции, ADR должен описать method, boundary, owner и stop condition, а не подставлять в таблицу красивое число.'),
p('MADR держит рядом drivers, options, outcome, consequences и validation. Choice без driver неясен, consequence без validation не закрывает риск. Если варианты несравнимы или critical evidence отсутствует, ADR остаётся Proposed: сначала исследовательский вопрос, потом implementation.'),
h2('Порядок сравнения и принятия'),
ol([
'<strong>Запишите one decision.</strong> Один ADR не должен выбирать и database, и rollout strategy, и access model. Разделите независимые compromises.',
'<strong>Сформулируйте constraints.</strong> Каждое условие должно быть читаемо как вопрос проверки, а не как оценка «современно» или «правильно».',
'<strong>Соберите одинаковые alternatives.</strong> Для каждого назовите mechanism, owner, reversibility, evidence gap и cost. Не скрывайте вариант отказа от change.',
'<strong>Запишите decision rationale.</strong> Фраза должна связать choice с конкретными drivers и назвать price. Score можно приложить как synthetic or agreed model, но не вместо rationale.',
'<strong>Определите validation.</strong> Назовите artifact, owner, scope и stop condition. Если evidence нельзя собрать сейчас, record остаётся Proposed.',
'<strong>Поставьте reassessment hook.</strong> Review date и signal должны быть ближе к assumptions, чем к календарному ритуалу.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('Материал не предлагает universal weights, не сравнивает vendors, не выбирает cache, queue или export architecture для чьего-либо продукта. Fixed objects не читают files, Git, network, CI, production, interviews или metrics. Поэтому PASS fixture доказывает только закрытость и canonical shape учебной модели. Он не доказывает, что score честен, что alternative реализуема или что новая policy даст измеримый эффект.'),
p('Следующий проверяемый шаг: возьмите ADR, в котором выбранный вариант подробно описан, а отклонённый — нет. Сделайте из него матрицу из четырёх rows: constraint, reversibility, evidence, owner cost. Затем добавьте одну строку «чего не знаем». Если после этого выбор меняется, не исправляйте старый Accepted ADR. Создайте Proposed successor и объясните изменение criterion. Если не меняется, всё равно появится полезный artefact для следующего review.'),
h2('Историческая граница сентября 2024'),
p('Источники закреплены до сентября 2024: датированный snapshot первичного Nygard text, immutable Git snapshot Nygard template и immutable commit MADR 3.0.0. Они поддерживают структуру ADR, alternatives, consequences, validation и передачу rationale, но не дают универсальную score formula и не задают process чужой команды. Все values, weights, outcomes и supersede paths в учебной fixture fixed synthetic in-memory; это не production metric, не interviews, не Git analysis, не CI result и не historical evidence конкретного решения.'),
]);
const field = revision({
slug: 'editorial-2024-09-field-adr-decisions',
title: 'Через полгода: ADR не заменяет проверку решения',
categories: ['Архитектура', 'Документация'],
cover: '/assets/editorial/2024/adr-decisions-2024-reassessment-loop.svg',
excerpt: 'Полевой маршрут для устаревшего ADR: заметить drift, сверить assumptions с code и evidence, подготовить successor и безопасно отметить прежнее решение как Superseded только после acceptance.',
readingMinutes: 12,
}, [
p('Через полгода после Accepted ADR команда видит в code знакомую границу: export когда-то был synchronous и рассчитан на небольшой request. Теперь рядом появился status endpoint, а в discussion звучит «старый документ уже не нужен». Самое рискованное действие — отредактировать прошлый ADR так, будто он всегда описывал новый путь. Симптом — code, record и current operational question показывают разные формы системы. Цена ошибки — потерять доказательство, почему old boundary был разумен, и одновременно не создать проверяемое описание нового compromise.'),
p('Другой соблазн — считать ADR самоисполняющимся. В нём может быть написано «проверить после изменения request shape», но это не означает, что check произошёл. Record хранит hypothesis и expected consequence; compliance требует отдельного evidence с scope, owner и методом. Если data, code link или requirement изменились, ADR помогает сформулировать reassessment, но не заменяет test, security review, metric query или controlled rollout.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Existing ADR говорит об одной boundary, а новый code, contract или requirement заставляет reviewer сомневаться, что assumption всё ещё выполняется.',
'<strong>Причина.</strong> Record приняли как вечное правило либо link на implementation не имеет validation path, owner и trigger для возврата к решению.',
'<strong>Проверка.</strong> Сверьте four items: original context, stated assumptions, current code or contract link, allowed evidence. Затем назовите, какой signal опровергает assumption, а какой лишь требует наблюдения.',
'<strong>Действие.</strong> Создайте successor ADR в Proposed, не меняя old record. После human acceptance проставьте связь Superseded; implementation и rollback проведите отдельным change plan с собственными проверками.',
]),
h2('Три сигнала drift и разный ответ на каждый'),
p('Drift — не синоним «что-то поменялось». Он появляется, когда изменение затрагивает assumption или constraint, на котором стоял decision. New line of code может быть совместима со старым ADR, а ровно такой же line в другом boundary — нет. Поэтому сначала нужно выписать initial contract. Если record говорил «small synchronous export only», а requirement теперь требует visible status для неизвестного completion time, вопрос не в том, насколько много строк поменялось. Вопрос в том, отменён ли основной boundary.'),
table('Сигналы устаревания ADR и безопасный первый шаг', ['Сигнал', 'Что может измениться', 'Проверка', 'Первый безопасный шаг'], [
['Code or contract link drift', 'implementation больше не совпадает с declared decision boundary', 'прочитать diff и linked contract; отделить фактический change от interpretation', 'создать proposed reassessment note, не редактировать old ADR'],
['Assumption drift', 'source shape, access rule, data class или request bound перестали быть теми, что были записаны', 'назвать отменённую assumption и owner, который может подтвердить факт', 'сформулировать successor question и evidence plan'],
['Evidence gap', 'decision требует test, query или review, но результата нет или scope не определён', 'проверить method, period, environment and stop condition', 'оставить status Proposed либо открыть отдельную validation task'],
['Consequence drift', 'rollback, support cost или dependency ownership стали другими', 'сверить owner и actual boundary, не подменяя это одной metric', 'обновить successor consequences и change plan'],
['Calendar review без сигнала', 'дата наступила, но нет нового факта', 'проверить assumptions, links и evidence gap', 'reconfirm or schedule evidence; не объявлять supersede автоматически'],
]),
figure('/assets/editorial/2024/adr-decisions-2024-reassessment-loop.svg', 'Цикл reassessment: зафиксированное assumption встречает signal, затем команда сверяет context, code link и evidence boundary. Если assumption выдерживает проверку, record подтверждается; если нет, создаётся proposed successor, который только после acceptance помечает прежний ADR как Superseded.', 'Схема разделяет проверку решения, новый ADR и implementation change. Она не показывает реальный incident, status конкретной команды, Git history или production metric.'),
h2('Учебный разбор: старый export record и новый status contract'),
p('В учебной модели есть fixed case <code>fixed-report-export-reassessment-v1</code>. В нём старый synthetic ADR называется <code>synthetic-adr-0012-synchronous-export</code>. Его initial boundary — small synchronous path. Новый synthetic context говорит лишь одно: declared request bound больше не считается выполненным, а caller должен видеть status. Это не incident report и не evidence реального export. Но этого достаточно для учебного вопроса: old record нельзя переписывать, потому что он описывал другой context.'),
p('Alternatives тоже не обязаны скрываться под словом «обновим». Можно оставить synchronous path, предложить queued export with visible status или уйти в unbounded background work. Matrix выбирает второй synthetic вариант не потому, что он всегда лучше, а потому что fixed criteria требуют status contract и отделяют initial request от completion. Цена видна сразу: нужен owner status, recovery boundary и отдельная validation. Если эти вещи не удаётся назвать, successor должен остаться Proposed, даже если implementation уже кажется очевидным.'),
code([
"import {",
" createFixedSyntheticAdrInput,",
" inspectSyntheticAdrDecision,",
" planSyntheticAdrRecord,",
" reassessSyntheticAdrRecord,",
"} from './upgrade-2024-09.mjs';",
'',
"const report = inspectSyntheticAdrDecision(",
" createFixedSyntheticAdrInput('fixed-report-export-reassessment-v1'),",
');',
'const successor = planSyntheticAdrRecord(report);',
'const review = reassessSyntheticAdrRecord(successor);',
'',
'console.log(successor.status); // proposed',
'console.log(review.previousStatus); // accepted-until-a-human-accepts-the-successor',
'console.log(review.successorStatus); // proposed-only',
'',
'// Никакой ADR file, Git record, code, CI, metric или production system не меняется.',
].join('\n')),
p('Важна отрицательная ветка: reassessment result не меняет old status на Superseded. Он возвращает <code>accepted-until-a-human-accepts-the-successor</code>. Это не излишняя строгость. Пока successor не прошёл review, old record остаётся единственным Accepted объяснением. Если change надо остановить или откатить, команда знает, к какому document decision вернуться. Только после acceptance появляется связь «superseded by», а затем implementation может отдельно пройти delivery и validation steps.'),
h2('Как связать ADR с code и metrics без ложной автоматизации'),
p('Link на ADR полезен, когда указывает на конкретный code or contract boundary. Он не доказывает compliance. Для каждого ADR нужен отдельный validation question: test, schema check, human review или permitted metric query с scope и stop condition. Если evidence недоступен, это limitation successor, а не повод рисовать график.'),
p('Metric не заменяет assumption. Throughput не доказывает security constraint, а короткое окно без errors не отменяет missing recovery path. Evidence должен отвечать на конкретный вопрос: например, есть ли status contract и различает ли caller states. Capacity требует отдельной workload boundary и owner.'),
h2('Безопасный supersede — это маршрут, а не редактирование строки'),
p('Первый шаг — оставить old ADR неизменным и создать successor с link назад. В context successor нужно написать именно drift, а не «стало лучше»: changed request bound, changed dependency contract, new access requirement, missing owner или invalidated non-functional assumption. Второй шаг — заново сравнить alternatives. Старый choice может снова победить, если change оказался local and reversible; тогда record reconfirms decision. Если winner другой, consequences должны включить migration, rollback and support price.'),
p('Третий шаг — провести human review. У accepted decision есть owner и readers who can challenge context, but a status name must not impersonate unanimous approval. После acceptance old record получает Superseded link. Это меняет document lifecycle. Не нужно переписывать его context or consequences, иначе future reader потеряет причинную связь. Четвёртый шаг — вести code change отдельно: scope, test, deployment, monitoring, stop condition and rollback. Documentation status не запускает migration, а migration не переписывает history.'),
h2('Полевой маршрут для одной устаревшей записи'),
ol([
'<strong>Откройте old ADR и выпишите invariants.</strong> Context, decision, consequences, owner, review date и links должны быть видны до чтения current diff.',
'<strong>Назовите один signal.</strong> Не «система изменилась», а конкретно: bound не держится, contract changed, owner disappeared, evidence missing или code link diverged.',
'<strong>Определите evidence boundary.</strong> Кто читает data, в какой environment, за какой period, какой result опровергнет assumption и когда нужно остановиться.',
'<strong>Создайте successor Proposed.</strong> Добавьте old ADR link, current context, alternatives, price, validation and owner. Не ставьте Superseded заранее.',
'<strong>Примите или отклоните successor.</strong> Если Accepted, mark old record Superseded with a link. Если Rejected, сохраните reason and leave old decision readable.',
'<strong>Проведите delivery отдельно.</strong> Реальный change имеет own tests, approvals, rollback and operational checks. ADR помогает review, но не заменяет ни один из них.',
]),
h2('Что делать, если старого ADR вообще нет'),
p('Отсутствие ADR — не повод создавать реконструкцию с выдуманными мотивами. Сначала составьте current decision record: what system does now, what evidence exists, which unknown remain and who owns the next check. Если прошлый rationale известен из accessible source, сослитесь на него как на источник, не переписывая его как уверенный факт. Если unknown критичен, record должен прямо сказать «не восстановлено». Новый ADR может зафиксировать будущее choice without pretending to certify the past.'),
p('Такой подход особенно важен после incident or urgent fix. Code может правильно снизить risk, но explanation появится позже. В retrospective ADR отделите observed facts от interpretation, назовите temporary workaround и expiry. Затем либо create successor for the durable decision, либо archive the workaround as rejected. Плохой путь — назвать emergency patch Accepted architecture без вариантов, consequence and owner. Тогда временное решение получает срок жизни системы.'),
h2('Ограничения и следующий проверяемый шаг'),
p('Материал не читает current code, Git history, ticket, service metric, CI, network или production. Synthetic export record не доказывает actual timeout, queue length, user experience, security or cost. Nygard snapshot и immutable templates показывают форму rationale, но не назначают период reassessment и не дают authority supersede record в чужом repository. Любая реальная проверка требует scope, permission, owner and safe evidence collection.'),
p('Следующий шаг: возьмите один Accepted ADR старше трёх месяцев. Запишите original assumption, current link, evidence gap и verdict: reconfirm, successor or unknown. Old text не редактируйте. Successor начните с context и alternatives. Так видно, изменили implementation или само инженерное решение.'),
h2('Историческая граница сентября 2024'),
p('Материал опирается на датированный snapshot первичного ADR text Michael Nygard и два immutable Git artifacts: snapshot Nygard template и MADR 3.0.0 template. Все они закреплены состоянием до сентября 2024. Из них следует ограниченный подход: сохранять rationale, alternatives, consequences, status and validation, а при смене decision связывать новый record со старым. Fixed export case, synthetic signals, scores and lifecycle outcomes не являются реальным ADR, source code, Git history, interview, CI output, metric or production evidence.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runAdrDecisionsFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length > 0) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
return;
}
const count = Object.keys(report.assertions).length;
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');