diff --git a/editorial/production/README.md b/editorial/production/README.md
index b73ffc7..8ee7bbc 100644
--- a/editorial/production/README.md
+++ b/editorial/production/README.md
@@ -1,6 +1,6 @@
# Производство редакционных партий
-На 31 июля 2026 года строгий аудит проходит 136 из 358 созданных материалов. Остальные 222 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
+На 31 июля 2026 года строгий аудит проходит 139 из 358 созданных материалов. Остальные 219 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
diff --git a/editorial/reviews/2021-11-draft.md b/editorial/reviews/2021-11-draft.md
new file mode 100644
index 0000000..67cd495
--- /dev/null
+++ b/editorial/reviews/2021-11-draft.md
@@ -0,0 +1,236 @@
+# Автономное тройное ревью П45 · ноябрь 2021 · «Нагрузочное тестирование»
+
+Статус: **готово к независимой интеграционной приёмке**. Это только author
+package: registry, README, articles.json, общие audit scripts,
+редакционные правила, очередь и чужие файлы не менялись. Git add, commit и
+push не выполнялись.
+
+Пакет создаёт ровно пять разрешённых файлов:
+
+1. web/scripts/upgrade-2021-11.mjs;
+2. editorial/reviews/2021-11-draft.md;
+3. web/public/assets/editorial/2021/load-test-profile-2021.svg;
+4. web/public/assets/editorial/2021/load-test-observation-2021.svg;
+5. web/public/assets/editorial/2021/load-test-diagnosis-2021.svg.
+
+Revision-модуль экспортирует ровно три overlay revision для stable slug:
+
+- editorial-2021-11-practice-load-testing;
+- editorial-2021-11-mechanism-load-testing;
+- editorial-2021-11-field-load-testing.
+
+В export нет полей date и author; следовательно,
+исходные дата и автор берутся из архивной записи. Модуль не импортируется
+registry и не изменяет базовый archive.
+
+## Проход 1. Структура, тон и объём — пройдено
+
+| Revision | Ситуация и цена в первых двух абзацах | Главный вопрос | Объём body |
+| --- | --- | --- | ---: |
+| Практика | У команды есть только просьба «нагрузить endpoint», а разные люди помнят разные данные и условия; цена — число без причины и случайное изменение кода или среды | Как описать workload, среду, data setup и stop criterion до выбора инструмента | **9 456** |
+| Механизм | Есть aggregate «много запросов», но нет модели и возникает ложная причинность; цена — приписать изменение очереди, базе или коду без связи со сценарием | Почему arrival intent, профиль, observability и evidence packet не сводятся к total | **9 874** |
+| Полевой разбор | После теста появляется error-signal или latency-signal без контекста; цена — поменять систему на основании другой среды, плохого сценария или не-измерения | Как отделить environment drift, scenario gap и synthetic boundary и выбрать обратимое действие | **9 973** |
+
+- Все тексты находятся в коридоре П45 8–11 тыс. знаков и в обязательном
+ диапазоне 5–15 тыс. знаков.
+- В каждом revision есть девять смысловых разделов, table с
+ caption/thead, figure с самостоятельными
+ alt/figcaption, рабочий JS example, нумерованный
+ маршрут «симптом → причина → проверка → действие», ограничения и пять
+ официальных либо нормативных ссылок.
+- Голос соответствует M4 / ноябрю 2021: автор связывает endpoint, данные,
+ среду и наблюдение, но не изображает владельца production platform или
+ руководителя incident response. Термины arrival,
+ evidence packet, drift и
+ rollback-safe сразу привязаны к object, полю или действию.
+- Из текстов удалены общие оценки и ложная уверенность. Нет придуманного
+ прогона, latency, RPS, error rate, пользователей, capacity, инцидента или
+ production verdict. В частности, latency-signal в field
+ прямо помечен как диагностическая метка, а не измерение.
+
+Вердикт прохода: **пройден**. У каждого вывода есть прикладной артефакт и
+названо ограничение, поэтому статья не обещает больше, чем может проверить.
+
+## Проход 2. Техника, историческая рамка и fixture — пройдено
+
+| Утверждение | Первичный или официальный источник | Зафиксированная граница |
+| --- | --- | --- |
+| k6 v0.35.0 опубликован 17 ноября 2021 года; release notes упоминают stage tags в связи с конкретными executors | [Grafana k6 v0.35.0 release](https://github.com/grafana/k6/releases/tag/v0.35.0) | Текст использует только историческую границу версии. Он не запускает k6, не создаёт VU и не переносит executor semantics в fixture. |
+| В точном commit, на который указывает тег v0.35.0, sample thresholds связывает порог с именованной метрикой | [k6 fixed source snapshot 1c44b2d](https://github.com/grafana/k6/blob/1c44b2d0ecc16cbe788e773a1db019b6cbdad725/samples/thresholds.js) | Из sample взято требование называть metric вместе с порогом. Пакет не импортирует и не исполняет sample, HTTP или tool. |
+| Тег OpenTelemetry Specification v1.0.0 существовал в феврале 2021 года, а его Metrics API имеет status experimental | [fixed Metrics API snapshot f228a83](https://github.com/open-telemetry/opentelemetry-specification/blob/f228a83e652e5cd3ba96b9f780b704ee7a7daa4c/specification/metrics/api.md), [fixed Trace API snapshot f228a83](https://github.com/open-telemetry/opentelemetry-specification/blob/f228a83e652e5cd3ba96b9f780b704ee7a7daa4c/specification/trace/api.md) | Источники нужны только для терминов measurement, instrument, trace и span. Evidence packet — локальный object, не OpenTelemetry export и не claim о внедрённой телеметрии. |
+| Точное определение измеряемого свойства важнее названия графика | [RFC 2330](https://www.rfc-editor.org/rfc/rfc2330.html) | RFC задаёт рамку IP performance metrics, а не capacity contract application endpoint. Он не превращает synthetic units в benchmark. |
+
+### Техническая граница fixture
+
+runLoadTestingFixture() создаёт только arrays и plain objects в
+памяти. Один нейтральный intent —
+GET /fixture/neutral-resource — имеет
+invocation: not-performed. Его профиль строго идёт в порядке
+warm-up → steady → step → recovery.
+
+У каждого segment есть:
+
+- именованные plannedRequestSlots;
+- acceptedUnits и rejectedUnits;
+- syntheticBottleneckFlag;
+- строка measurement boundary;
+- неисполняемая отметка endpoint invocation.
+
+Среда явно задаёт isolated-training-envelope-v1,
+in-memory-object-evaluation и ограничение в три accepted
+synthetic units на segment. Step намеренно имеет пять slots, поэтому
+получает три accepted и два rejected units. Это заранее смоделированная
+ветка проверки, не найденное bottleneck и не capacity результата.
+
+Stop criterion срабатывает лишь после recovery, если последовательность
+полна, flag виден только в step, slots согласованы с accepted/rejected
+units, а recovery record присутствует. Packet содержит endpoint,
+environment, data setup, profile, stop, diagnostic model и явную
+measurement boundary. Плохой объект
+many-requests-without-scenario не содержит endpoint, среды,
+данных, profile или criterion и отклоняется как не-модель.
+
+Проверены девять assertions:
+
+1. порядок сегментов — ровно warm-up, steady, step, recovery;
+2. environment и его constraint явны;
+3. planned slots согласованы с accepted/rejected units;
+4. synthetic bottleneck flag виден только в step;
+5. остановка произошла по declared criterion после recovery;
+6. synthetic units не имеют полей и смысла latency, throughput или RPS;
+7. evidence packet полон;
+8. bare aggregate отклонён;
+9. external work не выполнялся.
+
+Не моделируются и не выполняются HTTP, сеть, load tool, server, database,
+browser, clock, file system, process, telemetry, latency, throughput,
+error rate, RPS, пользователи, capacity, production incident или benchmark.
+Fixture не выдаёт synthetic rejected unit за HTTP error и прямо хранит
+latencySignal: not-collected-in-fixture.
+
+Вердикт прохода: **пройден**. Исторические источники ограничивают словарь и
+версию, а fixture доказывает собственные invariants, не приписывая их
+внешней системе.
+
+## Проход 3. SVG, ссылки и preflight — пройдено
+
+- load-test-profile-2021.svg — мобильная вертикальная диаграмма
+ четырёх segments. В ней видны planned slots, accepted/rejected synthetic
+ units, единственный flag в step и явный запрет трактовать units как
+ latency, throughput, RPS, users или capacity.
+- load-test-observation-2021.svg — вертикальная цепочка
+ «карточка workload → профиль → evidence packet»; в нижней карточке bare
+ total отклоняется как не-model.
+- load-test-diagnosis-2021.svg — диагностический маршрут
+ environment drift, scenario gap и synthetic boundary. У каждой ветки есть
+ rollback-safe действие; latency в fixture не collected.
+- У SVG есть title, desc,
+ role="img", контрастные элементы, короткие строки и
+ мобильный вертикальный viewBox. В XML нет script,
+ foreignObject, external asset reference или data URI.
+- Sharp отрендерил все три SVG в PNG шириной 375 px. Финальные renders
+ просмотрены вручную: заголовки, карточки, стрелки и нижние ограничения
+ читаются; clipping, overlap и horizontal overflow после правок не
+ обнаружены.
+
+### Фактически выполненные проверки
+
+Команды выполнены из web/:
+
+
node --check scripts/upgrade-2021-11.mjs
+npm run audit:draft -- scripts/upgrade-2021-11.mjs
+node scripts/upgrade-2021-11.mjs --verify-fixture
+xmllint --noout \
+ public/assets/editorial/2021/load-test-profile-2021.svg \
+ public/assets/editorial/2021/load-test-observation-2021.svg \
+ public/assets/editorial/2021/load-test-diagnosis-2021.svg
+rg -n -i '<script|<foreignObject|(?:href|src)="(?:https?:|data:)|xlink:href=' \
+ public/assets/editorial/2021/load-test-profile-2021.svg \
+ public/assets/editorial/2021/load-test-observation-2021.svg \
+ public/assets/editorial/2021/load-test-diagnosis-2021.svg
+node -e "import('sharp').then(async ({default: sharp}) => { ... resize({width:375}) ... })"
+
+| Проверка | Реальный результат |
+| --- | --- |
+| node --check | PASS, code 0 |
+| Draft gate | PASS: **9 456 / 9 874 / 9 973** знака body; найдены три archive slug, sections, tables, figures, code, routes, local assets и пять external sources в каждой статье |
+| In-memory fixture | PASS: все девять assertions равны true; проверены order, environment, unit reconciliation, flag, criterion, unit meaning, packet, bad aggregate и отсутствие external work |
+| xmllint --noout | PASS, три SVG — корректный XML |
+| SVG safety scan | PASS: совпадений нет; rg завершился с code 1, что для поиска означает ожидаемый пустой результат |
+| Sharp 375 px | PASS: созданы и просмотрены три PNG; после mobile-first правок clipping, overlap и horizontal overflow не найдены |
+| Scope/self-review | PASS: создано только пять разрешённых P45 файлов; overlay не меняет date/author; registry, README, archive, audit scripts, правила, очередь, Git и mascot PNG не затронуты |
+
+npm run audit:draft завершилась с code 0. npm вывел старые
+предупреждения о пользовательских store-dir, cache-dir
+и public-hoist-pattern; они не относятся к П45 и не менялись.
+
+Не запускались strict audit после registry integration, production build,
+browser, screen reader, HTTP-запрос к нейтральному endpoint, load tool,
+server, database, сетевой workload, реальный benchmark, commit или
+публикация. Официальные historical sources были прочитаны read-only для
+fact-check; это не запуск сценария и не сетевой тест. Это намеренная граница
+автономной партии.
+
+## Итог
+
+Три прохода завершены после исправления двух найденных проблем: в mechanism
+первый абзац получил явную постановку проблемы для draft gate, а SVG были
+перестроены в мобильную вертикальную композицию; у observation и diagnosis
+убраны пересечение стрелки с текстом и обрезание нижней строки. П45 готова к
+независимой интеграции без изменения shared state.
+
+## Независимое редакторское ревью и интеграция
+
+Статус: **принято и подключено в editorial overlay 31 июля 2026 года**.
+
+### 1. Факты и техника
+
+- Независимая проверка подтвердила дату релиза k6 v0.35.0 —
+ 17 ноября 2021 года. Его lightweight-тег разрешается в commit
+ 1c44b2d0ecc16cbe788e773a1db019b6cbdad725, который использован
+ в ссылке на samples/thresholds.js. Source действительно
+ связывает threshold с именованной метрикой; статья делает из этого только
+ ограниченный редакционный вывод, а не копирует API инструмента.
+- Тег OpenTelemetry Specification v1.0.0 разрешается в commit
+ f228a83e652e5cd3ba96b9f780b704ee7a7daa4c. Его Metrics API
+ помечен Experimental и определяет instrument как средство
+ захвата raw measurements. Ссылки поэтому исторически фиксированы, а
+ evidence packet в статьях остаётся локальным object, не telemetry export.
+- Повторный node --check прошёл. Draft audit подтвердил объёмы
+ 9 456, 9 874 и 9 973 знака. Fixture дал 9 из 9 истинных assertions:
+ четыре сегмента идут в нужном порядке, step содержит единственный
+ synthetic flag и два rejected units, recovery завершает criterion, а bare
+ aggregate отвергается как не-модель. В нём по-прежнему нет HTTP, clock,
+ latency, RPS или нагрузки.
+- После подключения overlay strict audit прошёл для всех трёх slug: у каждой
+ статьи по 1 figure, 1 table и 1 code example. В registry 130 revision,
+ из них ровно три ноябрьские; дубликатов slug нет. Как и требуется, overlay
+ не содержит date и author.
+
+### 2. Редактура и голос
+
+- Все три текста перечитаны после независимой source-проверки. Они не
+ подменяют workload абстрактным total, не выдают synthetic rejection за HTTP
+ error и не выносят verdict о capacity. Каждый оставляет конкретный следующий
+ шаг: описать разрешённый реальный запуск отдельно с версией, окружением,
+ входом и raw evidence.
+- Тон соответствует ноябрю 2021: автор уже видит границы сценария, данных,
+ окружения и наблюдения, но не изображает опыт владельца production
+ platform. Термины привязаны к полям fixture и таблицам; выводы условны и
+ обратимы.
+
+### 3. Визуал и выпуск
+
+- Три SVG повторно прошли xmllint --noout; safety scan не нашёл
+ script, foreignObject, внешних URL или
+ data:image. Каждый файл отрендерен Sharp в ширину 375 px и
+ просмотрен вручную: профиль, evidence route и diagnostic cards остаются
+ читаемыми без overlap и horizontal clipping.
+- npm run build завершилась с code 0 и сгенерировала 374
+ статические страницы. git diff --check также прошёл. Обычные
+ предупреждения npm о пользовательских store-dir,
+ cache-dir и public-hoist-pattern не изменялись и
+ не повлияли на результат.
+
+Итог: ноябрьские статьи заменяют архивные записи через overlay. После этой
+интеграции строгий аудит проходит **139 из 358**, остаётся **219**.
diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs
index 474b1c9..bb8a6b2 100644
--- a/web/data/editorial-revisions.mjs
+++ b/web/data/editorial-revisions.mjs
@@ -41,6 +41,7 @@ import { revisions as july2021Revisions } from '../scripts/upgrade-2021-07.mjs';
import { revisions as august2021Revisions } from '../scripts/upgrade-2021-08.mjs';
import { revisions as september2021Revisions } from '../scripts/upgrade-2021-09.mjs';
import { revisions as october2021Revisions } from '../scripts/upgrade-2021-10.mjs';
+import { revisions as november2021Revisions } from '../scripts/upgrade-2021-11.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -87,4 +88,5 @@ export const editorialRevisions = [
...august2021Revisions,
...september2021Revisions,
...october2021Revisions,
+ ...november2021Revisions,
];
diff --git a/web/public/assets/editorial/2021/load-test-diagnosis-2021.svg b/web/public/assets/editorial/2021/load-test-diagnosis-2021.svg
new file mode 100644
index 0000000..842fb03
--- /dev/null
+++ b/web/public/assets/editorial/2021/load-test-diagnosis-2021.svg
@@ -0,0 +1,55 @@
+
diff --git a/web/public/assets/editorial/2021/load-test-observation-2021.svg b/web/public/assets/editorial/2021/load-test-observation-2021.svg
new file mode 100644
index 0000000..fc57070
--- /dev/null
+++ b/web/public/assets/editorial/2021/load-test-observation-2021.svg
@@ -0,0 +1,61 @@
+
diff --git a/web/public/assets/editorial/2021/load-test-profile-2021.svg b/web/public/assets/editorial/2021/load-test-profile-2021.svg
new file mode 100644
index 0000000..5e5688a
--- /dev/null
+++ b/web/public/assets/editorial/2021/load-test-profile-2021.svg
@@ -0,0 +1,90 @@
+
diff --git a/web/scripts/upgrade-2021-11.mjs b/web/scripts/upgrade-2021-11.mjs
new file mode 100644
index 0000000..09504c4
--- /dev/null
+++ b/web/scripts/upgrade-2021-11.mjs
@@ -0,0 +1,584 @@
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function paragraph(text) {
+ return '' + text + '
'; +} + +function heading(text) { + return '' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '';
+}
+
+function figure(src, alt, caption) {
+ return 'GET /fixture/neutral-resource. Она не отправляет HTTP, не запускает generator, сервер, базу или сеть. planned request slots, accepted units и rejected units здесь — только заданные единицы плана. Они не означают RPS, миллисекунды, пользователей, latency, throughput или реальную ёмкость.'),
+ heading('Карточка workload отвечает на пять вопросов'),
+ paragraph('Первый вопрос — какой контракт проверяем. Не «каталог вообще», а нейтральное намерение чтения, метод, путь и ожидаемая граница результата. Второй — кто владеет данными: откуда берётся fixture record, изменяется ли он и как узнать его версию. Третий — где выполняется проверка. Слово «стенд» бесполезно, пока не записаны конфигурация, версия, внешние зависимости и ограничение, которое сценарий обязуется не пересекать молча.'),
+ paragraph('Четвёртый вопрос — как профиль меняется по сегментам. Warm-up, steady, step и recovery нужны не как красивые английские подписи. У каждого есть роль: до основного шага проверить карточку, удержать повторяемый план, показать заранее смоделированную границу и вернуть модель к малому плану. Пятый вопрос — когда остановиться. Если критерий не объявлен заранее, остановка выбирается после того, как кому-то понравилось или не понравилось число.'),
+ dataTable(
+ 'Что записать до выбора load tool',
+ ['Поле карточки', 'Пример в учебной fixture', 'Проверка', 'Не является'],
+ [
+ ['Endpoint intent', 'GET /fixture/neutral-resource', 'путь и метод совпадают во всех сегментах', 'HTTP-вызовом или существующим сервисом'],
+ ['Среда', 'isolated-training-envelope-v1', 'есть identity, режим и явное ограничение', 'описанием production или capacity'],
+ ['Данные', 'training-data-v1', 'fixture record фиксирован, mutation отсутствует', 'копией пользовательских данных'],
+ ['Профиль', 'warm-up → steady → step → recovery', 'последовательность и slots сохранены в packet', 'одним общим количеством запросов'],
+ ['Критерий остановки', 'recovery evidence after synthetic bottleneck', 'модель останавливается только после recovery', 'таймаутом или сигналом настоящей системы'],
+ ],
+ ),
+ heading('Профиль — это переходы, а не один большой счётчик'),
+ paragraph('Внутри fixture каждый сегмент хранит массив planned request slots. Это полезнее одного total: можно увидеть, какой слот принадлежит warm-up, какой — step, и где появились rejected synthetic units. При declared limit в три accepted units step содержит пять slots; два из них модель помечает rejected. Такое расхождение не доказывает перегрузку. Оно заранее создано, чтобы увидеть, что evidence packet умеет хранить и план, и результат его учебной границы.'),
+ paragraph('Steady не обязан быть «нормой», а step не обязан быть «пиком» настоящей нагрузки. Это названия ролей в документе. Если в реальном тесте их смысл другой, карточку переименовывают и рядом объясняют почему. Хуже оставить привычные слова, но не записать вход, data setup и переход. Тогда reader видит форму графика, а автор не может ответить, изменилось ли приложение, данные или только generator.'),
+ figure(
+ '/assets/editorial/2021/load-test-profile-2021.svg',
+ 'Схема учебного профиля из четырёх сегментов: warm-up, steady, step и recovery. В каждом показаны planned request slots, accepted и rejected synthetic units; в step отмечен искусственный bottleneck flag. Подпись на схеме прямо исключает RPS, latency и throughput.',
+ 'Профиль — это последовательность намерений и границ модели. Красная отметка в step означает только заданное условие fixture, а не найденное узкое место.',
+ ),
+ heading('Среда и data setup — часть результата'),
+ paragraph('Среда в карточке — не колонка «dev» или «stage». Нужны identity и ограничение: например, «in-memory evaluation, внешней системы нет, за один segment принимается не больше трёх synthetic units». Такое условие не похоже на реальный лимит, и это хорошо: его нельзя случайно перенести в конфигурацию. Оно заставляет автора назвать, что именно сравнивается, и запрещает потом объяснять результат неизвестным сервером.'),
+ paragraph('Data setup тоже не прячется за словом «тестовые данные». Для операции чтения достаточно назвать fixture record, его версию и правило неизменности. Для операции изменения понадобились бы начальное состояние, очистка, idempotency и проверка результата, но их нельзя домыслить к нейтральному чтению. Если данные меняются между сегментами, это отдельная гипотеза и отдельный evidence packet, а не параметр, затерянный в shell history.'),
+ heading('Критерий остановки защищает расследование'),
+ paragraph('В учебной модели stop criterion требует четыре факта: сегменты прошли по порядку, только step получил synthetic bottleneck flag, accepted плюс rejected units равны количеству slots, а recovery записан в packet. До этого состояния модель не называет себя завершённой. В реальном инструменте критерий может быть другим, но ему всё равно нужна наблюдаемая формулировка: какая граница остановит сценарий, какие данные будут сохранены и кто решит, что запуск повторять нельзя.'),
+ paragraph('Критерий не заменяет проверку качества. Он отделяет два вопроса. Сначала: «достаточно ли evidence, чтобы прочитать именно этот сценарий?» Потом: «соответствует ли наблюдение договорённости?» Если первый вопрос не закрыт, нельзя честно переходить ко второму. Поэтому bare counter «много запросов» в fixture отклонён: у него нет endpoint intent, среды, данных, последовательности, criterion и packet.'),
+ heading('Минимальный пример: прочитать модель, не запускать benchmark'),
+ paragraph('Ниже пример не обращается к адресу из endpoint intent. Он импортирует функцию из revision-модуля, выводит already-materialized objects и проверяет объявленный criterion. Такой пример нужен для редакционной проверки: любой change в profile, который спутал порядок сегментов или назвал units latency, должен разрушить assertion до интеграции статьи.'),
+ codeBlock(profileExample),
+ paragraph('Важная деталь — comment в конце. Она не декоративная: exact boundary повторяется в объекте каждого segment и в assertions. Если в будущем кто-то добавит поле latency, throughput или rps в эту модель, assertion станет false. Для реального измерения лучше создать другой артефакт с источником времени, версией инструмента и условиями запуска, чем тихо расширить учебную fixture.'),
+ heading('Evidence packet должен пережить разговор через неделю'),
+ paragraph('После описания профиля сохраняем не только результат. Packet несёт endpoint intent, environment, data setup, profile records, stop criterion, stop state и diagnostic labels. Этого достаточно, чтобы коллега проверил порядок размышления: почему step виден отдельно, почему recovery обязателен и почему rejected units нельзя выдать за error rate. Это ещё не raw result инструмента. Но это минимальный договор, без которого raw result тоже быстро теряет смысл.'),
+ paragraph('Историческая рамка здесь намеренно скромная. В релизе k6 v0.35.0 от 17 ноября 2021 года есть работа с stage tags и сценариями; sample того же тега показывает пороги, связанные с именованными метриками. Из этого следует только практическое правило: инструмент и метрика должны быть названы вместе. Fixture не использует k6 и не делает вывод о его executor semantics. OpenTelemetry v1.0.0 полезен как напоминание, что измерению дают явный смысл; в том снимке Metrics API всё ещё experimental.'),
+ heading('Маршрут: симптом → причина → проверка → действие'),
+ orderedList([
+ 'Симптом. В обсуждении есть лишь «много запросов» или один total, а по нему уже предлагают менять endpoint.',
+ 'Причина. Workload не отделён от среды, data setup и условия завершения; разные запуски невозможно сравнить по одному договору.',
+ 'Проверка. Выпишите endpoint intent, среду с ограничением, версию данных, четыре сегмента и criterion. Проверьте, что planned slots раскладываются на accepted/rejected units.',
+ 'Действие. Сначала исправьте карточку и сохраните evidence packet. Инструмент выбирайте после того, как стало понятно, какую реальную величину и на какой версии среды он должен собирать.',
+ 'Повторная проверка. Если изменились среда или данные, дайте новому запуску новую identity. Не сравнивайте его с прежним только по форме графика.',
+ 'Ожидаемый результат. У команды остаётся сценарий, который можно прочитать и воспроизвести как договорённость, не выдавая учебные units за benchmark.',
+ ]),
+ heading('Ограничения и следующий проверяемый шаг'),
+ paragraph('Эта fixture не моделирует HTTP status, DNS, TLS, соединения, очередь процесса, scheduler, clock, нагрузочный generator, database, cache, данные пользователя, concurrency, latency, throughput, error rate, capacity, пользователей или production incident. Rejected unit означает только, что planned slot не прошёл заданный synthetic limit. Он не говорит, почему реальная система могла бы вернуть ошибку и не обещает, что реальная среда выдержит иной сценарий.'),
+ paragraph('Следующий проверяемый шаг в своём проекте — не копировать числа из модели, а оформить такую же карточку для одного настоящего endpoint: выбрать инструмент версии, существовавшей на дату работы, записать его конфигурацию, среду, data setup, вход, критерий остановки и место хранения raw output. Лишь после этого имеет смысл запускать отдельный реальный тест. Учебная модель помогает не потерять вопрос до такого запуска, но не заменяет его.'),
+ ],
+ commonSources,
+);
+
+const mechanismArticle = createRevision(
+ {
+ slug: 'editorial-2021-11-mechanism-load-testing',
+ title: 'Почему «много запросов» не является нагрузочной моделью',
+ categories: ['Нагрузка', 'Надёжность'],
+ cover: '/assets/editorial/2021/load-test-observation-2021.svg',
+ excerpt: 'Разбор механизма workload: arrival intent, профиль сегментов, evidence packet и synthetic bottleneck. Почему aggregate без среды и stop criterion не объясняет ни причину, ни следующее действие.',
+ readingMinutes: 14,
+ },
+ [
+ paragraph('Проблема фразы «мы дали много запросов» в том, что она описывает объём шума, но не модель нагрузки. В ней не видно, когда возникал input, какой endpoint он представлял, какой набор данных использовался, что считалось завершением и кто наблюдал последствия. Цена — ложная причинность: aggregate меняется, а команда приписывает его очереди, базе или коду, хотя могла измениться сама среда или сценарий.'),
+ paragraph('Для механизма достаточно одной строгой границы. В этой статье arrival означает намерение поставить planned request slot в сегмент профиля. Это не отправка запроса и не скорость. Fixture материализует slots в accepted/rejected synthetic units внутри local objects. Она не создаёт clock, HTTP, сеть, процесс, нагрузочный инструмент или telemetry. Поэтому слова latency-signal и error-signal ниже — диагностические ярлыки, а не измеренные метрики.'),
+ heading('Модель начинается с формы входа'),
+ paragraph('Полезная запись выглядит так: endpoint intent + data setup + environment constraint + ordered segments + stop criterion + evidence packet. Каждый элемент отвечает на отдельный вопрос. Endpoint ограничивает предмет. Data setup не даёт одному и тому же имени скрывать разные записи. Environment объясняет, при каком договоре существует наблюдение. Segments показывают переход. Criterion определяет, когда доказательство достаточно. Packet держит все поля рядом, чтобы вывод не зависел от памяти автора.'),
+ paragraph('Если убрать любой элемент, получаем другой тип неопределённости. Без endpoint нельзя отличить чтение от изменения. Без данных нельзя повторить branch. Без среды невозможно увидеть drift. Без сегментов total не показывает порядок. Без criterion нельзя понять, почему модель закончилась именно здесь. Без packet остаётся пересказ, который невозможно проверить. Это не бюрократия вокруг теста: это минимальная структура причинной связи.'),
+ dataTable(
+ 'Профиль и плохой aggregate отвечают на разные вопросы',
+ ['Свойство', 'Профиль fixture', '«Много запросов без сценария»', 'Инженерское последствие'],
+ [
+ ['Endpoint intent', 'один нейтральный путь и метод', 'не указан', 'нельзя проверить контракт входа'],
+ ['Arrival intent', 'slots принадлежат named segment', 'есть только total', 'нельзя увидеть переходы'],
+ ['Среда', 'identity и synthetic constraint записаны', 'не указана', 'любой drift маскируется под результат'],
+ ['Accepted / rejected', 'разложены по segment', 'не определены', 'неясно, что именно не прошло'],
+ ['Stop criterion', 'evidence после recovery', 'нет', 'конец наблюдения произволен'],
+ ['Interpretation', 'только training model', 'обычно звучит как verdict', 'риск выдать счётчик за throughput'],
+ ],
+ ),
+ heading('Arrival не равен completed work'),
+ paragraph('В реальном инструменте способ моделировать arrival зависит от executor, версии и конфигурации. Нельзя переносить значение одного параметра между tool без чтения его документации. В k6 v0.35.0 release notes отдельно связывают stage tags с конкретными executors; это исторический факт о версии, а не лицензия назвать любой массив slots её сценарием. Наша fixture специально не повторяет API инструмента. Она показывает только вопрос, который нужно сформулировать до выбора API: что означает появление следующего planned slot и как эта попытка будет отделена от результата обработки.'),
+ paragraph('Разделение полезно и для закрытой модели пользователей, и для открытой модели arrival. В первом случае нужно назвать, кто ждёт завершения предыдущей итерации. Во втором — как tool ведёт себя при невозможности начать следующую работу. Но оба случая остаются неполными без data setup и environment. Число на оси не заменяет эту информацию. Поэтому article не предлагает универсальный executor и не выдаёт synthetic acceptance limit за реальную настройку генератора.'),
+ heading('Профиль хранит намерение и результат рядом'),
+ paragraph('У каждого segment fixture есть именованные slots, accepted units, rejected units, synthetic bottleneck flag и boundary text. Наличие flag не превращает rejection в ошибку приложения. Это самопроверка модели: step был объявлен как учебная граница до materialization, а не задним числом назван узким местом после просмотра результата. Такая разница особенно важна в текстах про производительность, где красивый график часто даёт больше уверенности, чем его исходные условия.'),
+ paragraph('Проверка reconciliation предельно простая: длина plannedRequestSlots должна равняться acceptedUnits + rejectedUnits. Она не измеряет полезную работу и не заменяет server-side evidence. Зато она ловит редакционную ошибку: если часть plan исчезла между профилем и таблицей, автор больше не может честно сказать, что сравнил одно и то же. Именно такие мелкие несовпадения потом превращают нагрузочную заметку в набор несвязанных сигналов.'),
+ heading('Observability начинается с семантики данных'),
+ paragraph('Наблюдаемость здесь не означает автоматически подключённый dashboard. Сначала нужны хорошо названные поля: endpoint intent, environment identity, data identity, segment name, planned slots, accepted/rejected units и stop state. Затем выбирается настоящий инструмент, который умеет сохранить требуемый raw signal и его контекст. OpenTelemetry Metrics API в historical tag v1.0.0 подчёркивает, что instrument задаёт смысл measurement, а не только форму числа. При этом документ помечен experimental; он не может быть основанием приписать старой системе готовую телеметрию.'),
+ paragraph('Evidence packet полезен потому, что связывает две шкалы: plan и observation. План отвечает, что хотели проверить. Observation отвечает, какие учебные units были materialized. Если packet не содержит environment или criterion, визуализация всё равно может существовать, но reader уже не знает, какую именно гипотезу она проверяет. Поэтому packet не экспортируется как trace и не изображает работу настоящего мониторинга: это object для детерминированной проверки редакционной модели.'),
+ figure(
+ '/assets/editorial/2021/load-test-observation-2021.svg',
+ 'Схема наблюдения учебного workload: сценарий с endpoint, средой, данными и stop criterion проходит через четыре сегмента в evidence packet. Отдельная красная ветка показывает, что один total «много запросов» отклоняется как не-модель. Подписи отделяют synthetic units от latency и throughput.',
+ 'Слева остаётся замысел workload, справа — packet для проверки. Между ними нет HTTP-клиента, генератора, сервера или telemetry pipeline.',
+ ),
+ heading('Synthetic bottleneck нужен для отрицательной проверки'),
+ paragraph('Step в fixture получил пять slots при явном limit в три accepted synthetic units. Модель оставляет два rejected units и поднимает единственный syntheticBottleneckFlag. Это намеренно скучный результат. Его задача — проверить четыре свойства: rejected units видны, flag находится в правильном segment, recovery идёт после step, stop criterion не срабатывает раньше. Если бы модель всегда принимала всё, она не проверяла бы путь, в котором author обязан объяснить границу.'),
+ paragraph('Неправильный вывод звучал бы так: «мы нашли bottleneck и latency выросла». У fixture нет времени, сервера или наблюдаемой очереди, поэтому такой вывод нельзя получить. Правильный вывод уже: «план содержит заранее отмеченную synthetic boundary; для реального расследования нужны environment manifest, raw output выбранного tool и отдельный источник latency-signal». Скромная формулировка оставляет место для следующего эксперимента, а не подменяет его.'),
+ heading('Минимальный пример: проверить packet и отклонить bare count'),
+ paragraph('Этот пример читает mechanism, а не запускает testing software. Он выводит путь как intent, явное ограничение среды и stop object. Последняя assertion возвращает false для aggregate, где есть только synthetic total. Так мы проверяем не способность «создать много», а способность не называть нагрузочной моделью то, что не содержит сценария.'),
+ codeBlock(observationExample),
+ paragraph('В коде нет fetch, http, setTimeout, файла или child process. Это не ограничение языка и не рекомендация для production. Это защита смысла fixture: добавление реального вызова сделало бы её зависимой от внешнего состояния и позволило бы принять случайный ответ за доказательство. Реальный инструмент нужно запускать отдельной операцией с отдельным пакетом условий, а не прятать в редакционный self-check.'),
+ heading('Маршрут: симптом → причина → проверка → действие'),
+ orderedList([
+ 'Симптом. В отчёте есть aggregate или один график, но невозможно назвать endpoint, среду, data setup и момент остановки.',
+ 'Причина. Total принят за workload model; intention, completed work и наблюдение смешаны в одном числе.',
+ 'Проверка. Разделите карточку на endpoint, данные, environment, named segments, accepted/rejected units и criterion. Проверьте порядок warm-up → steady → step → recovery.',
+ 'Проверка смысла. Для каждого поля назовите единицу и запрет. Если unit не имеет времени, не называйте его latency, throughput или RPS.',
+ 'Действие. Отклоните bare count, пока в нём нет scenario и evidence packet. Затем выберите historical version конкретного tool и сверяйте его semantics с его документацией.',
+ 'Ожидаемый результат. Следующая диаграмма объясняет не только, что было нарисовано, но и какой вопрос она вправе помогать расследовать.',
+ ]),
+ heading('Ограничения и следующий проверяемый шаг'),
+ paragraph('Fixture не сообщает, как ведут себя executors k6, как рассчитываются thresholds, как именно агрегирует метрики OpenTelemetry или какие свойства имеет конкретный server. Ссылки на k6 v0.35.0 и его samples/thresholds.js зафиксированы, чтобы не ссылаться на mutable current documentation. Они нужны только для historical boundary и для требования называть metric вместе с порогом. Пакет не исполняет k6, не создаёт VU и не использует его API.'),
+ paragraph('Не моделируются HTTP, сеть, TLS, DNS, scheduler, queue, CPU, память процесса, база, cache, browser, пользователь, latency, throughput, error rate, capacity, production incident или результат benchmark. RFC 2330 добавлен как внешняя рамка аккуратного определения метрик, но он относится к IP performance metrics и не определяет готовность application endpoint. Следующий шаг — описать один реальный tool-run отдельно, не смешивая его raw output с объектами этой учебной модели.'),
+ ],
+ commonSources,
+);
+
+const fieldArticle = createRevision(
+ {
+ slug: 'editorial-2021-11-field-load-testing',
+ title: 'Разбор signal при нагрузке: среда, сценарий или узкое место',
+ categories: ['Нагрузка', 'Надёжность'],
+ cover: '/assets/editorial/2021/load-test-diagnosis-2021.svg',
+ excerpt: 'Полевой маршрут для error-signal и latency-signal без выдуманного инцидента: собрать evidence, отличить environment drift, пробел сценария и synthetic boundary, затем выбрать обратимое действие.',
+ readingMinutes: 15,
+ },
+ [
+ paragraph('После теста часто появляется короткое сообщение: «ошибок стало больше» или «latency ухудшилась». Сам сигнал ещё не говорит, где причина. Цена поспешного диагноза — поменять лимит, базу или код на основании наблюдения, которое пришло из другой среды, другого сценария или вообще не было измерением. Сначала нужно сохранить то, что именно видел автор, а не выбирать виновника по знакомому слову.'),
+ paragraph('Здесь error-signal и latency-signal — только диагностическая модель. Fixture показывает synthetic rejected units в step и прямо хранит latencySignal: not-collected-in-fixture. В ней нет clock, HTTP, server, load tool, сети, базы, telemetry или production incident. Поэтому она не может сказать, что latency росла, а rejection не равен реальной ошибке. Её смысл — разложить проверку причин до того, как появятся данные настоящего запуска.'),
+ heading('Сначала сохраняем evidence, потом меняем систему'),
+ paragraph('Минимальный evidence packet состоит из endpoint intent, environment identity, data setup, профиля, stop criterion, записи accepted/rejected units и diagnostic card. Это не бюллетень для руководителя и не dashboard. Это порядок чтения фактов. Если signal нельзя привязать к endpoint и environment, он ещё не готов для вывода о приложении. Если неизвестен data setup, нельзя отличить изменение входа от изменения реализации. Если отсутствует criterion, нельзя доказать, что наблюдение завершилось по тому же правилу, по которому началось.'),
+ paragraph('Перед изменением полезно добавить два отрицательных вопроса. Что могло поменяться вне кода? И что в самом сценарии может создавать видимость проблемы? Первый вопрос ведёт к environment drift: иной build, настройка, зависимость или manifest. Второй — к scenario gap: total без сегментов, другой endpoint intent, данные без identity или пропущенный recovery. Только после этих проверок можно обсуждать локальную границу, и даже тогда фиксировать её как hypothesis, а не как уже найденный bottleneck.'),
+ dataTable(
+ 'Как не перепутать три причины диагностического signal',
+ ['Кандидат', 'Что увидеть в evidence', 'Чего пока нет', 'Rollback-safe действие'],
+ [
+ ['Environment drift', 'identity среды не совпадает с declared envelope', 'доказательства проблемы приложения', 'вернуть сравнение к зафиксированному manifest; не менять endpoint'],
+ ['Scenario gap', 'нет сегмента, data identity, criterion или endpoint intent', 'сопоставимого workload', 'восстановить последнюю полную карточку и повторить чтение packet'],
+ ['Synthetic bottleneck', 'flag в step и reconciliation slots с units', 'доказательства очереди или latency реального сервиса', 'менять только один model constraint или segment, затем собрать packet заново'],
+ ['Неизвестная реальная причина', 'есть raw signal, но не хватает context', 'достаточной связи между source и effect', 'остановить широкий change и назначить один измеримый следующий шаг'],
+ ],
+ ),
+ heading('Environment drift: сравниваем договор, а не название стенда'),
+ paragraph('Environment drift не надо сводить к «стенд сломан». Сначала сравнивают identity, configuration boundary и присутствие внешних систем с тем, что записано в запуске. В fixture среда называется isolated-training-envelope-v1, а external system помечена not-present. Если кто-то позже читает packet как результат серверного запроса, уже есть явное противоречие: такая интерпретация нарушает договор модели.'),
+ paragraph('Безопасное действие здесь обычно скучное: остановить изменение приложения и вернуть исследование к declared manifest. Это rollback-safe, потому что меняется не рабочая система, а вывод о ней. Нельзя чинить connection pool или добавлять cache, пока не доказано, что наблюдение пришло из той среды, для которой эти действия вообще имеют смысл. Такой шаг не решает настоящую проблему, но он убирает ложную ветку расследования.'),
+ heading('Scenario gap: total не заменяет путь пользователя'),
+ paragraph('Если packet содержит только total, ситуация ещё хуже: нельзя выяснить, был ли warm-up, как выглядел steady, что именно менялось на step и был ли recovery. Агрегат может совпасть с другим запуском случайно, но это не делает их сравнимыми. Правильная реакция — не усреднять ещё сильнее, а восстановить пропавшую структуру: endpoint intent, data setup, segment order, planned slots, правило accepted/rejected и criterion остановки.'),
+ paragraph('В fixture bad aggregate называется many-requests-without-scenario и сразу получает verdict acceptedAsLoadModel: false. Это не осуждение коротких тестов. Коротким может быть и корректный сценарий, если у него ясная граница. Отклоняется другое: попытка назвать диагностикой число, для которого невозможно рассказать, откуда оно взялось и какое действие оно вправе изменить.'),
+ heading('Synthetic bottleneck: видим границу, не придумываем причину'),
+ paragraph('Step fixture содержит больше planned slots, чем разрешает declared synthetic constraint. Поэтому два units отмечены rejected, а flag включён только у этого segment. Это достаточное evidence, чтобы проверить согласованность модели: slots не исчезли, rejection не попал в warm-up, recovery записан после step. Но этого недостаточно, чтобы говорить о thread pool, блокировке, network saturation или ответе базы. Все эти слова относятся к системам, которых здесь нет.'),
+ paragraph('Полезный вопрос после flag звучит так: «какая реальная граница могла бы соответствовать этой гипотезе и каким независимым сигналом её проверить?» Ответ должен быть один. Например, сначала зафиксировать environment и вход, затем выбрать инструмент и raw signal, затем проверить конкретную зависимость. Не надо одновременно менять retry, таймаут, SQL и число генераторов. Широкое изменение уничтожит связь между гипотезой и результатом, даже если итоговое число станет приятнее.'),
+ figure(
+ '/assets/editorial/2021/load-test-diagnosis-2021.svg',
+ 'Диагностическое дерево для signal при нагрузке: сначала сохранить evidence packet, затем проверить environment drift, completeness сценария и synthetic bottleneck flag. Каждая ветка ведёт к обратимому действию; отдельная подпись предупреждает, что latency-signal в fixture не измеряется.',
+ 'Дерево не выбирает виновника автоматически. Оно уменьшает область поиска и запрещает переход от учебной метки к claim о production-причине.',
+ ),
+ heading('Минимальный JS: получить кандидатов, не выполнить изменение'),
+ paragraph('Пример ниже выводит кандидаты из local diagnostic card. Он не собирает новую телеметрию и не запускает correction. Это удобно для review: читатель видит, какое evidence потребуется для каждой ветки и какое действие можно откатить без вмешательства в неизвестную инфраструктуру. Проверка latency-signal в конце намеренно требует not-collected-in-fixture.'),
+ codeBlock(diagnosisExample),
+ paragraph('Rollback-safe здесь означает обратимость расследования. Для drift мы возвращаемся к declared manifest. Для scenario gap — к последней полной карточке. Для synthetic boundary — к одному изменённому model constraint или segment. Все три действия сохраняют original packet и не требуют менять runtime системы. Если настоящая среда уже изменилась, её откат — отдельная операция с owner, правами и планом; из учебного object такой команды не получить.'),
+ heading('Как выбрать одно действие после классификации'),
+ paragraph('Если environment identity различается, следующее действие — зафиксировать разницу и не сравнивать numbers между envelope. Если отсутствует сценарий, действие — восстановить route и проверить assertions, не запускать ещё один «большой» total. Если synthetic flag единственный, порядок segments корректен, а slots reconcile, можно изменить одну переменную модели и посмотреть, остаётся ли packet читаемым. Если ни одна ветка не подтверждена, verdict должен остаться «недостаточно evidence». Это нормальный результат расследования, а не провал.'),
+ paragraph('После появления настоящего raw output порядок не меняется. Нужно добавить точную версию инструмента, command/configuration, окружение, data setup, окно наблюдения и способ связать signal с endpoint. И только потом сопоставлять observed error или latency с конкретной гипотезой. Документация k6 v0.35.0 и historical OpenTelemetry tag помогают не спутать название инструмента со свойством модели, но ни один из этих источников не содержит результат вашего endpoint.'),
+ heading('Маршрут: симптом → причина → проверка → действие'),
+ orderedList([
+ 'Симптом. Появился error-signal или latency-signal, но его origin, среда или сценарий не приложены к сообщению.',
+ 'Причина-гипотеза. Сначала разделите environment drift, scenario gap и явную synthetic boundary. Не называйте реальный bottleneck до независимого evidence.',
+ 'Проверка среды. Сверьте environment identity и constraint с declared packet. При несовпадении остановите вывод о коде.',
+ 'Проверка сценария. Проверьте endpoint, data setup, порядок warm-up → steady → step → recovery, criterion и reconciliation slots с units.',
+ 'Проверка границы. Убедитесь, что flag находится только в объявленном step и diagnostic model не называет units latency или throughput.',
+ 'Действие. Выберите одно обратимое действие для подтверждённой ветки; сохраните исходный packet. Для реальной причины подготовьте отдельный измеримый запуск.',
+ 'Ожидаемый результат. Следующий change связан с проверяемой гипотезой, а не с самым тревожным словом на графике.',
+ ]),
+ heading('Историческая граница и ограничения'),
+ paragraph('Использованные ссылки намеренно закреплены на версиях до конца ноября 2021 года. k6 v0.35.0 опубликован 17 ноября; его sample объясняет, что threshold связывается с выбранной метрикой внутри инструмента. OpenTelemetry v1.0.0 Metrics API в historical snapshot имеет experimental status, а Trace API задаёт терминологию trace/span. Эти документы помогают формулировать вопросы к настоящей телеметрии, но не делают packet реализацией k6 или OpenTelemetry.'),
+ paragraph('Не было реального прогона, latency, RPS, error rate, пользователей, capacity, production incident или raw result. Fixture не выполняет HTTP, сеть, tool, server, database, browser, clock, filesystem или process. Synthetic rejected units не равны HTTP error; latency-signal зафиксирован как not collected. Следующий проверяемый шаг — подготовить отдельный real-test protocol с владельцем, разрешённой средой, rollback plan и способом хранить raw evidence. До него честнее оставить verdict узким: модель проверила собственный порядок, не систему.'),
+ ],
+ commonSources,
+);
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
+ .map(({ proseLength, ...revision }) => revision);
+
+const isMainModule = process.argv[1]
+ && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isMainModule) {
+ if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions));
+ } else if (process.argv.includes('--verify-fixture')) {
+ const fixture = runLoadTestingFixture();
+ if (!Object.values(fixture.assertions).every(Boolean)) {
+ throw new Error('fixture assertions must all be true');
+ }
+ process.stdout.write(JSON.stringify(fixture, null, 2) + '\n');
+ } else {
+ process.stderr.write('Usage: node web/scripts/upgrade-2021-11.mjs --print-revisions | --verify-fixture\n');
+ }
+}