From 3faaaa0992e85a4a39b3398aa0ee4785546021df Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 15:51:12 +0300 Subject: [PATCH] revise May 2024 platform template articles --- editorial/production/README.md | 2 +- editorial/reviews/2024-05-draft.md | 41 + web/data/editorial-revisions.mjs | 2 + .../platform-templates-2024-adoption-loop.svg | 50 ++ .../platform-templates-2024-escape-hatch.svg | 49 ++ .../platform-templates-2024-golden-path.svg | 59 ++ web/scripts/upgrade-2024-05.mjs | 733 ++++++++++++++++++ 7 files changed, 935 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2024-05-draft.md create mode 100644 web/public/assets/editorial/2024/platform-templates-2024-adoption-loop.svg create mode 100644 web/public/assets/editorial/2024/platform-templates-2024-escape-hatch.svg create mode 100644 web/public/assets/editorial/2024/platform-templates-2024-golden-path.svg create mode 100644 web/scripts/upgrade-2024-05.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index 9072c0c..febabe0 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 226 из 358 созданных материалов. Остальные 132 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 229 из 358 созданных материалов. Остальные 129 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2024-05-draft.md b/editorial/reviews/2024-05-draft.md new file mode 100644 index 0000000..d703936 --- /dev/null +++ b/editorial/reviews/2024-05-draft.md @@ -0,0 +1,41 @@ +# P75 · 2024-05 · Шаблоны для команд — три самостоятельных прохода ревью + +## Рамка sidecar-пакета + +- Archive slugs: editorial-2024-05-practice-platform-templates, editorial-2024-05-mechanism-platform-templates, editorial-2024-05-field-platform-templates. +- Голос: M7, май 2024. Автор пишет коротко и прикладно: сначала ситуация и цена, затем симптом, причина, проверка и действие; он называет owner, границу инструмента, rollback и следующий шаг. В текст не добавлены вымышленные incident, метрики, adoption, скорость, usage или CI-result. +- Созданы ровно пять sidecar-файлов: этот review, import-safe script и три локальные SVG. Overlay, README, очередь, archive JSON, Git, staging, commit/push и чужие файлы не менялись. +- Fixture содержит только заранее записанные marked synthetic records в памяти Node. Он не читает template, repository, файлы, usage, issue, interview, CI log, environment, clock, сеть, HTTP или production state. PASS говорит лишь о согласованности учебных records и отрицательных веток. + +## Проход 1 — факты, источники и модель + +- Источники сверены 31.07.2026 и ограничены тем, что было доступно не позднее мая 2024. [Backstage v1.25.0: Software Templates](https://github.com/backstage/backstage/blob/v1.25.0/docs/features/software-templates/index.md) — первичный tagged snapshot: template может загрузить skeleton, подставить переменные и опубликовать результат; при ошибке видны шаги, отмена передаёт abort signal. Материал не превращает эти возможности в claim о скорости разработки, качестве generated output или безопасности custom action. +- [Backstage v1.25.0: Adding your own Templates](https://github.com/backstage/backstage/blob/v1.25.0/docs/features/software-templates/adding-templates.md) показывает Template entity с owner, type, parameters и последовательными steps. В статье это граница scaffolder-механизма: документация не определяет политику конкретной команды, credential boundary, допустимость всех action или критерий отказа от template. +- [GitHub Enterprise Server 3.12: Creating a template repository](https://docs.github.com/en/enterprise-server@3.12/repositories/creating-and-managing-repositories/creating-a-template-repository) фиксирует: новая repository получает структуру и файлы template, но созданные ветви имеют несвязанную историю. Поэтому текст говорит о риске самостоятельных copies и не обещает автоматическую синхронизацию base template с fork-ами. +- Модель держит один versioned base contract: known owner, internal HTTP service, approved runtime и internal data class. Она различает три фиксированных case: совпадение с golden path; одно named extension с owner, boundary и rollback; отказ для one-off regulated migration без стабильного shape, owner и policy. Эти case не описывают реальные сервисы. +- Input contract закрыт: marker synthetic, точный scope, fixed-memory-only mode и известный case id. Extra field, request path, file-like field, usage-like field, иной scope, scan mode и неизвестный case отдельно отклоняются. Fixture не может стать скрытым reader для template, repository, usage, issue, interview, CI или сети. +- Главная приёмка заменила allow-list ключей на exact own-key contract для input, report и decision draft. Canonical comparison безопасно отвергает циклический внешний report, а rollback не принимает разрежённый список actions. Это не создаёт capability для реального template; это не даёт учебному примеру сломаться или принять неаудируемую форму. +- Plan повторно создаёт canonical report из embedded record и сравнивает весь report, а не только verdict. Подмена decision, исчезновение extension или добавление repositoryPath возвращают rejection. Rollback принимает лишь canonical synthetic draft и возвращает явное отсутствие операций над template, repository, files, catalog, CI и сетью. +- На самостоятельном model review добавлена отдельная отрицательная ветка для usage-like input. До неё общий extra-field guard уже защищал модель, но явная assertion делает границу «не читать и не утверждать adoption» проверяемой, а не только описанной в комментарии. + +## Проход 2 — язык, объём и голос + +- Все три статьи начинают с ситуации и цены. Practice показывает, как переключатели превращают template в набор несовместимых стартовых точек. Mechanism показывает цену открытого input contract. Field разбирает три synthetic requests и цену общего действия Create для разных классов решений. +- В каждом материале есть последовательность «симптом → причина → проверка → действие», а не общая рекомендация. У practice действие — оставить только повторяемый base contract; у mechanism — закрыть input и отличить plan от operation; у field — выбрать golden path, named extension либо explicit decline. +- Таблицы отвечают на рабочие вопросы: что входит в base contract, чем различаются механизмы, как три requests получают разные outcomes. Примеры помечены synthetic и прямо перечисляют, что модель не читает. В каждой статье есть ordered route, ограничение, rollback и следующий шаг. +- Основной текст без раздела источников: practice — 10 288 знаков, mechanism — 11 646 знаков, field — 11 039 знаков. Все материалы входят в обязательный диапазон 5 000–15 000 и целевой коридор 8–11 тыс. с небольшим оправданным запасом у mechanism, где отдельно объяснены три технических слоя. +- Речь вычитана на M7: short technical claims привязаны к договору, owner-у или источнику. Слова golden path, extension, decline, repository template, scaffolder, invariant и rollback не служат украшением. Нет обещания, что template сам обеспечит adoption, безопасность, совместимость, скорость, production approval или синхронизацию копий. + +## Проход 3 — визуал, безопасность и выпуск + +- Три SVG разделяют три разных вопроса: base facts и golden path; controlled escape hatch; цикл решения, evidence и версии policy. У каждого рисунка есть содержательный alt в статье и подпись, которая отделяет схему от реального состояния template, CI, usage и production. +- XML validation трёх SVG — PASS. SVG safety scan — чисто: нет script, foreignObject, external URL, data:image или event-handler атрибутов. Все изображения статичны, не принимают input и не тянут внешние ресурсы. +- Sharp-render каждого SVG на 375 px и ручной просмотр — PASS. Заголовки, карточки, стрелки, нижние ограничения и красно-жёлто-зелёные развилки читаются; текст не обрезан, блоки не перекрываются. В первой схеме прямо отделён новый policy от поля формы, во второй видны три outcomes, в третьей нет ложного графика adoption. +- Финальные проверки: node --check web/scripts/upgrade-2024-05.mjs — PASS; fixed memory-only fixture — PASS 27/27 assertions; cd web && npm run audit:draft -- scripts/upgrade-2024-05.mjs — PASS для трёх slug; import-safe export не содержит date или author; XML — PASS; SVG safety — чисто; Sharp 375 px — PASS. +- Пакет намеренно не интегрирован: overlay, README и очередь не менялись; commit и push не выполнялись. Реальные analysis, credentials, action execution, dry run, CI, validation, evidence collection и rollout находятся за границей P75. + +## Выпуск после трёх проходов + +- Главный редактор подключил ровно три майские ревизии в `web/data/editorial-revisions.mjs`, не меняя архивный `articles.json`, и обновил счётчик производства до 229 из 358 материалов. +- После подключения `npm run audit:articles -- <три slug>` подтвердил объём, figure, таблицу и пример каждой статьи; registry содержит 220 уникальных ревизий без повторов slug. +- `npm run build` завершился успешно: Next.js сгенерировал 374 статические страницы. В выпуск не включены пользовательские правки и неинтегрированные sidecar-пакеты июня и июля. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 43ec6ad..9cb4a36 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -71,6 +71,7 @@ import { revisions as january2024Revisions } from '../scripts/upgrade-2024-01.mj import { revisions as february2024Revisions } from '../scripts/upgrade-2024-02.mjs'; import { revisions as march2024Revisions } from '../scripts/upgrade-2024-03.mjs'; import { revisions as april2024Revisions } from '../scripts/upgrade-2024-04.mjs'; +import { revisions as may2024Revisions } from '../scripts/upgrade-2024-05.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -147,4 +148,5 @@ export const editorialRevisions = [ ...february2024Revisions, ...march2024Revisions, ...april2024Revisions, + ...may2024Revisions, ]; diff --git a/web/public/assets/editorial/2024/platform-templates-2024-adoption-loop.svg b/web/public/assets/editorial/2024/platform-templates-2024-adoption-loop.svg new file mode 100644 index 0000000..360004a --- /dev/null +++ b/web/public/assets/editorial/2024/platform-templates-2024-adoption-loop.svg @@ -0,0 +1,50 @@ + + + Контур обновления политики шаблона + Пять шагов: зафиксировать запрос, сравнить с контрактом, выбрать золотой путь, расширение или отказ, собрать разрешённые доказательства отдельно, затем обновить версию политики. Контур не показывает метрики внедрения. + + + Контур policy: решение отдельно от evidence и операции + Цикл обновляет versioned contract только после разрешённой проверки; он не является графиком adoption. + + + 1. Record + class, owner, + base facts + явно назван scope + + + 2. Match + versioned + base contract + закрытый input + + + 3. Decision + golden path + named extension + или decline + не silent fork + + + 4. Evidence + разрешённый review, + dry run, validation + в отдельном scope + + + + + + + + + + + + 5. Версия policy меняется осознанно + Только после evidence и review, не после количества копий. + + + Граница схемы: не читает usage, issues, интервью, CI или сеть и не делает claim о скорости или adoption. + diff --git a/web/public/assets/editorial/2024/platform-templates-2024-escape-hatch.svg b/web/public/assets/editorial/2024/platform-templates-2024-escape-hatch.svg new file mode 100644 index 0000000..777077e --- /dev/null +++ b/web/public/assets/editorial/2024/platform-templates-2024-escape-hatch.svg @@ -0,0 +1,49 @@ + + + Решение для escape hatch командного шаблона + Запрос сначала сравнивается с базовым контрактом. Полное совпадение ведёт в golden path, одно именованное расширение с владельцем и rollback ведёт в review, а изменение инварианта останавливает шаблон. + + + Escape hatch: расширение имеет границу, owner и обратный ход + Свободный параметр создаёт fork; контролируемая развилка делает различие видимым до генерации. + + + Запрос + type, owner, runtime, + data class, policy + не произвольный объект + + + + + Сверка с + base contract + invariants не меняются? + + + + + + + + + + 1. Golden path + Все base facts совпали + Действие: draft + human review + Rollback: убрать draft + + + 2. Named extension + Один adapter, owner известен + Boundary: base policy без замены + Действие: extension review + + + 3. Decline template + Новый owner, runtime или policy + Не подгонять через checkbox + Действие: design path + + Ветка extension не даёт право менять base runtime, ownership, data class или delivery policy. + diff --git a/web/public/assets/editorial/2024/platform-templates-2024-golden-path.svg b/web/public/assets/editorial/2024/platform-templates-2024-golden-path.svg new file mode 100644 index 0000000..adf1b4f --- /dev/null +++ b/web/public/assets/editorial/2024/platform-templates-2024-golden-path.svg @@ -0,0 +1,59 @@ + + + Golden path для командного шаблона + Четыре стабильных факта внутреннего HTTP-сервиса ведут в версионированный базовый контракт. Новый policy-вопрос отводится в design path, а не добавляется в форму. + + + Golden path: повторяемое решение, не каталог исключений + Версия контракта ограничивает выбор до того, как generated repository превратится в fork. + + + Вход заявки + 1. internal HTTP service + + 2. owner известен + + 3. approved runtime + + 4. internal data class + + Проверка: + все base facts совпали + + + + + + Версионированный base contract + synthetic-service-golden-path-v3 + + Создаёт: + • skeleton repository + • metadata draft + • review checklist draft + Не создаёт: + • production approval + • новую policy + • измеренный adoption + + + + + + Golden path + base facts + сохранены + Следующий шаг: + human fit review + + + + + Новый policy + retention, access + или неизвестный owner + Не поле формы + Design path + + Схема объясняет контракт выбора. Она не является снимком реального template, CI, usage или production. + diff --git a/web/scripts/upgrade-2024-05.mjs b/web/scripts/upgrade-2024-05.mjs new file mode 100644 index 0000000..0a59747 --- /dev/null +++ b/web/scripts/upgrade-2024-05.mjs @@ -0,0 +1,733 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +const p = (text) => '

