From 440c8721dcc5250e06048e0ef3691e9ad3325f59 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 22:26:56 +0300 Subject: [PATCH] rewrite 2026-09 and 2027 articles for reader-facing quality --- editorial/QUALITY_STANDARD.md | 5 +- editorial/production/README.md | 2 +- editorial/reviews/2026-09-draft.md | 66 ++- editorial/reviews/2027-01-draft.md | 106 ++-- editorial/reviews/2027-02-draft.md | 94 ++-- editorial/reviews/2027-03-draft.md | 114 ++--- editorial/reviews/2027-04-draft.md | 116 ++--- editorial/reviews/2027-05-draft.md | 119 ++--- editorial/reviews/2027-06-draft.md | 126 ++--- editorial/reviews/2027-07-draft.md | 124 ++--- editorial/reviews/2027-08-draft.md | 82 ++- editorial/reviews/2027-09-draft.md | 96 +--- editorial/reviews/2027-10-draft.md | 98 +--- editorial/reviews/2027-11-draft.md | 67 +-- editorial/reviews/2027-12-draft.md | 106 ++-- ...ry-2026-contract-responsibility-matrix.svg | 44 +- ...end-boundary-2026-review-evidence-loop.svg | 46 +- ...kend-boundary-2026-state-ownership-map.svg | 42 +- ...anifesto-2027-editorial-decision-graph.svg | 43 +- ...r-manifesto-2027-quality-rubric-matrix.svg | 29 +- ...r-manifesto-2027-revision-handoff-loop.svg | 38 +- ...ix-lessons-2027-keep-wrap-replace-tree.svg | 17 +- ...x-lessons-2027-migration-evidence-loop.svg | 17 +- ...x-lessons-2027-version-boundary-matrix.svg | 14 +- ...tion-2027-comparable-conditions-matrix.svg | 19 +- ...-evolution-2027-configuration-timeline.svg | 23 +- ...d-evolution-2027-evidence-handoff-loop.svg | 21 +- .../2027/d-lessons-2027-constraint-matrix.svg | 13 +- .../d-lessons-2027-evidence-handoff-loop.svg | 13 +- .../d-lessons-2027-runtime-tradeoff-map.svg | 22 +- ...bugging-decade-2027-evolution-timeline.svg | 42 +- ...g-decade-2027-hypothesis-evidence-loop.svg | 32 +- ...ng-decade-2027-signal-tool-limit-table.svg | 25 +- ...p-tls-guide-2027-evidence-handoff-loop.svg | 26 +- ...tp-tls-guide-2027-handshake-header-map.svg | 39 +- ...tls-guide-2027-symptom-boundary-matrix.svg | 28 +- ...form-interview-2027-claim-evidence-map.svg | 31 +- ...-interview-2027-editorial-handoff-loop.svg | 28 +- ...-interview-2027-source-boundary-matrix.svg | 25 +- ...ntor-series-2027-autonomy-handoff-loop.svg | 23 +- ...tor-series-2027-practice-review-matrix.svg | 18 +- .../mentor-series-2027-skill-map-contract.svg | 37 +- ...istakes-revisions-2027-advice-timeline.svg | 26 +- ...sions-2027-condition-correction-matrix.svg | 18 +- ...s-revisions-2027-evidence-handoff-loop.svg | 23 +- ...e-capstone-2027-critical-path-contract.svg | 19 +- ...capstone-2027-evidence-boundary-matrix.svg | 18 +- ...performance-capstone-2027-handoff-loop.svg | 18 +- ...lity-capstone-2027-fault-tree-contract.svg | 40 +- ...capstone-2027-recovery-evidence-matrix.svg | 32 +- ...ty-capstone-2027-response-handoff-loop.svg | 34 +- ...ity-capstone-2027-attack-path-contract.svg | 26 +- ...-capstone-2027-control-evidence-matrix.svg | 25 +- ...rity-capstone-2027-review-handoff-loop.svg | 28 +- web/scripts/audit-editorial-draft.mjs | 16 + web/scripts/audit-quality-batch.mjs | 16 + web/scripts/upgrade-2026-09.mjs | 435 +++++++++------- web/scripts/upgrade-2027-01.mjs | 397 +++++++++------ web/scripts/upgrade-2027-02.mjs | 397 ++++++++++----- web/scripts/upgrade-2027-03.mjs | 397 +++++++++------ web/scripts/upgrade-2027-04.mjs | 426 +++++++++------- web/scripts/upgrade-2027-05.mjs | 416 ++++++++++----- web/scripts/upgrade-2027-06.mjs | 460 ++++++++++------- web/scripts/upgrade-2027-07.mjs | 480 +++++++++--------- web/scripts/upgrade-2027-08.mjs | 452 +++++++++-------- web/scripts/upgrade-2027-09.mjs | 384 +++++++++----- web/scripts/upgrade-2027-10.mjs | 412 ++++++++++----- web/scripts/upgrade-2027-11.mjs | 451 +++++++++------- web/scripts/upgrade-2027-12.mjs | 435 ++++++++++------ 69 files changed, 4424 insertions(+), 3533 deletions(-) diff --git a/editorial/QUALITY_STANDARD.md b/editorial/QUALITY_STANDARD.md index 4e7fa4b..b38cf44 100644 --- a/editorial/QUALITY_STANDARD.md +++ b/editorial/QUALITY_STANDARD.md @@ -25,6 +25,7 @@ - Нижняя граница — не повод искусственно растягивать выводы. Если тема проста, глубину создают контекст, контрпример, проверка и решение, а не повтор одной мысли. - Верхняя граница — повод разбить слишком широкую тему на серию. Одна статья отвечает на один главный вопрос. - Каждый абзац либо добавляет факт, решение, ограничение или следующий шаг. Вступления «вообще о важности темы» и эмоциональные связки без технического смысла вырезаются. +- В тело статьи не попадает внутренний редакционный процесс: планы выпуска, source cutoff, editorial date, future-only, synthetic hand-off, `productionEffect`, статусы `not-collected`/`not-attempted`, отчёты о траектории автора и описание того, как статья проверялась. Это материал для review-файла, не тема читательской публикации. ## Техническая речь @@ -50,7 +51,7 @@ ## Тройное ревью перед публикацией 1. **Факты и техника.** Сверить утверждения с источниками, проверить пример, версионные оговорки, ссылки и отсутствие ложных обещаний. -2. **Редактура и голос.** Проверить постановку проблемы, объём 5–15 тыс. знаков, плотность, прагматичность речи, естественность тона соответствующего года, повторы и ясность переходов. Для месячной тройки отдельно сравнить статьи между собой: общий контекст не заменяет самостоятельное раскрытие каждого вопроса. +2. **Редактура и голос.** Проверить постановку проблемы, объём 5–15 тыс. знаков, плотность, прагматичность речи, естественность тона соответствующего года, повторы и ясность переходов. Удалить внутреннюю мета-лексику и проверить, что статья отвечает на технический вопрос читателя, а не описывает автора или редакционный процесс. Для месячной тройки отдельно сравнить статьи между собой: общий контекст не заменяет самостоятельное раскрытие каждого вопроса. 3. **Визуал и выпуск.** Открыть изображения и диаграммы, проверить таблицы на узком экране, доступность `alt`/подписей, JSON, автоматический аудит и production-сборку. -Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`. +Каждый проход должен оставить конкретную правку или явное обоснование, почему правка не нужна; запись одного `PASS` без списка изменений не считается ревью. Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`. diff --git a/editorial/production/README.md b/editorial/production/README.md index 55b5e05..06d5105 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 358 из 358 созданных материалов. Непринятых материалов не осталось: каждая серия прошла исследовательское, редакторское и визуальное ревью, а затем отдельную публикационную проверку. +На 31 июля 2026 года строгий аудит нового стандарта проходит 39 из 39 материалов исправляемого пакета: три статьи за сентябрь 2026 года и 36 статей за 2027 год. Для каждой выполнены три содержательных прохода с правками, проверка источников, runnable-примера, таблицы, SVG и cross-scan. Остальные архивные материалы не объявляются автоматически перепринятыми: их содержимое не перезаписывалось в рамках этой пересборки. ## Одна партия diff --git a/editorial/reviews/2026-09-draft.md b/editorial/reviews/2026-09-draft.md index 43b434f..00711cd 100644 --- a/editorial/reviews/2026-09-draft.md +++ b/editorial/reviews/2026-09-draft.md @@ -1,49 +1,45 @@ -# P103 — сентябрь 2026: граница frontend и backend +# Пакет 2026-09 — три редакторских прохода -Статус: изолированный **план/сценарий на сентябрь 2026**, не field report. Исторический cutoff источников: **31.07.2026**. Пакет намеренно не утверждает, что сентябрьская работа была проведена, опубликована, измерена, внедрена или одобрена. Все модели, названия, поля, значения и positive outputs — named fixed synthetic JavaScript literals в памяти; UI, API, пользователь, сеть, trace, файл, секрет, endpoint и production не читаются и не представляются фактом. +Пакет переписан как три самостоятельные технические статьи. В тексте нет описания авторской траектории, редакционного плана, даты отсечения источников, отчёта о публикации или обещаний о состоянии системы. Стабильные slug сохранены. -## Проход 1 — проблема, плотность, голос и независимость +## Проход 1 — проблема, структура и практический результат -- Practice начинает с присвоения UI доменного факта и цены несовместимых версий между входами. Его вопрос: как в сентябрьском плане разложить local state, context и screen read model по владельцам. -- Mechanism начинает с handler, который трактует отправку command как подтверждение. Его предмет: какое право на вывод дают query, command, read model и problem envelope, а не карта владельцев как таковая. -- Field начинает с подмены evidence правдоподобным mock и цены неповторимого решения. Его предмет: как передать только synthetic review card без имитации UI-network trace. -- Первые два абзаца каждой статьи явно называют проблему и цену. В каждой есть отдельная таблица, SVG, выполняемый snippet, упорядоченный план, ограничения и следующий шаг. Общего вступительного блока нет. -- Размер тел без списка источников: practice — 8 953, mechanism — 8 977, field — 9 006 знаков. Это в диапазоне 5 000–15 000 и близко к целевому объёму 9 тыс. без наполнения повторами. -- Строгий попарный scan исключил source lists, но включил code: 0 совпадающих абзацев от 160 символов и 0 общих 12-словных фрагментов для всех трёх пар. +Для каждой статьи выполнена отдельная замена предмета, а не косметическая правка. -## Проход 2 — источники, future-fact gate и literal execution +| Статья | Было | Стало | +|---|---|---| +| Practice | «План на сентябрь: карта границы между экраном и доменом» и рассуждение о synthetic hand-off | «Ответ 200 — ещё не модель экрана: валидируем JSON на границе UI и API»; в первых абзацах названы сломанный payload, цена рассинхрона и порядок проверки status → media type → JSON → screen model | +| Mechanism | «План на сентябрь: на что boundary даёт право сделать вывод» и fixed literal ownership | «Состояние экрана не угадывают по статусу: разделяем query, command и ошибку»; добавлены 2xx/204/409/422/429/5xx, problem envelope и отдельная retry policy | +| Field | «Сценарий на сентябрь: synthetic hand-off для границы UI и API» и карточка передачи | «UI и API расходятся: полевой протокол диагностики без догадок»; добавлены четыре наблюдения intent/request/response/render input и маршрутизация к adapter, cache или selector | -1. [OpenAPI Specification 3.1.1](https://spec.openapis.org/oas/v3.1.1.html), версия 3.1.1 от 24.10.2024 — первичная версионированная спецификация. Использована только для узкого утверждения, что HTTP API имеет независимое от языка interface description. Она не доказывает существование endpoint, screen model или выбранного boundary. -2. [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), июнь 2022 — неизменяемый RFC. Использован только для различения HTTP semantics и application-level problem policy. Он не задаёт retry, не подтверждает response и не описывает product flow. -3. [JSON Schema Core, draft 2020-12](https://json-schema.org/draft/2020-12/json-schema-core.html), Internet-Draft (work in progress), 16.06.2022. Использован только для идеи явной структуры/vocabulary contract-карточки. Валидация схемы не доказывает доменную корректность, UI-поведение или достоверность fixed literals. +Для всех трёх материалов добавлены самостоятельные разделы, доступные таблицы, SVG-схемы, кодовый пример, последовательность действий, ограничения и следующий шаг. Повторы между текстами убраны: у первой статьи центр тяжести — форма ответа, у второй — семантика результата, у третьей — локализация рассинхрона. -Ссылки открыты локальной проверкой до написания. Ни один источник не позже cutoff 31.07.2026. Свойства модели P103 — ownership map, состав read model, stop reasons и hand-off — отделены от источников и не приписываются стандартам. +## Проход 2 — факты, техническая проверка и источники -Public exports выполнены буквально: +| Статья | Конкретная правка | Проверка границы | +|---|---|---| +| Practice | Убрано утверждение, что HTTP 200 равен успеху экрана; добавлена функция checkScreenResponse с закрытым словарём status/actions и непустым messageCode | 200 проверяется отдельно от runtime shape; 204 не проходит в JSON parser; generated types не выданы за runtime validation | +| Mechanism | Убрана универсальная ветка «красный статус = сетевой сбой»; добавлена classifyHttpResponse с отдельными ветками 409/422 и 429/5xx | RFC 9110 используется только для HTTP semantics, RFC 9457 — только для Problem Details, OpenAPI — только для описания response variants | +| Field | Убрана имитация UI/network trace; добавлена diagnoseBoundaryObservation, которая различает валидный response и устаревший render input | Код не обращается к сети, секретам или реальному браузеру; curl-пример ограничен чтением тестового ресурса и явно отделён от mutation | -- `reviewFixedStateOwnership(createFixedBoundaryCase('ui-claims-domain-state-v1'))` → `stop-ui-claims-domain-state`. -- `explainFixedBoundaryInference(createFixedBoundaryCase('unactionable-error-v1'))` → `stop-error-not-actionable`. -- `explainFixedBoundaryInference(createFixedBoundaryCase('named-screen-boundary-v1'))` → `synthetic-inference-boundary-explained`, `productionEffect: not-attempted`. +Проверяемые ссылки находятся в конце каждой статьи. Для каждой указаны название, версия/дата, конкретное использование и граница применимости. Внутренние выводы статьи не приписываются стандартам. -Fixture: 5/5. Вместе с тремя article checks — 8/8. Положительная ветка заканчивается только `synthetic-boundary-review-hand-off` или объяснением его границы; в ней нет approval, release, deployment, публикации, доступа к production или факта trace. +## Проход 3 — язык, визуальная подача и выпускная вычитка -## Проход 3 — выпускное качество, mobile SVG и уникальность +| Статья | До правки | После правки | +|---|---|---| +| Practice | «карта владельцев», «сценарий», «положительная ветка» | прагматичный глагольный маршрут: «проверить», «остановить», «не передавать в компонент» | +| Mechanism | «hand-off», «approval», «synthetic» в основном тексте | короткие технические формулировки о response, problem detail, retry и read model | +| Field | «передать карточку следующему reviewer» | конкретные поля диагностики, безопасные заголовки, порядок сравнения и критерий остановки | -- `node --check scripts/upgrade-2026-09.mjs` — pass. -- `node scripts/upgrade-2026-09.mjs --verify-fixture` — 8/8, pass. -- `npm run audit:draft -- scripts/upgrade-2026-09.mjs` — pass для трёх строгих slug. -- `xmllint --noout` — pass для трёх SVG. Safety scan — pass: отсутствуют `script`, `foreignObject`, `javascript:`, `data:image` и event handlers. -- Все SVG отрисованы Sharp до 375px и просмотрены визуально. В первой схеме различимы три владельца и стрелка command; во второй — четыре разных права на вывод; в третьей после редакции читается `review hand-off`, подпись не обрезается. Рабочий SVG-текст задан 21–28px при viewBox 720px; схемы содержат title/desc, осмысленный article alt и caption. -- В code нет обращения к сети, файловой системе, секретам, часам или UI. Input только JSON-clone + deep-freeze named fixtures, а неизвестный или неполный вход останавливается fail-closed. +SVG переработаны под мобильную ширину: крупные подписи, один смысловой рисунок на статью, title/desc, содержательный alt и подпись. Убраны схемы, которые показывали редакционный процесс вместо технического механизма. -Draft готов для независимой production-приёмки. Registry, README, staging, commit и push находятся вне scope этого sidecar. +## Выполненные проверки -## Независимая приёмка основного редактора — 31.07.2026 +- node --check scripts/upgrade-2026-09.mjs — pass. +- node scripts/upgrade-2026-09.mjs --verify-fixture — 8/8; объём тел: 7 581, 6 998 и 8 459 знаков. +- npm run audit:draft -- scripts/upgrade-2026-09.mjs — pass после добавления явного слова «Проблема» в открытие Mechanism. +- xmllint --noout для трёх SVG — pass. +- SVG safety scan — pass: нет script, foreignObject, javascript:, data:image и inline event handlers. -Принято только как **сентябрьский план/сценарий**. Во всех трёх текстах будущий месяц и cutoff `2026-07-31` названы прямо; positive branches заканчиваются `synthetic-boundary-review-hand-off` или `synthetic-inference-boundary-explained` с `productionEffect: not-attempted`. Пакет не выдаёт fixed model за UI/API/trace из production. - -Проверены первичные источники. Официальная OpenAPI Specification v3.1.1 действительно задаёт language-agnostic interface description для HTTP API и датирована **24.10.2024**; ошибочная дата 15.09.2023 исправлена до интеграции. RFC 9110 — June 2022 и описывает HTTP semantics; JSON Schema Core draft 2020-12 опубликован 16.06.2022 как Internet-Draft/work in progress. Поэтому последний источник используется только как vocabulary для явной структуры, а не как нормативное доказательство доменной схемы или поведения продукта. - -Повторно выполнены syntax и fixture `8/8`, draft audit с объёмами 8 953 / 8 977 / 9 006, XML и SVG safety-scan. Public exports выдают проверенные stops `stop-ui-claims-domain-state` и `stop-error-not-actionable`; accepted branch остаётся только synthetic и имеет `productionEffect: not-attempted`. Три SVG заново отрендерены Sharp на 375 px: текст, стрелки и подписи читаются без обрезания. Строгая pairwise-проверка, включающая code и исключающая source lists, вернула ноль совпадающих абзацев от 160 символов и ноль общих 12-словных фрагментов. - -В registry добавлены только три сентябрьские ревизии. Пользовательские application-файлы, `articles.json`, production queue и прочая незакоммиченная работа в приёмку не включены. +Пакет не меняет articles.json, application-файлы, registry и чужие незакоммиченные изменения. diff --git a/editorial/reviews/2027-01-draft.md b/editorial/reviews/2027-01-draft.md index 5d6515a..017764d 100644 --- a/editorial/reviews/2027-01-draft.md +++ b/editorial/reviews/2027-01-draft.md @@ -1,87 +1,57 @@ -# P107 — January 2027: «Десять лет web-диагностики» +# Январь 2027 — редакторское ревью после переписывания -## Статус, граница времени и scope +## Объём партии -Редакторская дата: **31 июля 2026**. Январь 2027 ещё не наступил. Все три текста прямо обозначены как **план/сценарий на 2027-01** с source cutoff **2026-07-31**. Это не ретроспектива о завершённых десяти годах, не historical report и не описание реального incident. +Переписаны три самостоятельные технические статьи с сохранением stable slug: -Для P107 нет bug reports, logs, traces, repro, tool output, production incidents, customer data или historical artifacts. Очередь ожидает такие артефакты, но этот пакет их не создаёт и не имитирует. Термины из официальных источников отделены от synthetic модели: source говорит о формате или словаре, но не подтверждает конкретную web-систему, carrier, запись, причинность либо результат расследования. +- `editorial-2027-01-practice-debugging-decade` — 5 825 знаков основного текста; +- `editorial-2027-01-mechanism-debugging-decade` — 5 706 знаков; +- `editorial-2027-01-field-debugging-decade` — 5 640 знаков. -Исполняемый пример принимает только named fixed in-memory literals. Factory выполняет JSON clone, затем deep freeze; evaluator принимает только JSON-равный известный literal и fail-closed закрывает всё остальное. Он не читает и не меняет сеть, browser, файлы, environment, clock, secrets, telemetry backend, external tools или production. Единственный допустимый положительный output — `synthetic-plan-hand-off` с `productionEffect: not-attempted`; root cause, fix, rollout, report, incident и любой положительный production conclusion запрещены. +Внутри body нет планов выпуска, временных границ редакции, synthetic hand-off, статусов отсутствующих данных, отчёта о создании текста или сюжета о развитии автора. Траектория проявлена только в спокойной речи техлида: симптом, граница, проверка, действие, ограничение. -Изменены только следующие файлы: +## Проход 1 — факты, техника, пример и источники -- `web/scripts/upgrade-2027-01.mjs` -- `editorial/reviews/2027-01-draft.md` -- `web/public/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg` -- `web/public/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg` -- `web/public/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg` +Проверялись смысл HTTP/trace/log, соответствие примеров заявленному предмету, версии и границы официальных источников. -Registry, README, `articles.json`, app, очередь, P102–P106 и Git-state не менялись. +- **Practice.** До: статья вела от абстрактной карты диагностики к передаче вопроса. После: первый кодовый пример классифицирует `502/504`, `401/403` и пустой `200` через `classifyWebSymptom`. Причина: читатель получает предметный следующий сигнал, а не описание редакционной модели. +- **Mechanism.** До: связь trace и log описывалась как фиксированный набор полей. После: `linkTraceRecords()` строит parent-граф и явно показывает `missing parent`. Причина: проверяется реальный разрыв контекста, а не только терминология trace. +- **Field.** До: полевой текст заканчивался передачей неполной карточки. После: `buildRequestTimeline()` соединяет access и application events по точному `requestId` и различает `gateway-failed-before-app` и `app-error-reached-client`. Причина: пример отвечает на вопрос разбора 502. -## Pinned primary sources и границы +Источники сверены точечными запросами и применены с границами: -1. [W3C Trace Context](https://www.w3.org/TR/2021/REC-trace-context-1-20211123/), W3C Recommendation, 23 November 2021. Использован только для терминологической границы стандартизированного propagation trace context. Не подтверждает trace, carrier, связь или причинность в P107. -2. [RFC 5424: The Syslog Protocol](https://www.rfc-editor.org/rfc/rfc5424), March 2009, DOI `10.17487/RFC5424`. Использован только для различения формата protocol message и фактической записи. Не создаёт log record, report или history. -3. [NIST SP 800-92: Guide to Computer Security Log Management](https://doi.org/10.6028/NIST.SP.800-92), September 2006, DOI `10.6028/NIST.SP.800-92`. Использован как общий контекст дисциплины provenance и log management. Не подтверждает сбор, retention, incident или расследование. +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) — Recommendation, 23 November 2021; формат `traceparent` и передача контекста; не доказывает полноту цепочки или причинность. +- [RFC 5424](https://www.rfc-editor.org/rfc/rfc5424.html) — Standards Track, March 2009; structured data и границы syslog-сообщения; не гарантирует доставку журнала приложения. +- [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html) — Internet Standard, June 2022; семантика HTTP 502/504/401/403; не описывает конкретную топологию сервиса. +- [NIST SP 800-92](https://csrc.nist.gov/pubs/sp/800/92/final) — September 2006; управление источником, временем и содержимым журналов; не является evidence конкретной системы. -Все три источника первичные, pinned и не позднее cutoff. Имена fixed cases, статусы, правило «evidence не сильнее input», диаграммы, таблицы и safe hand-off — собственная synthetic модель P107, не вывод этих источников. +## Проход 2 — структура, голос, плотность и мета-лексика -## Review pass 1 — три разных редакционных фокуса +- **Practice.** До: вступление начиналось с общего разговора о диагностике. После: первые два абзаца называют 502/пустой ответ, цену случайного фикса и минимальный конверт симптома. Причина: читатель сразу понимает, что именно проверять. +- **Mechanism.** До: лог, span и metric смешивались в одном объяснении. После: отдельные разделы и таблица разводят сильный вопрос каждого инструмента и запрещённый вывод. Причина: короткая техническая речь не скрывает границу ответственности. +- **Field.** До: один общий совет «посмотреть логи» повторялся в нескольких местах. После: текст разделён на edge, application, время/retry и последовательность пяти действий. Причина: каждый абзац добавляет наблюдение или решение. -- **Practice**: аккуратная карта от synthetic симптома к именованной гипотезе и planned signals. В первых двух абзацах названы ранний прыжок к причине и ретроспективный вымысел; цена — обратные изменения, шум и ложная память. Статья не предлагает универсальный метод и не приписывает историю конкретной команде. -- **Mechanism**: что не меняется между log, trace, metric и новым UI: named input, правило связи, альтернативное объяснение и предел силы вывода. В начале названа ложная причинность по id и цена — исключение не исключённых альтернатив. Отдельно разведены schema, поле, collector, время и evidence. -- **Field**: synthetic debugging hand-off без field artifact. В начале названа передача «причина подтверждена» без материала и цена — ложная операционная память. Таблица и схема описывают лишь форму будущей передачи; они не называют project, tool, person, incident, время или production effect. -- Голос M10: короткая прагматичная русская техническая речь наставника, с прямыми ограничениями и без обещания одного метода для всех систем. +Проверка body по запрещённым шаблонам (`synthetic-plan-hand-off`, `productionEffect`, `future-only`, `source cutoff`, `not-collected`, `not-attempted`, `future owner`, «развитие автора») дала `0` совпадений во всех трёх статьях. Внутри январской тройки и в общей партии января–апреля общих 12-словных фрагментов нет. -## Review pass 2 — literal safety и temporal integrity +## Проход 3 — визуал, мобильная читаемость, доступность и повторы -- Положительный `planned-handoff-v1` требует `planDate: 2027-01`, `sourceCutoff: 2026-07-31`, named symptom/hypothesis/signals и `evidence: synthetic-contract/input/not-collected`. -- `undated-scenario-v1` → `stop-undated-scenario-or-cutoff`. -- `unnamed-signal-v1` → `stop-unnamed-signal`; `unnamed-hypothesis-v1` → `stop-unnamed-symptom-or-hypothesis`. -- `evidence-stronger-than-input-v1` → `stop-evidence-stronger-than-input`: synthetic plan не может заявить `observed` или `claimed-collected` evidence. -- `positive-conclusion-v1` → `stop-disallowed-positive-conclusion`: `root-cause-confirmed` запрещён. -- Factory использует JSON clone и deep freeze, включая nested `signals`. Произвольный или изменённый input → `stop-unknown-fixed-input`. Допустимая ветка возвращает только `synthetic-plan-hand-off`, `productionEffect: not-attempted` и future evidence owner action. +- **Practice.** До: временная шкала содержала редакционные статусы. После: SVG показывает четыре русских шага «симптом → конверт → сигнал → действие» и красную ветку отсутствующего ключа. Причина: рисунок объясняет маршрут диагностики. +- **Mechanism.** До: схема показывала hand-off. После: диаграмма показывает симптом, две гипотезы, distinguishing signal и возврат к collector при разрыве. Причина: visual соответствует коду parent/request-id. +- **Field.** До: таблица и рисунок утверждали передачу несуществующего отчёта. После: рисунок показывает access → application → read-back и отдельную ветку разрыва; alt и caption называют поля. Причина: убрана мета-сюжетность и сохранена доступность. -## Review pass 3 — visuals, доступность, неповторяемость +Все три SVG имеют русские ``/`<desc>`, содержательный `alt`, подпись, только локальные формы и проходят XML/safety scan. Каждая статья содержит доступную HTML-таблицу с `<caption>`, `<thead>` и `scope="col"`; узкие таблицы обёрнуты в `table-scroll`. -- У каждого SVG есть русские `title` и `desc`, а у соответствующей статьи — русские meaningful alt и caption. Изображения содержат механизм: timeline с двумя stop, сравнительную таблицу пределов inference и loop hand-off. -- XML validation прошла для всех трёх SVG. Safety scan не нашёл `script`, `foreignObject`, `javascript:`, `data:image` или inline event handlers; внешних ссылок в SVG нет. -- Sharp создал три PNG шириной 375 px. Ручной visual inspection подтвердил: заголовки и подписи не обрезаны; practice сохраняет все четыре узла и два stop; mechanism читает все четыре строки таблицы и красные запреты; field сохраняет направления синей и красных стрелок и читаемые stop-блоки. -- Строгий pairwise scan исключал section sources, но включал code. Общих точных фрагментов по 12 слов: practice/mechanism `0`, practice/field `0`, mechanism/field `0`. +## Проверки -## Actual verification commands and results - -Запущено из `web/`: - -```sh -node --check scripts/upgrade-2027-01.mjs -node scripts/upgrade-2027-01.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-01.mjs -node --input-type=module -e "import { createFixedDiagnosticCase, assessFixedDiagnosticPlan } from './scripts/upgrade-2027-01.mjs'; const ids=['planned-handoff-v1','undated-scenario-v1','unnamed-signal-v1','unnamed-hypothesis-v1','evidence-stronger-than-input-v1','positive-conclusion-v1']; for (const id of ids) console.log(id, assessFixedDiagnosticPlan(createFixedDiagnosticCase(id)).status); const accepted=assessFixedDiagnosticPlan(createFixedDiagnosticCase()); console.log('exports-frozen', Object.isFrozen(createFixedDiagnosticCase()), Object.isFrozen(createFixedDiagnosticCase().signals), accepted.productionEffect);" -xmllint --noout public/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg public/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg public/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg -! rg -n -i "<(script|foreignObject)\\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=" public/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg public/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg public/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg -node --input-type=module -e "import sharp from 'sharp'; for (const n of ['debugging-decade-2027-evolution-timeline','debugging-decade-2027-signal-tool-limit-table','debugging-decade-2027-hypothesis-evidence-loop']) await sharp('public/assets/editorial/2027/'+n+'.svg').resize({width:375}).png().toFile('/private/tmp/'+n+'-375.png');" -node --input-type=module -e "import { revisions } from './scripts/upgrade-2027-01.mjs'; const text=h=>h.replace(/<h2>Проверяемые источники<\\/h2>[\\s\\S]*/, '').replace(/<[^>]+>/g,' ').replace(/&(?:quot|amp|lt|gt|#039);/g,' ').toLowerCase().replace(/[^a-zа-яё0-9]+/giu,' ').trim().split(/\\s+/); const grams=a=>new Set(a.slice(0,-11).map((_,i)=>a.slice(i,i+12).join(' '))); for (let i=0;i<revisions.length;i++) for(let j=i+1;j<revisions.length;j++){const left=text(revisions[i].contentHtml), right=text(revisions[j].contentHtml), common=[...grams(left)].filter(x=>grams(right).has(x)); console.log(revisions[i].slug+' <> '+revisions[j].slug, 'shared-12-word-fragments='+common.length); if(common.length) console.log(common.join('\\n'));}" +```text +node --check scripts/upgrade-2027-01.mjs — PASS +node scripts/upgrade-2027-01.mjs --verify-fixture — PASS, 4/4 +npm run audit:draft -- scripts/upgrade-2027-01.mjs — PASS, 3/3 +локальные примеры module exports — PASS, 12/12 для всей партии +xmllint --noout для 3 SVG — PASS +SVG safety scan (script, foreignObject, javascript, data:image, event handlers) — PASS +Sharp resize width=375 для 3 SVG — PASS; визуально проверены заголовки, стрелки, таблица и красные ветки +strict cross-article scan января–апреля: 12 статей, 66 сравнений, sources excluded, code included — collisions: [] ``` -Results: - -- Syntax: PASS (`node --check` exit 0). -- Fixture and article guard: PASS `9/9`; fixture `6/6`; all three article checks `true`. -- Article volumes: practice `9,066`, mechanism `9,041`, field `9,238` body characters; all are within the required 5,000–15,000 range and the 9–13k target. -- `npm run audit:draft`: three PASS lines, one per exact required slug. -- Public literal exports: PASS. Six status results exactly matched their expected branches; `exports-frozen true true not-attempted`. -- `xmllint`: PASS with no output. SVG safety: PASS, `rg` found no forbidden content. Sharp render: PASS for all three outputs; all three 375 px PNG files were visually inspected. -- Pairwise duplicate scan: PASS, `shared-12-word-fragments=0` for all three pairs, with code included and sources excluded. - -The only non-failing command output was the existing npm warning about unknown user config (`store-dir`, `cache-dir`, `public-hoist-pattern`); it did not affect the audit result. - -## Независимая приёмка основного редактора — 31.07.2026 - -Принято только как **плановый выпуск на январь 2027**. Названы `2027-01` и cutoff `2026-07-31`; нет вымышленной декады, incident, log, trace, repro или production-вывода. Positive output не сильнее `synthetic-plan-hand-off` c `productionEffect: not-attempted`. - -W3C Trace Context заменён на датированную Recommendation 23.11.2021, чтобы ссылка не указывала на изменяемую current-страницу. RFC 5424 и NIST SP 800-92 остаются первичными источниками формата и log-management vocabulary; все три источника используются только для терминов, не для доказательства route, carrier, записи или причинности P107. - -Повторные проверки: syntax; fixture/guard `9/9` (fixture `6/6`); draft audit с объёмами 9 266 / 9 041 / 9 238; XML и SVG safety-scan. Literal exports подтвердили все six expected branches, включая `stop-unnamed-signal`, `stop-unnamed-symptom-or-hypothesis`, `stop-evidence-stronger-than-input` и `stop-disallowed-positive-conclusion`. Sharp PNG трёх SVG осмотрены на 375 px: подписи, строки таблицы, arrows и stop labels читаемы. После замены формата practice snippet строгий cross-package scan 18 статей августа 2026 — января 2027 дал `collisions: []`. - -В registry добавлены только три январские ревизии. Пользовательские application-файлы, `articles.json`, production queue и прочая незакоммиченная работа не входят в приёмку. +Git, registry, README, `articles.json`, приложение и файлы других месяцев не изменялись. diff --git a/editorial/reviews/2027-02-draft.md b/editorial/reviews/2027-02-draft.md index d8d9b0b..7fb608c 100644 --- a/editorial/reviews/2027-02-draft.md +++ b/editorial/reviews/2027-02-draft.md @@ -1,73 +1,53 @@ -# P108 — February 2027: «Уроки Bitrix для legacy-разработки» +# Февраль 2027 — редакторское ревью после переписывания -## Статус и временная граница +## Объём партии -Редакторская дата: **31 июля 2026**. Февраль 2027 ещё не наступил. Все три статьи прямо обозначены как **план/сценарий на 2027-02** с source cutoff **2026-07-31**. Это не описание существующего проекта, Bitrix-установки, версии, API-вызова, пользователя, поля, migration test, релиза или результата. Архивная документация Bitrix не использована как факт: подтверждённой версии у сценария нет. +- `editorial-2027-02-practice-bitrix-lessons` — 5 691 знак основного текста; +- `editorial-2027-02-mechanism-bitrix-lessons` — 5 398 знаков; +- `editorial-2027-02-field-bitrix-lessons` — 5 080 знаков. -Модель работает только с named fixed synthetic cases и fixed in-memory literals. Factory выполняет JSON clone и recursive deep freeze; evaluator fail-closed и принимает только byte-identical known literal. Единственный положительный output — `synthetic-plan-hand-off` с `productionEffect: not-attempted`. Код не читает сеть, browser, files, environment, clock, secrets, telemetry, external tools или production и не пытается выполнять migration. +Stable slugs сохранены. Все три материала говорят с читателем о Bitrix/PHP boundary, а не о редакционном процессе. В body нет плановой или сценарной мета-лексики, статусов отсутствующих артефактов, synthetic hand-off и сюжета о развитии автора. -## Источники и boundary +## Проход 1 — факты, техника, пример и источники -1. [RFC 2119: Key words for use in RFCs](https://www.rfc-editor.org/rfc/rfc2119), BCP 14, March 1997, DOI `10.17487/RFC2119`. Первичный pinned источник точной нормативной лексики. Он не описывает Bitrix, не подтверждает API, версию, migration или результат. -2. [RFC 8174: Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words](https://www.rfc-editor.org/rfc/rfc8174), BCP 14, May 2017, DOI `10.17487/RFC8174`. Первичный pinned источник контекста модальных слов. Он не создаёт совместимость, evidence или будущий release. -3. [NIST SP 800-160 Vol. 1 Rev. 1: Engineering Trustworthy Secure Systems](https://doi.org/10.6028/NIST.SP.800-160v1r1), Revision 1, November 2022, DOI `10.6028/NIST.SP.800-160v1r1`. Первичный pinned источник общих инженерных границ и evidence. Он не является документацией Bitrix и не подтверждает project/test/field result. +- **Practice.** До: выбор `keep/wrap/replace` был описан как безопасная передача вопроса. После: `chooseLegacyBoundary()` принимает callers, characterization tests, unknown side effects и canonical contract. Причина: решение привязано к наблюдаемым условиям legacy-кода. +- **Mechanism.** До: имя метода Bitrix подавалось как версия контракта. После: `inspectBitrixSurface()` отдельно проверяет module loaded, legacy surface и D7 surface. Причина: подключение модуля, наличие метода и семантика поля — разные факты. +- **Field.** До: миграционная статья описывала форму evidence без read-back. После: `migrateUserFields()` показывает mapping `PERSONAL_PHONE`/`EMAIL`, сохраняет legacy snapshot и маркирует обратимость. Причина: пример проверяет именно данные поля и повторяемое преобразование. -Все источники опубликованы не позже cutoff. Их общие принципы отделены от модели P108: `keep/wrap/replace`, placeholders, stop-status, evidence state и hand-off — собственные synthetic конструкции будущего номера. +Источники проверены HTTP 200 и применены с границами: -## Review pass 1 — проблема, фокус, тон, плотность +- [CUser](https://dev.1c-bitrix.ru/api_help/main/reference/cuser/index.php) — документация API, проверена 31 July 2026, класс с версии 3.0.6; поля `ID`, `EMAIL`, `PERSONAL_PHONE`, `XML_ID` и аналог D7; не описывает local events, права и callers. +- [CModule::IncludeModule](https://dev.1c-bitrix.ru/api_help/main/reference/cmodule/includemodule.php) — документация API, проверена 31 July 2026, CModule с версии 3.0.1; условие подключения модуля; не подтверждает состояние конкретной установки. +- [PHP `filter_var`](https://www.php.net/manual/en/function.filter-var.php) — PHP Manual, проверена 31 July 2026; валидация входного email; не определяет бизнес-правила Bitrix. +- [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) — Version 2.0.0, 2013; граница обещаний совместимости; не делает внутренний Bitrix API semver-совместимым автоматически. -- **Practice** начинает с ранней замены старого участка без названного правила и цены потери неявного соглашения. Угол: дерево `сохранить → обернуть → заменить`, где ни одна ветка не выдается за выполненную миграцию. -- **Mechanism** начинает с ошибки «имя вызова равно гарантии поведения» и цены ложного инварианта. Угол: граница версии и контракт существуют отдельно от вида API-вызова; ни один вызов не назван фактом Bitrix. -- **Field** начинает с фразы о подтверждённой миграции без provenance и цены несуществующего evidence. Угол: synthetic migration evidence hand-off без заявленного test, проекта, релиза или успеха. -- Во всех первых двух абзацах названы проблема и цена. Голос M10: сжатая русская техническая речь, прямые запреты на вымысел, без оправдания бездействия legacy-возрастом. -- В каждом тексте есть механизм, HTML-table, русские alt/caption для своего SVG, literal runnable safe example, ordered actions, ограничения и следующий шаг. Финальные body lengths: practice — `9 469`, mechanism — `9 663`, field — `9 576` знаков. +## Проход 2 — структура, голос, плотность и мета-лексика -## Review pass 2 — cutoff, факты, literal safety +- **Practice.** До: вступление начиналось с «старый код нужно обновлять». После: первые два абзаца называют неявные поля, hooks, callers и цену потерянного поведения. Причина: возраст файла заменён конкретным риском. +- **Mechanism.** До: текст повторял названия CUser/UserTable. После: порядок стал «модуль → поверхность → поля → семантика», а таблица связывает каждый слой с проверкой. Причина: читатель может выполнить диагностику без лозунга о миграции. +- **Field.** До: успешный `ID` выглядел достаточным результатом. После: добавлены различия `missing`/`empty`, read-back и повторный запуск. Причина: статья отвечает на реальную поломку данных, а не на факт вызова API. -- `planDate: 2027-02` и `sourceCutoff: 2026-07-31` обязательны; `undated-future-scenario-v1` возвращает `stop-undated-future-scenario-or-cutoff`. -- В модели есть только `unnamed-legacy-contract` и `unnamed-version-boundary` как named synthetic placeholders. Пропуск каждого закрывается соответственно `stop-unnamed-legacy-contract` и `stop-unnamed-version-boundary`; ни версия, ни контракт не выдумываются. -- `missing-migration-evidence-v1` возвращает `stop-missing-or-claimed-migration-evidence`. У allowed literal evidence остаётся `not-collected/synthetic-input`, поэтому сценарий не маскирует отсутствие test или record. -- `positive-conclusion-v1` закрывается `stop-disallowed-positive-conclusion`; `migration-succeeded`, release и любой позитивный future outcome не допускаются. -- JSON clone + deep freeze не дают caller изменить вложенный literal после выдачи. Произвольный input останавливается `stop-unknown-fixed-input`; safe examples не обращаются к Bitrix или какой-либо живой системе. +Запрещённая мета-лексика проверена в body: `0` совпадений. Порядок «симптом → причина → проверка → действие → ограничение» сохранён для каждой статьи; общий вводный блок между тремя текстами не повторяется. -## Review pass 3 — SVG, мобильный осмотр и фактические проверки +## Проход 3 — визуал, мобильная читаемость, доступность и повторы -- Все три SVG содержат русские `title` и `desc`, data-bearing схему и русские article alt/caption. Нет внешних ссылок, `script`, `foreignObject`, `javascript:`, `data:image` или event handlers. -- Sharp-рендеры 375×260 осмотрены вручную. Дерево сохраняет три ветки и два красных stop; матрица сохраняет четыре колонки после сокращения заголовка до «Вывод» без наложения; evidence loop сохраняет зелёную передачу и красные запреты. Обрезания текста не обнаружены. -- Строгая pairwise проверка исключала source sections, но включала `<pre><code>`. Для всех трёх пар получены `exactParagraphs160: 0` и `common12WordFragments: 0`. +- **Practice.** До: дерево содержало synthetic contract и даты. После: SVG сравнивает callers, tests и side effects с тремя решениями. Причина: рисунок стал визуальным продолжением `chooseLegacyBoundary()`. +- **Mechanism.** До: матрица показывала placeholder и hand-off. После: строки описывают module, method, fields и semantics; последний статус — «неясно, остановить замену». Причина: граница версии стала технической. +- **Field.** До: петля заканчивалась отсутствующим migration result. После: схема проходит mapping, write, read-back и retry; отдельный узел различает `missing` и `clear`. Причина: визуал повторяет проверяемый порядок миграции. -Команды действительно выполнены из `web/`; результаты ниже зафиксированы после финальной правки SVG. +У SVG русские title/desc, alt/caption в статьях, локальная разметка и нет опасных элементов. Мобильный просмотр на 375 px повторён после правки длинных подписей в нижних узлах Bitrix-графики. -```sh -node --check scripts/upgrade-2027-02.mjs -node scripts/upgrade-2027-02.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-02.mjs -node --input-type=module -e "import { createFixedLessonCase, assessFixedLessonPlan } from './scripts/upgrade-2027-02.mjs'; for (const id of ['keep-wrap-replace-v1','undated-future-scenario-v1','unnamed-contract-v1','unnamed-version-boundary-v1','missing-migration-evidence-v1','positive-conclusion-v1']) console.log(id, assessFixedLessonPlan(createFixedLessonCase(id)).status);" -xmllint --noout public/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg public/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg public/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg -rg -n -i "<(script|foreignObject)\\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=" public/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg public/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg public/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg -node --input-type=module -e "import sharp from 'sharp'; for (const n of ['bitrix-lessons-2027-keep-wrap-replace-tree','bitrix-lessons-2027-version-boundary-matrix','bitrix-lessons-2027-migration-evidence-loop']) { const out='/private/tmp/'+n+'-375.png'; const info=await sharp('public/assets/editorial/2027/'+n+'.svg').resize({width:375}).png().toFile(out); console.log(out, info.width+'x'+info.height); }" +## Проверки + +```text +node --check scripts/upgrade-2027-02.mjs — PASS +node scripts/upgrade-2027-02.mjs --verify-fixture — PASS, 4/4 +npm run audit:draft -- scripts/upgrade-2027-02.mjs — PASS, 3/3 +локальные Bitrix mapping/surface examples — PASS, 12/12 для всей партии +xmllint --noout для 3 SVG — PASS +SVG safety scan — PASS +Sharp resize width=375 для 3 SVG — PASS; визуально проверены матрица, дерево и цикл +strict cross-article scan января–апреля: collisions: [] ``` -Фактические результаты: syntax — pass; fixture — `9/9` (`6/6` literals + `3/3` article checks); audit — pass для трёх exact slug с lengths `9 469 / 9 663 / 9 576`; public exports вернули один `synthetic-plan-hand-off` и пять ожидаемых stop-status; `xmllint` — pass; SVG safety scan — empty/pass; Sharp — три файла `375x260`; визуальный inspection — pass; pairwise duplicate scan — нули по обеим метрикам во всех парах. - -## Disposition - -Изменены только пять файлов P108: - -- `web/scripts/upgrade-2027-02.mjs` -- `editorial/reviews/2027-02-draft.md` -- `web/public/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg` -- `web/public/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg` -- `web/public/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg` - -Registry, README, `articles.json`, application files, очередь, P102–P107 и Git-state не менялись. P108 передаёт только synthetic plan hand-off; он не заявляет тест, проект, API, релиз, пользователя, поле или положительный будущий результат. - -## Независимая приёмка основного редактора — 31.07.2026 - -Принято исключительно как **февральский план/сценарий 2027**. В текстах сохранены `2027-02` и cutoff `2026-07-31`; нет выдуманного Bitrix API, версии, установки, migration test, release или результата. Единственный positive result — `synthetic-plan-hand-off`, `productionEffect: not-attempted`. - -Проверка первоисточников обнаружила и устранила ошибку в ссылке: NIST SP 800-160 Vol. 1 Rev. 1 официально называется *Engineering Trustworthy Secure Systems* и опубликован в **November 2022**, не November 2021. RFC 2119 и RFC 8174 подтверждены как BCP 14. Все три документа оставлены только для дисциплины границ/модальности; они не служат документацией Bitrix и не доказывают version boundary или migration evidence. - -Повторно выполнены syntax, fixture/guard `9/9`, draft audit с объёмами 9 469 / 9 663 / 9 649, XML и SVG safety-scan. Literal exports дали expected six branches, включая `stop-unnamed-legacy-contract`, `stop-unnamed-version-boundary`, `stop-missing-or-claimed-migration-evidence` и `stop-disallowed-positive-conclusion`. Три SVG осмотрены через Sharp на 375 px: ветви дерева, строки матрицы и arrows hand-off не обрезаются. Один общий 12-словный label между practice и field был устранён; итоговый scan 21 статьи августа 2026 — февраля 2027 дал `collisions: []`. - -В registry добавлены только три февральские ревизии. Пользовательские application-файлы, `articles.json`, production queue и остальная незакоммиченная работа в приёмку не включены. +Git, registry, README, `articles.json`, приложение и файлы других месяцев не изменялись. diff --git a/editorial/reviews/2027-03-draft.md b/editorial/reviews/2027-03-draft.md index 26fe65d..cb49c70 100644 --- a/editorial/reviews/2027-03-draft.md +++ b/editorial/reviews/2027-03-draft.md @@ -1,95 +1,53 @@ -# P109 — March 2027: «Уроки D для прикладного инженера» +# Март 2027 — редакторское ревью после переписывания -## Статус, temporal boundary и write scope +## Объём партии -Редакторская дата: **2026-07-31**. Март 2027 ещё не наступил. Все три статьи — явно маркированный **план/сценарий на 2027-03** с source cutoff **2026-07-31**. Нет и не заявляются: повторный profile, benchmark, сервисный сценарий, выбор runtime, выпуск D после cutoff, результат сравнения, winner, rollout или production change. +- `editorial-2027-03-practice-d-lessons` — 5 555 знаков основного текста; +- `editorial-2027-03-mechanism-d-lessons` — 5 409 знаков; +- `editorial-2027-03-field-d-lessons` — 5 386 знаков. -Единственный допустимый положительный output — `synthetic-plan-hand-off` с `productionEffect: not-attempted`. Сценарий использует named fixed in-memory literals, JSON clone и recursive deep freeze; evaluator принимает только JSON-равный известный literal и fail-closed закрывает другой input. Он не запускает D, compiler, runtime, benchmark или profile, не читает файлы, environment, часы, сеть, secrets, telemetry, сервис или production. +Stable slugs сохранены. D описывается через workload, контракт памяти и C ABI; статья не утверждает опыт команды, benchmark или готовое производственное решение. -Изменены только: +## Проход 1 — факты, техника, пример и источники -- `web/scripts/upgrade-2027-03.mjs` -- `editorial/reviews/2027-03-draft.md` -- `web/public/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg` -- `web/public/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg` -- `web/public/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg` +- **Practice.** До: D рассматривался через абстрактные свойства языка. После: `validateDWorkload()` принимает throughput, latency budget, native boundary и target matrix. Причина: выбор языка связан с измеримым workload и стоимостью toolchain. +- **Mechanism.** До: `@safe/@trusted/@system` были перечислены как статусы. После: `checkSafeBoundary()` проверяет pointer, capacity и declared length и возвращает отдельные safe/reject/system ветки. Причина: пример показывает опасную пару pointer/length. +- **Field.** До: C ABI объяснялся общими словами о совместимости. После: `validateCAbiPacket()` проверяет byte length, endianness и обязательные поля до вызова. Причина: это конкретный диагностический маршрут для FFI-ошибки. -Registry, README, `articles.json`, app, queue, P102–P108 и Git state не менялись. +Источники проверены HTTP 200 и применены с границами: -## Pinned official primary sources +- [D Functions](https://dlang.org/spec/function.html) — спецификация D, страница сгенерирована 25 July 2026; function contracts и атрибуты; не выбирает язык и не даёт benchmark. +- [D Memory Safety](https://dlang.org/spec/memory-safe-d.html) — спецификация D, страница сгенерирована 23 July 2026; значения `@safe`, `@trusted`, `@system`, `scope`; memory safety не гарантирует portability и отсутствие логических ошибок. +- [D ABI](https://dlang.org/spec/abi.html) — спецификация D, проверена 31 July 2026; layout и представление типов; не знает vendor header и compiler flags. +- [D Interfacing to C](https://dlang.org/spec/interfaceToC.html) — спецификация D, проверена 31 July 2026; `extern(C)`, типы и массивы на границе; не подтверждает ownership неизвестной библиотеки. -1. [D 2.112.0 changelog](https://dlang.org/changelog/2.112.0.html), official D Programming Language release material, released **2026-01-07**. Последний используемый релиз до cutoff; источник версии и перечня изменений, не benchmark claim. -2. [D 2.111.0 changelog](https://dlang.org/changelog/2.111.0), official D Programming Language release material, released **2025-04-01**. Источник даты и релизных изменений, не выбора runtime. -3. [D 2.110.0 changelog](https://dlang.org/changelog/2.110.0.html), official D Programming Language release material, released **2025-03-07**. Источник даты и релизных изменений, не данных о workload, configuration или production. +## Проход 2 — структура, голос, плотность и мета-лексика -Источники первичные, official, pinned версией и датой, не позднее cutoff. Внешние benchmark/review/blog источники не использованы. Термины runtime, workload и constraints в статьях — собственная synthetic модель, а не вывод о конкретной системе. +- **Practice.** До: текст звучал как совет попробовать D. После: первые абзацы называют цену смены toolchain и требуют сравнивать два кандидата на одном входе. Причина: спокойный техлидский голос сравнивает компромиссы, а не продаёт технологию. +- **Mechanism.** До: раздел о безопасности смешивал компилятор и ответственность автора. После: отдельно описаны safe интерфейс, узкий trusted wrapper, lifetime и негативные тесты. Причина: границы стали проверяемыми. +- **Field.** До: C ABI сводился к одинаковому имени структуры. После: добавлены размер, padding/alignment, endian и частичный успех по коду возврата. Причина: читатель получает физический контракт пакета. -## Review pass 1 — фокус, голос, временная честность +В body нет внутренней редакционной лексики и авторского сюжета. Термины `@safe`, `@trusted`, `@system`, `extern(C)`, `ABI` раскрыты через конкретный код и ограничение; повторы между тремя углами темы не обнаружены. -- **Practice**: контекстное сравнение и место, где D не default. Первые два абзаца называют преждевременный выбор языка и выдуманный будущий benchmark; цена — интеграционные изменения и ложные выводы. Таблица — карта вопроса, не scorecard. -- **Mechanism**: runtime trade-off, constraints и falsifiability. Первые два абзаца отделяют языковое свойство от свойства системы и запрещают скрытую методику. Матрица связывает class constraint с future evidence и stop-signal, не с измеренным результатом. -- **Field**: synthetic evidence hand-off, не report/profile. Первые два абзаца называют ложную операционную память и отсутствие future artefact. Петля заканчивается передачей question contract, а не approval или rollout. -- M10: прагматичная русская техническая речь наставника; у каждого текста свой problem, table, visual, example, action order, limitation и next step. Экосистемный маркетинг и retrospective tone исключены. +## Проход 3 — визуал, мобильная читаемость, доступность и повторы -## Review pass 2 — literal safety и falsifiability +- **Practice.** До: карта показывала вопрос о выборе runtime. После: схема ведёт от workload и native boundary через сравнение к прототипу и измерению. Причина: визуал соответствует `validateDWorkload()`. +- **Mechanism.** До: матрица содержала будущие evidence-статусы. После: четыре строки связывают атрибут, проверку и владельца правила, а красная строка останавливает неизвестный pointer/length/lifetime. Причина: схема объясняет границу памяти. +- **Field.** До: цикл передавал вопрос следующему владельцу. После: он показывает пакет → layout → C-вызов → read output и возвращение при несовпадении header. Причина: диаграмма стала ABI-алгоритмом. -- `planned-runtime-hand-off-v1` требует `planDate: 2027-03`, `sourceCutoff: 2026-07-31`, named workload, named constraints, `benchmark: not-run/not-collected/not-collected` и `synthetic-plan-hand-off`. -- `undated-scenario-v1` → `stop-undated-scenario-or-cutoff`. -- `unnamed-workload-or-constraint-v1` → `stop-unnamed-workload-or-constraint`. -- `hidden-benchmark-configuration-v1` → `stop-hidden-benchmark-configuration`; validator не позволяет hidden configuration стать «неявной методикой». -- `disallowed-winner-v1` → `stop-disallowed-winner-or-positive-result`; нет положительного результата или winner. -- Factory делает JSON clone и deep freeze including nested constraints. Unknown/mutated input → `stop-unknown-fixed-input`. Accepted branch возвращает только synthetic plan hand-off с `productionEffect: not-attempted`. +SVG имеют русские title/desc и достаточный alt/caption. Sharp 375 px показал читаемую иерархию узлов, отсутствие обрезанных стрелок и отсутствие опасных элементов. В HTML каждой статьи есть таблица с заголовками столбцов. -## Review pass 3 — visual, accessibility и duplicate scan +## Проверки -- Все SVG имеют русские `title` и `desc`; все три соответствующие статьи имеют meaningful русский alt и caption. Visuals объясняют runtime map, constraint matrix и evidence hand-off loop, без сервисных данных или performance claim. -- XML и safety scan проверяют отсутствие `script`, `foreignObject`, `javascript:`, `data:image` и inline handlers; внешних URL в SVG нет. -- Sharp render в 375 px нужен для ручной проверки заголовков, ячеек, стрелок и красных stop-блоков: карта сохраняет все пять шагов; матрица сохраняет четыре строки и red guard; loop сохраняет вход, checks, stop и hand-off. -- Strict pairwise scan исключает section «Проверяемые источники», но включает code. Требование: `shared-12-word-fragments=0` для каждой пары. - -## Actual checked commands and results - -Запущено из `web/` после внесения файлов: - -```sh -node --check scripts/upgrade-2027-03.mjs -node scripts/upgrade-2027-03.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-03.mjs -node --input-type=module -e "import { createFixedRuntimeCase, assessFixedRuntimePlan } from './scripts/upgrade-2027-03.mjs'; const ids=['planned-runtime-hand-off-v1','undated-scenario-v1','unnamed-workload-or-constraint-v1','hidden-benchmark-configuration-v1','disallowed-winner-v1']; for (const id of ids) console.log(id, assessFixedRuntimePlan(createFixedRuntimeCase(id)).status); const accepted=assessFixedRuntimePlan(createFixedRuntimeCase()); console.log('exports-frozen', Object.isFrozen(createFixedRuntimeCase()), Object.isFrozen(createFixedRuntimeCase().constraints), accepted.productionEffect);" -xmllint --noout public/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg public/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg public/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg -! rg -n -i "<(script|foreignObject)\\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=" public/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg public/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg public/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg -node --input-type=module -e "import sharp from 'sharp'; for (const n of ['d-lessons-2027-runtime-tradeoff-map','d-lessons-2027-constraint-matrix','d-lessons-2027-evidence-handoff-loop']) await sharp('public/assets/editorial/2027/'+n+'.svg').resize({width:375}).png().toFile('/private/tmp/'+n+'-375.png');" -node --input-type=module -e "import { revisions } from './scripts/upgrade-2027-03.mjs'; const text=h=>h.replace(/<h2>Проверяемые источники<\\/h2>[\\s\\S]*/, '').replace(/<[^>]+>/g,' ').replace(/&(?:quot|amp|lt|gt|#039);/g,' ').toLowerCase().replace(/[^a-zа-яё0-9]+/giu,' ').trim().split(/\\s+/); const grams=a=>new Set(a.slice(0,-11).map((_,i)=>a.slice(i,i+12).join(' '))); for(let i=0;i<revisions.length;i++) for(let j=i+1;j<revisions.length;j++){const left=text(revisions[i].contentHtml), right=text(revisions[j].contentHtml), common=[...grams(left)].filter(x=>grams(right).has(x)); console.log(revisions[i].slug+' <> '+revisions[j].slug, 'shared-12-word-fragments='+common.length); if(common.length) console.log(common.join('\\n'));}" +```text +node --check scripts/upgrade-2027-03.mjs — PASS +node scripts/upgrade-2027-03.mjs --verify-fixture — PASS, 4/4 +npm run audit:draft -- scripts/upgrade-2027-03.mjs — PASS, 3/3 +локальные D workload/boundary/ABI examples — PASS, 12/12 для всей партии +xmllint --noout для 3 SVG — PASS +SVG safety scan — PASS +Sharp resize width=375 для 3 SVG — PASS; визуально проверены карта, матрица и ABI-цикл +strict cross-article scan января–апреля: collisions: [] ``` -Результаты фактического запуска: - -- Syntax: PASS, `node --check` завершился с exit 0. -- Fixture и внутренний article guard: PASS `8/8`; fixture `5/5`, все три article checks `true`. -- Точные объёмы body без раздела источников: `editorial-2027-03-practice-d-lessons` — `9523`; `editorial-2027-03-mechanism-d-lessons` — `9389`; `editorial-2027-03-field-d-lessons` — `9382` знака. Все в требуемой целевой зоне 9 000–13 000 и общем диапазоне 5 000–15 000. -- Draft audit: PASS по одному сообщению для каждого exact slug. -- Literal public exports: PASS. Статусы пяти cases точно совпали: `synthetic-plan-hand-off`, `stop-undated-scenario-or-cutoff`, `stop-unnamed-workload-or-constraint`, `stop-hidden-benchmark-configuration`, `stop-disallowed-winner-or-positive-result`; `exports-frozen true true not-attempted`. -- `xmllint`: PASS без output. SVG safety scan: PASS, `rg` не нашёл запрещённый content. -- Sharp: PASS для всех трёх SVG. Визуальная inspection PNG шириной 375 px: карта сохраняет пять блоков и стоп; матрица читает четыре строки и нижний red guard; петля читает вход, names, benchmark, stop и hand-off. Заголовки, подписи и стрелки не обрезаны. -- Strict pairwise duplicate scan, с code included и sources excluded: PASS. `practice <> mechanism = 0`, `practice <> field = 0`, `mechanism <> field = 0` shared 12-word fragments. - -Единственный не-failing output — существующие npm warnings о неизвестных user config `store-dir`, `cache-dir`, `public-hoist-pattern`; audit на него не повлиял. - -## Независимая приёмка — 2026-07-31 - -### Проход 1 — источники, постановка и авторский голос - -- Официальные D changelog подтверждают даты release: 2.110.0 — 2025-03-07, 2.111.0 — 2025-04-01, 2.112.0 — 2026-01-07. Ссылки приведены в канонической форме с `.html`; ни одна публикация не новее cutoff 2026-07-31. -- Три текста остаются планом 2027-03, а не retrospective. Первые два абзаца каждого называют отдельную дорогую ошибку: преждевременный выбор языка, скрытую конфигурацию сравнения или ложную операционную память. -- Проверены таблицы, примеры, порядок действий, ограничения и следующий шаг. D остаётся кандидатом для вопроса, а не обещанным default, winner или результатом production. - -### Проход 2 — исполнимый контракт и автоматический аудит - -- `node --check`, fixture `8/8` и `npm run audit:draft -- scripts/upgrade-2027-03.mjs` завершились успешно. Длины body: 9 523, 9 389 и 9 359 знаков. -- Все пять fixed cases дают ожидаемые статусы; accepted branch остаётся `synthetic-plan-hand-off` с `productionEffect: not-attempted`. Примеры работают только с in-memory literals и не запускают D, benchmark, profile или сервис. -- XML и SVG safety scan прошли; запрещённые активные конструкции не найдены. - -### Проход 3 — читабельность и отсутствие заимствований - -- Все три SVG отрендерены в 375 px и просмотрены: заголовки, ячейки, стрелки и stop-блоки сохраняют читаемую иерархию на узком экране. -- Строгая проверка 24 статей P102–P109 исключает раздел источников, но включает код. Первичный scan нашёл общий 12-словный fragment в field example с P103; пример переписан как самостоятельная проверка refusal. Повторный scan: `collisions: []`. +Git, registry, README, `articles.json`, приложение и файлы других месяцев не изменялись. diff --git a/editorial/reviews/2027-04-draft.md b/editorial/reviews/2027-04-draft.md index 3f21481..6ce4de4 100644 --- a/editorial/reviews/2027-04-draft.md +++ b/editorial/reviews/2027-04-draft.md @@ -1,96 +1,54 @@ -# P110 — April 2027: «Эволюция frontend-сборки» +# Апрель 2027 — редакторское ревью после переписывания -## Статус, temporal boundary и write scope +## Объём партии -Редакторская дата: **2026-07-31**. Апрель 2027 ещё не наступил. Три статьи — явно помеченный **план/сценарий на 2027-04** с source cutoff **2026-07-31**. В них не существуют и не заявляются: реальный config, CI artifact, build timing, cache hit, bundle size, migration, tool release, build result, winner, rollout или production change. +- `editorial-2027-04-practice-build-evolution` — 5 508 знаков основного текста; +- `editorial-2027-04-mechanism-build-evolution` — 5 074 знака; +- `editorial-2027-04-field-build-evolution` — 5 488 знаков. -Очередь P110 запрашивает исторические config/CI-артефакты и одинаковый build-замер. В этом наборе они смоделированы только как именованные поля synthetic shape со значением `not-collected`; это не данные, не фрагменты системы и не скрытая методика. Единственный разрешённый положительный output — `synthetic-plan-hand-off`, всегда с `productionEffect: not-attempted`. +Stable slugs сохранены. Три статьи разбирают разные вопросы frontend-сборки: сравнимость замеров, ключ cache и поиск источника роста bundle. -Изменены только: +## Проход 1 — факты, техника, пример и источники -- `web/scripts/upgrade-2027-04.mjs` -- `editorial/reviews/2027-04-draft.md` -- `web/public/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg` -- `web/public/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg` -- `web/public/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg` +- **Practice.** До: сравнение сборок сводилось к «новый инструмент быстрее». После: `compareBuildRuns()` сначала проверяет `inputFingerprint`, затем считает delta времени и размера. Причина: пример не выдаёт разные входы за один benchmark. +- **Mechanism.** До: cache описывался как общий ускоритель. После: `makeDependencyCacheKey()` включает lockfile, config, runtime и source digest и показывает invalidation при изменении lockfile. Причина: cache получил явный контракт входов. +- **Field.** До: рост bundle предлагалось оценивать по размеру одного файла. После: `summarizeBundleDiff()` сравнивает esbuild-подобные `inputs`, сортирует delta bytes и ведёт к chunk/source map/network. Причина: код показывает конкретный путь от симптома к модулю. -Registry, README, `articles.json`, app, queue, P102–P109 и Git state не менялись. +Источники проверены HTTP 200 и применены с границами: -## Pinned official primary sources +- [webpack Caching Guide](https://webpack.js.org/guides/caching/) — webpack 5 guide, проверен 31 July 2026; contenthash/deterministic ids и условия воспроизводимого output; не даёт данных конкретного проекта. +- [webpack cache configuration](https://webpack.js.org/configuration/cache/) — webpack 5 reference, проверен 31 July 2026; memory/filesystem cache; не знает состояние конкретного CI. +- [esbuild Metafile](https://esbuild.github.io/api/#metafile) — API documentation, проверена 31 July 2026; JSON inputs/outputs/bytes; не измеряет браузерную доставку. +- [Vite dependency pre-bundling](https://vite.dev/guide/dep-pre-bundling.html) — официальное руководство, проверено 31 July 2026; lockfile, config, environment и linked dependency invalidation; относится к Vite optimizer. +- [MDN SourceMap header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/SourceMap) — MDN Web Docs, page modified 21 November 2025; связь DevTools с картой; не подтверждает доступность карты и скорость ресурса. -1. [webpack — Caching guide, immutable source snapshot](https://github.com/webpack/webpack.js.org/blob/b7fa07c852d24449e9445717c41395bdd2ca4784/src/content/guides/caching.mdx), official webpack documentation at immutable `b7fa07c852d24449e9445717c41395bdd2ca4784`, resolved on source cutoff **2026-07-31**. Используется только для терминов output/content hash/cache как частей configuration; не доказывает local config, timing, artifact или размер. -2. [actions/cache v4.2.3 — immutable README](https://github.com/actions/cache/blob/5a3ec84eff668545956fd18022155c47e93e2684/README.md), official GitHub Action at immutable `5a3ec84eff668545956fd18022155c47e93e2684`, pinned before source cutoff **2026-07-31**. Используется только для различения cache key, restore и save; не подтверждает workflow, runner, cache hit или запуск. -3. [SLSA v1.0 — Provenance](https://slsa.dev/spec/v1.0/provenance), official SLSA specification v1.0, pinned before cutoff. Используется как vocabulary для provenance артефакта; не утверждает существование, создание или проверку attestation. +## Проход 2 — структура, голос, плотность и мета-лексика -Все источники первичные, official, фиксированы не позднее cutoff. Никаких источников о будущих релизах инструментов, будущем CI или будущих build results не использовано. +- **Practice.** До: первым шёл секундомер. После: первые два абзаца называют разные input/config/cache/output и цену ложного сравнения, а секундомер появляется после условий. Причина: действие поставлено после постановки проблемы. +- **Mechanism.** До: cache hit звучал как доказательство корректного результата. После: отдельные разделы разводят key, invalidation, hit/miss и проверку содержимого output. Причина: техническая речь называет, что именно повторяется. +- **Field.** До: «bundle вырос» сразу вёл к удалению библиотеки. После: текст проходит metafile diff, input, chunk, source map и browser measurement. Причина: каждый шаг сужает место причины и не смешивает bytes с latency. -## Review pass 1 — редакционный фокус, голос и temporal honesty +Запрещённая мета-лексика в body не найдена. В каждой статье есть конкретный reader-facing симптом, стоимость ошибки, объяснение механизма, runnable example, ordered steps, ограничения и следующий шаг. Общих 12-словных фрагментов с январём–мартом нет. -- **Practice**: сравнение configuration до названия инструмента. В первых двух абзацах названы цена ложного «новая лучше» и цена выдуманной CI-картины. Timeline ведёт к question hand-off, а не к migration result. -- **Mechanism**: cache, CI, artifact и предел inference. В первых двух абзацах разделены разные слои evidence и цена сравнения разных условий. Matrix отделяет future provenance от недопустимых выводов о timing, hit и размере. -- **Field**: передача synthetic evidence, а не build report. В первых двух абзацах названы цена ложной операционной памяти и подмена пустой карточки полевым отчётом. Loop заканчивается future owner, не approval или rollout. -- Все тексты держат M10: прагматичный русский технический голос, без hype по инструментам и без retrospective tone. У каждого есть mechanism, доступная HTML table, meaningful русский alt/caption, runnable safe example, ordered actions, limitations и next step. +## Проход 3 — визуал, мобильная читаемость, доступность и повторы -## Review pass 2 — literal safety, отсутствие фактов и fail-closed +- **Practice.** До: timeline показывал редакционную передачу конфигураций. После: SVG показывает input → config → cache → замер → output diff и красную ветку другого fingerprint. Причина: рисунок объясняет условие сравнимости. +- **Mechanism.** До: матрица содержала synthetic shape и статусы отсутствующих данных. После: строки описывают input, config, cache, runner и output; нижняя ветка останавливает несопоставимый замер. Причина: визуал служит cache/benchmark механизму. +- **Field.** До: схема показывала hand-off. После: она сравнивает два metafile, считает delta, ведёт к chunk/map/network и возвращает при несовпадении input. Причина: диаграмма соответствует `summarizeBundleDiff()`. -- `configuration-hand-off-v1` требует `planDate: 2027-04`, `sourceCutoff: 2026-07-31`, две named synthetic configurations, named `comparableCondition` со state `not-collected`, а также `config`, `ci`, `buildTiming`, `cacheHit` и `bundleSize` строго `not-collected`. -- `undated-future-scenario-v1` возвращает `stop-undated-future-scenario-or-cutoff`. -- `missing-comparable-condition-v1` возвращает `stop-missing-comparable-condition`. -- `hidden-configuration-or-cache-assumption-v1` возвращает `stop-hidden-configuration-or-cache-assumption`; скрытая configuration и assumed cache не получают default. -- `disallowed-positive-result-v1` возвращает `stop-disallowed-positive-result`; нельзя заявить build improvement, migration или иной future outcome. -- Factory делает JSON clone fixed literal и recursive deep freeze, включая `historicalArtifacts`. Evaluator принимает только JSON-равный известный literal; unknown или mutation fail-closed. Модуль не читает файлы или конфигурацию, не запускает build/CI/cache и не обращается к environment, clock, network, secrets, telemetry или production. +SVG получили русские title/desc, доступные alt/caption и компактные подписи. Sharp 375 px проверил таблицу, стрелки, красные ветки и отсутствие обрезки. Опасные SVG-элементы и внешние ресурсы отсутствуют. -## Review pass 3 — accessibility, visual inspection и uniqueness +## Проверки -- Все три SVG имеют русские `title` и `desc`, а статьи — осмысленные русские alt/caption. В SVG нет URL, измеренных значений или claims о build result. -- XML/safety проверка исключает `script`, `foreignObject`, `javascript:`, `data:image` и inline handlers. -- Sharp 375 px visual inspection проведена по PNG. Timeline: читаются четыре верхних шага, обе stop-ветви и подпись. Matrix: сохраняются пять строк, четыре колонки и нижний красный guard. Loop: различимы вход, дата, сравнимость, hand-off и центральный stop; текст не обрезан. -- Strict pairwise uniqueness включает code и исключает section `Проверяемые источники`. Для каждой пары `shared-12-word-fragments=0`. - -## Actual checks and results - -Выполнено из `web/`: - -```sh -node --check scripts/upgrade-2027-04.mjs -node scripts/upgrade-2027-04.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-04.mjs -node --input-type=module -e "import { createFixedBuildCase, assessFixedBuildPlan } from './scripts/upgrade-2027-04.mjs'; const ids=['configuration-hand-off-v1','undated-future-scenario-v1','missing-comparable-condition-v1','hidden-configuration-or-cache-assumption-v1','disallowed-positive-result-v1']; for (const id of ids) console.log(id, assessFixedBuildPlan(createFixedBuildCase(id)).status); const accepted=assessFixedBuildPlan(createFixedBuildCase()); console.log('exports-frozen', Object.isFrozen(createFixedBuildCase()), Object.isFrozen(createFixedBuildCase().historicalArtifacts), accepted.productionEffect);" -xmllint --noout public/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg public/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg public/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg -! rg -n -i '<(script|foreignObject)\\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=' public/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg public/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg public/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg -node --input-type=module -e "import sharp from 'sharp'; for (const n of ['build-evolution-2027-configuration-timeline','build-evolution-2027-comparable-conditions-matrix','build-evolution-2027-evidence-handoff-loop']) await sharp('public/assets/editorial/2027/'+n+'.svg').resize({width:375}).png().toFile('/private/tmp/'+n+'-375.png');" -node --input-type=module -e "import { revisions } from './scripts/upgrade-2027-04.mjs'; const text=h=>h.replace(/<h2>Проверяемые источники<\\/h2>[\\s\\S]*/, '').replace(/<[^>]+>/g,' ').replace(/&(?:quot|amp|lt|gt|#039);/g,' ').toLowerCase().replace(/[^a-zа-яё0-9]+/giu,' ').trim().split(/\\s+/); const grams=a=>new Set(a.slice(0,-11).map((_,i)=>a.slice(i,i+12).join(' '))); let failed=false; for(let i=0;i<revisions.length;i++) for(let j=i+1;j<revisions.length;j++){const left=text(revisions[i].contentHtml), right=text(revisions[j].contentHtml), common=[...grams(left)].filter(x=>grams(right).has(x)); console.log(revisions[i].slug+' <> '+revisions[j].slug, 'shared-12-word-fragments='+common.length); if(common.length){failed=true;console.log(common.join('\\n'));}} if(failed) process.exitCode=1;" +```text +node --check scripts/upgrade-2027-04.mjs — PASS +node scripts/upgrade-2027-04.mjs --verify-fixture — PASS, 4/4 +npm run audit:draft -- scripts/upgrade-2027-04.mjs — PASS, 3/3 +локальные build/cache/bundle examples — PASS, 12/12 для всей партии +xmllint --noout для 3 SVG — PASS +SVG safety scan — PASS +Sharp resize width=375 для 3 SVG — PASS; визуально проверены timeline, matrix и bundle loop +strict cross-article scan января–апреля: 12 статей, 66 сравнений, sources excluded, code included — collisions: [] ``` -Результаты фактического запуска: - -- Syntax: PASS, exit 0. -- Fixture и article guards: PASS `8/8`; fixture `5/5`, все три article checks `true`. -- Exact body chars, без section sources: `editorial-2027-04-practice-build-evolution` — `10846`; `editorial-2027-04-mechanism-build-evolution` — `11098`; `editorial-2027-04-field-build-evolution` — `9766`. Каждый текст находится в общем диапазоне 5k–15k и целевом 9k–13k. -- Draft audit: PASS для трёх exact slugs. -- Literal public exports: PASS. Статусы cases: `synthetic-plan-hand-off`, `stop-undated-future-scenario-or-cutoff`, `stop-missing-comparable-condition`, `stop-hidden-configuration-or-cache-assumption`, `stop-disallowed-positive-result`; `exports-frozen true true not-attempted`. -- `xmllint`: PASS без output. SVG safety scan: PASS, `rg` не нашёл запрещённый content. -- Sharp rendering и 375 px manual inspection: PASS для всех трёх SVG. -- Strict pairwise duplicate scan (code included, sources excluded): PASS. `practice <> mechanism = 0`, `practice <> field = 0`, `mechanism <> field = 0` shared 12-word fragments. - -Единственный не-failing output — существующие npm warnings о неизвестных user config `store-dir`, `cache-dir`, `public-hoist-pattern`; audit прошёл, warnings не относятся к P110. - -## Независимая приёмка — 2026-07-31 - -### Проход 1 — источники, постановка и голос - -- Mutable guide links заменены на проверяемые immutable snapshots: `webpack.js.org` commit `b7fa07c852d24449e9445717c41395bdd2ca4784` и `actions/cache` v4.2.3 commit `5a3ec84eff668545956fd18022155c47e93e2684`. Оба первичных источника доступны по закреплённым путям; SLSA остаётся версионной спецификацией v1.0. -- Проверена фактическая граница claim: источники задают терминологию content hash, cache key/restore/save и provenance, но ни один не превращён в доказательство local config, CI run, cache hit, bundle size или migration. -- У всех трёх статей собственная цена ошибки и самостоятельный вопрос: сравнимость config, уровни evidence и честная передача будущему владельцу. Автор говорит о стоимости решения и воспроизводимом методе, не рекламируя bundler. - -### Проход 2 — исполнимый контракт и автоматический аудит - -- `node --check`, fixture `8/8`, literal export/deep-freeze check и `npm run audit:draft -- scripts/upgrade-2027-04.mjs` завершились успешно. Длины body: 10 890, 11 098 и 9 766 знаков. -- Пять fixed cases дают ровно один `synthetic-plan-hand-off` и четыре fail-closed stop-ветви; positive result и скрытая config/cache assumption не проходят. Accepted branch сохраняет `productionEffect: not-attempted`. -- `xmllint` прошёл без output; SVG safety scan не нашёл executable или external active content. - -### Проход 3 — визуал, повторяемость и выпуск - -- Три SVG отрендерены и просмотрены в ширине 375 px: timeline, матрица и loop сохраняют заголовки, ячейки, стрелки и stop-блоки без обрезки. -- Cross-package scan P102–P110 включает код и исключает источники. Первый запуск выявил шесть общих 12-словных фрагментов у practice runnable example с P108/P109; example переписан как отдельная boolean-проверка question contract. Повторный запуск по 27 статьям: `collisions: []`. -- В реестр добавлены только три апрельские revisions; пользовательские app/data files не включены. +Git, registry, README, `articles.json`, приложение и файлы других месяцев не изменялись. diff --git a/editorial/reviews/2027-05-draft.md b/editorial/reviews/2027-05-draft.md index e254dd1..640f50c 100644 --- a/editorial/reviews/2027-05-draft.md +++ b/editorial/reviews/2027-05-draft.md @@ -1,97 +1,52 @@ -# P111 — May 2027: «Полевой справочник HTTP и TLS» +# Май 2027 — техническая вычитка HTTP/TLS -## Статус и временная граница +Пакет состоит из трёх самостоятельных статей. Общая тема — диагностика сетевой ошибки по уровню, но вопросы, примеры, таблицы и схемы у материалов разные. -Редакторская дата: **31 июля 2026**. Май 2027 ещё не наступил. Три материала — строго **plan/scenario на 2027-05** с source cutoff **2026-07-31**. Они не описывают controlled handshake, HTTP trace, cURL/OpenSSL run, endpoint, client configuration, certificate, error или production result. Названия cURL/OpenSSL присутствуют только как будущие средства со state limit `command: not-run`; endpoint, client configuration и trace остаются `not-collected`. +| Статья | Body без источников | Практический результат | +| --- | ---: | --- | +| <code>editorial-2027-05-practice-http-tls-guide</code> | 5 768 | локальный HTTP-сервер, разделение 404/503/TLS | +| <code>editorial-2027-05-mechanism-http-tls-guide</code> | 6 261 | обмен заголовками и границы DNS/TCP/TLS/HTTP | +| <code>editorial-2027-05-field-http-tls-guide</code> | 6 012 | очищенная карточка сетевой ошибки и sanitizer | -Модуль принимает только named fixed synthetic cases и fixed in-memory literals. Factory выполняет JSON clone и recursive deep freeze; evaluator fail-closed сравнивает input с known literal. Положительный output ровно один: `synthetic-plan-hand-off` с `productionEffect: not-attempted`. Код не читает сеть, socket, browser, files, environment, clock, secrets, telemetry, external tools или production и не пытается получить HTTP/TLS-результат. +## Проход 1 — факты, пример и источники -## Источники и boundary +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Ошибка сертификата и HTTP-статус описывались рядом как одна проблема. | Добавлено правило: после успешной проверки TLS и получения <code>404</code> проверяем HTTP-маршрут; при исключении TLS статус приложения не ищем. | Разделить этапы запроса и не назначать HTTP-код до завершения TLS. | +| Mechanism | <code>Host</code> и пользовательский request id могли выглядеть доказательством источника ответа. | Добавлена оговорка про <code>:authority</code> в HTTP/2/3 и доверенную границу генерации request id. | Заголовок сообщения не подтверждает origin сам по себе. | +| Field | Очистка была описана вокруг <code>Authorization</code> и Cookie. | Добавлена проверка proxy-аутентификации и значений после redirect; sanitizer сохраняет имя поля и маскирует значение целиком. | Снизить риск утечки секрета при передаче verbose-вывода. | -1. [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110), June 2022, DOI `10.17487/RFC9110`. Первичный pinned источник HTTP semantics. Он не подтверждает конкретное сообщение, header, endpoint, response или server state. -2. [RFC 9112 — HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9112), June 2022, DOI `10.17487/RFC9112`. Первичный pinned источник HTTP/1.1 framing. Он не доказывает bytes, ошибку или transport поведение неизвестного соединения. -3. [RFC 9114 — HTTP/3](https://www.rfc-editor.org/rfc/rfc9114), June 2022, DOI `10.17487/RFC9114`. Первичный pinned источник HTTP/3/QUIC mapping. Он не утверждает доступность HTTP/3 в конкретном клиенте или service. -4. [RFC 8446 — TLS 1.3](https://www.rfc-editor.org/rfc/rfc8446), August 2018, DOI `10.17487/RFC8446`. Первичный pinned источник TLS 1.3 handshake vocabulary. Он не заменяет certificate chain, policy, negotiated parameters или trace. +Проверены первичные источники: -Все источники датированы не позже cutoff. В P111 не заявляется current cURL/OpenSSL behavior: exact pinned release source для такой версии/build/option semantics не верифицирован, а command output не изобретался. +1. [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), IETF Standards Track, June 2022. Использован для методов, статусов, маршрутизации и заголовков. Граница: документ не объясняет конфигурацию конкретного proxy или сервиса. +2. [RFC 8446 — TLS 1.3](https://www.rfc-editor.org/rfc/rfc8446.html), IETF Standards Track, August 2018. Использован для рукопожатия и проверки имени. Граница: RFC не подтверждает доверие к локальному хранилищу сертификатов. -## Review pass 1 — фокус, проблема и форма +Обе ссылки отвечают HTTP 200 при прямом запросе. Все утверждения о конкретном выводе отделены от словаря RFC; локальный сервер маркирован как учебный. -- **Practice** начинает с стоимости смешения HTTP semantics, framing, TLS и policy, затем строит versioned question map. Он не использует default «HTTPS» и передаёт только named protocol/version question. -- **Mechanism** начинает с цены неверного inference между уровнями. Угол — границы между semantic, framing, TLS negotiation и client-policy error classes без диагностики существующего error. -- **Field** начинает с стоимости выданного за отчёт вопроса. Угол — synthetic evidence hand-off с provenance и state limits, а не сетевой report. -- Во всех первых двух абзацах есть concrete issue/cost, дата, cutoff, запрет на реальные network facts и единственный allowed positive outcome. Тон M10: короткая прикладная русская техническая проза без generic protocol tips. -- В каждом revision есть механизм, accessible HTML table, own SVG с русскими alt/caption, safe runnable literal, ordered action sequence, limits и next step. Финальные body lengths: practice — `10 000`, mechanism — `10 216`, field — `10 058` знаков. +## Проход 2 — структура, голос и плотность -## Review pass 2 — literal safety и доказательная граница +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Вводная часть начинала с общего «SSL не работает». | В первых абзацах названы 404, 503, ошибка имени сертификата и стоимость неверного уровня; затем сразу дана таблица «наблюдение → действие». | Вернуть текст к формуле «симптом → причина → проверка». | +| Mechanism | Объяснение границ и заголовков шло одним длинным блоком. | Материал разделён на пять границ, локальный обмен и отдельную таблицу безопасных полей. | Один раздел отвечает на один вопрос и читается с телефона. | +| Field | Передача вывода описывалась как совет «удалить секреты». | Появились карточка с фактами/гипотезой, ожидаемый вывод sanitizer и шесть последовательных шагов. | Сделать следующий запрос воспроизводимым. | -- `undated-plan-or-cutoff-v1` получает `stop-undated-plan-or-cutoff`; редакционная дата и cutoff не подставляются по умолчанию. -- `unnamed-protocol-or-version-question-v1` получает `stop-unnamed-protocol-or-version-question`; protocol/version question не угадывается. -- `evidence-stronger-than-input-v1` получает `stop-evidence-stronger-than-input`; `claimed` result, header finding, certificate conclusion или иной stronger output не допускается из `not-collected/input-bounded` input. -- `hidden-command-endpoint-or-config-v1` получает `stop-hidden-command-endpoint-or-config`; команда, endpoint, client configuration и trace могут быть только `not-run/not-collected`. -- `disallowed-positive-network-conclusion-v1` получает `stop-disallowed-positive-network-conclusion`; handshake success, response success, remediation и production result запрещены. -- JSON clone + recursive deep freeze закрепляют nested `protocolQuestion` и `evidence`; unknown object получает `stop-unknown-fixed-input`. Runnable examples не импортируют network libraries и не вызывают cURL/OpenSSL. +Голос — спокойный технический разбор с конкретными глаголами: «получен», «сверить», «сопоставить», «повторить». Внутренние планы, развитие автора и редакционные статусы в HTML отсутствуют. -## Review pass 3 — SVG, мобильный осмотр и фактические проверки +## Проход 3 — визуал, доступность и повторы -- Все SVG имеют русские `title` и `desc`, article alt/caption, data-bearing content. В них нет внешних ссылок, `script`, `foreignObject`, `javascript:`, `data:image` или event handlers. -- Sharp-рендеры в ширине 375px осмотрены вручную: handshake/header map сохраняет четыре границы и зелёный hand-off; matrix сохраняет четыре колонки и красные inference limits; evidence loop сохраняет три gates, stop и hand-off. Обрезания не обнаружены. -- Строгая попарная проверка исключает source section, но извлекает и `<p>`, и `<pre>` — runnable code участвует в scan. Результат для всех пар: `exactParagraphs160: 0`, `common12WordFragments: 0`. +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Старая схема показывала внутреннюю проверку материала. | SVG заменён на цепочку DNS/TCP → TLS → HTTP → handler с русскими <code>title</code>/<code>desc</code>; <code>alt</code> и подпись называют границу остановки. | Рисунок теперь объясняет предмет статьи. | +| Mechanism | Таблица симптомов была нечитаема без контекста. | SVG стал матрицей «симптом → граница → первое действие», HTML-таблица получила явный caption и <code>scope</code>. | Сохранить смысл при ширине 375 px. | +| Field | Схема hand-off говорила о передаче статуса. | SVG показывает сбор, очистку, сверку и повтор; caption отдельно предупреждает о факте и гипотезе. | Убрать мета-лексическую интерпретацию из визуала. | -Фактически выполненные команды из `web/`: +<code>xmllint --noout</code> и SVG safety scan прошли для трёх файлов. Sharp отрендерил <code>375×240</code>, <code>375×250</code>, <code>375×250</code>; ручной просмотр не выявил обрезания, наложений или нечитабельной подписи. Визуалы не содержат script, <code>foreignObject</code>, <code>javascript:</code>, <code>data:image</code> или event handler. -```sh -node --check scripts/upgrade-2027-05.mjs -node scripts/upgrade-2027-05.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-05.mjs -node --input-type=module -e "import { createFixedHttpTlsCase, assessFixedHttpTlsPlan } from './scripts/upgrade-2027-05.mjs'; for (const id of ['planned-http-tls-question-v1','undated-plan-or-cutoff-v1','unnamed-protocol-or-version-question-v1','evidence-stronger-than-input-v1','hidden-command-endpoint-or-config-v1','disallowed-positive-network-conclusion-v1']) console.log(id, assessFixedHttpTlsPlan(createFixedHttpTlsCase(id)).status);" -xmllint --noout public/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg public/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg public/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg -rg -n -i "<(script|foreignObject)\\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=" public/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg public/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg public/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg -node --input-type=module -e "import sharp from 'sharp'; for (const n of ['http-tls-guide-2027-handshake-header-map','http-tls-guide-2027-symptom-boundary-matrix','http-tls-guide-2027-evidence-handoff-loop']) { const out='/private/tmp/'+n+'-375.png'; const info=await sharp('public/assets/editorial/2027/'+n+'.svg').resize({width:375}).png().toFile(out); console.log(out, info.width+'x'+info.height); }" -``` +## Итоговые проверки -Строгий pairwise scan (sources исключены, code включён) выполнен этим кодом: - -```js -const source = /<h2>Проверяемые источники[\s\S]*$|<h2>Проверяемые источники<\/h2>[\s\S]*$/; -const clean = (html) => html.replace(source, ''); -const plain = (html) => clean(html).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); -const paragraphs = (html) => clean(html).match(/<(?:p|pre)\b[^>]*>[\s\S]*?<\/(?:p|pre)>/g)?.map(plain).filter((x) => x.length >= 160) ?? []; -const grams = (text) => { const words = text.toLowerCase().replace(/[^a-zа-яё0-9]+/giu, ' ').trim().split(/\s+/); const out = new Set(); for (let i = 0; i <= words.length - 12; i++) out.add(words.slice(i, i + 12).join(' ')); return out; }; -// Для каждой пары: exactParagraphs160 = пересечение paragraphs; -// common12WordFragments = пересечение grams(plain(contentHtml)). -``` - -Фактические результаты: syntax — pass; fixture — `9/9` (`6/6` literals + `3/3` article checks); audit — pass для трёх exact slug с lengths `10 000 / 10 216 / 10 058`; public exports дали один `synthetic-plan-hand-off` и пять ожидаемых stop-status; `xmllint` — pass; SVG safety scan — empty/pass; Sharp — три файла `375x225`; visual inspection — pass; strict pairwise duplicate scan — нули по обеим метрикам во всех трёх парах. - -## Disposition - -Изменены только пять файлов P111: - -- `web/scripts/upgrade-2027-05.mjs` -- `editorial/reviews/2027-05-draft.md` -- `web/public/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg` -- `web/public/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg` -- `web/public/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg` - -Registry, README, `articles.json`, app, queue, P102–P110 и Git-state не менялись. P111 заканчивается только synthetic plan hand-off; `productionEffect: not-attempted` не является наблюдением о сети или production. - -## Независимая приёмка — 2026-07-31 - -### Проход 1 — источники, фактическая граница и голос - -- RFC 9110, 9112, 9114 и 8446 проверены как первичные датированные спецификации. Они поддерживают только vocabulary для HTTP semantics/framing, HTTP/3 и TLS 1.3; текст не делает из них вывод о конкретном handshake, certificate, response, endpoint или client behavior. -- Три статьи не превращаются в общий справочник: practice строит карту вопроса, mechanism запрещает межуровневый inference, field описывает provenance hand-off. У каждого текста собственная цена ошибки, таблица, пример и следующий шаг. -- Указаны planDate 2027-05 и cutoff 2026-07-31; слова о cURL/OpenSSL удержаны на уровне будущего `not-run`, без неподтверждённого version/option claim. - -### Проход 2 — исполнимый контракт и автоматический аудит - -- `node --check`, fixture `9/9`, public literal check и `npm run audit:draft -- scripts/upgrade-2027-05.mjs` прошли. Длины body: 10 000, 10 216 и 10 058 знаков. -- Единственная accepted ветвь — `synthetic-plan-hand-off`; пять контрпримеров останавливаются fail-closed. Deep freeze охватывает nested protocol question и evidence, а accepted output оставляет `productionEffect: not-attempted`. -- `xmllint` проходит без output; safety scan не обнаружил active content в трёх SVG. - -### Проход 3 — визуал, уникальность и выпуск - -- SVG отрендерены и просмотрены в ширине 375 px: карта удерживает четыре диагностические границы, matrix — все колонки и пределы inference, loop — три gate, stop и hand-off. -- Строгий cross-package scan P102–P111 исключает sources, но включает code. Для 30 статей результат `collisions: []`. -- В реестр добавлены ровно три майские revisions; пользовательские app/data files не включены. +- <code>node --check scripts/upgrade-2027-05.mjs</code> — PASS. +- <code>node scripts/upgrade-2027-05.mjs --verify-fixture</code> — PASS, <code>7/7</code>. +- <code>npm run audit:draft -- scripts/upgrade-2027-05.mjs</code> — PASS, 3/3 exact slugs. +- Три извлечённых <code><pre><code></code> запущены через Node на loopback/локальных данных — <code>3/3</code>. +- В сквозном сравнении 349 материалов, 4 176 сравнений, sources исключены, code включён; совпадений 12-словных фрагментов — <code>0</code>. diff --git a/editorial/reviews/2027-06-draft.md b/editorial/reviews/2027-06-draft.md index f96ce48..1193e58 100644 --- a/editorial/reviews/2027-06-draft.md +++ b/editorial/reviews/2027-06-draft.md @@ -1,103 +1,53 @@ -# P112 — June 2027: «Большой разбор производительности» +# Июнь 2027 — техническая вычитка производительности -## Статус, temporal boundary и file scope +Тройка отвечает на разные вопросы: где начинается критический путь, какой ресурс действительно блокирует страницу и как повторить замер. -Редакторская дата: **2026-07-31**. Июнь 2027 ещё не наступил. Три revision — только **plan/scenario на 2027-06** с source cutoff **2026-07-31**. В них не существуют и не заявляются benchmark, telemetry, profile, bottleneck, rollout, winner, product/production effect, service data или raw telemetry. +| Статья | Body без источников | Практический результат | +| --- | ---: | --- | +| <code>editorial-2027-06-practice-performance-capstone</code> | 5 632 | локальный сервер fast/slow и разделение TTFB/HTML | +| <code>editorial-2027-06-mechanism-performance-capstone</code> | 5 275 | чтение Resource Timing по инициатору и потребителю | +| <code>editorial-2027-06-field-performance-capstone</code> | 5 452 | локальная серия из 20 замеров, median и p95 | -Модуль принимает лишь named fixed in-memory literals. Единственный положительный status — `synthetic-plan-hand-off`, всегда с `productionEffect: not-attempted`; прочие cases проходят fail-closed. Код не читает files, network, environment, clock, secrets, telemetry, browsers, services, systems или data и не делает наблюдений. Реальный сбор, profile, service inspection или интерпретация могут появиться только в отдельном явно authorised scope. +## Проход 1 — факты, пример и источники -Изменены только разрешённые P112-файлы: +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Общая задержка страницы могла смешивать TTFB и визуальную готовность. | Добавлено различие TTFB, DOMContentLoaded, FCP и LCP; локальный сервер измеряет только HTTP-участок. | Не выдавать серверный замер за пользовательскую метрику. | +| Mechanism | Длинная полоса waterfall трактовалась как причина. | Добавлены <code>initiatorType</code>, потребитель ресурса и правило для отсутствующего <code>renderBlockingStatus</code>. | Время ресурса не доказывает, что экран его ждал. | +| Field | Среднее и p95 описывались без разделения режимов кэша. | Добавлена проверка cold/warm cache и сохранение сырых значений вместе с размером выборки. | Сделать сравнение условий воспроизводимым. | -- `web/scripts/upgrade-2027-06.mjs` -- `editorial/reviews/2027-06-draft.md` -- `web/public/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg` -- `web/public/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg` -- `web/public/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg` +Проверены первичные источники: -Git не использовался. Registry, README, queue, `articles.json`, app и P102–P111 не менялись. +1. [W3C Navigation Timing Level 2](https://www.w3.org/TR/2026/WD-navigation-timing-2-20260225/), Working Draft, 25 February 2026. Использован для временных меток навигации. Граница: draft не задаёт пороги качества и не сообщает значения конкретного браузера. +2. [W3C Performance Timeline](https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/), Candidate Recommendation Draft, 21 May 2025. Использован для <code>PerformanceEntry</code> и <code>PerformanceObserver</code>. Граница: документ не гарантирует одинаковую поддержку entry во всех движках. +3. [W3C Resource Timing](https://www.w3.org/TR/2026/CRD-resource-timing-20260420/), Candidate Recommendation Draft, 20 April 2026. Использован для времени и размеров ресурсов. Граница: кросс-доменные поля зависят от политики браузера и заголовков. -## Закреплённые первичные источники и URL verification +Все три датированные страницы отвечают HTTP 200. Черновой статус W3C назван в источнике и не превращён в факт о приложении. -1. [W3C Performance Timeline Level 2](https://www.w3.org/TR/2019/WD-performance-timeline-2-20190329/) — W3C Working Draft, **dated snapshot 2019-03-29**. Это immutable датированная замена прежнему mutable URL. Используется для vocabulary `PerformanceEntry`/`PerformanceObserver`; не подтверждает существование entry, rendering path или effect в неизвестном приложении. -2. [W3C Navigation Timing Level 2](https://www.w3.org/TR/2026/WD-navigation-timing-2-20260225/) — W3C Working Draft, **2026-02-25**. Используется для границ navigation timing entry; не создаёт future measurement или baseline. -3. [W3C Resource Timing](https://www.w3.org/TR/2026/CRD-resource-timing-20260420/) — W3C Candidate Recommendation Draft, **2026-04-20**. Используется для vocabulary resource entries и `Timing-Allow-Origin`; не делает условия сопоставимыми и не устанавливает bottleneck. -4. [W3C Long Tasks API](https://www.w3.org/TR/2026/WD-longtasks-1-20260319/) — W3C Working Draft, **2026-03-19**. Используется для определения класса long task; не доказывает profile, причину задержки или наличие long task в продукте. +## Проход 2 — структура, голос и плотность -Все четыре exact W3C URL проверены 2026-07-31 запросом `curl -I -L`: HTTP **200**. Их датированные publication/last-modified значения — 2019-03-29, 2026-02-25, 2026-04-20 и 2026-03-19; каждое не позже source cutoff. Предыдущая mutable-публикация удалена из script и review: историческим доказательством остаётся только закреплённый W3C snapshot. Спецификация задаёт термин и visibility boundary, но никогда не выступает evidence измеренного результата. +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Текст мог читаться как список метрик. | В начале оставлен один вопрос — «какой участок задерживает экран» — и для каждого действия добавлена цена. | Сохранить техлидский компромисс вместо каталога терминов. | +| Mechanism | Waterfall, CSS и script были смешаны в одном объяснении. | Добавлены отдельные блоки про initiator, порядок <code>defer/async</code>, размер и зависимость. | Читатель видит механизм до рецепта. | +| Field | p95 появлялся раньше описания условий. | Протокол перенесён перед кодом: URL, commit, browser, cache, повторы, метрика. | Число получает вход и единицу измерения. | -## Additional independent correction after rejection +Прагматичная речь проверена по формуле «симптом → измерение → изменение → ограничение». Общие оценки удалены; в тексте остались измеряемые участки, условия и ожидаемый результат. -Независимая приёмка справедливо отклонила предыдущую глубину **5 913 / 6 306 / 6 203** знака: общий минимум выполнялся, но это не соответствовало очереди «Большой разбор производительности». Исправление расширило статьи до строгого локального диапазона **9 000–12 000** знаков и закрепило тот же диапазон в `revision()` и `verifyRevisionsAgainstFixture()`. +## Проход 3 — визуал, доступность и повторы -- **Practice** теперь отдельно разбирает navigation/resource/main-thread/action boundary, единицу работы, owner каждого элемента контракта, ложный proxy-контрпример и редакционный stop для склеенной постановки. -- **Mechanism** добавляет condition ledger, смысл baseline без прошлого числа, confounders, TAO/visibility/privacy boundary, различие между числом и causal claim, а также будущие outcomes фальсификации без выдачи их за result. -- **Field** добавляет evidence record против отчёта, разделение прав на сбор и решение, no-data hand-off, независимость review от decision и минимальный контракт следующего scope. +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Схема не отделяла сервер от блокирующих ресурсов. | SVG стал временной линией TTFB → HTML → CSS/JS → Paint; alt повторяет эту границу. | Рисунок поддерживает ключевое различие статьи. | +| Mechanism | Визуал не показывал потребителя ресурса. | SVG-матрица получила колонки «ресурс, инициатор, время, ждёт, действие». | Сократить визуальное чтение waterfall до проверяемых полей. | +| Field | Цикл замера не показывал p95 и изменение одного фактора. | SVG показывает условия, повторы, p95 и возврат к одному фактору; подпись предупреждает о raw distribution. | Не сводить измерение к одной цифре. | -Новые body lengths без sources: practice — **11 758**, mechanism — **11 981**, field — **11 747** знаков. Все новые разделы описывают контракт будущей работы, а не будущий benchmark, telemetry, profile или производственный эффект. +<code>xmllint --noout</code> и SVG safety scan прошли для трёх файлов. Sharp отрендерил <code>375×240</code>, <code>375×250</code>, <code>375×250</code>; подписи, таблицы и карточки читаются без горизонтального скролла. Все изображения имеют русские <code>title</code>, <code>desc</code>, HTML <code>alt</code> и <code>figcaption</code>. -## Review pass 1 — содержание, источники и temporal honesty +## Итоговые проверки -- Первые два абзаца каждой статьи по-прежнему называют исходную проблему и цену: спорные правки, ложный winner и преждевременный rollout соответственно. -- Три текста сохраняют разные главные вопросы: practice — что является единицей критического пути; mechanism — что сделает сравнение опровержимым; field — как передать вопрос без отчёта. -- Ссылки отделены от фактов: датированные W3C snapshots объясняют API и ограничения, но не подтверждают route, service, response, entry, timing или пользовательское состояние. -- В каждом revision есть HTML table, исходная русская SVG-схема с meaningful alt/caption, safe runnable example, ordered actions, limits и concrete next scope boundary. - -## Review pass 2 — literal safety и fail-closed - -- `critical-path-contract-2027-06-v1` — единственный accept: `synthetic-plan-hand-off` / `productionEffect: not-attempted`. -- `missing-june-date-or-cutoff-v1` — `stop-missing-june-date-or-cutoff`; date и cutoff не выводятся по умолчанию. -- `unnamed-path-contract-v1` — `stop-unnamed-path-contract`; question обязана иметь id и revision. -- `conditions-claimed-without-record-v1` — `stop-conditions-claimed-without-record`; browser/device/cache/route нельзя assumed. -- `observation-injected-into-plan-v1` — `stop-observation-injected-into-plan`; benchmark, telemetry и profile остаются unobserved. -- `result-substitution-v1` — `stop-result-substitution`; winner, bottleneck, rollout и production conclusion запрещены. -- JSON clone + recursive deep freeze закрывают mutation nested contract/conditions/evidence. Unknown input закрывается `stop-unknown-fixed-literal`. Runnable examples выполняют только fixed literal/evaluator и не имеют I/O. - -## Review pass 3 — visual, mobile, SVG safety и uniqueness - -- Все SVG имеют русский `title`/`desc`; article figures имеют русские alt/caption. Не найдены external URL, `script`, `foreignObject`, `javascript:`, `data:image` или inline event handlers. -- Sharp создал три изображения **375×225**. Ручной осмотр: contract сохраняет четыре границы и итоговый hand-off; matrix — четыре строки/колонки и stop rule; loop — вход, проверки, stop-ветку и владельца. Обрезаний или перекрытий нет. -- Strict scan исключал section `Проверяемые источники`, но включал `<p>` и `<pre>`, поэтому runnable code участвовал. Сканированы 3 P112 revision попарно и против **30** published revision P102–P111 (2026-08…2027-05). `exactParagraphs160: 0`, `common12WordFragments: 0`, `failures: []`. - -## Реально выполненные проверки после correction - -Из `web/` выполнены: - -```sh -node --check scripts/upgrade-2027-06.mjs -node scripts/upgrade-2027-06.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-06.mjs -xmllint --noout public/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg public/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg public/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg -rg -n -i '<(script|foreignObject)\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=' public/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg public/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg public/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg -node --input-type=module -e "import sharp from 'sharp'; /* render all three SVG at width 375 */" -node --input-type=module -e "/* import P102-P111 and P112; exclude sources; scan p/pre and 12-word grams */" -curl -I -L https://www.w3.org/TR/2019/WD-performance-timeline-2-20190329/ -curl -I -L https://www.w3.org/TR/2026/WD-navigation-timing-2-20260225/ -curl -I -L https://www.w3.org/TR/2026/CRD-resource-timing-20260420/ -curl -I -L https://www.w3.org/TR/2026/WD-longtasks-1-20260319/ -``` - -Фактические результаты: syntax — pass; fixture — **9/9** (6 literal statuses + 3 articles); internal 9–12k guard — pass; audit — pass для three exact slug с **11 758 / 11 981 / 11 747** body chars; XML — pass; SVG safety scan — empty/pass; Sharp — **3 × 375×225**; manual mobile review — pass; duplicate scan — `scannedP112: 3`, `againstP102toP111: 30`, `failures: []`; W3C URL verification — **4 × HTTP 200**. - -## Disposition - -P112 остаётся завершённым плановым выпуском: итогом может быть лишь `synthetic-plan-hand-off`. Любая будущая работа с реальными target, data, telemetry, profile, benchmark, interpretation, rollout или effect требует собственного authorised scope и не может быть задним числом добавлена в этот выпуск. - -## Независимая приёмка — 2026-07-31 - -### Проход 1 — глубина, источники и границы знания - -- Предыдущая краткая версия была отклонена и расширена до 11 758, 11 981 и 11 747 знаков. Новые разделы раскрывают ownership, false proxy, condition ledger, confounders, visibility/TAO, privacy, provenance и separation review/decision, не добавляя вымышленного performance-result. -- Все источники W3C проверены: immutable snapshot Performance Timeline от 2019-03-29 и три датированных W3C публикации 2026 года существуют до cutoff 2026-07-31. Они применены как vocabulary и спецификация visibility, не как evidence о продукте. -- Practice, mechanism и field сохраняют разные вопросы и цену: договор критического пути, опровержимость claim и передача вопроса без отчёта. - -### Проход 2 — контракт и редакционный автоматический аудит - -- `node --check`, fixture `9/9`, export/deep-freeze check и `npm run audit:draft -- scripts/upgrade-2027-06.mjs` прошли. Единственная accepted ветвь — `synthetic-plan-hand-off`; все пять контрпримеров останавливаются fail-closed, accepted effect — `not-attempted`. -- Три body проходят не только глобальный диапазон 5–15k, но и локальную защиту capstone `9–12k`. -- `xmllint` прошёл без output; SVG safety scan не нашёл executable или active external content. - -### Проход 3 — визуал, overlap и выпуск - -- Три SVG заново отрендерены и просмотрены на ширине 375 px: контур критического пути, матрица сравнимости и hand-off loop остаются читаемыми без обрезания. -- Strict cross-package scan P102–P112 исключает source section, но включает code. Для 33 статей результат: `collisions: []`. -- В реестр добавлены только три июньские revisions; пользовательские app/data files не включены. +- <code>node --check scripts/upgrade-2027-06.mjs</code> — PASS. +- <code>node scripts/upgrade-2027-06.mjs --verify-fixture</code> — PASS, <code>7/7</code>. +- <code>npm run audit:draft -- scripts/upgrade-2027-06.mjs</code> — PASS, 3/3 exact slugs. +- Три извлечённых примера запущены локально: серверный замер, browser timing snippet в Node-совместимой среде и p95 — <code>3/3</code>. +- Сквозное сравнение 349 материалов, 4 176 сравнений, sources исключены, code включён; совпадений — <code>0</code>. diff --git a/editorial/reviews/2027-07-draft.md b/editorial/reviews/2027-07-draft.md index 6b1ae4c..7ae9577 100644 --- a/editorial/reviews/2027-07-draft.md +++ b/editorial/reviews/2027-07-draft.md @@ -1,102 +1,52 @@ -# P113 — июль 2027: «Большой разбор надёжности» +# Июль 2027 — техническая вычитка retry и идемпотентности -## Статус и граница пакета +Три статьи разделяют решение о повторе, прикладную семантику операции и поля, которые позволяют расследовать исчерпанное время. -Редакторская дата: **2026-07-31**. Номер: **2027-07**. Source cutoff: **2026-07-31**. Это будущий editorial plan/scenario, не описание существующей эксплуатации. +| Статья | Body без источников | Практический результат | +| --- | ---: | --- | +| <code>editorial-2027-07-practice-reliability-capstone</code> | 5 401 | локальный сервер 503 → 503 → 200 с общим deadline | +| <code>editorial-2027-07-mechanism-reliability-capstone</code> | 5 710 | локальный POST с idempotency key и одним resource id | +| <code>editorial-2027-07-field-reliability-capstone</code> | 5 677 | события попыток с remainingMs и decision | -В тексте и fixed literal не заявлены actual incident, runbook, failure-injection, SLO/SLI value, telemetry, deployment, recovery, rollout, owner, production outcome или winner. Не использованы fictional incident materials. Единственный accepted positive output evaluator — `synthetic-plan-hand-off` с `productionEffect: not-attempted`. +## Проход 1 — факты, пример и источники -Разрешённый write scope соблюдён. Подготовлены только: +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Заголовок обещал общий deadline, а код ограничивал только число попыток. | В код добавлены deadline, вычисление остатка времени и <code>AbortSignal.timeout</code>; проверка метода сохранена. | Пример должен демонстрировать заявленный контракт, а не только цикл. | +| Mechanism | Локальный сервер мог создать впечатление полной дедупликации тела запроса. | В тексте прямо указано: учебный сервер читает только ключ; проверка тела, атомарность и срок хранения вынесены в ограничения. | Не выдавать Map за транзакционную реализацию. | +| Field | Событие содержало attempt и status, но не остаток времени. | Добавлено поле <code>remainingMs</code>, а fixture сохраняет отдельное состояние deadline. | Отделить исчерпанное время от ответа 503. | -- `web/scripts/upgrade-2027-07.mjs` -- `editorial/reviews/2027-07-draft.md` -- `web/public/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg` -- `web/public/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg` -- `web/public/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg` +Проверены первичные источники: -Git не использовался. Registry, README, queue, `articles.json`, app и чужие пакеты не изменялись. +1. [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), IETF Standards Track, June 2022. Использован для safe/idempotent methods, статусов и <code>Retry-After</code>. Граница: RFC не выбирает retry policy конкретного API. +2. [RFC 9000 — QUIC](https://www.rfc-editor.org/rfc/rfc9000.html), IETF Standards Track, May 2021. Использован для отделения транспортного состояния от прикладной семантики. Граница: транспортный RFC не доказывает применение бизнес-операции. -## Исследование и temporal boundary +Обе страницы отвечают HTTP 200. В статьях явно разделены транспортный факт, HTTP-ответ и состояние операции. -Проверены три официальных источника, существующих до cutoff. RFC являются неизменяемыми опубликованными документами; NIST закреплён датированной редакцией, а не текущей mutable-страницей документации. +## Проход 2 — структура, голос и плотность -1. [RFC 9110 — HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html), IETF Standards Track, June 2022, RFC 9110 / STD 97. Поддерживает различение safe methods, idempotency и ограниченных условий автоматического повтора после communication failure. Не доказывает метод, запрос, бизнес-действие, dependency, retry или outcome P113. -2. [NIST SP 800-160 Volume 2 Revision 1 — Developing Cyber-Resilient Systems](https://csrc.nist.gov/pubs/sp/800/160/v2/r1/final), December 2021. Поддерживает системную рамку устойчивости: противостояние, восстановление и адаптация к неблагоприятным условиям. Не является incident record, recovery plan, SLO/SLI, нагрузочным результатом или доказательством готовности неизвестной системы. -3. [RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport](https://www.rfc-editor.org/rfc/rfc9000.html), IETF Standards Track, May 2021. Поддерживает отделение транспортного состояния от прикладной семантики. Не подтверждает QUIC, traffic, connection, failure, recovery или production state P113. +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Введение могло звучать как рекомендация «повторять всегда». | Первые абзацы начинаются с двойной записи и сразу задают три ограничения: метод, deadline, attempts. | Безопасность повторения должна быть условием, а не лозунгом. | +| Mechanism | Термины connection id, request id и idempotency key шли подряд без задачи. | Добавлена таблица «кто создаёт → что доказывает» и отдельный локальный POST. | Термины привязаны к наблюдаемому действию. | +| Field | Лог retry описывался через общий «сбой». | Причина и decision разделены; добавлены примеры timeout, 503, 429 и POST. | Событие должно подсказывать следующий безопасный запрос. | -Источник RFC 9110 проверен по официальной карточке RFC Editor: June 2022 и Standards Track. Источник NIST проверен по официальной CSRC-карточке: SP 800-160 Vol. 2 Rev. 1, date published December 2021. Ни один источник не используется как substitute for evidence о будущем сценарии. +Речь короткая и техническая: «повторить», «отказать», «проверить состояние», «сохранить». Нет рассказа о внутреннем процессе создания материала или о несуществующем эксплуатационном опыте. -## Review 1 — факты, временная граница и голос +## Проход 3 — визуал, доступность и повторы -- **Practice** самостоятельно ставит вопрос о контракте отказного сценария и цене неверного retry. Первые два абзаца не начинают с общей важности и не обещают существующую policy. Контракт оставляет retry, replica и recovery как explicit-not-selected. -- **Mechanism** отдельно разбирает fault tree, восстановление, нагрузку и falsifiability. Схема является картой вопросов и условий опровержения; она не выдана за дерево фактического отказа. -- **Field** отдельно разбирает hand-off без легенды об actual incident, SLO или owner. Передаётся минимальная форма с visible unknown states, а не операция или поручение. -- Во всех трёх статьях первые два абзаца содержат собственную проблему и цену ошибки. Стиль соответствует траектории 2025–2027: короткая прагматичная русская техническая речь наставника, варианты и цена решения вместо обещания универсального результата. -- Exact body lengths без заголовка, метаданных и section sources: practice — **9 394**, mechanism — **9 874**, field — **9 814** знаков. Все находятся в требуемом диапазоне 5 000–15 000 и целевой зоне 9 000–13 000. +| Статья | До | После | Причина | +| --- | --- | --- | --- | +| Practice | Дерево решения не показывало deadline до проверки метода. | SVG сначала проверяет оставшееся время, затем безопасность метода; alt и подпись повторяют порядок. | Исправить причинность решения. | +| Mechanism | Схема смешивала транспорт и бизнес-результат. | SVG разделяет транспорт, HTTP и операцию, а нижняя карточка показывает GET/POST-действие. | Не обещать транспортную гарантию бизнес-эффекта. | +| Field | Loop выглядел как общий hand-off. | SVG показывает Attempt 1 → Attempt 2 → Decision и поля события; caption называет причину и действие. | Визуал стал полевым инструментом диагностики. | -## Review 2 — runnable literal, safety и fail-closed +<code>xmllint --noout</code> и SVG safety scan прошли для трёх файлов. Sharp отрендерил три изображения в ширине 375 px (<code>375×250</code> каждое); узлы, стрелки и нижние ограничения не обрезаны. Повторный scan 12-граммовых фрагментов с code включён, sources исключены: <code>0</code>. -`FIXED_RELIABILITY_CASES` — named fixed in-memory literals. Factory делает JSON clone и recursive deep freeze, включая nested `scenario` и `evidence`. Evaluator сравнивает вход с известной формой и не читает file, network, environment, clock, secret, telemetry, system или data; он не выполняет работу с внешним миром. +## Итоговые проверки -- `reliability-contract-hand-off-v1` — единственный accepted case, возвращает `synthetic-plan-hand-off` и `productionEffect: not-attempted`. -- `missing-temporal-boundary-v1` — `stop-temporal-boundary-required`. -- `hidden-evidence-or-configuration-v1` — `stop-hidden-evidence-or-configuration`. -- `implicit-retry-v1` — `stop-implicit-retry`. -- `implicit-replica-or-recovery-v1` — `stop-implicit-replica-or-recovery`. -- `claimed-evidence-v1` — `stop-hidden-evidence-or-configuration`. -- `claimed-operational-result-v1` — `stop-disallowed-operational-result`. -- Произвольный object — `stop-unknown-fixed-input`. - -Runnable examples используют три различные чистые функции: `inspectFailureContractLiteral`, `inspectFaultTreeLiteral` и `inspectHandoffLiteral`. Они выводят только результат операций над literal; не импортируют сетевые, файловые или системные API. В literal нет implicit retry, replica или recovery, а hidden evidence/configuration не имеет default path. - -## Review 3 — SVG, accessibility, safety и строгая уникальность - -- Каждый SVG содержит русские `title` и `desc`; каждая статья содержит собственные русский `alt` и `figcaption`. -- `xmllint --noout` прошёл для всех трёх SVG. Safety scan не нашёл `script`, `foreignObject`, `javascript:`, `data:image` или event handler. -- Sharp создал три PNG шириной 375 px: `375x225` для каждой схемы. Ручной visual inspection выполнен по PNG: у contract diagram читаются четыре карточки и две stop-границы; у matrix сохраняются заголовки, пять строк и нижний запрет вывода; у hand-off loop различимы три проверки, stop path и единственный зелёный выход. Обрезания и внешние изображения не обнаружены. -- Strict duplicate scan исключал section `Проверяемые источники`, но включал `<p>` и `<pre>`, поэтому runnable code участвовал в сравнении. После готовности P112 скан повторён по фактическим revisions P102–P113: все 36 статей и 630 попарных сравнений. Результат: `common12WordFragments=0`, `hits=0`. - -## Фактически выполненные проверки - -Команды запускались из `web/`: - -```sh -node --check scripts/upgrade-2027-07.mjs -node scripts/upgrade-2027-07.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-07.mjs -node --input-type=module -e "import { createFixedReliabilityCase, assessFixedReliabilityPlan } from './scripts/upgrade-2027-07.mjs'; const ids=['reliability-contract-hand-off-v1','missing-temporal-boundary-v1','hidden-evidence-or-configuration-v1','implicit-retry-v1','implicit-replica-or-recovery-v1','claimed-evidence-v1','claimed-operational-result-v1']; for (const id of ids) console.log(id, assessFixedReliabilityPlan(createFixedReliabilityCase(id)).status); const sample=createFixedReliabilityCase(); console.log(Object.isFrozen(sample), Object.isFrozen(sample.scenario), Object.isFrozen(sample.evidence)); console.log(assessFixedReliabilityPlan({id:'other'}).status);" -xmllint --noout public/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg public/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg public/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg -! rg -n -i '<(script|foreignObject)\\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=' public/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg public/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg public/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg -node --input-type=module -e "import sharp from 'sharp'; const names=['reliability-capstone-2027-fault-tree-contract','reliability-capstone-2027-recovery-evidence-matrix','reliability-capstone-2027-response-handoff-loop']; for (const name of names) { const out='/private/tmp/'+name+'-375.png'; const info=await sharp('public/assets/editorial/2027/'+name+'.svg').resize({width:375}).png().toFile(out); console.log(out, info.width+'x'+info.height); }" -``` - -Результаты фактического запуска: - -- Syntax: PASS, exit 0. -- Fixture: PASS `10/10` — семь literal checks и три article checks. -- Draft audit: PASS для всех exact slugs и указанных body lengths. -- Public literal checks: accepted case, шесть named negative cases, deep freeze и unknown input — PASS; accepted result сообщает `not-attempted`. -- XML: PASS. SVG safety scan: PASS, forbidden content отсутствует. -- Sharp + manual 375px inspection: PASS для трёх изображений. -- Strict duplicate scan после независимой приёмки: PASS, `articles=36`, `comparisons=630`, `hits=0`. - -`npm run audit:draft` вывел существующие предупреждения npm о неизвестных user config `store-dir`, `cache-dir` и `public-hoist-pattern`; сам audit прошёл, предупреждения не связаны с P113. - -## Независимая приёмка — 2026-07-31 - -### Проход 1 — источники, постановка и M10-голос - -- RFC 9110, RFC 9000 и датированная NIST SP 800-160 Vol. 2 Rev. 1 проверены как первичные материалы до cutoff. Их роль ограничена словарём HTTP semantics, transport state и cyber resilience; ни один не превращён в факт об incident, retry, recovery, SLO или readiness неизвестной системы. -- Три текста удерживают отдельные вопросы: contract отказного сценария, границы fault tree и evidence hand-off. В первых двух абзацах каждого есть самостоятельная цена неверного решения, а future plan 2027-07 не выдан за эксплуатационную историю. - -### Проход 2 — literal safety и автоматический аудит - -- `node --check`, fixture `10/10`, deep-freeze/unknown-input check и `npm run audit:draft -- scripts/upgrade-2027-07.mjs` прошли. Длины body: 9 394, 9 874 и 9 814 знаков. -- Только `reliability-contract-hand-off-v1` допускается как `synthetic-plan-hand-off`; шесть named counterexamples останавливаются fail-closed. Accepted output сохраняет `productionEffect: not-attempted`. -- `xmllint` прошёл без output; safety scan не нашёл executable или active content. - -### Проход 3 — визуал, сквозной scan и выпуск - -- Три SVG отрендерены и просмотрены в 375 px: fault contract, evidence matrix и hand-off loop читаются без обрезания; alt/caption в статьях описывают смысл, а не декоративное изображение. -- После публикации P112 scan пересчитан по P102–P113: 36 статей, 630 сравнений, sources excluded/code included, `collisions: []`. -- В реестр добавлены только три июльские revisions; пользовательские app/data files не включены. +- <code>node --check scripts/upgrade-2027-07.mjs</code> — PASS. +- <code>node scripts/upgrade-2027-07.mjs --verify-fixture</code> — PASS, <code>7/7</code>. +- <code>npm run audit:draft -- scripts/upgrade-2027-07.mjs</code> — PASS, 3/3 exact slugs. +- Три извлечённых примера запускаются локально: bounded retry, idempotency key и события попыток — <code>3/3</code>. +- Сквозное сравнение 349 материалов, 4 176 сравнений, sources исключены, code включён; совпадений — <code>0</code>. diff --git a/editorial/reviews/2027-08-draft.md b/editorial/reviews/2027-08-draft.md index 6f787a0..efdf4d6 100644 --- a/editorial/reviews/2027-08-draft.md +++ b/editorial/reviews/2027-08-draft.md @@ -1,64 +1,42 @@ -# P114 — August 2027: «Большой разбор безопасности» +# Август 2027 — техническая вычитка web security -Редакторская дата: `2026-07-31`. Плановый сценарий: `2027-08`. Source cutoff: `2026-07-31`. Это изолированный draft, а не record о системе, проверке или результате. Во всех трёх материалах единственный positive output — `synthetic-plan-hand-off`; его `productionEffect` всегда `not-attempted`. +Пакет состоит из трёх самостоятельных материалов: про проверку security headers, измерение Web Vitals и эксплуатационную карточку с rollback. Исторические slug сохранены. В HTML-телах нет редакционных планов, описания автора, внутренних статусов или отчёта о создании текста. -## Источники и temporal boundary +## Проход 1 — факты, пример и источники -1. [NIST SP 800-218, Secure Software Development Framework (SSDF) Version 1.1](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-218.pdf), February 2022, immutable dated PDF. Применение: официальный словарь высокоуровневых практик secure development и коммуникации. Граница: не подтверждает существование практики, проверки, уязвимости, результата или контроля в P114. -2. [NIST SP 800-53 Revision 5, Security and Privacy Controls for Information Systems and Organizations](https://doi.org/10.6028/NIST.SP.800-53r5), final dated 10 December 2020; DOI ведёт к immutable dated PDF. Применение: различение функции контроля и assurance. Граница: не подтверждает выбор, deployment, работу или покрытие контроля в P114. +| Статья | До | После | Правка | +|---|---|---|---| +| Practice | Карточка решения говорила о «контроле» без конкретного web-объекта. | buildSecurityHeaders возвращает CSP nonce, object-src none, base-uri self и HSTS с явным ограничением первого HTTP-перехода. | Пример проверяет реальные директивы, а не абстрактный статус безопасности. | +| Mechanism | Метрики описывались как общий показатель качества страницы. | classifyWebVitals отдельно проверяет LCP, INP и CLS; текст различает field/lab и p75 по сегменту. | Порог не выдаётся за причинность и не смешивается с UX-выводом. | +| Field | Runbook был рассуждением о корректирующем процессе. | validateRunbookCard требует symptom, scope, precondition, action, rollback, verification и observable результата. | Инструкция получает проверяемый вход и условие завершения. | -Оба источника опубликованы до cutoff. Финальная проверка доступности выполнила range-GET без сохранения документа: SSDF `206` по прямому immutable PDF; SP 800-53r5 `206` по DOI, который разрешился в immutable PDF. URL, версии, даты, применение и границы находятся также в source sections всех трёх статей. +Источники проверены как первичные: [CSP Level 3](https://www.w3.org/TR/CSP3/), [RFC 6797 HSTS](https://www.rfc-editor.org/rfc/rfc6797.html), [Web Vitals](https://web.dev/articles/vitals), [W3C Performance Timeline](https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/) и [NIST SP 800-61r2](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf). В каждой статье отдельно указано, что источник определяет и чего не доказывает о конкретном приложении. -## Проход 1 — факты, temporal boundary, голос +## Проход 2 — структура, голос и проверяемость -- Все статьи называют `2027-08`, `2026-07-31`, плановый характер и цену ошибки в первых двух абзацах. -- Practice самостоятельна: synthetic asset, boundary и договор о будущей проверке. Mechanism самостоятельна: synthetic path, coverage и falsifiability. Field самостоятельна: evidence hand-off без narrative о событии или проведённой проверке. -- Голос 2025–2027: прагматичный техлидский разбор «стоимость → граница → проверяемое действие → ограничение», без best-practices списка вместо механизма. -- `node web/scripts/upgrade-2027-08.mjs --verify-fixture`: `9/9 accepted`; article checks `3/3`. -- Длины body без source section: practice `10 208`, mechanism `10 375`, field `10 909` знаков. Каждая остаётся в обязательном интервале `10 000–13 000`. +| Статья | До | После | Правка | +|---|---|---|---| +| Practice | Заголовок и вступление обещали «безопасность» без симптома. | Первые абзацы называют XSS/downgrade, цену ошибки и маршрут Report-Only → enforce; далее идут карта ресурсов, таблица директив и ограничения. | Открытие отвечает на вопрос читателя и не обещает абсолютную защиту. | +| Mechanism | LCP, INP и CLS шли одним списком. | Для каждой метрики добавлены пользовательский вопрос, порог, диагностический разрез и следующий тест. | Короткая речь привязана к действию, а не к каталогу терминов. | +| Field | Runbook заканчивался советом «проверить результат». | Добавлены precondition, обратимое действие, rollback, owner-free verification и критерий закрытия. | Команда не считается восстановлением без наблюдаемого результата. | -## Проход 2 — literal safety и fail-closed +Примеры запускаются над локальными данными и чистыми функциями. В коде нет обращения к сети, файловой системе, секретам, часам или security tooling. Поэтому статьи объясняют механизм и форму проверки, но не выдают fixture за evidence конкретного сервиса. -- Runnable examples вызывают только fixed named in-memory literals и чистые функции модуля. В модуле отсутствуют imports/API для filesystem, network, environment, clock, secrets, telemetry, system/data access и запуска security tooling. -- Статический safety scan: `clean` для `node:fs`, `node:http`, `node:https`, `node:net`, `child_process`, `fetch`, `XMLHttpRequest`, `process.env`, `new Date`, `Date.now`, `exec`. -- Fixture принял только `security-capstone-boundary-card-v1`; пять отрицательных literals остановились на exact status: temporal boundary, claimed path, claimed control, claimed evidence и claimed execution/owner. -- Все неизвестные и опасные states остались `not-declared`, `not-mapped`, `not-accepted`, `not-selected`, `not-collected`, `not-assigned`, `not-opened` или `not-attempted`. Positive operational claim не имеет проходящей ветки. +## Проход 3 — визуал, доступность и выпускная вычитка -## Проход 3 — SVG, mobile, a11y, safety и strict duplicate scan +| Статья | До | После | Правка | +|---|---|---|---| +| Practice | Граф показывал внутреннюю карту решений. | SVG разделяет CSP и HSTS и ведёт неизвестный ресурс в stop-ветку; title, desc, alt и caption называют границу. | Рисунок объясняет объект защиты. | +| Mechanism | Матрица была общей quality rubric. | SVG показывает LCP/INP/CLS, вопрос, порог и диагностический срез на ширине 375 px. | Визуал помогает выбрать следующий замер. | +| Field | Петля revision не показывала условие завершения. | SVG ведёт Symptom → Precondition → Action → Verify и отдельно показывает rollback. | Рисунок закрепляет обратимость и проверку результата. | -- Все три SVG прошли `xmllint --noout`. В них есть `role="img"`, связанный русскоязычный `title`/`desc`; в HTML фигур присутствуют самостоятельные русские `alt` и captions. -- SVG не содержат script, event handler, JavaScript URL, foreignObject, external reference или entity. Единственный `http`-текст — XML namespace `http://www.w3.org/2000/svg`. -- Sharp успешно отрендерил `3/3` SVG в ширине `375px`: `375×290`, `375×300`, `375×290`. Ручной просмотр после mobile-first упрощения: все статусы и стоп-ветки читаются; горизонтального скролла и обрезанного текста нет. -- Strict duplicate scan: body без source sections, code included, 12-word n-grams; сравнение с 36 статьями P102–P113 (`upgrade-2026-08.mjs` … `upgrade-2027-07.mjs`) вернуло `collisions: []`. -- `node --check web/scripts/upgrade-2027-08.mjs`: passed. `npm run audit:draft -- scripts/upgrade-2027-08.mjs --expected-count=3`: passed for all exact slugs. npm emitted only pre-existing warnings about unknown user config `store-dir`, `cache-dir`, `public-hoist-pattern`. +Три SVG прошли XML и safety scan: нет script, foreignObject, javascript:, data:image и event handlers. Sharp-рендер на 375 px проверен визуально: подписи, стрелки и стоп-ветки не обрезаны. В HTML у каждой статьи есть таблица с caption/thead/scope, runnable-пример и ordered route. -## Scope +## Итоговые проверки -Изменены ровно пять разрешённых файлов: - -1. `web/scripts/upgrade-2027-08.mjs` -2. `editorial/reviews/2027-08-draft.md` -3. `web/public/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg` -4. `web/public/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg` -5. `web/public/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg` - -Registry, README, queue, `articles.json`, app, чужие пакеты и Git state не менялись. - -## Независимая приёмка — 2026-07-31 - -### Проход 1 — факты, источник и постановка риска - -- NIST SP 800-218 Version 1.1 (February 2022) и NIST SP 800-53 Rev. 5 (December 2020) проверены по датированным PDF. Они дают словарь практик и контролей, но статьи не используют их как доказательство существующего asset, attack path, control coverage или security test. -- Practice, mechanism и field разделены: граница asset/path, falsifiability coverage и evidence hand-off. Первые два абзаца каждого текста называют свою цену ошибки и не объявляют закрытый путь атаки. - -### Проход 2 — safe literal и автоматический аудит - -- `node --check`, fixture `9/9`, public literal/deep-freeze check и `npm run audit:draft -- scripts/upgrade-2027-08.mjs` прошли. Длины body: 10 208, 10 375 и 10 909 знаков. -- Один accepted output — `synthetic-plan-hand-off`; пять named counterexamples останавливаются fail-closed. Любой unknown input также останавливается, а accepted effect остаётся `not-attempted`. -- XML и SVG safety scan прошли без запрещённого active content. - -### Проход 3 — визуал, overlap и выпуск - -- Три схемы повторно отрендерены в 375 px: contract, coverage matrix и hand-off loop сохраняют читаемые labels и stop-границы, а captions уточняют, что это не карта реальной системы. -- Strict scan P102–P114 исключает sources, но включает code: 39 статей, 741 сравнение, `collisions: []`. -- В реестр добавлены ровно три августовские revisions; пользовательские app/data files не включены. +- node --check scripts/upgrade-2027-08.mjs — PASS. +- node scripts/upgrade-2027-08.mjs --verify-fixture — PASS, 7/7. +- npm run audit:draft -- scripts/upgrade-2027-08.mjs — PASS, 3/3 exact slugs. +- npm run audit:articles для трёх августовских slug — PASS. +- xmllint --noout, SVG safety scan и Sharp 375 px — PASS для трёх assets. +- Cross-scan body с включённым code и исключёнными source lists — без 12-словных повторов в общей партии. diff --git a/editorial/reviews/2027-09-draft.md b/editorial/reviews/2027-09-draft.md index a6078ce..c0fd23b 100644 --- a/editorial/reviews/2027-09-draft.md +++ b/editorial/reviews/2027-09-draft.md @@ -1,82 +1,38 @@ -# P115 — September 2027: «Серия для инженера, который растёт» +# Редакторское ревью: сентябрь 2027 -## Статус и граница +Пакет переписан как три самостоятельных технических материала про API-контракты. Исторические slug сохранены, но заголовки, excerpts, body и SVG рассказывают о HTTP-интерфейсах, JSON-валидации и code review API-diff. -Редакторская дата: **2026-07-31**. Период 2027-09 ещё не наступил; пакет является только **plan/scenario** с source cutoff **2026-07-31**. Он не сообщает о реальной учебной программе, репозитории, mentee, упражнении, review, feedback, прогрессе, owner, очереди или production effect. +## Три содержательных прохода -Модуль принимает только named fixed in-memory literals. Он не читает files, network, environment, clock, secrets, telemetry, system или data и не создаёт учебный процесс. Единственный accepted evaluator output — `synthetic-plan-hand-off`; в accepted и stop output всегда `productionEffect: not-attempted`. +### `editorial-2027-09-practice-mentor-series` -## Проход 1 — facts / temporal / voice +1. Факты и пример: до — фиксированный literal о навыке и отсутствии наблюдения; после — `validateCustomerResponse` с обязательными `id`, `revision`, `state`, положительной и отрицательной ветками. Причина: пример должен проверять форму HTTP-ответа, а не редакционный статус. +2. Структура и голос: до — абстрактное рассуждение о развитии; после — проблема несовместимого ответа и стоимость rollback стоят в первых двух абзацах, затем идут таблица breaking changes, причина, действие и ограничение. Причина: читателю нужен инженерный маршрут. +3. Визуал и доступность: до — «карта навыка»; после — схема `Endpoint → Форма → Потребитель` с русскими `<title>` и `<desc>`, содержательными `alt` и caption. Причина: рисунок теперь объясняет границу API, а не внутренний процесс. -| Статья | Body chars без sources | Угол | Временная граница | Источники и boundary | -| --- | ---: | --- | --- | --- | -| `editorial-2027-09-practice-mentor-series` | 9 227 | ситуация → synthetic prompt → критерий наблюдения | 2026-07-31 / 2027-09 / cutoff 2026-07-31 | RFC 2119 (March 1997) и RFC 8174 (May 2017), immutable IETF RFC; только нормативная лексика literal, не доказательство обучения | -| `editorial-2027-09-mechanism-mentor-series` | 9 169 | skill map, review loop, deliberate practice и falsifiability | та же | те же URL/version/date; не создают review, feedback, participant или model of learning | -| `editorial-2027-09-field-mentor-series` | 9 249 | hand-off самостоятельности и evidence без narrative | та же | те же URL/version/date; не назначают owner, процесс или персональную оценку | +### `editorial-2027-09-mechanism-mentor-series` -- Оба URL проверены `curl -I`: RFC 2119 и RFC 8174 вернули HTTP 200. Это первичные immutable RFC до cutoff. -- Первые два абзаца каждой статьи называют конкретную проблему и цену ошибки. У каждой статьи отдельные таблица, SVG, runnable safe example, упорядоченные действия, ограничения и следующий шаг. -- Голос 2025–2027: прагматичный техлидский разбор границ и стоимости. Нет общего мотивационного призыва, заявленного результата или фальшивого опыта. Deliberate practice используется только как ограничитель рассуждения, не как доказанная модель обучения. -- Фактологический verdict: **pass**. Temporal verdict: **pass**. Voice verdict: **pass**. +1. Факты и пример: до — evaluator для skill map; после — `validateFilterInput`, который показывает диапазон `limit`, enum `state` и безопасное значение по умолчанию. Причина: отделить JSON-форму от бизнес-инварианта и состояния. +2. Структура и голос: до — обещание карты уровня; после — три слоя валидации, матрица статусов `400/409/403`, порядок проверки и явное ограничение JSON Schema. Причина: «valid» больше не скрывает различие между документом и операцией. +3. Визуал и доступность: до — practice/review matrix; после — матрица «форма → инвариант → состояние → право» с русскими title/desc и подписью. Причина: визуальный объект должен помогать выбрать класс ошибки. -## Проход 2 — literal safety / fail closed +### `editorial-2027-09-field-mentor-series` -`mentor-series-plan-v1` принят только как `synthetic-plan-hand-off`. Factory делает JSON clone и recursive deep freeze; evaluator сравнивает input с known fixed literal. Неизвестный object не нормализуется. +1. Факты и пример: до — hand-off самостоятельности; после — `classifyApiChange`, который различает удаление поля, новое required-поле и сужение enum. Причина: code review должен иметь наблюдаемое правило классификации diff. +2. Структура и голос: до — мета-описание передачи; после — маршрут от API-diff к consumer tests, compatibility matrix, expand/contract и rollback. Причина: действие и ограничение должны следовать из риска совместимости. +3. Визуал и доступность: до — петля назначения владельца; после — схема `Diff → Класс риска → Потребители → Expand/switch/contract` с веткой остановки. Причина: схема показывает обратимость миграции и читается на узком экране. -| Literal | Ожидаемый результат | Фактический результат | -| --- | --- | --- | -| `mentor-series-plan-v1` | `synthetic-plan-hand-off` | pass | -| `mentor-series-undated-v1` | `stop-temporal-boundary-required` | pass | -| `mentor-series-implicit-skill-v1` | `stop-unnamed-or-implicit-skill` | pass | -| `mentor-series-invented-review-v1` | `stop-invented-observation-review-feedback-or-progress` | pass | -| `mentor-series-invented-feedback-v1` | `stop-invented-observation-review-feedback-or-progress` | pass | -| `mentor-series-invented-outcome-v1` | `stop-invented-observation-review-feedback-or-progress` | pass | -| `mentor-series-invented-owner-v1` | `stop-invented-autonomy-owner-or-process` | pass | +## Источники и границы применения -- Unnamed/implicit skill не допускается; `skillMap` и `exercise` обязаны быть exact named synthetic literals. -- Claimed observation, review, feedback, progress и outcome останавливаются. `autonomy: not-evaluated`, `recipient: not-assigned` и `process: not-created` не получают fallback. -- Fixture: **10/10** — 7/7 literal statuses и 3/3 article checks. `productionEffect: not-attempted` не является наблюдением о production. -- Safety/fail-closed verdict: **pass**. +- [OpenAPI Specification 3.1.1](https://spec.openapis.org/oas/v3.1.1.html), 24 октября 2024 года: структура HTTP-интерфейса; не доказывает runtime-ответ. +- [JSON Schema Core 2020-12](https://json-schema.org/draft/2020-12/json-schema-core.html), draft 2020-12: форма JSON; не проверяет права и состояние базы. +- [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), июнь 2022 года: HTTP-методы, статусы и представления; не описывает конкретный сервис. -## Проход 3 — visual / mobile / a11y / safety + strict duplicate scan +## Проверки -- Каждый SVG содержит русские `title` и `desc`; каждая статья содержит отдельные русские `alt` и `figcaption`. SVG поясняют контракт карты, матрицу review и петлю hand-off, а не выступают декоративной иллюстрацией. -- `xmllint --noout` прошёл для трёх assets. Safety scan на `script`, `foreignObject`, `javascript:`, `data:image` и event-handler attributes — empty/pass. -- Sharp создал 3 PNG `375x225`; они открыты вручную. На mobile читаемы: карта удерживает три состояния и stop, matrix — все три колонки и пять строк, hand-off — четыре gate и красный stop. Обрезаний или наложений не обнаружено. -- `npm run audit:draft -- scripts/upgrade-2027-09.mjs` — pass по трём exact slug и lengths 9 227 / 9 169 / 9 249. -- Строгий duplicate scan исключал section `Проверяемые источники`, но включал `<p>` и `<pre>`; runnable code участвовал. Прочитаны P102–P114: 39 prior articles, 120 comparisons (117 current-to-prior + 3 внутрипакетные), `hits: []`; совпадений exact paragraphs длиной 160+ и common 12-word fragments нет. -- Visual/mobile/a11y/safety verdict: **pass**. Duplicate scan verdict: **pass**. - -## Фактически выполненные команды - -```sh -cd web -node --check scripts/upgrade-2027-09.mjs -node scripts/upgrade-2027-09.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-09.mjs -xmllint --noout public/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg public/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg public/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg -rg -n -i '<(script|foreignObject)\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=' public/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg public/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg public/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg -node --input-type=module -e 'import sharp from "sharp"; /* three SVG → 375px PNG */' -curl -I https://www.rfc-editor.org/rfc/rfc2119.html -curl -I https://www.rfc-editor.org/rfc/rfc8174.html -``` - -## Disposition - -Изменены только пять разрешённых файлов P115: - -- `web/scripts/upgrade-2027-09.mjs` -- `editorial/reviews/2027-09-draft.md` -- `web/public/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg` -- `web/public/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg` -- `web/public/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg` - -Registry, README, очередь, `articles.json`, app и чужие пакеты не изменялись этой работой. Git не использовался. - -## Независимое приёмочное ревью - -- Первичные источники повторно открыты 2026-07-31: RFC 2119 подтверждает статус BCP и дату March 1997, RFC 8174 — статус BCP, дату May 2017 и уточнение смысла заглавных ключевых слов. В статьях они применены только к точности требований к literal; они не используются как источник об обучении или оценке человека. -- Повторно выполнены `node --check`, fixture (**10/10**), `npm run audit:draft` и три runnable-примера. Проверены их фактические outputs: они выводят только synthetic literals и `synthetic-plan-hand-off`; внешнего ввода и побочных действий нет. -- Три SVG прошли XML/safety scan и открыты после рендера Sharp в ширине 375 px. Текст, стрелки, stop-ветки и подписи читаемы; декоративных или небезопасных элементов нет. -- Строгий повторный duplicate scan P102–P115: **42 статьи**, **861 сравнение**, sources исключены, code включён, common 12-word fragments — `[]`. -- Приёмочный verdict: **pass**. Пакет включён в registry как 347–349-й материал; production build и публикация выполняются после общей проверки интеграции. +- `node --check scripts/upgrade-2027-09.mjs` — PASS. +- `node scripts/upgrade-2027-09.mjs --verify-fixture` — PASS, 9/9. +- `npm run audit:draft -- scripts/upgrade-2027-09.mjs` — PASS, 6140 / 6119 / 6027 body chars по отчёту audit. +- `npm run audit:articles -- editorial-2027-09-practice-mentor-series editorial-2027-09-mechanism-mentor-series editorial-2027-09-field-mentor-series` — PASS. +- `xmllint --noout` и SVG safety scan — PASS для трёх существующих assets. +- Ручная проверка Sharp 375 px выполнена после общего рендера; обрезаний и наложений нет. diff --git a/editorial/reviews/2027-10-draft.md b/editorial/reviews/2027-10-draft.md index 43c8ac4..9916e6f 100644 --- a/editorial/reviews/2027-10-draft.md +++ b/editorial/reviews/2027-10-draft.md @@ -1,83 +1,39 @@ -# P116 — октябрь 2027: «Длинное техническое интервью» +# Редакторское ревью: октябрь 2027 -## Статус, temporal boundary и изолированный scope +Пакет переписан как три технические статьи о границах HTTP-соединения: deadline, TLS-сертификат и Trace Context. Исторические slug сохранены; внутренние редакционные статусы из body удалены. -Редакторская дата: **2026-07-31**. PlanDate: **2027-10**. Source cutoff: **2026-07-31**. Пакет — только future editorial plan/scenario, не описание интервью или производства. +## Три содержательных прохода -Ни в prose, ни в fixed literals не заявлены actual interview, transcript, speaker, quote, code, metric, evidence, review, approval, ownership, publication или production effect. Не созданы fictional people, quotes, metrics или outcomes. Единственный positive evaluator output — `synthetic-plan-hand-off` с `productionEffect: not-attempted`. +### `editorial-2027-10-practice-long-form-interview` -Изменены только разрешённые файлы: +1. Факты и пример: до — synthetic assertion без HTTP-предмета; после — `allocateTimeoutBudget` с фазами DNS, TLS и чтения, а также граничным `deadline-exceeded`. Причина: runnable-код должен показывать абсолютный бюджет, не изображать интервью или процесс. +2. Структура и голос: до — рассуждение о сборе evidence; после — symptom и цена retry в начале, таблица фаз, объяснение идемпотентности, ordered diagnostic route и ограничения. Причина: общий timeout нужно разложить до выбора действия. +3. Визуал и доступность: до — claim/evidence map; после — схема deadline и фаз с русскими title/desc, alt/caption. Причина: на 375 px видно, что новая фаза получает только остаток. -- `web/scripts/upgrade-2027-10.mjs` -- `editorial/reviews/2027-10-draft.md` -- `web/public/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg` -- `web/public/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg` -- `web/public/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg` +### `editorial-2027-10-mechanism-long-form-interview` -Git не использовался. Registry, README, очередь, `articles.json`, application files и чужие пакеты не менялись. +1. Факты и пример: до — контракт утверждения без сертификата; после — `validateCertificateWindow` с ISO-временем и SAN, включая mismatch. Причина: «curl работает» нужно разбирать через предметные проверки TLS. +2. Структура и голос: до — boundary matrix для скрытого источника; после — цепочка trust, срок, hostname и алгоритм, затем безопасный маршрут диагностики без `insecure`. Причина: причина ошибки должна быть отделена от обхода проверки. +3. Визуал и доступность: до — evidence matrix; после — таблица слоёв срока, SAN, цепочки и policy с ясной красной границей. Причина: рисунок объясняет, что шифрование не равно доверию. -## Research: закреплённые источники и их границы +### `editorial-2027-10-field-long-form-interview` -1. [W3C PROV-DM: The PROV Data Model](https://www.w3.org/TR/2013/REC-prov-dm-20130430/) — W3C Recommendation, **30 April 2013**, dated immutable publication. Поддерживает vocabulary provenance: entity, activity, derivation, agent и связь происхождения с trust decision. Используется только для разделения assertion, evidence и origin. Не подтверждает future interview, speaker, transcript, code, metric, approval, review или publication. -2. [C2PA Technical Specification 1.3](https://spec.c2pa.org/specifications/specifications/1.3/specs/C2PA_Specification.html) — **version 1.3, April 2023**, version-pinned publication. Поддерживает vocabulary assertion, provenance data, validation и границу: association/tamper evidence не является value judgement о содержании. Не превращает literal в source, не доказывает claim и не создаёт ownership, approval или outcome. +1. Факты и пример: до — fabricated-record hand-off; после — `parseTraceparent` проверяет четыре поля, длину, lowercase hex и ненулевые id. Причина: пример должен валидировать Trace Context на HTTP-входе. +2. Структура и голос: до — заметка о speaker/transcript; после — symptom потерянного запроса, proxy boundary, structured logs, ordered diagnostic route и ограничение доверия. Причина: читатель получает способ найти потерянный контекст. +3. Визуал и доступность: до — editorial hand-off loop; после — `Клиент → Gateway → Service` и controlled fallback для плохого header. Причина: связь через proxy стала предметно проверяемой и читаемой на мобильной ширине. -Оба URL проверены 2026-07-31 через `curl -I -L`: HTTP 200. Дата W3C закреплена в URL; C2PA закреплена версией `1.3` и датой из version history. Обе публикации не позже source cutoff. Источники используются как нормативный язык границы, а не как substitute for evidence о будущем сценарии. +## Источники и границы применения -## Review pass 1 — facts, temporal boundary и voice +- [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), июнь 2022 года: HTTP-обмен; не измеряет фазы конкретной библиотеки. +- [RFC 8446](https://www.rfc-editor.org/rfc/rfc8446.html), август 2018 года: TLS 1.3 handshake; не задаёт local timeout policy. +- [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280.html), май 2008 года: X.509/PKIX path validation; не заменяет trust store ОС. +- [W3C Trace Context Level 1](https://www.w3.org/TR/2021/REC-trace-context-1-20211123/), 23 ноября 2021 года: формат traceparent; не задаёт storage и sampling. -- Первые два абзаца каждого текста называют самостоятельные problem/cost и сразу фиксируют будущую temporal boundary. -- **Practice** решает вопрос «какой assertion contract должен существовать до вопроса»; его артефакт — карта claim/evidence и проверка формы literal. -- **Mechanism** отдельно разбирает evidence, provenance, independent confirmation и falsifiability; его артефакт — source-boundary matrix и fail-closed states. -- **Field** отдельно разбирает редакционный hand-off без fabricated transcript/interview; его артефакт — loop передачи и record states без recipient/owner/publication. -- Во всех трёх revision есть таблица, original SVG с русскими alt/caption, runnable safe example, ordered actions, limits и следующий scope. Речь соответствует 2025–2027: короткая прагматичная русская речь наставника, цена решения и границы вместо журналистского narrative. -- Body chars без metadata, title и sources: practice — **10 078**, mechanism — **11 024**, field — **10 992**. Каждый текст находится в требуемых 10 000–13 000. +## Проверки -## Review pass 2 — safe literal и fail closed - -`FIXED_INTERVIEW_LITERALS` содержит только named fixed in-memory synthetic literals. Factory делает JSON clone и recursive deep freeze для root, `assertion`, `evidence` и `record`. Evaluator принимает только exact known literal; произвольный object закрывается `stop-unknown-fixed-literal`. - -- `long-form-interview-plan-v1` — единственный accepted case: `synthetic-plan-hand-off` и `productionEffect: not-attempted`. -- `long-form-interview-undated-v1` — `stop-temporal-boundary-required`. -- `long-form-interview-unnamed-claim-v1` — `stop-unnamed-claim`. -- `long-form-interview-hidden-source-v1` — `stop-hidden-source`. -- `long-form-interview-claimed-evidence-v1` — `stop-claimed-evidence-or-confirmation`. -- `long-form-interview-fabricated-record-v1` — `stop-fabricated-interview-record` для fabricated interview, transcript, speaker, quote, code, metric, approval, review, publication или ownership. -- `long-form-interview-disallowed-result-v1` — `stop-disallowed-positive-result`. - -Runnable examples вызывают три разные pure functions: `inspectAssertionContractLiteral`, `inspectEvidenceBoundaryLiteral`, `inspectEditorialHandoffLiteral`. Code не читает files, network, environment, clock, secrets, telemetry, system или data; не делает публикацию, не создаёт interview/transcript и не изменяет внешний state. - -## Review pass 3 — visual, mobile, a11y, safety и strict duplicate scan - -- Каждый original SVG содержит русский `title` и `desc`; article figures имеют самостоятельные русские `alt` и `figcaption`. -- `xmllint --noout` прошёл для трёх SVG. Safety scan не нашёл `script`, `foreignObject`, `javascript:`, `data:image` или inline event handler. -- Sharp создал три PNG `375×225`. Ручной 375px review: claim map сохраняет четыре шага, красный stop и зелёный hand-off; matrix сохраняет пять строк и нижнее fail-closed правило; hand-off loop сохраняет три входа, итог и запрет на fabricated record. Обрезаний, внешних изображений и нечитаемых наложений нет. -- Strict duplicate scan исключал section `Проверяемые источники`, но включал `<p>` и `<pre>`, то есть runnable code участвовал в сравнении. Проверены все **42** prior articles P102–P115 против трёх P116 и три внутрипакетные пары: **129** comparisons, `exactParagraphs160=0`, `common12WordFragments=0`, `hits=0`. - -## Фактически выполненные проверки - -Команды выполнялись из `web/`, если не указано иное: - -```sh -node --check scripts/upgrade-2027-10.mjs -node scripts/upgrade-2027-10.mjs --verify-fixture -npm run audit:draft -- scripts/upgrade-2027-10.mjs -node --input-type=module -e "/* public literal statuses, deep freeze, unknown input */" -xmllint --noout public/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg public/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg public/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg -! rg -n -i '<(script|foreignObject)\\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=' public/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg public/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg public/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg -node --input-type=module -e "import sharp from 'sharp'; /* render three SVG at width 375 */" -node --input-type=module -e "/* P102-P115 + P116, sources excluded, p/pre and 12-word grams */" -curl -I -L https://www.w3.org/TR/2013/REC-prov-dm-20130430/ -curl -I -L https://spec.c2pa.org/specifications/specifications/1.3/specs/C2PA_Specification.html -``` - -Фактические результаты: syntax — PASS; fixture — **10/10** (7 literal statuses + 3 article checks); draft audit — PASS для exact slugs и указанных body chars; public literal checks — PASS, включая deep freeze и unknown input; XML — PASS; SVG safety scan — PASS; Sharp + manual 375px review — PASS; strict duplicate scan — PASS (`42` prior articles, `129` comparisons, zero collisions); оба source URL — HTTP **200**. - -`npm run audit:draft` вывел существующие npm warnings о неизвестных user config `store-dir`, `cache-dir` и `public-hoist-pattern`; сам audit прошёл, а warnings не относятся к P116. - -## Независимое приёмочное ревью - -- Первичные источники повторно открыты 2026-07-31. PROV-DM подтверждает статус W3C Recommendation и датированную версию 30 April 2013; C2PA 1.3 подтверждает version-pinned выпуск April 2023, структуру assertion/claim/provenance и ограничение, что проверка происхождения не является value judgement. Их использование в статьях не выходит за эти границы. -- Повторно выполнены `node --check`, fixture (**10/10**), `npm run audit:draft` и три runnable-примера. Они возвращают только named fixed literal и `synthetic-plan-hand-off`; внешний ввод, сеть и запись состояния отсутствуют. -- Три SVG прошли XML/safety scan и независимый рендер Sharp на 375 px. Заголовки, матрица, переходы и stop-ветки читаемы; обрезаний, наложений и небезопасных элементов нет. -- Строгий duplicate scan P102–P116: **45 статей**, **990 парных сравнений**, sources исключены, code включён, common 12-word fragments — `[]`. -- Приёмочный verdict: **pass**. Пакет включён в registry как 350–352-й материал; production build и публикация выполняются после общей проверки интеграции. +- `node --check scripts/upgrade-2027-10.mjs` — PASS. +- `node scripts/upgrade-2027-10.mjs --verify-fixture` — PASS, 9/9. +- `npm run audit:draft -- scripts/upgrade-2027-10.mjs` — PASS, 5804 / 5532 / 5645 body chars. +- `npm run audit:articles -- editorial-2027-10-practice-long-form-interview editorial-2027-10-mechanism-long-form-interview editorial-2027-10-field-long-form-interview` — PASS. +- `xmllint --noout` и SVG safety scan — PASS для трёх существующих assets. +- Ручная проверка Sharp 375 px выполнена после общего рендера; текстовые блоки не обрезаны. diff --git a/editorial/reviews/2027-11-draft.md b/editorial/reviews/2027-11-draft.md index 06454b2..e2640fc 100644 --- a/editorial/reviews/2027-11-draft.md +++ b/editorial/reviews/2027-11-draft.md @@ -1,54 +1,39 @@ -# P117 — ноябрь 2027: «Пересмотр старых советов» +# Редакторское ревью: ноябрь 2027 -## Scope и временная граница +Пакет переписан как три самостоятельных материала о надёжности: совместимая миграция схемы, retry/idempotency и incident runbook. Исторические slug и имена assets сохранены, технические title/desc/alt/caption обновлены. -P117 — future-only редакционный сценарий: `editorDate: 2026-07-31`, `planDate: 2027-11`, `sourceCutoff: 2026-07-31`. Он не утверждает существование исторической статьи, snapshot, старого совета, документационного изменения, свежего repro, тестового прогона, коррекции, публикации, owner-а или production-результата. Любые такие предметы представлены только именованными полями будущих вопросов. Единственный положительный evaluator output — `synthetic-plan-hand-off`; во всех ветках `productionEffect: not-attempted`. +## Три содержательных прохода -Изменены ровно пять разрешённых файлов: +### `editorial-2027-11-practice-mistakes-revisions` -- `web/scripts/upgrade-2027-11.mjs` -- `editorial/reviews/2027-11-draft.md` -- `web/public/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg` -- `web/public/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg` -- `web/public/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg` +1. Факты и пример: до — вопрос о пересмотре совета без базы; после — `classifyMigrationStep` с unsafe/expand/switch/contract и совместимостью reader/writer. Причина: миграция должна иметь проверяемую матрицу, а не обещание безопасного перехода. +2. Структура и голос: до — временная линия без реальной DDL-логики; после — symptom блокировки, expand/dual write/switch/contract, таблица и порядок rollback. Причина: reader должен увидеть, где остановиться до удаления колонки. +3. Визуал и доступность: до — timeline будущей коррекции; после — техническая timeline четырёх фаз с контрольной точкой проверки данных. Причина: схема связывает время и совместимость без мета-сюжета. -Git не использовался. Registry, README, очередь, `articles.json`, app и чужие пакеты не менялись. +### `editorial-2027-11-mechanism-mistakes-revisions` -## Источники и их границы +1. Факты и пример: до — condition/correction evaluator; после — `calculateRetryDelay` с exponential cap и фиксированным jitter, включая invalid input. Причина: пример должен показывать реальный механизм backoff. +2. Структура и голос: до — абстрактная передача вопроса; после — право на retry, таблица статусов 429/503/timeout, idempotency key, deadline и порядок настройки. Причина: backoff нельзя обсуждать без семантики операции. +3. Визуал и доступность: до — матрица неизвестных полей; после — матрица сигнала, решения, задержки и риска с отдельной строкой POST timeout. Причина: визуал предотвращает чтение retry как универсального исправления. -1. [W3C PROV-DM: The PROV Data Model](https://www.w3.org/TR/2013/REC-prov-dm-20130430/) — W3C Recommendation, 30 April 2013, датированная неизменяемая публикация. HTTP `200` проверен перед написанием. Применение: терминологически отделить сущность, деятельность и происхождение записи от пересказа о якобы известном совете. Граница: не доказывает, что существовала статья, автор, совет, условие, противоречие или коррекция P117. -2. [NIST SP 800-128: Guide for Security-Focused Configuration Management of Information Systems](https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-128.pdf) — NIST SP 800-128, August 2011, dated PDF; HTTP `200` проверен 2026-07-31. Применение: язык фиксированной конфигурационной основы и контролируемого изменения, ограничивающий будущий вопрос о версии/условии. Граница: не устанавливает фактическую конфигурацию, ревизию, тест, решение, владельца или результат. -3. [RFC 3339: Date and Time on the Internet: Timestamps](https://www.rfc-editor.org/rfc/rfc3339.html) — IETF RFC 3339, July 2002, immutable RFC. Применение: дисциплина точных фиксированных дат editor date, plan date и source cutoff. Граница: не подтверждает время события, публикацию, наблюдение либо hand-off. +### `editorial-2027-11-field-mistakes-revisions` -Все источники опубликованы не позднее cutoff и используются как vocabulary/boundary, не как результат пересмотра. В HTML каждой revision у ссылки указаны exact URL, версия/дата, применение и граница. +1. Факты и пример: до — hand-off вопроса о correction; после — `classifyIncidentSignal` с приоритетом 5xx/error rate над latency и отдельной проверкой. Причина: runbook требует наблюдаемого входа и порога. +2. Структура и голос: до — текст о будущей истории исправления; после — symptom, scope, hypothesis, одно обратимое действие, recovery criteria и ordered incident route. Причина: оператор должен получить следующий безопасный шаг. +3. Визуал и доступность: до — evidence hand-off loop; после — петля `Симптом → Гипотеза → Действие → Проверка` и остановка без verification. Причина: рисунок показывает закрытие цикла по метрике. -## Проход 1 — факты, структура и безопасность модели +## Источники и границы применения -- Три независимые M10 статьи начинаются с конкретной цены: обрезанный совет и лишний повторный поиск предпосылок; ложное опровержение из смены версии; фиктивная коррекция и ложная очередь. -- Practice отвечает, как сохранить claim вместе с assumption envelope; mechanism — как разделить version, condition, contradiction и repro boundary; field — как передать вопрос о возможной коррекции без verdict-а, owner-а или статуса завершения. -- Каждая статья содержит собственную таблицу, SVG с русскими `alt`/caption, исполнимый Node-пример с отдельным named fixed in-memory literal и нумерованный маршрут. Основной body без sources: **9 619 / 9 491 / 9 023** знака. -- `assessAdviceRevisionPlan` принимает только exact known fixed literals, ничего не читает и не пишет. Отсутствующая дата, unnamed claim, unnamed condition, mutable/unpinned evidence, injected repro result, declared correction/outcome закрываются explicit stop statuses. Fixture проверяет 9 веток, включая три допустимых плановых literals. +- [PostgreSQL 16 Documentation, Modifying Tables](https://www.postgresql.org/docs/16/ddl-alter.html), версия 16: DDL и ограничения операций; не заменяет проверку конкретного кластера. +- [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), июнь 2022 года: HTTP semantics и идемпотентность методов; не задаёт retry policy. +- [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html), апрель 2012 года: 429 Too Many Requests; не выбирает задержку. +- [NIST SP 800-61r2](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf), revision 2, май 2012 года: структура incident handling; не знает локальные пороги и права. -## Проход 2 — редактура, голос и независимость - -- Голос 2025–2027: короткая прагматичная техническая речь, явная стоимость решения, comparison matrix и воспроизводимый способ остановиться. Нет универсального роста, вымышленного опыта и обещания результата. -- Future-only граница повторена смыслово, но не как общий ввод: practice работает с envelope утверждения; mechanism — с различающим вопросом и falsifiability; field — с передачей без назначения работы. -- Строгий scan исключил `Проверяемые источники`, но включил `<p>` и `<pre><code>`; он проверил exact normalised fragments длиной от 160 символов и 12-word n-grams. Сканированы **45** предыдущих статей P102–P116 и три P117-пары: **138** сравнений, `collisions: []`. - -## Проход 3 — визуал, запуск и выпусковой preflight +## Проверки - `node --check scripts/upgrade-2027-11.mjs` — PASS. -- `node scripts/upgrade-2027-11.mjs --verify-fixture` — **PASS fixture: 9/9 assertions**. -- `npm run audit:draft -- scripts/upgrade-2027-11.mjs` из `web/` — PASS для exact three slugs: **9 619 / 9 491 / 9 023** body chars. npm вывел существующие warnings о неизвестных user config `store-dir`, `cache-dir`, `public-hoist-pattern`; audit завершился успешно, warnings не относятся к P117. -- `xmllint --noout` для трёх SVG — PASS. SVG safety scan на `script`, event handlers, `javascript:`, `foreignObject`, `<image>` и external `href` — пустой/PASS. -- Sharp создал три PNG шириной **375 px**; ручной осмотр подтвердил читаемые заголовки, таблицу, подписи и отсутствие обрезания после переноса нижней красной строки timeline на две строки. - -P117 заканчивается plan-only hand-off. Любая работа с реальным советом, источником, версией, contradiction, repro, correction, owner-ом или публикацией требует отдельного authorisation scope и не может быть задним числом добавлена в этот пакет. - -## Независимое приёмочное ревью - -- Повторно проверены первичные источники. PROV-DM подтверждает датированную W3C Recommendation 30 April 2013 и различение entity, activity и provenance; NIST SP 800-128 подтверждает название и публикацию August 2011; RFC 3339 подтверждает статус Standards Track, дату July 2002 и назначение формата timestamp. В P117 они служат только vocabulary и границей метода, а не доказательством существования старого совета или его исправления. -- Повторно выполнены `node --check`, fixture (**9/9**), `npm run audit:draft` и три runnable-примера. Они возвращают только fixed future-question literals и `synthetic-plan-hand-off`; реальный совет, текущая страница, воспроизведение или correction не создаются. -- Три SVG повторно прошли XML/safety scan и рендер Sharp на 375 px. На мобильной ширине читаемы дата/границы, матрица и независимый hand-off; красные stop-ветки не обрезаны и не накладываются на данные. -- Строгий duplicate scan P102–P117: **48 статей**, **1 128 парных сравнений**, sources исключены, code включён, common 12-word fragments — `[]`. -- Приёмочный verdict: **pass**. Пакет включён в registry как 353–355-й материал; production build и публикация выполняются после общей проверки интеграции. +- `node scripts/upgrade-2027-11.mjs --verify-fixture` — PASS, 9/9. +- `npm run audit:draft -- scripts/upgrade-2027-11.mjs` — PASS, 5592 / 5389 / 5381 body chars. +- `npm run audit:articles -- editorial-2027-11-practice-mistakes-revisions editorial-2027-11-mechanism-mistakes-revisions editorial-2027-11-field-mistakes-revisions` — PASS. +- `xmllint --noout` и SVG safety scan — PASS для трёх существующих assets. +- Ручная проверка Sharp 375 px выполнена после общего рендера; стрелки и подписи помещаются в canvas. diff --git a/editorial/reviews/2027-12-draft.md b/editorial/reviews/2027-12-draft.md index 1498899..71412af 100644 --- a/editorial/reviews/2027-12-draft.md +++ b/editorial/reviews/2027-12-draft.md @@ -1,88 +1,42 @@ -# P118 — декабрь 2027: «Манифест инженерного письма» +# Редакторское ревью: декабрь 2027 -## Статус и временная граница +Пакет переписан как три самостоятельных технических материала о web security, web performance и эксплуатационной документации. Исторические slug сохранены, но никакая статья не рассказывает о внутреннем создании текста или о развитии человека. -Редакторская дата: **2026-07-31**. Декабрь 2027 ещё не наступил, поэтому P118 — только **plan/scenario** с `planDate: 2027-12` и source cutoff **2026-07-31**. Пакет не сообщает о будущих статьях, архиве, редакторе, review, feedback, corrections, публикации, author development, команде, метриках или production effect. +## Три содержательных прохода -Модуль использует только named fixed in-memory literals. Доменный evaluator не читает filesystem, network, environment, clock, secrets, telemetry, system или data; CLI-вывод существует только для локальной проверки экспортированного JSON. Единственный accepted positive output — `synthetic-plan-hand-off`; на positive и stop-ветках `productionEffect` всегда `not-attempted`. +### `editorial-2027-12-practice-author-manifesto` -## Проход 1 — evidence / temporal / voice +1. Факты и пример: до — карточка решения без web-объекта; после — `buildSecurityHeaders` с CSP nonce, `object-src none`, `base-uri self` и HSTS. Причина: runnable-пример должен показать реальную конфигурацию security headers и её границу. +2. Структура и голос: до — рассуждение о качестве текста; после — XSS/downgrade symptom, цена ошибки, карта ресурсов, таблица директив, порядок Report-Only → enforce и ограничения. Причина: reader должен понять, что именно защищает заголовок. +3. Визуал и доступность: до — граф редакционного решения; после — два слоя `CSP` и `HSTS` с веткой неизвестного ресурса. Причина: рисунок разделяет защиту ресурсов и схему соединения. -| Статья | Body chars без sources | Независимый угол | Временная граница | Источники: применение и boundary | -| --- | ---: | --- | --- | --- | -| `editorial-2027-12-practice-author-manifesto` | 10 874 | decision record до technical advice | 2026-07-31 / 2027-12 / cutoff 2026-07-31 | RFC 2119, March 1997, immutable URL; модальные границы совета, не доказательство статьи или решения. W3C PROV-DM, 30 April 2013, dated URL; происхождение claim, не история локального архива или результата. | -| `editorial-2027-12-mechanism-author-manifesto` | 11 177 | rubric: evidence levels, claims, falsifiability и три прохода | та же | RFC 8174, May 2017, immutable URL; точное чтение требований, не review или verdict. W3C PROV-DM, 30 April 2013; различение claim и provenance, не evidence P118. | -| `editorial-2027-12-field-author-manifesto` | 10 831 | corrective hand-off без invented feedback или author history | та же | RFC 2119 и RFC 8174; область действия нормативной формулировки, не owner, reviewer, publication, correction или author development. | +### `editorial-2027-12-mechanism-author-manifesto` -Проверяемые первичные/нормативные источники до cutoff: +1. Факты и пример: до — rubric без performance API; после — `classifyWebVitals` для LCP, INP и CLS с валидным и отрицательным входом. Причина: пример должен проверять предмет статьи, а не редакционный статус. +2. Структура и голос: до — три абстрактных прохода; после — три пользовательских вопроса, официальные пороги, p75 по сегменту, lab/field distinction и маршрут поиска причины. Причина: порог не должен подменять причинность. +3. Визуал и доступность: до — quality matrix; после — матрица LCP/INP/CLS с вопросом, порогом и диагностическим разрезом. Причина: мобильный читатель видит различия без длинного абзаца. -- [RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels](https://www.rfc-editor.org/rfc/rfc2119.html), IETF RFC 2119, March 1997, immutable RFC publication. Использован для явного различения требования и рекомендации. Не подтверждает событие, review, публикацию, коррекцию или effect P118. -- [RFC 8174 — Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words](https://www.rfc-editor.org/rfc/rfc8174.html), IETF RFC 8174, May 2017, immutable RFC publication. Использован для границы сильной формулировки. Не создаёт будущий редакционный процесс или результат. -- [PROV-DM: The PROV Data Model](https://www.w3.org/TR/2013/REC-prov-dm-20130430/), W3C Recommendation, 30 April 2013, dated immutable Recommendation. Использован как vocabulary происхождения утверждений. Не доказывает provenance будущего архива, review, feedback или outcome. +### `editorial-2027-12-field-author-manifesto` -`curl -I` вернул HTTP 200 для всех трёх URL. Первые два абзаца каждой статьи называют конкретную проблему и цену ошибки. Каждая статья имеет собственные таблицу, SVG с русскими `alt`/подписью, безопасный runnable example, ordered process, ограничения и следующий шаг. Голос М10 держит прагматичную рамку «условие → риск → проверка → действие»; словарь local editorial standards и voice trajectory используется только как future contract vocabulary, не как свидетельство будущего прохода. +1. Факты и пример: до — revision hand-off без операции; после — `validateRunbookCard` с шестью полями, явным rollback и observable verification. Причина: техническая документация должна проверять вход и выход действия. +2. Структура и голос: до — текст о корректирующем процессе; после — симптом, scope, precondition, action, rollback, verification, таблица решений и порядок эксплуатации. Причина: статья стала инструкцией для reader-facing работы. +3. Визуал и доступность: до — петля revision; после — `Symptom → Precondition → Action → Verify` с отдельной веткой rollback. Причина: схема показывает, что команда не равна восстановлению без метрики. -Текущий evidence/temporal/voice review plan-files: **pass**. Это результат проверки формы в 2026-07-31, не заявление о будущем редакционном результате. +## Источники и границы применения -## Проход 2 — literal safety и fail closed +- [W3C Content Security Policy Level 3](https://www.w3.org/TR/CSP3/), Working Draft: директивы CSP, nonce и режимы отчёта; статус draft требует проверки совместимости. +- [RFC 6797](https://www.rfc-editor.org/rfc/rfc6797.html), ноябрь 2012 года: HSTS; не защищает первый HTTP-переход. +- [W3C Largest Contentful Paint](https://www.w3.org/TR/largest-contentful-paint/), Working Draft, страница проверена 31 июля 2026 года: объект измерения LCP; не описывает весь UX. +- [W3C Performance Timeline](https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/), Candidate Recommendation Draft, 21 мая 2025 года: PerformanceObserver и entries. +- [Web Vitals](https://web.dev/articles/vitals), опубликовано 4 мая 2020 года, обновлено 31 октября 2024 года: пороги LCP/INP/CLS и p75 по сегментам. +- [NIST SP 800-61r2](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf), revision 2, май 2012 года: общая структура incident handling; не задаёт локальные команды. +- [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html), март 1997 года: уровни обязательности в техническом языке; не даёт разрешение выполнять операцию. -`author-manifesto-plan-v1` — единственный accepted fixed literal. Factory делает JSON-clone и recursive deep freeze. Evaluator принимает literal только при exact JSON equality с закрытым named набором; неизвестный object не нормализуется и не получает fallback. +## Проверки -| Fixture literal | Ожидаемый статус | Фактический статус | -| --- | --- | --- | -| `author-manifesto-plan-v1` | `synthetic-plan-hand-off` | pass | -| `author-manifesto-undated-v1` | `stop-temporal-boundary-required` | pass | -| `author-manifesto-unknown-source-v1` | `stop-unknown-source-set` | pass | -| `author-manifesto-unknown-question-v1` | `stop-unknown-question` | pass | -| `author-manifesto-hidden-review-v1` | `stop-hidden-review-input` | pass | -| `author-manifesto-declared-quality-v1` | `stop-declared-quality-pass-publication-correction-or-outcome` | pass | -| `author-manifesto-disallowed-output-v1` | `stop-disallowed-positive-result` | pass | -| ad-hoc unknown object | `stop-unknown-fixed-literal` | pass | - -- Exact editor date, plan date и source cutoff обязательны; unknown source/date/question fail closed. -- `reviewInputs.evidence`, `temporal` и `voice` обязаны остаться `not-provided`; hidden review input останавливает путь. -- `quality`, `pass`, `publication`, `correction` и `outcome` обязаны остаться `not-declared`; объявленный future result останавливает путь. -- Fixture: **11/11** — 8 literal statuses и 3 article checks. `productionEffect: not-attempted` сохраняется в каждом output. - -## Проход 3 — visual / mobile / a11y / safety и duplicate scan - -- SVG имеют русские `<title>` и `<desc>`; в каждой статье есть отдельные русские `alt` и `figcaption`. Граф решения, matrix рубрики и loop hand-off объясняют разные механизмы, а не иллюстрируют выдуманную публикационную историю. -- `xmllint --noout` прошёл для всех трёх SVG. Safety scan для `<script>`, `<foreignObject>`, `javascript:`, `data:image` и inline event-handler attributes вернул пустой output: pass. -- Sharp отрендерил 3 PNG шириной **375 px**. Ручной просмотр фактических рендеров: граф решения сохраняет входы, positive и stop path; matrix сохраняет три читаемые строки проходов; hand-off loop сохраняет разрыв перед выдуманным review. Обрезаний и наложений не обнаружено. -- `npm run audit:draft -- scripts/upgrade-2027-12.mjs` прошёл для трёх exact slug и body lengths 10 874 / 11 177 / 10 831. npm вывел существующие предупреждения об unknown user config `store-dir`, `cache-dir`, `public-hoist-pattern`; audit завершился успешно. -- Strict duplicate scan исключал section `Проверяемые источники`, но включал `<p>` и `<pre>`, поэтому runnable code участвовал. P102–P115 взяты из `upgrade-2026-08.mjs` … `upgrade-2027-09.mjs`; P116–P117 — из существующих body в `articles.json` только для read-only comparison. Сканированы **48** prior articles; выполнено **147** comparisons: 144 P118-to-prior и 3 внутренних. Exact paragraphs длиной 160+ и common 12-word fragments: `hits: []`. - -## Фактически выполненные команды - -```sh -node --check web/scripts/upgrade-2027-12.mjs -node web/scripts/upgrade-2027-12.mjs --verify-fixture -cd web && npm run audit:draft -- scripts/upgrade-2027-12.mjs -xmllint --noout public/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg public/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg public/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg -rg -n -i '<(script|foreignObject)\b|javascript:|data:image|(?:^|[[:space:]])on[a-z]+=' public/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg public/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg public/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg -node --input-type=module -e 'import sharp from "./web/node_modules/sharp/lib/index.js"; /* SVG → 375 px PNG */' -curl -I https://www.rfc-editor.org/rfc/rfc2119.html -curl -I https://www.rfc-editor.org/rfc/rfc8174.html -curl -I https://www.w3.org/TR/2013/REC-prov-dm-20130430/ -node --input-type=module -e '/* P102–P117 source-excluded, code-included duplicate scan */' -``` - -## Disposition - -Изменены только разрешённые P118-файлы: - -- `web/scripts/upgrade-2027-12.mjs` -- `editorial/reviews/2027-12-draft.md` -- `web/public/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg` -- `web/public/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg` -- `web/public/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg` - -Git не использовался. Registry, README, очередь, `articles.json`, app и чужие пакеты не изменялись. P118 остаётся плановым пакетом: будущая review, publication, correction или effect потребуют собственного authorised scope и не могут быть добавлены к этому сценарию задним числом. - -## Независимое приёмочное ревью - -- RFC 2119 и RFC 8174 повторно сверены как BCP-публикации March 1997 и May 2017; PROV-DM — как dated W3C Recommendation 30 April 2013. В финальной серии они используются только для точности модальных требований и терминологии происхождения, не как доказательство авторского развития или будущего выпуска. -- Повторно выполнены `node --check`, fixture (**11/11**), `npm run audit:draft` и все три runnable-примера. Каждый возвращает только named fixed literal с `synthetic-plan-hand-off`; никакой review, публикация, correction или outcome не создаются. -- Три SVG прошли XML/safety scan и независимый рендер Sharp на 375 px. Все диаграммы содержат читаемые заголовки, входы, hand-off и stop-ветки; обрезаний, наложений и внешнего содержимого нет. -- Независимый strict scan сначала обнаружил общий 12-словный фрагмент с P116 в первом абзаце practice-статьи. Формулировка переписана, затем скан P102–P118 повторён: **51 статья**, **1 275 парных сравнений**, sources исключены, code включён, common 12-word fragments — `[]`. -- Приёмочный verdict: **pass**. Пакет включён в registry как 356–358-й материал; production build и публикация выполняются после общей проверки интеграции. +- `node --check scripts/upgrade-2027-12.mjs` — PASS. +- `node scripts/upgrade-2027-12.mjs --verify-fixture` — PASS, 9/9. +- `npm run audit:draft -- scripts/upgrade-2027-12.mjs` — PASS, 5710 / 5476 / 5686 body chars. +- `npm run audit:articles -- editorial-2027-12-practice-author-manifesto editorial-2027-12-mechanism-author-manifesto editorial-2027-12-field-author-manifesto` — PASS. +- `xmllint --noout` и SVG safety scan — PASS для трёх существующих assets. +- Ручная проверка Sharp 375 px выполнена после общего рендера; title, подписи и стрелки не обрезаны. diff --git a/web/public/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg b/web/public/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg index ac94e60..34a3896 100644 --- a/web/public/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg +++ b/web/public/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg @@ -1,7 +1,39 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="720" height="590" viewBox="0 0 720 590" role="img" aria-labelledby="title desc"> - <title id="title">Матрица обязанностей контрактаЧетыре строки различают query, command, read model и problem envelope. Красная нижняя строка запрещает доменный вывод из отправленной команды. - Boundary: право на вывод - ФормаДаёт UIНе даёт UI - queryвопрос к modelизменение domaincommandintentуспешный фактread modelstatus + actionsскрытую причинуproblemcode + retryдогадку о retry - Отправка command ≠ новый domain factМатрица для plan review; не описание реального API. + + HTTP результат и следующий шаг UI + Таблица-схема связывает 2xx, 409 и 422, 429 и 5xx с разрешённым действием интерфейса и показывает, что отправка команды сама по себе не меняет модель экрана. + + + Статус задаёт ветку, не весь смысл + каждому результату — свой следующий шаг + + + + + результат + что известно + следующий шаг UI + + + + + + 200 + JSON + valid representation + render from model + + + + 409 / 422 + domain rejection + show reason or refetch + + + + 429 / 5xx + temporary failure + bounded retry policy + + + command sent ≠ screen state changed: read the next model + diff --git a/web/public/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg b/web/public/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg index 7632fc1..0e17973 100644 --- a/web/public/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg +++ b/web/public/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg @@ -1,9 +1,39 @@ - - Цикл evidence для synthetic hand-offПять шагов соединяют fixed literal, проверку ownership, stop или counterexample, уточнённый literal и ограниченный synthetic hand-off. Красная ветка возвращает domain claim к read model. - - Synthetic evidence loop - fixed literalownership checknamed resultreview hand-offnew literal - - domain claim → stop → read question - Positive output: только review hand-off.productionEffect: not-attemptedНет UI trace, сети, файлов или секретов. + + Диагностика рассинхрона UI и API + Четыре наблюдения сравниваются последовательно: намерение, запрос, ответ и вход в рендер. Несовпадение ведёт к проверке соответствующей границы. + + + Сравниваем соседние границы + intent → request → response → render input + + + + + + intent + action + request + HTTP + response + contract + render + input + + + + + + + + + + mapping формы + и безопасный input + route, status, + Content-Type + stale state, + selector или race + + Снимок одного слоя не доказывает причину расхождения в другом. + diff --git a/web/public/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg b/web/public/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg index b19df84..e7dca96 100644 --- a/web/public/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg +++ b/web/public/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg @@ -1,9 +1,35 @@ - - Карта владельцев состоянияТри колонки разделяют локальное состояние UI, контекст и API read model. Команда направлена к API, доменный факт не находится в UI. - - Карта владельцев: план на сентябрь - UIdraft-filteropen-panelне владеетdomain fact - Contextfixed-localepermissionусловиеинтерпретации - APIstatusallowedActionsmessageCoderead modelи problem - command intentFixed synthetic model; не trace и не факт системы. + + Проверка ответа перед рендером + Последовательность из HTTP status, Content-Type, JSON parse, screen model validator и рендера. Ошибка контракта ведёт в отдельную ветку остановки. + + + До рендера доходят только проверенные данные + transport → format → shape → view + + + HTTP + status + + Content- + Type + + JSON + parse + + screen + validator + + view + + + + + + + ошибка контракта + остановить render и записать код + + + HTTP 200 сообщает об обмене; validator решает, можно ли использовать representation. + diff --git a/web/public/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg b/web/public/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg index 9d29719..7a5de94 100644 --- a/web/public/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg +++ b/web/public/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg @@ -1,33 +1,16 @@ - - Граф решения перед техническим советом - Три входа: вопрос, проверяемый источник и граница вывода. Только точные literals ведут к синтетической передаче плана. Неизвестный источник или заявленный результат ведут к остановке. - - - - - - - Совет начинается с карточки решения - P118: форма вопроса, а не история будущего выпуска + + Граф настройки CSP и HSTS + Карта security headers связывает список ресурсов с CSP, HTTPS-ответ с HSTS, а неизвестный ресурс ведёт к блокировке или отдельной проверке. + + + Security headers: два независимых слоя + CSP ограничивает ресурсы, HSTS закрепляет HTTPS - - 1. Вопрос - Какое решениенельзя принять? - - 2. Источник - Дата, URL, версияи предел применения - - Exact literal - граница выводабез review и outcome - - synthetic-plan-hand-off - productionEffect:not-attempted - - STOPunknown / declared + Ресурсы страницыscripts · styles · framesself + nonce + CSPdefault-src selfobject none · base-uri + HSTSHTTPS onlymax-age + subdomains + + unknown resource → block/reportне отключать verify ради «зелёного» ответа + - - - - - unknown source / claim diff --git a/web/public/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg b/web/public/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg index fc4b14f..fef8e6c 100644 --- a/web/public/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg +++ b/web/public/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg @@ -1,20 +1,15 @@ - - Матрица рубрики качества инженерного текста - Три независимых прохода: evidence, temporal и voice. Каждый требует названный вход; неизвестный вход или заявленный pass приводят к остановке, а не к оценке качества. - - Три прохода не складываются в общий stamp - Каждый проход проверяет свой риск и может остановить hand-off + + Матрица web performance metrics + Матрица разделяет LCP, INP и CLS по пользовательскому вопросу, порогу и диагностическому разрезу. Один хороший показатель не закрывает остальные. + + LCP, INP и CLS отвечают на разные вопросы + Порог помогает triage, но не называет причину - - ПроходЧто различаетStop без входа - - Evidenceclaim / источник / boundarynot-providedне становится verdict - - Temporaleditorial date / plan / cutoffunknown dateзакрывает маршрут - - Voiceпроблема / цена / действие / limitclaimed passне подменяет review - - Только все named fixed states → synthetic-plan-hand-off + МетрикаВопросПорогРазрез + + LCPкрупный контентпоявился?≤ 2500 msTTFB + resource + INPклик получил ответ?≤ 200 mslong task + CLSlayout не сдвинулся?≤ 0.1element + space + p75 по одинаковому сегменту → затем действие - diff --git a/web/public/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg b/web/public/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg index 268a028..5d48737 100644 --- a/web/public/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg +++ b/web/public/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg @@ -1,24 +1,18 @@ - - Петля корректирующей передачи без выдуманного review - Вопрос и временная граница проходят только при отсутствии review inputs и деклараций качества. Любое заявленное review, публикация, correction или outcome останавливает передачу. - - - Корректирующий hand-off не выдумывает петлю revision - P118 передаёт границу вопроса и останавливается перед feedback - - - Вопросclaim остаётсявопросом - - Границадаты и cutoffexact literal - - Hand-offnot-declarednot-attempted - - STOPclaimed result + + Петля эксплуатационной инструкции + Симптом, scope и precondition ведут к одному обратимому действию, затем к проверке метрики и условному откату. + + + Runbook: вход → действие → проверка + Команда не считается завершённой без наблюдаемого результата + + Symptomметрика + scope + Preconditionдоступ + версия + Actionодин шаг + Verifyметрика + окно + + + проверка не пройдена → rollback + - - - - новый scope решает, нужен ли review - - review / publication / correction / outcome diff --git a/web/public/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg b/web/public/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg index 1807ba0..10bb758 100644 --- a/web/public/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg +++ b/web/public/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg @@ -1 +1,16 @@ -Дерево сохранить, обернуть, заменитьРусская схема планового выбора для synthetic legacy-контракта с остановками при отсутствии даты, версии и evidence.План 2027-02: сохранить → обернуть → заменитьsynthetic contractи version boundary названы?СОХРАНИТЬне менять без фактао legacy-правилеОБЕРНУТЬназвать будущуюграницу адаптераЗАМЕНИТЬтолько future hand-offнет даты → STOPнет evidence → STOP + + Дерево решения для Bitrix legacy + Схема связывает callers, побочные эффекты и тесты с выбором сохранить вызов, добавить адаптер или заменить реализацию. + + + Bitrix legacy: сохранить → обернуть → заменить + Проверить callers и контракттесты · поля · побочные эффекты + + СОХРАНИТЬэффекты ещё не ясны + ОБЕРНУТЬcallers несколько + ЗАМЕНИТЬконтракт и тесты готовы + + добавитьcharacterization test + canonical input/output + regression и rollback + diff --git a/web/public/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg b/web/public/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg index c591453..584b3d4 100644 --- a/web/public/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg +++ b/web/public/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg @@ -1 +1,16 @@ -Петля передачи миграционного evidenceРусская схема передаёт synthetic вопрос будущему владельцу и блокирует несуществующий migration test или положительный результат.Петля evidence: передать вопрос, не отчётfixed questionevidence: not-collectedfuture ownerотдельный scope?вернуть hand-offбез production actionСТОПtest/releaseзапрет claimmigration succeededЗелёная ветка: synthetic-plan-hand-off. Красная: evidence не становится фактом от желания. + + Цикл проверки миграции Bitrix-поля + Цикл соединяет mapping, нормализацию, запись, повторное чтение и повторный запуск. Ошибка в пустом значении возвращает поле на уточнение. + + + Миграция поля: запись не равна проверке + Legacy inputPERSONAL_PHONE + Mappingphone · email + Writeодин стабильный ID + + Read-backсравнить смысл + Retryне создать дубль + + Проверить пустотуmissing ≠ clear + Вход, read-back и повтор должны иметь один понятный mapping. + diff --git a/web/public/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg b/web/public/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg index 408b876..4991fef 100644 --- a/web/public/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg +++ b/web/public/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg @@ -1 +1,13 @@ -Матрица границы версии и инвариантаРусская матрица отделяет имя вызова от synthetic контракта и version boundary; пропуски возвращают stop.Граница версии: имя вызова не равно инвариантуУровеньЧто известноВыводСтатусИмя вызоватолько лексемаинвариант не доказанСТОПКонтрактnamed syntheticplaceholderпередать вопросбез API-фактаHAND-OFFВерсияboundary названабез номеране обещатьсовместимостьСТОПКрасный: нет основания усиливать claim. Зелёный: только synthetic plan hand-off. + + Матрица поверхности Bitrix API + Матрица отделяет подключение модуля, доступные методы, mapping полей и семантику ошибки перед заменой API. + + Bitrix API: имя метода не равно контракту + СлойПроверяемРезультатДальше + + МодульIncludeModuleверсия установкизагруженискать surface + МетодCUser или UserTableоперация доступнанайденсверить поля + Поляtype, empty, missingmapping операциисовпализапустить тест + Семантикаошибка, event, IDread-backнеясноостановитьзамену + Поверхность API подтверждается только после проверки среды и смыслового результата операции. + diff --git a/web/public/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg b/web/public/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg index 66f8653..432a0ff 100644 --- a/web/public/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg +++ b/web/public/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg @@ -1,8 +1,13 @@ - - Матрица сравнимых условий frontend-сборкиРусская таблица связывает вход, конфигурацию, кэш, CI и артефакт с будущим evidence. Нижняя красная строка сообщает, что скрытое условие и положительный вывод останавливают сценарий. - - Сравнимость: condition до числа и выводаПлан 2027-04 · все исторические evidence остаются not-collected - СлойСейчас можно назватьНужно future evidenceНельзя вывести - Inputsynthetic shapeграница входачто приложения равныConfigurationдва placeholderflags и provenanceэквивалентностьCachenot-collectedkey и statehit или ускорениеCIcondition questionrunner и boundaryсравнимый timingArtifactartifact questionmanifest и составменьший bundle - STOP: missing comparable condition, hidden configuration/cache assumption или positive resultРазрешён только synthetic-plan-hand-off; productionEffect: not-attempted. + + Матрица сравнимых условий frontend-сборки + Таблица связывает исходный граф, конфигурацию, окружение, cache и output с проверкой сопоставимости замеров. + + Сравнимость: условие до числаОдинаковый input · конфигурация · cache state · output + СлойФиксируемПроверяемЕсли различается + Inputcommit, lockfile, entryfingerprintисключить замер + Configmode, flags, pluginsnormalized digestразные работы + Cachecold/warm, key, dirhit и причина missразделить серии + RunnerNode, OS, CPUпорядок запускане смешивать цифры + Outputchunks, maps, compressionmetafile и размерыуточнить budget + СТОП: другой вход или cache state не даёт честного сравненияСначала исправить условия, затем обсуждать скорость. diff --git a/web/public/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg b/web/public/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg index c01ef24..4ba6922 100644 --- a/web/public/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg +++ b/web/public/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg @@ -1,13 +1,14 @@ - - Временная схема планового сравнения конфигураций frontend-сборкиРусская схема ведёт от даты и двух synthetic конфигураций через условие сравнения к передаче вопроса будущему владельцу. Красные блоки останавливают недатированный сценарий и положительный результат. - - 2027-04: сначала конфигурации, затем evidencesource cutoff 2026-07-31 · future scenario · productionEffect: not-attempted - 1. Граница времени2027-04 / cutoffне report - 2. Две стороныbaseline / candidatesynthetic names - 3. Условиеinput + output + CInot-collected - 4. Hand-offfuture ownerбез winner + + Последовательность сравнения frontend-сборки + Схема ведёт от исходного fingerprint через одинаковую конфигурацию и cache state к замеру и разбору output. + + Сборка: сначала условия, затем секундомер + 1. Inputcommit + lockfileentry fingerprint + 2. Configmode + flagsplugins + target + 3. Cachecold/warm + keyодинаковая директория + 4. Замерdurationoutput bytes - STOP: нет датыundated future scenarioне становится планом - STOP: «стало лучше»нет config, artifact, timing,cache hit или migration result - Исторические config/CI-артефакты и результаты — только not-collected shape, не данные. + СТОП: fingerprint другойне считать цифры одной серией + 5. Output diffmetafile, chunks, mapsзатем browser check + diff --git a/web/public/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg b/web/public/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg index 811495e..c097e4d 100644 --- a/web/public/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg +++ b/web/public/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg @@ -1,12 +1,13 @@ - - Петля передачи синтетического evidence для frontend-сборкиРусская диаграмма показывает путь от синтетического вопроса к проверке даты, двух конфигураций и условия сравнения. Ошибка уходит в красный stop; корректная форма передаётся будущему владельцу без результата production. - - Synthetic evidence hand-off: вопрос не становится отчётом2027-04 scenario · source cutoff 2026-07-31 · no build report - Входfixed in-memory literalnot-collected shape - Проверка даты2027-04 / cutoffне retrospective - Сравнимостьдве configurationnamed condition - Передачаfuture ownernot-attempted + + Цикл разбора роста bundle + Диаграмма показывает переход от двух metafile к delta input, chunk, source map и браузерной проверке результата. + + Bundle вырос: ищем input, а не виновника + Доmetafile + outputinput bytes + Послетот же fingerprintновый output + Deltatop inputschunk и import + Проверкаmap + networkcache и transfer - STOP: дата отсутствует, condition не назван или configuration/cache скрытыЗапрещено: cache hit, build timing, bundle size, migration или «сборка улучшена».Следующий ход — уточнить вопрос, а не заполнить пробел предположением. - Допустимый output: synthetic-plan-hand-off → future evidence owner + СТОП: input различается или карта не совпадаетНе делать вывод о performance по размеру одного файла.Сначала вернуть сравнение к одинаковым условиям. + diff --git a/web/public/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg b/web/public/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg index 7218115..3586e46 100644 --- a/web/public/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg +++ b/web/public/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg @@ -1 +1,12 @@ -Матрица ограничений и опровержимости runtime trade-offТаблица связывает четыре именованных класса ограничений с будущим evidence и стоп-сигналами. Нижняя красная строка запрещает скрытую конфигурацию benchmark и результат-победитель.Runtime trade-off: ограничение → evidence → falsificationОграничениеМеханизм вопросаНужный future evidenceСтоп-сигналLatencyграница операциии budgetполная configне тот путьOwnershiplifecycle и APIboundarycontract provenanceскрытый aliasDeliverycompiler, runtime,packagingtarget и flagsнесовместимостьObservabilityдопустимый inputи доступсогласованный ownerнет provenanceСейчас: benchmark = not-run; configuration/result = not-collectedСкрытая конфигурация, неназванное ограничение и заявленный winner возвращают fail-closed stop. + + Матрица границы D и C API + Таблица связывает атрибут безопасности D, проверку указателя и длины, владельца памяти и допустимый интерфейс C. + + D и C: ограничение → проверка → безопасный интерфейс + УровеньЧто проверяемКто владеет правиломОшибка + @safeограниченные операциии типы данныхкомпилятор + тестдовериться C + @trustedpointer, length, lifetimeдо вызоваавтор wrapperслишком большой scope + @systemraw pointer и ABIвручнуюreview + sanitizerнет контракта + slicecapacity и диапазонвместе с даннымиграница Dlength извне + СТОП: pointer, length или lifetime не имеют доказанного контрактаНе расширять @trusted; вернуть данные к boundary. + diff --git a/web/public/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg b/web/public/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg index ec9f377..2b30407 100644 --- a/web/public/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg +++ b/web/public/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg @@ -1 +1,12 @@ -Петля synthetic evidence hand-offПроцесс проверяет дату, workload, constraints и состояние benchmark. При ошибке возникает красный stop, при корректном fixed literal вопрос передаётся будущему владельцу evidence без production effect.Synthetic evidence hand-off: что можно передать в 2027-03Вход2027-03 / cutofffixed literalИменаworkload + constraintssynthetic questionsBenchmarknot-runnot-collectedHand-offfuture evidence ownereffect: not-attemptedSTOPundated, unnamed, hidden configили declared winner + + Цикл проверки D и C ABI + Процесс проверяет размер пакета, порядок байтов, обязательные поля и код возврата до вызова C и после него. + + FFI-проверка: пакет должен совпасть с ABI + Пакетbyte lengthверсия и поля + Layoutoffset, alignmentendianness + C-вызовextern(C)код возврата + Результатread outputпо версии + СТОПразмер, байты или полене совпали с header + + diff --git a/web/public/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg b/web/public/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg index 676cc83..3be179c 100644 --- a/web/public/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg +++ b/web/public/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg @@ -1,12 +1,12 @@ - - Карта планового сравнения runtime для DСхема ведёт от synthetic нагрузки и ограничений к вариантам D и не-D, а затем к безопасной передаче вопроса. Красные блоки останавливают default, скрытый benchmark и объявление победителя. - - D в 2027-03: карта вопроса, а не выбор runtime - 1. Нагрузкаnamed synthetic request shapeне сервис, не profile - 2. Ограниченияlatency / ownershipdelivery / observability - 3. ВариантыD рядом с альтернативамибез языка по умолчанию - 4. Benchmarknot-runconfiguration: not-collected - 5. Hand-offproductionEffect: not-attempted - STOPdefault без контекстаhidden config или winner - + + Карта выбора D для прикладной утилиты + Схема ведёт от нагрузки и ограничений к сравнению D с текущим языком, затем к прототипу и измеряемому решению. + + D: сначала нагрузка и границы, затем выбор языка + 1. Workloadthroughput, latencyразмер данных + 2. Ограниченияnative boundarytargets и доставка + 3. СравнениеD рядом с текущим языкомодинаковый вход и output + 4. Прототипcompiler, profile, binaryвремя и стоимость поддержки + СТОПнет workload или бюджетаязык не является лечением + diff --git a/web/public/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg b/web/public/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg index b620dcc..084b4c7 100644 --- a/web/public/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg +++ b/web/public/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg @@ -1,26 +1,24 @@ - Карта развития плановой web-диагностики - Временная шкала показывает, как от синтетического симптома перейти к именованной гипотезе, плановым сигналам и безопасной передаче вопроса. Красные блоки обозначают остановки. + Маршрут диагностики web-запроса + Схема ведёт от внешнего симптома через конверт запроса и различающий сигнал к проверяемому действию. Отдельная ветка показывает разрыв цепочки. - Плановая карта диагностики на 2027-01 - source cutoff 2026-07-31 · инструменты меняются, границы вывода остаются - - - 1 - Симптомsynthetic вопрос - 2 - Гипотезаопровержимая - 3 - Сигналыplanned-input - 4 - Hand-offnot-attempted + + Web-диагностика: от симптома к проверке + Один запрос · один различающий сигнал · одно действие + + + 1 + Симптом502, 403, пустой DOM + 2 + Конвертroute · time · request-id + 3 + Сигналразделяет причины + 4 + Действиелог, trace или DOM - - - STOP: нет именисигнала или гипотезы - - - STOP: evidenceсильнее plan input - - Вывод не сильнее входа: никакого root cause, отчёта или production effect + + + СТОП: нет ключа связине соединять событиятолько по времени + + Вывод ограничен наблюдениемсначала найти слой, потом менять код diff --git a/web/public/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg b/web/public/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg index 740352d..1e66888 100644 --- a/web/public/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg +++ b/web/public/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg @@ -1,21 +1,17 @@ - Петля synthetic debugging hand-off - Диаграмма показывает переход от синтетического симптома к гипотезе и плановым сигналам. Неназванные поля, evidence сильнее input и позитивный вывод ведут в stop, а допустимый вариант передаётся будущему владельцу evidence. - + Петля проверки гипотезы + Диаграмма соединяет внешний симптом, две проверяемые гипотезы и сигнал, который отличает их. При отсутствии записи цепочка возвращается к качеству журналирования. + - Synthetic hand-off: вопрос, а не кейс - 2027-01 · source cutoff 2026-07-31 · productionEffect not-attempted - - Симптомnamed question - Гипотезаnamed alternative - Сигналыplanned-input - - - Synthetic plan hand-offfuture evidence owner - - STOPнет имени / даты - - STOPevidence > input - - Запрещённый positive conclusion: root-cause-confirmed + + Петля: гипотеза должна изменить проверку + Симптомedge 502 + Гипотезаgateway или app + Сигналодинаковый request-id + + Решениеследующий конкретный запрос + + Разрывпроверить collector + + Идентификатор связывает записи, но причинность требует времени, статуса и границы операции. diff --git a/web/public/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg b/web/public/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg index 14f2a47..63871d1 100644 --- a/web/public/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg +++ b/web/public/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg @@ -1,17 +1,14 @@ - Роли сигналов и границы инструментов - Таблица сравнивает плановые роли лога, trace и synthetic контракта. Последняя колонка показывает, какие выводы запрещены без нового evidence. + Инструмент и граница вывода + Таблица сопоставляет log, span, metric и trace-id с полезным вопросом и ограничением вывода. - Сигнал, инструмент, граница inference - План 2027-01 · нет реальных логов, trace или метрик - - - Форма inputМожно назватьНужно ещёНельзя вывести - Логschema, полеprovenanceединственную причину - Tracecarrier, связьграница propagationпричинность - Метрикавопрос агрегацииокно и populationконкретный путь - Контрактбудущий inputscope и ownerчто evidence собран - - - Связь по id помогает задать вопрос, но сама не доказывает root cause + + Log, span и metric отвечают на разные вопросы + ИсточникПолезный вопросПроверить рядомНе выводить + + Logчто произошло в процессе?время, schema, sourcerequest-idполную причину + Spanкакая операция и сколько?parent, status, attributessampling, asyncчто она вызвала всё + Metricкак ведёт себя population?окно, labels, p95путь запросаодин request + Trace-idгде искать общий контекст?propagation chainпропуски записиполную цепочку + Совпадение ID — начало разбора, а не доказательство причинности diff --git a/web/public/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg b/web/public/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg index 0cb9f79..3fd8660 100644 --- a/web/public/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg +++ b/web/public/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg @@ -1,11 +1,17 @@ - - Петля передачи synthetic evidence для HTTP и TLSСхема проводит датированный protocol/version question через проверки evidence, скрытого метода и запрещённого положительного заключения к будущему владельцу. Все красные ветви заканчиваются stop. - - Synthetic evidence hand-off: передать предел, не отчёт - 1. План2027-05 / cutoffnamed protocol/version - 2. Evidence limitnot-collectedinput-bounded - 3. Method limitcommand: not-runtrace/config: not-collected - 4. Hand-offfuture evidence ownerproductionEffect: not-attempted - STOPundated / unnamed / stronger evidence / hidden methodили positive network conclusion - + + Безопасная передача сетевой ошибки + Цикл диагностики очищает вывод, фиксирует этап и проверяет одну гипотезу. + + Карточка сетевой ошибки + + Собратьметод · этап + Очиститьtoken · cookie + Сверитьлог · статус + Повторитьодин тест + + + + + + Факт и гипотеза хранятся раздельно. diff --git a/web/public/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg b/web/public/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg index 10f6fca..9444179 100644 --- a/web/public/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg +++ b/web/public/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg @@ -1,12 +1,29 @@ - - Карта вопроса HTTP и TLS на май 2027Схема отделяет HTTP-семантику, framing, TLS handshake и policy клиента. Они передают только синтетический вопрос будущему владельцу; красные блоки запрещают endpoint, команду и вывод о соединении. - - HTTP и TLS: карта будущего вопроса, не сетевой отчёт - HTTP semanticsmethod / status / headerне observed message - HTTP framingnamed version questionне wire bytes - TLS handshakenamed TLS boundaryне certificate/result - Client policytrust / proxy questionне hidden config - Hand-off2027-05 / cutoffproductionEffect: not-attempted - STOPendpoint, command, trace, config или «соединение успешно» - + + Уровни HTTP и TLS + Диагностическая цепочка от имени и порта через TLS к HTTP и обработчику приложения. + + Где остановился запрос + + + DNS / TCP + имя + и порт + + + TLS + hostname + и SAN + + + HTTP + метод + путь · статус + + + handler + + + Правило проверки + Статус HTTP подтверждает TLS, но не наоборот. + Фиксируем первый слой, о котором есть наблюдение. diff --git a/web/public/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg b/web/public/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg index efbb4b8..a9e5717 100644 --- a/web/public/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg +++ b/web/public/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg @@ -1,16 +1,14 @@ - - Матрица границ inference для HTTP и TLSТабличная схема показывает, какое доказательство требуется для HTTP-семантики, framing, TLS negotiation и client policy, а также запрещает вывод о соседнем уровне. - - Симптом не равен причине: матрица допустимого inference - - Класс вопросаНужное evidenceЧто не следуетСейчас - - HTTP semanticsразрешённый messagecontextTLS причина, endpointили peer stateназвать rule - - HTTP framingversioned wiremappingheader meaning илиclient policyназвать version - - TLS negotiationразрешённыйhandshake materialHTTP status илиapplication outcomeназвать boundary - - Client policyявная clientconfigurationidentity peer илиnetwork faulthand-off only - Красные ячейки: запрещённый вывод о соседнем уровне. P111 не содержит наблюдений или diagnosis. + + Матрица сетевых симптомов + Три симптома связаны с уровнем запроса и первой проверкой. + + Симптом → граница → проверка + + + СимптомГраницаПервое действие + ошибка имениTLSсверить SAN + 404 Not FoundHTTPпроверить путь + 503 Service Unavailableобработчикпрочитать Retry-After + + Не переносим ответ из одной строки в другую. diff --git a/web/public/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg b/web/public/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg index 9ef0a4f..9d8ae14 100644 --- a/web/public/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg +++ b/web/public/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg @@ -1,16 +1,17 @@ - - Карта контракта утверждения - Именованное синтетическое утверждение проходит проверку источника, происхождения, независимости и опровержимости. Скрытый или отсутствующий слой останавливает вывод. - - Контракт утвержденияP116: форма проверки, не факт - 1. Claimnamed literalне ответ - 2. Sourcenot-collectedhidden → stop - 3. Originactivity + timeне из prose - 4. Checkother pathfalsifier - - STOPunnamed · hidden · claimed - HAND-OFFnot-attempted - - not-collected и not-tested сохраняют неизвестность. - + + HTTP deadline и фазы запроса + Общий deadline распределяет оставшееся время между DNS, TLS и чтением ответа. Просроченная фаза возвращает контролируемую ошибку. + + + Общий deadline, отдельные фазы + Новый таймер не начинается после истечения границы операции + + Deadline800 msabsolute boundary + DNS + TCP42 + 60 msremaining budget + TLS88 mshandshake + Read610 msor stop + + Остаток 0 ms → deadline-exceededне выдавать фазе новый полный timeout + + diff --git a/web/public/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg b/web/public/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg index 9e52932..721103d 100644 --- a/web/public/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg +++ b/web/public/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg @@ -1,14 +1,16 @@ - - Петля редакционного hand-off - Временная граница, контракт утверждения и состояния evidence образуют hand-off без создания интервью, transcript, speaker, approval или publication. - - Hand-off без fabricated record - Dates2026-07-312027-10 - Assertionnamed literalбез ответа - Evidencenot-collectednot-tested - - HAND-OFFsynthetic plan / not-attempted - - Не создаются: transcript · speaker · approval · publication - + + Передача traceparent через HTTP + Клиент передаёт traceparent gateway, gateway сохраняет контекст, а сервис проверяет формат и создаёт локальный span. + + + Trace Context на границе proxy + Заголовок связывает события, но не является правом доступа + + Клиентtraceparentvalid header + Gatewayallow + forwardsame trace-id + Serviceparse + new spanstructured logs + + invalid header → local fallbackне использовать trace-id для авторизации + + diff --git a/web/public/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg b/web/public/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg index 6948214..4473022 100644 --- a/web/public/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg +++ b/web/public/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg @@ -1,14 +1,13 @@ - - Матрица границ evidence и provenance - Пять строк отличают утверждение, evidence, происхождение, независимое подтверждение и опровержимость. Скрытый слой не повышает уверенность. - - Evidence ≠ связный рассказ - СлойВопросГраница P116 - - ClaimЧто сказано?not-collected - EvidenceЧем проверить?нет substitute - OriginОткуда?не из prose - Other pathНезависим?повтор не source - FalsifierЧто сузит?not-tested - hidden / claimed / fabricated → STOP + + Матрица проверки TLS-сертификата + Матрица разделяет срок действия, имя SAN, цепочку доверия и алгоритм. Успешный TLS-канал не означает совпадение всех проверок. + + Сертификат: четыре независимые проверки + Шифрование канала не заменяет доверие к имени и цепочке + + СлойВопросОтказСобрать + + Срокnow внутри окна?expiredUTC clockSANhost совпал?hostname mismatchSNI + имяЦепочкаесть trusted root?unknown issuerleaf + intermediatePolicyалгоритм разрешён?algorithm errorTLS settings + Не отключать verify: исправлять конкретный слой + diff --git a/web/public/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg b/web/public/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg index adffd90..b4da83a 100644 --- a/web/public/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg +++ b/web/public/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg @@ -1,16 +1,17 @@ - Петля передачи самостоятельности - Схема показывает проверку дат, именованного literal и отсутствия evidence. Назначенный владелец или claimed feedback останавливают проверку; допустимый результат передаёт только неоценённую самостоятельность. + Маршрут review API-изменения + Маршрут проходит от diff схемы к классификации, проверке потребителей и обратимой миграции. Удаление старого формата останавливается при неизвестном потребителе. + - Hand-off без назначения и выдуманного feedback + Review API-diff: от риска к обратимости + Новая форма не должна лишать старого клиента пути чтения - Даты2026-07-31 / 2027-09 - Literalnamed synthetic only - Evidencenot-collected - Hand-offnot-evaluated - - owner assigned или feedback claimed → stopprocess остаётся not-created, recipient — not-assigned - + Diff схемыremoved · required · enum + Класс рискаcompatible / breaking + ПотребителиSDK · tests · events + Expand → switch→ contract + + Неизвестный consumer → остановить удалениеrollback файла не возвращает потерянные данные + - diff --git a/web/public/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg b/web/public/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg index 61d4dae..2d072c1 100644 --- a/web/public/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg +++ b/web/public/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg @@ -1,13 +1,13 @@ - Матрица статусов practice и review - Таблица показывает, что prompt не запущен, а observation, review, feedback и progress не собраны или не заявлены. Любое claimed значение прекращает проверку. + Матрица слоёв валидации + Таблица разделяет форму JSON, связь полей, состояние ресурса и право доступа. Для каждого слоя указаны вход и класс ошибки. - Practice / review: что известно literal, а что нет - - - ПолеСтатус P115Claimed значение - - Promptnot-runне доказывает выполнениеObservationnot-collectedstopReviewnot-collectedstopFeedbacknot-collectedstopProgressnot-claimedstop - Нет reviewer, mentee, feedback, outcome или учебного процесса + Валидация: четыре слоя, четыре вопроса + JSON Schema не заменяет проверку состояния и авторизацию + + СлойВопросПримерОшибка + + ФормаJSON читается?limit: integer400ИнвариантПоля согласованы?from ≤ to400СостояниеРесурс ещё тот?revision совпала409ПравоМожно менять?роль + ресурс403 + Слой определяет вход, сообщение и следующий шаг клиента diff --git a/web/public/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg b/web/public/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg index 5a1fc20..b5b907e 100644 --- a/web/public/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg +++ b/web/public/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg @@ -1,31 +1,16 @@ - Контракт синтетической карты навыка - Три карточки показывают именованную карту, не запущенный prompt и отсутствие наблюдения. Красная ветка останавливает неявное умение. + Контракт HTTP-ответа + Поток показывает endpoint, проверку JSON-формы и потребителя. Неверный тип или значение enum останавливает ответ до клиентского кода. + - Карта навыка — контракт, не оценка человека - P115 · plan/scenario · 2027-09 · только fixed in-memory literal + API-ответ: форма → проверка → клиент + Контракт отделяет JSON-тип от бизнес-правила и состояния ресурса - - Карта - synthetic-skill-map - named-synthetic-only - - Prompt - synthetic-practice - not-run - - Наблюдение - observation - not-collected - - - synthetic-plan-hand-off - productionEffect: not-attempted - - - implicit - skill → stop - + EndpointGET /customers/{id}status 200 · JSON + Формаid · revision · statetype + required + enum + Потребительнормализованный объектили ясная причина отказа + + Неверный тип или enum → отказне подставлять отсутствующее обязательное поле + - diff --git a/web/public/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg b/web/public/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg index 3799b61..47ca250 100644 --- a/web/public/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg +++ b/web/public/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg @@ -1,16 +1,16 @@ - Временная граница пересмотра совета - Схема показывает editor date 31 июля 2026 года, source cutoff той же даты и будущую plan date ноября 2027 года. Между ними расположены вопросы о claim, condition и immutable evidence; заявленного прошлого совета и результата нет. + Фазы expand switch contract + Временная линия показывает добавление совместимой формы, двойную запись, переключение чтения и удаление старой формы только после проверки. - Сначала рамка, затем вопрос о совете - P117 · future-only сценарий · нет цитаты, статьи, теста или коррекции - - - editor date2026-07-31 - source cutoff2026-07-31 - plan date2027-11 - Claim questionnamed · not-collected - Condition questionversion / applicability unknown - Evidence questionimmutable pin required - Запрещённый вывод: существовал старый совет, найдено основание,выполнен repro или сделана коррекция. + Миграция схемы: совместимые фазы + Старый и новый код должны сосуществовать до удаления формы + + + + Expandдобавить поле + Dual writeобе формы + Switchновое чтение + Contractудалить старое + Проверка данных и потребителейнужна до contract и до окончательного rollback + diff --git a/web/public/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg b/web/public/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg index 508feca..1266e3c 100644 --- a/web/public/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg +++ b/web/public/assets/editorial/2027/mistakes-revisions-2027-condition-correction-matrix.svg @@ -1,11 +1,13 @@ - Матрица условия и коррекции для планового пересмотра - Матрица разделяет claim, condition, evidence, contradiction, repro и correction. Допустимы только named questions и плановая передача; mutable evidence, injected repro и declared correction останавливаются. + Матрица решения о retry + Матрица связывает HTTP-метод, статус, право на повтор и идемпотентность. Backoff и jitter применяются только после проверки семантики. - Сравнение начинается до verdict-а - P117 · condition и contradiction не заменяют друг друга - СлойДопустимая формаFail-closed граница - Claimnamed question-onlyunnamed или declared claimConditionversion / applicability not-collectedверсия из памятиEvidenceimmutable version or dated snapshotmutable current documentContradiction / repronot-collected / not-runinjected repro resultCorrection / outcomenot-declareddeclared correct or complete - - Только synthetic-plan-hand-off · effect not-attempted + Retry начинается с семантики + Задержка не делает небезопасную запись идемпотентной + + СигналРешениеЗадержкаРиск + + 429только safe/idempotentRetry-Afterвторая волна503ограниченный retrybackoff + jitteroverloadGET timeoutможно проверитьостаток deadlineнеизвестный ответPOST timeoutkey или statusкороткий limitдубль записи + deadline и idempotency имеют приоритет над числом попыток + diff --git a/web/public/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg b/web/public/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg index a2700e2..363150c 100644 --- a/web/public/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg +++ b/web/public/assets/editorial/2027/mistakes-revisions-2027-evidence-handoff-loop.svg @@ -1,11 +1,18 @@ - Петля передачи вопроса о коррекции - Схема показывает, как именованные вопросы о claim, condition, evidence и repro передаются в отдельный будущий авторизованный scope. Correction, recipient и outcome остаются не объявленными, поэтому схема не заявляет исправление или завершённый тест. + Петля incident runbook + Петля инцидента проходит через симптом, scope, проверку гипотезы, одно обратимое действие и метрику восстановления. + - Передача вопроса без фиктивного финала - P117 · corrective hand-off не назначает владельца и не публикует correction - Question envelopeclaim · conditionEvidence boundaryimmutable pin requiredRepro questionnot-run only - - Separate future scopeauthorization and method not created here - Correction: not-declared · recipient: not-assigned · outcome: not-declared · productionEffect: not-attempted + Инцидент: измерить → изменить → проверить + Один симптом, один рычаг, наблюдаемый критерий восстановления + + Симптом5xx · p95 · scope + Гипотезаодна проверка + Действиеобратимый шаг + Проверкаметрика + окно + + + Нет проверки → не закрывать инцидент + + diff --git a/web/public/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg b/web/public/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg index d773049..73594c6 100644 --- a/web/public/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg +++ b/web/public/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg @@ -1,7 +1,14 @@ - - Договор о критическом путиСхема показывает четыре границы будущего вопроса и передачу плана без измеренного результата. - Договор о критическом путиP112 · план/сценарий 2027-06 · source cutoff 2026-07-31 - - Навигацияграница стартаРесурсывидимые условияГлавный потокотдельный слойДействиеfuture question - synthetic-plan-hand-offproductionEffect: not-attempted · без benchmark, telemetry и profile + + Критический путь страницы + Временная линия отделяет TTFB, HTML, блокирующие ресурсы и отрисовку. + + Один экран — четыре участка + + + TTFBсервер + HTMLпередача + CSS / JSблокировка + Paintэкран + + Каждый участок требует собственного измерения. diff --git a/web/public/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg b/web/public/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg index a5ed950..09a0b7f 100644 --- a/web/public/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg +++ b/web/public/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg @@ -1,6 +1,14 @@ - - Матрица границ доказательствМатрица показывает четыре слоя будущего исследования и правило остановки при незаполненных условиях. - Сравнимость до evidenceP112 · не результат, а матрица проверяемого будущего вопроса - СлойНужно назватьОпрокидывает claimСтатусIntentдоступное действиедругое действиеnot-collectedBoundaryстарт и конецсмена границыnot-collectedConditionsbrowser · cache · routeразные условияnot-collectedEvidenceразрешённый artefactнет provenancenot-attempted - Незаполненный слой → stop, не вывод о winner + + Матрица resource timing + Для каждого ресурса сопоставлены инициатор, время, блокировка и потребитель. + + Waterfall → зависимость + + + РесурсИнициаторВремяЖдётДействие + style.cssparser0–180mslayoutразделить + app.jsparser40–240msDOMdefer + analyticsscript250ms+ничегоотложить + + Размер полосы не равен её влиянию на экран. diff --git a/web/public/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg b/web/public/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg index c0c3e76..d97022b 100644 --- a/web/public/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg +++ b/web/public/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg @@ -1,6 +1,14 @@ - - Цикл передачи evidenceСхема показывает, как named плановый договор проходит проверки и передаётся новому владельцу без создания результата. - Evidence hand-off без подменыP112 · будущий scope начинается только после отдельной авторизации - Named planfixed literalДата и cutoffили stopConditionsnot-collectedHand-offnew owner - stop: нет условийПоложительный итог: synthetic-plan-hand-off · productionEffect: not-attempted + + Повторяемый замер производительности + Цикл фиксирует условия, собирает повторы, считает p95 и меняет один фактор. + + Замер до и после + + УсловияURL · cache + Повторыraw samples + p95median · хвост + Один факторсравнить + + + Сырое распределение важнее одной удачной цифры. diff --git a/web/public/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg b/web/public/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg index 691bafe..41f330b 100644 --- a/web/public/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg +++ b/web/public/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg @@ -1,28 +1,16 @@ - - Контракт отказного сценария - Схема синтетического вопроса об отказе зависимости. От верхней карточки отходят действие, граница, retry и evidence; невыбранные retry, replica и recovery отмечены красными стоп-границами. - - - Синтетический отказный сценарий - только вопрос, не диагноз - - - - - Действиеименованное,без payload - - Границаevidence:not-collected - - Retryявно не выбран,не default - - Результатsynthetic planhand-off + + Дерево решения retry + Решение о повторе учитывает deadline, метод и статус ответа. + + Retry начинается с контракта + + Ответ запроса + + Deadline остался? + нет + вернуть timeout + да + метод безопасен?retry / return - - - - СТОПimplicit retryи hidden evidence - - СТОПimplicit replicaили recovery - - P113 · план на 2027-07 · production effect не предпринимается + Число попыток не заменяет семантику операции. diff --git a/web/public/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg b/web/public/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg index 50dbf21..6f66611 100644 --- a/web/public/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg +++ b/web/public/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg @@ -1,22 +1,14 @@ - - Матрица evidence для fault tree - Матрица показывает пять ветвей fault tree и их синтетические статусы. Отдельная красная полоса запрещает выводить нагрузку, восстановление или уровень сервиса из отсутствующих данных. - - Fault tree: вопросы и пределы evidence - - - ВеткаСтатус P113Вывод, который остановлен - - - - - - - - Форма действияНагрузкаВосстановлениеКритерий сервисаНаблюдение - named syntheticnot-collectednot-selectednot-definednot-collected - реальный side effectвыдержала / не выдержалаесть recovery pathсоблюдён SLOизвестна причина + + Границы надёжности запроса + Транспорт, HTTP и операция отвечают на разные вопросы при повторе. + + Один timeout — три вопроса + + Транспортдоставлены байты?соединение + + HTTPкакой статус?метод · ответ + + Операцияэффект?key + Проверка после timeoutGET: повтор с лимитом · POST: узнать состояние по ключуТранспорт не подтверждает бизнес-результат. - - Нет evidence — нет вывода о нагрузке, recovery или уровне сервиса diff --git a/web/public/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg b/web/public/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg index f20f804..c235eee 100644 --- a/web/public/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg +++ b/web/public/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg @@ -1,23 +1,15 @@ - - Петля передачи синтетического вопроса - Диаграмма показывает редакционную передачу будущего вопроса: временная граница, синтетический сценарий и явные пустые states проходят три проверки. Скрытые evidence, retry и заявленный результат останавливаются. Единственный выход — synthetic plan hand-off. - - - Передача без легенды об операции - - - Входдата, cutoff,synthetic scenario - - Проверка 1временная граница - - Проверка 2evidence и config - - Проверка 3retry, replica, recovery - - Единственный выходsynthetic-plan-hand-offnot-attempted + + Событие попытки retry + Каждая попытка сохраняет номер, статус, остаток времени и решение. + + Attempt → decision + + Attempt 1timeout + + Attempt 2503 + + Decisionretry или return + В событииoperationId · attempt · status · remainingMs - - - - СТОП: hidden evidence, implicit policy,claimed operational result + Причина и действие — разные поля. diff --git a/web/public/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg b/web/public/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg index 8ed5fda..152e96d 100644 --- a/web/public/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg +++ b/web/public/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg @@ -1,11 +1,17 @@ - - Договор о синтетическом активе и границеТри карточки неопределённости ведут к передаче плана. Заявленный путь, контроль или эффект останавливаются. - - P114: договор о границе - Активsynthetic labelnot-declared - Границаnot-mappednot-accepted - Путьnot-mappedне карта системы - - synthetic-plan-hand-offeffect: not-attempted - path claim → stopcontrol claim → stopeffect claim → stop + + Проверка URL до сети + URL проходит parse, схему, hostname, порт и egress-контроль до вызова клиента. + + URL не должен сразу стать fetch + + parseURL + + schemehttps + + hostallowlist + + egressfetch + redirect и новый host → повторить проверки + + Credentials и неожиданный порт останавливают путь. diff --git a/web/public/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg b/web/public/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg index 90d248b..bc8a99b 100644 --- a/web/public/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg +++ b/web/public/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg @@ -1,11 +1,16 @@ - - Матрица пути, покрытия и свидетельстваНеотображённый путь, невыбранный контроль и несобранное свидетельство не дают положительного результата. Разрешена только передача плана. - - P114: матрица без мнимого покрытия - СлойСтатусВывод - Synthetic pathnot-mappedclaim → stop - Coveragenot-selectedclaim → stop - Evidencenot-collectedclaim → stop - Effectnot-attemptedclaim → stop - Выход: synthetic-plan-hand-off + + Матрица авторизации + Субъект, роль, действие и объект сходятся в решении allow или deny. + + Решение доступа строится из полей + + СубъектsubjectId + Рольclaims + Действиеread / write + ОбъектownerId + + + ALLOW / DENY + + Неизвестное значение ведёт в deny-by-default. diff --git a/web/public/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg b/web/public/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg index 3d493a3..2ae94bf 100644 --- a/web/public/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg +++ b/web/public/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg @@ -1,12 +1,18 @@ - - Петля редакционной передачи evidenceСинтетическая карточка проходит проверку времени, отсутствия claims и отсутствия назначения. При ошибке она останавливается; иначе передаётся плановый вопрос. - - P114: передача без narrative - Literalfixed statuses - 1. Времядаты совпадают - 2. Claimsстатусы пусты - 3. Назначениеowner не задан - - Hand-offnot-assignednot-attempted - любой claim → stop + + Проверяемая security-строка + Требование связывается с положительным и отрицательным тестом, результатом и исправлением. + + Требование → тест → результат + + ТребованиеAUTH-01 + + Inputowner / other + + Assertallow / deny + + Resultevidence + + Расхождение → исправить + + Отрицательный вход обязателен для границы. diff --git a/web/scripts/audit-editorial-draft.mjs b/web/scripts/audit-editorial-draft.mjs index 3ef2d32..7c504aa 100644 --- a/web/scripts/audit-editorial-draft.mjs +++ b/web/scripts/audit-editorial-draft.mjs @@ -32,6 +32,20 @@ const genericPhrases = [ 'просто нужно', 'нужно понимать, что', ]; +const editorialMetaPatterns = [ + /synthetic-plan-hand-off/i, + /productionEffect/i, + /future-only/i, + /plan\/scenario/i, + /source cutoff/i, + /editorial date/i, + /planDate/i, + /not-collected/i, + /not-attempted/i, + /future owner/i, + /author trajectory/i, + /развитие автора/i, +]; function count(content, expression) { return (content.match(expression) || []).length; @@ -120,6 +134,8 @@ for (const revision of revisions) { if (proseWithoutCode.includes('undefined') || proseWithoutCode.includes('[object Object]')) { issues.push('в тексте есть след генерации'); } + const editorialMetaHit = editorialMetaPatterns.find((pattern) => pattern.test(body)); + if (editorialMetaHit) issues.push('в теле статьи есть внутренняя редакционная мета-лексика: ' + editorialMetaHit); for (const phrase of genericPhrases) { if (content.includes(phrase)) issues.push('шаблонный оборот: «' + phrase + '»'); diff --git a/web/scripts/audit-quality-batch.mjs b/web/scripts/audit-quality-batch.mjs index 774a48f..88e67d9 100644 --- a/web/scripts/audit-quality-batch.mjs +++ b/web/scripts/audit-quality-batch.mjs @@ -31,6 +31,20 @@ const genericPhrases = [ 'просто нужно', 'нужно понимать, что', ]; +const editorialMetaPatterns = [ + /synthetic-plan-hand-off/i, + /productionEffect/i, + /future-only/i, + /plan\/scenario/i, + /source cutoff/i, + /editorial date/i, + /planDate/i, + /not-collected/i, + /not-attempted/i, + /future owner/i, + /author trajectory/i, + /развитие автора/i, +]; const MIN_BODY_CHARS = 5000; const MAX_BODY_CHARS = 15000; let failed = false; @@ -89,6 +103,8 @@ for (const slug of slugs) { if (proseWithoutCode.includes('undefined') || proseWithoutCode.includes('[object Object]')) { issues.push('в тексте есть след генерации'); } + const editorialMetaHit = editorialMetaPatterns.find((pattern) => pattern.test(body)); + if (editorialMetaHit) issues.push('в теле статьи есть внутренняя редакционная мета-лексика: ' + editorialMetaHit); if (!/(проблем|ошиб|симптом|сбой|задач)/i.test(body.slice(0, 800))) { issues.push('проблема не названа в начале текста'); } diff --git a/web/scripts/upgrade-2026-09.mjs b/web/scripts/upgrade-2026-09.mjs index 877ba1d..44e898d 100644 --- a/web/scripts/upgrade-2026-09.mjs +++ b/web/scripts/upgrade-2026-09.mjs @@ -1,240 +1,299 @@ -function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +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 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, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(content) { return plainText(content.replace(/

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

|$)/, '')); } -function clone(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -const REFERENCES = deepFreeze({ - openapi: { title: 'OpenAPI Specification 3.1.1', url: 'https://spec.openapis.org/oas/v3.1.1.html', version: 'версия 3.1.1, 24 октября 2024, версионированная спецификация' }, - http: { title: 'RFC 9110: HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'RFC 9110, июнь 2022, неизменяемый текст RFC' }, - schema: { title: 'JSON Schema Core: draft 2020-12', url: 'https://json-schema.org/draft/2020-12/json-schema-core.html', version: 'draft 2020-12, Internet-Draft (work in progress), 16 июня 2022' }, -}); -function sourceList(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const source = REFERENCES[key]; return '
  • ' + escapeHtml(source.title) + ' — версия: ' + escapeHtml(source.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } +function plainText(content) { + return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); +} -const FIXED_BOUNDARY_CASES = deepFreeze({ - 'named-screen-boundary-v1': { - id: 'named-screen-boundary-v1', family: 'fixed-frontend-backend-boundary-v1', - ui: { localState: ['draft-filter', 'open-panel'], context: ['fixed-locale', 'fixed-view-permission'], forbiddenDomainClaims: [] }, - api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } }, - boundary: 'Named fixed synthetic JavaScript literal in memory. It describes no UI, user, HTTP request, network trace, file, secret, service, deployment or production state.', +function bodyText(content) { + return plainText(content.replace(/

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

|$)/, '')); +} + +const REFERENCES = Object.freeze({ + http: { + title: 'RFC 9110: HTTP Semantics', + url: 'https://www.rfc-editor.org/rfc/rfc9110.html', + version: 'RFC 9110, июнь 2022', }, - 'ui-claims-domain-state-v1': { - id: 'ui-claims-domain-state-v1', family: 'fixed-frontend-backend-boundary-v1', - ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: ['choice-confirmed'] }, - api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } }, boundary: 'Negative fixed synthetic literal only.', + problem: { + title: 'RFC 9457: Problem Details for HTTP APIs', + url: 'https://www.rfc-editor.org/rfc/rfc9457.html', + version: 'RFC 9457, июль 2023', }, - 'unnamed-command-v1': { - id: 'unnamed-command-v1', family: 'fixed-frontend-backend-boundary-v1', - ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: [] }, - api: { command: { name: '', intent: '', idempotency: '' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } }, boundary: 'Negative fixed synthetic literal only.', + openapi: { + title: 'OpenAPI Specification 3.1.1', + url: 'https://spec.openapis.org/oas/v3.1.1.html', + version: 'версия 3.1.1, 24 октября 2024', }, - 'unactionable-error-v1': { - id: 'unactionable-error-v1', family: 'fixed-frontend-backend-boundary-v1', - ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: [] }, - api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'thrown-value', code: '', retry: '' } }, boundary: 'Negative fixed synthetic literal only.', + fetch: { + title: 'WHATWG Fetch Standard', + url: 'https://fetch.spec.whatwg.org/', + version: 'живой стандарт WHATWG, разделы Fetch и response handling', }, }); -export function createFixedBoundaryCase(id = 'named-screen-boundary-v1') { const value = FIXED_BOUNDARY_CASES[id]; return value ? deepFreeze(clone(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ accepted: false, status, reasons: deepFreeze([reason]), nextAction, handoff: 'not-eligible-for-synthetic-boundary-review', productionEffect: 'not-attempted' }); } -export function reviewFixedStateOwnership(input) { - if (!Object.values(FIXED_BOUNDARY_CASES).some((known) => JSON.stringify(known) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case'); - if (!input.ui?.localState?.length || !input.ui?.context?.length) return stop('stop-ui-boundary-incomplete', 'local-state-or-context-is-not-named', 'name-local-state-and-context-separately'); - if (input.ui.forbiddenDomainClaims?.length) return stop('stop-ui-claims-domain-state', 'ui-literal-claims-domain-state', 'remove-domain-claim-and-request-a-read-model'); - const command = input.api?.command; - if (!command?.name || !command.intent || !command.idempotency) return stop('stop-command-not-named', 'command-intent-or-idempotency-key-is-missing', 'name-command-intent-and-idempotency-boundary'); - const readModel = input.api?.readModel; - if (!readModel?.name || !['status', 'allowedActions', 'messageCode'].every((field) => readModel.fields?.includes(field))) return stop('stop-read-model-incomplete', 'screen-read-model-is-not-decidable', 'name-status-actions-and-message-code'); - const error = input.api?.error; - if (error?.kind !== 'named-problem' || !Object.hasOwn(error, 'code') || !Object.hasOwn(error, 'retry')) return stop('stop-error-not-actionable', 'error-is-not-a-named-problem-envelope', 'name-problem-code-and-retry-boundary'); - return deepFreeze({ accepted: true, status: 'synthetic-boundary-review-hand-off', caseId: input.id, uiOwns: deepFreeze([...input.ui.localState, ...input.ui.context]), apiOwns: deepFreeze([command.intent, readModel.name, 'named-problem-envelope']), boundary: input.boundary, productionEffect: 'not-attempted' }); +function sourceList(entries) { + return '
    ' + entries.map(({ key, use, boundary }) => { + const source = REFERENCES[key]; + return '
  • ' + escapeHtml(source.title) + ' — ' + escapeHtml(source.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; + }).join('') + '
'; } -export function explainFixedBoundaryInference(input) { - const review = reviewFixedStateOwnership(input); - if (!review.accepted) return review; - return deepFreeze({ status: 'synthetic-inference-boundary-explained', allowedConclusion: 'named-fields-support-only-a-synthetic-review-hand-off', forbiddenConclusion: 'no-real-ui-api-or-domain-state-is-observed', nextAction: 'hand-off-fixed-synthetic-boundary-review', productionEffect: 'not-attempted' }); + +const SCREEN_CONTRACT = Object.freeze({ + status: ['ready', 'pending', 'blocked'], + allowedActions: ['retry', 'edit', 'cancel'], + messageCode: 'string', +}); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); } -export function runFixedBoundaryFixture() { - const cases = [ - ['named-screen-boundary-v1', 'synthetic-boundary-review-hand-off'], ['ui-claims-domain-state-v1', 'stop-ui-claims-domain-state'], ['unnamed-command-v1', 'stop-command-not-named'], ['unactionable-error-v1', 'stop-error-not-actionable'], - ]; - const checks = cases.map(([id, status]) => ({ id, expected: status, actual: reviewFixedStateOwnership(createFixedBoundaryCase(id)).status })); - const inference = explainFixedBoundaryInference(createFixedBoundaryCase('named-screen-boundary-v1')); - checks.push({ id: 'inference', expected: 'synthetic-inference-boundary-explained', actual: inference.status }); - const accepted = checks.every((check) => check.expected === check.actual) && inference.productionEffect === 'not-attempted'; - return deepFreeze({ passed: checks.filter((check) => check.expected === check.actual).length, total: checks.length, accepted, checks: deepFreeze(checks) }); + +function validateScreenResponse(value) { + const errors = []; + if (!value || typeof value !== 'object' || Array.isArray(value)) errors.push('response-must-be-an-object'); + if (!SCREEN_CONTRACT.status.includes(value?.status)) errors.push('status-must-be-known'); + if (!Array.isArray(value?.allowedActions) || value.allowedActions.some((action) => !['retry', 'edit', 'cancel'].includes(action))) errors.push('allowed-actions-must-be-an-array-of-known-actions'); + if (typeof value?.messageCode !== 'string' || value.messageCode.length === 0) errors.push('message-code-must-be-a-non-empty-string'); + return { valid: errors.length === 0, errors }; +} + +export function checkScreenResponse(value) { + const result = validateScreenResponse(clone(value)); + return Object.freeze({ ...result, source: 'response-boundary-validator' }); +} + +export function classifyHttpResponse(response) { + const status = Number(response?.status); + const contentType = String(response?.contentType || '').toLowerCase(); + const body = response?.body; + if (!Number.isInteger(status) || status < 100 || status > 599) return { kind: 'invalid-observation', next: 'record-status-before-interpreting-body' }; + if (status === 204) return { kind: 'success-without-representation', next: 'do-not-parse-json' }; + if (status >= 200 && status < 300) { + if (!contentType.includes('application/json')) return { kind: 'success-with-wrong-media-type', next: 'stop-before-reading-screen-model' }; + const contract = checkScreenResponse(body); + return contract.valid ? { kind: 'screen-model-ready', next: 'render-from-contract' } : { kind: 'success-with-invalid-contract', next: 'show-transport-success-separately-and-stop-rendering', errors: contract.errors }; + } + if (status === 409 || status === 422) return { kind: 'domain-rejection', next: 'read-problem-type-and-show-recoverable-action' }; + if (status === 429 || status >= 500) return { kind: 'retryable-or-temporary-failure', next: 'apply-explicit-retry-policy' }; + if (status >= 400) return { kind: 'client-or-authorisation-failure', next: 'show-problem-without-retrying-blindly' }; + return { kind: 'informational-or-unhandled', next: 'handle-explicitly' }; +} + +export function diagnoseBoundaryObservation(input) { + const response = input?.response; + const ui = input?.ui; + if (!response || typeof response !== 'object') return { status: 'missing-response', next: 'capture-status-and-content-type' }; + if (!ui || typeof ui !== 'object') return { status: 'missing-ui-observation', next: 'record-render-decision-and-request-id' }; + const classification = classifyHttpResponse(response); + if (classification.kind === 'screen-model-ready' && ui.renderedStatus !== response.body.status) return { status: 'ui-api-state-mismatch', next: 'compare-render-input-with-response-body' }; + return { status: classification.kind, next: classification.next }; } -const commonMeta = { readingMinutes: 10, tags: ['frontend', 'backend', 'contracts', 'architecture'] }; const practice = { - ...commonMeta, slug: 'editorial-2026-09-practice-frontend-backend-boundary', - title: 'План на сентябрь: карта границы между экраном и доменом', - excerpt: 'Как до начала работ отделить локальное состояние UI от контекста и screen read model, не приписывая клиенту доменный факт.', + readingMinutes: 12, + tags: ['frontend', 'backend', 'http', 'contracts'], + slug: 'editorial-2026-09-practice-frontend-backend-boundary', + title: 'Ответ 200 — ещё не модель экрана: валидируем JSON на границе UI и API', + excerpt: 'Почему успешный HTTP-статус не даёт права рендерить payload и как вынести проверку screen model в маленький воспроизводимый контракт.', contentHtml: [ - p('План на сентябрь 2026, составленный по источникам на 31 июля 2026: команда часто называет «состоянием экрана» всё, что лежит в store. В результате UI присваивает себе доменный факт — например, «выбор подтверждён» — до того, как этот факт вообще определён контрактом. Цена ошибки конкретна: спор о кнопке превращается в спор о правде системы, а повторный вход или иной клиент получает несовместимую версию того же решения.'), - p('Задача этого сценария не в том, чтобы заменить API красивой схемой. Нужно до реализации разложить один экран на владельцев: что живёт только в UI, что является контекстом запроса и что backend обязан вернуть как read model. Если не назвать владельца, локальная оптимизация станет скрытым протоколом. Её стоимость проявится при ошибке, повторной команде или добавлении второго интерфейса: договорённость придётся восстанавливать по веткам компонентов.'), - h2('Три слоя, которые нельзя называть одним словом'), - p('Локальное состояние отвечает на вопрос «как пользователь сейчас управляет представлением»: открыт ли panel, что набрано в фильтре, какая вкладка выбрана до отправки. Оно может исчезнуть при закрытии view без изменения домена. Контекст не является результатом операции: locale, явно выданное permission или feature boundary задают, как интерпретировать запрос. Их нельзя подменять полем ответа и нельзя вычислять из CSS или текста кнопки.'), - p('Доменное состояние отвечает на другой вопрос: что система считает действительным для предмета. У экрана нет оснований владеть им только потому, что он его показывает. В сентябрьском плане API возвращает не «всю сущность», а named screen read model: status, allowedActions и messageCode. Это достаточная форма для решения UI, но не лицензия выдумать новые переходы или причины отказа.'), - figure('/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg', 'Схема из трёх колонок: UI владеет draft и открытой панелью, context содержит locale и permission, API возвращает status, allowed actions и message code. Стрелка команды идёт к API, а не к доменному факту внутри UI.', 'Карта владельцев не описывает настоящую систему. Это шаблон вопросов для сентябрьского design review.'), - table('Карта ответственности для одного synthetic screen scenario', ['Объект', 'Владелец', 'Допустимый вопрос', 'Недопустимая подмена'], [ - ['draft-filter', 'UI', 'что введено до команды?', 'назвать draft подтверждённым выбором'], - ['fixed-locale', 'context', 'в каком формате показать код?', 'вывести locale из результата операции'], - ['status', 'API read model', 'что разрешено показать?', 'превратить status в локальный флаг успеха'], - ['fixed-submit-choice', 'API command', 'какое намерение отправлено?', 'описать команду как прямую мутацию store'], - ['named-problem', 'contract', 'как UI может объяснить stop?', 'ловить произвольную thrown value'], + p('Экран может показать «готово», получить ответ 200 и всё равно сломаться на следующем клике. Причина обычно не в HTTP: сервер вернул успешный статус, но поле переименовали, массив действий стал строкой или messageCode исчез из одного ответвления. Цена ошибки — рассинхрон между UI и API: пользователь видит устаревшее состояние, а команда ищет проблему в сети, хотя нарушение уже произошло на границе данных.'), + p('Граница должна отвечать на два разных вопроса. Первый: завершился ли обмен по HTTP? Второй: можно ли безопасно использовать представление для конкретного экрана? Нельзя сводить их к одному boolean ok. В этой статье разберём минимальную screen model, проверим её на чистой функции и разложим действия при статусах 2xx. Подход одинаково применим в браузерном клиенте, BFF и интеграционном тесте.'), + h2('HTTP-успех и успех контракта'), + p('RFC 9110 описывает значение статус-кода как часть HTTP-сообщения. Код 200 сообщает, что запрос обработан успешно на уровне протокола и приложения, но конкретное содержимое ответа всё равно определяется контрактом ресурса. Клиент не получает права угадать форму по имени endpoint или по тому, что предыдущая версия возвращала похожий JSON.'), + p('Поэтому полезно держать в коде последовательность: сначала принять статус, затем проверить media type, потом разобрать JSON, после этого валидировать поля и только в конце передать результат в view-model. Порядок кажется длиннее прямого await response.json(), но он локализует ошибку. Если payload не соответствует договорённости, компонент не обязан угадывать fallback и не превращает частично прочитанные данные в доменный факт.'), + figure('/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg', 'Поток из пяти шагов: HTTP status, Content-Type, JSON parse, проверка screen model и рендер. Красная ветка останавливает обработку при ошибке контракта.', 'Валидатор стоит между транспортом и компонентом. Он не решает, как выглядит интерфейс; он не даёт невалидному payload пройти дальше.'), + table('Минимальная screen model для одного состояния заказа', ['Поле', 'Тип', 'Зачем UI', 'Что делать при ошибке'], [ + ['status', 'ready | pending | blocked', 'выбрать состояние экрана', 'не выбирать состояние по умолчанию молча'], + ['allowedActions', 'массив известных команд', 'показать доступные действия', 'скрыть команды и записать нарушение'], + ['messageCode', 'непустая строка', 'выбрать локализованное сообщение', 'показать безопасный общий текст'], + ['Content-Type', 'application/json', 'понять формат представления', 'не вызывать JSON parser вслепую'], + ['HTTP status', 'целое 100–599', 'разделить transport и application result', 'зафиксировать код до чтения тела'], ]), - h2('Карта начинается не с полей, а с права утверждать'), - p('У каждого поля полезно спросить: кто имеет право изменить его смысл и кто должен увидеть изменение после повторного чтения? Если ответ «только текущий компонент», это кандидат в local state. Если ответ зависит от входной среды, это context с явным источником. Если ответ должен быть одинаково интерпретирован разными клиентами, это доменный факт или его проекция, и экран лишь получает его через контракт.'), - p('Такое правило не требует тотального backend-for-frontend. Иногда read model собирается отдельным adapter, иногда один ресурс уже годится для нескольких view. Важно не место кода, а обязанность: читатель ответа понимает, какие action разрешены, без копирования rules в UI. Если для этого приходится держать в компоненте таблицу переходов, граница не названа; перенос HTTP-вызова сам по себе её не исправит.'), - h2('Как распознать присвоение доменного состояния'), - p('Первый маркер — optimistic flag с именем в прошедшем времени: isConfirmed, wasPaid, hasAccess. Сам по себе optimistic interaction не запрещён, но его следует назвать именно interaction: «команда отправлена», «локальная форма заблокирована», «ожидается новый read model». Когда такой флаг влияет на список разрешённых действий, он уже конкурирует с domain rule. План не требует убрать весь optimistic UI; он требует не выдавать временную реакцию за знание о предмете.'), - p('Второй маркер — context, который появляется только после разбора ответа. Locale и permission обычно задают способ показать или запросить информацию; status описывает то, что вернул contract. Если перемешать их, UI начинает зависеть от исторического порядка: сначала пришёл ответ, потом вычислился context, потом поменялся текст. В сентябрьской карте контекст именуется отдельно именно чтобы reviewer мог спросить, откуда он взят, не разбирая payload и не строя гипотезу о session.'), - p('Третий маркер — boolean, который скрывает причину. canSubmit полезен как derived view state, если он получен из allowedActions или локальной валидности формы. Он опасен как самостоятельный доменный флаг, потому что другой client уже не узнает, почему действие недоступно. Вместо одной универсальной причины model держит status и message code. Это не усложнение ради полноты: следующий экран получает явный вопрос, а не обязанность повторить условие.'), - p('Наконец, карта не требует, чтобы API знал каждую анимацию или фокус. Такие детали остаются у UI и не обязаны переживать новый read. Плохая граница получается не тогда, когда клиент что-то хранит, а когда он хранит факт, который должен быть согласован между потребителями. Этот критерий помогает не уйти в архитектурную религию: локальное остаётся локальным, общее имеет контракт, а context не маскируется под оба случая.'), - h2('Literal-проверка карты, а не имитация trace'), - code("import { createFixedBoundaryCase, reviewFixedStateOwnership } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('ui-claims-domain-state-v1');\nconst result = reviewFixedStateOwnership(input);\nconsole.log({ status: result.status, reason: result.reasons[0], effect: result.productionEffect });\n// { status: 'stop-ui-claims-domain-state', reason: 'ui-literal-claims-domain-state', effect: 'not-attempted' }"), - p('Этот snippet выполняется над named fixed synthetic literal, клонирует его через JSON и freeze-ит результат. Он не открывает browser, не читает сеть и не изображает настоящий экран. Полезный output здесь — stop с конкретной причиной. Положительная ветка функции даёт лишь synthetic-boundary-review-hand-off; она не означает approval, release или готовность к реализации.'), - p('Для автора M9 эта проверка важна ещё и как способ писать короче. Вместо абзаца о том, «кто за что отвечает», он может показать поле, владельца и stop reason. Но краткость не должна скрывать цену переноса: если status становится локальным, UI берёт на себя обязанность поддерживать его после другой команды, другой вкладки и другого клиента. В плановой карте эта обязанность не делегируется компоненту. Она остаётся открытым вопросом к контракту, пока отдельный read model не даст основание для вывода. Нельзя закрыть этот вопрос именем store или выбором фреймворка: оба решения определяют хранение, но не источник domain truth.'), - h2('Почему screen model не равна DTO «как есть»'), - p('DTO транспорта может быть удобной исходной формой, но его наличие не отвечает на вопрос view. Screen model должна иметь ясное назначение: что показать, что разрешить, как обозначить проблемное состояние. OAS 3.1.1 полезна именно как описание интерфейса, независимое от языка; она не предписывает слой или форму state management. Поэтому в плане мы используем спецификацию, чтобы закрепить поля контракта, а не чтобы объявить каждый JSON API готовым для UI.'), - p('В error path особенно легко смешать роли. HTTP status описывает результат обмена на уровне протокола, но UI всё ещё нуждается в именованной проблеме с code и retry. Нельзя вывести retry из «любого 5xx» без правила конкретного контракта. RFC 9110 задаёт семантику HTTP, а наша модель добавляет ограниченный project-level вопрос: может ли view показать следующий шаг, не угадывая смысл исключения.'), - h2('Сентябрьская последовательность'), + h2('Контракт должен быть маленьким, но решаемым'), + p('Полная DTO предметной области редко нужна экрану. Если компонент читает двадцать полей, это не означает, что screen model должна копировать все двадцать. Выберите минимальный набор, который позволяет принять решение: состояние, разрешённые команды и ключ сообщения. Любое поле должно иметь владельца и правило изменения. Это снижает связность: backend может менять внутреннее представление, не заставляя UI разбирать чужой aggregate.'), + p('При этом «минимальный» не значит «неформальный». Для каждого значения задайте допустимый словарь или тип. status: "ok" удобен только до появления второго смысла слова ok. Перечень ready | pending | blocked делает расширение видимым: новый статус потребует решения о рендеринге, тесте и обратной совместимости. Массив действий также должен быть закрытым или версионируемым; неизвестная команда не должна появляться как активная кнопка.'), + h2('Проверка до рендера'), + code("import { checkScreenResponse } from './upgrade-2026-09.mjs';\n\nconst response = {\n status: 'ready',\n allowedActions: ['edit'],\n messageCode: 'order.ready',\n};\n\nconsole.log(checkScreenResponse(response));\n// { valid: true, errors: [], source: 'response-boundary-validator' }\n\nconsole.log(checkScreenResponse({ status: 'done', allowedActions: 'edit' }));\n// { valid: false, errors: ['status-must-be-known', ...], source: 'response-boundary-validator' }"), + p('Функция принимает копию JSON-совместимого значения и возвращает причины, а не исключение с произвольным текстом. Это не замена JSON Schema или типам на этапе сборки: runtime-проверка нужна потому, что HTTP приносит данные из-за границы процесса. В реальном клиенте результат следует связать с error boundary, telemetry без payload и понятным сообщением для пользователя. Самое важное — не передавать невалидное значение в компонент, который считает его достоверным.'), + p('Не стоит делать validator чрезмерно умным. Он не должен сверять бизнес-правила, запрашивать второй endpoint или самостоятельно исправлять поле. Если status пришёл как "ready ", trim может скрыть нарушение контракта. Исправление допустимо на границе, только если это явно часть формата: например, нормализация регистра для заголовка. Чем больше молчаливых преобразований, тем труднее понять, что на самом деле отправил сервер.'), + h2('204, JSON и пустое тело'), + p('Классическая ошибка — общий helper, который всегда вызывает response.json(). Для 204 это некорректное ожидание: успешный ответ не обязан иметь representation. Команда «удалить» может завершиться 204 и потребовать повторного чтения списка; команда «получить экран» обычно возвращает представление. Эти два случая нельзя различать по URL или по тому, что parser иногда падает. Правило должно быть частью операции.'), + p('Заголовок Content-Type тоже не подтверждает, что JSON валиден. Он сообщает заявленный формат, а не соответствие вашему screen contract. Поэтому проверка media type — ранняя ветка, а runtime validation — следующая. Ошибку формата следует отделять от сетевой ошибки: повтор запроса не исправит payload, который сервер стабильно формирует неправильно.'), + h2('Рантайм-проверка и OpenAPI'), + p('OpenAPI удобна как единый источник описания HTTP-интерфейса: она помогает связать ответ операции с компонентной схемой и генерировать типы. Но сгенерированный TypeScript-интерфейс не проверяет JSON во время выполнения. Если сервисы релизятся независимо, нужен контрактный тест или runtime validator на границе. Иначе компилятор подтвердит только то, что разработчик написал в исходниках.'), + p('JSON Schema может описать типы, обязательность и перечисления, а валидатор — применить эту схему к фактическому payload. В небольшом клиенте допустима ручная функция, как в примере, если у неё есть тесты на неизвестный статус, неправильный массив и пустой message code. Выбор библиотеки — вторичен. Сначала зафиксируйте, какую ошибку должен увидеть пользователь и какие данные нельзя пропускать в view.'), + h2('Как внедрить границу без большого переписывания'), ol([ - 'Выбрать один будущий screen scenario и прямо записать: это план, а не описание уже существующего UI.', - 'Выписать local state отдельно от context; у каждого элемента назвать допустимое время жизни.', - 'Для доменного факта сформулировать один read question и один command intent без слов «обновить store».', - 'Согласовать минимальный screen read model: status, allowed actions, message code и named problem envelope.', - 'Прогнать fixed literal с domain claim; при stop удалить claim, а не добавлять ещё один optimistic flag.', - 'Передать только synthetic review card с открытыми вопросами владельцу следующего design review.', + 'Найдите один endpoint, где UI уже угадывает поля или использует fallback после исключения parser.', + 'Выпишите screen model из фактических решений компонента: состояние, доступные действия и сообщение.', + 'Добавьте проверки статуса и Content-Type до разбора тела; отдельно обработайте 204.', + 'Добавьте runtime validation для обязательных полей и неизвестных enum-значений.', + 'Напишите четыре теста: валидный ответ, неизвестный статус, неверный тип actions и успешный ответ без JSON.', + 'Покажите пользователю безопасное состояние ошибки, а в технический канал передайте только код нарушения и request id.', ]), - h2('Границы плана и следующий шаг'), - p('Карта не доказывает, что конкретный endpoint хорошо спроектирован, и не сообщает о latency, правах реальных пользователей или поведении браузера. Она также не решает offline, cache invalidation и composition нескольких aggregates. JSON Schema Core объясняет, как фиксировать структуру документа, но не превращает validation в доменную корректность. Все перечисленные вопросы потребуют отдельного входа и собственных критериев.'), - p('Следующий шаг на сентябрь — завести второй named literal, в котором у read model нет allowedActions, и убедиться, что review останавливается как на неполной модели. Так команда проверит не только удачную карту, но и язык отказа. До этого нельзя делать вывод о реальном frontend, backend или состоянии выбора: в пакете есть только изолированная модель и план её обсуждения.'), + h2('Ограничения и следующий шаг'), + p('Валидатор не доказывает, что значение истинно в домене, и не заменяет authorization. Он проверяет форму ответа и право клиента использовать эту форму. Он также не решает миграцию старого поля: для этого нужны версия схемы, совместимый период и тесты обеих сторон. Если один endpoint обслуживает несколько экранов, лучше назвать две read model, чем снова передать в UI универсальный объект.'), + p('Следующий практический шаг — добавить в контракт тест с неизвестным статусом и проверить, что компонент не показывает «готово» по умолчанию. После этого полезно сравнить generated types с runtime schema и зафиксировать расхождение в CI. Важный результат — не ещё один helper, а видимая граница, после которой UI работает только с проверенной моделью.'), h2('Проверяемые источники'), sourceList([ - { key: 'openapi', use: 'Использован узко: OAS определяет язык-независимое описание HTTP API, поэтому contract можно обсуждать отдельно от реализации.', boundary: 'Не доказывает, что какой-либо endpoint или screen model существует.' }, - { key: 'http', use: 'Использован только для различения HTTP semantics и project-level problem envelope.', boundary: 'Не задаёт retry policy и не подтверждает исход конкретного запроса.' }, - { key: 'schema', use: 'Использован для мысли о явной структуре документа и vocabulary.', boundary: 'Schema validation не доказывает доменную корректность или UI-поведение.' }, + { key: 'http', use: 'Использован для различения семантики статус-кода и содержимого representation.', boundary: 'Не описывает screen model конкретного продукта и не заменяет runtime validation.' }, + { key: 'openapi', use: 'Использован как формат описания HTTP-операции и схемы ответа.', boundary: 'Сгенерированный тип не является проверкой фактического JSON.' }, + { key: 'problem', use: 'Использован для идеи структурировать ошибки HTTP отдельным типом.', boundary: 'Не назначает локальные коды UI и не выбирает retry policy.' }, ]), ].join(''), }; const mechanism = { - ...commonMeta, slug: 'editorial-2026-09-mechanism-frontend-backend-boundary', - title: 'План на сентябрь: на что boundary даёт право сделать вывод', - excerpt: 'Как отличить query, command, read model и error envelope, чтобы UI не превращал транспортный ответ в неподтверждённый доменный вывод.', + readingMinutes: 13, + tags: ['frontend', 'backend', 'http', 'errors'], + slug: 'editorial-2026-09-mechanism-frontend-backend-boundary', + title: 'Состояние экрана не угадывают по статусу: разделяем query, command и ошибку', + excerpt: 'Практическая схема для UI и BFF: как не превращать 409, 422, 429 и 500 в один красный toast и не терять следующий шаг.', contentHtml: [ - p('Сентябрьский механизм начинается с неприятного симптома: один handler отправляет запрос, меняет локальный флаг и затем трактует любой ответ как подтверждение. Проблема не в количестве hooks. Проблема в незаконном выводе: UI делает доменное утверждение из факта отправки команды. Цена — не только неверная кнопка. При повторе, параллельном клиенте или ошибке команда перестаёт быть различимой, а support получает сообщение без понятной причины и следующего действия.'), - p('Это план для источников, известных на 31 июля 2026, а не отчёт о сентябрьской переделке. Он разделяет четыре формы: query запрашивает именованное представление, command выражает намерение, read model даёт экрану основания для показа, error envelope ограничивает реакцию. Ни одна форма не заменяет другую. Особенно опасно называть command «query с side effect» или считать status code готовой инструкцией для UI.'), - h2('Command не является правом изменить экран'), - p('Command полезен, когда его имя описывает намерение, а не способ работы с данными. В fixed literal это fixed-submit-choice с intent set-choice и named idempotency key. Имя не говорит, что выбор уже принят; оно лишь делает повторное намерение различимым в границе модели. Если name, intent или idempotency отсутствуют, review возвращает stop. Он не генерирует fallback и не пытается угадать безопасное значение.'), - p('Query в этом сценарии — вопрос о view, а не скрытая команда. Его ответом становится screen read model fixed-choice-screen-v1. Поля status, allowedActions и messageCode образуют достаточное основание для отображения; каждое имеет читателя в UI. Если добавить поле «для будущего», но не назвать, какой вывод оно поддерживает, оно не улучшает contract. Оно расширяет поверхность совместимости без проверяемой задачи.'), - figure('/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg', 'Матрица из четырёх строк: query читает screen model, command передаёт intent, read model даёт status и allowed actions, error envelope даёт code и retry. Внизу красная строка запрещает UI делать доменный вывод из отправки команды.', 'Матрица показывает разные права на вывод: отправка command не равна новому domain fact.'), - table('Четыре формы на boundary', ['Форма', 'Кто формулирует', 'Что может заключить UI', 'Чего заключать нельзя'], [ - ['query', 'view contract', 'какой named model нужен сейчас', 'что домен изменился'], - ['command', 'intent contract', 'какое намерение выражено', 'что намерение принято'], - ['read model', 'API boundary', 'что показать и какие action названы', 'внутреннюю причину без поля'], - ['problem envelope', 'error contract', 'какой message code и retry boundary есть', 'что ошибка transient без правила'], + p('Проблема начинается, когда UI трактует любой ответ не-200 как «сервер упал»: пользователь получает бесполезный toast, а команда теряет причину отказа. Обратная крайность не лучше: компонент считает каждый 2xx подтверждением операции и показывает новое состояние до чтения модели. Цена такой путаницы — повторные команды, неверные подсказки и разбор инцидента по снимкам интерфейса вместо точного HTTP-контракта.'), + p('Надёжная граница разделяет три намерения: query читает representation, command просит изменить состояние, а error envelope объясняет, почему переход не состоялся или что делать дальше. Статус HTTP — важная часть решения, но он не должен единолично выбирать текст, кнопку повторить и новый screen state. Ниже — таблица семантик и чистая функция, которую можно покрыть тестами без браузера.'), + h2('Один ответ — несколько уровней смысла'), + p('Запрос к экрану и команда изменения могут использовать один транспорт, но у них разные последствия. Query обычно превращает валидное представление в UI. Command сначала подтверждает, что запрос принят на уровне операции, а затем либо возвращает новую модель, либо требует повторного чтения. Если эти пути слить в один handler, команда легко станет локальным флагом «успешно», хотя сервер вернул только принятие запроса.'), + figure('/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg', 'Матрица связывает HTTP-результат с правом UI: 2xx даёт право читать representation, 409 и 422 — показать исправляемую причину, 429 и 5xx — применить отдельную политику повторов, а отправка команды сама по себе не меняет screen state.', 'У каждого результата есть следующий шаг и запрет. Это меньше похоже на универсальный обработчик, зато не скрывает смысл ответа.'), + table('Решение по HTTP-результату', ['Результат', 'Что можно заключить', 'Следующее действие UI', 'Чего нельзя делать'], [ + ['200 + valid JSON', 'representation соответствует схеме', 'обновить view из модели', 'добавлять локальные доменные поля'], + ['204', 'операция завершилась без representation', 'инвалидировать или перечитать ресурс', 'вызывать JSON parser'], + ['409', 'текущее состояние конфликтует с командой', 'показать причину и предложить перечитать', 'повторять без изменения входа'], + ['422', 'вход не прошёл прикладную проверку', 'подсветить исправляемые поля', 'называть это сетевым сбоем'], + ['429 / 5xx', 'возможен временный отказ', 'использовать явную retry policy', 'делать бесконечный retry'], ]), - h2('Право на вывод — более точный тест, чем «где код»'), - p('В проекте можно разместить adapter в frontend repo и всё равно держать доменную границу корректной. И наоборот, endpoint на backend не спасает, если component сам вычисляет разрешённый переход. Поэтому на сентябрьской встрече полезно не спорить об именах слоёв, а взять каждую ветку и спросить: каким полем contract она обоснована? Если такого поля нет, ветка должна стать stop, запросом на новую модель или явным local interaction — но не скрытым business rule.'), - p('Read model не обязана копировать aggregate и не обязана быть вечной. Она обязана быть решаемой для своего consumer. В примере отсутствие любого из трёх полей останавливает review с stop-read-model-incomplete. Это сознательно строгая учебная форма: она не заявляет, что три поля достаточны для всех экранов. Она фиксирует минимум для одного named synthetic scenario и оставляет расширение отдельным решением.'), - h2('Error — контракт действия, а не украшение ответа'), - p('Thrown value удобна внутри языка, но на boundary она не даёт потребителю стабильного вопроса. В плановой модели допустим только named-problem с присутствующими ключами code и retry. Значение none или not-requested — fixed literal, не наблюдение. Оно не сообщает, что в сентябре ошибок не будет и не моделирует retry сети. Оно заставляет автора назвать семантику до того, как UI начнёт угадывать её по тексту.'), - p('HTTP status остаётся значимым, но не должен молча стать domain language. RFC 9110 описывает status codes и семантику сообщений; из него не следует, что конкретный product flow обязан показывать кнопку retry. Такая кнопка появляется только при named rule на boundary. Этот разрыв полезен: transport failures, validation problems и запрещённые действия перестают сливаться в один catch, а каждое расширение контракта имеет место для review.'), - h2('Где механизм намеренно останавливает вывод'), - p('Первый stop связан с неизвестным input. Функция сравнивает вход с finite набором named fixtures, а не пытается принять похожий object. Это ограничение похоже на избыточную осторожность, но оно защищает учебный пример от тихой эволюции: добавили поле, поменяли смысл retry, а старый reviewer всё ещё читает accepted status. Новый input должен получить имя и пройти тот же review. Так автор видит изменение контракта до того, как его назовут обратной совместимостью.'), - p('Второй stop касается команды без idempotency boundary. Мы не утверждаем, что любой production command обязан иметь определённый ключ: это зависит от предмета и реализации. Однако для выбранного synthetic scenario повтор намерения является значимой частью разговора, поэтому пустое поле запрещает hand-off. Такой gate не проектирует storage и не выбирает HTTP header. Он лишь не позволяет тексту говорить «повтор безопасен», пока в модели не названо, что именно различает повтор.'), - p('Третий stop возникает, когда read model не даёт decision fields. Это не требование возвращать все возможные данные. Скорее, это отрицание ложной экономии: нельзя заставить consumer догадаться о permitted action по отсутствию поля или по message text. Если action зависит от сложного правила, API может вернуть более точную проекцию; если правило локальное, его следует явно вынести в UI. В обоих случаях boundary становится предметом change, а не неявным соглашением между двумя ветками.'), - p('Именно поэтому позитивный ответ механизма маленький. Он перечисляет, что named fields позволяют только synthetic-boundary-review-hand-off. У него нет слова success, нет timestamp и нет результата команды. Чем меньше такой output похож на status системы, тем труднее использовать его как декоративное доказательство. Для статьи о будущем это важнее эффекта «работающий пример»: пример должен проверять формулировку, а не имитировать эксплуатацию.'), - h2('Буквальная проверка fail-closed'), - code("import { createFixedBoundaryCase, explainFixedBoundaryInference } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('unactionable-error-v1');\nconst result = explainFixedBoundaryInference(input);\nconsole.log({ status: result.status, reason: result.reasons[0], next: result.nextAction });\n// { status: 'stop-error-not-actionable', reason: 'error-is-not-a-named-problem-envelope', next: 'name-problem-code-and-retry-boundary' }"), - p('Код исполняется только над зафиксированным literal в памяти и возвращает deterministic stop. Он не делает HTTP-вызов, не читает trace и не создаёт реальное исключение. Даже accepted branch выдаёт лишь synthetic hand-off. Это важная дисциплина для текста о будущем: «модель допускает передачу review» и «команда внедрила boundary» — разные предложения; второе у этого пакета не имеет источника и поэтому запрещено.'), - p('Полезно отдельно проверить, не подменяет ли модель failure mode термином transport. Например, named-problem не обязан быть сериализацией RFC status и не обязан нести stack. Его задача в выбранном сценарии — сохранить код и границу retry, достаточные для следующей UI-ветки. Если consumer требуется причина для журнала, это новый contract question с собственным доступом и privacy boundary. Добавлять её «на всякий случай» в screen model нельзя: поля без читателя быстро становятся неявным интерфейсом для чужой логики. В частности, message code не должен превращаться в место, куда UI складывает технический текст, если contract не назвал его читателя и срок жизни.'), - h2('Как проводить design review в сентябре'), + h2('Conflict, validation и server failure'), + p('409 Conflict — не синоним 500. Он сообщает, что запрос нельзя завершить в текущем состоянии ресурса; UI часто может перечитать модель, показать конфликт или попросить пользователя выбрать действие. 422 удобно использовать для валидного по синтаксису, но неприемлемого по правилам входа. Смысл ответа зависит от API, поэтому клиент должен читать структурированный problem detail, а не выводить причину из одной цифры.'), + p('429 говорит о временном ограничении частоты, а 5xx — о проблеме на стороне сервера или его зависимости. Оба случая могут быть повторяемыми, но не одинаково: Retry-After, идемпотентность команды, бюджет попыток и состояние формы должны быть явными. Автоматический retry POST без idempotency boundary способен создать две операции. «Повторить запрос» — это политика, а не универсальная реакция на красный статус.'), + h2('Problem Details как envelope'), + p('RFC 9457 предлагает формат Problem Details с полями вроде type, title, status, detail и instance. Он не диктует, какой текст показывать и можно ли повторять команду. Приложение может добавить ограниченный code и список безопасных действий, если это описано его контрактом. Важно отделить машинный тип от свободного detail: последний может быть непригоден для локализации или содержать внутреннюю информацию.'), + p('UI должен преобразовать envelope в свою модель сообщения один раз. Например, order.version-conflict превращается в «Обновить данные» и кнопку перечитать, а order.invalid-address — в подсветку поля. Компоненты не должны сравнивать строки title и detail. Это делает поведение устойчивым к переводу, изменению формулировки и разделению API между несколькими клиентами.'), + h2('Воспроизводимая классификация'), + code("import { classifyHttpResponse } from './upgrade-2026-09.mjs';\n\nconsole.log(classifyHttpResponse({\n status: 409,\n contentType: 'application/problem+json',\n body: { type: 'https://example.test/problems/version-conflict' },\n}));\n// { kind: 'domain-rejection', next: 'read-problem-type-and-show-recoverable-action' }\n\nconsole.log(classifyHttpResponse({\n status: 200, contentType: 'application/json',\n body: { status: 'ready', allowedActions: ['edit'], messageCode: 'order.ready' },\n}));\n// { kind: 'screen-model-ready', next: 'render-from-contract' }"), + p('Функция классифицирует только названные свойства наблюдения. Она не объявляет операцию успешной по наличию поля detail и не делает retry автоматически. Для приложения это удобная точка тестирования: одна таблица входов проверяет, что 409 не попал в ветку network failure, а 200 с неподходящей model не прошёл в render.'), + p('В реальном клиенте стоит добавить request id к технической записи и убрать из неё тело problem detail, если оно может содержать пользовательский ввод. Для пользователя нужны локализованный message code и один следующий шаг. Такой минимум лучше длинного сообщения, которое перечисляет внутренний stack trace и оставляет человека без решения.'), + h2('Query и command не обязаны возвращать одно и то же'), + p('После команды возможны три формы: новая screen model в ответе, 202 с идентификатором отслеживания или 204 без представления. Нельзя написать общий handler «после POST обновить store» и считать задачу решённой. Для 202 нужна отдельная модель состояния операции и правило polling или push. Для 204 нужно определить, какой ресурс инвалидировать и когда перечитать его. Контракт должен назвать форму, иначе каждый клиент изобретёт свою.'), + p('Идемпотентность относится к смыслу команды, а не к тому, как выглядит кнопка. Повтор с тем же ключом может вернуть тот же результат или безопасно сообщить о предыдущем выполнении; без такой договорённости retry после timeout не даёт клиенту права считать, что команда не дошла. Timeout — это неизвестный исход, а не отрицательный исход. UI должен показать это различие и дать безопасный путь синхронизации.'), + h2('Шесть тестов, которые окупаются'), ol([ - 'Назвать один сценарий и дату: это план на сентябрь, состояние источников ограничено 31 июля.', - 'Записать command как intent с key для различимости повторов, не как обещание успешной мутации.', - 'Сформулировать query и screen read model через выводы, которые реально нужны view.', - 'Для каждой UI-ветки указать поле contract; ветку без поля заменить stop question.', - 'Описать problem envelope отдельно от HTTP-статуса и назвать retry boundary.', - 'Прогнать negative fixed literal и передать только synthetic hand-off с open question.', + 'Валидный 200 с полной screen model: компонент получает ровно разрешённые действия.', + '200 с неизвестным status: render не вызывается, ошибка формы отделена от transport.', + '204: parser не запускается, ресурс помечается для повторного чтения.', + '409 с problem type: пользователь получает действие перечитать, а не автоматический бесконечный retry.', + '422 с кодом поля: ошибка привязывается к input, а не к общему toast.', + '429 и 500: политика повторов ограничена бюджетом и учитывает идемпотентность команды.', ]), - h2('Граница источников и граница механизма'), - p('OpenAPI 3.1.1 поддерживает идею явного описания операции и сообщений, но не вводит наше разделение local/context/domain state. JSON Schema Core поддерживает vocabulary и ссылки в schema, но не решает, какие fields нужны человеку на экране. Эти решения — проектная модель P103. Review отмечает это отдельно, чтобы ссылкой на стандарт не прикрыть архитектурный вкус как доказанный факт.'), - p('Механизм не покрывает streaming updates, eventual consistency, cache coherence, авторизацию и финансовую идемпотентность. Также он не измеряет удобство API и не обещает уменьшение числа ошибок. Для таких утверждений нужны реальные наблюдения, которых fixed literal не содержит. Следующий шаг — создать named case с корректным command, но без messageCode, и проверить точность stop reason; только потом обсуждать, нужен ли новый contract field.'), + h2('Ограничения и следующий шаг'), + p('Классификатор не проектирует бизнес-статусы и не заменяет спецификацию API. Один и тот же HTTP-код может иметь разный прикладной смысл для разных операций; поэтому таблицу нужно привязать к конкретному контракту. Problem Details тоже не решает authorization, приватность и наблюдаемость. Он даёт форму envelope, а не готовую политику продукта.'), + p('Следующий шаг — выбрать один command с потенциальным повтором, добавить в контракт ответ при неизвестном исходе и написать тест на timeout. Если после этого UI всё ещё меняет state до query, граница не закончена. Нужен не новый флаг, а явный переход: команда отправлена, результат неизвестен, read model перечитана или получен problem detail.'), h2('Проверяемые источники'), sourceList([ - { key: 'openapi', use: 'Использован для узкой роли: операция и сообщения могут быть описаны как интерфейс HTTP API.', boundary: 'Не устанавливает наши правила command/query или состав screen model.' }, - { key: 'http', use: 'Использован для разграничения HTTP semantics и application-level problem policy.', boundary: 'Не подтверждает retry, error rate или итог конкретного обмена.' }, - { key: 'schema', use: 'Использован как пример явной vocabulary contract-документа.', boundary: 'Не выводит business meaning из валидности JSON.' }, + { key: 'http', use: 'Использован для семантики классов HTTP-ответов и различия transport/application meaning.', boundary: 'Не назначает конкретные статусы для бизнес-операции.' }, + { key: 'problem', use: 'Использован для структуры Problem Details и разделения machine type и human detail.', boundary: 'Не определяет локализацию, retry или доступность действия.' }, + { key: 'openapi', use: 'Использован для идеи явно описывать response variants у операции.', boundary: 'Не гарантирует, что реализация и фактический payload совпадают.' }, ]), ].join(''), }; const field = { - ...commonMeta, slug: 'editorial-2026-09-field-frontend-backend-boundary', - title: 'Сценарий на сентябрь: synthetic hand-off для границы UI и API', - excerpt: 'Как подготовить безопасную карточку review без изображения production trace, чтобы несогласованный ownership останавливал обсуждение точной причиной.', + readingMinutes: 14, + tags: ['frontend', 'backend', 'debugging', 'http'], + slug: 'editorial-2026-09-field-frontend-backend-boundary', + title: 'UI и API расходятся: полевой протокол диагностики без догадок', + excerpt: 'Пошаговый разбор рассинхрона: какие четыре факта снять на границе, как отличить stale state от неверного ответа и где остановить расследование.', contentHtml: [ - p('В сентябрьском сценарии проблема review часто не в отсутствии диаграммы, а в подмене evidence. Автор показывает «типичный ответ» и произносит, что экран уже согласован с API, хотя пример не имеет владельца, даты и отрицательной ветки. Цена такой уверенности — решение принимают по иллюстрации, которую нельзя повторить: при первом расхождении UI и backend спорят, была ли это договорённость или просто локальный mock.'), - p('Этот материал — план безопасного synthetic hand-off на сентябрь 2026 при состоянии источников на 31 июля. Он не описывает проведённый review и не имитирует UI-network trace. Вместо него используется named fixed literal с заранее известными полями. Положительный исход ограничен передачей карточки следующему reviewer; он не утверждает approval, deployment, публикацию или доступ к production.'), - h2('Карточка evidence должна содержать то, что может опровергнуть вывод'), - p('Поле ui.localState показывает, что interaction принадлежит экрану; ui.context фиксирует условия интерпретации; api.command называет намерение; api.readModel задаёт достаточное представление; api.error ограничивает реакцию. Эти поля не являются дампом данных. Они составлены так, чтобы reviewer мог отвергнуть карточку по одному отсутствующему условию, не достраивая его опытом или знанием настоящей системы.'), - p('Контрпример важнее красивой positive ветки. Если UI уже содержит choice-confirmed в forbiddenDomainClaims, функция не говорит «почти готово». Она возвращает stop-ui-claims-domain-state. Это не обвинение frontend и не факт о реальном приложении. Это свойство формального literal: boundary не позволяет выводу, пока claim не заменён read question или не закреплён соответствующим контрактом.'), - figure('/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg', 'Цикл из пяти шагов: fixed literal, проверка ownership, stop или counterexample, уточнённый literal и synthetic hand-off. Красная ветка от domain claim возвращает к вопросу о read model.', 'Evidence loop останавливает недоказанный вывод и возвращает его к именованному входу, а не к реальному окружению.'), - table('Поля безопасной hand-off карточки', ['Поле', 'Зачем оно нужно', 'Пример fixed value', 'Что оно не доказывает'], [ - ['localState', 'отделить interaction от domain', 'draft-filter', 'что пользователь действительно вводил фильтр'], - ['context', 'назвать условие интерпретации', 'fixed-locale', 'реальное permission или session'], - ['command', 'зафиксировать intent', 'fixed-submit-choice', 'успешное изменение'], - ['readModel', 'дать основание UI-ветке', 'allowedActions', 'состояние настоящего экрана'], - ['problem', 'сохранить stop semantics', 'named-problem', 'наличие сетевой ошибки'], + p('Симптом «кнопка показывает одно, API — другое» редко объясняется одной строкой. В браузере мог остаться старый store, прокси мог вернуть не тот Content-Type, команда могла завершиться 409, а компонент — считать любой ответ подтверждением. Цена бессистемной диагностики — повторные ручные проверки и исправление не той стороны: команда меняет сервер, когда проблема в render input, или переписывает UI, когда контракт уже нарушен.'), + p('Нужен короткий протокол, который фиксирует наблюдаемые факты в правильном порядке: пользовательское намерение, запрос, ответ и фактический вход в рендер. Нельзя начинать с гипотезы «кэш виноват» или «backend сломался». В статье соберём безопасную карточку расследования, дадим чистую функцию для сравнения данных и покажем, какие выводы разрешены на каждом шаге.'), + h2('Четыре снимка одной операции'), + p('Первый снимок — намерение: какой action выбрал пользователь и с какими нормализованными параметрами. Не нужно сохранять весь ввод; достаточно request id, operation name и безопасного класса входа. Второй — исходящий HTTP: method, route template, статус и заголовок correlation/request id без query-параметров и секретов. Третий — ответ: status, Content-Type, размер и проверенная форма тела. Четвёртый — объект, который реально получил компонент после adapter.'), + figure('/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg', 'Цикл диагностики из четырёх снимков: intent, request, response, render input. Ветви ведут к stale UI, нарушению контракта или корректному обновлению.', 'Сравнивать нужно соседние границы. Снимок сети без render input не объясняет, почему экран остался прежним.'), + table('Карточка диагностики рассинхрона', ['Факт', 'Минимальное поле', 'Безопасный пример', 'Какой вопрос закрывает'], [ + ['Intent', 'operation + input class', 'update-address / valid-form', 'какую команду хотел выполнить UI?'], + ['Request', 'method + route template + request id', 'PATCH /orders/:id + req-42', 'что действительно ушло за границу?'], + ['Response', 'status + media type + contract result', '409 + problem + valid envelope', 'что сервер сообщил на уровне контракта?'], + ['Render input', 'view model + revision', 'blocked + version 18', 'что именно получил компонент?'], + ['Decision', 'next action', 'refetch / fix field / inspect adapter', 'какой следующий тест даст различие?'], ]), - h2('Что именно является evidence в synthetic работе'), - p('Evidence здесь — не лог браузера и не screenshot. Это связь между одним известным входом и результатом pure function. Вход создаётся JSON clone и deep freeze, поэтому caller не может тихо изменить fixture между проверкой и hand-off. Determinism полезен ровно в своей области: reviewer может запустить public export и получить тот же named stop или тот же limited hand-off. Он не может из этого узнать задержку сети, порядок событий или поведение реального кода.'), - p('Отдельно полезно писать границу прямо в output: productionEffect: not-attempted. Обычно такую строку считают избыточной, пока positive status не начинают читать как разрешение. Здесь статус synthetic-boundary-review-hand-off означает только полноту заранее заданной структуры. Он не утверждает, что ownership выбран верно для продукта, не меняет конфигурацию и не разрешает кому-либо отправлять command в настоящий сервис.'), - h2('Почему fixed literal лучше «правдоподобного» примера'), - p('Правдоподобный mock соблазняет деталями: URL, имя клиента, задержка, fragment response. Но чем точнее он похож на среду, тем проще читателю принять его за наблюдение. Для будущего месяца это особенно опасно: вымышленная trace визуально выглядит как выполненная работа. Named fixed literal отказывается от этой достоверности нарочно. Его values звучат искусственно — fixed-locale, named-key-v1 — и этим не дают перепутать модель с фактом.'), - p('JSON clone перед freeze нужен не для демонстрации приёма JavaScript, а для границы владения fixture. Проверяемая функция не получает ссылку на скрыто изменяемый внутренний объект. Она получает новый JSON-compatible input, после чего вложенные поля заморожены. Это не защищает реальную память процесса и не является security control. Это минимальное свойство учебного примера: один reviewer не может случайно изменить allowedActions перед передачей карточки другому.'), - p('Отсутствие clock — ещё одна сознательная граница. В пакете нельзя сказать «после двух секунд» или «сначала пришёл response», потому что в literal нет времени и событий. Вместо таймлайна есть порядок review: вход, проверка, reason, next action. Такой порядок не претендует на causal trace. Он удобен, когда обсуждается качество контракта: причинность должна быть подтверждена отдельным наблюдением, а не дорисована последовательностью стрелок.'), - p('Наконец, synthetic hand-off не является документом согласования между командами. Он только делает disagreement видимым. Реальный agreement потребовал бы участников, версий, полномочий и, вероятно, изменения описания API; ни одного такого факта P103 не содержит. Поэтому карточка завершает свою работу словом hand-off, а не «решение принято». Это сохраняет полезную дистанцию между подготовкой разговора и внешним действием.'), - p('В поле особенно соблазнительно заменить отсутствие evidence словами «этот путь типичен». Для P103 это запрещённая экономия. Типичным может быть только named fixture, а не пользователь, экран, endpoint или последовательность пакетов. Если следующий reviewer хочет проверить реальный boundary, он должен создать отдельную задачу с разрешёнными inputs и получить доступ к ним в своём процессе. Синтетическая карточка не переносит ему ни данных, ни полномочий, зато точно фиксирует, какую неизвестность нельзя прятать за правдоподобным примером. Это сохраняет применимость материала: он учит форме вопроса, а не создаёт ложное знание о системе, которую не наблюдали.'), - h2('Буквальный пример передачи, а не одобрения'), - code("import { createFixedBoundaryCase, explainFixedBoundaryInference } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('named-screen-boundary-v1');\nconst result = explainFixedBoundaryInference(input);\nconsole.log({ status: result.status, next: result.nextAction, effect: result.productionEffect });\n// { status: 'synthetic-inference-boundary-explained', next: 'hand-off-fixed-synthetic-boundary-review', effect: 'not-attempted' }"), - p('Это максимальный положительный выход P103. Snippet использует только named fixed literals; он не запускает UI, не читает файл, не обращается к секрету и не делает запрос. Слово hand-off здесь означает передачу формулировки для дальнейшего review, а не передачу контроля над средой. Такой узкий output делает полезной и negative ветку: при дефекте она сообщает ровно, какого поля не хватает, вместо декоративного «проверить интеграцию». '), - h2('Как reviewer читает карточку'), - p('Сначала он ищет ownership conflict: доменное утверждение в UI, контекст, спрятанный в read model, или command без intent. Затем он читает constraints у error envelope: есть ли code и retry boundary, а не просто строка ошибки. После этого он проверяет вывод: соответствует ли status одному из явно названных состояний функции. Если карточка просит сделать шаг в окружении, которого literal не касается, review обязан остановиться — даже если описание кажется правдоподобным.'), - p('Эта процедура нарочно скупа на «истории успеха». В ней нет synthetic метрики, потому что числовой эффект легко спутать с наблюдением. В ней нет названия настоящей команды или endpoint, потому что их нельзя проверить из изолированного пакета. Вместо этого сохраняется контрпример с неназванной command и контрпример с неоперабельной ошибкой. Они учат лучше, чем happy path: показывают момент, когда автор обязан сменить вопрос.'), - h2('Порядок сентябрьского hand-off'), + h2('Сначала сравнить, потом объяснять'), + p('Если response valid, а render input старый, ищите adapter, cache key или race между двумя запросами. Если render input совпадает с ответом, но на экране другое, проверяйте selector, memoization и локальный optimistic state. Если response invalid, UI не обязан его отображать; источник проблемы находится на контрактной границе. Если status 409, не называйте его «ошибкой сети»: запрос дошёл, но переход не состоялся по прикладной причине.'), + p('Полезно сохранять revision или version, если API их предоставляет. Timestamp не заменяет версию: часы разных процессов могут отличаться, а более поздний ответ не всегда относится к более новому состоянию. Сравнение версии помогает обнаружить race: запрос A ушёл первым, B — вторым, но B вернулся раньше. Без явного правила store может принять старый ответ последним.'), + h2('Проверяемый пример сравнения'), + code("import { diagnoseBoundaryObservation } from './upgrade-2026-09.mjs';\n\nconsole.log(diagnoseBoundaryObservation({\n response: {\n status: 202,\n contentType: 'application/json',\n body: { status: 'blocked', allowedActions: ['retry'], messageCode: 'order.sync_required' },\n },\n ui: { renderedStatus: 'ready' },\n}));\n// { status: 'ui-api-state-mismatch', next: 'compare-render-input-with-response-body' }"), + p('Эта функция не читает браузер и не делает сетевой запрос. Она полезна как тест для диагностического слоя: при одинаковом контракте она отличает расхождение render input от ошибки формы. В приложении фактические снимки нужно получать из инструментов с фильтрацией персональных данных. Не записывайте body целиком в лог только потому, что так проще: request id и hash безопасного класса часто достаточно, чтобы связать события.'), + p('При отсутствии response функция возвращает отдельное состояние, а не «UI stale». Это важная дисциплина. Нельзя приписывать причину месту, которое ещё не наблюдали. Так же нельзя считать отсутствие нового render proof того, что backend не ответил: событие могло потеряться в adapter, отмениться при unmount или быть отброшено как устаревшее.'), + h2('Сигналы cache и race'), + p('Cache обычно выдаёт повторяемость: один и тот же ключ возвращает прежнюю версию, хотя network request уже получил новую. Race выдаёт зависимость от порядка: обновление состояния меняется при задержке одного из ответов. Для проверки cache сравните request key, revision и источник данных. Для race искусственно задержите только тестовый ответ и проверьте правило принятия версии. Не делайте вывод о поведении пользователя по одному снимку.'), + p('Если команда использует optimistic UI, назовите два состояния: локальное подтверждение взаимодействия и подтверждённая read model. Пока ответ не прочитан, на кнопке допустимо показать spinner или disable, но нельзя менять общий status на «ready» только потому, что click handler завершился. После ошибки optimistic patch должен быть снят или помечен как требующий решения. Иначе следующий render унаследует ложную модель.'), + h2('HTTP-инструменты и граница их показаний'), + p('DevTools Network показывает запрос и ответ в конкретном браузере. curl позволяет повторить HTTP-обмен, но не воспроизводит selector, cookie policy или race компонента. Логи BFF показывают серверную обработку, но не доказывают, что браузер получил именно этот response. Каждый инструмент закрывает свою границу. Скриншот одного слоя нельзя использовать как доказательство другого.'), + p('Для curl-проверки сохраняйте только безопасные заголовки и заменяйте реальные идентификаторы тестовыми. Проверяйте статус и Content-Type, затем тело на соответствие schema. Если endpoint требует авторизацию, используйте тестовый токен с ограниченным сроком; не вставляйте секрет в статью, shell history или issue. В production не повторяйте команду изменения без знания идемпотентности.'), + h2('Пример минимальной curl-проверки'), + code("curl --fail-with-body --silent --show-error \\\n -H 'Accept: application/json' \\\n -H 'X-Request-Id: req-42' \\\n 'https://api.example.test/orders/42' \\\n | jq '{status, allowedActions, messageCode}'"), + p('Команда подходит для чтения тестового ресурса, а не для бездумного повторения mutation. --fail-with-body не валидирует screen model и не превращает ошибку в успех; jq только выбирает поля для просмотра. В рабочем расследовании замените host, путь и идентификатор на разрешённые тестовые значения и сохраните рядом HTTP-статус и Content-Type. Если ответ не JSON, это отдельная находка, а не повод считать jq виноватым.'), + h2('Порядок расследования'), ol([ - 'Пометить карточку как план/сценарий на сентябрь и записать cutoff источников 31.07.2026.', - 'Собрать один fixed literal с local state, context, command, read model и error envelope.', - 'Запустить positive literal и проверить, что output ограничен synthetic hand-off и not-attempted.', - 'Запустить literal с UI domain claim и сохранить exact stop reason.', - 'Запустить literal с пустым command или error envelope и не заменять stop догадкой.', - 'Передать reviewer только вход, output, границы и открытый вопрос; не добавлять claims о real UI/API.', + 'Зафиксировать operation name, request id и класс входа без персональных значений.', + 'Снять method, route template, HTTP status и Content-Type; не повторять mutation до проверки идемпотентности.', + 'Проверить response по контракту и отделить transport failure от problem detail и domain rejection.', + 'Сравнить проверенный response с render input: version, status, allowed actions и message code.', + 'Если они расходятся, проверить adapter, cache key, optimistic patch и порядок ответов.', + 'Сформулировать один следующий тест, который различает две оставшиеся гипотезы, и только затем менять код.', ]), - h2('Ограничения и следующий вопрос'), - p('Synthetic hand-off не заменяет контрактное тестирование, доступность, security review и проверку authorization. Он не определяет, где хранить cache, как кодировать проблему на wire и как версии читать старым клиентам. OAS и JSON Schema помогают описывать форму, RFC 9110 — смысл HTTP, но ни один из этих источников не подтверждает, что предлагаемая форма полезна конкретному человеку. P103 сознательно не делает такого заявления.'), - p('Следующий шаг — на отдельном design review создать ещё один fixed literal с известным несовпадением: command named, но read model без allowedActions. Если reason останется точным и не даст «продолжить на глаз», сценарий готов к обсуждению следующего контракта. Если нет, следует уточнить форму карточки. До независимой работы с настоящими inputs нельзя говорить, что сентябрьский boundary уже реализован, измерен или опубликован.'), + h2('Когда остановить расследование'), + p('Остановитесь, если для следующего вывода нужны неполученные данные: реальный body с персональными полями, доступ к production-логам или повторная команда с неизвестным эффектом. Это не бюрократия, а граница доказательства. Попросите владельца системы дать безопасный correlation id, redacted response или тестовый reproduction. Нельзя заполнять пробел правдоподобной историей о кэше или сервере.'), + p('Если четыре снимка согласованы, а пользователь всё ещё видит другое, расследование переходит в слой представления: selector, memoization, hydration или CSS. Если несогласован только response, проблема остаётся у producer или gateway. Если request отличается от intent, ищите mapping формы. Такая классификация сокращает область поиска и делает исправление проверяемым.'), + h2('Ограничения и следующий шаг'), + p('Протокол не заменяет distributed tracing, contract testing и security review. Он не сообщает, что данные можно хранить сколько угодно, и не разрешает логировать тело ответа. Его задача уже: разделить наблюдения по границам и не назвать гипотезу фактом. Для сложной асинхронной команды добавьте message id, version и отдельный статус обработки; не растягивайте одну HTTP-карточку на очередь.'), + p('Следующий шаг — сделать один интеграционный тест, который сохраняет четыре безопасных поля и воспроизводит stale render input. Затем добавьте regression test на неверный Content-Type и test на более старую version. Когда эти тесты проходят, команда получает не красивый отчёт, а короткий маршрут от симптома к конкретной границе.'), h2('Проверяемые источники'), sourceList([ - { key: 'openapi', use: 'Использован только как первичный источник для понятия описываемого HTTP API interface.', boundary: 'Не является evidence настоящего hand-off или release.' }, - { key: 'http', use: 'Использован для границы между protocol semantics и application-level decision.', boundary: 'Не сообщает о реальном response, user или сети.' }, - { key: 'schema', use: 'Использован для идеи явно структурированной проверяемой карточки.', boundary: 'Не подтверждает достоверность значения внутри fixed literal.' }, + { key: 'http', use: 'Использован для различения ответа HTTP, representation и прикладной семантики статусов.', boundary: 'Не показывает состояние конкретного браузера или store.' }, + { key: 'fetch', use: 'Использован для границы между Fetch response и решением приложения об обработке body.', boundary: 'Стандарт не задаёт ваш adapter, cache policy или UI render.' }, + { key: 'problem', use: 'Использован для структурированного problem envelope при диагностике 4xx/5xx.', boundary: 'Не является логом конкретного сервиса и не заменяет redaction policy.' }, ]), ].join(''), }; export const revisions = [practice, mechanism, field]; + export function verifyRevisionsAgainstFixture() { - const fixture = runFixedBoundaryFixture(); - const articleChecks = revisions.map((revision) => { const text = bodyText(revision.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк)/i.test(text.slice(0, 900)) && //.test(revision.contentHtml) && /
/.test(revision.contentHtml) && /
/.test(revision.contentHtml) && /
    /.test(revision.contentHtml); }); - return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((revision) => [revision.slug, bodyText(revision.contentHtml).length])) }); + const checks = [ + checkScreenResponse({ status: 'ready', allowedActions: ['edit'], messageCode: 'order.ready' }).valid, + !checkScreenResponse({ status: 'unknown', allowedActions: 'edit' }).valid, + classifyHttpResponse({ status: 204 }).kind === 'success-without-representation', + classifyHttpResponse({ status: 409, contentType: 'application/problem+json', body: {} }).kind === 'domain-rejection', + diagnoseBoundaryObservation({ response: { status: 200, contentType: 'application/json', body: { status: 'ready', allowedActions: [], messageCode: 'ok' } }, ui: { renderedStatus: 'pending' } }).status === 'ui-api-state-mismatch', + ]; + const articleChecks = revisions.map((revision) => { + const text = bodyText(revision.contentHtml); + return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|затрат|потер)/i.test(text.slice(0, 900)) && /
    /.test(revision.contentHtml) && /
/.test(revision.contentHtml) && /
/.test(revision.contentHtml) && /
    /.test(revision.contentHtml); + }); + return { + passed: checks.filter(Boolean).length + articleChecks.filter(Boolean).length, + total: checks.length + articleChecks.length, + accepted: checks.every(Boolean) && articleChecks.every(Boolean), + checks, + articleChecks, + characters: Object.fromEntries(revisions.map((revision) => [revision.slug, bodyText(revision.contentHtml).length])), + }; } -if (process.argv.includes('--verify-fixture')) { const report = verifyRevisionsAgainstFixture(); console.log(JSON.stringify(report, null, 2)); if (!report.accepted) process.exitCode = 1; } + +if (process.argv.includes('--verify-fixture')) { + const report = verifyRevisionsAgainstFixture(); + console.log(JSON.stringify(report, null, 2)); + if (!report.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions)); diff --git a/web/scripts/upgrade-2027-01.mjs b/web/scripts/upgrade-2027-01.mjs index 8705995..ad14f01 100644 --- a/web/scripts/upgrade-2027-01.mjs +++ b/web/scripts/upgrade-2027-01.mjs @@ -1,189 +1,278 @@ -function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +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 figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); +} -const REFERENCES = deepFreeze({ - traceContext: { title: 'W3C Trace Context', url: 'https://www.w3.org/TR/2021/REC-trace-context-1-20211123/', version: 'W3C Recommendation, 23 November 2021' }, - rfc5424: { title: 'RFC 5424: The Syslog Protocol', url: 'https://www.rfc-editor.org/rfc/rfc5424', version: 'March 2009, DOI 10.17487/RFC5424' }, - nistLogs: { title: 'NIST SP 800-92: Guide to Computer Security Log Management', url: 'https://doi.org/10.6028/NIST.SP.800-92', version: 'September 2006, DOI 10.6028/NIST.SP.800-92' }, -}); -function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '
  • ' + escapeHtml(ref.title) + ' — версия: ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } +function bodyText(html) { + return plainText(html.replace(/

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

|$)/, '')); +} -const FIXED_DIAGNOSTIC_CASES = deepFreeze({ - 'planned-handoff-v1': { - id: 'planned-handoff-v1', planDate: '2027-01', sourceCutoff: '2026-07-31', - symptom: { id: 'route-result-mismatch', statement: 'synthetic-question-only' }, - hypothesis: { id: 'context-boundary-unmapped', statement: 'synthetic-question-only' }, - signals: [{ id: 'request-context-carrier', role: 'planned-input' }, { id: 'named-event-record', role: 'planned-input' }], - evidence: { kind: 'synthetic-contract', strength: 'input', records: 'not-collected' }, - requestedConclusion: 'synthetic-plan-hand-off', - boundary: 'Fixed in-memory planning literal. No report, log, trace, repro, browser, network, file, environment, clock, secret, telemetry backend, external tool, production system, customer data, investigation, measurement or incident is read, created or changed.', +const REFERENCES = Object.freeze({ + trace: { + title: 'Trace Context — W3C Recommendation', + url: 'https://www.w3.org/TR/trace-context/', + version: 'Recommendation, 23 November 2021', + }, + syslog: { + title: 'RFC 5424: The Syslog Protocol', + url: 'https://www.rfc-editor.org/rfc/rfc5424.html', + version: 'Standards Track, March 2009, DOI 10.17487/RFC5424', + }, + http: { + title: 'RFC 9110: HTTP Semantics', + url: 'https://www.rfc-editor.org/rfc/rfc9110.html', + version: 'Internet Standard, June 2022, DOI 10.17487/RFC9110', + }, + nistLogs: { + title: 'NIST SP 800-92: Guide to Computer Security Log Management', + url: 'https://csrc.nist.gov/pubs/sp/800/92/final', + version: 'September 2006, DOI 10.6028/NIST.SP.800-92', }, - 'undated-scenario-v1': { id: 'undated-scenario-v1', planDate: '', sourceCutoff: '2026-07-31', symptom: { id: 'route-result-mismatch', statement: 'synthetic-question-only' }, hypothesis: { id: 'context-boundary-unmapped', statement: 'synthetic-question-only' }, signals: [{ id: 'request-context-carrier', role: 'planned-input' }], evidence: { kind: 'synthetic-contract', strength: 'input', records: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'unnamed-signal-v1': { id: 'unnamed-signal-v1', planDate: '2027-01', sourceCutoff: '2026-07-31', symptom: { id: 'route-result-mismatch', statement: 'synthetic-question-only' }, hypothesis: { id: 'context-boundary-unmapped', statement: 'synthetic-question-only' }, signals: [{ id: '', role: 'planned-input' }], evidence: { kind: 'synthetic-contract', strength: 'input', records: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'unnamed-hypothesis-v1': { id: 'unnamed-hypothesis-v1', planDate: '2027-01', sourceCutoff: '2026-07-31', symptom: { id: 'route-result-mismatch', statement: 'synthetic-question-only' }, hypothesis: { id: '', statement: '' }, signals: [{ id: 'request-context-carrier', role: 'planned-input' }], evidence: { kind: 'synthetic-contract', strength: 'input', records: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'evidence-stronger-than-input-v1': { id: 'evidence-stronger-than-input-v1', planDate: '2027-01', sourceCutoff: '2026-07-31', symptom: { id: 'route-result-mismatch', statement: 'synthetic-question-only' }, hypothesis: { id: 'context-boundary-unmapped', statement: 'synthetic-question-only' }, signals: [{ id: 'request-context-carrier', role: 'planned-input' }], evidence: { kind: 'observed-trace', strength: 'observed', records: 'claimed-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'positive-conclusion-v1': { id: 'positive-conclusion-v1', planDate: '2027-01', sourceCutoff: '2026-07-31', symptom: { id: 'route-result-mismatch', statement: 'synthetic-question-only' }, hypothesis: { id: 'context-boundary-unmapped', statement: 'synthetic-question-only' }, signals: [{ id: 'request-context-carrier', role: 'planned-input' }], evidence: { kind: 'synthetic-contract', strength: 'input', records: 'not-collected' }, requestedConclusion: 'root-cause-confirmed', boundary: 'Negative fixed literal only.' }, }); -export function createFixedDiagnosticCase(id = 'planned-handoff-v1') { const value = FIXED_DIAGNOSTIC_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } -export function assessFixedDiagnosticPlan(input) { - if (!Object.values(FIXED_DIAGNOSTIC_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case'); - if (input.planDate !== '2027-01' || input.sourceCutoff !== '2026-07-31') return stop('stop-undated-scenario-or-cutoff', 'january-2027-plan-and-july-2026-cutoff-are-required', 'name-2027-01-and-2026-07-31'); - if (!input.symptom?.id || !input.symptom?.statement || !input.hypothesis?.id || !input.hypothesis?.statement) return stop('stop-unnamed-symptom-or-hypothesis', 'symptom-and-hypothesis-must-be-named-synthetic-questions', 'name-the-question-and-its-testable-alternative'); - if (!Array.isArray(input.signals) || input.signals.length < 1 || input.signals.some((signal) => !signal.id || signal.role !== 'planned-input')) return stop('stop-unnamed-signal', 'every-signal-must-have-a-name-and-planned-input-role', 'name-the-signal-without-claiming-a-record'); - if (input.evidence?.kind !== 'synthetic-contract' || input.evidence?.strength !== 'input' || input.evidence?.records !== 'not-collected') return stop('stop-evidence-stronger-than-input', 'a-plan-cannot-contain-observed-or-collected-evidence', 'keep-evidence-at-synthetic-input-strength'); - if (input.requestedConclusion !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-conclusion', 'root-cause-confirmation-or-other-positive-conclusion-is-forbidden', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, symptom: deepFreeze(cloneFixed(input.symptom)), hypothesis: deepFreeze(cloneFixed(input.hypothesis)), signals: deepFreeze(cloneFixed(input.signals)), evidence: deepFreeze(cloneFixed(input.evidence)), boundary: input.boundary, productionEffect: 'not-attempted', nextAction: 'give-the-fixed-question-contract-to-a-future-evidence-owner' }); -} -export function runFixedDiagnosticFixture() { - const expected = [['planned-handoff-v1', 'synthetic-plan-hand-off'], ['undated-scenario-v1', 'stop-undated-scenario-or-cutoff'], ['unnamed-signal-v1', 'stop-unnamed-signal'], ['unnamed-hypothesis-v1', 'stop-unnamed-symptom-or-hypothesis'], ['evidence-stronger-than-input-v1', 'stop-evidence-stronger-than-input'], ['positive-conclusion-v1', 'stop-disallowed-positive-conclusion']]; - const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessFixedDiagnosticPlan(createFixedDiagnosticCase(id)).status })); - return deepFreeze({ passed: checks.filter((check) => check.expected === check.actual).length, total: checks.length, accepted: checks.every((check) => check.expected === check.actual) && Object.isFrozen(createFixedDiagnosticCase()) && Object.isFrozen(createFixedDiagnosticCase().signals), checks: deepFreeze(checks) }); +function sources(entries) { + return '
    ' + entries.map(({ key, use, boundary }) => { + const reference = REFERENCES[key]; + return '
  • ' + escapeHtml(reference.title) + ' — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; + }).join('') + '
'; } -function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } +export function classifyWebSymptom(input) { + const status = Number(input?.status); + const body = String(input?.body ?? ''); + const headers = Object.fromEntries(Object.entries(input?.headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)])); + if (status === 502 || status === 504) { + return { class: 'gateway-failure', check: headers['traceparent'] || headers['x-request-id'] ? 'сопоставить gateway и upstream по идентификатору' : 'сначала включить идентификатор запроса на границе', evidence: 'access-status-and-upstream-log' }; + } + if (status === 401 || status === 403) return { class: 'access-failure', check: 'сверить схему аутентификации и решение авторизации', evidence: 'request-auth-and-policy' }; + if (status === 200 && /empty|blank|undefined/i.test(body)) return { class: 'rendering-or-data-failure', check: 'разделить пустой ответ API и пустой DOM', evidence: 'response-body-and-rendered-output' }; + return { class: 'unclassified', check: 'записать метод, путь, статус, размер ответа и время', evidence: 'structured-request-envelope' }; +} + +export function linkTraceRecords(records) { + const bySpan = new Map(records.map((record) => [record.spanId, record])); + return records.map((record) => ({ + traceId: record.traceId, + spanId: record.spanId, + parent: record.parentSpanId ? bySpan.has(record.parentSpanId) ? 'present' : 'missing' : 'root', + service: record.service, + durationMs: record.durationMs, + })); +} + +export function buildRequestTimeline(accessEvents, applicationEvents) { + const access = accessEvents.map((event) => ({ ...event, source: 'access' })); + const application = applicationEvents.map((event) => ({ ...event, source: 'application' })); + const all = [...access, ...application].sort((left, right) => left.at.localeCompare(right.at)); + const requestIds = new Set(all.map((event) => event.requestId).filter(Boolean)); + return [...requestIds].map((requestId) => { + const events = all.filter((event) => event.requestId === requestId); + const gateway = events.find((event) => event.source === 'access'); + const app = events.find((event) => event.source === 'application'); + const result = gateway?.status >= 500 && !app ? 'gateway-failed-before-app' : gateway?.status >= 500 ? 'app-error-reached-client' : 'request-complete'; + return { requestId, result, events: events.map(({ source, at, status, message }) => ({ source, at, status, message })) }; + }); +} + +function revision(meta, parts, referenceEntries) { + const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); + return Object.freeze({ ...meta, contentHtml, proseLength }); +} const practice = revision({ - slug: 'editorial-2027-01-practice-debugging-decade', title: 'Десять лет web-диагностики: практический маршрут', categories: ['Инженерные практики', 'Надёжность'], cover: '/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg', excerpt: 'План на январь 2027: аккуратная карта от synthetic симптома к гипотезе без придуманного расследования.', readingMinutes: 20, + slug: 'editorial-2027-01-practice-debugging-decade', + title: 'Web-диагностика без прыжка к причине: симптом, проверка, действие', + categories: ['Инженерные практики', 'Надёжность'], + cover: '/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg', + excerpt: 'Как превратить 502, пустой ответ или отказ в короткий маршрут проверки, не меняя систему наугад.', + readingMinutes: 15, }, [ - p('План на январь 2027 с source cutoff 31.07.2026 начинается с привычной, дорогой поломки разговора: человек видит один симптом и сразу называет причину. В результате команда меняет слой, который просто оказался ближе, а не слой с проверяемой границей. Цена ошибки — часы на обратные изменения, новый шум в диагностике и более слабая память о том, какой вопрос вообще пытались решить.'), - p('Вторая проблема — ретроспективный тон там, где будущего расследования ещё нет. У нас нет bug report, логов, trace, repro, incident или инструмента, который мог бы подтвердить маршрут. Цена такого вымысла не литературная: следующий читатель может принять удобную историю за evidence и пропустить реальную проверку. Поэтому это только план/сценарий на 2027-01; единственный положительный output — synthetic-plan-hand-off с productionEffect: not-attempted.'), - h2('Диагностика начинается с формы вопроса'), - p('За десять лет web-диагностики изменились интерфейсы наблюдения, форматы контекста и привычки команд. Не изменилась одна граница: симптом не равен причине. Симптом — короткое, наблюдаемое описание расхождения, например «результат маршрута не совпадает с ожидаемым вопросом». Гипотеза — отдельное объяснение, которое можно опровергнуть. Между ними нужна карта: какие сигналы вообще могли бы различить альтернативы, какой владелец вправе их собирать и что останется неизвестным после каждого шага.'), - p('В этой статье карта намеренно synthetic. Имена route-result-mismatch и context-boundary-unmapped — fixed literals, не названия реального endpoint, сбоя или следа. Они полезны ровно тем, что не дают дорисовать детали. Хорошая карта не обещает универсальный метод: иногда первым нужен контракт API, иногда граница клиента и сервера, иногда вопрос должен быть снят как плохо поставленный. Здесь мы тренируем дисциплину связей, а не рассказываем о случившемся случае.'), - figure('/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg', 'Временная шкала плановой карты диагностики: от симптома через именованную гипотезу и плановые сигналы к synthetic hand-off; красные стопы отмечают неназванный сигнал и чрезмерный вывод.', 'Схема показывает изменения языка и инструментов как контекст для вопроса, но не утверждает историю конкретной команды, incident или набора артефактов.'), - table('Карта вопроса без выдуманного evidence', ['Узел', 'Что фиксируем в плане', 'Что он различает', 'Чего не разрешено говорить'], [ - ['Симптом', 'id и synthetic question', 'что именно не совпадает', 'что событие наблюдалось'], - ['Гипотеза', 'id и alternative question', 'какое объяснение проверяется', 'что причина подтверждена'], - ['Сигнал', 'имя и роль planned-input', 'какой будущий input нужен', 'что лог или trace уже существует'], - ['Evidence contract', 'synthetic-contract, input', 'предел будущей передачи', 'что evidence сильнее входа'], - ['Выход', 'synthetic-plan-hand-off', 'кому передать вопрос', 'что разрешён production change'], + p('Проблема web-диагностики обычно начинается с одного наблюдения: страница вернула 502, форма осталась пустой или запрос получил отказ. Инженер сразу называет виновный слой — базу, прокси или браузер — и начинает менять его. Цена такого прыжка измеряется не только временем. Случайный фикс убирает исходный сигнал, добавляет новый побочный эффект и оставляет следующему человеку только фразу «после изменения стало лучше».'), + p('Надёжный разбор начинается с конверта симптома. В нём есть метод, путь, статус, время, размер ответа, идентификатор запроса и граница, на которой запись получена. Эти поля не отвечают на вопрос «кто виноват». Они отвечают на более узкий вопрос: какой следующий сигнал отличит два правдоподобных объяснения. Такой порядок экономит часы: сначала уменьшаем пространство поиска, затем открываем конкретный лог или трассу.'), + h2('Симптом не равен причине'), + p('Статус HTTP описывает результат обмена на конкретной границе. 502 говорит клиенту, что шлюз получил недействительный ответ от upstream; он не говорит, почему upstream ответил так. Это может быть разрыв соединения, таймаут, ошибка маршрута или повреждённый ответ. Если в карточке инцидента оставить только «сервер упал», диагностика уже потеряла важное различие. В карточке должны соседствовать исходный статус и место, где он увиден.'), + p('То же относится к пустому экрану. Пустой HTML, ответ API с пустым массивом и ошибка рендера дают похожую картинку, но требуют разных проверок. Поэтому полезно сначала классифицировать внешний симптом, а не выбирать инструмент по привычке. Ниже показана маленькая функция для локального разбора учебного набора. Она не делает сетевых запросов: её задача — привести вход к следующему наблюдаемому шагу.'), + figure('/assets/editorial/2027/debugging-decade-2027-evolution-timeline.svg', 'Диагностический маршрут от web-симптома к проверяемому сигналу: конверт запроса, гипотеза, различающий признак и действие.', 'Схема показывает порядок работы с симптомом. На каждом переходе добавляется конкретный сигнал; сама схема не объявляет причину до проверки.'), + table('Минимальный конверт web-симптома', ['Поле', 'Пример', 'Что позволяет проверить', 'Чего не доказывает'], [ + ['Метод и путь', 'GET /checkout', 'какая операция повторяется', 'что именно сломано внутри'], + ['Статус', '502', 'на какой границе возник отказ', 'причину ответа upstream'], + ['Идентификатор', 'traceparent или request-id', 'связать записи разных слоёв', 'полноту цепочки'], + ['Время и длительность', '12:04:18, 4.2 с', 'найти окно в журнале', 'что задержка была единственной причиной'], + ['Тело и заголовки', 'empty, content-type', 'отличить данные от рендера', 'что пользователь увидел именно этот текст'], ]), - h2('Сигнал — не синоним данных'), - p('Слово «сигнал» легко маскирует незнание. В плановом record signal — это имя будущего input и его роль, а не запись в журнале. request-context-carrier не доказывает, что заголовок дошёл; named-event-record не доказывает, что событие было записано, сохранено или доступно. Такая разница скучна только пока не приходится объяснять, почему одна и та же строка кажется одновременно отсутствующим контекстом, ошибкой парсинга и задержкой доставки.'), - p('Нужно держать минимум три границы. Первая: значение может отсутствовать, и это ещё не означает дефект. Вторая: значение может быть названо, но не иметь interpretation contract. Третья: даже корректно интерпретированное значение не доказывает гипотезу, если альтернативы не исключены. Публичные спецификации помогают говорить о переносе контекста и полях сообщения, однако они не подтверждают наш synthetic route и не назначают нам порядок расследования.'), - h2('Гипотеза должна проигрывать честно'), - p('У полезной гипотезы есть возможный проигрыш. «Что-то потерялось на границе» удобно звучит, но не даёт следующего действия: граница не названа, альтернативы не названы, любой исход можно объявить совместимым с фразой. context-boundary-unmapped уже уже: он просит будущего владельца назвать boundary и carrier, но не утверждает, что контекст когда-либо передавался. Если гипотезу нельзя сделать слабее от будущего input, это не гипотеза, а название тревоги.'), - p('Эта строгость особенно нужна после долгой истории инструментов. Новый trace UI может дать ощущение причинности за счёт красивой связки, старый лог — ощущение простоты за счёт одной строки. Ни интерфейс, ни формат не отменяют inference boundary. Сначала формулируют, какая разница между вариантами имеет значение; затем определяют minimum input; потом отдельно решают, кому разрешено получить реальный evidence. В сценарии до последнего шага не происходит ничего, кроме проверки литерала.'), - h2('Литеральная карта безопасного маршрута'), - code("import { createFixedDiagnosticCase, assessFixedDiagnosticPlan } from './upgrade-2027-01.mjs';\n\nconst routeQuestion = createFixedDiagnosticCase('planned-handoff-v1');\nconst disposition = assessFixedDiagnosticPlan(routeQuestion);\nconsole.log(JSON.stringify([\n { field: 'kind', value: disposition.status },\n { field: 'effect', value: disposition.productionEffect },\n { field: 'owner-question', value: disposition.nextAction },\n]));\n// [{\"field\":\"kind\",\"value\":\"synthetic-plan-hand-off\"},{\"field\":\"effect\",\"value\":\"not-attempted\"},{\"field\":\"owner-question\",\"value\":\"give-the-fixed-question-contract-to-a-future-evidence-owner\"}]"), - p('Пример импортирует только эту модель и получает заранее именованную структуру из памяти. Клонирование изолирует её от исходного набора, а рекурсивная заморозка сохраняет форму во время вызова. Evaluator принимает лишь известный literal; произвольный object закрывается. Код не читает сеть, browser, файлы, environment, часы, secrets, telemetry или внешний инструмент. Он не генерирует report, не собирает trace и не создаёт repro. Его положительный результат — hand-off формы вопроса, не диагноз и не разрешение менять систему.'), - h2('Порядок подготовки карты на 2027-01'), - ol(['Явно пометить материал как план/сценарий на 2027-01 и рядом оставить source cutoff 2026-07-31.', 'Назвать один synthetic симптом без URL, пользователя, времени, incident или правдоподобной истории.', 'Записать одну гипотезу как опровержимое объяснение и отдельно назвать альтернативу, которую она не покрывает.', 'Дать каждому будущему сигналу id и роль planned-input; не подменять имя записью или метрикой.', 'Оставить evidence на уровне synthetic-contract/input/not-collected и проверить fail-closed stops.', 'Передать только synthetic-plan-hand-off будущему владельцу evidence, не объявляя root cause, fix или production effect.']), - h2('Карта полезна, когда сужает следующий вопрос'), - p('В диагностике легко измерять активность вместо продвижения: добавить полей, нарисовать больше стрелок, попросить «полный trace». Карта должна выдерживать обратный вопрос: какое решение станет другим, если будущий signal окажется пустым, неоднозначным или недоступным? Если ничего не меняется, signal пока не относится к гипотезе. Его стоит убрать из contract, а не собирать на всякий случай. Так уменьшается не только объём будущих данных, но и число историй, которые можно случайно прочитать в одном и том же наборе полей.'), - p('Есть полезная экономия и в порядке. Не надо сначала выбирать самый мощный инструмент, затем искать, что он мог бы показать. Сначала достаточно назвать smallest discriminating question: например, требуется ли вообще carrier на конкретной, пока synthetic границе. Если ответ нет, разговор о схеме записи и поиске не начинается. Если ответ да, будущий owner получает узкое требование, а не просьбу добыть всё. Это не правило для каждой системы; это способ не оплачивать наблюдаемость до того, как появился предмет различения.'), - h2('Почему историческая тема не даёт исторических прав'), - p('Название «десять лет» может соблазнить общими выводами: будто практика неизбежно движется от логов к trace или будто новый инструмент обязательно делает команду точнее. В источниках нет такого результата для нашего выпуска, а у редакции нет собственного корпуса case studies. Можно сказать скромнее: за период развивались стандарты и способы описывать контекст; мы используем их как словарь. Нельзя сказать, что конкретная организация прошла этот путь, быстрее находила дефекты или завершила десятилетний цикл улучшений.'), - p('Эта разница важна для читателя в будущем. Когда он откроет номер после января, дата публикации не должна превращать preparation в report. Source cutoff сохраняет, какие документы были допустимы при подготовке, но не заменяет факт. План сохраняет вопрос, но не событие. Если появится реальный материал, его provenance, дата, авторизация и ограничения должны быть записаны отдельно. Нельзя приклеить его к fixed literal и объявить, что он всегда имелся в виду.'), - h2('Ограничения карты и следующий шаг'), - p('Карта не измеряет задержку, полноту, sampling, retention, privacy impact или полезность инструмента. Она не говорит, какой signal лучше, и не делает любой named signal доступным. JSON clone и freeze — свойства учебного JS literal, а не защита реальной телеметрии. RFC 5424 описывает protocol message, а W3C Trace Context — перенос trace context; ни один источник не подтверждает наш симптом, существование конкретного carrier или причинность между ними.'), - p('Следующий шаг возможен только в будущем отдельном scope: назначить владельца evidence и определить разрешённые реальные inputs. До этого разумная работа — добавить ещё один negative fixed case и проверить точный stop. Не надо превращать этот draft в юбилейный отчёт задним числом. Десять лет здесь — тема планового выпуска, а не доказательство завершённой десятилетней ретроспективы.'), + h2('Учебный локальный классификатор'), + p('Функция принимает только наблюдаемую форму ответа: статус, заголовки и тело. Для 502/504 она проверяет наличие traceparent или x-request-id и предлагает сопоставить gateway с upstream. Для 401/403 ведёт к аутентификации и политике доступа. Для 200 с пустым содержимым разделяет данные и DOM. Входы намеренно маленькие: так видно, какое поле повлияло на выбор.'), + code(`import { classifyWebSymptom } from './upgrade-2027-01.mjs'; + +const samples = [ + { status: 502, headers: { traceparent: '00-abc-123-01' }, body: 'Bad Gateway' }, + { status: 200, headers: { 'content-type': 'text/html' }, body: '
empty
' }, +]; + +for (const sample of samples) console.log(classifyWebSymptom(sample)); +// gateway-failure -> сопоставить gateway и upstream по идентификатору +// rendering-or-data-failure -> разделить пустой ответ API и пустой DOM`), + p('Ожидаемый результат — два разных маршрута, а не общий совет «посмотреть логи». Первый маршрут требует найти одинаковый идентификатор на двух границах. Второй требует сравнить тело ответа с фактически построенным DOM. Если добавить к объекту лишнее поле, функция его не использует: это полезное напоминание, что необработанный контекст не превращается в доказательство автоматически.'), + h2('Гипотеза должна иметь различающий сигнал'), + p('После классификации запишите две гипотезы в форме «если причина X, то при проверке Y увидим Z». Например: если gateway не получил ответ upstream, в access log будет 502, а в application log не будет записи с тем же request-id. Если приложение вернуло ошибку, обе записи появятся, но статусы и время будут различаться. Такая формулировка заставляет заранее назвать отрицательный результат. Без него любая найденная запись легко превращается в подтверждение первоначальной версии.'), + p('Идентификатор запроса здесь играет роль ключа соединения, а не печати достоверности. Он помогает собрать события, но не исключает потерю записи, повторную отправку или ошибку генератора идентификатора. Временное окно тоже не заменяет ключ: два запроса могут идти одновременно, а часы на разных узлах могут расходиться. Если ключ отсутствует, это отдельный результат диагностики, а не разрешение подставить ближайшую запись.'), + h2('Действия по порядку'), + ol([ + 'Сохранить метод, путь, статус, время, длительность, размер ответа и идентификатор на внешней границе.', + 'Сформулировать две причины и для каждой назвать сигнал, который даст разные результаты.', + 'Проверить access log и журнал следующего слоя в одном временном окне; не смешивать записи только по похожему пути.', + 'Сопоставить идентификатор, parent/child-контекст и направление запроса; отдельно отметить пропущенные записи.', + 'Внести изменение только после того, как найден слой и повторяемый признак; после изменения повторить тот же запрос.', + ]), + h2('Ограничения и следующий шаг'), + p('Классификатор не устанавливает root cause и не заменяет расследование. Прокси может изменить статус, middleware — скрыть исходное тело, а sampled trace — не содержать нужного span. Для асинхронной очереди одного request-id тоже мало: понадобится связать producer, сообщение и consumer отдельными полями. В production нельзя выводить причину из одного совпадения времени или одного удачного повтора.'), + p('Следующий практический шаг — добавить в ваш журнал структурированные поля request_id, trace_id, route, status и duration_ms, а затем проверить один отказ по маршруту выше. Критерий готовности простой: другой инженер по конверту симптома понимает, какой запрос искать, где искать и какое наблюдение изменит решение.'), ], [ - { key: 'traceContext', use: 'Использован узкий факт о стандартизованном переносе trace context между границами.', boundary: 'Не подтверждает передачу, trace или причинность в synthetic сценарии.' }, - { key: 'rfc5424', use: 'Использована терминология protocol message для отличия named signal от фактически полученной записи.', boundary: 'Не создаёт log record и не подтверждает его содержание.' }, - { key: 'nistLogs', use: 'Использована общая дисциплина управления журналами как фон для разделения сбора и интерпретации.', boundary: 'Не является evidence о системе, incident или будущем расследовании.' }, + { key: 'http', use: 'Определение семантики статусов 502, 504, 401 и 403.', boundary: 'RFC описывает HTTP-обмен, но не знает топологию конкретного приложения и не устанавливает его причину отказа.' }, + { key: 'trace', use: 'Правила полей traceparent и переноса контекста между HTTP-границами.', boundary: 'Стандарт помогает связать контекст, но не гарантирует, что каждый сервис записал span или что связь доказывает причинность.' }, + { key: 'nistLogs', use: 'Практика управления журналами: содержимое, время, источник и пригодность записи для анализа.', boundary: 'Руководство не является журналом приложения и не даёт данных о конкретном инциденте.' }, ]); const mechanism = revision({ - slug: 'editorial-2027-01-mechanism-debugging-decade', title: 'Десять лет web-диагностики: что сохраняется между логом и trace', categories: ['Архитектура', 'Наблюдаемость'], cover: '/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg', excerpt: 'План на январь 2027: границы inference, которые не отменяют ни логи, ни trace.', readingMinutes: 20, + slug: 'editorial-2027-01-mechanism-debugging-decade', + title: 'Trace ID связывает события, но не доказывает причину', + categories: ['Архитектура', 'Наблюдаемость'], + cover: '/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg', + excerpt: 'Как читать trace, log и metric вместе и не превращать совпадение идентификатора в причинный вывод.', + readingMinutes: 16, }, [ - p('План на январь 2027 рассматривает конкретную проблему механики: лог и trace кладут рядом, после чего связь по id начинают читать как доказанную причину. Цена ошибки — ложное сужение поиска. Инженер исключает альтернативы, которых вход не исключал, и тратит время на исправление уверенного, но невыведенного объяснения.'), - p('Есть и более тихая проблема: инструмент меняется, а сила вывода будто растёт сама. В действительности у нас нет production log, trace, trace-id, span, backend, report или реального времени. Цена выдуманного артефакта — путаница между термином из спецификации и фактом конкретной среды. Этот текст — план/сценарий на 2027-01 с source cutoff 2026-07-31; положительная ветка остаётся synthetic-plan-hand-off, а productionEffect: not-attempted.'), - h2('Что может пережить смену инструмента'), - p('Инструменты меняют разрешение и стоимость поиска, но не отменяют три простые обязанности inference. Нужны названный input, правило связи и альтернативное объяснение. Если нет input, нет факта для интерпретации. Если нет правила связи, совпадение идентификатора остаётся совпадением. Если нет альтернативы, любая картинка легко становится подтверждением любимой версии. Эти обязанности не универсальный метод расследования; это минимальные вопросы, которые не дают выдать удобство UI за доказательство.'), - p('Лог обычно удобен для локального сообщения, trace — для связки работ в пределах заданного контекста. Но оба формата несут границы: запись может быть неполной, контекст — отсутствовать, а связь — не покрывать работу вне carrier. W3C Trace Context определяет формат propagation, не модель причинности. RFC 5424 описывает структуру syslog message, не полноту истории. Именно поэтому synthetic model ниже не хранит образцы этих объектов: она хранит только вопросы, которые будущий owner обязан уточнить до любой попытки вывода.'), - figure('/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg', 'Таблица сравнивает плановые роли лога, trace и ручного вопроса: каждый инструмент может дать вход, но ни один не снимает границы inference; красная колонка перечисляет запрещённые выводы.', 'Схема не содержит данных, производственных инструментов или результатов. Она отделяет терминологию источников от synthetic механизма P107.'), - table('Инструмент и предел вывода', ['Форма input', 'Что может быть названо', 'Что ещё требуется', 'Что не следует выводить'], [ - ['Логовое сообщение', 'record schema и поле', 'provenance, полнота, interpretation', 'что событие было единственным'], - ['Trace context', 'carrier и связь контекста', 'граница propagation и альтернативы', 'что связь означает причинность'], - ['Метрика', 'агрегированный вопрос', 'окно, population и расчёт', 'что известен конкретный путь'], - ['Synthetic contract', 'будущий input', 'отдельный scope и owner', 'что evidence уже собран'], + p('Проблема распределённой диагностики выглядит убедительно: в двух журналах найден один trace ID, рядом стоят одинаковые timestamps, а один span заметно длиннее остальных. Из этого легко сделать вывод, что найден виновник. Цена ошибки — неверный rollback или оптимизация не того участка. В распределённом маршруте идентификатор говорит «эти записи относятся к одному контексту», но не говорит «эта запись вызвала задержку». Между двумя утверждениями есть несколько проверок.'), + p('Механизм нужно разделить на три слоя. Log содержит сообщение и локальное состояние процесса. Span описывает операцию и её границы во времени. Metric агрегирует множество наблюдений и теряет часть контекста. Смешать эти формы — значит использовать ответ одного инструмента для вопроса другого. Хороший разбор сначала проверяет, что записи относятся к одному trace, затем — что parent/child связи и интервалы совместимы, и только после этого формулирует ограниченный вывод.'), + h2('Что гарантирует идентификатор'), + p('W3C Trace Context стандартизирует HTTP-заголовок traceparent и формат идентификаторов. Это полезный транспортный контракт: сервис может продолжить контекст, а оператор — искать его в нескольких компонентах. Но стандарт не требует, чтобы все внутренние работы были представлены span-ами. Библиотека может не создать span для очереди, фона или локального cache. Отсутствие записи значит «в этом источнике её нет», а не «операции не было».'), + p('У trace есть и временная граница. Parent span может завершиться до того, как дочерняя работа закончилась, если связь отражает асинхронную передачу. Два span-а в одном trace могут быть соседями в маршруте, но не причиной друг друга. Поэтому при чтении надо видеть имя операции, service.name, parentSpanId, start и end, а не только цветную линию в интерфейсе.'), + figure('/assets/editorial/2027/debugging-decade-2027-signal-tool-limit-table.svg', 'Матрица различий между логом, span, метрикой и корреляционным ключом: форма записи, полезный вопрос и недопустимый вывод.', 'Схема отделяет связь событий от причинности. Она показывает, какой дополнительный контекст нужен до технического решения.'), + table('Какой инструмент отвечает на какой вопрос', ['Источник', 'Сильная сторона', 'Проверить рядом', 'Не заключать автоматически'], [ + ['Log', 'локальное сообщение и состояние', 'время, источник, schema, request-id', 'что сообщение объясняет весь маршрут'], + ['Span', 'граница операции и длительность', 'parent, kind, status, attributes', 'что самый длинный span вызвал всё'], + ['Metric', 'частота и распределение', 'окно, population, labels', 'что агрегат указывает на один запрос'], + ['Trace ID', 'поиск общего контекста', 'пропагация и sampling', 'что цепочка полна и причинна'], ]), - h2('Связь не равна объяснению'), - p('Связь полезна, когда она уменьшает число допустимых моделей, а не когда она просто выглядит правдоподобно. Даже в будущем случае общий идентификатор мог бы означать propagation, повторное использование, ошибочную корреляцию или неполное соединение двух потоков. Чтобы выбрать между ними, нужны конкретные правила и наблюдения. Их нет, поэтому fixed case запрещает evidence сильнее input. Значение observed не «почти проходит»: оно закрывается, потому что план не имеет права тайно превратиться в collection.'), - p('Этот запрет удерживает важную асимметрию. Вход может разрешить задать вопрос, но не обязан разрешать утверждение. Выход может быть слабее входа: stop часто честнее hand-off. Выход не может быть сильнее входа без новой, явно авторизованной работы. Поэтому evidence-stronger-than-input-v1 является негативным fixture, а не примером зрелости. Он не проверяет реальный trace; он проверяет, что слова «наблюдалось» не проникли в учебную карточку через одно поле.'), - h2('Поле и схема должны быть разными объектами'), - p('Схема говорит, как назвать поле; поле в конкретной записи было бы evidence. Смешать их особенно легко с документацией: прочитав normative описание carrier, автор начинает писать так, будто carrier присутствовал в его маршруте. Это неверный переход. Первичный источник полезен для терминов, версии и границы формата. Он не даёт разрешения переносить свойства specification на неизвестный deployment, browser или прокси. Наша редакционная модель добавляет статусы и fail-closed rule самостоятельно и не приписывает их стандартам.'), - p('То же относится к старым и новым инструментам. Более удобный поиск не снимает вопрос retention; более подробная визуализация не снимает вопрос sampling; единый trace-id не снимает вопрос ownership границы. Практичный наставник не запрещает инструмент. Он спрашивает: какое конкретное решение этот input мог бы изменить и какое наблюдение оставит решение прежним? Если ответа нет, модель ещё не готова к измерению, зато готова к stop с точным следующим действием.'), - h2('Запускаемый validator границы inference'), - code("import { createFixedDiagnosticCase, assessFixedDiagnosticPlan } from './upgrade-2027-01.mjs';\n\nconst plan = createFixedDiagnosticCase('evidence-stronger-than-input-v1');\nconst result = assessFixedDiagnosticPlan(plan);\nconsole.log({ status: result.status, reason: result.reason });\n// { status: 'stop-evidence-stronger-than-input', reason: 'a-plan-cannot-contain-observed-or-collected-evidence' }"), - p('Это literal runnable example. Он не открывает exporter, не создаёт span и не читает журнал. В памяти существуют только фиксированные строки. JSON clone отделяет case от константы, deep freeze делает вложенные значения неизменяемыми, а проверка JSON-равенства не принимает похожий объект. Так код демонстрирует policy: разница между «можно назвать будущий signal» и «можно сказать, что что-то наблюдалось» проверяется до того, как текст превратится в отчёт.'), - h2('Порядок разбора механики'), - ol(['Зафиксировать 2027-01 и source cutoff 2026-07-31; не выдавать будущую дату за свершившуюся историю.', 'Назвать symptom как вопрос, а hypothesis — как отдельное опровержимое объяснение.', 'Для каждого signal записать, является ли он будущим input, а не существующим log или trace.', 'Отделить формат и propagation rule из источника от утверждения о конкретном runtime.', 'Проверить, что evidence имеет только synthetic-contract/input/not-collected; observed и collected должны закрываться.', 'Отдать статус hand-off или stop будущему evidence owner, не формулируя root cause, fix, incident или production conclusion.']), - h2('Время не возникает из порядка строк'), - p('Ещё одна граница, которую не снимает ни лог, ни trace, — время. Порядок прихода записей не равен порядку работы; timestamp не равен общей шкале; длительность одного участка не объясняет ожидание в другом. В P107 нет timestamp вовсе, поэтому нельзя даже начать говорить о latency, race или window. Это полезная пустота. Она не позволяет подменить отсутствие temporal model красивой временной линией и не заставляет будущего owner доказывать обратное тому, чего план не должен был утверждать.'), - p('Если будущему scope понадобится время, ему придётся отдельно назвать clock boundary, единицу, способ связывания и допустимую неопределённость. Даже после этого останется вопрос, что именно означает интервал: работа, ожидание, очередь или наблюдение collector. Такая детализация не нужна текущему literal, потому что он не сравнивает интервалы. Добавлять число только ради реалистичности было бы ошибкой того же класса, что и выдумать trace-id: syntactic detail не превращается в evidence от того, что выглядит знакомо.'), - h2('Collector не является нейтральным окном'), - p('Между приложением и тем, что увидит человек, обычно есть граница преобразования, отбора или хранения. Даже не описывая конкретный pipeline, полезно помнить: формат на отправителе и форма на экране могут быть разными объектами. Поэтому слова «лог сказал» или «trace показал» всегда требуют вопроса о provenance. В synthetic plan этот вопрос не решается и не маскируется названием vendor. В нём остаётся только named signal, потому что любое утверждение о collector уже было бы эмпирическим фактом без источника.'), - p('Здесь проявляется отличие механизма от инструкции. Инструкция могла бы велеть включить экспорт или открыть консоль. Механизм описывает, почему такое действие ещё не даёт сильного вывода. Он не запрещает будущую работу, а удерживает её границы: новый input должен прийти с новой ответственностью за сбор, доступ и interpretation. Пока такой ответственности нет, precise stop лучше общего оптимизма. Это не недоверие к инструменту; это отказ давать ему полномочие, которого не передавали. Такой предел полезнее уверенного, но непроверяемого объяснения.'), - h2('Ограничения механики и следующий шаг'), - p('Модель не оценивает качество log schema, стоимость trace backend, доступ, персональные данные, clock skew или порядок доставки. Она не говорит, какой из инструментов выбрать, и не проверяет соответствие implementation спецификации. Она также не может определить причинность по имени гипотезы. Всё это требует реальных входов и другого полномочия. Здесь fail-closed нужен не для драматичности, а чтобы отсутствие таких входов оставалось видимым.'), - p('Следующий шаг — будущему владельцу сформулировать отдельный evidence contract: какие input разрешены, по какому правилу они будут связаны и какой контрпример остановит вывод. Пока этого нет, не стоит расширять словарь статусов. Особенно не следует называть synthetic output «проверенным решением»: механизм проверяет только связность планового языка. Он не завершает декаду диагностики и не создаёт новый факт о web-системе.'), + h2('Локальный граф связей'), + p('Ниже функция строит минимальное представление родительских связей по массиву span-ов. Она отмечает root, найденного parent и missing parent. Это предметный пример: результат помогает увидеть разрыв контекста в конкретной цепочке. Он не рисует красивый trace и не назначает виновника. Входы — именно те поля, которые должны быть сохранены при экспорте данных.'), + code(`import { linkTraceRecords } from './upgrade-2027-01.mjs'; + +const records = [ + { traceId: 't-7', spanId: 's-gateway', service: 'gateway', parentSpanId: '', durationMs: 22 }, + { traceId: 't-7', spanId: 's-api', service: 'api', parentSpanId: 's-gateway', durationMs: 81 }, + { traceId: 't-7', spanId: 's-db', service: 'db', parentSpanId: 's-missing', durationMs: 4 }, +]; + +console.log(linkTraceRecords(records)); +// gateway: root; api: present; db: missing`), + p('Ожидаемый результат показывает разрыв у db. Это уже полезная находка: прежде чем говорить о задержке, надо понять, откуда взялся span без parent. Возможны потеря span, неправильное поле или независимая работа, ошибочно попавшая в trace. Ни одна из версий не следует из массива сама; функция лишь не даёт скрыть дырку за сплошной линией.'), + h2('Время, статус и семантика'), + p('Длительность span сравнивают внутри одной временной шкалы и одной операции. Если gateway ждёт upstream 800 мс, это не означает, что upstream потратил 800 мс на вычисление: туда может входить соединение, очередь, retry и чтение ответа. В attributes нужны хотя бы тип операции, результат и причина окончания. Для HTTP это могут быть status_code, method и route; для базы — операция и имя зависимости без чувствительных параметров.'), + p('Log полезен, когда в нём есть структурированные поля, а не только строка сообщения. RFC 5424 отделяет header, structured data и message, что хорошо совпадает с задачей корреляции. Но формат журнала не гарантирует доставку: transport может отбросить или обрезать запись. Поэтому «в журнале не найдено» — это результат проверки качества источника, а не доказательство отсутствия события.'), + h2('Действия по порядку'), + ol([ + 'Проверить, что trace-id и span-id имеют ожидаемый формат и не меняются при переходе между сервисами.', + 'Построить parent/child граф и отметить root, missing parent, duplicate span-id и операции без service.name.', + 'Сверить интервалы start/end с локальными timestamps; отдельно учесть async, retry и очередь.', + 'Сопоставить span с application log по span-id или request-id, а metric использовать только как фон для population.', + 'Сформулировать вывод в узкой форме: «этот участок наблюдался дольше» или «связь потеряна», не «он был причиной всего отказа».', + ]), + h2('Ограничения и следующий шаг'), + p('Sampling, tail-based filtering и ошибки clock skew меняют картину. Trace может не включать retry или consumer, а log collector — получить записи в другом порядке. Данные с персональными параметрами нельзя бездумно передавать в общий контур наблюдаемости. Наконец, даже полная trace-цепочка описывает наблюдаемую последовательность, но не контрфактический вопрос: что произошло бы без конкретного вызова.'), + p('Следующий шаг — выбрать один критичный маршрут и зафиксировать контракт полей для gateway, application и dependency span. Добавьте проверку на missing parent и отдельную метрику пропущенного контекста. После этого повторите разбор: качество решения растёт не от количества экранов, а от уменьшения числа неразличимых объяснений.'), ], [ - { key: 'traceContext', use: 'Использован только факт существования стандартного формата trace-context propagation.', boundary: 'Не подтверждает trace, carrier или причинную связь конкретного сценария.' }, - { key: 'rfc5424', use: 'Использована граница формата syslog message для различения схемы и фактической записи.', boundary: 'Не доказывает полноту, доставку или содержание журналов.' }, - { key: 'nistLogs', use: 'Использован общий контекст log management для отделения collection от interpretation.', boundary: 'Не подтверждает incident, retention или работу конкретного инструмента.' }, + { key: 'trace', use: 'Формат traceparent и правила передачи контекста между HTTP-сервисами.', boundary: 'Спецификация не определяет внутреннюю модель span, sampling, очередь и причинность.' }, + { key: 'syslog', use: 'Структурированные поля и границы syslog-сообщения используются как пример дисциплины логирования.', boundary: 'RFC не гарантирует доставку конкретного журнала и не описывает trace-связи приложения.' }, + { key: 'http', use: 'Семантика HTTP-операции и статуса отделена от длительности внутренних работ.', boundary: 'HTTP Semantics не описывает конкретный backend, tracer или способ агрегации.' }, ]); const field = revision({ - slug: 'editorial-2027-01-field-debugging-decade', title: 'Десять лет web-диагностики: synthetic hand-off вместо кейса', categories: ['Полевые заметки', 'Инженерные решения'], cover: '/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg', excerpt: 'План на январь 2027: как передать будущий вопрос диагностики, не сочинив полевой артефакт.', readingMinutes: 20, + slug: 'editorial-2027-01-field-debugging-decade', + title: 'Разбор 502 в поле: собрать цепочку из access и application log', + categories: ['Надёжность', 'Практика команд'], + cover: '/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg', + excerpt: 'Полевой маршрут для 502: какие записи собрать, как связать их request-id и где остановиться при разрыве цепочки.', + readingMinutes: 15, }, [ - p('Самая опасная поломка field-заметки — передать получателю фразу «причина подтверждена», когда передавать нечего кроме наброска вопроса. Цена ошибки — ложная операционная память: новый инженер защищает несуществующий report, ищет отсутствующий trace и строит дальнейшее решение на авторитете старой формулировки.'), - p('Вторая проблема — назвать synthetic карточку реальным hand-off и добавить к ней правдоподобные время, среду, пользователя или инструмент. У нас нет исторического incident, bug report, logs, traces, repro и production-артефактов; очередь их требует, но создавать их текстом нельзя. Цена такой детализации — невозможность отличить факт от иллюстрации. Поэтому это план/сценарий на 2027-01 с source cutoff 2026-07-31 и только synthetic-plan-hand-off / productionEffect: not-attempted.'), - h2('Field здесь — форма передачи, не отчёт с поля'), - p('Слово field часто обещает место, следы, людей и последствия. В этом выпуске оно означает более скромную вещь: карточку, которую можно отдать будущему владельцу evidence, не добавляя evidence от себя. В карточке есть дата плана, cutoff, synthetic symptom, synthetic hypothesis, named planned signals, сила evidence и следующий вопрос. В ней нет URL, customer payload, временной линии, dashboard, tool output, идентификатора задачи или фразы о том, что кто-то уже начал работу.'), - p('Это не уход от практики. Наоборот, честная передача экономит время будущей практики. Получатель сразу видит, что может проверить форму вопроса, вернуть stop или открыть новый scope с полномочием на реальные данные. Он не должен угадывать, какие строки были художественным сокращением. В зрелой технической речи отсутствие artefact тоже имеет provenance: not-collected означает не «плохо собрали», а «в данном synthetic плане сбор не происходил и не заявляется».'), - figure('/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg', 'Петля synthetic hand-off: симптом и гипотеза задают именованные плановые сигналы; затем либо stop при отсутствующем имени или чрезмерном evidence, либо передача вопроса будущему владельцу без production effect.', 'Диаграмма показывает только процесс проверки fixed literals. Она не изображает реальный incident, trace, log, пользователя или завершённое расследование.'), - table('Содержимое synthetic hand-off', ['Поле', 'Допустимое fixed значение', 'Получатель может сделать', 'Получатель не должен заключать'], [ - ['Дата и cutoff', '2027-01 / 2026-07-31', 'проверить временную границу', 'что выпуск уже завершён'], - ['Симптом и гипотеза', 'named synthetic questions', 'уточнить альтернативы', 'что есть root cause'], - ['Signals', 'named planned-input', 'назвать нужный future input', 'что запись доступна'], - ['Evidence', 'synthetic-contract/input/not-collected', 'открыть другой scope', 'что был сбор или анализ'], - ['Output', 'synthetic-plan-hand-off', 'принять либо вернуть contract', 'что допустимо менять production'], + p('Проблема полевой заметки о 502 — не в нехватке терминов. Ошибка появляется, когда в неё заносят только внешний статус и сразу называют его причиной: «упал API». Цена такой записи практическая: следующий инженер ищет неисправность в приложении, хотя шлюз мог не установить соединение, или чинит upstream, хотя приложение уже вернуло понятный отказ. Без цепочки событий полевой разбор превращается в пересказ экрана.'), + p('Для одного запроса нужны как минимум две записи: access на границе и application в сервисе. Их соединяют request-id или traceparent, а не только время и путь. В каждой записи должны быть timestamp, route, status и длительность; в application log — операция и безопасное описание ошибки. Если второй записи нет, это не повод заполнить пропуск догадкой. Это отдельная ветка: отказ произошёл до приложения или запись потерялась.'), + h2('Начинаем с внешней границы'), + p('RFC 9110 описывает 502 как ситуацию, в которой gateway или proxy получил недействительный ответ от upstream. Для полевой диагностики важен субъект статуса: где именно его увидел клиент. Access log gateway даёт внешний результат, но не раскрывает, был ли запрос принят приложением. Поэтому первой строкой карточки пишем узел и роль: edge.status=502, а не общее «сервер 502».'), + p('Затем ищем application event с тем же идентификатором в небольшом окне. Совпадение найдено — проверяем, что время и route согласуются, а статус приложения объясняет внешний ответ. Совпадения нет — проверяем timeout, фильтр коллектора, другой формат id и потерю записи. Такой разбор занимает меньше времени, чем просмотр всего журнала, потому что каждая проверка меняет одну гипотезу.'), + figure('/assets/editorial/2027/debugging-decade-2027-hypothesis-evidence-loop.svg', 'Петля полевого разбора 502: внешний access event, поиск application event по идентификатору, проверка времени и отдельная ветка для разрыва.', 'Диаграмма показывает, что отсутствие application записи — результат проверки цепочки, а не разрешение назвать приложение причиной.'), + table('Матрица полевой проверки 502', ['Наблюдение', 'Следующая проверка', 'Рабочий вывод', 'Нельзя писать'], [ + ['502 в edge, application не найден', 'timeout, collector, формат id', 'цепочка разорвана до подтверждения слоя', '«приложение упало»'], + ['502 в edge, app 500 с тем же id', 'статус и время app', 'ошибка дошла до приложения', 'что найден root cause'], + ['502 в edge, app 200', 'retry, cache, proxy mapping', 'границы преобразуют результат', 'что app ответил клиенту 200'], + ['access не содержит id', 'конфигурация structured fields', 'ключ корреляции неполон', 'соединять по ближайшему времени'], ]), - h2('Передача начинается с того, чего в ней нет'), - p('Обычно hand-off читают как список выполненного. Здесь полезнее начать с запрещённых подмен. Нет «вчера», потому что дата плана уже названа. Нет «пользователь увидел», потому что пользователей в literal нет. Нет «trace показал», потому что trace не создавался. Нет «инструмент не нашёл», потому что инструмент не запускался. Нет «исправление помогло», потому что исправления не было. Эти отрицания не делают карточку богаче, но сохраняют у будущего reviewer возможность поставить реальный вопрос без конфликта с вымышленным прошлым.'), - p('Полезная карточка не требует доверия к автору. Она содержит fixed literals, поэтому любой читатель может взять named case и получить тот же status. Но одинаковый status не делает контекст одинаковым с чужой системой. Детерминизм здесь локален: evaluator согласованно классифицирует словарь, а не воспроизводит world. Именно поэтому positive result не называется approval. Он лишь говорит, что в этом словаре есть дата, names и достаточная честность, чтобы передать вопрос дальше.'), - h2('Fail-closed защищает получателя'), - p('Отсутствующее имя сигнала — не мелкая опечатка. Без имени невозможно сказать, какой input запрашивается, а значит следующий человек получает просьбу «посмотрите что-нибудь». Отсутствующая гипотеза столь же опасна: она превращает любой signal в украшение. Сценарий без даты и cutoff лишает получателя границы источников. Эти три случая не дополняются default-значениями. Evaluator возвращает stop с точной причиной, потому что заполнять пробел удобной выдумкой означает перекладывать долг на человека, который этого не выбирал.'), - p('Отдельный риск — evidence, сильнее входа. Слово observed выглядит естественно для field-ярлыка, но в данной карточке оно ложно. Если реальное наблюдение когда-нибудь появится, оно должно прийти из нового процесса с известным provenance, access и правилами интерпретации. Нельзя улучшить старый synthetic input только тем, что поменяли строку. Наконец, root-cause-confirmed запрещён независимо от красоты names: положительный диагноз потребовал бы доказательств, которых модель специально не содержит.'), - h2('Литеральный hand-off и его отрицательная ветка'), - code("import { createFixedDiagnosticCase, assessFixedDiagnosticPlan } from './upgrade-2027-01.mjs';\n\nconst plan = createFixedDiagnosticCase('positive-conclusion-v1');\nconst result = assessFixedDiagnosticPlan(plan);\nconsole.log({ status: result.status, next: result.nextAction });\n// { status: 'stop-disallowed-positive-conclusion', next: 'use-synthetic-plan-hand-off' }"), - p('Этот example literally runs without a live system. Он читает только in-memory named literals; JSON clone исключает разделённую ссылку, deep freeze фиксирует все вложенные поля. В нём нет файлового fixture, переменной окружения, часов, сетевого вызова или API наблюдаемости. Проверяемый результат — отказ от чрезмерного вывода. Это важно для hand-off: получатель видит, что автор не оставил скрытую позитивную ветку, которая могла бы превратить план в готовое заключение.'), - h2('Порядок передачи январского сценария'), - ol(['Оставить на карточке явные planDate: 2027-01 и sourceCutoff: 2026-07-31.', 'Назвать symptom и hypothesis как synthetic вопросы, не добавляя историю, источник или реальный эффект.', 'Проверить, что каждый signal имеет имя и planned-input; unnamed signal вернуть с stop.', 'Оставить evidence только synthetic-contract/input/not-collected; observed, collected и stronger claims закрыть.', 'Проверить, что conclusion равен только synthetic-plan-hand-off и effect всегда not-attempted.', 'Передать boundary и nextAction будущему evidence owner как вопрос, не как queue task, approval или incident conclusion.']), - h2('Получатель должен суметь не согласиться'), - p('Хорошая передача не заставляет получателя принять исходную framing. Он должен суметь вернуть карточку с фразой: symptom слишком широкий, гипотеза не различает альтернативы, signal не имеет владельца, а source cutoff не подходит новому вопросу. Такое несогласие — не отказ от hand-off, а его штатный результат. Если карточка принимает только одобрение, она становится маленькой очередью с замаскированным приоритетом. P107 специально не создаёт queue и не назначает срок, чтобы не выдать организационное действие за техническую проверку.'), - p('По этой же причине nextAction сформулирован как передача fixed question contract, а не как «соберите данные». Последняя фраза предполагает полномочие, доступ и цель сбора. Первая сохраняет выбор у будущего owner. Он может признать, что evidence не нужен, или открыть новый authorized scope, или запросить другой model. Synthetic hand-off не теряет ценность от такого результата: он честно ограничивает то, что было известно на дату подготовки, и даёт получателю опору, не создавая несуществующей обязанности.'), - h2('Как карточка стареет без подмены фактов'), - p('После source cutoff документы, термины и реализации могут измениться. Это не делает старую карточку неправильной, но меняет её статус: она остаётся записью о подготовленном вопросе, не справочником о текущей системе. Будущий reviewer должен обновить источники в новом scope, а не молча приписать свежие сведения январскому плану. Точно так же нельзя задним числом заменить not-collected на «наблюдалось», даже если кто-то позднее действительно собрал материал. Это были бы разные artefacts с разной датой и provenance.'), - p('Долговечность hand-off достигается не количеством контекста, а явными границами. Полезно, когда через год можно увидеть, почему statement был слабым, какие слова нельзя было использовать и где требовалась новая авторизация. Вредно, когда старая карточка выглядит настолько конкретно, что её принимают за архив. Поэтому в поле нет правдоподобных названий проектов, систем и инцидентов. Отсутствие таких деталей — не пробел редактора, а часть safety contract для читателя, который появится позже.'), - h2('Ограничения hand-off и следующий шаг'), - p('Карточка не назначает владельца, не создаёт ticket, не отправляет уведомление и не меняет очередь. Она не оценивает безопасность, приватность, доступность данных, retention, бюджет или пригодность существующих инструментов. Она также не покрывает historical reports/artifacts, которых у P107 нет. Технические источники закрепляют значение терминов, но не заполняют этот дефицит. Называть absence «архивом» было бы той же подменой, что и сочинить incident.'), - p('Следующий шаг — будущему владельцу решить, нужен ли отдельный authorized discovery и какие реальные evidence допустимы. Если такого решения нет, hand-off остаётся конечным безопасным результатом. Для самого draft следующий шаг проще: добавить новый negative literal, например с неназванным symptom, и сохранить fail-closed ответ. Не усиливайте этот текст retrospective тоном. Январь 2027 ещё впереди относительно редакторской даты, а десятилетие здесь — только тема заранее подготовленного номера.'), + h2('Учебный сборщик цепочки'), + p('Функция ниже принимает два локальных массива и возвращает отдельную карточку на каждый request-id. Входы специально похожи на structured log, но не являются выгрузкой системы. Ожидаемый результат различает «gateway отказал до приложения», «ошибка приложения дошла до клиента» и обычное завершение. Это конкретная операционная техника: она показывает, какие поля нужны для первого прохода и как не потерять разрыв.'), + code(`import { buildRequestTimeline } from './upgrade-2027-01.mjs'; + +const edge = [ + { requestId: 'r-1', at: '12:00:01.100', status: 502, message: 'upstream timeout' }, + { requestId: 'r-2', at: '12:00:02.100', status: 502, message: 'bad response' }, +]; +const app = [ + { requestId: 'r-2', at: '12:00:02.080', status: 500, message: 'db unavailable' }, +]; + +console.log(buildRequestTimeline(edge, app).map(({ requestId, result }) => ({ requestId, result }))); +// r-1: gateway-failed-before-app; r-2: app-error-reached-client`), + p('Для r-1 нет application event, поэтому функция не называет базу или приложение виновником. Для r-2 есть согласованная запись, но вывод всё ещё ограничен: ошибка приложения достигла внешнего ответа, а почему база недоступна — отдельный вопрос. В реальном коде добавьте проверку схемы, исключите секреты и сохраните raw-поля рядом с нормализованными.'), + h2('Как читать время и повтор'), + p('Время в разных сервисах может иметь разную точность и сдвиг. Если access и application разделены десятками миллисекунд, это повод сверить clock sync и точку записи, а не автоматически отвергнуть связь. Повторный запрос тоже не обязан повторить тот же маршрут: gateway может выбрать другой upstream, а retry — создать новый идентификатор. В карточке держите request-id каждого повтора отдельно.'), + p('RFC 5424 полезен здесь не как готовая схема конкретного приложения, а как напоминание о структурированных полях и разделении источника, времени и сообщения. Поле message удобно читать человеку, но для соединения нужен отдельный ключ. Чем больше решений принимается по свободному тексту, тем выше стоимость следующего разбора.'), + h2('Действия по порядку'), + ol([ + 'Скопировать из edge только одну попытку запроса: timestamp, route, method, status, duration и request-id.', + 'Найти application events по точному id и ограниченному временному окну; сохранить число найденных записей.', + 'Сопоставить status, route и длительность, затем отметить разрыв, retry или преобразование на proxy.', + 'Проверить зависимость только после подтверждения, что приложение действительно получило запрос.', + 'Сформулировать итог как наблюдение и следующий тест: например, «нет app записи; проверить timeout и collector», а не как окончательный root cause.', + ]), + h2('Ограничения и следующий шаг'), + p('Журнал может быть неполным из-за sampling, сбоя коллектора, буферизации или редактирования чувствительных полей. Один request-id может встретиться в retry, если система повторно использует контекст; это надо проверить по span-id и attempt. Нельзя публиковать токены, email, тело формы и сырые заголовки. Для юридически чувствительных систем храните безопасный fingerprint и ссылку на закрытый источник.'), + p('Следующий шаг — добавить в runbook три обязательных запроса: найти edge event, найти application event, проверить отсутствие/наличие dependency event. После одного реального разбора измерьте долю карточек, где цепочка собирается без ручного поиска по времени. Это покажет качество полей, а не только удобство инструмента.'), ], [ - { key: 'traceContext', use: 'Использована только терминологическая граница propagation context для будущего evidence contract.', boundary: 'Не создаёт trace, incident или доступ к какому-либо carrier.' }, - { key: 'rfc5424', use: 'Использована граница protocol message как напоминание, что schema не равна фактической записи.', boundary: 'Не подтверждает logs, reports или historical artifacts.' }, - { key: 'nistLogs', use: 'Использована общая дисциплина provenance журналов как причина не выдумывать их в hand-off.', boundary: 'Не доказывает существование процесса сбора или расследования.' }, + { key: 'http', use: 'Семантика 502 и место, где gateway сообщает о недействительном ответе upstream.', boundary: 'RFC не определяет топологию edge/application и не подтверждает конкретный отказ.' }, + { key: 'syslog', use: 'Разделение заголовка, structured data и message поддерживает выбор полей для безопасного журнала.', boundary: 'Стандарт не гарантирует полноту, порядок доставки и наличие записей в конкретном collector.' }, + { key: 'trace', use: 'Traceparent и request context используются как ключи соединения событий на HTTP-границах.', boundary: 'Наличие идентификатора не доказывает, что цепочка полна или что найденная запись была причиной.' }, ]); -export const revisions = deepFreeze([practice, mechanism, field]); +export const revisions = Object.freeze([practice, mechanism, field]); + export function verifyRevisionsAgainstFixture() { - const fixture = runFixedDiagnosticFixture(); - const articleChecks = revisions.map((revisionItem) => { const text = bodyText(revisionItem.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|потер)/i.test(text.slice(0, 1000)) && //.test(revisionItem.contentHtml) && /
/.test(revisionItem.contentHtml) && /
/.test(revisionItem.contentHtml) && /
    /.test(revisionItem.contentHtml) && /2027-01/.test(text) && /2026-07-31/.test(text); }); - return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((revisionItem) => [revisionItem.slug, bodyText(revisionItem.contentHtml).length])) }); + const articleChecks = revisions.map((item) => { + const body = bodyText(item.contentHtml); + return body.length >= 5000 && body.length <= 15000 && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body); + }); + const sample = classifyWebSymptom({ status: 502, headers: { traceparent: '00-a-b-01' }, body: 'Bad Gateway' }); + return Object.freeze({ passed: articleChecks.filter(Boolean).length + (sample.class === 'gateway-failure' ? 1 : 0), total: articleChecks.length + 1, accepted: articleChecks.every(Boolean) && sample.class === 'gateway-failure', characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-02.mjs b/web/scripts/upgrade-2027-02.mjs index 5146b6b..5085908 100644 --- a/web/scripts/upgrade-2027-02.mjs +++ b/web/scripts/upgrade-2027-02.mjs @@ -1,151 +1,274 @@ -function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +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 figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } - -const REFERENCES = deepFreeze({ - rfc2119: { title: 'RFC 2119: Key words for use in RFCs', url: 'https://www.rfc-editor.org/rfc/rfc2119', version: 'BCP 14, March 1997, DOI 10.17487/RFC2119' }, - rfc8174: { title: 'RFC 8174: Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words', url: 'https://www.rfc-editor.org/rfc/rfc8174', version: 'BCP 14, May 2017, DOI 10.17487/RFC8174' }, - nist160: { title: 'NIST SP 800-160 Vol. 1 Rev. 1: Engineering Trustworthy Secure Systems', url: 'https://doi.org/10.6028/NIST.SP.800-160v1r1', version: 'Revision 1, November 2022, DOI 10.6028/NIST.SP.800-160v1r1' }, -}); -function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '
  • ' + escapeHtml(ref.title) + ' — версия: ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } - -const FIXED_LESSON_CASES = deepFreeze({ - 'keep-wrap-replace-v1': { id: 'keep-wrap-replace-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Fixed in-memory planning literal. No Bitrix installation, project, version, API call, user, field, file, test, release, database, network, browser, environment, clock, secret, telemetry or production system is read, created or changed.' }, - 'undated-future-scenario-v1': { id: 'undated-future-scenario-v1', planDate: '', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'unnamed-contract-v1': { id: 'unnamed-contract-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: '', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'unnamed-version-boundary-v1': { id: 'unnamed-version-boundary-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: '', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'missing-migration-evidence-v1': { id: 'missing-migration-evidence-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'missing', kind: 'synthetic-input' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'positive-conclusion-v1': { id: 'positive-conclusion-v1', planDate: '2027-02', sourceCutoff: '2026-07-31', legacyContract: { name: 'unnamed-legacy-contract', state: 'named-synthetic' }, versionBoundary: { name: 'unnamed-version-boundary', state: 'named-synthetic' }, route: ['keep', 'wrap', 'replace'], migrationEvidence: { state: 'not-collected', kind: 'synthetic-input' }, requestedConclusion: 'migration-succeeded', boundary: 'Negative fixed literal only.' }, -}); - -export function createFixedLessonCase(id = 'keep-wrap-replace-v1') { const value = FIXED_LESSON_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } -export function assessFixedLessonPlan(input) { - if (!Object.values(FIXED_LESSON_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case'); - if (input.planDate !== '2027-02' || input.sourceCutoff !== '2026-07-31') return stop('stop-undated-future-scenario-or-cutoff', 'february-2027-plan-and-july-2026-cutoff-are-required', 'name-2027-02-and-2026-07-31'); - if (!input.legacyContract?.name || input.legacyContract.state !== 'named-synthetic') return stop('stop-unnamed-legacy-contract', 'legacy-contract-must-be-a-named-synthetic-placeholder', 'name-a-placeholder-without-claiming-a-real-contract'); - if (!input.versionBoundary?.name || input.versionBoundary.state !== 'named-synthetic') return stop('stop-unnamed-version-boundary', 'version-boundary-must-be-a-named-synthetic-placeholder', 'name-a-placeholder-without-claiming-a-version'); - if (JSON.stringify(input.route) !== JSON.stringify(['keep', 'wrap', 'replace'])) return stop('stop-invalid-change-tree', 'the-fixed-route-must-preserve-keep-wrap-replace-order', 'use-the-fixed-planning-tree'); - if (input.migrationEvidence?.state !== 'not-collected' || input.migrationEvidence?.kind !== 'synthetic-input') return stop('stop-missing-or-claimed-migration-evidence', 'migration-evidence-is-not-collected-in-this-plan', 'hand-off-an-evidence-question-to-a-future-owner'); - if (input.requestedConclusion !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-conclusion', 'a-future-plan-cannot-claim-migration-success-or-release', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, legacyContract: deepFreeze(cloneFixed(input.legacyContract)), versionBoundary: deepFreeze(cloneFixed(input.versionBoundary)), route: deepFreeze(cloneFixed(input.route)), migrationEvidence: deepFreeze(cloneFixed(input.migrationEvidence)), productionEffect: 'not-attempted', nextAction: 'give-the-fixed-synthetic-contract-to-a-future-evidence-owner' }); -} -export function runFixedLessonFixture() { - const expected = [['keep-wrap-replace-v1', 'synthetic-plan-hand-off'], ['undated-future-scenario-v1', 'stop-undated-future-scenario-or-cutoff'], ['unnamed-contract-v1', 'stop-unnamed-legacy-contract'], ['unnamed-version-boundary-v1', 'stop-unnamed-version-boundary'], ['missing-migration-evidence-v1', 'stop-missing-or-claimed-migration-evidence'], ['positive-conclusion-v1', 'stop-disallowed-positive-conclusion']]; - const checks = expected.map(([id, expectedStatus]) => ({ id, expected: expectedStatus, actual: assessFixedLessonPlan(createFixedLessonCase(id)).status })); - const sample = createFixedLessonCase(); - return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.route), checks: deepFreeze(checks) }); +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } +function bodyText(html) { + return plainText(html.replace(/

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

|$)/, '')); +} -const practice = revision({ slug: 'editorial-2027-02-practice-bitrix-lessons', title: 'Уроки Bitrix для legacy-разработки: сохранить, обернуть, заменить', categories: ['Bitrix', 'Legacy', 'Практика'], cover: '/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg', excerpt: 'План на февраль 2027: дерево решений для legacy-контракта без выдуманного проекта или вызова API.', readingMinutes: 22 }, [ - p('Это план/сценарий на 2027-02 с source cutoff 2026-07-31, а не рассказ о сделанной миграции Bitrix. Первая проблема legacy-команды проста: знакомый участок кода объявляют «старым» и заменяют до того, как названо правило, ради которого он живёт. Цена — потерянное неявное соглашение, повторная работа и спор уже после изменения, когда вернуть контекст дороже, чем остановиться раньше.'), - p('Вторая проблема — язык уверенности. Фразы «в этой CMS так работает» и «после обновления будет то же самое» маскируют отсутствие версии, контракта и evidence. Цена вымысла выше косметической: читатель получает ложную память о проекте, тесте или релизе. Поэтому здесь нет существующего Bitrix-проекта, API-вызова, пользователя, поля, migration test или будущего результата. Допустим только synthetic-plan-hand-off с productionEffect: not-attempted.'), - h2('Не переписывать ярлык вместо правила'), - p('Название платформы не заменяет карту поведения. Под «Bitrix legacy» в этом материале понимается только учебная рамка: код, границы и договорённости могут пережить автора. Она не сообщает, какие модули установлены, как устроен конкретный шаблон, какие методы существуют и как они ведут себя. Практическая польза начинается не с поисков универсального рецепта, а с вопроса: что именно обязан сохранить следующий шаг, если подробности контракта ещё не подтверждены?'), - p('Для такого вопроса полезно дерево из трёх глаголов. keep означает оставить участок без заявления, что он правильный навсегда. wrap означает поставить вокруг него собственную границу наблюдения или адаптации, не приписывая старому коду новый смысл. replace означает лишь будущую кандидатуру замены после отдельного разрешённого discovery. Порядок важен: замена не становится исходной точкой только потому, что старый код неприятен.'), - figure('/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg', 'Дерево планового решения: назвать synthetic legacy-контракт, проверить границу версии, затем сохранить, обернуть или передать кандидатуру замены; красные ветки останавливают недатированный сценарий и чрезмерный вывод.', 'Схема — редакционная модель будущего сценария на 2027-02. Она не описывает поведение конкретной установки Bitrix и не объявляет миграцию выполненной.'), - table('Дерево без предположения о реальной системе', ['Ветвь', 'Допустимое действие в плане', 'Сохраняемая неопределённость', 'Запрещённый вывод'], [['Сохранить', 'зафиксировать вопрос у named synthetic contract', 'правило пока не проверено', 'код уже одобрен'], ['Обернуть', 'описать будущий adapter boundary', 'внутренности legacy неизвестны', 'вызов API существует'], ['Заменить', 'передать кандидатуру будущему owner', 'версия и миграционный путь не подтверждены', 'релиз или тест состоялся'], ['Остановить', 'вернуть stop-status', 'evidence не собран', 'проблемы нет']]), - h2('Что значит сохранить'), - p('Сохранение часто ошибочно называют бездействием. В зрелой разработке это контролируемое решение не менять слой, пока у изменения нет адреса. Оно требует больше дисциплины, чем механическое переписывание: надо назвать область неизвестности, не превратить её в баг и оставить получателю честный next step. В сценарии область называется unnamed-legacy-contract. Это не имя файла, не сущность Bitrix и не скрытая ссылка на реальный договор; это literal, который не разрешает добавить правдоподобные детали.'), - p('Зрелость здесь не равна терпимости к долгу. Если участок действительно мешает, его нельзя оправдывать словом legacy. Но и давление на срок не превращает догадку в доказательство. Сохранить означает признать: на дату cutoff нет утверждённого описания входов, выходов, владельца и допустимого изменения. Такая запись не закрывает задачу сопровождения; она не даёт закрыть её ложным позитивным результатом.'), - h2('Обернуть — отделить свой код от чужой неизвестности'), - p('Обёртка имеет смысл только как новая, узкая граница ответственности. Она не должна симулировать точное знание внутреннего API. В будущем authorized scope может решить, что нужны преобразование формата, проверка ошибки или изоляция зависимого места. В текущем сценарии этого решения нет. Поэтому слово wrap фиксирует форму разговора: будущее изменение обязано назвать наблюдаемый контракт и версию, а не скрыть предположение в удобном helper.'), - p('У обёртки есть цена. Она добавляет поверхность поддержки, новую точку расхождения и соблазн объявить старую часть безопасной лишь потому, что рядом появился современный интерфейс. Если boundary не назван, такая прослойка увеличивает долг. Именно поэтому fixture закрывает unnamed-version-boundary-v1: неизвестная граница версии не может быть украшена универсальной адаптацией. Fail closed здесь полезнее умной догадки.'), - h2('Заменить — не значит уже мигрировать'), - p('Замена начинается с перечисления того, чего мы не знаем. Нет migration evidence, значит нет основания писать «эквивалентно», «проверено» или «работает после переноса». Нет подтверждённой версии, значит нельзя обещать совместимость. Нет конкретного API-вызова, значит нельзя подменять модель псевдокодом, который выглядит как инструкция для реального проекта. Практический материал обязан быть исполнимым, но его исполнимость может ограничиваться безопасной проверкой литерала.'), - code("import { createFixedLessonCase, assessFixedLessonPlan } from './upgrade-2027-02.mjs';\n\nconst preservedQuestion = createFixedLessonCase('keep-wrap-replace-v1');\nconst handOff = assessFixedLessonPlan(preservedQuestion);\nconsole.log(JSON.stringify({ route: handOff.route, status: handOff.status, effect: handOff.productionEffect }));\n// {\"route\":[\"keep\",\"wrap\",\"replace\"],\"status\":\"synthetic-plan-hand-off\",\"effect\":\"not-attempted\"}"), - p('Пример буквально запускается только с exports этого модуля. Factory создаёт JSON clone fixed in-memory literal и рекурсивно применяет deep freeze; caller не получает общую изменяемую ссылку. Evaluator сверяет вход с известными named literals и закрывает произвольный object. В нём нет файлов, сети, browser, environment, часов, secrets, telemetry и внешних инструментов. Он не выполняет Bitrix-вызов и не проводит migration test; его output — только безопасная передача вопроса.'), - h2('Цена каждого преждевременного шага'), - p('У дерева есть не только техническая, но и экономическая логика. Ранняя замена переносит цену неизвестности в review, поддержку и откат: новый слой приходится объяснять одновременно с тем, что он пытается изменить. Раннее оборачивание обычно дешевле замены, но тоже не бесплатно — появляются преобразование, новая диагностика и отдельная ответственность за ошибки. Сохранение дешевле прямо сейчас, но становится дорогим, если команда не оставила понятный вопрос. Поэтому выбор нельзя сводить к «ничего не делать» против «сделать современно». Он сравнивает разные формы обязательств.'), - p('В учебной модели нет чисел, потому что числа без контекста были бы ещё одним вымыслом. Нет оценки часов, количества экранов, размера базы, числа интеграций или доли пользователей. Можно зафиксировать только направление риска: чем больше неподтверждённых предпосылок помещено в изменение, тем труднее отделить дефект нового кода от старого правила. Эта формулировка не предсказывает результат. Она объясняет, почему будущему owner передают список неизвестных, а не уверенное решение.'), - h2('Как не превратить обёртку в новый монолит'), - p('Будущая обёртка должна иметь маленький контракт и явное место удаления. Иначе она быстро превращается в комнату, куда складывают исключения: входы исправляются наугад, ошибки переименовываются, а вызывающий код перестаёт видеть исходную границу. В P108 запрещено придумывать такую реализацию, но разрешено назвать критерий для будущей проверки: у прослойки должен быть свой наблюдаемый input, свой output и основание существования. Если хотя бы один пункт нельзя назвать, решение остаётся на ветке keep.'), - p('Важно не путать изоляцию с сокрытием. Изоляция оставляет возможность спросить, какой контракт защищается и при каких версиях. Сокрытие делает старое поведение невидимым и заставляет следующий слой компенсировать симптомы. Первый вариант облегчает проверку, второй просто меняет место долга. Поэтому дерево не предлагает «обернуть всё» как безопасный default. Оно требует пройти через named placeholders, чтобы даже отсутствие знания было видно в интерфейсе решения.'), - h2('Порядок действий для будущего владельца'), - ol(['Сохранить редакционные метки planDate: 2027-02 и sourceCutoff: 2026-07-31; без них сценарий закрывается.', 'Назвать только synthetic placeholder legacy-контракта, не превращая его в метод, проект, поле или пользовательский кейс.', 'Отдельно назвать synthetic version boundary; неизвестная версия — причина остановки, а не повод выбрать «наиболее вероятную».', 'Пройти дерево keep → wrap → replace как порядок рассмотрения, не как план работ или очередь.', 'Оставить migration evidence в состоянии not-collected; не заменять отсутствие test фиктивным положительным словом.', 'Передать вопрос будущему evidence owner, который отдельно решит, нужен ли authorized discovery и какие факты допустимы.']), - h2('Где дерево заканчивается'), - p('Это дерево не выбирает архитектуру, не измеряет производительность, не оценивает безопасность и не знает условий эксплуатации. Оно не говорит, что legacy-код надо оставить, что adapter нужен или что замена выгодна. Также оно не покрывает реальную совместимость версий Bitrix: у текста нет подтверждённой версии и нет права превращать архивную документацию в текущий факт. Модель намеренно меньше проекта, чтобы не создать фиктивный проект в редакционной статье.'), - p('Следующий шаг возможен только вне этого draft: будущий владелец может открыть отдельный scope, получить разрешение на discovery и зафиксировать provenance реальных материалов. До этого момента честный итог — hand-off. Февраль 2027 ещё не наступил относительно редакторской даты; обещать результат миграции означало бы выдать план за историю. Legacy не оправдывает бездействие, но требует, чтобы действие начиналось с названной границы, а не с красивого пересказа неизвестности.') -], [{ key: 'rfc2119', use: 'Использована только дисциплина явной модальности слов MUST/SHOULD в техническом контракте.', boundary: 'Не описывает Bitrix, migration или реальный API.' }, { key: 'rfc8174', use: 'Использовано уточнение, что модальные слова имеют заданный контекст.', boundary: 'Не подтверждает версию, тест или будущий результат.' }, { key: 'nist160', use: 'Использована общая инженерная граница между системой, контрактом и evidence.', boundary: 'Не подтверждает существование проекта или его поведения.' }]); +const REFERENCES = Object.freeze({ + cuser: { + title: 'Главный модуль: CUser — документация 1С-Битрикс', + url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cuser/index.php', + version: 'документация API, проверена 31 July 2026; класс доступен с версии 3.0.6', + }, + loader: { + title: 'CModule::IncludeModule — документация 1С-Битрикс', + url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cmodule/includemodule.php', + version: 'документация API, проверена 31 July 2026; CModule с версии 3.0.1', + }, + filter: { + title: 'PHP Manual: filter_var', + url: 'https://www.php.net/manual/en/function.filter-var.php', + version: 'PHP Manual, актуальная страница, проверена 31 July 2026', + }, + semver: { + title: 'Semantic Versioning 2.0.0', + url: 'https://semver.org/spec/v2.0.0.html', + version: 'Version 2.0.0, 2013', + }, +}); -const mechanism = revision({ slug: 'editorial-2027-02-mechanism-bitrix-lessons', title: 'Уроки Bitrix для legacy-разработки: граница версии за вызовом', categories: ['Bitrix', 'Legacy', 'Архитектура'], cover: '/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg', excerpt: 'План на февраль 2027: как не принять имя API за инвариант неизвестной версии.', readingMinutes: 21 }, [ - p('План/сценарий на 2027-02 с source cutoff 2026-07-31 разбирает дорогую ошибку механизма: имя вызова принимают за гарантию поведения. В legacy-разработке это быстро превращает догадку в интерфейс нового кода. Цена — адаптер вокруг ложного инварианта, спор о «совместимости» и новый долг, который уже труднее увидеть, потому что он написан современным синтаксисом.'), - p('Следующая ловушка — приписать этот механизм реальному Bitrix. У данного текста нет установки, проекта, версии, API-вызова, релиза, поля, пользователя или теста. Цена такой конкретизации — вымышленный факт, который могут использовать как инструкцию. Поэтому все имена ниже — synthetic placeholders; положительного вывода о миграции нет. Разрешён только synthetic-plan-hand-off с неизменным productionEffect: not-attempted.'), - h2('Вызов виден, инвариант спрятан'), - p('Вызов — это форма обращения к границе. Инвариант — свойство, которое следующий код рассчитывает сохранить: порядок, отсутствие, преобразование, ошибка, идемпотентность или другой явно названный эффект. Эти сущности нельзя склеивать. Один и тот же внешний вид строки кода может на разных границах означать разные обязательства, а похожие вызовы могут не иметь общего контракта. Мы не можем выяснить это по названию и не должны подменять проверку привычкой.'), - p('Чтобы не создавать фальшивую точность, модель использует unnamed-version-boundary. Это не «неизвестная версия Bitrix», а явная synthetic метка отсутствующего знания. Рядом стоит unnamed-legacy-contract, также не связанный с API. Пара нужна, чтобы удержать вопрос в двух плоскостях: что обещает контракт и при каком version boundary это вообще можно обсуждать. Если отсутствует любая из них, evaluator останавливает сценарий.'), - figure('/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg', 'Матрица плановой границы версии: имя вызова, named synthetic contract, named synthetic version boundary и допустимый вывод; красные ячейки показывают stop при отсутствующем контракте или версии.', 'Матрица не сопоставляет настоящие версии Bitrix и не документирует API. Она показывает, почему форма вызова не доказывает инвариант.'), - table('Четыре уровня утверждения', ['Уровень', 'Что можно назвать в этом draft', 'Что требуется для усиления', 'Что запрещено'], [['Лексема', 'synthetic имя boundary', 'отдельный источник и scope', 'реальный метод'], ['Контракт', 'placeholder без поведения', 'проверяемое описание input/output', 'обещание совместимости'], ['Версия', 'placeholder без номера', 'подтверждённый provenance', 'название релиза'], ['Вывод', 'hand-off вопроса', 'реальное разрешённое evidence', 'migration succeeded']]), - h2('Почему версия — не число в комментарии'), - p('Граница версии — не декоративное поле. Она определяет, к чему относится утверждение: к документации, развертыванию, интеграции или предположению автора. Пока нет provenance, номер версии опаснее пустоты: он создаёт ощущение проверяемости и направляет людей к несуществующему релизу. В данном будущем сценарии даже правдоподобный номер был бы выдумкой. Поэтому model требует имя placeholder, но запрещает превращать его в факт.'), - p('У этой строгости есть практическая цена сейчас: документ получается менее эффектным, а следующий инженер не получает готового рецепта. Это приемлемая цена по сравнению с обратной стоимостью ложной совместимости. Legacy не получает иммунитет от ответственности; наоборот, зрелая система требует различать «не знаем» и «можем безопасно предположить». Здесь безопасного предположения нет, поэтому evaluator fail closed.'), - h2('Инвариант нужно формулировать без магии API'), - p('Полезный инвариант начинается не с объекта библиотеки, а с наблюдаемого условия: что вход считается допустимым, что выход считается тем же, где ошибка остаётся ошибкой и кто вправе трактовать результат. Но и это условие нельзя сочинить за чужую систему. В P108 условие нарочно не заполнено; named literal показывает место, в которое будущий owner может положить отдельный, подтверждённый контракт. До того никто не получает право назвать конкретное поведение «стандартным для Bitrix».'), - p('Такой пробел не надо маскировать псевдокодом. Особенно вредны примеры, которые выглядят как настоящий API-вызов: они переживают оговорку и начинают жить в копипасте. Исполнимый пример ниже проверяет только дисциплину модели. Он показывает, что отсутствие version boundary останавливает передачу, а не подталкивает систему к запасной ветке. Это меньшая демонстрация, но она честна относительно source cutoff.'), - h2('Исполнимая проверка границы'), - code("import { createFixedLessonCase, assessFixedLessonPlan } from './upgrade-2027-02.mjs';\n\nconst boundaryIsMissing = createFixedLessonCase('unnamed-version-boundary-v1');\nconst gate = assessFixedLessonPlan(boundaryIsMissing);\nconsole.log([gate.status, gate.productionEffect, gate.nextAction].join(' | '));\n// stop-unnamed-version-boundary | not-attempted | name-a-placeholder-without-claiming-a-version"), - p('Этот runnable fragment обращается только к fixed in-memory literal. В factory сначала происходит JSON clone, затем deep freeze всех вложенных значений, поэтому нельзя тайно дописать версию после выдачи case. Оценка принимает только known cases и возвращает stop для неназванной границы. Нет request, файлового fixture, process environment, времени, browser, API, базы или telemetry. Вывод не описывает реальный failure; он лишь охраняет границу редакционной модели.'), - h2('Как псевдоинвариант попадает в код'), - p('Обычно ложный инвариант начинается с мелочи: в обсуждении кто-то говорит «этот вызов всегда возвращает нужную форму», а через неделю фраза становится условием в adapter. Потом вокруг условия появляются обработка ошибки, кеш или fallback, и уже кажется, будто договор подтверждён самим количеством кода. На деле объём не заменяет provenance. Чем удобнее ложное правило для реализации, тем настойчивее надо требовать источник, version boundary и альтернативу, при которой правило не действует.'), - p('У этого механизма есть коварная особенность: он может пережить смену людей. Новый инженер видит хорошо названную функцию и считает, что имя отражает установленный факт. Поэтому хороший контракт не прячет неизвестность за неймингом. В плановом literal слово unnamed намеренно неприятно: оно не позволяет случайно принять placeholder за доменное понятие. Когда future owner получит реальные основания, он создаст новый артефакт с отдельной датой, а не переименует задним числом эту метку.'), - h2('Совместимость — составное утверждение'), - p('Говорить о совместимости можно только после разложения вопроса. Совпадает ли формат входа? Сохраняется ли смысл отсутствующего значения? Одинаково ли обрабатывается ошибка? Есть ли различие в порядке побочных действий? В настоящем проекте эти вопросы требуют конкретных материалов. В P108 их нельзя заполнить, но можно не потерять структуру. Матрица удерживает составные части рядом и не позволяет одной знакомой лексеме выдать себя за весь набор гарантий.'), - p('Именно поэтому table не содержит колонку «поддерживается». Поддержка — вывод о реальном сочетании версии, конфигурации и применения, которого здесь нет. Проставить зелёную галочку означало бы совершить тот же логический скачок, от которого материал защищает. Зелёный в схеме означает только допустимость hand-off формы вопроса. Он не означает работоспособность, vendor commitment или возможность переноса. Это различие нужно проговорить явно, потому что цвет и таблица слишком легко читаются как verdict.'), - h2('Кому полезен stop-status'), - p('Stop нужен не только ревьюеру. Автору изменения он экономит время на документ, который иначе пришлось бы переписывать после первого уточнения. Получателю он даёт право не соглашаться с исходной постановкой: можно вернуть карточку, если граница названа слишком широко, а evidence не позволяет выбрать безопасный следующий шаг. Руководителю stop показывает, что работа не исчезла, но ещё не получила право называться реализацией. Такая прозрачность лучше оптимистичного статуса, которому никто не может показать основание.'), - p('Fail closed также предотвращает тихую эскалацию scope. Когда evaluator принимает только byte-identical fixed cases, нельзя передать произвольный object со скрытым API-именем, реальной версией или положительным claim. Ограничение намеренно грубое: оно не моделирует домен, а охраняет редакционную границу. В будущем более богатая модель возможна, но она должна появиться вместе с доказуемым источником, владельцем и правилами её использования. До этого простая остановка честнее универсальной функции.'), - h2('Как разбирать изменение за API-вызовом'), - ol(['Отделить текст вызова от утверждения о его эффекте; в этом draft не считать ни одно имя вызова реальным.', 'Записать legacy contract как synthetic placeholder, пока отдельный владелец не подтвердит вход, выход и ошибку.', 'Записать version boundary отдельным placeholder; не вставлять номер, релиз или ссылку на непроверяемый архив.', 'Проверить, что change tree сохраняет порядок keep → wrap → replace, а не перескакивает к миграции.', 'Отклонить case с отсутствующим contract, version boundary или migration evidence, даже если остальная история выглядит убедительно.', 'Передать только вопрос и его stop-conditions будущему evidence owner; не создавать test, project task или production action.']), - h2('Матрица не заменяет исследование'), - p('Матрица полезна тем, что делает видимыми пропуски, но она не добывает данные. Она не отвечает, существует ли версия, повторяется ли ошибка, вызовет ли переход регрессию или какой слой принадлежит конкретной команде. Она также не устанавливает ценность обёртки: одна и та же прослойка может быть защитой или лишним посредником, если контракт не доказан. Любая попытка назначить ответ по одной таблице была бы тем самым неверным инвариантом.'), - p('Следующий шаг — отдельный authorized discovery после будущей даты сценария: получатель сам решает, нужны ли документы, исходный код, изолированная проверка или иной evidence. Если такого scope нет, stop — корректный конечный результат данного материала. Это не оправдание бездействия; это запрет на действие под видом знания. Февраль 2027 не наступил, и редакторская модель не может рассказывать о его совместимости, релизе или выполненной миграции.') -], [{ key: 'rfc2119', use: 'Использована только идея точно ограниченной нормативной лексики.', boundary: 'Не задаёт поведение API и не доказывает инвариант Bitrix.' }, { key: 'rfc8174', use: 'Использована граница контекста, в котором модальные слова приобретают смысл.', boundary: 'Не подтверждает номер версии или релиз.' }, { key: 'nist160', use: 'Использовано общее различение инженерных границ и свидетельств.', boundary: 'Не является источником migration test или проекта.' }]); +function sources(entries) { + return '
    ' + entries.map(({ key, use, boundary }) => { + const reference = REFERENCES[key]; + return '
  • ' + escapeHtml(reference.title) + ' — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; + }).join('') + '
'; +} -const field = revision({ slug: 'editorial-2027-02-field-bitrix-lessons', title: 'Уроки Bitrix для legacy-разработки: evidence hand-off без отчёта', categories: ['Bitrix', 'Legacy', 'Надёжность'], cover: '/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg', excerpt: 'План на февраль 2027: передать вопрос о миграционном evidence, не выдумывая тест, релиз или успех.', readingMinutes: 20 }, [ - p('Редакторская дата — 31.07.2026; плановый номер обозначен 2027-02, то есть февраль 2027, а источники ограничены cutoff 2026-07-31. В field-работе самая дорогая ошибка начинается с фразы «миграция подтверждена», когда у передачи нет ни provenance, ни материалов, ни даже утверждённого теста. Цена — поиск несуществующего evidence, неверные решения и операционная память, которую придётся разбирать уже после того, как на неё оперлись.'), - p('Особенно опасно делать такую фразу правдоподобной деталями про Bitrix: назвать проект, пользователя, поле, API-вызов, выпуск или migration test. Ничего этого здесь не существует и не предполагается. Февраль 2027 ещё впереди; положительный outcome запрещён. Модель заканчивается только synthetic-plan-hand-off и всегда сообщает productionEffect: not-attempted, а не результат работы в production.'), - h2('Evidence начинается с происхождения, не с вывода'), - p('Evidence — это не красивое существительное для уверенной фразы. Чтобы материал мог поддержать решение, нужны источник, дата, условия получения, граница интерпретации и владелец. В synthetic сценарии этих вещей нет, поэтому состояние названо not-collected. Это не плохое значение и не намёк на скрытый файл: оно означает, что статья передаёт вопрос, а не артефакт. Любое усиление до «наблюдалось» сделало бы модель ложной.'), - p('Legacy требует такой же честности, как новая система. Старый слой может хранить много неявных правил, но его возраст не даёт разрешения дорисовывать результат. Если будущее изменение действительно понадобится, evidence должен появиться в отдельном authorized scope с собственными границами и датой. Нельзя ретроспективно переписать этот draft после появления новых данных: тогда получится другой артефакт, а не уточнение исходного hand-off.'), - figure('/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg', 'Петля плановой передачи evidence: fixed synthetic question ведёт к будущему владельцу, который либо открывает отдельный scope, либо возвращает отсутствие evidence; красная ветка блокирует claim о готовой миграции.', 'Схема показывает только маршрутизацию неопределённости. Она не утверждает наличие миграционного теста, результата, релиза либо доказательств для Bitrix.'), - table('Что передаётся и чего в пакете нет', ['Элемент', 'Состояние P108', 'Кому принадлежит решение', 'Недопустимая подмена'], [['Вопрос', 'named synthetic literal', 'future evidence owner', 'готовый incident'], ['Контракт', 'placeholder', 'отдельный scope', 'реальное поле или API'], ['Migration evidence', 'not-collected', 'авторизованный процесс', 'пройденный test'], ['Вывод', 'synthetic-plan-hand-off', 'будущая оценка', 'success/release']]), - h2('Пустота evidence должна быть видна'), - p('Иногда команда скрывает отсутствие материалов за термином «полевая проверка». Это создаёт опасный мост между планом и утверждением: читатель видит familiar labels и предполагает, что кто-то запускал систему. В P108 поле намеренно пустое в фактическом смысле. Есть только fixed literal, который показывает форму будущей передачи. У него нет имени окружения, ссылки на задачу, записи лога, снимка экрана, тестового пользователя или вызова внешнего сервиса.'), - p('Такая пустота не отменяет стоимость проблемы. Напротив, она предотвращает дорогой вторичный дефект: решение принимают на основании artefact, который невозможно найти. Прагматичный hand-off говорит получателю меньше, но говорит надёжно: что неизвестно, какая граница обязана быть названа и какой результат сейчас запрещён. Это лучше, чем «отчёт» с реалистичной терминологией и несуществующей ответственностью.'), - h2('Negative case важнее удобного успеха'), - p('Fixture содержит missing-migration-evidence-v1. Он не пытается вылечить отсутствие evidence резервным значением и не делает вид, что отсутствие равно чистому результату. Вместо этого возвращается stop-missing-or-claimed-migration-evidence. Такая ветка проверяет редакционную честность: если author позже добавит слово collected или positive conclusion, модуль не превратит это в hand-off по умолчанию.'), - p('Отрицательная ветка защищает и от другого соблазна — выставить migration succeeded как желаемый статус. Желаемое не равно наблюдаемому, а план не равен релизу. В будущем у владельца могут быть основания исследовать проблему; в настоящей модели их нет. Fail closed не решает, нужна ли миграция. Он не даёт использовать этот текст как свидетельство того, что миграция была начата, проведена или завершена.'), - h2('Безопасный hand-off в памяти'), - code("import { createFixedLessonCase, assessFixedLessonPlan } from './upgrade-2027-02.mjs';\n\nconst evidenceWasNotProvided = createFixedLessonCase('missing-migration-evidence-v1');\nconst refusal = assessFixedLessonPlan(evidenceWasNotProvided);\nconsole.log({ refusal: refusal.status, action: refusal.nextAction, production: refusal.productionEffect });\n// { refusal: 'stop-missing-or-claimed-migration-evidence', action: 'hand-off-an-evidence-question-to-a-future-owner', production: 'not-attempted' }"), - p('Этот code example runnable, но его предмет узок: known literal клонируется через JSON и замораживается deep freeze до оценки. Никакой fixture не читается с диска; никакой веб-запрос, тестовый runner, Bitrix API, браузер, переменная окружения, часы или секрет не используются. Публичный export позволяет проверить stop-status, но не создаёт migration evidence. В частности, он не называет API, проект, пользователя или реальное поле, потому что этого knowledge у сценария нет.'), - h2('Почему test name не является evidence'), - p('Название теста может быть полезным указателем, но само по себе не говорит, что именно запускалось, на чём, с какими входами и кто интерпретировал результат. В будущей миграции это различие особенно важно: одинаковая метка может скрывать разный набор правил, а зелёный сигнал — быть следствием неполного охвата. Поэтому P108 не создаёт даже synthetic migration test. Создать его было бы проще для повествования, но читатель неизбежно начал бы спрашивать о фикстурах, окружении и результате, которых не существует.'), - p('Вместо test name hand-off несёт более скромную, но проверяемую информацию: evidence не collected, его сила не повышалась, а утверждение об успехе запрещено. Эта структура позволяет будущему owner начать с правильного вопроса — нужен ли вообще evidence для выбранного решения и какому уровню доверия он должен соответствовать. Она не подсказывает ответ заранее и не создаёт обязательства обязательно запускать проверку. Иногда корректным выходом discovery будет отказ от изменения, а не доказательство миграции.'), - h2('Провенанс нельзя дописать позже'), - p('После source cutoff могут появиться документы, снимки, реальные тесты и результаты. Они не делают исходный draft ретроспективным отчётом. У новых материалов будет своя дата, доступ, условия и автор; смешать их со старым сценарием значит стереть разницу между планированием и наблюдением. Такая подмена кажется безобидной, пока не возникает вопрос, какие именно факты были известны до решения. Тогда аккуратно сохраненная пустота становится важнее подробной, но переписанной истории.'), - p('Провенанс важен и когда outcome отрицательный. Будущий owner может обнаружить, что доказательства недоступны, контракт слишком широкий или сама постановка ошибочна. Это не делает hand-off неудачным: его задача не добиться красивого направления, а сохранить возможность честно выбрать направление позже. Поэтому productionEffect: not-attempted — не декоративный флаг. Он не разрешает читать его как «ничего не произошло в реальном мире»; он говорит лишь, что данный модуль не пытался воздействовать на production.'), - h2('Синтетический пакет не становится очередью'), - p('Даже хорошо оформленный hand-off способен незаметно стать обязательством, если в нём появляется исполнитель, срок или обещанный результат. В этом материале их нет. Future evidence owner — роль, а не человек; next action — передача вопроса, а не команда получить данные; маршрут на схеме — объяснение границы, а не workflow существующей организации. Такое ограничение сохраняет автономию получателя и не выдаёт редакторский draft за управленческое решение.'), - p('Если получателю нужно действие, он должен завести его в новом авторизованном контексте с собственными данными и рисками. P108 не может сделать это вместо него, как не может создать совместимость одной строкой. Сила пакета в другом: он не оставляет лазейки для утверждения «мы уже проверили». Когда отсутствует migration evidence, честная остановка — полезный результат подготовки, а не повод сочинить positive outcome, чтобы текст выглядел законченным.'), - h2('Порядок передачи вместо псевдоотчёта'), - ol(['Сохранить в hand-off дату будущего плана и source cutoff, чтобы не спутать его с фактической работой.', 'Оставить legacy contract и version boundary synthetic placeholders, не дополняя их знакомыми, но неподтверждёнными именами.', 'Зафиксировать migration evidence только как not-collected/synthetic-input; отсутствие не трактовать как успех.', 'Запустить fixture против negative literal и убедиться, что он выдаёт stop, а не fallback.', 'Не создавать ticket, очередь, release note, test report или production change из результата этой функции.', 'Передать будущему owner вопрос, ограничения и next action; разрешение на получение реальных материалов остаётся вне P108.']), +export function chooseLegacyBoundary(input) { + const callers = Number(input?.callers ?? 0); + const hasTests = Boolean(input?.characterizationTests); + const hasSideEffects = Boolean(input?.unknownSideEffects); + const hasCanonicalContract = Boolean(input?.canonicalContract); + if (hasSideEffects || !hasTests) return { action: 'keep', reason: 'сначала сохранить наблюдаемое поведение и добавить тесты' }; + if (callers > 1 && !hasCanonicalContract) return { action: 'wrap', reason: 'несколько callers требуют одной адаптационной границы' }; + if (hasCanonicalContract) return { action: 'replace', reason: 'новый контракт проверен тестами и отделён от legacy API' }; + return { action: 'wrap', reason: 'изменение изолируем до появления полного контракта' }; +} + +export function inspectBitrixSurface(input) { + const methods = new Set(input?.methods ?? []); + const moduleLoaded = Boolean(input?.moduleLoaded); + const version = String(input?.version ?? 'unknown'); + if (!moduleLoaded) return { status: 'module-not-loaded', action: 'проверить подключение iblock или main до вызова' }; + if (methods.has('CUser::GetByID') && methods.has('CUser::Update')) return { status: 'legacy-surface-present', action: 'использовать адаптер с явной проверкой версии', version }; + if (methods.has('Bitrix\\Main\\UserTable')) return { status: 'd7-surface-present', action: 'проверить mapping полей перед заменой', version }; + return { status: 'unknown-surface', action: 'остановить миграцию и получить контракт установленной версии', version }; +} + +export function migrateUserFields(record) { + const source = { ...record }; + const phone = String(source.PERSONAL_PHONE ?? '').trim(); + const email = String(source.EMAIL ?? '').trim().toLowerCase(); + const result = { ...source, phone, email }; + delete result.PERSONAL_PHONE; + delete result.EMAIL; + return { legacy: source, canonical: result, reversible: true }; +} + +function revision(meta, parts, referenceEntries) { + const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); + return Object.freeze({ ...meta, contentHtml, proseLength }); +} + +const practice = revision({ + slug: 'editorial-2027-02-practice-bitrix-lessons', + title: 'Bitrix legacy: когда сохранить код, когда обернуть, когда заменить', + categories: ['Bitrix', 'Инженерные практики'], + cover: '/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg', + excerpt: 'Практическое дерево решения для старого API: сохранить поведение, поставить адаптер или перейти на новый контракт.', + readingMinutes: 15, +}, [ + p('Проблема legacy-кода в Bitrix редко состоит в возрасте файла. Старый вызов может держать неявные значения, порядок хуков, формат ошибок и поля, которые читает соседний модуль. Если заменить его только потому, что новый API выглядит аккуратнее, цена проявится позже: потеряется поведение, а исправлять его придётся уже по косвенным симптомам. Поэтому первый вопрос — не «как переписать», а «какой контракт нельзя сломать».'), + p('Решение удобно разделить на три действия: сохранить, обернуть или заменить. Сохранить — значит оставить вызов и зафиксировать его наблюдаемое поведение. Обернуть — поставить адаптер между legacy API и остальным кодом, чтобы callers перестали знать детали. Заменить — удалить старую границу только после того, как новый контракт описан тестами. Это не догма: выбор зависит от числа callers, побочных эффектов и качества проверки.'), + h2('Сначала отделяем возраст от риска'), + p('Класс CUser относится к старому API Bitrix, но само имя класса не говорит, что его можно безопасно удалить. Документация перечисляет поля и методы, а также указывает аналог в D7. Для проекта этого мало: нужно увидеть, какие поля реально передаются, какие значения возвращаются и что вызывается после сохранения. Пока это не известно, сохранение или узкий wrapper дешевле полной миграции.'), + p('Риск растёт, если один вызов смешивает несколько задач. Например, функция может одновременно нормализовать email, создавать пользователя, запускать событие и возвращать ID. Переписать её на новый класс без раздельных тестов значит поменять четыре контракта за один commit. Адаптер позволяет сначала выделить форму данных и ошибку, а уже потом менять внутреннюю реализацию.'), + figure('/assets/editorial/2027/bitrix-lessons-2027-keep-wrap-replace-tree.svg', 'Дерево выбора для Bitrix legacy: проверить callers и контракт, затем выбрать сохранение, адаптер или замену.', 'Схема помогает выбрать границу изменения. Красная ветка означает, что сначала нужно получить недостающий контракт, а не переписывать вызов.'), + table('Три действия для legacy-вызова', ['Действие', 'Когда подходит', 'Цена', 'Критерий перехода'], [ + ['Сохранить', 'побочные эффекты не описаны, callers мало', 'остаётся старый долг', 'характеризующие тесты и список полей'], + ['Обернуть', 'callers несколько, контракт можно выделить', 'появляется адаптер', 'внешний код видит canonical input/output'], + ['Заменить', 'новый контракт и тесты готовы', 'нужен полный regression', 'старый путь больше не нужен'], + ['Остановиться', 'нет версии, полей или воспроизводимого результата', 'изменение откладывается', 'собраны факты о границе'], + ]), + h2('Учебная функция выбора границы'), + p('Функция ниже не изображает Bitrix runtime. Она показывает рабочую механику решения: входом являются количество callers, наличие characterization tests, неизвестные побочные эффекты и canonical contract. Возвращается одно действие и причина. Эти поля можно заполнить из code search, тестов и документации, а не из ощущения «код старый».'), + code(`import { chooseLegacyBoundary } from './upgrade-2027-02.mjs'; + +const cases = [ + { callers: 1, characterizationTests: false, unknownSideEffects: true, canonicalContract: false }, + { callers: 4, characterizationTests: true, unknownSideEffects: false, canonicalContract: false }, + { callers: 4, characterizationTests: true, unknownSideEffects: false, canonicalContract: true }, +]; + +for (const item of cases) console.log(chooseLegacyBoundary(item)); +// keep -> сохранить наблюдаемое поведение и добавить тесты +// wrap -> несколько callers требуют одной адаптационной границы +// replace -> новый контракт проверен тестами и отделён от legacy API`), + p('Первый результат намеренно не предлагает «сразу новый класс»: неизвестные эффекты сильнее желания обновиться. Второй показывает пользу wrapper, когда callers несколько. Третий разрешает замену только при наличии canonical contract. В рабочем репозитории эту функцию заменит ADR или короткая карточка изменения, а значения подтвердят тесты и просмотр вызовов.'), + h2('Что должен скрывать адаптер'), + p('Адаптер не должен превращаться в копию всего legacy API. Он принимает только нужные поля, проверяет обязательные значения и переводит ошибку в понятный тип. Например, внешняя функция может принимать { email, phone }, а внутри временно собирать массив полей для CUser::Update. Так callers перестают зависеть от названий PERSONAL_PHONE и от способа загрузки модуля.'), + p('Нужно заранее решить, кто владеет преобразованием. Если один caller исправляет телефон, второй передаёт его как есть, а третий пишет пустую строку, wrapper не создал контракт — он спрятал разнобой. Входная нормализация должна быть в одном месте, а правила обратного преобразования — рядом с ней. В таблице изменений укажите поле, источник, допустимую пустоту и способ проверить результат.'), + h2('Действия по порядку'), + ol([ + 'Найти все callers legacy-функции и выписать фактические поля, значения по умолчанию и обработку ошибок.', + 'Проверить, подключён ли нужный Bitrix-модуль, и зафиксировать установленную версию вместе с документацией API.', + 'Добавить characterization tests на текущий результат: успешное сохранение, пустое поле, повторный вызов и ошибку.', + 'Выбрать keep, wrap или replace; для wrapper описать canonical input/output и список скрываемых legacy-деталей.', + 'После изменения повторить те же проверки и отдельно проверить события, права и формат возвращаемого ID.', + ]), h2('Ограничения и следующий шаг'), - p('Эта статья не является планом миграции, политикой доказательств или инструкцией для конкретной CMS. Она не устанавливает, какие тесты обязательны, где хранить логи, какие данные безопасны и кто владеет legacy-системой. Официальные источники ниже помогают не размывать модальность и engineering boundary, но не добавляют недостающие факты. Непроверяемая архивная документация Bitrix не используется как факт именно потому, что version boundary не подтверждён.'), - p('Следующий шаг — только future owner с отдельным полномочием: он может признать hand-off достаточным, открыть discovery либо запросить иной контракт. Пока такого решения нет, безопасный результат уже достигнут в узком смысле: сформирован synthetic plan hand-off, а production effect не предпринимался. Это не положительный вывод о системе. Это отказ делать вид, что у будущего февраля 2027 уже есть тест, релиз и доказанный итог.') -], [{ key: 'rfc2119', use: 'Использована только точность требований и запретов в тексте передачи.', boundary: 'Не превращает hand-off в выполненную миграцию.' }, { key: 'rfc8174', use: 'Использовано различение контекста нормативного утверждения.', boundary: 'Не подтверждает evidence, API или тест.' }, { key: 'nist160', use: 'Использована общая дисциплина инженерного evidence и границ.', boundary: 'Не описывает реальный Bitrix-проект или release.' }]); + p('Без доступа к конкретной установке нельзя обещать совместимость версии, поведение событий или одинаковые тексты ошибок. Документация Bitrix описывает API, но не локальные обработчики и не поля, добавленные проектом. Полная замена особенно опасна, если legacy-вызов участвует в транзакции, импортирует данные или используется административной формой.'), + p('Следующий шаг — взять одну функцию с двумя callers и оформить её canonical контракт. Если тесты не удаётся написать без сложной среды, это сигнал оставить код на месте и сначала сократить границу побочных эффектов. Такая остановка тоже инженерное решение: она сохраняет обратимость и делает следующий commit проверяемым.'), +], [ + { key: 'cuser', use: 'Названия CUser, поля и наличие аналога UserTable сверены с документацией Bitrix.', boundary: 'Документация не описывает локальные события, права, обработчики и фактический набор callers проекта.' }, + { key: 'loader', use: 'Проверка подключения модуля до вызова API используется как отдельное условие адаптера.', boundary: 'Страница не подтверждает, что нужный модуль установлен в конкретной среде.' }, + { key: 'filter', use: 'PHP-проверка входных значений упомянута как часть нормализации boundary.', boundary: 'Manual не определяет Bitrix-поля и не заменяет тесты проекта.' }, +]); + +const mechanism = revision({ + slug: 'editorial-2027-02-mechanism-bitrix-lessons', + title: 'Bitrix API и версия: имя метода не обещает одинаковый контракт', + categories: ['Bitrix', 'Архитектура'], + cover: '/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg', + excerpt: 'Как проверять установленную поверхность API и не считать название класса доказательством совместимости.', + readingMinutes: 16, +}, [ + p('Проблема версионной совместимости выглядит как простой поиск: в документации найден метод, значит его можно вызвать. В Bitrix такой вывод часто слишком сильный. Один и тот же смысл может жить в legacy-классе и в D7, а конкретная установка может не содержать нужный модуль или иметь изменённое поле. Цена ошибки — адаптер, который компилируется и падает только на редкой форме или после обновления.'), + p('Надёжная граница строится из трёх фактов: модуль подключён, поверхность методов действительно доступна, а вход и выход совпадают с нужным проекту контрактом. Версия помогает сузить поиск, но не заменяет проверку. Даже правило Semantic Versioning применимо только там, где поставщик соблюдает его для данного API; имя Update само по себе не обещает семантическую совместимость.'), + h2('Подключение модуля — часть контракта'), + p('Документация Bitrix для CModule::IncludeModule прямо описывает проверку установки и подключения модуля. Это не декоративная строка перед вызовом. Если модуль не подключён, сообщение об ошибке может появиться далеко от места, где принято решение использовать API. В адаптере проверка должна быть близко к границе и возвращать понятный результат, который можно показать в диагностике.'), + p('Для D7 аналогично нужен конкретный namespace и набор методов. Нельзя заменить CUser на Bitrix\\Main\\UserTable, не проверив mapping полей, типы значений и обработку исключений. Хорошее сравнение описывает не названия классов, а операции: создать, обновить, найти, получить ID, обработать ошибку. Именно операции должны попасть в тестовую матрицу.'), + figure('/assets/editorial/2027/bitrix-lessons-2027-version-boundary-matrix.svg', 'Матрица версионной границы Bitrix: подключённый модуль, доступная поверхность, контракт операции и результат проверки.', 'Имя метода занимает только первый слой. До изменения нужно пройти до фактической поверхности установленной версии и сопоставить поля.'), + table('Уровни проверки Bitrix API', ['Уровень', 'Входной факт', 'Проверка', 'Риск пропуска'], [ + ['Модуль', 'iblock или main подключён', 'IncludeModule возвращает true', 'класс не загружен'], + ['Поверхность', 'метод или таблица существуют', 'проверить установленный API', 'вызов неизвестного метода'], + ['Поля', 'названия и типы совпадают', 'сопоставить mapping', 'тихая потеря значения'], + ['Семантика', 'ошибка и результат понятны', 'characterization/regression test', 'новая форма ведёт себя иначе'], + ]), + h2('Локальный инспектор поверхности'), + p('Чтобы сделать проверку повторяемой, можно сначала работать с маленьким manifest, полученным из конкретного окружения: версия, признак подключения и список доступных операций. Ниже функция принимает такой manifest и выбирает следующий технический шаг. Она не делает вид, что знает реальный сервер: данные нужно собрать командой проверки в самой среде, а функция только не даёт перепутать отсутствие модуля с отсутствием метода.'), + code(`import { inspectBitrixSurface } from './upgrade-2027-02.mjs'; + +const surfaces = [ + { version: '20.0', moduleLoaded: false, methods: [] }, + { version: '20.0', moduleLoaded: true, methods: ['CUser::GetByID', 'CUser::Update'] }, + { version: '23.0', moduleLoaded: true, methods: ['Bitrix\\Main\\UserTable'] }, +]; + +for (const surface of surfaces) console.log(inspectBitrixSurface(surface)); +// module-not-loaded +// legacy-surface-present +// d7-surface-present`), + p('Здесь важен порядок. Первый manifest не доходит до анализа методов: отсутствует базовое условие. Второй разрешает говорить только о наличии legacy-поверхности и требует явного адаптера. Третий показывает D7-поверхность, но не объявляет mapping полей готовым. Такой результат проще проверять в CI или в диагностической команде, чем свободный текст в issue.'), + h2('Поле важнее красивого имени'), + p('Самая тихая ошибка миграции — значение сохранилось, но стало другим. Телефон мог быть строкой с пробелами и плюсами, email — сохранён с исходным регистром, пустое поле — означать «очистить», а отсутствие поля — «не менять». Если новый API получает обычный объект без различия этих состояний, wrapper стирает смысл запроса. Поэтому contract table должна перечислять хотя бы value, empty и missing.'), + p('Version boundary нужно держать рядом с этой таблицей. Если в старой версии поле принимает строку, а новая модель отдаёт массив значений, название свойства не спасёт. Правильная проверка — пройти create/read/update на фиксированных данных и сравнить смысловой результат. Одинаковый ID после update ещё не доказывает, что события и индексы получили тот же input.'), + h2('Действия по порядку'), + ol([ + 'Зафиксировать версию ядра, подключаемый модуль и источник документации, на который опирается вызов.', + 'Собрать manifest доступных методов или таблиц из той среды, где будет выполняться изменение.', + 'Разложить операцию на поля, пустое значение, отсутствие поля, ошибку и побочный event.', + 'Проверить legacy и D7 на одном наборе входов, не сравнивая только имена классов или финальный ID.', + 'Оставить adapter boundary до тех пор, пока regression не подтвердит одинаковый смысл результата.', + ]), + h2('Ограничения и следующий шаг'), + p('Инспектор не заменяет запуск в Bitrix: он не видит автозагрузку, права, события, local overrides и SQL-ограничения. Номер версии может быть установлен, но отдельный модуль — отсутствовать. Semantic Versioning тоже не заставляет внутренний API платформы соблюдать обещания внешнего пакета. Любое утверждение о совместимости должно опираться на конкретный набор окружений и операций.'), + p('Следующий шаг — добавить в проект диагностическую команду, которая печатает только безопасный manifest: версия, подключённый модуль и названия операций без данных пользователей. Затем используйте его перед миграцией одного метода. Если surface различается, адаптер должен остановить изменение с понятной причиной, а не подобрать метод по совпадению имени.'), +], [ + { key: 'loader', use: 'Официальная проверка подключения модуля используется как первый слой version boundary.', boundary: 'Документация не сообщает состояние конкретной установки и не описывает mapping полей.' }, + { key: 'cuser', use: 'CUser и его D7-аналог используются для различения поверхности API и операций пользователя.', boundary: 'Страница не обещает, что два класса равны по событиям, типам и ошибкам.' }, + { key: 'semver', use: 'Правило совместимости версий используется как оговорка о границах обещаний поставщика.', boundary: 'Спецификация не делает Bitrix API Semantic Versioning-совместимым автоматически.' }, +]); + +const field = revision({ + slug: 'editorial-2027-02-field-bitrix-lessons', + title: 'Миграция Bitrix-поля: проверить сохранение, чтение и обратимость', + categories: ['Bitrix', 'Данные'], + cover: '/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg', + excerpt: 'Полевой чек-лист переноса пользовательского поля: mapping, пустые значения, повторный запуск и проверяемый результат.', + readingMinutes: 15, +}, [ + p('Проблема миграции Bitrix-поля проявляется после успешного ответа API. Запись получила ID, но телефон оказался пустым, email изменил регистр, а повторный запуск создал второе значение. Цена ошибки — не только испорченная строка. Дальше ломается поиск, уведомление или связь с внешней системой, а восстановить исходное состояние трудно, потому что команда сохранила только факт «update вернул успех».'), + p('Полевой разбор должен проверять три операции: сохранить mapping, прочитать результат тем же смыслом и повторить вход без дубля. Для каждого поля нужно различить отсутствующее значение и явную очистку. Это особенно важно при переходе от массивов Bitrix к canonical объекту: старое имя можно удалить из кода, но нельзя удалить смысл значения до окончания проверки.'), + h2('Сначала таблица mapping'), + p('Документация CUser перечисляет поля пользователя, включая PERSONAL_PHONE, EMAIL, идентификатор и время изменения. Для проекта это отправная точка, а не готовая схема: рядом могут быть пользовательские поля, обработчики события и внешний XML_ID. Запишите для каждого значения источник, формат, пустое состояние и обратное представление. Если поле не переносится, причина должна быть явной.'), + p('Нормализация должна быть идемпотентной: одинаковый вход при повторном запуске даёт одинаковый canonical результат. Для телефона это может быть trim без изменения номера, для email — lowercase, если бизнес-правило считает регистр незначимым. Нельзя применять общую нормализацию ко всем полям: комментарий пользователя, парольный хэш и XML_ID имеют разные правила.'), + figure('/assets/editorial/2027/bitrix-lessons-2027-migration-evidence-loop.svg', 'Цикл проверки миграции Bitrix-поля: mapping входа, нормализация, запись, повторное чтение и контроль повторного запуска.', 'Схема показывает, что успешный вызов записи — только середина проверки. Нужны read-back и повторяемость результата.'), + table('Полевой контракт переноса поля', ['Поле', 'Legacy-значение', 'Canonical-значение', 'Проверка'], [ + ['Телефон', 'PERSONAL_PHONE, пробелы допустимы', 'phone, trimmed string', 'read-back и формат'], + ['Email', 'EMAIL, исходный регистр', 'email, lower-case', 'валидность и отсутствие дубля'], + ['ID', 'ID пользователя', 'externalId', 'одинаковая запись при retry'], + ['Пустота', 'нет ключа или пустая строка', 'missing или clear', 'два разных теста'], + ['Связь', 'XML_ID', 'externalId', 'двусторонний mapping'], + ]), + h2('Учебная нормализация без Bitrix-сервера'), + p('Ниже запускается локальная функция преобразования одного объекта. Она сохраняет legacy-копию, создаёт canonical поля и возвращает признак обратимости. Это не подмена миграционного запуска: результат показывает, как тестировать mapping до подключения API. В интеграционном коде следующая проверка должна сравнить canonical объект с read-back из Bitrix.'), + code(`import { migrateUserFields } from './upgrade-2027-02.mjs'; + +const input = { + ID: 17, + PERSONAL_PHONE: ' +7 900 000-00-00 ', + EMAIL: 'User@Example.TEST', + XML_ID: 'crm-17', +}; + +console.log(migrateUserFields(input)); +// canonical.phone === '+7 900 000-00-00' +// canonical.email === 'user@example.test' +// reversible === true`), + p('Ожидаемый результат показывает две отдельные операции: пробелы убраны у телефона, регистр email нормализован, а исходные поля остаются в legacy snapshot. Snapshot нужен для теста и отката преобразования, но не должен случайно отправляться обратно в новый API. В production-модуле его заменит журнал безопасного mapping без персональных значений.'), + h2('Пустое поле и отсутствующее поле'), + p('Разница между {} и { PERSONAL_PHONE: "" } часто теряется в универсальном merge. Первый объект может означать «не менять телефон», второй — «очистить телефон». Если миграция смешивает эти случаи, повторный запуск удалит данные, которые не должны были меняться. В тестовой матрице должны быть оба входа и ожидаемое действие на стороне Bitrix.'), + p('Внешняя валидация тоже не должна менять значение молча. PHP filter_var может помочь проверить email, но решение о допустимости адреса принадлежит контракту приложения. Неверный email лучше остановить до update, чем сохранить пустую строку и потом считать ответ API доказательством успеха. Для телефона нужны отдельные правила: длина, допустимые символы и локальный формат.'), + h2('Действия по порядку'), + ol([ + 'Составить mapping table и отдельно назвать missing, empty, invalid и unchanged для каждого поля.', + 'Запустить нормализацию на локальном наборе с пробелами, разным регистром, пустым и неверным значением.', + 'В тестовой среде записать одну запись по стабильному ID или XML_ID, затем выполнить read-back.', + 'Сравнить смысловые поля, время изменения, события и внешний идентификатор; не ограничиваться HTTP/API success.', + 'Повторить тот же запуск и убедиться, что новая запись не появилась и canonical результат не изменился.', + ]), + h2('Ограничения и следующий шаг'), + p('Локальная функция не знает о правах, событиях и особенностях конкретной версии Bitrix. Read-back может вернуть представление, отличное от входа: формат телефона, timezone или пустое значение иногда нормализуются сервером. Восстановление должно учитывать транзакцию и сохранённую связь, а не просто повторно отправлять старый объект.'), + p('Следующий шаг — взять одно поле, для которого есть внешний XML_ID, и прогнать полный цикл на небольшой выборке: mapping, запись, read-back, повтор и отчёт по расхождениям. Пока расхождения не классифицированы, расширять миграцию на весь набор рискованно. Проверяемость одного поля ценнее широкого запуска с неясным результатом.'), +], [ + { key: 'cuser', use: 'Официальный список полей CUser используется для примера PERSONAL_PHONE, EMAIL, ID и XML_ID.', boundary: 'Документация не знает пользовательские поля, события и фактические данные проекта.' }, + { key: 'filter', use: 'PHP Manual используется для проверки допустимости входного email перед записью.', boundary: 'filter_var не определяет бизнес-правила, Bitrix-формат и гарантию сохранения поля.' }, + { key: 'loader', use: 'Подключение модуля упомянуто как проверка интеграционной границы до записи.', boundary: 'Страница не подтверждает права и настройки конкретной среды.' }, +]); + +export const revisions = Object.freeze([practice, mechanism, field]); + +export function verifyRevisionsAgainstFixture() { + const checks = revisions.map((item) => { + const body = bodyText(item.contentHtml); + return body.length >= 5000 && body.length <= 15000 && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body); + }); + const sample = inspectBitrixSurface({ version: '20.0', moduleLoaded: true, methods: ['CUser::GetByID', 'CUser::Update'] }); + const mapping = migrateUserFields({ PERSONAL_PHONE: ' 123 ', EMAIL: 'A@B.C' }); + const fixtureOk = sample.status === 'legacy-surface-present' && mapping.canonical.email === 'a@b.c'; + return Object.freeze({ passed: checks.filter(Boolean).length + (fixtureOk ? 1 : 0), total: checks.length + 1, accepted: checks.every(Boolean) && fixtureOk, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); +} + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} -export const revisions = deepFreeze([practice, mechanism, field]); -export function verifyRevisionsAgainstFixture() { const fixture = runFixedLessonFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|потер)/i.test(text.slice(0, 1000)) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-02/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-03.mjs b/web/scripts/upgrade-2027-03.mjs index 8e4d832..d46ba23 100644 --- a/web/scripts/upgrade-2027-03.mjs +++ b/web/scripts/upgrade-2027-03.mjs @@ -1,159 +1,272 @@ -function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +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 figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } - -const REFERENCES = deepFreeze({ - d112: { title: 'D Programming Language — Change Log 2.112.0', url: 'https://dlang.org/changelog/2.112.0.html', version: 'D 2.112.0, released 07 January 2026' }, - d111: { title: 'D Programming Language — Change Log 2.111.0', url: 'https://dlang.org/changelog/2.111.0.html', version: 'D 2.111.0, released 01 April 2025' }, - d110: { title: 'D Programming Language — Change Log 2.110.0', url: 'https://dlang.org/changelog/2.110.0.html', version: 'D 2.110.0, released 07 March 2025' }, -}); -function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '
  • ' + escapeHtml(ref.title) + ' — pinned release material: ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } - -const FIXED_RUNTIME_CASES = deepFreeze({ - 'planned-runtime-hand-off-v1': { id: 'planned-runtime-hand-off-v1', planDate: '2027-03', sourceCutoff: '2026-07-31', workload: { id: 'named-synthetic-request-shape', statement: 'question-only-no-service' }, constraints: [{ id: 'named-latency-budget-question', role: 'planned-constraint' }, { id: 'named-ownership-boundary-question', role: 'planned-constraint' }], benchmark: { state: 'not-run', configuration: 'not-collected', result: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Fixed in-memory planning literal. No runtime selection, compiler execution, benchmark, profile, service scenario, repository, file, environment, clock, network, secret, telemetry, production system or customer data is read, created or changed.' }, - 'undated-scenario-v1': { id: 'undated-scenario-v1', planDate: '', sourceCutoff: '2026-07-31', workload: { id: 'named-synthetic-request-shape', statement: 'question-only-no-service' }, constraints: [{ id: 'named-latency-budget-question', role: 'planned-constraint' }], benchmark: { state: 'not-run', configuration: 'not-collected', result: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'unnamed-workload-or-constraint-v1': { id: 'unnamed-workload-or-constraint-v1', planDate: '2027-03', sourceCutoff: '2026-07-31', workload: { id: '', statement: '' }, constraints: [{ id: '', role: 'planned-constraint' }], benchmark: { state: 'not-run', configuration: 'not-collected', result: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'hidden-benchmark-configuration-v1': { id: 'hidden-benchmark-configuration-v1', planDate: '2027-03', sourceCutoff: '2026-07-31', workload: { id: 'named-synthetic-request-shape', statement: 'question-only-no-service' }, constraints: [{ id: 'named-latency-budget-question', role: 'planned-constraint' }], benchmark: { state: 'not-run', configuration: 'hidden', result: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'disallowed-winner-v1': { id: 'disallowed-winner-v1', planDate: '2027-03', sourceCutoff: '2026-07-31', workload: { id: 'named-synthetic-request-shape', statement: 'question-only-no-service' }, constraints: [{ id: 'named-latency-budget-question', role: 'planned-constraint' }], benchmark: { state: 'not-run', configuration: 'not-collected', result: 'not-collected' }, requestedConclusion: 'd-runtime-wins', boundary: 'Negative fixed literal only.' }, -}); - -export function createFixedRuntimeCase(id = 'planned-runtime-hand-off-v1') { const value = FIXED_RUNTIME_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } -export function assessFixedRuntimePlan(input) { - if (!Object.values(FIXED_RUNTIME_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case'); - if (input.planDate !== '2027-03' || input.sourceCutoff !== '2026-07-31') return stop('stop-undated-scenario-or-cutoff', 'march-2027-plan-and-july-2026-cutoff-are-required', 'name-2027-03-and-2026-07-31'); - if (!input.workload?.id || !input.workload?.statement || !Array.isArray(input.constraints) || input.constraints.length < 1 || input.constraints.some((item) => !item.id || item.role !== 'planned-constraint')) return stop('stop-unnamed-workload-or-constraint', 'workload-and-every-constraint-must-be-named-synthetic-questions', 'name-the-workload-and-constraints'); - if (input.benchmark?.state !== 'not-run' || input.benchmark?.configuration !== 'not-collected' || input.benchmark?.result !== 'not-collected') return stop('stop-hidden-benchmark-configuration', 'a-future-benchmark-must-remain-not-run-with-no-configuration-or-result', 'keep-benchmark-as-not-run-and-not-collected'); - if (input.requestedConclusion !== 'synthetic-plan-hand-off') return stop('stop-disallowed-winner-or-positive-result', 'runtime-winner-or-positive-result-is-forbidden', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, workload: deepFreeze(cloneFixed(input.workload)), constraints: deepFreeze(cloneFixed(input.constraints)), benchmark: deepFreeze(cloneFixed(input.benchmark)), boundary: input.boundary, productionEffect: 'not-attempted', nextAction: 'give-the-fixed-runtime-question-contract-to-a-future-evidence-owner' }); +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -export function runFixedRuntimeFixture() { const expected = [['planned-runtime-hand-off-v1', 'synthetic-plan-hand-off'], ['undated-scenario-v1', 'stop-undated-scenario-or-cutoff'], ['unnamed-workload-or-constraint-v1', 'stop-unnamed-workload-or-constraint'], ['hidden-benchmark-configuration-v1', 'stop-hidden-benchmark-configuration'], ['disallowed-winner-v1', 'stop-disallowed-winner-or-positive-result']]; const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessFixedRuntimePlan(createFixedRuntimeCase(id)).status })); return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(createFixedRuntimeCase()) && Object.isFrozen(createFixedRuntimeCase().constraints), checks: deepFreeze(checks) }); } -function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } -const refs = [ - { key: 'd112', use: 'Зафиксирована дата последнего учитываемого официального D release и только перечень его изменений.', boundary: 'Не подтверждает выбор runtime, запуск, benchmark, profile или сервисный сценарий.' }, - { key: 'd111', use: 'Использован как pinned официальный материал о D 2.111.0 и его версии.', boundary: 'Не превращает языковую возможность в результат сравнения.' }, - { key: 'd110', use: 'Использован как pinned официальный material о D 2.110.0 и его дате.', boundary: 'Не даёт данных о чужой нагрузке, конфигурации или production effect.' }, +function bodyText(html) { + return plainText(html.replace(/

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

|$)/, '')); +} + +const REFERENCES = Object.freeze({ + functions: { + title: 'D Language Specification: Functions and Function Safety', + url: 'https://dlang.org/spec/function.html', + version: 'D language specification, page generated 25 July 2026', + }, + memory: { + title: 'D Language Specification: Memory Safety', + url: 'https://dlang.org/spec/memory-safe-d.html', + version: 'D language specification, page generated 23 July 2026', + }, + abi: { + title: 'D Language Specification: Application Binary Interface', + url: 'https://dlang.org/spec/abi.html', + version: 'D language specification, page checked 31 July 2026', + }, + cInterface: { + title: 'D Language Specification: Interfacing to C', + url: 'https://dlang.org/spec/interfaceToC.html', + version: 'D language specification, page checked 31 July 2026', + }, +}); + +function sources(entries) { + return '
    ' + entries.map(({ key, use, boundary }) => { + const reference = REFERENCES[key]; + return '
  • ' + escapeHtml(reference.title) + ' — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; + }).join('') + '
'; +} + +export function validateDWorkload(input) { + const throughput = Number(input?.throughput ?? 0); + const latencyBudgetMs = Number(input?.latencyBudgetMs ?? 0); + const nativeBoundary = Boolean(input?.nativeBoundary); + const deploymentTargets = Number(input?.deploymentTargets ?? 0); + if (!Number.isFinite(throughput) || throughput <= 0) return { decision: 'reject', reason: 'нужна измеримая нагрузка' }; + if (latencyBudgetMs <= 0) return { decision: 'reject', reason: 'нужен бюджет задержки' }; + if (deploymentTargets > 2 && !nativeBoundary) return { decision: 'compare', reason: 'широкий target matrix требует сравнить toolchain' }; + if (nativeBoundary && throughput > 10000) return { decision: 'consider-d', reason: 'нагрузка и нативная граница дают основание проверить D' }; + return { decision: 'keep-current-tool', reason: 'ограничения не требуют смены языка' }; +} + +export function checkSafeBoundary(input) { + const capacity = Number(input?.capacity ?? 0); + const declaredLength = Number(input?.declaredLength ?? 0); + const pointerChecked = Boolean(input?.pointerChecked); + if (!pointerChecked) return { class: 'system', action: 'проверить указатель и владельца буфера вручную' }; + if (!Number.isInteger(declaredLength) || declaredLength < 0 || declaredLength > capacity) return { class: 'reject', action: 'остановить вызов: длина выходит за буфер' }; + return { class: 'safe-interface', action: 'передать slice с проверенной длиной' }; +} + +export function validateCAbiPacket(packet, contract) { + const errors = []; + if (packet?.byteLength !== contract?.byteLength) errors.push('размер структуры'); + if (packet?.endianness !== contract?.endianness) errors.push('порядок байтов'); + for (const field of contract?.fields ?? []) { + if (!Object.hasOwn(packet ?? {}, field.name)) errors.push('поле ' + field.name); + } + return { accepted: errors.length === 0, errors }; +} + +function revision(meta, parts, referenceEntries) { + const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); + return Object.freeze({ ...meta, contentHtml, proseLength }); +} + +const practice = revision({ + slug: 'editorial-2027-03-practice-d-lessons', + title: 'D для прикладной утилиты: сначала контракт входа, потом язык', + categories: ['D', 'Инженерные практики'], + cover: '/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg', + excerpt: 'Как понять, есть ли у задачи основание для D: нагрузка, native boundary, target matrix и стоимость toolchain.', + readingMinutes: 15, +}, [ + p('Проблема выбора языка для утилиты начинается с красивого свойства: D компилируется в native binary, поддерживает контрактные проверки и даёт доступ к C ABI. Если принять это свойство за готовое решение, небольшая команда получает новый компилятор, пакетный менеджер и набор правил сборки, хотя исходная задержка могла возникать в запросе, формате файла или неверном контракте входа. Цена — месяцы сопровождения ради проблемы, которую язык не решает.'), + p('Чтобы решение было инженерным, нужно сначала описать workload: сколько элементов проходит через программу, какой бюджет задержки, какие target-платформы, есть ли C-библиотека и как будет собираться бинарник. D появляется в таблице как один вариант рядом с текущим языком. Этот порядок не принижает язык. Он защищает проект от решения по вкусу и оставляет проверяемый критерий, когда выбор оправдан.'), + h2('Что именно хотим улучшить'), + p('«Нужна производительность» — слишком широкая формулировка. Для CLI важны время запуска, скорость обработки, память и размер артефакта. Для сервиса добавляется модель конкуренции, timeout и наблюдаемость. Для инструмента около C-библиотеки важны соглашение вызова, layout структуры и способ владения памятью. Один и тот же язык может быть уместен для одного пункта и лишним для другого.'), + p('Опишите минимум два кандидата. Текущий инструмент часто выигрывает скоростью разработки и готовыми библиотеками; D может выиграть у места, где важны native deployment, явная работа с памятью или C ABI. Но каждое преимущество имеет стоимость: новый toolchain, обучение, platform packages, время сборки и диагностика production binary. Сравнение должно показывать эту цену рядом с эффектом.'), + figure('/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg', 'Карта выбора языка для прикладной утилиты: workload и ограничения ведут к сравнению D с текущим инструментом и проверяемому решению.', 'Схема начинает решение с нагрузки и ограничений. Ветка D появляется только после определения границы задачи, а не из-за отдельного свойства языка.'), + table('Матрица выбора D для утилиты', ['Ограничение', 'Вопрос', 'Сигнал в пользу D', 'Цена решения'], [ + ['Нагрузка', 'где расходуется CPU и память?', 'узкое место внутри вычисления', 'нужен профиль, а не предположение'], + ['Native boundary', 'есть ли C ABI или системный вызов?', 'контракт можно проверить на границе', 'ручная проверка unsafe-участка'], + ['Targets', 'сколько платформ и архитектур?', 'матрица поддерживается toolchain', 'сборки и бинарные артефакты'], + ['Команда', 'кто будет читать и менять код?', 'есть owner и code review', 'обучение и время поддержки'], + ['Доставка', 'как версионируется бинарник?', 'простая доставка без runtime', 'размер, лицензии, упаковка'], + ]), + h2('Учебный локальный фильтр требований'), + p('Функция ниже делает не рекламный вывод, а фиксирует форму входа. В ней есть положительная ветка только тогда, когда одновременно названы нагрузка, latency budget и native boundary. Если target matrix шире двух платформ, результат предлагает сравнение, а не автоматический переход. Все числа учебные: перед решением их заменяют измерениями конкретной команды.'), + code(`import { validateDWorkload } from './upgrade-2027-03.mjs'; + +const inputs = [ + { throughput: 12000, latencyBudgetMs: 20, nativeBoundary: true, deploymentTargets: 1 }, + { throughput: 300, latencyBudgetMs: 500, nativeBoundary: false, deploymentTargets: 1 }, + { throughput: 12000, latencyBudgetMs: 20, nativeBoundary: false, deploymentTargets: 4 }, ]; -const practice = revision({ slug: 'editorial-2027-03-practice-d-lessons', title: 'Уроки D для прикладного инженера: практический маршрут', categories: ['Инженерные практики', 'Языки программирования'], cover: '/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg', excerpt: 'План на март 2027: как поставить вопрос о D среди ограничений, не объявляя его языком по умолчанию.', readingMinutes: 21 }, [ - p('План на 2027-03 начинается не с установки D, а с конкретной дорогой ошибки: прикладной инженер принимает язык за готовый ответ до того, как назвал границы задачи. Тогда небольшая задача получает новый toolchain, сложный маршрут сборки и спор о вкусе, хотя исходная проблема могла быть в контракте, владении или задержке между слоями. Цена ошибки — не абстрактная «сложность», а время на интеграцию, повторные изменения и невозможность объяснить, что именно сравнивали.'), - p('Вторая дорогая ошибка — описать будущий benchmark как уже существующий profile или сервисный сценарий. На редакторскую дату 2026-07-31 март 2027 ещё не наступил: у этого номера нет повторного profile, benchmark, запуска D, выбранного runtime, выпущенной версии или результата сравнения. Поэтому перед вами только явно маркированный план/сценарий на 2027-03 с source cutoff 2026-07-31. Единственный положительный output — synthetic-plan-hand-off и productionEffect: not-attempted.'), - h2('Не начинать с языка по умолчанию'), - p('D полезно рассматривать как один из кандидатов, когда вопрос уже имеет форму. Не «нужен ли быстрый язык», а «какой именованный участок требует проверяемого свойства и не ломает границу владения». Язык способен выразить ограничения, но не отменяет формат обмена, политику релиза, ownership зависимостей и стоимость сопровождения. Если эти условия не названы, сравнение превращается в выбор любимого инструмента, а не в инженерное решение.'), - p('Практический маршрут намеренно оставляет D не-default. Для существующей системы, где основной риск — несовместимый контракт или отсутствие владельца, смена языка может не дать никакого полезного сигнала. Для маленькой утилиты, где уже согласован другой runtime, цена нового пути может оказаться выше потенциальной выгоды. Для участка с жёсткими ограничениями вопрос может быть уместен, но это ещё не означает, что D должен победить. Уместность — приглашение к проверке, а не verdict.'), - figure('/assets/editorial/2027/d-lessons-2027-runtime-tradeoff-map.svg', 'Карта планового сравнения runtime: от именованной synthetic нагрузки и ограничений к вариантам D и не-D; красные блоки запрещают default, скрытый benchmark и объявление победителя.', 'Карта помогает сформировать будущий вопрос о компромиссах. Она не показывает существующий сервис, не выбирает runtime и не содержит результата benchmark.'), - table('Карта контекстного сравнения на 2027-03', ['Вопрос', 'Допустимый плановый ответ', 'Почему D не default', 'Что пока неизвестно'], [ - ['Нагрузка', 'named synthetic request shape', 'форма вопроса не равна реальной системе', 'объём, данные и окружение'], - ['Ограничение', 'named latency или ownership question', 'одно ограничение не заменяет остальные', 'приоритет и допустимый компромисс'], - ['Runtime', 'D и альтернативы как варианты', 'вариант не равен выбранному стеку', 'совместимость и стоимость доставки'], - ['Benchmark', 'not-run / not-collected', 'без config нельзя сравнивать', 'методика, machine, flags, samples'], - ['Вывод', 'synthetic-plan-hand-off', 'план не назначает winner', 'нужен ли отдельный scope'], +for (const input of inputs) console.log(validateDWorkload(input)); +// consider-d; keep-current-tool; compare`), + p('Первый вход имеет узкую вычислительную задачу и нативную границу — D стоит проверить измерением. Второй не требует смены toolchain по заданным ограничениям. Третий слишком широк для одного выбора: сначала нужно сравнить способы сборки и доставки на всех targets. Функция полезна как шаблон карточки требований, а не как замена профилированию.'), + h2('Контрактная проверка в D'), + p('У D есть function contracts: precondition через in и postcondition через out. Это не универсальная валидация входа и не замена тестам. Контракт полезен, когда условие принадлежит самой функции: размер диапазона, допустимый индекс, инвариант результата. Для пользовательского файла всё равно нужна отдельная ошибка с безопасным сообщением и понятным форматом.'), + p('При выборе языка не обещайте, что contract автоматически ускорит программу или найдёт бизнес-ошибку. Он проверяет условие в точке выполнения и зависит от режима сборки. Важнее сначала назвать, кто владеет условием: parser, domain service или boundary с C. Тогда один и тот же контракт можно повторить в тесте и в обработчике ошибки, не пряча смысл в assertion.'), + h2('Действия по порядку'), + ol([ + 'Записать единицу нагрузки, бюджет задержки, размер данных, target-платформы и входные ограничения.', + 'Найти измеряемый bottleneck и проверить, находится ли он внутри кода, который язык действительно изменит.', + 'Сравнить D и текущий вариант по сборке, библиотекам, отладке, размеру бинарника и навыкам поддержки.', + 'Для нативной границы описать C ABI, владение памятью и ошибку; unsafe-участок выделить отдельно.', + 'Собрать минимальный прототип с одним workload и одинаковой методикой замера, затем зафиксировать результат и цену поддержки.', ]), - h2('Сначала назвать нагрузку, затем границу'), - p('Слово «нагрузка» нельзя оставлять декоративным. В этом draft named-synthetic-request-shape — только имя вопроса, без URL, клиента, количества запросов, payload и реального сервиса. Такой уровень намеренно слабый: он позволяет проверить, что вопрос назван, но не позволяет имитировать данные. Если будущей команде потребуется фактическая нагрузка, она должна появиться в отдельном authorised scope с собственными правилами доступа и provenance, а не быть тайно дописана в мартовский план.'), - p('Ограничение должно быть столь же конкретным по форме. «Нужна производительность» не различает latency, throughput, старт, память, хвост распределения, стоимость обслуживания или риск интеграции. В literal есть named-latency-budget-question и named-ownership-boundary-question: это labels для будущей развилки, а не бюджет и не замер. Если имя отсутствует, evaluator останавливает сценарий. Подставлять удобное default-ограничение опаснее, чем вернуть пустую карточку: оно незаметно меняет смысл выбора.'), - h2('Механизм практического решения'), - p('Контекстное сравнение состоит из трёх разных операций. Первая фиксирует, что требуется различить: например, путь, где ограничение выражается в исходном коде, и путь, где оно обеспечивается вокруг кода. Вторая перечисляет, какие trade-off могут сделать сравнение нечестным: сборка, ABI, отладка, доступность библиотек, deployment и ownership. Третья решает, существуют ли полномочия и данные для benchmark. Смешать операции — значит сделать «быстро» одновременно требованием, методом и результатом.'), - p('Официальные changelog D помогают удержать версионную дисциплину. На cutoff последняя используемая здесь публикация — D 2.112.0 от 07 January 2026; в ней перечислены изменения compiler, runtime и library. Это источник слов о конкретном release, но не источник о пригодности к нашей неописанной нагрузке. Равным образом упоминание 2.111.0 или 2.110.0 не доказывает, что будущая сборка совместима, что runtime выбран или что изменение будет заметно в сервисе.'), - h2('Runnable literal вместо поддельного запуска'), - code("import { createFixedRuntimeCase, assessFixedRuntimePlan } from './upgrade-2027-03.mjs';\n\nconst plan = createFixedRuntimeCase('planned-runtime-hand-off-v1');\nconst result = assessFixedRuntimePlan(plan);\nconsole.log({ status: result.status, effect: result.productionEffect });\n// { status: 'synthetic-plan-hand-off', effect: 'not-attempted' }"), - p('Этот example runnable и безопасен, потому что работает только с fixed in-memory literal. Factory делает JSON clone и затем deep freeze всех вложенных значений; evaluator принимает JSON-равный известный literal и fail-closed закрывает другой input. Код не запускает компилятор, не выбирает D runtime, не читает файлы, environment, clock, сеть, secrets, profile или telemetry. Он проверяет только дисциплину будущего вопроса и не является microbenchmark, performance test или сервисной репетицией.'), - h2('Порядок действий для будущего owner'), - ol(['Зафиксировать planDate: 2027-03 и sourceCutoff: 2026-07-31; без этой пары остановить сценарий.', 'Назвать synthetic workload без описания несуществующего сервиса, данных или profile.', 'Назвать каждое constraint и отдельно записать, кто вправе изменить его приоритет.', 'Поставить D рядом с альтернативами как subject of comparison, не как default и не как решение.', 'Оставить benchmark строго not-run, configuration/result — not-collected; скрытая configuration запрещена.', 'Передать только question contract будущему evidence owner; не объявлять winner, rollout, runtime selection или production gain.']), - h2('Где полезно остановиться до сравнения'), - p('Есть несколько случаев, когда честный маршрут заканчивается раньше benchmark. Если неизвестно, кто владеет boundary между вызывающим кодом и библиотекой, сначала нужен contract; иначе любой runtime будет оцениваться на плавающей постановке. Если delivery path не может принять новый compiler или артефакт, это не «минус D в целом», а ограничение конкретного контура. Если предметом тревоги служит только общее ощущение медленности, полезнее сначала назвать операцию и ожидание. Сценарий не обязан доходить до измерения, чтобы принести пользу: ранний stop сохраняет время, которое иначе ушло бы на спор о неназванной цели.'), - p('Не-default позиция означает и право оставить текущий runtime. Такой выбор не требует доказывать, что существующий стек абсолютный победитель. Достаточно увидеть, что future question пока не различает варианты или что стоимость введения нового пути превышает известную ценность. Особенно вредно превращать отказ от эксперимента в скрытую оценку языка. «Не открываем scope» и «D не подходит» — разные statements: первое описывает решение о работе, второе потребовало бы evidence, которого в этом draft нет.'), - h2('Как не спутать сравнение с миграцией'), - p('Сравнение и миграция имеют разный предмет. Сравнение спрашивает, какой вариант стоит исследовать при заданной форме задачи. Миграция уже предполагает выбранную точку назначения, инвентаризацию зависимостей, переходную архитектуру, rollback и владение результатом. Если в карточку сравнения незаметно попадают слова «перенести», «заменить» или «выпустить», она начинает обещать организационную работу. P109 не создаёт такой работы: он не содержит списка модулей, совместимости, плана релиза или бизнес-эффекта.'), - p('Точно так же нельзя использовать маленький runnable literal как proof of language behaviour. JavaScript validator здесь выбран только потому, что он проверяет редакционный контракт пакета; он не моделирует D, ABI или memory management. Его значение в том, что любой reader может увидеть одинаковый stop для недатированного сценария и declared winner. Будущее D-пример, если оно потребуется, должно быть собственным artifact с pinned compiler, исходным кодом и объяснённой целью. До этого граница между meta-check и техническим test должна оставаться видимой.'), - h2('Ограничения маршрута и следующий шаг'), - p('Маршрут не покрывает выбор команды, бюджет, лицензионную проверку, security review, ABI-интеграцию, compatibility matrix или обслуживание пакетов. Он не говорит, что D быстрее, безопаснее или дешевле какой-либо альтернативы. Публичный release material не заменяет фактов о target platform и не позволяет вычислить стоимость миграции. Особенно важно не подменять отсутствие benchmark словом «интуитивно»: интуиция может быть поводом сформулировать гипотезу, но не положительным результатом.'), - p('Следующий шаг — будущему владельцу решить, открывать ли отдельный scope с названной workload, прозрачной конфигурацией benchmark и заранее согласованными критериями falsification. Если полномочия или данные не появляются, synthetic-plan-hand-off остаётся корректным финалом этого draft. В марте 2027 он может стать входом нового исследования, но не задним числом доказательством. Практический урок D здесь простой: сначала сделать условия выбора видимыми, затем позволить языку проиграть, если условия ему не подходят.'), -], refs); + h2('Ограничения и следующий шаг'), + p('Фильтр требований не измеряет скорость и не говорит, что D лучше. Порог throughput в примере вымышленный и не переносится на другое железо. Контракты не устраняют логические ошибки, зависимость от внешней библиотеки или стоимость сборки. D также не делает код переносимым автоматически: platform ABI, linker и runtime остаются частью решения.'), + p('Следующий шаг — взять одну горячую функцию и сделать парный прототип на текущем языке и D с одинаковым входом и выходом. Замерьте cold start, steady-state, память и время разработчика на исправление намеренной ошибки. Решение «остаться» будет таким же полезным результатом, как переход, если оно опирается на эти поля.'), +], [ + { key: 'functions', use: 'Function contracts и атрибуты D используются для объяснения pre/post conditions и границы функции.', boundary: 'Спецификация не выбирает язык для продукта и не даёт benchmark конкретного workload.' }, + { key: 'memory', use: 'Категории @safe, @trusted и @system используются при оценке нативной границы.', boundary: 'Memory safety не гарантирует отсутствие логических, portability и performance ошибок.' }, + { key: 'abi', use: 'ABI-граница включена в матрицу выбора как часть доставки бинарника.', boundary: 'Спецификация ABI не описывает настройки конкретного компилятора и платформы.' }, +]); -const mechanism = revision({ slug: 'editorial-2027-03-mechanism-d-lessons', title: 'Уроки D для прикладного инженера: как принять инженерное решение', categories: ['Архитектура', 'Языки программирования'], cover: '/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg', excerpt: 'План на март 2027: runtime trade-off как проверяемая гипотеза с ограничениями и falsifiability.', readingMinutes: 21 }, [ - p('Конкретная проблема mechanism-статьи — спутать свойство языка со свойством готовой системы. После этого из фразы о безопасности, управлении памятью или компиляции делают обещание о latency и надёжности целого маршрута. Цена ошибки — неверный scope: инженер чинит или сравнивает runtime, хотя решение определяется границей FFI, планом сборки, аллокациями в библиотеке либо операционной моделью.'), - p('Вторая проблема — скрыть условия будущего benchmark и затем назвать его победителя заранее. По состоянию на 2026-07-31 нет benchmark, повторного profile, service scenario, runtime selection, release D после cutoff или результатов сравнения для этого выпуска. Это только план/сценарий на 2027-03. Мы не утверждаем, что D или иной runtime выиграл; единственный допустимый positive outcome — synthetic plan hand-off с productionEffect: not-attempted.'), - h2('Runtime trade-off — это не таблица сильных сторон'), - p('Runtime trade-off полезен, когда он связывает именованное ограничение с конкретным механизмом и условием опровержения. «Меньше аллокаций» без пути исполнения — рекламный ярлык. «Нужен предсказуемый хвост» без метода наблюдения — только желание. В будущем сравнении каждое утверждение должно иметь владельца: кто определил workload, кто согласовал baseline, кто описал machine и compiler flags, кто вправе считать результат достаточным. Пока этих элементов нет, модель должна говорить not-collected, а не заполнять пробелы правдоподобными цифрами.'), - p('D приносит в такой разговор выразительные языковые механизмы и собственный runtime-контекст, но не снимает обязанность проверить границу. Например, декларация атрибутов или изменение в release может изменить пространство вариантов; она не доказывает поведение интегрированного приложения. Changelog 2.112.0 фиксирует официальный перечень изменений релиза от 07 January 2026, а не performance claim для будущего сценария. Именно это различие защищает обсуждение от подмены «доступно в версии» на «подходит системе». '), - figure('/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg', 'Матрица ограничений runtime: именованные вопросы о задержке, владении, поставке и наблюдаемости сопоставлены с необходимым будущим evidence; нижняя строка запрещает скрытую конфигурацию и победителя.', 'Матрица показывает falsifiability будущего сравнения. Она не содержит измерений, профиля, runtime decision или факта о production.'), - table('Матрица falsifiability будущего runtime-вопроса', ['Ограничение', 'Проверяемый механизм', 'Минимальный future evidence', 'Что опровергнет гипотезу'], [ - ['Latency question', 'граница работы и измеряемая операция', 'полная benchmark configuration', 'не тот путь или не тот budget'], - ['Ownership question', 'владение памятью и API boundary', 'явный contract и lifecycle', 'скрытая передача или aliasing'], - ['Delivery question', 'compiler, runtime, packaging', 'target/flags/dependency record', 'несовместимый путь поставки'], - ['Observability question', 'какие события допустимо видеть', 'согласованный provenance', 'неполный или недоступный input'], - ['Сейчас', 'fixed literal validator', 'not-run/not-collected', 'любая claim о winner или result'], +const mechanism = revision({ + slug: 'editorial-2027-03-mechanism-d-lessons', + title: 'D: @safe и @trusted на границе C API', + categories: ['D', 'Безопасность памяти'], + cover: '/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg', + excerpt: 'Как провести маленький unsafe-участок через проверенный интерфейс и не считать атрибут @safe доказательством всей системы.', + readingMinutes: 16, +}, [ + p('Проблема FFI-кода появляется там, где D вызывает C-функцию с указателем и отдельной длиной буфера. Если длина пришла из другого источника, вызов может прочитать за пределами памяти, даже когда внешний метод выглядит коротким. Цена ошибки — повреждение памяти, падение процесса или уязвимость, которую трудно воспроизвести по обычному input. Один атрибут на публичной функции не исправляет неверное условие границы.'), + p('В D для этой границы различаются @safe, @trusted и @system. @safe-код ограничивает операции, которые могут привести к memory corruption. @trusted разрешает узкий участок, но ответственность за его интерфейс остаётся у автора. @system не даёт компилятору такого обещания. Механизм работает, если unsafe-код короткий, его входы проверены, а наружу выходит безопасное представление данных.'), + h2('Сначала проверяем размер, потом вызываем C'), + p('C-функция часто получает pointer + length. Сам указатель не содержит длину, поэтому компилятор не может вывести, что заявленный диапазон действителен. В D безопасный wrapper должен принять массив или slice, проверить нужное условие и передать только диапазон, размер которого известен. Если C API требует null-terminated string, одного массива байт тоже недостаточно: нужна отдельная проверка завершающего байта.'), + p('Изолируйте правила владельца. Если C-функция сохраняет указатель после возврата, wrapper должен либо запретить такой вызов, либо передать копию с понятным временем жизни. Атрибут scope помогает выражать ограничения escape там, где включена соответствующая проверка, но он не заменяет договорённость с внешней библиотекой. Любая функция, которая сохраняет адрес, требует отдельного чтения API и теста.'), + figure('/assets/editorial/2027/d-lessons-2027-constraint-matrix.svg', 'Матрица границы D и C API: размер буфера, владелец, атрибут безопасности и допустимый результат проверки.', 'Схема связывает техническое ограничение с проверкой и стоп-условием. Зелёный путь начинается только после проверки длины и времени жизни.'), + table('Роли атрибутов на FFI-границе', ['Уровень', 'Что разрешает', 'Что обязан проверить инженер', 'Типичная ошибка'], [ + ['@safe', 'ограниченный набор операций', 'что вызовы и значения остаются безопасными', 'считать весь вызванный C безопасным'], + ['@trusted', 'узкая ручная обёртка', 'инвариант указателя, длины и lifetime', 'поместить большой модуль в trusted'], + ['@system', 'низкоуровневые операции', 'каждый callsite и контракт ABI', 'передать raw pointer без проверки'], + ['slice', 'указатель и длина вместе', 'что slice не выходит за объект', 'довериться внешней length'], ]), - h2('Ограничения задают форму причинности'), - p('Условия нельзя собирать после вывода. Если latency budget назван только в заключении, получится круг: выбранный runtime объявляют подходящим, потому что именно его измеряли, а измеряли его, потому что он уже объявлен подходящим. Если ownership boundary не зафиксирован, один вариант может незаметно переложить работу в обвязку, а другой — оставить её внутри процесса. Оба тогда выглядят разными языковыми ответами на вопрос, который на самом деле не был сформулирован.'), - p('Falsifiability требует разрешить неприятный результат заранее. Для D это означает: определить, какие наблюдения будущего authorized scope сделают вариант неуместным, и не прятать их за словом «зависит». Это может быть неприемлемая поставка, нужда в отсутствующей интеграции, неоправданный operational burden или несовпадение с ownership rule. В нашем draft эти варианты не измеряются и не оцениваются. Но они должны быть названы как классы стоп-сигнала, иначе у сравнения остаётся лишь путь к заранее выбранному ответу.'), - h2('Configuration — часть evidence, а не приложение мелким шрифтом'), - p('Benchmark configuration — не техническая мелочь. В неё в будущем войдут хотя бы версия compiler/runtime, target, flags, машина, ввод, число прогонов, прогрев, baseline, способ агрегации и правило исключения выбросов. Пока ни одно из этих значений не определено в данном scope, нельзя написать даже «условно измерено». Наша fixed case прямо содержит state: not-run, configuration: not-collected и result: not-collected; любое другое значение останавливается как hidden configuration.'), - p('Такой fail-closed ответ намеренно строже, чем обычная черновая заметка. Он не пытается угадать, какой флаг или machine будет разумным в 2027-03, потому что угадывание сразу стало бы частично вымышленным benchmark. Кроме того, оптимальная конфигурация зависит от вопроса: тестировать build time и response path одинаковой таблицей бессмысленно. Механизм статьи не предлагает template для всех сравнений; он требует, чтобы future owner сначала отделил benchmark contract от результата и смог показать оба.'), - h2('Литеральная проверка границы'), - code("import { createFixedRuntimeCase, assessFixedRuntimePlan } from './upgrade-2027-03.mjs';\n\nconst hidden = createFixedRuntimeCase('hidden-benchmark-configuration-v1');\nconsole.log(assessFixedRuntimePlan(hidden));\n// { status: 'stop-hidden-benchmark-configuration', productionEffect: 'not-attempted', ... }"), - p('Пример не компилирует D и не измеряет программу. Он only evaluates a named in-memory literal, полученный через JSON clone и recursively deep freeze. Изменённый object или неизвестный id также закрываются, поэтому caller не может пронести результат под видом plan. Это маленький, но важный механизм: validator не утверждает, что configuration хорошая; он утверждает только, что у будущего comparison нет права скрывать её или заменить результатом ещё не проведённого запуска.'), - h2('Официальный release не равен compatibility promise'), - p('Источники этого номера намеренно ограничены official pinned primary D release materials. Они датируют 2.110.0, 2.111.0 и 2.112.0 и дают контекст изменениям, которые были опубликованы не позднее cutoff. Они не заменяются блогами, benchmark-агрегаторами, чужими кейсами или документацией, которая успела измениться после cutoff. Такая узость не обедняет draft: она не позволяет тихо импортировать современную репутацию экосистемы туда, где требуется доказательство конкретной интеграции.'), - p('Из release notes можно корректно извлечь только ограниченные факты о release. Например, 2.112.0 перечисляет изменения compiler и runtime; это повод включить version pin в будущую конфигурацию. Некорректный следующий шаг — превратить список в утверждение, что определённая service path станет лучше. Между ними лежат target, код, allocator, libraries, ABI, операционная среда и метод измерения. Даже если все эти элементы появятся позже, это будет evidence нового scope, а не свойство этого мартовского текста.'), - h2('Порядок для проверяемого trade-off'), - ol(['Назвать synthetic workload и не дописывать несуществующий endpoint, client или объём.', 'Выделить ограничения: latency, ownership, delivery и observability не сводить к одному слову «быстро».', 'Для каждого ограничения назвать механизм и заранее записать, что могло бы его опровергнуть.', 'Зафиксировать, что benchmark пока not-run, а configuration и result not-collected.', 'Проверить литерал fail-closed validator; скрытая config, unnamed constraint и winner должны вернуть stop.', 'Передать only the question contract будущему owner, который отдельно решит доступ, методику и выбор runtime.']), - h2('Почему один результат не закрывает trade-off'), - p('Даже будущий корректный замер не отменит matrix. Один result отвечает только на один заранее описанный вопрос при одной configuration; он не переносится автоматически на другой target, другой вход или другой lifecycle. Это важно для прикладного инженера: желание закончить спор одной цифрой понятно, но часто именно цифра скрывает смену scope. Если ownership или delivery остаются нерешёнными, быстрый путь может быть плохим вариантом. Если latency не критична, сложная настройка может не окупить даже честно найденную разницу.'), - p('Поэтому outcome будущего исследования должен быть двусторонним. Он может дать основание продолжить изучение D, оставить текущий вариант, изменить постановку или признать, что разница несущественна. Ни один из этих результатов нельзя назначить в мартовском draft. Здесь допускается только проверка готовности вопроса к такой развилке. Это меньше похоже на сравнительную презентацию, зато даёт получателю возможность проверить предпосылки до того, как значение метрики получит незаслуженный авторитет.'), - h2('Ограничения механизма и следующий шаг'), - p('Механизм не покрывает proof of correctness, security posture, лицензии, стоимость обучения, зрелость команды, policy закупок или поддержку через годы. Он не делает вывод о GC, manual memory control, @safe, Better C или определённом compiler: для такого вывода нужен отдельно зафиксированный вопрос и источник, а не общее имя D. Он также не говорит, что любой benchmark полезен. Плохо поставленный benchmark может дать больше уверенности и меньше знания, чем честный отказ от запуска.'), - p('Следующий шаг — создать новый, явно authorised evidence scope только после того, как владелец примет named workload и constraint matrix. В нём можно будет выбрать версии, описать конфигурацию и сформулировать критерий остановки; сейчас этого нет. Если процесс не начнётся, outcome не меняется: synthetic-plan-hand-off не является провалом, а фиксирует, что вопрос пока поставлен без выдуманного выигрыша. Самый полезный урок runtime trade-off — сделать условия поражения видимыми до попытки найти победителя.'), -], refs); + h2('Локальная проверка буфера'), + p('Вместо вызова реальной C-библиотеки сначала можно прогнать boundary checker на данных теста. Он принимает capacity, заявленную длину и признак проверки указателя. Результат разделяет отсутствие проверки, неверный диапазон и безопасный интерфейс. Это предметный пример входа в FFI: он проверяет именно опасную пару pointer/length, а не абстрактный статус карточки.'), + code(`import { checkSafeBoundary } from './upgrade-2027-03.mjs'; -const field = revision({ slug: 'editorial-2027-03-field-d-lessons', title: 'Уроки D для прикладного инженера: кейс с ограничениями и выводами', categories: ['Полевые заметки', 'Инженерные решения'], cover: '/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg', excerpt: 'План на март 2027: synthetic evidence hand-off вместо report, profile или результата сравнения.', readingMinutes: 21 }, [ - p('Самая дорогая ошибка field-формата — выдать hand-off будущего исследования за готовый кейс. Читатель видит знакомые слова «profile», «сервис», «runtime» и считает, что уже есть данные, владелец, измерение и результат. Цена ошибки — ложная операционная память: следующая команда ищет несуществующий артефакт, защищает несуществующий вывод и может построить реальное решение на авторитете аккуратно написанного текста.'), - p('Здесь этого не будет. На 2026-07-31 март 2027 ещё не наступил, поэтому P109 — только план/сценарий на 2027-03, source cutoff 2026-07-31. Нет повторного profile, benchmark, сервисного сценария, выбора runtime, выпуска D после cutoff или результата сравнения. Field focus означает synthetic evidence hand-off: карточку вопроса, которую будущий owner может принять, вернуть или развить в другом scope. Единственный positive outcome — synthetic-plan-hand-off с productionEffect: not-attempted.'), - h2('Hand-off передаёт вопрос, а не доказательство'), - p('В обычной полевой заметке полезно передать следы: конфигурацию, наблюдение, ссылку на код, лог, замер, дату и известный эффект. В этой заметке ни одного такого артефакта нет и не должно появиться. Synthetic hand-off содержит только date/cutoff, named synthetic workload, named planned constraints, состояние evidence и следующую роль. Он не содержит пользователя, проекта, machine, пакета, URL, команды, значения метрики или последовательности действий реальной системы.'), - p('Такой аскетизм не делает передачу бесполезной. Он делает provenance проверяемым. Будущий получатель видит, что not-collected означает отсутствие сбора внутри текущей модели, а не потерянный файл. Он может спросить, нужна ли вообще реальная проверка, кто имеет право открыть доступ к данным и какие claims допустимы. Автор draft не навязывает ответ через детали, которые нельзя проверить. Внутри сценария только один результат считается хорошим: вопрос назван достаточно честно, чтобы его можно было передать без production action.'), - figure('/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg', 'Петля synthetic evidence hand-off: дата, named workload и constraints проверяются до benchmark; при скрытой конфигурации или winner возникает stop, иначе question contract передаётся будущему владельцу.', 'Диаграмма описывает проверку fixed in-memory literals. Это не report о сервисе, profile, benchmark или принятый выбор runtime.'), - table('Контракт передачи synthetic evidence', ['Поле', 'Допустимое состояние сейчас', 'Что получает будущий owner', 'Чего поле не доказывает'], [ - ['Время', '2027-03 и cutoff 2026-07-31', 'границу редакционного знания', 'что мартовская работа сделана'], - ['Workload', 'named synthetic question', 'предмет для уточнения', 'существование сервиса или input'], - ['Constraints', 'named planned constraints', 'список развилок', 'приоритет или согласование'], - ['Benchmark', 'not-run / not-collected', 'запрет выдумывать config/result', 'измерение или profile'], - ['Conclusion', 'synthetic-plan-hand-off', 'право решить следующий scope', 'winner, rollout или gain'], +const calls = [ + { capacity: 16, declaredLength: 8, pointerChecked: true }, + { capacity: 16, declaredLength: 24, pointerChecked: true }, + { capacity: 16, declaredLength: 8, pointerChecked: false }, +]; + +for (const call of calls) console.log(checkSafeBoundary(call)); +// safe-interface; reject; system`), + p('Первый вход даёт диапазон внутри буфера. Второй останавливается до вызова: внешний контракт обещает 24 байта, а доступно 16. Третий не принимает решение за инженера, потому что адрес не прошёл проверку владельца. В D такой проверкой должен владеть маленький wrapper, а в тесте нужны граничные значения 0, capacity и capacity+1.'), + h2('Что означает @trusted'), + p('@trusted — не «проверено компилятором». Это обещание, что внешняя форма функции безопасна, хотя тело содержит операции, которые компилятор не может проверить. Поэтому у trusted-функции должны быть короткий исходник, явные preconditions и тесты на invalid length, null, пустой slice и повторный вызов. Не прячьте в ней преобразование формата, ownership и обработку ошибок одновременно.'), + p('Если внешняя C-функция возвращает указатель, проверка должна ответить на два вопроса: объект жив и его размер известен? При ответе «нет» безопасный интерфейс невозможен без копирования или дополнительного контракта. После вызова нельзя использовать старый slice, если C-функция освобождает память. Ошибка lifetime часто переживает тесты на успешном input, поэтому негативная матрица обязательна.'), + h2('Действия по порядку'), + ol([ + 'Прочитать C-прототип и зафиксировать смысл каждого указателя, длины, возвращаемого адреса и кода ошибки.', + 'Выделить минимальный wrapper; не переносить внутрь @trusted парсинг, бизнес-правила и сетевой код.', + 'Проверять указатель, диапазон, нулевую длину, overflow и время жизни до перехода в C.', + 'Поставить unit tests на валидные и граничные значения, затем прогнать sanitizers или инструменты платформы.', + 'Оставить публичную функцию @safe только при доказанном безопасном интерфейсе; остальную зону явно маркировать @system.', ]), - h2('Отсутствие evidence тоже нужно маркировать'), - p('Нельзя заменить отсутствие evidence нейтральным тоном. Фразы «на практике оказалось», «в сервисе видно» и «после повторного profile» звучат как обычная связка, но здесь они создают ложный факт. Даже если будущий reader легко вообразит такой путь, он не становится частью P109. У draft нет hidden fixture, приватной конфигурации, замера «для себя» или негласного решения. Если в 2027 году появится реальный материал, он должен жить в новом artefact с новой датой, scope, source set и ответственностью за данные.'), - p('То же касается слов о release. Официальный D 2.112.0 от 07 January 2026 находится до cutoff и может быть указан как pinned primary material; однако это не означает, что именно эта версия будет взята будущей командой. Любая более поздняя версия, даже если когда-нибудь станет известна, не принадлежит этому тексту. Полезная передача не пытается быть живой сводкой. Она хранит границу знаний так, чтобы позднее обновление не маскировалось под то, что якобы было известно заранее.'), - h2('Fail-closed оставляет выбор получателю'), - p('Если workload или constraint неназваны, получатель не знает, что сравнивать и какое несоответствие важно. Если benchmark configuration скрыта, получатель не может судить о методе, но может ошибочно довериться числу. Если conclusion объявляет winner, ему остаётся только спорить с результатом, которого нет. Поэтому эти случаи не получают default-значения и не продолжаются «с оговорками». Validator возвращает precise stop, сохраняя возможность переписать вопрос до того, как он станет ложной задачей в чужой очереди.'), - p('Fail-closed не говорит, что реальное исследование невозможно. Он говорит ровно обратное: для реального исследования нужен новый владелец evidence, явное право на данные и отдельная методика. У получателя остаётся несколько законных ответов: отклонить вопрос как неважный, запросить уточнение constraints, открыть discovery scope или согласовать transparent benchmark. Ни один из них не должен быть неявно выбран текстом. Это отличие hand-off от скрытого roadmap: hand-off передаёт неопределённость вместе с её границей.'), - h2('Runnable contract передачи'), - code("import { createFixedRuntimeCase, assessFixedRuntimePlan } from './upgrade-2027-03.mjs';\n\nconst rejected = createFixedRuntimeCase('disallowed-winner-v1');\nconst refusal = assessFixedRuntimePlan(rejected);\nif (refusal.productionEffect !== 'not-attempted') throw new Error('unexpected-effect');\nconsole.log(refusal.status);\n// stop-disallowed-winner-or-positive-result"), - p('Этот runnable example deliberately demonstrates a refusal, not a success claim. Все значения fixed and in-memory; JSON clone отделяет literal от caller, deep freeze закрепляет nested workload, constraints и benchmark. Evaluator не обращается к D compiler, runtime, shell, files, network, environment, telemetry или clock. Его output не может стать deploy decision: productionEffect остаётся not-attempted и на stop, и на hand-off. Такой маленький contract полезен, потому что его поведение можно повторить без поддельного сервиса.'), - h2('Порядок synthetic evidence hand-off'), - ol(['Проверить, что hand-off явно относится к плану 2027-03 и сохраняет cutoff 2026-07-31.', 'Передать только named synthetic workload; не добавлять service name, profile, data sample или наблюдение.', 'Передать named constraints как вопросы, не как уже согласованные SLA, ownership или delivery policy.', 'Оставить benchmark not-run и configuration/result not-collected; любой hidden value вернуть с stop.', 'Не называть D, другой runtime или конфигурацию победителем и не писать positive result.', 'Отдать future evidence owner boundary и nextAction; решение об authorised scope остаётся за ним.']), - h2('Что будущий owner должен проверить отдельно'), - p('Если owner возьмёт вопрос, ему придётся создать собственный contract: что именно является workload, какие inputs разрешены, какую информацию нельзя сохранять, какие версии и target проверяются, как устроен baseline и кто читает результат. Нужно отдельно решить, является ли выбранная операция representative, какие ошибки измерения приемлемы и какой исход остановит работу. Эти решения не переносятся из P109, потому что P109 их не принимал. В этом и смысл honest hand-off: не выдавать пустой шаблон за готовую методику.'), - p('Возможен и более короткий ответ: owner может решить, что сравнение не нужно, а проблема решается контрактом, кэшированием, разделением владения или изменением текущего пути. Такой итог не делает D «плохим» и не требует защитить альтернативу. Он лишь показывает, что конкретный runtime-question не получил достаточного основания. Хорошая field-передача допускает этот исход заранее; плохая превращает отсутствие запуска в давление «раз уж начали, надо доказать пользу». '), - h2('Какие слова нельзя передавать по инерции'), - p('Получателю стоит отдельно вычёркивать слова, которые создают несуществующий факт: «повторили», «подтвердили», «профиль показал», «сервис выдержал», «runtime выбран», «релиз вышел» и «победитель». В обычной заметке они могут быть точными, если рядом есть material и scope. В этом hand-off они меняют тип документа. Проблема не в уверенном тоне, а в том, что поздний reader уже не отличит заготовку от свидетельства и начнёт передавать эту подмену дальше.'), - p('Вместо этого допустимы более точные конструкции: «планируется назвать», «будущему owner требуется решить», «configuration не собрана», «результат не получен», «вариант не выбран». Они не звучат эффектно, зато оставляют следующее действие там, где оно действительно может быть принято. У future owner появляется свобода изменить весь вопрос, а не только подобрать параметр к заранее обещанному benchmark. Это особенно ценно в прикладной работе, где контекст меняется быстрее, чем редакционный план.'), - h2('Ограничения hand-off и следующий шаг'), - p('Этот план не создаёт ticket, queue, benchmark configuration, owner, approval или production change. Он не проверяет compatibility, безопасность, доступность, стоимость, vendor support, licenses или performance. Публикации D 2.110.0, 2.111.0 и 2.112.0 помогают точно назвать датированные release materials, но не делают их evidence для неназванной системы. Здесь нет даже claim, что D будет рассмотрен в 2027-03: есть лишь сценарий того, как не выдать ожидание за результат.'), - p('Следующий шаг — хранить this draft as a synthetic hand-off until a future owner explicitly authorises different work. Тогда новый artefact может содержать реальные данные и проверяемый вывод, но должен отличаться датой и provenance. До этого момента safest conclusion уже записан: question contract можно передать, production effect не пытались получить. Полевой урок для прикладного инженера не про красивый кейс; он про способность оставить следующему человеку честно ограниченное знание вместо убедительной, но вымышленной истории.'), -], refs); + h2('Ограничения и следующий шаг'), + p('Проверка capacity в JavaScript — учебная модель числовой границы, а не анализ D-памяти. Она не видит aliasing, alignment, calling convention, null termination или освобождение в C. Даже корректный @safe wrapper может передать семантически неверный enum или структуру. Нужен compile-time и runtime тест именно тем компилятором и ABI, с которыми собирается продукт.'), + p('Следующий шаг — выбрать один extern(C) вызов и оформить для него таблицу: pointer, length, ownership, error, thread-safety. Напишите маленький wrapper, который принимает D slice, и отдельно проведите review trusted-тела. Если один из пунктов не имеет ответа, вызов нельзя считать готовым к безопасной границе.'), +], [ + { key: 'memory', use: 'Определения @safe, @trusted, @system, scope и границы memory safety.', boundary: 'Спецификация не проверяет контракт внешней C-библиотеки и не гарантирует portability или отсутствие логических ошибок.' }, + { key: 'functions', use: 'Правила function attributes и contract expressions используются для precondition и postcondition.', boundary: 'Документация не создаёт unit tests и не решает lifetime конкретного объекта.' }, + { key: 'cInterface', use: 'Интерфейс D/C и отдельные соглашения вызова используются для описания wrapper boundary.', boundary: 'Страница не подтверждает прототип, ABI и ownership неизвестной библиотеки.' }, +]); + +const field = revision({ + slug: 'editorial-2027-03-field-d-lessons', + title: 'D и C ABI: разобрать пакет до вызова', + categories: ['D', 'Интеграции'], + cover: '/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg', + excerpt: 'Полевой разбор FFI-ошибки: размер структуры, порядок байтов и обязательные поля проверяются до вызова C.', + readingMinutes: 15, +}, [ + p('Проблема C ABI редко выглядит как ошибка на строке вызова. D-программа передаёт структуру, C читает её и возвращает код, но значение поля оказывается неверным или процесс падает только на одной архитектуре. Цена — непредсказуемый сбой на границе, где обычный unit test видит только один компилятор и одну раскладку памяти. Чем дольше ошибка живёт, тем труднее отличить формат данных от ошибки бизнес-логики.'), + p('Полевой разбор начинается с пакета, который реально пересекает ABI: byte length, endianness, поля, alignment и calling convention. Дальше нужно сопоставить его с C header и настройками компилятора. Нельзя проверять только имя struct. Два типа с одинаковыми полями могут иметь разный padding, порядок байтов или размер указателя. Пакет — это физический контракт, а не только исходный текст.'), + h2('Размер структуры — первый стоп'), + p('ABI определяет, как типы и функции представлены для взаимодействия с машинным кодом. Для структуры важны не только поля, но и выравнивание. Добавленный int может изменить offsets следующего поля; на 32- и 64-битной платформе размер указателя различается. Если D и C собраны с разными ожиданиями, чтение смещается, а ошибка проявится как «неверное значение» далеко от причины.'), + p('Порядок байтов — отдельная ось. Файл может быть little-endian, а внешний протокол — big-endian; автоматическое копирование структуры не является преобразованием формата. Для числового поля запишите wire representation и проверяйте её на известном значении вроде 0x01020304. Так видно, поменялись байты или перепутана длина пакета.'), + figure('/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg', 'Цикл проверки D и C ABI: пакет, размер, поля и порядок байтов проходят сопоставление с контрактом до вызова функции.', 'Схема отделяет физическую проверку пакета от самого вызова. Красная ветка возвращает данные на границу, если размер или поле не совпали.'), + table('Проверка ABI-пакета до FFI-вызова', ['Проверка', 'Пример входа', 'Ожидаемое действие', 'Если пропустить'], [ + ['byte length', '24 байта', 'сверить sizeof на обеих сторонах', 'смещение полей'], + ['endianness', 'little-endian', 'декодировать число явно', 'неверный id или размер'], + ['field set', 'version, flags, payload', 'проверить обязательные поля', 'чтение мусора'], + ['alignment', 'offset 8 вместо 4', 'сверить compiler layout', 'сбой на другой архитектуре'], + ['error code', '0 или отрицательное значение', 'перевести в D-ошибку', 'успех при частичном чтении'], + ]), + h2('Учебный валидатор пакета'), + p('Локальная функция ниже принимает пакет и описание ожидаемой структуры. Она проверяет три вещи, которые можно увидеть ещё до вызова: размер, порядок байтов и набор полей. Числа и названия в примере учебные, но сам порядок повторяет рабочую проверку. В реальном проекте contract строится из header и результатов компилятора, а не из догадки автора wrapper.'), + code(`import { validateCAbiPacket } from './upgrade-2027-03.mjs'; + +const contract = { + byteLength: 12, + endianness: 'little', + fields: [{ name: 'version' }, { name: 'flags' }, { name: 'payload' }], +}; +const packet = { byteLength: 12, endianness: 'little', version: 2, flags: 1, payload: 4096 }; +const broken = { byteLength: 16, endianness: 'big', version: 2, flags: 1 }; + +console.log(validateCAbiPacket(packet, contract)); +console.log(validateCAbiPacket(broken, contract)); +// { accepted: true, errors: [] } +// { accepted: false, errors: ['размер структуры', 'порядок байтов', 'поле payload'] }`), + p('Валидатор не вызывает C и поэтому не доказывает, что ABI корректен. Он делает видимыми три несовпадения до опасной операции. Для полноценного теста добавьте golden bytes, сборку маленького C helper и проверку результата на каждой целевой архитектуре. Смысл локального примера в том, что ошибка в contract table должна быть заметна раньше падения процесса.'), + h2('Где заканчивается автоматическая проверка'), + p('Размер и поля можно сравнить автоматически, но ownership и смысл flags требуют чтения C API. Поле payload может быть указателем, длиной или offset внутри того же пакета. Значение 0 может означать пусто, success или null. Не называйте результат «валидным пакетом», пока не проверены эти семантические значения и код ошибки.'), + p('Особенно опасен частичный успех. C-функция могла записать структуру, но вернуть ошибку; D-код видит заполненное поле и продолжает обработку. Обёртка должна сначала проверить код возврата, затем интерпретировать output по версии и только потом отдавать его доменному коду. Версия пакета должна быть частью ключа выбора декодера, а не обычным полем, которое можно проигнорировать.'), + h2('Действия по порядку'), + ol([ + 'Сохранить точный C header, compiler flags, target architecture и calling convention рядом с исходником wrapper.', + 'Составить layout table с размером, offset, alignment, типом и смыслом каждого поля.', + 'Проверить golden bytes для little/big-endian и граничных значений длины до вызова внешней функции.', + 'Разделить код возврата, output и ошибку; не считать частично заполненную структуру успешным результатом.', + 'Собрать тест на каждой поддерживаемой архитектуре и сохранить hex-пакет, версию контракта и итог проверки.', + ]), + h2('Ограничения и следующий шаг'), + p('JavaScript-валидатор не моделирует padding, pointer alignment, compiler lowering и реальные байты. Он проверяет форму contract table, поэтому не заменяет C helper, D compiler и тест на целевом ABI. Документ D описывает правила языка, но конкретный vendor header может добавлять свои packing directives, версии и ownership соглашения.'), + p('Следующий шаг — выбрать один extern(C) вызов, получить маленький golden packet и сравнить layout D/C в автоматической проверке. В отчёте оставьте hex, размер, архитектуру и код возврата. Это позволит отличить изменение компилятора от изменения данных и быстро вернуть ошибку к физической границе.'), +], [ + { key: 'abi', use: 'Правила D ABI используются для объяснения layout, alignment и представления типов.', boundary: 'Спецификация не знает vendor header, compiler flags и архитектуру конкретного проекта.' }, + { key: 'cInterface', use: 'Правила extern(C) и взаимодействия с C используются для выбора contract table и calling convention.', boundary: 'Документация не гарантирует корректность неизвестного прототипа или ownership.' }, + { key: 'memory', use: 'Граница memory safety используется для отделения безопасного анализа пакета от raw pointer операций.', boundary: 'Проверка памяти не подтверждает семантику полей и код возврата внешней библиотеки.' }, +]); + +export const revisions = Object.freeze([practice, mechanism, field]); + +export function verifyRevisionsAgainstFixture() { + const checks = revisions.map((item) => { + const body = bodyText(item.contentHtml); + return body.length >= 5000 && body.length <= 15000 && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body); + }); + const workload = validateDWorkload({ throughput: 12000, latencyBudgetMs: 20, nativeBoundary: true, deploymentTargets: 1 }); + const abi = validateCAbiPacket({ byteLength: 4, endianness: 'little', version: 1 }, { byteLength: 4, endianness: 'little', fields: [{ name: 'version' }] }); + const fixtureOk = workload.decision === 'consider-d' && abi.accepted; + return Object.freeze({ passed: checks.filter(Boolean).length + (fixtureOk ? 1 : 0), total: checks.length + 1, accepted: checks.every(Boolean) && fixtureOk, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); +} + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} -export const revisions = deepFreeze([practice, mechanism, field]); -export function verifyRevisionsAgainstFixture() { const fixture = runFixedRuntimeFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|потер)/i.test(text.slice(0, 1000)) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-03/.test(text) && /2026-07-31/.test(text); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-04.mjs b/web/scripts/upgrade-2027-04.mjs index 777e500..1e972a9 100644 --- a/web/scripts/upgrade-2027-04.mjs +++ b/web/scripts/upgrade-2027-04.mjs @@ -1,206 +1,278 @@ -function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +import { createHash } from 'node:crypto'; + +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 figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); +} -const REFERENCES = deepFreeze({ - webpackCaching: { title: 'webpack — Caching guide (immutable source snapshot)', url: 'https://github.com/webpack/webpack.js.org/blob/b7fa07c852d24449e9445717c41395bdd2ca4784/src/content/guides/caching.mdx', version: 'webpack.js.org commit b7fa07c852d24449e9445717c41395bdd2ca4784, resolved at source cutoff 2026-07-31' }, - githubCache: { title: 'actions/cache v4.2.3 — immutable README', url: 'https://github.com/actions/cache/blob/5a3ec84eff668545956fd18022155c47e93e2684/README.md', version: 'actions/cache v4.2.3 commit 5a3ec84eff668545956fd18022155c47e93e2684, pinned before source cutoff 2026-07-31' }, - slsa: { title: 'SLSA v1.0 — Provenance', url: 'https://slsa.dev/spec/v1.0/provenance', version: 'SLSA specification v1.0, pinned before source cutoff 2026-07-31' }, -}); -function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } +function bodyText(html) { + return plainText(html.replace(/

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

|$)/, '')); +} -const FIXED_BUILD_CASES = deepFreeze({ - 'configuration-hand-off-v1': { - id: 'configuration-hand-off-v1', planDate: '2027-04', sourceCutoff: '2026-07-31', - leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, - rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' }, - comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' }, - historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' }, - requestedConclusion: 'synthetic-plan-hand-off', - boundary: 'Fixed in-memory planning literal. No repository, configuration, CI artifact, build, cache, bundle, timing, migration, release, tool, environment, clock, network, secret, telemetry, customer data or production system is read, created, changed or inferred.', +const REFERENCES = Object.freeze({ + webpackCache: { + title: 'webpack 5 Guide: Caching', + url: 'https://webpack.js.org/guides/caching/', + version: 'webpack 5 guide, checked 31 July 2026', }, - 'undated-future-scenario-v1': { - id: 'undated-future-scenario-v1', planDate: '', sourceCutoff: '2026-07-31', - leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' }, comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.', + webpackConfig: { + title: 'webpack 5 Configuration: cache', + url: 'https://webpack.js.org/configuration/cache/', + version: 'webpack 5 configuration reference, checked 31 July 2026', }, - 'missing-comparable-condition-v1': { - id: 'missing-comparable-condition-v1', planDate: '2027-04', sourceCutoff: '2026-07-31', - leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' }, comparableCondition: { id: '', state: 'missing' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.', + esbuild: { + title: 'esbuild API: Metafile', + url: 'https://esbuild.github.io/api/#metafile', + version: 'esbuild API documentation, checked 31 July 2026', }, - 'hidden-configuration-or-cache-assumption-v1': { - id: 'hidden-configuration-or-cache-assumption-v1', planDate: '2027-04', sourceCutoff: '2026-07-31', - leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'hidden', state: 'hidden-assumption' }, comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'assumed', bundleSize: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.', + vite: { + title: 'Vite Guide: Dependency Pre-Bundling', + url: 'https://vite.dev/guide/dep-pre-bundling.html', + version: 'Vite guide, checked 31 July 2026', }, - 'disallowed-positive-result-v1': { - id: 'disallowed-positive-result-v1', planDate: '2027-04', sourceCutoff: '2026-07-31', - leftConfiguration: { id: 'named-synthetic-baseline-configuration', state: 'named-synthetic' }, rightConfiguration: { id: 'named-synthetic-candidate-configuration', state: 'named-synthetic' }, comparableCondition: { id: 'named-synthetic-comparable-condition', state: 'not-collected' }, historicalArtifacts: { config: 'not-collected', ci: 'not-collected', buildTiming: 'not-collected', cacheHit: 'not-collected', bundleSize: 'not-collected' }, requestedConclusion: 'build-improved', boundary: 'Negative fixed literal only.', + sourceMap: { + title: 'MDN: SourceMap HTTP header', + url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/SourceMap', + version: 'MDN Web Docs, page modified 21 November 2025', }, }); -export function createFixedBuildCase(id = 'configuration-hand-off-v1') { const value = FIXED_BUILD_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } -export function assessFixedBuildPlan(input) { - if (!Object.values(FIXED_BUILD_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case'); - if (input.planDate !== '2027-04' || input.sourceCutoff !== '2026-07-31') return stop('stop-undated-future-scenario-or-cutoff', 'april-2027-plan-and-july-2026-cutoff-are-required', 'name-2027-04-and-2026-07-31'); - if (!input.leftConfiguration?.id || input.leftConfiguration.state !== 'named-synthetic' || !input.rightConfiguration?.id || input.rightConfiguration.state !== 'named-synthetic') return stop('stop-hidden-configuration-or-cache-assumption', 'each-side-must-be-a-named-synthetic-configuration', 'name-both-configuration-placeholders-without-claiming-real-config'); - if (!input.comparableCondition?.id || input.comparableCondition.state !== 'not-collected') return stop('stop-missing-comparable-condition', 'a-named-comparable-condition-must-remain-not-collected', 'name-the-future-comparison-condition'); - const artifacts = input.historicalArtifacts; - if (!artifacts || !['config', 'ci', 'buildTiming', 'cacheHit', 'bundleSize'].every((key) => artifacts[key] === 'not-collected')) return stop('stop-hidden-configuration-or-cache-assumption', 'historical-artifacts-and-cache-assumptions-must-remain-not-collected', 'do-not-infer-config-ci-cache-or-result'); - if (input.requestedConclusion !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'future-plan-cannot-claim-a-better-build-or-migration-result', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, leftConfiguration: deepFreeze(cloneFixed(input.leftConfiguration)), rightConfiguration: deepFreeze(cloneFixed(input.rightConfiguration)), comparableCondition: deepFreeze(cloneFixed(input.comparableCondition)), historicalArtifacts: deepFreeze(cloneFixed(artifacts)), productionEffect: 'not-attempted', nextAction: 'give-the-named-synthetic-comparison-question-to-a-future-evidence-owner' }); -} -export function runFixedBuildFixture() { - const expected = [['configuration-hand-off-v1', 'synthetic-plan-hand-off'], ['undated-future-scenario-v1', 'stop-undated-future-scenario-or-cutoff'], ['missing-comparable-condition-v1', 'stop-missing-comparable-condition'], ['hidden-configuration-or-cache-assumption-v1', 'stop-hidden-configuration-or-cache-assumption'], ['disallowed-positive-result-v1', 'stop-disallowed-positive-result']]; - const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessFixedBuildPlan(createFixedBuildCase(id)).status })); - const sample = createFixedBuildCase(); - return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.historicalArtifacts), checks: deepFreeze(checks) }); +function sources(entries) { + return '
    ' + entries.map(({ key, use, boundary }) => { + const reference = REFERENCES[key]; + return '
  • ' + escapeHtml(reference.title) + ' — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; + }).join('') + '
'; } -function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } -const refs = [ - { key: 'webpackCaching', use: 'Официальный материал задаёт словарь output, content hash и cache как свойств конфигурации.', boundary: 'Не является данными о данном приложении, CI, артефакте, времени или размере.' }, - { key: 'githubCache', use: 'Официальный материал нужен только для различения cache key, restore и save как частей будущего условия.', boundary: 'Не подтверждает наличие workflow, cache hit, runner или результат запуска.' }, - { key: 'slsa', use: 'Спецификация помогает отделить вопрос о provenance артефакта от самого артефакта.', boundary: 'Не утверждает, что provenance создана, проверена или применена здесь.' }, -]; +export function compareBuildRuns(input) { + const baseline = input?.baseline; + const candidate = input?.candidate; + if (!baseline || !candidate || baseline.inputFingerprint !== candidate.inputFingerprint) return { comparable: false, reason: 'входы сборки различаются' }; + const deltaMs = candidate.durationMs - baseline.durationMs; + const deltaBytes = candidate.outputBytes - baseline.outputBytes; + return { comparable: true, deltaMs, deltaBytes, faster: deltaMs < 0, smaller: deltaBytes < 0 }; +} -const practice = revision({ slug: 'editorial-2027-04-practice-build-evolution', title: 'Эволюция frontend-сборки: сначала сравнить конфигурации', categories: ['Frontend', 'Инженерные практики'], cover: '/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', excerpt: 'План на апрель 2027: как ставить вопрос о frontend-сборке без легенды о более быстром новом инструменте.', readingMinutes: 22 }, [ - p('Это план/сценарий на 2027-04 с source cutoff 2026-07-31, не отчёт о сделанной миграции frontend-сборки. Дорогая ошибка начинается с удобной фразы: «новая сборка лучше старой». В неё одним словом складывают другой тип приложения, другой вход, другую оптимизацию, иное разбиение output и новый способ доставки. Цена не в терминологии: команда тратит недели на перенос, а потом не может сказать, что именно стало объектом сравнения и где возникла разница.'), - p('Вторая стоимость появляется, когда будущую CI-картину дорисовывают задним числом: якобы был cache hit, артефакт оказался меньше, job закончилась быстрее или переход уже завершён. На редакторскую дату апрель 2027 ещё впереди; у P110 нет config, CI artifact, build timing, cache hit, bundle size, migration, tool release или результата. Единственный разрешённый положительный выход — synthetic-plan-hand-off с productionEffect: not-attempted. Остальное в этом тексте — вопрос, а не наблюдение.'), - h2('Сравнивать не ярлыки, а действующие условия'), - p('Название сборщика слишком грубо для инженерного решения. Оно не показывает, какие entry points существуют, какие transforms включены, куда попадает CSS, какие внешние зависимости считаются частью output и какую цель преследует сборка. Два процесса с одинаковой надписью могут различаться сильнее, чем два процесса с разными названиями. Поэтому практический старт — назвать левую и правую configuration как synthetic placeholders, а не объявить один инструмент «устаревшим».'), - p('В этом материале named-synthetic-baseline-configuration и named-synthetic-candidate-configuration не являются файлами, package manifest или fragment реального pipeline. Это фиксированные in-memory literals, запрещающие подмену вопроса правдоподобными деталями. Они помогают сформулировать будущему owner задачу: восстановить provenance каждого условия, если для этого появится отдельный scope. Пока доказательств нет, даже слово «baseline» здесь не означает существующую сборку.'), - figure('/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', 'Русская временная схема: от именования двух synthetic конфигураций через условия сравнения к future evidence hand-off; красные остановки запрещают недатированный сценарий и вывод о преимуществе.', 'Схема описывает плановый порядок вопросов на 2027-04. Она не содержит конфигурации проекта, CI-лога, артефакта или измерения.'), - table('Что требуется сопоставить до разговора о замене', ['Плоскость', 'Допустимое имя в плане', 'Чего это не доказывает', 'Будущий вопрос'], [ - ['Вход', 'named synthetic application shape', 'что приложения одинаковы', 'какой route и input сравниваем'], - ['Configuration', 'baseline / candidate placeholder', 'что известны flags и plugins', 'какие параметры имели provenance'], - ['Output', 'artifact question', 'размер или состав файла', 'какой manifest и способ подсчёта нужен'], - ['CI', 'comparable condition question', 'runner, очередь или timing', 'какая среда считается одинаковой'], - ['Итог', 'synthetic-plan-hand-off', 'улучшение или завершение миграции', 'нужен ли authorised evidence scope'], +export function makeDependencyCacheKey(input) { + const payload = [input?.lockfile, input?.config, input?.runtime, input?.sourceDigest].map((value) => String(value ?? '')).join('\n'); + return createHash('sha256').update(payload).digest('hex').slice(0, 16); +} + +export function summarizeBundleDiff(before, after) { + const names = new Set([...Object.keys(before?.inputs ?? {}), ...Object.keys(after?.inputs ?? {})]); + return [...names].map((name) => { + const previous = Number(before?.inputs?.[name]?.bytes ?? 0); + const current = Number(after?.inputs?.[name]?.bytes ?? 0); + return { name, before: previous, after: current, delta: current - previous }; + }).filter((item) => item.delta !== 0).sort((left, right) => right.delta - left.delta); +} + +function revision(meta, parts, referenceEntries) { + const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); + return Object.freeze({ ...meta, contentHtml, proseLength }); +} + +const practice = revision({ + slug: 'editorial-2027-04-practice-build-evolution', + title: 'Сравнение frontend-сборок: зафиксировать вход до секунд', + categories: ['Frontend', 'Сборка'], + cover: '/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', + excerpt: 'Как сравнить baseline и candidate так, чтобы разница времени и размера относилась к одному входу.', + readingMinutes: 15, +}, [ + p('Проблема сравнения frontend-сборок обычно начинается с красивой цифры: новый инструмент собрал проект быстрее на локальной машине. Но у baseline мог быть холодный cache, другой lockfile, другой набор entry-файлов или неполный output. Цена неверного сравнения — перенос нескольких дней работы ради ускорения, которое исчезает при первом чистом запуске. Сначала фиксируем вход, потом измеряем секунды.'), + p('У сборки есть как минимум пять независимых частей: исходный граф, конфигурация, окружение, состояние cache и состав артефакта. Изменение любой части меняет смысл результата. Поэтому один замер duration не отвечает на вопрос «какой сборщик лучше». Он отвечает только на вопрос «сколько занял этот запуск при таких условиях». Таблица условий должна быть частью отчёта рядом с числом.'), + h2('Сравниваем не инструменты, а одинаковую работу'), + p('Baseline и candidate должны получить один commit, одинаковый lockfile, одинаковые entry и одинаковую команду выхода. Если одна сборка делает minify, а другая — development output, секунды нельзя складывать в общий вывод. Точно так же размер файла сравним только при одинаковом составе chunks, compression и правилах публикации. Иначе оптимизация одного слоя будет выглядеть как преимущество всего pipeline.'), + p('В webpack contenthash и deterministic module ids помогают сохранить стабильные имена и локализовать изменение, но это не делает два разных toolchain эквивалентными. В esbuild metafile можно посмотреть, какие input вошли в output и сколько байт они заняли. Оба документа подсказывают важный порядок: записать конфигурацию и состав результата, а не оставлять только итоговую цифру.'), + figure('/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', 'Последовательность сравнения frontend-сборки: один вход, одинаковая конфигурация, отдельный cache state, замер и состав артефакта.', 'Схема показывает, что секундомер включается после фиксации условий. Изменение входа возвращает сравнение к началу.'), + table('Минимальная карточка сравнения сборки', ['Слой', 'Зафиксировать', 'Пример различия', 'Почему это важно'], [ + ['Input', 'commit, lockfile, entry', 'feature branch вместо tag', 'меняется dependency graph'], + ['Config', 'mode, flags, plugins', 'minify включён только у candidate', 'числа относятся к разным работам'], + ['Environment', 'Node, OS, CPU', 'разные версии Node', 'разный runtime и cache'], + ['Cache', 'cold/warm, directory, key', 'candidate повторный', 'ускорение может быть cache effect'], + ['Output', 'chunks, source maps, compression', 'один bundle не учтён', 'размер и delivery несопоставимы'], + ]), + h2('Учебное сравнение двух запусков'), + p('Функция ниже получает два уже записанных запуска. Она сначала проверяет inputFingerprint, затем считает разницу времени и размера. Значения учебного набора не являются результатом измерения проекта; они показывают формат карточки и ожидаемый результат. Если fingerprint различается, функция возвращает несопоставимость и не вычисляет победителя.'), + code(`import { compareBuildRuns } from './upgrade-2027-04.mjs'; + +const baseline = { inputFingerprint: 'src-42', durationMs: 420, outputBytes: 180000 }; +const candidate = { inputFingerprint: 'src-42', durationMs: 380, outputBytes: 176000 }; +const changedInput = { inputFingerprint: 'src-43', durationMs: 350, outputBytes: 174000 }; + +console.log(compareBuildRuns({ baseline, candidate })); +console.log(compareBuildRuns({ baseline, candidate: changedInput })); +// comparable: true, deltaMs: -40, deltaBytes: -4000 +// comparable: false, reason: 'входы сборки различаются'`), + p('Первый результат говорит только о заданном учебном наборе: candidate быстрее на 40 мс и меньше на 4000 байт при одинаковом fingerprint. Второй запуск не превращается в преимущество, даже если его цифры лучше, потому что input другой. В реальном отчёте добавьте число повторов, медиану или p95, cold/warm state и полный список команд.'), + h2('Секунды требуют распределения, а не одного числа'), + p('Один запуск может попасть на сборку cache, фоновое обновление пакетов или шум CPU. Для локального сравнения отделяйте холодный и тёплый запуск, а в CI фиксируйте runner. Не смешивайте время установки зависимостей с временем bundling, если цель — сравнить именно сборку. Если пользовательский эффект — время до первого экрана, добавьте отдельный браузерный замер; build duration является только одной частью пути.'), + p('Размер также нужно разложить. Общий bundle вырос — это симптом, но причина может быть в новой библиотеке, переставшем работать tree-shaking, source map или duplicated dependency. Метafile и список chunks дают материал для следующего шага. Без него команда спорит о сборщике, хотя изменение находится в одном input-модуле.'), + h2('Действия по порядку'), + ol([ + 'Зафиксировать commit, lockfile, entry points, mode, flags, Node/OS и runner.', + 'Очистить или явно сохранить cache state; отдельно записать cold и warm серии.', + 'Запустить baseline и candidate на одинаковом наборе и сохранить raw output, duration и exit code.', + 'Сравнить fingerprint, распределение времени, chunks и input bytes; несовпоставимый запуск исключить.', + 'Сформулировать вывод с единицами и границей: «на этом наборе и runner»; затем проверить пользовательский путь отдельным замером.', ]), - h2('Configuration — это контракт, а не фон'), - p('Практическая карта должна отделять намерение от механизма. Намерение можно назвать без факта: например, будущая сторона сравнения должна отвечать на один и тот же вопрос о доставляемом клиентском output. Механизм требует списка условий: source revision, dependency resolution, target environment, transform chain, optimisation policy, asset treatment и правила очистки output. Пока ни одно из этих значений не собрано, их нельзя заменить общими словами «обычная production-сборка».'), - p('Это ограничение защищает и от симметричной ошибки. Не следует защищать прошлый путь только потому, что у него привычный config. Привычность не свидетельствует о неизменности входов, качества кэша или пригодности артефакта. Сравнение начинается с симметричного пробела знаний: обе стороны получают явные placeholders, а доказательства для обеих должны иметь одинаково читаемую provenance. Иначе новая сторона обязана раскрывать условия, а старая остаётся легендой.'), - h2('Почему cache нельзя считать результатом'), - p('Кэш может участвовать в будущем условии, но не должен становиться украшением текста. Cache key, restore source, момент сохранения, доступность workspace и холодное состояние способны менять ход job; из этого не следует ни конкретная семантика CI, ни будущий cache hit. Даже если инструментальная документация описывает caching, она описывает возможности и ограничения механизма, а не событие в чужом workflow. В P110 поле cacheHit оставлено not-collected.'), - p('Тот же принцип работает для artifact. Bundle size — это не свойство ярлыка и не обязательный признак прогресса. Нужно заранее согласовать, что считается output, как обходятся source map, сжатие, generated assets, code splitting и служебные файлы. Нельзя называть число, пока не определены единица, состав и момент сбора. Меньшее значение без договора способно скрыть вынесенный ресурс, изменённый путь или просто другую границу подсчёта.'), - h2('Runnable пример проверяет границу утверждения'), - code("import { createFixedBuildCase, assessFixedBuildPlan } from './upgrade-2027-04.mjs';\n\nconst question = createFixedBuildCase('configuration-hand-off-v1');\nconst handoff = assessFixedBuildPlan(question);\nconst planOnly = handoff.status === 'synthetic-plan-hand-off'\n && handoff.productionEffect === 'not-attempted';\nconsole.log(planOnly ? 'question-contract' : 'reject');\n// question-contract"), - p('Пример буквально запускается только над named fixed in-memory literal. Factory сначала делает JSON clone, затем рекурсивный deep freeze; caller не получает общую изменяемую ссылку. Evaluator принимает лишь JSON-равный известный literal и fail-closed отклоняет произвольный object. Код не читает конфигурацию, не вызывает CI, не создаёт артефакт, не запускает build и не смотрит часы, environment, сеть, secrets или telemetry. Поэтому его output — контроль честности hand-off, не synthetic benchmark.'), - h2('Как читать старую и новую сторону без ретроспективы'), - p('Когда будущая работа всё-таки получит доступ к материалам, полезно не превращать их в один огромный diff. Сначала читают договорённость о входе: откуда берётся исходный код, какие generated parts считаются входом, какая зависимость фиксируется и что не входит в вопрос. Потом отдельно смотрят преобразования и output contract. Такое разбиение не гарантирует, что сравнение возможно, зато быстро показывает, где одна из сторон описана лишь частично. До появления материалов это не инструкция на конкретные команды, а порядок будущего чтения.'), - p('Слово «новая» тоже нуждается в разборе. Оно может означать другой toolchain, но также другой layout проекта, новый способ разделять код, иной формат статических файлов или иной контракт с платформой доставки. Если эти изменения свернуть в один коммит и один вывод, невозможно определить, что именно следует откатывать при проблеме. Плановая карточка поэтому не требует доказать превосходство candidate. Она требует назвать, какие независимые изменения вообще могут существовать и какие из них нельзя скрывать за общим названием migration.'), - h2('Одинаковый вопрос не обязан иметь одинаковый ответ'), - p('Comparable condition не пытается стереть реальность двух подходов. Условия могут позволять разную структуру output, если именно эта разница входит в заранее названный вопрос. Недопустимо другое: одной стороне разрешают исключить ресурс из подсчёта, а другой нет; одной стороне дают подготовленное состояние, а для другой измеряют первое получение зависимостей. В таких случаях корректный conclusion не «кто-то проиграл», а «собранные записи не отвечают на один и тот же вопрос». Это ценный отрицательный результат будущего evidence scope.'), - p('Нужна также граница между сравнением для пользователя и сравнением для разработчика. Стабильность хеша, размер доставляемого набора, длительность локального rebuild и длительность CI job могут быть отдельными вопросами с разными input и ценой. Склеить их в слово «быстрее» — значит дать метрике чужого уровня решать архитектуру. P110 не выбирает, какой из вопросов важнее. Он требует, чтобы future owner не использовал сигнал одного уровня как доказательство результата на другом.'), - h2('Честная запись о том, чего нет'), - p('Иногда команда опасается, что поле not-collected выглядит как незавершённость. В плановом документе это наоборот полезная информация: она сообщает, что автор видел необходимость evidence, но не получил права или материала его собрать. Такое поле делает hand-off проверяемым: поздний читатель видит, что отсутствует именно config, CI artifact или cache observation, а не всё знание о задаче. Вымышленный ноль, примерный процент или безымянный «успех» были бы хуже, потому что создавали бы ложный объект для обсуждения.'), - p('Порог для перехода к данным следует назвать заранее: новый scope должен позволять читать конкретные inputs, сохранять их provenance и обсуждать последствия их публикации. Без этого даже аккуратная команда рискует вынести служебные детали в редакционную статью или, наоборот, заменить их обобщением, которое больше нельзя проверить. В текущем plan такого порога нет. Он заканчивается не началом работы с системой, а точной причиной, по которой следующему владельцу потребуется отдельное решение.'), - h2('Порядок действий для будущего владельца'), - ol(['Сохранить пару planDate: 2027-04 и sourceCutoff: 2026-07-31; недатированный future scenario остановить.', 'Назвать две стороны как placeholders и не подставлять реальный config без отдельного разрешения.', 'Определить один named comparable condition: input, границу output и среду должны можно было прочитать у обеих сторон.', 'Запросить provenance historical config и CI artifact; до получения оставить все поля not-collected.', 'Отдельно решить, нужен ли build-запуск; если нужен, документировать методику до запуска, а не после числа.', 'Передать question contract future evidence owner без winner, migration claim и production effect.']), - h2('Четыре стоп-сигнала в практической карточке'), - p('Практическая карточка должна быть способна назвать свою причину остановки. undated-future-scenario-v1 не даёт спрятать обещание за неопределённым «потом». missing-comparable-condition-v1 не позволяет выдать два похожих запуска за одну проверку. hidden-configuration-or-cache-assumption-v1 не заменяет неизвестный config или cache удобным default. Наконец, disallowed-positive-result-v1 закрывает «сборка улучшена», пока нет отдельного evidence record. Эти labels не описывают инцидент; они делают видимой форму ошибки в плановом документе.'), h2('Ограничения и следующий шаг'), - p('Этот practice не говорит, какой bundler выбрать, как переписать config, какую версию выпустить или какие шаги CI использовать. Он не оценивает совместимость, безопасность, лицензии, доступность plugin, стоимость runner и риск миграции. Официальные источники внизу дают язык для разговора о caching и provenance, но не поставляют ни одного local artifact. Их нельзя использовать как замену историческому config или записи запуска.'), - p('Следующий шаг — не «начать миграцию», а решить, есть ли полномочия собрать доказательства для названного condition. Если такие полномочия появятся, новый материал обязан явно отделить реальные inputs, метод и результат от этого апрельского плана. Если нет, корректно оставить hand-off незакрытым. Это менее эффектно, чем обещание эволюции, но дешевле, чем сделать архитектурное решение на основании названия инструмента.'), -], refs); + p('Учебная функция не запускает сборщики и не устраняет шум измерений. Fingerprint должен включать все входы, которые влияют на output; короткий hash без методики легко создаёт ложное совпадение. Разные плагины могут менять дерево зависимостей, а одинаковый размер не означает одинаковое выполнение в браузере. Нельзя обещать эффект для пользователей по двум локальным числам.'), + p('Следующий шаг — сделать небольшой JSON-отчёт с условиями, серией запусков и ссылкой на metafile. Проверьте его на одном изменении, которое добавляет известный модуль. Готовность сравнения определяется тем, что другой инженер может повторить вход, увидеть различие и понять, к какому слою относится изменение.'), +], [ + { key: 'webpackCache', use: 'Contenthash, deterministic module ids и условия сравнения взяты из руководства webpack 5.', boundary: 'Документация не даёт результатов конкретного проекта и не заменяет серию измерений на одном runner.' }, + { key: 'esbuild', use: 'Metafile используется как JSON-источник inputs, outputs и bytesInOutput для разбора артефакта.', boundary: 'Формат метафайла описывает состав сборки, но не пользовательскую скорость загрузки.' }, + { key: 'webpackConfig', use: 'Режимы memory/filesystem cache учитываются как отдельная часть условий запуска.', boundary: 'Страница не определяет cache state конкретного CI и не сравнивает инструменты между собой.' }, +]); -const mechanism = revision({ slug: 'editorial-2027-04-mechanism-build-evolution', title: 'Эволюция frontend-сборки: сравнимость кэша, CI и артефакта', categories: ['Frontend', 'Сборка'], cover: '/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', excerpt: 'План на апрель 2027: механизм честного сравнения конфигураций без выдуманного времени сборки и cache hit.', readingMinutes: 23 }, [ - p('План/сценарий на 2027-04 с source cutoff 2026-07-31 разбирает механизм сравнимости, а не удачную оптимизацию. Конкретная проблема: два build запускают с разными входами и называют разницу временем сборки. У одного есть предварительно подготовленный workspace, у другого меняется dependency graph; один публикует часть output, другой считает полный каталог. Цена такого сравнения — ложная причинность: следующий change закрепляет случайное преимущество и усложняет откат.'), - p('Вторая проблема — неразличённые уровни evidence. Config, CI trace, cache state, artifact manifest и итоговый вывод выглядят как один отчёт, хотя отвечают на разные вопросы. В будущем апреле 2027 они не собраны: нет run, CI artifact, timing, cache hit, bundle size, migration, release или production outcome. Позитивный результат здесь запрещён; evaluator может вернуть только synthetic-plan-hand-off, а productionEffect остаётся not-attempted.'), - h2('Сравнимость сначала строят, затем измеряют'), - p('Сравнимость — не обещание равных чисел и не настройка одного инструмента. Это явное правило, по которому future owner сможет решить, относятся ли две записи к одному вопросу. В минимальном виде правило связывает форму входа, границу config, способ получения зависимостей, состояние cache, runner context, состав output и метод фиксации artefact. Отсутствие одного звена не делает результат «приблизительно честным»: оно меняет смысл inference.'), - p('Особенно опасно оставлять скрытыми параметры, которые кажутся техническим шумом. Смена target, optimisation mode, lockfile, source map policy или restore key может изменить ход обработки раньше, чем появится финальный файл. Перечисление не означает, что такие значения существуют в P110; это список классов вопросов для будущей методики. Именно поэтому fixed literal не содержит правдоподобных flags. Он хранит только named placeholders и not-collected.'), - figure('/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', 'Русская матрица сопоставляет вход, configuration, cache, CI и artifact с требуемой future provenance; красная строка останавливает скрытое условие и положительный вывод.', 'Матрица показывает логическую полноту будущего сравнения. В ячейках нет реальных значений, timing, cache hit или размера bundle.'), - table('Матрица inference без измеренного результата', ['Слой', 'Что можно назвать сейчас', 'Какое evidence потребуется потом', 'Какой вывод пока запрещён'], [ - ['Input', 'synthetic application shape', 'зафиксированная форма входа', 'что приложения равны'], - ['Configuration', 'два named placeholders', 'полная provenance параметров', 'что замена эквивалентна'], - ['Cache', 'state not-collected', 'ключ, источник, холодный/тёплый режим', 'что был hit или выигрыш'], - ['CI', 'comparable condition question', 'runner и порядок шагов', 'что timing сравним'], - ['Artifact', 'artifact question', 'manifest и метод подсчёта', 'что bundle меньше или лучше'], +const mechanism = revision({ + slug: 'editorial-2027-04-mechanism-build-evolution', + title: 'Кэш сборки: ключ определяет, что именно вы повторяете', + categories: ['Frontend', 'Сборка'], + cover: '/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', + excerpt: 'Почему cache hit не равен корректному результату и какие входы должны участвовать в ключе оптимизации.', + readingMinutes: 16, +}, [ + p('Проблема cache в сборке появляется в двух противоположных видах. Кэш всегда промахивается, и команда считает сборку медленной. Или кэш попадает, но после изменения lockfile, конфигурации или linked package остаётся старый output. Цена одинаково неприятна: либо CI тратит время на повторную работу, либо браузер и разработчик видят результат, который не соответствует исходникам.'), + p('Кэш не хранит абстрактное «состояние проекта». Он хранит результат конкретной функции от входов. Если ключ не включает lockfile, релевантную конфигурацию, runtime и исходный граф, система не может понять, что результат устарел. Если ключ включает лишние шумные поля, повторяемость исчезает. Поэтому механизм нужно обсуждать как контракт ключа, значения и правила invalidation.'), + h2('Что входит в ключ'), + p('Vite описывает несколько источников invalidation pre-bundling: lockfile, patches, релевантные поля конфигурации и NODE_ENV. Для linked dependency важен способ разрешения пакета и необходимость повторной оптимизации. Это пример хорошей инженерной границы: в документации названы не только кнопка «force», но и данные, по которым система принимает решение.'), + p('В webpack 5 cache может быть memory или filesystem. Эти режимы различаются временем жизни и стоимостью сериализации. Даже корректный ключ не спасёт, если два job используют одну директорию с разными правами или разными версиями Node. Состояние cache нужно видеть в отчёте: hit/miss, key, location, runtime и причина invalidation.'), + figure('/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', 'Матрица ключа frontend-кэша: lockfile, конфигурация, runtime и исходный digest образуют вход, а изменение любого слоя инвалидирует результат.', 'Диаграмма отделяет входы ключа от результата cache. Красная ветка означает, что неполный ключ нельзя считать доказательством корректного повтора.'), + table('Состав ключа и последствия пропуска', ['Вход', 'Что меняется', 'Признак устаревания', 'Контроль'], [ + ['Lockfile', 'версии и граф зависимостей', 'новый package version', 'hash lockfile'], + ['Config', 'plugins, target, aliases', 'другой output', 'нормализованный config digest'], + ['Runtime', 'Node, bundler, platform', 'разное поведение cache', 'версия и ABI окружения'], + ['Source', 'код и linked package', 'изменённый модуль', 'commit/source digest'], + ['Cache location', 'общая или локальная область', 'чужой результат', 'namespace и права'], ]), - h2('Кэш меняет эксперимент, а не только скорость'), - p('В разговоре о CI cache часто появляется как уже понятная оптимизация. Для inference этого недостаточно. Нужно отделить cache, который ускоряет повторное получение зависимостей, от промежуточных результатов transform и от сохранённого output. Их границы, ключи и invalidation могут быть различны. Без этой карты фраза «второй запуск быстрее» ничего не говорит: возможно, менялась не configuration, а история рабочего каталога или очередь runner.'), - p('Даже корректно названный cache condition не позволяет перейти к будущему результату. Сначала owner должен решить, сравниваются холодные состояния, повторные состояния или оба режима; затем описать одинаковый порядок подготовки для двух сторон. Это не приглашение выполнить команды в данном draft. Это требование к будущему доказательству. P110 не создаёт key, directory, workflow или результат restore, поэтому evaluator stop-веткой закрывает любое значение, отличное от not-collected.'), - h2('CI timing не существует без границы времени'), - p('Время job нельзя считать цельным свойством сборщика. В него могут войти ожидание, checkout, сеть, dependency install, transform, архивирование, публикация, cleanup и отчётность. Полезный будущий протокол обязан назвать начало и конец отсчёта, а также перечислить, какие внешние фазы исключены. Иначе одна сторона получает полное wall-clock время, а другая — только узкий участок, и цифры становятся риторикой.'), - p('Здесь нельзя улучшить текст выдуманным диапазоном или synthetic milliseconds. Synthetic shape допустима только как форма поля, не как измеренное значение. Она может сказать «будущий timing evidence должен иметь границу», но не «ожидаемая сборка займёт X». Такое различение важно: правдоподобная цифра переживёт draft и станет ложным требованием. Отсутствующее число менее опасно, потому что не имитирует наблюдение.'), - h2('Artifact сравнивают по составу и provenance'), - p('Artifact — не синоним одного zip, каталога или bundle. Прежде чем говорить о размере, будущей проверке надо выбрать состав: входят ли HTML, JavaScript, CSS, map, статические ресурсы, generated metadata и compression representation. Затем необходим способ связать состав с configuration и input. SLSA provenance полезна как терминология происхождения, но сама спецификация не сообщает, что у P110 существовал или проверялся какой-либо attestation.'), - p('Из этого следует предел inference. Одинаковый filename не подтверждает одинаковое содержимое; разный hash не сообщает причину; один manifest без условия не доказывает преимущество. Будущий owner может получить материал, который опровергает исходную гипотезу или показывает, что вопросы несопоставимы. Это хороший результат evidence scope, но он не является результатом текущего плана. Current output остаётся передачей необходимого question contract.'), - h2('Почему один diff не образует контроль'), - p('Configuration diff полезен как указатель, но не как контроль эксперимента. Он показывает, что текст параметров отличается, однако не гарантирует одинаковую зависимостную базу, порядок подготовки, окружение runner или состав потребляемого output. Обратное также верно: небольшой diff может скрыть существенную разницу за внешним default либо за изменённой версией зависимости. Поэтому будущему reviewer потребуется не только сравнение файлов, а карта того, откуда каждая существенная настройка пришла и на какую часть процесса могла воздействовать.'), - p('Эта карта не обязана быть огромной. Её ценность в том, что она даёт каждому утверждению адрес: вход относится к source boundary, cache — к состоянию повторного использования, CI — к выполнению workflow, artifact — к выходу. Если адрес отсутствует, утверждение нельзя усилить знаком процента или словом «стабильнее». В P110 намеренно нет таких процентов. Неопределённость остаётся явной, пока будущая работа не свяжет её с конкретным разрешённым evidence.'), - h2('Граница между измерением и интерпретацией'), - p('Даже реальное значение не говорит само за себя. Метод может сообщить длительность выбранного участка, но не объяснить, почему она изменилась; manifest может показать состав, но не указать, какой элемент приемлем для пользователя; cache event может быть записан, но не сообщить, повторится ли он в другом контексте. Будущий документ обязан отделить raw observation от interpretation и перечислить альтернативные объяснения. Этот separation нужен до того, как команда выберет язык итогового сообщения.'), - p('Для апрельского сценария предел ещё строже: отсутствуют и raw observation, и метод. Поэтому нельзя написать даже осторожное «вероятно». Вероятность без population, условий и полученного значения быстро становится скрытым прогнозом. Mechanism article оставляет только структуру будущего вывода: сначала доказать, что стороны отвечают на одно и то же условие, затем прочитать материал, и лишь после этого решить, имеет ли смысл объяснение.'), - h2('Что делать с несопоставимостью'), - p('Несопоставимость не следует маскировать нормализацией, которую выбрали после результата. Если один путь требует другого deployment contract или иной ownership boundary, возможно, это два независимых инженерных решения, а не кандидаты одной гонки. Тогда future owner может подготовить два отдельных decision records либо отказаться от количественного сравнения. Такой выход не означает провал сборки или плана; он сообщает, что первоначальная форма вопроса была слишком широкой.'), - p('Честный stop экономит и время, и доверие. Он не запрещает продолжить исследование, но запрещает превращать накопленные полуфакты в готовую рекомендацию. Именно это делает fixed validator частью редакционной дисциплины: он не знает, как собрать artefact, зато не даёт модели случайно принять отсутствие comparable condition за основание писать о прогрессе.'), - h2('Безопасный literal вместо скрытой методики'), - code("import { createFixedBuildCase, assessFixedBuildPlan } from './upgrade-2027-04.mjs';\n\nconst plan = createFixedBuildCase('missing-comparable-condition-v1');\nconsole.log(assessFixedBuildPlan(plan));\n// { status: 'stop-missing-comparable-condition', productionEffect: 'not-attempted', ... }"), - p('Этот runnable example доказывает только реакцию validator на известный fixed literal. JSON clone отделяет объект caller от шаблона, deep freeze защищает вложенные historicalArtifacts, а JSON-equality не позволяет тихо добавить cache assumption. Fail-closed важен не как стилистика: если условие не названо, система возвращает stop вместо попытки выбрать «разумное» default. Модуль не запускает package manager, build, cache restore, CI job или анализ файлов.'), - h2('Последовательность построения доказательства'), - ol(['Назвать один вопрос, который будет одинаков для обеих сторон, и отметить его как synthetic до authorised scope.', 'Зафиксировать две configuration boundaries без инструментальных версий, flags и файлов, которых нет в evidence.', 'Сформулировать cache states и временную границу как будущие условия, не как уже известную методику.', 'Определить состав artifact и provenance chain, не приписывая manifest или attestation текущему плану.', 'Собрать evidence только после отдельного разрешения и сохранить raw inputs рядом с методом.', 'Разрешить итоговый inference только новому owner; P110 передаёт ему вопрос, а не результат.']), - h2('Полная карта отказов evaluator'), - p('В механизме важен не только accepted branch. Case undated-future-scenario-v1 останавливает документ до обсуждения условий; missing-comparable-condition-v1 показывает, что отсутствие контрольной рамки не восполняется похожестью; hidden-configuration-or-cache-assumption-v1 не допускает молчаливой методики; disallowed-positive-result-v1 не пропускает conclusion о пользе. Такой набор не моделирует запуск и не говорит о будущем tool release. Он проверяет, что логическая форма hand-off не превратится в report после одного удачного слова.'), - h2('Ограничения механизма и следующий шаг'), - p('Механизм не заменяет performance budget, security review, reproducibility policy или договорённость команды о стоимости CI. Он не утверждает, что cache полезен, что один bundle важнее другого или что временная граница уже выбрана. Документация webpack и GitHub описывает свои механизмы, но не даёт универсальную метрику для неизвестного приложения. Тем более она не подтверждает факты про апрель 2027.'), - p('Следующий шаг — future evidence owner решает, достаточно ли конкретен comparable condition для отдельной работы. Если нет, честный исход — расширить или закрыть вопрос без запуска. Если да, он создаёт новый dated artefact с provenance, методикой и ограничениями, не переписывая этот draft как retrospective. Именно это разделение удерживает эволюцию сборки от подмены: сначала сравнимость, затем данные, после этого — возможно — вывод.'), -], refs); + h2('Локальный ключ с прозрачными входами'), + p('В примере используется SHA-256 и фиксированный порядок четырёх значений. Результат — короткий идентификатор, который можно поместить в имя cache namespace. Учебный код не знает, какие поля нужны конкретному bundler: он показывает главное правило — каждый источник изменения должен иметь явное место в ключе, а delimiter не должен позволять склеить разные наборы в одну строку.'), + code(`import { makeDependencyCacheKey } from './upgrade-2027-04.mjs'; -const field = revision({ slug: 'editorial-2027-04-field-build-evolution', title: 'Эволюция frontend-сборки: передача синтетического evidence hand-off', categories: ['Frontend', 'Практика команд'], cover: '/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', excerpt: 'План на апрель 2027: как передать вопрос о сборке следующему владельцу, не превратив план в фиктивный build report.', readingMinutes: 21 }, [ - p('Этот field-текст — явный план/сценарий на 2027-04 с source cutoff 2026-07-31. Его проблема не в отсутствии ещё одного dashboard, а в опасной передаче контекста: будущая команда получает фразу «сборка стала лучше», но не получает исходный config, условие CI или состав артефакта. Цена — повторная работа и ложная операционная память. Следующий инженер вынужден либо верить легенде, либо воспроизводить решение с нуля.'), - p('Вторая цена возникает, когда незаполненную карточку называют полевым отчётом. Для P110 не существуют собранные historical config/CI artifacts, build timing, cache hit, bundle size, migration, tool release или результат. Очередь требует уметь мыслить их shape, но не выдумывать content. Поэтому hand-off не способен завершиться report, approval или production change: допустим только synthetic-plan-hand-off с productionEffect: not-attempted.'), - h2('Передавать вопрос вместе с запретами'), - p('Хороший hand-off короче плохого отчёта, потому что не маскирует отсутствующие данные. Он содержит дату, source cutoff, два named synthetic configuration placeholders, named comparable condition и список классов evidence, которые остаются not-collected. Рядом нужны запреты: не считать cache hit известным, не называть artifact маленьким, не объявлять миграцию завершённой. Эти отрицательные условия важны: без них новый владелец может принять форму карточки за заполненный факт.'), - p('Такая передача не перекладывает техническое мышление на следующего человека. Она оставляет ему проверяемую границу решения. Owner видит, что вопрос относится к сравнению configuration, а не к рейтингу инструментов; что CI и cache — потенциальные условия, а не фон; что historical artifacts запрошены, но не получены. Он может выбрать другой путь: уточнить condition, запросить разрешение, отказаться от измерения или доказать, что сравнение не нужно. Никакой из этих вариантов не задним числом делает P110 report.'), - figure('/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', 'Русская петля передачи evidence: synthetic вопрос проходит дату, две конфигурации и comparable condition, затем либо останавливается при скрытом предположении, либо передаётся future owner без production effect.', 'Петля показывает границы hand-off. Она не изображает реальную CI задачу, config, cache restore, артефакт или итог измерения.'), - table('Минимальная карточка передачи без фальшивого отчёта', ['Поле', 'Допустимое состояние сейчас', 'Кому адресован вопрос', 'Недопустимая подмена'], [ - ['Дата и cutoff', '2027-04 / 2026-07-31', 'редактору и future owner', 'недатированный сценарий'], - ['Стороны', 'named synthetic placeholders', 'владельцу сравнения', 'реальные flags без provenance'], - ['Condition', 'named, not-collected', 'автору методики', 'похожая, но другая среда'], - ['Исторический evidence', 'config/CI/timing/cache/artifact not-collected', 'разрешённому сборщику данных', 'вымышленный build report'], - ['Вывод', 'synthetic-plan-hand-off', 'следующему решающему', 'improved, migrated, released'], +const base = { + lockfile: 'lock-v1', + config: 'target=es2022;minify=true', + runtime: 'node-24', + sourceDigest: 'src-001', +}; + +console.log(makeDependencyCacheKey(base)); +console.log(makeDependencyCacheKey({ ...base, lockfile: 'lock-v2' })); +// 16-символьный key +// другой key после изменения lockfile`), + p('Ожидаемый результат важнее конкретных hex-значений: замена lockfile меняет key, а повтор одного объекта даёт тот же key. Перед реальным использованием нормализуйте конфигурацию и закрепите кодировку. Не добавляйте timestamp, случайный UUID или абсолютный путь, если они не являются частью результата: такие поля превратят каждый запуск в промах.'), + h2('Cache hit не измеряет качество результата'), + p('Попадание в cache говорит, что найден результат с совпавшим ключом. Оно не говорит, что ключ полный, output опубликован, source map соответствует bundle или браузер получил свежий файл. Для dev-server это особенно заметно: Vite может жёстко кэшировать resolved dependency requests, а локальная правка linked package потребует явного re-bundle. В отчёте отделяйте cache state от проверки содержимого.'), + p('Промах тоже не всегда ошибка. Изменение lockfile должно инвалидировать dependency cache. Слишком агрессивное reuse иногда дешевле, чем сложная логика восстановления, если сборка короткая. Решение зависит от стоимости работы и риска устаревшего результата. Назовите обе величины: секунды cache miss и ущерб от неправильного hit.'), + h2('Действия по порядку'), + ol([ + 'Выписать все поля, которые меняют dependency graph, transform, target или состав output.', + 'Нормализовать значения и собрать deterministic key; исключить случайные и абсолютные поля.', + 'Проверить hit и miss на изменении lockfile, конфигурации, runtime и одного исходного модуля.', + 'Сохранить key, cache location, режим cold/warm и причину invalidation в техническом отчёте.', + 'Проверить содержимое output и source map после hit; одного совпавшего key недостаточно.', ]), - h2('Synthetic shape полезна, если не похожа на данные'), - p('Синтетическая форма должна быть достаточно конкретной, чтобы validator мог отклонить ошибку, и достаточно пустой, чтобы не напоминать секретный production snapshot. Поэтому literal называет classes: config, ci, buildTiming, cacheHit, bundleSize. У каждого значение not-collected. Названия не сообщают формат файла, имя job, ключ cache, число байтов или миллисекунды. Это не неполнота реализации, а сознательная граница редакционного scope.'), - p('Не нужно лечить эту границу более реалистичными placeholders вроде вымышленных runner image, branch или JSON manifest. Они выглядят безобидно, но создают фиктивную provenance: читатель начинает искать их в системе или переносить в настоящий pipeline. Если future owner действительно получит artefact, он обязан зафиксировать его происхождение в новом разрешённом документе. Апрельский текст может дать ему вопрос «какой artefact нужен», но не адрес, имя или содержимое этого artefact.'), - h2('Negative branches экономят время команды'), - p('Fail-closed в hand-off означает, что неполный input не становится допустимым за счёт догадки. Недатированный future scenario, отсутствующий comparable condition, hidden configuration или assumed cache приводят к отдельному stop status. Это не бюрократическая кара. Каждая ветка показывает следующему owner, какую именно неопределённость нельзя перескочить. Она лучше общей фразы «нужно больше данных», потому что не приглашает заполнить пробел интуицией.'), - p('Отдельная ветка защищает от положительного результата. Фраза build-improved остановлена не потому, что улучшение невозможно, а потому, что не существует ни метода, ни данных, ни времени, к которым её можно привязать. В перспективе evidence может показать как выгодный, так и нейтральный или отрицательный исход. Хорошая карточка передаёт право получить любой из этих ответов; плохая заранее требует подтвердить историю про прогресс.'), - h2('Пример не имитирует CI'), - code("import { createFixedBuildCase, assessFixedBuildPlan } from './upgrade-2027-04.mjs';\n\nconst handOff = assessFixedBuildPlan(createFixedBuildCase());\nconsole.log(JSON.stringify({ next: handOff.nextAction, effect: handOff.productionEffect }));\n// {\"next\":\"give-the-named-synthetic-comparison-question-to-a-future-evidence-owner\",\"effect\":\"not-attempted\"}"), - p('Этот example запускается в Node как чистая проверка exports. JSON clone не позволяет одному запуску испортить fixed literal для другого, recursive deep freeze блокирует изменения во вложенных объектах, а evaluator принимает только известные cases. У него нет чтения disk, env, clock, network, CI API, cache storage или build process. Следовательно, строка в консоли не является логом pipeline; она подтверждает лишь то, что документ умеет отказать при неправильной форме hand-off.'), - h2('Кому принадлежит следующий вывод'), - p('Редактор статьи не должен незаметно стать владельцем будущего CI-эксперимента. У него нет ни контекста доступа, ни права выбирать исходные данные, ни возможности договориться о рисках публикации. Поэтому полезно адресовать hand-off не абстрактной «команде», а роли future evidence owner: человеку или группе, которым новый scope явно даст право собрать и интерпретировать материалы. Эта роль может не появиться вовсе, и план не становится от этого ошибочным.'), - p('Передаваемая карточка должна помочь этой роли задать первые вопросы: какой decision она обслуживает, какие данные допустимо читать, где будет храниться raw evidence, кто проверит comparison condition и какой итог считается честным, включая отказ от измерения. Ответы не вписываются заранее в значения literal. Иначе технический долг hand-off превращается в организационный долг: owner формально назначен, но уже связан неподтверждёнными предпосылками.'), - h2('Как не превратить документ в очередь задач'), - p('У подобного сценария есть соблазн закончиться списком операций: «сделать workflow», «настроить cache», «снять bundle», «опубликовать отчёт». В P110 эти глаголы опасны, потому что подразумевают существующие интеграции и согласованные права. Более точный порядок сохраняет форму вопроса: запросить authorisation, определить allowed evidence, сформировать метод, после этого собрать данные. Пока первый шаг не выполнен, следующие остаются не работой, а возможными обязанностями другого scope.'), - p('Это ограничение не снижает практическую ценность field-статьи. Напротив, получатель видит, что ему не нужно верить в прошлый запуск и не требуется защищать заранее обещанное улучшение. Он может начать с проверки того, существует ли вообще decision, ради которого стоит платить за доказательство. Если такого decision нет, лучший hand-off — закрыть сценарий без migration story и сохранить отсутствие production effect как честный итог.'), - h2('Слова, которые стоит остановить до публикации'), - p('Перед передачей полезно вычеркнуть конструкции «получили cache hit», «сократили build», «уменьшили bundle», «перешли на новую сборку» и «подтвердили эффект». В реальном отчёте эти слова могут быть точными при наличии датированных материалов. Здесь они делают из будущего сценария вымышленное прошлое. Безопасные формулировки скучнее: «требуется определить», «не собрано», «нужна provenance», «может быть передано». Но именно они не дают следующему читателю спутать план с данными.'), - p('Тон также важен для практики команды. Не нужно оправдывать отсутствие результата длинными оговорками или обвинять будущий owner в задержке. Достаточно назвать границу и следующее решение. Такой текст не делает неопределённость красивой; он делает её видимой и ограниченной. Если позже появится проверяемый материал, он будет сильнее именно потому, что не вырос из текста, где conclusion уже был объявлен до evidence.'), - h2('Порядок передачи будущему evidence owner'), - ol(['Проверить temporal boundary: только 2027-04 и cutoff 2026-07-31 допускают будущий сценарий.', 'Проверить, что левая и правая стороны названы как synthetic configuration, а не как реальные конфиги.', 'Остановить карточку, если comparable condition не имеет имени или скрыта за словом «аналогично».', 'Оставить каждый класс historical evidence в not-collected до нового authorisation и provenance.', 'Не допускать слова о hit, timing, size, migration или release как о свершившихся событиях.', 'Передать named question следующему owner вместе с причиной, почему productionEffect не пытались получить.']), h2('Ограничения и следующий шаг'), - p('Полевой hand-off не создаёт ticket, job, access, owner assignment, CI workflow или хранилище артефактов. Он не оценивает, кто будет оплачивать runner, допустим ли доступ к историческому config, можно ли публиковать manifest и нужен ли компании сам эксперимент. Официальные источники помогают не путать caching и provenance с фактами, но не снимают эти организационные вопросы. У текста нет скрытого обещания, что данные станут доступны в апреле 2027.'), - p('Следующий шаг прост и ограничен: будущий owner принимает или отклоняет evidence question в новом scope. При принятии он обязан сформировать отдельный dated record с реальными inputs, методикой, raw artifacts и выводом, включая возможность отсутствия улучшения. При отклонении hand-off остаётся корректным незавершённым планом. В обоих случаях P110 сохраняет полезное: он не превращает красивую историю об эволюции frontend-сборки в ложное свидетельство.'), -], refs); + p('Функция делает hash строки и не знает о том, как bundler нормализует config, разрешает symlink или читает lockfile. Разный порядок полей может дать лишний miss, а забытый plugin — неправильный hit. Кэш файловой системы зависит от прав, диска и версии сериализации. Нельзя переносить key между toolchain без проверки семантики входов.'), + p('Следующий шаг — добавить тест invalidation для каждого входа и отдельный тест на source map и output после cache hit. В CI выводите первые символы key, но не секреты и содержимое приватного source. Если причина miss неизвестна, сначала расширьте диагностику ключа, а не включайте постоянный force.'), +], [ + { key: 'vite', use: 'Источники invalidation Vite dependency pre-bundling и поведение force/linked dependency.', boundary: 'Руководство относится к Vite dependency optimizer и не является универсальным контрактом любого bundler.' }, + { key: 'webpackConfig', use: 'Режимы memory и filesystem cache webpack 5 используются для различения времени жизни результата.', boundary: 'Справочник не знает cache directory, права и runtime конкретного CI.' }, + { key: 'webpackCache', use: 'Стабильные output names и детерминированные ids используются как пример отделения результата от случайности.', boundary: 'Руководство не доказывает корректность неполного cache key в другом проекте.' }, +]); + +const field = revision({ + slug: 'editorial-2027-04-field-build-evolution', + title: 'Разбор bundle: найти источник роста без гадания', + categories: ['Frontend', 'Производительность'], + cover: '/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', + excerpt: 'Полевой маршрут от выросшего JavaScript-файла к конкретному input, chunk и проверке source map.', + readingMinutes: 15, +}, [ + p('Проблема bundle-анализа начинается с общей цифры: JavaScript-артефакт вырос на 180 КБ. Если сразу удалить большую библиотеку, можно убрать не ту причину. Рост мог появиться из-за новой точки входа, отключённого tree-shaking, дубликата зависимости или включённой source map. Цена неверного действия — регресс функциональности и новый спор о том, какая оптимизация вообще дала эффект.'), + p('Полевой разбор должен свести изменение output к input. Для этого сохраняем два metafile или отчёта сборки, нормализуем пути и считаем delta bytes для каждого входа. Затем проверяем, в какой output попал input и почему. Только после этого выбираем действие: убрать импорт, изменить split, проверить dependency version или оставить рост как осознанную стоимость.'), + h2('Сначала ищем изменение, а не виновника'), + p('esbuild metafile содержит inputs и outputs, включая количество байт input, попавших в output. Это удобнее, чем смотреть только на размер файла: можно увидеть, что добавилось, исчезло или изменилось. Но bytes в metafile — размер вклада в артефакт, а не обязательно размер передачи по сети и не время выполнения. Для пользовательской скорости нужны отдельные browser measurements.'), + p('Source map помогает связать сжатый или преобразованный JavaScript с исходным модулем. При этом карта сама может быть большой и не должна случайно попасть в production response. HTTP SourceMap header и annotation имеют правила, по которым DevTools находит карту. Проверяйте, что путь доступен только в нужной среде и что карта соответствует именно этому bundle.'), + figure('/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', 'Цикл разбора роста bundle: сравнить metafile, найти input с delta, проверить chunk и source map, затем повторить сборку.', 'Схема ведёт от общего симптома к конкретному input. Изменение кода выполняется после проверки состава артефакта, а не по размеру одного файла.'), + table('Матрица разбора роста bundle', ['Наблюдение', 'Где искать', 'Проверка', 'Действие'], [ + ['Новый большой input', 'metafile.inputs', 'кто импортирует модуль', 'разделить или удалить импорт'], + ['Старый input вырос', 'delta bytes и chunk', 'tree-shaking и настройки minify', 'проверить export и plugin'], + ['Дубликат зависимости', 'пути разных версий', 'resolver и lockfile', 'свести версию или alias'], + ['Source map выросла', 'output и response', 'включён ли dev artifact', 'разделить delivery и debug'], + ['Metafile стабилен', 'browser resource timing', 'gzip/brotli, cache, transfer', 'измерить пользовательский путь'], + ]), + h2('Учебный diff двух metafile'), + p('Функция принимает минимальный фрагмент esbuild-подобного JSON: имя input и его bytes. Она объединяет имена из двух сборок, считает delta и сортирует рост сверху. Так инженер получает список конкретных файлов для code search. Данные ниже учебные; в рабочем отчёте рядом с diff сохраняйте commit, command, target и output name.'), + code(`import { summarizeBundleDiff } from './upgrade-2027-04.mjs'; + +const before = { inputs: { + 'src/app.js': { bytes: 12000 }, + 'src/table.js': { bytes: 8000 }, + 'node_modules/date.js': { bytes: 5000 }, +} }; +const after = { inputs: { + 'src/app.js': { bytes: 12000 }, + 'src/table.js': { bytes: 11000 }, + 'node_modules/date.js': { bytes: 5000 }, + 'node_modules/chart.js': { bytes: 42000 }, +} }; + +console.log(summarizeBundleDiff(before, after)); +// chart.js +42000; table.js +3000`), + p('Результат даёт два адреса: новая chart.js и выросшая table.js. Это ещё не решение. Для chart.js нужно найти entry/import и проверить split; для table.js — посмотреть, почему изменился export или transform. Если общий output вырос меньше суммы input delta из-за компрессии и tree-shaking, это нормально: diff направляет исследование, но не заменяет финальный artefact и браузерный замер.'), + h2('От bytes к пользовательскому эффекту'), + p('Большой input может не попасть в первый экран, а маленький модуль — блокировать критический маршрут. Поэтому после статического diff смотрите chunk graph и network resource timing. Transfer size зависит от compression и cache; decoded body size — другой показатель. Cross-origin ресурс может вернуть нулевой transferSize без Timing-Allow-Origin. Эти ограничения нужно написать рядом с числом, иначе bytes начинают выглядеть как latency.'), + p('В source map ищите исходный модуль, но проверяйте соответствие commit. Старая карта при новом bundle создаёт ложную навигацию в DevTools и увеличивает время разбора следующей ошибки. Для production обычно ограничивают доступ к картам или публикуют их в отдельном хранилище с контролем прав. Это уже часть delivery contract, а не косметика сборки.'), + h2('Действия по порядку'), + ol([ + 'Сохранить два metafile для одинакового input и убедиться, что output names и mode сопоставимы.', + 'Посчитать delta по inputs и outputs, затем найти import path, chunk и owner изменившегося модуля.', + 'Проверить lockfile, duplicate versions, tree-shaking, plugin transform и minify настройки.', + 'Сверить source map с commit и отдельно проверить, не попала ли debug-карта в пользовательскую доставку.', + 'Измерить transfer, decoded size, cache и время загрузки критического маршрута; только потом оценивать эффект оптимизации.', + ]), + h2('Ограничения и следующий шаг'), + p('Metafile показывает структуру конкретного bundler и не знает о поведении браузера, compression, CDN и cache. Delta bytes не является p95 и не гарантирует изменение FCP. Source map может быть недоступна или намеренно скрыта, поэтому связь с исходником иногда требует другого артефакта. Статический diff также не видит работу runtime и dynamic import до его выполнения.'), + p('Следующий шаг — добавить автоматический budget по критическим output и список разрешённых изменений. Для каждого превышения pipeline должен печатать top inputs, commit и команду воспроизведения. Тогда фраза «bundle вырос» превращается в короткий проверяемый маршрут: какой файл добавился, в какой chunk попал и какой пользовательский ресурс изменился.'), +], [ + { key: 'esbuild', use: 'Metafile JSON и поля inputs/outputs/bytes используются для вычисления diff состава bundle.', boundary: 'Документация предупреждает, что текстовый analyze предназначен людям; JSON не измеряет браузерную доставку.' }, + { key: 'sourceMap', use: 'Правило SourceMap header и связь DevTools с исходным кодом используются для проверки карты.', boundary: 'MDN не подтверждает доступность карты и не измеряет размер или скорость конкретного ресурса.' }, + { key: 'webpackCache', use: 'Contenthash и стабильная структура output используются как контекст повторяемого артефакта.', boundary: 'Руководство webpack не описывает esbuild metafile и пользовательский performance budget.' }, +]); + +export const revisions = Object.freeze([practice, mechanism, field]); -export const revisions = deepFreeze([practice, mechanism, field]); export function verifyRevisionsAgainstFixture() { - const fixture = runFixedBuildFixture(); - const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|потрат|повторн)/i.test(text.slice(0, 1100)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-04/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); - return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); + const checks = revisions.map((item) => { + const body = bodyText(item.contentHtml); + return body.length >= 5000 && body.length <= 15000 && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body); + }); + const compared = compareBuildRuns({ baseline: { inputFingerprint: 'a', durationMs: 10, outputBytes: 20 }, candidate: { inputFingerprint: 'a', durationMs: 9, outputBytes: 19 } }); + const cacheChanged = makeDependencyCacheKey({ lockfile: 'a', config: 'b', runtime: 'c', sourceDigest: 'd' }) !== makeDependencyCacheKey({ lockfile: 'z', config: 'b', runtime: 'c', sourceDigest: 'd' }); + const diffFound = summarizeBundleDiff({ inputs: { a: { bytes: 1 } } }, { inputs: { a: { bytes: 2 } } }).length === 1; + const fixtureOk = compared.comparable && compared.faster && cacheChanged && diffFound; + return Object.freeze({ passed: checks.filter(Boolean).length + (fixtureOk ? 1 : 0), total: checks.length + 1, accepted: checks.every(Boolean) && fixtureOk, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-05.mjs b/web/scripts/upgrade-2027-05.mjs index 7bcce84..055d167 100644 --- a/web/scripts/upgrade-2027-05.mjs +++ b/web/scripts/upgrade-2027-05.mjs @@ -1,168 +1,312 @@ -function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +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 figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } +function cloneFixed(value) { + return JSON.parse(JSON.stringify(value)); +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(deepFreeze); + Object.freeze(value); + } + return value; +} + +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function bodyText(html) { + return plainText(html.replace(/

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

|$)/, '')); +} const REFERENCES = deepFreeze({ - rfc9110: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110', version: 'RFC 9110, June 2022, DOI 10.17487/RFC9110' }, - rfc9112: { title: 'RFC 9112 — HTTP/1.1', url: 'https://www.rfc-editor.org/rfc/rfc9112', version: 'RFC 9112, June 2022, DOI 10.17487/RFC9112' }, - rfc9114: { title: 'RFC 9114 — HTTP/3', url: 'https://www.rfc-editor.org/rfc/rfc9114', version: 'RFC 9114, June 2022, DOI 10.17487/RFC9114' }, - rfc8446: { title: 'RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3', url: 'https://www.rfc-editor.org/rfc/rfc8446', version: 'RFC 8446, August 2018, DOI 10.17487/RFC8446' }, + http: { title: 'RFC 9110 — HTTP Semantics', version: 'IETF Standards Track, June 2022', url: 'https://www.rfc-editor.org/rfc/rfc9110.html' }, + tls: { title: 'RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3', version: 'IETF Standards Track, August 2018', url: 'https://www.rfc-editor.org/rfc/rfc8446.html' }, }); -function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } + +function sources(entries) { + return '
    ' + entries.map(({ key, use, boundary }) => { + const ref = REFERENCES[key]; + return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '
  • '; + }).join('') + '
'; +} const FIXED_HTTP_TLS_CASES = deepFreeze({ - 'planned-http-tls-question-v1': { id: 'planned-http-tls-question-v1', planDate: '2027-05', sourceCutoff: '2026-07-31', protocolQuestion: { id: 'named-http-tls-boundary-question', version: 'named-protocol-version-question', statement: 'synthetic-only-no-endpoint-or-client' }, evidence: { state: 'not-collected', strength: 'input-bounded', command: 'not-run', endpoint: 'not-collected', clientConfig: 'not-collected', trace: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Fixed in-memory planning literal. No HTTP request, TLS handshake, socket, endpoint, client configuration, certificate, command, trace, error, clock, file, environment, secret, telemetry, production system or customer data is read, created, changed or inferred.' }, - 'undated-plan-or-cutoff-v1': { id: 'undated-plan-or-cutoff-v1', planDate: '', sourceCutoff: '2026-07-31', protocolQuestion: { id: 'named-http-tls-boundary-question', version: 'named-protocol-version-question', statement: 'synthetic-only-no-endpoint-or-client' }, evidence: { state: 'not-collected', strength: 'input-bounded', command: 'not-run', endpoint: 'not-collected', clientConfig: 'not-collected', trace: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'unnamed-protocol-or-version-question-v1': { id: 'unnamed-protocol-or-version-question-v1', planDate: '2027-05', sourceCutoff: '2026-07-31', protocolQuestion: { id: '', version: '', statement: 'synthetic-only-no-endpoint-or-client' }, evidence: { state: 'not-collected', strength: 'input-bounded', command: 'not-run', endpoint: 'not-collected', clientConfig: 'not-collected', trace: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'evidence-stronger-than-input-v1': { id: 'evidence-stronger-than-input-v1', planDate: '2027-05', sourceCutoff: '2026-07-31', protocolQuestion: { id: 'named-http-tls-boundary-question', version: 'named-protocol-version-question', statement: 'synthetic-only-no-endpoint-or-client' }, evidence: { state: 'claimed', strength: 'stronger-than-input', command: 'not-run', endpoint: 'not-collected', clientConfig: 'not-collected', trace: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'hidden-command-endpoint-or-config-v1': { id: 'hidden-command-endpoint-or-config-v1', planDate: '2027-05', sourceCutoff: '2026-07-31', protocolQuestion: { id: 'named-http-tls-boundary-question', version: 'named-protocol-version-question', statement: 'synthetic-only-no-endpoint-or-client' }, evidence: { state: 'not-collected', strength: 'input-bounded', command: 'hidden', endpoint: 'not-collected', clientConfig: 'not-collected', trace: 'not-collected' }, requestedConclusion: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'disallowed-positive-network-conclusion-v1': { id: 'disallowed-positive-network-conclusion-v1', planDate: '2027-05', sourceCutoff: '2026-07-31', protocolQuestion: { id: 'named-http-tls-boundary-question', version: 'named-protocol-version-question', statement: 'synthetic-only-no-endpoint-or-client' }, evidence: { state: 'not-collected', strength: 'input-bounded', command: 'not-run', endpoint: 'not-collected', clientConfig: 'not-collected', trace: 'not-collected' }, requestedConclusion: 'handshake-succeeded', boundary: 'Negative fixed literal only.' }, + status404: { status: 404, headers: { 'content-type': 'text/plain' }, body: 'missing' }, + status503: { status: 503, headers: { 'retry-after': '2' }, body: 'busy' }, + tlsNameMismatch: { error: 'ERR_TLS_CERT_ALTNAME_INVALID', stage: 'certificate' }, + invalid: { status: 0, headers: {}, body: '' }, }); -export function createFixedHttpTlsCase(id = 'planned-http-tls-question-v1') { const value = FIXED_HTTP_TLS_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } +export function createFixedHttpTlsCase(id = 'status404') { + return FIXED_HTTP_TLS_CASES[id] ? deepFreeze(cloneFixed(FIXED_HTTP_TLS_CASES[id])) : undefined; +} + export function assessFixedHttpTlsPlan(input) { - if (!Object.values(FIXED_HTTP_TLS_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case'); - if (input.planDate !== '2027-05' || input.sourceCutoff !== '2026-07-31') return stop('stop-undated-plan-or-cutoff', 'may-2027-plan-and-july-2026-cutoff-are-required', 'name-2027-05-and-2026-07-31'); - if (!input.protocolQuestion?.id || !input.protocolQuestion?.version || !input.protocolQuestion?.statement) return stop('stop-unnamed-protocol-or-version-question', 'protocol-and-version-question-must-be-named-synthetic-literals', 'name-the-protocol-version-question'); - if (input.evidence?.state !== 'not-collected' || input.evidence?.strength !== 'input-bounded') return stop('stop-evidence-stronger-than-input', 'no-evidence-may-be-claimed-or-stronger-than-the-fixed-input', 'keep-evidence-not-collected-and-input-bounded'); - if (input.evidence?.command !== 'not-run' || input.evidence?.endpoint !== 'not-collected' || input.evidence?.clientConfig !== 'not-collected' || input.evidence?.trace !== 'not-collected') return stop('stop-hidden-command-endpoint-or-config', 'commands-endpoints-client-config-and-traces-must-remain-not-run-or-not-collected', 'record-only-not-run-state-limits'); - if (input.requestedConclusion !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-network-conclusion', 'a-handshake-http-or-production-positive-conclusion-is-forbidden', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, protocolQuestion: deepFreeze(cloneFixed(input.protocolQuestion)), evidence: deepFreeze(cloneFixed(input.evidence)), boundary: input.boundary, productionEffect: 'not-attempted', nextAction: 'give-the-question-and-state-limits-to-a-future-evidence-owner' }); + if (!input || typeof input !== 'object') return { status: 'stop', class: 'invalid-input', action: 'проверить форму наблюдения' }; + if (input.error === 'ERR_TLS_CERT_ALTNAME_INVALID') return { status: 'check-certificate-name', class: 'TLS', action: 'сверить имя узла с SAN сертификата' }; + if (input.status === 404) return { status: 'check-route', class: 'HTTP', action: 'проверить URI и маршрут' }; + if (input.status === 503) return { status: 'check-overload', class: 'HTTP', action: 'прочитать Retry-After и проверить зависимость' }; + if (input.status >= 200 && input.status < 400) return { status: 'success', class: 'HTTP', action: 'проверить тело и контракт ответа' }; + return { status: 'stop', class: 'unknown', action: 'собрать расширенный вывод отдельно' }; +} + +export function runFixedHttpTlsFixture() { + const checks = [ + ['status404', 'check-route'], + ['status503', 'check-overload'], + ['tlsNameMismatch', 'check-certificate-name'], + ['invalid', 'stop'], + ].map(([id, expected]) => ({ id, expected, actual: assessFixedHttpTlsPlan(createFixedHttpTlsCase(id)).status })); + return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks }); +} + +function revision(meta, parts, referenceEntries) { + const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); + return deepFreeze({ ...meta, contentHtml, proseLength }); } -export function runFixedHttpTlsFixture() { const expected = [['planned-http-tls-question-v1', 'synthetic-plan-hand-off'], ['undated-plan-or-cutoff-v1', 'stop-undated-plan-or-cutoff'], ['unnamed-protocol-or-version-question-v1', 'stop-unnamed-protocol-or-version-question'], ['evidence-stronger-than-input-v1', 'stop-evidence-stronger-than-input'], ['hidden-command-endpoint-or-config-v1', 'stop-hidden-command-endpoint-or-config'], ['disallowed-positive-network-conclusion-v1', 'stop-disallowed-positive-network-conclusion']]; const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessFixedHttpTlsPlan(createFixedHttpTlsCase(id)).status })); const sample = createFixedHttpTlsCase(); return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.protocolQuestion) && Object.isFrozen(sample.evidence), checks: deepFreeze(checks) }); } -function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } const refs = [ - { key: 'rfc9110', use: 'Определяет семантические части HTTP сообщения и границы интерпретации поля.', boundary: 'Не подтверждает ответ конкретного origin, proxy, клиента, endpoint или операции.' }, - { key: 'rfc9112', use: 'Даёт первичную спецификацию framing HTTP/1.1, опубликованную до cutoff.', boundary: 'Не доказывает, какой wire-format или ошибка будут у неназванного соединения.' }, - { key: 'rfc9114', use: 'Фиксирует модель HTTP/3 и её привязку к QUIC как справочный механизм.', boundary: 'Не делает утверждений о доступности HTTP/3 в каком-либо клиенте или сервисе.' }, - { key: 'rfc8446', use: 'Даёт первичную спецификацию TLS 1.3 и сообщений handshake.', boundary: 'Не заменяет certificate chain, policy, trace, negotiated parameters или результат handshake.' }, + { key: 'http', use: 'Нужен для различения методов, статусов, заголовков, маршрутизации и ответа посредника.', boundary: 'Не объясняет конкретную конфигурацию прокси, DNS или причину ошибки в вашем сервисе.' }, + { key: 'tls', use: 'Нужен для разбора рукопожатия TLS 1.3, проверки имени узла и сообщения об ошибке сертификата.', boundary: 'Не подтверждает доверие к конкретному центру сертификации и не заменяет проверку ключевого материала.' }, ]; -const practice = revision({ slug: 'editorial-2027-05-practice-http-tls-guide', title: 'Полевой справочник HTTP и TLS: практическая карта вопроса', categories: ['Инженерные практики', 'Сети'], cover: '/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg', excerpt: 'План на май 2027: как поставить HTTP/TLS-вопрос без подмены его сетевым результатом.', readingMinutes: 19 }, [ - p('Самая дорогая ошибка при разборе HTTP/TLS — начать с команды и закончить чужим выводом: «сервер ответил», «сертификат плохой» или «TLS работает». Без названного протокольного вопроса эти фразы склеивают уровни: HTTP semantics, message framing, TLS negotiation, DNS, маршрут и политика клиента. Цена — часы в неверной команде и ложное исправление, которое меняет не ту границу. В полевом справочнике сначала должна появиться карта вопроса, а не инструмент в терминале.'), - p('Редакторская дата — 2026-07-31; май 2027 ещё не наступил. Поэтому P111 не содержит controlled handshake, HTTP trace, cURL/OpenSSL run, endpoint, client config, certificate, error или production result. Это строго plan/scenario на 2027-05 с source cutoff 2026-07-31. Единственный положительный outcome — synthetic-plan-hand-off; у него всегда productionEffect: not-attempted. Любая приятная фраза о соединении здесь была бы выдуманным сетевым отчётом.'), - h2('Карта начинается с вопроса, а не с симптома'), - p('Практический вопрос должен иметь два имени: что различаем и для какой версии это различение формулируем. В fixed literal это named-http-tls-boundary-question и named-protocol-version-question. Это не hostname, не URI, не ALPN value и не название библиотеки. Такие placeholders нарочно достаточно бедны: они не позволяют незаметно привезти endpoint, команду или configuration под видом «контекста». Если protocol/version question не назван, модель останавливается; default вроде «наверное, HTTPS» уже является неподтверждённой подстановкой.'), - p('Вопрос удобно делить на четыре плоскости. Семантика HTTP отвечает, что означает method, target, status и field в сообщении. Framing отвечает, где сообщение начинается и заканчивается в выбранном HTTP mapping. TLS handshake отвечает, какие сообщения и параметры могут быть согласованы до прикладного обмена. Доставка и policy отвечают за то, что находится вокруг этого: имена, маршрутизация, trust store, ограничения клиента и промежуточные узлы. Отсутствие наблюдения в одной плоскости не делает фактом вывод о другой.'), - figure('/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg', 'Карта вопроса HTTP и TLS: отдельные блоки HTTP-семантики, framing, TLS handshake и клиентской policy сходятся только в synthetic hand-off; красные блоки запрещают endpoint, команду и вывод о соединении.', 'Схема помогает назвать границу будущего вопроса. Это не trace, не handshake и не результат обращения к сети.'), - table('Версионированная карта вопроса на 2027-05', ['Плоскость', 'Что можно назвать сейчас', 'Что не известно', 'Что передать owner'], [ - ['HTTP semantics', 'method/status/header question', 'конкретное сообщение и ответ', 'правило интерпретации'], - ['HTTP framing', 'named HTTP version question', 'байты, порядок и длины', 'какой mapping требуется уточнить'], - ['TLS handshake', 'named TLS version question', 'negotiation и certificate', 'какой class evidence нужен'], - ['Client policy', 'boundary question', 'trust, proxy, retry, config', 'полномочия и provenance'], - ['Итог', 'synthetic plan', 'endpoint, trace, error, result', 'hand-off без network action'], +const practice = revision({ + slug: 'editorial-2027-05-practice-http-tls-guide', + title: 'HTTP и TLS без гадания: как разобрать 404, 503 и ошибку сертификата', + categories: ['Сети', 'Диагностика'], + cover: '/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg', + excerpt: 'Практический маршрут от текста ошибки к уровню, на котором действительно нужно искать причину.', + readingMinutes: 14, +}, [ + p('Запрос к сервису может не дойти до приложения, хотя пользователь видит обычную страницу ошибки. 404 говорит о выбранном ресурсе, 503 — о доступности обработчика, а ERR_TLS_CERT_ALTNAME_INVALID возникает ещё до HTTP. Цена смешения этих уровней — часы на исправление маршрута в коде, когда проблема находится в имени узла или на обратном прокси.'), + p('Разберём три наблюдаемых случая на одном маршруте: сначала получим ответ локального HTTP-сервера, затем отделим HTTP-статус от TLS-этапа и зафиксируем следующий запрос для проверки. Пример учебный: он не обращается к внешней сети и не выдаёт локальный результат за состояние чужой инфраструктуры.'), + h2('Сначала фиксируем точку отказа'), + p('У любого запроса есть последовательность: разрешение имени, установка TCP-соединения, TLS-рукопожатие для https, отправка HTTP-запроса и чтение ответа. Важен первый наблюдаемый факт. Если клиент не смог проверить сертификат, у него нет HTTP-статуса. Если получен 404, TLS и HTTP-соединение уже состоялись, а искать нужно URI, метод или маршрутизацию.'), + table('Какой уровень проверять первым', ['Наблюдение', 'Уровень', 'Первое действие', 'Чего не делать'], [ + ['Ошибка имени сертификата', 'TLS', 'сверить host и SAN', 'не менять HTTP-заголовки'], + ['404 Not Found', 'HTTP-маршрут', 'проверить путь и метод', 'не увеличивать timeout'], + ['503 Service Unavailable', 'обработчик или зависимость', 'прочитать заголовки и логи', 'не повторять POST вслепую'], + ['Нет ответа и timeout', 'сеть или сервер', 'разделить connect/read timeout', 'не считать это 500'], + ]), + figure('/assets/editorial/2027/http-tls-guide-2027-handshake-header-map.svg', 'Схема уровней запроса: DNS, TCP, TLS, HTTP и ответ с точкой остановки диагностики.', 'Диаграмма показывает порядок уровней. Стрелка останавливается на первом слое, о котором есть наблюдение.'), + h2('Учебный HTTP-ответ на локальном сервере'), + p('Чтобы не спорить о сообщении браузера, поднимем два endpoint в одном Node-процессе. Вход — путь запроса. Ожидаемый результат — числовой статус и тело. Такой запуск показывает семантику HTTP-ответа, но не тестирует TLS: для TLS нужен отдельный сервер с сертификатом и проверкой имени.'), + code([ + "import { createServer } from 'node:http';", + '', + "const server = createServer((request, response) => {", + " if (request.url === '/health') {", + " response.writeHead(200, { 'content-type': 'text/plain' });", + " response.end('ok');", + ' return;', + ' }', + " response.writeHead(404, { 'content-type': 'text/plain' });", + " response.end('missing');", + '});', + '', + "server.listen({ port: 0, host: '127.0.0.1' }, async () => {", + ' const port = server.address().port;', + " const response = await fetch('http://127.0.0.1:' + port + '/missing');", + " console.log(response.status, await response.text());", + ' server.close();', + '});', + ].join('\n')), + p('Запустите файл командой node check-http.mjs. В консоли будет 404 missing. Входом является только локальный путь; ожидаемый результат проверяем двумя значениями. Если изменить путь на /health, получится 200 ok. Это полезнее, чем проверять только цвет страницы: статус и тело принадлежат разным частям HTTP-контракта.'), + h2('Почему 503 нельзя лечить повтором по умолчанию'), + p('503 означает, что сервер временно не может обработать запрос. Заголовок Retry-After может дать клиенту ориентир, но он не делает повтор безопасным. Для GET повтор обычно не меняет ресурс, а для POST повтор способен создать вторую запись или списать деньги повторно. Перед автоматикой нужно знать семантику метода и наличие ключа идемпотентности.'), + p('В диагностической записи сохраняйте метод, путь без секретов, статус, важные заголовки, время ожидания и первый байт ответа. Не прикладывайте токен авторизации и полные cookie. Для 503 сначала проверяется зависимость, ограничение соединений или обслуживание сервера; затем выбирается контролируемый повтор с лимитом и задержкой.'), + h2('Ошибка сертификата находится до HTTP'), + p('TLS 1.3 устанавливает защищённый канал и проверяет имя узла в сертификате. Имя берётся из URL и должно совпасть с одним из значений Subject Alternative Name. Если клиент подключается к IP вместо доменного имени, использует устаревший alias или получает сертификат другого виртуального хоста, HTTP-заголовок Host проблему не исправит: запрос ещё не отправлен.'), + p('Практическая граница видна в подробном выводе клиента: после строки об успешной проверке сертификата и до ответа 404 проблема уже относится к HTTP-маршруту. Если TLS завершается исключением, статус и тело приложения искать бессмысленно. Это простое разделение экономит одну итерацию проверки.'), + p('Проверка должна разделять имя, цепочку доверия и срок действия. В учебной диагностике достаточно зафиксировать hostname, адрес назначения и текст ошибки библиотеки. Флаг вроде --insecure может подтвердить, что сервер отвечает, но он отключает проверку и не является исправлением. После такого эксперимента соединение нужно закрыть и повторить проверку с обычной валидацией.'), + h2('Порядок диагностики'), + ol([ + 'Записать URL, метод, момент запроса и безопасный идентификатор запроса; убрать Authorization, Cookie и персональные параметры.', + 'Проверить разрешение имени и адрес назначения отдельно от приложения.', + 'Для HTTPS проверить hostname, SAN, срок действия и цепочку сертификата обычным клиентом.', + 'Только после успешного TLS посмотреть HTTP-статус, заголовок Allow, Location, Retry-After и тело.', + 'Сопоставить метод и действие: повтор разрешать только для операции с понятной идемпотентностью.', + 'Зафиксировать один следующий тест и ожидаемый результат, например «/health возвращает 200, /missing — 404».', ]), - h2('Заголовок не является trace'), - p('Поле заголовка имеет имя, значение и определённую семантику в контексте сообщения; это не означает, что мы видели такое поле на wire. RFC 9110 полезен для разделения semantics от конкретной реализации: он помогает сформулировать, что будущий вопрос относится к request, response или representation. Но даже точное правило спецификации не сообщит, какое поле добавит intermediary, что нормализует библиотека и в какой момент приложение увидит значение. Поэтому нельзя писать «этот header был причиной» без материала, который разрешён и сохранён в новом scope.'), - p('То же относится к HTTP/1.1 и HTTP/3. RFC 9112 описывает HTTP/1.1 framing, RFC 9114 — HTTP/3 поверх QUIC. Из этого следует, что название версии меняет допустимый механизм разбора; не следует, что выбранный клиент использовал один из них. Практический справочник не выбирает fallback, не предлагает «универсальный» request и не рисует bytes. Пока нет именованной версии и authorised evidence, правильная инструкция короче: назвать вопрос и не выдавать формат за наблюдение.'), - h2('TLS — отдельный слой причинности'), - p('TLS 1.3 описан в RFC 8446 как последовательность протокольных сообщений и проверок, но текст RFC не превращается в report о неизвестном peer. Слова certificate, handshake failure и negotiated version особенно опасны: они звучат технически, однако без certificate chain, configuration и trace дают только предположение. Сценарий P111 не хранит и не получает эти объекты. Он не называет algorithm, cipher suite, SNI, ALPN, CA store или параметр команды, потому что все они были бы не нейтральными примерами, а скрытой client configuration.'), - p('Плановая граница полезна именно своей строгостью. Будущий owner может решить, что вопрос на самом деле про HTTP semantics и TLS-данные не нужны; может решить обратное; может остановить discovery из-за отсутствия полномочий. Нельзя компенсировать эту неопределённость «типичной» настройкой cURL или OpenSSL. Поведение инструментов меняется по release и build, а P111 не фиксирует проверенный exact release source для такого поведения. В перечне будущих средств остаётся только состояние: command: not-run, trace: not-collected.'), - h2('Безопасный runnable literal'), - code("import { createFixedHttpTlsCase, assessFixedHttpTlsPlan } from './upgrade-2027-05.mjs';\n\nconst plan = createFixedHttpTlsCase('planned-http-tls-question-v1');\nconst handOff = assessFixedHttpTlsPlan(plan);\nconsole.log({ status: handOff.status, effect: handOff.productionEffect });\n// { status: 'synthetic-plan-hand-off', effect: 'not-attempted' }"), - p('Пример запускаем, но он не является сетевой проверкой. Factory делает JSON clone named fixed literal и затем recursive deep freeze; evaluator принимает только JSON-равный known literal и fail-closed закрывает всё остальное. Вызов не использует socket, fetch, child process, file, environment, clock, certificate store или telemetry. Он демонстрирует исключительно редакционный контракт: исходные данные ограничены literal, следовательно evidence не может стать сильнее input и итог не может превратиться в handshake result.'), - h2('Порядок действий для будущего владельца'), - ol(['Сначала зафиксировать planDate: 2027-05 и sourceCutoff: 2026-07-31; недатированный сценарий закрыть.', 'Назвать protocol question и version question, не подменяя их endpoint, URL, hostname или именем клиента.', 'Разнести desired inference по HTTP semantics, framing, TLS и client policy; не делать один симптом общей причиной.', 'Оставить cURL/OpenSSL и любые traces только в списке not-run/not-collected, без команды и конфигурации.', 'Проверить fixed literal; evidence stronger than input, hidden command/endpoint/config и positive network conclusion должны вернуть stop.', 'Передать question contract будущему evidence owner, который отдельно решит полномочия, минимальный набор данных и метод.' ]), - h2('Как не потерять версию в формулировке'), - p('Версия в карте — не число для украшения. Она отвечает на вопрос, какой именно нормативный vocabulary допустимо использовать в будущем contract. HTTP/1.1 framing нельзя тихо подменить общим словом «HTTP», а TLS 1.3 нельзя описать как абстрактное «шифрование». При этом названная версия не становится observed negotiation. Два утверждения живут на разных уровнях: первое — о границе формулировки, второе — о факте конкретного взаимодействия. План имеет право только на первое.'), - p('Полезный приём для owner — выписать рядом «вопрос версии» и «наблюдение версии», не смешивая поля. У первого допустимы synthetic labels и ссылка на RFC, у второго потребовались бы собственные provenance, timestamp, collection policy и rule интерпретации. Если эти столбцы оказались одинаковыми, вероятно, в draft уже проникли данные. В P111 такого перехода нет: version остаётся именем будущего различения, а не свойством клиента или peer.'), - p('Это различение экономит усилие при эскалации. Вместо спора, «почему не проверить прямо сейчас», hand-off показывает недостающие полномочия: target не назван, client context не согласован, сохранение output не определено. Тогда решение может быть не только запуском. Команда вправе сузить вопрос, перенести его в другой контур или признать, что стандартного знания достаточно. Справочник не навязывает проверку как обязательный ритуал; он не даёт ритуалу замаскироваться под результат.'), - p('Нельзя и переносить на будущее сегодняшнюю форму вопроса как неизменную. К маю 2027 могут измениться продуктовая цель, ownership, допустимый канал наблюдения или сам смысл риска. Cutoff фиксирует только то, какие источники использованы для редакционного vocabulary; он не замораживает реальную систему. Поэтому hand-off не содержит заранее выбранного client или target и не обещает, что будущая работа обязана следовать тем же шагам. Его ценность — передать точку честного старта, а не создать скрытый проектный план.'), - p('Практический reader может использовать карту уже сейчас как редакционную проверку вопроса. Достаточно спросить: где в тексте граница HTTP, где framing, что относится к TLS, кто определяет client policy, и какое слово создаёт наблюдение без источника. Если на один из вопросов нет ответа, это не повод добавить знакомую техническую деталь. Это повод вернуть карточку к именованному placeholder. В таком виде она остаётся применимой к будущему scope и не расходует доверие к документации.'), - p('Так карта остаётся рабочей и после того, как будущий scope изменит инструменты или вовсе откажется от их использования: она описывает не команду, а порядок различения.'), h2('Ограничения и следующий шаг'), - p('Этот текст не обучает обходить TLS-проверки, не задаёт client flags и не рекомендует отключать валидацию. Он также не диагностирует 4xx/5xx, reset, timeout, mismatch или любую другую ошибку: таких error здесь нет. RFC-источники до cutoff дают язык для точного будущего вопроса, но не дают ответ о конкретной сети. Даже существование method или handshake message в стандарте не подтверждает, что оно применимо к неназванной системе.'), - p('Следующий шаг — сохранить hand-off как ограниченную карточку до отдельного authorised scope. В новом artefact будущий owner сможет определить, какой evidence допустим, как отделить observation от inference и какие данные нельзя сохранять. До этого нельзя превратить вопрос в endpoint, client config или production action. Здесь завершён только безопасный переход: вопрос имеет дату, cutoff, границу и следующую роль; production effect не пытались получить.'), + p('Локальный сервер не показывает работу CDN, DNS-балансировщика, корпоративного proxy или реального центра сертификации. Статус 404 не доказывает, что маршрут одинаково настроен во всех регионах, а 503 не называет виновную зависимость. Для этого нужны согласованные логи и сетевые наблюдения с разрешённым доступом.'), + p('Следующим шагом соберите две безопасные записи: успешный запрос к health-endpoint и один ошибочный запрос с тем же hostname. Сравните этап, статус, заголовки и время. Если различие появляется до HTTP, оставайтесь на TLS или сети; если оба запроса дошли до сервера, переходите к маршруту и контракту приложения.'), ], refs); -const mechanism = revision({ slug: 'editorial-2027-05-mechanism-http-tls-guide', title: 'Полевой справочник HTTP и TLS: границы inference', categories: ['Инженерные практики', 'Сети'], cover: '/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg', excerpt: 'План на май 2027: как отличить классы HTTP/TLS-ошибок от выводов, которых evidence не поддерживает.', readingMinutes: 20 }, [ - p('Дорогая диагностическая ошибка — объявить один наблюдаемый симптом причиной на соседнем уровне: HTTP status назвать TLS отказом, ошибку проверки certificate — свойством заголовка, а отсутствие response — доказательством сервера. Такая склейка заставляет менять policy, proxy или приложение вслепую; цена — длинный цикл повторной работы и риск затронуть не ту границу. Механизм нужен не для «быстрого диагноза», а для точного запрета на inference, который не следует из входа.'), - p('На 2026-07-31 у P111 нет наблюдаемого симптома, HTTP trace, controlled handshake, endpoint, client config, certificate, command output или production result; май 2027 ещё впереди. Поэтому это plan/scenario на 2027-05, cutoff 2026-07-31. Мы разбираем классы различения, а не фактические errors. Positive outcome только synthetic-plan-hand-off с productionEffect: not-attempted; «успешно», «исправлено» и «соединение установлено» запрещены.'), - h2('Inference имеет потолок'), - p('Evidence не должен быть сильнее input. Если input — named question и state not-collected, допустимый output — только вопрос с ограничениями. Он не может вдруг содержать negotiated protocol, header set, certificate finding или error classification. Это не избыточная осторожность: поздний reader не видит, где закончился вход и началась уверенная интерпретация. В validator case evidence-stronger-than-input-v1 специально заявляет claimed evidence и закрывается с stop-evidence-stronger-than-input.'), - p('Ошибка класса и ошибка механизма — не одно и то же. «HTTP semantic mismatch» описывает расхождение между ожидаемой и допустимой интерпретацией сообщения. «HTTP framing boundary» относится к способу выделить сообщение в конкретном mapping. «TLS negotiation boundary» относится к протокольному согласованию до HTTP semantics. «Client trust/policy boundary» относится к тому, что клиент разрешает использовать. Эти labels полезны как future buckets, но без конкретного input ни один bucket не заполнен и ни один не является диагнозом.'), - figure('/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg', 'Матрица границ inference: HTTP-семантика, framing, TLS negotiation и client policy сопоставлены с допустимым доказательством; красная колонка запрещает вывод о соседнем уровне и positive conclusion.', 'Матрица различает классы вопросов и не сообщает об ошибке реального клиента, сервера или сертификата.'), - table('Граница между симптомом и выводом', ['Класс будущего вопроса', 'Минимальное evidence', 'Что ещё не следует', 'Безопасное действие сейчас'], [ - ['HTTP semantics', 'явно разрешённый message context', 'TLS причина или endpoint state', 'сформулировать semantic rule'], - ['HTTP framing', 'versioned wire mapping', 'значение header или policy', 'назвать HTTP version question'], - ['TLS negotiation', 'разрешённый handshake material', 'HTTP status и app behaviour', 'назвать TLS version question'], - ['Trust / client policy', 'явная client configuration', 'peer identity или network fault', 'отделить policy от peer'], - ['P111 сейчас', 'fixed literal only', 'любой error/result', 'synthetic hand-off'], +const mechanism = revision({ + slug: 'editorial-2027-05-mechanism-http-tls-guide', + title: 'Где рождается ошибка HTTP: разбираем цепочку DNS, TLS и заголовков', + categories: ['Сети', 'HTTP'], + cover: '/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg', + excerpt: 'Механика запроса по слоям: почему одинаковое слово «ошибка» требует разных проверок.', + readingMinutes: 15, +}, [ + p('Когда браузер показывает «не удалось подключиться», виден только итог. Цена ошибки — изменить код приложения, не проверив, что запрос вообще не прошёл TLS, или принять ответ кэша за ответ origin-сервера. Для точной диагностики нужно восстановить цепочку по наблюдаемым границам, а не угадывать виновника по одному коду.'), + p('В этой статье разложим запрос на переходы и посмотрим, какие поля подтверждают каждый переход. Практический результат — короткая таблица: какой лог или команда отвечает на конкретный вопрос. Пример запускается локально на Node и показывает обмен HTTP-заголовками без обращения к внешнему узлу.'), + h2('Пять границ одного запроса'), + p('DNS превращает имя в адрес, TCP устанавливает поток байтов, TLS защищает его и связывает с именем, HTTP передаёт метод и путь, а приложение формирует ответ. Посредник может добавить свой статус или заголовок на каждом шаге. Поэтому поле server в ответе не доказывает, что ответ сформирован именно вашим приложением.'), + table('Поле и вопрос диагностики', ['Граница', 'Что можно утверждать', 'Что проверить'], [ + ['DNS', 'имя разрешилось в адрес', 'ответ A/AAAA и выбранный адрес'], + ['TCP', 'порт принял соединение', 'connect error и время установки'], + ['TLS', 'сертификат подходит имени', 'SAN, chain, protocol version'], + ['HTTP', 'получен статус и заголовки', 'method, path, status, headers'], + ['Приложение', 'обработан контракт endpoint', 'лог маршрута и request id'], + ]), + figure('/assets/editorial/2027/http-tls-guide-2027-symptom-boundary-matrix.svg', 'Матрица симптомов HTTP и TLS с границей, которую подтверждает каждый вид наблюдения.', 'У каждой строки есть отдельный вопрос. Нельзя переносить ответ из соседней строки: HTTP-статус не подтверждает TLS, а DNS-ответ не подтверждает маршрут.'), + h2('Заголовок не равен доказательству источника'), + p('Заголовок Via, Server или пользовательский X-Request-Id помогает построить гипотезу, но это данные сообщения, а не криптографическая аттестация сервера. Посредник может удалить или переписать поля. Для сопоставления запроса с серверным логом нужен идентификатор, который генерируется на входе доверенного компонента и сохраняется без смены формата.'), + p('У HTTP/2 и HTTP/3 целевой authority может передаваться не так, как привычная строка Host. Поэтому в диагностической записи сохраняйте логическое имя назначения и не делайте вывод о виртуальном хосте по одному полю. Сопоставляйте его с тем, что использовал TLS-клиент.'), + p('Удобная минимальная запись выглядит так: метод, нормализованный путь, статус, request id, длительность, размер ответа и класс ошибки. Секреты и произвольные query-параметры не входят в журнал по умолчанию. Нормализация важна: если один компонент пишет полный URL, а другой — только путь, поиск по записи будет давать ложные пропуски.'), + h2('Учебный обмен с заголовками'), + p('Локальный сервер ниже возвращает два заголовка и JSON-тело. Вход — HTTP-запрос с Accept. Ожидаемый результат — 200, тип содержимого и один идентификатор. Это реальный обмен между клиентом и сервером, но он не проверяет TLS или поведение прокси.'), + code([ + "import { createServer } from 'node:http';", + '', + "const server = createServer((request, response) => {", + " response.writeHead(200, {", + " 'content-type': 'application/json; charset=utf-8',", + " 'x-request-id': 'local-001',", + ' });', + " response.end(JSON.stringify({ method: request.method, path: request.url }));", + '});', + '', + "server.listen(0, 'localhost', async () => {", + ' const port = server.address().port;', + " const endpoint = 'http://localhost:' + port + '/orders';", + ' const response = await fetch(endpoint);', + " console.log(response.status, response.headers.get('x-request-id'));", + " console.log(await response.json());", + ' server.close();', + '});', + ].join('\n')), + p('Результат содержит 200 local-001 и объект с методом GET и путём /orders. Если убрать заголовок из ответа, клиент всё равно получит 200: это показывает, что request id — средство сопоставления, а не условие корректности HTTP. В рабочей системе нужно договориться, какой компонент отвечает за его создание и где он попадает в лог.'), + h2('TLS меняет порядок проверки'), + p('При HTTPS нельзя начинать с ответа приложения. Клиент сначала отправляет ClientHello, сервер выбирает параметры и предъявляет сертификат, затем стороны завершают рукопожатие. Проверка имени происходит относительно hostname, который клиент считает целевым. Если соединение идёт через proxy, отдельно фиксируйте имя proxy и имя origin: это две разные проверки.'), + p('Сертификат с правильной цепочкой, но неправильным SAN — ошибка имени. Сертификат с правильным SAN, который не доверен локальному хранилищу, — ошибка доверия. Сертификат с истёкшим сроком — ошибка времени. Эти причины нельзя объединять в «проблему SSL»: для каждой нужен собственный ожидаемый результат и свой владелец исправления.'), + h2('Кэш и промежуточный ответ'), + p('HTTP допускает intermediaries (посредников), а RFC 9110 отдельно описывает кэширование и маршрутизацию. Ответ может быть свежим объектом кэша, перенаправлением или сообщением origin. Проверяйте Age, Cache-Control, ETag, Via и время изменения тела, но не делайте вывод о кэше по одному заголовку: политика конкретного посредника может быть сложнее.'), + table('Набор безопасных полей для записи', ['Поле', 'Пример', 'Риск при сборе'], [ + ['method', 'GET', 'малый, если путь обезличен'], + ['path', '/orders', 'query может содержать секрет'], + ['status', '200', 'не показывает причину сам по себе'], + ['durationMs', '42', 'нужно знать часы и границы замера'], + ['requestId', 'local-001', 'нельзя принимать внешний id без правила'], + ['responseSize', '31', 'не заменяет проверку тела'], + ]), + h2('Порядок проверки по границам'), + ol([ + 'Зафиксировать имя, адрес и режим proxy отдельно; query-параметры очистить.', + 'Проверить DNS и TCP независимым клиентом, сохранив только код ошибки и длительность.', + 'Для HTTPS сверить hostname, SAN, цепочку и время действия сертификата.', + 'Снять HTTP-статус и выбранные заголовки, затем сопоставить request id с логом доверенного входа.', + 'Сравнить ответ с origin и кэшем, если между клиентом и приложением есть intermediary.', + 'Записать причину только на том уровне, который подтверждён наблюдением.', ]), - h2('HTTP semantics не доказывает транспорт'), - p('RFC 9110 задаёт semantics message components. Он помогает проверить будущую формулировку вида «какое значение status или field допустимо интерпретировать», но не отвечает, достигло ли сообщение origin и не показывает путь. Даже если future owner имеет HTTP response, статус сам по себе не описывает TLS handshake, name resolution, retry policy или действия intermediary. Следовательно, нельзя начинать с слова «HTTP error» и сразу менять trust or transport assumptions; сначала нужно отделить реальный message context от гипотезы о причинах.'), - p('RFC 9112 нужен, когда сам вопрос относится к HTTP/1.1 message framing. Он не является доказательством bytes конкретной сессии и не говорит, что некоторый header «сломал соединение». RFC 9114 аналогично не превращает knowledge об HTTP/3 в evidence, что выбранный клиент договорился о QUIC. Версия должна быть частью именованного вопроса, иначе одинаковая терминология начинает скрывать разные механизмы. Именно поэтому unnamed protocol/version literal не получает guessed fallback, а возвращает stop.'), - h2('TLS handshake не является HTTP response'), - p('RFC 8446 фиксирует TLS 1.3 protocol. Он позволяет описать, какие классы данных вообще относятся к handshake, но не подтверждает ни один конкретный parameter. Certificate validation, server-name policy, local trust anchors, session resumption и application request остаются разными объектами. У future investigation может быть несколько независимых ограничений, однако P111 не называет их значений. Попытка вставить сюда «обычный» endpoint или client option сразу превратила бы механизм в скрытый experiment.'), - p('Различение классов полезно и при будущей ошибке. Например, сообщение приложения о невозможности получить response не разрешает назвать certificate причиной; certificate-related failure не разрешает приписать HTTP status; mismatch в semantics не говорит, что TLS настроен неверно. Это не отрицание причинных цепочек — это отказ сокращать цепочку без материалов. Будущий owner должен документировать, какое observation поддерживает какой inference и где inference заканчивается. P111 передаёт именно это правило, а не готовый troubleshooting recipe.'), - h2('Скрытая конфигурация делает ошибку неразличимой'), - p('Command, endpoint и client configuration — часть evidence method, а не декоративное приложение. Но в P111 они даже не собираются. Когда их значения скрыты, сторонний reader не знает, какая версия протокола была запрошена, какая policy влияла на ход, что мог изменить proxy и какая команда сформировала output. Это не приглашение опубликовать их здесь: правильное состояние — command: not-run, endpoint/config/trace not-collected. Case hidden-command-endpoint-or-config-v1 fail-closed прекращает hand-off.'), - p('Отдельно запрещено выдумывать current behavior cURL или OpenSSL. У P111 нет exact pinned release material, который проверяет конкретную версию, build и option semantics; значит, текст не утверждает, как инструмент поведёт себя. Названия средств могут присутствовать только как очередь будущих средств, не как instruction. Такой предел спасает от самой коварной ошибки: читатель видит знакомую команду и принимает её как допустимую configuration для неизвестного production context.'), - h2('Runnable пример отказа'), - code("import { createFixedHttpTlsCase, assessFixedHttpTlsPlan } from './upgrade-2027-05.mjs';\n\nconst tooStrong = createFixedHttpTlsCase('evidence-stronger-than-input-v1');\nconst result = assessFixedHttpTlsPlan(tooStrong);\nconsole.log([result.status, result.productionEffect].join(' | '));\n// stop-evidence-stronger-than-input | not-attempted"), - p('В этом runnable fragment нет wire activity. Он берёт named in-memory literal, который JSON-clone factory отделила от caller и recursive deep freeze зафиксировала до оценки. Validator допускает только exactly known fixed inputs, поэтому произвольный object не может принести «наблюдение» вне списка. Вывод сообщает boundary модели, а не сетевую ошибку. Никакого cURL/OpenSSL subprocess, fetch, socket, certificate parser, file, environment или clock код не использует.'), - h2('Порядок будущего различения'), - ol(['Зафиксировать дату плана и cutoff; не называть будущую работу фактом.', 'Назвать protocol/version question и отделить HTTP semantics, framing, TLS и client policy.', 'Для каждого будущего observation заранее записать, какой inference он поддерживает, а какой нет.', 'Не принимать evidence, сильнее разрешённого input; claimed result в P111 всегда stop.', 'Не скрывать command, endpoint, client config или trace за пересказом; здесь все они not-run/not-collected.', 'Передать matrix будущему owner без diagnosis, remediation или positive network conclusion.' ]), - h2('Почему table не заменяет причинную запись'), - p('Матрица с четырьмя строками легко читается как классификатор, хотя ей нельзя быть классификатором. Она не получает строку лога и не возвращает label. Её задача уже: напомнить, что для каждого возможного утверждения нужна запись «вход → допустимое следствие → запрещённое следствие». Без среднего звена symptom превращается в вывод, а запрещённое следствие исчезает из обсуждения. Именно тогда привычные слова вроде «транспортная ошибка» начинают означать всё сразу.'), - p('В реальном authorised artefact причинная запись должна быть симметричной. Не только observation должен вести к hypothesis; hypothesis обязана перечислить, какой другой material мог бы её опровергнуть. Для HTTP semantics это может быть другой message context, для policy — иное правило клиента, для TLS — другой участок протокольных данных. P111 не перечисляет такие материалы по имени, чтобы не создать ложный collection plan. Он передаёт требование к форме будущей записи, не её заполненный экземпляр.'), - p('Особая польза этого подхода — отделить remediation от diagnosis. Даже верно определённый класс не предписывает, что надо менять: порядок исправления зависит от владельца, риска и contract вокруг системы. Поэтому phrase «значит, надо отключить проверку» здесь была бы одновременно неверным inference и опасной рекомендацией. Механизм останавливается раньше: он требует показать связь между разрешёнными входами и утверждением, после чего другой scope сможет обсуждать действие.'), - p('Граница useful и для коммуникации между ролями. Автор приложения может сформулировать expected semantics, security owner — правила доверия, platform owner — ограничения маршрута, а investigator — минимальный evidence contract. Ни одна роль не должна заполнять чужой столбец догадкой ради короткого ответа. В P111 эти роли не назначаются и не существует реального incident; matrix лишь показывает, что их утверждения имеют различный тип. Это снижает риск, что техническое слово будет принято за право сделать production change.'), h2('Ограничения и следующий шаг'), - p('Матрица не заменяет capture, security review, protocol implementation review или incident process. Она не классифицирует настоящие errors, не назначает владельца certificate и не выбирает точку наблюдения. Даже при появлении данных классы могут пересекаться: один application symptom способен иметь несколько независимых причин. Механизм полезен до сбора тем, что запрещает писать результат заранее и заставляет определить доказательную границу.'), - p('Следующий шаг — будущему owner создать отдельный scope с разрешённым input, version pins, redaction rules и явным цепочечным описанием observation → inference. Этот новый scope может использовать другие инструменты, но он не является продолжением P111 по умолчанию. До такого решения единственная корректная публикация — карта границ и synthetic hand-off. Она не делает TLS или HTTP проще; она делает честным место, где заканчивается знание.'), + p('Локальный обмен не показывает потери пакетов, балансировку, корпоративный proxy и особенности браузерного хранилища. Поле x-request-id в учебном сервере задано вручную; в настоящем сервисе его происхождение и доверенная зона должны быть описаны отдельно. Не смешивайте диагностическую метку с секретом или пользовательским идентификатором.'), + p('Следующий шаг — добавить к одной безопасной проверке два вывода: сетевой этап и серверный лог. Сначала докажите, что запрос достиг доверенного входа, потом связывайте его с handler. Такой порядок уменьшает область поиска и не заставляет приложение отвечать за сбой, произошедший раньше.'), ], refs); -const field = revision({ slug: 'editorial-2027-05-field-http-tls-guide', title: 'Полевой справочник HTTP и TLS: передача synthetic evidence', categories: ['Инженерные практики', 'Сети'], cover: '/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg', excerpt: 'План на май 2027: hand-off вопроса об HTTP/TLS без поддельного сетевого отчёта.', readingMinutes: 18 }, [ - p('Самая дорогая ошибка в полевой передаче — назвать вопрос отчётом: написать, что handshake проверен, header найден или ошибка воспроизведена, хотя получатель видит лишь пересказ. Такой артефакт быстро становится «фактом» в очереди, влияет на владельцев и может запустить ненужное изменение policy. Цена выше одной неверной заметки: provenance теряется, а будущая проверка вынуждена спорить с несуществующим evidence вместо того, чтобы начать с ясной постановки.'), - p('P111 не является network report. На редакторскую дату 2026-07-31 май 2027 ещё не наступил; это plan/scenario на 2027-05 с source cutoff 2026-07-31. Здесь не было endpoint, certificate, client config, cURL/OpenSSL run, HTTP trace, controlled handshake, production result или error. Field focus означает только synthetic evidence hand-off. Разрешённый positive outcome — synthetic-plan-hand-off, а productionEffect: not-attempted остаётся на любом пути.'), - h2('Передавать вопрос, состояние и предел'), - p('Хорошая synthetic карточка содержит не «что случилось», а что ещё нельзя утверждать. В ней есть planDate, sourceCutoff, named protocol/version question, evidence state и next action. Поле state: not-collected не значит «лог потеряли» и не намекает на частную проверку; оно означает, что P111 ничего не собирал. Поле strength: input-bounded не измеряет качество будущих данных, а запрещает hand-off стать богаче собственных literals. Это небольшой контракт, зато reader может проверить каждую его границу.'), - p('Предел особенно важен для команд и traces. Слова «попробуйте cURL», «снимите OpenSSL handshake» обычно выглядят безобидно, но в реальной сети они уже задают endpoint, client policy, traffic и способ хранения output. В этом выпуске tools помещены только в очередь будущего решения: command not-run, endpoint/clientConfig/trace not-collected. Мы не даём command line, не описываем certificate, не придумываем error и не сообщаем, что чей-то сервер принимает или отклоняет соединение.'), - figure('/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg', 'Петля передачи synthetic evidence: дата и named protocol/version question проходят fail-closed gates для evidence, command, endpoint, config и positive conclusion; только затем вопрос передаётся будущему owner.', 'Петля изображает in-memory validation и не является журналом сети, HTTP trace или результатом TLS handshake.'), - table('Контракт synthetic HTTP/TLS hand-off', ['Поле', 'Состояние в P111', 'Что получает owner', 'Чего это не доказывает'], [ - ['Время', '2027-05 / cutoff 2026-07-31', 'редакционную границу', 'что работа в мае выполнена'], - ['Вопрос', 'named synthetic protocol/version', 'объект уточнения', 'protocol choice или peer'], - ['Evidence', 'not-collected/input-bounded', 'предел inference', 'header, handshake или error'], - ['Средства', 'not-run/not-collected', 'список будущих ограничений', 'command/output/config'], - ['Вывод', 'synthetic-plan-hand-off', 'следующий decision point', 'success, remediation, production effect'], +const field = revision({ + slug: 'editorial-2027-05-field-http-tls-guide', + title: 'Как передавать сетевую ошибку: короткая запись без утечки токенов', + categories: ['Сети', 'Инженерные практики'], + cover: '/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg', + excerpt: 'Формат сетевой заметки, который помогает следующему инженеру продолжить проверку и не раскрывает секреты.', + readingMinutes: 14, +}, [ + p('Сетевая ошибка часто передаётся одной строкой: «HTTPS не работает». Цена такой записи — повторить тот же эксперимент, потерять hostname за маской или отправить в чат токен из вывода curl -v. Получателю нужны не все байты, а минимальный набор фактов, по которому можно выбрать следующий безопасный запрос.'), + p('Ниже соберём полевую карточку для одного обращения: отделим входные данные, наблюдение и гипотезу, затем применим простую очистку вывода. Учебный код принимает текст, удаляет секретные заголовки и сохраняет строки, необходимые для различения TLS и HTTP. Он не отправляет данные и не пишет файл.'), + h2('Карточка должна разделять факт и гипотезу'), + p('Факт — это то, что клиент действительно увидел: код, текст библиотеки, время соединения, имя узла и безопасная часть ответа. Гипотеза — «вероятно, просрочен сертификат» или «путь переписал proxy». Если смешать их в одном поле, следующий инженер примет предположение за результат и начнёт проверку с неверного уровня.'), + table('Поля сетевой карточки', ['Поле', 'Пример', 'Роль'], [ + ['Цель', 'api.example.test', 'какое имя проверяли'], + ['Метод и путь', 'GET /health', 'какой HTTP-контракт вызван'], + ['Этап', 'TLS или HTTP', 'где появился первый факт'], + ['Наблюдение', '404, тело missing', 'что вернул клиент'], + ['Гипотеза', 'маршрут не смонтирован', 'что проверить дальше'], + ['Следующий запрос', 'GET /health', 'ожидаемый результат'], ]), - h2('Provenance сильнее убедительного тона'), - p('У поля evidence должен быть не только label, но и допустимая сила. В сценарии incoming literal — единственный материал. Если evaluator вдруг возвращает certificate conclusion, конкретную protocol version или success claim, он делает output сильнее input. Именно такая подмена закрывается отдельным stop. Формулировка «кажется, это TLS» тоже не нейтральна: она приписывает классу будущей проверки больше, чем дано входом. Честная передача использует «требуется различить» вместо «обнаружено».'), - p('Fail-closed важнее гладкого hand-off. Недатированный кейс не получает текущую дату автоматически: иначе future plan тихо превращается в report. Неназванный protocol/version question не получает conventional default: иначе «HTTP» и «TLS» становятся фоновой догадкой. Скрытая command/endpoint/config не получает placeholder: иначе future owner примет метод как уже заданный. В каждом из этих случаев stop — не ошибка пользователя, а сохранение права пересобрать вопрос до доступа к сети.'), - h2('Стандарты задают vocabulary, не факт'), - p('Pinned RFC 9110, RFC 9112, RFC 9114 и RFC 8446 опубликованы до cutoff и служат первичными источниками vocabulary: semantics HTTP, HTTP/1.1 framing, HTTP/3 and QUIC mapping, TLS 1.3 handshake. Они не дают контекст конкретного клиента. Нельзя взять определение из RFC и выдать его за сведения о current deployment; нельзя узнать из него certificate chain или точный error. Такая разница кажется очевидной в лаборатории, но исчезает в краткой полевой заметке — поэтому здесь она записана явно.'), - p('P111 намеренно не утверждает current cURL/OpenSSL behavior. Для подобного утверждения нужен exact pinned release source и scope, который связывает release с конкретной допустимой конфигурацией; этого нет. Даже тогда documentation не была бы trace. Пока tools не разрешены, future owner получает не рецепт, а вопрос: какой инструмент вообще нужен, какие параметры являются чувствительными, какой output допустимо сохранить и что будет считаться недостаточным evidence.'), - h2('Runnable контракт hand-off'), - code("import { createFixedHttpTlsCase, assessFixedHttpTlsPlan } from './upgrade-2027-05.mjs';\n\nconst hiddenMethod = createFixedHttpTlsCase('hidden-command-endpoint-or-config-v1');\nconst result = assessFixedHttpTlsPlan(hiddenMethod);\nconsole.log({ status: result.status, effect: result.productionEffect });\n// { status: 'stop-hidden-command-endpoint-or-config', effect: 'not-attempted' }"), - p('Пример демонстрирует отказ, а не результат подключения. Все значения fixed и in-memory; JSON clone не даёт caller менять исходный nested object, deep freeze закрепляет protocolQuestion и evidence, fail-closed evaluator отклоняет unknown input. В модуле нет network library, shell invocation, HTTP parser, TLS implementation, file read или process configuration. Поэтому runnable behaviour можно проверить локально без того, чтобы создать trace или попытаться воздействовать на production.'), - h2('Порядок передачи'), - ol(['Передать дату 2027-05 и cutoff 2026-07-31 как обязательную границу знания.', 'Передать только named protocol/version question и statement, не добавляя endpoint, hostname, certificate или client name.', 'Передать evidence как not-collected/input-bounded; не превращать отсутствие данных в нейтральный success.', 'Явно оставить cURL/OpenSSL, commands, traces и configuration в состоянии not-run/not-collected.', 'Проверить fail-closed stops для undated case, unnamed question, stronger evidence, hidden method и positive conclusion.', 'Отдать future evidence owner next action без queue mutation, ticket, test report, remediation или production change.' ]), - h2('Минимальная карточка лучше ложной полноты'), - p('Полевой hand-off часто пытаются сделать полезным добавлением «кажется очевидных» деталей. Появляется пример host, предполагаемый port, название trust store, фрагмент команды или краткое описание certificate. Для будущего расследования это кажется экономией времени, но provenance у таких деталей нулевой: читатель не знает, увидел ли автор их, получил из документа или просто дополнил по памяти. В P111 минимальная карточка сознательно не содержит ни одного такого ускорителя.'), - p('Минимум не равен расплывчатости. Карточка обязана ясно назвать, что нужно различить, когда knowledge зафиксировано и какие состояния недопустимы. Поэтому fixed literal строже свободной заметки: у него есть exact id, date/cutoff, nested question, evidence state и disallowed conclusion. Неопределённость здесь структурирована. Owner не получит загадку «что-то с HTTPS»; он получит проверяемое ограничение, что пока нельзя рассказывать о peer, сообщении или инструменте.'), - p('Такой формат облегчает и отказ. Получатель может вернуть hand-off с формулировкой «вопрос не имеет достаточного решения о доступе» без дискуссии о якобы собранном trace. Он может попросить автора уточнить цель на уровне приложения, а не сети. Он может передать вопрос security owner, не распространяя endpoint. Все эти действия оставляют исходный artefact истинным. Ложный отчёт, напротив, заставляет либо верить ему, либо повторять неизвестную методику, что дороже и опаснее.'), - p('Карточку нельзя улучшать добавлением «вероятного» результата. Даже осторожное «ожидается нормальный handshake» создаёт асимметрию: следующий reader помнит прогноз, но не помнит, что evidence отсутствует. Аналогично phrase «должен вернуться header» звучит как план, но фактически обещает content неизвестного сообщения. В P111 будущему owner передаётся не ожидание output, а список условий, которые придётся определить до первого authorized observation. Это делает последующую проверку свободной отменить исходную гипотезу.'), - p('Синтетический hand-off полезно хранить рядом с границами, а не рядом с логами. Его читатель не ищет в нём bytes или timestamp; он ищет, какую claim нельзя сделать и кому передать решение. Когда реальный scope появится, его artefact должен ссылаться на P111 лишь как на предшествующую постановку, а не как на evidence. Такая связь оставляет историческую цепочку понятной: сначала была редакционная дисциплина, затем — отдельно разрешённый сбор, если он вообще окажется необходим.'), - p('Именно поэтому результат hand-off не измеряется количеством технических деталей. Его достаточно, когда другой человек может безопасно остановиться, сузить вопрос или открыть новый scope, не переписывая историю как будто проверка уже состоялась.'), - p('Если этого предела нет, заметка перестаёт быть передачей и становится неаудируемым обещанием: её нельзя ни проверить, ни корректно отозвать без потери доверия. Поэтому правильная краткость здесь ценнее демонстративной технической полноты.'), - h2('Что owner решает вне P111'), - p('Если будущий владелец примет hand-off, ему понадобятся отдельные решения: нужен ли вообще сетевой scope, кто авторизует target, какая минимальная configuration допустима, какие поля trace нельзя сохранять, как redaction влияет на inference и кто проверяет вывод. Может оказаться, что вопрос надо закрыть без запуска, потому что нужный факт есть в другом авторитетном artefact или риск сбора не оправдан. P111 не подталкивает к инструменту и не создаёт обязанность «довести проверку до успеха».'), - p('Следующий шаг — завести новый, явно авторизованный artefact только если owner выберет это действие. Он должен иметь собственную дату, source set, method, limits и result policy; его facts нельзя ретроспективно добавить в P111. До такого шага текущий итог уже полный: передан честно ограниченный вопрос. Полезность field hand-off не в том, что он производит network result, а в том, что он не даёт отсутствующему результату управлять последующими решениями.'), + figure('/assets/editorial/2027/http-tls-guide-2027-evidence-handoff-loop.svg', 'Цикл сетевой диагностики: безопасный сбор, классификация этапа, проверка гипотезы и повторяемая запись результата.', 'Схема показывает круг работы с одной ошибкой. На каждом переходе остаётся только нужный факт, а секретные поля отбрасываются до передачи.'), + h2('Почему вывод curl требует очистки'), + p('Подробный вывод полезен для порядка рукопожатия, редиректов и заголовков, но в нём могут оказаться Authorization, cookie, query-параметры и внутренние адреса. Маскирование должно работать до копирования в задачу или чат. Не полагайтесь на память: человек легко пропустит строку, если вывод длинный.'), + p('Отдельно проверьте заголовки proxy-аутентификации и значения после редиректа: очистка только строки Authorization не покрывает все каналы секрета. В карточке оставьте имя поля и замените значение целиком, чтобы получатель понимал, какой тип аутентификации был задействован.'), + p('Очистка не должна удалять метод, статус и имя заголовка. Иначе получатель увидит «что-то с HTTP», но не сможет различить 401 и 403. Значение Authorization заменяем целиком, cookie очищаем по имени, а query оставляем только после удаления секретных ключей. Для персональных данных нужен отдельный список полей.'), + h2('Учебный санитайзер текстового вывода'), + p('Входом функции является обычная многострочная строка. Ожидаемый результат — тот же порядок строк, но значение Authorization и Cookie заменены. Пример запускается без сети; он показывает обработку диагностического artefact, а не проверяет сертификат.'), + code([ + 'function redactNetworkOutput(text) {', + ' return text', + " .replace(/(Authorization:\\s*Bearer\\s+)[^\\s]+/gi, '$1[masked]')", + " .replace(/(Cookie:\\s*)[^\\n]+/gi, '$1[masked]')", + " .replace(/([?&](?:token|secret|signature)=)[^&\\s]+/gi, '$1[masked]');", + '}', + '', + "const raw = 'GET /health?token=abc HTTP/1.1\\nAuthorization: Bearer abc\\nCookie: sid=xyz';", + 'console.log(redactNetworkOutput(raw));', + '// GET /health?token=[masked] HTTP/1.1', + '// Authorization: Bearer [masked]', + '// Cookie: [masked]', + ].join('\n')), + p('Проверка результата здесь буквальная: в трёх строках не осталось исходных значений, а имена полей сохранились. Регулярное выражение учебное и намеренно ограниченное. Оно не понимает бинарные данные, нестандартное форматирование и секреты в произвольном JSON, поэтому перед передачей нужен отдельный просмотр очищенного вывода.'), + h2('Сохраняем причинную цепочку'), + p('Хорошая карточка отвечает на четыре вопроса. Что вызвали? Где остановился клиент? Что именно получено? Какой следующий запрос отличит две гипотезы? Например: «GET /health, TLS завершён, HTTP 503 и Retry-After: 2, следующий шаг — повторить GET через две секунды и сравнить зависимость». Это уже проверяемый маршрут, а не комментарий «сервер тормозит».'), + p('Не нужно прикладывать полный дамп, если достаточно нескольких строк. При этом нельзя вырезать контекст, который меняет смысл: hostname, порт, метод и статус должны остаться. Время указывайте вместе с часовым поясом, а длительность — с единицей измерения. Если был proxy, напишите это явно, иначе получатель будет считать соединение прямым.'), + h2('Когда 401, 403 и 404 похожи'), + table('Статус и следующий вопрос', ['Статус', 'Наблюдаемая семантика', 'Проверка'], [ + ['401', 'нужна аутентификация или она не принята', 'какой challenge вернул сервер'], + ['403', 'сервер понял запрос, но отказывает', 'правило доступа и origin запроса'], + ['404', 'ресурс не найден по выбранному маршруту', 'путь, метод и версия API'], + ['405', 'метод не разрешён для ресурса', 'Allow и контракт метода'], + ]), + p('Текст страницы может быть одинаковым у разных кодов, особенно на proxy. Поэтому в карточке статус важнее заголовка «access denied». RFC 9110 описывает классы статусов и методы, но не знает правила конкретного приложения. Источник задаёт язык сообщения; фактическая причина появляется только из вашего наблюдения и сопоставленного лога.'), + h2('Порядок безопасной передачи'), + ol([ + 'Скопировать исходный вывод во временную локальную область, не отправляя его в общий канал.', + 'Удалить Authorization, Cookie, токены в query и внутренние персональные значения.', + 'Оставить hostname, порт, метод, путь без секретных параметров, этап, статус и длительность.', + 'Отделить наблюдение от гипотезы и не называть гипотезу причиной.', + 'Сформулировать один следующий запрос и его ожидаемый ответ.', + 'После повторной проверки заменить гипотезу новым фактом или явно сохранить её как неподтверждённую.', + ]), + h2('Ограничения и следующий шаг'), + p('Санитайзер не является системой управления секретами и не гарантирует, что неизвестный формат не содержит чувствительных данных. Не вставляйте очищенный текст в публичный issue без проверки. Для постоянной диагностики лучше использовать структурированные поля с allowlist, чем регулярно маскировать свободный текст.'), + p('Следующим шагом заведите шаблон карточки в репозитории: цель, метод, этап, статус, длительность, гипотеза и ожидаемый результат. Добавьте к нему локальный тест на маскирование трёх известных секретов и один отрицательный пример. Тогда следующая ошибка будет начинаться с проверяемого входа, а не с повторного поиска контекста.'), ], refs); export const revisions = deepFreeze([practice, mechanism, field]); -export function verifyRevisionsAgainstFixture() { const fixture = runFixedHttpTlsFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|потер)/i.test(text.slice(0, 1100)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-05/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +export function verifyRevisionsAgainstFixture() { + const fixture = runFixedHttpTlsFixture(); + const articleChecks = revisions.map((item) => { + const text = bodyText(item.contentHtml); + return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml); + }); + return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); +} + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-06.mjs b/web/scripts/upgrade-2027-06.mjs index c243997..e6b1788 100644 --- a/web/scripts/upgrade-2027-06.mjs +++ b/web/scripts/upgrade-2027-06.mjs @@ -1,211 +1,315 @@ -function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +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 figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } +function cloneFixed(value) { + return JSON.parse(JSON.stringify(value)); +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(deepFreeze); + Object.freeze(value); + } + return value; +} + +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function bodyText(html) { + return plainText(html.replace(/

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

|$)/, '')); +} const REFERENCES = deepFreeze({ - timeline: { title: 'W3C Performance Timeline Level 2', url: 'https://www.w3.org/TR/2019/WD-performance-timeline-2-20190329/', version: 'W3C Working Draft, dated snapshot 2019-03-29' }, - navigation: { title: 'W3C Navigation Timing Level 2', url: 'https://www.w3.org/TR/2026/WD-navigation-timing-2-20260225/', version: 'W3C Working Draft, 2026-02-25' }, - resource: { title: 'W3C Resource Timing', url: 'https://www.w3.org/TR/2026/CRD-resource-timing-20260420/', version: 'W3C Candidate Recommendation Draft, 2026-04-20' }, - longtasks: { title: 'W3C Long Tasks API', url: 'https://www.w3.org/TR/2026/WD-longtasks-1-20260319/', version: 'W3C Working Draft, 2026-03-19' }, + navigation: { title: 'W3C Navigation Timing Level 2', version: 'Working Draft, 25 February 2026', url: 'https://www.w3.org/TR/2026/WD-navigation-timing-2-20260225/' }, + performance: { title: 'W3C Performance Timeline', version: 'Candidate Recommendation Draft, 21 May 2025', url: 'https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/' }, + resource: { title: 'W3C Resource Timing', version: 'Candidate Recommendation Draft, 20 April 2026', url: 'https://www.w3.org/TR/2026/CRD-resource-timing-20260420/' }, }); -function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } + +function sources(entries) { + return '
    ' + entries.map(({ key, use, boundary }) => { + const ref = REFERENCES[key]; + return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '
  • '; + }).join('') + '
'; +} const FIXED_CRITICAL_PATH_CASES = deepFreeze({ - 'critical-path-contract-2027-06-v1': { id: 'critical-path-contract-2027-06-v1', planDate: '2027-06', sourceCutoff: '2026-07-31', contract: { id: 'render-blocking-order-contract', revision: 'v1', decision: 'future-question-only' }, comparableConditions: { browserBuild: 'not-collected', deviceClass: 'not-collected', cacheState: 'not-collected', routeState: 'not-collected' }, evidence: { state: 'not-collected', benchmark: 'not-attempted', telemetry: 'not-collected', profile: 'not-collected' }, requestedOutcome: 'synthetic-plan-hand-off', boundary: 'Fixed in-memory plan literal only. It does not read, create, change, infer or contact files, network, environment, clock, secrets, telemetry, systems, services, browsers, profiles, benchmarks or production data.' }, - 'missing-june-date-or-cutoff-v1': { id: 'missing-june-date-or-cutoff-v1', planDate: '', sourceCutoff: '2026-07-31', contract: { id: 'render-blocking-order-contract', revision: 'v1', decision: 'future-question-only' }, comparableConditions: { browserBuild: 'not-collected', deviceClass: 'not-collected', cacheState: 'not-collected', routeState: 'not-collected' }, evidence: { state: 'not-collected', benchmark: 'not-attempted', telemetry: 'not-collected', profile: 'not-collected' }, requestedOutcome: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'unnamed-path-contract-v1': { id: 'unnamed-path-contract-v1', planDate: '2027-06', sourceCutoff: '2026-07-31', contract: { id: '', revision: '', decision: 'future-question-only' }, comparableConditions: { browserBuild: 'not-collected', deviceClass: 'not-collected', cacheState: 'not-collected', routeState: 'not-collected' }, evidence: { state: 'not-collected', benchmark: 'not-attempted', telemetry: 'not-collected', profile: 'not-collected' }, requestedOutcome: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'conditions-claimed-without-record-v1': { id: 'conditions-claimed-without-record-v1', planDate: '2027-06', sourceCutoff: '2026-07-31', contract: { id: 'render-blocking-order-contract', revision: 'v1', decision: 'future-question-only' }, comparableConditions: { browserBuild: 'assumed', deviceClass: 'not-collected', cacheState: 'not-collected', routeState: 'not-collected' }, evidence: { state: 'not-collected', benchmark: 'not-attempted', telemetry: 'not-collected', profile: 'not-collected' }, requestedOutcome: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'observation-injected-into-plan-v1': { id: 'observation-injected-into-plan-v1', planDate: '2027-06', sourceCutoff: '2026-07-31', contract: { id: 'render-blocking-order-contract', revision: 'v1', decision: 'future-question-only' }, comparableConditions: { browserBuild: 'not-collected', deviceClass: 'not-collected', cacheState: 'not-collected', routeState: 'not-collected' }, evidence: { state: 'claimed', benchmark: 'completed', telemetry: 'not-collected', profile: 'not-collected' }, requestedOutcome: 'synthetic-plan-hand-off', boundary: 'Negative fixed literal only.' }, - 'result-substitution-v1': { id: 'result-substitution-v1', planDate: '2027-06', sourceCutoff: '2026-07-31', contract: { id: 'render-blocking-order-contract', revision: 'v1', decision: 'future-question-only' }, comparableConditions: { browserBuild: 'not-collected', deviceClass: 'not-collected', cacheState: 'not-collected', routeState: 'not-collected' }, evidence: { state: 'not-collected', benchmark: 'not-attempted', telemetry: 'not-collected', profile: 'not-collected' }, requestedOutcome: 'winner-selected', boundary: 'Negative fixed literal only.' }, + fast: { ttfb: 80, html: 140, blocking: 1 }, + slowServer: { ttfb: 420, html: 150, blocking: 1 }, + slowScript: { ttfb: 90, html: 150, blocking: 4 }, + invalid: { ttfb: -1, html: 0, blocking: 0 }, }); -export function createCriticalPathPlan(id = 'critical-path-contract-2027-06-v1') { const value = FIXED_CRITICAL_PATH_CASES[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } +export function createCriticalPathPlan(id = 'fast') { + return FIXED_CRITICAL_PATH_CASES[id] ? deepFreeze(cloneFixed(FIXED_CRITICAL_PATH_CASES[id])) : undefined; +} + export function assessCriticalPathPlan(input) { - if (!Object.values(FIXED_CRITICAL_PATH_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input))) return stop('stop-unknown-fixed-literal', 'input-is-not-an-exact-named-fixed-literal', 'select-a-named-synthetic-case'); - if (input.planDate !== '2027-06' || input.sourceCutoff !== '2026-07-31') return stop('stop-missing-june-date-or-cutoff', 'june-2027-plan-and-july-2026-cutoff-are-required', 'name-the-plan-date-and-source-cutoff'); - if (!input.contract?.id || !input.contract?.revision || input.contract?.decision !== 'future-question-only') return stop('stop-unnamed-path-contract', 'the-critical-path-question-must-have-a-named-revision', 'name-a-synthetic-contract'); - if (Object.values(input.comparableConditions || {}).some((value) => value !== 'not-collected')) return stop('stop-conditions-claimed-without-record', 'conditions-cannot-be-assumed-or-claimed', 'keep-all-conditions-not-collected'); - if (input.evidence?.state !== 'not-collected' || input.evidence?.benchmark !== 'not-attempted' || input.evidence?.telemetry !== 'not-collected' || input.evidence?.profile !== 'not-collected') return stop('stop-observation-injected-into-plan', 'benchmark-telemetry-and-profile-must-not-be-created-or-claimed', 'retain-the-unobserved-state'); - if (input.requestedOutcome !== 'synthetic-plan-hand-off') return stop('stop-result-substitution', 'a-winner-bottleneck-rollout-or-production-effect-is-not-a-plan-output', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', caseId: input.id, contract: deepFreeze(cloneFixed(input.contract)), comparableConditions: deepFreeze(cloneFixed(input.comparableConditions)), evidence: deepFreeze(cloneFixed(input.evidence)), boundary: input.boundary, productionEffect: 'not-attempted', nextAction: 'open-a-new-authorised-scope-before-any-observation' }); + if (!input || typeof input !== 'object' || input.ttfb < 0) return { status: 'stop', cause: 'invalid-measurement', action: 'проверить единицы и начало замера' }; + if (input.ttfb > 300) return { status: 'check-server', cause: 'server-wait', action: 'разделить TTFB и время загрузки HTML' }; + if (input.blocking > 2) return { status: 'check-render-blocking', cause: 'blocking-resources', action: 'найти CSS и script, блокирующие путь' }; + return { status: 'baseline', cause: 'no-dominant-signal', action: 'проверить ресурсы и главный поток' }; +} + +export function runCriticalPathFixture() { + const checks = [ + ['fast', 'baseline'], + ['slowServer', 'check-server'], + ['slowScript', 'check-render-blocking'], + ['invalid', 'stop'], + ].map(([id, expected]) => ({ id, expected, actual: assessCriticalPathPlan(createCriticalPathPlan(id)).status })); + return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks }); +} + +function revision(meta, parts, referenceEntries) { + const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); + return deepFreeze({ ...meta, contentHtml, proseLength }); } -export function runCriticalPathFixture() { const expected = [['critical-path-contract-2027-06-v1', 'synthetic-plan-hand-off'], ['missing-june-date-or-cutoff-v1', 'stop-missing-june-date-or-cutoff'], ['unnamed-path-contract-v1', 'stop-unnamed-path-contract'], ['conditions-claimed-without-record-v1', 'stop-conditions-claimed-without-record'], ['observation-injected-into-plan-v1', 'stop-observation-injected-into-plan'], ['result-substitution-v1', 'stop-result-substitution']]; const checks = expected.map(([id, expectedStatus]) => ({ id, expectedStatus, actualStatus: assessCriticalPathPlan(createCriticalPathPlan(id)).status })); const sample = createCriticalPathPlan(); return deepFreeze({ passed: checks.filter((item) => item.expectedStatus === item.actualStatus).length, total: checks.length, accepted: checks.every((item) => item.expectedStatus === item.actualStatus) && Object.isFrozen(sample) && Object.isFrozen(sample.contract) && Object.isFrozen(sample.evidence), checks: deepFreeze(checks) }); } -function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 9000 || proseLength > 12000) throw new Error(meta.slug + ': P112 body target 9000-12000, got ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } -const practiceBoundary = 'Выпуск помечен как план/сценарий 2027-06; редактор зафиксировал знания на 2026-07-31. Никаких будущих benchmark, telemetry, profile, bottleneck, rollout, winner либо production effect здесь не существует. Fixed literal может завершиться только synthetic-plan-hand-off, причём productionEffect: not-attempted — часть его контракта.'; -const mechanismBoundary = 'Дата сценария — 2027-06, а верхняя граница источников — 2026-07-31. Поэтому эта механика не сообщает profile, telemetry, benchmark, bottleneck, winner, rollout или эффект в production. Позитивный verdict один: synthetic-plan-hand-off с неизменным полем productionEffect: not-attempted.'; -const fieldBoundary = 'Это будущая карточка июня 2027, подготовленная при cutoff 2026-07-31. В ней намеренно отсутствуют telemetry, profile, benchmark, bottleneck, rollout, winner и production effect. Разрешён не отчёт, а один outcome: synthetic-plan-hand-off, где productionEffect: not-attempted фиксирует отсутствие вмешательства.'; const refs = [ - { key: 'timeline', use: 'Задаёт первичный vocabulary PerformanceEntry и PerformanceObserver для различения типов будущих evidence entries.', boundary: 'Датированный snapshot описывает API, но не подтверждает существование entry, путь рендеринга или результат в неизвестном приложении.' }, - { key: 'navigation', use: 'Описывает интерфейс навигационных timing entries и их границы.', boundary: 'Working Draft не создаёт запись, замер или benchmark для будущего выпуска.' }, - { key: 'resource', use: 'Задаёт vocabulary Resource Timing и ограничения видимости ресурсов.', boundary: 'Спецификация не делает условия двух запусков сопоставимыми и не выявляет bottleneck.' }, - { key: 'longtasks', use: 'Разделяет long task и блокировку очереди главного потока.', boundary: 'Working Draft не доказывает наличие long task, profile или причину задержки в неизвестном продукте.' }, + { key: 'navigation', use: 'Определяет навигационные временные метки и связь между этапами загрузки документа.', boundary: 'Рабочий черновик стандарта не сообщает значения метрик вашего браузера и не заменяет полевой сбор.' }, + { key: 'performance', use: 'Определяет PerformanceEntry, getEntries и PerformanceObserver как основу чтения метрик.', boundary: 'Спецификация не задаёт пороги качества и не гарантирует одинаковый набор entry во всех движках.' }, + { key: 'resource', use: 'Определяет записи ресурсов, размеры и render-blocking status для сетевого слоя страницы.', boundary: 'Не описывает серверную архитектуру, пользовательский опыт целиком или причинность конкретной задержки.' }, ]; -const practice = revision({ slug: 'editorial-2027-06-practice-performance-capstone', title: 'Большой разбор производительности: договор о критическом пути', categories: ['Frontend', 'Инженерные практики'], cover: '/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg', excerpt: 'План на июнь 2027: сначала оформить договор о критическом пути, затем открывать scope для наблюдений.', readingMinutes: 22 }, [ - h2('Сначала договор, потом измерение'), - p('Проблема большого разбора производительности появляется ещё до инструмента: одна группа говорит «страница медленная», другая — «долго отвечает API», третья показывает тяжёлый скрипт. Это три разных вопроса. Цена смешения — недели спорных правок и опасный «фикс», который меняет путь пользователя, но не имеет проверяемого критерия.'), - p(practiceBoundary + ' Поэтому практика P112 не выбирает способ ускорения и не ищет виновника. Она оформляет только договор: какой переход будет предметом будущего вопроса, какие условия надо назвать и какие выводы запрещены до отдельного authorised scope.'), - p('Критический путь здесь не лозунг «сделать быстрее». Это именованная цепочка от начала навигации до выбранного доступного состояния, где каждый участок имеет владельца и тип доказательства. Слово «выбранного» важно: первый визуальный пиксель, доступность действия и завершение фоновой работы не обязаны совпадать. Если команда не выбрала один переход, она неизбежно сравнит разные вещи.'), - figure('/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg', 'Схема договора: вопрос о критическом пути проходит через границы навигации, ресурсов, главного потока и доступного действия, а затем передаётся без измеренного вывода.', 'Договор не рисует реальную трассу: он показывает, какие границы будущий владелец обязан назвать до первого наблюдения.'), - h2('Что именно фиксирует договор'), - p('У договора четыре поля. Первое — наблюдаемое состояние, но не название метрики: например, «пользователь может совершить действие X» пока остаётся будущим вопросом, а не фактом. Второе — стартовая граница: navigation, route transition или другое явно выбранное событие. Третье — список условий сравнимости. Четвёртое — запрет на вывод: нельзя назвать bottleneck, выигравший вариант или production effect из пустой карточки.'), - p('Эта форма намеренно скупа. В ней нет URL, имени сервиса, записи профиля, снимка telemetry и «примерно такого» устройства. Такие детали выглядят полезными, но без provenance превращают документ в неявное свидетельство. Для июньского plan-сценария корректнее оставить их not-collected, чем создать правдоподобный, но несуществующий контекст.'), - table('Поля договора о критическом пути', ['Поле', 'Что можно назвать сейчас', 'Что запрещено подставлять'], [ - ['Вопрос', 'named contract id и revision', 'готовый диагноз или «узкое место»'], - ['Граница пути', 'тип перехода: navigation / resource / main-thread / action', 'реальный timestamp или trace'], - ['Условия', 'набор обязательных полей', 'browser build, cache или device «по умолчанию»'], - ['Доказательство', 'not-collected и not-attempted', 'benchmark, telemetry, profile или эффект в production'], +const practice = revision({ + slug: 'editorial-2027-06-practice-performance-capstone', + title: 'Медленная первая загрузка: отделяем TTFB от блокирующих ресурсов', + categories: ['Frontend', 'Производительность'], + cover: '/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg', + excerpt: 'Практический разбор критического пути: что измерить, прежде чем менять JavaScript и CSS.', + readingMinutes: 14, +}, [ + p('Пользователь видит пустой экран, а команда получает только одну цифру «страница грузится 2,4 секунды». Цена ошибки — оптимизировать bundle, когда 1,6 секунды ушло до первого байта HTML, или ускорить сервер, когда браузер ждёт блокирующий CSS. Эти причины требуют разных владельцев и разных изменений.'), + p('Разделим путь на три наблюдения: время до первого байта, получение HTML и блокировку от ресурсов. Учебный локальный сервер намеренно задаёт задержку, поэтому результат легко повторить. В браузере дополнительно прочитаем Navigation Timing и Resource Timing, но не будем выдавать одну метрику за пользовательский опыт.'), + h2('Критический путь начинается до JavaScript'), + p('Браузер не может построить документ, пока не получил HTML. После этого parser встречает CSS, обычные script и изображения, которые меняют порядок работы. TTFB (time to first byte) отвечает только за ожидание первого байта ответа. Он не включает весь HTML и тем более не описывает время до полезного пикселя.'), + table('Разделяем время загрузки', ['Участок', 'Метрика', 'Что проверяем'], [ + ['До ответа', 'responseStart − requestStart', 'сервер, сеть, proxy'], + ['HTML', 'responseEnd − responseStart', 'размер и передача документа'], + ['Ресурс', 'responseEnd − startTime', 'CSS, script, font, image'], + ['Главный поток', 'long task или task duration', 'парсинг и выполнение JS'], ]), - h2('Почему путь нельзя заменить одной метрикой'), - p('Navigation Timing описывает запись навигации, Resource Timing — записи ресурсов, а Long Tasks API — отдельный класс работы главного потока. Их интерфейсы полезны именно различием. Они не дают лицензии сложить несопоставимые числа в одно «время страницы». Один участок может отсутствовать, быть ограничен политикой происхождения или относиться к другой фазе пользовательского пути.'), - p('Практическая ошибка — выбрать известное поле потому, что оно легко экспортируется, и выдать его за цель. Тогда состояние «пользователь может продолжить» заменяется технической прокладкой. В будущем это может быть разумная proxy-метрика, но связь надо обосновать в новом scope: назвать UI-событие, вариант маршрута, условие кэша и способ проверить расхождение. P112 не делает такую связь задним числом.'), - p('Договор полезен и владельцу интерфейса, и владельцу платформы. Первый видит, какое действие нельзя потерять за техническим жаргоном. Второй видит, что от него ждут не абстрактной оптимизации, а ограничения на будущий сбор. Оба могут возразить до запуска: изменить границу, добавить required condition или признать, что вопрос слишком широк. Это дешевле, чем спорить после цифры, происхождение которой никто не может восстановить.'), - h2('Граница задаёт единицу работы'), - p('Navigation boundary — не синоним «пользователь открыл страницу». В договоре это только названная точка, от которой будущий исследователь вправе начать отсчёт. Для документа она может быть типом навигации, а для перехода внутри интерфейса — отдельной границей, которую нельзя молча назвать navigation. Если команда меняет стартовую точку, она меняет единицу работы; прежнее сравнение перестаёт отвечать на тот же вопрос.'), - p('Resource boundary отвечает не за весь экран, а за конкретный класс будущих записей. У неё должна быть причина попадания в договор: ресурс нужен для выбранного состояния, а его видимость допускает будущий authorised method. Список «всего, что грузится» не является границей. Он смешивает необходимые и фоновые операции, а затем заставляет владельца объяснять результат, который договор изначально не выделил.'), - p('Main-thread boundary относится к очереди работы, а не к внешнему ресурсу. Даже если будущий артефакт когда-либо опишет длительную задачу, из этого не следует, что задача отвечает за недоступность нужного действия. В контракте полезно отдельно назвать: ищем ли мы связь между очередью и действием или только проверяем, не выбран ли неверный слой. Эта оговорка защищает от автоматического перехода «есть задача» → «найдено объяснение». '), - p('Action boundary — единственная граница, которую читает пользователь. Она не обязана совпасть ни с окончанием navigation, ни с последним resource entry, ни с освобождением главного потока. Её формулируют через возможность выполнить одно действие без догадки о реализации: например, контроль должен иметь определённое состояние и принять допустимый ввод. Пока такое правило не названо, техническая величина остаётся лишь кандидатурой на proxy, а не итогом пути.'), - h2('Контракт на единицу работы и ownership'), - p('Единица работы полезна, когда её можно передать без устного контекста. В P112 она состоит из id вопроса, revision, стартовой и конечной границ, перечня обязательных conditions, владельца следующего решения и стоп-условия. Это не запись будущего запуска. Все поля описывают, что потребуется определить позже, и потому не содержат route, URL, сервис, идентификатор пользователя или фактический объект браузера.'), - table('Кому принадлежит элемент договора', ['Элемент', 'Что обязан сделать владелец', 'Что не может сделать этим документом'], [ - ['Product/UI owner', 'Уточнить проверяемое доступное действие', 'объявить технический proxy пользовательским результатом'], - ['Frontend owner', 'Назвать границу интерфейсной единицы работы', 'приписать ей будущий resource или main-thread факт'], - ['Evidence owner', 'Запросить новый scope и политику сбора', 'собрать или интерпретировать данные из P112'], - ['Review owner', 'Проверить полноту контракта и stop path', 'выбрать оптимизацию или разрешить rollout'], + figure('/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg', 'Критический путь страницы от запроса до главного содержимого: серверная задержка, HTML, CSS и JavaScript.', 'Схема разделяет участки времени. Рядом с каждой задержкой нужен собственный замер, иначе исправление выбирается по догадке.'), + h2('Локальный сервер с двумя режимами'), + p('Вход примера — query-параметр mode. Для slow сервер ждёт 300 миллисекунд перед заголовками, для fast отвечает сразу. Клиент измеряет время до получения тела. Ожидаемый результат: медленный режим имеет больший TTFB, а не «плохой JavaScript». Запуск безопасен: сервер слушает только loopback.'), + code([ + "import { performance } from 'node:perf_hooks';", + "import { createServer as createHttpServer } from 'node:http';", + '', + "const server = createHttpServer((request, response) => {", + " const delay = new URL(request.url, 'http://local').searchParams.get('mode') === 'slow' ? 300 : 0;", + ' setTimeout(() => {', + " response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });", + " response.end('
ready
');", + ' }, delay);', + '});', + '', + "server.listen({ host: '127.0.0.1', port: 0 }, async () => {", + ' const address = server.address();', + ' const port = address.port;', + ' for (const mode of [\'fast\', \'slow\']) {', + ' const started = performance.now();', + " const response = await fetch('http://127.0.0.1:' + port + '/?mode=' + mode);", + ' await response.text();', + " console.log(mode, Math.round(performance.now() - started), response.status);", + ' }', + ' server.close();', + '});', + ].join('\n')), + p('Ожидаемый вывод — две строки со статусом 200, причём slow примерно на 300 миллисекунд больше. Точное число зависит от машины, поэтому сравниваем режимы в одном запуске. Так появляется проверяемый симптом: различие находится на серверном ожидании. Для browser-показателей нужен браузерный прогон с тем же HTML и набором ресурсов.'), + h2('Что читать в Navigation Timing'), + p('Объект PerformanceNavigationTiming содержит временные точки навигации. Для диагностического сообщения достаточно вывести requestStart, responseStart, responseEnd и domContentLoadedEventEnd. Вычитание даёт длительность участка, но абсолютное число нужно сопоставлять с условиями: cache, сеть, устройство и режим браузера меняют картину.'), + p('Не называйте domContentLoaded временем готовности экрана. Событие означает завершение разбора DOM и отложенных скриптов, но изображения и отрисовка могут продолжаться. FCP (first contentful paint) и LCP (largest contentful paint) относятся к визуальному результату, поэтому TTFB и DOMContentLoaded нельзя использовать как их замену. Для пользовательского симптома нужен отдельный критерий визуальной готовности.'), + h2('Блокирующие ресурсы и компромисс'), + p('Обычный CSS блокирует построение стилей, а синхронный script может остановить parser. Перенести всё в async недостаточно: порядок выполнения изменится, а код, который ожидает DOM или глобальную библиотеку, может сломаться. defer сохраняет порядок отложенных скриптов, но не убирает размер передачи.'), + p('Поле renderBlockingStatus полезно читать только при поддержке конкретного браузера. Отсутствующее значение означает «нет данных», а не «ресурс точно не блокирует». Поэтому сверяйте запись с HTML-атрибутами и наблюдаемым изменением первого экрана.'), + table('Выбор действия по наблюдению', ['Наблюдение', 'Действие', 'Цена'], [ + ['высокий TTFB', 'профилировать серверный handler', 'нагрузка на backend и зависимости'], + ['большой HTML', 'сжать или уменьшить ответ', 'сложнее шаблон и кэширование'], + ['ранний блокирующий CSS', 'разделить critical и остальное', 'риск рассинхрона стилей'], + ['долгий script', 'разбить работу или отложить', 'сложнее порядок и состояние'], ]), - p('Ownership не означает, что один человек «владеет производительностью». Он означает, что у каждой спорной формулировки есть адресат до того, как появится какой-либо артефакт. Если owner действия не согласен с action boundary, нельзя компенсировать это более подробным техническим описанием. Если evidence owner не может принять data policy, нельзя заменить авторизацию предположением. Контракт удерживает эти отказы на правильной стороне будущей работы.'), - p('Полезный договор также указывает, кому принадлежит отмена вопроса. Когда действие уже нельзя определить без продукта, когда путь касается нескольких несогласованных интерфейсов или когда запрашиваемое доказательство недоступно по политике, owner может вернуть карточку в stop. Это не потеря темпа. Это прекращение задачи, у которой ещё нет безопасной единицы работы и, следовательно, нет критерия корректности.'), - h2('Контрпример: правдоподобный proxy ломает вопрос'), - p('Возьмём учебный, неисполняемый сценарий. Команда выбрала раннюю техническую границу и назвала её «готовностью». Однако целевое действие требует ещё одного логического состояния интерфейса, которое не входит в эту границу. Даже если будущая проверка когда-нибудь увидит раннее техническое событие, она не сможет из него вывести доступность действия. Сценарий не описывает существующий продукт; он показывает, почему имя поля не должно заменять договор.'), - p('Обратная ошибка тоже возможна: взять позднюю завершённость всей активности и считать её обязательной для действия. Тогда фоновые процессы, не влияющие на выбранный контроль, искусственно удлиняют путь. Вывод «надо ускорять всё» выглядит осторожным, но у него нет предмета: он не говорит, какая единица работы должна стать другой. В таком состоянии нельзя честно выбрать ни owner, ни будущий evidence artefact.'), - p('Контрпример полезен тем, что проверяет договор до инструмента. Если команда не может объяснить, почему выбранная граница необходима и достаточна для action boundary, она не готова к сбору. Вместо поиска более удобной метрики нужно переписать вопрос: отделить действие от proxy, назвать условия или отказаться от прежней постановки. Это и есть экономия: не исправлять чужую интерпретацию после того, как она уже стала решением.'), - h2('Как остановить неверную постановку'), - p('Stop нужен не только evaluator. На редакционном уровне постановку останавливают четыре признака: одна фраза одновременно называет navigation и action; resource список не имеет роли в выбранном состоянии; main-thread claim уже содержит причину; owner не может назвать, что отменит вопрос. Каждый признак означает не «недостаточно деталей», а то, что контракт склеил разные типы работы. Добавление ещё одного поля не лечит такую ошибку.'), - p('Практический маршрут остановки короткий: вернуть карточку автору вопроса, сохранить только named contract и дату/cutoff, пометить неясный слой как unresolved, затем потребовать одно из двух — новую формулировку либо явное закрытие. Нельзя оставлять старый текст как «черновую гипотезу для замера»: следующий читатель всё равно примет его за ориентир. До нового authorised scope честнее иметь остановленную постановку, чем удобный, но составной proxy.'), - h2('Безопасный runnable literal'), - code("import { createCriticalPathPlan, assessCriticalPathPlan } from './scripts/upgrade-2027-06.mjs';\n\nconst plan = createCriticalPathPlan('critical-path-contract-2027-06-v1');\nconst handOff = assessCriticalPathPlan(plan);\nconsole.log(handOff.status, handOff.productionEffect);\n// synthetic-plan-hand-off not-attempted"), - p('Этот пример исполняет только clone, freeze и проверку fixed in-memory literal. Он не открывает браузер, не читает каталог, не берёт environment, не обращается к часам и не создаёт запрос. Положительный ответ означает лишь, что карточка соблюдает собственные границы. Он не означает, что путь существует, что он критический или что его изменение даст результат.'), - h2('Маршрут команды'), - ol(['Назовите пользовательское состояние и стартовую границу одним предложением; если получается два предложения с «и», это два вопроса.', 'Дайте договору стабильный id и revision, не называя метрику будущим результатом.', 'Составьте список сравнимых условий: browser build, device class, cache state и route state; до нового scope все остаются not-collected.', 'Запишите запреты на вывод: никакого benchmark, profile, bottleneck, winner, rollout или production effect.', 'Передайте карточку владельцу будущего исследования; только он может запросить отдельную авторизацию на наблюдение.']), - h2('Ограничение и следующий шаг'), - p('Договор не решает, какие ресурсы блокируют рендер, не объясняет отзывчивость и не заменяет профиль. Его сила в другом: он делает отсутствие знания видимым и не позволяет «быстрой правке» выглядеть доказанным решением. Если после hand-off вопрос всё ещё нужен, следующий шаг — новый authorised scope с отдельными target, политикой данных, условиями сравнения, методом и правилом интерпретации.'), - p('До этого шага результат P112 уже завершён: существует проверяемая постановка. Она не обещает ни ускорения, ни находки. Такой порядок полезнее общего совета «оптимизировать critical path», потому что команда сначала договаривается, что именно будет иметь право назвать улучшением.'), + h2('Порядок проверки'), + ol([ + 'Записать пользовательский симптом и выбрать одну страницу с фиксированным URL.', + 'Сравнить TTFB и время получения HTML на холодном и тёплом кэше.', + 'Прочитать navigation entry и выписать длительности без округления до одной общей цифры.', + 'Посмотреть resource entries и найти CSS, script или font, которые начинают работу раньше нужного.', + 'Изменить один участок: сервер, размер HTML или порядок загрузки ресурса.', + 'Повторить тот же прогон и сравнить не только среднее, но и медиану или p95.', + ]), + h2('Ограничения и следующий шаг'), + p('Node-замер не знает о layout, paint, LCP и работе главного потока браузера. Navigation Timing — временная модель навигации, а не готовый ответ о причине. Сетевые записи могут быть скрыты политикой приватности или кросс-доменными ограничениями. Поэтому локальный пример проверяет только серверный участок.'), + p('Следующим шагом подключите браузерный прогон к тестовой странице и сохраните четыре ряда: navigation, resources, long tasks и визуальную метрику. Меняйте одну причину за раз. Если TTFB стабилен, а экран задерживает CSS или JS, не переносите работу обратно на сервер.'), ], refs); -const mechanism = revision({ slug: 'editorial-2027-06-mechanism-performance-capstone', title: 'Большой разбор производительности: сравнимость и falsifiability', categories: ['Frontend', 'Качество'], cover: '/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg', excerpt: 'План на июнь 2027: как построить проверяемый вопрос о производительности, не выдав сравнение условий за результат.', readingMinutes: 23 }, [ - h2('Сравнить можно только одинаковый вопрос'), - p('Самая дорогая ошибка performance-разбора — считать два разных условия экспериментом. Один прогон мог бы иметь иной cache state, build браузера, маршрут или устройство; второй — другую границу завершения. Если записать рядом два числа без контракта, команда получает не сравнение, а материал для подтверждения уже выбранного решения. Цена — ложный winner и повторная работа, когда вывод нельзя опровергнуть.'), - p(mechanismBoundary + ' В механической части P112 отсутствуют числа и записи инструментов. Она показывает, какие утверждения должны быть falsifiable: заранее известно, какое условие может разрушить вывод. Пока conditions и evidence имеют статус not-collected, допустим только synthetic-plan-hand-off, а не оценка будущего варианта.'), - p('Falsifiability здесь не философский декор. Это конкретный вопрос к карточке: «какая наблюдаемая разница заставит нас отказаться от вывода?» Если ответ не назван, «улучшение» невозможно отделить от предпочтения автора. Например, нельзя проверить claim о порядке критического пути, пока не зафиксированы граница начала, состояние кэша и правило, по которому выбранное действие считается доступным.'), - figure('/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg', 'Матрица отделяет будущие условия сравнения от типов доказательств: каждый незаполненный слой ведёт к остановке, а не к выводу о производительности.', 'Матрица — не таблица результатов. Она показывает, почему отсутствующее условие не может быть заменено предположением.'), - h2('Четыре слоя, которые нельзя склеивать'), - p('Первый слой — intent: что пользователь пытается сделать. Второй — execution boundary: где начинается и заканчивается выбранный путь. Третий — conditions: версия браузера, класс устройства, состояние кэша и состояние маршрута. Четвёртый — evidence: будущая запись, полученная в разрешённом процессе. Между слоями нельзя перепрыгнуть. Описание API относится к vocabulary, но не превращает intent в событие и не создаёт evidence.'), - p('Такое разделение защищает от популярной подмены: технический artefact оказывается реальным, поэтому его пытаются считать ответом на продуктовый вопрос. Но Resource Timing может описывать ресурсную запись, а пользовательский путь зависеть от другого действия; Long Tasks API определяет категорию задачи, но не доказывает, что именно она мешает конкретному переходу. Доказательство имеет силу лишь в пределах заранее названного слоя.'), - table('Матрица проверяемости будущего вывода', ['Слой', 'Вопрос до запуска', 'Что опровергнет claim', 'Недопустимая подмена'], [ - ['Intent', 'Какое действие доступно пользователю?', 'другое определение действия', 'техническое поле вместо действия'], - ['Boundary', 'Где начинается и заканчивается путь?', 'изменённая граница', 'сумма несвязанных фаз'], - ['Conditions', 'Какие параметры должны совпасть?', 'зафиксированное различие условий', 'assumed cache или device'], - ['Evidence', 'Какой artefact разрешён?', 'отсутствие provenance', 'документация инструмента как результат'], +const mechanism = revision({ + slug: 'editorial-2027-06-mechanism-performance-capstone', + title: 'Waterfall без иллюзий: как понять, какой ресурс блокирует страницу', + categories: ['Frontend', 'Производительность'], + cover: '/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg', + excerpt: 'Разбираем waterfall по зависимостям, очереди и блокировке, а не по длине полоски на графике.', + readingMinutes: 15, +}, [ + p('В waterfall длинная полоса у шрифта выглядит как причина медленной страницы, но шрифт мог начать загрузку после того, как главный контент уже отрисовался. Цена ошибки — менять порядок ресурсов без понимания зависимости, получить вспышку нестилизованного текста и увеличить количество сетевых запросов.'), + p('Механика здесь простая: ресурс задерживает страницу только через конкретную зависимость — parser, CSSOM, layout, script или компонент, который ждёт ответ. Воспроизводимый пример покажет, как собрать записи ресурсов в браузере и отфильтровать их по времени старта и статусу блокировки.'), + h2('Полоса времени не объясняет причинность'), + p('Waterfall отвечает на вопрос «когда ресурс был активен». Он не отвечает на вопрос «почему экран ждал». Для причинности нужны DOM-порядок, атрибуты rel и async/defer, тип ресурса и событие, которое использовало результат. Две полосы одинаковой длины могут иметь разную цену: одна относится к hero-изображению, другая — к аналитике после загрузки.'), + table('Читаем запись ресурса', ['Признак', 'Что он даёт', 'Следующая проверка'], [ + ['name', 'какой URL запрошен', 'секреты и query удалены?'], + ['initiatorType', 'кто начал запрос', 'parser, script, css или fetch'], + ['startTime', 'когда началась активность', 'какое событие было раньше'], + ['responseEnd', 'когда пришёл ответ', 'кто действительно ждал'], + ['renderBlockingStatus', 'признак блокировки', 'поддерживает ли его браузер'], ]), - h2('Почему документация не является замером'), - p('Официальная спецификация сообщает семантику интерфейса и его ограничения. Это первичный источник для слов «navigation entry», «resource entry» или «long task». Она не сообщает, что будущая страница имеет такую запись, что entry доступен в выбранных условиях или что его значение объясняет пользовательскую задержку. Именно поэтому список источников в этой статье привязан к применимости каждого документа, а не играет роль списка подтверждённых фактов о системе.'), - p('У такой дисциплины есть практическая цена: документ получается менее эффектным. В нём нет графика, который можно показать как победу, и нет profile, который можно обсудить. Зато он сохраняет falsifier. Если будущий сбор покажет несоответствие условий, owner обязан отменить сравнение или открыть новый вопрос, а не нормализовать неудобный факт словами «разница небольшая».'), - p('Остановка — нормальный результат механизма. Она предотвращает дорогую ложную точность. В evaluator остановка возвращает причину и безопасный next action; она не заполняет пропуск значением по умолчанию. Эта деталь важнее удобства API: default browser или guessed cache превращают неизвестное условие в скрытую предпосылку, а затем вывод получает видимость повторяемости.'), - h2('Condition ledger: условия не живут в голове'), - p('Condition ledger — это не журнал результатов, а список переменных, без которых будущий вопрос нельзя назвать тем же самым. У каждой строки есть имя, причина включения, допустимое состояние до авторизации и правило, при котором сравнение прекращается. В P112 значения строк не заполняются: browser build, device class, cache state и route state остаются not-collected. Важна не их будущая величина, а обязанность не потерять переменную между формулировкой и решением.'), - p('Леджер не должен собирать всё, что можно узнать. Избыточный список превращает проверку в ритуал и делает любое несоответствие удобным поводом ничего не решать. Для каждого condition нужен вопрос «как его различие изменит смысл claim?». Если ответ отсутствует, строка не является confounder и не должна попасть в контракт. Если различие может изменить ответ, строка обязана иметь fail condition, а не пометку «желательно совпадение». '), - table('Condition ledger для будущего исследования', ['Condition', 'Почему влияет на вопрос', 'До нового scope', 'Что делает различие'], [ - ['Browser build', 'семантика и доступность будущих API могут отличаться', 'not-collected', 'отменяет прямое сопоставление'], - ['Device class', 'исполнение и ввод относятся к разным возможностям', 'not-collected', 'требует раздельного вопроса'], - ['Cache state', 'путь может включать разные классы получения данных', 'not-collected', 'делает baseline другим'], - ['Route state', 'одинаковое действие может иметь разную предысторию', 'not-collected', 'возвращает к уточнению boundary'], + figure('/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg', 'Матрица проверки ресурса: инициатор, время, статус блокировки, потребитель и действие.', 'Матрица не сортирует ресурсы по размеру. Она связывает строку waterfall с проверяемой зависимостью страницы.'), + h2('Учебная страница с наблюдателем'), + p('Это локальный HTML-фрагмент. Вставьте его в страницу, запущенную любым статическим сервером, откройте DevTools и посмотрите консоль. Входом являются browser PerformanceEntry. Результат — компактный список ресурсов, которые начали работу до события DOMContentLoaded, с указанием инициатора. Если браузер не поддерживает поле, выводится unknown, а не придуманное значение.'), + code([ + "const started = performance.getEntriesByType('resource').map((entry) => ({", + ' name: new URL(entry.name).pathname,', + ' initiator: entry.initiatorType,', + ' start: Math.round(entry.startTime),', + ' end: Math.round(entry.responseEnd),', + " blocking: entry.renderBlockingStatus ?? 'unknown',", + '}));', + '', + "const navigation = performance.getEntriesByType('navigation')[0];", + 'const domEnd = navigation?.domContentLoadedEventEnd ?? Infinity;', + 'console.table(started.filter((entry) => entry.start < domEnd));', + ].join('\n')), + p('Поля start и end — миллисекунды относительно начала навигации. Фильтр оставляет ресурсы, начавшие работу до DOMContentLoaded, но это ещё не список причин. Для каждой строки проверьте потребителя: script мог запросить JSON после DOM, а CSS мог повлиять на первый layout. Наблюдатель сокращает поиск, а не заменяет чтение кода.'), + h2('Как CSS и script меняют порядок'), + p('CSS, подключённый в head, нужен браузеру для построения стилей и поэтому часто попадает в раннюю часть цепочки. Обычный script без атрибута может остановить parser. defer загружает скрипт параллельно и исполняет его после разбора документа в порядке появления. async отдаёт порядок загрузке и подходит только для независимого кода.'), + p('Любое изменение атрибута проверяется через зависимость. Если script обращается к DOM-узлу, defer обычно подходит. Если он ожидает библиотеку выше, async может дать race. Если стиль нужен только внутри раскрываемого блока, его можно разделить, но нужно проверить скачок layout и доступность. Критерий решения — не «полоса стала короче», а перестал ли нужный компонент ждать ресурс.'), + h2('Размер и приоритет не одно и то же'), + p('Большой ресурс может загружаться низким приоритетом после первого экрана, а маленький CSS блокировать парсер. Проверяйте размер вместе с инициатором, типом, приоритетом и моментом потребления. В Resource Timing есть данные о времени и размерах, но доступ к кросс-доменным значениям зависит от заголовков и политики браузера.'), + table('Три изменения и их риск', ['Изменение', 'Положительный эффект', 'Что сломать легко'], [ + ['defer для независимого script', 'parser не ждёт выполнение', 'код, рассчитывающий на ранний глобальный объект'], + ['preload для критичного ресурса', 'запрос начинается раньше', 'лишний запрос и неправильный тип'], + ['разделение CSS', 'меньше раннего CSS', 'FOUC, layout shift и порядок правил'], ]), - p('В будущей работе ledger должен быть закрыт до интерпретации, а не после неё. Если сначала увидеть число, а затем подбирать conditions, команда почти неизбежно выберет только те параметры, которые сохраняют любимую историю. P112 не предлагает такой последовательности. Он оставляет condition list пустым по факту, но полным по требованиям: отсутствие записи честнее, чем ретроспективно подобранная объясняющая рамка.'), - h2('Baseline — правило сравнения, а не удобное прошлое число'), - p('Baseline часто называют «тем, что было раньше», и этим уже портят вопрос. В строгом смысле baseline — версия договора, с которой будущий evidence artefact можно сравнить по той же границе, тем же conditions и тому же правилу интерпретации. Он не возникает из памяти команды, из случайного скрина или из документации API. Пока в P112 нет authorised observation, baseline существует только как требование к следующему scope, а не как скрытый reference result.'), - p('У baseline есть две части. Первая — semantic: та же пользовательская единица работы и та же boundary. Вторая — operational: тот же набор существенных conditions из ledger. Сохранение одной части без другой не даёт сравнения. Например, одинаковое техническое поле при другой route state не становится baseline только потому, что его легко вывести. Такой случай надо назвать новым вопросом или остановить до появления независимого решения.'), - h2('Confounders, privacy и граница видимости'), - p('Confounder — не любая неизвестность, а условие, которое может сменить объяснение наблюдаемого различия. Cache, device и route полезны именно потому, что могут изменить смысл будущего entry. Но леджер не даёт права добывать их из удобного источника. Источник, метод и разрешение принадлежат новому scope. В P112 нет ни одного такого значения, поэтому нельзя говорить, что confounder устранён или что он несущественен.'), - p('Visibility имеет собственную границу. Resource Timing описывает механизм, в котором детализация cross-origin resource может зависеть от Timing-Allow-Origin (TAO). Это свойство спецификации, а не утверждение о каком-либо ответе. Для будущего контракта следствие скромное: отсутствие доступной детали нельзя трактовать ни как нулевую стоимость, ни как доказательство отсутствия ресурса. Сначала owner должен решить, допустимо ли вообще запрашивать такой evidence и как будет описана его неполнота.'), - p('Privacy boundary появляется раньше аналитики. Даже безопасное с виду поле может стать связующим идентификатором в сочетании с route, временем или устройством. Поэтому P112 не предлагает собирать «минимальный набор на всякий случай». До нового разрешения допустим только schema вопроса. Будущий data policy должен отдельно описать цель, минимальность, хранение, redaction и того, кто увидит raw artefact. Ссылка на публичную спецификацию не отменяет эти обязанности.'), - h2('Почему число ещё не causal claim'), - p('Число само по себе обозначает значение в некотором artefact, но не объясняет его происхождение. Оно не указывает, какой слой пути изменился, какая переменная осталась постоянной и что произошло бы без предполагаемой причины. Даже два будущих значения при одинаковой подписи не становятся causal claim, если boundary, conditions или provenance не были зафиксированы до интерпретации. У числа есть формат; у причинного вывода есть дополнительные обязательства.'), - p('Для причинного вопроса заранее называют candidate change, механизм, который должен быть затронут, и наблюдение, способное опровергнуть связь. Это не просьба обещать effect. Это запрет выдавать совпадение за объяснение. Если будущий artefact не различает, изменился ли action boundary, cache state или внешний слой, корректный вывод должен быть слабее: «данные не поддерживают выбранное объяснение», а не «причина найдена». '), - h2('Фальсификация до интерпретации'), - p('Перед любым новым scope команда может записать четыре будущих исхода без цифр: conditions совпали и вопрос остаётся открытым; condition разошёлся и сравнение отменено; visibility не позволила получить допустимый artefact; action boundary оказалась другой, чем предполагал договор. Каждый исход меняет следующий шаг, но ни один не превращается в benchmark result. Это делает план операционным: владелец знает, когда прекращать, а не только когда продолжать.'), - h2('Runnable проверка отказа'), - code("import { createCriticalPathPlan, assessCriticalPathPlan } from './scripts/upgrade-2027-06.mjs';\n\nconst impossibleComparison = createCriticalPathPlan('conditions-claimed-without-record-v1');\nconst verdict = assessCriticalPathPlan(impossibleComparison);\nconsole.log(verdict.status, verdict.productionEffect);\n// stop-conditions-claimed-without-record not-attempted"), - p('Пример получает заранее описанный отрицательный literal и возвращает stop-status. Он не подменяет запись измерением: строка assumed находится внутри in-memory fixture и нужна, чтобы проверить fail-closed ветку. В модуле отсутствуют filesystem, network, process environment, clock, telemetry SDK и profile API. Поэтому пример можно запускать как проверку контракта, а не как скрытый benchmark.'), - h2('Как подготовить falsifiable вопрос'), - ol(['Сформулируйте один claim в условном будущем времени: что именно предполагается проверить, без обещания результата.', 'Укажите границу начала и конца, не используя одно техническое имя как замену пользовательскому состоянию.', 'Перечислите условия, различие которых отменяет сравнение, и оставьте их not-collected до отдельной авторизации.', 'Определите допустимый evidence artefact и правило provenance; source documentation в этот artefact не превращайте.', 'Заранее запишите stop path: что owner сделает при несовпадении условий, отсутствии данных или спорной границе.']), - h2('Где кончается механизм'), - p('Механизм не утверждает, что строгая форма автоматически даёт хороший performance-план. Он не выбирает метрику, не устанавливает sample size и не заменяет исследование реальными данными. Он всего лишь делает будущий вывод уязвимым для корректного опровержения. Это ограничение полезно: оно не даёт превратить контракт в универсальный шаблон для любой команды и любого интерфейса.'), - p('Следующий шаг возможен только в новом authorised scope. Его владелец должен отдельно решить, допустимы ли target и данные, какие версии и состояния будут записаны, что считается comparability failure и кто принимает интерпретацию. До тех пор единственный честный итог P112 — структурированная возможность сказать «сравнения пока нет».'), + h2('Порядок чтения waterfall'), + ol([ + 'Зафиксировать страницу, режим кэша, viewport и браузер, иначе строки несопоставимы.', + 'Отфильтровать resource entries по initiatorType и времени до DOMContentLoaded.', + 'Для каждой ранней строки найти тег, script или CSS-правило, которое её инициировало.', + 'Проверить, кто читает ответ и действительно ли этот потребитель влияет на первый экран.', + 'Изменить один атрибут или порядок загрузки и повторить запись.', + 'Сравнить визуальный результат, long tasks и сетевой waterfall; не принимать сокращение одной полосы как успех.', + ]), + h2('Ограничения и следующий шаг'), + p('PerformanceObserver и renderBlockingStatus зависят от поддержки браузера. Сетевые записи скрывают часть кросс-доменных данных, а локальная страница не отражает задержки мобильной сети. Визуальный результат зависит от шрифтов, viewport и устройства. Поэтому наблюдатель полезен как диагностический слой, но не как единственный критерий качества.'), + p('Следующий шаг — сохранить в тестовом отчёте таблицу «ресурс → инициатор → потребитель → действие». В ней должны быть хотя бы один оставленный ресурс и один ресурс, который вы сознательно отложили. Это помогает объяснить компромисс и не возвращать preload или async при следующем изменении шаблона.'), ], refs); -const field = revision({ slug: 'editorial-2027-06-field-performance-capstone', title: 'Большой разбор производительности: evidence hand-off без подмены результата', categories: ['Инженерные практики', 'Практика команд'], cover: '/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg', excerpt: 'План на июнь 2027: передать evidence-вопрос следующему владельцу, не превращая пустую карточку в performance-отчёт.', readingMinutes: 21 }, [ - h2('Передача — это не отчёт без цифр'), - p('Проблема полевой работы — самый опасный артефакт выглядит аккуратно: в нём есть заголовок «performance», пара названий метрик и уверенное «проверить позже». Получатель часто читает такую карточку как сокращённый отчёт и начинает действовать так, будто bottleneck уже известен. Цена — лишний rollout, неверно выбранный owner и утрата вопроса, который действительно нужно было задать.'), - p(fieldBoundary + ' Полевой фокус P112 — evidence hand-off. Он передаёт не наблюдение, а обязанность не подменять отсутствие наблюдения результатом. Карточка сохраняет named contract, blank conditions и явный next action: сначала открыть новый authorised scope, затем решать, возможен ли какой-либо сбор.'), - figure('/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg', 'Цикл передачи evidence: плановый договор проверяет дату, условия и отсутствие наблюдений, после чего передаёт вопрос новому владельцу либо останавливается.', 'Цикл завершает передачу синтетического вопроса. Он не содержит реальных профилей, benchmark-значений или решения о rollout.'), - h2('Минимум provenance для будущего владельца'), - p('В этой карточке provenance означает происхождение утверждения, а не логотип инструмента. У named contract происхождение простое: это fixed literal редакционного плана. У conditions и evidence происхождения нет, поэтому они честно имеют статус not-collected. У официальных спецификаций происхождение есть: точный URL, дата и версия. Но они подтверждают только термины и ограничения интерфейсов, не будущую картину продукта.'), - p('Такое различение спасает от подмены «мы знаем, чем измерять» на «мы уже измерили». Библиотека, DevTools или API могут быть уместны в авторизованном исследовании, однако факт их существования ничего не говорит о выбранном target, конфиденциальности данных, representativeness или причине задержки. Hand-off должен сохранить эту дистанцию, иначе future owner начнёт с ложной предпосылки.'), - table('Содержимое evidence hand-off', ['Элемент', 'Что несёт P112', 'Чего он не несёт'], [ - ['Контракт', 'id, revision и future question', 'причину проблемы'], - ['Границы', 'planDate и source cutoff', 'временную линию будущего события'], - ['Состояния', 'not-collected / not-attempted', 'telemetry, profile или benchmark output'], - ['Владелец', 'следующее безопасное действие', 'авторизацию, rollout или решение победителя'], +const field = revision({ + slug: 'editorial-2027-06-field-performance-capstone', + title: 'Измерение производительности, которому можно верить: фиксируем условия и p95', + categories: ['Производительность', 'Инженерные практики'], + cover: '/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg', + excerpt: 'Как оформить повторяемый замер страницы, чтобы одна удачная попытка не стала решением.', + readingMinutes: 14, +}, [ + p('Фраза «после оптимизации стало быстрее» не даёт проверить ни исходное условие, ни размер выигрыша. Цена ошибки — принять удачный прогрев кэша за эффект изменения и откатить полезную работу, когда другой браузер покажет обратный результат. Измерение начинается с описания входа, а не с красивого числа.'), + p('Соберём маленький локальный стенд: сервер отдаёт страницу с фиксированной задержкой, клиент делает серию запросов и считает медиану и p95. Такой тест не заменяет браузерный прогон, но показывает дисциплину измерения: одинаковый URL, количество повторов, единица времени, выбросы и границы вывода.'), + h2('Число имеет смысл только вместе с условиями'), + p('Для сравнения нужны версия сборки, браузер, viewport, режим кэша, сеть, размер данных и порядок действий. Если поменялось сразу пять условий, разность не принадлежит одному изменению. В отчёте сначала фиксируйте входные данные, затем распределение наблюдений и только потом пишите вывод.'), + table('Минимальный протокол замера', ['Параметр', 'Пример значения', 'Зачем'], [ + ['URL и commit', '/catalog, abc123', 'что именно измеряли'], + ['Браузер и viewport', 'Chromium, 1280×800', 'какой клиент выполнял код'], + ['Кэш', 'cold или warm', 'что уже было в storage'], + ['Повторы', '20', 'насколько устойчиво число'], + ['Метрика', 'TTFB, p95', 'какой участок сравниваем'], ]), - h2('Как не испортить передачу полезными деталями'), - p('Соблазн велик: добавить предполагаемый route, название service, скрин из чужого профиля или «типичную» последовательность запросов. Эти детали ускоряют чтение, но меняют класс документа. Если происхождение не записано и scope не разрешён, detail становится необъявленным наблюдением. Дальше его трудно убрать: он уже влияет на выбор owner и на то, какие гипотезы команда считает допустимыми.'), - p('Вместо детали hand-off оставляет вопрос на уровне, который можно пересмотреть. Он говорит: существует будущая проверка границы критического пути, но условия ещё не собраны. Это не уклонение от ответственности. Это точная ответственность автора плана: не добавлять сервисные и telemetry-факты, которые принадлежат будущему исследованию. Если вопрос не стоит дополнительного scope, owner имеет право закрыть его без запуска.'), - p('Fail-closed evaluator поддерживает ту же дисциплину. Неизвестный объект не получает позитивный статус; дата не выводится из редакторского календаря; claimed observation не «понижается» до предупреждения. Такой интерфейс специально неудобен для обхода. Он показывает получателю, что корректная карточка либо полностью именована, либо остановлена с причиной и следующим безопасным действием.'), - h2('Evidence record отличается от отчёта'), - p('Evidence record фиксирует происхождение утверждения так, чтобы другой человек мог определить его силу. Для будущей performance-работы запись должна различать как минимум: contract, автора вопроса, разрешение на сбор, метод, время действия scope, применимые conditions, границы видимости, raw artefact и интерпретацию. В P112 заполнена лишь первая группа: редакционный named contract, версия, дата сценария и source cutoff. Остальные поля не пустые по забывчивости — они не принадлежат этому scope.'), - p('Отчёт устроен иначе: он связывает вопрос с полученными artefact и выводом. Попытка сделать отчёт из plan record создаёт ложную непрерывность. Например, одна формулировка «данные будут собраны» уже звучит как факт о разрешении и методе, хотя P112 никого не уполномочивает. Карточка не обещает, что сбор когда-либо состоится. Она передаёт ограничение: если он появится, provenance должен быть создан рядом с ним, а не ретроспективно добавлен сюда.'), - table('Поля будущего evidence record и статус P112', ['Поле', 'Зачем нужно будущей работе', 'Статус в P112'], [ - ['Question contract', 'связывает artefact с одним проверяемым вопросом', 'named fixed literal'], - ['Collection authority', 'показывает, кто и в каком scope вправе действовать', 'not-authorised here'], - ['Method and visibility', 'объясняет, какие границы и ограничения применимы', 'not-selected'], - ['Raw artefact and interpretation', 'отделяет запись от решения', 'not-collected'], + figure('/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg', 'Цикл замера: зафиксировать условия, выполнить повторения, посчитать распределение, изменить один фактор и сравнить.', 'Цикл возвращает к исходным условиям после каждого изменения. Без этого p95 не показывает, что именно дало разницу.'), + h2('Локальный p95 на серии запросов'), + p('Вход кода — число повторов и локальный endpoint. Сервер отвечает после одной и той же задержки, а клиент измеряет round trip. Ожидаемый результат — отсортированный список и p95, близкий к заданной задержке плюс накладные расходы Node. Это рабочий замер локального HTTP-пути, а не оценка интерфейса.'), + code([ + "import { performance } from 'node:perf_hooks';", + "import { createServer as createMeasureServer } from 'node:http';", + '', + "const server = createMeasureServer((request, response) => {", + ' setTimeout(() => response.end(\'ready\'), 40);', + '});', + '', + 'function percentile(values, rank) {', + ' const sorted = [...values].sort((a, b) => a - b);', + ' return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * rank) - 1)];', + '}', + '', + "server.listen({ host: '127.0.0.1', port: 0 }, async function measure() {", + ' const address = server.address();', + ' const port = address.port;', + ' const samples = [];', + ' for (let index = 0; index < 20; index += 1) {', + ' const started = performance.now();', + " await (await fetch('http://127.0.0.1:' + port)).text();", + ' samples.push(performance.now() - started);', + ' }', + " console.log({ count: samples.length, median: percentile(samples, 0.5).toFixed(1), p95: percentile(samples, 0.95).toFixed(1) });", + ' server.close();', + '});', + ].join('\n')), + p('В результате count равен 20, медиана и p95 находятся около 40 миллисекунд. На загруженной машине числа выше, но их разброс остаётся видимым. Функция percentile здесь намеренно короткая: на малой выборке p95 — один элемент, поэтому отчёт должен сохранять размер серии и сами условия, а не только округлённый итог.'), + p('Cold и warm cache нельзя смешивать в одной выборке. Если нужен общий отчёт, маркируйте режим каждой строки и сравнивайте одинаковые поднаборы. Иначе p95 может вырасти только потому, что несколько первых запросов скачали ресурсы.'), + h2('Среднее скрывает хвост'), + p('Среднее удобно для оценки общей стоимости, но пользователь попадает в хвост распределения. p95 означает, что 95 процентов измерений не превышают значение, а пять процентов превышают. Это не «медленность каждого запроса». При малом числе повторов один выброс сильно меняет p95, поэтому сравнивайте одинаковые объёмы выборки.'), + p('Для страницы полезно хранить несколько метрик отдельно: TTFB, время до DOMContentLoaded, LCP, long tasks и размер ответа. Нельзя складывать их в один балл без явного решения. LCP зависит от содержимого и браузера, а TTFB — от серверной и сетевой части. Изменение, которое улучшило один участок, может ухудшить другой.'), + h2('Как не перепутать корреляцию с причиной'), + table('Сигнал и безопасный вывод', ['Сигнал', 'Можно сказать', 'Нужна дополнительная проверка'], [ + ['TTFB выше', 'ответ начал приходить позже', 'backend, сеть и proxy'], + ['p95 вырос', 'хвост стал тяжелее в этих условиях', 'нагрузка и размер выборки'], + ['script появился раньше', 'порядок ресурсов изменился', 'влияние на render и DOM'], + ['LCP изменился', 'визуальный критерий изменился', 'какой элемент стал LCP'], ]), - p('Особенно опасно смешать provenance источника и provenance данных. У W3C snapshot можно проверить URL и дату; это позволяет ссылаться на определение интерфейса. У будущего artefact происхождение будет другим: он потребует scope, способа получения и политики обращения с данными. Первый тип provenance не компенсирует второй. Публичная документация не делает неизвестный entry настоящим, а naming API не наделяет редакционный текст правом собрать его.'), - h2('Кто вправе собирать и кто вправе решать'), - p('В P112 роли описаны как будущая граница, а не как назначение конкретных людей. Editor может сформировать план и проверить temporal honesty. Evidence owner может предложить отдельный scope. Data/privacy owner может оценить допустимость категорий данных и ограничений хранения. Review owner может проверить, соответствует ли будущий artefact договору. Ни одна из этих ролей не получает из текущего модуля право открыть инструменты, увидеть raw telemetry или запустить сбор.'), - p('В будущем scope право на действие должно быть уже, чем вопрос. Например, контракт может разрешать только заранее описанную категорию artefact, только для выбранной boundary и только в оговорённый срок. Если нужная деталь оказывается вне этих рамок, owner не расширяет её «по ходу». Он останавливает работу и возвращается к авторизации. Иначе provenance записи будет говорить об одном методе, а фактическая работа — о другом.'), - h2('No-data hand-off — полноценный результат'), - p('Передача без данных часто кажется слабой, потому что в ней нечего визуализировать. Но она полезна, если сохраняет четыре отрицания: evidence не collected, benchmark не attempted, profile не available здесь, а production effect не заявлен. Эти состояния дают следующему владельцу возможность начать с чистой границы, а не тратить время на отделение выдуманного фона от настоящих входов. No-data hand-off — не пустой файл, а контроль над тем, чего файл не утверждает.'), - p('Чтобы такая передача не стала тупиком, в ней должен быть reasoned next action. В P112 он один: открыть новый authorised scope до любого наблюдения. Он не говорит «собрать всё», не задаёт target и не навязывает tool. Новый owner может принять этот маршрут, сузить question или закрыть его. Все три решения честны, потому что старый документ не предлагает скрытый baseline, готовую выборку или обещанный эффект.'), - p('No-data hand-off также защищает от ретроспективной подмены. Если позже появится исследование, его автор не должен вставлять результат в этот plan, даже если contract совпадает по названию. Совпадение вопроса не делает совпадением evidence context. У новой работы будут собственные дату, scope, collector, policy, source set и результат интерпретации. Старая карточка может быть ссылкой на постановку, но не контейнером для полученных фактов.'), - h2('Review не принимает решение за owner'), - p('Редакционный review проверяет форму высказывания: temporal boundary, источники, отсутствие поддельного evidence, читаемость схемы и стоп-ветви. Он не проверяет существующую скорость и не выбирает, что менять. Даже если reviewer уверен в популярной гипотезе, его обязанность — зафиксировать, что она остаётся гипотезой до другой авторизованной работы. Иначе review превращается в канал, через который мнение получает видимость технического результата.'), - p('Decision review появляется только после того, как новый scope определил допустимый evidence и его interpretation boundary. Его вопрос будет иным: поддерживают ли полученные artefact ограниченный claim и достаточно ли этого для решения. Это не продолжение текущего review по инерции. Между ними лежат авторизация, сбор, provenance, проверка conditions и возможный stop. Удаление любого звена делает решение зависимым от доверия, а не от контракта.'), - h2('Контракт следующего scope'), - p('Если hand-off будет принят, следующий документ начинает не с команды и не с dashboard, а с нового контракта. В нём отдельно фиксируют target class без лишних идентификаторов, action boundary, hypothesised mechanism, allowable evidence, condition ledger, privacy limits, retention/redaction policy, reviewer и decision rule. Каждый пункт должен быть написан до того, как появится результат. P112 не заполняет эти поля и не предлагает считать их значениями по умолчанию.'), - p('Наконец, новый контракт должен разделить факт, интерпретацию и решение. Факт принадлежит artefact с provenance; интерпретация ограничена объявленной boundary; решение имеет owner и последствия, которых P112 не определяет. В этой последовательности нет гарантии, что появится полезная цифра. Зато нет и короткого пути, по которому одна плановая карточка превращается в якобы готовое обоснование действия.'), - h2('Runnable hand-off без скрытого доступа'), - code("import { createCriticalPathPlan, assessCriticalPathPlan } from './scripts/upgrade-2027-06.mjs';\n\nconst card = createCriticalPathPlan('critical-path-contract-2027-06-v1');\nconst result = assessCriticalPathPlan(card);\nconsole.log({ status: result.status, next: result.nextAction });\n// { status: 'synthetic-plan-hand-off', next: 'open-a-new-authorised-scope-before-any-observation' }"), - p('Здесь нет чтения файлов, сети, environment, часов, secrets, telemetry, систем или данных. Factory возвращает копию fixed literal и freeze фиксирует вложенные поля. Evaluator сравнивает вход только с известными literal; произвольный объект закрывается stop-status. Вывод описывает передачу карточки, а не факт о browser, service, production или пользователе.'), - h2('Порядок evidence hand-off'), - ol(['Проверьте, что документ помечен как 2027-06 plan/scenario и содержит source cutoff 2026-07-31.', 'Оставьте только named contract и required conditions; удалите route, service, telemetry и profile details, если они не принадлежат новому scope.', 'Отметьте benchmark как not-attempted, а evidence как not-collected; отсутствие данных не смягчайте прогнозом.', 'Прогоните fixture с положительным hand-off и всеми stop cases, чтобы проверка не стала permissive.', 'Передайте owner действие открыть новый authorised scope или закрыть вопрос; не ставьте rollout, winner либо production change в очередь.']), - h2('Граница ответственности и следующий шаг'), - p('Hand-off не даёт доказательства и не обязан его имитировать. Он не покажет, существует ли bottleneck, не назовёт effect и не выберет optimisation. Его результат — меньшая вероятность, что команда примет уже сформулированную гипотезу за наблюдение. Это особенно важно для performance-тем: правдоподобная история о задержке часто убедительнее, чем честное «данных нет».'), - p('Следующий шаг — только новый authorised scope с самостоятельным решением о target, data policy, collection method, comparability и interpretation. Его итог нельзя вставить в P112 и нельзя использовать P112 как evidence. Пока такого scope нет, synthetic-plan-hand-off с productionEffect: not-attempted остаётся полным и единственным положительным результатом этого выпуска.'), + p('Слово «корреляция» полезно только вместе с ограничением. Если TTFB и LCP выросли одновременно, это повод проверить сервер, но не доказательство, что сервер — единственная причина. Свяжите метрики по одному запуску, сохраните network log и посмотрите, какой элемент стал самым поздним крупным контентом.'), + h2('Порядок замера'), + ol([ + 'Зафиксировать URL, код сборки, браузер, viewport, сеть и режим кэша.', + 'Прогреть или очистить кэш одинаковым способом перед каждым набором.', + 'Сделать одинаковое число повторов и сохранить сырые значения времени.', + 'Посчитать median, p95 и диапазон, указав единицу измерения.', + 'Изменить один фактор и повторить тот же набор.', + 'Записать, какая метрика улучшилась, какая ухудшилась и какие вопросы остались открытыми.', + ]), + h2('Ограничения и следующий шаг'), + p('Локальный HTTP-сервер не моделирует мобильную сеть, CPU браузера, кэш CDN и работу layout. Маленькая серия не годится для долгих выводов. W3C описывает интерфейсы измерения, но не устанавливает ваш порог приемки. Порог должен учитывать страницу, устройство и стоимость задержки.'), + p('Следующий шаг — добавить к локальной серии один контролируемый browser-run и сохранить raw JSON вместе с условиями. Проверяйте распределение, а не только среднее. Если результат меняется после очистки кэша или при другом viewport, сначала уточните входные данные, затем выбирайте оптимизацию.'), ], refs); export const revisions = deepFreeze([practice, mechanism, field]); -export function verifyRevisionsAgainstFixture() { const fixture = runCriticalPathFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 9000 && text.length <= 12000 && /(цен[аы]|стоимост|издержк|потер|дороже)/i.test(text.slice(0, 1100)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-06/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +export function verifyRevisionsAgainstFixture() { + const fixture = runCriticalPathFixture(); + const articleChecks = revisions.map((item) => { + const text = bodyText(item.contentHtml); + return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml); + }); + return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); +} + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-07.mjs b/web/scripts/upgrade-2027-07.mjs index 762c06f..2672ade 100644 --- a/web/scripts/upgrade-2027-07.mjs +++ b/web/scripts/upgrade-2027-07.mjs @@ -11,10 +11,13 @@ const p = (text) => '

    ' + text + '

    '; const h2 = (text) => '

    ' + text + '

    '; const code = (text) => '
    ' + escapeHtml(text) + '
    '; const ol = (items) => '
      ' + items.map((item) => '
    1. ' + item + '
    2. ').join('') + '
    '; -const figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } +function cloneFixed(value) { + return JSON.parse(JSON.stringify(value)); +} + function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); @@ -22,146 +25,67 @@ function deepFreeze(value) { } return value; } + function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } + function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } const REFERENCES = deepFreeze({ - rfc9110: { - title: 'RFC 9110 — HTTP Semantics', - url: 'https://www.rfc-editor.org/rfc/rfc9110.html', - version: 'IETF Standards Track, June 2022, RFC 9110 / STD 97, immutable publication', - }, - nist800160: { - title: 'NIST SP 800-160 Volume 2 Revision 1 — Developing Cyber-Resilient Systems', - url: 'https://csrc.nist.gov/pubs/sp/800/160/v2/r1/final', - version: 'NIST Special Publication 800-160 Vol. 2 Rev. 1, December 2021, dated revision', - }, - rfc9000: { - title: 'RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport', - url: 'https://www.rfc-editor.org/rfc/rfc9000.html', - version: 'IETF Standards Track, May 2021, immutable publication', - }, + http: { title: 'RFC 9110 — HTTP Semantics', version: 'IETF Standards Track, June 2022', url: 'https://www.rfc-editor.org/rfc/rfc9110.html' }, + quic: { title: 'RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport', version: 'IETF Standards Track, May 2021', url: 'https://www.rfc-editor.org/rfc/rfc9000.html' }, }); + function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; - return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Не доказывает: ' + escapeHtml(boundary) + '
  • '; + return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } const FIXED_RELIABILITY_CASES = deepFreeze({ - 'reliability-contract-hand-off-v1': { - id: 'reliability-contract-hand-off-v1', - editorialDate: '2026-07-31', - planningIssue: '2027-07', - sourceCutoff: '2026-07-31', - scenario: { - id: 'named-synthetic-dependency-unavailable-scenario', - retry: 'explicit-not-selected', - replica: 'explicit-not-selected', - recovery: 'explicit-not-selected', - }, - evidence: { - incident: 'not-collected', - serviceLevel: 'not-defined', - observation: 'not-collected', - configuration: 'not-collected', - load: 'not-collected', - }, - requestedOutput: 'synthetic-plan-hand-off', - }, - 'missing-temporal-boundary-v1': { - id: 'missing-temporal-boundary-v1', editorialDate: '', planningIssue: '2027-07', sourceCutoff: '2026-07-31', - scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' }, - evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'hidden-evidence-or-configuration-v1': { - id: 'hidden-evidence-or-configuration-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31', - scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' }, - evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'hidden', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'implicit-retry-v1': { - id: 'implicit-retry-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31', - scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'implicit', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' }, - evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'implicit-replica-or-recovery-v1': { - id: 'implicit-replica-or-recovery-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31', - scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'implicit', recovery: 'assumed' }, - evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'claimed-evidence-v1': { - id: 'claimed-evidence-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31', - scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' }, - evidence: { incident: 'claimed', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'claimed-operational-result-v1': { - id: 'claimed-operational-result-v1', editorialDate: '2026-07-31', planningIssue: '2027-07', sourceCutoff: '2026-07-31', - scenario: { id: 'named-synthetic-dependency-unavailable-scenario', retry: 'explicit-not-selected', replica: 'explicit-not-selected', recovery: 'explicit-not-selected' }, - evidence: { incident: 'not-collected', serviceLevel: 'not-defined', observation: 'not-collected', configuration: 'not-collected', load: 'not-collected' }, requestedOutput: 'reliability-improved', - }, + get503: { method: 'GET', status: 503, retryAfter: 1 }, + get200: { method: 'GET', status: 200, retryAfter: 0 }, + post503: { method: 'POST', status: 503, retryAfter: 1 }, + timeout: { method: 'GET', status: 0, error: 'timeout' }, }); -export function createFixedReliabilityCase(id = 'reliability-contract-hand-off-v1') { - const value = FIXED_RELIABILITY_CASES[id]; - return value ? deepFreeze(cloneFixed(value)) : undefined; -} -function stop(status, reason, nextAction) { - return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); +export function createFixedReliabilityCase(id = 'get503') { + return FIXED_RELIABILITY_CASES[id] ? deepFreeze(cloneFixed(FIXED_RELIABILITY_CASES[id])) : undefined; } + export function assessFixedReliabilityPlan(input) { - const known = Object.values(FIXED_RELIABILITY_CASES).some((item) => JSON.stringify(item) === JSON.stringify(input)); - if (!known) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-literal'); - if (input.editorialDate !== '2026-07-31' || input.planningIssue !== '2027-07' || input.sourceCutoff !== '2026-07-31') { - return stop('stop-temporal-boundary-required', 'editorial-date-planning-issue-and-source-cutoff-must-be-exact', 'name-the-fixed-temporal-boundary'); - } - if (!input.scenario?.id || input.evidence?.configuration !== 'not-collected' || input.evidence.incident !== 'not-collected' || input.evidence.serviceLevel !== 'not-defined' || input.evidence.observation !== 'not-collected' || input.evidence.load !== 'not-collected') { - return stop('stop-hidden-evidence-or-configuration', 'evidence-and-configuration-must-remain-explicitly-uncollected', 'remove-hidden-or-claimed-material'); - } - if (input.scenario.retry !== 'explicit-not-selected') { - return stop('stop-implicit-retry', 'retry-cannot-be-inferred-or-defaulted', 'state-that-retry-is-not-selected'); - } - if (input.scenario.replica !== 'explicit-not-selected' || input.scenario.recovery !== 'explicit-not-selected') { - return stop('stop-implicit-replica-or-recovery', 'replica-and-recovery-cannot-be-assumed', 'state-that-replica-and-recovery-are-not-selected'); - } - if (input.requestedOutput !== 'synthetic-plan-hand-off') { - return stop('stop-disallowed-operational-result', 'a-future-scenario-cannot-claim-an-operational-result', 'use-synthetic-plan-hand-off'); - } - return deepFreeze({ - status: 'synthetic-plan-hand-off', caseId: input.id, planningIssue: input.planningIssue, - scenario: deepFreeze(cloneFixed(input.scenario)), evidence: deepFreeze(cloneFixed(input.evidence)), - productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-evidence-scope-if-a-decision-requires-it', - }); + if (!input || typeof input !== 'object') return { status: 'stop', action: 'проверить входные поля' }; + if (input.error === 'timeout') return { status: 'bounded-timeout', action: 'закончить попытку по deadline' }; + if (input.status >= 200 && input.status < 300) return { status: 'success', action: 'вернуть результат вызывающему коду' }; + if (input.status === 503 && input.method === 'GET') return { status: 'retry-allowed', action: 'повторить с лимитом и задержкой' }; + if (input.status === 503) return { status: 'retry-denied', action: 'проверить идемпотентность операции' }; + return { status: 'stop', action: 'разобрать ответ отдельно' }; } + export function inspectFailureContractLiteral() { - const input = createFixedReliabilityCase(); - return deepFreeze({ namedScenario: input.scenario.id, retry: input.scenario.retry, outcome: assessFixedReliabilityPlan(input).status }); + return deepFreeze({ timeoutMs: 400, maxAttempts: 3, retryableMethods: ['GET', 'HEAD', 'PUT', 'DELETE'] }); } + export function inspectFaultTreeLiteral() { - const result = assessFixedReliabilityPlan(createFixedReliabilityCase()); - return deepFreeze({ topQuestion: 'named-synthetic-dependency-unavailable-scenario', branches: ['request-shape-not-collected', 'capacity-not-collected', 'recovery-not-selected'], outcome: result.productionEffect }); + return deepFreeze({ first: 'timeout', second: '503', decision: 'bounded-retry' }); } + export function inspectHandoffLiteral() { - const result = assessFixedReliabilityPlan(createFixedReliabilityCase()); - return deepFreeze({ accepted: result.status, incident: result.evidence.incident, serviceLevel: result.evidence.serviceLevel, nextAction: result.nextAction }); + return deepFreeze({ attempt: 2, method: 'GET', status: 200, next: 'return-response' }); } + export function runFixedReliabilityFixture() { - const expected = [ - ['reliability-contract-hand-off-v1', 'synthetic-plan-hand-off'], - ['missing-temporal-boundary-v1', 'stop-temporal-boundary-required'], - ['hidden-evidence-or-configuration-v1', 'stop-hidden-evidence-or-configuration'], - ['implicit-retry-v1', 'stop-implicit-retry'], - ['implicit-replica-or-recovery-v1', 'stop-implicit-replica-or-recovery'], - ['claimed-evidence-v1', 'stop-hidden-evidence-or-configuration'], - ['claimed-operational-result-v1', 'stop-disallowed-operational-result'], - ]; - const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessFixedReliabilityPlan(createFixedReliabilityCase(id)).status })); - const sample = createFixedReliabilityCase(); - return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.scenario) && Object.isFrozen(sample.evidence), checks: deepFreeze(checks) }); + const checks = [ + ['get503', 'retry-allowed'], + ['get200', 'success'], + ['post503', 'retry-denied'], + ['timeout', 'bounded-timeout'], + ].map(([id, expected]) => ({ id, expected, actual: assessFixedReliabilityPlan(createFixedReliabilityCase(id)).status })); + return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks }); } function revision(meta, parts, referenceEntries) { @@ -170,181 +94,241 @@ function revision(meta, parts, referenceEntries) { if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } + const refs = [ - { key: 'rfc9110', use: 'Закрепляет различие безопасного метода, идемпотентности и автоматического повторения после сбоя связи.', boundary: 'Не подтверждает метод, запрос, зависимость, повтор или результат в данном сценарии.' }, - { key: 'nist800160', use: 'Даёт официальный словарь устойчивости как способности противостоять, восстанавливаться и адаптироваться к неблагоприятным условиям.', boundary: 'Не является incident record, SLO, планом восстановления или доказательством готовности системы.' }, - { key: 'rfc9000', use: 'Показывает, что транспортный слой имеет собственные состояния и ограничения, которые нельзя выдать за семантику прикладного действия.', boundary: 'Не доказывает наличие QUIC, конкретной нагрузки, соединения или восстановления.' }, + { key: 'http', use: 'Задаёт семантику безопасных и идемпотентных методов, статусов и поля Retry-After.', boundary: 'Не выбирает политику повтора для конкретного API и не гарантирует отсутствие побочных эффектов.' }, + { key: 'quic', use: 'Показывает, что транспортное управление потоками и потерями отделено от прикладной семантики запроса.', boundary: 'Не является инструкцией по retry HTTP-клиента и не описывает поведение конкретной библиотеки.' }, ]; const practice = revision({ slug: 'editorial-2027-07-practice-reliability-capstone', - title: 'Большой разбор надёжности: практический маршрут', - categories: ['Надёжность', 'Кейс'], + title: 'Повтор запроса без двойного действия: timeout, 503 и безопасный retry', + categories: ['Надёжность', 'HTTP'], cover: '/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg', - excerpt: 'План на июль 2027: как зафиксировать контракт отказного сценария, не выдавая предположение о retry за решение.', - readingMinutes: 23, + excerpt: 'Практический клиент с общим deadline и повтором только там, где операция допускает повторное выполнение.', + readingMinutes: 14, }, [ - p('Это редакционный план на 2027-07 с source cutoff 2026-07-31, а не отчёт о работе системы. Практическая проблема начинается до первого кода: зависимость названа «недоступной», но не сказано, какое действие остаётся незавершённым, что именно разрешено повторить и что должно остановиться. Такая пустота быстро превращается в неверный retry. Его цена не абстрактна: один и тот же запрос может быть отправлен повторно без доказанного права на это, а команда получает спор о последствиях вместо ясной границы сценария.'), - p('Вторая цена — ложная экономия на формулировке. Если в будущем описании незаметно появляются резервная копия, вторая реплика или «обычное восстановление», читатель воспринимает их как уже выбранный путь. На редакторскую дату у этой статьи нет реального incident, значения SLO или SLI, deployment, наблюдений, конфигурации, нагрузки либо результата. Разрешён только synthetic-plan-hand-off с productionEffect: not-attempted. Поэтому ниже не рецепт обработки сбоя, а контракт, который не подставляет отсутствующие решения.'), - h2('Контракт начинается с действия, а не с названия сбоя'), - p('Фраза «сервис упал» слишком велика для решения о повторе. В ней не видно, был ли вызов начат, принято ли прикладное действие другой стороной, есть ли у вызывающего кода ключ идемпотентности и допустимо ли вообще сохранять попытку. В будущем scope эти вопросы могут получить разные ответы. Пока ответ не собран, безопаснее назвать только синтетический класс: named dependency unavailable. Это не диагноз и не намёк на конкретный компонент; это ограничитель для разговора.'), - p('Контракт полезен, когда в нём есть четыре независимых поля: действие, наблюдаемая граница, разрешение на повтор и состояние результата. В P113 действие намеренно не получает endpoint, payload или consumer. Наблюдаемая граница остаётся not-collected. Разрешение на повтор не вычисляется из HTTP-слова или названия операции: оно явно остаётся explicit-not-selected. Состояние результата не меняется на успех или восстановление, потому что данных для такого перехода нет.'), - table('Части синтетического контракта отказного сценария', ['Часть', 'Что можно назвать сейчас', 'Что нельзя достраивать'], [ - ['Граница действия', 'Именованный synthetic scenario', 'Реальный запрос, ключ, consumer или side effect'], - ['Повтор', 'explicit-not-selected', 'Автоматический retry по умолчанию'], - ['Резервирование', 'explicit-not-selected', 'Реплику, запасной маршрут или переключение'], - ['Восстановление', 'explicit-not-selected', 'Готовый recovery path или время возврата'], - ['Свидетельство', 'not-collected', 'Incident, конфигурацию, нагрузку или production outcome'], + p('Клиент получил timeout и повторил запрос, а пользователь увидел две созданные заявки. Цена ошибки — повторить побочный эффект, который сервер уже выполнил, но ответ потерялся по дороге. Обратная ситуация тоже опасна: не повторить безопасный GET после временного 503 и показать лишний сбой.'), + p('Нужны три явных ограничения: общий deadline, число попыток и список методов, для которых повтор допустим. Учебный локальный сервер дважды отвечает 503, затем возвращает 200. Клиент показывает порядок попыток и завершает работу в пределах заданного времени.'), + h2('Сначала решаем, что можно повторить'), + p('RFC 9110 называет идемпотентным запрос, повтор которого имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это свойство операции, а не магия клиента: GET, HEAD, PUT и DELETE имеют другую семантику, чем обычный POST. Сервер может отдельно предоставить идемпотency-key для создания ресурса, но это нужно подтвердить его контрактом.'), + table('Матрица решения о повторе', ['Метод', 'Пример действия', 'Повтор после timeout', 'Условие'], [ + ['GET', 'прочитать каталог', 'обычно допустим', 'лимит и deadline'], + ['HEAD', 'проверить ресурс', 'обычно допустим', 'ответ не нужен в теле'], + ['PUT', 'записать состояние по ключу', 'возможен', 'сервер сохраняет идемпотентность'], + ['DELETE', 'удалить по идентификатору', 'возможен', 'повторный 404 трактуется контрактом'], + ['POST', 'создать заказ', 'не по умолчанию', 'idempotency-key и правила сервера'], ]), - h2('Почему идемпотентность не заменяет контракт'), - p('RFC 9110 различает безопасные методы и идемпотентные методы, а также описывает условия, при которых клиент может повторить идемпотентный запрос после сбоя связи. Это полезная норма для языка разговора, но не лицензия на скрытый retry. Документ не знает бизнес-действие, дедупликацию, состояние принимающей стороны и политику конкретного клиента. Поэтому нельзя взять слово idempotent и выдать его за доказательство, что повтор не изменит будущий предметный результат.'), - p('Проверка должна идти в обратную сторону. Сначала будущий scope формулирует, что считается одним логическим действием. Затем определяет, есть ли явный способ различить первую попытку и повтор. Потом отделяет транспортную неясность от прикладного подтверждения. Лишь после этого можно обсуждать policy. Этот порядок медленнее одной строки с retry, зато не переносит риск из слоя связи в слой данных под видом надёжности.'), - figure('/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg', 'Схема контракта отказного сценария: один синтетический вопрос разложен на действие, границу, явный retry и запрещённые предположения о реплике и восстановлении.', 'Схема не показывает реальный путь запроса. Она удерживает четыре поля контракта и красные стоп-границы для невыбранных решений.'), - h2('Исполнимый пример без доступа к системе'), - p('Пример ниже запускается в Node и работает только с named fixed in-memory literal. Он не импортирует HTTP-клиент, не читает файл, не использует переменные окружения, часы, секреты или внешние данные. Его результат не говорит, что retry безопасен: он показывает, что literal не способен подставить retry сам.'), - code("import { inspectFailureContractLiteral } from './upgrade-2027-07.mjs';\n\nconst contract = inspectFailureContractLiteral();\nconsole.log(contract);\n// { namedScenario: 'named-synthetic-dependency-unavailable-scenario',\n// retry: 'explicit-not-selected', outcome: 'synthetic-plan-hand-off' }"), - p('Здесь namedScenario — имя формы, а не запись об инфраструктуре. retry — намеренный отказ от implicit policy. Если изменить fixed literal или передать произвольный объект, evaluator не ищет недостающие сведения и не выбирает fallback. Он возвращает stop-status. Это важнее красивого примера с сетевым клиентом: пример не создаёт следов, не обещает повторного выполнения и не маскирует конфигурацию.'), - h2('Порядок постановки будущего вопроса'), + figure('/assets/editorial/2027/reliability-capstone-2027-fault-tree-contract.svg', 'Дерево решения для повторения HTTP-запроса: timeout, статус, метод, deadline и конечный ответ.', 'Диаграмма сначала проверяет deadline и метод, затем статус. Ошибка не превращается в повтор автоматически.'), + h2('Общий deadline важнее числа попыток'), + p('Три попытки по 500 миллисекунд могут занять больше секунды, если между ними стоят задержки. Пользователь и вызывающая система ждут не количество попыток, а завершение операции. Поэтому задаём общий deadline, а на каждой итерации считаем оставшееся время. Если его недостаточно для следующего запроса, возвращаем timeout.'), + p('Задержка между попытками должна быть ограниченной и лучше иметь случайное рассеивание в многоклиентской системе. Но jitter не исправляет неправильную семантику. Сначала решается «можно ли повторять», затем «сколько времени отдать», и только потом выбираются backoff и случайная добавка.'), + h2('Учебный клиент и локальный сервер'), + p('Вход функции requestWithRetry — URL, метод и параметры времени. Сервер хранит счётчик только внутри процесса: первые два ответа — 503, третий — 200. Ожидаемый результат — attempts: 3 и status: 200. На локальном loopback нет внешней зависимости.'), + code([ + "import { createServer } from 'node:http';", + '', + 'let calls = 0;', + "const server = createServer((request, response) => {", + ' calls += 1;', + ' if (calls < 3) {', + " response.writeHead(503, { 'retry-after': '0' });", + " response.end('busy');", + ' return;', + ' }', + ' response.end(\'ready\');', + '});', + '', + 'async function requestWithRetry(url, { method = \'GET\', maxAttempts = 3, deadlineMs = 1000 } = {}) {', + " if (!['GET', 'HEAD', 'PUT', 'DELETE'].includes(method)) throw new Error('method is not retryable');", + ' const deadline = Date.now() + deadlineMs;', + ' for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {', + ' const remaining = deadline - Date.now();', + " if (remaining <= 0) throw new Error('deadline reached');", + ' const response = await fetch(url, { method, signal: AbortSignal.timeout(remaining) });', + " if (response.ok) return { attempts: attempt, status: response.status };", + ' }', + ' throw new Error(\'deadline reached\');', + '}', + '', + "server.listen(0, '127.0.0.1', async function retryDemo() {", + ' const port = server.address().port;', + " console.log(await requestWithRetry('http://127.0.0.1:' + port));", + ' server.close();', + '});', + ].join('\n')), + p('Результат — { attempts: 3, status: 200 }. В коде есть намеренное ограничение: он не повторяет POST и не разбирает Retry-After. Для учебного стенда этого достаточно, чтобы увидеть связь между методом и повтором. В библиотеке нужно добавить AbortSignal, deadline, backoff, обработку сетевого исключения и лог безопасных попыток.'), + h2('Что означает 503'), + p('503 — ответ сервера, а не доказательство, что повтор сработает. Заголовок Retry-After может указать время ожидания, но его значение нужно проверить как число секунд или дату. Если сервис за прокси возвращает 503 от посредника, повтор может увеличить нагрузку на origin. Логируйте источник ответа, если архитектура позволяет отличить proxy от приложения.'), + p('Для timeout сложнее: запрос мог быть принят и выполнен, а клиент потерял ответ. Для чтения это обычно приемлемо, для записи — нет без ключа дедупликации. Поэтому error class должна хранить не только «сетевой сбой», но и метод, request id, idempotency key и достигнутый этап.'), + h2('Порядок внедрения'), ol([ - 'Назвать одно логическое действие без endpoint, payload и рассказа о прошлой операции.', - 'Указать редакционную дату 2026-07-31, planning issue 2027-07 и source cutoff 2026-07-31.', - 'Оставить evidence, configuration и load в состоянии not-collected.', - 'Зафиксировать retry, replica и recovery как explicit-not-selected, пока отдельное решение не даст им смысл.', - 'Остановить карточку при hidden field, implicit retry или claimed result; не компенсировать пробел предположением.', - 'Передать только synthetic plan question в отдельный будущий scope, если появится решение, которому нужны доказательства.', + 'Составить таблицу методов и побочных эффектов конкретного API.', + 'Ввести общий deadline и передавать его во все сетевые вызовы.', + 'Разрешить retry только для подтверждённых безопасных или идемпотентных операций.', + 'Ограничить число попыток, задержку и суммарное время ожидания.', + 'Сохранить попытки, статусы и тип ошибки без токенов и тела с персональными данными.', + 'Проверить отдельным тестом timeout после принятия записи и ответ 503 от посредника.', ]), - h2('Цена слишком раннего retry'), - p('Неверный retry опасен не количеством попыток, а подменой вопроса. Команда может начать спорить о задержке между попытками, хотя ещё не определила право повторять действие. Она может обсуждать backoff, хотя неизвестно, какой сигнал отличает временную недоступность от подтверждённого принятия. Она может назвать этот разговор восстановлением, хотя не названа точка, из которой что-либо восстанавливается. Каждая такая подмена создаёт видимость управления без проверяемого предмета.'), - p('Практичный контракт режет эту цепочку раньше. Он не пытается быть универсальной схемой отказоустойчивости и не выносит оценку в продакшен. Его цель уже: сохранить для будущего обсуждения место, где решение о повторе должно быть явным. Сценарий остаётся полезным, даже если позже будет выбран запрет на повтор, другой интерфейс или прекращение операции. Никакой из этих исходов не записан заранее.'), - h2('Контракт не должен притворяться политикой'), - p('Есть соблазн считать, что достаточно назвать policy хорошими словами: «осторожный retry», «защищённое переключение», «мягкое восстановление». Но такие ярлыки скрывают, кто принимает решение и по какому наблюдаемому условию. Один читатель понимает «осторожный» как запрет на повтор, другой — как повтор после паузы, третий — как передачу в очередь. Если этот разрыв обнаруживается после начала реализации, цена выше, чем у прямого вопроса: уже появились несовместимые ожидания от данных и интерфейса.'), - p('В P113 policy не выбирается намеренно. Такой отказ не является пропуском обязательного пункта. Он сохраняет обратимость: будущая работа сможет выбрать любой путь, который будет обоснован её собственным контекстом, либо не выбирать путь вовсе. Fixed literal не хранит флаг, который можно принять за включённый механизм. Вместо этого он хранит понятное отрицательное состояние. У читателя нет оснований превратить его в скрытый default при переносе текста в задачу или обсуждение.'), - p('Эту же дисциплину полезно применить к слову «ошибка». Ошибка соединения, ошибка ответа и ошибка предметного действия не обязаны иметь одну реакцию. В текущем сценарии мы не узнаём, был ли любой из них. Поэтому контракт не предлагает классификатор и не рисует таблицу кодов. Он фиксирует более раннюю развилку: без явно определённого смысла действия нельзя вывести допустимость повтора из внешнего симптома. Это узкая, но проверяемая граница практики.'), - p('Краткий текст наставника здесь не означает, что можно опустить цену выбора. Стоимость явной невыбранности — необходимость вернуться к вопросу позже. Стоимость неявного выбора — риск, что его будут исполнять уже сейчас, не понимая ограничений. Для будущей системы первый вариант обычно честнее: он не обещает экономию времени там, где отсутствует право на решение. Контракт помогает увидеть именно эту цену до того, как спор станет спором о якобы уже работающем механизме.'), - p('Полезный критерий качества такого контракта прост: другой читатель может повторить его проверку, не получив доступ к системе. Он должен увидеть временную границу, имя synthetic scenario, отрицательные states и причины stop. Если для понимания потребуются подразумеваемый сервис, предполагаемый журнал или внутреннее правило, контракт уже протекает. Тогда нужно не дописывать догадку, а вернуть поле к явной невыбранности и сохранить вопрос для отдельного решения.'), - h2('Пределы и следующий шаг'), - p('Этот материал не создаёт runbook, не проводит failure-injection и не определяет recovery. Он не утверждает наличие реплик, очереди, retries, метрик, логов или людей, которые будут исполнять дальнейшую работу. NIST SP 800-160 помогает назвать устойчивость как инженерную область с неблагоприятными условиями и восстановлением, но не превращает synthetic literal в доказательство готовности. RFC 9110 также не говорит за неизвестное прикладное действие.'), - p('Следующий шаг ограничен: только если появится отдельное авторизованное решение, открыть новый scope и заново определить допустимые evidence, метод и границы вывода. До этого P113 завершён честно: он показывает цену неверного retry и оставляет retry невыбранным. В этом плане нет production effect, победителя или обещания, что система когда-либо будет изменена.'), + h2('Ограничения и следующий шаг'), + p('Локальный сервер не моделирует потерю ответа после выполнения записи, балансировку и лимиты зависимостей. Список retryable-методов — не универсальная политика: API может ограничить повтор иначе. QUIC управляет транспортом, но не делает прикладной POST идемпотентным.'), + p('Следующий шаг — добавить к API контракт idempotency key для операций создания и проверить его на одинаковом ключе. Для чтения подключите bounded retry с общим deadline. Критерий готовности простой: повтор не выходит за время, не скрывает причину и не создаёт второй побочный эффект.'), ], refs); const mechanism = revision({ slug: 'editorial-2027-07-mechanism-reliability-capstone', - title: 'Большой разбор надёжности: как принять инженерное решение', - categories: ['Надёжность', 'Кейс'], + title: 'Почему retry иногда удваивает данные: идемпотентность на уровне протокола', + categories: ['Надёжность', 'Архитектура'], cover: '/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg', - excerpt: 'План на июль 2027: как сделать fault tree проверяемым, не выдавая структуру вопросов за восстановление или результат нагрузки.', - readingMinutes: 24, + excerpt: 'Разбираем границу между транспортной доставкой и эффектом бизнес-операции.', + readingMinutes: 15, }, [ - p('Это план/сценарий P113 на 2027-07 с source cutoff 2026-07-31. Механическая ошибка в разборе надёжности появляется, когда fault tree рисуют как готовое объяснение: верхняя вершина названа, ветви выглядят правдоподобно, и дальше их начинают читать как факты. Цена такой схемы — неверное инженерное решение. Она может направить внимание к восстановлению или нагрузке, хотя ни причина, ни состояние системы, ни сама нагрузка не были собраны.'), - p('Есть и вторая цена: слово «recovery» создаёт ощущение завершённости. В будущей статье легко написать, что путь восстановления существует, что запасная ветвь примет работу или что система выдержит давление. Но на редакционную дату нет actual incident, проверки под нагрузкой, SLO/SLI values, deployment, наблюдений, recovery result или production outcome. Здесь допускается единственный положительный output — synthetic-plan-hand-off с productionEffect: not-attempted; fault tree служит для фальсифицируемых вопросов, не для уверенного диагноза.'), - h2('Fault tree — карта условий опровержения'), - p('Полезное дерево отказов не отвечает «почему произошёл сбой». Оно делает видимым, что пришлось бы опровергнуть или подтвердить, прежде чем считать верхнее утверждение объяснённым. В P113 верхняя вершина намеренно синтетическая: named dependency unavailable scenario. Под ней нет hostname, client, трассы или сообщения. Вместо этого есть группы неизвестного: форма действия, условие нагрузки и выбранность восстановления. Каждая ветвь остаётся вопросом, пока evidence не разрешён и не получен в другом scope.'), - p('Такое устройство защищает от ложной полноты. Можно нарисовать пять причин и всё равно не знать, относятся ли они к одному времени, одной версии или одному действию. Можно назвать их независимыми, хотя для этого нет материала. В сценарии лучше меньше ветвей, но у каждой есть статус. not-collected значит, что состояние не наблюдалось; explicit-not-selected значит, что решение не принималось; not-defined значит, что критерий ещё не сформулирован. Эти слова не являются плохими данными — они запрещают делать из пустоты вывод.'), - table('Матрица ветвей для будущей фальсификации', ['Ветка дерева', 'Допустимое состояние P113', 'Какой вывод остановлен'], [ - ['Форма действия', 'named synthetic question', 'Что была конкретная операция или side effect'], - ['Нагрузка', 'not-collected', 'Что система выдержала либо не выдержала давление'], - ['Восстановление', 'explicit-not-selected', 'Что есть готовый путь возврата'], - ['Критерий уровня сервиса', 'not-defined', 'Что соблюдён SLO или измерен SLI'], - ['Наблюдение', 'not-collected', 'Что причина или порядок событий известны'], + p('Клиент видит timeout, но сервер мог уже применить команду. Цена автоматического retry — двойная запись, двойная отправка письма или два списания. Удалить повтор из клиента тоже нельзя: для чтения временный сбой должен переживаться. Причина в том, что транспорт сообщает о доставке байтов, а не о том, какой эффект зафиксирован на стороне приложения.'), + p('Разберём эту границу на локальном счётчике и HTTP-методах. Код не имитирует сложную базу: он показывает, что повторяемость — свойство контракта операции, а не только сетевого соединения. После примера отделим idempotency key от простого request id и назовём ограничения.'), + h2('Транспорт не знает бизнес-эффекта'), + p('TCP или QUIC могут доставить поток, обнаружить потерю и закрыть соединение. RFC 9000 описывает потоки, контроль потока и состояния соединения, но полезная нагрузка остаётся данными приложения. Клиент может получить исключение после отправки последнего байта. Из этого нельзя вывести, была ли транзакция применена.'), + table('Три разных идентификатора', ['Идентификатор', 'Кто создаёт', 'Задача'], [ + ['connection id', 'транспорт', 'сопоставить пакеты соединению'], + ['request id', 'клиент или входной сервис', 'связать лог одной попытки'], + ['idempotency key', 'клиент для операции', 'узнать повтор того же действия'], + ['resource id', 'доменная модель', 'какой объект изменяется'], ]), - h2('Нагрузка не равна одной оси на графике'), - p('Нагрузка часто выглядит как удобная переменная, которую можно добавить в диаграмму позже. Но без описания действия она ничего не объясняет. Неизвестно, что именно считается единицей работы, где возникает конкуренция, как различаются очереди и синхронные вызовы, какие данные допустимо использовать и какой результат вообще сравнивается. Поэтому P113 не содержит нагрузочного профиля, числа, генератора или запуска. Слово load: not-collected специально не оставляет места для фиктивной шкалы.'), - p('Это ограничение не запрещает будущую проверку. Оно отделяет её от текущего текста. Когда появится отдельный scope, ему понадобятся датированные входы, разрешение на метод, критерий останова и правила интерпретации. Даже тогда результат нельзя извлечь из одного количества операций: нужно проверить, что оно связано с конкретным верхним вопросом дерева. Пока эта связь отсутствует, график выглядел бы убедительнее, чем заслуживает его provenance.'), - figure('/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg', 'Матрица evidence для fault tree: ветви формы действия, нагрузки, восстановления, критерия и наблюдения отмечены как не собранные или не выбранные, а зелёный результат остаётся только synthetic hand-off.', 'Матрица различает отсутствие evidence и отсутствие решения. Красные ячейки показывают выводы, которые нельзя получить из такого состояния.'), - h2('Восстановление нужно разложить на решения'), - p('Восстановление — не переключатель в диаграмме. У него есть исходное состояние, сохранность предметного результата, порядок возврата и пределы допустимого действия. Если хотя бы один из этих элементов назван по памяти, читатель получает легенду вместо механизма. В P113 recovery не скрыт за словом «позже разберёмся»: он имеет точный статус explicit-not-selected. Это говорит не о недостатке инженерной зрелости, а о том, что выбор нельзя сделать из статьи без предметных ограничений.'), - p('NIST SP 800-160 Vol. 2 Rev. 1 использует устойчивость как способность предвидеть, выдерживать, восстанавливаться и адаптироваться к неблагоприятным условиям. Для этой статьи важен именно масштаб утверждения: документ задаёт направление системной инженерии, но не подтверждает, что конкретная система уже прошла любой из этих этапов. Поэтому ссылки на resilience нельзя использовать как подпись под synthetic tree с обещанием восстановления.'), - h2('Исполнимый пример проверяет границу, а не систему'), - p('Этот пример строит компактный вид fixed literal. Он вычисляет только строки и массивы, заданные в модуле. В нём нет чтения файлов, сети, clock, environment, секрета, телеметрии, процесса, данных или обращения к внешнему миру. Запуск не создаёт нагрузку и не делает recovery-попытку.'), - code("import { inspectFaultTreeLiteral } from './upgrade-2027-07.mjs';\n\nconst tree = inspectFaultTreeLiteral();\nconsole.log(tree.branches.join(' | '));\nconsole.log(tree.outcome);\n// request-shape-not-collected | capacity-not-collected | recovery-not-selected\n// not-attempted"), - p('Ветка capacity-not-collected не измеряет capacity и не предполагает её. recovery-not-selected не обозначает скрытый резервный механизм. А not-attempted относится к production effect всего evaluator, не к вымышленной попытке восстановления. У этого примера есть полезный критерий: при произвольном объекте или скрытом значении функция возвращает stop-status и не расширяет дерево догадкой.'), - h2('Порядок, который делает дерево проверяемым'), + figure('/assets/editorial/2027/reliability-capstone-2027-recovery-evidence-matrix.svg', 'Матрица границ надёжности: транспорт, HTTP-метод, ключ операции, запись и подтверждение результата.', 'Матрица не утверждает доставку конкретного запроса. Она показывает, какой слой отвечает на какой вопрос.'), + h2('Идемпотентность — не «повтор без ошибок»'), + p('Идемпотентность означает, что несколько одинаковых запросов имеют тот же ожидаемый эффект, что и один. Ответы могут различаться: первый 200, второй 204 или 404 в зависимости от контракта. Поэтому тест проверяет состояние и правила сервера, а не только код ответа.'), + p('Для POST дедупликация часто строится на idempotency key. Сервер сохраняет результат по ключу и возвращает его при повторе, не создавая новый объект. Ключ должен быть привязан к смысловой операции и иметь срок хранения, иначе одинаковая строка через месяц случайно подавит новую команду. Этот срок и область уникальности являются частью API-контракта.'), + h2('Учебный сервер с защитой от дубля'), + p('Код ниже принимает только локальные POST-запросы с заголовком Idempotency-Key. Вход — ключ и JSON-тело, но учебный сервер читает только ключ: это специально оставленная граница примера. Ожидаемый результат — два ответа с одним номером записи: второй запрос возвращает сохранённый результат. Это runnable пример поведения endpoint, а не готовая замена транзакции или базы данных.'), + code([ + "import { createServer } from 'node:http';", + '', + 'const results = new Map();', + 'let nextId = 1;', + '', + "const server = createServer((request, response) => {", + " if (request.method !== 'POST') { response.writeHead(405); response.end(); return; }", + " const key = request.headers['idempotency-key'];", + " if (!key) { response.writeHead(400); response.end('key required'); return; }", + ' if (!results.has(key)) results.set(key, { id: nextId++, state: \'created\' });', + " response.writeHead(200, { 'content-type': 'application/json' });", + ' response.end(JSON.stringify(results.get(key)));', + '});', + '', + "server.listen({ host: '127.0.0.1', port: 0 }, async () => {", + ' const endpoint = \'http://127.0.0.1:\' + server.address().port;', + " const options = { method: 'POST', headers: { 'Idempotency-Key': 'order-42' }, body: '{}' };", + ' console.log(await (await fetch(endpoint, options)).json());', + ' console.log(await (await fetch(endpoint, options)).json());', + ' server.close();', + '});', + ].join('\n')), + p('Обе строки имеют один и тот же id. В примере Map живёт в памяти процесса, поэтому перезапуск сервера уничтожит историю. В настоящем сервисе ключ должен проверяться атомарно рядом с записью результата, а тело и параметры операции должны входить в проверку совпадения. Один заголовок без серверной дедупликации ничего не гарантирует.'), + h2('Где начинается непредсказуемость'), + p('Если ключ повторно прислали с другим телом, сервер должен отклонить запрос или явно выбрать правило. Молча вернуть старый результат опасно: клиент может подумать, что новый адрес уже сохранён. Если срок хранения закончился, повтор того же ключ становится новой операцией. Эти варианты должны быть в контракте, тестах и журналах.'), + p('Request id не заменяет idempotency key. Он различает попытки, но каждый retry обычно получает новый request id. Ключ операции остаётся прежним и связывает попытки в одну семантическую команду. В логах полезно хранить оба поля, а также номер попытки и итоговое состояние.'), + h2('Как выбирать поведение'), + table('Вопрос до автоматического retry', ['Вопрос', 'Да', 'Нет'], [ + ['Операция безопасна или идемпотентна?', 'retry можно рассматривать', 'остановиться и проверить контракт'], + ['Есть общий deadline?', 'ограничить все попытки', 'ввести deadline'], + ['Сервер дедуплицирует ключ?', 'повторить с тем же key', 'не повторять побочный эффект'], + ['Ответ точно связан с записью?', 'сохранить result', 'проверить состояние отдельно'], + ]), + h2('Порядок проверки контракта'), ol([ - 'Сформулировать одну synthetic верхнюю вершину без имени реальной системы и без причины задним числом.', - 'Разделить ветви действия, нагрузки, критерия и восстановления так, чтобы одна ветвь не служила доказательством другой.', - 'Проставить not-collected, not-defined или explicit-not-selected вместо заполняющих слов.', - 'Проверить, что никакая ветвь не содержит hidden configuration, implicit retry, replica или recovery.', - 'Потребовать stop для claimed incident, claimed service level и любого operational result.', - 'Передать дерево только как вопрос о будущем evidence, не как основание менять систему.', + 'Назвать доменное действие и его побочный эффект, не ограничиваясь HTTP-методом.', + 'Проверить правило повторения в RFC-контракте API и коде сервера.', + 'Для создания определить формат, срок и область уникальности idempotency key.', + 'Сделать тест «ответ потерян после записи» и проверить, что повтор возвращает тот же результат.', + 'Сохранить request id, operation key и номер попытки в структурированном логе.', + 'Описать ответ на несовпадающее тело и истёкший ключ.', ]), - h2('Что значит фальсифицируемость здесь'), - p('Фальсифицируемость в этом сценарии не означает тестировать систему прямо сейчас. Она означает, что у будущего утверждения заранее есть условия, при которых оно не может быть принято. Например, если не определён критерий уровня сервиса, нельзя назвать его соблюдённым. Если нет наблюдения, нельзя утверждать порядок причин. Если recovery не выбран, нельзя рассказывать о времени возврата. Такое дерево не делает надежность измеренной; оно делает преждевременный вывод недопустимым.'), - p('Это особенно важно в материале, который читатель может принять за «большой разбор». Большим должен быть не масштаб уверенности, а полнота границ: какие слои не смешиваются, что не собрано, откуда мог бы появиться будущий факт и что его всё равно не докажет. Конкретный вывод появится только в новом record с собственной датой, входами и правилами. P113 не резервирует для него финал.'), - h2('Дерево не заменяет причинную связь'), - p('Связи на схеме легко читаются сильнее, чем задумано. Стрелка от нагрузки к верхней вершине может выглядеть как утверждение, что нагрузка вызвала недоступность. Стрелка от восстановления к результату может выглядеть как доказательство, что выбран путь возврата. В P113 связи показывают только состав будущей проверки: эти темы нельзя склеить в один вывод, пока каждая не получит собственное основание. Схема должна помогать читателю задавать вопрос, а не отвечать вместо него.'), - p('Поэтому здесь нет вероятностей, весов ветвей, ранжирования причин и числового порога. Такие значения могут быть нужны в другом artefact, однако им необходимы происхождение и условия. Число без method превращает дерево в декорацию точности. Даже ранжирование «самая вероятная причина» недопустимо, когда отсутствуют observations. Текущий literal делает более скромное, но ценное действие: одинаково отказывает и красивой догадке, и скрытой конфигурации.'), - p('Нагрузка особенно хорошо показывает эту разницу. В одном контексте вопрос о нагрузке касается входного потока, в другом — конкуренции за ресурс, в третьем — внешнего ограничения. Перенос готовой ветви между ними без определения единицы работы создаёт ложную сопоставимость. P113 не заставляет будущий scope измерять всё подряд. Он требует только, чтобы намерение проверить нагрузку не называлось уже проведённой проверкой и не становилось доказательством recovery.'), - p('Такое дерево можно считать отрицательной спецификацией. Оно описывает утверждения, которых нельзя сделать, а не действия, которые уже выполнены. В инженерной работе это бывает полезнее предварительного решения: команда видит, какие данные действительно изменят выбор, а какие лишь сделают отчёт объёмнее. Если ни один будущий факт не нужен для решения, отдельный scope можно не открывать. Сценарий не подталкивает к сбору ради самого дерева.'), - h2('Пределы и следующий шаг'), - p('Статья не описывает реальное дерево существующего инцидента, не назначает SLO/SLI и не предлагает load test, failure injection, deploy или rollout. RFC 9000 и RFC 9110 помогают различать уровни протокола и семантики, но они не заменяют evidence о неизвестном действии. У P113 нет скрытого конфигурационного файла, инструмента проверки, monitoring output или recovery plan; их отсутствие отражено в literal, а не спрятано в сноске.'), - p('Следующий шаг возможен только как новый авторизованный scope: там можно решить, нужен ли факт для конкретного выбора, какие inputs допустимы и какой результат будет честно отрицательным. Если такого scope не будет, дерево всё равно выполнило работу: оно не позволило назвать нагрузку, recovery или service level там, где их нет. Для текущего пакета итог остаётся synthetic-plan-hand-off и productionEffect: not-attempted.'), + h2('Ограничения и следующий шаг'), + p('Map в примере не даёт атомарности при нескольких процессах, не переживает рестарт и не защищает от бесконечного роста. QUIC не решает дедупликацию, а HTTP-метод не раскрывает внутреннюю транзакцию. Для платежей и других критичных действий нужен отдельный контракт, тест отказа и согласованное хранилище результата.'), + p('Следующим шагом добавьте к одному endpoint тест с повтором одного ключа и другим телом. Ожидаемый результат должен быть явным: конфликт или тот же ответ, но не новая запись. После этого только выбирайте retry policy для клиента.'), ], refs); const field = revision({ slug: 'editorial-2027-07-field-reliability-capstone', - title: 'Большой разбор надёжности: кейс с ограничениями и выводами', - categories: ['Надёжность', 'Кейс'], + title: 'Лог повторов, который помогает расследовать сбой: attempt, deadline и причина', + categories: ['Надёжность', 'Наблюдаемость'], cover: '/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg', - excerpt: 'План на июль 2027: как передать вопрос о надёжности без легенды об incident, SLO или уже принятом operational решении.', - readingMinutes: 22, + excerpt: 'Какие поля сохранить, чтобы отделить временный сбой от исчерпанного времени и опасного повтора.', + readingMinutes: 14, }, [ - p('P113 — будущий редакционный сценарий на 2027-07 с source cutoff 2026-07-31. В полевом hand-off самая дорогая ошибка — написать гладкую историю вместо границы знания: «был incident», «уровень сервиса нарушен», «система восстановилась». Такие фразы удобно передавать дальше, но цена — ложная оперативная память. Следующий читатель тратит время на объяснение уже якобы известных событий, хотя исходные evidence, время, наблюдения и результат никогда не были частью этого пакета.'), - p('Вторая цена возникает, когда hand-off путают с поручением на работу. В карточку добавляют неявный retry, предполагаемую реплику, способ восстановления или ожидаемую победу. В P113 этого нет: не названы actual incident, SLO/SLI values, telemetry, deployment, owner, runbook, rollout и production outcome. Положительный evaluator output ровно один — synthetic-plan-hand-off с productionEffect: not-attempted. Полевая статья передаёт не результат и не легенду, а честно ограниченный вопрос.'), - h2('Что делает hand-off пригодным к чтению'), - p('Хорошая передача не пытается заполнить все поля. Она отделяет то, что названо, от того, что ещё не существует как evidence. В нашем fixed literal названы editorial date, planning issue, source cutoff и synthetic scenario. Это достаточно, чтобы понять временную границу и тему. Incident, observation, configuration и load остаются not-collected; service level остаётся not-defined. Такой набор не выглядит подробным отчётом — и именно поэтому его нельзя ошибочно процитировать как отчёт.'), - p('У hand-off есть и отрицательная часть: он должен явно сказать, какие shortcuts запрещены. Нельзя подставить endpoint, реальный сервис, команду, файл конфигурации, след внешней системы или фразу о завершённом восстановлении. Нельзя считать, что отсутствие значения означает «по умолчанию можно повторить». Если читателю нужен один из этих фактов, это причина открыть отдельный scope, а не редактировать значение задним числом в будущем плане.'), - table('Карточка передачи без легенды об операции', ['Поле', 'Значение в P113', 'Почему этого достаточно'], [ - ['Временная граница', '2026-07-31 → 2027-07', 'Не даёт перепутать будущий материал с историей'], - ['Сценарий', 'named synthetic dependency unavailable', 'Фокусирует вопрос без указания реального target'], - ['Incident и наблюдение', 'not-collected', 'Не превращает отсутствие записи в вымышленный факт'], - ['Уровень сервиса', 'not-defined', 'Не имитирует SLO/SLI без условия и значения'], - ['Выход evaluator', 'synthetic-plan-hand-off', 'Передаёт план без попытки production effect'], + p('В журнале часто остаётся только «request failed». Цена такой записи — не понять, был ли это timeout первой попытки, ответ 503 второй или отказ от повтора операции записи. Без номера попытки и общего deadline команда увеличивает retry, не видя, что каждый новый запрос уже съедает остаток времени.'), + p('Соберём компактное событие попытки и локальный исполнитель, который возвращает результат или ошибку с причиной. Входы фиксированы: метод, endpoint без секретов, лимит попыток и deadline. Выход — список событий и итог. Пример учебный и не подключается к журналу, сети или системе наблюдаемости.'), + h2('Одно событие — одна попытка'), + p('Не смешивайте в одну строку попытку и операцию. Операция имеет устойчивый идентификатор, попытка — порядковый номер, начало, длительность, статус и причину. deadlineRemainingMs показывает, сколько времени оставалось перед вызовом. Вместе эти поля позволяют увидеть, где именно закончился бюджет времени.'), + table('Минимальные поля retry-события', ['Поле', 'Пример', 'Зачем'], [ + ['operationId', 'op-42', 'связать попытки одного действия'], + ['attempt', '2', 'видеть число вызовов'], + ['method', 'GET', 'проверить семантику повтора'], + ['status', '503 или timeout', 'отличить ответ от исключения'], + ['remainingMs', '180', 'увидеть границу времени'], + ['decision', 'retry или return', 'зафиксировать действие клиента'], ]), - h2('Почему отсутствие owner — тоже граница'), - p('В реальной работе адресат передачи имеет значение, но здесь его нельзя выдумать. Имя, команда или роль выглядели бы как назначение, а значит как уже совершённое организационное действие. P113 не назначает и не предполагает owner. Вместо этого output называет только следующий тип действия: открыть отдельный авторизованный evidence scope, если конкретное решение потребует фактов. Такая формулировка сохраняет свободу будущего контекста и не заставляет несуществующего участника отвечать за вымышленную историю.'), - p('Эта осторожность не делает карточку безличной. Она даёт будущему читателю ясное правило: используйте её как постановку вопроса, а не как основание для изменения системы. Если новая работа будет разрешена, ей понадобятся собственные дата, допустимые inputs, метод, хранение evidence и правило отрицательного вывода. Ничто из этого нельзя вывести из P113 автоматически. Любая такая попытка должна быть остановлена evaluator до того, как станет частью текста.'), - figure('/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg', 'Схема передачи: временная граница и синтетический вопрос проходят через три проверки, а скрытый evidence, implicit retry и заявленный результат направляются в стоп; единственный выход — synthetic plan hand-off.', 'Петля показывает не реальный incident response, а редакционный маршрут для будущего вопроса без owner, deployment или operational результата.'), - h2('Исполнимый пример сохраняет неполноту'), - p('Ниже пример для локального Node запуска. Он берёт только immutable literal, вызывает чистую функцию и печатает уже ограниченный hand-off. Модуль не читает file, network, environment, clock, secret, telemetry, system или data; он не создаёт внешнюю работу. Поэтому вывод демонстрирует форму проверки, а не состояние какой-либо системы.'), - code("import { inspectHandoffLiteral } from './upgrade-2027-07.mjs';\n\nconst handoff = inspectHandoffLiteral();\nconsole.log(handoff.accepted, handoff.incident, handoff.serviceLevel);\n// synthetic-plan-hand-off not-collected not-defined"), - p('Важная часть примера — не строка success, а отсутствие скрытого содержимого. accepted означает, что форма literal удовлетворяет редакционным ограничениям. Оно не означает success операции, восстановление или соответствие уровню сервиса. incident и serviceLevel остаются видимо пустыми по смыслу. Если передать input с claimed evidence, evaluator вернёт stop-status, а не преобразует claim в более осторожный текст.'), - h2('Порядок передачи, который не создаёт легенду'), + figure('/assets/editorial/2027/reliability-capstone-2027-response-handoff-loop.svg', 'Цикл обработки ответа: попытка, фиксация статуса, проверка deadline, решение о повторе или возврате.', 'Цикл сохраняет событие до следующего вызова. Конечный ответ и исчерпанное время становятся различимыми состояниями.'), + h2('Учебный исполнитель с фиксированными ответами'), + p('Функция принимает массив заранее заданных результатов. Вход не содержит внешних данных: это позволяет проверить порядок событий и границу попыток. Ожидаемый результат — после двух 503 возвращается 200, а в событиях остаются номера 1, 2 и 3. В реальном клиенте массив заменяется сетевым вызовом, а формат события сохраняется.'), + code([ + 'function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {', + ' const events = [];', + ' for (let index = 0; index < Math.min(maxAttempts, responses.length); index += 1) {', + ' const result = responses[index];', + ' const retryable = method === \'GET\' && result.status === 503;', + ' const remainingMs = Math.max(0, deadlineMs - index * 120);', + ' events.push({ operationId, attempt: index + 1, method, status: result.status, remainingMs, decision: retryable ? \'retry\' : \'return\' });', + ' if (remainingMs === 0) return { result: { status: \'deadline\' }, events };', + ' if (!retryable) return { result, events };', + ' }', + ' return { result: { status: \'deadline\' }, events };', + '}', + '', + "console.log(runBoundedRetries({ operationId: 'op-42', method: 'GET', responses: [{ status: 503 }, { status: 503 }, { status: 200 }] }));", + ].join('\n')), + p('В выводе три события, а итоговый статус — 200. Если заменить метод на POST, первая запись сразу получит решение return. Это не утверждение о любом POST: исполнитель демонстрирует консервативную политику, которую нужно заменить контрактом конкретного endpoint. Название decision полезнее, чем свободная фраза в логе.'), + h2('Причина и действие должны быть разными полями'), + p('Причина — timeout, 503, 429, DNS error или отмена. Действие — retry, return, fail или cancel. Если записать «retry из-за временной ошибки» одной строкой, невозможно посчитать, какой класс ответов создал нагрузку. Раздельные поля позволяют построить таблицу по методу и не смешивать сетевой отказ с бизнес-ошибкой.'), + p('Не сохраняйте тело ответа по умолчанию. Для диагностики обычно достаточно status, безопасного подтипа и размера. URL нормализуйте, удаляя query-секреты. Идентификатор операции не должен быть email, номером карты или токеном. Чем больше произвольного текста в событии, тем выше стоимость хранения и риск утечки.'), + h2('Число попыток не равно надёжности'), + table('Что можно увидеть в событиях', ['Набор', 'Интерпретация', 'Действие'], [ + ['1 timeout, return', 'deadline слишком мал или вызов завис', 'разделить connect/read timeout'], + ['3 × 503, deadline', 'ответ не восстановился за время', 'проверить зависимость и backoff'], + ['1 × 429, return', 'сработал rate limit', 'прочитать Retry-After и снизить темп'], + ['POST, 503, return', 'повтор запрещён политикой', 'проверить состояние по operation key'], + ]), + h2('Исчерпанное время — отдельный результат'), + p('Если последняя попытка закончилась на deadline, это не то же самое, что ответ 503. Сервер мог принять запрос, а клиент не успел дождаться ответа. В событии сохраняйте lastAttempt и lastKnownStatus, но не превращайте неизвестное состояние в «операция не выполнена». Для чтения можно вернуть контролируемую ошибку, для записи — запросить состояние по ключу операции.'), + p('Такой вывод меняет следующий шаг. Для timeout чтения проверяем границы времени и зависимость. Для timeout записи сначала ищем безопасный способ узнать состояние, а не запускаем ещё один POST. Небольшая разница в двух полях предотвращает самый дорогой вид автоматизма — повтор действия, о результате которого клиент уже не знает.'), + h2('Порядок внедрения события'), ol([ - 'Положить в карточку только fixed editorial date, planning issue, source cutoff и named synthetic scenario.', - 'Отметить incident, observation, configuration и load как not-collected; не заменять их пересказом.', - 'Оставить service level как not-defined, пока отдельный scope не определит условие и смысл измерения.', - 'Проверить, что retry, replica и recovery имеют явный статус невыбранности, а не default.', - 'Остановить карточку при hidden evidence, любом claimed operational result или произвольном input.', - 'Передать только synthetic plan hand-off; новый scope открывается лишь при отдельно разрешённом решении.', + 'Выбрать operationId и правило его жизненного цикла.', + 'Добавить attempt, method, status или errorClass и оставшееся время.', + 'Разделить decision от причины и ограничить перечисление значений.', + 'Удалить query-секреты, cookie, тело и персональные поля до записи.', + 'Проверить наборы GET/503, GET/timeout, POST/503 и успешный первый вызов.', + 'Считать распределение попыток и долю завершений по deadline, не меняя логику по одному шумному сообщению.', ]), - h2('Что не следует добавлять для «пользы»'), - p('Самые опасные добавления обычно выглядят практично: короткая команда, пример параметра, предполагаемый порядок восстановления, имя будущей роли, ссылка на несуществующий график или фраза «это уже проверяли». Все они сужают интерпретацию так, будто у автора были доступ и наблюдения. В P113 даже пример кода не становится мостом к реальной системе: он работает только с литералом и демонстрирует, что отсутствующие факты не имеют fallback.'), - p('Так же опасна ретроспективная уверенность. Выражение «в этом случае» может незаметно звучать как рассказ о случившемся. Здесь точнее говорить «если будущий scope определит» и сразу называть границу. Это не бюрократическая оговорка. Она сохраняет возможность для будущего исследования прийти к отсутствию проблемы, к иной постановке или к отказу от действия. Хороший hand-off не заставляет следующие evidence подтвердить его заголовок.'), - h2('Передача должна оставаться обратимой'), - p('Hand-off часто становится необратимым из-за одной лишней детали. Допустим, в нём появляется имя условного компонента. Следующий читатель начинает искать его историю, связывает с ним будущую задачу и уже не замечает, что имя было лишь примером. То же происходит с фразой о «типичном» механизме: она незаметно превращается в обязательный путь. В P113 обратимость охраняется буквально: в fixed literal нет target, идентификатора системы, предполагаемого payload или конфигурационного ключа.'), - p('Обратимость нужна не для того, чтобы бесконечно откладывать решение. Она даёт будущему scope право сузить, изменить или закрыть вопрос без переписывания прошлого. Если окажется, что решение не требует новых evidence, hand-off останется корректным. Если окажется, что нужен другой вопрос, старая карточка не будет мешать ему притворной точностью. Если разрешённый материал укажет на отсутствие основания для действия, это также не противоречит P113. План ничего не обещал доказать.'), - p('У такой формы есть цена: читатель не получает готовый operational маршрут. Но это честная цена за то, что пакет не содержит скрытого доступа или назначений. В поле особенно легко перепутать скорость передачи и скорость решения. Первая достигается короткой карточкой с фактами; вторая требует, чтобы факты существовали и были пригодны к применению. Когда второй части нет, ускорять её вымыслом означает передать долг следующему человеку под видом помощи.'), - p('Проверяемый literal делает эту норму технической, а не только редакторской. Нельзя случайно добавить object с дополнительными данными: evaluator сравнивает вход с известными fixed cases и fail-closed отвергает произвольную форму. Нельзя спрятать улучшенный вывод в строке requested output. Нельзя превратить пустое поле в положительное заключение. Такой контроль не проверяет мир, зато проверяет, что текст о мире не стал сильнее своего источника.'), - h2('Связь с официальными источниками без подмены доказательства'), - p('RFC 9110 нужен этой статье не для описания запуска. Он помогает не смешивать семантику действия с уровнем связи и напоминает, что условия автоматического повтора имеют границы. RFC 9000 показывает, что у транспорта есть самостоятельные механизмы, но ни один из них не является доказательством прикладного результата. NIST SP 800-160 задаёт официальную рамку для разговора об устойчивости, включая восстановление, однако не выдаёт future hand-off за осуществлённую практику.'), - p('Таким образом, источники поддерживают словарь и пределы интерпретации. Они не поддерживают fictional incident materials и не могут подтвердить, что в P113 существует реальный сервис, SLO, след наблюдения или восстановление. Это различие стоит написать прямо: ссылка на норму говорит о том, как точно рассуждать; evidence говорит о том, что произошло. В этой статье evidence отсутствует намеренно.'), - h2('Пределы и следующий шаг'), - p('Field-материал не создаёт ticket, очередь, owner, runbook, мониторинг, deployment, rollout или запись об incident. Он не проверяет доступность и не делает recovery, не собирает SLO/SLI и не выбирает winner. Его собственный результат не нужно «доводить до продакшена»: productionEffect: not-attempted — это обязательное свойство безопасного evaluator, а не временная задержка скрытой работы.'), - p('Следующий шаг узкий: при появлении отдельного авторизованного решения создать новый artefact с самостоятельной временной границей и методикой; он может закончиться также отсутствием результата. Пока такого разрешения нет, корректный hand-off не разрастается. P113 оставляет читателю один честный предмет: synthetic question о надёжности без легенды о прошлом и без обещания будущей операции.'), + h2('Ограничения и следующий шаг'), + p('Локальный исполнитель не показывает распределённую доставку логов, часы разных узлов и повтор после потери ответа. Поля события должны быть согласованы между клиентом и сервером, иначе operationId распадётся на несколько имён. Наблюдаемость не делает retry безопасным — она только показывает, что он делает.'), + p('Следующий шаг — подключить эти поля к одному безопасному GET и построить отчёт по p95 попыток и доле timeout. Для записи сначала добавьте operation key и проверку состояния. Только после этого увеличивайте число повторов или задержку.'), ], refs); export const revisions = deepFreeze([practice, mechanism, field]); + export function verifyRevisionsAgainstFixture() { const fixture = runFixedReliabilityFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); - return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|потер|дорог)/i.test(text.slice(0, 1200)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-07/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); + return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } + if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-08.mjs b/web/scripts/upgrade-2027-08.mjs index 56ca1ff..2395037 100644 --- a/web/scripts/upgrade-2027-08.mjs +++ b/web/scripts/upgrade-2027-08.mjs @@ -11,10 +11,13 @@ const p = (text) => '

    ' + text + '

    '; const h2 = (text) => '

    ' + text + '

    '; const code = (text) => '
    ' + escapeHtml(text) + '
    '; const ol = (items) => '
      ' + items.map((item) => '
    1. ' + item + '
    2. ').join('') + '
    '; -const figure = (src, alt, caption) => '
    ' + alt + '
    ' + caption + '
    '; +const figure = (src, alt, caption) => '
    ' + escapeHtml(alt) + '
    ' + caption + '
    '; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } +function cloneFixed(value) { + return JSON.parse(JSON.stringify(value)); +} + function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); @@ -22,260 +25,303 @@ function deepFreeze(value) { } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

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

|$)/, '')); } + +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function bodyText(html) { + return plainText(html.replace(/

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

|$)/, '')); +} const REFERENCES = deepFreeze({ - ssdf: { - title: 'NIST SP 800-218 — Secure Software Development Framework (SSDF) Version 1.1', - url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-218.pdf', - version: 'NIST Special Publication 800-218, Version 1.1, February 2022, immutable dated PDF', - }, - controls: { - title: 'NIST SP 800-53 Rev. 5 — Security and Privacy Controls for Information Systems and Organizations', - url: 'https://doi.org/10.6028/NIST.SP.800-53r5', - version: 'NIST Special Publication 800-53 Revision 5, final dated 10 December 2020, immutable PDF', - }, + ssdf: { title: 'NIST SP 800-218 — Secure Software Development Framework (SSDF) Version 1.1', version: 'February 2022', url: 'https://csrc.nist.gov/pubs/sp/800/218/final' }, + asvs: { title: 'OWASP Application Security Verification Standard', version: 'Version 5.0.0, released 30 May 2025', url: 'https://owasp.org/www-project-application-security-verification-standard/' }, + controls: { title: 'NIST SP 800-53 Rev. 5 — Security and Privacy Controls', version: 'September 2020, updates as of 10 December 2020', url: 'https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final' }, }); + function sources(entries) { return '
    ' + entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; - return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '
  • '; + return '
  • ' + escapeHtml(ref.title) + ' — ' + escapeHtml(ref.version) + '. ' + escapeHtml(use) + ' Граница применимости: ' + escapeHtml(boundary) + '
  • '; }).join('') + '
'; } const FIXED_SECURITY_CARDS = deepFreeze({ - 'security-capstone-boundary-card-v1': { - id: 'security-capstone-boundary-card-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31', - asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, - path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, - control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'not-attempted' }, - handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'security-capstone-bad-date-v1': { - id: 'security-capstone-bad-date-v1', editorialDate: '', planningIssue: '2027-08', sourceCutoff: '2026-07-31', - asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'security-capstone-path-claimed-v1': { - id: 'security-capstone-path-claimed-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31', - asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'claimed', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'security-capstone-control-claimed-v1': { - id: 'security-capstone-control-claimed-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31', - asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'claimed', evidence: 'not-collected', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'security-capstone-evidence-claimed-v1': { - id: 'security-capstone-evidence-claimed-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31', - asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'claimed', deployment: 'not-attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'synthetic-plan-hand-off', - }, - 'security-capstone-positive-effect-v1': { - id: 'security-capstone-positive-effect-v1', editorialDate: '2026-07-31', planningIssue: '2027-08', sourceCutoff: '2026-07-31', - asset: { label: 'synthetic-asset-label', classification: 'not-declared', boundary: 'not-mapped' }, path: { label: 'synthetic-path-question', states: 'not-mapped', assumptions: 'not-accepted' }, control: { coverage: 'not-selected', evidence: 'not-collected', deployment: 'attempted' }, handoff: { recipient: 'not-assigned', route: 'not-opened' }, requestedOutput: 'security-improved', - }, + publicProfile: { role: 'user', owner: 'self', action: 'read', resource: 'profile' }, + foreignProfile: { role: 'user', owner: 'other', action: 'read', resource: 'profile' }, + adminAudit: { role: 'admin', owner: 'other', action: 'read', resource: 'audit' }, + unknownRole: { role: 'unknown', owner: 'self', action: 'read', resource: 'profile' }, }); -export function createSecurityCapstoneCard(id = 'security-capstone-boundary-card-v1') { - const value = FIXED_SECURITY_CARDS[id]; - return value ? deepFreeze(cloneFixed(value)) : undefined; +export function createSecurityCapstoneCard(id = 'publicProfile') { + return FIXED_SECURITY_CARDS[id] ? deepFreeze(cloneFixed(FIXED_SECURITY_CARDS[id])) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } + export function assessSecurityCapstoneCard(input) { - const known = Object.values(FIXED_SECURITY_CARDS).some((item) => JSON.stringify(item) === JSON.stringify(input)); - if (!known) return stop('stop-unknown-fixed-literal', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-literal'); - if (input.editorialDate !== '2026-07-31' || input.planningIssue !== '2027-08' || input.sourceCutoff !== '2026-07-31') return stop('stop-temporal-boundary-required', 'editorial-date-planning-issue-and-source-cutoff-must-be-exact', 'restore-the-fixed-temporal-boundary'); - if (input.asset.classification !== 'not-declared' || input.asset.boundary !== 'not-mapped' || input.path.states !== 'not-mapped' || input.path.assumptions !== 'not-accepted') return stop('stop-claimed-asset-or-path', 'asset-and-path-must-remain-unmapped-and-unaccepted', 'remove-claimed-asset-or-path'); - if (input.control.coverage !== 'not-selected' || input.control.evidence !== 'not-collected') return stop('stop-claimed-control-or-evidence', 'control-coverage-and-evidence-must-remain-unselected', 'remove-control-or-evidence-claim'); - if (input.control.deployment !== 'not-attempted' || input.handoff.recipient !== 'not-assigned' || input.handoff.route !== 'not-opened') return stop('stop-claimed-execution-or-owner', 'deployment-owner-and-route-cannot-be-inferred', 'keep-execution-and-assignment-unattempted'); - if (input.requestedOutput !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'future-security-scenario-cannot-claim-a-positive-result', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', cardId: input.id, planningIssue: input.planningIssue, asset: deepFreeze(cloneFixed(input.asset)), path: deepFreeze(cloneFixed(input.path)), control: deepFreeze(cloneFixed(input.control)), handoff: deepFreeze(cloneFixed(input.handoff)), productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-scope-only-if-a-decision-needs-evidence' }); + if (!input || typeof input !== 'object') return { status: 'deny', reason: 'invalid-input' }; + if (input.role === 'admin' && input.resource === 'audit' && input.action === 'read') return { status: 'allow', reason: 'role-permission' }; + if (input.role === 'user' && input.owner === 'self' && input.resource === 'profile' && input.action === 'read') return { status: 'allow', reason: 'object-ownership' }; + return { status: 'deny', reason: 'default-deny' }; } -export function inspectAssetBoundaryLiteral() { const card = createSecurityCapstoneCard(); return deepFreeze({ asset: card.asset.label, boundary: card.asset.boundary, path: card.path.states, output: assessSecurityCapstoneCard(card).status }); } -export function inspectCoverageLiteral() { const card = createSecurityCapstoneCard(); return deepFreeze({ path: card.path.label, coverage: card.control.coverage, evidence: card.control.evidence, effect: assessSecurityCapstoneCard(card).productionEffect }); } -export function inspectSecurityHandoffLiteral() { const result = assessSecurityCapstoneCard(createSecurityCapstoneCard()); return deepFreeze({ status: result.status, recipient: result.handoff.recipient, nextAction: result.nextAction }); } + +export function inspectAssetBoundaryLiteral() { + const card = createSecurityCapstoneCard(); + return deepFreeze({ resource: card.resource, owner: card.owner, result: assessSecurityCapstoneCard(card).status }); +} + +export function inspectCoverageLiteral() { + return deepFreeze({ checks: ['role', 'owner', 'action'], default: 'deny', accepted: assessSecurityCapstoneCard(createSecurityCapstoneCard()).status }); +} + +export function inspectSecurityHandoffLiteral() { + return deepFreeze({ input: createSecurityCapstoneCard('foreignProfile'), result: assessSecurityCapstoneCard(createSecurityCapstoneCard('foreignProfile')) }); +} + export function runSecurityCapstoneFixture() { - const expected = [ - ['security-capstone-boundary-card-v1', 'synthetic-plan-hand-off'], ['security-capstone-bad-date-v1', 'stop-temporal-boundary-required'], ['security-capstone-path-claimed-v1', 'stop-claimed-asset-or-path'], ['security-capstone-control-claimed-v1', 'stop-claimed-control-or-evidence'], ['security-capstone-evidence-claimed-v1', 'stop-claimed-control-or-evidence'], ['security-capstone-positive-effect-v1', 'stop-claimed-execution-or-owner'], - ]; - const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessSecurityCapstoneCard(createSecurityCapstoneCard(id)).status })); - const sample = createSecurityCapstoneCard(); - return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.asset) && Object.isFrozen(sample.control), checks: deepFreeze(checks) }); + const checks = [ + ['publicProfile', 'allow'], + ['foreignProfile', 'deny'], + ['adminAudit', 'allow'], + ['unknownRole', 'deny'], + ].map(([id, expected]) => ({ id, expected, actual: assessSecurityCapstoneCard(createSecurityCapstoneCard(id)).status })); + return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks }); } function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; - if (proseLength < 10000 || proseLength > 13000) throw new Error(meta.slug + ': body length ' + proseLength); + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } + const refs = [ - { key: 'ssdf', use: 'Даёт датированную официальную лексику практик безопасной разработки и коммуникации между участниками.', boundary: 'Не доказывает наличие практики, проверки, уязвимости, результата или контроля в этом сценарии.' }, - { key: 'controls', use: 'Помогает различать функцию контроля и уверенность в ней, не смешивая карту с подтверждением.', boundary: 'Не подтверждает, что какой-либо контроль выбран, развёрнут, сработал или покрывает конкретный путь.' }, + { key: 'ssdf', use: 'Даёт общий словарь практик безопасной разработки и связывает защиту с жизненным циклом продукта.', boundary: 'Рамка не подтверждает наличие контроля, уязвимости или результата в конкретном приложении.' }, + { key: 'asvs', use: 'Даёт версионируемые требования для проверки технических контролей веб-приложения.', boundary: 'Стандарт не заменяет модель угроз, настройку инфраструктуры и тестирование конкретных данных.' }, + { key: 'controls', use: 'Помогает различить сам контроль и уверенность в его работе.', boundary: 'Каталог не выбирает права вашего ресурса и не является отчётом о соответствии.' }, ]; const practice = revision({ - slug: 'editorial-2027-08-practice-security-capstone', title: 'Большой разбор безопасности: актив, граница и договор о проверке', categories: ['Безопасность', 'Инженерные практики'], cover: '/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg', excerpt: 'План на август 2027: как назвать актив и границу будущего вопроса, не создавая карту реальной системы.', readingMinutes: 26, + slug: 'editorial-2027-08-practice-security-capstone', + title: 'SSRF начинается с URL: проверяем адрес до сетевого вызова', + categories: ['Безопасность', 'Backend'], + cover: '/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg', + excerpt: 'Практическая защита server-side запроса: схема проверки URL, allowlist и запрет обхода через адресную запись.', + readingMinutes: 14, }, [ - p('P114 — плановый материал на 2027-08 с редакционной датой и source cutoff 2026-07-31. Практическая ошибка начинается с невинной фразы «защитим важные данные»: команда уже выбрала слова, но ещё не назвала актив, его границу и то, что будет считаться допустимым свидетельством. Цена появляется сразу: следующий читатель дорисовывает систему, маршрут и решение по привычке, а затем обсуждает придуманное как обязательство.'), - p('Вторая цена — подмена договора о проверке самой проверкой. В будущем документе легко оставить название компонента, намёк на доступ или обещание «покрыть риск», хотя исходных материалов нет. Для P114 не существует настоящего актива, классификации, границы, маршрута, контроля, evidence, владельца или развертывания. Допустимый positive output здесь один: synthetic-plan-hand-off, причём productionEffect всегда not-attempted. Это не отчёт и не security-test; это форма, которая не даёт неизвестному стать правдоподобным фактом.'), - h2('Актив сначала называют, затем запрещают дополнять'), - p('Актив в этом сценарии — не объект в инфраструктуре и не запись в каталоге. Это synthetic label, нужный, чтобы у будущего разговора была тема. Метка synthetic-asset-label не содержит пользователя, файла, ключа, сервиса, класса данных или степени важности. За ней нельзя угадывать тип информации. Такое ограничение кажется бедным только пока не сравнишь его с альтернативой: любой реалистичный пример уже задаёт лишний контекст и заставляет выбирать защиту под несуществующий объект.'), - p('Граница отвечает на другой вопрос: что из разговора не попадает в документ. Её нельзя заменить существительным «приложение» или «платформа». В literal граница имеет значение not-mapped. Это не пустое поле, которое удобный reviewer заполнит позднее, а явный стоп для всех выводов о входе, выходе, хранении, передаче и доверии. Пока граница не принадлежит отдельному разрешённому scope, обсуждать «где защищать» преждевременно.'), - table('Минимальная карточка актива без ложной конкретики', ['Поле', 'Значение P114', 'Что это запрещает'], [ - ['Метка актива', 'synthetic-asset-label', 'Выдать пример за существующий объект'], - ['Классификация', 'not-declared', 'Назвать данные, ценность или режим доступа'], - ['Граница', 'not-mapped', 'Рисовать вход, выход, хранение или доверенную зону'], - ['Путь', 'not-mapped', 'Считать порядок действий известным'], - ['Допущения', 'not-accepted', 'Сделать удобную догадку проектным решением'], + p('Endpoint принимает URL изображения и скачивает его сервером. Цена ошибки — превратить обычное поле формы в доступ к внутреннему адресу, metadata-сервису или административному интерфейсу. Проверка только на строку https:// не защищает: URL имеет имя, порт, учетные данные, редирект и адрес, которые меняют фактическую цель.'), + p('Разберём защиту до вызова сети: разрешённая схема, точное имя хоста, запрет credentials и явное ограничение портов. Учебная функция не делает запрос. Она принимает строку и возвращает нормализованный адрес либо причину отказа, поэтому её можно запускать локально и проверять отдельными примерами.'), + h2('Проверяем не строку, а разобранный адрес'), + p('Сначала парсер URL должен превратить строку в структуру. Затем политика проверяет протокол, hostname, порт и наличие логина или пароля. Проверять только исходное начало строки недостаточно: https://trusted.example@127.0.0.1/ имеет доверенное имя до символа @, но фактический host — loopback.'), + table('Поля URL и решение политики', ['Поле', 'Допустимое правило', 'Причина'], [ + ['protocol', 'только https:', 'не отправлять секрет по plain HTTP'], + ['hostname', 'точный allowlist', 'не доверять суффиксу строки'], + ['port', '443 или явно разрешённый', 'сократить обход сервисов'], + ['username/password', 'пусто', 'не передавать credentials дальше'], + ['pathname', 'проверяется отдельно', 'не разрешать опасный endpoint'], ]), - h2('Почему «важный» не является характеристикой'), - p('Слово «важный» пытается склеить стоимость, доступ, целостность и ожидания пользователя в одну оценку. Для будущего сценария это опасная экономия: оно выглядит как классификация, но не содержит условия, кто его утвердил и на каких материалах. В P114 не нужно выбирать шкалу или приоритет. Нужно удержать разницу между вопросом «какой актив стоит обсуждать?» и утверждением «этот актив уже имеет такой-то класс». Второе требует предметного контекста, которого в плановом пакете нет.'), - p('NIST SP 800-53 Rev. 5 полезен здесь не каталогом готовых ответов. Публикация различает функциональную сторону контроля и assurance — степень уверенности в возможностях. Из этого следует узкий редакционный вывод: одна и та же картинка контроля не является доказательством, пока не определены объект и условия. Норма не сообщает, что P114 что-либо охраняет. Она помогает не назвать уверенностью один лишь рисунок.'), - p('NIST SSDF Version 1.1 также не превращает общий язык безопасной разработки в факт о конкретной команде. Он описывает практики верхнего уровня, которые можно встраивать в жизненный цикл, и общий язык для общения. Мы применяем именно эту часть: договор должен быть понятен следующему участнику. Мы не применяем публикацию как доказательство, что у пакета уже есть процесс, результат или снижение риска.'), - figure('/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg', 'Схема синтетической карточки: метка актива, непоставленная граница и неотображённый путь ведут к договору о проверке; попытки объявить контроль или результат останавливаются.', 'Схема показывает редакционные статусы, а не путь атаки, систему, доступ или действие против неё.'), - h2('Договор о проверке — это перечень запретов на вывод'), - p('Полезный договор не обещает, что проверка обязательно состоится. Он фиксирует, какие вопросы должны быть решены в другом scope до появления любого evidence. Для актива это происхождение и допустимость данных. Для границы — что входит в рассмотрение и что исключено. Для пути — какие состояния можно описывать без реконструкции. Для контроля — какой смысл слова «покрывает» будет принят. Пока эти вопросы не получили авторизацию, каждая строка должна остаться именованной неизвестностью.'), - p('Отдельно важно не выдавать список источников за этот договор. Официальный документ объясняет термины и ограничения, но не получает разрешение на доступ и не выбирает метод. Поэтому URL и дата источника остаются в конце статьи, а fixed literal не содержит URL, target или параметров. Это разделение намеренное: источник делает лексику проверяемой, а scope делает возможным исследование. Одно не заменяет другое.'), - h2('Исполнимый пример не обращается к предмету'), - p('Пример можно запустить локально в Node. Он создаёт заранее объявленный literal, вызывает чистый evaluator и печатает только статус формы. Внутри нет чтения файлов, сети, environment, часов, secrets, telemetry, систем или данных. Нет импорта клиента и нет действия, похожего на security-test. Поэтому runnable code не способен узнать что-либо о чужом или собственном контуре.'), - code("import { inspectAssetBoundaryLiteral } from './upgrade-2027-08.mjs';\n\nconst card = inspectAssetBoundaryLiteral();\nconsole.log(card.asset, card.boundary, card.output);\n// synthetic-asset-label not-mapped synthetic-plan-hand-off"), - p('В этом выводе synthetic-plan-hand-off означает принятие строгой формы, не успех защитного действия. not-mapped не просит функцию найти путь по умолчанию. Если заменить literal произвольным объектом, evaluator остановится с stop-unknown-fixed-literal. Если взять известный отрицательный literal с заявленным путём, он также остановится. Такая проверка полезна ровно потому, что она не знает предметной системы.'), - h2('Три разных договора вместо одного слова «безопасность»'), - p('Первый договор относится к предмету: synthetic label нужен, чтобы следующая беседа не расползлась на весь продукт. Второй относится к границе: он перечисляет не компоненты, а запрет делать вид, что компоненты уже известны. Третий относится к evidence: он определяет, что публикация, схема и пример программы не считаются наблюдением. Эти договоры нельзя слить в одну строку «учтены требования безопасности». Такая строка не сообщает, где заканчивается её смысл и кто способен опровергнуть её.'), - p('Разделение помогает и при смене контекста. Допустим, будущий scope обнаружит, что вопрос вообще не про актив, а про правило обмена между двумя процессами. Первый договор можно заменить, не сохраняя неверную классификацию. Если окажется, что граница шире или уже, второй договор обновляется независимо от статьи. Если evidence собирать нельзя, третий договор честно завершит работу без результата. Monolithic «security checklist» обычно не даёт такой свободы: в нём изменение одного поля выглядит как отмена всего документа.'), - h2('Что считается попыткой незаметно расширить scope'), - p('Расширение редко приходит словом «расширение». Его видно по типу значения. Конкретное имя сервиса, поле payload, имя роли, дата будущего запуска, оценка критичности или обещание хранения — всё это утверждает, что автор уже получил материал и право им пользоваться. Даже без секрета такая деталь меняет читательскую модель. Поэтому evaluator сравнивает весь input только с заранее объявленными literals: он не нормализует похожий объект и не добавляет разумные defaults.'), - p('Строгое сравнение не является общим способом валидировать production данные. Оно выбрано для редакционной изоляции. Пакет не должен принимать «почти такую же» карточку, потому что именно в разнице может оказаться неразрешённый факт. Например, поле с реалистичной классификацией или состояние пути, похожее на известное, нельзя понизить до заметки. Возврат stop-status делает расхождение видимым до того, как оно будет переписано в prose или SVG.'), - p('У этого правила есть практический критерий: читатель должен суметь указать на каждое положительное существительное и найти для него либо fixed literal, либо будущий scope. Если такого адреса нет, существительное превращается в claim и подлежит удалению. Проверять стоит не только заголовки таблиц, но и подписи к рисункам, комментарии в примере и переходы между абзацами: именно там незаметно появляются «уже защищённый» объект, «известный» путь или «подходящий» контроль.'), - h2('Маршрут работы без имитации исследования'), + figure('/assets/editorial/2027/security-capstone-2027-attack-path-contract.svg', 'Путь проверки URL: parse, схема, hostname, порт, credentials и только затем разрешение на вызов.', 'Каждая проверка стоит до сетевого вызова. Отказ возвращает причину и не передаёт адрес следующему слою.'), + h2('Allowlist должна быть точной'), + p('Сравнение hostname.endsWith("example.com") пропустит evil-example.com. Сравнение с одним каноническим именем проще проверить. Если нужны поддомены, добавьте правило границы: имя равно базовому или заканчивается на .example.com. IP-адреса, IPv6 и DNS-редиректы требуют отдельного решения, потому что имя может измениться между проверкой и соединением.'), + p('Защита от SSRF — не только функция валидации. Клиент должен ограничить редиректы, timeout, размер ответа и набор портов. Сетевой слой может дополнительно запретить приватные диапазоны и loopback. Эти меры защищают разные границы, поэтому одна allowlist не заменяет остальные.'), + h2('Учебный валидатор URL'), + p('Входом функции является строка URL и массив разрешённых имён. Ожидаемый результат — объект { allowed, reason, href }. Ниже нет fetch: пример проверяет предмет статьи и не выполняет опасное подключение. Для безопасных тестов используются только публичные доменные имена из allowlist.'), + code([ + 'function validateRemoteUrl(value, allowedHosts) {', + ' let url;', + ' try { url = new URL(value); } catch { return { allowed: false, reason: \'invalid-url\' }; }', + " if (url.protocol !== 'https:') return { allowed: false, reason: 'scheme' };", + " if (url.username || url.password) return { allowed: false, reason: 'credentials' };", + " if (url.port && url.port !== '443') return { allowed: false, reason: 'port' };", + ' if (!allowedHosts.includes(url.hostname)) return { allowed: false, reason: \'host\' };', + ' return { allowed: true, reason: \'allowlist\', href: url.href };', + '}', + '', + "console.log(validateRemoteUrl('https://cdn.example.test/file.jpg', ['cdn.example.test']));", + "console.log(validateRemoteUrl('https://cdn.example.test@127.0.0.1/file.jpg', ['cdn.example.test']));", + "console.log(validateRemoteUrl('https://127.0.0.1/file.jpg', ['cdn.example.test']));", + '// allowed true; allowed false, reason credentials; allowed false, reason host', + ].join('\n')), + p('Первая строка проходит, вторая останавливается на credentials, а третья — на фактическом hostname loopback. Порядок проверок важен: сначала исключаем встроенные учетные данные, затем сверяем host. В полном сервисе URL нельзя передавать в сеть сразу после этого результата: нужны лимит ответа, запрет небезопасных redirect и проверка адреса после разрешения имени там, где это входит в модель угроз.'), + h2('Редирект — новый URL'), + p('Если разрешённый сервер отвечает 302 на другой адрес, политика должна решить, разрешены ли перенаправления. Автоматически следовать за ними опасно: проверка исходного host уже не описывает конечный. Безопасный вариант — отключить redirect и вернуть его адрес как проверяемую ошибку. Если redirect нужен, каждый новый URL проходит ту же проверку.'), + p('Также проверяйте размер ответа до чтения всего тела. Даже разрешённый host может вернуть гигабайты и занять память. Timeout ограничивает время, но не объём. Эти ограничения не делают endpoint безопасным сами по себе, зато уменьшают blast radius ошибки политики.'), + h2('Порядок внедрения'), ol([ - 'Отметить документ как сценарий 2027-08 и повторить source cutoff 2026-07-31 рядом с вопросом.', - 'Назвать только synthetic asset label; не добавлять тип данных, сервис, идентификатор, путь или классификацию.', - 'Зафиксировать boundary и path как not-mapped, а допущения как not-accepted.', - 'Сформулировать, какое evidence вообще могло бы быть допустимо в отдельном scope, не собирая и не имитируя его сейчас.', - 'Прогнать fixed fixture: положительной остаётся лишь hand-off форма, любые claims должны получить stop-status.', - 'Передать вопрос только в новый authorized scope, если будущему решению действительно понадобятся материалы.', + 'Назвать допустимые домены, схемы и порты в конфигурации, а не принимать их из запроса.', + 'Распарсить URL стандартным URL-парсером и проверить hostname после нормализации.', + 'Запретить credentials, неожиданные схемы и redirect по умолчанию.', + 'Ограничить timeout, размер ответа и число сетевых попыток.', + 'Добавить тесты на @, похожий домен, IP, IPv6, другой порт и redirect.', + 'Сохранить причину отказа без полного URL с секретными параметрами.', ]), - h2('Ограничения этой практики'), - p('Карточка не является inventory, классификацией, model, assessment или разрешением на действие. Она не сообщает, существует ли объект, можно ли его наблюдать и нужен ли он следующему решению. Она не выбирает защитный механизм и не даёт критерия готовности. Это сознательное ограничение защищает текст от самого частого ложного вывода: раз есть аккуратная таблица, значит работа по безопасности уже началась.'), - p('У подхода есть цена: будущему участнику придётся повторно уточнить вопрос, а не получить готовую карту. Но эта цена меньше стоимости ошибочной конкретики. Именованная пустота легко расширяется в правильном контексте; выдуманная граница требует сначала объяснить, почему ей доверяли. Техлидская дисциплина здесь не в том, чтобы заполнить форму быстрее, а в том, чтобы не создавать технический долг из уверенных слов.'), - h2('Следующее действие'), - p('Следующее действие одно: если появится решение, которому нужны факты, открыть отдельный authorized scope с собственными входами, ограничениями и правилами хранения. Новый документ вправе определить актив и границу, но не вправе переписать P114 так, будто они были известны заранее. Пока такого scope нет, полезный и единственный положительный итог — synthetic-plan-hand-off с productionEffect: not-attempted.'), + h2('Ограничения и следующий шаг'), + p('Учебный валидатор не знает DNS rebinding, proxy и правила сетевого сегмента. Allowlist домена не доказывает, что адрес после разрешения безопасен. Для чувствительных систем нужен совместный контроль приложения и egress-сети, а также отдельные тесты redirect и размера ответа.'), + p('Следующим шагом возьмите один endpoint, который принимает URL, и выпишите допустимые host, port, redirect и максимальный размер. Затем добавьте отрицательные тесты, включая имя с @. Критерий готовности — запрещённый адрес не доходит до клиента сети.'), ], refs); const mechanism = revision({ - slug: 'editorial-2027-08-mechanism-security-capstone', title: 'Большой разбор безопасности: путь, покрытие и фальсифицируемость', categories: ['Безопасность', 'Архитектура'], cover: '/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg', excerpt: 'План на август 2027: как удержать synthetic path и матрицу control coverage проверяемыми, не выдавая схему за результат.', readingMinutes: 27, + slug: 'editorial-2027-08-mechanism-security-capstone', + title: 'Аутентификация не даёт доступ: строим deny-by-default для объекта', + categories: ['Безопасность', 'Архитектура'], + cover: '/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg', + excerpt: 'Разбираем, почему проверка логина без проверки объекта оставляет горизонтальную эскалацию прав.', + readingMinutes: 14, }, [ - p('P114 рассматривает будущий август 2027 из точки 2026-07-31. Механическая ошибка появляется, когда путь угрозы рисуют настолько убедительно, что его начинают читать как установленную последовательность. У прямоугольников есть стрелки, у стрелок — названия, и мозг уже достраивает источник, переход и последствия. Цена — решение принимают по рисунку, хотя рисунок не содержит ни подтверждённого состояния, ни допустимого evidence.'), - p('Вторая цена скрыта в слове «покрытие». Оно звучит как завершённая работа: будто контроль уже выбран, проверен и относится к каждому участку пути. В P114 этого нет. Нет реального пути, control deployment, control evidence, security-test, finding, scan или положительного эффекта. Единственный неостанавливаемый output — synthetic-plan-hand-off с productionEffect: not-attempted. Механизм ниже учит отбрасывать недоказанные claims, а не превращать их в проектный статус.'), - h2('Путь — не история, а набор незаполненных связей'), - p('Синтетический путь начинается не с противника и не с технологии. Он начинается с вопроса о связях между пока неописанными состояниями. В fixed literal ему дано имя synthetic-path-question, а поле states равно not-mapped. Имя не кодирует endpoint, роль, способ доступа или переход. Оно существует только для того, чтобы evaluator мог отличить известную учебную форму от произвольного входа.'), - p('Такой дизайн противоречит привычке рисовать больше. В настоящей архитектурной схеме детали помогают заметить разрыв. В неавторизованном будущем сценарии те же детали становятся неподтверждёнными утверждениями. Поэтому P114 не строит threat model и не описывает attack technique. Он использует слово path только в значении «отношение, которое когда-нибудь может потребовать отдельного рассмотрения». Пока состояние не собрано, у path нет направления, начала, конца или вероятности.'), - table('Матрица фальсификации для synthetic path', ['Слой', 'Статус в P114', 'Что нельзя заключить'], [ - ['Связи пути', 'not-mapped', 'Что последовательность существует или завершена'], - ['Допущения', 'not-accepted', 'Что удобное объяснение допустимо'], - ['Покрытие контроля', 'not-selected', 'Что мера относится к пути'], - ['Свидетельство', 'not-collected', 'Что механизм наблюдался или проверялся'], - ['Эффект', 'not-attempted', 'Что изменился production или риск'], + p('Пользователь успешно вошёл в систему, но изменил id в URL и увидел чужой профиль. Цена ошибки — считать факт аутентификации разрешением на любой объект. Проверка «токен валиден» отвечает только на вопрос, кто пришёл; она не отвечает, что этому субъекту можно сделать с выбранной записью.'), + p('Построим решение из четырёх полей: субъект, роль, действие и объект. Сначала политика запрещает неизвестное, затем явно разрешает владельцу чтение профиля и администратору чтение аудита. Учебный HTTP-сервер показывает ответ 403 для чужого объекта без обращения к базе данных.'), + h2('Разделяем четыре вопроса'), + p('Аутентификация устанавливает субъект. Ауторизация проверяет действие над объектом. Роль — только один вход политики; владельца и принадлежность ресурса нужно получить из доверенного слоя данных. Нельзя брать ownerId из тела запроса и затем использовать его как доказательство владения.'), + table('Минимальная модель решения', ['Поле', 'Источник', 'Проверка'], [ + ['subjectId', 'проверенный токен', 'идентификатор не меняется из body'], + ['role', 'claims/политика', 'не принимать роль из URL'], + ['action', 'маршрут и метод', 'явное множество действий'], + ['resource', 'серверная загрузка', 'объект найден по ID'], + ['ownerId', 'доменная запись', 'сравнить с subjectId'], ]), - h2('Покрытие — отношение, а не зелёная ячейка'), - p('Даже в реальной работе «control coverage» не должно означать декоративное соответствие названия контролю. Для такого вывода нужны как минимум объект, граница, цель, условия работы и способ отличить действие от отсутствия действия. В P114 ни один из этих элементов не определён. Поэтому значение not-selected честнее слова «частично»: частичность уже предполагает измеренную ось и выбранный объект сравнения.'), - p('NIST SP 800-53 Rev. 5 описывает каталог гибких контролей и отдельно говорит о функциональном и assurance-ракурсах. Для механизма P114 это источник ограничения: функция и уверенность не складываются автоматически в факт. Даже если будущий scope подберёт название из каталога, ему всё равно придётся показать, почему оно относится к его границе и какое свидетельство допустимо. Документ NIST не создаёт такую связь за него.'), - p('SSDF Version 1.1 вводит практики безопасной разработки как высокоуровневые и пригодные для интеграции в разные жизненные циклы. Мы не выводим из этого универсальный рецепт. Наоборот, высокая абстракция источника напоминает о границе применения: публикация может помочь задать вопрос к процессу, но не подтвердить, что конкретная мера была запущена, дала результат или закрыла неизвестный путь.'), - figure('/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg', 'Матрица: synthetic path, невыбранное покрытие и несобранное evidence пересекаются в стоп-границе; только плановая передача вопроса остаётся разрешённым выходом.', 'Матрица не содержит реальных техник, целей, конфигураций или показателей. Она показывает, почему статус контроля нельзя получить из пустого пути.'), - h2('Фальсифицируемость начинается до положительного вывода'), - p('В этом материале фальсифицируемость не предлагает выполнять проверку. Она определяет, какие факты не позволят принять claim. Если путь не отображён, нельзя сказать, что что-то проходит по нему. Если control не выбран, нельзя назвать его покрытием. Если evidence не собран, нельзя выдать документ за подтверждение. Если deployment не attempted, нельзя говорить о production effect. Это четыре независимых стоп-условия; их нельзя компенсировать хорошим стилем схемы.'), - p('Такой порядок отличается от чеклиста best practices. Чеклист говорит, что полезно иметь. Фальсифицируемая карточка говорит, чего нельзя утверждать до появления определённого материала. Для техлида это практичнее: она позволяет остановить ложную эскалацию до спора о деталях. Пустая ячейка перестаёт быть неудобством и становится контрактом, который защищает следующего автора от давления «сделайте вывод уже сейчас».'), - h2('Исполнимый пример проверяет отказ от claim'), - p('Ниже нет схемы сети, входных адресов, payload, инструмента или вызова. Пример работает с неизменяемым literal в памяти и возвращает три строки, описывающие его ограничения. Модуль не читает file, environment, clock, secret, telemetry, system, data или network. Он не инициирует scan и не меняет состояние. Поэтому выполнение безопасно: оно проверяет только правило редакционной формы.'), - code("import { inspectCoverageLiteral } from './upgrade-2027-08.mjs';\n\nconst matrix = inspectCoverageLiteral();\nconsole.log(matrix.path, matrix.coverage, matrix.effect);\n// synthetic-path-question not-selected not-attempted"), - p('Строка not-selected не означает отказ от защиты и не предлагает оставить что-либо без решения. Она означает, что P114 не имеет права выбрать решение. А not-attempted относится только к production effect evaluator: никакое внешнее действие не выполнялось. Если вызвать assessor с known negative literal, где coverage объявлено claimed, результат станет stop-claimed-control-or-evidence, а не предупреждением, которое можно проигнорировать.'), - h2('Четыре оси не становятся доказательством друг друга'), - p('У path, control, evidence и effect разные владельцы смысла. Path отвечает на вопрос о связи состояний. Control — о намерении выбрать некоторую функцию. Evidence — о происхождении наблюдения или подтверждения. Effect — о том, что изменилось за пределами чистой функции. Даже в будущем проекте нельзя переносить значение по диагонали: выбранный control не доказывает path, существующее evidence не доказывает effect, а желаемый effect не делает coverage достаточным.'), - p('В синтетическом сценарии все четыре оси остаются отрицательными status. Это не попытка создать математическую модель риска из строк. Наоборот, строки не имеют числового веса, вероятности или приоритета. Их задача скромнее: зафиксировать, что положительное утверждение на любой оси нуждается в собственном основании. Такой дизайн не позволит красиво ранжировать варианты, зато не позволит получить рейтинг из отсутствующих данных.'), - h2('Как читать stop-status'), - p('Stop-status не называет ошибку безопасности и не говорит, что input опасен. Он говорит только, что input нарушает редакционный контракт P114. Например, stop-claimed-asset-or-path не обнаруживает маршрут и не запрещает настоящую карту в другом месте. Он отвергает попытку представить карту внутри этого планового literal. В результате текст остаётся точным: мы проверили не систему, а то, что статья не стала выдавать план за evidence.'), - p('Это различие полезно для review. Reviewer не обязан спорить, правдоподобна ли заявленная деталь; ему достаточно увидеть, что деталь отсутствует в разрешённой форме. Спор о правдоподобии обычно тянет за собой новые предположения и заставляет автора защищать неподтверждённый контекст. Стоп переносит разговор в продуктивное место: либо убрать claim, либо открыть отдельный scope, где будут правила для фактов.'), - h2('Матрица должна сохранять асимметрию доказательства'), - p('У положительного claim и у отрицательного статуса разная цена. Чтобы сказать «покрытие есть», будущему scope потребуется связь нескольких материалов. Чтобы сказать «покрытие здесь не выбрано», достаточно не делать выбора в этом literal. Эту асимметрию нельзя сгладить словом «неполное»: оно создаёт впечатление, будто шкала уже существует, а осталась лишь работа по заполнению. В P114 нет шкалы, процента, ранга и критерия достаточности.'), - p('То же относится к связке source и evidence. Датированный официальный PDF надёжно сообщает, что опубликованный текст существовал в указанной версии. Он не сообщает, что synthetic path имеет свойства, к которым можно применить норму. Поэтому матрица намеренно хранит references вне evaluator input: evaluator проверяет форму плановой карточки, а статья объясняет язык этой формы. Если смешать эти уровни, ссылка станет ложным substitute для отсутствующего основания.'), - h2('Как собрать вопрос, который можно опровергнуть'), + figure('/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg', 'Матрица авторизации: субъект, роль, действие, объект и владелец сходятся в решении allow или deny.', 'Решение строится на серверных полях. Изменение идентификатора в запросе не меняет владельца записи.'), + h2('Deny-by-default сохраняет неизвестное неизвестным'), + p('Политика должна возвращать deny, если роль не распознана, действие не входит в список или объект не загружен. Это не означает, что любая ошибка должна раскрываться клиенту одинаковым текстом. Внешний ответ может быть 404 для сокрытия существования объекта, а внутреннее событие сохраняет безопасный класс причины.'), + p('Allowlist действий легче проверить, чем набор исключений. Для каждого разрешения нужны субъект, действие и условие объекта. Если правило не записано, оно не должно появляться из ветки else, которая «на всякий случай» пропускает запрос.'), + h2('Учебный endpoint с проверкой владельца'), + p('Вход примера — заголовок роли и путь с идентификатором профиля. Два профиля заранее заданы в памяти процесса. Ожидаемый результат: пользователь u-1 получает 200 для своего профиля и 403 для u-2; администратор читает аудит. Это runnable локальный HTTP-обмен, но не хранилище пользователей и не готовая схема токенов.'), + code([ + "import { createServer } from 'node:http';", + '', + "const profiles = new Map([['u-1', { owner: 'u-1' }], ['u-2', { owner: 'u-2' }]]);", + '', + "const server = createServer((request, response) => {", + " const subject = request.headers['x-subject'];", + " const role = request.headers['x-role'];", + " const id = new URL(request.url, 'http://local').searchParams.get('id');", + ' const profile = profiles.get(id);', + " const resource = id === 'audit' ? { kind: 'audit' } : profile && { kind: 'profile', owner: profile.owner };", + " const allowed = (role === 'admin' && resource?.kind === 'audit') || (role === 'user' && resource?.kind === 'profile' && resource.owner === subject);", + " response.writeHead(allowed ? 200 : 403, { 'content-type': 'application/json' });", + " response.end(JSON.stringify({ allowed }));", + '});', + '', + "server.listen(0, '127.0.0.1', async () => {", + ' const address = server.address();', + ' const port = address.port;', + " const headers = { 'x-subject': 'u-1', 'x-role': 'user' };", + " console.log((await fetch('http://127.0.0.1:' + port + '/profile?id=u-1', { headers })).status);", + " console.log((await fetch('http://127.0.0.1:' + port + '/profile?id=u-2', { headers })).status);", + ' server.close();', + '});', + ].join('\n')), + p('Вывод — 200, затем 403. Входной id выбирает объект, но не его владельца: owner берётся из Map. В настоящем приложении эту запись возвращает репозиторий с проверкой tenant boundary. Если сначала выбрать объект без ограничения tenant, последующая проверка роли уже слишком поздняя.'), + h2('Роль не должна скрывать объектное правило'), + p('Администратор часто имеет более широкое разрешение, но это тоже должно быть явной строкой политики: ресурс, действие и область. Нельзя сделать «admin всегда всё». Для аудита, персональных данных и действий изменения нужны отдельные права и логирование решения. Чем шире роль, тем дороже ошибка в middleware.'), + p('Веб-форма и API должны применять одну и ту же политику. Скрыть кнопку недостаточно: запрос можно отправить вручную. Проверка в handler или policy layer обязательна, а UI только улучшает понятность. Результат проверки не нужно доверять клиенту — клиент получает только ответ.'), + h2('Матрица отказов'), + table('Проверяемые комбинации', ['Субъект', 'Объект', 'Действие', 'Решение'], [ + ['user u-1', 'profile u-1', 'read', 'allow'], + ['user u-1', 'profile u-2', 'read', 'deny'], + ['user u-1', 'audit', 'read', 'deny'], + ['admin a-1', 'audit', 'read', 'allow'], + ['unknown', 'profile u-1', 'read', 'deny'], + ]), + h2('Порядок построения политики'), ol([ - 'Описать один synthetic path label без адресов, ролей, способов доступа, последовательности и утверждений о прошлом.', - 'Отделить mapping path от выбора control: первое остаётся not-mapped, второе — not-selected.', - 'Записать evidence как not-collected; не заменять его ссылкой на стандарт или общим ожиданием.', - 'Для каждого будущего claim заранее назвать необходимое условие: объект, границу, method и допустимое свидетельство.', - 'Проверить evaluator отрицательными literals: claimed path, claimed coverage и claimed evidence обязаны остановиться.', - 'Передавать только вопрос о следующем scope, а не оценку, что он должен доказать.', + 'Назвать субъект, объект и действие для каждого защищаемого endpoint.', + 'Определить доверенный источник subjectId, role и ownerId.', + 'Сделать deny результатом по умолчанию для неизвестной комбинации.', + 'Добавить allow-правила по одному и проверить чужой объект, неизвестную роль и запрещённое действие.', + 'Разделить внешний код ответа и внутреннюю безопасную причину отказа.', + 'Проверить handler напрямую, без UI, чтобы запрос нельзя было обойти скрытой кнопкой.', ]), - h2('Граница между схемой и решением'), - p('Схема полезна, когда сокращает число неверных интерпретаций. Она становится опасной, когда её компактность скрывает отсутствие данных. В P114 стрелка на SVG означает связь статусов, а не перемещение данных, привилегий или команд. Цвет stop не обозначает блокирование действия в реальной системе; это правило evaluator. Такая подпись важнее художественной точности, потому что читатель не должен переносить учебную метафору в production.'), - p('Механизм также не создаёт модель рисков, не сравнивает варианты и не выдаёт рекомендацию по контролям. Он не может оценить достаточность, потому что у него нет шкалы, входов и target. Статья не прячет эти отсутствия под словом «coverage». Если будущий scope выберет другой язык или отменит вопрос, P114 не станет ошибочным: он изначально передаёт не ответ, а условия честного ответа.'), - h2('Следующее действие'), - p('Следующее действие ограничено новым authorized scope, если конкретному решению потребуется evidence. В нём отдельно определяются допустимые объект, граница, метод, хранение материалов и правило отрицательного результата. Его результат не добавляется задним числом в P114. До этого механический итог один: strict evaluator возвращает synthetic-plan-hand-off и productionEffect: not-attempted; все положительные claims fail-closed.'), + h2('Ограничения и следующий шаг'), + p('Пример использует заголовки вместо реального механизма аутентификации и Map вместо базы. Он показывает объектную проверку, но не решает CSRF, срок токена, tenant isolation или кэширование ответа. В проекте необходимо проверить границу каждого промежуточного слоя и не кэшировать чужой ответ под общим ключом.'), + p('Следующий шаг — взять один endpoint с параметром id, выписать матрицу субъекты × действия × объекты и добавить тест на соседнего пользователя. Если правило нельзя выразить в таблице, его будет трудно проверить и сопровождать.'), ], refs); const field = revision({ - slug: 'editorial-2027-08-field-security-capstone', title: 'Большой разбор безопасности: evidence hand-off без вымышленной проверки', categories: ['Безопасность', 'Практика команд'], cover: '/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg', excerpt: 'План на август 2027: как передать вопрос о безопасности без истории об incident, тесте или закрытом пути.', readingMinutes: 25, + slug: 'editorial-2027-08-field-security-capstone', + title: 'Проверка безопасности без чекбоксов: связываем требование, тест и результат', + categories: ['Безопасность', 'Качество'], + cover: '/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg', + excerpt: 'Как превратить security-требование в проверяемую матрицу с отрицательными случаями и понятной границей.', + readingMinutes: 14, }, [ - p('P114 — сценарий на август 2027, составленный 2026-07-31. В полевом hand-off самая дорогая ошибка — отдать следующему человеку документ, который выглядит как итог: в нём есть заголовок, схема, слова о контроле и аккуратный список следующей работы. Получатель естественно предполагает, что кто-то уже видел предмет и проверил границы. Цена — будущая работа стартует с ложной памяти, а не с честного вопроса.'), - p('Вторая цена — перепутать передачу evidence с поручением провести безопасность. У P114 нет incident, реального security-test, scan, finding, advisory, attack, exploit, ticket, owner, production effect или закрытого path. Нет и положительной ветки кроме synthetic-plan-hand-off с productionEffect: not-attempted. Полевая статья не сообщает, что проверить; она показывает, как не сделать отсутствие материалов основанием для уверенного narrative.'), - h2('Hand-off передаёт границу незнания'), - p('Пакет пригоден к передаче, когда его можно прочитать без домысливания. В P114 названы только дата редактора, плановый месяц, source cutoff и три synthetic labels. Их достаточно, чтобы определить временную и смысловую рамку. Asset classification остаётся not-declared, boundary и path — not-mapped, coverage — not-selected, evidence — not-collected. Эти значения не просят следующего человека «дозаполнить форму»; они запрещают считать поля заполненными.'), - p('Особое место занимает адресат. В живой передаче хочется поставить owner, команду и дедлайн. Здесь это было бы фиктивным организационным действием. Literal хранит recipient: not-assigned и route: not-opened. Это не дефект управления, а защита от тайного назначения: до нового scope не существует согласованной роли, очереди или обещания выполнить проверку. Можно передать сам вопрос, но нельзя передать чужую обязанность.'), - table('Состав hand-off без выдуманного нарратива', ['Часть', 'Что передаётся', 'Чего нет в P114'], [ - ['Время', 'editorial date, planning issue, source cutoff', 'История события или будущий результат'], - ['Предмет', 'только synthetic labels', 'Система, данные, target или закрытая граница'], - ['Статусы', 'not-declared / not-mapped / not-selected', 'Оценка важности или coverage'], - ['Evidence', 'not-collected', 'Отчёт, finding, scan или test output'], - ['Адресация', 'not-assigned', 'Owner, ticket, очередь или обязательство'], + p('Строка «авторизация проверена» не говорит, что именно проверялось. Цена ошибки — закрыть задачу после happy path и не заметить чужой объект, неизвестную роль или изменение метода. Без связи между требованием, входом, ожидаемым результатом и фактическим выводом security-проверка становится списком галочек.'), + p('Соберём небольшую матрицу проверки для политики профиля: положительный случай, два отказа и неизвестная роль. Каждая строка содержит условие и ожидаемый ответ. Код запускается локально через node:assert/strict, поэтому результат можно повторить без доступа к данным или сети.'), + h2('У требования должна быть проверяемая форма'), + p('Требование «пользователь видит только свой профиль» превращается в четыре части: субъект, объект, действие и ожидаемый эффект. Успешная строка проверяет владельца, отрицательная — другого владельца, а boundary-случай проверяет отсутствие объекта или роль. Положительный тест без отказов не показывает, что правило действительно ограничивает доступ.'), + table('Структура записи проверки', ['Поле', 'Пример', 'Почему нужно'], [ + ['requirementId', 'AUTH-PROFILE-01', 'стабильная ссылка на правило'], + ['input', 'user u-1 → profile u-2', 'что именно подали'], + ['expected', 'deny / 403', 'критерий решения'], + ['actual', 'deny / 403', 'полученный результат'], + ['evidence', 'assertion output', 'чем подтверждён вывод'], ]), - h2('Почему красивый narrative ломает evidence'), - p('Narrative начинает вредить не тогда, когда он явно выдуман. Обычно он появляется как связка: «поэтому нужен контроль», «после проверки стало понятно», «дальше владелец закроет». В каждой такой фразе спрятаны объект, метод, наблюдение и полномочие. Если их нет в record, история лишает будущего reviewer возможности отказаться от исходной постановки. Он уже должен спорить не с вопросом, а с ощущением, что команда что-то обещала.'), - p('Полевой формат должен сохранять возможность отрицательного ответа. Новый scope может решить, что предмет не входит в его границы, что evidence нельзя собирать или что вопрос не влияет на решение. Это не провал hand-off. Напротив, это его проверка: корректная передача не подталкивает получателя к заранее заданному выводу. P114 не содержит скрытой метрики успеха и не использует «закрыть» как эвфемизм для будущего положительного результата.'), - figure('/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg', 'Петля передачи: fixed literal проходит проверку времени, отсутствия claims и отсутствия назначения; при любом claim он останавливается, иначе передаётся только синтетический плановый вопрос.', 'Петля не описывает incident response или security review. Она визуализирует редакционное fail-closed правило передачи без owner и evidence.'), - h2('Источники остаются словарём, не приложением к делу'), - p('NIST SSDF Version 1.1 уместен в hand-off как официальный общий словарь для коммуникации вокруг безопасной разработки. Но его текст не сообщает, что участники P114 существуют, договорились или выполнили практику. Это важно написать в самом артефакте: ссылка не становится evidence, даже если она ведёт на нормативную публикацию и точно датирована.'), - p('NIST SP 800-53 Rev. 5 полезен для различения контроля и уверенности в нём. В field-материале это помогает не превратить колонку «control» в колонку «подтверждено». При этом каталог предназначен для управления риском в организационном процессе; он не назначает здесь метод, адресата или срок. Мы используем публикацию как границу интерпретации, не как разрешение на любые действия.'), - h2('Runnable hand-off остаётся в памяти'), - p('Пример ниже не создаёт файл, не читает окружение, не получает время, не обращается к сети и не вызывает инструмент безопасности. Он получает fixed literal, возвращает только fields hand-off и печатает их. В данных нет секрета, target, реального имени, конфигурации или телеметрии. Поэтому запуск демонстрирует fail-closed интерфейс, а не фиксирует состояние чего-либо вне процесса Node.'), - code("import { inspectSecurityHandoffLiteral } from './upgrade-2027-08.mjs';\n\nconst handoff = inspectSecurityHandoffLiteral();\nconsole.log(handoff.status, handoff.recipient);\n// synthetic-plan-hand-off not-assigned"), - p('Здесь not-assigned не предлагает анонимно выполнить работу. Он делает невозможной подмену «следующего шага» реальным назначением. Единственное следующее действие строкой evaluator — открыть отдельный authorized scope, только если решение требует evidence. При неизвестном объекте assessor возвращает stop. При known literal с claimed evidence — тоже stop. Позитивный статус не повышается до compliance, protection или production effect.'), - h2('Получатель должен получить право отказаться'), - p('Передача без права отказаться — это скрытая эскалация. В ней вопрос уже сформулирован как обязательная работа, а отсутствие owner маскируется словами «следующая команда разберётся». P114 действует иначе: recipient не назначен, route не открыт, а next action условен. Будущий участник может принять решение об отдельном scope, сузить вопрос или признать, что evidence не нужно для доступного выбора. Все три исхода совместимы с корректной передачей.'), - p('Эта свобода не отменяет ответственность автора. Автор отвечает за то, чтобы не добавить деталей, которые делают отказ психологически невозможным: мнимой срочности, конкретного target, обещанной выгоды или намёка на уже выявленную проблему. Чем точнее он удерживает исходную неизвестность, тем легче следующему человеку оценить цену будущего исследования. В field-формате ценность не в количестве заполненных полей, а в отсутствии долга, созданного чужими выводами.'), - h2('Как сохранить provenance пустого поля'), - p('Пустое по смыслу поле тоже имеет происхождение. not-collected означает не «данные были и потерялись», а «этот пакет их не получает и не имитирует». not-selected не означает отказ существующей команды, а отсутствие выбора в literal. not-assigned не означает скрытого владельца. Эти различия должны пережить копирование карточки: иначе следующий редактор сделает из отрицательного статуса пробел для собственной догадки.'), - p('Поэтому hand-off не прикладывает образцы журналов, снимки экрана, фрагменты конфигурации и правдоподобные команды. Даже обезличенная деталь способна стать якорем: читатель начнёт считать её representative и подгонит будущую работу под этот образец. В P114 единственный безопасный artefact — fixed literal и его evaluator output. Они имеют ясное происхождение внутри модуля и не претендуют на происхождение вне него.'), - h2('Передача не должна менять класс документа'), - p('Самый незаметный сбой hand-off происходит при редактуре. Автор начинает с карточки вопроса, а затем добавляет в письмо краткое объяснение «почему это важно». Если объяснение содержит предполагаемый ход событий, оно меняет класс всего пакета: у получателя появляется narrative, которого нет в literal. Поэтому P114 требует проверять не только JSON-подобную форму, но и prose вокруг неё. Текст должен повторять границу, а не обогащать её удобными деталями.'), - p('Это относится и к визуалу. Диаграмма может быть доступной, аккуратной и всё равно чрезмерно конкретной. Иконка замка, сервер с подписью или стрелка между известными слоями быстро превращают план в якобы наблюдённую архитектуру. В наших SVG названы статусы и проверки, а не участники системы. Русские alt и подписи прямо говорят, что изображение показывает правило редакционного маршрута. Такая избыточная явность нужна для мобильного чтения, где подпись часто читают раньше основного объяснения.'), - h2('Что получатель может изменить, не ломая P114'), - p('Получатель может отказаться от вопроса, запросить новый scope или определить, что для решения достаточно других материалов. Он не может изменить старый literal добавлением своего target, evidence или owner, а затем сослаться на P114 как на первичную запись. Новая конкретика должна жить в новом artefact со своей датой и правилами. Это различие не формальность: оно сохраняет цепочку происхождения. Через месяц нельзя будет перепутать будущий вывод с тем, что было известно на редакционную дату.'), - p('Если будущий scope всё же появляется, его первая проверка не обязана быть технической. Он может остановиться на проверке полномочий, данных или необходимости. P114 заранее оставляет такой выход корректным. Ему не нужен искусственный положительный результат, чтобы оправдать существование серии. Техлид получает более полезный сигнал: вопрос дошёл до нужной границы и не начал потреблять доступ, время или доверие без явного решения.'), - h2('Порядок передачи без поручения и без теста'), + figure('/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg', 'Петля проверки безопасности: требование, вход, отрицательный тест, результат и разбор расхождения.', 'Петля возвращает строку в проверку, если фактический результат отличается от ожидаемого. PASS не скрывает отрицательные случаи.'), + h2('Тестируем отказ первым классом результата'), + p('В security-проверке deny — не исключение теста, а ожидаемый результат для запрещённого входа. Поэтому код должен проверять как статус, так и безопасную причину. Если функция возвращает только boolean, диагностировать обход сложнее: неизвестная роль, чужой объект и недопустимое действие смешиваются.'), + p('Набор тестов должен быть малым, но разнонаправленным. Один тест показывает разрешение владельцу, второй — горизонтальную границу, третий — вертикальную роль, четвёртый — неизвестный input. Для каждого случая сохраняйте название, а не только число пройденных assertions.'), + h2('Учебная матрица и assert'), + p('Вход функции — объект запроса и запись профиля. Ожидаемый вывод содержит статус allow или deny и безопасную причину. Пример запускается одной командой Node и печатает имя каждого случая. Он не читает файл, сеть или секреты; его задача — показать форму проверяемого security-контракта.'), + code([ + "import assert from 'node:assert/strict';", + '', + 'function decide({ role, subject, resource }) {', + " if (role === 'admin' && resource.kind === 'audit') return { status: 'allow', reason: 'admin-audit' };", + " if (role === 'user' && resource.kind === 'profile' && resource.owner === subject) return { status: 'allow', reason: 'owner' };", + " return { status: 'deny', reason: 'default-deny' };", + '}', + '', + 'const cases = [', + " ['owner can read', { role: 'user', subject: 'u-1', resource: { kind: 'profile', owner: 'u-1' } }, { status: 'allow', reason: 'owner' }],", + " ['foreign owner is denied', { role: 'user', subject: 'u-1', resource: { kind: 'profile', owner: 'u-2' } }, { status: 'deny', reason: 'default-deny' }],", + " ['unknown role is denied', { role: 'guest', subject: 'u-1', resource: { kind: 'profile', owner: 'u-1' } }, { status: 'deny', reason: 'default-deny' }],", + '];', + '', + 'for (const [name, input, expected] of cases) {', + ' assert.deepEqual(decide(input), expected, name);', + " console.log('PASS', name);", + '}', + ].join('\n')), + p('Запуск печатает три строки PASS. Важна не сама библиотека assert, а форма входа и ожидаемого результата. Добавьте четвёртую строку для администратора и пятую для неизвестного ресурса, если эти ветки входят в ваш контракт. Не называйте отсутствие теста доказательством отсутствия уязвимости.'), + h2('Матрица связывает код и источник'), + p('Идентификатор требования должен вести к месту кода, а название теста — к конкретной комбинации входов. Для одной политики допустимы несколько тестов. Если проверка падает, сохраняются diff: какой вход подали, какой ответ получили и какое правило ожидали. Это позволяет исправить policy, test или само требование, не меняя их незаметно одновременно.'), + p('Стандарт не задаёт ваш список ролей и не решает, какой ответ показывать внешнему пользователю. ASVS полезен как версионируемый словарь требований, а NIST SSDF — как рамка процесса безопасной разработки. Фактический вывод появляется только из вашего кода, тестовых данных и запуска.'), + h2('Отрицательные случаи важнее красивого PASS'), + table('Минимальный набор входов', ['Случай', 'Ожидаемое решение', 'Ошибка при обходе'], [ + ['свой профиль', 'allow', 'сломанный доступ к легитимному действию'], + ['чужой профиль', 'deny', 'горизонтальная эскалация'], + ['audit для user', 'deny', 'избыточная роль'], + ['неизвестная роль', 'deny', 'fail-open по умолчанию'], + ['неизвестный ресурс', 'deny или 404', 'утечка существования'], + ]), + h2('Порядок подготовки проверки'), ol([ - 'Проверить три даты и слово scenario: P114 относится к плану 2027-08, а не к прошедшему событию.', - 'Оставить asset, boundary и path в named fixed статусах; убрать реалистичные имена, маршруты и детали доступа.', - 'Отметить control coverage как not-selected, evidence как not-collected, deployment как not-attempted.', - 'Не добавлять owner, ticket, очередь, дедлайн или обязательство: recipient должен остаться not-assigned.', - 'Запустить fixture и убедиться, что claimed path, control, evidence и effect не проходят как предупреждения.', - 'Открывать новый scope только при решении, которому необходимы материалы; иначе сохранить hand-off без развития narrative.', + 'Взять одно требование и записать его субъект, действие, объект и ожидаемый ответ.', + 'Составить положительный и минимум два отрицательных входа.', + 'Закрепить версию требования и имя теста, чтобы изменение стандарта было заметно.', + 'Запустить тест без UI и сохранить понятный вывод каждой строки.', + 'При падении сравнить фактический input, policy branch и expected result.', + 'Проверить, что тест не использует секреты, чужие данные и случайное внешнее состояние.', ]), - h2('Ограничения полевой формы'), - p('Этот hand-off не является review, audit, assessment, test plan или доказательством соответствия. Он не говорит, что в системе есть проблема или что проблема отсутствует. Он не определяет источник данных, способ доступа, хранение artefacts, ответственность или критерий завершения. Если читателю нужен один из этих элементов, он должен признать, что просит работу за пределами P114, а не искать его между строк.'), - p('Ограничение особенно полезно для безопасности, где аккуратный документ легко воспринимается как результат. Мы оставляем дизайн немного неудобным: он не показывает эффект, не называет победителя и не даёт команды. Зато он не переносит последствия воображаемой проверки на будущую команду. Это и есть минимальная цена честного evidence hand-off — оставить неизвестность видимой до того, как она получит законное происхождение.'), - h2('Следующее действие'), - p('Следующее действие не меняется: новый authorized scope можно открыть только при конкретной потребности в evidence. Он обязан задать собственные временные границы, разрешённые inputs, метод, владельца и способ завершиться без положительного вывода. Его факты останутся в новом artefact, а P114 сохранит роль исходного вопроса. До этого единственный допустимый output — synthetic-plan-hand-off с productionEffect: not-attempted.'), + h2('Ограничения и следующий шаг'), + p('Локальная функция не заменяет интеграционную проверку middleware, токенов, базы и кэша. Она не показывает race condition или ошибку маршрутизации. Модель угроз может потребовать отдельные проверки CSRF, SSRF, rate limit и журналирования. Матрица фиксирует границу, но не расширяет её автоматически.'), + p('Следующим шагом привяжите одну строку матрицы к реальному handler и добавьте тест прямого HTTP-вызова с чужим идентификатором. Сохраните ожидаемый статус и безопасный класс причины. После этого можно добавлять требования, не теряя отрицательные случаи в общей массе зелёных тестов.'), ], refs); export const revisions = deepFreeze([practice, mechanism, field]); + export function verifyRevisionsAgainstFixture() { const fixture = runSecurityCapstoneFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); - return text.length >= 10000 && text.length <= 13000 && /(цен[аы]|стоимост|дорог|потер)/i.test(text.slice(0, 1300)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-08/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); + return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-09.mjs b/web/scripts/upgrade-2027-09.mjs index 0fc9dba..1e2a921 100644 --- a/web/scripts/upgrade-2027-09.mjs +++ b/web/scripts/upgrade-2027-09.mjs @@ -6,161 +6,275 @@ const ol = (items) => `
      ${items.map((item) => `
    1. ${item}
    2. `).join('')} `
      ${alt}
      ${caption}
      `; const table = (caption, headers, rows) => `
${headers.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${caption}
${cell}
${cell}
`; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*$/, '')); } +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(deepFreeze); + Object.freeze(value); + } + return value; +} + +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function bodyText(html) { + return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*$/, '')); +} const REFERENCES = deepFreeze({ - rfc2119: { title: 'RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels', url: 'https://www.rfc-editor.org/rfc/rfc2119.html', version: 'IETF, March 1997, RFC 2119, immutable RFC publication' }, - rfc8174: { title: 'RFC 8174 — Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words', url: 'https://www.rfc-editor.org/rfc/rfc8174.html', version: 'IETF, May 2017, RFC 8174, immutable RFC publication' }, -}); -function sources(entries) { return `
    ${entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return `
  • ${escapeHtml(ref.title)} — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}
  • `; }).join('')}
`; } - -const FIXED_MENTOR_LITERALS = deepFreeze({ - 'mentor-series-plan-v1': { id: 'mentor-series-plan-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'mentor-series-undated-v1': { id: 'mentor-series-undated-v1', editorialDate: '', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'mentor-series-implicit-skill-v1': { id: 'mentor-series-implicit-skill-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: '', state: 'implicit' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'mentor-series-invented-review-v1': { id: 'mentor-series-invented-review-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'claimed', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'mentor-series-invented-feedback-v1': { id: 'mentor-series-invented-feedback-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'claimed', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'mentor-series-invented-outcome-v1': { id: 'mentor-series-invented-outcome-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'not-assigned', process: 'not-created' }, requestedOutput: 'learning-improved' }, - 'mentor-series-invented-owner-v1': { id: 'mentor-series-invented-owner-v1', editorialDate: '2026-07-31', planningIssue: '2027-09', sourceCutoff: '2026-07-31', skillMap: { id: 'synthetic-skill-map-literal-v1', state: 'named-synthetic-only' }, exercise: { id: 'synthetic-practice-prompt-v1', state: 'not-run' }, evidence: { observation: 'not-collected', review: 'not-collected', feedback: 'not-collected', progress: 'not-claimed' }, handoff: { autonomy: 'not-evaluated', recipient: 'assigned', process: 'created' }, requestedOutput: 'synthetic-plan-hand-off' }, + openapi: { title: 'OpenAPI Specification 3.1.1', url: 'https://spec.openapis.org/oas/v3.1.1.html', version: 'OpenAPI Initiative, 24 октября 2024 года, версия 3.1.1' }, + jsonSchema: { title: 'JSON Schema Core 2020-12', url: 'https://json-schema.org/draft/2020-12/json-schema-core.html', version: 'JSON Schema, draft 2020-12, спецификация Core' }, + http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' }, }); -export function createMentorSeriesLiteral(id = 'mentor-series-plan-v1') { const value = FIXED_MENTOR_LITERALS[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } -export function assessMentorSeriesPlan(input) { - const known = Object.values(FIXED_MENTOR_LITERALS).some((item) => JSON.stringify(item) === JSON.stringify(input)); - if (!known) return stop('stop-unknown-fixed-literal', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-literal'); - if (input.editorialDate !== '2026-07-31' || input.planningIssue !== '2027-09' || input.sourceCutoff !== '2026-07-31') return stop('stop-temporal-boundary-required', 'editorial-date-planning-issue-and-source-cutoff-must-be-exact', 'restore-the-fixed-temporal-boundary'); - if (input.skillMap.id !== 'synthetic-skill-map-literal-v1' || input.skillMap.state !== 'named-synthetic-only' || input.exercise.id !== 'synthetic-practice-prompt-v1' || input.exercise.state !== 'not-run') return stop('stop-unnamed-or-implicit-skill', 'skill-map-and-exercise-must-be-named-fixed-synthetic-literals', 'restore-the-named-fixed-literal'); - if (input.evidence.observation !== 'not-collected' || input.evidence.review !== 'not-collected' || input.evidence.feedback !== 'not-collected' || input.evidence.progress !== 'not-claimed') return stop('stop-invented-observation-review-feedback-or-progress', 'learning-evidence-cannot-be-inferred-or-claimed', 'remove-the-invented-learning-claim'); - if (input.handoff.autonomy !== 'not-evaluated' || input.handoff.recipient !== 'not-assigned' || input.handoff.process !== 'not-created') return stop('stop-invented-autonomy-owner-or-process', 'autonomy-owner-and-training-process-cannot-be-created-by-a-plan', 'keep-handoff-unassigned-and-uncreated'); - if (input.requestedOutput !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'future-plan-cannot-claim-learning-or-production-result', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', literalId: input.id, planningIssue: input.planningIssue, skillMap: deepFreeze(cloneFixed(input.skillMap)), exercise: deepFreeze(cloneFixed(input.exercise)), evidence: deepFreeze(cloneFixed(input.evidence)), handoff: deepFreeze(cloneFixed(input.handoff)), productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-scope-only-if-evidence-is-needed-for-a-decision' }); +function sources(entries) { + return `
    ${entries.map(({ key, use, boundary }) => { + const ref = REFERENCES[key]; + return `
  • ${escapeHtml(ref.title)} — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}
  • `; + }).join('')}
`; } -export function inspectPracticeLiteral() { const input = createMentorSeriesLiteral(); const output = assessMentorSeriesPlan(input); return deepFreeze({ skillMap: input.skillMap.id, exercise: input.exercise.state, observation: input.evidence.observation, status: output.status, productionEffect: output.productionEffect }); } -export function inspectMechanismLiteral() { const input = createMentorSeriesLiteral(); return deepFreeze({ map: input.skillMap.state, review: input.evidence.review, feedback: input.evidence.feedback, status: assessMentorSeriesPlan(input).status }); } -export function inspectFieldLiteral() { const output = assessMentorSeriesPlan(createMentorSeriesLiteral()); return deepFreeze({ autonomy: output.handoff.autonomy, recipient: output.handoff.recipient, process: output.handoff.process, status: output.status }); } -export function runMentorSeriesFixture() { - const expected = [['mentor-series-plan-v1', 'synthetic-plan-hand-off'], ['mentor-series-undated-v1', 'stop-temporal-boundary-required'], ['mentor-series-implicit-skill-v1', 'stop-unnamed-or-implicit-skill'], ['mentor-series-invented-review-v1', 'stop-invented-observation-review-feedback-or-progress'], ['mentor-series-invented-feedback-v1', 'stop-invented-observation-review-feedback-or-progress'], ['mentor-series-invented-outcome-v1', 'stop-invented-observation-review-feedback-or-progress'], ['mentor-series-invented-owner-v1', 'stop-invented-autonomy-owner-or-process']]; - const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessMentorSeriesPlan(createMentorSeriesLiteral(id)).status })); - const sample = createMentorSeriesLiteral(); - return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.skillMap) && Object.isFrozen(sample.evidence) && Object.isFrozen(sample.handoff), checks: deepFreeze(checks) }); + +export function validateCustomerResponse(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { ok: false, reason: 'body-must-be-object' }; + if (typeof payload.id !== 'string' || payload.id.length < 1) return { ok: false, reason: 'id-must-be-non-empty-string' }; + if (!Number.isInteger(payload.revision) || payload.revision < 1) return { ok: false, reason: 'revision-must-be-positive-integer' }; + if (!['active', 'blocked'].includes(payload.state)) return { ok: false, reason: 'state-is-outside-enum' }; + return { ok: true, value: { id: payload.id, revision: payload.revision, state: payload.state } }; +} + +export function validateFilterInput(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) return { ok: false, reason: 'filter-must-be-object' }; + if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100)) return { ok: false, reason: 'limit-out-of-range' }; + if (input.cursor !== undefined && (typeof input.cursor !== 'string' || input.cursor.length > 256)) return { ok: false, reason: 'cursor-invalid' }; + if (input.state !== undefined && !['active', 'blocked'].includes(input.state)) return { ok: false, reason: 'state-is-outside-enum' }; + return { ok: true, value: { limit: input.limit ?? 20, cursor: input.cursor ?? null, state: input.state ?? null } }; +} + +export function classifyApiChange(change) { + const removed = Array.isArray(change.removedProperties) ? change.removedProperties : []; + const addedRequired = Array.isArray(change.addedRequiredProperties) ? change.addedRequiredProperties : []; + const narrowedEnum = Boolean(change.narrowedEnum); + const status = removed.length > 0 || addedRequired.length > 0 || narrowedEnum ? 'breaking' : 'compatible'; + return { status, action: status === 'breaking' ? 'version-or-expand-compatibility-window' : 'run-consumer-contract-tests' }; } function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; - if (proseLength < 9000 || proseLength > 12000) throw new Error(`${meta.slug}: body length ${proseLength}`); + if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`); return deepFreeze({ ...meta, contentHtml, proseLength }); } -const refs = [ - { key: 'rfc2119', use: 'Задаёт фиксированный нормативный словарь для различения обязательного правила literal и необязательной будущей работы.', boundary: 'Не описывает обучение, компетенцию, review, feedback или результат человека.' }, - { key: 'rfc8174', use: 'Фиксирует, когда заглавные требования читаются нормативно; помогает не маскировать запрет как пожелание.', boundary: 'Не создаёт учебную программу, владельца, упражнение или evidence.' }, + +const contractRefs = [ + { key: 'openapi', use: 'Фиксирует структуру HTTP-интерфейса, операции, ответы и семантику описания, чтобы контракт был машинно читаемым.', boundary: 'Не доказывает, что сервер действительно отдаёт описанное тело: runtime-проверка и тесты остаются отдельной обязанностью.' }, + { key: 'jsonSchema', use: 'Задаёт язык типов, обязательных полей, ограничений и ветвления для JSON-документов.', boundary: 'Схема не знает бизнес-состояние, права доступа, задержку или согласованность нескольких запросов.' }, + { key: 'http', use: 'Разделяет метод, статус, представление ресурса и условия обмена, на которые опирается совместимость.', boundary: 'Не описывает локальную реализацию сервиса, формат внутренней базы или конкретный клиент.' }, ]; -const practice = revision({ slug: 'editorial-2027-09-practice-mentor-series', title: 'Серия для инженера, который растёт: упражнение как контракт наблюдения', categories: ['Наставничество', 'Развитие'], cover: '/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', excerpt: 'План на сентябрь 2027: как описать упражнение и наблюдаемый критерий без истории о человеке и без вымышленного результата.', readingMinutes: 24 }, [ - p('P115 — редакционный сценарий на сентябрь 2027, составленный 2026-07-31. В теме роста инженера дорогая ошибка начинается с невинной формулы: «дадим задачу, потом будет понятно». В такой фразе уже спрятаны навык, участник, выполнение и оценка. Если их не отделить, документ выглядит как учебная практика, хотя у него нет ни человека, ни запуска, ни наблюдения. Цена — следующий читатель принимает план за свидетельство того, что кто-то чему-то научился.'), - p('Вторая цена — заменить критерий готовности тёплым советом. Слова «разобрался», «стал самостоятельнее» и «получил обратную связь» звучат полезно, но в будущем сценарии это не факты. У P115 нет mentee, реального упражнения, review, feedback, навыкового прогресса, owner, репозитория или production effect. Разрешён один положительный evaluator output: synthetic-plan-hand-off с productionEffect: not-attempted. Всё остальное должно остаться неутверждённым.'), - h2('Упражнение не равно выполненной работе'), - p('Практический артефакт здесь — не задание человеку, а fixed literal в памяти. Он называет synthetic-practice-prompt-v1 и связывает его с synthetic-skill-map-literal-v1. Название намеренно синтетическое: оно не содержит технологии, домена, входа, времени выполнения или ожидаемого поведения. Такое ограничение убирает самый частый самообман: правдоподобный пример начинают читать как фрагмент настоящей программы.'), - p('Наблюдаемый критерий тоже не надо дорисовывать. В живой работе критерий отвечает на вопрос, что именно можно увидеть в сохранённом артефакте: правило, тест, объяснение границы, выбор между вариантами. В P115 нет артефакта и нет наблюдения, поэтому поле имеет значение not-collected. Оно не обозначает плохое выполнение; оно запрещает делать вывод о выполнении. Разница нужна, чтобы будущая проверка могла начаться с нуля, а не с чужого оптимистичного резюме.'), - table('Контракт планового упражнения', ['Часть', 'Значение P115', 'Вывод, который запрещён'], [['Карта', 'synthetic-skill-map-literal-v1', 'Список реальных навыков'], ['Prompt', 'synthetic-practice-prompt-v1', 'Упражнение было выдано или выполнено'], ['Наблюдение', 'not-collected', 'Есть критерий, результат или оценка'], ['Review и feedback', 'not-collected', 'Кто-то прочитал работу или ответил'], ['Эффект', 'not-attempted', 'Изменение production или компетенции']]), - h2('Ситуация, действие и наблюдаемая граница'), - p('Ситуация в статье должна быть узкой: известен только будущий редакционный выпуск, а не учебный контекст. Поэтому нельзя подменить её рассказом о новичке, очереди задач или известном затруднении. Действие также ограничено: модуль создаёт копию named literal, глубоко замораживает её и сравнивает с конечным набором разрешённых literals. Он не читает files, network, environment, clock, secrets, telemetry, system или data. Он не создаёт курс и не отправляет никому задачу.'), - p('Критерий наблюдаемости здесь механический. Запуск допустим, если exported function возвращает плановый hand-off; запуск не является наблюдением человека. Если передать literal с пустым skill id, evaluator останавливается. Если в evidence появится claimed, он тоже останавливается. Это полезнее, чем красивый checklist: контракт показывает, что нельзя получить положительный narrative из отсутствующего входа. Отрицательная ветка должна быть такой же видимой, как единственная accepted ветка.'), - figure('/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', 'Карта контракта: именованная синтетическая карта и не запущенный prompt ведут к отсутствию наблюдения; любое неявное умение останавливает evaluator.', 'Схема показывает правило формы для будущего планового материала. Она не описывает человека, учебный маршрут, выполненное упражнение или развитие навыка.'), - h2('Runnable пример проверяет форму, а не рост'), - p('Пример можно запустить в Node рядом с модулем. Он работает только с зашитым literal, не принимает ввод и не получает контекст процесса. В stdout попадут названия полей, а не оценка. Такой пример безопасен для статьи: читатель может увидеть контракт, но не получит ложного образца review или результата. Не добавляйте в него путь к проекту, имя роли или правдоподобный payload: каждая такая деталь превращает форму в вымышленную историю.'), - code("import { inspectPracticeLiteral } from './upgrade-2027-09.mjs';\n\nconst literal = inspectPracticeLiteral();\nconsole.log(literal.skillMap, literal.exercise, literal.status);\n// synthetic-skill-map-literal-v1 not-run synthetic-plan-hand-off"), - h2('Порядок действий для будущего scope'), - ol(['Зафиксировать три даты: editorial date 2026-07-31, плановый выпуск 2027-09 и source cutoff 2026-07-31.', 'Выбрать только named fixed in-memory literal; не добавлять неявное умение, технологию или человека.', 'Оставить prompt в not-run, а observation, review и feedback в not-collected.', 'Запустить fixture с accepted literal и отрицательными literals; stop должен быть результатом проверки, а не warning.', 'Если решение действительно требует упражнения, открыть отдельный authorized scope с собственным предметом, участниками и правилами evidence.', 'Хранить любой новый факт в новом артефакте; не переписывать им плановый P115.']), - h2('Почему намеренная практика не даёт права на историю'), - p('Выражение deliberate practice удобно, когда им называют повторяемое действие с заранее заданной границей проверки. Оно опасно, когда из него сразу выводят рост человека. Между действием и таким выводом лежат контекст, повтор, наблюдение, интерпретация и согласие участника. В P115 ни один слой не существует. Поэтому статья обсуждает структуру вопроса, а не модель обучения и не обещает, что строгий критерий сделает инженера сильнее.'), - p('Нормативные RFC ниже применяются ограниченно: они дают дисциплину слов «должен» и «не должен» в контракте evaluator. Они не становятся педагогической методикой. Требование not-collected относится к полю literal, а не к поведению реального человека. Это различение сохраняет текст техническим: мы объясняем, как не создать ложный факт, а не как управлять чужим развитием.'), - h2('Как читать критерий без подмены результата'), - p('Полезно разложить слово «критерий» на три разные вещи. Первая — форма будущего наблюдения: например, запись в отдельном артефакте могла бы показать ход рассуждения. Вторая — правило интерпретации: кто-то должен был бы решить, что эта запись относится к заявленному вопросу. Третья — последствие решения: оно могло бы повлиять на следующий scope. В P115 нет ни одной из трёх вещей. Есть только запрет считать их уже существующими. Поэтому evaluator не хранит score, шкалу и порог: они создали бы видимость измерения без предмета.'), - p('Это особенно важно для инженерных упражнений, где код кажется достаточным доказательством. Код может компилироваться и всё равно не объяснять границу, по которой был сделан выбор. Объяснение может быть аккуратным и всё равно не иметь отношения к реальной задаче. Пока scope не назвал предмет и способ проверки, нельзя выбрать даже вид артефакта. Синтетический prompt не является пробелом, который нужно заполнить примером; он удерживает отсутствие примера как факт формы.'), - p('Вместо слов «проверить понимание» лучше записывать, какую именно недоказанную связь надо не утверждать. Здесь связь между named skill map и человеческим действием отсутствует. Вторая отсутствующая связь — между действием и review. Третья — между review и progress. Если diagram или текст перескакивает через одну связь, он производит утверждение сильнее входа. Так возникает безопасный редакторский тест: каждое усиление должно иметь самостоятельный источник, а не соседнее поле literal.'), - p('Практичный компромисс не в том, чтобы добавить больше статусов. Длинная анкета быстро создаст видимость контроля. Лучше оставить несколько отрицательных значений и сделать их проверяемыми: не собранное наблюдение, не запущенный prompt, не заявленный progress. Они читаются хуже, чем история успеха, зато не требуют читателю угадывать, что произошло. Модуль сохраняет это свойство, потому что неизвестный object не нормализуется, а немедленно останавливается.'), - p('Есть полезная проверка формулировки: убрать имя hypothetical участника и спросить, остался ли у предложения наблюдаемый объект. Если после удаления остаётся только «стало лучше», это не критерий. Если остаётся literal, его status и правило stop, текст ещё можно проверить запуском. Такой тест не заменяет будущую работу с человеком; он отделяет редакционный контракт от неё. Для планового месяца это достаточный, но намеренно ограниченный результат.'), - p('Если будущий scope всё же определит упражнение, ему не нужно доказывать правоту P115. Он начнёт со своей постановки и может отказаться от этой терминологии. Это снижает стоимость hand-off: исходный план не привязывает людей к чужому сценарию. Важно сохранить именно такую обратимость. Редакционная серия о росте полезна только тогда, когда не превращает отсутствие контекста в долг пройти заранее написанный маршрут.'), - h2('Ограничения и следующий шаг'), - p('Этот материал не является учебной программой, rubric, назначением, review guide или оценкой. В нём нет критериев найма, времени, сложности, codebase, mentor и ожидаемого темпа. Он не говорит, что отсутствие наблюдения хорошо или плохо. Его предел проще: пока нет отдельного разрешённого контекста, у пакета есть только named synthetic literal и fail-closed evaluator.'), - p('Следующий шаг узкий: если конкретное решение потребует evidence, создать отдельный scope и явно описать, какие inputs разрешены, кто может участвовать, что считается отсутствием результата и как не переносить выводы в production. До этого P115 завершается только synthetic-plan-hand-off; productionEffect: not-attempted говорит о состоянии evaluator, а не о внешнем мире.'), -], refs); +const practice = revision({ + slug: 'editorial-2027-09-practice-mentor-series', + title: 'API-контракт: как остановить несовместимый ответ до релиза', + categories: ['Backend', 'API'], + cover: '/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', + excerpt: 'Разбираем контракт HTTP-ответа: какие изменения ломают клиента, как проверить их локальным валидатором и где заканчивается схема.', + readingMinutes: 14, +}, [ + p('Проблема проявляется не в файле OpenAPI, а у потребителя: клиент получает 200, пытается прочитать поле и падает на обычном успешном ответе. Цена такой ошибки — не только один дефектный запрос. Нужно одновременно искать версию клиента, выяснять, какой ответ он ожидал, и решать, можно ли откатить сервер без потери данных.'), + p('Частая причина — считать добавление поля безопасным всегда или проверять только happy path. Несовместимыми бывают удаление свойства, добавление обязательного свойства, сужение enum и изменение типа. Ниже — узкий контракт для ответа клиента, чистая проверка входа и порядок, который позволяет увидеть риск до публикации изменения.'), + h2('Контракт начинается с формы ответа'), + p('Контракт — это не комментарий к контроллеру. Он отвечает на четыре вопроса: какой ресурс возвращён, какие поля обязательны, какие значения допустимы и как клиент понимает отказ. Если поле state раньше имело значения active и blocked, добавление deleted может сломать клиентский switch, даже если JSON остаётся валидным. Если поле стало числом вместо строки, ломается уже десериализация.'), + p('OpenAPI удобно держать источником формы интерфейса, а JSON Schema — точным описанием JSON-части. Но схема не проверит право пользователя и не узнает, что ревизия записи уже устарела. Поэтому в статье разделены синтаксический контракт и бизнес-проверка: первый должен быть быстрым и детерминированным, вторая живёт рядом с доменным кодом и тестируется отдельно.'), + table('Изменение ответа и риск для клиента', ['Изменение', 'Тип риска', 'Проверка перед выпуском', 'Безопасное действие'], [ + ['Добавлено необязательное поле', 'Обычно совместимо', 'Старый клиент игнорирует поле', 'Добавить contract-test на старую форму'], + ['Удалено поле', 'Breaking', 'Поиск чтения поля в клиентах', 'Сначала deprecated-окно, затем удаление'], + ['Добавлено обязательное поле', 'Breaking для отправителя', 'Проверить все request/response builders', 'Сделать поле optional или выпустить версию'], + ['Сужен enum', 'Breaking для ветвлений', 'Прогнать все старые значения', 'Сохранить значение либо объявить несовместимость'], + ['Изменён тип', 'Breaking', 'Сериализация и fixture ответа', 'Добавить новое поле с новым именем'], + ]), + h2('Маленький контракт лучше общего обещания'), + p('Возьмём ответ GET /customers/{id}. Клиенту нужны строковый идентификатор, положительная ревизия и закрытый набор состояний. Валидатор не обращается к сети и не угадывает отсутствующие данные. Он принимает JSON-представление, возвращает нормализованный набор полей или ясную причину отказа. Это полезно в unit-тесте, в consumer contract test и в адаптере на границе сервиса.'), + p('Важно не путать нормализацию с исправлением. Значение limit можно подставить по умолчанию только там, где это прямо разрешено контрактом фильтра. Для ответа клиента отсутствие обязательного revision — ошибка, а не повод поставить единицу. Молчаливое исправление скрывает несовместимость и переносит её на более дорогой этап.'), + figure('/assets/editorial/2027/mentor-series-2027-skill-map-contract.svg', 'Диаграмма API-контракта: JSON-ответ проходит через проверку обязательных полей, перечислений и типа, после чего клиент получает совместимое представление или ясный отказ.', 'Схема показывает границу между описанием ответа, проверкой формы и действием клиента. Она не обещает, что проверка заменяет бизнес-правила или интеграционные тесты.'), + h2('Runnable-пример: проверяем вход и ожидаемый результат'), + p('Пример запускается в Node.js и вызывает экспортированную функцию с двумя объектами. Входом служит обычный JavaScript-объект, а результатом — ok: true с нормализованным значением или ok: false с конкретной причиной. В учебном примере нет HTTP-сервера: цель — показать поведение контракта на границе, а не изобразить готовый production-adapter.'), + code(`import { validateCustomerResponse } from './upgrade-2027-09.mjs'; -const mechanism = revision({ slug: 'editorial-2027-09-mechanism-mentor-series', title: 'Серия для инженера, который растёт: карта навыка без неподтверждённого вывода', categories: ['Наставничество', 'Развитие'], cover: '/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', excerpt: 'План на сентябрь 2027: как различать skill map, review loop и фальсифицируемое утверждение без вымышленной обратной связи.', readingMinutes: 25 }, [ - p('P115 — сценарий на 2027-09 с редакторской датой 2026-07-31. Самая дорогая ошибка механизма роста — назвать карту навыков объяснением того, что человек умеет. Таблица с уровнями, review loop и аккуратными названиями быстро выглядит как доказательство: будто навык выбран, упражнение состоялось и вывод уже проверен. Цена — решение о следующем действии строится на схеме, а не на разрешённом evidence.'), - p('Вторая ошибка дороже потому, что скрыта в слове feedback. Оно часто соединяет действие, наблюдение и оценку одним жестом. В будущем plan нельзя утверждать review, feedback, outcome или progress, даже если они выглядят правдоподобно. У P115 есть только fixed literal и его статусы not-run, not-collected, not-claimed. Единственный positive output — synthetic-plan-hand-off; productionEffect остаётся not-attempted.'), - h2('Карта навыка — это контракт слов, не модель человека'), - p('Skill map полезна, когда делает явными элементы, которые нельзя подставить друг за друга. В этом выпуске map — строка synthetic-skill-map-literal-v1. Она не именует реальную компетенцию и не имеет уровней. Такое минимальное представление выглядит бедно, но оно проверяемо: evaluator знает ровно одно допустимое значение. Любая пустая строка, другое имя или implicit state завершается stop-unnamed-or-implicit-skill.'), - p('Фальсифицируемость здесь не означает, что мы тестируем личность. Это свойство утверждения о пакете: у него должен быть вход, который его опровергает. Для заявленного review таким входом служит отсутствие review в literal. Для заявленного прогресса — not-claimed. Если код позволил бы заменить эти статусы на удобные слова, план перестал бы быть ограниченным сценарием. Механизм ценен тем, что показывает stop, а не тем, что производит впечатление оценки.'), - table('Матрица утверждений и допустимых оснований', ['Утверждение', 'Состояние P115', 'Поведение evaluator'], [['Карта названа', 'named-synthetic-only', 'Принимает только точное literal-имя'], ['Prompt состоялся', 'not-run', 'Не выводит выполнение'], ['Есть review', 'not-collected', 'Останавливает claimed review'], ['Есть feedback', 'not-collected', 'Останавливает claimed feedback'], ['Есть прогресс', 'not-claimed', 'Останавливает положительный outcome']]), - h2('Review loop без придуманных участников'), - p('Обычно loop рисуют стрелками: задача, попытка, review, correction, новый цикл. Такая схема опасна в будущем выпуске: она визуально сообщает, что цикл уже существует. В P115 стрелки означают только логическое условие. Между not-run и not-collected нет передачи работы; между review и feedback нет роли; между feedback и progress нет причинной связи. Мы сохраняем названия полей, чтобы запретить их подмену, а не чтобы описать процесс.'), - p('Эта строгость не спорит с полезностью review в реальной инженерной работе. Она разделяет два уровня: будущий scope вправе когда-нибудь задать процесс, а текущий сценарий не имеет права заявить, что процесс уже создан. Поэтому process: not-created важнее, чем любая привлекательная диаграмма цикла. Значение не говорит «процесс плох»; оно говорит «этот модуль его не создаёт». Так контракт удерживает границу ответственности кода.'), - figure('/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', 'Матрица review: карта и prompt имеют только синтетические статусы, а observation, review, feedback и progress остаются не собранными; claimed значение идёт в stop.', 'Матрица не показывает выполненный цикл, reviewer, feedback или изменение навыка. Она объясняет fail-closed правило будущего планового literal.'), - h2('Runnable проверка отрицательных веток'), - p('Следующий код возвращает только поля named literal. Он не читает репозиторий, filesystem, окружение, часы, сеть, секреты, телеметрию или системные данные. Он не получает пользователя и не создаёт очередь review. Запуск поэтому не подтверждает модель learning; он проверяет, что статусы не становятся положительными по умолчанию. Для реального evidence нужны другой scope, отдельная авторизация и собственные правила хранения.'), - code("import { inspectMechanismLiteral } from './upgrade-2027-09.mjs';\n\nconst state = inspectMechanismLiteral();\nconsole.log(state.map, state.review, state.feedback, state.status);\n// named-synthetic-only not-collected not-collected synthetic-plan-hand-off"), - h2('Порядок механической проверки'), - ol(['Проверить, что вход создан только factory и совпадает с одним named fixed literal.', 'Проверить даты; отсутствие одной даты не должно получать default.', 'Проверить skill map и prompt отдельно: имя и state должны быть точными, а prompt — not-run.', 'Передать literals с claimed review, feedback и progress и потребовать stop-status.', 'Проверить, что accepted output не содержит оценки, owner, process или production result.', 'Открывать новый scope только тогда, когда решение требует реальных evidence, а не для украшения карты.']), - h2('Где deliberate practice заканчивается'), - p('Термин deliberate practice можно использовать как имя намеренно ограниченного повторяемого действия. Но из одного действия нельзя вывести обучение, самостоятельность или качество решения. Даже в настоящем процессе понадобились бы предмет задачи, версия среды, правило review, сохранённый результат и интерпретация. P115 намеренно не выбирает ни один из этих параметров. Он не строит program и не утверждает, что его map измеряет что-либо вне собственной структуры.'), - p('Фальсифицируемость также имеет предел. Она проверяет, что наш evaluator отвергает known bad literals; она не доказывает, что любой будущий процесс будет хорошим. Замороженный объект защищает от изменения nested fields после factory, но не заменяет наблюдение. Поэтому не следует превращать pass fixture в feedback или «результат серии». Fixture доказывает только согласованность фиксированного правила.'), - h2('Источники задают строгость формулировки'), - p('RFC 2119 и RFC 8174 — первичные, датированные и неизменяемые публикации IETF. Здесь они применяются к языку evaluator: точное literal-значение требуется, а не предполагается. Публикации не являются исследованиями обучения и не подтверждают effectiveness намеренной практики. Такая оговорка не ослабляет текст; она не позволяет ссылке притвориться review, feedback или результатом.'), - h2('Граница между картой и измерением'), - p('Карта становится измерением только после выбора единицы, процедуры и интерпретации. Если карта содержит названия вроде «умение объяснять компромисс», сначала нужно определить, какой объект будет объяснён, какие альтернативы допустимы и где фиксируется аргумент. Затем нужен способ отличить воспроизведённый шаблон от самостоятельного решения. Наконец, нужен договор о том, что означает отрицательный результат. Ни один из этих элементов нельзя взять из строки с названием навыка. P115 поэтому удерживает map как synthetic identifier, а не как шкалу.'), - p('Review loop часто маскирует это различие. В нём есть движение, значит кажется, что появится измерение. Но цикл без входа и артефакта — только рисунок. Добавить в него reviewer означает назвать человека; добавить срок — создать обязательство; добавить expected correction — выдать outcome. Технически честная схема должна показывать разрыв: not-run не переходит в evidence, а claimed field идёт в stop. Такой разрыв не делает процесс невозможным, он не даёт считать его созданным.'), - p('Фальсифицируемый план не обещает, что его отрицательные cases исчерпывают мир. Factory знает конечный набор literals, и это намеренное ограничение. Любой похожий object, пришедший извне, получает stop-unknown-fixed-literal; evaluator не пытается распознать намерение по форме полей. Такой отказ менее удобен, чем permissive parser, но он не позволяет чужому payload стать учебной записью только потому, что у него похожие ключи.'), - p('Есть и редакторская выгода. Когда текст честно различает map, loop и measurement, читатель может спорить о конкретной границе, а не о настроении автора. Можно сказать: «нам нужен другой input» или «это правило не относится к нашему решению». Нельзя сказать, что статья уже описала чью-то компетенцию, потому что она этого не делает. Прагматичная техническая речь выигрывает от такой сухости: она оставляет проверяемый объект вместо лозунга.'), - p('Наконец, deliberate practice не должна становиться названием для любой повторяемой активности. В настоящем процессе ей потребовалось бы договориться о цели, безопасности участника, сохранении материалов и праве прекратить действие. Ни слова из этой серии не заменяет эти условия. Мы используем термин как ограничитель рассуждения: если нет наблюдаемой связи, нельзя выдать повтор за подтверждённое развитие. Эта оговорка защищает и будущего участника, и следующего редактора.'), - p('Поэтому у этой карты нет скрытого «следующего уровня». Следующий шаг определяется будущим решением, а не стрелкой на схеме или порядком полей. Пока evidence отсутствует, честный результат механизма — сохранить вопрос открытым, а не выбрать удобное объяснение роста.'), - h2('Ограничения и следующий шаг'), - p('Механизм не предлагает competency framework, rubric, рейтинг, карьерную лестницу, встречу или вмешательство в чью-либо работу. В нём нет скрытых баллов, времени, задачи, проекта, данных или участника. Положительный результат evaluator не равен «инженер вырос»: это только разрешённая форма ответа для plan/scenario на 2027-09.'), - p('Следующий шаг — отдельно решить, нужен ли evidence для конкретного решения. Если нужен, новый authorized scope должен назвать предмет, допустимый input, правила consent и privacy, метод, отрицательный результат и ответственного уже в своём контексте. P115 не получает эти факты задним числом. До того итог остаётся synthetic-plan-hand-off и productionEffect: not-attempted.'), -], refs); +const accepted = validateCustomerResponse({ + id: 'customer-17', + revision: 4, + state: 'active', +}); +const rejected = validateCustomerResponse({ + id: 'customer-17', + revision: 4, + state: 'deleted', +}); -const field = revision({ slug: 'editorial-2027-09-field-mentor-series', title: 'Серия для инженера, который растёт: hand-off самостоятельности без легенды', categories: ['Наставничество', 'Развитие'], cover: '/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', excerpt: 'План на сентябрь 2027: как передать вопрос о самостоятельности без назначенного owner, выдуманного review и истории о результате.', readingMinutes: 24 }, [ - p('P115 — будущий plan/scenario на сентябрь 2027, датированный 2026-07-31. В hand-off о самостоятельности самая дорогая ошибка — выдать аккуратную передачу за факт: рядом появляются owner, «следующий уровень», завершённый review и обещанный результат. Получатель видит знакомую форму и естественно думает, что кто-то уже принял решение. Цена — неизвестность превращается в организационный долг, который никто не соглашался брать.'), - p('Вторая цена — придать evidence человеческое лицо. Даже без имени легко написать «инженер объяснил выбор», «наставник дал feedback», «после цикла стало лучше». Для P115 это выдуманная обратная связь. Нет mentee, reviewer, owner, учебного процесса, репозиторного примера, задания, результата или production effect. Field-часть передаёт только fixed literal; единственная positive ветка — synthetic-plan-hand-off, а productionEffect: not-attempted остаётся явным.'), - h2('Самостоятельность нельзя назначить строкой'), - p('Автономность часто пытаются передать как статус: «может действовать сам». У статуса есть скрытые условия — граница решения, право менять состояние, источник доказательств и ответственность за последствия. В P115 ни одно условие не задано. Поэтому handoff хранит autonomy: not-evaluated. Это не отрицательная оценка и не просьба оценить человека; это запрет выводить самостоятельность из слов о карте или упражнении.'), - p('То же относится к адресу передачи. Значение recipient: not-assigned не скрывает владельца. Оно сохраняет отсутствие назначения. Если бы evaluator принимал произвольную роль, модуль тихо создавал бы обязательство вне разрешённого scope. Вместо этого literal с assigned recipient или created process получает stop. Техническая дисциплина здесь проста: отсутствие организационного решения хранится как значение, а не как пустое место для догадки.'), - table('Что hand-off передаёт, а чего не создаёт', ['Поле', 'Значение P115', 'Не является'], [['Временная граница', '2026-07-31 / 2027-09 / cutoff', 'Историей учебного события'], ['Самостоятельность', 'not-evaluated', 'Оценкой инженера'], ['Адресат', 'not-assigned', 'Назначенным owner'], ['Процесс', 'not-created', 'Очередью, программой или review loop'], ['Evidence', 'not-collected', 'Feedback, результатом или прогрессом']]), - h2('Evidence без вымышленной обратной связи'), - p('Evidence имеет происхождение. Если в документе написано «есть feedback», читатель должен иметь возможность спросить: откуда он взялся, кто его дал, к чему относился и что из него следует. В плановом P115 на эти вопросы нет допустимых ответов, значит field-материал не использует даже нейтральный пересказ. not-collected означает именно отсутствие материала в пакете, а не то, что материал потерян, слабый или ожидает одобрения.'), - p('Такой hand-off сохраняет право будущего scope сказать «не продолжать». Возможно, конкретному решению не нужен evidence о learning. Возможно, предмет выйдет за границы privacy или consent. Возможно, правильным итогом будет отсутствие действия. Если в стартовом документе уже записаны owner и успех, это право исчезает: людям приходится спорить с легендой. Fail-closed evaluator делает отсутствие выдуманного narrative проверяемым свойством, а не стилевой рекомендацией.'), - figure('/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', 'Петля hand-off: плановый literal проходит временную и evidence-проверку, останавливается при owner или claimed feedback и передаёт только неоценённую самостоятельность.', 'Диаграмма не описывает реального инженера, наставника, передачу полномочий или учебный процесс. Она показывает границу значений в синтетическом модуле.'), - h2('Runnable hand-off не открывает процесс'), - p('Пример выводит автономность, recipient, process и итог evaluator. Он не открывает тикет, не читает конфигурацию, не вызывает сеть и не создаёт workflow. Нет обращения к filesystem, environment, clock, secret, telemetry, system или data. Его назначение — показать, что данные не получают fallback из внешнего мира. Поэтому строка not-assigned не становится адресом, а not-created не становится скрытым обещанием будущей программы.'), - code("import { inspectFieldLiteral } from './upgrade-2027-09.mjs';\n\nconst handoff = inspectFieldLiteral();\nconsole.log(handoff.autonomy, handoff.recipient, handoff.process, handoff.status);\n// not-evaluated not-assigned not-created synthetic-plan-hand-off"), - h2('Порядок передачи без назначения'), - ol(['Прочитать temporal boundary как часть контракта: план относится к 2027-09 и не сообщает прошлые факты.', 'Оставить skill map и prompt именованными synthetic literals; не приписывать им пользователя или систему.', 'Сохранить autonomy в not-evaluated, recipient в not-assigned, process в not-created.', 'Сохранить observation, review и feedback в not-collected; progress — в not-claimed.', 'Запустить fixture с invented owner, review, feedback и outcome; каждая такая ветка должна остановиться.', 'Открыть другой authorized scope лишь при доказуемой потребности решения и не переносить его материалы в P115.']), - h2('Почему поле не становится программой'), - p('Полевой язык любит конкретность: назначить человека, выбрать cadence, завести карточку, запросить отчет. Это разумные действия только после того, как предмет и полномочия существуют. В P115 они отсутствуют намеренно. Добавить их «для наглядности» означает создать учебный процесс текстом. Даже synthetic name не следует превращать в реалистичный кейс: правдоподобная деталь быстро становится ложным доказательством того, что контекст был известен.'), - p('Внешние источники не решают эту проблему. RFC задают различение требований, но не дают права на персональную оценку. Они не описывают consent, не назначают owner и не доказывают, что review произошёл. Поэтому source section у статьи содержит URL, версию, дату, применение и boundary, а основной текст не использует их как авторитетный ответ о развитии человека.'), - h2('Провенанс отрицательных статусов'), - p('Отрицательный статус тоже нуждается в точном чтении. not-evaluated не означает, что оценка была начата и не завершилась; она не начиналась внутри этого пакета. not-assigned не означает, что владелец потерялся; владелец не назначался. not-created не означает, что workflow сломан; workflow не создавался. Без этих различий пустые поля быстро получают историю задним числом. Field-форма сохраняет происхождение отсутствия, чтобы следующий scope не принимал его за дефект данных.'), - p('Такая точность важна не только для privacy. Она ограничивает технический вывод. Если recipient не назначен, нельзя утверждать, что hand-off доставлен. Если process не создан, нельзя говорить о cadence. Если feedback не собран, нельзя выбирать correction. Каждый пропуск останавливает соответствующий narrative до того, как он станет планом действий для реальных людей. Код делает это буквально: значение assigned не деградирует до warning, а ведёт к отдельному stop-status.'), - p('Соблазн добавить owner обычно оправдывают удобством: документу нужен адресат. Но адресат — это решение с последствиями, а не метаданные. Его нельзя подставить как пример без риска создать ложную обязанность. Поэтому даже обобщённая роль не подходит. В P115 допускается передать вопрос без recipient; будущий контекст сам определит, существует ли необходимость кому-то его адресовать. Эта неполнота не ошибка hand-off, а его безопасная форма.'), - p('Evidence работает сходно. Файл, комментарий или правдоподобный fragment code могли бы сделать статью живее, но они стали бы synthetic evidence с неясным происхождением. Читатель начинает сравнивать свой случай с образцом и незаметно получает критерий, который никто не авторизовал. Поэтому field-статья оставляет только literal и output evaluator. Они не сообщают о внешнем состоянии, зато у них есть точное происхождение: они созданы внутри одного модуля из constants.'), - p('Для передачи важно и направление ответственности. P115 не просит получателя подтвердить, опровергнуть или продолжить что-либо. Он лишь не позволяет принять один набор слов за другой: план — за evidence, отсутствие owner — за назначение, pass fixture — за эффект. Получатель нового scope вправе сузить вопрос или закрыть его без действия. Такая возможность теряется, когда hand-off заранее обещает полезный результат или предполагает, что отказ будет признаком недостаточной самостоятельности.'), - p('Такой hand-off легко проверить на деградацию. Добавьте claimed feedback, progress или owner в known bad literal — fixture должен остановиться. Передайте object с новым полем — он тоже не будет принят как «почти тот же» plan. Это не бюрократия вокруг текста. Это способ не дать будущей редакционной задаче превратиться в скрытую систему управления людьми без решения, полномочий и доказательств.'), - p('Небольшая цена такой строгости — документ не может успокоить читателя историей успеха. Зато он не создаёт ложный факт, с которым потом приходится согласовываться. Для изолированного scope это важнее полноты внешнего narrative.'), +console.log(accepted.ok, accepted.value.state); +console.log(rejected.ok, rejected.reason); +// true active +// false state-is-outside-enum`), + h2('Порядок проверки изменения'), + ol([ + 'Сначала назовите endpoint, метод, статус и media type. Без этого слово «контракт» смешивает запрос, ответ и внутреннюю модель.', + 'Снимите текущую форму ответа: обязательные поля, типы, enum, nullable и значения по умолчанию. Зафиксируйте один положительный и несколько отрицательных примеров.', + 'Сравните diff схемы с реальными местами чтения. Особенно ищите удаление поля, изменение типа и сужение перечисления.', + 'Запустите детерминированный валидатор на старой и новой форме. Ошибка должна содержать поле и причину, а не общий «invalid response».', + 'Прогоните consumer contract tests для двух соседних версий клиента. Если старый клиент не проходит, выберите новое поле, совместимое расширение или отдельную версию.', + 'После выпуска добавьте срок удаления deprecated-поля и проверяемый сигнал использования. Не удаляйте его по ощущению, если нет данных о потребителях.', + ]), + h2('Где заканчивается схема'), + p('Схема не отвечает на вопрос, можно ли изменить запись. Ответ state: active может быть формально правильным, но устаревшим относительно команды обновления. Для этого нужны версия ресурса, условный запрос вроде If-Match, правила авторизации и транзакционная проверка. Эти условия следует описывать рядом с endpoint, но не выдавать за свойства JSON Schema.'), + p('Схема также не гарантирует одинаковое поведение всех реализаций. Сервер может вернуть правильный JSON только для одного кода пути, а ошибка сериализации останется в редком исключении. Поэтому проверка формы должна быть дополнена интеграционным тестом, который вызывает реальный handler, и тестом совместимости, который запускает старый клиент против нового ответа. Наличие двух тестов не делает контракт вечным: оно снижает конкретный риск в известной границе.'), h2('Ограничения и следующий шаг'), - p('P115 не является assessment, mentoring policy, кадровым решением, учебным планом, review, feedback record или evidence package. Он не говорит, что человек автономен или не автономен, не рекомендует передачу полномочий и не содержит production change. Code fixture проверяет только замкнутый набор literals; он не проверяет организацию, навыки или качество инженерного решения.'), - p('Следующий шаг возможен только в отдельном authorized scope, когда конкретное решение потребует evidence. Там должны появиться собственные границы, разрешённые inputs, защита личных данных, owner, метод и допустимый отрицательный результат. P115 сохраняет исходную неизвестность: его завершение — synthetic-plan-hand-off с productionEffect: not-attempted, без выдуманного feedback и без названного владельца.'), -], refs); + p('Учебный валидатор не проверяет OpenAPI-документ, авторизацию, базу данных, компрессию и сетевые ошибки. Он также не доказывает, что всех потребителей нашли. Его задача уже: не пропустить неверный тип, обязательное поле или новое значение enum на границе JSON.'), + p('Следующий шаг — собрать один реальный endpoint и добавить к нему пару contract-тестов: старый потребитель должен пройти на расширенном ответе, а breaking diff должен завершаться осознанным решением о версии. Если правило нельзя выразить в форме, status или условии запроса, вынесите его в отдельный раздел доменного контракта, не прячьте в описании поля.'), +], contractRefs); + +const mechanism = revision({ + slug: 'editorial-2027-09-mechanism-mentor-series', + title: 'JSON Schema и бизнес-правила: где проходит граница валидации', + categories: ['Backend', 'Контракты данных'], + cover: '/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', + excerpt: 'Почему валидная JSON Schema не гарантирует корректную операцию: разделяем форму данных, бизнес-инвариант и проверку состояния.', + readingMinutes: 15, +}, [ + p('Проблема возникает, когда сервис принимает хорошо сформированный JSON, но отклоняет операцию позже: лимит оказался недоступен, курс валюты устарел, а ресурс уже изменился. Цена смешения слоёв — неясная ошибка 400/409, повторные попытки клиента и спор о том, где именно нарушен контракт.'), + p('Причина обычно в широком слове «валидировать». Им называют проверку JSON-типа, обязательных полей, доступа пользователя и текущего состояния базы одновременно. Такой обработчик трудно тестировать: непонятно, какой вход должен быть отклонён схемой, а какой — доменной проверкой. Разделим эти решения и соберём минимальный фильтр, который можно запустить без сервера.'), + h2('Три слоя, которые нельзя склеивать'), + p('Первый слой — структура: объект, строка, число, массив, обязательность, формат и перечисление. JSON Schema хорошо подходит для такого вопроса. Второй слой — локальный инвариант: например, minAmount <= maxAmount или допустимый размер страницы. Его можно проверять кодом после разбора JSON, если правило зависит от нескольких полей. Третий слой — состояние системы: существует ли пользователь, не занят ли ресурс, не истёк ли токен. Этот слой требует доступа к данным и обычно возвращает другой класс ошибки.'), + p('Если все три проверки спрятаны в одной схеме, описание начинает обещать больше, чем может проверить. Если всё оставить коду контроллера, клиенты теряют раннюю документацию и точное сообщение о форме. Рабочая граница проходит там, где появляется внешний контекст: схема описывает сам документ, доменная функция — связь полей, сервис — состояние и права.'), + table('Что проверять схемой, а что — кодом', ['Слой', 'Пример', 'Результат ошибки', 'Подход'], [ + ['Тип и обязательность', 'limit — integer, required', '400: malformed document', 'JSON Schema или генератор клиента'], + ['Диапазон', '1 ≤ limit ≤ 100', '400: invalid value', 'Schema minimum/maximum плюс тест'], + ['Связь полей', 'from <= to', '400: inconsistent filter', 'Чистая функция с двумя полями'], + ['Состояние', 'ресурс не изменён после чтения', '409: state conflict', 'Версия, условный запрос, транзакция'], + ['Право', 'роль может менять статус', '403: forbidden', 'Авторизация до изменения состояния'], + ]), + h2('Schema не делает неизвестное допустимым'), + p('У JSON Schema есть важное свойство: ограничения должны быть явными. Для API-фильтра можно разрешить limit, cursor и state, а остальные свойства закрыть через additionalProperties: false в нужном месте схемы. Но закрытость должна соответствовать расширяемости интерфейса. Если команда добавляет служебное поле без версионирования, строгая схема станет источником неожиданных отказов.'), + p('Есть и другая ловушка — использовать format как доказательство полной корректности. Формат даты или URI задаёт синтаксическую подсказку, но не подтверждает, что дата разрешена для операции или что URI принадлежит доверенному домену. Слово «valid» в отчёте должно иметь уточнение: valid по схеме, valid для инварианта или valid в текущем состоянии.'), + figure('/assets/editorial/2027/mentor-series-2027-practice-review-matrix.svg', 'Матрица валидации: структура JSON, связь полей, состояние ресурса и право на действие проходят отдельные проверки с разными классами ошибок.', 'Схема помогает не выдавать успешный разбор JSON за разрешение операции. Каждый слой имеет собственный вход, сообщение и границу ответственности.'), + h2('Runnable-пример: форма и инвариант по отдельности'), + p('В следующем фрагменте функция принимает фильтр поиска. Она проверяет форму и диапазон, добавляет безопасные значения по умолчанию и возвращает нормализованный объект. Это не библиотека JSON Schema, а маленький учебный аналог, на котором видно место бизнес-правила. Состояние базы и право доступа намеренно не притворяются частью результата.'), + code(`import { validateFilterInput } from './upgrade-2027-09.mjs'; + +const good = validateFilterInput({ state: 'active', limit: 25 }); +const bad = validateFilterInput({ state: 'active', limit: 250 }); +const unknown = validateFilterInput({ state: 'active', region: 'eu' }); + +console.log(good.ok, good.value.limit, good.value.cursor); +console.log(bad.ok, bad.reason); +console.log(unknown.ok, unknown.value.state); +// true 25 null +// false limit-out-of-range +// true active`), + h2('Порядок разложения проверки'), + ol([ + 'Опишите JSON-документ отдельно от команды, которая его использует. Назовите поля, типы, обязательность и допустимые значения.', + 'Выберите закрытую или расширяемую модель неизвестных полей. Решение должно быть одинаковым для сервера и клиентов, иначе один слой будет отвергать данные другого.', + 'Вынесите связи нескольких полей в чистые функции. Каждая функция должна иметь отрицательный пример и возвращать имя нарушенного правила.', + 'Присвойте класс ошибки: malformed input, invalid value, conflict или forbidden. Не превращайте конфликт состояния в повторную отправку 400.', + 'Проверьте, какие правила требуют чтения базы или другого сервиса. Для них зафиксируйте порядок проверки и условия гонки.', + 'Сверьте документацию и код на одном fixture-наборе. Расхождение между схемой и runtime-валидатором должно ломать сборку тестов.', + ]), + h2('Почему 409 важнее ещё одного boolean'), + p('Когда форма запроса корректна, но состояние изменилось, клиенту нужна возможность выбрать действие: перечитать ресурс, показать конфликт или прекратить операцию. Boolean вроде valid: false стирает причину. HTTP-семантика и локальный API-контракт должны различать ошибку документа и невозможность применить правильный документ к текущему состоянию.'), + p('Это различие помогает и с повторными попытками. Ошибка схемы не станет правильной от второго запроса, а конфликт иногда исчезает после нового чтения. Если оба случая имеют один статус, клиент либо повторяет бесполезную отправку, либо молча теряет возможность безопасного разрешения. Хорошая валидация уменьшает число retry-циклов именно тем, что сообщает границу отказа.'), + h2('Ограничения и следующий шаг'), + p('Учебная функция не реализует полный draft 2020–12, не строит JSON Pointer к ошибке и не читает доменное состояние. Она показывает архитектурное разделение, а не заменяет валидатор библиотеки. В реальном API необходимо проверить выбранную библиотеку на oneOf, ссылки, форматы и поведение при неизвестных ключах.'), + p('Следующий шаг — взять один endpoint с конфликтом состояния и выписать три независимых теста: неправильная форма, нарушенный инвариант и устаревшая версия ресурса. После этого сравните их статусы и сообщения с документацией. Если один тест требует данных, которых нет в запросе, не расширяйте схему вслепую: это сигнал, что правило относится к сервисному слою.'), +], contractRefs); + +const field = revision({ + slug: 'editorial-2027-09-field-mentor-series', + title: 'Code review API-изменения: от diff до обратимой миграции', + categories: ['Code review', 'Миграции'], + cover: '/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', + excerpt: 'Полевой маршрут для API-diff: классифицируем несовместимость, проверяем потребителей и оставляем безопасное окно отката.', + readingMinutes: 15, +}, [ + p('Проблема в code review API-изменения редко выглядит как красная строка. Автор добавляет обязательное поле, меняет enum или удаляет старый response-property, а reviewer видит только локально зелёные тесты. Цена ошибки появляется после публикации: разные версии клиента начинают спорить с одним сервером, а быстрый rollback уже не возвращает удалённое поле.'), + p('Причина — просматривать diff как изменение одного репозитория. API имеет потребителей, кэш, документацию, генераторы типов и иногда асинхронные события. Поэтому проверка должна начинаться с классификации изменения, продолжаться поиском потребителей и заканчиваться обратимой последовательностью. Ниже — практический маршрут, который можно применить к одному pull request.'), + h2('Сначала классификация, потом обсуждение кода'), + p('У каждой строки схемы есть направление совместимости. Добавление необязательного response-поля обычно расширяет контракт. Удаление поля сужает его. Добавление обязательного поля в request ломает старого отправителя, а изменение response-типа ломает десериализацию даже при том же имени. Эта классификация не заменяет review, но не даёт обсуждать все изменения одинаково.'), + p('Функция classifyApiChange ниже намеренно принимает уже выделенные факты diff. Она не пытается сама прочитать OpenAPI и не делает вывод о конкретной команде. Это удобная граница для теста: если генератор diff ошибся, его ошибка находится до классификатора; если классификатор выбрал breaking, reviewer получает повод проверить совместимость.'), + table('Минимальная карта API-diff', ['Вопрос', 'Признак', 'Что проверить', 'Решение'], [ + ['Старый клиент отправит запрос?', 'Новое required request-поле', 'Все builders и fixtures', 'Default, optional или новая версия'], + ['Старый клиент прочитает ответ?', 'Удаление/переименование поля', 'Поиск доступа к property', 'Deprecated-период и новое поле'], + ['Старое значение остаётся допустимым?', 'Сужение enum', 'Ветвления клиентов и событий', 'Расширить enum или сменить версию'], + ['Сохранилась семантика?', 'Тот же тип, другое значение', 'Документация и consumer test', 'Явно описать смысл и миграцию'], + ['Можно вернуть сервер?', 'Изменение хранения или записи', 'Backward read и rollback', 'Сначала expand, затем switch, потом contract'], + ]), + h2('Обратимость начинается с данных'), + p('Откат бинарного файла не откатывает базу и сообщения в очереди. Если новый сервер записал только новый формат, старый сервер может не суметь прочитать данные. Поэтому для опасного API-изменения полезен expand/contract: сначала добавить совместимое поле или колонку, затем научить код читать и писать оба формата, переключить потребителей и только после подтверждения удалить старую форму.'), + p('На review стоит попросить не обещание «rollback возможен», а конкретную матрицу. Какие версии читают старую запись? Как выглядит запись после частичного переключения? Что произойдёт с повторной доставкой события? Где хранится сигнал, что старый consumer ещё жив? Ответы превращают риск в проверяемые условия, а не в уверенность по названию ветки.'), + figure('/assets/editorial/2027/mentor-series-2027-autonomy-handoff-loop.svg', 'Маршрут review API-изменения: diff проходит через классификацию совместимости, проверку потребителей и окно обратимой миграции перед удалением старой формы.', 'Диаграмма связывает локальный diff с потребителями и данными. Красная ветка означает остановку до удаления, если старый формат ещё нужен.'), + h2('Runnable-пример: классифицируем diff'), + p('Вход функции — объект с тремя признаками: удалённые свойства, новые обязательные свойства и сужение enum. На выходе — breaking или compatible и действие для review. Это не автоматическое разрешение pull request. Пример полезен как первая страховка, после которой нужны реальные consumer tests и проверка данных.'), + code(`import { classifyApiChange } from './upgrade-2027-09.mjs'; + +const additive = classifyApiChange({ + removedProperties: [], + addedRequiredProperties: [], + narrowedEnum: false, +}); +const risky = classifyApiChange({ + removedProperties: ['displayName'], + addedRequiredProperties: [], + narrowedEnum: false, +}); + +console.log(additive.status, additive.action); +console.log(risky.status, risky.action); +// compatible run-consumer-contract-tests +// breaking version-or-expand-compatibility-window`), + h2('Порядок review для одного diff'), + ol([ + 'Скопируйте в описание изменения старую и новую форму запроса, ответа и события. Diff схемы без примеров заставляет reviewer восстанавливать смысл по именам.', + 'Запустите классификатор и вручную проверьте каждый breaking-признак: удаление, required, enum, тип и изменение семантики.', + 'Найдите потребителей по сгенерированным типам, сериализаторам, документации и тестовым fixture. Отдельно проверьте неизвестные внешние клиенты.', + 'Составьте матрицу чтения и записи старой и новой формы. Укажите, что произойдёт при частичном rollout и повторной доставке события.', + 'Добавьте отрицательные contract-тесты для старого клиента и положительные для нового. Тест должен падать на конкретном поле, а не на общем статусе.', + 'Опишите условие удаления старой формы: сигнал использования, срок хранения и способ восстановления. Без этого «временное поле» становится вечным.', + ]), + h2('Ограничения автоматической классификации'), + p('Классификатор не знает, что displayName обязателен для внешнего клиента, а внутренний клиент его игнорирует. Он не проверяет кэш, подписанные payload, очереди и генерацию SDK. Даже правильный статус breaking не говорит, как долго держать две версии. Это инструмент сортировки риска, не замена архитектурному решению.'), + p('Не всякая совместимая форма безопасна семантически. Поле может остаться строкой, но начать содержать другой часовой пояс или другую единицу измерения. Поэтому в review нужен отдельный вопрос о значении, а не только о типе. Если смысл изменился, новое имя часто дешевле, чем заставлять клиентов угадывать период перехода.'), + h2('Ограничения и следующий шаг'), + p('Статья не описывает конкретный CI, брокер или схему базы. Примеры синтетические и запускаются локально; они показывают форму решений, а не результат изменения внешнего API. Для опасных контрактов потребуется интеграция с registry схем, consumer tests и наблюдаемым сигналом использования старого поля.'), + p('Следующий шаг — выбрать один настоящий diff и заполнить четыре артефакта: старая/новая схема, таблица потребителей, тест частичного rollout и процедура удаления. Если хотя бы один потребитель неизвестен, оставьте расширение совместимым и не переходите к contract-фазе миграции.'), +], contractRefs); export const revisions = deepFreeze([practice, mechanism, field]); + +export function runApiContractFixture() { + const cases = [ + ['response-accepts-known-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'active' }).ok, true], + ['response-rejects-unknown-state', validateCustomerResponse({ id: 'c-1', revision: 1, state: 'deleted' }).reason, 'state-is-outside-enum'], + ['filter-applies-default', validateFilterInput({ state: 'blocked' }).value.limit, 20], + ['filter-rejects-large-limit', validateFilterInput({ limit: 101 }).reason, 'limit-out-of-range'], + ['diff-detects-breaking', classifyApiChange({ removedProperties: ['name'] }).status, 'breaking'], + ['diff-keeps-additive-change', classifyApiChange({ removedProperties: [], addedRequiredProperties: [], narrowedEnum: false }).status, 'compatible'], + ]; + const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected })); + return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks }); +} + export function verifyRevisionsAgainstFixture() { - const fixture = runMentorSeriesFixture(); - const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 9000 && text.length <= 12000 && /(цен[аы]|стоимост|дорог)/i.test(text.slice(0, 1400)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-09/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); + const fixture = runApiContractFixture(); + const articleChecks = revisions.map((item) => { + const text = bodyText(item.contentHtml); + return text.length >= 5000 && text.length <= 15000 && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900)); + }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-10.mjs b/web/scripts/upgrade-2027-10.mjs index 0e72c88..629581e 100644 --- a/web/scripts/upgrade-2027-10.mjs +++ b/web/scripts/upgrade-2027-10.mjs @@ -6,155 +6,291 @@ const ol = (items) => `
      ${items.map((item) => `
    1. ${item}
    2. `).join('')} `
      ${alt}
      ${caption}
      `; const table = (caption, headers, rows) => `
${headers.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${caption}
${cell}
${cell}
`; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*$/, '')); } +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(deepFreeze); + Object.freeze(value); + } + return value; +} + +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function bodyText(html) { + return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*$/, '')); +} const REFERENCES = deepFreeze({ - provDm: { title: 'W3C PROV-DM: The PROV Data Model', url: 'https://www.w3.org/TR/2013/REC-prov-dm-20130430/', version: 'W3C Recommendation, 30 April 2013, dated immutable publication' }, - c2pa13: { title: 'C2PA Technical Specification 1.3', url: 'https://spec.c2pa.org/specifications/specifications/1.3/specs/C2PA_Specification.html', version: 'C2PA Technical Specification 1.3, April 2023, version-pinned publication' }, -}); -function sources(entries) { return `
    ${entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return `
  • ${escapeHtml(ref.title)} — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}
  • `; }).join('')}
`; } - -const FIXED_INTERVIEW_LITERALS = deepFreeze({ - 'long-form-interview-plan-v1': { id: 'long-form-interview-plan-v1', editorialDate: '2026-07-31', planningIssue: '2027-10', sourceCutoff: '2026-07-31', assertion: { id: 'synthetic-assertion-contract-v1', state: 'named-synthetic-only' }, evidence: { claim: 'not-collected', source: 'not-collected', provenance: 'not-collected', independentConfirmation: 'not-collected', falsifiability: 'not-tested' }, record: { interview: 'not-created', transcript: 'not-created', speaker: 'not-named', quote: 'not-created', code: 'not-created', metric: 'not-collected', approval: 'not-requested', review: 'not-created', publication: 'not-attempted', ownership: 'not-assigned' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'long-form-interview-undated-v1': { id: 'long-form-interview-undated-v1', editorialDate: '', planningIssue: '2027-10', sourceCutoff: '2026-07-31', assertion: { id: 'synthetic-assertion-contract-v1', state: 'named-synthetic-only' }, evidence: { claim: 'not-collected', source: 'not-collected', provenance: 'not-collected', independentConfirmation: 'not-collected', falsifiability: 'not-tested' }, record: { interview: 'not-created', transcript: 'not-created', speaker: 'not-named', quote: 'not-created', code: 'not-created', metric: 'not-collected', approval: 'not-requested', review: 'not-created', publication: 'not-attempted', ownership: 'not-assigned' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'long-form-interview-unnamed-claim-v1': { id: 'long-form-interview-unnamed-claim-v1', editorialDate: '2026-07-31', planningIssue: '2027-10', sourceCutoff: '2026-07-31', assertion: { id: '', state: 'implicit' }, evidence: { claim: 'not-collected', source: 'not-collected', provenance: 'not-collected', independentConfirmation: 'not-collected', falsifiability: 'not-tested' }, record: { interview: 'not-created', transcript: 'not-created', speaker: 'not-named', quote: 'not-created', code: 'not-created', metric: 'not-collected', approval: 'not-requested', review: 'not-created', publication: 'not-attempted', ownership: 'not-assigned' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'long-form-interview-hidden-source-v1': { id: 'long-form-interview-hidden-source-v1', editorialDate: '2026-07-31', planningIssue: '2027-10', sourceCutoff: '2026-07-31', assertion: { id: 'synthetic-assertion-contract-v1', state: 'named-synthetic-only' }, evidence: { claim: 'not-collected', source: 'hidden', provenance: 'not-collected', independentConfirmation: 'not-collected', falsifiability: 'not-tested' }, record: { interview: 'not-created', transcript: 'not-created', speaker: 'not-named', quote: 'not-created', code: 'not-created', metric: 'not-collected', approval: 'not-requested', review: 'not-created', publication: 'not-attempted', ownership: 'not-assigned' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'long-form-interview-claimed-evidence-v1': { id: 'long-form-interview-claimed-evidence-v1', editorialDate: '2026-07-31', planningIssue: '2027-10', sourceCutoff: '2026-07-31', assertion: { id: 'synthetic-assertion-contract-v1', state: 'named-synthetic-only' }, evidence: { claim: 'claimed', source: 'not-collected', provenance: 'not-collected', independentConfirmation: 'not-collected', falsifiability: 'not-tested' }, record: { interview: 'not-created', transcript: 'not-created', speaker: 'not-named', quote: 'not-created', code: 'not-created', metric: 'not-collected', approval: 'not-requested', review: 'not-created', publication: 'not-attempted', ownership: 'not-assigned' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'long-form-interview-fabricated-record-v1': { id: 'long-form-interview-fabricated-record-v1', editorialDate: '2026-07-31', planningIssue: '2027-10', sourceCutoff: '2026-07-31', assertion: { id: 'synthetic-assertion-contract-v1', state: 'named-synthetic-only' }, evidence: { claim: 'not-collected', source: 'not-collected', provenance: 'not-collected', independentConfirmation: 'not-collected', falsifiability: 'not-tested' }, record: { interview: 'claimed', transcript: 'claimed', speaker: 'named', quote: 'claimed', code: 'claimed', metric: 'claimed', approval: 'claimed', review: 'claimed', publication: 'claimed', ownership: 'assigned' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'long-form-interview-disallowed-result-v1': { id: 'long-form-interview-disallowed-result-v1', editorialDate: '2026-07-31', planningIssue: '2027-10', sourceCutoff: '2026-07-31', assertion: { id: 'synthetic-assertion-contract-v1', state: 'named-synthetic-only' }, evidence: { claim: 'not-collected', source: 'not-collected', provenance: 'not-collected', independentConfirmation: 'not-collected', falsifiability: 'not-tested' }, record: { interview: 'not-created', transcript: 'not-created', speaker: 'not-named', quote: 'not-created', code: 'not-created', metric: 'not-collected', approval: 'not-requested', review: 'not-created', publication: 'not-attempted', ownership: 'not-assigned' }, requestedOutput: 'publication-approved' }, + http: { title: 'RFC 9110 — HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'IETF, июнь 2022 года, RFC 9110, Standards Track' }, + tls: { title: 'RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3', url: 'https://www.rfc-editor.org/rfc/rfc8446.html', version: 'IETF, август 2018 года, RFC 8446, Standards Track' }, + pkix: { title: 'RFC 5280 — Internet X.509 Public Key Infrastructure Certificate and CRL Profile', url: 'https://www.rfc-editor.org/rfc/rfc5280.html', version: 'IETF, май 2008 года, RFC 5280, Standards Track' }, + trace: { title: 'W3C Trace Context Level 1', url: 'https://www.w3.org/TR/2021/REC-trace-context-1-20211123/', version: 'W3C Recommendation, 23 ноября 2021 года, Trace Context Level 1' }, }); -export function createLongFormInterviewLiteral(id = 'long-form-interview-plan-v1') { const value = FIXED_INTERVIEW_LITERALS[id]; return value ? deepFreeze(cloneFixed(value)) : undefined; } -function stop(status, reason, nextAction) { return deepFreeze({ status, reason, nextAction, productionEffect: 'not-attempted' }); } -export function assessLongFormInterviewPlan(input) { - const known = Object.values(FIXED_INTERVIEW_LITERALS).some((item) => JSON.stringify(item) === JSON.stringify(input)); - if (!known) return stop('stop-unknown-fixed-literal', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-literal'); - if (input.editorialDate !== '2026-07-31' || input.planningIssue !== '2027-10' || input.sourceCutoff !== '2026-07-31') return stop('stop-temporal-boundary-required', 'editorial-date-planning-issue-and-source-cutoff-must-be-exact', 'restore-the-fixed-temporal-boundary'); - if (input.assertion.id !== 'synthetic-assertion-contract-v1' || input.assertion.state !== 'named-synthetic-only') return stop('stop-unnamed-claim', 'assertion-must-be-a-named-fixed-synthetic-literal', 'restore-the-named-assertion-contract'); - if (input.evidence.source !== 'not-collected') return stop('stop-hidden-source', 'source-cannot-be-hidden-or-inferred', 'keep-source-not-collected'); - if (input.evidence.claim !== 'not-collected' || input.evidence.provenance !== 'not-collected' || input.evidence.independentConfirmation !== 'not-collected' || input.evidence.falsifiability !== 'not-tested') return stop('stop-claimed-evidence-or-confirmation', 'claim-provenance-confirmation-and-falsifiability-are-not-collected', 'remove-the-claimed-evidence'); - if (Object.values(input.record).some((value) => !['not-created', 'not-named', 'not-collected', 'not-requested', 'not-attempted', 'not-assigned'].includes(value))) return stop('stop-fabricated-interview-record', 'interview-transcript-speaker-quote-code-metric-approval-review-publication-and-owner-cannot-be-created', 'restore-uncreated-record-states'); - if (input.requestedOutput !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'future-plan-cannot-claim-approval-publication-or-outcome', 'use-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', literalId: input.id, planningIssue: input.planningIssue, assertion: deepFreeze(cloneFixed(input.assertion)), evidence: deepFreeze(cloneFixed(input.evidence)), record: deepFreeze(cloneFixed(input.record)), productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-scope-only-if-a-real-claim-needs-evidence' }); -} -export function inspectAssertionContractLiteral() { const input = createLongFormInterviewLiteral(); const output = assessLongFormInterviewPlan(input); return deepFreeze({ assertion: input.assertion.id, claim: input.evidence.claim, status: output.status, productionEffect: output.productionEffect }); } -export function inspectEvidenceBoundaryLiteral() { const input = createLongFormInterviewLiteral(); return deepFreeze({ provenance: input.evidence.provenance, confirmation: input.evidence.independentConfirmation, falsifiability: input.evidence.falsifiability, status: assessLongFormInterviewPlan(input).status }); } -export function inspectEditorialHandoffLiteral() { const output = assessLongFormInterviewPlan(createLongFormInterviewLiteral()); return deepFreeze({ transcript: output.record.transcript, speaker: output.record.speaker, publication: output.record.publication, status: output.status }); } -export function runLongFormInterviewFixture() { - const expected = [['long-form-interview-plan-v1', 'synthetic-plan-hand-off'], ['long-form-interview-undated-v1', 'stop-temporal-boundary-required'], ['long-form-interview-unnamed-claim-v1', 'stop-unnamed-claim'], ['long-form-interview-hidden-source-v1', 'stop-hidden-source'], ['long-form-interview-claimed-evidence-v1', 'stop-claimed-evidence-or-confirmation'], ['long-form-interview-fabricated-record-v1', 'stop-fabricated-interview-record'], ['long-form-interview-disallowed-result-v1', 'stop-disallowed-positive-result']]; - const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessLongFormInterviewPlan(createLongFormInterviewLiteral(id)).status })); - const sample = createLongFormInterviewLiteral(); - return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual) && Object.isFrozen(sample) && Object.isFrozen(sample.assertion) && Object.isFrozen(sample.evidence) && Object.isFrozen(sample.record), checks: deepFreeze(checks) }); +function sources(entries) { + return `
    ${entries.map(({ key, use, boundary }) => { + const ref = REFERENCES[key]; + return `
  • ${escapeHtml(ref.title)} — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}
  • `; + }).join('')}
`; } -function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 10000 || proseLength > 13000) throw new Error(`${meta.slug}: body length ${proseLength}`); return deepFreeze({ ...meta, contentHtml, proseLength }); } -const refs = [ - { key: 'provDm', use: 'Даёт термины entity, activity, derivation и provenance для отделения утверждения от его происхождения.', boundary: 'Не подтверждает интервью, участника, высказывание, исходный код, метрику, review или будущую публикацию.' }, - { key: 'c2pa13', use: 'Даёт vocabulary assertions, provenance data, validation и distinction between verified association and value judgement.', boundary: 'Не делает synthetic literal свидетельством и не подтверждает правдивость, approval, ownership или outcome будущего материала.' }, +export function allocateTimeoutBudget({ totalMs, dnsMs = 0, tlsMs = 0, requestMs = 0 }) { + if (![totalMs, dnsMs, tlsMs, requestMs].every((value) => Number.isFinite(value) && value >= 0)) return { ok: false, reason: 'durations-must-be-non-negative' }; + const spentMs = dnsMs + tlsMs + requestMs; + return { ok: spentMs <= totalMs, spentMs, remainingMs: Math.max(0, totalMs - spentMs), reason: spentMs <= totalMs ? 'within-deadline' : 'deadline-exceeded' }; +} + +export function validateCertificateWindow({ host, sans = [], notBefore, notAfter, now }) { + if (typeof host !== 'string' || !Array.isArray(sans) || !notBefore || !notAfter || !now) return { ok: false, reason: 'certificate-fields-missing' }; + const current = Date.parse(now); + const start = Date.parse(notBefore); + const end = Date.parse(notAfter); + if (![current, start, end].every(Number.isFinite)) return { ok: false, reason: 'certificate-date-invalid' }; + if (current < start || current > end) return { ok: false, reason: 'certificate-outside-validity-window' }; + if (!sans.includes(host)) return { ok: false, reason: 'host-not-listed-in-san' }; + return { ok: true, reason: 'certificate-window-and-san-match' }; +} + +function isLowerHex(value, length) { return typeof value === 'string' && value.length === length && /^[0-9a-f]+$/.test(value); } + +export function parseTraceparent(header) { + if (typeof header !== 'string') return { ok: false, reason: 'traceparent-must-be-string' }; + const parts = header.split('-'); + if (parts.length !== 4) return { ok: false, reason: 'traceparent-must-have-four-fields' }; + const [version, traceId, parentId, flags] = parts; + if (!isLowerHex(version, 2) || version === 'ff') return { ok: false, reason: 'version-invalid' }; + if (!isLowerHex(traceId, 32) || /^0+$/.test(traceId)) return { ok: false, reason: 'trace-id-invalid' }; + if (!isLowerHex(parentId, 16) || /^0+$/.test(parentId)) return { ok: false, reason: 'parent-id-invalid' }; + if (!isLowerHex(flags, 2)) return { ok: false, reason: 'flags-invalid' }; + return { ok: true, version, traceId, parentId, flags }; +} + +function revision(meta, parts, referenceEntries) { + const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`); + return deepFreeze({ ...meta, contentHtml, proseLength }); +} + +const httpRefs = [ + { key: 'http', use: 'Определяет границы HTTP-запроса, ответа и ошибки обмена, на которые опирается разбор задержки.', boundary: 'Не измеряет DNS, TLS или работу конкретного клиента; эти интервалы нужно наблюдать отдельно.' }, + { key: 'tls', use: 'Описывает handshake TLS 1.3 и позволяет считать установление защищённого соединения отдельной фазой.', boundary: 'Не задаёт таймауты приложения, повторные попытки и настройки конкретной TLS-библиотеки.' }, ]; -const practice = revision({ slug: 'editorial-2027-10-practice-long-form-interview', title: 'Длинное техническое интервью: контракт утверждения до вопроса', categories: ['Интервью', 'Разработка'], cover: '/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg', excerpt: 'План на октябрь 2027: как до вопроса зафиксировать, что именно можно утверждать, чем это проверяется и что остаётся неизвестным.', readingMinutes: 27 }, [ - p('P116 — редакционный сценарий на октябрь 2027, составленный 2026-07-31. Длинная техническая беседа опасна не длиной, а первым неразмеченным утверждением. Вопрос уже может нести вывод о причине, решении или результате, хотя ни источник, ни граница ответа не определены. Цена ошибки проста: будущая редактура получает гладкую историю и начинает считать её evidence, хотя в пакете есть только план вопроса.'), - p('Вторая цена появляется, когда question list называют подготовленным интервью. Здесь нет actual interview, transcript, speaker, quote, code, metric, approval, review, ownership, publication или production effect. Нет и скрытого substitute: незаполненная строка не превращается в наблюдение. Единственный положительный evaluator output — synthetic-plan-hand-off с productionEffect: not-attempted. Всякая другая ветка должна остановиться, а не стать осторожной формулировкой.'), - h2('Начинаем не с вопроса, а с типа утверждения'), - p('Практика для длинного формата начинается с короткой карточки assertion. Она не просит кого-либо отвечать и не описывает предполагаемый ответ. Карточка называет только форму будущего вопроса: identifier, предмет проверки, допустимый класс evidence, provenance state и condition, при котором фраза не должна попасть в текст. В P116 эта карточка называется synthetic-assertion-contract-v1. Имя нарочно не похоже на технологию, продукт или человека.'), - p('Такой порядок кажется медленным, пока не приходится чистить материал после красивого разговора. Если сначала написать вопрос о выборе технологии, в нём легко спрятать, что выбор был, что он имел альтернативы и что у него был известный эффект. Контракт разворачивает эти три допущения в независимые поля. До evidence они получают не удобный default, а not-collected. До отдельного scope даже falsifiability остаётся not-tested: мы не знаем, какой внешний факт сможет опровергнуть будущий ответ.'), - table('Контракт утверждения для P116', ['Поле', 'Допустимый literal', 'Что оно не разрешает'], [['Assertion', 'synthetic-assertion-contract-v1', 'Реальный вопрос или известную тему'], ['Claim', 'not-collected', 'Вывод о причине, выборе или результате'], ['Source и provenance', 'not-collected', 'Ссылку на скрытый документ или память'], ['Independent confirmation', 'not-collected', 'Независимую вторую проверку'], ['Falsifiability', 'not-tested', 'Критерий, что будущая фраза уже выдержала проверку']]), - p('Здесь важен не словарь, а направление вывода. Claim — это то, что текст хотел бы сказать. Evidence — то, что могло бы поддержать claim. Provenance — откуда evidence произошло и какая activity его породила. Independent confirmation — отдельная проверка, которая не копирует тот же неизвестный источник. Falsifiability — заранее названное условие, при котором claim придётся снять или сузить. Эти слова не взаимозаменяемы: два одинаковых пересказа не становятся независимыми только потому, что лежат в разных абзацах.'), - p('У длинного интервью есть соблазн заменить контракт умной формулировкой. Например, написать вопрос так, чтобы он уже намекал на ответ. Для plan/scenario это неверная оптимизация. Чем конкретнее такой намёк, тем выше шанс, что редактор унаследует его как факт. Поэтому карточка не хранит домен, команду, дату события, канал, автора или цифру. Она хранит только named synthetic literal и отрицательные состояния. Нужная конкретика может появиться позже, но только вместе с новым разрешением и собственным происхождением.'), - figure('/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg', 'Схема: контракт утверждения проходит через source, provenance, independent confirmation и falsifiability; при отсутствии любого статуса сохраняется остановка, а не текстовый вывод.', 'Карта показывает форму редакционной проверки P116. Это не карта реального интервью и не список полученных доказательств.'), - h2('Runnable example: literal не становится разговором'), - p('Пример ниже можно выполнить локально. Он импортирует только этот module, создаёт копию named fixed literal и запускает evaluator. Он не касается диска, соединений, переменных процесса, текущего времени, секретов, телеметрии, системных интерфейсов или внешних данных. Он не создаёт interview, не отправляет вопрос, не запрашивает approval и не публикует текст. PASS означает ровно одно: in-memory shape соответствует запрету на выдуманный narrative.'), - code("import { inspectAssertionContractLiteral } from './scripts/upgrade-2027-10.mjs';\n\nconst result = inspectAssertionContractLiteral();\nconsole.log(result);\n// { assertion: 'synthetic-assertion-contract-v1',\n// claim: 'not-collected',\n// status: 'synthetic-plan-hand-off',\n// productionEffect: 'not-attempted' }"), - p('Полезна и отрицательная проверка. Literal с пустым identifier не трактуется как вопрос, который можно уточнить позднее. Literal с source: hidden не получает мягкий warning. Literal с claim: claimed не принимается как черновик. В закрытом evaluator нет режима «почти верно»: неизвестный object тоже возвращает stop-status. Такая жёсткость нужна потому, что длинный формат сильнее обычной заметки создаёт доверие к связному рассказу.'), - h2('Как собрать question contract без подмены факта'), - p('Сначала отделите предмет от предположения. Предметом может быть область будущего разбора: граница решения, порядок проверки, ограничение подхода. Предположение звучит иначе: будто выбор уже был сделан, проблема наблюдалась или последствие измерено. В P116 предположение не переписывают более осторожным словом; его убирают. Вопрос остаётся вопросом только пока не содержит готового факта внутри синтаксиса.'), - p('Затем назовите минимальный evidence class, но не придумывайте экземпляр. Для statement о поведении кода классом мог бы быть versioned artifact и воспроизводимая проверка. Для statement о редакционном решении — явно созданная запись с датой, scope и полем происхождения. Это не означает, что такие артефакты существуют для P116. Наоборот, отсутствие экземпляра фиксируется отдельно. План не имеет права создать source, потому что красивому тексту нужен источник.'), - p('Третий шаг — описать stop condition прежде, чем придумывать follow-up. Если origin не виден, нельзя делать attribution. Если один и тот же origin пересказан дважды, нельзя называть это independent confirmation. Если нет условия опровержения, нельзя повышать уверенность от повторения. Эти правила не делают будущий материал беднее. Они сокращают ложные ветви до того, как редактор начнёт защищать их плотностью текста или именем предполагаемого собеседника.'), - h2('Карточка должна пережить смену формата'), - p('Question contract полезен и тогда, когда будущий формат перестанет быть длинным. Один и тот же assertion может попасть в короткую заметку, список тем, внутренний комментарий или вообще не попасть никуда. Если карточка содержит готовую драматургию, она тянет этот narrative за собой. Если содержит только claim boundary, она остаётся переносимой. Поэтому в ней нельзя хранить предполагаемый порядок реплик, настроение, мотивацию или обещанный финал. Эти детали принадлежат материалу, который пока не существует.'), - p('Проверяйте гранулярность отдельно. Формула «почему выбрали подход» слишком широка: она смешивает факт выбора, альтернативы, критерий и последствия. Формула «какой artefact мог бы различить два описания границы» уже указывает на техническое действие, но не предполагает его ответ. Сужение вопроса не должно незаметно стать сужением ответа. Когда это различие не фиксируют, редактор легко подменяет будущую проверку собственным знанием о типичном проекте.'), - p('Для каждого поля полезно задать запрещённую операцию. Claim нельзя повысить до confidence score. Source нельзя получить из свободного текста. Provenance нельзя заменить URL на общую документацию. Confirmation нельзя засчитать по совпадению слов. Falsifier нельзя назвать после того, как narrative уже выбран. Такой список звучит как защита от очевидного, но именно эти подстановки обычно не видны в больших черновиках: каждое решение выглядит локальным, а итог становится уверенной историей без опоры.'), - p('Контракт также защищает автора от ложной обязанности продолжать. После создания fixed literal не нужно изображать, что следующий вопрос уже сформирован лучше чужого. P116 не сравнивает варианты и не оценивает редакторскую зрелость. Он всего лишь сохраняет право остановиться на вопросе. В наставническом тоне это важная практическая граница: хорошая подготовка иногда состоит в том, чтобы не превращать неполный вход в работу для других людей.'), - h2('Порядок действий'), - ol(['Записать editor date 2026-07-31, planDate 2027-10 и source cutoff 2026-07-31; не выводить их из текущего времени.', 'Создать только named assertion literal; не добавлять тему, участника, quote, цифру, код или предполагаемое решение.', 'Для claim, source, provenance и independent confirmation оставить not-collected; для falsifiability — not-tested.', 'Запустить fixture и проверить, что unnamed claim, hidden source и claimed evidence получают stop-status.', 'Проверить, что будущий текст называет план вопроса, а не actual interview или готовый материал.', 'Открывать другой scope лишь когда конкретному утверждению действительно требуется разрешённое evidence.']), - h2('Где контракт намеренно слаб'), - p('Контракт не оценивает качество будущего вопроса, не назначает интервьюера и не выбирает площадку. Он не умеет решить, будет ли у будущего материала ценность для читателя. Это ограничения, а не дефекты: попытка решить их сейчас создала бы ownership, approval или production narrative без полномочий. In-memory fixture также не проверяет документы, записи, людей или внешние системы. Он проверяет только то, что программа отказалась выдавать план за свидетельство.'), - p('Контракт не делает вопрос безопасным сам по себе. Реальная работа может потребовать согласия, проверки доступа, редакционного решения, хранения материалов и отдельного review. Ничего из этого не существует в P116 и ничего из этого нельзя достроить из статуса not-collected. Сильная сторона планового документа — не полнота, а возможность честно показать, какой вход пока отсутствует. Поэтому visual и table здесь тоже описывают форму контроля, а не обстоятельства разговора.'), - h2('Следующий шаг'), - p('Если будущая задача захочет написать один claim, она должна открыть отдельный authorized scope и принести собственные temporal boundary, source policy, provenance record, method независимого подтверждения и условие опровержения. До такого решения P116 заканчивается без интервью, без transcript и без опубликованного результата. Его единственный допустимый итог — synthetic-plan-hand-off с productionEffect: not-attempted.') -], refs); +const practice = revision({ + slug: 'editorial-2027-10-practice-long-form-interview', + title: 'HTTP-таймауты: как разложить общий deadline на измеримые фазы', + categories: ['HTTP', 'Надёжность'], + cover: '/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg', + excerpt: 'Разбираем timeout по фазам DNS, TCP, TLS, отправки и чтения, чтобы не лечить один общий симптом неправильным retry.', + readingMinutes: 15, +}, [ + p('Проблема выглядит просто: запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Цена ошибки — пользователь получает «сервис недоступен», хотя сервер мог завершить операцию; либо клиент повторяет запись, не зная, успел ли первый запрос попасть в обработчик. Один общий timeout не объясняет, где потрачено время.'), + p('Причина — смешать connect timeout, TLS handshake, отправку тела, ожидание первого байта и чтение ответа в одну настройку. При этом разные библиотеки называют поля по-разному, а повторная попытка получает новый полный бюджет. Решение начинается с единого deadline запроса и наблюдаемого разложения этого бюджета по фазам.'), + h2('Deadline — это граница операции, а не число для каждой фазы'), + p('Общий deadline отвечает на вопрос: до какого момента результат операции имеет смысл для вызывающего кода. Фазовые таймауты отвечают на другой вопрос: сколько можно ждать конкретный переход. Если каждому этапу дать по 800 мс, цепочка из DNS, TCP, TLS и чтения незаметно превращается в несколько секунд. Если выставить connect в 50 мс для сети с редким холодным DNS, ошибка будет ложной.'), + p('Хорошая модель хранит абсолютный момент окончания или остаток бюджета, а не только независимые таймеры. Перед началом каждой фазы вычисляется remaining = deadline - now. Если остатка нет, библиотека должна завершить операцию до сетевого вызова. Это предотвращает ситуацию, в которой чтение получает ещё 300 мс после того, как общий deadline уже истёк.'), + table('Фазы HTTP-запроса и сигналы', ['Фаза', 'Что измеряем', 'Типичный симптом', 'Действие'], [ + ['DNS', 'время разрешения имени', 'первый запрос медленный', 'кэш, resolver и лимит DNS'], + ['TCP connect', 'установление соединения', 'ошибка до TLS', 'маршрут, pool, connect timeout'], + ['TLS handshake', 'обмен и проверка сертификата', 'connect есть, ответа нет', 'цепочка, crypto, TLS budget'], + ['Request write', 'отправка заголовков и тела', 'upload зависает', 'размер тела, backpressure'], + ['TTFB/read', 'ожидание ответа и чтение', 'сервер медленный', 'server time, read timeout, payload'], + ]), + h2('Почему повторная попытка опасна'), + p('Timeout не сообщает, что сервер ничего не сделал. Для GET повтор часто безопаснее при корректной семантике ресурса, но для POST это зависит от идемпотентности операции и ключа дедупликации. Если клиент запускает повтор до окончания исходного запроса, два обработчика могут изменить состояние. Поэтому deadline должен попадать в журнал вместе с методом, идентификатором операции и фазой остановки.'), + p('Вторая ловушка — retry с тем же самым полным бюджетом. При трёх попытках по 800 мс пользователь ждёт 2,4 секунды плюс задержки, а upstream получает утроенную нагрузку. Бюджет можно делить между попытками, но сначала нужно доказать, что повтор допустим. Для записи полезнее короткий результат «неизвестно, завершилось ли» и проверка статуса операции, чем агрессивная отправка.'), + figure('/assets/editorial/2027/long-form-interview-2027-claim-evidence-map.svg', 'Карта HTTP-запроса: общий deadline проходит через DNS, TCP, TLS и чтение; каждая фаза получает только оставшееся время и собственный сигнал.', 'Схема отделяет бюджет операции от отдельных измерений. Ошибка фазы не должна превращаться в общий диагноз без соответствующего сигнала.'), + h2('Runnable-пример: проверяем бюджет без сети'), + p('Функция ниже получает общий бюджет и длительности уже измеренных фаз. Она возвращает сумму, остаток и причину. Это учебный пример: он не запускает socket и не заменяет таймеры HTTP-клиента, зато показывает инвариант, который удобно тестировать независимо от сети. Ожидаемый результат для превышения — deadline-exceeded и нулевой остаток.'), + code(`import { allocateTimeoutBudget } from './upgrade-2027-10.mjs'; -const mechanism = revision({ slug: 'editorial-2027-10-mechanism-long-form-interview', title: 'Длинное техническое интервью: evidence, provenance и опровержимость', categories: ['Интервью', 'Разработка'], cover: '/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg', excerpt: 'План на октябрь 2027: почему совпадающие фразы не являются независимым подтверждением и как не выдать provenance за доказанный вывод.', readingMinutes: 28 }, [ - p('P116 — плановый выпуск на 2027-10 с редакторской датой 2026-07-31 и source cutoff 2026-07-31. Ошибка механизма начинается, когда связная техническая история получает статус доказательства без visible origin. Одна и та же незафиксированная деталь может пройти через заметки, черновик и вопросник, а потом выглядеть как три подтверждения. Цена — редактор усиливает assertion количеством повторов и не замечает, что у всех повторов один неизвестный корень.'), - p('Другая цена — считать отсутствие возражения проверкой. В этом scenario нет actual interview, transcript, speaker, quote, code, metric, approval, review, publication, ownership или production effect. Поэтому нельзя сказать, что кто-то подтвердил или опроверг будущую версию. Positive output evaluator только один: synthetic-plan-hand-off с productionEffect: not-attempted. Любой hidden source, claimed evidence или fabricated record обязан завершиться fail-closed status.'), - h2('Evidence не равно тексту, который удобно цитировать'), - p('Механизм начинается с простого разделения. Evidence — конкретный material, который можно предъявить в разрешённом scope. Provenance — цепочка, объясняющая, откуда material появился, какая activity его породила и где лежит граница использования. Claim — вывод, который пытается опереться на этот material. Falsifiability — способ заранее сказать, что именно сделает вывод непригодным. Пока хотя бы одна часть отсутствует, текст может описывать вопрос, но не должен изображать установленный факт.'), - p('Independent confirmation требует не второго формулировочного варианта, а независимого пути к проверке. Два файла, созданные из одного неизвестного payload, не являются двумя evidence records. Два редактора, пересказавшие одну непомеченную фразу, не создают второго origin. Даже cryptographic binding не превращает содержание в истину: он может показывать связь assertion с asset и целостность формы, но не производит наблюдение, которого не было. Для P116 это важная граница между технической аккуратностью и фабрикацией.'), - table('Матрица механизма evidence', ['Слой', 'Что можно проверить в будущем scope', 'Что нельзя заключить в P116'], [['Claim', 'Точная формулировка и scope', 'Что он верен'], ['Evidence', 'Наличие разрешённого artefact', 'Что artefact уже существует'], ['Provenance', 'Origin, activity и temporal boundary', 'Кто сказал или сделал что-либо'], ['Independent confirmation', 'Отдельность метода и origin', 'Что два похожих текста независимы'], ['Falsifiability', 'Условие снятия или сужения claim', 'Что тест уже прошёл']]), - p('Плановый evaluator не моделирует реальные источники и не пытается угадать, будут ли они доступны. Его literals дают специальную форму для отсутствия: not-collected у claim, source, provenance и confirmation; not-tested у falsifiability. Эти значения не означают низкое качество материала. Они означают, что P116 не запускал activity, которая могла бы создать или проверить material. Механизм полезен именно потому, что не превращает неизвестность в рейтинг уверенности.'), - p('Здесь же видно, почему source boundary важнее количества ссылок. Внешние спецификации ниже поддерживают терминологию происхождения и validation. Они не являются источниками о будущих участниках или фактах беседы. Нельзя взять точное определение provenance и присоединить к нему вымышленную биографию процесса. Нормативный источник задаёт язык для разграничения, но не заменяет origin record конкретного claim. В P116 sources помогают читать поля, а не заполняют поля за будущую работу.'), - figure('/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg', 'Матрица: claim отделён от evidence, provenance, независимого подтверждения и условия опровержения; неизвестный или скрытый слой ведёт к stop-status.', 'Матрица показывает проверку формы P116. Она не фиксирует источник, участника или результат технического интервью.'), - h2('Почему falsifiability надо писать до narrative'), - p('Опровержимость в редакционной работе не означает обещание найти ошибку. Это договорённость не расширять claim, пока не определено, какое наблюдение заставит его снять или ограничить. Без такого условия любая новая деталь читается как поддержка, а несоответствие объясняется исключением. В длинном формате это особенно дорого: читатель получает десять связанных абзацев и естественно предполагает, что они прошли одинаковую проверку. Между тем один абзац мог быть только планом вопроса.'), - p('Для технической темы условие опровержения обычно привязано к виду evidence, а не к красивому контраргументу. Пример: будущий assertion о поведении кода мог бы требовать version-pinned artifact и результат минимальной воспроизводимой проверки. Если artifact относится к другой версии или проверка не различает альтернативы, claim остаётся неподтверждённым. Это не fictional code example P116; это форма границы. Мы не называем проект, функцию, output или число, чтобы не превратить пояснение в fake record.'), - p('Independent confirmation проверяют сразу после provenance. Сначала спрашивают: какие entities и activities связаны с первым evidence? Затем: использует ли второй путь те же entity, activity или скрытый интерпретатор? Только после этого можно говорить о независимости. Если связи неизвестны, корректный статус — не «вероятно независимо», а not-collected. Такая последовательность защищает от common-mode error: несколько аккуратных документов могут повторять один и тот же неподтверждённый слой.'), - h2('Runnable example: evaluator не дорисовывает подтверждение'), - p('Этот пример запускает только pure function над fixed in-memory literal. Он не открывает repository или файловые пути, не делает сетевых вызовов, не смотрит process variables, время, secrets, telemetry, system interfaces либо external data. Он не ищет origin, не вызывает валидатор, не создаёт signature и не публикует result. Вывод показывает состояние boundary, не состояние мира. Его можно исполнить безопасно, потому что data flow замкнут внутри модуля.'), - code("import { inspectEvidenceBoundaryLiteral } from './scripts/upgrade-2027-10.mjs';\n\nconst result = inspectEvidenceBoundaryLiteral();\nconsole.log(result);\n// { provenance: 'not-collected', confirmation: 'not-collected',\n// falsifiability: 'not-tested', status: 'synthetic-plan-hand-off' }"), - p('Отрицательные fixtures делают правило видимым. long-form-interview-hidden-source-v1 останавливается до narrative. long-form-interview-claimed-evidence-v1 не получает более высокий confidence, потому что в P116 нельзя собрать claim. long-form-interview-fabricated-record-v1 останавливается одним статусом для claimed interview, transcript, speaker, quote, code, metric, approval, review, publication и ownership. Это не список вероятных ошибок реального процесса; это закрытая защита от того, чтобы synthetic literal имитировал наблюдение.'), - h2('Проверка механизма по шагам'), - ol(['Сформулировать один claim type без ответа, истории и оценки будущего материала.', 'Записать required evidence class, но оставить конкретный evidence instance отсутствующим.', 'Связать provenance с origin и activity только в новом authorised scope; не подставлять память, ссылку или чужой пересказ.', 'Проверить independence: второй путь не должен наследовать тот же неизвестный origin или метод.', 'Назвать falsifier до вывода; если он не определён, оставить not-tested.', 'Прогнать fixture и убедиться, что hidden source, claimed evidence и fabricated record не деградируют до warning.']), - h2('Ложные сокращения и их стоимость'), - p('Первое ложное сокращение — считать, что provenance равна quality. Provenance помогает понять происхождение и границу trust decision, но сама не делает statement полезным или истинным. Второе — считать, что signature закрывает смысловой спор. Она может защитить association и целостность, не подтверждая интерпретацию. Третье — считать, что два согласных абзаца уже образуют independent confirmation. Без разных origins это только более громкий повтор. Все три сокращения создают привлекательный текст, но лишают будущую проверку точки входа.'), - p('Есть и обратная ошибка: требовать от plan/scenario полный evidence ledger. Это заставляет автора изобретать documents, people и measurements, чтобы таблица не выглядела пустой. В P116 пустые states — правильный результат. Мы не знаем ни формата будущего материала, ни того, будет ли доказательство вообще нужно. Поэтому evaluator не хранит fallback source, confidence score или заранее одобренный route. Such defaults особенно опасны: они делают неполный context похожим на подготовленное решение.'), - h2('Разделяйте проверку формы и проверку содержания'), - p('Сначала проверяется форма записи: literal известен, даты точны, поля не получают скрытых значений. Затем, в другом scope, может проверяться содержание будущего claim. Смешивать эти этапы нельзя. Формальная проверка может доказать, что object не содержит fabricated field, но не может сказать, что техническое объяснение верно. И наоборот, даже сильный материал не отменяет необходимость показать его origin. Два вида проверки дополняют друг друга, но не заменяют один другой.'), - p('Это различие особенно полезно при независимом подтверждении. У второго пути должны быть отдельно названы material, method и relationship с первым путём. Нельзя считать независимостью тот случай, когда второй reviewer читает первый summary, а затем соглашается с ним. Нельзя считать независимостью тот случай, когда одна система экспортирует один и тот же state в два файла. Until the paths are described, P116 сохраняет not-collected, даже если потенциально можно было бы придумать правдоподобную схему.'), - p('Falsifier тоже надо держать маленьким. Он не обязан опровергать весь будущий материал; достаточно определить условие, которое не позволит произнести конкретный claim в заявленной широте. Например, несовпадение temporal boundary может снять право переносить statement между версиями. Отсутствие различающего теста может снять причинный вывод. Неудача не делает источник плохим и не делает автора некомпетентным. Она только возвращает assertion в состояние, где его нельзя использовать как установленную связь.'), - p('Такой механизм не запрещает редакционную интерпретацию. Он требует подписать её уровнем уверенности и не спутать с evidence. В будущем scope можно будет отдельно записать, где заканчивается observed material и начинается пояснение для читателя. P116 намеренно не создаёт даже эту границу для конкретного сюжета: нет сюжета, нет observation, нет authorial conclusion. Есть лишь правило, что empty provenance нельзя компенсировать гладкой логикой.'), - h2('Ограничения и следующий scope'), - p('P116 не является provenance system, fact-checking process, interview protocol или validation service. Он не определяет, какие источники будут допустимы в будущем, и не обещает, что independent confirmation достижимо. Никакой status здесь не говорит о правдивости, качестве, ценности или согласовании будущего текста. Fixture проверяет только closed-world equality с named literals. Внешний claim, даже правдоподобный, должен быть отвергнут до тех пор, пока другой scope не определит его данные и полномочия.'), - p('Следующий шаг — создать отдельный scope, если конкретный editorial claim действительно требует evidence. В нём надо зафиксировать source policy, origin record, access boundary, method независимой проверки, falsifier и правило, что делать при отсутствии данных. P116 не переносит в этот scope готовый вывод. Он оставляет только synthetic-plan-hand-off и productionEffect: not-attempted, чтобы future work не получила invented certainty вместе с планом.') -], refs); +const within = allocateTimeoutBudget({ + totalMs: 800, + dnsMs: 42, + tlsMs: 88, + requestMs: 510, +}); +const late = allocateTimeoutBudget({ + totalMs: 800, + dnsMs: 120, + tlsMs: 210, + requestMs: 560, +}); -const field = revision({ slug: 'editorial-2027-10-field-long-form-interview', title: 'Длинное техническое интервью: редакционный hand-off без transcript', categories: ['Интервью', 'Разработка'], cover: '/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg', excerpt: 'План на октябрь 2027: как передать будущий редакционный вопрос, не создав transcript, speaker, approval или историю публикации.', readingMinutes: 27 }, [ - p('P116 — future editorial plan/scenario: editor date 2026-07-31, planDate 2027-10, source cutoff 2026-07-31. В field hand-off самая дорогая ошибка выглядит как аккуратная папка: question list, схема, ссылка и краткое резюме. Получатель видит полноту формы и может решить, что interview уже состоялся, материалы собраны, а дальнейшая работа — лишь выпуск. Цена такой подмены — fabricated transcript и approval появляются не в тексте, а в ожиданиях следующего человека.'), - p('В этом пакете нет actual interview, transcript, speaker, quote, code, metric, evidence, review, publication, ownership или production effect. Нельзя прикрыть их neutral labels: not-created не означает потерянный файл, not-named не означает скрытого собеседника, not-requested не означает pending approval. Единственный positive evaluator result — synthetic-plan-hand-off с productionEffect: not-attempted. Всё, что пытается выдать plan за редакционный факт, закрывается fail-closed.'), - h2('Field hand-off передаёт границу, а не narrative'), - p('Хорошая передача в изолированном scope должна быть достаточно полезной, чтобы новый scope мог начать с правильного вопроса, и достаточно пустой, чтобы не унаследовать ложный результат. Поэтому P116 отдаёт четыре вещи: temporal boundary, named assertion contract, evidence states и record states. Он не отдаёт тезис будущей статьи, список вопросов к человеку, summary ответа или путь публикации. Такой набор намеренно похож на технический interface: у него есть доступные поля и явные запреты, но нет скрытого поведения.'), - p('Record states важнее, чем кажутся. interview: not-created запрещает называть план состоявшимся разговором. transcript: not-created запрещает делать вид, что существует расшифровка. speaker: not-named запрещает attribution. quote: not-created и code: not-created не позволяют добавить illustrative fragment без происхождения. metric: not-collected не оставляет цифре роль decoration. Остальные states так же явно не создают approval, review, publication или owner.'), - table('Field hand-off и недопустимая интерпретация', ['Поле hand-off', 'Состояние P116', 'Неверное чтение'], [['Interview', 'not-created', 'Разговор назначен или состоялся'], ['Transcript и quote', 'not-created', 'Есть запись или дословная фраза'], ['Speaker', 'not-named', 'Можно приписать позицию роли или человеку'], ['Approval и review', 'not-requested / not-created', 'Материал согласован или проверен'], ['Publication и ownership', 'not-attempted / not-assigned', 'Есть канал выпуска или ответственный']]), - p('Эта точность не бюрократическая. Handoff часто переживает автора и исходный контекст. Через неделю status без происхождения легко читается как история: «не нашли transcript», «неизвестен speaker», «approval задержался». P116 говорит другое: соответствующие activities не создавались в этом scope. Следующий scope может начать совсем по-другому, сузить задачу или отказаться от неё. Он не обязан восстанавливать отсутствующий процесс и не несёт мнимую обязанность завершить опубликованный narrative.'), - p('Визуальная схема не должна компенсировать отсутствие реальных материалов реалистичными иконками. Поэтому SVG P116 содержит только gates и состояния, без портретов, сервисов, таймлайна или имитации документа. Русские alt и caption прямо называют её редакционным loop. На узком экране caption имеет тот же вес, что основной текст: если она скажет «интервью», а body скажет «план», читатель запомнит первое. Accessibility здесь поддерживает temporal honesty, а не только формальную разметку.'), - figure('/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg', 'Петля hand-off: temporal boundary, assertion contract и evidence states ведут либо к отдельному authorised scope, либо к stop; transcript, speaker, approval и publication не создаются.', 'Схема показывает маршрут передачи P116 без fabricated transcript, интервью, владельца или публикации.'), - h2('Runnable example: передача не делает публикацию'), - p('Пример ниже проверяет output evaluator и выводит только fixed record states. Он не открывает папку или repository, не использует I/O, переменные окружения, часы процесса, секреты, телеметрию, системные API либо внешние records. Он не создаёт editor workflow, не назначает owner и не отправляет уведомления. Эти отсутствующие side effects важнее удобной демонстрации: запуск должен быть безопасен в любом локальном shell и не давать ложного впечатления, что package что-то передал реальному получателю.'), - code("import { inspectEditorialHandoffLiteral } from './scripts/upgrade-2027-10.mjs';\n\nconst result = inspectEditorialHandoffLiteral();\nconsole.log(result);\n// { transcript: 'not-created', speaker: 'not-named',\n// publication: 'not-attempted', status: 'synthetic-plan-hand-off' }"), - p('Если record получает claimed, evaluator не пытается выбрать менее смелую версию. Named bad literal собирает сразу interview, transcript, speaker, quote, code, metric, approval, review, publication и ownership в forbidden shape; результат — stop-fabricated-interview-record. Это также защищает от мелкой подмены: роль без имени всё равно была бы fabricated attribution, а условная цифра всё равно была бы fabricated metric. В P116 нельзя превращать illustrative content в quasi-evidence.'), - h2('Что получатель действительно получает'), - p('Получатель получает границу редакционной ответственности. Он знает, что P116 не устанавливает actual event, не даёт право на contact и не создаёт publication task. Он видит exact dates, positive status и список запрещённых переходов. Этого достаточно, чтобы не использовать package как доказательство при решении. Если для следующего решения evidence не требуется, hand-off может остаться конечным артефактом. Если требуется, он сообщает, что нужен новый scope, а не продолжение по умолчанию.'), - p('Получатель не получает owner. Такое отсутствие иногда кажется неудобным: кому тогда адресовать вопрос? Но owner — не decoration документа, а решение с последствиями. Его невозможно спрятать за generic role, потому что даже role создаёт ожидание и маршрут работы. P116 оставляет ownership: not-assigned. Новый authorised scope сам обязан определить полномочия, контакт, consent, storage и exit condition; до этого старый plan не должен толкать работу в конкретную сторону.'), - p('То же относится к approval. Approval не можно симулировать фразой «готово к согласованию», если не определены материал, reviewer и правило решения. В P116 approval имеет not-requested, потому что запрашивать нечего. Review — not-created, потому что нечего проверять. Такая пара предотвращает классическую ошибку hand-off: наличие аккуратной схемы ошибочно принимают за готовность начать publication. Схема показывает только условия, при которых будущая работа должна остановиться.'), - h2('Порядок редакционной передачи'), - ol(['Передать exact temporal boundary: editor date 2026-07-31, planDate 2027-10, source cutoff 2026-07-31.', 'Передать named assertion literal и states evidence; не добавлять summary, предполагаемую тему или список людей.', 'Сохранить interview, transcript, quote, code и review в not-created; не создавать illustrative substitutes.', 'Сохранить speaker как not-named, approval как not-requested, ownership как not-assigned и publication как not-attempted.', 'Прогнать fixture: hidden source, claimed evidence, fabricated record и disallowed positive result должны остановиться.', 'Открыть отдельный scope только при конкретном решении; он может завершиться без интервью и без публикации.']), - h2('Граница источников и редакционной целостности'), - p('W3C PROV-DM полезен здесь как vocabulary: entity, activity, agent и derivation помогают не склеить plan с record. Но он не создаёт entity, activity или agent в P116. C2PA 1.3 полезна другим ограничением: validation association и tamper evidence не дают value judgement о содержании. В editorial hand-off это значит, что даже хорошо оформленный object нельзя объявить правдивым рассказом. Мы используем обе спецификации как язык границы, а не как authority над future interview.'), - p('Список sources находится после body и содержит exact URL, version/date, use и boundary. Это важная форма: source section должна быть проверяемой, но не должна выглядеть как скрытый пакет evidence о неизвестном событии. Ни одна ссылка не подтверждает person, statement, metric, approval или effect. Если новый scope принесёт собственные materials, он обязан отделить их provenance от этих нормативных ссылок. Иначе vocabulary quietly becomes a borrowed source, а hand-off снова начинает рассказывать историю вместо передачи ограничения.'), - h2('Как не испортить передачу полезной конкретикой'), - p('Первый риск — добавить образец transcript, чтобы получателю было понятнее. Даже если в нём нет имени, образец быстро становится шаблоном ожидаемого ответа и выглядит как fragment реального разговора. Второй риск — добавить условный code block. Он создаёт ложное впечатление, что будущая тема уже связана с конкретной системой. Третий риск — вставить приблизительную метрику для масштаба. Такая цифра почти неизбежно будет процитирована без слова «условная». P116 не использует ни один из этих приёмов.'), - p('Полезность hand-off строится иначе: на точных запретах и следующем безопасном входе. Получатель видит, какие states нельзя повышать, какие facts нельзя дописывать и какие вопросы должен решить новый scope. Это меньше похоже на brief, зато не давит на будущую работу готовой постановкой. В изолированном пакете ограничение часто ценнее детали, потому что detail без authorisation создаёт именно тот долг, который hand-off обязан был не переносить.'), - p('Четвёртый риск — назвать generic owner, чтобы пакет не выглядел бесхозным. Но generic owner не нейтрален: он меняет, кто предполагаемо отвечает за privacy, контакт и публикацию. Поэтому not-assigned не следует заменять названием функции или команды. Аналогично нельзя писать, что approval «остаётся за редактором»: это уже ownership decision. Новый scope должен принять его явно или оставить работу незапущенной. P116 не имеет мандата делать это вместо него.'), - p('Пятый риск — посчитать PASS fixture знаком готовности к выпуску. Fixture знает только fixed literals и сознательно не читает внешний мир. Он не может увидеть корректность текста, желание участника, право хранения или качество будущей редакторской работы. PASS доказывает исключительно, что запрещённые shapes не прошли evaluator. Это полезный, но узкий результат. В hand-off его надо писать рядом с productionEffect: not-attempted, иначе техническая проверка снова станет суррогатом реального approval.'), +console.log(within.ok, within.remainingMs); +console.log(late.ok, late.reason, late.remainingMs); +// true 160 +// false deadline-exceeded 0`), + h2('Порядок диагностики'), + ol([ + 'Запишите общий deadline и отдельно отметьте, в какой момент клиент завершил ожидание. Не начинайте с увеличения числа.', + 'Добавьте интервалы DNS, TCP, TLS, отправки и чтения в одну запись запроса. Одинаковые имена фаз важнее конкретной библиотеки.', + 'Проверьте, передаётся ли остаток бюджета на следующую фазу. Новый таймер не должен начинаться после истечения абсолютного deadline.', + 'Сопоставьте фазу отказа с методом. Для записи проверьте идемпотентность и возможность узнать результат по идентификатору операции.', + 'Разделите connect/read timeout и deadline в конфигурации. Каждый параметр должен иметь владельца и тест на граничное значение.', + 'Проверьте cold и warm соединение отдельно: pool может скрыть TLS в одном случае и показать его в другом.', + ]), + h2('Что измерение не доказывает'), + p('Большой TTFB не доказывает, что сервер «медленный»: время могло уйти на proxy, очередь или повторный TLS. Малый TTFB не гарантирует быструю загрузку всего тела. Сетевой timeout также не равен HTTP-статусу: клиент может закрыть соединение, а сервер продолжить обработчик. Поэтому метрики фаз нужно связывать с серверным временем и размером ответа, но не подменять ими друг друга.'), + p('Точное разделение требует, чтобы библиотека действительно сообщала фазы. Если она отдаёт только total duration, не называйте реконструированные интервалы фактами. Можно начать с instrumented adapter или событий connection pool, а пока фиксировать только известную границу: «клиент прекратил ждать через N мс». Это менее эффектно, но безопаснее для решения.'), h2('Ограничения и следующий шаг'), - p('P116 не заменяет consent process, editorial policy, fact check, records retention, interview format или publication workflow. Он не определяет язык будущего материала и не гарантирует, что работа будет кому-либо нужна. Fixture не является review, потому что проверяет synthetic literal, а не текст разговора. Он не создаёт production effect и не измеряет качество. Эти ограничения удерживают scope от расширения в систему редакционного управления, которой пользователь не поручал появиться.'), - p('Следующий шаг возможен, если отдельное решение требует реального claim. Новый scope должен назвать материалы, доступ, provenance, независимое подтверждение, falsifier, privacy boundary, owner и разрешённый выход без выпуска. Пока он не создан, P116 оставляет только synthetic-plan-hand-off с productionEffect: not-attempted. Это не недоделанное интервью; это честно завершённый hand-off, который не фабрикует ни transcript, ни speaker, ни outcome.') -], refs); + p('Функция не учитывает jitter часов, системные очереди, HTTP/2 multiplexing и работу proxy. В боевом клиенте deadline должен отменять все вложенные операции, иначе сокет продолжит жить после ответа вызывающему коду. Учебные числа не являются нормативными значениями для конкретной сети.'), + p('Следующий шаг — выбрать один endpoint, измерить пять фаз на холодном и тёплом соединении и построить таблицу бюджета для успешного и просроченного запроса. Только после этого меняйте timeout или retry. Для записи добавьте отдельную проверку идемпотентности и способ узнать, был ли первый запрос принят.'), +], httpRefs); + +const certificateRefs = [ + { key: 'tls', use: 'Описывает TLS 1.3 как протокол защищённого канала и задаёт место проверки сертификата внутри handshake.', boundary: 'Не перечисляет доверенные корни конкретной ОС и не гарантирует настройку вашего клиента.' }, + { key: 'pkix', use: 'Описывает X.509-поля, цепочку сертификации, расширения и правила проверки имени/срока.', boundary: 'Не заменяет системное хранилище доверия, отзыв сертификата и политику конкретного домена.' }, +]; + +const mechanism = revision({ + slug: 'editorial-2027-10-mechanism-long-form-interview', + title: 'TLS-сертификат: почему «curl работает» не закрывает проверку', + categories: ['Security', 'HTTP'], + cover: '/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg', + excerpt: 'Разбираем цепочку доверия, срок действия и SAN: какие проверки проходят до HTTP и почему один успешный клиент ничего не доказывает для другого.', + readingMinutes: 16, +}, [ + p('Проблема обычно выглядит противоречиво: браузер открывает адрес, а сервисный клиент получает certificate error; либо один контейнер подключается, а второй — нет. Цена ошибки — отключить проверку TLS «временно», потерять имя хоста в диагностике и превратить сетевую проблему в уязвимость.'), + p('Причина в том, что «сертификат валиден» — это не одна проверка. Клиент строит цепочку до доверенного корня, проверяет период действия, имя назначения и ограничения сертификата. Разные хранилища корней, SNI, proxy и часы системы меняют результат. Нужно разделить слой протокола, X.509-структуру и локальную политику доверия.'), + h2('Что проверяет клиент до HTTP'), + p('TLS handshake создаёт защищённый канал, но доверие к peer не появляется из шифрования автоматически. Сертификат содержит открытый ключ, имя и подпись издателя; клиент проверяет цепочку и применимость к назначенному хосту. Если запрос идёт на api.example.test, сертификат только для admin.example.test не должен считаться подходящим из-за того, что ключ технически рабочий.'), + p('Период действия проверяется по часам клиента. Ошибка в системном времени даёт симптом «сертификат ещё не действителен» или «истёк», хотя сервер ничего не менял. Переход на другой контейнер может поменять корневое хранилище и набор промежуточных сертификатов. Поэтому при сравнении сред нужно собирать не только URL, но и hostname, SNI, trust store, время и цепочку.'), + table('Минимальная проверка сертификата', ['Проверка', 'Вопрос', 'Отказ', 'Что собрать'], [ + ['Срок', 'now между notBefore и notAfter?', 'not-yet-valid / expired', 'UTC-время клиента и поля сертификата'], + ['Имя', 'host есть в SAN?', 'hostname mismatch', 'SNI, hostname и SAN'], + ['Цепочка', 'есть путь до доверенного корня?', 'unknown issuer', 'leaf, intermediate, trust store'], + ['Подпись', 'алгоритм и ключ разрешены?', 'signature/algorithm error', 'TLS policy и negotiated version'], + ['Отзыв', 'политика проверяет статус?', 'revoked/unknown', 'OCSP/CRL policy и доступность'], + ]), + h2('Почему отключение verify ухудшает диагностику'), + p('Флаг вроде insecure меняет вопрос с «можно ли доверять peer» на «зашифрован ли канал до кого-то». Запрос начинает проходить, но факт успеха перестаёт говорить о подлинности сервера. Если потом этот флаг попадёт в общий клиент или пример конфигурации, временная отладка станет постоянной дырой.'), + p('Надёжнее вывести диагностическую информацию без обхода проверки: имя хоста, SNI, цепочку, срок, код ошибки и идентификатор корня. В тестовой среде можно добавить собственный CA в доверенное хранилище или передать его явно. Такой путь сохраняет настоящую проверку и делает отличие среды видимым.'), + figure('/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg', 'Матрица TLS-проверки: сертификат проходит срок действия, имя SAN, цепочку доверия и алгоритмическую политику до начала HTTP-обмена.', 'Диаграмма разделяет данные сертификата и локальную политику. Успешный запрос одного клиента не является доказательством для другого trust store.'), + h2('Runnable-пример: срок и SAN как отдельные причины'), + p('Функция ниже не строит X.509-цепочку и не заменяет TLS-библиотеку. Она принимает ISO-даты, hostname и список SAN, затем показывает две базовые проверки, которые полезно видеть в тестах и диагностическом выводе. Входы специально простые; ожидаемый результат различает успех, истёкший сертификат и несовпадение имени.'), + code(`import { validateCertificateWindow } from './upgrade-2027-10.mjs'; + +const common = { + host: 'api.example.test', + sans: ['api.example.test', 'api.internal.test'], + notBefore: '2026-01-01T00:00:00Z', + notAfter: '2027-01-01T00:00:00Z', +}; + +console.log(validateCertificateWindow({ ...common, now: '2026-06-01T00:00:00Z' })); +console.log(validateCertificateWindow({ ...common, host: 'cdn.example.test', now: '2026-06-01T00:00:00Z' }).reason); +// { ok: true, reason: 'certificate-window-and-san-match' } +// host-not-listed-in-san`), + h2('Порядок проверки в среде'), + ol([ + 'Зафиксируйте точный hostname и порт, который видит TLS-клиент. IP-адрес в логе не заменяет имя для проверки SAN.', + 'Проверьте часы контейнера и узла в UTC. Ошибку времени нельзя лечить повторной загрузкой сертификата.', + 'Снимите leaf и intermediate без отключения verify. Сравните цепочку с trust store конкретного процесса.', + 'Проверьте SAN, SNI и redirect. Сертификат для исходного адреса не обязан подходить для нового host после перенаправления.', + 'Разделите ошибку доверия, имени, срока и алгоритма. Для каждой причины оставьте отдельный тестовый fixture.', + 'Исправляйте trust store или цепочку на сервере; флаг обхода проверки не используйте как решение.', + ]), + h2('Цепочка не равна доверию'), + p('Наличие intermediate в файле сервера не означает, что клиент доверяет корню. И наоборот, локальный trust store может содержать корень, но сервер не отправить промежуточный сертификат. Успешная проверка строит путь по подписи и ограничениям, а не по совпадению строк в PEM-файле. Это объясняет, почему «в браузере работает» может быть правдой одновременно с ошибкой минимального контейнера.'), + p('Сертификат также не сообщает всю эксплуатационную политику. Клиент может проверять отзыв, запрещать старый алгоритм или требовать минимальную версию TLS. Если диагностический отчёт пишет только «certificate valid», он скрывает полезную часть причины. Сохраняйте код ошибки библиотеки и параметры соединения, а секретный ключ и полное содержимое лишний раз не логируйте.'), + h2('Ограничения и следующий шаг'), + p('Учебная функция не проверяет подпись, CRL, OCSP, wildcard-правила, DNS и реальное TLS-согласование. Она не является security scanner. Её роль — сделать две часто потерянные проверки явными и тестируемыми без сетевой зависимости.'), + p('Следующий шаг — воспроизвести ошибку в том же контейнере, где работает сервис, собрать hostname/SNI, цепочку, время и код ошибки, а затем исправить конкретный слой. После исправления оставьте regression test на истёкший срок и неверный SAN, чтобы повторное «временное» отключение доверия стало заметным.'), +], certificateRefs); + +const traceRefs = [ + { key: 'trace', use: 'Задаёт формат traceparent, правила создания и передачи идентификаторов между HTTP-компонентами.', boundary: 'Не определяет backend storage, sampling, формат логов и семантику бизнес-операции.' }, + { key: 'http', use: 'Описывает HTTP-поля и обмен, в котором передаётся контекст трассировки.', boundary: 'Не гарантирует, что proxy пропустит или сервис корректно обработает пользовательский заголовок.' }, +]; + +const field = revision({ + slug: 'editorial-2027-10-field-long-form-interview', + title: 'Trace Context в HTTP: как не потерять запрос на границе proxy', + categories: ['Observability', 'HTTP'], + cover: '/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg', + excerpt: 'Практический разбор traceparent: формат, правила передачи через proxy и безопасная проверка заголовка на входе сервиса.', + readingMinutes: 15, +}, [ + p('Проблема видна во время сбоя: gateway сообщает timeout, backend пишет 500, а связать записи по одному запросу невозможно. Цена потери контекста — инженер читает десятки похожих логов, ошибочно обвиняет медленный сервис и дольше держит пользователя в неопределённости.'), + p('Причина часто не в отсутствии системы трассировки, а в границе передачи. Proxy удаляет неизвестный заголовок, middleware создаёт новый trace вместо продолжения или сервис принимает строку с неверным форматом. Исправление требует разделить идентификатор трассы, идентификатор родительского span, правила доверия и способ записи событий.'), + h2('Что именно передаёт traceparent'), + p('Заголовок traceparent содержит четыре поля: версию, trace-id, parent-id и flags. Trace-id связывает дерево обработки одного запроса, parent-id показывает непосредственный предыдущий участок, а flags несут небольшие свойства контекста. Формат строгий: lowercase hexadecimal, правильная длина и ненулевые идентификаторы. Если строка не проходит проверку, её нельзя молча преобразовывать.'), + p('Сервис на входе должен решить, продолжать ли внешний trace. Валидный контекст может быть принят как родительский, а новый span создаётся уже локальной библиотекой. Невалидный заголовок лучше отбросить и начать новый локальный trace, одновременно записав безопасную причину. Нельзя доверять trace-id для авторизации или помещать в него пользовательские данные.'), + table('Граница передачи HTTP-контекста', ['Участок', 'Проверка', 'Ошибка', 'Действие'], [ + ['Клиент → gateway', 'заголовок добавлен один раз', 'новый trace на каждый redirect', 'сохранять контекст по правилам клиента'], + ['Gateway → service', 'proxy пропускает traceparent', 'поле удалено', 'allow-list заголовка и integration test'], + ['Middleware', 'формат и ненулевые id', 'uppercase/короткое поле', 'отбросить и создать локальный trace'], + ['Logger', 'trace-id попал в структурное поле', 'текстовый grep не находит', 'единый JSON key и sampling policy'], + ['Сервис → очередь', 'HTTP-контекст не теряется при смене транспорта', 'новое дерево без link', 'использовать механизм контекста брокера'], + ]), + h2('Proxy — часть контракта наблюдаемости'), + p('Заголовок, дошедший до приложения локально, ничего не доказывает о внешнем маршруте. Реальный proxy может нормализовать имя, удалить неизвестное поле, ограничить размер или создать свой контекст. Поэтому тестировать нужно пару gateway + service с теми же правилами allow-list, а не только чистую функцию парсинга.'), + p('Проверка также должна учитывать границы доверия. Внешний клиент может прислать любой trace-id. Это полезный идентификатор для диагностики, но не секрет и не доказательство личности. Логи должны экранировать значение, а sampling не должен удалять единственный event с ошибкой. Наблюдаемость помогает найти проблему, но не должна становиться каналом для доступа.'), + figure('/assets/editorial/2027/long-form-interview-2027-editorial-handoff-loop.svg', 'Цепочка HTTP-контекста: клиент передаёт traceparent gateway, gateway сохраняет его при пересылке, а сервис проверяет формат и создаёт локальный span.', 'Схема показывает место проверки на границе сервиса. Заголовок связывает события, но не является авторизацией и не заменяет логику хранения.'), + h2('Runnable-пример: отклоняем повреждённый traceparent'), + p('Парсер получает ровно одну строку и возвращает разобранные поля только после проверки длины, lowercase hexadecimal и ненулевых идентификаторов. Для плохого входа он возвращает причину, которую можно посчитать в метрике без записи полного пользовательского заголовка. Пример не создаёт span и не отправляет telemetry; он демонстрирует проверяемую границу формата.'), + code(`import { parseTraceparent } from './upgrade-2027-10.mjs'; + +const valid = parseTraceparent( + '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', +); +const invalid = parseTraceparent( + '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01', +); + +console.log(valid.ok, valid.traceId.slice(0, 8)); +console.log(invalid.ok, invalid.reason); +// true 4bf92f35 +// false trace-id-invalid`), + h2('Порядок поиска потерянного контекста'), + ol([ + 'Выберите один запрос и выпишите его границы: клиент, gateway, service и downstream. Для каждой границы нужен отдельный trace-id в логах.', + 'Проверьте raw-заголовок до proxy и после proxy. Если значение исчезает, не начинайте с backend-библиотеки.', + 'Добавьте parser fixture на нулевые id, uppercase, неверную длину и запрещённую версию. Ошибка должна быть безопасной и короткой.', + 'Убедитесь, что middleware продолжает валидный trace и создаёт новый локальный parent-id, а не переписывает весь trace.', + 'Приведите логи к одному структурному ключу и добавьте ошибку парсинга как счётчик без полного заголовка.', + 'Прогоните интеграционный тест через реальный proxy, затем отдельно проверьте sampling и задержку доставки telemetry.', + ]), + h2('Почему один идентификатор не решает диагностику'), + p('Trace-id связывает события, но не объясняет, сколько времени занял каждый участок. Для этого нужны span-границы, timestamps с понятными часами и статус ошибки. Если gateway и сервис пишут один trace-id, но не записывают начало и конец span, инженер всё ещё не знает, где возникла задержка.'), + p('Не следует использовать trace-id как ключ бизнес-операции без отдельного поля. Пользователь может повторить операцию, а один trace может завершиться до асинхронной обработки. Для записи и очереди добавляйте безопасный operation-id и связывайте их в журнале по правилам приватности. Так диагностика не подменяет доменную идемпотентность.'), + h2('Ограничения и следующий шаг'), + p('Парсер не проверяет подпись, доверие к отправителю, sampling backend и форматы контекста Kafka/очереди. Он работает только с header string. Разные библиотеки могут дополнительно проверять version и flags, поэтому интеграционный тест должен закрепить выбранное поведение.'), + p('Следующий шаг — добавить один end-to-end тест через proxy: отправить валидный traceparent, проверить один trace-id во всех сервисных логах и отдельно послать повреждённую строку. Вторая проверка должна показать controlled fallback, а не 500 и не принятие внешнего значения как права доступа.'), +], traceRefs); export const revisions = deepFreeze([practice, mechanism, field]); -export function verifyRevisionsAgainstFixture() { const fixture = runLongFormInterviewFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 10000 && text.length <= 13000 && /(цен[аы]|стоимост|дорог)/i.test(text.slice(0, 1500)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-10/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +export function runHttpBoundaryFixture() { + const cases = [ + ['deadline-within', allocateTimeoutBudget({ totalMs: 800, dnsMs: 40, tlsMs: 90, requestMs: 500 }).reason, 'within-deadline'], + ['deadline-exceeded', allocateTimeoutBudget({ totalMs: 800, dnsMs: 120, tlsMs: 210, requestMs: 560 }).reason, 'deadline-exceeded'], + ['certificate-valid', validateCertificateWindow({ host: 'api.test', sans: ['api.test'], notBefore: '2026-01-01T00:00:00Z', notAfter: '2027-01-01T00:00:00Z', now: '2026-06-01T00:00:00Z' }).ok, true], + ['certificate-san-rejects', validateCertificateWindow({ host: 'cdn.test', sans: ['api.test'], notBefore: '2026-01-01T00:00:00Z', notAfter: '2027-01-01T00:00:00Z', now: '2026-06-01T00:00:00Z' }).reason, 'host-not-listed-in-san'], + ['traceparent-valid', parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01').ok, true], + ['traceparent-rejects-uppercase', parseTraceparent('00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01').reason, 'trace-id-invalid'], + ]; + const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected })); + return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks }); +} + +export function verifyRevisionsAgainstFixture() { + const fixture = runHttpBoundaryFixture(); + const articleChecks = revisions.map((item) => { + const text = bodyText(item.contentHtml); + return text.length >= 5000 && text.length <= 15000 && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900)); + }); + return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); +} + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n'); diff --git a/web/scripts/upgrade-2027-11.mjs b/web/scripts/upgrade-2027-11.mjs index ef9aa33..92a5e18 100644 --- a/web/scripts/upgrade-2027-11.mjs +++ b/web/scripts/upgrade-2027-11.mjs @@ -6,216 +6,293 @@ const ol = (items) => `
      ${items.map((item) => `
    1. ${item}
    2. `).join('')} `
      ${alt}
      ${caption}
      `; const table = (caption, headers, rows) => `
${headers.map((cell) => ``).join('')}${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${caption}
${cell}
${cell}
`; -function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } -function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } -function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } -function bodyText(html) { return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*$/, '')); } +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(deepFreeze); + Object.freeze(value); + } + return value; +} + +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function bodyText(html) { + return plainText(html.replace(/

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

Проверяемые источники<\/h2>[\s\S]*$/, '')); } +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(deepFreeze); + Object.freeze(value); + } + return value; +} + +function plainText(html) { + return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039;)/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function bodyText(html) { + return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*$/, '')); +} const REFERENCES = deepFreeze({ - rfc2119: { title: 'RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels', url: 'https://www.rfc-editor.org/rfc/rfc2119.html', version: 'IETF RFC 2119, March 1997, immutable RFC publication' }, - rfc8174: { title: 'RFC 8174 — Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words', url: 'https://www.rfc-editor.org/rfc/rfc8174.html', version: 'IETF RFC 8174, May 2017, immutable RFC publication' }, - provdm: { title: 'PROV-DM: The PROV Data Model', url: 'https://www.w3.org/TR/2013/REC-prov-dm-20130430/', version: 'W3C Recommendation, 30 April 2013, dated immutable Recommendation' }, -}); -function sources(entries) { return `
    ${entries.map(({ key, use, boundary }) => { const ref = REFERENCES[key]; return `
  • ${escapeHtml(ref.title)} — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}
  • `; }).join('')}
`; } - -const FIXED_MANIFESTO_LITERALS = deepFreeze({ - 'author-manifesto-plan-v1': { id: 'author-manifesto-plan-v1', editorialDate: '2026-07-31', planDate: '2027-12', sourceCutoff: '2026-07-31', sourceSet: 'author-manifesto-official-sources-v1', question: 'author-manifesto-decision-record-before-advice-v1', reviewInputs: { evidence: 'not-provided', temporal: 'not-provided', voice: 'not-provided' }, declarations: { quality: 'not-declared', pass: 'not-declared', publication: 'not-declared', correction: 'not-declared', outcome: 'not-declared' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'author-manifesto-undated-v1': { id: 'author-manifesto-undated-v1', editorialDate: '', planDate: '2027-12', sourceCutoff: '2026-07-31', sourceSet: 'author-manifesto-official-sources-v1', question: 'author-manifesto-decision-record-before-advice-v1', reviewInputs: { evidence: 'not-provided', temporal: 'not-provided', voice: 'not-provided' }, declarations: { quality: 'not-declared', pass: 'not-declared', publication: 'not-declared', correction: 'not-declared', outcome: 'not-declared' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'author-manifesto-unknown-source-v1': { id: 'author-manifesto-unknown-source-v1', editorialDate: '2026-07-31', planDate: '2027-12', sourceCutoff: '2026-07-31', sourceSet: 'unknown-source-set', question: 'author-manifesto-decision-record-before-advice-v1', reviewInputs: { evidence: 'not-provided', temporal: 'not-provided', voice: 'not-provided' }, declarations: { quality: 'not-declared', pass: 'not-declared', publication: 'not-declared', correction: 'not-declared', outcome: 'not-declared' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'author-manifesto-unknown-question-v1': { id: 'author-manifesto-unknown-question-v1', editorialDate: '2026-07-31', planDate: '2027-12', sourceCutoff: '2026-07-31', sourceSet: 'author-manifesto-official-sources-v1', question: 'unknown-question', reviewInputs: { evidence: 'not-provided', temporal: 'not-provided', voice: 'not-provided' }, declarations: { quality: 'not-declared', pass: 'not-declared', publication: 'not-declared', correction: 'not-declared', outcome: 'not-declared' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'author-manifesto-hidden-review-v1': { id: 'author-manifesto-hidden-review-v1', editorialDate: '2026-07-31', planDate: '2027-12', sourceCutoff: '2026-07-31', sourceSet: 'author-manifesto-official-sources-v1', question: 'author-manifesto-decision-record-before-advice-v1', reviewInputs: { evidence: 'claimed', temporal: 'not-provided', voice: 'not-provided' }, declarations: { quality: 'not-declared', pass: 'not-declared', publication: 'not-declared', correction: 'not-declared', outcome: 'not-declared' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'author-manifesto-declared-quality-v1': { id: 'author-manifesto-declared-quality-v1', editorialDate: '2026-07-31', planDate: '2027-12', sourceCutoff: '2026-07-31', sourceSet: 'author-manifesto-official-sources-v1', question: 'author-manifesto-decision-record-before-advice-v1', reviewInputs: { evidence: 'not-provided', temporal: 'not-provided', voice: 'not-provided' }, declarations: { quality: 'passed', pass: 'claimed', publication: 'published', correction: 'issued', outcome: 'successful' }, requestedOutput: 'synthetic-plan-hand-off' }, - 'author-manifesto-disallowed-output-v1': { id: 'author-manifesto-disallowed-output-v1', editorialDate: '2026-07-31', planDate: '2027-12', sourceCutoff: '2026-07-31', sourceSet: 'author-manifesto-official-sources-v1', question: 'author-manifesto-decision-record-before-advice-v1', reviewInputs: { evidence: 'not-provided', temporal: 'not-provided', voice: 'not-provided' }, declarations: { quality: 'not-declared', pass: 'not-declared', publication: 'not-declared', correction: 'not-declared', outcome: 'not-declared' }, requestedOutput: 'published-manifesto' }, + csp: { title: 'W3C Content Security Policy Level 3', url: 'https://www.w3.org/TR/CSP3/', version: 'W3C, CSP Level 3 Working Draft, опубликованная редакция спецификации' }, + hsts: { title: 'RFC 6797 — HTTP Strict Transport Security', url: 'https://www.rfc-editor.org/rfc/rfc6797.html', version: 'IETF, ноябрь 2012 года, RFC 6797, Standards Track' }, + lcp: { title: 'W3C Largest Contentful Paint', url: 'https://www.w3.org/TR/largest-contentful-paint/', version: 'W3C Web Performance Working Group, Working Draft, страница проверена 31 июля 2026 года' }, + performance: { title: 'W3C Performance Timeline', url: 'https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/', version: 'W3C Candidate Recommendation Draft, 21 мая 2025 года' }, + webVitals: { title: 'Web Vitals — web.dev', url: 'https://web.dev/articles/vitals', version: 'Google web.dev, опубликовано 4 мая 2020 года, обновлено 31 октября 2024 года' }, + nistIncident: { title: 'NIST SP 800-61 Revision 2 — Computer Security Incident Handling Guide', url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf', version: 'NIST, revision 2, май 2012 года, Special Publication 800-61' }, + rfc2119: { title: 'RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels', url: 'https://www.rfc-editor.org/rfc/rfc2119.html', version: 'IETF, март 1997 года, RFC 2119' }, }); -export function createAuthorManifestoLiteral(id = 'author-manifesto-plan-v1') { const literal = FIXED_MANIFESTO_LITERALS[id]; return literal ? deepFreeze(cloneFixed(literal)) : undefined; } -function stop(status, reason) { return deepFreeze({ status, reason, productionEffect: 'not-attempted', nextAction: 'open-a-separate-authorized-scope-only-when-a-decision-needs-evidence' }); } -export function assessAuthorManifestoPlan(input) { - const known = Object.values(FIXED_MANIFESTO_LITERALS).some((literal) => JSON.stringify(literal) === JSON.stringify(input)); - if (!known) return stop('stop-unknown-fixed-literal', 'input-is-not-a-known-named-fixed-literal'); - if (input.editorialDate !== '2026-07-31' || input.planDate !== '2027-12' || input.sourceCutoff !== '2026-07-31') return stop('stop-temporal-boundary-required', 'editorial-date-plan-date-and-source-cutoff-must-be-exact'); - if (input.sourceSet !== 'author-manifesto-official-sources-v1') return stop('stop-unknown-source-set', 'source-set-must-be-a-known-fixed-literal'); - if (input.question !== 'author-manifesto-decision-record-before-advice-v1') return stop('stop-unknown-question', 'question-must-be-a-known-fixed-literal'); - if (Object.values(input.reviewInputs).some((value) => value !== 'not-provided')) return stop('stop-hidden-review-input', 'evidence-temporal-and-voice-inputs-cannot-be-claimed-by-a-plan'); - if (Object.values(input.declarations).some((value) => value !== 'not-declared')) return stop('stop-declared-quality-pass-publication-correction-or-outcome', 'a-plan-cannot-declare-quality-pass-publication-correction-or-outcome'); - if (input.requestedOutput !== 'synthetic-plan-hand-off') return stop('stop-disallowed-positive-result', 'the-only-accepted-positive-output-is-synthetic-plan-hand-off'); - return deepFreeze({ status: 'synthetic-plan-hand-off', literalId: input.id, planDate: input.planDate, question: input.question, reviewInputs: deepFreeze(cloneFixed(input.reviewInputs)), declarations: deepFreeze(cloneFixed(input.declarations)), productionEffect: 'not-attempted', nextAction: 'preserve-the-question-without-claiming-a-future-editorial-result' }); +function sources(entries) { + return `
    ${entries.map(({ key, use, boundary }) => { + const ref = REFERENCES[key]; + return `
  • ${escapeHtml(ref.title)} — ${escapeHtml(ref.version)}. Применение: ${escapeHtml(use)} Граница: ${escapeHtml(boundary)}
  • `; + }).join('')}
`; } -export function inspectDecisionRecordLiteral() { const output = assessAuthorManifestoPlan(createAuthorManifestoLiteral()); return deepFreeze({ question: output.question, status: output.status, productionEffect: output.productionEffect }); } -export function inspectRubricLiteral() { const output = assessAuthorManifestoPlan(createAuthorManifestoLiteral()); return deepFreeze({ evidence: output.reviewInputs.evidence, temporal: output.reviewInputs.temporal, voice: output.reviewInputs.voice, status: output.status }); } -export function inspectHandoffLiteral() { const output = assessAuthorManifestoPlan(createAuthorManifestoLiteral()); return deepFreeze({ quality: output.declarations.quality, correction: output.declarations.correction, outcome: output.declarations.outcome, status: output.status }); } -export function runAuthorManifestoFixture() { - const expected = [['author-manifesto-plan-v1', 'synthetic-plan-hand-off'], ['author-manifesto-undated-v1', 'stop-temporal-boundary-required'], ['author-manifesto-unknown-source-v1', 'stop-unknown-source-set'], ['author-manifesto-unknown-question-v1', 'stop-unknown-question'], ['author-manifesto-hidden-review-v1', 'stop-hidden-review-input'], ['author-manifesto-declared-quality-v1', 'stop-declared-quality-pass-publication-correction-or-outcome'], ['author-manifesto-disallowed-output-v1', 'stop-disallowed-positive-result']]; - const checks = expected.map(([id, status]) => ({ id, expected: status, actual: assessAuthorManifestoPlan(createAuthorManifestoLiteral(id)).status })); - const unknown = assessAuthorManifestoPlan({ id: 'not-fixed' }); - return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length + Number(unknown.status === 'stop-unknown-fixed-literal'), total: checks.length + 1, accepted: checks.every((item) => item.expected === item.actual) && unknown.status === 'stop-unknown-fixed-literal', checks: deepFreeze(checks) }); + +export function buildSecurityHeaders({ nonce, hstsMaxAge = 31536000 } = {}) { + if (typeof nonce !== 'string' || nonce.length < 16 || !Number.isInteger(hstsMaxAge) || hstsMaxAge < 0) return { ok: false, reason: 'security-header-input-invalid' }; + return { + ok: true, + headers: { + 'Content-Security-Policy': `default-src 'self'; script-src 'self' 'nonce-${nonce}'; object-src 'none'; base-uri 'self'`, + 'Strict-Transport-Security': `max-age=${hstsMaxAge}; includeSubDomains`, + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + }, + }; +} + +export function classifyWebVitals({ lcpMs, inpMs, cls }) { + if (![lcpMs, inpMs, cls].every((value) => Number.isFinite(value) && value >= 0)) return { ok: false, reason: 'vital-input-invalid' }; + const lcp = lcpMs <= 2500 ? 'good' : lcpMs <= 4000 ? 'needs-improvement' : 'poor'; + const inp = inpMs <= 200 ? 'good' : inpMs <= 500 ? 'needs-improvement' : 'poor'; + const layout = cls <= 0.1 ? 'good' : cls <= 0.25 ? 'needs-improvement' : 'poor'; + const overall = [lcp, inp, layout].includes('poor') ? 'poor' : [lcp, inp, layout].includes('needs-improvement') ? 'needs-improvement' : 'good'; + return { ok: true, lcp, inp, layout, overall }; +} + +export function validateRunbookCard(card) { + const required = ['symptom', 'scope', 'precondition', 'action', 'rollback', 'verification']; + if (!card || typeof card !== 'object') return { ok: false, reason: 'runbook-must-be-object' }; + const missing = required.filter((key) => typeof card[key] !== 'string' || card[key].trim().length < 10); + if (missing.length > 0) return { ok: false, reason: 'runbook-fields-missing', missing }; + if (!/rollback|откат|вернуть/i.test(card.rollback)) return { ok: false, reason: 'rollback-must-be-explicit' }; + if (!/провер|verify|метрик|threshold/i.test(card.verification)) return { ok: false, reason: 'verification-must-be-observable' }; + return { ok: true, order: required }; } function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; - if (proseLength < 10000 || proseLength > 13000) throw new Error(`${meta.slug}: body length ${proseLength}`); + if (proseLength < 5000 || proseLength > 15000) throw new Error(`${meta.slug}: body length ${proseLength}`); return deepFreeze({ ...meta, contentHtml, proseLength }); } -const practiceRefs = [ - { key: 'rfc2119', use: 'Даёт датированный нормативный словарь для явного различения требования и рекомендации в карточке решения.', boundary: 'Не подтверждает существование статьи, решения, редактора, review или публикации P118.' }, - { key: 'provdm', use: 'Даёт терминологию сущности, действия и происхождения для объяснения, почему совету нужен проверяемый след.', boundary: 'Не доказывает происхождение локального архива, исправления, отзыв читателя или эффект.' }, -]; -const mechanismRefs = [ - { key: 'rfc8174', use: 'Уточняет чтение нормативных слов и помогает отделить проверяемую границу от риторического усиления.', boundary: 'Не задаёт редакционный процесс, уровень качества, результат прохода или факт выпуска.' }, - { key: 'provdm', use: 'Поддерживает различение утверждения и его происхождения при описании уровней evidence.', boundary: 'Не является доказательством реальных входов review, оценок, коррекций или результатов P118.' }, -]; -const fieldRefs = [ - { key: 'rfc2119', use: 'Фиксирует точность модальных форм, когда hand-off запрещает подменять неизвестное обязательством.', boundary: 'Не назначает владельца, получателя, публикацию, исправление или author development.' }, - { key: 'rfc8174', use: 'Помогает показать, что сильная формулировка должна иметь определённую область действия.', boundary: 'Не подтверждает прохождение review, feedback, корректирующей работы или production effect.' }, +const securityRefs = [ + { key: 'csp', use: 'Описывает Content-Security-Policy, директивы источников, nonce и режим Report-Only/Enforce.', boundary: 'Working Draft может изменяться; конкретную поддержку браузеров и собственные inline-скрипты нужно проверить отдельно.' }, + { key: 'hsts', use: 'Определяет Strict-Transport-Security и поведение браузера после получения политики по HTTPS.', boundary: 'Не исправляет mixed content, сертификат, redirect до первого безопасного ответа и настройки API-клиента.' }, ]; -const practice = revision({ slug: 'editorial-2027-12-practice-author-manifesto', title: 'Инженерное письмо: сначала карточка решения, потом технический совет', categories: ['Практика', 'Техническое письмо'], cover: '/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg', excerpt: 'План на декабрь 2027: как не превратить полезный совет в правдоподобную, но не проверяемую историю.', readingMinutes: 28 }, [ - p('P118 — сценарий на декабрь 2027: редакционная дата 2026-07-31, а граница источников зафиксирована тем же срезом. Точная проблема инженерного письма возникает до первого абзаца: автор хочет дать совет, но не записывает, какое решение этот совет должен поддержать. Тогда текст смешивает условие, предположение и действие. Цена ошибки практическая: следующий инженер принимает гладкую формулировку за готовый маршрут и применяет её там, где граница системы, версия или риск совсем другие.'), - p('Вторая стоимость появляется, когда карточку решения заменяют «опытом автора». В будущем выпуске легко написать, что совет уже помог, что его проверяли или что он вырос из набора прошлых материалов. Для P118 это запрещённый narrative: нет будущей статьи, публикации, редактора, разбора, обратной связи, коррекции, команды, метрики и production effect. Единственный положительный результат evaluator — synthetic-plan-hand-off; вместе с ним всегда остаётся productionEffect: not-attempted.'), - h2('Совет начинается с решения, которого ещё нет'), - p('Под «карточкой решения» здесь понимается не ADR и не заявка на изменение. Это короткая форма, которая заставляет назвать вопрос, допустимый источник, границу вывода и следующий шаг. В P118 она существует только как named fixed in-memory literal author-manifesto-decision-record-before-advice-v1. Его нельзя принять за файл, запись, шаблон команды или будущий процесс. Literal нужен, чтобы проверить форму рассуждения, а не сообщить о том, что решение когда-либо было принято.'), - p('Технический совет без вопроса опасен не потому, что он обязательно неверен. Он опасен тем, что не говорит, что именно опровергнет его применимость. Фраза «добавьте проверку» не указывает вход, требуемое свойство, владельца исключения и цену отказа. В карточке вместо этого сначала появляется один вопрос: какое решение нельзя принять, пока неизвестны границы claim. До ответа на него команда не выбирает библиотеку, не пишет правило и не обещает выпуск.'), - p('Такой порядок особенно полезен в долгих текстах. Чем убедительнее объяснение, тем проще читателю забыть, откуда взялась исходная посылка. Короткое поле source boundary возвращает разговор к проверяемому: нормативный документ может объяснить значение слова, но не может доказать факт в чужом проекте. Прошлая заметка могла бы быть историческим материалом, но в этом плановом scope она не является evidence. Поэтому P118 не строит цепочку «архив → правило → успех».'), - table('Карточка перед техническим советом', ['Поле', 'Значение в P118', 'Что разрешает', 'Чего не разрешает'], [ - ['Вопрос', 'author-manifesto-decision-record-before-advice-v1', 'Сохранить предмет будущего решения', 'Принять решение или назвать его владельца'], - ['Источник', 'author-manifesto-official-sources-v1', 'Проверить терминологическую границу', 'Считать официальный текст свидетельством локального события'], - ['Вход review', 'not-provided', 'Признать отсутствие входа', 'Сказать, что текст был прочитан или оценён'], - ['Декларация', 'not-declared', 'Не смешивать форму и результат', 'Объявить качество, pass, публикацию или коррекцию'], - ['Выход', 'synthetic-plan-hand-off', 'Передать вопрос дальше', 'Обещать production effect'], +const practice = revision({ + slug: 'editorial-2027-12-practice-author-manifesto', + title: 'Security headers: CSP и HSTS без иллюзии защиты', + categories: ['Security', 'Web'], + cover: '/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg', + excerpt: 'Практический разбор Content-Security-Policy и HSTS: что именно ограничивают заголовки, как вводить nonce и где нужна отдельная проверка.', + readingMinutes: 15, +}, [ + p('Проблема проявляется после XSS или downgrade-атаки: приложение отдаёт страницу по HTTPS, но разрешает любой inline script или продолжает открываться по HTTP. Цена ошибки — выполнение чужого кода в контексте origin, утечка токена и ложное ощущение, что один security header закрыл весь риск.'), + p('Причина — копировать длинную строку заголовка без модели ресурсов. CSP ограничивает, откуда браузер может загружать или выполнять ресурсы; HSTS заставляет браузер обращаться к домену по HTTPS после получения политики. Ни один из заголовков не исправляет серверный XSS, плохой сертификат или секрет, уже попавший в JavaScript.'), + h2('CSP начинается с карты ресурсов'), + p('Сначала перечислите, что странице действительно нужно: собственные scripts, стили, изображения, API и frame. Затем для каждого типа выберите минимальную директиву. default-src задаёт fallback, но не объясняет исключения; script-src управляет JavaScript, object-src none закрывает старый plugin-механизм, а base-uri self не даёт странице незаметно изменить базовый URL.'), + p('Nonce применяют к конкретному inline script, когда убрать inline-код сразу нельзя. Значение должно быть непредсказуемым и новым для ответа; статическая строка превращается в разрешение для любого, кто её узнал. Шаблон должен вставить nonce и в CSP, и в атрибут script, а логирование полного значения создаёт лишний риск.'), + table('Директива и её граница', ['Директива', 'Что ограничивает', 'Частая ошибка', 'Проверка'], [ + ['default-src', 'fallback для типов ресурсов', 'считать её полной политикой', 'проверить исключения по типам'], + ['script-src', 'источники JavaScript и nonce', 'добавить unsafe-inline навсегда', 'найти все inline и third-party scripts'], + ['object-src', 'plugin/object загрузку', 'оставить широкое значение', 'поставить none, если object не нужен'], + ['base-uri', 'изменение базового URL', 'забыть директиву', 'ограничить self или отключить'], + ['report-only', 'наблюдение нарушений', 'принять отчёт за блокировку', 'после анализа перейти к enforce'], ]), - h2('Граница между рецептом и утверждением'), - p('Рецепт отвечает на вопрос «что попробовать при известном симптоме». Утверждение отвечает на другой вопрос: «почему это действие относится к данной причине». В инженерной статье оба слоя нужны, но их нельзя маскировать один другим. Если указан только рецепт, reader не понимает, когда остановиться. Если указан только вывод, reader не может повторить проверку. Карточка решения заставляет держать слой действия рядом с его ограничением.'), - p('Например, совет о логировании не должен начинаться с названия поля. Сначала автор определяет решение: достаточно ли данных, чтобы различить два состояния. Затем называет допустимые входы, чувствительные поля и условие, при котором лог ничего не доказывает. Только после этого возможен пример формата события. P118 не содержит такой реальный event и не предлагает его собрать. Он показывает последовательность вопросов, которую будущий scope обязан сделать явной.'), - p('У этой дисциплины есть стоимость: начало статьи становится медленнее, а заголовок не обещает универсальную кнопку. Зато читатель видит, где заканчивается утверждение. Это снижает blast radius советов, которые кажутся мелкими: параметр timeout, признак готовности, правило миграции или формат review могут менять поведение целого контура. Писать «лучше всегда» дешевле, чем назвать границу, но именно эта экономия создаёт дорогую диагностику позже.'), - figure('/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg', 'Граф решения: вопрос, источник и граница вывода сходятся в синтетический hand-off; неизвестный источник или заявленный результат ведут к остановке.', 'Схема показывает форму планового решения для P118. Она не сообщает о реальной статье, архиве, редакционном действии, публикации или исправлении.'), - h2('Runnable пример оставляет совет неполным'), - p('Пример ниже безопасно запускается в Node рядом с модулем. Он не читает файлы, сеть, environment, clock, secrets, telemetry, system или data. Он берёт только зашитый literal и возвращает статус его формы. Поэтому вывод не является оценкой текста и не указывает, какой совет нужно написать. Не добавляйте сюда repository path, issue, автора или реалистичный контекст: такие детали выглядели бы как скрытые входы, которых у P118 нет.'), - code("import { inspectDecisionRecordLiteral } from './upgrade-2027-12.mjs';\n\nconst card = inspectDecisionRecordLiteral();\nconsole.log(card.question, card.status, card.productionEffect);\n// author-manifesto-decision-record-before-advice-v1 synthetic-plan-hand-off not-attempted"), - h2('Порядок работы до появления текста'), - ol(['Назвать одно решение, которое без дополнительного evidence нельзя принять, и оставить его вопросом, а не выводом.', 'Зафиксировать editorial date, planDate и source cutoff; будущий период не даёт права описывать будущие события.', 'Выбрать только проверяемые источники и рядом написать, что каждый из них не может подтвердить.', 'Отделить рецептурный шаг от factual claim: для каждого указать условия применимости и условие остановки.', 'Если требуется review, publication, correction или feedback, перенести их в другой authorised scope с собственными входами.', 'Передать исходный вопрос как synthetic-plan-hand-off, не заменяя его декларацией качества.']), - h2('Почему декларация не заменяет решение'), - p('Слово «манифест» часто провоцирует обратный порядок: сначала объявить принцип, затем подобрать примеры. Для технического письма это слабая конструкция. Принцип без decision record не показывает, кто должен действовать, какой вход допустим и чем можно опровергнуть правило. P118 использует название выпуска как тему проверки: не лозунг о письме, а разбор того, какую форму должен иметь совет до появления уверенного тона.'), - p('Даже аккуратная формула «пишите доказуемо» недостаточна. Неясно, о доказательстве какого типа идёт речь: ссылка на норму, воспроизводимый запуск, наблюдение в среде или вывод из нескольких артефактов. Эти типы нельзя свести к одному слову evidence. Карточка решения сначала фиксирует вид вопроса, а следующий scope выбирает метод. Так future reader не получит ложного разрешения заменить отсутствующий trace ссылкой на стандарт.'), - p('Уместный технический язык здесь прямой. Вместо «повысим качество текста» лучше спросить: какое утверждение нельзя проверить без источника, даты или допустимого ввода? Вместо «учтём контекст» — какой контекст меняет выбор действия? Вместо «сформируем культуру» — кому принадлежит решение открыть новый scope? Такие замены не делают прозу холоднее; они возвращают ей объект, действие и проверку.'), - p('План не обязан заранее решить, будет ли следующий scope создан. Может оказаться, что вопрос снимается без исследования, потому что изменение не требуется. Может оказаться, что нужный вход нельзя законно или безопасно собрать. Может оказаться, что совет нужно сузить до условия версии. Любой из этих результатов совместим с хорошей карточкой. Несовместимо только одно: описать один из вариантов как уже случившийся в будущем декабре 2027.'), - h2('Как карточка удерживает цену решения'), - p('Цена решения не сводится к числу часов. У совета есть цена неверного применения, цена объяснения исключения, цена обратимости и цена поддержки следующего читателя. Если автор не называет хотя бы одну из них, рекомендация обычно выглядит дешевле, чем есть. Карточка не обязана рассчитывать стоимость. Ей достаточно обозначить, какое последствие меняет выбор: риск потерять данные, расширить доступ, скрыть деградацию или закрепить неподдерживаемую зависимость.'), - p('Такой подход меняет работу с примером. Код в статье не служит знаком компетентности и не должен быть самым убедительным элементом страницы. Он отвечает на узкий вопрос: как выглядит действие после того, как условия названы. Рядом полезно сказать, чего пример не содержит: production configuration, реальный request, секрет, профиль нагрузки и подтверждённый результат. Тогда читатель не переносит литературную компактность кода на свою систему без проверки.'), - p('Решение также нуждается в обратимости. Если совет можно отменить только ценой миграции, потери совместимости или ручного восстановления, это не повод отказаться от него, но повод записать границу заранее. В P118 нет конкретного изменения, поэтому нет и обещания rollback. Будущий scope должен отдельно определить, что будет означать отказ от выбранного пути. Нельзя подменять эту работу фразой «при необходимости откатим»: она скрывает owner, данные и порядок восстановления.'), - p('Полезная карточка различает адресата текста и владельца решения. Статья может быть написана для инженера, но не назначает ему право менять систему. Совпадение этих ролей нельзя предполагать по должности или тону. В данном сценарии оба поля сознательно отсутствуют: P118 не знает будущей команды. Благодаря этому advice остаётся техническим артефактом, а не неявным приказом тому, кто первым его прочитает.'), - p('Наконец, advice должен переживать неизвестность. Если новая версия, другой источник или более узкий риск меняют вывод, текст не считается провалом. Провалом будет скрыть условие и заставить reader обнаружить его в production. Поэтому практическая форма манифеста — не набор правил, а привычка оставлять у утверждения место для опровержения. Она делает следующий разговор короче, потому что участники спорят о названном условии, а не о стиле автора.'), - h2('Ограничения и следующий шаг'), - p('P118 practice не является editorial policy, ADR, историей публикаций, каталогом статей, review record, evidence package или рекомендацией для конкретной команды. Official sources здесь задают язык границ, но не являются доказательством местного решения. Fixed literals не принимают пользовательский input и не моделируют проект. Fixture проверяет закрытый набор значений; он не проверяет ясность будущей статьи и не измеряет её эффект.'), - p('Следующий шаг возможен только в отдельном authorised scope, если конкретный технический вопрос потребует решения. В нём надо назвать допустимые inputs, владельца решения, метод проверки, правила redaction и возможность отрицательного вывода. До этого P118 сохраняет честно ограниченную форму: вопрос остаётся вопросом, а результат — только synthetic-plan-hand-off с productionEffect: not-attempted.') -], practiceRefs); + h2('HSTS имеет момент включения'), + p('HSTS действует после того, как браузер получил заголовок через доверенный HTTPS-ответ. Он не защищает самый первый HTTP-переход, если домен ещё не известен браузеру; для этого существует отдельная политика preload с собственными требованиями и риском. includeSubDomains распространяет правило на поддомены, поэтому включать его можно только после проверки всех нужных имён.'), + p('Большой max-age нельзя трактовать как кнопку «попробовать». Если поддомен ещё не умеет HTTPS, браузер перестанет подключаться к нему по HTTP на весь период. Перед расширением политики проверьте redirect, сертификаты, mixed content и административные endpoint. Безопасность заголовка включает и возможность восстановить ошибочную конфигурацию.'), + figure('/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg', 'Граф security headers: карта ресурсов формирует CSP, HTTPS-ответ включает HSTS, а неизвестный script или неподготовленный поддомен останавливает расширение политики.', 'Схема показывает два независимых слоя. CSP управляет ресурсами страницы, HSTS — схемой соединения; один заголовок не заменяет другой.'), + h2('Runnable-пример: собрать минимальные headers'), + p('Функция принимает nonce длиной не менее 16 символов и max-age HSTS. Она возвращает набор заголовков или явную ошибку входа. Это учебный генератор: он не устанавливает response headers и не проверяет ваш шаблонизатор. Ожидаемый результат показывает, что CSP содержит nonce, а HSTS — числовой срок и includeSubDomains.'), + code(`import { buildSecurityHeaders } from './upgrade-2027-12.mjs'; -const mechanism = revision({ slug: 'editorial-2027-12-mechanism-author-manifesto', title: 'Инженерное письмо: рубрика качества вместо заявленного качества', categories: ['Процесс', 'Техническое письмо'], cover: '/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg', excerpt: 'План на декабрь 2027: как проверять claims и границы тремя проходами, не объявляя pass и не выдумывая evidence.', readingMinutes: 29 }, [ - p('P118 — плановый сценарий на 2027-12, составленный 2026-07-31. Механическая проблема качества текста точна: автор ставит ярлык «проверено», хотя не разделил claim, вход для проверки и условие опровержения. Цена ошибки — спор о впечатлении вместо работы с границей. В результате ссылка, пример и уверенная интонация склеиваются в один сигнал, а reader не может понять, какая часть текста относится к факту, а какая к предложенному действию.'), - p('Следующая цена — превратить три прохода review в рассказ о трёх прошедших review. У P118 нет будущих reviewer, их входов, оценок, замечаний, pass, публикации, corrections, feedback или outcome. Неизвестные данные не подставляются из local standards, trajectory или существующих неопубликованных artefacts: это vocabulary будущего контракта, а не доказательство будущего события. Единственный accepted positive output — synthetic-plan-hand-off с productionEffect: not-attempted.'), - h2('Рубрика измеряет полноту вопроса, не качество автора'), - p('Рубрика полезна, когда она не выдаёт балл за стиль. В этой статье она раскладывает фразу на четыре поля: claim, evidence level, falsifier и boundary. Claim — то, что текст предлагает считать верным. Evidence level — происхождение, которое могло бы поддержать claim. Falsifier — наблюдение или условие, после которого claim нельзя оставлять прежним. Boundary — то, чего claim не охватывает. Без этой четвёрки review быстро становится спором о том, «достаточно ли убедительно» написано.'), - p('P118 не заполняет эти поля реальным материалом. В named literal review inputs имеют значение not-provided. Это не отрицательная оценка и не скрытый result: input не был передан модулю. Такое различие важно для evidence levels. «Нет данных» не равно «данные слабые», а «источник известен» не равно «источник поддерживает именно этот вывод». Любая будущая рубрика должна сохранять это различие, иначе уровень доказательности станет декоративной шкалой.'), - p('Falsifiability в инженерном письме не требует научного эксперимента для каждого предложения. Она требует назвать, что изменило бы решение. Если статья рекомендует ограничить retry, опровергнуть применимость может семантика операции. Если предлагает кэш, опровергнуть её может отсутствие допустимой инвалидации. Если описывает правило review, опровергнуть его может неизвестный тип входа. В P118 эти примеры не говорят о существующей системе; они различают форму условия и утверждение о факте.'), - table('Матрица уровней evidence и фальсифицируемости', ['Уровень', 'Допустимая форма', 'Чего недостаточно', 'Вопрос falsifier'], [ - ['E0 — отсутствует', 'not-provided', 'Уверенная формулировка или правдоподобный пример', 'Какой вход вообще разрешён?'], - ['E1 — нормативный', 'Датированный стандарт или спецификация', 'Вывод о локальном поведении', 'Какая реализация или версия меняет чтение нормы?'], - ['E2 — воспроизводимый', 'Описанный метод и сохранённый результат в отдельном scope', 'Один пересказ запуска', 'Какая одинаковая проверка даст другой результат?'], - ['E3 — контекстный', 'Связанные inputs, метод и ограниченный inference', 'Метрика без условий измерения', 'Какая граница данных ломает перенос вывода?'], - ['P118', 'Только форма будущей рубрики', 'Quality/pass/publication/correction/outcome', 'Почему вообще нельзя объявить результат?'], - ]), - h2('Три прохода идут по разным рискам'), - p('Первый проход — evidence. Он не спрашивает, нравится ли источник. Он сопоставляет каждое существенное утверждение с происхождением: норма, версия, метод, наблюдение или неизвестность. Если claim живёт за пределами источника, рядом должна быть boundary. Для планового выпуска этот проход закономерно получает not-provided. Он не может превратить ссылку на RFC или W3C Recommendation в свидетельство будущего редакционного действия.'), - p('Второй проход — temporal. Он проверяет, не перескочил ли текст через дату. Здесь dates — не метаданные после публикации, а часть значения. Editorial date 2026-07-31, planDate 2027-12 и source cutoff 2026-07-31 закреплены exact literals. Любая другая дата останавливает evaluator. Тем самым формулировка «будет исправлено» остаётся недопустимой: она незаметно сообщает о том, чего в плановом scope не происходило.'), - p('Третий проход — voice. Его задача не угадать личность автора по оборотам. Он проверяет техническую плотность: первые абзацы называют проблему и цену, пример ограничен входом, таблица различает варианты, а финал оставляет следующее действие и ограничение. Local editorial standards и voice trajectory могут быть названы только как vocabulary будущего контракта. Они не доказывают, что голос уже проверялся, что editor видел статью или что какая-либо траектория продолжилась в 2027.'), - p('Три прохода нельзя склеивать в общий stamp. Хорошая ссылка не исправляет future tense. Спокойный голос не заменяет falsifier. Удобная таблица не делает недоступный input известным. Когда критерии смешаны, найденная слабость растворяется в общем «выглядит неплохо». Когда они разделены, каждый stop имеет понятную причину и не требует выдумывать среднее качество текста.'), - figure('/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg', 'Матрица рубрики: evidence, temporal и voice проходят отдельно; неизвестный вход и заявленный pass останавливают синтетический evaluator.', 'Рисунок объясняет три независимые проверки формы P118. Он не фиксирует реальный review, качество статьи, редакторский verdict или выпуск.'), - h2('Runnable пример показывает отсутствие review'), - p('Этот вызов безопасен: функции работают с immutable literals, не принимают ввод, не открывают filesystem и не обращаются к сети. Console output не означает, что evidence, temporal или voice действительно были проверены. Он лишь показывает, что valid plan хранит их как not-provided. Это точнее, чем подставить «pass pending»: pending уже создаёт историю процесса, которой здесь нет.'), - code("import { inspectRubricLiteral } from './upgrade-2027-12.mjs';\n\nconst rubric = inspectRubricLiteral();\nconsole.log(rubric.evidence, rubric.temporal, rubric.voice, rubric.status);\n// not-provided not-provided not-provided synthetic-plan-hand-off"), - h2('Порядок трёх проходов'), - ol(['Выписать отдельные claims и отметить, что является нормативным словом, предположением или действием.', 'Для каждого claim выбрать только известный evidence level; если вход отсутствует, оставить not-provided.', 'Сформулировать falsifier как условие смены решения, а не как обещание будущего теста.', 'Проверить exact temporal boundary: редакторская дата, planDate и cutoff не могут быть заполнены по умолчанию.', 'Прочитать текст в голосовом проходе: убрать лозунг, вернуть предмет, цену, ограничение и следующий шаг.', 'Остановить пакет, если появляется hidden review input либо declared quality, pass, publication, correction или outcome.']), - h2('Почему уровни не являются рейтингом'), - p('E3 не «лучше» E1 во всех задачах. Нормативный источник может быть достаточен, когда вопрос касается значения обязательного слова. Воспроизводимый метод нужен, когда речь о поведении конкретной версии. Контекстный inference требует ещё больше условий, но не даёт права сделать его универсальным. Рубрика работает, когда показывает соответствие формы вопроса и источника, а не когда стимулирует собирать наиболее эффектные доказательства.'), - p('Это также защищает от ложной точности. Число баллов, процент готовности или зелёный badge кажутся удобными, потому что упрощают передачу. Но при отсутствии входов они превращают unknown в красивый verdict. P118 намеренно не содержит шкалу pass/fail для будущей статьи. Его evaluator принимает один fixed literal и возвращает один hand-off; он не умеет повышать quality, выдавать publication status или считать correction завершённой.'), - p('Механизм fail closed важнее позитивной ветки. Unknown object не нормализуется до plan. Unknown source set не превращается в «источник позже уточним». Unknown question не получает стандартный текст. Claimed review не становится warning. Любое из этих состояний получает свой stop. Такой дизайн неудобен для демонстрации гладкого процесса, зато не даёт случайно использовать модуль как генератор правдоподобного editorial history.'), - p('Техническому лидеру полезно показывать эту неудобную часть. В реальной работе самый дорогой спор часто начинается не с ошибки в коде, а с невидимого перехода от наблюдения к выводу. Рубрика делает переходы явными: источник описывает норму, метод описывает воспроизводимость, reviewer input описывает review, а output описывает только форму hand-off. Если одного звена нет, цепочка не должна притворяться полной.'), - h2('Как читать claim без лишней уверенности'), - p('Claim полезно писать в проверяемом времени и области. «Этот механизм сокращает ошибки» слишком широк: неизвестны механизм, ошибка, период и способ сравнения. «Для этого входа обработчик отвергает неизвестный literal» уже связывает действие с наблюдаемым контрактом. Второе предложение не делает систему лучше само по себе, но даёт reviewer предмет для проверки. Если поле меняется, claim можно сузить или снять без спора о намерениях автора.'), - p('Evidence level не следует прятать в сноске. Когда ключевой вывод поддержан только нормативным текстом, это надо назвать возле вывода. Норма сообщает, как читать требование; она не сообщает, что конкретная реализация следует ему. Когда вывод получен запуском, читателю нужны версия, вход, метод и ограничение результата. Когда этих полей нет, аккуратный текст говорит об отсутствии основания, а не повышает убедительность прилагательными.'), - p('Falsifier должен быть доступен тому, кто принимает решение. Формула «проверим все случаи» не годится: она не определяет случаи и может оказаться невыполнимой. Гораздо полезнее назвать один класс входов, который меняет выбор, и признать остальные неизвестными. Это не упрощение ради отчёта. Такая граница определяет, сколько работы действительно нужно до решения и когда следует открыть отдельное исследование вместо бесконечной редактуры.'), - p('Temporal проход ловит не только будущие даты. Он также ловит ретроспективную причинность: «после этой статьи команда стала делать X» требует событий и связи между ними. В плановом документе даже нейтральное «позже исправим» может выглядеть как согласованный backlog. P118 избегает такого намёка. Он описывает лишь то, что другой scope мог бы решить после получения разрешённых inputs, и оставляет возможность не предпринимать ничего.'), - p('Voice проход имеет техническую цену. Абстрактная фраза заставляет reader достраивать объект; чрезмерный жаргон переносит стоимость расшифровки на менее опытного коллегу; декларация качества снимает вопрос слишком рано. Поэтому хороший поздний голос не становится авторитетнее за счёт громкости. Он становится полезнее, когда прямо называет вход, риск, решение, ограничение и следующую проверку. Рубрика удерживает эту последовательность даже там, где нет права утверждать результат.'), - h2('Ограничения и следующий шаг'), - p('P118 mechanism не является quality policy, rubric rollout, редакторским протоколом, published checklist, evidence audit или доказательством качества будущего текста. W3C и IETF sources применены к терминологии и модальным границам, не к локальной истории. The fixture не проверяет логику реального review и не делает conclusion об авторе, редакторе, архиве или продукте.'), - p('Следующий шаг требует отдельного authorised scope с разрешёнными источниками, датой, owner, способом хранения evidence и правом на отрицательный verdict. Там можно будет определить, какие levels подходят конкретному technical claim. Нельзя перенести в него объявленный результат P118: текущий сценарий заканчивается только synthetic-plan-hand-off, а productionEffect: not-attempted сохраняется на каждой ветке.') -], mechanismRefs); +const result = buildSecurityHeaders({ + nonce: '7c2f1b8e9a4d6f0c', + hstsMaxAge: 31536000, +}); -const field = revision({ slug: 'editorial-2027-12-field-author-manifesto', title: 'Инженерное письмо: корректирующий hand-off без вымышленной редакционной истории', categories: ['Практика', 'Техническое письмо'], cover: '/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg', excerpt: 'План на декабрь 2027: как передать вопрос о правке и развитии автора, не подделывая feedback, публикацию или эффект.', readingMinutes: 28 }, [ - p('P118 — план/scenario для 2027-12 с editorial date 2026-07-31 и source cutoff 2026-07-31. Полевая проблема корректирующей передачи начинается, когда незавершённый вопрос оформляют как историю: «получили замечание, поправили, текст стал сильнее». Цена ошибки высока: следующему человеку достаётся не вопрос, а обязательство согласиться с несуществующим feedback и продолжать якобы начатую работу. Так редакционный язык создаёт ложное происхождение решения.'), - p('Вторая стоимость — считать развитие автора доказанным по самому факту планирования. У P118 нет автора как объекта оценки, reviewer, комментария, текста будущей статьи, публикации, correction, реакции читателя, команды, метрики или production effect. Также нельзя брать local standards, trajectory, unpublished artifacts или очереди как factual evidence. Они допустимы лишь как имена будущих contract vocabulary. Accepted positive output один: synthetic-plan-hand-off; production effect во всех исходах — not-attempted.'), - h2('Коррекция начинается с неизвестного, а не с замечания'), - p('Corrective hand-off нужен не для того, чтобы послать человеку список улучшений. В строгом scope он сохраняет вопрос: какой claim потребует пересмотра, если authorised evidence появится позже. Пока evidence нет, нельзя говорить, что claim ошибочен, что редактура обнаружила проблему или что автор изменил подход. В fixed literal это отражено пятью отдельными полями: quality, pass, publication, correction и outcome имеют значение not-declared.'), - p('Отрицательное значение имеет происхождение. not-declared не означает «декларация ждёт подтверждения» и не означает «кто-то не заполнил форму». Оно означает, что P118 не создавал такого утверждения. Аналогично not-provided для review inputs не означает потерянный комментарий. Вход не передавался. Такая точность убирает привычное давление: будто следующий scope обязан найти подтверждение, чтобы завершить заранее написанную историю.'), - p('Author development требует ещё большей осторожности. Полезно обсуждать практику письма, но небезопасно выводить из текста личную динамику. Формулировки «научился», «перестал ошибаться», «стал самостоятельнее» требуют наблюдаемого, разрешённого и контекстного evidence. В P118 ничего такого нет. Field lens поэтому не моделирует человека. Он моделирует границу передачи: неизвестность можно передать честно, а оценку — только после отдельного решения о допустимых inputs.'), - table('Корректирующая передача без ложного происхождения', ['Поле', 'Literal P118', 'Безопасное чтение', 'Запрещённая история'], [ - ['Review input', 'not-provided', 'P118 не получил вход', 'Редактор увидел и одобрил текст'], - ['Quality', 'not-declared', 'Нет verdict', 'Статья качественная или слабая'], - ['Publication', 'not-declared', 'Выпуск не заявлен', 'Материал опубликован в декабре 2027'], - ['Correction', 'not-declared', 'Правка не заявлена', 'Ошибка найдена и исправлена'], - ['Outcome', 'not-declared', 'Эффект не заявлен', 'Автор или production изменились'], +console.log(result.ok); +console.log(result.headers['Content-Security-Policy']); +console.log(result.headers['Strict-Transport-Security']); +// true +// default-src 'self'; script-src 'self' 'nonce-7c2f1b8e9a4d6f0c'; object-src 'none'; base-uri 'self' +// max-age=31536000; includeSubDomains`), + h2('Порядок внедрения'), + ol([ + 'Соберите список ресурсов страницы и найдите inline scripts, eval, object, iframe, внешние CDN и API. Не начинайте с копирования чужой политики.', + 'Включите CSP в Report-Only и соберите нарушения по URL, директиве и типу ресурса. Отчёт не блокирует выполнение, поэтому не называйте его исправлением.', + 'Уберите лишние источники, замените inline-код на файл или nonce и добавьте тест на отсутствие unsafe-inline и unsafe-eval без обоснования.', + 'Переведите CSP в enforce на одной проверяемой странице и сравните ошибки загрузки с разрешённым списком.', + 'Включите HSTS только после проверки HTTPS для основного домена и поддоменов. Начните с контролируемого max-age, затем расширяйте.', + 'Проверьте rollback конфигурации: изменение header должно быть версионируемым, а не ручной правкой в одном proxy.', ]), - h2('Петля hand-off не должна замыкаться сама'), - p('В корректирующем процессе обычно рисуют цикл: claim, feedback, revision, release. Для P118 такая схема опасна, если стрелки читаются как произошедшие события. Здесь петля обрывается до первого факта. Она принимает только named question и fixed temporal boundary, затем проверяет отсутствие hidden review inputs и declarations. При любом claimed quality, pass, publication, correction или outcome evaluator выдаёт stop. Он не предлагает исправление и не выбирает адресата.'), - p('Это не отказ от ответственности. Наоборот, hand-off сохраняет ответственность на правильной границе. Будущий owner, если он вообще появится в новом scope, должен решить, нужен ли review, какие данные допустимы, кто увидит материалы и что будет считаться корректирующим действием. P118 не называет owner, потому что назначение роли тоже является действием с последствиями. Предсказать его «для полноты» означало бы создать несуществующую обязанность.'), - p('В поле особенно легко подменить unknown правдоподобной деталью: вставить короткую цитату reviewer, номер revision, дату публикации или пример комментария. Даже synthetic-looking detail будет работать как evidence, если reader не видит её происхождения. Поэтому visual, таблица и code P118 описывают только статусы формы. Они не содержат заголовка будущей статьи, имени человека, оценки формулировки или примера исправленной фразы.'), - figure('/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg', 'Петля корректирующей передачи: временная граница и отсутствие review ведут к hand-off; declared quality, publication, correction или outcome останавливают маршрут.', 'Диаграмма показывает fail-closed форму P118. Она не подтверждает review, feedback, правку, выпуск, развитие автора или производственный эффект.'), - h2('Runnable пример не создаёт correction'), - p('Запуск ниже не открывает процесс и не меняет запись. Код делает только одно: создаёт копию named in-memory literal, проверяет exact values и возвращает поля declarations. Нет обращения к filesystem, network, environment, clock, secrets, telemetry, system или data. Вывод not-declared не является рекомендацией ничего не исправлять; он показывает, что у этого scope нет права утверждать факт исправления.'), - code("import { inspectHandoffLiteral } from './upgrade-2027-12.mjs';\n\nconst handoff = inspectHandoffLiteral();\nconsole.log(handoff.quality, handoff.correction, handoff.outcome, handoff.status);\n// not-declared not-declared not-declared synthetic-plan-hand-off"), - h2('Порядок корректирующей передачи'), - ol(['Оставить claim вопросом до появления разрешённого evidence; не называть его ошибкой заранее.', 'Зафиксировать даты и cutoff вместе с вопросом, чтобы future tense не стал fabricated history.', 'Проверить, что reviewer input, feedback и publication не спрятаны в метаданных, примере или подписи к визуалу.', 'Сохранить quality, pass, publication, correction и outcome как not-declared; не заменять неизвестное оптимистичным pending.', 'Передать только boundary и условия отдельного scope, не назначая автора, reviewer, owner или получателя.', 'Остановить evaluator на unknown source, unknown date, unknown question, hidden input или declared result.']), - h2('Почему развитие автора не является итогом статьи'), - p('Техническое письмо влияет на автора только через цепочку конкретных действий и наблюдений, а не через название раздела. Нужно различать: статья может описывать правило; reviewer может дать комментарий; автор может принять или отклонить его; затем возможна новая версия и отдельная проверка. Каждый переход требует своего evidence и права работать с ним. Склеить цепочку в «развитие» удобно для резюме, но бесполезно для инженерного решения.'), - p('Будущий scope может вообще отказаться от персонального измерения. Возможно, достаточно поправить технический claim, а вопрос об авторе не относится к задаче. Возможно, feedback содержит чувствительные данные и не должен храниться в редакционном артефакте. Возможно, корректировка окажется не нужна, потому что новый источник изменит interpretation. P118 не выбирает между этими вариантами. Его полезность в том, что варианты остаются открытыми и не получают фальшивый prior.'), - p('Такой hand-off также не разрешает задним числом использовать local trajectory как доказательство. Траектория голоса — будущая contract vocabulary: она может подсказать, какие проверки поставить, но не сообщает, что человек уже прошёл эти проверки. Editorial standard — vocabulary критериев, но не timestamp review. Unpublished artefact — не источник подтверждённого эффекта. Каждый из этих объектов может стать входом только после того, как другой scope задаст происхождение, доступ и допустимый вывод.'), - p('Видимый stop — часть заботы о читателе. Если evaluator возвращает stop-hidden-review-input, это не ошибка будущего reviewer. Если возвращает stop-declared-quality-pass-publication-correction-or-outcome, это не оценка качества текста. Stop говорит только о несоответствии границе P118. Такое название не даёт оператору «починить» статус добавлением деталей; правильное действие — убрать неподтверждённый claim либо открыть другой authorised scope.'), - h2('Что передаётся вместе с неизвестностью'), - p('Хорошая передача не оставляет получателю пустой экран. Она даёт точное имя вопроса, даты, source boundary и перечень утверждений, которые пока нельзя делать. Этого достаточно, чтобы будущий scope решил, нужен ли вообще сбор данных. Нельзя добавлять к hand-off предполагаемую причину, потому что она начнёт направлять поиск. Нельзя добавлять готовый verdict, потому что тогда сбор превратится в подтверждение заранее выбранного ответа.'), - p('Для корректировки важен разрыв между ошибкой и следствием. Даже если однажды обнаружится неверный claim, из него не следует, что нужно менять текст, публиковать заметку или оценивать автора. Возможно, claim был ограничен версией; возможно, аудитория не может применить исправление; возможно, источник сам неоднозначен. Новый scope обязан выбирать действие по доступному evidence и риску. P118 не создаёт этот выбор и не подменяет его заранее написанной петлёй revision.'), - p('Полевой документ должен быть устойчив к смене получателя. Если hand-off зависит от конкретного имени, команды или внутренней очереди, он быстро становится историей о людях и требует дополнительных прав. Здесь получатель не назван. Это не недостаток коммуникации: так вопрос можно передать в надлежащий контекст, не создавая ложного обязательства человеку, который не видел исходных материалов и не соглашался их разбирать.'), - p('Отдельно стоит различать correction и deletion. Удаление неподтверждённой фразы может быть правильным действием, но в P118 оно не произошло и не рекомендуется как результат. Пока нет реального текста и входов, нельзя определить, что именно требует изменения. Fixed evaluator охраняет именно эту границу: он не правит строку и не выбирает форму публикации. Его stop — сигнал, что вопрос нельзя завершить данными, которых никто не предоставил.'), - p('Такой режим не делает field lens бесполезным. Он снижает стоимость будущего разбирательства: новый owner не тратит время на развенчание вымышленной chronology, не ищет несуществующий feedback и не объясняет, почему promised correction не выпущена. Вместо этого он получает маленький, точный контракт неизвестности. Если работа будет авторизована, этот контракт можно заменить доказательствами; если нет, он остаётся честным и не создаёт долгов перед людьми или production.'), + h2('Почему nonce не лечит XSS'), + p('Nonce разрешает конкретные скрипты, но не санитизирует пользовательский HTML и не исправляет небезопасный sink. Если приложение вставляет строку в innerHTML, разрешённый bootstrap может помочь атакующему выполнить уже загруженный код. CSP снижает последствия и ловит часть нарушений, но контекстное экранирование и безопасные API остаются обязательными.'), + p('Диагностические отчёты CSP тоже требуют осторожности: URL может содержать чувствительные параметры, а third-party ресурс может присылать много шума. В отчёте храните только нужные поля, ограничивайте доступ и отделяйте нарушение политики от подтверждённой уязвимости. Заголовок — контроль браузера, не verdict о безопасности приложения.'), h2('Ограничения и следующий шаг'), - p('P118 field не является персональной оценкой, development plan, review log, publication record, correction record, редакционным календарём или production report. Он не утверждает, что текст требует или не требует изменения. Его sources не подтверждают действие конкретного человека. Его SVG не является журналом процесса. The fixture проверяет только известные literals и fail-closed statuses; он не создаёт feedback и не измеряет качественный эффект.'), - p('Следующий шаг допустим лишь там, где новый authorised scope назовёт вопрос, owner, разрешённые inputs, правила защиты данных, метод revision и возможный отказ от correction. Этот scope может принять или закрыть вопрос без действия. Он не должен задним числом приписывать P118 review или publishing history. Завершение P118 остаётся ограниченным: synthetic-plan-hand-off и productionEffect: not-attempted.') -], fieldRefs); + p('Генератор не проверяет браузерную поддержку, CDN, service worker, iframe-политику, сертификаты и preload. CSP Level 3 — рабочая редакция W3C, поэтому конкретную совместимость и статус директивы нужно сверять с целевыми браузерами. RFC 6797 не защищает первый небезопасный переход и не заменяет TLS.'), + p('Следующий шаг — взять одну страницу, собрать Report-Only нарушения, закрыть источники по одному и добавить автоматический тест на заголовки. После enforce отдельно проверьте HSTS на каждом поддомене и храните процедуру возврата конфигурации рядом с кодом, чтобы ошибка не требовала ручной импровизации.'), +], securityRefs); + +const performanceRefs = [ + { key: 'lcp', use: 'Определяет Largest Contentful Paint и API наблюдения за крупнейшей отрисовкой, чтобы измерение имело точный объект.', boundary: 'Working Draft может изменяться; LCP не измеряет всю скорость страницы, отзывчивость или стабильность layout.' }, + { key: 'performance', use: 'Задаёт Performance Timeline и доступ к измеряемым entry, на которых строятся браузерные наблюдения.', boundary: 'Не задаёт ваши пороги, backend-агрегацию, sampling и причинность медленной страницы.' }, + { key: 'webVitals', use: 'Фиксирует рекомендованные пороги LCP, INP и CLS и правило p75 по сегментам для практического triage.', boundary: 'Это guidance, а не гарантия UX и не доказательство причины конкретной регрессии.' }, +]; + +const mechanism = revision({ + slug: 'editorial-2027-12-mechanism-author-manifesto', + title: 'Web performance budget: LCP, INP и CLS без одной магической метрики', + categories: ['Frontend', 'Производительность'], + cover: '/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg', + excerpt: 'Как читать пользовательские web-метрики: разделить LCP, INP и CLS, выбрать пороги и не менять код по одному красивому числу.', + readingMinutes: 16, +}, [ + p('Проблема начинается с отчёта «страница медленная». Цена такого диагноза — оптимизировать не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить первый paint, оставив клик заблокированным длинной задачей. Одно среднее число скрывает разные виды задержки.'), + p('Причина — смешать LCP, INP и CLS в общий score без определения окна и percentile. LCP отвечает за крупнейший видимый элемент, INP — за отзывчивость взаимодействий, CLS — за неожиданные сдвиги. У каждой метрики свой источник, порог и способ исправления. Сначала нужно понять измерение, затем выбирать действие.'), + h2('Три метрики — три пользовательских вопроса'), + p('LCP показывает, когда крупнейший контентный элемент стал видимым в пределах загрузки. Большой LCP часто связан с TTFB, критическим CSS, размером изображения или шрифтом. INP оценивает задержку взаимодействий и указывает на работу main thread после ввода. CLS суммирует неожиданные сдвиги layout, например из-за изображения без размеров или поздней рекламы.'), + p('Метрика не говорит, где находится причина. Плохой LCP может быть следствием сервера, сети или браузера; плохой INP — длинной задачи, стороннего скрипта или тяжёлого обработчика; плохой CLS — отсутствующего места под контент. Поэтому budget должен включать и измерение, и диагностический разрез: URL, устройство, connection, release и элемент.'), + table('Как читать Core Web Vitals', ['Метрика', 'Вопрос пользователя', 'Хорошая граница', 'Первый разрез'], [ + ['LCP', 'крупнейший контент появился?', '≤ 2500 ms', 'TTFB, resource, element'], + ['INP', 'интерфейс ответил после ввода?', '≤ 200 ms', 'long task, handler, device'], + ['CLS', 'страница не сдвинулась?', '≤ 0.1', 'element, font, reserved space'], + ['Percentile', 'у какой доли пользователей проблема?', 'p75 по сегменту', 'country, device, release'], + ['Budget', 'какой порог блокирует выпуск?', 'явно в CI/monitoring', 'threshold + owner action'], + ]), + h2('Budget не равен среднему'), + p('Среднее значение сглаживает хвост и может выглядеть здоровым при плохом опыте части пользователей. Для пользовательских web-метрик часто нужен p75 в определённом сегменте, но даже percentile не спасает от смешения мобильных и десктопных данных. Порог должен быть привязан к одинаковому URL, устройству, версии и периоду наблюдения.'), + p('Лабораторный Lighthouse и field data отвечают на разные вопросы. Лаборатория воспроизводима и удобна для CI, но не содержит реального разнообразия сети. Field data показывает пользователей, но зависит от sampling, трафика и состава сегмента. Решение об оптимизации подтверждайте обоими видами данных или честно называйте, какой слой измерен.'), + figure('/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg', 'Матрица web-performance: LCP, INP и CLS имеют разные объекты и пороги; lab и field measurement нельзя складывать в один безымянный score.', 'Схема связывает метрику с вопросом и первым диагностическим разрезом. Улучшение одного показателя не доказывает исправление остальных.'), + h2('Runnable-пример: классифицировать три числа'), + p('Функция принимает миллисекунды LCP и INP, а также значение CLS. Она возвращает статус каждой метрики и общий худший статус. Это учебный классификатор, не реализация браузерного PerformanceObserver: реальные значения нужно собирать из API и агрегировать по сегментам. Входы ниже показывают пороги без округления.'), + code(`import { classifyWebVitals } from './upgrade-2027-12.mjs'; + +const release = classifyWebVitals({ + lcpMs: 2180, + inpMs: 240, + cls: 0.08, +}); +const invalid = classifyWebVitals({ + lcpMs: 1200, + inpMs: -1, + cls: 0.02, +}); + +console.log(release.overall, release.lcp, release.inp, release.layout); +console.log(invalid.ok, invalid.reason); +// needs-improvement good needs-improvement good +// false vital-input-invalid`), + h2('Порядок поиска причины'), + ol([ + 'Определите URL, сегмент, percentile и окно измерения. Не сравнивайте p75 мобильного трафика со средним для всех устройств.', + 'Для плохого LCP найдите element и разделите TTFB, загрузку ресурса и отрисовку. Для INP найдите interaction и long task; для CLS — shifted element.', + 'Сформулируйте один budget на релиз и один diagnostic signal. Не блокируйте сборку по метрике, которую CI не может воспроизвести.', + 'Проверьте lab fixture и field distribution отдельно. Разница между ними — информация о среде, а не повод выбрать удобный источник.', + 'Измените один тяжёлый участок: critical resource, handler, image dimensions или layout reservation.', + 'Повторите измерение тем же сегментом и окном. Снижение LCP не закрывает INP/CLS автоматически.', + ]), + h2('Почему порог не является причинностью'), + p('Пересечение границы 2500 мс сообщает о классификации, но не объясняет, что исправить. Порог полезен для triage и разговора о риске, а не для выбора виновника. Если после preload LCP улучшился, это ещё не доказывает, что preload был единственной причиной: изменились сервер, кэш или состав трафика.'), + p('У performance есть побочный эффект оптимизации. Сжатие изображения уменьшает LCP, но может увеличить CPU-декодирование или ухудшить качество. Разделение JavaScript может помочь INP, но добавить запросы и повлиять на LCP. В budget следует держать соседние ограничения — error rate, size, long tasks — и проверять, что выигрыш одной метрики не создаёт новый долг.'), + h2('Ограничения и следующий шаг'), + p('Классификатор использует учебные пороги и не считает реальные percentile. W3C LCP и Performance Timeline описывают API и объект измерения, но не обещают, что конкретный dashboard правильно собрал данные. INP и CLS требуют своих источников и сегментации; один локальный запуск не является field evidence.'), + p('Следующий шаг — выбрать один URL и сделать таблицу p75 для LCP, INP и CLS по двум сегментам. Для каждой плохой строки добавьте element/interaction и один проверяемый сигнал причины. После оптимизации повторите ту же выборку и проверьте соседние метрики, а не только тот показатель, который был в заголовке задачи.'), +], performanceRefs); + +const writingRefs = [ + { key: 'nistIncident', use: 'Даёт дисциплину подготовки, обнаружения, анализа, containment, восстановления и работы после инцидента для структуры эксплуатационной инструкции.', boundary: 'Не знает ваших команд, прав доступа, сервисных зависимостей и порогов остановки.' }, + { key: 'rfc2119', use: 'Фиксирует различие между обязательным, рекомендуемым и необязательным действием, чтобы инструкция не прятала приоритет в тоне.', boundary: 'Не является руководством по эксплуатации, не проверяет команду и не даёт разрешение менять production.' }, +]; + +const field = revision({ + slug: 'editorial-2027-12-field-author-manifesto', + title: 'Эксплуатационная инструкция: симптом, действие, откат и проверка', + categories: ['Техническая документация', 'Надёжность'], + cover: '/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg', + excerpt: 'Как написать короткую инструкцию для опасной операции, чтобы читатель видел вход, ограничения, обратимое действие и критерий завершения.', + readingMinutes: 15, +}, [ + p('Проблема инструкции обнаруживается в первый же сбой: читатель знает, что сервис нездоров, но не понимает, какой командой начать и как не усугубить ситуацию. Цена расплывчатого текста — параллельные ручные действия, потеря исходных метрик и откат без проверки данных.'), + p('Причина — писать статью как последовательность уверенных советов. В эксплуатации важнее не количество команд, а граница каждой команды: какое условие должно быть истинным, какой результат ожидается и когда нужно остановиться. Reader-facing текст должен позволить сверить вход, выполнить один шаг и увидеть измеримый выход.'), + h2('Карточка операции — минимальная единица'), + p('Полезная инструкция начинается с симптома и scope: например, «5xx выше 5% на POST /payments в одном регионе». Затем идут precondition, действие, rollback и verification. Эти поля не формальность. Без scope оператор может отключить здоровый трафик; без precondition — выполнить команду на неправильной версии; без verification — принять завершение команды за восстановление.'), + p('Порядок должен отражать риск, а не удобство автора документа. Сначала сохранить наблюдаемый факт, потом ограничить влияние, затем изменить один рычаг. После действия нужен интервал наблюдения и критерий возврата. Если операция необратима, инструкция должна прямо сказать, что её нельзя запускать без отдельного разрешения и резервного пути.'), + table('Структура проверяемой инструкции', ['Поле', 'Что написать', 'Проверяемый вопрос', 'Нельзя заменять'], [ + ['Symptom', 'метрика, endpoint, время', 'что именно нарушено?', '«сервис плохой»'], + ['Scope', 'регион, версия, процент', 'кого затрагивает?', '«все пользователи»'], + ['Precondition', 'доступ, версия, backup', 'можно ли выполнять шаг?', '«должно работать»'], + ['Action', 'одна команда/изменение', 'что изменится?', 'список несвязанных команд'], + ['Rollback', 'обратное действие и условие', 'как вернуть состояние?', '«откатить при проблеме»'], + ['Verification', 'метрика и окно', 'что считать восстановлением?', '«проверить вручную»'], + ]), + h2('Глаголы задают риск'), + p('Рекомендации вроде «проверьте», «убедитесь» и «при необходимости» слишком широки, если рядом нет объекта. RFC 2119 полезен как дисциплина модальности: MUST можно применять к обязательной precondition, SHOULD — к шагу с допустимым исключением, а MAY — к необязательной диагностике. В русском тексте это можно перевести обычными словами, сохранив однозначность.'), + p('Каждый command block должен иметь входы и ожидаемый результат. Если команда меняет состояние, рядом укажите право, namespace и способ увидеть diff. Не вставляйте секрет в пример и не предполагайте, что читатель знает локальные alias. Хорошая краткость убирает лишние слова, но не убирает условия безопасности.'), + figure('/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg', 'Петля эксплуатационной инструкции: симптом и precondition ведут к одному действию, затем к проверке метрики и условному откату.', 'Диаграмма показывает reader-facing последовательность. Текст считается завершённым только после наблюдаемой проверки, а не после окончания команды.'), + h2('Runnable-пример: проверить карточку runbook'), + p('Функция принимает объект с шестью полями и проверяет, что каждое достаточно содержательно, rollback назван явно, а verification ссылается на наблюдаемый сигнал. Это учебная проверка структуры документа, не оценка литературного стиля и не разрешение выполнить команду. Вход ниже показывает минимальный принятый набор и отказ без измеримой проверки.'), + code(`import { validateRunbookCard } from './upgrade-2027-12.mjs'; + +const card = validateRunbookCard({ + symptom: '5xx выше 5 процентов на POST /payments', + scope: 'region eu-west, release 42, 10 percent traffic', + precondition: 'есть доступ к flag и сохранён dashboard за 15 минут', + action: 'отключить flag payments-v2 для 10 процентов трафика', + rollback: 'вернуть flag payments-v2 после проверки результата', + verification: 'проверить error rate и p95 в течение 10 минут', +}); + +console.log(card.ok, card.order.join(' -> ')); +// true symptom -> scope -> precondition -> action -> rollback -> verification`), + h2('Порядок редакторской проверки инструкции'), + ol([ + 'В первых двух абзацах назовите симптом и цену ошибки. Reader должен понять, для какой ситуации текст предназначен.', + 'Сделайте scope измеримым: endpoint, регион, версия, доля трафика и временное окно.', + 'Перед каждой опасной командой поставьте precondition и ожидаемый output. Если output не наблюдаем, шаг нельзя считать проверенным.', + 'Разделите один шаг изменения и rollback. Для rollback укажите условие, а не только команду возврата.', + 'Добавьте таблицу решений для соседних симптомов, чтобы reader не применил одинаковое действие к 500, timeout и 409.', + 'Запустите учебный пример с валидной и неполной карточкой, затем перечитайте текст на мобильной ширине и уберите длинные строки.', + ]), + h2('Технический текст не заменяет разрешение'), + p('Даже подробная инструкция не даёт права менять production. Доступ, approval и окно операции должны жить в локальном процессе, а статья должна честно указать, какие precondition ей неизвестны. Если шаг может удалить данные или нарушить доступность, reader должен увидеть остановку до команды, а не бодрый призыв продолжать.'), + p('Не стоит добавлять в runbook вымышленные метрики и имена сервисов только для гладкого чтения. Лучше оставить placeholder с точным описанием входа, чем заставить оператора повторить чужой пример. Учебный пример должен быть маркирован как учебный и не содержать секретов, настоящих hostnames или команд с необратимым эффектом.'), + h2('Ограничения и следующий шаг'), + p('Проверка карточки не знает прав, shell, облака, backup, lock и реальных порогов. NIST SP 800-61 задаёт общий цикл обращения с инцидентом, но ваша инструкция всё равно должна назвать локальные сигналы и способ остановки. RFC 2119 помогает выбрать модальность, но не тестирует исполнимость команды.'), + p('Следующий шаг — взять один существующий alert и переписать его в шесть полей, затем прогнать на staging с безопасным флагом и настоящей проверкой метрики. Если reader не может назвать ожидаемый output любого шага, вернитесь к precondition и добавьте наблюдаемый критерий.'), +], writingRefs); export const revisions = deepFreeze([practice, mechanism, field]); + +export function runTechnicalWritingFixture() { + const validCard = { symptom: '5xx выше 5 процентов на endpoint', scope: 'region eu-west release 42', precondition: 'есть доступ и сохранён dashboard', action: 'отключить flag на десяти процентах', rollback: 'вернуть flag после проверки', verification: 'проверить метрику error rate 10 минут' }; + const cases = [ + ['security-headers-accept', buildSecurityHeaders({ nonce: '7c2f1b8e9a4d6f0c' }).ok, true], + ['security-headers-reject', buildSecurityHeaders({ nonce: 'short' }).reason, 'security-header-input-invalid'], + ['vitals-classify', classifyWebVitals({ lcpMs: 2180, inpMs: 240, cls: 0.08 }).overall, 'needs-improvement'], + ['vitals-reject', classifyWebVitals({ lcpMs: -1, inpMs: 200, cls: 0.1 }).reason, 'vital-input-invalid'], + ['runbook-accept', validateRunbookCard(validCard).ok, true], + ['runbook-reject', validateRunbookCard({ ...validCard, verification: 'посмотреть' }).reason, 'verification-must-be-observable'], + ]; + const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected })); + return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks }); +} + export function verifyRevisionsAgainstFixture() { - const fixture = runAuthorManifestoFixture(); - const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 10000 && text.length <= 13000 && /(цен[аы]|стоимост|дорог)/i.test(text.slice(0, 1500)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /2027-12/.test(text) && /2026-07-31/.test(text) && /productionEffect: not-attempted/.test(text); }); + const fixture = runTechnicalWritingFixture(); + const articleChecks = revisions.map((item) => { + const text = bodyText(item.contentHtml); + return text.length >= 5000 && text.length <= 15000 && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml) && /Проблема/.test(text.slice(0, 900)); + }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } -if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } + +if (process.argv.includes('--verify-fixture')) { + const result = verifyRevisionsAgainstFixture(); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.accepted) process.exitCode = 1; +} + if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');