Files
progcode/web/scripts/upgrade-2025-05.mjs
T
huncode fa6d1c3eac
Build and deploy / deploy (push) Successful in 15s
revise May 2025 engineering automation articles
2026-07-31 17:48:51 +03:00

916 lines
85 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: 'HashiCorp Terraform plan, immutable commit 898e397 for v1.10.5 (22 January 2025)',
url: 'https://raw.githubusercontent.com/hashicorp/terraform/898e397ebaefb4d0c363b7332b3ba184b8852162/website/docs/cli/commands/plan.mdx',
version: 'Terraform v1.10.5 tag points to commit 898e397ebaefb4d0c363b7332b3ba184b8852162, dated 2025-01-22',
claim: 'Документ говорит, что plan сам не выполняет proposed changes; speculative plan описывает эффект без намерения применить его, а изменения target system между plan и apply могут изменить final effect, поэтому final non-speculative plan надо перепроверять.',
boundary: 'Документация Terraform не задаёт универсальный protocol approval, состав audit trail или rollback для произвольной автоматизации и не доказывает корректность конкретного change.',
},
{
title: 'GitHub Docs: managing environments for deployment, immutable commit 6a92295 (31 January 2025)',
url: 'https://raw.githubusercontent.com/github/docs/6a92295d73cd86b37a810a87f45e7deb21d10a0f/content/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment.md',
version: 'github/docs immutable commit 6a92295d73cd86b37a810a87f45e7deb21d10a0f, dated 2025-01-31',
claim: 'GitHub описывает, что job, ссылающийся на environment, не стартует и не получает environment secrets до прохождения protection rules; required reviewer может пропустить job, а bypass зависит от отдельной настройки environment.',
boundary: 'Это поведение GitHub Actions environments в описанной версии. Оно не подтверждает семантическую верность diff, полноту preview, независимость reviewer или безопасность другого CI и production процесса.',
},
{
title: 'Kubernetes kubectl apply reference, immutable website commit c889d9b (14 March 2024)',
url: 'https://raw.githubusercontent.com/kubernetes/website/c889d9b2510bba78dbb527a79b8e2099b16f3d67/content/en/docs/reference/kubectl/generated/kubectl_apply/_index.md',
version: 'kubernetes/website immutable commit c889d9b2510bba78dbb527a79b8e2099b16f3d67, dated 2024-03-14',
claim: 'Справочник kubectl apply различает dry-run=client, который печатает object без отправки, и dry-run=server, который отправляет server-side request без persistence ресурса.',
boundary: 'Этот справочник не обещает, что последующий реальный apply увидит то же состояние, пройдёт те же policy или не затронет другие объекты. Он описывает только режим dry-run конкретной команды.',
},
]);
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">' + escapeHtml(source.title) + '</a> — ' + escapeHtml(source.claim) + ' Граница: ' + escapeHtml(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 = 'fixed-synthetic-engineering-automation-2025-05-v1';
const OPERATION_KIND = 'fixed-synthetic-operation-card-v1';
const AUTHORITY_KIND = 'fixed-synthetic-authority-card-v1';
const APPROVAL_KIND = 'fixed-synthetic-approval-card-v1';
const PREVIEW_KIND = 'fixed-synthetic-preview-report-v1';
const APPROVAL_REQUEST_KIND = 'fixed-synthetic-approval-request-v1';
const EXECUTION_KIND = 'fixed-synthetic-execution-receipt-v1';
const VERIFICATION_KIND = 'fixed-synthetic-verification-report-v1';
const STOP_KIND = 'fixed-synthetic-stop-and-rollback-plan-v1';
const SYNTHETIC_SCOPE = 'p87-engineering-automation-2025-05';
const MODEL_LIMIT = 'fixed-synthetic-in-memory-only-no-files-no-git-no-network-no-ci-no-clock-no-telemetry-no-production-no-cloud-no-real-api-no-real-change';
const EFFECT_BOUNDARY = deepFreeze({
model: MODEL_LIMIT,
files: 'not-read-or-written',
git: 'not-read-or-written',
network: 'not-used',
ci: 'not-run',
clock: 'not-read',
telemetry: 'not-read',
production: 'not-contacted',
cloud: 'not-contacted',
realApi: 'not-called',
realChange: 'not-attempted',
auditDurability: 'in-memory-object-freeze-only-not-a-durable-or-tamper-proof-log',
rollback: 'plan-only-not-executed',
});
function makeCard(caseId, operationId, targetIds, targetDigest, requestedAuthority, changeDigest) {
return {
kind: OPERATION_KIND,
version: MODEL_VERSION,
caseId,
operationId,
requestedAuthority,
targetSelection: {
kind: 'fixed-synthetic-target-selection-v1',
selectorId: 'synthetic-doc-labels-scope-v1',
targetIds,
targetDigest,
},
proposedChange: {
kind: 'fixed-synthetic-change-v1',
changeType: 'normalize-synthetic-document-label',
expectedFrom: 'legacy',
desiredTo: 'stable',
changeDigest,
},
sequence: ['preview', 'bounded-scope', 'approval', 'simulate-execute', 'audit-record', 'verify', 'stop-or-plan-rollback'],
};
}
function makeAuthority(caseId, authorityId, level, maxTargets, selectorId) {
return {
kind: AUTHORITY_KIND,
version: MODEL_VERSION,
caseId,
authorityId,
level,
maxTargets,
selectorId,
grantBoundary: 'fixed-synthetic-example-only-not-an-identity-or-production-permission',
};
}
function makeApproval(caseId, approvalId, operationId, targetDigest, authorityId, decision) {
return {
kind: APPROVAL_KIND,
version: MODEL_VERSION,
caseId,
approvalId,
operationId,
targetDigest,
authorityId,
decision,
approvedAt: '2025-05-20T10:00:00Z',
approvalBoundary: 'fixed-synthetic-review-marker-only-not-a-human-or-production-approval',
};
}
const FIXED_CASES = deepFreeze({
'fixed-bounded-approved-v1': {
card: makeCard(
'fixed-bounded-approved-v1',
'synthetic-normalize-doc-labels-bounded-v1',
['synthetic-doc-a', 'synthetic-doc-b'],
'synthetic-targets-a-b-v1',
'synthetic-authority-l2',
'synthetic-change-legacy-to-stable-v1',
),
authority: makeAuthority(
'fixed-bounded-approved-v1',
'synthetic-authority-l2',
'bounded-two-targets',
2,
'synthetic-doc-labels-scope-v1',
),
approval: makeApproval(
'fixed-bounded-approved-v1',
'synthetic-approval-bounded-v1',
'synthetic-normalize-doc-labels-bounded-v1',
'synthetic-targets-a-b-v1',
'synthetic-authority-l2',
'approve-bounded-simulation',
),
},
'fixed-over-scope-v1': {
card: makeCard(
'fixed-over-scope-v1',
'synthetic-normalize-doc-labels-over-scope-v1',
['synthetic-doc-a', 'synthetic-doc-b', 'synthetic-doc-c'],
'synthetic-targets-a-b-c-v1',
'synthetic-authority-l2',
'synthetic-change-legacy-to-stable-v1',
),
authority: makeAuthority(
'fixed-over-scope-v1',
'synthetic-authority-l2',
'bounded-two-targets',
2,
'synthetic-doc-labels-scope-v1',
),
approval: null,
},
'fixed-target-mismatch-v1': {
card: makeCard(
'fixed-target-mismatch-v1',
'synthetic-normalize-doc-labels-mismatch-v1',
['synthetic-doc-a', 'synthetic-doc-b'],
'synthetic-targets-a-b-v1',
'synthetic-authority-l2',
'synthetic-change-legacy-to-stable-v1',
),
authority: makeAuthority(
'fixed-target-mismatch-v1',
'synthetic-authority-l2',
'bounded-two-targets',
2,
'synthetic-doc-labels-scope-v1',
),
approval: makeApproval(
'fixed-target-mismatch-v1',
'synthetic-approval-mismatched-target-v1',
'synthetic-normalize-doc-labels-mismatch-v1',
'synthetic-targets-a-c-v1',
'synthetic-authority-l2',
'approve-bounded-simulation',
),
},
'fixed-lower-authority-v1': {
card: makeCard(
'fixed-lower-authority-v1',
'synthetic-normalize-doc-labels-lower-authority-v1',
['synthetic-doc-a', 'synthetic-doc-b'],
'synthetic-targets-a-b-v1',
'synthetic-authority-l1',
'synthetic-change-legacy-to-stable-v1',
),
authority: makeAuthority(
'fixed-lower-authority-v1',
'synthetic-authority-l1',
'single-target-only',
1,
'synthetic-doc-labels-scope-v1',
),
approval: null,
},
});
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
}
function hasExactKeys(value, expected) {
if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) return false;
const actual = Object.keys(value).sort();
const wanted = [...expected].sort();
return actual.length === wanted.length && actual.every((item, index) => item === wanted[index]);
}
function canonicalJson(value, stack = new Set()) {
if (value === null) return 'null';
if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
if (typeof value === 'number') return Number.isFinite(value) ? JSON.stringify(value) : null;
if (typeof value !== 'object' || Object.getOwnPropertySymbols(value).length > 0 || stack.has(value)) return null;
stack.add(value);
let result = null;
if (Array.isArray(value)) {
const keys = Object.keys(value);
if (keys.length === value.length && keys.every((key, index) => key === String(index))) {
const entries = [];
for (const item of value) {
const encoded = canonicalJson(item, stack);
if (encoded === null) {
stack.delete(value);
return null;
}
entries.push(encoded);
}
result = '[' + entries.join(',') + ']';
}
} else if (isPlainObject(value)) {
const entries = [];
for (const key of Object.keys(value).sort()) {
const encoded = canonicalJson(value[key], stack);
if (encoded === null) {
stack.delete(value);
return null;
}
entries.push(JSON.stringify(key) + ':' + encoded);
}
result = '{' + entries.join(',') + '}';
}
stack.delete(value);
return result;
}
function hasSameCanonicalJson(actual, expected) {
const actualJson = canonicalJson(actual);
const expectedJson = canonicalJson(expected);
return actualJson !== null && expectedJson !== null && actualJson === expectedJson;
}
function cloneFixed(value) {
const encoded = canonicalJson(value);
if (encoded === null) throw new Error('fixed synthetic value must be canonical');
return deepFreeze(JSON.parse(encoded));
}
function rejected(kind, reason, extra = {}) {
return deepFreeze({
kind,
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
accepted: false,
stopped: true,
reason,
productionEffect: 'not-attempted',
boundary: EFFECT_BOUNDARY,
...extra,
});
}
function caseByCard(card) {
return Object.entries(FIXED_CASES).find(([, fixed]) => hasSameCanonicalJson(card, fixed.card));
}
function previewFor(caseId) {
const fixed = FIXED_CASES[caseId];
if (!fixed) return null;
const card = fixed.card;
return deepFreeze({
kind: PREVIEW_KIND,
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
caseId,
operationId: card.operationId,
targetSelection: cloneFixed(card.targetSelection),
proposedChange: cloneFixed(card.proposedChange),
requestedAuthority: card.requestedAuthority,
previewDigest: 'synthetic-preview-' + caseId + '-v1',
status: 'preview-only-requires-bounded-authority-and-approval',
previewMeaning: 'fixed synthetic proposal, not a real diff, query, dry-run against a live system, or execution guarantee',
productionEffect: 'not-attempted',
boundary: EFFECT_BOUNDARY,
});
}
function approvalRequestFor(caseId) {
const preview = previewFor(caseId);
if (!preview) return null;
return deepFreeze({
kind: APPROVAL_REQUEST_KIND,
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
caseId,
operationId: preview.operationId,
targetSelection: cloneFixed(preview.targetSelection),
requestedAuthority: preview.requestedAuthority,
previewDigest: preview.previewDigest,
status: 'awaiting-fixed-synthetic-approval',
authorityMeaning: 'bounded synthetic gate, not a human identity, legal authority, production permission, or correctness proof',
productionEffect: 'not-attempted',
boundary: EFFECT_BOUNDARY,
});
}
function approvedFor(caseId) {
const fixed = FIXED_CASES[caseId];
const request = approvalRequestFor(caseId);
if (!fixed || !request || !fixed.approval || caseId !== 'fixed-bounded-approved-v1') return null;
return deepFreeze({
kind: 'fixed-synthetic-approved-operation-v1',
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
caseId,
operationId: request.operationId,
targetSelection: cloneFixed(request.targetSelection),
previewDigest: request.previewDigest,
authorityId: fixed.authority.authorityId,
approvalId: fixed.approval.approvalId,
approvalDecision: fixed.approval.decision,
status: 'approved-for-simulation-only',
productionEffect: 'not-attempted',
boundary: EFFECT_BOUNDARY,
});
}
function receiptFor(caseId) {
const approved = approvedFor(caseId);
if (!approved) return null;
const auditRecord = deepFreeze({
kind: 'fixed-synthetic-immutable-audit-record-v1',
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
recordId: 'synthetic-audit-bounded-v1',
operationId: approved.operationId,
targetDigest: approved.targetSelection.targetDigest,
previewDigest: approved.previewDigest,
authorityId: approved.authorityId,
approvalId: approved.approvalId,
simulatedAt: '2025-05-20T10:00:00Z',
event: 'simulation-recorded-no-real-change',
immutabilityBoundary: 'Object.freeze in this fixed in-memory model; not durable storage, signature, append-only infrastructure, or tamper-proof evidence',
});
return deepFreeze({
kind: EXECUTION_KIND,
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
caseId,
operationId: approved.operationId,
targetSelection: cloneFixed(approved.targetSelection),
status: 'simulated-execution-recorded-no-real-change',
auditRecord,
productionEffect: 'not-attempted',
boundary: EFFECT_BOUNDARY,
});
}
function verificationFor(caseId) {
const receipt = receiptFor(caseId);
if (!receipt) return null;
return deepFreeze({
kind: VERIFICATION_KIND,
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
caseId,
operationId: receipt.operationId,
auditRecordId: receipt.auditRecord.recordId,
targetDigest: receipt.targetSelection.targetDigest,
status: 'requires-external-verification-not-performed',
verificationMeaning: 'the model checks only the exact synthetic receipt; it does not read a target, compare real state, or attest that a change happened',
productionEffect: 'not-attempted',
boundary: EFFECT_BOUNDARY,
});
}
export function createFixedSyntheticOperationCard(caseId) {
const fixed = FIXED_CASES[caseId];
return fixed ? cloneFixed(fixed.card) : null;
}
export function createFixedSyntheticAuthority(caseId) {
const fixed = FIXED_CASES[caseId];
return fixed ? cloneFixed(fixed.authority) : null;
}
export function createFixedSyntheticApproval(caseId) {
const fixed = FIXED_CASES[caseId];
return fixed && fixed.approval ? cloneFixed(fixed.approval) : null;
}
export function previewSyntheticAutomation(card) {
const expectedCardKeys = ['caseId', 'kind', 'operationId', 'proposedChange', 'requestedAuthority', 'sequence', 'targetSelection', 'version'];
if (!hasExactKeys(card, expectedCardKeys)) {
return rejected(PREVIEW_KIND, 'invalid-operation-card-shape');
}
if (!hasExactKeys(card.targetSelection, ['kind', 'selectorId', 'targetDigest', 'targetIds']) || !hasExactKeys(card.proposedChange, ['changeDigest', 'changeType', 'desiredTo', 'expectedFrom', 'kind'])) {
return rejected(PREVIEW_KIND, 'invalid-operation-subcontract');
}
const entry = caseByCard(card);
if (!entry) return rejected(PREVIEW_KIND, 'untrusted-or-unknown-operation-card');
return previewFor(entry[0]);
}
export function requestSyntheticApproval(preview, authority) {
if (!isPlainObject(preview) || !isPlainObject(authority) || !Object.hasOwn(FIXED_CASES, preview.caseId)) {
return rejected(APPROVAL_REQUEST_KIND, 'missing-or-unknown-preview-or-authority');
}
const fixed = FIXED_CASES[preview.caseId];
if (!hasSameCanonicalJson(preview, previewFor(preview.caseId))) {
return rejected(APPROVAL_REQUEST_KIND, 'untrusted-preview-report');
}
if (!hasSameCanonicalJson(authority, fixed.authority)) {
return rejected(APPROVAL_REQUEST_KIND, 'forged-or-wrong-authority-card');
}
if (authority.authorityId !== preview.requestedAuthority) {
return rejected(APPROVAL_REQUEST_KIND, 'authority-id-does-not-match-request');
}
if (authority.selectorId !== preview.targetSelection.selectorId) {
return rejected(APPROVAL_REQUEST_KIND, 'authority-selector-does-not-match-target-scope');
}
if (preview.targetSelection.targetIds.length > authority.maxTargets) {
return rejected(APPROVAL_REQUEST_KIND, 'stop-scope-exceeds-authority-limit', {
targetCount: preview.targetSelection.targetIds.length,
authorityMaxTargets: authority.maxTargets,
});
}
return approvalRequestFor(preview.caseId);
}
export function approveSyntheticOperation(request, approval) {
if (!isPlainObject(request) || !isPlainObject(approval) || !Object.hasOwn(FIXED_CASES, request.caseId)) {
return rejected('fixed-synthetic-approved-operation-v1', 'missing-or-unknown-approval-input');
}
const fixed = FIXED_CASES[request.caseId];
if (!hasSameCanonicalJson(request, approvalRequestFor(request.caseId))) {
return rejected('fixed-synthetic-approved-operation-v1', 'untrusted-approval-request');
}
if (!fixed.approval || !hasSameCanonicalJson(approval, fixed.approval)) {
return rejected('fixed-synthetic-approved-operation-v1', 'forged-or-missing-approval-card');
}
if (approval.operationId !== request.operationId || approval.targetDigest !== request.targetSelection.targetDigest || approval.authorityId !== request.requestedAuthority) {
return rejected('fixed-synthetic-approved-operation-v1', 'stop-approval-does-not-bind-preview-target-and-authority');
}
return approvedFor(request.caseId);
}
export function simulateSyntheticExecution(approved) {
if (!isPlainObject(approved) || approved.caseId !== 'fixed-bounded-approved-v1' || !hasSameCanonicalJson(approved, approvedFor('fixed-bounded-approved-v1'))) {
return rejected(EXECUTION_KIND, 'stop-no-exact-approved-synthetic-operation');
}
return receiptFor('fixed-bounded-approved-v1');
}
export function verifySyntheticExecution(receipt) {
if (!isPlainObject(receipt) || receipt.caseId !== 'fixed-bounded-approved-v1' || !hasSameCanonicalJson(receipt, receiptFor('fixed-bounded-approved-v1'))) {
return rejected(VERIFICATION_KIND, 'untrusted-or-forged-synthetic-audit-receipt');
}
return verificationFor('fixed-bounded-approved-v1');
}
export function stopAndPlanSyntheticRollback(value) {
const validVerification = hasSameCanonicalJson(value, verificationFor('fixed-bounded-approved-v1'));
const validReceipt = hasSameCanonicalJson(value, receiptFor('fixed-bounded-approved-v1'));
if (!validVerification && !validReceipt) {
return rejected(STOP_KIND, 'stop-untrusted-input-before-rollback-plan');
}
return deepFreeze({
kind: STOP_KIND,
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
stopped: true,
reason: validVerification ? 'verification-not-performed-outside-fixed-model' : 'simulation-boundary-before-external-verification',
rollbackPlan: {
kind: 'fixed-synthetic-rollback-plan-v1',
status: 'planned-only-not-executed',
preconditions: ['identify-exact-scope', 'confirm-observed-state-outside-model', 'obtain-new-authority', 'create-new-preview', 'approve-separate-rollback-operation'],
boundary: 'this is a fixed in-memory plan, not a rollback command or permission to change anything',
},
productionEffect: 'not-attempted',
boundary: EFFECT_BOUNDARY,
});
}
export function runEngineeringAutomationFixture() {
const boundedCard = createFixedSyntheticOperationCard('fixed-bounded-approved-v1');
const boundedAuthority = createFixedSyntheticAuthority('fixed-bounded-approved-v1');
const boundedApproval = createFixedSyntheticApproval('fixed-bounded-approved-v1');
const boundedPreview = previewSyntheticAutomation(boundedCard);
const boundedRequest = requestSyntheticApproval(boundedPreview, boundedAuthority);
const boundedApproved = approveSyntheticOperation(boundedRequest, boundedApproval);
const boundedReceipt = simulateSyntheticExecution(boundedApproved);
const boundedVerification = verifySyntheticExecution(boundedReceipt);
const boundedStop = stopAndPlanSyntheticRollback(boundedVerification);
const overScopeCard = createFixedSyntheticOperationCard('fixed-over-scope-v1');
const overScopePreview = previewSyntheticAutomation(overScopeCard);
const overScopeRequest = requestSyntheticApproval(overScopePreview, createFixedSyntheticAuthority('fixed-over-scope-v1'));
const mismatchCard = createFixedSyntheticOperationCard('fixed-target-mismatch-v1');
const mismatchPreview = previewSyntheticAutomation(mismatchCard);
const mismatchRequest = requestSyntheticApproval(mismatchPreview, createFixedSyntheticAuthority('fixed-target-mismatch-v1'));
const mismatchApproval = approveSyntheticOperation(mismatchRequest, createFixedSyntheticApproval('fixed-target-mismatch-v1'));
const lowerCard = createFixedSyntheticOperationCard('fixed-lower-authority-v1');
const lowerPreview = previewSyntheticAutomation(lowerCard);
const lowerAuthorityRequest = requestSyntheticApproval(lowerPreview, createFixedSyntheticAuthority('fixed-lower-authority-v1'));
const unknown = previewSyntheticAutomation({ ...boundedCard, caseId: 'fixed-unknown-v1' });
const extraField = previewSyntheticAutomation({ ...boundedCard, telemetry: 'forbidden' });
const missingField = previewSyntheticAutomation({ kind: OPERATION_KIND, version: MODEL_VERSION });
const forgedTarget = previewSyntheticAutomation({
...boundedCard,
targetSelection: { ...boundedCard.targetSelection, targetDigest: 'synthetic-targets-forged-v1' },
});
const sparseCard = cloneFixed(boundedCard);
const sparseTargets = [];
sparseTargets[1] = sparseCard.targetSelection.targetIds[1];
const sparseResult = previewSyntheticAutomation({
...sparseCard,
targetSelection: { ...sparseCard.targetSelection, targetIds: sparseTargets },
});
const cyclicCard = { ...boundedCard };
cyclicCard.self = cyclicCard;
const cyclicResult = previewSyntheticAutomation(cyclicCard);
const forgedAuthority = requestSyntheticApproval(boundedPreview, { ...boundedAuthority, maxTargets: 3 });
const forgedRequest = requestSyntheticApproval({ ...boundedPreview, previewDigest: 'forged-preview-v1' }, boundedAuthority);
const forgedApproval = approveSyntheticOperation(boundedRequest, { ...boundedApproval, authorityId: 'forged-authority-v1' });
const missingApproval = approveSyntheticOperation(boundedRequest, null);
const forgedApproved = simulateSyntheticExecution({ ...boundedApproved, approvalId: 'forged-approval-v1' });
const forgedReceipt = verifySyntheticExecution({
...boundedReceipt,
auditRecord: { ...boundedReceipt.auditRecord, recordId: 'forged-audit-v1' },
});
const sparseReceipt = { ...boundedReceipt, targetSelection: { ...boundedReceipt.targetSelection, targetIds: [] } };
sparseReceipt.targetSelection.targetIds[1] = 'synthetic-doc-b';
const sparseReceiptResult = verifySyntheticExecution(sparseReceipt);
const cyclicReceipt = { ...boundedReceipt };
cyclicReceipt.loop = cyclicReceipt;
const cyclicReceiptResult = verifySyntheticExecution(cyclicReceipt);
const forgedStop = stopAndPlanSyntheticRollback({ ...boundedVerification, status: 'verified-production-change' });
const fixedBefore = canonicalJson(FIXED_CASES['fixed-bounded-approved-v1']);
void previewSyntheticAutomation(boundedCard);
const fixedAfter = canonicalJson(FIXED_CASES['fixed-bounded-approved-v1']);
return deepFreeze({
kind: 'fixed-synthetic-engineering-automation-fixture-v1',
version: MODEL_VERSION,
scope: SYNTHETIC_SCOPE,
assertions: {
boundedPreviewIsOnlyPreview: boundedPreview.status === 'preview-only-requires-bounded-authority-and-approval' && boundedPreview.productionEffect === 'not-attempted',
boundedPreviewKeepsExactTargets: boundedPreview.targetSelection.targetIds.join(',') === 'synthetic-doc-a,synthetic-doc-b' && boundedPreview.targetSelection.targetDigest === 'synthetic-targets-a-b-v1',
authorityCreatesSeparateGate: boundedRequest.status === 'awaiting-fixed-synthetic-approval' && boundedRequest.accepted === undefined,
boundedApprovalBindsOperationAndTarget: boundedApproved.status === 'approved-for-simulation-only' && boundedApproved.approvalId === 'synthetic-approval-bounded-v1',
simulationNeverClaimsRealExecution: boundedReceipt.status === 'simulated-execution-recorded-no-real-change' && boundedReceipt.productionEffect === 'not-attempted',
auditRecordIsFrozenInMemoryOnly: Object.isFrozen(boundedReceipt.auditRecord) && boundedReceipt.auditRecord.immutabilityBoundary.includes('not durable storage'),
verificationDoesNotClaimObservedState: boundedVerification.status === 'requires-external-verification-not-performed' && boundedVerification.productionEffect === 'not-attempted',
safeStopCreatesPlanOnly: boundedStop.stopped === true && boundedStop.rollbackPlan.status === 'planned-only-not-executed',
safeStopNamesNewAuthorityForRollback: boundedStop.rollbackPlan.preconditions.includes('obtain-new-authority'),
overScopeStopsBeforeApproval: overScopeRequest.accepted === false && overScopeRequest.reason === 'stop-scope-exceeds-authority-limit',
overScopeNamesCounts: overScopeRequest.targetCount === 3 && overScopeRequest.authorityMaxTargets === 2,
targetMismatchStopsAtApproval: mismatchApproval.accepted === false && mismatchApproval.reason === 'stop-approval-does-not-bind-preview-target-and-authority',
lowerAuthorityStopsBeforeApproval: lowerAuthorityRequest.accepted === false && lowerAuthorityRequest.reason === 'stop-scope-exceeds-authority-limit',
unknownCaseFailsClosed: unknown.accepted === false && unknown.reason === 'untrusted-or-unknown-operation-card',
unexpectedFieldFailsClosed: extraField.accepted === false && extraField.reason === 'invalid-operation-card-shape',
missingFieldsFailClosed: missingField.accepted === false && missingField.reason === 'invalid-operation-card-shape',
forgedTargetFailsClosed: forgedTarget.accepted === false && forgedTarget.reason === 'untrusted-or-unknown-operation-card',
sparseTargetsDoNotThrow: sparseResult.accepted === false && sparseResult.reason === 'untrusted-or-unknown-operation-card',
cyclicCardDoesNotThrow: cyclicResult.accepted === false && cyclicResult.reason === 'invalid-operation-card-shape',
forgedAuthorityFailsClosed: forgedAuthority.accepted === false && forgedAuthority.reason === 'forged-or-wrong-authority-card',
forgedPreviewFailsClosed: forgedRequest.accepted === false && forgedRequest.reason === 'untrusted-preview-report',
forgedApprovalFailsClosed: forgedApproval.accepted === false && forgedApproval.reason === 'forged-or-missing-approval-card',
missingApprovalFailsClosed: missingApproval.accepted === false && missingApproval.reason === 'missing-or-unknown-approval-input',
forgedApprovedOperationCannotSimulate: forgedApproved.accepted === false && forgedApproved.reason === 'stop-no-exact-approved-synthetic-operation',
forgedAuditRecordCannotVerify: forgedReceipt.accepted === false && forgedReceipt.reason === 'untrusted-or-forged-synthetic-audit-receipt',
sparseReceiptCannotVerify: sparseReceiptResult.accepted === false && sparseReceiptResult.reason === 'untrusted-or-forged-synthetic-audit-receipt',
cyclicReceiptDoesNotThrow: cyclicReceiptResult.accepted === false && cyclicReceiptResult.reason === 'untrusted-or-forged-synthetic-audit-receipt',
forgedStopCannotPlanRollback: forgedStop.accepted === false && forgedStop.reason === 'stop-untrusted-input-before-rollback-plan',
fixedCasesStayFrozenAndUnchanged: fixedBefore === fixedAfter && Object.isFrozen(FIXED_CASES['fixed-bounded-approved-v1']) && Object.isFrozen(FIXED_CASES['fixed-bounded-approved-v1'].card.targetSelection.targetIds),
effectBoundaryForbidsAllExternalActions: boundedPreview.boundary.files === 'not-read-or-written' && boundedReceipt.boundary.network === 'not-used' && boundedStop.boundary.realChange === 'not-attempted',
canonicalComparisonRejectsCycle: hasSameCanonicalJson(cyclicCard, boundedCard) === false,
},
samples: {
boundedPreview,
boundedRequest,
boundedApproved,
boundedReceipt,
boundedVerification,
boundedStop,
overScopeRequest,
mismatchApproval,
lowerAuthorityRequest,
},
});
}
const fixtureExample = [
"import {",
" createFixedSyntheticOperationCard,",
" createFixedSyntheticAuthority,",
" createFixedSyntheticApproval,",
" previewSyntheticAutomation,",
" requestSyntheticApproval,",
" approveSyntheticOperation,",
" simulateSyntheticExecution,",
" verifySyntheticExecution,",
" stopAndPlanSyntheticRollback,",
" runEngineeringAutomationFixture,",
"} from './upgrade-2025-05.mjs';",
'',
"const caseId = 'fixed-bounded-approved-v1';",
'const preview = previewSyntheticAutomation(createFixedSyntheticOperationCard(caseId));',
'const request = requestSyntheticApproval(preview, createFixedSyntheticAuthority(caseId));',
'const approved = approveSyntheticOperation(request, createFixedSyntheticApproval(caseId));',
'const receipt = simulateSyntheticExecution(approved);',
'const verification = verifySyntheticExecution(receipt);',
'const stop = stopAndPlanSyntheticRollback(verification);',
'',
'if (!Object.values(runEngineeringAutomationFixture().assertions).every(Boolean)) {',
" throw new Error('fixed synthetic fixture failed');",
'}',
'',
'console.log({ preview: preview.status, execute: receipt.status, verify: verification.status, rollback: stop.rollbackPlan.status });',
'',
'// Fixed synthetic values in memory only.',
'// No files, Git, network, CI, clock, telemetry, production, cloud, real API or real change is used.',
'// simulateSyntheticExecution records a model receipt; it never executes an external operation.',
].join('\n');
const practice = revision({
slug: 'editorial-2025-05-practice-engineering-automation',
title: 'Автоматизация инженерной рутины без массовой ошибки',
categories: ['Инфраструктура', 'Процессы'],
cover: '/assets/editorial/2025/engineering-automation-2025-preview-approval-rollback.svg',
excerpt: 'Как разбить массовую инженерную операцию на preview, ограниченный scope, отдельное approval, проверку результата и заранее спланированный rollback.',
readingMinutes: 13,
}, [
p('Рутина становится опасной в момент, когда один корректный шаг превращают в массовый. Скрипт умеет переименовать label, обновить правило или закрыть устаревший флаг на одном объекте — и тем же циклом затрагивает тысячу. Симптом обычно спокойный: список targets собран, команда выглядит идемпотентной, а change уже нельзя глазами просмотреть. Цена ошибки конкретна: массово неверное состояние, длинное восстановление, потерянное время reviewers и спор, какой именно запуск изменил объект.'),
p('Причина не в том, что автоматизация сама по себе плоха. Ошибка появляется, когда preview считают доказательством будущего результата, approval — доказательством правильности, а audit log — страховкой от последствий. Это три разных вопроса: что <em>предлагается</em>, кто разрешил <em>ограниченную операцию</em>, и что <em>зафиксировал процесс</em>. Между ними остаются selection, момент запуска, реальные права, состояние цели и проверка после change. Поэтому процесс нужно строить как цепочку доказательств, а не как одну зелёную кнопку.'),
h2('Симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Один job готов обработать широкий selector, но reviewer видит только краткое «N объектов» и общий статус success.',
'<strong>Причина.</strong> Команда не связала target list, preview, authority, approval и audit record одним неизменяемым идентификатором операции.',
'<strong>Проверка.</strong> До запуска сравнить exact target digest и limit authority; после запуска запросить независимый observed-state check, а не только exit code job.',
'<strong>Действие.</strong> Разделить путь на preview → bounded scope → approval → execute → audit trail → verification; rollback оформить отдельной операцией с новым preview и approval.',
]),
h2('Preview показывает намерение, а не будущую реальность'),
p('Preview полезен, когда отвечает на короткий вопрос: «какие именно объекты и какое изменение мы сейчас предлагаем?» В Terraform документ для версии 1.10.5 прямо отделяет <code>plan</code> от применения: plan сам не выполняет change. Speculative plan может разойтись с final effect, если target system изменится между расчётом и execute.'),
p('Для массовой операции preview должен иметь собственный contract. В нём не «обновить документацию», а operation id, selector id, dense list target ids, digest списка, желаемый переход и граница времени. Если target list строится из широкого query, сохраняют и query, и результат query. Иначе reviewer согласует название операции, а исполнитель потом получает уже другой набор. Preview без списка — это заметка; preview без digest — это заметка, которую нельзя связать с approval.'),
table('Что фиксирует каждый барьер', ['Барьер', 'Вопрос', 'Минимальный артефакт', 'Чего он не доказывает'], [
['preview / dry-run', 'что предлагается сейчас?', 'operation id, selector, target ids, digest, proposed diff', 'что реальный change позже увидит то же состояние'],
['bounded scope', 'сколько и какие targets разрешены?', 'max targets, allowed selector, exclusions', 'что изменение семантически корректно'],
['approval', 'кто согласовал именно этот scope?', 'approval id, preview digest, authority id, decision', 'что reviewer прочитал каждый объект или что execution пройдёт'],
['audit trail', 'какой transition зафиксирован?', 'append-only record или event с links', 'что event невозможно подделать без свойств конкретного хранилища'],
['verification', 'какое состояние наблюдается после change?', 'independent read, expected/observed comparison, stop reason', 'что следующий change безопасен или rollback не нужен'],
]),
figure('/assets/editorial/2025/engineering-automation-2025-preview-approval-rollback.svg', 'Схема из семи карточек: preview, ограниченный scope, authority, approval, execute, audit trail, verification. Красная ветка ведёт к безопасной остановке и отдельному rollback plan; стрелки показывают, что approval и audit record не заменяют проверку результата.', 'Цепочка не пытается сделать один барьер универсальным. Preview связывают с targets, approval — с preview, а verification остаётся отдельным действием после исполнения.'),
h2('Dry-run и preview различаются, но оба имеют границу'),
p('Термины часто смешивают. Preview может быть расчётом в доменной модели: список того, что процесс намерен сделать. Dry-run — режим конкретного инструмента. В справочнике Kubernetes зафиксированы два разных dry-run режима для <code>kubectl apply</code>: client печатает object без отправки, server отправляет request без persistence ресурса. Значит, даже внутри одной команды «проверили заранее» имеет разные источники данных и разные границы. Ни один режим не говорит, что поздний реальный request обязательно увидит тот же набор policy и состояние.'),
h2('Ограниченный scope должен быть машинно проверяемым'),
p('Ограничение scope не означает «попросили быть осторожнее». Это правило, которое сравнивает authority с exact selection. У authority есть level, допустимый selector и максимум targets. У preview есть target digest. Approval содержит оба значения. Перед execute исполнитель ещё раз проверяет, что approval привязан к тому же operation id, selector, target digest и authority id. Любое несовпадение закрывает путь. Лучше получить стоп из-за лишнего target, чем позволить динамическому selector незаметно расширить команду.'),
p('GitHub Actions environments дают знакомый, но узкий пример такой границы: job, ссылающийся на environment, ждёт прохождения protection rules и не получает environment secrets до этого. Required reviewer может пропустить job, а возможность bypass зависит от отдельной настройки. Из документации не следует, что reviewer оценил бизнес-смысл diff или что выбранный environment равен вашему. Но она хорошо показывает разделение обязанностей: контроль допуска к запуску существует отдельно от содержательной проверки change.'),
code(fixtureExample),
p('Пример выше не запускает command и не моделирует реальное окружение. Все cards, ids, digest, authority, approval, timestamps и outputs — fixed synthetic values в памяти. Функция с названием <code>simulateSyntheticExecution</code> создаёт только immutable-in-memory receipt с явным <code>productionEffect: not-attempted</code>. Это важнее красивого demo: читатель может повторить negative cases без риска получить частично изменённую систему.'),
h2('Approval — это binding, а не печать «безопасно»'),
p('Approval имеет техническую работу: связать согласие с конкретным preview. Для этого недостаточно поля <code>approved: true</code>. Нужны operation id, target digest, authority id, decision, version policy и понятный срок действия. Если approval не несёт digest, его можно случайно применить к другому списку targets. Если не несёт authority, непонятно, какому уровню риска соответствовало решение. Если не несёт identifier preview, reviewer не может доказать, что видел именно тот diff.'),
h2('Audit trail нужен для расследования, не для оправдания запуска'),
p('Audit record записывает связь между intent и событием: operation id, preview digest, target digest, authority, approval, time, actor class, execution result и verification result. Он нужен, чтобы после сбоя не восстанавливать историю из чата и stdout. Но наличие log не делает change обратимым. Лог может быть неполным, неверно коррелированным или лежать рядом с системой, которая отказала. Свойства «append-only», signature и retention нужно доказывать свойствами конкретного storage, а не словом <em>immutable</em> в JSON.'),
h2('Verification начинает новый вопрос'),
p('После execute не нужно спрашивать «job зелёный?». Нужно сформулировать expectation и observed evidence: какие fields на каком наборе targets должны измениться, кто читает их из authoritative source, как распознаётся partial change и сколько времени допустимо ждать. Если verification не получена или не совпала с expectation, процесс переходит в stop path. Он не делает второй автоматический write в надежде, что это станет rollback.'),
p('Rollback — отдельная операция, а не кнопка «вернуть». Перед обратным change нужно знать фактическое состояние, источник истины, новый scope и побочные зависимости. Поэтому безопасный stop может создать только rollback <em>plan</em>: зафиксировать reason, собрать observed state вне модели, получить новую authority, подготовить отдельный preview и approval. Эта задержка дешевле, чем обратный массовый change по старому предположению.'),
h2('Маршрут внедрения на одной рутине'),
ol([
'<strong>Выберите одну обратимую операцию.</strong> Например, поменять synthetic label на двух учебных cards, а не «автоматизировать всё сопровождение».',
'<strong>Опишите target contract.</strong> Dense ids, selector, exclusions, target digest, expected before/after и max scope.',
'<strong>Разведите preview и execute.</strong> Preview не имеет capability писать; execute принимает только exact bound approval.',
'<strong>Сделайте один refusal test.</strong> Подмените target digest или добавьте третий target. Ожидаемый результат — stop before approval.',
'<strong>Добавьте observed verification.</strong> Заранее опишите, какую независимую read-проверку выполнит owner после реального запуска.',
'<strong>Спланируйте rollback отдельно.</strong> Не выдавайте старый preview за разрешение на обратный change; создайте новый operation card.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('Эта статья не даёт production runner, identity system, policy engine, durable audit storage или реальный rollback. Fixed model не читает и не пишет files, Git, сеть, CI, telemetry, cloud, production или real API. Она не подтверждает права пользователя, фактическое состояние targets, качество query или успешность внешнего execute. Её смысл уже: сделать несоответствие scope, authority или approval наблюдаемой причиной остановки.'),
p('Следующий проверяемый шаг: выберите один существующий batch-job и выпишите два списка — что он <em>предлагает</em> и что он <em>на самом деле меняет</em>. Затем добавьте в тест подмену target digest и отдельный assertion, что executor останавливается до write. Если пока нельзя назвать exact targets и owner verification, автоматизацию не расширяют: сначала строят contract preview и stop path.'),
h2('Историческая граница мая 2025'),
p('Terraform commit для v1.10.5 датирован 22 января 2025; GitHub Docs commit — 31 января 2025; Kubernetes website commit — 14 марта 2024. Все источники существовали к маю 2025 и закреплены immutable commits. Они используются только для узких фактов о plan, environments и dry-run. Цепочка cards, fixed fixture, правила binding и rollback plan — инженерная конструкция автора, не обещание поведения Terraform, GitHub Actions или Kubernetes вне их документации.'),
]);
const mechanism = revision({
slug: 'editorial-2025-05-mechanism-engineering-automation',
title: 'Почему preview, approval и audit trail не доказывают безопасный change',
categories: ['Надёжность', 'Процессы'],
cover: '/assets/editorial/2025/engineering-automation-2025-authority-matrix.svg',
excerpt: 'Разбираем разные доказательства в автоматизации: preview показывает намерение, authority ограничивает scope, approval связывает решение, audit trail фиксирует событие, verification наблюдает результат.',
readingMinutes: 14,
}, [
p('У массовой автоматизации есть опасная иллюзия: если pipeline показал preview, получил approval и оставил audit log, то change якобы стал безопасным. Симптом заметен после incident: в log есть аккуратный record, reviewer помнит кнопку approve, а реальный scope оказался шире или состояние цели успело измениться. Цена ошибки — не только неверные объекты. Команда тратит часы на доказательство того, что именно произошло, и может повторить ошибку rollback-скриптом, которому доверили старый контекст.'),
p('Причина в смешении типов доказательств. Preview отвечает о предложении, authority — о допустимом пределе, approval — о согласии на связанный request, audit trail — о записанном событии, verification — о наблюдаемом результате. Ни один из них не выводится из другого автоматически. Технически зрелый процесс проверяет переход между карточками и умеет остановиться при любом разрыве. Он не называет «green pipeline» доказательством правильности, пока не названо, что конкретно было измерено.'),
h2('Пять утверждений, которые нельзя склеивать'),
table('Артефакт и его логическое значение', ['Артефакт', 'Что можно утверждать честно', 'Типовая ложная интерпретация', 'Нужное следующее доказательство'], [
['preview', 'в фиксированный момент рассчитан proposed change для зафиксированного selection', 'реальный execute будет таким же', 'повторная проверка preconditions рядом с execute'],
['authority', 'policy ограничила допустимый selector или размер scope', 'операция полезна и корректна', 'review содержимого preview и intent'],
['approval', 'кто-то разрешил связанный request в своей роли', 'reviewer проверил каждую техническую деталь', 'binding id/digest и понятная review policy'],
['audit trail', 'система зафиксировала событие по выбранному contract', 'событие необратимо доказано и state верный', 'свойства storage плюс independent verification'],
['verification', 'авторитетный reader наблюдал заданное условие', 'все последствия change устранены', 'monitoring, residual-risk owner или новый rollback plan'],
]),
ol([
'<strong>Зафиксировать proposal.</strong> Сохранить operation id, selector, dense targets, digest и version, а не только красивый diff.',
'<strong>Сравнить scope с authority.</strong> Проверить selector и limit до того, как появится approval request.',
'<strong>Связать approval буквально.</strong> Operation, preview, target и authority ids должны совпасть без implicit default.',
'<strong>Отделить receipt от evidence.</strong> После execute читать authoritative target и только затем решать, закрывать operation или готовить новый plan.',
]),
figure('/assets/editorial/2025/engineering-automation-2025-authority-matrix.svg', 'Матрица из строк authority level и столбцов target scope, preview digest, approval binding, execution и verification. Зелёная клетка разрешает только bounded scope; красные клетки показывают, что превышение лимита, другой selector или чужой digest должны остановить цепочку.', 'Authority сравнивается со scope, но не с «качеством идеи». Поэтому она защищает от расширения операции, а не заменяет review или verification.'),
h2('Preview — снимок намерения с известным сроком годности'),
p('Terraform отделяет speculative plan от apply: изменения target system между ранним plan и final apply могут изменить результат. Это относится и к самописному orchestration: состояние меняют другой job, человек или controller. Preview — вход в review, а не билет на исполнение через час.'),
p('Чтобы превратить preview в contract, сохраняют не только human-readable diff. Нужны stable operation id, model/config version, selector, dense target list, target digest, expected precondition и proposal digest. Digest — не мистическая защита: он просто позволяет проверять равенство того, что видел reviewer, и того, что получил executor. Надёжность hash, подписи и collision resistance — отдельные свойства конкретной реализации. В fixed model digest — статическая строка именно поэтому; fixture проверяет связь, а не криптографию.'),
h2('Dry-run не обладает одной силой'),
p('Называть любой безопасный расчёт dry-run вредно. Kubernetes reference для <code>kubectl apply</code> различает client dry-run и server dry-run. Первый печатает object без отправки; второй отправляет server-side request, но не сохраняет ресурс. Это два разных наблюдения: один проверяет serialization и локальную обработку, другой может пройти часть server-side пути. Они полезны, но neither превращается в утверждение о будущем persistent change.'),
p('В инженерном process рядом с названием preview пишут source of truth и side-effect boundary. Например: «local model preview, no external read», «server admission dry-run, no persistence» или «read-only inventory with fixed snapshot». Тогда reviewer понимает, какие questions ещё открыты. Если source inventory старый, target list может быть уже неверен. Если server dry-run прошёл, policy может поменяться до execute. Если preview собран на client, он может не знать admission rule. Каждая граница добавляет самостоятельный check, а не больше уверенности в одном цветном статусе.'),
h2('Authority ограничивает мощность, не доказывает смысл'),
p('Authority card в хорошем дизайне мала и машинно проверяема: level, allowed selector, maximum count, time window и исключения. Она не хранит длинное объяснение change и не решает за owner, стоит ли менять поле. Её задача — не позволить approval от малой операции «переехать» на больший scope. Если preview содержит три targets, а authority допускает два, executor не округляет limit и не просит пользователя «подтвердить ещё раз» в том же запуске. Он возвращает <code>stop-scope-exceeds-authority-limit</code>.'),
p('GitHub Docs для environments показывает похожую, но не тождественную механику: job ожидает protection rules, а секреты environment недоступны до их прохождения. Это gate доступа к стадии запуска. В документе есть и bypass boundary: возможность администратора обходить правила зависит от настройки environment. Следствие для собственного runner простое: bypass — тоже операция с отдельным identity, reason и audit event. Его нельзя скрыть внутри общего <code>approved</code>, иначе расследование не отличит нормальный путь от исключения.'),
code([
"import {",
" createFixedSyntheticOperationCard,",
" createFixedSyntheticAuthority,",
" previewSyntheticAutomation,",
" requestSyntheticApproval,",
"} from './upgrade-2025-05.mjs';",
'',
"const preview = previewSyntheticAutomation(",
" createFixedSyntheticOperationCard('fixed-over-scope-v1'),",
');',
'const gate = requestSyntheticApproval(',
' preview,',
" createFixedSyntheticAuthority('fixed-over-scope-v1'),",
');',
'',
'console.log({ accepted: gate.accepted, reason: gate.reason, targetCount: gate.targetCount });',
'// Expected: false, stop-scope-exceeds-authority-limit, 3.',
'// The values are fixed synthetic in memory; no target is read or changed.',
].join('\n')),
p('Пример показывает не «проверку прав пользователя», а exact contract. Card с тремя synthetic targets и authority максимум на два закрывает путь до approval. Никакой тайный fallback не оставляет третью запись «на потом», никакой реальный job не создаётся. Для production нужны identity provider, policy owner, storage и real-time conditions; они намеренно не входят в fixture.'),
h2('Approval обязан связывать четыре значения'),
p('Минимальный approval связывает operation id, preview digest, target digest и authority id. Если хотя бы одно значение другое, согласие не относится к request. Отдельно полезны policy version, reviewer role, expiry и reason. Но поля не делают approval автоматическим доказательством: reviewer мог видеть неполное представление, policy могла быть слишком широкой, а time window — слишком длинным. Поэтому в high-impact operations approval сопровождают change owner, expected verification и stop owner, а не только именем reviewer.'),
p('Target mismatch — показательная авария. Preview может содержать <code>synthetic-targets-a-b-v1</code>, approval — <code>synthetic-targets-a-c-v1</code>, а оба выглядят похожими для человека. В fixed fixture это не «warning»: approval получает <code>stop-approval-does-not-bind-preview-target-and-authority</code>. Хороший runner использует такую же fail-closed семантику для отсутствующего, неизвестного и forged field. Иначе позитивный путь testируется, а ошибочный request случайно получает полезный default.'),
h2('Audit trail хранит причинную цепочку, но не наблюдает state'),
p('Audit trail нужен потому, что у команды несколько источников истины: queue, executor, policy engine и target system. Он связывает phase transition одним operation id и удобен для восстановления последовательности. Но audit record может сказать «simulation recorded» и не может сам прочитать target. Поэтому audit fields не заменяют verification fields. Если их смешать, dashboard будет показывать «verified» по факту наличия log, хотя реальные targets никто не открыл.'),
h2('Verification и rollback не должны быть скрытым retry'),
p('Verification начинается с измеримого expected state. Не «проверьте всё», а «reader X видит field Y со значением Z на scope digest D; отсутствующие targets перечислены; stale data имеет отдельный reason». Если read не прошёл, state не равен expectation или источник не авторитетен, status остаётся stopped. Green execution receipt не переписывают как verification.'),
p('Rollback нужен именно в этот момент, но не как автоматическая инверсия исходного payload. Что вернуть зависит от observed state: часть targets могла измениться вручную, исходное значение уже неактуально, а побочные объекты требуют другого порядка. Поэтому rollback plan содержит preconditions: собрать observed state, создать новый bounded scope, получить новую authority, подготовить новый preview и approve отдельную операцию. «Rollback planned» не равно «rollback executed» и не даёт права делать новый write.'),
table('Fail-closed ветки fixed fixture', ['Нарушение', 'Где обнаруживается', 'Результат', 'Почему это важно'], [
['unknown или missing field', 'preview contract', 'stop без proposal', 'не возникает default operation'],
['forged authority', 'authority gate', 'stop before approval', 'нельзя расширить limit подменой card'],
['approval с другим target digest', 'approval binding', 'stop before simulation', 'review не переносится на другой selection'],
['sparse или cyclic structure', 'canonical comparison', 'reject без exception', 'fixture не скрывает некорректный input'],
['forged audit record', 'verification entry', 'stop, не verified', 'record не становится state evidence'],
]),
h2('Ограничения и следующий проверяемый шаг'),
p('В model нет реального permission check, scheduler, external time, inventory, API, storage или production target. Все values fixed synthetic и лежат в памяти. Поэтому fixture не проверяет network race, cryptographic digest, identity, доступность audit store или честность reviewer. Она проверяет более узкую вещь: каждый переход принимает только exact card и закрывается на mismatch, missing, unknown, sparse или cyclic input.'),
p('Следующий проверяемый шаг: возьмите самый короткий batch automation и перечислите для него пять предикатов из первой таблицы. Для каждого напишите owner и data source. Затем удалите один binding field — например target digest — и попробуйте доказать, что executor всё ещё не сможет применить старый approval к новому scope. Если доказательство строится на дисциплине оператора, а не на contract, добавьте gate до расширения автоматизации.'),
h2('Историческая граница мая 2025'),
p('В этой статье Terraform v1.10.5 используется только для границы speculative plan, GitHub Docs commit от 31 января 2025 — только для behaviour environment protection rules и bypass setting, Kubernetes reference commit от 14 марта 2024 — только для различия client/server dry-run. Все ссылки закреплены immutable commits и существовали к маю 2025. Матрица authority, stop codes и fixed cards — собственный учебный механизм автора; они не названы гарантией сторонних систем.'),
]);
const field = revision({
slug: 'editorial-2025-05-field-engineering-automation',
title: 'Полевой маршрут: автоматизация без массового неверного change',
categories: ['Инфраструктура', 'Надёжность'],
cover: '/assets/editorial/2025/engineering-automation-2025-dry-run-evidence-loop.svg',
excerpt: 'Практический маршрут для владельца batch-операции: построить preview, ограничить scope, привязать approval, сохранить audit trail, проверить observed state и остановиться до опасного rollback.',
readingMinutes: 14,
}, [
p('Полевой риск автоматизации выглядит буднично: небольшая операция накопила исключения, кто-то добавил selector «для удобства», а запуск теперь меняет всё, что совпало с шаблоном. Сначала это экономит час ручной работы. Затем неверный filter или stale inventory создаёт массовый change, который трудно отмотать. Цена ошибки — частично изменённые targets, потерянные данные о прежнем состоянии, простой команды и ещё один рискованный запуск ради исправления первого.'),
p('В этот момент нельзя ограничиться просьбой «давайте внимательнее ревьюить скрипт». Причина системная: у процесса нет наблюдаемой границы между расчётом, согласием, исполнением и проверкой. Нужен короткий маршрут, который может остановить работу раньше внешнего write: preview/dry-run, bounded scope, authority, approval, execute, audit trail, independent verification и отдельно подготовленный rollback plan. Каждая остановка должна возвращать причину и владельца, а не расплывчатый failure.'),
h2('Сначала опишите operation card'),
p('Operation card — это не тикет с заголовком, а минимальный контракт запуска. В нём есть operation id, intent, selector id, dense target list, target digest, expected before/after, version change logic, requested authority и expected verification. Карточка должна уместиться в review без поиска по нескольким системам. Если один из fields нельзя назвать, это не повод проставить <code>unknown</code> и идти дальше. Это сигнал остановиться до того, как автоматизация выберет targets по умолчанию.'),
table('Карточка операции перед первым запуском', ['Поле', 'Пример в учебной модели', 'Проверка владельца', 'Стоп, если'], [
['operationId', 'synthetic-normalize-doc-labels-bounded-v1', 'ID один и тот же в preview, approval, audit, verify', 'переиспользован или отсутствует'],
['targetSelection', 'synthetic-doc-a + synthetic-doc-b', 'ids dense, selector и exclusions видны', 'list пустой, sparse или не совпадает с selector'],
['targetDigest', 'synthetic-targets-a-b-v1', 'approval связывает именно этот list', 'digest другой или не зафиксирован'],
['authority', 'synthetic-authority-l2, максимум два target', 'limit и selector подходят операции', 'scope шире authority'],
['expected verification', 'external observed state после execute', 'есть reader и критерий success/failure', 'проверка только «job завершился»'],
]),
h2('Preview и dry-run располагают данные, но не дают разрешение'),
p('Начните с preview, который можно показать reviewer без побочных эффектов. Он показывает proposed change и selection. Если инструмент предоставляет dry-run, подпишите его режим и источник. Kubernetes documentation различает client mode, который только печатает object, и server mode, который отправляет request без persistence. Но precondition всё равно проверяют перед execute. На реальном запуске другой controller может уже поменять target, а policy может принять другое решение.'),
p('Terraform описывает тот же интервал на другом языке: speculative plan не имеет намерения применяться, а изменения target system между ранним plan и final apply способны поменять effect. Поэтому не сохраняйте preview как статичный артефакт без срока. Укажите snapshot version, retrieval time, max age и condition, при котором runner обязан recompute preview. Даже если ваша система не похожа на Terraform, вопрос один: что делает старый calculated diff непригодным для нового execute?'),
figure('/assets/editorial/2025/engineering-automation-2025-dry-run-evidence-loop.svg', 'Петля доказательств: preview и dry-run создают proposal, authority ограничивает scope, approval связывает карточку, simulated execute оставляет audit record, независимая verification сравнивает observed state. Красный выход STOP ведёт к новому rollback plan, а не к автоматическому обратному write.', 'Проверка результата намеренно находится после execute и возвращает процесс к новому plan. Так receipt об исполнении не выдаётся за наблюдение состояния, а rollback не наследует полномочия исходной операции.'),
h2('Ограничьте scope до approval'),
p('Следующий шаг — проверить, может ли authority покрыть exact selection. Хороший authority level отвечает на три вопроса: какой selector допустим, сколько targets максимум разрешено и какие исключения требуют отдельного процесса. Это легче автоматизировать, чем оценку «change выглядит разумно». Если limit два, card с тремя targets должна остановиться до approval. Не уменьшайте список молча и не заменяйте limit на warning: оба решения делают следующее состояние непредсказуемым для reviewer.'),
p('Authority также не равна identity. В учебной модели <code>synthetic-authority-l2</code> — строка и max targets, не пользователь и не реальная permission. В production policy владелец определяет identity source, expiry, delegated approval, break-glass и audit scope. GitHub environments дают понятный пример одного gate: job ждёт protection rules и не получает environment secrets до их прохождения. Это не общая модель полномочий, но полезное напоминание, что доступ к исполнению и смысловая правильность change — разные проверки.'),
h2('Approval связывает review с конкретной карточкой'),
p('В approval должен попасть не только комментарий reviewer. Минимальный набор: approval id, operation id, target digest, preview digest, authority id, decision и срок. Тогда executor проверяет связку перед execute. Если approval относится к <code>synthetic-targets-a-c-v1</code>, а preview — к <code>synthetic-targets-a-b-v1</code>, путь закрывается. В нормальном UX это может быть короткое объяснение «target mismatch, создай новый preview», а не попытка догадаться, какой target имел в виду reviewer.'),
code([
"import {",
" createFixedSyntheticOperationCard,",
" createFixedSyntheticAuthority,",
" previewSyntheticAutomation,",
" requestSyntheticApproval,",
"} from './upgrade-2025-05.mjs';",
'',
"const card = createFixedSyntheticOperationCard('fixed-lower-authority-v1');",
'const preview = previewSyntheticAutomation(card);',
"const authority = createFixedSyntheticAuthority('fixed-lower-authority-v1');",
'const result = requestSyntheticApproval(preview, authority);',
'',
'console.log({ stopped: result.stopped, reason: result.reason, limit: result.authorityMaxTargets });',
'// Expected: true, stop-scope-exceeds-authority-limit, 1.',
'// This is fixed synthetic in-memory data, not a real approval or execution.',
].join('\n')),
p('Этот компактный example проверяет failure path, а не счастливый сценарий. Card содержит два synthetic target, authority допускает один, поэтому approval request даже не формируется. Fixture дополнительно подаёт unknown/missing fields, forged authority и approval, mismatch targets, sparse arrays и cyclic structures. Все такие inputs fail closed: report содержит reason и <code>productionEffect: not-attempted</code>, а не best-effort default.'),
h2('Execute, audit trail и verification — три разные фазы'),
p('Только после exact approval executor может начать реальный change. До него полезно повторить preconditions: target ещё существует, version не устарела, scope не расширился, authorization не истекла. Если проверка не проходит, результат — stop, а не частичное применение «того, что осталось». Для high-impact операций стоит заранее решить, допустима ли partial execution, и кто владеет её recovery. Это domain decision, не побочный эффект loop.'),
p('Audit trail получает event после попытки execution, но event не подтверждает observed state. В нём полезно хранить operation id, input digests, authority/approval links, executor version, timestamps, per-target result и correlation id. Но свойства сохранности зависит от storage. Fixed script создаёт frozen record в памяти только для того, чтобы fixture могла проверить exact transition. Он прямо называет границу: это не durable append-only storage, не подпись и не доказательство, что target изменился.'),
p('Verification читает authoritative source вне executor и сравнивает observed state с explicit expectation. У неё три исхода: matched, mismatched или unknown. Только первый позволяет закрыть операцию с ограничениями; второй и третий идут в stop path. Убедительная фраза «apply completed» не должна менять status verification. Если reader недоступен, result не «успех с warning», а недостающая проверка с владельцем и сроком.'),
table('Реакция на результат после execute', ['Наблюдение', 'Статус процесса', 'Разрешённое действие', 'Что нельзя делать'], [
['observed state совпадает с expectation', 'verified с named boundary', 'закрыть card и сохранить evidence', 'объявлять будущие runs безопасными'],
['часть targets не совпала', 'stop / investigate', 'сохранить exact subset и owner recovery', 'запустить широкую обратную операцию'],
['reader недоступен или evidence stale', 'unknown / stop', 'вернуть verification owner', 'подменить наблюдение exit code executor'],
['scope неизвестен после partial failure', 'stop before rollback', 'собрать inventory и новый preview', 'использовать исходный approval для rollback'],
]),
h2('Rollback plan — новый change, а не команда в finally'),
p('Когда verification не пройдена, хочется сразу выполнить inverse. Это особенно рискованно после массовой операции: часть targets может быть исправлена руками, прежнее значение могло стать опасным, а новая policy может запрещать обратный переход. Поэтому rollback plan сначала фиксирует boundary: какой subset наблюдался, что именно неизвестно, кому принадлежит recovery, какие data нужны до следующего decision. План не выполняет write и не наследует approval исходной операции.'),
p('Rollback повторяет весь маршрут с новым scope, authority, preview, approval и verification. В fixed model <code>stopAndPlanSyntheticRollback</code> возвращает <code>planned-only-not-executed</code>: stop event не разрешает новый write.'),
h2('Минимальный выпускной checklist'),
ol([
'<strong>Проверьте target contract.</strong> Selector, ids, digest, exclusions и max count можно показать в одной карточке.',
'<strong>Проверьте freshness preview.</strong> Явно задайте, когда preview перестаёт быть применимым и должен быть пересчитан.',
'<strong>Проверьте binding approval.</strong> Operation, preview, target и authority ids совпадают буквально, а не «примерно».',
'<strong>Проверьте audit boundary.</strong> Log хранит links, но команда не называет его observed state и не заявляет свойства storage без доказательства.',
'<strong>Проверьте observed verification.</strong> Назначены reader, expectation, timeout и владелец mismatch.',
'<strong>Проверьте stop path.</strong> Target mismatch, отсутствующий field и unknown authority останавливают process до write; rollback остаётся отдельным plan.',
]),
h2('Ограничения и следующий проверяемый шаг'),
p('Полевой маршрут не заменяет policy и review конкретной организации. Все data, timestamps, approval и audit records в fixture synthetic и fixed; никаких файлов, Git, CI, сети, telemetry, production, cloud или real API script не касается. Он не доказывает, что текущий batch-job идемпотентен, что audit storage неизменяем или что rollback возможен. Он даёт строго проверяемую форму остановки до внешнего change.'),
p('Следующий проверяемый шаг: выберите одну задачу с batch selector и создайте рядом с ней учебный test из трёх cases — valid bounded selection, over-scope selection и approval с другим target digest. Ожидаемый результат во втором и третьем case должен быть один: no execute, явный stop code, сохранённый preview. Только после этого подключайте реальный reader для verification; если этот reader ещё не определён, у операции нет доказательства результата.'),
h2('Историческая граница мая 2025'),
p('Источники закреплены до мая 2025: Terraform v1.10.5 commit от 22 января 2025, GitHub Docs commit от 31 января 2025 и Kubernetes website commit от 14 марта 2024. Terraform подтверждает ограничение speculative plan, GitHub Docs — gate environment protection rules и configurable bypass, Kubernetes — различие режимов dry-run. Они не обещают единый безопасный процесс. Все stop codes, contracts и fixture этой статьи принадлежат fixed synthetic модели автора.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runEngineeringAutomationFixture();
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');