' + text + '

'; +const h2 = (text) => '

' + text + '

'; +const code = (text) => '
' + escapeHtml(text) + '
'; +const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +const ul = (items) => ''; +const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; +const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; + +function plainText(content) { + return content + .replace(/<[^>]+>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function bodyText(content) { + return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); +} + +const sources = [ + { + title: 'Backstage v1.25.0: Software Templates, апрель 2024', + url: 'https://github.com/backstage/backstage/blob/v1.25.0/docs/features/software-templates/index.md', + note: 'Первичный снимок документации Backstage, выпущенный до конца мая 2024. Software Templates умеют загрузить skeleton, подставить переменные и опубликовать результат; задача показывает шаги и поддерживает отмену, но документ не обещает скорость внедрения, корректность конкретного template или автоматическую синхронизацию с будущими изменениями.', + }, + { + title: 'Backstage v1.25.0: Adding your own Templates, апрель 2024', + url: 'https://github.com/backstage/backstage/blob/v1.25.0/docs/features/software-templates/adding-templates.md', + note: 'Первичная документация формата Template: owner, type, parameters и последовательные steps. Она показывает возможности scaffolder-а, но не определяет policy команды, допустимость всех action, безопасность секретов или критерий, когда от template нужно отказаться.', + }, + { + title: 'GitHub Enterprise Server 3.12: Creating a template repository', + url: 'https://docs.github.com/en/enterprise-server@3.12/repositories/creating-and-managing-repositories/creating-a-template-repository', + note: 'Первичная историческая документация GitHub, доступная до мая 2024. Новый repository получает структуру и файлы template, однако ветви имеют несвязанную историю. Это объясняет риск самостоятельных копий; источник не описывает portal-template, ownership или процесс обновления команды.', + }, +]; + +function sourceList() { + return ''; +} + +function revision(meta, parts) { + const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList(); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) { + throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength); + } + return Object.freeze({ ...meta, contentHtml, proseLength }); +} + +const MODEL_LIMIT = 'fixed-in-memory-synthetic-platform-template-model-no-template-read-no-files-no-network-no-ci-no-usage-no-issues-no-interviews-no-speed-or-adoption-claim'; +const DEMO_SCOPE = 'synthetic-platform-template-demo'; +const TEMPLATE_ID = 'synthetic-service-golden-path-v3'; +const TEMPLATE_OWNER = 'synthetic-platform-templates-owner'; + +const fixedCases = Object.freeze({ + 'fixed-golden-path-fit': Object.freeze({ + label: 'a repeatable internal HTTP service matches the documented base contract', + request: Object.freeze({ + repeatedShape: true, + componentType: 'internal-http-service', + ownershipKnown: true, + runtimeClass: 'approved-runtime', + dataClass: 'internal', + specialPolicy: 'none', + requestedPath: 'golden-path', + }), + expectedDecision: 'use-golden-path', + expectedReason: 'base-contract-covers-fixed-synthetic-request', + }), + 'fixed-reviewed-escape-hatch': Object.freeze({ + label: 'a standard service needs one named adapter owned by the template team', + request: Object.freeze({ + repeatedShape: true, + componentType: 'internal-http-service', + ownershipKnown: true, + runtimeClass: 'approved-runtime', + dataClass: 'internal', + specialPolicy: 'named-observability-adapter', + requestedPath: 'reviewed-extension', + }), + expectedDecision: 'use-reviewed-extension', + expectedReason: 'named-extension-keeps-base-contract-intact', + }), + 'fixed-decline-template': Object.freeze({ + label: 'a one-off regulated data migration has no stable repeated service shape', + request: Object.freeze({ + repeatedShape: false, + componentType: 'one-off-regulated-data-migration', + ownershipKnown: false, + runtimeClass: 'not-yet-chosen', + dataClass: 'regulated-external', + specialPolicy: 'new-retention-and-access-model', + requestedPath: 'force-template', + }), + expectedDecision: 'decline-template', + expectedReason: 'request-needs-design-before-any-template-choice', + }), +}); + +const baseContract = Object.freeze({ + templateId: TEMPLATE_ID, + version: '3', + owner: TEMPLATE_OWNER, + supportedComponentType: 'internal-http-service', + requiredFacts: Object.freeze(['owner', 'service-name', 'approved-runtime', 'internal-data-class']), + produces: Object.freeze(['repository-skeleton', 'catalog-metadata-draft', 'review-checklist-draft']), + doesNotProduce: Object.freeze(['production-approval', 'measured-adoption', 'security-attestation', 'runtime-performance-result']), + namedExtension: Object.freeze({ + id: 'observability-adapter', + owner: TEMPLATE_OWNER, + boundary: 'adds only an adapter record; does not replace base runtime, ownership, data class or delivery policy', + rollback: 'remove the adapter draft and return to the versioned base contract', + }), + refusalCriterion: 'decline when the request has no repeatable shape, lacks an owner, introduces a new policy model, or requires changing a base invariant to make the form pass', +}); + +const REPORT_KEYS = Object.freeze([ + 'kind', 'syntheticOnly', 'accepted', 'reason', 'modelLimit', 'scope', 'caseId', + 'caseLabel', 'request', 'baseContract', 'decision', 'decisionReason', 'extension', + 'refusalCriterion', 'nextHumanQuestion', 'rollback', 'evidence', 'productionEffect', +]); + +const DRAFT_KEYS = Object.freeze([ + 'kind', 'accepted', 'syntheticOnly', 'reason', 'modelLimit', 'sourceReport', 'decision', + 'actions', 'realTemplate', 'files', 'catalog', 'usage', 'issues', 'interviews', 'ci', + 'network', 'adoption', 'speed', 'productionEffect', +]); + +const actionsByDecision = Object.freeze({ + 'use-golden-path': Object.freeze([ + 'record-base-contract-draft', + 'ask-for-human-fit-review', + 'keep-unlisted-choices-outside-the-template', + ]), + 'use-reviewed-extension': Object.freeze([ + 'record-base-contract-draft', + 'open-named-extension-review-draft', + 'record-extension-owner-boundary-and-removal-condition', + ]), + 'decline-template': Object.freeze([ + 'record-decline-reason-draft', + 'start-design-review-outside-template', + 'return-only-when-a-repeatable-contract-is-written', + ]), +}); + +function hasExactKeys(value, keys) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return (prototype === Object.prototype || prototype === null) + && Object.keys(value).length === keys.length + && keys.every((key) => Object.hasOwn(value, key)); +} + +function hasDenseArray(value) { + return Array.isArray(value) + && Object.keys(value).length === value.length + && Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean); +} + +function hasSameCanonicalJson(actual, expected) { + try { + return JSON.stringify(actual) === JSON.stringify(expected); + } catch { + return false; + } +} + +function rejectTemplate(reason) { + return Object.freeze({ + kind: 'synthetic-platform-template-report-v1', + syntheticOnly: true, + accepted: false, + reason, + modelLimit: MODEL_LIMIT, + }); +} + +export function createFixedSyntheticTemplateInput(caseId) { + if (!Object.hasOwn(fixedCases, caseId)) { + return Object.freeze({ synthetic: false, kind: 'unknown-synthetic-platform-template-input', caseId }); + } + return Object.freeze({ + synthetic: true, + kind: 'synthetic-platform-template-input-v1', + scope: DEMO_SCOPE, + mode: 'fixed-memory-only', + caseId, + }); +} + +function decisionFor(fixed) { + const request = fixed.request; + if (request.requestedPath === 'golden-path' + && request.repeatedShape === true + && request.componentType === baseContract.supportedComponentType + && request.ownershipKnown === true + && request.runtimeClass === 'approved-runtime' + && request.dataClass === 'internal' + && request.specialPolicy === 'none') { + return Object.freeze({ + decision: 'use-golden-path', + reason: fixed.expectedReason, + extension: null, + nextHumanQuestion: 'confirm that the real request still fits the recorded base assumptions', + rollback: 'discard the synthetic contract draft; no repository, template or deployment exists in this fixture', + }); + } + if (request.requestedPath === 'reviewed-extension' + && request.repeatedShape === true + && request.componentType === baseContract.supportedComponentType + && request.ownershipKnown === true + && request.runtimeClass === 'approved-runtime' + && request.dataClass === 'internal' + && request.specialPolicy === 'named-observability-adapter') { + return Object.freeze({ + decision: 'use-reviewed-extension', + reason: fixed.expectedReason, + extension: baseContract.namedExtension, + nextHumanQuestion: 'review the extension boundary, owner and removal condition before any real action is enabled', + rollback: baseContract.namedExtension.rollback, + }); + } + return Object.freeze({ + decision: 'decline-template', + reason: fixed.expectedReason, + extension: null, + nextHumanQuestion: 'start a small design review outside the template and return only after a stable repeated contract exists', + rollback: 'there is no generated artifact to remove; retain only the synthetic decline record', + }); +} + +/** + * Inspects exactly one embedded, fixed synthetic request. It does not read a + * template, repository, file, task, usage counter, issue, interview, CI log, + * environment, clock, network endpoint, HTTP response or production system. + * The result is a teaching decision, never evidence of adoption or speed. + */ +export function inspectSyntheticTemplateChoice(input) { + if (!input || input.synthetic !== true || input.kind !== 'synthetic-platform-template-input-v1') { + return rejectTemplate('synthetic-input-required'); + } + const allowed = ['synthetic', 'kind', 'scope', 'mode', 'caseId']; + if (!hasExactKeys(input, allowed)) return rejectTemplate('unexpected-input-field'); + if (input.scope !== DEMO_SCOPE) return rejectTemplate('unexpected-synthetic-scope'); + if (input.mode !== 'fixed-memory-only') return rejectTemplate('synthetic-mode-required'); + if (!Object.hasOwn(fixedCases, input.caseId)) return rejectTemplate('unknown-fixed-synthetic-case'); + + const fixed = fixedCases[input.caseId]; + const outcome = decisionFor(fixed); + const report = Object.freeze({ + kind: 'synthetic-platform-template-report-v1', + syntheticOnly: true, + accepted: true, + reason: 'fixed-synthetic-request-inspected', + modelLimit: MODEL_LIMIT, + scope: DEMO_SCOPE, + caseId: input.caseId, + caseLabel: fixed.label, + request: fixed.request, + baseContract, + decision: outcome.decision, + decisionReason: outcome.reason, + extension: outcome.extension, + refusalCriterion: baseContract.refusalCriterion, + nextHumanQuestion: outcome.nextHumanQuestion, + rollback: outcome.rollback, + evidence: Object.freeze({ + source: 'embedded-fixed-records-only', + realTemplate: 'not-read', + files: 'not-read', + usage: 'not-read', + issues: 'not-read', + interviews: 'not-read', + ci: 'not-run', + network: 'not-used', + adoption: 'not-measured', + speed: 'not-measured', + }), + productionEffect: 'not-attempted', + }); + return report; +} + +function canonicalReportFor(caseId) { + return inspectSyntheticTemplateChoice(createFixedSyntheticTemplateInput(caseId)); +} + +function isCanonicalTemplateReport(report) { + if (!hasExactKeys(report, REPORT_KEYS) + || report.kind !== 'synthetic-platform-template-report-v1' + || report.syntheticOnly !== true + || report.accepted !== true + || report.reason !== 'fixed-synthetic-request-inspected' + || report.modelLimit !== MODEL_LIMIT + || report.scope !== DEMO_SCOPE + || !Object.hasOwn(fixedCases, report.caseId) + || report.caseLabel !== fixedCases[report.caseId].label + || report.productionEffect !== 'not-attempted' + || !hasExactKeys(report.baseContract, ['templateId', 'version', 'owner', 'supportedComponentType', 'requiredFacts', 'produces', 'doesNotProduce', 'namedExtension', 'refusalCriterion']) + || !hasExactKeys(report.evidence, ['source', 'realTemplate', 'files', 'usage', 'issues', 'interviews', 'ci', 'network', 'adoption', 'speed'])) return false; + + return hasDenseArray(report.baseContract.requiredFacts) + && hasDenseArray(report.baseContract.produces) + && hasDenseArray(report.baseContract.doesNotProduce) + && hasSameCanonicalJson(report, canonicalReportFor(report.caseId)); +} + +function makeSyntheticTemplateDecisionDraft(fresh) { + return Object.freeze({ + kind: 'synthetic-platform-template-decision-draft-v1', + accepted: true, + syntheticOnly: true, + reason: 'canonical-fixed-synthetic-decision-draft', + modelLimit: MODEL_LIMIT, + sourceReport: fresh, + decision: fresh.decision, + actions: actionsByDecision[fresh.decision], + realTemplate: 'not-read-or-written', + files: 'not-read-or-written', + catalog: 'not-read-or-written', + usage: 'not-read', + issues: 'not-read', + interviews: 'not-read', + ci: 'not-run', + network: 'not-used', + adoption: 'not-measured', + speed: 'not-measured', + productionEffect: 'not-attempted', + }); +} + +function isCanonicalTemplateDecisionDraft(plan) { + if (!hasExactKeys(plan, DRAFT_KEYS) + || plan.kind !== 'synthetic-platform-template-decision-draft-v1' + || plan.accepted !== true + || plan.syntheticOnly !== true + || plan.reason !== 'canonical-fixed-synthetic-decision-draft' + || plan.modelLimit !== MODEL_LIMIT + || !hasDenseArray(plan.actions) + || plan.realTemplate !== 'not-read-or-written' + || plan.files !== 'not-read-or-written' + || plan.catalog !== 'not-read-or-written' + || plan.usage !== 'not-read' + || plan.issues !== 'not-read' + || plan.interviews !== 'not-read' + || plan.ci !== 'not-run' + || plan.network !== 'not-used' + || plan.adoption !== 'not-measured' + || plan.speed !== 'not-measured' + || plan.productionEffect !== 'not-attempted' + || !isCanonicalTemplateReport(plan.sourceReport)) return false; + + return hasSameCanonicalJson(plan, makeSyntheticTemplateDecisionDraft(canonicalReportFor(plan.sourceReport.caseId))); +} + +/** + * Creates only a synthetic decision draft after re-reading the canonical fixed + * record. It does not create or modify a real template, repository, catalog + * entity, workflow, secret, task or deployment. + */ +export function planSyntheticTemplateDecision(report) { + if (!report || report.kind !== 'synthetic-platform-template-report-v1' || report.syntheticOnly !== true || report.accepted !== true) { + return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'accepted-synthetic-report-required', modelLimit: MODEL_LIMIT }); + } + if (!hasExactKeys(report, REPORT_KEYS)) { + return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'unexpected-report-field', modelLimit: MODEL_LIMIT }); + } + if (report.scope !== DEMO_SCOPE || !Object.hasOwn(fixedCases, report.caseId)) { + return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'untrusted-synthetic-report-scope', modelLimit: MODEL_LIMIT }); + } + const fresh = canonicalReportFor(report.caseId); + if (!isCanonicalTemplateReport(report) || !hasSameCanonicalJson(fresh, report)) { + return Object.freeze({ accepted: false, syntheticOnly: true, reason: 'report-does-not-match-fixed-record', modelLimit: MODEL_LIMIT }); + } + return makeSyntheticTemplateDecisionDraft(fresh); +} + +/** + * Rolls back only an authenticated synthetic decision draft. It recreates the + * canonical draft from the embedded record before returning a result and never + * touches real state. + */ +export function rollbackSyntheticTemplateDraft(plan) { + if (!isCanonicalTemplateDecisionDraft(plan)) { + return Object.freeze({ restored: false, syntheticOnly: true, reason: 'no-accepted-synthetic-decision-draft' }); + } + return Object.freeze({ + restored: true, + syntheticOnly: true, + reason: 'synthetic-decision-draft-discarded', + template: 'not-read-or-written', + repository: 'not-read-or-changed', + files: 'not-read-or-written', + catalog: 'not-read-or-written', + usage: 'not-read', + ci: 'not-run', + network: 'not-used', + productionEffect: 'not-attempted', + }); +} + +export function runPlatformTemplatesFixture() { + const golden = inspectSyntheticTemplateChoice(createFixedSyntheticTemplateInput('fixed-golden-path-fit')); + const extension = inspectSyntheticTemplateChoice(createFixedSyntheticTemplateInput('fixed-reviewed-escape-hatch')); + const decline = inspectSyntheticTemplateChoice(createFixedSyntheticTemplateInput('fixed-decline-template')); + const nonSynthetic = inspectSyntheticTemplateChoice({ ...createFixedSyntheticTemplateInput('fixed-golden-path-fit'), synthetic: false }); + const unexpectedInput = inspectSyntheticTemplateChoice({ ...createFixedSyntheticTemplateInput('fixed-golden-path-fit'), requestPath: '/real/template/not/read' }); + const fileInput = inspectSyntheticTemplateChoice({ ...createFixedSyntheticTemplateInput('fixed-golden-path-fit'), files: ['not-read'] }); + const usageInput = inspectSyntheticTemplateChoice({ ...createFixedSyntheticTemplateInput('fixed-golden-path-fit'), usage: { claimedAdoption: 100 } }); + const wrongScope = inspectSyntheticTemplateChoice({ ...createFixedSyntheticTemplateInput('fixed-golden-path-fit'), scope: 'different-scope' }); + const wrongMode = inspectSyntheticTemplateChoice({ ...createFixedSyntheticTemplateInput('fixed-golden-path-fit'), mode: 'scan-real-template' }); + const unknownCase = inspectSyntheticTemplateChoice({ ...createFixedSyntheticTemplateInput('fixed-golden-path-fit'), caseId: 'invented-case' }); + const goldenPlan = planSyntheticTemplateDecision(golden); + const extensionPlan = planSyntheticTemplateDecision(extension); + const declinePlan = planSyntheticTemplateDecision(decline); + const forgedDecisionPlan = planSyntheticTemplateDecision({ ...golden, decision: 'use-reviewed-extension' }); + const forgedExtensionPlan = planSyntheticTemplateDecision({ ...extension, extension: null }); + const unexpectedReportPlan = planSyntheticTemplateDecision({ ...golden, repositoryPath: '/not/read' }); + const restored = rollbackSyntheticTemplateDraft(extensionPlan); + const forgedRestore = rollbackSyntheticTemplateDraft({ ...extensionPlan, actions: ['write-real-template'] }); + const rejectedRestore = rollbackSyntheticTemplateDraft(extension); + const cyclicReport = { + ...golden, + evidence: { ...golden.evidence }, + }; + cyclicReport.evidence.self = cyclicReport.evidence; + const cyclicReportPlan = planSyntheticTemplateDecision(cyclicReport); + const sparseRestore = rollbackSyntheticTemplateDraft({ + ...extensionPlan, + actions: new Array(extensionPlan.actions.length), + }); + + return Object.freeze({ + assertions: Object.freeze({ + acceptsClosedFixedGoldenInput: golden.accepted === true && golden.decision === 'use-golden-path', + recordsTheBaseContract: golden.baseContract.templateId === TEMPLATE_ID && golden.baseContract.owner === TEMPLATE_OWNER && golden.baseContract.requiredFacts.length === 4, + keepsGoldenRequestInMemoryOnly: golden.request.componentType === 'internal-http-service' && golden.evidence.source === 'embedded-fixed-records-only', + doesNotClaimRealTemplateOrAdoption: golden.evidence.realTemplate === 'not-read' && golden.evidence.adoption === 'not-measured' && golden.evidence.speed === 'not-measured' && golden.productionEffect === 'not-attempted', + acceptsNamedEscapeHatchOnlyAsReview: extension.accepted === true && extension.decision === 'use-reviewed-extension' && extension.extension.id === 'observability-adapter', + keepsEscapeHatchBounded: extension.extension.owner === TEMPLATE_OWNER && extension.extension.boundary.includes('does not replace base runtime'), + declinesNonRepeatableRequest: decline.accepted === true && decline.decision === 'decline-template' && decline.decisionReason === 'request-needs-design-before-any-template-choice', + exposesRefusalCriterion: decline.refusalCriterion.includes('no repeatable shape') && decline.nextHumanQuestion.includes('design review'), + rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-input-required', + rejectsUnexpectedInputField: unexpectedInput.accepted === false && unexpectedInput.reason === 'unexpected-input-field', + rejectsFileLikeInput: fileInput.accepted === false && fileInput.reason === 'unexpected-input-field', + rejectsUsageLikeInput: usageInput.accepted === false && usageInput.reason === 'unexpected-input-field', + rejectsDifferentScope: wrongScope.accepted === false && wrongScope.reason === 'unexpected-synthetic-scope', + rejectsScanMode: wrongMode.accepted === false && wrongMode.reason === 'synthetic-mode-required', + rejectsUnknownFixedCase: unknownCase.accepted === false && unknownCase.reason === 'unknown-fixed-synthetic-case', + plansOnlyGoldenDraft: goldenPlan.accepted === true && goldenPlan.decision === 'use-golden-path' && goldenPlan.actions.includes('keep-unlisted-choices-outside-the-template'), + plansOnlyExtensionDraft: extensionPlan.accepted === true && extensionPlan.decision === 'use-reviewed-extension' && extensionPlan.actions.includes('record-extension-owner-boundary-and-removal-condition'), + plansOnlyDeclineDraft: declinePlan.accepted === true && declinePlan.decision === 'decline-template' && declinePlan.actions.includes('start-design-review-outside-template'), + doesNotWriteOrMeasureOperationalSystems: extensionPlan.realTemplate === 'not-read-or-written' && extensionPlan.files === 'not-read-or-written' && extensionPlan.usage === 'not-read' && extensionPlan.ci === 'not-run' && extensionPlan.adoption === 'not-measured', + rejectsForgedDecision: forgedDecisionPlan.accepted === false && forgedDecisionPlan.reason === 'report-does-not-match-fixed-record', + rejectsForgedExtension: forgedExtensionPlan.accepted === false && forgedExtensionPlan.reason === 'report-does-not-match-fixed-record', + rejectsUnexpectedReportField: unexpectedReportPlan.accepted === false && unexpectedReportPlan.reason === 'unexpected-report-field', + rollbackDiscardsOnlyCanonicalSyntheticDraft: restored.restored === true && restored.template === 'not-read-or-written' && restored.repository === 'not-read-or-changed' && restored.catalog === 'not-read-or-written', + rollbackRejectsForgedDraft: forgedRestore.restored === false && forgedRestore.reason === 'no-accepted-synthetic-decision-draft', + reportCannotBeRolledBackAsDraft: rejectedRestore.restored === false && rejectedRestore.reason === 'no-accepted-synthetic-decision-draft', + rejectsCyclicReportWithoutThrowing: cyclicReportPlan.accepted === false && cyclicReportPlan.reason === 'report-does-not-match-fixed-record', + rejectsSparseDraftActions: sparseRestore.restored === false && sparseRestore.reason === 'no-accepted-synthetic-decision-draft', + }), + samples: Object.freeze({ + golden, extension, decline, nonSynthetic, unexpectedInput, fileInput, usageInput, wrongScope, wrongMode, + unknownCase, goldenPlan, extensionPlan, declinePlan, forgedDecisionPlan, forgedExtensionPlan, + unexpectedReportPlan, restored, forgedRestore, rejectedRestore, cyclicReportPlan, sparseRestore, + }), + }); +} + +const fixtureExample = [ + "import {", + " createFixedSyntheticTemplateInput,", + " inspectSyntheticTemplateChoice,", + " planSyntheticTemplateDecision,", + " runPlatformTemplatesFixture,", + "} from './upgrade-2024-05.mjs';", + "", + "const report = inspectSyntheticTemplateChoice(", + " createFixedSyntheticTemplateInput('fixed-reviewed-escape-hatch'),", + ");", + "const draft = planSyntheticTemplateDecision(report);", + "", + "if (!Object.values(runPlatformTemplatesFixture().assertions).every(Boolean)) {", + " throw new Error('synthetic fixture failed');", + "}", + "", + "console.log(report.decision); // use-reviewed-extension", + "console.log(draft.ci); // not-run", + "", + "// Фикстура использует только embedded records. Она не читает шаблон,", + "// репозиторий, файлы, usage, issue, интервью, CI или сеть.", +].join('\n'); + +const fixtureCommand = fixtureExample + '\n\nnode web/scripts/upgrade-2024-05.mjs --verify-fixture\n\n# PASS подтверждает согласованность только fixed synthetic records и отрицательных веток.'; + +const practice = revision({ + slug: 'editorial-2024-05-practice-platform-templates', + title: 'Шаблоны для команд: golden path без фабрики клонов', + categories: ['Архитектура', 'Практика'], + cover: '/assets/editorial/2024/platform-templates-2024-golden-path.svg', + excerpt: 'Практический маршрут для командного шаблона: назвать повторяемый контракт, оставить узкий golden path, дать проверяемый escape hatch и вовремя отказаться от формы, которая маскирует новую задачу.', + readingMinutes: 12, +}, [ + p('Шаблон обещает убрать первые сорок минут новой задачи: структура каталога, имя сервиса, ownership, минимальная документация, базовый delivery-маршрут. Проблема начинается, когда он начинает решать то, чего команда ещё не решила. В форму добавляют переключатель для нового runtime, особого хранения, исключения из policy и «временной» ветки CI. Через несколько запусков один и тот же шаблон выдаёт несовместимые стартовые точки. Цена не в YAML. Review становится длиннее, поддержка спорит с каждым generated repository, а локальная правка превращается в fork без владельца.'), + p('Сильный template не пытается покрыть все будущие сервисы. Он экономит работу там, где ответ уже повторяется и известен owner. Остальное нужно вынести из формы: либо в названное расширение, либо в короткий design review. Это выглядит как ограничение выбора, но именно оно оставляет стартовый путь дешёвым для следующей команды. Если шаблон содержит пять альтернативных архитектур, он перестаёт быть golden path и становится каталогом чужих рисков.'), + h2('Симптом → причина → проверка → действие'), + ol([ + 'Симптом. В запросе на новый сервис появляются фразы «добавьте одну галочку», «скопируем соседний generated repo» или «после создания поправим всё руками».', + 'Причина. В template не отделены повторяемые факты от конкретного решения. Поле формы выдали за договор, а локальное исключение выдали за следующий default.', + 'Проверка. Для каждого поля спросите: есть ли у него известный owner, одинаковое значение для повторяемого класса задач и обратимый путь, если оно оказалось неверным? Если ответ хотя бы раз нет, поле не принадлежит base template.', + 'Действие. Оставьте в golden path только стабильный контракт. Для известного варианта создайте named extension с owner и условием удаления. Для неизвестного варианта откажитесь от template и начните отдельный design path.', + ]), + h2('Начать с контракта, а не с набора файлов'), + p('Практический template можно описать одной карточкой. В ней есть идентификатор и версия, owner, целевой тип компонента, обязательные факты, список того, что он создаёт, и список того, чего он принципиально не обещает. Например, internal HTTP service с известным owner-ом, одобренным runtime и internal data class. Шаблон может подготовить skeleton, draft catalog metadata и checklist review. Он не может выдать production approval, доказать безопасность, измерить adoption или назвать будущую скорость работы команды.'), + p('Эта разница полезна и автору шаблона, и потребителю. Команда получает понятный вход: service name, owner и класс решения. Платформа получает границу: не надо угадывать retention policy или выбирать архитектуру за доменную команду. Если один из входов неизвестен, это не ошибка пользователя формы. Это сигнал, что задача не готова к конвейеру. В такой точке быстрый отказ экономит больше времени, чем генерация заготовки, которую потом переписывают.'), + table('Что входит в base contract internal service template', ['Часть', 'Фиксируем', 'Не фиксируем', 'Почему'], [ + ['Идентичность', 'имя сервиса, owner, версия template', 'название будущей команды или продукта', 'контракту нужен ответственный, но он не предсказывает организацию'], + ['Повторяемая форма', 'internal HTTP service и approved runtime class', 'новый runtime ради одной задачи', 'повторение уменьшает стоимость старта; новый выбор требует design review'], + ['Выход', 'skeleton, metadata draft, review checklist draft', 'production approval и итог CI', 'generated файл не является доказательством запуска'], + ['Data policy', 'явно выбранный internal class', 'новая retention или access model', 'policy нельзя безопасно спрятать в переключатель'], + ['Изменение', 'named extension с owner и rollback', 'локальный patch в каждом generated repo', 'расширение остаётся видимым; копия быстро теряет общий контракт'], + ]), + figure('/assets/editorial/2024/platform-templates-2024-golden-path.svg', 'Схема golden path: известный владелец, internal HTTP service, approved runtime и internal data ведут к versioned base contract. Нестабильный policy-вопрос вынесен в отдельную развилку, а не добавлен в форму.', 'Golden path показывает договор выбора. Он не измеряет фактическую скорость старта и не утверждает, что реальные команды уже используют именно такую схему.'), + h2('Как выглядит короткий договор'), + p('Договору не нужен внутренний фреймворк команды. Достаточно назвать факты, которые должен сохранить любой результат. У generated проекта есть owner; его тип соответствует заявленному классу; runtime относится к перечню, который уже поддерживается; metadata не прячет новый policy. Важно записать и отрицательную часть: template не создаёт секреты, не меняет production, не выбирает retention и не подменяет security review. Так новый автор не превратит «удобную заготовку» в невидимую систему полномочий.'), + code([ + '// Только учебная запись контракта; это не реальный template.yaml.', + 'const goldenPathContract = {', + " templateId: 'synthetic-service-golden-path-v3',", + " owner: 'synthetic-platform-templates-owner',", + " supports: 'internal-http-service',", + " requires: ['owner', 'approved-runtime', 'internal-data-class'],", + " produces: ['repository-skeleton', 'catalog-metadata-draft'],", + " refuses: ['new-retention-model', 'unknown-owner', 'one-off-migration'],", + " extension: 'named-observability-adapter-with-owner-and-rollback',", + '};', + '', + '// Никаких файлов, CI, usage и реального repository здесь нет.', + ].join('\n')), + h2('Golden path должен быть узким'), + p('Узкий не значит бедный. Он может собрать все договорённости, которые команда уже повторяет: расположение документации, обязательные labels, способ зарегистрировать компонент, базовый health endpoint или checklist. Но каждое правило должно отвечать на один вопрос: кто меняет его, если оно устарело? Если owner не назван, правило нельзя обновить без массового угадывания. Если оно зависит от доменной модели одной команды, оно не является общим default.'), + p('Backstage в snapshot v1.25.0 описывает Software Templates как skeleton с переменными и последовательными steps, которые могут публиковать результат. Это полезный механизм, когда входы и шаги уже согласованы. Из его возможностей не следует, что любой новый step должен появляться во всех template. В документации также видны логи шага и отмена task; это помогает расследовать запуск, но не превращает task log в правило архитектуры или в отчёт о качестве внедрения.'), + h2('Escape hatch: расширение вместо fork'), + p('Escape hatch нужен не для того, чтобы обойти каждый guard. Он нужен для случая, который остаётся в том же классе работы, но требует одного известного подключения. Пример: service на approved runtime нуждается в заранее описанном observability adapter. Extension должен иметь name, owner, ограниченную границу, условие удаления и rollback. Его output добавляется к base contract, а не заменяет owner, data class, runtime или delivery policy. Тогда reviewer видит, что было исключением, а будущая версия template может решить, стало ли оно общим.'), + p('Fork выглядит дешевле: команда копирует generated repository и меняет всё, что мешает. Но GitHub прямо указывает, что ветви repository, созданного из template, имеют несвязанную историю. Это нормальная механика создания нового репозитория, а не канал дальнейшей синхронизации. Поэтому нельзя надеяться, что исправление base template само найдёт все копии. Если команда всё же выбирает самостоятельный путь, его надо назвать самостоятельным design decision, а не «временной настройкой шаблона».'), + h2('Порядок принятия решения'), + ol([ + 'Назвать повторяемый класс. Запишите одно предложение: какой компонент создаётся, какой owner и какой runtime/data class уже одобрены. Не добавляйте поля, которые существуют только ради текущей истории.', + 'Сверить base contract. Проверяйте не файлы, а инварианты: тип, owner, обязательные facts и список обещанных outputs. Совпало — выбирайте golden path.', + 'Проверить extension. Если различие одно и уже имеет name, owner, границу и rollback, создайте review на extension. Extension не получает право расширять base policy.', + 'Отказаться вовремя. Если нет повторяемой формы, неизвестен owner, нужна новая access/retention модель или пользователь должен изменить инвариант, form заканчивается. Следующий артефакт — design record, не fork.', + 'Вернуть результат в цикл. После реального review обновите контракт либо добавьте скоуп extension. Не объявляйте template улучшенным по ощущениям; соберите разрешённые evidence отдельно.', + ]), + h2('Когда отказ — правильный результат'), + p('Особенно опасна задача «один раз перенесём регулируемые данные, а потом, может быть, повторим». В ней ещё нет стабильного component type, неизвестны ownership и lifecycle, а retention и access могут быть собственными решениями. Попытка вставить её в service template скрывает вопросы под нейтральными параметрами. Форма создаст видимость готовности, но команда всё равно будет принимать архитектуру уже после генерации. Правильный ответ здесь: не template пока.'), + p('Это не запрет на рост платформы. После нескольких разрешённых design review может появиться устойчивый класс работ и понятный owner. Тогда можно выделить отдельный golden path или extension. Но сперва должен появиться договор, а не набор прошлых копий. Критерий прост: если чтобы пройти форму надо нарушить base invariant, не добавляйте переключатель. Зафиксируйте отказ, объясните следующую проверку и оставьте путь обратимым.'), + h2('Ограничения, rollback и следующий шаг'), + p('Template contract не заменяет threat modeling, legal review, capacity planning, тесты, CI или migration plan. Он не доказывает, что generated repository можно деплоить, и не обязан совпадать со всеми repository teams. Backstage и GitHub описывают конкретные механизмы scaffolding и template repository, а не универсальную организационную policy. Прежде чем привязать реальный action к форме, отдельно задайте разрешённую область, credential boundary и способ проверить output.'), + p('Rollback для решения тоже должен быть коротким. Пока создан только draft contract или extension proposal, удаляется именно он; не нужно откатывать чужой template или переписывать repositories. Если расширение оказалось неверным, команда возвращается к versioned base contract и проводит обычный design review. Следующий шаг — выбрать одну часто повторяемую заявку и заполнить для неё пять полей: class, owner, invariants, allowed extension, refusal criterion. Если поле не удаётся назвать без текущей истории, не переносите его в template.'), + h2('Историческая граница мая 2024'), + p('Материал ограничен Backstage v1.25.0 и GitHub Enterprise Server 3.12: оба источника доступны до конца мая 2024. Они подтверждают, что template может подставлять переменные и создавать новую структуру repository, но не подтверждают adoption, скорость, качество конкретной команды или автоматическую синхронизацию forks. Автор уровня M7 использует инструмент как узкий контракт, а не как повод скрыть незакрытое архитектурное решение.'), +]); + +const mechanism = revision({ + slug: 'editorial-2024-05-mechanism-platform-templates', + title: 'Шаблон как договор: параметры, extension point и право сказать «нет»', + categories: ['Архитектура', 'Инженерные практики'], + cover: '/assets/editorial/2024/platform-templates-2024-escape-hatch.svg', + excerpt: 'Техническая механика командного template: закрытый input contract, versioned base path, named extension, отказ и rollback — без мифа, что генерация заменяет политику или проверку результата.', + readingMinutes: 12, +}, [ + p('Самый дорогой template обычно выглядит очень гибким. В него постепенно добавляют boolean-поля: включить другой runtime, не создавать metadata, применить альтернативный delivery, разрешить особую сеть. Каждое поле кажется безобидным, но комбинации создают продукт, который никто не поддерживает. Пользователь получает возможность собрать противоречивую конфигурацию, reviewer — обязанность восстановить намерение по анкете, а platform team — скрытый набор compatibility promises. Цена растёт быстрее числа шаблонов: она растёт числом комбинаций, для которых нет владельца и rollback.'), + p('Механика должна сделать невозможным хотя бы очевидную ошибку: нельзя провести неготовое решение через форму просто потому, что поле существует. Для этого template не принимает произвольный объект «настройки сервиса». Он принимает короткий закрытый contract: какие facts обязательны, какие значения образуют base path, какое расширение дозволено и по какому условию нужно прекратить генерацию. Такой contract не делает архитектуру автоматической. Он делает точку выбора наблюдаемой и проверяемой человеком.'), + h2('Симптом → причина → проверка → действие'), + ol([ + 'Симптом. В форме появляются взаимно исключающие чекбоксы, а в generated repository остаются комментарии «выберите одно из двух позже».', + 'Причина. Input model открыт: поле добавляют для текущей просьбы, не связывая его с owner-ом, базовым инвариантом и дальнейшей поддержкой.', + 'Проверка. Перечислите допустимые keys и canonical combinations. Отдельно назовите values, которые ведут к golden path, к named extension и к отказу. Не используйте случайный user input как доказательство policy.', + 'Действие. Примите только закрытый input contract, пересоберите решение из versioned record, а всё, что не совпало, направьте в review или decline. Эта проверка может жить рядом с template, но не подменяет реальное выполнение.', + ]), + h2('Три разных механизма, которые часто путают'), + p('Repository template и platform template решают соседние, но разные задачи. GitHub template repository копирует структуру и файлы в новый repository; у созданных ветвей несвязанная история. Это хорошо для стартового состояния, но не означает обновление копий в будущем. Backstage Software Templates работают как scaffolder: template описывается как entity с owner, type, parameters и последовательными steps; skeleton и переменные можно использовать для создания компонента. Это способ провести согласованный маршрут, а не стандарт качества организации.'), + p('Третий слой — командный contract. Он не поставляется GitHub или Backstage. Именно он отвечает, какой service type повторяем, какие inputs считаются допустимыми, где проходит extension boundary и что делать при несовпадении. Без этого слоя портал лишь ускоряет копирование. С ним portal становится удобным интерфейсом к уже принятой policy. Если platform team меняет form, но не меняет contract, она меняет UI, а не архитектурное решение.'), + table('Механизм и его граница', ['Слой', 'Что он умеет', 'Чего он не доказывает', 'Кто владеет решением'], [ + ['GitHub repository template', 'создать новый repository со структурой и файлами', 'синхронизацию будущих копий и общую policy', 'owner template repository'], + ['Backstage Software Template', 'собрать parameters и выполнить последовательные scaffolder steps', 'корректность любого action или бизнес-совместимость output', 'owner template и owner интеграций'], + ['Base contract', 'назвать allowed input, invariant, output и refusal', 'результат CI, безопасность и adoption', 'platform team вместе с доменным owner-ом'], + ['Named extension', 'добавить одно описанное различие поверх base path', 'право заменить runtime, ownership или data policy', 'owner extension и reviewer'], + ['Design path', 'разобрать новый класс работ отдельно от формы', 'что новый class уже готов стать default', 'команда, которая владеет задачей'], + ]), + figure('/assets/editorial/2024/platform-templates-2024-escape-hatch.svg', 'Диаграмма escape hatch: вход сверяется с base contract. Совпадение идёт в golden path; одно названное расширение с owner и rollback — в review; изменение инварианта или неизвестная policy останавливает template и ведёт в design path.', 'Escape hatch здесь — контролируемая развилка. Он не является произвольным флагом и не показывает состояние реального портала, CI или репозиториев.'), + h2('Закрытый input contract'), + p('У формы должен быть не только список labels, но и точная форма данных. Для учебного internal service это marker synthetic, scope, mode fixed-memory-only и один из заранее записанных case id. В реальном template аналогом будет схема параметров и server-side validation, привязанная к версии contract. Важна идея: input не может содержать template path, repository path, usage counter, issue link, CI result или произвольный policy object только потому, что клиент отправил лишнее поле. Такие значения смешивают выбор с недостоверным evidence и дают инструменту слишком много полномочий.'), + p('Закрытость нужна не из любви к строгим схемам. Она связывает действие с известным источником правил. Когда service name, owner и approved class соответствуют base contract, результат может быть «golden path». Когда есть ровно одно заранее названное различие, результат может быть «extension review». Если record не существует, default должен быть decline. Иначе новая policy незаметно становится частью прошлой версии template, а платформа узнаёт об этом только после fork-а.'), + code([ + '// Synthetic fixture: здесь caseId выбирает только один embedded record.', + "const input = createFixedSyntheticTemplateInput('fixed-reviewed-escape-hatch');", + 'const report = inspectSyntheticTemplateChoice(input);', + '', + "if (report.decision !== 'use-reviewed-extension') {", + " throw new Error('unexpected synthetic decision');", + '}', + '', + '// report.extension содержит name, owner, boundary и rollback.', + '// Она не читает template.yaml, repositories, issues, usage или CI.', + ].join('\n')), + h2('Base path и extension должны иметь разные права'), + p('Base path фиксирует то, что уже является повторяемым: например, internal HTTP service с known owner, approved runtime и internal data class. Он может выдать skeleton, draft metadata и checklist. Но он не должен принимать параметр «выбери любой runtime». Такой параметр не расширяет base path, а удаляет его смысл. Разница важна для rollback: если contract остаётся целым, команда возвращается к предыдущей versioned записи; если base invariants менялись произвольно, назад возвращать уже нечего.'), + p('Extension можно разрешить, только если он не меняет base invariants. Named observability adapter — хорошая учебная граница: он добавляет один adapter record, имеет owner и явно не заменяет runtime, ownership, data class и delivery policy. Другой retention model, неизвестный owner или новая external access policy — не extension. Они изменяют сам тип решения. Передавать их через extension значит назвать fork безопасным только потому, что он лежит в другом поле формы.'), + h2('Проверка canonical record до draft'), + p('Фикстура в этом пакете намеренно не принимает произвольный request. Она хранит три fixed synthetic records в памяти: чистый golden path, одно именованное расширение и отказ от одноразовой регулируемой миграции. Input с неожиданным field, другим scope, режимом scan-real-template или неизвестным case id отклоняется. Это не parser настоящего template. Это маленькая модель того, почему платформа сначала должна подтвердить идентичность rule, а потом составлять proposal.'), + p('Вторая проверка защищает от более тонкой подмены. Plan принимает только report, который снова совпадает с canonical fixed record целиком: request, decision, extension и evidence. Если подменить decision на extension, убрать extension или добавить repositoryPath, draft не создаётся. После принятия plan содержит только draft actions и прямое описание границы: real template не читается и не пишется, files не читаются, CI не запускается, adoption и speed не измеряются. Это важно: хороший учебный fixture не выдаёт собственную согласованность за результат команды.'), + code(fixtureCommand), + h2('Отказ — часть API, а не аварийная ветка'), + p('В техническом смысле decline должен иметь такой же ясный contract, как success. Для одноразовой regulated data migration input говорит: shape не повторяется, owner не известен, runtime не выбран, data class и retention model новые. Ответ «decline-template» не оставляет пользователя на пустой странице. Он возвращает reason, следующий human question и rollback: generated артефакта нет, остаётся только decision record. Дальше нужна маленькая design review, а не поиск ещё одного параметра.'), + p('Это повышает качество технического интерфейса. Пользователь знает, почему не получил генерацию. Reviewer знает, что нужно решить. Platform team не вынуждена поддерживать потенциально опасное исключение. Если через время сценарий станет регулярным, можно создать новый versioned contract с отдельным owner-ом и доказать границы реальными разрешёнными evidence. Пока этого нет, честный отказ лучше ложного зелёного результата.'), + h2('Маршрут внедрения механики'), + ol([ + 'Записать base facts. Зафиксируйте target type, owner, mandatory inputs, outputs и list of non-goals. Версия contract меняется вместе с этими словами, а не только с template file.', + 'Сделать schema закрытой. Отвергайте неизвестные keys и неполные combinations. Нельзя передавать через форму path, credential, raw policy или метрику usage без отдельного разрешённого контекста.', + 'Развести три outcomes. Golden path использует base record; extension создаёт review draft; decline открывает design path. Никакой outcome не должен молча становиться fork.', + 'Записать rollback. Для base и extension proposal обозначьте, какой versioned draft можно убрать. Не обещайте откат реальных репозиториев, если их жизненный цикл не входит в scope.', + 'Подключить реальный action отдельно. После согласования policy проведите threat model, credential review, dry run, output validation и ограниченный rollout в своих системах. Fixture и documentation не выполняют это вместо команды.', + ]), + h2('Ограничения и следующий шаг'), + p('Backstage v1.25.0 показывает parameters и serial steps, но не объявляет все custom action безопасными или подходящими. GitHub template repository создаёт новые repository, но не создаёт канал обновления между независимыми историями. JSON schema или validation logic могут отфильтровать shape input, но не узнают бизнес-смысл нового data policy. Поэтому не надо называть closed contract «автоматической архитектурой». Он только удерживает инструмент в границах решений, которые команда уже владеет.'), + p('Следующий шаг — выбрать один действующий template и выписать его current inputs без изменения output. Затем пометить каждый input как base invariant, named extension или unowned choice. Любой третий тип сначала вынесите из формы в design queue. После этого можно добавить отрицательные tests: неизвестный field, изменение decision, подмена extension, попытка рассматривать report как plan. Это дешевле, чем обнаруживать несовместимые forks по истории репозиториев.'), + h2('Историческая граница мая 2024'), + p('Все технические ссылки ограничены snapshot Backstage v1.25.0 и GitHub Enterprise Server 3.12, доступными к маю 2024. Они используются только для заявленных механизмов: parameters и steps scaffolder-а, создание repository из template и несвязанная история его ветвей. Из источников не выводятся claims об adoption, скорости разработки, безопасности action или качестве реального generated output.'), +]); + +const field = revision({ + slug: 'editorial-2024-05-field-platform-templates', + title: 'Шаблоны для команд: полевой разбор между fork и честным отказом', + categories: ['Архитектура', 'Кейсы'], + cover: '/assets/editorial/2024/platform-templates-2024-adoption-loop.svg', + excerpt: 'Учебный разбор трёх запросов к service template: совпадение с golden path, узкое расширение и отказ для новой регулируемой задачи. Без выдуманных метрик, использования или результатов CI.', + readingMinutes: 12, +}, [ + p('В учебной очереди лежат три почти одинаковые просьбы: «нужен новый сервис». Первая действительно похожа на остальные: internal HTTP service, owner известен, runtime из approved набора, данные internal. Во второй нужен заранее названный observability adapter. В третьей команда хочет провести одноразовую регулируемую миграцию, но owner, runtime, retention и access model ещё выбираются. Ошибка — обработать все три одной кнопкой Create. Цена такой экономии появляется позже: третья задача получает service skeleton вместо решения, а первая и вторая начинают жить рядом с fork-ами, которыми никто не владеет.'), + p('Это не рассказ о конкретном repository или компании. Records ниже заранее записаны в памяти и помечены synthetic. Мы не читали template, usage, issue, interview, CI, файлы или сеть; не измеряли adoption, скорость и эффект в production. Цель разбора — показать, как М7-автор отделяет форму запроса от доказательства и как объясняет отказ без театра «платформа всё решила».'), + h2('Три заявки, три разных результата'), + table('Учебная карта решений', ['Synthetic заявка', 'Наблюдаемый факт', 'Решение', 'Почему не fork'], [ + ['fixed-golden-path-fit', 'повторяемый service type, owner известен, approved runtime и internal data', 'use-golden-path', 'base contract уже описывает этот класс без новых policy'], + ['fixed-reviewed-escape-hatch', 'всё как в base path, плюс named observability adapter', 'use-reviewed-extension', 'одно различие имеет owner, границу и rollback; base invariants не меняются'], + ['fixed-decline-template', 'one-off regulated migration, owner и lifecycle не определены, нужны новая access и retention policy', 'decline-template', 'это design question, а не пропущенная checkbox в service form'], + ['локальный patch после генерации', 'нет versioned extension и неясно, кто поддерживает отличие', 'не является outcome fixture', 'patch скрывает контракт; он не доказывает допустимость решения'], + ]), + figure('/assets/editorial/2024/platform-templates-2024-adoption-loop.svg', 'Цикл принятия решения: зафиксировать synthetic request, сверить с base contract, выбрать golden path, named extension или decline, затем отдельно собрать разрешённые evidence для следующей версии. Стрелка не обозначает измеренный adoption.', 'Схема показывает управляемое обучение policy. Она не утверждает, что template использовался в production или что какие-либо команды стали работать быстрее.'), + h2('Первый запрос: не усложнять то, что уже совпало'), + p('У первого record все факты совпадают с base contract. Это не повод сказать «сервис готов». Это повод применить короткий договор: service type известен, owner есть, runtime одобрен, data class internal. Result называется use-golden-path, потому что его inputs не требуют нового решения. Platform template может подготовить skeleton, metadata draft и review checklist. Дальше люди всё равно проверяют безопасность, credentials, delivery и поведение уже в пределах реальной системы.'), + p('Симптом здесь был бы другой: reviewer предлагает добавить option «может быть внешний data class, но пока оставим internal». Причина — желание удержать текущую задачу внутри одной формы. Проверка проста: меняет ли option base invariant? Если да, это не улучшение golden path. Действие — удалить option из base form и направить новую policy в отдельный путь. Первый запрос не должен платить complexity tax за гипотетическую третью задачу.'), + h2('Второй запрос: extension должен быть конкретнее желания'), + p('Во втором record нужен observability adapter. Важна не вывеска «расширение», а его рамка: extension имеет identifier, owner, boundary и rollback. Он добавляет только adapter record, не переопределяет runtime, ownership, data class или delivery policy. Поэтому fixture выдаёт use-reviewed-extension, а план — только draft actions: записать base contract, открыть review на extension, зафиксировать owner, boundary и removal condition. Никакого реального action, template edit или CI запуск не происходит.'), + p('Такое различение защищает от ложного компромисса. Если пользователю нужна новая retention policy, нельзя назвать её observability adapter. Если extension просит заменить approved runtime, это уже изменение base contract либо новый component type. В обоих случаях reviewer останавливает форму. Нельзя выдавать общий namespace extension за свободный вход для любого domain solution.'), + code([ + '// Данный фрагмент работает только с embedded synthetic case.', + "const report = inspectSyntheticTemplateChoice(", + " createFixedSyntheticTemplateInput('fixed-reviewed-escape-hatch'),", + ');', + '', + "console.log(report.decision); // use-reviewed-extension", + "console.log(report.extension.id); // observability-adapter", + "console.log(report.evidence.ci); // not-run", + '', + '// Ни template file, ни repository, ни usage не читаются.', + ].join('\n')), + h2('Третий запрос: form не обязана принять всё'), + p('Третья запись нарочно неудобна. Это one-off regulated data migration; владелец ещё не назначен, runtime не выбран, data class external and regulated, а access и retention модель только формулируются. Попытка прогнать её через internal service template создаст красивую структуру, но не ответит ни на один существенный вопрос. Более того, сама structure будет давить на решение: команда начнёт подгонять policy под generated файлы, а не наоборот.'), + p('Поэтому verdict decline-template содержит не только отказ, но и следующий вопрос: провести небольшой design review за пределами template и вернуться, только когда появится repeatable contract. Rollback здесь почти пустой: нет generated artifact, только synthetic decline record. Это хороший признак. Чем меньше неготовая задача успела создать, тем меньше полей придётся спасать при уточнении policy. «Не делали» иногда гораздо более обратимо, чем «создали и потом передумали».'), + h2('Симптом → причина → проверка → действие на одной карточке'), + ol([ + 'Симптом. Команда просит добавить «универсальный» флаг, потому что текущая задача не помещается в service template.', + 'Причина. Различие затрагивает ownership, runtime, data class, retention или access policy; это меняет base invariant, а не только technical adapter.', + 'Проверка. Сверьте request с contract. Совпадение всех base facts даёт golden path. Одно заранее описанное дополнение с owner и rollback даёт extension review. Всё остальное требует decline.', + 'Действие. Не создавайте локальную копию и не расширяйте форму. Зафиксируйте reason, начните design path, а позже верните только устойчивый class как новый versioned contract или named extension.', + ]), + h2('Почему fork не является четвёртым разрешённым ответом'), + p('Fork часто маскируют словом «bootstrap». Пользователь создаёт repository из template, меняет структуру и обещает позже перенести полезное обратно. Но механизм GitHub template repository даёт новой ветви несвязанную историю. Это означает, что исходный template не получает естественный канал обновить все созданные copies. Такое состояние допустимо, когда команда осознанно владеет самостоятельным проектом. Оно не должно быть невидимым fallback для всякого несовпадения с form.'), + p('Полевое правило короткое: если отличие нельзя описать как named extension с owner, boundary и rollback, не называйте его extension. Если команда всё же продолжает отдельно, создайте design record, назначьте owner и скажите, что это отдельный path. Тогда платформа не обещает синхронизацию, которой у неё нет, а команда не получает ложное чувство, что она всё ещё находится на golden path.'), + h2('Маленькая фикстура как проверка границы'), + p('Fixture этого sidecar хранит три records и использует закрытый input: synthetic marker, scope, fixed-memory-only mode и case id. Он отвергает extra field, который похож на request path или files, другой scope, scan mode и неизвестный case. Это намеренно. В реальной работе нельзя подменять policy случайной строкой из issue или коэффициентом usage, если контекст не разрешил читать их и если не определено, как они влияют на decision.'), + p('После inspection plan повторно сравнивает report с canonical embedded record. Подмена use-golden-path на use-reviewed-extension, исчезновение extension, добавление repositoryPath, циклический report или разрежённый список действий приводят к отказу без падения fixture. После этого rollback принимает только canonical synthetic draft. Такая модель не решает problem реального onboarding. Она показывает минимальную дисциплину: evidence, decision и operation не следует смешивать в одном неаудируемом объекте.'), + code(fixtureCommand), + h2('Маршрут для реальной команды после учебного разбора'), + ol([ + 'Возьмите одну настоящую заявку с разрешением на анализ. Не запускайте массовый scan. Выпишите только заявленный component type, owner и policy, которых она касается.', + 'Сопоставьте с текущим contract. Пройдите четыре base facts и один refusal criterion. Если данное поле не имеет owner-а или ожидаемого значения, это не input golden path.', + 'Сделайте выбор видимым. Golden path, extension review и decline должны попадать в разные human-readable records. Не прячьте decision в комментарии generated файла.', + 'Проверьте безопасный rollout отдельно. Для реального action нужны отдельные credentials, dry run, threat model, output validation, CI и rollback plan. Ни один из этих результатов не следует брать из fixture.', + 'Обновляйте policy после evidence. Повторяемость доказывается разрешёнными наблюдениями и review, а не количеством похожих копий в репозиториях. Только затем меняйте versioned template contract.', + ]), + h2('Ограничения, rollback и следующий шаг'), + p('Этот разбор не назначает owner реальной миграции, не выбирает data policy, не читает current template и не знает поддерживаемые runtime в чужой организации. Backstage documentation показывает, как описывать Template entity и sequential steps; GitHub docs — как создаётся repository из template. Ни один источник не говорит, что данная regulated migration можно автоматизировать или что extension безопасен. Именно поэтому synthetic report остаётся учебным record, а не рекомендацией выполнить действие.'), + p('Rollback лучше планировать до кнопки Create. Для base proposal можно удалить draft. Для extension proposal — вернуть base contract и закрыть review. Для declined request не надо «откатывать» ничего, потому что форма не создала ложный результат. Следующий шаг — добавить к одному реальному template явный refusal criterion и owner для каждого existing extension. Затем провести review двух самых частых локальных patches: возможно, один станет честным extension, а второй останется отдельным design path.'), + h2('Историческая граница мая 2024'), + p('Разбор опирается только на первичные документы Backstage v1.25.0 и GitHub Enterprise Server 3.12, доступные до мая 2024. Из них взяты ограниченные факты о skeleton/parameters/steps и о новых repository с несвязанной историей. Все request records, decision codes и outcomes в статье synthetic; они не являются статистикой использования, интервью, issue-анализом, CI или production-результатом.'), +]); + +export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); + +function verifyFixture() { + const report = runPlatformTemplatesFixture(); + const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key); + if (failed.length) { + process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); + process.exitCode = 1; + return; + } + 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');