Files
progcode/web/scripts/upgrade-2024-11.mjs
T
huncode d6c9a8f75a
Build and deploy / deploy (push) Successful in 16s
revise November 2024 deprecation articles
2026-07-31 16:46:20 +03:00

896 lines
83 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 deepFreeze(value, seen = new Set()) {
if (!value || typeof value !== 'object' || seen.has(value)) return value;
seen.add(value);
for (const child of Object.values(value)) deepFreeze(child, seen);
return Object.freeze(value);
}
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 = deepFreeze([
{
title: 'RFC 8594: The Sunset HTTP Header Field, May 2019',
url: 'https://datatracker.ietf.org/doc/rfc8594/',
version: 'RFC 8594, May 2019',
claim: 'Sunset сообщает, что конкретный URI, вероятно, станет недоступен в указанную дату; это hint, а не гарантия доступности или доказательство миграции.',
boundary: 'RFC различает стадию «не рекомендуем» и decommission: Sunset относится ко второй. Он не перечисляет клиентов, не задаёт срок уведомления и не обещает ответ после даты.',
},
{
title: 'IETF draft-ietf-httpapi-deprecation-header-09, 27.09.2024',
url: 'https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-deprecation-header-09',
version: 'Internet-Draft revision 09, September 2024',
claim: 'Deprecation header сообщает, что ресурс уже устарел или устареет; Link с relation deprecation может вести к документации и migration guide.',
boundary: 'На ноябрь 2024 это IETF Internet-Draft, не опубликованный RFC. Сам header не меняет поведение ресурса и не является списком его пользователей.',
},
{
title: 'OpenAPI Specification v3.1.0: Operation Object',
url: 'https://spec.openapis.org/oas/v3.1.0.html',
version: 'OpenAPI Specification v3.1.0, versioned official URL',
claim: 'Поле deprecated в Operation Object объявляет операцию устаревшей; consumers SHOULD refrain from usage, default false.',
boundary: 'Спецификация описывает декларацию в контракте. Она не подтверждает, что generated client обновлён, вызовов нет или replacement совместим с каждым потребителем.',
},
{
title: 'Semantic Versioning 2.0.0',
url: 'https://semver.org/spec/v2.0.0.html',
version: 'Semantic Versioning 2.0.0, versioned official URL',
claim: 'SemVer требует объявить public API; при пометке public API как deprecated увеличивается minor version, а при несовместимом изменении public API — major version.',
boundary: 'SemVer — схема версий для объявленного API, не protocol удаления. Она не даёт consumer map, calendar policy, telemetry model или authority удалить endpoint.',
},
]);
export const sourceReport = sources.map((source) => deepFreeze({ ...source }));
function sourceList() {
return '<ul>' + sources.map((source) => '<li><a href="' + source.url + '" target="_blank" rel="noopener noreferrer">' + source.title + '</a> — ' + source.claim + ' Граница: ' + source.boundary + '</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 deepFreeze({ ...meta, contentHtml, proseLength });
}
const MODEL_VERSION = 'synthetic-deprecation-2024-11-v1';
const INPUT_KIND = 'synthetic-deprecation-input-v1';
const REPORT_KIND = 'synthetic-deprecation-report-v1';
const DRAFT_KIND = 'synthetic-deprecation-review-draft-v1';
const SYNTHETIC_SCOPE = 'p81-deprecation-2024-11';
const MODEL_LIMIT = 'fixed-synthetic-in-memory-only-no-source-search-no-files-no-git-no-network-no-ci-no-clock-no-production-no-telemetry-no-customer-list-no-user-data';
const EVIDENCE_BOUNDARY = deepFreeze({
sourceUsage: 'not-read-fixed-synthetic-label-only',
declaredDependency: 'not-read-fixed-synthetic-label-only',
exposureTraffic: 'not-read-fixed-synthetic-label-only',
authorization: 'not-read-fixed-synthetic-label-only',
unknownConsumer: 'not-resolved-fixed-synthetic-label-only',
files: 'not-read-or-written',
git: 'not-read-or-written',
network: 'not-used',
ci: 'not-run',
telemetry: 'not-read',
customerList: 'not-read',
production: 'not-contacted',
});
const FIXED_CASES = deepFreeze({
'fixed-active-consumer-v1': {
label: 'a fixed synthetic map where one declared source-usage record remains active',
contract: {
api: 'synthetic-ledger-v1-posting-path',
owner: 'synthetic-ledger-owner',
replacement: 'synthetic-ledger-v2-posting-path',
deprecationDate: 'synthetic-2024-11-04',
sunsetBoundary: 'synthetic-2025-02-03',
removalScope: 'synthetic-v1-write-route-only',
},
consumerMap: [
{
consumer: 'synthetic-web-checkout',
class: 'source-usage',
state: 'active',
owner: 'synthetic-checkout-owner',
contract: 'synthetic-v1-write-route-only',
migrationPath: 'replace-fixed-v1-call-with-fixed-v2-contract',
deadline: 'synthetic-2025-01-13',
announcement: 'synthetic-deprecation-note-v1',
evidence: 'fixed-synthetic-source-usage-label-not-a-code-search',
},
{
consumer: 'synthetic-sdk-package',
class: 'declared-dependency',
state: 'migration-planned',
owner: 'synthetic-sdk-owner',
contract: 'synthetic-v1-write-route-only',
migrationPath: 'publish-fixed-v2-sdk-surface-before-consumer-move',
deadline: 'synthetic-2025-01-20',
announcement: 'synthetic-package-release-note',
evidence: 'fixed-synthetic-dependency-label-not-a-package-scan',
},
{
consumer: 'synthetic-unknown-integrator',
class: 'unknown-consumer',
state: 'unknown',
owner: 'synthetic-api-owner',
contract: 'synthetic-v1-write-route-only',
migrationPath: 'keep-discoverable-notice-and-open-scope-question',
deadline: 'synthetic-2025-02-03',
announcement: 'synthetic-public-deprecation-page',
evidence: 'fixed-synthetic-unknown-label-not-a-customer-list',
},
],
removalGate: [
'active-fixed-record-must-not-remain',
'unknown-scope-requires-human-decision',
'replacement-contract-must-have-a-named-owner',
'removal-change-needs-a-separate-human-approval',
],
restoreBoundary: {
condition: 'a fixed review identifies an active or unresolved consumer before removal',
safeAction: 'keep-the-synthetic-old-contract-described-and-discard-only-the-in-memory-removal-draft',
doesNotDo: 'does-not-reenable-a-real-route-change-a-real-header-or-restore-production-state',
},
decision: {
code: 'block-removal-on-active-or-unknown-fixed-record',
symptom: 'a calendar date is treated as proof that a synthetic v1 route can disappear',
cause: 'the date, the contract, the consumer state and the evidence boundary were never separated',
check: 'require every fixed map row to name contract, owner, migration path, deadline, announcement and evidence boundary',
action: 'keep removal blocked in the teaching model and prepare only a human review draft',
limitation: 'fixed labels do not read code, headers, authorization, traffic, telemetry, customer records or production state',
},
},
'fixed-unknown-consumer-v1': {
label: 'a fixed synthetic map where visible records are migrated but unknown scope remains',
contract: {
api: 'synthetic-reports-v1-export-path',
owner: 'synthetic-reporting-owner',
replacement: 'synthetic-reports-v2-export-path',
deprecationDate: 'synthetic-2024-11-11',
sunsetBoundary: 'synthetic-2025-02-10',
removalScope: 'synthetic-v1-export-route-only',
},
consumerMap: [
{
consumer: 'synthetic-admin-console',
class: 'declared-dependency',
state: 'migrated',
owner: 'synthetic-console-owner',
contract: 'synthetic-v1-export-route-only',
migrationPath: 'fixed-v2-export-contract-recorded',
deadline: 'synthetic-2025-01-17',
announcement: 'synthetic-console-change-note',
evidence: 'fixed-synthetic-declared-dependency-label-not-a-repository-search',
},
{
consumer: 'synthetic-service-account-class',
class: 'authorization',
state: 'unknown',
owner: 'synthetic-api-owner',
contract: 'synthetic-v1-export-route-only',
migrationPath: 'ask-for-authorized-scope-evidence-before-removal',
deadline: 'synthetic-2025-02-10',
announcement: 'synthetic-authorized-client-notice',
evidence: 'fixed-synthetic-authorization-label-not-an-access-log',
},
{
consumer: 'synthetic-public-client',
class: 'exposure-traffic',
state: 'not-observed-in-model',
owner: 'synthetic-api-owner',
contract: 'synthetic-v1-export-route-only',
migrationPath: 'do-not-infer-absence-from-a-fixed-label',
deadline: 'synthetic-2025-02-10',
announcement: 'synthetic-deprecation-page',
evidence: 'fixed-synthetic-exposure-label-not-traffic-or-telemetry',
},
],
removalGate: [
'unknown-authorization-scope-is-a-blocker',
'not-observed-in-model-is-not-no-consumer',
'replacement-contract-and-restore-boundary-must-be-readable',
'human-owner-must-accept-residual-risk-or-keep-the-route',
],
restoreBoundary: {
condition: 'consumer scope remains unknown at the synthetic sunset boundary',
safeAction: 'do-not-model-removal-and-preserve-only-the-proposed-review-record',
doesNotDo: 'does-not-query-access-logs-contact-customers-or-change-an-authorization-policy',
},
decision: {
code: 'block-removal-on-unknown-scope',
symptom: 'no fixed source label remains active, so the team calls the user list complete',
cause: 'source usage, declared dependency, traffic, authorization and unknown scope are treated as the same evidence type',
check: 'record which evidence type produced each row and mark missing scope as unknown instead of zero',
action: 'leave the synthetic route unresolved until an authorized human decision supplies a bounded next step',
limitation: 'the model never accesses telemetry, API gateway data, source code, customer identity or authorization records',
},
},
'fixed-migrated-consumer-v1': {
label: 'a fixed synthetic map where named rows are migrated but hidden consumers are not declared impossible',
contract: {
api: 'synthetic-profile-v1-preferences-path',
owner: 'synthetic-profile-owner',
replacement: 'synthetic-profile-v2-preferences-path',
deprecationDate: 'synthetic-2024-11-18',
sunsetBoundary: 'synthetic-2025-02-17',
removalScope: 'synthetic-v1-preferences-route-only',
},
consumerMap: [
{
consumer: 'synthetic-mobile-bff',
class: 'source-usage',
state: 'migrated',
owner: 'synthetic-mobile-owner',
contract: 'synthetic-v1-preferences-route-only',
migrationPath: 'fixed-v2-contract-and-behaviour-check-recorded',
deadline: 'synthetic-2025-01-24',
announcement: 'synthetic-bff-release-note',
evidence: 'fixed-synthetic-source-label-not-a-code-search',
},
{
consumer: 'synthetic-partner-adapter',
class: 'declared-dependency',
state: 'migrated',
owner: 'synthetic-partner-owner',
contract: 'synthetic-v1-preferences-route-only',
migrationPath: 'fixed-v2-adapter-contract-recorded',
deadline: 'synthetic-2025-01-31',
announcement: 'synthetic-partner-migration-note',
evidence: 'fixed-synthetic-dependency-label-not-a-package-inventory',
},
{
consumer: 'synthetic-undiscovered-client',
class: 'unknown-consumer',
state: 'unknown-not-proved-absent',
owner: 'synthetic-profile-owner',
contract: 'synthetic-v1-preferences-route-only',
migrationPath: 'document-residual-risk-and-use-a-human-removal-gate',
deadline: 'synthetic-2025-02-17',
announcement: 'synthetic-deprecation-page',
evidence: 'fixed-synthetic-limitation-not-proof-of-absence',
},
],
removalGate: [
'all-named-fixed-rows-must-be-migrated',
'replacement-contract-must-be-reviewed',
'announcement-and-deadline-must-be-preserved-in-the-change-record',
'unknown-consumer-risk-must-be-explicitly-accepted-by-a-human-owner',
'restore-boundary-must-exist-before-a-separate-removal-change',
],
restoreBoundary: {
condition: 'a permitted review finds a consumer after a removal proposal but before a real removal change',
safeAction: 'stop-the-removal-proposal-and-return-to-the-last-documented-compatible-contract',
doesNotDo: 'does-not-claim-a-real-rollback-revive-a-real-endpoint-or-recover-any-production-request',
},
decision: {
code: 'allow-human-removal-review-only',
symptom: 'all named fixed records say migrated and the team wants to equate that with no hidden consumers',
cause: 'a bounded map is mistaken for a complete population and the restore boundary is omitted',
check: 'verify named rows, replacement contract, announcement, deadline, residual unknown and safe stop condition separately',
action: 'allow only a human review proposal; a separate authorized change would still need its own evidence and restore plan',
limitation: 'the model cannot prove the absence of hidden consumers and performs no real deletion, traffic read, notification or rollback',
},
},
});
const INPUT_KEYS = deepFreeze(['kind', 'synthetic', 'modelVersion', 'scope', 'mode', 'caseId']);
const CONTRACT_KEYS = deepFreeze(['api', 'owner', 'replacement', 'deprecationDate', 'sunsetBoundary', 'removalScope']);
const CONSUMER_KEYS = deepFreeze(['consumer', 'class', 'state', 'owner', 'contract', 'migrationPath', 'deadline', 'announcement', 'evidence']);
const RESTORE_KEYS = deepFreeze(['condition', 'safeAction', 'doesNotDo']);
const DECISION_KEYS = deepFreeze(['code', 'symptom', 'cause', 'check', 'action', 'limitation']);
const EVIDENCE_KEYS = deepFreeze(['sourceUsage', 'declaredDependency', 'exposureTraffic', 'authorization', 'unknownConsumer', 'files', 'git', 'network', 'ci', 'telemetry', 'customerList', 'production']);
const REPORT_KEYS = deepFreeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'scope', 'caseId',
'caseLabel', 'contract', 'consumerMap', 'removalGate', 'restoreBoundary', 'decision', 'evidence', 'productionEffect',
]);
const DRAFT_KEYS = deepFreeze([
'kind', 'accepted', 'syntheticOnly', 'reason', 'modelVersion', 'modelLimit', 'scope', 'caseId',
'sourceReport', 'actions', 'files', 'git', 'network', 'ci', 'telemetry', 'customerList', 'production', 'productionEffect',
]);
const ACTIONS_BY_CASE = deepFreeze({
'fixed-active-consumer-v1': [
'keep-synthetic-removal-blocked',
'record-owner-contract-migration-deadline-announcement-and-evidence-boundary',
'separate-any-real-evidence-collection-from-this-fixed-model',
],
'fixed-unknown-consumer-v1': [
'treat-unknown-scope-as-a-blocker',
'do-not-convert-not-observed-into-no-consumer',
'ask-a-human-owner-to-bound-the-next-authorized-check-or-keep-the-route',
],
'fixed-migrated-consumer-v1': [
'prepare-only-a-human-removal-review',
'preserve-the-replacement-contract-and-restore-boundary',
'record-residual-unknown-risk-before-any-separate-removal-change',
],
});
function hasExactKeys(value, keys) {
try {
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));
} catch {
return false;
}
}
function hasDenseArray(value) {
try {
return Array.isArray(value)
&& Object.keys(value).length === value.length
&& Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean);
} catch {
return false;
}
}
function hasSameCanonicalJson(actual, expected) {
try {
return JSON.stringify(actual) === JSON.stringify(expected);
} catch {
return false;
}
}
function isFixedConsumerMap(value) {
return hasDenseArray(value)
&& value.length > 0
&& value.every((row) => hasExactKeys(row, CONSUMER_KEYS)
&& Object.values(row).every((item) => typeof item === 'string' && item.length > 0));
}
function isFixedReportShape(report) {
return hasExactKeys(report, REPORT_KEYS)
&& hasExactKeys(report.contract, CONTRACT_KEYS)
&& Object.values(report.contract).every((item) => typeof item === 'string' && item.length > 0)
&& isFixedConsumerMap(report.consumerMap)
&& hasDenseArray(report.removalGate)
&& report.removalGate.every((item) => typeof item === 'string' && item.length > 0)
&& hasExactKeys(report.restoreBoundary, RESTORE_KEYS)
&& Object.values(report.restoreBoundary).every((item) => typeof item === 'string' && item.length > 0)
&& hasExactKeys(report.decision, DECISION_KEYS)
&& Object.values(report.decision).every((item) => typeof item === 'string' && item.length > 0)
&& hasExactKeys(report.evidence, EVIDENCE_KEYS)
&& Object.values(report.evidence).every((item) => typeof item === 'string' && item.length > 0);
}
function rejected(reason) {
return deepFreeze({
kind: REPORT_KIND,
accepted: false,
syntheticOnly: true,
reason,
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
});
}
function canonicalReportFor(caseId) {
const fixed = FIXED_CASES[caseId];
if (!fixed) return null;
return deepFreeze({
kind: REPORT_KIND,
accepted: true,
syntheticOnly: true,
reason: 'fixed-synthetic-review-record-only',
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
scope: SYNTHETIC_SCOPE,
caseId,
caseLabel: fixed.label,
contract: fixed.contract,
consumerMap: fixed.consumerMap,
removalGate: fixed.removalGate,
restoreBoundary: fixed.restoreBoundary,
decision: fixed.decision,
evidence: EVIDENCE_BOUNDARY,
productionEffect: 'not-attempted',
});
}
function isCanonicalSyntheticDeprecationReport(report) {
if (!isFixedReportShape(report)
|| report.kind !== REPORT_KIND
|| report.accepted !== true
|| report.syntheticOnly !== true
|| report.reason !== 'fixed-synthetic-review-record-only'
|| report.modelVersion !== MODEL_VERSION
|| report.modelLimit !== MODEL_LIMIT
|| report.scope !== SYNTHETIC_SCOPE
|| typeof report.caseId !== 'string'
|| !Object.hasOwn(FIXED_CASES, report.caseId)
|| report.productionEffect !== 'not-attempted') return false;
const canonical = canonicalReportFor(report.caseId);
return canonical !== null && hasSameCanonicalJson(report, canonical);
}
function makeSyntheticDeprecationReviewDraft(report) {
return deepFreeze({
kind: DRAFT_KIND,
accepted: true,
syntheticOnly: true,
reason: 'fixed-synthetic-human-review-draft-only',
modelVersion: MODEL_VERSION,
modelLimit: MODEL_LIMIT,
scope: SYNTHETIC_SCOPE,
caseId: report.caseId,
sourceReport: report,
actions: ACTIONS_BY_CASE[report.caseId],
files: 'not-read-or-written',
git: 'not-read-or-written',
network: 'not-used',
ci: 'not-run',
telemetry: 'not-read',
customerList: 'not-read',
production: 'not-contacted',
productionEffect: 'not-attempted',
});
}
function isCanonicalSyntheticDeprecationDraft(draft) {
if (!hasExactKeys(draft, DRAFT_KEYS)
|| draft.kind !== DRAFT_KIND
|| draft.accepted !== true
|| draft.syntheticOnly !== true
|| draft.reason !== 'fixed-synthetic-human-review-draft-only'
|| draft.modelVersion !== MODEL_VERSION
|| draft.modelLimit !== MODEL_LIMIT
|| draft.scope !== SYNTHETIC_SCOPE
|| typeof draft.caseId !== 'string'
|| !Object.hasOwn(FIXED_CASES, draft.caseId)
|| !hasDenseArray(draft.actions)
|| draft.files !== 'not-read-or-written'
|| draft.git !== 'not-read-or-written'
|| draft.network !== 'not-used'
|| draft.ci !== 'not-run'
|| draft.telemetry !== 'not-read'
|| draft.customerList !== 'not-read'
|| draft.production !== 'not-contacted'
|| draft.productionEffect !== 'not-attempted'
|| !isCanonicalSyntheticDeprecationReport(draft.sourceReport)) return false;
return hasSameCanonicalJson(draft, makeSyntheticDeprecationReviewDraft(canonicalReportFor(draft.caseId)));
}
export function createFixedSyntheticDeprecationInput(caseId) {
if (!Object.hasOwn(FIXED_CASES, caseId)) {
return deepFreeze({ kind: 'unknown-synthetic-deprecation-input', synthetic: false, caseId });
}
return deepFreeze({
kind: INPUT_KIND,
synthetic: true,
modelVersion: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
mode: 'fixed-memory-only',
caseId,
});
}
/**
* Inspects one fixed object embedded in this file. It never reads source code,
* API traffic, authorization, telemetry, a customer list, files, Git, clock,
* network, CI, production state, or a real API. A PASS is shape consistency,
* not evidence that a real consumer exists, migrated, or disappeared.
*/
export function inspectSyntheticDeprecation(input) {
if (!input || input.synthetic !== true || input.kind !== INPUT_KIND) return rejected('synthetic-fixed-input-required');
if (!hasExactKeys(input, INPUT_KEYS)) return rejected('unexpected-input-field');
if (input.modelVersion !== MODEL_VERSION) return rejected('unexpected-model-version');
if (input.scope !== SYNTHETIC_SCOPE) return rejected('unexpected-synthetic-scope');
if (input.mode !== 'fixed-memory-only') return rejected('fixed-memory-mode-required');
if (typeof input.caseId !== 'string' || !Object.hasOwn(FIXED_CASES, input.caseId)) return rejected('unknown-fixed-synthetic-case');
return canonicalReportFor(input.caseId);
}
/**
* Makes an in-memory human-review draft only after exact canonical validation.
* It cannot announce, modify, disable, sunset, remove, restore, or query an
* actual API path or any operational evidence.
*/
export function planSyntheticDeprecationReview(report) {
if (!report || report.kind !== REPORT_KIND || report.syntheticOnly !== true || report.accepted !== true) {
return deepFreeze({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
if (!hasExactKeys(report, REPORT_KEYS)) {
return deepFreeze({ accepted: false, syntheticOnly: true, reason: 'unexpected-report-field', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
if (report.modelVersion !== MODEL_VERSION || report.scope !== SYNTHETIC_SCOPE || !Object.hasOwn(FIXED_CASES, report.caseId)) {
return deepFreeze({ accepted: false, syntheticOnly: true, reason: 'untrusted-synthetic-report-scope', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
if (!isCanonicalSyntheticDeprecationReport(report)) {
return deepFreeze({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-fixed-object', modelVersion: MODEL_VERSION, modelLimit: MODEL_LIMIT });
}
return makeSyntheticDeprecationReviewDraft(canonicalReportFor(report.caseId));
}
/**
* Discards a canonical in-memory review draft before any real change. This is
* a restore boundary for the teaching object only; it does not rollback a
* deployed route, a header, a client, authorization, data, or production.
*/
export function restoreSyntheticDeprecationReview(draft) {
if (!isCanonicalSyntheticDeprecationDraft(draft)) {
return deepFreeze({ restored: false, syntheticOnly: true, reason: 'no-accepted-synthetic-deprecation-review-draft' });
}
return deepFreeze({
restored: true,
syntheticOnly: true,
reason: 'fixed-synthetic-review-draft-discarded-before-real-change',
restoreBoundary: draft.sourceReport.restoreBoundary.safeAction,
files: 'not-read-or-written',
git: 'not-read-or-written',
network: 'not-used',
ci: 'not-run',
telemetry: 'not-read',
customerList: 'not-read',
production: 'not-contacted',
productionEffect: 'not-attempted',
});
}
export function runDeprecationFixture() {
const active = inspectSyntheticDeprecation(createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'));
const unknown = inspectSyntheticDeprecation(createFixedSyntheticDeprecationInput('fixed-unknown-consumer-v1'));
const migrated = inspectSyntheticDeprecation(createFixedSyntheticDeprecationInput('fixed-migrated-consumer-v1'));
const fixedBefore = JSON.stringify(FIXED_CASES['fixed-active-consumer-v1']);
const nonSynthetic = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), synthetic: false });
const unexpectedInput = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), file: 'api.yaml' });
const telemetryInput = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), telemetry: { count: 1 } });
const customerInput = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), customerList: [] });
const networkInput = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), endpoint: 'https://not-used.example' });
const badVersion = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), modelVersion: 'other-version' });
const badScope = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), scope: 'other-scope' });
const badMode = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), mode: 'read-live-usage' });
const unknownCase = inspectSyntheticDeprecation({ ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1'), caseId: 'real-api' });
const cyclicInput = { ...createFixedSyntheticDeprecationInput('fixed-active-consumer-v1') };
cyclicInput.self = cyclicInput;
const cyclicInputResult = inspectSyntheticDeprecation(cyclicInput);
const activePlan = planSyntheticDeprecationReview(active);
const unknownPlan = planSyntheticDeprecationReview(unknown);
const migratedPlan = planSyntheticDeprecationReview(migrated);
const forgedDecisionPlan = planSyntheticDeprecationReview({ ...active, decision: { ...active.decision, code: 'remove-now' } });
const forgedMapPlan = planSyntheticDeprecationReview({ ...migrated, consumerMap: [...migrated.consumerMap, { ...migrated.consumerMap[0], consumer: 'synthetic-added-row' }] });
const unexpectedReportPlan = planSyntheticDeprecationReview({ ...unknown, traffic: 'not-accepted' });
const sparseMapPlan = planSyntheticDeprecationReview({ ...active, consumerMap: new Array(active.consumerMap.length) });
const cyclicDecision = { ...migrated.decision };
cyclicDecision.self = cyclicDecision;
const cyclicReportPlan = planSyntheticDeprecationReview({ ...migrated, decision: cyclicDecision });
const cyclicJsonValue = { kind: 'synthetic-cycle' };
cyclicJsonValue.self = cyclicJsonValue;
const restored = restoreSyntheticDeprecationReview(migratedPlan);
const forgedRestore = restoreSyntheticDeprecationReview({ ...migratedPlan, actions: ['remove-real-route'] });
const sparseRestore = restoreSyntheticDeprecationReview({ ...activePlan, actions: new Array(activePlan.actions.length) });
const reportAsDraft = restoreSyntheticDeprecationReview(active);
const fixedAfter = JSON.stringify(FIXED_CASES['fixed-active-consumer-v1']);
return deepFreeze({
assertions: {
acceptsActiveCase: active.accepted === true && active.decision.code === 'block-removal-on-active-or-unknown-fixed-record',
activeMapKeepsDenseRows: hasDenseArray(active.consumerMap) && active.consumerMap.length === 3,
activeCaseHasContractOwnerAndDeadline: active.contract.owner === 'synthetic-ledger-owner' && active.consumerMap[0].deadline === 'synthetic-2025-01-13',
acceptsUnknownCase: unknown.accepted === true && unknown.decision.code === 'block-removal-on-unknown-scope',
unknownIsNotConvertedToZero: unknown.consumerMap.some((row) => row.state === 'unknown') && unknown.evidence.unknownConsumer.includes('not-resolved'),
acceptsMigratedCase: migrated.accepted === true && migrated.decision.code === 'allow-human-removal-review-only',
migratedDoesNotPromiseNoHiddenConsumers: migrated.consumerMap.some((row) => row.state === 'unknown-not-proved-absent'),
keepsRemovalGateDense: hasDenseArray(migrated.removalGate) && migrated.removalGate.length === 5,
recordsNoOperationalAccess: migrated.evidence.files === 'not-read-or-written' && migrated.evidence.telemetry === 'not-read' && migrated.evidence.production === 'not-contacted',
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-fixed-input-required',
rejectsUnknownInputField: unexpectedInput.accepted === false && unexpectedInput.reason === 'unexpected-input-field',
rejectsTelemetryInput: telemetryInput.accepted === false && telemetryInput.reason === 'unexpected-input-field',
rejectsCustomerInput: customerInput.accepted === false && customerInput.reason === 'unexpected-input-field',
rejectsNetworkInput: networkInput.accepted === false && networkInput.reason === 'unexpected-input-field',
rejectsWrongVersion: badVersion.accepted === false && badVersion.reason === 'unexpected-model-version',
rejectsWrongScope: badScope.accepted === false && badScope.reason === 'unexpected-synthetic-scope',
rejectsLiveReadMode: badMode.accepted === false && badMode.reason === 'fixed-memory-mode-required',
rejectsUnknownCase: unknownCase.accepted === false && unknownCase.reason === 'unknown-fixed-synthetic-case',
rejectsCyclicInputWithoutThrowing: cyclicInputResult.accepted === false && cyclicInputResult.reason === 'unexpected-input-field',
invalidInputDoesNotMutateFixedRecord: fixedBefore === fixedAfter && Object.isFrozen(FIXED_CASES['fixed-active-consumer-v1']) && Object.isFrozen(FIXED_CASES['fixed-active-consumer-v1'].consumerMap),
activePlanBlocksRemovalOnly: activePlan.accepted === true && activePlan.actions.includes('keep-synthetic-removal-blocked'),
unknownPlanKeepsUnknownAsBlocker: unknownPlan.accepted === true && unknownPlan.actions.includes('treat-unknown-scope-as-a-blocker'),
migratedPlanIsHumanReviewOnly: migratedPlan.accepted === true && migratedPlan.actions.includes('prepare-only-a-human-removal-review'),
draftHasNoOperationalEffect: migratedPlan.files === 'not-read-or-written' && migratedPlan.git === 'not-read-or-written' && migratedPlan.production === 'not-contacted',
rejectsForgedDecision: forgedDecisionPlan.accepted === false && forgedDecisionPlan.reason === 'report-does-not-match-fixed-object',
rejectsForgedMap: forgedMapPlan.accepted === false && forgedMapPlan.reason === 'report-does-not-match-fixed-object',
rejectsUnexpectedReportField: unexpectedReportPlan.accepted === false && unexpectedReportPlan.reason === 'unexpected-report-field',
rejectsSparseMap: sparseMapPlan.accepted === false && sparseMapPlan.reason === 'report-does-not-match-fixed-object',
rejectsCyclicReportWithoutThrowing: cyclicReportPlan.accepted === false && cyclicReportPlan.reason === 'report-does-not-match-fixed-object',
canonicalJsonRejectsCycleWithoutThrowing: hasSameCanonicalJson(cyclicJsonValue, { kind: 'synthetic-cycle' }) === false,
restoreDiscardsOnlyCanonicalDraft: restored.restored === true && restored.restoreBoundary.includes('stop-the-removal-proposal') && restored.productionEffect === 'not-attempted',
restoreRejectsForgedDraft: forgedRestore.restored === false && forgedRestore.reason === 'no-accepted-synthetic-deprecation-review-draft',
restoreRejectsSparseActions: sparseRestore.restored === false && sparseRestore.reason === 'no-accepted-synthetic-deprecation-review-draft',
reportCannotBeRestoredAsDraft: reportAsDraft.restored === false && reportAsDraft.reason === 'no-accepted-synthetic-deprecation-review-draft',
},
samples: {
active, unknown, migrated, nonSynthetic, unexpectedInput, telemetryInput, customerInput, networkInput,
badVersion, badScope, badMode, unknownCase, cyclicInputResult, activePlan, unknownPlan, migratedPlan,
forgedDecisionPlan, forgedMapPlan, unexpectedReportPlan, sparseMapPlan, cyclicReportPlan,
restored, forgedRestore, sparseRestore, reportAsDraft,
},
});
}
const fixtureExample = [
"import {",
" createFixedSyntheticDeprecationInput,",
" inspectSyntheticDeprecation,",
" planSyntheticDeprecationReview,",
" restoreSyntheticDeprecationReview,",
" runDeprecationFixture,",
"} from './upgrade-2024-11.mjs';",
'',
"const report = inspectSyntheticDeprecation(",
" createFixedSyntheticDeprecationInput('fixed-migrated-consumer-v1'),",
");",
'const draft = planSyntheticDeprecationReview(report);',
'const restored = restoreSyntheticDeprecationReview(draft);',
'',
'if (!Object.values(runDeprecationFixture().assertions).every(Boolean)) {',
" throw new Error('fixed synthetic fixture failed');",
'}',
'',
'console.log({ decision: report.decision.code, restored: restored.restored });',
'',
'// Fixed objects in memory only.',
'// No files, Git, network, CI, clock, production, telemetry, customer list or real API is accessed.',
'// restored means discard of the teaching draft, not rollback of a deployed endpoint.',
].join('\n');
const practice = revision({
slug: 'editorial-2024-11-practice-deprecation',
title: 'Устаревший API нельзя удалить по дате в календаре',
categories: ['Рефакторинг', 'API'],
cover: '/assets/editorial/2024/deprecation-2024-consumer-map.svg',
excerpt: 'Consumer map для устаревшего API: контракт, владелец, путь миграции, дедлайн, объявление и граница доказательства до отдельного решения об удалении.',
readingMinutes: 13,
}, [
p('В контракте появляется дата: путь <code>/v1/posting</code> будет удалён после февраля. В день, когда дата наступает, в pull request легко написать «старый endpoint больше никому не нужен» и вырезать обработчик. Симптом понятен: календарь даёт один ясный сигнал, а список потребителей разбросан между контрактом, владельцами и документами. Цена ошибки тоже конкретна: один неизвестный клиент получает отказ, а команда не может объяснить, кому сообщили, какой replacement предложили и где безопасно остановить изменение.'),
p('Обратная ошибка не лучше: путь держат бесконечно, потому что никто не хочет отвечать за слово «последний consumer». Дата сама по себе не решает этот спор. Она полезна как boundary для планирования, но не как доказательство отсутствия вызовов. Удаление начинается не с календаря, а с карты: какой именно контракт устаревает, кто отвечает за миграцию, какое объявление видит потребитель и какой факт разрешает перейти к отдельному change на удаление.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> В задаче есть дедлайн, но нет перечня контрактов и ответственных за переход.',
'<strong>Причина.</strong> Endpoint принимают за один объект, хотя вокруг него есть public contract, client integrations, authorizations, documentation и старый migration path.',
'<strong>Проверка.</strong> Для каждого известного или неизвестного потребителя заполните одну строку: contract, owner, migration path, deadline, announcement и evidence boundary.',
'<strong>Действие.</strong> До закрытия карты не удаляйте путь. После карты подготовьте отдельный review: что доказано, что неизвестно, какой риск остаётся и какие условия остановят удаление.',
]),
h2('Сначала ограничить предмет удаления'),
p('Фраза «удаляем v1» слишком широкая. В ней не видно, речь о write route, одной операции в OpenAPI, SDK method, callback или полном наборе ресурсов. Начните с имени contract: HTTP method, URI template, request and response shape, authentication boundary и replacement. Если replacement меняет semantics, одной замены URL мало. Нужно записать, кто проверяет compatibility: например, сохраняется ли idempotency key, как кодируются ошибки и может ли старый client понять ответ.'),
p('OpenAPI 3.1.0 даёт полезную, но узкую декларацию: у Operation Object есть <code>deprecated: true</code>, и consumers должны воздерживаться от этой операции. Это не инвентаризация. Пометка в schema не рассказывает, какой generated client уже выпущен, какой сервис спрятал вызов за adapter и какая интеграция вообще не читает OpenAPI. Поэтому declaration и map — разные артефакты. Первое говорит, что контракт больше не рекомендован; второе делает migration работой с владельцами.'),
table('Consumer map для учебного контракта', ['Потребитель', 'Класс сведения', 'Contract и owner', 'Migration path', 'Deadline', 'Announcement и evidence boundary'], [
['synthetic-web-checkout', 'source usage', 'v1 write route; synthetic-checkout-owner', 'заменить fixed v1 call на fixed v2 contract', 'synthetic-2025-01-13', 'synthetic note; label учебный, не code search'],
['synthetic-sdk-package', 'declared dependency', 'v1 write route; synthetic-sdk-owner', 'сначала выпустить fixed v2 SDK surface', 'synthetic-2025-01-20', 'synthetic release note; не package inventory'],
['synthetic-unknown-integrator', 'unknown consumer', 'v1 write route; synthetic-api-owner', 'оставить notice discoverable и открыть scope question', 'synthetic-2025-02-03', 'synthetic deprecation page; не customer list'],
]),
figure('/assets/editorial/2024/deprecation-2024-consumer-map.svg', 'Схема consumer map: от точно названного API-контракта идут шесть обязательных полей — потребитель, владелец, путь миграции, дедлайн, объявление и граница доказательства. Отдельная ветка unknown consumer блокирует автоматическое удаление.', 'Карта показывает порядок инженерного разговора. Имена, даты и статусы в статье — fixed synthetic model, а не сведения о реальной системе или клиентах.'),
h2('Карта не обязана притворяться полным реестром'),
p('У карты есть два честных типа строк. Известная строка связывает конкретного consumer с owner и migration path. Неизвестная строка фиксирует противоположное: область ещё не доказана. Она не портит отчёт. Она не позволяет календарю выдать желаемый ответ. Если в карте есть <code>unknown consumer</code>, следующий вопрос звучит не «когда удалить?», а «какой разрешённый способ ограничит неизвестность и кто примет остаточный риск?».'),
p('Это особенно важно для внешнего API. Нельзя получать список потребителей из одной телеметрической витрины и объявлять его полным: часть клиентов может не попадать в выбранное окно, использовать другой credential class, идти через proxy или вообще не иметь доступного механизма учёта. В этой статье нет ни telemetry, ни customer list. Учебная строка <code>synthetic-unknown-integrator</code> нужна только для того, чтобы не потерять этот класс риска в дизайне.'),
h2('Deprecation record: короткий контракт перехода'),
p('Уведомление полезно, когда у него есть контекст. IETF draft deprecation header в версии 09, доступной к ноябрю 2024, описывает signal о том, что resource уже устарел или устареет, и link на документацию. RFC 8594 отдельно описывает Sunset как hint о предполагаемой недоступности. Из этого не следует, что два header автоматически мигрируют client. Они лишь делают решение discoverable. Migration guide, replacement, owner и scope должны быть отдельными полями записи.'),
code([
'{',
' "contract": "synthetic-ledger-v1-posting-path",',
' "owner": "synthetic-ledger-owner",',
' "replacement": "synthetic-ledger-v2-posting-path",',
' "deprecationDate": "synthetic-2024-11-04",',
' "sunsetBoundary": "synthetic-2025-02-03",',
' "announcement": "synthetic-public-deprecation-page",',
' "knownConsumerRule": "every row names owner and migration path",',
' "unknownConsumerRule": "unknown blocks automatic removal",',
' "restoreBoundary": "stop proposal before a real removal change"',
'}',
].join('\n')),
p('Это не production configuration и не готовый header. Здесь нет реальной даты HTTP, токена, route table или client data. Запись проверяет другую вещь: нельзя провести строку к removal gate, пока не названы replacement, owner, объявление и реакция на unknown. Если public API ведётся по SemVer, versioning contract можно включить рядом: SemVer 2.0.0 требует объявить public API, а deprecated public functionality отражать minor version. Но версия пакета не заменяет communication plan для конкретного endpoint.'),
h2('Как читать доказательство, не подменяя его обещанием'),
p('В consumer map колонка evidence должна называть не результат «никого нет», а способ и границу. Например, «объявленная зависимость» сообщает, что чей-то manifest или contract сказал о зависимости; «source usage» — что разрешённая проверка кода нашла конкретный call; «authorization» — что определён набор credential class. Каждая строка отвечает на свой вопрос. Ни одна не равна population всех пользователей.'),
table('Сведения, которые нельзя склеивать в один зелёный статус', ['Сведение', 'На какой вопрос отвечает', 'Чего не доказывает', 'Безопасная формулировка'], [
['Deprecated в OpenAPI', 'операция больше не рекомендована в описании контракта', 'что consumer уже перестали её вызывать', 'declaration опубликована, миграция не подтверждена'],
['Deprecation / Sunset signal', 'resource сообщил о lifecycle и boundary', 'что клиент получил, понял или применил notice', 'signal доступен в указанной response boundary'],
['Declared dependency', 'потребитель объявил связь с API или SDK', 'что этот путь выполняется сейчас', 'зависимость требует owner и migration path'],
['Unknown consumer', 'полнота scope не установлена', 'что отсутствует риск', 'автоматическое удаление заблокировано'],
]),
h2('Removal gate — отдельное решение'),
p('Когда строки карты заполнены, удаление всё ещё не становится автоматическим. Нужен gate с отрицательными условиями: нет active row, unknown scope обработан отдельным решением, replacement contract читаем, announcement и дедлайн сохранены, а у change есть restore boundary. Это делает цену решения видимой. Можно ускорить удаление, сузив scope до одной operation, или отложить, если replacement ещё не может принять нужный сценарий. Нельзя компенсировать незнание более ранней датой.'),
p('Если перед реальным удалением появляется active consumer, безопасное действие — остановить proposal. Не менять одновременно documentation, route, authorization и fallback. Сначала сохранить, где обнаружен конфликт и какой compatibility boundary сохраняется. В synthetic fixture восстановление означает только discard in-memory draft: оно не возвращает endpoint, не меняет header и не обещает rollback production. В настоящем проекте restore plan должен быть отдельным, с owner, ограничением данных и проверкой после возврата.'),
h2('Ограничения и следующий проверяемый шаг'),
p('Материал не выполняет source search, API call, анализ трафика, чтение access logs, customer list, Git или CI. Fixed rows не доказывают существование реальных клиентов, а synthetic deadlines не задают policy. RFC 8594 называет Sunset hint и не гарантирует, что ресурс станет недоступен ровно в дату. IETF draft-09 на дату исторической границы был draft, поэтому нельзя выдавать его за завершённый стандарт ноября 2024.'),
p('Следующий шаг: выберите одну operation, а не весь API. Создайте consumer map из шести колонок этой статьи. В первой же строке, где не удаётся назвать owner, evidence boundary или replacement, поставьте <code>unknown</code>. Ожидаемый результат — не преждевременное удаление, а понятное решение: какую проверку запросить, кто её владелец и почему до неё route остаётся совместимым.'),
h2('Историческая граница ноября 2024'),
p('Для терминов lifecycle использованы RFC 8594 (May 2019) и IETF draft-ietf-httpapi-deprecation-header-09 (September 2024). Draft показывает deprecation signal и documentation link, но на ноябрь 2024 ещё не был RFC. OpenAPI Specification v3.1.0 подтверждает декларацию deprecated operation, а SemVer 2.0.0 — правила для заявленного public API. Все имена, строки карты, даты, outcomes и действия в этом материале — fixed synthetic in-memory model; это не реальная телеметрия, customer list, исходный код, incident или production evidence.'),
]);
const mechanism = revision({
slug: 'editorial-2024-11-mechanism-deprecation',
title: 'Почему telemetry не равна списку пользователей',
categories: ['Рефакторинг', 'API'],
cover: '/assets/editorial/2024/deprecation-2024-timeline.svg',
excerpt: 'Механизм deprecation: разделить source usage, declared dependency, exposure or traffic, authorization и unknown consumer, а затем держать warning, sunset и removal как разные границы.',
readingMinutes: 13,
}, [
p('Перед удалением API часто появляется уверенная фраза: «в telemetry, то есть в данных наблюдения, за две недели не видно вызовов». Она звучит как ответ на вопрос о последнем consumer, но отвечает только на вопрос о выбранном наборе наблюдений. Симптом — одно число или пустой график превращают в список пользователей. Цена ошибки — удалить путь для client, который не попал в окно, идёт по другой authorization boundary или не присылает нужный signal, а затем спорить, была ли это «неожиданная» зависимость.'),
p('Другой риск возникает раньше. Команда ставит <code>deprecated</code> в OpenAPI или отправляет warning, но называет это миграцией. Declaration, runtime signal и removal — разные состояния. Если смешать их, можно начать возврат только после поломки: replacement не описан, owner не назначен, Sunset date трактуется как жёсткая гарантия, а неизвестный consumer исчезает из таблицы. Практичнее держать отдельные evidence types и timeline с boundary для каждого шага.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Нулевой график или один access report объявлен доказательством, что у API больше нет пользователей.',
'<strong>Причина.</strong> Source usage, declared dependency, exposure or traffic, authorization и unknown consumer измеряют разные поверхности, но их сложили в один статус.',
'<strong>Проверка.</strong> Для каждой строки map укажите evidence type, scope, period, owner и то, чего этот тип не может доказать.',
'<strong>Действие.</strong> Unknown оставьте отдельным состоянием. Warning, deprecation, sunset и removal проводите по timeline, где у каждой boundary есть entry condition и stop condition.',
]),
h2('Пять разных источников сведений'),
p('Source usage отвечает на локальный вопрос: известен ли вызов в разрешённой кодовой области. Это полезный сигнал для named service, но не доказательство всех deployed clients. Declared dependency отвечает на другой вопрос: кто явно объявил SDK, schema или API contract. Такая запись может пережить migration и не говорить о runtime execution. Эти два слоя помогают найти owner, но не позволяют объявить population полной.'),
p('Exposure or traffic отвечает на вопрос об observed requests в конкретном инструменте, периоде и маршруте. Здесь особенно опасно слово «пользователь». Request не всегда равен человеку, а отсутствие request в окне не равно отсутствию caller. Authorization описывает credential class, permission или gateway policy: она может показать, какие классы способны использовать resource, но не гарантирует, что каждый класс реально делает вызов. Unknown consumer остаётся, когда scope нельзя честно замкнуть.'),
table('Evidence types для lifecycle API', ['Тип', 'Полезный вопрос', 'Типичная ложная подмена', 'Что записать рядом'], [
['Source usage', 'есть ли known call в согласованной code scope?', 'не нашли call → никто не использует API', 'repository or module scope, revision, owner, blind zones'],
['Declared dependency', 'кто объявил SDK, schema или contract?', 'зависимость → текущий runtime call', 'artifact, version, owner, migration target'],
['Exposure / traffic', 'что observed в заданном route and period?', 'нет samples → нет users', 'instrument, interval, sampling, auth and cache boundary'],
['Authorization', 'какой credential class может обратиться?', 'credential exists → active consumer', 'policy scope, owner, exceptions, review date'],
['Unknown consumer', 'какая часть population не доказана?', 'unknown → zero', 'reason, risk owner, next authorized check or decision'],
]),
figure('/assets/editorial/2024/deprecation-2024-timeline.svg', 'Timeline deprecation разделяет четыре состояния: documentation warning, declared deprecation, sunset boundary и отдельный removal gate. Под каждой стадией отмечены требуемые поля: replacement, owner, evidence scope, stop condition и restore boundary.', 'Временная шкала показывает порядок состояний, а не календарную политику. Даты и пункты в статье — fixed synthetic values; схема не читает telemetry и не указывает реальный срок отключения.'),
h2('Warning, deprecation, sunset и removal не являются одним событием'),
p('Warning — это discoverable documentation: consumer может увидеть replacement и условия перехода, но сам resource продолжает работать. OpenAPI 3.1.0 позволяет объявить operation устаревшей в contract. IETF draft-09 описывает Deprecation header как runtime signal и допускает Link на deprecation documentation. Ничто из этого не выключает endpoint. Это правильно: notice должен уменьшать появление новых зависимостей, не меняя semantics незаметно.'),
p('Sunset — следующий уровень риска. RFC 8594 описывает timestamp, после которого URI ожидаемо станет unresponsive. Документ специально отделяет стадию «не preferred» от decommission и называет Sunset hint. Поэтому нельзя использовать timestamp как доказательство того, что все callers успели перейти. Он задаёт boundary для plan, например: после неё compatibility может закончиться, но до actual removal всё равно нужен check, owner и безопасный stop.'),
table('Lifecycle timeline и условия перехода', ['Состояние', 'Что становится видимым', 'Что ещё запрещено утверждать', 'Boundary для следующего шага'], [
['Warning', 'replacement, owner, migration guide и scope notice', 'что caller увидел notice или начал migration', 'declaration and documentation reviewed'],
['Deprecated', 'OpenAPI flag or Deprecation signal для resource', 'что response semantics уже изменились или consumer ушли', 'named consumer map and evidence boundary recorded'],
['Sunset boundary', 'планируемая дата возможной недоступности URI', 'что endpoint обязательно выключится именно в момент timestamp', 'human review of residual risk and restore boundary'],
['Removal gate', 'отдельный decision о change', 'что hidden consumer невозможен', 'authorized evidence, stop condition, safe rollback or restore plan'],
]),
h2('Ограниченная воспроизводимая модель'),
p('Ниже нет HTTP call, file read, code search, customer record, CI or production. Функции принимают только один case id и возвращают fixed objects из этого модуля. Это намеренное ограничение. Оно позволяет проверить строгие input and report contracts: extra key, sparse array, forged decision и cyclic JSON не превращаются в «зелёный» план. Но fixture не доказывает ни один факт о реальном API.'),
code(fixtureExample),
p('В модели есть три case. <code>fixed-active-consumer-v1</code> блокирует removal, потому что одна fixed row active и другая unknown. <code>fixed-unknown-consumer-v1</code> показывает более неприятную ветку: visible row migrated, но authorization scope remains unknown. <code>fixed-migrated-consumer-v1</code> разрешает только human review proposal — не deletion — потому что named rows migrated, а undiscovered client не доказан отсутствующим.'),
h2('Почему строгая форма важнее красивого summary'),
p('Если report можно дополнить произвольным полем <code>traffic: 0</code>, следующий reviewer может принять внешний факт без scope and method. Если array consumer map sparse, «пустое место» выглядит как строка, но у него нет owner and evidence. Если forged decision заменяет <code>block-removal</code> на <code>remove-now</code>, description перестаёт соответствовать fixed case. Поэтому fixture требует exact keys, dense arrays, canonical JSON и безопасно отвергает cycle.'),
p('Это не security control для реальных data. Это дисциплина учебного примера: model should not create its own fake evidence. В реальной работе содержимое report нужно получать только в разрешённой процедуре и хранить с method, period, scope and owner. Даже тогда его следует читать как evidence for a question, not universal consumer list.'),
h2('Как построить timeline без ложной автоматизации'),
ol([
'<strong>Назовите resource scope.</strong> Method, URI template, operationId, version and replacement должны описывать один предмет, а не «весь API».',
'<strong>Опубликуйте warning.</strong> Добавьте migration guide, owner and communication path. Это уменьшает новые dependencies, но не подтверждает чтение notice.',
'<strong>Объявите deprecation.</strong> Сверьте OpenAPI contract и, если выбран HTTP signal, его resource scope. Не меняйте functional behavior под видом notice.',
'<strong>Поставьте sunset boundary.</strong> Назовите дату как planned availability boundary и document, что client must not treat it as hard promise.',
'<strong>Соберите evidence by type.</strong> Source, dependency, exposure, authorization and unknown rows не склеивайте. Для каждой запишите blind zone.',
'<strong>Откройте removal review.</strong> До отдельного change назовите active blockers, residual unknown, stop condition and restore boundary.',
]),
h2('Stop condition и restore boundary'),
p('Stop condition должен быть короче, чем план удаления. Пример: «появилась active row в разрешённой проверке» или «owner replacement contract не может подтвердить compatibility». В этот момент proposal останавливают, а не «дочищают» ещё два слоя, чтобы сохранить дату. Restore boundary отвечает на следующий вопрос: куда команда возвращается, пока реальное удаление не началось? Для API это обычно last documented compatible contract, а не магическое «вернуть всё назад».'),
p('В synthetic model <code>restoreSyntheticDeprecationReview</code> умеет только выбросить canonical in-memory draft. Это специально слабая операция. Она не открывает route, не трогает headers and configuration и не возвращает traffic. Настоящий rollback должен описывать authorization, schema and data compatibility отдельно. If a new client contract already writes irreversible state, a timestamp cannot be its restore plan.'),
h2('Ограничения и следующий проверяемый шаг'),
p('Эта статья не предлагает метод собирать customer list и не описывает реальную telemetry. В ней нет access-log query, real request count, credential, incident, metric или active account. Fixed labels созданы для проверки формата, а не для оценки нагрузки. SemVer и OpenAPI задают versioning and contract vocabulary, а RFC 8594 и IETF draft-09 задают lifecycle signals; ни один источник не делает один график доказательством отсутствия всех consumers.'),
p('Следующий шаг: возьмите один endpoint и создайте пять строк evidence types из таблицы. В каждой добавьте самый опасный blind zone. Если для строки exposure нельзя назвать tool and period, она остаётся unknown. Ожидаемый результат — timeline без фальшивого зелёного статуса: команда знает, что именно объявлено, что observed, что не доказано и какой факт остановит removal review.'),
h2('Историческая граница ноября 2024'),
p('Источники зафиксированы до ноября 2024: RFC 8594 (May 2019), IETF draft-ietf-httpapi-deprecation-header-09 (September 2024), OpenAPI Specification v3.1.0 и Semantic Versioning 2.0.0. Draft-09 в этот момент не был RFC. Его signal и RFC Sunset — information about lifecycle, не telemetry contract. Все labels, states, dates, actions and outcomes в fixture — fixed synthetic in-memory records; script не обращается к network, files, Git, CI, production, telemetry, authorization или customer data.'),
]);
const field = revision({
slug: 'editorial-2024-11-field-deprecation',
title: 'Последний consumer: доказуемое удаление',
categories: ['Рефакторинг', 'API'],
cover: '/assets/editorial/2024/deprecation-2024-removal-gate.svg',
excerpt: 'Три fixed synthetic case для removal gate: active, unknown и migrated. Как остановить удаление, зафиксировать residual risk и не назвать учебную карту доказательством отсутствия скрытых потребителей.',
readingMinutes: 13,
}, [
p('В конце migration часто остаётся один вопрос: «кто последний consumer?». Он опасен не потому, что на него нельзя ответить, а потому, что звучит как просьба о полном списке пользователей. Команда находит несколько migrated integrations, открывает задачу удаления и считает путь свободным. Цена ошибки — hidden client получает 4xx после change, а у владельца нет сохранённой boundary: что именно было проверено, кто принял residual risk и можно ли безопасно остановиться до разрушения compatibility.'),
p('Противоположная крайность — не удалять ничего, пока не появится невозможное доказательство отсутствия всех неизвестных. Так старый контракт навсегда остаётся в code, documentation и test matrix. Практический выход не в обещании «скрытых consumers нет». Он в removal gate, то есть наборе условий допуска к удалению: separate known evidence from unknown, назвать stop condition, оставить restore boundary и дать human owner принять решение по ограниченному scope.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Все известные integrations migrated, но никто не может доказать, что hidden caller невозможен.',
'<strong>Причина.</strong> Migration status named rows перепутали с complete population, а removal change не имеет отдельного gate и restore boundary.',
'<strong>Проверка.</strong> Разложите результат на three cases: active, unknown and migrated. Для каждого укажите, что blocked, кто owner и где изменение должно остановиться.',
'<strong>Действие.</strong> Active and unknown блокируют automatic removal. Migrated открывает только human review с residual risk; actual deletion остаётся отдельным authorized change.',
]),
h2('Три fixed synthetic case'),
p('Первый case — <code>fixed-active-consumer-v1</code>. В нём fixed map содержит active source-usage row для <code>synthetic-web-checkout</code> и unknown integrator. Важно не то, как эти строки получены: они не получены из реального code search. Важно, что модель не разрешает спорить с собственным фактом. Пока active row есть, removal gate закрыт. Next step — не новое окно наблюдения, а owner and replacement discussion для указанной operation.'),
p('Второй case — <code>fixed-unknown-consumer-v1</code>. Named declared dependency уже migrated, но authorization class остаётся unknown, а exposure label говорит только <code>not-observed-in-model</code>. Это не ноль. Такое состояние сложнее active: нельзя дать migration task конкретному consumer, но и нельзя безопасно вычеркнуть риск. Gate требует human decision: ограничить scope authorised evidence, отложить removal или сохранить compatibility.'),
p('Третий case — <code>fixed-migrated-consumer-v1</code>. Два named rows migrated, replacement contract and announcement записаны, но <code>synthetic-undiscovered-client</code> остаётся <code>unknown-not-proved-absent</code>. Модель поэтому не возвращает <code>safe-to-delete</code>. Она разрешает только <code>allow-human-removal-review-only</code>: сформировать proposal с residual risk and restore boundary. Это честнее, чем объявить map полным без способа проверить population.'),
table('Три case и честный вердикт removal gate', ['Fixed case', 'Что известно в model', 'Что остаётся неизвестным', 'Вердикт', 'Следующий шаг'], [
['active', 'одна named row active; contract and owner defined', 'полнота неизвестной внешней scope', 'block removal', 'владелец active row согласует replacement and migration'],
['unknown', 'одна visible dependency migrated', 'authorization scope and exposure population', 'block removal', 'human owner ограничивает authorised check or keeps compatibility'],
['migrated', 'named rows migrated; replacement, deadline and notice recorded', 'hidden consumer not proved absent', 'human review only', 'оформить residual risk, stop condition and separate removal change'],
]),
figure('/assets/editorial/2024/deprecation-2024-removal-gate.svg', 'Removal gate с тремя ветками: active ведёт к миграции, unknown — к ограничению scope или сохранению совместимости, migrated — к human review. Все ветки сходятся только в отдельный authorized removal change с stop condition и restore boundary.', 'Схема не обещает обнаружить всех hidden consumers. Она показывает, почему active, unknown и migrated должны оставаться разными состояниями до реального change.'),
h2('Removal gate проверяет отрицательные условия'),
p('Gate полезен, когда сформулирован как набор причин не удалять. Нет active named row. Нет replacement without owner. Announcement and deadline сохранены. Unknown не спрятан под нулём. Restore boundary существует. Такой список звучит медленнее, чем deadline, но делает стоимость решения видимой. Если один пункт не выполнен, scope удаления нельзя расширять.'),
table('Removal gate перед отдельным change', ['Проверка', 'Пройти можно, если', 'Stop condition', 'Безопасное действие'], [
['Contract scope', 'одна operation и replacement описаны без двусмысленности', 'route, method or response semantics спорны', 'остановить proposal и сузить contract'],
['Known consumers', 'каждая named row имеет owner and migration path', 'active row или owner missing', 'не удалять; вернуть migration к owner'],
['Unknown boundary', 'unknown явно записан и residual risk имеет owner', 'unknown назван «нулём» без method', 'заблокировать automatic removal'],
['Announcement', 'migration guide, deadline and affected scope доступны', 'notice не связан с replacement', 'обновить communication record before review'],
['Restore boundary', 'есть last compatible contract и criterion остановки', 'rollback требует неизвестных data or auth changes', 'не начинать removal change'],
]),
h2('Воспроизводимый учебный прогон'),
p('Пример ниже проверяет pure in-memory object. Он не показывает real API status. Его ценность в другом: input принимает только exact case id; planner принимает только canonical report; forged map, extra key, sparse array and cyclic JSON не могут пройти как valid removal plan. Это минимальная защита от того, чтобы красивый summary подменил структуру доказательства ещё до настоящего review.'),
code([
"import {",
" createFixedSyntheticDeprecationInput,",
" inspectSyntheticDeprecation,",
" planSyntheticDeprecationReview,",
" restoreSyntheticDeprecationReview,",
"} from './upgrade-2024-11.mjs';",
'',
"const report = inspectSyntheticDeprecation(",
" createFixedSyntheticDeprecationInput('fixed-migrated-consumer-v1'),",
");",
'const proposal = planSyntheticDeprecationReview(report);',
'const stopped = restoreSyntheticDeprecationReview(proposal);',
'',
'console.log(report.decision.code); // allow-human-removal-review-only',
'console.log(stopped.restored); // true: only draft is discarded',
'',
'// The model does not query an endpoint or restore a deployed route.',
].join('\n')),
p('Если заменить fixed report на report с extra <code>traffic</code>, or make consumer map sparse, planner returns rejected object. Если добавить cyclic decision, comparison also rejects it without throwing. Fixture covers these negative branches. PASS means only that the three artificial cases retain their shape and boundaries. It does not mean v1 has no consumers, that v2 is compatible or that a production rollback would succeed.'),
h2('Stop condition нужно назвать до удаления'),
p('Stop condition — это не «если что-то пойдёт не так». Он должен быть наблюдаемым и привязанным к scope: active consumer found in an authorized check; replacement contract cannot preserve an agreed error or idempotency boundary; unknown risk cannot be accepted by named owner; migration guide has no reachable communication path. После stop condition change не расширяют и не склеивают с redesign. Возвращаются к last documented compatible contract и открывают отдельный вопрос.'),
p('Restore boundary ограничивает обещание. Для status before a real change достаточно сказать: removal proposal stopped, old compatibility stays documented, draft discarded. После реального route removal это уже другой plan: кто может re-enable, какие credentials, data and schema remain compatible, как проверить response and client recovery. Если этих фактов нет, нельзя называть operation reversible. Пустая строка «rollback available» хуже, чем явное <code>unknown</code>.'),
h2('Checklist для human review'),
ol([
'<strong>Сверьте один contract.</strong> Method, URI, operationId, authentication, request and response boundary должны совпадать между notice, map and replacement.',
'<strong>Прочитайте rows по классу.</strong> Не складывайте source usage, declared dependency, observed traffic and authorization в единый count.',
'<strong>Оставьте unknown видимым.</strong> Укажите reason, owner and next authorised question. Не называйте его отсутствием consumer.',
'<strong>Проверьте replacement.</strong> Migration path должен вести к contract, который имеет owner and compatibility scope, а не просто к новому URL.',
'<strong>Проверьте announcement.</strong> Documentation link, deadline and affected scope должны быть доступны до Sunset boundary.',
'<strong>Назовите stop condition.</strong> Один факт должен прекращать proposal без попытки одновременно исправить route, data and policy.',
'<strong>Отделите removal change.</strong> Только после review отдельное изменение получает tests, approvals, deployment and restore plan; deprecation record не выполняет эти действия сам.',
]),
h2('Почему header не закрывает поле доказательств'),
p('Deprecation header и OpenAPI flag полезны для communication. IETF draft-09 говорит, что deprecation itself does not change resource behavior. RFC 8594 говорит, что Sunset timestamp is a hint and does not tell which response follows afterwards. Эти свойства как раз защищают migration: client получает notice before path becomes unavailable. Но client can ignore notice, cache a contract or be outside the chosen delivery boundary. Therefore headers belong to announcement row, not to final proof of removal.'),
p('SemVer 2.0.0 similarly helps communicate public API change when it applies: deprecating public functionality increments minor version, incompatible deletion requires major version. It does not choose an acceptable residual risk or inventory consumer. Versioning makes contract evolution explicit; removal gate makes the operational decision reviewable. These layers should reinforce each other, not impersonate each other.'),
h2('Ограничения и следующий проверяемый шаг'),
p('В статье нет real client names, calls, traffic, authorization records, customer accounts, incidents or metrics. The three cases are fixed synthetic records embedded in one JS module. They do not read files, Git, network, CI, clock, production or telemetry. Unknown is deliberately preserved as a limitation, so the material does not promise that hidden consumers are absent.'),
p('Следующий шаг: для одного deprecated operation проведите этот checklist с владельцем replacement. Укажите one active, one unknown or one migrated verdict — whichever is honest — и отдельно зафиксируйте stop condition. Ожидаемый результат: team either blocks removal for a concrete reason or opens a narrowly scoped human review with an explicit residual risk, rather than deleting an endpoint because the calendar reached a date.'),
h2('Историческая граница ноября 2024'),
p('Материал использует RFC 8594 (May 2019) для Sunset, IETF draft-ietf-httpapi-deprecation-header-09 (September 2024) для deprecation signal, OpenAPI Specification v3.1.0 для declaration и Semantic Versioning 2.0.0 для versioning vocabulary. На историческую дату draft-09 был draft, не RFC. Fixed consumer names, dates, evidence labels, outcomes and restore actions — teaching values. Они не являются результатом real telemetry, source search, authorization review, customer communication, incident or production deletion.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runDeprecationFixture();
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');