diff --git a/editorial/agent-rewrites/001.json b/editorial/agent-rewrites/001.json new file mode 100644 index 0000000..e4aef68 --- /dev/null +++ b/editorial/agent-rewrites/001.json @@ -0,0 +1,7 @@ +{ + "index": 1, + "slug": "editorial-2027-12-field-author-manifesto", + "title": "Эксплуатационная инструкция: от симптома к проверенному откату", + "excerpt": "Как описать опасную операцию так, чтобы оператор видел область сбоя, условия запуска, одно обратимое действие, сигнал остановки и критерий восстановления.", + "contentHtml": "

Проблема эксплуатационной инструкции видна в первый сбой: сервис отвечает ошибками, а оператор не понимает, с какого шага начать и как ограничить воздействие. Цена неточного текста — несколько людей одновременно меняют состояние системы, исходные метрики теряются, а откат объявляют успешным только потому, что команда завершилась без ошибки.

Типичный симптом нужно описать наблюдаемыми признаками: например, «5xx выше 5% на POST /payments в одном регионе», а не «сервис нездоров». Если инструкция не задаёт область, условие запуска и проверку результата, читатель может отключить здоровый трафик, выполнить команду на другой версии или вернуть конфигурацию без восстановления данных.

Тезис: инструкция описывает границы действия

Хороший runbook не перечисляет команды ради полноты. Он связывает симптом с областью, предварительным условием, одним изменением, обратным действием и наблюдаемым результатом. Каждый шаг отвечает на четыре вопроса: что должно быть истинно до команды, что изменится, какой вывод ожидается и когда нужно остановиться.

Такой порядок отделяет факт от гипотезы. Сначала оператор сохраняет сигнал и ограничивает scope. Затем меняет один рычаг. После этого ждёт заданное окно и сравнивает метрику с критерием. Если критерий не выполнен или появился новый риск, оператор запускает rollback по заранее описанному условию. Окончание команды не равно восстановлению сервиса.

Механизм проверяемой операции

Symptom фиксирует метрику, endpoint и время. Scope показывает, кого затронула проблема: регион, релиз, долю трафика или конкретный tenant. Precondition подтверждает доступ, версию, наличие backup или сохранённого dashboard. Action меняет одно состояние. Rollback возвращает его при указанном условии. Verification задаёт метрику и окно наблюдения.

Эти поля защищают от разных ошибок. Без scope оператор расширит локальный сбой до всей системы. Без precondition он применит команду к неправильной версии. Несвязанные команды усложнят причинность: после них нельзя понять, что помогло. Без verification текст заканчивается слишком рано — на синтаксически успешном вызове, а не на подтверждённом результате.

Модальность тоже должна быть явной. Слова «проверьте», «убедитесь» и «при необходимости» ничего не задают, пока рядом нет объекта и критерия. В терминах RFC 2119 обязательное условие можно пометить как MUST, допустимое исключение — как SHOULD, необязательную диагностику — как MAY. В русской инструкции достаточно написать «обязательно», «рекомендуется» или «можно», если правило остаётся однозначным.

Перед изменением состояния сохраните наблюдаемый факт. Нужен минимальный набор, по которому можно сравнить до и после: scope, версия, временное окно и исходная метрика. Для опасной команды укажите право доступа, namespace и способ увидеть diff. Не вставляйте секреты, настоящие hostname и локальные alias, которых читатель не сможет проверить.

Симптом, причина, проверка и действие
СимптомВероятная причинаПроверкаДействие
5xx выше порога на одном endpointНовая версия или флаг затрагивает ограниченный scopeСравнить release, регион, долю трафика и error rate за одинаковое окноОграничить воздействие флагом; при ухудшении вернуть его
Timeout растёт, 5xx не меняетсяЗависимость отвечает медленно или исчерпан ресурсСопоставить p95/p99, trace и лимиты пула с baselineНе повторять запросы вслепую; остановить изменение и проверить зависимость
409 при повторной операцииСостояние уже изменено или нарушена идемпотентностьПроверить operation id, запись состояния и время первого вызоваНе выполнять повторно; выбрать безопасный путь чтения или ручного разбора
После отката ошибка остаётсяОткатил один рычаг, но причина вне его scopeСравнить метрики до, после action и после rollbackОстановиться, зафиксировать результат и передать расследование владельцу
Команда завершилась успешноИзменился только control plane, data plane ещё не восстановленПроверить пользовательский сигнал и заданное окно наблюденияНе закрывать инцидент до прохождения verification
Петля эксплуатационной инструкции: симптом и предварительное условие ведут к одному действию, затем к проверке метрики и условному откату.
Операция считается завершённой после наблюдаемой проверки. Если сигнал не достиг критерия, инструкция возвращает оператора к безопасной остановке или откату.

Минимальный рабочий пример

Ниже — учебная проверка карточки runbook. Функция принимает шесть полей и отклоняет объект без содержательного rollback или наблюдаемой verification. Она не проверяет права, shell, облако и реальную исполнимость команды. Её задача — поймать структурный пробел до публикации инструкции.

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

В примере строка с результатом показывает порядок полей, а не разрешение выполнить операцию. В настоящем runbook команда должна ссылаться на локальный механизм изменения и на конкретный сигнал. Если эти сведения нельзя проверить, их нужно оставить как явно обозначенные placeholders, а не заполнять вымышленными значениями.

Порядок редакторской проверки

  1. Запишите симптом в первых двух абзацах: метрика, объект, временное окно и цена ошибки. Не начинайте с инструмента или команды.
  2. Сузьте scope до endpoint, региона, версии, доли трафика или другой проверяемой границы. Слово «все» требует отдельного доказательства.
  3. Перед действием перечислите precondition: доступ, версия, backup, lock, сохранённые метрики и разрешённый объём воздействия.
  4. Оставьте одно изменение на шаг. Рядом укажите ожидаемый output и способ увидеть diff, чтобы результат можно было отличить от совпадения.
  5. Опишите rollback как действие и условие. Формулировка «откатить при проблеме» не говорит, что считать проблемой и когда начинать возврат.
  6. Задайте verification: метрика, порог, сегмент и окно наблюдения. Сравните результат с тем же scope, который был зафиксирован до изменения.
  7. Добавьте соседние ветки для timeout, 409 и ошибки после отката. Один симптом не должен автоматически вести к одному и тому же действию.
  8. Проверьте учебный пример на принятом и неполном объекте. После этого удалите команды, которые нельзя безопасно воспроизвести.

Ограничения

Структурная проверка не знает, кому разрешён доступ, как устроены shell и облако, есть ли backup, lock и реальные пороги. Она не выполняет команду и не доказывает, что rollback действительно возвращает исходное состояние. Поэтому runbook нельзя считать разрешением менять production: approval, окно операции и владельца изменения задаёт локальный процесс.

NIST SP 800-61 задаёт общий цикл работы с инцидентом: подготовку, обнаружение, анализ, containment, восстановление и действия после инцидента. Документ не знает ваших сервисных зависимостей, команд и порогов остановки. RFC 2119 помогает различить обязательное и рекомендуемое действие, но не тестирует исполнимость конкретной команды.

Не добавляйте в текст вымышленные замеры или производственные результаты ради гладкого примера. Учебная карточка должна оставаться учебной и не содержать секретов, настоящих адресов и необратимых команд. Если значение неизвестно, лучше назвать, где его проверить, чем подменить неизвестное числом.

Критерий готовности

Инструкция готова, когда читатель может по первым абзацам определить симптом и scope, до команды проверить precondition, выполнить одно ограниченное действие, увидеть ожидаемый output, запустить rollback по явному условию и подтвердить восстановление метрикой за заданное окно. Если для любого шага нельзя назвать наблюдаемый результат, текст ещё не готов: вернитесь к условию, границе или проверке.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/002.json b/editorial/agent-rewrites/002.json new file mode 100644 index 0000000..746cae4 --- /dev/null +++ b/editorial/agent-rewrites/002.json @@ -0,0 +1 @@ +{"index":2,"slug":"editorial-2027-12-mechanism-author-manifesto","title":"Web performance budget: как читать LCP, INP и CLS вместе","excerpt":"Разбираем, почему один показатель не объясняет скорость интерфейса, как разделить LCP, INP и CLS и превратить наблюдение в проверяемое действие.","contentHtml":"

Проблема начинается с отчёта «страница медленная». Такой симптом не говорит, что именно увидел пользователь: крупнейший блок появился поздно, клик обработался с задержкой или контент прыгнул под пальцем. Цена ошибки — чинить не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить загрузку, оставив интерфейс заблокированным длинной задачей.

\n

Причина — свести LCP, INP и CLS к одному среднему score. Эти метрики отвечают на разные вопросы и требуют разных разрезов данных. LCP описывает появление крупнейшего видимого элемента, INP — задержку взаимодействия, CLS — неожиданные сдвиги layout. Сначала нужно определить тип симптома и границу измерения, потом выбирать изменение в коде.

\n

Тезис: performance budget состоит из нескольких проверяемых границ

\n

Budget — это не красивое число в дашборде. Это набор порогов, сегментов, периода и действий владельца. Для каждого показателя нужно знать, какую часть опыта он описывает, где искать причину и что считать регрессией. Рекомендованные границы Core Web Vitals — LCP до 2500 мс, INP до 200 мс и CLS до 0,1. Они помогают классифицировать опыт, но не доказывают причину.

\n

Для полевых данных используйте percentile, а не только среднее. Например, p75 показывает значение, ниже которого находится 75 процентов наблюдений в выбранном сегменте. Смешивать в одной строке мобильные и десктопные устройства, разные версии и разные периоды нельзя: итог потеряет смысл. В budget явно запишите URL, устройство, соединение, release и окно наблюдения.

\n

Механизм: три метрики — три объекта диагностики

\n

LCP фиксирует момент, когда крупнейший контентный элемент стал видимым в пределах загрузки. На результат влияют TTFB, критический CSS, изображение, шрифт, сеть и работа браузера. Поэтому плохой LCP — сигнал разобрать цепочку, а не команда сразу добавить preload или сжать картинку.

\n

INP оценивает задержку после взаимодействий пользователя. Ищите конкретное interaction, обработчик и long task на main thread. Причиной может быть тяжёлый обработчик, сторонний скрипт, лишняя синхронная работа или слабое устройство. Быстрый LCP не означает, что интерфейс быстро отвечает после ввода.

\n

CLS суммирует неожиданные сдвиги layout. Типовые источники — изображение без зарезервированных размеров, поздняя реклама, вставленный сверху контент или изменение шрифта. Для проверки нужен shifted element и момент сдвига. Удаление одного долгого скрипта не исправит CLS, если браузер по-прежнему не знает размеры блока.

\n
Как переводить симптом в проверку
СимптомВероятная причинаПроверкаДействие
LCP выше 2500 мсTTFB, ресурс, CSS или шрифтelement timing, TTFB, waterfallУскорить критический путь и повторить замер
INP выше 200 мсlong task или тяжёлый handlerinteraction trace и main threadРазбить работу или перенести её после ответа
CLS выше 0,1Нет места под изображение или вставкуshifted element и layout traceЗарезервировать размер и стабилизировать layout
Среднее хорошее, p75 плохойДлинный хвост или смешанные сегментыp75 по URL, устройству и releaseРазделить сегменты и назначить владельца
Lab и field расходятсяРазная среда и состав трафикаСопоставить условия запускаНе подменять один тип данных другим
\n

Минимальный рабочий пример

\n

Классификатор ниже принимает уже собранные значения и возвращает статус каждой метрики. Он показывает механику budget, но не собирает браузерные данные и не считает percentile. Реальные значения нужно получать через подходящие API наблюдения, сохранять с контекстом и агрегировать по сегментам.

\n
function classifyWebVitals({ lcpMs, inpMs, cls }) {\n  if (![lcpMs, inpMs, cls].every((value) => Number.isFinite(value) && value >= 0)) {\n    return { ok: false, reason: 'vital-input-invalid' };\n  }\n\n  const lcp = lcpMs <= 2500 ? 'good' : lcpMs <= 4000 ? 'needs-improvement' : 'poor';\n  const inp = inpMs <= 200 ? 'good' : inpMs <= 500 ? 'needs-improvement' : 'poor';\n  const layout = cls <= 0.1 ? 'good' : cls <= 0.25 ? 'needs-improvement' : 'poor';\n  const statuses = [lcp, inp, layout];\n  const overall = statuses.includes('poor') ? 'poor'\n    : statuses.includes('needs-improvement') ? 'needs-improvement'\n    : 'good';\n\n  return { ok: true, lcp, inp, layout, overall };\n}\n\nconsole.log(classifyWebVitals({ lcpMs: 2180, inpMs: 240, cls: 0.08 }));\n// { ok: true, lcp: 'good', inp: 'needs-improvement', layout: 'good', overall: 'needs-improvement' }
\n

Порог в функции нужен для triage. Он не отвечает на вопрос «какой код виноват». Даже улучшение LCP после preload не доказывает, что preload был единственной причиной: могли измениться сервер, кэш или состав трафика.

\n
\"Матрица
Один score скрывает разные механизмы. Для каждой метрики нужен собственный объект проверки и действие.
\n

Порядок работы

\n
  1. Определите URL, сегмент, percentile и окно наблюдения. Не сравнивайте p75 мобильного трафика со средним по всем устройствам.
  2. Для плохого LCP найдите element и разделите TTFB, загрузку ресурса и отрисовку. Для INP найдите interaction и long task. Для CLS найдите shifted element.
  3. Сформулируйте один budget на релиз и один диагностический сигнал. Не блокируйте сборку по метрике, которую CI не может воспроизвести.
  4. Сопоставьте лабораторные и полевые данные отдельно. Lighthouse удобен для воспроизводимого CI, field data показывает реальное разнообразие сети и устройств.
  5. Измените один тяжёлый участок: критический ресурс, обработчик, размеры изображения или резервирование места.
  6. Повторите измерение тем же сегментом и окном. Улучшение LCP не закрывает автоматически INP и CLS.
\n

Ограничения и критерий готовности

\n

Локальный запуск классификатора не является field evidence. W3C LCP и Performance Timeline описывают API и объекты измерения, но не проверяют вашу агрегацию, sampling или дашборд. Порог web.dev — практическая рекомендация, а не гарантия UX и не причинная модель. Внешние скрипты, кэш, браузер, сеть и состав аудитории могут изменить результат.

\n

Оптимизация одной метрики может ухудшить другую. Сжатие изображения уменьшает передаваемый объём, но может добавить CPU-декодирование или снизить качество. Разделение JavaScript способно помочь INP, но добавить запросы и повлиять на LCP. Рядом с Core Web Vitals проверяйте размер ресурсов, long tasks и ошибки.

\n

Готовность наступает, когда для выбранного URL есть p75 LCP, INP и CLS по явно названным сегментам; для каждой плохой строки указан element, interaction или shifted element; изменение повторено тем же способом; соседние метрики не ухудшились. Если одного поля нет, вывод нужно назвать неполным, а не превращать его в общий score.

\n

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/003.json b/editorial/agent-rewrites/003.json new file mode 100644 index 0000000..de80121 --- /dev/null +++ b/editorial/agent-rewrites/003.json @@ -0,0 +1 @@ +{"index":3,"slug":"editorial-2027-12-practice-author-manifesto","title":"Security headers: CSP и HSTS без иллюзии защиты","excerpt":"Как CSP и HSTS ограничивают браузер, почему nonce и HTTPS не заменяют исправление XSS и как вводить политики с проверяемым откатом.","contentHtml":"

Проблема становится видимой после XSS или downgrade-атаки: приложение отдаёт страницу по HTTPS, но разрешает произвольный inline-скрипт или продолжает открываться по HTTP. Симптомы можно увидеть в DevTools: CSP фиксирует нарушение или отсутствует вовсе, а HTTP-запрос сначала доходит до редиректа. Цена ошибки — выполнение чужого кода в контексте origin, утечка токена и потеря данных. Один security header не закрывает весь риск.

Ошибка начинается с копирования длинной строки заголовка без карты ресурсов и момента включения политики. CSP управляет тем, откуда страница может загружать и выполнять ресурсы. HSTS говорит браузеру обращаться к домену по HTTPS после получения политики через доверенный HTTPS-ответ. Ни один заголовок не исправляет серверный XSS, плохой сертификат, mixed content или секрет, который уже попал в JavaScript.

Тезис: заголовок — это граница, а не доказательство безопасности

Политику нужно проектировать от конкретной страницы. Сначала назовите нужные скрипты, стили, изображения, API и фреймы. Затем разрешите только эти источники и отдельно проверьте отрицательные сценарии. Так CSP становится исполняемым контрактом браузера, а не декоративной строкой в конфигурации.

default-src задаёт запасное правило для типов ресурсов, но не заменяет явные директивы. script-src управляет JavaScript. object-src 'none' закрывает загрузку plugin/object, если она не нужна. base-uri 'self' ограничивает изменение базового URL. Такая политика уменьшает поверхность атаки, но не превращает небезопасный sink в безопасный.

Механизм CSP

Браузер получает Content-Security-Policy вместе с ответом и сопоставляет каждую попытку загрузки или выполнения с директивой. При несовпадении ресурс блокируется в режиме enforce. В режиме Report-Only браузер отправляет нарушение для анализа, но не блокирует действие. Поэтому отчёт помогает собрать карту, но сам по себе не является исправлением.

Nonce нужен для конкретного inline-скрипта, который пока нельзя вынести в файл. Сервер генерирует непредсказуемое значение заново для каждого ответа, вставляет его в CSP и в атрибут нужного script. Статическая строка не даёт защиты: тот, кто её узнает, получает разрешение. Полный nonce не стоит писать в диагностические логи.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Inline-скрипт заблокированНет nonce или разрешение слишком узкоеСверить директиву script-src и атрибут nonceВынести код в файл или выдать свежий nonce только этому скрипту
В отчёте много нарушенийПолитика собрана без карты ресурсовСгруппировать отчёты по директиве, URL и типу ресурсаУдалить лишние источники и обосновать каждый оставшийся
Сайт всё ещё доступен по HTTPБраузер ещё не получил HSTS или первый переход был HTTPПроверить заголовок в HTTPS-ответе и цепочку переходовИсправить HTTPS и redirect; отдельно оценить требования preload
Поддомен перестал открыватьсяincludeSubDomains включили до проверки всех имёнПроверить сертификаты, HTTPS и endpoint каждого поддоменаРасширять область и max-age только после контролируемой проверки
После CSP остаётся XSSНебезопасный HTML или sink не исправленПроверить контекстное экранирование и места вроде innerHTMLИсправить источник XSS; CSP оставить дополнительным барьером
\"Граф
Два независимых слоя защиты: CSP управляет ресурсами страницы, HSTS — схемой соединения. Один заголовок не заменяет другой.

Минимальный рабочий пример

Ниже — учебная функция, которая собирает заголовки из nonce и срока HSTS. Она не устанавливает заголовки в серверном ответе и не проверяет шаблонизатор. Проверяемый результат показывает только форму политики: nonce присутствует в CSP, а HSTS содержит числовой срок и includeSubDomains.

import { buildSecurityHeaders } from './security-headers.mjs'; const result = buildSecurityHeaders({ nonce: '7c2f1b8e9a4d6f0c', hstsMaxAge: 31536000 }); if (!result.ok) throw new Error(result.reason); response.setHeader('Content-Security-Policy', result.headers['Content-Security-Policy']); response.setHeader('Strict-Transport-Security', result.headers['Strict-Transport-Security']);

В реальном шаблоне nonce должен попасть только в разрешённый inline-скрипт:

<script nonce=\"7c2f1b8e9a4d6f0c\">startApplication();</script>

Пример не доказывает, что приложение безопасно. Он проверяет сборку значения и показывает границу между генерацией заголовка и его применением. Тесты должны отдельно проверить отсутствие случайного unsafe-inline, соответствие nonce в двух местах и отправку заголовков только по нужному маршруту.

Механизм HSTS

HSTS начинает действовать после того, как браузер получил Strict-Transport-Security через доверенный HTTPS-ответ. Большой max-age не защищает самый первый HTTP-переход, если домен ещё неизвестен браузеру. Он также не исправляет сертификат или mixed content. Для защиты первого перехода существует preload с отдельными требованиями и риском: сначала нужно проверить готовность домена.

includeSubDomains распространяет правило на поддомены. Включайте его только после проверки всех имён, которые ещё нужны пользователям, API и административным инструментам. Если один поддомен не умеет HTTPS, браузер перестанет подключаться к нему по HTTP на весь срок политики. Возможность отката — часть безопасности, а не последняя строка runbook.

Порядок внедрения

  1. Соберите карту ресурсов страницы: scripts, inline-код, eval, стили, изображения, CDN, API, iframe и object. Зафиксируйте нужные источники до написания политики.
  2. Включите CSP в Report-Only. Соберите нарушения по директиве, URL и типу ресурса. Не называйте этот режим блокировкой.
  3. Удалите лишние источники. Inline-код вынесите в файл или замените на nonce. Оставшиеся исключения объясните рядом с конфигурацией.
  4. Переведите CSP в enforce на одной проверяемой странице. Сравните ошибки загрузки с разрешённым списком и проверьте отрицательные сценарии.
  5. Проверьте HTTPS для основного домена и поддоменов. Только после этого добавляйте HSTS, затем осторожно расширяйте max-age и область.
  6. Добавьте версионируемый откат и автоматическую проверку заголовков. Изменение не должно зависеть от ручной правки одного proxy.

Ограничения и критерий готовности

Nonce не санитизирует пользовательский HTML и не исправляет небезопасный sink. Если приложение передаёт строку в innerHTML, разрешённый bootstrap может помочь атакующему выполнить уже загруженный код. Нужны контекстное экранирование и безопасные API. CSP снижает последствия и обнаруживает часть нарушений, но не заменяет исправление причины.

Эта схема не проверяет совместимость целевых браузеров, CDN, service worker, iframe-политику, сертификаты, preload и настройки API-клиента. CSP Level 3 остаётся Working Draft, поэтому директивы и поддержку следует сверять с целевыми браузерами. RFC 6797 описывает HSTS, но не даёт защиты до первого безопасного ответа.

Готовность наступает, когда для конкретной страницы есть карта ресурсов, Report-Only нарушения разобраны, enforce проверен, HSTS подтверждён на каждом нужном домене, а откат воспроизводится. Дополнительный признак — автоматическая проверка заголовков ловит возврат широкого источника или исчезновение обязательной политики. Без этих проверок строка конфигурации остаётся предположением.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/004.json b/editorial/agent-rewrites/004.json new file mode 100644 index 0000000..e233021 --- /dev/null +++ b/editorial/agent-rewrites/004.json @@ -0,0 +1 @@ +{"index":4,"slug":"editorial-2027-11-field-mistakes-revisions","title":"Incident runbook: как пройти от симптома к проверенному rollback","excerpt":"Практический маршрут инцидента: зафиксировать симптом и границу влияния, проверить одну гипотезу, выполнить обратимое действие и убедиться, что восстановилась система, а не только один график.","contentHtml":"

Проблема во время инцидента начинается с фразы «сервис упал после релиза». Она смешивает наблюдение, время и причинность. На практике симптом может выглядеть иначе: HTTP 503, рост p95 latency, очередь сообщений или ошибки только у одного метода и в одном регионе.

Цена такой неточности — лишние изменения и потеря следов причины. Оператор перезапускает всё подряд, меняет несколько параметров или откатывает код, не проверив совместимость со схемой базы. Ошибка исчезает на минуту, а команда не знает, что именно помогло и не осталось ли повреждённое состояние.

Тезис: runbook должен ограничивать неопределённость

Хороший runbook не угадывает первопричину за оператора. Он превращает тревожный сигнал в короткий цикл: зафиксировать факт, сузить область, сформулировать гипотезу, выполнить одну обратимую проверку и измерить результат. Если гипотеза не подтверждается, инструкция должна вести к следующей проверке, а не подталкивать к тому же действию.

Начинайте с полей, которые можно заполнить без интерпретации: timestamp, affected endpoint, регион, версия, доля ошибок, latency, saturation и baseline. Запись «всё медленно» не задаёт ни области поиска, ни критерия улучшения. Запись «POST /payments, EU, 5xx 8%, p95 1,8 с, baseline — предыдущее окно» уже позволяет выбрать следующий шаг.

Механизм: сигнал, гипотеза, действие, восстановление

Статус 500, задержка и error rate описывают разные стороны одного события. Они могут иметь общий корень, но не обязаны. Поэтому классификация сигнала должна быть детерминированной и прозрачной: высокий приоритет получает отказ или превышение error threshold, затем проверяется latency threshold. Классификатор помогает выбрать срочность, но не доказывает причину.

Гипотеза связывает наблюдение с проверяемым признаком: «после изменения лимита pool выросло ожидание соединения; проверка — метрика pool wait в том же временном окне». В формулировке должны быть условие и ожидаемый сигнал. Гипотеза «виноват backend» слишком широкая: её нельзя опровергнуть одним локальным измерением.

Первое изменение должно уменьшать blast radius. Подойдут отключение feature flag, остановка нового consumer или перевод небольшой доли трафика на старую версию — если такой путь предусмотрен архитектурой. Перезапуск без фиксации метрик может убрать симптом и одновременно удалить полезный контекст.

\"Петля
Каждое действие в runbook должно иметь ожидаемый эффект, условие возврата и отдельную проверку результата.

Минимальный рабочий пример

Ниже — небольшая чистая функция. Она принимает HTTP status, latency и error rate, применяет два явно заданных порога и возвращает классификацию. Числа в примере учебные: функция не знает бизнес-критичность endpoint, не отправляет уведомления и не выбирает rollback.

function classifySignal({ status, latencyMs, errorRate, latencyLimitMs = 1000, errorLimit = 0.05 }) {\n  if (!Number.isInteger(status) || !Number.isFinite(latencyMs) || !Number.isFinite(errorRate)) {\n    return { ok: false, reason: 'signal-invalid' };\n  }\n  if (status >= 500 || errorRate >= errorLimit) {\n    return { ok: true, severity: 'high', reason: 'availability-or-error-threshold' };\n  }\n  if (latencyMs >= latencyLimitMs) {\n    return { ok: true, severity: 'medium', reason: 'latency-threshold' };\n  }\n  return { ok: true, severity: 'low', reason: 'signal-below-threshold' };\n}\n\nconsole.log(classifySignal({ status: 503, latencyMs: 820, errorRate: 0.08 }));\n// { ok: true, severity: 'high', reason: 'availability-or-error-threshold' }

Проверяемость здесь важнее полноты. Для одинакового входа функция возвращает одинаковый результат; невалидный status или нечисловая метрика не превращаются в уверенный severity. Реальный runbook должен отдельно описать источник каждой метрики, допустимое окно и владельца действия.

Карта диагностики

Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаПервое действие
5xx растёт сразу после выкатаНовая версия или flag затронули часть трафикаСравнить версии, долю трафика и error rate по endpointОграничить flag или traffic split, затем повторить измерение
p95 растёт, 5xx не меняетсяОжидание соединения, CPU или внешнего ответаСопоставить latency breakdown, saturation и pool waitНе перезапускать всё; убрать один изменённый лимит, если он подтверждён
Очередь увеличивается после откатаConsumer остановлен или не справляется с потокомПроверить lag, rate обработки и ошибки consumerВернуть только совместимый consumer или остановить входной поток по policy
5xx снизился, операции не завершаютсяСервис доступен, но есть незавершённое состояниеСверить успешные операции, очередь, дубли и состояние данныхНе закрывать инцидент; запустить recovery-проверку и сохранить факты
Timeout при rollbackИзменение не применилось или control plane недоступенПроверить фактическую версию и audit log, а не только код командыОстановить новые изменения и выбрать заранее описанный ручной путь

В таблице указаны направления проверки, а не универсальные причины. Один симптом может иметь несколько объяснений. Оператор должен подтвердить выбранную ветку своим сигналом и записать результат рядом с timestamp.

Rollback не равен восстановлению

Rollback возвращает конфигурацию, feature flag или binary. Recovery означает, что система снова выполняет допустимую работу и данные остаются согласованными. Можно успешно вернуть старую версию и оставить очередь, двойные записи или несовместимые события. Поэтому после rollback проверяйте не только 5xx, но и lag, успешность операций, saturation и консистентность данных.

До отката проверьте совместимость. Старая версия должна читать текущую схему базы и понимать события, которые уже создала новая версия. Если перед инцидентом была миграция, порядок действий нельзя восстанавливать по памяти: в runbook нужны матрица совместимости, ожидаемый эффект, риск и способ вернуть сам rollback.

Критерий завершения должен быть измеримым. Формулировка «график выглядит лучше» не подходит. Нужны конкретные условия: error rate ниже согласованного порога, latency вернулась к допустимому диапазону, очередь не продолжает расти, а контрольная бизнес-операция проходит. Интервал наблюдения зависит от окна метрики и характера нагрузки; его задают заранее, а не в момент усталости.

Порядок действий

  1. Запишите timestamp, scope, версию и один измеримый симптом. Сохраните ссылку на dashboard и baseline.
  2. Проверьте границу влияния: регионы, методы, версии, долю трафика и затронутые зависимости.
  3. Сформулируйте одну гипотезу и одну проверку. Для проверки заранее назовите сигнал, который должен измениться.
  4. Выберите одно обратимое действие. Запишите ожидаемый эффект, риск и условие возврата до выполнения команды.
  5. После действия выдержите заданное окно и сравните error rate, latency, saturation, очередь и бизнес-сигнал.
  6. Если сигнал не улучшился, остановите ветку и зафиксируйте отрицательный результат. Не добавляйте второе изменение, пока не понятен эффект первого.
  7. После стабилизации сохраните timeline, фактическое состояние версии и оставшиеся риски. Не удаляйте временные изменения до проверки recovery.

Ограничения и критерий готовности

Этот маршрут не заменяет on-call график, права доступа, SLO, уведомления и локальную матрицу severity. Классификатор не видит traces, не отличает бизнес-критичный endpoint от второстепенного и не знает, можно ли безопасно повторить операцию. Порог 5% и latency 1000 мс в примере не являются рекомендацией для конкретной системы.

Runbook готов, если оператор может без устного контекста ответить на пять вопросов: какой факт зафиксировать, где проходит граница влияния, какую гипотезу проверить, какое действие обратимо и по какому сигналу считать recovery подтверждённым. Для каждого опасного шага должны существовать условие остановки и путь возврата.

Минимальная проверка инструкции — проиграть один сценарий на тестовом окружении с контролируемым 503 и пройти ветку до конца. Такой прогон проверяет структуру runbook, но не доказывает поведение production, отсутствие флаков или безопасность реального rollback. Фактические результаты конкретной системы нужно приложить отдельно.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/005.json b/editorial/agent-rewrites/005.json new file mode 100644 index 0000000..8ca88b0 --- /dev/null +++ b/editorial/agent-rewrites/005.json @@ -0,0 +1,7 @@ +{ + "index": 5, + "slug": "editorial-2027-11-mechanism-mistakes-revisions", + "title": "Retry без шторма: как повторять HTTP-запросы безопасно", + "excerpt": "Повтор запроса помогает пережить временный сбой только после проверки идемпотентности, статуса, Retry-After и общего deadline. Разбираем backoff, jitter и отрицательный путь для записей.", + "contentHtml": "

Симптом обычно выглядит просто: upstream отвечает 503 или 429, клиент ждёт timeout, а затем несколько раз отправляет тот же запрос. Если таких клиентов много, восстановление превращается во вторую волну нагрузки. Растут latency и очередь, здоровые запросы получают меньше ресурсов. Для POST цена выше: сервер мог принять запись до разрыва соединения, а повтор может создать второй заказ, платёж или задачу.

\n

Тезис статьи короткий: retry — это решение о семантике, а не число попыток в конфиге. Сначала нужно понять, можно ли безопасно повторить операцию и как узнать результат первой попытки. Только после этого выбирают статус, задержку, jitter и предел. Backoff не делает небезопасную запись безопасной. Idempotency key не отменяет deadline и не заменяет проверку состояния.

\n

Механизм ошибки

\n

У клиента есть два независимых вопроса. Первый: разрешён ли повтор. Второй: когда его отправить. Код ответа и HTTP-метод помогают ответить на первый вопрос, но не описывают всю бизнес-семантику. GET обычно читает ресурс. PUT и DELETE относятся к идемпотентным методам по смыслу HTTP: повтор не должен менять запрошенный эффект. POST часто создаёт новую операцию, поэтому таймаут оставляет результат неизвестным.

\n

Неизвестный результат важнее слова «ошибка». Клиент мог отправить тело, сервер мог записать данные, а ответ мог потеряться при обратной передаче. В этом случае повтор — не восстановление связи, а новая попытка выполнить команду. Для записи нужен один из трёх путей: idempotency key с серверной дедупликацией, запрос статуса операции или контракт, который делает повтор фактически идемпотентным. Если ни одного пути нет, автоматический retry должен закончиться отказом с сохранением диагностического контекста.

\n

Коды 429 и 503 тоже не дают универсального разрешения. 429 означает ограничение частоты; ответ может содержать Retry-After. 503 часто указывает на временную недоступность, но повтор без лимита способен продлить перегрузку. 400, 401 и 403 обычно требуют исправить входные данные или права. Повтор не изменит причину. Сетевой timeout не является HTTP-статусом и требует отдельно оценить, была ли операция отправлена и могла ли она завершиться.

\n
Решение о повторе по наблюдаемому симптому
СимптомПричинаПроверкаДействие
429 с Retry-AfterСработал лимит частотыПрочитать заголовок и область лимитаПовторить только идемпотентную операцию, не раньше разрешённого времени
503 без Retry-AfterUpstream временно недоступен или перегруженСверить статус, deadline и счётчик попытокПрименить ограниченный backoff с jitter
Timeout GETОтвет потерян или сервер ещё работаетПовторно прочитать ресурс и проверить остаток deadlineПовторить чтение при наличии бюджета
Timeout POSTЗапись могла завершитьсяПроверить idempotency key или статус операцииНе отправлять второй POST без защиты
400, 401 или 403Неверные данные или праваПосмотреть тело ответа и авторизациюНе повторять автоматически
\n

Backoff и jitter

\n

Экспоненциальный backoff снижает частоту повторов по мере роста номера попытки. Базовая формула: min(cap, base * 2^attempt). Параметр cap не даёт задержке расти бесконечно. Но один backoff не решает проблему синхронизации. Если тысячи клиентов получили сбой в одном интервале, одинаковая формула разбудит их почти одновременно.

\n

Jitter добавляет случайное смещение. В простом варианте клиент выбирает задержку в диапазоне от нуля до рассчитанного значения. В другом варианте он добавляет небольшой случайный интервал к deterministic backoff. Выбор варианта зависит от клиента и нагрузки. Важно не смешать случайность с бесконтрольным ожиданием: итоговая задержка всё равно должна укладываться в общий deadline и максимальное число попыток.

\n

Deadline принадлежит всей операции. Нельзя выдавать каждой попытке новый полный timeout. Если у операции осталось 120 миллисекунд, а рассчитанный backoff равен 500 миллисекундам, нужно завершить операцию или перейти к проверке состояния. Иначе локальный retry будет скрывать задержку от вызывающего кода и увеличивать очередь. В журналах сохраняйте номер попытки, статус, задержку, остаток deadline и причину остановки. Не записывайте секреты и полное тело запроса.

\n
\"Матрица
Сначала проверяется семантика операции. Только после этого выбирается задержка. Нижняя граница схемы напоминает: deadline и идемпотентность важнее числа попыток.
\n

Учебный пример политики

\n

Ниже — учебная функция для расчёта задержки. Она не выполняет HTTP-запрос, не генерирует случайность и не знает, разрешён ли retry для конкретного метода. Jitter передаётся числом, чтобы пример имел воспроизводимый результат. В реальном клиенте случайное значение нужно получать через управляемый генератор и сравнивать итог с остатком deadline.

\n
function retryDelay(attempt, baseMs, capMs, jitterMs) {\n  if (!Number.isInteger(attempt) || attempt < 0) {\n    return { ok: false, reason: 'invalid-attempt' };\n  }\n  if (![baseMs, capMs, jitterMs].every(Number.isFinite)) {\n    return { ok: false, reason: 'invalid-delay' };\n  }\n  if (baseMs < 0 || capMs < baseMs || jitterMs < 0) {\n    return { ok: false, reason: 'invalid-delay-range' };\n  }\n\n  const exponentialMs = Math.min(capMs, baseMs * (2 ** attempt));\n  return {\n    ok: true,\n    exponentialMs,\n    delayMs: exponentialMs + jitterMs,\n  };\n}\n\nconst result = retryDelay(3, 100, 1000, 37);\n// { ok: true, exponentialMs: 800, delayMs: 837 }
\n

Числа в примере учебные. На попытке с индексом 3 экспоненциальная часть равна 800 миллисекундам, потому что 100 * 2^3 не превышает cap 1000. Этот расчёт не доказывает, что задержка подходит вашему upstream. Для подбора параметров нужны его лимиты, общий deadline, размер пула и допустимая нагрузка. Если jitter добавить после проверки deadline, клиент всё равно может превысить бюджет, поэтому проверку выполняют для итоговой задержки.

\n

Idempotency key и отрицательный путь

\n

Request ID связывает попытки в логах. Он не заставляет сервер считать их одной операцией. Idempotency key должен входить в контракт endpoint. Сервер хранит ключ вместе с идентификатором операции и результатом в течение согласованного времени. При повторе с тем же ключом и тем же содержимым он возвращает тот же результат или текущее состояние. При другом содержимом сервер должен отклонить запрос, а не молча перезаписать первую операцию.

\n

У ключа есть ограничения. Он может истечь до того, как клиент повторит запрос. Сбой может произойти между записью результата и сохранением записи о ключе. Разные пользователи могут случайно выбрать одинаковый ключ, если сервер не включает владельца в область уникальности. При смене версии схемы может потребоваться отдельная совместимость. Поэтому ключ снижает риск дубля, но не обещает успешный исход и не заменяет reconciliation.

\n

Отрицательный путь должен быть явным. Если POST завершился timeout, нет ключа и нет API статуса, клиент не повторяет его автоматически. Он возвращает состояние «результат неизвестен», сохраняет correlation id и предлагает безопасную проверку. Если upstream отвечает 400, клиент исправляет запрос или показывает ошибку. Если deadline закончился во время backoff, клиент останавливается. Повтор ради заполнения метрики успешных ответов не является корректным действием.

\n

Порядок настройки retry

\n
  1. Опишите endpoint, HTTP-метод, побочные эффекты и источник истины. Для записи укажите, как узнать результат после разрыва соединения.
  2. Разделите статусы и сетевые сбои. Для каждого сигнала задайте право на повтор, причину остановки и ожидаемый ответ сервера.
  3. Проверьте idempotency key, статусную операцию или иной механизм дедупликации. Если механизм отсутствует, запретите автоматический retry для неизвестного результата.
  4. Передайте один абсолютный deadline через весь вызов. Перед каждой попыткой сравнивайте остаток времени с рассчитанной задержкой и timeout самой попытки.
  5. Задайте base, cap и максимум попыток. Проверьте нулевую попытку, достижение cap, отрицательные и нечисловые параметры.
  6. Добавьте jitter после расчёта backoff, но до проверки deadline. В тесте подмените генератор случайных чисел или передайте фиксированное значение.
  7. Обработайте Retry-After. Не отправляйте запрос раньше указанного сервером времени, если это не запрещает deadline. Некорректный заголовок обработайте как недоверенное значение.
  8. Проверьте искусственные 429, 503, timeout до ответа и timeout после отправки тела. Сверяйте число запросов, дубли записей и время до остановки.
\n

Ограничения

\n

HTTP-метод не описывает побочные эффекты конкретного сервиса. GET может запускать плохо спроектированную команду, а POST может быть идемпотентным по отдельному контракту. Проверяйте реализацию endpoint, а не только название метода.

\n

Retry не заменяет circuit breaker, rate limit, очередь, bulkhead и backpressure. Эти механизмы решают разные задачи. Circuit breaker ограничивает вызовы при устойчивом сбое. Rate limit распределяет бюджет запросов. Очередь меняет момент выполнения. Ни один из них не разрешает повторить неизвестную запись без проверки семантики.

\n

Таблица и код выше не являются готовой библиотекой. Они не учитывают конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. Пример не содержит production-замеров и не заявляет универсальные значения base, cap или jitter. Эти параметры нужно подтвердить тестом на вашей границе нагрузки.

\n

Проверяемый критерий готовности

\n

Политика готова, если для каждого endpoint можно ответить на четыре вопроса: какую операцию повторяем, почему она безопасна, сколько времени и попыток разрешено, и что делаем при неизвестном результате. Тест должен показать, что 429 учитывает Retry-After, 503 не создаёт синхронную вторую волну, timeout POST не создаёт дубль без ключа, а исчерпание deadline останавливает цикл. Если хотя бы один ответ звучит как «повторяем всегда», политика не готова.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/006.json b/editorial/agent-rewrites/006.json new file mode 100644 index 0000000..105bff5 --- /dev/null +++ b/editorial/agent-rewrites/006.json @@ -0,0 +1 @@ +{"index":6,"slug":"editorial-2027-11-practice-mistakes-revisions","title":"Миграция схемы БД без простоя: совместимые фазы expand, switch, contract","excerpt":"Как изменить схему при работающем старом и новом коде: проверить совместимость чтения и записи, пережить backfill и удалить старую форму только после явного сигнала.","contentHtml":"

Команда ALTER TABLE проходит на пустой базе, но на большой таблице может ждать блокировку и задержать пользовательские запросы. Другой симптом появляется после выката: новый writer сохраняет только новую форму данных, а ещё работающий старый reader ищет старую колонку и получает ошибку или неполную запись.

Цена ошибки — очередь запросов, простой части сервиса и откат приложения, который уже не возвращает совместимость со схемой. Откат кода не отменяет записи, сделанные новым writer, а обратное изменение типа или удаление данных может оказаться необратимым. Поэтому миграцию проектируют как последовательность совместимых состояний, а не как один SQL-файл.

Тезис: сначала совместимость, потом переключение

Безопасный порядок такой: добавить новую форму данных, научить код работать со старой и новой формами, переключить чтение, проверить потребителей и только затем удалить старую форму. Старый и новый binary некоторое время живут одновременно, экземпляры обновляются не синхронно, а миграция может остановиться между фазами.

Рассмотрим замену вычисляемого имени из first_name и last_name на колонку display_name. Сначала новая колонка должна быть совместима со старым кодом: она nullable или имеет безопасное значение по правилам домена. Пока старый reader ещё работает, новый writer не может отказаться от старых полей без fallback.

Механизм: матрица reader и writer

Перед DDL выпишите четыре возможности: умеет ли старый reader читать новую форму, умеет ли новый reader читать её, пишет ли старый writer старую форму и пишет ли новый writer обе формы. Из этой матрицы видно, на какой фазе находится система и где возникнет несовместимость.

Совместимые состояния миграции
ФазаЧтениеЗаписьЧто разрешеноКонтроль
Expandстарая формастарая формадобавить nullable-колонку или совместимый индексстарый binary продолжает работать
Dual writeстарая форма, новая с fallbackобе формызаполнять новую форму пачками или при записисверять значения и ошибки записи
Switchновая форма с fallbackобе формыперевести reader после проверки данныхнаблюдать долю чтения fallback
Contractновая формановая формаудалить старую форму отдельным изменениеместь сигнал, что старые потребители ушли
Rollbackстарая или fallbackсовместимая записьвернуть binary без потери данныхпуть отката проверен до switch

Новый writer без совместимого reader — небезопасное состояние. Двойная запись решает только доставку данных в две формы; она не доказывает, что значения одинаковы, что backfill не перезапишет более свежую запись и что все потребители готовы к switch.

DDL — операция с ресурсом

Изменение таблицы зависит от блокировок, объёма работы и конкретной версии PostgreSQL. В review смотрите на lock mode, время ожидания, границы транзакции, индексы, триггеры, репликацию и план восстановления. Добавление колонки, создание индекса, backfill и изменение типа имеют разную стоимость. Объединять их в одну «маленькую миграцию» нельзя без проверки.

Backfill — отдельная нагрузка, а не деталь миграции схемы. Большой UPDATE конкурирует с пользовательскими запросами и может увеличить WAL. Идемпотентные ограниченные пачки позволяют остановить работу и продолжить её позже. Размер пачки, пауза и условие обновления зависят от вашей нагрузки; пример ниже не задаёт универсальные значения.

\"Последовательность
Старая и новая формы сосуществуют до тех пор, пока проверяемый сигнал не разрешит удалить старую.

Минимальный рабочий пример

Небольшая функция формализует главный запрет: нельзя включать новую запись, если нет reader, который понимает новую форму. Это учебная проверка совместимости; она не подключается к базе, не запускает DDL и не заменяет проверку конкретного кластера.

function classifyMigrationStep({ oldReads, newReads, oldWrites, newWrites }) {\n  if (newWrites && !oldReads && !newReads) {\n    return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };\n  }\n  if (newWrites && !oldWrites) {\n    return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };\n  }\n  if (newReads && oldReads && newWrites) {\n    return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };\n  }\n  if (oldReads && !newReads && !newWrites) {\n    return { phase: 'contract', reason: 'remove-format-only-after-consumers-move' };\n  }\n  return { phase: 'inspect', reason: 'compatibility-matrix-is-incomplete' };\n}\n\nconsole.log(classifyMigrationStep({\n  oldReads: false,\n  newReads: false,\n  oldWrites: false,\n  newWrites: true,\n}).phase);\n// unsafe

В настоящей миграции вместо boolean-признаков нужны конкретные версии приложения, формы записи и список потребителей. Проверка должна отвечать на вопрос «кто прочитает запись после этого шага?», а не только на вопрос «принял ли SQL сервер?».

Порядок выполнения

  1. Опишите старую и новую формы данных. Укажите каждый reader и writer, версию binary и допустимый fallback.
  2. Проверьте DDL на блокировки, размер таблицы, индексы, транзакцию, репликацию и план восстановления. Для production-объёма используйте среду с похожими данными, если это возможно в вашей процедуре.
  3. Добавьте новую форму без требования, которое сломает старый binary. Сначала проверьте, что старый код продолжает читать и писать прежнюю форму.
  4. Включите двойную запись или backfill идемпотентными пачками. Сверяйте количество обработанных строк, контрольные значения и случаи, когда более свежая запись уже существует.
  5. Переведите чтение на новую форму с fallback. Наблюдайте ошибки, latency, lock wait и долю чтения старой формы. Fallback должен быть виден, иначе нельзя понять, ушли ли старые потребители.
  6. Удалите fallback и старую форму отдельным изменением после окна наблюдения. Сохраните понятный сигнал, что старый reader больше не обращается к колонке и rollback-путь больше не требуется.

Почему rollback не равен обратной миграции

Откат приложения возвращает код, но не обязательно возвращает схему. Если новый writer заполняет только display_name, старый reader без fallback может увидеть пустое значение. Если преобразование типа потеряло информацию, обратный DDL не восстановит её. Поэтому старый reader должен оставаться совместимым с данными, которые создал новый writer, а путь отката нужно определить до switch.

Проверяйте промежуточные состояния: сразу после expand, во время частичной двойной записи, после остановки backfill и после переключения только чтения. В каждом состоянии остановка приложения или миграции должна иметь понятное продолжение. Финальный smoke test не проверяет совместимость всех этих переходов.

Ограничения и критерий готовности

Эта схема не выбирает lock mode, размер пачки, стратегию индексации или настройки WAL для вашего кластера. На результат влияют версия PostgreSQL, расширения, ORM, триггеры, партиционирование, репликация, размер таблицы и политика блокировок. Документация описывает свойства операций, но не разрешает выполнять их без проверки вашей нагрузки.

Миграция готова к contract, когда новая форма заполнена и сверена, новый reader работает без скрытой зависимости от старой формы, старый binary больше не является потребителем, а rollback-путь проверен на промежуточном состоянии. Если хотя бы один пункт нельзя доказать наблюдением или проверкой, оставьте старую форму и продолжите расследование.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/007.json b/editorial/agent-rewrites/007.json new file mode 100644 index 0000000..1a15caa --- /dev/null +++ b/editorial/agent-rewrites/007.json @@ -0,0 +1 @@ +{"index":7,"slug":"editorial-2027-10-field-long-form-interview","title":"Trace Context в HTTP: как сохранить запрос на границе proxy","excerpt":"Если traceparent исчезает или не проходит проверку, gateway и сервис перестают видеть один запрос. Разбираем формат, безопасный fallback и проверку через реальный proxy.","contentHtml":"

Симптом появляется во время сбоя: gateway сообщает timeout, backend пишет 500, а записи нельзя связать одним запросом. Инженер видит несколько похожих событий и не понимает, относятся ли они к одной операции. Цена ошибки — лишний поиск по логам, неверная гипотеза о медленном сервисе и более долгий разбор пользовательской проблемы.

Часто ломается не сама трассировка, а граница между компонентами. Proxy удаляет неизвестный заголовок, middleware создаёт новый trace вместо продолжения, либо сервис принимает строку с неверным форматом. Исправление должно проверять весь путь: что отправили, что пропустил proxy, что разобрал сервис и что записал logger.

Тезис: traceparent — транспортный контракт, а не доказательство доступа

W3C Trace Context задаёт переносимый HTTP-заголовок traceparent. Он связывает участки обработки одного запроса, но не гарантирует наличие span, корректность логирования или сохранность заголовка на каждом proxy. Поэтому проверять нужно не только парсер. Нужен контракт между клиентом, gateway, middleware и сервисом.

Валидный внешний контекст можно продолжить как родительский. После этого локальная библиотека создаёт новый span для текущего сервиса. Невалидный контекст нельзя молча чинить: сервис отбрасывает его, создаёт новый локальный trace и фиксирует короткую причину отказа. Trace-id не заменяет авторизацию и не должен содержать пользовательские данные.

Механизм передачи

traceparent состоит из четырёх полей: версии, trace-id, parent-id и flags. Для базового формата важны точные длины, lowercase hexadecimal и ненулевые идентификаторы. Ошибка в одном поле делает вход непригодным для продолжения. Парсер должен вернуть результат проверки, а не угадывать намерение отправителя.

Proxy — часть этого контракта. Он может удалить неизвестный заголовок, нормализовать имя, ограничить размер или создать собственный контекст. Локальный тест функции парсинга не доказывает, что значение дошло до приложения. Проверка должна проходить через ту же пару gateway и service, с которой работает запрос.

Даже сохранённый trace-id не объясняет задержку сам по себе. Для поиска места ожидания нужны границы span, временные отметки и статус ошибки. Если компоненты пишут только общий идентификатор, связь событий появится, но длительность отдельных участков останется неизвестной.

\"Клиент
Проверяйте контекст на границе сервиса после proxy. Заголовок связывает события, но не является авторизацией и не заменяет хранение telemetry.

Минимальный рабочий пример

Ниже парсер получает одну строку и возвращает разобранные поля только после проверки формата. Для плохого входа он отдаёт короткую причину, которую можно считать в метрике без записи полного заголовка. Пример не создаёт span и не отправляет telemetry: он показывает только проверяемую границу.

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

В примере uppercase trace-id отклоняется. Это controlled fallback, а не 500: сервис не принимает повреждённую строку как родительский контекст и не использует её как право доступа. Конкретную политику для version и flags нужно закрепить интеграционным тестом выбранной библиотеки.

Диагностика по симптому

СимптомПричинаПроверкаДействие
В gateway и service разные trace-idProxy удалил заголовок или middleware начал новый traceСравнить raw-заголовок до и после proxyРазрешить передачу traceparent и проверить продолжение валидного контекста
Контекст есть только в локальном запускеРеальный proxy применяет другой allow-list или лимит размераПовторить запрос через gateway с теми же правиламиДобавить integration test для gateway + service
Валидный на вид заголовок отклонёнUppercase, неверная длина, нулевой id или неподдерживаемая версияПрогнать parser fixture на каждое полеОтбросить вход и записать безопасную причину
По trace-id не находится событиеLogger пишет другой ключ или sampling удалил eventПроверить структурное поле и политику samplingУнифицировать JSON key и сохранить ошибку парсинга как счётчик
После HTTP-запроса теряется связь с очередьюHTTP-контекст не перенесён в механизм брокераПроверить envelope или headers сообщенияИспользовать механизм контекста очереди и отдельный link между участками

Порядок проверки

  1. Выберите один запрос и выпишите его границы: клиент, gateway, service и downstream. Для каждой границы определите, где должен появиться trace-id.
  2. Снимите заголовок до proxy и после proxy. Если значение исчезло на этом участке, не начинайте поиск с backend-библиотеки.
  3. Добавьте тестовые случаи для нулевых идентификаторов, uppercase, неверной длины и запрещённой версии. Для каждого случая зафиксируйте ожидаемый controlled fallback.
  4. Проверьте middleware на валидном входе: trace-id должен продолжиться, а текущий сервис должен получить новый локальный parent-id для своего span.
  5. Сведите записи к одному структурному ключу. Причину отказа парсера считайте отдельно и не сохраняйте полный внешний заголовок без необходимости.
  6. Прогоните end-to-end проверку через реальный proxy. Затем отдельно проверьте sampling и задержку доставки telemetry, чтобы отсутствие записи не принять за потерю контекста.

Ограничения и критерий готовности

Этот подход проверяет HTTP-заголовок и передачу контекста между компонентами. Он не проверяет подпись, доверие к отправителю, backend sampling, полноту collector и форматы контекста Kafka или другой очереди. Он также не делает trace-id бизнес-идентификатором: повтор операции и асинхронная обработка требуют отдельного безопасного operation-id.

Готовность можно считать доказанной, если интеграционный тест через реальный proxy сохраняет один trace-id на границе gateway и service, валидный контекст получает локальный span, а повреждённый вход приводит к новому локальному trace без 500. Логи должны содержать единый trace-id key, а проверка не должна превращать внешний идентификатор в секрет или разрешение на действие.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/008.json b/editorial/agent-rewrites/008.json new file mode 100644 index 0000000..908406d --- /dev/null +++ b/editorial/agent-rewrites/008.json @@ -0,0 +1 @@ +{"index":8,"slug":"editorial-2027-10-mechanism-long-form-interview","title":"TLS-сертификат: почему «curl работает» не закрывает проверку","excerpt":"Разбираем цепочку доверия, срок действия и SAN: какие проверки проходят до HTTP и почему один успешный клиент ничего не доказывает для другого.","contentHtml":"

Проблема обычно выглядит противоречиво: браузер открывает адрес, а сервисный клиент получает certificate error; либо один контейнер подключается, а второй — нет. Цена ошибки — отключить проверку TLS «временно», потерять имя хоста в диагностике и превратить сетевую проблему в уязвимость.

Причина в том, что «сертификат валиден» — это не одна проверка. Клиент строит цепочку до доверенного корня, проверяет период действия, имя назначения и ограничения сертификата. Разные хранилища корней, SNI, proxy и часы системы меняют результат. Нужно разделить слой протокола, X.509-структуру и локальную политику доверия.

Что проверяет клиент до HTTP

TLS handshake создаёт защищённый канал, но доверие к peer не появляется из шифрования автоматически. Сертификат содержит открытый ключ, имя и подпись издателя; клиент проверяет цепочку и применимость к назначенному хосту. Если запрос идёт на api.example.test, сертификат только для admin.example.test не должен считаться подходящим из-за того, что ключ технически рабочий.

Период действия проверяется по часам клиента. Ошибка в системном времени даёт симптом «сертификат ещё не действителен» или «истёк», хотя сервер ничего не менял. Переход на другой контейнер может поменять корневое хранилище и набор промежуточных сертификатов. Поэтому при сравнении сред нужно собирать не только URL, но и hostname, SNI, trust store, время и цепочку.

Минимальная проверка сертификата
ПроверкаВопросОтказЧто собрать
Срокnow между notBefore и notAfter?not-yet-valid / expiredUTC-время клиента и поля сертификата
Имяhost есть в SAN?hostname mismatchSNI, hostname и SAN
Цепочкаесть путь до доверенного корня?unknown issuerleaf, intermediate, trust store
Подписьалгоритм и ключ разрешены?signature/algorithm errorTLS policy и negotiated version
Отзывполитика проверяет статус?revoked/unknownOCSP/CRL policy и доступность

Почему отключение verify ухудшает диагностику

Флаг вроде insecure меняет вопрос с «можно ли доверять peer» на «зашифрован ли канал до кого-то». Запрос начинает проходить, но факт успеха перестаёт говорить о подлинности сервера. Если потом этот флаг попадёт в общий клиент или пример конфигурации, временная отладка станет постоянной дырой.

Надёжнее вывести диагностическую информацию без обхода проверки: имя хоста, SNI, цепочку, срок, код ошибки и идентификатор корня. В тестовой среде можно добавить собственный CA в доверенное хранилище или передать его явно. Такой путь сохраняет настоящую проверку и делает отличие среды видимым.

\"Матрица
Диаграмма разделяет данные сертификата и локальную политику. Успешный запрос одного клиента не является доказательством для другого trust store.

Runnable-пример: срок и SAN как отдельные причины

Функция ниже не строит X.509-цепочку и не заменяет TLS-библиотеку. Она принимает ISO-даты, hostname и список SAN, затем показывает две базовые проверки, которые полезно видеть в тестах и диагностическом выводе. Входы специально простые; ожидаемый результат различает успех, истёкший сертификат и несовпадение имени.

import { validateCertificateWindow } from './upgrade-2027-10.mjs';\n\nconst common = {\n  host: 'api.example.test',\n  sans: ['api.example.test', 'api.internal.test'],\n  notBefore: '2026-01-01T00:00:00Z',\n  notAfter: '2027-01-01T00:00:00Z',\n};\n\nconsole.log(validateCertificateWindow({ ...common, now: '2026-06-01T00:00:00Z' }));\nconsole.log(validateCertificateWindow({ ...common, host: 'cdn.example.test', now: '2026-06-01T00:00:00Z' }).reason);\n// { ok: true, reason: 'certificate-window-and-san-match' }\n// host-not-listed-in-san

Порядок проверки в среде

  1. Зафиксируйте точный hostname и порт, который видит TLS-клиент. IP-адрес в логе не заменяет имя для проверки SAN.
  2. Проверьте часы контейнера и узла в UTC. Ошибку времени нельзя лечить повторной загрузкой сертификата.
  3. Снимите leaf и intermediate без отключения verify. Сравните цепочку с trust store конкретного процесса.
  4. Проверьте SAN, SNI и redirect. Сертификат для исходного адреса не обязан подходить для нового host после перенаправления.
  5. Разделите ошибку доверия, имени, срока и алгоритма. Для каждой причины оставьте отдельный тестовый fixture.
  6. Исправляйте trust store или цепочку на сервере; флаг обхода проверки не используйте как решение.

Цепочка не равна доверию

Наличие intermediate в файле сервера не означает, что клиент доверяет корню. И наоборот, локальный trust store может содержать корень, но сервер не отправить промежуточный сертификат. Успешная проверка строит путь по подписи и ограничениям, а не по совпадению строк в PEM-файле. Это объясняет, почему «в браузере работает» может быть правдой одновременно с ошибкой минимального контейнера.

Сертификат также не сообщает всю эксплуатационную политику. Клиент может проверять отзыв, запрещать старый алгоритм или требовать минимальную версию TLS. Если диагностический отчёт пишет только «certificate valid», он скрывает полезную часть причины. Сохраняйте код ошибки библиотеки и параметры соединения, а секретный ключ и полное содержимое лишний раз не логируйте.

Ограничения и следующий шаг

Учебная функция не проверяет подпись, CRL, OCSP, wildcard-правила, DNS и реальное TLS-согласование. Она не является security scanner. Её роль — сделать две часто потерянные проверки явными и тестируемыми без сетевой зависимости.

Следующий шаг — воспроизвести ошибку в том же контейнере, где работает сервис, собрать hostname/SNI, цепочку, время и код ошибки, а затем исправить конкретный слой. После исправления оставьте regression test на истёкший срок и неверный SAN, чтобы повторное «временное» отключение доверия стало заметным.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/009.json b/editorial/agent-rewrites/009.json new file mode 100644 index 0000000..1c1d986 --- /dev/null +++ b/editorial/agent-rewrites/009.json @@ -0,0 +1 @@ +{"index":9,"slug":"editorial-2027-10-practice-long-form-interview","title":"HTTP-таймауты: как разложить общий deadline на измеримые фазы","excerpt":"Запрос может завершиться таймаутом до ответа сервера или повторить запись после неясного результата. Разбираем deadline, фазы HTTP и проверку retry без догадок.","contentHtml":"

Запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Пользователь видит ошибку, хотя сервер мог завершить операцию. Хуже случай с записью: клиент повторяет POST, не зная, успел ли первый запрос попасть в обработчик. Цена ошибки — потерянный результат, двойное изменение состояния и лишняя нагрузка на upstream.

Симптом не говорит, где потрачено время. Один общий timeout смешивает DNS, TCP, TLS, отправку тела, ожидание первого байта и чтение ответа. Повторная попытка получает новый полный бюджет и скрывает исходную причину. Тезис статьи прост: задайте один абсолютный deadline для операции, разложите его на наблюдаемые фазы и разрешайте retry только после проверки семантики метода.

Deadline — граница операции

Общий deadline отвечает на вопрос: до какого момента результат имеет смысл для вызывающего кода. Фазовый timeout отвечает на другой вопрос: сколько можно ждать конкретный переход. Если дать DNS, TCP, TLS и чтению по 800 мс каждому, запрос может жить несколько секунд. Если ограничить connect 50 мс, холодное разрешение имени превратит нормальный запрос в ложный отказ.

Храните абсолютный момент окончания или остаток бюджета. Перед каждой фазой вычисляйте remaining = deadline - now. Если остатка нет, завершайте операцию до следующего сетевого вызова. Новый независимый таймер после истечения deadline нарушает контракт вызывающего кода.

Механизм: что измерять

DNS показывает время разрешения имени. TCP connect — установление соединения. TLS handshake — защищённый обмен и проверку сертификата. Request write — передачу заголовков и тела. TTFB показывает ожидание первого байта, а read — получение остального ответа. Эти интервалы отвечают на разные вопросы, поэтому один счётчик total duration не заменяет их.

Фазы HTTP-запроса и сигналы
ФазаЧто измеряемТипичный симптомДействие
DNSРазрешение имениМедленный первый запросПроверить resolver, кэш и лимит DNS
TCP connectОткрытие соединенияОтказ до TLSПроверить маршрут, pool и connect timeout
TLS handshakeЗащищённый обменСоединение есть, ответа нетПроверить цепочку, crypto и TLS budget
Request writeОтправка телаЗависает uploadПроверить размер, backpressure и write timeout
TTFB/readОтвет и телоКлиент ждёт серверСопоставить server time, read timeout и payload

Симптом → причина → проверка → действие

СимптомПричинаПроверкаДействие
Timeout без статуса HTTPИстёк deadline до первого байта или клиент закрыл сокетСравнить timestamps DNS, connect, TLS, TTFB и server logРазделить фазовые интервалы и не называть отказ HTTP-статусом
Первый запрос медленный, следующие быстрыеCold DNS, TCP или TLS скрыты poolСравнить cold и warm соединенияИзмерять установление соединения отдельно от чтения
POST повторяет изменениеRetry запущен без доказанной идемпотентностиПроверить метод, operation-id и результат первого вызоваДобавить ключ дедупликации или сначала читать статус операции
Каждый retry ждёт полный timeoutПопытки не делят общий deadlineПосчитать абсолютный deadline всего запросаПередавать остаток бюджета, а не запускать новый полный таймер
В логах только total durationКлиент не экспортирует фазыПроверить API instrumentation и события poolФиксировать известную границу и не выдавать реконструкцию за факт

Повторная попытка не лечит неизвестный результат

Timeout не доказывает, что сервер ничего не сделал. Для GET повтор обычно допустим, если ресурс читает актуальное состояние и клиент принимает возможное изменение данных между попытками. Для POST, PUT и DELETE решение зависит от семантики операции. Нужны идемпотентность, ключ дедупликации или endpoint для проверки статуса. Одного сетевого флага retry недостаточно.

Три попытки по 800 мс дают до 2,4 секунды ожидания без учёта задержек и одновременно утроенную нагрузку на upstream. Общий deadline должен охватывать все попытки. Если остатка мало, новая попытка не должна начинать работу. Для записи безопаснее вернуть состояние «результат неизвестен» и проверить operation-id, чем отправлять запрос повторно наугад.

\"Карта
Общий бюджет проходит через фазы запроса. Диагноз должен ссылаться на сигнал конкретной фазы, а не только на слово timeout.

Учебный пример без сети

Функция ниже получает общий бюджет и длительности уже измеренных фаз. Она возвращает сумму, остаток и причину. Это учебный пример: он не открывает socket, не моделирует отмену запроса и не заменяет таймеры HTTP-клиента. Его граница — проверка арифметического инварианта отдельно от сети.

import { allocateTimeoutBudget } from './upgrade-2027-10.mjs';\n\nconst within = allocateTimeoutBudget({\n  totalMs: 800,\n  dnsMs: 42,\n  tlsMs: 88,\n  requestMs: 510,\n});\nconst late = allocateTimeoutBudget({\n  totalMs: 800,\n  dnsMs: 120,\n  tlsMs: 210,\n  requestMs: 560,\n});\n\nconsole.log(within.ok, within.remainingMs);\nconsole.log(late.ok, late.reason, late.remainingMs);\n// true 160\n// false deadline-exceeded 0

В первом вызове потрачено 640 мс, поэтому остаётся 160 мс. Во втором сумма равна 890 мс: функция возвращает deadline-exceeded и нулевой остаток. Реальный адаптер должен дополнительно отменять вложенные DNS, socket и чтение, иначе завершение функции не остановит работу сокета.

Порядок проверки

  1. Запишите абсолютный deadline и момент, когда клиент прекратил ожидание. Не начинайте диагностику с увеличения числа.
  2. Добавьте DNS, TCP, TLS, write, TTFB и read в одну структурированную запись. Одинаковые имена фаз важнее названий полей конкретной библиотеки.
  3. Проверьте, передаётся ли остаток бюджета на следующую фазу и между retry. Новый таймер не должен начинаться после истечения deadline.
  4. Сопоставьте фазу отказа с HTTP-методом. Для записи проверьте идемпотентность, operation-id и способ узнать результат первого вызова.
  5. Разделите connect, read и общий deadline в конфигурации. У каждого параметра должен быть владелец и тест на граничное значение.
  6. Сравните cold и warm соединение. Pool может скрыть TLS в одном случае и показать его в другом.
  7. Повторите проверку через реальный proxy и downstream. Локальная функция не доказывает поведение всей цепочки.

Что измерение не доказывает

Большой TTFB не доказывает, что сервер медленный: время могло уйти на proxy, очередь или повторный TLS. Малый TTFB не гарантирует быструю загрузку всего тела. Клиентский timeout не равен HTTP-статусу: клиент может закрыть соединение, а сервер продолжить обработчик. Метрики фаз нужно связывать с серверным временем и размером ответа, но не подменять ими друг друга.

Если библиотека отдаёт только total duration, реконструированные интервалы нельзя называть фактами. Выберите instrumented adapter или события connection pool. До этого фиксируйте только известную границу: «клиент прекратил ждать через N мс». Неполное, но честное измерение лучше точного на вид вымысла.

Ограничения и критерий готовности

Подход не учитывает jitter часов, системные очереди, HTTP/2 multiplexing и детали конкретного proxy. Учебные числа не являются нормативными значениями для вашей сети. RFC описывает протокол, но не выбирает таймауты, retry policy или параметры клиента. Для боевой системы нужен адаптер с отменой всех вложенных операций и отдельная проверка безопасности повторов.

Материал готов к применению, если интеграционный тест для выбранного endpoint показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, а просроченная операция останавливает вложенное чтение. Для записи дополнительно нужен проверяемый operation-id: после timeout система может узнать, был ли первый вызов принят. Если хотя бы одна граница не наблюдается, результат следует считать недоказанным, а не успешным.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/010.json b/editorial/agent-rewrites/010.json new file mode 100644 index 0000000..bee99ea --- /dev/null +++ b/editorial/agent-rewrites/010.json @@ -0,0 +1,7 @@ +{ + "index": 10, + "slug": "editorial-2027-09-field-mentor-series", + "title": "API-diff в code review: как найти несовместимость и сохранить откат", + "excerpt": "Практический разбор API-изменений: классифицируем риск, ищем потребителей, проверяем частичный rollout и удаляем старый контракт только после измеримого сигнала.", + "contentHtml": "

Ошибка в API-изменении часто выглядит безобидно: сервер собирается, локальный тест получает 200, diff занимает несколько строк. Затем старый клиент отправляет прежний запрос, получает новый обязательный ответ или неизвестное значение enum и ломается на успешном пути. Цена ошибки растёт быстро: приходится восстанавливать потребителей, задерживать rollout и решать, как вернуть сервер, если он уже записал данные в новом формате.

\n

Тезис простой: review API-diff должен проверять не только код сервера. Он должен связать форму контракта, потребителей, данные и порядок выката. Сначала классифицируйте изменение. Затем найдите тех, кто читает и пишет старую форму. После этого выберите расширение, версию или expand/contract. Такой порядок превращает спор о «безопасном» diff в набор проверяемых условий.

\n

Механизм: контракт живёт по обе стороны границы

\n

API состоит как минимум из запроса, ответа и иногда события. У каждого есть форма, значения и смысл. Добавление необязательного поля в ответ обычно расширяет контракт: старый клиент может его проигнорировать. Удаление поля сужает контракт. Новое обязательное поле в запросе ломает старого отправителя. Сужение enum ломает ветвление, которое раньше обрабатывало удалённое значение.

\n

Тип изменения недостаточен. Поле может остаться строкой, но поменять часовой пояс, единицу измерения или правило пустого значения. Формальная схема пропустит такой ответ, а клиент изменит поведение. Поэтому отдельно фиксируйте структурную совместимость и семантическую совместимость. Первая отвечает на вопрос «можно ли распарсить данные». Вторая — «можно ли продолжить прежнюю операцию с тем же смыслом».

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый клиент не создаёт ресурсВ запрос добавили required-полеНайти builders, SDK и fixtures старой версииОставить поле optional, задать совместимое значение или выпустить версию
Клиент падает на успешном ответеУдалили свойство или изменили типПоиск чтения свойства и contract-тест старого клиентаСначала deprecated-окно и новое поле, затем удаление
Новая ветка обработки не срабатываетСузили enum или добавили неизвестное значениеПрогнать значения через старые switch и парсерыСохранить старые значения либо сменить версию
Откат приложения не восстанавливает работуНовый сервер записал только новый форматПроверить чтение старой версией после частичного rolloutСначала expand, затем switch, потом contract
Тесты зелёные, внешний потребитель сломанПотребитель не попал в репозиторийПроверить registry, документацию, логи и владельцев интеграцийОстановить удаление и оставить совместимый путь
\n

Пример: классификатор как первая страховка

\n

Ниже учебная функция получает уже выделенные признаки diff. Она не читает OpenAPI, не ищет клиентов и не разрешает pull request автоматически. Её граница полезна именно поэтому: генератор или reviewer передаёт факты, а функция одинаково маркирует очевидный breaking-риск. Реальная проверка должна дополнить её consumer-тестами и проверкой данных.

\n
function classifyApiChange(change) {\n  const removed = Array.isArray(change.removedProperties)\n    ? change.removedProperties\n    : [];\n  const required = Array.isArray(change.addedRequiredProperties)\n    ? change.addedRequiredProperties\n    : [];\n  const narrowedEnum = Boolean(change.narrowedEnum);\n  const breaking = removed.length > 0\n    || required.length > 0\n    || narrowedEnum;\n\n  return {\n    status: breaking ? 'breaking' : 'compatible',\n    action: breaking\n      ? 'version-or-expand-compatibility-window'\n      : 'run-consumer-contract-tests',\n  };\n}\n\nconst result = classifyApiChange({\n  removedProperties: ['displayName'],\n  addedRequiredProperties: [],\n  narrowedEnum: false,\n});\n\nconsole.log(result);\n// { status: 'breaking',\n//   action: 'version-or-expand-compatibility-window' }
\n

Функция намеренно не считает любое добавление опасным. Необязательное поле в ответе обычно можно выпустить сразу, если клиенты действительно игнорируют неизвестные свойства. Но это условие нужно проверить. Клиент с жёстким декодером, схемой с additionalProperties: false или сравнением полного JSON может сломаться даже на расширении. Статус compatible здесь означает «нет очевидного структурного breaking-признака», а не «изменение доказанно безопасно».

\n

Обратимость: сначала данные, потом бинарник

\n

Rollback приложения не возвращает базу, очередь и уже отправленные события. Если новый код записал только displayNameV2, старый код может не знать, как его читать. Если событие получило новое обязательное поле, повторная доставка старому consumer не станет безопасной от одного переключения образа. Поэтому опасные изменения проводите в несколько фаз.

\n

На фазе expand добавьте новую колонку, поле или форму так, чтобы старый код продолжал работать. На фазе совместимости научите новый код читать старую и новую формы и, при необходимости, писать обе. На фазе switch переведите потребителей и проверьте сигнал использования старого пути. Только после этого выполняйте contract: удаляйте поле, старый writer или колонку. Для каждой фазы нужна обратная операция и условие перехода.

\n
Маршрут API review: классификация diff, проверка потребителей и окно обратимой миграции перед удалением старой формы.
Схема связывает локальный diff с потребителями и данными. Если старый формат ещё нужен, путь к удалению должен остановиться.
\n

Порядок действий для одного изменения

\n
  1. Назовите endpoint, метод, статус, media type и версию клиента. Не смешивайте request, response и внутреннюю модель в одном описании.
  2. Снимите старую и новую формы. Выпишите required-поля, типы, nullable, enum, значения по умолчанию и изменение смысла.
  3. Запустите классификацию. Для каждого breaking-признака укажите конкретное поле и потребителя, которого он затрагивает.
  4. Найдите потребителей по сгенерированным типам, сериализаторам, SDK, fixture, документации, очередям и логам. Отдельно отметьте внешние интеграции, которых нет в монорепозитории.
  5. Составьте матрицу чтения и записи: старая версия против старой формы, старая против новой, новая против старой и новая против новой. Добавьте частичный rollout и повторную доставку события.
  6. Напишите отрицательный contract-тест для старого клиента и положительный тест для нового. Ошибка должна называть поле, значение и ожидаемую форму.
  7. Выберите способ перехода: совместимое расширение, новая версия или expand/contract. Для rollout запишите точку остановки и способ возврата.
  8. Удаляйте старую форму только после измеримого сигнала: старый consumer не обращается к полю, истёк согласованный срок хранения, а восстановление проверено.
\n

Где автоматическая проверка не помогает

\n

Классификатор не знает, что внешний клиент использует поле чаще внутреннего. Он не видит подписанный payload, кэш, задержанную очередь и SDK, который обновляется отдельно. Он также не проверяет авторизацию, конкурентное изменение ресурса и идемпотентность повторного запроса. Эти риски нужно описать в своих тестах и процедуре rollout.

\n

Схема не доказывает корректность операции. Ответ может быть валидным по JSON, но устареть между чтением и записью. Для такого случая нужны версия ресурса, условный запрос вроде If-Match, правило конфликта и отдельный статус. Не прячьте доменный инвариант в описании поля: форма данных и допустимость перехода состояния — разные проверки.

\n

Пример синтетический. Он показывает границу классификатора и порядок миграции, но не заявляет замеры, production-результаты или совместимость с конкретным сервисом. Перед выпуском замените вымышленные поля реальным diff и приложите доказательства по вашим потребителям.

\n

Критерий готовности

\n

Изменение готово к выпуску, если reviewer может открыть одну страницу и увидеть старую и новую формы, список потребителей, результат классификации, матрицу частичного rollout, contract-тесты и процедуру возврата. Для breaking-изменения дополнительно указан владелец старого пути, сигнал его использования и условие удаления. Если хотя бы один потребитель неизвестен, не переходите к contract-фазе: оставьте совместимое расширение или выпустите отдельную версию.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/011.json b/editorial/agent-rewrites/011.json new file mode 100644 index 0000000..394833d --- /dev/null +++ b/editorial/agent-rewrites/011.json @@ -0,0 +1,7 @@ +{ + "index": 11, + "slug": "editorial-2027-09-mechanism-mentor-series", + "title": "JSON Schema не разрешает операцию: как разделить форму, инвариант и состояние", + "excerpt": "Валидный JSON может описывать недопустимое действие. Разбираем границу между JSON Schema, проверкой связи полей и конфликтом текущего состояния ресурса.", + "contentHtml": "

Сервис принимает запрос на изменение заказа. JSON проходит проверку схемы: поля есть, типы верны, сумма положительная. Через несколько миллисекунд сервис отвечает отказом, потому что заказ уже оплатили или лимит клиента изменился. В логах оба случая часто выглядят одинаково: validation failed. Клиент повторяет запрос, оператор видит лишнюю операцию, а разработчик ищет ошибку то в схеме, то в базе. Цена ошибки — потерянное время и риск повторить действие, которое нельзя повторять.

\n

Причина в том, что слово «валидация» объединяет разные вопросы. JSON Schema отвечает, имеет ли документ допустимую форму. Чистая доменная проверка отвечает, согласованы ли его поля. Сервис проверяет, можно ли применить документ к текущему состоянию и имеет ли пользователь право на действие. Тезис статьи простой: границу нужно проводить по источнику контекста. Пока проверке не нужны база, часы, права или другой запрос, её можно держать на границе документа. Как только появляется внешний контекст, это уже правило операции.

\n

Три вопроса вместо одного «valid»

\n

Первый вопрос относится к форме. Запрос должен быть объектом, limit — целым числом от 1 до 100, а state — одним из заранее объявленных значений. Проверка не читает базу и не вызывает сеть. Для одинакового входа она всегда возвращает одинаковый ответ.

\n

Второй вопрос относится к связи полей. Например, диапазон дат требует, чтобы from не был позже to. Оба значения могут иметь правильный тип и формат, но вместе быть бессмысленными. Это правило можно выразить в схеме, если выбранный диалект и валидатор поддерживают нужную конструкцию. На практике его часто проще вынести в чистую функцию с отдельным тестом. Важно не место записи, а ясная граница ответственности.

\n

Третий вопрос относится к состоянию. Документ может быть безупречным, но ресурс уже изменился. Версия revision: 4 устарела, купон исчерпан, а роль пользователя не позволяет перевести заказ в новый статус. Такой отказ нельзя исправить изменением JSON-типа. Клиенту нужно перечитать ресурс, показать конфликт или прекратить операцию.

\n
Слой проверки и его граница
СлойЧто проверяемПример отказаГде выполнять
ФормаТип, обязательность, диапазон, enumlimit не является integerJSON Schema или валидатор на входе
ИнвариантСвязь нескольких полейfrom позже toЧистая доменная функция
СостояниеАктуальность и доступность ресурсаРевизия уже измениласьСервис, репозиторий, транзакция
ПравоРазрешение на операциюРоль не может отменить заказАвторизация до изменения состояния
\n

Что именно описывает JSON Schema

\n

Схема полезна там, где нужно зафиксировать форму документа и дать одинаковую проверку нескольким потребителям. Она задаёт типы, обязательные ключи, диапазоны, шаблоны, перечисления и структуру вложенных объектов. Она также делает контракт читаемым для инструментов. OpenAPI 3.1 использует модель Schema Object, совместимую с JSON Schema 2020-12 с оговорёнными изменениями, поэтому форму HTTP-запроса удобно держать рядом с описанием endpoint.

\n

Схема не знает, что запись существует именно сейчас. Она не проверяет доступ к строке базы, не сравнивает версию с версией в хранилище и не гарантирует, что внешний сервис ответил успешно. Даже формат даты не равен бизнес-смыслу даты: строка может быть корректной датой, но лежать за пределами периода, в котором разрешена операция.

\n

Неизвестные поля требуют отдельного решения. Закрытая схема с запретом лишних ключей ловит опечатку, но может помешать расширению контракта. Открытая схема облегчает добавление полей, но пропускает ошибочный ключ, который клиент потом молча проигнорирует. Выберите модель для конкретного endpoint и закрепите её тестом. Не меняйте это поведение случайно при обновлении валидатора.

\n
\"Матрица
Один запрос проходит несколько независимых границ. У каждой границы свой источник данных, класс ошибки и следующий шаг клиента.
\n

Учебный пример: сначала форма, потом доменное правило

\n

Ниже — самостоятельный учебный фрагмент без HTTP-сервера и базы данных. Он показывает порядок вычислений, а не готовый production-код. Первая функция проверяет форму фильтра. Вторая проверяет связь полей. Обе функции чистые: их результат зависит только от переданного объекта.

\n
const filterSchema = {\n  type: 'object',\n  additionalProperties: false,\n  properties: {\n    limit: { type: 'integer', minimum: 1, maximum: 100 },\n    from: { type: 'string', format: 'date' },\n    to: { type: 'string', format: 'date' }\n  }\n};\n\nfunction validateRange(input) {\n  if (input.from && input.to && input.from > input.to) {\n    return { ok: false, reason: 'from-after-to' };\n  }\n  return { ok: true };\n}\n\nconst shapeIsValid = validateWithSchema(filterSchema, {\n  limit: 25, from: '2027-09-10', to: '2027-09-12'\n});\nconst rangeIsValid = validateRange({\n  from: '2027-09-12', to: '2027-09-10'\n});
\n

В примере validateWithSchema обозначает вызов выбранной библиотекой JSON Schema. Это намеренное сокращение: конкретные API библиотек различаются, а задача фрагмента — показать две границы. Первый вызов отвечает за форму. Второй не пытается читать ресурс и не решает, есть ли право на поиск. В учебных данных нет утверждения о производительности или поведении в production.

\n

Для изменения ресурса добавляется третий шаг. Клиент отправляет ожидаемую ревизию. Сервис читает текущую ревизию внутри транзакции и применяет изменение только при совпадении. Если в хранилище уже другая версия, сервис возвращает конфликт. Простая проверка до транзакции не защищает от гонки: другой запрос может изменить запись после чтения.

\n
async function updateOrder(command, actor) {\n  const shape = validateCommandShape(command);\n  if (!shape.ok) return httpError(400, shape.reason);\n\n  const domain = validateCommandInvariant(command);\n  if (!domain.ok) return httpError(422, domain.reason);\n\n  if (!canEditOrder(actor, command.orderId)) {\n    return httpError(403, 'forbidden');\n  }\n\n  return db.transaction(async (tx) => {\n    const order = await tx.orders.getForUpdate(command.orderId);\n    if (!order || order.revision !== command.expectedRevision) {\n      return httpError(409, 'state-conflict');\n    }\n    return tx.orders.update(command.orderId, command.patch);\n  });\n}
\n

Этот код тоже ограничен учебной моделью. В реальной системе нужно проверить семантику блокировки, изоляцию транзакции, повторяемость команды и формат ошибки. Нельзя переносить фрагмент в production без проверки конкретного драйвера и правил домена.

\n

Симптом → причина → проверка → действие

\n
Диагностика смешанной валидации
СимптомПричинаПроверкаДействие
Валидатор принимает ключ, а клиент его не используетСхема открыта или контракт не обновилиСверить unknown-key policy и чтение поляЗакрыть объект либо явно документировать расширение
Правильный JSON получает 400Ошибка состояния скрыта под ошибкой формыРазделить логи shape, invariant и stateВернуть отдельный класс ошибки и исправить retry
Повторный запрос иногда меняет уже изменённый ресурсНет идемпотентности или проверки ревизииПовторить команду при конкурентном обновленииДобавить idempotency key или условие версии
Тест схемы проходит, endpoint падаетСхема не покрывает runtime-веткуВызвать реальный handler с теми же даннымиДобавить интеграционный тест на границе
Клиент бесконечно повторяет запросКонфликт обозначен как временная ошибкаПроверить статус и тело ответаРазличить retryable отказ и конфликт ресурса
\n

Порядок внедрения

\n
  1. Назовите endpoint, метод, формат запроса и ожидаемый ответ. Не начинайте с общей функции validate: сначала определите документ.
  2. Выпишите поля, типы, обязательность, enum, диапазоны и политику неизвестных ключей. Сохраните положительный и отрицательный пример.
  3. Отделите правила, которые используют только вход, от правил, которым нужны два поля, часы, права или данные ресурса.
  4. Назначьте класс отказа. Неверная форма, нарушенный инвариант, запрет и конфликт состояния не должны превращаться в один boolean.
  5. Проверьте отрицательный путь: устаревшая ревизия, повтор команды, отсутствие ресурса, запрещённая роль и сетевой отказ должны приводить к ожидаемому действию клиента.
  6. Запустите проверку на реальном handler и на выбранной библиотеке схем. Unit-тест чистой функции не заменяет интеграционный тест.
  7. Опишите безопасное расширение. Если добавляется поле или значение enum, проверьте старого потребителя и решите, нужен ли период совместимости.
  8. Зафиксируйте наблюдаемый критерий готовности: каждый класс отказа имеет тест, статус, причину и понятный следующий шаг.
\n

Ограничения механизма

\n

Разделение слоёв не устраняет сложность домена. Схема может стать слишком строгой. Чистая функция может повторить правило, которое уже проверяет база. Транзакция может быть недоступна для внешнего сервиса. Авторизация может зависеть от времени и нескольких систем. В таких случаях нужно явно описать границу и риск, а не расширять JSON Schema до роли универсального движка правил.

\n

Статус HTTP тоже не заменяет доменный контракт. 409 Conflict подходит для конфликта с текущим состоянием ресурса, но тело ответа должно объяснить, что проверил сервис и что может сделать клиент. 422 может обозначать семантически неприемлемый документ, если это решение согласовано в API. Важна не магическая цифра, а стабильное различие между исправлением входа и разрешением конфликта.

\n

Нельзя заявлять, что схема доказала корректность операции. Проверяемый результат уже скромнее и полезнее: неправильная форма отсекается на границе, локальное правило имеет отдельный отрицательный тест, а конкурентное изменение ресурса не проходит без ясного конфликта. Это снижает конкретный риск, но не доказывает отсутствие всех дефектов.

\n

Критерий готовности

\n

Решение для выбранного endpoint готово, когда команда может показать четыре независимых теста: схема отклоняет неправильный тип, доменная функция отклоняет неверную связь полей, авторизация отклоняет запрещённого актёра, а транзакция отклоняет устаревшую ревизию. Для каждого теста указаны статус, причина и действие клиента. Положительный сценарий тоже проходит через тот же порядок. Если один из этих вопросов пока отвечает только фразой «валидатор всё проверит», граница ещё не проведена.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/012.json b/editorial/agent-rewrites/012.json new file mode 100644 index 0000000..0e14d02 --- /dev/null +++ b/editorial/agent-rewrites/012.json @@ -0,0 +1,7 @@ +{ + "index": 12, + "slug": "editorial-2027-09-practice-mentor-series", + "title": "Совместимый API-ответ: как поймать breaking change до релиза", + "excerpt": "Разбираем ответ HTTP-метода на границе сервиса: какие изменения ломают старого клиента, как отделить форму JSON от состояния системы и чем доказать совместимость.", + "contentHtml": "

Сервис возвращает HTTP 200, но клиент падает при разборе ответа. В логах появляется ошибка чтения поля, неизвестное значение перечисления или попытка вызвать метод у числа вместо строки. Сервер считает запрос успешным, а потребитель — нет. Цена ошибки растёт быстро: приходится искать все версии клиента, откатывать серверный код и решать, не потеряны ли уже записи, созданные по новой схеме.

\\n

Причина обычно не в синтаксической ошибке JSON. Команда меняет форму ответа как внутреннюю модель и не замечает потребителей. Она удаляет поле, делает новое поле обязательным, меняет тип или добавляет значение в enum. Каждый такой diff имеет собственный риск. Тезис простой: API-ответ нужно проверять как контракт двух сторон — по форме, по поведению старого клиента и по условиям, которые схема не описывает.

\\n

Что именно обещает ответ

\\n

Контракт начинается с конкретной границы: метод, путь, статус, media type и тело. Для GET /customers/{id} можно зафиксировать объект с обязательными полями id, revision и state. У id строковый тип. У revision положительное целое число. У state закрытый набор значений active и blocked.

\\n

Эта форма отвечает на вопрос «можно ли разобрать JSON». Она не отвечает на вопросы «имеет ли пользователь право видеть клиента» и «не устарела ли ревизия записи». Эти проверки относятся к авторизации и состоянию. Если смешать их со схемой, ответ об ошибке станет неточным: клиент не поймёт, нужно ли исправить запрос, обновить данные или прекратить повторные попытки.

\\n
GET /customers/{id}\\nAccept: application/json\\n\\n200 OK\\nContent-Type: application/json\\n\\n{\\n  "id": "customer-17",\\n  "revision": 4,\\n  "state": "active"\\n}
\\n

Успешный ответ не становится совместимым только потому, что его принимает парсер JSON. Клиент может ветвить логику по state, строить URL из id и сравнивать revision с локальной версией. Сохранить синтаксис недостаточно: нужно сохранить значения и смысл, на которые опирается старый код.

\\n

Изменения, которые требуют решения

\\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый клиент получает ошибку при чтении поляПоле удалили или изменили его типСравнить старую и новую схему и найти чтения поляСохранить поле или выпустить новую версию
Клиент попадает в ветку «неизвестное состояние»Enum расширили без обработки нового значенияПрогнать старый switch на каждом значенииДобавить обработку либо не включать значение в старый контракт
Запросы начинают отклоняться после обновленияНовое поле объявили обязательнымОтправить старую форму без поляСделать поле необязательным или изменить версию
Клиент принимает ответ, но действует по неверной веткеСохранили тип, но изменили смысл значенияПроверить примеры поведения, а не только JSON SchemaСохранить семантику или переименовать поле
Ответ формально верен, но операция получает отказНарушено право или текущее состояние ресурсаПроверить авторизацию и условие версии отдельноВернуть точный 403/409 и не маскировать его под 400
\\n

Добавление необязательного поля чаще всего совместимо: старый клиент его игнорирует. Но это правило действует только для потребителя, который действительно игнорирует неизвестные свойства. У строгого декодера или схемы с запретом дополнительных полей появится отказ. Поэтому решение принимают по реальным правилам клиента, а не по названию изменения.

\\n

Учебный валидатор на границе

\\n

Следующая функция показывает минимальную проверку ответа. Она не ходит в сеть и не читает базу. Входом служит уже разобранный JavaScript-объект. Функция принимает только известную форму и возвращает нормализованное значение. Это учебный пример: он показывает границу контракта, но не заменяет OpenAPI, JSON Schema, интеграционный тест или авторизацию.

\\n
function validateCustomerResponse(payload) {\\n  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\\n    return { ok: false, reason: 'body-must-be-object' };\\n  }\\n\\n  if (typeof payload.id !== 'string' || payload.id.length === 0) {\\n    return { ok: false, reason: 'id-must-be-non-empty-string' };\\n  }\\n\\n  if (!Number.isInteger(payload.revision) || payload.revision < 1) {\\n    return { ok: false, reason: 'revision-must-be-positive-integer' };\\n  }\\n\\n  if (!['active', 'blocked'].includes(payload.state)) {\\n    return { ok: false, reason: 'state-is-outside-enum' };\\n  }\\n\\n  return {\\n    ok: true,\\n    value: { id: payload.id, revision: payload.revision, state: payload.state },\\n  };\\n}\\n\\nconst accepted = validateCustomerResponse({\\n  id: 'customer-17', revision: 4, state: 'active',\\n});\\nconst rejected = validateCustomerResponse({\\n  id: 'customer-17', revision: 4, state: 'deleted',\\n});\\n\\nconsole.log(accepted.ok, accepted.value.state);\\nconsole.log(rejected.ok, rejected.reason);\\n// true active\\n// false state-is-outside-enum
\\n

Отдельный отрицательный пример важнее ещё одного успешного fixture. Если сервер начнёт отправлять state: deleted, валидатор обнаружит изменение до того, как клиент выполнит неверную ветку. Если сервер отправит revision: "4", отказ произойдёт по типу. Если поле исчезнет, причина должна назвать поле, а не скрыться за общим сообщением invalid response.

\\n
\"Схема
Граница совместимости состоит из трёх проверок: форма ответа, поведение потребителя и условия операции. Схема не доказывает наличие права доступа или актуальность данных.
\\n

Порядок проверки перед изменением

\\n
  1. Назовите endpoint, метод, статус и media type. Отделите тело ответа от заголовков, запроса и внутренней модели.
  2. Снимите текущую форму: обязательные поля, типы, nullable, enum и значения по умолчанию. Сохраните один успешный и несколько отрицательных примеров.
  3. Найдите потребителей. Проверьте чтение полей, ветвления по enum, строгие декодеры и преобразователи DTO. Один найденный клиент не доказывает, что найден каждый.
  4. Сравните старую и новую форму. Отдельно отметьте удаление поля, изменение типа, сужение enum и появление обязательного свойства.
  5. Запустите runtime-валидатор на старом и новом ответе. Ошибка должна указывать путь к полю и причину отказа.
  6. Прогоните consumer contract test со старым клиентом. Проверяйте не только десериализацию, но и ветку поведения для каждого допустимого значения.
  7. Проверьте отрицательный путь: неизвестное поле, пропущенное поле, неверный тип, неизвестное enum-значение, 403 и конфликт версии. Для каждого случая зафиксируйте ожидаемый статус.
  8. Если изменение несовместимо, выберите действие: сохранить старое поле, добавить новое рядом, открыть период deprecated или выпустить новую версию. Запишите условие удаления.
\\n

Где заканчивается JSON Schema

\\n

Схема хорошо описывает типы, обязательность и ограничения документа. Она может запретить лишние поля или определить ветвление по значению. Но она не видит пользователя, базу и время. Ответ state: active может быть синтаксически правильным, хотя запись уже заблокирована. Значение revision: 4 не доказывает, что обновление с ревизией 3 ещё допустимо.

\\n

Состояние требует отдельного протокола. Для конкурентного обновления подойдут версия ресурса и условный запрос с If-Match; для права — проверка роли до изменения; для отсутствующего ресурса — договорённый статус 404. Не превращайте 409 в 400: клиенту нужен сигнал, что запрос сформирован правильно, но состояние изменилось. Не повторяйте 403 автоматически: повтор не добавит прав.

\\n

Есть и отрицательный путь для самой проверки. Схема может пройти, а реальный handler — вернуть другой ответ в исключении. Поэтому contract test должен вызвать маршрут, проверить статус, заголовок и тело. Runtime-проверка должна работать на фактическом ответе, а не только на вручную собранном объекте. Это снижает конкретный риск, но не доказывает, что список потребителей полон.

\\n

Ограничения и критерий готовности

\\n

Учебная функция не проверяет OpenAPI-документ, сериализацию фреймворка, авторизацию, транзакции, сетевые сбои и содержимое базы. Она также не решает вопрос обратной совместимости для неизвестного клиента. Эти границы нужно оставить видимыми, иначе локальный зелёный тест создаст ложное чувство безопасности.

\\n

Изменение готово к выпуску, если команда может показать четыре доказательства: новая форма проходит schema- и runtime-проверку; старый клиент проходит consumer contract test; отрицательные случаи возвращают согласованные статусы и причины; для breaking change указаны версия, период совместимости и проверяемое условие удаления. Если хотя бы одного доказательства нет, результат следует считать непроверенным, даже когда сборка завершилась успешно.

\\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/013.json b/editorial/agent-rewrites/013.json new file mode 100644 index 0000000..86b3f09 --- /dev/null +++ b/editorial/agent-rewrites/013.json @@ -0,0 +1,7 @@ +{ + "index": 13, + "slug": "editorial-2027-08-field-security-capstone", + "title": "Авторизация, которую можно доказать: от симптома до отрицательного теста", + "excerpt": "Доступ к своему профилю разрешён, к чужому — тоже. Разбираем, как связать security-требование, объектную политику, HTTP-проверку и доказательство отказа.", + "contentHtml": "

Пользователь открывает свой профиль, а затем меняет один идентификатор в URL и получает профиль другого пользователя. В журналах виден успешный ответ 200. В интерфейсе нет кнопки для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого. Исправление XSS в форме не закрывает этот путь. Экран может быть безопасным, а endpoint — нет.

\n

Тезис статьи простой: security-проверка готова только тогда, когда требование связано с конкретным субъектом, объектом и действием, а разрешение и отказ подтверждены на границе HTTP. Строка «авторизация проверена» ничего не доказывает. Доказательство содержит вход, ожидаемый ответ, фактический ответ и понятную причину расхождения.

\n

Почему happy path вводит в заблуждение

\n

Проверка владельца обычно начинается с положительного сценария. Пользователь u-1 запрашивает profile u-1 и получает 200. Этот тест показывает, что легитимный запрос работает. Он не показывает, что policy остановит profile u-2. Без отрицательной строки система может разрешать оба запроса.

\n

Причина часто появляется на стыке слоёв. Handler получает id из URL. Middleware проверяет, что токен действителен. Репозиторий ищет запись по одному id. Если проверка владельца не входит в запрос или выполняется после чтения, соседний объект уже попал в ответ. Кэш способен усилить ошибку: ответ для u-1 сохранится под ключом profile:42 и станет доступен u-2.

\n

Нужна объектная проверка. Субъект приходит из проверенного контекста сессии или токена. Объект приходит из маршрута и базы. Действие задаёт endpoint. Решение принимает код рядом с границей доступа, а не только компонент интерфейса.

\n

Требование превращается в матрицу

\n

Фраза «пользователь видит только свой профиль» слишком короткая для теста. Разложите её на четыре поля: кто действует, что делает, над каким объектом и какой результат допустим. Для профиля минимальная политика выглядит так: user может read свой profile; user не может read чужой profile; неизвестная роль получает deny; admin может read audit, если это входит в контракт.

\n
Минимальная матрица политики профиля
СубъектДействиеОбъектОжидаемый результат
user u-1readprofile u-1allow, 200
user u-1readprofile u-2deny, 403 или 404
user u-1readauditdeny, 403 или 404
unknownreadprofile u-1deny, 401 или 403
\n

Статус зависит от контракта. 403 сообщает, что запрос распознан, но запрещён. 404 иногда скрывает существование чужого объекта. Важно не выбрать «правильный» код вообще, а закрепить один вариант для конкретного endpoint и проверить его на внешней границе.

\n

Идентификатор требования должен быть стабильным. Например, AUTH-PROFILE-01 связывает описание политики, тесты и запись изменения. Если требования ссылаются на OWASP ASVS, фиксируйте версию стандарта в ссылке. Идентификатор без версии может начать означать другой текст после обновления стандарта.

\n

Механизм: deny по умолчанию и проверка владения

\n

Политика должна сначала отказывать, а затем явно разрешать узкие комбинации. Для профиля нельзя проверять только роль. Два пользователя имеют одну роль, но разные объекты. Нельзя проверять только наличие токена. Валидная сессия не даёт доступ ко всем ресурсам.

\n
function decide({ role, subjectId, action, resource }) {\n  if (!role || !subjectId || !resource) {\n    return { status: 'deny', reason: 'invalid-input' };\n  }\n\n  if (role === 'admin' && action === 'read' && resource.kind === 'audit') {\n    return { status: 'allow', reason: 'role-permission' };\n  }\n\n  if (role === 'user' && action === 'read' &&\n      resource.kind === 'profile' && resource.ownerId === subjectId) {\n    return { status: 'allow', reason: 'object-ownership' };\n  }\n\n  return { status: 'deny', reason: 'default-deny' };\n}
\n

Код выше — учебный пример. Он не является готовым middleware и не проверяет токен, CSRF, tenant, срок сессии, rate limit, кэш или журналирование. Его задача — показать форму решения: функция принимает субъект, действие и объект; результат содержит статус и безопасную причину. Не передавайте в клиент внутренние сведения о policy. Причину используйте внутри теста и журнала с учётом правил о чувствительных данных.

\n

В реальном handler сначала извлеките субъект из уже проверенного контекста, затем загрузите объект с учётом tenant и вызовите policy. Не принимайте ownerId из тела запроса как доказательство владения. Клиент может изменить это поле. Источник владельца — доверенная запись на сервере.

\n
Цикл проверки: требование, вход, отрицательный тест, результат и разбор расхождения.
Проверка возвращает строку матрицы в разбор, если фактический результат отличается от ожидаемого. Отрицательные случаи остаются частью доказательства.
\n

Тестируем отказ как ожидаемый результат

\n

Отказ не должен считаться исключением теста. Он должен быть ожидаемым результатом конкретного входа. Для каждой строки зафиксируйте имя, вход и результат. Так падение отвечает на вопрос: policy разрешила чужой объект, middleware потерял subject или handler вернул неверный статус.

\n
import assert from 'node:assert/strict';\n\nconst cases = [\n  ['owner reads own profile',\n    { role: 'user', subjectId: 'u-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-1' } },\n    { status: 'allow', reason: 'object-ownership' }],\n  ['owner cannot read foreign profile',\n    { role: 'user', subjectId: 'u-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-2' } },\n    { status: 'deny', reason: 'default-deny' }],\n  ['unknown role is denied',\n    { role: 'guest', subjectId: 'u-1', action: 'read',\n      resource: { kind: 'profile', ownerId: 'u-1' } },\n    { status: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n  assert.deepEqual(decide(input), expected, name);\n  console.log('PASS', name);\n}
\n

Этот фрагмент также учебный. Его можно выполнить только после добавления функции decide из предыдущего фрагмента. Он не обращается к базе и не доказывает безопасность HTTP-слоя. Если такой unit-тест зелёный, это доказывает только выбранные ветки функции. Следующий тест должен вызвать реальный handler с двумя субъектами и двумя объектами.

\n

Симптом → причина → проверка → действие

\n
Диагностика типовых расхождений
СимптомПричинаПроверкаДействие
Чужой профиль отвечает 200Проверили токен, но не владельца объектаПовторить запрос с u-1 к profile u-2Добавить object-level deny в handler или policy
User читает auditРоль проверяют по наличию, а не по разрешениюЗапросить ресурс с user и adminСделать список разрешённых role/action/resource явным
Неизвестная роль получает доступВетка по умолчанию разрешает запросПодать роль guest или пустую рольВернуть deny до всех allow-веток
После исправления UI тест зелёный, API уязвимПроверяли только скрытую кнопкуВызвать endpoint напрямую без браузерного интерфейсаПеренести контроль на серверную границу и добавить HTTP-тест
Иногда виден чужой ответКлюч кэша не содержит tenant или subjectПовторить запрос после прогрева кэша разными пользователямиРазделить ключи или запретить кэширование приватного ответа
\n

Порядок проверки

\n
  1. Выберите один endpoint, который возвращает или меняет объект по идентификатору.
  2. Запишите субъект, действие, объект и внешний результат в строке требования.
  3. Найдите источник каждого поля. Subject должен прийти из доверенного контекста, owner — из серверной записи, action — из маршрута.
  4. Добавьте один разрешённый случай и минимум три отказа: чужой объект, неподходящая роль и неизвестный объект или действие.
  5. Проверьте policy отдельно, затем вызовите handler напрямую без UI.
  6. Проверьте кэш, tenant-границу, сериализацию ошибки и отсутствие утечки существования объекта.
  7. Сохраните фактические статус, тело ответа и имя проверки. При расхождении исправьте policy или контракт, а не удаляйте отрицательную строку.
\n

Ограничения и отрицательный путь

\n

Матрица не заменяет модель угроз. Она не проверяет CSRF для изменяющего запроса, подделку токена, SSRF, загрузку файлов, гонки, обход маршрутизации и настройку reverse proxy. Для multi-tenant системы добавьте tenant в субъект и объект. Для массовых операций проверьте каждый объект, а не только первый. Для файлов отдельная проверка должна учитывать тип содержимого, имя, хранение вне webroot и выдачу через авторизованный обработчик.

\n

Не считайте зелёный unit-тест доказательством всей защиты. Отрицательный путь может сломаться между слоями: policy вернула deny, но adapter превратил его в 200; handler вернул 403, но кэш отдал старый 200; база загрузила запись другого tenant до проверки. Поэтому критерий должен проходить через реальный маршрут.

\n

Проверяемый критерий готовности

\n

Для выбранного endpoint есть версия требования, матрица входов и автоматическая проверка. Запрос владельца получает закреплённый allow-ответ. Запрос к чужому объекту, неизвестная роль и недопустимое действие получают закреплённый deny-ответ. Прямой HTTP-вызов подтверждает это без UI. Повторная проверка после прогрева кэша не меняет результат между субъектами. В логе остаётся безопасный идентификатор проверки, но не секрет и не лишние персональные данные.

\n

Если хотя бы одна строка не имеет фактического результата, проверка не закончена. Если результат зависит от случайного состояния, сначала стабилизируйте окружение и зафиксируйте границу. Готовность — это не отсутствие замечаний в чек-листе. Это воспроизводимый отказ на запрещённом входе и воспроизводимое разрешение на допустимом.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/014.json b/editorial/agent-rewrites/014.json new file mode 100644 index 0000000..72c4255 --- /dev/null +++ b/editorial/agent-rewrites/014.json @@ -0,0 +1,7 @@ +{ + "index": 14, + "slug": "editorial-2027-08-mechanism-security-capstone", + "title": "Проверка логина не защищает объект: строим deny-by-default", + "excerpt": "Пользователь может быть правильно аутентифицирован и всё равно не иметь права читать выбранную запись. Разбираем объектную авторизацию, проверку владельца и отрицательный путь от URL до ответа 403.", + "contentHtml": "

Пользователь входит в систему, открывает /profile?id=u-1, меняет один символ и получает профиль u-2. Токен остаётся действительным. Сервер проверяет его наличие и отдаёт найденную запись. Симптом выглядит как обычный доступ к странице, но это горизонтальная эскалация прав.

Цена ошибки — утечка персональных данных, изменение чужих объектов и потеря границы между tenant-ами. Чем больше endpoint-ов строится по схеме «взяли id из URL, нашли запись, вернули ответ», тем больше таких точек появляется. Скрытая кнопка в интерфейсе не помогает: запрос можно повторить вручную.

Тезис: логин устанавливает субъекта, но не разрешение

Аутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Между вопросами стоят роль, область доступа и принадлежность записи. Если сервер не проверяет их отдельно, валидная сессия превращается в пропуск к любому известному идентификатору.

Надёжное решение принимает четыре значения: субъект, действие, объект и контекст политики. Субъект приходит из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода. Объект загружается сервером. Владелец, tenant и чувствительность объекта берутся из доверенных данных, а не из тела запроса. Неизвестная комбинация получает deny.

Механизм объектной проверки

Сначала middleware или слой сессии устанавливает subjectId и роль. Затем handler разбирает путь и получает идентификатор ресурса. Репозиторий возвращает объект вместе с его владельцем и tenant-ом. Policy layer сравнивает эти поля с субъектом и разрешает только явно описанные действия. UI может скрыть недоступную кнопку, но решение всё равно принимает сервер.

Правило владельца нельзя заменить проверкой роли. Два пользователя могут иметь одну роль user, но видеть разные профили. Роль говорит о классе полномочий. Владелец говорит о конкретном объекте. Для администратора нужен отдельный allow-список: «читать аудит» не равно «читать любую персональную запись», а «администратор» не должно означать «разрешено всё».

Нельзя принять ownerId из JSON и использовать его как доказательство владения. Клиент сообщает, какой объект он хочет выбрать. Сервер сам читает владельца из базы или доменного сервиса. Для multi-tenant системы запрос к хранилищу должен сразу включать tenant boundary. Если сначала получить запись без ограничения области, последующая проверка уже может оказаться слишком поздней.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Замена id в URL показывает чужой профильПроверили сессию, но не владельца объектаОтправить запрос для своего и соседнего идентификатораСравнивать subjectId с владельцем записи; чужой объект отклонять
Пользователь читает audit endpointРоль проверяют слишком широкоВызвать endpoint с ролью user напрямую, без UIОписать ресурс и действие в отдельном allow-правиле
Неизвестная роль получает 200Ветка по умолчанию пропускает запросУдалить или подменить claim и повторить вызовСделать результатом по умолчанию deny
Чужой ответ появляется после кэшированияКлюч кэша не содержит субъекта или областиПовторить запрос разными субъектами и сравнить телоРазделить кэш по permission context либо не кэшировать ответ
403 раскрывает существование записиВнешний ответ повторяет внутреннюю причинуСравнить ответ для отсутствующего и чужого объектаВыбрать 404 или 403 по модели угроз, причину логировать безопасно
Матрица авторизации связывает субъекта, роль, действие, объект и владельца с решением allow или deny
Решение строится на серверных атрибутах объекта. Изменение идентификатора в запросе не меняет его владельца и tenant.

Учебный endpoint

Ниже — маленький пример на Node.js. Профили хранятся в Map, а субъект и роль передаются заголовками только для учебного сценария. Реальная система должна получать их из проверенной сессии, JWT или другого принятого механизма. Пример не подключается к production и не доказывает безопасность конкретного приложения.

const profiles = new Map([['u-1', { owner: 'u-1', tenant: 't-1' }], ['u-2', { owner: 'u-2', tenant: 't-1' }]]); function decide({ role, subjectId, tenant, resource, action }) { if (role === 'admin' && resource.kind === 'audit' && action === 'read') return { status: 200, reason: 'admin-audit' }; if (role === 'user' && resource.kind === 'profile' && action === 'read' && resource.tenant === tenant && resource.owner === subjectId) return { status: 200, reason: 'owner' }; return { status: 403, reason: 'default-deny' }; } const id = new URL(request.url, 'http://local').searchParams.get('id'); const profile = profiles.get(id); const resource = profile && { kind: 'profile', ...profile }; const result = resource ? decide({ role, subjectId, tenant, resource, action: 'read' }) : { status: 404, reason: 'not-found' };

Условие для профиля проверяет роль, действие, tenant и владельца. Запрос от u-1 к u-1 получает 200. Тот же субъект к u-2 получает 403. Если tenant отличается, результат также deny. Идентификатор из URL только выбирает запись; он не назначает ей владельца.

Проверка существования объекта требует отдельного решения. В примере отсутствующий профиль даёт 404, а найденный чужой — 403. В некоторых системах оба случая наружу превращают в 404, чтобы не раскрывать наличие записи. Это не универсальное правило. Выбор зависит от модели угроз. Внутри сохраняйте короткий класс причины и не пишите в журнал полный токен или секретные параметры.

Отрицательный путь важнее happy path

Разрешённый запрос показывает, что легитимный сценарий работает. Он не показывает, что граница закрыта. Минимальный набор должен включать чужой объект, запрещённое действие, неизвестную роль, другой tenant, отсутствующий ресурс и повторный вызов через прямой HTTP-клиент. Для mutation добавьте проверку метода и защиту от повторной операции. Для чтения проверьте кэш и сериализацию ответа.

Проверяйте policy без интерфейса. Если тест кликает только по видимой кнопке, он не проверяет handler. Отправьте запрос с изменённым id, вручную задайте роль и удалите обязательный claim. Эти входы учебные и не должны содержать реальные идентификаторы или секреты. Их смысл — показать отрицательную ветку, а не воспроизвести доступ к настоящим данным.

Порядок внедрения

  1. Составьте карту одного endpoint-а: субъект, HTTP-метод, действие, объект, tenant и внешний ответ.
  2. Определите доверенный источник каждого поля. Не берите роль, владельца и tenant из пользовательского body.
  3. Загрузите объект в правильной области доступа. Для tenant-системы включите tenant в запрос к хранилищу.
  4. Запишите явные allow-правила для ресурса и действия. Оставьте deny результатом для неизвестной комбинации.
  5. Добавьте тесты своего объекта, чужого объекта, запрещённого действия, неизвестной роли и другой области.
  6. Вызовите handler напрямую, без UI, и проверьте код, тело, кэш и отсутствие лишних данных в ответе.
  7. Настройте безопасное журналирование: причина должна помогать расследованию, но не содержать токены, пароли и полный URL с секретами.
  8. Повторите отрицательные тесты после изменения middleware, репозитория, политики и ключа кэша.

Ограничения и критерий готовности

Учебный код использует заголовки вместо настоящей аутентификации и Map вместо базы. Он не проверяет срок жизни токена, подпись JWT, CSRF, race condition, права на поля, согласованность реплик и поведение прокси. Он также не решает, как кэшировать персональный ответ. Эти вопросы требуют отдельных контрактов и тестов.

Проверка готова для одного endpoint-а, если видны источник subjectId, правило области, серверный способ получения владельца, явное действие и default deny. Интеграционный тест должен показать 200 для разрешённого объекта, отказ для чужого объекта и отказ для неизвестной роли через реальный HTTP-маршрут. Если проходит только unit-тест policy или только проверка UI, работа не готова: граница между запросом, хранилищем и ответом ещё не доказана.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/015.json b/editorial/agent-rewrites/015.json new file mode 100644 index 0000000..7067bb5 --- /dev/null +++ b/editorial/agent-rewrites/015.json @@ -0,0 +1,7 @@ +{ + "index": 15, + "slug": "editorial-2027-08-practice-security-capstone", + "title": "SSRF начинается с URL: проверяем адрес до сетевого вызова", + "excerpt": "Практическая защита server-side запроса: разбираем URL, применяем точный allowlist, запрещаем обход через credentials и редиректы, а затем ограничиваем сам сетевой вызов.", + "contentHtml": "

Сервис принимает URL картинки и скачивает её на сервере. Пользователь вводит https://cdn.example.test/avatar.jpg, но тот же endpoint может получить https://cdn.example.test@127.0.0.1/admin или адрес внутреннего metadata-сервиса. Если проверка ищет только подстроку https://cdn.example.test, сервер сам отправляет запрос не туда. Цена ошибки — чтение внутреннего ответа, обращение к административному интерфейсу и утечка данных через внешний ответ или журнал.

\n

Тезис простой: SSRF нужно останавливать до сетевого вызова и проверять разобранные поля URL, а не похожесть исходной строки. После этого нужны отдельные ограничения redirect, DNS, IP, порта, времени и размера ответа. Учебный код ниже возвращает решение политики, но не выполняет запрос и не доказывает безопасность конкретной сети.

\n

Как возникает ошибка

\n

URL содержит схему, authority, имя пользователя и пароль, hostname, порт, путь и query. Эти части имеют разную роль. Строка до символа @ может выглядеть как доверенное имя, но фактический host находится после него. В адресе https://cdn.example.test@127.0.0.1/file значение url.hostname — 127.0.0.1. Сравнение исходной строки не отвечает на вопрос, куда подключится клиент.

\n

Первый слой принимает только нужную схему. Для загрузки изображения это обычно https:. Второй слой запрещает username и password. Третий слой сравнивает нормализованный hostname с точным allowlist. Четвёртый слой решает, какие порты и пути разрешены. Только после этих проверок приложение может передать адрес сетевому клиенту.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Доверенный» URL обращается к loopbackПроверяли строковый префикс или часть до @Распарсить URL и вывести только hostnameЗапретить credentials и сравнивать фактический host
Проходит похожий доменИспользовали endsWith без границы имениПроверить evil-example.test и sub.example.testРазрешать точное имя или явный суффикс .example.test
Запрос уходит на другой адрес после 302Клиент автоматически следует redirectПерехватить заголовок LocationЗапретить redirect или повторить политику для каждого нового URL
Имя разрешено, IP закрытыйПроверен hostname, но не результат DNSПроверить A и AAAA и диапазоны адресовСверить адреса с политикой и контролировать egress
Разрешённый ответ занимает памятьЕсть allowlist, но нет лимита телаПроверить Content-Length и поток чтенияОстановить чтение после заданного размера и ограничить timeout
\n
\"Проверка
Каждое решение принимается до вызова сети. Отказ возвращает причину, но не передаёт непроверенный адрес следующему слою.
\n

Allowlist должен описывать ресурс

\n

Точный allowlist проще проверить, чем набор отрицательных исключений. Для одного CDN можно разрешить только cdn.example.test. Если нужны поддомены, правило должно различать границу: имя равно example.test или заканчивается на .example.test. Условие hostname.endsWith('example.test') пропустит evil-example.test. Оно также не отвечает, разрешён ли вложенный сервис и кто владеет его DNS-записью.

\n

Не принимайте список разрешённых доменов из запроса. Конфигурация принадлежит серверу и меняется через контролируемую поставку. Сравнивайте hostname после разбора стандартным URL-парсером. Не подставляйте вручную протокол, не вырезайте query регулярным выражением и не используйте отображаемое пользователю значение как доказательство адреса.

\n

Порт нужно ограничить отдельно. Явный :8443 — это не тот же сетевой контракт, что порт 443. Если приложение разрешает нестандартный порт, перечислите его для конкретного hostname. Запретите пустой или неожиданный порт, если клиент или прокси трактует его по-разному. Путь тоже может быть частью политики: CDN может разрешать только каталог изображений, а не весь host.

\n

Учебная проверка без запроса

\n

Функция принимает строку и массив имён. Она возвращает { allowed, reason, href }. Функция не вызывает fetch, не разрешает DNS и не проверяет корпоративный proxy. Поэтому её можно использовать только как маленький учебный пример для проверки порядка решений. Доменные имена в примерах вымышлены и не подтверждают наличие реального сервиса.

\n
function validateRemoteUrl(value, allowedHosts) {\n  let url;\n  try {\n    url = new URL(value);\n  } catch {\n    return { allowed: false, reason: 'invalid-url' };\n  }\n\n  if (url.protocol !== 'https:') {\n    return { allowed: false, reason: 'scheme' };\n  }\n  if (url.username || url.password) {\n    return { allowed: false, reason: 'credentials' };\n  }\n  if (url.port && url.port !== '443') {\n    return { allowed: false, reason: 'port' };\n  }\n  if (!allowedHosts.includes(url.hostname)) {\n    return { allowed: false, reason: 'host' };\n  }\n\n  return { allowed: true, reason: 'allowlist', href: url.href };\n}\n\nvalidateRemoteUrl(\n  'https://cdn.example.test/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: true, reason: 'allowlist', href: ... }\n\nvalidateRemoteUrl(\n  'https://cdn.example.test@127.0.0.1/file.jpg',\n  ['cdn.example.test'],\n);\n// { allowed: false, reason: 'credentials' }
\n

Первый пример проходит allowlist. Во втором функция останавливается на credentials. Адрес https://127.0.0.1/file.jpg остановится на hostname. Это отрицательный путь: приложение не должно сначала выполнить запрос, а потом решить, был ли адрес допустим. Не включайте полный входной URL в лог отказа. В нём могут быть пароль, token или query с персональными данными.

\n

Редирект меняет цель

\n

Даже разрешённый CDN может ответить 301 или 302 с новым Location. После redirect исходный allowlist уже не описывает конечную цель. Надёжный вариант для простого endpoint — отключить автоматическое следование и вернуть отказ с классом redirect-not-allowed. Если перенаправление нужно по требованиям продукта, каждый новый URL должен пройти ту же проверку схемы, credentials, host и порта. Ограничьте число переходов.

\n

Проверяйте redirect до чтения тела ответа. Не разрешайте схему, отличную от исходной, если это не входит в явную политику. Не считайте относительный Location безопасным автоматически: его нужно разрешить относительно уже проверенного URL и снова проверить результат. Учебная функция выше redirect не обрабатывает. Это осознанная граница, а не пропущенная ветка.

\n

DNS и сетевой слой

\n

Проверка имени не доказывает, что соединение пойдёт к безопасному IP. DNS может вернуть несколько адресов. Запись может измениться между проверкой и подключением. Возможны IPv4, IPv6, loopback, link-local и приватные диапазоны. Политика должна решить, какие адреса допустимы, и применить это решение ко всем результатам A и AAAA там, где это важно для модели угроз.

\n

В чувствительной сети приложение и egress-шлюз должны дополнять друг друга. Приложение проверяет контракт endpoint. Сетевой слой запрещает выход к metadata, loopback и приватным сегментам, если они не нужны. Ни один слой не должен молча считать другой слой достаточным. Если библиотека клиента кеширует DNS или сама разрешает redirect, это нужно проверить в её документации и тесте.

\n

DNS rebinding и proxy могут изменить момент, в который адрес превращается в соединение. Не обещайте защиту одной функцией validateRemoteUrl. Для конкретного runtime нужен способ связать проверенный адрес с фактическим соединением или вынести запрос в изолированный egress-сервис с собственной политикой.

\n

Ограничения сетевого вызова

\n

Allowlist не ограничивает объём ответа. Сервер может вернуть большой файл, бесконечный поток или медленное тело. Задайте общий deadline, timeout установления соединения, максимальный размер ответа и ограничение числа одновременных загрузок. Проверяйте размер по заголовку, но не доверяйте ему как единственному барьеру: поток нужно прекращать при достижении лимита.

\n

Не принимайте ответ только потому, что его Content-Type похож на изображение. Сначала ограничьте размер и поток, затем проверяйте формат отдельной библиотекой и сохраняйте результат вне webroot по серверному имени. Это уже другая граница системы, но SSRF endpoint часто совмещает загрузку, декодирование и публикацию. Ошибка на одном шаге не должна превращать следующий в обход.

\n

Порядок внедрения

\n
  1. Найдите каждый endpoint, который получает URL или косвенно строит его из пользовательского ввода. Запишите цель запроса и побочный эффект.
  2. Опишите политику в конфигурации: схемы, точные hostname, допустимые порты, пути, redirect, размер и deadline.
  3. Разберите URL стандартным парсером до любого DNS или HTTP-вызова. Отдельно запретите username, password, неожиданные схемы и некорректные порты.
  4. Добавьте отрицательные тесты для @, похожего домена, loopback, IPv6, приватного IP, нестандартного порта, пустого host и невалидного URL.
  5. Определите поведение redirect. По умолчанию отключите его; при необходимости проверяйте каждый новый адрес и ограничьте число переходов.
  6. Сверьте все адреса A и AAAA с сетевой политикой и проверьте фактические правила egress. Не подменяйте этот шаг строковым сравнением hostname.
  7. Задайте timeout, общий deadline, максимальный размер тела и лимит параллельных операций. Проверьте, что превышение каждого лимита останавливает чтение.
  8. Логируйте безопасную причину отказа, hostname или хэш операции и correlation id. Не записывайте credentials, query с секретами и полный URL без очистки.
  9. Покажите тестом, что запрещённый адрес не дошёл до сетевого клиента. Для разрешённого адреса отдельно проверьте статус, размер, формат ответа и обработку ошибки.
\n

Ограничения и критерий готовности

\n

Учебный валидатор не знает DNS, proxy, балансировщик, сетевые ACL и поведение конкретной HTTP-библиотеки. Allowlist домена не доказывает отсутствие DNS rebinding. Запрет redirect не решает проблему доступа к разрешённому, но скомпрометированному host. Лимит ответа не заменяет авторизацию и проверку формата. Эти ограничения нужно оставить в техническом контракте, иначе зелёный unit-тест создаст ложную уверенность.

\n

Endpoint готов к проверке, когда запрещённый URL не вызывает сетевой клиент, redirect проходит отдельную политику, все DNS-адреса проходят сетевую проверку, а timeout и размер тела прерывают операцию. Тестовый набор должен показать причины отказа для схемы, credentials, host, порта, IP и redirect. Если команда не может предъявить такой отрицательный результат, защита ещё не доказана.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/016.json b/editorial/agent-rewrites/016.json new file mode 100644 index 0000000..2b3bb04 --- /dev/null +++ b/editorial/agent-rewrites/016.json @@ -0,0 +1,7 @@ +{ + "index": 16, + "slug": "editorial-2027-07-field-reliability-capstone", + "title": "Когда retry превращается в аварию: как связать попытку, deadline и результат", + "excerpt": "Разбираем лавину повторных запросов при сбое зависимости: какие поля сохранить, когда остановиться и почему timeout записи нельзя считать доказательством неуспеха.", + "contentHtml": "

Сервис вызывает зависимость, получает timeout и немедленно повторяет запрос. Зависимость ещё не восстановилась. Каждый экземпляр клиента добавляет новый запрос, очередь растёт, а исходная причина скрывается за потоком одинаковых ошибок. Пользователь ждёт дольше. Команда видит только «request failed» и не знает, сколько раз действие уже выполнялось.

\n

Цена ошибки зависит от операции. Для чтения повтор может лишь увеличить нагрузку. Для записи он может создать второй заказ, повторно списать деньги или отправить уведомление. Хуже всего timeout: клиент не знает, принял ли сервер запрос. Ответ мог потеряться после успешной записи.

\n

Тезис простой: retry нельзя проектировать как цикл вокруг HTTP-вызова. Клиент должен принимать решение по четырём данным — операции, попытке, оставшемуся времени и причине отказа. Если хотя бы одно поле отсутствует, автоматическое повторение становится догадкой.

\n

Механизм отказа

\n

Одна пользовательская операция может породить несколько сетевых попыток. У операции есть устойчивый operationId. У каждой попытки есть номер, время начала, deadline и результат. Эти сущности нельзя смешивать. Если записать только финальное «503», расследование потеряет порядок событий.

\n

Общий deadline ограничивает всю операцию. Локальный timeout ограничивает отдельный вызов. Три вызова по 400 миллисекунд не должны превращаться в 1,2 секунды, если пользовательский сценарий допускает только 700 миллисекунд. Перед каждой попыткой клиент вычисляет остаток бюджета. Когда бюджет исчерпан, он возвращает отдельное состояние deadline_exceeded.

\n

Причина и действие тоже различаются. 503, 429, timeout, ошибка DNS и отмена запроса — причины. retry, return, fail и cancel — действия. Раздельные поля позволяют увидеть, что именно создало нагрузку. Свободная фраза в логе такой подсчёт ломает.

\n
Минимальный контракт события попытки
ПолеПримерЗачем
operationIdop-42Связать попытки одной операции
attempt2Увидеть порядок и число вызовов
methodGETПроверить семантику повтора
status или errorClass503, timeoutОтделить ответ сервера от исключения
remainingMs180Понять, сколько времени оставалось
decisionretryЗафиксировать решение клиента
\n
\"Цикл
Каждая попытка сначала оставляет событие, затем проходит проверку времени и семантики операции. Неизвестный результат записи ведёт к проверке состояния, а не к слепому повтору.
\n

Что именно можно повторять

\n

HTTP-метод даёт полезную подсказку, но не заменяет контракт доменной операции. Идемпотентный запрос при повторе имеет тот же ожидаемый эффект, даже если отдельные ответы отличаются. Это не означает, что любой endpoint с таким методом безопасен в любой реализации. Серверный обработчик и его побочные эффекты остаются частью проверки.

\n

GET для чтения обычно можно повторить после временного 503, если клиент соблюдает общий лимит и не игнорирует Retry-After. PUT может быть безопасен, когда ключ ресурса задаёт всё состояние операции. DELETE требует правила для повторного 404. POST нельзя повторять по умолчанию. Для создания нужен подтверждённый механизм идемпотентности: ключ операции, срок его действия и сохранённый результат.

\n

Учебный пример ниже намеренно консервативен. Он повторяет только GET со статусом 503. Массив ответов заменяет сеть, поэтому код не доказывает поведение конкретной библиотеки и не описывает production-систему.

\n
function runBoundedRetries({ operationId, method, responses, maxAttempts = 3, deadlineMs = 400 }) {\n  const events = [];\n\n  for (let i = 0; i < Math.min(maxAttempts, responses.length); i += 1) {\n    const result = responses[i];\n    const remainingMs = Math.max(0, deadlineMs - i * 120);\n    const retryable = method === 'GET' && result.status === 503;\n    const decision = remainingMs === 0 ? 'fail' : retryable ? 'retry' : 'return';\n\n    events.push({\n      operationId,\n      attempt: i + 1,\n      method,\n      status: result.status,\n      remainingMs,\n      decision\n    });\n\n    if (decision !== 'retry') {\n      return { result: decision === 'fail' ? { status: 'deadline_exceeded' } : result, events };\n    }\n  }\n\n  return { result: { status: 'deadline_exceeded' }, events };\n}\n\nrunBoundedRetries({\n  operationId: 'op-42',\n  method: 'GET',\n  responses: [{ status: 503 }, { status: 503 }, { status: 200 }]\n});
\n

В этом учебном наборе клиент создаёт три события и возвращает 200. Если заменить метод на POST, первый 503 получит решение return. Такой результат не означает, что любой POST надо немедленно завершать. Он показывает отрицательный путь: без доказанной идемпотентности повтор запрещён.

\n

В настоящем клиенте есть ещё одна проверка. Если ответ потерялся после записи, новый POST не должен быть способом «узнать, получилось ли». Клиент сохраняет тот же ключ операции и запрашивает состояние отдельным endpoint либо передаёт запрос на ручное разбирательство. Если сервер не умеет ни дедупликацию, ни проверку состояния, политика должна явно возвращать неопределённый результат.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для повторов
СимптомПричинаПроверкаДействие
Резкий рост запросов после 503Нет общего deadline или backoffСравнить attempt и remainingMsОграничить бюджет, добавить задержку и jitter
Один пользователь получил две записиPOST повторили после timeoutСопоставить operationId на сервереОстановить retry, ввести ключ и запрос состояния
В логах только «failed»Причина и decision слитыНайти поля status/errorClass и decisionСделать перечисление причин и действий
Клиент ждёт дольше SLATimeout задан на попытку, не на операциюПроверить остаток времени перед каждым вызовомПередавать общий deadline вниз по стеку
После 429 нагрузка не падаетКлиент игнорирует ограничение сервераПроверить Retry-After и частоту попытокСнизить темп и завершать попытку по политике лимита
Нельзя связать клиентский и серверный следИдентификатор меняется при retryСопоставить operationId и requestIdСохранить идентификатор операции, а запросу дать номер попытки
\n

Как читать отрицательный путь

\n

Рассмотрим последовательность для чтения. Первая попытка получила 503 при остатке 380 миллисекунд. Клиент записал decision=retry, подождал ограниченный интервал и повторил запрос. Вторая попытка снова получила 503. Осталось 120 миллисекунд, поэтому третья попытка допустима только после оценки её минимального времени выполнения. Если бюджет мал, клиент завершает операцию с deadline_exceeded, даже если в массиве есть следующий ответ.

\n

Теперь рассмотрим запись. Сервер мог принять запрос, но соединение оборвалось до ответа. Клиент записал errorClass=timeout, decision=check_state и сохранил operation key. Он не создаёт новую запись. Это медленнее, чем слепой retry, но цена неизвестного результата ниже цены дублирования побочного эффекта.

\n

Для логов достаточно безопасного endpoint без query-секретов, метода, статуса, класса ошибки, номера попытки, оставшегося времени и решения. Не записывайте тело ответа, cookie, токены, номера карт и произвольные пользовательские строки без отдельной причины. Поле наблюдаемости не должно становиться новым каналом утечки.

\n

Порядок внедрения

\n
  1. Назвать доменное действие и его побочный эффект. Не ограничиваться HTTP-методом.
  2. Зафиксировать operationId и правило его жизненного цикла. Один retry не должен создавать новый идентификатор операции.
  3. Разделить timeout отдельного вызова и общий deadline операции.
  4. Составить явный список повторяемых причин и методов. Для каждой пары указать лимит попыток и действие при исчерпании времени.
  5. Добавить событие попытки с attempt, status или errorClass, remainingMs и decision.
  6. Для записи проверить потерю ответа: повторить тот же ключ, а затем запросить состояние.
  7. Удалить секреты и персональные данные из endpoint, заголовков, тела и идентификаторов до отправки события.
  8. Проверить четыре сценария: успешный первый вызов, GET/503, GET/timeout и POST/timeout.
  9. Считать распределение попыток и долю завершений по deadline. Не менять политику из-за одной шумной записи.
\n

Ограничения

\n

Локальный исполнитель не моделирует сетевую очередь, балансировщик, распределённую доставку логов, рассинхрон часов и рестарт процесса. Фиксированный шаг в 120 миллисекунд нужен только для учебной демонстрации. В реальном клиенте время измеряют монотонными часами, а задержку выбирают по контракту зависимости и допустимой нагрузке.

\n

Ни RFC, ни схема событий не делают повтор безопасным сами по себе. Идемпотентность требует согласованного поведения сервера и хранилища результата. Наблюдаемость показывает действие клиента, но не подтверждает, что сервер применил операцию. Для платежей, заказов и других критичных записей нужна отдельная проверка состояния и согласованный владелец ключа.

\n

Проверяемый критерий готовности

\n

Изменение готово, если тестовый набор воспроизводит все четыре сценария и для каждого проверяет не только итог, но и последовательность событий. Для GET после двух временных отказов видны попытки 1 и 2 с decision=retry, а затем успешный возврат либо честное завершение по deadline. Для POST после timeout нет второго побочного вызова: клиент сохраняет тот же operation key и переходит к проверке состояния. В каждом событии есть причина, действие и оставшееся время. Секреты отсутствуют.

\n

Этот критерий не обещает доступность зависимости и не измеряет реальный SLA. Он проверяет границу поведения, которую команда действительно контролирует: клиент не создаёт бесконечную лавину и не превращает неизвестный результат записи в новый побочный эффект.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/017.json b/editorial/agent-rewrites/017.json new file mode 100644 index 0000000..4376198 --- /dev/null +++ b/editorial/agent-rewrites/017.json @@ -0,0 +1,7 @@ +{ + "index": 17, + "slug": "editorial-2027-07-mechanism-reliability-capstone", + "title": "Retry после timeout: как не повторить бизнес-операцию дважды", + "excerpt": "Timeout сообщает о потерянном ответе, но не о результате записи. Идемпотентный контракт связывает повторы одной операции и не даёт сетевому сбою превратиться в дубль.", + "contentHtml": "

Клиент отправляет запрос на создание заказа и ждёт ответа. Через пять секунд он получает timeout. Пользователь нажимает «Повторить», а клиент автоматически отправляет тот же POST ещё раз. В системе появляются два заказа. Для платежа цена выше: можно получить двойное списание, повторное письмо или две отгрузки.

\n

Timeout говорит только о том, что клиент не получил ответ в отведённый срок. Сервер мог не принять запрос, мог отклонить его или уже записать результат, пока ответ терялся между сервисами. Поэтому retry нельзя строить вокруг одной ошибки сети. Нужно знать, какой эффект имеет операция и как сервер распознаёт повтор.

\n

Тезис: надёжный retry начинается с контракта бизнес-операции. Для чтения достаточно ограниченного повтора с общим deadline. Для записи нужен идемпотентный метод или ключ операции, который сервер проверяет атомарно вместе с результатом. Один request id, повторная отправка POST и надежда на быстрый ответ такой контракт не заменяют.

\n

Что именно скрывает timeout

\n

Транспорт доставляет байты и сообщает о состоянии соединения. Он не знает, зафиксирована ли транзакция в базе. HTTP описывает метод, статус и заголовки, но не видит внутреннюю границу между записью и отправкой ответа. Если процесс записал заказ, а затем упал до ответа, клиент получает неопределённый исход: операция могла завершиться.

\n

Для GET повтор обычно не создаёт новый объект. Для DELETE повтор может вернуть другой статус, но ожидаемое состояние остаётся удалённым. POST по умолчанию не даёт такой гарантии: сервер может создать новый ресурс при каждом запросе. Нельзя выводить безопасность повтора из короткого имени метода. Нужно проверить доменное действие.

\n
Четыре идентификатора и их границы
ИдентификаторКто создаётЧто связываетЧего не гарантирует
Connection IDтранспортпакеты одного соединениярезультат бизнес-операции
Request IDклиент или gatewayодну попытку и её логиотсутствие повторного эффекта
Idempotency-Keyклиент для операциинесколько попыток одного действияатомарность, если сервер её не реализует
Resource IDдоменный сервиссозданный объектсвязь двух попыток без контракта
\n
\"Матрица
Матрица показывает, какой слой отвечает на конкретный вопрос. Она не доказывает доставку отдельного запроса.
\n

Идемпотентность означает один эффект

\n

Операция идемпотентна, если один или несколько одинаковых запросов дают тот же ожидаемый эффект, что и один запрос. Ответы при этом могут отличаться. Первый вызов может вернуть 201, повтор — сохранённый 200 или 409 по правилам API. Проверять нужно состояние и контракт, а не только код ответа.

\n

Для создания ресурса сервер может принять Idempotency-Key. Он сохраняет связь между ключом, параметрами операции и результатом. Повтор с тем же ключом возвращает сохранённый результат или определённую ошибку. Повтор с тем же ключом, но другим телом должен завершаться конфликтом. Иначе старый результат можно ошибочно выдать за результат новой команды.

\n

Ключ относится к смысловой операции, а не к соединению. Его область уникальности и срок хранения должны быть явными. Ключ order-42 может быть уникальным в пределах одного клиента, магазина или всей системы. После истечения срока тот же ключ может стать новой операцией. Это часть API-контракта.

\n

Учебный сервер с защитой от дубля

\n

Ниже — ограниченный учебный пример на Node.js. Он показывает только идею: два POST с одним ключом получают один номер записи. Данные хранятся в Map в памяти процесса. Пример не заменяет транзакцию, распределённое хранилище, аутентификацию и проверку тела запроса.

\n
const results = new Map(); let nextId = 1; function create(key) { if (!results.has(key)) results.set(key, { id: nextId++, state: 'created' }); return results.get(key); } console.log(create('order-42')); console.log(create('order-42'));
\n

В учебном запуске оба ответа содержат один id. Это ожидаемое свойство примера, а не результат работы реального сервиса. В настоящей системе проверка ключа и создание записи должны проходить под атомарным ограничением. Два процесса не должны одновременно увидеть отсутствующий ключ и создать два ресурса.

\n

Хранилище должно запоминать параметры операции или их отпечаток. Если первый запрос создаёт заказ на 100 рублей, а повтор с тем же ключом просит 10 000 рублей, сервер не должен молча отдавать старый результат. Он должен вернуть конфликт до нового побочного эффекта. Успешный результат, ошибка валидации и ошибка сервера требуют отдельных правил хранения.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для повторов
СимптомПричинаПроверкаДействие
Timeout, но ресурс уже естьОтвет потерялся после записиСопоставить trace, request id и состояние ресурсаПовторить только с тем же ключом или запросить результат по resource id
Каждый retry создаёт новый ресурсPOST не имеет дедупликацииОтправить два запроса с одним ключом и сравнить записиДобавить контракт ключа или запретить автоматический retry
Один ключ даёт разные ответыКлюч не связан с результатом или истёкПроверить TTL и область уникальностиЗафиксировать срок, namespace и правило истечения
Повтор с другим телом проходитСервер хранит только строку ключаСравнить отпечатки телВернуть конфликт до побочного эффекта
После сбоя растёт очередьRetry не учитывает deadlineПосчитать попытки, задержки и время отменыОграничить повторы, backoff и бюджет времени
\n

Когда повторять нельзя

\n

Не повторяйте запись, если сервер не обещает идемпотентность и нельзя отдельно проверить состояние. Это отрицательный путь. Лучше вернуть неопределённый результат и передать операцию на доменную проверку, чем незаметно создать второй эффект.

\n

Не превращайте любой 5xx в разрешение на повтор. 503 может сопровождаться Retry-After, но его наличие не доказывает, что запрос не был принят. Gateway может вернуть свой 503 после выполнения upstream-операции. Сетевое исключение, отмена deadline и ответ посредника должны различаться в логах.

\n

Не повторяйте после истечения общего deadline. Отдельные таймауты на каждый вызов могут растянуть цепочку на минуты и создать лавину в зависимостях. Backoff снижает частоту, но не исправляет небезопасный эффект. Circuit breaker ограничивает давление, но не сообщает, была ли запись принята.

\n

Порядок проверки

\n
  1. Назовите доменное действие и его побочный эффект: создание заказа, списание, отправка письма или изменение лимита.
  2. Определите состояние после timeout и запрос, который проверит его без нового побочного эффекта.
  3. Проверьте контракт HTTP и серверную реализацию. Не делайте вывод только из метода или статуса.
  4. Для записи задайте формат, область уникальности и срок жизни Idempotency-Key.
  5. Сделайте тест потери ответа после записи. Повтор должен вернуть тот же результат или явный конфликт.
  6. Сделайте тест повторного ключа с другим телом. Вторая команда не должна менять состояние.
  7. Добавьте общий deadline, лимит попыток, backoff и поля operation key, request id и attempt.
  8. Проверьте отрицательный путь: при отсутствии контракта клиент останавливается.
\n

Ограничения

\n

Idempotency-Key не решает конкуренцию сам по себе. Нужны атомарная запись, согласованное хранилище и правило восстановления после сбоя между фиксацией результата и сохранением ответа. В многорегиональной системе область уникальности должна охватывать все узлы, которые принимают операцию.

\n

Срок хранения ключей выбирают по максимальному времени повторной доставки и бизнес-риску. Слишком короткий TTL снова разрешит дубль. Слишком длинный TTL увеличит хранилище. Платёжные и юридически значимые операции требуют отдельного доменного контракта, аудита и проверки состояния.

\n

Учебный сервер не моделирует рестарт, несколько процессов, транзакцию базы, частичный ответ и истечение TTL. Он полезен только для проверки различия между попыткой и операцией. Не переносите его Map в production без этих механизмов.

\n

Проверяемый критерий готовности

\n

Механизм готов, если для одного operation key можно воспроизвести четыре исхода: успешный первый запрос, timeout после записи, повтор с тем же телом и повтор с другим телом. В первых двух случаях состояние содержит один ресурс и один побочный эффект. Третий случай возвращает тот же результат или согласованный статус. Четвёртый возвращает конфликт до новой записи. Все попытки видны по operation key, request id и номеру попытки, а общий deadline ограничивает цепочку.

\n

Если хотя бы один исход нельзя проверить тестом или наблюдаемым сигналом, retry остаётся предположением. В таком месте автоматический повтор нужно отключить до появления контракта.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/018.json b/editorial/agent-rewrites/018.json new file mode 100644 index 0000000..1dd0d6f --- /dev/null +++ b/editorial/agent-rewrites/018.json @@ -0,0 +1 @@ +{"index":18,"slug":"editorial-2027-07-practice-reliability-capstone","title":"Retry без двойной записи: как связать timeout, 503 и идемпотентность","excerpt":"Разбираем, почему повтор HTTP-запроса нельзя включать одной настройкой. Сначала проверяем семантику операции, затем ограничиваем время, попытки и последствия сбоя.","contentHtml":"

Сервис отвечает медленно. Клиент ждёт 400 миллисекунд, получает timeout и отправляет тот же запрос ещё раз. В логах появляется один ответ, а в базе — две заявки. Первая запись завершилась, но ответ потерялся между сервером и клиентом. Цена ошибки — двойное списание, повторная доставка или ручное удаление лишней записи.

Есть и обратный симптом. Чтение каталога получает 503 Service Unavailable, клиент сразу сдаётся, а пользователь видит отказ, хотя зависимость восстановилась через секунду. Команда добавляет общий retry для всех запросов и исправляет один сценарий, но открывает другой: небезопасную операцию можно выполнить повторно.

Тезис простой: retry — это часть контракта операции, а не свойство сетевого клиента. Клиент должен знать, что он повторяет, сколько времени осталось, какой ответ разрешает повтор и как отличить неизвестный результат записи от подтверждённого отказа.

Механизм: ответ и результат — не одно и то же

HTTP-ответ сообщает клиенту о результате только тогда, когда клиент его получил. Таймаут ломает эту связь. Сервер мог не начать работу, мог завершить чтение или мог сохранить запись перед обрывом соединения. По одному исключению timeout нельзя выбрать безопасное действие.

У запроса есть две разные характеристики. Безопасный метод не меняет состояние сервера. Идемпотентная операция допускает повторение с тем же ожидаемым эффектом. GET обычно читается повторно. PUT может перезаписать ресурс по известному ключу. POST, который создаёт новый ресурс, нельзя повторять по умолчанию. Заголовок Idempotency-Key меняет правило только тогда, когда сервер действительно хранит ключ, результат и срок его действия.

Статус 503 не является командой «повтори». Он говорит, что сервис временно не готов обработать запрос. Заголовок Retry-After может задать паузу. Клиент всё равно должен проверить метод, deadline, лимит попыток и нагрузку на зависимость. Повтор через proxy может снова попасть в перегруженный origin.

Диагностика повтора HTTP-запроса
СимптомПричинаПроверкаДействие
После timeout появились две записиСервер принял POST, но клиент не получил ответСверить request id и журнал транзакцииНе повторять без ключа или проверки состояния
GET завершился после первого 503Клиент не различает чтение и записьПроверить метод, status и deadlineПовторить ограниченно с backoff
Три попытки вышли за времяЛимит попыток не связан с deadlineИзмерить запросы и паузыСчитать остаток времени перед каждой попыткой
Сервис перегружается после сбояКлиенты повторяют одновременноСопоставить rate retry, 503 и нагрузку originДобавить jitter, бюджет и circuit breaker
Причина ошибки исчезает в логахВсе исключения сведены к одному типуПроверить method, status, attempt и request idСохранить безопасный контекст без payload
Дерево решения для HTTP-повтора: deadline, метод, статус и окончательное действие
Безопасное решение начинается с deadline и семантики метода. Статус 503 не отменяет проверку побочного эффекта.

Минимальный пример с общим deadline

Ниже — учебный пример для локального сервера. Он показывает два ответа 503, затем 200. Сервер не моделирует потерю ответа после записи, балансировщик, очередь и реальную нагрузку. Результат примера нельзя выдавать за производственный замер.

async function getWithRetry(url, { maxAttempts = 3, deadlineMs = 1000 } = {}) { 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 exceeded'); const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(remaining) }); if (response.ok) return { attempt, status: response.status }; if (response.status !== 503 || attempt === maxAttempts) throw new Error('stop on status ' + response.status); await new Promise((resolve) => setTimeout(resolve, 50 * attempt)); } }

Функция повторяет только GET. Она ограничивает суммарное время, а не умножает timeout на число попыток. В настоящем клиенте нужно отдельно обработать сетевую ошибку, проверить Retry-After, добавить случайную добавку к паузе и передать request id. Для POST эта функция не подходит: её сигнатура специально не принимает тело и метод.

Последовательность важна. Сначала проверяется остаток deadline. Затем клиент получает ответ. Успех возвращается сразу. 503 разрешает следующую попытку только при оставшемся времени и ненулевом бюджете. Другой статус останавливает цикл. Ошибка валидации или авторизации не превращается в поток бесполезных повторов.

Что делать с записью

Для записи нужно разделить неизвестный результат и явный отказ. Если соединение оборвалось до отправки тела, повтор иногда допустим. Если сервер мог принять тело, клиент должен сначала запросить состояние по естественному идентификатору или использовать ключ дедупликации. Нельзя считать отсутствие ответа доказательством отсутствия действия.

Idempotency key должен входить в контракт API. Сервер сохраняет ключ вместе с результатом и возвращает тот же результат при повторе того же ключа. Надо определить срок хранения, связь ключа с параметрами запроса и ответ при несовпадении параметров. Если клиент отправит тот же ключ с другим заказом, сервер должен отклонить запрос, а не изменить исходную операцию.

Логирование помогает расследованию, но не делает повтор безопасным. Записывайте метод, endpoint без секретных параметров, request id, idempotency key в обезличенном виде, номер попытки, статус и длительность. Не записывайте токены, платёжные данные и полный payload. Метрика должна различать исходные запросы и повторы, иначе рост нагрузки останется незаметным.

Порядок действий

  1. Опишите побочный эффект операции и ключ, по которому можно проверить состояние.
  2. Разделите методы и статусы: чтение, идемпотентная запись, создание и явный отказ.
  3. Задайте общий deadline для всей операции и передавайте остаток времени в каждый вызов.
  4. Разрешите retry только для подтверждённых сценариев. Для POST сначала зафиксируйте idempotency key и правила сервера.
  5. Ограничьте число попыток, суммарную задержку и долю трафика на повторы.
  6. Обработайте Retry-After, backoff и jitter. При росте 503 или превышении бюджета остановите повтор.
  7. Сохраните безопасный контекст попытки и отделите timeout от ответа сервера.
  8. Проверьте чтение после 503, timeout после принятой записи и отказ без повторной отправки.

Ограничения

Ни один клиент не узнает из timeout, выполнил ли сервер запись. Это ограничение протокола, а не недостающий флаг библиотеки. Проверка состояния требует API, а дедупликация требует поддержки на сервере. Если такого контракта нет, безопасное действие после timeout — остановиться и передать операцию на разбор, а не угадывать.

Идемпотентность не означает отсутствие ошибок. Повторная запись может вернуть конфликт версии, истёкший ключ или отказ зависимости. Circuit breaker снижает давление на зависимость, но не восстанавливает потерянный результат. QUIC и HTTP/2 могут менять транспортное поведение, однако транспорт не превращает создание заказа через POST в идемпотентную операцию.

Учебный сервер с фиксированными ответами не показывает реальные задержки, распределение нагрузки и работу нескольких клиентов. Поэтому он годится для проверки ветвления, но не для обещаний о доступности или времени ответа. Производственные числа нужно получать из наблюдений конкретной системы.

Проверяемый критерий готовности

Решение готово, если для каждого метода и исхода записано действие: повторить, проверить состояние или остановиться. Тест успешного чтения после 503 подтверждает ограниченный retry. Тест timeout после принятой записи подтверждает отсутствие слепого повтора. Каждый запуск укладывается в общий deadline, а журнал связывает попытки с одной операцией без раскрытия секретов. Если ветка заканчивается фразой «попробуем ещё раз», контракт ещё не определён.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/019.json b/editorial/agent-rewrites/019.json new file mode 100644 index 0000000..952c029 --- /dev/null +++ b/editorial/agent-rewrites/019.json @@ -0,0 +1,7 @@ +{ + "index": 19, + "slug": "editorial-2027-06-field-performance-capstone", + "title": "p95 не изменился: как найти настоящую причину медленной страницы", + "excerpt": "Уменьшение bundle не гарантирует быстрый ответ. Разбираем полевой симптом, разделяем сервер, сеть и браузер, а затем принимаем решение по повторяемому p95.", + "contentHtml": "

После релиза JavaScript-бандл стал меньше на 180 КБ, но p95 загрузки каталога остался около 3,1 секунды. Пользователь по-прежнему видит пустой первый экран. Цена ошибки — потратить спринт на минификацию, а затем обнаружить, что запрос к базе ждёт 1,8 секунды или браузер тратит время на главный поток. Размер файла изменился. Причина задержки могла остаться прежней.

Такой симптом нельзя лечить одним советом вроде «включите кеш» или «сократите JavaScript». Сначала разложите задержку по участкам одного запуска: ожидание ответа, передача HTML и ресурсов, выполнение кода, отрисовка крупного элемента. Тезис статьи прост: результат замера становится инженерным доказательством только тогда, когда команда сохраняет условия, сырые наблюдения и метрику, которой принято решение.

Что именно измеряет p95

p95 — это значение, ниже которого лежат 95 процентов наблюдений в выбранной выборке. Пять процентов измерений находятся выше него. Это характеристика хвоста, а не обещание для каждого пользователя. На двадцати замерах один поздний запрос уже заметно влияет на p95. На пяти замерах такая оценка почти неустойчива.

У любой цифры есть граница. TTFB показывает, когда начал приходить ответ. Он не описывает выполнение JavaScript. LCP показывает момент отрисовки крупнейшего видимого элемента, но зависит от HTML, CSS, шрифта, изображения, viewport и устройства. Размер bundle показывает объём передачи и распаковки, но не говорит, какой запрос блокирует страницу. Эти значения нужно хранить раздельно.

Минимальный протокол одного сравнимого замера
ПолеПримерЗачем оно нужно
Версияcommit abc123Связать результат с конкретным кодом
УсловияChromium, 1280×800, cold cacheНе смешать разные сценарии
Выборка20 повторовПонимать устойчивость p95
Сырые данныеJSON со всеми значениямиПроверить выбросы и пересчитать итог
МетрикиTTFB, LCP, p95, long tasksОтделить сервер от браузера
\"Цикл
Сравнение возвращается к тем же условиям после одного изменения. Иначе разницу нельзя уверенно связать с исправлением.

Механизм: задержка складывается из разных очередей

Навигация начинается с запроса документа. До первого байта браузер ждёт сеть, proxy и сервер. Сервер в этот момент может ждать соединение с базой, блокировку или внешний сервис. После первого байта браузер получает остальной HTML. Затем parser встречает CSS и обычные script. Они меняют порядок загрузки и работы главного потока. Позже браузер выбирает крупный элемент для LCP.

Пусть время до полезного экрана можно представить как сумму server_wait + html_transfer + blocking_resources + main_thread_work + paint. Это не универсальная формула пользовательской метрики. Это рабочая карта расследования. Если TTFB вырос, сначала ищите серверную или сетевую задержку. Если TTFB стабилен, а LCP вырос, смотрите ресурсы, layout и JavaScript. Изменение одной части не подтверждает улучшение всей страницы.

В браузере начните с записи навигации и ресурсов. performance.getEntriesByType('navigation')[0] даёт временные точки документа. Для ресурсов используйте performance.getEntriesByType('resource'). Сопоставьте ранние записи с HTML-тегом или инициатором в waterfall. Не делайте вывод по одной полосе: ресурс мог загрузиться рано, но не влиять на первый экран.

Учебный локальный замер HTTP-пути

Следующий пример специально ограничен локальным HTTP-путём. Он не моделирует браузер, мобильную сеть, CDN или реальную базу данных. Сервер задерживает ответ на 40 миллисекунд. Клиент делает двадцать одинаковых запросов, сохраняет сырые времена и считает медиану и p95. Это позволяет проверить арифметику и увидеть влияние выброса до анализа страницы.

import { performance } from 'node:perf_hooks'; import { createServer } from 'node:http'; const server = createServer((request, response) => { setTimeout(() => response.end('ready'), 40); }); function percentile(values, rank) { const sorted = [...values].sort((a, b) => a - b); const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * rank) - 1); return sorted[index]; } server.listen({ host: '127.0.0.1', port: 0 }, async () => { const { port } = server.address(); const samples = []; for (let attempt = 0; attempt < 20; attempt += 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), samples: samples.map(value => value.toFixed(1)) }); server.close(); });

В нормальном запуске count равен 20, а значения находятся немного выше 40 миллисекунд из-за накладных расходов процесса. Точное число зависит от машины. Это ожидаемый учебный результат, а не production-результат. Если добавить один искусственный выброс, p95 вырастет, хотя девятнадцать запросов не изменились. Поэтому отчёт хранит и итог, и выборку.

В рабочем замере не смешивайте cold и warm cache. В первом режиме браузер и CDN скачивают ресурсы. Во втором часть данных уже доступна локально. Если перемешать режимы, p95 описывает смесь сценариев. То же относится к viewport, throttling, версии браузера, размеру ответа и состоянию данных.

Симптом → причина → проверка → действие

Карта решения после первого наблюдения
СимптомВозможная причинаПроверкаДействие
TTFB и p95 вырослисервер ждёт базу или upstreamtrace, серверные тайминги, план запросаисправить узкий участок и повторить тот же сценарий
TTFB стабилен, LCP выросблокирующий CSS, шрифт или scriptwaterfall, resource entries, long tasksизменить порядок или размер ресурса, затем проверить первый экран
bundle меньше, LCP тот жеузким местом был не bundleсравнить TTFB, ресурсы и главный потокне объявлять успех; выбрать доминирующий участок
Среднее лучше, p95 хужестал тяжелее хвост или появились выбросысырые значения, размер выборки, нагрузканайти поздние запуски и не заменять p95 средним
Метрика пропаланет поддержки или запись ограничена политикой доступаsupportedEntryTypes, браузер, originпометить отсутствие и выбрать доступный сигнал

Как связать цифру с причиной

Сначала найдите доминирующий участок, а не самое знакомое слово в отчёте. Высокий TTFB не доказывает, что виновата база. Он только говорит, что ответ начал приходить поздно. Разделите server timing, сеть и proxy. Если серверная часть стабильна, проверьте передачу HTML и очередь ресурсов.

Ранний script тоже не равен проблеме. Он может быть маленьким и нужным для маршрутизации. Большой script может прийти поздно и не влиять на LCP, если крупный элемент уже отрисован. Смотрите на блокировку главного потока и на связь с конкретным элементом. В отрицательном пути команда не находит причины, потому что проверяет только размер файла. Тогда замер нужно остановить и расширить до навигации, ресурсов и trace.

Изменяйте один фактор за раз. Например, сначала уберите лишний preload, затем повторите двадцать запусков с теми же условиями. Не меняйте одновременно SQL, компрессию, порядок script и viewport. Иначе улучшение или регрессия не принадлежит одному решению.

Порядок действий

  1. Запишите симптом, URL, commit, браузер, viewport, сеть и режим кэша.
  2. Выберите одну метрику решения и сохраните связанные метрики: TTFB, LCP, p95 и long tasks.
  3. Сделайте одинаковую серию запусков и сохраните каждое сырое значение.
  4. Разделите задержку на сервер, HTML, ресурсы, главный поток и отрисовку.
  5. Проверьте одну гипотезу минимальным изменением, которое можно откатить.
  6. Повторите серию при тех же условиях и сравните распределения, а не только средние.
  7. Проверьте отрицательный путь: cold cache, слабый CPU, поздний upstream или отсутствующую запись.
  8. Зафиксируйте действие, ограничение вывода и результат для того же критерия.

Ограничения и критерий готовности

Локальный стенд проверяет код подсчёта, но не пользовательскую скорость. Лабораторный browser-run показывает повторяемость, но не покрывает все устройства и сети. Полевой p95 зависит от состава пользователей, частоты запусков и способа агрегации. Порог нельзя переносить между страницами без объяснения.

Работа готова, когда другая команда может открыть отчёт, увидеть исходные условия и пересчитать p95 из сохранённых значений. В отчёте есть один доминирующий участок, проверенная гипотеза, повтор после одного изменения и отрицательный сценарий. Для страницы критерий должен включать конкретную метрику и порог, например: p95 LCP не выше согласованного значения при указанном браузере, viewport, сети и режиме кэша. Это проверяемое утверждение. «Стало быстрее» — нет.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/020.json b/editorial/agent-rewrites/020.json new file mode 100644 index 0000000..d676f62 --- /dev/null +++ b/editorial/agent-rewrites/020.json @@ -0,0 +1,7 @@ +{ + "index": 20, + "slug": "editorial-2027-06-mechanism-performance-capstone", + "title": "Waterfall без иллюзий: как доказать, что ресурс задерживает первый экран", + "excerpt": "Длинная полоса в waterfall не равна причине задержки. Разбираем зависимость ресурса, инициатора и потребителя, а затем проверяем одно изменение на учебной странице.", + "contentHtml": "

Пользователь видит пустой или неполный первый экран. В DevTools рядом с ним растягивается полоса шрифта или изображения. Команда объявляет этот ресурс виновником и меняет порядок загрузки. Через релиз экран не ускоряется, зато появляется лишний preload, вспышка нестилизованного текста или гонка между скриптами. Цена ошибки — не только потерянные миллисекунды. Вы меняете контракт загрузки, не доказав, что страницу действительно задерживал этот запрос.

\n

Waterfall показывает время сетевой работы. Он не показывает причинность сам по себе. Ресурс влияет на экран только тогда, когда его результат нужен конкретной зависимости: parser ждёт script, CSS нужен для построения стилей, layout ждёт шрифт, а компонент ждёт данные. Поэтому тезис статьи простой: ищите не самую длинную полосу, а цепочку «инициатор → ресурс → потребитель → наблюдаемый эффект».

\n

Что именно нужно доказать

\n

У каждой записи ресурса есть временная и причинная часть. startTime говорит, когда запрос начал работу, а responseEnd — когда браузер получил последний байт. initiatorType помогает понять, кто начал запрос: parser, script, css, fetch или другой источник. Эти поля описывают наблюдение. Они ещё не отвечают, ждал ли результат первый экран.

\n

Для причинности добавьте три вопроса. Какой элемент или код потребляет ответ? В какой момент он потребляет его? Что изменится, если ответ придёт позже или не придёт вовсе? Если на последний вопрос нет проверяемого ответа, запись остаётся кандидатом. Её нельзя называть узким местом.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
CSS заканчивается поздно, первый экран пустойСтили нужны до первого layoutСопоставить тег link, DOM и момент первого визуального результатаУменьшить критический CSS или разделить его, затем проверить layout shift
Script начинается рано и долго выполняетсяParser или главный поток ждёт выполнениеПроверить атрибуты, initiator и long taskПрименить defer или разбить работу только после проверки зависимостей
Шрифт имеет длинную полосу, но контент уже виденШрифт не нужен первому экрану или заменяется fallbackСравнить момент ответа шрифта с визуальным результатом и layoutНе добавлять preload; проверить font-display и потребителя
JSON заканчивается поздно, карточки пустыеКомпонент ждёт fetchНайти вызов, состояние ожидания и время отображения данныхОптимизировать запрос или skeleton, не меняя случайные сетевые приоритеты
Одна запись отсутствует в отчётеНет поддержки поля, кросс-доменное ограничение или буфер очищенПроверить браузер, Timing-Allow-Origin и момент чтенияПометить «нет данных» и выбрать другой сигнал
\n
\"Матрица
Иллюстрация связывает строку waterfall с проверкой зависимости. Размер ресурса — только один из входов, а не итоговый вердикт.
\n

Модель загрузки на конкретном примере

\n

Рассмотрим страницу с таким HTML:

\n
<head>\n  <link rel=\"stylesheet\" href=\"/app.css\">\n  <script src=\"/vendor.js\" defer></script>\n  <script src=\"/analytics.js\" async></script>\n</head>\n<body>\n  <main id=\"catalog\"></main>\n  <script src=\"/catalog.js\" defer></script>\n</body>
\n

app.css может влиять на первый layout. vendor.js и catalog.js загружаются параллельно с разбором HTML, но выполняются после разбора документа и сохраняют порядок между собой. analytics.js выполняется, когда загрузится, поэтому не должен зависеть от глобального объекта, который создаёт другой script. Его длинная полоса не объясняет задержку каталога, если аналитика не участвует в рендере.

\n

Учебный наблюдатель ниже читает доступные записи после загрузки страницы. Он не меняет сеть и не утверждает, что каждая ранняя запись блокирует экран. Пример предназначен для локальной или тестовой страницы. Он показывает границу данных: если поле не поддерживается, результат будет unknown, а не выдуманный статус.

\n
const navigation = performance.getEntriesByType('navigation')[0];\nconst domEnd = navigation?.domContentLoadedEventEnd ?? Infinity;\n\nconst resources = performance\n  .getEntriesByType('resource')\n  .filter((entry) => entry.startTime < domEnd)\n  .map((entry) => ({\n    name: new URL(entry.name).pathname,\n    initiator: entry.initiatorType || 'unknown',\n    start: Math.round(entry.startTime),\n    end: Math.round(entry.responseEnd),\n    duration: Math.round(entry.duration),\n    blocking: entry.renderBlockingStatus ?? 'unknown',\n  }));\n\nconsole.table(resources);
\n

Фильтр ограничивает список ресурсами, которые начали работу до DOMContentLoaded. Это удобная граница для первичного поиска, но не доказательство готовности экрана. DOM мог закончить разбор, пока изображение, шрифт или отрисовка ещё продолжаются. Для визуального симптома нужен отдельный браузерный прогон и понятный элемент, который считается первым полезным результатом.

\n

Дальше найдите инициатор в HTML или исходном коде. Для parser проверьте тег. Для script найдите вызов fetch, импорт или создание элемента. Для css проверьте правило и используемый шрифт. Затем назовите потребителя. Формулировка «CSS заканчивается поздно» слишком слабая. Формулировка «карточки не получают стили до первого layout, потому что link указывает на полный файл» уже задаёт проверяемое действие.

\n

Порядок проверки

\n
  1. Зафиксируйте URL, браузер, viewport, сеть, режим кэша и состояние авторизации. Без этих условий две записи waterfall нельзя честно сравнить.
  2. Опишите симптом словами пользователя: пустой первый экран, поздние карточки, скачок текста или задержка интерактивности. Запишите момент и элемент, а не только общую длительность.
  3. Снимите navigation entry и resource entries в одном прогоне. Сохраните исходные значения, включая URL, инициатор, время начала, конец ответа и доступный статус блокировки.
  4. Отберите ранние записи по типу и времени. Для каждой найдите конкретный тег, вызов или CSS-правило, которое запустило запрос.
  5. Назовите потребителя и проверьте его зависимость. Удалите ресурс, отложите его в учебной копии или замените ответом-заглушкой. Если симптом не меняется, гипотеза не подтверждена.
  6. Измените один фактор: defer, разделение CSS, порядок запроса или код потребителя. Не смешивайте изменение сети с изменением рендера.
  7. Повторите прогон в тех же условиях. Сравните визуальный критерий, long tasks и нужный участок waterfall. Сокращение отдельной полосы без изменения симптома не считается успехом.
\n

Когда популярные исправления вредят

\n

preload запускает запрос раньше. Это полезно для действительно критичного ресурса, но лишний preload конкурирует с HTML, CSS и данными. Укажите правильный as и проверьте, что документ использует ответ. Иначе браузер предупреждает о неиспользованной загрузке, а критический путь становится шире.

\n

async освобождает parser, но отдаёт порядок выполнения сети. Он подходит для независимого кода. Если script читает объект, который создаёт другой script, или меняет DOM до инициализации компонента, появится гонка. defer сохраняет порядок отложенных скриптов, но не уменьшает размер файла и не доказывает, что работа главного потока стала короче.

\n

Разделение CSS снижает ранний объём только при точной границе. Ошибка даёт FOUC, неверный порядок правил или layout shift. Отложенный шрифт может убрать блокировку, но изменить переносы строк и высоту блока. Каждое исправление нужно оценивать по тому же потребителю, который породил исходный симптом.

\n

Отрицательный путь важен. Если waterfall показывает поздний запрос, но первый экран от него не зависит, остановите оптимизацию этой строки. Если данные недоступны из-за политики браузера, не подставляйте ноль. Если замер меняет состояние кэша, не сравнивайте его с холодным запуском. Честный вывод «причина не доказана» экономит больше времени, чем уверенная правка не того слоя.

\n

Ограничения и критерий готовности

\n

Resource Timing зависит от браузера и политики доступа. Кросс-доменные значения могут быть скрыты без Timing-Allow-Origin. renderBlockingStatus может отсутствовать. DOMContentLoaded не равен FCP или LCP, а локальная сеть не воспроизводит мобильное устройство. Записи Performance API не описывают серверную очередь, весь главный поток и пользовательский опыт одной цифрой.

\n

Учебный код не даёт production-результатов. Его задача — показать форму проверки. Для реального решения нужны повторяемые прогоны в целевых браузерах, сохранённые условия и выбранный визуальный критерий. Не называйте ресурс блокирующим только потому, что он большой, ранний или заметный на скриншоте DevTools.

\n

Работа готова, когда для заявленного симптома есть исходная запись и повторяемый критерий, у спорного ресурса найден инициатор и потребитель, одно изменение прошло в тех же условиях, а отрицательный путь тоже описан. После изменения должен измениться именно наблюдаемый симптом или заранее выбранная метрика. Если меняется только длина полосы, расследование не закончено.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/021.json b/editorial/agent-rewrites/021.json new file mode 100644 index 0000000..5e00f94 --- /dev/null +++ b/editorial/agent-rewrites/021.json @@ -0,0 +1 @@ +{"index":21,"slug":"editorial-2027-06-practice-performance-capstone","title":"Медленная первая загрузка: отделяем TTFB от блокирующих ресурсов","excerpt":"Как разобрать пустой первый экран на измеряемые участки: ожидание сервера, передача HTML и блокировка CSS или JavaScript.","contentHtml":"

Пользователь открывает страницу и несколько секунд видит пустой экран. В отчёте появляется одна цифра: «загрузка заняла 2,4 секунды». Этой цифры недостаточно для решения. 1,6 секунды могли уйти до первого байта HTML, а могли — на выполнение скрипта после ответа сервера. Цена ошибки — менять JavaScript, когда тормозит backend, или добавлять кеш, когда браузер ждёт блокирующий CSS.

Тезис статьи простой: сначала разделите критический путь на участки, затем меняйте один участок и повторяйте тот же замер. TTFB (time to first byte) показывает ожидание первого байта ответа. Он не показывает время до готового экрана. Передача HTML, CSS, JavaScript и работа главного потока требуют отдельных наблюдений.

Механизм: один экран, несколько причин

Браузер начинает навигацию с запроса. Сервер формирует ответ и отправляет первый байт. Только после этого браузер получает весь HTML и строит DOM. Когда parser встречает таблицу стилей, он ждёт CSSOM для расчёта стилей. Синхронный script может остановить parser. Отложенный код продолжает занимать главный поток даже после завершения сетевой загрузки.

Поэтому время нужно разложить. Участок до первого байта относится к сети, proxy и серверному обработчику. Участок от первого байта до конца ответа относится к размеру HTML и передаче. Ранний CSS влияет на построение стилей. Скрипт может задержать DOM, layout или обработку пользовательского ввода. Одна общая длительность скрывает владельца и действие.

Диагностика первой загрузки
СимптомПричинаПроверкаДействие
TTFB стабильно выше 300 мсСервер или зависимость задерживает начало ответаСравнить responseStart - requestStart и журнал handlerПрофилировать серверный путь; не начинать с bundle
TTFB нормален, HTML приходит долгоБольшой ответ или медленная передачаСравнить responseEnd - responseStart и размер HTMLУменьшить ответ, проверить сжатие и кеш
HTML пришёл, первый экран ждёт CSSРанний stylesheet блокирует построение стилейСверить waterfall, initiator и видимый результатСократить критический CSS или отложить второстепенный
Сеть закончилась, экран не готовДолгая задача на главном потокеПосмотреть long tasks и длительность scriptРазбить работу или перенести некритичную часть
После async ломается интерфейсКод потерял порядок инициализацииПроверить зависимости скриптов и ошибки консолиВернуть порядок или использовать defer, если он подходит
Одно измерение лучше остальныхСработал кеш или изменились сеть и устройствоПовторить серию и сравнить p50/p95Не принимать единичный прогон за результат
Критический путь первой загрузки: запрос, TTFB, HTML, CSS и JavaScript
Схема разделяет ожидание ответа, получение HTML и работу ресурсов. Каждому участку нужен собственный замер и собственное действие.

Учебный пример: серверная задержка видна отдельно

Ниже — минимальный локальный сервер. Параметр mode=slow добавляет задержку перед отправкой заголовков. Пример намеренно проверяет только серверный участок. Он не моделирует мобильную сеть, кеш браузера, CDN, рендеринг или реальную нагрузку. Его вывод нельзя выдавать за production-результат.

import { createServer } from 'node:http'; import { performance } from 'node:perf_hooks'; const server = createServer((request, response) => { const url = new URL(request.url, 'http://127.0.0.1'); const delay = url.searchParams.get('mode') === 'slow' ? 300 : 0; setTimeout(() => { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end('<main>ready</main>'); }, delay); }); server.listen({ host: '127.0.0.1', port: 0 }, async () => { const { port } = server.address(); 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(); });

Ожидаемый результат — две строки со статусом 200. Строка slow должна быть примерно на 300 миллисекунд длиннее. Точное значение зависит от машины, поэтому сравнивайте режимы в одном запуске. Если различия нет, проверьте URL, единицы времени и то, что задержка стоит до writeHead, а не после отправки ответа.

Этот пример показывает причинность только для TTFB. Клиент ждёт тело целиком, поэтому его общее время включает передачу HTML. Чтобы измерить участки в браузере, откройте ту же страницу и прочитайте navigation entry. Не переносите число из Node в вывод о FCP или LCP: серверный пример не видит отрисовку.

Читаем Navigation Timing

В браузере найдите запись типа navigation. Поля requestStart, responseStart и responseEnd дают точки для разделения запроса, первого байта и конца ответа. Поле domContentLoadedEventEnd показывает завершение соответствующего события. Это диагностические временные точки, а не готовая оценка качества экрана.

const navigation = performance.getEntriesByType('navigation')[0]; if (navigation) { console.table({ ttfb: navigation.responseStart - navigation.requestStart, html: navigation.responseEnd - navigation.responseStart, domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime }); }

Если responseStart - requestStart велик, ищите серверную задержку, соединение и proxy. Если TTFB мал, а responseEnd - responseStart велик, проверьте размер HTML, сжатие и сеть. Если оба участка малы, но пользователь всё ещё видит пустой экран, переходите к ресурсам и главному потоку. Такой отрицательный путь важен: отсутствие серверной проблемы не доказывает, что страница быстрая.

DOMContentLoaded нельзя называть временем готовности экрана. Событие связано с разбором документа и отложенными скриптами. Изображения, шрифты, layout, paint и работа JavaScript могут продолжаться. Для визуального симптома нужен отдельный наблюдаемый критерий. Если команда использует FCP или LCP, измеряйте его тем же браузерным сценарием и не подменяйте его TTFB.

Ресурсы и порядок выполнения

После navigation entry соберите записи ресурсов. Для каждой записи важны URL без секретных параметров, тип ресурса, время начала, конец ответа, initiator и размер. Сначала ищите ресурс, который действительно пересекается с критическим участком. Длинная полоса в waterfall сама по себе не доказывает блокировку: шрифт мог начать загрузку после отрисовки главного содержимого.

Обычный stylesheet влияет на построение стилей. Синхронный script может остановить parser. defer оставляет порядок отложенных скриптов и запускает их после разбора документа. async запускает скрипт по готовности и меняет порядок. Если второй файл использует глобальный объект первого, безоговорочная замена на async создаёт отрицательный путь: сеть стала быстрее, но приложение упало до инициализации.

Поле renderBlockingStatus полезно только при поддержке конкретного браузера и конкретной записи. Отсутствующее значение означает отсутствие данных, а не доказательство, что ресурс не блокирует. Сверяйте API с HTML и визуальным результатом. Если сведения не совпадают, оставьте это ограничение в отчёте и опирайтесь на поддерживаемые наблюдения.

Как выбрать следующее изменение
НаблюдениеПервое изменениеРиск
Высокий TTFB при разных клиентахПрофилировать handler и его зависимостиИзменение кеша скроет, но не устранит причину
Большой HTML при нормальном TTFBПроверить структуру ответа и сжатиеСложнее кеширование и отладка шаблона
CSS задерживает первый полезный контентОтделить критические стили от второстепенныхFOUC и рассинхрон стилей
Script создаёт длинную задачуРазбить вычисление или отложить егоИзменится порядок состояния и событий

Порядок проверки

  1. Зафиксировать симптом: URL, устройство, браузер, сеть, режим кеша и видимый момент задержки.
  2. Повторить страницу серией прогонов, а не одним открытием. Сохранить сырые navigation и resource entries.
  3. Разделить TTFB, передачу HTML и время после получения документа.
  4. Сопоставить каждый участок с серверным журналом, waterfall и главным потоком браузера.
  5. Выбрать одну гипотезу и изменить только её: handler, HTML, CSS, script или порядок ресурса.
  6. Повторить тот же сценарий и сравнить медиану и p95. Проверить, что визуальный симптом изменился вместе с измеряемым участком.
  7. Проверить отрицательный путь: после изменения async/defer открыть страницу с медленной сетью и убедиться, что зависимости не запускаются в неверном порядке.
  8. Зафиксировать ограничения и вернуть изменение, если улучшилась одна цифра, но ухудшился первый экран или интерактивность.

Ограничения

Navigation Timing описывает события навигации, но не знает архитектуру backend и не устанавливает пороги качества. Resource Timing может скрывать часть сведений из-за политики приватности и кросс-доменных ограничений. Браузеры различаются по поддержке отдельных полей. Поэтому один API не заменяет сетевой журнал, профиль главного потока и визуальный замер.

Локальный сервер с фиксированной задержкой проверяет ветвление диагностики, но не показывает распределение latency, холодный кеш, CDN, TLS, балансировщик и конкуренцию запросов. Числа из примера не являются обещанием для production. Полевой вывод требует зафиксированных условий и серии наблюдений на целевом устройстве.

Ускорение одного участка может ухудшить другой. Отложенный CSS уменьшает блокировку, но может вызвать вспышку нестилизованного контента. Разбиение JavaScript сокращает длинную задачу, но увеличивает количество границ состояния. Проверяйте не только сеть, но и визуальную стабильность, ошибки консоли и интерактивность.

Проверяемый критерий готовности

Диагностика готова, если для страницы есть серия повторяемых замеров, отдельные значения TTFB и передачи HTML, список ранних ресурсов с initiator и запись о главном потоке. Для выбранной гипотезы названо одно действие, а повторный прогон показывает изменение именно целевого участка. Первый экран не ухудшился, консоль не получила новую ошибку, а отрицательный путь проверен на медленной сети. Если команда может сказать только «страница стала быстрее», причина и критерий ещё не определены.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/022.json b/editorial/agent-rewrites/022.json new file mode 100644 index 0000000..a59e5af --- /dev/null +++ b/editorial/agent-rewrites/022.json @@ -0,0 +1,7 @@ +{ + "index": 22, + "slug": "editorial-2027-05-field-http-tls-guide", + "title": "HTTP и TLS без догадок: как найти границу сетевой ошибки", + "excerpt": "504, ошибка сертификата и 404 выглядят похожими в браузере, но рождаются на разных этапах. Разбираем безопасную диагностику: от имени узла и TLS до HTTP-статуса, логов и критерия готовности.", + "contentHtml": "

Пользователь видит в браузере «не удаётся подключиться», а мониторинг показывает 504. Инженер меняет таймаут в приложении, повторяет запрос и получает тот же результат. Иногда он добавляет --insecure, видит ответ и считает проблему решённой. Цена такой ошибки — потерянное время, ослабленная проверка сертификата и повтор запроса, который для POST может создать вторую операцию.

\n

У сетевого сбоя есть граница. Он возникает при разрешении имени, установке TCP-соединения, TLS-рукопожатии, передаче HTTP или обработке маршрута приложением. Код из браузера не называет границу. Поэтому проверяйте этапы по порядку и записывайте только подтверждённые факты.

\n

Тезис: сначала установите, где остановился запрос

\n

HTTP-статус появляется только после того, как клиент получил HTTP-ответ. Если TLS завершился ошибкой, приложение не могло вернуть 404 или 503. Если запрос дошёл до доверенного входа и получил 404, бессмысленно начинать с проверки цепочки сертификата. Один и тот же текст ошибки в интерфейсе может скрывать разные этапы.

\n
Граница запроса и допустимый вывод
ЭтапЧто подтвержденоЧего это не подтверждает
DNSИмя разрешилось в адресПорт принимает соединение
TCPСоединение с адресом установленоСертификат подходит имени
TLSКанал и проверка имени завершилисьМаршрут приложения существует
HTTPПолучены статус и заголовкиОтвет сформировало origin-приложение
ПриложениеЛог связывает запрос с handlerПроблем нет у посредника или клиента
\n

Эта граница защищает расследование от скачка к удобной гипотезе. Статус 504 обычно означает, что компонент, который отвечает клиенту, не дождался другого компонента. Он не доказывает, что origin недоступен: причиной может быть маршрут, лимит соединений, балансировщик или промежуточный proxy. Проверяйте того, кто сформировал статус.

\n
\"Цикл
Сначала остаётся безопасный факт, затем выбирается граница проверки. Гипотеза меняется только после нового наблюдения.
\n

Механизм: что проверяет каждый слой

\n

DNS отвечает на вопрос «какой адрес связан с именем». Запишите имя и выбранный адрес. Если имя разрешается в несколько адресов, один успешный ответ не объясняет поведение остальных. Зафиксируйте также тип записи и момент проверки. Не делайте из DNS-ответа вывод о доступности сервиса.

\n

TCP отвечает на вопрос «принимает ли адрес соединение на порту». Ошибка соединения и таймаут различают отказ узла и отсутствие ответа, но не объясняют причину сами по себе. Балансировщик может принять TCP и не передать запрос дальше.

\n

TLS добавляет проверку защищённого канала и имени. Клиент сравнивает hostname с именами в Subject Alternative Name сертификата и проверяет цепочку доверия и срок действия. Сертификат может быть действующим, но выпущенным для другого имени. Подмена URL на IP часто ломает именно эту проверку. Заголовок Host не исправит ошибку: до HTTP клиент ещё не дошёл.

\n

Флаг --insecure полезен только как ограниченный учебный эксперимент, который показывает, что сервер способен отправить байты. Он отключает проверку сертификата и не является исправлением. После него вернитесь к обычной валидации и не переносите результат в критерий доступности.

\n

HTTP сообщает метод, путь, статус, заголовки и тело. Смотрите на Retry-After, Location, Allow, Cache-Control, Age и Via, если они относятся к вопросу. Один заголовок не доказывает источник ответа: proxy может его добавить, удалить или переписать. Сопоставляйте ответ с логом доверенного входа по request id.

\n

Учебный пример: отделяем запрос от его результата

\n

Ниже — самостоятельный локальный пример без сети. Сервер возвращает безопасный идентификатор, метод и путь. Код демонстрирует форму HTTP-обмена; он не показывает работу CDN, TLS, балансировщика или production-сервиса.

\n
import { createServer } from 'node:http';\n\nconst server = createServer((request, response) => {\n  response.writeHead(request.url === '/health' ? 200 : 404, {\n    'content-type': 'application/json; charset=utf-8',\n    'x-request-id': 'local-001'\n  });\n  response.end(JSON.stringify({ method: request.method, path: request.url }));\n});\n\nserver.listen(0, '127.0.0.1', async () => {\n  const { port } = server.address();\n  for (const path of ['/health', '/missing']) {\n    const response = await fetch(\\`http://127.0.0.1:\\${port}\\${path}\\`);\n    console.log(response.status, response.headers.get('x-request-id'));\n  }\n  server.close();\n});
\n

В учебном запуске /health возвращает 200, а /missing — 404. Это проверяет только локальный HTTP-контракт. Идентификатор local-001 задан вручную, поэтому он не является доказательством доверенного происхождения в настоящей системе.

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Ошибка до HTTP-статусаDNS, TCP или TLSСравнить этап и текст ошибки клиентаИсправлять имя, порт или сертификат на подтверждённом этапе
504 от proxyТаймаут ожидания upstreamСопоставить request id, длительность и лог proxyПроверить маршрут, лимит и upstream; не увеличивать таймаут вслепую
404 после успешного TLSПуть, метод или версия APIСверить метод, нормализованный путь и лог handlerИсправить контракт или маршрутизацию
401Аутентификация не принятаПосмотреть challenge и безопасный класс credentialsПроверить выдачу и область токена; секрет не копировать
403Доступ запрещён правиломПроверить policy и origin запросаИсправить право или объяснить отказ; не подменять его повтором
503 с Retry-AfterВременная недоступность сервераСверить зависимость, лимит и семантику методаПовторять только идемпотентную операцию с лимитом
\n

Безопасная запись результата

\n

Полный вывод curl -v удобен для диагностики, но может содержать Authorization, cookie, токены в query и непубличные имена. Очищайте вывод до копирования в issue или чат. Сохраняйте hostname, порт, метод, путь без секретных параметров, этап, статус, длительность, размер ответа и безопасный request id. Время пишите вместе с часовым поясом, длительность — с единицей измерения.

\n
function redactNetworkOutput(text) {\n  return text\n    .replace(/(Authorization:\\s*Bearer\\s+)[^\\s]+/gi, '$1[masked]')\n    .replace(/(Cookie:\\s*)[^\\n]+/gi, '$1[masked]')\n    .replace(/([?&](?:token|secret|signature)=)[^&\\s]+/gi, '$1[masked]');\n}\n\nconst sample = 'GET /health?token=abc HTTP/1.1\\nAuthorization: Bearer abc\\nCookie: sid=xyz';\nconsole.log(redactNetworkOutput(sample));
\n

Это учебный санитайзер текстовой строки. Он показывает три известных формата и не обнаруживает неизвестные секреты, JSON-поля, бинарные данные или нестандартные заголовки. Перед передачей всё равно просмотрите результат. Для постоянной диагностики надёжнее allowlist структурированных полей, чем маскирование произвольного текста.

\n

Порядок действий

\n
  1. Зафиксируйте URL, метод, время, режим proxy и безопасный идентификатор. Уберите Authorization, cookie и персональные query-параметры.
  2. Проверьте DNS и адрес назначения отдельно от приложения. Сохраните выбранный адрес, код ошибки и длительность.
  3. Проверьте TCP-порт. Не называйте сервис доступным только потому, что имя разрешилось.
  4. Для HTTPS проверьте hostname, SAN, цепочку доверия и срок действия сертификата обычным клиентом.
  5. После успешного TLS снимите HTTP-статус и нужные заголовки. Сравните ответ с origin и кэшем, если между ними есть посредник.
  6. Сопоставьте request id с логом доверенного входа и handler. Причину формулируйте только на уровне, подтверждённом наблюдением.
  7. Выберите один следующий тест с ожидаемым результатом. Для POST отдельно проверьте идемпотентность и ключ операции до любого повтора.
\n

Ограничения и отрицательный путь

\n

Один локальный запрос не показывает потерю пакетов, DNS-балансировку, корпоративный proxy, особенности браузерного хранилища, региональные маршруты и политику реального центра сертификации. Код 504 не называет зависимость, а 404 не доказывает одинаковую настройку всех регионов. Для этих выводов нужны согласованные логи и доступные сетевые наблюдения.

\n

Если TLS не завершился, остановите HTTP-проверку. Не подставляйте Host, не включайте --insecure как постоянный режим и не меняйте таймауты приложения. Если TLS успешен, но серверный лог не знает request id, не объявляйте origin источником ответа: сначала установите доверенную границу сопоставления. Если очиститель оставил неизвестное поле, не публикуйте запись.

\n

Критерий готовности

\n

Диагностика готова, когда запись содержит проверенный этап остановки, безопасные входные данные, наблюдаемый результат и один повторяемый тест. Для TLS это hostname, SAN, цепочка и срок действия; для HTTP — метод, путь, статус, выбранные заголовки и связь с логом. Исправление готово, когда тот же тест с обычной проверкой сертификата и тем же контрактом даёт ожидаемый результат, а отрицательный путь остаётся объяснимым: неизвестный путь возвращает согласованный 404, а повтор небезопасного метода не запускается автоматически.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/023.json b/editorial/agent-rewrites/023.json new file mode 100644 index 0000000..bb2fd36 --- /dev/null +++ b/editorial/agent-rewrites/023.json @@ -0,0 +1 @@ +{"index":23,"slug":"editorial-2027-05-mechanism-http-tls-guide","title":"Где ломается HTTPS: проверяем запрос по границам DNS, TCP, TLS и HTTP","excerpt":"Браузер показывает один итог, но ошибка возникает на конкретной границе. Разбираем порядок проверок, безопасную запись запроса и признаки, по которым можно отделить TLS, посредника и приложение.","contentHtml":"

Браузер сообщает: «не удалось подключиться». Сервисный клиент пишет certificate verify failed. В третьем месте тот же адрес возвращает 404. Команда видит одно слово — «ошибка» — и меняет маршрут или отключает проверку сертификата. Цена такого решения — потерянное время, неверный владелец исправления и иногда открытое соединение без проверки имени сервера.

Тезис простой: диагностируйте запрос по границам. DNS отвечает за имя и адрес. TCP отвечает за соединение. TLS устанавливает защищённый канал и проверяет сертификат. HTTP передаёт метод, путь и заголовки. Приложение обрабатывает контракт endpoint. Пока предыдущая граница не подтверждена, следующая не даёт фактов.

Механизм: пять границ одного запроса

Клиент начинает с имени из URL. Resolver возвращает адрес. TCP открывает поток к порту. Для HTTPS клиент и сервер проводят TLS handshake, выбирают параметры и проверяют цепочку доверия и имя. Только после этого клиент отправляет HTTP-запрос. Сервер или intermediary возвращает статус, заголовки и тело.

Порядок важен. Если TLS завершился исключением, у приложения нет HTTP-статуса, который можно расследовать. Если TLS завершился успешно, но ответ равен 404, сертификат уже не объясняет отсутствие маршрута. Если посредник вернул 503, этот статус может описывать его собственное состояние, а не состояние origin.

Граница запроса и допустимое утверждение
ГраницаЧто можно утверждатьЧто ещё не доказано
DNSИмя разрешилось в выбранный адресПорт принимает соединения, сертификат подходит имени
TCPСоединение с адресом и портом установленоTLS доверен, HTTP-маршрут существует
TLSЗащищённый канал принят клиентомЗапрос дошёл до нужного origin
HTTPПолучены статус и заголовкиОтвет сформировало ваше приложение
ПриложениеЛог доверенного входа связывает запрос с handlerДругой регион или кэш ведёт себя так же

Симптом → причина → проверка → действие

Первое безопасное действие для типичных симптомов
СимптомВероятная причинаПроверкаДействие
Нет HTTP-статуса, клиент сообщает о сертификатеИмя, срок или цепочка сертификата не прошли проверкуСверить hostname, SAN, срок и локальное хранилище доверияИсправить сертификат или доверенную цепочку; не оставлять отключённую проверку
Имя разрешается, connect завершается отказомПорт закрыт, маршрут недоступен или адрес выбран неверноСравнить адрес DNS и время TCP connectПроверить firewall, listener, балансировщик и выбранный адрес
TLS успешен, пришёл 404Путь, метод или виртуальный хост не совпал с маршрутомСопоставить URL, метод, authority и лог входного proxyИсправить маршрут или контракт; не менять сертификат
Приходит 503 от proxyПосредник не получил рабочий upstream или отказал по лимитуПроверить Via, время ответа и логи upstreamРазделить состояние proxy и origin, затем проверить соединения и лимиты
Заголовок Server указывает на знакомый продуктПоле добавил intermediary или его можно переписатьСопоставить request id с доверенным логом входаСчитать заголовок гипотезой, а не доказательством источника
Повторный запрос даёт другой статусКэш, балансировка, редирект или меняющееся состояниеСравнить Age, Cache-Control, ETag, адрес и времяПроверить маршрут каждого ответа и не объединять их в один результат

Почему заголовки не подтверждают источник

Server, Via и X-Request-Id принадлежат HTTP-сообщению. Посредник может добавить, удалить или переписать их. Даже правильный на вид идентификатор не доказывает, что запрос обработал конкретный handler. Доказательство появляется только там, где доверенный компонент создал идентификатор и записал его вместе с маршрутом, временем и результатом.

Сохраняйте в диагностике логическое имя назначения, а не только строку Host. В HTTP/2 и HTTP/3 используется поле authority, и привычная проверка одного заголовка может дать неполную картину. Если есть proxy, отдельно фиксируйте имя proxy и имя origin. Сертификат проверяет имя, которое использовал TLS-клиент; это не всегда имя, которое позже увидел application handler.

Минимальная безопасная запись содержит метод, нормализованный путь, этап отказа, статус, длительность, размер тела и request id. Уберите Authorization, cookie и секретные query-параметры. Путь должен быть полезен для маршрутизации, но не обязан содержать персональные или платёжные данные.

Учебный пример: отделяем TLS от HTTP

Ниже — локальный HTTP-сервер. Он показывает только границу HTTP: сервер принимает запрос, возвращает статус и идентификатор, клиент читает тело. Сеть, TLS, proxy и production-маршрутизация в пример не входят. Число local-001 не является доказательством доверенного источника.

import { createServer } from 'node:http';\n\nconst server = createServer((request, response) => {\n  response.writeHead(200, {\n    'content-type': 'application/json; charset=utf-8',\n    'x-request-id': 'local-001',\n  });\n  response.end(JSON.stringify({\n    method: request.method,\n    path: request.url,\n  }));\n});\n\nserver.listen(0, 'localhost', async () => {\n  const { port } = server.address();\n  const response = await fetch(`http://localhost:${port}/orders`);\n  console.log(response.status);\n  console.log(response.headers.get('x-request-id'));\n  console.log(await response.json());\n  server.close();\n});

Ожидаемый учебный результат — статус 200, идентификатор local-001 и тело с методом GET и путём /orders. Если убрать x-request-id, HTTP всё равно останется корректным. Это показывает границу поля: идентификатор помогает сопоставлять записи, но не является условием успешного запроса.

Отрицательный путь выглядит иначе. Если заменить URL на HTTPS и получить ошибку проверки сертификата до строки со статусом, код сервера не объясняет отказ. Если HTTPS проходит, а сервер возвращает 404, нужно проверять метод, путь и authority. Флаг вроде --insecure может показать, что удалённая сторона отвечает, но он отключает важную проверку и не исправляет конфигурацию.

TLS меняет порядок диагностики

Для HTTPS проверяйте не «SSL вообще», а три независимых условия. Первое — сертификат выдан для целевого hostname: имя должно совпасть с одним из значений Subject Alternative Name. Второе — цепочка ведёт к центру сертификации, которому доверяет клиент. Третье — текущая дата попадает в срок действия сертификата. Неправильный SAN, неизвестный issuer и истёкший срок требуют разных исправлений.

Подключение к IP вместо имени часто ломает проверку имени, даже если IP ведёт к нужному серверу. Заголовок Host не исправляет это задним числом: TLS завершается раньше, чем клиент отправляет HTTP-заголовки. Через proxy добавляется ещё одна граница. Имя proxy и имя origin нужно проверять отдельно, иначе ошибку промежуточного соединения можно принять за ошибку конечного сервиса.

Кэш также меняет смысл ответа. Age может показать возраст объекта, Cache-Control — правила хранения, ETag — валидатор представления, а Via — участие intermediary. Ни одно поле само по себе не доказывает, кто создал тело. Сопоставляйте заголовки с логом доверенного входа и, если возможно, с ответом origin.

\"Матрица
Каждая граница отвечает только на свой вопрос. HTTP-статус не подтверждает сертификат, а DNS-ответ не подтверждает маршрут приложения.

Порядок проверки

  1. Запишите URL, метод, безопасное имя назначения и момент запроса. Уберите токены, cookie и секретные query-параметры.
  2. Проверьте DNS: зафиксируйте выбранный A/AAAA-адрес и не делайте из этого вывода о доступности порта.
  3. Проверьте TCP connect и порт. При отказе остановитесь на сети, listener или балансировщике.
  4. Для HTTPS сверите hostname, SAN, срок действия и цепочку доверия обычным клиентом. Не используйте отключение проверки как исправление.
  5. После успешного TLS снимите статус, метод, путь, authority и ограниченный набор заголовков. Для ошибки до этого шага HTTP-поля не используйте.
  6. Сопоставьте request id с логом доверенного proxy или входного сервиса. Заголовок от удалённой стороны без такой записи оставьте гипотезой.
  7. Проверьте кэш и посредников по Age, Cache-Control, ETag, Via и времени ответа. Сравните cold и повторный запрос.
  8. Запишите одну подтверждённую причину и один следующий тест. Если граница не наблюдается, укажите «не доказано», а не назначайте виновника по косвенному полю.

Ограничения

Эта модель не заменяет трассировку сети. Она не показывает потери пакетов, особенности HTTP/2 multiplexing, работу CDN, разницу между регионами, настройки корпоративного proxy или состояние локального DNS-кэша. Локальный сервер не доказывает поведение реального origin. Учебные значения статуса, порта и идентификатора нельзя переносить в конфигурацию без проверки вашей среды.

Некоторые клиенты скрывают отдельные фазы и возвращают только итоговую длительность. Не восстанавливайте DNS, TCP и TLS по догадке. Запишите известный факт: например, «клиент прекратил ожидание через 800 мс» или «TLS завершился с ошибкой имени». Для детализации нужен клиент с подходящей диагностикой или наблюдение на доверенном proxy.

Не отключайте проверку сертификата в постоянной конфигурации и не публикуйте полный verbose-вывод. Не принимайте внешний request id как надёжную связь с серверным логом. Не объявляйте origin виновником ответа, который мог создать кэш или proxy. Эти отрицательные правила защищают диагностику от ложной уверенности.

Проверяемый критерий готовности

Проверка готова, когда для выбранного endpoint есть две безопасные записи: успешная и ошибочная. В каждой видны имя назначения, этап, статус или текст ошибки, длительность и ожидаемый следующий шаг. Интеграционный тест или разрешённое наблюдение должны показать, что ошибка до TLS не получает выдуманный HTTP-статус, а ответ 404 после TLS ведёт к проверке маршрута.

Для запроса через proxy дополнительно видны границы proxy и origin, а request id находится в логе доверенного входа. Если ответ может прийти из кэша, запись содержит признаки его участия или честно отмечает, что источник не установлен. Критерий выполнен только тогда, когда команда может повторить проверку и получить тот же вывод о границе отказа.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/024.json b/editorial/agent-rewrites/024.json new file mode 100644 index 0000000..d06c411 --- /dev/null +++ b/editorial/agent-rewrites/024.json @@ -0,0 +1,7 @@ +{ + "index": 24, + "slug": "editorial-2027-05-practice-http-tls-guide", + "title": "HTTP и TLS: как найти границу ошибки до того, как менять код", + "excerpt": "404, 503 и ошибка сертификата возникают на разных этапах запроса. Разбираем их по наблюдаемым признакам, проверяем локальным примером и не превращаем retry или --insecure в случайное исправление.", + "contentHtml": "

Браузер показывает ошибку, а команда сразу меняет timeout, маршрут или сертификат. Через час выясняется, что запрос вообще не дошёл до приложения. В другом случае приложение вернуло 404, но инженер ищет проблему в TLS. Цена такой ошибки — лишний rollout, потерянное время и риск сломать рабочий путь, пытаясь исправить не тот слой.

\n

Тезис: сначала нужно определить первый подтверждённый этап отказа. До HTTP находятся DNS, TCP и TLS. После успешного TLS появляются метод, URI, заголовки и статус HTTP. Если перепутать границу, проверка не отвечает на вопрос и создаёт ложное ощущение прогресса.

\n

Один запрос, несколько разных отказов

\n

У HTTPS-запроса есть последовательность. Клиент разрешает имя, открывает TCP-соединение, проводит TLS-рукопожатие, отправляет HTTP-сообщение и читает ответ. Посредник может завершить запрос на любом шаге. Поэтому текст ошибки важнее цвета страницы: ERR_TLS_CERT_ALTNAME_INVALID ещё не является HTTP-ответом, а 404 означает, что HTTP-обмен уже состоялся.

\n

Уровень ошибки задаёт набор допустимых проверок. Заголовок Host не исправит сертификат, если TLS-клиент не доверяет имени. Увеличение timeout не создаст отсутствующий маршрут. Повтор POST не становится безопасным только потому, что сервер вернул 503.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
ERR_TLS_CERT_ALTNAME_INVALIDИмя в URL не совпадает с SANСверить hostname, SAN и адресИсправить имя, сертификат или vhost
404 Not FoundURI или метод не попал в маршрутПроверить известный endpoint и лог маршрутаИсправить путь, метод или route config
503 Service UnavailableОбработчик или зависимость недоступныПроверить Retry-After и upstream-логиУстранить недоступность; retry ограничить контрактом
Timeout без статусаНеизвестен этап задержкиРазделить connect и read timeoutНайти этап, затем менять лимит
\n
\"Последовательность
Диагностика идёт слева направо. Первый наблюдаемый сбой ограничивает область поиска.
\n

Что означает HTTP-статус

\n

404 — это статус HTTP-ответа. Он не доказывает, что ответ сформировало origin-приложение: его мог вернуть reverse proxy или другой посредник. Сохраняйте метод, нормализованный путь, статус, request ID и безопасный набор заголовков. Полные Cookie, Authorization и чувствительные query-параметры в запись не нужны.

\n

503 сообщает о временной невозможности обработать запрос. Заголовок Retry-After может дать ориентир, но не гарантирует безопасность повтора. Для чтения задайте ограниченный retry с общим deadline. Для записи сначала проверьте идемпотентность и правило дедупликации. Иначе потерянный ответ после успешной записи превратится в дубль.

\n

Отрицательный путь обязателен. Если метод меняет деньги, заказ, подписку или другой ресурс, а сервер не принимает ключ операции и не описывает повтор, клиент должен остановиться. Автоматический retry в таком месте скрывает неопределённый результат.

\n

Почему ошибка сертификата возникает раньше HTTP

\n

TLS защищает канал и связывает его с именем узла. Клиент сравнивает имя назначения с именами в Subject Alternative Name сертификата. Если URL содержит старый alias, IP-адрес или имя другого виртуального хоста, проверка может завершиться до отправки HTTP-запроса. Тогда у приложения нет статуса и тела для анализа.

\n

Проверяйте три свойства: имя, цепочку доверия и срок действия. Общий текст certificate error скрывает различия между ними. Не подменяйте проверку флагом --insecure. Он может показать, что endpoint отвечает без валидации сертификата, но не исправляет доверие и не доказывает безопасность соединения.

\n

Учебная проверка на локальном сервере

\n

Пример ниже учебный. Он запускает только локальный HTTP-сервер, не обращается к внешней сети и не моделирует TLS. Его задача — показать разницу между известным маршрутом и отсутствующим URI. В production этот код не заменяет proxy, сертификат, health-check или журнал приложения.

\n
import { createServer } from 'node:http'; const server = createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok'); return; } res.writeHead(404, { 'content-type': 'text/plain' }); res.end('missing'); }); server.listen({ port: 0, host: '127.0.0.1' }, async () => { const { port } = server.address(); const response = await fetch('http://127.0.0.1:' + port + '/missing'); console.log(response.status, await response.text()); server.close(); });
\n

Запуск node check-http.mjs в этом учебном случае печатает 404 missing. Если заменить путь на /health, получится 200 ok. Мы проверяем и статус, и тело. Один только текст страницы не показывает, какой контракт нарушен.

\n

Отправьте POST /health и получите 404: маршрут принимает только GET. Это не проблема TLS и не причина увеличивать timeout. Сначала решите, должен ли такой метод существовать в контракте.

\n

Порядок диагностики

\n
  1. Запишите URL, метод, время и request ID. Удалите Authorization, Cookie и персональные параметры.
  2. Проверьте DNS и адрес назначения отдельно от приложения. Несколько адресов могут вести к разным конфигурациям.
  3. Для HTTPS проверьте hostname, SAN, срок действия и цепочку сертификата обычным клиентом. Не отключайте проверку в рабочем запросе.
  4. После успешного TLS проверьте статус, Allow, Location, Retry-After, тип тела и идентификатор ответа.
  5. Сопоставьте метод с эффектом. Для изменения состояния определите идемпотентность или ключ операции до включения повторов.
  6. Сравните ошибочный запрос с безопасным endpoint на том же hostname и зафиксируйте ожидаемый результат.
\n

Ограничения

\n

HTTP-статус не раскрывает автоматически путь внутри прокси или состояние upstream. Заголовок Server не является доказательством источника ответа. DNS-ответ не доказывает наличие нужного маршрута. Для вывода о реальной инфраструктуре нужны согласованные логи, сетевые данные и разрешённый доступ.

\n

Локальный пример не проверяет CDN, балансировщик, корпоративный proxy, реальную цепочку сертификатов, рестарт процесса или запись в базе. Он показывает только границу между URI, методом и ответом локального HTTP-сервера. Не переносите его упрощённое поведение в production без явного контракта и тестов.

\n

Если TLS не проходит, не ищите заголовки приложения. Если TLS проходит, но статус равен 404, ищите маршрут и метод. Если пришёл 503, определяйте доступность обработчика и безопасность повтора. Если нет статуса, сначала найдите этап timeout.

\n

Проверяемый критерий готовности

\n

Диагностика готова, если для одного hostname можно воспроизвести успешный HTTPS-запрос, ошибку проверки имени сертификата, известный 404 и временный 503. Для каждого случая запись содержит этап, метод, путь, статус или TLS-ошибку, безопасный request ID и одно действие. Для записи с неопределённым результатом retry остановлен или защищён идемпотентным контрактом.

\n

Проверяемый результат — не «ошибка исчезла», а совпадение наблюдения с уровнем проверки. Повторите запрос после изменения одного условия. Если причина и новый результат не различаются по логам или команде, ремонт ещё не доказан.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/025.json b/editorial/agent-rewrites/025.json new file mode 100644 index 0000000..aed29bf --- /dev/null +++ b/editorial/agent-rewrites/025.json @@ -0,0 +1 @@ +{"index":25,"slug":"editorial-2027-04-field-build-evolution","title":"Рост frontend bundle: как найти конкретный input и не чинить симптом","excerpt":"JavaScript-артефакт стал больше, но размер файла не говорит о причине. Разбираем metafile, связываем delta с input и проверяем, что изменилось в output и доставке.","contentHtml":"

После изменения сборки JavaScript-файл стал больше. В отчёте видна общая delta, но не видно, какой импорт её создал. Команда удаляет самую крупную библиотеку по названию. Так легко сломать функцию и не убрать причину: размер мог вырасти из-за нового entry point, дубликата зависимости, отключённого tree-shaking или source map, попавшей в артефакт. Цена ошибки — регресс поведения, лишний сетевой трафик и несколько итераций вслепую.

Тезис статьи простой: рост bundle нужно свести к изменению между двумя наборами входов. Сначала сравнивают metafile или другой отчёт состава сборки. Потом находят input с ненулевой delta, проверяют его связь с chunk и только затем меняют импорт, конфигурацию или delivery. Общий размер файла остаётся симптомом, а не диагнозом.

Как проходит сигнал от input к браузеру

Сборщик читает entry point и рекурсивно разрешает импорты. Он преобразует модули, удаляет недостижимый код, объединяет часть графа и записывает output. Metafile описывает этот путь в структурированном виде. У esbuild в нём есть разделы inputs и outputs; у конкретного инструмента формат может отличаться, но граница анализа остаётся той же.

Input — файл или модуль, который участвовал в сборке. Его bytes показывают вклад исходного входа в анализ. Output — созданный артефакт. Его размер зависит от преобразования, минификации, разделения chunks и повторного использования общего кода. Передача по сети зависит ещё от gzip или Brotli, заголовков и кэша браузера. Поэтому число в metafile нельзя называть размером загрузки без отдельной проверки.

Source map решает другую задачу. Она связывает преобразованный код с исходными файлами для отладки. Карта может быть большой. Её наличие в каталоге сборки не означает, что её нужно отдавать каждому пользователю. Проверяйте output, HTTP-заголовок и политику публикации отдельно.

\"Цикл
Общий симптом проходит несколько границ: input, chunk, output и HTTP-ответ. Исправление выбирают после перехода к конкретному слою.

Учебный diff двух отчётов

Ниже — учебная функция для минимального esbuild-подобного JSON. Она объединяет имена входов из двух отчётов, подставляет ноль для отсутствующего input, считает разницу и сортирует рост сверху. Пример показывает способ поиска. Он не запускает сборку и не доказывает результат в конкретном проекте.

import { summarizeBundleDiff } from './upgrade-2027-04.mjs';\n\nconst before = {\n  inputs: {\n    'src/app.ts': { bytes: 4200 },\n    'src/search.ts': { bytes: 1800 },\n    'node_modules/date-fns/index.js': { bytes: 900 }\n  }\n};\n\nconst after = {\n  inputs: {\n    'src/app.ts': { bytes: 4200 },\n    'src/search.ts': { bytes: 1800 },\n    'node_modules/date-fns/index.js': { bytes: 900 },\n    'node_modules/chart-lib/index.js': { bytes: 7600 }\n  }\n};\n\nconsole.log(summarizeBundleDiff(before, after));\n// [{ name: 'node_modules/chart-lib/index.js', before: 0,\n//    after: 7600, delta: 7600 }]

В реальном отчёте сохраняйте рядом commit, lockfile, команду сборки, режим, entry points, версию runtime и имена output. Иначе два JSON могут выглядеть сравнимыми, хотя один собран с другой конфигурацией. Перед diff проверьте, что пути нормализованы: абсолютный путь рабочей машины и относительный путь CI создадут две разные строки для одного файла.

Следующий вопрос — не «какой input самый большой?», а «как этот input попал в конкретный output?». Ищите связи в разделе outputs или в анализаторе вашего bundler. Если модуль вошёл только в ленивый chunk, изменение не равно росту initial загрузки. Если он попал в общий chunk, его стоимость может распространяться на несколько страниц. Если output не изменился, ищите причину в сжатии, заголовках, кэше или измерении браузера.

Симптом → причина → проверка → действие

Матрица диагностики роста bundle
СимптомПричинаПроверкаДействие
Появился новый большой inputНовый импорт или entry pointНайти первый импорт и output, в который он попалРазделить загрузку, удалить импорт или оставить стоимость с объяснением
Старый input выросИзменился export, plugin или transformСравнить delta input и настройки tree-shakingПроверить side effects, export и конфигурацию плагина
Одна зависимость видна несколькими путямиДубликаты версий или разные resolver conditionsСопоставить реальные пути и lockfileСвести версии, alias или условия разрешения
Metafile почти тот же, но ответ тяжелееИзменились minify, compression или headersСравнить raw, gzip/Brotli и HTTP responseИсправить delivery и повторить browser check
Выросла source mapDebug artifact публикуется рядом с production outputПроверить каталог, header SourceMap и сетевой запросОграничить публикацию карты нужной среде
В metafile нет объясненияСравниваются разные входы или другой формат отчётаСверить commit, command, target и схему JSONПересобрать baseline и candidate в одинаковых условиях

Пример с динамическим импортом

Представим страницу поиска. В baseline она импортирует форму и таблицу при первом открытии. В candidate в общий модуль добавили визуализацию: import Chart from 'chart-lib'. В metafile появился новый input. Но решение зависит от маршрута импорта.

// Динамическая граница загрузки. Учебный пример.\nconst openChart = async () => {\n  const { renderChart } = await import('./chart/render-chart.js');\n  return renderChart();\n};\n\nbutton.addEventListener('click', openChart);

Если сборщик поддерживает code splitting и конфигурация сохраняет эту границу, библиотека может уйти в отдельный chunk. Тогда initial bundle не обязан вырасти на весь вклад библиотеки. Цена появляется при открытии графика: пользователь ждёт дополнительный запрос и выполнение кода. Нужно измерять оба пути.

Статический импорт даёт другой результат: import { renderChart } from './chart/render-chart.js'. Если модуль достижим из entry point и не исключён настройками, он может попасть в initial output. Это не ошибка само по себе. Для критического пути важнее время до функции, чем минимальный размер каждого файла. Сначала сформулируйте границу загрузки, затем проверьте, сохранил ли её bundler.

Динамический импорт также не гарантирует маленький chunk. Внутри него могут оказаться общие зависимости, полифиллы или набор файлов, созданный шаблонным путём. Для runtime-пути проверяйте сетевой waterfall, размер после сжатия, cache headers и время выполнения. Нельзя делать вывод только по строке delta в отчёте.

Не перепутать состав с поведением

Tree-shaking удаляет код, который инструмент считает недостижимым. Побочные эффекты, формат модуля и настройки package могут изменить этот вывод. Если большой input присутствует в отчёте, это ещё не доказывает, что весь исходный файл попал в переданный bundle. Смотрите связь input с output и фактические bytes output.

Дубликат зависимости часто выглядит как два похожих пути: одна копия разрешилась из корня, другая — из вложенного package. Сначала проверьте lockfile и resolver. Alias может уменьшить размер, но сломать пакет, который рассчитывает на другую версию или экспорт. Правило «свести всё к одной версии» применяйте только после проверки совместимости.

Source map нельзя считать частью пользовательского JavaScript без проверки HTTP. Если карта доступна по ссылке из production-ответа, браузер и инструменты разработчика смогут запросить её. Это удобно для отладки, но карта может раскрывать исходники и увеличивать доступный объём артефактов. Решение зависит от политики проекта и среды.

Действия по порядку

  1. Зафиксировать baseline и candidate: commit, lockfile, runtime, команда, режим, entry points, flags и output directory.
  2. Собрать оба отчёта состава на одинаковом окружении. Записать exit code и не смешивать cold cache с warm cache без пометки.
  3. Нормализовать пути и схему JSON. Запустить diff по inputs, затем отсортировать изменения по абсолютной delta.
  4. Для каждого заметного input найти output и chunk. Отделить initial, lazy и shared части.
  5. Проверить причину: импорт, версия зависимости, resolver, plugin, tree-shaking, minify или source map.
  6. Сделать одно изменение. Пересобрать candidate и повторить diff, чтобы увидеть, исчезла ли именно заявленная delta.
  7. Проверить браузерный путь: network transfer, compression, cache hit, время загрузки lazy chunk и ошибки runtime.
  8. Зафиксировать отрицательный путь: если metafile стабилен, не менять импорт, а перейти к delivery или browser measurement.

Ограничения и критерий готовности

Metafile показывает модель сборщика, а не полную стоимость для пользователя. Разные bundler описывают input и output по-разному. Сжатие, HTTP-кэш, CDN, service worker и скорость CPU находятся за пределами одного JSON. Source map может быть создана, но не отдана клиенту. Поэтому сравнение состава нельзя выдавать за измерение производительности страницы.

Нельзя считать исправлением постоянное отключение source map, удаление зависимости по имени или включение агрессивного split без проверки поведения. Нельзя сравнивать отчёты после разных изменений в lockfile и конфигурации. Если входы различаются, сначала восстановите сопоставимые условия; иначе отрицательный результат анализа честнее случайного вывода.

Готовность подтверждается четырьмя артефактами: отчёты baseline и candidate с условиями запуска, diff с конкретным input и output, проверка изменённого пользовательского пути и повторная сборка после действия. Другой инженер должен увидеть, что изменилось, воспроизвести проверку и понять, почему выбранное действие относится к причине. Если причина не найдена, готовым результатом считается зафиксированная граница: состав bundle стабилен, следующий тест идёт на уровне compression, HTTP или браузера.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/026.json b/editorial/agent-rewrites/026.json new file mode 100644 index 0000000..4210671 --- /dev/null +++ b/editorial/agent-rewrites/026.json @@ -0,0 +1 @@ +{"index":26,"slug":"editorial-2027-04-mechanism-build-evolution","title":"Кэш frontend-сборки: как ключ сохраняет или скрывает устаревший результат","excerpt":"Разбираем, какие входы должны формировать ключ кэша сборки, почему cache hit не доказывает свежесть артефакта и как проверить отрицательный путь.","contentHtml":"

Сборка внезапно стала медленной, хотя в CI почти каждый запуск сообщает cache hit. В другом случае job проходит за секунды, но после изменения lockfile приложение получает старый bundle. Эти симптомы похожи на проблему производительности, но цена ошибки выше: команда либо платит временем за постоянные промахи, либо публикует артефакт, который не соответствует исходникам.

Тезис простой: кэш повторяет результат функции от конкретного набора входов. Ключ должен меняться, когда меняется любой вход, влияющий на dependency graph, transform или output. Cache hit подтверждает только совпадение ключа. Он не подтверждает полноту ключа, корректность публикации и соответствие source map.

Механизм: кэш повторяет вычисление, а не «проект»

Bundler читает исходники, lockfile, конфигурацию, плагины и окружение. Затем он строит граф модулей и сохраняет промежуточные или итоговые данные. При следующем запуске он вычисляет ключ и решает, можно ли использовать сохранённый результат. Если ключ содержит мало данных, система не видит устаревание. Если ключ содержит случайные данные, система не видит повторение.

У ключа есть три свойства. Он должен быть детерминированным: одинаковые нормализованные входы дают одинаковое значение. Он должен быть чувствительным: изменение значимого входа меняет значение. Он должен быть ограниченным: в него не попадают timestamp, случайный UUID и абсолютный путь, если они не влияют на output. Иначе кэш либо выдаёт ложный hit, либо превращает каждый запуск в miss.

Минимальный набор зависит от инструмента. Для dependency pre-bundling важны lockfile, patches, релевантная конфигурация и среда выполнения. Для файлового кэша webpack дополнительно важны режим, каталог и сериализация. Для linked dependency нужно проверить, как bundler разрешает symlink и когда повторяет оптимизацию. Нельзя перенести список входов из одного toolchain в другой без проверки его семантики.

Матрица ключа кэша frontend-сборки: lockfile, конфигурация, runtime, исходный digest и каталог кэша ведут к проверке результата.
Ключ связывает входы с результатом, но не заменяет проверку output. Изменение значимого входа должно вести к invalidation.

Симптом → причина → проверка → действие

Диагностика кэша сборки
СимптомПричинаПроверкаДействие
Каждый запуск — missКлюч включает время или нестабильный путьСравнить ключ двух запусков без изменения исходниковНормализовать входы и убрать шумные поля
Hit после изменения lockfileLockfile не входит в ключИзменить только lockfile и записать key до/послеДобавить digest lockfile и проверить invalidation
Разные job видят чужой outputОбщий каталог без namespaceСопоставить key, runner, права и locationРазделить namespace или ограничить общий кэш
Bundle новый, source map стараяКэшируются связанные артефакты с разными условиямиСверить bundle, map и commit в одном jobПубликовать согласованную пару или остановить выпуск
Linked package не меняетсяDev-server использует сохранённый pre-bundleИзменить linked dependency и проверить повторную оптимизациюПрименить документированный force/re-bundle и уточнить watch-контракт

Учебный пример ключа

Ниже функция показывает прозрачный способ собрать ключ из четырёх строк. Пример учебный: он не знает формат конфигурации вашего bundler, не запускает сборку и не доказывает результат в production. Его проверяемое свойство — изменение lockfile меняет ключ, а повтор тех же входов сохраняет его.

import { makeDependencyCacheKey } from './upgrade-2027-04.mjs';\n\nconst base = {\n  lockfile: 'lock-v1',\n  config: 'target=es2022;minify=true',\n  runtime: 'node-24',\n  sourceDigest: 'src-001',\n};\n\nconst first = makeDependencyCacheKey(base);\nconst repeat = makeDependencyCacheKey({ ...base });\nconst afterLockfileChange = makeDependencyCacheKey({\n  ...base,\n  lockfile: 'lock-v2',\n});\n\nconsole.log(first === repeat); // true\nconsole.log(first === afterLockfileChange); // false

Функция использует фиксированный порядок полей и разделитель строк перед вычислением SHA-256. Реальный проект должен определить полный набор входов отдельно. Если plugin меняет transform, его версия или нормализованная конфигурация должны участвовать в digest. Если runtime меняет ABI или формат сериализации, одной версии Node может быть мало.

Обратный путь важнее положительного. Если ключ совпал, но output не соответствует commit, нельзя лечить симптом постоянным force. Сначала нужно установить, какой вход пропущен, где лежит чужой результат и какой job его записал. Если причина неизвестна, безопасное действие — остановить публикацию или очистить ограниченный namespace, а затем добавить диагностический вывод. Принудительная инвалидизация скрывает дефект ключа и вернёт его после следующего изменения.

Cache hit требует второй проверки

После hit проверьте не только exit code. Сверьте digest bundle, source map, список chunks и commit, из которого построен артефакт. Если сборка публикует manifest, сравните его с фактическими файлами. Наличие файла в каталоге кэша не означает, что job использовал его целиком: bundler мог восстановить часть данных и пересобрать остальное.

Разделяйте cold и warm режимы. Cold run показывает стоимость работы без сохранённого результата. Warm run показывает выигрыш при совпадении условий. Эти числа отвечают на разные вопросы. Не смешивайте время установки зависимостей, bundling, minify и upload, если измеряете только сборку. Не переносите локальный hit на CI: другой Node, runner, каталог или права меняют результат.

Для Vite linked dependency является отдельной границей. Локальный пакет может разрешаться не так, как опубликованная зависимость. Изменение файла не обязано автоматически менять pre-bundle. Проверяйте документированное поведение dependency optimizer и режим повторной оптимизации. Для webpack memory cache живёт в процессе, а filesystem cache переживает запуски. У них разная стоимость, область действия и диагностика.

Порядок проверки

  1. Выпишите входы, которые меняют граф зависимостей, transform, target, формат output или правила публикации.
  2. Нормализуйте конфигурацию и значения окружения. Зафиксируйте lockfile, runtime, bundler, платформу, каталог и namespace.
  3. Соберите deterministic key. Уберите timestamp, случайные значения и абсолютные пути, если они не меняют результат.
  4. Проверьте положительный путь: одинаковые входы дают hit и одинаковые digest bundle, map и manifest.
  5. Проверьте отрицательный путь по одному изменению: lockfile, конфигурация, runtime, исходный модуль и linked package должны дать miss или документированную invalidation.
  6. Запишите key, hit/miss, location, cold/warm режим и причину invalidation. Не выводите секреты и приватные исходники.
  7. Запретите публикацию, если key совпал, а согласованность артефактов не доказана. Исправьте входы или границу кэша и повторите проверку.

Ограничения

Hash строки не понимает смысл конфигурации. Два разных текста могут описывать одинаковое поведение и дать разные ключи. Обратная ситуация опаснее: один digest может не учитывать plugin, symlink, системную библиотеку или скрытый флаг. Поэтому формула ключа должна следовать реальным входам bundler, а не удобству реализации.

Общий файловый кэш зависит от прав, конкуренции job, срока хранения и способа очистки. Namespace защищает от смешения результатов, но не исправляет неполный ключ. Source map может не публиковаться в production, однако при диагностике её нужно сверять с тем же bundle и commit. Пользовательская скорость также не следует из cache hit: её проверяют отдельными браузерными и сетевыми измерениями.

Проверяемый критерий готовности

Механизм готов, если другой инженер может повторить два запуска на одинаковом входе и получить одинаковый key, затем изменить один значимый вход и увидеть ожидаемый miss или явную invalidation. После hit bundle, source map и manifest проходят проверку согласованности. В отчёте видны входы, runtime, location, состояние кэша и причина решения.

Если хотя бы один изменённый вход сохраняет старый output без объяснённого контракта, кэш нельзя считать корректным. Если система не показывает причину hit или miss, сначала добавьте наблюдаемость. Только после этого сравнивайте секунды и решайте, оправдывает ли ускорение сложность хранения.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/027.json b/editorial/agent-rewrites/027.json new file mode 100644 index 0000000..c3065ea --- /dev/null +++ b/editorial/agent-rewrites/027.json @@ -0,0 +1,7 @@ +{ + "index": 27, + "slug": "editorial-2027-04-practice-build-evolution", + "title": "Сборка быстрее на 20%? Сначала докажите, что вход одинаковый", + "excerpt": "Как сравнивать frontend-сборки по одному входу, не принимать cache hit за ускорение и находить причину роста bundle по данным артефакта.", + "contentHtml": "

Новая frontend-сборка закончилась за 38 секунд вместо 47. Через день CI снова показывает 47 секунд. В другом запуске candidate оказался быстрее, но собирал только production entry, а baseline — два entry и source map. Цена ошибки — неверный выбор инструмента, потерянное время на миграцию и артефакт, который нельзя сравнить с опубликованным.

\n

Тезис: время и размер имеют смысл только для одинаковой работы. Сборщик получает исходный граф, lockfile, конфигурацию, runtime, entry points и состояние кэша. Если хотя бы один существенный вход отличается, результат нужно пометить как несопоставимый, а не объявлять победителя.

\n

Что именно сравнивает инженер

\n

Сборка не является одной операцией. Сначала резолвер строит граф модулей. Затем плагины и loaders преобразуют входы. Bundler раскладывает граф по chunks, минифицирует код и пишет output. Кэш может вернуть промежуточный результат до части этих шагов. Поэтому число из секундомера описывает не «скорость инструмента», а конкретный маршрут с конкретным состоянием.

\n

Размер тоже имеет несколько значений. Размер исходного input показывает вклад модуля в сборку. Размер output показывает файл на диске. Transfer size показывает объём после compression и HTTP-обмена. Эти величины нельзя подменять друг другом. Большой input может попасть в отложенный chunk, а небольшой модуль — блокировать первый экран.

\n
\"Последовательность
Секундомер запускается после фиксации условий. Если меняется вход или конфигурация, сравнение начинается заново.
\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Candidate быстрее в одном запускеУ него тёплый cacheСравнить hit/miss, directory и серию cold/warmРазвести режимы и повторить замер
Output меньше, но entry другойСобирается другая работаСверить entry, mode, flags и список chunksИсключить запуск или выровнять вход
Bundle вырос на 60 КБНовый input или duplicate dependencyСравнить metafile inputs и lockfileПроверить import, версии и split
Fingerprint совпал, результат старыйВ ключ не вошёл plugin или linked packageИзменить один вход и проверить invalidationРасширить ключ и проверить output после hit
\n

Кэш повторяет не проект, а функцию от входов

\n

Кэш хранит результат, полученный при определённых условиях. Его ключ должен различать изменения, которые влияют на граф, transform или output. Для типового frontend-проекта это lockfile, нормализованная конфигурация, версия Node и bundler, исходный digest, entry и параметры режима. Состав полей зависит от инструмента. Нельзя скопировать ключ webpack в Vite и считать его полным.

\n

Неполный ключ даёт опасный cache hit. Например, команда меняет alias или plugin, но имя cache namespace остаётся прежним. Bundler видит старый промежуточный результат и выпускает артефакт без нового правила. Постоянный флаг принудительной пересборки скрывает проблему, но не объясняет, что именно должно инвалидировать кэш.

\n

Слишком широкий ключ создаёт обратную проблему. Если в него попадает абсолютный путь временной директории или случайный идентификатор job, каждый запуск выглядит новым. CI теряет повторяемость. Поэтому ключ должен быть детерминированным: одинаковые значимые входы дают одинаковое значение, а изменение значимого входа меняет его.

\n

Учебная проверка сопоставимости

\n

Ниже учебный код. Он не запускает bundler и не измеряет реальный проект. Функция получает два уже записанных запуска, отбрасывает разные inputFingerprint и только затем считает разницу. Числа нужны для показа контракта, а не для заявления о production-эффекте.

\n
function compareBuildRuns({ baseline, candidate }) {\n  if (!baseline || !candidate) {\n    return { comparable: false, reason: 'нет двух запусков' };\n  }\n\n  if (baseline.inputFingerprint !== candidate.inputFingerprint) {\n    return { comparable: false, reason: 'входы сборки различаются' };\n  }\n\n  return {\n    comparable: true,\n    deltaMs: candidate.durationMs - baseline.durationMs,\n    deltaBytes: candidate.outputBytes - baseline.outputBytes,\n  };\n}\n\nconst baseline = {\n  inputFingerprint: 'src-42', durationMs: 420, outputBytes: 180000,\n};\nconst candidate = {\n  inputFingerprint: 'src-42', durationMs: 380, outputBytes: 176000,\n};\nconst changedInput = {\n  inputFingerprint: 'src-43', durationMs: 350, outputBytes: 174000,\n};\n\nconsole.log(compareBuildRuns({ baseline, candidate }));\n// { comparable: true, deltaMs: -40, deltaBytes: -4000 }\nconsole.log(compareBuildRuns({ baseline, candidate: changedInput }));\n// { comparable: false, reason: 'входы сборки различаются' }
\n

Первый вызов разрешает вычисление: учебный candidate завершился на 40 мс раньше и дал на 4000 байт меньше. Второй вызов останавливается до сравнения цифр. Более быстрое число не компенсирует другой исходный граф. В рабочем отчёте fingerprint должен быть связан с commit, lockfile, entry и версией окружения, а не с короткой строкой, которую никто не умеет восстановить.

\n

Почему одного запуска недостаточно

\n

Время зависит от cache state, нагрузки CPU, диска и фоновых процессов. Поэтому записывайте cold и warm отдельно. Для каждой серии сохраняйте несколько запусков и выбирайте заранее заданное представление: медиану для типичного времени или p95 для хвоста. Не смешивайте установку зависимостей с bundling, если вопрос касается только сборки.

\n

Сравнивайте не только duration. Запишите exit code, peak memory, число и имена chunks, output bytes, cache hit/miss и команду запуска. Если candidate быстрее, но потерял source map или собрал меньше entry, это не оптимизация. Это изменение результата.

\n

В webpack contenthash помогает увидеть, какой файл изменился после изменения содержимого. Deterministic module ids уменьшают случайные изменения имён. Эти настройки улучшают диагностику и кэширование, но не делают разные конфигурации одинаковыми. Их эффект нужно проверять на конкретном output.

\n

От общего роста bundle к конкретному input

\n

Размер bundle — только симптом. Сравните два metafile или эквивалентных отчёта сборщика. В JSON-метафайле esbuild можно найти inputs и их вклад в outputs. Отсортируйте delta по каждому input. Новый крупный модуль, выросший старый модуль и две версии одной зависимости ведут к разным действиям.

\n

Если delta появилась в библиотеке, найдите import path и проверьте tree-shaking. Если появились два пути к разным версиям пакета, проверьте lockfile и resolver. Если input не изменился, а output вырос, ищите plugin transform, target, minify и split. После исправления повторите сборку на том же fingerprint.

\n

Metafile не измеряет браузерную скорость. Для пользовательского эффекта отдельно смотрите transfer size, compression, cache и timing критического ресурса. Source map помогает связать bundle с исходным модулем, но карта может быть большой и не должна случайно попасть в production delivery.

\n

Порядок проверки

\n
  1. Запишите вопрос сравнения: время bundling, размер output или скорость критического пути. Не смешивайте эти метрики.
  2. Зафиксируйте commit, lockfile, entry points, mode, flags, target, версии Node и bundler, runner и расположение кэша.
  3. Соберите baseline и candidate с одинаковой командой. Отдельно пометьте cold и warm state, сохраните raw output и exit code.
  4. Проверьте fingerprint и состав результата. Разный fingerprint, entry, chunk или режим означает «несопоставимо», даже если число лучше.
  5. Сравните серию запусков, chunks и input delta. Для роста найдите import path, dependency version или transform до изменения кода.
  6. После изменения повторите измерение на том же входе. Затем отдельно проверьте transfer и критический пользовательский маршрут.
\n

Ограничения

\n

Учебная функция не знает, какие поля использует ваш bundler. Fingerprint не доказывает корректность, если его строит неполный скрипт. Одинаковый runtime не устраняет различия диска, CPU и виртуализации. Число запусков не исправляет несопоставимый entry.

\n

Рост output не равен росту времени выполнения в браузере. Source map и metafile описывают артефакт, но не гарантируют cache hit у пользователя. Compression, CDN, service worker и код до первого экрана требуют отдельных наблюдений. Не называйте локальную разницу production-результатом без измерения соответствующего пути.

\n

Отрицательный путь должен быть явным. Если входы различаются, функция возвращает «несопоставимо». Если output изменился, а причина не найдена, не откатывайте код по одной цифре. Если cache hit дал старый артефакт, исправьте ключ или invalidation. Не оставляйте постоянный force.

\n

Проверяемый критерий готовности

\n

Сравнение готово, если другой инженер может восстановить два запуска по commit, lockfile, команде и окружению, увидеть одинаковый fingerprint и получить те же поля отчёта. В отчёте видны cold/warm state, серия времени, chunks, input delta и ограничения метрики.

\n

Для кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/028.json b/editorial/agent-rewrites/028.json new file mode 100644 index 0000000..dab3532 --- /dev/null +++ b/editorial/agent-rewrites/028.json @@ -0,0 +1,7 @@ +{ + "index": 28, + "slug": "editorial-2027-03-field-d-lessons", + "title": "D и C ABI: как остановить ошибку на границе пакета", + "excerpt": "Если D и C по-разному понимают размер, layout или код возврата, ошибка проявляется далеко от FFI-вызова. Разбираем физический контракт пакета и проверяем его до передачи в C.", + "contentHtml": "

Сбой на границе D и C часто выглядит случайным. На одной архитектуре функция возвращает неверный идентификатор. На другой процесс падает при чтении поля. В тесте с коротким пакетом всё проходит, а реальный пакет ломает декодер. Цена ошибки высока: данные уже могли попасть в доменную логику, а причина остаётся в нескольких байтах, которые две стороны интерпретируют по-разному.

Тезис простой: FFI-вызов нельзя считать началом проверки. Сначала нужно подтвердить физический контракт пакета. Он включает размер, offsets, alignment, порядок байтов, набор обязательных полей, calling convention, ownership и код возврата. Только после этого пакет можно передавать в C. Имя структуры и успешная компиляция этого не доказывают.

Что именно ломается

ABI описывает представление типов и вызовов на машинной границе. В D и C совпадение названий полей не гарантирует совпадение layout. Между двумя полями может появиться padding. Указатель занимает разный размер на разных target-платформах. Директива packing меняет offsets. Сборка с другим compiler flag создаёт другой контракт, даже если исходный header не изменился.

Порядок байтов нужно проверять отдельно. Структура из памяти не является wire-форматом. Число 0x01020304 в little-endian и big-endian занимает те же четыре байта, но читается с разным значением. Если код копирует входной буфер в структуру без явного декодирования, ошибка будет похожа на неверный размер или повреждённый id.

Есть и семантическая часть. Поле payload может быть указателем, длиной или смещением внутри буфера. Ноль может означать пустой пакет, null или успешный результат. C-функция может частично заполнить output и вернуть ошибку. Поэтому проверка размера без проверки ownership и кода возврата создаёт ложное чувство безопасности.

\"Схема
Проверка отделяет физический контракт от вызова. Несовпадение возвращает пакет на границу и останавливает опасную операцию.
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Поле имеет неверное значение только на одной платформеРазный padding, alignment или размер указателяСравнить sizeof, offsets и alignment D/C на каждом targetЗафиксировать layout, выровнять типы или сериализовать поля явно
Число меняется после чтения буфераПерепутан порядок байтовПрогнать golden bytes с 0x01020304Декодировать wire-формат явно, не копировать структуру целиком
Редкий crash после успешного вызоваНеверная длина или истёкшее владение памятьюПроверить pointer, length, lifetime и правило освобожденияСузить wrapper, скопировать данные или вернуть ошибку до C
Ошибочный пакет выглядит успешнымOutput читается до проверки кода возвратаПроверить порядок обработки return code и outputСначала переводить ошибку, потом интерпретировать output
Тесты проходят, production-пакет не читаетсяТест использует другой header, target или версию протоколаСохранить hex-пакет, compiler flags и версию ABIДобавить контрактный тест для реального target matrix

Минимальный безопасный порядок

Начните с байтов, а не с вызова. Сохраните один пакет, который воспроизводит проблему, и его ожидаемую расшифровку. Укажите длину, архитектуру, endianness и версию контракта. Без этих данных «неверное поле» остаётся описанием симптома.

Затем составьте layout table. Для каждого поля запишите тип C, тип D, offset, размер, alignment, смысл, допустимый диапазон и владельца памяти. Если поле является указателем, рядом должна стоять длина и правило освобождения. Если их нельзя указать, wrapper не готов.

Только после этого сравните C header и D-объявление. Сверьте calling convention и compiler flags. Отдельно проверьте, не добавляет ли C-код packing или условную компиляцию. Сборка одного target не подтверждает остальные.

Учебный фрагмент wrapper

Ниже приведён учебный фрагмент. Он показывает порядок проверки пустого буфера и передачи длины. Имена функции, код ошибки и типы условны. Фрагмент не доказывает корректность конкретной библиотеки и не является production-результатом.

extern(C) int decode_packet(\\n    const(ubyte)* data,\\n    size_t length,\\n    uint* version,\\n);\\n\\n// Учебный пример: контракт библиотеки нужно подтвердить по её header.\\nint decode(scope const(ubyte)[] data, out uint version) @trusted\\n{\\n    if (data.length == 0)\\n        return -1;\\n\\n    return decode_packet(data.ptr, data.length, &version);\\n}

В примере wrapper не передаёт null вместе с ненулевой длиной. Но это только одна проверка. Реальный контракт может требовать null для пустого буфера, завершающий ноль, выравнивание адреса или отдельный allocator. Поэтому правило нельзя переносить на библиотеку без чтения её header и документации.

Атрибут @trusted не означает, что C-вызов проверен компилятором. Он означает, что автор wrapper берёт на себя доказательство инвариантов. Держите такую функцию короткой. Не смешивайте в ней разбор формата, бизнес-правила и освобождение памяти. Чем шире trusted-зона, тем труднее проверить её границу.

Проверка кода возврата

Обрабатывайте результат внешней функции в фиксированном порядке: сначала код возврата, затем размер и версию output, затем семантику полей. Не используйте частично заполненную структуру после ошибки. Если C API допускает частичный output, это должно быть явно записано в контракте и покрыто отдельным тестом.

Для числовых полей нужны golden bytes. Возьмите известное значение, закодируйте его в требуемом wire-формате и сравните результат на D-стороне. Такой тест показывает, где ошибка: в байтах, offsets или выборе типа. Для указателей добавьте нулевую длину, длину ровно до границы и длину на один байт больше.

Действия по порядку

  1. Зафиксировать C header, D-объявление, compiler flags, target architecture, packing directives и calling convention.
  2. Составить таблицу layout: размер, offset, alignment, тип, значение, длина и владелец каждого поля.
  3. Сохранить golden bytes для корректного пакета, неверного endianness, обрезанной длины и неизвестной версии.
  4. Вынести FFI в маленький wrapper и поставить проверки pointer, length, lifetime и кода возврата до передачи данных доменному коду.
  5. Проверить валидный и отрицательный пути на каждой поддерживаемой архитектуре, включая границы 0, capacity и capacity+1.
  6. Сохранить в отчёте hex-пакет, размер, target, версию ABI и точную ошибку. Это связывает симптом с физическим контрактом.

Когда проверка должна остановить вызов

Остановите вызов, если размер не совпал, обязательное поле отсутствует, версия неизвестна, pointer не согласован с length или правило ownership не имеет ответа. Не пытайтесь «продолжить с тем, что удалось прочитать». На FFI-границе частичный успех часто превращается в повреждённое состояние выше по стеку.

Если layout зависит от платформы, есть два пути. Можно описать отдельные контракты и тестировать каждый target. Можно отказаться от передачи структуры и использовать явную сериализацию полей в буфер. Второй путь иногда медленнее, но уменьшает зависимость от padding и размера указателя. Выбирайте его, когда переносимость важнее нулевой копии.

Ограничения

Учебный wrapper не моделирует все правила D, C и конкретной библиотеки. Он не проверяет compiler lowering, alignment адреса, aliasing, null termination, thread safety, освобождение памяти и совместимость версий. Таблица layout не заменяет сборку маленького C helper и тест на целевом ABI. Документация языка объясняет общие правила, но не подтверждает vendor header.

Не называйте проверку успешной только потому, что код компилируется и один тест возвращает ожидаемое поле. Готовность требует повторяемого отрицательного пути. Ошибочный размер должен остановить вызов. Ошибочный порядок байтов должен быть виден в golden test. Ошибка C не должна превращаться в валидный доменный объект.

Критерий готовности

Граница готова, если для каждого target есть зафиксированные размер и layout, есть golden bytes и тесты на отрицательные случаи, wrapper проверяет pointer, length, lifetime и код возврата, а неизвестная версия или несовпадение контракта останавливает вызов. Проверка должна оставлять диагностический пакет: hex, target, версию ABI и причину отказа. Тогда следующая ошибка возвращается к конкретному байту, а не к предположению о языке.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/029.json b/editorial/agent-rewrites/029.json new file mode 100644 index 0000000..65b2e52 --- /dev/null +++ b/editorial/agent-rewrites/029.json @@ -0,0 +1,7 @@ +{ + "index": 29, + "slug": "editorial-2027-03-mechanism-d-lessons", + "title": "D и C API: как закрыть небезопасную границу буфера", + "excerpt": "Как проверить pointer, length и lifetime до вызова C, изолировать @trusted и не принять атрибут @safe за доказательство всей системы.", + "contentHtml": "

C-функция получает указатель и длину буфера. Указатель не несёт длину сам. Если длина пришла из заголовка пакета, а буфер содержит меньше байт, C прочитает за его пределами. Симптомы плавают: редкое падение, испорченный результат, ошибка только на одной нагрузке или незаметное повреждение памяти. Цена ошибки — потеря данных, аварийное завершение процесса и уязвимость, которую трудно связать с исходным запросом.

\n

Тезис: безопасная граница D/C строится не атрибутом на имени функции. Нужны проверенный диапазон, ясный владелец памяти, известное время жизни и маленький участок, где компилятор не может проверить внешний контракт. В D этот участок обычно помечают @trusted. Наружу он должен отдавать интерфейс, который можно вызывать из @safe кода.

\n

Как возникает ошибка

\n

Рассмотрим условный C API. Он принимает адрес, число байт и возвращает код. C доверяет вызывающему. Он не знает capacity исходного массива и не может проверить, что length соответствует выделенной памяти. D тоже не восстановит этот факт из одного raw pointer.

\n
extern(C) int decode_packet(const(ubyte)* data, size_t length);\n\n// Учебный пример: здесь нет реальной библиотеки и production-данных.\nint call_decoder(const(ubyte)[] input) {\n    return decode_packet(input.ptr, input.length);\n}
\n

Сам вызов выглядит убедительно, но контракт неполон. Нужно знать, читает ли функция ровно length байт, ожидает ли завершающий ноль, сохраняет ли указатель после возврата и кто освобождает возвращённую память. Если C сохраняет адрес, передача временного массива становится ошибкой lifetime. Если формат требует заголовок фиксированного размера, проверка только верхней границы не подтверждает корректность пакета.

\n

Что именно обещают атрибуты

\n

@safe ограничивает набор операций, которые могут привести к повреждению памяти. Это обещание относится к проверяемому D-коду и его интерфейсу. Оно не проверяет реализацию неизвестной C-библиотеки, её ABI, размер структуры или смысл поля.

\n

@system разрешает низкоуровневые операции. Такой код может выполнять арифметику указателей и другие действия, которые требуют ручного доказательства. @trusted сохраняет эти возможности внутри тела, но разрешает вызов из безопасного кода. Поэтому @trusted — не знак «компилятор проверил». Это ручное обещание автора. Чем больше тело trusted-функции, тем больше непроверенных предположений в одном месте.

\n

Узкий wrapper должен принимать сильное представление входа. Slice D связывает адрес и длину. Но slice не знает, соблюдает ли внешний API null termination, не освобождает ли C память во время вызова и не сохраняет ли адрес. Эти условия остаются частью контракта библиотеки.

\n
\"Матрица
Безопасный путь начинается после проверки длины и времени жизни. Цвет атрибута не заменяет проверку внешнего контракта.
\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Падение на длинном пакетеЗаявленная длина больше capacityСравнить длину с размером slice до вызоваОстановить вызов и вернуть ошибку формата
Успешный вызов, затем сбойC сохранила указатель на временный буферПрочитать ownership и проверить escape адресаЗапретить сохранение или передать копию
Ошибка только на C-строкеНет null terminatorПроверить завершающий байт и длину строкиДобавить terminator либо вызвать byte API
Работает в одном targetНе совпали ABI, alignment или layoutСверить header, calling convention и размеры типовЗафиксировать ABI-тест для каждого target
Функция помечена @trusted, но меняет всёВ wrapper спрятали парсинг и бизнес-логикуПосчитать обязанности и raw операции в телеОставить только boundary check и вызов C
\n

Минимальная проверка перед переходом в C

\n

Проверка должна отвечать на разные вопросы отдельно. Сначала адрес принадлежит живому объекту. Затем длина целая, неотрицательная и не выходит за capacity. Потом проверяется формат: минимальный размер, terminator, допустимый enum или версия. Только после этого вызывается C-функция. Один boolean с именем isValid скрывает слишком много условий и плохо объясняет отказ.

\n

Ниже учебный checker. Он не анализирует D-память и не вызывает библиотеку. Он показывает отрицательный путь: неизвестный владелец и длина за пределами capacity не превращаются в safe-interface. Числа нужны только для иллюстрации правил.

\n
struct BoundaryResult {\n    string kind;\n    string action;\n}\n\nBoundaryResult checkBoundary(size_t capacity, size_t length, bool pointerChecked) {\n    if (!pointerChecked) {\n        return BoundaryResult(`system`, `проверить указатель и владельца`);\n    }\n    if (length > capacity) {\n        return BoundaryResult(`reject`, `остановить вызов до C`);\n    }\n    return BoundaryResult(`safe-interface`, `передать проверенный slice`);\n}\n\n// Учебные входы: 16/8 -> safe-interface; 16/24 -> reject.\n// 16/8 без проверки указателя -> system.
\n

Реальный wrapper должен учитывать переполнение при вычислении диапазона, нулевую длину, alignment, null pointer, правила потока и возвращаемый код ошибки. Если функция принимает offset + length, нельзя сначала сложить значения без проверки переполнения. Сравнение через length <= capacity - offset безопаснее, если сначала доказано, что offset <= capacity.

\n

Ownership и lifetime нельзя угадывать

\n

Параметр scope помогает выразить, что функция не должна сохранять ссылку на переданный объект. Но атрибут не переписывает документацию C. Если библиотека кладёт адрес в глобальное состояние или использует его в другом потоке, wrapper должен запретить такой сценарий или передать отдельную копию с явным владельцем.

\n

Возвращаемый raw pointer создаёт обратную задачу. До преобразования в D slice нужно знать размер объекта и способ освобождения. Если размер неизвестен, безопасного представления нет. Если C требует специальную функцию освобождения, вызов free из D неверен. Копирование в D-буфер часто увеличивает стоимость, но даёт ясный lifetime. Это инженерный trade-off, а не деталь синтаксиса.

\n

Не объединяйте в @trusted чтение файла, разбор формата, бизнес-правила и FFI. Тогда тест на один указатель не покрывает остальные решения. Пусть trusted-тело делает одну вещь: проверяет инвариант, формирует вызов и возвращает результат с понятным ownership.

\n

Действия по порядку

\n
  1. Выписать прототип C-функции и смысл каждого указателя, длины, возвращаемого адреса и кода ошибки.
  2. Зафиксировать ownership: кто создаёт, кто читает, кто сохраняет и кто освобождает каждый буфер.
  3. Проверить ABI: размер и layout структур, alignment, порядок байтов, calling convention и target-платформы.
  4. Сформировать минимальный D wrapper со slice или копией и вынести raw операции в короткое @trusted-тело.
  5. Добавить тесты на пустой вход, длину 0, точную capacity, capacity+1, null, overflow, неверный terminator и повторный вызов.
  6. Прогнать компиляцию и runtime-проверки тем же компилятором, ABI и target, которые использует продукт; отдельно проверить sanitizer или эквивалентный инструмент.
  7. Оставить публичную функцию @safe только после доказательства интерфейса. Неясную или непроверяемую ветку пометить @system и запретить случайный вызов.
\n

Ограничения и отрицательный путь

\n

Memory safety не означает переносимость, корректный порядок байтов, отсутствие логической ошибки или правильный ABI. @safe не делает C-библиотеку безопасной. @trusted не создаёт доказательство автоматически. Даже верная проверка capacity не замечает неверный enum, неправильную версию структуры или гонку за буфер.

\n

Если C API сохраняет входной адрес, простой вызов с borrowed slice нельзя считать готовым. Если невозможно установить размер возвращённого объекта, нужно копирование, дополнительный API или отказ от интеграции. Если target изменяет layout, один зелёный тест на локальной машине ничего не доказывает. Если неясно, кто освобождает память, не передавайте владение через границу.

\n

Код checker выше не даёт production-результата. Он не видит aliasing, реальный lifetime, alignment и calling convention. Его можно использовать только как учебную форму таблицы решений. Доказательство создают контракт библиотеки, тесты на реальном ABI и наблюдаемое поведение сборки продукта.

\n

Проверяемый критерий готовности

\n

Граница готова, если другой инженер может по документации и коду ответить на пять вопросов: какой диапазон читается, кто владеет памятью, может ли адрес пережить вызов, какой ABI используется и как сообщается ошибка. Для каждого вопроса есть тест или явное стоп-условие. Невалидная длина не достигает C-вызова. Возвращаемый буфер освобождается тем способом, который требует библиотека.

\n

Дополнительная проверка должна проходить на всех target-платформах продукта. Успешный тест недостаточен: нужен тест, который намеренно нарушает длину, lifetime и формат и получает контролируемый отказ. Только после этого @safe на внешней функции описывает проверенный интерфейс, а не надежду на реализацию C.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/030.json b/editorial/agent-rewrites/030.json new file mode 100644 index 0000000..181a170 --- /dev/null +++ b/editorial/agent-rewrites/030.json @@ -0,0 +1,7 @@ +{ + "index": 30, + "slug": "editorial-2027-03-practice-d-lessons", + "title": "D для прикладной утилиты: как проверить, нужен ли новый язык", + "excerpt": "Перед переходом на D измерьте workload, найдите границу с native-кодом и сравните стоимость toolchain с реальным выигрышем. Учебный фильтр и критерий готовности помогают принять решение, включая отказ от миграции.", + "contentHtml": "

Утилита запускается медленно, занимает больше памяти, чем ожидалось, или требует вызова C-библиотеки. Команда сразу предлагает переписать её на D: язык компилируется в native binary, умеет работать с C ABI и даёт контроль над памятью. Но симптом ещё не показывает причину. Задержку может создавать сеть, формат файла, лишние копии или неверная граница API. Цена ошибочного выбора — новый компилятор, сборочный pipeline, обучение и месяцы поддержки без исправления узкого места.

\n

Тезис простой: D стоит проверять не по списку свойств языка, а по контракту задачи. Сначала зафиксируйте workload и бюджет, затем найдите участок, который действительно изменится при смене языка. После этого сравните D с текущим инструментом по измеримому эффекту и полной стоимости доставки. Если доказательства не складываются, решение остаться на текущем языке будет корректным результатом.

\n

Сначала отделите симптом от причины

\n

Фраза «нужна производительность» не задаёт задачи. Для CLI важны время запуска, время обработки одного входа, пиковая память и размер бинарника. Для фонового процесса важны throughput, steady-state latency и поведение после нескольких часов работы. Для сервиса добавляются конкуренция, timeout и наблюдаемость. Для вызова C-библиотеки важны layout структуры, calling convention, ownership указателей и код ошибки.

\n

Запишите один сценарий, а не среднее впечатление. Например: «утилита читает 2 ГБ логов, должна обработать файл менее чем за 20 секунд, запускается на Linux x86_64 и arm64, а парсер отдаёт данные в C-библиотеку». В таком описании уже видны единица нагрузки, предел времени, targets и native boundary. Без них benchmark легко превращается в сравнение несопоставимых программ.

\n
\"Карта
Выбор начинается с workload и ограничений. D появляется как проверяемый вариант только после описания границы задачи.
\n

Механизм решения

\n

У решения есть четыре связанные части. Workload показывает, сколько данных и операций проходит через код. Бюджет latency задаёт допустимую цену одной операции. Native boundary показывает, нужен ли прямой доступ к C, системному вызову или нативному формату. Target matrix показывает, сколько раз придётся собрать, протестировать и доставить бинарник.

\n

D может быть сильным кандидатом, когда горячий участок вычисляет данные локально, нужен native deployment или уже есть C ABI. Но это только основание для эксперимента. У D остаются стоимость компилятора и зависимостей, различия runtime, диагностика бинарника, упаковка под несколько архитектур и время команды. Нативный бинарник не отменяет сетевую задержку и не делает внешний API безопасным.

\n

Контрактные проверки полезны внутри функции. Precondition проверяет входной инвариант, postcondition — свойство результата. Они не заменяют проверку пользовательского файла, обработку ошибки и тесты. Проверка должна принадлежать тому уровню, который владеет условием: parser проверяет формат, доменный код — смысл, wrapper — указатель, длину и время жизни.

\n

Матрица перед сменой языка

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Долгий запускИмпорт модулей, чтение конфигурации, сетьПрофиль cold start с отключённой сетьюИсправить инициализацию; язык менять только при доказанном CPU-узком месте
Медленная обработка файлаКопии строк, декодирование, неверный алгоритмПрофиль CPU и аллокаций на одном входеСравнить алгоритм и парный прототип D
Рост RSSДолгоживущие ссылки, кэш, фрагментацияСнять профиль памяти по этапам batchУкоротить lifetime; не отключать GC по одному графику
Падение в C-вызовеНеверная длина, layout или ownershipСверить header, размер, offset и код возвратаИзолировать wrapper и остановить вызов при несовпадении
Сложная доставкаНесколько архитектур и ручная упаковкаСобрать чистые артефакты для каждого targetСравнить цену toolchain с выигрышем runtime
\n

Учебный фильтр требований

\n

Следующая функция не измеряет скорость и не выбирает язык автоматически. Она превращает карточку задачи в явные условия. Числа учебные: throughput 12 000 и бюджет 20 мс нельзя переносить на другое железо. В реальном решении их заменяют измерениями одного workload.

\n
function decideD(input) {\n  const throughput = Number(input.throughput);\n  const latencyBudgetMs = Number(input.latencyBudgetMs);\n  const targets = Number(input.deploymentTargets);\n  if (!Number.isFinite(throughput) || throughput <= 0) return { decision: 'reject', reason: 'нет измеримой нагрузки' };\n  if (latencyBudgetMs <= 0) return { decision: 'reject', reason: 'нет бюджета задержки' };\n  if (targets > 2 && !input.nativeBoundary) return { decision: 'compare', reason: 'сначала сравнить toolchain' };\n  if (input.nativeBoundary && throughput > 10000) return { decision: 'prototype-d', reason: 'есть основание для парного прототипа' };\n  return { decision: 'keep-current-tool', reason: 'смена языка не обоснована' };\n}\n\nconsole.log(decideD({ throughput: 12000, latencyBudgetMs: 20, nativeBoundary: true, deploymentTargets: 1 }));\n// Учебный результат: { decision: 'prototype-d', ... }
\n

Положительная ветка означает только «собрать прототип». Она не означает «переписать продукт». Ветка keep-current-tool нужна намеренно: если нагрузка мала, границы с native-кодом нет, а текущий стек уже покрывает доставку, новый язык увеличит риск без доказанной пользы. Ветка compare останавливает преждевременный выбор при широкой матрице targets.

\n

Как проверять нативную границу

\n

Указатель и длина образуют один контракт. Сам указатель не сообщает, сколько байт можно читать. Wrapper должен получить буфер, проверить его владельца и диапазон, а затем передать в C только проверенный slice или пару pointer/length. Если библиотека сохраняет адрес после возврата, обычного временного буфера недостаточно: нужен согласованный lifetime или копия.

\n

Структуру тоже нельзя считать совместимой по имени полей. Сверьте размер, offsets, alignment, порядок байтов и calling convention. Отдельно зафиксируйте значения кода ошибки. Частично заполненный output не равен успешному результату. Сначала проверьте код возврата, затем версию и layout, потом отдайте значение доменному коду.

\n

Действия по порядку

\n
  1. Записать единицу нагрузки, размер входа, бюджет задержки, пик памяти, targets и ожидаемый результат.
  2. Повторить симптом на одном входе и снять профиль CPU, аллокаций, памяти или cold start. Не менять язык до появления измеримого узкого места.
  3. Сравнить текущий инструмент и D по алгоритму, библиотекам, сборке, отладке, размеру артефакта и времени поддержки.
  4. Описать native boundary: типы, размер, ownership, lifetime, порядок байтов, код ошибки и вариант отказа.
  5. Собрать маленький парный прототип с одинаковым входом, выходом и методикой замера. Проверить положительный и отрицательный путь.
  6. Принять решение по заранее заданному критерию. Сохранить измерения и стоимость поддержки рядом с кодом прототипа.
\n

Ограничения

\n

Фильтр не заменяет profiler, benchmark и review ABI. Его пороги вымышлены и нужны только для формы проверки. Даже хороший benchmark не переносит результат на другую архитектуру, версию компилятора, размер данных или режим нагрузки. Нативная сборка не гарантирует меньшую память. Контракт не исправляет неверную бизнес-логику.

\n

Оценка должна учитывать отрицательный путь. Если wrapper не может доказать длину, lifetime или layout, вызов нужно остановить. Если D выигрывает только в искусственном микротесте, но требует отдельной упаковки и дежурства, выигрыш не доказан. Если текущий язык после устранения лишних копий укладывается в бюджет, миграция не нужна.

\n

Проверяемый критерий готовности

\n

Решение готово, когда для одного и того же workload есть повторяемые замеры текущего инструмента и прототипа D, описаны targets и native boundary, а также измерена цена сборки и поддержки. Для каждого результата указаны вход, версия toolchain, архитектура, число повторов и критерий успеха. Вызов C проходит только после проверки размера, lifetime и кода ошибки. Команда может объяснить не только почему D быстрее, но и почему это преимущество покрывает стоимость доставки.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/031.json b/editorial/agent-rewrites/031.json new file mode 100644 index 0000000..62fdff7 --- /dev/null +++ b/editorial/agent-rewrites/031.json @@ -0,0 +1 @@ +{"index":31,"slug":"editorial-2027-02-field-bitrix-lessons","title":"Миграция пользовательского поля Bitrix: сохранить смысл, а не только значение","excerpt":"Как перенести поле пользователя из legacy API Bitrix в новый контракт без тихой потери данных: mapping, пустые значения, read-back, повторный запуск и критерий готовности.","contentHtml":"

Симптом появляется после успешного вызова Bitrix API. Пользователь получил новый ID, но телефон остался пустым. Email сохранился в другом регистре. Повторный запуск создал вторую связь с внешней системой. В логах есть только true от CUser::Update, поэтому команда не видит, на каком шаге исчезло значение.

Цена ошибки выше, чем одна неверная строка. По телефону не находится пользователь. Уведомление уходит на старый адрес. Импорт нельзя безопасно повторить. Если исходное значение уже перезаписано, восстановление зависит от резервной копии или ручного поиска.

Тезис: миграция поля готова не тогда, когда API принял запрос. Она готова, когда команда может показать mapping, прочитать сохранённый смысл обратно и повторить тот же вход без дубля или неожиданной очистки.

Механизм: поле проходит четыре границы

Legacy-код обычно передаёт массив с именами Bitrix: PERSONAL_PHONE, EMAIL, XML_ID. Новый код хочет получить объект вроде { phone, email, externalId }. Это не простая замена имён. Каждое поле имеет формат, правило пустого значения и обратное представление.

Первая граница — вход. Отсутствующий PERSONAL_PHONE может означать «не менять телефон», а пустая строка — «очистить телефон». Если привести оба состояния к null, адаптер потеряет команду пользователя.

Вторая граница — нормализация. Для телефона допустимы пробелы и разные формы записи, но правило должно быть конкретным. Для email можно привести регистр к нижнему, если это разрешает контракт приложения. Нельзя применять одну функцию ко всем значениям: XML_ID, комментарий и парольный хэш имеют разные правила.

Третья граница — запись. Официальный метод CUser::Update принимает ID и массив полей. Успех означает, что метод не сообщил об ошибке. Он не доказывает, что downstream-обработчик, индекс или внешний обмен увидели ожидаемый смысл.

Четвёртая граница — чтение. После записи нужно получить ту же запись способом, которым её читает приложение. Сравнивайте не только ID и флаг успеха. Сравнивайте поля, внешний идентификатор, состояние пустоты и, если это важно, время изменения.

\"Цикл
Успешная запись занимает середину цикла. Доказательство результата появляется после повторного чтения и проверки повторяемости.

Mapping должен описывать смысл

Начните с небольшой таблицы. В ней видны не только старое и новое имя, но также пустое состояние, источник и проверка результата.

ПолеLegacy-входКаноническое значениеПроверка
ТелефонPERSONAL_PHONE, строка с пробеламиphone, нормализованная строкаread-back и формат
EmailEMAIL, исходный регистрemail, регистр по правилу контрактавалидность и точное чтение
СвязьXML_IDexternalIdодна запись при retry
Не переданключ отсутствуетunchangedстарое значение не меняется
Очищенключ есть, значение пустоеclearдва разных теста

Если пришёл неизвестный ключ, не угадывайте его назначение по похожему имени. Остановите преобразование и сообщите, какое поле не входит в контракт. Если email не проходит правило приложения, не превращайте его в пустую строку. Верните ошибку до записи.

Учебный пример: нормализация до вызова Bitrix

Следующая функция — изолированный учебный пример. Она не подключается к Bitrix и не обещает результат реального окружения. Её задача — показать, где сохраняется различие между отсутствующим полем и явной очисткой.

function toCanonical(input) {\n  const result = {};\n\n  if (Object.hasOwn(input, 'PERSONAL_PHONE')) {\n    if (input.PERSONAL_PHONE === '') {\n      result.phone = { action: 'clear' };\n    } else {\n      const phone = String(input.PERSONAL_PHONE).trim();\n      if (!phone) throw new Error('phone-invalid');\n      result.phone = { action: 'set', value: phone };\n    }\n  }\n\n  if (Object.hasOwn(input, 'EMAIL')) {\n    const email = String(input.EMAIL).trim().toLowerCase();\n    if (!email.includes('@')) throw new Error('email-invalid');\n    result.email = { action: 'set', value: email };\n  }\n\n  if (Object.hasOwn(input, 'XML_ID')) {\n    result.externalId = { action: 'set', value: String(input.XML_ID) };\n  }\n\n  return result;\n}\n\nconst value = toCanonical({\n  PERSONAL_PHONE: ' +7 900 000-00-00 ',\n  EMAIL: 'User@Example.TEST',\n  XML_ID: 'crm-17',\n});\n\n// phone: set '+7 900 000-00-00'\n// email: set 'user@example.test'\n// externalId: set 'crm-17'

Для учебного входа функция удаляет внешние пробелы, приводит email к нижнему регистру и оставляет связь как строку. Это выбранные правила примера, а не универсальная политика Bitrix. В реальном проекте их нужно заменить правилами доменного контракта.

Адаптер записи строится после такой проверки. Для unchanged поле не добавляется. Для clear передаётся явное значение очистки, согласованное с API и проектом.

$fields = [];\n\nif ($canonical['phone']['action'] === 'set') {\n    $fields['PERSONAL_PHONE'] = $canonical['phone']['value'];\n}\n\nif ($canonical['phone']['action'] === 'clear') {\n    $fields['PERSONAL_PHONE'] = '';\n}\n\nif ($canonical['email']['action'] === 'set') {\n    $fields['EMAIL'] = $canonical['email']['value'];\n}\n\n$user = new CUser;\nif (!$user->Update($userId, $fields)) {\n    throw new RuntimeException($user->LAST_ERROR);\n}

Код показывает только форму вызова. Он не проверяет права, события, пользовательские поля и транзакцию. Перед использованием нужно подтвердить, что модуль и нужная поверхность API доступны в конкретной установке. Для D7 и legacy-пути нельзя считать классы взаимозаменяемыми без отдельного mapping.

Симптомы и проверки

СимптомПричинаПроверкаДействие
API вернул успех, поле пустоеНеверное имя, формат или обработчик изменил значениеСравнить mapping, raw-вход и read-backИсправить границу и повторить на одной записи
Повторный запуск создаёт дубльНет стабильного внешнего ключа или retry неидемпотентенПовторить вход по XML_ID и проверить количество записейЗакрепить ключ операции и запретить создание без него
Поле исчезает при частичном обновленииMissing и clear сведены к одному значениюПроверить отсутствие ключа и пустую строку отдельноПередавать очистку только явной командой
Один сервер принимает вызов, другой — нетМодуль не подключён или поверхности API различаютсяПроверить IncludeModule, класс и метод в целевой средеОстановить миграцию и выбрать совместимый adapter
Email стал недействительнымНормализация скрыла ошибку или правило шире контрактаПроверить валидатор до записи и значение после чтенияВернуть ошибку, не записывать пустой заменитель

Порядок действий

  1. Выберите одно поле и один стабильный идентификатор записи. Не начинайте с массового запуска.
  2. Снимите фактический вход: ключи, типы, пустые значения, внешний ID и источник данных.
  3. Составьте mapping table. Отдельно назовите состояния missing, clear, invalid и unchanged.
  4. Проверьте доступность модуля и метода в целевой версии Bitrix. Если условие не выполнено, остановите изменение.
  5. Прогоните нормализацию на обезличенных значениях. Проверьте пробелы, регистр, пустоту, неверный формат и неизвестный ключ.
  6. Запишите одну запись через адаптер. Сохраните безопасный идентификатор операции, не помещая персональные данные в лог.
  7. Сделайте read-back: сравните поля, пустые состояния, внешний ID и побочные признаки, важные для приложения.
  8. Повторите тот же вход. Убедитесь, что запись не дублируется, значение не меняется без правила, а операция остаётся обратимой.
  9. Только после этого расширяйте выборку. Любое расхождение сначала классифицируйте как mapping, формат, права, событие или версию API.

Ограничения

Документация Bitrix описывает публичный API, но не знает локальные события, права, пользовательские поля, обработчики и SQL-ограничения проекта. Успешный вызов в одной установке не доказывает совместимость другой. Версия ядра помогает сузить поиск, но не заменяет проверку фактической поверхности.

Read-back может отличаться от входа по допустимому правилу сервера. Телефон может получить другой формат. Время изменения зависит от среды. Такие расхождения нужно разделить на ожидаемую нормализацию и потерю смысла. Нельзя объявлять их одинаковыми только потому, что совпал ID.

Учебный JavaScript-пример не является production-валидатором. Проверка email.includes('@') намеренно упрощена. PHP-функция filter_var может быть частью технической проверки email, но она не определяет бизнес-правила, разрешённые домены и требуемое поведение пустого поля.

Если read-back невозможен, миграция не получает доказательство сохранения. В этом случае не расширяйте объём. Сначала добавьте безопасный способ проверить запись или оставьте адаптер за границей массового запуска. Отрицательное решение лучше тихой потери данных.

Проверяемый критерий готовности

Одна миграция поля готова, если другой инженер может восстановить вход и получить тот же результат: mapping объясняет каждое переданное поле, missing и clear различаются, API-поверхность подтверждена, read-back совпадает по смысловым значениям, а повторный запуск не создаёт дубль.

Для отрицательных случаев есть отдельные доказательства: неизвестное поле останавливается, неверный email не превращается в пустую строку, отсутствие поля не очищает старое значение, недоступный модуль не приводит к частичному запуску. Только после этих проверок можно говорить о расширении на следующую выборку.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/032.json b/editorial/agent-rewrites/032.json new file mode 100644 index 0000000..9eb72c0 --- /dev/null +++ b/editorial/agent-rewrites/032.json @@ -0,0 +1,7 @@ +{ + "index": 32, + "slug": "editorial-2027-02-mechanism-bitrix-lessons", + "title": "Bitrix API и версия: имя метода не обещает одинаковый контракт", + "excerpt": "Как проверить установленную поверхность Bitrix API, сопоставить поля legacy и D7 и остановить миграцию, если среда не подтверждает совместимость.", + "contentHtml": "

Ошибка начинается без падения. В документации найден метод, класс подключён, вызов возвращает ID или объект. Но на другой установке модуль не загружен, поле называется иначе, пустая строка означает другое состояние, а обработчик события меняет результат. Симптом появляется позже: форма теряет значение, импорт создаёт дубль, редкая операция падает после обновления. Цена ошибки — не только исправление PHP. Команда получает повреждённые данные, повторную загрузку и миграцию, которую уже нельзя безопасно повторить.

\n

Тезис: совместимость Bitrix проверяют не по имени класса и не по номеру версии. Нужна граница из трёх фактов: модуль подключён, нужная поверхность API доступна, а вход и выход совпадают с контрактом проекта. Только после этого выбирают legacy-вызов, D7 или адаптер между ними.

\n

Механизм ошибки

\n

У старого и нового API может быть одна предметная область, но разные правила. Документация Bitrix указывает CUser и Bitrix\\Main\\UserTable как поверхности работы с пользователями. Это не утверждение, что вызовы взаимозаменяемы в конкретном проекте. У них могут различаться способ выборки, типы полей, ошибки, события и требования к версии ядра.

\n

Сначала проверяют загрузчик. CModule::IncludeModule('iblock') или \\Bitrix\\Main\\Loader::includeModule('iblock') отвечает на вопрос «модуль установлен и подключён?». Ответ true ещё не подтверждает нужный метод и mapping полей. Ответ false закрывает путь к следующему слою: нельзя маскировать отсутствие модуля вызовом класса, который случайно доступен через другой bootstrap.

\n

Затем проверяют поверхность. Нужны точные операции: найти запись, создать, обновить, получить идентификатор и разобрать ошибку. Проверка только существования класса слишком слаба. Она не отвечает, принимает ли метод нужные поля и сохранит ли различие между отсутствующим полем и явной очисткой.

\n

Последний слой — смысл результата. Успешный ID доказывает, что операция вернула идентификатор. Он не доказывает, что значение записалось в нужный формат, обработчики отработали ожидаемо, а публичная выборка увидит запись. Поэтому контракт нужно проверять через повторное чтение и отрицательные случаи.

\n
Матрица проверки Bitrix API: модуль, поверхность, поля и семантика результата
Имя метода — только первый слой. Надёжная граница проходит через подключённый модуль, доступную операцию, mapping полей и проверенный смысл результата.
\n

Учебный пример: один контракт для двух поверхностей

\n

Ниже — учебная функция. Она не обращается к реальному серверу и не обещает поддержку перечисленных версий. Входной manifest нужно получить в конкретной среде безопасной диагностикой. Функция только разделяет отсутствие модуля, legacy-поверхность и D7-поверхность.

\n
function chooseUserSurface(manifest) {\n    if (!manifest.moduleLoaded) {\n        return { kind: 'stop', reason: 'module-not-loaded' };\n    }\n\n    if (manifest.methods.includes('CUser::Update')) {\n        return { kind: 'legacy', operation: 'update-user' };\n    }\n\n    if (manifest.methods.includes('Bitrix\\\\Main\\\\UserTable')) {\n        return { kind: 'd7', operation: 'update-user' };\n    }\n\n    return { kind: 'stop', reason: 'operation-not-confirmed' };\n}\n\n// Учебные данные. Это не результат работы production-установки.\nconst surface = chooseUserSurface({\n    moduleLoaded: true,\n    methods: ['CUser::Update'],\n});\n\nconsole.log(surface);\n// { kind: 'legacy', operation: 'update-user' }
\n

Важен порядок условий. Сначала функция останавливается при отсутствии модуля. Затем она выбирает подтверждённую операцию, а не любой похожий класс. Если обе поверхности доступны, выбор должен задавать адаптер проекта: например, установленная версия, зафиксированный набор полей и проверенная матрица регрессии. Автоматически предпочитать D7 только потому, что он новее, нельзя.

\n

Адаптер должен принимать канонический вход. Для пользователя это может быть объект с id, email, phone и явными состояниями missing и clear. Внутри адаптера поля переводятся в формат выбранного API. Так legacy-детали не расползаются по формам, импорту и обработчикам.

\n
function toLegacyFields(user) {\n    const fields = {};\n\n    if (user.email.state === 'value') {\n        fields.EMAIL = user.email.value;\n    } else if (user.email.state === 'clear') {\n        fields.EMAIL = '';\n    }\n\n    if (user.phone.state === 'value') {\n        fields.PERSONAL_PHONE = user.phone.value.trim();\n    }\n\n    return fields;\n}
\n

Этот код показывает только mapping. Он не вызывает CUser::Update, не проверяет права и не описывает локальные события. В рабочем проекте перед вызовом нужно зафиксировать, что означает пустое поле, кто владеет нормализацией, какие ошибки возвращает API и что должен увидеть read-back. Если D7-модель хранит поле в другом представлении, адаптер должен преобразовать его обратно в тот же канонический результат.

\n

Симптом → причина → проверка → действие

\n
Диагностика границы Bitrix API
СимптомПричинаПроверкаДействие
Класс найден, вызов падает на части серверовМодуль не установлен или не подключён в этом bootstrapПроверить IncludeModule в том же окружении и записать результатОстановить операцию с диагностикой либо подключить модуль явно
Одинаковое имя поля даёт разный результатРазличаются тип, формат или пользовательское полеСравнить mapping, тип значения, missing и clearОставить преобразование в адаптере и добавить read-back
Update вернул успех, но данные не видныПроверяется только код ответа; фильтр или событие меняет выборкуПрочитать запись по ID и выполнить контрольный запрос с условиями каталогаРазделить факт записи и публичную видимость
После обновления появился неизвестный методДокументация описывает другую версию или другую поверхностьСверить версию ядра, модуль и фактический manifest операцийВернуть адаптер к подтверждённой операции или ограничить поддержку
Повторный импорт создаёт новые записиВ контракте нет стабильного ключа и идемпотентного поискаПовторить тот же вход и сравнить внешний ключ и результат чтенияНайти существующую запись по согласованному ключу до создания
Миграция проходит на тесте, но меняет production-смыслТест проверяет ID, но не события, права и пустые состоянияДобавить characterization-тесты для успеха, ошибки, retry и очисткиНе заменять поверхность до закрытия отрицательного пути
\n

Порядок действий

\n
  1. Записать предметную операцию: что создаём, ищем или обновляем, какой результат считаем успехом и какие данные нельзя изменить.
  2. Зафиксировать версию ядра, идентификатор модуля, bootstrap и официальный источник документации для выбранного вызова.
  3. Собрать в целевой среде минимальный manifest: модуль подключён, класс или таблица доступны, нужные операции найдены.
  4. Составить таблицу mapping для каждого поля. Отдельно описать значение, отсутствие, явную очистку, нормализацию и внешний ключ.
  5. Проверить legacy и D7 на одном наборе учебных входов. Сравнить не только ID, но и read-back, коды ошибок и побочные события.
  6. Спрятать выбранную поверхность за узким адаптером. Наружу вернуть канонический результат и классифицированную ошибку.
  7. Проверить повторный запуск и отрицательный путь: отсутствующий модуль, неизвестная операция, плохое поле, отказ прав и частичный результат.
  8. Если хотя бы один обязательный слой не подтверждён, остановить миграцию и оставить текущий вызов. Сначала закрыть неизвестность, затем менять API.
\n

Ограничения

\n

Документация Bitrix описывает публичную поверхность, но не знает локальные обработчики, права, переопределения в /local, структуру инфоблока и фактический bootstrap. Две установки с одним номером версии могут иметь разные модули и данные. Поэтому ссылка на страницу API не является доказательством совместимости проекта.

\n

Manifest тоже не равен полному тесту. Он подтверждает доступность слоя, но не доказывает корректность SQL, событий и бизнес-правил. Не следует печатать в диагностике пользовательские данные. Достаточно версии, имени модуля и названий операций. Секреты и значения полей в такой вывод не входят.

\n

Иногда правильное решение — не мигрировать. Если legacy-вызов покрыт тестами, выполняет нужную операцию и новый API не даёт проверяемого выигрыша, адаптер может сохранить старую поверхность. Если новый метод доступен, но его mapping или события не доказаны, переход откладывают. Остановка с причиной безопаснее частичной миграции и ручного восстановления данных.

\n

Проверяемый критерий готовности

\n

Граница готова, когда повторяемая проверка показывает: нужный модуль подключается; операция существует в каждой обязательной среде; поля имеют записанный mapping; значение, отсутствие и очистка дают ожидаемый read-back; ошибка и отказ прав не превращаются в успех; повторный запуск не создаёт дубль; а сборка и тесты проходят без ручного вмешательства. Для каждой версии нужен сохранённый результат проверки. Если нет хотя бы одного из этих доказательств, готов только план проверки, а не миграция.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/033.json b/editorial/agent-rewrites/033.json new file mode 100644 index 0000000..663dddb --- /dev/null +++ b/editorial/agent-rewrites/033.json @@ -0,0 +1,7 @@ +{ + "index": 33, + "slug": "editorial-2027-02-practice-bitrix-lessons", + "title": "Bitrix legacy без догадок: сохранить вызов, поставить адаптер или заменить API", + "excerpt": "Как принять решение по старому Bitrix API: отделить наблюдаемое поведение от имени класса, проверить границу и не начинать миграцию без контракта.", + "contentHtml": "

После замены старого вызова Bitrix страница продолжает открываться, но новый пользователь не создаётся. В логах остаётся общий отказ. Административная форма показывает успех, хотя обработчик, который отправляет данные во внешнюю систему, не сработал. Цена ошибки — не один сломанный метод. Команде приходится восстанавливать порядок событий, формат полей и правила, которые раньше были спрятаны в legacy-коде.

\n

Возраст класса не доказывает его опасность. Имя нового API не доказывает совместимость. Безопасное решение начинается с наблюдаемого контракта: какие входы принимает код, какое состояние меняет, что возвращает, какие события запускает и как сообщает об отказе. Пока контракт не проверен, есть три действия: сохранить вызов, обернуть его адаптером или заменить после сравнения поведения.

\n

Симптом сначала, название класса потом

\n

Типичный симптом выглядит безобидно: после обновления вызова исчезает запись, меняется формат телефона или внешний обработчик получает пустой идентификатор. Система может не упасть. Она продолжит работать с неполным состоянием. Поэтому проверять нужно не только отсутствие исключения. Нужны чтение результата, события, права, повторный запуск и реакция на частичный отказ.

\n

Риск выше, если одна функция выполняет несколько операций. Она может привести email к нижнему регистру, создать пользователя, вызвать обработчик и вернуть ID. Замена класса меняет сразу четыре соглашения. Сравнение строк вызова этого не показывает.

\n

В Bitrix старый CUser и D7-класс Bitrix\\\\Main\\\\UserTable относятся к одной предметной области, но это не делает их взаимозаменяемыми в проекте. Нужно проверить mapping полей, способ ошибки, порядок событий и доступность модуля в конкретной установке. Документация даёт публичную поверхность API. Она не знает локальные обработчики, пользовательские поля и скрытые callers.

\n
Дерево решения для Bitrix legacy: проверка callers и контракта перед сохранением, адаптацией или заменой API
Сначала проверяют контракт. Если побочные эффекты неизвестны, ветка ведёт к остановке изменения и сбору фактов.
\n

Три решения и их границы

\n

Сохранить — оставить текущий вызов и ограничить изменение вокруг него. Это подходит, когда callers мало, побочные эффекты не описаны, а задача не требует нового контракта. Сохранение не убирает технический долг. Оно не даёт неизвестному поведению разойтись дальше и оставляет обратимый шаг.

\n

Обернуть — поставить одну границу между приложением и Bitrix API. Адаптер принимает поля проекта, проверяет обязательные значения, вызывает legacy-код и переводит ошибку в согласованный результат. Внешний код перестаёт зависеть от PERSONAL_PHONE, подключения модуля и объекта, в котором Bitrix хранит последнюю ошибку.

\n

Заменить — перейти на новый контракт, а не только поменять имя класса. Сначала описывают mapping полей, порядок операций, успешный результат, ошибку, события и требования к версии. Затем запускают старый и новый путь на одинаковых входах. Если различие намеренное, его фиксируют как изменение поведения. Если случайное, замену откладывают.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Вызов вернул ID, но внешняя система не получила записьсобытие зависит от старого порядкажурнал событий и повторное чтение записисохранить путь или обернуть его
Обновление прошло, поле стало пустымпустое значение трактуется как очисткасравнить отсутствие поля, null и пустую строкузадать mapping и правило пустого значения
Класс не найденмодуль не подключён или API недоступно в версиипроверить IncludeModule и поверхность методовостановить замену и уточнить контракт
Повторный запуск создал дубльоперация не различает обработанный объектзапустить один вход дважды и сравнить IDдобавить ключ идемпотентности
Новый вызов работает для одного callercaller-ы передают разные форматынайти все места вызова и фактические входыпоставить адаптер с единым входом
\n

Таблица задаёт порядок вопросов, но не доказывает причину сама. Для каждой строки нужен артефакт: лог, diff полей, результат повторного запуска, список callers или проверка подключения модуля. Если артефакта нет, решение остаётся гипотезой.

\n

Учебный пример адаптера

\n

Ниже приведён учебный PHP-фрагмент. Он показывает форму узкой границы и не утверждает, что конкретный проект должен обновлять пользователя именно так. Перед применением нужно сверить версию Bitrix, права, обработчики и правила хранения контактов.

\n
<?php\nfunction updateUserContact(int $userId, array $input): int\n{\n    if ($userId < 1) {\n        throw new InvalidArgumentException('userId must be positive');\n    }\n\n    $fields = [];\n    if (array_key_exists('email', $input)) {\n        $email = trim((string) $input['email']);\n        if ($email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {\n            throw new InvalidArgumentException('email is invalid');\n        }\n        $fields['EMAIL'] = strtolower($email);\n    }\n\n    if (array_key_exists('phone', $input)) {\n        $fields['PERSONAL_PHONE'] = trim((string) $input['phone']);\n    }\n    if ($fields === []) {\n        throw new InvalidArgumentException('no fields to update');\n    }\n\n    $user = new CUser();\n    if ($user->Update($userId, $fields) === false) {\n        throw new RuntimeException($user->LAST_ERROR);\n    }\n    return $userId;\n}
\n

Адаптер делает три вещи. Он отличает отсутствие поля от переданного значения. Он приводит email к одному формату. Он переводит отказ Bitrix в исключение, которое может обработать caller. Он не объявляет успехом сам факт вызова метода. После обновления нужен read-back: получить пользователя, сравнить поля и проверить побочный обработчик.

\n

Отрицательный путь важнее короткого примера. Если проект разрешает очистить email пустой строкой, проверка выше неверна. Если обработчик ожидает исходный регистр, lowercase меняет контракт. Если старый метод обновляет связанные поля, узкий wrapper скрывает обязательную операцию. В этих случаях пример нельзя переносить целиком. Нужно изменить mapping, расширить контракт или оставить legacy-вызов.

\n

Что выяснить до миграции

\n

Начните с callers. Поиск по имени метода недостаточен: вызов может находиться в сервисе, обработчике события, шаблоне или административной форме. Для каждого caller запишите входные поля, права, ожидаемый результат и реакцию на ошибку. Если один caller передаёт пустую строку, а другой не передаёт поле, это разные операции.

\n

Затем зафиксируйте владельца состояния. Кто создаёт запись? Кто меняет её после события? Кто формирует внешний ID? Какой код считается успехом? Где находится последняя ошибка? Ответы должны подтверждаться кодом, логом или чтением данных. Фраза «Bitrix сам вызывает обработчик» не является проверкой: обработчик зависит от модуля, версии, прав и условий события.

\n

Третья граница — версия. Для старого API проверьте доступность модуля и метода в выполняемой среде. Для нового API проверьте namespace, mapping полей, типы значений и поведение исключений. Вызов CModule::IncludeModule должен быть частью проверки границы, а не строкой, которую добавляют после сбоя.

\n
<?php\nif (!CModule::IncludeModule('main')) {\n    throw new RuntimeException('Bitrix main module is unavailable');\n}\n\n$user = new CUser();\n$ok = $user->Update($userId, $fields);\nif (!$ok) {\n    throw new RuntimeException($user->LAST_ERROR);\n}
\n

Этот второй фрагмент тоже учебный. Он проверяет подключение модуля, но не проверяет права, события, mapping или корректность данных. В конкретном проекте идентификатор модуля и способ вызова нужно сверить с установленной версией.

\n

Действия по порядку

\n
  1. Соберите один воспроизводимый симптом: вход, caller, версия среды, права, результат и цену отказа.
  2. Найдите все вызовы и выпишите фактические поля, значения по умолчанию, события и обработку ошибок.
  3. Проверьте подключение нужного модуля и доступную поверхность API в выполняемой версии.
  4. Добавьте проверки для успеха, обязательного поля, пустого значения, ошибки и повторного запуска.
  5. Выберите сохранить, обернуть или заменить. Если контракт неизвестен, остановите миграцию.
  6. Для адаптера опишите canonical input/output: поля, формат, пустое состояние, ошибку, ID и владельца состояния.
  7. Сравните старый и новый путь на одинаковых учебных входах. Проверьте read-back, события, права и отрицательный путь.
  8. Оставьте только изменение, для которого можно назвать проверку, результат и способ отката.
\n

Ограничения и критерий готовности

\n

Переход на D7 или другой новый слой не переносит автоматически пользовательские поля, события, права и внешние идентификаторы. Даже правильный mapping опасен, если операция выполняется внутри транзакции или участвует в импорте. Нужно понять поведение при повторе и частичном отказе.

\n

Unit-тест на чистом объекте не проверяет модуль, реальную версию, события и права. Но полная копия production-среды не обязательна для первого шага. Достаточно сузить границу, зафиксировать факты и назвать, чего учебная проверка не покрывает. Не следует объявлять замену готовой только потому, что новый метод вернул ожидаемый ID.

\n

Иногда правильный результат — не менять код. Если нет воспроизводимого входа, список callers неполный, а ошибка зависит от неизвестного обработчика, сохранение вызова снижает риск. Сначала получают недостающий контракт. Затем возвращаются к адаптеру или замене.

\n

Изменение готово, когда другой разработчик может повторить проверку по записи входа и получить тот же результат. В записи есть callers, версия и подключение модуля, mapping полей, события, успешный и отрицательный путь, read-back и способ отката. Для замены старый и новый путь дают одинаковый результат либо команда явно согласовала изменение. Если пункт неизвестен, готова не миграция, а следующая проверка.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/034.json b/editorial/agent-rewrites/034.json new file mode 100644 index 0000000..21ae276 --- /dev/null +++ b/editorial/agent-rewrites/034.json @@ -0,0 +1,7 @@ +{ + "index": 34, + "slug": "editorial-2027-01-field-debugging-decade", + "title": "502 без догадок: как восстановить цепочку запроса по логам", + "excerpt": "Пошаговый разбор 502 по access и application log: как связать события, отличить отказ до приложения от ошибки в нём и не назвать причину без доказательства.", + "contentHtml": "

Клиент получает 502. В access log шлюза видны маршрут, время и внешний статус. В журнале приложения нет записи с тем же запросом. Инженер открывает последний релиз и начинает искать ошибку в коде. Через несколько часов выясняется, что шлюз не дождался upstream или не смог разобрать его ответ. Исправление приложения не меняет ситуацию, а время расследования уже потеряно.

\n

Цена ошибки состоит не только в часах. Команда может откатить исправный релиз, увеличить таймаут без понимания причины или добавить повторные запросы к уже перегруженной зависимости. Следующий дежурный получит уверенную, но неверную запись: «упало приложение». Она направит новое расследование по тому же ложному следу.

\n

Рабочий тезис простой: 502 надо разбирать как разрыв цепочки событий. Сначала фиксируют, где появился статус. Затем ищут связанную запись приложения по идентификатору. Только после подтверждения передачи запроса переходят к зависимости. Отсутствие записи — тоже результат. Он ограничивает вывод и открывает отдельную проверку.

\n

Что означает внешний статус

\n

RFC 9110 определяет 502 как ответ gateway или proxy, который получил недействительный ответ от сервера, к которому обращался для выполнения запроса. Это описание роли узла, а не доказательство того, что конкретный сервис сломан. Клиент видит ответ ближайшей границы. Причина может находиться между шлюзом и upstream: в соединении, таймауте, формате ответа, маршрутизации или фильтре.

\n

Поэтому первая запись должна называть субъект результата. Поле edge.status=502 точнее, чем сообщение «сервер вернул 502». Рядом нужны route, method, duration_ms, время события, имя узла и ключ корреляции. Без этих полей access log подтверждает симптом, но не даёт короткого пути к следующему слою.

\n

Как строится доказательная цепочка

\n

Для одной попытки запроса нужны минимум два источника: access log на границе и application log в сервисе. Они связываются по точному request_id или по trace context. Время и путь помогают проверить совпадение, но не должны быть единственным ключом. Два запроса к одному маршруту могут попасть в одно и то же временное окно.

\n

Если application event найден, сравните время, маршрут, статус и длительность. Запись приложения с 500 показывает, что запрос дошёл до приложения и там завершился ошибкой. Она не объясняет, почему произошёл отказ зависимости. Запись приложения с 200 при внешнем 502 показывает расхождение границ: надо проверять retry, кэш, преобразование статуса или другой upstream.

\n

Если application event не найден, не подставляйте приложение в роль виновника. Проверьте timeout до приложения, правила маршрутизации, формат идентификатора, фильтры коллектора и задержку доставки. После этого можно сказать только: «внешний отказ подтверждён, запись приложения не найдена». Это полезный вывод, потому что он отделяет неисправность канала наблюдения от отказа бизнес-операции.

\n
\"Цепочка
Полевой разбор начинается с внешнего события. Разрыв между access и application не позволяет объявить приложение причиной.
\n

Симптом → причина → проверка → действие

\n
Матрица первой проверки для 502
СимптомВозможная причинаПроверкаДействие
502 в edge, application event отсутствуетtimeout, маршрут до сервиса или потеря записисверить upstream, окно времени, collector и формат idзафиксировать разрыв; не обвинять приложение
502 в edge, application 500 с тем же idошибка обработки запроса в сервисесравнить время, route, статус и dependency eventисследовать ошибку приложения и её границу
502 в edge, application 200retry, cache или преобразование ответа на proxyпроверить попытки, upstream и mapping статусовразделить результат приложения и результат клиента
В access нет request idнеполная схема structured logпроверить конфигурацию полей и передачу заголовкаисправить корреляцию до следующего разбора
\n

Учебный пример корреляции

\n

Ниже приведён учебный пример для локальной проверки идеи. Массивы не взяты из production и не доказывают поведение конкретного шлюза. Функция получает события одной попытки и классифицирует только наблюдаемый разрыв.

\n
const edge = [\n  { requestId: 'r-1', at: '12:00:01.100', status: 502, durationMs: 3000 },\n  { requestId: 'r-2', at: '12:00:02.100', status: 502, durationMs: 420 },\n];\n\nconst app = [\n  { requestId: 'r-2', at: '12:00:02.080', status: 500, error: 'db unavailable' },\n];\n\nfunction classify(edgeEvent, appEvents) {\n  const appEvent = appEvents.find((event) => event.requestId === edgeEvent.requestId);\n  if (!appEvent) return 'gateway-failed-before-app-or-event-missing';\n  if (appEvent.status >= 500) return 'app-error-reached-gateway';\n  return 'status-mapping-needs-check';\n}\n\nedge.map((event) => ({\n  requestId: event.requestId,\n  result: classify(event, app),\n}));\n// r-1: gateway-failed-before-app-or-event-missing\n// r-2: app-error-reached-gateway
\n

Для r-1 код видит только отсутствие события. Это не различает timeout, сбой коллектора и неверный идентификатор. Для r-2 найдено согласованное событие приложения. Оно подтверждает путь запроса, но не доказывает, что база была первопричиной. Следующий запрос должен проверить dependency event, лимит соединений и время ожидания.

\n

В рабочем коде нормализуйте схему на входе, сохраняйте номер попытки и не смешивайте повторные запросы в одну карточку. Не включайте в общий журнал токены, тело формы, email и сырые заголовки. Безопасный fingerprint может помочь связать закрытый источник с публичной карточкой, если правила хранения это разрешают.

\n

Действия по порядку

\n
  1. Скопируйте одну попытку из edge log: timestamp, route, method, status, duration и request id.
  2. Уточните, какой узел сформировал 502 и какой upstream он выбирал.
  3. Найдите application events по точному id в ограниченном временном окне. Запишите число найденных событий.
  4. Сверьте время, route, status и номер попытки. Отдельно отметьте retry, очередь и возможный clock skew.
  5. Если приложение подтверждено, проверьте dependency event и только затем формулируйте рабочую гипотезу о причине.
  6. Если приложение не найдено, проверьте timeout, маршрутизацию, collector, формат идентификатора и задержку доставки.
  7. Запишите вывод как наблюдение и следующий тест: например, «нет application event; проверить timeout и collector».
  8. После изменения повторите тот же запрос и убедитесь, что цепочка снова собирается по идентификатору.
\n

Где метод перестаёт работать

\n

Лог может быть неполным. Sampling удаляет часть событий. Буферизация меняет порядок доставки. Collector может отбросить запись или обрезать длинное сообщение. Часы сервисов могут расходиться. Поэтому близкое время не заменяет ключ корреляции, а найденный ключ не гарантирует полноту цепочки.

\n

Один request id может пережить retry или быть создан заново на новой попытке. Это надо выяснять по attempt, span id и временным интервалам. Trace context помогает передавать связь между HTTP-границами, но не доказывает, что каждый сервис записал событие или что наблюдаемый участок был причиной сбоя.

\n

Структурированные поля упрощают поиск, но не делают журнал достоверным автоматически. Формат RFC 5424 предусматривает отдельную область для parseable structured data; конкретная система всё равно может неправильно настроить поля, транспорт или collector. Если корреляция часто ломается, сначала исправьте контракт логирования. Новый экран наблюдаемости не компенсирует отсутствующий идентификатор.

\n

Метод также не заменяет нагрузочный анализ и проверку контракта ответа. Если 502 появляется только при перегрузке, одного разбора карточки недостаточно. Нужны распределение длительностей, число retry, состояние очередей и лимиты соединений. Эти данные расширяют расследование, но не отменяют первый шаг: определить, где появился внешний статус.

\n

Критерий готовности

\n

Разбор готов, когда для одной повторной попытки можно показать access event, application event или явно подтверждённый разрыв, согласованные время и маршрут, номер попытки и следующий проверяемый вывод. Исправление готово, когда после него тот же сценарий даёт ожидаемый статус, цепочка событий собирается по идентификатору, а отрицательный путь остаётся различимым. Формулировка «проблема решена» без этих наблюдений недостаточна.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/035.json b/editorial/agent-rewrites/035.json new file mode 100644 index 0000000..cf4fee2 --- /dev/null +++ b/editorial/agent-rewrites/035.json @@ -0,0 +1,7 @@ +{ + "index": 35, + "slug": "editorial-2027-01-mechanism-debugging-decade", + "title": "Trace ID связывает события, но не доказывает причину", + "excerpt": "Как читать trace, log и metric вместе, проверять разрывы контекста и не принимать совпадение идентификатора за доказательство причины.", + "contentHtml": "

В двух журналах найден один trace ID. Временные метки почти совпадают. Один span длится дольше остальных. Команда объявляет его причиной задержки и меняет таймаут в этом сервисе. Через день задержка возвращается: запросы ждали соединение в шлюзе, а длинный span лишь включал это ожидание. Цена ошибки — потерянное время, лишний rollback и новый побочный эффект.

\n

Trace ID отвечает на вопрос «к каким данным относится эта запись?». Он не отвечает на вопрос «какая операция вызвала отказ или задержку?». Причинный вывод требует проверить структуру трассы, интервалы, статус, локальные журналы и путь, по которому запрос действительно прошёл.

\n

Механизм: три сигнала и три разных вопроса

\n

Log фиксирует событие в одном процессе: сообщение, локальное состояние, уровень и время. Span описывает операцию: начало, конец, родителя, сервис и атрибуты. Metric агрегирует много запросов и показывает частоту, распределение или долю ошибок. Один сигнал не заменяет другой.

\n

W3C Trace Context задаёт формат передачи traceparent и tracestate между HTTP-границами. Так разные сервисы могут продолжить общий контекст. Инструмент может только передать контекст, не создав подробный span. Очередь, фоновая задача, retry или библиотека без интеграции могут остаться за пределами записи.

\n

Поэтому trace ID создаёт область поиска, а parent/child-связи задают наблюдаемую структуру. Если у span нет родителя, это не доказывает, что операция независима. Возможны потеря записи, неверное поле, sampling или отдельная работа, ошибочно попавшая в trace. Отсутствие события в одном источнике означает только, что его там не нашли.

\n
\"Сравнение
Корреляционный ключ связывает записи. Причину подтверждает только согласованный набор независимых признаков.
\n

Пример: найти разрыв, а не назначить виновника

\n

Следующий код — учебный пример. Он работает с заранее заданным массивом и ничего не знает о production-трафике. Его задача — показать отрицательный путь: система должна явно отметить отсутствующего родителя, а не дорисовать целую цепочку.

\n
const spans = [\n  { traceId: 't-7', spanId: 'gateway', parentSpanId: '', service: 'gateway', durationMs: 22 },\n  { traceId: 't-7', spanId: 'api', parentSpanId: 'gateway', service: 'api', durationMs: 81 },\n  { traceId: 't-7', spanId: 'db', parentSpanId: 'missing', service: 'db', durationMs: 4 }\n];\n\nconst byId = new Map(spans.map((span) => [span.spanId, span]));\nconst links = spans.map((span) => ({\n  service: span.service,\n  parent: span.parentSpanId\n    ? (byId.has(span.parentSpanId) ? 'present' : 'missing')\n    : 'root'\n}));\n\nconsole.log(links);\n// gateway: root; api: present; db: missing
\n

Результат даёт один проверяемый факт: у db нет родителя в принятом наборе. Он не говорит, что db вызвал задержку. Следующая проверка зависит от вопроса. Нужно узнать, потерялся ли span, не перепуталось ли поле parentSpanId, не создалась ли операция вне контекста и не отфильтровал ли сборщик запись.

\n

Длительность тоже требует контекста. Если gateway ждёт upstream 800 мс, эти 800 мс могут включать DNS, установку соединения, очередь, retry и чтение ответа. Долгий span показывает время, проведённое внутри его границ. Он не раскладывает это время по причинам без дочерних span-ов или дополнительных журналов.

\n

Симптомы и проверяемые действия

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Один trace ID есть в gateway и API, но ответа нетСервис не записал span или запрос прервался до негоСверить access log, статус соединения, sampling и окно времениОтметить разрыв; не называть API причиной без записи операции
У span есть parentSpanId, но родителя нетПотеря span, ошибка экспорта или неверная связьПроверить полный экспорт, формат ID и дубликаты span-idИсправить передачу или сбор; сохранить missing parent как сигнал
Самый длинный span совпал с пиком latencySpan включает ожидание upstream, retry или очередьСопоставить дочерние интервалы, status, retry count и метрику populationРазделить время по операциям; не оптимизировать сервис по одному trace
В log нет записи с нужным trace IDПоле не попало в журнал, запись отбросил collector или выбран другой IDПроверить схему, доставку, источник и request-id на границеСчитать источник неполным и продолжить по access/metric, не делать вывод об отсутствии события
\n

Действия по порядку

\n
  1. Зафиксировать конверт симптома: метод, маршрут, статус, размер ответа, timestamp, длительность, trace ID и границу, на которой получена запись.
  2. Проверить формат trace-id и span-id. Убедиться, что сервисы не меняют trace ID без явной новой границы и не смешивают его с request-id.
  3. Построить граф parent/child. Отдельно отметить root, missing parent, duplicate span-id, пустой service.name и операции с разными trace ID.
  4. Сверить start/end span-ов с локальными временными метками. Учесть clock skew, асинхронную передачу, retry, очередь и время ожидания соединения.
  5. Сопоставить span с application log по span-id или request-id. Metric использовать для проверки масштаба: единичный trace должен быть сопоставим с общей картиной запросов.
  6. Сформулировать узкий вывод. Например: «gateway наблюдал задержку чтения ответа» или «контекст потерян между API и worker». Не писать «API был причиной» без различающего доказательства.
  7. Только после этого менять код, конфигурацию или лимит. Повторить тот же сценарий и проверить, исчез ли исходный симптом, не ухудшились ли соседние метрики и сохранился ли контекст.
\n

Ограничения

\n

Sampling может исключить нужный span. Tail-based filtering может оставить только часть цепочки. Collector может получить события не по порядку или отбросить запись при перегрузке. Разные часы на узлах искажают сравнение timestamps. Асинхронный consumer может законно продолжить работу после завершения parent span. Для него нужны отдельные связи producer, сообщения и consumer.

\n

Trace не доказывает контрфактическое утверждение: нельзя по одной цепочке узнать, что произошло бы без конкретного вызова. Не стоит помещать персональные параметры в атрибуты и журналы. Корреляционный ключ должен помогать искать запись, а не раскрывать содержимое запроса.

\n

Критерий готовности

\n

Разбор готов, когда другой инженер получает один симптом и может повторить маршрут проверки без устной истории. В записи видны граница симптома, полный или явно неполный граф, проверенные интервалы, источник каждого вывода и отрицательный путь для отсутствующей записи. Исправление готово, когда повторный сценарий подтверждает изменение на исходном сигнале, не создаёт нового отказа по соседней метрике, а проверка missing parent или другого разрыва остаётся наблюдаемой.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/036.json b/editorial/agent-rewrites/036.json new file mode 100644 index 0000000..81b0809 --- /dev/null +++ b/editorial/agent-rewrites/036.json @@ -0,0 +1,7 @@ +{ + "index": 36, + "slug": "editorial-2027-01-practice-debugging-decade", + "title": "502, пустой экран, отказ: как проверить причину по сигналам", + "excerpt": "Практический маршрут от наблюдаемого web-симптома к проверяемой гипотезе: что сохранить, где искать разрыв и когда исправление действительно готово.", + "contentHtml": "

Пользователь открывает страницу, а получает 502. Или видит пустой экран после ответа 200. Или форма отвечает 403, хотя доступ должен быть разрешён. В каждом случае команда быстро называет причину: «упал сервис», «сломался фронтенд», «протух токен». Если первая версия неверна, инженер меняет не тот слой, стирает исходный сигнал и тратит часы на новый симптом. Для пользователя это недоступная операция. Для команды — лишний релиз, повторный инцидент и решение, которое трудно откатить.

Тезис этой статьи простой: диагностика должна идти от наблюдаемого эффекта к различающему сигналу, а не от любимого инструмента к предполагаемому виновнику. Сначала сохраните конверт запроса. Затем назовите две правдоподобные причины. Для каждой запишите проверку, которая может её опровергнуть. Только после этого меняйте конфигурацию или код.

Что именно наблюдает клиент

HTTP-статус описывает ответ на одной границе. Статус 502 означает, что шлюз или прокси получил недействительный ответ от вышестоящего сервера. Он не сообщает, разорвалось ли соединение, истёк ли таймаут, не совпал ли маршрут или приложение вернуло неожиданные данные. Статус 504 говорит о таймауте шлюза, но тоже не указывает, на каком участке закончился бюджет времени. Поэтому запись «получили 502» — факт, а не диагноз.

Пустой экран требует такой же дисциплины. Браузер мог получить пустой HTML. JavaScript мог не выполнить рендер. API мог вернуть пустой массив по корректному условию. Компонент мог скрыть ошибку и вывести контейнер без содержимого. Все четыре случая выглядят похоже на скриншоте. Различают их тело ответа, ошибки консоли, сетевые события и фактически построенный DOM.

Сохраните исходный сигнал до повтора и до исправления. Минимальный конверт содержит метод, путь, статус, время, длительность, размер ответа, content-type, идентификатор запроса и границу наблюдения. Для браузерного симптома добавьте URL документа, статус API, ошибку консоли и результат проверки DOM. Эти поля не доказывают причину. Они ограничивают поиск и показывают, какого сигнала пока не хватает.

\"Маршрут
Каждый переход от симптома к действию должен добавлять наблюдаемый признак. Иллюстрация показывает маршрут, а не готовый диагноз.

Механизм: гипотеза должна различать причины

Формулируйте гипотезу в условной форме: «если причина X, то при проверке Y увидим Z». Такая запись заранее допускает отрицательный результат. Например: если gateway не получил ответ приложения, в access log будет 502, а в application log не будет события с тем же идентификатором. Если приложение вернуло ошибку, обе записи появятся в одном временном окне, а статусы и длительности будут различаться.

Идентификатор запроса связывает записи, но не доказывает причинность. Он может потеряться на границе, повториться из-за ошибки интеграции или не попасть в sampled trace. Совпадение времени тоже не является связью: параллельные запросы имеют похожие отметки, а часы узлов могут расходиться. Если ключа нет, это результат проверки — «цепочка не связана», — а не разрешение взять ближайшую запись.

Для распределённого маршрута полезно различать три вида данных. Log показывает сообщение и локальное состояние процесса. Span показывает границу операции, родителя и длительность. Metric показывает агрегат по множеству запросов. Trace ID помогает найти общий контекст. Ни один из этих сигналов в одиночку не отвечает на все вопросы. Длинный span не обязательно является причиной задержки. Ошибка в метрике не доказывает ошибку конкретного запроса.

Учебный пример: классифицировать вход, не объявляя root cause

Ниже — локальный учебный пример. Он не обращается к сети, не читает production-логи и не утверждает, что найден источник ошибки. Функция только выбирает следующий сигнал по форме ответа. Автоматизация может упорядочить проверку, но не может выдать доказательство из отсутствующих данных.

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 headers.traceparent || headers['x-request-id'] ? 'связать gateway и upstream по идентификатору' : 'включить идентификатор на границе';
  if (status === 401 || status === 403) return 'сверить аутентификацию, авторизацию и политику доступа';
  if (status === 200 && /empty|blank|undefined/i.test(body)) return 'сравнить тело ответа API с фактическим DOM';
  return 'сохранить метод, путь, статус, размер и время';
}
console.log(classifyWebSymptom({ status: 502, headers: { traceparent: '00-abc-123-01' }, body: 'Bad Gateway' }));
// связать gateway и upstream по идентификатору

Вход с 502 и traceparent ведёт к сопоставлению записей на двух границах. Вход с 502 без идентификатора ведёт к исправлению наблюдаемости, а не к перезапуску приложения. Вход с 200 и пустым содержимым ведёт к сравнению ответа, ошибок рендера и DOM. Вход 403 ведёт к проверке схемы аутентификации и решения авторизации. Эти маршруты не заменяют расследование. Они не дают функции права выбрать базу данных, прокси или браузер виновником.

Симптом → причина → проверка → действие

Минимальные развилки для первого прохода
СимптомВозможная причинаПроверкаДействие
502 на границеgateway не получил корректный ответ upstreamСопоставить request-id или traceparent; проверить запись приложения в том же окнеРазделить отказ до приложения и ошибку приложения; затем исправлять найденный слой
504 после фиксированного интервалаБюджет времени закончился на gateway, клиенте или зависимостиСравнить таймауты границ и длительности span-овНайти участок, который исчерпал бюджет; не добавлять повтор вслепую
200 и пустой экранпустые данные, ошибка рендера или пустой HTMLСопоставить response body, console error и DOMИсправить контракт данных или рендер; отдельно проверить fallback
403 для ожидаемого пользователярешение политики не совпало с контекстом доступаПроверить токен, claims, scope, ресурс и версию политикиИсправить конкретное условие; не ослаблять всю политику
Запись есть только в одном слоепотеря корреляции, sampling или отказ до следующей границыПроверить формат полей, перенос заголовка и временное окноОтметить разрыв как результат; добавить сигнал перед повтором

Порядок действий

  1. Запишите симптом без объяснения: метод, путь, статус, время, длительность, размер ответа, content-type и границу, на которой увидели ответ.
  2. Сформулируйте минимум две причины. Для каждой запишите наблюдение, которое должно появиться, и наблюдение, которое её опровергнет.
  3. Проверьте внешний access log и журнал следующего слоя в одном временном окне. Сопоставляйте записи по идентификатору, а не только по пути и секунде.
  4. Разделите синхронный и асинхронный путь. Для очереди или фоновой задачи найдите отдельную связь между producer, сообщением и consumer.
  5. Проверьте отрицательный путь: запрос без токена, просроченный токен, отсутствующий parent span, пустой ответ и превышение таймаута.
  6. Внесите одно изменение в найденном слое. Повторите тот же сценарий с теми же входами и сравните исходный конверт с новым.
  7. Сохраните проверку рядом с исправлением: тест, запрос для воспроизведения, структурированный лог или короткую операционную инструкцию.

Ограничения

Этот маршрут не восстанавливает данные, которых система не записала. Если gateway не переносит идентификатор, связь нельзя честно реконструировать по одному времени. Если trace sampling отбросил span, отсутствие span не означает отсутствие работы. Если прокси переписал статус или тело, нужно искать его access log и правила маршрутизации. Если несколько запросов выполняются параллельно, порядок строк в журнале не равен порядку причин.

Учебный классификатор не подходит как готовое правило блокировки или маршрутизации. Его ветки намеренно грубые. В реальной системе нужно учесть редиректы, retries, кеш, CDN, разные схемы авторизации и версию контракта API. Не добавляйте повторные попытки только потому, что ответ медленный: retry может увеличить нагрузку и скрыть первичный отказ. Не меняйте таймаут, пока не измерили бюджет на каждой границе.

Статус 502 или 504 также не доказывает, что downstream был недоступен. Причина может быть в несовместимом формате ответа, неверном DNS, закрытом соединении или ограничении шлюза. Статус 403 не доказывает, что пользователь «не имеет доступа» в бизнес-смысле: решение могло использовать устаревшие claims или другую версию политики. Проверяйте именно тот контекст, который использовал компонент.

Проверяемый критерий готовности

Диагностика готова, когда другой инженер получает исходный конверт и может без устных пояснений назвать запрос, границу и следующий сигнал. Для исправления нужен ещё один результат: тот же сценарий проходит по ожидаемому пути, а отрицательный вариант по-прежнему получает правильный отказ. В журнале остаются идентификатор, статус, длительность и причина завершения. Если повтор только «стал зелёным», но различающий сигнал не сохранился, причина не доказана.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/037.json b/editorial/agent-rewrites/037.json new file mode 100644 index 0000000..e3acab5 --- /dev/null +++ b/editorial/agent-rewrites/037.json @@ -0,0 +1,7 @@ +{ + "index": 37, + "slug": "editorial-2026-12-field-portfolio-case", + "title": "Как передать инженерный кейс, не превратив сценарий в доказательство", + "excerpt": "Практический разбор synthetic hand-off: как отделить входные данные от вывода, остановить усиленный claim и передать следующему владельцу проверяемую границу риска.", + "contentHtml": "

Получатель открывает карточку и видит знакомые слова: evidence, decision, residual risk, next action. Через час он ищет ADR, метрику, тест и запись rollout, которых никогда не было. Ошибка началась не в коде. В документе сценарий выглядел как отчёт о выполненной работе. Цена — потерянное время, неверная операционная память и решение, принятое на основании отсутствующих данных.

\n

Есть и более тихий симптом. Автор называет synthetic hand-off «field report», добавляет правдоподобную дату, имя команды или номер изменения. Читатель уже не различает учебный literal и наблюдение из production. В следующем пересказе оговорка исчезает, а выдуманный результат остаётся. Поэтому такой текст должен начинаться не с красивого итога, а с границы: какие входы существуют, чего в них нет и какой вывод разрешён.

\n

Тезис простой: безопасная передача не доказывает результат. Она передаёт фиксированный сценарий, его происхождение, допустимую силу утверждения и явный путь остановки. Если вход имеет статус scenario-only, claim не может стать benchmark-confirmed. Если результат не наблюдался, его нельзя назвать выполненным. Это правило одинаково полезно для редакционного примера, архитектурной записки и будущего hand-off между командами.

\n

Сначала отделите наблюдение от модели

\n

Наблюдение отвечает на вопрос «что произошло и откуда это известно». Модель отвечает на вопрос «как можно организовать будущую проверку». Эти вопросы нельзя закрыть одной карточкой. В synthetic case есть фиксированный объект в памяти: его имя, дата плана, дата отсечения источников, варианты, отклонённый вариант, состояние evidence и residual risk. У объекта нет системы, пользователя, change или telemetry.

\n

В этом различии важен не английский словарь, а сила claim. no-observation говорит, что наблюдение не собрано. scenario-only говорит, что вход — учебная конструкция. external-effect-none говорит, что внешний эффект не запускался. Вместе эти поля не делают сценарий слабым. Они не дают ему притвориться сильнее, чем он есть.

\n
const handoff = { caseName: 'named-fixed-synthetic-portfolio-case', plan-date: '2026-12', source-cutoff: '2026-07-31', evidence: { inputStrength: 'scenario-only', claimStrength: 'scenario-only', state: 'no-observation' }, requestedResult: 'bounded-hand-off', externalEffect: 'external-effect-none' };
\n

Это учебный объект. Он не взят из production и не описывает выполненный проект. Его смысл — удержать границу между данными и выводом. Поля plan-date и source-cutoff задают время сценария, но не утверждают, что в декабре шла работа. Поле requestedResult описывает допустимый ответ функции, а не полезность решения для пользователя.

\n

Варианты защищают от удобной легенды

\n

Одновариантный рассказ почти всегда выглядит убедительно. Автор показывает выбранную структуру и не говорит, что могло быть иначе. В результате читатель принимает отсутствие альтернативы за качество решения. В фиксированном сценарии есть два имени: narrow-evidence-note и expanded-evidence-note. Первый сохраняет только границу входа и следующий вопрос. Второй добавил бы детали, похожие на реальные доказательства. В literal явно указан rejectedOption.

\n

Отклонённый вариант не означает, что состоялся design review. Он нужен как контроль потери контекста. Если поле пустое, evaluator возвращает stop-missing-rejected-option. Он не выбирает вариант сам и не дописывает причину отказа. Такой отрицательный путь полезнее автоматического значения по умолчанию: он возвращает проблему туда, где исчезло решение.

\n

То же правило действует для дат и источников. Если сценарий теряет plan-date или меняет cutoff, evaluator возвращает stop-undated-scenario-or-cutoff. Дата не превращает модель в исторический факт. Она лишь не даёт пересказать сценарий как нечто вне времени.

\n
\"Схема
Иллюстрация показывает маршрут статусов synthetic hand-off. Она не показывает реальный workflow, запуск, изменение системы или production-результат.
\n

Симптом → причина → проверка → действие

\n
Проверки границы для безопасной передачи
СимптомПричинаПроверкаДействие
В карточке есть положительный результат, но вход только scenario-onlyClaim сильнее исходных данныхСравнить inputStrength и claimStrengthВернуть stop и снизить claim до scenario-only
Указан один вариантОтсутствует rejected optionПроверить минимум два options и имя отвергнутого вариантаОстановить передачу и восстановить границу выбора
Нет даты плана или cutoffСценарий потерял временную рамкуСверить plan-date 2026-12 и source-cutoff 2026-07-31Вернуть stop-undated-scenario-or-cutoff
В тексте появились ADR, metric или rolloutМодель получила неразрешённые production-поверхностиНайти утверждения о сделанном и источник каждогоУдалить claim или заменить его на допустимый вопрос
Положительный status запускает внешнее действиеHand-off перепутан с очередью работПроверить externalEffect и наличие вызовов наружуОставить только in-memory результат с external-effect-none
\n

Как работает fail-closed проверка

\n

Проверка должна принимать только известный fixed literal. Произвольный объект с похожими полями недостаточен: он может содержать незаметно усиленный claim. Поэтому evaluator сначала сравнивает вход с одним из именованных сценариев. Затем он проверяет дату, варианты, состояние evidence и запрошенный результат. Ошибка на любом шаге возвращает статус stop, причину и следующий безопасный шаг.

\n

Порядок важен. Сначала проверяется provenance объекта, потом сила его утверждения. Нельзя обсуждать качество решения, если неизвестно, откуда взялся вход. Нельзя обсуждать rollout, если результат уже запрещён самим контрактом. Короткий ответ с причиной лучше длинного текста, который компенсирует пропущенное поле правдоподобной историей.

\n
const incompleteStory = prepareHandoff('missing-rejected-option-v1'); const reply = gateHandoff(incompleteStory); console.log({ status: reply.status, action: reply.nextAction, production: reply.externalEffect }); // status: stop-missing-rejected-option; action: name-the-option-not-carried-forward; production: external-effect-none
\n

Фрагмент учебный. Он вызывает функции из локального модуля, не читает файлы и не делает сетевых запросов. Его вывод показывает классификацию неполного literal. Он не подтверждает качество архитектуры, не создаёт ADR и не доказывает, что подобный сценарий случался в рабочей системе.

\n

Ветвь с более сильным evidence должна завершаться так же жёстко. Если inputStrength равен scenario-only, а claimStrength равен benchmark-confirmed, результат — stop-evidence-stronger-than-input. Если запрошен case-complete, evaluator возвращает stop-disallowed-positive-result. В обоих случаях следующий шаг описывает исправление boundary, а не назначает владельца и не запускает работу.

\n

Provenance — это не выдуманный audit trail

\n

Для synthetic hand-off достаточно короткого происхождения: имя fixed case, дата плана, cutoff, набор вариантов и состояние no-observation. JSON clone отделяет выданный экземпляр от исходной константы. Deep freeze не даёт учебному вызову изменить вложенные поля в памяти. Эти свойства делают модель читаемой. Они не создают историю событий.

\n

Не добавляйте номер инцидента, ссылку на dashboard, имя реального владельца, timestamp якобы запуска или процент улучшения. Без источника такие детали не увеличивают воспроизводимость. Они только создают поверхность для ложной ссылки. Если цифра важна, сначала нужен разрешённый источник и метод измерения. До этого корректнее записать «не собрано» или «нельзя утверждать».

\n

Риск тоже надо формулировать точно. future-owner-may-need-a-separate-evidence-contract — это открытое условие. Оно не означает, что владелец уже назначен, контракт согласован или данные будут доступны. Следующий читатель может остановиться, запросить полномочия или отказаться от отдельного исследования. Материал должен позволять эти решения, а не подталкивать к ним скрытым обещанием.

\n

Hand-off не равен очереди работ

\n

Фраза «владелец подготовит ADR» уже утверждает владельца и будущий артефакт. Фраза «после change проверим метрику» утверждает change и набор метрик. В fixed input этого нет. Поэтому nextAction должен называть класс будущего вопроса: «уточнить, нужен ли отдельный evidence contract». Он не должен содержать назначение, дедлайн, уведомление или запуск.

\n

Техническая возможность также не равна разрешению. Модуль мог бы получить file reader, API client или доступ к telemetry. Это не даёт права читать production данные. Аналогично, команда могла бы написать тест, но модель не может заявить, что тест нужен, согласован или уже запущен. Граница hand-off — возвращаемый объект в памяти. Он не меняет систему и не отправляет сообщение наружу.

\n

Порядок действий

\n
  1. Назвать материал synthetic-сценарием и указать plan-date 2026-12 и source-cutoff 2026-07-31.
  2. Выбрать только именованный fixed literal. Не принимать объект с похожей формой из неизвестного источника.
  3. Проверить контекст и записать минимум два варианта. Явно назвать rejected option.
  4. Оставить evidence как scenario-only, claim как scenario-only, а state как no-observation.
  5. Проверить, что в тексте нет утверждений о реальном ADR, benchmark, metric, test, incident, change, rollout или результате.
  6. Отклонить любой requested result, кроме bounded-hand-off с externalEffect: external-effect-none.
  7. Вернуть status, reason, boundary, residual risk и nextAction без внешних вызовов.
  8. Передать карточку следующему читателю как ограниченный вопрос, а не как поручение и не как подтверждение.
\n

Ограничения и отрицательный путь

\n

Такая модель не заменяет реальный evidence hand-off. Она не проверяет качество будущего решения, совместимость вариантов, безопасность изменения или пользу для пользователя. В ней нет production inputs, контрольной группы, периода наблюдения, измерительного плана и разрешения на внешнее действие. Нельзя использовать её как аргумент для release decision, security review или архитектурного утверждения.

\n

Отрицательный путь не означает, что система сломалась. Он означает, что вход не позволяет сделать следующий вывод. Отсутствующий rejected option возвращает вопрос о выборе. Усиленный claim возвращает вопрос о доказательстве. Недатированный сценарий возвращает вопрос о границе времени. Запрещённый positive result возвращает материал к hand-off. Это полезная остановка: она сохраняет неопределённость видимой и не заполняет её выдуманными фактами.

\n

Есть и практическое ограничение формата. Короткая карточка может потерять детали при пересказе. Поэтому рядом с полями нужно хранить boundary и reason, а не только status. Статус без объяснения быстро превращается в зелёную галочку. Причина удерживает связь между конкретным нарушением и действием, которое допустимо дальше.

\n

Критерий готовности

\n

Передача готова, если читатель за один проход может назвать источник входа, временную рамку, два варианта, отвергнутый вариант, силу evidence, состояние наблюдения и residual risk. Для каждого усиленного claim существует явный stop. Успешный status не обещает production effect и возвращает только bounded-hand-off. Учебный код помечен как учебный, иллюстрация имеет существующий asset path, а ссылки отделены от собственных данных модели.

\n

Готовность не равна фразе «кейс доказан». Здесь проверяемый итог скромнее: граница не потерялась при hand-off, отрицательный путь различим, а следующий читатель понимает, что ещё нужно получить до любого реального решения. Если хотя бы одно из этих условий нарушено, документ следует остановить и исправить, а не украшать дополнительными деталями.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/038.json b/editorial/agent-rewrites/038.json new file mode 100644 index 0000000..b946c64 --- /dev/null +++ b/editorial/agent-rewrites/038.json @@ -0,0 +1,6 @@ +{"index": 38, + "slug": "editorial-2026-12-mechanism-portfolio-case", + "title": "Инженерный кейс: как проверить причинность, а не приписать результат изменению", + "excerpt": "Если после изменения стало лучше, это ещё не доказывает причинность. Разбираем, как отделить событие от наблюдения, проверить контрфакты и остановить кейс, когда данных недостаточно.", + "contentHtml": "

После релиза команда видит знакомый симптом: задержка ответа снизилась, ошибок стало меньше, а в отчёте появляется фраза «изменение дало результат». Через неделю показатель снова меняется. Уже непонятно, помог релиз, закончилась нагрузка, изменился состав трафика или перестал отвечать другой компонент. Цена ошибки — неверное решение на следующем шаге. Команда может закрепить бесполезный код, отменить полезный откат или объявить временный эффект доказанным.

Тезис прост: инженерный кейс доказывает не соседство изменения и результата, а цепочку «вход → механизм → наблюдение → сравнение → вывод». Если хотя бы одно звено отсутствует, текст должен понизить силу утверждения или остановиться. Такой кейс остаётся полезным: он показывает, что известно, чего не хватает и какое наблюдение отличит объяснения.

Сначала разделите событие, наблюдение и вывод

Событие — действие, которое действительно произошло: например, сервис начал отдавать ответ из локального кеша. Наблюдение — запись с измерением: время ответа, число ошибок, трасса запроса или лог. Вывод — утверждение о связи между ними. Эти три слоя нельзя заменять друг другом.

Фраза «после включения кеша p95 снизился» описывает последовательность. Фраза «кеш снизил p95» уже утверждает причинность. Для второй фразы нужно знать входы, окно измерения, контрольное сравнение и альтернативные причины. OpenTelemetry разделяет traces, metrics и logs именно как разные сигналы: путь запроса, измерение во время работы и запись события. Один сигнал не заменяет остальные.

Не начинайте с красивого итога. Запишите симптом и цену ошибки. Затем зафиксируйте, какое изменение проверяется, какой механизм должен сработать и какое наблюдение его подтвердит. Если механизм нельзя описать одним-двумя предложениями, причинную связь пока рано считать рабочей гипотезой.

\"Схема
Схема помогает удержать границу между данными и выводом. Это иллюстрация метода, а не отчёт о production-системе.

Механизм причинности держится на сравнении

Рассмотрим учебный пример. Страница каталога долго ждёт ответ от API. Команда добавляет кеш на пять минут. Ожидаемый механизм таков: повторный запрос с тем же ключом читает локальное значение, не обращается к API и завершает работу быстрее. Это проверяемая гипотеза. Она не равна обещанию, что p95 улучшится во всей системе.

Минимальная модель должна назвать ключ кеша, срок жизни, ветку промаха и измеряемое событие. Нужны также условия, при которых сравнение честно. Если до изменения запросы шли в час пик, а после — ночью, число «до/после» ничего не доказывает. Если одновременно изменился размер ответа, источник трафика или лимит API, у результата появились конкурирующие объяснения.

function assessCase(input) {\n  const sameWindow = input.before.window === input.after.window;\n  const sameTraffic = input.before.trafficClass === input.after.trafficClass;\n  const mechanismObserved = input.after.cacheHits > 0 && input.after.apiCalls < input.before.apiCalls;\n  const effectObserved = input.after.p95Ms < input.before.p95Ms;\n\n  if (!sameWindow || !sameTraffic) {\n    return { status: 'stop', reason: 'comparison-is-not-comparable' };\n  }\n  if (!mechanismObserved || !effectObserved) {\n    return { status: 'stop', reason: 'mechanism-or-effect-is-not-observed' };\n  }\n  return { status: 'hypothesis-supported', confidence: 'bounded' };\n}

Код — учебный пример. Он не измеряет реальный сервис и не даёт production-результата. Его задача — показать отрицательный путь. При несовпоставимых окнах функция возвращает stop, а не достраивает причинность. При отсутствии признака механизма она тоже останавливается. Даже положительный статус остаётся ограниченным: он говорит, что гипотеза согласуется с входом, но не исключает все внешние причины.

Контрфакт отсекает удобное объяснение

Контрфактический вопрос звучит так: «Что должно было бы наблюдаться, если изменение не вызвало эффект?» Для кеша это может быть снижение p95 без роста cache hits, если одновременно упала нагрузка на API. Тогда результат совместим с другим объяснением. Второй вопрос: «Что должно измениться, если механизм работает?» Должны появиться cache hits, уменьшиться обращения к API и сохраниться сравнимый класс трафика.

Контрфакт не требует идеального эксперимента. Он требует назвать альтернативу до интерпретации результата. В зависимости от системы это может быть контрольная группа, поэтапное включение, повторное измерение в том же окне или сравнение запросов с одинаковыми ключами. Если ни один вариант недоступен, вывод становится слабее: «наблюдение совпало с гипотезой», а не «изменение вызвало эффект».

Диагностика причинности в инженерном кейсе
СимптомПричинаПроверкаДействие
После релиза p95 нижеИзменилось окно или распределение трафикаСравнить время, регион, endpoint и класс нагрузкиПонизить вывод и собрать сопоставимое окно
Ошибок меньше, cache hits нетСработал внешний fallback или изменился upstreamПроверить traces, логи ошибок и вызовы APIНе приписывать эффект кешу
Cache hits есть, API calls не снизилисьКлючи расходятся или кеш не участвует в ответеСопоставить ключ, TTL и ветку чтенияИсправить механизм или остановить кейс
До/после различаются сильноОдновременно изменились несколько факторовСоставить список изменений и найти контрольРазделить изменения либо назвать результат неоднозначным
Наблюдения неполныеСигнал не собирался в нужном местеПроверить покрытие метрик, логов и трассОписать пробел, не заполнять его предположением

Как писать кейс без ретроспективного proof

Сильный материал не скрывает отвергнутую ветку. Для кеша это может быть увеличение TTL, предварительная загрузка или изменение самого API. Назовите вариант и причину отказа только там, где есть запись. Если решения не было, пишите «рассматривался как учебная альтернатива», а не «команда отвергла его на проверке». Правдоподобная деталь без источника превращает пример в ложное свидетельство.

Разделяйте факты и условия применимости. Факт — в наблюдаемом окне было 120 cache hits. Условие — вывод относится только к запросам с тем же ключом и TTL. Ограничение — холодный кеш, ошибки сериализации и инвалидация не проверены. Такая запись переносима: другой инженер видит, какую часть можно повторить, а какую нельзя переносить без новых данных.

Для риска полезно использовать не одно число, а пару «воздействие × вероятность» и явно отмечать неопределённость. NIST SP 800-30 описывает оценку риска как работу с потенциальным событием, его последствиями и вероятностью, а также рекомендует фиксировать допущения и ограничения. Это не готовая формула для любого продукта. Это дисциплина, которая не даёт спрятать неизвестное за словом «результат».

Порядок проверки

  1. Запишите наблюдаемый симптом и цену ошибки одним абзацем.
  2. Назовите одно изменение и механизм, который связывает его с эффектом.
  3. Определите сигнал для механизма и сигнал для результата: например, cache hits и p95.
  4. Сделайте окна, трафик и границы сравнения сопоставимыми.
  5. Назовите хотя бы одну альтернативную причину и сформулируйте контрфакт.
  6. Проверьте отрицательный путь: что делает кейс при пропущенном сигнале или несовместимом сравнении.
  7. Снизьте силу вывода до уровня входных данных и явно запишите остаточный риск.
  8. Назовите критерий готовности и источник каждого внешнего факта.

Ограничения

Наблюдаемая корреляция не доказывает причинность, если система менялась сразу в нескольких местах. Даже контрольная группа может быть нерепрезентативной. Sampling может скрыть редкую ошибку. Метрика может быть правильно собрана, но измерять не тот пользовательский путь. Traces показывают маршрут запроса, но не объясняют бизнес-причину сами по себе. Logs фиксируют события, но без контекста их трудно сопоставить с запросом.

Учебный код также не заменяет нагрузочный тест, проверку инвалидации, анализ стоимости хранения и оценку отказа API. Не называйте его production-проверкой. Если данных нет, корректный результат — остановка с конкретным next action: собрать сигнал, выровнять окно, добавить контроль или отказаться от сильного claim. Отрицательный путь — часть механизма, а не признак незавершённости текста.

Критерий готовности

Кейс готов, когда читатель может ответить на четыре вопроса без доверия к автору: какой симптом наблюдали и чем грозила ошибка; какое изменение и механизм проверяли; какое сравнение отличает гипотезу от альтернативы; что произойдёт, если данных не хватит. Дополнительно у каждого результата должны быть границы окна, набор сигналов, условия применимости и явно названный остаточный риск.

Если хотя бы на один вопрос отвечает только предположение, кейс не должен переходить в формулировку «изменение улучшило систему». Оставьте более узкий вывод: «наблюдение совместимо с гипотезой при таких условиях». Это не ослабляет инженерную работу. Это сохраняет возможность проверить её снова и не превращает временную удачу в правило.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/039.json b/editorial/agent-rewrites/039.json new file mode 100644 index 0000000..4d33fc5 --- /dev/null +++ b/editorial/agent-rewrites/039.json @@ -0,0 +1,7 @@ +{ + "index": 39, + "slug": "editorial-2026-12-practice-portfolio-case", + "title": "Инженерный кейс: как связать симптом, решение и доказательство", + "excerpt": "Практический способ разобрать инженерную проблему: отделить наблюдаемый симптом от причины, сравнить варианты, проверить отрицательный путь и не приписать решению эффект без данных.", + "contentHtml": "

Запрос к API иногда занимает две секунды вместо двухсот миллисекунд. Пользователь видит пустой экран, оператор получает всплеск таймаутов, а команда увеличивает лимит ожидания. Цена ошибки выше самого запроса: задержка становится менее заметной, но причина остаётся, а зависшие соединения занимают пул.

Такой эпизод легко описать фразой: «добавили кэш, и экран ускорился». В ней смешаны симптом, причина, изменение и эффект. Если между ними нет наблюдений, читатель не отличит факт от догадки и не сможет повторить решение в другой системе.

Тезис: кейс должен показывать причинную цепочку

Хороший инженерный кейс связывает проверяемые звенья: входное условие, симптом, гипотезу, действие, наблюдение и границу вывода. Что случилось? Почему это объяснение правдоподобно? Что изменили? Что измерили? Что осталось неизвестным?

Сила вывода не может быть выше силы наблюдения. Лог подтверждает запись. Трасса подтверждает путь запроса и его длительность. Сравнение двух групп подтверждает различие между группами. Ни один источник сам по себе не доказывает, что изменение улучшило всю систему.

Механизм причинной цепочки

Начните с наблюдаемого симптома. Укажите маршрут, условие, временной диапазон и единицу измерения. «Медленно» недостаточно. «P95 запроса GET /portfolio вырос с 240 до 1900 мс при 20 параллельных запросах» уже задаёт объект проверки.

Отделите симптом от гипотезы. Симптом виден в логе или трассе. Гипотеза объясняет его и может оказаться неверной. Здесь гипотеза такая: задержка возникает на повторном получении одного профиля для каждой позиции портфеля, а не в базе данных. Проверка должна различить эти варианты.

Зафиксируйте варианты до выбора. Первый вариант — исправить цикл и передать профиль одним запросом. Второй — добавить короткий кэш на границе запроса. Третий — увеличить таймаут. Он может убрать ранний отказ, но не сокращает работу. Если не назвать этот путь, любое изменение легко принять за устранение причины.

Решение должно описывать механизм. «Добавили кэш» — слабая запись. «В пределах одного запроса сохраняем профиль по ключу пользователя; повторный вызов читает это значение; кэш живёт только во время обработки запроса» — проверяемый контракт. Из него следуют тесты, ограничения и способ наблюдения.

Пример: кэш только в пределах одного запроса

Ниже учебный пример на JavaScript. Он показывает форму локального кэша. Он не сообщает, что конкретная система получила ускорение. Данные, нагрузку и результат нужно измерять отдельно.

export async function loadPortfolio(userId, api) { const profile = new Map(); async function getProfile(id) { if (!profile.has(id)) profile.set(id, api.get('/profiles/' + id)); return profile.get(id); } const positions = await api.get('/portfolios/' + userId); return Promise.all(positions.map(async (position) => ({ ...position, owner: await getProfile(position.ownerId) }))); }

Карта создаётся внутри loadPortfolio. Поэтому два параллельных вызова не делят состояние. Значение сохраняется как promise, а не как готовый ответ. Два одновременных обращения к одному владельцу получают один запрос, даже если первый ещё не завершился.

Отрицательная ветка — часть механизма. Если поместить карту на уровень модуля, данные одного пользователя могут попасть в обработку другого. Если сохранять только готовый ответ, параллельные обращения создадут дубликаты. Если ключом сделать имя, разные идентификаторы сольются. Ошибка меняет не только скорость, но и корректность данных.

Симптом → причина → проверка → действие

Разбор инженерного кейса по наблюдаемым признакам
СимптомПричинаПроверкаДействие
P95 растёт вместе с числом позицийПовторный запрос профиляСравнить число позиций и запросов в одной трассеУстранить дубликаты внутри запроса
Таймаутов меньше, длительность та жеИзменён лимит, не механизмСопоставить длительность и число запросовВернуться к гипотезе о лишней работе
Появляются чужие данныеСостояние живёт дольше запросаПроверить ключ и два разных userIdПеренести хранилище внутрь обработчика
Скачки только при параллельной нагрузкеНе сохраняется promiseЗапустить два одинаковых вызова до завершения первогоСохранять общий promise и обработать ошибку

Иллюстрация причинной границы

\"Схема
Схема показывает порядок связи между наблюдением и выводом. Она иллюстрирует структуру разбора, а не измеренный результат конкретной системы.

Различайте стрелку «после» и стрелку «из-за». Изменение могло произойти до наблюдения, но этого мало для причинного вывода. Нужны одинаковые условия сравнения, источник данных и проверка альтернативных объяснений. Снижение задержки после включения кэша может совпасть с уменьшением нагрузки или прогревом соединений.

Порядок действий

  1. Запишите один симптом с маршрутом, условием, периодом и измерением.
  2. Отделите наблюдение от гипотезы и назовите альтернативную причину.
  3. Назовите варианты, включая путь, который меняет симптом, но не механизм.
  4. Опишите область жизни состояния, ключи, ошибки и параллельные вызовы.
  5. Сделайте минимальное изменение и сохраните исходное поведение для сравнения.
  6. Проверьте положительный путь: повторное чтение использует тот же promise или значение.
  7. Проверьте отрицательный путь: разные пользователи не делят данные, ошибка не оставляет битое значение, пустой список не вызывает лишних обращений.
  8. Сравните одинаковые показатели до и после в сопоставимых условиях.
  9. Запишите остаточный риск и сформулируйте вывод не шире найденных данных.

Что считать доказательством

Для учебного примера достаточно проверить область жизни и параллельность. Для реального кейса нужен источник наблюдений. Трасса должна содержать идентификатор запроса, длительность и ключевые внешние вызовы. Метрика должна иметь название, единицу, окно и условия сбора. Лог должен связывать ошибку с операцией и не раскрывать секреты.

Сравнение требует базовой линии. Если до изменения измеряли среднее, а после — P95, вывода о сравнении нет. Если объём запросов различался на порядки, различие может отражать нагрузку. Если менялись код, база и лимит одновременно, эффект нельзя надёжно приписать одному фактору.

Отрицательный результат тоже важен. Если число запросов не уменьшилось, кэш не достиг цели. Если длительность снизилась только в локальном запуске, этого мало для вывода о другой среде. Если тест обнаружил утечку между пользователями, изменение нужно остановить и исправить границу состояния.

Ограничения

Кэш внутри запроса не помогает между запросами и не заменяет общий кэш, если источник дорогой для всех клиентов. Он увеличивает память пропорционально числу ключей в одном ответе. Ошибка при загрузке профиля должна удалять сохранённый promise или завершать запрос по явному контракту.

Схема не решает проблемы устаревших данных, распределённой согласованности и лимитов внешнего API. Она подходит только тогда, когда обращения дублируют работу внутри одной операции, а область жизни результата совпадает с областью жизни запроса. Иначе локальный кэш скрывает проблему или создаёт новую.

Проверяемый критерий готовности

Кейс готов, если независимый читатель может назвать симптом, проверить гипотезу, воспроизвести положительный и отрицательный путь, увидеть источник измерения и понять, какой вывод запрещено делать. Для loadPortfolio минимальный критерий таков: запрос с двумя позициями одного владельца вызывает один запрос профиля; два разных вызова не делят карту; ошибка профиля не оставляет пригодное значение; в выводе нет обещания эффекта, которого не измеряли.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/040.json b/editorial/agent-rewrites/040.json new file mode 100644 index 0000000..fb58295 --- /dev/null +++ b/editorial/agent-rewrites/040.json @@ -0,0 +1,7 @@ +{ + "index": 40, + "slug": "editorial-2026-11-field-technology-evaluation", + "title": "Как сравнивать технологии, когда цена ошибки выше цены эксперимента", + "excerpt": "Сравнение технологий начинается не с рейтинга. Сначала нужно определить симптом, стоимость ошибки, критерии, измерение и границы вывода.", + "contentHtml": "

На встрече появляется таблица из двух технологий и итоговых баллов: 86 против 74. Через неделю никто не может ответить, откуда взялись числа. Не указаны версии, входные данные, число повторов и правило обработки разброса. Симптом простой: вывод выглядит точным, но его нельзя воспроизвести.

\n

Цена ошибки зависит от решения. Если команда выбирает библиотеку для короткого скрипта, потеря обычно ограничивается временем разработчика. Если выбор затрагивает данные, задержки, безопасность и обслуживание, ошибка переезжает в архитектуру. Она увеличивает стоимость миграции, усложняет диагностику и заставляет защищать решение, основанное на неизвестных предпосылках.

\n

Тезис: сравнивать нужно не названия, а условия

\n

Технология не бывает лучшей сама по себе. Сравнение имеет смысл только внутри задачи: с известными входами, версией, окружением и ограничением по стоимости ошибки. Рейтинг без этих условий смешивает измеряемый сигнал с предпочтением.

\n

Сначала отделите четыре вещи. Симптом показывает, что в текущем решении болит. Критерий описывает, что важно для новой альтернативы. Измерение даёт наблюдаемый сигнал. Риск показывает, чем обернётся неверный вывод. Вес критерия выражает приоритет. Он не доказывает свойство инструмента.

\n

Механизм: четыре слоя решения

\n

Предположим, сервис обрабатывает одинаковые сообщения и выбирает между двумя библиотеками очередей. Первая жалоба — время обработки скачет. Но этот симптом ещё не говорит, что другая библиотека быстрее. Причиной может быть размер сообщения, холодный процесс, блокировка записи, повторная доставка или неверный таймер.

\n

Поэтому вопрос формулируют уже: «Как меняется время обработки сообщения заданного размера при одинаковом числе потребителей и одинаковой политике подтверждения?» Вопрос задаёт границы. Он не обещает ответа до измерения.

\n

Критерии должны быть наблюдаемыми или проверяемыми отдельно. Например: p95 времени обработки, доля повторной доставки, сложность миграции, требования к операционному сопровождению. В эти критерии нельзя незаметно включить симпатию к знакомому API. Если удобство важнее задержки, его нужно назвать и объяснить.

\n

Измерение требует протокола. Зафиксируйте версии, размер и форму входа, число потребителей, длительность прогрева, число повторов и способ вычисления итогового значения. Низкое среднее не компенсирует длинный хвост, если именно хвост ломает пользовательский сценарий.

\n

Риск связывает наблюдение с последствием. Разница в 2 миллисекунды может быть важной для одного маршрута и бесполезной для другого. Ошибка в оценке миграции может стоить больше, чем разница в скорости. Взвешенная матрица помогает сделать этот обмен видимым, но не делает его объективным автоматически.

\n
\"Схема
Существующая схема показывает границы сравнения: критерии и условия измерения должны быть видимыми до вывода.
\n

Учебный пример: матрица для двух библиотек

\n

Ниже приведён учебный пример с условными именами queue-a и queue-b. Он не описывает конкретный продукт и не содержит данных реальной системы. Числа весов нужны только для демонстрации расчёта. Их нельзя выдавать за измеренный результат.

\n
Границы сравнения до получения чисел
КритерийВесЧто наблюдаемЧто пока неизвестно
p95 времени обработки35Миллисекунды на одинаковом входеЗначение для реальной нагрузки
Повторная доставка25Доля сообщений с повторомПричины отказов за пределами теста
Стоимость миграции25Список изменений и трудоёмкость шаговФактическое время команды
Сопровождение15Количество обязательных компонентовДолгосрочная нагрузка на операторов
\n

Сумма весов равна 100. Это проверка полноты, а не доказательство правильности шкалы. Для каждой строки задайте шкалу от 0 до 3 и опишите смысл каждого балла. Нельзя ставить ноль только потому, что данных ещё нет. Отсутствие наблюдения — это unknown, а не плохое значение.

\n

Пример кода с явной границей

\n
const criteria = [\n  { id: 'p95-latency', weight: 35, scoreA: 0, scoreB: 0 },\n  { id: 'redelivery', weight: 25, scoreA: 0, scoreB: 0 },\n  { id: 'migration-cost', weight: 25, scoreA: 0, scoreB: 0 },\n  { id: 'operations', weight: 15, scoreA: 0, scoreB: 0 }\n];\n\nfunction weightedScore(items, side) {\n  const weightTotal = items.reduce((sum, item) => sum + item.weight, 0);\n  if (weightTotal !== 100) return { status: 'stop-invalid-weights' };\n\n  const hasUnknown = items.some((item) => item[side] === 'unknown');\n  if (hasUnknown) return { status: 'stop-missing-observation' };\n\n  const score = items.reduce(\n    (sum, item) => sum + item.weight * item[side] / 3,\n    0\n  );\n  return { status: 'score-available', score };\n}\n\nconsole.log(weightedScore(criteria, 'scoreA'));\n// { status: 'score-available', score: 0 }
\n

Этот код показывает арифметику матрицы. Нулевые баллы здесь означают начальное состояние примера, а не качество queue-a. До подстановки наблюдений функция не выбирает библиотеку. Если один критерий получает unknown, она останавливается. Это важнее красивого итогового числа: неизвестность не должна маскироваться под результат.

\n

Для измерения времени используйте отдельный инструмент и одинаковые условия. Документация Python timeit прямо отделяет измерение небольших фрагментов от общего профилирования. Даже корректное измерение фрагмента не описывает всю систему. Оно отвечает только на вопрос, который вы ему задали.

\n

Симптом → причина → проверка → действие

\n
Диагностика неубедительного сравнения
СимптомПричинаПроверкаДействие
Есть победитель, но нет исходных чиселПредпочтение выдали за наблюдениеНайти входы, версии, протокол и результаты повторовУбрать вывод и вернуть статус unknown
Среднее улучшилось, а ошибки вырослиСмотрели один показательСравнить p95, p99, ошибки и повторы на одном наборе входовДобавить критерий надёжности и пересчитать решение
Числа меняются после каждого запускаНе зафиксированы прогрев и окружениеСверить версии, ресурсы, размер входа и число повторовУточнить протокол или признать результат несопоставимым
Неизвестное значение заменили нулёмПропуск смешали с плохим результатомПроверить источник каждого баллаИспользовать unknown и остановить итоговую оценку
Миграция выглядит дешёвой по одной строкеНе учли данные, откат и обучениеСоставить карту изменений, зависимостей и обратного путиСчитать стоимость диапазоном с явными допущениями
\n

Порядок действий

\n
  1. Опишите наблюдаемый симптом: что именно изменилось, где это видно и кому мешает.
  2. Назовите цену ошибки: задержка, потеря данных, трудоёмкость отката или дополнительное сопровождение.
  3. Сформулируйте один вопрос с измеримыми входами и двумя сравниваемыми альтернативами.
  4. Выберите критерии, задайте шкалы и веса; проверьте, что каждый вес имеет объяснение, а сумма равна 100.
  5. Зафиксируйте версии, окружение, входные данные, прогрев, повторы и формулу расчёта.
  6. Проведите одинаковые измерения для каждой альтернативы и сохраните исходные значения, а не только средний балл.
  7. Отдельно проверьте хвост распределения, ошибки, повторную доставку и стоимость изменений.
  8. Отметьте неизвестные поля как unknown. Не делайте вывод, пока критичный критерий не получил наблюдение.
  9. Сравните результат с порогом решения и укажите, какие условия ограничивают перенос вывода.
\n

Ограничения и отрицательный путь

\n

Матрица не заменяет нагрузочное испытание, анализ безопасности и разговор с владельцем данных. Она не предсказывает поведение другой версии, другого размера входа или другого окружения. Если библиотека показывает лучший p95 на маленьком сообщении, это не доказывает преимущество на больших сообщениях.

\n

Не всякий вопрос стоит превращать в число. Совместимость лицензий, доступность специалистов и возможность отката могут быть жёсткими ограничениями. Если альтернатива нарушает такое ограничение, её нельзя «спасти» высоким баллом скорости. Сначала применяют стоп-условие, потом сравнивают оставшиеся варианты.

\n

Отрицательный путь выглядит так: протокол не описывает входы; версии различаются; повторов мало; часть ошибок исключили; неизвестный показатель заменили нулём; или вывод шире, чем проверенный сценарий. В каждом случае корректное действие — остановиться, назвать пробел и сузить утверждение. Нельзя дорисовывать данные, чтобы таблица выглядела законченной.

\n

Проверяемый критерий готовности

\n

Сравнение готово к инженерному решению, если другой специалист без устных пояснений может найти симптом, цену ошибки, входы, версии, протокол, исходные значения, веса, ограничения и правило остановки. Для каждого итогового балла есть источник. Для каждого неизвестного поля стоит unknown. Вывод не выходит за пределы проверенного сценария.

\n

Быстрая проверка состоит из пяти вопросов: что измеряли; на каких входах; сколько было повторов; какие показатели могли ухудшиться; что произойдёт при ошибочном выборе. Если хотя бы на один вопрос нет ответа, таблица ещё не поддерживает решение. Это не недостаток оформления. Это граница достоверности.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/041.json b/editorial/agent-rewrites/041.json new file mode 100644 index 0000000..a15f71a --- /dev/null +++ b/editorial/agent-rewrites/041.json @@ -0,0 +1,7 @@ +{ + "index": 41, + "slug": "editorial-2026-11-mechanism-technology-evaluation", + "title": "Как сравнивать технологии, если данных пока мало", + "excerpt": "Практический способ отделить наблюдение от предпочтения: как зафиксировать критерии, остановить псевдоточный выбор и подготовить проверяемое решение.", + "contentHtml": "

Команда сравнивает два runtime. В одном демо вариант A отвечает быстрее. В другом варианте B проще выглядит в коде. Через неделю появляется таблица с баллами 8,7 и 7,9. В ней нет версии окружения, размера входа, повторений и стоимости перехода. Симптом ясен: число выглядит точным, но его нельзя связать с конкретной нагрузкой.

\n

Цена ошибки приходит после выбора. Команда переносит код на неподходящую границу, тратит время на обучение и поддержку, а затем объясняет сбой «шумом измерения». Вернуться трудно: исходные условия не записали, поэтому никто не знает, какой вывод нужно пересмотреть. Тезис статьи простой: технология не выбирает себя числом. Сначала нужно разделить вопрос, наблюдение, неопределённость и предпочтение.

\n

Четыре сущности, которые нельзя смешивать

\n

Вопрос описывает, что команда хочет узнать. Например: «какой вариант уменьшает работу по миграции при сохранении текущего API?». Наблюдение отвечает, что произошло в конкретных условиях. Это может быть время операции, число ошибок или объём памяти при названном входе.

\n

Неопределённость описывает, где наблюдение может не перенестись. Результат зависит от версии, данных, нагрузки и способа измерения. Предпочтение показывает, что команда считает более важным. Вес критерия 40 — это не свойство технологии. Это открытое решение людей, которое можно оспорить.

\n

Если поставить вес на место наблюдения, таблица скроет пробел. Если поставить одно измерение на место решения, команда выдаст локальный результат за универсальный. Если убрать неопределённость, читатель не поймёт, где действует вывод. Поэтому эти четыре слоя нужно хранить отдельно и проверять по отдельности.

\n
\"Граница
Схема показывает пространство компромиссов. Точка победителя не появляется без согласованных входов, измерения и правил интерпретации.
\n

Таблица диагностики: симптом → причина → проверка → действие

\n
Проверка инженерного сравнения до выбора технологии
СимптомПричинаПроверкаДействие
Есть итоговый балл, но нет входных условийЛокальное наблюдение выдали за общий результатНайти версию, вход, нагрузку и границу операцииУбрать итог и записать недостающие условия
Веса появились после демоПредпочтение подогнали под понравившийся результатСпросить, кто и до измерения утвердил критерииВернуть веса на обсуждение и сохранить объяснение
В отчёте написано «одинаковая среда»Ключевые параметры спрятали в общей фразеРаскрыть версии, зависимости, входы и пределы времениОстановить сравнение до явной конфигурации
Есть среднее, но нет разбросаНеопределённость потеряли при агрегацииПроверить повторения и правило обработки выбросовПоказать вариацию или назвать её неизвестной
Один вариант назван победителем во всех условияхКомпромисс заменили универсальным рейтингомПроверить, какие критерии ухудшаются у победителяОписать trade-off и границу применимости
\n

Измерение начинается с вопроса

\n

Слово «производительность» слишком широкое. Оно может означать задержку, пропускную способность, расход памяти или время восстановления. Сначала назовите одну операцию и её границу: например, «время сериализации объекта размером 1 МБ при версии X». Затем укажите, какое решение это наблюдение должно поддержать.

\n

После этого зафиксируйте вход. Запишите версию runtime, версию зависимостей, тип процессора, размер данных, число повторений и правило очистки окружения. Не используйте формулировки «реальная нагрузка» и «одинаковая машина» без расшифровки. Они создают видимость контроля, но не дают читателю повторить проверку.

\n

Среднее без разброса тоже не даёт уверенности. Если один прогон занял 10 мс, а другой 100 мс, запись «среднее 55 мс» скрывает важное свойство системы. Нужны повторения, диапазон или другая заранее выбранная форма описания вариации. Если повторений не было, напишите «не измерено». Это честнее, чем нулевой разброс.

\n

Вес критерия — договорённость, а не метрика

\n

Взвешенная матрица помогает сделать спор видимым. Пусть команда оценивает пригодность, стоимость внедрения и эксплуатацию. Она может назначить веса 40, 35 и 25. Числа задают порядок внимания. Они не говорят, что пригодность в 1,6 раза важнее эксплуатации в объективном смысле.

\n

Каждый вес должен иметь вопрос-владелец. Для пригодности спросите, какую границу задачи обязан закрыть вариант. Для стоимости внедрения — какие обучение, миграция, документация и обратимость входят в расчёт. Для эксплуатации — кто будет замечать отказ и сколько времени есть на восстановление.

\n

Сумма 100 удобна как контроль записи. Она не превращает шкалу в физическую величину. Оценка 3 не означает, что вариант в три раза лучше оценки 1. Если шкала порядковая, так и пишите. Её задача — поддержать разговор о приоритетах, а не создать научный вид.

\n

Учебный пример: остановить скрытую конфигурацию

\n

Ниже — учебный JavaScript-пример. Он не запускает технологии, не читает файлы и не получает данные из среды. Функция проверяет только структуру заранее заданного объекта. Имена вариантов условны. Код показывает отрицательный путь: если конфигурация скрыта, функция не выдаёт победителя.

\n
function assessEvaluation(input) {\n  const required = ['question', 'alternatives', 'criteria', 'measurement'];\n\n  if (!required.every((key) => key in input)) {\n    return { status: 'stop', reason: 'missing-field' };\n  }\n\n  const totalWeight = input.criteria.reduce((sum, item) => sum + item.weight, 0);\n  const measurementIsVisible = Boolean(\n    input.measurement.input &&\n    input.measurement.version &&\n    input.measurement.repetitions > 0\n  );\n\n  if (totalWeight !== 100) {\n    return { status: 'stop', reason: 'invalid-weight-total' };\n  }\n\n  if (!measurementIsVisible) {\n    return { status: 'stop', reason: 'hidden-measurement' };\n  }\n\n  return { status: 'ready-for-review', result: 'no-winner' };\n}\n\nconsole.log(assessEvaluation({\n  question: 'compare serialization cost for a fixed input',\n  alternatives: ['runtime-a', 'runtime-b'],\n  criteria: [\n    { name: 'fit', weight: 40 },\n    { name: 'adoption-cost', weight: 35 },\n    { name: 'operation', weight: 25 },\n  ],\n  measurement: { input: '1 MB JSON', version: 'fixed', repetitions: 0 },\n}));\n// { status: 'stop', reason: 'hidden-measurement' }
\n

В примере веса заполнены, но число повторений равно нулю. Поэтому код возвращает stop. Он не угадывает результат и не заменяет отсутствующее измерение единицей. Статус ready-for-review тоже не означает, что технология выбрана. Он означает только, что структура прошла локальные проверки и может перейти к отдельному обсуждению данных.

\n

Это важное ограничение автоматизации. Проверка полей ловит пропуск, но не знает, подходит ли вход реальному пользователю. Она не проверяет корректность бенчмарка, статистический метод, права доступа и стоимость поддержки. Для этого нужны отдельные владельцы и доказательства.

\n

Как читать trade-off

\n

Сравнение редко даёт вариант, который лучше по всем осям. Быстрый runtime может потребовать больше обучения. Инструмент с простой миграцией может усложнить диагностику. Библиотека с хорошими метриками может ограничить формат данных. Такие отношения образуют границу компромиссов.

\n

Поэтому не спрашивайте «кто победил вообще». Спросите, какой компромисс допустим для данной операции. Если миграцию нельзя откатить за рабочее окно, стоимость обратимости получает больший вес. Если операция чувствительна к задержке, важнее зафиксировать задержку именно этой операции, а не пересказывать общий результат демо.

\n

Проверяйте чувствительность решения. Измените один вес и посмотрите, меняется ли порядок вариантов. Если небольшое изменение переворачивает вывод, решение хрупкое. Это не доказывает, что оно неверно. Это показывает, что нужно уточнить критерии и границы данных. Без реальных оценок такая проверка остаётся подготовкой, а не доказательством устойчивости.

\n

Порядок действий

\n
  1. Опишите наблюдаемый симптом: что сравнение скрывает, где это видно и сколько стоит ошибочный выбор.
  2. Сформулируйте один вопрос и назовите минимум две альтернативы. Не называйте вариант победителем до проверки условий.
  3. Разделите критерии на пригодность, стоимость внедрения и эксплуатацию либо на другие явно объяснённые группы.
  4. Зафиксируйте владельца каждого критерия и веса до получения результата. Проверьте, что сумма весов равна 100.
  5. Опишите вход, версию, окружение, операцию, повторения и правило обработки вариации.
  6. Сохраните неизвестные значения как неизвестные. Не подставляйте ноль, среднее из одного прогона или оценку из памяти.
  7. Проверьте отрицательный путь: отсутствующее поле, скрытую конфигурацию, неверную сумму и запрещённый положительный вывод.
  8. Сравните варианты по каждому критерию отдельно. Затем запишите компромисс, границу применимости и решение уполномоченного владельца.
  9. После изменения проверьте тот же сигнал на той же границе. Если условие изменилось, это новый замер, а не продолжение старого.
\n

Ограничения и отрицательный путь

\n

Этот механизм не выбирает язык, runtime или базу данных. Он не определяет допустимую нагрузку и не выдаёт статистическую значимость. Он не заменяет security review, оценку лицензий, accessibility-проверку, финансовую модель и план отката.

\n

Учебная матрица может помочь увидеть пробел, но не создаёт production-результат. Не вставляйте в неё реальные секреты, идентификаторы клиентов и ссылки на закрытые панели. Если нужно сравнить рабочие системы, сначала получите разрешение на данные и опишите протокол. Пока этого нет, допустим только структурный разбор.

\n

Отрицательный путь должен оставаться нормальным исходом. Неверный вес возвращает вопрос к критериям. Скрытый input возвращает вопрос к конфигурации. Большой разброс возвращает вопрос к нагрузке и повторениям. Наличие stop не означает провал команды. Оно показывает, что следующий вывод пока нельзя защищать.

\n

Критерий готовности

\n

Сравнение готово к решению, когда читатель может назвать операцию, вход, версии, повторения, критерии и веса. Для каждого числа есть источник и правило интерпретации. Для каждого неизвестного поля есть явная отметка и следующий способ проверки. Для каждого варианта описаны сильная сторона, цена внедрения, эксплуатационный риск и условие отката.

\n

Готовность не равна строке «выбран вариант B». Она означает, что решение можно оспорить по частям: отдельно проверить вход, отдельно пересмотреть вес, отдельно повторить измерение. Если хотя бы один слой скрыт, корректный результат — stop, а не красивый рейтинг.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/042.json b/editorial/agent-rewrites/042.json new file mode 100644 index 0000000..c7b2ea9 --- /dev/null +++ b/editorial/agent-rewrites/042.json @@ -0,0 +1,7 @@ +{ + "index": 42, + "slug": "editorial-2026-11-practice-technology-evaluation", + "title": "Как сравнивать технологии, когда ошибка стоит дороже прототипа", + "excerpt": "Практический способ сравнить технологии по задаче, цене перехода и эксплуатации: отделить факты от предпочтений, проверить отрицательный путь и не принять пустую ячейку за нулевой балл.", + "contentHtml": "

Команда выбирает новую технологию по удачному демо. Через несколько недель выясняется, что демо не учитывало миграцию данных, обучение, права доступа и поддержку редкого сбоя. Прототип работает, а основная система ещё не готова его принять. Люди переключаются на ручной разбор, релиз откладывается, а возврат к прежнему решению становится дороже с каждым изменением.

\n

Цена ошибки здесь не равна цене лицензии или времени на первый запуск. Она включает работу, которую не записали в сравнении: перенос состояния, изменение контрактов, настройку наблюдения, обучение дежурных и обратный переход. Ошибка становится дорогой ещё и потому, что таблица с итоговым баллом выглядит убедительно. Она скрывает, какие данные измерили, какие предположили и какие критерии добавили после просмотра результата.

\n

Тезис: сравнение технологии — это проверка решения, а не конкурс инструментов. Хорошая матрица не обещает объективного победителя. Она показывает границу задачи, цену внедрения, эксплуатационный риск, качество evidence и условия, при которых вывод перестаёт действовать.

\n

Механизм: четыре разных типа утверждений

\n

Сначала разделите четыре сущности. Вопрос описывает, что нужно узнать. Наблюдение фиксирует то, что действительно получили при заданных условиях. Неопределённость показывает, где результат может измениться. Предпочтение задаёт важность критерия для конкретного решения. Эти сущности связаны, но не заменяют друг друга.

\n

Вес 35 не доказывает, что переход дешевле. Балл 3 не доказывает, что технология подходит вашему коду. Слово «надёжная» не заменяет границу отказа и способ проверки. Если в клетке нет входных данных или метода измерения, там должно стоять unknown, а не аккуратный ноль. Ноль означает известное плохое свойство. Пустое значение означает, что команда ещё не знает, что именно проверять.

\n
const decision = {\n  problem: 'снизить стоимость поддержки очереди',\n  alternatives: ['runtime-a', 'runtime-b'],\n  criteria: [\n    { id: 'fit', weight: 40, evidence: 'unknown' },\n    { id: 'adoption', weight: 35, evidence: 'unknown' },\n    { id: 'operations', weight: 25, evidence: 'unknown' }\n  ],\n  status: 'plan-only',\n  winner: null\n};
\n

Этот фрагмент — учебный пример структуры, а не результат сравнения. Имена альтернатив условны. В нём нет запуска, производственного журнала, benchmark и рекомендации к внедрению. Поле winner: null защищает от перехода от намерения к утверждению. В настоящей системе такой объект должен дополниться владельцем решения, версией входных данных и разрешённым способом получить evidence.

\n

Как оценить пригодность

\n

Начинайте не с названия инструмента, а с границы задачи. «Нужна современная платформа» не позволяет проверить результат. «Нужно обрабатывать повторную доставку сообщения с сохранением порядка внутри одного ключа» уже задаёт вопрос к контракту и к отказам. Для каждой альтернативы запишите, какие части задачи она покрывает напрямую, а какие потребуют адаптера, собственной логики или изменения модели данных.

\n

Пригодность нельзя свести к числу функций в документации. Важен путь ошибки. Если операция прерывается после записи в одно хранилище и до подтверждения в другом, кто обнаружит рассогласование? Можно ли повторить действие без дубликата? Где живёт идентификатор операции? Если технология отвечает только на happy path, её высокий балл создаёт ложное чувство готовности.

\n

Стоимость внедрения — не сноска

\n

Cost of adoption состоит из нескольких работ. Назовите их отдельно: обучение команды, изменение кода, перенос данных, интеграция с инструментами сборки, наблюдение, документация, дежурство и обратимость. Не нужно сразу превращать список в финансовую модель. Нужно сделать скрытую работу видимой и назначить владельца каждого неизвестного пункта.

\n

Особенно опасна фраза «миграция простая». У неё нет проверяемого смысла, пока не названы объём данных, допустимое окно простоя, схема отката и критерий сохранности. Учебное сравнение может отметить эти поля как unknown. Оно не имеет права подставить среднюю оценку из другого проекта: другая версия, команда или форма данных меняет стоимость перехода.

\n

Эксплуатация начинается после успешного теста

\n

Технологию будет поддерживать не автор демо, а дежурная команда. Поэтому проверяйте не только пропускную способность, но и обнаружение отказа, восстановление, диагностику и обновление. Уточните, какие метрики доступны, какие события связываются одним идентификатором и что увидит оператор при частичном сбое.

\n

Фраза «работает стабильно» не является наблюдением. Нужны условия: версия, вход, длительность, нагрузка, число повторов и правило интерпретации. Без них цифра переносится на чужой контекст без основания. Если измерение ещё не разрешено или его конфигурация не описана, корректное действие — остановить сравнение, а не придумать результат.

\n
\"Матрица
Матрица удерживает критерии и веса в одном месте. Она не содержит фактических баллов и не выбирает технологию без evidence.
\n

Симптом → причина → проверка → действие

\n
Диагностика ошибок в сравнении технологий
СимптомПричинаПроверкаДействие
У каждой альтернативы есть точный итоговый баллНеизвестные данные заменили числамиПопросить вход, метод и источник каждого баллаВернуть ячейку в unknown и остановить итог
Победитель меняется после каждого обсужденияВес критерия выбран после результатаСравнить версии матрицы и время изменения весаЗафиксировать причину веса до новых наблюдений
Демо успешно, но миграция не оцененаПроверяли happy path, а не границу переходаОписать данные, откат, простой и владельцаДобавить отдельный adoption-критерий
Оператор узнаёт об отказе от пользователяЭксплуатацию приняли за наличие метрикВоспроизвести частичный сбой и пройти alert pathПотребовать сигнал, runbook и срок реакции
«Одинаковые условия» нельзя повторитьКонфигурация скрыта в окруженииПроверить версии, входы, повторения и лимитыНе называть запуск benchmark до фиксации условий
\n

Как не спутать вес и evidence

\n

Вес отвечает на вопрос «насколько этот критерий важен для решения». Evidence отвечает на вопрос «что мы наблюдали и насколько этому можно доверять». Веса 40, 35 и 25 могут быть полезной учебной конфигурацией, если команда явно объяснила приоритеты и понимает, что это не измерение. Они не превращают три неизвестных значения в доказательство.

\n

Проведите простую проверку чувствительности только после появления разрешённых данных: измените один вес в заранее заданном диапазоне и посмотрите, меняется ли порядок альтернатив. Если результат меняется от небольшого сдвига, решение зависит от предпочтения и должно так и сообщать. Если результат не меняется, это не доказывает универсальность. Он лишь устойчив к проверенному диапазону при тех же входах.

\n

Учебный валидатор и отрицательный путь

\n
function validatePlan(plan) {\n  if (plan.criteria.some(item => item.weight == null)) {\n    return { status: 'stop-missing-weight' };\n  }\n  if (plan.criteria.some(item => item.evidence === 'unknown')) {\n    return { status: 'stop-no-evidence' };\n  }\n  if (plan.winner != null && plan.status !== 'measured') {\n    return { status: 'stop-unearned-winner' };\n  }\n  return { status: 'ready-for-review' };\n}
\n

Валидатор — учебный код. Он не заменяет статистический анализ, архитектурное ревью и контроль доступа. Его задача уже: не дать форме выдать план за измерение. Если отсутствует вес, он возвращает stop-missing-weight. Если evidence неизвестен, он возвращает stop-no-evidence. Если появился победитель без статуса измерения, он возвращает stop-unearned-winner. Положительный статус означает только готовность к следующему ревью, а не разрешение на rollout.

\n

Порядок действий

\n
  1. Опишите наблюдаемую проблему и цену ошибки без названия любимой технологии.
  2. Задайте одну границу решения: данные, контракт, операцию или путь отказа.
  3. Назовите альтернативы и исключите варианты, которые не могут пройти эту границу.
  4. Разделите критерии на пригодность, стоимость перехода и эксплуатацию; добавьте только то, что влияет на решение.
  5. Запишите вес каждого критерия и причину веса до появления результата.
  6. Для каждого будущего измерения укажите входы, версию среды, метод, повторы и правило трактовки неопределённости.
  7. Отделите неизвестное от плохого результата. Не заменяйте отсутствующие данные нулём.
  8. Проверьте отрицательный путь: частичный сбой, откат, потеря наблюдения и невозможность повторить условия.
  9. Передайте матрицу на ревью с явным статусом: план, измерение или решение. Не смешивайте статусы.
\n

Когда сравнение нужно остановить

\n

Остановитесь, если критерий не имеет владельца и метода проверки. Остановитесь, если конфигурация измерения скрывает версии, входы или лимиты. Остановитесь, если веса появились после того, как стал виден удобный результат. Остановитесь, если таблица требует назвать победителя, хотя наблюдений нет. Такой stop не означает, что технология плоха. Он означает, что вопрос ещё не готов к честному ответу.

\n

Есть и отрицательный путь после внедрения. Если стоимость поддержки превысила исходное допущение или оператор не может восстановить систему в заданное время, не защищайте первоначальный выбор суммой баллов. Зафиксируйте, какое условие нарушилось, ограничьте дальнейшее распространение решения и проверьте обратимость. Матрица нужна для пересмотра, а не для оправдания уже потраченной работы.

\n

Ограничения

\n

Матрица не оценивает всё. Она не заменяет проверку безопасности, лицензий, юридических требований, доступности специалистов и совместимости с конкретной инфраструктурой. Она не создаёт данные, которых нет, и не переносит benchmark из одного окружения в другое. Она также не решает конфликт приоритетов сама: владелец решения должен объяснить, почему одна цена важнее другой.

\n

Сумма баллов может упростить разговор, но скрывает форму trade-off. Альтернатива с меньшей ценой перехода может требовать больше ручной поддержки. Альтернатива с лучшим happy path может хуже вести себя при восстановлении. Если один критический отказ недопустим, его нельзя компенсировать высокими баллами по второстепенным критериям. Задайте veto-условие отдельно.

\n

Проверяемый критерий готовности

\n

Сравнение готово к решению, когда другой инженер может восстановить его без устного контекста: видит проблему и границу; знает альтернативы; понимает смысл каждого критерия и веса; открывает источник каждого наблюдения; видит версии, входы, повторы и ограничения; может пройти отрицательный путь; знает владельца решения и срок пересмотра. До этого документ готов только к уточнению.

\n

Практическая финальная проверка проста. Удалите итоговый столбец и попросите коллегу ответить, какие данные ещё нужны для выбора. Если ответ не меняется, матрица действительно отделяет вопрос от результата. Если коллега вынужден защищать уже напечатанного победителя, таблица стала риторическим инструментом. Верните её к наблюдаемой проблеме.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/043.json b/editorial/agent-rewrites/043.json new file mode 100644 index 0000000..0f8c8f3 --- /dev/null +++ b/editorial/agent-rewrites/043.json @@ -0,0 +1,7 @@ +{ + "index": 43, + "slug": "editorial-2026-10-field-code-review-standard", + "title": "Code review: как не пропустить риск изменения контракта", + "excerpt": "Форматирование занимает строки в комментариях, а необратимое изменение контракта остаётся без проверки. Разбираем порядок review, который связывает симптом, evidence, отрицательный путь и решение.", + "contentHtml": "

В pull request меняют поле ответа с обязательного на nullable. В комментариях спорят о названии функции, порядке импортов и длине строки. Через неделю старый клиент падает на пустом значении. Ошибка возникла не в синтаксисе. Review проверил видимый diff, но не проверил границу контракта. Цена такого пропуска — аварийный откат, срочный выпуск совместимости и потеря времени у команды, которая теперь ищет всех потребителей вслепую.

\n

Тезис простой: code review должен связывать каждый существенный риск с проверяемым evidence. Если изменение меняет форму данных, одного чтения строк недостаточно. Нужно назвать потребителей, переходы состояния и путь возврата. Если evidence не хватает, reviewer формулирует точный вопрос и останавливает сильный вывод. Он не заменяет пробел догадкой и не маскирует его стилевым комментарием.

\n

Сначала отделите симптом от причины

\n

Симптом обычно виден в обсуждении: много мелких замечаний, спор о вкусе, длинный список предложений без одного вопроса о поведении системы. Это не доказывает плохой review. Но это сигнал проверить, не вытеснил ли стиль риск. Причина часто лежит за пределами изменённого файла: у поля есть другой consumer, миграция не обратима, а тест покрывает только новый путь.

\n

Начните с вопроса: что изменится для пользователя или соседнего сервиса, если этот diff попадёт в основную ветку? Ответ должен быть конкретным. «Станет современнее» не подходит. «Клиент, который не различает null и отсутствие поля, получит другой результат» — подходит. Следующий вопрос: каким артефактом это можно проверить? Это может быть schema delta, карта потребителей, тест на старую форму или явная инструкция отката. Список должен быть конечным.

\n

Механизм evidence map

\n

Удобно хранить review как короткую связку из пяти полей: change, risk, evidence, status и next action. Change называет один предмет. Risk описывает тип последствий, а не эмоциональную оценку. Evidence перечисляет входы, которыми можно проверить риск. Status показывает границу текущего вывода. Next action говорит, что должен сделать следующий владелец.

\n
change: fixed-nullable-discount-contract\nrisk: contract-migration\nevidence:\n  - fixed-schema-delta\n  - fixed-consumer-map\n  - fixed-rollback-note\nstatus: evidence-map-ready\nnext: ask-contract-owner-to-confirm-consumers
\n

Имена в примере учебные. Они не ссылаются на настоящий репозиторий, pull request или production-систему. Их задача — показать форму записи. В реальном review вместо них нужны ссылки на существующие артефакты и владелец каждого из них.

\n

Эта модель снижает силу вывода до уровня входных данных. Полная карта потребителей позволяет задать вопрос о совместимости. Она не доказывает, что каждый клиент уже обновлён. Schema delta показывает изменение формы. Она не доказывает, что миграция обратима. Rollback note описывает возможный путь возврата. Он не доказывает, что команда успеет выполнить его в аварии.

\n

Пример: nullable-поле и скрытый consumer

\n

Представьте учебный API ответа со скидкой. Было discount: number, стало discount: number | null. Сервер может собрать ответ, а новый тест может пройти. Но старый клиент способен сразу передать значение в арифметику или отрисовать его без ветки для null. Поэтому строка изменения ещё не является достаточным evidence.

\n
type Price = {\n  amount: number;\n  discount: number | null;\n};\n\nfunction total(price: Price) {\n  // Учебный пример: null нельзя молча считать скидкой.\n  if (price.discount === null) return price.amount;\n  return price.amount - price.discount;\n}
\n

В этом фрагменте проверяется только локальное правило функции. Он не проверяет всех клиентов и не показывает результат выпуска. Чтобы review был содержательным, нужно найти границу потребления: кто декодирует ответ, какие значения разрешает его схема, что делает старый код и как тестируется несовместимая форма. Если карты нет, правильный комментарий звучит так: «Нужен список потребителей поля и их поведение при null. Без него нельзя оценить охват изменения». Это вопрос, а не вердикт о качестве автора.

\n

Симптом → причина → проверка → действие

\n
Как переводить наблюдение в следующий проверяемый шаг
СимптомПричинаПроверкаДействие
Комментарии заполнены форматированиемРиск поведения не названСверить diff с целью и контрактомСнять style-only комментарии и задать один вопрос о последствиях
Поле стало nullableНе видны все consumersПроверить schema delta и карту потребителейЗапросить конкретный список клиентов и обработку null
Есть слово rollbackНе описано, что возвращаетсяСопоставить старую форму и переход состоянияПопросить шаг возврата и условие его применимости
Тест проходит только на новом ответеОтрицательный путь отсутствуетПодать старую форму и nullДобавить проверку отказа или безопасного значения
Автор просит approve при неполном inputВывод сильнее evidenceПроверить обязательные поля risk-классаОстановить review с перечнем недостающих данных
\n

Как выглядит отрицательный путь

\n

Надёжный стандарт должен объяснять остановку так же ясно, как положительный путь. Если отсутствует consumer map, статус — «недостаточно evidence», а действие — запросить только карту. Не нужно добавлять «вероятно безопасно» или искать потребителей по памяти. Если reviewer видит только изменение стиля, а риск относится к контракту, стилевой комментарий не закрывает проверку. Если risk class неизвестен, сначала нужно назвать его границы.

\n

Есть и другой стоп-сигнал: все обязательные артефакты перечислены, но итоговая фраза говорит «approve and merge». Полный набор входов не превращает учебную карточку в разрешение на слияние. В настоящем процессе approval зависит от полномочий, политики репозитория и результата остальных проверок. В записи review лучше разделять «evidence достаточно для следующего вопроса» и «изменение готово к merge».

\n
function nextReviewAction(review) {\n  if (!review.consumerMap) {\n    return { status: 'stop-insufficient-evidence',\n      action: 'request-consumer-map' };\n  }\n\n  if (review.risk === 'contract-migration' && review.decision === 'style-note') {\n    return { status: 'stop-style-displaces-risk',\n      action: 'request-contract-evidence' };\n  }\n\n  return { status: 'evidence-map-ready',\n    action: 'ask-owner-to-confirm-boundary' };\n}
\n

Код ограничен учебной проверкой объекта в памяти. Он не читает pull request, не запускает CI и не принимает решение о merge. Его ценность — в явных ветках. Каждая ветка показывает, какое условие отсутствует и что делать дальше. В production-автоматизации те же статусы потребуют отдельного контракта, тестов и владельца.

\n
\"Петля
Проверка должна замыкаться на evidence: неполный вход возвращает точный вопрос, а не уверенный вердикт.
\n

Порядок проверки

\n
  1. Назовите цель изменения одним предложением. Укажите, какая форма данных, граница доступа или переход состояния меняется.
  2. Выберите один риск-класс. Для nullable-поля это совместимость контракта, а не абстрактное «качество кода».
  3. Составьте короткий список обязательного evidence: schema delta, потребители, отрицательный путь и способ возврата, если он нужен.
  4. Проверьте каждый пункт по конкретному артефакту. Если ссылки нет или артефакт не отвечает на вопрос, пометьте пункт как отсутствующий.
  5. Сначала прогоните отрицательные ветки: нет карты потребителей, выбран только стиль, не назван risk class, вывод просит approval.
  6. Сформулируйте действие с одним владельцем и одним недостающим входом. Не отправляйте список предположений.
  7. После получения evidence повторите проверку границы. Убедитесь, что вывод не стал сильнее данных и что новый тест покрывает отказной путь.
\n

Ограничения

\n

Evidence map не заменяет архитектурное решение, security assessment или эксплуатационную проверку. Он не вычисляет severity, не назначает SLA и не доказывает отсутствие дефекта. Для миграции данных понадобятся отдельные вопросы о совместимости версий, объёме записей и восстановлении. Для security-риска понадобятся trust boundary, правило входа и наблюдаемый сценарий злоупотребления. Нельзя переносить набор полей из одного риска в другой без проверки.

\n

Стандарт также не делает review быстрым автоматически. Иногда карта потребителей дороже самого изменения. Это нормальная цена, если поле пересекает границу сервиса. Если изменение локально и контракт не меняется, достаточно меньшего набора evidence. Смысл стандарта не в максимальном числе проверок, а в соразмерности: риск определяет обязательные входы.

\n

Не следует превращать каждое замечание в блокирующее. Комментарий о названии может улучшить читаемость, но не должен изображать угрозу совместимости. И наоборот, отсутствие доказательства по контракту нельзя закрывать фразой «потом посмотрим». Разделяйте обязательное условие и полезное предложение.

\n

Проверяемый критерий готовности

\n

Review готов для передачи решения, когда выполнены четыре условия: цель изменения понятна; риск назван; каждый обязательный вход имеет проверяемый источник; отрицательный путь возвращает явное действие. Дополнительно проверьте, что итоговая формулировка соответствует данным. Если карта потребителей не полна, критерий не выполнен. Если evidence полон, это ещё не равно approval: это означает, что вопрос можно передать владельцу контракта с понятной границей.

\n

Практический тест можно выполнить на учебном объекте. Удалите consumer map — запись должна вернуть stop-insufficient-evidence. Замените проверку риска на style-only — запись должна вернуть stop-style-displaces-risk. Добавьте недопустимое слово approval — запись должна остановиться. Верните все поля и оставьте вывод ограниченным вопросом — запись должна пройти как готовая evidence map. Эти результаты проверяют механику примера, а не production-поведение.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/044.json b/editorial/agent-rewrites/044.json new file mode 100644 index 0000000..dd13d5f --- /dev/null +++ b/editorial/agent-rewrites/044.json @@ -0,0 +1,7 @@ +{ + "index": 44, + "slug": "editorial-2026-10-mechanism-code-review-standard", + "title": "Code review как механизм управления риском: evidence, stop и решение", + "excerpt": "Как отличить замечание о стиле от риска контракта, состояния или границы доверия, запросить проверяемое evidence и не выдать предположение за готовое решение.", + "contentHtml": "

В pull request меняется nullable-поле. Reviewer оставляет шесть комментариев о названиях и форматировании. Никто не спрашивает, какие клиенты читают новое значение, что получит старый клиент и как вернуть прежнюю форму. После слияния один потребитель начинает трактовать null как «скидки нет», а другой — как ошибку. Ошибка появляется не в строке с новым типом. Она появляется в границе контракта.

\n

Цена такого пропуска выше цены неудачного комментария. Команда тратит время на разбор несовместимого ответа, откатывает часть изменений и выясняет, кто владеет обратимостью. При этом review могло выглядеть аккуратно. Проблема не в том, что reviewer не заметил все дефекты. Проблема в том, что вывод оказался сильнее доступных фактов.

\n

Тезис: сначала ограничьте вывод

\n

Надёжное review связывает четыре элемента: наблюдаемый симптом, класс риска, нужное evidence и допустимое действие. Evidence — это проверяемое основание вывода: форма данных, список потребителей, переход состояния или правило входа. Если основание неполно, review останавливает вывод и называет недостающий факт. Такой режим называют fail-closed: пробел не превращается в «скорее всего безопасно».

\n

Эта схема не оценивает reviewer и не делает из checklist универсальную policy. Она помогает выбрать следующий вопрос. Изменение формы ответа требует проверить контракт. Новая ветка ошибки требует проверить состояние до и после неё. Проверка входа требует определить границу доверия и возможное злоупотребление. Один комментарий о стиле не закрывает ни одну из этих границ.

\n

Механизм: риск выбирает доказательство

\n

Contract risk возникает, когда меняется форма данных или ожидание потребителя. Назовите старую и новую форму. Затем перечислите категории потребителей. После этого опишите возврат к старой форме или честно укажите, что возврат невозможен.

\n

Operational risk возникает, когда меняется поведение во времени. Запишите состояние до ветки, событие, состояние после неё и результат повтора. Слова «retry» и «timeout» сами по себе ничего не доказывают. Нужно показать, повторяется ли побочный эффект и кто увидит отказ.

\n

Security risk возникает на границе доверия. Назовите доверенную сторону, правило входа и последствие нарушения. Непривычный diff ещё не означает уязвимость. Но отсутствие границы доверия не позволяет утверждать, что проверка входа защищает систему.

\n
\"Три
Схема показывает порядок вывода: определить риск, собрать нужные факты, остановиться при пробеле и только затем передать точный вопрос владельцу границы.
\n

Gate не обязан выдавать approve или reject. Его задача уже выполнена, если он не дал неполному input породить ложное решение. При полном наборе фактов reviewer всё ещё не доказывает работоспособность всей системы. Он получает право сформулировать узкий вопрос: например, «проверьте совместимость этих потребителей с новой формой».

\n

Симптомы и действия

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
В обсуждении много style-комментариев, но нет вопроса о данныхРиск границы не названСравнить старую и новую форму, найти потребителейОстановить вывод и запросить contract evidence
Есть обработчик ошибки, но непонятно, что будет при повтореНе описан переход состоянияЗаписать state до ветки, событие, state после и эффект повтораСформулировать operational question
Валидатор принимает вход, но доверие к источнику не определеноСмешаны проверка значения и security boundaryНазвать trust boundary, input rule и abuse consequenceПередать вопрос владельцу безопасности
Комментарий говорит «безопасно» после одного тестаВывод шире evidenceСверить утверждение с тем, что реально проверил тестЗаменить вердикт на ограниченный результат
\n

Учебный пример: остановка при неполной карте потребителей

\n

Ниже показана учебная ветка для изменения контракта. Она не читает pull request, репозиторий, CI, сеть или production. Функция получает обычный объект и возвращает статус. Такой пример объясняет механизм stop, но не проверяет совместимость реальных клиентов.

\n
const required = ['schemaDelta', 'consumerMap', 'rollbackNote'];\n\nfunction assessContractEvidence(input) {\n  const missing = required.filter((name) => !input[name]);\n\n  if (missing.length > 0) {\n    return {\n      status: 'stop-insufficient-evidence',\n      missing,\n      action: 'request-only-named-evidence'\n    };\n  }\n\n  return {\n    status: 'contract-question-ready',\n    action: 'ask-owner-to-check-compatibility'\n  };\n}\n\nconsole.log(assessContractEvidence({\n  schemaDelta: 'price: number -> number | null',\n  rollbackNote: 'restore previous response before consumer rollout'\n}));\n// missing: ['consumerMap']
\n

Вызов возвращает только имя отсутствующего evidence. Он не делает запрос к клиентам и не сообщает, что совместимость нарушена. Если добавить consumerMap, статус изменится на contract-question-ready. Это тоже не approval. Он лишь разрешает задать владельцу контракта конкретный вопрос.

\n

В реальном review названия полей должны описывать факты проекта. schemaDelta — это не слово «изменился API», а точная старая и новая форма. consumerMap — не список случайных сервисов, а граница поиска и категории потребителей. rollbackNote — не обещание отката, а описание старой формы, порядка возврата и условий, при которых возврат возможен.

\n

Почему стиль часто вытесняет риск

\n

Стиль легко обсуждать: строка видна, правило знакомо, исправление локально. Контракт требует контекста. Поэтому style-комментарий не плох сам по себе. Ошибка возникает, когда он закрывает обсуждение изменения поведения. Разделяйте уровни: обязательное правило форматирования исправьте локально, а риск контракта вынесите отдельным блоком с доказательством и владельцем.

\n

То же относится к тесту. Наличие теста не равно проверке нужного свойства. Unit-тест может подтвердить, что функция вернула null. Он не показывает, как это значение трактуют старые потребители. Тест перехода состояния может подтвердить ветку ошибки. Он не доказывает, что повтор не создаёт дубль, если побочный эффект выполняется до записи статуса.

\n

Stop и escalation — разные действия

\n

Stop означает, что данных недостаточно даже для точного следующего вопроса. Если неизвестен класс риска, сначала опишите границу изменения. Если для contract risk нет карты потребителей, запросите только её. Не добавляйте «проверьте всё» — такой запрос нельзя проверить и нельзя завершить.

\n

Escalation означает, что риск и нужное evidence названы, но решение принадлежит другой роли. Вопрос о форме ответа передают владельцу контракта. Вопрос о повторе побочного эффекта — владельцу состояния или эксплуатации. Вопрос о границе доверия — владельцу безопасности. Передача должна содержать риск, факты, точный вопрос и ограничение вывода. Она не должна приписывать получателю готовый диагноз.

\n

Слабый вывод здесь полезнее громкого. «Нужно проверить совместимость потребителей с новой nullable-формой» честнее, чем «все клиенты совместимы». «Нужно уточнить повтор операции после timeout» честнее, чем «retry безопасен». «Нужно привлечь владельца trust boundary» честнее, чем «уязвимость найдена».

\n

Порядок действий

\n
  1. Опишите наблюдаемый симптом: что изменилось в diff и какое поведение может увидеть пользователь, потребитель или оператор.
  2. Назовите одну границу риска: контракт, переход состояния или доверие к входу.
  3. Сопоставьте границе минимальное evidence и отделите факт от предположения.
  4. Проверьте каждую позицию по исходному коду, тесту, схеме или документу; не заменяйте её общим «выглядит нормально».
  5. Если позиция отсутствует, верните stop с точным именем missing evidence.
  6. Если набор полон, сформулируйте ограниченный вопрос и передайте его владельцу границы.
  7. Отдельно проверьте отрицательный путь: повтор, отказ, старый потребитель, недопустимый вход или невозможность возврата.
  8. Закройте review только после проверки того свойства, ради которого меняли код; style-заметки не выдавайте за доказательство поведения.
\n

Ограничения

\n

Эта модель не считает severity, не назначает SLA и не заменяет security-аудит, контрактные тесты, нагрузочные проверки или миграционный план. Она не доказывает отсутствие дефекта. Она ограничивает силу комментария доступными фактами.

\n

Одна матрица не покрывает весь домен. Для финансовой операции могут потребоваться идемпотентность и аудит. Для публичного API — версия и период совместимости. Для персональных данных — срок хранения и права доступа. Добавляйте такие строки, когда они принадлежат конкретной границе. Не превращайте review в ритуал, где каждый change получает одинаковый пакет документов.

\n

Учебный код выше намеренно мал. Он не моделирует распределённую транзакцию, не запускает миграцию и не возвращает production-метрики. Его проверяемое свойство уже: при отсутствии consumerMap функция возвращает stop и не объявляет совместимость доказанной.

\n

Критерий готовности

\n

Review готово, когда читатель может ответить на четыре вопроса без догадок: какой симптом привёл к проверке, какая граница риска затронута, какое evidence подтверждает следующий вопрос и какое утверждение запрещено делать. Для неполного набора виден точный stop. Для полного набора есть владелец вопроса и отрицательный путь. Если вместо этих ответов остаются «выглядит хорошо», «тесты зелёные» или «потом проверим», механизм ещё не сработал.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/045.json b/editorial/agent-rewrites/045.json new file mode 100644 index 0000000..91d42dd --- /dev/null +++ b/editorial/agent-rewrites/045.json @@ -0,0 +1,7 @@ +{ + "index": 45, + "slug": "editorial-2026-10-practice-code-review-standard", + "title": "Code review без шума: как проверить риск изменения", + "excerpt": "Практический стандарт для code review: сначала назвать границу изменения и цену ошибки, затем запросить нужное доказательство и остановиться, если сильный вывод пока не подтверждён.", + "contentHtml": "

В pull request может быть двадцать комментариев, но ни одного вопроса о данных, миграции или отказе. Reviewer исправляет имя переменной и форматирование. В это время изменение меняет nullable-поле, порядок переходов состояния или правило доступа. Симптом виден сразу: обсуждение длинное, а главный риск не назван. Цена ошибки — несовместимый потребитель, повторная операция после timeout или доступ к данным не той роли. Такой дефект часто обнаруживается уже после слияния, когда исправление требует обратной миграции или ручного восстановления.

\n

Code review снижает риск только тогда, когда связывает границу изменения с проверяемым доказательством. Комментарий должен отвечать на четыре вопроса: что меняется, чем это опасно, какой факт сузит неопределённость и какое действие допустимо сейчас. Если факта не хватает, reviewer должен остановить вывод, а не заполнять пробел догадкой.

\n

Тезис: сначала граница, потом замечание

\n

Разделите изменение на риск-классы. Contract risk возникает, когда меняется форма данных или ожидание потребителя. Operational risk появляется при изменении timeout, retry, состояния, очереди или наблюдаемости. Security risk затрагивает доверенную сторону, правило входа и последствие злоупотребления. Style-only ограничен читаемостью и не меняет поведения.

\n

Класс риска не равен severity. Он выбирает первый вопрос. Для изменения контракта нужен schema delta, карта consumers и путь возврата. Для изменения поведения нужны переходы состояния и failure mode. Для границы доступа нужна модель доверия и проверка запрещённого входа. Для локального стиля достаточно короткого объяснения, почему код станет понятнее.

\n
\"Матрица
Существующая схема помогает выбрать вопрос к изменению. Она не заменяет запуск тестов и не выдаёт вердикт о конкретном pull request.
\n

Механизм: evidence ограничивает силу вывода

\n

Evidence — это именованный факт, который другой инженер может проверить в пределах задачи. Ссылка на файл не всегда является evidence. Три изменённых файла показывают объём diff, но не доказывают, что перечислены все потребители. Тест с зелёным статусом показывает проход конкретного сценария, но не объясняет, что произойдёт при повторе после отказа.

\n

Свяжите каждый факт с вопросом. schemaDelta отвечает, какое поле изменилось. consumerMap показывает, кто читает старую форму. rollbackNote описывает, что происходит при возврате. failureMode задаёт отрицательный путь. Такая связь важнее количества ссылок: один точный артефакт может закрыть вопрос, а десять общих ссылок — нет.

\n

Reviewer не обязан принимать формулу «это только рефакторинг». Попросите назвать invariant — свойство, которое не должно измениться, — и способ его проверить. Если invariant не назван, scope остаётся гипотезой. Положительный вывод не открывается.

\n

Учебный пример: карточка риска

\n

Ниже — учебный пример на TypeScript. Он не читает репозиторий и не утверждает результат настоящего review. Функция проверяет только полноту входной карточки. Её задача — не найти дефект автоматически, а не дать написать «можно одобрять», когда отсутствует обязательная граница.

\n
type Risk = 'contract' | 'operational' | 'security' | 'style-only';\n\ntype ReviewCard = {\n  risk: Risk;\n  evidence: {\n    changeBoundary: string;\n    question: string;\n    verification: string;\n  };\n  requestedAction: 'comment' | 'stop' | 'handoff';\n};\n\nfunction assess(card: ReviewCard): string {\n  const required = [\n    card.evidence.changeBoundary,\n    card.evidence.question,\n    card.evidence.verification\n  ];\n\n  if (required.some((item) => item.trim() === '')) {\n    return 'stop-missing-evidence';\n  }\n\n  if (card.risk === 'contract' &&\n      card.requestedAction === 'handoff') {\n    return 'stop-contract-needs-consumer-map';\n  }\n\n  return card.requestedAction === 'stop'\n    ? 'stop-review-question'\n    : 'review-question-ready';\n}\n\nconst card: ReviewCard = {\n  risk: 'contract',\n  evidence: {\n    changeBoundary: 'discount is now nullable',\n    question: 'which consumers handle null?',\n    verification: 'trace each consumer and add the compatibility case'\n  },\n  requestedAction: 'stop'\n};\n\nconsole.log(assess(card));\n// stop-review-question
\n

В примере статус описывает следующий разговор, а не качество кода. Если reviewer не видит карту потребителей, он возвращает stop-contract-needs-consumer-map. Это отрицательный путь. Он полезнее общего комментария «нужно больше тестов», потому что называет недостающий факт и действие. Если карта полна, это всё равно не доказывает совместимость: нужно проверить перечисленные consumers и их обработку null.

\n

Симптом → причина → проверка → действие

\n
Диагностика замечаний в code review
СимптомПричинаПроверкаДействие
Много комментариев о стиле, риск не названВсе замечания получили один приоритетОтметить, меняется ли поведение или контрактВынести риск в отдельный комментарий
«Все клиенты совместимы» без спискаВывод подменил карту потребителейНайти владельцев и места чтения старой формыОстановить вывод и запросить consumer map
Тест зелёный, но retry не описанПроверен happy pathПроследить переход после timeout и повторной попыткиДобавить failure case или оставить stop
«Это только рефакторинг»Не назван invariantСравнить вход, выход и побочные эффекты до и послеПопросить invariant и способ проверки
Комментарий звучит как приказ, но не объясняет рискНормативное слово заменило аргументСпросить, какое свойство защищает требованиеПереписать комментарий через факт и действие
\n

Как писать сильный комментарий

\n

Начните с наблюдаемого факта. «Поле discount стало nullable» точнее, чем «изменение опасное». Затем назовите последствие: «клиент, который распаковывает значение без проверки, получит ошибку». После этого укажите проверку: «найдите все consumers старой схемы и покажите обработку null». Завершите действием: «до этой проверки не делаем вывод о совместимости».

\n

Один комментарий должен вести к одному действию. Не смешивайте обязательный вопрос о контракте с необязательным предложением переименовать функцию. Метка request-contract-evidence говорит о границе данных. Метка style-note говорит о читаемости. Автор может ответить на них разными изменениями и не потеряет важный риск среди косметических правок.

\n

Для security и эксплуатации требуйте владельца вопроса, если сами не можете проверить границу. Reviewer может заметить, что endpoint принимает роль из тела запроса, но не должен объявлять всю модель доступа безопасной без контекста авторизации. Точный комментарий выглядит так: «Роль приходит из недоверенного входа. Где сервер связывает её с authenticated user? Нужен путь проверки отрицательного случая». Это уже проверяемый вопрос.

\n

Порядок действий

\n
  1. Опишите симптом и цену ошибки одним предложением.
  2. Найдите границу изменения: данные, состояние, доступ, наблюдаемость или только стиль.
  3. Назовите риск-класс и invariant, который должен сохраниться.
  4. Сформулируйте один вопрос, ответ на который изменит решение.
  5. Запросите минимальное evidence: schema delta, consumer map, failure mode, access rule или другой конкретный артефакт.
  6. Проверьте отрицательный путь, а не только успешный сценарий.
  7. Разделите обязательное исправление, уточняющий вопрос и необязательную style-note.
  8. Если evidence отсутствует, верните точный stop без предположения о причине.
  9. Если evidence есть, сделайте только тот вывод, который оно поддерживает; совместимость, approval и выпуск проверяются отдельно.
\n

Ограничения стандарта

\n

Матрица не заменяет тестирование, threat model, дизайн-документ, миграционный план или наблюдаемость. Она не перечисляет всех возможных рисков и не назначает единственный порядок приоритетов. В маленьком style-only изменении запрос consumer map создаст ритуал без пользы. Поэтому классификация тоже должна опираться на invariant и границу поведения.

\n

Даже полная карта потребителей не доказывает, что каждый путь проверен. Она показывает область поиска. Результат зависит от статического анализа, динамической маршрутизации, конфигурации и скрытых интеграций. Если список получен неполным способом, так и напишите. Честный stop лучше уверенного «совместимо».

\n

Стандарт также не решает спор о продуктовой цели. Изменение может быть технически аккуратным, но не соответствовать требованиям продукта или политики безопасности. В таком случае reviewer фиксирует технические факты и передаёт вопрос владельцу решения. Code review не превращает полномочия reviewer в полномочия архитектора или владельца риска.

\n

Проверяемый критерий готовности

\n

Review-вопрос готов, если другой инженер может быстро назвать границу изменения, цену ошибки, нужное evidence, отрицательный путь и следующее действие. В тексте нет вывода сильнее, чем подтверждающие факты. Для каждого обязательного замечания указан владелец проверки или понятный способ её выполнить. Косметический комментарий не маскирует контрактный, эксплуатационный или security-риск.

\n

Проверьте это на одной карточке. Если читатель не может ответить, какой факт переведёт stop в следующий шаг, карточка не готова. Если ответ есть, это ещё не разрешение на слияние. Это только ясная граница между тем, что уже видно, и тем, что нужно проверить.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/046.json b/editorial/agent-rewrites/046.json new file mode 100644 index 0000000..47a4247 --- /dev/null +++ b/editorial/agent-rewrites/046.json @@ -0,0 +1,7 @@ +{ + "index": 46, + "slug": "editorial-2026-09-field-frontend-backend-boundary", + "title": "Когда UI и API расходятся: как найти нарушенную границу", + "excerpt": "Кнопка сообщает об успехе, экран показывает старое состояние, а API отвечает иначе. Разбираем четыре наблюдения, порядок проверки и границу, после которой нельзя делать выводы.", + "contentHtml": "

Пользователь нажимает «Сохранить», видит сообщение об успехе, а после обновления страницы получает старые данные. В DevTools один ответ имеет статус 202, в логе сервера виден 409, а компонент уже переключился в состояние ready. Такой дефект выглядит как одна проблема, но может возникнуть в четырёх местах: намерение превратилось в другой запрос, сервер вернул другой контракт, адаптер потерял ответ или store отрисовал старую версию.

Цена ошибки — не только неверный текст на экране. Пользователь повторяет действие и может создать дубль. Оператор ищет причину в backend, хотя ответ не дошёл до store. Команда добавляет повторный запрос и получает гонку. Каждый следующий workaround увеличивает число состояний, которые нужно объяснять.

Тезис статьи простой: границу frontend и backend нужно проверять по наблюдаемым переходам, а не по месту, где впервые заметили симптом. Сравните intent, request, response и render input. Только после этого выбирайте слой исправления. Если один снимок отсутствует, вывод о причине ещё не доказан.

Где именно ломается цепочка

Взаимодействие проходит несколько границ. UI формирует команду из ввода пользователя. Клиентский слой превращает команду в HTTP-запрос. Gateway или backend возвращает статус, заголовки и representation. Адаптер проверяет ответ и строит view model. Store принимает её с учётом версии и передаёт компоненту. Компонент выбирает данные и рисует экран.

Эти шаги не взаимозаменяемы. HTTP 204 означает успешное выполнение без representation в ответе. Он не доказывает, что компонент уже получил новую read model. HTTP 409 означает конфликт состояния или команды, а не сетевой timeout. Успешное завершение обработчика click не означает, что бизнес-операция завершилась.

Для расследования достаточно безопасных полей: имя операции, класс входа, шаблон маршрута, идентификатор запроса, статус, Content-Type, результат проверки схемы и версия view model. Не нужно писать в лог тело ответа целиком. Идентификатор и хэш нормализованного класса часто связывают события без копирования персональных данных.

\"Четыре
Сравнивайте соседние границы. Ответ из Network не доказывает, что тот же объект получил компонент.
Симптомы на границе frontend и backend
СимптомВероятная причинаПроверкаДействие
На экране старое значение, ответ содержит новоеАдаптер или store не принял responseСравнить response с render input и versionИсправить mapping, cache key или правило принятия версии
После повторного клика разные результатыГонка ответов или повторная mutationЗаписать request id и задержать один ответ в тестеВвести idempotency key или отбросить устаревшую версию
UI показывает готово, сервер вернул 409Клиент считает любой ответ успехомПроверить status и problem envelopeРазделить transport success и domain rejection
Поля исчезли после загрузкиНеверный Content-Type или форма bodyПроверить media type и schema validationОстановить адаптер на невалидном payload
curl и браузер дают разные наблюденияРазные cookie, кеш или render logicСопоставить запросы, затем проверить storeНе переносить вывод curl на UI без render input

Пример: ответ не равен состоянию экрана

Рассмотрим учебный пример. Сервер возвращает JSON с состоянием заказа. UI должен показывать кнопку retry, если синхронизация обязательна. Ошибка появляется, когда обработчик проверяет только факт получения ответа и ставит ready, не разобрав тело.

type OrderScreen = { status: 'ready' | 'blocked'; allowedActions: string[]; messageCode: string; version: number; }; function toScreenModel(response: Response, body: unknown): OrderScreen { if (!response.ok) throw new Error('domain-or-transport-failure'); const value = body as Partial&lt;OrderScreen&gt;; if (value.status !== 'ready' &amp;&amp; value.status !== 'blocked') throw new Error('invalid-screen-contract'); return { status: value.status, allowedActions: Array.isArray(value.allowedActions) ? value.allowedActions : [], messageCode: typeof value.messageCode === 'string' ? value.messageCode : 'unknown', version: typeof value.version === 'number' ? value.version : 0 }; }

Код показан только как учебная схема. Он не подтверждает поведение конкретного API и не заменяет схему валидации. В реальном приложении не следует молча подставлять version 0, если версия обязательна: лучше остановить переход и отправить безопасный диагностический сигнал.

Store должен принять модель только если она не старше уже принятой. Временная метка не решает задачу: часы процессов могут расходиться, а более поздний ответ может относиться к более раннему чтению. Версия, sequence number или серверное правило порядка дают проверяемое условие.

function accept(current: OrderScreen | undefined, next: OrderScreen) { if (current &amp;&amp; next.version &lt; current.version) return current; return next; }

Это учебный отрицательный путь: устаревший ответ не меняет экран. Если API не выдаёт версию, не выдумывайте её на клиенте. Сначала определите, допускает ли контракт чтение последнего состояния, нужен ли повторный fetch или достаточно локального подтверждения. Optimistic UI может показать, что нажатие принято. Он не должен выдавать это за подтверждённое состояние ресурса.

Что проверяет каждый инструмент

Network в браузере показывает запрос и ответ конкретного user agent. Он помогает проверить метод, маршрут, статус, заголовки и тело. Он не показывает, какой объект передали selector или memoized компоненту.

curl повторяет HTTP-обмен с указанными заголовками. Он не воспроизводит cookie policy браузера, отмену запроса при unmount и порядок двух ответов. Лог backend подтверждает обработку на сервере, но не подтверждает, что браузер получил тот же response. Snapshot DOM показывает итог, но не говорит, откуда пришло значение.

Учебная команда для чтения тестового ресурса:

curl --fail-with-body --silent --show-error -H 'Accept: application/json' -H 'X-Request-Id: req-test-42' 'https://api.example.test/orders/42' | jq '{status, allowedActions, messageCode, version}'

Здесь фиктивные host и идентификатор. Команда предназначена для чтения тестового ресурса. Не повторяйте mutation, пока не проверили идемпотентность и последствия. Если endpoint требует авторизацию, используйте тестовый токен с ограниченным сроком. Не помещайте секрет в shell history, статью или задачу.

Как отличить cache от race

Кеш обычно даёт повторяемость: один и тот же ключ возвращает прежнюю версию. Сравните request key, заголовки кеша, revision и источник данных. Не называйте кеш причиной, пока повторный запрос с новым ключом не меняет наблюдение.

Гонка зависит от порядка. Запрос A ушёл первым, B — вторым, но B вернулся раньше. Если store принимает ответы без проверки версии или актуальности запроса, A перезапишет более новое состояние. В тесте задержите только один ответ. Если результат меняется вместе с задержкой, гипотеза о race получила проверку.

Отдельно проверьте отмену запроса. Компонент мог размонтироваться, adapter мог получить AbortError, а локальный optimistic patch остался. В этом случае отсутствие response не доказывает отказ backend. Оно означает только, что текущий слой не получил наблюдаемого ответа.

Порядок расследования

  1. Запишите симптом, имя операции, класс безопасного входа и request id. Не начинайте с предположения о кеше.
  2. Снимите method, route template, статус и Content-Type. Для mutation сначала проверьте идемпотентность и не запускайте повтор вслепую.
  3. Проверьте response по контракту. Разделите transport failure, domain rejection и успешный ответ без representation.
  4. Сравните response с render input: status, allowed actions, message code и version.
  5. Если значения расходятся, проверьте adapter, cache key, optimistic patch и порядок ответов.
  6. Если значения совпадают, перейдите к selector, memoization, hydration или локальному состоянию компонента.
  7. Сформулируйте один следующий тест, который различает оставшиеся гипотезы. Меняйте код только после проверки.

Когда расследование нужно остановить

Остановитесь, если следующий вывод требует неполученных данных. Так бывает, когда нужен production body с персональными полями, закрытый лог или повторная команда с неизвестным эффектом. Попросите владельца системы дать redacted response, безопасный correlation id или воспроизводимый тестовый запрос.

Остановка — точная граница доказательства. Нельзя объявлять кеш виноватым, если у вас есть только скриншот экрана. Нельзя обвинять backend, если вы не проверили request. Нельзя чинить selector, если render input уже неверен.

Отрицательный путь важен и для автоматической проверки. Невалидный Content-Type должен остановить адаптер. Устаревшая version не должна менять store. 409 должен вести к прикладному сообщению, а не к общему «ошибка сети». Отсутствующий response должен иметь отдельный статус диагностики, а не маскироваться под stale UI.

Ограничения и критерий готовности

Протокол не заменяет distributed tracing, contract testing, авторизацию и security review. Он не решает проблему очереди одной HTTP-карточкой: для асинхронной команды нужны message id, статус обработки и правило повторов. Он также не разрешает логировать тело ответа целиком.

Критерий готовности проверяем так: для учебного сценария и отрицательного сценария можно связать intent, request, response и render input по безопасному идентификатору; невалидный ответ не меняет экран; устаревшая версия не перезаписывает новую; 409 получает отдельное прикладное состояние; команда может назвать следующий шаг или остановиться при нехватке данных. Это проверяемое свойство границы, а не обещание production-результата.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/047.json b/editorial/agent-rewrites/047.json new file mode 100644 index 0000000..67baa93 --- /dev/null +++ b/editorial/agent-rewrites/047.json @@ -0,0 +1,7 @@ +{ + "index": 47, + "slug": "editorial-2026-09-mechanism-frontend-backend-boundary", + "title": "Состояние экрана не угадывают по статусу: разделяем query, command и ошибку", + "excerpt": "Как провести границу между UI и API: отличить чтение модели от команды, разобрать 409, 422, 429 и 5xx и не показать пользователю состояние, которого сервер не подтвердил.", + "contentHtml": "

Кнопка показывает «Готово», но после обновления страницы заказ снова выглядит незавершённым. В другой версии того же дефекта любой отказ превращается в красный toast «Что-то пошло не так». Пользователь повторяет команду, оператор ищет причину по снимку интерфейса, а команда спорит, сломан ли браузер или API. Цена ошибки — дубликаты операций, потерянные изменения и неверное решение по инциденту.

\n

Причина обычно не в самом HTTP-вызове. UI смешивает три разных смысла: чтение representation, отправку команды и объяснение отказа. Надёжная граница оставляет право менять screen state только за проверенной моделью. Query читает данные. Command просит изменить состояние. Error envelope сообщает, почему переход не состоялся и какой следующий шаг допустим.

\n

Тезис: статус не является моделью экрана

\n

Статус HTTP сужает множество возможных решений, но не выбирает состояние компонента в одиночку. Ответ 200 может содержать неизвестный для клиента статус. Ответ 204 подтверждает отсутствие тела, но не говорит, какую локальную модель нужно строить. Ответ 409 может требовать перечитать ресурс. Ответ 422 может подсветить поле формы. Ответ 429 может разрешать повтор только после паузы. Универсальный обработчик «не 2xx — ошибка, 2xx — успех» стирает эти различия.

\n

Разделите контекст по последствиям. Query не должен менять доменное состояние. Command не должен объявлять новую screen model только потому, что сервер принял запрос. Error envelope должен содержать машинный тип проблемы и безопасные данные для следующего шага. Текст для пользователя — ответственность адаптера UI, а не строка, которую компонент извлекает из свободного detail.

\n
\"Матрица
Матрица связывает результат HTTP с разрешённым действием UI. Она не доказывает корректность конкретного API и не заменяет тесты.
\n

Механизм границы

\n

У каждой операции должны быть названы вход, форма результата и отрицательные переходы. Для query это обычно валидная representation или ошибка чтения. Для command возможны новая representation, 202 Accepted с идентификатором операции или 204 No Content. Эти ответы нельзя обрабатывать одной функцией. В 202 результат команды ещё не равен готовому состоянию ресурса. В 204 тела нет, поэтому запуск JSON parser — уже ошибка клиента.

\n

409 Conflict означает конфликт текущего состояния ресурса с запросом. Если версия записи устарела, UI может предложить перечитать данные и выбрать действие заново. Не стоит без изменения входа отправлять команду снова. 422 Unprocessable Content означает, что запрос синтаксически понятен, но содержимое не прошло прикладную проверку. Это путь к конкретному полю или правилу, а не сетевой сбой.

\n

429 Too Many Requests и 5xx могут быть временными, но их нельзя объединять в бесконечный retry. Политика зависит от идемпотентности команды, бюджета попыток, Retry-After и того, известен ли исход операции. Timeout особенно опасен: сервер мог принять команду, а ответ мог потеряться. В этом случае клиент не имеет права считать операцию не выполненной и безопасно повторять POST без договорённости о ключе идемпотентности.

\n

Учебный пример: адаптер ответа

\n

Ниже — ограниченный учебный пример. Он не обращается к сети, не проверяет реальную схему и не объявляет production-операцию успешной. Его задача — показать место, где transport result превращается в решение UI. В настоящем клиенте список допустимых статусов, problem types и действий должен следовать конкретному контракту API.

\n
type UiDecision =\n  | { kind: 'render'; model: ScreenModel }\n  | { kind: 'read-again'; problemType: string }\n  | { kind: 'field-error'; fields: Record<string, string> }\n  | { kind: 'retry-later'; retryAfterSeconds?: number }\n  | { kind: 'unknown'; reason: string };\n\nfunction decide(response: {\n  status: number;\n  body: unknown;\n  retryAfter?: number;\n}): UiDecision {\n  if (response.status === 204) {\n    return { kind: 'read-again', problemType: 'no-representation' };\n  }\n\n  if (response.status === 409 && isProblem(response.body)) {\n    return { kind: 'read-again', problemType: response.body.type };\n  }\n\n  if (response.status === 422 && isValidationError(response.body)) {\n    return { kind: 'field-error', fields: response.body.fields };\n  }\n\n  if (response.status === 429 || response.status >= 500) {\n    return { kind: 'retry-later', retryAfterSeconds: response.retryAfter };\n  }\n\n  if (response.status === 200 && isScreenModel(response.body)) {\n    return { kind: 'render', model: response.body };\n  }\n\n  return { kind: 'unknown', reason: 'response-does-not-match-contract' };\n}
\n

Важен отрицательный путь в конце. Неизвестный статус или форма тела не должны попадать в ветку render по умолчанию. Сгенерированный TypeScript-тип тоже не даёт такой гарантии: он описывает ожидаемый payload, но не проверяет фактический JSON во время выполнения. Runtime-проверка должна отделять корректную модель от данных, которые нельзя безопасно показать.

\n

Query, command и ошибка на одном сценарии

\n

Представим экран редактирования адреса. Query GET /orders/42 возвращает модель заказа и разрешённые действия. Пользователь отправляет command POST /orders/42/address. Если сервер возвращает 200 с новой моделью, адаптер может передать её в store. Если сервер возвращает 202, store получает состояние «операция принята» и идентификатор отслеживания. Если сервер возвращает 204, клиент перечитывает заказ по правилу, которое явно указано контрактом.

\n

Если версия заказа устарела, API возвращает 409 с problem type order.version-conflict. UI показывает, что данные изменились, и предлагает перечитать их. Если индекс адреса неверен, API возвращает 422 и привязку ошибки к полю. Если ограничение частоты сработало, UI не очищает форму и не создаёт вторую команду: он показывает ограниченное сообщение и ждёт разрешённый момент повтора. Во всех трёх случаях компонент не придумывает доменное состояние из одного числа.

\n

Симптом → причина → проверка → действие

\n
Диагностика границы между UI и API
СимптомПричинаПроверкаДействие
После 2xx экран показывает новое состояние, но после reload данные старыеCommand принята или завершилась без representationСверить статус, тело и момент повторного чтенияРазделить подтверждение команды и query
409 показывает общий сетевой toastВсе 4xx сведены к одной веткеПроверить problem type и текущую версию ресурсаПредложить перечитать или разрешить конфликт
422 не подсвечивает полеАдаптер читает только HTTP statusПроверить структуру ошибок и указатель поляПреобразовать код поля в модель формы
После 429 запросы идут без остановкиRetry стал общей реакцией на отказПосчитать попытки и проверить Retry-AfterВвести бюджет повторов и паузу
После timeout пользователь повторяет команду вручнуюНеизвестный исход назван отрицательнымПроверить idempotency key и способ узнать результатПеречитать операцию или показать безопасное ожидание
Неизвестный JSON отображается как готовый экранВалидация формы отсутствует или стоит после renderПроверить runtime schema до записи в storeОстановить render и передать техническую ошибку
\n

Порядок действий

\n
  1. Опишите наблюдаемый симптом и цену ошибочного перехода.
  2. Назовите операцию: query, command или получение результата фоновой операции.
  3. Зафиксируйте метод, маршрут, статус, Content-Type и безопасный request id.
  4. Определите, есть ли в ответе representation, problem detail или только подтверждение приёма.
  5. Опишите переходы для 2xx, 204, 409, 422, 429, 5xx и неизвестного ответа.
  6. Проверьте тело до того, как записывать его в screen state.
  7. Для command отдельно проверьте повтор после timeout и правило идемпотентности.
  8. Для 202 и 204 укажите, как клиент узнает актуальную модель.
  9. Добавьте тест на отрицательный путь: неизвестный статус, неверную форму или отсутствующий обязательный тип.
  10. Сопоставьте UI-действие с одним машинным кодом, а не со свободной строкой сообщения.
\n

Ограничения

\n

Эта схема не назначает единственный статус для каждой бизнес-операции. Один API может использовать 409 для конфликта версии, другой — отдельный прикладной код внутри 409. Решение должно быть закреплено в контракте и одинаково понято всеми клиентами. Problem Details задаёт форму переносимого описания ошибки, но не выбирает локализацию, право доступа, retry policy или безопасное содержание полей.

\n

Граница также не решает проблему stale data сама по себе. Кэш, очередь, реплика и фоновая обработка требуют своих версий и сигналов. Если command запускает асинхронную работу, одной HTTP-карточки мало: нужны идентификатор операции, статус её обработки и путь к итоговой representation. Не маскируйте очередь под мгновенный 200.

\n

Не всякое различие нужно превращать в новый тип. Для простого чтения достаточно строгой схемы и понятного error path. Но если UI должен показать разные действия, контракт обязан назвать эти действия или стабильные коды, а не заставлять клиента разбирать английский текст detail. Учебный классификатор выше не заменяет security review, нагрузочное испытание и проверку реальной реализации.

\n

Проверяемый критерий готовности

\n

Граница готова, если для каждого поддержанного ответа можно назвать четыре вещи: какая модель разрешена, какое действие получает UI, какое действие запрещено и как это проверяется. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, 409 не вызывает бесконечный retry, 422 связывает ошибку с полем, а неизвестный ответ останавливает render. Для command после timeout должен существовать отдельный путь узнать фактический результат.

\n

Если команда не может ответить на эти вопросы по контракту и тесту, исправление не завершено. Нельзя закрывать пробел общим toast или локальным флагом isSuccess. Готовность — это совпадение HTTP-семантики, проверенной модели и следующего действия пользователя. Только после этого компонент получает право менять экран.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/048.json b/editorial/agent-rewrites/048.json new file mode 100644 index 0000000..4c8534c --- /dev/null +++ b/editorial/agent-rewrites/048.json @@ -0,0 +1,7 @@ +{ + "index": 48, + "slug": "editorial-2026-09-practice-frontend-backend-boundary", + "title": "Ответ 200 не описывает экран: как проверить модель на границе UI и API", + "excerpt": "Клиент может получить успешный HTTP-ответ и всё равно показать неверное состояние. Разбираем порядок проверок, runtime-контракт и отрицательные пути для screen model.", + "contentHtml": "

Кнопка показывает «Готово». Запрос завершился с кодом 200. Пользователь нажимает «Изменить», а компонент падает: поле переименовали, список действий пришёл строкой или новый статус не попал в клиентский код. Иногда экран не падает. Он выбирает состояние по умолчанию и показывает устаревшую информацию.

\n

Цена ошибки выше исключения. Пользователь повторяет команду. Оператор ищет проблему в сети. Разработчик смотрит на типы, которые были верны во время сборки. На границе процесса уже лежит другой JSON. Если UI записал его в state без проверки, ошибка проявится только в редком сценарии.

\n

Тезис статьи прост: статус HTTP сообщает результат обмена, но не доказывает, что тело подходит конкретному экрану. Клиент должен проверить HTTP, определить наличие representation, разобрать JSON, проверить минимальную screen model и только потом передать данные компоненту. Для каждого шага нужен отрицательный путь.

\n

Что именно считается успехом

\n

У ответа есть несколько уровней смысла. Код 200 сообщает, что сервер обработал запрос успешно на уровне операции. Заголовок Content-Type заявляет формат тела. JSON parser проверяет синтаксис. Runtime-валидатор проверяет поля, которые нужны экрану. Ни один предыдущий шаг не заменяет следующий.

\n

Тело {\"status\":\"done\",\"allowedActions\":\"edit\"} может быть корректным JSON. Но экран, который знает только ready, pending и blocked, не может безопасно выбрать состояние для done. Строка вместо массива также не становится моделью от того, что в ней записано знакомое слово.

\n

Граница принадлежит адаптеру данных, а не JSX-компоненту. Компонент получает проверенную модель или явный результат ошибки. Он не должен угадывать неизвестный статус, подставлять пустой массив и считать это подтверждённым состоянием.

\n
\"Поток
Проверки идут от транспорта к экрану. Ошибка на любом шаге останавливает передачу данных в render state.
\n

Минимальная модель экрана

\n

Не копируйте в UI всю доменную сущность. Выпишите поля, по которым компонент принимает решение. Для экрана заказа это состояние, набор известных действий и код сообщения. У каждого поля есть тип, допустимые значения и действие при нарушении.

\n
Минимальная screen model для состояния заказа
ПолеДопустимое значениеРешение UIОтрицательный путь
statusready | pending | blockedвыбрать состояние экранаостановить render
allowedActionsмассив известных командпоказать разрешённые кнопкине создавать кнопку
messageCodeнепустая строкавыбрать локализованный текстпоказать безопасную ошибку
HTTP statusстатус операцииотделить успех от отказане строить модель только по числу
\n

Словарь статусов должен быть закрытым, пока команда не описала новый переход. Это не запрет на развитие API. Новый статус должен менять контракт, обработчик и тест. Молчаливый fallback скрывает изменение API и превращает его в случайный UI-дефект.

\n

Рабочий пример: проверяем ответ до render

\n

Ниже учебный пример на JavaScript. Списки статусов и действий выбраны для демонстрации. Они не описывают production API и не обещают production-результат. В реальном проекте их заменяет контракт конкретного endpoint.

\n
const statuses = new Set(['ready', 'pending', 'blocked']);\nconst actions = new Set(['retry', 'edit', 'cancel']);\nfunction validateScreenModel(value) {\n  const errors = [];\n  if (!value || typeof value !== 'object' || Array.isArray(value)) return { valid: false, errors: ['body-must-be-object'] };\n  if (!statuses.has(value.status)) errors.push('status-is-unknown');\n  if (!Array.isArray(value.allowedActions)) errors.push('allowedActions-must-be-array');\n  else if (value.allowedActions.some((item) => !actions.has(item))) errors.push('allowedActions-contains-unknown-action');\n  if (typeof value.messageCode !== 'string' || value.messageCode.length === 0) errors.push('messageCode-must-be-non-empty');\n  return { valid: errors.length === 0, errors };\n}\nasync function readScreen(response) {\n  if (response.status === 204) return { kind: 'empty-success', next: 'refetch-screen' };\n  if (!response.ok) return { kind: 'http-error', status: response.status };\n  if (!response.headers.get('content-type')?.includes('application/json')) return { kind: 'wrong-media-type' };\n  let body;\n  try { body = await response.json(); } catch { return { kind: 'invalid-json' }; }\n  const result = validateScreenModel(body);\n  return result.valid ? { kind: 'screen-model-ready', model: body } : { kind: 'invalid-screen-model', errors: result.errors };\n}
\n

Функция возвращает классификацию, а не случайную строку из parser. Такой результат связывается с error boundary, логом и безопасным состоянием компонента. В технический канал передавайте код нарушения и request id. Не отправляйте весь payload: он может содержать персональные или доменные данные.

\n

Порядок проверок важен. Для 204 нельзя вызывать response.json(): успешное выполнение не означает наличие representation. Для неверного Content-Type повтор запроса обычно не исправит формат. Для невалидной модели retry тоже не является решением: сервер может стабильно возвращать тот же payload.

\n

Симптомы и действия на границе

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Экран показал «Готово», следующий клик падает200 приняли за модельсравнить тело с runtime-схемойзапретить запись в state до валидации
DELETE вызывает ошибку parserhelper ждёт JSON после 204проверить статус до json()вернуть empty-success и перечитать ресурс
Появилась кнопка неизвестной командыUI доверяет строке actionпроверить каждый элемент словарёмотклонить модель и не создавать кнопку
422 превратился в общий toastприкладной отказ назвали сетьюпрочитать machine code и полеподсветить поле, если контракт это разрешает
После timeout команда выполнилась дваждыклиент повторил запрос вслепуюпроверить idempotency key и статус операциине повторять; перечитать результат
Типы проходят, внешний сервис прислал другой JSONтип сборки не проверяет сетьвоспроизвести фактический ответдобавить contract или runtime test
\n

Query, command и пустой результат

\n

Чтение и изменение состояния требуют разных правил. Query получает representation и обновляет экран после проверки. Command просит сервер изменить состояние. Ответ 202 означает принятие асинхронной работы, а не завершённый переход. Ответ 204 означает успешную операцию без тела. В обоих случаях клиенту нужен путь к актуальной модели.

\n

Не превращайте ответ команды в локальный флаг isSuccess. Если запрос оборвался после отправки, клиент не знает, успел ли сервер изменить ресурс. Повтор POST может создать вторую операцию. Безопасный вариант зависит от API: idempotency key, endpoint статуса или повторное чтение. Если контракт ничего не даёт, UI не может честно обещать результат после timeout.

\n

Ошибки 409, 422, 429 и 5xx нельзя свести к одному toast. 409 может требовать перечитать конфликтующий ресурс. 422 может вернуть ошибки полей. 429 может содержать ограничение частоты и время следующей попытки. 5xx допускает повтор только при известной идемпотентности и ограниченном бюджете. Код статуса сужает выбор, но не заменяет error detail и правило следующего действия.

\n

Как внедрить границу

\n
  1. Выберите endpoint, где компонент читает поля напрямую или использует fallback после ошибки parser.
  2. Зафиксируйте intent: query, command или получение результата фоновой операции.
  3. Выпишите минимальную screen model и закройте словари статусов, действий и кодов сообщений.
  4. Разделите обработку HTTP, media type, JSON parse и runtime validation. Для 204 задайте результат без тела.
  5. Определите отрицательный путь для неизвестного поля, неверного типа, 4xx, 5xx и timeout.
  6. Добавьте тесты на валидный ответ, неизвестный статус, неправильный массив, пустое тело и неверный media type.
  7. Передавайте в компонент только проверенную модель. Ошибку показывайте безопасным состоянием, диагностику связывайте с request id.
  8. Проверьте повтор команды отдельно и зафиксируйте, как клиент узнаёт результат после обрыва сети.
\n

Ограничения

\n

Runtime-валидатор проверяет форму данных, но не доказывает бизнес-истину. status: ready может быть формально корректным и устареть через секунду. Для этого нужны версия ресурса, контроль конкуренции, кэш-политика или повторное чтение. Валидация не заменяет authorization: наличие действия в JSON не выдаёт право на серверную операцию.

\n

OpenAPI и сгенерированные TypeScript-типы описывают договорённость, но тип на этапе сборки не проверяет байты из сети. При независимом выпуске сервисов оставьте тест фактического ответа или runtime-схему. Ручная функция подходит для маленькой модели. Для сложных вложенных структур используйте schema validator и измерьте стоимость на реальном размере payload.

\n

Учебный пример не описывает конкретную бизнес-модель. Он показывает место проверки и решения, которые нужно подтвердить контрактом проекта. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект бесконечно. Назовите отдельные read model или версионируйте ответ.

\n

Проверяемый критерий готовности

\n

Граница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.

\n

Проверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы или строит кнопку из свободной строки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/049.json b/editorial/agent-rewrites/049.json new file mode 100644 index 0000000..f1b849f --- /dev/null +++ b/editorial/agent-rewrites/049.json @@ -0,0 +1,7 @@ +{ + "index": 49, + "slug": "editorial-2026-08-field-end-to-end-observability", + "title": "Сквозная наблюдаемость без ложной связи: как довести сигнал от UI до worker", + "excerpt": "E2E-тест падает, API пишет лог, worker считает задачу, но причина теряется между границами. Разбираем correlation context, роли trace, log и metric, отрицательный путь и критерий готовности.", + "contentHtml": "

E2E-тест сообщает об ошибке, но в панели нельзя быстро найти соответствующий backend-запрос. В логах есть время и название операции. Worker показывает метрику. UI показывает упавший шаг. Однако эти записи нельзя надёжно связать. Инженер тратит часы на ручное сравнение временных окон и похожих идентификаторов.

\n

Цена ошибки выше времени расследования. Команда может исправить worker, хотя проблема возникла при передаче контекста из API в очередь. Может включить повтор, хотя первая команда уже была принята. Может добавить в логи email, полный URL или сырой идентификатор пользователя. Тогда диагностика ускорится на один случай, но данные станут чувствительнее, а метрики — бесполезнее из-за высокой кардинальности.

\n

Тезис статьи простой: end-to-end наблюдаемость начинается с проверяемой связи между границами, а не с количества панелей. Сначала нужно назвать один путь, один технический context и вопрос для каждого сигнала. Затем нужно запретить поля, которые не нужны этому вопросу. Если связь или границу данных нельзя доказать, система должна вернуть точный stop, а не дорисовать причинную историю по похожему имени.

\n

Что именно связывает сквозной сигнал

\n

Рассмотрим путь ui.checkout.submit → API → worker. UI создаёт операцию и отправляет запрос. API принимает запрос и ставит работу в очередь. Worker получает сообщение и выполняет работу. Это три разные границы. Между ними передаётся не весь объект операции, а небольшой технический контекст, который позволяет понять: записи относятся к одному пути.

\n

Correlation context не отвечает на вопрос «какой пользователь это сделал». Он отвечает на вопрос «какие сигналы относятся к одной технической цепочке». Это различие важно для доступа и хранения. Идентификатор пользователя, email, текст формы и полный query string не становятся допустимыми только потому, что их удобно искать. Для расследования отдельной операции может потребоваться другой защищённый процесс. Его нельзя незаметно встроить в общий label или trace attribute.

\n

У trace, log и metric разные задачи. Span показывает последовательность и границы операции. Structured log объясняет решение в конкретной ветке: например, API принял задачу или отклонил её по известному классу. Metric агрегирует повторяющиеся события: число jobs по ограниченному job-kind и outcome-class. Один context может связать сигналы, но не превращает их в один и тот же тип данных.

\n
\"Цикл
Схема показывает порядок проверки. Сначала формулируется вопрос, затем проверяются связь и состав полей. Цикл заканчивается проверяемым hand-off или точным stop, а не выводом о production-системе.
\n

Механизм: один context, три семантики

\n

Начните с одного технического значения, например trace-7f в учебной модели. UI, API и worker должны явно показать это значение в своей записи. В настоящей системе формат и перенос определяет конкретный контракт, например W3C Trace Context. Важно не название стандарта, а инвариант: каждая граница либо несёт допустимый context, либо end-to-end вывод прекращается.

\n

API не должен искать «ближайший» trace по времени. Worker не должен присоединяться к trace только потому, что совпал job-kind. Такие эвристики создают убедительную, но недоказанную историю. При пропавшем или некорректном context нужно сохранить локальный сигнал и отдельно отметить, что сквозная связь не подтверждена.

\n

Асинхронная очередь добавляет смысловую границу. Принятие задачи и её выполнение не являются одной операцией по умолчанию. Для них нужно описать carrier, место извлечения, место вставки и поведение при ошибке. Нельзя считать, что SDK автоматически сохранит родительскую связь через любую очередь. Это должно следовать из контракта message boundary и проверки конкретной реализации.

\n

Время требует такой же аккуратности. UI waiting, время обработки API, задержка очереди и worker execution — разные интервалы. Их нельзя складывать без источника времени, правил для retry и определения начала и конца каждого участка. Один root span может скрыть задержку очереди. Три коротких span могут скрыть потерянную связь. Поэтому сначала фиксируют границы и допустимый вопрос, а измерение добавляют после этого.

\n

Учебный пример: проверка карты сигналов

\n

Ниже приведён ограниченный учебный пример. Он не обращается к браузеру, API, очереди или telemetry backend. Он не доказывает, что в production есть нужный context. Его задача — показать fail-closed правило: validator принимает только три named signals с одним context и отклоняет запрещённое поле.

\n
type Signal = {\n  component: 'ui' | 'api' | 'worker';\n  name: string;\n  context: string;\n  fields: string[];\n};\n\nfunction checkMap(signals: Signal[], forbidden: Set<string>) {\n  const contexts = new Set(signals.map((signal) => signal.context));\n  const forbiddenFields = signals\n    .flatMap((signal) => signal.fields)\n    .filter((field) => forbidden.has(field));\n\n  if (signals.length !== 3 || contexts.size !== 1 || signals.some((s) => !s.context)) {\n    return { status: 'stop-broken-correlation-context' };\n  }\n\n  if (forbiddenFields.length > 0) {\n    return {\n      status: 'stop-forbidden-signal-field',\n      fields: [...new Set(forbiddenFields)],\n    };\n  }\n\n  return { status: 'synthetic-map-ready-for-review' };\n}\n\nconst result = checkMap([\n  { component: 'ui', name: 'span: ui.checkout.submit', context: 'trace-7f', fields: ['route-template'] },\n  { component: 'api', name: 'log: api.accepted', context: 'trace-7f', fields: ['outcome-class'] },\n  { component: 'worker', name: 'metric: worker.jobs', context: 'trace-7f', fields: ['job-kind'] },\n], new Set(['email', 'raw-user-id', 'request-url-with-query']));\n\nconsole.log(result.status);\n// synthetic-map-ready-for-review
\n

Этот код проверяет структуру входного объекта в памяти. Он не создаёт trace header и не отправляет данные. Если у worker поставить пустой context, результат станет stop-broken-correlation-context. Если в UI добавить email, результат станет stop-forbidden-signal-field. Это полезный отрицательный путь: отсутствие доказательства не превращается в успешную связь.

\n

В реальной системе такой validator не заменяет SDK, интеграционный тест, контроль доступа и проверку схемы сообщений. Он задаёт только минимальное правило, которое можно проверять отдельно от транспорта. Полезность примера ограничена именно этим.

\n

Симптом → причина → проверка → действие

\n
Диагностика разрыва сквозной наблюдаемости
СимптомПричинаПроверкаДействие
E2E-тест и API-лог нельзя связатьUI не передал технический context или лог потерял егоСверить context на входе API и в span операцииНазвать carrier и остановить cross-boundary вывод при его отсутствии
API и worker выглядят связанными по времени, но причина спорнаяСвязь построена эвристикой по timestamp или job nameПроверить точное значение context и parent/message boundaryУбрать эвристику; вернуть stop для неподтверждённой цепочки
Metric содержит trace id или user idИндивидуальный идентификатор стал labelПосчитать уникальные значения и проверить список attributesОставить low-cardinality class; индивидуальный след вынести в отдельный доступный канал
Лог помогает одному расследованию, но быстро растётВ log попал свободный payload или полный текст ошибкиПроверить schema, размер записи и наличие query, email, телефонаОставить operation name и outcome class; payload удалить или ограничить policy
После sampling «всё равно» видны чувствительные поляSampling перепутали с разрешением на сборРазделить sampling rule и data allow-listСначала убрать запрещённое поле, затем отдельно обсуждать объём traces
Worker показывает успешную metric, а пользователь получил ошибкуMetric измеряет получение job, а не итог операцииСверить смысл outcome-class и место инкрементаРазвести accepted, processing и completed; не называть одно другим
\n

Почему sampling не решает cardinality

\n

Cardinality описывает число разных комбинаций значений в измерении. Sampling выбирает, какие события или traces сохранять. Это разные решения. Если label содержит email, сохранение одного из двадцати событий не делает поле low-cardinality и не меняет его смысл. Если outcome-class имеет небольшой закрытый словарь, ему не нужен trace id в качестве дополнительного измерения.

\n

Сначала определите вопрос метрики. Для worker это может быть количество jobs по классу работы и ограниченному исходу. job-kind должен приходить из закрытой taxonomy. Новое значение должно пройти изменение схемы, а не появиться из свободного текста сообщения. Не используйте текст исключения, полный URL, request id или сырые идентификаторы как metric dimension.

\n

Sampling тоже требует причины и границы. Например, правило может отдельно обсуждать ошибки и обычный путь. Но статья не может назвать coverage, стоимость или процент потерь без реального измерения. Учебное правило — это только параметр дизайна. Оно не доказывает, что выбранный объём достаточен для SLA или расследования.

\n

Порядок действий

\n
  1. Запишите наблюдаемый симптом и цену неверного вывода: потеря времени, повтор операции, лишние данные или неправильный fix.
  2. Сузьте сценарий до одного пути, например ui.checkout.submit → API → worker.
  3. Для UI, API и worker назовите главный signal и вопрос, на который он отвечает.
  4. Опишите technical correlation context, carrier, точки extraction и injection.
  5. Зафиксируйте поведение при пустом, неверном или отсутствующем context.
  6. Составьте allow-list полей и отдельно forbidden-list: email, phone, raw user id, свободный payload и URL с query.
  7. Разведите accepted, processing и completed, если путь содержит очередь или retry.
  8. Проверьте cardinality до обсуждения sampling. Для metric оставьте только закрытые классы.
  9. Проверьте отрицательные варианты: worker без context, запрещённое поле и неизвестный outcome.
  10. Добавьте интеграционный тест на реальную границу сообщения и отдельный тест на безопасную схему сигналов.
  11. Передайте результат как карту вопроса, границ, полей и stop. Не называйте её incident report или production evidence без соответствующих данных.
\n

Ограничения и отрицательный путь

\n

Описанный механизм не доказывает, что конкретный SDK корректно переносит context через браузер, HTTP-клиент или очередь. Необходимы тесты с реальным carrier и версиями библиотек. Стандарт задаёт формат и семантику контекста, но не выбирает права доступа, retention, список разрешённых бизнес-полей или способ обработки customer data.

\n

Механизм также не отвечает на вопрос, действительно ли пользователь увидел результат. Наличие span до worker не доказывает доставку UI-ответа. Metric worker.jobs не доказывает завершение операции. Для этого нужны отдельные сигналы и договорённость о состоянии команды. Не смешивайте техническую связь с бизнес-подтверждением.

\n

Если context потерян, не восстанавливайте его по времени, имени операции или ближайшей записи. Сохраните локальный сигнал, обозначьте границу и верните stop. Если schema неизвестна, не принимайте свободный JSON как допустимый payload. Если outcome не входит в закрытый словарь, классифицируйте его как unknown и передайте владельцу taxonomy. Такой результат выглядит менее удобным, но его можно проверить.

\n

Статья не описывает production-исследование, не сообщает latency, error rate, sampling coverage или экономию времени. Все значения в коде и примере учебные. Их можно использовать как форму проверки границ, но нельзя цитировать как результат запуска.

\n

Проверяемый критерий готовности

\n

Сценарий готов к следующему техническому review, если другой инженер без устного объяснения может ответить на четыре вопроса: какой путь проверяется, какой context связывает границы, какие поля разрешены и что произойдёт при нарушении. Проверка должна показать один named context на UI, API и worker; раздельную семантику span, log и metric; отсутствие запрещённых полей; закрытый словарь outcome-class; и точный stop для разрыва связи.

\n

Для реальной системы добавьте доказательство транспорта: интеграционный тест передаёт context через HTTP и message boundary, worker сохраняет ожидаемую связь, а неизвестный или пустой input не получает искусственный идентификатор. Отдельно проверьте, что metric не принимает trace id и user id как labels. Пока эти проверки не пройдены, готова только схема расследования, а не end-to-end наблюдаемость.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/050.json b/editorial/agent-rewrites/050.json new file mode 100644 index 0000000..dd46785 --- /dev/null +++ b/editorial/agent-rewrites/050.json @@ -0,0 +1,7 @@ +{ + "index": 50, + "slug": "editorial-2026-08-mechanism-end-to-end-observability", + "title": "End-to-end наблюдаемость: как не перепутать сигналы с доказательством", + "excerpt": "Когда e2e-тест падает, совпадение имён в логах не связывает UI, API и worker. Разбираем propagation, смысл span/log/metric, отрицательный путь и критерий, при котором сквозной вывод можно считать проверяемым.", + "contentHtml": "

В e2e-тесте упала отправка заказа. Браузер показал таймаут, API записал ошибку, worker продолжил обрабатывать очередь. Все три записи содержат checkout. Но инженер не знает, относятся ли они к одной попытке. Он тратит время на поиск «медленного сервиса», хотя разрыв мог произойти в propagation. Цена ошибки — ложный вывод, лишний rollback и повтор инцидента после следующего релиза.

\n

End-to-end наблюдаемость начинается не с дашборда. Она начинается с проверяемого контракта связи. UI, API и worker должны передать один контекст по названным границам. Каждая граница должна описывать свой сигнал. Если контекст потерян, система должна остановить сквозной вывод. Похожее имя, соседнее время и одинаковый тип операции не заменяют корреляцию.

\n

Что именно связывает сквозной сигнал

\n

Контекст отвечает на вопрос «к какой цепочке относится операция». В стандарте W3C для этого есть переносимый traceparent с trace-id и parent-id. На практике важен не сам заголовок, а договор: кто его извлекает, кто передаёт дальше, кто создаёт новую связь и что происходит с пустым или неверным значением.

\n

У разных сигналов разные задачи. Span показывает участок операции и его границы. Log объясняет событие и его исход. Metric считает повторяющиеся события по небольшому набору признаков. Общий context помогает перейти от одного объекта к другому, но не делает эти объекты взаимозаменяемыми. Нельзя считать metric доказательством конкретной попытки. Нельзя читать один log как полную историю запроса.

\n

Асинхронная очередь добавляет отдельную границу. API может принять сообщение в одной операции, а worker обработать его позже и повторить несколько раз. Время API, задержка очереди и время worker нельзя сложить без явных часов и правил retry. Если carrier сообщения не определён, связь с worker остаётся гипотезой.

\n
\"Схема
Иллюстрация разделяет две независимые задачи: sampling выбирает наблюдаемые traces, а cardinality ограничивает форму агрегируемых признаков. Одно не исправляет другое.
\n

Сигнал должен отвечать на один вопрос

\n

Перед добавлением поля сформулируйте вопрос. Для span это может быть «какая операция заняла участок пути». Для log — «какой ограниченный исход получил API». Для metric — «сколько задач класса payment завершилось исходом timeout». Если вопрос требует email, полного URL, текста запроса или случайного идентификатора, поле нельзя добавлять в metric label. Такие значения раздувают число series и смешивают диагностику с хранением данных.

\n

Ошибка тоже требует словаря. Transport failure означает, что граница вызова не завершилась ожидаемо. Domain rejection означает, что правило продукта отклонило операцию. Retryable failure означает, что worker может попробовать ещё раз. Одно error=true скроет разницу между ними. Храните ограниченный outcome_class, а подробности оставляйте в защищённом событии с отдельными правилами доступа и хранения.

\n
const signal = {\n  context: 'trace-7f',\n  operation: 'checkout.submit',\n  outcome_class: 'transport_failure',\n  route_template: '/orders/{id}'\n};\n\nconst allowed = new Set([\n  'operation', 'outcome_class', 'route_template'\n]);\n\nfunction accept(fields) {\n  return Object.keys(fields).every((name) => allowed.has(name));\n}\n\nif (!accept(signal)) {\n  throw new Error('stop: forbidden signal field');\n}
\n

Это учебный JavaScript-пример. Он не подключается к браузеру, HTTP-клиенту, очереди или telemetry backend и не доказывает свойства production-системы. Его задача — показать fail-closed правило: неизвестное поле не проходит молча, а пустой контекст не получает новый идентификатор только ради красивой связи.

\n

Симптом → причина → проверка → действие

\n
Диагностическая таблица для одного UI → API → worker пути
СимптомПричинаПроверкаДействие
e2e упал, backend-запрос не находитсяUI не передал context или query скрывает егоСравнить carrier на запросе с context в spanОстановить вывод и назначить owner propagation
API и worker имеют одинаковый job nameИмя операции приняли за correlationПроверить trace-id и parent relation, а не текст имениНе связывать события эвристикой
Metric содержит много уникальных labelsВ label попали id, URL или свободный текстПосчитать допустимые значения каждой dimensionsОставить named low-cardinality class или убрать поле
После retry длительность выглядит вдвое большеСложили API, очередь и повтор workerРазделить сегменты и проверить источники времениНе делать latency-вывод до полной модели границ
Trace иногда есть, иногда исчезаетSampling или async carrier не описаныПроверить policy, message headers и absent-context branchНазвать правило отбора и fail-closed поведение
\n

Как работает отрицательный путь

\n

Представим учебный маршрут: UI создаёт trace-7f, API получает тот же контекст, а worker получает сообщение без carrier. В этой точке нельзя подставить «ближайший» trace и нельзя связать worker по имени задачи. Правильный результат — stop-broken-correlation-context. Он не сообщает причину сбоя в production. Он сообщает, какого факта не хватает для сквозного вывода.

\n

Другой отрицательный путь возникает, когда UI добавляет email в список полей, а API и worker остаются корректными. Проверка должна остановиться на запрещённом поле. Sampling не исправляет нарушение: меньший объём trace не меняет характер персонального значения. Переагрегация тоже не оправдывает сбор лишнего поля задним числом.

\n

Третий путь — неизвестный исход. Если API записал свободный текст исключения, metric не должна превращать его в новую series. Сначала ограничьте taxonomy: например, ok, domain_rejected, transport_failure, retryable_failure. Если новый исход нельзя отнести к классу, запишите unknown и отправьте вопрос владельцу словаря. Это сохраняет честность сигнала.

\n

Порядок проверки

\n
  1. Опишите один пользовательский путь: UI → API → очередь → worker. Укажите владельца каждой границы.
  2. Назовите carrier, точки extraction и injection, а также реакцию на отсутствующий и неверный context.
  3. Разведите span, log и metric по вопросам. Не переносите trace-id в metric label.
  4. Составьте allow-list полей и закрытый словарь outcome-классов. Уберите identity и свободный payload.
  5. Разделите UI time, API processing, queue delay и worker execution. Не складывайте интервалы без общей модели часов.
  6. Назовите sampling policy и её границу. Не объявляйте coverage, latency или стоимость без измерения.
  7. Прогоните положительный и три отрицательных варианта: потерянный context, запрещённое поле и неизвестный исход.
  8. Сохраните результат как проверяемый контракт. Если любой stop сработал, не публикуйте end-to-end причину.
\n

Ограничения

\n

Наличие одинакового trace-id ещё не доказывает, что пользователь получил успешный результат. Span может быть экспортирован с задержкой. Log может отсутствовать из-за уровня записи. Metric агрегирует множество операций. Sampling может не сохранить нужную попытку. Прокси может удалить заголовок, а очередь — не поддержать выбранный carrier.

\n

Стандарты задают модели и форматы, но не выбирают taxonomy конкретного продукта, retention, доступ, redaction или стоимость telemetry. Учебная схема не доказывает compliance и не заменяет нагрузочное измерение. Перед внедрением нужен отдельный контракт для каждой границы и проверка реального SDK, collector и хранилища.

\n

Критерий готовности

\n

Механизм готов к следующему инженерному шагу, если независимая проверка получает один и тот же результат: для выбранного пути назван carrier, UI, API и worker несут один контекст, каждый сигнал отвечает на свой вопрос, поля проходят allow-list, sampling описан без выдуманной эффективности, а все отрицательные варианты дают именованный stop. При этом нет заявления о production-латентности, покрытии, релизе или устранённой аварии.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/051.json b/editorial/agent-rewrites/051.json new file mode 100644 index 0000000..1f4cfe9 --- /dev/null +++ b/editorial/agent-rewrites/051.json @@ -0,0 +1,7 @@ +{ + "index": 51, + "slug": "editorial-2026-08-practice-end-to-end-observability", + "title": "Сквозная наблюдаемость без ложных связей: UI, API и worker", + "excerpt": "Как сохранить correlation context между UI, API и worker, выбрать отдельный смысл для span, log и metric и остановиться, если связь или граница данных нарушена.", + "contentHtml": "

End-to-end тест падает на кнопке оформления заказа. В браузере виден timeout. В API-логе есть принятый запрос. В метрике worker растёт число задач. Но найти один backend-запрос по этому тесту нельзя. Инженер не знает, где оборвалась цепочка: в браузере, proxy, API, очереди или worker. Он меняет timeout и повторяет запуск. Иногда тест проходит. Причина остаётся.

Цена ошибки — не только лишние минуты расследования. Команда может увеличить таймаут, скрыть повторную работу или добавить второй диагностический канал. Если для связи в telemetry попадают email, полный URL или raw user id, локальное удобство превращается в проблему данных и кардинальности. Тезис статьи простой: сквозной сигнал начинается с одного технического context и явных границ. Он не начинается с дашборда и не требует передавать весь payload.

Механизм: один путь, три разных сигнала

Возьмём один учебный сценарий: пользователь нажал «Оплатить», API принял команду, worker обработал задание. UI создаёт span ui.checkout.submit. API пишет структурированный log api.accepted. Worker увеличивает metric worker.jobs. Все три записи получают технический correlation context fixed-trace-7f.

Context связывает позиции в одной операции. Он не превращает сигналы в один формат и не отвечает на все вопросы сразу. Span показывает ход ограниченной операции и её длительность. Log объясняет одно событие и его outcome-class. Metric считает повторяющиеся события по небольшому словарю классов. Если записать trace id как label метрики, агрегат начнёт хранить идентификаторы отдельных операций. Это уже не полезная группировка.

У асинхронной границы нужен отдельный контракт. API должен решить, что именно передаётся в сообщение, кто создаёт дочернюю операцию и что делать при отсутствии context. Worker не может считать, что очередь сама сохранила parent relation. Если context пуст, разбор заканчивается статусом stop-broken-correlation-context. Нельзя дорисовывать связь по одинаковому имени job или времени запуска.

\"Карта
Учебная карта показывает владельца каждого перехода. Она не изображает работающую telemetry-систему, не содержит production trace и не доказывает propagation через конкретную очередь.

Что разрешает context

Correlation и identity решают разные задачи. Correlation отвечает: относятся ли записи к одному пути. Identity отвечает: кто совершил действие. Для первой задачи достаточно технического идентификатора внутри разрешённого контура. Добавлять в span или log пользовательский email «для удобства» нельзя без отдельного назначения, доступа и срока хранения.

Полезно заранее записать allow-list. Для UI это могут быть route-template и request-kind. Для API — operation-name и outcome-class. Для worker — job-kind и outcome-class. Вне списка остаются raw user id, email, phone, свободный текст, полный URL с query и текст исключения. Название поля само по себе ничего не гарантирует. Без ограниченного словаря outcome станет свободным текстом.

const path = [{ component: 'ui', signal: 'span', context: 'fixed-trace-7f', fields: ['route-template', 'request-kind'] }, { component: 'api', signal: 'log', context: 'fixed-trace-7f', fields: ['operation-name', 'outcome-class'] }, { component: 'worker', signal: 'metric', context: 'fixed-trace-7f', fields: ['job-kind', 'outcome-class'] }];\nconst forbidden = ['email', 'raw-user-id', 'free-text-query'];\nconst sameContext = new Set(path.map((step) => step.context)).size === 1;\nconst safe = path.every((step) => step.fields.every((field) => !forbidden.includes(field)));\nif (!sameContext || !safe) return 'stop: review the boundary';\nreturn 'synthetic-observability-plan-hand-off';

Это учебный JavaScript-пример. Он проверяет заранее заданный объект в памяти. Он не создаёт HTTP-заголовок, не подключает SDK, не отправляет telemetry и не подтверждает состояние production. Его задача — не дать назвать схему готовой, если worker потерял context или поле нарушило границу.

Симптом → причина → проверка → действие

Диагностическая таблица для одного UI → API → worker пути
СимптомПричинаПроверкаДействие
Тест видит timeout, backend-запрос не находитсяContext не дошёл до API или не попал в logСравнить наличие одного технического context на UI и APIИсправить propagation или остановить расследование на границе
UI и API связаны, worker выглядит отдельнымMessage boundary не описывает перенос и parent relationПроверить контракт сообщения и значение context перед обработкойНазначить владельца перехода; при пустом значении вернуть stop
Metric имеет почти отдельную series на каждую операциюВ label попал trace id, raw id или свободный текстСверить labels с allow-list и посчитать классы, а не значенияУбрать identity; оставить низкокардинальный class
Все отказы помечены одинаковоTransport failure и domain rejection смешаны в error=trueПроверить словарь outcome-classРазделить transport, domain и retryable processing outcome
Есть sampling rule, но нет уверенности в покрытииПлан выдаётся за измерениеНайти реальные данные о rate, collector и retentionНазвать правило планом; не делать вывод о latency или стоимости

Почему нельзя смешивать ошибку и результат

Transport failure означает, что граница вызова не завершилась ожидаемо. Domain rejection означает, что запрос дошёл до правила и получил допустимый отрицательный исход. Retryable processing failure означает, что worker может повторить работу. Эти состояния требуют разных действий. Одно поле error=true не говорит, искать ли сеть, бизнес-правило или повтор.

Для учебной схемы достаточно небольшого словаря: transport-unavailable, domain-rejected, retryable-processing, accepted. Не следует добавлять в metric текст исключения. Он может содержать input, URL, имя клиента или случайный идентификатор. Подробный event, если он действительно нужен, должен иметь отдельный канал, доступ и retention. Trace context не является разрешением на хранение payload.

Sampling не исправляет плохую схему

Sampling выбирает объём trace-наблюдений. Cardinality определяет, сколько отдельных серий создаёт metric. Если metric получила raw user id, правило sampling для trace не уменьшает проблему metric. Если worker context потерян, двадцать процентов сохранённых trace не докажут связь с worker. Поэтому sampling записывают рядом с причиной, границей и ожидаемым вопросом. Формулировка «ошибка или 1 из 20» здесь только учебная. Она не сообщает реальную долю, стоимость хранения или полноту покрытия.

Время также нельзя складывать без границ. UI wait, server processing, queue delay и worker execution — разные сегменты. Один root span может скрыть ожидание очереди и retry. Три коротких span могут не показать путь, если context оборвался. Пока не определены часы, события и повторные попытки, статья не делает вывода о bottleneck. Это отрицательный путь: отсутствие данных о границе запрещает уверенный performance claim.

Порядок действий

  1. Выбрать один пользовательский путь и назвать три границы: UI, API, worker.
  2. Для каждой границы задать главный сигнал и один вопрос, на который он отвечает.
  3. Назначить технический context и проверить, что он одинаково представлен на каждом переходе.
  4. Описать message boundary: что переносится, кто создаёт новую операцию и что происходит при пустом context.
  5. Составить allow-list полей и отдельный список запретов для identity, свободного текста и query.
  6. Разделить outcome-class для transport, domain и retryable processing.
  7. Проверить учебным validator-ом положительный hand-off и три отрицательных случая: нет context, запрещённое поле, operational verb вместо plan.
  8. Только после этого согласовать реальный collector, access, retention, sampling и тест в разрешённой среде.

Ограничения

Один и тот же context в трёх литералах не доказывает, что заголовок дойдёт через browser, proxy и очередь. SVG не доказывает наличие SDK. Учебный код не измеряет latency, throughput, error rate или стоимость telemetry. OpenTelemetry и W3C задают терминологию и форматы, но не назначают словарь вашей команды, права доступа, срок хранения и правила редактирования данных.

Нельзя объявлять проблему решённой только потому, что тест снова прошёл. Повторный запуск мог попасть в другую ветку, а retry мог скрыть отказ. Нельзя объявлять metric безопасной только по короткому имени label. Нужны допустимые values и проверка неизвестного значения. Если вопрос требует индивидуального payload, его нельзя протащить в агрегат под видом «диагностики».

Проверяемый критерий готовности

Материал готов к отдельному design review, когда для одного пути есть карта UI → API → worker, названный владелец каждой границы, один technical context, allow-list полей и словарь outcome-class. Validator должен вернуть только synthetic-observability-plan-hand-off для полного учебного объекта. Для пустого context он обязан вернуть stop-broken-correlation-context; для forbidden field — stop-forbidden-signal-field. Ни один результат не должен называться deploy, rollout или production success.

После этого готовность системы проверяют уже другими средствами: разрешённым тестом propagation, проверкой редактирования данных, контролем доступа, измерением cardinality и сопоставлением реального trace с запросом. Пока этих доказательств нет, корректный итог — ограниченный hand-off и точный stop, а не красивая легенда о сквозной наблюдаемости.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/052.json b/editorial/agent-rewrites/052.json new file mode 100644 index 0000000..9d7ddc9 --- /dev/null +++ b/editorial/agent-rewrites/052.json @@ -0,0 +1,7 @@ +{ + "index": 52, + "slug": "editorial-2026-07-field-migration-playbook", + "title": "Безопасный переход между старой и новой схемой", + "excerpt": "Как перенести поле или формат данных без разрыва совместимости: разделить чтение и запись, назвать состояние отката и остановиться при неполной проверке.", + "contentHtml": "

Запросы к старой версии API проходят, а часть новых клиентов получает 500 после записи нового поля. Через несколько минут становится непонятно, что возвращать: трафик на старый endpoint или данные к прежнему формату. Если откатить только маршрут, старый код может прочитать уже изменённую запись. Если откатить только схему, новый код потеряет обязательное поле. Цена ошибки — потерянные обновления, повторные операции и ручное восстановление согласованности.

\n

Безопасный переход начинается с совместимости, а не с переключателя. Старая и новая версии должны некоторое время читать общий набор данных. Каждое изменение делят на независимые состояния: код, маршрут и данные. Для каждого состояния называют условие возврата. Тогда отказ возвращает систему в известное состояние, а не просто включает старый URL.

\n

Механизм совместимого перехода

\n

Рассмотрим поле display_name, которое нужно заменить на объект profile_name. Старый клиент ожидает строку. Новый клиент ожидает объект с языком и значением. Удалять строку сразу нельзя: старый reader ещё может работать после переключения части трафика.

\n
  1. Добавьте новую форму данных как необязательную.
  2. На записи временно сохраняйте старую и новую формы из одного входного значения.
  3. На чтении нового клиента сначала используйте новую форму, затем совместимый fallback.
  4. Переключайте трафик только после проверки чтения и записи обеих версий.
  5. Удаляйте старую форму только после измеримого сигнала, что старые readers больше её не запрашивают.
\n

Эти шаги защищают только совместимость формата. Они не гарантируют правильность бизнес-правил, отсутствие дублей или сохранность данных после ошибочного повторного запроса. Такие свойства проверяют отдельно.

\n
\"Схема
Сначала сосуществуют две формы записи. Переключение трафика и возврат данных имеют разные условия.
\n

Пример записи и чтения

\n

Ниже учебный фрагмент на TypeScript. Он не подключается к базе и не показывает результат конкретного сервиса. Его задача — сделать порядок совместимости явным.

\n
type LegacyRecord = { display_name: string };\ntype MigratedRecord = LegacyRecord & {\n  profile_name?: { value: string; locale: string };\n};\n\nfunction writeBoth(input: string): MigratedRecord {\n  return {\n    display_name: input,\n    profile_name: { value: input, locale: 'ru-RU' },\n  };\n}\n\nfunction readForNewClient(record: MigratedRecord): string {\n  return record.profile_name?.value ?? record.display_name;\n}\n\nfunction canRemoveLegacyField(state: {\n  legacyReads: number;\n  newReads: number;\n  dataBackfillComplete: boolean;\n}): boolean {\n  return state.legacyReads === 0\n    && state.newReads > 0\n    && state.dataBackfillComplete;\n}
\n

В этом примере запись остаётся совместимой, пока существуют старые readers. Fallback защищает новую версию от неполной миграции данных, но не исправляет пустое или неверное значение. Функция удаления требует трёх наблюдаемых условий: старые чтения не встречаются, новая форма действительно читается, перенос данных завершён. В настоящей системе пороги и окно наблюдения задаёт владелец данных.

\n

Симптомы и действия

\n
СимптомПричинаПроверкаДействие
Старый клиент получает 500 после записиНовая форма стала обязательной для старого readerСравнить payload старого клиента и схему ответаВернуть optional-поле и сохранить старую форму
Новый клиент видит пустое имяНет fallback или запись прошла только в старую формуПроверить обе формы одной записиДобавить fallback и двойную запись
После возврата маршрута данные не совпадаютОткатили traffic state, но не определили data stateСопоставить версию reader с формой записиОстановить переключение и назвать восстановление данных
Старая колонка остаётся востребованнойВ системе есть старый consumer или кешПосчитать обращения по имени поля и версии клиентаНе удалять колонку; найти consumer
Повторная запись создаёт разные значенияДвойная запись неидемпотентнаПовторить request с тем же idempotency keyСделать запись идемпотентной
\n

Почему трафик и данные откатываются отдельно

\n

Возврат трафика меняет адрес или долю запросов. Он не возвращает уже записанные значения. Возврат данных меняет записи, журнал преобразований или источник чтения. Он не гарантирует, что запросы снова попадут в старый код. Эти операции имеют разные условия и риски.

\n

Например, новая версия записала profile_name, а старый reader читает display_name. Маршрут можно вернуть, только если старая форма сохраняется и содержит корректное значение. Если двойной записи не было, переключатель маршрута скрывает проблему до следующего чтения. Поэтому «можно откатить» — неполная формулировка. Нужно назвать объект отката, триггер и состояние после него.

\n

Отрицательный путь: нет условия восстановления

\n

Остановитесь, если описан только успешный путь: новая версия читает новую форму, а старый маршрут считается запасным. Здесь нет ответа, какие данные уже изменились, кто читает старую форму, что запускает возврат и как проверить его результат. Отсутствие ответа — причина не продолжать переход, а не повод подставить «откатить при ошибке».

\n
const migration = {\n  trafficState: 'candidate-25-percent',\n  dataState: 'dual-write',\n  rollback: {\n    trigger: '',\n    trafficState: 'legacy-100-percent',\n    dataState: '',\n  },\n};\n\nconst safeToSwitch = Boolean(\n  migration.rollback.trigger\n  && migration.rollback.trafficState\n  && migration.rollback.dataState\n);\n\nif (!safeToSwitch) {\n  throw new Error('rollback state is incomplete');\n}
\n

Этот код — учебная проверка структуры, а не механизм управления трафиком или базой. Он намеренно возвращает отрицательный путь. Пустой триггер и пустое состояние данных нельзя заменить общим словом «ошибка»: разные сбои требуют разных условий и действий.

\n

Ограничения

\n

Совместимая схема не решает проблему удаления данных, если старый и новый формат имеют разную семантику. Fallback может скрыть неполный перенос. Двойная запись может удвоить побочный эффект. Кеш может отдавать старую форму после изменения источника. Очередь может повторить сообщение. Для этих границ нужны идемпотентность, версия события, контроль источника и явное состояние обработки.

\n

Учебные значения не описывают пропускную способность, ошибки или поведение конкретной среды. Нельзя по ним утверждать, что переход завершился, что откат безопасен или что данные согласованы. Такие утверждения требуют наблюдений из системы за определённое окно и с понятной выборкой.

\n

Проверяемый критерий готовности

\n

Переход готов к следующему проценту трафика, когда одновременно выполнены четыре условия: старая и новая версии читают доступные формы; запись формирует согласованные формы из одного входа; названы отдельный триггер возврата трафика и отдельный способ восстановления данных; после возврата обе версии снова читают ожидаемое значение. Если хотя бы одно условие нельзя проверить по конкретной записи, запросу или счётчику, переход останавливают на текущей доле.

\n

После окончания перехода удаляйте старую форму в отдельном изменении. Сначала зафиксируйте нулевое чтение старого поля за согласованное окно, затем отключите запись старой формы, после этого удалите consumer и только потом меняйте схему. Каждый шаг должен иметь обратимое предыдущее состояние или явно названную причину необратимости.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/053.json b/editorial/agent-rewrites/053.json new file mode 100644 index 0000000..dc7d2c0 --- /dev/null +++ b/editorial/agent-rewrites/053.json @@ -0,0 +1,7 @@ +{ + "index": 53, + "slug": "editorial-2026-07-mechanism-migration-playbook", + "title": "Миграция без ложного rollback: четыре проверяемых границы перехода", + "excerpt": "Без инвентаря, сопоставимого трафика, обратимого состояния данных и именованного триггера rollback статус «готово» ничего не доказывает. Разбираем механизм, отрицательные ветки и критерий готовности.", + "contentHtml": "

После переключения на новую версию запросы начинают возвращаться с ошибкой. Команда нажимает rollback, старый маршрут снова отвечает, но часть записей уже прошла через новую схему. Трафик вернулся. Данные — нет. Цена ошибки — не только простой. Оператор теряет границу между тем, что отменилось, и тем, что осталось изменённым. Следующий шаг превращается в ручное расследование.

\n

Так происходит, когда миграцию описывают одним статусом: «готово», «можно переключать» или «rollback есть». Эти слова смешивают четыре разных вопроса. Известно ли, что именно переезжает? С чем сравнивают новый путь? Можно ли восстановить состояние данных? Понятно ли, когда и кто принимает решение о возврате? Если хотя бы один ответ отсутствует, зелёный статус создаёт ложную уверенность.

\n

Тезис: переход состоит из независимых ворот

\n

Безопасный переход — это не кнопка и не линейный список задач. Это последовательность ворот. Сначала система должна иметь полный для выбранного случая инвентарь. Затем она должна сравнивать control и candidate на одной границе. После этого нужно отдельно описать состояние данных и способ его восстановления. В конце нужен именованный триггер rollback, владелец решения и оба состояния возврата.

\n

Ворота проверяют структуру решения. Они не подтверждают, что production уже работает хорошо. Положительный результат означает только: карточка перехода достаточно полна для следующего инженерного шага. Он не переключает маршрут, не переносит записи и не обещает отсутствие ошибок.

\n
\"Матрица
Иллюстрация разделяет причины остановки. Возврат трафика не заменяет восстановление данных, а названный trigger не компенсирует отсутствующую контрольную границу.
\n

Механизм: сначала объект, потом движение

\n

Начните с одного маршрута и одного типа записи. Для маршрута назовите owner, reader, writer и dependency. Для записи назовите source и target. Такая связка отвечает на вопрос: что именно меняет переход и кто видит результат. Не выводите эти связи из похожего имени файла, URL или сервиса. Если связь неизвестна, верните остановку. Предположение «скорее всего, это тот же объект» уже меняет смысл проверки.

\n

Инвентарь не обязан охватить всю платформу. Он обязан быть замкнутым для выбранного учебного или реального среза. Если карточка описывает только чтение, не называйте её миграцией записи. Если dependency не указана, нельзя оценивать трафик или восстановление: неизвестно, к какой части данных относится наблюдение.

\n

Вторые ворота проверяют трафик. Control и candidate должны иметь одну route boundary. Доли 90/10 сами по себе ничего не значат. Если control обслуживает другой путь, это не сравнение. Если control не назван, candidate проверяет себя сам. Observation window тоже должно иметь имя. В реальной среде оно включает период, источник метрик и правила расчёта. В учебном объекте достаточно зафиксировать границу, но нельзя выдавать её за реальные измерения.

\n

Третьи ворота относятся к данным. Флаг migrated: true не описывает обратный путь. Нужны source, target, reconciliation и restore state. Копия, прошедшая проверку количества строк, ещё не означает, что старую write-семантику можно восстановить. Это особенно важно при расширении схемы, смене идентификатора или переносе владельца записи.

\n

Четвёртые ворота делают rollback решаемым. Назовите trigger, условие, decision owner, traffic return и data return. Слово «аномалия» слишком широко. Оно не говорит, какой сигнал остановит переход. «Ошибки выросли» тоже недостаточно, если не указаны окно, источник и правило сравнения. Именованный trigger не запускает откат сам. Он делает вопрос воспроизводимым для того, кто принимает решение.

\n

Конкретный пример: fail-closed проверка

\n

Ниже — учебный JavaScript-пример. Он проверяет только фиксированный объект в памяти. Он не читает балансировщик, базу данных, метрики или Kubernetes API. Его задача — показать отрицательную ветку: candidate существует, но контрольная граница не задана.

\n
const record = {\n  inventory: {\n    route: 'ledger-read',\n    owner: 'payments-team',\n    dependency: 'ledger-entry'\n  },\n  traffic: {\n    candidate: { routes: ['ledger-read'], share: 10 },\n    control: { routes: [], share: 90 },\n    observationWindow: 'window-v1'\n  }\n};\n\nfunction evaluate(input) {\n  if (!input.inventory.route || !input.inventory.owner || !input.inventory.dependency) {\n    return 'stop-missing-inventory';\n  }\n\n  const candidate = input.traffic.candidate.routes;\n  const control = input.traffic.control.routes;\n  if (!candidate.length || !control.length || candidate.join() !== control.join()) {\n    return 'stop-unbounded-traffic-slice';\n  }\n\n  return 'continue-to-data-and-rollback-gates';\n}\n\nconsole.log(evaluate(record));\n// stop-unbounded-traffic-slice
\n

Результат не говорит, что новый маршрут опасен. Он говорит более узко: текущая запись не задаёт объект сравнения. Исправление тоже узкое — назвать control с той же границей и повторить проверку. Не следует заменять этот вывод фразой «проверить балансировщик»: код не имеет такого доступа и не может подтвердить действие в окружении.

\n

Симптом → причина → проверка → действие

\n
Диагностика перехода по первой наблюдаемой причине
СимптомПричинаПроверкаДействие
Rollback вернул старый маршрут, но записи расходятсяТрафик и data state считались одним rollbackНазвать отдельно traffic return и data restoreОстановить переход до описания recovery state
Есть candidate 10% и control 90%У наборов разные route boundariesСравнить route set, owner и окно наблюденияНе считать доли сравнением
Копия завершилась успешноCopy приняли за обратимое состояниеПроверить source, target, reconciliation и restoreВернуть stop-irreversible-data-state
В документе написано «откат при проблеме»Нет trigger, условия и владельца решенияНайти named condition и два return stateНе расширять слово rollback; дописать границы
Сервис не назван в инвентареСвязь вывели по имени или предположениюПроверить owner, reader, writer и dependencyОстановить review на stop-missing-inventory
\n

Отрицательный путь важнее зелёной ветки

\n

Представьте три неполных карточки. В первой нет dependency. Правильный результат — stop-missing-inventory; обсуждать проценты трафика рано. Во второй control и candidate указывают разные маршруты. Результат — stop-unbounded-traffic-slice; числа 90 и 10 не становятся доказательством. В третьей есть source, target и copy, но нет restore state. Результат — stop-irreversible-data-state; возврат маршрута не объявляют полным rollback.

\n

Есть и четвёртая ошибка: trigger назван, но не задано условие. «Rollback по решению владельца» не отвечает на вопрос, какое наблюдение открывает решение. В таком случае возвращается stop-unnamed-rollback-trigger. Система не должна угадывать порог, подставлять последний dashboard или считать любой timeout достаточным. У разных переходов разные сигналы и разные допустимые последствия.

\n

Порядок проверки намеренно короткий. Первая ошибка останавливает оценку. Это не скрывает остальные дефекты. Это задаёт ближайшую проверяемую работу. Если перечислить сразу десять проблем, команда может исправить вторую и пропустить первую. Если назвать только статус «не готово», следующий исполнитель снова будет восстанавливать контекст из разговора.

\n

Порядок действий

\n
  1. Выберите один переход и зафиксируйте его границу: маршрут, тип записи и owner.
  2. Свяжите маршрут с reader, writer и dependency. Не заполняйте пропуск догадкой.
  3. Опишите control и candidate с одинаковым route set. Назовите observation window и поля, которые в нём будут проверяться.
  4. Опишите data transition: source, target, reconciliation и restore state. Не используйте флаг «миграция завершена» как замену обратному пути.
  5. Назовите trigger, условие, decision owner, traffic return и data return. Разведите эти сущности в отдельных полях или строках.
  6. Проверьте по очереди положительный случай и контрпримеры для каждого ворот. Сохраните первый stop reason и следующее действие.
  7. Ограничьте положительный результат статусом hand-off на следующий review. Не превращайте его в разрешение на cutover.
\n

Что подтверждают официальные механизмы, а чего не подтверждают

\n

У слова rollback нет общего смысла для всех слоёв. В документации Kubernetes откат Deployment относится к его Pod template. Это полезная граница: возврат версии workload не означает возврат записей в хранилище и не отменяет побочные эффекты приложения. Поэтому в карточке перехода traffic return и data return должны быть отдельными полями.

\n

У базы данных граница может быть другой. PostgreSQL описывает ROLLBACK как отмену изменений текущей транзакции. Это не равно восстановлению данных, уже записанных в другой транзакции, внешней очереди или стороннем сервисе. Нельзя перенести семантику одной транзакции на весь процесс миграции. Сначала назовите объект и границу действия.

\n

Ограничения

\n

Эта модель не измеряет задержку, error rate, нагрузку, стоимость простоя или долю пользователей. Она не проверяет корректность выбранного owner. Она не знает, что произойдёт при конкурирующих записях, повторной доставке сообщения или частичной недоступности хранилища. Именованный trigger тоже может быть плохим. Механизм лишь не даёт скрыть его отсутствие.

\n

Учебный код не создаёт traffic slice, не запускает SQL, не меняет Deployment и не выполняет восстановление. Его можно использовать для проверки формы карточки и отрицательных веток. Перед реальным переходом нужны инвентарь конкретной системы, rehearsal, наблюдение, права на действие и отдельно описанная процедура восстановления. Ни один положительный результат этой статьи не заменяет их.

\n

Проверяемый критерий готовности

\n

Механизм готов к следующему review, если независимый читатель получает одинаковый результат из одной карточки: выбранный объект назван; owner, reader, writer и dependency связаны; control и candidate имеют общую границу; observation window указан; data state содержит reconciliation и restore; rollback имеет trigger, условие, владельца и два return state; каждый неполный вариант выдаёт именованный stop. Положительный результат остаётся hand-off. В нём нет утверждения о выполненном rollout, исправленных данных или достигнутом production-эффекте.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/054.json b/editorial/agent-rewrites/054.json new file mode 100644 index 0000000..d8b9448 --- /dev/null +++ b/editorial/agent-rewrites/054.json @@ -0,0 +1,7 @@ +{ + "index": 54, + "slug": "editorial-2026-07-practice-migration-playbook", + "title": "Миграция без прыжка: как сохранить данные и управлять откатом", + "excerpt": "Пошаговая схема миграции с инвентарём, совместимыми версиями, контрольным срезом трафика и отдельным планом возврата данных.", + "contentHtml": "

После переключения на новую версию часть заказов читает новые поля, а часть записывает старые. HTTP-ответы остаются успешными. Ошибка проявляется позже: фильтр не находит заказ, отчёт считает две версии одной записи разными, а возврат трафика не возвращает уже записанные данные. Команда видит зелёный deploy, но не может быстро ответить, что именно откатывать.

\n

Цена ошибки — не только простой. Оператор повторяет операции, разработчик сверяет несовместимые логи, а ручное исправление может создать дубликаты. Чем дольше работают две схемы, тем больше записей пересекают границу. Поэтому миграцию нельзя сводить к копированию и последующему cutover.

\n

Тезис простой: безопасный переход состоит из совместимых состояний, ограниченного среза трафика и заранее названного пути возврата. Каждый этап должен отвечать на четыре вопроса: что меняется, кто владеет состоянием, как проверяется переход и что вернётся при отказе. Если ответа нет, этап останавливается.

\n

Механизм: сначала совместимость, потом переключение

\n

Рассмотрим учебный пример. Сервис заказов хранит поле status, а новая версия хочет использовать state. Нельзя сразу удалить старое поле. Сначала новая схема принимает оба имени, затем приложение пишет оба значения, потом команда сверяет записи и переводит чтение на новое поле. Только после этого старый контракт можно убрать.

\n

Такой порядок разделяет четыре разных изменения. Схема должна принять новый формат. Писатель должен создать согласованные значения. Читатель должен уметь сравнить старое и новое представление. Маршрутизатор должен направить ограниченный поток на новый путь. Если один шаг смешать с другим, откат приложения не отменит изменение данных.

\n
\"Схема
Переход проходит через четыре границы. Красная ветка означает остановку, если не названа зависимость, состояние данных, контрольный маршрут или условие возврата.
\n

Инвентарь показывает границу риска

\n

Начните с одного маршрута, а не со всей системы. Запишите его владельца, читателя, писателя, запись и внешние зависимости. Для примера это GET /orders/:id, таблица orders, обработчик записи и индекс, которым пользуется отчёт.

\n

Связи важнее списка файлов. Если известен маршрут, но неизвестен писатель, нельзя оценить совместимость записи. Если известен писатель, но нет читателя отчёта, нельзя определить, где появится расхождение. Пустое звено — это не мелкая недостача документа. Это причина остановить переход до проверки.

\n

Состояние данных не равно копии

\n

Копия отвечает только на вопрос «создан ли второй набор». Она не отвечает, совпадают ли ключи, как обрабатываются новые записи и куда вернётся запись при отказе. Поэтому карточка перехода должна хранить источник, назначение, способ сверки и состояние восстановления.

\n

Для учебного сценария достаточно такой модели:

\n
const migration = {\n  route: 'orders-read-v1',\n  owner: 'orders-team',\n  source: 'orders.status',\n  target: 'orders.state',\n  reconciliation: 'same-keys-and-normalized-values',\n  writeRecovery: 'resume-source-writes',\n  traffic: { control: 'orders-read-v1', candidate: 'orders-read-v2' },\n  rollback: {\n    trigger: 'contract-mismatch-in-observation-window',\n    traffic: 'restore-control-route',\n    data: 'resume-source-writes'\n  }\n};
\n

Этот объект не подключается к базе, балансировщику или системе метрик. Он только показывает минимальные поля, которые нужно назвать до реальной операции. В проекте вместо строк должны стоять реальные маршруты, команды сверки, владельцы и процедуры восстановления.

\n
Диагностика перехода
СимптомПричинаПроверкаДействие
Новый читатель видит пустое полеКопия создана, но запись не синхронизированаСравнить ключи и нормализованные значения на одной выборкеОстановить срез и вернуть чтение на control
Старый и новый отчёты расходятсяРазные правила преобразованияСравнить результат одного заказа в обоих представленияхИсправить преобразование до расширения среза
После rollback появляются новые расхожденияВозврат маршрута не вернул write stateПроверить, какой писатель принимал записи в окнеВозобновить источник или применить обратное преобразование
Нельзя выбрать момент остановкиНе назван trigger и владелец решенияНайти условие, окно наблюдения и ответственногоНе считать миграцию готовой
Кандидат работает лучше, но сравнение спорноеНет control с тем же маршрутомСверить route boundary, запросы и окноСоздать сопоставимую контрольную сторону
\n

Контрольный срез должен иметь две стороны

\n

Число «10% трафика» само по себе ничего не доказывает. Нужна контрольная сторона с тем же типом запроса, сопоставимым окном и одинаковыми правилами подсчёта ошибок. В учебной модели orders-read-v1 — control, а orders-read-v2 — candidate. Это имена границ, а не рекомендация направлять ровно десять процентов реального трафика.

\n

Сравнивайте не только HTTP-коды. Проверьте долю ошибок контракта, расхождение значений, задержку и долю повторных запросов. Порог зависит от сервиса и его SLO. Если порог не определён, результат «ошибок не заметили» нельзя использовать как разрешение расширить срез.

\n

Rollback состоит из трёх разных возвратов

\n

Возврат версии приложения возвращает код. Возврат маршрута возвращает поток запросов. Восстановление данных возвращает способ обработки записей. Эти действия могут иметь разные триггеры и разных владельцев. Фраза «откатим релиз» не описывает ни одного из них.

\n

Укажите условие остановки до начала среза. Например: «в окне наблюдения появился mismatch контракта для нормализованного значения». Затем укажите, кто принимает решение, куда возвращается чтение и какой писатель принимает новые данные после возврата. Если запись уже прошла только через новую схему, одного переключения маршрута недостаточно.

\n

Официальная документация Kubernetes прямо ограничивает смысл rollback Deployment: при возврате ревизии восстанавливается Pod template. Это полезное различие. Возврат контейнера не отменяет SQL-изменения, сообщения в очереди или внешний API-контракт. Такие состояния нужно проектировать отдельно.

\n

Учебная проверка карточки

\n

Следующая функция демонстрирует fail-closed проверку. Она возвращает причину остановки, если отсутствует контрольная сторона, обратимое состояние данных или условие rollback. Пример учебный: он не вызывает внешние системы и не подтверждает готовность реального перехода.

\n
function checkMigration(card) {\n  if (!card.route || !card.owner || !card.source || !card.target) {\n    return { status: 'stop-missing-inventory' };\n  }\n  if (!card.reconciliation || !card.writeRecovery) {\n    return { status: 'stop-irreversible-data-state' };\n  }\n  if (card.traffic?.control === card.traffic?.candidate) {\n    return { status: 'stop-missing-control-boundary' };\n  }\n  if (!card.rollback?.trigger || !card.rollback?.traffic || !card.rollback?.data) {\n    return { status: 'stop-unnamed-rollback' };\n  }\n  return { status: 'ready-for-environment-specific-review' };\n}\n\nconsole.log(checkMigration(migration));\n// { status: 'ready-for-environment-specific-review' }
\n

Положительный результат означает только, что учебная структура заполнена. Он не означает, что данные совпали, срез безопасен или команда может выполнять cutover. В реальном проекте функция должна дополняться проверкой конкретной базы, схемы, метрик, прав и процедуры восстановления.

\n

Порядок действий

\n
  1. Выбрать один маршрут и записать его владельца, читателя, писателя, запись и зависимости.
  2. Добавить новый формат без удаления старого и проверить, что обе версии могут читать данные.
  3. Назвать источник, назначение, правило сверки и write state, который возвращается при отказе.
  4. Сформировать control и candidate на одной границе маршрута и определить окно наблюдения.
  5. Заранее записать trigger, владельца решения, возврат маршрута и восстановление записи.
  6. Запустить учебную или тестовую проверку с отрицательными примерами: пустой писатель, несовпадающие ключи и rollback без data state.
  7. Расширять срез только после проверки фактических данных и разрешения, принятого владельцем сервиса.
  8. Удалять старый контракт последним, когда читатели и писатели больше от него не зависят.
\n

Ограничения

\n

Схема не выбирает способ репликации и не задаёт универсальный процент трафика. Она не решает конфликты конкурентной записи, задержку репликации, изменение индексов или восстановление внешних потребителей. PostgreSQL предупреждает, что логическая репликация может остановиться на конфликте ограничений, а некоторые отсутствующие строки при обновлении или удалении пропускаются. Значит, одну сверку количества строк нельзя считать доказательством эквивалентности.

\n

Схема также не заменяет rehearsal. Учебный объект проверяет полноту описания, но не проверяет реальную выборку. Для production нужны контрольные запросы, журнал изменений, лимит времени, доступ к процедуре восстановления и ответственный, который может остановить переход. Если хотя бы один из этих элементов не проверен, критерий готовности не выполнен.

\n

Проверяемый критерий готовности

\n

Переход готов к отдельному решению владельца, когда для выбранного маршрута можно воспроизвести одну запись в старом и новом представлении, показать правило сверки, назвать control и candidate, а также выполнить отрицательный сценарий с точным trigger. Отдельно должно быть понятно, как новые записи вернутся к источнику. Если команда может только вернуть контейнер, но не объяснить судьбу данных, миграция не готова.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/055.json b/editorial/agent-rewrites/055.json new file mode 100644 index 0000000..8bdeb78 --- /dev/null +++ b/editorial/agent-rewrites/055.json @@ -0,0 +1,7 @@ +{ + "index": 55, + "slug": "editorial-2026-06-field-multi-runtime", + "title": "Когда PHP, JavaScript и D говорят разное: как закрыть границу контракта", + "excerpt": "Разные runtime не становятся одной системой от похожего JSON. Разберём наблюдаемый симптом, явный контракт, отрицательный путь и критерий, который отделяет проверяемую модель от заявления об интеграции.", + "contentHtml": "

Запрос проходит через PHP, затем попадает в JavaScript и заканчивается обработкой на D. Пользователь видит ошибку без понятной причины, а оператор не может связать запись D с исходным запросом. Иногда все три части возвращают похожий JSON, но одна сторона считает ошибку исключением, другая — обычным значением, а третья записывает время в другой шкале. Внешне система работает. Внутри она уже потеряла общий смысл.

\n

Цена такой ошибки выше, чем один неудачный запрос. Команда может повторить несовместимый ответ, принять его за временный сбой и включить повторную попытку там, где операция уже выполнена. Можно получить двойное списание, зависшую задачу или неверный отчёт. Похожая форма данных не защищает от разных правил обработки.

\n

Тезис: общий смысл живёт на границе

\n

Три runtime можно соединить только через узкий, именованный контракт. Он должен назвать операцию, форму успешного значения, форму ошибки и основание времени. Каждый адаптер обязан сохранить эти поля без скрытого приведения. Если поле нельзя сопоставить, система должна остановиться и назвать причину. Молчаливое «примерно подходит» опаснее явного отказа.

\n

В этом материале проверяется модель границы. Учебный пример хранит запись в памяти JavaScript и читает её обычной функцией. Он не запускает PHP, JavaScript как отдельный процесс или D. Он не обращается к сети, диску, часам, телеметрии и сервисам. Поэтому его результат говорит только о внутренней сопоставимости записи. Это ограничение входит в смысл примера.

\n

Механизм: четыре поля, которые нельзя угадывать

\n

Сначала назовите операцию. Строка fixed-order-decision лучше, чем общий «обработчик заказа»: у неё есть конкретная граница. Затем задайте value tag. В примере это order-ready. Число 4200 получает единицу и валюту. Без tag и единицы потребитель может принять копейки за рубли или число лимита за сумму.

\n

Ошибка получает named envelope. В нём явно присутствуют semantics, code и retry. Значение code: null означает отсутствие кода внутри известного envelope. Отсутствующий сам envelope означает другую проблему. Эти два случая нельзя сливать в одну пустую строку.

\n

Время в изолированной модели задаётся ordered fixed logical ticks. Пара 100..108 показывает порядок и интервал из восьми условных шагов. Это не миллисекунды, не latency и не SLA. Если нужен production-замер, он требует отдельного источника времени, политики измерения и проверки среды.

\n
const contract = {\n  schemaVersion: 'fixed-boundary-1',\n  operation: 'fixed-order-decision',\n  value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n  time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n};\n\nconst adapters = ['php', 'javascript', 'd'].map((model) => ({\n  model,\n  contractVersion: 'fixed-boundary-1',\n  valueTag: 'order-ready',\n  errorSemantics: 'named-envelope',\n  timeBasis: 'fixed-logical-ticks',\n  mapping: 'exact'\n}));\n\nfunction check(record) {\n  if (record.schemaVersion !== 'fixed-boundary-1') {\n    return { status: 'stop-incomplete-contract', reason: 'schema-version-missing' };\n  }\n  if (record.adapters.some((item) => item.mapping !== 'exact')) {\n    return { status: 'stop-incomparable-adapter', reason: 'mapping-is-not-exact' };\n  }\n  return { status: 'synthetic-review-hand-off', observedEffect: 'none-observed' };\n}\n\nconsole.log(check({ ...contract, adapters }));
\n

Пример учебный. Он показывает форму проверки, а не совместимость библиотек. В нём имена php, javascript и d — значения поля model. Они не доказывают, что три языка обменялись данными. Положительный результат означает лишь: запись содержит названные поля, версии совпадают, а mapping не скрывает преобразование.

\n

Как распознать ложное совпадение

\n

Одинаковый текст ошибки не задаёт одинаковое действие. PHP может вернуть envelope, JavaScript — выбросить значение, а D — записать код в отдельное поле. Потребитель, который проверяет только сообщение, потеряет режим завершения. Сравнивайте не текст, а семантику: кто владеет ошибкой, можно ли повторить операцию и какие данные сохраняются.

\n

Одинаковое число времени тоже ничего не гарантирует. Один адаптер может передать логические шаги, другой — epoch seconds. Числа совпадут случайно, а вывод окажется ложным. Поэтому basis должна быть полем контракта и каждого адаптера. Пропущенный closed должен закрывать проверку, а не заменяться текущим временем.

\n
Симптомы на границе и проверяемое действие
СимптомПричинаПроверкаДействие
Ответы похожи, но retry ведёт себя по-разномуEnvelope смешан с thrown valueСравнить error semantics и наличие code/retryВыровнять envelope или вернуть stop
Время совпадает только на одном стендеРазные time basisПроверить basis и обе границы интервалаНазвать одну шкалу или прекратить сравнение
Сумма проходит проверку, но меняет порядок величиныTag, unit или currency угадываются по имениПроверить value tag, amountMinor и currencyДобавить явное поле и запретить inference
Адаптер «почти» совпадает с contractСкрытая coercion при mappingПотребовать mapping: exactОписать преобразование явно либо остановить hand-off
Нельзя связать запись D с запросом PHPНет общего correlation fieldПроверить, входит ли идентификатор в contractДобавить поле в новый контракт; не восстанавливать связь по времени
\n
\"Цикл
Схема показывает чтение одной записи в памяти. Стрелки не означают сетевое соединение, запуск runtime или измерение production.
\n

Отрицательный путь важнее happy path

\n

Проверка должна отказываться от удобного вывода. Возьмём запись, где у одного адаптера errorSemantics: 'thrown-value', а у контракта остаётся named-envelope. Такой набор нельзя объявить совместимым. Функция возвращает stop-incomparable-adapter и оставляет владельцу конкретное действие: выровнять семантику.

\n

Другой случай: time.basis равен wall-clock, а closed отсутствует. Здесь нельзя написать «обработка заняла неизвестное время» и нельзя вычислить значение из текущих часов. Проверка должна вернуть stop-undetermined-time-boundary. Она не спорит о точности. Она фиксирует отсутствие основания для сравнения.

\n

Третий случай — coercion. Если адаптер превратил строку в число, округлил сумму или заменил пустое значение значением по умолчанию, результат уже не exact mapping. Приведение может быть правильным в конкретной программе, но оно требует правила, единицы и теста. До этого момента оно скрывает смысл и закрывает hand-off.

\n

Порядок действий

\n
  1. Назовите одну операцию и версию схемы. Не начинайте с описания всех сервисов.
  2. Опишите value через tag и явные единицы: например, amountMinor и currency.
  3. Опишите error envelope целиком. Зафиксируйте code, retry и допустимое отсутствие кода.
  4. Выберите одну time basis и запишите ordered opened и closed. Не называйте ticks миллисекундами без источника.
  5. Добавьте по одной записи для PHP, JavaScript и D. Сверяйте каждый адаптер с contract.
  6. Запретите скрытое приведение. Для каждого преобразования добавьте именованное правило или верните stop.
  7. Прогоните положительный и отрицательные случаи через одну проверку. Сохраните status и nextAction без редакторской интерпретации.
  8. Передайте запись на следующий review только как synthetic hand-off. Реальную интеграцию проверяйте отдельным набором доказательств.
\n

Что можно утверждать после проверки

\n

Допустимая формулировка узкая: «фиксированная запись соответствует названным правилам и может перейти на synthetic review». Нельзя писать «PHP, JavaScript и D совместимы», «адаптер работает», «интеграция подтверждена» или «latency равна восьми». У модели нет runtime, транспорта, хоста, зависимостей, прав, пользовательских данных и production-метрик.

\n

Это не бюрократическая оговорка. Явная граница защищает решение от расширения смысла при копировании. Читатель видит, какой факт проверен, а какой ещё требует отдельного эксперимента. Если понадобится связать PHP-запрос и запись D, добавьте correlation id и проверьте его на реальном пути. Не выводите связь из одинакового времени, порядка строк или похожего JSON.

\n

Ограничения

\n

Модель не описывает ABI, сериализацию, nullable policy, иерархию классов, stack unwinding, сборку мусора, планировщик, retry конкретного клиента или схему регистрации сервисов. Эти свойства нельзя спрятать в поле details. Если свойство влияет на решение, назовите его отдельным полем и задайте проверку. Если назвать его нельзя, остановите границу.

\n

Модель также не заменяет контракт домена. Tag order-ready говорит о форме значения, но не доказывает, что бизнес действительно разрешает выдавать заказ. Доменный смысл проверяет владелец операции. Техническая проверка должна передать ему точное значение и не присваивать себе его решение.

\n

Проверяемый критерий готовности

\n

Граница готова к synthetic hand-off, если выполнены все условия: есть schema version и operation; value имеет tag и единицы; error содержит named semantics, code и retry; time имеет одну basis и целые ordered ticks; присутствуют все три model labels; каждый адаптер сохраняет version, tag, error semantics, time basis и exact mapping; отрицательные случаи возвращают именованные stop; результат содержит observedEffect: none-observed.

\n

Проверяемый результат можно повторить на той же записи и получить тот же status. Если status меняется от текущих часов, сети, окружения или неявной нормализации, это уже не изолированная проверка. Если положительный status требует доверия к словам о запущенном сервисе, он выходит за границу примера. В обоих случаях работу нужно остановить и уточнить новый scope.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/056.json b/editorial/agent-rewrites/056.json new file mode 100644 index 0000000..16ed3d4 --- /dev/null +++ b/editorial/agent-rewrites/056.json @@ -0,0 +1,7 @@ +{ + "index": 56, + "slug": "editorial-2026-06-mechanism-multi-runtime", + "title": "Один payload, три runtime: как сохранить смысл на границе", + "excerpt": "Похожая структура данных не делает PHP, JavaScript и D взаимозаменяемыми. Разбираем контракт type, error и time, отрицательный путь и проверяемый критерий совместимости.", + "contentHtml": "

Сервис принял заказ и вернул объект с полями amount, currency и status. PHP назвал статусом готовности строку ready. JavaScript обработал её как обычный результат. D получил тот же набор полей, но считает отсутствие кода ошибки отдельным состоянием. На границе всё выглядит одинаково. После сбоя команда видит три разных решения, а в логах остаётся один красивый JSON.

\n

Цена такой ошибки — не только неверное сообщение. Клиент может повторить уже принятый заказ, worker может пропустить отказ, а расследование свяжет события по совпадающим полям, хотя они относятся к разным состояниям. Исправление обычно начинается с догадки: добавить retry, привести значение к строке или считать пустой код успехом. Каждая такая догадка расширяет зону риска.

\n

Тезис статьи простой: общий boundary contract должен называть не только поля, но и их смысл. Для минимальной проверки достаточно разделить три независимые оси: type отвечает за вид значения, error — за режим завершения, time — за определённую шкалу и порядок. Если одна ось не задана или подменена другой, проверка должна остановиться.

\n

Почему одинаковый JSON не равен общему контракту

\n

JSON переносит форму. Он не переносит договор о поведении. Число 4200 может означать сумму в копейках, лимит, внутренний идентификатор или случайный счётчик. Строка ready может быть именем состояния, текстом для интерфейса или результатом нестрогого сравнения. Без названного типа consumer вынужден угадывать.

\n

Ошибки создают второй разрыв. Один runtime может вернуть объект результата с полем error. Другой может завершить операцию исключением. Третий может вернуть код и продолжить выполнение. Человек способен описать эти случаи одной фразой «обработка ошибки». Адаптеру такой фразы недостаточно: ему нужно знать, можно ли повторять операцию, сохранено ли значение и кто владеет решением.

\n

Время создаёт третий разрыв. Длительность из monotonic clock нельзя без оговорки сравнивать с календарным timestamp. Два числа без шкалы не доказывают latency. Даже одинаковые начало и конец могут быть только порядковыми метками внутри тестового объекта, а не наблюдением работающей системы.

\n
\"Матрица
Иллюстрация разделяет три вопроса boundary. Матрица показывает структуру проверки, а не сравнительную характеристику PHP, JavaScript и D и не результат запуска в конкретной среде.
\n

Три оси контракта

\n

type должен содержать named tag. Не выводите его из соседних полей. В примере order-ready — это фиксированная метка значения, а amountMinor и currency — дополнительные поля с собственной единицей и форматом. Если адаптер заменяет tag числом или оставляет его пустым, shape больше нельзя считать сохранённым.

\n

error должен описывать режим завершения. Удобная минимальная форма — semantics, code и retry. Значение code: null означает отсутствие кода в данном envelope. Оно не означает «в системе ошибок нет». Значение retry: not-requested не запускает повтор и не обещает, что повтор безопасен. Для этого нужны отдельные правила идемпотентности.

\n

time должен называть basis и обе границы интервала. Fixed logical ticks подходят для проверки порядка внутри заранее заданной записи. Они не являются миллисекундами. Если контракт требует наблюдаемую длительность, ему нужны источник измерения, единицы, точка начала и точка окончания. Нельзя подставить текущие часы, чтобы получить зелёный результат.

\n

Три оси проверяются отдельно. Ошибка не сообщает тип значения. Числовое поле не задаёт единицу времени. Наличие timestamp не подтверждает, что операция завершилась успешно. Такое разделение кажется избыточным только до первого неоднозначного отказа.

\n

Учебный пример проверки

\n

Ниже приведён ограниченный JavaScript-пример. Он проверяет только объект в памяти и возвращает причину остановки. Он не запускает PHP или D, не вызывает сеть, не читает часы, не измеряет производительность и не подтверждает поведение сервиса. Его задача — показать форму fail-closed проверки.

\n
const contract = {\n  schemaVersion: 'boundary-1',\n  value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n  error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n  time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n};\n\nconst adapters = [\n  { model: 'php', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'javascript', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n  { model: 'd', version: 'boundary-1', valueTag: 'order-ready',\n    errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' }\n];\n\nfunction review(record, participants) {\n  if (!record.schemaVersion || participants.length !== 3) {\n    return 'stop-incomplete-contract';\n  }\n  if (record.time.basis !== 'fixed-logical-ticks' ||\n      record.time.closed === null || record.time.closed < record.time.opened) {\n    return 'stop-undetermined-time-boundary';\n  }\n  const valid = participants.every((item) =>\n    item.version === record.schemaVersion &&\n    item.valueTag === record.value.tag &&\n    item.errorSemantics === record.error.semantics &&\n    item.timeBasis === record.time.basis &&\n    item.mapping === 'exact'\n  );\n  return valid ? 'accepted-fixed-contract' : 'stop-incomparable-adapter';\n}\n\nconsole.log(review(contract, adapters)); // accepted-fixed-contract
\n

Пример сравнивает ровно те сведения, которые записаны в объекте. Он не делает вывод о типовой системе языка по полю model. Подписи php, javascript и d здесь лишь заранее названные участники матрицы. Версия runtime, библиотека, transport и формат сериализации в этот объект не входят.

\n

В реальном коде такой boundary нужно реализовать в согласованном контракте и покрыть тестами конкретного продукта. Нельзя скопировать функцию и объявить интеграцию проверенной. Учебный объект специально маленький: он помогает увидеть missing field и смешанную семантику, но не заменяет контракт API.

\n

Симптом → причина → проверка → действие

\n
Признаки потери смысла на границе и минимальная реакция
СимптомПричинаПроверкаДействие
Одинаковый shape даёт разные решенияНет named value tag или версии схемыСверить tag, schemaVersion и единицу каждого поляДобавить обязательные поля; не выводить смысл из shape
Один участник бросает ошибку, другие возвращают envelopeСмешаны error semanticsСравнить режим завершения, code и retryВыбрать один boundary mode или остановить mapping
Отчёт говорит «быстрее», но метрики нетLogical ticks приняли за latencyПроверить basis, источник часов и обе границыУдалить вывод о скорости либо завести отдельное измерение
Повтор после таймаута создаёт второй заказRetry назван, но идемпотентность не определенаПроверить operation key и эффект повторного вызоваОстановить повтор; согласовать ключ и политику отдельно
«Успех» появляется при неполном объектеПроверка подставляет default вместо отказаУдалить default и прогнать missing-caseВернуть точную stop-причину владельцу контракта
\n

Отрицательный путь важнее зелёного примера

\n

Положительный объект удобен, но он почти ничего не говорит о дисциплине контракта. Настоящая проверка начинается с испорченной записи. Удалите schemaVersion. Ожидаемый результат — stop-incomplete-contract. Не подставляйте текущую версию автоматически: иначе тест перестанет замечать несовместимый участник.

\n

Замените у JavaScript-участника errorSemantics на thrown-value. Ожидаемый результат — stop-incomparable-adapter. Проверка не должна оборачивать исключение в envelope задним числом. Такое преобразование может быть правильным решением продукта, но тогда оно должно быть отдельным адаптером с названными правилами, а не скрытой операцией review.

\n

Измените time.basis на wall-clock и оставьте closed: null. Ожидаемый результат — stop-undetermined-time-boundary. Нельзя сказать «интервал неизвестен, но примерно короткий». В этом объекте нет факта, который поддерживает такую оценку.

\n

Такой путь защищает и от незаметной нормализации. Coercion может сделать данные удобнее для одного consumer, но скрыть различие между целым числом и tagged value. Если преобразование нужно, его надо назвать, версионировать и проверить как новую границу. Молчаливое приведение не является доказательством совместимости.

\n

Порядок действий

\n
  1. Назовите одну операцию и владельца её смысла. Не начинайте с общего утверждения «три языка совместимы».
  2. Запишите schema version, value tag, единицы полей и допустимые пустые значения.
  3. Определите один error envelope или другой единый режим завершения. Отдельно назовите retry и условие его безопасности.
  4. Выберите time basis. Для логического порядка зафиксируйте обе границы; для latency укажите источник измерения и единицы.
  5. Составьте по одной записи для PHP, JavaScript и D. Сравнивайте каждую с контрактом, а не одну реализацию с другой.
  6. Запустите positive-case и четыре negative-case: missing schema, mixed error, неизвестный tag и незакрытый интервал.
  7. Для каждого отказа сохраните точную причину. Не заменяйте её общим «adapter error».
  8. Только после успешной проверки границы подключайте конкретный transport, runtime и наблюдение. Их результаты нельзя приписывать синтетическому объекту.
\n

Ограничения применимости

\n

Контрактная матрица не делает разные языки одинаковыми. Она не описывает сборщик мусора, правила приведения типов, исключения, ABI, сериализатор или планировщик. Эти свойства могут влиять на интеграцию и требуют отдельных источников и тестов. Матрица только не даёт спрятать их за одинаковым полем.

\n

Даже официальный документ языка отвечает на вопрос о данном языке, а не о совместимости трёх систем. Например, строгая типизация PHP может изменить момент отказа, completion record ECMAScript описывает семантику спецификации JavaScript, а D документирует собственную обработку ошибок. Ни один из этих фактов сам по себе не доказывает общий API.

\n

Матрица также не проверяет бизнес-смысл суммы, права пользователя, повторную доставку сообщения или транзакцию. Для них нужны свои поля, владельцы и отрицательные сценарии. Если boundary не может выразить важное условие, нельзя считать его достаточным только потому, что все участники прошли текущую проверку.

\n

Проверяемый критерий готовности

\n

Граница готова к следующему техническому тесту, если другой инженер без устного пояснения может восстановить: какую операцию описывает запись, какой tag означает допустимое значение, как кодируется ошибка, что означает retry, какая шкала времени используется и какой результат даёт каждый отрицательный случай.

\n

Дополнительное условие — все три участника сохраняют contract shape без неявного приведения, а положительный результат не содержит утверждения о latency, deployment или реальном поведении среды. Если хотя бы одно поле приходится угадывать, проверка должна закончиться именованной stop-причиной. Это и есть полезный результат: команда видит границу знания до того, как похожий payload станет ошибочным действием.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/057.json b/editorial/agent-rewrites/057.json new file mode 100644 index 0000000..fc8e0da --- /dev/null +++ b/editorial/agent-rewrites/057.json @@ -0,0 +1,7 @@ +{ + "index": 57, + "slug": "editorial-2026-06-practice-multi-runtime", + "title": "PHP, JavaScript и D: как удержать общий контракт на границе runtime", + "excerpt": "Когда один ответ проходит через PHP, JavaScript и D, похожие поля ещё не означают одинаковый смысл. Разбираем узкий контракт, fail-closed проверку и границу между учебной моделью и реальной интеграцией.", + "contentHtml": "

Симптом обычно выглядит безобидно: PHP возвращает объект заказа, JavaScript показывает его как готовый, а D-обработчик принимает тот же пакет после адаптации. В логах остаются одинаковые поля, но в редком случае одно отсутствие превращается в 0, другая ветка сохраняет исключение, а третья считает время по другой шкале. Ошибка обнаруживается уже после передачи данных. Цена — неверное решение, повторная обработка или часы разбора, потому что команда спорит о runtime вместо формы сообщения.

\n

Тезис простой: общий контракт нужно проектировать на границе задачи, а не выводить из сходства языков. Для учебной проверки достаточно одного объекта в памяти. В нём надо явно назвать операцию, вид значения, семантику ошибки, шкалу времени и правила адаптеров. Если хотя бы одно поле нельзя сравнить, проверка должна остановиться. Такой результат подтверждает только внутреннюю согласованность модели. Он не подтверждает работу PHP, JavaScript, D или production-сервиса.

\n

Почему одинаковый payload обманывает

\n

JSON-подобная форма скрывает решения. Число может означать деньги в минимальных единицах, счётчик или результат преобразования. Пустое поле может означать отсутствие значения, ошибку или значение по умолчанию. Время может быть timestamp, длительностью или логическим порядком событий. Если контракт не называет эти свойства, каждый адаптер заполняет пробел своим правилом.

\n

Нужен узкий boundary contract. Он не пытается описать всю систему и не переносит внутренние классы, stack trace, сборщик мусора или планировщик. Он отвечает на один вопрос: сохраняют ли три представления одну заранее названную форму. Поэтому в нём нет неявного default. Отсутствующее поле ведёт к отказу, а не к удобной подстановке.

\n

Из чего состоит граница

\n

В примере операция называется fixed-order-decision. Поле value.tag отделяет вид значения от его представления. amountMinor: 4200 — учебное целое число; оно не объявляет денежный протокол и не должно автоматически превращаться во float. Поле error использует именованный конверт: в нём есть семантика, код и правило повтора. Это не объект исключения и не текст сообщения.

\n

Время задаётся двумя упорядоченными логическими отметками. Числа 100 и 108 дают разность восемь внутри учебной шкалы. Они не являются timestamp и не показывают latency. Каждый адаптер получает ту же версию схемы, тот же tag, ту же семантику ошибки, ту же шкалу времени и результат exact. Приведение типа скрывает потерю смысла, поэтому его надо отклонять.

\n
Минимальные поля общего контракта
ПолеЗачем оно нужноКогда остановиться
schemaVersionСвязывает верхний объект и адаптеры.Версия пустая или различается.
value.tagНазывает вид значения до преобразования.Tag отсутствует или подменён.
errorФиксирует code и retry без object identity.Нет именованного конверта.
timeЗадаёт одну сравнимую шкалу.Нет двух упорядоченных отметок.
mappingПоказывает сохранение формы.Используется coercion вместо exact.
\n
\"Три
Схема показывает структуру границы. Она не изображает соединение процессов и не является трассировкой реальной системы.
\n

Учебный пример в памяти

\n

Ниже выполняется только JavaScript-код, который читает заранее заданный объект. Строки php, javascript и d — метки взглядов на форму, а не запущенные процессы. Пример полезен для проверки правил и отрицательных веток. Он не доказывает совместимость библиотек, транспортов или окружений.

\n
const record = {\n  id: 'named-contract-v1',\n  schemaVersion: 'fixed-boundary-1',\n  contract: {\n    operation: 'fixed-order-decision',\n    value: { tag: 'order-ready', amountMinor: 4200, currency: 'RUB' },\n    error: { semantics: 'named-envelope', code: null, retry: 'not-requested' },\n    time: { basis: 'fixed-logical-ticks', opened: 100, closed: 108 }\n  },\n  adapters: [\n    { model: 'php', contractVersion: 'fixed-boundary-1', valueTag: 'order-ready', errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n    { model: 'javascript', contractVersion: 'fixed-boundary-1', valueTag: 'order-ready', errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' },\n    { model: 'd', contractVersion: 'fixed-boundary-1', valueTag: 'order-ready', errorSemantics: 'named-envelope', timeBasis: 'fixed-logical-ticks', mapping: 'exact' }\n  ]\n};\n\nconst models = new Set(record.adapters.map(({ model }) => model));\nconst accepted =\n  record.schemaVersion === 'fixed-boundary-1' &&\n  record.contract.time.closed >= record.contract.time.opened &&\n  ['php', 'javascript', 'd'].every((model) => models.has(model)) &&\n  record.adapters.every((adapter) =>\n    adapter.contractVersion === record.schemaVersion &&\n    adapter.valueTag === record.contract.value.tag &&\n    adapter.errorSemantics === record.contract.error.semantics &&\n    adapter.timeBasis === record.contract.time.basis &&\n    adapter.mapping === 'exact'\n  );\n\nconsole.log({ accepted, externalEffect: 'not-checked' });
\n

Положительный результат означает: поля учебного объекта соответствуют названным правилам. externalEffect: not-checked удерживает смысл результата рядом с кодом. Если его убрать, читатель легко примет accepted: true за доказательство, что три системы связаны.

\n

Симптом → причина → проверка → действие

\n
Диагностика несогласованной границы
СимптомПричинаПроверкаДействие
Пропущенный amount читается как ноль.Нет отдельного tag для отсутствия.Сверить value.tag и наличие поля.Добавить именованный вариант или остановить проверку.
Один адаптер хранит thrown value.Смешаны semantics ошибки.Сравнить errorSemantics буквально.Выровнять конверт или вернуть stop-incomparable-adapter.
Для одного ответа считают duration.Нет общей шкалы и закрывающей отметки.Проверить basis, opened и closed.Задать ordered fixed ticks; не подставлять часы.
Адаптер возвращает похожее число.Форма прошла coercion.Проверить mapping на exact.Убрать приведение или описать новое поле и версию.
Пример называют интеграционным тестом.Метки моделей приняли за процессы.Перечислить реально запущенные компоненты.Сузить вывод до проверки объекта в памяти.
\n

Порядок проверки

\n
  1. Назовите одну операцию. Не смешивайте в одном объекте заказ, платёж и доставку.
  2. Зафиксируйте версию схемы и tag значения. Не используйте «любой JSON».
  3. Опишите ошибку отдельным именованным конвертом: semantics, code и retry.
  4. Выберите одну шкалу времени и запишите обе упорядоченные отметки.
  5. Добавьте по одной записи для PHP, JavaScript и D. Сверьте поля буквально.
  6. Проверьте exact mapping. Любая скрытая конверсия должна вернуть отказ.
  7. Прогоните положительный и отрицательные варианты. Сохраните status и точную причину.
  8. Сформулируйте результат в пределах наблюдения: «форма объекта согласована», а не «интеграция работает».
\n

Отрицательный путь важнее happy path

\n

Пустая версия или отсутствующий адаптер должны вернуть stop-incomplete-contract. Не надо принимать частичный объект ради продолжения разбора. Если JavaScript записывает thrown-value, а два других адаптера используют named-envelope, результат — stop-incomparable-adapter. Это не утверждение о поведении языков. Это точное описание несовпадения полей.

\n

Если closed отсутствует или basis равен wall-clock, верните stop-undetermined-time-boundary. Нельзя восстановить длительность из незаписанного события и нельзя превратить учебные ticks в метрику. Если новые требования делают эти поля недостаточными, создайте новую версию контракта. Не прячьте смысл в поле metadata.

\n

Ограничения

\n

Модель не описывает nullable semantics, ABI, сериализацию, transport, версии пакетов, доступы, retry конкретного клиента или бизнес-значение заказа. Она не запускает PHP, D или отдельный JavaScript runtime, не читает сеть и диск, не использует часы, не собирает telemetry, trace или profile и не содержит пользовательских данных. Поэтому из неё нельзя вывести latency, SLA, безопасность, совместимость релиза или готовность deploy.

\n

В production такой контракт может стать частью отдельного теста совместимости, но это потребует реальных входов, владельцев, версий и наблюдаемого результата. Учебный объект не заменяет этот тест. Он только не даёт начать разговор с ложного утверждения.

\n

Проверяемый критерий готовности

\n

Материал и его пример готовы, если независимый читатель может повторить проверку по одному объекту и получить одно из двух: accepted с перечисленными полями или точный stop reason. При accepted все три model label присутствуют, версия совпадает, tag и error semantics совпадают, время упорядочено, mapping равен exact, а итог прямо говорит externalEffect: not-checked. При отказе причина указывает на конкретное недостающее поле. Ни один результат не использует слова «интеграция подтверждена» без отдельного runtime-доказательства.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/058.json b/editorial/agent-rewrites/058.json new file mode 100644 index 0000000..71d5945 --- /dev/null +++ b/editorial/agent-rewrites/058.json @@ -0,0 +1,7 @@ +{ + "index": 58, + "slug": "editorial-2026-05-field-systems-performance", + "title": "Низкий CPU, длинный запрос: где возникает задержка", + "excerpt": "Пользователь ждёт ответ, хотя CPU почти свободен. Разбираем очередь, вложенные интервалы, сопоставимую нагрузку и отказ от ложной оптимизации.", + "contentHtml": "

Пользователь ждёт страницу десять секунд, а график CPU держится на двадцати процентах. Разработчик видит свободный процессор и меняет запрос, добавляет поток или увеличивает таймаут. Иногда это случайно скрывает симптом. Часто задержка остаётся: запрос ждал допуска в очередь, соединение с базой или ответ внешней системы. Цена ошибки — лишний релиз, рост нагрузки и потеря исходного сигнала. После изменения уже трудно восстановить исходные условия сравнения.

\n

Тезис простой: низкая загрузка CPU не опровергает медленный запрос. Сначала разложите end-to-end интервал на наблюдаемые части и назовите границу сравнения. Только после этого выбирайте действие. Один trace показывает структуру пути. Он не доказывает, что изменение ускорит систему.

\n

Механизм: задержка не равна работе процессора

\n

Общее время запроса включает ожидание и работу. Запрос может стоять в очереди, пока CPU свободен. Он может ждать соединение, блокировку строки, диск, DNS, TLS или ответ удалённого сервиса. В эти моменты процессор не обязан быть занят. Метрика CPU отвечает на вопрос о занятости вычислительного ресурса, но не о времени ответа конкретного запроса.

\n

Trace отделяет участки пути, если дерево полно и интервалы используют одну временную основу. Root span задаёт end-to-end границу. Дочерний span показывает названную операцию внутри неё. Если дочерний интервал не покрывает разницу, остаток остаётся неизвестным. Его нельзя без отдельного сигнала назвать очередью, сетью или базой.

\n

Сравнение требует второй границы. Записи до и после изменения должны иметь один класс входа, одинаковое число запросов, одинаковую конкурентность и одинаковую форму данных. Если один прогон обрабатывает 12 запросов при concurrency 3, а второй — 24 при concurrency 6, разница времени ничего не говорит об изменении кода. Более короткий интервал может означать другую нагрузку.

\n
Проверка задержки: полный trace, контрольная граница нагрузки, контрпример и остановка при нехватке данных
Схема связывает путь запроса с границей сравнения. Если одного условия не хватает, вывод о причине задержки прекращается. Это учебная схема, а не измерение.
\n

Учебный пример: не перепутать очередь с причиной

\n

Ниже — учебный пример с заранее заданными значениями. Он не обращается к сети, базе, часам или профайлеру. Единицы условны. Код показывает проверку структуры, а не результат работы сервиса.

\n
const trace = {\n  root: { id: 'root-01', start: 0, end: 1000 },\n  spans: [\n    { id: 'queue-01', parent: 'root-01', name: 'admission-queue', start: 40, end: 560 },\n    { id: 'db-01', parent: 'root-01', name: 'db-call', start: 570, end: 720 },\n    { id: 'catalog-01', parent: 'root-01', name: 'catalog-call', start: 730, end: 930 }\n  ],\n  load: { cohort: 'load-a', requests: 12, concurrency: 3, shape: 'read-shape-a' }\n};\n\nfunction inspectTrace(input) {\n  const ids = new Set(input.spans.map((span) => span.id));\n  const connected = input.spans.every((span) =>\n    span.parent === input.root.id || ids.has(span.parent)\n  );\n  const ordered = input.spans.every((span) =>\n    Number.isFinite(span.start) && Number.isFinite(span.end) && span.end >= span.start\n  );\n\n  if (!connected) return { status: 'stop-incomplete-trace' };\n  if (!ordered) return { status: 'stop-invalid-interval' };\n  return { status: 'observation-ready', claim: 'not-measured' };\n}\n\nconsole.log(inspectTrace(trace));
\n

Результат observation-ready означает только, что учебная запись связна и содержит интервалы. Queue span занимает 520 условных единиц из 1000. Это повод проверить очередь отдельным сигналом. Код не доказывает, что очередь является корнем задержки, что база виновата или что удаление очереди ускорит пользователя.

\n

Отрицательный путь важнее короткого положительного. Если у span parent равен missing-01, функция возвращает stop-incomplete-trace. Если записи до и после изменения используют разные поля load, их нельзя сравнивать. Если в записи стоит effect: 'faster-after-change', это не измерение. Такое утверждение нельзя принимать без наблюдаемых данных.

\n

Симптом → причина → проверка → действие

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
CPU низкий, запрос медленныйОчередь, блокировка или внешний ответ входит в end-to-end времяОткрыть trace и разделить ожидание, локальную работу и дочерние вызовыНазвать только покрытый span; неизвестный остаток оставить unknown
Самый длинный span совпал с пиком latencySpan включает ожидание upstream или retryПроверить parent/child, status, retry count и дочерние интервалыНе объявлять span причиной; добавить недостающую границу
Вторая запись короче первойИзменилась нагрузка, cohort или форма входаСверить requests, concurrency, cohort и input shapeСнять сравнение и повторить с одной control boundary
Дочерний span без parentПотеря записи, ошибка экспорта или неверный IDПроверить полный экспорт, уникальность ID и формат связиВернуть stop; не дорисовывать дерево по времени
После изменения есть одна короткая записьНет сопоставимой пары и распределения наблюденийСравнить тот же сценарий до и после на заданном окнеНазвать observation, а не improvement
\n

Как читать интервалы

\n

Сначала найдите root span и его границы. Затем проверьте, что каждый дочерний span имеет существующего родителя, начало не позже конца, а единицы времени совпадают. Интервалы могут перекрываться. Нельзя складывать все длительности и получать время ответа: параллельные операции будут посчитаны дважды.

\n

Если root длится 1000 условных единиц, очередь — 520, база — 150, а каталог — 200, сумма дочерних интервалов равна 870. Она не означает, что оставшиеся 130 — сеть. Часть времени могла пересекаться, а часть могла прийтись на неразмеченную работу. Корректная формулировка: «в записи есть 130 единиц, которые не покрыты названными span-ами». Их нельзя приписывать компоненту без отдельной границы.

\n

Время ожидания и время исполнения также нельзя смешивать. База могла выполнить запрос быстро после освобождения соединения. Внешний вызов мог вернуть ответ быстро, но запрос долго ждал его начала. Название span должно отражать проверенное содержание. db-call не равно «всё время до базы», если выдача соединения записывается отдельно.

\n

Порядок действий

\n
  1. Зафиксируйте исходный симптом: маршрут, метод, статус, длительность, размер ответа, timestamp и идентификатор запроса.
  2. Сохраните trace до изменения кода или конфигурации. Отметьте root, дочерние операции, пропуски и неизвестные интервалы.
  3. Проверьте связность дерева и единицы времени. Отдельно отметьте перекрывающиеся span-ы; не складывайте их механически.
  4. Назовите контрольную границу: cohort, число логических запросов, concurrency и форму входных данных.
  5. Разделите ожидание и работу. Не называйте остаток причиной, пока для него нет отдельного сигнала.
  6. Сформулируйте две конкурирующие гипотезы. Для каждой запишите проверку, которая может её опровергнуть.
  7. Проверьте отрицательный случай: отсутствующий parent, неизвестная задержка или другая нагрузка должны вернуть точный stop.
  8. Измените один фактор. Повторите тот же сценарий и сохраните записи до и после рядом.
  9. Сопоставьте исходный симптом с соседними сигналами: ошибки, таймауты, очередь, throughput и потребление ресурсов. Не заменяйте пользовательскую задержку одним CPU-графиком.
\n

Что следует из записи

\n

Узкий результат может быть полезным. Например: «В trace-01 при load-a root равен 1000 условных единиц. Названный queue span занимает 520. Дерево связано. Сравнение до и после не выполнялось». Это указывает на очередь как на место для отдельной проверки. Формулировка не содержит обещания исправления.

\n

Сильнее звучит, но не следует из записи: «очередь стала bottleneck», «изменение БД ускорит путь» и «latency снизилась». Для каждого утверждения нужна отдельная граница доказательства. Нельзя получить контрфактический эффект из одного trace: он не показывает, что произошло бы без выбранного вызова или при другой конкуренции.

\n

Ограничения

\n

Sampling может убрать нужный span. Collector может потерять запись или доставить события не по порядку. Асинхронный worker может продолжить работу после root span. Часы узлов могут расходиться. Retry может создать несколько операций с похожими именами. Эти условия не делают trace бесполезным, но снижают силу вывода. Их нужно записать рядом с наблюдением.

\n

Карта интервалов не заменяет нагрузочный тест. Она не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Она также не задаёт SLA. HTTP-стандарт описывает семантику запроса и ответа, а не конкретный бюджет latency. Для этих вопросов нужны собственные измерения и критерии.

\n

Учебный код нельзя считать проверкой реальной телеметрии. В нём заранее заданные числа, одна запись и известные поля. Он не проверяет экспорт, трассировку через прокси, поведение клиента и права доступа. Результат для работающего сервиса появляется только после измерения в описанной среде.

\n

Проверяемый критерий готовности

\n

Проверка достаточна для технического вывода, если другой инженер получает тот же вход и без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу нагрузки, отличить названную задержку от unknown и воспроизвести stop на неполном trace или несопоставимой нагрузке. Сравнение до и после допустимо, если записи сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а новый результат не маскирует ошибку ростом таймаутов или потерей сигнала.

\n

Если критерий не выполнен, вывод ограничивается нехваткой данных. Нельзя выбирать знакомый компонент только потому, что он виден на графике.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/059.json b/editorial/agent-rewrites/059.json new file mode 100644 index 0000000..4371817 --- /dev/null +++ b/editorial/agent-rewrites/059.json @@ -0,0 +1,7 @@ +{ + "index": 59, + "slug": "editorial-2026-05-mechanism-systems-performance", + "title": "Почему короткий trace не доказывает ускорение системы", + "excerpt": "Как отделить ожидание от работы, проверить сопоставимость нагрузки и остановить разбор, когда trace не подтверждает причинный вывод.", + "contentHtml": "

Фраза «БД медленная» часто появляется после одного взгляда на trace. На ней виден длинный промежуток, но не видно, ждёт ли запрос очередь, выполняет ли БД работу или задерживается внешний вызов. Цена ошибки — недели оптимизации не того участка. Команда меняет SQL, а пользовательский путь не становится короче.

\n

Надёжный разбор начинается с более узкого тезиса: длительность показывает интервал, но не его причину. Чтобы сравнить два результата, нужно одновременно сохранить связную trace, одинаковую границу нагрузки и ограниченный вывод. Если одно условие нарушено, правильное действие — остановиться и назвать недостающий факт.

\n

Одна trace и три разных времени

\n

Рассмотрим учебный пример. Root span описывает путь запроса от входа до ответа. Внутри него идут три последовательных интервала: admission queue ждёт 520 условных единиц, вызов БД занимает 150, внешний каталог — 200. Эти единицы придуманы для примера. Они не являются миллисекундами, метрикой сервиса или результатом production-измерения.

\n
Что видно в учебной trace и что можно сказать
СегментРольИнтервалДопустимый вывод
fixed-admission-queuequeue-wait40–560, 520 unitsСамый длинный названный сегмент этого input
fixed-db-calldatabase-execution570–720, 150 unitsИнтервал вызова БД в этой trace
fixed-catalog-callexternal-dependency730–930, 200 unitsИнтервал внешнего вызова в этой trace
fixed-gatewayend-to-end0–1000, 1000 unitsГраница пути, но не объяснение причины
\n

Таблица не говорит, что очередь замедляет production. Она не говорит, что SQL нужно переписать. Она фиксирует структуру одного учебного input. Это важное различие. Длинный span можно ранжировать. Причину нужно проверять отдельным экспериментом с той же границей.

\n
\"Две
Рисунок. Более короткий root не доказывает улучшение, если cohort, concurrency или форма входа изменились.
\n

Почему root span легко вводит в заблуждение

\n

Допустим, второй root равен 800 вместо 1000. На графике он выглядит лучше. Но во втором input одновременно изменились cohort, число логических запросов с 12 до 24, concurrency с 3 до 6 и форма чтения. Такой результат нельзя приписать предполагаемой оптимизации. Он описывает другой сценарий.

\n

Контрольная граница должна быть записана до сравнения длительностей. Для этого учебного примера она состоит из четырёх полей: cohort=fixed-load-a, logicalRequests=12, concurrency=3 и inputShape=fixed-read-shape-a. Это не универсальный стандарт нагрузки. Это минимальный контракт конкретной проверки. В другом сценарии набор полей будет иным, но правило останется тем же: заранее назвать условия, которые должны совпасть.

\n
const boundary = {\\n  cohort: 'fixed-load-a',\\n  logicalRequests: 12,\\n  concurrency: 3,\\n  inputShape: 'fixed-read-shape-a',\\n};\\n\\n// Учебный пример: отсутствие совпадающей границы\\n// запрещает называть разницу эффектом изменения.\\nconst comparable = sameBoundary(baseline, candidate, boundary);
\n

Код выше иллюстрирует только порядок проверки. Функция sameBoundary не измеряет latency и не находит bottleneck. Она отвечает на более простой вопрос: можно ли поставить два учебных результата рядом. В реальном проекте нужно явно определить сравниваемые поля, единицы измерения и правила обработки пропусков.

\n

Симптом → причина → проверка → действие

\n
Диагностическая таблица для разбора trace
СимптомВозможная причинаПроверкаДействие
Длинный интервал перед работойОжидание в admission queueЕсть отдельный span с ролью queue-wait и parent внутри rootОставить наблюдение; не называть очередь причиной production-задержки
Длинный span помечен только internalКласс задержки неизвестенПроверить роль и границы интервалаВернуть stop-hidden-queue и назвать ожидание unknown
Root стал корочеИзменилась нагрузка или форма входаСверить cohort, requests, concurrency и input shapeПри расхождении вернуть stop-incomparable-load
Дочерний span ссылается на отсутствующий parentTrace неполнаПроверить связность дерева и интервалыВернуть stop-incomplete-trace; не восстанавливать связь догадкой
В записи есть «стало быстрее»Заявлен эффект без контроляНайти baseline с той же границей и явный критерийСнять effect claim и оставить только наблюдение
\n

Fail-closed: отрицательный путь важнее красивого графика

\n

Неполный материал должен завершать разбор отказом. У учебного input incomplete-trace-v1 дочерний span указывает на отсутствующий parent. Нельзя определить, относится ли интервал к этому пути. Статус stop-incomplete-trace точнее, чем попытка соединить span по времени или имени.

\n

У hidden-queue-v1 длинный интервал называется fixed-unclassified-delay. Он похож на ожидание, но такого сходства недостаточно. Статус stop-hidden-queue означает: сначала назовите границу ожидания, затем продолжайте. Иначе команда переложит время на очередь только потому, что она удобна как объяснение.

\n

У incomparable-load-v1 root равен 800, но нагрузка отличается от baseline. Статус stop-incomparable-load не утверждает, что число 800 неверно. Он запрещает делать из него вывод об ускорении. Наконец, unsupported-effect-v1 содержит фразу faster-after-change без допустимого контрольного сравнения. Для него нужен stop-unsupported-effect.

\n
for (const id of [\\n  'hidden-queue-v1',\\n  'incomparable-load-v1',\\n  'unsupported-effect-v1',\\n]) {\\n  const result = reviewFixedPerformanceInput(createInput(id));\\n  console.log(id, result.status, result.nextAction);\\n}\\n\\n// Учебный результат: stop — нормальный исход проверки.\\n// Он не доказывает, что система медленная.
\n

Отказ должен быть машинно и текстово видимым. Он сохраняет конкретную причину остановки и следующее действие. Общая фраза «нужно больше данных» хуже: она не показывает, какого именно условия не хватает.

\n

Длительность не равна причинности

\n

Даже связная trace не сообщает автоматически, почему очередь заняла 520 units. Причиной может быть лимит ресурса, политика admission, форма учебного сценария или другая часть системы. Роль span CLIENT, SERVER или INTERNAL помогает описать операцию. Она не превращает интервал в диагноз и не назначает оптимизацию.

\n

Правильная цепочка выглядит так: наблюдение — queue-wait=520; гипотеза — правило admission создаёт ожидание; новая проверка — заранее определённый input с той же границей и одним изменённым условием; результат — сравнимое наблюдение или новый stop. Перескок от первого пункта к утверждению «очередь является корнем проблемы» нарушает границу доказательства.

\n

То же относится к БД. database-execution=150 не является SQL-профилем, индексом, бюджетом или обещанием. Это интервал вызова в учебной trace. Если нужен разбор SQL, он требует отдельного measurement contract, собственных входов и критерия сравнения.

\n

Порядок действий

\n
  1. Выбрать одну trace и один root span. Не собирать критический путь из разрозненных логов и таймеров.
  2. Проверить дерево: у каждого дочернего span есть существующий parent, начало не позже конца, root покрывает выбранный путь.
  3. Разделить ожидание, исполнение БД и внешний вызов. Если роль неизвестна, остановить разбор.
  4. Записать контрольную границу до просмотра того, какой root короче: cohort, число запросов, concurrency и форма входа.
  5. Сравнить только совпадающие inputs. Различие хотя бы одного обязательного поля — отдельное наблюдение, а не эффект изменения.
  6. Сформулировать вывод ровно по данным: например, «queue-wait — самый длинный названный сегмент этого учебного input».
  7. Проверить отрицательный путь и сохранить status. Если вход не проходит проверку, передать stop reason, а не рекомендацию по оптимизации.
\n

Ограничения

\n

Учебная trace не даёт распределения latency, хвостов, throughput, variance, queue discipline или стоимости ресурсов. Условные units нельзя переводить в миллисекунды. Один root не заменяет серию измерений. Связь через trace-id не гарантирует полноту дерева. Пересекающиеся span нельзя бездумно складывать: они могут выполняться параллельно.

\n

Источники ниже описывают контекст trace, роли span и семантику HTTP. Они не доказывают bottleneck, не обещают latency и не подтверждают production-эффект. Поэтому материал ограничивает вывод учебным input и не предлагает rollout, изменение конфигурации или выбор индекса.

\n

Критерий готовности

\n

Разбор готов, если другой инженер получает тот же status на том же именованном input, видит связное дерево, понимает контрольную границу и может указать, какое условие приводит к stop. В принятом учебном случае вывод должен остаться наблюдением: queue wait — самый длинный названный сегмент в данной trace; причинность и эффект изменения не заявлены. Если для чтения вывода нужны устные пояснения, внешний дашборд или догадка автора, материал не готов.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/060.json b/editorial/agent-rewrites/060.json new file mode 100644 index 0000000..64555b3 --- /dev/null +++ b/editorial/agent-rewrites/060.json @@ -0,0 +1,7 @@ +{ + "index": 60, + "slug": "editorial-2026-05-practice-systems-performance", + "title": "Критический путь запроса: как найти задержку и не перепутать её с причиной", + "excerpt": "Запрос медленный, хотя CPU свободен. Разбираем end-to-end путь, отделяем очередь от работы и задаём проверку, после которой оптимизацию можно обсуждать без догадок.", + "contentHtml": "

Пользователь ждёт ответ десять секунд, а CPU сервиса держится на двадцати процентах. Команда меняет SQL, увеличивает пул потоков или поднимает таймаут. Симптом иногда маскируется, но путь не становится быстрее. Цена ошибки — релиз без эффекта, дополнительная нагрузка и потеря исходного сигнала. Следующий инженер уже не видит, что именно сравнивали.

\n

Низкая загрузка CPU не опровергает медленный запрос. End-to-end время включает ожидание, работу и вызовы зависимостей. Чтобы выбрать действие, нужно разложить один путь на связанные интервалы и удержать одну границу сравнения. Учебные значения ниже не являются измерениями production-системы. Они показывают способ рассуждать.

\n

Механизм: время ответа состоит не только из работы

\n

Root span задаёт границу от приёма запроса до ответа. Дочерний span показывает названную операцию внутри этой границы. Запрос может ждать admission queue, свободное соединение, блокировку, диск, DNS, TLS или ответ удалённого сервиса. Пока он ждёт, CPU может почти не работать.

\n

Название интервала ограничивает вывод. queue-wait означает отдельно записанное ожидание в очереди. database-execution означает интервал вызова базы в этой trace. external-dependency означает границу внешнего вызова. Ни одно из этих названий само по себе не доказывает причину задержки. Если ожидание не размечено, его нужно оставить неизвестным.

\n

Критический путь — это не рейтинг сервисов и не сумма всех span. Это временная цепь внутри одного root span. Связность важнее красивого графика: у каждого дочернего span должен существовать parent, начало не должно быть позже конца, а единицы времени должны совпадать. Если два вызова идут параллельно, их длительности нельзя складывать как последовательные.

\n

Учебный пример: один root, три названных интервала

\n

Рассмотрим условную trace fixed-trace-01. Root длится 1 000 units. Очередь занимает 520, вызов БД — 150, внешний каталог — 200. Промежутки между интервалами не получили отдельного объяснения. Поэтому их нельзя автоматически назвать сетью или дополнительной работой.

\n
Состав одного учебного end-to-end пути
СегментРольИнтервалДлительностьЧто можно сказать
fixed-admission-queuequeue-wait40–560520Самый длинный названный сегмент этой записи
fixed-db-calldatabase-execution570–720150Интервал вызова БД в этой trace
fixed-catalog-callexternal-dependency730–930200Интервал внешнего вызова в этой trace
fixed-gatewayend-to-end0–1 0001 000Граница пути, а не объяснение причины
\n
\"Учебный
Учебная схема показывает порядок проверки: сначала root и названные интервалы, затем границы вывода. Она не изображает production latency.
\n

В этой записи fixed-admission-queue длиннее двух других названных сегментов. Это единственный прямой вывод о порядке длительностей. Нельзя из него заключить, что очередь является bottleneck при другой нагрузке, что изменение gateway ускорит пользователя или что БД не требует исследования. Для любого такого утверждения нужна отдельная проверка.

\n

Пример проверки структуры

\n

Код ниже работает с заранее заданным объектом. Он не обращается к сети, базе, часам, профайлеру или телеметрии. Числа условны. Пример проверяет связность и интервалы, а не показывает результат реального сервиса.

\n
const trace = {\n  root: { id: 'root-01', start: 0, end: 1000 },\n  spans: [\n    { id: 'queue-01', parent: 'root-01', role: 'queue-wait', start: 40, end: 560 },\n    { id: 'db-01', parent: 'root-01', role: 'database-execution', start: 570, end: 720 },\n    { id: 'catalog-01', parent: 'root-01', role: 'external-dependency', start: 730, end: 930 }\n  ],\n  load: { cohort: 'fixed-load-a', requests: 12, concurrency: 3, shape: 'fixed-read-shape-a' }\n};\n\nfunction review(input) {\n  const ids = new Set(input.spans.map((span) => span.id));\n  const connected = input.spans.every((span) =>\n    span.parent === input.root.id || ids.has(span.parent)\n  );\n  const timed = input.spans.every((span) =>\n    Number.isFinite(span.start) && Number.isFinite(span.end) &&\n    span.end >= span.start\n  );\n\n  if (!connected) return { status: 'stop-incomplete-trace' };\n  if (!timed) return { status: 'stop-invalid-interval' };\n  return { status: 'observation-ready', effect: 'not-claimed' };\n}\n\nconsole.log(review(trace));
\n

observation-ready здесь означает только, что запись связна и содержит корректные условные интервалы. Если parent равен missing-01, результат должен быть stop-incomplete-trace. Если начало больше конца, функция должна остановиться. Отрицательный путь не является исключением из метода. Он показывает, что неполный материал нельзя превращать в уверенный диагноз.

\n

Симптом → причина → проверка → действие

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
CPU низкий, запрос медленныйВ end-to-end время вошло ожиданиеРазделить queue-wait, локальную работу и дочерние вызовыНазвать только покрытые span; остаток оставить unknown
Длинный span совпал с пиком latencySpan включает ожидание upstream или retryПроверить parent/child, повторные вызовы и дочерние интервалыНе объявлять span причиной без отдельного сигнала
Второй прогон короче первогоИзменилась нагрузка или форма входаСверить cohort, requests, concurrency и shapeСнять сравнение и повторить на общей границе
Есть root, но нет parent у дочернего spanПотеря записи или неверная связь IDПроверить полный экспорт и уникальность идентификаторовВернуть stop; не дорисовывать дерево по времени
После изменения есть одна короткая записьНет baseline и распределения наблюденийПовторить тот же сценарий и сохранить контрольНазвать observation, а не improvement
\n

Как не спутать наблюдение с причинностью

\n

Длинный интервал сообщает, что в конкретной записи он длинный. Он не сообщает, почему это произошло и какое изменение его сократит. Очередь может зависеть от admission policy или ограниченного ресурса. Вызов БД может ждать соединение до начала исполнения. Внешний вызов может включать локальную подготовку. Одна trace не выбирает между этими объяснениями.

\n

Полезно разделять три фразы. Наблюдение: «queue-wait занимает 520 units в fixed-trace-01». Гипотеза: «правило допуска создаёт часть ожидания». Проверка: «сравнить заранее определённые записи с теми же cohort, requests, concurrency и shape». Перескакивать от первой фразы к третьей нельзя. Тем более нельзя сразу объявлять эффект изменения.

\n

Та же граница действует для базы. database-execution = 150 — не диагноз SQL, не рекомендация индекса и не оценка бюджета. Если команда хочет исследовать запрос, она формулирует новый вопрос и сохраняет текущую запись как baseline только после проверки сопоставимости. Уменьшение знакомого локального шага не становится правильным действием из-за того, что его проще измерить.

\n

Что значит сопоставимая нагрузка

\n

Baseline и candidate можно сравнивать только внутри явно названной контрольной границы. В учебном примере это fixed-load-a, 12 логических запросов, concurrency 3 и fixed-read-shape-a. Если второй прогон использует 24 запроса, concurrency 6 или другую форму входа, он отвечает на другой вопрос. Более короткий root не доказывает ускорение.

\n

Смена одного поля уже важна. Если выросла concurrency, очередь может измениться без изменения кода. Если изменилась форма данных, база может выбрать другой план. Если другой cohort пришёл из другого окна, кэш и внешняя зависимость могли иметь иное состояние. Запись должна сделать эти условия видимыми, а не прятать их в подписи графика.

\n

Отдельно проверяйте параллельность. Дочерние span могут пересекаться. В таком случае их сумма превысит время root и не покажет стоимость пути. Сначала определите временную зависимость. Если это невозможно, оставьте вывод на уровне «интервалы пересекаются» и не выбирайте самый большой span как причину.

\n

Порядок действий

\n
  1. Зафиксируйте исходный симптом: маршрут, метод, статус, длительность, размер ответа, время и идентификатор запроса.
  2. Сохраните один trace до изменения кода или конфигурации. Отметьте root, дочерние операции, пропуски и неизвестные интервалы.
  3. Проверьте parent/child-связи, границы интервалов и единицы времени. Отдельно отметьте overlap.
  4. Выпишите контрольную границу: cohort, число логических запросов, concurrency и форму входных данных.
  5. Отделите ожидание от исполнения БД и внешнего вызова. Не называйте неразмеченный остаток причиной.
  6. Сформулируйте гипотезу и проверку, которая может её опровергнуть. Один длинный span не заменяет такую проверку.
  7. Прогоните отрицательный случай: отсутствующий parent, некорректный интервал, unknown-delay или другая нагрузка должны вернуть точный stop.
  8. Измените один фактор только после фиксации baseline. Повторите тот же сценарий на той же контрольной границе.
  9. Сравните исходный симптом с candidate и проверьте соседние сигналы: ошибки, таймауты, очередь, throughput и потребление ресурсов.
\n

Что делать с отрицательным путём

\n

Если trace неполная, остановитесь на stop-incomplete-trace. Если ожидание помечено только как unknown-delay, не называйте его очередью. Если нагрузка отличается, верните stop-incomparable-load. Если в записи уже есть утверждение «стало быстрее», но нет сопоставимого контроля, снимите claim и сохраните только наблюдение.

\n

Такая остановка экономит время. Неполный trace легко вставить в убедительный рассказ и трудно разобрать после нескольких изменений. Именованная причина stop сохраняет недостающий факт: нужно восстановить parent, назвать ожидание или выровнять нагрузку. Отказ от вывода точнее, чем правдоподобное объяснение пустого места.

\n

Ограничения

\n

Sampling может убрать нужный span. Collector может потерять событие или доставить его не по порядку. Асинхронный worker может продолжить работу после root. Часы узлов могут расходиться. Retry может создать несколько операций с похожими именами. Эти условия не делают trace бесполезной, но снижают силу вывода. Ограничение нужно записать рядом с наблюдением.

\n

Waterfall не измеряет throughput, хвост распределения, стоимость соединений, поведение при исчерпании пула или влияние кэша. Он не задаёт SLA и не заменяет нагрузочный тест. RFC 9110 описывает семантику HTTP, а не бюджет latency приложения. Для эксплуатационного решения нужны отдельные измерения, контрольные группы и критерии остановки.

\n

Учебный код нельзя подключать к реальной телеметрии без новой проверки. Он использует одну запись, фиксированные числа и заранее известные поля. Он не проверяет экспорт, прокси, клиентские повторы или права доступа. Production-результат появляется только после отдельного эксперимента с описанной средой.

\n

Проверяемый критерий готовности

\n

Разбор готов, если другой инженер без устных пояснений может найти root, проверить parent/child-связи и интервалы, увидеть контрольную границу, отличить названную задержку от unknown и воспроизвести stop на неполной trace или несопоставимой нагрузке. Это критерий качества evidence, а не обещание ускорения.

\n

Изменение можно оценивать отдельно, когда baseline и candidate сопоставимы, изменён один фактор, исходный симптом измерен тем же способом, а результат не маскирует ошибку ростом таймаута или потерей сигнала. До этого корректный итог звучит так: «В fixed-trace-01 при fixed-load-a queue-wait — самый длинный названный сегмент. Эффект изменения не заявлен». Другой инженер должен получить тот же вывод из той же записи.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/061.json b/editorial/agent-rewrites/061.json new file mode 100644 index 0000000..fbfffd2 --- /dev/null +++ b/editorial/agent-rewrites/061.json @@ -0,0 +1,7 @@ +{ + "index": 61, + "slug": "editorial-2026-04-field-modern-web-security", + "title": "Почему CORS не защищает POST: как проверить границы веб-безопасности", + "excerpt": "Чужой сайт может отправить запрос с cookie даже после настройки CORS. Разбираем механизм, связываем угрозу с контролем и evidence, а затем получаем проверяемый stop или ограниченный hand-off.", + "contentHtml": "

Симптом выглядит обнадёживающе: API отвечает на запросы только с нужным Origin, а в DevTools чужой сайт получает ошибку CORS. Команда помечает проблему закрытой. Но endpoint всё ещё принимает cross-site POST с cookie. Браузер может отправить запрос и не показать ответ атакующему. Если запрос меняет адрес доставки, пароль или лимит, ошибки CORS не возвращают деньги и не отменяют изменение.

\n

Цена такой подмены — ложное чувство защиты. CORS управляет чтением ответа из браузера. CSRF-защита управляет тем, может ли чужой сайт заставить браузер выполнить действие от имени пользователя. Эти механизмы стоят рядом, но решают разные задачи. Без явного пути атаки, контрольной точки и наблюдаемого evidence слово «проверено» слишком сильное.

\n

Тезис статьи простой: security review должен связывать asset, путь атаки, interruption и границу доказательства. Положительный результат подтверждает только эту связь. Он не разрешает deploy, не доказывает защиту production и не заменяет проверку входа, сессии, заголовков или поведения браузера.

\n

Один запрос показывает разницу между CORS и CSRF

\n

Пусть пользователь вошёл в bank.example. Браузер хранит cookie сессии и автоматически прикладывает её к запросу на этот origin. На странице evil.example размещена форма. Форма отправляет POST на банковский endpoint. Для простой формы браузер не обязан сначала выполнить CORS preflight. Сервер может получить cookie и изменить состояние.

\n
<form action=\"https://bank.example/profile/email\" method=\"POST\">\n  <input name=\"email\" value=\"attacker@example.net\">\n</form>\n<script>document.forms[0].submit()</script>\n\n// Учебный пример. Он не отправляется и не доказывает поведение\n// конкретного браузера или endpoint.
\n

Если сервер принимает такой POST только по cookie, запрос остаётся опасным. Проверка Origin или Referer может добавить условие. Synchronizer token или signed double-submit token связывает действие с формой приложения. Cookie с подходящим SameSite уменьшает поверхность, но его режим зависит от контекста браузера и схемы запроса. Без проверки на сервере нельзя считать один флаг достаточным.

\n

Тот же endpoint может иметь корректный CORS и всё равно быть уязвимым к изменению состояния. И наоборот: endpoint может не разрешать чтение ответа чужому origin, но нуждаться в CSRF-токене для опасного действия. Сначала назовите действие. Потом проверьте, какой контроль его прерывает.

\n

Механизм проверки: путь, контроль, evidence

\n

Начните с одного asset. Для примера это email пользователя. Путь атаки имеет порядок: чужая страница создаёт запрос, браузер добавляет cookie, endpoint принимает изменение, сервер сохраняет новый email. CORS находится на границе чтения ответа. Он не обязан останавливать первые три шага. CSRF-токен и серверная проверка origin находятся ближе к операции изменения.

\n

У каждого контроля должна быть одна фраза с глаголом. «CORS включён» ничего не говорит о действии. «Сервер отклоняет state-changing POST без валидного токена» описывает interruption. Такая запись проверяема: можно назвать вход, ответ и правило отказа. Если token проверяется только в JavaScript, контроль не стоит на серверной границе. Если endpoint разрешает запрос без cookie, нужно отдельно оценить анонимную операцию.

\n
\"Цикл
Граница hand-off должна быть видна. Учебный цикл передаёт только названный scope проверки и не превращается в решение о выпуске.
\n

Evidence тоже имеет границу. Заголовок Access-Control-Allow-Origin показывает настройку чтения ответа. Он не показывает, что сервер отверг чужой POST. Ответ 403 на запрос без токена показывает одну отрицательную ветку. Он не доказывает, что все state-changing endpoints используют тот же middleware. Эти два наблюдения нельзя склеить в общий verdict.

\n
От симптома к проверяемому действию
СимптомПричинаПроверкаДействие
Чужой origin видит CORS errorБраузер не отдаёт ему response bodyОтправить отдельный state-changing POST и проверить записьДобавить серверную CSRF-защиту, если действие использует cookie
POST проходит без tokenEndpoint доверяет cookie без дополнительного доказательства намеренияПовторить запрос без token в изолированной учебной средеОтклонять запрос до изменения состояния; сохранить ответ и correlation id
Token есть в форме, но не проверяетсяКонтроль остался на клиентеВызвать endpoint напрямую без выполнения UIПеренести проверку на сервер и покрыть отрицательным тестом
Один endpoint защищён, другие нетПроверка привязана к странице, а не к классу операцииСоставить список state-changing routes и найти общий middlewareНазначить владельца непокрытых маршрутов; не выдавать общий verdict
После изменения появился 403Изменился контракт запроса или cookie policyСверить token, origin, cookie и права в позитивном сценарииИсправить конкретный контракт; не ослаблять правило глобально
\n

Пример серверной границы

\n

Ниже псевдокод для учебного review. Он показывает порядок условий, но не является готовым middleware. Реальный фреймворк должен сам определить, как извлекать cookie, хранить token, сравнивать origin и формировать ответ.

\n
function updateEmail(request) {\n  if (request.method !== 'POST') return allowMethod();\n  if (!sameOrigin(request.headers.origin)) return reject(403);\n  if (!validCsrfToken(request.cookie, request.body.csrf)) {\n    return reject(403);\n  }\n  if (!validEmail(request.body.email)) return reject(400);\n  return saveEmail(request.session.userId, request.body.email);\n}\n\n// Учебный пример: не содержит production storage, logging\n// или конкретную реализацию token.
\n

Важен не синтаксис, а место проверки. Запрос отклоняется до записи. Позитивная ветка требует действующую сессию и корректный token. Негативная ветка проверяет запрос без token, с чужим origin и с повторно использованным token. Если тест вызывает только функцию в памяти, он подтверждает порядок условий в примере. Он не подтверждает маршрутизацию, cookie flags, proxy и реальную базу.

\n

Для CORS правило другое. Разрешайте конкретные origins, не отражайте произвольный заголовок Origin, не сочетайте wildcard с credentialed requests и проверяйте, нужен ли endpoint вообще для cross-origin чтения. Но даже строгий allowlist не заменяет CSRF-защиту. Это отрицательный путь статьи: исправление видимого CORS-симптома может не менять способность чужой формы отправить запрос.

\n

Как передавать результат без ложного допуска

\n

Передача результата должна содержать четыре поля: threat id, control id, observed evidence и остаток. Например, threat — «чужая форма меняет email в сессии пользователя». Control — «сервер отклоняет POST без token и при несоответствующем origin». Evidence — «в учебном тесте запрос без token вернул 403 до вызова сохранения». Остаток — «не проверены другие маршруты и поведение production proxy».

\n

Такой hand-off не означает, что система защищена. Он означает, что следующий человек видит, какую ветку повторить и где заканчивается наблюдение. Нельзя заменить остаток фразой «остальное стандартно». Нельзя перенести evidence с одного endpoint на весь API. Нельзя считать отсутствие ответа у атакующего доказательством отсутствия изменения в системе.

\n

Порядок действий

\n
  1. Назовите asset и действие. Запишите, что может изменить чужой запрос: email, пароль, заказ или иной объект.
  2. Нарисуйте путь. Укажите страницу-источник, cookie, endpoint, проверку и запись. Не объединяйте чтение ответа с изменением состояния.
  3. Разделите controls. Отдельно опишите CORS, CSRF token, origin check, SameSite и авторизацию. Для каждого назовите interruption.
  4. Проверьте отрицательный запрос. В учебной или специально разрешённой среде уберите token, измените origin и убедитесь, что запись не произошла.
  5. Проверьте позитивный запрос. С действующей сессией и корректным token операция должна пройти. Сохраните только наблюдаемые поля.
  6. Сверьте покрытие. Найдите все state-changing маршруты и убедитесь, что правило применяет общий серверный слой, а не одну форму.
  7. Запишите остаток. Назовите непроверенные proxy, браузеры, cookie-режимы, фоновые операции и endpoints.
  8. Передайте ограниченный результат. Если связка неполна, верните stop с точным следующим вопросом. Не превращайте учебный output в release approval.
\n

Ограничения и критерий готовности

\n

Эта проверка не измеряет вероятность атаки, размер ущерба, покрытие всех маршрутов, устойчивость к обходу proxy или состояние системы после deploy. Пример не обращается к реальному API, не запускает браузер и не использует production cookie. Официальные стандарты помогают выбрать вопросы, но не подтверждают конкретную конфигурацию. CSP, например, полезна как дополнительная граница для content injection, но не отменяет безопасную обработку данных. ASVS задаёт требования для верификации web-контролей, а не автоматический verdict. NIST описывает наборы техник проверки, а не единый сертификат.

\n

Критерий готовности должен быть узким и воспроизводимым: для каждого state-changing endpoint существует один записанный attack path, серверная проверка стоит до изменения состояния, позитивный запрос проходит с корректным token, отрицательный запрос не создаёт запись, а evidence содержит маршрут, вход, статус и границу применимости. Непокрытый маршрут остаётся stop. Если нельзя показать, какой контроль прервал путь, review не завершён.

\n

Если после проверки требуется изменить код или конфигурацию, это отдельная уполномоченная работа. Hand-off только указывает следующий шаг. Он не включает заголовки, не меняет cookie policy и не разрешает выпуск. Такое ограничение делает вывод уже, но честнее: команда знает, что проверила, чего не проверила и какую работу нельзя считать выполненной.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/062.json b/editorial/agent-rewrites/062.json new file mode 100644 index 0000000..ab61c86 --- /dev/null +++ b/editorial/agent-rewrites/062.json @@ -0,0 +1,7 @@ +{ + "index": 62, + "slug": "editorial-2026-04-mechanism-modern-web-security", + "title": "Почему список security controls не доказывает защиту веб-приложения", + "excerpt": "Один CSP-заголовок не объясняет, какой шаг атаки он прерывает. Разбираем трассировку threat model → control → evidence → residual risk на учебном примере и фиксируем границу, после которой нужен отдельный тест.", + "contentHtml": "

После ревью в отчёте остаётся знакомый список: CSP включён, cookies имеют флаг HttpOnly, сканер не нашёл критических проблем. Через неделю в приложение попадает пользовательский фрагмент, а команда не может ответить, какой именно шаг атаки должен был остановиться. Ошибка стоит дорого: разработчики спорят о настройке заголовка, инцидент получает ложный статус «закрыт», а реальная проверка входных данных и места вывода остаётся без владельца.

\n

Тезис статьи простой: security control имеет смысл только внутри трассы threat model → interruption → evidence → residual risk. Сначала нужно назвать актив, условие и порядок шагов атаки. Затем — указать, какой control прерывает конкретный шаг. После этого — ограничить вывод наблюдаемым evidence. Всё, что осталось за границей наблюдения, записывают как residual risk. Если связь оборвалась, результатом должен быть отказ от вывода, а не зелёная отметка.

\n

Почему перечень controls вводит в заблуждение

\n

Перечень хранит существительные: CSP, sanitizer, SAST, review, SameSite. Он не хранит направление связи. Из строки «CSP настроен» не следует, что конкретный фрагмент не попадёт в опасный sink. Из строки «cookie защищена» не следует, что сервер проверяет намерение запроса. Из строки «сканер чист» не следует, что сканер видел нужную ветку, конфигурацию и версию приложения.

\n

Один control часто действует позднее источника проблемы. CSP может ограничить исполнение скрипта в документе. Он не исправляет неверную валидацию и не превращает небезопасный HTML-sink в безопасный. Поэтому связь надо записать глаголом: отклонить скрипт без известного nonce на границе документа. Такой текст уже можно сопоставить с шагом атаки и с проверкой.

\n
\"Матрица
Список мер превращается в инженерное утверждение только после четырёх связей: что атакуется, где путь прерывается, что наблюдалось и что осталось вне проверки.
\n

Минимальная модель пути

\n

Рассмотрим учебный путь. Ненадёжный фрагмент достигает именованного sink, а sink формирует документ, в котором возможен исполняемый сценарий. В модели это три разных шага. Нельзя заменить их словом «XSS»: короткое название скрывает условие и место, где должна сработать защита.

\n
asset: browser rendering context\nprecondition: untrusted fragment reaches named sink\npath:\n  1. untrusted-fragment\n  2. unsafe-render-sink\n  3. script-capable-document\ncontrol: reject-script-without-fixed-nonce\nevidence:\n  - named-directives-present\n  - synthetic-negative-script-is-not-authorized
\n

Это учебная запись в памяти. Она не создаёт HTTP-ответ, не запускает браузер, не проверяет заголовок и не измеряет поведение сайта. Её задача — показать форму рассуждения. В реальном ревью такую модель надо связать с конкретным маршрутом, кодом рендера, способом доставки policy и воспроизводимой проверкой.

\n

У control есть узкое место действия. В примере policy может ограничить следующий шаг: браузер не авторизует сценарий без нужного nonce. Но evidence не говорит, откуда взялся фрагмент, корректно ли закодирован вывод и все ли sinks покрыты. Эти вопросы остаются открытыми. Наличие residual risk не означает провал всей защиты. Оно означает, что вывод не расширяют за пределы наблюдения.

\n

Симптом → причина → проверка → действие

\n
Диагностика разорванной трассировки
СимптомПричинаПроверкаДействие
«CSP включён», но путь атаки не названControl записан без interruptionПопросить назвать шаг, который он прерываетДобавить ordered path и точку отказа
Тест зелёный, но относится к другой страницеEvidence не связано с threat idСверить идентификаторы пути и наблюденияОстановить вывод и привязать проверку заново
После исправления исчезла строка residual riskПоздний control приняли за исправление источникаПроверить validation, encoding и все sinksВернуть открытые участки и следующий вопрос
В отчёте написано «защита гарантирована»Ограниченное evidence расширили риторикойСопоставить каждое слово с наблюдениемСузить утверждение до проверяемого факта
Неизвестно, что делать при разрыве связиУ модели нет отрицательной веткиПодать запись без path или bindingВернуть точный stop и недостающий вход
\n

Как выглядит отрицательный путь

\n

Отрицательная ветка важнее красивого положительного результата. Если evidence содержит наблюдение, но ссылается на другой threat или control, система не должна искать «похожую» запись по тексту. Она возвращает ошибку связи. Если attack path пуст, нельзя считать policy доказанной. Если residual risk не содержит открытого участка и вопроса для следующей проверки, положительный hand-off также нельзя принимать.

\n
const review = {\n  threatId: 'fixed-html-injection-path-v1',\n  controlId: 'fixed-csp-nonce-boundary-v1',\n  evidence: {\n    bindsThreatId: 'other-path',\n    bindsControlId: 'other-control'\n  }\n};\n\nif (review.evidence.bindsThreatId !== review.threatId ||\n    review.evidence.bindsControlId !== review.controlId) {\n  return {\n    status: 'stop-unbound-evidence',\n    nextAction: 'bind-observation-to-named-threat-and-control'\n  };\n}
\n

Пример синтетический. Он проверяет только равенство идентификаторов в объекте. Он не доказывает свойства браузера и не подтверждает, что приложение послало нужный заголовок. В реальном коде эта проверка может быть частью валидатора review-записи. Проверку браузерного поведения, серверного ответа и входных данных проводят отдельно.

\n

Что считается evidence

\n

Evidence — не обязательно скриншот или лог. Это ограниченное наблюдение, которому заранее задан допустимый вывод. Для учебного объекта допустимы два факта: именованные директивы присутствуют в записи и отрицательный сценарий без nonce не авторизован в этой модели. Нельзя из них выводить отсутствие XSS, корректность всех HTML-преобразований, защиту сессии или безопасность каждого браузера.

\n

Границу пишут рядом с observation. Иначе при передаче она исчезает, а фраза «negative case не авторизован» превращается в «уязвимость устранена». Хорошая запись отвечает на четыре вопроса: какой threat проверялся, какой control к нему привязан, что именно наблюдалось и чего это наблюдение не доказывает.

\n

Эта дисциплина помогает и при конфликте controls. Санитизация может менять вход, а CSP — ограничивать последствия в документе. У них разные interruption. Если evidence относится только к policy, нельзя выдать вывод о преобразовании данных. Если два controls действуют на разные шаги, их нельзя слить в одну зелёную строку только ради компактного отчёта.

\n

Порядок проверки

\n
  1. Назовите актив. Запишите, что защищаете: например, контекст рендера браузера, профиль пользователя или изменение счёта.
  2. Опишите условие. Укажите, при каком входе или состоянии путь становится возможным.
  3. Разложите маршрут. Дайте шагам порядок и действующие глаголы. Не заменяйте маршрут названием класса уязвимости.
  4. Назовите interruption. Укажите, какой control и каким решением прерывает конкретный шаг.
  5. Привяжите evidence. Свяжите наблюдение одновременно с threat id и control id.
  6. Запишите предел. Явно перечислите утверждения, которых наблюдение не поддерживает.
  7. Оставьте residual risk. Назовите открытые участки и вопрос для отдельной проверки.
  8. Проверьте отрицательную ветку. Убедитесь, что неизвестный путь, чужая связь и чрезмерный положительный вывод дают stop.
\n

Ограничения механизма

\n

Трассировка не оценивает вероятность атаки, ущерб, exploitability или полноту покрытия. Она не заменяет threat modeling, code review, тесты, статический анализ, сканирование и независимую оценку. Она только не даёт одной записи присвоить себе результаты всех этих методов.

\n

CSP не заменяет валидацию входа и кодирование вывода. Cookie-флаги не заменяют проверку полномочий и намерения операции. CORS не является универсальной защитой от CSRF: если сервер принимает изменяющий запрос с cookie без отдельной проверки, исправление CORS может не закрыть путь. Этот отрицательный пример применим только при соответствующей схеме браузера, cookie и серверного endpoint; его надо проверять реальным запросом в тестовой среде.

\n

Описанный JavaScript ограничен учебной моделью. Он не сообщает production-результаты и не даёт разрешения на релиз. Если нужен реальный вывод, потребуются отдельные входы: собранный response, конфигурация доставки policy, тестовый браузер, тестовые данные и зафиксированный scope. Нельзя подменить их одной записью в памяти.

\n

Проверяемый критерий готовности

\n

Материал ревью готов, когда независимый читатель может пройти цепочку от актива до residual risk без устных пояснений. Для каждого control видны threat id, ordered path и точка прерывания. Для каждого evidence видны два binding id и допустимый вывод. Открытые участки не скрыты. Отрицательные случаи возвращают определённый stop. Положительная ветка остаётся только ограниченной передачей на следующий review, а не разрешением на изменение системы.

\n

Практический тест занимает один проход: удалите из записи любое звено и снова запустите валидатор. Если запись всё ещё выглядит «зелёной», модель слишком либеральна. Добавьте проверку, которая останавливает её на конкретном разрыве. Так список controls превращается в проверяемый аргумент, а не в коллекцию обещаний.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/063.json b/editorial/agent-rewrites/063.json new file mode 100644 index 0000000..73fd27d --- /dev/null +++ b/editorial/agent-rewrites/063.json @@ -0,0 +1,7 @@ +{ + "index": 63, + "slug": "editorial-2026-04-practice-modern-web-security", + "title": "Безопасность веба начинается с границы: как доказать, что контроль прерывает атаку", + "excerpt": "CSP, CSRF-токен и проверка прав решают разные задачи. Разбираем путь атаки, точку прерывания, отрицательный тест и критерий, по которому защиту можно проверить.", + "contentHtml": "

Симптом часто выглядит убедительно: сервер отдаёт заголовок CORS, cookie помечена HttpOnly, а endpoint изменения профиля проверяет авторизацию. Но чужая страница всё ещё может отправить POST с cookie пользователя. Команда видит несколько включённых controls и считает задачу закрытой. Цена ошибки — изменение данных от имени жертвы, инцидент без понятной точки отказа и долгий спор о том, какая настройка должна была остановить запрос.

\n

Тезис статьи простой: контроль защищает не «веб вообще», а конкретный переход в маршруте атаки. Для каждой меры нужно назвать вход, условие, точку прерывания и проверяемый результат. CORS ограничивает чтение ответа браузером. CSRF-токен проверяет намерение для state-changing запроса. Проверка прав решает, может ли пользователь выполнить операцию. Эти меры дополняют друг друга, но одна не заменяет другую.

\n

Сначала опишите путь атаки

\n

Начните с действия нарушителя, а не со списка заголовков. В учебном сценарии пользователь вошёл в приложение, браузер хранит сессионную cookie, а endpoint принимает POST /api/profile/email. Внешняя страница содержит форму или JavaScript, который отправляет запрос на этот адрес. Браузер может приложить cookie к запросу. Если сервер не требует отдельного доказательства намерения, запрос меняет email.

\n

У пути есть четыре наблюдаемые точки: источник запроса, браузер, endpoint и операция записи. CORS не делает внешний POST невозможным. Он обычно мешает прочитать ответ из JavaScript. Это другая граница. HttpOnly не запрещает браузеру отправлять cookie. Он только скрывает cookie от JavaScript. SameSite может уменьшить риск для части кросс-сайтовых запросов, но режим зависит от контекста, способа навигации и политики cookie. Защита должна проверять контракт на сервере.

\n
Карта пути атаки в вебе: внешний запрос проходит через браузер к endpoint, а проверка CSRF и прав прерывает разные переходы
Один путь атаки может пересекать несколько границ. Каждая проверка должна иметь свою точку прерывания и собственный отрицательный тест.
\n

Механизм: разные controls закрывают разные переходы

\n

Аутентификация отвечает на вопрос «кто отправил запрос?». Авторизация отвечает на вопрос «может ли этот пользователь изменить этот объект?». CSRF-защита отвечает на вопрос «есть ли у запроса доказательство, которое внешний сайт не может получить и воспроизвести?». Валидация входа отвечает на вопрос «соответствует ли значение контракту поля?». Нельзя перенести ответ одного слоя на другой.

\n

Сервер может принять валидный токен CSRF от пользователя без права менять чужой профиль. Тогда токен доказал происхождение запроса, но не право на операцию. Обратный случай тоже возможен: сервер правильно проверяет право, но принимает запрос с cookie без CSRF-защиты. Злоумышленник не получает новые права, но заставляет уже авторизованного пользователя выполнить разрешённое действие.

\n

Контроль становится проверяемым, когда его условие видно в коде и в тесте. Фраза «CORS настроен» не сообщает, что именно проверяли. Формулировка «без заголовка X-CSRF-Token endpoint возвращает 403 и не меняет запись» задаёт границу. Она не доказывает безопасность всех endpoint-ов, но доказывает один отрицательный путь для одной операции.

\n

Учебный пример: серверная граница для записи

\n

Ниже — учебный фрагмент на Express-подобном API. Он не подключается к базе, не создаёт настоящую сессию и не показывает production-результат. Функции getSession, findUser и updateEmail обозначают границы приложения. В реальном сервисе их контракты нужно проверить отдельно.

\n
app.post('/api/profile/email', async (req, res) => {\n  const session = await getSession(req);\n  if (!session) return res.sendStatus(401);\n\n  const csrf = req.get('X-CSRF-Token');\n  if (!csrf || !timingSafeEqual(csrf, session.csrfToken)) {\n    return res.sendStatus(403);\n  }\n\n  const email = parseEmail(req.body.email);\n  if (!email) return res.status(400).json({ error: 'invalid_email' });\n\n  const user = await findUser(session.userId);\n  if (!user || user.id !== session.userId) return res.sendStatus(403);\n\n  await updateEmail(user.id, email);\n  return res.sendStatus(204);\n});
\n

Порядок проверок здесь не является универсальным шаблоном. Он показывает цепочку решений: сессия допускает запрос в контекст пользователя, токен закрывает CSRF-переход, парсер ограничивает значение, а проверка идентификатора не даёт обновить чужую запись. Каждый отказ имеет отдельный статус и тестируемое условие. Если приложение использует другой способ передачи CSRF-токена, сохраняется тот же принцип: внешний сайт не должен получить нужное доказательство из обычной сессии.

\n

Отрицательный путь обязателен. Уберите заголовок, оставьте cookie и отправьте тот же POST. Ожидаемый результат — 403, а значение email не изменилось. Если сервер вернул 204 или запись изменилась, CORS, HttpOnly и наличие формы входа не имеют значения: граница endpoint пропускает запрос. В учебном примере это проверка логики, а не свидетельство поведения конкретного production-сервиса.

\n

Симптом → причина → проверка → действие

\n
Как отличить настройку от работающей границы
СимптомПричинаПроверкаДействие
Есть CORS, но чужая форма меняет данныеCORS ограничивает чтение ответа, а не сам state-changing запросОтправить POST без CSRF-токена и проверить статус и записьДобавить серверную проверку CSRF или иной эквивалентный механизм
Cookie имеет HttpOnlyБраузер всё ещё может приложить cookie к запросуПроверить фактический запрос во внешнем контекстеНе считать HttpOnly защитой от CSRF; оставить его для защиты от чтения cookie скриптом
Токен проверяется, но меняется чужой объектCSRF-токен не заменяет авторизациюС токеном пользователя запросить объект другого пользователяСверить владельца объекта с субъектом сессии и вернуть 403
Валидация поля есть только в браузереКлиентский код не является доверенной границейОтправить запрос напрямую с неверным или лишним полемПовторить валидацию на сервере до записи
CSP включена, но XSS-тест проходитПолитика не исправляет небезопасный sink и неверное происхождение HTMLПроверить response header и отрицательный сценарий для конкретного sinkИсправить источник и вывод данных; использовать CSP как дополнительный слой
\n

Как связывать control и evidence

\n

Для каждой меры заведите короткую карточку. В поле asset назовите защищаемый объект. В precondition запишите условие, при котором атака возможна. В interruption укажите запрещаемый переход. В evidence положите наблюдение, которое можно повторить. Последнее поле должно иметь границу: оно отвечает только на свой вопрос.

\n
{\n  "asset": "profile.email",\n  "precondition": "session_cookie_present",\n  "attackPath": [\n    "external_page",\n    "browser_attaches_cookie",\n    "POST_profile_email",\n    "profile_changed"\n  ],\n  "interruption": "reject_without_csrf_token",\n  "evidence": "same_request_returns_403_and_value_is_unchanged",\n  "notProven": [\n    "authorization_for_other_objects",\n    "all_other_write_endpoints",\n    "XSS_protection"\n  ]\n}
\n

Такая запись полезнее поля security: enabled. Она показывает, что именно проверяли и что осталось за пределами проверки. Если evidence не связывает asset, endpoint и отрицательный результат, его нельзя переносить на другой маршрут. Если в карте нет residual risk, это не означает нулевой риск. Это означает, что карту заполнили неполно.

\n

Почему CSP и CSRF нельзя смешивать

\n

Content Security Policy (CSP) задаёт браузеру правила для ресурсов и выполнения скриптов. Она может уменьшить последствия инъекции контента. Но CSP не знает, имеет ли пользователь право менять email, и не добавляет секретный токен в POST. Поэтому политика может быть полезной дополнительной защитой, но не заменяет серверную проверку намерения и прав.

\n

Обратное ограничение тоже важно. Хорошая CSRF-защита не исправляет HTML-инъекцию. Если пользовательский текст попадает в небезопасный DOM-sink, скрипт может выполнить действие уже из доверенного контекста страницы и прочитать доступные данные. В этом случае нужны безопасный вывод, кодирование, ограничения источников и тесты для конкретного sink. Нельзя закрыть XSS, добавив заголовок к endpoint изменения профиля.

\n

Порядок действий

\n
  1. Выберите одну операцию записи: endpoint, HTTP-метод, ресурс и изменяемое поле.
  2. Опишите симптом и цену ошибки: что изменится, если внешний запрос пройдёт.
  3. Нарисуйте упорядоченный путь от источника запроса до побочного эффекта.
  4. Для каждого control назовите один переход, который он должен прервать.
  5. Проверьте серверную аутентификацию, авторизацию, CSRF и валидацию независимо.
  6. Сделайте отрицательный запрос: уберите токен, поменяйте владельца или подставьте неверное поле.
  7. Проверьте не только статус ответа, но и состояние ресурса после отказа.
  8. Запишите evidence и отдельный список того, что тест не доказывает.
  9. Повторите проверку после изменения middleware, cookie-политики, маршрута или формата запроса.
\n

Ограничения

\n

CSRF-токен защищает конкретный контракт, если сервер действительно проверяет его до изменения состояния. Он не защищает от украденной сессии, вредоносного скрипта внутри доверенного origin или компрометации сервера. SameSite-cookie снижает риск для части сценариев, но её поведение зависит от браузера и контекста. Не делайте из свойства cookie универсальное доказательство.

\n

Код примера не покрывает OAuth callback, загрузку файлов, WebSocket, GraphQL mutations и фоновые очереди. У каждого канала свои границы. Для GraphQL нужно проверить mutation и resolver. Для файла — имя, тип, содержимое, место хранения и выдачу. Для очереди — кто помещает сообщение, кто его обрабатывает и повторяется ли операция безопасно.

\n

Не объявляйте защиту готовой по одному зелёному тесту. Тест может подтвердить отказ без токена, но не проверит права на чужой объект или другой endpoint. Проверка готова, когда исходный отрицательный запрос возвращает ожидаемый отказ, состояние ресурса не изменяется, а запись связывает результат с конкретным маршрутом и перечисляет непроверенные соседние пути.

\n

Проверяемый критерий готовности

\n

Для выбранной операции у команды есть карта из asset, precondition, attack path, interruption и evidence. Есть автоматизированный или воспроизводимый тест без нужного доказательства. Он получает отказ, а запись остаётся неизменной. Отдельный тест проверяет права на чужой объект. В документе явно указано, что CORS, HttpOnly и CSP не заменяют эти проверки. Если хотя бы одного пункта нет, результат — не «защищено», а «нужна следующая проверка».

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/064.json b/editorial/agent-rewrites/064.json new file mode 100644 index 0000000..f3aecf2 --- /dev/null +++ b/editorial/agent-rewrites/064.json @@ -0,0 +1,7 @@ +{ + "index": 64, + "slug": "editorial-2026-03-field-data-contracts", + "title": "Почему один verdict не описывает совместимость контракта данных", + "excerpt": "Один consumer прочитал новый JSON, но это не доказывает совместимость всей схемы. Разбираем проверку пары producer → consumer, строгие границы чтения и отказ при неизвестном сравнении.", + "contentHtml": "

После изменения JSON один consumer продолжает читать сообщения, и команда ставит схеме зелёный статус. Через день другой reader начинает отбрасывать объект: он запрещает новые поля. Ещё один consumer ждёт обязательный state, а producer уже отправляет только phase. Поле называется похоже, тест на sample проходит, но смысл и правила чтения различаются. Ошибка стоит дорого: сбой обнаруживается после раскатки, владелец находится вручную, а команда откатывает уже связанное изменение.

\n

Тезис простой: совместимость нельзя присвоить схеме в целом. Её проверяют для конкретной пары producer → consumer, конкретной версии и конкретного направления чтения. Для каждой пары нужно назвать contract family, baseline, candidate и capability reader. Неизвестный consumer не получает зелёный статус. Отсутствующая связь означает остановку и уточнение.

\n

Минимальная единица решения

\n

Список интеграций помогает найти владельцев, но не отвечает на вопрос о совместимости. Нужна одна строка review. В ней producer создаёт candidate schema, consumer читает эту форму, а gate сравнивает только заранее названные свойства. Такой scope ограничивает вывод и делает причину отказа адресной.

\n
const review = {\n  family: 'orders-v1',\n  baseline: { id: 'string', state: 'string', note: 'optional' },\n  candidate: { id: 'string', state: 'string', note: 'optional', priority: 'integer' },\n  producerId: 'producer-1.1',\n  consumerId: 'reader-tolerant-v1',\n  direction: 'producer-writes-consumer-reads',\n  policy: 'declared-additions-accepted'\n};
\n

Это учебный объект. Он не описывает реальный сервис, registry, сообщение или deployment. Он показывает форму решения: у comparison есть family, две формы, направление и правило reader. Если убрать любое из этих звеньев, результат нельзя расширять до общего обещания.

\n
\"Матрица
Одна и та же candidate schema даёт разные результаты: tolerant reader принимает объявленное поле, strict reader останавливает проверку, неизвестная пара требует сначала назвать связь.
\n

Что именно проверяет gate

\n

Сначала gate проверяет structural слой. Обязательное поле baseline не должно исчезнуть из candidate. Его тип не должен измениться без отдельного решения. В учебном примере замена state на phase — не безопасный rename. Reader, который ищет state, видит удалённое required field. Близость слов не доказывает совпадение семантики.

\n

Затем gate проверяет объявленные additions. Добавление необязательного priority сохраняет старую обязательную поверхность, но всё равно требует проверки consumer. Tolerant reader может принимать declared additions. Strict reader может отвергать любое дополнительное поле. Тип данных сам по себе не говорит, какая политика действует на границе.

\n

Третья проверка связывает diff с manifest. Если candidate содержит routingHint, но карточка change его не называет, это не повод угадать намерение. Gate возвращает stop-undocumented-schema-field. Скрытое поле может влиять на маршрутизацию, размер сообщения или безопасность. Сначала его нужно объявить и проверить.

\n

Наконец, gate проверяет relation. Поля двух JSON-объектов нельзя сравнивать только потому, что оба объекта выглядят одинаково. Нужны family, producer, consumer и direction. Если направление не задано, sample не превращается в compatibility verdict. Результат — stop-implicit-comparison.

\n

Пример fail-closed проверки

\n
function reviewCompatibility(item) {\n  if (!item.family || !item.producerId || !item.consumerId || !item.direction) {\n    return {\n      status: 'stop-implicit-comparison',\n      nextAction: 'name-contract-relation'\n    };\n  }\n\n  const removed = requiredFields(item.baseline)\n    .filter((field) => !(field in item.candidate));\n\n  if (removed.length > 0) {\n    return {\n      status: 'stop-backward-incompatible-schema',\n      removedRequiredFields: removed\n    };\n  }\n\n  if (hasUndeclaredAddedFields(item)) {\n    return {\n      status: 'stop-undocumented-schema-field',\n      nextAction: 'update-change-manifest'\n    };\n  }\n\n  if (item.policy === 'declared-additions-rejected' && hasAddedFields(item)) {\n    return {\n      status: 'stop-incompatible-consumer',\n      nextAction: 'hold-addition-or-migrate-reader'\n    };\n  }\n\n  return {\n    status: 'synthetic-compatibility-review-hand-off'\n  };\n}
\n

Пример синтетический. Он не читает сеть, не вызывает schema registry, не ищет consumers и не подтверждает результат в production. Функция демонстрирует порядок отказов. Сначала она требует relation, потом проверяет обязательную поверхность, затем manifest и только после этого применяет policy reader. Реальная система должна дополнить эти шаги своей схемой, тестами и наблюдаемыми входами.

\n

Симптом → причина → проверка → действие

\n
Диагностика неверного verdict
СимптомПричинаПроверкаДействие
Один consumer прочитал sample, схема объявлена совместимойПроверили одну пару и свернули результат в общий статусПеречислить named consumers и их policyРазбить review на отдельные строки producer → consumer
Reader перестал находить stateRequired field заменили на похожее имя phaseСравнить обязательные поля baseline и candidateВернуть поле или оформить отдельную migration
Новый reader падает на поле priorityStrict policy не принимает additionsПроверить capability reader, а не только тип поляУдержать addition или расширить границу reader
В candidate есть routingHint, но в change его нетSchema diff не связан с manifestСверить все добавленные поля с declared listОстановить review и описать поле явно
В отчёте написано «совместимо», но direction пустСравнение сделано по внешнему сходству JSONПроверить family, producerId, consumerId и directionВернуть работу на описание relation
\n

Почему общий зелёный статус опасен

\n

У change может быть пять consumers. Один принимает addition, второй запрещает его, третий относится к другой family, а четвёртый неизвестен. Общий статус «compatible» скрывает владельца решения и стирает отрицательные ветки. Такой статус допустим только как агрегат после того, как каждая известная пара получила собственный результат. Даже тогда рядом должны остаться причины stop и несопоставимые отношения.

\n

Strict reader не является неисправным. Его policy — часть контракта. Gate не должен менять её ради удобства producer. Если producer добавляет поле, есть три честных варианта: не добавлять его, мигрировать reader или выпустить отдельную форму. Пока выбор не сделан, stop полезнее зелёного предположения.

\n

Отдельно храните неизвестность. Отсутствие карточки consumer не означает tolerance. Скрытый reader нельзя объявить совместимым по умолчанию. Если связь только предполагается, сначала нужен владелец, подтверждение family и направление чтения. Это отрицательный путь механизма, а не исключение из него.

\n

Порядок проверки

\n
  1. Зафиксируйте одну пару. Назовите producer, consumer, family и направление: кто пишет candidate и кто его читает.
  2. Сохраните baseline. Выпишите обязательные поля, типы и версию формы до изменения.
  3. Опишите candidate. Отделите сохранённые поля, удалённые поля и additions. Не трактуйте rename по сходству имён.
  4. Проверьте manifest. Каждое добавленное поле должно быть объявлено. Необъявленное поле возвращает stop.
  5. Назовите capability reader. Зафиксируйте supported version и policy для declared additions. Не выводите policy из того, что reader однажды прочитал sample.
  6. Запустите structural check. Сначала остановите удалённое required field и изменение типа. Только потом проверяйте additions.
  7. Сохраните отдельный verdict. Запишите точную причину: backward break, undocumented field, incompatible consumer или implicit comparison.
  8. Проверьте отрицательные случаи. Подайте объект без relation, с удалённым state, со скрытым routingHint и со strict reader. Каждый случай должен остановиться на своей причине.
  9. Передайте ограниченный результат. Успешная synthetic-проверка означает только hand-off на следующий review. Она не означает публикацию, раскатку или работоспособность внешней системы.
\n

Ограничения

\n

Этот механизм не обнаруживает неизвестных consumers. Он не знает, кто хранит старую форму в архиве, какой proxy меняет payload и как асинхронная доставка обрабатывает повтор. Для этого нужны inventory, наблюдаемая маршрутизация и отдельные проверки. Compatibility gate не заменяет schema registry, consumer contract tests, миграцию данных и план возврата.

\n

Проверка required fields не покрывает всю семантику. Два поля могут иметь один тип и разные единицы измерения, часовые пояса или правила округления. Название family не доказывает значение поля. Такие условия нужно добавить в контракт отдельными правилами и тестовыми случаями. Нельзя получить полноту из короткой функции.

\n

Учебные literals не дают production-результата. Успешный вызов функции не говорит, что реальный consumer обработал candidate, что registry содержит нужную версию или что deployment завершился. Для реального изменения потребуется привязать проверку к фактическим схемам, версиям, данным и владельцам. Если вход невозможно подтвердить, результат должен остаться stop.

\n

Проверяемый критерий готовности

\n

Проверка готова, когда независимый читатель без устных пояснений видит одну relation, baseline, candidate, required surface и policy consumer. Для каждого addition есть запись в manifest. Для каждого verdict есть причина и следующий адрес действия. Удаление обязательного поля, строгий reader, скрытое поле и пустое направление дают определённые stop-результаты. Ни один synthetic hand-off не назван разрешением на production.

\n

Практический тест готовности короткий: возьмите положительный case, удалите из него по одному звену и повторите проверку. Если объект без consumer или direction всё ещё получает зелёный результат, gate слишком либерален. Если state → phase проходит как косметическое изменение, structural слой слишком слаб. Если скрытый addition проходит, manifest не связан с diff. Готовность выражается не числом зелёных строк, а тем, что каждый разрыв даёт понятный stop.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/065.json b/editorial/agent-rewrites/065.json new file mode 100644 index 0000000..58b4144 --- /dev/null +++ b/editorial/agent-rewrites/065.json @@ -0,0 +1,7 @@ +{ + "index": 65, + "slug": "editorial-2026-03-mechanism-data-contracts", + "title": "Совместимость схемы — это направление, а не номер версии", + "excerpt": "Как compatibility gate отделяет направление чтения, обязательные поля, объявленные additions и возможности consumer — и почему неопределённость должна останавливать проверку.", + "contentHtml": "

Симптом обычно выглядит безобидно: producer выпускает схему v2, consumer видит знакомые поля, а в ревью появляется короткое слово compatible. Затем старый reader получает данные с новым полем, не находит обязательный state или встречает поле другого типа. Ошибка проявляется уже на границе сервисов. Цена — отклонённые сообщения, неверные значения по умолчанию, ручная миграция и спор о том, что именно обещала версия.

\n

Тезис простой: совместимость нельзя вычислять по номеру версии, пересечению имён или удачному примеру сериализации. Сначала нужно назвать направление, две точки схемы и конкретного consumer. Затем отдельно проверить обязательную поверхность, объявленные additions и capability reader. Если хотя бы одна часть неизвестна, gate должен остановиться. Такой отказ полезнее зелёного статуса без объяснения.

\n

Что именно сравнивает gate

\n

Назовём baseline старой схемой и candidate новой схемой. В выбранном направлении фиксированный producer создаёт candidate, а фиксированный consumer читает эту форму, опираясь на baseline как на точку отсчёта. Это не единственное возможное направление. Новый reader может читать старые данные, но это уже другой вопрос и другая карточка сравнения.

\n

Минимальная запись отношения содержит пять значений: direction, family, baselineVersion, candidateVersion и consumerId. family не даёт сравнить случайные JSON-объекты только потому, что у них совпали ключи. Версии закрепляют обе точки. consumerId не позволяет заменить проверяемого reader абстрактным «клиентом». Пустое или изменённое значение даёт stop-implicit-comparison.

\n
\"Цикл
Gate проверяет отношение, форму данных, намерение producer и способность named consumer принять новую поверхность. Красная ветка сохраняет конкретную причину остановки.
\n

Три независимые проверки

\n

Первая проверка смотрит на обязательную поверхность baseline. Если required-поле исчезло из candidate или сменило тип, старый reader больше не получает обещанную форму. Например, замена state на phase может казаться переименованием с тем же смыслом. Gate не угадывает смысл имён. Для reader поле state отсутствует, поэтому результат — stop-backward-incompatible-schema.

\n

Вторая проверка смотрит на новые поля. Candidate может сохранить id и state, но добавить priority. Это не разрушает обязательную поверхность. Однако producer должен явно назвать addition в manifest. Скрытое routingHint, появившееся в candidate без записи в manifest, даёт stop-undocumented-schema-field. Gate сначала требует объяснить новую поверхность, а потом спрашивает, принимает ли её reader.

\n

Третья проверка смотрит на capability consumer. Tolerant reader может разрешать declared additions. Strict reader может отклонять неизвестные поля. Слово optional в схеме producer не меняет policy reader автоматически. Если strict consumer не принимает priority, результат — stop-incompatible-consumer. Gate не удаляет поле на лету и не придумывает adapter. Команда отдельно выбирает изменение reader, разделение формы, задержку candidate или миграцию.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Для двух схем написано compatible, но не указано направлениеОдин boolean склеил разные отношения producer и readerПроверить direction, family, baseline, candidate и consumerIdОстановить как stop-implicit-comparison и оформить relation
В candidate нет обязательного stateRequired-поле baseline удалили или переименовалиПостроить field map и сравнить required surface baselineВернуть поле или назвать отдельную migration
Новый routingHint есть в данных, но нет в описании измененияФактический diff шире declared manifestСверить additions candidate с declaredAddedFieldsОстановить как stop-undocumented-schema-field
Tolerant reader проходит, strict reader падаетCapability зависит от конкретного consumer, а не от версии producerПроверить policy declared additions у named readerИзменить reader, форму или завести migration
Отчёт говорит «75% совместимо»Агрегат скрыл разные причины и владельцев следующего шагаПроверить исходный status и reason одного сравненияСохранить конкретный stop-status вместо процента
\n

Учебный пример с фиксированными схемами

\n

Ниже — учебный JavaScript-подобный пример. Он не подключается к registry, сети, файловой системе, CI или production data. fixedCase возвращает заранее известный объект, а review выполняет только описанные проверки. Пример показывает форму решения, но не доказывает совместимость реального формата.

\n
const baseline = {\n  version: '1.0',\n  required: { id: 'string', state: 'string' }\n};\n\nconst candidate = {\n  version: '2.0',\n  required: { id: 'string', phase: 'string' },\n  additions: []\n};\n\nconst relation = {\n  direction: 'backward',\n  family: 'orders',\n  baselineVersion: '1.0',\n  candidateVersion: '2.0',\n  consumerId: 'orders-reader'\n};\n\nconst report = review({ baseline, candidate, relation });\nconsole.log(report);\n// {\n//   status: 'stop-backward-incompatible-schema',\n//   removedRequiredFields: ['state'],\n//   nextAction: 'retain-required-baseline-field-or-name-a-separate-migration'\n// }
\n

Важна не длина функции, а граница вывода. Gate обнаружил отсутствие state. Он не объявил новый phase эквивалентом, не выдал разрешение на deploy и не выбрал стратегию миграции. Следующий шаг зависит от владельца контракта и требований старого reader.

\n

Положительный учебный случай тоже ограничен. Если candidate сохраняет id и state, добавляет объявленный priority, а named reader допускает declared additions, gate может вернуть synthetic hand-off. Это означает только то, что фиксированная проверка закончилась положительно. Это не означает, что parser, registry, права, нагрузка и выпуск в реальной системе готовы.

\n

Почему порядок проверок имеет значение

\n

Если сначала спросить reader, принимает ли он неизвестные поля, tolerant policy может скрыть неописанное изменение producer. Поэтому gate сначала устанавливает отношение, затем проверяет required surface, потом сверяет manifest и только после этого проверяет capability.

\n

Так распределяется ответственность. Producer называет новую поверхность. Сравнение проверяет буквальную форму. Manifest связывает diff с намерением. Consumer описывает границу принятия. Ни один слой не подменяет другой. Если переставить шаги, зелёный результат может появиться раньше, чем команда поймёт, что именно она выпускает.

\n

Порядок действий

\n
  1. Выберите одну contract family и зафиксируйте baseline и candidate.
  2. Назовите направление: какой producer пишет, какой reader читает и относительно какой точки.
  3. Запишите consumerId и остановите сравнение при неизвестной или неполной связи.
  4. Постройте карты полей и проверьте исчезновение required-полей и смену их типов.
  5. Составьте manifest всех новых полей candidate; не выводите намерение из одного sample.
  6. Сверьте фактические additions с manifest и остановите скрытые поля.
  7. Проверьте capability именно named consumer: required fields, допустимую версию и policy дополнительных полей.
  8. Верните один status, reason и next action; положительный результат назовите только synthetic hand-off.
  9. Для обратного отношения заведите отдельное сравнение, а не расширяйте текущий boolean.
\n

Отрицательный путь

\n

Нельзя считать gate работающим только по зелёному fixed case. Передайте объект без direction. Ожидайте stop-implicit-comparison. Удалите state из candidate. Ожидайте stop-backward-incompatible-schema. Добавьте routingHint без manifest. Ожидайте stop-undocumented-schema-field. Замените tolerant reader на strict reader. Ожидайте stop-incompatible-consumer.

\n

Каждый отказ должен сохранять следующий шаг. Неизвестное отношение требует уточнить карточку. Удалённое required-поле требует вернуть поверхность или назвать миграцию. Скрытый addition требует обновить описание изменения или убрать поле. Несовместимый reader требует решения владельца consumer. Общий статус «не прошёл» не даёт команде достаточного действия.

\n

Ограничения механизма

\n

Этот gate проверяет узкое отношение между фиксированными описаниями. Он не извлекает схемы из registry, не знает все deployment-версии, не проверяет реальные payloads и не подтверждает, что consumer честно описал свои потребности. Он также не решает семантическое изменение: строка state=active может сохранить тип и имя, но начать означать другой бизнес-статус.

\n

Он не заменяет contract tests, миграцию данных, нагрузочную проверку, security review, SLA и план отката. JSON Schema, JTD и Avro дают полезные понятия для формы и чтения, но не определяют статусы этого gate. Поэтому результат нужно читать узко: механизм сделал одно сравнение явным и остановил неизвестность. Он не управляет релизом.

\n

Проверяемый критерий готовности

\n

Для выбранной пары есть заполненные family, direction, baseline, candidate и consumerId. Gate возвращает отдельные результаты для неизвестного отношения, разрушенной required surface, скрытого addition и несовместимого reader. Есть один положительный учебный случай и отрицательные случаи для каждой остановки. Каждый report содержит reason и next action. Положительный report прямо говорит synthetic hand-off и не выдаёт право на deploy.

\n

Если команда не может воспроизвести эти статусы на фиксированных входах или не знает, какой reader проверяется, критерий не выполнен. Номер версии и зелёный процент не заменяют evidence. Готовность здесь означает, что вопрос о совместимости имеет направление, named участников, отдельную причину и проверяемый следующий шаг.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/066.json b/editorial/agent-rewrites/066.json new file mode 100644 index 0000000..ab54ab9 --- /dev/null +++ b/editorial/agent-rewrites/066.json @@ -0,0 +1,7 @@ +{ + "index": 66, + "slug": "editorial-2026-03-practice-data-contracts", + "title": "Изменение схемы без устных договорённостей: как проверить контракт данных", + "excerpt": "Практический маршрут для изменения JSON-контракта: зафиксировать baseline, candidate, manifest, направление чтения и конкретного consumer до передачи изменения дальше.", + "contentHtml": "

Продюсер добавляет поле в JSON-ответ. Потребитель узнаёт об этом из сообщения в чате. Через несколько часов строгий парсер отклоняет объект с новым ключом, а команда спорит, было ли поле обязательным, кто согласовал изменение и какую версию нужно откатить. Цена ошибки складывается из простоя, ручного восстановления данных и времени на поиск настоящего контракта.

\n

Проблема начинается не с синтаксиса схемы. Она начинается с неявного обещания: «новое поле необязательное, значит всё совместимо». Это утверждение неполно. Нужно назвать исходную форму, новую форму, направление чтения, producer и конкретного consumer. Только после этого можно решить, additive change это или несовместимое изменение.

\n

Тезис: версия не заменяет проверку

\n

Номер v1.1 связывает две точки во времени, но не отвечает на главный вопрос. Может ли reader, рассчитанный на baseline, принять candidate? Ответ зависит от обязательных полей, типов, дополнительных ключей и правил самого reader. Один consumer игнорирует незнакомые поля. Другой отвергает их. Одинаковый JSON для них имеет разный результат.

\n

Контракт данных — это не только схема. Это схема вместе с владельцем записи, ожидаемым reader, направлением совместимости и правилом изменения. Для практической проверки достаточно начать с одной пары: producer создаёт candidate, named consumer читает его как продолжение baseline. Остальные потребители требуют отдельных проверок.

\n

Механизм: сравнить пару, а не два файла

\n

Сначала зафиксируйте baseline — форму, которую уже читает потребитель. Затем опишите candidate — форму после изменения. В manifest перечислите добавленные, удалённые и изменённые по типу поля. Направление backward в этом материале означает: старый reader получает новую запись. Это не означает, что новый reader обязательно прочитает старую запись.

\n

Проверка должна идти в том же порядке. Сначала она убеждается, что обязательная поверхность baseline не исчезла. Затем проверяет, что каждое новое поле названо в manifest. После этого она спрашивает capability конкретного consumer: принимает ли он дополнительные ключи. Если входные данные не называют направление или consumer, проверка останавливается. Пустое сравнение нельзя считать зелёным результатом.

\n
\"Схема
Учебная иллюстрация показывает, почему additive change зависит не только от candidate, но и от правил reader. Красная ветка означает остановку до передачи изменения дальше.
\n

Минимальный пример

\n

Ниже — учебная функция. Она не подключается к registry, не читает реальные схемы и не доказывает совместимость сервиса. Её задача — сделать порядок решений видимым.

\n
const baseline = {\n  id: { type: 'string', required: true },\n  state: { type: 'string', required: true },\n  note: { type: 'string', required: false },\n};\n\nconst candidate = {\n  ...baseline,\n  priority: { type: 'number', required: false },\n};\n\nconst change = {\n  direction: 'backward',\n  producer: 'work-item-api',\n  consumer: 'billing-worker-v1',\n  added: ['priority'],\n  removed: [],\n  changed: [],\n};\n\nfunction review({ baseline, candidate, change, acceptsAdditional }) {\n  const requiredLost = Object.entries(baseline)\n    .filter(([name, field]) => field.required && !candidate[name])\n    .map(([name]) => name);\n\n  if (!change.direction || !change.consumer) {\n    return { status: 'stop-implicit-comparison' };\n  }\n  if (requiredLost.length > 0) {\n    return { status: 'stop-backward-incompatible-schema', requiredLost };\n  }\n\n  const actualAdded = Object.keys(candidate)\n    .filter((name) => !baseline[name]);\n  const undocumented = actualAdded\n    .filter((name) => !change.added.includes(name));\n\n  if (undocumented.length > 0) {\n    return { status: 'stop-undocumented-schema-field', undocumented };\n  }\n  if (actualAdded.length > 0 && !acceptsAdditional) {\n    return { status: 'stop-incompatible-consumer' };\n  }\n  return { status: 'synthetic-compatibility-review-hand-off' };\n}\n\nconsole.log(review({\n  baseline,\n  candidate,\n  change,\n  acceptsAdditional: true,\n}));\n// { status: 'synthetic-compatibility-review-hand-off' }
\n

В примере priority не удаляет id и state, поэтому структурная проверка проходит. Поле также записано в manifest. Tolerant consumer принимает дополнительные ключи, и функция возвращает ограниченный положительный статус. Этот статус означает только одно: фиксированная учебная пара прошла перечисленные правила. Он не означает deploy, миграцию базы или успешную обработку реального сообщения.

\n

Теперь измените candidate: замените state на phase. Функция вернёт stop-backward-incompatible-schema. Имена похожи, но старый consumer всё ещё ищет обязательное поле state. Не пытайтесь исправить этот результат добавлением номера версии. Здесь нужен отдельный план миграции или сохранение старого поля на период перехода.

\n

Третий случай — strict consumer. Оставьте additive candidate, но передайте acceptsAdditional: false. Результат станет stop-incompatible-consumer. Поле может быть корректным для одного reader и запрещённым для другого. Поэтому слово «optional» должно описывать не только schema declaration, но и поведение потребителя.

\n

Симптомы и действия

\n
СимптомПричинаПроверкаДействие
Старый reader падает на новом ключеConsumer запрещает дополнительные поляПроверить его parser policy и тест на candidateОставить поле вне старой формы, изменить reader или ввести отдельный контракт
После rename пропало значениеУдалено обязательное поле baselineСравнить required-поля baseline и candidateВернуть поле на переходный период или спроектировать миграцию
В review нет единого вердиктаНе задано направление или named consumerПроверить manifest: direction, producer, consumerОстановить изменение и сначала определить пару
Новый ключ появился без обсужденияDiff шире заявленного manifestСравнить фактические ключи candidate со списком addedНазвать поле и его смысл либо удалить его из candidate
Тип остался строкой, но смысл изменилсяСемантический breaking change не виден в structural diffСверить единицы, timezone, enum и документацию consumerДать новое имя или подготовить явную миграцию значения
\n

Почему schema validation недостаточно

\n

JSON Schema описывает структуру экземпляра: свойства, типы и дополнительные свойства. Это полезная граница, но schema validation не знает, какой сервис владеет полем, кто читает объект и можно ли менять смысл значения без новой версии. Две схемы могут быть валидными по отдельности и всё равно не образовывать безопасную пару.

\n

Та же граница видна в JSON Type Definition. В RFC 8927 required properties и optionalProperties разделены явно. Режим дополнительных свойств тоже задаётся отдельно. Это хороший словарь для разговора о форме объекта. Но RFC не выбирает migration policy вашей команды и не сообщает, выдержит ли конкретный consumer изменение.

\n

В форматах с writer и reader schemas направление становится ещё заметнее. Apache Avro описывает schema resolution между схемой записи и схемой чтения. Для JSON-сервисов конкретные правила будут другими, но принцип переносим: нельзя обсуждать compatibility без указания стороны, которая пишет, и стороны, которая читает.

\n

Порядок действий

\n
  1. Зафиксируйте baseline. Укажите идентификатор и версию формы. Выпишите обязательные поля и их типы.
  2. Опишите candidate. Покажите полную новую форму, а не только короткий diff. Не меняйте baseline задним числом.
  3. Составьте manifest. Перечислите added, removed и changed. Любое поле вне списка считается неоформленным.
  4. Назовите участников. Запишите producer, конкретного consumer, family контракта и направление: backward или другое явно определённое отношение.
  5. Проверьте обязательную поверхность. Убедитесь, что candidate сохраняет required-поля baseline и их типы.
  6. Проверьте новые поля. Сверьте фактический diff с manifest. Затем проверьте parser policy named consumer.
  7. Разберите отрицательный путь. Запустите тест на удаление обязательного поля, скрытый новый ключ и strict reader. Для каждой ветки сохраните отдельную причину остановки.
  8. Передайте результат с границей. Положительный synthetic verdict передаёт change на независимый review. Он не разрешает deploy без интеграционных проверок и наблюдаемого rollout.
\n

Ограничения

\n

Учебный gate не видит неизвестных внешних клиентов. Он не проверяет кеши, очереди, сохранённые payload, SDK, базы и семантику бизнес-значений автоматически. Он также не определяет срок поддержки старой формы. Для этих вопросов нужны реальные инвентари потребителей, contract tests и план удаления.

\n

Добавление необязательного поля часто безопаснее удаления обязательного, но это не универсальное правило. Строгий parser, подпись payload или downstream-система с закрытым набором ключей превращают additive change в остановку. Не называйте поле безопасным только потому, что оно не помечено как required.

\n

Отдельный риск — изменение смысла без изменения типа. Строка amount может перейти с рублей на копейки. Timestamp может сменить timezone. Enum может получить другой смысл при том же наборе строк. Structural diff этого не докажет. Нужны доменное описание, тесты значений и проверка consumer.

\n

Критерий готовности

\n

Изменение готово к следующему review, если другая инженерная команда без устного пояснения может открыть одну карточку и ответить на пять вопросов: какая форма была baseline, какая стала candidate, что изменилось по manifest, кто читает результат и в каком направлении выполнялась проверка. Для additive change дополнительно нужен положительный тест tolerant consumer и отрицательный тест strict consumer. Для breaking change нужен отдельный migration или versioning decision.

\n

Если хотя бы один ответ неизвестен, итогом должен быть stop, а не зелёный комментарий. Такая остановка дешевле аварийного отката: она превращает неясное обещание в конкретный вопрос, который можно проверить.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/067.json b/editorial/agent-rewrites/067.json new file mode 100644 index 0000000..916193f --- /dev/null +++ b/editorial/agent-rewrites/067.json @@ -0,0 +1,7 @@ +{ + "index": 67, + "slug": "editorial-2026-02-field-resilience", + "title": "Когда retry усиливает отказ: как ограничить каскад запросов", + "excerpt": "Медленный внешний API превращает повторы, fallback и репликацию в новый источник нагрузки. Разбираем, где поставить единую границу, как проверить трассу и когда остановиться.", + "contentHtml": "

Внешний API начинает отвечать за 3 секунды вместо 200 миллисекунд. Ваш сервис не падает сразу: он повторяет запрос, пробует другую реплику и запускает fallback. Через минуту очередь растёт, рабочие потоки заняты ожиданием, а внутренние запросы получают таймауты. Ошибка внешней зависимости превращается в отказ собственного приложения.

\n

Цена каскада состоит не только из лишних запросов. Команда теряет связь между исходным запросом и его повторами. Логи показывают несколько похожих ошибок. Метрики смешивают первичную работу и повторную. Пользователь получает задержку вместо ответа, а перегруженный сервис продолжает принимать новую работу. Если система не знает, где остановиться, каждая защитная мера увеличивает масштаб отказа.

\n

Главный тезис прост: retry, fallback и репликация должны подчиняться одному явному бюджету. Его нужно применять до расширения маршрута. У повторной попытки должен быть один владелец, у fan-out — целочисленный предел, у fallback — имя и конечный результат. Когда бюджет исчерпан, система должна выполнить terminal action. Она не должна незаметно создавать ещё один уровень попыток.

\n
\"Каскад
Единая граница отделяет ограниченное восстановление от нового витка нагрузки. Красная ветка заканчивается окончательным действием, а не очередной попыткой.
\n

Как возникает усиление отказа

\n

Представим два логических запроса. Для каждого сервис делает первичный вызов и разрешает одну дополнительную попытку. Если каждый слой владеет retry, число физических вызовов растёт быстрее, чем ожидает владелец верхнего слоя. Edge может повторить вызов адаптера, а адаптер — повторить тот же вызов внешнего API. В результате одна ошибка получает два независимых счётчика.

\n

Репликация добавляет ширину. Правило «попробовать все доступные реплики» не ограничивает работу. При задержке или частичном отказе оно запускает несколько запросов до того, как первый результат станет понятен. Fallback добавляет ещё одну ветку. Если fallback сам умеет повторять вызов, его нельзя считать запасным результатом: это второй retry-контур.

\n

Ограниченный маршрут устроен иначе. Edge владеет двумя попытками. Каждая попытка выбирает одну реплику. Fallback получает управление только после именованного исхода, например `optional-result-unavailable`, и не создаёт retry. После второй попытки система возвращает заранее определённый деградированный результат или явно отказывает. Такая схема не обещает восстановить полный ответ. Она ограничивает стоимость отказа и оставляет понятную трассу.

\n

Что должно быть названо в контракте

\n

Сначала назовите отказ. `temporary-timeout` отличается от ошибки контракта или отказа авторизации. Повтор допустим только для исходов, для которых владелец операции подтвердил безопасность и полезность повторения. HTTP 503 сообщает о временной неспособности обработать запрос, но сам по себе не доказывает, что конкретную операцию можно безопасно повторить. То же относится к заголовку `Retry-After`: он передаёт подсказку о времени, но не назначает владельца retry.

\n

Затем назовите владельца. В системе может быть несколько компонентов, которые технически способны повторять запрос. Это не значит, что каждый должен это делать. Зафиксируйте один слой, его `maxAttempts`, список повторяемых исходов и момент, когда он прекращает работу. Если два слоя имеют `enabled: true`, проверьте их совместно: верхний повтор может повторять уже повторённую работу.

\n

После этого назовите предел маршрута. `maxFanout: 1` означает, что одна попытка выбирает одну реплику. Список из трёх реплик не даёт права обращаться ко всем трём одновременно. Если предел не задан, его нельзя вывести из количества имён в списке. Неявный предел не является защитой.

\n

Последним назовите terminal action. Это может быть деградированный ответ, ошибка с понятным кодом или сохранение результата частичной операции. Он зависит от предметной области. Для операции с финансовым побочным эффектом нельзя бездумно возвращать «неполный успех». Важен сам принцип: после terminal action нет скрытого retry и нового fallback.

\n

Пример ограниченного маршрута

\n

Ниже приведён самодостаточный JavaScript-пример. Он работает только с переданным объектом и не вызывает сеть. Числа показывают форму контракта, а не рекомендуемые значения для production. Перед переносом в сервис их нужно заменить правилами конкретной операции и подтвердить безопасность повтора.

\n
const route = {\n  retry: {\n    owner: 'edge',\n    maxAttempts: 2,\n    retryable: ['temporary-timeout'],\n  },\n  replicas: {\n    names: ['primary-a', 'primary-b', 'primary-c'],\n    maxFanout: 1,\n  },\n  fallback: {\n    name: 'named-summary',\n    trigger: 'optional-result-unavailable',\n    addsRetry: false,\n    output: 'degraded-summary',\n  },\n  limit: {\n    point: 'before-route-expansion',\n    onExhaustion: 'return-degraded-result',\n  },\n};\n\nfunction nextStep(outcome, attempt) {\n  if (route.retry.retryable.includes(outcome) &&\n      attempt < route.retry.maxAttempts) {\n    return { action: 'retry', owner: route.retry.owner };\n  }\n\n  if (outcome === route.fallback.trigger) {\n    return { action: 'fallback', name: route.fallback.name };\n  }\n\n  return { action: route.limit.onExhaustion };\n}\n\nconsole.log(nextStep('temporary-timeout', 2));\n// { action: 'return-degraded-result' }
\n

Функция не измеряет задержку и не проверяет реальную доступность реплики. Она показывает важную границу: после второй попытки результат не переходит к новому retry. Для реальной реализации нужно дополнительно передать идентификатор логического запроса, сохранить причину остановки и связать события одной трассой. Не следует считать этот вывод доказательством устойчивости сервиса.

\n

Симптом → причина → проверка → действие

\n
Диагностика каскада без смешения гипотез
СимптомПричинаПроверкаДействие
Число внешних вызовов выше числа пользовательских запросовПовторяют несколько слоёвСопоставить owner и attempt в traceОставить одного владельца retry
При одном timeout растёт нагрузка на все репликиНе задан fan-out на попыткуПосчитать выбранные реплики для одного logical requestЗадать целочисленный maxFanout и проверить его до расширения маршрута
Fallback запускается после каждой ошибкиНе различены retryable и terminal outcomesПроверить trigger и список повторяемых исходовНазвать trigger и запретить fallback создавать retry
В trace появляются действия без владельцаЛогика скрыта в библиотеке или промежуточном адаптереНайти первый span, который создаёт новый вызовДобавить owner и событие расхода бюджета
После исчерпания попыток запрос продолжает житьНет terminal action или отмены in-flight работыПроверить последнюю запись trace и состояние очередиВернуть явный результат и прекратить дальнейшее расширение
Два сценария дают разные цифры, но считаются сопоставимымиРазличаются logical load или failure injectionСверить baseline key, число запросов и исход отказаРазделить сценарии и не усреднять результаты
\n

Порядок проверки

\n
  1. Зафиксируйте один логический запрос и его ожидаемый результат. Отделите его от физических вызовов к зависимостям.
  2. Назовите failure injection: цель, исход и область действия. Не заменяйте конкретный timeout общим словом «сбой».
  3. Найдите всех владельцев retry. Оставьте один слой, если операция не требует другой схемы с доказанным бюджетом.
  4. Задайте `maxAttempts` и список `retryable` outcomes. Для каждого исхода проверьте идемпотентность и смысл повтора.
  5. Опишите реплики и `maxFanout`. Убедитесь, что одна попытка выбирает не больше разрешённого числа направлений.
  6. Опишите fallback: имя, trigger, результат и отсутствие собственного retry. Если ветка делает несколько вызовов, вынесите её в отдельный ограниченный контракт.
  7. Поставьте limit point до расширения маршрута. Запишите, что происходит при исчерпании бюджета.
  8. Соберите trace из событий одного logical request. Каждый новый вызов должен иметь причину, владельца и номер попытки.
  9. Повторите отрицательные сценарии: второй retry owner, пустой fallback, `maxFanout: null`, отсутствующий limit и несовпадающий baseline.
  10. Сверьте результат с тем же сигналом, который обнаружил проблему. Не заменяйте проверку заявлением о будущей эффективности.
\n

Отрицательный путь важнее зелёного примера

\n

Ограничение считается рабочим только тогда, когда оно останавливает неправильные конфигурации. Включите retry у edge и adapter одновременно. Проверка должна вернуть статус о нескольких владельцах, а не выбрать один молча. Удалите имя fallback. Результатом должна стать остановка с причиной, а не переход к безымянному ответу. Замените `maxFanout: 1` на `null`. Проверка обязана остановить сценарий до выбора реплик.

\n

Удалите limit point. Не подставляйте его из `maxAttempts`: это разные свойства. `maxAttempts` ограничивает конкретный счётчик попыток. Limit point отвечает за порядок: бюджет должен быть проверен до того, как начнётся новое расширение маршрута. Если сценарий использует семь логических запросов вместо двух или другой failure injection, его нельзя сравнивать с базовым примером. Сначала выровняйте входы, затем сравнивайте trace.

\n

Отдельно проверьте постоянную ошибку контракта. Её нельзя автоматически считать временным timeout. Повтор не исправит несовместимую схему и может увеличить нагрузку. Для неё нужен другой путь: быстрый отказ, карантин сообщения или согласованная миграция. Универсальный retry по любому статусу — частая причина каскада.

\n

Что покажет трасса

\n

Полезная трасса отвечает на четыре вопроса: какой логический запрос начал работу, какой исход получил каждый вызов, кто решил повторить и где система остановилась. Для ограниченного примера последовательность может выглядеть так: `logical-01 → primary-a → temporary-timeout`; `edge → retry budget 2 → 1`; `logical-01 → primary-b → complete`. Для второго запроса: `primary-c → optional-result-unavailable`; затем `named-summary → degraded-summary`.

\n

Такой список не является метрикой производительности. Он нужен, чтобы восстановить решение. Если в нём есть вызов, которого нет в retry plan, fallback или replication policy, модель неполна. Если последний span заканчивается ошибкой, но очередь продолжает принимать работу того же класса, причина может находиться выше: лимит стоит слишком поздно, а не просто имеет неправильное число.

\n

Связывайте повтор с логическим идентификатором, но не записывайте в trace секреты и пользовательские данные. Внешний trace id помогает найти событие, но не является доказательством личности или разрешением на доступ. Наблюдаемость должна объяснять расход бюджета и границу остановки.

\n

Ограничения применимости

\n

Описанный механизм не выбирает оптимальный timeout. Он не знает пропускную способность сервиса, размер очереди, стоимость подключения, deadline клиента и долю ошибок зависимости. Маленький `maxAttempts` может быть правильным для одного чтения и опасным для другой операции. Значение нужно выводить из контракта и capacity модели, а не копировать из примера.

\n

Механизм не подтверждает идемпотентность. Повтор чтения обычно отличается от повтора платежа, создания заказа или отправки письма. Если запрос мог изменить состояние, сначала определите ключ идемпотентности и границу подтверждения. При отсутствии такого контракта безопаснее остановиться, чем включить retry ради доступности.

\n

Механизм не заменяет отмену in-flight работы. Если верхний слой уже вернул terminal action, нижний вызов может продолжать занимать соединение. Нужны deadline, cancellation и проверка поведения клиента. Также отдельной проверки требуют circuit breaker, rate limit, очередь и политика деградации. Единый бюджет не устраняет эти компоненты, но не даёт им бесконтрольно складывать новые попытки.

\n

Примеры в статье фиксируют значения только для объяснения механизма. Они не содержат production-трафик, реальные latency, измерения восстановления или обещания SLA. Нельзя писать в отчёте «сервис выдерживает отказ» только потому, что объект прошёл проверку. Доказательство требует воспроизводимого сценария в целевой системе и согласованного критерия результата.

\n

Проверяемый критерий готовности

\n

Для одного класса операции есть заполненные failure injection, retry owner, `maxAttempts`, retryable outcomes, `maxFanout`, fallback и terminal action. Trace связывает каждый физический вызов с одним logical request. Неправильные конфигурации останавливаются с отдельными причинами: несколько retry owners, безымянный fallback, неограниченный fan-out, отсутствие limit point и несопоставимый сценарий.

\n

Проверка считается завершённой, если команда может воспроизвести положительный и отрицательный пути на фиксированных входах, а затем повторить тот же сценарий в тестовой среде с разрешённым наблюдением. На последнем шаге видно, где прекратилось создание новой работы, сколько попыток получил запрос и какой terminal action вернулся. Если любой из этих фактов неизвестен, готовность не доказана.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/068.json b/editorial/agent-rewrites/068.json new file mode 100644 index 0000000..08e52e1 --- /dev/null +++ b/editorial/agent-rewrites/068.json @@ -0,0 +1,7 @@ +{ + "index": 68, + "slug": "editorial-2026-02-mechanism-resilience", + "title": "Механика устойчивости: сначала ограничьте каскад, потом настраивайте retry", + "excerpt": "Timeout не ограничивает объём работы. Устойчивый маршрут задаёт одного владельца retry, конечный fan-out, именованный fallback и точку остановки до расширения каскада.", + "contentHtml": "

Внешний сервис начинает отвечать медленно. На входе растёт очередь. Gateway повторяет запрос по timeout, адаптер повторяет его ещё раз, а выбор реплики отправляет работу на несколько узлов. Каждый механизм выглядит разумно отдельно. Вместе они увеличивают поток к уже перегруженной зависимости. Пользователь получает задержку или ошибку. Оператор видит несколько причин и не знает, где остановить цепочку. Цена ошибки — не один лишний запрос. Это занятые соединения, память под незавершённые операции и потеря мощности именно в момент отказа.

\n

Тезис статьи простой: устойчивость начинается с границы дополнительной работы. Сначала нужно определить логический запрос, одного владельца retry, максимальное число попыток, ширину выбора реплики и результат после исчерпания бюджета. Только после этого имеют смысл timeout, backoff и fallback. Если каждый слой может запустить следующую попытку, система не имеет одного механизма восстановления. Она имеет усилитель нагрузки.

\n

Ниже используется учебная fixed-модель. В ней два логических запроса, один именованный временный timeout, две попытки на запрос, fan-out равен одному и fallback имеет одну условную единицу работы. Модель не открывает сеть, не измеряет latency, не знает реальных реплик и не подтверждает production-устойчивость. Она проверяет только структуру решения и умеет остановиться, когда структура нарушена.

\n
\"Матрица
Учебная матрица разделяет исход, разрешённое действие, добавленную работу и точку остановки. Она не описывает топологию настоящего сервиса.
\n

Механизм: считать логическую работу

\n

Считать нужно не только HTTP-запросы. Единица анализа — логический запрос пользователя или вызывающего сервиса. Он может породить несколько исходящих действий. Для каждого действия запишите причину запуска и право запускать следующее действие. Это сразу разделяет последовательный retry и fan-out.

\n

Retry запускает новый вызов после определённого исхода. Fan-out создаёт несколько направлений для одной попытки. Репликация задаёт множество доступных имён, но не обязана выбирать их все. Fallback меняет контракт результата. Он может вернуть неполный ответ, но не должен незаметно создавать собственную политику повторов. Limit point запрещает следующий переход. Если он срабатывает после fan-out, он уже не ограничивает первую волну работы.

\n
Величины, которые должны иметь отдельную границу
ВеличинаУчебное значениеЧто проверяетОшибка при смешении
logical requests2единицу сравнениясравнение разных объёмов работы
retry owneredgeкто имеет право повторить вызовдва слоя запускают вложенные повторы
max attempts2конечный предел попытокretry превращается в цикл
max fan-out1одно имя реплики за попыткуодин запрос размножается по пулу
fallback work1стоимость деградированного результатазапасной путь считают бесплатным
limit pointдо route expansionмомент запрета следующего действиясчётчик фиксирует проблему постфактум
\n

В bounded-cascade-v1 есть три возможных имени реплики, но на одну попытку выбирается только одно. Для двух логических запросов и максимум двух попыток верхняя граница учебной работы равна четырём route attempts. Это не QPS, не прогноз CPU и не оценка времени ответа. Она нужна, чтобы проверить, что новая защита не добавила скрытую ветвь.

\n

Пример и отрицательный путь

\n

Положительный сценарий начинается с logical-01. Он обращается к primary-a и получает temporary-timeout. Единственный владелец retry, edge, списывает одну попытку. Следующий вызов идёт к primary-b. Второй логический запрос получает fixed-optional-result-unavailable; вместо нового поиска он возвращает named-summary с результатом fixed-degraded-summary. У fallback нет своего retry. После нулевого бюджета limit point возвращает фиксированный деградированный результат и не расширяет маршрут.

\n
const candidate = createFixedResilienceScenario('retry-amplification-v1');\nconst result = assessFixedResilienceScenario(candidate);\nconsole.log({ status: result.status, reason: result.reasons[0], handoff: result.handoff });\n// stop-retry-amplification\n// retry-must-have-one-owner-and-a-fixed-two-attempt-bound
\n

Этот фрагмент показывает отрицательный путь. В retry-amplification-v1 включены два владельца retry: edge и adapter. Проверка не выбирает «лучший» слой и не моделирует задержку. Она отказывает сразу. Причина сильнее локальной настройки: у логического запроса должно быть одно право создавать следующую попытку и конечный предел. Такой отказ полезен в review. Следующее действие однозначно: убрать дублирование или уточнить границу ответственности. Нельзя компенсировать конфликт ещё одним timeout.

\n

Тот же fail-closed подход нужен для неизвестного сценария, неименованного fallback и неограниченного fan-out. Если вход нельзя сопоставить с fixed record, проверка не должна подставлять значения по умолчанию. Неизвестное поведение — это повод остановиться, а не разрешить самый широкий маршрут.

\n

Симптомы и проверка

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
После timeout растёт число исходящих вызововretry есть на gateway и в адаптеренайти owner в каждой политикеоставить одного owner, остальные слои сделать pass-through
Одна операция видна на нескольких репликахfan-out не ограничен на попыткупосчитать выбранные имена в traceзадать числовой maxFanout и проверять его до запуска
Fallback увеличивает задержкузапасной путь сам делает поиск или retryразвернуть его переходы и work unitsназвать стоимость, убрать вложенный retry или выключить fallback
Лимит есть в конфигурации, но каскад уже расширилсяlimit point стоит после route expansionсравнить порядок trace transitionsперенести ограничение перед созданием следующего маршрута
Результаты тестов нельзя сравнитьизменились нагрузка или failure injectionсверить logical load и ключ сценариявернуться к тому же baseline или объявить сравнение недействительным
\n

Почему timeout и статус ошибки не решают задачу

\n

Timeout отвечает на вопрос «сколько ждать этот вызов». Он не отвечает на вопрос «кто имеет право создать следующий». Короткий timeout может даже повысить нагрузку, если каждый слой интерпретирует его как разрешение на повтор. Статус 503 сообщает, что сервис временно не готов обработать запрос, но не выбирает retry owner и не доказывает идемпотентность действия. Retry-After задаёт подсказку для времени ожидания, а не общий бюджет каскада.

\n

Backoff тоже не является лимитом. Он раздвигает попытки во времени, но оставляет их количество и владельца. Если несколько слоёв применяют независимый backoff, суммарное число переходов остаётся неясным. Поэтому сначала фиксируйте право и число попыток. Затем выбирайте расписание. Для операций с побочными эффектами отдельно проверяйте идемпотентность и компенсацию. В этой учебной модели таких эффектов нет.

\n

Порядок действий

\n
  1. Назовите логический запрос и перечислите все исходящие действия, которые он может породить.
  2. Назначьте одного владельца retry. Запретите остальным слоям запускать второй цикл.
  3. Назовите retryable outcomes и задайте конечное число попыток.
  4. Запишите доступные реплики, но ограничьте число выбранных имён на одну попытку.
  5. Опишите fallback как отдельный результат с именем, условием и стоимостью.
  6. Поставьте limit point до retry, fallback и route expansion, если эти переходы создают новую работу.
  7. Добавьте trace transition для расхода бюджета и terminal action после нуля.
  8. Прогоните положительный и отрицательный fixed-сценарии на одинаковой логической нагрузке.
  9. Только после этого перенесите контракт в настоящий тест с безопасными данными, отменой и наблюдением.
\n

Ограничения модели

\n

Модель не знает о реальной ёмкости, очередях, дедлайнах, cancellation, сетевых сбоях, распределённом состоянии, правах доступа или пользовательском ущербе. Числа два и один выбраны для учебной проверки. Их нельзя переносить в конфигурацию сервиса. Положительный статус означает, что fixed-карточка удовлетворяет формальным ограничениям. Он не означает availability, recovery time, безопасный rollout или допустимую бизнес-деградацию.

\n

Отдельно ограничен сам способ сравнения. Нельзя сопоставлять сценарий с двумя логическими запросами со сценарием с другой нагрузкой и делать вывод о причине. Нельзя менять тип failure injection и сохранять прежний baseline. Нельзя считать trace доказательством скорости: trace показывает порядок и факт переходов, а latency требует измерения. Если один из этих фактов неизвестен, правильный результат — остановка и новый вопрос, а не расширение предположений.

\n

Проверяемый критерий готовности

\n

Механизм готов к отдельному инженерному тесту, если для одного фиксированного сценария можно показать пять вещей: один retry owner; конечный maxAttempts; числовой maxFanout; fallback с именем, условием и запретом вложенного retry; limit point до расширения маршрута. Trace должен показывать расход бюджета и один terminal action после его исчерпания. Тест должен пройти положительный record и отклонить retry-amplification-v1 с причиной, которую можно прочитать без догадки.

\n

Если хотя бы один пункт не выполняется, не добавляйте новую защиту. Сначала уточните владельца, границу или контракт результата. После этого можно отдельно планировать нагрузочный тест и проверку в среде с реальной телеметрией. Эта статья даёт механизм чтения каскада, а не обещание, что учебная схема переживёт отказ в production.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/069.json b/editorial/agent-rewrites/069.json new file mode 100644 index 0000000..76b29f3 --- /dev/null +++ b/editorial/agent-rewrites/069.json @@ -0,0 +1,7 @@ +{ + "index": 69, + "slug": "editorial-2026-02-practice-resilience", + "title": "Как остановить каскадный отказ: один бюджет для retry и fallback", + "excerpt": "Медленный dependency превращает несколько разумных защит в каскад лишней работы. Разбираем владельца retry, предел fan-out, безопасный fallback и проверяемую точку остановки.", + "contentHtml": "

Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос. Адаптер повторяет тот же вызов. Затем fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад.

\n

Симптом виден в трёх местах: исходящих вызовов на один пользовательский запрос становится больше, очередь не сокращается после добавления реплик, а trace показывает несколько владельцев retry. Цена ошибки выше задержки. Ослабленный dependency получает дополнительную работу в момент, когда уже не справляется с прежней. Каскад может перегрузить соседние компоненты и превратить частичный отказ в общий.

\n

Тезис простой: устойчивость начинается с ограничения работы. Для каждой логической операции нужно назначить одного владельца retry, задать конечный fan-out, назвать fallback и определить точку, после которой новые вызовы запрещены. Если эти границы нельзя восстановить из trace, систему нельзя считать готовой к проверке отказа.

\n

Механизм: считать логическую операцию

\n

Логическая операция — это один запрос пользователя или одно сообщение, которое система должна обработать. Внутри неё могут быть несколько сетевых вызовов. Поэтому успешные ответы не показывают полную стоимость. Считайте попытки, созданные после каждого временного исхода.

\n

Предположим, внешний вызов получает timeout. Край делает две попытки. Каждая попытка попадает в адаптер, который тоже разрешает два повтора. Затем необязательная часть запускает fallback. Число обращений растёт не как сумма настроек. Оно перемножается по слоям. Формула полезна как сигнал: общий fan-out равен произведению локальных ветвей, пока слой не остановит работу.

\n

Retry должен иметь одного владельца. Остальные слои передают ему исход и принимают конечный результат. Они не запускают собственные циклы. Выбор реплики также должен иметь числовой предел. На одну разрешённую попытку выбирается одна заранее названная реплика, а не «любая доступная» без ограничения.

\n
\"Схема
Учебная схема показывает порядок контроля: лимит срабатывает до расширения маршрута, поэтому fallback не становится вторым слоем повторов.
\n

Учебный пример с явной границей

\n

Ниже синтетический JavaScript-фрагмент. Он не обращается к сети, не запускает таймеры и не измеряет настоящий сервис. Имена реплик, исходы и размеры бюджета нужны только для объяснения переходов.

\n
const policy = {\n  retryOwner: 'edge',\n  maxAttempts: 2,\n  retryable: ['temporary-timeout'],\n  replicas: ['primary-a', 'primary-b'],\n  maxFanoutPerAttempt: 1,\n  fallback: {\n    name: 'named-summary',\n    trigger: 'optional-result-unavailable',\n    addsRetry: false,\n  },\n  exhausted: 'return-degraded-result',\n};\n\nfunction nextAction(outcome, retryBudget) {\n  if (retryBudget === 0) return policy.exhausted;\n  if (outcome === 'temporary-timeout') return 'retry-on-one-named-replica';\n  if (outcome === 'optional-result-unavailable') return policy.fallback.name;\n  return 'complete';\n}\n\n// Учебная проверка: это не production-код и не нагрузочный тест.\nconsole.log(nextAction('temporary-timeout', 0));\n// return-degraded-result
\n

Отрицательный путь здесь важнее положительного. При нулевом бюджете функция не выбирает новую реплику и не входит в fallback. Она возвращает конечное состояние. В реальной системе такой degraded result подходит не каждой операции. Для платежа, записи или команды с побочным эффектом он может скрыть неопределённость. Тогда контракт должен вернуть явную ошибку или запустить безопасную компенсацию. Учебный результат нельзя переносить туда без отдельной проверки.

\n

Положительный путь тоже ограничен. При temporary-timeout край может выполнить одну разрешённую повторную попытку на одной реплике. Fallback не получает право повторить основной вызов. Его задача — вернуть заранее названный урезанный результат, если необязательная часть недоступна. Если fallback сам ищет реплику, он становится новым маршрутом и получает собственный лимит.

\n

Симптом → причина → проверка → действие

\n
Карта проверки каскада
СимптомПричинаПроверкаДействие
Вызовов больше, чем логических запросовRetry включён на нескольких слояхПосчитать попытки на один request id и найти их владельцев в traceОставить одного владельца, вложенный retry отключить
При timeout запускаются несколько репликFan-out задан как «все здоровые»Проверить список имён и максимум запусков на попыткуЗадать число и одно имя на одну попытку
Fallback увеличивает нагрузкуЗапасной путь скрывает сетевой вызов или retryРазвернуть trace fallback до terminal resultСделать fallback режимом без повторов или выключить его
Лимит виден после новых вызововОграничение стоит после расширения маршрутаСравнить порядок: budget, маршрут, вызовПеренести limit до retry, fan-out и fallback
Сравниваются разные сценарииРазличаются нагрузка или failure injectionСверить logical requests, исход и baselineСначала выровнять сценарии, затем сравнивать изменения
\n

Что фиксировать в trace

\n

Trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции запишите request id, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и terminal action. Если модель использует условные units, не называйте их миллисекундами. Единица измерения должна быть ясна.

\n

Минимальная последовательность ограниченного сценария может выглядеть так: logical-01 → primary-a → temporary-timeout, затем edge retry budget: 2 → 1, затем logical-01 → primary-b → complete. Для необязательной части допустима ветка optional-result-unavailable → named-summary. В ней не должно появляться скрытого выбора третьей реплики.

\n

Проверяйте место лимита по порядку событий, а не по имени поля конфигурации. Если trace сначала показывает два новых вызова, а потом сообщает об исчерпанном бюджете, счётчик не остановил каскад. Он только зафиксировал уже сделанную работу.

\n

Порядок действий

\n
  1. Назовите логическую операцию и отделите её от исходящих вызовов.
  2. Перечислите все места, где отказ может создать дополнительную работу.
  3. Выберите одного владельца retry и задайте конечное число попыток.
  4. Опишите fan-out числом и именами реплик; запретите неограниченный поиск.
  5. Назовите fallback, его входной исход, стоимость и terminal result.
  6. Поставьте limit до расширения маршрута и укажите действие при нулевом бюджете.
  7. Проверьте положительный и отрицательный trace на одинаковой логической нагрузке.
  8. Отдельно решите, допустим ли degraded result для операции с побочным эффектом.
\n

Ограничения

\n

Схема не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, отмену, дедлайны, балансировку, идемпотентность, транзакции, распределённое состояние и стоимость запуска процесса. Она также не отвечает, какой ответ полезен пользователю после деградации.

\n

Временный timeout не означает, что повтор безопасен. Запрос мог дойти до сервера и завершить запись до того, как клиент получил ответ. Для операции с побочным эффектом сначала проверьте идемпотентность и контракт повторной доставки. Без такого контракта даже ограниченный retry может продублировать действие.

\n

Код 503 и заголовок Retry-After помогают передать перегрузку на HTTP-уровне, но сами не назначают владельца retry и не ограничивают fan-out. Автоматическое повторение включайте только для исходов, которые действительно могут стать успешными, и с учётом бюджета всей цепочки.

\n

Проверяемый критерий готовности

\n

Сценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбирается на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых logical requests и одинакового failure injection число дополнительных запусков не превышает заданный предел.

\n

Если хотя бы один ответ требует догадки, сценарий не готов. Сначала назовите скрытый переход и назначьте его владельца. После этого отдельный тест с реальной сетью, данными, правами и наблюдением должен подтвердить поведение уже production-подобного пути. Результат учебной модели такой тест не предсказывает.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/070.json b/editorial/agent-rewrites/070.json new file mode 100644 index 0000000..e58d882 --- /dev/null +++ b/editorial/agent-rewrites/070.json @@ -0,0 +1,7 @@ +{ + "index": 70, + "slug": "editorial-2026-01-field-platform-api", + "title": "Как проверить совместимость платформенного API до изменения контракта", + "excerpt": "Новое поле и номер версии не доказывают совместимость. Разбираем, как сопоставить контракт с конкретным потребителем, остановить несопоставимый путь и передать проверяемый результат.", + "contentHtml": "

После небольшого изменения API клиент начинает показывать пустой экран. Платформенная команда добавила поле в JSON, оставила старые поля и подняла версию с 1.2.0 до 1.3.0. Один ручной запрос вернул правильный ответ. Через час другой клиент получает новый статус, не находит запись и повторяет запрос.

\n

Цена ошибки выше, чем неудачный запрос. Клиент может сохранить неверное состояние, повторить команду или показать пользователю, что объект исчез. Команда платформы тратит время на спор о слове «совместимо», хотя не записала, что именно клиент обязан прочитать и какие ошибки должен различать.

\n

Тезис статьи короткий: совместимость принадлежит паре «контракт — конкретный потребитель». Номер версии помогает назвать поверхность изменения, но не заменяет проверку. Схема OpenAPI описывает форму HTTP API, а не закрытый парсер клиента, порядок обработки полей или смысл ошибки. Поэтому перед изменением нужно зафиксировать семью контракта, минимальные поля, разрешённые ошибки и отдельные исключения.

\n

Сначала отделите форму ответа от его смысла

\n

Возьмём учебный API чтения каталожной записи. Он принимает recordId и возвращает обязательные поля id и state. Поле label необязательно. Ошибка fixed-not-found означает только отсутствие записи. Она не означает ошибку сети, отказ в доступе или невалидный запрос.

\n
GET /records/42 HTTP/1.1\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n  \"id\": \"42\",\n  \"state\": \"active\",\n  \"label\": \"Учебная запись\"\n}
\n

Этот фрагмент показывает одну representation — передаваемую форму ресурса. Он не обещает порядок ключей, время ответа, сохранность записи или наличие поля в следующей версии. Даже наличие label в примере не делает его обязательным. Эти свойства нужно вынести в контракт явно. Иначе наблюдение быстро превращается в неофициальную гарантию.

\n

Потребитель должен быть описан так же точно. Например, fixed-tolerant-reader-v1 читает только id и state, принимает версию 1.3.0 и знает ошибку fixed-not-found. Другой клиент требует legacyMode. Для него тот же ответ неполон. Третий адаптер отправляет команды, а не читает записи. Его нельзя сравнивать с read API только из-за одинакового JSON.

\n

Минимальная карточка потребителя

\n

Не начинайте с перечня всех команд и репозиториев. Запишите минимальное решение, которое клиент принимает по ответу. В карточке нужны четыре поля: contractFamily, поддерживаемая версия, обязательные поля и известные ошибки. Если клиент зависит от порядка, повторов или специального заголовка, это тоже явное требование. Если требование неизвестно, статус должен остаться неизвестным.

\n
\"Цикл
Проверка сначала устанавливает сопоставимость, затем сравнивает поверхность и гарантии. Она не выпускает версию и не меняет API.
\n
Диагностика перед изменением контракта
СимптомПричинаПроверкаДействие
Клиент не видит новое полеПоле добавили, но клиент использует закрытую десериализациюСверить required fields и обработку unknown fieldsСохранить старый ответ или выпустить отдельный контракт
404 стал «нет записи» для всех ошибокКлиент смешал прикладную ошибку с сетевойВоспроизвести 404, 401, 403, 422 и timeout раздельноОставить fallback только для документированной ошибки
Похожий endpoint объявили несовместимымСравнили разные contract familyПроверить operation и family до сравнения полейВернуть stop-incomparable-consumer
После minor-версии изменился смысл статусаНовый символ или переход не вошёл в гарантиюСверить список значений и переходы состоянийОформить изменение как новый контракт или сохранить семантику
Ручной запрос успешен, релиз сломанПроверили один пример вместо named consumerЗапустить проверку на карточке конкретного клиентаНе передавать общий verdict без причин и next action
\n

Проверяйте family до полей

\n

Contract family — это вид операции и её смысловая граница. Read API, командный адаптер и webhook могут иметь поля id и state, но описывают разные действия. Сначала сравните fixed-catalog-read-v1 с тем, что объявил consumer. Если consumer относится к fixed-catalog-command-v1, проверка не должна доходить до полей.

\n

Такой результат не равен incompatibility. Команды пока не доказали, что объекты сопоставимы. Если назвать его просто «несовместимо», следующая команда начнёт ненужную миграцию. Точный статус сохраняет границу: нужен отдельный review для command family.

\n

Синтетический пример отрицательного пути

\n

Следующий код ограничен учебными объектами в памяти. Он не вызывает API, не читает production-трассы и не доказывает поведение реального клиента. Его задача — показать порядок решения: family проверяется раньше полей.

\n
const api = {\n  family: 'fixed-catalog-read-v1',\n  version: '1.3.0',\n  response: ['id', 'state'],\n  errors: ['fixed-not-found']\n};\n\nconst consumer = {\n  id: 'fixed-command-adapter-v1',\n  family: 'fixed-catalog-command-v1',\n  required: ['id', 'state']\n};\n\nfunction review(api, consumer) {\n  if (api.family !== consumer.family) {\n    return {\n      status: 'stop-incomparable-consumer',\n      nextAction: 'separate-contract-review-by-family'\n    };\n  }\n\n  const missing = consumer.required.filter(\n    (field) => !api.response.includes(field)\n  );\n  return missing.length\n    ? { status: 'incompatible-consumer', missing }\n    : { status: 'compatible-for-this-check' };\n}\n\nconsole.log(review(api, consumer));\n// { status: 'stop-incomparable-consumer',\n//   nextAction: 'separate-contract-review-by-family' }
\n

Положительный статус здесь тоже узкий. Он означает только, что зафиксированная пара прошла перечисленные проверки. Он не означает, что rollout безопасен, SLA выполнен или в системе нет неизвестных клиентов. В production-коде такой verdict должен сопровождаться именем consumer, версией и перечнем проверенных гарантий.

\n

Не путайте optional с совместимостью

\n

Слово optional обычно относится к конкретному валидатору или схеме. Оно не отвечает на вопрос, что сделает consumer, если поле отсутствует или появилось неожиданное поле. Один reader игнорирует расширение. Другой использует строгую модель. Третий считает отсутствие поля признаком старого режима.

\n

Предположим, что в ответ добавили legacyMode. Если tolerant reader его не использует, добавление может быть безопасным для этой пары. Но клиент, который требует поле, уже нельзя пометить compatible. Нельзя выводить обратное поведение из названия поля или из того, что ручной запрос всё ещё проходит.

\n

То же относится к значениям перечисления. Старый клиент может принимать active и archived, но падать на новом paused. В схеме поле осталось строкой, а смысл ответа изменился. Значит, проверка должна сравнивать не только наличие поля, но и допустимые значения и переходы, которые видит клиент.

\n

Отдельно фиксируйте ошибки и исключения

\n

Список ошибок — часть поведения consumer. Если fallback разрешён только для fixed-not-found, клиент не должен подставлять пустое состояние после 401, 403, 422 или таймаута. Иначе временный сбой превращается в потерю данных на экране, а повтор может отправить команду дважды.

\n

Иногда нужен специальный путь. Например, один внутренний инструмент получает расширенное представление. Это допустимо только как отдельный, названный и документированный контракт. Он должен описать получателя, версию, входные условия и то, чего не гарантирует. Секретный query-параметр вроде ?debug=1 не является исключением. Его обнаружит следующий consumer, но не обнаружит общий review.

\n

Если команда добавляет гарантию о стабильном порядке, времени ответа или доступности, её нельзя прятать рядом с полем. Это новая публичная обязанность. Её нужно назвать, проверить на соответствующем уровне и привязать к consumer. Один успешный trace не доказывает ни одну из этих гарантий.

\n

Что даёт номер версии

\n

SemVer полезен после того, как команда определила public API. Он помогает назвать совместимые добавления и несовместимые изменения. Но строка 1.3.0 сама не отвечает, является ли новый статус допустимым, игнорирует ли клиент неизвестные поля и относится ли consumer к той же семье.

\n

OpenAPI снижает догадки о форме HTTP-интерфейса: путях, параметрах, запросах, ответах и схемах. Это необходимый слой описания. Но документ не знает скрытую ветку клиентского кода. RFC 9110 также не превращает representation в гарантию прикладной совместимости. Поэтому стандарты дают словарь и границы, а итог принимает проверка конкретной пары.

\n

Порядок действий перед изменением

\n
  1. Назовите операцию. Запишите один endpoint, действие, contract family и версию. Не смешивайте чтение, команду и webhook.
  2. Назовите consumer. Укажите систему или модуль, владельца проверки и supported versions. Не используйте «все клиенты» как идентификатор.
  3. Опишите минимальное чтение. Перечислите обязательные поля, допустимые значения, ошибки и требования к неизвестным полям.
  4. Отсечьте другую family. При различии семей верните stop-incomparable-consumer и откройте отдельный review.
  5. Сопоставьте поверхность. Найдите отсутствующие поля, новые значения и изменившиеся ошибки. Каждый пробел оставьте причиной, а не спрячьте под номером версии.
  6. Проверьте исключения. Для отдельного пути потребуйте имя, версию, получателя и отрицательную границу гарантий.
  7. Сформируйте hand-off. Передайте status, reasons и next action. Положительный статус не запускает rollout и не заменяет тесты потребителя.
\n

Ограничения и критерий готовности

\n

Такой review не обнаруживает неизвестные интеграции сам по себе. Он не заменяет контрактные тесты, нагрузочные проверки, security review, миграцию данных, SLA или план отката. Учебный код выше работает на фиксированных литералах. Он не сообщает production-результат и не подтверждает, что реальный клиент действительно описал все свои зависимости.

\n

Проверку можно считать готовой только для явно ограниченной пары. В отчёте есть одна contract family, одна версия, один named consumer, список required fields, допустимые ошибки, заявленные гарантии и итоговый status. Для compatible-for-this-check нет неописанного claim и не осталось неизвестного обязательства. Для любого stop указаны причина и следующий шаг. Если хотя бы одного элемента нет, слово «совместимо» преждевременно.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/071.json b/editorial/agent-rewrites/071.json new file mode 100644 index 0000000..14fabce --- /dev/null +++ b/editorial/agent-rewrites/071.json @@ -0,0 +1 @@ +{"index":71,"slug":"editorial-2026-01-mechanism-platform-api","title":"Платформенный API без скрытых обещаний: как проверить контракт и потребителя","excerpt":"Платформенный API ломается не в момент изменения endpoint, а раньше: потребитель начинает зависеть от неописанного поля, порядка ответа или особого флага. Разбираем поверхность контракта, именованные исключения и проверку совместимости на учебном примере.","contentHtml":"

Потребитель вызывает платформенный API и получает ответ, который формально не нарушает документацию. Но код уже читает поле, которого нет в контракте, ждёт определённый порядок элементов или включает внутренний режим особым флагом. Через месяц владелец меняет реализацию. Запрос остаётся успешным, а потребитель начинает показывать пустой экран, неверный статус или устаревшие данные.

Симптомы обычно появляются не в одном месте. В логах нет ошибки схемы. В трассировке виден HTTP 200. Падение происходит позже: парсер не находит поле, сортировка меняет порядок, а fallback получает значение, для которого не определено поведение. Цена ошибки — не только один сломанный экран. Команда откладывает релиз, возвращает совместимость наугад и навсегда добавляет в платформу исключение, о котором знают только два человека.

Тезис статьи простой: платформенный API нужно проверять как набор объявленных ожиданий. У операции есть имя и версия. У запроса есть обязательные поля. У ответа есть обязательные и необязательные поля. У ошибок есть закрытый или явно расширяемый набор. Любой особый маршрут получает имя, границу и отдельное решение о совместимости. Если потребитель зависит от детали, которой нет в этом списке, система уже имеет скрытый контракт.

\n

Механизм: контракт ограничивает ожидания

\n

API — это не только URL и тип ответа. Контракт отвечает на четыре вопроса: что отправляет потребитель, что возвращает сервис, какие ошибки он различает и что именно сервис гарантирует. Реализация может быть сложнее. Потребитель должен зависеть только от объявленной части.

Для платформенного сервиса полезно разделить поверхность на пять блоков. Первый блок — идентичность: имя операции и версия контракта. Второй — запрос: обязательные поля и допустимые значения. Третий — ответ: обязательные поля, необязательные поля и правило расширения. Четвёртый — ошибки: коды, которые потребитель действительно умеет обработать. Пятый — гарантии: например, фиксированный набор состояний. Кэширование, задержка, сортировка и доступность не становятся гарантией только потому, что текущая реализация их даёт.

Эта граница защищает обе стороны. Владелец может менять внутренний код, если не меняет публичную поверхность. Потребитель не получает права читать новые поля «на всякий случай». Если новая потребность возникает, команда принимает её как изменение контракта, а не как случайный доступ к внутреннему представлению.

\n
\"Поверхность
Схема показывает поверхность контракта. Потребитель может опираться только на названные поля и гарантии; внутренние детали остаются за границей.
\n

Учебный пример: фиксированный ответ и строгий потребитель

\n

Ниже — синтетический TypeScript-пример. Он хранит данные в памяти, не обращается к сети и не показывает результат работы реального сервиса. Имена, версия и значения нужны, чтобы проверить логику границ.

type CatalogResponse = {\n  id: string;\n  state: 'ready' | 'blocked';\n  label?: string;\n};\n\ntype Contract = {\n  family: 'fixed-catalog-read-v1';\n  version: '1.3.0';\n  operation: 'readFixedRecord';\n  errors: ['fixed-not-found'];\n  response: CatalogResponse;\n};\n\nconst contract: Contract = {\n  family: 'fixed-catalog-read-v1',\n  version: '1.3.0',\n  operation: 'readFixedRecord',\n  errors: ['fixed-not-found'],\n  response: { id: 'r-17', state: 'ready', label: 'Demo' },\n};\n\nfunction consume(value: CatalogResponse) {\n  if (value.state === 'ready') return value.label ?? value.id;\n  return 'blocked';\n}\n\n// Учебная проверка: это не сетевой вызов и не измерение сервиса.\nconsole.log(consume(contract.response)); // Demo

Потребитель использует только id, state и явно допустимое поле label. Он не читает внутреннюю сортировку, не предполагает наличие дополнительных ключей и не превращает неизвестную ошибку в успешный ответ. Обязательное поле state задаёт конечный набор значений. Если сервис отправит новое состояние, потребитель должен получить явный сигнал несовместимости или заранее иметь правило расширения.

Теперь рассмотрим особый случай. Потребителю нужна сырая форма записи. Это не повод открыть внутренний объект без условий. У исключения должны быть собственное имя, версия и отрицательная граница: например, «возвращает одну фиксированную форму; не обещает сортировку, фильтрацию, будущие поля, задержку или сохранность». Граница важнее самого доступа. Она не даёт временной лазейке стать вторым API.

type EscapeHatch = {\n  name: 'raw-envelope-v1';\n  returns: 'one-fixed-representation';\n  guarantees: ['id', 'state'];\n  doesNotGuarantee: [\n    'ordering',\n    'filtering',\n    'future-fields',\n    'availability',\n  ];\n};\n\nconst escapeHatch: EscapeHatch = {\n  name: 'raw-envelope-v1',\n  returns: 'one-fixed-representation',\n  guarantees: ['id', 'state'],\n  doesNotGuarantee: [\n    'ordering',\n    'filtering',\n    'future-fields',\n    'availability',\n  ],\n};

Если исключение нельзя назвать или его граница звучит как «пока работает», его нельзя считать контрактом. Отрицательный путь здесь обязательный. Сервис либо возвращает объявленную форму, либо останавливает вызов с известной ошибкой. Он не подменяет отсутствующее поле, не угадывает режим по тексту ошибки и не молча переключается на внутренний endpoint.

\n

Совместимость: сравнивать нужно пару

\n

Совместимость не принадлежит одному API. Она возникает между конкретным контрактом и конкретным потребителем. Одинаковое имя операции ничего не доказывает. Сначала нужно убедиться, что обе стороны относятся к одной семейству контракта. Затем сравнить версии, обязательные поля и ошибки.

Потребитель совместим с учебным контрактом, если он поддерживает версию 1.3.0, требует только id и state, а также обрабатывает единственную объявленную ошибку fixed-not-found. Потребитель, который требует legacyMode, несовместим: поле отсутствует в ответе. Потребитель с семейством fixed-catalog-command-v1 нельзя назвать несовместимым или совместимым с read-контрактом. Это другой тип операции. Его нужно разбирать отдельно.

Неизвестный потребитель тоже не равен совместимому. Если команда не знает его версию, обязательные поля и обработку ошибок, у неё нет данных для вывода. Правильное действие — запросить минимальную карточку потребителя, а не принять его по умолчанию и не расширить API наугад.

\n

Симптом → причина → проверка → действие

Диагностика скрытого контракта
СимптомПричинаПроверкаДействие
Клиент падает после успешного ответаОн читает неописанное поле или значениеСопоставьте чтение полей с опубликованной схемойУдалите зависимость или добавьте поле в отдельную версию контракта
Ответ считается неверным при перестановке элементовПотребитель зависит от неоговорённого порядкаСравните контракт и код сортировкиОбъявите порядок или запретите на него опираться
Особый флаг стал обязательнымВременный обход не получил имени и границыНайдите флаг, его потребителей и обещанияОформите versioned escape hatch либо удалите обход
Ошибка превращается в пустой успешный ответКлиент принимает неизвестные ошибки за известныеСверьте список кодов и ветки обработкиОстановите неизвестный исход и добавьте явную миграцию
Команды спорят о совместимостиСравнивают похожие имена, а не contract familyПроверьте family, version, required fields и errorsРазделите несопоставимые операции и повторите сравнение
\n

Что проверять в изменении

\n

Добавление необязательного поля обычно безопаснее удаления обязательного, но безопасность зависит от поведения потребителя. Если сериализатор меняет форму, проверьте, принимает ли клиент неизвестные ключи. Если меняется перечисление, проверьте, что происходит с новым значением. Если меняется ошибка, проверьте отрицательный путь: клиент не должен сообщать «не найдено», когда сервис вернул «доступ запрещён».

Версия сама по себе не лечит несовместимость. Она только даёт адрес, по которому можно найти правила. Владелец должен связать версию с конкретной схемой, списком ошибок и известными потребителями. Потребитель должен хранить поддерживаемые версии и обязательные поля. Без этой пары строка 1.3.0 остаётся декоративной меткой.

OpenAPI помогает описать HTTP-поверхность так, чтобы её могли читать люди и инструменты. Но документ не знает скрытых зависимостей конкретного клиента. Семантическое версионирование требует объявить публичный API, однако не обнаруживает потребителей автоматически. Поэтому описание, инвентарь потребителей и проверка отрицательных ветвей дополняют друг друга.

\n

Порядок действий

  1. Назовите одну операцию, её contract family и версию.
  2. Выпишите обязательные и необязательные поля запроса и ответа.
  3. Назовите известные ошибки и поведение клиента для каждой.
  4. Отделите гарантии от текущих свойств реализации: порядок, задержку, кэш и доступность.
  5. Соберите карточку каждого потребителя: family, поддерживаемые версии, обязательные поля и ошибки.
  6. Сначала отсеките другую family; не вычисляйте совместимость по похожему имени.
  7. Проверьте удаление поля, новое значение перечисления, неизвестную ошибку и перестановку ответа.
  8. Для особого случая задайте имя, версию и отрицательную границу либо удалите его.
  9. Зафиксируйте результат для конкретной пары API и потребителя.
\n

Ограничения

\n

Эта схема не доказывает доступность, задержку, безопасность или пропускную способность сервиса. Учебный код не заменяет контрактные тесты, интеграционный запуск и проверку прав. Он показывает форму решения и место, где нужно задать вопрос. Нельзя объявлять реальный API совместимым по одному описанию или одному успешному запросу.

HTTP-статус тоже не описывает всю прикладную семантику. Два ответа с кодом 200 могут содержать разные состояния, а одинаковый 404 может означать разные причины для разных операций. Потребитель должен видеть объявленные поля и ошибки, а не угадывать смысл по случайному тексту.

Жёсткий контракт имеет цену. Если команда запрещает любые дополнительные поля и особые режимы, потребители начнут копировать данные или обращаться к хранилищу напрямую. Поэтому escape hatch допустим, когда его стоимость и граница видны. Он не должен скрывать внутренние поля, не должен обещать будущее и не должен обходить проверку совместимости.

\n

Проверяемый критерий готовности

Изменение готово к следующему этапу, если другой инженер без чтения реализации может ответить на пять вопросов: какая операция меняется; какую версию поддерживает потребитель; какие поля он требует; какие ошибки он обрабатывает; где проходит граница особого случая. Для удаления поля, нового значения и неизвестной ошибки есть отдельный отрицательный сценарий. В нём система останавливается явно, а не возвращает правдоподобный, но неверный результат.

Если хотя бы на один вопрос приходится отвечать догадкой, контракт не готов. Сначала назовите скрытое ожидание и решите, должно ли оно стать публичной гарантией. Затем добавьте его в версию, оформите миграцию или удалите зависимость. Только после этого сравнивайте потребителей. Учебный пример не сообщает, что ваш сервис уже совместим; он задаёт проверяемую форму доказательства.

\n

Проверяемые источники

\"\n}"} diff --git a/editorial/agent-rewrites/072.json b/editorial/agent-rewrites/072.json new file mode 100644 index 0000000..2623433 --- /dev/null +++ b/editorial/agent-rewrites/072.json @@ -0,0 +1,7 @@ +{ + "index": 72, + "slug": "editorial-2026-01-practice-platform-api", + "title": "Платформенный API без скрытых обещаний: как проверить контракт до hand-off", + "excerpt": "Потребитель просит особый флаг или поле, а команда рискует превратить случайное поведение в обязательство. Разбираем контрактную поверхность, узкий escape hatch и проверяемый stop для несовместимых случаев.", + "contentHtml": "

Проблема часто начинается с безобидной просьбы: потребителю нужен ещё один флаг, сырой фрагмент ответа или особый порядок элементов. Платформенная команда добавляет параметр и закрывает задачу. Через месяц другой клиент начинает зависеть от этого поведения. Затем команда меняет внутренний формат, а клиент ломается на поле, которое никто не называл публичным.

\n

Цена ошибки — не только откат релиза. Клиент может принять неверное решение, сохранить неправильное состояние или повторить операцию. Владельцы API тратят время на спор: это баг, новая гарантия или локальный обход? Номер версии и проходящий schema-check не отвечают на этот вопрос.

\n

Тезис простой: платформенный API нужно проверять как договор между конкретным контрактом и конкретным потребителем. Сначала назовите операцию, вход, ответ, ошибки и гарантии. Потом сравните их с решением потребителя. Если требование выходит за поверхность, оформите узкое documented escape hatch или остановите hand-off с причиной.

\n

Что считается контрактом

\n

Контракт описывает не все детали реализации. Он описывает то, на чём потребитель вправе строить решение. Для API чтения это обычно операция, версия, обязательные поля запроса, обязательные поля ответа, допустимые ошибки и смысл значений. Дополнительное поле не становится гарантией только потому, что его видно в JSON.

\n

Отдельно фиксируйте поведение при расширении. Один reader игнорирует неизвестные поля. Другой закрыто десериализует объект. Третий строит хэш полного ответа. Для них одно и то же добавление имеет разный риск. Проверять нужно reader, а не только схему.

\n

То же относится к порядку. Если клиент берёт первый элемент массива, порядок стал частью его фактического ожидания. Но это ещё не значит, что API его обещал. Пока команда не записала такую гарантию и не проверила её, результатом должен быть stop, а не новая версия с более уверенным названием.

\n
\"Контрактная
Потребитель должен проходить через названную поверхность API. Скрытый параметр и неописанная гарантия не расширяют договор автоматически.
\n

Сначала решение потребителя, потом форма ответа

\n

Потребитель редко просит поле ради самого поля. Он хочет выбрать ветку: показать статус, повторить запрос, отобразить объяснение или передать запись дальше. Запишите это решение первым. Затем спросите, какой минимальный факт ему нужен.

\n

Например, fixed reader читает только id и state. Ему не нужен весь внутренний объект. Другой reader требует legacyMode. Это не повод молча добавить поле в общий договор. Сначала проверьте семью контракта, версию, обязательность поля и условие миграции.

\n

Такой порядок сдерживает две крайности. Команда не превращает ответ в бесконечный объект «на будущее». И она не отказывает потребителю общей фразой «так нельзя». Для каждого требования появляется конкретный путь: публичное расширение, отдельное исключение или stop с недостающим фактом.

\n

Учебный пример проверки

\n

Ниже — учебная модель. Она не отправляет HTTP-запросы, не читает production-трафик и не доказывает совместимость настоящего сервиса. Код показывает только порядок проверки и названия отрицательных результатов.

\n
type Contract = {\n  family: 'catalog-read';\n  version: '1.3.0';\n  requiredResponse: Array<'id' | 'state'>;\n  errors: Array<'not_found' | 'invalid_request'>;\n  guarantees: Array<'order_is_irrelevant'>;\n};\n\ntype Consumer = {\n  family: string;\n  requiredFields: string[];\n  needsStableOrder: boolean;\n};\n\nfunction review(contract: Contract, consumer: Consumer) {\n  if (consumer.family !== contract.family) {\n    return 'stop-incomparable-family';\n  }\n\n  const missing = consumer.requiredFields.filter(\n    (field) => !contract.requiredResponse.includes(field as 'id' | 'state'),\n  );\n  if (missing.length > 0) return 'stop-missing-contract-field';\n  if (consumer.needsStableOrder && !contract.guarantees.includes('order_is_stable')) {\n    return 'stop-undeclared-guarantee';\n  }\n  return 'bounded-review-hand-off';\n}
\n

Положительный результат здесь узкий. Он означает, что учебный consumer относится к той же семье, его обязательные поля описаны, а требуемые гарантии не выходят за контракт. Он не означает, что реальный клиент работает, что API выдержит нагрузку или что выпуск безопасен.

\n

Отрицательный путь важнее. Для command API возвращается stop-incomparable-family. Для требования legacyMode — stop-missing-contract-field. Для непроверенного порядка — stop-undeclared-guarantee. Сохраняйте причину. Слово «совместимо» без причины не помогает следующему инженеру продолжить проверку.

\n

Симптом → причина → проверка → действие

\n
Типовые утечки платформенного API
СимптомПричинаПроверкаДействие
Клиент использует поле, которого нет в документацииНаблюдение приняли за гарантиюСравнить reader с declared response fieldsОстановить hand-off или объявить поле отдельным изменением
Добавление поля ломает старый readerКлиент строго разбирает объект или хэширует ответПроверить декодер на unknown fields, отсутствие и nullСохранить совместимую форму либо подготовить migration
Потребитель зависит от первого элементаПорядок не назван, но стал скрытой гарантиейНайти сортировку и проверку порядка в коде consumerДобавить явную гарантию и тест или убрать зависимость
Особый query-флаг нужен одному клиентуEscape hatch не имеет владельца и границыПроверить имя, версию, вход, ответ и negative boundaryОформить узкий hatch или удалить скрытый обход
Read API сравнивают с command APIНе названа contract familyСопоставить операцию, вход и побочный эффектВернуть incomparable и завести отдельный review
\n

Escape hatch — отдельный договор

\n

Escape hatch полезен, когда общий контракт честно не покрывает ограниченную задачу. Он должен иметь имя, версию, разрешённый вход, форму результата и отрицательную границу. Например, raw-envelope-v1 может дать одному названному consumer одну дополнительную representation. Это не обещает порядок, задержку, хранение, доступность или сохранение будущих полей.

\n

Скрытый debug-wire устроен иначе. У него нет понятного получателя и предела. Один клиент прочитает внутреннее поле, второй скопирует его в свою схему, третий начнёт рассчитывать на случайный порядок. Название debug не ограничивает зависимость. Ограничивает её только записанный договор и проверяемая граница.

\n

Не расширяйте общий response «на всякий случай». Если потребителю нужен raw envelope, это отдельная поверхность с отдельным риском. Если потребитель не может назвать решение, которое он принимает с помощью особого поля, сначала уточните задачу. Без этого команда не знает, что именно должна поддерживать.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите вход, ответ, ветку consumer и цену неверного решения до изменения кода.
  2. Назовите участников. Укажите владельца API, конкретный consumer, contract family и версию. Слово «клиенты» недостаточно.
  3. Опишите поверхность. Выпишите required fields, допустимые значения, ошибки, порядок и гарантии. Отделите наблюдение от обещания.
  4. Найдите фактическую зависимость. Проверьте decoder, fallback, сравнение полного объекта, чтение первого элемента и особые параметры.
  5. Сравните изменение. Проверьте удаление, переименование, новый enum, неизвестные поля, отсутствие и null. Для разных family остановите сравнение.
  6. Выберите форму. Расширьте public contract с правилами совместимости, оформите versioned escape hatch или верните stop с причиной.
  7. Передайте ограниченный результат. Приложите diff, профиль consumer, тест положительного пути и тест каждого ожидаемого stop. Не выдавайте учебную проверку за rollout.
\n

Ограничения

\n

Метод не обнаруживает потребителя, которого нет в inventory. Если API доступен за пределами известной команды, список зависимостей может быть неполным. В таком случае неописанное поведение безопаснее считать риском до отдельной проверки.

\n

Метод не заменяет нагрузочное тестирование, проверку доступа, анализ данных, SLA и план отката. OpenAPI описывает форму интерфейса, но не подтверждает, что реализация ей соответствует. SemVer помогает назвать изменение после определения public API, но номер версии сам не создаёт гарантию. HTTP-стандарт задаёт общие семантики, но не решает прикладной вопрос совместимости конкретного reader.

\n

Положительный учебный пример также не даёт production-результата. Его граница — порядок мышления: назвать контракт, назвать потребителя, проверить нужный факт и остановиться там, где факта нет.

\n

Критерий готовности

\n

Проверка готова, когда другой инженер без устного пояснения находит в одном месте contract family, версию, required fields, допустимые ошибки, явные гарантии, профиль каждого проверенного consumer, diff изменения и автоматические проверки положительного и отрицательного путей.

\n

Тест должен падать, если удалили обязательное поле, добавили неподдерживаемое значение, нарушили объявленный порядок или вернули скрытый hatch без имени и границы. Если хотя бы одного элемента нет, результат — не «совместимо», а конкретный stop и имя следующего доказательства.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/073.json b/editorial/agent-rewrites/073.json new file mode 100644 index 0000000..1d69bad --- /dev/null +++ b/editorial/agent-rewrites/073.json @@ -0,0 +1,7 @@ +{ + "index": 73, + "slug": "editorial-2025-12-field-year-synthesis", + "title": "Как проверить годовой инженерный вывод: контрфакты, цена и граница сравнения", + "excerpt": "Годовой вывод часто превращает событие после изменения в доказательство его пользы. Разбираем, как отделить решение от наблюдения, назвать альтернативу и остановить вывод там, где данных недостаточно.", + "contentHtml": "

В годовом отчёте появляется знакомая связка: команда выбрала решение, после него метрика изменилась, значит решение сработало. Через несколько месяцев такой вывод начинают повторять как рецепт. Симптом ошибки прост: в записи есть выбранный путь и хороший результат, но нет отвергнутых вариантов, цены выбора и границы сравнения. Цена ошибки — неверный выбор в следующем проекте. Команда переносит не проверенный механизм, а удачную последовательность событий.

\n

Тезис статьи: годовой инженерный вывод готов только тогда, когда он различает decision, observation и causal claim. Для этого нужно назвать доступную альтернативу, зафиксировать cost, сформулировать unknown и указать, какие состояния действительно сравнивались. Если хотя бы одного элемента нет, результатом должен быть запрос на исправление, а не уверенный итог.

\n

Почему порядок событий не доказывает причину

\n

Пусть команда изменила лимит очереди в понедельник, а во вторник снизилась задержка. Запись подтверждает порядок событий. Она не показывает, что произошло бы без изменения. В этот же период могли измениться объём трафика, состав запросов, кэш, версия зависимости или нагрузка на соседний сервис.

\n

Наблюдение отвечает на вопрос «что увидели». Причинное утверждение отвечает на другой вопрос: «что именно вызвало это изменение». Между ними нужен способ сравнения. Им может быть контрольное окно, сопоставимая группа, повторяемый эксперимент или другая методика, которую команда заранее описала. Если такого способа нет, нужно сохранить unknown, а не заменить его глаголом «улучшило».

\n

Контрфактический вопрос не требует придумывать альтернативную историю. Он проверяет границу утверждения: какой фактор мог дать тот же результат, какое состояние служит сравнением и что нельзя узнать из текущей записи. Такой вопрос делает текст менее эффектным, но более переносимым.

\n

Шесть полей для одной точки года

\n

Decision описывает выбор в конкретный момент. В нём есть действие и контекст: например, «оставили один владелец повторных запросов для внешнего вызова». Фраза «сделали систему надёжнее» уже описывает предполагаемый результат и не подходит.

\n

Alternatives перечисляет варианты, доступные тогда же. Нельзя добавлять идеальный вариант, появившийся только после инцидента. Если команда выбирала между локальным повтором и повтором на границе клиента, нужно назвать именно эти варианты и требования, по которым их сравнивали.

\n

Cost показывает, чем заплатили за выбор. Это может быть дополнительная задержка, сложность отката, неполное покрытие, расход памяти или ручная операция. Без cost решение выглядит бесплатным и неизбежным.

\n

Observation фиксирует наблюдаемый факт: число попыток в учебном сценарии, значение поля, порядок событий или статус проверки. Не называйте его эффектом. Слово «снизило» уже содержит причинный вывод.

\n

Unknown называет первый вопрос, на который запись не отвечает. Например: «неизвестно, как повёл бы себя тот же поток без изменения лимита». Unknown не является дефектом текста. Это честная граница знания.

\n

Comparison boundary уточняет набор сравнения: два фиксированных состояния, окно времени, тип нагрузки и исключённые факторы. Без границы нельзя понять, насколько широк вывод.

\n
Цикл проверки годового инженерного вывода: решение, альтернатива, стоимость, наблюдение, неизвестное и граница сравнения.
Цикл возвращает запись к пропущенному полю. Если граница сравнения не определена, проверка останавливается.
\n

Симптом → причина → проверка → действие

\n
Диагностика слабого годового вывода
СимптомПричинаПроверкаДействие
После изменения появилась хорошая метрикаПорядок событий приняли за причинностьНайти контрольное состояние или явно записать его отсутствиеЗаменить «изменение улучшило» на observation и добавить unknown
Выбранный путь выглядит единственнымАльтернативы вырезали при сокращении отчётаВосстановить варианты, доступные в момент решенияДобавить alternatives и критерии выбора
Решение описано только как успехCost остался в рабочей перепискеПроверить задержку, сложность, покрытие и откатНазвать принятый расход рядом с decision
Разные команды спорят о результатеОни сравнивают разные окна или нагрузкиСопоставить период, входы, версии и исключенияСузить comparison boundary до проверяемого набора
Reviewer пишет «не хватает контекста»Не назван конкретный разрывПроверить шесть полей по одномуВернуть один repair request с ожидаемым дополнением
\n

Учебная карточка и код проверки

\n

Ниже учебный пример. Он работает только с фиксированным объектом в памяти. В нём нет настоящих логов, метрик, тикетов, запросов или производственных данных. Код показывает порядок проверки записи, но не доказывает эффект решения.

\n
type ReviewCard = {\n  decision: string;\n  alternatives: string[];\n  cost: string;\n  observation: string;\n  unknown: string;\n  comparisonBoundary: string;\n};\n\nfunction inspect(card: ReviewCard) {\n  const gaps = Object.entries(card)\n    .filter(([, value]) => value.length === 0)\n    .map(([field]) => field);\n\n  if (gaps.length > 0) {\n    return { status: 'stop-and-repair', gaps };\n  }\n\n  return {\n    status: 'synthetic-review-handoff',\n    note: 'Учебная запись не подтверждает production-эффект',\n  };\n}\n\nconst card = {\n  decision: 'Оставили один владелец retry',\n  alternatives: ['retry на клиенте', 'retry на адаптере'],\n  cost: 'Дополнительная задержка перед окончательной ошибкой',\n  observation: 'В учебном прогоне выполнено не более двух попыток',\n  unknown: 'Неизвестно поведение при другой нагрузке',\n  comparisonBoundary: 'Фиксированный сценарий и два заданных входа',\n};\n\nconsole.log(inspect(card).status);\n// synthetic-review-handoff
\n

Положительный статус означает только полноту учебной карточки. Он не означает, что один владелец retry уменьшил задержку или количество ошибок в настоящей системе. Для production понадобятся реальные входы, наблюдаемая телеметрия, план сравнения и владелец проверки.

\n

Порядок полевого прохода

\n
  1. Выберите одну запись, которая связывает решение с последующим результатом. Не пытайтесь разбирать весь год одной таблицей.
  2. Перепишите decision как действие в конкретный момент. Уберите слова «улучшили», «оптимизировали» и другие слова результата.
  3. Восстановите alternatives. Оставьте только варианты, которые реально были доступны при выборе.
  4. Назовите cost. Запишите дополнительную работу, риск, задержку, неполное покрытие или сложность отката.
  5. Отделите observation от интерпретации. Добавьте окно, входы, версию и способ измерения, если они известны.
  6. Сформулируйте один unknown. Если вопросов десять, выберите первый разрыв, который мешает проверить причинность.
  7. Определите comparison boundary. Укажите, какие два состояния или периода сопоставлены и что исключено.
  8. Выберите маршрут. При пропущенном поле остановите запись и верните точный repair request. При заполненных полях передайте только synthetic hand-off на человеческое чтение.
\n

Отрицательный путь важнее красивого итога

\n

Слабая проверка проходит только по заполненной карточке. Надёжная проверка должна остановиться на пустом поле и не превращать запуск без исключения в успех. Например, если unknown отсутствует, функция не должна подставлять «нет неизвестных». Это не знание, а потеря границы.

\n

Есть и другой отрицательный путь: reviewer не согласен с выбранной альтернативой, хотя все поля заполнены. Это не обязательно ошибка фактов. Сначала нужно проверить traceability записи. Затем можно отдельно обсуждать trade-off. Нельзя маскировать стратегическое несогласие под «неполный контекст» и нельзя исправлять пропуск данных спором о предпочтениях.

\n

Если comparison boundary невозможно сформулировать, остановите итоговый вывод. Не расширяйте его словами «в целом», «обычно» или «для системы». Широкая формулировка не заменяет отсутствующее сравнение.

\n

Ограничения метода

\n

Такая карточка не восстанавливает прошлое идеально. Участники могут не помнить все варианты. Метрики могли собираться с другой семантикой. Внешние изменения могли совпасть по времени. Контрфактический вопрос выявляет эти ограничения, но сам по себе не создаёт контрольную группу.

\n

Полный проход не нужен для каждого мелкого изменения. Он оправдан там, где запись предлагает повторить решение, объясняет заметное изменение или становится основанием для технического стандарта. Для локальной заметки может хватить decision и границы. Чем дороже ошибочный перенос рецепта, тем полнее должна быть карточка.

\n

Учебный код также ограничен. Он не читает реальные источники, не проверяет качество метрик, не запускает эксперимент и не создаёт production-решение. Все значения в примере заданы вручную. Их нельзя выдавать за результат измерения.

\n

Проверяемый критерий готовности

\n

Запись готова к передаче на человеческое чтение, если она содержит конкретное decision, доступные alternatives, явный cost, наблюдаемый observation, один unknown и точную comparison boundary. Для каждого поля можно указать источник или честно отметить, что это фиксированный учебный литерал. Отсутствующее поле возвращает статус stop-and-repair. Ни один абзац не называет причиной то, что запись лишь наблюдала после изменения.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/074.json b/editorial/agent-rewrites/074.json new file mode 100644 index 0000000..0c2054f --- /dev/null +++ b/editorial/agent-rewrites/074.json @@ -0,0 +1,7 @@ +{ + "index": 74, + "slug": "editorial-2025-12-mechanism-year-synthesis", + "title": "Годовой инженерный вывод: как отличить наблюдение от эффекта", + "excerpt": "После изменения метрика часто меняется, но порядок событий ещё не доказывает причинность. Разбираем шесть полей записи, проверку с остановкой и границу, за которую нельзя расширять вывод.", + "contentHtml": "

В годовом отчёте появляется знакомая связка: команда выбрала решение, после него метрика изменилась, значит решение сработало. Через несколько месяцев такой вывод начинают повторять как рецепт. Симптом ошибки прост: в записи есть выбранный путь и удобный результат, но нет отвергнутых вариантов, цены выбора и границы сравнения. Цена ошибки — неверное решение в следующем проекте. Команда переносит не механизм, а совпадение событий.

\n

Тезис статьи: инженерный вывод готов только тогда, когда он различает решение, наблюдение и причинное утверждение. Для этого нужно назвать доступную альтернативу, зафиксировать стоимость, сформулировать неизвестное и указать, какие состояния действительно сравнивались. Если хотя бы одного элемента нет, вывод нужно сузить или остановить.

\n

Почему порядок событий не доказывает причину

\n

Представим изменение лимита очереди в понедельник. Во вторник задержка снизилась. Запись подтверждает порядок событий. Она не показывает, что произошло бы без изменения. За это же время могли измениться объём трафика, состав запросов, кэш, версия зависимости или нагрузка на соседний сервис.

\n

Наблюдение отвечает на вопрос «что увидели». Причинное утверждение отвечает на другой вопрос: «что вызвало изменение». Между ними нужен способ сравнения. Это может быть контрольное окно, сопоставимая группа, повторяемый эксперимент или заранее описанная методика. Если способа нет, нужно сохранить неизвестное, а не заменить его глаголом «улучшило».

\n

Контрфактический вопрос не требует сочинять альтернативную историю. Он проверяет границу фразы: какой фактор мог дать тот же результат, какое состояние служит сравнением и чего текущая запись не знает. Такой вопрос уменьшает уверенность текста, но увеличивает его пользу при переносе.

\n

Шесть полей одной записи

\n

Decision описывает действие в конкретный момент. Например: «оставили одного владельца повторных запросов для внешнего вызова». Фраза «сделали систему надёжнее» уже содержит вывод и не подходит.

\n

Alternatives перечисляет варианты, доступные тогда же. Не добавляйте идеальный путь, который появился после инцидента. Если команда выбирала между повтором на клиенте и повтором на адаптере, запишите оба варианта и критерии выбора.

\n

Cost показывает, чем заплатили за решение. Это дополнительная задержка, сложность отката, неполное покрытие, расход памяти или ручная операция. Без стоимости выбранный путь выглядит бесплатным и неизбежным.

\n

Observation фиксирует факт: порядок событий, значение поля, число попыток или статус проверки. Не называйте его эффектом. Слово «снизило» уже делает причинный шаг.

\n

Unknown называет первый вопрос, на который запись не отвечает. Например: «неизвестно, как повёл бы себя тот же поток без изменения лимита». Это не слабость отчёта. Это честная граница знания.

\n

Comparison boundary уточняет набор сравнения: два состояния, окно времени, тип нагрузки и исключённые факторы. Без этой границы читатель не понимает, насколько широк вывод.

\n
Цикл проверки годового инженерного вывода: решение, альтернатива, стоимость, наблюдение, неизвестное и граница сравнения.
Цикл возвращает запись к пропущенному полю. Если граница сравнения не определена, проверка останавливается.
\n

Симптом → причина → проверка → действие

\n
Диагностика слабого годового вывода
СимптомПричинаПроверкаДействие
После изменения появилась хорошая метрикаПорядок событий приняли за причинностьНайти контрольное состояние или записать его отсутствиеОставить observation и добавить unknown
Выбранный путь выглядит единственнымАльтернативы вырезали при сокращении отчётаВосстановить варианты, доступные при выбореДобавить alternatives и критерии выбора
Решение описано только как успехCost остался в перепискеПроверить задержку, сложность, покрытие и откатНазвать принятый расход рядом с решением
Команды спорят о результатеОни сравнивают разные окна или нагрузкиСопоставить период, входы, версии и исключенияСузить comparison boundary
Проверяющий пишет «не хватает контекста»Не назван конкретный разрывПроверить шесть полей по одномуВернуть точный запрос на дополнение
\n

Учебный пример: проверка с остановкой

\n

Ниже учебный пример. Он работает только с объектом в памяти. В нём нет настоящих логов, метрик, запросов или данных эксплуатации. Код проверяет полноту записи. Он не доказывает, что решение изменило систему.

\n
type ReviewCard = { decision: string; alternatives: string[]; cost: string; observation: string; unknown: string; comparisonBoundary: string };\n\nfunction inspect(card: ReviewCard) {\n  const gaps = Object.entries(card).filter(([, value]) => Array.isArray(value) ? value.length === 0 : value.trim() === '').map(([field]) => field);\n  if (gaps.length > 0) return { status: 'stop-and-repair', gaps };\n  return { status: 'review-ready', note: 'Учебная запись не подтверждает причинный эффект' };\n}\n\nconst card = { decision: 'Оставили одного владельца retry', alternatives: ['retry на клиенте', 'retry на адаптере'], cost: 'Дополнительная задержка перед окончательной ошибкой', observation: 'В учебном прогоне выполнено не более двух попыток', unknown: 'Неизвестно поведение при другой нагрузке', comparisonBoundary: 'Фиксированный сценарий и два заданных входа' };\nconsole.log(inspect(card).status); // review-ready
\n

Положительный статус означает только то, что поля заполнены. Он не означает, что один владелец retry уменьшил задержку или число ошибок в настоящей системе. Для такого вывода понадобятся реальные входы, телеметрия, план сравнения и владелец проверки.

\n

Теперь уберём unknown:

\n
const incomplete = { ...card, unknown: '' };\nconsole.log(inspect(incomplete));\n// { status: 'stop-and-repair', gaps: ['unknown'] }
\n

Проверка не подставляет «неизвестных нет». Пустое поле возвращает остановку. Это отрицательный путь, который защищает текст от уверенного вывода без основания.

\n

Как читать запись в работе

\n
  1. Выберите одну фразу, где изменение связано с последующим результатом. Не начинайте с полного календаря.
  2. Перепишите decision как действие в конкретный момент. Уберите «улучшили» и другие слова результата.
  3. Восстановите alternatives. Оставьте только варианты, которые реально были доступны при выборе.
  4. Назовите cost. Запишите задержку, ручную работу, риск, неполное покрытие или сложность отката.
  5. Отделите observation от интерпретации. Добавьте окно, входы, версию и способ измерения, если они известны.
  6. Сформулируйте один unknown. Выберите первый разрыв, который мешает проверить причинность.
  7. Определите comparison boundary. Укажите сопоставляемые состояния и исключения.
  8. Выберите исход. При пропущенном поле остановите запись. При заполненных полях передайте узкий факт на человеческое чтение.
\n

Три отрицательных пути

\n

Первый путь возникает при пустом поле. Если неизвестно, что было бы без изменения, нельзя писать «решение уменьшило задержку». Верная формулировка уже: «после решения в указанном окне наблюдалась меньшая задержка; контрфакт не проверен».

\n

Второй путь возникает при споре о вариантах. Проверяющий может не согласиться с выбранной альтернативой, хотя все поля заполнены. Сначала проверьте, какие варианты действительно были доступны. Затем обсуждайте trade-off. Нельзя маскировать стратегическое несогласие под пропуск данных.

\n

Третий путь возникает при смешанных окнах. Если одна команда смотрит на неделю, а другая — на квартал, их числа не образуют общее сравнение. Не усредняйте их автоматически. Запишите границу каждого наблюдения и остановите общий вывод.

\n

Ограничения

\n

Карточка не восстанавливает прошлое идеально. Участники могут не помнить все варианты. Метрика могла иметь другую семантику. Внешние изменения могли совпасть по времени. Контрфактический вопрос показывает эти ограничения, но сам не создаёт контрольную группу.

\n

Полный разбор не нужен для каждого мелкого изменения. Он оправдан, когда запись предлагают повторить, когда она объясняет заметное изменение или когда её превращают в техническое правило. Для локальной заметки может хватить решения и границы. Чем дороже ошибочный перенос рецепта, тем полнее должна быть запись.

\n

Учебный код также ограничен. Он не читает внешние источники, не проверяет качество метрик, не запускает эксперимент и не заменяет решение владельца системы. Все значения в примере заданы вручную. Их нельзя выдавать за измеренный результат.

\n

Проверяемый критерий готовности

\n

Запись готова к чтению, если содержит конкретное решение, доступные альтернативы, явную стоимость, наблюдаемый факт, одно неизвестное и точную границу сравнения. Для каждого поля указан источник или прямо сказано, что значение учебное. Отсутствующее поле возвращает stop-and-repair. Ни один абзац не называет причиной то, что запись лишь наблюдала после изменения.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/075.json b/editorial/agent-rewrites/075.json new file mode 100644 index 0000000..0d9be53 --- /dev/null +++ b/editorial/agent-rewrites/075.json @@ -0,0 +1,7 @@ +{ + "index": 75, + "slug": "editorial-2025-12-practice-year-synthesis", + "title": "Как разбирать инженерный год: от решения к проверяемому выводу", + "excerpt": "Годовая запись инженерных решений становится полезной, когда отделяет выбор, стоимость, наблюдение и неизвестное. Показываю контракт записи, учебный валидатор и границы вывода.", + "contentHtml": "

Годовой отчёт часто превращается в список запусков: команда изменила систему, график пошёл вверх, пункт попал в итог. Через несколько месяцев такая запись уже не отвечает на главный вопрос: какое решение дало наблюдение и что ещё могло его вызвать. Цена ошибки — повторная работа, спор о причинах и новые изменения без исходной точки сравнения.

\n

Полезный разбор начинается не с общего вывода, а с карточки решения. В ней нужно сохранить выбранный путь, отвергнутые альтернативы, принятую стоимость, наблюдение, неизвестное и границу сравнения. Если в записи осталось только «после стало лучше», причинный вывод делать нельзя. Эта статья показывает практический контракт и учебный способ его проверять.

\n

Сначала отделите решение от результата

\n

Решение — это действие, которое можно связать с конкретным моментом и владельцем. Например: «разделили проверку входных данных и запись результата». Это не утверждение о пользе. Оно только фиксирует, что изменилось.

\n

Рядом запишите минимум две альтернативы. В примере можно было оставить общий шаг или сначала записывать результат, а потом проверять вход. Альтернатива нужна не для красивой истории. Она показывает, какие ограничения команда реально сравнивала. Без неё выбранный путь выглядит неизбежным.

\n

Третье поле — стоимость. Она бывает явной: дополнительный проход, новый запрос, больше места в логе. Бывает отложенной: усложнение схемы, обслуживание двух форматов, риск неполного покрытия. Если стоимость неизвестна, так и напишите. Пустое поле нельзя заменить словом «эффективнее».

\n

Шесть полей удерживают механизм

\n
Схема разбора инженерного года: решение связано с альтернативами и стоимостью, затем с наблюдением, неизвестным и границей сравнения
Хронология решения помогает сохранить связи между выбором и наблюдением. Она не доказывает причинность.
\n

Наблюдение должно описывать факт и окно проверки. «В трёх заранее заданных примерах порядок статусов читается одинаково» — наблюдение. «Процесс стал надёжнее» — уже интерпретация. Её нельзя записывать без измерения, контрольного сравнения и условий, в которых результат повторился.

\n

Неизвестное ограничивает вывод. Хорошая формулировка отвечает на вопрос, чего запись пока не знает: «неизвестно, сохранится ли порядок при частично заполненном входе». Плохая формулировка маскирует пробел: «нужно исследовать дальше». Читатель должен понимать, какой следующий факт изменит решение.

\n

Граница сравнения связывает утверждение с данными. Если сравнивались два литерала в учебном коде, это не сравнение двух месяцев, релизов или команд. Если метрика выросла одновременно с несколькими изменениями, годовая запись не выбирает причину сама. Она только сохраняет условия, при которых нужно продолжить проверку.

\n

Минимальный контракт записи

\n
Симптом неполной записи и следующий шаг проверки
СимптомПричинаПроверкаДействие
Есть только выбранный вариантАльтернативы потеряли при сокращенииНазвать два реально доступных путиВернуть их в карточку и сравнить условия
Есть «стало быстрее»Наблюдение смешали с выводомУказать метрику, окно и границу сравненияЗаменить оценку на наблюдаемый факт
Стоимость равна «нулю»Учитывали только время запускаПроверить сложность поддержки и новые зависимостиЗаписать принятый компромисс
Годовой вывод объясняет всёНеизвестное убрали из итогового текстаСпросить, какие внешние факторы не провереныДобавить неизвестное и сузить утверждение
Дата зависит от часового поясаСохранили локальное время без смещенияПроверить формат каждой отметкиХранить однозначное время и отдельно показывать локаль
\n

Учебный валидатор не даёт перепутать факт с эффектом

\n

Ниже — самостоятельный учебный пример на JavaScript. Он проверяет структуру одной записи. Значения в объекте придуманы для демонстрации и не описывают production-систему. Валидатор не измеряет скорость, не строит контрольную группу и не устанавливает причинность.

\n
const record = {\n  at: '2025-06-18T11:30:00Z',\n  decision: 'разделить проверку входа и запись результата',\n  alternatives: [\n    'оставить один общий шаг',\n    'сначала записывать результат, потом проверять вход',\n  ],\n  cost: 'дополнительный проход и отдельный статус unknown',\n  observation: 'в учебных примерах порядок статусов читается явно',\n  unknown: 'поведение на частично заполненном входе',\n  comparisonBoundary: 'только заранее заданные значения, без данных эксплуатации',\n};\n\nfunction validateRecord(value) {\n  const required = [\n    'at', 'decision', 'cost',\n    'observation', 'unknown', 'comparisonBoundary',\n  ];\n  const missing = required.filter((key) => !value[key]);\n  const validTime = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/.test(value.at);\n  const hasAlternatives = Array.isArray(value.alternatives)\n    && value.alternatives.length >= 2\n    && value.alternatives.every(Boolean);\n\n  return {\n    ok: missing.length === 0 && validTime && hasAlternatives,\n    missing,\n    validTime,\n    hasAlternatives,\n  };\n}\n\nconsole.log(validateRecord(record));
\n

Проверка времени здесь намеренно узкая: она принимает UTC-формат с суффиксом Z. Для рабочего кода нужно решить, допустимы ли числовые смещения, как хранить локальную зону и что делать с некорректной датой календаря. Регулярное выражение проверяет форму строки, но не заменяет разбор даты библиотекой и предметную проверку.

\n

Валидатор должен завершаться ошибкой при отсутствии стоимости, неизвестного или границы сравнения. Это отрицательный путь. Он важнее зелёного результата: система не позволяет оформить неполную карточку как доказательство эффекта. Если поле неизвестного заполнено словом «нет», это тоже повод остановиться. Неизвестное не равно отсутствию риска.

\n

Как читать годовую хронологию

\n

Не связывайте соседние события только потому, что они стоят рядом по дате. Сначала спросите, какая запись фиксирует решение. Затем найдите изменение, на которое она повлияла, и отдельно запишите наблюдение. Между ними могут быть релиз, смена данных, изменение трафика или исправление другой команды.

\n

Временная отметка нужна для трассировки, а не для доказательства. RFC 3339 задаёт однозначное представление момента времени и требует явного отношения к UTC. Это помогает сопоставить запись с логом, но не объясняет смысл события. Причину всё равно связывают через идентификатор, ссылку на изменение или другой проверяемый след.

\n

Сводный вывод пишите последним. Он должен быть слабее или равен данным, на которых построен. Если есть только наблюдение, формулировка звучит так: «после изменения в указанном окне наблюдалось X». Формулировка «изменение привело к X» требует более сильного дизайна сравнения. Годовая хронология сама по себе его не создаёт.

\n

Порядок действий

\n
  1. Соберите исходные точки по идентификатору решения и однозначной временной отметке.
  2. Для каждой точки запишите выбранный путь и не менее двух доступных альтернатив.
  3. Назовите цену выбора: время, сложность, риск, зависимость или потерянную возможность.
  4. Отделите наблюдаемый факт от объяснения и добавьте окно, метрику или входные условия.
  5. Запишите неизвестное, которое остаётся после наблюдения.
  6. Сформулируйте границу сравнения: какие данные вошли, а какие не вошли.
  7. Прогоните отрицательный сценарий с пустым полем и убедитесь, что запись не проходит проверку.
  8. Только после этого напишите общий вывод и укажите, какой следующий тест может его опровергнуть.
\n

Ограничения и критерий готовности

\n

Такой контракт не восстанавливает потерянную историю. Если решение не связано с изменением или наблюдением, карточка покажет пробел, но не заполнит его. Не стоит задним числом придумывать альтернативы, стоимость и контрольную группу. Лучше сохранить неполную запись и назвать, какого факта не хватает.

\n

Метод плохо подходит для событий без устойчивого владельца, плавающего входа и измеримого окна. Он также не заменяет postmortem, журнал изменений, эксперимент или аудит безопасности. Его задача уже: не дать короткому годовому выводу скрыть разрыв между решением и данными.

\n

Запись готова к следующему разбору, если по ней можно без догадки ответить на шесть вопросов: что выбрали, что отвергли, чем заплатили, что увидели, чего не узнали и с чем сравнивали. Дополнительный критерий — пустое обязательное поле останавливает проверку. Если эти два условия выполнены, текст помогает принять следующий проверяемый шаг. Он не обещает результат, которого в данных нет.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/076.json b/editorial/agent-rewrites/076.json new file mode 100644 index 0000000..2df8893 --- /dev/null +++ b/editorial/agent-rewrites/076.json @@ -0,0 +1,7 @@ +{ + "index": 76, + "slug": "editorial-2025-11-field-research-method", + "title": "Как проверять техническое утверждение по первоисточнику", + "excerpt": "Практический метод для случаев, когда ссылка выглядит убедительно, но не отвечает на вопрос о версии, представлении и условиях применения.", + "contentHtml": "

Инженер открывает документацию, находит знакомую фразу и переносит её в решение. Через месяц API меняется, ссылка показывает другую редакцию, а команда уже не знает, на каком факте построен выбор. Симптомы обычно просты: повторный поиск по тому же вопросу, спор о трактовке одного абзаца, расхождение между документацией и ответом сервиса. Цена ошибки — не только лишний час. Неверное утверждение может закрепить несовместимый контракт, скрыть риск миграции или заставить пользователя принять необратимое решение.

\n

Тезис статьи такой: техническое утверждение нужно проверять не длиной списка ссылок, а связкой «версия источника → место в документе → наблюдаемый фрагмент → ограниченный вывод». Если одного звена нет, вывод нельзя расширять. Его нужно остановить или вернуть на уточнение.

\n

Почему текущая ссылка часто не является доказательством

\n

URL отвечает только на вопрос «где сейчас находится страница». Он не всегда отвечает на вопросы «какую редакцию прочитали», «какой объект описывает текст» и «какое условие действовало в момент проверки». Страница может быть изменяемой. Релиз может иметь несколько представлений: HTML, PDF, JSON-схему или ответ API. У каждого представления свой адрес, заголовки и набор деталей.

\n

Рассмотрим фразу: «клиент поддерживает условные запросы». Она может означать четыре разных утверждения. Клиент умеет отправить заголовок If-None-Match. Сервер возвращает ETag. Кэш принимает решение по validator. Конкретная версия SDK корректно обрабатывает ответ 304 Not Modified. Первые три пункта относятся к протоколу. Последний требует отдельной проверки клиента, сервера и условий запроса. Одна ссылка на RFC не доказывает весь набор.

\n

Такая ошибка возникает из-за смешения уровней. Документ стандарта описывает правило. Представление документа показывает конкретную редакцию. Наблюдение фиксирует строку или ответ. Claim формулирует вывод. Decision выбирает действие. Между соседними уровнями должна быть явная связь. Иначе читатель достраивает её сам и незаметно усиливает исходный факт.

\n

Механизм: карточка утверждения

\n

Перед поиском запишите предложение, которое меняет решение. Не «исследовать кеширование», а «можно ли использовать ETag для повторного запроса этого ресурса при таком-то клиенте». В карточке нужны пять полей:

\n\n

Отдельно запишите status. Например, ready-with-scope означает, что узкий вывод можно передать дальше. repair-source-pin означает, что публикация известна, но её версия не закреплена. hold означает, что данные не позволяют делать техническую рекомендацию. Статус не оценивает автора. Он показывает следующий допустимый шаг.

\n
const card = {\n  statement: 'Для ответа 304 клиент может повторить запрос без тела ответа',\n  scope: 'учебный пример: GET, один ресурс, HTTP cache semantics',\n  source: {\n    url: 'https://www.rfc-editor.org/rfc/rfc9110.html',\n    locator: 'section 15.4.5'\n  },\n  artifact: '304 Not Modified означает, что условный GET выполнен, а payload не передаётся',\n  status: 'ready-with-scope'\n};\n\nif (!card.source.url || !card.source.locator || !card.artifact) {\n  card.status = 'hold';\n}
\n

Это учебный пример структуры данных. Он не проверяет сеть, библиотеку или реальный сервис. Его задача — показать границу между записью evidence и утверждением о поведении продукта. Чтобы утверждать совместимость, добавьте отдельный эксперимент с конкретными версиями клиента и сервера.

\n

Как читать первоисточник без ложной точности

\n

Начните с объекта, который описывает документ. У стандарта есть название, редакция и дата публикации. У релиза есть тег или commit. У ответа API есть URL, метод, время и значимые заголовки. Не называйте ETag номером версии, если источник этого не говорит. В RFC 9110 ETag относится к выбранному представлению ресурса и служит validator. Это не универсальный идентификатор релиза и не оценка смысла содержимого.

\n

Затем найдите точное место. Заголовок раздела лучше, чем ссылка на главную страницу. Для HTML сохраните fragment identifier, для PDF — страницу и название раздела, для JSON — путь к полю. Locator не должен заставлять читателя угадывать, где искать подтверждение. Если формулировка встречается в нескольких местах, выберите место с нормативным условием и запишите, какое именно условие вы используете.

\n

После этого перепишите не весь раздел, а один наблюдаемый артефакт. В нём должны остаться субъект, действие и условие. «Документ поддерживает кеширование» — пересказ. «Сервер сравнивает полученный validator с текущим представлением при условном запросе» — уже более точное наблюдение, но оно всё ещё не доказывает реализацию конкретного сервера.

\n

Последним шагом отделите факт от вывода. Факт отвечает на вопрос «что написано или что возвращено». Вывод отвечает на вопрос «что разрешено сделать в нашем контексте». Если контекст не совпадает, статус должен стать hold, даже если цитата настоящая.

\n
\"Цикл
Источник не производит решение сам. Карточка связывает вопрос, наблюдение и границу действия. Новая редакция создаёт новое основание, а не молча переписывает старое.
\n

Пример: validator не равен совместимости

\n

Предположим, команда хочет добавить условные GET-запросы в клиент. В черновике появляется вывод: «ETag гарантирует, что после обновления ресурс не устареет». Он звучит технически, но в нём смешаны три разных обещания: сервер публикует validator, клиент сравнивает его, а содержимое ресурса соответствует бизнес-правилу свежести.

\n

Исправленный claim уже: «В учебном сценарии с одним представлением ресурса ETag помогает сравнить текущий ответ с ранее сохранённым представлением. Это не доказывает семантическую актуальность данных, корректность кэша конкретной библиотеки и поведение при смене вариантов представления». Такой текст слабее по интонации, но сильнее как инженерная опора: его условия можно проверить.

\n

Практический тест должен повторить ровно заявленный контекст. Отправьте первый GET. Сохраните ответ и ETag. Отправьте условный GET с If-None-Match. Зафиксируйте код ответа, тело, ETag и вариант запроса. Затем измените представление или контент и повторите тест. Если тест использует gzip, разные языки или промежуточный кэш, эти условия входят в scope. Нельзя убрать их из описания только потому, что они усложняют вывод.

\n
Диагностика слабого технического утверждения
СимптомПричинаПроверкаДействие
Ссылка открывается, но версия неизвестнаИспользована изменяемая current pageНайти дату, release, commit или архивную публикациюПоставить repair-source-pin и не расширять claim
Есть цитата, но непонятно, что она доказываетНет locator и artifactВыписать раздел и короткий наблюдаемый фрагментСузить statement до проверяемой строки
Стандарт выдан за гарантию SDKСмешаны правило протокола и реализацияПроверить документацию и тест конкретной версии клиентаРазделить protocol fact и compatibility claim
Подпись принята за истинность данныхЦелостность смешана с семантической корректностьюНазвать, что именно проверяет подпись и кто отвечает за значениеОставить integrity claim или запросить независимое evidence
После обновления вывод меняется молчаСтарое наблюдение перезаписалиСравнить source pin, locator и дату двух карточекСоздать новую карточку и явно изменить scope
\n

Отрицательный путь важнее красивого результата

\n

Хорошая проверка должна уметь отказать. Если источник не имеет версии, не называйте его «почти подтверждённым». Если в документе есть только маркетинговая фраза «works everywhere», сохраните её как наблюдение текста, но не как результат испытания. Если подписанный документ цел, это подтверждает целостность выбранного представления. Это не доказывает, что каждое поле верно или что интеграция безопасна.

\n

Отказ экономит время, когда он привязан к причине. repair-source-pin требует найти dated primary publication. missing-locator требует вернуться в документ. unsupported-context требует отдельного теста. semantic-claim-unproven запрещает превращать криптографическую проверку в бизнес-вывод. Не подменяйте эти статусы дополнительными ссылками на те же слова: количество цитат не исправляет отсутствие наблюдения.

\n

Сравнение двух источников тоже может быть недопустимым. Если один описывает выпуск 3.2, а второй — «текущую версию», у них нет общей временной точки. Сначала закрепите представления. Потом сравните условия, locator и artifact. Если этого сделать нельзя, результатом будет не рейтинг, а остановка перед сравнением.

\n

Порядок действий

\n
  1. Назовите решение. Запишите, какую рекомендацию может изменить ответ. Уберите общий глагол «исследовать».
  2. Сформулируйте один claim. Укажите субъект, действие, условия и границу. Не объединяйте протокол, SDK и бизнес-эффект.
  3. Выберите первичный источник. Используйте стандарт, официальный релиз, исходный репозиторий или опубликованную спецификацию. Зафиксируйте дату и версию.
  4. Поставьте locator. Запишите раздел, якорь, страницу или путь к полю. Проверьте, что читатель открывает то же место.
  5. Сохраните artifact. Выпишите короткий фрагмент или результат запроса. Не заменяйте его общим пересказом.
  6. Сверьте scope. Сравните условия источника с клиентом, сервером, форматом, версией и средой вашего решения.
  7. Проверьте отрицательную ветку. Удалите pin, locator или условие и убедитесь, что статус меняется на repair или hold.
  8. Передайте ограниченный вывод. В решении укажите, что доказано, что не доказано и какой тест нужен для расширения claim.
  9. Обновляйте явно. Новый источник добавляйте рядом со старым. Не переписывайте историю наблюдения поверх прежней карточки.
\n

Ограничения метода

\n

Карточка не делает источник истинным. Она не заменяет предметного эксперта, нагрузочный тест, аудит безопасности или проверку лицензии. Она также не превращает официальный документ в доказательство того, что конкретный продукт соблюдает документ. Для этого нужен отдельный observation на конкретной версии и в конкретных условиях.

\n

Метод плохо работает, если вопрос слишком широкий. «Безопасна ли система» нельзя подтвердить одной ссылкой и одной строкой ответа. Разбейте его на claims: какая граница доверия, какая атака, какая версия, какой контроль и какой наблюдаемый результат. Сложность должна появиться в карточках и тестах, а не скрыться в уверенном абзаце.

\n

Есть и стоимость дисциплины. Нужно хранить версии, локаторы и результаты повторных проверок. Но эта стоимость видна и ограничена. Цена альтернативы обычно выше: команда повторяет поиск, спорит о разных редакциях и принимает решение на основании фразы, которую никто уже не может воспроизвести.

\n

Проверяемый критерий готовности

\n

Материал готов к передаче, если другой инженер без устного контекста может открыть именно ту публикацию, найти locator, увидеть artifact и пересказать claim без усиления. Для каждого сильного вывода есть scope. Для каждого отсутствующего звена есть status и следующий шаг. Учебный пример явно отделён от результата в production. При удалении версии, локатора или условия проверка не продолжает выдавать положительный вывод.

\n

Финальная проверка короткая: спросите «какой факт изменит решение?» и «что именно этот источник не доказывает?». Если на первый вопрос нет ответа, claim не связан с действием. Если на второй нет ответа, в тексте почти наверняка спрятано лишнее обещание. Оставьте только то, что можно открыть, увидеть и повторить в названной границе.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/077.json b/editorial/agent-rewrites/077.json new file mode 100644 index 0000000..d64b87a --- /dev/null +++ b/editorial/agent-rewrites/077.json @@ -0,0 +1,7 @@ +{ + "index": 77, + "slug": "editorial-2025-11-mechanism-research-method", + "title": "Как проверить источник до того, как он повлияет на решение", + "excerpt": "ETag, дата публикации и цифровая подпись подтверждают разные свойства документа. Разбираем границы этих сигналов и собираем проверку, которая останавливает слишком сильный вывод.", + "contentHtml": "

Команда находит страницу с нужным API, видит свежую дату и сразу переносит совет в интеграцию. Через месяц endpoint ведёт себя иначе. Оказывается, страница описывала другой режим, а заголовок ETag приняли за номер релиза. Ошибка стоит дороже, чем время на чтение: меняется код, растёт число обходов, а причину трудно восстановить после инцидента.

\n

Тезис статьи прост: источник не равен доказательству решения. Проверка должна разделять публикацию, конкретное представление документа, наблюдаемый факт и действие, которое из него следует. Каждый переход требует своей опоры. Если опоры нет, проверка должна остановиться и показать, чего не хватает.

\n

Четыре уровня, которые нельзя смешивать

\n

Публикация — RFC, стандарт, релиз или датированная рекомендация. Она задаёт документ и его область. Представление — именно тот HTML, PDF или HTTP-ответ, который прочитал инженер. Оно может зависеть от языка, заголовков запроса, авторизации и времени.

\n

Наблюдение — короткая фраза, которую можно проверить в представлении: «в разделе указано условие X» или «ответ содержит заголовок ETag». Утверждение — уже интерпретация наблюдения. Решение — изменение кода, конфигурации или процесса. Страница может подтвердить наблюдение, но не обязана подтверждать решение для любого продукта.

\n
\"Матрица
Проверка движется от адресуемого представления к наблюдению, затем к ограниченному утверждению и только после этого к решению.
\n

Удобно хранить эти уровни раздельно. Для публикации нужен идентификатор и дата. Для представления — URL, версия, commit или снимок. Для наблюдения — точный раздел, строка или заголовок ответа. Для решения — условия применимости, риск и способ проверки.

\n

Почему ETag не заменяет версию

\n

В HTTP заголовок ETag служит validator выбранного представления ресурса. Клиент сравнивает значение при условных запросах. Сервер может вычислить его по содержимому, назначить внутренний идентификатор или учитывать согласование формата. Само значение не обязано раскрывать эту схему.

\n

Из ETag: \"a81c\" нельзя вывести, что документ относится к релизу 8.1, что он был опубликован в конкретную дату или что смысл каждого абзаца сохранится для другого endpoint. Last-Modified также говорит о времени изменения представления, а не о полном жизненном цикле продукта.

\n

Для исторической проверки нужен более сильный якорь: датированный RFC, тег релиза, commit, опубликованный PDF или сохранённый снимок. Validator полезно записать дополнительно. Он помогает повторить протокольную проверку, но не превращается в семантическую версию.

\n

Что подтверждает цифровая подпись

\n

Подпись помогает проверить целостность защищённого объекта и связь с подписантом. Она не превращает каждое утверждение внутри объекта в доказанный факт. Документ может быть подлинным и одновременно описывать ограниченный сценарий, старую конфигурацию или другой объект.

\n

Поэтому у записи проверки нужны две разные строки: integrity и claimEvidence. Первая отвечает на вопрос «документ не изменили после подписи?». Вторая — «есть ли в нём наблюдение, которое поддерживает именно нашу фразу?». Подмена одной строки другой создаёт ложную уверенность.

\n

Учебный пример: остановить слишком сильный вывод

\n
const record = {\n  source: {\n    url: 'https://example.test/api-guide',\n    etag: '\"a81c\"',\n    signature: 'valid'\n  },\n  observation: 'Документ описывает режим read-only для версии 2.',\n  claim: 'API безопасен для записи в любой версии.'\n};\n\nconst canRecommend =\n  Boolean(record.source.url) &&\n  Boolean(record.source.etag) &&\n  record.source.signature === 'valid' &&\n  record.claim.includes('версии 2') &&\n  record.observation.includes('версии 2');\n\nconsole.log(canRecommend); // false
\n

Это учебный пример, а не проверка реальной подписи и не production-код. У записи есть адрес, validator и валидная подпись. Но наблюдение ограничивает вывод версией 2 и режимом read-only, а утверждение расширяет его до записи и любой версии. Проверка возвращает отказ. Правильное действие — сузить утверждение или найти отдельное наблюдение для записи и нужной версии.

\n

Отрицательный путь важен не меньше успешного. Если система продолжает работу со статусом «достаточно» после отсутствия версии, locator или области применимости, она маскирует неизвестное. Средний балл доверия не исправит отсутствие конкретного факта.

\n

Симптом → причина → проверка → действие

\n
Диагностика ошибок при чтении источника
СимптомПричинаПроверкаДействие
ETag выглядит как номер версииПротокольный validator смешали с релизомОткрыть правила версий издателя и сравнить с RFCСохранить ETag отдельно, найти release pin
Ссылка открывается, но текст уже другойЗафиксирован только текущий URLПроверить дату, commit, PDF или архивный снимокСузить исторический вывод или сменить источник
Подписанный документ подтверждает всё сразуЦелостность приняли за истинность тезисаРазделить signature и claim evidenceОставить только вывод об авторстве либо добавить факт
Документ верен, но совет не работаетScope документа не совпал с продуктомСопоставить endpoint, версию, права и режимДобавить условие применимости и отдельный тест
В отчёте нет причины остановкиОтказ заменили общим статусом доверияПроверить обязательные поля и отрицательные веткиВернуть статус hold с конкретным недостающим полем
\n

Порядок проверки

\n
  1. Сформулируйте решение одним предложением. Укажите действие, объект, версию и режим.
  2. Назовите уровень каждого утверждения: публикация, представление, наблюдение или решение.
  3. Закрепите представление. Используйте release, commit, датированный документ или снимок. Текущий URL оставьте только как навигацию.
  4. Запишите точный locator: раздел, строку, заголовок ответа или другой повторяемый фрагмент.
  5. Сверьте scope. Проверьте endpoint, версию, метод, права, язык, формат и условия, при которых сделано наблюдение.
  6. Сравните силу формулировки с фактом. Уберите слова «всегда», «безопасно», «поддерживает» и «для всех», если источник их не подтверждает.
  7. Проверьте отрицательный путь: уберите версию, измените режим или подставьте другой объект. Система должна остановиться, а не выдать прежний совет.
  8. Только после этого выберите действие и добавьте проверяемый критерий результата.
\n

Ограничения метода

\n

Эта модель не делает веб неизменяемым. Она не устанавливает истинность заявления одной ссылкой и не заменяет предметного эксперта. Официальный стандарт может не описывать operational-ограничение конкретного сервиса. Подпись может быть корректной, но относиться не к тому представлению. Архивный снимок фиксирует текст, но не доказывает, что система в тот день работала по нему.

\n

Метод также не требует сохранять весь интернет. Для дорогих решений достаточно закрепить минимальный набор: первичный документ, конкретное представление, наблюдение, область применимости и способ проверки. Если какой-то элемент нельзя получить, вывод должен стать уже. Отказ — нормальный результат, когда данных недостаточно.

\n

Критерий готовности

\n

Проверка готова, когда другой инженер может открыть тот же документ или его закреплённое представление, найти указанный locator, увидеть наблюдение и понять, почему оно поддерживает решение именно в заданном scope. Если он меняет версию, режим или объект, отрицательный путь возвращает остановку с понятной причиной. Ни один из этих результатов не требует доверять интонации автора.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/078.json b/editorial/agent-rewrites/078.json new file mode 100644 index 0000000..f3ec28b --- /dev/null +++ b/editorial/agent-rewrites/078.json @@ -0,0 +1,7 @@ +{ + "index": 78, + "slug": "editorial-2025-11-practice-research-method", + "title": "Как превратить ссылку в проверяемое техническое утверждение", + "excerpt": "Ссылка сама по себе не доказывает решение. Разбираем журнал утверждений: как зафиксировать версию источника, область применимости, наблюдение, отрицательный путь и следующий проверяемый шаг.", + "contentHtml": "

Ошибка в исследовании источников обычно обнаруживается поздно. В тексте стоит ссылка на документацию, но читатель не знает, какую версию открывал автор, где находится нужный факт и при каком условии он перестаёт работать. В коде такой совет превращается в неверный запрос, несовместимую настройку или лишнюю переделку. Цена ошибки — часы на повторную проверку и решение, которое нельзя уверенно объяснить после обновления документа.

\n

Ссылка — это адрес. Утверждение — это ограниченная фраза, которую можно проверить. Между ними нужен короткий журнал: statement, scope, source pin, locator, observation и status. Такая запись не делает источник истинным. Она показывает, какой факт прочитан, где он находится и какое действие он разрешает.

\n

Что именно нужно зафиксировать

\n

Начинайте с одного предложения. «ETag ускоряет кеширование» слишком широко: здесь не названы протокол, представление, условие запроса и ожидаемый результат. «Для выбранного HTTP-представления сильное сравнение ETag позволяет проверить условие If-None-Match» уже имеет границу. Его можно сопоставить с разделом спецификации и с наблюдаемым запросом.

\n

У утверждения есть шесть полей. statement хранит фразу. scope описывает, где она действует. sourcePin фиксирует версию первичного материала: RFC, tagged release или датированный PDF. locator указывает раздел, таблицу или endpoint. observation записывает минимальный факт без расширения смысла. status управляет следующим шагом: можно передавать запись дальше или нужно остановиться.

\n
\"Лестница
Доказательство усиливается не числом ссылок, а связью между версией, локатором и наблюдаемым фактом.
\n

Механизм: отделить источник от наблюдения

\n

URL без версии ведёт на текущую страницу. Он не доказывает, что документ выглядел так же в момент чтения. Поэтому журнал хранит pin отдельно. Датированный RFC или commit отвечает на вопрос «какой материал открыт». Locator отвечает на вопрос «где искать». Observation отвечает на вопрос «что там написано или возвращено».

\n

Это разделение не формальность. Заголовок ETag — непрозрачный валидатор выбранного представления. Он помогает отличать представления ресурса, но сам по себе не описывает бизнес-смысл данных и не обещает совместимость конкретного SDK. Из факта о протоколе нельзя автоматически вывести факт о продукте.

\n

Полезно также различать целостность и смысл. Подписанный документ подтверждает происхождение или неизменность представления, если проверка подписи прошла. Это не доказывает, что интерпретация автора подходит другому endpoint, региону, праву доступа или версии клиента. Если источник сообщает только «подпись действительна», журнал должен остановить более сильное утверждение.

\n

Минимальный пример

\n

Ниже учебный пример. Он не обращается к сети и не имитирует результат production-сервиса. Функция проверяет только полноту локальной записи: pin, область, locator и наблюдение. Такой код полезен для демонстрации границы метода, а не для оценки качества реального источника.

\n
const claim = {\n  statement: 'If-None-Match compares the selected representation',\n  scope: 'HTTP conditional GET',\n  sourcePin: 'RFC-9110',\n  locator: 'section 8.8.3',\n  observation: 'ETag is an opaque validator for a selected representation',\n};\n\nfunction assess(record) {\n  const required = ['statement', 'scope', 'sourcePin', 'locator', 'observation'];\n  const missing = required.filter((field) => !record[field]);\n\n  if (missing.length) {\n    return { status: 'hold', nextAction: 'repair-record', missing };\n  }\n\n  return { status: 'ready-for-scoped-handoff', nextAction: 'use-with-scope' };\n}\n\nconsole.log(assess(claim));\n// { status: 'ready-for-scoped-handoff', nextAction: 'use-with-scope' }
\n

Статус ready-for-scoped-handoff означает только одно: запись адресуемая и содержит наблюдение. Он не означает, что измерена производительность, проверена совместимость или доказан эффект для продукта. Чтобы не потерять эту границу, следующий шаг хранится рядом со статусом.

\n

Отрицательная ветка обязательна. Если удалить sourcePin, функция вернёт hold. Добавление ещё одной цитаты вокруг текущей страницы не исправит отсутствие версии. Нужно найти неизменяемый первичный материал, открыть locator заново и записать новый observation. Если источник содержит маркетинговое обещание, его можно сохранить как наблюдение текста, но нельзя выдавать за измеренный результат.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Ссылка открывается, но факт не находитсяНет locator или он относится к другой версииОткрыть pinned material и найти раздел зановоДобавить точный locator; при несовпадении остановить вывод
Совет звучит уверенно, но не имеет условияТема заменяет проверяемое утверждениеНазвать объект, вход, результат и границуСузить statement до одной проверяемой фразы
Подписанный файл принимают за доказательство поведенияЦелостность смешали с семантической истинностьюСверить observation с заявленным выводомОставить только вывод об авторстве или найти независимый факт
Две ссылки дают «среднюю уверенность»Сравнивают разные классы источниковПроверить одинаковые scope, термин и объектНе агрегировать записи до устранения несовместимости
После обновления API совет ломаетсяЗафиксирован только корневой URLСравнить дату, release или commit с датой решенияДобавить source pin и повторить проверку
\n

Порядок проверки

\n
  1. Сформулируйте одну фразу. Уберите слова «лучше», «быстрее» и «надёжнее», если рядом нет объекта, условия и способа измерения.
  2. Назовите область. Укажите протокол, версию API, тип документа, права, среду или другой фактор, который ограничивает вывод.
  3. Зафиксируйте первичный материал. Используйте RFC, официальную спецификацию, документацию владельца API или tagged release. Сохраните версию, дату или commit.
  4. Найдите локатор. Запишите раздел, страницу, таблицу, endpoint или короткий уникальный фрагмент. Корневого URL недостаточно.
  5. Перепишите наблюдение. Сохраните только тот факт, который виден в источнике. Не добавляйте к нему обещание о продукте, которого там нет.
  6. Проверьте отрицательный путь. Удалите pin, измените scope или подставьте маркетинговую фразу. Запись должна перейти в hold, а не остаться «почти подтверждённой».
  7. Назначьте действие. Укажите, что разрешено сделать дальше: использовать с ограничением, найти версию, получить измерение или остановить решение.
\n

Ограничения метода

\n

Журнал не заменяет эксперимент. Он не измеряет задержку, не проверяет нагрузку и не устанавливает, что API одинаково ведёт себя во всех средах. Он только связывает фразу с источником и наблюдением. Для утверждения о производительности нужен воспроизводимый тест с входами, условиями и метрикой. Для утверждения о безопасности нужны модель угроз, область действия и отдельная проверка.

\n

Метод также не решает спор между двумя корректными первичными источниками. Если версии или области различаются, журнал должен показать это различие. Решение появится после явного критерия: совместимость, дата поддержки, стоимость миграции или другой измеримый приоритет. Нельзя скрывать отсутствие критерия под статусом «достаточно надёжно».

\n

Учебный код выше проверяет структуру записи, а не внешний мир. В настоящем исследовании нужно сохранить ссылку на доступный первичный документ, дату чтения и способ воспроизвести observation. Если источник исчез, изменился или не отвечает на нужный вопрос, корректный результат — hold.

\n

Проверяемый критерий готовности

\n

Запись готова к использованию, когда другой инженер без устного пояснения может открыть зафиксированный источник, перейти к locator, увидеть observation, назвать scope и понять следующий шаг. Проверка должна пройти и для отрицательной ветки: при отсутствии версии, несовпадении области или подмене факта обещанием статус блокирует передачу.

\n

Это небольшой критерий, но он защищает решение от самой дорогой подмены: ссылка начинает выглядеть как доказательство только потому, что её никто не перепроверил.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/079.json b/editorial/agent-rewrites/079.json new file mode 100644 index 0000000..3c0167d --- /dev/null +++ b/editorial/agent-rewrites/079.json @@ -0,0 +1,7 @@ +{ + "index": 79, + "slug": "editorial-2025-10-field-teaching-engineering", + "title": "Promise.all не отменяет работу: как объяснить границу на рабочем примере", + "excerpt": "После первой ошибки Promise.all возвращает rejected promise, но уже начатая работа входов не исчезает сама. Разбираем модель, контрпример и проверку, которые не дают перенести рецепт за пределы его условий.", + "contentHtml": "

Симптом появляется в момент первой ошибки. Код ждёт несколько операций через await Promise.all(tasks), одна операция отклоняется, обработчик сразу переходит в catch, а автор считает остальные операции остановленными. Позже выясняется, что один запрос всё ещё пишет данные, таймер всё ещё выполняется, а зависимая логика уже очистила состояние. Цена ошибки — повторные записи, лишние запросы и расследование, в котором приходится восстанавливать границу ответственности по логам.

\n

Тезис простой: Promise.all объединяет наблюдаемые исходы promises, но не является протоколом отмены работы. Он сообщает результат aggregate promise. Он не получает автоматически право остановить операцию, которая создала входной promise. Чтобы объяснить такой код без ложной гарантии, нужно разделить три объекта: входной promise, aggregate promise и внешнюю работу.

\n

Сначала назовите задачу

\n

Рецепт становится опасным, когда его показывают раньше задачи. Возьмём учебный сценарий: нужно получить профиль и настройки, а затем построить экран. Если любой результат недоступен, экран строить нельзя. Здесь Promise.all подходит как условие для зависимого шага: зависимый код запускается только после успешного исхода двух входов.

\n

Но это условие не отвечает на другой вопрос: что делать с операцией, которая уже началась и ещё не завершилась? Объединение результатов и остановка работы относятся к разным контрактам. Первый задаёт JavaScript. Второй должен задавать приложение, библиотека или владелец ресурса.

\n
\"Цикл
Правильное объяснение ведёт от задачи к модели, затем показывает оставшийся вход после reject и только после этого говорит о следующем действии. Схема учебная: она не показывает реальный сетевой trace и не доказывает поведение конкретного сервиса.
\n

Механизм: два исхода вместо одного

\n

Пусть в Promise.all переданы profilePromise и settingsPromise. JavaScript создаёт новый aggregate promise. Он выполнится успешно, если все входы выполнятся. Значения попадут в массив в порядке входного iterable, а не в порядке завершения. Если один вход отклонится, aggregate promise отклонится с первой причиной отказа.

\n

Это не означает, что второй вход получил команду отмены. Второй promise может уже завершиться, продолжить ожидание или скрывать за собой работу, которую можно прервать только отдельным API. Вызов catch наблюдает отказ aggregate. Сам по себе он не меняет жизненный цикл запроса, чтения файла, вычисления или записи.

\n

Отсюда следует отрицательный путь. Если профиль отклонился, нельзя выводить «настройки отменены». Можно вывести только «общий результат недоступен с такой причиной». Дальше код обязан использовать собственный контракт: сигнал отмены, закрытие ресурса, остановку очереди, компенсацию или безопасное ожидание остатка. Если такого контракта нет, честный ответ — отмена не определена.

\n

Минимальный контрпример

\n

Ниже намеренно узкий пример. Он не обращается к сети, диску, часам или данным пользователей. Один вход отклоняется сразу. Второй вход удерживается до явного вызова finishRemaining. Это позволяет увидеть порядок событий и не приписывать JavaScript поведение, которого в коде нет.

\n
let finishRemaining;\nconst remaining = new Promise((resolve) => {\n  finishRemaining = () => resolve('settings');\n});\n\nconst combined = Promise.all([\n  Promise.reject(new Error('profile failed')),\n  remaining,\n]).catch(() => 'aggregate handled');\n\nawait combined;\nconsole.log('aggregate rejected');\nfinishRemaining();\nconsole.log(await remaining);\n// aggregate rejected\n// settings
\n

После первой строки вывода aggregate уже обработал отказ. Но второй promise ещё существует. Функция finishRemaining завершает его позже. Пример доказывает только это: rejected aggregate не равен отмене каждого входа. Он не доказывает, как поведёт себя HTTP-клиент, база данных или очередь сообщений. Для каждого такого ресурса нужна отдельная документация и отдельный тест.

\n

Контрпример полезнее общей фразы «promises выполняются параллельно». Это слово слишком широкое. В JavaScript promise представляет состояние будущего результата; он не является универсальным дескриптором процесса, который можно остановить одним методом. Операции могут стартовать до создания aggregate, а их остановка может быть невозможна или требовать согласия внешнего владельца.

\n

Три уровня, которые нельзя смешивать

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
catch сработал, но запрос продолжилсяAggregate наблюдает отказ, а клиент запроса не получил сигнал отменыПосмотреть API запроса и его обработчик сигналаДобавить явный cancel-контракт или признать работу неотменяемой
Результаты приходят в неожиданном порядкеПорядок завершения перепутан с порядком iterableСравнить индексы входов и trace завершенияЧитать массив по исходным индексам, а не по времени
После одной ошибки обработчик пишет частичный результатЗависимый шаг запускается не только после fulfilled aggregateПроверить место вызова и ветки после catchРазделить partial result и готовый aggregate
Заменили all на allSettled, но проблема осталасьНужен был протокол остановки, а изменили только форму отчётаНазвать требование: все исходы или остановка работыВыбрать combinator для отчёта и отдельно спроектировать отмену
Текст обещает «остановить всё»Рецепт подменил причинную модельПопросить показать объект, который посылает cancelСузить утверждение до aggregate и добавить отрицательный путь
\n

Как объяснить код без лишней теории

\n

Начните с одной проверяемой фразы: «Экран строится только после двух успешных результатов». Затем назовите aggregate promise и покажите, где читается его результат. После этого добавьте отказ одного входа. Читатель должен увидеть, что зависимый шаг не запускается. Только теперь задайте вопрос о втором входе.

\n

Ответ должен быть конкретным. Если второй вход уже создан, у него есть собственное состояние. Promise.all не предоставляет в этом вызове метода cancel. Если вход связан с fetch, автор может передать AbortSignal и вызвать AbortController.abort(). Но это уже договор Fetch и конкретного кода приложения, а не свойство Promise.all. Даже сигнал не превращает любую серверную операцию в гарантированно отменённую: сервер мог принять запрос, а клиент мог лишь прекратить ожидание ответа.

\n

Такой ответ не должен звучать как универсальный рецепт отмены. Для учебного фрагмента достаточно показать место ответственности. В production нужно проверить, что делает клиент после abort, что происходит на сервере, как закрывается ресурс и допустима ли повторная попытка. Если эти условия не описаны, статья должна остановиться на границе знания.

\n

Когда нужен другой combinator

\n

Promise.all выбирают, когда нужен общий успех всех входов и ранний отказ aggregate при первой ошибке. Promise.allSettled выбирают, когда нужно дождаться и сохранить статус каждого входа. Это разные требования. Переключение на allSettled не отменяет операции и не исправляет частичную запись.

\n

Например, экран может показывать независимые виджеты. Тогда полезно получить массив состояний и отрисовать ошибку только у одного виджета. Но платёжный сценарий, в котором нельзя продолжать без обязательного ответа, требует другой проверки: dependent action не должна начаться после rejected aggregate. Если же один вызов уже создал побочный эффект, combinator не решает вопрос компенсации. Его должен решить доменный контракт.

\n

Не стоит заменять Promise.all на последовательный await только ради иллюзии контроля. Последовательный запуск уменьшает число одновременно начатых операций, но не отменяет первую операцию при ошибке второй. Он также меняет задержку и нагрузку. Сначала зафиксируйте требование, затем выбирайте форму ожидания.

\n

Проверяемый порядок действий

\n
  1. Запишите исходную задачу одним предложением: какие результаты нужны и какой шаг зависит от них.
  2. Назовите каждый входной promise и работу, которая стоит за ним. Не называйте promise самой работой.
  3. Покажите fulfilled-путь: все входы завершились, aggregate вернул значения в порядке iterable, зависимый шаг получил полный набор.
  4. Покажите отрицательный путь: один вход отклонился, aggregate отклонился, зависимый шаг не стартовал.
  5. Проверьте оставшийся вход отдельным наблюдением. Ответьте, может ли он завершиться после reject и кто имеет право его остановить.
  6. Если нужна отмена, найдите реальный контракт ресурса: сигнал, close, cancel, rollback или другой механизм подтверждения.
  7. Проверьте частичные результаты и побочные эффекты. Отдельно решите, допустимы ли повтор, компенсация и повторный запуск.
  8. Сформулируйте готовность одной проверяемой фразой и приложите тест для положительной и отрицательной ветки.
\n

Ограничения учебного примера

\n

Пример выше фиксирует порядок наблюдений в памяти. Он не моделирует latency, сетевые разрывы, retry, таймауты, серверную обработку, блокировки базы, очередь или пользовательскую сессию. Нельзя переносить его вывод как готовую архитектуру. Его задача уже: показать, почему aggregate rejection не доказывает отмену входа.

\n

Есть и другой отрицательный путь: остановка может быть технически доступна, но логически запрещена. Например, сервер уже принял команду, и отмена клиентского ожидания не должна приводить к повторной команде без idempotency key. В таком случае «прервать ожидание» и «отменить побочный эффект» — два разных решения. Текст, который объединяет их одним словом «cancel», скрывает риск.

\n

Не заявляйте результат обучения или production-эффект по одному примеру. Можно проверить, что читатель назвал aggregate, вход и отдельный cancel-контракт. Нельзя из этого вывести, что он безопасно спроектирует любой асинхронный pipeline. Для такого вывода нужна другая проверка, привязанная к конкретной системе.

\n

Критерий готовности

\n

Объяснение готово, если независимый читатель может без подсказки ответить на четыре вопроса: какой результат объединяет Promise.all; что происходит при первом reject; может ли оставшийся вход закончиться позже; кто именно останавливает внешнюю работу. Код должен проходить тесты для fulfilled-пути и для отказа, а текст — не обещать отмену там, где в API нет такого контракта.

\n

Практическая финальная проверка короткая. Уберите названия методов и попросите восстановить модель по событиям. Затем верните код и сравните каждое утверждение с наблюдаемым результатом. Если для фразы «всё остановилось» нельзя назвать объект, который отправил сигнал остановки, замените её на точное утверждение об aggregate. Такая редактура сохраняет полезный рецепт и не переносит его за пределы условий задачи.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/080.json b/editorial/agent-rewrites/080.json new file mode 100644 index 0000000..8dfed48 --- /dev/null +++ b/editorial/agent-rewrites/080.json @@ -0,0 +1,7 @@ +{ + "index": 80, + "slug": "editorial-2025-10-mechanism-teaching-engineering", + "title": "Promise.all: почему первая ошибка не останавливает остальные операции", + "excerpt": "Promise.all управляет итогом набора промисов, но не владеет внешней работой. Разбираем порядок результатов, ранний reject, отдельную отмену и безопасную проверку на небольшом примере.", + "contentHtml": "

Сервис собирает профиль пользователя из трёх запросов: настройки, лимиты и историю операций. Один запрос завершается ошибкой. Обработчик сразу попадает в catch и возвращает ответ об ошибке. Через секунду другой запрос всё ещё пишет в лог, держит соединение или меняет локальное состояние.

\n

Симптом — разработчик видит отклонённый Promise.all и говорит: «набор остановился». Цена ошибки — лишняя нагрузка, гонка за общим состоянием и неверная очистка ресурсов. Код может повторить операцию, пока первый запуск ещё работает. Расследование усложняется: aggregate уже отклонён, а позднее событие живёт в другом promise.

\n

Тезис простой: Promise.all сообщает исход группы. Он не является командой отмены для входных promise и не знает, какая внешняя операция их породила. Ранний reject останавливает ожидание успешного aggregate, но не доказывает остановку работы. Для остановки нужен отдельный контракт: владелец сигнала, способ передать его операции и подтверждение результата.

\n

Механизм: три разных объекта

\n

Первый объект — входной promise. Он представляет один будущий исход: значение или ошибку. Второй — aggregate promise, который возвращает Promise.all(iterable). Он собирает значения по позиции входного iterable и принимает решение о собственном исходе. Третий — внешняя операция: HTTP-запрос, чтение файла, запрос к базе или вычисление. Она может существовать за пределами promise-модели.

\n

Связь направлена только в одну сторону. Вход сообщает aggregate, что он fulfilled или rejected. Aggregate сообщает вызывающему коду свой исход. Такая связь не содержит команды «остановись» для другого входа. Даже после reject aggregate другой вход может позже fulfilled или rejected.

\n
const left = Promise.reject(new Error('parse failed'));\nconst right = new Promise((resolve) => {\n  setTimeout(() => resolve('right is done'), 100);\n});\n\ntry {\n  await Promise.all([left, right]);\n} catch (error) {\n  console.log('aggregate rejected:', error.message);\n}\n\n// Это не команда отмены для right.
\n

Здесь Promise.all отклоняется из-за left. Ожидание текущей функции заканчивается. Но таймер получил отдельное право завершить right. Никакой код не передал ему сигнал отмены. Поэтому позднее значение появится, даже если вызывающий код больше его не читает.

\n

Это учебный пример с фиксированными promise и таймером. Он не измеряет задержку сети, поведение браузера или расход ресурсов в production. Он проверяет только границу между исходом aggregate и исходом другого входа.

\n
Три уровня Promise.all: входной promise, aggregate promise и внешняя операция
Aggregate фиксирует свой исход. Из этого факта нельзя автоматически вывести отмену входа или внешней операции.
\n

Что можно вывести из исхода

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
catch сработал, но поздний лог продолжилсяДругой вход завершает собственную работуДобавить trace для каждого входаНе называть aggregate отменой; найти владельца
Значения пришли в неожиданном порядкеОжидалась скорость, а не позиция iterableСравнить индексы и порядок событийЧитать результат по позиции или хранить ключ
Нужно увидеть каждую ошибку, но видна однаPromise.all завершает aggregate на первом rejectПроверить требование к полному отчётуРассмотреть Promise.allSettled
После ошибки запросы продолжаютсяОперации не получили общий сигналПроверить API и обработчик сигналаПередать AbortSignal или другой cancel contract
Остановка считается доказанной по rejectСмешаны исход результата и управление ресурсомНазвать реальное действие прерыванияРазделить зависимую логику и протокол остановки
\n

Порядок результатов и первая ошибка

\n

При успешном исходе массив значений сохраняет порядок входного iterable, а не порядок завершения. Быстрый второй promise всё равно окажется во второй позиции. Это удобно для сопоставления, пока массив не меняется между запуском и чтением.

\n

При reject aggregate получает ошибку входа, который первым сообщил отклонение в рамках алгоритма наблюдения. Это не отчёт обо всех ошибках и не журнал времени завершения. Promise.allSettled решает другую задачу: ждёт завершения всех входов и возвращает статус каждого. Он тоже не добавляет отмену.

\n

Отмена живёт рядом, но отдельно

\n

Для операции, которая умеет принимать сигнал, отмену можно связать с обработкой aggregate. В браузерном или серверном коде это часто AbortController и его signal. Контроллер создаёт вызывающий код. Операции получают сигнал. При ошибке вызывающий код вызывает abort(). Конкретный API должен обработать сигнал по своему контракту.

\n
async function loadPages(urls) {\n  const controller = new AbortController();\n  const signal = controller.signal;\n\n  try {\n    return await Promise.all(\n      urls.map((url) => fetch(url, { signal }).then((response) => {\n        if (!response.ok) throw new Error('HTTP ' + response.status);\n        return response.json();\n      })),\n    );\n  } catch (error) {\n    controller.abort();\n    throw error;\n  }\n}
\n

Фрагмент ограничен операциями fetch, которые получили сигнал. Он не отменяет синхронную функцию, уже записанную транзакцию или promise, который игнорирует signal. abort() означает запрос на остановку поддерживаемой операции, а не откат каждого побочного эффекта.

\n

Проверка на маленькой трассировке

\n

Запишите события каждого уровня. Первый promise отклоняет aggregate. Затем ручной владелец второго promise вызывает сохранённый resolve. Ожидаемая последовательность показывает два факта: aggregate rejected, а другой вход позже fulfilled.

\n
const trace = [];\nlet finishRight;\n\nconst left = Promise.reject(new Error('left failed'));\nconst right = new Promise((resolve) => {\n  finishRight = () => {\n    trace.push('right fulfilled');\n    resolve('ok');\n  };\n});\n\nawait Promise.all([left, right]).catch(() => trace.push('aggregate rejected'));\nfinishRight();\nawait right;\n\nconsole.log(trace);\n// ['aggregate rejected', 'right fulfilled']
\n

Трассировка не доказывает, что реальный запрос продолжился: в ней нет запроса. Она доказывает более узкое утверждение о promise, которым мы управляем вручную. Для сетевой отмены нужен отдельный тест API, который принимает сигнал, и проверка его наблюдаемого завершения.

\n

Порядок действий

\n
  1. Назовите каждый вход и внешнюю операцию, которая его создаёт.
  2. Запишите нужный результат: все значения, первый успешный, все статусы или раннее завершение зависимой логики.
  3. Снимите отдельный trace для входов и aggregate.
  4. Проверьте отрицательный путь: после первого reject другой вход может завершиться позже.
  5. Если нужна остановка, найдите API отмены и его владельца. Передайте сигнал до запуска.
  6. Проверьте, что обработчик сигнала меняет состояние нужной операции.
  7. Только затем выбирайте Promise.all, Promise.allSettled или другой способ композиции.
\n

Ограничения модели

\n

Promise.all не обещает параллельное выполнение в смысле потоков. JavaScript может передать управление между асинхронными продолжениями, а конкретный API сам определяет выполнение. Нельзя выводить пропускную способность, порядок сетевых пакетов или освобождение ресурса из одного вызова combinator.

\n

Нельзя считать abort() универсальным откатом. Он не возвращает отправленные данные, не отменяет побочный эффект на сервере и не исправляет уже завершённую запись. Он также не заменяет дедупликацию, идемпотентность и таймаут.

\n

Нельзя выбирать allSettled только потому, что первая ошибка неудобна. Если следующий шаг требует всех значений, ранний reject не даёт начать неполный расчёт. Если нужен отчёт о каждом независимом входе, подходит all-settled-семантика. Решение зависит от зависимости между работами.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда команда отвечает на четыре вопроса: какой promise отклоняет aggregate; какой вход может завершиться позже; какое действие останавливает внешнюю работу; какое наблюдение подтверждает остановку. Учебный код готов, если trace показывает независимые исходы. Код с реальными операциями готов, если тест проверяет поддержку сигнала и отрицательный путь, где один вход отвергнут, а другой ещё выполняется.

\n

Если на вопрос об остановке отвечают «Promise.all», модель не готова. Добавьте владельца отмены или честно зафиксируйте, что операция не поддерживает остановку.

\n

Проверяемые источники

" +} \ No newline at end of file diff --git a/editorial/agent-rewrites/081.json b/editorial/agent-rewrites/081.json new file mode 100644 index 0000000..3141d1f --- /dev/null +++ b/editorial/agent-rewrites/081.json @@ -0,0 +1,7 @@ +{ + "index": 81, + "slug": "editorial-2025-10-practice-teaching-engineering", + "title": "Promise.all не отменяет работу: как объяснить границу и проверить её", + "excerpt": "Promise.all объединяет результаты асинхронных операций, но не управляет их остановкой. Разбираем модель, контрпример, проверку и отдельный контракт отмены.", + "contentHtml": "

Симптом появляется после первой же ошибки: один запрос отклонился, обработчик получил исключение, а соседние запросы продолжают выполняться. В логах уже виден общий отказ, но база данных, сетевой клиент или внешний сервис ещё получают работу. Цена ошибки — лишний трафик, ненужные записи, гонки при очистке и неверное сообщение пользователю. Если эту границу объяснить неточно, команда начнёт искать отмену в месте, которое умеет только ждать.

\n

Тезис простой: Promise.all описывает исход группы promises, а не жизненный цикл операций, которые за ними стоят. Aggregate promise может отклониться на первой ошибке. Остальные входы при этом не получают приказ остановиться. Значит, ожидание и отмена — два разных контракта. Их нужно проектировать и проверять раздельно.

\n

Модель: что именно объединяет Promise.all

\n

Представьте два входа: left и right. Каждый из них обещает значение в будущем. Вызов Promise.all([left, right]) создаёт третий promise. Он становится успешным, когда все входы успешны, и отклоняется, когда один вход отклоняется. При успехе значения идут в том же порядке, что и входы.

\n

Третий promise не владеет внутренней работой left и right. Он наблюдает их состояния и сообщает общий результат. Если left завершился ошибкой, aggregate promise может завершиться сразу. Уже начатый right не обязан завершаться в тот же момент. Если right сам изменяет состояние внешней системы, наблюдение за ним не откатывает это изменение.

\n
Схема: два входных promise сходятся в общий результат, а отмена остаётся отдельным контрактом
Общий promise сообщает результат группы. Отдельный сигнал отмены управляет конкретной операцией, если такой сигнал предусмотрен её API.
\n

Минимальный пример с видимым порядком событий

\n

Учебный пример ниже не обращается к сети, файлам или базе. Он вручную завершает второй promise после того, как общий promise уже отклонился. Так видна только семантика ожидания. Пример не доказывает, что какой-либо реальный клиент продолжит работу именно с такой задержкой.

\n
let finishRight;\n\nconst right = new Promise((resolve) => {\n  finishRight = () => {\n    console.log('right finished');\n    resolve('cache value');\n  };\n});\n\nconst combined = Promise.all([\n  Promise.reject(new Error('left failed')),\n  right,\n]).catch(() => {\n  console.log('combined rejected');\n});\n\nawait combined;\nfinishRight();\nawait right;
\n

Сначала появится combined rejected. Затем появится right finished. Это не означает, что aggregate promise «забыл» второй вход. Он уже сообщил общий отказ, но второй вход всё ещё может перейти в состояние fulfilled. Ожидание результата группы не стало командой отмены.

\n

Важна и другая деталь. Promise.all не создаёт сами операции. К моменту вызова promises часто уже запущены: функция вернула promise, сетевой запрос уже отправлен, чтение уже поставлено в очередь. Обёртка видит результат этой работы, но не получает универсального доступа к её внутренним ресурсам.

\n

Симптом → причина → проверка → действие

\n
Диагностика ошибок вокруг Promise.all
СимптомПричинаПроверкаДействие
После первой ошибки соседний запрос всё ещё виден в логахAggregate promise сообщает отказ, но не отменяет входДобавить отдельные метки старта и завершения каждой операцииРешить, нужна ли отмена, и передать сигнал в API операции
Код ловит общий отказ и считает работу законченнойСмешаны состояние aggregate promise и состояние ресурсовПроверить, что происходит с каждым входом после catchДождаться нужных cleanup-действий или описать их отдельный контракт
Пользователь нажал «отмена», но запрос продолжилсяКнопка меняет интерфейс, а сигнал не дошёл до транспортаПроверить прохождение AbortSignal до вызова APIСвязать действие пользователя с поддерживаемым механизмом отмены
После ошибки меняется общий объектОдна из операций имеет побочный эффектСравнить состояние до запуска, после отказа и после завершения остальных входовСделать операцию идемпотентной, добавить компенсацию или изменить порядок
В сообщении указано «все запросы остановлены»Вывод сделан по первой ошибке, а не по наблюдению ресурсовНайти подтверждение остановки для каждого участника группыСузить формулировку до «общий результат отклонён»
\n

Как выглядит отдельная отмена

\n

Отмена появляется только там, где её поддерживает исполнитель. Для браузерного fetch обычно передают AbortSignal, полученный от AbortController. Контроллер не превращает любой promise в отменяемый. Он передаёт сигнал в конкретный API, а API решает, как остановить или прервать свою работу.

\n
const controller = new AbortController();\n\nconst requests = [\n  fetch('/api/profile', { signal: controller.signal }),\n  fetch('/api/limits', { signal: controller.signal }),\n];\n\ntry {\n  const responses = await Promise.all(requests);\n  // Обрабатываем ответы только после успеха всей группы.\n} catch (error) {\n  if (error.name === 'AbortError') {\n    // Это отдельный путь отмены, а не обычная ошибка данных.\n  }\n  throw error;\n}\n\n// Вызывается по явному решению приложения, например по кнопке.\ncontroller.abort();
\n

В этом фрагменте есть важное ограничение: вызов abort() стоит после try только для наглядности механизма. В рабочем коде его вызывают из отдельного события или политики таймаута. Если один fetch уже успел изменить серверное состояние, прекращение ожидания ответа не отменит это изменение. Для серверной операции нужен серверный контракт: ключ идемпотентности, отмена задания, транзакция или компенсационное действие.

\n

Не следует использовать контроллер как универсальный откат. Он помогает остановить поддерживаемую передачу данных. Он не возвращает уже отправленный платёж, не удаляет запись и не отменяет произвольную функцию, которая просто вернула promise.

\n

Положительный и отрицательный пути

\n

Положительный путь начинается с успешных входов. Тогда aggregate promise возвращает массив значений в порядке входного массива. Это удобно, когда зависимый код может начать работу только после получения всех значений. Проверка должна подтвердить не только наличие двух ответов, но и соответствие позиции: первый результат относится к первому входу.

\n

Отрицательный путь начинается с отклонения одного входа. Aggregate promise сообщает ошибку, но у приложения остаются вопросы. Нужно ли дождаться остальных результатов? Нужно ли отменить их? Можно ли показать частичные данные? Нужна ли компенсация побочного эффекта? На эти вопросы Promise.all не отвечает. Если нужен итог каждого входа, включая ошибки, применяют Promise.allSettled. Если нужна остановка, добавляют API отмены. Если нужна повторная попытка, описывают её лимит и область действия отдельно.

\n

Отдельно проверяйте поздние события. Отмена может прийти после ответа сервера. Таймаут может сработать после записи на сервере. Первый отказ может появиться раньше, чем второй запрос заметит закрытый сигнал. Обработчик должен различать «мы перестали ждать», «транспорт получил отмену» и «внешняя операция действительно прекратилась». Это три разных наблюдения.

\n

Порядок проверки

\n
  1. Опишите каждую операцию отдельно: что она читает, что меняет и какой ресурс должен остановиться.
  2. Зафиксируйте ожидаемый общий результат: все значения, первая ошибка или полный набор исходов.
  3. Запустите два входа с разными задержками и запишите отдельные события старта, отказа, завершения и отмены.
  4. Проверьте сценарий, в котором первый вход отклоняется, а второй завершается позже.
  5. Если нужна отмена, передайте поддерживаемый сигнал в каждый исполнитель и проверьте его получение.
  6. Проверьте поздний ответ после отмены: он не должен менять UI или состояние, если операция уже устарела.
  7. Проверьте побочные эффекты отдельно: отмена ожидания не должна называться откатом без подтверждения внешней системы.
\n

Ограничения модели

\n

Эта модель объясняет семантику группы promises, но не задаёт политику приложения. Она не решает, какой запрос важнее, как долго ждать, сколько раз повторять и как сообщать частичный результат. Для этих решений нужны отдельные правила и наблюдаемые сигналы.

\n

Она также не заменяет документацию конкретной библиотеки. Один клиент может поддерживать AbortSignal, другой — собственный метод отмены, третий — только закрытие соединения. Нельзя переносить поведение одного транспорта на другой по одному имени promise.

\n

Учебный код с ручным finishRight показывает порядок переходов в памяти. Он не является нагрузочным тестом, не измеряет задержки и не подтверждает поведение production-системы. В реальном сервисе проверяйте контракт библиотеки, логи транспорта и состояние внешнего ресурса.

\n

Критерий готовности

\n

Объяснение и код готовы, если читатель может ответить на четыре вопроса без догадки: какой promise сообщает общий результат, что происходит с остальными входами после первой ошибки, какой объект или API действительно принимает отмену и что происходит с уже выполненным побочным эффектом. В проверочном запуске должны быть видны отдельные события для aggregate promise и каждой операции. Для сценария отмены нужно подтвердить получение сигнала исполнителем, а для серверного изменения — отдельное подтверждение остановки или компенсации.

\n

Если на вопрос об остановке отвечают только названием Promise.all, объяснение не готово. Добавьте контрпример и укажите владельца отмены. Если такого владельца нет, честный результат звучит так: общий promise отклонился, а начатая работа может продолжиться.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/082.json b/editorial/agent-rewrites/082.json new file mode 100644 index 0000000..9da236c --- /dev/null +++ b/editorial/agent-rewrites/082.json @@ -0,0 +1,7 @@ +{ + "index": 82, + "slug": "editorial-2025-09-field-engineering-interviews", + "title": "Как калибровать техническое интервью по фактам, а не по впечатлению", + "excerpt": "Два reviewer-а могут прочитать один технический ответ по-разному. Разбираем, как найти первую точку расхождения на synthetic-пробе, проверить её и остановить опасный переход от учебной записи к выводу о человеке.", + "contentHtml": "

Два reviewer-а читают один технический ответ. Один видит аккуратную гипотезу. Другой — недостаток глубины. Через пять минут спор уже идёт о впечатлении, а не о тексте. В журнале нет ссылки на строку, где началось расхождение.

\n

Цена ошибки высока. Команда меняет rubric после самого громкого мнения. Кандидат или сотрудник получает вывод, который нельзя проверить. Следующий review повторяет тот же спор. Калибровка не исправляет это обсуждением в конце встречи. Она должна сначала зафиксировать одинаковый вход, независимые факты и границу того, чего проба не показывает.

\n

Тезис: технический review калибруется через цепочку «наблюдение → неизвестное → интерпретация → следующий запрос». Reviewer-ы не обязаны прийти к одинаковой интерпретации. Они обязаны показать, на каких фактах она стоит и где заканчивается evidence. Если в записи появляется score, ranking или вывод о личности, процесс должен остановиться.

\n

Механизм: разделить факт и объяснение

\n

Возьмём учебную карточку с ответом на вопрос об устаревшем API-ответе. В тексте есть max-age=0, маршрут /v1/report и описание симптома. Правило origin-сервера не указано. Это намеренный пробел. Он позволяет проверить, умеет ли reviewer отделить видимое от неизвестного.

\n

Observation отвечает только на вопрос «что видно в sample?». Например: «В заголовке указан max-age=0». Unknown отвечает на вопрос «чего здесь нет?»: «Правило, по которому origin формирует cache-control, не показано». Interpretation связывает эти записи: «Нужен запрос правила origin; изменение cache policy пока не доказано». Такой порядок не запрещает гипотезы. Он не даёт гипотезе притвориться фактом.

\n
СлойДопустимая записьЗапрещённый скачок
ObservationВ sample есть max-age=0.«Сервис неправильно настроен».
UnknownOrigin rule не показано.«Автор не понимает кеширование».
InterpretationНужно запросить правило origin.«Reviewer B глубже разобрался».
Hand-offЗапросить один отсутствующий факт.Создать score, ranking или hiring outcome.
\n

Независимость нужна до обсуждения. Каждый reviewer получает тот же sampleId, ту же role rubric и те же критерии. Он сначала пишет факты и неизвестные, затем интерпретацию. Если начать с общей беседы, участники быстро выровняют формулировки, но потеряют момент, где они увидели разное.

\n

Конкретный пример: безопасный hand-off

\n

Ниже — учебный TypeScript-подобный код. Он работает с объектом в памяти. Он не обращается к сети, не пишет файл и не создаёт оценку человека. В production этот пример ничего не доказывает.

\n
type ReviewRecord = {\n  sampleId: string;\n  criterionId: string;\n  evidenceRef: string;\n  kind: 'observation' | 'unknown' | 'interpretation' | 'hand-off';\n  text: string;\n};\n\nfunction acceptHandOff(record: ReviewRecord) {\n  const safePrefix = 'hand-off:';\n  const forbidden = /score|ranking|hiring|person|candidate/i;\n\n  if (record.kind !== 'hand-off' || !record.text.startsWith(safePrefix)) {\n    return { accepted: false, action: 'stop-and-repair-boundary' };\n  }\n  if (forbidden.test(record.text)) {\n    return { accepted: false, action: 'stop-and-repair-boundary' };\n  }\n  return { accepted: true, action: 'request-missing-evidence' };\n}\n\nconst next = acceptHandOff({\n  sampleId: 'api-cache-fixed-v1',\n  criterionId: 'evidence-boundary',\n  evidenceRef: 'sample.headers.cache-control',\n  kind: 'hand-off',\n  text: 'hand-off: request the origin cache rule',\n});
\n

Вызов возвращает учебный запрос недостающего evidence. Он не разрешает менять конфигурацию. Ветка с текстом hiring: reject должна вернуть stop-and-repair-boundary. Это отрицательный путь, а не дополнительная функция: он показывает, что процесс заметил незаконное расширение задачи.

\n

Проверять нужно не только результат функции. Сверьте четыре поля: один sampleId, существующий evidenceRef, применимый criterionId и допустимый тип hand-off. Пустая ссылка, общий ярлык или другой sample делают запись непроверяемой. В таком случае правильное действие — остановка, а не попытка угадать недостающий факт.

\n
\"Цикл
Учебный цикл возвращает неопределённость в проверяемый запрос. Стрелка stop блокирует personal outcome и production effect.
\n

Симптомы и действия

\n
СимптомПричинаПроверкаДействие
Reviewer-ы спорят о «глубине».Критерий не описывает наблюдаемое поведение.Найдите строку sample, на которую ссылается каждый.Сузьте criterion до проверяемого признака.
Оба reviewer-а используют одинаковые слова, но делают разные выводы.Они смешали observation и interpretation.Перепишите записи в два отдельных поля.Запросите недостающий факт вместо решения.
В hand-off появляется score или ranking.Учебная проба пересекла границу оценки человека.Проверьте текст и допустимые типы записи.Верните stop-and-repair-boundary; не сохраняйте outcome.
После калибровки меняют сразу sample, rubric и инструкцию.Нельзя понять, что исправило расхождение.Сопоставьте первую непарную запись с изменённым артефактом.Измените один артефакт и повторите тот же sample.
Reviewer просит данные из сети или реального разговора.Проба не содержит нужного evidence и маскирует это.Проверьте boundary и список разрешённых источников.Остановите прогон или замените sample на явно новый учебный кейс.
\n

Порядок короткой calibration session

\n
  1. Заморозьте вход. Создайте synthetic sample с идентификатором, фиксированными литералами и описанием того, чего в нём нет.
  2. Опишите критерий. Запишите observable behaviour и исключённые измерения. Слова «сильный», «слабый» и «системный» без признака не подходят.
  3. Раздайте одинаковые условия. Передайте reviewer-ам один sample, одну rubric и одинаковый порядок чтения. Не начинайте с общей дискуссии.
  4. Соберите независимые записи. Каждый reviewer фиксирует observation, unknown и interpretation с ссылками на evidence.
  5. Найдите первую развилку. Сначала сравните sample id и факты. Затем сравните criterion. Только после этого обсуждайте interpretation.
  6. Классифицируйте расхождение. Разные факты указывают на проблему sample или чтения. Одинаковые факты и разные трактовки указывают на rubric. Разные hand-off указывают на неясную границу действия.
  7. Измените один артефакт. Исправьте sample, criterion или инструкцию, но не все сразу. Старую версию оставьте как учебный контрпример без связи с человеком.
  8. Повторите тот же прогон. Убедитесь, что прежняя развилка стала видимой, а hand-off по-прежнему не создаёт score, ranking, personal record или production effect.
\n

Когда этот метод не подходит

\n

Synthetic-проба проверяет процесс чтения и границу доказательства. Она не измеряет производительность инженера, качество найма, способность работать в команде или результат реального проекта. Нельзя переносить её вывод на человека. Нельзя называть отсутствие расхождения доказательством валидности rubric.

\n

Один fixed sample быстро устаревает. Изменился API, критерий или рабочая задача — изменился и смысл пробы. Набор из нескольких sample требует отдельного дизайна: иначе команда начнёт сравнивать разные задачи как одну шкалу. Если нужен реальный отбор, его должны спроектировать владельцы процесса с учётом применимых требований. Учебный журнал не заменяет такую процедуру.

\n

Метод также не спасает от плохого критерия. Если criterion требует «понять намерение автора», его нельзя проверить ссылкой на текст. Если sample скрывает несколько причин, reviewer-ы будут расходиться по делу, а не из-за плохого review. Сначала уменьшите область утверждения. Потом добавляйте сложность.

\n

Проверяемый критерий готовности

\n

Сессия готова, если независимые записи можно открыть без устного пояснения и ответить на четыре вопроса: какой sample читали; какой факт увидели; какое неизвестное осталось; почему hand-off разрешён или остановлен. Для каждого расхождения указан один артефакт, который изменили. Повторный прогон использует тот же идентификатор пробы и не создаёт score, ranking, personal outcome, сетевой запрос или production effect.

\n

Минимальная проверка — прогнать положительную и отрицательную ветки. Положительная ветка принимает только hand-off с ссылкой на существующий evidence и запросом одного недостающего факта. Отрицательная ветка отклоняет текст с оценкой человека, невалидную ссылку и неизвестный критерий. Если хотя бы одна ветка проходит без явного результата, материал не готов к использованию даже как учебный пример.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/083.json b/editorial/agent-rewrites/083.json new file mode 100644 index 0000000..716144b --- /dev/null +++ b/editorial/agent-rewrites/083.json @@ -0,0 +1 @@ +{"index":83,"slug":"editorial-2025-09-mechanism-engineering-interviews","title":"Техническое интервью: как проверять ход инженерного решения, а не впечатление","excerpt":"Рабочая проба даёт полезный сигнал только тогда, когда команда отделяет наблюдаемый факт от его трактовки и заранее ограничивает следующий вывод.","contentHtml":"

После технического интервью в заметке часто остаётся одна фраза: «сильный инженер, хорошо понимает кеширование». Через неделю другой reviewer читает ту же запись и видит только уверенный рассказ без проверки инвалидации. Восстановить ход разговора уже нельзя. Команда спорит о впечатлении, а не о факте. Цена ошибки — разные кандидаты получают разные трактовки одной и той же работы, а слабый сигнал превращается в решение.

Проблема возникает не потому, что reviewer плохо помнит разговор. В одну запись смешивают три операции. Сначала нужно зафиксировать, что человек сделал или сказал. Затем — объяснить, какой узкий вывод из этого следует. И только после этого — выбрать следующий проверяемый шаг. Если пропустить первый слой, техническое слово маскирует догадку.

Тезис: интервью проверяет цепочку доказательств

Хорошее инженерное интервью не пытается измерить «системное мышление» одним вопросом. Оно создаёт небольшую рабочую задачу с известными границами. Reviewer смотрит не на сходство ответа с эталоном, а на цепочку: симптом, наблюдение, ограничение, действие и проверка результата. Такая цепочка не делает решение объективным автоматически. Она делает спор локальным: можно показать строку, на которой возникло расхождение.

В этой статье рабочая проба учебная. Она не предсказывает результат реального найма и не заменяет требования конкретной роли. Её задача — показать, как сохранить техническое evidence и не выдать интерпретацию за факт. В реальном процессе такую пробу нужно проверить на соответствие работе, заранее согласовать критерии и отдельно определить правила хранения данных.

Механизм: три слоя с разными правами

Наблюдение отвечает только на вопрос «что видно в условии или ответе». Например: «в ответе указан заголовок Cache-Control: max-age=0». Это не объяснение причины. Оно не доказывает, что данные устарели, и не показывает, что origin вернул новый ответ.

Интерпретация связывает наблюдение с одним критерием. Допустимая формулировка: «кандидат заметил клиентскую настройку кеша, но причина stale-ответа пока не установлена». Недопустимая формулировка — «не понимает кеширование»: в ней нет ни границы вывода, ни способа проверки.

Действие выбирает следующий шаг. В учебном примере это запрос недостающего факта: «уточнить, меняется ли заголовок на origin и какой возраст ответа видит клиент». Это не оценка человека и не изменение production-конфигурации. Если интерпретация не ссылается на наблюдение, действие должно быть stop.

const observation = { fact: 'Cache-Control: max-age=0', unknown: 'origin response is unknown' }; const interpretation = { evidence: ['observation-1'], claim: 'symptom is observed; cause is not proven' }; const handOff = interpretation.evidence.length ? 'request: check origin' : 'stop: interpretation-not-traceable';

Код выше — учебная модель без сети, файлов и реального интервью. Он не выставляет балл и не делает вывод о человеке. Его смысл — показать ссылочную целостность: у интерпретации есть наблюдение, а у действия есть проверяемая причина.

Конкретный пример: stale-ответ

Пусть условие звучит так: «GET /report иногда показывает старые данные. В клиентском ответе есть Age: 120 и Cache-Control: max-age=0. Назовите следующий шаг диагностики». Условия намеренно неполные. Они показывают симптом и два заголовка, но не дают ответа от origin, конфигурацию CDN или момент изменения данных.

Слабая запись выглядит убедительно: «кандидат сразу понял, что кеш сломан». Она приписывает причину по одному симптому. Сильная запись короче: «назвал Age: 120; заметил max-age=0; запросил ответ origin и время его формирования». В ней видны факт, неизвестное и действие. Если кандидат предлагает очистить кеш до проверки origin, reviewer отмечает порядок действий только по заранее определённому критерию.

Отрицательный путь важен. Если reviewer пишет «не понимает CDN», но не указывает, какого наблюдения не хватило, запись нельзя передать дальше. Нужно вернуться к фактам: какой вопрос прозвучал, какой заголовок был назван, что осталось неизвестным. Это защита от вывода, который невозможно повторно проверить.

Схема разделяет наблюдение, интерпретацию и следующий проверяемый шаг в техническом интервью
Граница между фактом, трактовкой и действием: каждый следующий слой должен ссылаться на предыдущий.
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
В заметке есть общий ярлыкНаблюдение смешали с интерпретациейПопросить цитату или действие из ответаПереписать запись как факт и unknown
Reviewer-ы спорят о решенииОни использовали разные критерииСравнить criterion до выводаЗафиксировать одну развилку
Ответ предлагает очистить кеш сразуСимптом приняли за источникПроверить origin, Age и время измененияЗапросить данные, не менять систему
Интерпретация не имеет ссылкиВ запись попала догадкаНайти observation для claimОстановить hand-off

Как калибровать два разбора

Калибровка не означает, что два reviewer-а обязаны написать одинаковый текст. Она проверяет, видят ли они одну границу evidence. Сначала каждый читает одну пробу отдельно. Затем сравнивают первую расходящуюся строку. Если один записал «Age равен 120», а другой — «кеш устарел», расхождение найдено: второй перескочил через неизвестное. Если факты совпали, но действия различаются, вопрос относится к критерию, а не к памяти о разговоре.

  1. Заморозьте условие. Дайте один текст задачи, те же данные и одинаковое время.
  2. Запишите наблюдения. Для каждого факта сохраните цитату, действие или ссылку на строку условия.
  3. Назовите неизвестное. Укажите, чего в пробе нет и почему это ограничивает вывод.
  4. Привяжите интерпретацию. Укажите criterion и ссылки на observations.
  5. Сравните первую развилку. Найдите строку, где записи стали разными.
  6. Сформируйте следующий шаг. Оставьте запрос недостающего факта или остановите разбор.

Такой порядок снижает стоимость обсуждения. Reviewer-ы говорят не «ты слишком строгий», а «здесь claim не следует из observation». Это не гарантирует одинакового решения. Оно даёт короткий путь к месту, где нужно уточнить условие, критерий или ответ.

Что проверять в рабочей пробе

Проба должна напоминать реальную инженерную работу, но проверять один ограниченный навык. Для задачи про кеширование достаточно дать симптом, несколько заголовков и скрыть один важный факт. Если добавить origin response, CDN policy, трассировку и журнал деплоя, reviewer уже оценивает объём подсказок и скорость чтения. Слишком широкая задача превращает интервью в угадывание ожидаемого рассказа.

Критерий должен описывать действие, которое можно увидеть. «Понимает распределённые системы» слишком широк. «Разделяет симптом клиента и источник ответа; запрашивает недостающий origin-факт» допускает проверку. Слова «глубокий», «зрелый» и «сильный» нельзя делать единственным содержанием записи.

Нельзя задним числом добавлять к пробе критерий, которого она не проверяла. Если команда хочет оценивать идемпотентность, нужно добавить условие, где повтор запроса имеет последствия. Если хочет проверить наблюдаемость, нужно дать доступный сигнал и определить достаточный ответ. Иначе reviewer приписывает человеку отсутствие навыка, хотя проба не давала возможности его показать.

Ограничения и отрицательный путь

Разделение слоёв не устраняет субъективность. Reviewer всё ещё выбирает, какую цитату считать достаточной, а автор проб выбирает условие. Модель не измеряет будущую производительность, командное взаимодействие или качество работы в другой среде. Она не оправдывает автоматическое ранжирование и не даёт основания хранить больше персональных данных.

Заголовок HTTP — это сигнал, а не полная причинная модель кеша. Реальное поведение зависит от клиента, промежуточного кеша, валидаторов, времени ответа и политики источника. Поэтому учебный пример показывает порядок диагностики, но не доказывает, что конкретная система неисправна. Прежде чем менять конфигурацию, нужно проверить факты в самой системе и иметь безопасный откат.

Если в записи появились персональные сведения, решение о найме, production-change или claim без ссылки, процесс должен остановиться. Допустимый hand-off — запросить технический факт, уточнить условие или пересобрать критерий. Нельзя превращать отсутствие evidence в отрицательный вывод о человеке.

Проверяемый критерий готовности

Материал и проба готовы к ограниченному учебному прогону, если другой reviewer может ответить на четыре вопроса: какой был симптом, какой факт наблюдали, что осталось неизвестным и почему выбран следующий шаг. Дополнительная проверка проста: удалите интерпретации и оставьте observations. Если действие всё ещё выглядит очевидным, в нём спрятана причина без доказательства. Перепишите его в запрос к отсутствующему факту.

Готовность не означает production-результат и не подтверждает качество отбора. Она означает только воспроизводимую форму записи на заданной учебной пробе. Для реального процесса владельцы роли должны отдельно проверить содержание задачи, юридические требования, доступ к данным и правила хранения.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/084.json b/editorial/agent-rewrites/084.json new file mode 100644 index 0000000..2aab189 --- /dev/null +++ b/editorial/agent-rewrites/084.json @@ -0,0 +1,7 @@ +{ + "index": 84, + "slug": "editorial-2025-09-practice-engineering-interviews", + "title": "Инженерное интервью: как проверить работу с неполным входом", + "excerpt": "Вместо экзамена по терминам используйте короткую техническую задачу с одним симптомом, неполным входом и наблюдаемыми критериями. Статья показывает, как отделить факт от гипотезы, проверить отрицательный путь и понять, готова ли такая проба.", + "contentHtml": "

Интервьюер спрашивает: «Что такое stale-while-revalidate?» Собеседник уверенно отвечает. Через несколько минут разговор заканчивается, но главный рабочий вопрос остаётся без ответа: что он сделает, если API вернул устаревший ответ, причина неизвестна, а изменение может затронуть клиентов?

\n

Симптом плохого интервью появляется сразу. В одной записи остаётся «хорошо знает HTTP», в другой — «не задал уточняющих вопросов». Нельзя восстановить, какой факт прозвучал и на чём основан вывод. Цена ошибки — спор о впечатлении вместо данных. Команда может принять знание термина за умение безопасно искать причину, а потом получить поспешное изменение TTL без проверки источника ответа.

\n

Тезис прост: инженерное интервью должно показывать маршрут от симптома к следующему безопасному действию. Для этого нужна короткая рабочая проба с одним неполным входом и заранее заданными наблюдаемыми критериями. Если вход не подтверждает причину, правильный ответ — назвать неизвестное и запросить конкретный факт. Уверенная догадка не заменяет проверку.

\n

Механизм: разделите факт, гипотезу и действие

\n

Хорошая техническая запись состоит из трёх слоёв. Факт описывает то, что действительно дано. Гипотеза объясняет, что может стоять за симптомом. Действие показывает, какой сигнал нужно получить дальше и что пока нельзя менять. Смешение слоёв превращает предположение в якобы установленную причину.

\n
const observation = {\n  fact: 'Cache-Control: max-age=0',\n  source: 'fixed-response-header',\n  unknown: 'origin rule и владелец маршрута не заданы'\n};\n\nconst nextStep = {\n  action: 'запросить origin rule read-only способом',\n  stop: 'не менять TTL без источника причины'\n};
\n

Заголовок в примере — факт. Он не доказывает, что origin сломан, кэш настроен неправильно или новый TTL исправит ответ. Следующий шаг тоже ограничен: получить один технический факт без изменения состояния. Именно это показывает работу с неопределённостью. Память о директиве HTTP здесь вторична.

\n

Как устроить короткую рабочую пробу

\n

Дайте один симптом и несколько заранее известных значений: заголовок ответа, имя маршрута и условие остановки. Не давайте репозиторий, сеть, персональные данные или настоящую историю изменений. Учебный пример ниже использует только фиксированные значения в памяти. Он показывает форму технического разговора и не описывает реального собеседника или настоящую систему.

\n
\"Схема
Ограниченный вход ведёт к наблюдаемому факту и следующему запросу. Он не даёт оснований менять систему или делать вывод о человеке.
\n

Критерий формулируйте через действие. Фраза «сильный инженер» слишком широкая: разные люди вложат в неё разные признаки. «Называет факт, неизвестное и источник следующей проверки» — наблюдаемый критерий. «Отделяет проверку без изменения состояния от изменения» — ещё один. «Связывает симптом с действием короткой цепочкой» — третий.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Ответ помечен stale, а разговор сразу переходит к TTLГипотезу приняли за фактПопросить назвать источник заголовка и недостающие данныеСохранить наблюдение; не менять политику кэширования
Два слушателя по-разному описывают один ответКритерий задан оценочным прилагательнымЗаменить его фразой, шагом или артефактомСравнивать одинаковые наблюдения
Собеседник просит открыть настоящий сервисПроба вышла за заданную границуПроверить список разрешённых данных и условие остановкиВернуться к фиксированному входу
После ответа появляется «подходит / не подходит»Техническое наблюдение смешали с выводом о человекеНайти персональный вывод и его основаниеУбрать вывод; оставить технический вопрос
\n

Пример: положительный и отрицательный путь

\n

Изолированный учебный модуль принимает только фиксированную карточку. Он проверяет структуру входа, связь между наблюдением и объяснением, разрешённые данные и форму следующего запроса. Вызов ниже не выставляет балл и не создаёт рекомендации.

\n
import {\n  createFixedInterviewInput,\n  inspectEngineeringInterview,\n} from './upgrade-2025-09.mjs';\n\nconst input = createFixedInterviewInput('fixed-valid-v1');\nconst report = inspectEngineeringInterview(input);\n\nconsole.log(report.accepted); // true\nconsole.log(report.reasons.length === 0); // true
\n

Положительная ветка оставляет только запрос недостающего технического факта. Она не возвращает числовую оценку и не говорит, каким является человек. В реальной беседе такой результат означает: причина ещё не установлена, поэтому нужен следующий источник данных.

\n

Отрицательная ветка должна останавливать разговор. Если объяснение ссылается на несуществующее наблюдение, карточка просит открыть настоящий репозиторий или в записи появляется вывод о человеке, граница нарушена.

\n
const broken = createFixedInterviewInput(\n  'fixed-unsupported-interpretation-v1'\n);\nconst rejected = inspectEngineeringInterview(broken);\n\nconsole.log(rejected.accepted); // false\nconsole.log(rejected.reasons);  // ['interpretation-not-traceable']\n// Сначала восстановите ссылку на наблюдаемый факт.
\n

Отказ полезнее красивого объяснения без основания. Он показывает, где закончились данные. Учебный код работает только с фиксированными значениями. Он не читает файлы, сеть, аудио, персональные данные или внешние сервисы.

\n

Какие критерии не дублируют друг друга

\n

Три критерия нужны для трёх разных ошибок. Граница доказательства ловит выдуманную причину: заголовок увидели, а правило origin не видели. Безопасность следующего шага ловит поспешное изменение: из симптома сразу сделали новую политику кэша. Техническая коммуникация ловит потерю связи между симптомом и проверкой: вместо маршрута остаётся набор терминов.

\n

Для каждого критерия запишите контрпример. Для границы доказательства это «max-age=0 значит, что origin сломан». Для безопасности — «сразу выставим новый TTL». Для коммуникации — «это сложная инвалидизация кэша». Контрпример проверяет сам критерий. Если его нельзя описать без биографии, интонации или предполагаемого мотива, критерий не относится к технической работе.

\n

Не объединяйте «знает HTTP» и «знает Cache-Control» в разные пункты. Оба проверяют память о термине. Лучше спросить, какой факт нужен для следующего шага. Человек может не вспомнить точное название директивы, но заметить, что одного заголовка недостаточно. Это более полезное наблюдение для задачи с неопределённостью.

\n

Порядок действий

\n
  1. Назовите границу. Запишите, что проба проверяет и чего не проверяет.
  2. Оставьте один симптом. Уберите детали, которые не нужны для формулировки неизвестного.
  3. Запишите факт отдельно. Укажите literal, источник и неизвестное. Не добавляйте причину в поле факта.
  4. Свяжите критерий с наблюдением. Для каждого пункта назовите фразу, шаг или артефакт, который можно увидеть.
  5. Зафиксируйте остановку. Прекратите пробу, если следующий шаг требует незаданного доступа, реальных данных или догадки о намерениях.
  6. Проверьте отрицательный путь. Убедитесь, что неподтверждённая причина, изменение состояния и вывод о человеке прекращают упражнение.
  7. Сформулируйте следующий запрос. Передайте только недостающий технический факт или причину остановки.
\n

Почему вопросы по терминам дают ложную экономию

\n

Термин легко спросить и легко записать. Но ответ «это механизм обновления кэша» почти ничего не говорит о выборе действия. Инженер может правильно помнить определение и всё равно не выяснить, где сформирован заголовок, какой компонент владеет маршрутом и как отменить изменение.

\n

Рабочая проба требует больше подготовки. Нужно убрать лишние подсказки, описать одинаковый вход и заранее определить, какое наблюдение считается достаточным. Зато она показывает ход решения. Ответ «этого заголовка недостаточно; сначала нужен origin rule» оставляет техническую цепочку. Ответ «поменяем TTL» показывает скачок от симптома к изменению.

\n

Одна проба имеет узкую область действия. Она не измеряет всю инженерную компетентность, не заменяет проверку требований роли и не доказывает справедливость отбора. Для другой роли нужны другие симптомы и другие границы. Сопоставлять можно только записи с одинаковым входом и одинаковыми критериями.

\n

Ограничения и критерий готовности

\n

Фиксированные значения не являются данными кандидата. Учебный результат не является решением о найме. Поведение на одной карточке нельзя переносить на проект без отдельной проверки. Внешние источники ниже объясняют структурированный формат интервью и смысл заголовка HTTP; они не подтверждают конкретную рубрику или эффект этой учебной модели.

\n

Проба готова, если по одной записи можно ответить на четыре вопроса: какой симптом дан, какой факт наблюдался, чего не хватает и почему следующий шаг безопасен. Дополнительный критерий проверяемости: на допустимом входе остаётся запрос недостающего факта, а на каждом запрещённом входе появляется конкретная причина остановки. Если запись требует догадки, оценки личности или доступа к настоящей системе, проба не готова.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/085.json b/editorial/agent-rewrites/085.json new file mode 100644 index 0000000..8cccaf4 --- /dev/null +++ b/editorial/agent-rewrites/085.json @@ -0,0 +1,7 @@ +{ + "index": 85, + "slug": "editorial-2025-08-field-tool-ux-research", + "title": "Как исследовать UX внутреннего инструмента и не принять мнение за факт", + "excerpt": "Нейтральная задача, короткая заметка и прослеживаемое решение помогают понять, где внутренний инструмент мешает работе. Разбираем границы согласия, отрицательные ветки и критерий готовности до изменения интерфейса.", + "contentHtml": "

Команда получает просьбу «сделать форму заявки удобнее» и сразу обсуждает новую кнопку. Симптом заметен: в карточке задачи есть цитаты коллег, но нет исходного сценария, точного наблюдения и границы согласия. Непонятно, что человек действительно сделал, а что автор уже объяснил за него.

\n

Цена ошибки — не только лишняя кнопка. Команда тратит разработку на решение, которое нельзя связать с проблемой. После релиза коллега снова спрашивает, кто отвечает за заявку и что означает её статус. В ответ появляются новые подсказки, ручные обходы и ещё один цикл обсуждений.

\n

Тезис: UX-исследование внутреннего инструмента должно передавать в разработку не «инсайт», а короткую проверяемую цепочку: разрешённый материал → нейтральная задача → наблюдение → код → неопределённость → решение → следующий сценарий. Если звено нельзя открыть и проверить, изменение остаётся гипотезой.

\n

Механизм: отделить наблюдение от решения

\n

Начните с одной рабочей задачи. Например, участник должен найти состояние заявки на доступ и назвать владельца следующего вопроса. Не называйте будущий блок, цвет, кнопку или термин, который хотите проверить. Так человек может показать неожиданный маршрут: сразу найти владельца, задержаться на статусе или использовать внешний список.

\n

Заранее запишите исходное состояние и условие успеха. В примере карточка содержит номер заявки, статус и поле владельца. Успех означает, что участник назвал следующий вопрос и адресата. Успех не означает, что интерфейс ему понравился, что он работал быстро или что такой путь типичен для всех.

\n

Следующий слой — согласие. Зафиксируйте цель, допустимый тип evidence, запись, доступ и отзыв. Если разрешены только обезличенные заметки, запись экрана не становится допустимой из-за удобства анализа. Отзыв останавливает работу с материалом по правилам организации. В учебном примере ниже нет реальных людей и данных; он показывает только порядок проверки.

\n

Наблюдение описывает действие или вопрос. Фраза «курсор остановился у поля owner, затем прозвучал вопрос “кто отвечает после отправки?”» годится как observation. Фраза «человеку непонятен интерфейс» уже содержит интерпретацию. Её можно получить позже как тему, но нельзя выдавать за факт.

\n

Код даёт наблюдаемой детали короткое имя. owner-unclear означает, что в этом сценарии не найден следующий владелец. Он не объясняет причину, не измеряет частоту и не доказывает, что проблема относится к каждому пользователю. Поле uncertainty сохраняет это ограничение рядом с наблюдением.

\n

Decision log связывает решение с observation id. Запись может предложить проверить компактное пояснение статуса и владельца. Она не должна говорить «пояснение улучшит UX». Корректная формулировка — «проверить кандидатное изменение на том же сценарии». Это сохраняет отрицательный путь: при сломанной ссылке на наблюдение, withdrawn consent или изменённой задаче нужно остановиться.

\n

Учебный пример: одна карточка и два наблюдения

\n

Ниже — учебный пример. Все значения условны и служат только для иллюстрации связи между полями. Они не описывают production, не заменяют согласие и не дают результата о реальных пользователях.

\n
const research = {\n  consent: {\n    evidence: 'de-identified-note',\n    recording: 'not-permitted',\n    withdrawal: 'stop-and-discard'\n  },\n  scenario: {\n    task: 'Найти статус заявки и владельца следующего вопроса',\n    success: 'Назвать следующий вопрос и owner',\n    prompt: 'Покажите, как вы разбираетесь со статусом заявки'\n  },\n  observations: [\n    {\n      id: 'obs-owner-1',\n      statement: 'Курсор остановился у owner; задан вопрос о следующем ответственном',\n      code: 'owner-unclear',\n      uncertainty: 'Одна заметка не показывает частоту и причину'\n    },\n    {\n      id: 'obs-status-1',\n      statement: 'Термин processing прочитан, но не связан со следующим действием',\n      code: 'status-hidden',\n      uncertainty: 'Нельзя заключить, что термин непонятен всем'\n    }\n  ],\n  decision: {\n    observationIds: ['obs-owner-1', 'obs-status-1'],\n    candidateChange: 'Проверить компактный блок status и owner',\n    claim: 'not-established',\n    followUp: 'Повторить тот же нейтральный сценарий'\n  }\n};
\n

Смысл примера не в формате JavaScript. Важна последовательность. candidateChange не становится задачей на безусловную разработку. Сначала проверяющий открывает оба observation id, читает факты и ограничения, затем проверяет, что follow-up сохраняет цель и исходное состояние.

\n

Если решение ссылается на obs-owner-2, которого нет, нельзя восстановить происхождение идеи. Не следует угадывать ссылку по похожему тексту. Два безопасных действия — найти исходную заметку или остановить решение и завести новый нейтральный сценарий.

\n

Симптом → причина → проверка → действие

\n
Диагностика исследовательского материала перед изменением интерфейса
СимптомПричинаПроверкаДействие
В заметке есть только оценка экранаВопрос подсказал готовое решениеПрочитать prompt без макета и найти действие участникаПовторить сценарий нейтральной задачей
Цитата не связана с задачейЗапись собрали после обсуждения, без scenario idПроверить исходное состояние и условие успехаПометить материал как контекст, не как evidence решения
Решение ссылается на неизвестный observationЗаметки и ticket живут раздельноОткрыть каждый observation id из decision logОстановить hand-off и восстановить источник
Анализ требует запись экранаГраница согласия была задана слишком поздноСверить permitted evidence и фактический материалНе использовать запись; уточнить policy до нового раунда
После правки задан другой вопросИзменились цель или исходное состояниеСравнить objective, starting state и promptНе объявлять эффект; спланировать сопоставимый follow-up
Две заметки превращены в «проблему всех»Неопределённость потеряна при обобщенииПрочитать uncertainty рядом с каждой observationОставить claim ограниченным и собрать следующий материал
\n

Иллюстрация цепочки

\n
\"Цикл
Цепочка не выпускает изменение автоматически. Она показывает, какое звено нужно открыть, чтобы проверить решение, и где процесс должен остановиться.
\n

Такая схема полезна как контрольная точка проверки. Consent boundary отвечает на вопрос «какой материал можно использовать». Scenario отвечает на вопрос «какую работу наблюдаем». Observation отвечает на вопрос «что произошло». Code помогает сортировать детали. Uncertainty ограничивает вывод. Decision формулирует следующий шаг, а не скрытый результат.

\n

Порядок действий

\n
  1. Назовите рабочую задачу. Запишите роль, исходное состояние и наблюдаемый критерий окончания. Не начинайте с названия компонента.
  2. Откройте границу согласия. Укажите purpose, допустимые заметки или записи, доступ и порядок отзыва. Если boundary неясна, не читайте материал для принятия решения.
  3. Сформулируйте нейтральный prompt. Просите показать, как человек выполняет задачу. Уберите подсказку о кнопке, цвете, термине и желаемом ответе.
  4. Запишите наблюдаемое. Отделите действие, паузу и вопрос от своей интерпретации. Одна заметка должна содержать одну проверяемую деталь.
  5. Добавьте code и uncertainty. Код называйте коротко. Рядом укажите, чего эта заметка не устанавливает: частоту, причину, универсальность или эффект.
  6. Соберите decision log. Перечислите observation ids, тему, маленькое кандидатное изменение и claim со скромной силой. Ссылка должна открывать конкретный материал.
  7. Проверьте отрицательные ветки. Withdrawn consent, evidence вне разрешённой границы, leading prompt и отсутствующий id дают STOP, а не догадку и не автоматическое исправление.
  8. Назначьте сопоставимый follow-up. Сохраните цель, исходное состояние и нейтральную задачу. Отдельно опишите, что можно будет наблюдать и чего результат всё ещё не докажет.
\n

Ограничения

\n

Нейтральный сценарий не устраняет влияние ведущего. Интонация, порядок действий и знакомство с автором всё равно меняют поведение. Поэтому полезно приглашать отдельного наблюдателя и фиксировать условия, но не называть это устранением bias.

\n

Короткий журнал не делает выборку представительной. Две заметки могут дать хорошую гипотезу для следующего раунда и не дать оснований для вывода о всей организации. Внутренние пользователи тоже различаются по роли, доступам и опыту. Если эти условия важны для решения, их нужно проверять отдельно.

\n

Обезличивание не равно отсутствию риска. Имя, команда, заявка и редкое событие могут вместе указать на человека. Реальная организация отдельно определяет владельца данных, хранение, доступ, срок и порядок удаления. В этом материале учебные значения не содержат персональных данных.

\n

Локальный проверяющий скрипт или таблица может подтвердить структуру записи: наличие полей, допустимый тип evidence и целостность ссылок. Он не проверяет качество разговора, честность заметки, поведение браузера или эффект интерфейса. PASS такой модели означает только прохождение её собственных проверок.

\n

Проверяемый критерий готовности

\n

Материал готов к обсуждению кандидатного изменения, если проверяющий за один проход может открыть consent boundary, нейтральный scenario и каждое observation из decision log. Каждое observation описывает действие или вопрос, имеет допустимый code и явную uncertainty. Claim не обещает улучшение. Follow-up сохраняет цель и исходное состояние. При withdrawn consent, недопустимой записи, leading prompt или сломанной ссылке система возвращает STOP.

\n

Если хотя бы одно условие не выполнено, готово не изменение интерфейса, а список недостающих доказательств. Это полезный результат: команда видит, что нужно проверить дальше, и не маскирует пробел в исследовании новой формулировкой кнопки.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/086.json b/editorial/agent-rewrites/086.json new file mode 100644 index 0000000..2448897 --- /dev/null +++ b/editorial/agent-rewrites/086.json @@ -0,0 +1,7 @@ +{ + "index": 86, + "slug": "editorial-2025-08-mechanism-tool-ux-research", + "title": "UX внутреннего инструмента: от наблюдения к проверяемому решению", + "excerpt": "Внутренний интерфейс ломается не только из-за плохой кнопки. Разбираем цепочку consent → observation → code → theme → decision, отрицательные ветки и критерий готовности.", + "contentHtml": "

После короткого интервью в задаче часто остаётся фраза «коллеге неудобно», а рядом уже стоит готовый редизайн. Симптом виден: решение не содержит исходного действия, условий сценария и того, чего наблюдение не установило. Через неделю никто не может восстановить связь между заметкой и изменением. Цена ошибки — лишнее время и новый обход того же участка.

\n

Внутренний инструмент нужно исследовать через границу доказательства. Сначала фиксируют, что разрешено наблюдать и хранить. Затем записывают видимое действие. Потом группируют записи, формулируют вопрос и только после этого выбирают следующий эксперимент. Цепочка выглядит так: consent → observation → code → theme → decision. Каждый следующий уровень допускает более сильное решение, но не стирает ограничения предыдущего.

\n

Почему одна цитата не объясняет проблему

\n

Фраза «не нашёл владельца» — это не причина и не предложение для интерфейса. Это наблюдение, если оно привязано к задаче: человек прочитал статус, дошёл до поля owner и задал вопрос о следующем ответственном. Запись не доказывает, что термин плох, что все пользователи теряются или что нужна кнопка «Написать владельцу».

\n

У наблюдения должны быть идентификатор сценария, видимое действие, короткий code и uncertainty. Code собирает похожие факты. Он не ставит диагноз. Например, owner-unclear означает только, что в данном маршруте не удалось найти владельца следующего шага. Uncertainty говорит, чего запись не установила: частоту, причину и переносимость на другие роли.

\n

Механизм и граница силы вывода

\n

Consent boundary. До разговора определяют цель, тип evidence, наблюдение, запись и путь отзыва. Для внутреннего сотрудника действует та же необходимость добровольного согласия, что и для внешнего участника. Если разрешены обезличенные заметки, запись экрана не появляется «для удобства анализа». Отозванное согласие останавливает hand-off и требует следовать правилам хранения организации.

\n

Observation. Запись описывает видимое действие или вопрос в заданном сценарии. «Курсор остановился у owner» сильнее, чем «человек растерялся»: первое можно проверить по маршруту, второе уже содержит интерпретацию.

\n

Code и theme. Code даёт стабильное имя детали. Theme объединяет несколько codes в проверяемый вопрос. Два наблюдения про owner и статус могут образовать theme next-step-visibility: видит ли исполнитель, что делать после текущего состояния. Theme не отвечает, какая кнопка нужна.

\n

Decision. Решение выбирает candidate change и следующий follow-up. Оно ссылается на observation ids, содержит claim и отмечает статус not-established, если эффект ещё не проверен. Decision без ссылок — список предпочтений. Decision с отсутствующей ссылкой получает STOP.

\n
Матрица связи observation, code, theme и decision: неполная граница consent или отсутствующая ссылка останавливает переход к candidate change
Матрица показывает переход от двух synthetic observations к теме и следующему эксперименту. Она не показывает процент удобства и не доказывает эффект изменения.
\n

Учебный пример с отрицательной веткой

\n

Ниже — только фиксированный synthetic пример. В нём нет реальных людей, заявок, записей, API, telemetry и production-данных. Есть карточка access request, статус processing и поле owner. Нейтральная задача просит показать, как найти состояние заявки и назвать следующий вопрос. Ведущий не подсказывает будущую кнопку.

\n
const observation = { id: 'synthetic-observation-owner-v1', scenarioId: 'synthetic-scenario-access-request-v1', evidenceKind: 'de-identified-note', statement: 'Статус прочитан; курсор остановился у owner; следующий вопрос: кто отвечает после отправки?', codeIds: ['code-owner-unclear-v1'], uncertainty: 'Не установлены частота и причина остановки.' }; const decision = { observationIds: ['synthetic-observation-owner-v1', 'synthetic-observation-status-v1'], theme: 'next-step-visibility', candidateChange: 'Добавить компактный блок следующего шага.', claim: 'not-established', followUp: 'Повторить тот же neutral scenario.', status: 'synthetic-follow-up-only' };
\n

В нормальной ветке инспектор проверяет, что оба observation id существуют, code есть в codebook, scenario нейтрален, а consent разрешает именно этот тип заметки. Результат — не «интерфейс улучшен», а разрешение на изолированный follow-up. В учебном примере нужно повторить ту же задачу с заранее описанным изменением и заново записать наблюдаемое действие.

\n

В отрицательной ветке decision ссылается на missing-observation-v1. Инспектор возвращает decision-not-traceable-to-evidence и не чинит ссылку автоматически. Владелец восстанавливает исходную запись или создаёт новый сценарий. Если consent имеет статус withdrawn, проверка останавливается раньше темы. Если prompt звучит как «вам ведь не хватает большой кнопки?», результат — neutral-task-scenario-required. Удачный ответ на наведённый вопрос не становится evidence.

\n

Симптом → причина → проверка → действие

\n
Диагностика цепочки от наблюдения до решения
СимптомПричинаПроверкаДействие
В задаче сразу нарисована кнопкаTheme подменили candidate changeЕсть ли два observation с видимым действием?Вернуться к нейтральному сценарию и записать uncertainty
В заметке написано «пользователь запутался»Интерпретация смешалась с фактомМожно ли описать действие без оценки?Переписать statement и сохранить контекст
Решение выглядит убедительно, но ссылок нетDecision вырос из памяти встречиКаждый cited id находится в том же наборе?Остановить hand-off до восстановления evidence
Для анализа включили запись экранаТип evidence расширили после согласияРазрешены ли запись и наблюдатели в boundary?Не использовать запись; сверить policy и consent
После изменения объявили «UX улучшился»Follow-up выдали за результатЕсть ли сравнимый сценарий и измеримый критерий?Поставить claim not-established и назначить отдельную проверку
\n

Порядок работы для одной UX-задачи

\n
  1. Открыть boundary. Записать цель, допустимый тип evidence, хранение, наблюдателей и порядок отзыва.
  2. Описать neutral scenario. Назвать исходное состояние, действие и условие успеха. Убрать из prompt будущую кнопку и оценку участника.
  3. Сделать observation. Записать действие или вопрос, scenario id и uncertainty. Не называть причину, которую человек не показал.
  4. Применить code. Выбрать существующую метку из codebook. Если метки нет, не расширять вывод одной записью.
  5. Сформулировать theme. Объединить несколько наблюдений в вопрос и назвать альтернативу. Theme должна допускать отрицательный ответ.
  6. Собрать decision log. Добавить candidate change, observation ids, claim и follow-up. Проверить каждую ссылку, consent и статус.
  7. Запустить следующий тест. Повторить сопоставимый сценарий. Не объявлять эффект до заранее заданного критерия.
\n

Ограничения и отрицательный путь

\n

Две заметки не дают частоту, репрезентативность или объяснение задержки. Codebook не заменяет исследователя. Neutral prompt не устраняет социальное давление во внутренней команде. Обезличивание не отменяет требований к доступу, сроку хранения и отзыву. Для чувствительных данных нужны policy организации, владелец данных и юридическая проверка.

\n

Механизм не выбирает лучший интерфейс. Он не даёт слабому evidence незаметно превратиться в backlog ticket. Candidate change может оказаться неверным. При сломанной ссылке, неподтверждённом consent, наведённом prompt или несопоставимом follow-up результатом должен быть STOP, а не новая гипотеза.

\n

Проверяемый критерий готовности

\n

UX-разбор готов, если независимый reviewer может пройти от decision до каждой observation и обратно к одному neutral scenario. У каждой observation есть видимое действие, scenario id, code и uncertainty. У decision существуют все cited ids, есть claim not-established до проверки эффекта и указан следующий сопоставимый сценарий. Boundary разрешает использованный evidence. Для withdrawn consent или любой сломанной ссылки проверка возвращает STOP. Это проверяемый контракт, а не обещание, что интерфейс уже стал удобнее.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/087.json b/editorial/agent-rewrites/087.json new file mode 100644 index 0000000..af92f0f --- /dev/null +++ b/editorial/agent-rewrites/087.json @@ -0,0 +1,7 @@ +{ + "index": 87, + "slug": "editorial-2025-08-practice-tool-ux-research", + "title": "UX внутреннего инструмента: как найти реальную проблему до правки интерфейса", + "excerpt": "Нейтральный сценарий, наблюдаемое действие и короткий decision log помогают отличить проблему рабочего процесса от просьбы добавить ещё одну кнопку.", + "contentHtml": "

Команда получает просьбу «сделать внутренний инструмент удобнее». На встрече сразу показывают макет большой кнопки связи с владельцем заявки. Участник кивает, а в заметке появляется фраза «кнопка нужна». Это наблюдаемый симптом: исследователь проверяет уже выбранное решение, а не работу по задаче.

\n

Цена ошибки видна после релиза. Инженер тратит время на локальную правку. Коллега по-прежнему не знает, где искать статус или кому передать исключение. Новая кнопка добавляет поверхность, но не убирает неопределённость. Следующая встреча снова начинается с цитаты и нового предложения по интерфейсу.

\n

Тезис простой: UX внутреннего инструмента нужно проверять через маршрут задачи. Сначала зафиксируйте, что человек пытается сделать. Затем дайте нейтральный сценарий и запишите видимое действие, вопрос и неизвестное. Только после этого формулируйте изменение как гипотезу. Такое исследование не обещает эффект. Оно делает решение проверяемым и останавливает правку, если evidence не хватает.

\n

Механизм: от задачи к решению

\n

У рабочего разбора есть четыре разных объекта. Сценарий задаёт исходное состояние и понятный результат. Наблюдение описывает действие или вопрос в этом сценарии. Тема объединяет несколько наблюдений, но не объясняет их автоматически. Решение предлагает следующий тест, а не объявляет интерфейс улучшенным.

\n

Например, сценарий звучит так: «Найдите текущее состояние одной заявки и назовите владельца следующего вопроса». Исходное состояние известно: карточка содержит номер, статус и поле владельца. Успех тоже известен: человек называет следующий вопрос и ответственного. В сценарии нет слов «нажмите кнопку связи» и «оцените новый блок». Он допускает ответ, неудобный автору макета.

\n
Карта доказательств UX-исследования внутреннего инструмента: согласие, нейтральный сценарий, наблюдение, код, тема и следующий проверяемый шаг
Цепочка evidence отделяет наблюдение от решения. Решение разрешает следующий тест, но не доказывает эффект интерфейса.
\n

Согласие задаёт границу материала. Перед сессией участник должен понимать цель, собираемые данные, наблюдение или запись, использование результата и возможность остановиться. Для внутреннего инструмента это важно не меньше, чем для внешнего сервиса: знакомство с коллегой не отменяет добровольность. Если разрешены только обезличенные заметки, не добавляйте запись экрана «для удобства анализа».

\n

Наблюдение должно быть скучным и точным: «прочитал статус, остановил курсор у поля owner, спросил, кто отвечает после отправки». Запись «растерялся» уже содержит трактовку. Код owner-unclear может помочь найти похожие записи, но не объясняет причину и не показывает частоту. Рядом укажите uncertainty: «неизвестно, не виден ли владелец, непонятен ли термин или не хватает контекста заявки».

\n

Конкретный формат записи

\n

Короткая структура защищает от пересказа встречи. Её можно хранить в задаче исследования или в согласованном хранилище заметок. Учебный пример ниже не содержит реальных людей, заявок и production-данных.

\n
const observation = {\n  scenario: 'find-status-and-next-owner',\n  action: 'прочитан status; курсор остановился у owner',\n  question: 'кто отвечает после отправки?',\n  code: 'owner-unclear',\n  uncertainty: 'неизвестна причина и частота вопроса',\n};\n\nconst decision = {\n  evidence: ['observation-01'],\n  hypothesis: 'проверить пояснение status и next owner',\n  nextCheck: 'повторить тот же сценарий без подсказки',\n  claim: 'эффект не установлен',\n};
\n

В этом примере hypothesis не превращается в задачу «срочно добавить блок». Сначала нужно решить, что именно проверять: видимость владельца, язык статуса, порядок полей или отсутствие перехода к следующему действию. Если одна заметка не различает варианты, следующий шаг — ещё один нейтральный сценарий, а не выбор идеи по громкости голоса.

\n

Симптом → причина → проверка → действие

\n
Как разбирать слабое UX-доказательство
СимптомПричинаПроверкаДействие
Все соглашаются с макетомВопрос подсказывает решениеУбрать название кнопки и дать задачуПереписать сценарий без UI-ответа
В заметке есть только цитатаНе записан маршрут работыСпросить, что было видно до и после фразыДобавить действие, вопрос и исходное состояние
Появился один «инсайт»Факт смешали с объяснениемОтделить observation, code и uncertaintyНазвать тему как гипотезу
Решение нельзя перепроверитьНет ссылки на исходную заметкуОткрыть каждый evidence idОстановить hand-off и восстановить связь
После правки «стало лучше»Изменились сценарий и вопросСравнить исходное состояние и критерий успехаПовторить тот же маршрут или признать сравнение несостоятельным
\n

Отрицательный путь важнее удачной цитаты

\n

Представим, что ведущий начинает так: «Вам ведь не хватает большой кнопки “Написать владельцу”?» Участник может согласиться из вежливости. Даже несогласие будет слабым: вопрос уже сузил пространство ответов. Такой материал нельзя использовать как подтверждение кнопки.

\n

Правильный отрицательный путь должен останавливать решение. Если сценарий ведущий, согласие не подтверждено, заметка выходит за разрешённую границу или decision ссылается на несуществующее наблюдение, исследование не переходит к изменению интерфейса. Не надо лечить пробел догадкой. Верните материал владельцу и запросите новый нейтральный проход.

\n

То же правило действует для отзыва согласия. Если участник остановился, прекратите сессию и примените согласованный процесс удаления или ограничения материалов. Код в задаче может обозначить статус «stop», но он не заменяет политику хранения и юридическую проверку. Учебная схема показывает порядок принятия решения, а не готовую процедуру для организации.

\n

Порядок действий

\n
  1. Сформулируйте неизвестное. Запишите вопрос вроде «почему человек не называет следующий шаг», а не решение «нужна кнопка связи».
  2. Определите границу данных. До сессии зафиксируйте цель, тип заметки или записи, доступ, срок хранения и способ отзыва.
  3. Опишите сценарий. Укажите исходное состояние и критерий успеха. Не называйте будущий control и не просите оценить макет.
  4. Наблюдайте маршрут. Запишите последовательность действий, остановку и вопрос. Не заменяйте их оценкой человека.
  5. Закодируйте факт. Дайте короткую метку и сразу добавьте то, чего наблюдение не устанавливает.
  6. Свяжите решение с evidence. В decision log перечислите идентификаторы заметок, тему, гипотезу и следующий сценарий.
  7. Повторите ту же задачу. Изменяйте один проверяемый элемент и сохраняйте исходное состояние и критерий успеха. Если условия изменились, не называйте результат сравнением.
\n

Как читать результат

\n

Две заметки с одинаковым вопросом — повод исследовать тему, но не доказательство проблемы всей команды. Даже несколько повторов не устанавливают причинность сами по себе. Участники могут отличаться ролью, опытом, правами доступа и частотой работы. Внутренний инструмент также связан с регламентом, данными и соседними системами. Интерфейс не всегда является источником задержки.

\n

Разделяйте три утверждения. «Человек остановился у owner» — наблюдение. «Следующий ответственный плохо виден» — рабочая гипотеза. «Новый блок сократил время» — измеряемое утверждение, для которого нужны заранее определённая метрика, условия сравнения и достаточный объём данных. Не подменяйте третье первым.

\n

Есть и социальное ограничение. Коллега может помогать автору, потому что знает его по работе. Нейтральная формулировка, пауза и отсутствие макета уменьшают давление, но не устраняют его. Поэтому молчаливое действие, обход и вопрос часто полезнее оценки «нравится». Если человек хвалит новый блок, но не завершает задачу, фиксируйте маршрут.

\n

Проверяемый критерий готовности

\n

Материал готов к следующему шагу, когда другой инженер без устного пересказа может восстановить цепочку: цель и consent boundary, исходное состояние, нейтральный сценарий, наблюдаемое действие, code, uncertainty, ссылка на decision и критерий следующей проверки. В decision явно написано, чего данные не доказывают. Отрицательные ветки имеют остановку и владельца восстановления.

\n

Материал не готов, если в нём осталась только удачная цитата, название любимой кнопки, диагноз пользователя или обещание production-эффекта. В этом случае задача должна вернуться к исследовательскому вопросу. Практический тест занимает несколько минут: удалите автора записи и попросите коллегу объяснить, что было проверено и что будет проверено дальше. Если он может назвать только макет, evidence не выдерживает hand-off.

\n

Ограничения

\n

Метод не заменяет доступность, исследование с разными ролями, анализ событий, нагрузочное тестирование и проверку прав доступа. Он не даёт репрезентативную выборку и не устанавливает юридические требования к данным. Руководства ниже описывают общие принципы user research; правила вашей организации могут быть строже. Примеры в статье учебные. Они не сообщают production-результаты и не доказывают, что конкретная кнопка улучшит внутренний инструмент.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/088.json b/editorial/agent-rewrites/088.json new file mode 100644 index 0000000..b32bc4c --- /dev/null +++ b/editorial/agent-rewrites/088.json @@ -0,0 +1,7 @@ +{ + "index": 88, + "slug": "editorial-2025-07-field-product-metrics", + "title": "Когда рост conversion не означает улучшение продукта", + "excerpt": "Conversion имеет смысл только вместе с определением событий, cohort, периодом, denominator и guardrail. Разбираем механизм ошибки, отрицательные пути и проверяемый критерий готовности.", + "contentHtml": "

На дашборде растёт conversion, а число ошибок рендера растёт вместе с ним. Пользователь открывает форму повторно, но повторное открытие уже не попадает в denominator. Команда видит красивую дробь и оставляет новый вариант. Через день выясняется, что сравнивались разные множества событий. Цена ошибки — неверное решение, повторный сбор данных и часы спора о том, что именно измерила система.

\n

Тезис простой: метрика становится основанием для решения только вместе с условиями её получения. Нужно связать изменение, событие, cohort, period, numerator, denominator и guardrail. Если связь не доказана, расчёт должен остановиться с понятной причиной. Неполный результат лучше честного на вид числа, которое отвечает на другой вопрос.

\n

Механизм ошибки

\n

Conversion — это отношение numerator к denominator. Например, numerator может считать уникальных субъектов с событием checkout_confirmed, а denominator — уникальных субъектов с событием checkout_opened. Дробь отвечает на вопрос «какая доля открывших подтвердила действие» только при одинаковых правилах отбора.

\n

Cohort задаёт сравниваемую группу: control или treatment. Period задаёт единое окно времени. Attribution связывает подтверждение с конкретным открытием и вариантом. Guardrail показывает ущерб, который не должен расти ради локального улучшения. Если один элемент выпадает, значение conversion меняет смысл.

\n

Предзагрузка формы может сократить ожидание. Она не доказывает, что пользователь чаще завершает действие. Для такого вывода нужны как минимум открытие, подтверждение и правило их связи. Отдельно нужно считать сбой рендера, отмену или другой риск, который может скрыться за ростом локальной метрики.

\n

Контракт события

\n

Событие должно иметь стабильное имя и отдельные атрибуты. Динамические значения нельзя зашивать в имя: запрос не сможет надёжно сгруппировать такие записи. OpenTelemetry формулирует то же правило для semantic conventions: имя события должно однозначно описывать структуру, а переменные значения должны жить в attributes.

\n
event: product.checkout_opened; subject: subject-a; cohort: treatment; period: 2025-07-14; requestId: request-2;
\n

Вызов подтверждения должен содержать совместимые поля: event, subject, cohort, period и requestId. Тогда запрос может проверить, что открытие и подтверждение относятся к одному субъекту, одной попытке и одному окну. Если requestId отсутствует, запрос не должен угадывать связь.

\n

Значения в примере фиксированы и нужны только для объяснения механизма. Они не задают политику идентификации. В реальной системе отдельно определяют допустимый идентификатор, срок хранения, доступ к данным и правила обработки задержанных событий. Нельзя переносить строку subject-a в действующий сбор без такой проверки.

\n

Конкретный расчёт

\n

Возьмём малый набор событий. В control два открытия и одно подтверждение. В treatment два открытия и одно подтверждение. В control один экран завершился событием product.render_failed. Расчёт считает уникальных субъектов внутри cohort и period.

\n
const events = [\n  { event: 'product.checkout_opened', subject: 'a', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\\n  { event: 'product.checkout_confirmed', subject: 'a', cohort: 'control', period: '2025-07-14', requestId: 'r-1' },\\n  { event: 'product.checkout_opened', subject: 'b', cohort: 'treatment', period: '2025-07-14', requestId: 'r-2' },\\n  { event: 'product.checkout_confirmed', subject: 'b', cohort: 'treatment', period: '2025-07-14', requestId: 'r-2' },\\n  { event: 'product.checkout_opened', subject: 'c', cohort: 'treatment', period: '2025-07-14', requestId: 'r-3' },\\n  { event: 'product.checkout_opened', subject: 'd', cohort: 'control', period: '2025-07-14', requestId: 'r-4' },\\n  { event: 'product.render_failed', subject: 'd', cohort: 'control', period: '2025-07-14', requestId: 'r-4' }\n];
\n

В treatment conversion равна 1/2. В control conversion тоже равна 1/2. Для control guardrail равен 1/2. Эти значения показывают только работу дроби на фиксированных данных. Они не оценивают эффект, статистическую значимость или поведение пользователей.

\n

Смысл примера раскрывается на ошибочном пути. Если подтверждение приходит без requestId, его нельзя приписать открытию. Если одно событие имеет следующий period, его нельзя молча объединить с предыдущим. Если denominator заменили на all-events, старое имя conversion больше не описывает новый расчёт.

\n

Симптом → причина → проверка → действие

\n
Проверки перед интерпретацией метрики
СимптомПричинаПроверкаДействие
Conversion выросла после изменения запросаИзменился denominator или deduplicationВывести numerator и denominator по cohortОстановить сравнение и исправить правило inclusion
Подтверждение есть, вариант не определёнНет attribution или requestIdПроверить subject, requestId и cohortВернуть ошибку владельцу событий
Группы имеют разные датыСмешан period или пришли задержанные событияПроверить period каждой записи до агрегацииРазделить окна или собрать данные заново
Local metric растёт вместе с ошибкамиGuardrail считает другую populationПосчитать failure на том же срезеНе считать локальный рост улучшением
Расчёт нельзя повторитьDefinition хранится только в сообщенияхВосстановить запрос по полям метрикиЗафиксировать definition, owner и limitation
\n

Таблица задаёт маршрут диагностики. Она не заменяет проверку данных. Каждый ответ должен вести к действию: пересчитать дробь, исправить событие, разделить period, пересмотреть guardrail или остановить решение.

\n

Иллюстрация цепочки доказательств

\n
\"Цепочка
Схема показывает, какие условия нужно проверить до решения. Финальная остановка сохраняет причину, которую можно исправить.
\n

Человек находится в конце цепочки не случайно. Код может проверить обязательные поля, период, denominator и известные отрицательные пути. Он не может по одной дроби выбрать допустимый продуктовый риск. Локальный рост может сопровождаться отказами, отменами или недоступностью. Guardrail делает такую цену видимой, но не выбирает порог вместо владельца.

\n

Отрицательный путь в коде

\n

Проверяющая функция должна отклонять неполный контракт. Ниже приведён ограниченный пример с фиксированными данными в памяти. Он не читает сеть или файл и не отправляет события.

\n
const report = inspectProductMetric(\\n  createProductMetricInput('mixed-period-example'),\\n);\\n\\nconst decision = prepareProductDecision(report);\\n\\nconsole.log(decision);\\n// {\\n//   status: 'stop-before-decision',\\n//   reasons: ['mixed-cohort-or-period']\\n// }
\n

Смешанный period не превращается в null и не замазывается средним. Проверка возвращает короткую причину. Она направляет действие: владелец событий проверяет instrumentation, владелец запроса проверяет population, владелец варианта проверяет cohort и окно.

\n

То же правило действует для неверного denominator и отсутствующего attribution. Ветка wrong-denominator не чинит запрос автоматически и не разрешает сохранить старое имя метрики. Ветка missing-attribution-rule не угадывает связь между открытием и подтверждением. Такая строгость защищает от убедительного числа, собранного из несвязанных фактов.

\n

Порядок действий

\n
  1. Назвать решение. Записать, что можно оставить, остановить или повторить. Не начинать с самого удобного графика.
  2. Описать цепочку. Связать изменение с событием, действием, outcome и возможным ущербом. Отделить гипотезу от наблюдаемого факта.
  3. Зафиксировать event contract. Записать имена событий, обязательные поля, deduplication, attribution, cohort и period.
  4. Разложить ratio. Показать numerator и denominator отдельно для каждой группы. Проверить одно правило inclusion.
  5. Положить рядом guardrail. Использовать тот же срез или явно назвать отличие. Указать владельца порога и error path.
  6. Проверить отрицательные входы. Подать missing attribution, mixed period и wrong denominator. Каждый случай должен вернуть остановку с причиной.
  7. Сохранить доказательства. Оставить definition, запрос, источник данных, limitation, owner и дату следующей проверки. Если другой инженер не восстановит расчёт, вернуться к шагу 3.
\n

Ограничения

\n

Контракт событий не доказывает причинность. Он не заменяет randomization, расчёт мощности, проверку задержки доставки, privacy, retention и качества identity. Guardrail не делает эксперимент безопасным автоматически. Он заранее называет ущерб, который нельзя скрыть локальным ростом.

\n

Малый набор не говорит о размере эффекта. В нём нет реальных пользователей, денег, сети, clock, действующего запроса или инцидента. Его можно использовать для детерминированной проверки веток и отсутствия побочных действий. Нельзя выдавать его значения за наблюдение действующего продукта.

\n

Корректный контракт тоже может устареть после изменения клиента, схемы или pipeline. Проверяйте смысл события рядом с кодом отправки и запросом. Если событие меняет смысл, меняйте definition и отмечайте несовместимость. Молчаливое сохранение старого названия опаснее явной остановки.

\n

Проверяемый критерий готовности

\n

Измерение готово к продуктовому решению, если другой инженер по одной карточке восстанавливает population, numerator, denominator, attribution, period и guardrail. Он видит входящие события, условие остановки и владельца каждой ошибки. Если нужен устный контекст, контракт измерения ещё не готов.

\n

Минимальный проверяемый результат таков: корректный фиксированный пример получает статус needs-human-decision; missing attribution, mixed period и wrong denominator получают stop-before-decision с разными причинами; ни один вызов не отправляет данные и не меняет состояние системы. Для действующего проекта добавьте отдельные проверки схемы, задержки, прав доступа и повторяемости запроса.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/089.json b/editorial/agent-rewrites/089.json new file mode 100644 index 0000000..47d7b36 --- /dev/null +++ b/editorial/agent-rewrites/089.json @@ -0,0 +1,7 @@ +{ + "index": 89, + "slug": "editorial-2025-07-mechanism-product-metrics", + "title": "Рост метрики не равен улучшению продукта: проверяем denominator и guardrail", + "excerpt": "Практический способ проверить продуктовую метрику до решения: зафиксировать событие, cohort, attribution, окно и denominator, затем сопоставить локальный сигнал с guardrail и остановить вывод при разрыве данных.", + "contentHtml": "

На дашборде treatment показывает conversion 52%, а control — 48%. Команда готовит выпуск. Через день выясняется, что в treatment считали уникальных открывших экран, а в control — все строки события. Ещё часть подтверждений пришла без связи с вариантом. Числа выглядят аккуратно, но сравнивают разные множества.

\n

Цена ошибки — не только неверный график. Команда может раскатить изменение, которого пользователь не заметил, потерять доверие к аналитике и потратить следующий спринт на поиск причины. Если рядом выросли отказы, задержка или отмены, локальный рост conversion скрывает ущерб.

\n

Тезис простой: метрика становится основанием для решения только вместе с контрактом измерения. Контракт называет субъектов, событие, cohort, attribution, период, numerator, denominator и guardrail. Любое нарушение контракта должно остановить product decision. Пустой или неполный результат не следует трактовать как нулевой эффект.

\n

Механизм: дробь отвечает только на свой вопрос

\n

Запись 52 / 100 ничего не говорит без описания ста субъектов и пятидесяти двух действий. В продуктовой метрике нужно сначала определить population, затем выбрать единицу счёта. Если один пользователь повторил событие три раза, число строк и число пользователей отвечают на разные вопросы.

\n

Учебный пример ниже считает conversion по уникальным субъектам. Numerator — субъекты с checkout_confirmed. Denominator — субъекты с checkout_opened. Обе группы ограничены одним cohort и одним днём. Guardrail считает render_failed среди открывших. Это фиксированные значения для иллюстрации. Они не описывают production и не доказывают эффект.

\n
const events = [\n  { event: 'checkout_opened', subject: 'u-1', cohort: 'control', period: '2025-07-14' },\n  { event: 'checkout_confirmed', subject: 'u-1', cohort: 'control', period: '2025-07-14' },\n  { event: 'checkout_opened', subject: 'u-2', cohort: 'control', period: '2025-07-14' },\n  { event: 'render_failed', subject: 'u-2', cohort: 'control', period: '2025-07-14' },\n  { event: 'checkout_opened', subject: 'u-3', cohort: 'treatment', period: '2025-07-14' },\n  { event: 'checkout_confirmed', subject: 'u-3', cohort: 'treatment', period: '2025-07-14' },\n  { event: 'checkout_opened', subject: 'u-4', cohort: 'treatment', period: '2025-07-14' },\n];\n\nconst unique = (name, cohort) => new Set(\n  events.filter((x) => x.event === name && x.cohort === cohort)\n    .map((x) => x.subject),\n).size;\n\nconst conversion = unique('checkout_confirmed', 'treatment')\n  / unique('checkout_opened', 'treatment');\nconst guardrail = unique('render_failed', 'control')\n  / unique('checkout_opened', 'control');\n\n// Учебный результат: treatment conversion = 0.5,\n// control guardrail = 0.5. Это не production-вывод.
\n

В этом наборе treatment conversion равна 1 из 2, а control guardrail — 1 из 2. Эти дроби нужны, чтобы показать форму вычисления, а не чтобы объявить treatment лучше. В реальной системе дополнительно проверяют распределение вариантов, задержку доставки, повторные события, идентификаторы, окно наблюдения и статистическую неопределённость.

\n

attribution связывает действие с вариантом. Например, подтверждение можно отнести к treatment, если у события есть тот же subject и request или сохранённый exposure key. Если связи нет, система не должна угадывать. Она возвращает hold и причину missing-attribution.

\n
\"Матрица
Метрика не заканчивается на delta. Сначала проверяется контракт данных, затем сопоставляются сигнал успеха и guardrail.
\n

Симптомы требуют разных проверок

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Conversion выросла сразу после изменения trackingПропали открытия или изменился способ deduplicationСравнить число субъектов, строк и долю доставки каждого события до и после измененияОстановить интерпретацию; проверить instrumentation и denominator
Treatment и control имеют разные размерыНарушилось распределение вариантов или одна группа потеряла событияСверить ожидаемое и наблюдаемое соотношение, exposure и data-quality metricНе объявлять победителя; найти источник mismatch
Подтверждение не содержит cohort или requestНет правила attributionПроверить event schema и цепочку от exposure до outcomeВернуть hold; не приписывать outcome варианту
Local metric растёт, но растут ошибки рендераВыигрыш куплен ухудшением соседнего шагаПосчитать guardrail по той же population и тому же окнуСверить порог с владельцем риска и остановить выпуск при нарушении
В одном отчёте смешаны два дняQuery собрал разные окнаПроверить period на каждой записи и границы окнаПересобрать выборку; не усреднять разрыв молча
\n

Положительный и отрицательный путь

\n

Положительный путь означает не «метрика хорошая». Он означает, что измерение прошло базовые проверки и может попасть к владельцу решения. Минимальный результат содержит definition, population, период, attribution, guardrail и список ограничений.

\n
function inspect(report) {\n  const reasons = [];\n\n  if (!report.attribution) reasons.push('missing-attribution');\n  if (report.denominator !== 'unique-opened-subjects') {\n    reasons.push('wrong-denominator');\n  }\n  if (report.periods.length !== 1) reasons.push('mixed-period');\n  if (report.guardrailRate > report.guardrailLimit) {\n    reasons.push('guardrail-breached');\n  }\n\n  return reasons.length === 0\n    ? { status: 'eligible-for-human-review', reasons: [] }\n    : { status: 'hold', reasons };\n}\n\n// Код иллюстрирует stop conditions.\n// Он не читает telemetry и не принимает решение о выпуске.
\n

В отрицательном пути нет попытки «починить» данные средним значением или подстановкой default cohort. Если период смешан, выборку пересобирают. Если denominator изменился, заново описывают метрику. Если нет attribution, чинят схему события или правила связи. Если guardrail превышен, владелец риска решает, допустимо ли продолжать. Функция не должна скрывать эти причины в null или в зелёном статусе.

\n

Такое разделение защищает от двух подмен. Первая — движение локальной метрики превращают в причинное объяснение. Вторая — техническую проверку превращают в автоматический ship. Инспектор может сказать «условия расчёта выполнены» или «расчёт остановлен». Он не может доказать, что изменение вызвало результат, если дизайн и данные этого не показывают.

\n

Почему guardrail нужен рядом с успехом

\n

Success metric отвечает на вопрос о желаемом результате. Local metric помогает понять ближайший шаг. Guardrail ограничивает цену улучшения. Например, форма может увеличить число подтверждений, но одновременно повысить ошибки рендера или отмены. Если guardrail появляется только после обсуждения успеха, команда уже выбрала удобную рамку.

\n

Guardrail должен иметь population, окно, единицу счёта и владельца порога. «Ошибок стало больше» недостаточно. Нужны доля, база и правило: например, render_failed unique subjects / checkout_opened unique subjects в том же cohort и периоде. Порог задают до интерпретации результата. Его не следует подбирать после того, как local metric уже выросла.

\n

Отдельная data-quality metric проверяет, можно ли доверять самой выборке. Она не является guardrail пользовательского опыта. Sample-ratio mismatch, потеря exposure или резкий провал доставки событий могут остановить анализ раньше, чем команда посмотрит conversion. Это отрицательный путь измерения, а не доказательство плохого продукта.

\n

Порядок проверки перед решением

\n
  1. Назовите решение. Запишите, какое действие возможно: продолжить наблюдение, остановить rollout или передать данные владельцу.
  2. Опишите population. Укажите субъект, inclusion rule, cohort и период до расчёта.
  3. Разложите дробь. Напишите словами numerator и denominator. Проверьте deduplication и повторные события.
  4. Проверьте attribution. У каждого outcome должна быть воспроизводимая связь с exposure или вариантом.
  5. Посмотрите data quality. Сверьте доставку событий, expected ratio групп, пропуски и задержку.
  6. Положите рядом guardrail. Используйте совместимые population и окно. Заранее назовите порог и владельца.
  7. Прогоните отрицательный вход. Подайте mixed period, wrong denominator или missing attribution. Ожидаемый ответ — hold с конкретной причиной.
  8. Передайте человеку. Только после проверок владелец продукта оценивает риск, ограничения и дальнейший rollout.
\n

Ограничения

\n

Контракт метрики не заменяет дизайн эксперимента. Он не доказывает случайное распределение, достаточную мощность, отсутствие сезонности или причинный эффект. Небольшой fixed набор в примере не моделирует реальный трафик. Имена u-1 и u-2 не являются советом хранить открытые идентификаторы пользователя.

\n

Событие с корректным именем всё равно может потеряться в клиенте, задержаться в очереди или попасть в другую систему времени. Поэтому проверка схемы должна сопровождаться проверкой доставки и задержки. Для финансовых, медицинских и других чувствительных сценариев нужны отдельные правила приватности, retention и доступа.

\n

Проверяемый критерий готовности такой: независимый инженер по записи может восстановить population, numerator, denominator, cohort, период, attribution и guardrail. На валидном наборе система возвращает eligible-for-human-review, а на каждом специально испорченном наборе — hold с причиной. Ни один путь не публикует результат и не запускает rollout автоматически. Если критерий не выполняется, сначала ремонтируют измерение.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/090.json b/editorial/agent-rewrites/090.json new file mode 100644 index 0000000..30d84f9 --- /dev/null +++ b/editorial/agent-rewrites/090.json @@ -0,0 +1,7 @@ +{ + "index": 90, + "slug": "editorial-2025-07-practice-product-metrics", + "title": "Метрики продукта для инженера: связать изменение с решением", + "excerpt": "Рост conversion не доказывает пользу изменения. Разбираем, как связать технический сигнал, cohort, denominator, пользовательский outcome и guardrail, чтобы ошибка измерения остановила решение до релиза.", + "contentHtml": "

После ускорения checkout на графике выросла conversion. Команда готовит rollout. Через несколько дней выясняется: повторные открытия перестали попадать в denominator, а часть ошибок рендера исчезла из отчёта вместе с событием. Пользователи не стали чаще подтверждать заказ. Изменился способ счёта.

Цена ошибки — решение по ложному сигналу. Команда выпускает изменение, тратит время на обратное расследование и теряет возможность сравнить варианты в одном окне. Если ошибка затрагивает платежный или регистрационный путь, к этому добавляются незавершённые операции и обращения в поддержку.

Тезис: продуктовая метрика для инженера — это не имя на дашборде, а контракт. Он связывает техническое изменение с наблюдаемым действием, задаёт cohort, период и denominator, а рядом держит guardrail. При разрыве связи расчёт должен остановиться. Число без этих условий не становится доказательством.

Механизм: от изменения к решению

Техническое изменение само по себе не является продуктовым результатом. Предзагрузка формы может сократить ожидание. Сокращённое ожидание может изменить долю открывших checkout, которые нажали confirm. Но между этими утверждениями стоят события, идентификаторы и правила включения.

Для каждого измерения назовите пять звеньев:

Например, гипотеза звучит так: «Предзагрузка формы увеличит долю подтверждений среди пользователей, открывших checkout, но не повысит долю render failure». Это проверяемая цепочка. Формулировка «сделаем экран быстрее и поднимем conversion» цепочки не содержит.

\"Причинная
Схема разделяет локальный сигнал и побочную цену. Она не доказывает причинность, но показывает, какие связи нужно проверить до интерпретации числа.

Событие должно сохранять контекст

Событие отвечает на вопрос «что произошло», а его поля — на вопросы «с кем», «в каком варианте», «когда» и «как связать шаги». Имя вроде checkout_confirmed полезнее произвольного button_click, но одного имени мало. Два одинаковых события могут относиться к разным вариантам и разным попыткам.

Минимальный учебный контракт может выглядеть так:

const event = {\n  name: 'product.checkout_confirmed',\n  subjectId: 'u-17',\n  cohort: 'treatment',\n  period: '2025-07-14',\n  requestId: 'r-204',\n  schemaVersion: 1,\n};\n\n// Учебный объект в памяти. Он не отправляет telemetry\n// и не показывает результат реального продукта.

subjectId нужен, чтобы повторная доставка события не увеличила denominator. cohort не следует восстанавливать по текущему флагу: пользователь мог увидеть один вариант, а запросить данные после переключения флага. period не даёт смешать окна. requestId связывает открытие, подтверждение и техническую ошибку одной попытки.

OpenTelemetry разделяет traces, metrics и logs как разные сигналы наблюдаемости. Это полезная граница: latency можно увидеть в span, число ошибок — в metric, а контекст конкретной попытки — в log или event. Но сама телеметрия не создаёт product contract. Владелец решения должен заранее определить, какие сигналы отвечают на его вопрос.

Denominator важнее красивой дроби

Учебная локальная метрика может быть записана так:

conversion(cohort, period) =\n  unique subjects with checkout_confirmed\n  /\n  unique subjects with checkout_opened\n\nrenderFailureRate =\n  unique subjects with render_failed\n  /\n  unique subjects with checkout_opened

Обе дроби используют одну базу opened, один cohort и один период. Это не универсальное определение conversion. Реальный продукт может считать заказ, оплату или завершённую сессию иначе. Важно другое: правило нельзя менять между вариантами, а его состав нужно хранить рядом с результатом.

Если один пользователь открыл checkout три раза, denominator по subjects равен одному, а не трём. Если повторная попытка имеет другой смысл для продукта, это решение нужно зафиксировать до подсчёта. Нельзя выбрать удобный вариант после просмотра результата.

Attribution связывает outcome с показанным вариантом. Если подтверждение пришло без requestId, расчёт не должен молча принять его. Оно могло относиться к старому экрану, другой вкладке или повторной попытке. В этом случае правильный статус — остановка с причиной missing-attribution-rule, а не нулевая conversion.

Симптом → причина → проверка → действие

Диагностика продуктовой метрики
СимптомПричинаПроверкаДействие
Conversion выросла сразу после измененияИз denominator исчезли повторные или ошибочные открытияСравнить множества unique subjects и правило включения до и послеОстановить интерпретацию и восстановить сопоставимый denominator
Confirm есть, но вариант неизвестенНет attribution или requestIdПроверить связь opened, confirmed и cohort для каждой попыткиВернуть hold, добавить ключ связи и тест отрицательного пути
Treatment лучше control, но даты различаютсяСмешаны cohort или periodСверить период каждого события и источник cohortПересобрать окна и не сравнивать текущие числа
Локальная метрика растёт вместе с отказамиGuardrail не включён в решениеПосчитать render failure на той же базе openedОстановить rollout и разобрать технический путь отказа
На графике появились нулиПайплайн не отличает отсутствие данных от нулевого результатаПроверить статус расчёта и причины отклоненияПоказывать stop reason отдельно от числового значения
Число меняется после повторного запускаДубликаты событий или плавающее окноПроверить idempotency по subjectId, requestId и periodЗафиксировать дедупликацию и повторить расчёт

Учебный пример отрицательного пути

Предположим, есть два cohort и один день наблюдения. В каждом варианте два пользователя открыли checkout. В treatment один пользователь подтвердил действие. В control один пользователь подтвердил действие. У treatment дополнительно зафиксирован один render failure.

В таком маленьком наборе обе conversion равны 0,5. Это не результат эксперимента и не основание для запуска. Он показывает только форму контракта: одинаковые знаменатели, явный cohort и guardrail рядом. Если удалить cohort у одного confirm, расчёт должен остановиться, даже если арифметика всё ещё возможна.

function evaluate(events) {\n  const required = events.every((event) =>\n    event.subjectId && event.cohort && event.period && event.requestId\n  );\n\n  if (!required) {\n    return { status: 'hold', reason: 'missing-attribution-rule' };\n  }\n\n  const opened = unique(events, 'checkout_opened', 'subjectId');\n  const confirmed = unique(events, 'checkout_confirmed', 'subjectId');\n  const failures = unique(events, 'render_failed', 'subjectId');\n\n  return {\n    status: 'eligible-for-human-review',\n    conversion: confirmed.size / opened.size,\n    renderFailureRate: failures.size / opened.size,\n  };\n}

Функция учебная. В ней нет проверки случайного распределения, задержки доставки, часовых поясов, privacy-политики или достаточного размера выборки. Она иллюстрирует отрицательный путь: отсутствие обязательного поля переводит расчёт в hold до деления. В production это правило должно жить в проверяемом pipeline, а не только в тексте.

Порядок работы

  1. Назовите решение. Запишите действие: rollout, hold, rollback или дополнительная проверка. Не начинайте с названия графика.
  2. Сформулируйте цепочку. Укажите изменение, наблюдаемый шаг, outcome и guardrail одним абзацем.
  3. Зафиксируйте contract. Опишите cohort, period, population, numerator, denominator и attribution до первого сравнения.
  4. Проверьте данные. Сверьте уникальность subjectId, связь requestId, полноту полей и единое окно времени.
  5. Посчитайте локальный сигнал. Отдельно выведите числитель, знаменатель и правила, по которым они получены.
  6. Посчитайте guardrail. Используйте сопоставимую базу и заранее названный порог риска.
  7. Пройдите отрицательный путь. Подайте событие без attribution, с другим period и с неверным denominator. Ожидайте разные stop reasons.
  8. Передайте решение владельцу. Код может вернуть eligible или hold, но product decision принимает человек с указанным owner и ограничениями.

Ограничения

Такая схема не заменяет экспериментальный дизайн. Она не доказывает randomization, причинность, статистическую значимость или долгосрочный outcome. Она не исправляет потерю событий и не знает, был ли пользователь заблокирован сетью. Она только делает условия сравнения явными и не даёт незаметно продолжить при нарушенном контракте.

Один guardrail не покрывает все риски. Для платежа важны отказ и незавершённая операция. Для регистрации — доступность и повторная отправка. Для медленного интерфейса — время до действия и ошибки клиента. Выбирайте guardrail по цене конкретного ухудшения, а не по удобству существующего дашборда.

Нельзя выдавать рост local metric за рост выручки или удовлетворённости. Нельзя считать отсутствие события нулевым значением без проверки доставки. Нельзя сравнивать cohort, собранные разными версиями схемы, если вы не доказали сопоставимость. Если это невозможно, честный результат — hold и план исправления данных.

Критерий готовности

Проверка готова, когда другой инженер может по decision record восстановить гипотезу, cohort, период, numerator, denominator, attribution, guardrail и owner. Для каждого числа есть источник событий. Для каждого stop reason есть воспроизводимый вход. Повторный запуск на том же окне даёт тот же результат.

Минимальный набор доказательств — контракт событий, пример успешного расчёта, три отрицательных проверки, сравнение local metric с guardrail и запись ограничения. Только после этого human owner выбирает rollout, hold или rollback. Если связь между изменением и outcome не доказана, система не обязана выдавать красивую цифру. Она обязана показать, где цепочка оборвалась.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/091.json b/editorial/agent-rewrites/091.json new file mode 100644 index 0000000..d235d79 --- /dev/null +++ b/editorial/agent-rewrites/091.json @@ -0,0 +1,7 @@ +{ + "index": 91, + "slug": "editorial-2025-06-field-developer-experience", + "title": "Когда внутренний инструмент заставляет разработчика ждать", + "excerpt": "Задержка между отправкой заявки и результатом часто выглядит как проблема скорости. Разбираем, как отделить время ожидания от неясного маршрута, проверить гипотезу и не объявить улучшение без сопоставимых данных.", + "contentHtml": "

Заявка отправилась, ошибок нет, но разработчик не знает, что будет дальше. Он ждёт подтверждения, ищет владельца в чате и повторяет запрос. Через сорок минут результат появляется. Формально инструмент сработал. Практически он оставил человека без следующего шага.

\n

Цена такой ошибки складывается из нескольких частей. Разработчик теряет время. Support повторно объясняет маршрут. Владелец получает сообщения, которые нельзя связать с конкретным этапом. Команда видит среднее время ответа, но не видит, где именно возникло ожидание. Если сразу менять интерфейс или добавлять автоматические повторы, можно ускорить не тот участок.

\n

Тезис. Удобство внутреннего инструмента нельзя вывести из одного времени ожидания. Сначала нужно разделить четыре факта: событие в системе, то, что понял человек, сигнал поддержки и действие владельца. Только после сопоставимой проверки можно говорить об изменении пути. Один учебный пример ниже показывает этот принцип; его данные не описывают реальный сервис.

\n

Механизм задержки

\n

Любой путь состоит из этапов. Для заявки на доступ это могут быть открытие задачи, отправка запроса, начало ожидания согласования, получение подтверждения и появление результата. События фиксируют порядок и время. Они не объясняют причину задержки.

\n

Причина может находиться в очереди согласований, в правах, в другой системе или в тексте интерфейса. В последнем случае человек ждёт не потому, что операция медленная. Он не понимает, кому адресован следующий шаг. Разница важна: таймаут лечит медленную операцию, но не лечит неясного владельца.

\n

Поэтому время полезно использовать как адрес проверки. Корзина 30m–1h сообщает, что между двумя событиями есть заметный промежуток. Она не сообщает, сколько людей столкнулись с ним, почему он возник и помогло ли изменение.

\n
\"Схема
Учебный путь заявки. Событие, наблюдение человека и решение владельца отвечают на разные вопросы.
\n

Учебный пример

\n

Ниже — фиксированная модель без реальных пользователей, заявок, сетевых запросов и production-данных. Роль platform-engineer отправляет запрос на учебный доступ к sandbox. В 09:03 задача открыта. В 09:04 запрос отправлен. В 09:05 начинается ожидание согласования. В 09:41 приходит подтверждение. В 09:45 появляется учебный результат.

\n

В модели есть ещё два поля. UX-наблюдение: «после отправки неясно, кто отвечает за следующий шаг». Сигнал поддержки: routing-unclear. Эти записи не доказывают, что каждый пользователь испытывает то же самое. Они только формулируют две проверяемые гипотезы: задержка связана с очередью или с маршрутом; подсказка с владельцем может уменьшить число неопределённых обращений.

\n
const journey = {\n  stages: [\n    ['request.submitted', '09:04'],\n    ['approval.wait.started', '09:05'],\n    ['approval.received', '09:41'],\n    ['result.confirmed', '09:45']\n  ],\n  waitBucket: '30m–1h',\n  observation: 'owner-unclear',\n  supportSignal: 'routing-unclear',\n  claim: 'not-established'\n};
\n

Поле claim намеренно не говорит «инструмент улучшен» или «инструмент плох». Учебная запись содержит один путь и не содержит группы сравнения. Она не показывает частоту, распределение, стоимость ожидания и причинность. Её роль — не доказать эффект, а не дать перепутать разные виды данных.

\n

Симптом → причина → проверка → действие

\n
СимптомВозможная причинаПроверкаДействие
После отправки человек спрашивает «кто отвечает?»Владелец этапа не виденПроверить путь до отправки и текст статусаПоказать владельца и следующий шаг
Долгий интервал между двумя событиямиОчередь, право или внешний процессСопоставить этап, роль и источник времениИсправить узкое место или объяснить ожидание
Растёт число ручных обходовНеясный маршрут либо срочная задачаРазделить причины обращений поддержкиИзменять только подтверждённую часть пути
После изменения среднее время нижеИзменились роль, задача или состав данныхСравнить одинаковые границы и периодОставить вывод открытым при несопоставимости
Один яркий отзыв требует срочного решенияСигнал приняли за масштаб проблемыПроверить частоту и альтернативные объясненияНазначить узкую проверку без общего обещания
\n

Почему нельзя смешивать сигналы

\n

Системное событие отвечает на вопрос «что произошло и когда». Оно может показать, что ожидание началось в 09:05 и закончилось в 09:41. Оно не отвечает на вопрос «почему».

\n

Наблюдение отвечает на вопрос «что человек понял или не понял». Формулировка «не вижу владельца» полезна для интерфейса. Но она не показывает число таких случаев и не доказывает, что новая подпись решит проблему.

\n

Сигнал поддержки показывает тему обращения. Категория routing-unclear помогает найти направление, но не заменяет подсчёт обращений и не доказывает, что маршрут вызвал ожидание. Решение владельца описывает выбранное изменение. Оно ещё не является результатом изменения.

\n

Эта граница защищает от отрицательного пути. Если после добавления владельца среднее время стало меньше, но вместе с этим изменилась задача или источник данных, сравнение нельзя считать честным. Если обращений стало меньше, но пользователи начали бросать заявки, снижение поддержки не равно улучшению. Если нет сопоставимых данных, вывод остаётся not-established.

\n

Как построить проверяемое сравнение

\n

Сравнение требует одинаковой задачи, роли, порядка этапов и набора свидетельств. Иначе изменение может появиться из-за другой нагрузки, другой очереди или другого способа считать время. Сравнительная граница не создаёт причинность сама по себе. Она только убирает очевидные подмены.

\n

Нужно заранее назвать ожидаемое свидетельство. Например: в той же роли человек видит владельца до отправки, проходит тот же этап и реже создаёт обращение с категорией routing-unclear. Даже такое свидетельство требует осторожности. Оно не доказывает, что исчезла вся cognitive cost. Оно проверяет одну часть маршрута.

\n

Ограниченное окно проверки тоже не означает статистический результат. Оно задаёт срок, в который владелец возвращается к вопросу. Если источник данных не определён, окно сравнения не спасает ситуацию. Правильное действие — остановить утверждение и уточнить, что именно можно проверить.

\n
\"Учебная
Корзина времени показывает участок пути. Она не измеряет удобство и не объясняет причину.
\n

Порядок действий

\n
  1. Назвать одну задачу и её ожидаемый результат. Не объединять весь onboarding в один показатель.
  2. Записать роль, этапы, источники событий и границы времени.
  3. Отделить наблюдение человека от системного события и сигнала поддержки.
  4. Проверить владельца этапа, права, очередь и внешний процесс до изменения интерфейса.
  5. Выбрать одну небольшую правку и заранее назвать свидетельство, которое её подтвердит или опровергнет.
  6. Сравнить тот же путь с той же ролью и тем же набором полей.
  7. Если сравнение нарушено или данных не хватает, оставить статус not-established и не приписывать эффект.
\n

Ограничения

\n

Учебная модель не содержит реального workflow, telemetry, тикетов, пользователей, сетевых ответов и production-результатов. Время 09:03–09:45, роль и категории — искусственные значения. Их нельзя использовать как бенчмарк, KPI или прогноз. Иллюстрации также показывают учебную схему, а не состояние конкретного инструмента.

\n

Даже реальные данные имеют пределы. Событие может потерять контекст. Support-сигнал может отражать только тех, кто решил написать. Среднее время скрывает длинный хвост ожидания. Изменение интерфейса может перевести вопрос в другой канал. Поэтому один показатель нельзя объявлять ответом за весь путь.

\n

Есть и отрицательный вариант: команда не может законно или технически получить сопоставимые данные. Тогда не нужно заменять их впечатлением, красивой диаграммой или единичным отзывом. Можно исправить очевидную ошибку текста, уточнить владельца или записать вопрос для будущей проверки. Но эффект такого действия не следует объявлять установленным.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда для одной задачи можно показать упорядоченные события, источник каждого сигнала, роль, владельца, ограничение сравнения и условие остановки. После изменения есть повторная запись с той же задачей и ролью. Она содержит заранее названное свидетельство. Если хотя бы одного элемента нет, готово только описание проблемы, а не вывод об улучшении.

\n
\"Петля
Петля проверки: сигнал ведёт к узкому действию, а не сразу к заявлению об эффекте.
\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/092.json b/editorial/agent-rewrites/092.json new file mode 100644 index 0000000..9010fda --- /dev/null +++ b/editorial/agent-rewrites/092.json @@ -0,0 +1,7 @@ +{ + "index": 92, + "slug": "editorial-2025-06-mechanism-developer-experience", + "title": "DX внутреннего инструмента: как доказать, где ломается путь задачи", + "excerpt": "Время ожидания не объясняет удобство внутреннего инструмента. Разбираем контракт одной задачи: события, роль, наблюдение, сигнал поддержки, отрицательный путь и критерий, который не позволяет объявить гипотезу улучшением без сравнимых данных.", + "contentHtml": "

Заявка во внутреннем инструменте может завершиться успешно, а разработчик всё равно не поймёт, кто отвечает за следующий шаг. Он ищет владельца в чате, повторяет уже введённые данные и держит задачу открытой до непонятного результата. Ошибка редко видна в статусе: система показывает approved, но не показывает, почему путь занял время и что делать при тишине.

Цена такой ошибки — не только минуты. Теряется контекст, растёт поток уточнений, support повторяет одну и ту же инструкцию, а команда может начать переделку по единичному громкому отзыву. Если измерить только время от submit до результата, эти причины смешаются.

Тезис. Удобство внутреннего инструмента нужно проверять на границе одной задачи. Контракт должен отделять факт перехода от того, как его понял человек, от сигнала поддержки, решения владельца и доказательства эффекта. Пока сопоставимого сравнения нет, вывод остаётся not-established.

Механизм: задача вместо общего DX-score

Задача — это не экран и не весь сервис. Это путь одной объявленной роли от ясного входа к проверяемому результату. Например, роль инженера открывает запрос на доступ к sandbox. Вход можно сформулировать так: «запрос отправлен с указанным окружением». Результат — «доступ подтверждён и его можно проверить». Между ними видны этапы: открытие, отправка, начало ожидания approval, получение approval и подтверждение результата.

У каждого этапа должны быть имя, порядок, время факта и источник записи. Время факта отвечает на вопрос «когда переход произошёл». Время наблюдения отвечает на другой вопрос: «когда источник его зафиксировал». Если эти значения совпадают в учебном примере, это не обещает такой же доставки в реальной системе.

OpenTelemetry разделяет traces, metrics и logs как разные сигналы. В его семантических соглашениях событие несёт timestamp момента, когда оно произошло. Это полезная дисциплина для контракта: событие и измерение нельзя заменять свободным текстом. Но стандарт не выбирает за команду UX-метрику и не доказывает причину задержки.

Поля, которые удерживают смысл

ПолеЗачем нужноПроверкаЧто нельзя выводить
declaredRoleОписывает, для кого рассматриваем путь.Роль совпадает с объявленной записью.Она не равна реальному пользователю или его правам.
stage и порядокПоказывают, где находится переход.Список этапов фиксирован и упорядочен.Порядок не объясняет причину ожидания.
occurredAt и observedAtРазделяют время факта и время фиксации.ISO-время, occurredAt ≤ observedAt, хронология.Время не измеряет cognitive cost.
waitBucketДаёт диапазон без ложной точности.Корзина разрешена только для нужного этапа.Корзина не является оценкой DX.
evidenceKind и sourceНе дают наблюдению притвориться событием.Для каждого вида задана допустимая пара.Источник сам по себе не делает тезис причинным.
knownUnknownsСохраняют пробелы рядом с решением.Есть непустой список конкретных неизвестных.Неизвестное нельзя заменить удобной догадкой.

Строгий контракт нужен не ради красивого JSON. Он задаёт место отказа. Лишнее поле вроде unboundedScore меняет смысл записи и должно быть отвергнуто так же, как пропущенное обязательное поле. Разреженный массив, неверная версия модели или неизвестный источник должны закрывать проверку. Иначе один слой назовёт запись событием, другой — наблюдением, а третий построит на ней решение.

Пять разных операций свидетельства

Instrumented event фиксирует переход: запрос отправлен или approval получен. UX-observation описывает понимание шага: роль не видит ответственного после отправки. Support signal группирует формулировку вопроса, например routing-unclear. Candidate change задаёт ограниченную гипотезу: показать owner до submit. Effect evidence появляется только после повторной проверки по той же границе.

Эти объекты нельзя переставить местами. Событие не говорит, что ожидание плохо. Наблюдение не доказывает, что так происходит у всех. Сигнал поддержки не является счётчиком обращений и не устанавливает причину. Гипотеза не равна результату. Если система не сохранила сопоставимое сравнение, безопасный ответ — остановиться, а не дописать эффект в отчёт.

\"Петля
Контракт ведёт от наблюдения к ограниченному изменению. Без сравнимого evidence цикл заканчивается safe stop. Схема учебная.

Учебный пример с отрицательным путём

Ниже приведён только учебный in-memory пример. Он не обращается к внутреннему инструменту, не содержит пользователей, заявок, telemetry или production-результатов. Пусть фиксированная запись описывает один sandbox-запрос. Между отправкой и получением approval стоит корзина 30m–1h. Роль не знает владельца после submit. Это три разных факта: этап, наблюдение и вопрос маршрутизации.

const input = createFixedJourneyInput(); input.claimedEffect.status = 'established'; input.claimedEffect.evidenceRefs = ['one-record-is-not-a-comparison']; const result = evaluateJourney(input); console.log(result.reason); // effect-claim-not-evidenced; console.log(result.effectClaim); // not-established

Проверка должна отвергнуть такой input. Одна запись показывает, что модель умеет представить путь. Она не показывает частоту, стоимость ожидания, причину ручного обхода или улучшение после изменения. Поле effectState остаётся false. Даже принятый decision означает только «гипотезу можно проверить в ограниченном follow-up», а не «изменение разрешено к выпуску».

Отрицательный путь важнее happy path. Если валидатор принимает голословный established, команда быстро перенесёт вывод на другие роли и задачи. Если сравнение меняет роль, ожидаемый результат, порядок этапов или источник данных, разница может появиться из-за другой границы, а не из-за изменения интерфейса.

Симптом → причина → проверка → действие

СимптомВероятная причинаПроверкаДействие
Результат успешен, но человек спрашивает «к кому идти».Owner не виден на этапе маршрутизации.Сопоставить UX-observation с конкретным stage.Проверить показ owner до submit; не обещать сокращение approval.
В dashboard растёт время до результата.В одну метрику попали очередь, доставка события и ручная работа.Разделить stage events, occurredAt и observedAt.Проверить источник и задержку доставки отдельно.
Есть один громкий тикет про неудобство.Сигнал поддержки приняли за распространённый эффект.Проверить категорию сигнала и неизвестный denominator.Назначить вопрос и owner; не строить общий DX-score.
После изменения «стало лучше».Сравнили разные роли или разные задачи.Сверить comparison boundary и порядок этапов.Вернуть claim в not-established и повторить сопоставимый проход.
Валидатор принимает запись с лишним полем.Контракт проверяет наличие, но не точный набор ключей.Запустить exact-key и canonical-JSON проверки.Отклонять лишние и пропущенные поля до решения.

Почему wait bucket не равен cognitive cost

Когнитивная цена возникает между видимыми событиями. Человек ищет инструкцию в другом чате, сравнивает похожие формы, сомневается, повторно отправляет запрос или запоминает обходной путь. Можно ждать недолго и всё равно потратить много внимания. Можно ждать долго из-за внешнего окна и не считать это дефектом интерфейса.

Поэтому корзина времени только указывает участок для исследования. Она не объясняет причину и не превращается в score. Нельзя умножить 30m–1h на observation «owner неясен» и получить измерение удобства. Это разные данные, у которых разные владельцы и разные способы проверки.

\"Учебная
Корзина времени помогает выбрать участок пути. Числа учебные и не описывают реальный инструмент.

Порядок действий

  1. Назовите одну задачу, одну declared role и проверяемый результат. Не включайте весь onboarding в один маршрут.
  2. Опишите этапы, допустимый порядок, источник каждого события и два времени: факт и наблюдение.
  3. Добавьте wait bucket только как диапазон и отдельно запишите, чего он не объясняет.
  4. Сформулируйте UX-вопрос и support signal. Не превращайте ни один из них в готовую причину.
  5. Назначьте owner, одну candidate change, comparison boundary и bounded follow-up.
  6. Заранее задайте stop condition: если граница или источник не сопоставимы, claim остаётся not-established.
  7. После повторной проверки сравните ту же роль, задачу, порядок этапов и виды evidence. Только затем решайте, есть ли основание для нового claim.

Ограничения и критерий готовности

Контракт проверяет структуру и границы данных, но не правдивость внешнего мира. Он не заменяет user research, проверку безопасности telemetry, согласие на сбор данных, анализ support-категорий или измерение реальной выборки. Он также не объясняет причинность: одинаковый результат до и после изменения может быть следствием другой нагрузки, инструкции или внешнего процесса.

Учебный код не содержит transport, client, user identifier, retention policy или integration point. Его положительный результат означает только согласованность фиксированных литералов. Его отрицательный результат не доказывает, что реальный инструмент неудобен или что предложенное изменение поможет.

Проверяемый критерий готовности: для одной разрешённой задачи существуют две записи с одинаковыми role, objective, stage order, evidence labels и comparison boundary; каждая запись проходит exact-key и timestamp-проверки; изменение и bounded window задокументированы; а effect claim либо опирается на это сопоставление, либо явно остаётся not-established. Если хотя бы одно условие не выполнено, работа готова только к следующему исследовательскому шагу, но не к заявлению об улучшении.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/093.json b/editorial/agent-rewrites/093.json new file mode 100644 index 0000000..ff0501b --- /dev/null +++ b/editorial/agent-rewrites/093.json @@ -0,0 +1,7 @@ +{ + "index": 93, + "slug": "editorial-2025-06-practice-developer-experience", + "title": "DX внутреннего инструмента: найти место, где застревает задача", + "excerpt": "Как разобрать путь одной задачи, отделить событие от причины ожидания и принять решение только при сопоставимой проверке.", + "contentHtml": "

Внутренний инструмент отвечает 200 OK, но задача не заканчивается. Разработчик отправляет заявку, не видит следующего владельца, открывает чат и повторяет вопрос. В журнале есть успешный запрос. В интерфейсе нет ошибки. Симптом появляется между двумя этапами: система приняла действие, а человек не понял, что делать дальше.

\n

Цена ошибки выше времени одного ожидания. Разработчик переключается между системами и повторяет ввод. Поддержка отвечает на одинаковые вопросы. Владелец инструмента видит хорошие технические статусы и откладывает проблему. Затем команда чинит заметный экран, хотя задержку создаёт очередь согласования или неясное право доступа.

\n

Тезис. Developer experience нельзя надёжно оценить одним средним временем или общим баллом. Нужно взять одну задачу, провести её от входа до результата и сохранить границы каждого вывода. Событие показывает переход. Наблюдение описывает действие человека. Сигнал поддержки указывает вопрос. Решение выбирает изменение. Эффект подтверждает только сопоставимое повторение.

\n

Единица разбора — одна задача

\n

Не начинайте с всего onboarding и не смешивайте разные роли. Возьмите один повторяемый путь. Например, инженер запрашивает доступ к sandbox. Ожидаемый результат — подтверждение или объяснимый отказ. Между ними находятся открытие заявки, отправка, начало ожидания, решение владельца и подтверждение результата.

\n

У задачи должны быть четыре явные границы. Первая — declared role: кто выполняет действие. Вторая — objective: зачем он его выполняет. Третья — expected result: что можно увидеть и проверить. Четвёртая — comparison boundary: какие условия обязаны совпасть при повторной проверке. Без этих полей сравнение легко превращается в сравнение разных задач.

\n

Временная метка помогает найти участок пути. Она не объясняет причину. occurredAt может обозначать момент перехода, а observedAt — момент его фиксации. Если запись пришла позже, это свойство наблюдения, а не обязательно задержка процесса. Поэтому время нужно хранить рядом с источником и этапом, а не превращать в самостоятельную оценку удобства.

\n
const journey = {\n  role: 'platform-engineer',\n  objective: 'получить sandbox access',\n  expectedResult: 'confirmation или объяснимый отказ',\n  stages: [\n    'task.opened',\n    'request.submitted',\n    'approval.wait.started',\n    'approval.received',\n    'result.confirmed'\n  ],\n  waitBucket: '30m-1h',\n  nextOwner: 'unknown',\n  uxObservation: 'после submit неясен следующий шаг',\n  supportSignal: 'routing-unclear',\n  effectClaim: 'not-established'\n};
\n

Код — учебный пример в памяти. Он не обращается к сети, не отправляет telemetry и не описывает реальную заявку. Его задача — показать минимальный набор полей и место, где система должна остановиться. В рабочем инструменте отдельно определяют разрешённые данные, права доступа, срок хранения и правила удаления идентификаторов.

\n

Механизм: пять свидетельств отвечают на разные вопросы

\n

Событие отвечает: «Что произошло и когда?» Например, заявка перешла из submitted в approval.wait.started. Оно не отвечает, почему человек открыл чат.

\n

UX-наблюдение отвечает: «Как человек понял или не понял шаг?» Запись «после отправки человек ищет владельца в другом канале» полезнее диагноза «плохой интерфейс». Наблюдение ещё не говорит, что добавление label исправит путь.

\n

Support signal отвечает: «Какой вопрос повторяется и какая команда может его проверить?» Категория routing-unclear помогает сгруппировать обращения. Она не показывает долю всех пользователей и не доказывает причинность.

\n

Решение отвечает: «Какое ограниченное изменение проверяем, на каком этапе и кто отвечает?» Хорошее решение содержит owner, target stage, candidate change, окно проверки и условие остановки.

\n

Effect evidence отвечает: «Что изменилось при той же границе?» Для него нужны та же роль, тот же результат, сопоставимый порядок этапов и заранее определённый признак. Одна удачная запись не становится evidence только потому, что её удобно показать.

\n

Симптом → причина → проверка → действие

\n
Диагностика пути внутреннего инструмента
СимптомПричинаПроверкаДействие
Заявка успешна, но человек повторяет вопросСледующий владелец или шаг не виденВосстановить путь от submit до следующего действия одной ролиЗаписать UX-наблюдение и проверить видимость owner
Среднее время ожидания растётВ одну метрику попали разные роли и этапыРазделить stage, role и wait bucketВыбрать одну границу задачи и не строить общий DX-score
Один отзыв сразу превращается в правкуНаблюдение смешали с решениемОтделить действие, вопрос, гипотезу и неизвестноеСформулировать candidate change с owner
Категорию поддержки называют доказательством эффектаНет сопоставимого результата после измененияПроверить source, роль, период и тот же ожидаемый результатОставить claim как not-established
После изменения стало «удобнее»Повторили другой маршрут или изменили состав ролиСравнить objective, stage order, fields и окно наблюденияОстановить вывод и повторить задачу по прежней границе
\n

Таблица не заменяет разговор с человеком и не создаёт статистику из одной записи. Она заставляет назвать следующий проверяемый шаг. Если действие нельзя выполнить или его результат нельзя увидеть, это не действие, а пожелание.

\n

Почему ожидание не равно причине

\n

Корзина 30m-1h говорит только о диапазоне между двумя событиями. В одном случае человек не понимает, что заявка уже стоит в очереди. Тогда нужно проверить статус, владельца и текст подтверждения. В другом случае владелец понятен, но согласование зависит от внешнего окна. Тогда интерфейс может честно объяснить ограничение, но не сократить сам процесс.

\n

Одинаковый wait bucket поэтому ведёт к разным решениям. Нельзя строить DX-score из времени и выдавать его за причину. Нельзя считать отсутствие обращения доказательством удобства: человек мог не иметь канала поддержки, а событие могло не видеть ручной шаг в соседней системе.

\n
\"Схема
Учебная иллюстрация. Красная ветка означает остановку вывода, если повторная проверка не сопоставима с исходной задачей.
\n

Источник каждого факта должен быть виден рядом с ним. Запись журнала подтверждает событие. Интервью или наблюдение подтверждает вопрос человека. Категория поддержки подтверждает повторяемую формулировку. Ни один источник не заменяет остальные. OpenTelemetry полезен здесь как пример дисциплины временных событий: имя и время помогают восстановить переход, но не отвечают на продуктовый вопрос о понятности шага.

\n

Отрицательный путь: остановиться при слабом доказательстве

\n

Представим, что после одной записи команда меняет effectClaim на established. В качестве evidence она прикладывает ту же запись. Такой вывод нужно отклонить. Нет второй точки сравнения. Неизвестно, совпала ли роль. Нельзя отделить эффект изменения от внешнего окна согласования.

\n
function decide(claim) {\n  const comparable = claim.sameRole &&\n    claim.sameObjective &&\n    claim.sameStageBoundary &&\n    claim.evidenceCount >= 2;\n\n  if (claim.status === 'established' && !comparable) {\n    return {\n      status: 'HOLD',\n      reason: 'effect-claim-not-evidenced'\n    };\n  }\n\n  return { status: 'needs-owner-decision' };\n}
\n

Это тоже учебный пример. Функция проверяет условие остановки на объекте в памяти. Она не оценивает правдивость внешних данных и ничего не меняет в production. В настоящем валидаторе понадобятся проверки схемы, порядка этапов, формата времени, источника события и разрешений.

\n

HOLD не означает, что гипотеза неверна. Он означает, что текущая запись не отвечает на вопрос. Это защищает команду от преждевременного переноса решения на другие роли и задачи. Если показ owner уменьшит число вопросов, повторная проверка это обнаружит. Если причина лежит во внешней очереди, результат будет другим: интерфейс улучшит объяснение, но не сократит ожидание.

\n

Порядок действий

\n
  1. Назовите одну задачу. Зафиксируйте role, objective и ожидаемый результат. Не включайте весь onboarding в один маршрут.
  2. Опишите этапы. Задайте порядок событий, source, occurredAt, observedAt и допустимые wait buckets.
  3. Соберите наблюдения. Запишите видимое действие или вопрос человека без диагноза. Отдельно сохраните support signal и known unknowns.
  4. Выберите одну гипотезу. Назначьте owner, target stage, candidate change и признак, который можно проверить.
  5. Зафиксируйте границу сравнения. Сохраните ту же роль, цель, ожидаемый результат и порядок этапов. Заранее задайте окно повторной проверки.
  6. Проверьте отрицательные входы. Подайте неизвестного owner, пропущенный source, нарушенный порядок и неподтверждённый effect claim. Для каждого ожидайте остановку с причиной.
  7. Примите ограниченное решение. Передавайте изменение дальше только при сопоставимом evidence. Иначе сохраните not-established и сформулируйте, каких данных не хватает.
\n

Ограничения применения

\n

Эта модель не даёт репрезентативную выборку. Она не измеряет cognitive cost, удовлетворённость, частоту обращений или стоимость поддержки. Она не заменяет user research, проверку доступности, нагрузочное тестирование и анализ прав. Она помогает не смешать вопросы и не выдать техническую запись за ответ на каждый из них.

\n

Учебные значения нельзя публиковать как результат внутреннего инструмента. В production-пути могут отличаться часы, задержка доставки, идентичность роли, источник ручной работы и правила хранения данных. Любое сравнение должно сохранять версию контракта и явно отмечать изменения границы.

\n

Показ владельца до отправки может снизить число вопросов о маршрутизации и не изменить очередь согласования. Ускорение одного этапа может увеличить нагрузку на другой. Поэтому изменение должно иметь narrow target и stop condition, а не обещание «сделать DX лучше».

\n

Проверяемый критерий готовности

\n

Разбор готов к инженерному решению, когда другой человек без устного пересказа может восстановить role, objective, expected result, stage order, source, wait bucket, UX-наблюдение, support signal, unknowns, owner, candidate change и comparison boundary. Он понимает, какой результат подтвердит гипотезу, а какой остановит вывод.

\n

Минимальная проверка даёт три наблюдаемых исхода. Корректная задача проходит структурную проверку и остаётся гипотезой до решения владельца. Неполный source, неверный порядок и forged effect claim возвращают HOLD с причиной. Ни один учебный вызов не отправляет данные и не меняет production. Только после этого можно подключать разрешённые источники и повторять тот же путь.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/094.json b/editorial/agent-rewrites/094.json new file mode 100644 index 0000000..6049997 --- /dev/null +++ b/editorial/agent-rewrites/094.json @@ -0,0 +1,7 @@ +{ + "index": 94, + "slug": "editorial-2025-05-field-engineering-automation", + "title": "Как ограничить batch-автоматизацию до безопасного изменения", + "excerpt": "Надёжный маршрут для автоматической операции: сначала получить точный preview, затем проверить scope и согласование, выполнить change, независимо проверить результат и остановиться перед опасным rollback.", + "contentHtml": "

Скрипт выбирает записи по фильтру и обновляет их за секунды. Затем владелец видит лишние изменения: шаблон совпал с архивными объектами, список targets устарел, а часть полей уже исправил другой процесс. Ошибка не заканчивается неудачным exit code. Она оставляет частичный change, теряет исходные значения и заставляет команду запускать ещё одну операцию для исправления первой. Цена ошибки — простой, ручная сверка и риск испортить данные при поспешном rollback.

\n

Безопасная batch-автоматизация строится как цепочка независимых границ: preview, ограниченный scope, проверка полномочий, approval, execute, audit trail и независимая verification. Ни один этап не должен выдавать результат следующего этапа. Preview не равен разрешению. Успешное завершение процесса не доказывает состояние данных. Rollback не должен автоматически наследовать полномочия исходной операции.

\n

Сначала зафиксируйте контракт операции

\n

До запуска опишите operation card — короткую карточку изменения. Она отвечает на вопрос: что именно процесс собирается изменить и как владелец узнает, что изменение завершилось правильно. В карточке нужны стабильный operation id, selector, полный список targets, исключения, digest списка, ожидаемое состояние до и после, версия логики и критерий проверки.

\n

Список targets должен быть плотным: без пропущенных элементов, неявного «всё найденное» и повторного поиска между preview и execute. Digest не заменяет список для чтения. Он связывает карточку, согласование и запуск. Если selector, список и digest невозможно показать вместе, reviewer не видит границу операции.

\n
Поля карточки перед запуском
ПолеПример учебного значенияПроверкаОстановиться, если
operationIdnormalize-labels-2025-05-01одно значение проходит через preview, approval и auditидентификатор переиспользован или отсутствует
targetsdoc-a, doc-bсписок плотный, видны selector и exclusionsсписок пуст, разрежен или не соответствует фильтру
targetDigestsha256:7b…digest вычислен по каноническому спискуdigest относится к другому набору
authoritylabel-editor, максимум 2 записилимит покрывает точный scopeоперация шире разрешённого лимита
expectationlabel=normalized после запускаесть источник, который это прочитаетуспехом считается только exit code
\n

Почему dry-run не делает запуск безопасным

\n

Preview показывает proposed change без внешней записи. Это полезная граница для чтения, но не гарантия будущего результата. Между расчётом и запуском другой процесс может изменить target. Запись может исчезнуть. Политика может истечь. Поэтому preview получает время расчёта, версию входных данных и максимальный срок действия.

\n

Короткий пример ниже учебный. Он не обращается к файлам, сети или реальным targets. Его задача — показать отрицательный путь: authority разрешает одну запись, а preview содержит две. В таком случае код не уменьшает список молча и не запускает разрешённую часть.

\n
const preview = {\n  operationId: 'normalize-labels-2025-05-01',\n  targets: ['doc-a', 'doc-b'],\n  targetDigest: 'sha256:7b-demo',\n  expiresAt: '2025-05-01T12:00:00Z'\n};\n\nconst authority = {\n  selector: 'document-label',\n  maxTargets: 1\n};\n\nfunction approve(preview, authority, now) {\n  if (new Date(preview.expiresAt) <= now) {\n    return { approved: false, reason: 'preview-expired' };\n  }\n  if (preview.targets.length > authority.maxTargets) {\n    return { approved: false, reason: 'scope-exceeds-authority-limit' };\n  }\n  return { approved: true };\n}\n\nconsole.log(approve(preview, authority, new Date('2025-05-01T11:00:00Z')));\n// Учебный результат: approved=false, scope-exceeds-authority-limit.
\n

Проверка должна происходить до approval и тем более до write. Нельзя превращать ограничение в предупреждение. Предупреждение оставляет решение в голове оператора и делает два одинаковых запуска разными по поведению. Явный stop code даёт владельцу причину, которую можно проверить и обработать.

\n
\"Схема
Preview и approval ограничивают внешнее действие. Проверка результата находится после execute, а rollback начинается с нового плана.
\n

Свяжите approval с тем, что увидел reviewer

\n

Approval должен относиться к конкретной карточке, а не к названию задачи. Минимальная связка содержит operation id, preview digest, target digest, authority id, решение, identity reviewer и срок действия. Executor сверяет значения буквально. Если reviewer согласовал список doc-a и doc-b, а перед запуском список стал doc-a и doc-c, старое согласование недействительно.

\n

Это правило закрывает распространённый отрицательный путь. Сервис обновил inventory, получил новый список и продолжил старым approval. В логах есть «approved», но approval относился к другому scope. Правильное действие — остановиться, построить новый preview и запросить новое решение. Автоматически угадывать намерение reviewer нельзя.

\n

Разделите execute, audit и verification

\n

Execute сообщает, что процесс попытался применить change. Audit trail сохраняет связанный контекст: operation id, digest входа, версию исполнителя, время, результат по каждому target и correlation id. Audit не доказывает, что данные приняли новое состояние. Для этого нужен отдельный reader, который обращается к authoritative source после execute.

\n

Verification сравнивает наблюдаемое состояние с явным expectation. У неё должно быть не два, а три результата: matched, mismatched и unknown. Недоступный reader даёт unknown. Таймаут не превращает unknown в успех. Если один target совпал, а второй нет, операция остаётся остановленной с указанием subset и владельца восстановления.

\n
Диагностика batch-операции
СимптомПричинаПроверкаДействие
В preview слишком много записейSelector шире ожидаемогоСравнить selector, exclusions и лимит authorityОстановиться и сузить scope; не обрезать список автоматически
Approval есть, но digest не совпалInventory изменился после reviewСверить operation id и два target digestПостроить новый preview и получить новое approval
Executor завершился успешно, данных нетExit code приняли за наблюдаемое состояниеПрочитать authoritative source отдельным readerОтметить unknown и назначить владельца verification
Изменена только часть targetsPartial execution или внешний конфликтСохранить точный subset и результаты по объектамОстановиться; не запускать широкий inverse
Preview старше допустимого окнаStale input или истёкшая политикаСравнить retrieval time с max ageПересчитать preview перед новым approval
\n

Rollback — отдельная операция

\n

После mismatched verification естественно вызвать обратную команду в блоке finally. Это опасно. Часть объектов могла измениться вручную. Старое значение могло стать неверным. Новый selector может выбрать больше записей. Поэтому rollback сначала создаёт план восстановления: наблюдаемый subset, известные и неизвестные состояния, владельца recovery и данные для следующего решения.

\n

Дальше rollback проходит тот же маршрут: новый scope, новый authority check, новый preview, новое approval и новая verification. Исходное согласование не даёт права на обратную запись. Если команда пока не может перечислить targets для rollback, результатом должен быть stop и расследование, а не команда «вернуть всё назад».

\n

Порядок действий

\n
  1. Опишите operation card: intent, selector, targets, exclusions, digest и expectation.
  2. Сформируйте preview без внешней записи. Зафиксируйте версию входных данных и срок действия.
  3. Проверьте, что точный scope укладывается в authority. При превышении верните явный stop code.
  4. Передайте reviewer карточку, preview и ограничения. Сохраните approval с привязкой к digest.
  5. Непосредственно перед execute повторите проверки freshness, scope, identity и срока approval.
  6. Запишите audit event с результатом каждой попытки и correlation id.
  7. Прочитайте authoritative source отдельным reader. Закройте операцию только при matched.
  8. При unknown или mismatched составьте новый rollback plan. Не используйте старый approval для обратного change.
\n

Ограничения

\n

Этот маршрут не делает опасную операцию безопасной сам по себе. Он не доказывает идемпотентность кода, неизменяемость журнала, корректность identity provider, отсутствие гонок и возможность восстановления данных. Внешний сервис может вернуть неполный список, а reader — устаревшее состояние. Эти свойства нужно проверять в архитектуре конкретной системы.

\n

Учебный код использует фиксированные строки и память процесса. Он не является реализацией permission system, durable audit storage или реального batch runner. В production нужно определить источник targets, каноническое вычисление digest, правила истечения preview, владельца остановки и допустимость partial execution. Если хотя бы один из этих пунктов неизвестен, scope автоматизации следует уменьшить.

\n

Проверяемый критерий готовности

\n

Операция готова к ограниченному запуску, если команда может воспроизвести три случая на тестовых данных: корректный bounded scope проходит весь маршрут; scope больше authority останавливается до approval; изменившийся target digest останавливает запуск после повторной проверки. Для каждого случая видны operation id, причина остановки или matched verification, запись audit и отсутствие запрещённой записи. Критерий не обещает результат для production. Он показывает, что границы процесса работают.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/095.json b/editorial/agent-rewrites/095.json new file mode 100644 index 0000000..53bf304 --- /dev/null +++ b/editorial/agent-rewrites/095.json @@ -0,0 +1,7 @@ +{ + "index": 95, + "slug": "editorial-2025-05-mechanism-engineering-automation", + "title": "Почему preview и approval не делают автоматизацию безопасной", + "excerpt": "Preview показывает намерение, approval фиксирует согласие, журнал хранит событие. Без ограничения scope и независимой проверки состояния автоматизация всё равно может изменить не те объекты.", + "contentHtml": "

Pipeline завершился зелёным, preview показал ожидаемый diff, а reviewer нажал approve. После запуска изменились записи за пределами заявки. В журнале есть operation id, но он не отвечает на главный вопрос: какие объекты реально изменились. Команда тратит часы на восстановление списка целей, проверку частичного результата и ручной откат.

\n

Цена ошибки растёт с размером batch-операции. Неверный selector меняет не одну запись, а весь совпавший набор. Старый preview уже не описывает состояние системы. Автоматический rollback может затереть исправления, которые кто-то внёс между двумя запусками.

\n

Тезис простой: безопасный change требует разных доказательств на разных границах. Preview доказывает только рассчитанное намерение. Authority ограничивает допустимый scope. Approval связывает согласие с конкретной версией proposal. Audit trail сохраняет переходы. Verification читает целевое состояние после execute. Если один слой подменяет другой, runner должен остановиться.

\n

Механизм: пять артефактов, пять вопросов

\n

У каждой фазы свой владелец и свой вопрос. Preview отвечает, что планировщик рассчитал в фиксированный момент. Authority отвечает, имеет ли операция право работать с выбранным scope. Approval отвечает, согласовал ли reviewer именно эту операцию. Audit trail отвечает, какое событие записал исполнитель. Verification отвечает, видит ли авторитетный reader ожидаемый результат.

\n

Ни один ответ не следует из другого. Наличие approval не доказывает, что reviewer видел полный список целей. Запись execution не доказывает, что target system приняла каждое изменение. Успешный dry-run не доказывает, что следующий write применится к тому же состоянию.

\n
Что на самом деле доказывает каждый артефакт
АртефактЧестное утверждениеОпасная подменаСледующая проверка
PreviewПланировщик рассчитал proposed change для выбранного snapshotExecute будет ровно таким жеПересчитать preconditions перед write
AuthorityPolicy разрешает selector и размер scopeChange полезен и технически корректенПроверить intent и содержимое preview
ApprovalReviewer разрешил связанную карточкуReviewer проверил каждую цель и побочный эффектСверить operation id и digests
Audit trailИсполнитель записал событие по контрактуTarget system уже находится в ожидаемом состоянииСделать независимый read
VerificationReader увидел заданное условиеВсе последствия change безопасныПроверить остаточный риск и recovery
\n

Preview имеет срок годности

\n

Preview — снимок намерения, а не разрешение на исполнение. Между расчётом и write другой job может изменить объект. Policy может обновиться. Selector может начать выбирать другой набор. Поэтому в карточке хранят не только человекочитаемый diff, но и operation id, snapshot version, selector, dense target list, target digest, expected precondition и proposal digest.

\n

Terraform разделяет speculative plan и план, который можно применить. Официальная документация прямо предупреждает: изменения в целевой системе после раннего speculative plan могут поменять финальный эффект, поэтому перед apply нужно проверить актуальный non-speculative plan. Этот принцип переносится на самописный runner без привязки к Terraform: старый diff нужно пересчитать или отклонить по TTL.

\n

Dry-run тоже бывает разным. В Kubernetes kubectl apply --dry-run=client только печатает объект и не отправляет его. Режим --dry-run=server отправляет server-side request, но не сохраняет ресурс. Первый проверяет локальную сериализацию. Второй проходит часть серверной обработки. Ни один режим не подтверждает будущий persistent change.

\n

Authority ограничивает мощность

\n

Authority не решает, стоит ли менять поле. Она ограничивает то, что исполнитель может сделать: допустимый selector, максимальное число целей, срок действия и исключения. Если policy разрешает два объекта, карточка с тремя не должна превращаться в warning. Runner возвращает stop до approval.

\n

Граница должна быть машинной. Сравнивайте exact selector и exact target digest. Не полагайтесь на текст «небольшой batch». Не уменьшайте список молча: reviewer должен увидеть тот же scope, который получит executor. Не расширяйте authority по умолчанию, если часть целей стала недоступна.

\n

Учебный пример: остановка до write

\n

Ниже — самостоятельная модель в памяти. Она не обращается к сети, не читает права и не меняет production. Карточка содержит три synthetic targets, а authority разрешает два. Отрицательный путь важнее счастливого: операция не доходит до approval.

\n
const operation = {\n  id: 'op-normalize-labels-v1',\n  selector: 'labels.env=staging',\n  targets: ['service-a', 'service-b', 'service-c'],\n  targetDigest: 'targets-a-b-c-v1',\n  proposalDigest: 'proposal-7f2a'\n};\n\nconst authority = {\n  id: 'authority-l2-v1',\n  selector: 'labels.env=staging',\n  maxTargets: 2\n};\n\nfunction requestApproval(op, policy) {\n  if (op.selector !== policy.selector) {\n    return { status: 'STOP', reason: 'selector-not-authorized' };\n  }\n  if (op.targets.length > policy.maxTargets) {\n    return {\n      status: 'STOP',\n      reason: 'scope-exceeds-authority-limit',\n      targetCount: op.targets.length\n    };\n  }\n  return { status: 'READY_FOR_APPROVAL', operationId: op.id };\n}\n\nconsole.log(requestApproval(operation, authority));\n// { status: 'STOP', reason: 'scope-exceeds-authority-limit', targetCount: 3 }
\n

Пример проверяет контракт, а не безопасность пользователя. В реальной системе policy получает identity из провайдера, срок из доверенных часов, а target list — из авторитетного inventory. Здесь значения фиксированы, чтобы показать один переход: превышение лимита останавливает цепочку до любого внешнего действия.

\n

Approval должен быть связанным

\n

Approval без binding легко переносится на другую операцию. Минимальная связка содержит operation id, proposal digest, target digest, authority id, reviewer role, decision и expiry. Executor сравнивает эти поля перед write. Любое несовпадение, отсутствие или неизвестное значение возвращает stop.

\n

Похожий gate есть в GitHub Environments: job, который ссылается на environment, проходит настроенные protection rules до запуска и доступа к environment secrets. Required reviewers и bypass — отдельные настройки. Это gate стадии, а не доказательство смысла изменения. Reviewer подтверждает разрешение на job, но не заменяет проверку конкретного target digest.

\n

Проверяйте также отрицательный путь. Если preview относится к targets-a-b-v1, а approval к targets-a-c-v1, процесс не должен выбирать «более похожий» список. Он возвращает STOP: approval-does-not-bind-target и требует новый preview. Иначе система превратит человеческую ошибку в скрытый write.

\n

Audit trail не наблюдает состояние

\n

Журнал связывает фазы одной операции. В нём полезны operation id, version runner, selector, digests, authority, approval, timestamps и результат каждого target. Это помогает восстановить причинную цепочку. Но журнал фиксирует сообщение исполнительной системы. Он не читает целевой объект и не доказывает, что тот сохранился.

\n

Разделяйте receipt и evidence. Receipt говорит: «executor отправил запрос и получил ответ». Evidence говорит: «authoritative reader прочитал поле после операции». Если dashboard помечает change как verified только по наличию audit event, он скрывает самый дорогой отказ — неизвестное состояние.

\n

Verification и rollback

\n

Verification начинается с явного expected state. Например: reader видит у двух объектов label env=staging, target digest совпадает с карточкой, отсутствующие объекты перечислены, а stale inventory имеет отдельный статус. Exit code исполнителя не заменяет этот read.

\n

Если reader недоступен, состояние неизвестно. Если один объект не совпал, операция остановлена. Не переводите эти исходы в «успех с предупреждением», если следующий запуск может затронуть тот же scope.

\n

Rollback не является обратной строкой в finally. После частичного выполнения исходное значение может быть устаревшим. Часть объектов может исправить человек. Новая policy может запретить обратную операцию. Безопасный rollback начинается с observed state, нового bounded scope, новой authority, нового preview и нового approval. Исходное approval не наследуется.

\n

Симптом → причина → проверка → действие

\n
Диагностика автоматизированного change
СимптомПричинаПроверкаДействие
После approve изменились лишние объектыScope не связан с approval или selector расширилсяСравнить target digest, selector и preconditions перед writeОстановить операцию и создать новый bounded preview
Preview зелёный, а execute получил другой diffМежду фазами изменился target stateПроверить snapshot version и возраст previewПересчитать plan; старый approval не использовать
В audit есть success, но поле не изменилосьReceipt выдали за verificationПрочитать authoritative source после executeВернуть статус unknown или mismatch
Dry-run прошёл, write отклонён серверомЛокальная проверка не видела server-side policyСравнить client/server режим и ответ admissionИсправить вход или остановить до нового review
Rollback снова повредил часть данныхИспользовали старый scope и старое состояниеСобрать observed inventory и проверить preconditionsПланировать rollback как отдельный change
\n

Иллюстрация границ

\n
\"Матрица
Authority ограничивает scope, но не оценивает смысл идеи. Approval связывает карточку. Verification проверяет состояние после execute. Красная клетка означает STOP, а не автоматический rollback.
\n

Порядок действий

\n
  1. Опишите операцию. Запишите intent, selector, target list, target digest, expected before и expected after.
  2. Зафиксируйте границу preview. Укажите snapshot version, источник данных, время расчёта и условие устаревания.
  3. Проверьте authority. Сравните selector, лимит, срок и исключения до запроса approval.
  4. Свяжите approval. Сохраните operation id, proposal digest, target digest и authority id. Любое несовпадение остановите.
  5. Повторите preconditions перед write. Проверьте существование целей, версию, scope и действительность policy.
  6. Запишите receipt отдельно. Не называйте событие исполнителя доказательством состояния.
  7. Сделайте независимую verification. Прочитайте authoritative source и сравните его с expected state.
  8. Остановите неизвестное. При mismatch или недоступном reader соберите observed inventory и спланируйте новый bounded change. Не запускайте широкий rollback автоматически.
\n

Ограничения

\n

Эта схема не создаёт identity provider, неизменяемое хранилище журнала, криптографическую подпись, распределенный lock или гарантию идемпотентности. Учебный код не читает реальные targets и не даёт production-результатов. Digests в примере — строки для проверки связи, а не доказательство криптографической стойкости.

\n

Независимая verification тоже имеет границу. Reader может быть устаревшим, неполным или не видеть побочные системы. Назначьте источник истины и отдельно опишите, что означает unknown. Для destructive change нужны дополнительные ограничения: backup, ручное подтверждение, лимит скорости и владелец recovery.

\n

Approval не делает решение правильным. Он только фиксирует разрешение в определённой роли. Authority не проверяет бизнес-смысл. Audit trail не гарантирует сохранность без свойств storage. Эти ограничения нужно оставить рядом с контрактом, иначе короткий статус начнёт обещать больше, чем проверка.

\n

Проверяемый критерий готовности

\n

Механизм готов к ограниченному запуску, если другой инженер может по одной карточке восстановить intent, selector, exact targets, digests, authority, approval и expected verification. Тест с несовпадающим target digest останавливается до write. Устаревший preview требует пересчёта. Недоступный reader возвращает unknown. Частичный результат не запускает обратную операцию по старому approval.

\n

Минимальный набор проверок состоит из четырёх случаев: bounded scope проходит к review; scope выше лимита останавливается; approval с другим digest останавливается; после execute verification читает target system и отдельно сообщает mismatch. Только после прохождения этих случаев можно обсуждать более широкий scope. В статье нет утверждения, что такой механизм уже дал результат в production.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/096.json b/editorial/agent-rewrites/096.json new file mode 100644 index 0000000..e7284ad --- /dev/null +++ b/editorial/agent-rewrites/096.json @@ -0,0 +1,7 @@ +{ + "index": 96, + "slug": "editorial-2025-05-practice-engineering-automation", + "title": "Массовая автоматизация без слепого запуска: scope, approval и проверка результата", + "excerpt": "Как превратить массовую инженерную операцию в проверяемую цепочку: зафиксировать targets, ограничить scope, связать approval с preview, отделить receipt от verification и остановиться при несовпадении.", + "contentHtml": "

Скрипт меняет label на двух объектах и отрабатывает за секунду. Через неделю тот же selector находит две тысячи объектов. Job завершается со статусом success, но один шаблон не подходит части targets. В журнале есть время запуска и имя оператора, а список фактически изменённых объектов восстановить нельзя.

Симптом тихий: preview показывает только число объектов, approval хранит approved: true, а исполнитель повторно вычисляет динамический selector во время запуска. Цена ошибки — массовое неверное состояние, ручное восстановление, спор о границе операции и потеря времени команды. Если часть объектов успела измениться, старый input уже не описывает безопасный rollback.

Тезис: безопасная автоматизация строится не вокруг одной кнопки, а вокруг цепочки связанных доказательств. Preview отвечает, что предлагается. Ограниченный scope отвечает, какие targets допустимы. Approval разрешает именно этот scope. Audit trail сохраняет переходы. Verification проверяет наблюдаемое состояние после исполнения. При разрыве связи процесс останавливается.

Механизм: пять границ одной операции

Массовая операция начинается с намерения, но не должна сразу получать право записи. Сначала система строит operation card: идентификатор, selector, точный список targets, digest списка, ожидаемый переход и лимит размера. Затем отдельные проверки связывают карточку с authority и approval.

У каждого барьера свой вопрос:

Эти ответы нельзя склеивать. Наличие preview не доказывает будущий результат. Approval не доказывает, что reviewer проверил смысл каждого изменения. Receipt исполнителя не доказывает состояние target system. Лог не делает операцию обратимой.

Preview фиксирует намерение, а не обещает эффект

Хороший preview показывает не фразу «обновить конфигурацию», а exact selection и proposed diff. Для списка targets нужны плотный массив идентификаторов, selector, exclusions и digest. Для изменения нужны expected before и expected after. Для расследования нужны operation id, версия правила и время построения.

Если selector вычисляется заново после approval, approval может относиться к другому набору. Поэтому исполнитель принимает только карточку с тем же operationId, targetDigest, selector и authority id. Любое несовпадение даёт stop. Список без digest можно прочитать, но его нельзя надёжно связать с последующим запросом.

У Terraform есть та же полезная граница: terraform plan создаёт execution plan и сам не выполняет предложенные изменения. Официальная документация отдельно предупреждает, что между speculative plan и применением состояние цели может измениться, поэтому перед применением нужен повторный non-speculative plan. Это пример того, почему preview нельзя считать вечным разрешением.

Симптом → причина → проверка → действие

Диагностика массовой операции
СимптомПричинаПроверкаДействие
В preview есть только число targetsОперация не фиксирует exact selection и exclusionsСравнить список ids, selector и target digestОстановить approval до появления списка и лимита
Job получил approval, но selector вычисляется сноваApproval не связан с previewПроверить operation id, selector и digest перед executeОтклонить запрос при любом несовпадении
В журнале есть success, но состояние неизвестноReceipt исполнителя приняли за observed stateСделать независимое чтение authoritative sourceОставить статус stopped или verification-pending
Rollback запускает обратный payload автоматическиСтарый preview используют как новое разрешениеПроверить observed state и новый scopeСоздать отдельный rollback plan и новую authority
Операция затрагивает лишние targetsЛимит проверяют только в интерфейсеПодать scope больше лимита и проверить отказ до approvalПеренести проверку max targets в executor
\"Цепочка
Каждая карточка отвечает на свой вопрос. Красная ветка останавливает операцию; rollback начинается только с нового плана и новой проверки.

Учебная модель с fail-closed поведением

Ниже — учебный пример в памяти. Он не читает targets, не отправляет запросы и не выполняет запись. Его задача — показать границу между preview, approval, receipt и verification. В реальной системе объекты должны получать identity, durable storage, policy evaluation и контроль доступа из конкретной инфраструктуры.

const preview = { operationId: 'op-demo-17', selector: 'label=legacy', targetIds: ['doc-a', 'doc-b'], targetDigest: 'sha256:targets-a-b' }; const authority = { authorityId: 'role-maintainer', selector: 'label=legacy', maxTargets: 2 }; function requestApproval(p, a) { if (p.targetIds.length > a.maxTargets) return { status: 'STOP', reason: 'scope-exceeds-authority-limit' }; if (p.selector !== a.selector) return { status: 'STOP', reason: 'selector-not-allowed' }; return { status: 'WAITING_APPROVAL', operationId: p.operationId }; } function execute(approved, p) { if (approved.operationId !== p.operationId || approved.targetDigest !== p.targetDigest) return { status: 'STOP', reason: 'approval-does-not-bind-preview' }; return { status: 'SIMULATED_RECEIPT', effect: 'учебная запись' }; }

Первый отказ должен произойти до approval, если карточка содержит третий target. Второй — до simulated execute, если кто-то подменил digest после согласования. Отсутствующее поле, неизвестный operation id и лишнее поле также должны закрывать путь. Fail-closed означает, что неполный или подозрительный input не получает безопасный-looking default.

SIMULATED_RECEIPT — только запись о прохождении учебной функции. Она не означает, что doc-a и doc-b изменились. Проверка результата потребовала бы отдельного чтения системы, которой в примере нет.

Dry-run имеет собственную границу

Preview и dry-run часто называют одним словом, хотя они отвечают за разные слои. Preview доменной операции может построить proposed diff из локальной модели. Dry-run конкретной команды может остановиться на клиенте или отправить проверяемый запрос серверу без сохранения.

Документация Kubernetes для kubectl apply разделяет режимы --dry-run=client и --dry-run=server. Client только печатает объект, который был бы отправлен. Server отправляет запрос без persistence ресурса. Значит, статус «dry-run прошёл» нужно сопровождать указанием режима, источника данных и границы. Ни один режим не гарантирует, что поздний реальный запрос встретит те же policy и состояние.

Если проверка использовала client dry-run, нельзя утверждать, что API-сервер примет запрос. Если server dry-run прошёл, нельзя утверждать, что через час selector вернёт тот же набор ресурсов. Следующее действие — повторить preconditions рядом с execute и сохранить новый digest.

Approval связывает, но не оправдывает

Поле approved: true слишком слабое. Минимальная связь включает operation id, preview digest, target digest, authority id, reviewer или service identity, policy version и срок действия. Если digest другой, согласие относится к другой операции. Если authority другой, неизвестно, какой лимит применялся. Если истёк срок, старое решение не должно продолжать жить.

GitHub Actions даёт официальный пример узкой контрольной точки: job, который ссылается на environment с required reviewers, ждёт approval до старта; доступ к environment secrets появляется только после прохождения protection rules. Это контроль допуска к запуску. Он не доказывает правильность diff, качество selector или факт изменения целевых объектов. Содержательный review остаётся отдельной проверкой.

Audit trail и verification отвечают на разные вопросы

Audit trail связывает переходы: operation id, preview digest, authority, approval, actor, время, receipt и stop reason. Он помогает восстановить последовательность без поиска по чату и stdout. Но свойства «append-only» и «невозможно подделать» нельзя получить одним названием поля. Их нужно обеспечивать конкретным хранилищем, правами записи, retention и проверкой целостности.

Verification начинается после execute и использует authoritative read. Сначала задайте expectation: например, на каждом target поле label должно иметь значение current, а число отсутствующих targets равно нулю. Затем укажите источник, допустимую задержку и правило частичного результата. Если source недоступен, status должен быть verification-pending, а не verified.

Зелёный exit code не заменяет observed state. Receipt сообщает, что executor дошёл до своей точки завершения. Verification сообщает, что внешний объект сейчас выглядит ожидаемым образом. Эти записи нельзя объединять в одно поле.

Rollback — новая операция

Автоматический inverse payload опасен. Target мог измениться вручную после запуска. Исходное значение могло быть неправильным. Часть объектов могла принять новое состояние, а часть — нет. Внешняя зависимость могла изменить порядок восстановления.

При остановке безопасно создать только rollback plan: сохранить причину, прочитать observed state, определить новый bounded scope, выбрать owner, построить новый preview и получить новую authority и approval. Статус rollback-planned не означает rollback-executed. Старое approval не даёт права на новый write.

Порядок действий

  1. Выберите одну операцию. Начните с малого обратимого batch, а не с широкого selector на всей системе.
  2. Назовите exact targets. Запишите ids, selector, exclusions, digest, expected before/after и maximum scope.
  3. Разделите capability. Preview и approval не должны иметь права записи. Execute принимает только связанный approval.
  4. Проверьте отказ. Добавьте лишний target, подмените digest, удалите authority id и передайте неизвестную карточку. Для каждого входа ожидайте STOP.
  5. Привяжите approval. Сравните operation id, selector, target digest, authority id, policy version и срок действия непосредственно перед execute.
  6. Запишите receipt отдельно. Сохраните переход и stop reason. Не называйте receipt подтверждением состояния внешней системы.
  7. Выполните независимую verification. Прочитайте authoritative source, сравните expected и observed, зафиксируйте partial result и задержку.
  8. Остановите неясный результат. При недоступном source или несовпадении не делайте автоматический retry-write.
  9. Планируйте rollback заново. Используйте observed state и новый scope. Получите новое разрешение на отдельную операцию.

Ограничения

Учебная модель не проверяет реальные permissions, identity provider, состояние базы, гонки между preview и execute, сетевые повторы, durable audit storage, scheduler, очереди или фактический rollback. Она также не доказывает, что selector выражает правильное бизнес-условие. Все значения кода фиксированы в памяти; production effect намеренно не выполняется.

Даже exact digest не решает проблему сам по себе. Хэш связывает представления, но не говорит, что selection полон, authority разумна, expected state корректен или reviewer понял риск. Нужны владельцы данных, политика доступа и источник истины. Чем дороже ошибка, тем меньше должна быть граница первой операции.

Проверяемый критерий готовности

Операция готова к ограниченному реальному запуску, если другой инженер может без устного объяснения восстановить exact targets, proposed diff, maximum scope, authority, approval binding, stop conditions, receipt и authoritative verification. Тест с подменой target digest останавливает executor до записи. Тест с лишним target останавливает approval до запуска. После execute существует отдельная проверка observed state. При несовпадении система не делает скрытый retry и создаёт только новый rollback plan.

Если хотя бы один пункт держится на внимательности оператора, автоматизацию не расширяют. Сначала переносят правило в contract и проверяют отрицательный путь. Практическая граница — не «job завершился успешно», а «система показывает, что именно было разрешено, что произошло и каким чтением проверен результат».

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/097.json b/editorial/agent-rewrites/097.json new file mode 100644 index 0000000..b2c2ba4 --- /dev/null +++ b/editorial/agent-rewrites/097.json @@ -0,0 +1,7 @@ +{ + "index": 97, + "slug": "editorial-2025-04-field-ai-data-privacy", + "title": "Перед AI-инструментом данные должны пройти четыре проверки", + "excerpt": "Почему удаление имён из лога не делает контекст безопасным: разбираем классификацию, договорные условия, маршрут передачи и полномочия на конкретный фрагмент.", + "contentHtml": "

Инженер копирует в AI-чат фрагмент ошибки, удаляет имя пользователя и ждёт подсказку. Симптом исчезает из текста, но риск остаётся. В логе могут сохраниться время операции, редкий тип события, идентификатор запроса, структура платежа или сочетание полей, по которому запись узнают. Если сервис добавляет к prompt историю диалога, файлы проекта или поиск, наружу уходит не только выделенная строка.

\n

Цена ошибки складывается из нескольких частей. Команда теряет контроль над копией данных. Нельзя быстро доказать, куда она попала и как долго хранилась. Нельзя уверенно ответить на запрос владельца данных. В худшем случае один удобный ответ превращается в инцидент, расследование и остановку инструмента для всей команды.

\n

Тезис

\n

Безопасный AI-review начинается не с маскирования и не с выбора модели. Сначала нужно доказать, что конкретный фрагмент можно передать конкретной поверхности по конкретному маршруту. Для этого разделите четыре решения: какой класс у данных, какие условия обработки действуют, куда пойдёт запрос и кто имеет право его отправить. Если хотя бы один факт неизвестен, путь должен закрыться.

\n

Эта граница важнее названия продукта. Один vendor может иметь несколько поверхностей: веб-чат, расширение редактора, API, поиск и агент с доступом к репозиторию. Они могут собирать разный контекст и использовать разные настройки хранения. Одобрение «AI разрешён» не покрывает автоматически каждый экран и каждый тип записи.

\n

Как возникает ошибка

\n

Входной фрагмент обычно рассматривают как строку. Система должна рассматривать его как объект с контекстом: recordId, версией, владельцем, классом, целью, destination и сроком действия разрешения. Redaction меняет содержимое. Он не назначает класс и не подтверждает договорные условия. Поле, заменённое на [redacted], всё ещё может быть чувствительным по структуре.

\n

Следующий слой — маршрут. Запрос может пройти через прокси, региональный endpoint, подключённый поиск, плагин или сервис-посредник. Успешное TLS-соединение доказывает только доступность канала. Оно не доказывает, что выбранный destination разрешён для этого класса данных. Так же firewall allow-list не превращает restricted record в public.

\n

Последний слой — полномочия. Разрешение относится к ресурсу, цели, requester и сроку. Старое согласование для синтетического примера не даёт права отправить production-лог. Доступ к инструменту не равен праву передавать ему все доступные пользователю данные.

\n

Учебный пример: запрос, который обязан остановиться

\n

Ниже приведён синтетический пример. Он не читает лог, не содержит PII, секретов, клиентских идентификаторов и настоящего endpoint. Названия намеренно фиксированы. Цель примера — показать отрицательный путь: при restricted-классе функция закрывает передачу до проверки разрешения. Это не готовая DLP-система и не доказательство безопасности какого-либо AI-сервиса.

\n
const request = {\n  recordId: 'synthetic-log-shape-v1',\n  dataClass: 'restricted',\n  allowExternalEgress: false,\n  destination: 'fixed-external-ai-boundary-alpha',\n  requester: 'synthetic-engineering-read',\n  expiresAt: '2025-04-20T00:00:00Z'\n};\n\nfunction decide(request, now) {\n  if (request.dataClass === 'restricted') {\n    return { decision: 'stop', reason: 'class-denies-external-egress' };\n  }\n\n  if (!request.allowExternalEgress) {\n    return { decision: 'stop', reason: 'contract-not-proven' };\n  }\n\n  if (new Date(request.expiresAt) <= now) {\n    return { decision: 'stop', reason: 'authorization-expired' };\n  }\n\n  return { decision: 'hand-off' };\n}\n\nconst result = decide(request, new Date('2025-04-19T12:00:00Z'));\n// { decision: 'stop', reason: 'class-denies-external-egress' }
\n

Порядок проверок здесь принципиален. Сначала система видит запрет класса. Она не использует наличие пользователя или действующий срок как override. Если заменить класс на допустимый, нужно всё равно проверить условия хранения, destination, requester и дату. Положительный результат в таком fixture означает только, что фиксированные входы соответствуют фиксированным правилам учебной модели.

\n

Симптом → причина → проверка → действие

\n
Диагностика передачи контекста в AI-инструмент
СимптомПричинаПроверкаДействие
«Мы удалили имена, значит всё можно»Redaction перепутали с классификациейПроверить остаточную структуру, owner и правило классаОстановить передачу до решения владельца данных
«Продукт уже разрешён»Surface, plan или route отличаютсяЗафиксировать фактический destination и дополнительные источники contextСверить конкретный маршрут с policy и договором
«Сеть пропускает endpoint»Технический egress приняли за право на данныеСопоставить data class, destination и условия обработкиНе отправлять payload при любом несовпадении
«Есть approval от команды»Разрешение не имеет scope или истеклоПроверить record, requester, цель, issuer и expiresAtЗапросить новое датированное решение или выбрать локальный путь
«Ассистент ответил, значит утечки нет»Проверили output, но не весь outbound pathПроверить request, журналы, включённые интеграции и retention termsСчитать результат недоказанным до проверки маршрута
\n

Граница передачи

\n
\"Схема
Учебная схема показывает порядок вопросов. Она не изображает реальный сетевой trace и не подтверждает настройки конкретного поставщика.
\n

На схеме нет шага «попросить модель оценить риск». Модель может помочь сформулировать вопрос, но не должна сама становиться источником разрешения на доступ к данным. Сначала работает детерминированный gate. Он возвращает один из двух исходов: hand-off в заранее разрешённый workflow или stop с причиной и владельцем следующего решения.

\n

Порядок действий

\n
  1. Зафиксируйте объект. Запишите record ID, версию, владельца и минимально необходимую цель. Не вставляйте исходный payload в заявку на согласование.
  2. Назначьте класс. Проверьте, какое правило относится к данным после всех преобразований. Если class неизвестен, считайте его неизвестным, а не public.
  3. Проверьте условия обработки. Найдите документ, версию, plan, регион, retention, training terms и подключённые функции для этой поверхности. Не переносите вывод с другого продукта или аккаунта.
  4. Опишите маршрут. Укажите endpoint или сервис, прокси, поиск, расширения и другие места, куда может уйти context. Отдельно проверьте, что технический маршрут разрешён для этого класса.
  5. Проверьте полномочия. Сопоставьте requester, цель, record, destination, issuer и дату окончания. Устаревшее или широкое approval не расширяйте по аналогии.
  6. Выберите отрицательный путь. При пробеле оставьте данные локально, верните stop с причиной и назначьте владельца вопроса. Не обходите запрет через другой account, устройство или неофициальный интерфейс.
  7. Проверьте минимальный тест. Запустите synthetic case для разрешённого и запрещённого классов. Убедитесь, что stop не строит prompt и не вызывает внешний клиент.
\n

Ограничения

\n

Эта схема не заменяет инвентаризацию данных, DLP, договор, privacy impact assessment, контроль доступа и аудит поставщика. Она не обнаруживает PII сама по себе. Она не знает, что делает vendor после получения запроса, если это не подтверждено условиями конкретного сервиса. Она также не решает вопрос законного основания обработки и не определяет допустимость данных без владельца policy.

\n

Маскирование может снизить объём данных, но не гарантирует анонимизацию. Локальная модель может убрать внешний egress, но не отменяет права доступа и хранение локальных журналов. Человеческое согласование полезно, но не должно заменять проверяемые ограничения в коде и конфигурации.

\n

Учебные строки в примере не дают production-результата. Они показывают форму контракта. В реальной системе положительный результат нужно подтвердить документацией поставщика, конфигурацией маршрута, журналом события и решением владельца данных на дату запуска.

\n

Проверяемый критерий готовности

\n

Решение готово, если другой инженер без доступа к исходному обсуждению может повторить проверку и получить тот же исход. Для одного synthetic restricted record тест должен показать: внешний вызов не выполнен, payload не попал в prompt builder, причина stop сохранена, а следующий владелец назван. Для допустимого учебного record тест должен показать все совпадения: class, условия обработки, destination, requester и expiry.

\n

Если хотя бы один из этих фактов нельзя предъявить ссылкой, конфигурацией или тестовым результатом, передача не доказана. Оставьте данные локально и закройте вопрос до появления недостающего evidence.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/098.json b/editorial/agent-rewrites/098.json new file mode 100644 index 0000000..a9e4be1 --- /dev/null +++ b/editorial/agent-rewrites/098.json @@ -0,0 +1,7 @@ +{ + "index": 98, + "slug": "editorial-2025-04-mechanism-ai-data-privacy", + "title": "Передача данных в AI: четыре проверки до отправки контекста", + "excerpt": "Класс данных, условия обработки, сетевой маршрут и полномочия отвечают на разные вопросы. Разбираем, как соединить их в один fail-closed контроль и остановить передачу при пробеле в доказательствах.", + "contentHtml": "

Симптом обычно выглядит безобидно: инженер копирует в AI-инструмент кусок лога, тикет или stack trace, а потом не может точно сказать, что именно ушло наружу. В настройках включён корпоративный тариф, сеть разрешает нужный домен, коллега устно подтвердил «можно». Но эти факты не доказывают одно и то же. Цена ошибки — раскрытие персональных данных или секрета, нарушение договорного условия, невозможность восстановить решение и остановка всего сценария до повторной проверки.

\n

Тезис статьи простой: передача контекста допустима только тогда, когда сходятся четыре независимых доказательства. Нужно знать класс записи, применимую policy и договорные условия, конкретный технический маршрут и полномочия человека на эту запись в этот срок. Если один слой неизвестен, система должна остановить hand-off и вернуть причину. Нельзя заменить отсутствующий факт более удобным фактом из другой колонки.

\n

Почему одного разрешения недостаточно

\n

Класс описывает содержимое. Например, public, internal или restricted. Это не название папки и не ощущение автора фрагмента. Класс должен иметь владельца и правило, по которому его присвоили. Удаление имени из строки тоже не меняет автоматически класс: структура события, редкий идентификатор и сочетание полей могут оставаться чувствительными.

\n

Policy отвечает на вопрос «допустима ли такая обработка». Договор уточняет условия для конкретного сервиса, тарифа, региона и функции: retention, обучение моделей, subprocessors или запрет внешнего хранения. Слово «корпоративный» не заменяет проверку этих условий. Одобрение продукта не является автоматически разрешением на любой payload.

\n

Egress отвечает на другой вопрос: куда и каким путём может уйти запрос. Это endpoint, proxy, firewall, DNS-политика или другой сетевой control. Разрешённый маршрут не делает данные допустимыми. Authorization отвечает ещё на один слой: какой владелец разрешил конкретную запись, конкретному requester, для конкретного destination и до какой даты.

\n
\"Маршрут
Каждый слой проверяет свой вопрос. Несовпадение на любом переходе закрывает передачу и сохраняет причину остановки.
\n

Механизм: четыре доказательства должны описывать одну операцию

\n

Рассмотрим запрос на анализ ошибки в платёжном сервисе. Инженер хочет передать AI фрагмент журнала. В нём есть время, тип операции, идентификатор клиента и текст исключения. Даже если значение идентификатора заменили на client-17, нужно отдельно решить, что осталось в записи и какой класс ей присвоен.

\n

Первое доказательство — карточка контекста. В ней есть стабильный идентификатор записи, версия, класс, владелец, срок действия и ссылка на правило классификации. Второе — policy и contract evidence для выбранной поверхности. Ссылка должна покрывать именно тот plan и тот режим, в котором работает инструмент. Третье — проверенный egress scope: destination и технический control должны совпадать с карточкой. Четвёртое — датированное решение владельца. Оно содержит record id, requester, destination, срок и причину.

\n

Проверка не должна начинаться с approval. Иначе человек сможет невольно «перекрыть» запрет класса. Она не должна начинаться с сети. Иначе доступный endpoint станет ложным доказательством допустимости. Сначала проверяют форму запроса и запись, затем допустимость, маршрут, доступ и срок полномочий. Только после этого можно передать результат в отдельный разрешённый workflow.

\n

Таблица диагностики

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«В логе нет имени, значит можно»Редактура смешана с классификациейСверить остаточную структуру, класс и owner ruleНе передавать; запросить классификацию или заменить запись на публичный пример
«У нас Enterprise-тариф»План принят за договорное условиеПроверить policy, retention и training terms для нужной функцииЗакрыть hand-off до появления ссылки и применимого scope
«Firewall уже разрешил домен»Сетевой control принят за решение о данныхСопоставить destination, route и egress scope с карточкойИспользовать только подтверждённый маршрут; не искать обход
«Коллега разрешал это раньше»Approval не привязан к record, requester или срокуСверить id записи, роль, requester, destination и expiryПолучить новое датированное решение или остановить передачу
«Неизвестно, что вернёт интеграция»Дополнительный context и внешние запросы не учтеныПроверить документацию конкретной surface и включённые функцииОставить данные локально до подтверждения всех outbound paths
\n

Учебный пример с отрицательным путём

\n

Ниже — ограниченный пример на JavaScript. В нём используются только фиксированные строки, нет настоящего лога, PII, секрета, файла, сети, вызова модели или production side effect. Функция не отправляет payload. Она показывает только контракт: запись с запрещённым классом должна остановиться до проверки approval.

\n
const request = createFixedContextRequest('restricted-log-shape');\nconst decision = assessEgress(request);\nconst result = stopWhenClosed(decision);\n\nconsole.log(decision.accepted);              // false\nconsole.log(decision.reason);                // class-not-allowed\nconsole.log(result.payloadReleased);         // false\nconsole.log(result.nextAction);              // ask-data-owner\n\n// В этом учебном примере нет HTTP-вызова.
\n

Реальная реализация должна проверять строгую схему входа. Не принимайте лишние поля, отсутствующий scope, поддельную authorization или просроченную дату как частично корректный запрос. Fail-closed означает конкретный результат: accepted: false, причина, ссылка на проверенный источник и следующий владелец вопроса. Это не доказывает, что технология всегда запрещена. Это доказывает только, что текущих фактов недостаточно для данной передачи.

\n

Как выполнить проверку

\n
  1. Остановите копирование. Назовите record id и не открывайте AI-поверхность ради «быстрой проверки».
  2. Опишите класс. Зафиксируйте содержимое, версию классификации, owner и правило. Не считайте редактирование доказательством безопасности.
  3. Сверьте policy и contract. Проверьте plan, feature, region, retention и другие условия, которые относятся к выбранной surface.
  4. Проверьте маршрут. Запишите destination, route и сетевой control. Убедитесь, что они совпадают с разрешённым scope.
  5. Проверьте полномочия. Сопоставьте requester, access к record, роль владельца, record id и expiry. Старое общее одобрение не переносится автоматически.
  6. Выберите outcome. При полном совпадении передайте только разрешённый контекст в authorised workflow. При пробеле сохраните payload локально, запишите reason и назначьте следующий вопрос.
\n

Что могут и чего не могут доказать официальные документы

\n

Документация конкретного AI-продукта может описывать обработку prompt, добавление repository context или отдельный внешний поиск. Это помогает обнаружить дополнительные границы egress. Но такая документация не классифицирует ваш лог и не выдаёт сотруднику право на его раскрытие.

\n

Сетевое правило снижает риск неправильного endpoint, но не видит смысл payload. DPA и условия сервиса помогают ответить на вопрос о permitted processing, но не подтверждают, что человек имеет доступ к записи. NIST описывает policy-based access и неявное доверие к network location как разные вещи. Эти источники формируют вопросы для проверки; они не дают универсального вердикта «можно передавать».

\n

Ограничения

\n

Четыре проверки не заменяют DLP, data inventory, юридическую оценку, access control или технический мониторинг. Классификация может ошибиться. Документ поставщика может измениться. Proxy может быть настроен не так, как написано в схеме. Поэтому в рабочей системе храните версию evidence, дату проверки, применимый scope и границу, которую документ не покрывает.

\n

Не обещайте результат, которого не измеряли. Эта статья не утверждает, что конкретная интеграция сохраняет или не сохраняет prompt, не обучает на нём модели и не гарантирует отсутствие утечки. Учебный код не является доказательством свойств production. Его проверяемый результат уже: запрещённый класс не приводит к передаче.

\n

Критерий готовности

\n

Контроль готов к ограниченному применению, когда другой инженер может взять одну карточку без payload и воспроизвести весь вопрос: какой класс, какая policy и договорная версия, какой destination и route, кто разрешил, какой срок. Для каждого пробела система возвращает accepted: false, reason, citation и next action; ни один отрицательный case не вызывает внешний запрос. Положительный outcome допускает только тот scope, который совпал во всех четырёх доказательствах. Если эти условия нельзя проверить, готов не hand-off, а только следующий вопрос владельцу данных.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/099.json b/editorial/agent-rewrites/099.json new file mode 100644 index 0000000..4b8df08 --- /dev/null +++ b/editorial/agent-rewrites/099.json @@ -0,0 +1,7 @@ +{ + "index": 99, + "slug": "editorial-2025-04-practice-ai-data-privacy", + "title": "Перед AI-инструментом данные должны пройти четыре независимые проверки", + "excerpt": "Как не отправить код, лог или тикет за пределы компании по ошибке: разделяем класс данных, условия сервиса, сетевой маршрут и полномочия человека.", + "contentHtml": "

Инженер копирует в AI-инструмент десять строк лога, чтобы быстрее найти причину ошибки. В строках оказываются email, внутренний идентификатор и часть заголовка запроса. Интерфейс принимает текст. Ответ выглядит полезным. Через неделю никто не может точно сказать, какой контекст ушёл, к какому режиму сервиса он относился и кто разрешил передачу. Симптом проявился как удобная подсказка, а цена ошибки — потеря контроля над данными и дорогое расследование без исходного payload.

\n

Проблема не решается одной настройкой «корпоративный тариф» или запретом на секреты в prompt. Передача должна пройти четыре независимые проверки: что находится во фрагменте, допускает ли policy и договор такую обработку, куда технически пойдёт запрос и кто разрешил именно этот объём данных. Если один вопрос подменяет остальные, команда получает ложное зелёное состояние.

\n

Тезис: класс данных не равен разрешению

\n

Класс описывает содержимое. Например, public, internal или restricted. Он не говорит, можно ли передавать запись внешнему сервису. Это решает policy с учётом договора, режима хранения, обучения, региона и выбранного endpoint. Даже если policy разрешает класс, сеть должна вести запрос в нужное место, а сотрудник должен иметь право передать конкретный record.

\n

Разделяйте эти факты в карточке контекста. Запишите идентификатор и версию записи, класс, основание допустимости, проверенное условие сервиса, символическое имя назначения, requester, владельца и срок разрешения. Не заполняйте неизвестное значение словом «внутреннее». Не переносите approval с соседнего файла. При неполном evidence путь закрывается.

\n
\"Пять
Передача допускается только после независимой проверки содержимого, условий сервиса, маршрута и полномочий. Отсутствие одного доказательства ведёт к остановке.
\n

Как возникает утечка контекста

\n

Пользователь видит поле prompt, но поставщик может обрабатывать его вместе с контекстом продукта. Это может быть открытый файл, история диалога, выбранный репозиторий или дополнительный поиск. Поэтому проверяйте не только видимый текст, но и конкретную поверхность, включённые функции и endpoint. Такой анализ относится к режиму продукта, а не ко всем AI-инструментам сразу.

\n

Сетевое правило отвечает на узкий вопрос: может ли трафик достичь назначения. Allow-list не классифицирует payload. DPA или другая договорённость описывает обязательства, но не доказывает, что firewall отправил запрос только в нужный endpoint. Разрешение владельца показывает полномочия, но не меняет класс записи и не продлевает истёкший срок. Каждый контроль должен иметь собственную проверку.

\n

Учебный пример: решение без внешнего запроса

\n

Ниже приведён учебный JavaScript-пример. Он работает только с заранее заданными объектами в памяти. Он не вызывает модель, не читает файл, не отправляет лог и не утверждает ничего о production. Его задача — показать порядок проверок и отрицательный путь.

\n
const context = {\n  id: 'example-17',\n  version: 3,\n  dataClass: 'synthetic-public',\n  serviceCondition: 'synthetic-retention-reviewed',\n  egressScope: 'fixed-ai-boundary-alpha',\n  requester: 'alice',\n  access: 'synthetic-read',\n  authority: { owner: 'data-steward', scope: 'example-17', expires: '2026-12-31' }\n};\n\nfunction decide(record, policy, route, today) {\n  const checks = [\n    record.dataClass === policy.allowedClass,\n    record.serviceCondition === policy.requiredCondition,\n    record.egressScope === route.allowedScope,\n    record.access === 'synthetic-read',\n    record.authority.scope === record.id,\n    record.authority.expires >= today\n  ];\n\n  return checks.every(Boolean)\n    ? { status: 'review-ready', egressPerformed: false }\n    : { status: 'stop', egressPerformed: false };\n}\n\nconst result = decide(\n  context,\n  { allowedClass: 'synthetic-public', requiredCondition: 'synthetic-retention-reviewed' },\n  { allowedScope: 'fixed-ai-boundary-alpha' },\n  '2026-08-02'\n);
\n

Статус review-ready не означает, что запрос отправлен или что поставщик гарантирует нужный режим. Он означает только: фиксированная карточка прошла локальные проверки и может перейти к полномочному решению. Если изменить egressScope, срок или serviceCondition, функция возвращает stop. В реальной системе нужны собственные справочники, журнал решения и проверка фактического маршрута.

\n

Симптом → причина → проверка → действие

\n
Диагностика передачи контекста
СимптомПричинаПроверкаДействие
В prompt попал production-логКласс записи не определён до копированияНайти record и назначение класса у владельца данныхОстановить передачу, удалить локальную копию из рабочего контекста, запросить классификацию
Есть DPA, но неизвестен endpointДоговор приняли за доказательство маршрутаСверить plan, endpoint, proxy и сетевой журналРазрешать только явно названное назначение; иначе закрыть egress
Firewall пропускает запросТехнический доступ приняли за допустимость payloadСопоставить destination с policy и классом записиОставить сеть, но запретить payload до решения владельца
Approval есть в чатеНет scope, срока или версии recordПроверить requester, record id, version, destination и expiryПолучить датированное разрешение; старое общее «можно» не переносить
Ответ модели содержит лишний контекстВключён repository context, история или поискПроверить настройки конкретного режима и фактический запросОтключить дополнительный контекст или выбрать режим с проверенным scope
\n

Порядок pre-flight проверки

\n
  1. Назовите запись. Зафиксируйте record id, версию, источник и владельца до открытия внешнего инструмента.
  2. Классифицируйте содержимое. Отделите публичное описание от персональных данных, секретов, клиентского кода и внутренних деталей. Не угадывайте класс по имени файла.
  3. Проверьте policy и договор. Найдите условие для выбранного сервиса и режима: хранение, обучение, регион, субподрядчик и срок. Если условие относится к другому плану, оно не подходит.
  4. Проверьте маршрут. Укажите разрешённый endpoint, proxy и правило egress. Наличие соединения не является разрешением на данные.
  5. Сверьте полномочия. Requester должен иметь доступ к записи. Владелец должен разрешить именно этот scope, destination и срок.
  6. Проверьте дополнительные источники контекста. Уточните, не добавляет ли режим историю, файлы репозитория, поиск или другие поля запроса.
  7. Оставьте отрицательный путь. При неизвестном поле не маскируйте данные и не переходите в другой аккаунт или интерфейс. Запишите вопрос, владельца и условие возобновления.
\n

Что нельзя считать доказательством

\n

Название инструмента, значок щита, корпоративная почта и доступ к приложению не доказывают допустимость конкретного payload. Слово «анонимизированный» тоже недостаточно: нужно знать, какие поля удалены, можно ли восстановить субъекта и кто проверил преобразование. Маскирование email не очищает токен в заголовке или идентификатор в URL.

\n

Не смешивайте учебную карточку с реальной политикой. В примере используются значения с префиксом synthetic-; они не описывают вашу систему, vendor contract или фактическое хранение. Пример показывает форму fail-closed решения. Он не даёт production-результата и не заменяет legal, security или data-owner review.

\n

Ограничения и критерий готовности

\n

Подход не отвечает за правильность самой классификации. Он делает неизвестность видимой. Если каталог данных устарел, approval выдан не тому владельцу или сетевой журнал неполон, проверка должна остановиться. Она также не доказывает, что ответ модели верен, что поставщик не изменит условия или что будущая функция инструмента сохранит прежний маршрут.

\n

Материал готов к применению в конкретной команде, когда для одного реального типа контекста можно предъявить четыре связанные записи: версионированный class, policy/contract condition для выбранного режима, проверенный egress и датированное scoped authorization. Отрицательный тест должен показать stop при истёкшем сроке, чужом scope или неизвестном endpoint. После этого команда может передавать только разрешённый минимальный фрагмент и восстановить, почему решение было принято.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/100.json b/editorial/agent-rewrites/100.json new file mode 100644 index 0000000..69481d9 --- /dev/null +++ b/editorial/agent-rewrites/100.json @@ -0,0 +1,7 @@ +{ + "index": 100, + "slug": "editorial-2025-03-field-knowledge-retrieval", + "title": "Когда поиск по базе знаний должен остановиться", + "excerpt": "Высокая похожесть найденного фрагмента не доказывает его актуальность, доступность и пригодность для цитирования. Разбираем stop-условие, проверку источника и безопасный путь для неполного результата.", + "contentHtml": "

Поиск по инженерной базе часто ломается не тогда, когда ничего не нашёл. Опаснее другой симптом: система возвращает убедительный фрагмент, а команда принимает его за действующее правило. Документ уже закрыт для текущего пользователя, срок его действия истёк или в нём нет точного места, которое подтверждает вывод. Поиск показывает высокий score, интерфейс показывает ответ, а ошибка обнаруживается позже.

\n

Цена такой ошибки измеряется не длиной задержки. Старая инструкция может привести к неверной миграции. Закрытый фрагмент может попасть в ответ человеку без права доступа. Неточная цитата превращает предположение в решение на ревью. Исправлять последствия дороже, чем остановить ответ на несколько минут и передать вопрос владельцу источника.

\n

Тезис: похожесть не равна доказательству

\n

Retrieval должен отвечать на два разных вопроса. Первый: насколько найденный фрагмент похож на запрос. Второй: можно ли использовать его для конкретного ответа. Векторный или текстовый поиск решает только первый вопрос. Он ранжирует результаты. Он не подтверждает дату, права и точный смысл документа.

\n

Поэтому ответ разрешается продолжить только для записи, которая одновременно проходит три проверки: источник доступен этому запросу, источник свеж по заданному правилу и в нём есть точная цитата с устойчивым anchor. Если хотя бы одно условие не выполнено у всех кандидатов, система возвращает stop. Она не заполняет пробел вероятным пересказом.

\n

Stop не означает «в базе ничего нет». Он означает «найденное нельзя безопасно использовать». Это важное различие для диагностики. Пользователь должен увидеть безопасную причину и следующий маршрут, но не закрытый текст. Владелец документа должен понять, что обновить или разрешить. Так система сохраняет и полезность, и границу доказательств.

\n

Механизм проверки

\n

У записи источника должен быть контракт. Минимальный набор полей выглядит так: стабильный идентификатор, версия, URI, anchor, владелец, класс доступа, дата публикации, дата индексации и локальная дата истечения. Поле retrievedAt фиксирует момент, когда поиск увидел запись. Эти даты нельзя сводить к одному timestamp: задержка индекса и срок действия документа описывают разные риски.

\n

Сначала система получает кандидатов. Затем применяет policy-фильтр. Проверка прав происходит до передачи excerpt в контекст ответа. Проверка свежести зависит от типа вопроса. Для операционного вопроса истёкшая инструкция непригодна. Для исторического вопроса она может быть полезна, но ответ должен назвать версию и дату. В обоих случаях правило задают метаданные и владелец политики, а не score.

\n

Положительная ветка возвращает source id, version, URI, anchor, retrievedAt и разрешённый фрагмент. Отрицательная ветка возвращает код причины: access-denied, expired, missing-anchor или unknown-policy. Закрытый excerpt не входит в отрицательный результат. Такой контракт не делает источник истинным автоматически. Он не даёт системе скрыть отсутствие доказательства.

\n
const result = retrieve(query, records, policy);\n\nif (!result.accepted) {\n  return {\n    status: 'stop',\n    code: result.reason,\n    sourceId: result.sourceId,\n    next: result.ownerAction,\n  };\n}\n\nreturn {\n  status: 'review',\n  claim: draftClaim(query, result.excerpt),\n  citation: {\n    uri: result.uri,\n    version: result.version,\n    anchor: result.anchor,\n    retrievedAt: result.retrievedAt,\n  },\n};
\n

Это учебный пример. Функции retrieve и draftClaim здесь не подключены к настоящей базе и не подтверждают реальную авторизацию. Пример показывает границу: положительный результат ещё требует ручного сравнения утверждения с источником, а отрицательный результат останавливает дальнейшую обработку.

\n

Симптомы, причины и действия

\n
Диагностика retrieval-ответа
СимптомПричинаПроверкаДействие
Высокий score, но документ старыйРанжирование не учитывает срок действияСравнить expiresAt и retrievedAtОстановить ответ и направить к владельцу документа
Найден закрытый фрагментПоиск и проверка прав разделены или проверка выполнена поздноПроверить access decision до передачи excerptВернуть безопасную причину без текста источника
Цитата ведёт на страницу без местаВ индексе сохранён URI, но нет устойчивого anchorОткрыть URI и проверить заголовок, номер раздела или якорьОстановить ответ и исправить карточку источника
Разные даты в индексе и документеНе различены публикация, индексация и срок действияСопоставить все даты и владельца каждойИсправить policy или обновить индекс
Система отвечает «ничего нет»Reject reasons потерялись после фильтраПроверить structured result и журнал решенияПоказать safe reason и следующий маршрут
\n

Почему HTTP-дата не решает задачу свежести

\n

HTTP-заголовок Last-Modified сообщает дату, когда origin считает выбранное представление изменённым. Это полезный сигнал для индексации и условных запросов. Но он не доказывает, что инструкция всё ещё действует. Правило могло устареть из-за миграции, смены владельца или изменения контекста, даже если файл с тех пор не менялся.

\n

Практическая политика должна назвать, какая дата управляет ответом. Например, publishedAt отвечает за версию документа, indexedAt показывает задержку конвейера, а expiresAt задаёт допустимый срок для операционных вопросов. Если источник не предоставляет нужные данные, это не повод угадывать. Нужна остановка или явное решение владельца о том, какое ограничение действует.

\n

Права доступа проверяются отдельно

\n

Доступ к документу нельзя выводить из факта, что поисковый сервис смог его прочитать. Индекс может работать с расширенными правами, а запрос пользователя — с ограниченными. Проверка должна учитывать субъекта, ресурс и цель обращения. Deny должен произойти до того, как закрытый фрагмент попадёт в контекст следующего шага.

\n

Безопасный stop-ответ содержит только то, что разрешено policy: например, внутренний идентификатор записи, общий код причины и группу владельца. Не следует возвращать закрытый заголовок, соседние предложения или признаки, по которым можно восстановить содержание. Уровень подробности диагностического сообщения — тоже часть модели доступа.

\n
\"Схема
Остановка происходит внутри цикла: кандидат проходит проверку доступа, свежести и anchor до формирования ответа.
\n

Порядок действий для одной базы

\n
  1. Разделите вопросы на операционные и исторические. Зафиксируйте разные правила свежести, если они действительно различаются.
  2. Опишите карточку источника. Сохраните id, версию, URI, anchor, владельца, класс доступа и нужные даты.
  3. Определите обязательные условия ответа: доступ, свежесть и точная цитата. Назовите stop-коды для каждого нарушения.
  4. Проверьте отрицательный путь. Подложите кандидата с высоким score, но с истёкшим сроком или запрещённым доступом.
  5. Убедитесь, что excerpt закрытого кандидата не покидает слой проверки. В результате должны остаться только разрешённая причина и маршрут.
  6. Проверьте положительный путь вручную. Откройте exact source, сравните claim с anchor, подтвердите версию и сохраните citation.
  7. Назначьте владельца остаточного риска. Если policy неизвестна, вопрос должен попасть к владельцу policy, а не к слою ответа.
\n

Отрицательный путь важнее красивого ответа

\n

Качество retrieval видно не только по найденным документам. Оно видно по тому, как система ведёт себя при конфликте. Если документ похож на запрос, но истёк, она должна сохранить причину и остановиться. Если фрагмент разрешён, но anchor отсутствует, она не должна ссылаться на всю страницу. Если policy неизвестна, она не должна превращать неизвестность в allow.

\n

Простой тест проверяет именно это поведение: highest-score candidate получает значение expired или access-denied; итоговый объект имеет статус stop; поля claim и citation.excerpt отсутствуют; присутствуют причина и действие владельца. Это проверяет контракт учебного модуля, но не доказывает работу вашей production-базы. Для реальной системы нужны отдельные проверки identity, источника и наблюдаемости.

\n

Ограничения

\n

Эта модель не выбирает лучший алгоритм поиска и не обещает, что keyword search, embeddings или reranking дадут конкретную точность. Она не заменяет классификацию документов, аудит прав и управление версиями. Score остаётся полезным для порядка кандидатов, но не становится доказательством. На качество ответа также влияет полнота корпуса: если нужного документа нет, фильтр не создаст его.

\n

Учебный код не читает настоящую wiki, не обращается к сети и не выполняет реальную authorization decision. Все записи в примере условны. Нельзя переносить его значения дат, score или статусы в production. Переносить стоит только форму результата: accepted либо stop, структурированную причину, citation с anchor и явного владельца следующего действия.

\n

Проверяемый критерий готовности

\n

Работа готова, когда для одного выбранного типа вопроса система может показать полный путь. Разрешённый свежий источник даёт версию, URI, anchor и дату retrieval, после чего человек подтверждает claim. Просроченный, закрытый или нецитируемый кандидат даёт stop без раскрытия запрещённого текста. В обоих случаях есть воспроизводимая проверка и назначенный владелец.

\n

Если тест с высоким score и истёкшим документом всё ещё формирует ответ, проблема находится не в формулировке prompt. Не хватает контракта между индексом, policy и слоем ответа. Сначала добавьте metadata и отрицательную ветку. Только после этого имеет смысл настраивать ranking.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/101.json b/editorial/agent-rewrites/101.json new file mode 100644 index 0000000..fb57da3 --- /dev/null +++ b/editorial/agent-rewrites/101.json @@ -0,0 +1,7 @@ +{ + "index": 101, + "slug": "editorial-2025-03-mechanism-knowledge-retrieval", + "title": "Почему высокий vector score не доказывает ответ", + "excerpt": "Retrieval находит похожие фрагменты, но не подтверждает их свежесть, доступность и смысл. Разбираем границу между ranking и проверяемым источником.", + "contentHtml": "

Проблема. Поиск по инженерной базе возвращает первый фрагмент с высоким vector score. Фрагмент похож на вопрос, поэтому ответ выглядит уверенно. Через несколько дней выясняется, что документ описывает старый контракт, закрыт для автора запроса или не содержит точного правила, на которое ссылается ответ.

\n

Симптом. В результате есть title, excerpt и число вроде 0.98, но нет версии источника, срока действия, access decision и anchor. Инженер меняет код по старой инструкции. Цена ошибки. Команда тратит время на повторное расследование: нужно найти нужную редакцию, выяснить права и отделить цитату от пересказа. Ошибка также подрывает доверие к базе знаний. Следующий хороший фрагмент начинают игнорировать вместе с плохим.

\n

Тезис. Vector score решает одну задачу: упорядочивает похожие candidates в выбранной модели поиска. Он не доказывает истинность claim, право читать источник, актуальность документа и полноту найденных материалов. Поэтому retrieval должен завершаться не выбором top-1, а проверяемым решением: candidate допускается к ответу только после проверок access, freshness, provenance и exact citation.

\n

Где заканчивается score

\n

Nearest-neighbor поиск сравнивает запрос с векторами документов. Его результат помогает сократить очередь чтения. Это полезно, когда корпус велик и человек не может открыть все записи. Но score не знает, кто задаёт вопрос. Он не знает, отозвал ли владелец документ. Он не знает, поддерживает ли найденный абзац именно тот claim, который собирается сделать система.

\n

Порог похожести не исправляет эту границу. Представим учебный корпус из трёх записей. Просроченная инструкция получила score 0.97, закрытое исключение — 0.99, а текущая разрешённая инструкция — 0.91. Фильтр score >= 0.90 оставит все три. Сортировка поставит опасные записи выше нужной. Это не результат реального сервиса, а минимальный пример, который показывает, почему ranking нельзя использовать как authorization или evidence.

\n
Что сообщает каждый сигнал
СигналВопросДействиеЧего он не доказывает
vectorScoreНасколько candidate похож на query?Ставит запись в очередь проверки.Что claim верен, свеж и доступен.
accessLabelsРазрешён ли класс источника requester scope?Отбрасывает закрытый фрагмент.Что выполнена вся реальная policy identity.
publishedAt / expiresAtВходит ли запись в объявленное окно свежести?Отбрасывает будущие и просроченные записи.Что это единственная актуальная редакция.
citationUri#anchorМожно ли открыть точное место в конкретной версии?Делает candidate адресуемым.Что источник поддерживает более широкий вывод.
human verificationСовпадает ли claim с открытым фрагментом?Разрешает сформулировать ответ.Что одна проверка заменяет владельца policy.
\n

Запись индекса должна сохранять происхождение

\n

Chunking часто оставляет в индексе только текст и embedding. Заголовок, версия и срок действия остаются в исходном документе. После retrieval система показывает удобный snippet, но не может ответить на простой вопрос: из какой редакции он взят? Одинаковая фраза может встречаться в новой инструкции, старом RFC и закрытом исключении.

\n

Минимальная запись должна связывать chunk с source record. Source record хранит sourceId, sourceVersion, owner, publishedAt и URI. Chunk хранит sourceId, текстовый фрагмент, citationAnchor, indexedAt, expiresAt и access labels. Retrieval event сохраняет query, момент поиска, список candidates и причины отказа. Система может не передавать все поля в пользовательский интерфейс, но decision должен иметь к ним доступ.

\n

Если у chunk нет стабильной связи с редакцией, он остаётся подсказкой для поиска. Если у него нет anchor, он не становится точной цитатой. Если у него нет declared policy свежести, приложение не должно молча называть его текущим. Эти ограничения лучше показать явно, чем восстанавливать по смыслу из текста.

\n
\"Матрица
Score сокращает очередь проверки. Матрица не объявляет самый похожий фрагмент доказательством: доступ, срок и адресуемая цитата имеют отдельные границы.
\n

Проверяйте источник до составления ответа

\n

Надёжный порядок начинается с evidence, а не с готового текста. Сначала система получает candidates и их metadata. Затем она применяет policy к каждому candidate. Только оставшиеся фрагменты передаются человеку или компоненту, который формулирует ответ. Так нельзя незаметно добавить в draft тезис, которого не было в источнике.

\n
type Candidate = {\n  id: string;\n  score: number;\n  sourceVersion?: string;\n  accessLabels: string[];\n  publishedAt?: string;\n  expiresAt?: string;\n  citationUri?: string;\n  citationAnchor?: string;\n};\n\nfunction admissible(candidate, requesterLabel, retrievalAt) {\n  const allowed = candidate.accessLabels.includes(requesterLabel);\n  const fresh = candidate.publishedAt <= retrievalAt &&\n    (!candidate.expiresAt || retrievalAt < candidate.expiresAt);\n  const citable = Boolean(\n    candidate.sourceVersion &&\n    candidate.citationUri &&\n    candidate.citationAnchor\n  );\n\n  return { allowed, fresh, citable, ok: allowed && fresh && citable };\n}\n\n// Учебный код: локальные значения, без реального corpus и identity provider.
\n

Код показывает контракт, а не готовую систему доступа. Проверка accessLabels.includes не заменяет authentication, authorization policy и аудит. Сравнение дат работает только после фиксации формата и часового пояса. Human verification всё равно нужен: технически доступный anchor может содержать исключение, которое не подтверждает общий claim.

\n

Симптом → причина → проверка → действие

\n
Диагностика retrieval без смешения сигналов
СимптомПричинаПроверкаДействие
Top-1 отвечает на вопрос, но описывает старый контракт.Ranking не учитывает срок действия или version policy.Сравнить publishedAt и expiresAt с зафиксированным retrievalAt.Отклонить просроченный candidate и показать актуальную редакцию либо остановить ответ.
Фрагмент релевантен, но его нельзя открыть.Индекс получил текст без согласованной access boundary.Проверить requester scope, labels и решение владельца ресурса.Убрать candidate из answer path; не маскировать отказ новым пересказом.
Ссылка ведёт на страницу, но не на правило.Сохранён URI без версии и anchor.Открыть ссылку на том же snapshot и найти точный фрагмент.Оставить запись кандидатом, пока источник не станет адресуемым.
Ответ звучит шире цитаты.Генератор обобщил локальное исключение.Сопоставить каждое предложение claim с одним или несколькими фрагментами.Сузить формулировку, добавить limitation или отправить вопрос на ручную проверку.
После пустого результата система всё равно отвечает.Fallback подменяет отсутствие evidence вероятным текстом.Проверить negative path: все candidates expired, закрыты или без anchor.Вернуть stop status с причиной и запросить источник или решение владельца.
\n

Отрицательный путь важнее удачного top-1

\n

Проверять нужно не только случай, где нашлась хорошая запись. Система должна остановиться, если все candidates просрочены, закрыты или не имеют точного locator. Пустой результат честнее уверенного ответа без основания. Его можно объяснить и исправить: обновить документ, выдать доступ, добавить anchor или уточнить вопрос.

\n

Не смешивайте причины в один статус вроде relevance_low. access_denied ведёт к владельцу policy. expired_at_retrieval ведёт к владельцу документа. missing_citation_anchor ведёт к ingestion или структуре источника. Такое разделение экономит время и не толкает команду сразу менять embedding model.

\n

Порядок внедрения проверки

\n
  1. Зафиксируйте query, requester scope и retrievalAt. Один и тот же запрос должен иметь воспроизводимый срез времени.
  2. Сохраните для каждого candidate id, score, source version и все причины будущего отказа. Не передавайте дальше только текстовый snippet.
  3. Проверьте access до раскрытия фрагмента в answer path. Техническая доступность записи не равна праву показать её requester.
  4. Примените freshness policy. Запишите, какое поле и какой срок считаются обязательными для operational question.
  5. Проверьте provenance и citation. URI должен вести к нужной редакции, anchor — к месту, где действительно находится утверждение.
  6. Сопоставьте claim с источником. Если источник поддерживает только условие или исключение, сузьте ответ до этой границы.
  7. Если пересечение пусто, остановите ответ, верните понятный reason code и предложите следующий безопасный шаг.
  8. Закрепите проверки на отрицательных примерах: высокий score у expired, закрытый record и candidate без anchor не должны попасть в citations.
\n

Ограничения

\n

Эта схема не обещает, что vector search найдёт полный корпус. Она не сравнивает качество embedding-моделей и не утверждает, что keyword search всегда лучше. Она также не превращает metadata в доказательство смысла. Свежая доступная цитата может быть двусмысленной или слишком узкой для вопроса.

\n

Учебный код использует локальные значения и упрощённые labels. Он не проверяет реальную личность, RBAC, ABAC, SSO, журнал аудита, конкурентное обновление документа или кэш. Применять его как готовую authorization implementation нельзя. В реальной системе policy должен иметь владельца, а source version и правила expiry должны быть частью явного контракта.

\n

Ограничение есть и у даты. HTTP-поле Last-Modified сообщает время, когда origin считает представление изменённым. Оно не доказывает, что инженерное правило всё ещё применимо. Документ может не меняться и устареть из-за миграции. Поэтому freshness policy должна учитывать смысл вопроса, а не только HTTP timestamp.

\n

Проверяемый критерий готовности

\n

Механизм готов к ограниченному применению, когда для одного зафиксированного query можно показать полный decision trace: candidates с score, source version, access result, freshness result, citation URI с anchor и итоговый human verification. На учебном отрицательном наборе expired, closed и no-anchor записи получают отдельные причины и не попадают в answer.

\n

Дополнительный критерий — повторный запуск с тем же retrievalAt даёт тот же набор допущенных записей, если corpus и policy не менялись. При пустом пересечении система возвращает stop status, а не догадку. Только после этого можно измерять top-k, reranking и стоимость запросов: эти настройки ускоряют поиск кандидатов, но не заменяют границу доказательства.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/102.json b/editorial/agent-rewrites/102.json new file mode 100644 index 0000000..74bb1cc --- /dev/null +++ b/editorial/agent-rewrites/102.json @@ -0,0 +1,7 @@ +{ + "index": 102, + "slug": "editorial-2025-03-practice-knowledge-retrieval", + "title": "Поиск по инженерной базе: как не превратить похожий текст в доказательство", + "excerpt": "Практический маршрут для retrieval по инженерной документации: сначала проверить версию, срок, права и точную цитату, а затем решать, можно ли отвечать.", + "contentHtml": "

Инженер задаёт вопрос по внутренней документации. Поиск возвращает фрагмент с высоким score. Заголовок совпадает, формулировка выглядит знакомой, ответ можно написать за минуту. Потом выясняется, что фрагмент описывает старый контракт, закрыт для этого читателя или ведёт на страницу без точного места в тексте. Ошибка уже повлияла на решение: изменился адаптер, в ответ попал закрытый материал, а reviewer не может быстро проверить источник.

Цена такой ошибки складывается из отката, повторного расследования и потери доверия к базе знаний. Пустой результат заметен. Уверенный пересказ устаревшего правила — нет. Поэтому поисковый ответ должен сначала доказать право на использование конкретной записи, её применимость на момент запроса и адресуемость цитаты. Только после этого можно формулировать вывод.

Тезис: score выбирает кандидата, но не подтверждает утверждение

Similarity search решает узкую задачу: он упорядочивает похожие документы или фрагменты. В score нет ответа на вопросы «кто может читать запись», «действует ли правило сейчас» и «подтверждает ли этот абзац конкретный claim». Эти вопросы требуют других данных и других проверок. Если объединить их в одно число, система перестанет объяснять, почему кандидат отклонён.

Рабочая граница выглядит так: query → candidates → access check → freshness check → exact citation → human verification. Retrieval сокращает очередь чтения. Metadata задают условия допуска. Человек сопоставляет утверждение с источником. Если пересечение условий пусто, система останавливается и сообщает причину. Она не заменяет недостающий evidence правдоподобной фразой.

Симптом → причина → проверка → действие

Диагностика результата поиска по инженерной базе
СимптомПричинаПроверкаДействие
Первый кандидат имеет высокий score, но у него нет версииИндекс хранит chunk и embedding без связи с редакцией источникаНайти stable recordId, sourceVersion и URI исходного документаОставить кандидата для поиска, но не использовать как цитату
Фрагмент точно отвечает на вопрос, но expiresAt уже прошёлРанжирование не учитывает локальную политику актуальностиСравнить expiresAt и зафиксированный retrievedAtОтклонить запись и запросить действующую редакцию
Кандидат закрыт для requesterAccess policy проверяется после генерации или не проверяетсяСопоставить accessLabels записи и scope запроса до показа excerptСкрыть закрытый текст, сохранить безопасную причину отказа
Ссылка ведёт на документ, но не на нужный абзацВ индексе нет citationAnchorОткрыть URI и проверить точный раздел, строку или якорьОстановить ответ до появления адресуемого фрагмента
Ответ написан, ссылки добавлены позжеТекст успел включить выводы, которых нет в evidenceСравнить каждый claim с источником до публикацииСначала собрать допущенные citations, затем писать ответ

Механизм: запись индекса должна нести provenance

Голый фрагмент удобен для векторного поиска, но плох для проверки. Минимальная запись связывает chunk с источником и сохраняет границы его использования. Нужны устойчивый recordId, sourceVersion, publishedAt, indexedAt, expiresAt, класс доступа, citationUri и citationAnchor. Поле vectorScore тоже полезно, но только для порядка кандидатов.

publishedAt отвечает на вопрос, существовала ли редакция к моменту поиска. indexedAt показывает задержку между публикацией и попаданием в индекс. expiresAt выражает локальное правило применимости. retrievedAt фиксирует срез, на котором система приняла решение. Ни одна из этих дат сама по себе не доказывает смысл утверждения. Вместе они позволяют воспроизвести проверку.

Такая схема важна после chunking. Когда документ режут на части, title и текст часто сохраняют, а версию, владельца и раздел оставляют в исходном хранилище. Затем в ответ попадает удобный snippet, который нельзя связать с конкретной редакцией. Metadata должны наследоваться каждым chunk или однозначно находиться по stable source id. Иначе retrieval создаёт видимость точности, а не проверяемую provenance.

Схема пути от запроса и retrieval через проверки прав и свежести к точной цитате и human verification; при отсутствии условий ответ останавливается.
Score сокращает список кандидатов. Ответ появляется только после проверки свежего, разрешённого и адресуемого источника.

Пример: фильтр допуска перед формированием ответа

Ниже — учебный пример на фиксированных синтетических записях. Он показывает порядок решения и отрицательный путь. Он не обращается к реальной базе, identity provider, часам, production или сети. Синтетические значения нужны только для проверки контракта обработки.

const retrievedAt = '2025-03-17T10:00:00Z';\nconst requesterLabels = ['engineering-read'];\n\nconst candidates = [\n  {\n    recordId: 'adapter-v1',\n    sourceVersion: 'v1',\n    expiresAt: '2025-03-01T00:00:00Z',\n    accessLabels: ['engineering-read'],\n    citationUri: 'https://docs.example.test/adapter',\n    citationAnchor: '#old-field',\n    vectorScore: 0.97,\n  },\n  {\n    recordId: 'adapter-v3',\n    sourceVersion: 'v3',\n    expiresAt: '2025-04-01T00:00:00Z',\n    accessLabels: ['engineering-read'],\n    citationUri: 'https://docs.example.test/adapter',\n    citationAnchor: '#schema-upgrade',\n    vectorScore: 0.91,\n  },\n];\n\nconst allowed = candidates.filter((item) =>\n  item.expiresAt > retrievedAt &&\n  item.accessLabels.some((label) => requesterLabels.includes(label)) &&\n  item.citationUri && item.citationAnchor,\n);\n\nif (!allowed.length) {\n  throw new Error('stop-no-fresh-authorized-citable-source');\n}\n\n// Учебный результат: v1 имеет больший score, но истёк.\n// v3 остаётся кандидатом для human verification.

Здесь высокий score у adapter-v1 не отменяет истечение срока. adapter-v3 проходит технический фильтр, но это ещё не автоматическое доказательство ответа. Reviewer должен открыть citationUri#schema-upgrade и проверить, что фрагмент действительно говорит о нужной замене. Если у всех записей истёк срок, нет доступа или отсутствует anchor, функция должна вернуть stop condition. Скрытый fallback на старый текст создаёт именно ту ошибку, от которой защищает схема.

Проверка должна идти до генерации текста

Безопасный порядок начинается с вопроса и времени retrieval. Затем система сохраняет candidates вместе с score и metadata. После этого она проверяет права, срок и citation. В ответ проходит только допущенная запись. Generator или автор получают ограниченный набор evidence, а не абстрактное «знание базы». Для каждого отклонённого кандидата остаётся причина: access-label-not-granted, expired-at-retrieval-time или missing-exact-citation-anchor.

Ссылка в конце готового текста не исправляет неверный порядок. Draft уже мог добавить условие, которого нет в документе, или смешать две версии. Citation должна появиться в момент выбора evidence. Тогда reviewer видит claim, sourceVersion, дату и точный fragment, а не пытается восстановить происхождение ответа по памяти.

HTTP-метаданные помогают, но не заменяют внутреннюю политику. В RFC 9110 Last-Modified описывает время, когда origin server считает изменённым выбранное представление. Это полезный сигнал о представлении, но не вся политика актуальности инженерного правила. Документ может иметь собственный срок пересмотра, дату deprecation или область действия. В ответе нужно назвать, какое условие применялось.

Порядок действий

  1. Зафиксируйте точный query, requester scope и retrievedAt. Не меняйте эти значения в середине проверки.
  2. Получите top-k candidates и сохраните для каждого recordId, score, sourceVersion и все поля допуска. Не оставляйте только текстовый snippet.
  3. Проверьте access до показа excerpt. Неподходящий label превращает запись в diagnostic result, а не в материал для ответа.
  4. Сравните publishedAt и expiresAt с retrievedAt по заранее объявленной policy. Не используйте «выглядит свежим» как критерий.
  5. Проверьте citationUri и citationAnchor. Они должны вести к конкретной редакции и месту, которое можно открыть и сопоставить с claim.
  6. Откройте источник и проверьте смысл утверждения. Зафиксируйте короткое подтверждение или точную границу применимости.
  7. Если хотя бы одно обязательное свойство отсутствует у всех candidates, верните stop condition с reject reasons и владельцем следующего действия.

Что показывать читателю

Проверяемый ответ не обязан раскрывать внутренний record целиком. Читателю достаточно названия источника, версии, citation, дат публикации и retrieval, а также краткого ограничения. Конкретные роли, токены и закрытые поля не нужно включать в provenance карточку. Класс доступа можно показать только тогда, когда это разрешает сама policy.

Отдельно храните result decision и human verification. Статус candidate-found означает, что поиск нашёл похожий материал. Статус citation-accepted означает, что запись прошла технические условия. Это всё ещё не равно «утверждение истинно», пока человек не сопоставил claim с фрагментом. Такое разделение делает интерфейс честнее: пользователь видит, что уже проверено, а что ещё нет.

Ограничения и отрицательный путь

Эта схема не доказывает полноту корпуса, качество embeddings, корректность access policy или семантическую истинность ответа. Она не говорит, что vector search лучше keyword search. Она задаёт более узкую границу: score не заменяет version, freshness, authorization и citation. Учебный код не является benchmark и не сообщает production-результаты.

Если источник закрыт, просрочен или не имеет точного anchor, система не должна пересказывать его «для справки». Безопасное действие — показать безопасную причину, сохранить recordId без restricted excerpt и направить вопрос владельцу документа или policy. Если policy неизвестна, нельзя молча выбрать allow или deny как окончательное решение: нужен owner и явное правило. Отрицательный путь входит в контракт наравне с успешным.

Проверяемый критерий готовности

Один вопрос должен проходить тестовый набор из четырёх случаев: свежая разрешённая запись с anchor, просроченная запись с высоким score, закрытая запись с высоким score и запись без anchor. В первом случае результат содержит citation и требует human verification. В трёх остальных случаях citation не появляется, а decision содержит конкретную причину отказа. Повторный запуск с теми же query, retrievedAt и входными записями даёт тот же decision. Это проверяемый критерий готовности маршрута.

После этого можно отдельно измерять recall, latency и качество ранжирования. Такие метрики улучшают поиск кандидатов, но не отменяют проверку допуска. Если команда не может показать, почему выбран конкретный fragment и почему отклонены остальные, retrieval ещё не стал надёжным источником ответа.

Проверяемые источники

" +} \ No newline at end of file diff --git a/editorial/agent-rewrites/103.json b/editorial/agent-rewrites/103.json new file mode 100644 index 0000000..492152d --- /dev/null +++ b/editorial/agent-rewrites/103.json @@ -0,0 +1,7 @@ +{ + "index": 103, + "slug": "editorial-2025-02-field-ai-code-verification", + "title": "Как проверить AI-предложение кода до merge", + "excerpt": "Зелёный основной сценарий не доказывает сохранность API, входного состояния и правил доступа. Разбираем три границы, отрицательные проверки и критерий готовности изменения.", + "contentHtml": "

Проблема: основной тест проходит, но изменение возвращает не то поле, меняет входной объект или пропускает запрос без роли; цена ошибки — повторная отладка, задержка релиза и риск отказа пользователю.

\\n

Тезис простой: проверяйте не убедительность AI-предложения и не число зелёных статусов, а границы контракта. Для каждой границы нужна короткая цепочка доказательств: что обещает код, что наблюдает проверка, какой отрицательный путь рассмотрен и какое действие следует из результата. Если сигналы относятся к разным контрактам, merge нельзя считать готовым.

\\n

Механизм: один сигнал закрывает только свой вопрос

\\n

Контракт описывает неизменяемое условие. Это форма результата, сохранность аргумента или список разрешённых ролей. Статический анализ показывает структуру кода. Позитивный тест показывает разрешённый путь. Негативный тест показывает отказ. Review связывает эти наблюдения с контекстом вызова. Ручной сценарий проверяет эффект на границе системы.

\\n

Ни один сигнал не даёт общего вердикта. Линтер не знает, какое имя поля ждёт потребитель. Тест строки не видит мутацию объекта. Успешный сценарий редактора не доказывает запрет для пустой роли. Поэтому сначала назовите риск как наблюдаемый разрыв, затем выберите проверку, которая может его опровергнуть.

\\n

Пример 1. Значение верно, форма API нарушена

\\n

Mapper получает subtotalCents и taxCents. Он правильно складывает их. Потребитель ожидает объект с полем amountCents, но предложенный код возвращает total. Тест арифметики остаётся зелёным: число не изменилось. Ошибка появляется на границе потребителя.

\\n

Проверка должна сравнить не только значение, но и публичную форму. Нужны assertion на ключи результата и тест, который читает объект так же, как реальный потребитель. Если переименование действительно нужно, его оформляет владелец API. Нельзя переписать тест под новое поле и объявить проблему решённой: так тест закрепит решение, которого ещё никто не принял.

\\n

Пример 2. Preview выдаёт правильный текст, но меняет состояние

\\n

Функция с именем preview должна вычислить результат и вернуть его. Внутри она выполняет draft.status = 'normalized'. Caller передал свой объект и ожидает, что после preview он останется прежним. Проверка ответа не замечает побочный эффект, потому что строка выглядит правильно.

\\n

Здесь нужен снимок входа до вызова и сравнение после вызова. Статическое правило может искать присваивание аргументу, но оно не заменяет проверку владения данными. Исправление — создать производное значение без мутации или вынести изменение в отдельную явно названную операцию. Название функции не является доказательством поведения.

\\n

Пример 3. Позитивный путь не защищает правило доступа

\\n

Контракт разрешает только роль editor. Условие actorRole !== 'viewer' блокирует viewer, но пропускает запрос без роли. Тест для editor сообщает только один факт: разрешённый путь работает. Даже тест для viewer не подтверждает, что отсутствие значения отклоняется.

\\n

Явный allow-list делает правило проверяемым:

\\n
function canEdit(actorRole) {\\n  const allowedRoles = new Set(['editor']);\\n  return allowedRoles.has(actorRole);\\n}\\n\\nexpect(canEdit('editor')).toBe(true);\\nexpect(canEdit('viewer')).toBe(false);\\nexpect(canEdit()).toBe(false);
\\n

Это учебный пример малого контракта. Он не проверяет токены, identity provider, tenant boundary, сессии или threat model реального приложения. Он показывает другое: разрешённое значение задано явно, а отрицательная ветка входит в контракт. Для production нужны отдельные проверки всей цепочки авторизации.

\\n
\"Цепочка
Цепочка связывает контракт, статический анализ, позитивный и негативный тесты, review и ручной сценарий. Каждый этап отвечает только за свою границу.
\\n

Симптом → причина → проверка → действие

\\n
СимптомПричинаПроверкаДействие
Число верно, потребитель не находит полеПроверили значение, но не форму результатаСравнить ключи и выполнить тест через потребительВернуть имя поля или принять отдельное решение о совместимости
Preview меняет draftВычисление смешано с мутацией входаСравнить объект до и после; проверить присваивания аргументуСоздать производное значение или назвать мутацию отдельной операцией
Запрос без роли проходитУсловие перечисляет исключение, а не разрешённые ролиДобавить проверку отсутствующей роли и прочитать ветви от allowОставить явный allow-list и default-deny
Все статусы зелёные, но scope различаетсяПроверки относятся к разным контрактамСопоставить вход, выход и границу каждого сигналаЗаблокировать merge до единого scope или решения владельца
\\n

Порядок проверки перед merge

\\n
  1. Зафиксируйте контракт. Запишите форму результата, инвариант входа или точный allow-list. Формулировка «код должен быть качественным» не подходит для проверки.
  2. Определите scope. Укажите вход, выход, потребителя и границу изменения. Сверьте, что статический анализ, тест и review смотрят на один scope.
  3. Проверьте основной путь. Убедитесь, что разрешённый сценарий даёт ожидаемый результат и не меняет состояние сверх контракта.
  4. Проверьте отрицательный путь. Добавьте отказ, пустое значение, неверный тип или запрещённую роль — тот случай, который может опровергнуть исходное предположение.
  5. Проверьте побочные эффекты. Сравните входное состояние до и после. Для функций preview, format и calculate отдельно подтвердите отсутствие мутации.
  6. Сработайте stop при расхождении. Сохраните наблюдение и не продолжайте подготовку merge. Не подменяйте этот шаг автоматическим revert или rollback.
  7. Сформулируйте вопрос владельцу. Назовите выбор, недостающее доказательство, полномочие принимающего решение и условие снятия блокировки.
  8. Повторите проверки после правки. Новый diff должен пройти тот же контракт, отрицательные сценарии и проверку побочных эффектов.
\\n

Что означает stop, а что — revert и rollback

\\n

Stop означает только одно: не продолжать подготовку merge, пока доказательства расходятся. Он не меняет историю системы и не восстанавливает доставленное состояние. Revert отменяет конкретное изменение в истории версий. Rollback возвращает уже работающую систему к прежнему состоянию и требует отдельной проверки данных, scope и полномочий. Называть любой block rollback нельзя: это создаёт видимость готового пути восстановления.

\\n

Human approval нужен после ясной формулировки выбора. Например, владелец может сохранить старое поле ради совместимости, принять переименование с обновлением потребителей или потребовать исправить правило доступа. Approval не закрывает пробел в доказательствах. Если reviewer не может назвать контракт, границу пользователя и остаточный риск, вопрос ещё не готов.

\\n

Ограничения

\\n

Примеры в статье учебные. В них нет настоящего репозитория, CI, токенов, пользователей, telemetry или production-нагрузки. Имена полей, ролей и функций подобраны для объяснения механизма. Из зелёного учебного теста нельзя вывести отсутствие уязвимости в реальной авторизации. Из одной цепочки доказательств нельзя вывести готовность релиза.

\\n

Официальная документация инструментов тоже имеет границы. GitHub описывает code review как комментарии и рекомендации, а обязательное решение об изменении остаётся за процессом команды. NIST задаёт практики безопасной разработки, но не выбирает ваши контракты и владельцев риска. OWASP подчёркивает пользу ручной проверки там, где автоматические средства не видят бизнес-логику и контекст. Эти источники поддерживают многослойную проверку, но не подтверждают конкретный учебный пример.

\\n

Проверяемый критерий готовности

\\n

Изменение готово к approval, если для каждой затронутой границы выполнены четыре условия: контракт записан; основной и отрицательный пути проверены; входное состояние не меняется без явного разрешения; каждый сигнал относится к тому же scope, что и diff. В записи есть вердикт merge, revise или block. Для merge указан владелец решения. Для block указано доказательство, которое снимет блокировку. Если хотя бы одно условие не выполнено, готовность не доказана.

\\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/104.json b/editorial/agent-rewrites/104.json new file mode 100644 index 0000000..378b120 --- /dev/null +++ b/editorial/agent-rewrites/104.json @@ -0,0 +1,7 @@ +{ + "index": 104, + "slug": "editorial-2025-02-mechanism-ai-code-verification", + "title": "Как проверить сгенерированный код, когда зелёных сигналов недостаточно", + "excerpt": "Сгенерированный diff может пройти линтер и happy path, но нарушить контракт, изменить чужое состояние или пропустить отрицательную ветку. Разбираем, как связать риск с независимой проверкой и когда остановить merge.", + "contentHtml": "

Сгенерированный diff может пройти линтер и unit test, но сломать следующего потребителя. Mapper вернул число, хотя API требует поле amountCents. Функция preview показала правильную строку, но изменила объект, который передал вызывающий код. Проверка роли пропустила редактора, но не проверила отсутствие роли.

\n

Цена ошибки начинается не с плохого ответа модели. Она начинается с ложной уверенности. Команда видит несколько зелёных статусов, считает риск закрытым и узнаёт о нарушенной границе в следующем consumer, review или релизном сценарии. По числу зелёных индикаторов нельзя вывести размер ущерба. Но можно заранее не принять их за одно доказательство.

\n

Тезис статьи простой: проверка должна связывать наблюдаемый риск с конкретным свидетельством. Контракт проверяет форму и инварианты. Статический анализ ищет формальный паттерн. Узкий тест проверяет названную ветку. Человек проверяет намерение и контекст. Ручной сценарий смотрит на путь потребителя. Эти способы пересекаются, но не заменяют друг друга.

\n

Механизм: риск → свидетельство → граница

\n

Начните не с инструмента, а с разрыва. «AI ошибся» не помогает выбрать проверку. «Публичное поле переименовано», «входной объект изменился после вызова» и «пустая роль получила доступ» уже задают наблюдаемые вопросы.

\n

Затем выберите свидетельство, которое может опровергнуть именно этот разрыв. Для формы результата подойдёт assertion на схему и тест реального consumer. Для ownership нужен снимок входа до и после вызова. Для доступа нужен allow-list и отрицательный тест для неизвестного значения. Если check не способен увидеть риск, его зелёный результат ничего о риске не говорит.

\n

Последняя часть — граница. Контракт не доказывает безопасность всей системы. Линтер не понимает смысл каждого вызова. Тест не проверяет ветку, которую в него не внесли. Review зависит от контекста и внимания. Ручное воспроизведение не становится регрессионным тестом само по себе.

\n
\"Матрица
Матрица связывает классы риска с проверками. Зелёная клетка означает релевантное свидетельство, а не гарантию отсутствия ошибки.
\n

Три учебных примера

\n

Ниже приведены фиксированные учебные случаи. Они не читают репозиторий, не вызывают модель и не показывают результат реального проекта. Их задача — показать форму рассуждения: наблюдение, причина, проверка и действие.

\n

1. Число верно, форма API неверна

\n

Контракт mapper требует объект { amountCents: number }. Сгенерированный код складывает subtotalCents и taxCents, но возвращает { total: 1234 }. Happy-path test проверяет только значение суммы. Он зелёный: арифметика правильная.

\n

Потребитель читает result.amountCents и получает undefined. Симптом — зелёный тест рядом с неверной формой результата. Причина — тест описывает значение, а не публичный shape. Проверка — сравнить assertion контракта, тест consumer и вопрос reviewer: «Переименование поля принято отдельно?». Действие — вернуть amountCents или оформить совместимость как отдельное решение. Нельзя переписать тест на total только ради зелёного статуса.

\n

2. Preview меняет входной объект

\n

Helper с именем preview получает объект заявки и возвращает правильный текст. Внутри он выполняет draft.status = 'normalized'. Если объект принадлежит caller, после preview следующий код видит изменённое состояние. Результат на экране правильный, но операция имеет побочный эффект.

\n
function preview(draft) {\\n  draft.status = 'normalized'; // скрыто меняет объект caller\\n  return render(draft);\\n}\\n\\nconst draft = { status: 'raw' };\\nconst text = preview(draft);\\n\\n// text выглядит правильно, но draft.status уже равен 'normalized'
\n

Симптом — output совпал, а state изменился. Причина — контракт не разделяет производное значение и владение входом. Проверка — сравнить вход до и после вызова и отдельно просмотреть записи в аргумент. Действие — создать производный объект, оставить аргумент неизменным или назвать мутацию отдельной операцией. Проверка результата строки здесь недостаточна.

\n

3. Accept-case не доказывает default-deny

\n

Учебный контракт разрешает роль editor и отклоняет viewer и отсутствие роли. Условие actorRole !== 'viewer' пропускает редактора, отклоняет viewer, но также пропускает undefined. Если suite содержит только тест редактора, она доказывает один accept path и ничего не говорит о неизвестном значении.

\n

Это не доказанная уязвимость реальной авторизации: в примере нет токенов, tenant boundary, identity provider и threat model. Но конкретный контракт уже нарушен. Проверка должна включить viewer и missing-role, а reviewer должен прочитать условие как allow-list. Для доступа отрицательная ветка — часть правила, а не дополнительный тест «на всякий случай».

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Тест проверяет число, consumer не находит полеЗначение приняли за форму APIContract assertion и consumer testСохранить поле или принять отдельное compatibility decision
Preview возвращает правильный текст, вход изменилсяНе определено владение аргументомСравнить input до/после и найти запись в аргументВернуть derived value без мутации или назвать мутацию явно
Редактор проходит, пустая роль тожеAccept-case заменил allow-listПроверить viewer и missing-roleЯвно разрешить роли и отклонять неизвестные
Линтер зелёный, риск не названДля риска нет формального правилаСверить scope правила с contract boundaryДобавить assertion, тест или вопрос reviewer
\n

Почему независимость важнее количества

\n

Два свидетельства независимы не потому, что их назвали разными словами. Они независимы, когда могут опровергнуть разные предпосылки. Contract check и consumer test частично пересекаются: один смотрит на declared shape, другой — на использование поля. Это полезное пересечение. Ошибка в одном тесте не должна автоматически скрыть ошибку в другом.

\n

Опаснее повторить одну неверную модель. Сгенерированный код и сгенерированный тест могут вместе решить, что отсутствие роли означает «не viewer». Второй зелёный статус тогда только усиливает доверие к ошибке. Добавьте проверку, которая получает другое основание: allow-list, внешний контракт или вопрос владельцу политики.

\n

Не нужно складывать статусы в процент корректности. Static check быстро ловит формальный паттерн, но требует правила. Focused test делает один сценарий воспроизводимым, но не видит неназванную ветку. Human review замечает скрытую границу, но не заменяет точный expected result. Manual reproduction показывает путь потребителя, но плохо масштабируется. Выбирайте самый дешёвый способ, который способен оспорить текущую гипотезу.

\n

Минимальный учебный прогон

\n

В этом примере report собирает пять видов свидетельств. При расхождении он не превращает результат в «почти готово». Он возвращает решение остановить подготовку merge до выяснения контракта.

\n
const report = inspectGeneratedDiff({\\n  risk: 'public-field-renamed',\\n  contract: { amountCents: 'number' },\\n  result: { total: 1234 },\\n  checks: ['static', 'focused-test', 'review', 'manual']\\n});\\n\\nif (report.contractAgrees === false) {\\n  return { merge: 'blocked', reason: 'evidence-disagrees' };\\n}
\n

Код выше — учебная схема, а не готовый пакет и не production-рецепт. Она показывает важное свойство: решение опирается на конкретное расхождение, а не на число пройденных команд. В реальном проекте названия полей, правила и тестовые входы должны соответствовать фактическому контракту.

\n

Порядок действий

\n
  1. Сузьте scope. Назовите один diff, одну границу контракта и один ожидаемый эффект.
  2. Запишите риск наблюдаемым разрывом. Укажите поле, состояние, роль или побочный эффект, который может нарушиться.
  3. Выберите прямое свидетельство. Добавьте contract assertion, статическое правило, positive/negative test или consumer scenario по смыслу риска.
  4. Проверьте повтор предпосылки. Спросите, не повторяют ли code и test одно ошибочное правило.
  5. Запишите stop condition. Например: «contract и consumer test требуют разные поля» или «input boundary не доказана».
  6. Передайте вопрос владельцу. После сбора фактов попросите выбрать revise, compatibility decision или approval остаточного риска.
\n

Ограничения

\n

Эта схема не доказывает корректность кода, безопасность, отсутствие дефектов или готовность релиза. Она не заменяет threat model, анализ зависимостей, интеграционные тесты, наблюдение и правила доступа. Один учебный отрицательный тест не покрывает все реальные роли и переходы состояний.

\n

Stop означает только «не продолжать подготовку merge при расхождении evidence». Он не означает revert или rollback. Revert требует решения о конкретном изменении в истории VCS. Rollback относится к уже доставленному состоянию и требует подтверждённого пути восстановления. Не подменяйте отсутствие решения словом rollback.

\n

Источники тоже не дают готового verdict. Они поддерживают практику, но не выбирают порог для вашего репозитория. GitHub описывает Copilot code review как комментарий, который не заменяет required approval. NIST связывает review и analysis с процессом разработки и triage findings. OWASP подчёркивает, что автоматизированные инструменты дополняют ручной анализ там, где нужен бизнес-контекст.

\n

Проверяемые источники

\n\n

Проверяемый критерий готовности

\n

Diff готов к передаче на human approval только тогда, когда для каждого заявленного риска записаны scope, прямое свидетельство, отрицательный путь и граница того, чего проверка не доказывает. Contract, test и reviewer rationale не должны противоречить друг другу. Если противоречие осталось, готовый результат — не зелёный статус, а понятный stop с вопросом владельцу.

" +} diff --git a/editorial/agent-rewrites/105.json b/editorial/agent-rewrites/105.json new file mode 100644 index 0000000..ab4eb6c --- /dev/null +++ b/editorial/agent-rewrites/105.json @@ -0,0 +1,7 @@ +{ + "index": 105, + "slug": "editorial-2025-02-practice-ai-code-verification", + "title": "Зелёный тест не доказывает корректность AI-изменения", + "excerpt": "Как проверить сгенерированный diff по контракту, отрицательной ветке, побочным эффектам и независимому review — и когда остановить merge.", + "contentHtml": "

Сгенерированный diff может пройти линтер и один unit-тест, но сломать потребителя. Типичный симптом — тест проверяет значение, а публичный контракт требует другое имя поля. Другой симптом — preview возвращает правильный текст, но меняет входной объект. Третий — роль editor проходит, а пустая роль тоже получает доступ. Цена ошибки начинается с повторного разбора и задержки merge. В реальной системе она может включать неверные данные, отказ функции или нарушение политики доступа. По одному зелёному статусу цену не определить.

\n

Тезис простой: результат AI-инструмента — это материал для проверки, а не доказательство. Корректность появляется только тогда, когда заявленный контракт, наблюдаемое поведение и решение ревьюера совпадают. Каждый сигнал должен отвечать на свой вопрос. Тест проверяет названный сценарий. Статический анализ ищет известный паттерн. Review проверяет намерение и границы. Ручное воспроизведение смотрит на путь потребителя.

\n

Сначала назовите границу изменения

\n

До запуска тестов запишите, что именно нельзя нарушить. Для mapper это форма результата и имена полей. Для функции preview — владение входным объектом и запрет на скрытую мутацию. Для проверки роли — список разрешённых значений и поведение при отсутствии роли. Для обработчика ошибки — статус, формат ответа и отсутствие утечки деталей.

\n

Фраза «функция должна вернуть итог» слишком широкая. Рабочая формулировка выглядит так: «при входе с subtotalCents и taxCents вернуть объект { amountCents: number }; вход не менять». В ней есть вход, выход и инвариант. По ней можно написать проверку и понять, где заканчивается её область действия.

\n
contract = {\n  input: { subtotalCents: 900, taxCents: 100 },\n  output: { amountCents: 1000 },\n  invariant: 'input remains unchanged'\n}
\n

Этот фрагмент — учебный пример. Он не обращается к репозиторию, базе, CI или production. Его задача — показать форму контракта, а не предсказать поведение конкретной модели.

\n
\"Воронка
Проверка сужает вопрос по шагам. Ни один шаг не превращается в универсальную гарантию.
\n

Как один зелёный тест пропускает ошибку

\n

Представим учебную функцию, которую изменил ассистент:

\n
function toInvoice(input) {\n  return {\n    total: input.subtotalCents + input.taxCents\n  };\n}\n\nconst result = toInvoice({ subtotalCents: 900, taxCents: 100 });\nconsole.assert(result.total === 1000);
\n

Тест зелёный. Арифметика верна. Но контракт требует amountCents, а consumer читает result.amountCents. Значение становится undefined. Ошибка возникла не в сложной математике. Тест задал слишком узкий вопрос и не проверил форму публичного результата.

\n

Исправленная проверка должна смотреть на поле, доступное потребителю:

\n
const input = { subtotalCents: 900, taxCents: 100 };\nconst result = toInvoice(input);\n\nconsole.assert(result.amountCents === 1000);\nconsole.assert(!('total' in result));\nconsole.assert(input.subtotalCents === 900);\nconsole.assert(input.taxCents === 100);
\n

Второй пример тоже учебный. Он не доказывает безопасность функции и не заменяет тесты всех реальных потребителей. Он показывает, как превратить контракт в наблюдаемое утверждение.

\n

Симптомы и действия

\n
Диагностика небольшого AI-generated diff
СимптомПричинаПроверкаДействие
Тест проверяет число, consumer не находит полеПроверили значение, но не shapeСравнить ожидаемые ключи и чтение consumerВернуть контрактное поле или отдельно принять изменение API
Preview выдаёт верный текст, вход изменилсяНе зафиксировано владение inputСравнить объект до и после вызоваСоздать derived value или назвать мутацию отдельной операцией
Editor проходит, missing role тоже проходитЕсть accept case, но нет default-denyПроверить viewer и отсутствие значенияЗаписать allow-list и добавить отрицательные тесты
Линтер чистый, смысл изменения неясенПаттерн проверен вместо намеренияСопоставить diff с контрактом и owner boundaryУменьшить scope и запросить предметный review
Ручной сценарий расходится с unit-тестомТест не повторяет путь потребителяВоспроизвести тот же вход от API или UI до результатаОстановить merge до объяснения расхождения
\n

Почему независимые проверки не складываются в одну гарантию

\n

Статический анализ видит то, для чего у него есть правило. Он может найти присваивание аргументу или подозрительный вызов. Он не знает, разрешена ли мутация в конкретном API. Unit-тест видит только входы и ожидания, которые записал автор. Он не проверяет незаписанный путь. Review видит контекст, но зависит от размера diff, ясности требования и времени ревьюера. Ручная проверка видит один маршрут пользователя и может не охватить редкий вариант.

\n

Поэтому пять зелёных сигналов могут повторять одну предпосылку. Например, линтер, unit-тест и screenshot подтверждают, что экран получил число. Ни один из них не подтверждает, что имя публичного поля осталось прежним. Независимость означает не количество инструментов, а разные вопросы и разные способы обнаружить нарушение.

\n

Нужно заранее написать blind spot каждого шага. Контракт не доказывает реализацию. Тест не доказывает покрытие всех consumers. Review не доказывает отсутствие runtime-ошибки. Такой список не делает систему безопасной сам по себе. Он не даёт зелёному статусу большего смысла, чем тот, который реально проверен.

\n

Порядок проверки одного небольшого diff

\n
  1. Сузьте scope. Назовите изменённый файл, одну границу и один ожидаемый эффект. Если описание требует нескольких страниц, diff уже не подходит для короткой проверки.
  2. Запишите контракт. Укажите вход, выход, запрещённый side effect и условие отказа. Не заменяйте их словами «работает правильно».
  3. Добавьте отрицательный путь. Проверьте старое поле, отсутствие значения, чужую роль, неверный формат или отказ внешней зависимости — тот случай, который реально относится к границе.
  4. Сравните output и state. Для mapper проверьте shape. Для preview сравните input до и после. Для access rule проверьте не только ответ 200, но и условие допуска.
  5. Проведите предметный review. Ревьюер должен назвать контракт, остаточный риск и владельца решения. Просьба «посмотреть AI-код» не задаёт проверяемый вопрос.
  6. Повторите короткий маршрут потребителя. Используйте фиксированный учебный вход или безопасный тестовый объект. Сопоставьте результат с тем, что увидит consumer.
  7. Остановите подготовку merge при расхождении. Не подгоняйте тест под код и не добавляйте случайный запуск ради зелёного статуса. Сначала объясните конфликт или измените контракт явно.
\n

Отрицательный путь важнее ещё одного happy path

\n

Отрицательная ветка должна следовать из контракта. Если неизвестная роль не должна получать доступ, проверяйте и viewer, и отсутствие роли. Если preview не меняет вход, проверяйте равенство объекта после вызова. Если поле нельзя переименовывать без совместимости, проверяйте ключ результата и чтение старого consumer.

\n
function canEdit(actorRole) {\n  return actorRole === 'editor';\n}\n\nconsole.assert(canEdit('editor') === true);\nconsole.assert(canEdit('viewer') === false);\nconsole.assert(canEdit(undefined) === false);
\n

Это не threat model и не доказательство безопасности авторизации. В примере нет identity provider, tenant boundary, токена или реального хранилища прав. Он проверяет только заявленное правило для трёх фиксированных входов. Если production-контракт шире, пример нужно расширить фактическими условиями, а не переносить его verdict.

\n

Когда нужно остановиться

\n

Практический stop condition таков: contract, отрицательный тест и rationale ревьюера описывают разные результаты. Тогда merge preparation блокируется. Это не автоматический revert и не rollback. Ничего доставленного система не меняет. Команда только прекращает движение спорного diff, пока владелец границы не выберет одно из действий: вернуть код к контракту, оформить совместимое изменение или собрать недостающее свидетельство.

\n

Если расхождение нельзя объяснить одним предложением, не передавайте diff на approval. Approval должен отвечать на конкретный вопрос: можно ли принять переименование поля, кто владеет совместимостью, почему мутация разрешена или какое правило действует для отсутствующей роли. Человек утверждает осознанное исключение, а не пустоту в проверке.

\n

Ограничения

\n

AI-проверка не заменяет знание домена, threat model, тесты реальных интеграций и ответственность владельца. Ассистент может придумать несуществующий API, пропустить условие, удалить падающий тест или предложить зависимость с неподходящей лицензией. Статический инструмент может не понимать бизнес-правило. Ручной review тоже может пропустить ошибку.

\n

Все функции и данные в примерах вымышлены и предназначены только для обучения. В статье нет production-измерений, утверждений о качестве конкретной модели и обещания, что описанный набор шагов обнаружит любой дефект. Перед применением нужно заменить учебный контракт фактическим API, входами, ролями и условиями доставки.

\n

Проверяемый критерий готовности

\n

Небольшой diff готов к следующему решению, когда выполнены четыре условия: контракт записан; позитивный и отрицательный сценарии проходят на одних и тех же входных данных; побочный эффект либо запрещён и проверен, либо назван частью контракта; ревьюер может объяснить, что проверено и что осталось вне scope. Если хотя бы одно условие не выполнено, статус «зелёный» описывает запуск инструмента, а не готовность изменения.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/106.json b/editorial/agent-rewrites/106.json new file mode 100644 index 0000000..952003e --- /dev/null +++ b/editorial/agent-rewrites/106.json @@ -0,0 +1,7 @@ +{ + "index": 106, + "slug": "editorial-2025-01-field-ai-coding-assistant", + "title": "AI-помощник перед merge: как проверить diff, а не впечатление", + "excerpt": "Сгенерированный diff может пройти happy path и всё равно выйти за scope, изменить контракт или не иметь теста для изменённой ветки. Разбираем проверяемый gate перед merge: граница задачи, отрицательный путь, evidence и критерий готовности.", + "contentHtml": "

Перед merge лежит небольшой сгенерированный diff. Он исправляет видимый симптом, тест рядом зелёный, а код выглядит аккуратно. Через час выясняется, что правка изменила ветку отказа, добавила доступ к данным или превратила ошибочный вход в допустимый. Цена ошибки — не только откат. Нужно найти затронутый контракт, остановить выпуск, проверить уже собранные артефакты и вернуть доверие к проверке.

\n

Тезис простой: ответ AI-помощника — кандидат на изменение, а не доказательство корректности. Перед merge нужно проверить три границы: diff меняет только разрешённый scope, сохраняет контракт и имеет evidence для каждой изменённой ветки. Линтер и успешный happy path закрывают лишь часть вопросов.

\n

Что именно проверяет gate

\n

Verification gate отделяет candidate diff от решения человека. Он не одобряет pull request автоматически и не обещает безопасность. Он собирает четыре наблюдаемых поля: задачу, разрешённые пути, условия контракта и проверку результата. Если поле не заполнено, verdict должен остановиться на stop, а не превращаться в «скорее всего, всё хорошо».

\n
const review = {\n  task: 'normalize invoice key',\n  allowedPaths: ['src/invoice/normalizeKey.ts'],\n  forbiddenEffects: ['change authorization', 'write on invalid input'],\n  evidence: [\n    { input: 'valid-key', expected: 'valid-key', writes: 0 },\n    { input: 'invalid-key', expected: 'error', writes: 0 },\n  ],\n};\n\nfunction canMerge(candidate, contract) {\n  const pathsOk = candidate.paths.every((path) => contract.allowedPaths.includes(path));\n  const behaviorOk = candidate.invalidInput.writes === 0;\n  return pathsOk && behaviorOk;\n}\n\n// Учебный пример: он не запускает CI и не проверяет реальный репозиторий.\n// false означает остановку, а не разрешение исправить scope молча.
\n

В примере canMerge показывает только форму решения. Реальный проект должен связать каждое условие с тестом, ревьюером и фактическим diff. Нельзя считать этот фрагмент защитой доступа, транзакций или всех потребителей API.

\n

Три способа принять неверный diff

\n

Scope mismatch

\n

Задача просит нормализовать ключ счёта. Помощник меняет parser и соседний accessDecision. Изменение авторизации может быть технически небольшим, но его риск не равен риску форматирования строки. Если path не назван в задаче, его нельзя включать в тот же merge под видом удобного сопутствующего исправления.

\n

Правильный отрицательный путь — остановить diff и удалить лишний hunk либо вынести его в отдельный запрос. Не нужно объяснять расширение scope красивым комментарием. Сначала возвращают границу, затем отдельно обсуждают новую задачу и её владельца.

\n

Contract mismatch

\n

Контракт различает три входа: непустой ключ, пустую строку и недопустимый маркер. Первый нужно нормализовать. Второй означает отсутствие значения. Третий возвращает ошибку. Сгенерированный код может свести последние два случая к пустой строке. Такой код короче и проходит happy path, но теряет смысл ошибки.

\n

Проверка должна сравнивать не только типы и снимки ответа. Для каждого входа нужно назвать ожидаемый результат и запрещённый побочный эффект. Если invalid input не должен писать в базу, это условие обязано появиться в тесте. Иначе тест докажет лишь то, что функция что-то вернула.

\n

Test-evidence mismatch

\n

Тест может быть зелёным и не относиться к изменённой ветке. Например, он проверяет уникальный ключ, а diff добавляет запись до того, как обработает duplicate key. На duplicate-ветке результат должен быть ошибкой, а write helper не должен вызываться. Наличие файла с тестом не доказывает эту связь.

\n

Для каждой changed branch запишите три значения: input, expected output и forbidden side effect. Если ветка не имеет отдельного наблюдаемого условия, её следует считать непроверенной. Это правило действует и для кода, написанного человеком.

\n

Симптом → причина → проверка → действие

\n
Типовые ошибки перед merge
СимптомПричинаПроверкаДействие
В diff появился файл, которого нет в задачеПомощник расширил scope по соседнему контекстуСопоставить каждый changed path с карточкой задачиОстановить merge и вынести лишний hunk в отдельную задачу
Happy path зелёный, invalid input принятКод изменил смысл ошибки или absenceПрогнать фиксированные valid, absent и invalid входыВернуть различие в контракт и добавить negative test
Тест есть, но побочный эффект не проверенТест видит output, но не вызовы write helperПроверить число вызовов и порядок до errorЗафиксировать forbidden side effect и повторить тест
Линтер прошёл, поведение неизвестноПравило стиля не видит доменный контрактСверить ветки, статус, данные и права отдельноНе считать lint verdict доказательством correctness
Исправление выглядит локальным, но меняет доступСоседний security-sensitive код попал в контекстПоказать владельцу access path и отрицательные случаиОстановить merge до отдельного security review
\n

Иллюстрация границы

\n
\"Схема
Gate проверяет scope, контракт и evidence. Красная ветка означает stop до merge. Схема учебная: она не запускает CI, не выполняет rollback и не доказывает свойства production-системы.
\n

Схема полезна как напоминание о порядке. Сначала фиксируют границу задачи. Затем читают diff. После этого проверяют контракт и тест. Решение человека появляется в конце. Если начать с впечатления от кода, предыдущие вопросы легко пропустить.

\n

Порядок проверки перед merge

\n
  1. Сформулируйте задачу. Запишите цель, разрешённые файлы, запреты, владельца решения и ожидаемые evidence. Уберите секреты и персональные данные из контекста помощника.
  2. Прочитайте весь diff. Проверьте каждый path, импорт, условие, изменение схемы и вызов внешнего сервиса. Не ограничивайтесь hunk, который объясняет исходный симптом.
  3. Сверьте контракт. Назовите valid, absent и invalid входы. Для каждого укажите результат, статус или исключение и запрещённые побочные эффекты.
  4. Привяжите тест к ветке. Найдите тест, который действительно выполняет изменённое условие. Проверьте output, вызовы helper, права и состояние после ошибки.
  5. Прогоните отрицательный путь. Передайте неизвестное значение, пропущенное поле, неверный тип, duplicate или отказ в доступе — в зависимости от контракта. Ожидайте точный отказ, а не только отсутствие падения.
  6. Проверьте неизвестные. Отдельно запишите, чего не видно: всех ли потребителей нашли, совпадает ли runtime-конфигурация, проверены ли миграции, права и конкурентные вызовы. Unknown не равен pass.
  7. Примите ограниченное решение. Если всё доказано, человек одобряет конкретный diff. Если нет, reviewer запрашивает изменения, сужает scope или открывает отдельный риск. Не расширяйте разрешение молча.
\n

Ограничения и отрицательный путь

\n

Ни один слой не гарантирует correctness для всей системы. Модель может не знать скрытый consumer. Unit test может не увидеть реальный сериализатор. Линтер не знает, что 403 нельзя превращать в повторяемый 400. CI подтверждает только запущенные сценарии и окружение, в котором они запустились.

\n

Учебный код выше не заменяет review, contract test, security analysis, интеграционный запуск и процедуру отката. Он также не измеряет качество модели и не подтверждает экономию времени. В статье нет production-метрик и результатов реального внедрения. Примеры нужны только для проверки границы: что изменилось, какой вход это наблюдает и какой эффект запрещён.

\n

Если помощник удалил тест, ослабил проверку прав, изменил миграцию или добавил неизвестную зависимость, остановка важнее скорости. Верните diff к минимальному scope. Сохраните причину отказа. Повторно запросите только тот фрагмент, который можно проверить отдельным контрактом.

\n

Проверяемый критерий готовности

\n

Diff готов к решению о merge, когда reviewer может показать: каждый изменённый path разрешён задачей; для каждой изменённой ветки есть вход, ожидаемый результат и проверка запрещённого эффекта; отрицательные случаи возвращают согласованный отказ; lint и тесты выполнены в заявленном окружении; неизвестные записаны отдельно и не выданы за pass. Для изменения доступа, схемы или внешнего контракта нужен отдельный владелец соответствующего риска.

\n

Проверяемый результат — не фраза «код выглядит правильно». Это короткий набор ссылок на diff, contract rows и focused tests. Если хотя бы одна changed branch не связана с evidence, merge не готов. Такой критерий одинаково применим к ручному и AI-assisted коду.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/107.json b/editorial/agent-rewrites/107.json new file mode 100644 index 0000000..ea2d150 --- /dev/null +++ b/editorial/agent-rewrites/107.json @@ -0,0 +1,7 @@ +{ + "index": 107, + "slug": "editorial-2025-01-mechanism-ai-coding-assistant", + "title": "Как проверить код, предложенный AI-помощником", + "excerpt": "AI-помощник быстро создаёт правдоподобный diff, но не знает контракт конкретного репозитория. Разбираем, как найти нарушение границы, проверить отрицательный путь и принять решение по наблюдаемым данным.", + "contentHtml": "

Самая дорогая ошибка AI-помощника часто выглядит как хороший результат. Diff компилируется. Имена понятны. Форматирование проходит. Тест happy path возвращает ожидаемую строку. После merge выясняется, что невалидный маркер превратился в пустое значение или повторный ключ успел вызвать запись перед возвратом ошибки. Команда платит не только за исправление. Она восстанавливает прежний контракт, ищет затронутых потребителей и разбирает, почему зелёная проверка пропустила проблему.

\n

Тезис простой: ответ модели — кандидат на изменение, а не доказательство корректности. Чтобы принять такой diff, нужно раздельно проверить область изменения, контракт, отрицательный путь и неизвестные границы. Если хотя бы одна граница не подтверждена, код нельзя считать готовым только потому, что он выглядит естественно.

\n

Откуда берётся правдоподобная ошибка

\n

Помощник продолжает текст по контексту задачи. Он видит имена функций, соседний код и формулировку запроса. Но репозиторий хранит больше правил, чем попало в контекст: допустимые значения, права, порядок побочных эффектов, требования потребителей, версию внешнего API и смысл пустого результата. Когда правило не выражено явно, кандидат заполняет пробел самым удобным вариантом.

\n

Так возникает подмена ответственности. Модель выбирает поведение, которое кажется локально разумным. Инженер принимает его за восстановленное требование. Тест подтверждает только тот сценарий, который в него положили. Три разных утверждения сливаются в одно слово «проверено».

\n
Что именно доказывает каждый слой проверки
СлойВопросЧто можно подтвердитьЧего это не доказывает
Ответ моделиКакой код предложен?Текст diff и его локальная гипотезаЧто поведение разрешено контрактом
КонтрактЧто разрешено и запрещено?Вход, результат и forbidden side effectЧто diff соблюдает правило
ТестЧто наблюдалось на конкретной ветке?Связь input, output и побочного эффектаЧто покрыты все потребители и среды
РевьюПочему принят этот scope?Пути файлов, владельца и решение о границеЧто runtime ведёт себя так же
НеизвестноеКаких данных нет?Честно названную непроверенную границуОтсутствие риска
\n

Учебный пример: форматирование заметки

\n

Ниже — изолированный учебный пример. Он не обращается к модели, базе данных, CI или production-сервису. В контракте есть три различающихся входа. Пустая строка означает отсутствие заметки. Невалидный маркер означает ошибку входа. Эти результаты нельзя объединять без решения владельца интерфейса.

\n
const cases = [\n  { input: 'note', expected: 'formatted-note' },\n  { input: 'blank', expected: 'absent-note' },\n  { input: 'invalid-marker', expected: 'invalid-note' },\n];\n\nfunction formatNote(input) {\n  if (input === 'blank') return 'absent-note';\n  if (input === 'invalid-marker') return 'invalid-note';\n  return `formatted-${input}`;\n}\n\n// Учебный контракт: invalid-marker нельзя превращать в ''.
\n

Правдоподобный кандидат может сократить функцию до одной условной ветки и вернуть пустую строку для всех неизвестных значений. Для видимого значения note результат останется правильным. Поэтому один позитивный тест ничего не скажет о границе. Нужны отдельные проверки для blank и invalid-marker. Если функция вызывается перед записью, нужно проверить ещё и запрет записи при ошибочном входе.

\n
expect(formatNote('note')).toBe('formatted-note');\nexpect(formatNote('blank')).toBe('absent-note');\nexpect(formatNote('invalid-marker')).toBe('invalid-note');\n\n// Отдельное требование для вызывающего кода:\n// invalid-marker не должен вызывать writeNote().
\n

Смысл примера не в конкретной функции. Он показывает способ чтения diff: для каждой изменённой ветки назовите разрешённый результат, запрещённый результат и наблюдение, которое отличит их. Объяснение модели может описать алгоритм, но не может само назначить смысл отсутствующего поля или разрешить побочный эффект.

\n
\"Матрица
Схема помогает разделить четыре вопроса до merge. Это учебная модель проверки, а не классификация production-инцидентов и не измерение качества какой-либо модели.
\n

Как читать diff по границам

\n

Сначала проверьте scope. Сравните каждый изменённый путь с задачей. Соседний полезный hunk не становится разрешённым автоматически. Если помощник добавил обработчик, конфигурацию или вызов в другом модуле, остановите проверку и получите отдельное решение владельца. Иначе локальная оптимизация расширит поверхность изменения незаметно.

\n

Затем зафиксируйте контракт до обсуждения стиля. Запишите допустимые входы, результат для каждого класса входов и побочный эффект, которого быть не должно. Важны не только возвращаемые значения. Для операции создания записи дубликат может вернуть ошибку и не сделать ни одного write-вызова. Если такой запрет не назван, зелёный тест на ошибку не доказывает безопасность ветки.

\n

После этого свяжите тест с изменённой веткой. Тест должен называть вход, ожидаемый результат и запрещённое действие. Проверка соседней ветки не покрывает новую ветку. Линтер подтверждает форму кода. Типы подтверждают часть интерфейса. Ни один из них не восстанавливает доменное правило, которое нигде не записано.

\n

Симптомы и точечные проверки

\n
Диагностическая таблица для candidate diff
СимптомПричинаПроверкаДействие
Изменён файл, которого нет в задачеScope расширился по соседнему контекстуСверить каждый path с формулировкой и владельцемОстановить diff или оформить отдельное решение
Happy path зелёный, invalid input не описанМодель выбрала default вместо контрактаДобавить таблицу классов входа и негативный тестВернуть код к владельцу контракта
Ошибка возвращается после write-вызоваРезультат проверили, side effect — нетПроверить число и аргументы write-вызововЗапретить запись до валидации
Есть тест, но он не касается changed branchТест подтверждает другую веткуСвязать branch с конкретным input и expected outputДобавить focused negative case
Ревьюер говорит «выглядит безопасно»Неизвестная граница принята за отсутствие рискаСоставить список непроверенных consumers, прав и версийСузить обещание или получить недостающее evidence
\n

Порядок действий перед принятием

\n
  1. Сформулируйте задачу в одном абзаце: какие пути можно менять и какой результат нужен.
  2. Отделите ответ помощника от решения. Сохраните candidate diff, но не называйте его исправлением.
  3. Выпишите контракт для каждой изменённой ветки: вход, разрешённый результат и запрещённый side effect.
  4. Проверьте scope по списку файлов и строк. Каждый выход за границу требует отдельного владельца и решения.
  5. Запустите focused tests для happy path, пустого значения, невалидного значения и повторной операции, если она возможна.
  6. Проверьте отрицательный путь по наблюдаемому следу: результат ошибки, количество вызовов и состояние после отказа.
  7. Отдельно перечислите неизвестное: реальные потребители, совместимость версий, права, конкурентный доступ и нагрузка.
  8. Примите diff только после того, как reviewer может показать конкретное evidence для каждой изменённой границы.
\n

Ограничения метода

\n

Такая проверка снижает риск, но не превращает код в гарантированно корректный. Focused test может пропустить редкую последовательность. Ревью может не знать о скрытом потребителе. Статический анализ не моделирует все права и состояния. Само наличие источника или пояснения модели не заменяет запусков и проверки доменного контракта.

\n

Учебный пример выше намеренно мал. Он не даёт данных о конкретном помощнике, модели, репозитории, скорости разработки или production-ошибках. Для чувствительного кода нужно дополнительно ограничить доступ к контексту, проверить секреты, просмотреть зависимости и согласовать правила хранения исходников. Если нет данных о совместимости или владельце результата, корректное действие — остановиться и назвать пробел, а не заполнить его догадкой.

\n

Проверяемый критерий готовности можно сформулировать жёстко: для каждого изменённого пути есть владелец и разрешённый scope; для каждой изменённой ветки есть contract row; для отрицательного пути зафиксированы output и forbidden side effect; тест наблюдает именно эту ветку; неизвестные перечислены отдельно. Если один пункт отсутствует, готовность не доказана. Это не означает, что изменение нельзя сделать. Это означает, что решение требует ещё одного факта.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/108.json b/editorial/agent-rewrites/108.json new file mode 100644 index 0000000..3c3306c --- /dev/null +++ b/editorial/agent-rewrites/108.json @@ -0,0 +1,7 @@ +{ + "index": 108, + "slug": "editorial-2025-01-practice-ai-coding-assistant", + "title": "AI-помощник в разработке: как принять только проверяемый diff", + "excerpt": "Сгенерированный код может выглядеть готовым и всё же менять не тот контракт. Разбираем ограниченный контекст, отрицательные условия, проверку diff и критерий готовности до merge.", + "contentHtml": "

Разработчик просит помощника исправить обработку ключа. В ответ приходит аккуратный diff: имена понятны, форматирование совпадает с проектом, основной тест проходит. Через день выясняется, что invalid input получает default, а соседний helper меняет право доступа. Симптом заметен не в ответе помощника, а на границе системы: функция вернула допустимую по типам, но неверную по смыслу строку. Цена ошибки — повторная проверка всех затронутых путей, задержка merge и риск выпустить изменение, за которое никто не взял явную ответственность.

\n

Проблема начинается до первого ответа. Запрос без контракта просит правдоподобный текст. Он не говорит, какие файлы разрешено менять, какой результат запрещён, кто принимает расширение области и каким наблюдением подтверждается решение. Поэтому полезный фрагмент легко получает лишние полномочия. Правильная граница выглядит так: помощник предлагает candidate diff, инженер задаёт контракт, reviewer проверяет scope, а тест наблюдает изменённую ветку. Ни один из этих шагов нельзя заменить красивым объяснением.

\n

Механизм: от запроса к решению

\n

У ограниченной задачи есть пять частей. Сначала формулируют один наблюдаемый результат. Затем называют допустимый контекст: сигнатуру функции, строки контракта и связанные тесты. После этого фиксируют отрицательные условия: не менять authorization, не добавлять default, не трогать публичный формат. Владелец принимает смысл изменения. Наконец, команда называет evidence: список путей, contract cases, focused test и человеческий review.

\n

Такой порядок разделяет разные вопросы. Scope отвечает на вопрос что изменилось. Контракт отвечает на вопрос какое поведение допустимо. Тест отвечает на вопрос что произошло на выбранном входе. Review отвечает на вопрос кто принимает остаточный риск. Если один зелёный тест используют как ответ на все четыре вопроса, появляется ложная уверенность.

\n

Симптом → причина → проверка → действие

\n
Четыре типовых сбоя при работе с AI-assisted diff
СимптомПричинаПроверкаДействие
Полезный hunk сопровождается изменением соседнего файлаВ запросе нет allowed paths и запрета на расширениеСверить каждый changed path с карточкой задачиУдалить лишний hunk или открыть отдельное решение
Happy path проходит, invalid input получает новый defaultДо первого ответа не записан отрицательный результатСравнить input-output rows для normal, blank и invalidВернуть diff владельцу контракта
Тест зелёный, но side effect вызывается раньше ошибкиТест наблюдает соседнюю веткуПроверить вызовы helper на duplicate или forbidden inputДобавить focused negative case
Ответ выглядит убедительно, но область риска неизвестнаExplanation приняли за evidenceПеречислить неизвестные consumers, права и версииСузить задачу, привлечь owner или остановить merge
\n

Prompt-card до первого черновика

\n

Карточка не улучшает модель сама по себе. Она делает решение читаемым для инженера и reviewer. Для учебного parser достаточно записать: нормализовать один synthetic invoice key; разрешить только функцию parser и таблицу входов и выходов; не менять authorization, public labels и dependencies; назначить владельца контракта; проверить bounded diff и focused cases. Это не настоящий prompt и не доказательство качества модели. Карточка содержит только фиксированные учебные значения.

\n
const task = {\n  result: 'normalize one invoice key',\n  allowedContext: ['parseInvoiceKey signature', 'fixed input-output rows'],\n  forbidden: ['authorization edits', 'new defaults', 'dependency changes'],\n  owner: 'synthetic-parser-owner',\n  evidence: ['changed paths', 'invalid-input case', 'human review']\n};\n\n// Candidate output is a proposal, not an approval.\n// Any access-policy change stops the review.
\n

Важен не размер diff, а его связь с задачей. Правильная правка иногда меняет два файла: реализацию и тест. Такой diff остаётся bounded, если оба пути названы, а новое поведение следует из контракта. Небольшой diff может быть опасным, если одна строка меняет default, разрешение или смысл ошибки. При обнаружении новой области нельзя задним числом включить её в исходную просьбу. Нужно остановиться, назвать новый риск и получить отдельное решение.

\n
Учебный цикл: prompt-card переходит в bounded diff, затем отдельно проверяются scope, human review и focused test; выход за scope ведёт к остановке.
Рисунок 1. Схема границы между карточкой задачи, candidate diff и проверкой. Подписи и значения synthetic; рисунок не описывает реальный pipeline и не показывает production-результаты.
\n

Конкретный пример: правдоподобная ветка с неверным смыслом

\n

Предположим, parser различает нормальный ключ, пустое значение и недопустимый маркер. Вход invoice-42 даёт invoice-42. Пустой ввод означает отсутствие значения. Маркер ? означает ошибку. Помощник предлагает вернуть пустую строку для любого значения, которое не удалось распознать. Happy path остаётся зелёным. Ошибка скрывается в том, что invalid и absent стали одним состоянием.

\n
const cases = [\n  { input: 'invoice-42', expected: 'invoice-42' },\n  { input: '', expected: 'absent' },\n  { input: '?', expected: 'invalid' }\n];\n\n// Учебный контракт. Он не вызывает реальный parser.\n// Candidate, который сводит '?' к 'absent', отклоняется.
\n

Проверка должна увидеть не только значение. Если duplicate key запрещает запись, test обязан проверить ноль вызовов write helper. Если изменение касается authorization, нужно проверить deny-ветку и запрет на allow по умолчанию. Код может вернуть правильную ошибку после побочного эффекта. Поэтому expected result и forbidden side effect записывают рядом. Линтер проверяет форму. Unit test наблюдает сценарий. Reviewer связывает сценарий с задачей. Эти evidence не складываются в универсальную гарантию.

\n

Порядок проверки до merge

\n
  1. Сформулируйте результат. Запишите один вход, ожидаемый выход и запрещённое побочное действие.
  2. Ограничьте контекст. Передайте только нужную сигнатуру, контрактные строки и тестовые случаи. Уберите секреты, персональные данные и ненужную историю.
  3. Зафиксируйте границу. Назовите allowed paths, запреты и owner. Новое поведение вне карточки остановите.
  4. Отделите candidate diff. Просмотрите список файлов и hunks до чтения объяснения. Ищите лишний путь, новый default, изменение зависимости или прав доступа.
  5. Сверьте контракт. Проверьте normal, blank, invalid и duplicate cases. Для каждой ветки назовите результат и отсутствие forbidden side effect.
  6. Запустите focused checks. Выполните тесты и статический анализ, которые отвечают именно на изменённый вопрос.
  7. Проведите человеческий review. Owner принимает смысл и расширение scope. Reviewer фиксирует comment, request changes или решение принять bounded diff.
  8. Запишите неизвестное. Перечислите других consumers, реальную нагрузку, совместимость версий и security context, если их не проверяли.
\n

Ограничения применения

\n

Этот подход не превращает помощника в источник истины. Ограниченный prompt не знает скрытых consumers, если их не дали в контексте. Тест не доказывает поведение всех комбинаций. Review может пропустить доменную ошибку. Static analysis не заменяет threat model. Чем ближе изменение к authentication, платежам, персональным данным, миграции схемы или внешнему API, тем меньше допустимая область и тем сильнее нужны domain owner, security review и интеграционные проверки. Иногда разумное действие — не применять такой инструмент к чувствительному участку.

\n

Не следует переносить учебный пример в production как готовую библиотеку. Учебный пример не вызывает модель, не обращается к репозиторию, сети, CI или реальным пользователям и не содержит production-метрик. Имена, ключи и outcomes намеренно зафиксированы. Они показывают только форму проверки: сопоставить вход с контрактом, увидеть отрицательный путь и остановить предложение при выходе за границу.

\n

Официальная документация GitHub рекомендует проверять функциональность, контекст, зависимости и AI-specific pitfalls, включая выдуманные API, пропущенные ограничения и удалённые тесты. Это последовательность вопросов, но не сертификат корректности. Профиль NIST для secure software development с generative AI помогает встроить практики безопасности в жизненный цикл. Он не знает доменный контракт и не заменяет решение владельца риска.

\n

Проверяемый критерий готовности

\n

Изменение готово к решению о merge, если reviewer может показать пять вещей: каждый path связан с задачей; каждый changed branch имеет допустимый и запрещённый результат; focused test наблюдает эту ветку; owner назван и принял остаточный риск; неизвестные перечислены отдельно. Если пункт отсутствует, действие должно быть конкретным: сузить diff, добавить evidence, привлечь владельца или остановить merge.

\n

Итог работы с помощником — не удачный ответ и не идеальный prompt. Итог — ограниченное изменение, для которого видно, что изменилось, почему это разрешено и как проверяется отказной путь. Так команда получает скорость черновика без передачи модели ответственности за контракт.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/109.json b/editorial/agent-rewrites/109.json new file mode 100644 index 0000000..94509cf --- /dev/null +++ b/editorial/agent-rewrites/109.json @@ -0,0 +1,7 @@ +{ + "index": 109, + "slug": "editorial-2024-12-field-maintenance-retro", + "title": "Год сопровождения: как решить, продолжить, остановить или перепроверить", + "excerpt": "Повторяемая ошибка в сопровождении редко требует немедленной переделки. Сначала отделите симптом от причины, назовите владельца риска и поставьте проверяемую границу для следующего действия.", + "contentHtml": "

В конце квартала список сопровождения выглядит знакомо: одна и та же ручная проверка, старый риск совместимости и задача на удаление, которую откладывают. Симптомы повторяются, но решение каждый раз начинается с нуля. Цена ошибки — не только лишний час инженера. Команда может удалить ещё используемый маршрут, принять риск без владельца или назвать договорённость исправлением.

\n

Тезис этой статьи простой: годовое сопровождение нужно вести как последовательность проверяемых решений. Для каждой проблемы сначала фиксируют симптом и границу, затем решают: продолжить узкий эксперимент, остановить рост области работ или перепроверить устаревшее свидетельство. Это не обещает результата в production. Такой порядок не даёт черновому решению получить права на изменение живой системы.

\n

Начните с наблюдаемого симптома

\n

Запись «в системе накопился технический долг» ничего не проверяет. Запись «при диагностике одного типа отказа инженер каждый раз ищет один и тот же параметр в трёх местах» уже задаёт наблюдение. У него есть действие, граница и возможный следующий шаг.

\n

Ретроспектива сопровождения не заменяет postmortem. Postmortem описывает подтверждённое событие, воздействие, причины и follow-up. Если инцидента не было, нельзя добавлять в текст ущерб, время восстановления или результат исправления. Для годового обзора достаточно назвать повторяемый симптом, неизвестное и решение, которое можно проверить отдельно.

\n

Механизм: timeline и граница решения

\n

Полезная карточка сопровождения содержит четыре точки. T0 — наблюдение. T1 — ограниченная гипотеза или эксперимент. T2 — повторная проверка свидетельства. T3 — решение продолжить, изменить формулировку или остановиться. Такая шкала не изображает календарь реальной команды. Она показывает порядок знаний.

\n
\"Лента
Схема показывает порядок проверки. Она не описывает реальные события, deployment или rollback.
\n

Граница решения отвечает на вопрос «что именно мы сейчас можем утверждать». Например, можно утверждать, что диагностический шаг повторяется в учебной карточке. Нельзя утверждать, что он уже уменьшил нагрузку на поддержку. Можно увидеть отсутствие роли-владельца. Нельзя считать риск принятым. Можно сохранить вопрос о восстановлении. Нельзя объявлять cleanup безопасным до проверки зависимостей.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Один диагностический шаг снова объясняют вручнуюУ runbook нет явной границы остановкиДругой инженер находит symptom, check и stop condition по одной записиПродолжить один узкий runbook-эксперимент
Риск совместимости описан, но owner не названНаблюдение приняли за решениеПроверить роль, которая может принять residual riskОстановить расширение scope
Cleanup выглядит безопасным по старой карточкеНеизвестны consumers и путь восстановленияПерепроверить dependency graph, data conditions и restore boundaryНе переходить к удалению
В отчёте появился измеренный эффект без источникаУчебный вывод смешали с production-фактомНайти trace, метрику, журнал или убрать утверждениеОставить только подтверждённое наблюдение
\n

Три решения на одном годовом обзоре

\n

Продолжить: повторяемый пробел в runbook

\n

Представьте учебную карточку: на трёх проверках инженер повторно спрашивает, где заканчивается диагностический путь. Это не доказывает частоту проблемы в реальной системе. Но факт повторения в карточке оправдывает небольшой эксперимент: добавить один boundary, один способ проверки и один stop condition.

\n

Эксперимент готов, если другой читатель проходит фиксированный failing path и получает тот же порядок действий без доступа к авторским пояснениям. Если задача разрастается до redesign поддержки или начинает обещать экономию времени, её нужно остановить и оформить как отдельное решение. Runbook не должен незаметно стать программой перестройки.

\n

Остановить: риск без владельца

\n

Вторая карточка описывает границу контракта, но не содержит роли, которая принимает остаточный риск. В такой ситуации фраза «продолжаем миграцию» подменяет решение намерением. Отсутствие известных consumers тоже не равно доказанному отсутствию consumers.

\n

Правильный следующий шаг — остановить рост области работ и задать один вопрос: кто может принять или отклонить утверждение о совместимости именно этой границы? Пока роль не названа и не имеет полномочий, карточка не должна переходить в rollout, removal или обещание обратной совместимости.

\n

Перепроверить: cleanup ещё не план отката

\n

Третья карточка выглядит спокойной: есть предложение удалить старый объект и короткое описание риска. Но неизвестны зависимости, совместимость данных и успешность восстановления. Слово «cleanup» скрывает изменение состояния. Его нельзя считать обратимым только потому, что действие кажется маленьким.

\n

Recheck должен назвать boundary, факт остановки и путь возврата. Для маршрута это может быть прежняя конфигурация и проверка ответа клиента. Для данных — совместимая схема и проверка чтения. Для зависимости — список потребителей и подтверждённый владелец. Если эти условия неизвестны, draft не превращается в rollback plan.

\n

Учебный пример stop path

\n

Ниже — намеренно маленькая модель. Она не читает репозиторий, не вызывает сеть, не выполняет deployment и не откатывает изменения. Её задача — показать, что решение остановиться меняет только статус черновика.

\n
const card = {\n  symptom: 'cleanup proposed',\n  known: ['restore question exists'],\n  unknown: ['consumers', 'data compatibility', 'rollback check'],\n};\n\nfunction plan(card) {\n  const needsRecheck = card.unknown.length > 0;\n  return { decision: needsRecheck ? 'recheck' : 'continue', realChange: false };\n}\n\nconst draft = plan(card);\nconsole.log(draft.decision);  // recheck\nconsole.log(draft.realChange); // false
\n

Пример учебный. Он не определяет риск автоматически и не доказывает, что список неизвестных полон. В реальном проекте поля нужно связать с разрешёнными источниками, владельцем решения и конкретным тестом. Если код начинает сам удалять, менять или откатывать состояние, он вышел за границу этой модели.

\n

Как отличить факт от решения

\n

Факт можно показать другому человеку: записью события, конфигурацией, тестовым входом, ссылкой на код или повторяемым действием. Решение добавляет владельца и условие следующего шага. Гипотеза связывает факт с возможной причиной. Нельзя заменить один тип другим.

\n
Факт: один шаг диагностики повторяется в учебной карточке.\nГипотеза: runbook не показывает boundary.\nПроверка: другой читатель проходит тот же failing path.\nРешение: продолжить один эксперимент, не менять production.
\n

Если подтверждающего источника нет, пишите «неизвестно». Это полезнее, чем округлённая оценка. Неизвестное задаёт следующий вопрос. Выдуманная точность создаёт ложное разрешение на действие.

\n

Порядок работы с одной карточкой

\n
  1. Сузьте scope. Оставьте один симптом и одну границу. Не объединяйте cleanup, миграцию и изменение контракта в одну карточку.
  2. Восстановите T0–T3. Для каждой точки запишите только известный артефакт и отдельный список неизвестного.
  3. Назовите цену ошибки. Укажите, что произойдёт при неверном решении: повторная ручная работа, отказ потребителя, потеря совместимости или невозможность восстановления.
  4. Проверьте owner boundary. Найдите роль, которая может принять риск или остановить действие. Если роли нет, остановите расширение scope.
  5. Выберите один исход. Continue оставляет ограниченный эксперимент. Revise меняет карточку после новой проверки. Stop отбрасывает draft до отдельного разрешённого решения.
  6. Запишите отрицательный путь. Укажите факт, который блокирует rollout, removal или обещание результата. Не заменяйте его словом «проверим позже».
  7. Повторите проверку. Используйте тот же вход и ту же границу. Если условия изменились, это новая карточка, а не тихое продолжение старой.
\n

Ограничения

\n

Эта схема не измеряет надёжность и не ранжирует весь backlog. Она не заменяет incident response, change control, threat model, интеграционные тесты и право владельца на изменение системы. Один runbook-эксперимент не доказывает снижение toil. Один найденный owner не доказывает совместимость. Один restore question не доказывает успешный rollback.

\n

Учебные карточки, T0–T3 и код выше вымышлены. В статье нет production-метрик, истории конкретной команды, данных клиентов, deployment или результата исправления. Переносить вывод можно только после замены учебного входа фактическими источниками и проверки условий среды.

\n

Проверяемый критерий готовности

\n

Карточка готова к следующему решению, если читатель видит один наблюдаемый симптом, одну границу, цену ошибки, известное и неизвестное, роль владельца, отрицательный путь и один следующий эксперимент. Для cleanup дополнительно указаны зависимости, условия данных и проверка восстановления. Для риска без owner итогом должен быть stop, а не скрытое продолжение.

\n

Год сопровождения не закрывается красивым списком исправлений. Он закрывается набором границ, которые можно повторно проверить. Если новая проверка не может изменить решение, она не проверяет механизм.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/110.json b/editorial/agent-rewrites/110.json new file mode 100644 index 0000000..bb64da1 --- /dev/null +++ b/editorial/agent-rewrites/110.json @@ -0,0 +1,7 @@ +{ + "index": 110, + "slug": "editorial-2024-12-mechanism-maintenance-retro", + "title": "Как приоритизировать сопровождение без ложной точности", + "excerpt": "Maintenance backlog становится полезным, когда отделяет наблюдаемый факт от оценки. Разбираем evidence, повторяемость и uncertainty, чтобы выбрать следующую проверку и не принять учебный score за прогноз.", + "contentHtml": "

В maintenance backlog появляется строка «риск 8,7», но никто не может показать исходные данные. В соседней карточке зафиксирован один и тот же симптом после нескольких проверок, но числового score нет. Команда выбирает первую задачу: число выглядит точнее. Цена ошибки — повторяемый дефект остаётся без проверки, а спорная оценка получает вид готового решения. Позже приходится восстанавливать границу риска, владельца и основание приоритета.

Тезис: сопровождение нужно приоритизировать по проверяемой границе, а не по красивой арифметике. Сначала отделите evidence от оценки, repeatability от впечатления, а uncertainty от нулевого риска. Затем используйте score только как фильтр очереди. Он может выбрать следующую проверку, но не доказывает вероятность сбоя, денежную экономию или необходимость изменения.

Механизм: факт, сигнал, граница действия

Evidence — факт, который можно предъявить другому инженеру: запись проверки, contract, тест, runbook или повторная карточка. Signal — признак, помогающий выбрать следующий вопрос. Priority boundary — правило, которое переводит карточку в одно из состояний: проверить сейчас, подготовить к следующему review или наблюдать. Эти понятия нельзя заменять одним числом.

Для каждого item нужны четыре поля. Первое — symptom: что наблюдаем и где. Второе — boundary: какой потребитель, интерфейс или операция затронуты. Третье — evidence: что уже известно и чего нет. Четвёртое — action: какую одну проверку можно выполнить без расширения scope. Если action требует сначала узнать владельца, собрать неизвестный граф зависимостей и изменить код, карточка описывает не задачу, а гипотезу.

Повторяемость тоже имеет границу. Можно считать повтором только тот же diagnostic step, тот же вопрос совместимости или тот же recheck gate. Фразы «мы часто к этому возвращаемся» недостаточно. Если boundary меняется от review к review, события нельзя складывать в один показатель.

Учебная heatmap сопоставляет impact и repeatability, а uncertainty показывает отдельную границу проверки
Учебная heatmap помогает расположить карточки по двум осям. Она задаёт порядок review, но не показывает вероятность отказа и не считает бюджет.

Какие evidence нельзя смешивать

Тип evidence и допустимый вывод
ТипЧто зафиксированоЧего это не доказываетСледующий вопрос
Known artifactЕсть конкретный тест, contract, runbook или запись проверкиЧто artifact покрывает всех потребителей и все путиКакую именно границу он покрывает?
Leading signalДо изменения виден ранний признак роста риска или scopeЧто отказ уже произошёл или обязательно произойдётКакой stop condition сработает раньше?
Lagging signalОдин и тот же вопрос вернулся после reviewЧто известна первопричина или будущая частотаЧто прошлое действие не изменило?
UnknownДанных недостаточно или метод проверки недоступенЧто риск равен нулю и число можно угадатьКакой разрешённый метод изменит статус?

Known artifact сужает область незнания, но не закрывает её автоматически. Leading signal позволяет остановить расширение scope до изменения системы. Lagging signal показывает повторение, но не объясняет причину. Unknown — не пустая клетка. Это явное условие, при котором нельзя усиливать вывод.

Такой подход согласуется с практикой оценки риска: оценка помогает выбрать курс действий, но не заменяет решение владельца. Если неизвестное исчезает при переносе строки в таблицу, формула начинает работать с ложным входом. Сначала сохраните текст неизвестного. Потом решайте, нужен ли отдельный способ его проверить.

Что можно измерять без фальшивой цены

У maintenance item бывает наблюдаемый класс издержки: повторное ручное объяснение, задержка review, дополнительная проверка, возврат к той же границе. Такой label честнее, чем «экономия 14 часов», если команда не зафиксировала период, выборку, метод подсчёта и право использовать эти данные.

Денежная оценка требует отдельного контракта. Нужны scope, период, источник, правило attribution и человек, который принимает допущения. Без них точное число создаёт асимметрию: карточка с выдуманной суммой выигрывает у карточки с честным unknown. Для порядка review достаточно сказать, какой повторяемый шаг мешает работе и как его можно проверить.

Учебная модель priority boundary

Ниже — учебный пример. Он работает только с заранее заданными метками impact, repeatability, uncertainty и evidenceStrength. Он не читает production-метрики, не использует историю инцидентов и не предсказывает результат. Формула нужна для прозрачного разговора о входах:

function teachingPriority(card) {\n  const score = card.impact * card.repeatability\n    + card.uncertainty * 2\n    - card.evidenceStrength;\n\n  const boundary = score >= 10\n    ? 'review-now'\n    : score >= 6\n      ? 'plan-next-review'\n      : 'watch-and-recheck';\n\n  return { score, boundary };\n}\n\n// Учебный объект в памяти. Не production-метрика.\nconst example = teachingPriority({\n  impact: 3,\n  repeatability: 2,\n  uncertainty: 2,\n  evidenceStrength: 1,\n});\n// { score: 9, boundary: 'plan-next-review' }

Число 9 в этом фрагменте ничего не говорит о вероятности сбоя. Оно только показывает, как выбранные teaching labels переводят карточку в учебную полосу. Если reviewer не согласен с uncertainty или evidenceStrength, спорить нужно с входом и его границей, а не с десятичными знаками.

В реальном проекте такой score можно применять только после явного согласования шкал и источников. Если у карточки появился traffic, incident count или денежная оценка, это не повод молча добавить поле в объект. Нужно пересмотреть модель и правила доступа к данным. Иначе учебная функция начинает изображать систему, которой она не видела.

Симптом → причина → проверка → действие

Диагностика maintenance-приоритета
СимптомПричинаПроверкаДействие
Карточка с высоким score не имеет фактаОценку приняли за evidenceПопросить ссылку на тест, контракт, incident или повторную записьПонизить вывод до unknown и назначить отдельную проверку
Один симптом возвращается на каждом reviewПовторяемая граница не названаСравнить diagnostic step, consumer и recheck gateСформулировать одну bounded проверку, не начинать rewrite
Unknown исчез после расчётаПропуск данных заменили нулёмСверить исходную карточку и обязательные поля моделиОстановить score и сохранить причину неизвестности
Leading signal требует немедленного deployРанний признак перепутали с доказанным отказомПроверить symptom, affected boundary и stop conditionОграничить следующий шаг review или тестом
Lagging signal лечат автоматизациейПовторение приняли за первопричинуОткрыть прошлую карточку и проверить, что изменилосьСначала уточнить owner, evidence и действие, затем выбирать автоматизацию
Денежная сумма определяет очередьНет периода, метода или attributionПроверить источник, выборку, допущения и полномочияВернуть cost к наблюдаемому классу до отдельной оценки

Отрицательный путь важнее красивого score

Проверка должна уметь остановиться. Если карточка не содержит boundary, owner role или evidence, результатом не должен быть score с нулевыми значениями. Ноль означает измеренное отсутствие, а unknown означает отсутствие знания. Эти состояния нельзя смешивать.

Остановите draft, если scope вырос с одной проверки до переписывания подсистемы, если action не имеет stop condition или если неизвестный consumer влияет на решение. Не объявляйте item закрытым после одной удачной проверки. Успешный путь показывает, что выбранный вход обработан. Отрицательный путь показывает, что опасный вход не превратился в разрешение на изменение.

Для lagging signal отдельно сравните прошлое и текущее действие. Если symptom вернулся, спросите, изменился ли contract, owner, evidence или stop condition. Если ничего не изменилось, повторная формулировка задачи не является прогрессом. Если изменилось только название, карточку нужно вернуть в review.

Порядок работы

  1. Опишите symptom. Укажите наблюдаемый факт, место и цену ошибки без предположения о причине.
  2. Назовите boundary. Зафиксируйте consumer, интерфейс, диагностический шаг или recheck gate.
  3. Разделите evidence. Отметьте known artifact, leading signal, lagging signal и unknown отдельно.
  4. Выберите один action. Он должен проверять границу и иметь stop condition без изменения production.
  5. Назначьте повторяемость. Считайте повтором только одинаковый diagnostic step или тот же recheck gate.
  6. Проверьте score. Если применяете учебную шкалу, покажите все входы и не называйте результат вероятностью или ценой.
  7. Передайте decision owner. Он выбирает review-now, plan-next-review или watch-and-recheck с учётом остаточного риска.
  8. Зафиксируйте результат. Запишите, какое знание изменилось, какой путь остановился и что проверять при следующем review.

Ограничения

Heatmap и формула не заменяют risk assessment, change approval, тесты, rollback и наблюдение системы. Они не знают реальный traffic, SLO, support queue, стоимость простоя или число пользователей. Учебный код не подключается к данным и не подтверждает, что выбранные коэффициенты подходят вашему проекту.

Preventive maintenance не всегда нужно автоматизировать. Ручной review может быть обязательным из-за безопасности, прав доступа или редкой операции. Повторяемость сама по себе не доказывает, что автоматизация окупится. Она только помогает найти шаг, который стоит разобрать.

Нельзя выдавать score за вероятность incident. Нельзя считать отсутствие evidence доказательством отсутствия риска. Нельзя включать реальный incident или денежную оценку в учебную модель без нового контракта, метода и ответственного владельца. При такой неопределённости правильное действие — остановить расширение scope.

Проверяемый критерий готовности

Карточка готова к следующему review, если другой инженер может восстановить symptom, boundary, тип сигнала, evidence, неизвестное, один action и stop condition. Для каждого значения понятно, откуда оно взялось. Для каждого перехода между полосами понятна причина. Повторная проверка на том же входе даёт тот же статус.

Если используется score, рядом лежат шкала, формула и явная оговорка о её учебном или локальном статусе. Отрицательная проверка переводит карточку в hold или unknown, а не в нулевой риск. Готовность означает не «задача решена», а «следующий шаг ограничен, проверяем и не маскирует неизвестное».

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/111.json b/editorial/agent-rewrites/111.json new file mode 100644 index 0000000..f4733c5 --- /dev/null +++ b/editorial/agent-rewrites/111.json @@ -0,0 +1,7 @@ +{ + "index": 111, + "slug": "editorial-2024-12-practice-maintenance-retro", + "title": "Maintenance review: как превратить повторяющуюся проблему в проверяемое действие", + "excerpt": "Повторяющаяся ручная работа и старые задачи не образуют план сами по себе. Разбираем симптом, цену ошибки, границу риска и один эксперимент, который можно проверить до большого изменения.", + "contentHtml": "

В конце квартала список сопровождения обычно растёт быстрее, чем команда успевает его читать. В нём соседствуют «обновить зависимость», «разобраться с алертами», «убрать ручной шаг» и «проверить старый контракт». Через месяц эти записи перестают объяснять, что повторяется. Инженер снова выясняет контекст, а затем переносит задачу, потому что не понимает, какой результат считать достаточным.

\n

Симптом виден в работе: один и тот же вопрос возвращается на встречу, оператор вручную повторяет одинаковую последовательность, а изменение обсуждают без владельца и границы проверки. Цена ошибки — не абстрактный технический долг. Команда тратит время на повторное объяснение, принимает решение по неполному контексту и может удалить нужную совместимость раньше, чем найдёт потребителя.

\n

Тезис статьи простой: maintenance review должен превращать жалобу в короткую проверяемую карточку. В ней есть наблюдаемый симптом, риск, видимая цена, принимающая роль, доказательства, неизвестное и один следующий эксперимент. Карточка не разрешает большой рефакторинг. Она помогает решить, что проверить первым и где остановиться.

\n

Сначала зафиксируйте симптом

\n

Начинайте не с названия технологии и не с решения. Запишите действие, которое можно увидеть ещё раз. «Система хрупкая» слишком широко. «При проверке релиза инженер каждый раз вручную ищет, где заканчивается диагностический шаг» уже задаёт границу. Её можно показать в runbook, маршруте, контракте или записи проверки.

\n

Затем укажите цену на уровне, который подтверждён наблюдением. Подойдут «повторный interrupt», «ещё один круг review», «задержка проверки» или «риск удаления потребного пути». Не подставляйте часы, деньги и проценты из ощущения. Точное число требует периода, метода подсчёта и разрешённого источника.

\n

Отделяйте симптом от причины. Повторный ручной шаг может возникнуть из-за отсутствующей инструкции, неясного контракта, неудобного инструмента или неверной границы ответственности. Пока проверка не проведена, причина остаётся гипотезой. Такой порядок не смягчает текст. Он не позволяет спорить о виновнике вместо проверки.

\n

Механизм карточки

\n

Карточка работает как маленький контракт между тем, кто заметил проблему, и тем, кто принимает следующий вопрос. Она не обязана описывать всю систему. Её задача — сузить вопрос до одного эксперимента и сохранить то, чего мы пока не знаем.

\n
Поля maintenance review: симптом → причина → проверка → действие
ПолеЧто записатьПроверкаЧего не утверждать
СимптомПовторяемое действие и его границаДругой инженер может указать тот же шаг или артефакт«Так происходит везде» без проверенного охвата
ПричинаГипотеза с опорой на конкретный артефактЕсть лог, тест, контракт, diff или запись наблюдения«Плохой код» без доказательства
ЦенаКласс усилия или риск пересечения границыПонятно, что повторится при бездействииПридуманная экономия и точный прогноз потерь
ВладелецРоль, принимающая следующий узкий вопросУ роли есть полномочие принять или отклонить действиеИмя человека без согласия и полномочий
ДоказательстваИзвестные факты и список неизвестногоДля каждого факта указан источник или способ проверкиПолноту, которой проверка не показала
ДействиеОдин эксперимент и критерий остановкиРезультат изменит знание, а не только создаст активностьАвтоматическое разрешение deploy, удаления или rewrite
\n

Важна именно связка полей. Симптом без цены превращается в раздражитель. Цена без причины превращается в приоритет «на глаз». Причина без проверки создаёт спор. Проверка без действия оставляет запись в том же состоянии. Карточка готова к review, когда следующий шаг ограничен и его результат можно увидеть.

\n

Пример: повторный ручной шаг перед релизом

\n

Ниже учебный пример. Он не описывает реальный сервис, команду или измеренный результат. Представим, что перед каждым релизом инженер вручную сравнивает список маршрутов с короткой инструкцией. Инструкция не говорит, на каком условии проверку можно закончить. Ошибка в карточке была бы такой: «автоматизировать релиз». Это уже решение, а не описание проблемы.

\n

Рабочая карточка выглядит уже: симптом — повторное ручное сравнение маршрутов; риск — изменение может пройти без проверки одного compatibility boundary; цена — ещё один review pass и interrupt; владелец — роль, отвечающая за release checklist; известное — в инструкции нет stop condition; неизвестное — какие потребители используют старый маршрут; эксперимент — добавить один явный stop condition и прогнать его на фиксированном учебном наборе маршрутов.

\n
const reviewItem = {\n  symptom: 'manual route comparison repeats before each review',\n  risk: 'a compatibility boundary may be skipped',\n  costClass: 'repeat-review',\n  ownerRole: 'release-checklist owner',\n  evidence: ['runbook step 4 has no stop condition'],\n  unknown: ['consumers of the legacy route'],\n  nextExperiment: 'add one stop condition and check a fixed route set',\n};\n\nconst canContinue =\n  reviewItem.evidence.length > 0 &&\n  reviewItem.nextExperiment.length > 0 &&\n  reviewItem.unknown.length > 0;\n\nconsole.log(canContinue); // учебный результат: true
\n

Код показывает только форму данных и условие перехода к review. Он не читает репозиторий, сеть, метрики, логи или состояние сервиса. Значение true означает лишь, что учебная карточка заполнена минимально. Оно не доказывает наличие потребителей, безопасность изменения и экономию времени.

\n

Как читать симптом и выбирать действие

\n
Диагностическая таблица
СимптомВероятная причинаПроверкаДействие
Один вопрос возвращается на каждом reviewНе задана граница завершенияНайти шаг инструкции и попросить коллегу назвать stop conditionСформулировать одну границу и повторить проверку
Ручной шаг повторяется и растёт вместе с числом объектовПроцесс не имеет устойчивого автоматизированного путиРазделить обязательную проверку и повторяемую механикуПроверить малый bounded experiment, не автоматизировать всё сразу
Удаление старого пути выглядит безопаснымНе проверены потребители или совместимостьПроверить контракт, ссылки и restore-вопросОстановить removal и назначить recheck
Есть риск, но нет принимающей ролиКарточка описывает проблему, а не ответственностьНазвать роль с правом принять residual riskСначала задать owner question, потом расширять scope
В карточке появился точный scoreНеизвестное заменили удобным числомРазложить score на входы и источникиВернуть класс цены и отдельно записать unknown
\n

Эта таблица не заменяет диагностику. Она задаёт порядок вопросов. Если симптом не совпадает ни с одной строкой, не подгоняйте его под знакомый шаблон. Добавьте наблюдение, уточните границу и только затем решайте, нужен ли новый тип проверки.

\n

Иллюстрация цикла

\n
\"Цикл
Цикл ограничивает scope: наблюдение ведёт к одному эксперименту, а не к автоматическому изменению системы.
\n

Схема важна из-за последней развилки. Если эксперимент добавляет redesign, удаление или обещание неизвестных данных, карточка останавливается. Это отрицательный путь, а не неудача. Он показывает, что текущая граница слишком мала для предлагаемого действия. Сохраните исходный симптом и откройте отдельный review с новым scope.

\n

Порядок действий

\n
  1. Запишите один симптом. Укажите повторяемый шаг, маршрут, контракт или вопрос. Уберите слова «всё», «всегда» и «система» без границы.
  2. Назовите риск. Опишите, какую границу можно пересечь и какое решение станет ошибочным.
  3. Укажите цену классом. Запишите повторный interrupt, задержку review, ручное усилие или риск несовместимости. Числа добавляйте только с методом и источником.
  4. Назначьте роль. Найдите того, кто может принять следующий вопрос или вернуть его на уточнение.
  5. Разделите known и unknown. Для каждого факта укажите артефакт. Не превращайте отсутствие данных в нулевой риск.
  6. Сформулируйте один эксперимент. Он должен изменить знание и иметь stop condition. Не называйте экспериментом deploy, удаление или большой рефакторинг.
  7. Проведите recheck. Сравните результат с исходным симптомом. Если повторение не исчезло или граница стала шире, пересмотрите карточку.
\n

Что считать поддержанием, а что — рутинной нагрузкой

\n

Не вся ручная работа является дефектом. Иногда человек обязан принять решение, проверить исключение или подтвердить риск. Google SRE отличает toil от полезной инженерной работы по признакам: работа ручная, повторяемая, предсказуемая, без устойчивой ценности и растёт вместе с системой. Это полезная проверка гипотезы, но не универсальный повод для автоматизации.

\n

Если шаг требует экспертного решения, автоматизируйте подготовку данных, а не само решение. Если шаг повторяет одну и ту же механику и не меняет вывод, ищите маленький эксперимент. Если ручная проверка существует ради безопасности, её удаление может увеличить риск. В карточке нужно записать, что именно должно остаться человеческим.

\n

Ограничения

\n

Maintenance review не выдаёт вероятность инцидента и не вычисляет бюджет исправления. Учебная карточка не знает реальный traffic, список потребителей, окно изменений, требования отката и полномочия ролей. Пример с маршрутами фиксирует только форму рассуждения. Его нельзя переносить в рабочую систему без отдельной проверки входов и разрешения на изменение.

\n

Ограничение scope защищает от двух ошибок. Первая — начать большую переделку по одному повторному вопросу. Вторая — удалить старый путь, потому что в известном наборе ссылок его не нашли. В обоих случаях неизвестное ошибочно приняли за отсутствие зависимости. Отрицательный результат проверки означает «в этом методе и охвате не найдено», а не «этого нет».

\n

Критерий готовности

\n

Maintenance review готов к следующему решению, если независимый инженер может за несколько минут ответить на пять вопросов: какой симптом повторяется; какую границу он затрагивает; что уже доказано; что остаётся неизвестным; какой один эксперимент и stop condition идут дальше. После эксперимента есть повторная проверка, связанная с тем же симптомом. Если хотя бы один ответ требует устного контекста автора, карточка ещё не готова.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/112.json b/editorial/agent-rewrites/112.json new file mode 100644 index 0000000..40f84f4 --- /dev/null +++ b/editorial/agent-rewrites/112.json @@ -0,0 +1,7 @@ +{ + "index": 112, + "slug": "editorial-2024-11-field-deprecation", + "title": "Как удалить устаревший API и не сломать последнего клиента", + "excerpt": "Депрекация не доказывает, что endpoint больше не используют. Разбираем removal gate: как отделить активного клиента от неизвестной зоны, выбрать проверку, остановить удаление и определить готовность изменения.", + "contentHtml": "

После миграции старого endpoint команда видит зелёные тесты, пустой список известных клиентов и открывает удаление. В следующем релизе один интегратор получает 404 или 410. Его не нашли, потому что поиск прошёл только по репозиторию, а клиент жил в другом аккаунте, в старой версии SDK или за пределами выбранных логов. Цена ошибки — не только один сбой. Команда теряет совместимость, получает срочный откат и уже не может точно сказать, какую область проверила.

\n

Проблема начинается с неверного вопроса: «кто последний consumer?». Полный список потребителей часто недостижим. Рабочий вопрос уже: «какие условия допускают удаление этого ресурса, что осталось неизвестным и какое наблюдение остановит change?». Это removal gate — отдельная проверка перед удалением. Она не обещает отсутствие скрытых клиентов. Она делает риск ограниченным, видимым и управляемым.

\n

Что именно устаревает

\n

Сначала зафиксируйте один ресурс. Это может быть GET /v1/orders/{id}, операция с конкретным operationId или поле ответа в версии контракта. Не называйте предметом проверки «старый API» целиком. У разных маршрутов будут разные владельцы, клиенты и сроки.

\n

Депрекация меняет статус ресурса, но не должна незаметно менять его поведение. В OpenAPI поле deprecated: true сообщает о статусе операции. HTTP-заголовок Deprecation сообщает тот же сигнал во время запроса. Ссылка через Link может вести к описанию причины и замены. Ни один из этих сигналов не доказывает, что клиент прочитал уведомление и перешёл на новый маршрут.

\n

Sunset тоже не является доказательством. Он обозначает ожидаемую границу, после которой ресурс может стать недоступным. Это дата для миграционного плана, а не подтверждение, что все callers уже ушли. Между уведомлением и удалением нужен отдельный decision.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Все найденные клиенты migratedСписок известных строк приняли за полную populationНазвать scope источника, период и blind zoneОставить unknown и заблокировать автоматическое удаление
Трафик равен нулюПроверка не видит нужный регион, credential или кэшСверить охват telemetry с ресурсом и клиентамиРасширить разрешённую проверку или сохранить совместимость
Есть Deprecation и дата SunsetСигнал перепутали с фактом миграцииПроверить replacement, доставку notice и статус каждого клиентаПродолжить миграцию; removal gate не закрывать
Нашёлся active consumerВладелец начал change до согласования последнего клиентаПроверить owner, контракт и путь переходаОстановить удаление и вернуть задачу на миграцию
Неясно, как откатить changeRestore boundary не описали до удаленияНазвать последний совместимый контракт и stop conditionНе начинать removal change
\n

Механизм removal gate

\n

Разделите результат на три состояния. Active означает, что проверка нашла действующий вызов или зависимость. Unknown означает, что область не наблюдается или её нельзя проверить в разрешённом scope. Migrated означает, что названная зависимость перешла на replacement. Эти слова описывают разные факты. Нельзя превратить unknown в migrated только потому, что известные строки уже закрыты.

\n

Active сразу блокирует удаление. У него должен быть владелец, способ связаться с ним и новый контракт. Unknown тоже блокирует автоматическое удаление, но по другой причине: неизвестность не равна нулевой активности. Для неё нужен владелец остаточного риска и конкретное решение — расширить проверку, продлить поддержку или принять ограниченный риск на human review. Migrated допускает подготовку предложения, но не означает, что маршрут можно удалить без отдельного change.

\n

Каждая строка evidence должна отвечать на пять вопросов: какой ресурс проверяли, каким инструментом, за какой период, в какой области и чего инструмент не видит. Запись «usage = 0» без этих полей слаба. Она выглядит точной, но не объясняет, что именно измерено.

\n
\"Схема
Removal gate разделяет active, unknown и migrated. Схема учебная: она показывает порядок решения, но не обнаруживает реальных клиентов.
\n

Учебный пример контракта

\n

Ниже приведён ограниченный учебный пример. Он не читает access log, код, сеть, CI или production и не возвращает реальные данные. Его задача — показать форму записи, в которой неизвестная зона остаётся явной.

\n
const review = {\n  resource: {\n    method: 'GET',\n    path: '/v1/orders/{id}',\n    operationId: 'getOrderV1',\n    replacement: 'GET /v2/orders/{id}'\n  },\n  evidence: [\n    {\n      state: 'migrated',\n      subject: 'checkout-service',\n      source: 'dependency inventory',\n      scope: 'repository set A, reviewed 2024-11-20',\n      blindZone: 'runtime clients outside set A'\n    },\n    {\n      state: 'unknown',\n      subject: 'external integrations',\n      source: 'not checked',\n      scope: 'none',\n      blindZone: 'all external credentials'\n    }\n  ],\n  decision: 'block-removal',\n  stopCondition: 'any active row or unresolved unknown scope',\n  restoreBoundary: 'keep v1 route until approved removal change'\n};
\n

В этом примере первый consumer migrated, но второй остаётся unknown. Поэтому итог — block-removal. Поле blindZone не украшает отчёт. Оно показывает, почему у команды нет права назвать результат полным. Если для unknown нельзя назвать следующую разрешённую проверку, риск нужно принять явно или сохранить старый контракт.

\n

Порядок действий

\n
  1. Определите границу. Запишите метод, URI или поле, версию, operationId, replacement и владельца. Уберите из формулировки соседние операции.
  2. Остановите новые зависимости. Обновите документацию и контракт. Добавьте понятную ссылку на замену. Если применяете Deprecation, проверьте scope заголовка. Notice не должен менять semantics ответа.
  3. Назначьте срок как boundary. При необходимости объявите Sunset и объясните, что это ожидаемая дата возможной недоступности. Не выдавайте её за гарантию миграции.
  4. Соберите evidence по типам. Разделяйте исходный код, зависимости, runtime-наблюдение, authorization scope и неизвестные области. Для каждой строки храните инструмент, период, охват и blind zone.
  5. Разнесите клиентов по состояниям. Active блокирует. Unknown блокирует автоматическое удаление. Migrated переводит вопрос на human review, но не закрывает его сам.
  6. Сформулируйте stop condition. Например: «проверка нашла active row» или «owner replacement не подтвердил совместимость». При таком факте review прекращается.
  7. Опишите restore boundary. Назовите последний совместимый контракт, способ вернуть маршрут и ограничения отката. Если изменение уже записывает необратимые данные, возврат HTTP-маршрута не решает проблему.
  8. Проведите отдельный removal review. Удаление должно быть самостоятельным изменением с понятным владельцем, residual risk и планом проверки после релиза.
\n

Отрицательный путь важнее зелёного статуса

\n

Хороший gate часто заканчивается отказом. Это не ошибка процесса. Если есть active row, команда получает конкретную работу по миграции. Если есть unknown, команда не маскирует пробел красивым нулём. Если replacement меняет поля, коды ошибок или порядок авторизации, старый маршрут остаётся до согласования совместимости.

\n

Опасный путь выглядит иначе: поиск вернул пусто, в отчёте написали «клиентов нет», дату sunset приняли за дедлайн, а удаление объединили с миграцией. Такой результат нельзя воспроизвести и нельзя честно откатить. Пустой результат — это только утверждение инструмента в его границах.

\n

Ограничения

\n

Ни один источник не даёт универсального способа доказать отсутствие всех consumers. Логи могут не охватить редкий вызов. Dependency inventory не видит динамически собранный URL. Внутренний сервис может ходить через общий gateway. Credential scope может скрывать другой tenant. Кэш и очередь могут отложить вызов за пределы выбранного периода. Поэтому removal gate должен хранить границу наблюдения, а не только вердикт.

\n

Учебная таблица и код выше не являются telemetry, списком клиентов, результатом incident analysis или production evidence. Их можно использовать как шаблон полей. Реальные значения нужно получать из разрешённых систем и проверять у владельцев этих систем. Если доступ к источнику отсутствует, состояние остаётся unknown.

\n

Проверяемый критерий готовности

\n

Удаление готово к отдельному change только тогда, когда одновременно выполнены пять условий: ресурс и replacement однозначно определены; notice и его scope опубликованы; каждая известная зависимость имеет состояние и владельца; unknown-зона записана с методом, периодом и stop condition; restore boundary проверена на совместимом контракте. Финальный review должен ответить «да» или «нет» на каждый пункт.

\n

После удаления проверьте не только код ответа. Проверьте, что новый маршрут принимает прежние обязательные сценарии, что старый маршрут действительно недоступен в заявленной области и что ошибки не появились у клиентов, которых охватил change. Если хотя бы один критерий не проверен, удаление не закончено — оно только запланировано.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/113.json b/editorial/agent-rewrites/113.json new file mode 100644 index 0000000..d77f70f --- /dev/null +++ b/editorial/agent-rewrites/113.json @@ -0,0 +1,7 @@ +{ + "index": 113, + "slug": "editorial-2024-11-mechanism-deprecation", + "title": "Deprecation API: почему пустая telemetry не разрешает удаление", + "excerpt": "Пустой график вызовов не доказывает отсутствие потребителей API. Разбираем пять типов evidence, границы deprecation и removal gate на ограниченном примере.", + "contentHtml": "

Команда собирается удалить старый endpoint. За последние две недели в dashboard нет вызовов. В access log видны только запросы к новой версии. Кто-то делает вывод: потребителей больше нет.

\n

Симптом выглядит убедительно, но он описывает только выбранный источник наблюдения. В него могли не попасть редкие клиенты, другой gateway, кэш, batch-задача или credential с отдельной политикой доступа. Цена ошибки — несовместимый релиз для неизвестного caller. Ошибка проявится после удаления, когда старый контракт уже не с чем сравнивать.

\n

Тезис статьи простой: deprecation — это управляемый переход, а не доказательство отсутствия пользователей. Сначала нужно разделить типы сведений и их границы. Затем объявить замену, предупредить потребителей, определить sunset boundary и только после отдельной проверки открыть removal gate. Ни один сигнал сам по себе не превращает пустую telemetry в полный список клиентов.

\n

Что именно нужно доказать

\n

Начните с одной операции: method, URI template, operationId и версия контракта. Формулировка «удаляем старый API» слишком широка. Для POST /v1/ledger/entries вопрос звучит точнее: какие callers ещё зависят от поведения этой операции, кто отвечает за замену и какие наблюдения покрывают маршрут?

\n

Source usage показывает вызов в заданном дереве исходников. Он помогает найти известный сервис, но не видит закрытый репозиторий, скомпилированный клиент или deployed версию, которая не совпала с локальной веткой. Declared dependency показывает объявленный SDK, schema или contract. Зависимость может остаться в manifest после миграции и не доказывает runtime-вызов.

\n

Exposure or traffic показывает observed requests в конкретном инструменте, маршруте, периоде и sampling policy. Запрос не равен пользователю. Один сервис может отправить тысячу запросов, а редкий клиент — один запрос за месяц. Нулевое значение означает «в этом scope сигнал не найден», а не «caller отсутствует».

\n

Authorization показывает, какой credential class или permission способен обратиться к resource. Способность не равна активности. Если политика допускает партнёрский ключ, это ещё не доказывает, что партнёр вызывает операцию. Но если такой класс не учтён, removal имеет слепую зону.

\n

Unknown consumer — не ошибка заполнения таблицы. Это честное состояние, когда scope нельзя замкнуть. Его нужно хранить отдельно от zero и migrated. Именно unknown должен блокировать автоматическое решение об удалении.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
График вызовов равен нулюОкно или route не покрывает всех callersЗаписать инструмент, период, sampling, cache и auth boundaryОставить consumer unknown и расширить разрешённую проверку
В manifest осталась зависимостьDeclared dependency приняли за runtime usageСверить версию, owner и фактический call pathНазначить migration owner, не удалять endpoint по одной записи
В OpenAPI стоит deprecatedДекларацию приняли за завершённую миграциюНайти replacement и проверить его контрактОпубликовать warning и сохранить совместимое поведение
Наступила дата SunsetДату приняли за доказательство, что все ушлиПровести removal review по отдельному scopeОстановить change при active или unknown row
\n

Четыре границы lifecycle

\n

Warning делает замену видимой. Документация должна назвать replacement, owner, область действия и способ задать вопрос. Warning не подтверждает, что клиент его прочитал. Поэтому он не меняет функциональное поведение endpoint.

\n

Deprecation сообщает, что операция больше не является предпочтительной. В OpenAPI это поле deprecated: true. В HTTP можно использовать Deprecation header и ссылку на документацию, если такой сигнал поддерживают ваши клиенты. Эти механизмы помогают обнаружить новые зависимости, но не выключают ресурс и не перечисляют callers.

\n

Sunset задаёт планируемую границу возможной недоступности. RFC 8594 описывает её как сигнал о том, что конкретный URI, вероятно, станет недоступен в указанное время. Это hint, а не доказательство миграции и не гарантия, что сервер исчезнет ровно в timestamp. До этой границы всё равно нужна проверка остаточного риска.

\n

Removal — отдельное несовместимое изменение. Оно должно иметь узкий scope, owner, stop condition и restore boundary. Если одна строка consumer map имеет состояние active или unknown, автоматическое удаление нельзя считать безопасным. Состояние migrated разрешает только review: нужно проверить замену, данные и поведение, а не просто закрыть старый маршрут.

\n
\"Временная
Сигналы идут последовательно, но не заменяют друг друга. Иллюстрация показывает порядок решений; она не содержит telemetry и не задаёт реальную календарную дату.
\n

Учебный пример: одна операция и три состояния

\n

Ниже приведён синтетический пример. Имена, даты и строки не получены из production, telemetry или списка клиентов. Они нужны, чтобы показать логику решения на одном endpoint.

\n
operation: POST /v1/ledger/entries\nreplacement: POST /v2/ledger/entries\nowner: ledger-team\nsunset: 2025-02-03\n\nconsumer              evidence             state\ncheckout-service      source usage         active\nmobile-sdk             declared dependency migrated\npartner-gateway       authorization       unknown\n\nremoval: block\nreason: active and unknown consumers remain
\n

Первая строка содержит наблюдаемый вызов. Она блокирует removal. Вторая подтверждает план перехода, но сама по себе не доказывает, что старый endpoint больше не вызывается. Третья показывает capability boundary: gateway может иметь доступ, однако его активность не установлена. Вердикт должен остаться block.

\n

Если заменить значение unknown на zero, факты не изменятся. Изменится только видимость риска. Поэтому consumer map должна хранить не только state, но и evidence scope: tool, period, route, sampling, auth boundary, owner и blind zone. Без этих полей строка не воспроизводится.

\n

Порядок действий

\n
  1. Зафиксируйте одну операцию и точный replacement. Не объединяйте в одну карту разные URI, версии и semantics.
  2. Назначьте owner старого контракта и owner замены. Запишите, кто может остановить change.
  3. Добавьте warning в документацию и migration guide. Опишите совместимость, различия ответов и путь поддержки.
  4. Объявите deprecation в OpenAPI или согласованным HTTP-сигналом. Не меняйте поведение endpoint только из-за notice.
  5. Соберите evidence по отдельным типам: source usage, declared dependency, exposure or traffic, authorization и unknown. Для каждой записи укажите scope и слепую зону.
  6. Проверьте replacement на тех же входах и ошибках, которые важны для старого контракта. «Клиент обновился» недостаточно без проверки поведения.
  7. Назначьте sunset boundary как плановую дату и заранее определите stop condition. Active row, unknown row или несовместимый replacement должны останавливать удаление.
  8. Проведите human review removal. Решение должно содержать residual risk, restore boundary и ссылку на evidence. Только затем выполняйте отдельный change.
\n

Отрицательный путь

\n

Иногда все named consumers мигрировали, а неизвестный класс доступа остался. Это не повод объявить карту полной. Сохраните старый контракт совместимым, сузьте следующую проверку до разрешённой auth boundary и назначьте срок пересмотра. Если нужное наблюдение нельзя получить законно или технически, риск остаётся unknown. Дата Sunset не превращает его в zero.

\n

Другой отрицательный путь — replacement меняет semantics: новые обязательные поля, другой порядок побочных эффектов или иные коды ошибок. Даже полный список callers не делает такое удаление безопасным. Сначала нужно сравнить контракты и решить, как клиент переживёт несовместимость. Если restore невозможен после записи новых данных, rollback старого route не восстановит прежнее состояние.

\n

Ограничения

\n

Эта схема не создаёт универсальный consumer map. Она не отменяет sampling, кэширование, batch-вызовы, закрытые сети, задержку доставки логов и различия между deployed и исходным кодом. Она также не говорит, сколько дней нужно наблюдать. Окно зависит от частоты вызовов и допустимого риска, а его границы нужно обосновать.

\n

Учебный код выше не вызывает сеть, не читает файлы и не утверждает production-результаты. Официальные спецификации описывают смысл сигналов, но не проверяют ваш API. Реальную готовность устанавливает только evidence с понятным scope и ответственным владельцем.

\n

Проверяемый критерий готовности

\n

Removal готов к отдельному рассмотрению, если выполнены все условия: одна операция имеет названный replacement; у старого и нового контрактов есть owners; warning и deprecation опубликованы; каждая строка consumer map содержит тип evidence, scope, период и blind zone; active и unknown строки либо закрыты разрешённой проверкой, либо явно приняты владельцем как residual risk; replacement проверен на критичных входах; определены stop condition и restore boundary. Если хотя бы одно условие неизвестно, вердикт — block, а не «пользователей нет».

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/114.json b/editorial/agent-rewrites/114.json new file mode 100644 index 0000000..ee7e62a --- /dev/null +++ b/editorial/agent-rewrites/114.json @@ -0,0 +1,7 @@ +{ + "index": 114, + "slug": "editorial-2024-11-practice-deprecation", + "title": "Депрекация API: как не удалить контракт вместе с неизвестным потребителем", + "excerpt": "Дата Sunset не доказывает, что API больше никто не вызывает. Разбираем consumer map, границы доказательств и removal gate, который останавливает опасное удаление.", + "contentHtml": "

В задаче стоит дата: после 3 февраля путь /v1/posting удалят. Наступает день релиза. В графике вызовов пусто, в OpenAPI уже стоит deprecated: true, а команда не видит владельца старого клиента. Кто-то открывает pull request и удаляет обработчик. Через час внешний интегратор получает 404 или 410. У команды нет ответа на три вопроса: кому сообщили, какой контракт предложили взамен и что именно доказало безопасность удаления.

\n

Это не редкий сбой календаря. Дата задаёт границу планирования, но не подтверждает отсутствие потребителей. Пустой график описывает только выбранный инструмент, маршрут, период и набор сигналов. Пометка в схеме сообщает о жизненном цикле операции, но не мигрирует SDK. Реальная депрекация должна разделять объявление, миграцию и удаление.

\n

Тезис: не удаляйте устаревший API по дате или одному зелёному индикатору. Сначала ограничьте одну операцию, составьте consumer map и укажите границу каждого доказательства. Если остался активный или неизвестный потребитель, автоматическое удаление запрещено. Для полностью подготовленного случая открывают отдельный human review, а не превращают ревью депрекации в незаметный production change.

\n

Симптом → причина → проверка → действие

\n
Быстрый разбор перед изменением контракта
СимптомПричинаПроверкаДействие
Есть дата удаления, но нет списка владельцевДедлайн приняли за доказательство последнего consumerПроверить contract, scope, owner и evidence boundaryЗаблокировать removal и открыть consumer map
В telemetry нет запросовПустой сигнал ограничен периодом, sampling, proxy или credential boundaryЗаписать инструмент, окно, маршрут и слепые зоныНазвать результат not observed in this scope, а не «никто не использует»
В OpenAPI стоит deprecated: trueДекларацию смешали с миграциейНайти replacement, владельца и путь переходаОпубликовать notice и migration guide
Все известные клиенты мигрировалиNamed rows приняли за полную populationПроверить unknown scope и остаточный рискРазрешить только отдельный human review
\n

Сначала зафиксируйте, что именно устаревает

\n

Фраза «удаляем v1» слишком широкая. Она может означать один HTTP route, несколько методов, SDK-функцию, callback или весь набор ресурсов. Выберите одну operation. Запишите HTTP method, URI template, operationId, request и response shape, authentication boundary и replacement. Если новый путь меняет семантику, сравните не только URL. Проверьте idempotency key, коды ошибок, pagination, ретраи и правила авторизации.

\n

Такой scope снижает риск ложного согласия. Владелец сервиса может подтвердить удаление одного метода, но не всего API. Владелец SDK может выпустить новую функцию, но не контролировать старые бинарные клиенты. Внешний потребитель может получать notice через документацию, но не читать вашу схему. Контракт, владелец и канал объявления должны совпадать по scope.

\n

OpenAPI помогает объявить операцию устаревшей. Поле deprecated отвечает на вопрос «рекомендуется ли эта операция дальше?». Оно не отвечает на вопросы «кто вызывает её сейчас?» и «может ли replacement принять тот же сценарий?». Поэтому schema и consumer map — разные артефакты. Один сообщает о намерении. Второй связывает намерение с людьми, зависимостями и проверками.

\n

Consumer map хранит не только найденных клиентов

\n

Минимальная строка карты содержит consumer, класс сведения, contract, owner, migration path, deadline, announcement и evidence boundary. Не скрывайте неизвестность. Строка unknown consumer честнее, чем пустая таблица. Она означает, что область ещё не замкнута и автоматический removal нельзя считать безопасным.

\n
Учебная карта для одной операции
ПотребительСведениеВладелец и переходГраница доказательства
synthetic-web-checkoutsource usagesynthetic-checkout-owner; fixed v1 call → fixed v2 contractУчебная строка, не результат поиска кода
synthetic-sdk-packagedeclared dependencysynthetic-sdk-owner; выпустить v2 SDK surfaceУчебная запись зависимости, не package inventory
synthetic-unknown-integratorunknown consumersynthetic-api-owner; сохранить notice и ограничить scopeНе customer list и не доказательство отсутствия вызовов
\n
\"Consumer
Карта разделяет известные зависимости и unknown scope. Все имена и даты на схеме учебные.
\n

Разные классы evidence нельзя складывать в один count. Source usage показывает вызов в разрешённой области исходного кода. Declared dependency показывает объявленную связь пакета, схемы или SDK. Observed traffic показывает запросы в конкретном маршруте и окне. Authorization показывает, какой credential class имеет право обратиться. Ни один класс сам по себе не доказывает полную population клиентов.

\n

Например, пустой access report может не видеть запросы через gateway, другой hostname, старый credential или редкий batch. Кодовый поиск может не найти вызов, спрятанный в сгенерированном клиенте или внешнем binary. Manifest может хранить уже неиспользуемую зависимость. Поэтому рядом с каждой строкой пишите не только результат, но и то, чего он не доказывает.

\n

Объявление, Sunset и удаление — разные границы

\n

Уведомление должно дать потребителю понятный replacement, владельца и ссылку на инструкцию. HTTP-заголовок Deprecation может сообщить, что ресурс устарел или станет устаревшим. Sunset сообщает ожидаемую будущую недоступность ресурса. Оба сигнала улучшают обнаруживаемость решения. Они не заставляют клиент мигрировать и не подтверждают, что клиент получил, понял или применил notice.

\n

Сохраните в записи lifecycle отдельные поля. Дата депрекации описывает статус контракта. Sunset boundary описывает ожидаемое изменение доступности. Removal gate описывает условия допуска к отдельному изменению. Не называйте Sunset жёсткой гарантией: RFC 8594 формулирует его как указание на ожидаемую недоступность, а не как доказательство фактического поведения каждого клиента.

\n
{\n  \"contract\": \"synthetic-ledger-v1-posting-path\",\n  \"owner\": \"synthetic-ledger-owner\",\n  \"replacement\": \"synthetic-ledger-v2-posting-path\",\n  \"deprecationDate\": \"synthetic-2024-11-04\",\n  \"sunsetBoundary\": \"synthetic-2025-02-03\",\n  \"announcement\": \"synthetic-public-deprecation-page\",\n  \"unknownConsumerRule\": \"unknown blocks automatic removal\",\n  \"restoreBoundary\": \"stop proposal before a real removal change\"\n}
\n

Код выше — учебный объект. Он не является production-конфигурацией, не содержит реальную дату, токен, route table или список клиентов. Его задача — показать обязательные связи. Если у replacement нет владельца или у unknown нет границы проверки, объект не готов к removal review.

\n

Removal gate проверяет отрицательные условия

\n

Хороший gate формулируют как список причин не удалять. Есть active consumer — остановиться. Есть unknown scope без принятого остаточного риска — остановиться. Replacement не сохраняет важную семантику — остановиться. Notice не связан с affected scope — остановиться. Restore boundary не описана — остановиться. Ранняя дата не компенсирует ни один из этих пробелов.

\n

Слово «мигрировал» тоже требует проверки. Оно должно означать, что конкретный consumer получил replacement, проверил совместимость и больше не зависит от старой операции в согласованной границе. Если строка лишь помечена как migrated в таблице, это статус документа, а не runtime evidence. Оставьте исходный тип сведения и ссылку на проверку.

\n

Если перед удалением найден active consumer, не меняйте одновременно route, authorization, документацию и fallback. Остановите proposal. Зафиксируйте конфликт, сохраните текущую compatibility boundary и назначьте владельца миграции. Если неизвестный потребитель остаётся, владелец должен выбрать одно из трёх действий: сузить разрешённую проверку, продлить совместимость или принять residual risk отдельным решением.

\n

Порядок действий

\n
  1. Сузьте scope. Назовите одну operation: method, URI, operationId, request, response и authentication boundary.
  2. Опишите replacement. Сравните семантику, ошибки, idempotency, ретраи и права. Один новый URL недостаточен.
  3. Соберите consumer map. Добавьте known rows и отдельную строку unknown, если полнота scope не доказана.
  4. Назначьте владельцев. Для каждой строки укажите owner, migration path, дедлайн и доступное объявление.
  5. Разделите evidence. Запишите тип сигнала, период, инструмент, область и слепые зоны. Не превращайте «не наблюдалось» в «отсутствует».
  6. Опубликуйте lifecycle notice. Свяжите deprecation, Sunset, replacement и migration guide. Проверьте, что ссылка доступна нужным потребителям.
  7. Проверьте stop conditions. Active, unknown без решения, несовместимый replacement и отсутствующий restore boundary закрывают gate.
  8. Откройте отдельный human review. В нём укажите остаточный риск и точный scope. Только после одобрения создавайте authorized removal change.
  9. Проверьте результат тем же контрактом. Отдельно подтвердите, что удаление затронуло только выбранную operation и не изменило соседние пути.
\n

Ограничения и отрицательный путь

\n

Эта схема не делает неизвестных потребителей видимыми автоматически. Она не заменяет разрешённый source search, анализ access logs, inventory клиентов, review авторизации или проверку replacement в реальной среде. Учебные строки в статье не доказывают наличие или отсутствие клиентов. Они показывают, как сохранить класс риска в модели.

\n

Не пытайтесь получить «нулевой риск» из одного сигнала. Даже полный на вид отчёт имеет scope: конкретный период, route, credential, sampling и доступность данных. Отрицательный путь должен быть первым классом результата. Если проверка не может замкнуть область, ответ — unknown, а действие — сохранить совместимость или провести отдельное решение с владельцем остаточного риска.

\n

Restore boundary также ограничивает обещание. Для draft достаточно сказать: proposal остановлен, старый контракт не изменён, черновик удалён. После реального удаления нужен другой план: кто возвращает route, какие schema и credentials ещё совместимы и как проверяется восстановление. Фраза «rollback available» без этих условий не является проверяемым планом.

\n

Проверяемый критерий готовности

\n

Депрекация готова к отдельному removal review, когда одна operation однозначно названа; replacement имеет владельца и описанную compatibility boundary; каждая известная строка consumer map содержит migration path; unknown scope либо ограничен разрешённой проверкой, либо явно принят владельцем; notice и дедлайн доступны; stop condition и restore boundary записаны. Итоговый вердикт должен быть одним из трёх: block: active, block: unknown или allow human review only. Ни один из них не означает, что endpoint уже можно удалить.

\n

Проверяемый результат — не пустой график и не дата в календаре. Это воспроизводимая запись, в которой другой инженер видит scope, доказательство, его границу, владельца и причину следующего действия. Если он не может повторить проверку или назвать условие остановки, контракт ещё не готов к удалению.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/115.json b/editorial/agent-rewrites/115.json new file mode 100644 index 0000000..ba8ae81 --- /dev/null +++ b/editorial/agent-rewrites/115.json @@ -0,0 +1,7 @@ +{ + "index": 115, + "slug": "editorial-2024-10-field-capacity-cost", + "title": "Ёмкость и стоимость: как выбрать следующий предел, а не самый большой инстанс", + "excerpt": "Большой инстанс может улучшить одну latency-метрику и одновременно увеличить расход и операционный риск. Разбираем причинную цепочку, проверку и безопасный критерий следующего шага.", + "contentHtml": "

На разборе ёмкости команда видит знакомую картину: p95 снизился после перехода на 4 CPU, а счёт и запас зарезервированных ресурсов выросли. Вторая карточка с 1,6 CPU показывала очередь и почти касалась SLO, поэтому большой инстанс кажется очевидным ответом. Цена ошибки — закрепить дорогой reservation без доказанного эффекта, принять одну удачную latency-точку за решение и потерять понятный путь возврата.

\n

Тезис: ёмкость выбирают не по лучшему p95, а по следующему проверяемому пределу. Сравните performance, модель стоимости и операционный риск в одном scope. Запишите условие остановки до выбора размера. Если хотя бы одна ось не объяснена, остановитесь и запросите недостающие данные.

\n

Что именно нужно сравнить

\n

Сначала зафиксируйте workload, период, единицы и формулу стоимости. Затем разделите четыре слоя. request описывает ресурс, который workload просит у планировщика. limit задаёт верхнюю границу для контейнера. quota ограничивает суммарное потребление в области политики. Ни один из этих терминов не является ценой сам по себе.

\n

Стоимость требует отдельной формулы. В ней могут участвовать базовая плата, объём выделенного ресурса, единица измерения, период, ступени тарифа и дополнительные услуги. Если цена неизвестна, используйте только обозначения. Не подменяйте счёт процентом CPU. Низкая загрузка говорит о наблюдаемом использовании, но не отвечает, какая часть счёта исчезнет после изменения.

\n
cost(period) = fixed_base\n             + allocated_cpu * cpu_rate\n             + allocated_memory * memory_rate\n             + traffic * traffic_rate\n\ncompare only one changed input\nstop when SLO headroom <= threshold\nstop when the return boundary is unknown
\n

Код выше — учебная формула. Она не содержит тариф, валюту, скидку или счёт реального проекта. Её задача — показать, какие переменные нужно назвать до арифметики. Если период или единица расходятся, сравнение двух инстансов не имеет смысла.

\n

Симптом → причина → проверка → действие

\n
Быстрый разбор перед изменением ёмкости
СимптомПричинаПроверкаДействие
Большой инстанс дал лучший p95Latency сравнили без стоимости и риска возвратаСопоставить SLO headroom, allocation, период и ownerНе выбирать размер; проверить меньший шаг
CPU utilisation ниже 40%Использование приняли за цену и доступный запасНайти billing unit, fixed base и лимиты ресурсаНе обещать экономию; запросить billing evidence
В quota ещё есть CPUРазрешённый aggregate приняли за доступную ёмкостьПроверить request, limit, admission и cluster capacityОтделить policy-проверку от performance-проверки
p95 почти достиг SLOСледующий шаг хотят выбрать до stop conditionВычислить headroom и проверить сигнал очередиОстановить ветку и открыть отдельный разбор
\n

Пример: три предела на одной шкале

\n

Рассмотрим учебные данные для одного workload и 24 часов. У текущего состояния 1,2 CPU, p95 248 ms и условная стоимость 11,424 единицы. Следующий предел поднимает request до 1,4 CPU, даёт p95 248 ms в измеренном окне и условную стоимость 11,856 единицы. Это не доказательство улучшения. Это маленький шаг, в котором изменено ограниченное число входов.

\n

Вторая точка использует 1,6 CPU. Её p95 равен 296 ms при SLO 300 ms, а сигнал queue-growth показывает рост очереди. Headroom равен 4 ms. Стоимость — 12,288 условной единицы за 24 часа. Ветка останавливается. Ещё больший request не становится объяснением причины очереди.

\n

Третья точка резервирует 4 CPU и 6 GiB памяти. p95 снижается до 208 ms, а наблюдаемая загрузка CPU составляет 31%. Условная стоимость — 17,76 единицы за 24 часа. Такой результат показывает, что latency можно купить резервом. Он не доказывает, что резерв нужен, что цена рассчитана по реальному тарифу или что его безопасно уменьшить.

\n
Учебное сравнение трёх вариантов; числа не являются production-измерениями
ВариантPerformanceСтоимость за 24 чРиск и решение
next-limitp95 248 ms; запас 52 ms11,856 ulow; сравнить следующий малый шаг
saturation-stopp95 296 ms; запас 4 ms; queue-growth12,288 ustop; сначала выяснить причину сигнала
large-reservationp95 208 ms; CPU 31%17,76 umedium; не считать размер базовой стратегией
\n

Таблица не превращает три оси в общий score. Такой score потребовал бы отдельного владельца и обоснованной формулы. Здесь важнее сохранить отрицательный путь. Если performance хорош, но стоимость или возврат не объяснены, ответом становится остановка. Если стоимость ниже, но SLO почти нарушен, ответом также становится остановка.

\n
\"Кривая
Схема разделяет latency, условную стоимость и риск. Она помогает увидеть, где лучший p95 перестаёт быть достаточным основанием для выбора.
\n

Почему request, limit и quota не заменяют расчёт

\n

Планировщик Kubernetes учитывает requests при размещении Pod. Limit задаёт отдельное ограничение исполнения. ResourceQuota ограничивает суммарные requests или limits в namespace. LimitRange может задать значения по умолчанию и минимальные или максимальные границы на этапе admission. Эти механизмы отвечают на разные вопросы: можно ли принять объект, сколько ресурса он просит и какие пределы действуют. Они не говорят, сколько стоит час работы и выдержит ли сервис нагрузку.

\n

Поэтому фраза «в quota ещё есть 6 CPU» недостаточна. Она может означать, что объект проходит одну policy-проверку. Она не подтверждает свободную ёмкость кластера, отсутствие конкуренции, нужный запас по SLO или экономический эффект. Сначала проверьте policy. Потом проверьте runtime-сигналы. Затем сопоставьте allocation с разрешённой моделью billing.

\n

Остановку нужно определить заранее

\n

Stop condition защищает от решения под давлением. Пример правила: остановиться, если запас до SLO меньше 15 ms; остановиться при сигнале роста очереди; остановиться, если новый вариант меняет одновременно CPU, память, класс машины, сеть и concurrency; остановиться, если никто не назвал owner и границу возврата.

\n

Предел считается следующим только тогда, когда он меняет один основной контролируемый параметр. Для него известны исходное значение, новое значение, период наблюдения и обратная граница. Если вместе с CPU меняются storage, network и commitment, это уже набор решений. Его нельзя объяснить одной строкой «увеличили ёмкость».

\n

Порядок действий

\n
  1. Зафиксируйте scope. Назовите workload, окружение, период, SLO, единицы и владельца решения.
  2. Опишите текущую точку. Запишите request, limit, quota, p50/p95, error rate, saturation signal и формулу стоимости.
  3. Назначьте stop condition. Укажите числовой запас до SLO и сигналы, которые прекращают сравнение.
  4. Выберите один малый шаг. Измените один основной параметр и сохраните исходный return boundary.
  5. Проверьте policy. Отдельно подтвердите admission, LimitRange, ResourceQuota и возможность размещения.
  6. Проверьте runtime. Сравните тот же workload и период по latency, ошибкам, очередям и фактическому использованию.
  7. Проверьте стоимость. Подтвердите SKU, usage unit, base unit, ступени тарифа и период. Если данных нет, оставьте стоимость неизвестной.
  8. Примите ограниченный outcome. Либо сравните следующий шаг, либо остановитесь с причиной и вопросом владельцу. Большой инстанс не является outcome по умолчанию.
  9. Зафиксируйте критерий готовности. Другой инженер должен повторить расчёт и назвать условие остановки без устных пояснений.
\n

Ограничения и отрицательный путь

\n

Учебные числа в этой статье не описывают конкретный кластер, provider, invoice, telemetry или production workload. Они показывают форму рассуждения. Нельзя переносить 11,856 u, 17,76 u, 31% CPU или p95 208 ms в бюджет и SLO другой системы. Нельзя выводить экономию из разницы между двумя условными суммами.

\n

Модель также не учитывает автоматически cold start, autoscaling, burst, noisy neighbor, storage, network egress, commitments, скидки, налоги и стоимость сопровождения. Эти факторы могут изменить решение. Если хотя бы один из них влияет на выбор, добавьте его в scope или остановите сравнение.

\n

Если после изменения сигнал ухудшился, отрицательный путь прост: не скрывайте его усреднением, верните исходную границу только в рамках разрешённого процесса и сохраните причину остановки. Если реальная команда не описала, кто и как выполняет возврат, статья не даёт команды на rollback. Она требует сначала закрыть этот пробел.

\n

Проверяемый критерий готовности

\n

Разбор готов к решению, когда одна строка связывает workload, resource allocation, performance signal, billing unit, owner, stop condition и return boundary. Для выбранного шага известны изменяемое поле и период проверки. Policy не смешана с runtime-измерением. Стоимость либо подтверждена официальной формулой и собственными данными, либо явно помечена неизвестной.

\n

Проверяемый результат — не согласие на самый большой инстанс. Это запись, в которой другой инженер может пересчитать условие, увидеть отрицательную ветку и ответить, почему выбран именно следующий предел. Если он не может назвать, что остановит сравнение, ёмкость ещё не готова к изменению.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/116.json b/editorial/agent-rewrites/116.json new file mode 100644 index 0000000..cfff4f8 --- /dev/null +++ b/editorial/agent-rewrites/116.json @@ -0,0 +1,7 @@ +{ + "index": 116, + "slug": "editorial-2024-10-mechanism-capacity-cost", + "title": "Почему низкая загрузка не означает низкую стоимость", + "excerpt": "Процент CPU показывает использование выбранного ресурса, но не цену. Разбираем fixed и variable части, quota и limit, saturation и способ проверить вывод до изменения capacity.", + "contentHtml": "

График показывает CPU 31%. p95 равен 208 ms при SLO 300 ms. Команда делает вывод: инстанс слишком большой, его можно уменьшить. Через неделю тот же график используют как доказательство экономии. Но в расчёте нет базовой ставки, единицы тарификации, memory allocation и правила quota. Ошибка стоит дороже одного неверного числа: можно получить очередь, нарушить SLO или принять учебную арифметику за счёт провайдера.

\n

Тезис простой: utilisation, capacity и cost отвечают на разные вопросы. Низкий процент означает только, что наблюдаемая нагрузка мала относительно выбранной базы. Цена зависит от формулы, периода, fixed части и единицы расчёта. Quota ограничивает допустимый объём. Limit задаёт верхнюю границу ресурса. Saturation показывает, что система приближается к отказу. Эти величины связаны, но ни одна не заменяет другую.

\n

Начните с наблюдаемого симптома

\n

Сначала запишите факт без вывода. Например: «CPU 31%, p95 208 ms, model cost 17,76 u/24h». Это одна строка наблюдений, а не рекомендация. Затем разделите вопросы. Почему latency хорошая? Сколько ресурса зарезервировано? Какая часть формулы фиксирована? Какую единицу умножает тариф? Есть ли stop condition для следующего изменения?

\n

Такой порядок защищает от короткой, но неверной стрелки «низкая загрузка → уменьшить инстанс». Usage измеряют относительно allocation. Allocation может влиять на модель стоимости. Saturation зависит от нагрузки и запаса latency. Между наблюдением и действием лежат ещё resource policy, billing expression и граница риска.

\n

Механизм: пять разных объектов

\n

Observed utilisation — измерение использования относительно выбранного ресурса. CPU 31% не говорит, был ли выбранный request разумным и сколько стоит час. Fixed resource — постоянная часть учебной формулы, например base 0,36 u/h. Она остаётся в scope, пока существует сама модель.

\n

Variable resource — часть, которая меняется с allocation. В примере это CPU request и memory request, умноженные на учебные ставки за core-hour и GiB-hour. Billing unit — единица, к которой относится формула: час, GiB-hour или другая unit из конкретного контракта. Без неё разность двух чисел не имеет смысла.

\n

Quota и limit задают ограничения ресурса, а не цену. Quota может ограничивать суммарные requests и limits в namespace. Limit задаёт верхнюю границу для workload. Saturation — сигнал, что очередь, p95 или retry risk приближаются к принятой границе. Saturation может остановить выбор, но не пересчитывает billing formula.

\n
Причинная схема разделяет usage, allocation, quota и limit, saturation и модельную стоимость
Схема разделяет наблюдаемое использование, выделенный ресурс, ограничения, saturation и модельную стоимость. Она не показывает реальный счёт, кластер или реальную telemetry.
\n

Учебная формула и код

\n

Ниже — ограниченный учебный пример. Он не читает облачный счёт и не предлагает менять рабочую систему. Формула нужна, чтобы сделать промежуточные величины видимыми:

\n
const card = {\n  basePerHour: 0.36,\n  cpuRequest: 4,\n  memoryGiB: 6,\n  cpuUnitPerCoreHour: 0.08,\n  memoryUnitPerGiBHour: 0.01,\n  hours: 24,\n};\n\nconst hourly =\n  card.basePerHour +\n  card.cpuRequest * card.cpuUnitPerCoreHour +\n  card.memoryGiB * card.memoryUnitPerGiBHour;\n\nconst modelCost = hourly * card.hours;\n// 17.76 model units for this fixed educational card
\n

Значение 17,76 — результат именно этой формулы. Оно не является тарифом, счётом или прогнозом. Если поменять CPU с 4 до 1,6, нужно сравнить не только cost. Нужны одинаковые period и unit, а также p95, workload, memory, quota и operational risk. В соседней учебной карточке 1,6 CPU и 2,4 GiB дают 0,512 u/h. При p95 296 ms и сигнале queue-growth меньшая цена не доказывает безопасное уменьшение.

\n

Marginal cost — разность двух сопоставимых формул. Например, переход с 1,4 до 1,6 CPU при ставке 0,08 u/core-hour добавляет (1,6 - 1,4) × 0,08 × 24 = 0,384 u/24h. Это размер следующего вопроса, а не команда увеличить request. Если вместе с CPU меняются memory, region, commitment или shared base, одна разность уже не объясняет решение.

\n

Симптом → причина → проверка → действие

\n
Как разбирать вывод о capacity и стоимости
СимптомПричинаПроверкаДействие
CPU ниже 40%Usage сравнили с allocation и сразу назвали ресурс лишнимСверить request, workload, p95 и периодНе менять размер; сформулировать следующий контролируемый вопрос
Model cost вырослаИзменились fixed base, unit или allocationРазложить формулу по полям и одинаковому периодуПроверить billing contract; не называть число счётом
Quota ещё не исчерпанаQuota приняли за доступную безопасную capacityПроверить aggregate rule, admission и saturationОстановить изменение, если p95 или очередь уже у границы
Большой инстанс даёт лучший p95Одну метрику использовали вместо performance, cost и riskСравнить соседний limit и return boundaryСохранить stop и запросить отдельное решение владельца
Два расчёта не совпадаютСмешаны scope, unit или intervalСверить workload, resource, formula и часыНе сравнивать карточки до выравнивания входов
\n

Почему quota и saturation нельзя менять местами

\n

Quota отвечает на вопрос «какой aggregate объём policy допускает?». Она не отвечает на вопрос «выдержит ли сервис следующий request?». В Kubernetes quota может ограничивать суммарные requests и limits, но сама по себе не обещает свободную ёмкость кластера. LimitRange может задать default, minimum или maximum на admission. Это правила формы ресурса, а не доказательство latency.

\n

В учебной карточке quota равна 6 CPU, а request большого варианта равен 4 CPU. Из этого нельзя вывести, что сервис безопасно выдержит 4 CPU или что его следует уменьшить. Если p95 почти касается SLO и растёт очередь, saturation требует остановки даже при доступной quota. Если p95 стабилен, это всё равно не доказывает стоимость: нужна отдельная billing expression.

\n

Отрицательный путь

\n

Хорошая проверка должна уметь остановиться. Для large-instance-unjustified учебная ветка видит CPU 31%, p95 208 ms и большую reservation. Она возвращает stop-and-compare-smaller-limit. Ветка не уменьшает request и не создаёт rollback. Она запрещает два одинаково слабых вывода: «низкая загрузка означает лишний ресурс» и «лучший p95 оправдывает самый большой ресурс».

\n

Остановка нужна и при подмене входа. Если отчёт содержит неизвестное поле вроде invoice, другой scope, sparse array или изменённую model cost, его нельзя молча принять. Сначала нужно вернуть форму к согласованному контракту. Если unit или период не подтверждены, вычисление marginal cost прекращается. Отрицательный путь защищает границу примера, а не реальный API и не рабочую систему.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите usage, p95, SLO, cost и signal без слов «дёшево», «дорого» или «лишний».
  2. Опишите scope. Назовите workload, resource, период, unit и границу сравнения. Разные интервалы нельзя сводить в одну карточку.
  3. Разложите формулу. Отделите fixed base, variable allocation и billing unit. Если unit неизвестна, остановите расчёт.
  4. Проверьте policy. Сверьте request, limit, quota и admission rules. Ответ «quota позволяет» не закрывает performance branch.
  5. Проверьте saturation. Сопоставьте queue, p95 headroom, retry risk и SLO. При stop condition не выбирайте следующий размер.
  6. Сравните соседний вариант. Меняйте один основной параметр, сохраняйте период и unit, считайте marginal difference только для сопоставимых карт.
  7. Назовите недостающий источник. Для цены это billing contract или export, для performance — telemetry и workload evidence, для policy — действующее правило admission.
  8. Зафиксируйте результат. Выберите compare, stop или human review. Ни один результат учебной модели не становится командой над кластером.
\n

Ограничения и критерий готовности

\n

Все числа в примере учебные. Модель не видит provider invoice, SKU, скидки, commitment, tax, network charge, storage, cluster state, runtime, trace или реальную нагрузку. Она не знает owner, полномочия, rollback policy и последствия изменения. Поэтому статья не обещает savings, не выбирает размер instance и не переносит unit из одного provider в другой.

\n

Проверка готова, если читатель может ответить на четыре вопроса: что наблюдалось; какая формула и unit применены; какое правило остановит изменение; какой реальный источник подтвердит следующий шаг. Дополнительно должны быть видны request, limit, quota, p95 и период. Если ответ держится только на CPU percent или на красивом model cost, вывод не готов к operational решению.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/117.json b/editorial/agent-rewrites/117.json new file mode 100644 index 0000000..068b81c --- /dev/null +++ b/editorial/agent-rewrites/117.json @@ -0,0 +1,7 @@ +{ + "index": 117, + "slug": "editorial-2024-10-practice-capacity-cost", + "title": "Ёмкость и стоимость: почему зелёный SLO не означает дешёвую систему", + "excerpt": "Как связать нагрузку, резерв ресурсов и единицу тарификации, чтобы рост расхода не маскировался выполненным SLO.", + "contentHtml": "

Сервис держит p95 на уровне 235 мс при целевом SLO 300 мс, но резерв CPU и памяти растёт каждую неделю. На графике задержка зелёная. В счёте появляется лишняя базовая ёмкость. Если принять зелёный SLO за доказательство эффективности, команда платит за резерв, которого не связывает ни с нагрузкой, ни с единицей тарификации.

\n

Ошибка возникает в месте, где смешивают четыре разные величины: поток запросов, выделенный ресурс, фактическое использование и цену. SLO отвечает за задержку или долю успешных запросов. Он не объясняет, сколько CPU зарезервировано и как провайдер выставляет счёт. Тезис статьи простой: стоимость ёмкости проверяют цепочкой workload → resource → billing unit → stop condition. Если звено пропущено, число на дашборде остаётся симптомом.

\n

Сначала разделите наблюдаемые факты

\n

Workload описывает поток работы: requests per second, размер сообщения, число фоновых задач и период измерения. Resource описывает выделение: replicas, CPU request, memory request, limit и квоту. Usage показывает фактическое потребление за интервал. Billing unit говорит, за что считают деньги: час инстанса, CPU-hour, GiB-hour, запрос, байт или составную единицу.

\n

Эти поля связаны, но не заменяют друг друга. Низкий usage не доказывает, что request можно уменьшить: запас может защищать от пика. Высокий usage не доказывает рост цены: тариф может быть фиксированным. Quota ограничивает суммарное потребление пространства имён, но сама по себе не является счётом. Сначала нужно назвать роль каждого значения.

\n
\"Схема
Учебная схема проверки: нагрузка задаёт контекст, ресурс фиксирует резерв, billing unit задаёт формулу, а предел останавливает сравнение. Иллюстрация не показывает реальный кластер, тариф или счёт.
\n

Механизм: SLO и стоимость смотрят на разные слои

\n

Представьте один сервис с 120 запросами в секунду в обычный час и 180 в пиковый. Его p95 равен 235 мс. На каждый экземпляр задано 1,2 CPU и 2 ГиБ памяти, а предел равен 1,6 CPU и 3 ГиБ. Для учебного расчёта возьмём фиксированную часть 0,36 условной единицы в час, 0,08 за CPU-hour и 0,01 за GiB-hour.

\n

Если модель считает зарезервированный request, стоимость часа равна 0.36 + 1.2 * 0.08 + 2 * 0.01 = 0.476. За 24 часа это 11,424 условной единицы. Это не валюта и не счёт провайдера. Числа нужны, чтобы показать границу: формула использует allocation rule, а не процент CPU на графике. При двух репликах результат удваивается. При изменении тарифа меняется billing unit, а не SLO.

\n

Теперь увеличим request до 1,4 CPU и 2,2 ГиБ. p95 в учебном сценарии остаётся ниже 300 мс, но дневная стоимость становится (0.36 + 1.4 * 0.08 + 2.2 * 0.01) * 24 = 11.856. Разница равна 0,432 условной единицы за сутки на одну реплику. Нельзя назвать её экономией или потерей в реальной среде: для этого нужны настоящий тариф, число реплик, период, скидки, shared overhead и подтверждённая нагрузка.

\n
const card = {\n  scope: 'checkout-api',\n  period: '24h',\n  workload: { baselineRps: 120, peakRps: 180, p95Ms: 235, sloMs: 300 },\n  resource: { replicas: 2, requestCpu: 1.2, requestMemoryGiB: 2, limitCpu: 1.6 },\n  billing: { fixedPerHour: 0.36, cpuHour: 0.08, memoryGiBHour: 0.01 },\n  stop: 'p95 headroom < 10 ms or saturation is observed'\n};\n\nconst hourly = card.billing.fixedPerHour\n  + card.resource.requestCpu * card.billing.cpuHour\n  + card.resource.requestMemoryGiB * card.billing.memoryGiBHour;\nconst daily = hourly * 24 * card.resource.replicas;\nconsole.log({ hourly, daily, sloHeadroomMs: card.workload.sloMs - card.workload.p95Ms });
\n

Код — учебный пример. Он не читает Kubernetes API, облачный биллинг или метрики. В нём намеренно видны период, replicas и правило тарификации. В рабочем контуре эти значения нужно получить из разрешённых источников и сохранить вместе с timestamp. Если источник не различает request и usage, расчёт нельзя выдавать за стоимость резервирования.

\n

Симптом → причина → проверка → действие

\n
СимптомПричина-кандидатПроверкаДействие
SLO зелёный, расход растётУвеличили request или replicasСравнить deployment, период и число репликРазделить изменение ресурса и изменение нагрузки
CPU usage низкий, уменьшение не проходитНужен запас на пик или действует quotaСверить peak RPS, p95, eviction и admission policyПроверить один меньший request в одинаковом интервале
Процент CPU вырос, цена не измениласьФиксированная тарификацияПрочитать billing unit и rate cardНе считать usage заменой счёта
Цена сравнивается у двух сервисовРазные scope или периодыСверить регион, replicas, shared overhead и окноОстановить сравнение до выравнивания входа
Новый limit отклонёнQuota или LimitRange запрещает значениеПроверить policy и сообщение admissionСначала исправить контракт ресурса, затем считать стоимость
\n

Таблица задаёт порядок проверки, а не автоматическое решение. Один симптом может иметь несколько причин. Проверка должна исключить хотя бы очевидные альтернативы. Например, низкий CPU при большом p95 может указывать на ожидание сети или блокировку, а не на свободный запас вычислений. Уменьшение request в таком случае меняет риск, но не устраняет задержку.

\n

Что именно считать резервом

\n

В Kubernetes scheduler учитывает requests при размещении Pod. Limits задают отдельные ограничения выполнения. Поэтому запись «сервис использует 60% CPU» не говорит, что он занимает 60% оплачиваемой ёмкости. Нужно знать, от какой базы рассчитан процент и какую величину использует финансовая модель.

\n

Память требует отдельной осторожности. Краткий средний usage может скрыть редкий пик. Если процесс получает OOMKilled, зелёный средний график не спасает запросы. Для памяти полезнее сверять peak, рабочий набор, ошибки и время окна. Для CPU важны throttling, очередь и p95. Один общий порог utilisation не подходит обоим ресурсам.

\n

Quota и LimitRange задают допустимый диапазон на уровне политики. Они могут отклонить Pod с большим request или назначить значения по умолчанию. Но policy не знает цену часа инстанса. Она проверяет допустимость ресурса, а billing-система применяет свою формулу. Смешение этих слоёв рождает ложный вывод: «quota равна capacity» или «limit равен тарифу».

\n

Как выбрать stop condition

\n

Сравнение нельзя продолжать бесконечно. До расчёта назовите условие остановки. Для latency это может быть запас p95 до SLO. Для ресурса — сигнал saturation, throttling или нехватка памяти. Для стоимости — максимально допустимая дневная дельта. Для политики — отказ admission. Stop condition не говорит, какой вариант выбрать. Он говорит, когда следующий вариант нельзя считать продолжением того же эксперимента.

\n

В учебном примере зададим запас 10 мс. При p95 235 мс запас до SLO равен 65 мс, и сравнение двух соседних reservation допустимо как расчётная иллюстрация. Если p95 стал 295 мс, запас равен 5 мс. Следующий рост request уже требует отдельного решения о надёжности. Нельзя спрятать этот риск в таблице стоимости.

\n

Порядок действий

\n
  1. Зафиксируйте scope и окно. Запишите сервис, регион, число реплик и интервал. Не сравнивайте сутки одного сервиса с часом другого.
  2. Опишите workload. Сохраните baseline и peak, p95, ошибки, размер сообщения и долю фоновой работы. Назовите источник каждого значения.
  3. Разделите request, limit и usage. Выпишите CPU и память отдельно. Не подставляйте процент utilisation вместо request.
  4. Проверьте политики. Прочитайте quota, LimitRange и правила admission. Убедитесь, что сравниваемый вариант вообще допустим.
  5. Назовите billing unit. Укажите фиксированную и переменную часть, тариф, tier, скидку и период. Если поле неизвестно, оставьте его неизвестным.
  6. Посчитайте два соседних варианта. Меняйте одну величину за раз. Сохраните формулу, вход и результат с единицами.
  7. Проверьте stop condition. Сверьте запас до SLO, saturation и допустимую дельту. При нарушении остановите подбор.
  8. Сформулируйте открытый вопрос. Укажите, какой владелец должен подтвердить тариф, нагрузку или риск. Без этого расчёт остаётся учебным.
\n

Ограничения и отрицательный путь

\n

Такая карточка не заменяет capacity planning. Она не моделирует autoscaling, cold start, сеть, хранилище, резервирование, скидки, burst-кредиты, простои и общие узлы. Она не доказывает, что меньший request безопасен. Она только не даёт связать цену с SLO напрямую.

\n

Отрицательный путь важнее удачного расчёта. Если метрики собраны за разные окна, остановитесь. Если тариф относится к узлу, а request — к Pod, не складывайте их без правила распределения. Если неизвестно число реплик в пике, не называйте дневную стоимость точной. Если policy изменилась после замера, пересчитайте вход. Не подставляйте ноль вместо неизвестного поля: это превращает отсутствие данных в ложную экономию.

\n

Не стоит запускать уменьшение ресурса только потому, что модель дала меньшую цифру. Сначала проверьте peak и p95 на том же окне, затем выполните изменение по обычному безопасному процессу, а после него сравните ошибки, задержку, throttling и billing. В этой статье нет production-результата и нет обещания экономии. Есть только критерии, по которым такой результат можно будет подтвердить.

\n

Проверяемый критерий готовности

\n

Проверка готова, если второй инженер может по одной карточке ответить на пять вопросов: какой workload измеряли; какой resource зарезервирован; что означает usage; какая billing unit применена; при каком сигнале сравнение останавливается. Формула должна воспроизводиться на том же входе. Ссылки на тариф и политику должны быть доступны владельцу. При отсутствии любого ответа статус должен быть «данных недостаточно», а не «дешевле».

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/118.json b/editorial/agent-rewrites/118.json new file mode 100644 index 0000000..b67f7fc --- /dev/null +++ b/editorial/agent-rewrites/118.json @@ -0,0 +1,7 @@ +{ + "index": 118, + "slug": "editorial-2024-09-field-adr-decisions", + "title": "ADR устарел: как проверить решение и не переписать его историю", + "excerpt": "Старый ADR не становится неверным только потому, что изменился код. Разбираем признаки drift, проверку assumptions, successor-запись и безопасный переход от Accepted к Superseded.", + "contentHtml": "

Через несколько месяцев после принятия ADR команда открывает его перед изменением сервиса. В документе описан синхронный экспорт для небольшого запроса. В коде уже появился фоновый worker и endpoint со статусом операции. Один разработчик предлагает просто исправить старый текст: заменить «синхронный экспорт» на «фоновый экспорт» и оставить прежнюю дату.

\n

Симптом виден сразу: ADR, код и текущий вопрос описывают разные границы системы. Цена ошибки выше, чем кажется. Команда теряет причину прежнего выбора, не видит принятые компромиссы и может повторно принять уже отвергнутый вариант. При аварии становится неясно, к какому решению возвращаться. При проверке доступа или миграции нельзя отличить старое обязательство от нового предложения.

\n

Тезис прост: ADR фиксирует принятое решение в конкретном контексте, но не проверяет систему сам. Когда меняется assumption или constraint, старую запись нужно сохранить, а новое решение оформить как successor. Только после его принятия старый ADR можно связать с ним статусом Superseded. Так история остаётся читаемой, а проверка не маскируется под редактирование документа.

\n

Что именно хранит ADR

\n

ADR отвечает на четыре вопроса: какая проблема наблюдалась, какое решение выбрали, какие альтернативы рассмотрели и какие последствия приняли. Поля Status, Context, Decision и Consequences образуют минимальный каркас. В расширенном шаблоне рядом появляются владельцы, факторы выбора и способ проверки.

\n

Запись не является приказом навсегда. Она говорит: «при этих условиях мы выбрали этот вариант». Условие может измениться из-за нового контракта, класса данных, требования к времени ответа, стоимости отказа или исчезновения владельца. Сам факт изменения кода ещё ничего не доказывает. Нужно показать, какое условие перестало выполняться.

\n

Полезно разделять три объекта. ADR хранит rationale и границу решения. Тест или запрос к метрикам даёт evidence по конкретному вопросу. План изменения описывает выкладку, откат и наблюдение. Ни один объект не заменяет два других.

\n

Симптом → причина → проверка → действие

\n
Признаки устаревания ADR и безопасное первое действие
СимптомПричинаПроверкаДействие
Код больше не совпадает с границей ADRИзменился контракт или способ выполненияСопоставить diff с разделами Context и DecisionСоздать Proposed successor, старый текст не менять
В записи есть assumption, но нет факта о её состоянииADR приняли за автоматическую проверкуНазвать источник, период, среду и результат, который опровергнет assumptionОткрыть отдельную validation task
Наступила дата review, но новых данных нетКалендарный срок подменил сигналПроверить ссылки, владельца, ограничения и evidence gapReconfirm или запланировать проверку; не ставить Superseded
Старое решение кажется «неудобным»Последствия стали дороже или изменился приоритетЗаново сравнить альтернативы по текущим критериямЗаписать новый компромисс и цену миграции
Нужно отменить изменениеSuccessor ещё не принят или его проверка не закрытаПроверить статус и ссылку на прежний Accepted ADRВернуться к известной записи, не стирать историю
\n

Механизм reassessment

\n

Начните с исходной границы. Запишите её одним предложением: «экспорт выполняется синхронно, если размер запроса не превышает установленный предел, а вызывающая сторона ждёт ответ». Затем назовите новый факт: например, вызывающая сторона должна видеть статус завершения, а время выполнения больше не ограничено коротким запросом.

\n

Новый факт ещё не выбирает архитектуру. Сравните варианты: оставить синхронный путь, добавить очередь со статусом или запустить неограниченную фоновую работу. У каждого варианта есть владелец статуса, путь восстановления, поведение при повторе и цена поддержки. Если критерий «вызывающая сторона должна видеть статус» обязателен, первый вариант отпадает. Если нет владельца очереди и проверки восстановления, второй остаётся только предложением.

\n

Отрицательный путь важен. Если проверка показала, что новый код не отменяет исходную границу, не создавайте successor ради даты review. Зафиксируйте результат проверки и подтвердите прежнее решение. Если evidence отсутствует, не превращайте отсутствие ошибки в доказательство пригодности. Статус должен остаться Proposed, пока ответственный не согласовал контекст и способ проверки.

\n

Условный пример с кодом

\n

Ниже приведена условная модель. Она не читает репозиторий, не меняет ADR и не доказывает свойства реальной очереди. Функции только показывают порядок состояний: сначала формируется предложение, затем отдельная проверка возвращает его без автоматического принятия.

\n
const oldRecord = {\n  id: 'adr-0012',\n  status: 'accepted',\n  boundary: 'small synchronous export'\n};\n\nconst successor = {\n  id: 'adr-0013',\n  status: 'proposed',\n  supersedes: oldRecord.id,\n  decision: 'queued export with visible status',\n  validation: 'caller observes pending, completed and failed states'\n};\n\nconst review = validateSuccessor(successor);\n\nif (review.accepted) {\n  oldRecord.status = 'superseded';\n} else {\n  oldRecord.status = 'accepted';\n  successor.status = 'proposed';\n}
\n

Главная защита находится в ветке else. Ошибка проверки не должна автоматически закрывать старую опору. В реальной системе функция проверки была бы тестом, ручным review, проверкой схемы или запросом с известным scope. Само поле accepted в примере не означает, что такой контроль уже существует.

\n
\"Цикл
Цикл reassessment: сигнал приводит к проверке контекста, ссылки на код и границы доказательства. Если assumption не выдерживает проверку, создаётся Proposed successor. Старый ADR получает Superseded только после принятия нового.
\n

Как связать запись с кодом и проверкой

\n

Ссылка из ADR должна вести к устойчивой границе: контракту, схеме, модулю или отдельному тесту. Ссылка не подтверждает соответствие сама по себе. Для каждого важного assumption задайте проверяемый вопрос. Например: «видит ли вызывающая сторона три состояния операции?» Ответ должен иметь источник, среду, период и критерий остановки.

\n

Метрика отвечает только на тот вопрос, для которого её собрали. Низкая доля ошибок не подтверждает путь восстановления. Высокая пропускная способность не доказывает корректность прав доступа. Тест схемы не доказывает стоимость эксплуатации. Если один источник не покрывает риск, запишите это как ограничение, а не как скрытое условие готовности.

\n

Порядок действий

\n
  1. Откройте Accepted ADR и выпишите Context, Decision, Consequences, owner, ссылки и исходные assumptions.
  2. Назовите один наблюдаемый сигнал: изменился контракт, предел запроса, класс данных, владелец или требование к восстановлению.
  3. Сверьте сигнал с кодом и текущим контрактом. Отделите факт от предположения и сохраните источник проверки.
  4. Определите evidence boundary: кто проверяет, где, за какой период, каким артефактом и какой результат опровергнет assumption.
  5. Создайте successor в статусе Proposed. Добавьте ссылку на старый ADR, альтернативы, последствия, стоимость отката и критерий проверки.
  6. Проведите review. Если новое решение принято, свяжите записи и поставьте старой Superseded. Если отклонено, сохраните причину и оставьте старый ADR действующим.
  7. Проведите изменение отдельно: тесты, разрешения, выкладка, наблюдение, stop condition и rollback.
\n

Если старой записи нет

\n

Не восстанавливайте прошлые мотивы по одному фрагменту кода. Составьте текущую запись: что система делает, какие факты доступны, какие неизвестны и кто отвечает за следующий вопрос. Доступный commit или тикет можно указать источником наблюдения. Нельзя выдавать его за доказательство первоначального rationale.

\n

После срочного исправления особенно легко назвать временный обход Accepted архитектурой. Запишите срок действия, риск и условие удаления. Если команда не может назвать альтернативы и последствия, решение ещё не готово. Это честнее, чем создавать уверенную историю задним числом.

\n

Ограничения и критерий готовности

\n

ADR не запускает миграцию, не заменяет threat model, benchmark, тест-план, runbook или incident review. MADR и исходная форма Nygard предлагают структуру, но не устанавливают универсальные сроки review, роли согласования и веса критериев. Периодическая дата полезна только вместе с сигналом. Нельзя объявлять решение устаревшим из-за одной даты или одной метрики.

\n

Проверяемый критерий готовности таков: для одного текущего ADR видны исходная assumption, подтверждающий источник, владелец проверки, отрицательный результат и действие при нём. Если нужен successor, он содержит ссылку назад, альтернативы, последствия, способ проверки и статус Proposed. Связь Superseded появляется только после явного принятия successor. После этого отдельный change plan проходит свои тесты и имеет путь отката.

\n

Если хотя бы одного элемента нет, результатом должна быть открытая проверка или статус Proposed. Это не незавершённость документа. Это точное описание границы знания команды.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/119.json b/editorial/agent-rewrites/119.json new file mode 100644 index 0000000..2026ba0 --- /dev/null +++ b/editorial/agent-rewrites/119.json @@ -0,0 +1,7 @@ +{ + "index": 119, + "slug": "editorial-2024-09-mechanism-adr-decisions", + "title": "ADR без иллюзии выбора: как записать решение, его цену и путь назад", + "excerpt": "Архитектурное решение теряет смысл, если в записи виден только победивший вариант. Разбираем, как сравнить альтернативы по одной схеме, проверить отрицательный путь и оставить условие для пересмотра.", + "contentHtml": "

В review появляется знакомый симптом: команда обсуждает не условие задачи, а вкус. Один вариант называют простым, другой — надёжным, третий — слишком дорогим. В ADR уже записан победитель, но альтернативы сведены к двум словам. Через месяц авторы помнят контекст, а остальные видят только решение. Через год никто не знает, какую проблему оно закрывало и что делать, если исходное условие изменилось.

\n

Цена ошибки растёт вместе с границей решения. Неудачный выбор может закрепить общий протокол, миграцию, формат данных или операционную обязанность. Тогда быстрый первый merge скрывает дорогой rollback. Ошибка не в том, что команда выбрала неидеальный вариант. Ошибка в том, что запись не показывает принятый компромисс и не даёт проверить, когда его пора пересмотреть.

\n

Тезис: хороший ADR не доказывает, что выбранный вариант лучший вообще. Он связывает наблюдаемую проблему с конкретным ограничением, одинаково описывает альтернативы, называет цену выбора и задаёт сигнал для возврата к решению. Числа могут помочь в учебной модели, но не заменяют факты и не превращают мнение в production-метрику.

\n

Механизм: от симптома к решению

\n

ADR полезен как короткая цепочка причин. Сначала автор отделяет факт от объяснения. Факт можно наблюдать: caller должен получить статус, пока фоновая операция продолжается; данные нельзя отдавать старше заданной границы; изменение должно откатываться без записи нового формата. Гипотеза объясняет, почему текущий путь не подходит. Решение отвечает на ограничение, а не на абстрактную цель вроде «улучшить архитектуру».

\n

У каждой альтернативы должна быть одна и та же карточка. Запишите, какой constraint она закрывает, где находится её граница, кто владеет дополнительной работой, как выглядит возврат и какое evidence уже есть. Отсутствующее evidence тоже является результатом сравнения. Оно ограничивает уверенность, но не доказывает безопасность или опасность варианта.

\n

Статус помогает не смешивать обсуждение и историю. Proposed означает, что запись готова к проверке. Accepted фиксирует принятое решение. Superseded означает, что новый ADR заменил старый и объяснил причину. Статус не запускает миграцию, не назначает approval и не проверяет rollback автоматически. Эти действия должны иметь отдельного владельца и отдельный сигнал.

\n
symptom      = caller не получает понятный status\nconstraint   = acknowledgement не должен зависеть от business completion\nalternatives = direct response | bounded async state | shared workflow\ndecision     = bounded async state у границы сервиса\nprice        = expiry, owner состояния, cleanup и отдельная проверка retry\nreassess     = меняется контракт caller или срок хранения состояния
\n

Этот фрагмент — учебный пример формы. Он не описывает реальный сервис, трафик, SLO или production-результат. Его задача — показать, как решение связывает симптом, условие, цену и отрицательный путь. В настоящем ADR вместо учебных утверждений нужны ссылки на контракт, issue, тест, threat model или другой разрешённый источник evidence.

\n

Как сравнить альтернативы честно

\n

Сначала выровняйте уровень вариантов. Нельзя сравнивать «локальный cache» с «переделать платформу»: это разные масштабы и владельцы. Сформулируйте варианты на уровне решения, а затем укажите реализацию как следствие. Для asynchronous boundary это могут быть прямой ответ после завершения работы, bounded state с выдачей статуса и общий workflow с отдельным хранением состояния.

\n

Constraint fit отвечает на вопрос «закрывает ли вариант обязательное условие». Reversibility описывает не наличие кнопки undo, а область возврата, порядок действий и владельца. Evidence fit показывает, какие факты поддерживают выбор и какой вопрос остался открытым. Operating cost называет долг: expiry, retry, cleanup, миграцию, поддержку контракта или ручной review. Reassessment показывает, что должно измениться, чтобы открыть новый ADR.

\n
КритерийВопрос к каждому вариантуПроверяемый артефактЧего нельзя утверждать
Constraint fitКакое объявленное условие выполняется?Контракт, problem statement или acceptance questionЧто вариант оптимален для всех целей
ReversibilityКакой scope возврата, кто его выполняет и что останавливает change?План rollback и граница затронутого состоянияЧто rollback уже проверен в production
Evidence fitКакой факт поддерживает выбор и чего пока не хватает?Ссылка на источник, controlled test или явное unknownЧто отсутствие данных равно безопасному результату
Operating costКто поддерживает status, expiry, retry, cleanup или migration?Owner и consequence в ADRЧто стоимость измерена в часах или деньгах
ReassessmentКакой сигнал отменяет исходное предположение?Review condition, metric question или contract linkЧто дата сама запустит пересмотр
\n

Таблица не требует единой оценки. Она делает пропуски видимыми. Если у одного варианта есть контракт, а у другого только слово «сложно», сравнение ещё не началось. Если команда всё же применяет score, заранее закрепите шкалу, веса и смысл баллов. Рядом напишите, какие данные модель не учитывает. Итоговый балл может быть tie-breaker для обсуждения, но не доказательством корректности.

\n
\"Синтетическая
Иллюстрация показывает синтетическую матрицу. Значения помогают увидеть форму сравнения и не являются метриками реальной команды или production-системы.
\n

Пример записи с отрицательным путём

\n

Предположим, synchronous caller ждёт результат долгой операции. Прямой ответ сохраняет простую модель, но не выдерживает границу времени. Shared workflow даёт общий status, но добавляет cross-service owner и отдельный контракт. Bounded state у границы сервиса отделяет acknowledgement от business completion. Это может закрыть исходное условие, если срок хранения, повтор запроса и очистка состояния определены явно.

\n
## Decision\nВыбираем bounded state у границы сервиса.\n\n## Consequences\nПоложительные: caller получает отдельный status.\nЦена: owner хранит expiry и cleanup; retry должен быть идемпотентным.\n\n## Не делаем\nНе вводим shared workflow и не считаем локальное состояние\nуниверсальным механизмом координации.\n\n## Reassessment\nОткрываем новый ADR, если caller требует общего status между\nсервисами или срок хранения превышает допустимую границу.
\n

Отрицательный путь защищает запись от незаметного расширения. Без него локальное решение постепенно начинает обслуживать новые callers, а временное поле превращается в общий протокол. Если новое требование действительно появилось, это не повод молча дописывать старый ADR. Нужен successor с новым контекстом, альтернативами и ценой. Старый record остаётся историей прежнего компромисса.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Выбранный вариант подробно описан, остальные названы «сложными»Автор сравнил не варианты, а привлекательность собственного решенияЗаполнить одну карточку criteria для каждой альтернативыПереписать ADR и назвать explicit downside победителя
После merge спорят, что на самом деле означал ADRВ записи смешаны факт, гипотеза и implementation detailПометить источник каждого важного утвержденияВынести неизвестное в evidence gap и добавить owner проверки
Rollback существует только как фразаНе определены scope, порядок и stop conditionПровести dry run на учебной копии или описать точную границуСвязать ADR с отдельным rollback-планом и не объявлять его проверенным без evidence
Локальный механизм используют новые callersОтрицательный путь и граница применимости не записаныПроверить список потребителей и контракт состоянияОстановить расширение, создать successor ADR при новом требовании
Дата review прошла, но никто не вернулся к решениюДата ошибочно принята за автоматический процессНайти владельца сигнала и фактическое событие пересмотраНазначить действие отдельно или заменить дату проверяемым trigger
\n

Порядок работы

\n
  1. Сузьте вопрос. Опишите одну границу: какой caller, какое состояние и какое обязательное условие создают проблему.
  2. Зафиксируйте наблюдаемый симптом и цену. Отделите факт от гипотезы. Назовите, что станет дороже или опаснее при неверном выборе.
  3. Соберите три разумные альтернативы. Приведите их к одному уровню и не добавляйте вариант, который не может закрыть constraint.
  4. Заполните одинаковые поля. Укажите constraint fit, reversibility, evidence gap, operating cost, owner и отрицательный путь.
  5. Сформулируйте decision как компромисс. Запишите, что реализуем и чего намеренно не делаем.
  6. Назначьте проверяемый сигнал. Это может быть изменение контракта, новый потребитель, исчерпание срока хранения или конкретный вопрос к метрике.
  7. Проверьте запись до implementation. Reviewer должен суметь назвать выбранный вариант, его цену и условие пересмотра, не открывая код.
  8. Свяжите запись с реализацией отдельно. ADR не заменяет тест, threat model, migration plan, benchmark, runbook или incident review.
\n

Ограничения и критерий готовности

\n

ADR не делает решение истинным и не заменяет исследование. Он не измеряет latency, надёжность, стоимость или безопасность, если рядом нет соответствующего evidence. Он не гарантирует, что команда найдёт все альтернативы. Формат может различаться: важнее сохранить контекст, выбор, статус, последствия, границы и путь пересмотра. Для legal, security, privacy и data boundary нужны отдельные проверки с отдельными владельцами.

\n

Не добавляйте в учебный пример вымышленные traffic, error rate, savings или outcome. Если число нужно для объяснения score, пометьте его как синтетическое и не переносите вывод за пределы модели. Если production-данных нет, честная формулировка — «данных пока недостаточно», а не «вариант доказанно безопасен».

\n

Запись готова, когда независимый читатель может ответить на пять вопросов: какой симптом наблюдали; какое constraint обязателен; почему сравнивали именно эти варианты; какую цену принимает выбранный путь; какой сигнал заставит открыть новое решение. Проверка проста: уберите из текста заголовок Decision и попросите коллегу восстановить его из context, alternatives и consequences. Если он не может назвать отрицательный путь или owner, ADR ещё не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/120.json b/editorial/agent-rewrites/120.json new file mode 100644 index 0000000..04592da --- /dev/null +++ b/editorial/agent-rewrites/120.json @@ -0,0 +1 @@ +{"index":120,"slug":"editorial-2024-09-practice-adr-decisions","title":"ADR без бюрократии: как сохранить причину технического решения","excerpt":"Практический разбор ADR: от наблюдаемого симптома и цены ошибки до проверяемого решения, отрицательного пути и даты пересмотра.","contentHtml":"

После релиза в коде остаётся необычный обходной путь: запрос проходит через отдельный слой, хотя прямой вызов короче. Через полгода обсуждение исчезает из чата, авторы переключаются на другие задачи, а reviewer видит только итоговый diff. Симптом прост: команда снова спорит, зачем существует условие, очередь или дополнительная граница.

Цена ошибки выше стоимости потерянного контекста. Можно удалить защиту, которая всё ещё нужна для старого ограничения. Можно оставить дорогую схему после того, как ограничение исчезло. Оба решения выглядят разумно, если известен только код. Нужен артефакт, который связывает наблюдаемую проблему с выбором и его последствиями.

Тезис: ADR хранит причину, а не оправдание

Architecture Decision Record фиксирует один значимый выбор. Он отвечает на пять вопросов: что произошло, какие ограничения действовали, какие варианты сравнили, что выбрали и какую цену приняли. Отдельно записывают владельца, статус и сигнал для пересмотра. ADR не объявляет решение вечным и не доказывает, что реализация корректна.

Это важная граница. Ticket хранит работу и сроки. Code review хранит обсуждение изменения. Test проверяет поведение. Runbook описывает операционное действие. ADR хранит rationale — причину, по которой команда выбрала один путь среди допустимых. Ссылка на ticket не заменяет rationale, а commit не заменяет сравнение альтернатив.

Механизм: от симптома к записи

Сначала отделите факт от интерпретации. Факт можно увидеть в логе, контракте, trace, конфигурации или наблюдаемом поведении. Интерпретация объясняет факт, но требует проверки. В ADR полезно явно назвать стоимость ошибки: потеря обратимости, рост задержки, новый владелец данных, риск несовместимости или усложнение отката.

Затем ограничьте вопрос. «Как устроить export» слишком широко. «Где caller получает status, если synchronous boundary не выполняется» уже задаёт предмет. Один ADR должен описывать один выбор. Если обсуждение меняет два независимых контракта, разделите записи и свяжите их ссылками.

Варианты сравнивают по одним и тем же критериям. В простом случае достаточно reversibility, соответствия constraint, стоимости эксплуатации и доступного evidence. Не превращайте невыбранные варианты в карикатуры. Если вариант «ничего не менять» не рассматривался, его стоит назвать отдельно: иногда это самый дешёвый и самый обратимый путь.

Title: bounded cache at the BFF boundary\nStatus: Proposed\nOwner: application owner\nReview by: 2025-03-31\n\nContext: source contract permits bounded freshness.\nOptions: direct read; local cache; shared cache.\nDecision: choose local cache with named expiry.\nConsequences: add freshness check and direct-read rollback.\nEvidence question: does the source contract still permit this cache?

Пример учебный. Он не создаёт cache, не обращается к repository и не сообщает latency, hit ratio или экономию. Его задача — показать форму записи. В настоящем ADR каждое утверждение о контракте должно ссылаться на доступный артефакт, а владелец должен иметь право проверить его.

Симптом → причина → проверка → действие

Как переводить наблюдаемую проблему в следующий шаг
СимптомПричинаПроверкаДействие
В review спорят о старом обходном путиКонтекст остался в перепискеНайдите исходное ограничение и отделите факт от гипотезыСоздайте proposed ADR и укажите ссылку на code
В записи перечислен только выбранный путьАльтернативы не стали частью решенияПроверьте, можно ли сравнить варианты по одним критериямДобавьте реально рассмотренные варианты и причины отказа
Текст обещает «простую поддержку»Последствия описаны абстрактноСпросите, кто что должен сделать и какой риск остаётсяЗапишите владельца, cost, rollback и signal пересмотра
ADR принят, но code изменился иначеЗапись смешана с реализациейСопоставьте decision с контрактом и diff отдельной проверкойИсправьте code или обновите ADR новым решением; не переписывайте историю
Ограничение больше не действуетУ записи нет review boundaryПроверьте дату, source contract и сигнал измененияСоздайте successor ADR со статусом superseded для старого

Как читать короткий ADR

Хорошая запись начинается с контекста, но не превращается в историю всей команды. Достаточно назвать границу системы, затронутый контракт, decision drivers и цену неверного выбора. Слова «быстрее», «надёжнее» и «проще» требуют уточнения. Быстрее для какого сценария? Надёжнее при каком отказе? Проще для какого владельца?

Последствия должны включать отрицательную сторону. Если выбран local cache, положительный эффект может быть ограничен целевым read path. Цена — необходимость хранить expiry, проверять freshness и иметь путь к direct read. Если данные могут быть чувствительными, добавляется отдельная проверка класса данных. Не прячьте эту цену под словом «trade-off»: читателю нужно понимать, что именно он будет поддерживать.

Запишите две границы: что решение делает и чего оно не делает. В учебном примере мы создаём bounded local state с именованным expiry. Мы не создаём shared invalidation system и не объявляем число запросов измеренным результатом. Такая отрицательная часть защищает от незаметного расширения scope.

\"Схема
Схема показывает порядок работы с решением. Она не описывает конкретную approval-систему, репозиторий, CI или состояние production.

Почему запись не заменяет проверку

ADR может быть логичным и всё равно ошибочным. Он фиксирует состояние знаний на момент выбора. Контракт источника может измениться. Ограничение по данным может оказаться неверным. Операционная цена может вырасти. Поэтому Accepted означает «решение принято», а не «реализация доказана во всех средах».

Свяжите ADR с отдельным evidence question. Для cache это вопрос о допустимой freshness и о том, как обнаружить нарушение. Для миграции это вопрос о совместимости схемы и обратном пути. Для security-решения это вопрос о threat model и обязательной проверке. Не подменяйте evidence красивой формулировкой в разделе Consequences.

Порядок работы с одной перепиской

  1. Ограничьте вопрос. Сформулируйте один выбор, его границу и затронутый контракт.
  2. Соберите факты. Запишите наблюдаемый симптом, дату, источник, owner и цену неверного выбора. Догадки пометьте как гипотезы.
  3. Назовите варианты. Добавьте два-три реально доступных пути, включая вариант ничего не менять, если он был возможен.
  4. Сравните одинаково. Используйте один набор критериев: constraint fit, обратимость, стоимость эксплуатации и пробел в evidence.
  5. Зафиксируйте решение. Укажите выбранный вариант, status, дату и человека, который принимает решение.
  6. Опишите последствия. Назовите положительную сторону, цену, отрицательный путь и условие rollback. Не обещайте измерения, которых ещё нет.
  7. Назначьте пересмотр. Запишите дату или сигнал: изменение контракта, превышение лимита, новый класс данных или невозможность выполнить проверку.
  8. Свяжите артефакты. После реализации добавьте ссылки на code, test, metric question или runbook. Ссылки помогают навигации, но не заменяют чтение этих артефактов.
  9. Проверьте расхождение. Сравните ADR с реализацией. Если code ушёл в другой вариант, исправьте code либо оформите новый decision.

Ограничения и отрицательный путь

ADR не заменяет design document, threat model, migration plan, benchmark, test plan, runbook или incident review. Он не гарантирует полноту альтернатив и не превращает согласие участников в технический факт. Формат нужно подстроить под локальные правила хранения, доступа и approval.

Не редактируйте старую запись так, чтобы она описывала новое решение. История нужна именно для ответа на вопрос «почему раньше сделали так». Если предпосылка исчезла, старый ADR получает статус superseded, а новый record объясняет следующий компромисс. Это сохраняет причинность и не заставляет будущего читателя угадывать, какая версия текста была действующей.

Не называйте synthetic пример production-результатом. Не добавляйте вымышленные числа, названия сервисов и ссылки на несуществующие dashboards. Если факт нельзя проверить, напишите, какой артефакт должен его подтвердить. Такой пробел полезнее уверенного, но ложного вывода.

Проверяемый критерий готовности

ADR готов, когда новый читатель без поиска по чату может назвать симптом, constraint, цену ошибки, выбранный вариант и отклонённые альтернативы. Он видит владельца, статус и дату или сигнал пересмотра. Он понимает отрицательный путь и знает, где проверяется реализация. При сравнении с code не возникает скрытого второго решения. Если хотя бы один пункт требует догадки, запись ещё не готова.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/121.json b/editorial/agent-rewrites/121.json new file mode 100644 index 0000000..1bdb86c --- /dev/null +++ b/editorial/agent-rewrites/121.json @@ -0,0 +1,7 @@ +{ + "index": 121, + "slug": "editorial-2024-08-field-feature-flags", + "title": "Feature flag после rollout: как отделить rollback от cleanup", + "excerpt": "Выключенный feature flag прекращает выдачу нового поведения, но не удаляет ветки, конфигурацию и несовместимые данные. Разбираем безопасный rollout, проверяемый rollback и отдельный cleanup gate.", + "contentHtml": "

Новый экран работает у сотрудников, но часть клиентов внезапно видит его после релиза. Команда ставит feature flag в false. Ошибка исчезает, график успокаивается, change закрывают. Через месяц в коде всё ещё живут две ветки, в конфигурации лежит старый ключ, а тесты проверяют два ответа. Любая случайная смена окружения может вернуть candidate-путь. Цена ошибки — повторный инцидент, долгий поиск владельца и риск для данных, которые новый путь уже успел записать.

\n

Обратный случай не безопаснее. Rollout достиг 100 процентов, и это объявили завершением. Но 100 процентов означает только текущий результат правила для объявленной аудитории. Это не доказывает совместимость данных, исправность recovery path и право удалить fallback. Feature flag — не одно переключение, а временный контракт: кто получает вариант, кто его вычисляет, что считается остановкой и когда старый путь перестаёт быть нужен.

\n

Тезис. Rollout, rollback и cleanup нужно проводить разными изменениями. Rollout расширяет аудиторию. Rollback останавливает candidate и возвращает fallback. Cleanup удаляет лишнюю ветку после выбора итогового поведения. Если смешать эти операции, команда теряет причинную связь и принимает отключение за исправление.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Флаг выключен, но в коде остались две веткиRollback смешали с удалением реализацииНайти evaluator, branches, config references и тесты по ключуОставить fallback исполняемым, открыть отдельный cleanup change
100% rollout считают доказательством готовностиПроцент подменяет проверку контракта и данныхСверить cohort key, effective config version, stop condition и recovery pathЗаморозить процент и провести решение о final variant
После возврата к fallback часть запросов получает ошибкуCandidate записал состояние, которое fallback не читаетПроверить read/write compatibility и миграционный контрактОстановить расширение; исправить совместимость отдельно от flag value
После удаления ключ снова появляется в UIОстался client check, stale cache или старый payloadПроверить server response, client bundle, cache key и configУдалить presentation toggle и оставить только итоговый UI contract
Никто не может назвать дату удаленияУ флага нет owner, final variant и cleanup gateЗапросить карточку решения с evidence и списком referencesНе расширять rollout; назначить владельца и новый change
\n

Как работает решение

\n

Сначала evaluator получает ключ флага и контекст. Контекст должен однозначно описывать subject, окружение и другие поля, которые участвуют в targeting. Для процентного rollout особенно важен стабильный cohort key. Если сегодня используется user id, а завтра случайный request id, один пользователь будет переходить между вариантами. Это уже не gradual rollout, а непредсказуемое распределение.

\n

Evaluator возвращает выбранный вариант и, если система это поддерживает, детали оценки: key, value, variant, reason и версию конфигурации. Приложение применяет результат в одной точке. Второй слой не должен пересчитывать то же правило с другим контекстом. Иначе сервер отдаст новый ответ, а клиент решит, что пользователь относится к fallback-группе.

\n

Процент не описывает весь жизненный цикл. Он не говорит, обновился ли client cache, дошёл ли запрос до нового обработчика и можно ли откатить данные. Для каждого этапа нужны вопрос наблюдения и заранее выбранное действие. При остановке меняют одну существенную переменную: аудиторию, правило или код. Одновременная смена процента, evaluator и схемы данных уничтожает полезность сравнения.

\n
type FlagDecision = {\n  key: string;\n  variant: 'fallback' | 'candidate';\n  cohortKey: string;\n  configVersion: string;\n};\n\nfunction renderCheckout(decision: FlagDecision, order: Order) {\n  if (decision.variant === 'candidate') {\n    return renderNewCheckout(order);\n  }\n\n  return renderLegacyCheckout(order);\n}
\n

Этот фрагмент — учебный. Он показывает границу между результатом оценки и применением варианта. В нём нет настоящего flag provider, хранилища, телеметрии, миграции или команды rollback. В рабочей системе нужно дополнительно определить формат контекста, источник версии конфигурации, правила кеширования и поведение при ошибке evaluator.

\n

Учебная лестница rollout

\n
ЭтапВопросСтоп-условиеДействие
0%Fallback выполняет согласованный smoke scenario?Старый путь уже не работаетНе включать candidate; исправить базовый контракт
5%Одна стабильная cohort получает candidate в объявленной версии?Сигнал нарушает заранее записанную границуВернуть аудиторию к 0%, сохранить конфигурацию и вопрос расследования
25%Candidate можно сопоставить с fallback в одном окне?Версия или источник сигнала неизвестныОстановить расширение и не менять правило вместе с кодом
100%Вся объявленная аудитория получает выбранный вариант?Final variant не утверждён или fallback нужен для recoveryНе удалять флаг; открыть решение о завершении
Cleanup gateКод и конфигурация больше не требуют alternate branch?Осталась runtime reference или зависимость данныхВернуть cleanup в доработку
\n

Значения 0, 5, 25 и 100 процентов в таблице — фиксированный учебный пример. Они не являются рекомендацией, нормативом или результатом измерения. Реальный шаг выбирают по размеру риска, качеству сигнала, обратимости и размеру cohort. Если команда не может назвать population, control, окно и владельца решения, процент не даёт полезной информации.

\n
\"Rollout
Rollout расширяет аудиторию, rollback возвращает fallback, cleanup удаляет alternate branch. Проценты на схеме относятся к учебной модели.
\n

Rollback возвращает поведение, но не переписывает историю

\n

При stop condition минимальное действие — прекратить дальнейшее расширение candidate. Для этого возвращают аудиторию к fallback или к заранее определённому safe value. Записывают effective config version и причину остановки. Не нужно в тот же момент удалять ветки, менять evaluator и переписывать миграцию. Иначе нельзя будет понять, что именно остановило симптом.

\n

Rollback значения флага не равен rollback данных. Если candidate уже создал записи, отправил события или изменил схему ответа, старый путь должен уметь прочитать это состояние. Фича-флаг не добавляет backward compatibility автоматически. Если fallback не понимает результат candidate, ответом должен стать отдельный migration contract, а не уверенность, что false решает всё.

\n

Также не стоит называть rollback мгновенным. Сервис, edge-кеш и браузер могут получить конфигурацию в разное время. В карточке изменения укажите, где вычисляется вариант, сколько живёт кеш и что происходит с активной сессией. Разница версий допустима только тогда, когда она входит в контракт и не ломает recovery path.

\n

Cleanup — отдельное решение

\n

Cleanup начинается после выбора единственного поведения. Сначала владелец фиксирует final variant. Затем команда перестаёт добавлять новые условия и открывает отдельное изменение удаления. В его области должны быть server branches, client checks, configuration, permissions, tests, документация и миграционные заметки.

\n

Проверка cleanup строится на отрицательных утверждениях. Runtime больше не вызывает evaluator для ключа. Клиент не переключает UI по старому payload. Конфигурация не содержит targeting rules и environment overrides, нужные только флагу. Тесты проверяют итоговый business contract, а не сохранение двух boolean-веток. Документ объясняет принятое поведение, но не предлагает включить исчезнувший путь.

\n

Глобальный поиск по имени ключа полезен, но недостаточен. Ключ может быть собран из частей, сохранён в типе, скрыт в конфиге или пришит к кешу. Ищите вызовы evaluator, schema fields, token scopes, generated client types и тестовые данные. Если reference нужна для обратимой миграции, cleanup ещё не завершён: у неё должен быть owner и срок следующей проверки.

\n

Порядок действий

\n
  1. Опишите контракт. Назовите owner, evaluator, cohort key, fallback, config version и данные, которые меняет candidate.
  2. Проверьте fallback. Выполните smoke scenario и убедитесь, что старый путь доступен до включения нового.
  3. Запишите stop condition. Свяжите её с конкретным сигналом, окном, источником и действием на остановке.
  4. Запускайте один этап. Не меняйте одновременно аудиторию, правило, код и схему данных.
  5. При проблеме остановите расширение. Верните cohort к fallback, сохраните effective config version и отделите mitigation от поиска причины.
  6. Проверьте совместимость данных. Убедитесь, что fallback читает состояние, которое оставил candidate, либо оформите отдельную миграцию.
  7. После выбора final variant заморозьте flag policy. Не добавляйте новые условия и не продлевайте флаг без новой причины.
  8. Проведите cleanup search. Проверьте server, client, config, кеши, права, тесты и документацию.
  9. Удалите артефакты отдельным change. Сохраните тест итогового поведения и обычный rollback plan для самого cleanup-релиза.
  10. Закройте решение. Зафиксируйте final variant, owner, причину удаления и evidence отсутствия runtime-зависимости.
\n

Ограничения

\n

Этот подход не выбирает конкретный SDK, размер cohort, TTL кеша, SLO или схему миграции. OpenFeature даёт общий API для evaluation context и результата, но не задаёт правила вашей платформы. Kubernetes rollback относится к версии Deployment и его Pod template; он не доказывает обратимость бизнес-данных. Система флагов также не доказывает качество эксперимента: для этого нужны отдельные метрики, контрольная группа и методика измерения.

\n

Не следует удалять fallback только потому, что candidate получил 100 процентов. Не следует сохранять flag навсегда только потому, что когда-то существовал инцидент. Если данные, кеш или долгоживущий consumer не описаны, это блокер cleanup. Если evaluator недоступен, нужен явный default и проверяемая политика ошибки. Если неизвестно, какой вариант является итоговым, сначала принимают это решение, а потом удаляют код.

\n

Проверяемый критерий готовности

\n

Cleanup готов, если независимая проверка отвечает «да» на все вопросы: final variant записан владельцем; fallback больше не нужен для recovery или миграции; runtime не зависит от ключа; client и server не пересчитывают старое правило; configuration и permissions не содержат флаговые остатки; тесты сохраняют итоговое поведение; обычный релизный rollback не требует возвращать удалённый flag.

\n

До этого момента выключенный флаг нужно считать остановленным, но не удалённым. Такое различие сохраняет причину, границу действия и следующий шаг. Оно также не даёт спокойному графику скрыть технический долг, который снова станет пользовательской ошибкой.

\n

Проверяемые источники

\n

OpenFeature: Evaluation Context — официальный материал о контексте оценки, targeting key и рисках передачи персональных данных.

\n

OpenFeature: Evaluation API — официальное описание результата оценки и его деталей.

\n

Kubernetes: Update a Deployment Without Downtime — официальная документация о проверке обновления и возврате Deployment к предыдущей ревизии.

" +} diff --git a/editorial/agent-rewrites/122.json b/editorial/agent-rewrites/122.json new file mode 100644 index 0000000..56737ea --- /dev/null +++ b/editorial/agent-rewrites/122.json @@ -0,0 +1,7 @@ +{ + "index": 122, + "slug": "editorial-2024-08-mechanism-feature-flags", + "title": "Feature flags без рассинхрона: где принимать решение и что считать показом", + "excerpt": "Как разделить серверное решение, клиентское отображение и аналитическое событие, чтобы rollout оставался объяснимым, а временная ветка действительно исчезла.", + "contentHtml": "

Новый экран включили для десяти процентов пользователей. Часть из них увидела кнопку, но API продолжил выполнять старую ветку. У другой части браузер показал новый вариант после обновления страницы, хотя сервер ещё отдавал старый. В аналитике при этом появился один общий event exposure. По нему нельзя понять, было ли решение вычислено, дошёл ли ответ до браузера и отрендерился ли экран.

\n

Цена такой ошибки растёт после первого успешного релиза. Команда тратит время на поиск слоя, который изменил вариант. В защищённый клиентский контекст могут попасть лишние сведения. Сегмент получает разные правила на соседних запросах. После эксперимента остаются две ветки кода, старый ключ конфигурации и тесты, которые поддерживают уже несуществующее решение.

\n

Feature flag — не просто boolean. Это контракт из входов, владельца решения, версии конфигурации, безопасного значения и пути удаления. Главный принцип прост: один слой владеет eligibility, а остальные получают только тот результат, который им нужен. Evaluation, render и business effect нужно считать разными фактами.

\n

Симптом → причина → проверка → действие

\n
Диагностика рассинхрона feature flag
СимптомПричинаПроверкаДействие
UI показывает новый вариант, API выполняет старыйСервер и браузер независимо вычисляют один flagСравнить evaluator, targeting key и config version в одном запросеОставить eligibility на сервере; клиенту передавать verdict и version
Один пользователь меняет вариант после логинаДо логина используется anonymous ID, после логина — account IDПостроить timeline ключа для одной сессииОписать переход ключа или закрепить вариант на время сессии
Exposure есть, но экран не показанСобытие отправляется сразу после evaluationСопоставить событие с точкой рендера и ошибками UIРазделить evaluation record и render confirmation
Отключение флага не меняет всех клиентов сразуКлиент держит кэш или обновляет конфигурацию по расписаниюПроверить effective config version и границу refreshЗадать допустимое окно устаревания и fallback
\n

Сначала разделите входы

\n

Оценка флага получает context. В него могут входить идентификатор субъекта, приложение, окружение, локаль и другие признаки. Не каждый такой признак можно передавать в браузер. Право доступа, индивидуальная цена, fraud signal и внутреннее состояние аккаунта должны оставаться за доверенной границей. Клиенту нужен ответ о доступности функции, а не правило, по которому сервер его получил.

\n

Публичный layout preference или локаль часто подходят для client-side решения. Это верно только тогда, когда они уже доступны клиенту и не скрывают защищённую политику. Если один flag зависит и от локали, и от права доступа, его нельзя безопасно перенести в браузер целиком. Сервер может вычислить eligibility, а клиент — выбрать разрешённое представление внутри полученного контракта.

\n

Один authoritative evaluator

\n

Evaluator отвечает на вопрос: какой вариант вернуть для данного flag key и context. В архитектуре должен быть один владелец этого ответа. Если сервер и браузер повторяют одно правило, они должны либо использовать один явно согласованный контракт, либо считаться независимыми решениями с отдельными названиями. Скрытая копия правила почти всегда приводит к расхождению.

\n

Пример ниже учебный. Он не подключается к provider, не читает реальную конфигурацию и не отправляет события. В нём показана граница: сервер принимает защищённый вход, возвращает уже принятое решение и версию, а клиент не переоценивает право доступа.

\n
type FlagDecision = {\n  variant: 'control' | 'candidate';\n  enabled: boolean;\n  configVersion: string;\n};\n\nfunction resolveCheckoutBanner(input: {\n  accountId: string;\n  hasNewCheckoutAccess: boolean;\n}): FlagDecision {\n  return {\n    variant: input.hasNewCheckoutAccess ? 'candidate' : 'control',\n    enabled: input.hasNewCheckoutAccess,\n    configVersion: 'checkout-banner-v3',\n  };\n}\n\nconst decision = resolveCheckoutBanner({\n  accountId: 'account-42',\n  hasNewCheckoutAccess: false,\n});\n\n// Browser receives the result, not hasNewCheckoutAccess or the rule.\nrenderBanner({ enabled: decision.enabled, variant: decision.variant });
\n

В реальной системе значение accountId не нужно отправлять в клиент только ради отображения. Поле configVersion помогает объяснить результат и сопоставить его с журналом. Его нельзя считать глобальным доказательством: другой сервис или кэш может работать с другой версией.

\n

Отрицательный путь важнее счастливого. Если provider недоступен, context не содержит обязательного ключа или версия конфигурации устарела сверх допустимого окна, система должна вернуть явно выбранный fallback. Не следует молча вычислять тот же flag вторым алгоритмом в браузере. Иначе отказ превращается в незаметное изменение аудитории.

\n

Когорта начинается с targeting key

\n

Процент rollout стабилен только относительно ключа. Сервер может распределять пользователей по account ID, а браузер — по cookie ID. Тогда один человек получит разные варианты. После входа ключ может измениться снова. Это не мелкая деталь хеширования. Это изменение субъекта, которому принадлежит решение.

\n

Зафиксируйте четыре вещи: кто является subject, когда появляется его ключ, как проходит переход anonymous → authenticated и нужно ли закреплять вариант на активную сессию. Если конфигурация меняется, решите, может ли следующий запрос пересчитать когорту. Для некоторых экранов это допустимо. Для оплаты, миграции данных или последовательного сценария может потребоваться pinning до конца операции.

\n

Не включайте персональные данные в context без необходимости. Провайдер может сериализовать или сохранять его для таргетинга. Используйте стабильный идентификатор или заранее определённый хеш, если это соответствует модели угроз и правилам хранения. Хеш сам по себе не делает данные безопасными.

\n

Evaluation не равно exposure

\n

Evaluation означает, что evaluator получил входы и вернул вариант или fallback. Это технический факт вычисления. Exposure candidate означает, что приложение подготовило запись о выбранном варианте. Render confirmation означает, что клиент дошёл до конкретной точки показа. Business effect означает отдельное действие пользователя или доменный результат. Эти события нельзя сливать в одно поле exposure.

\n
Что означает запись о flag
ФактМинимальные поляЧего он не доказывает
Evaluationflag key, variant, evaluator, config versionЧто UI отрендерился
Exposure candidatesubject boundary, variant, idempotency keyЧто событие доставлено ровно один раз
Render confirmationscreen, display point, client timestampЧто пользователь прочитал экран
Business effectДоменное действие и его собственный contractЧто действие вызвал только flag
\n

Слово exactly-once здесь опасно. Повторная отправка, таймаут, падение consumer и повторный запуск страницы создают разные сценарии. Если аналитике нужна дедупликация, downstream должен получить устойчивый idempotency key и окно хранения ключей. Если нужно знать факт рендера, отправляйте событие в точке рендера. Даже это не доказывает, что пользователь увидел или использовал результат.

\n

Сервер, клиент или разделённое решение

\n

Серверное решение подходит, когда flag зависит от защищённых данных или должно одинаково влиять на API и UI. Недостаток — сетевой путь и возможность увидеть старый результат из кэша. Его компенсируют явная версия, короткий контракт ответа и проверяемый fallback.

\n

Клиентское решение подходит для уже публичной конфигурации, например локали или разрешённой темы. Недостаток — контекст и правила становятся частью доверенной границы браузера. Нельзя использовать этот вариант для скрытого entitlement только потому, что так быстрее собрать интерфейс.

\n

Разделённое решение полезно, когда сервер отвечает за eligibility, а клиент выбирает только presentation. Например, сервер возвращает candidate и версию, а клиент решает, какой из двух разрешённых layout показать. Граница должна быть написана рядом с контрактом. Иначе presentation постепенно начнёт повторять policy.

\n
\"Поток
Схема разделяет decision, render и event delivery. Она учебная: не показывает реальный трафик, задержки, размер когорты или результаты эксперимента.
\n

Изменение конфигурации имеет границу свежести

\n

Отключение flag не обязано мгновенно менять каждый клиент. Кэш, refresh interval, service worker, edge и повторная загрузка страницы создают окно устаревшего состояния. Поэтому контракт должен отвечать на вопрос: какая версия вернулась этому evaluator и сколько времени такой результат допустим.

\n

Если UI получает вариант с сервера, не заставляйте браузер заново применять скрытое targeting rule. Передавайте verdict, variant и config version. Если provider сообщает об изменении конфигурации, это событие помогает запустить refresh или диагностику, но само по себе не является бизнес-exposure. При ошибке обновления используйте заранее выбранное поведение: сохранить последний допустимый вариант, перейти на control или заблокировать опасную операцию. Выбор зависит от риска функции.

\n

Порядок проектирования и проверки

\n
  1. Назовите изменение. Запишите старый путь, новый путь, безопасный fallback и доменную цель. Не смешивайте release flag, permission и эксперимент в одном ключе.
  2. Классифицируйте входы. Отделите protected facts от client-safe fields. Для каждого поля укажите, кто его видит и зачем он нужен.
  3. Выберите evaluator. Назначьте один слой владельцем eligibility. Второй слой может отображать результат, но не пересчитывает скрытое правило.
  4. Зафиксируйте targeting key. Опишите subject, переход логина, поведение после logout и политику активной сессии.
  5. Добавьте версию. Возвращайте effective config version вместе с решением. Сопоставляйте её с журналом и логами, не называя её глобальной версией системы.
  6. Разведите события. Отдельно назовите evaluation, exposure candidate, render confirmation и business effect. Для повторов задайте idempotency key.
  7. Проверьте отрицательный путь. Отключите provider, уберите targeting key и предъявите устаревшую конфигурацию. Убедитесь, что система выбирает ожидаемый fallback и не запускает скрытый второй evaluator.
  8. Откройте удаление. После выбора варианта удалите ветку, конфигурационный ключ, лишние тесты и документацию одной согласованной change. Продление срока должно иметь новую причину и новую дату review.
\n

Ограничения и критерий готовности

\n

Эта модель не выбирает конкретный SDK, TTL кэша, транспорт событий или процент rollout. Она не доказывает, что provider доступен, токен клиента ограничен или эксперимент статистически значим. Актуальная документация OpenFeature описывает provider, evaluation context и provider events как части API и жизненного цикла. Она не задаёт вашей команде SLA свежести, политику персональных данных или семантику product exposure.

\n

Учебный код выше не является production-рецептом. В боевой системе отдельно проверяют авторизацию, валидацию context, обработку ошибок, наблюдаемость и совместимость версий. Не переносите строку checkout-banner-v3 в рабочую конфигурацию без определения владельца и пути удаления.

\n

Критерий готовности проверяемый. Для одного реального flag возьмите один subject и один запрос. По логам и ответу назовите evaluator, targeting key, variant, config version и fallback. Затем покажите, где возникло evaluation, где произошёл render и как consumer обработал повтор события. Если любой ответ требует догадки или поиска правила в двух слоях, контракт ещё не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/123.json b/editorial/agent-rewrites/123.json new file mode 100644 index 0000000..2e7f112 --- /dev/null +++ b/editorial/agent-rewrites/123.json @@ -0,0 +1,7 @@ +{ + "index": 123, + "slug": "editorial-2024-08-practice-feature-flags", + "title": "Фича-флаг как контракт выпуска: включить, проверить, удалить", + "excerpt": "Release-флаг снижает риск только тогда, когда у него есть ограниченная аудитория, безопасный fallback, владелец, срок пересмотра и заранее описанное удаление.", + "contentHtml": "

В пятницу новый checkout включают для пяти процентов пользователей. В понедельник команда видит ошибки только на части запросов и выключает флаг. Но старый и новый код остаются в репозитории, тесты продолжают покрывать два пути, а конфигурация живёт без владельца. Через месяц никто не знает, можно ли удалить условие: новый путь мог записать данные в другом формате, а клиент мог закешировать старый вариант.

\n

Цена такой ошибки растёт не вместе с процентом rollout. Она растёт с каждым новым условием, исключением и сервисом, который читает тот же ключ. Выключенный флаг уменьшает аудиторию, но не убирает ветки, не возвращает изменённые данные и не объясняет, почему система выбрала вариант. Тезис статьи простой: release-флаг — это временный контракт выпуска. Он должен описывать границу решения, безопасное значение, наблюдение и момент, когда код исчезнет.

\n

Механизм: boolean скрывает решение

\n

Значение true или false отвечает только на один вопрос: какой путь выбрать сейчас. Выпуск требует ответов на другие вопросы. Для кого действует правило? Где его вычисляют? Что произойдёт при ошибке провайдера или устаревшей конфигурации? Кто остановит rollout? Как проверить новый путь? Что удалить после выбора варианта?

\n

Удобно разделить флаг на пять частей. Owner принимает решение на review. Audience задаёт стабильную когорту и окружение. Fallback определяет путь при недоступном или сомнительном решении. Signals показывают, что именно произошло. Cleanup связывает итоговый вариант с удалением условий, настроек и тестовых исключений.

\n

Эти части не заменяют flag management system. Они задают контракт вокруг неё. Провайдер может вернуть default value, сообщить об ошибке или показать, что его состояние устарело. Приложение всё равно должно решить, какое значение безопасно для конкретной операции. Для цены, права доступа и другого защищённого решения fallback выбирает сервер. Клиент получает уже вычисленный результат, а не правило с закрытым контекстом.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
«Временный» флаг появляется без даты удаленияBoolean приняли за весь контракт выпускаНайдите owner, review date и список ветокОформите карточку и назначьте review до rollout
После выключения ошибки исчезли, но код не меняетсяDisablement перепутали с cleanupСравните ветки, config keys, тесты и формат записанных данныхОткройте отдельное удаление и оставьте fallback до его завершения
Один пользователь видит разные вариантыСервисы используют разные targeting keys или версии конфигурацииЗапишите key, effective version и границу сессии в evaluation detailsНазначьте один authoritative evaluator или явно опишите split decision
Событие exposure есть, а интерфейс не показалсяEvaluation назвали эффектомРазделите evaluation, response, render и user actionПереименуйте сигнал и не делайте вывод о результате по одной записи
Провайдер недоступенFallback не определён для ошибки или stale stateВоспроизведите provider error в изолированной проверкеВерните объявленный safe value и поднимите сигнал оператору
\n

Карточка до первой строки кода

\n

Сначала сформулируйте один вопрос выпуска. «Включить новый checkout» слишком широко. Лучше: «Показать новую форму checkout синтетической внутренней когорте, оставив прежнюю форму доступной при любом сбое оценки». Такая формулировка задаёт аудиторию, вариант и отрицательный путь.

\n
const releaseFlagCard = {\n  key: 'synthetic-checkout-copy-v1',\n  class: 'release',\n  owner: 'synthetic-checkout-owner',\n  audience: 'synthetic-internal-beta-cohort',\n  targetingKey: 'synthetic-subject-id',\n  fallback: 'existing-checkout-copy',\n  reviewAt: 'synthetic-2026-09-30',\n  signals: ['evaluation-outcome', 'client-render-error'],\n  cleanup: ['freeze-variant', 'remove-branches', 'remove-config'],\n};\n\n// Учебная запись. Она не создаёт флаг, не читает provider\n// и не отправляет telemetry или данные пользователя.
\n

В примере все значения synthetic. Дата не запускает таймер, ключ не определяет настоящую когорту, а массив cleanup не удаляет файлы. Он показывает форму review. В реальной системе карточка должна ссылаться на конкретные владельца, окружение, конфигурацию и проверку, но не должна содержать секреты.

\n

Где принимать решение

\n

Сервер должен владеть решением, если оно зависит от entitlement, цены, account state, fraud signal или другого защищённого входа. Сервер возвращает клиенту presentation value и, при необходимости, effective configuration version. Браузер не должен повторять правило по урезанному контексту: такое дублирование создаёт два источника истины.

\n

Клиент может вычислять presentation-флаг, если все входы уже публичны: например, доступная локаль, опубликованный вариант оформления или локальная настройка layout. Это сокращает сетевой путь, но не отменяет проверку token, CORS, cache и refresh semantics конкретного провайдера.

\n

Разделяйте evaluation и exposure. Evaluation означает, что evaluator вернул вариант для заданного контекста. Exposure можно использовать для записи попытки показать вариант, но запись не доказывает успешный render, пользовательское действие или бизнес-эффект. Если событие доставляется повторно или теряется, это нужно учитывать в consumer и в выводах из метрик.

\n
\"Дерево
Сначала проверяется контракт, затем выбирается граница решения. Схема учебная: она не показывает состояние реального flag-сервиса, rollout или production-метрики.
\n

Порядок внедрения

\n
  1. Назовите старый и новый путь. Запишите, какая функция меняется, что остаётся fallback и какие данные каждый путь читает или записывает.
  2. Выберите класс флага. Не смешивайте release, эксперимент, миграцию данных и постоянную policy в одном ключе. Для каждого класса нужны разные сроки и проверки.
  3. Назначьте owner и review date. Owner должен выбрать итоговый вариант, продлить контракт с причиной или начать cleanup. Продление без новой причины не является нейтральным действием.
  4. Опишите audience boundary. Зафиксируйте environment, targeting key, способ удержания когорты и данные, которые не покидают сервер. Проверьте поведение после логина, смены сессии и обновления конфигурации.
  5. Определите fallback и отрицательный путь. Проверьте default value, provider error, stale state, таймаут и недоступность сети. Убедитесь, что возврат не скрывает несовместимые данные.
  6. Назовите сигналы и их пределы. Разделите evaluation result, render error, API failure и user action. Для каждого напишите, какой вывод он разрешает и какой запрещает.
  7. Расширяйте аудиторию постепенно. На каждом шаге проверяйте ошибку, latency, целостность ответа и работу старой ветки. Если сигнал нарушает stop condition, верните объявленный fallback и зафиксируйте причину.
  8. Зафиксируйте выбранный вариант. После решения не добавляйте новые rules в старый release-флаг. Создайте cleanup change для кода, конфигурации, тестов, документации и лишних сигналов.
\n

Как проверить удаление

\n

Выключение и удаление проходят разными проверками. После выключения убедитесь, что новый путь не получает аудиторию и старый путь отвечает за заявленный контракт. Затем проверьте, не остались ли записи нового формата, client cache, server cache, отдельные разрешения и тестовые обходы. Только после этого удаляйте ветки. Иначе «cleanup» может убрать защитное условие раньше, чем система перестанет читать его последствия.

\n

Простой критерий для pull request: поиск по ключу флага не находит production-условий после удаления, тесты больше не выбирают вариант через старую конфигурацию, а единственный оставшийся путь не зависит от временного default. Если данные менялись, добавьте отдельную проверку совместимости. Не объявляйте cleanup завершённым по одному зелёному unit-тесту.

\n

Ограничения

\n

Карточка не выбирает безопасный процент rollout и не заменяет threat model, approval, миграцию схемы, SLA или план отката данных. OpenFeature задаёт API evaluation context, targeting key, providers и provider events, но не навязывает поля owner, reviewAt или cleanup. Эти поля — локальная инженерная policy.

\n

Разные провайдеры по-разному обновляют конфигурацию, кэшируют правила и обрабатывают ошибки. Нельзя переносить интервал refresh или семантику токена одного SDK в другой без проверки его документации. Нельзя считать targeting key доказательством единой когорты во всех сервисах, если они не используют общую версию конфигурации и один authoritative evaluator.

\n

Учебный объект выше не является результатом production-эксперимента. Он не доказывает latency, delivery rate, безопасность данных или полезный эффект новой формы. Production-вывод требует реальных наблюдений, определённого окна измерения и заранее согласованного stop condition.

\n

Проверяемые источники

\n

Проверяемая готовность

\n

Флаг готов к включению, если другой инженер без устного контекста может назвать его вопрос, owner, audience boundary, authoritative evaluator, safe fallback, signals, stop condition и review date. Флаг готов к удалению, если выбран вариант зафиксирован, поиск не находит временных веток, тесты не зависят от старого ключа, а изменения данных проверены отдельно. Если хотя бы один ответ отсутствует, это не повод добавить ещё один boolean. Это сигнал вернуть контракт на review.

" +} diff --git a/editorial/agent-rewrites/124.json b/editorial/agent-rewrites/124.json new file mode 100644 index 0000000..7a7de62 --- /dev/null +++ b/editorial/agent-rewrites/124.json @@ -0,0 +1,7 @@ +{ + "index": 124, + "slug": "editorial-2024-07-field-release-engineering", + "title": "Когда версия не доказывает релиз: проверка artifact, migration и rollback", + "excerpt": "Один номер релиза может скрывать разные commit, artifact и migration. Разбираем, где остановиться, какие связи проверить и почему возврат образа не отменяет изменения данных.", + "contentHtml": "

В заявке на выпуск стоит 2024.07.0. Такой же номер виден у commit, container image, migration и rollout. После выкладки сервис отвечает кодом старой схемы: новый код ждёт поле, которого в базе нет. Команда повторяет deploy, потому что все карточки выглядят согласованными. Ошибка становится дороже с каждой попыткой: растёт окно недоступности, меняется состояние данных, а точку возврата уже трудно назвать.

\n

Проблема не в самом номере версии. Проблема в том, что номер заменил связи между объектами. Он не доказывает, что artifact собран из нужного commit, что migration рассчитана на этот contract и что rollout ссылается на тот же digest. Выпуск готов только тогда, когда эти связи можно проверить по точным значениям, а отрицательный результат останавливает действие.

\n

Где рвётся цепочка

\n

Commit описывает исходный revision. Artifact содержит собранное содержимое и immutable digest. Migration меняет схему или данные и должна назвать целевую версию и совместимость. Rollout intent говорит, какой digest команда собирается отправить. Return point хранит предыдущую версию и digest. Эти записи связаны, но не заменяют друг друга.

\n

У каждой связи есть проверяемое утверждение. Artifact должен ссылаться на exact commit id. Migration должна называть release version и совместимость с текущей схемой. Rollout должен содержать digest из artifact, а не только tag. Return point должен быть известен до approval. Если одно утверждение ложно или неизвестно, действие заканчивается на gate. Retry не исправляет неправильную запись.

\n
\"Схема
Учебная схема показывает порядок сверки. Она не является логом CI, registry, кластера или production rollout.
\n

Минимальный пример

\n

Ниже — ограниченный учебный пример. Значения вымышлены. Код не обращается к Git, registry, CI, Kubernetes API или базе данных. Он только сравнивает заранее заданные записи и возвращает решение для проверки человеком.

\n
const release={version:'2024.07.0'},commit={id:'commit-7f4a0c1'},artifact={digest:'sha256:release-070-a1',sourceCommitId:'commit-7f4a0c1'},migration={targetReleaseVersion:'2024.07.0',compatibleWith:'2024.06.3'},rollout={requestedArtifactDigest:'sha256:release-070-a1',migrationVersion:'2024.07.0'}; const checks={source:artifact.sourceCommitId===commit.id,migration:migration.targetReleaseVersion===release.version,artifact:rollout.requestedArtifactDigest===artifact.digest,rollout:rollout.migrationVersion===migration.targetReleaseVersion}; const ready=Object.values(checks).every(Boolean); if(!ready) throw new Error('stop: reconcile release records');
\n

При ready === true пример говорит только о согласованности пяти записей. Он не говорит, что образ существует, подпись действительна, migration выполнена или сервис здоров. Если заменить sourceCommitId на другой id, результат должен стать отрицательным. То же относится к digest и target version. Это и есть полезный отрицательный путь: система не угадывает, какую запись считать правильной.

\n

Симптом → причина → проверка → действие

\n
Диагностика расхождений до запуска
СимптомПричинаПроверкаДействие
Номер версии совпадает, но artifact указывает на другой commit.Tag используют вместо точной связи с исходным revision.Сравнить artifact.sourceCommitId и commit id.Остановить выпуск. Исправить запись или пересобрать artifact после решения владельца.
Код можно вернуть, но схема базы уже изменилась.Rollback binary ошибочно считают rollback данных.Проверить migration target, compatibility и обратную процедуру.Вернуть только явно разрешённый artifact; вопрос данных передать отдельному владельцу.
Rollout прошёл с тем же tag, но другим digest.Intent ссылается на mutable label, а не на immutable content.Сравнить requested digest с digest artifact.Не запускать rollout. Пересоздать intent после сверки.
После stop команда предлагает повторить deploy.Retry используют как замену объяснению расхождения.Найти первую ложную связь и назвать её источник.Сначала reconcile records, затем повторить только проверку.
\n

Таблица разделяет четыре разных вопроса. Mismatch commit относится к происхождению artifact. Mismatch migration относится к совместимости contract. Mismatch digest относится к содержимому, выбранному для rollout. Повторная попытка без такой классификации стирает причину и оставляет команду без доказуемого решения.

\n

Порядок действий перед выпуском

\n
  1. Зафиксируйте границу проверки. Укажите release version, owner и источник каждой записи. Пометьте, что сейчас выполняется review, а не deploy.
  2. Сверьте commit и artifact. Проверьте exact source commit и digest. Название ветки, последний merge и короткий tag не заменяют id.
  3. Опишите migration отдельно. Назовите target version, совместимость с текущим contract и действие по данным. Не прячьте migration в комментарии к image.
  4. Сверьте rollout intent. Он должен повторять immutable digest artifact и migration version. Любое расхождение ведёт в stop.
  5. Назовите return point. Запишите предыдущую версию и digest. Отдельно укажите, что произойдёт с данными и кто проверит этот путь.
  6. Повторите проверки после исправления. Передайте человеку только записи без ложных связей. Положительный результат открывает review, но не выдаёт автоматическое разрешение на deploy.
\n

Почему rollback не возвращает всё

\n

Rollback Deployment обычно возвращает предыдущую ревизию Pod template. Это полезно для кода и настроек, которые входят в template. Оно не отменяет произвольный SQL, удалённую запись, заполненное поле или изменение внешнего contract. Если migration уже прошла, старый image может не уметь читать новую схему.

\n

Return point должен содержать две границы. Первая — какую версию artifact можно запустить. Вторая — что разрешено делать с данными. Возможны обратная migration, совместимый промежуточный код, восстановление из резервной копии или запрет автоматического возврата. Пока путь не проверен, честная формулировка звучит так: «возврат artifact определён; откат данных не доказан».

\n

То же различие действует для provenance и attestations. Официальная документация SLSA описывает проверку provenance через сравнение с ожиданиями пакета. GitHub описывает artifact attestations как подписанные claims о происхождении и даёт команды для проверки. Ни один из этих механизмов сам по себе не утверждает, что migration совместима, rollout одобрен или production здоров.

\n

Ограничения и отрицательный путь

\n

Описанный подход ловит расхождения между названными записями. Он не доказывает, что значения правдивы. Он не проверяет историю Git, содержимое image, подпись, policy CI, права на кластер, runtime configuration, состояние базы, трафик, метрики или факт доставки. Для этих вопросов нужны разрешённые источники и отдельные проверки.

\n

Если commit неизвестен, digest отсутствует, migration не имеет compatibility statement или return point не назван, результат должен быть отрицательным. Не подставляйте «последний main», не ищите image по tag и не объявляйте data rollback по факту отката Pod template. Остановитесь на первой неизвестной границе. Такое поведение медленнее одной зелёной кнопки, но дешевле расследования после повреждения данных.

\n

Проверяемый критерий готовности

\n

Материал готов к передаче на human review, если второй инженер без устных пояснений может показать: exact commit id, artifact digest, связь artifact с commit, migration target и compatibility, rollout digest, migration version и return point. Для каждой строки есть источник, владелец и действие при mismatch. Проверка должна дать либо все утверждения true, либо конкретный stop с названием ложной связи. В первом случае разрешение на deploy всё ещё принимает авторизованный процесс. Во втором случае deploy не начинается.

\n

Учебные значения в примере не являются production-результатами. Их задача — показать форму проверки и сохранить отрицательный путь. Реальную оценку готовности нужно выполнять на доступных и разрешённых записях конкретного выпуска.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/125.json b/editorial/agent-rewrites/125.json new file mode 100644 index 0000000..61f823a --- /dev/null +++ b/editorial/agent-rewrites/125.json @@ -0,0 +1,7 @@ +{ + "index": 125, + "slug": "editorial-2024-07-mechanism-release-engineering", + "title": "Инженерия релиза: как связать артефакт, миграцию и откат", + "excerpt": "Один номер версии не доказывает, что команда выпускает нужный код. Разбираем цепочку commit → artifact → migration → rollout и ставим проверяемый стоп перед ошибочным deploy.", + "contentHtml": "

После выкладки сервис отвечает старым поведением, хотя в CI и карточке релиза стоит одна версия — 2024.07.0. Откат возвращает прежний контейнер, но ошибка в данных остаётся. Команда повторяет deploy, меняет таймаут и смотрит на зелёный статус job. Это не исправляет расхождение. Цена ошибки — потерянное время, спор о том, что именно работает, и риск усугубить миграцию данных.

\n

Тезис простой: релиз нужно проверять как цепочку связей, а не как строку с версией. Commit должен быть источником артефакта. Rollout должен ссылаться на точный digest артефакта. Миграция должна называть целевую версию и границу совместимости. Для возврата нужно заранее назвать версию и digest. Если хотя бы одна связь не сходится, процесс останавливается до deploy.

\n

Механизм: четыре связи вместо одного тега

\n

Тег отвечает на вопрос «как назвали выпуск». Он не отвечает на вопросы «из какого commit собрали образ», «какой образ запросил rollout» и «для какой схемы написана миграция». Для этих вопросов нужны неизменяемые значения и явные предикаты.

\n\n

Эти условия проверяют согласованность записей. Они не доказывают, что deploy завершился, что registry доступен или что миграция обратима. Execution result и release evidence — разные вещи. Успешный rollout может работать с неправильным артефактом. Согласованный record может ещё не быть разрешением на выкладку.

\n

Учебный пример расхождения

\n

Ниже — синтетические записи. Они не получены из production и не описывают реальную доставку.

\n
const commit = {\n  id: 'synthetic-commit-91',\n  releaseVersion: '2024.07.0'\n};\n\nconst artifact = {\n  digest: 'sha256:synthetic-artifact-42',\n  sourceCommitId: 'synthetic-commit-other-91'\n};\n\nconst migration = {\n  targetReleaseVersion: '2024.07.0',\n  compatibleWith: '2024.06.x'\n};\n\nconst rollout = {\n  requestedArtifactDigest: 'sha256:synthetic-artifact-42'\n};\n\nconst checks = {\n  sourceMatches: artifact.sourceCommitId === commit.id,\n  artifactMatches: rollout.requestedArtifactDigest === artifact.digest,\n  migrationMatches: migration.targetReleaseVersion === commit.releaseVersion\n};\n\nconst canDeploy = Object.values(checks).every(Boolean);\n// false: остановить процесс и сверить записи\n
\n

Две проверки проходят. Артефакт и rollout называют один digest, миграция нацелена на правильную версию. Но source commit не совпадает. Поэтому canDeploy равен false. Нельзя делать вывод, что контейнер содержит код из synthetic-commit-91. Нельзя лечить это повторным запуском того же deploy. Сначала нужно найти источник расхождения и заново зафиксировать запись.

\n

Обратный путь важен не меньше. Возврат контейнера к предыдущему digest не отменяет изменение схемы или данных. Если миграция уже прошла, прежний код может не поддерживать новую схему. В карточке возврата нужно разделить два действия: вернуть code artifact и решить, что делать с data effect. Если второго решения нет, честный статус — «возврат артефакта подготовлен, откат данных не определён».

\n
\"Связи
Учебная схема показывает границу: code rollback возвращает названный артефакт, но не обещает отмену миграции.
\n

Симптом → причина → проверка → действие

\n
Диагностика рассогласованного релиза
СимптомПричинаПроверкаДействие
Везде одна версия, но поведение разноеТег используют как единственный идентификаторСравнить exact commit id и artifact sourceCommitIdОстановить выпуск и пересобрать evidence chain
Rollout зелёный, но загружен не тот образКарточка хранит tag вместо digestСравнить requestedArtifactDigest с digest артефактаИсправить intent record, не повторять deploy
После возврата код падает на данныхRollback контейнера приняли за rollback данныхПроверить target schema, compatibility и migration statusПередать data effect отдельному владельцу и остановить автоматический возврат
Миграция прошла для другой версииПлан миграции следует ветке или последнему mainСравнить targetReleaseVersion с release.versionЗакрыть gate и выпустить новый migration review
Невозможно объяснить, что вернётсяReturn point описана словом «предыдущий»Проверить конкретные version и digestНе давать approval, пока точка возврата не названа
\n

Таблица полезна только тогда, когда каждая проверка имеет владельца и stop action. Строка «все jobs зелёные» недостаточна: она не связывает job с содержимым артефакта и контрактом данных. Строка «digest совпал» тоже недостаточна: она не подтверждает доступность сервиса после выкладки. Не смешивайте semantic consistency с результатом исполнения.

\n

Почему миграция меняет смысл отката

\n

У релиза есть как минимум два состояния: code state и data state. Deployment обычно управляет шаблоном Pod или другим runtime artifact. Миграция меняет схему, записи или внешний контракт. Эти операции могут иметь разные владельцы, журналы и способы возврата.

\n

Безопасный порядок требует compatibility window. Новый код сначала должен работать со старой и новой формой данных, если это возможно. Затем миграция меняет данные. После проверки трафика команда может удалить старую ветку совместимости. В такой схеме возврат на старый код возможен только до закрытия окна. После него нужен отдельный план: обратная миграция, восстановление из backup или сохранение нового кода с исправлением.

\n

Это не универсальная стратегия миграций. Некоторые изменения нельзя отменить. Некоторые системы разрешают только forward migration. Статья не утверждает, что любой Kubernetes Deployment или любой image digest можно безопасно вернуть. Она требует назвать границу действия и не приписывать rollback то, чего он не делает.

\n

Порядок действий перед deploy

\n
  1. Зафиксируйте release version и owner проверки. Owner отвечает за сравнение записей, но это не делает его автоматически исполнителем deploy.
  2. Проверьте связь commit → artifact. Сравните exact id. Название ветки, последний merge и номер задачи не заменяют идентификатор commit.
  3. Проверьте artifact → rollout. Сравните immutable digest. Не подставляйте digest по тегу и не считайте совпадение имён доказательством.
  4. Опишите migration target и compatibility. Назовите версию, допустимый предыдущий контракт и отдельный data effect.
  5. Проверьте все предикаты. При первом false верните статус stop-and-reconcile-records. Не запускайте новую попытку ради зелёного job.
  6. Подготовьте return point. Запишите version и digest артефакта для возврата. Рядом укажите, что произойдёт с данными.
  7. Отделите approval от исполнения. Проверка записи разрешает перейти к авторизованному review, но сама не вызывает registry, cluster или deploy runner.
\n

Ограничения

\n

Эта модель не проверяет настоящий Git history, подпись, identity builder, provenance, registry, environment configuration, secrets, права доступа, database state, трафик и telemetry. Она не выдаёт уровень SLSA и не доказывает, что конкретный attestation заслуживает доверия. Для этого нужны отдельные политики, хранилища и проверяющие компоненты.

\n

Учебный код также не является CI-конфигурацией. Синтетические id и digest нужны, чтобы показать рассуждение на закрытом наборе данных. Реальные значения нельзя подменить в этом примере и затем считать результат производственным evidence. Практический перенос начинается с одного разрешённого release record и read-only проверки, а не с подключения fixture к deploy.

\n

Проверяемый критерий готовности

\n

Релиз готов к авторизованному review, если второй проверяющий без устных пояснений находит в одной записи:

\n\n

Проверяющий должен назвать результат каждой связи: true или false, stop action при false и владельца следующего вопроса. Если он может только сказать «job зелёный», критерий не выполнен. Это проверяемый предел статьи: согласовать записи до действия, не объявить production success.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/126.json b/editorial/agent-rewrites/126.json new file mode 100644 index 0000000..5d8fd6f --- /dev/null +++ b/editorial/agent-rewrites/126.json @@ -0,0 +1,7 @@ +{ + "index": 126, + "slug": "editorial-2024-07-practice-release-engineering", + "title": "Релиз без догадок: как проверить связь между commit, artifact и rollback", + "excerpt": "Одинаковый tag не доказывает, что команда собирается доставить нужный код. Разбираем проверяемую цепочку от commit до rollout, отрицательный путь и границу rollback для данных.", + "contentHtml": "

После выкладки сервис отвечает кодом старой версии, хотя в заявке указан новый релиз. В карточке сборки, образе и rollout стоит один tag. Команда повторяет запуск, но не может быстро ответить на три вопроса: из какого commit собран artifact, какой digest отправили и совместима ли migration с данными. Цена ошибки растёт с каждой попыткой: увеличивается окно сбоя, меняется состояние базы, а точку возврата приходится восстанавливать по разным журналам.

\n

Одинаковая версия не связывает объекты сама по себе. Релиз готов к следующему действию только тогда, когда можно сравнить exact commit id, immutable digest, migration target и return point. Если одна связь неизвестна или ложна, проверка должна остановить выпуск. Новый retry не исправляет расхождение записей.

\n

Механизм цепочки

\n

У релиза есть несколько разных объектов. commit фиксирует исходный revision. artifact содержит собранное содержимое и digest. migration меняет схему или данные и должна назвать целевую версию и совместимость. rollout описывает намерение отправить конкретный digest. return point указывает версию и digest, к которым можно вернуться.

\n

Эти записи не заменяют друг друга. Artifact должен ссылаться на exact commit, а не только на имя ветки. Rollout должен содержать digest artifact, а не mutable tag. Migration должна отвечать на вопрос о совместимости. Return point должен быть известен до разрешения операции. Такая цепочка не доказывает, что deploy уже состоялся. Она делает расхождение видимым до действия.

\n
\"Схема
Учебная схема показывает связи между записями релиза. Она не является журналом CI, registry, кластером или результатом production-выкладки.
\n

Минимальный пример

\n

Ниже приведён учебный пример с вымышленными значениями. Код не обращается к Git, registry, CI, Kubernetes API или базе данных. Он только сравнивает записи. Положительный результат означает согласованность этих записей, а не готовность реальной среды.

\n
const release = { version: '2024.07.0' };\nconst commit = { id: 'commit-7f4a0c1' };\nconst artifact = {\n  digest: 'sha256:release-070-a1',\n  sourceCommitId: 'commit-7f4a0c1',\n};\nconst migration = {\n  targetReleaseVersion: '2024.07.0',\n  compatibleWith: '2024.06.3',\n};\nconst rollout = {\n  requestedArtifactDigest: 'sha256:release-070-a1',\n  migrationVersion: '2024.07.0',\n};\n\nconst checks = {\n  source: artifact.sourceCommitId === commit.id,\n  migration: migration.targetReleaseVersion === release.version,\n  artifact: rollout.requestedArtifactDigest === artifact.digest,\n  rollout: rollout.migrationVersion === migration.targetReleaseVersion,\n};\n\nconst readyForReview = Object.values(checks).every(Boolean);\nif (!readyForReview) throw new Error('stop: reconcile release records');
\n

Каждая проверка отвечает только на один вопрос. Если заменить sourceCommitId на другой id, результат станет отрицательным. Если изменить digest в rollout, tag всё ещё будет выглядеть правильно, но содержимое уже не совпадёт. Отрицательный путь важнее зелёной строки: система не выбирает за инженера «примерно подходящую» запись.

\n

Название readyForReview намеренно не означает readyForDeploy. Код не проверяет подпись, права, конфигурацию среды, состояние базы, доступность сервиса или факт доставки. Он лишь открывает следующий этап проверки, если четыре связи согласованы.

\n

Симптом → причина → проверка → действие

\n
Диагностика расхождений до запуска
СимптомПричинаПроверкаДействие
Tag совпадает, но artifact ссылается на другой commit.Tag используют вместо точной связи с исходным revision.Сравнить artifact.sourceCommitId и commit id.Остановить выпуск. Исправить запись или пересобрать artifact после решения владельца.
Старый код не читает новую схему.Rollback образа ошибочно считают rollback данных.Проверить target migration, совместимость и обратную процедуру.Вернуть только разрешённый artifact. Изменение данных рассмотреть отдельно.
Rollout прошёл с тем же tag, но другим digest.Намерение ссылается на изменяемую метку.Сравнить requested digest с digest artifact.Не запускать rollout. Пересоздать запись после сверки.
После остановки предлагают повторить deploy.Retry используют вместо объяснения mismatch.Найти первую ложную связь и её источник.Сначала исправить записи, затем повторить только проверки.
Точку возврата называют «предыдущим релизом».У return point нет конкретного содержимого.Проверить version и immutable digest.Не обещать возврат, пока обе величины не записаны.
\n

Таблица разделяет разные классы риска. Ошибка commit относится к происхождению artifact. Ошибка digest относится к содержимому, выбранному для rollout. Ошибка migration относится к совместимости данных и кода. Неопределённый return point относится к возможности безопасно назвать действие после сбоя. Одно поле release=green не заменяет эти проверки.

\n

Порядок действий перед выпуском

\n
  1. Назовите границу проверки. Зафиксируйте release version, owner и источник каждой записи. Укажите, что сейчас выполняется сверка, а не deploy.
  2. Свяжите artifact с commit. Проверьте exact source commit и digest. Имя ветки, последний merge и короткий tag не заменяют идентификатор.
  3. Опишите migration отдельно. Назовите target version, совместимость с текущим contract и действие по данным. Не прячьте migration в комментарии к образу.
  4. Сверьте rollout. Он должен содержать immutable digest artifact и migration version. Любое расхождение переводит процесс в stop.
  5. Назовите return point. Запишите предыдущую версию и digest. Отдельно укажите, что произойдёт с данными и кто проверит этот путь.
  6. Повторите проверки после исправления. Передайте дальше только записи без ложных связей. Положительный результат открывает авторизованную проверку, но не выдаёт разрешение на deploy.
\n

Почему rollback не возвращает данные

\n

Rollback Deployment возвращает прежнюю ревизию Pod template. Это помогает вернуть код и настройки, которые входят в этот template. Но такой rollback не отменяет произвольный SQL, удалённую запись, заполненное поле или изменение внешнего contract. Если migration уже изменила данные, старый artifact может не уметь с ними работать.

\n

Поэтому return point должен содержать две границы. Первая говорит, какой artifact можно запустить. Вторая говорит, что разрешено делать с данными. Возможны обратная migration, совместимый промежуточный код, восстановление из резервной копии или запрет автоматического возврата. Пока выбранный путь не проверен, честная формулировка звучит так: «возврат artifact определён; откат данных не доказан».

\n

Та же граница действует для provenance и attestation. Provenance описывает происхождение сборки. Attestation может подтверждать утверждение об этом происхождении. Ни одно из них само по себе не доказывает совместимость migration, approval rollout или здоровье сервиса. Эти вопросы требуют собственных источников и проверок.

\n

Ограничения и отрицательный путь

\n

Схема ловит расхождения между названными записями. Она не доказывает правдивость каждого значения. Она не проверяет историю Git, содержимое образа, подпись, policy CI, права на кластер, runtime configuration, состояние базы, трафик, метрики или факт доставки.

\n

Если commit неизвестен, digest отсутствует, migration не содержит compatibility statement или return point не назван, результат должен быть отрицательным. Не подставляйте «последний main». Не ищите образ по tag. Не объявляйте rollback данных по факту возврата Pod template. Остановитесь на первой неизвестной границе и назначьте источник, который может её подтвердить.

\n

Проверяемый критерий готовности

\n

Запись готова к передаче на авторизованную проверку, если второй инженер без устных пояснений может показать exact commit id, artifact digest, связь artifact с commit, migration target и compatibility, rollout digest, migration version и return point. Для каждой строки есть источник, владелец и действие при mismatch. Проверка даёт либо все утверждения true, либо конкретный stop с названием ложной связи.

\n

В учебном примере все значения вымышлены и не описывают production-результат. В реальном выпуске критерий нужно применять к доступным и разрешённым записям. Если одна строка не имеет источника или действия, выпуск не готов: сначала уточните contract, затем повторите сверку.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/127.json b/editorial/agent-rewrites/127.json new file mode 100644 index 0000000..747b267 --- /dev/null +++ b/editorial/agent-rewrites/127.json @@ -0,0 +1,7 @@ +{ + "index": 127, + "slug": "editorial-2024-06-field-container-orchestration", + "title": "Kubernetes без ложных сигналов: как связать ресурсы, readiness и HPA", + "excerpt": "После релиза Pod может быть Unready, CPU — высоким, а HPA — не менять число реплик. Разбираем, какой контур породил симптом, чем его проверить и когда изменение манифеста действительно готово.", + "contentHtml": "

После обновления образа часть Pod-ов долго остаётся Unready. В графике растёт CPU. Команда предлагает увеличить maxReplicas и CPU limit. Это может не изменить ни одного симптома. Readiness управляет допуском Pod к трафику Service. HPA рассчитывает реплики по метрике. Scheduler размещает Pod по requests. Runtime применяет limits. Один Pod, четыре контура.

\n

Цена ошибки — не только лишние ресурсы. Новый Pod может не пройти readiness из-за зависимости. HPA может не считать CPU, если у контейнера нет request. Увеличенный limit может скрыть рост памяти до следующего отказа. Если изменить все поля сразу, команда потеряет причинную связь. Она не узнает, что именно сработало и какой риск остался.

\n

Тезис. Сначала нужно назвать контракт сигнала, затем проверить его источником того же типа. Не называйте Ready доказательством capacity. Не называйте CPU percentage самостоятельным числом. Не называйте значение из учебной модели показанием кластера.

\n

Механизм: четыре контура вместо одной «нагрузки»

\n

Request задаёт reservation contract. Scheduler учитывает requests контейнеров при выборе Node. Для одного ресурса request Pod складывается из requests его контейнеров. Это не прогноз постоянного потребления. Это условие размещения.

\n

Limit задаёт границу ресурса для контейнера. CPU и memory ведут себя по-разному. CPU limit может ограничивать выполнение. Memory limit не превращается в прогноз пикового потребления и не объясняет lifetime cache или batch buffer. Нельзя вывести безопасные значения из одного универсального коэффициента.

\n

Readiness отвечает на другой вопрос: можно ли отправлять трафик этому Pod сейчас. Когда Pod не готов, Service не должен использовать его как backend. Readiness probe не обязана объяснять причину. Readiness gate добавляет named condition, но не задаёт ей смысл. Владелец приложения должен определить producer, переход в True и путь восстановления.

\n

HPA формирует предложение по числу реплик из метрики и target. CPU utilization — процент относительно CPU request контейнеров, которые попали в выборку. Если relevant request отсутствует, controller не может получить такой utilization для контейнера. Значит, строка target: 65 без request не является рабочим scaling contract.

\n
apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: api\nspec:\n  replicas: 2\n  template:\n    spec:\n      containers:\n        - name: api\n          image: registry.example/api@sha256:...\n          resources:\n            requests:\n              cpu: 500m\n              memory: 256Mi\n            limits:\n              cpu: \"1\"\n              memory: 512Mi\n          startupProbe:\n            httpGet:\n              path: /startup\n              port: 8080\n          readinessProbe:\n            httpGet:\n              path: /ready\n              port: 8080\n          readinessGates:\n            - conditionType: example.com/SchemaReady\n---\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: api\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: api\n  minReplicas: 2\n  maxReplicas: 10\n  metrics:\n    - type: Resource\n      resource:\n        name: cpu\n        target:\n          type: Utilization\n          averageUtilization: 70
\n

Фрагмент учебный. Образ, пути probes, custom condition и значения ресурсов нельзя переносить в production без profile приложения и проверки среды. В манифесте видно главное: HPA percentage имеет denominator requests.cpu: 500m. Readiness gate не становится HPA metric. Limit 1 не меняет denominator HPA.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для одного workload
СимптомВозможная причинаПроверкаДействие
Pod Unready после релизаProbe или gate не выполняет контракт; зависимость ещё не готоваСопоставить condition, probe event и смысл ReadyИсправить owner и recovery path; не увеличивать replicas автоматически
CPU высокий, HPA не меняет репликиНет CPU request, нет metrics API или выбран другой target typeПроверить effective request, HPA status и источник метрикиСначала восстановить denominator или метрику; не рисовать scale policy по одному графику
Memory близка к limitРост cache, batch, retained object или неверный limitОпределить lifetime памяти и проверить runtime evidenceОграничить владельца роста; отделить memory action от CPU HPA
Новые Pod запускаются, но трафик не растётReadiness false, gate не становится True или Service не видит endpointПроверить Pod condition и endpoints разрешённым способомПроверить traffic contract; не считать создание Pod доказательством capacity
Процент выглядит убедительно только в fixtureСинтетическое число приняли за telemetryПроверить источник и marker данныхОграничить вывод учебной моделью и запросить реальное evidence отдельно
\n

Конкретный пример: почему request меняет смысл процента

\n

Пусть CPU request равен 500m, а HPA target — 70%. В модели controller это означает usage около 350m на Pod для целевой точки. Это 70% request, а не 70% Node и не 70% CPU limit. Если request изменить на 1000m, тот же target будет означать другую рабочую точку. Одновременно Scheduler начнёт резервировать больше CPU. Одно изменение затронет placement и interpretation метрики.

\n

Теперь уберём request. Значение synthetic CPU 600m всё ещё выглядит конкретно, но процент больше не имеет объявленного denominator. Корректный verdict — «нельзя интерпретировать utilization», а не «нужно больше Pod». В этом отрицательном пути отсутствие действия HPA — ожидаемый результат проверки контракта. Сначала нужно определить serving unit, для которой request имеет смысл, и подтвердить состояние metrics API в разрешённой среде.

\n

Другой пример — memory. Если synthetic observation показывает 470Mi при limit 512Mi, это не доказывает OOM, eviction, restart или throttling. Без runtime event это только учебное значение рядом с границей. Следующий вопрос относится к владельцу памяти: cache, batch buffer, response aggregation или connection pool. Readiness false при этом остаётся отдельным сигналом traffic, а не именем причины memory pressure.

\n
\"Схема
Учебная схема показывает две границы. Readiness определяет traffic eligibility. HPA использует свою метрику и свой denominator. Asset не описывает состояние реального кластера.
\n

Как проверять безопасно

\n

Проверка должна отвечать на один вопрос и использовать один тип evidence. Условие Pod, HPA status, metrics API, controlled request и runtime event не взаимозаменяемы. Если источник не разрешён, его отсутствие фиксируют как blocker. Не подменяйте его значением из fixture, screenshot или случайным графиком.

\n
  1. Ограничьте scope. Выберите один Deployment, owner, среду, период и разрешённые источники. Не начинайте с массового изменения Pod.
  2. Снимите декларацию. Выпишите requests и limits для каждого контейнера. Отдельно зафиксируйте startup, readiness, gates, HPA metric, target и min/max.
  3. Опишите profile. Назовите serving unit, startup work, concurrency, dominant resource, dependency policy и точный смысл Ready.
  4. Проверьте denominator. Для utilization найдите relevant request и effective values после admission. Если request отсутствует, остановите процентный вывод.
  5. Разделите сигналы. Сопоставьте readiness с traffic, metric с replica proposal, limit с runtime boundary, request с placement. Запишите, чего каждый сигнал не доказывает.
  6. Выберите одну гипотезу. Назначьте один evidence source, ожидаемый результат и stop condition. Не меняйте request, probe и HPA в одном эксперименте.
  7. Проверьте отрицательный путь. Зафиксируйте, что произойдёт при missing request, false gate, stale metric или memory growth. Отсутствие решения иногда и есть корректный результат.
  8. Закройте изменение. Сохраните observed result, owner, ограничение и rollback snapshot. Повторите исходный вопрос тем же типом evidence.
\n

Что не должна делать учебная fixture

\n

Учебный код может держать три фиксированные карточки в памяти: steady serving с request, memory growth с false readiness и warmup без request. Он может проверять, что synthetic value не получила ярлык telemetry и что внешний field отклоняется. Он не должен читать kubeconfig, namespace, файл манифеста, CI, HTTP, trace или production. Комментарий syntheticObservedPodBehavior должен прямо говорить, что это не kubectl и не metrics API.

\n
const card = {\n  id: 'fixed-warmup-request-missing-v1',\n  declared: { cpuRequest: null, cpuTarget: 65 },\n  observed: {\n    kind: 'embedded-fixed-js-object-not-telemetry',\n    ready: false,\n    cpu: '600m'\n  }\n};\n\nconst verdict = card.declared.cpuRequest === null\n  ? 'block-utilization-conclusion'\n  : 'compare-with-request';\n\nconsole.log(verdict);\n// Учебная модель. Нет cluster, file, CI, HTTP или production data.
\n

Для этой карточки корректен verdict block-utilization-conclusion. Код не говорит, сколько реплик нужно реальному сервису. Он проверяет только отрицательную ветку: процент без request нельзя честно объяснить. Production-инструмент требует отдельной авторизации, источника данных и правил изменения. Учебный пример эти полномочия не получает.

\n

Ограничения и rollback

\n

Материал не выбирает размер Node, ratio CPU и memory, значения probes, тип custom metric или безопасный maxReplicas. Он не видит admission webhooks, quotas, EndpointSlice, runtime cgroups, Metrics Server, custom adapter, logs, traces и downstream dependencies. Официальная семантика Kubernetes не заменяет проверку конкретного кластера.

\n

Rollback должен возвращать snapshot декларации и проверять side effects. Откат HPA не исправляет неверный readiness contract. Возврат limit не объясняет рост памяти. Изменение probe не восстанавливает потерянные evidence. Для каждого изменения укажите owner, условие остановки, временную границу и наблюдаемый критерий отката.

\n

Критерий готовности. Изменение готово, если profile назван, у каждого сигнала есть источник и owner, CPU utilization имеет declared request, Ready имеет отдельный traffic contract, synthetic данные не выданы за production, а отрицательная ветка приводит к остановке или явному blocker. Кроме того, команда может повторить исходную проверку тем же типом evidence и получить объяснимый результат. Если хотя бы одна строка отвечает «кажется», манифест не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/128.json b/editorial/agent-rewrites/128.json new file mode 100644 index 0000000..dac0ff4 --- /dev/null +++ b/editorial/agent-rewrites/128.json @@ -0,0 +1,7 @@ +{ + "index": 128, + "slug": "editorial-2024-06-mechanism-container-orchestration", + "title": "Контейнерная нагрузка без догадок: как связать requests, readiness и HPA", + "excerpt": "После обновления образа Pod может стать Unready, а HPA — не изменить число реплик. Разбираем, какой механизм отвечает за placement, traffic и масштабирование, как проверить гипотезу и когда изменение манифеста действительно готово.", + "contentHtml": "

После обновления образа часть Pod долго остаётся на старой версии. Другие Pod переходят в Ready, но сразу теряют готовность. В ответ команда увеличивает maxReplicas, поднимает CPU limit и запускает rollout ещё раз. Симптомы меняются, а причина остаётся. Цена ошибки — лишние реплики, неуправляемая нагрузка на Node и более длинный путь отката. В худшем случае Service получает Pod, который ещё не готов обслуживать запросы.

\n

Тезис простой: Kubernetes не управляет контейнерной нагрузкой одной ручкой. Scheduler учитывает requests. Runtime ограничивает container по limits. Readiness решает, можно ли отправлять Pod трафик. HPA предлагает число реплик по своей метрике. Эти контуры связаны, но не заменяют друг друга. Пока у каждого значения нет явного смысла, процент CPU и статус Ready легко принять за доказательство capacity.

\n

Симптом → причина → проверка → действие

\n
Четыре частых подмены в разборе контейнерной нагрузки
СимптомПричинаПроверкаДействие
HPA показывает процент, но реплики не растутУ container нет CPU request или метрика не имеет нужного знаменателяПроверить request каждого container, тип metric и источник metrics APIСначала исправить контракт метрики; не увеличивать replicas вслепую
Pod Unready после стартаReadiness проверяет недоступную зависимость, либо probe срабатывает раньше прогреваСопоставить probe, startup path, condition и Service endpointsИзменить семантику readiness или порядок старта, затем повторить проверку
Pod Pending после изменения ресурсовСумма requests не помещается на доступные NodeСравнить requests с allocatable, quota и правилами admissionПересмотреть профиль workload или размещение
CPU limit увеличен, но задержка не исчезлаПричина находится в памяти, очереди или внешней зависимостиРазделить CPU, memory, queue, latency и dependency signalsПроверять один доминирующий ресурс, а не менять все поля сразу
\n

Как работают четыре контура

\n

Requests отвечают за размещение. Scheduler использует CPU и memory requests при выборе Node. Для Pod учитывается сумма requests контейнеров. Request не обещает постоянное потребление и не задаёт верхнюю границу. Это объявленная потребность, по которой система решает, может ли Pod быть размещён.

\n

Limits задают границу ресурса. CPU и memory limit принадлежат container. Они не являются целью HPA. Memory limit не описывает безопасный размер cache, а CPU limit не обещает throughput. При изменении limit нужно знать, какое поведение ожидается после достижения границы: ограничение CPU, ошибка выделения памяти или другой runtime effect. Без этого число в YAML не объясняет проблему.

\n

Readiness управляет допуском к трафику. Когда readiness probe возвращает failure, Kubernetes не считает Pod готовым backend для Service. Это полезный сигнал маршрутизации. Он не говорит, почему приложение не готово, насколько высока latency и хватит ли ему CPU при пике. Probe должна проверять короткий факт, которым владеет приложение или его платформа. Проверка десятка внешних зависимостей превращает краткий сбой одной зависимости в удаление Pod из трафика.

\n

HPA предлагает desired replicas. Для CPU utilization процент рассчитывается относительно CPU request целевых Pod. Поэтому target в 70 процентов — не 70 процентов Node и не 70 процентов limit. При request 500m такой target имеет другой смысл, чем при request 1000m. Изменение request одновременно влияет на placement и на интерпретацию HPA. Это одна причина, чтобы менять оба решения в одной проверяемой гипотезе.

\n
\"Учебная
Кривая показывает отношения между величинами, а не состояние конкретного кластера. Target HPA имеет знаменателем request; limit — отдельная граница container.
\n

Конкретный пример

\n

Пусть приложение обслуживает HTTP-запросы. Для одного container объявлены cpu request: 500m и cpu limit: 1000m. HPA использует targetAverageUtilization: 70. В учебном примере значение 70 процентов относится к 500m request. Условная точка сравнения равна 350m usage на Pod. Это арифметика для объяснения знаменателя, а не наблюдение из кластера и не рекомендация для реального сервиса.

\n
apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: orders\nspec:\n  replicas: 2\n  template:\n    spec:\n      containers:\n        - name: app\n          image: registry.example/orders:sha256-example\n          resources:\n            requests:\n              cpu: 500m\n              memory: 384Mi\n            limits:\n              cpu: 1000m\n              memory: 768Mi\n          readinessProbe:\n            httpGet:\n              path: /ready\n              port: 8080\n            periodSeconds: 5\n---\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: orders\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: orders\n  minReplicas: 2\n  maxReplicas: 8\n  metrics:\n    - type: Resource\n      resource:\n        name: cpu\n        target:\n          type: Utilization\n          averageUtilization: 70
\n

Этот фрагмент показывает форму контракта. Он не доказывает, что 500m достаточно, что endpoint отвечает быстро или что HPA получит метрики. В реальной системе нужно отдельно проверить effective manifest после admission, состояние metrics API, readiness transitions и фактический workload. Если CPU request убрать, процентный target теряет ожидаемый знаменатель. Если readiness отвечает 200 до завершения прогрева, Service направит трафик слишком рано. Если readiness зависит от внешней базы, краткий сбой базы может убрать все backend.

\n

Отрицательный путь: почему масштабирование не лечит готовность

\n

Рассмотрим запуск новой версии. Приложение мигрирует локальный cache 20 секунд, но readiness endpoint начинает отвечать успехом через две секунды. HPA видит CPU прогрева и предлагает больше реплик. Новые Pod повторяют тот же тяжёлый startup. Число реплик растёт, а полезная ёмкость не появляется. Это не доказательство, что HPA сломан. Сначала нужно отделить startup work от serving work.

\n

Обратная ошибка тоже опасна. Readiness проверяет внешний сервис, который не нужен каждому запросу. При коротком отказе зависимости все Pod становятся Unready, хотя основная функция могла бы продолжать работу. Увеличение replicas не помогает: новые Pod проходят ту же проверку и исключаются из Service. Действие находится в контракте readiness и failure policy, а не в capacity curve.

\n

Упорядоченный маршрут проверки

\n
  1. Ограничьте workload. Назовите Deployment, owner, serving path, startup path, единицу работы и период наблюдения. Не смешивайте два сервиса в одну гипотезу.
  2. Зафиксируйте декларацию. Выпишите requests и limits каждого container, probe, startup settings, HPA metric, minReplicas и maxReplicas. Отделите написанное в manifest от effective values после admission.
  3. Назовите смысл Ready. Запишите короткое условие, после которого Pod действительно может принимать Service traffic. Отдельно запишите, что readiness не доказывает: например, throughput, latency или здоровье всех зависимостей.
  4. Проверьте знаменатель метрики. Для CPU utilization свяжите target с CPU request. Для raw, custom и external metrics укажите target type, selector и источник. При missing data остановите вывод, а не подставляйте число.
  5. Соберите одно evidence. Выберите разрешённый condition, metric или controlled test. У evidence должны быть источник, время, workload и ограничение интерпретации. Учебные значения не заменяют этот шаг.
  6. Измените одну гипотезу. Выберите request, limit, probe или HPA policy. Запишите ожидаемый сигнал, stop condition и rollback. Не меняйте четыре контура одним commit.
  7. Повторите ту же проверку. Сравните результат с первоначальной гипотезой. Если изменился тип evidence или workload, результат нельзя считать подтверждением.
\n

Ограничения

\n

Эта модель не выбирает универсальные значения CPU и memory. Она не учитывает автоматически admission webhooks, ResourceQuota, PodDisruptionBudget, Node allocatable, runtime, Metrics Server, custom adapter, queueing, cache retention и downstream saturation. Официальная документация описывает общий механизм Kubernetes, но не сообщает конфигурацию конкретного кластера. Нельзя переносить учебную арифметику в production profile без измерения.

\n

Readiness не заменяет liveness и startup probes. HPA не устраняет утечку памяти и не гарантирует доступность внешней зависимости. Limit не превращается в SLO. Если причина не разделяется одним evidence, правильное действие — остановить изменение и уточнить контракт. Это отрицательный результат, но он дешевле массового rollout без объяснимого эффекта.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда для одного workload выполнены все условия: владелец назван; effective requests и limits зафиксированы по container; смысл readiness записан одной фразой; HPA metric имеет объявленный знаменатель и источник; выбранное evidence получено в указанном периоде; ожидаемый эффект измерим; stop condition и rollback проверяемы. Если после изменения команда всё ещё говорит только «Pod стал лучше» или «процент выглядит нормально», контракт не закрыт.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/129.json b/editorial/agent-rewrites/129.json new file mode 100644 index 0000000..243120e --- /dev/null +++ b/editorial/agent-rewrites/129.json @@ -0,0 +1,7 @@ +{ + "index": 129, + "slug": "editorial-2024-06-practice-container-orchestration", + "title": "Контейнерная нагрузка: как связать ресурсы, готовность и автомасштабирование", + "excerpt": "Почему Pod может успешно разместиться, но не выдержать трафик: разбираем requests и limits, readiness и HPA на одном учебном примере.", + "contentHtml": "

После выкладки Pod получает статус Running, но запросы к сервису ждут дольше обычного. Иногда HPA увеличивает число реплик, а доступных backend не становится больше: новые Pod остаются неготовыми. В другой версии проблемы readiness отвечает успешно ещё до прогрева, и Service отправляет трафик в приложение, которое не держит рабочую нагрузку. Цена ошибки — задержки для клиентов, лишние реплики и трудный откат. Команда видит зелёный rollout и ищет причину уже под нагрузкой.

\n

Тезис простой: requests, limits, readiness и HPA описывают разные границы. Их нельзя настраивать как четыре независимые строки в манифесте. Сначала нужно назвать профиль приложения и единицу работы. Затем связать каждую границу с проверяемым сигналом. Тогда Kubernetes размещает Pod по одному правилу, допускает его к трафику по другому, а autoscaler меняет replicas по третьему. Это не даёт готовых чисел для любого сервиса. Зато не позволяет принять один сигнал за другой.

\n

Симптом → причина → проверка → действие

\n
Как отделить похожие симптомы
СимптомПричинаПроверкаДействие
Pod долго остаётся PendingRequest не помещается на доступный Node или не учтены requests всех containers.Сверить request каждого container с событиями scheduler и allocatable Node.Исправить размер или размещение только после проверки профиля.
Pod Running, но сервис медленныйRunning означает процесс, а не готовность и не capacity.Сравнить Ready, readiness probe, latency и очередь за один период.Разделить контракт готовности и гипотезу о ресурсе.
HPA меняет replicas без эффектаНовые Pod не готовы, либо CPU target считается от неверного request.Проверить effective request, metric source, Ready и время прогрева.Не повышать maxReplicas, пока не исправлен сигнал.
Container получает OOMKilledMemory limit ограничивает container, но не описывает жизненный цикл cache или объектов.Сопоставить предел, рост working set и действие приложения при нехватке памяти.Изменять limit вместе с политикой роста и восстановления.
\n

Как устроена связка

\n

Request — заявка на ресурс для размещения. Scheduler использует её, когда выбирает Node. Для Pod ресурсная заявка складывается из заявок его containers. Request не равен фактическому потреблению. Container может использовать больше request, если на Node есть свободный ресурс и limit это позволяет. Поэтому фраза «у Pod есть 500m CPU» неполна: нужно сказать, это request, limit или наблюдаемое usage.

\n

Limit — верхняя граница исполнения для container. Он не обещает пропускную способность и не является знаменателем CPU utilization HPA. Для CPU превышение limit ограничивает доступ к CPU. Для memory превышение может закончиться убийством container. Применение зависит от ресурса и среды, поэтому нельзя переносить правило для CPU на memory. Если limit задан без request, конкретная admission-политика может использовать limit как request. Effective значения нужно увидеть в разрешённой проверке, а не угадывать по шаблону.

\n

Readiness отвечает на узкий вопрос: можно ли сейчас отправлять трафик в этот container. При failed readiness Kubernetes убирает Pod из EndpointSlice соответствующего Service. Probe не измеряет запас capacity, throughput и качество каждого ответа. Не стоит включать в неё все внешние зависимости без явной политики отказа: краткий сбой одной зависимости способен вывести из трафика все реплики. Обратная ошибка не менее опасна: слишком ранний success пускает запросы до окончания прогрева.

\n

HPA периодически меняет desired replicas по наблюдаемой метрике. Для CPU utilization в процентах важен request, к которому относится usage. Target 70 процентов — это не 70 процентов Node и не 70 процентов limit. Если request отсутствует там, где он нужен для расчёта, вывод по такой метрике нельзя считать осмысленным. При этом новый Pod ещё должен пройти startup и readiness. Автомасштабирование не может исправить неверный healthcheck и не сокращает время загрузки большого cache.

\n

Учебный манифест

\n

Ниже — ограниченный пример для HTTP-сервиса с устойчивой CPU-нагрузкой после прогрева. Значения не описывают реальный production workload. Они нужны, чтобы увидеть отношения между полями. Здесь request равен 500m, limit — 1000m, а target HPA относится к request. Startup probe отделяет запуск от liveness и readiness. Readiness проверяет локальный признак готовности приложения, а не весь внешний мир.

\n
apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: app\nspec:\n  replicas: 2\n  selector:\n    matchLabels:\n      app: app\n  template:\n    metadata:\n      labels:\n        app: app\n    spec:\n      containers:\n        - name: app\n          image: registry.example/app:1.4.0\n          resources:\n            requests:\n              cpu: \"500m\"\n              memory: \"384Mi\"\n            limits:\n              cpu: \"1000m\"\n              memory: \"768Mi\"\n          startupProbe:\n            httpGet: { path: /startup, port: 8080 }\n            failureThreshold: 30\n            periodSeconds: 2\n          readinessProbe:\n            httpGet: { path: /ready, port: 8080 }\n            periodSeconds: 5\n          livenessProbe:\n            httpGet: { path: /live, port: 8080 }\n            periodSeconds: 10\n---\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nmetadata:\n  name: app\nspec:\n  scaleTargetRef:\n    apiVersion: apps/v1\n    kind: Deployment\n    name: app\n  minReplicas: 2\n  maxReplicas: 6\n  metrics:\n    - type: Resource\n      resource:\n        name: cpu\n        target:\n          type: Utilization\n          averageUtilization: 70
\n

Этот манифест не доказывает, что 500m достаточно. Он задаёт гипотезу: после прогрева одна реплика выдерживает согласованную steady-нагрузку, а CPU — её главный ограничитель. Если в реальности первым растёт memory или очередь, HPA по CPU не решает проблему. Если /ready отвечает до открытия рабочих пулов, реплики формально Ready, но фактически бесполезны. Отрицательный путь важен: когда гипотеза не подтверждается, нужно остановить изменение чисел и пересмотреть профиль, а не автоматически добавлять replicas.

\n

Иллюстрация границ Pod

\n
\"Связь
Схема отделяет состояния Pending, Running, Ready и Unready от границ ресурсов. Она иллюстрирует порядок рассуждения и не показывает данные живого кластера.
\n

Схему полезно читать слева направо. Профиль задаёт вопрос к manifest: что является единицей работы и какой ресурс ограничивает её первым. Request влияет на размещение. Limit ограничивает исполнение. Только затем readiness отвечает на вопрос о допуске к Service traffic. HPA использует свою метрику и свой знаменатель. Перепрыгнуть через профиль нельзя: иначе одинаковый target будет означать разные вещи для CPU-bound HTTP, memory-retaining cache и batch-задачи.

\n

Проверка на конкретном workload

\n
  1. Назовите workload. Запишите owner, режим serving или batch, обычный startup, рабочую единицу и dominant resource. Фраза «сервис нагружен» для этого слишком расплывчата.
  2. Разберите каждый container. Выпишите CPU и memory request и limit отдельно. Укажите, складываются ли значения нескольких containers. Разрешённым способом проверьте admission defaults и effective manifest.
  3. Зафиксируйте readiness contract. Одним предложением опишите, что значит «можно принять запрос». Отдельно назовите случаи, когда Pod должен стать Unready, и случаи, которые не должны выводить его из трафика.
  4. Проверьте startup и liveness. Убедитесь, что долгая инициализация не выглядит как зависший процесс. Liveness должна обнаруживать неисправимое состояние, а не временную очередь или медленную внешнюю зависимость.
  5. Опишите метрику HPA. Запишите тип метрики, источник, request-знаменатель для utilization, minReplicas, maxReplicas и поведение при missing или not-yet-ready Pod. Не подменяйте эту проверку значением из учебного примера.
  6. Соберите ограниченное evidence. Выберите среду, владельца, временное окно и разрешённые источники. Сопоставьте Ready, usage, restart, latency и queue с одной гипотезой. Не объединяйте их в безымянное «состояние Pod».
  7. Измените одну границу. Задайте stop condition и rollback для request, limit, probe или HPA. После изменения повторите ту же проверку. Если сигнал не изменился, вернитесь к причине, а не к следующему коэффициенту.
\n

Ограничения и отрицательный путь

\n

Эта схема не назначает ресурсы настоящему сервису и не подтверждает состояние кластера. Она не читает Metrics API, события Node, EndpointSlice, controller flags, feature gates, логи или trace. Официальные документы описывают механизм Kubernetes, но не знают версию, admission-политику и настройки конкретной среды. Поэтому учебные 500m, 384Mi, 70 процентов и два Pod нельзя выдавать за результат измерения.

\n

Есть и граница применимости. HPA по CPU подходит не каждому workload. Для memory-heavy приложения нужно отдельно описать рост рабочего набора и способ его ограничить. Для batch часто важнее размер очереди и время обработки. Для сервиса с дорогим warmup нужно учитывать startup и скорость появления Ready backend. Если исходный сигнал не объясняет цену ошибки, корректное действие — не менять manifest. Сначала нужно получить недостающее разрешённое наблюдение или признать задачу неготовой к настройке.

\n

Проверяемый критерий готовности

\n

Конфигурация готова к обсуждению, когда для одного workload существует короткая карточка: request и limit каждого container, смысл readiness, условие startup, назначение liveness, metric HPA и её знаменатель, источник evidence, stop condition и rollback. Проверка должна связать каждое поле с одним наблюдаемым вопросом. После учебного прогона готовность не означает «Pod зелёный». Она означает, что команда может объяснить, какой сигнал изменился, почему это подтверждает или опровергает гипотезу и что произойдёт при отрицательном результате.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/130.json b/editorial/agent-rewrites/130.json new file mode 100644 index 0000000..ea88bb3 --- /dev/null +++ b/editorial/agent-rewrites/130.json @@ -0,0 +1,7 @@ +{ + "index": 130, + "slug": "editorial-2024-05-field-platform-templates", + "title": "Платформенный шаблон без ловушки fork: как провести границу решения", + "excerpt": "Шаблон ускоряет повторяемый путь, но не заменяет архитектурное решение. Разбираем симптомы слишком широкой формы, проверяем golden path, узкое расширение и корректный отказ.", + "contentHtml": "

Новая команда просит создать сервис, а платформа предлагает одну форму: имя, владелец, runtime, репозиторий и несколько флагов. Сначала это выглядит удобно. Через месяц появляются локальные правки, особые healthcheck, другой retention и ручные исключения в CI. Два сервиса уже не похожи на исходный шаблон, но команда всё ещё считает их его вариантами. Цена ошибки — не только лишняя работа. Теряется владелец контракта, обновления перестают доходить до копий, а рискованный выбор прячется за кнопкой Create.

\n

Тезис простой: шаблон должен принимать только повторяемый класс задач. Совпадение с базовым контрактом ведёт в golden path. Одно заранее описанное отличие ведёт на review расширения. Несовместимый или одноразовый запрос нужно остановить. Отказ дешевле fork-а, который создаёт ложное ощущение поддержки.

\n

Симптомы широкой формы

\n

Первый симптом — просьба добавить универсальное поле: «если понадобится, разрешим внешний data class», «пусть runtime выбирается позже», «добавим произвольный adapter». Поле кажется небольшим, но меняет инвариант. После него шаблон уже не описывает один тип сервиса. Он принимает несколько архитектурных решений без владельца.

\n

Второй симптом — локальный patch сразу после создания репозитория. Команда удаляет обязательный шаг, переписывает pipeline или меняет доступы, а потом обещает вернуть полезное изменение в общий шаблон. Если различие не имеет имени, владельца, границы и условия удаления, это не extension. Это отдельный проект, который маскируется под стандартный путь.

\n

Третий симптом — платформа выдаёт skeleton для задачи, у которой ещё нет data policy, access model или ответственного. Файлы создаются быстро, но структура начинает диктовать решение. Команда подгоняет требования под уже созданный репозиторий. Технический артефакт появляется раньше архитектурного договора.

\n

Механизм: три слоя вместо одной кнопки

\n

Разделите решение на три слоя. Первый — входные факты: тип компонента, владелец, runtime, класс данных, требования к доставке и срок жизни. Второй — контракт: допустимые значения и обязательные шаги. Третий — результат: применить базовый путь, отправить ограниченное расширение на review или отказать до уточнения требований.

\n

Backstage описывает шаблон как набор параметров и последовательных шагов. Это полезный механизм, но он не делает любой параметр безопасным. Параметр собирает значение. Решение о том, разрешено ли значение, должно жить в контракте и проверке. GitHub template repository копирует структуру и файлы в новый репозиторий, но создаёт несвязанную историю. Автоматического канала изменений исходного шаблона это не даёт.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Просят «универсальный» флагПоле меняет runtime, ownership, data class, access или retentionСравнить поле с базовым инвариантом и назвать владельцаУбрать поле из golden path; оформить отдельный путь
После создания нужен patchРазличие не описано как versioned extensionПроверить identifier, owner, boundary и rollbackОстановить копирование; вынести различие на review
Шаблон приняли для миграцииНет повторяемого типа и жизненного циклаПроверить повторяемость того же контрактаОтказать шаблону и провести отдельное решение
Repository считают fork-омСмешаны копирование и наследование измененийПроверить историю и канал обновленийЗафиксировать самостоятельное владение или другой механизм
\n
\"Цикл
Сначала запрос сверяется с контрактом, затем выбирается путь. Схема не обозначает измеренный adoption или production-результат.
\n

Пример контракта

\n

Ниже — учебный фрагмент. Он не запускает реальную задачу и не доказывает пригодность набора полей для вашей организации. Базовый путь принимает внутренний HTTP-сервис с известным владельцем и утверждённым runtime. Observability adapter разрешён как именованное расширение. Внешние регулируемые данные форму не проходят.

\n
apiVersion: scaffolder.backstage.io/v1beta3\\nkind: Template\\nmetadata:\\n  name: internal-http-service\\nspec:\\n  owner: group:platform\\n  type: service\\n  parameters:\\n    - title: Service contract\\n      required: [name, owner, runtime, dataClass]\\n      properties:\\n        runtime:\\n          type: string\\n          enum: [node20, go122]\\n        dataClass:\\n          type: string\\n          enum: [internal]\\n        extension:\\n          type: string\\n          enum: [none, observability-adapter]\\n  steps:\\n    - id: write-skeleton\\n      action: fetch:template\\n    - id: publish\\n      action: publish:github
\n

В примере enum ограничивает форму, но не заменяет проверку прав и политики. Owner должен ссылаться на существующую группу, а публикация требует разрешений и проверки credentials. В рабочем шаблоне эти условия подтверждаются средствами вашей платформы. YAML не доказывает успешный запуск, безопасность или пригодность runtime.

\n

Первая заявка содержит известный service type, владельца, approved runtime и internal data. Она совпадает с контрактом: golden path. Вторая содержит те же факты и один adapter из закрытого списка. Если у расширения есть owner, граница и способ удаления, это review extension. Третья описывает одноразовую регулируемую миграцию, но не содержит владельца, retention и access model. Ей нужен отказ от шаблона и отдельное решение.

\n

Почему fork не исправляет несовпадение

\n

Fork отвечает на вопрос «как начать самостоятельный проект на основе текущих файлов». Он не отвечает на вопрос «как поддерживать общий контракт между проектами». Repository, созданный из template, получает несвязанную историю. Pull request между копией и шаблоном не становится штатным каналом синхронизации.

\n

Fork допустим, когда команда принимает независимый жизненный цикл. Тогда нужно записать владельца, область ответственности и способ получать будущие изменения. Если ожидается обновление всех созданных проектов из базового шаблона, нужен другой механизм доставки или честная граница поддержки.

\n

Не расширяйте базовый шаблон ради редкого запроса. Новое поле увеличивает число состояний для всех пользователей. Особенно опасны поля, которые откладывают решение: «позже выберем runtime», «потом определим доступ», «retention настроит команда». Отсутствующее решение нельзя превратить в безопасный default названием параметра.

\n

Порядок действий

\n
  1. Запишите заявку: component type, owner, runtime, data class, access, retention и срок жизни. Не начинайте с копирования файлов.
  2. Сверьте каждый факт с контрактом. Пометьте значения, для которых нет допустимого варианта или владельца.
  3. Выберите результат. Полное совпадение даёт golden path. Одно названное отличие с границей и rollback даёт review extension. Остальные запросы получают decline.
  4. Проверьте отрицательный путь: неизвестный runtime, внешний data class, отсутствие owner и неподдерживаемое расширение должны остановиться до создания артефакта.
  5. Для расширения зафиксируйте identifier, owner, boundary, version и условие удаления. Если поле нельзя заполнить, расширение не готово.
  6. После review запускайте реальную публикацию. Проверьте credentials, права, pipeline, healthcheck, наблюдаемость и rollback в вашей среде.
  7. Пересмотрите расширение через согласованный срок. Повторяемый класс можно включить в новую версию контракта. Одноразовый случай не превращайте в обязательную опцию.
\n

Ограничения

\n

Шаблон не выбирает владельца, не определяет классификацию данных и не делает action безопасным. Список runtime устаревает. Документация объясняет форму и порядок шагов, но не знает ваших сетевых прав, требований регулятора и правил отката. Эти условия проходят отдельную проверку.

\n

Учебный YAML не является production-конфигурацией. В нём нет конкретной схемы прав, политики секретов, branch protection, SLO и обязательных проверок поставки. Не переносите его в рабочую систему без адаптации и review. Цифры adoption, скорости и снижения дефектов здесь не заявлены.

\n

Проверяемый критерий готовности

\n

Решение готово, когда другая команда берёт ту же заявку и получает тот же вердикт по тем же фактам. Для golden path форма принимает только значения контракта. Для extension документ содержит owner, boundary, version и rollback. Для отказа есть причина и следующий вопрос вне шаблона. Неизвестное или рискованное значение останавливается до создания репозитория и не превращается в локальный patch.

\n

Проверка состоит из четырёх записей: базовая заявка, узкое расширение, несовместимая заявка и повторный запуск первой. Готовность есть, если базовые записи дают одинаковый результат, расширение не меняет инварианты, отказ не создаёт артефакт, а повторный запуск не дублирует обязательные действия.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/131.json b/editorial/agent-rewrites/131.json new file mode 100644 index 0000000..1896c1c --- /dev/null +++ b/editorial/agent-rewrites/131.json @@ -0,0 +1 @@ +{"index":131,"slug":"editorial-2024-05-mechanism-platform-templates","title":"Платформенный шаблон как контракт: базовый путь, расширение и отказ","excerpt":"Шаблон ускоряет повторяемый старт, если ограничивает вход, называет владельца и умеет остановиться. Разбираем контракт параметров, extension point, отрицательный путь и границы repository templates.","contentHtml":"

Новая команда просит создать сервис. Форма платформы принимает имя, владельца, runtime и несколько флагов. Через месяц в созданном репозитории появляются ручные исключения в CI, другой healthcheck и особая политика хранения. Команда называет это вариантом шаблона, хотя базовый путь уже не описывает результат. Симптом виден в первом локальном patch после генерации.

Цена ошибки складывается из трёх частей. Reviewer восстанавливает исходное решение по разрозненным изменениям. Platform team поддерживает комбинации, для которых никто не назначил владельца. Команда-потребитель ждёт обновлений от шаблона, но получает самостоятельную копию. Чем больше полей добавляет форма, тем дороже становится неизвестность.

Тезис статьи простой: платформенный шаблон должен принимать закрытый класс повторяемых задач. Совпадение с базовым контрактом ведёт в golden path. Одно заранее названное отличие ведёт на review расширения. Изменение инварианта или неизвестная политика должны остановить шаблон. Право сказать нет — часть механизма, а не неудобная ошибка интерфейса.

Механизм: вход, решение, результат

Разделите шаблон на три слоя. Первый слой принимает факты: тип компонента, owner, runtime, класс данных и требования к доставке. Второй слой проверяет контракт: допустимы ли значения, совпадают ли они с версией base path, есть ли у отличия имя и владелец. Третий слой выдаёт один из трёх результатов: golden path, review extension или decline.

Такой порядок не делает архитектуру автоматической. Он не выбирает policy за доменную команду. Он не доказывает, что generated repository можно отправить в production. Он лишь не даёт форме превратить незакрытый вопрос в якобы безопасный default.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Просят универсальный флагПоле меняет runtime, owner, data class, доступ или retentionСравнить поле с базовым инвариантом и назвать owner решенияУбрать поле из golden path; вынести запрос на design path
Сразу после создания нужен ручной patchРазличие не оформлено как versioned extensionПроверить identifier, owner, boundary и rollbackОстановить расширение; отправить его на review
Шаблон используют для одноразовой миграцииНет повторяемого типа компонента и понятного lifecycleПроверить, повторится ли тот же контракт для другой заявкиОтказать шаблону и провести отдельное решение
Копию считают fork-омСмешаны копирование файлов и наследование измененийПроверить историю и реальный канал обновленийЗафиксировать самостоятельное владение или выбрать другой механизм
\"Развилка
Схема выбора: вход сначала сравнивается с контрактом. Иллюстрация объясняет логику маршрута и не показывает метрики adoption, состояние CI или production-результат.

Закрытый контракт параметров

У формы должен быть конечный список допустимых значений. В учебном примере базовый путь создаёт только внутренний HTTP-сервис. Для него известны owner, runtime и класс данных. Расширение добавляет observability adapter, но не меняет runtime, owner или data class. Любое лишнее поле шаблон отклоняет до создания репозитория.

# Учебный пример, не production-конфигурация: apiVersion: scaffolder.backstage.io/v1beta3; kind: Template; metadata.name: internal-http-service; spec.parameters.required: [name, owner, runtime, dataClass]; spec.parameters.properties.runtime.enum: [node20, go122]; spec.parameters.properties.dataClass.enum: [internal]; spec.parameters.properties.extension.enum: [none, observability-adapter]; spec.steps: [fetch:template, publish:github]

Фрагмент только показывает границу входа. Enum ограничивает форму, но не заменяет проверку. Значение owner должно ссылаться на существующую группу. Action публикации требует прав и credentials. Нужны отдельные проверки secrets, branch protection, pipeline, healthcheck и rollback. Учебный пример не подтверждает пригодность runtime и не создаёт policy организации.

Главное правило — не передавать через форму то, что меняет смысл базового сервиса. Поле runtime с двумя разрешёнными значениями может быть частью закрытого контракта. Поле customRuntimeConfig открывает неограниченное пространство решений. Поле retentionPolicy нельзя считать безобидным расширением, если оно меняет требования к данным и доступу.

Base path и extension point

Base path фиксирует повторяемую часть: компонент известного типа, назначенного владельца, одобренный runtime и заранее определённый класс данных. Он может создать skeleton, metadata и список обязательных проверок. Он не должен принимать вопрос «какой runtime выбрать позже». Отложенное решение не становится безопасным оттого, что его записали в параметр.

Extension point нужен для узкого отличия, которое не ломает базовые инварианты. У расширения должны быть identifier, owner, версия, граница изменений и условие удаления. Observability adapter подходит как учебный пример, если он добавляет один известный слой наблюдаемости. Новый класс данных, внешний доступ или другая модель retention уже меняют тип решения. Их нельзя спрятать за словом extension.

Проверяйте расширение до генерации. Сначала найдите canonical record для типа заявки. Затем сравните с ним все входы. Если запись не найдена, результатом должен быть decline. Если совпал base contract и добавлено одно разрешённое отличие, создайте review record. Только после review запускайте action, который публикует файлы.

Почему repository template не равен fork

GitHub repository template создаёт новый репозиторий с той же структурой и файлами. GitHub отдельно указывает, что ветви такого репозитория имеют несвязанные истории. Это полезный старт, но не канал автоматической синхронизации с шаблоном. Команда получает начальный набор файлов и дальше принимает собственный жизненный цикл, если другой договор не определён отдельно.

Fork решает другой вопрос. Он сохраняет историю родительского репозитория и подходит для совместной работы с исходным проектом. Нельзя обещать потребителю updates от template только потому, что интерфейс запуска называется похожим образом. Перед внедрением зафиксируйте, что именно распространяется: копия файлов, pull request-ы, пакет, action или самостоятельная версия. У механизма должен быть владелец доставки изменений.

Backstage Software Templates тоже не заменяют этот договор. Scaffolder принимает параметры, выполняет шаги, подставляет переменные и может публиковать результат в GitHub или GitLab. Эти возможности описывают способ создания компонента. Они не доказывают, что любой action разрешён, что вход соответствует policy или что созданные репозитории будут синхронизироваться. Контракт команды остаётся отдельным слоем.

Порядок действий

  1. Опишите класс задачи. Запишите component type, owner, runtime, data class, access boundary и срок жизни. Если ответ неизвестен, не маскируйте пробел новым флагом.
  2. Зафиксируйте base contract. Назовите обязательные входы, допустимые значения, результат и список non-goals. Версия контракта меняется вместе с этим договором.
  3. Закройте схему. Отклоняйте неизвестные keys и неполные комбинации. Не принимайте raw policy, credential, произвольный path или неподтверждённую метрику как вход.
  4. Разведите результаты. Полное совпадение даёт golden path. Одно разрешённое отличие даёт review extension. Несовместимое или неизвестное значение даёт decline.
  5. Проверьте отрицательный путь. Подайте неизвестный runtime, внешний data class, пустой owner и неподдерживаемое extension. Каждый случай должен остановиться до создания репозитория.
  6. Проведите review расширения. Проверьте owner, boundary, права, version, rollback и условие удаления. Если хотя бы одного поля нет, верните запрос на design path.
  7. Запустите публикацию только после проверки. В рабочей среде отдельно подтвердите credentials, результат pipeline, healthcheck, наблюдаемость и откат. Учебный пример выше этого не делает.
  8. Пересмотрите решение. Если расширение повторяется и сохраняет инварианты, включите его в новую версию контракта. Одноразовый случай не превращайте в обязательную опцию.

Ограничения и отрицательный путь

Шаблон не выбирает owner за команду, не проводит threat modeling, legal review или capacity planning. Он не устанавливает SLO и не определяет правила хранения данных. Backstage и GitHub документируют свои механизмы, но не знают сетевые права и требования конкретной организации. Эти проверки нельзя заменить несколькими полями формы.

Отказ должен возвращать причину и следующий вопрос. Например: «decline-template: нет стабильного component type; назначьте owner и определите data class». Артефакт не создаётся. Команда получает design record и может вернуться к шаблону после решения. Такой отказ дешевле fork-а, который выглядит стандартным только до первой ручной переделки.

Rollback должен быть коротким. Для не прошедшего review удаляется draft contract или extension proposal, а не чужой repository. Для ошибочной версии возвращаются к предыдущему base contract. Если шаблон уже публикует внешние ресурсы, отдельно документируйте компенсационные действия; форма сама по себе не делает их обратимыми.

Проверяемый критерий готовности

Механизм готов, если другая команда получает тот же вердикт по тем же фактам. Допустимая заявка проходит базовый путь. Узкое расширение содержит identifier, owner, boundary, version и rollback. Несовместимая заявка останавливается до публикации и возвращает понятную причину. Повторный запуск не создаёт дублирующие обязательные действия.

Проверка состоит из четырёх записей: базовая заявка, разрешённое расширение, несовместимая заявка и повтор базовой заявки. Сравните решения, созданные артефакты и отрицательные ветки. Готовность есть, если инварианты base path не меняются, extension нельзя активировать без review, decline не создаёт repository, а повторный запуск имеет явно определённое поведение. Это проверяемый контракт, а не обещание универсальной платформы.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/132.json b/editorial/agent-rewrites/132.json new file mode 100644 index 0000000..33bc7e7 --- /dev/null +++ b/editorial/agent-rewrites/132.json @@ -0,0 +1,7 @@ +{ + "index": 132, + "slug": "editorial-2024-05-practice-platform-templates", + "title": "Платформенный шаблон без ловушки универсальности: контракт, расширение и отказ", + "excerpt": "Шаблон экономит время только там, где повторяется один и тот же класс решений. Разбираем границы golden path, named extension, отрицательный путь и проверяемый критерий готовности.", + "contentHtml": "

Новая команда открывает заявку на сервис и получает знакомый ответ: возьмите соседний репозиторий, замените имя, а остальное поправьте по месту. Через неделю два сервиса уже расходятся по CI, healthcheck и владельцам конфигурации. Ошибка проявляется не при создании, а на первом изменении общего правила. Review приходится сравнивать с несколькими копиями, исправление нужно переносить вручную, а rollback зависит от того, какая копия стала «правильной». Цена ошибки — не лишний файл. Команда теряет время на восстановление контракта и получает несколько путей, за которые никто не отвечает.

\n

Тезис простой: платформенный шаблон должен фиксировать только повторяемый класс решений. У класса есть owner, версия, обязательные входы, инварианты и ограниченный выход. Всё, что меняет этот класс, выносят в именованное расширение. Всё неизвестное отправляют на отдельное архитектурное решение. Такой шаблон остаётся коротким golden path и не превращается в форму со скрытой политикой.

\n

Что именно делает шаблон

\n

Шаблон — это не обещание готового сервиса. Он может положить согласованную структуру каталогов, подставить имя, подготовить описание компонента и передать результат в выбранное место. Backstage называет такой механизм Software Templates: он загружает skeleton, подставляет переменные и может опубликовать результат в GitHub или GitLab. Это полезная граница автоматизации. Она отвечает на вопрос «как получить одинаковый старт», но не отвечает на вопросы о доступе к данным, security review, capacity или разрешении на выпуск.

\n

Поэтому сначала описывают договор, а потом файлы. Для внутреннего HTTP-сервиса договор может содержать owner, approved runtime, класс данных, обязательный health endpoint и способ регистрации в каталоге. Он также должен содержать отрицательную часть: шаблон не выдаёт production approval, не создаёт секреты, не выбирает retention и не меняет права. Без этой части любой новый параметр легко станет незаметным исключением.

\n

Симптом → причина → проверка → действие

\n
Диагностика шаблона до его расширения
СимптомПричинаПроверкаДействие
В форме появляется «ещё одна галочка» для особого runtime.В один шаблон поместили два разных класса компонентов.Сравнить owner, runtime, data class и обязательные проверки для обоих вариантов.Оставить один класс. Для второго открыть отдельный design path.
После создания каждый репозиторий правят вручную.Выход шаблона считают договором, хотя он содержит только стартовые файлы.Показать, какие инварианты сохраняются после правки и кто владеет каждым правилом.Вынести повторяемую правку в версионированный шаблон или named extension.
Один generated repository нужно синхронизировать с базой.Создание из template перепутали с fork или наследованием.Проверить историю и способ доставки изменений из исходного репозитория.Не обещать автоматическую синхронизацию. Выбрать обновление вручную или другой механизм.
Неизвестный владелец всё равно проходит форму.Проверку ownership оставили на потом.Сделать owner обязательным входом и остановить запуск при пустом значении.Вернуть заявку на уточнение ответственности.
Шаблон должен выбрать retention или access policy.Архитектурное решение спрятали в параметр интерфейса.Спросить, кто утвердил policy и действует ли она для всего класса.Убрать параметр из base path и провести отдельную проверку.
\n

Таблица нужна не для оценки удобства формы. Она разделяет повторяемое правило и исключение. Если проверка не может назвать owner и одинаковый смысл поля для нескольких задач, поле ещё не готово для base template. Если выход нельзя проверить без устной истории конкретной команды, шаблон выдаёт слишком много обещаний.

\n

Пример короткого контракта

\n

Ниже учебный JavaScript-объект. Он не создаёт репозиторий, не вызывает CI, не меняет кластер и не подтверждает работу сервиса. Его задача — показать форму проверки. В настоящем проекте значения должны ссылаться на реальные справочники и правила доступа, а не на строки из примера.

\n
const contract = {\n  templateId: 'internal-http-service',\n  version: 3,\n  owner: 'platform-team',\n  requires: [\n    'service-name',\n    'component-owner',\n    'approved-runtime',\n    'internal-data-class',\n  ],\n  produces: [\n    'repository-skeleton',\n    'catalog-metadata-draft',\n    'review-checklist',\n  ],\n  refuses: [\n    'unknown-owner',\n    'new-retention-policy',\n    'one-off-data-migration',\n  ],\n};\n\nfunction canUseTemplate(input) {\n  return contract.requires.every((field) => input[field])\n    && !contract.refuses.some((field) => input[field]);\n}\n\n// Учебные данные. true означает только согласованный вход.\nconsole.log(canUseTemplate({\n  'service-name': 'billing-api',\n  'component-owner': 'billing-team',\n  'approved-runtime': 'node-approved',\n  'internal-data-class': 'internal',\n}));
\n

Проверка возвращает true только для входа, который удовлетворяет договору. Она не говорит, что сервис безопасен или готов к выкладке. Если добавить new-retention-policy, функция должна вернуть false. Это важнее положительной ветки: неизвестная политика не должна незаметно превращаться в настройку по умолчанию.

\n

Имена полей также задают границы ответственности. component-owner отвечает за доменный компонент. owner контракта отвечает за сам шаблон. Эти роли могут принадлежать одной группе, но смешивать их в одно не стоит. Иначе пользователь сможет создать компонент без владельца, потому что «платформа же владеет формой».

\n

Узкий golden path

\n

Узкий путь не означает бедный путь. Он может включать структуру документации, labels, базовый health endpoint, регистрацию компонента и обязательные проверки. Ограничение относится к смыслу выбора. Каждое поле должно иметь одинаковое значение для заявленного класса. Поле «выбрать любую базу» не является частью golden path, если разные базы меняют отказоустойчивость, хранение данных и эксплуатацию. Поле «выбрать approved runtime из двух поддерживаемых» может быть допустимым, если правила для обоих вариантов уже определены и owner готов их поддерживать.

\n
\"Схема
Учебная схема отделяет стабильный base contract от policy-вопроса. Она не показывает живую платформу, статистику использования или результат выпуска.
\n

У каждого правила должен быть ответ на вопрос «кто изменит его, если оно устареет?». Ответом может быть команда платформы, владелец каталога или отдельная группа безопасности. Если ответа нет, правило нельзя считать общим. Копирование файла не назначает владельца. Число успешных запусков тоже не доказывает, что договор корректен.

\n

Расширение вместо локальной копии

\n

Иногда компонент остаётся в том же классе, но требует одного дополнительного подключения. Например, approved runtime и internal data class сохраняются, а команде нужен заранее описанный observability adapter. Тогда нужен named extension. У него есть имя, owner, входные условия, граница изменений, rollback и срок пересмотра. Extension добавляет output к base contract, но не заменяет owner, runtime и data policy.

\n

Не называйте расширением любое изменение после создания. Локальный patch без имени и владельца не оставляет следа для следующей команды. Он быстро становится новой копией шаблона, а исправление базы не доходит до него. Если одинаковый patch повторился несколько раз, соберите факты: одинаков ли класс, одинаковы ли условия и можно ли описать rollback. Только после этого решайте, стал ли patch частью extension или отдельным шаблоном.

\n

У механики template repository есть ещё одна ловушка. GitHub предупреждает: ветви репозитория, созданного из template, имеют несвязанную историю, поэтому между ними нельзя строить обычные pull request и merge. Это не дефект GitHub. Это свойство операции создания. Значит, шаблон нельзя рекламировать как канал синхронизации с исходным репозиторием. Для долгоживущего общего кода нужен другой механизм, например пакет, зависимость или явно поддерживаемый upstream-процесс.

\n

Порядок принятия решения

\n
  1. Назовите класс. Одним предложением опишите компонент, owner, runtime и класс данных. Если описание требует слова «обычно» или «иногда», граница ещё не определена.
  2. Запишите инварианты. Перечислите то, что должно остаться истинным после применения шаблона: обязательные метаданные, тип компонента, проверки и границы доступа.
  3. Отделите выход от разрешения. Укажите, что шаблон создаёт skeleton или draft, но не выдаёт approval, секреты и право на изменение среды.
  4. Проверьте расширение. Для единственного известного отклонения назовите extension, owner, условия входа и rollback. Если отклонений несколько и они меняют класс, не прячьте их в одной форме.
  5. Проверьте отрицательную ветку. Передайте неизвестного owner, новую retention policy и неподдерживаемый runtime. Каждый вход должен получить ясный stop, а не молча выбранный default.
  6. Выберите механизм доставки. Если результат должен жить независимо, template repository подходит для старта. Если изменения должны приходить из общего источника, используйте механизм с проверяемой связью, а не обещание синхронизации.
  7. Назначьте проверку результата. Укажите, кто сверяет входы, созданные файлы, metadata и права. Положительный ответ открывает следующий review, но не заменяет его.
  8. Зафиксируйте версию. Запишите версию контракта и действие при обновлении. Не меняйте смысл старого входа под тем же номером: добавьте новую версию или отдельный путь.
\n

Когда отказ — правильный результат

\n

Заявка на разовую миграцию регулируемых данных обычно не готова к service template. У неё могут быть неизвестны owner и lifecycle, а access и retention требуют отдельного решения. Если вставить такую задачу в форму, пользователь заполнит поля, но архитектура останется неразрешённой. Шаблон создаст уверенный вид, а вопросы проявятся после получения файлов.

\n

Остановка не означает запрет на работу. Она возвращает заявку на правильную границу: design record, security review или обсуждение владельца данных. После нескольких одинаковых решений может появиться новый класс. Тогда его можно оформить отдельным контрактом с собственным owner и проверками. Не делайте этот вывод по одной удачной заявке.

\n

Ограничения и критерий готовности

\n

Шаблон не заменяет threat modeling, capacity planning, CI, тесты, миграционный план и проверку прав. Он не доказывает, что созданный сервис можно выпускать. Он также не обязан поддерживать каждый исторический репозиторий. Base contract задаёт повторяемый старт, а не универсальную архитектуру компании.

\n

Rollback должен быть определён на уровне изменения. Для draft удаляют draft. Для extension возвращаются к versioned base contract и удаляют подключение по его инструкции. Для уже созданного репозитория отдельно проверяют, что возвращается: файлы, зависимость, конфигурация или данные. Возврат шаблона не откатывает автоматически изменения, которые команда внесла после создания.

\n

Материал готов к применению как учебная схема, когда второй инженер без устных пояснений может показать класс, owner, версию, обязательные входы, инварианты, выход, refusal criterion и rollback. Для каждой ссылки есть источник. Для каждого неизвестного входа есть stop. В примере выше все имена и значения вымышлены; они не описывают измерение, внедрение или результат в production.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/133.json b/editorial/agent-rewrites/133.json new file mode 100644 index 0000000..c95ec19 --- /dev/null +++ b/editorial/agent-rewrites/133.json @@ -0,0 +1,7 @@ +{ + "index": 133, + "slug": "editorial-2024-04-field-data-migrations", + "title": "Миграция данных без ловушки: совместимость, backfill и безопасный contract", + "excerpt": "Новая форма данных не становится безопасной от одного успешного deploy. Разбираем expand/migrate/contract, ограниченный backfill и признаки, по которым нужно остановить удаление старого представления.", + "contentHtml": "

Симптом обычно появляется после deploy: новая версия сервиса читает новое поле, а часть записей всё ещё хранит старую форму. Затем backfill начинает нагружать базу, а старый worker продолжает писать только старое представление. В логах растёт доля fallback-чтений, обработка очереди замедляется, а команда уже обсуждает удаление старой колонки. Цена ошибки — не только откат релиза. Код можно вернуть, но уже записанные данные не обязаны вернуться в прежнюю форму. Пользователь увидит пустое значение, а восстановление потребует отдельного data repair.

\n

Тезис простой: миграция данных — это не одна команда DDL и не один зелёный deploy. Сначала нужно сохранить совместимость версий, затем ограниченно перенести данные, после этого доказать готовность нового чтения и только в конце удалить старую форму. Каждый переход должен иметь собственную проверку. Если хотя бы один потребитель неизвестен, старое представление остаётся.

\n

Механизм: expand, migrate, switch, contract

\n

В старой системе заказ хранится в полях status и amount. Новая версия хочет хранить объект summary. На первом шаге схема получает новую форму, но старый writer не должен ломаться. Новый reader принимает обе формы. Новый writer временно записывает обе. Это expand.

\n

На втором шаге backfill обрабатывает старые записи. Он не должен проходить по таблице без границы. Нужны область работы, размер порции, владелец, идемпотентность и заранее определённый сигнал остановки. Это migrate. Важен не сам факт запуска job, а понятный результат частичного выполнения: какие записи обработаны и что произойдёт после остановки.

\n

Затем система переключает чтение на новую форму. Fallback к старой форме ещё нужен, пока не проверены старые записи, отложенные worker-ы и все читатели. Успешное чтение новой записи не доказывает, что старых потребителей больше нет. Switch опирается на наблюдаемые данные, а не на дату релиза.

\n

Contract — отдельное решение. Старую форму можно удалить только после подтверждения, что старый reader и writer больше не участвуют, backfill завершён с понятным критерием, а восстановление не зависит от удаляемых данных. Если условие не доказано, contract откладывают. Это отрицательный путь, а не неполная миграция.

\n

Учебный пример совместимости

\n

Ниже — ограниченный пример на JavaScript. Он проверяет только заявленные версии и формы. Функция не обращается к базе, не запускает SQL и не измеряет нагрузку. Поэтому результат stop означает «не переходить к следующему этапу в этом сценарии», а не verdict для production.

\n
const contract = {\n  oldReader: true,\n  oldWriter: true,\n  newReaderAcceptsOld: true,\n  newReaderAcceptsNew: true,\n  newWriterWritesBoth: true,\n  backfillHasStop: false,\n  oldConsumersFound: true,\n};\n\nfunction decideMigration(state) {\n  const compatible =\n    state.oldReader &&\n    state.oldWriter &&\n    state.newReaderAcceptsOld &&\n    state.newReaderAcceptsNew &&\n    state.newWriterWritesBoth;\n\n  if (!compatible) {\n    return { phase: 'expand', action: 'stop', reason: 'version mismatch' };\n  }\n\n  if (!state.backfillHasStop) {\n    return { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' };\n  }\n\n  if (state.oldConsumersFound) {\n    return { phase: 'contract', action: 'stop', reason: 'old consumer remains' };\n  }\n\n  return { phase: 'contract', action: 'review', reason: 'evidence required' };\n}\n\nconsole.log(decideMigration(contract));\n// { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' }
\n

Код защищает порядок рассуждения. Он сначала проверяет совместимость, потом наличие stop condition, затем старых потребителей. В production эти признаки получают из реестра версий, логов, метрик, запросов к данным и согласованного runbook. Нельзя заменить их булевыми значениями из фикстуры. Учебный результат ограничен демонстрацией ветвления.

\n

Симптом → причина → проверка → действие

\n
Диагностика перехода между формами данных
СимптомПричинаПроверкаДействие
Новая форма есть только у части записейBackfill ещё не закончен или новые записи обходят dual writeСравнить доли old/new по времени записи и источникуОставить fallback, остановить contract, исправить writer
Backfill замедляет рабочие запросыШирокий scope, слишком большая порция или конкурирующая нагрузкаПроверить latency, lock wait, размер batch и границу выборкиОстановить job, сузить scope и определить лимит до нового запуска
Старая версия получает ошибку записиSchema constraint введён раньше совместимого writerВоспроизвести запись v1 на тестовой копии и проверить порядок deployВернуть совместимое расширение, не маскировать ошибку retry
После deploy растёт fallbackReader видит old data или новый writer не заполнил полеРазделить fallback по версии, endpoint и типу записиСохранить старую ветку и найти источник несовместимых записей
Все тесты зелёные, но consumer неизвестенТест проверяет сценарий, а не весь fleetСверить владельцев, worker-ы, cron, batch и старые clientsНе удалять старую форму до найденного доказательства
Нужен срочный rollback после очисткиУдаление данных ошибочно назвали обратимымПроверить backup, retention и возможность read-back старой формыПерейти к data repair или restore-плану, не обещать обычный rollback
\n

Иллюстрация перехода

\n
\"Переход
Существующая схема показывает контрольные точки миграции. Gate не запускает операцию и не подтверждает production-готовность: он фиксирует вопросы, на которые должны ответить реальные данные и владельцы системы.
\n

Иллюстрация полезна именно как граница ответственности. Совместимость версий проверяет контракт приложения. Backfill проверяет состояние данных и нагрузку. Contract проверяет отсутствие зависимости от старой формы. Ни один этап не доказывает остальные.

\n

Порядок действий

\n
  1. Опишите old reader, old writer, new reader и new writer. Для каждой пары запишите, какую форму она читает и пишет.
  2. Сделайте expand совместимым: добавьте новую форму так, чтобы допустимый старый writer не получил отказ. Отдельно проверьте constraints, default, trigger и порядок deploy для вашей СУБД.
  3. Включите dual write только там, где можно определить поведение при частичной ошибке. Если две записи не входят в одну транзакционную границу, опишите reconciliation.
  4. Задайте backfill scope, batch boundary, owner, повторный запуск и stop signal. Перед стартом назовите состояние данных после остановки.
  5. Запустите ограниченную проверку на разрешённой среде. Сравните old и new representation, ошибки, пропуски, время обработки и влияние на рабочий трафик.
  6. Переключайте чтение по evidence. Оставьте fallback и сигнализируйте его использование, пока старые записи и потребители не проверены.
  7. Отдельно подтвердите отсутствие old consumer. Проверьте код, расписания, очереди, фоновые задачи, batch-процессы и внешние клиенты.
  8. Составьте recovery boundary. Укажите, что возвращает deploy, что восстанавливается из данных и в какой момент нужен restore или repair.
  9. Удаляйте старую форму последней операцией. Если один критерий не выполнен, остановитесь на migrate или switch и зафиксируйте причину.
\n

Ограничения и отрицательный путь

\n

Expand/contract не делает миграцию беспростойной. Dual write может дать расхождение, если запись в одну систему прошла, а в другую нет. Backfill может конкурировать с индексами, блокировками и репликацией. Внешний клиент может использовать старое поле без регистрации. ORM может добавить собственный cache или изменить порядок чтения. Эти случаи требуют проверки конкретной системы.

\n

Не переносите синтаксис PostgreSQL на другую СУБД. Даже в PostgreSQL команда, которая добавила constraint, не равна доказательству, что все старые строки уже проверены. Не считайте зелёный тест доказательством надёжности. Не увеличивайте batch, если неизвестна причина нагрузки. Не запускайте contract после одного удачного прогона. Если обнаружили несовместимость, правильное действие — остановить переход и сохранить старую форму.

\n

Проверяемый критерий готовности

\n

Миграция готова к contract только тогда, когда одновременно выполнены пять условий: все допустимые версии читают нужную форму; writer-ы не создают неподдерживаемые записи; backfill имеет завершённый scope и повторяемый результат; использование old representation и fallback равно нулю в согласованном окне наблюдения; recovery-план проверен для оставшейся границы риска. Число и длительность окна должны определить владельцы системы по своим SLO и traffic profile. В этой статье они не выдумываются.

\n

Если хотя бы одно условие нельзя подтвердить, критерий не выполнен. Это не повод скрыть расхождение за словом «почти». Оставьте старую форму, остановите удаление и соберите недостающее evidence. Такой отказ дешевле восстановления данных после необратимого contract.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/134.json b/editorial/agent-rewrites/134.json new file mode 100644 index 0000000..3017a2f --- /dev/null +++ b/editorial/agent-rewrites/134.json @@ -0,0 +1,7 @@ +{ + "index": 134, + "slug": "editorial-2024-04-mechanism-data-migrations", + "title": "Миграция данных без разрыва контракта: expand, backfill и граница отката", + "excerpt": "Как провести изменение формы данных при смешанных версиях приложения: сохранить совместимость старых reader и writer, ограничить backfill и не принять удаление старой формы за rollback.", + "contentHtml": "

После релиза новая версия сервиса получает записи без нового поля. В логах растёт число ошибок валидации, а старый worker продолжает записывать прежнюю форму. Другой симптом выглядит тише: backfill работает, но вместе с ним растут задержки обычных запросов. Остановка оставляет неизвестное число частично обработанных записей.

Цена ошибки — не только несколько 500. Смешанные версии могут по-разному прочитать одну запись. Повторная попытка может создать расхождение. Откат бинарника не вернёт удалённое поле или старое значение. Если команда не знает, какие записи уже изменились, она теряет безопасную границу восстановления.

Тезис: мигрируют не колонки, а контракт во времени

Схема базы — общий протокол между версиями приложения. Поэтому изменение нужно проверять для четырёх ролей: old reader, old writer, new reader и new writer. Пока две версии могут одновременно обслуживать запросы, новая схема обязана принимать допустимую старую форму. Новая версия должна уметь читать обе формы, если backfill ещё не закончен.

Надёжный маршрут разделяет четыре события: expand добавляет новую поверхность, migrate приводит старые записи к новой форме, switch переводит чтение и запись, contract удаляет старый путь. Эти этапы могут иметь разные владельцы, риски и критерии. Их нельзя прятать в одну миграцию, один релиз или одну фразу «схема уже готова».

Механизм совместимости

Представим запись заказа. Старая форма хранит сумму в поле amount, новая — объект money с суммой и валютой. В transition-периоде новая версия читает обе формы. Новый writer сохраняет обе формы, пока старый reader ещё возможен. Backfill заполняет money только для записей, где значение можно вывести без потери смысла.

function readAmount(order) {\n  if (order.money && Number.isFinite(order.money.value)) {\n    return { value: order.money.value, currency: order.money.currency };\n  }\n\n  if (Number.isFinite(order.amount)) {\n    return { value: order.amount, currency: 'RUB' };\n  }\n\n  return { ok: false, reason: 'amount-is-not-recoverable' };\n}\n\nfunction writeOrder(order, money) {\n  return {\n    ...order,\n    amount: money.value,\n    money: { value: money.value, currency: money.currency },\n  };\n}

Это учебный пример. Он не подключается к базе, не проверяет валюту по справочнику, не знает транзакционную границу и не доказывает, что dual write атомарен. Его задача уже: явно показать fallback и отрицательный путь. Если старое поле не позволяет однозначно восстановить валюту, функция должна остановиться, а не записать правдоподобное значение.

В production-протоколе нужно дополнительно определить семантику отсутствующего поля. null может означать «ещё не обработано», «значение неприменимо» или «данные потеряны». Эти состояния нельзя различать по догадке. Их фиксируют в контракте до начала backfill.

Симптом → причина → проверка → действие

Диагностика миграции по наблюдаемому сигналу
СимптомПричинаПроверкаДействие
Новый reader получает записи без нового поляBackfill не завершён или old writer ещё активенСопоставить версию writer, долю старой формы и область backfillОставить fallback, остановить contract и уточнить owner перехода
Старый writer получает отказ после expandСхема стала обязательной раньше rollout кодаПроверить запросы old writer и правила default/constraintВернуть совместимое правило или остановить выкладку
После запуска backfill растёт p95Фоновая работа конкурирует за CPU, I/O, lock или соединенияСравнить окно backfill с latency, saturation и ожиданием ресурсовОстановить работу по заранее заданному signal и сохранить partial state
Данные в старой и новой форме расходятсяDual write не покрывает путь обновления или повторяется неидемпотентноСравнить write paths, ключ операции и правило повторного запускаЗаморозить switch, определить источник истины и исправить расхождение
Новая версия зелёная, старый consumer ещё живГотовность оценили по одному deploymentПроверить workers, очереди, cron и внешних потребителейНе удалять старую форму; продлить совместимый период
Rollback кода прошёл, данные не читаютсяОткат приложения ошибочно приняли за recovery данныхПроверить форму записей, уже удалённые поля и recovery boundaryПерейти к data repair или restore-процедуре, если она предусмотрена

Таблица задаёт направление расследования, а не готовую причину. Один симптом может иметь несколько источников. Каждое действие должно ссылаться на конкретный signal и менять только одну переменную, иначе результат нельзя интерпретировать.

Expand: добавить новую форму, не сломав старую

На expand создают колонку, таблицу или индекс, который не требует от старого кода неизвестного значения. Новая поверхность может быть nullable, но nullable не означает безопасно. Нужно описать, что значит отсутствие, какие записи допустимы и когда значение станет обязательным. Если old writer не способен сохранить новый инвариант, его нельзя превратить в ошибку одним DDL-шагом.

Стоимость операции зависит от движка, версии и объекта. В документации PostgreSQL описаны разные последствия для добавления колонки, default и ограничений. Поэтому нельзя переносить обещание «без блокировки» с одной СУБД на другую. Перед запуском проверяют конкретную операцию на выбранной версии движка и учитывают её влияние на размер таблицы, lock и репликацию.

\"Матрица
Матрица показывает допустимый переход версий. Она не подтверждает, какие версии реально запущены, и не измеряет полноту данных.

Migrate: backfill с бюджетом и стоп-сигналом

Backfill — отдельный поток изменения состояния. Для него нужны область записей, размер управляемой порции, правило повторного запуска, owner, журнал результата и условие остановки. «Запустить джобу до конца» не является планом. Конец может не наступить, а повторный запуск может дважды применить преобразование.

Стоп-сигнал должен быть наблюдаемым: превышение согласованной задержки, рост ошибок, lock wait, нарушение контрольной выборки или явная команда владельца. Значения порции и пороги нельзя брать из этого учебного текста. Их получают для конкретной среды. Учебный код не читает метрики и не даёт производственных результатов.

Остановка не означает провал. Она должна оставить состояние, которое можно описать: какие записи обработаны, какие пропущены, что делает следующий запуск и сохраняется ли старая форма. Если после stop никто не может ответить на эти вопросы, backfill не готов к запуску.

Switch и contract: два разных решения

Switch переводит reader на новую форму. До него проверяют, что новый reader понимает старую запись, новая запись имеет ожидаемую семантику, а divergence можно обнаружить. После switch fallback ещё может оставаться. Факт, что известная выборка заполнена, не доказывает отсутствие старого writer-а в очереди, worker-е или внешнем consumer-е.

Contract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это необратимее обычного rollback. Возврат старого бинарника может вернуть старую логику, но не удалит уже записанные новые значения и не восстановит очищенную историю. Поэтому contract выполняют отдельным решением после доказательства отсутствия declared old reader/writer и после фиксации recovery boundary.

В официальном описании онлайн-миграции Stripe переход разделён на dual write, переключение readers, переключение writers и удаление старых данных. Это полезный шаблон последовательности, но не готовая настройка для другой БД. Сам Stripe отдельно отмечает дополнительную стоимость записи и постепенное увеличение нагрузки. В собственном проекте эти параметры нужно измерять отдельно.

Порядок действий

  1. Опишите old и new shape. Назовите обязательные поля, значение отсутствия, правила преобразования и случаи, которые нельзя восстановить.
  2. Составьте матрицу old/new reader и writer. Включите сервисы, worker-ы, очереди, cron и внешних потребителей. Неизвестную роль пометьте как blocker.
  3. Выберите expand, который сохраняет работу declared old writer. Проверьте DDL, lock и поведение конкретной версии СУБД по официальной документации.
  4. Выпустите совместимый reader и writer. Убедитесь, что fallback виден в коде и наблюдении, а dual write имеет понятное правило повторения.
  5. Оформите backfill: scope, owner, bounded batch, idempotency, stop signal, partial state и действие после остановки.
  6. Проведите ограниченную проверку mixed-version сценария. Зафиксируйте только проверенный результат; не называйте учебный или изолированный прогон доказательством production-ready.
  7. Переключите reader по заранее названному evidence. Оставьте старую форму доступной на период наблюдения.
  8. Проверьте отсутствие старых consumers, расхождения данных и необработанной области. Только после этого принимайте отдельное решение о contract.

Ограничения и отрицательный путь

Эта модель не выбирает isolation level, batch size, lock timeout, формат журнала, стратегию репликации или восстановление из backup. Она не решает вопросы PII, retention, шифрования и прав доступа. Dual write может быть неатомарным, если две формы лежат за разными транзакционными границами. ORM, cache, trigger и очередь могут добавить пути, которых нет в основном сервисе.

Если новый инвариант нельзя поддержать для old writer, не пытайтесь ускорить rollout. Оставьте expand совместимым, добавьте адаптер или выберите отдельный период остановки. Если backfill нельзя bounded-ить, не запускайте его «на пробу». Если не найден old consumer, не удаляйте старую форму. Если уже произошла потеря данных, называйте действие recovery или data repair, а не rollback.

Критерий готовности

Миграция готова к следующему этапу, когда документированный owner может проверить пять фактов: каждая активная версия имеет описанные read/write-пары; old writer не отвергается; backfill имеет повторяемость, границу и stop signal; divergence обнаруживается; contract имеет отдельный recovery boundary. Для финального удаления дополнительно нужно подтверждение, что старое представление больше не требуется ни одному заявленному consumer-у.

Учебный пример выше можно проверить на четырёх входах: новая форма, старая форма, неполная старая форма и конфликтующие значения. Ожидаемый результат — корректное чтение первых двух и явный отказ последних двух. Эта проверка подтверждает логику функции, но не поведение базы, нагрузку, deployment или полноту production-данных.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/135.json b/editorial/agent-rewrites/135.json new file mode 100644 index 0000000..b559c96 --- /dev/null +++ b/editorial/agent-rewrites/135.json @@ -0,0 +1,7 @@ +{ + "index": 135, + "slug": "editorial-2024-04-practice-data-migrations", + "title": "Миграция данных без ловушки отката: expand, migrate, contract", + "excerpt": "Как менять схему при смешанных версиях приложения: сохранить совместимость, ограничить backfill, проверить отрицательный путь и не принять удаление старого поля за обычный rollback.", + "contentHtml": "

После релиза новая версия сервиса отвечает ошибкой на запись: обязательное поле ещё не заполняет старый writer. В другой попытке поле сделали nullable, запустили backfill без предела и получили рост задержек. В обоих случаях DDL прошло успешно. Ошибка появилась позже, когда версии приложения стали жить рядом. Цена ошибки — потерянные записи, очередь повторных запросов и отсутствие честного пути назад. Откат бинарника не возвращает данные, которые уже перезаписаны или удалены.

\n

Безопасная миграция — это не одна команда изменения схемы. Это период совместимости между old и new reader, old и new writer, затем отдельное преобразование данных и только потом удаление старой формы. Такой маршрут называют expand–migrate–contract. Он не делает операцию безопасной автоматически. Он раскладывает риск на этапы, для каждого этапа задаёт проверку и оставляет границу, после которой rollback приложения уже недостаточен.

\n

Механизм совместимости

\n

Представьте запись заказа. Старая форма хранит имя клиента в поле customer_name. Новая форма должна хранить ссылку customer_id. Если сразу удалить старое поле, старый сервис перестанет писать. Если сразу потребовать customer_id, старые строки и старые workers станут ошибками. Поэтому сначала добавляют новую поверхность, не запрещая старую.

\n

На этапе expand новая схема должна принимать старую форму. New reader читает обе формы. New writer может записать новую форму и, пока жив старый consumer, сохраняет совместимое старое значение. Backfill переносит уже существующие строки. Только после переключения всех readers и writers появляется основание для contract. У каждого перехода должен быть owner, стоп-сигнал и ответ на вопрос: что сохраняется, если процесс остановить на этой строке?

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старая версия получает отказ после добавления поля.Expand уже требует new shape.Проверить SQL-ограничения и запись old writer в отдельной транзакции.Вернуть обязательность, добавить совместимый nullable-путь и выпустить reader до writer.
Backfill перегружает основную базу.Нет размера партии, лимита скорости и стоп-сигнала.Сверить нагрузку job с бюджетом обычных запросов и проверить остановку на середине.Ограничить batch, concurrency и время запуска. Сохранить cursor и повторный запуск.
New reader видит пустое значение.Заполнение приняли за доказательство полноты.Проверить долю старой формы, правила для null и список ещё живых writers.Оставить fallback и не переходить к contract.
После отката код читает новую схему, но старых данных нет.Удаление данных назвали rollback.Спросить, каким действием восстанавливаются удалённые строки и откуда берётся копия.Остановить contract. Подготовить backup/restore или совместимое представление заранее.
Миграция прошла на стенде, а в production неизвестен mixed fleet.Rehearsal проверила сценарий, но не фактические версии и объём.Проверить deployment inventory, consumers, lock behavior и размер выборки.Считать стенд только учебной проверкой. Собрать production evidence до переключения.
\n

Таблица отделяет наблюдаемый симптом от решения. Если нельзя назвать проверку, команда пока не знает, какой риск она закрывает. Если действие меняет данные, оно должно иметь отдельный журнал, владельца и правило повторного запуска.

\n

Expand: добавить, не отрезать

\n

Expand меняет структуру так, чтобы old code продолжал работать. Это может быть nullable-колонка, новая таблица или новый индекс. Конкретная операция зависит от СУБД. Нельзя переносить обещание «добавление поля быстро» с одной версии и одного типа default на другую. В PostgreSQL поведение ALTER TABLE, блокировки и переписывание таблицы зависят от команды и параметров. Проверяйте документацию именно своей версии.

\n

Для примера с заказом expand добавляет customer_id, но не удаляет customer_name и не делает ссылку обязательной для старых строк. New reader использует customer_id, если он есть, иначе временно читает старое имя. Такой fallback должен иметь owner и условие удаления. Иначе временный путь станет постоянным и команда не поймёт, закончена ли миграция.

\n
function readCustomer(order) {\n  if (order.customer_id != null) {\n    return { kind: 'id', value: order.customer_id };\n  }\n\n  if (order.customer_name != null) {\n    return { kind: 'legacy-name', value: order.customer_name };\n  }\n\n  return { kind: 'invalid', value: order.id };\n}\n\n// Учебный пример: не подключается к базе и не доказывает\n// корректность данных в реальном сервисе.
\n

Код показывает три ветки. Новая форма имеет приоритет. Старая форма остаётся читаемой. Отсутствие обеих форм не превращается в тихий default. В настоящем сервисе проверка должна также учитывать права, конкурентную запись и семантику ошибок. Имена в примере вымышлены.

\n

Migrate: управляемый backfill

\n

Backfill отвечает на вопрос «как преобразовать старые строки». Он не должен менять договор совместимости. Запускайте его как отдельный процесс с областью, cursor, размером партии, лимитом скорости, idempotency-правилом и измеримым стоп-сигналом. Партия из тысячи строк не является безопасной сама по себе. Она может запускаться без конца, конкурировать с пользовательскими запросами или повторно менять одну строку после сбоя.

\n

Идемпотентный шаг проверяет текущее состояние перед записью. Если строка уже имеет корректный customer_id, повторный запуск её пропускает. Если соответствие неоднозначно, job должна остановиться или отправить строку на ручной разбор. Она не должна выбирать первый результат молча. В миграции данных неопределённость — это сигнал остановки, а не повод увеличить batch.

\n

Учебный псевдокод ниже ограничен памятью процесса. Он не читает настоящую БД, не измеряет locks, не запускает транзакции и не сообщает о готовности production.

\n
for (const batch of batches(records, 100)) {\n  const updates = [];\n\n  for (const record of batch) {\n    if (record.customer_id != null) continue;\n\n    const match = lookupCustomer(record.customer_name);\n    if (match.kind !== 'unique') {\n      throw new Error(`stop: ${record.id} needs review`);\n    }\n\n    updates.push({ id: record.id, customer_id: match.id });\n  }\n\n  applyUpdates(updates);\n  saveCursor(batch.at(-1).id);\n}
\n

В примере ошибка останавливает весь учебный проход. В production решение может быть другим: отдельная quarantine-очередь, транзакция на партию или ручное подтверждение. Важно другое: неоднозначная строка не получает случайное значение, cursor сохраняется, а повторный запуск видит уже обработанные записи. Учебный пример не является готовой библиотекой миграции.

\n

Switch: сначала readers, затем writers

\n

Переключение чтения не равно завершению backfill. Оно означает, что new reader умеет обработать остаток старой формы и команда согласовала, что делать с null, конфликтом и повторной записью. После переключения наблюдайте ошибки, долю fallback, расхождения двух форм и время обработки. Не удаляйте старое поле сразу после первого зелёного графика.

\n

Пока old writer или долгоживущий worker ещё может работать, new writer должен сохранять совместимость. Это может быть dual write, событие для отдельного consumer или другой явно описанный механизм. Dual write тоже создаёт риск: записи могут завершиться только в одной форме, а порядок событий может расходиться. Поэтому нужна проверка расхождений и правило исправления. Сам термин dual write ничего не гарантирует.

\n
\"Временная
Схема показывает порядок решений. Старая форма сохраняется через expand, migrate и switch. Contract допускается только после проверки consumers и recovery plan. Иллюстрация не показывает реальный deployment, нагрузку или результат production-операции.
\n

Stripe описывает похожий четырёхэтапный путь для своей онлайн-миграции: dual write, перевод чтений, перевод записей и удаление старых данных. Это инженерный разбор инфраструктуры Stripe, а не универсальная гарантия. В другой системе нужно отдельно проверить объём, lock behavior, задержки, ретраи и все пути записи.

\n

Contract: граница невозврата

\n

Contract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это полезный финал, но не обычный rollback. Возврат к старому бинарнику восстановит код, а не удалённые значения. Если старое представление нужно для восстановления, его сохраняют до contract: backup проверяют восстановлением, копию снабжают сроком хранения, а divergence связывают с понятным действием.

\n

Не называйте contract готовым по одному признаку. Green build не знает о ручном SQL-клиенте. Нулевой fallback за минуту не доказывает, что вчерашний worker завершился. Полная проверка должна охватывать writers, readers, jobs, очереди, отчёты и восстановление. Если список consumers неполон, безопасное действие — продлить совместимый период.

\n

Порядок действий

\n
  1. Опишите old и new shape. Назовите owner данных, readers, writers, допустимый null и правило разрешения конфликта.
  2. Проверьте операцию expand в документации своей СУБД. Узнайте блокировки, rewrite, ограничения версии и размер затрагиваемого объекта.
  3. Добавьте новую структуру без отказа old writer. Выпустите reader, который понимает обе формы и явно обрабатывает отсутствие данных.
  4. Оформите backfill: область, cursor, batch, concurrency, idempotency, журнал ошибок, stop signal и действие после частичного прогресса.
  5. Проведите ограниченный rehearsal на изолированных данных. Проверьте mixed versions, повторный запуск и остановку на неоднозначной записи.
  6. Соберите evidence для switch: список consumers, долю старой формы, ошибки, расхождения и результат восстановления резервной копии.
  7. Переключите readers, затем writers. Оставьте fallback и dual write на согласованный период наблюдения.
  8. Отдельно одобрите contract. Если жив старый consumer, неизвестно, как восстановить данные, или нет критерия остановки, contract не выполняйте.
\n

Ограничения и критерий готовности

\n

Expand–migrate–contract не выбирает isolation level, batch size, lock timeout, retention, backup policy или график запуска. Он не заменяет требования к PII, disaster recovery, capacity planning и проверку прав. PostgreSQL, Stripe и учебный JavaScript-пример описывают разные границы. Их нельзя объединять в обещание zero downtime.

\n

Для конкретной миграции критерий готовности проверяем: old и new версии имеют записанный контракт; expand не отвергает old writer; backfill можно остановить и повторить; неоднозначные записи не получают default; список consumers подтверждён; fallback и расхождения наблюдаемы; backup восстановлен на тестовой копии; contract имеет отдельное решение и срок хранения recovery-артефактов.

\n

Если хотя бы один пункт не подтверждён, миграция не готова к следующему необратимому шагу. Это не провал плана. Это точная граница знания: команда видит, какой факт нужно получить до изменения данных.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/136.json b/editorial/agent-rewrites/136.json new file mode 100644 index 0000000..fa4321e --- /dev/null +++ b/editorial/agent-rewrites/136.json @@ -0,0 +1,7 @@ +{ + "index": 136, + "slug": "editorial-2024-03-field-package-boundaries", + "title": "Границы пакетов: как остановить утечку домена в общую utility", + "excerpt": "Общая utility начинает ломать архитектуру задолго до падения сборки: она узнаёт доменные типы, а consumers обходят public API через internal-файлы. Разбираем симптомы, проверку границы и безопасные варианты исправления.", + "contentHtml": "

Сборка проходит, тесты зелёные, но следующий небольшой import внезапно требует правок в трёх пакетах. Общая utility знает про доменный статус счёта. Feature импортирует её внутренний cache по файловому пути. После этого изменение enum затрагивает форматтер, а переименование cache ломает consumer. Цена ошибки — скрытая связанность, более длинные ревью и миграция, которую нельзя выполнить одним владельцем.

\n

Тезис простой: граница пакета — это не каталог и не слово shared. Это проверяемый договор о том, какие имена доступны, кто владеет смыслом данных и какие пути запрещены. Если договор не записан, рабочий import постепенно становится частью API. Если договор записан, нарушение можно увидеть до релиза.

\n

Два симптома одной потери договора

\n

Рассмотрим учебный пример. Пакет @example/platform-formatting форматирует деньги и даты для нескольких feature. Billing добавляет в него импорт InvoiceStatus, чтобы вывести особую подпись для просроченного счёта. Orders в это же время импортирует createFormatterCache из @example/platform-formatting/internal/cache, потому что корневой экспорт не дал нужную функцию.

\n

Первый import переносит доменное решение в техническую utility. Formatter теперь должен понимать, какие состояния бывают у счёта и какой текст им соответствует. Второй import превращает внутреннее устройство utility в обещание consumer-у. Эти нарушения нельзя лечить одной настройкой lint: у них разные владельцы и разные исправления.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Utility импортирует InvoiceStatusДоменный смысл оказался в общем слоеНайти владельца enum и проследить, кто выбирает labelОставить в formatter только primitive inputs; mapping вернуть в billing
Consumer импортирует /internal/*Public surface не описывает нужную операциюСверить specifier с package root и списком exportsДобавить осмысленный root export или убрать зависимость от cache
Никто не может назвать public namesКонтракт существует только в соглашениях командыПопросить owner указать root, имена и запретные маршрутыСоздать короткую запись API с владельцем и сроком пересмотра
Предлагают сразу отключить правилоИнструмент подменяет архитектурное решениеОтделить допустимый adapter от случайного deep importСначала принять решение о границе, затем настроить static guard
\n

Механизм: смысл движется вверх, детали — вниз

\n

Доменный пакет отвечает на вопрос «что означает состояние». Utility отвечает на вопрос «как представить уже выбранные данные». Поэтому billing должен выбрать подпись, а formatter — принять готовую строку или набор простых значений. Когда formatter читает InvoiceStatus, он начинает зависеть не от формы входа, а от причины, по которой вход существует.

\n

У deep import другой механизм. Consumer перестаёт зависеть от обещанного поведения и начинает зависеть от расположения файла, имени helper-а и его lifetime. Автор пакета больше не может свободно поменять cache, разнести код по файлам или изменить стратегию invalidation. Даже если Node или bundler сегодня разрешает путь, это ещё не делает путь публичным.

\n
// Учебный пример: домен выбирает смысл, utility форматирует данные. type InvoiceView = { amountMinor: number; currencyCode: string; statusLabel: string }; export function renderInvoice(view: InvoiceView, locale: string) { const amount = new Intl.NumberFormat(locale, { style: 'currency', currency: view.currencyCode }).format(view.amountMinor / 100); return `${amount} — ${view.statusLabel}`; } const view = { amountMinor: invoice.amountMinor, currencyCode: invoice.currencyCode, statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'К оплате' }; renderInvoice(view, 'ru-RU');
\n

В примере statusLabel — осознанная граница. Billing меняет текст и правила статуса. Formatter получает данные, достаточные для форматирования, но не получает право расширять модель счёта. Это учебная иллюстрация, а не утверждение о конкретном production-коде.

\n

Как назвать public API

\n

Начните не с glob-паттерна, а со списка обещаний. Для условного пакета запись может выглядеть так:

\n
// Учебная запись контракта, не готовая конфигурация проекта. const boundary = { root: '@example/platform-formatting', publicNames: ['formatMoney', 'formatDate'], forbidden: ['@example/platform-formatting/internal/*'], owner: 'formatting-team', reviewBy: '2026-09-01' };
\n

Поле root отвечает на вопрос, откуда импортировать. publicNames отделяет API от случайно экспортированного файла. forbidden показывает, что internal-пути не входят в обещание. Owner принимает изменения surface. Дата пересмотра нужна для временного adapter-а: без неё временная лазейка становится постоянной.

\n

После этого можно выбрать техническую проверку. В Node поле exports задаёт разрешённые entry points и subpaths для package resolution. TypeScript при подходящем moduleResolution учитывает этот контракт, но настройки должны соответствовать runtime или bundler. ESLint может ловить запрещённые static imports. Ни один из этих механизмов не отвечает за смысл InvoiceStatus и не доказывает, что dynamic loader соблюдает тот же договор.

\n
\"Учебный
Учебный маршрут: сначала отделить доменную утечку от обхода public API, затем проверить конкретный import. Иллюстрация не показывает реальный граф зависимостей или результат CI.
\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Запишите точный module specifier и imported name из diff или заявки. Не заменяйте его формулой «пакеты сильно связаны».
  2. Назовите владельца смысла. Для каждого типа спросите, кто меняет его значения и правила отображения. Если ответ — billing, не переносите enum в formatting.
  3. Разделите направления. Отметьте, где utility зависит от domain, а где consumer зависит от internal. Это две записи и два решения, даже если они находятся в одном diff.
  4. Сверьте public record. Проверьте root specifier, разрешённое имя, запретный subpath, версии runtime и способ разрешения модулей.
  5. Выберите действие. Верните mapping владельцу домена, добавьте reviewed root export, создайте named adapter или отклоните deep import. Не оставляйте «разрешить пока» без даты.
  6. Проверьте отрицательный путь. Убедитесь, что неизвестное имя, internal subpath и новый доменный import действительно отклоняются выбранным guard-ом. Отдельно проверьте dynamic imports и generated code, если они есть.
  7. Повторите проверку после изменения. Сравните public surface до и после, запустите type check и lint в поддерживаемой конфигурации, затем проверьте потребителя. Synthetic пример не заменяет чтение реального графа.
\n

Что делать с cache и adapter-ом

\n

Если cache нужен только formatter-у, consumer должен вызывать public функцию. Состояние и invalidation остаются внутри пакета. Это самый узкий контракт. Если несколько consumers действительно используют одну семантику cache, вынесите её в отдельный reviewed export. Зафиксируйте входы, lifetime, invalidation и обратную совместимость. Само совпадение кода не доказывает, что helper стал общей абстракцией.

\n

Adapter допустим, когда он имеет владельца и срок жизни. Например, старый consumer может временно вызывать formatMoneyAdapter, пока команда мигрирует на новый root API. Adapter не должен открывать весь internal. Его surface должен быть меньше исходной детали, а проверка удаления — иметь конкретный сигнал.

\n

Ограничения и отрицательный путь

\n

Поле exports не делает любую архитектуру правильной. Внутренние и внешние пакеты отличаются по semver-обязательствам. Legacy consumers могут требовать переходный слой. TypeScript может разрешить типы в одной конфигурации, а runtime или bundler — разрешить их иначе. Поэтому проверяйте фактическую toolchain, а не только редактор и компилятор.

\n

Static rule не видит все способы загрузки кода. Dynamic import(), generated files и framework entry points требуют отдельного решения. Нельзя объявлять отсутствие lint-ошибки доказательством отсутствия зависимости. Нельзя и запрещать весь pattern без исключений: так adapter-ы получат suppressions, а реальные нарушения станут менее заметны.

\n

Если domain type уже попал в utility, не исправляйте проблему только переносом файла. Сначала верните решение domain owner-у. Если consumer уже использует internal path, не публикуйте весь каталог ради совместимости. Найдите операцию, которую consumer действительно требует, и оформите только её. Если такой операции нет, удалите зависимость и оставьте cache деталью владельца.

\n

Критерий готовности

\n

Работу можно считать готовой, когда для каждого затронутого import-а есть четыре проверяемых ответа: какой симптом найден, кто владеет смыслом, какой public route разрешён и какой инструмент подтверждает запрет остальных routes. Дополнительно должны проходить type check и static guard в поддерживаемой конфигурации, а consumer должен использовать root API. Для временного adapter-а указаны owner, срок удаления и проверка его удаления.

\n

Критерий не требует доказать, что весь монорепозиторий свободен от domain leak. Он требует доказать одну согласованную границу на конкретном import-е. Это ограничение делает результат честным: учебный код показывает механизм, а реальный diff и выбранные проверки показывают состояние системы.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/137.json b/editorial/agent-rewrites/137.json new file mode 100644 index 0000000..92352af --- /dev/null +++ b/editorial/agent-rewrites/137.json @@ -0,0 +1,7 @@ +{ + "index": 137, + "slug": "editorial-2024-03-mechanism-package-boundaries", + "title": "Границы пакетов: как не превратить shared-утилиту в скрытую платформу", + "excerpt": "Рабочая схема для пакета, который начинает знать чужую доменную модель: минимальный public API, запрет внутренних импортов, проверка маршрута зависимости и честные ограничения инструментов.", + "contentHtml": "

Ошибка обычно начинается с проходящего импорта. Formatter получает InvoiceStatus, чтобы вывести подпись рядом с суммой. Другой consumer берёт cache по пути platform-formatting/internal/cache, потому что так короче. Сборка проходит, TypeScript не спорит, autocomplete подсказывает нужный путь. Цена появляется позже: изменение billing enum требует выпуска formatter-а, чистка cache ломает consumer-а, а владелец зависимости неизвестен. Небольшой пакет перестаёт меняться изолированно.

\n

Тезис простой: границу пакета нельзя поручить одному инструменту. Сначала команда описывает public API. Затем runtime и компилятор ограничивают видимые точки входа. После этого статическое правило ловит запрещённые направления. Каждый слой проверяет свою часть договора. exports не заменяет архитектурное решение, TypeScript не определяет смысл доменной зависимости, а lint не видит весь динамический граф.

\n

Механизм границы

\n

Пакет может содержать больше, чем обещает. Внутри formatter-а допустимы cache key, fallback locale и адаптер к библиотеке дат. Consumer должен видеть root specifier и небольшой набор имён. Если consumer импортирует внутренний файл, устройство каталогов превращается в публичный контракт. Если utility импортирует доменный enum, она получает чужое правило принятия решений.

\n

Type-only import не отменяет границу. Такой импорт может исчезнуть из JavaScript, но останется в исходном коде и в декларациях. Formatter всё равно знает язык billing. Поэтому проверка «в bundle нет billing» отвечает не на тот вопрос. Нужно спросить: может ли владелец billing изменить статус, не меняя контракт общей утилиты?

\n
Три уровня защиты границы
УровеньПроверяетНе доказываетДействие
Public API recordразрешённые specifier, имена, входы, выходы и ownerреальное разрешение модулейзафиксировать смысл договора
package.json exportsдоступные package entry pointsотсутствие абсолютных обходов и доменную политикусузить surface для поддерживаемого runtime
TypeScript resolutionсогласованное разрешение imports/exports и формата модулейправо utility знать чужую модельсинхронизировать compiler и runtime
ESLint restrictionназванные статические import routesdynamic import и полный графзакодировать узкий запрет с альтернативой
\n
\"Схема
Учебная схема: root API принимает примитивные данные, а доменный тип и внутренний subpath находятся за границей. Иллюстрация не описывает настоящий registry или production-пакет.
\n

Пример: вернуть смысл владельцу домена

\n

Рассмотрим синтетический пакет @synthetic/platform-formatting. Он форматирует деньги и даты. Billing хочет показывать особый текст для просроченного счёта. Плохой путь передаёт в formatter весь invoice или импортирует InvoiceStatus. Тогда форматирование решает бизнес-вопрос. Новый статус становится изменением shared package.

\n

Безопаснее сначала получить display model на стороне billing. Formatter принимает только данные, которые ему нужны для отображения. Пример учебный: он не доказывает работу настоящего приложения и не является рекомендацией менять конкретный репозиторий.

\n
// Синтетический пример. Billing владеет интерпретацией статуса.\nconst display = {\n  statusLabel: invoice.status === 'overdue' ? 'Просрочен' : 'Открыт',\n  amountMinor: invoice.amountMinor,\n  currencyCode: invoice.currencyCode,\n};\n\n// Общая утилита получает только форматируемые значения.\nconst amountLabel = formatMoney({\n  amountMinor: display.amountMinor,\n  currencyCode: display.currencyCode,\n  locale: 'ru-RU',\n});
\n

У consumer-а остаётся один публичный маршрут: @synthetic/platform-formatting. В record можно записать formatMoney и formatIsoDate как public names, а internal/* и доменные импорты — как запрещённые направления. Если функция действительно нужна нескольким пакетам, её добавляют в root API с owner, входами, выходами и планом совместимости. Deep import не становится API только потому, что он уже используется.

\n

Как связать договор и инструменты

\n

Поле exports в package.json помогает объявить entry points. Resolver видит перечисленные subpath, а не случайные файлы каталога. Это полезная граница package surface. Но абсолютный путь к файлу может обойти такую инкапсуляцию. Значит, exports не является security boundary и не доказывает отсутствие плохих зависимостей.

\n

TypeScript в режимах node16 и nodenext учитывает модель Node и package maps. Это уменьшает расхождение между проверкой типов и запуском. Но компилятор не знает, что InvoiceStatus принадлежит billing и не должен попадать в utility. Смысловой запрет остаётся задачей контракта и политики.

\n

ESLint можно настроить на конкретные маршруты. Запретите consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Сообщение должно предлагать root API или adapter. Правило должно быть узким: общий запрет «не импортировать домены» может заблокировать законный интеграционный слой.

\n
/* Учебная политика ESLint, не готовая конфигурация проекта. */\n'no-restricted-imports': ['error', {\n  patterns: [{\n    group: ['@synthetic/platform-formatting/internal/*'],\n    message: 'Используйте root API пакета.',\n  }],\n}]
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Utility импортирует доменный типсмысл статуса не принадлежит utility, но public API не описанкто меняет enum и кто меняет форматированиеперенести интерпретацию в domain owner, передать primitive/display data
Consumer импортирует /internalфайловое устройство приняли за контрактесть ли стабильная семантика и root exportубрать deep import или оформить отдельный public export
Lint rule просит исключениеправило появилось раньше архитектурного решенияназваны ли адресат, route и легальная альтернативасначала записать boundary record, затем настроить guard
Сборка чистая, но coupling растётпроверяется emitted code, а не исходный import graphнайти type-only, re-export и dynamic edges отдельнодобавить статические проверки и ручной review исключений
\n

Порядок действий

\n
  1. Выберите один пакет и назовите его роль. Не начинайте с общей папки shared.
  2. Запишите root specifier, public names, входы, выходы, owner и допустимых consumers.
  3. Отметьте внутренние subpath и доменные факты, которые пакет не должен интерпретировать.
  4. Проверьте реальные import routes: обычный import, re-export, type-only import и dynamic import.
  5. Настройте exports и compiler resolution только в поддерживаемой toolchain.
  6. Добавьте узкие ESLint restrictions с понятной альтернативой.
  7. Разберите каждое исключение отдельно. Для adapter укажите владельца и срок удаления.
  8. Проверьте public API тестом поведения и повторите поиск запрещённых маршрутов.
\n

Ограничения и критерий готовности

\n

Схема не делает пакеты независимыми автоматически. Adapter-ы, generated clients, plugin systems и framework entry points могут законно пересекать слои. Для них нужен явный маршрут и owner. Статический lint не описывает runtime registry и не ловит все вызовы import(). exports зависит от версии Node, bundler-а и способа потребления пакета. TypeScript resolution должен совпадать с тем, что реально запускает приложение.

\n

Не выдавайте учебный пример за аудит. В этой статье нет утверждения о production-результатах, размере bundle, CI или состоянии конкретного репозитория. Проверять нужно область, toolchain и импортный граф, а затем отдельно проверять поведение root API.

\n

Граница готова, если любой новый import можно классифицировать без чтения всего пакета: он входит в public API, нарушает названное правило или проходит через документированный adapter. Для выбранного пакета должны быть записаны owner и root API; команда должна получить диагностическое сообщение на запрещённый static import; тест public API должен пройти; поиск по исходникам не должен находить неразрешённые deep imports. Это проверяемый критерий, а не обещание абсолютной изоляции.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/138.json b/editorial/agent-rewrites/138.json new file mode 100644 index 0000000..18b6a20 --- /dev/null +++ b/editorial/agent-rewrites/138.json @@ -0,0 +1,7 @@ +{ + "index": 138, + "slug": "editorial-2024-03-practice-package-boundaries", + "title": "Границы пакетов: как не превратить общую утилиту в скрытую платформу", + "excerpt": "Общая утилита становится дорогой в сопровождении, когда принимает доменные типы и открывает внутренние файлы. Разбираем короткий public API, запретные направления, проверку и безопасный путь исправления.", + "contentHtml": "

Проблема часто начинается с безобидного изменения. Formatter денег уже используется в billing и orders. В него добавляют условие для InvoiceStatus, чтобы рядом с суммой вывести «Просрочен». Другой consumer импортирует внутренний cache по пути platform-formatting/internal/cache. Сборка проходит. Симптом появляется позже: изменение enum требует правки общей утилиты, очистка cache ломает consumer, а reviewer не может отличить обещанный API от случайного файла.

Цена ошибки — не одна лишняя зависимость. Доменная модель проникает в пакет, который считали нейтральным. Владелец billing начинает влиять на форматтер, владелец форматтера — на orders. Любой рефакторинг проходит через большее число команд. Ошибку труднее локализовать. Откат затрагивает код, который изначально не должен был знать друг о друге.

Тезис: граница пакета начинается с короткого договора, а не с папки и не с конфигурации линтера. Договор называет root specifier, публичные имена, входы, выходы и запрещённые направления. Инструменты затем проверяют отдельные части договора. Они не принимают архитектурное решение вместо владельца пакета.

Что именно считать границей

Пакет содержит больше кода, чем обещает. Внутри могут лежать cache, fallback для locale, адаптеры и тестовые helpers. Consumer должен видеть только root entry point и имена, которые команда готова поддерживать. Любой другой импорт превращает текущую структуру файлов в неявный контракт.

Доменная граница проходит по смыслу данных. amountMinor, currencyCode и locale описывают вход для форматирования. InvoiceStatus, лимит возврата и правило просрочки описывают billing. Если formatter принимает Invoice, он получает право интерпретировать чужую модель. Если formatter импортирует InvoiceStatus даже только как тип, зависимость остаётся: исходный код и декларации начинают отражать billing-словарь.

Правильный consumer сначала принимает решение у себя, затем передаёт утилите нейтральные данные. Billing может превратить статус в свою подпись, а formatter — отформатировать сумму. Так изменение статуса остаётся у владельца billing. Общий пакет меняет только правила представления чисел, валюты и даты.

Симптом → причина → проверка → действие

Учебная карта диагностики package boundary
СимптомПричинаПроверкаДействие
Utility импортирует InvoiceStatusДоменная интерпретация вошла в общий пакетПосмотреть imported name и владельца типаВернуть выбор статуса в billing и передать primitive values
Consumer импортирует /internal/*Public API не назван или оказался слишком малСверить specifier с API recordУбрать deep import либо открыть отдельный reviewed export
Новый export добавляют «на всякий случай»Поверхность пакета растёт без владельцаПроверить consumer, семантику и срок поддержкиОставить только нужное имя и зафиксировать owner
Lint rule разрешает всё через исключениеИнструмент скрывает неясное архитектурное решениеНазвать точный allowed route и причину исключенияСузить правило или создать именованный adapter
Надеются на один механизмexports, TypeScript и lint смешали в одно обещаниеПроверить область действия каждого слояРазделить package surface, module resolution и policy

Пример: от доменного типа к нейтральному API

Ниже приведён учебный пример. Имена billing и platform-formatting вымышлены. Код показывает форму границы, а не состояние конкретного проекта.

// Учебный пример: billing владеет смыслом статуса.\nconst displayData = {\n  statusLabel: invoice.status === \"overdue\" ? \"Просрочен\" : \"Открыт\",\n  amountMinor: invoice.amountMinor,\n  currencyCode: invoice.currencyCode,\n};\n\n// Общая функция получает только данные для форматирования.\nconst amountLabel = formatMoney({\n  amountMinor: displayData.amountMinor,\n  currencyCode: displayData.currencyCode,\n  locale: \"ru-RU\",\n});

Плохой вариант смешивает оба решения: formatter сам импортирует InvoiceStatus, выбирает подпись и форматирует деньги. Такой код может быть короче, но граница становится неясной. Хороший вариант не запрещает переиспользование. Он оставляет каждому пакету один вид ответственности.

Три разных механизма

Node.js package.json с полем exports описывает доступные entry points при обычном импорте пакета. Это полезно для surface и совместимости. Неэкспортированный subpath перестаёт быть обычной частью package API. Но exports не является защитой от любого прямого обращения к файлу. Он также не знает, что InvoiceStatus относится к billing и потому не должен попадать в formatter.

TypeScript в режимах node16 и nodenext учитывает exports, imports, self-reference и различия ESM/CJS. Compiler помогает согласовать типы с module resolution. Он не решает вопрос владения бизнес-смыслом. Корректный type-check не делает доменную зависимость хорошей.

ESLint no-restricted-imports подходит для названных статических маршрутов. Можно запретить consumer-ам @synthetic/platform-formatting/internal/*, а utility — импорты @synthetic/billing-domain/*. Это узкая проверка синтаксиса. Она не строит полный граф dynamic import, generated code и runtime plugin loading. В policy нужно обещать только то, что выбранное правило действительно видит.

\"Учебный
Иллюстрация показывает направление зависимостей в учебной модели. Она не является скриншотом и не доказывает граф какого-либо production-приложения.

Как поставить границу в существующем коде

Не начинайте с переезда всех файлов. Сначала остановите расширение поверхности. Назовите один root specifier и список публичных имён. Отдельно запишите forbidden routes: consumer не ходит в internal и src, а formatter не импортирует billing и account domains. Исключение для adapter-а оформляйте отдельным пакетом или явно названным слоем. Иначе исключение быстро станет новым правилом.

Затем возьмите один реальный edge. Если utility импортирует доменный тип, перенесите интерпретацию к owner-у домена. Если consumer использует cache, решите, кому принадлежит lifetime и invalidation. Иногда cache должен остаться деталью utility. Иногда несколько consumers действительно нуждаются в стабильном сервисе. Во втором случае публикуйте осмысленный API с входами, выходом и правилами изменения. Не экспортируйте внутренний объект только потому, что он уже существует.

Порядок действий

  1. Зафиксируйте точный module specifier, imported name и слой, где находится зависимость.
  2. Назначьте владельца каждого типа и функции. Если владелец не найден, не расширяйте API.
  3. Составьте короткий API record: root specifier, public names, входы, выходы и forbidden routes.
  4. Разделите domain decision и formatting. Перенесите интерпретацию модели обратно к её owner-у.
  5. Удалите deep import. Если consumer не может работать через root API, проведите отдельный review нового export-а или adapter-а.
  6. Настройте exports, TypeScript resolution или ESLint только для тех правил, которые уже согласованы.
  7. Проверьте положительный и отрицательный пути: допустимый root import проходит, доменный import и internal subpath получают понятный отказ.

Проверка на учебной модели

Для локальной проверки формы договора можно использовать три заранее заданных случая: чистый root import, utility с доменным импортом и consumer с deep import. Такой тест полезен, если он явно называет свои границы. Он проверяет классификацию записанных примеров. Он не читает репозиторий, не строит настоящий import graph и не доказывает состояние CI.

const boundary = {\n  publicSpecifier: \"@synthetic/platform-formatting\",\n  publicNames: [\"formatMoney\", \"formatIsoDate\"],\n  forbiddenConsumerRoutes: [\"@synthetic/platform-formatting/internal/*\"],\n  forbiddenUtilityTargets: [\"@synthetic/billing-domain/*\"],\n};\n\n// Проверяемый учебный результат:\n// clean root import       - compliant\n// utility -> billing      - violated\n// consumer -> internal    - violated

Если такой пример называют проверкой проекта, он вводит в заблуждение. Для реального edge нужны согласованная область чтения, выбранный resolver, учёт aliases и generated layers, затем отдельная проверка toolchain. Учебная модель не заменяет эти шаги.

Ограничения

Эта схема не делает пакеты независимыми автоматически. Она не измеряет размер bundle, не доказывает отсутствие циклов, не проверяет семантическую совместимость всех версий и не описывает dynamic loading. В legacy-коде может потребоваться временный adapter. У adapter-а должны быть владелец, разрешённый маршрут и дата удаления исключения.

Глобальный запрет тоже опасен. Framework entry point, generated client и plugin adapter могут законно пересекать слои. Важно назвать роль такого пакета. Запрещайте не слово domain, а конкретное направление для конкретного owner-а. Иначе команда начнёт отключать правило вместо исправления зависимости.

Критерий готовности

Работа готова, когда для одного выбранного package можно ответить на пять вопросов без поиска по всему репозиторию: кто owner; какой root specifier обещан; какие имена и входы публичны; какие направления запрещены; чем проверяется каждый запрет. Положительный тест импортирует только root API. Отрицательные тесты показывают отказ для domain leak и deep import. Проверка явно указывает, какие пути она не покрывает.

После этого изменение InvoiceStatus не требует знания внутренностей formatter-а, а изменение cache не заставляет искать случайных consumers. Граница не запрещает развитие пакета. Она делает цену нового знания видимой до того, как оно станет общей платформой.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/139.json b/editorial/agent-rewrites/139.json new file mode 100644 index 0000000..1753ff0 --- /dev/null +++ b/editorial/agent-rewrites/139.json @@ -0,0 +1,7 @@ +{ + "index": 139, + "slug": "editorial-2024-02-field-modular-monolith", + "title": "Модульный монолит под ревью: как остановить протечку границ", + "excerpt": "Три похожих импорта могут незаметно связать каталог, checkout и оплату. Разбираем границы на учебном примере, проверяем API и направление зависимостей, а затем выбираем обратимое исправление.", + "contentHtml": "

В pull request появляются три коротких импорта. Первый читает карточку товара из каталога. Второй вызывает приватный formatter каталога. Третий даёт модулю оплаты обратиться обратно к checkout. Код компилируется. Тест на один сценарий проходит. Через месяц изменение расчёта цены требует правок в каталоге, checkout и оплате. Команда ищет владельца инварианта по чатам, а не по коду. Цена ошибки — не эстетика архитектуры. Она выражается в связанных релизах, длинном ревью и скрытом риске сломать соседний сценарий.

\n

Модульный монолит не запрещает модулям общаться. Он делает эту связь проверяемой. Для каждой стрелки нужно назвать consumer, owner, публичную поверхность и направление. Если хотя бы одно поле неизвестно, импорт ещё не является понятным контрактом. Такой разбор не доказывает корректность всего приложения. Он отвечает на более узкий вопрос: кто имеет право вызвать кого и что произойдёт с границей после следующего изменения.

\n

Ниже приведён учебный пример с фиксированными именами catalog, checkout, payments и notifications. Он не описывает реальный репозиторий, метрики или результат CI. Его задача — показать форму рассуждения. В настоящем проекте каждую строку из примера нужно подтвердить точечным анализом исходников и отдельными тестами.

\n

Что именно ломается

\n

Первый симптом — новый вызов выглядит как обычное использование API, но его назначение никто не может сформулировать одним предложением. Второй — consumer импортирует класс из пакета internal, потому что нужного метода в публичной поверхности нет. Третий — новая обратная стрелка формально ведёт в api, но замыкает цикл. Компилятор видит типы. Он не видит владельца процесса и стоимость координации.

\n

Рассмотрим три связи. checkout → catalog.api может быть допустимой: checkout просит каталог вернуть данные, которыми владеет каталог. checkout → catalog.internal нарушает границу: checkout зависит от детали реализации. payments → checkout.api требует отдельного решения, если checkout уже вызывает payments.api. Публичность метода не делает любое направление безопасным.

\n

Тезис и механизм

\n

Граница модуля состоит из трёх договорённостей. Владелец отвечает за данные и инварианты. Поверхность API перечисляет возможности, которые владелец готов поддерживать. Направление зависимости ограничивает сценарии, в которых другой модуль может этой возможностью пользоваться. Папка помогает увидеть структуру, но сама по себе границу не создаёт.

\n

Проверка начинается не с названия класса, а с конкретной ссылки. Запишите её как source → target.surface. Затем ответьте на четыре вопроса: какой сценарий обслуживает вызов, кто меняет состояние, можно ли получить результат через опубликованный контракт и не создаёт ли стрелка цикл. Ответ «так принято» не заменяет ни одного из них.

\n
record BoundaryLink(\n    String source,\n    String target,\n    String surface,\n    String scenario\n) {}\n\nBoundaryLink link = new BoundaryLink(\n    \"checkout\", \"catalog\", \"api\", \"show product card\"\n);\n\n// Проверяем отдельно:\n// 1. surface опубликована владельцем;\n// 2. source -> target разрешено картой;\n// 3. новая ссылка не замыкает цикл;\n// 4. scenario не переносит чужой инвариант.\n
\n

Код выше — учебная запись, а не готовая библиотека. В реальном Java-проекте вместо строки surface понадобятся пакеты, named interface или другой явно поддерживаемый контракт. Важно сохранить сам порядок проверки: сначала смысл вызова, затем поверхность, потом направление и цикл.

\n

Три импорта под микроскопом

\n

Допустимая поверхность. checkout → catalog.api оставляют, если каталог действительно владеет данными о товаре и API возвращает только нужное представление. Владелец фиксирует контракт и отвечает за его изменение. Checkout не должен начинать пересчитывать каталожные правила, просто получив доступ к данным.

\n

Протечка во внутренность. checkout → catalog.internal обычно появляется из-за ближайшего удобства. Приватный formatter уже существует, поэтому его проще импортировать, чем обсудить новый контракт. Но такой импорт связывает consumer с форматом, именем класса и внутренним порядком работы. Сначала проверьте, должен ли результат остаться операцией каталога. Если да, перенесите вызов к владельцу. Если нет, опубликуйте узкий метод с понятным сценарием. Не экспортируйте весь пакет.

\n

Обратная зависимость. payments → checkout.api не становится безопасной только потому, что API публична. Если checkout уже вызывает payment, две стрелки образуют цикл. Тогда нужно выбрать владельца orchestration: один модуль управляет последовательностью, а второй возвращает результат или публикует событие. Взаимный вызов «на время» редко остаётся временным, потому что обе стороны начинают рассчитывать на детали другой.

\n
СимптомПричинаПроверкаДействие
Новый вызов проходит компиляцию, но его цель неяснаНет записи о сценарии и владельцеВыписать source, target.surface и инвариантОставить ссылку только после явного контракта
Consumer импортирует internalПубличная поверхность не покрывает потребностьСравнить импорт с опубликованными пакетами или интерфейсамиВернуть операцию владельцу или добавить узкий API
Два модуля вызывают друг другаНовая обратная связь добавлена без владельца процессаПостроить граф и найти циклВыбрать orchestration или event contract
Ссылка ведёт в неизвестный модульКарта зависимостей устарела или неполнаСверить имя с исходниками и конфигурацией модулейОстановить изменение до обновления карты
После переноса тесты зелёные, но граница снова открытаПроверка была только примером, без правилаЗапустить структурную проверку и отрицательный тестЗакрепить запрет на уровне сборки или тестового набора
\n
\"Петля
Учебная схема показывает порядок boundary review. Она не является отчётом CI и не доказывает наличие такой связи в конкретном проекте.
\n

Отрицательный путь важнее зелёной ветки

\n

Хорошая проверка должна отказать не только неизвестному consumer, но и почти правильной ссылке. Отклоните catalog → payments.api, если для неё нет сценария и разрешённого направления. Отклоните checkout → catalog.internal, даже если метод возвращает правильное значение. Отклоните повторную запись одной и той же связи, самозависимость и цикл. Иначе карта будет показывать только удобные случаи и пропустит именно тот импорт, который создаёт будущую связность.

\n

Учебная фикстура полезна для проверки формы рассуждения: фиксированный набор модулей, точный список связей, ожидаемые решения и отрицательные входы. Она не читает файлы проекта, не строит граф по настоящим импортам, не обращается к сети и не сообщает о состоянии production. В реальной проверке эти источники должны быть названы отдельно. Отсутствие скана означает только отсутствие скана, а не отсутствие нарушения.

\n

Порядок действий в настоящем ревью

\n
  1. Остановите обсуждение на конкретной ссылке. Укажите файл, consumer, owner, поверхность и сценарий. Формулировка «модули связаны» слишком широка для решения.
  2. Разделите факт и гипотезу. Реальный import подтвердите исходником или разрешённым анализатором. Не выдавайте учебную карту за evidence.
  3. Проверьте поверхность. Сопоставьте вызов с опубликованным API. Если consumer использует internal, решите, где должен жить инвариант.
  4. Проверьте направление. Добавьте стрелку в карту и найдите обратный путь. При цикле назначьте orchestration или сформулируйте событие.
  5. Выберите малое обратимое изменение. Перенесите один вызов, добавьте узкий адаптер или ограничьте API. Зафиксируйте, как удалить временное решение.
  6. Закрепите правило. Добавьте структурный тест, проверку модульной схемы или иной автоматический сигнал. Отдельно проверьте запрещённый импорт.
  7. Запишите решение. Оставьте владельца, сценарий, разрешённое направление, исключение, дату пересмотра и подтверждённый источник факта.
\n

Ограничения

\n

Граф зависимостей не описывает транзакции, задержки, права, объём данных и корректность бизнес-правил. Допустимая стрелка может вести к слишком тяжёлому запросу. Отсутствие цикла не гарантирует слабую связанность. Архитектурное правило не заменяет тесты API, проверку схемы данных и наблюдение за ошибками.

\n

Не всякая обратная связь требует немедленного выделения сервиса. В монолите можно оставить один процесс внутри владельца, передать команду через устойчивый контракт или использовать доменное событие. Но исключение должно иметь сценарий, owner и срок пересмотра. Если эти поля нельзя заполнить, граница ещё не согласована.

\n

Инструмент тоже имеет предел. Spring Modulith умеет вывести модель application modules и проверять нарушения, но он проверяет заданные правила, а не принимает за команду решение о владении доменом. ArchUnit и аналогичные средства помогают закрепить зависимости на уровне кода. Они не объясняют, какой модуль должен владеть инвариантом. Это решение остаётся частью design review.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда для каждой новой стрелки существует запись source → target.surface → scenario → owner; поверхность не раскрывает внутренность; граф не содержит нового неразобранного цикла; отрицательный тест отклоняет запрещённый импорт; а реальный факт отделен от учебной модели. Reviewer может повторить проверку по файлу и получить тот же вывод. Если для согласия нужно помнить устную договорённость, граница ещё не готова.

\n

Такой критерий не обещает идеальную архитектуру. Он снижает стоимость следующего изменения: команда видит владельца, знает допустимое направление и получает сигнал при нарушении. Для модульного монолита этого достаточно, чтобы превращать спор о папках в короткую проверку контракта.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/140.json b/editorial/agent-rewrites/140.json new file mode 100644 index 0000000..8b3fcb9 --- /dev/null +++ b/editorial/agent-rewrites/140.json @@ -0,0 +1,7 @@ +{ + "index": 140, + "slug": "editorial-2024-02-mechanism-modular-monolith", + "title": "Модульный монолит: как удержать границы до распила на сервисы", + "excerpt": "Папки по доменам не защищают код от скрытых связей. Разбираем публичную поверхность модуля, разрешённые направления, цикл зависимостей и проверку, которую можно встроить в обычный review.", + "contentHtml": "

В монолите проблема часто начинается с маленького импорта. Код оформления заказа берёт внутренний formatter каталога, потому что нужный API ещё не готов. Потом платежи вызывают helper оформления заказа, а общий пакет получает третий набор исключений. Сборка проходит. Папки по-прежнему выглядят как отдельные домены.

\n

Симптом появляется позже: изменение внутреннего типа каталога требует искать потребителей в заказах и платежах. Ревьюер не может ответить, какой вызов разрешён, а какой только терпят ради срока. Цена ошибки — скрытый контракт. Он увеличивает область каждого изменения, усложняет откат и делает будущий перенос модуля дороже.

\n

Тезис простой: модульный монолит держится не на глубине каталогов, а на явном договоре. Для каждой связи нужно назвать потребителя, владельца и поверхность доступа: consumer → owner.publicApi. Отдельно нужно перечислить разрешённые направления. Тогда правило можно обсуждать по конкретному вызову, а не по впечатлению от дерева файлов.

\n

Что именно считается границей

\n

Модуль владеет смыслом операции, своими данными и публичным входом. Публичный вход не равен каждому символу с модификатором public. Это обещание для другого модуля. Им может быть команда, запрос, событие, порт или функция. Вход должен выражать потребность, а не раскрывать внутреннее хранение.

\n

В учебной модели есть четыре модуля: catalog, checkout, payments и notifications. У каждого есть поверхность *.api и внутренняя часть *.internal. Разрешены только три связи: checkout → catalog.api, checkout → payments.api и payments → notifications.api. Это пример формы правила, а не описание реальной системы.

\n
Граница читается по четырём вопросам
ВопросПример ответаЗачем он нужен
Кто вызывает?checkoutФиксирует потребителя и его сценарий
Кто владеет смыслом?catalogНазначает ответственность за изменение контракта
Через что вызывают?catalog.apiНе даёт подменить API внутренним типом
Разрешено ли направление?checkout → catalogОстанавливает случайные обратные связи
\n

Одна стрелка без поверхности слишком широка. Запись «checkout зависит от catalog» допускает и запрос карточки, и чтение репозитория, и вызов приватного форматтера. Запись checkout → catalog.api задаёт проверяемый объект. Если потребителю нужен новый смысл, владелец решает, добавлять ли узкий метод, событие или оставить операцию внутри своего модуля.

\n
Схема разрешённых направлений модульного монолита: checkout вызывает catalog.api и payments.api, payments вызывает notifications.api, обращение к catalog.internal запрещено
Учебная схема показывает направление и поверхность связи. Она не является результатом сканирования исходников и не описывает production-систему.
\n

Механизм: поверхность плюс направленный граф

\n

Сначала команда описывает карту модулей. Для каждого модуля она записывает имя, публичную поверхность, внутренние пакеты и владельца. Затем добавляет разрешённые рёбра. Проверка каждой ссылки отвечает на четыре вопроса: существует ли источник, существует ли получатель, совпадает ли поверхность с опубликованной и есть ли такое направление в карте.

\n

Направление нужно хранить отдельно от физического пути. В одном языке internal-пакет можно закрыть средствами компилятора, в другом останется только соглашение и архитектурный тест. Оба слоя полезны. Видимость защищает от части ошибочных обращений, а карта объясняет, почему разрешён сам маршрут.

\n
const allowed = new Set(['checkout>catalog:catalog.api', 'checkout>payments:payments.api', 'payments>notifications:notifications.api']); function check(link) { if (link.surface !== link.to + '.api') return 'non-public surface'; return allowed.has(link.from + '>' + link.to + ':' + link.surface) ? 'allowed' : 'forbidden direction'; }
\n

Код выше — учебный пример проверки заранее описанной карты. Он не читает репозиторий, не строит граф импортов и не доказывает отсутствие нарушений в приложении. В настоящем проекте анализатор должен получить фактические ссылки из подходящего инструмента языка, сопоставить их с картой и сохранить результат проверки. Если такого анализа пока нет, честный результат — «карта описана, фактические импорты не проверены».

\n

Цикл проверяют на том же графе. Если карта разрешает checkout → payments, а затем добавляет payments → checkout, две области начинают знать друг о друге. Цикл не означает, что нужно немедленно выделить микросервис. Он означает, что не назван владелец процесса. Сначала уточняют orchestration, границу инварианта и направление обмена. Иногда помогает событие. Иногда — перенос операции к владельцу. Иногда — узкий контракт без обратного вызова.

\n

Симптом → причина → проверка → действие

\n
Практическая диагностика границы
СимптомПричинаПроверкаДействие
Потребитель импортирует catalog.internalAPI не выражает нужную операцию или деталь показалась удобнееСверить surface вызова со списком API и назвать сценарийВернуть операцию владельцу либо добавить узкий контракт
Появилась обратная стрелкаНе определён владелец процесса или смешаны ответственностиПостроить граф и найти циклВыбрать orchestration, событие или перенос операции
Все импортируют commonОбщий пакет стал обходом границыПроверить, кто владеет каждым типом и кто меняет егоРазделить контракты или вернуть код владельцу
API повторяет таблицы владельцаПубличная поверхность раскрывает реализациюПроверить, может ли владелец изменить хранение без consumerСузить данные до операции, результата или события
Тест зелёный, но импорт неизвестенПроверена только модель, а не исходный кодПроверить источник фактических ссылок и дату evidenceНе выдавать модель за аудит; добавить реальный анализ
\n

Порядок внедрения

\n
  1. Выберите один болезненный стык. Возьмите изменение, которое регулярно цепляет чужую внутренность. Не начинайте с перестройки всего монолита.
  2. Запишите потребность. Назовите consumer, ожидаемый результат и модуль-владелец. Если результат нельзя описать без внутреннего класса, граница ещё не сформулирована.
  3. Опишите поверхность. Оставьте минимальный вход: команду, запрос, событие или порт. Не публикуйте namespace целиком.
  4. Добавьте направление. Запишите from → to.surface и отдельно укажите запрещённую обратную связь. У каждого исключения должен быть владелец и дата пересмотра.
  5. Проверьте существующие ссылки. Используйте анализатор языка, правила сборки или архитектурный тест, который видит реальные импорты. Учебная карта сама по себе этого не делает.
  6. Переведите один вызов. Оставьте обратимый путь, проверьте отсутствие старого потребителя и только потом удаляйте внутренний доступ.
  7. Закрепите правило. Добавьте проверку в место, где она запускается вместе с изменением кода. Документ без проверки быстро становится устным соглашением.
\n

Ограничения и отрицательный путь

\n

Граф границ не отвечает за транзакции, задержку, права доступа, размер payload, версионирование событий и качество данных. Разрешённая стрелка может вести к медленной операции. Запрещённая стрелка может стать оправданной после смены владельца. Поэтому зелёный статус архитектурной проверки не заменяет нагрузочный, security или интеграционный тест.

\n

Отрицательный путь нужно сохранять рядом с правилом. Вызов catalog.internal должен завершаться понятным отказом, а не молча проходить через исключение. Неизвестный модуль, дубликат связи и цикл тоже должны иметь отдельные сообщения. Если проверка пропускает пустую поверхность или принимает произвольный путь к файлу, она защищает только видимость, но не границу.

\n

Java Platform Module System даёт физический пример: именованный модуль объявляет экспортируемые пакеты и зависимости. Spring Modulith показывает похожую идею для Java/Spring: API модуля отделяется от внутренних пакетов и разрешённых зависимостей. Эти механизмы нельзя перенести в любой стек без изменений. Их полезный общий принцип уже достаточен: доступ должен быть назван, ограничен и проверяем.

\n

Критерий готовности

\n

Граница готова, если для каждого межмодульного вызова команда может показать четыре записи: сценарий потребителя, владельца смысла, точную публичную поверхность и разрешённое направление. Реальная проверка должна пройти по фактическим ссылкам и отдельно показать отрицательные случаи: internal-протечку, неизвестный модуль и цикл. Учебная модель может проверить только формулировку правила и обязана так себя называть.

\n

Если один из четырёх ответов отсутствует, не расширяйте API и не выделяйте сервис. Сначала уточните владельца или оставьте код локальным. Модульный монолит приносит пользу именно в этот момент: команда получает ясную границу и может менять внутренность без скрытых потребителей.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/141.json b/editorial/agent-rewrites/141.json new file mode 100644 index 0000000..bba7e9a --- /dev/null +++ b/editorial/agent-rewrites/141.json @@ -0,0 +1,7 @@ +{ + "index": 141, + "slug": "editorial-2024-02-practice-modular-monolith", + "title": "Модульный монолит: как сделать границы зависимостей проверяемыми", + "excerpt": "Папки не защищают модуль от чужих импортов. Разбираем публичную поверхность, разрешённые направления, цикл зависимостей и короткую проверку, которую можно встроить в тесты.", + "contentHtml": "

Симптом обычно выглядит безобидно: разработчик в модуле checkout добавляет импорт из catalog/internal, потому что нужный helper уже готов. Сборка проходит. Через несколько недель изменение внутреннего parser-а каталога требует искать потребителей в оплате и заказах. Команда больше не знает, какой код можно менять локально. Цена ошибки — скрытые регрессии, длинный review и рефакторинг, который нельзя выполнить по частям.

\n

Папка с названием домена не создаёт границу. Она помогает найти файлы, но не определяет право на импорт. Граница появляется только тогда, когда команда явно задаёт публичную поверхность модуля, владельца этой поверхности и допустимые направления зависимостей. После этого правило можно проверить на коде и отдельно проверить отрицательный путь: внутренний импорт и цикл должны ломать проверку.

\n

Тезис: модуль — это контракт, а не каталог

\n

У модуля есть две стороны. Первая — то, что он публикует: команда, запрос, тип или событие с понятным смыслом. Вторая — то, от чего он зависит. Если описана только первая сторона, API быстро превращается в транзит к чужим деталям. Если описана только вторая, команда видит список импортов, но не понимает, какие вызовы считаются устойчивыми.

\n

Для каждой связи полезно хранить тройку source → target.surface. Например, checkout → catalog.api означает, что checkout использует именно опубликованную поверхность каталога. Запись checkout → catalog слишком широка: она не отличает API от repository, внутреннего mapper-а и класса, который случайно объявили public.

\n

Учебный пример ниже не описывает реальный продукт и не сообщает о результатах в production. В нём четыре модуля: catalog владеет товарами, checkout собирает заказ, payments проводит оплату, notifications отправляет уведомления. Разрешены только три стрелки: checkout к API каталога, checkout к API платежей и payments к API уведомлений.

\n
Разрешённые связи в учебной модели
ОткудаКудаРешениеЧто это защищает
checkoutcatalog.apiразрешенозаказ получает товар через контракт каталога
checkoutpayments.apiразрешенозаказ не знает внутреннюю реализацию оплаты
paymentsnotifications.apiразрешеноуведомление вызывается через отдельную поверхность
любой модульчужой *.internalзапрещенодетали реализации остаются у владельца
catalogpayments.apiзапрещено в этой моделиновая стрелка требует сценария и владельца
\n
\"Матрица
Учебная матрица показывает направление связи и поверхность API. Она не получена сканированием репозитория и не доказывает устройство production-системы.
\n

Механизм границы

\n

Публичная поверхность должна выражать потребность потребителя, а не повторять внутреннюю структуру владельца. Если checkout нужен итоговый товар для расчёта цены, ему не нужен ProductEntity, JPA repository и набор внутренних преобразователей. Ему нужен узкий порт, например CatalogReader. Владелец может заменить хранение и parser, пока сохраняет смысл этого порта.

\n
/* Учебный пример. Это контракт модуля catalog, а не готовая production-модель. */\nexport type ProductQuote = {\n  sku: string;\n  price: number;\n  currency: string;\n};\n\nexport interface CatalogReader {\n  quote(sku: string): Promise<ProductQuote>;\n}\n\n// checkout импортирует только public surface:\nimport type { CatalogReader } from '../catalog/public';\n\n// Такой импорт нарушает границу:\nimport { ProductParser } from '../catalog/internal/ProductParser';
\n

Само слово public не решает архитектурную задачу. В обычном монолите разработчик часто может технически импортировать любой доступный символ. Поэтому правило состоит из двух уровней. Язык и модульная система задают физическую видимость, а архитектурный тест задаёт смысловое разрешение. Нельзя подменять одно другим.

\n

Направления должны образовывать ориентированный ацикличный граф. Цикл checkout → payments → checkout не всегда означает, что предметная модель неверна. Он означает, что текущий порядок владения не объяснён. Пока цикл существует, изменение одного модуля требует держать в голове другой, а изолированный тест и поэтапная миграция становятся дороже.

\n

Разорвать цикл можно несколькими способами. Сначала назовите операцию и её владельца. Если payments сообщает checkout о результате, событие может идти в одну сторону. Если оба модуля используют одинаковое правило, возможно, нужен небольшой тип без поведения. Если один модуль просит внутреннюю деталь другого, сначала спроектируйте порт по потребности. Пакет common не является решением сам по себе: без владельца он превращается в новую общую свалку.

\n

Симптом → причина → проверка → действие

\n
Диагностика нарушенной модульной границы
СимптомПричинаПроверкаДействие
Изменение внутреннего класса требует искать чужие вызовыПотребитель импортирует деталь вместо контрактаВыписать from, to и surface для импортаСформировать узкий API и перевести один вызов
Два модуля ссылаются друг на другаНе назван владелец операции или сообщенияПостроить граф прямых зависимостей и найти циклВыбрать владельца, событие или односторонний adapter
Все новые вызовы идут через sharedВременный helper получил неограниченную рольПроверить владельца, потребителей и срок исключенияОставить тип локальным либо вернуть поведение владельцу
Тест границ зелёный, но API отдаёт слишком многоСтруктурное правило приняли за проверку бизнес-контрактаСопоставить данные API с конкретным сценарием потребителяУточнить DTO, права, инварианты и отдельные тесты
Новая стрелка добавлена ради прохождения сборкиПравило не требует обоснования связиСпросить сценарий, владельца, альтернативу и цену связиОформить исключение с датой пересмотра или не добавлять импорт
\n

Проверка должна ловить отрицательный путь

\n

Минимальная проверка отвечает на четыре вопроса: существует ли названный модуль, существует ли его поверхность, разрешено ли направление и нет ли цикла. Отдельно проверяется запрет на internal. Если тест проверяет только разрешённые примеры, его можно случайно сломать так, что он начнёт принимать любой импорт.

\n
// Учебный псевдокод проверки политики.\nconst allowed = new Set([\n  'checkout->catalog:catalog.api',\n  'checkout->payments:payments.api',\n  'payments->notifications:notifications.api',\n]);\n\nfunction check(reference) {\n  if (reference.surface.endsWith('.internal')) return 'reject: internal';\n  const key = `${reference.from}->${reference.to}:${reference.surface}`;\n  return allowed.has(key) ? 'accept' : 'reject: direction';\n}\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.api' });\n// accept\n\ncheck({ from: 'checkout', to: 'catalog', surface: 'catalog.internal' });\n// reject: internal
\n

Этот фрагмент проверяет только заранее переданную политику. Он не читает файлы, не строит AST, не сканирует package graph и не доказывает отсутствие нарушений в конкретном репозитории. Для реального проекта нужен инструмент, который видит фактические зависимости исходного кода, а затем тот же инструмент должен быть подключён к обычной проверке проекта. Учебный псевдокод помогает проверить форму правила, но не заменяет такой анализ.

\n

Порядок внедрения

\n
  1. Выберите один болезненный стык. Возьмите участок, где изменение часто затрагивает чужой internal-код или где уже виден цикл. Не начинайте с переименования всего монолита.
  2. Назовите владельца. Запишите, какой модуль отвечает за данные, инварианты и смысл операции. Потребитель не становится владельцем только потому, что первым вызвал функцию.
  3. Опишите поверхность. Дайте API имя по потребности: CatalogReader, а не CatalogInternals. Перечислите, что остаётся закрытым.
  4. Зафиксируйте тройку связи. Для каждого межмодульного вызова укажите source → target.surface. Запрещённые направления запишите явно.
  5. Добавьте положительный и отрицательный тест. Разрешённая связь должна проходить. Internal-импорт, неизвестная поверхность и цикл должны получать понятный отказ.
  6. Переведите один вызов. Сначала замените один импорт на API. После этого проверьте, что старый internal-символ больше не нужен потребителю.
  7. Оформите исключение отдельно. Если временный обход неизбежен, укажите сценарий, владельца, срок пересмотра и способ удаления. Комментарий без проверки не создаёт границу.
\n

Ограничения

\n

Граф зависимостей не отвечает за качество API. Разрешённый вызов может быть медленным, возвращать лишние данные или нарушать бизнес-инвариант. Он также не решает транзакции, права доступа, владение таблицами, доставку событий и совместимость схем. Эти свойства требуют отдельных контрактов и тестов.

\n

Физическая модульность зависит от стека. Java Platform Module System умеет ограничивать экспорт пакетов, но многие приложения живут в обычном classpath. Spring Modulith предлагает проверку application modules, API-пакетов, циклов и явно разрешённых зависимостей, но это решение для Spring-стека. В TypeScript или другом языке понадобится другой анализатор. Переносить аннотации без переноса семантики бесполезно.

\n

Не всякая связь должна исчезнуть. Две области могут честно зависеть от общего справочного типа или от события. Важно назвать форму связи и её владельца. Если новая стрелка появляется только потому, что импорт проще, это сигнал остановиться. Если она нужна предметному сценарию, она должна попасть в карту и пройти тот же отрицательный путь.

\n

Критерий готовности

\n

Участок готов, когда команда может показать карту с владельцами и поверхностями, а проверка даёт четыре наблюдаемых результата: разрешённая связь проходит; импорт чужого internal отвергается; неизвестная стрелка отвергается; цикл получает отдельную ошибку. После перевода одного реального вызова потребитель больше не импортирует детали владельца. Если хотя бы один результат нельзя воспроизвести на коде, граница пока остаётся договорённостью на словах.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/142.json b/editorial/agent-rewrites/142.json new file mode 100644 index 0000000..c24c935 --- /dev/null +++ b/editorial/agent-rewrites/142.json @@ -0,0 +1,7 @@ +{ + "index": 142, + "slug": "editorial-2024-01-field-legacy-modernization", + "title": "Модернизация legacy-системы: как ограничить rollout и не перепутать rollback с восстановлением данных", + "excerpt": "Пошаговая схема замены одного участка legacy-системы: наблюдаемый симптом, decision gate, control, ограниченная волна и отдельная проверка обратимости данных.", + "contentHtml": "

В день переключения новый обработчик отвечает успешно, но команда не может быстро ответить на четыре вопроса: какой трафик он получил, с чем его сравнивать, кто остановит волну и что произойдёт с уже записанными данными. Обычно звучит: «включим на десять процентов, а если что — откатим». Процент не задаёт границу риска. Слово «откат» не объясняет, вернётся ли только маршрут или ещё и состояние системы.

\n

Цена ошибки — не только временная деградация. Новый путь может отправить письмо, создать платёж, изменить баланс или записать событие до того, как команда заметит проблему. Переключатель вернёт следующие запросы в старую систему, но уже созданный эффект останется. Поэтому модернизация legacy начинается с узкого шва и проверяемого решения, а не с общего обещания переписать всё.

\n

Тезис. Безопасная замена legacy — это последовательность границ: один маршрут, явный владелец, сравнимый control, ограниченное окно и отдельно описанный путь возврата. Rollout отвечает на вопрос «кому разрешено увидеть новый код». Rollback-route отвечает на вопрос «куда направить следующий запрос». Восстановление данных отвечает на другой вопрос: «что делать с эффектами, которые уже произошли».

\n

Сначала найти шов

\n

Шов — участок поведения, который можно отделить от остальной системы. Это может быть чтение каталога, расчёт тарифа или выдача профиля. Для первого шага лучше выбрать операцию с понятным входом, ограниченным числом потребителей и наблюдаемым результатом. Если действие меняет деньги, права или внешнюю запись, его граница должна включать эти эффекты, а не только HTTP-ответ.

\n

Прокси или адаптер принимает запрос и выбирает старый либо новый обработчик. Сначала он может передавать запрос в legacy без изменения. Затем команда добавляет новый обработчик за той же границей. Такой подход оставляет старый путь доступным, пока новый контракт не проверен. Маршрут должен быть перехватываемым, а состояние — достаточно понятным для сравнения.

\n
Что должно быть известно до ограниченного включения
ПолеПримерЗачем нужно
ШовGET /catalog/itemОграничивает область изменения
ВладелецКоманда каталогаНазначает решение
ControlТот же запрос через legacyДаёт точку сравнения
СигналКод ответа и времяЗадаёт наблюдаемый признак
ВозвратПредыдущая версия правилаПоказывает обратимое действие
ДанныеТолько чтениеОтделяет маршрут от восстановления
\n

Таблица не заменяет проверку поведения. Одинаковый статус 200 может скрывать другой набор полей, задержку или побочный эффект. Для каждого шва нужны valid, invalid и repeat-сценарии. Повтор особенно важен для операций с ключом идемпотентности: одинаковый запрос не должен создать второй эффект.

\n
\"Схема
Учебная схема показывает порядок решения, а не выполненный rollout. В ней нет реальных процентов трафика, метрик или доказательства успешного возврата.
\n

Механизм: gate, control и окно

\n

Decision gate должен проверять один класс риска. Gate шва проверяет область и владельца. Gate совместимости проверяет входы, ответы и эффекты. Gate доставки проверяет версию адаптера и правило маршрутизации. Gate наблюдения проверяет control, population, duration и источник сигнала. Gate возврата проверяет только обратимое действие. Статус одного gate не доказывает остальные.

\n

Ограниченная волна имеет смысл только рядом с control. Если новый путь обслуживает пользователей без скидок, а legacy — остальных, различие может объясняться составом аудитории. Если окно короче агрегации метрики, сигнал опоздает. Если оба пути используют общий кеш или базу, новый код способен изменить поведение старого. Такое наблюдение останавливает вывод «новая версия сломана», но не отменяет расследование.

\n

Учебный пример ниже показывает форму решения для операции чтения. Имена и значения вымышлены; пример не сообщает о реальном сервисе, трафике или измерении.

\n
const gate = {\n  seam: 'catalog.item.read',\n  owner: 'catalog-team',\n  population: 'tenant=demo',\n  control: 'legacy-v3',\n  candidate: 'adapter-v1',\n  duration: '15m',\n  signal: ['status_code', 'latency_ms', 'schema_diff'],\n  decision: 'manual_review',\n  rollbackRoute: 'route -> legacy-v3',\n  dataEffect: 'read-only',\n};\n\nif (gate.dataEffect !== 'read-only' && !reconciliationOwner) {\n  throw new Error('data boundary is unknown');\n}
\n

Последняя проверка намеренно блокирует операцию. Если новый путь пишет данные, одного правила маршрута недостаточно. Нужны идентификатор эффекта, журнал, владелец сверки и решение для повторного запроса. Когда условия неизвестны, безопасный результат — не расширять волну и оставить шов на legacy.

\n

Симптом → причина → проверка → действие

\n
Диагностика готовности к замене
СимптомПричинаПроверкаДействие
Обсуждают только процентПроцент приняли за стратегиюНазвать population, control, duration и signalОстановить включение
Оба пути вернули 200Сравнили транспорт, не поведениеПроверить поля, ошибки, время и эффектДобавить case и владельца
«Откат» означает выключение флагаМаршрут смешали с даннымиПеречислить записи и внешние вызовыОписать reconciliation или запретить запись
Сигнал нового пути хуже controlРазличается population или shared stateСопоставить запросы, окно и зависимостиПоставить волну на паузу
Неизвестно, кто вернёт маршрутНет владельца и prior stateПроверить возврат без пользовательского трафикаНе выдавать разрешение
\n

Порядок действий

\n
  1. Выберите один шов и запишите владельца, потребителей и границу состояния.
  2. Опишите legacy-контракт: успешный вход, отказ, повтор и побочный эффект.
  3. Поставьте адаптер перед старым обработчиком и проверьте исходный маршрут.
  4. Добавьте новый обработчик за той же границей. Начните с чтения или однозначно сверяемого эффекта.
  5. Определите control, population, duration, сигналы и источник каждого сигнала.
  6. Проверьте valid, invalid и repeat на одинаковых входах.
  7. Проведите ограниченную волну с ручным решением: расширить, остановить или вернуть маршрут.
  8. Отдельно подтвердите возврат маршрута и состояние данных.
  9. Расширяйте область только после разбора существенных расхождений. Unknown оставляйте на legacy.
\n

Почему rollback не исправляет данные

\n

Для read-only шва возврат маршрута обычно проще: следующий запрос снова идёт в известную версию. Но общий кеш, sticky session или изменённая схема могут связать пути. Нужно проверить, что старый обработчик принимает текущее состояние и что новый код не изменил его косвенно.

\n

Для write-операции новый обработчик мог создать заказ, отправить сообщение, вызвать платёжный шлюз или записать событие. Отключение адаптера остановит новые вызовы, но не отменит внешний эффект. Компенсация может быть невозможна, дублировать действие или потребовать бизнес-решения. Перед такой миграцией нужны record id, журнал состояния, владелец сверки и ответ для повторной доставки.

\n

Если команда не может назвать эти элементы, не пишите аварийный delete-скрипт. Сузьте первый шов до чтения, добавьте preview или оставьте запись в legacy. Отложенное изменение сохраняет управляемость. Быстрое переключение без границы данных переносит проблему в момент, когда исправление дороже.

\n

Ограничения и критерий готовности

\n

Canary не заменяет тесты. Небольшая доля трафика снижает область воздействия, но не доказывает полноту поведения. Synthetic нагрузка не показывает все состояния реальных пользователей. Общие базы, кеши, очереди и внешние провайдеры могут испортить независимость control и нового пути. Автоматический rollback полезен только там, где действие действительно обратимо.

\n

Универсального безопасного процента нет. Для системы, где каждый запрос меняет баланс, десять процентов могут быть слишком много. Для чтения сто процентов допустимы после проверки совместимости. Число выбирают после определения population, эффекта и времени обнаружения проблемы.

\n

Критерий готовности проверяем. Другой инженер без устного контекста может показать владельца; legacy и candidate версии; control; population и duration; сигналы и их источники; valid, invalid и repeat cases; действие возврата; список необратимых эффектов и владельца сверки. Команда может выполнить безопасное обратное переключение на тестовом контуре и увидеть, куда пойдут следующие запросы. Если пункт неизвестен, готовность не доказана и волну не расширяют.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/143.json b/editorial/agent-rewrites/143.json new file mode 100644 index 0000000..2706472 --- /dev/null +++ b/editorial/agent-rewrites/143.json @@ -0,0 +1,7 @@ +{ + "index": 143, + "slug": "editorial-2024-01-mechanism-legacy-modernization", + "title": "Модернизация legacy без ложной совместимости: как проверять поведение", + "excerpt": "Одинаковый HTTP-ответ не доказывает совместимость старой и новой реализации. Разбираем контракт, побочные эффекты, повтор запроса и минимальную проверку перед переключением маршрута.", + "contentHtml": "

После замены старого обработчика команда получает знакомый симптом: новый endpoint отвечает тем же статусом и похожим JSON, но клиент ломается на повторном запросе. В одном случае validation-ошибка превращается в 500. В другом preview начинает публиковать событие. В третьем повтор той же операции создаёт вторую запись. Цена ошибки — не только откат релиза. Команда уже меняет данные, отправляет уведомления или теряет доверие к ответу, а по snapshot это не видно.

\n

Проблема начинается раньше кода. Команда называет совместимостью сходство сериализованного ответа. Но legacy-контракт включает больше: вход, категорию ошибки, обязательные поля, момент чтения состояния, запись, публикацию и правила повтора. Если эти свойства не названы до миграции, проверка после переключения превращается в спор о том, что считать регрессией.

\n

Тезис: переносить нужно договор, а не форму ответа

\n

Модернизация legacy безопаснее, когда команда выбирает один наблюдаемый шов и описывает его до замены. Шов — это конкретная операция между потребителем и старой реализацией. У него есть вход, выход, состояние, побочный эффект и владелец решения. Новый adapter может менять язык, библиотеку и внутреннюю структуру. Он не должен молча менять свойства, на которые опирается consumer.

\n

Parity здесь означает не побайтное равенство. Она означает совпадение заранее выбранных инвариантов. Для read-only preview важны статус, обязательные поля и отсутствие публикации. Для записи важны idempotency key, порядок эффекта и состояние после повтора. Для платежа добавляются денежная точность, авторизация и reconciliation. Один общий список проверок не подходит всем операциям.

\n

Механизм совместимости

\n

Разделите контракт на четыре слоя. Transport описывает метод, путь, статус и значимые заголовки. Payload описывает типы, обязательные поля, значение null и отсутствие поля. Effect описывает запись, публикацию, очистку cache и запрет повторного действия. Time описывает, в каком состоянии читаются данные и что означает «тот же запрос»: тот же input, business key или idempotency key.

\n

OpenAPI помогает зафиксировать первый и часть второго слоя. Он делает видимыми paths, operations, схемы и ответы. Но одинаковая схема не говорит, записал ли обработчик событие, когда он прочитал баланс и что произойдёт при повторе. Поэтому schema — это граница формы, а не сертификат поведенческой совместимости.

\n
type CompatibilityCase = {\n  name: 'valid' | 'invalid' | 'repeat';\n  precondition: string;\n  request: unknown;\n  expected: {\n    statusCategory: string;\n    fields: string[];\n    effect: 'none' | 'one' | 'same-key-no-duplicate';\n  };\n  evidence: string;\n};\n\nconst repeatCase: CompatibilityCase = {\n  name: 'repeat',\n  precondition: 'same idempotency key, known initial state',\n  request: { amount: 1000, idempotencyKey: 'case-42' },\n  expected: {\n    statusCategory: 'success-or-replayed-success',\n    fields: ['operationId', 'status'],\n    effect: 'same-key-no-duplicate'\n  },\n  evidence: 'response plus effect log in the test environment'\n};
\n

Это учебный TypeScript-пример. Он показывает форму записи, но не вызывает endpoint и не доказывает, что повтор безопасен. Значение поля evidence должно ссылаться на реально доступный журнал, тестовую базу или другой наблюдаемый источник. Если источник ещё не подключён, результат нельзя помечать как parity passed.

\n
Диагностика совместимости одного legacy-шва
СимптомПричинаПроверкаДействие
Одинаковый JSON, но повтор создаёт записьСравнили payload и не проверили effectПовторить запрос с тем же ключом и проверить журнал эффектаДобавить idempotency rule или оставить операцию на legacy
Невалидный ввод получил 500Новая реализация потеряла категорию ошибкиСопоставить status category и поля ошибки для invalid caseСохранить внешний error contract либо версионировать API
Результаты расходятся только утромРазличается время чтения состояния или часовой поясПовторить case на фиксированном состоянии и записать timestampЯвно определить time boundary и источник времени
Новый ответ содержит дополнительное полеСовместимое расширение принято без проверки consumerПроверить парсеры старых клиентов и правило unknown fieldsОставить поле optional или подготовить migration path
Команда не знает, какое отличие важноУ контракта нет владельца и инвариантовНазначить owner и классифицировать каждое расхождениеОстановить расширение шва до решения владельца
\n
\"Матрица
Матрица помогает разделить свойства операции. Она не содержит ответы реальных сервисов и не заменяет parity-проверку в доступном контуре.
\n

Как читать различия

\n

Сначала присвойте различию класс. Cosmetic — изменение, от которого не зависит consumer: например, пробел в сообщении или порядок незначимых ключей. Compatible extension — дополнительное optional-поле, которое старый клиент по договору игнорирует. Behavioral mismatch — другой статус, обязательное поле, значение, момент чтения или эффект. Unknown — различие обнаружено, но его значение ещё не установлено.

\n

Unknown нельзя считать совместимым по умолчанию. Если неизвестное поле влияет на сумму, доступ, уведомление или повтор, новый путь не готов. Нужна проверка или решение владельца. Иногда старое поведение выглядит как ошибка, но на него уже опирается клиент. Тогда есть три честных варианта: временно сохранить поведение, выпустить новый контракт с миграцией или отложить замену. Нельзя назвать bugfix совместимостью только потому, что он кажется правильнее.

\n

Три минимальных case

\n

Начните с трёх случаев, но не принимайте их за полное покрытие. Valid показывает основной результат и обязательные поля. Invalid проверяет категорию отказа и отсутствие недопустимого эффекта. Repeat проверяет одинаковый ключ или вход и ожидаемое действие. Для каждого случая запишите precondition, request, expected result, место наблюдения и owner.

\n

Если операция зависит от внешнего provider, курса, очереди или времени, это часть case. Зафиксируйте состояние, которое можно воспроизвести, или пометьте свойство неизвестным. Snapshot ответа подходит для payload. Для effect он недостаточен: два пути могут вернуть одинаковый JSON, но только один отправить сообщение. Здесь нужен журнал, счётчик, тестовая запись или иной источник, который действительно видит эффект.

\n

Порядок проверки перед переключением

\n
  1. Выберите одну операцию и назовите потребителя. Не начинайте с «переписать модуль».
  2. Запишите transport, payload, effect и time. Отдельно отметьте свойства, которые не входят в обещание.
  3. Назначьте владельца контракта. Он решает, что сохранять, что версионировать и что считать неизвестным.
  4. Подготовьте valid, invalid и repeat case с фиксированными precondition и expected result.
  5. Проверьте старый путь и сохраните evidence в одном сопоставимом формате. Не сравнивайте результаты из разных состояний.
  6. Запустите новый путь на тех же case. Для effect используйте источник, который видит запись или публикацию, а не только response body.
  7. Классифицируйте каждое отличие. Behavioral mismatch блокирует расширение маршрута; compatible extension требует проверки consumer.
  8. Определите обратное действие. Возврат маршрута к legacy не означает восстановление уже изменённых данных.
  9. Переключайте только выбранный шов. Остальной трафик остаётся на известном пути до отдельного решения.
\n

Отрицательный путь: когда модернизацию нужно остановить

\n

Не каждый шов следует переносить первым. Остановите замену, если неизвестен владелец эффекта, нельзя получить начальное состояние, consumer скрыт, а различие касается денег, доступа или публикации. Остановите её также, если rollback возвращает маршрут, но не объясняет судьбу уже созданных данных. В таком случае проблема не в недостатке тестов. Сначала нужна граница данных и решение о reconciliation.

\n

Не пытайтесь закрыть неизвестность большим snapshot или процентом «покрытия parity». Одно число смешивает критичный платёж и косметическую подпись. Полезнее список незакрытых классов: time-dependent, effectful, external-provider, access-denied. Для каждого выберите действие: проверить следующим, оставить на legacy или изменить контракт с версией.

\n

Ограничения и критерий готовности

\n

OpenAPI описывает интерфейс, но не бизнес-смысл. Три case задают стартовую границу, но не покрывают всю систему. Учебный код выше не читает legacy, не запускает трафик, не сравнивает базу и не измеряет производительность. Поэтому статья не утверждает сохранение поведения какой-либо production-системы. Реальная готовность требует evidence из конкретного тестового или staging-контура.

\n

Шов готов к ограниченному переключению, когда owner назван, четыре слоя контракта заполнены, valid/invalid/repeat воспроизводимы, каждое обязательное различие классифицировано, effect наблюдаем, а возврат маршрута проверен отдельно от восстановления данных. Критический unknown должен отсутствовать или иметь явно принятое решение оставить операцию на legacy. Только тогда команда может объяснить, что именно она перенесла и какую цену изменения согласовала.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/144.json b/editorial/agent-rewrites/144.json new file mode 100644 index 0000000..8f7702f --- /dev/null +++ b/editorial/agent-rewrites/144.json @@ -0,0 +1,7 @@ +{ + "index": 144, + "slug": "editorial-2024-01-practice-legacy-modernization", + "title": "Модернизация legacy-системы: как выбрать безопасный шов", + "excerpt": "Полное переписывание редко начинается с доказанной границы. Разбираем, как выделить одну операцию, описать её поведение, подключить новый путь через адаптер и не спутать возврат маршрута с восстановлением данных.", + "contentHtml": "

Симптом заметен по backlog: задача называется «переписать расчёт заказа», но не содержит одного входа и одного результата. Внутри старого модуля смешаны HTTP-обработчик, скидки, запись статуса, письмо и вызовы соседних систем. Команда создаёт новый сервис, а через несколько недель не знает, какая часть поведения уже перенесена. На переключении обнаруживаются редкие правила и побочные эффекты. Цена ошибки — задержка релиза, двойная запись, потерянное письмо или откат, который меняет маршрут, но не возвращает данные.

\n

Безопасная модернизация начинается не с новой технологии. Она начинается с измеримого шва. Шов — это одна операция с названным потребителем, допустимым входом, наблюдаемым результатом, владельцем состояния и понятным способом остановки. Он не обещает сохранить всю систему. Он ограничивает первый риск так, чтобы расхождение можно было увидеть и разобрать.

\n

Что именно нужно выделить

\n

Папка, класс или новый микросервис сами по себе не образуют границу. Граница появляется там, где можно задать проверяемый контракт. Для HTTP это может быть один endpoint. Для очереди — один тип сообщения и ключ повторной обработки. Для интерфейса — одно действие пользователя и один command.

\n

Опишите шов пятью строками: consumer, input, response, effect и owner. Consumer показывает, кто вызывает операцию. Input фиксирует обязательные поля, форматы и повтор. Response описывает статус и значимые поля. Effect перечисляет запись, сообщение, инвалидацию кеша или отсутствие изменения. Owner принимает решение при расхождении. Неизвестный эффект помечайте как unknown. Не заменяйте его предположением о parity.

\n

Широкая формулировка вроде «вся корзина» скрывает слишком много решений. Сузьте её до preview или confirm. Preview обычно легче сделать без изменения состояния. Confirm имеет побочные эффекты и требует отдельного контракта повторов, ошибок и идемпотентности. Эти операции могут использовать общие данные, но не должны попадать в один первый rollout только потому, что так выглядит удобнее.

\n

Учебный пример: quote и confirm

\n

Ниже — ограниченный учебный пример. Он не описывает конкретную production-систему и не доказывает совместимость с настоящим legacy-кодом. Пусть старый модуль рассчитывает цену заказа по запросу POST /quote. Клиент ожидает сумму, срок действия предложения и код ошибки. При POST /confirm модуль резервирует товар и отправляет событие в очередь. Начнём только с /quote: у него нет записи заказа, а результат можно сравнить до переключения.

\n
type QuoteInput = {\n  productId: string\n  quantity: number\n  customerTier?: 'base' | 'plus'\n}\n\ntype QuoteResult =\n  | { status: 'ok'; total: number; expiresAt: string }\n  | { status: 'rejected'; code: 'INVALID_QUANTITY' | 'NOT_AVAILABLE' }\n\nfunction routeQuote(input: QuoteInput, mode: 'legacy' | 'candidate') {\n  if (mode === 'candidate') return modernQuoteAdapter(input)\n  return legacyQuote(input)\n}
\n

Код показывает форму границы, а не готовую реализацию. Адаптер должен преобразовать вход в формат нового пути и вернуть объявленную форму ответа. Он не должен заодно писать в общую базу, отправлять письмо или вызывать confirm. Если для расчёта ему нужен скрытый глобальный флаг или побочный вызов, это факт для карты зависимости. Его нельзя прятать за универсальным gateway.

\n

Для /quote сравните не все байты ответа, а заранее названные инварианты: статус, итоговую сумму, срок действия и код отказа. Проверьте округление, отсутствие товара, нулевое и отрицательное количество, повторный запрос и тайм-аут зависимости. Учебные данные должны быть явно ограничены. Они показывают, как составить проверку; они не заменяют ответы старого модуля, историю инцидентов и реальные ограничения среды.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
«Перенесём весь расчёт»Единицей работы стала архитектура, а не операцияНазвать один consumer, input, response и effectСузить шов до quote или отдельного варианта результата
Зелёный тест локальноТест не знает скрытые вызовы и правила legacyСверить valid, invalid и repeat cases с источником поведенияОставить тест учебным и собрать отдельное evidence
Новый сервис копирует схемуСхема не фиксирует порядок эффектов и повторПроверить статус, идемпотентность, ошибки и время ответаДобавить compatibility record или уменьшить scope
Нет владельца маршрутаНекому принять решение при расхожденииНазначить owner решения и owner состоянияНе включать новый путь до явного решения
Rollback означает «вернуться назад»Маршрут смешан с уже изменёнными даннымиРазделить route return, provider effect и data recoveryОписать только обратимое действие; остальное считать отдельной работой
\n
\"Схема
Шов удерживает решение о маршруте на границе операции. Иллюстрация учебная: она не показывает реальный трафик, базу или подтверждённую совместимость.
\n

Почему нужен маршрутизатор

\n

Маршрутизатор полезен не названием паттерна, а местом принятия решения. В нём видны режимы legacy-only, ограниченное предложение нового пути и возврат к legacy. Переключатель должен жить на границе операции. Если флаг разбросан по внутренним вызовам, один запрос может частично пройти по старому пути и частично по новому. Тогда сравнение теряет смысл.

\n

Постепенное вытеснение не означает автоматическую безопасность. AWS описывает strangler fig как способ постепенно заменять отдельную функциональность работающего монолита. Это снижает размер изменения, но не проверяет бизнес-смысл полей и не отменяет работу с состоянием. Для каждой операции всё равно нужны owner, наблюдаемый сигнал, окно проверки и правило остановки.

\n

Порядок действий

\n
  1. Зафиксируйте наблюдаемый симптом и цену ошибки. Укажите, что может раздвоиться: ответ, запись, сообщение или внешний вызов.
  2. Выберите одну операцию с узкой границей. Запишите consumer, input, response, effect и owner. Если effect неизвестен, оставьте его unknown.
  3. Составьте таблицу valid, invalid и repeat cases. Для каждого случая укажите источник поведения: код, тест, лог, трассу или ручное подтверждение.
  4. Оставьте legacy основным путём и создайте адаптер нового пути. Адаптер не меняет побочные эффекты, которые не входят в контракт.
  5. Проверьте новый путь на ограниченных данных. Сравните только объявленные инварианты и отдельно отмечайте расхождение, которое требует сужения шва.
  6. Опишите ручное решение о включении. Назовите сигнал, период наблюдения, условие остановки и человека, который принимает решение.
  7. Подготовьте возврат маршрута к известной legacy-конфигурации. Отдельно запишите, что делать с данными и внешними эффектами, которые уже нельзя отменить.
  8. Расширяйте границу только после разбора расхождений. Если новое поведение требует общего состояния, сначала оформите этот state boundary отдельным швом.
\n

Отрицательный путь важнее красивого ответа

\n

Совместимость часто ломается не на успешном запросе. Старый код может округлять сумму после скидки, считать повтор безопасным или возвращать особый код при отсутствии товара. Новый сервис легко выдаёт правдоподобный 200, но записывает другое значение. Поэтому проверка должна начинаться с отказов, тайм-аутов и повторов.

\n

У операций с состоянием есть дополнительное ограничение. Возврат маршрута не удаляет созданную запись, не отменяет платёж и не отзывает сообщение у внешнего провайдера. Для такого эффекта нужны идентификатор операции, владелец сверки и отдельное правило компенсации. Если компенсация не доказана, не называйте rollout обратимым. Выберите read-only или preview-шов либо оставьте эффект у legacy.

\n

Ограничение касается и данных сравнения. Synthetic-пример, мок или локальная база проверяют форму адаптера. Они не дают оснований заявлять сохранённое поведение реального модуля. Не выдавайте зелёный тест за результат трафика и не переносите вывод с одной популяции на другую. Если новый путь видит только простые заказы, он ещё не проверен на скидки, возвраты и повторные запросы.

\n

Проверяемый критерий готовности

\n

Первый шов готов к ограниченному рассмотрению, если другой инженер может без устного контекста показать: один вход, один ожидаемый результат, список значимых эффектов, владельца решения, набор valid/invalid/repeat cases, источник каждого факта и известный legacy-маршрут. Для маршрута есть проверяемый возврат. Для необратимых данных отдельно названо, что возврат не покрывает.

\n

Если хотя бы один из этих пунктов неизвестен, решение не провалилось. Оно ещё не достигло границы, на которой безопасно менять путь. Сузьте операцию, соберите недостающее доказательство или оставьте legacy владельцем. Готовность здесь означает не «новый сервис написан», а «расхождение можно обнаружить, остановить и объяснить».

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/145.json b/editorial/agent-rewrites/145.json new file mode 100644 index 0000000..91183bd --- /dev/null +++ b/editorial/agent-rewrites/145.json @@ -0,0 +1,7 @@ +{ + "index": 145, + "slug": "editorial-2023-12-field-security-audit", + "title": "Аудит веб-проекта: как превратить список рисков в безопасный порядок исправлений", + "excerpt": "Высокий score не говорит, что менять первым. Разбираем security-triage через evidence, границы системы, владельца, обратимый шаг и проверяемый критерий результата.", + "contentHtml": "

После аудита команда часто получает длинный список пунктов. В одном есть версия зависимости. В другом — вопрос к авторизации. В третьем неясно, кто выдаёт загруженный файл. Заголовки звучат одинаково тревожно, но доказательства различаются. Если первым исправить самый громкий пункт, можно потратить релиз на косметическую правку и оставить публичный путь без владельца. Поспешное изменение может сломать рабочий сценарий. Цена ошибки — простой пользователей, потеря данных или новый обход защиты.

\n

Безопасный triage начинается не со score. Он отвечает на четыре вопроса: что наблюдается, какая граница затронута, кто подтверждает контракт и какой следующий шаг можно отменить. Severity помогает описывать риск. Она не назначает владельца, не даёт разрешение на проверку и не доказывает наличие уязвимости.

\n

Тезис: порядок исправлений строит evidence

\n

Разделите карточку риска на пять частей: evidence, exposure, owner, reversibility и decision. Evidence связывает утверждение с источником. Exposure показывает путь от внешнего входа к данным или действию. Owner подтверждает границу и принимает изменение. Reversibility описывает возврат. Decision объясняет, почему пункт идёт сейчас.

\n

Запись «проверить границу авторизации API» — вопрос. Запись «любой пользователь читает чужой заказ» — утверждение, которому нужны воспроизводимый сценарий, разрешённая среда и зафиксированный результат. Пока этих условий нет, карточка имеет статус evidence gap. Нельзя поднимать её до подтверждённой уязвимости только потому, что она выглядит правдоподобно.

\n

Как score вводит в заблуждение

\n

CVSS описывает характеристики уязвимости и помогает сравнивать техническую тяжесть. Но score не видит карту продукта. Он не знает, какой путь критичен для бизнеса, согласован ли тест, принадлежит ли endpoint вашей команде и что произойдёт после изменения. Поэтому высокий Base score может ждать уточнения, а менее громкий пункт с неизвестной публичной границей — получить первый безопасный gate.

\n

Порядок должен быть объясним одним предложением: «Сначала подтверждаем владельца и ожидаемый отказ на публичной границе доступа, затем согласуем контракт identity-провайдера, после этого меняем выдачу файла с готовым rollback». Если такую фразу нельзя составить, список смешивает проверку фактов, согласование scope и разработку.

\n
СимптомПричинаПроверкаДействие
Самый высокий score всегда первыйSeverity подменяет контекстЗапросить claim, источник и ограничениеОбосновать порядок evidence и exposure
Есть домен, но нет границыАктив и third-party путь смешаныНарисовать один пользовательский маршрутУбрать неподтверждённый участок
«Добавим проверку доступа» без тестаРешение опередило критерийНазвать ожидаемый allow и denyНаписать validation до кода
Rollback означает «откатить»Не названы версия и ответственныйПроверить обратное действиеНазначить owner и stop condition
security.txt считают разрешениемDisclosure смешали с authorizationПроверить объект, метод и времяБез согласования ограничиться инвентаризацией
\n
\"Схема
Маршрут показывает порядок вопросов. Он не оценивает реальный риск, не находит уязвимость и не запускает remediation.
\n

Минимальная запись, которая выдерживает проверку

\n

Карточка не обязана быть большой. Поле claim описывает факт или вопрос, а не решение. source указывает журнал, запрос, конфигурацию, владельца или документ. status различает hypothesis, observed и confirmed. В limit записывают то, чего проверка не показывает.

\n
const card = {\n  id: 'SEC-042',\n  claim: 'роль reader получает чужой заказ',\n  scope: 'orders-api / GET /orders/:id',\n  owner: 'orders-team',\n  status: 'hypothesis',\n  source: 'reproduction-2026-08-02-01',\n  expected: { allow: 'свой заказ', deny: 'чужой заказ: 403' },\n  limit: 'тестовая среда; production не проверялся',\n  next: 'согласовать сценарий с владельцем API',\n  rollback: 'изменений в системе нет'\n};\n\nif (card.status === 'hypothesis') {\n  console.log('Не называть карточку подтверждённой уязвимостью');\n}
\n

Пример учебный. Он не отправляет запросы, не получает токены и не доказывает поведение API. Его задача — показать форму записи. В настоящем отчёте идентификатор источника должен вести к разрешённому материалу. Не вставляйте пароль, токен, персональные данные или полный ответ, если для вывода достаточно хеша, фрагмента и защищённой ссылки.

\n

Проверяемый маршрут от гипотезы к изменению

\n
  1. Опишите симптом. Запишите сценарий, время и наблюдаемый результат. Не называйте сигнал критической уязвимостью заранее.
  2. Назовите границу. Укажите приложение, endpoint, роль, данные и переход к внешней зависимости. Домен не является картой продукта.
  3. Проверьте разрешение. Зафиксируйте объект, среду, окно времени, допустимый метод, запретные действия, контакт и условие остановки. Документ disclosure не заменяет эту запись.
  4. Разделите allow и deny. Для авторизации назовите разрешённый результат и отказ. Для файла опишите приём, серверное имя, хранение и проверку доступа при выдаче.
  5. Назначьте владельца. Он подтверждает контракт и принимает решение.
  6. Выберите обратимый gate. Сначала добавьте чтение, тест, флаг или review контракта. Не меняйте необратимую схему, пока не закрыты evidence и rollback.
  7. Сформулируйте критерий. Назовите наблюдаемый результат, источник результата и момент проверки.
  8. Обновите карточку. Измените status, source, limit и rationale. Если evidence не подтвердился, верните hypothesis или закройте false positive с объяснением.
\n

Отрицательный путь важнее красивого отчёта

\n

Если scope не подтверждён, активная проверка останавливается. Не стоит проверять путь на домене, который может принадлежать подрядчику. Если владелец неизвестен, назначьте вопрос и сохраните evidence gap. Если тест требует необратимой миграции, сначала проведите отдельный review изменения. Если после фикса нет безопасного способа проверить отказ, решение не готово.

\n

Та же логика работает для upload delivery. Нельзя считать файл защищённым только потому, что форма требует входа. Нужно проверить границу выдачи, серверное имя, место хранения, содержимое и авторизацию на чтении. Нельзя считать файл уязвимым только из-за расширения в URL. Нужны наблюдаемый сценарий и согласованный метод.

\n

Ограничения метода

\n

Матрица triage не заменяет penetration test, threat model, code review или incident response. Она не вычисляет business impact и не обещает срок исправления. OWASP ASVS задаёт проверяемые требования, но не знает архитектуру проекта. CVSS помогает описать тяжесть, но не выбирает владельца и не создаёт разрешение. RFC 9116 описывает канал раскрытия, а не право тестировать домен.

\n

Источники могут обновляться. Поэтому в отчёте фиксируйте версию стандарта и идентификатор требования. Не пишите «проверено по OWASP» без названия документа, версии, scope и метода. Не выдавайте учебный объект, синтетическую запись или локальный тест за production evidence.

\n

Критерий готовности

\n

Карточка готова к исправлению, когда другой инженер без устного контекста может ответить на пять вопросов: какой факт или вопрос проверяется; какая граница входит в scope; кто разрешил и принимает решение; какой результат подтвердит или опровергнет гипотезу; как вернуть изменение и кто это сделает. У карточки есть источник, версия метода, ограничение и дата следующей проверки.

\n

Если хотя бы одного ответа нет, готов не fix, а следующий gate. Это проверяемый результат аудита. Он снижает риск ошибочной правки и сохраняет отрицательный путь: команда знает, когда остановиться.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/146.json b/editorial/agent-rewrites/146.json new file mode 100644 index 0000000..81cb676 --- /dev/null +++ b/editorial/agent-rewrites/146.json @@ -0,0 +1,7 @@ +{ + "index": 146, + "slug": "editorial-2023-12-mechanism-security-audit", + "title": "Аудит веб-проекта: как отличить evidence от гипотезы и разрешения", + "excerpt": "Отчёт об аудите полезен только тогда, когда каждое утверждение связано со scope, источником, разрешённым методом и проверяемым следующим шагом. Разбираем эту границу на учебном примере.", + "contentHtml": "

После аудита в документе появляется строка: «найдена уязвимость на границе авторизации». Но рядом нет точного актива, версии среды, описания наблюдения и подтверждения, что проверка была разрешена. Через день команда уже спорит не о факте, а о формулировке. Одни требуют срочного исправления, другие не могут повторить проверку. Цена ошибки — неверный приоритет и риск изменить рабочий путь без понимания причины. Если вывод окажется ложным, команда потратит время на защиту несуществующей проблемы. Если он окажется верным, слабая запись задержит исправление.

\n

Тезис. Аудит строится из четырёх раздельных объектов: scope называет участок системы, authorization ограничивает допустимые действия, evidence связывает утверждение с наблюдением, а decision назначает владельца и следующий шаг. Ни один объект не заменяет другой. Публичный URL не описывает всю систему. Ссылка на стандарт не даёт право на тест. Скриншот не доказывает воспроизводимость. Score не превращается сам в план исправления.

\n

Сначала зафиксируйте наблюдаемую границу

\n

Начните с одного пользовательского сценария: вход, смена адреса, оплата или загрузка документа. Опишите путь от действия пользователя до изменения состояния. Для каждого перехода запишите asset ID, границу, владельца, класс данных и вопрос проверки. Формулировка «проверить API» слишком широкая. Формулировка «подтвердить, кто принимает решение о доступе между браузером и API» уже задаёт предмет.

\n

Карта активов не утверждает, что защита работает или не работает. Она показывает, где команда ожидает контракт и кто может его объяснить. Это важное отрицательное свойство карты. Если для identity provider нет владельца, запись не должна превращаться в «низкий риск». Её статус — пробел в evidence и запрос на подтверждение.

\n
Четыре слоя записи аудита
СлойВопросМинимальная записьЧего она не доказывает
ScopeКакой участок обсуждаем?asset ID, boundary, ownerЧто участок уже проверен
AuthorizationЧто разрешено делать?метод, среда, окно, stop conditionЧто проверка дала положительный результат
EvidenceЧто именно наблюдалось?источник, время, версия, ограничениеЧто вывод переносится на всю систему
DecisionКто и что делает дальше?owner, reversible step, criterionЧто исправление уже выполнено
\n
\"Матрица
Схема показывает форму связи между карточками. Это не карта реальной сети, не результат сканирования и не подтверждение безопасности продукта.
\n

Механизм: от наблюдения к проверяемому выводу

\n

Evidence начинается с узкого утверждения. Например: «в согласованной тестовой среде запрос без нужной роли получил ответ 200 на маршруте X». Такая запись ещё не объясняет причину и не говорит, что production уязвим. Она фиксирует наблюдение, условия и границу вывода. Чтобы перейти от наблюдения к finding, нужен повторяемый метод, сопоставимый результат и право выполнить именно это действие.

\n

У карточки evidence должны быть простые поля. scopeId связывает материал с активом. observation описывает факт, а не интерпретацию. source указывает лог, запрос, тестовый отчёт или подтверждение владельца. status различает гипотезу, наблюдение и внешне подтверждённый результат. limit показывает, чего материал не покрывает. nextAction задаёт обратимый шаг. Если одного поля нет, вывод нужно сузить.

\n

Учебный пример ниже использует только фиксированные значения в памяти. Имена, идентификаторы и статусы вымышлены. Пример не открывает URL, не читает исходный код, логи или секреты, не запускает сканер и не имитирует право на тест. Он показывает контракт записи и отрицательную ветку.

\n
const card = {\n  scopeId: 'synthetic-asset-web-api',\n  observation: 'synthetic-role-check-needs-owner-confirmation',\n  source: 'synthetic-record-only',\n  status: 'synthetic-not-externally-verified',\n  limit: 'no-real-system-no-production-claim',\n  nextAction: 'synthetic-owner-review-before-change'\n};\n\nconst accepted =\n  card.status === 'synthetic-not-externally-verified' &&\n  card.limit.includes('no-real-system');\n\n// accepted === true означает только корректную форму учебной записи.\n// status = 'confirmed' здесь должно быть отклонено.
\n

Здесь результат true не означает, что найден контрольный дефект или подтверждена безопасность. Он означает только, что запись сохранила ограничение модели. Если заменить статус на confirmed, пример обязан остановиться: у него нет внешнего источника, разрешённой среды и воспроизводимого теста. Это отрицательный путь, а не декоративная оговорка. Он не даёт учебному коду сказать больше, чем он действительно знает.

\n

Разрешение не следует из доступности ресурса

\n

Веб-страница может быть доступна из интернета, но это не делает любое действие с ней допустимым. Перед активной проверкой нужны объект, среда, период, метод, ограничения нагрузки, запрещённые действия, контакт и условие остановки. Отдельно определите, как хранить и удалять полученные материалы. Если владелец не подтвердил границу, оставайтесь в режиме инвентаризации и обсуждения контракта.

\n

Файл security.txt помогает найти канал раскрытия, но не расширяет scope и не создаёт подразумеваемое разрешение на тест. Так же работают публичная документация, ссылка на программу поиска ошибок и доступность административной формы: это сведения о контакте или интерфейсе, а не согласование конкретного действия. Запишите их как источники контекста, не как поле authorization.

\n

Симптом → причина → проверка → действие

\n
Диагностическая таблица для первой проверки
СимптомПричинаПроверкаДействие
В отчёте есть finding, но нет asset IDДомен выдали за scopeПостроить путь сценария и назвать boundaryПеревести вывод в hypothesis до заполнения карты
Есть скриншот, но нет метода и версииМатериал отделили от условий наблюденияПроверить источник, время, среду и повторДобавить limit или снять статус подтверждения
Ссылка на security.txt записана как permissionКанал связи смешали с authorizationНайти отдельную запись о владельце и допустимом методеОстановить активную проверку до согласования
Высокий score требует «срочно чинить»Оценку тяжести приняли за decisionПроверить exposure, owner, обратимость и критерийНазначить triage и выбрать обратимый шаг
Интеграция не имеет владельцаThird-party boundary не вошла в картуЗапросить owner и контракт обмена даннымиЗафиксировать evidence gap, не объявлять zero risk
\n

Порядок действий

\n
  1. Выберите один ценный пользовательский сценарий и назовите его начало и конец.
  2. Составьте карту активов: ID, граница, владелец, класс данных и вопрос проверки.
  3. Разделите инвентаризацию и authorization. До активного действия запишите среду, окно, метод, запреты и stop condition.
  4. Для каждого утверждения создайте evidence card с наблюдением, источником, версией, статусом и ограничением.
  5. Проверьте отрицательные ветки: что происходит без владельца, без разрешения, без источника и при попытке расширить вывод.
  6. Переведите пробелы в hypothesis или evidence gap. Не называйте их finding только потому, что формулировка звучит уверенно.
  7. Назначьте обратимый следующий шаг и критерий закрытия. После проверки сохраните ссылку на результат и пересмотрите scope, если граница изменилась.
\n

Ограничения и путь возврата

\n

Такая модель не обнаруживает уязвимости сама. Она не заменяет ручное тестирование, автоматические проверки, threat modeling, анализ кода или договор с владельцем внешней системы. Стандарт помогает выбрать язык и метод, но не создаёт evidence. Карта не покрывает автоматически скрытые сервисы. Один успешный сценарий не доказывает безопасность остальных ролей и состояний.

\n

Если новое наблюдение опровергло карточку, не стирайте историю. Верните статус в hypothesis или evidence-gap, сохраните причину пересмотра, уберите вывод из списка подтверждённых findings и назначьте владельца следующего запроса. Это и есть rollback документа. Для реального проекта отдельно определите хранение, доступ и удаление материалов; учебный пример этого не решает.

\n

Критерий готовности проверяем. Для выбранного сценария существует versioned scope record. Каждая граница имеет владельца. Каждое активное действие связано с отдельным authorization record. Каждое утверждение связано с источником, наблюдаемым фактом, версией и limit. Отрицательные ветки останавливают неподтверждённый вывод. Следующий шаг имеет owner, reversible action и criterion. Если хотя бы одного поля нет, аудит не завершён: результатом остаётся конкретный evidence gap, а не общий статус «проверено».

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/147.json b/editorial/agent-rewrites/147.json new file mode 100644 index 0000000..a7cfb2c --- /dev/null +++ b/editorial/agent-rewrites/147.json @@ -0,0 +1,7 @@ +{ + "index": 147, + "slug": "editorial-2023-12-practice-security-audit", + "title": "Аудит веб-проекта начинается с границ: как не проверять чужую систему", + "excerpt": "Как зафиксировать активы, владельцев и разрешённые действия до проверки веб-проекта. С примером scope-карты, диагностической таблицей и критерием готовности.", + "contentHtml": "

В задаче написано: «провести аудит веб-проекта». У команды есть домен, несколько учётных записей и длинный список проверок. Через день один инженер проверяет форму входа, другой смотрит CDN, а внешний identity provider и хранилище файлов никто не включил в разговор. Команда получает аккуратный отчёт, но не знает, какую часть системы он покрывает.

\n

Цена ошибки двойная. Пропущенная граница оставляет риск без владельца. Лишняя проверка может задеть подрядчика, чужой контур или данные настоящих пользователей. Аудит нельзя начинать с запуска сканера. Сначала нужно описать, что проверяем, кто отвечает за объект, какие данные пересекают границу и какие действия разрешены.

\n

Тезис. Практический аудит веб-проекта — это управляемая цепочка: пользовательский путь → активы → границы → разрешение → проверяемое утверждение. Если один элемент неизвестен, результатом становится не «низкий риск», а зафиксированный пробел. Такой порядок сокращает область случайного воздействия и делает следующий шаг воспроизводимым.

\n

Карта активов не равна списку URL

\n

URL показывает точку входа. Он не показывает, где приложение принимает решение об авторизации, где хранится сессия, кто принимает файл и кто отдаёт его пользователю. Для первого scope лучше выбрать один ценный путь: вход, смену адреса, оплату или загрузку документа. Затем пройти по нему от действия пользователя до конечного эффекта.

\n

У каждого участка должна быть карточка. В ней достаточно пяти полей: идентификатор, тип актива, граница ответственности, владелец и класс данных. Шестое поле задаёт вопрос проверки. Формулировка «проверить API» слишком широкая. Формулировка «подтвердить, что API проверяет роль до чтения чужого документа» уже задаёт наблюдаемое условие.

\n
Минимальная карточка актива
ПолеУчебный примерЧто уточняетЧего не доказывает
Идентификаторweb-api-profileСвязывает карту и результат проверкиЧто такой актив существует в production
ГраницаБраузер → APIПоказывает переход ответственностиЧто переход защищён
ВладелецКоманда профиляДаёт адрес для уточнения контрактаЧто владелец разрешил любой тест
ДанныеИдентификатор пользователяПомогает оценить последствия ошибкиЧто поле действительно хранится именно здесь
ВопросРоль проверяется до чтенияЗадаёт проверяемое утверждениеЧто нарушение уже найдено
\n

Карта должна описывать переходы, а не только узлы. Для загрузки файла отдельно отметьте браузер, API, хранилище и выдачу. Один и тот же файл проходит разные решения: кто принимает байты, кто назначает серверное имя, кто определяет право чтения и кто выдаёт ответ. Если на карте есть только endpoint загрузки, доступ к уже сохранённому файлу выпадает из scope.

\n
\"Карта
Иллюстрация показывает учебную карту активов и границ. Она не содержит адресов реальной сети и не является разрешением на тестирование.
\n

Граница аудита состоит из двух решений

\n

Первое решение отвечает на вопрос «какой объект обсуждаем». Это scope: домен, приложение, API, хранилище, среда и пользовательский путь. Второе отвечает на вопрос «что разрешено делать». Это authorization: допустимый метод, время, учётная запись, нагрузка, запретные действия, контакт для остановки и владелец результата.

\n

Публичность объекта не создаёт разрешение. Доступная из браузера форма не разрешает перебор параметров. Ссылка на документацию не разрешает отправлять нагрузку. Файл security.txt задаёт канал для сообщений о проблемах, но не превращает любой запрос в согласованный тест. Если владелец и допустимое действие не названы, остановитесь на инвентаризации и чтении документации.

\n

Эти слои нужно хранить раздельно. Scope может быть согласован, а активная проверка ещё запрещена. Разрешение может действовать только для тестовой среды. Найденный симптом может быть воспроизводимым, но не доказывать причину. Раздельные записи не добавляют бюрократию. Они не дают одному факту подменить другой.

\n

Механизм: от актива к проверяемому утверждению

\n

Хороший вопрос аудита связывает действие, субъект и ресурс. Например: «пользователь с ролью reader не может получить профиль другого пользователя по изменённому идентификатору». Вопрос содержит субъект, объект и ожидаемый отказ. Его можно проверить в тестовой среде с двумя учебными аккаунтами. Он не требует сразу проверять все endpoints и роли.

\n

Код ниже только показывает форму записи. Имена, значения и адреса вымышлены. Пример не обращается к сети и не сообщает о состоянии какого-либо проекта. Функция блокирует проверку, если владелец не подтвердил границу или метод.

\n
const auditBoundary = {\n  asset: 'web-api-profile',\n  environment: 'staging',\n  owner: 'profile-team',\n  dataClass: 'user-profile',\n  method: 'two-account-read-check',\n  permission: 'approved-by-owner',\n  stopContact: 'on-call-profile',\n};\n\nfunction assertReady(boundary) {\n  const required = ['asset', 'environment', 'owner', 'method',\n    'permission', 'stopContact'];\n\n  for (const field of required) {\n    if (!boundary[field]) {\n      throw new Error(`audit boundary is incomplete: ${field}`);\n    }\n  }\n\n  if (boundary.permission !== 'approved-by-owner') {\n    throw new Error('active testing is not authorized');\n  }\n}\n\nassertReady(auditBoundary);
\n

В реальном проекте проверка должна также учитывать срок действия согласования, область аккаунтов, допустимую частоту запросов и способ удаления тестовых данных. Строка approved-by-owner не заменяет документ. Она показывает, что без явного разрешения код не должен переходить к активному действию.

\n

Симптом → причина → проверка → действие

\n
Диагностика незрелого scope
СимптомПричинаПроверкаДействие
В задаче указан только основной доменТочку входа приняли за системуПройти один пользовательский путь до хранилища и внешних сервисовДобавить активы и владельцев каждого перехода
Сканер нашёл десятки предупрежденийНет приоритета и вопроса проверкиДля каждого сигнала назвать актив, данные и воспроизводимый запросОтделить подтверждённый симптом от непроверенной гипотезы
Проверяющий не знает, где остановитьсяНе записаны лимит, окно и контактПопросить владельца подтвердить метод и стоп-условиеНе начинать активную проверку до согласования
Форма входа проверена, а файл выдан без обсужденияКарту строили по экрану, а не по даннымНайти путь файла от приёма до ответаДобавить границу выдачи и отдельный вопрос о праве чтения
Отчёт говорит «уязвимость найдена»Наблюдение смешали с выводом о причинеПовторить запрос, сохранить вход, ответ и версию средыНазвать факт, гипотезу и следующий безопасный тест отдельно
\n

Отрицательный путь важнее красивого отчёта

\n

Аудит должен описывать не только успешную проверку, но и отказ от действия. Если актив найден, но владелец неизвестен, его можно записать в карту и не трогать. Если разрешение относится к staging, production нужно исключить. Если тест требует массовой нагрузки, а в согласовании указан один запрос, нагрузку нельзя «добавить по ходу».

\n

Есть и технический отрицательный путь. Сервер вернул 403 для одного запроса. Это наблюдение не доказывает, что все варианты доступа закрыты. Нужно проверить, какой субъект отправил запрос, какой ресурс запрошен, где сервер принял решение и не изменил ли ответ прокси. Если часть контекста неизвестна, запись должна содержать пробел, а не уверенный вывод.

\n

Такая осторожность не делает аудит бесполезным. Она делает его переносимым. Другой инженер сможет повторить разрешённую проверку, понять границу результата и не расширить действие случайно. Для безопасности непроверенное «всё закрыто» опаснее короткого «этот путь не проверен».

\n

Порядок действий

\n
  1. Выберите один пользовательский путь с понятной ценой ошибки: вход, изменение профиля, платёж или файл.
  2. Нарисуйте путь от браузера до конечного эффекта. Добавьте API, identity provider, очередь, хранилище и внешний сервис, если они участвуют.
  3. Для каждого узла укажите идентификатор, границу ответственности, владельца, класс данных и вопрос проверки.
  4. Отдельно запишите разрешённую среду, аккаунты, метод, период, лимит запросов, запретные действия и контакт остановки.
  5. Сформулируйте один положительный и один отрицательный пример. Положительный показывает штатный доступ, отрицательный — ожидаемый отказ.
  6. Проверьте, что метод отвечает вопросу и не расширяет scope. Если нужно изменить ресурс, отправить нагрузку или выйти на внешний сервис, получите отдельное согласование.
  7. Сохраните вход, ответ, время, версию среды и идентификатор владельца. Не прикладывайте секреты и персональные данные, если они не нужны для доказательства.
  8. Разделите результат на факт, гипотезу и действие. Назначьте владельца исправления только после воспроизведения наблюдаемого симптома.
  9. Повторите карту перед следующим классом проверки. Новый актив или новая граница требуют нового вопроса и проверки разрешения.
\n

Ограничения метода

\n

Карта активов не заменяет threat model, код-ревью, тесты или внешний penetration test. Она решает более узкую задачу: не потерять границы и не начать действие без понятного контракта. Полная карта невозможна, если архитектура меняется быстрее, чем её документация. В этом случае помечайте неизвестное поле и назначайте владельца, а не заполняйте его догадкой.

\n

Проверка в staging не доказывает поведение production. Два учебных аккаунта не покрывают все роли. Ответ 403 не доказывает отсутствие утечки через кеш, экспорт или другой endpoint. Автоматический сканер полезен для поиска кандидатов, но его предупреждение требует проверки входа, ответа, контекста и влияния.

\n

Нельзя обещать отсутствие уязвимостей по итогам одного маршрута. Нельзя переносить разрешение с одного домена на соседний. Нельзя считать список активов доказательством покрытия. Ограничения должны идти рядом с выводом, иначе читатель примет его за более сильный результат.

\n

Проверяемый критерий готовности

\n

Scope готов к первой разрешённой проверке, если другой инженер без устного объяснения может показать выбранный путь, входящие в него активы и переходы, владельца каждого участка и данные, пересекающие границы.

\n

Он также должен назвать среду, аккаунты, метод, лимит, период и точку остановки. Наконец, он должен показать запрос, ожидаемый ответ и границу результата.

\n

Проверьте это на одном учебном запросе в согласованной среде. Если команда не может назвать владельца, разрешённый метод или ожидаемый отрицательный ответ, готовность не доказана. Следующее действие — закрыть конкретный пробел в карте. Запускать более широкий тест нельзя.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/148.json b/editorial/agent-rewrites/148.json new file mode 100644 index 0000000..13cb5d4 --- /dev/null +++ b/editorial/agent-rewrites/148.json @@ -0,0 +1 @@ +{ "index": 148, "slug": "editorial-2023-11-field-postmortem", "title": "Полевой разбор сбоя: как отделить факт от догадки и довести проверку до действия", "excerpt": "Практический маршрут для разбора сбоя: восстановить доступные факты, проверить решение в его контексте и выбрать одну обратимую защиту с явным критерием готовности.", "contentHtml": "

После сбоя команда открывает документ и сразу спорит о решении: кто не заметил сигнал, почему не откатили релиз и чья инструкция подвела. В тексте появляются уверенные причины, но не появляется проверка. Следующая смена видит готовый вывод и повторяет тот же выбор в других условиях. Цена ошибки — новый простой, потеря данных или ручное восстановление, которое занимает больше времени, чем первый разбор.

Тезис прост: хороший разбор восстанавливает не всю историю, а границу знания в момент решения. Он показывает, что было зафиксировано, какое действие выбрали, какие альтернативы были доступны и какую защиту можно проверить отдельно. Имя человека не заменяет механизм. Гипотеза о причине не становится фактом от того, что звучит убедительно.

Сначала восстановите наблюдаемое

Начните с симптома, а не с объяснения. Запишите, что увидел пользователь или оператор: запросы стали получать ошибку, очередь перестала уменьшаться, запись появилась дважды, откат не изменил состояние. Добавьте время, область воздействия и способ обнаружения. Если значение неизвестно, напишите «не установлено». Такая строка полезнее числа, которое никто не может подтвердить.

У каждого факта должен быть источник. Это может быть метрика, лог, трасса, запись изменения, сообщение в канале или ручное наблюдение. Источник не делает утверждение автоматически истинным. Он позволяет другому читателю повторить проверку и увидеть границы данных. Сообщение в чате помогает восстановить порядок действий, но само по себе не доказывает техническую причину.

Отделяйте время события от времени знания. Сбой мог начаться в 10:02, а команда увидела его в 10:11. Решение в 10:12 нужно оценивать по сигналам, доступным в 10:12. Поздняя трасса или найденный после инцидента фрагмент конфигурации объясняют контекст расследования, но не меняют исходный набор данных.

Механизм: три разных записи

Разбор становится проверяемым, если в нём не смешиваются факты, решения и будущие проверки. Факт описывает наблюдаемое событие и ссылается на источник. Решение описывает действие и перечисляет факты, которые были доступны перед ним. Эксперимент проверяет гипотезу после события и имеет ограниченный масштаб, критерий остановки и возврат.

Unknown — не дырка, которую нужно срочно закрыть догадкой. Это отдельное состояние. Для него укажите вопрос, владелец которого может найти ответ, допустимый источник и срок повторной проверки. Если источник потерян, честный вывод звучит как «причина не установлена». Тогда улучшайте хранение или наблюдаемость, а не переписывайте прошлое.

Такой порядок не отменяет технический анализ. Он не запрещает говорить о root cause. Он требует пометить причинную связь как гипотезу, пока её не поддерживают данные. Иногда один инцидент имеет несколько contributing causes: дефект, слабый сигнал и неясный runbook. Сведение всего к одной причине убирает условия, при которых защита не сработала.

Учебный пример: решение при неполном сигнале

Ниже — ограниченный учебный пример. Он не описывает конкретную production-систему, не содержит настоящих логов и не доказывает эффект изменения. Пусть сервис начал возвращать ошибки после изменения конфигурации. Дежурный видит рост ошибок, но не видит распределение по версиям. Он приостанавливает дальнейшее изменение и просит проверить последнюю запись конфигурации. Это решение может быть разумным или нет, но оценивать его нужно по доступным в тот момент данным.

type Fact = {\n  id: string\n  observedAt: string\n  source: string\n  statement: string\n}\n\ntype Decision = {\n  action: 'pause-change' | 'rollback' | 'continue-observing'\n  basedOn: string[]\n  decidedAt: string\n}\n\ntype Experiment = {\n  hypothesis: string\n  scope: string\n  criterion: string\n  rollback: string\n}

В этом фрагменте структура важнее названий полей. basedOn может ссылаться только на факты, известные до decidedAt. Эксперимент не должен обещать «исключить все повторы». Его критерий должен отвечать на вопрос: что именно проверяем, когда остановимся и какое действие выполним при отрицательном результате.

Например, гипотеза может звучать так: «В runbook не указан порог, при котором нужно остановить изменение». Ограниченная проверка — дать документ инженеру, который не участвовал в событии, и попросить назвать действие при заданном сигнале. Критерий — он находит порог, владельца решения и ссылку на возврат без устного пояснения. Если не находит, это результат проверки формы документа, а не доказательство, что runbook вызвал настоящий сбой.

\"Цикл
Учебный цикл связывает факт, решение, неизвестность и ограниченную проверку. Иллюстрация не показывает реальный incident workflow, трафик или подтверждённый production-эффект.

Симптом → причина → проверка → действие

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
В разборе есть имя, но нет защитыПерсональная оценка заменила описание условия отказаПопросить назвать сигнал, границу управления и отказавшую защитуПереписать вывод как проверяемое системное условие
Хронология противоречит логамПозднее знание смешали с исходным контекстомСверить время события, время знания и источник каждой строкиРазделить timeline и историю расследования
Все версии выглядят правдоподобноГипотезы записали как фактыУ каждого утверждения найти evidence reference или пометить unknownОставить competing hypotheses и назначить различающую проверку
После разбора появился длинный backlogКоманда обещает исправить всё сразуДля каждой задачи проверить scope, criterion и rollbackВыбрать один риск и один обратимый эксперимент
Тест зелёный, а повтор не обнаруживаетсяПроверяли форму, но не сигнал и отрицательный путьСымитировать отказ, тайм-аут, повтор и отсутствие данныхДобавить наблюдаемый сигнал или признать, что тест ограничен формой

Проверьте решение в его моменте

Выберите одно действие из хронологии. Запишите, что было известно до него, какие варианты были доступны и чем они отличались по риску. Не спрашивайте сначала «почему инженер так сделал». Спросите: какой сигнал он видел, какие права имел, какой срок был у решения, какой путь возврата существовал. Ответ может выявить плохой выбор. Но он также может показать, что нужный dashboard отсутствовал, runbook был неоднозначен, а безопасный откат требовал доступа, которого у смены не было.

Оценка должна включать отрицательный путь. Если выбран rollback, что происходит, когда он не меняет метрику? Если выбран pause, кто решает, когда возобновить работу? Если система получила двойной запрос, можно ли повторить операцию без двойной записи? Если ответа нет, документ описывает намерение, а не управляемый механизм.

Не называйте действие обратимым только потому, что можно переключить флаг. Возврат маршрута не удаляет уже созданную запись, не отзывает отправленное сообщение и не отменяет внешний платёж. Для состояния нужны отдельные правила сверки и компенсации. Если их нет, сузьте эксперимент до read-only-пути или оставьте изменение в режиме наблюдения.

Сделайте одну профилактическую проверку

После хронологии легко составить десять улучшений: новый alert, обязательный review, запрет ручных изменений, переработка сервиса. Длинный список создаёт иллюзию движения. Выберите один риск, который можно проверить за короткий цикл. Укажите гипотезу, scope, owner роли, criterion, дату review и rollback. Если результат нельзя увидеть без новых предположений, эксперимент слишком широк.

Учебная проверка документа может быть достаточной первой ступенью. Дайте карточку с фактом, решением и неизвестностью независимому читателю. Попросите его ответить на три вопроса: что было известно, что сделали и что ещё только проверяют. Если он смешивает ответы, граница в документе не работает. Исправьте поля и повторите проверку. Это проверяет читаемость и полноту формы. Это не измеряет надёжность сервиса и не подтверждает предотвращение инцидента.

Техническая профилактика должна менять механизм. Если проблема связана с отсутствующим сигналом, добавьте измерение и проверьте его на отрицательном сценарии. OpenTelemetry разделяет telemetry signals на traces, metrics и logs; это удобная рамка для выбора наблюдаемого свидетельства, но сам факт наличия сигнала не доказывает, что он покрывает нужный вопрос. Сначала сформулируйте вопрос, затем выберите сигнал.

Порядок действий

  1. Опишите симптом, время, область воздействия и цену повторения. Не добавляйте причину в эту строку.
  2. Соберите факты с timestamp и ссылкой на источник. Разделите время события и время, когда команда узнала о нём.
  3. Выберите одно решение. Привяжите его только к фактам, доступным до решения, и перечислите реальные альтернативы.
  4. Вынесите поздние объяснения в hypotheses. Для каждой укажите подтверждающий или опровергающий источник.
  5. Отметьте unknown явно. Назначьте вопрос, владельца роли и безопасный способ проверки. Не заполняйте пробел именем человека.
  6. Проверьте отрицательный путь: отказ, тайм-аут, повтор, неполный ответ и неуспешный rollback.
  7. Выберите один обратимый эксперимент. Назовите scope, criterion, owner, дату review и точку остановки.
  8. Проведите проверку независимым читателем или автоматическим тестом формы. Запишите, что именно проверка не покрывает.
  9. Передайте результат в рабочий процесс с владельцем и сроком. Закройте задачу только по критерию, а не по факту обсуждения.

Ограничения

Разбор не восстанавливает удалённые логи и не превращает неполный сигнал в доказательство. При малом retention часть причин останется неизвестной. При sampling трасса может не содержать нужный запрос. При ручной хронологии порядок сообщений может быть неточным. Эти ограничения нужно показывать рядом с выводом.

Blameless-подход не означает отсутствие ответственности. Он запрещает подменять техническое объяснение обвинением. Если человек нарушил правило доступа или безопасности, это может потребовать отдельного процесса. В postmortem всё равно нужно описать, какая проверка или граница позволила нарушению пройти и как её можно сделать наблюдаемой.

Учебные структуры, synthetic-данные и локальные тесты имеют узкую область применимости. Они проверяют связи между полями и ветви отрицательного пути. Они не подтверждают impact, доступность, безопасность, финансовый ущерб, поведение пользователей или результат изменения в production. Такой результат нельзя приписывать команде без реальных данных и отдельной проверки.

Проверяемый критерий готовности

Разбор готов к передаче, если независимый читатель может без устного контекста показать: симптом и цену ошибки; источник каждого факта; решение и набор доступных до него данных; неизвестный пробел; одну причинную гипотезу с проверкой; профилактическое действие с owner, criterion и rollback. Для необратимых эффектов отдельно написано, что возврат не покрывает.

Критерий готовности не звучит как «сбой больше не повторится». Он звучит проверяемо: «по этой записи читатель назовёт сигнал остановки и действие при его появлении» или «тест обнаружит двойной запрос до записи второго результата». Если условие не выполняется, разбор ещё не закончен. Сузьте вывод, соберите источник или оставьте неизвестность явно.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/149.json b/editorial/agent-rewrites/149.json new file mode 100644 index 0000000..0f9414b --- /dev/null +++ b/editorial/agent-rewrites/149.json @@ -0,0 +1,7 @@ +{ + "index": 149, + "slug": "editorial-2023-11-mechanism-postmortem", + "title": "Postmortem без заднего знания: как связать факт, решение и действие", + "excerpt": "После сбоя команда легко принимает позднюю гипотезу за причину. Разбираем временную границу знания, контракт записей и проверяемый профилактический шаг.", + "contentHtml": "

После сбоя в чате появляется короткое объяснение: «релиз сломал обработку, поэтому инженер откатил его». В одной фразе смешаны событие, причина, решение и оценка. Но в момент отката команда могла не знать, был ли виноват релиз. Она могла видеть только рост ошибок и доступный способ остановить поток.

\n

Цена ошибки — не неточная формулировка. Команда ставит защиту вокруг самого заметного элемента истории. Она добавляет проверку к релизу, хотя сбой мог возникнуть из-за данных, лимита или внешней зависимости. Следующий разбор повторяет ту же подмену. Postmortem становится рассказом задним числом, а не инструментом изменения системы.

\n

Рабочая модель разделяет три записи: наблюдаемый факт, решение с доступной в тот момент информацией и будущую проверку гипотезы. Время ограничивает вывод. Поздний лог может объяснить событие, но не доказывает, что этот лог был доступен оператору при выборе. Так документ сохраняет неизвестное и показывает, какое действие нужно проверить.

\n

Граница между фактом и объяснением

\n

Факт описывает то, что можно привязать к источнику: время, сигнал, значение поля, изменение состояния. Он не обязан содержать причину. Запись «в 10:03 доля ответов 5xx превысила порог» сильнее записи «сервис упал из-за релиза», если связь с релизом ещё не проверена.

\n

Решение описывает действие и снимок доступных фактов. Его нельзя оценивать полным набором данных, который появился позже. Иначе документ наказывает человека за информацию, которой у него не было, и скрывает вопрос к системе: почему нужный сигнал, инструкция или безопасный способ остановки не были доступны раньше.

\n

Эксперимент переводит гипотезу в проверяемую работу. Он должен назвать один риск, способ проверки, критерий успеха и обратный путь. Фраза «добавить больше мониторинга» не даёт критерия. Фраза «для этого маршрута появляется alert при трёх последовательных ошибках, а дежурный подтверждает его в тестовом окружении» уже задаёт проверку формы. Она всё ещё не доказывает эффект в production.

\n
Как разобрать спорную фразу postmortem
СлойЧто записатьЧто не утверждать
ФактВремя, наблюдение и ссылка на лог, метрику или change.«Это точно причина» без проверки связи.
РешениеДействие и факты, доступные до него.Оценку через поздние данные.
ГипотезаКакой механизм нужно проверить.Причину, если она пока только предполагается.
ЭкспериментКритерий, владелец роли и rollback.Обещание предотвратить любой повтор.
\n

Механизм: время ограничивает допустимый вывод

\n

У каждой записи есть occurredAt. У решения есть availableFactIds. В список попадают только факты, которые уже существовали до решения. Это простое правило удерживает границу знания. Новая запись может изменить гипотезу о причине, но не меняет набор данных, на котором приняли исходное решение.

\n

Рассмотрим учебный пример. Он не читает реальные логи и не описывает настоящий инцидент. В нём зафиксированы три факта, решение остановить проверку изменения и эксперимент с обратным путём.

\n
{\n  \"facts\": [\n    {\"id\": \"f-01\", \"at\": \"10:00\", \"text\": \"доля ответов 5xx выросла\", \"source\": \"metric-card-01\"},\n    {\"id\": \"f-02\", \"at\": \"10:03\", \"text\": \"изменена версия конфигурации\", \"source\": \"change-02\"}\n  ],\n  \"decision\": {\n    \"at\": \"10:05\",\n    \"action\": \"остановить продвижение\",\n    \"availableFactIds\": [\"f-01\", \"f-02\"]\n  },\n  \"experiment\": {\n    \"hypothesis\": \"явная проверка версии сократит время обнаружения\",\n    \"successCriterion\": \"проверка видна в тестовом сценарии\",\n    \"rollback\": \"удалить проверку и вернуть прежнюю конфигурацию\"\n  }\n}
\n

Код показывает форму, а не результат. В реальном документе source должен указывать разрешённый артефакт, который команда действительно может открыть. Время должно использовать одну часовую зону. Если источник недоступен, это нужно записать как ограничение, а не заменить догадкой.

\n

Модель допускает, что причиной окажется не изменение версии. Например, поздняя проверка покажет исчерпанный лимит внешнего сервиса. Тогда факты и исходное решение остаются полезными. Меняется гипотеза и, возможно, эксперимент. Нельзя переписать факт так, чтобы он заранее подтверждал новую версию.

\n
\"Схема
Учебная схема границ postmortem: факты входят в решение только через доступную временную последовательность, а профилактика получает критерий и rollback. Рисунок не показывает настоящий инцидент.
\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для разбора
СимптомВероятная причина записиПроверкаДействие
В первом абзаце назван виновник.Имя человека используется как объяснение состояния.Убрать имя и спросить, какое условие системы нужно изменить.Записать владельца будущего действия отдельно от причины.
Решение выглядит очевидным после чтения всей timeline.К решению добавили факты, появившиеся позже.Сравнить время решения с каждым availableFactId.Оставить только предшествующие факты и сохранить unknown.
Action item звучит как «добавить мониторинг».Гипотеза не имеет измеримого критерия.Спросить, какой артефакт должен измениться и как увидеть проход.Указать сигнал, порог, владельца роли и rollback.
После исправления обещают отсутствие повторов.Учебная проверка выдана за production-результат.Найти источник эффекта и период наблюдения.Сузить вывод до «проверяет форму» или собрать реальные данные.
\n

Как писать решение без поиска виноватого

\n

Отсутствие поиска виноватого не отменяет ответственности. В документе должны быть владельцы действий, сроки и правила эскалации. Но роль владельца отвечает на вопрос «кто доведёт изменение», а не на вопрос «почему система оказалась в таком состоянии». Для второго вопроса нужны условия: доступный сигнал, версия инструкции, права, лимит, автоматическая защита или отсутствие безопасной остановки.

\n

Полезно отделить две оценки. Первая: было ли действие разумным при доступной информации? Вторая: какие условия сделали такой выбор вероятным? Первая требует воспроизвести границу знания. Вторая ведёт к изменению интерфейса, runbook, алерта или архитектуры. Поздняя причина может помочь второй оценке, но не должна подменять первую.

\n

Отрицательный путь важен не меньше положительного. Если источник не открывается, поле остаётся неизвестным. Если rollback нельзя выполнить безопасно, эксперимент не готов. Если критерий нельзя проверить без production-доступа, нужно сначала спроектировать безопасную проверку или признать границу. Документ не должен заполнять пробелы уверенным тоном.

\n

Порядок действий

\n
  1. Запишите наблюдаемый симптом с временем, системой и доступным источником.
  2. Отделите факт от слов «вызвал», «из-за», «виноват» и «предотвратит». Эти слова требуют отдельного доказательства.
  3. Соберите решение как снимок: действие, время и полный список фактов, известных до выбора.
  4. Проверьте временной порядок и единую часовую зону. Удалите из контекста решения все поздние записи.
  5. Сформулируйте одну гипотезу о защите. Не превращайте список идей в план без критерия.
  6. Добавьте бинарный или наблюдаемый критерий, владельца роли, границы доступа и rollback.
  7. Проверьте учебную форму отдельно от production-эффекта. Успешная проверка JSON или документа не доказывает снижение числа инцидентов.
  8. Закройте разбор только после того, как читатель, не участвовавший в инциденте, сможет восстановить факт, решение и следующий проверяемый шаг.
\n

Ограничения

\n

Эта модель не заменяет incident command, расследование безопасности, юридическую оценку или правила хранения персональных данных. В security-контуре источники и доступы требуют отдельной политики. В распределённой системе часы могут расходиться, а источник может измениться после события. Тогда нужно хранить версию артефакта, часовой пояс и допустимый уровень точности.

\n

Три слоя не доказывают причинность. Они только не дают написать вывод шире доступных данных. Причинную связь проверяют отдельными методами: воспроизведением, сравнением изменений, экспериментом или анализом данных. Если эти методы недоступны, корректная формулировка — «причина не подтверждена».

\n

Учебный JSON выше фиксирует структуру и отрицательный путь. Он не запускается на настоящей инфраструктуре, не читает метрики и не измеряет влияние. Не переносите его идентификаторы, время и критерий в production без адаптации к своим источникам, ролям и процедурам отката.

\n

Проверяемый критерий готовности

\n

Postmortem готов к техническому review, если независимый читатель может открыть источник каждого факта, увидеть, что решение ссылается только на предшествующую информацию, и проверить один профилактический эксперимент по его критерию. Если хотя бы один пункт не выполняется, статус должен быть «не готов», а следующий шаг — устранение конкретного пробела: источник, временная граница, критерий или rollback.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/150.json b/editorial/agent-rewrites/150.json new file mode 100644 index 0000000..dcdbcd2 --- /dev/null +++ b/editorial/agent-rewrites/150.json @@ -0,0 +1,7 @@ +{ + "index": 150, + "slug": "editorial-2023-11-practice-postmortem", + "title": "Postmortem, который помогает исправить систему", + "excerpt": "Как отделить наблюдаемые факты от поздних объяснений, связать решение с доступной информацией и превратить профилактику в проверяемое действие.", + "contentHtml": "

После сбоя команда обычно помнит две вещи: какой сигнал сработал и кто последним менял систему. На встрече эти детали быстро превращаются в объяснение: «ошибка произошла из-за этого изменения». Такой вывод может быть неверным. Он смешивает факт, решение и гипотезу о причине.

\n

Цена ошибки высока. Команда тратит время на защиту вокруг случайного признака, а настоящий механизм остаётся без проверки. Следующий дежурный получает документ с обвинением и общим советом «быть внимательнее». Похожий сбой повторяется, но его уже труднее разобрать: нужные логи истекли, контекст решения забыт, а исправление объявили готовым без измерения.

\n

Рабочая схема проста: сначала записать симптом и его цену, затем собрать факты с источниками, отдельно описать решение в моменте и только после этого сформулировать небольшую профилактическую проверку. Postmortem не должен угадывать причину по одной строке лога. Он должен показывать, что известно, чего не известно и какое действие уменьшит неопределённость.

\n

Три разных типа записи

\n

Факт отвечает на вопрос «что было зафиксировано и где это видно?». Это узкое утверждение с временем и ссылкой на разрешённый артефакт: лог, trace, метрику, версию конфигурации или запись мониторинга. Фраза «в 10:03 запросы к маршруту получили 502» может быть фактом, если рядом есть запрос к источнику и задано окно времени.

\n

Решение отвечает на вопрос «что команда сделала, имея такую информацию?». В него входят время, действие и список фактов, доступных до действия. Поздний trace или результат расследования нельзя добавлять в этот список задним числом. Он помогает понять выбор, но не доказывает, что выбор был правильным или ошибочным.

\n

Профилактическая проверка отвечает на вопрос «что мы проверим, чтобы уменьшить риск повторения?». В ней нужны гипотеза, минимальный метод, бинарный критерий и обратимый путь. До прогона это намерение проверить, а не доказательство того, что новый alert, лимит или тест уже защитил пользователей.

\n
Не смешивайте записи в одной строке
ТипВопросМинимальные поляНельзя выводить
ФактЧто зафиксировано?время, наблюдение, ссылка на источниквиновника и root cause
РешениеЧто выбрали тогда?время, действие, доступные fact IDоценку задним числом
ПроверкаЧто проверим дальше?гипотеза, метод, критерий, rollbackреальный эффект до измерения
ДействиеКто доведёт работу?владелец роли, срок, ссылка на проверкуобещание устранить все риски
\n

Механизм на маленьком примере

\n

Представим учебный инцидент в HTTP-сервисе. После релиза доля ответов 502 выросла. Дежурный откатил конфигурацию таймаута. Через несколько минут доля ошибок снизилась. Этого недостаточно, чтобы написать «новый таймаут был причиной». За это время могли исчезнуть входной всплеск, зависший upstream или другая ошибка маршрутизации.

\n

Сначала запишите наблюдения:

\n
const facts = [\n  { id: 'f-1', at: '10:03', text: 'gateway reported 502 for /checkout', source: 'metric:gateway_5xx' },\n  { id: 'f-2', at: '10:04', text: 'timeout config was version 17', source: 'config:checkout@17' },\n  { id: 'f-3', at: '10:05', text: 'on-call restored version 16', source: 'change:rollback-482' },\n];\n\nconst decision = {\n  at: '10:05',\n  action: 'restore checkout config to version 16',\n  availableFactIds: ['f-1', 'f-2'],\n};\n\nconst check = {\n  hypothesis: 'the timeout change contributes to the 502 path',\n  method: 'replay the same request class with versions 16 and 17',\n  criterion: 'both outcomes and upstream status are captured',\n  rollback: 'keep version 16 and stop the replay if error rate rises',\n};
\n

Пример учебный. Он не читает настоящие метрики, не запускает rollback и не доказывает связь между таймаутом и 502. Его польза в форме: у решения видны только два доступных факта, а проверка имеет отдельный критерий и остановку. Если команда позже найдёт новый trace, его добавят в факты и пересмотрят гипотезу, но не перепишут историю доступной информации.

\n

Нужна и отрицательная ветка. Если в карточке факта появилось поле rootCause, система или ревью должны остановить запись. Если решение ссылается на факт, который возник позже, его нельзя считать контекстом решения. Если проверка говорит «сбой больше не повторится», но не называет вход, окно и измерение, это обещание, а не критерий.

\n

Симптом → причина → проверка → действие

\n
Маршрут разбора
СимптомВероятная причина смешенияПроверкаДействие
В черновике есть имя инженера, но нет источниковоценка человека заменяет анализ условийнайти timestamp и evidence для каждой фразыубрать имя из объяснения, добавить владельца следующего действия
«Релиз вызвал ошибку» написано как фактгипотеза попала в timelineсравнить время релиза, симптома и альтернативные измененияпометить связь как непроверенную и сформулировать эксперимент
Action item звучит как «добавить мониторинг»нет сценария и порога срабатыванияназвать вход, сигнал, окно и ожидаемое значениесделать критерий бинарным и указать обратное действие
После отката написано «проблема решена»снижение симптома приняли за доказательство причинысопоставить ошибку с upstream, версиями и временемописать откат как mitigation, а причину оставить открытой
Документ нельзя проверить через неделюв нём остались воспоминания без артефактовпроверить каждое утверждение по ссылке и сроку хранениясохранить минимальный разрешённый evidence или отметить пробел
\n

Иллюстрация временной границы

\n
\"Учебная
Учебная схема: факты стоят до решения, а профилактическая проверка — после него. Она не представляет настоящий инцидент и не показывает production-метрики.
\n

Временная граница нужна не для бюрократии. Она защищает от hindsight bias: после сбоя команда видит больше, чем видела в момент действия. Поэтому в записи решения храните не весь итоговый материал, а именно набор сведений, который мог повлиять на выбор. Если действие необратимо, отдельно запишите владельца точки возврата и сигнал остановки.

\n

Порядок работы

\n
  1. Опишите симптом. Укажите затронутый путь, окно времени, наблюдаемый сигнал и цену ошибки: недоступность операции, потерю данных, ручное восстановление или задержку.
  2. Зафиксируйте факты. Для каждой строки назовите источник, время и точную формулировку. Не добавляйте в факт причину, виновника или эффект, которого источник не измеряет.
  3. Восстановите контекст решения. Запишите действие, доступные fact ID, обратимость и сигнал, по которому команда решала продолжать или остановиться.
  4. Разделите mitigation и cause. Откат, переключение трафика или отключение функции может убрать симптом. Это ещё не доказательство механизма сбоя.
  5. Сформулируйте одну гипотезу. Укажите конкретный вход, способ проверки и альтернативу. Не начинайте с общего «повысить надёжность».
  6. Задайте критерий. Критерий должен приводить к PASS или FAIL и ссылаться на измеримый артефакт. Добавьте rollback и условие остановки.
  7. Проверьте документ. Уберите фразы, которые шире источника. Отдельно перечислите открытые вопросы и назначьте владельца только для следующей проверяемой работы.
\n

Ограничения

\n

Такая схема не заменяет расследование распределённой системы. Один trace может не показать потерю сообщения. Откат может убрать симптом, но оставить повреждённые данные. Метрика может считать только успешные запросы и скрывать ошибки до входа в сервис. Поэтому границы источников и неполные данные нужно писать прямо.

\n

Blameless не означает «никто ни за что не отвечает». Документ должен содержать владельца действия, срок и критерий завершения. Он также должен фиксировать небезопасное изменение, нарушенный контроль или отсутствие доступа к сигналу, если это подтверждено. Не следует приписывать человеку мотив или использовать его имя как техническую причину.

\n

Учебный код выше не подключается к сети, CI, логам, alert-системе или production runtime. Его нельзя выдавать за результат прогона. В реальной системе доступ к incident data, приватность, retention и право публикации требуют отдельной проверки. Если источник недоступен, честная запись — «не проверено», а не правдоподобная реконструкция.

\n

Проверяемый критерий готовности

\n

Postmortem готов к разбору, когда другой инженер может пройти его без устного пересказа:

\n\n

Проверка готовности не утверждает, что система стала надёжнее. Она утверждает более узкую вещь: документ сохраняет границу знания и задаёт следующий эксперимент, который можно проверить. После прогона обновите запись фактическим результатом, источником и новой оценкой риска.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/151.json b/editorial/agent-rewrites/151.json new file mode 100644 index 0000000..ac683e0 --- /dev/null +++ b/editorial/agent-rewrites/151.json @@ -0,0 +1,7 @@ +{ + "index": 151, + "slug": "editorial-2023-10-field-sli-slo", + "title": "SLI/SLO в релизном разговоре: от красного графика к проверяемому решению", + "excerpt": "Как связать SLI, SLO и error budget с пользовательским путём, окном измерения и обратимым действием — и не выдать один график за доказательство инцидента или автоматический запрет релиза.", + "contentHtml": "

В день релиза на панели краснеет error budget. Один инженер говорит: «бюджет почти закончился». Другой просит не задерживать исправление. On-call не может показать, какой пользовательский путь пострадал, за какой период считался показатель и какие события попали в знаменатель. Команда спорит о цвете, а не о данных.

\n

Цена ошибки двойная. Шумный сигнал может остановить безопасное изменение. Слишком узкий SLI может пропустить отказ после точки измерения, и команда выпустит рискованный релиз. В обоих случаях SLO превращается в отчётность: число есть, но оно не подсказывает следующий безопасный шаг.

\n

Тезис статьи простой: error budget не принимает решение вместо команды. Он запускает проверяемую петлю. Сначала нужно подтвердить договор SLI: что измеряем, для кого, в каком окне и по какой формуле. Затем нужно проверить контекст и выбрать действие по policy. Только после этого можно обсуждать rollout, паузу или исправление.

\n

Механизм: сигнал, цель, бюджет и policy

\n

SLI — количественная мера свойства сервиса. Например, доля запросов, которые завершились полезным результатом, или доля операций с задержкой ниже порога. SLO — целевое значение этой меры в заданных условиях. Error budget — допустимая часть неуспеха в том же договоре. Если target равен 99%, бюджет равен 1% eligible-событий за указанное окно.

\n

Формула сама по себе ничего не решает. Для success ratio нужны как минимум scope, eligible count, good count, target и window. Scope задаёт путь пользователя и границу ответственности. Eligible определяет знаменатель. Good определяет успешный исход. Window задаёт период сравнения. Policy связывает состояние бюджета с действием и владельцем.

\n
eligible = 1000\ngood = 994\ntarget = 0.99\nactual = good / eligible       // 0.994\nallowed_bad = eligible * (1 - target) // 10\nactual_bad = eligible - good          // 6\nremaining = allowed_bad - actual_bad  // 4\n\n// Все числа учебные. Источник событий отсутствует.\n// Результат не описывает production-доступность.
\n

В этом примере остаются четыре условные единицы бюджета. Это арифметика модели, а не факт о сервисе. Если исключить отменённые операции, изменить окно или считать только ответы одного backend, результат станет другим. Поэтому процент без версии договора нельзя сравнивать с прошлым процентом и нельзя использовать как самостоятельную причину для блокировки.

\n

Почему scope важнее красивого процента

\n

Пользователь оценивает путь, а не внутренний HTTP-ответ. Запрос может получить код 202, но очередь позже отклонит операцию. Backend может ответить быстро, пока клиент ждёт подтверждение в другом компоненте. Если SLI измеряет только первый ответ, он может быть технически точным и продуктово бесполезным.

\n

Сначала назовите действие пользователя: например, «отправить заказ и получить подтверждение». Затем определите границу: где путь считается завершённым, какие отказы входят в оценку, кто владеет источником событий. Если путь нельзя связать с наблюдаемым результатом, не объявляйте готовый SLO. Сначала сократите вопрос или добавьте нужный сигнал.

\n

Знаменатель также требует явного правила. Eligible-события нельзя выбирать по удобству. Если фильтр исключает таймауты, повторные попытки или отмены, запишите причину и отрицательный пример. Иначе команда улучшит процент удалением сложных случаев. Это не повышение надёжности, а изменение измеряемой популяции.

\n

Окно определяет, чему доверять

\n

Фиксированное окно проще объяснить: события с 1 по 28 число сравниваются с предыдущим таким же периодом. Скользящее окно быстрее показывает недавнее ухудшение, но каждый момент измерения содержит немного иной набор событий. В обоих вариантах нужно назвать часовой пояс, границы, задержку поступления событий и правило пересчёта.

\n

Низкий трафик усиливает цену одного отказа. При десяти eligible-событиях один failure меняет ratio сильнее, чем при миллионе. Это не означает, что малотрафиковый путь нельзя измерять. Это означает, что порог, окно и способ реакции надо выбирать вместе. Иногда полезнее ticket и ручной разбор, чем срочное оповещение на каждое колебание.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Процент стал краснымНеизвестны окно и версия формулыСверить SLI-contract, границы и периодПриостановить интерпретацию, запросить источник
Команды считают доступность по-разномуРазные eligible и goodСравнить запросы, исключения и отрицательные случаиЗафиксировать одну формулу и владельца
Процент хороший, путь сломанSLI измеряет ранний backend-ответПройти пользовательский сценарий до результатаРасширить scope или добавить отдельный SLI
Один отказ резко изменил ratioМалое окно или низкий трафикПосчитать eligible и проверить распределение событийВыбрать устойчивое окно и ручной response
Красный график блокирует любой релизУ policy нет исключений и владельцаПроверить обратимость, срочность и evidenceСузить rollout, исправить, отложить или продолжить по policy
\n
\"Петля
Петля решения отделяет измерение от причины и ручного решения. Иллюстрация показывает учебную схему, а не мониторинг, CI-gate или журнал инцидента.
\n

Пример policy для релизного разговора

\n

Policy должна отвечать на пять вопросов. Какое состояние бюджета запускает разбор? Какие данные обязан принести владелец? Какое действие обратимо? Кто принимает решение? Когда команда пересматривает договор? Запись «при красном графике остановить всё» не отвечает ни на один вопрос до конца.

\n

Практичная ветка может выглядеть так: если формула или scope неизвестны, решение откладывают до проверки данных. Если договор подтверждён, но причина неясна, открывают разбор и уменьшают exposure рискованного изменения. Если budget exhausted, non-urgent rollout приостанавливают, а обязательное исправление оценивают отдельно с владельцем и планом отката. Если сигнал восстановился, повторяют ту же проверку; новый процент не должен появиться из другой формулы.

\n

Такая policy не запрещает каждый релиз. Она не разрешает и каждый релиз. Она задаёт минимальное evidence и оставляет полномочие у владельца. Security fix, изменение инфраструктуры и продуктовый rollout могут иметь разные уровни срочности, поэтому один универсальный gate создаёт ложную уверенность.

\n

Порядок действий

\n
  1. Сформулируйте наблюдаемую проблему: какой пользовательский путь, симптом и цена ошибки обсуждаются.
  2. Зафиксируйте версию SLI-contract: scope, eligible, good, exclusions, target, window и источник событий.
  3. Пересчитайте показатель на небольшом проверяемом наборе и добавьте отрицательный пример. Если две команды получают разные значения, сначала устраните расхождение.
  4. Отделите сигнал от причины. В разрешённой среде проверьте версию, зависимость, класс ответов, задержку, очередь или данные, которые связаны с тем же периодом.
  5. Выберите действие по policy: исправить причину, сузить rollout, отложить несрочное изменение или продолжить с явным контролем.
  6. Назначьте владельца и план обратного действия. Укажите, что вернуть, кто это сделает и каким наблюдением подтвердить результат.
  7. После действия примените ту же формулу и критерий. Если результат не изменился, пересмотрите гипотезу, а не denominator.
\n

Ограничения отрицательного пути

\n

Один SLI не объясняет корневую причину. Trace, log и dashboard помогают только тогда, когда они относятся к той же операции, версии и периоду. Корреляция не доказывает причинность. Красный budget не доказывает инцидент. Зелёный budget не доказывает, что весь пользовательский путь работает.

\n

Учебная арифметика выше не читает файлы, часы, monitoring, CI, сеть, HTTP или production-конфигурацию. Она не создаёт alert, не меняет rollout и не выдаёт разрешение на выпуск. В реальной системе эти полномочия должны находиться в явно назначенных инструментах и runbook. Если команда не может проверить источник или обратить действие, это ограничение нужно записать до решения.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда другой инженер без устного контекста может воспроизвести число и понять решение. В записи есть пользовательский путь, версия формулы, eligible и good, target, окно, источник, владелец, выбранное действие, план отката и повторная проверка. Есть хотя бы один отрицательный пример: событие, которое нельзя молча исключить, или путь, который текущий SLI не покрывает.

\n

Релизный разговор также готов, если команда может ответить на три вопроса: что измеряем, почему этому сигналу можно доверять в данном решении и что произойдёт при ухудшении. Если на любой вопрос отвечает только цвет графика, договор ещё не готов.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/152.json b/editorial/agent-rewrites/152.json new file mode 100644 index 0000000..86f2c86 --- /dev/null +++ b/editorial/agent-rewrites/152.json @@ -0,0 +1,7 @@ +{ + "index": 152, + "slug": "editorial-2023-10-mechanism-sli-slo", + "title": "Error budget без магии: как проверить SLI, окно и решение", + "excerpt": "Процент доступности не объясняет сам себя. Разбираем связь SLI, SLO и error budget: от пользовательского пути и знаменателя до проверки формулы, policy и безопасного действия.", + "contentHtml": "

На панели появляется красный процент. В релизном чате говорят: «бюджет почти закончился». Но никто не может быстро ответить, какой пользовательский путь измеряет график, какие события попали в знаменатель и когда началось окно. Один отчёт считает отменённый запрос, другой исключает его. Один смотрит последние 28 дней, другой — календарный месяц.

\n

Цена ошибки — не спор о терминах. Команда может остановить полезное исправление из-за неверной формулы. Или продолжить рискованный rollout, потому что неуспешные события не попали в расчёт. Красный цвет не становится решением, пока за ним нет проверяемого договора.

\n

Тезис: budget начинается с границы измерения

\n

SLI — это количественная мера конкретного свойства сервиса. SLO задаёт для этой меры цель или диапазон. Error budget — допустимая часть неуспешных событий внутри той же границы и того же окна. Если команда меняет scope, eligible-события, good-события, окно или target, она меняет модель. Старый процент больше нельзя сравнивать с новым без оговорки.

\n

Начинайте не с девяток. Сначала назовите действие пользователя. Для условного checkout это может быть «пользователь отправил заказ и получил подтверждение». Затем определите множество eligible-событий, правило успеха, исключения, окно, источник данных и владельца. Только после этого формула получает смысл.

\n
Минимальный договор для SLI
ПолеПримерПроверка
Scopecheckout-submitСобытие относится к нужному пользовательскому пути
Eligibleзавершённая попытка отправкиЗнаменатель включает все случаи этой границы
Goodподтверждение получено в срокПравило не зависит от цвета dashboard
Окноrolling 28 daysПериод одинаков в расчёте и обсуждении
Target99,5%Число связано с policy и владельцем
\n

Как работает арифметика

\n

Учебный пример ниже не читает monitoring и не описывает production. В окне есть 1 000 eligible-событий. Из них 994 соответствуют правилу good. SLI равен 994 / 1 000 = 99,4%. При target 99,5% условный budget исчерпан: допустимая доля bad равна 0,5%, то есть пять событий, а фактическая — шесть.

\n
const eligible = 1000; const good = 994; const target = 0.995; const sli = good / eligible; const allowedBad = eligible * (1 - target); const actualBad = eligible - good; console.log({ sli, allowedBad, actualBad, budgetExhausted: actualBad > allowedBad }); // учебный результат: { sli: 0.994, allowedBad: 5, actualBad: 6, budgetExhausted: true }
\n

Числа в коде намеренно synthetic. Они не доказывают доступность, burn rate, incident или стоимость простоя. В рабочей системе нужно подтвердить, откуда пришло каждое событие и почему оно относится к scope. Формула без этого лишь аккуратно делит неизвестные данные.

\n

Есть и отрицательный путь. Если знаменатель равен нулю, процент нельзя объявлять равным 100%. Если good больше eligible, источник или преобразование сломаны. Если сервис измеряет только HTTP-ответ, а пользовательская ценность появляется после фоновой обработки, SLI может быть полезным proxy, но не прямым измерением результата. Proxy gap надо назвать явно.

\n

Окно не лечит плохой знаменатель

\n

Rolling window показывает недавнее состояние и постепенно вытесняет старые события. Fixed window проще связать с отчётным периодом. Ни один режим не исправляет ошибку в eligible set. При малом трафике одна ошибка резко меняет процент. При большом трафике среднее может скрыть хвост задержки. Для latency среднее также может быть слишком грубым: несколько очень медленных запросов исчезнут в общей цифре.

\n

Окно нужно записать рядом с формулой, а не оставить подписью графика. При смене 28 дней на 30 дней, при смене fixed на rolling или при изменении часового пояса меняется сравнение. Пересчитайте исторические значения либо пометьте границу новой версией договора.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для SLI/SLO
СимптомПричинаПроверкаДействие
Два отчёта показывают разные SLIРазные scope или exclusionsСравнить определения eligible и good на трёх одинаковых событияхВерсионировать один контракт и убрать скрытый фильтр
Процент равен 100% при отсутствии трафикаНулевой знаменатель превращён в успехПроверить обработку пустого окнаВернуть состояние no-data и отдельное правило для него
Красный budget не связан с жалобамиSLI измеряет proxy или не тот путьСопоставить событие метрики с user journeyСузить scope либо добавить пользовательский сигнал
После смены окна исчезла деградацияСравнили несовместимые периодыПроверить версию окна и границы timestampПересчитать историю или явно разделить серии
Budget требует немедленной блокировкиНет policy и проверки контекстаНазвать owner, обратимость и тип измененияВыбрать review, rollback, сужение rollout или продолжение с контролем
\n
Связь SLI-контракта, окна и error budget: scope задаёт eligible-события, good-события формируют SLI, target задаёт допустимый budget, а policy определяет действие.
Механизм начинается с границы события. Процент появляется после определения scope, eligible, good, окна и target. Policy связывает результат с ручным решением; сама арифметика не выдаёт право блокировать релиз.
\n

Budget не является автоматическим gate

\n

Расход бюджета — сигнал для принятия решения, а не универсальная команда остановить deploy. Policy должна назвать владельца, обязательные evidence, допустимые действия и исключения. Security fix может потребовать другого пути согласования. Обратимый rollout может потребовать сужения exposure. Неверный расчёт требует остановить интерпретацию метрики, а не обязательно остановить весь релиз.

\n

Отдельно разделяйте SLI и диагностику. SLI отвечает на вопрос, нарушается ли выбранная мера. Логи, трассы, версии, очереди и зависимости помогают искать причину. Один сигнал не обязан объяснять другой. Если после красного процента команда сразу объявляет incident, она пропускает проверку scope, времени и источника данных.

\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Сохраните значение, timestamp, версию SLI-контракта и точный user journey. Не меняйте формулу во время расследования.
  2. Проверьте границу. Для одного good, одного bad и одного спорного события определите, попадает ли каждое в eligible и почему.
  3. Пересчитайте малую выборку. Сравните ручной подсчёт с запросом или exporter. Отдельно проверьте нулевой знаменатель и ошибочное превышение good над eligible.
  4. Проверьте окно. Сверьте начало, конец, timezone, fixed или rolling режим и задержку доставки событий.
  5. Отделите proxy от результата. Проверьте, измеряет ли событие ценность пользователя или только слой системы. Запишите расхождение.
  6. Примените policy. Назначьте owner и выберите обратимое действие: исправить источник, сузить rollout, отложить non-urgent изменение или продолжить с контролем.
  7. Повторите расчёт. Используйте ту же формулу и тот же критерий. Если сигнал не изменился ожидаемым образом, вернитесь к гипотезе.
\n

Ограничения

\n

SLI не измеряет всё качество продукта. Доступность backend не гарантирует успешный пользовательский сценарий. Error budget не показывает корневую причину и не определяет важность изменения. Target 99,5% и окно 28 дней в примере не являются рекомендацией. Для редкого трафика, пакетной обработки, долгих операций и юридического SLA нужны отдельные решения.

\n

Учебный код также не создаёт alert, не читает реальные события, не меняет CI и не принимает решение о выпуске. Его можно использовать для проверки арифметики и отрицательных веток. Production-вывод появляется только после проверки источника данных, владельца, разрешений и поведения системы на реальном трафике.

\n

Критерий готовности

\n

Договор готов, если инженер за несколько минут может показать scope, eligible, good, exclusions, окно, target, источник, owner и policy branch. Для трёх выбранных событий два инженера получают одинаковый ответ. Пустое окно не становится успешным автоматически. После действия та же версия формулы даёт ожидаемое изменение, а новое значение можно связать с timestamp и источником.

\n

Если хотя бы одно поле неизвестно, статус должен быть «договор не готов». Не добавляйте ещё один график поверх неопределённости. Сначала восстановите границу измерения, затем решайте, какое действие безопасно.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/153.json b/editorial/agent-rewrites/153.json new file mode 100644 index 0000000..ec8c18e --- /dev/null +++ b/editorial/agent-rewrites/153.json @@ -0,0 +1,7 @@ +{ + "index": 153, + "slug": "editorial-2023-10-practice-sli-slo", + "title": "SLI и SLO: как измерять пользовательский результат, а не цвет графика", + "excerpt": "Процент успешных ответов не становится SLI сам по себе. Разбираем путь пользователя, знаменатель, хороший исход, окно и цель; показываем учебный расчёт, отрицательный путь и критерий готового договора.", + "contentHtml": "

На дашборде сервис зелёный: 99,9% запросов завершились без HTTP 5xx. Пользователь всё равно нажимает «Оплатить» второй раз. Первый запрос принял API, но очередь не создала платёж, а клиент получил тайм-аут после точки измерения. Команда видит хороший процент и плохой результат. Цена ошибки — неверный приоритет: релиз считают безопасным, расследуют не тот компонент и позже спорят, был ли сбой частью SLO.

\n

SLI и SLO исправляют эту ошибку только при точном договоре. SLI отвечает на вопрос «что измеряем?». SLO задаёт цель для этого измерения. Договор должен назвать путь пользователя, события в знаменателе, хороший исход, исключения, окно, источник данных и владельца решения. Если хотя бы одно поле скрыто, процент остаётся удобной, но декоративной метрикой.

\n

Тезис: начинайте с результата пользователя

\n

Google SRE определяет SLI как количественную меру свойства сервиса, а SLO — как целевое значение или диапазон для этой меры. Из этого следует практический порядок: сначала назвать важное для пользователя действие, затем выбрать измеримый признак. Не начинайте с готового счётчика HTTP-кодов только потому, что он уже есть в системе.

\n

Для checkout полезный вопрос звучит так: «Как часто завершённая попытка оформления получает подтверждение, которое клиент может показать пользователю?» Это ещё не SLI. Он задаёт границу. Теперь нужно решить, какое событие означает завершённую попытку и какое событие означает успех. Ответы должны быть наблюдаемыми и одинаково понятными владельцу продукта, разработчику и on-call.

\n

Уptime процесса может остаться диагностическим сигналом. Он показывает состояние компонента, но не доказывает успех пользовательского маршрута. И наоборот, один backend-ответ может быть плохим, а продукт — успешно показать fallback. Поэтому название proxy должно оставаться явным. Proxy нельзя выдавать за прямое измерение результата.

\n

Механизм договора

\n

Для success ratio удобно разделить события на eligible и good. Eligible — все попытки, которые имеют право попасть в знаменатель. Good — подмножество eligible с заранее названным допустимым исходом. Тогда показатель считают так: SLI = good / eligible. SLO задаёт нижнюю границу, например 99% в выбранном окне. Error budget равен допустимой доле bad-событий в том же знаменателе и окне.

\n

Правило исключения нужно записывать рядом с формулой. Отмена клиентом до отправки запроса может не входить в eligible. Отмена после принятия запроса может быть уже частью результата, если сервис обязан её обработать. Нельзя молча вычёркивать спорное событие после того, как оно ухудшило график. Иначе два отчёта получат разные знаменатели, хотя ссылаются на один маршрут.

\n
Минимальный договор для учебного пути checkout
ЧастьЗначениеПроверкаОграничение
ПутьОтправка оформленного checkoutЕсть идентификатор попытки и граница завершенияНе описывает весь сайт
EligibleЗапрос дошёл до точки завершения APIСобытие содержит request_id и итоговый статусНе включает отмену до запроса
GoodКлиент получил подтверждение приёма платежаСтатус связан с пользовательским ответом, а не только с 2xxНе доказывает фактическое списание
Окно28 дней в учебном примереВсе сравнения используют одну границу времениНе является универсальным окном
Цель и владелец99%; владелец checkoutЕсть правило пересмотра и способ связиНе даёт автоматического права блокировать релиз
\n
\"Схема
Схема показывает структуру договора до подключения отчёта. Она не является production-дашбордом и не измеряет доступность реального сервиса.
\n

Число 99% без окна и знаменателя неполно. Сто успешных попыток из ста дают 100%, но один сбой при десяти попытках меняет показатель сильнее, чем один сбой при миллионе. Для малотрафикового маршрута процент может быть шумным. Для пакетной обработки важнее throughput или время завершения. Окно выбирают вместе с типом нагрузки и решением, которое SLO должно поддержать.

\n

Учебный пример расчёта

\n

Ниже — ограниченный пример. Он проверяет только арифметику договора на заранее заданных числах. Он не читает логи, не обращается к мониторингу и не сообщает состояние какого-либо сервиса. В учебном окне есть 1 000 eligible-событий, из них 994 good. Показатель равен 99,4%. При SLO 99% допустимы 10 bad-событий, а в примере их 6. Остаток условного бюджета — 4 события.

\n
const eligible = 1000;\nconst good = 994;\nconst target = 0.99;\n\nconst bad = eligible - good;\nconst sli = good / eligible;\nconst allowedBad = eligible * (1 - target);\nconst budgetLeft = Math.max(0, allowedBad - bad);\n\nconsole.log({\n  sliPercent: sli * 100,\n  bad,\n  allowedBad,\n  budgetLeft,\n});\n// Учебный вывод: 99.4%, 6, 10, 4\n// Числа не являются измерением production-сервиса.
\n

Формула полезна только при сохранении условий. Если из знаменателя убрать неудачные попытки, показатель вырастет без улучшения пути. Если заменить good на «ответ не 5xx», можно начать считать принятый запрос успехом, хотя пользователь ещё не получил подтверждение. Если смешать 28-дневное окно с дневным числом ошибок, error budget потеряет смысл.

\n

Цель 100% в таком примере не нужна: она скрывает допустимый риск и превращает каждое событие в повод для ручного спора. Это не запрет на строгие требования. Для финансовой операции продукт может выбрать особое правило, но оно должно быть обосновано сценарием, риском и способом проверки. Учебный target не переносится в рабочую систему автоматически.

\n

Симптом → причина → проверка → действие

\n
  1. Симптом: на панели есть процент, но разные люди по-разному называют путь, который он покрывает. Причина: запрос, дашборд и документация используют разные границы. Проверка: попросите показать одно исходное событие и восстановите его путь от начала до результата. Действие: зафиксируйте scope и идентификатор операции.
  2. Симптом: SLI растёт после фильтрации части ошибок. Причина: исключение добавили без правила для знаменателя. Проверка: сравните eligible до и после фильтра и разберите один спорный случай. Действие: назовите исключение в договоре или верните событие в расчёт.
  3. Симптом: «успех» означает любой ответ 2xx, но клиент не подтверждает действие. Причина: технический статус подменил пользовательский исход. Проверка: проследите, что получает клиент после ответа и где фиксируется завершение. Действие: разделите технический proxy и SLI пользовательского пути.
  4. Симптом: один сбой в малом потоке меняет решение о релизе. Причина: окно и минимальный объём данных не согласованы с трафиком. Проверка: покажите число eligible по окну и влияние одного события на процент. Действие: выберите иной метод агрегации или оставьте сигнал диагностическим.
  5. Симптом: красный график автоматически блокирует изменение. Причина: SLO смешали с policy и полномочием на действие. Проверка: найдите владельца, правило исключений и обратимый шаг. Действие: оставьте решение за владельцем; метрика поставляет evidence, а не разрешение.
\n

Порядок внедрения

\n
  1. Опишите один пользовательский путь и точку, после которой попытка считается eligible.
  2. Назовите good event и приведите один пример успеха, один пример ошибки и один спорный случай.
  3. Запишите исключения, источник событий, окно, target и владельца в одной версии договора.
  4. Сравните формулу с техническими proxy. Отдельно отметьте расхождение, если измерение заканчивается раньше пользовательского результата.
  5. Проверьте учебную арифметику на фиксированных числах, но не называйте её наблюдением сервиса.
  6. В разрешённой среде получите реальные evidence для нескольких событий и убедитесь, что отчёт и обсуждение релиза используют одну формулу.
  7. Добавьте policy: кто проверяет сигнал, какое действие обратимо, когда пересматривается договор и какие исключения требуют отдельного решения.
\n

Последний шаг важен. SLO не объясняет корневую причину. Для неё нужны диагностические сигналы: логи, метрики, трассы или данные продукта. OpenTelemetry описывает эти сигналы как разные виды наблюдений: trace показывает путь запроса, metric — измерение во времени, log — запись события. Их можно связать контекстом, но ни один сигнал не доказывает причину без проверки источника и границы времени.

\n

Ограничения и отрицательный путь

\n

Если событие не содержит request_id, нельзя надёжно связать его с пользовательской попыткой. Если good event появляется раньше фактического завершения, SLI измеряет промежуточный шаг. Если трафика мало, процент может не поддерживать срочное решение. Если внешний провайдер недоступен, нужно заранее определить, входит ли его сбой в договор и кто владеет реакцией. Если событие потеряно, отсутствие строки нельзя считать успехом.

\n

Отрицательный путь должен быть виден в примерах: нет знаменателя; good больше eligible; в событии отсутствует поле, необходимое для границы; отмена произошла после отправки и ошибочно исключена; два источника считают разные окна. В каждом случае честное действие — остановить интерпретацию и исправить договор или источник. Нельзя дорисовать процент, чтобы сохранить зелёный статус.

\n

Учебная арифметика также не доказывает SLO compliance, availability, burn rate, incident или безопасность релиза. Она не показывает реальную стоимость простоя. Официальные документы Google помогают выбрать термины и форму описания, но не назначают target конкретному продукту. Производственный критерий должен опираться на реальные события, согласованный владелец и проверяемое правило реакции.

\n

Критерий готовности

\n

Договор готов, если независимый инженер может без устных пояснений ответить на семь вопросов: какой путь измеряем; что входит в eligible; что считается good; какие исключения действуют; за какое окно считается показатель; где лежат исходные события; кто принимает решение при отклонении. Для трёх заранее выбранных событий формула должна дать одинаковый результат в отчёте и в проверочном запросе. После изменения должен существовать обратимый шаг и способ повторить ту же проверку.

\n

Если на любой вопрос нет ответа, готовность не достигнута. Сначала исправьте границу и названия событий. Потом меняйте дашборд, алерт или policy. Такой порядок защищает от главной ошибки SLI/SLO: принять число, которое легко измерить, за результат, который действительно важен пользователю.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/154.json b/editorial/agent-rewrites/154.json new file mode 100644 index 0000000..c27f5d1 --- /dev/null +++ b/editorial/agent-rewrites/154.json @@ -0,0 +1,7 @@ +{ + "index": 154, + "slug": "editorial-2023-09-field-telemetry-signals", + "title": "Ошибка без причины: как связать метрику, trace и log", + "excerpt": "График показывает класс ошибки, но не объясняет один запрос. Разбираем маршрут metric → trace → log/event, границу cardinality и проверку, которая не выдаёт учебный пример за production-доказательство.", + "contentHtml": "

График ошибок растёт, но инженер не может назвать запрос и этап, на котором возник отказ. В журналах много похожих сообщений, а в trace-поиске нет понятного ключа. Самая дорогая ошибка в этот момент — принять громкий сигнал за причину: увеличить timeout, добавить retry или обвинить downstream. Сбой может остаться, а новые записи и задержки вырастут.

\n

Проблема возникает, когда metric, trace и log описывают один путь разными словами. Метрика считает класс исходов. Trace показывает путь запроса через операции. Log или event фиксирует событие и его контекст. Если между ними нет общего договора, команда видит три витрины, а не одну проверяемую цепочку.

\n

Тезис: каждый сигнал отвечает на свой вопрос

\n

Начинайте с вопроса, а не с поиска текста ошибки. Metric отвечает: «какой класс исходов изменился?». Trace отвечает: «через какие операции прошёл один путь?». Log/event отвечает: «какое событие произошло на конкретном шаге?». Общий trace ID или другой разрешённый correlation key связывает записи. Он не превращает metric в журнал запросов.

\n

Идентификатор одного запроса нельзя бездумно добавлять в labels метрики. Каждый новый идентификатор может создавать отдельный time series. График станет дороже, агрегация — менее полезной, а проблема поиска не исчезнет. Для метрики оставляют небольшой словарь: service, route и outcome. Подробный контекст отправляют в trace или log после проверки политики доступа и хранения.

\n

Механизм маршрута

\n

Представим учебный checkout-сценарий. Metric сообщает: для маршрута authorization вырос класс rejected. Эта запись не знает пользователя, заказа и конкретного trace. Она только выбирает поле поиска. Далее trace с тем же synthetic correlation key показывает gateway span и дочерний payment span. Затем log/event указывает, что отказ произошёл на payment span, и повторяет trace ID и span ID.

\n

Каждая стрелка требует отдельной проверки. Наличие метрики не доказывает существование trace. Наличие trace не доказывает, что log экспортирован и доступен. Совпавший ID не доказывает причину отказа, если событие записалось после ошибки или относится к другому шагу. Доказательство должно состоять из наблюдаемых объектов и честного статуса каждой связи.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Счётчик ошибок изменился, trace не находитсяНет перехода от route/outcome к trace или trace не экспортируетсяВзять один разрешённый outcome и проверить correlation в реальной средеПочинить передачу контекста или назвать путь неподтверждённым
В metric появились request IDИдентичность запроса использовали как labelПосчитать набор labels и рост series на выбранном окнеОстановить изменение, вернуть малый словарь labels, ID оставить в trace/log
Trace есть, событие не объясняет отказLog не содержит span ID, событие относится к другому шагу или потеряно при samplingСверить trace ID, span ID, имя события и времяИсправить корреляцию; не объявлять downstream причиной
Свободный текст не ищется стабильноСообщение меняется между версиями и не имеет event nameПроверить структурированные поля и стабильное имя событияДобавить минимальную схему и сохранить текст как дополнительный context
Учебный тест зелёный, production неизвестенПроверили форму записей в памяти, а не экспорт и поискОтделить fixture от реальной выборки и явно отметить границуНазначить проверку в разрешённой среде; не публиковать результат как incident evidence
\n

Конкретный пример

\n

Ниже — классификатор учебных записей. Он проверяет только договор между объектами в памяти. Значения synthetic-* выдуманы для примера. Код не обращается к приложению, не создаёт telemetry и не подтверждает, что downstream действительно вернул ошибку.

\n
function checkRoute({ metric, trace, event }) {\n  const labels = Object.keys(metric.labels);\n  const allowed = ['service', 'route', 'outcome'];\n  const metricShape = labels.length === 3\n    && labels.every((name) => allowed.includes(name))\n    && !labels.includes('trace_id');\n  const traceShape = trace.root.traceId === trace.payment.traceId;\n  const eventShape = event.traceId === trace.payment.traceId\n    && event.spanId === trace.payment.spanId;\n  return {\n    metricShape,\n    traceShape,\n    eventShape,\n    readyForRealCheck: metricShape && traceShape && eventShape,\n  };\n}\n\nconst result = checkRoute({\n  metric: { labels: { service: 'checkout', route: 'authorization', outcome: 'rejected' } },\n  trace: {\n    root: { traceId: 'synthetic-trace-1' },\n    payment: { traceId: 'synthetic-trace-1', spanId: 'synthetic-span-payment' },\n  },\n  event: { traceId: 'synthetic-trace-1', spanId: 'synthetic-span-payment' },\n});\n\nconsole.log(result);\n// readyForRealCheck: true — только договор synthetic-записей.
\n

Отрицательный путь важнее зелёного результата. Если event получит другой trace ID, eventShape станет false. Если в metric появится trace_id, metricShape станет false. Код не угадывает причину и не исправляет систему. Он останавливает вывод: сначала нужно восстановить связь или признать, что её нет.

\n
\"Учебный
Учебная схема разделяет вопросы сигналов. Она не изображает реальный alert, запрос к backend, trace search или подтверждённую production-причину.
\n

Как читать три сигнала вместе

\n

Metric полезна на первом шаге, потому что сжимает поток в устойчивые классы. Используйте route template и outcome, а не полный URL, user ID, order ID или текст ошибки. Набор dimensions должен быть заранее ограничен. Точное число допустимых series зависит от платформы, окна и числа значений, поэтому его нельзя объявлять безопасным без расчёта и проверки владельца backend.

\n

Trace нужен, когда вопрос перешёл от класса к пути. Найдите один разрешённый trace и проверьте дерево spans: gateway должен вести к payment operation, а не просто соседствовать с ней по времени. Сверьте parent-child связь, статус, длительность и границы sampling. Даже полный trace показывает путь инструментирования, а не автоматически истинную причину бизнес-ошибки.

\n

Log/event нужен для контекста шага. Структурированное событие должно иметь стабильное имя, время, trace ID и, если событие связано с конкретной операцией, span ID. Дополнительные attributes должны пройти review на чувствительные данные, redaction, retention и права доступа. «Добавим весь request на всякий случай» — плохая стратегия: она увеличивает риск и не делает гипотезу точнее.

\n

Порядок действий

\n
  1. Опишите симптом одним предложением: какой класс исходов изменился, в каком route и за какое окно.
  2. Назовите ожидаемый переход metric → trace. Проверьте, что metric не содержит per-request labels и использует малый словарь service, route, outcome.
  3. Выберите один разрешённый trace. Сверьте trace ID, root span, дочерний span и время операции. Не делайте вывод по одному графику.
  4. Найдите log/event на конкретном span. Проверьте event name, trace ID, span ID и отсутствие лишних чувствительных полей.
  5. Прогоните отрицательные проверки: mismatch trace ID, mismatch span ID, лишний label и отсутствие event. Каждый случай должен останавливать вывод.
  6. Сформулируйте действие только после проверки связи. Если trace или event отсутствует, исправляйте instrumentation и экспорт, а не таймаут downstream.
  7. Повторите проверку тем же route, окном и правилом выборки. Сравните стоимость series, доступность поиска и соседние сигналы.
  8. Запишите результат как подтверждённый, неподтверждённый или неполный. Не называйте synthetic PASS наблюдением production.
\n

Когда остановиться и что откатывать

\n

Если новый label резко расширяет cardinality или event содержит запрещённое поле, остановите распространение изменения. Сначала определите, какие записи ещё могут появляться и какие потребители уже зависят от схемы. Затем выберите обратимое действие для конкретной конфигурации: отключить добавленный label, ограничить event attributes или вернуть предыдущую версию instrumentation. Нельзя обещать удаление уже сохранённых данных, пока не известны storage, retention и политика доступа.

\n

Если metric уже есть, а trace не связывается, не добавляйте ещё один ID в счётчик. Проверьте propagation на границе сервиса, sampling, exporter и возможность поиска. Если log не содержит span ID, назовите это дефектом корреляции. Если настоящая система не позволяет безопасно проверить путь, остановите расследование на статусе «не подтверждено» и не заменяйте evidence догадкой.

\n

Ограничения и критерий готовности

\n

Пример не содержит реальных logs, metrics, traces, latency, traffic, backend records или incident data. Synthetic value и IDs не являются измерениями. Статья не утверждает, что конкретная SDK, collector, exporter или backend поддерживает одинаковые поля и поиск. Sampling может скрыть часть trace. Асинхронная очередь может разорвать контекст. Событие может прийти позже операции. Эти условия нужно проверять в выбранном контуре.

\n

Критерий готовности проверяемый: для одного разрешённого route есть metric с заранее названными dimensions; для выбранного outcome найден trace с тем же correlation key; trace содержит ожидаемый span; log/event имеет тот же trace ID и корректный span ID; отрицательные ветки дают отказ; после изменения не выросли запрещённые labels и не появились чувствительные поля. Если хотя бы одна связь не доказана, итог — неполный.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/155.json b/editorial/agent-rewrites/155.json new file mode 100644 index 0000000..a911a91 --- /dev/null +++ b/editorial/agent-rewrites/155.json @@ -0,0 +1,6 @@ +{ + "index": 155, + "slug": "editorial-2023-09-mechanism-telemetry-signals", + "title": "Почему trace ID не должен становиться label метрики", + "excerpt": "Trace, metric и log отвечают на разные вопросы. Разбираем, как сохранить корреляцию одного запроса, не превратить метрику в журнал событий и проверить отрицательный путь.", + "contentHtml": "

Симптом знаком: график отказов растёт, но инженер не может назвать конкретный запрос и этап, на котором он сломался. В логах есть похожие сообщения, а трасса либо не находится, либо не связана с ними. Первый быстрый ремонт — добавить trace_id или request_id в labels метрики. График становится фильтруемым, но перестаёт быть хорошим графиком. Цена ошибки — не абстрактная «плохая наблюдаемость». Команда смешивает счётчик с идентичностью одного запроса, раздувает число временных рядов и принимает решение по данным, которые не отвечают на вопрос о причине.

\n

Тезис простой: общий идентификатор нужен для корреляции trace и log/event record, а labels метрики должны описывать небольшой набор групп, по которым допустима агрегация. Один и тот же атрибут может встретиться в нескольких сигналах, но его роль не становится одинаковой. Сначала определите вопрос сигнала. Потом выбирайте поле.

\n

Три сигнала, три вопроса

\n

Trace описывает путь операции. Его узлы — spans: например, gateway, вызов каталога и шаг оплаты. У trace есть TraceId, а у каждого span — собственный SpanId. Так можно связать дочернюю операцию с родительской и пройти от общего маршрута к конкретному шагу.

\n

Metric описывает измеряемый класс поведения во времени. Для неё важны имя инструмента, значение, единица, временной ряд и attributes, которые разделяют поток на dimensions. Вопрос метрики звучит как «сколько отказов было на этом маршруте?» или «какое распределение задержки видит этот класс операций?». Вопрос «какой именно request упал?» относится к другой записи.

\n

Log или event record фиксирует конкретное событие и его контекст. В него можно положить имя события, класс ошибки, span ID и trace ID, если это разрешено политикой хранения. Такая запись помогает объяснить один отказ. Она не заменяет агрегированную метрику, потому что свободный текст и уникальные идентификаторы плохо отвечают на вопрос о тренде.

\n
Роль поля в разных сигналах
СигналОсновной вопросПодходящие данныеЧего не следует требовать
TraceКакой путь прошла операция?trace_id, span_id, родительский span, имя операцииСчитать все ошибки и строить долгий тренд
MetricКак меняется класс результата?route template, service, outcome, environmentХранить идентичность каждого запроса
Log/eventЧто произошло на одном шаге?event name, trace ID, span ID, проверенные attributesСтановиться единственным источником агрегации
\n
\"Учебная
Учебная иллюстрация разделяет поля для агрегации и поля для корреляции. Она не показывает данные конкретного сервиса, backend или production-нагрузку.
\n

Механизм: корреляция отдельно, агрегация отдельно

\n

Представим учебный маршрут checkout. Gateway принимает запрос и создаёт корневой span. Дочерний span вызывает оплату. Оплата отклоняет авторизацию. Во всех трёх шагах используется один учебный trace ID, но gateway и payment имеют разные span ID. Event об отказе ссылается на payment span. Metric считает класс route=checkout, outcome=authorization_rejected. Идентификатор конкретного пути остаётся в trace и event.

\n
// Учебный пример. Он не создаёт telemetry и не сообщает о production.\nconst trace = {\n  traceId: 'synthetic-trace-2023-09-A',\n  spans: [\n    { spanId: 'synthetic-span-gateway-A', name: 'checkout', parent: null },\n    { spanId: 'synthetic-span-payment-A', name: 'payment.authorize',\n      parent: 'synthetic-span-gateway-A' },\n  ],\n};\n\nconst metricPoint = {\n  name: 'checkout.authorization.rejected.total',\n  value: 1,\n  labels: {\n    service: 'checkout-api',\n    route: 'checkout',\n    outcome: 'authorization_rejected',\n  },\n  // trace_id намеренно не является label.\n};\n\nconst event = {\n  name: 'payment.authorization.rejected',\n  traceId: trace.traceId,\n  spanId: 'synthetic-span-payment-A',\n  attributes: { failureClass: 'declined' },\n};
\n

В примере три labels имеют небольшой словарь только по замыслу. Учебные строки не доказывают, что такой набор безопасен для любого backend. Реальный владелец метрики должен знать допустимые значения, объём данных, правила retention и способ измерения series. Но граница уже видна: trace_id, request_id, user_id, order_id, полный URL и текст исключения описывают отдельные случаи. Их нельзя добавлять в metric labels «на всякий случай».

\n

Trace ID не запрещён во всех местах метрики. OpenTelemetry описывает exemplars как механизм, который может связать измеренное значение с trace и span. Это другой канал связи, не обычная dimension временного ряда. Нельзя заменить exemplar добавлением идентификатора в каждый label и объявить задачи одинаковыми. Конкретная поддержка exemplars зависит от инструмента и backend, поэтому её нужно проверять отдельно.

\n

Симптом → причина → проверка → действие

\n
Диагностика разорванной связи между сигналами
СимптомПричинаПроверкаДействие
График есть, виновный запрос не находитсяМетрика должна была заменить traceПроверить, есть ли рабочая связь от точки измерения к trace или eventОставить labels агрегируемыми и настроить отдельную корреляцию
Число series растёт вместе с трафикомВ labels попал per-request ID или свободный текстВыписать словарь значений каждого label и найти значения, уникальные для запросовУбрать поле из labels; перенести его в event attributes или корреляционный механизм
Log найден, но относится к другому spanКонтекст потерялся на границе процесса или записан вручнуюСравнить trace ID, span ID и родительский путь на одном учебном сценарииИсправить propagation и формат записи; mismatch считать отрицательным результатом
Одна ошибка попала в несколько группНазвания outcome и route не имеют единого договораСопоставить значения с владельцем маршрута и схемой агрегацииЗафиксировать малый словарь и версию изменения
Новый label нужен только для поискаMetric используют как индекс событийСформулировать вопрос, который этот label должен отвечать в агрегатеЕсли вопрос про один запрос, использовать trace/log, а не новую dimension
\n

Проверка должна включать отрицательный путь

\n

Положительный пример легко обманчив. Он показывает, что два объекта можно связать одинаковым ID, но не показывает, что система отвергает неверную связь. Минимальный учебный тест должен принимать согласованный trace и отклонять четыре случая: другой trace ID в event, другой span ID, trace_id в labels и новый неизвестный label. Тест проверяет форму договора. Он не проверяет экспорт, collector, индексацию, sampling, storage или реальную cardinality.

\n
// Учебный псевдокод. Вызовы не обращаются к SDK или сети.\nfunction checkScenario({ traceId, spanId, labels }) {\n  if (traceId !== 'synthetic-trace-2023-09-A') return 'reject: trace mismatch';\n  if (spanId !== 'synthetic-span-payment-A') return 'reject: span mismatch';\n  const allowed = ['service', 'route', 'outcome'];\n  if (Object.keys(labels).some((key) => !allowed.includes(key))) {\n    return 'reject: metric label contract';\n  }\n  return 'accept: synthetic correlation contract';\n}\n\ncheckScenario({\n  traceId: 'synthetic-trace-2023-09-A',\n  spanId: 'synthetic-span-payment-A',\n  labels: { service: 'checkout-api', route: 'checkout', outcome: 'authorization_rejected' },\n});\n// accept\n\ncheckScenario({\n  traceId: 'synthetic-trace-2023-09-A',\n  spanId: 'synthetic-span-payment-A',\n  labels: { service: 'checkout-api', route: 'checkout', outcome: 'authorization_rejected', trace_id: '...' },\n});\n// reject: metric label contract
\n

Отрицательный результат не означает, что любой trace ID в любом представлении запрещён. Он означает, что именно этот учебный contract не разрешает использовать его как dimension метрики. В рабочей системе правило должно жить рядом с инструментированием, а не только в статье. Иначе следующий разработчик изменит labels, а проверка останется зелёной на старом примере.

\n

Порядок действий

\n
  1. Назовите симптом. Запишите, какой вопрос остался без ответа: класс отказов, путь одного запроса или контекст события. Не начинайте с названия инструмента.
  2. Разложите объекты. Для trace выпишите spans и родительские связи. Для metric — имя, value, unit и labels. Для event — имя события, trace ID, span ID и attributes.
  3. Составьте словарь labels. Для каждого dimension укажите допустимые значения и владельца. Отдельно отметьте поля, которые меняются почти на каждый запрос.
  4. Проведите корреляцию. На безопасном учебном сценарии проверьте общий trace ID, соответствующий span ID и путь родитель-потомок. Несовпадение должно быть видимым отказом.
  5. Проверьте отрицательный путь. Добавьте per-request ID в копию metric и измените ID в event. Проверка должна отклонить оба случая по разным причинам.
  6. Проверьте реальный контур отдельно. Уточните, как конкретный SDK переносит context, где backend хранит attributes, поддерживает ли он exemplars и какие ограничения действует для series. Учебный код этого не делает.
  7. Зафиксируйте границу. Запишите, какой сигнал отвечает на какой вопрос, кто владеет схемой и как откатывается изменение instrumentation. Не называйте label budget соблюдённым без измерения в выбранной среде.
\n

Ограничения и отрицательный путь

\n

В статье нет production-телеметрии, реального counter, latency, trace, log, dashboard или запроса к backend. Все значения с префиксом synthetic- служат для объяснения связей. Учебная metric point не измеряет количество отказов. Согласованный trace не доказывает, что propagation работает в приложении. Пройденная функция не доказывает, что exporter доставит запись, collector не изменит её и backend покажет её пользователю.

\n

Высокая cardinality тоже не вычисляется по числу labels в примере. Влияние зависит от множества значений, сочетаний dimensions, периода хранения, агрегации и конкретной платформы. Поэтому отрицательный путь должен продолжаться за пределами кода: измерьте число временных рядов и стоимость выбранного набора в разрешённой среде. Если такой проверки нет, формулировка должна быть «контракт не разрешает поле», а не «система доказанно экономна».

\n

Если общий trace ID не проходит границу процесса, не компенсируйте это копированием идентификатора в каждую метрику. Сначала проверьте propagation, формат записи и доступность корреляции. Если event содержит чувствительные данные, отдельно решите redaction, retention и права доступа. Связь между сигналами не отменяет требований к данным.

\n

Критерий готовности

\n

Работа готова, когда на одном контролируемом сценарии видны четыре результата: агрегатная метрика содержит только согласованные dimensions; trace показывает ожидаемый путь и разные span ID; event ссылается на тот же trace и правильный span; неверный trace, неверный span и per-request label получают отдельный отказ. Для реального контура дополнительно есть проверка propagation и измерение series в конкретном backend. Если можно показать только зелёный учебный пример, готова модель договора, но не production-настройка наблюдаемости.

\n

Проверяемые источники

\n"} diff --git a/editorial/agent-rewrites/156.json b/editorial/agent-rewrites/156.json new file mode 100644 index 0000000..e00b6b6 --- /dev/null +++ b/editorial/agent-rewrites/156.json @@ -0,0 +1,7 @@ +{ + "index": 156, + "slug": "editorial-2023-09-practice-telemetry-signals", + "title": "Trace ID, метрика и лог: как связать сигналы без высокой cardinality", + "excerpt": "Практический договор для распределённого запроса: trace ID связывает путь и событие, метрика считает малый набор классов, а лог сохраняет контекст отказа.", + "contentHtml": "

На графике выросли ошибки авторизации. В журнале есть сообщения об отказе. В трассировке виден тот же endpoint, но инженер не может доказать, что три наблюдения относятся к одному запросу. Он тратит время на ручной поиск и может увеличить timeout или retry, не устранив причину. Цена ошибки — лишняя нагрузка, задержка расследования и решение по несвязанным данным.

\n

Быстрый ремонт — добавить trace ID во все метрики, а в лог оставить длинный текст. Связь одного запроса с графиком станет возможной, но метрика перестанет быть хорошим агрегатом. Число уникальных series будет расти вместе с числом запросов. Корреляция должна жить в trace и логах, а метрика должна отвечать на вопрос о классе событий.

\n

Тезис: каждому сигналу — свой вопрос

\n

Trace описывает путь операции через компоненты. Metric показывает число, долю или распределение во времени. Log фиксирует отдельное событие и его контекст. OpenTelemetry называет их разными сигналами, потому что у них разные модели данных и способы поиска.

\n

Общий trace ID связывает span одного распределённого пути. Span ID уточняет конкретный шаг. Лог может содержать оба значения и имя события. Метрика должна использовать поля с небольшим заранее известным набором значений: шаблон маршрута, результат и имя сервиса. Идентификатор запроса, пользователя, заказа и необработанный URL в этот набор обычно не входят.

\n

Механизм связи

\n

Клиент отправляет запрос в gateway. Gateway принимает или создаёт trace context и передаёт его дальше. Сервис оплаты создаёт дочерний span. При отказе сервис записывает событие в лог с trace ID и span ID шага оплаты. Отдельно он увеличивает счётчик отказов с labels service, route и outcome. По метрике видно, что класс отказа растёт. По trace ID можно найти конкретный путь. По записи события можно понять, что произошло внутри шага.

\n

Сигналы не связываются автоматически. Пропагатор может быть не настроен, лог может потерять контекст, sampling может не сохранить нужный trace, а индекс может скрыть поле поиска. Договор задаёт ожидаемую связь. Проверка должна показать, где она рвётся.

\n
СигналВопросДопустимые поляНе следует добавлять
TraceКакой путь прошёл запрос?trace ID, span ID, service, operationСвободный текст вместо структуры
MetricКак меняется класс событий?service, route template, outcometrace ID, request ID, user ID, order ID
LogЧто произошло на одном шаге?trace ID, span ID, event name, безопасные attributesСекреты, токены и лишние персональные данные
\n
\"Схема
Общий trace ID связывает путь и событие, а метрика сохраняет только агрегируемые labels.
\n

Учебный пример

\n

Ниже показана форма данных для одного учебного отказа. Имена с префиксом demo- не представляют реальные запросы, пользователей, задержки или результаты работы сервиса. Пример проверяет только границы между сигналами.

\n
const trace = { traceId: 'demo-trace-001', spans: [{ spanId: 'demo-span-gateway', service: 'gateway', operation: 'checkout' }, { spanId: 'demo-span-payment', service: 'payment', operation: 'authorize' }] }; const logEvent = { traceId: trace.traceId, spanId: 'demo-span-payment', eventName: 'payment.authorization.rejected', attributes: { reasonClass: 'demo-limit', retryable: false } }; const metric = { name: 'payment_authorization_total', value: 1, labels: { service: 'payment', route: 'checkout', outcome: 'rejected' } };
\n

В примере trace ID повторяется в trace и log. Он не попадает в labels метрики. Поля reasonClass и retryable объясняют событие, но не становятся dimensions автоматически. В рабочей системе имена и состав полей нужно согласовать с владельцами сервиса, требованиями безопасности и возможностями хранилища. Этот фрагмент не создаёт telemetry и не доказывает работу экспортера.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
График показывает всплеск, но запрос не найтиНет устойчивого перехода от metric к traceВыбрать один отказ и проверить его trace ID в логахОставить labels агрегируемыми, а корреляцию добавить в log/span
Число series растёт почти с каждым запросомВ labels попал trace ID или другой уникальный идентификаторПосчитать словари значений по каждой label и сравнить с числом запросовУдалить request-like label и заменить его малым классом
Trace есть, но событие не объясняет отказЛог содержит только текст или другой span IDСверить trace ID, span ID, имя события и обязательные attributesЗаписывать структурированное событие на том же шаге
Связь работает только иногдаКонтекст теряется на границе сервиса или при samplingПроверить propagation на входе и выходеИсправить границу передачи; не маскировать пробел новой label
\n

Порядок внедрения

\n
  1. Назовите один пользовательский путь и один отказ. Не начинайте с полного набора endpoint.
  2. Для каждого сигнала запишите его вопрос. Поле без ясного вопроса уберите из договора.
  3. Определите trace ID и span ID, которые должны попасть в контекст и событие. Проверьте, что они не содержат пользовательских данных.
  4. Составьте список metric labels. Для каждой укажите допустимый словарь или правило нормализации. Используйте шаблон маршрута вместо сырого URL.
  5. Проверьте отрицательные случаи: другой trace ID, неверный span ID, отсутствующее обязательное поле и попытка добавить уникальный ID в метрику.
  6. Запустите проверку в разрешённой среде и сохраните ссылку на trace, запись события и график. Учебный объект подтверждает только форму данных; рабочее подтверждение требует реальной системы.
\n

Отрицательный путь важнее счастливого

\n

Если gateway передал trace context, а payment создал новый trace вместо дочернего span, оба сигнала выглядят корректно по отдельности. Поиск по одному ID ничего не даст. Если лог записал trace ID, но указывает span gateway вместо span с отказом, инженер попадёт в начало пути и пропустит причину. Если метрика получила user_id, она может показать нужный случай, но ценой неконтролируемого числа комбинаций и лишнего раскрытия данных.

\n

Проверяйте эти случаи специально. Сравните входной и исходящий context на каждой границе. Сопоставьте span ID события с операцией, где произошёл отказ. Отдельно проверьте отказ без trace: пустая строка не должна смешивать разные случаи. Если связь потеряна, исправьте propagation. Не маскируйте пробел новой label.

\n

Ограничения

\n

Небольшой набор labels не гарантирует низкую стоимость хранения. Итог зависит от числа сервисов, маршрутов, окружений, времени хранения и запросов к backend. Удаление trace ID из метрики не решает проблему, если в labels остаются сырые URL, тексты ошибок или идентификаторы заказов. Нужен отдельный обзор cardinality и доступа к данным.

\n

Trace sampling может сохранить не каждый запрос. Логирование тоже может быть ограничено уровнем, фильтрами или политикой персональных данных. Поэтому отсутствие trace по графику не доказывает отсутствие ошибки. В критичном потоке метрика должна фиксировать класс отказа, а лог — безопасный контекст для следующей проверки.

\n

Критерий готовности

\n

Договор готов, когда для выбранного пути выполнены четыре условия: один запрос сохраняет общий trace ID через нужные границы; событие отказа содержит тот же trace ID и span ID правильного шага; метрика группируется только по описанным labels; отрицательная проверка обнаруживает mismatch и уникальные идентификаторы в labels. Результат должен воспроизводиться по ссылкам на конкретный trace, лог и график в разрешённой среде. Если условие не выполнено, связь сигналов ещё не доказана.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/157.json b/editorial/agent-rewrites/157.json new file mode 100644 index 0000000..0669d71 --- /dev/null +++ b/editorial/agent-rewrites/157.json @@ -0,0 +1,7 @@ +{ + "index": 157, + "slug": "editorial-2023-08-field-e2e-stability", + "title": "Flaky e2e-тест: как найти причину и сохранить сигнал", + "excerpt": "Если первый запуск падает, а retry проходит, тест не становится надёжным. Разбираем locator, готовность интерфейса, данные и trace, чтобы менять только доказанный слой.", + "contentHtml": "

В CI тест оформления платежа падает на click. Повторный запуск проходит. Через день тот же тест снова красный, но уже на проверке результата. Команда увеличивает timeout, добавляет ещё один retry и получает более длинную очередь. Ошибка не исчезает: тест лишь дольше скрывает нарушение пользовательского сценария.

\n

Цена такого решения измерима. Разработчик ждёт обратную связь дольше. Красный запуск перестаёт отличать дефект продукта от дефекта теста. Если retry маскирует настоящий сбой оплаты, команда может пропустить проблему до релиза. Если виноваты общие данные, каждый тест с большим timeout платит за чужую гонку.

\n

Тезис простой: flaky — это сигнал для разбора, а не причина менять настройки вслепую. Сначала разделите locator, actionability, readiness, данные и внешние зависимости. Затем привяжите evidence к конкретной попытке. После этого выбирайте маленькую правку с owner и понятным rollback.

\n

Что происходит между двумя попытками

\n

Статус failed → passed сообщает только о разных исходах запусков. Он не называет причину. Retry не продолжает ту же страницу с того же места. Runner создаёт новую попытку и может использовать другой worker. Меняются cookies, storage, порядок тестов, состояние базы, очистка данных и доступность внешнего сервиса.

\n

У действия есть несколько независимых условий. Locator должен указывать ровно на один элемент. Перед click элемент должен быть видимым, стабильным, доступным для событий и активным. После действия интерфейс должен показать пользовательский результат. Успешный click доказывает готовность действия. Он не доказывает, что платёж подтверждён.

\n

Ожидание networkidle не равно готовому экрану. Исчезнувший spinner не равен успешной операции. Прошедший retry не равен воспроизводимому тесту. Trace помогает увидеть ход одной попытки, но не подменяет контракт результата.

\n

Минимальный контракт теста

\n

Зафиксируйте четыре факта до изменения конфигурации: чем пользователь находит control, какой результат он должен увидеть, что произошло в initial и retry, и какой артефакт относится к каждой попытке. Не называйте доказательством файл без номера попытки. Trace retry не объясняет автоматически initial failure.

\n
import { expect, test } from '@playwright/test'; test('confirms payment', async ({ page }) => { await page.goto('/checkout'); await page.getByRole('button', { name: 'Оплатить' }).click(); await expect(page.getByRole('status')).toHaveText('Платёж подтверждён'); }); // Учебный пример: текст, маршрут и данные замените на контракт конкретного приложения.
\n

В примере locator описывает действие языком пользователя. Assertion ждёт смысловой результат, а не случайную задержку. Это учебный фрагмент: он не подтверждает работу платёжного контура и не заменяет проверку в вашем окружении. В реальном тесте задайте независимые данные и очистку, иначе зелёный запуск может зависеть от предыдущего теста.

\n

Симптом → причина → проверка → действие

\n
Как сузить область исправления
СимптомПричинаПроверкаДействие
Ноль или несколько совпадений locatorSelector не описывает одну цельПроверьте роль, имя и область контейнераИсправьте locator; timeout не меняйте
Click ждёт и завершается timeoutЭлемент скрыт, перекрыт, движется или disabledСмотрите actionability и состояние экранаИсправьте предусловие или selector
Click прошёл, результата нетНеверный readiness contract, данные или ответ сервисаСверьте assertion с пользовательским итогом и входомУточните UI-контракт или изоляцию данных
Initial failed, retry passedГонка, загрязнение данных или внешний сбойСравните worker, данные, порядок и evidenceСоздайте triage; не добавляйте retry автоматически
Trace есть только у retryКонтекст попыток собран несимметричноПроверьте attempt, тест и проект в отчётеДобавьте путь сбора initial evidence
\n

Locator и readiness проверяйте отдельно

\n

Начните с cardinality. Locator должен находить ровно один элемент в момент действия. Если кнопок несколько, сузьте область диалога или списка. Если совпадений нет, разберитесь с состоянием страницы и текстом. Увеличение timeout не делает неоднозначную цель однозначной.

\n

Предпочитайте роль, доступное имя и label. CSS-цепочка по классам связывает тест со строением DOM, а не с поведением интерфейса. Это не абсолютный запрет на test id или CSS. Test id полезен для стабильного технического контракта. CSS оправдан, когда команда сознательно поддерживает его как API. Важно назвать владельца и смысл locator.

\n

После click проверяйте факт, который видит пользователь: сообщение об успехе, новую запись, смену статуса или подтверждённый маршрут. Не используйте spinner как финальный результат. Не делайте expect(await locator.isVisible()).toBe(true), если нужен web-first assertion: такая форма сначала получает снимок состояния и теряет встроенное ожидание.

\n

Retry должен сохранять сигнал

\n

Retry полезен как диагностический слой и как защита от краткого сбоя инфраструктуры. Он опасен, когда превращается в разрешение на merge. Запишите номер попытки, worker, используемые данные и исходную ошибку. Слово «flaky» описывает классификацию запусков. Оно не заменяет root cause.

\n

Не смешивайте классы причин. Ошибка locator требует проверки DOM. Ошибка actionability требует проверки overlay, animation и disabled state. Отсутствие readiness требует проверки UI и ответа операции. Разные данные требуют проверки setup и cleanup. Внешний сервис требует отдельной политики зависимости. Один глобальный timeout не лечит все случаи.

\n
use: { trace: 'on-first-retry', screenshot: 'only-on-failure' }, retries: process.env.CI ? 1 : 0 // Учебная конфигурация. Значения зависят от цены очереди и среды.
\n

Фрагмент показывает форму, а не готовую политику. Trace на первом retry даёт контекст повторной попытки и экономит место. Он не создаёт trace initial failure. Если первичный контекст критичен, настройте отдельный способ его сохранить и явно подпишите артефакт.

\n

Trace отвечает на узкий вопрос

\n

Откройте trace с одним вопросом: locator указывал на одну кнопку перед click или после click появился нужный status? Trace Viewer позволяет сопоставить timeline, DOM snapshot, action log и сетевые запросы. Это помогает сузить гипотезу. Но trace показывает конкретный запуск. Он не знает, был ли результат бизнес-успешным, пока тест не проверяет assertion.

\n

Если артефакта нет, запишите evidence: absent. Не заменяйте отсутствие данных уверенным объяснением. Сначала сверяйте имя теста, проект, commit и номер попытки. Затем смотрите один слой. Широкий запрос «найти причину по trace» часто приводит к непроверенной версии.

\n
Цикл разбора flaky e2e-теста: сравнение initial и retry, проверка locator и readiness, затем малая правка с rollback
Схема задаёт порядок разбора: сначала фиксируются две попытки, затем отдельно проверяются цель действия и пользовательский результат. Это иллюстрация процедуры, а не реальный trace или отчёт CI.
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Сохраните initial error, retry outcome, номер попытки и ссылку на отчёт. Не сокращайте запись до «иногда падает».
  2. Назовите причины. Разделите locator, actionability, readiness, изоляцию данных и внешнюю зависимость.
  3. Проверьте locator. Убедитесь, что он находит ровно один пользовательский control в нужной области.
  4. Проверьте readiness. Сопоставьте assertion с финальным фактом интерфейса. Уберите sleep и ожидание вторичного сигнала.
  5. Сравните попытки. Проверьте worker, cookies, storage, входные данные, cleanup и порядок запуска.
  6. Привяжите evidence. Свяжите trace, screenshot или log с attempt и задайте артефакту один вопрос.
  7. Сделайте малый diff. Меняйте только подтверждённый слой: locator, assertion, данные или политику артефактов.
  8. Опишите rollback. Укажите владельца, что вернуть, каким запуском проверить результат и когда снять временное исключение.
\n

Проверьте отрицательный путь

\n

Проверяйте не только успешную оплату. Добавьте сценарий, в котором сервер возвращает отказ или данные невалидны. Убедитесь, что тест видит сообщение об ошибке и не принимает disabled control, spinner или старый status за успех. Если отрицательный путь ломается из-за случайного текста, проблема может быть в контракте интерфейса, а не в retry.

\n

Проверка изоляции тоже должна иметь отрицательный путь. Запустите тест отдельно и в другом порядке. Используйте новый идентификатор данных. Удалите запись после сценария. Если результат меняется, не маскируйте гонку timeout. Найдите владельца состояния и границу cleanup.

\n

Ограничения

\n

Ни один locator не защищает от неверного продукта. Auto-waiting ждёт actionability, но не исправляет серверный ответ. Web-first assertion ждёт условие, но не делает условие правильным. Retry может уменьшить шум инфраструктуры, но может и скрыть редкую ошибку. Trace полезен только там, где его записали и правильно связали с попыткой.

\n

Учебные фрагменты не дают production-результатов. Они не измеряют flake rate, длительность очереди, совместимость браузеров или качество данных. Версию Playwright, project config и окружение нужно сверять отдельно. Повышение timeout допустимо только после доказанной верхней границы задержки и проверки, что ожидание относится к нужному событию.

\n

Проверяемый критерий готовности

\n

Разбор готов, если команда может показать карточку запуска и ответить на пять вопросов: что упало в initial, что прошло в retry, какой locator использовался, какой пользовательский результат ожидался и какой evidence относится к каждой попытке. После правки отдельный запуск проверяет тот же результат на свежих данных, а отрицательный путь завершается ожидаемой ошибкой.

\n

Для временного исключения нужны owner, срок пересмотра и условие снятия. Для постоянной правки нужны малый diff и понятный rollback. Один зелёный retry не проходит этот критерий. Проходит повторяемый контракт, в котором тест отличает готовое действие от подтверждённого результата.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/158.json b/editorial/agent-rewrites/158.json new file mode 100644 index 0000000..58b2658 --- /dev/null +++ b/editorial/agent-rewrites/158.json @@ -0,0 +1,7 @@ +{ + "index": 158, + "slug": "editorial-2023-08-mechanism-e2e-stability", + "title": "Почему e2e-тест проходит на retry: четыре контракта устойчивого сценария", + "excerpt": "Timeout в e2e-тесте не объясняет причину. Разбираем locator, actionability, пользовательский результат и retry, чтобы отличать настоящий дефект от замаскированного падения.", + "contentHtml": "

В CI тест нажимает «Сохранить», получает timeout, а со второй попытки проходит. В отчёте он получает статус flaky. Команда повышает timeout и закрывает задачу. Через неделю тот же сценарий снова падает на другом шаге. Цена ошибки — не только красный pipeline. Команда теряет время на повторы, пропускает дефект интерфейса или данных и привыкает считать зелёный retry доказательством исправности.

\n

Тезис простой: устойчивость e2e-теста нельзя свести к одному timeout. В сценарии действуют отдельные контракты. Locator должен выбрать нужный элемент. Actionability должна разрешить действие. Assertion должен дождаться пользовательского результата. Retry должен описать факт повторного запуска, но не объяснить его причину.

\n

Симптом начинается раньше TimeoutError

\n

Один текст ошибки скрывает разные события. Locator может найти ноль элементов или несколько. Кнопка может быть видимой, но перекрытой анимацией. Click может пройти, но сервер вернёт ошибку. Сохранение может завершиться, а тест будет ждать исчезновения spinner, который не связан с итоговым состоянием. Retry может запуститься в новом worker с другим состоянием данных.

\n

Первый вопрос звучит не «какой timeout поставить?», а «какой контракт не выполнен?». Разделите фазу поиска locator, фазу действия, фазу ожидания результата и фазу retry. Это сразу сужает область правки.

\n

Четыре контракта одного теста

\n
КонтрактЧто он гарантируетЧто он не гарантирует
LocatorТест обращается к нужному пользовательскому элементу и ожидает понятную cardinality.Что операция завершилась успешно.
ActionabilityЭлемент допустимо использовать: он найден, видим, стабилен, принимает события и включён.Что приложение приняло действие или сохранило данные.
ReadinessПосле действия появился наблюдаемый пользовательский результат.Что причина результата находится в DOM.
RetryТест повторился и получил новый outcome.Что первая ошибка была случайной или устранена.
\n

Playwright автоматически ждёт actionability перед действиями. Для click() он проверяет, что locator разрешается ровно в один элемент, элемент видим, стабилен, получает события и включён. Это защищает от клика по исчезнувшему или перекрытому control. Но проверка заканчивается, когда click допустим. Библиотека не знает, должен ли после него появиться статус «Сохранено», новая строка или ошибка.

\n

Readiness принадлежит пользовательскому сценарию. Для профиля это status с текстом «Сохранено» и новое значение поля. Для импорта — строка с terminal state. Spinner, enabled-кнопка и network idle могут быть промежуточными признаками. Они не заменяют бизнес-результат.

\n

Учебный пример: действие и постусловие

\n

Фрагмент ниже учебный. Он не утверждает, что такой locator или текст существуют в вашем приложении. Он показывает разделение действия и постусловия.

\n
import { test, expect } from '@playwright/test';\n\ntest('user saves profile', async ({ page }) => {\n  await page.goto('/profile');\n\n  const email = page.getByLabel('Почта');\n  const save = page.getByRole('button', { name: 'Сохранить' });\n  const status = page.getByRole('status');\n\n  await email.fill('user@example.test');\n  await expect(save).toBeEnabled();\n  await save.click();\n\n  await expect(status).toHaveText('Сохранено');\n});
\n

В примере getByLabel() и getByRole() описывают пользовательский интерфейс. click() ждёт техническую готовность кнопки. toHaveText() ждёт наблюдаемый итог. Если сервер отвечает ошибкой, тест должен упасть на постусловии. Если locator стал неоднозначным, ошибка должна указывать на выбор элемента. Эти отказы требуют разных исправлений.

\n

Не подменяйте постусловие ручной паузой. waitForTimeout(2000) иногда скрывает медленный UI, а иногда просто откладывает отказ. Не подменяйте результат исчезновением spinner, если spinner исчезает и при ошибке. Не используйте force: true, чтобы обойти перекрытие, пока не доказано, что перекрытие не является дефектом интерфейса.

\n

Retry меняет условия запуска

\n

В Playwright retry выключен по умолчанию. Если он включён, упавший тест запускается снова. Runner работает с worker-процессами. После отказа worker может быть отброшен, а повтор начнётся в новом процессе. Retry способен убрать утечку состояния, изменить порядок подготовки данных или повторить cleanup. Это наблюдение о двух запусках, а не доказательство случайности.

\n

Статус flaky полезен как сигнал: первая попытка не прошла, повторная прошла. Он не отвечает на вопросы «почему упало», «исправили ли причину» и «будет ли проходить другой браузер». Trace, записанный через on-first-retry, относится к повторной попытке. Он показывает конкретный запуск, но не восстанавливает контекст первой ошибки.

\n

Отрицательный путь важен не меньше зелёного. Если click прошёл, но readiness не наступил, тест должен закончиться понятной ошибкой на assertion результата. Если retry затем проходит, сохраняйте обе попытки. Нельзя заменить историю фразой «flaky исчез» без проверки данных, состояния и UI-сигнала.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Timeout на clickLocator пустой, множественный, перекрыт или disabled.Проверьте cardinality, видимость, стабильность и получение событий.Уточните scope и locator; исправьте UI или данные, если control недоступен.
Click прошёл, тест ждёт до timeoutAssertion ждёт не тот результат или UI не сообщает terminal state.Назовите факт, который должен увидеть пользователь.Добавьте web-first assertion на этот факт или согласуйте UI-контракт.
Первый запуск failed, retry passedУтечка данных, порядок тестов, внешний сервис или скрытая готовность.Сравните входы, worker, cleanup, номер попытки и evidence.Изолируйте данные или исправьте ожидание. Не увеличивайте retry.
Тест проходит только с waitForTimeoutСценарий не ждёт наблюдаемое событие.Уберите паузу в учебной ветке и найдите первый пользовательский факт.Замените паузу на locator/assertion с ясным сообщением.
Trace ничего не объясняетАртефакт относится к retry, а вопрос слишком широк.Проверьте attempt, тест, проект и момент записи.Используйте trace как evidence одного запуска и соберите контекст initial failure.
\n
\"Схема
Locator выбирает control, actionability разрешает действие, readiness подтверждает пользовательский результат, а retry фиксирует повтор. Asset показывает модель, а не trace реального теста.
\n

Порядок разбора

\n
  1. Зафиксируйте симптом. Запишите тест, браузер, шаг, номер попытки, исходную ошибку и артефакт. Формулировки «иногда падает» недостаточно.
  2. Определите фазу. Отделите поиск locator, actionability, assertion результата, подготовку данных и retry.
  3. Проверьте locator. Убедитесь, что он выражает пользовательский смысл, работает в нужном scope и возвращает ожидаемое число элементов.
  4. Назовите readiness. Запишите terminal state, который доказывает успех. Сверьте его с тем, что увидит пользователь.
  5. Сравните попытки. Сопоставьте входные данные, worker, cleanup, конфигурацию и порядок действий. Retry рассматривайте как новый запуск.
  6. Проверьте evidence. Свяжите trace, screenshot или log с попыткой. Если артефакт отсутствует, так и запишите.
  7. Сделайте один diff. Меняйте один слой: locator, assertion, изоляцию данных или evidence. Укажите owner и rollback. Timeout меняйте только после доказательства нужного события.
  8. Проверьте отрицательный путь. Ошибка сервера, отсутствие readiness и неоднозначный locator должны давать разные понятные отказы. Зелёный retry сам по себе проверкой не считается.
\n

Ограничения

\n

Модель не делает любой тест стабильным. Locator с role/name может быть корректным, но приложение может показывать неверное состояние. Assertion может быть семантическим, но тестовые данные могут пересекаться. Retry помогает обнаружить flake, но не заменяет изоляцию и диагностику. Trace фиксирует только записанный запуск. Отдельно проверяйте версии Playwright, браузеры, сеть и внешний сервис.

\n

Учебный код не запускает реальный профиль, не измеряет flake rate и не доказывает production-результаты. Для рабочего теста подставьте реальные роли, данные и terminal state, затем проверьте их на целевой конфигурации.

\n

Критерий готовности

\n

Разбор готов, когда для одного сценария записаны четыре вещи: уникальный locator, ожидаемый пользовательский результат, evidence с номером попытки и действие с понятным rollback. На контролируемом отрицательном пути тест должен падать на соответствующем контракте, а не на случайном timeout. На повторном запуске команда должна видеть, что изменилось: locator, readiness, данные или окружение. Только после этого статус flaky становится входом для проверки, а не заменой объяснения.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/159.json b/editorial/agent-rewrites/159.json new file mode 100644 index 0000000..15fd378 --- /dev/null +++ b/editorial/agent-rewrites/159.json @@ -0,0 +1,7 @@ +{ + "index": 159, + "slug": "editorial-2023-08-practice-e2e-stability", + "title": "Почему e2e-тест проходит со второго раза и что проверять вместо retry", + "excerpt": "Flaky после retry — это симптом, а не причина. Разбираем границы locator, готовности интерфейса, изоляции данных и evidence на примере Playwright.", + "contentHtml": "

Тест нажимает «Оплатить», получает ошибку на первой попытке и проходит на повторной. CI помечает его как flaky. Команда поднимает timeout, добавляет retry и закрывает pull request. Через неделю тот же тест снова падает, но уже дольше занимает очередь.

Цена ошибки — не только медленный pipeline. Retry может скрыть настоящий отказ: грязные данные, зависимость от порядка тестов, гонку после ответа API или неверный locator. Прошедшая повторная попытка создаёт ощущение исправления, хотя первый запуск так и остался необъяснённым.

Тезис: стабильность e2e строится не вокруг большого timeout. Сначала нужно разделить selector, техническую готовность элемента, продуктовый результат, состояние данных и evidence конкретной попытки. Retry помогает классифицировать запуск. Он не объясняет причину.

Что именно ждёт тест

У одного сценария несколько ожиданий. Locator отвечает на вопрос «какой элемент нужно найти». Для click() Playwright дополнительно проверяет actionability: элемент должен существовать, быть видимым, стабильным, доступным для события и включённым. Это защищает от клика по скрытой или перекрытой кнопке.

Эти проверки не знают, завершилась ли операция. Кнопка может принять клик, пока запрос ещё идёт. Сервер может вернуть ошибку. Экран может показать промежуточный spinner. Поэтому после действия нужен отдельный readiness contract: наблюдаемый факт, который означает завершение сценария для пользователя.

Для платежа таким фактом может быть статус «Подтверждено». Для сохранения профиля — сообщение об успехе и новое значение в карточке. Для импорта — строка с терминальным состоянием. disabled и исчезновение spinner подходят только тогда, когда продуктовый контракт прямо говорит, что они означают успех.

Есть и граница данных. Если тест создаёт пользователя с фиксированным логином и не удаляет его, результат зависит от предыдущего запуска. Если два теста меняют одну корзину, retry получает другое начальное состояние. Locator и assertion могут быть правильными, а падает изоляция.

Пример: проверяем результат, а не паузу

Ниже учебный фрагмент для Playwright. Названия кнопки и статуса условны. Код не доказывает готовность конкретного продукта и не сообщает production-результаты. Он показывает границу между действием и постусловием.

import { expect, test } from '@playwright/test';\n\ntest('подтверждает оплату', async ({ page }) => {\n  const payButton = page.getByRole('button', { name: 'Оплатить' });\n  const paymentState = page.getByTestId('payment-state');\n\n  await payButton.click();\n  await expect(paymentState).toHaveText('confirmed');\n});

getByRole() выбирает пользовательское действие. toHaveText() ждёт смысловой результат и повторяет проверку до assertion timeout. Такой код лучше, чем waitForTimeout(1000): пауза ничего не говорит о состоянии приложения и одинаково плохо работает для быстрого и медленного ответа.

Если locator совпадает с несколькими кнопками, сначала исправляют область поиска или контракт доступной разметки. Если кнопка одна, но payment-state не меняется, проверяют продуктовый поток, API и состояние данных. Нельзя менять locator, assertion и timeout одновременно: после такой правки исчезает связь между симптомом и действием.

Симптом → причина → проверка → действие

Короткая карта разбора нестабильного e2e-теста
СимптомПричинаПроверкаДействие
Locator не найденРазметка не появилась или селектор зависит от CSSСнять число совпадений и посмотреть DOM первой попыткиВыбрать user-facing locator и сузить scope
Клик проходит, статус не меняетсяНет readiness condition, ошибка API или промежуточное состояние принято за успехПроверить итоговый сигнал и ответ операцииЖдать terminal state; отдельно исправить приложение или данные
Первый запуск failed, retry passedГонка, утечка данных, порядок тестов или новый workerСопоставить данные, worker, шаг ошибки и evidence обеих попытокИзолировать данные и подтвердить один источник различия
Тест проходит после роста timeoutОжидание было коротким или assertion смотрит не на тот фактИзмерить время наступления названного состоянияМенять timeout только вместе с контрактом и лимитом
Trace есть, причина неяснаАртефакт относится к одной попыткеПроверить номер попытки и последний шагСформулировать гипотезу и проверить её отдельно

Почему retry меняет картину

Retry запускает тест снова после сбоя. В Playwright повтор может выполняться в новом worker. Это полезно для изоляции, но одновременно меняет окружение: новый браузерный контекст, новое состояние фикстур, другой порядок подготовки. Если первая попытка получила пользователя от соседнего теста, повтор может пройти только потому, что набор данных изменился.

Статус flaky описывает сочетание результатов «первая попытка не прошла, повторная прошла». Он не доказывает случайность. Он не говорит, что сеть была медленной, selector неверен или приложение сломалось. Для каждой гипотезы нужны свои признаки.

Trace, screenshot и action log тоже имеют границу. Они показывают, что происходило в записанном запуске. Trace на on-first-retry полезен для повторной попытки, но не превращается в запись первого отказа. Сохраняйте номер попытки рядом с артефактом и не называйте trace root-cause analysis.

Иллюстрация разбора

Схема разбора e2e-сбоя через locator, readiness, retry и evidence
Схема разделяет четыре вопроса: найден ли элемент, наступил ли пользовательский результат, что изменил retry и к какой попытке относится evidence. Изображение иллюстрирует порядок проверки и не содержит измерений реального проекта.

Если locator не уникален, не обсуждают timeout. Если locator стабилен, но состояние не наступает, смотрят на readiness и ответ операции. Если обе части верны, сравнивают данные и окружение initial/retry. Только после этого решают, нужна ли настройка retry или дополнительные артефакты.

Порядок действий

  1. Зафиксируйте симптом: какая попытка упала, какая прошла, на каком шаге и с каким текстом ошибки.
  2. Проверьте locator. Он должен выбирать ожидаемый пользовательский элемент в нужной области. Не принимайте first() как доказательство правильного выбора.
  3. Назовите readiness condition одним предложением. Она должна описывать terminal state, а не длительность ожидания.
  4. Сверьте assertion с этим условием. Уберите sleep, если он маскирует отсутствие постусловия. Оставьте timeout как верхнюю границу.
  5. Сравните данные initial и retry: идентификаторы, cookies, storage, записи на сервере и порядок подготовки.
  6. Прочитайте evidence по номеру попытки. Отделите факт от гипотезы: trace показывает шаг, но не объясняет причину состояния.
  7. Внесите одну правку в один слой. После неё повторите сценарий в тех же условиях и сохраните критерий отката.
  8. Если причина не подтверждена, оставьте тест в очереди разбора. Не объявляйте повышение timeout исправлением.

Ограничения

Эта схема не гарантирует отсутствие flaky-тестов. Она не заменяет проверку backend, браузеров, сети, CI-ресурсов и тестовых данных. Playwright может ждать actionability и web-first assertion, но не может выбрать бизнес-сигнал за команду. Для сложного процесса readiness может включать несколько состояний, однако каждое должно иметь понятное сообщение об ошибке.

Учебный пример не запускался против браузера и не измеряет flake rate, latency или совместимость платформ. Синтетическая модель попыток не является production evidence. Официальная документация меняется вместе с версиями Playwright, поэтому поведение конкретного runner проверяют по версии в проекте.

Проверяемый критерий готовности

Разбор можно считать завершённым, если для выбранного теста записаны пять вещей: уникальный locator, наблюдаемый readiness condition, входные данные каждой попытки, evidence с номером попытки и одна подтверждённая причина различия. После правки тест проходит несколько независимых запусков без изменения timeout как единственного изменения, а отрицательный сценарий всё ещё падает на неверном результате. Если пункт отсутствует, стабильность не доказана — есть только удачный retry.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/160.json b/editorial/agent-rewrites/160.json new file mode 100644 index 0000000..dd1f0a4 --- /dev/null +++ b/editorial/agent-rewrites/160.json @@ -0,0 +1 @@ +{"index":160,"slug":"editorial-2023-07-field-contract-tests","title":"Контракт API прошёл schema-проверку, но сломал сценарий: как найти расхождение смысла","excerpt":"Валидный JSON не доказывает совместимость consumer и provider. Разбираем учебный случай с nullable-полем, отделяем форму ответа от его смысла и задаём проверяемый шлюз перед выпуском.","contentHtml":"

После выкладки экран может получить ответ со статусом 200, пройти проверку схемы и всё равно не показать пользователю нужное действие. Например, provider возвращает state: "active" и renewalAt: null. OpenAPI допускает такое тело. Consumer видит active, но строит экран продления по дате и оставляет кнопку недоступной.

Симптом наблюдаем: запрос успешен, JSON корректен, а пользовательский сценарий остановился. Цена ошибки — сломанный экран и неверное решение о релизе. Команда может откатить полезное изменение без доказательства или оставить несовместимость до следующей выкладки.

Тезис: schema проверяет форму, а contract interaction проверяет конкретное использование API. Решение о выпуске должно связывать consumer, provider, версии, состояние provider и результат verification. Один зелёный schema-check не доказывает совместимость всех клиентов.

Форма и смысл ответа

Schema описывает типы, обязательность, enum, media type и допустимость null. Она ловит удалённое поле, неверный тип и неожиданный статус. Но она не знает, какую кнопку должен показать конкретный consumer.

Consumer contract фиксирует более узкий вопрос: какой запрос отправляет клиент и какой ответ нужен его сценарию. Экран продления может принимать только active вместе с будущей датой. Другой consumer может законно использовать active без даты: ему достаточно показать состояние подписки. Поэтому правило renewalAt нельзя молча объявить глобальным правилом API.

Provider verification проверяет interaction на стороне provider. Для неё нужно назвать provider state: подписка активна, продление разрешено, дата существует или дата отсутствует. Фраза «вернулся active» недостаточна. Иначе тест закрепляет удобный ответ, а не сценарий пользователя.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
200, schema PASS, действие недоступноConsumer ждёт смысл, которого ответ не обещаетСопоставить scenario, поле и ветку интерфейсаУточнить expectation или добавить явное поле
Допустимый request получает 400Разошлись enum, required или provider stateСверить interaction, schema и состояние данныхИсправить контракт или совместимость provider
Verification не запускаетсяНет версии contract/provider или результатаНайти точный artifact и PASS/FAILОстановить шлюз до появления evidence
Старый consumer падает после нового enumКлиент не знает новое значениеПроверить все поддерживаемые версииСохранить обратную ветку или добавить поле

Учебный пример: active без даты

Это synthetic response, а не production-данные и не отчёт об инциденте. Он показывает границу между формой и смыслом.

const response = { status: 200, body: { state: "active", renewalAt: null } }; const schemaResult = response.status === 200 && response.body.state === "active"; const semanticResult = response.body.state !== "active" || response.body.renewalAt !== null; console.log({ schemaResult, semanticResult }); // { schemaResult: true, semanticResult: false }

Учебная проверка специально строже для одного сценария. Она не говорит, что null запрещён в API. Она говорит только: экрану продления нужна дата. В реальном проекте это правило подтверждает владелец сценария и фиксирует рядом с consumer contract.

Отрицательный путь важнее зелёного. Если дата отсутствует, consumer не должен подставлять текущий день, показывать фиктивную дату или бесконечно повторять запрос. Он должен скрыть действие, объяснить недоступность или выбрать безопасную ветку. Это часть контракта, которую одна JSON Schema не описывает.

Как собрать доказательство

Сначала сохраните исходный contract до изменения provider. Запишите consumer, provider, scenario, request, ожидаемый response, версию contract и версию provider. Не переписывайте contract под новый ответ: иначе пропадёт точка сравнения.

Отдельно проверьте форму: статус, Content-Type, required, enum, типы и null. Если форма не совпала, это самостоятельная причина отказа. Если совпала, проверьте semantic expectation: какое действие принимает consumer и какое условие ему нужно.

Затем provider выполняет interaction в контролируемом provider state. Результат связывается с точной версией provider. Ответ из лога не заменяет verification: он может относиться к другой сборке, данным или consumer. Если используется Pact Broker, результат должен попасть в матрицу, по которой релиз принимает решение.

\"Шлюз
Interaction, provider state и версия provider должны привести к результату verification. Отсутствующий результат не превращается в зелёное разрешение.

Не смешивайте статусы. Schema PASS означает совпадение с формой. Semantic FAIL означает, что конкретный consumer не может продолжить сценарий. Provider verification not-run означает, что совместимость с исполняемой версией ещё не доказана.

Порядок действий

  1. Зафиксируйте симптом. Сохраните один request, response, пользовательский эффект, время и доступные версии. Не называйте виновника заранее.
  2. Опишите expectation. Назовите поле и состояние, нужные consumer. Добавьте ветку для null, неизвестного enum и отсутствующей даты.
  3. Проверьте форму. Сопоставьте schema, статус, media type, required, enum и типы. Проверьте допустимость null в нужной версии схемы.
  4. Проверьте сценарий. Запустите interaction с названным provider state и точной версией provider. Не подменяйте слой, который должен формировать ответ.
  5. Выберите обратимое действие. При неизвестном результате остановите выпуск, сохраните старое поведение или включите согласованный fallback. Не объявляйте совместимость по schema PASS.
  6. Закройте цепочку. Опубликуйте результат с версиями и окружением. Перед deploy проверьте матрицу совместимости, после deploy запишите фактическую версию и среду.

Откат и отрицательный путь

Откат оправдан, если новый provider уже влияет на поддерживаемый consumer, а совместимого поведения нет. Сначала определите границу: версия provider обратима, а данные, созданные новым consumer, могут быть необратимы. Проверьте миграции, записи и feature flags отдельно.

Semantic FAIL не всегда означает дефект provider. Возможно, provider всегда считал active широким состоянием, а consumer ошибочно использовал его как гарантию даты. Тогда исправление нужно в consumer. Возможны также явное поле canRenew, временная поддержка двух форм или обновление старого consumer до изменения provider.

Если неизвестны consumer, provider state, версия или verification result, шлюз не должен трактовать неизвестность как PASS. Выпуск останавливается с объяснимой причиной. Молчаливое разрешение создаёт ложную уверенность.

Ограничения

Consumer-driven contract покрывает зафиксированные interactions, а не все ответы provider. Он не заменяет интеграционные, компонентные и end-to-end проверки. Он также не определяет бизнес-смысл сам: ошибочное expectation может надёжно защищать неправильное решение.

Contract test не проверяет автоматически auth, feature flags, миграции, лимиты, retries и downstream-зависимости. Их включают в provider state или проверяют отдельно, если они меняют ответ. Учебный пример из статьи не запускает сеть, broker, CI или provider. Его значения нельзя выдавать за измерение совместимости или за основание production rollback.

OpenAPI описывает интерфейс, но не знает, какую кнопку показать consumer. Pact связывает consumer expectations с provider verification, но требует дисциплины версий и окружений. Verification без точной версии provider или с неверным состоянием данных создаёт видимость доказательства.

Проверяемый критерий готовности

Изменение готово к выпуску, когда для каждого затронутого consumer есть versioned contract и scenario; schema-check прошёл; отрицательная ветка описана; provider verification выполнилась в нужном provider state; PASS связан с точной версией provider; решение о deploy проверено по актуальной матрице. Если любой пункт неизвестен, статус — «не готово к выпуску».

В учебном случае ответ 200 с active и renewalAt: null проходит формальную схему, но не проходит ожидание экрана продления. Это не доказывает дефект provider. Это требует уточнить семантику и выполнить настоящую provider verification до решения о выпуске.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/161.json b/editorial/agent-rewrites/161.json new file mode 100644 index 0000000..a8810a9 --- /dev/null +++ b/editorial/agent-rewrites/161.json @@ -0,0 +1,7 @@ +{ + "index": 161, + "slug": "editorial-2023-07-mechanism-contract-tests", + "title": "Почему schema match не равен совместимости API", + "excerpt": "Ответ API может пройти схему и всё равно сломать действие на экране. Разбираем три уровня контракта: форму JSON, ожидание consumer и проверку provider.", + "contentHtml": "

Экран получает HTTP 200, обязательные ключи на месте, типы совпадают с OpenAPI. Но кнопка продления не появляется: ответ содержит state: \"active\" и renewalAt: null. Схема допускает оба значения. Consumer ожидал дату, по которой можно показать действие. Пользователь видит неполный сценарий, поддержка получает жалобу, а команда спорит, был ли релиз совместимым.

\n

Цена ошибки растёт из-за ложного зелёного сигнала. Проверка JSON подтверждает форму, но не подтверждает, что consumer сможет закончить свой сценарий. Provider считает, что поле не менялось. Consumer видит изменение смысла. Владелец релиза видит успешный job и не получает основания остановить выкладку.

\n

Тезис статьи простой: контракт API состоит как минимум из трёх разных доказательств. Schema match проверяет структуру. Consumer expectation проверяет нужное поведение. Provider verification проверяет, что конкретная версия provider действительно отвечает опубликованному interaction. Один результат нельзя выдавать за другой.

\n

Три вопроса к одному ответу

\n

Сначала отделите форму от смысла. OpenAPI описывает интерфейс, который могут использовать люди и инструменты. Schema Object задаёт типы, обязательность, перечисления и допустимые варианты. Это хороший барьер против пропавшего ключа, числа вместо строки и неизвестного значения enum.

\n

Но схема не знает, какое действие должен показать конкретный экран. Поле renewalAt может быть nullable для одного клиента и обязательным условием для другого сценария. Поэтому второй уровень должен принадлежать consumer: «для экрана продления активная подписка должна иметь применимую дату». Это уже не только свойство JSON. Это правило принятия решения.

\n

Третий уровень связывает ожидание с provider. Provider verification исполняет interaction на согласованном состоянии provider и сравнивает фактический ответ с контрактом. Без такого результата у команды есть описание ожидания, но нет доказательства, что provider его выполнил.

\n
Что означает каждый результат
СимптомПричинаПроверкаДействие
Ответ 200, экран не показывает действиеСмысл поля шире ожидания consumerВоспроизвести decision rule на ответе active + nullУточнить state, добавить явное поле или изменить consumer
Пропал ключ или изменился типНарушена schema-границаПроверить required, type, enum и nullable для версии схемыИсправить provider либо согласовать версионное изменение
Consumer contract зелёный, provider не проверенПроверили только mock-ответНайти результат verification для версии provider и provider stateНе называть выпуск совместимым до реального результата
Один interaction зелёный, старый клиент сломанContract покрывает не всех consumersСверить список клиентов и поддерживаемые версииДобавить interaction или ограничить решение областью проверки
\n

Учебный пример: active не обещает дату

\n

Рассмотрим искусственный сценарий GET /v1/subscriptions/sub-42. Имена synthetic-portal-web и synthetic-billing-api нужны только для объяснения механизма. Это не лог реального сервиса, не результат запуска и не утверждение о production.

\n

Общая schema может разрешать такой ответ:

\n
{\n  \"id\": \"sub-42\",\n  \"state\": \"active\",\n  \"renewalAt\": null\n}
\n

На уровне формы ответ выглядит допустимым. На уровне consumer он не подходит экрану продления: у экрана нет даты и он не должен выдумывать её. Правило можно записать рядом с interaction:

\n
const expectation = ({ state, renewalAt }) =>\n  state === 'active' && isFutureIsoDate(renewalAt);\n\nexpect(expectation(response)).toBe(true);
\n

Этот код показывает только идею decision rule. Он не валидирует OpenAPI-документ, не вызывает HTTP, не поднимает provider и не запускает Pact. В реальном тесте надо определить формат даты, часовой пояс, момент отсчёта и provider state. Если эти условия не названы, тест может пройти на случайных данных и не защищать нужный сценарий.

\n

Теперь различие видно на трёх ответах. active с будущей датой может пройти форму и ожидание. active + null может пройти форму, но нарушить ожидание consumer. Число в state должно остановиться уже на схеме. Такая классификация полезнее единого флага compatible: true: она показывает, где именно возникло расхождение.

\n
\"Матрица
Матрица разделяет форму ответа, смысл для consumer и фактическую проверку provider. Отметки относятся к учебной модели и не являются результатом production-запуска.
\n

Как записать contract, который помогает принять решение

\n

Начните с одного действия consumer, а не со всего API. Назовите метод, путь, вход, ожидаемый статус и минимальный ответ. Затем запишите provider state. Формулировка «подписка активна» слишком общая, если экрану нужна именно дата продления после текущего момента. State должен объяснять, почему provider обязан вернуть нужные данные.

\n

Отдельно укажите отрицательный путь. Например: если state равен active, но renewalAt отсутствует или уже прошёл, consumer не показывает кнопку продления и сообщает, что действие недоступно. Это не означает, что поле надо сделать non-null для всех клиентов. Отрицательная ветка фиксирует решение одного сценария.

\n

Храните рядом версии. Укажите версию schema, consumer contract, provider и provider state. Версия OpenAPI не заменяет версию API-сборки. Версия consumer не доказывает, какую сборку provider проверяли. Эти указатели нужны, чтобы зелёный результат можно было воспроизвести и связать с конкретным изменением.

\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Запишите request, response, статус и пользовательское действие, которое не завершилось. Не называйте причину до проверки.
  2. Проверьте форму. Сверьте версию schema, media type, required-поля, типы, enum и nullable-границы. Если форма нарушена, исправляйте её прежде, чем обсуждать смысл.
  3. Опишите решение consumer. Укажите, какое поле запускает действие и что должен сделать клиент при его отсутствии, просрочке или неизвестном значении.
  4. Назовите provider state. Определите состояние данных, в котором interaction должен быть выполнен. Не заменяйте его удобным mock-ответом без объяснения.
  5. Запустите provider verification. Исполните опубликованный contract против согласованной версии provider. Сохраните результат, версию и список проверенных interactions.
  6. Примите ограниченное решение. Разрешайте выпуск только для проверенных consumers, states и версий. Для остальных оставьте явный риск или остановите изменение.
\n

Почему schema match недостаточен

\n

Schema проверяет допустимость значения, а не его полезность для каждого клиента. Nullable-поле может быть корректным по общему договору и непригодным для конкретного действия. Enum может сохранить прежний набор строк, но поменять бизнес-смысл каждой строки. HTTP 200 может сообщать об успешной обработке запроса, но не о готовности пользовательского шага.

\n

Consumer-driven contract помогает сузить проверку до реальной потребности клиента. Он не пытается описать все возможные ответы provider. Это достоинство для быстрого feedback, но и ограничение: неохваченный consumer остаётся неохваченным. Список interactions надо поддерживать вместе со списком клиентов, иначе команда легко перенесёт результат одного экрана на весь API.

\n

Provider verification тоже не даёт универсальной гарантии. Она подтверждает конкретные interactions в подготовленных состояниях. Она не заменяет авторизацию, миграцию данных, нагрузочные проверки, совместимость старых мобильных версий и наблюдение после выкладки. Эти проверки отвечают на другие вопросы.

\n

Ограничения и отрицательный путь

\n

Учебный код выше не доказывает совместимость реальных версий. В нём нет сети, broker, Pact, авторизации, зависимостей provider и production-данных. Даже корректный локальный результат означает только то, что правило примера отделяет форму от смысла. Нельзя писать в release-описании «provider verified», если запуск provider verification не состоялся.

\n

Если verification не прошла, сначала сохраните исходный contract и ответ. Затем решите, где находится граница изменения. Иногда provider должен вернуть прежний смысл. Иногда consumer должен перестать трактовать active слишком узко. Иногда безопаснее добавить новое поле и временно поддержать оба варианта. Автоматически делать nullable-поле обязательным нельзя: это может сломать другие сценарии.

\n

Rollback также требует конкретики. Назовите версии, которые можно вернуть, данные, уже записанные новым кодом, и consumer, который ещё читает старый ответ. Snapshot JSON не откатывает endpoint, базу, флаг или опубликованный артефакт. Если эти условия неизвестны, готовность к rollback не доказана.

\n

Критерий готовности

\n

Изменение готово к выпуску, когда выполнены все четыре условия: schema проверена для нужной версии; consumer expectation содержит положительную и отрицательную ветки; provider state и версия provider названы; provider verification дала сохранённый результат для каждого consumer, которого затрагивает изменение. Если хотя бы одного пункта нет, вывод должен звучать точнее: «форма проверена», «ожидание записано» или «verification не запускалась». Слово «совместимо» оставляйте только для доказанной области.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/162.json b/editorial/agent-rewrites/162.json new file mode 100644 index 0000000..e8084c0 --- /dev/null +++ b/editorial/agent-rewrites/162.json @@ -0,0 +1,7 @@ +{ + "index": 162, + "slug": "editorial-2023-07-practice-contract-tests", + "title": "Контрактные тесты API: как поймать совместимое на вид изменение", + "excerpt": "HTTP 200 и валидная схема ещё не означают, что consumer сможет продолжить сценарий. Разбираем смысловой контракт, provider verification и критерий готовности.", + "contentHtml": "

API возвращает 200 OK. Все обязательные поля на месте. Валидатор схемы сообщает PASS. После релиза экран всё равно не показывает действие, ради которого запрашивал данные. Например, поле state осталось строкой active, но поле renewalAt стало null. Для общей схемы ответ допустим. Для экрана продления — нет: пользователь видит подписку, но не получает дату и не может продолжить операцию.

\n

Цена ошибки растёт быстро. Consumer показывает неверное состояние или молча прячет кнопку. Поддержка получает жалобу, которую трудно повторить. Provider доказывает, что формат не менялся, а команда релиза видит зелёную проверку. Затем приходится откатывать версии или добавлять срочный обход. Ошибка возникла не в JSON-синтаксисе. Она возникла в несогласованном смысле поля.

\n

Тезис статьи простой: контрактный тест должен фиксировать наблюдаемое требование конкретного consumer, а не только форму ответа. Проверяйте три слоя отдельно: схему, смысловой сценарий и фактический запуск provider. PASS одного слоя не заменяет PASS другого.

\n

Где ломается обычная проверка схемы

\n

OpenAPI описывает интерфейс HTTP API: путь, метод, параметры, статусы и структуру ответа. Это полезная граница. Она ловит исчезнувшее поле, неверный тип и неизвестное значение перечисления. Но схема не знает, какое действие должен показать конкретный экран. null может быть допустимым для одного consumer и неприемлемым для другого.

\n

Представим endpoint GET /v1/subscriptions/sub-42. Общий ответ может выглядеть так:

\n
{\n  \"id\": \"sub-42\",\n  \"state\": \"active\",\n  \"renewalAt\": null\n}
\n

Схема проверяет, что id — строка, state входит в перечисление, а renewalAt имеет тип даты или допускает null. Consumer для экрана продления проверяет другое правило: если состояние active, дата следующего продления должна быть будущей и пригодной для отображения. Это правило нельзя считать выполненным только потому, что типы совпали.

\n

Такой пример учебный. Имена endpoint, provider и данные условны. Он не сообщает о конкретной production-системе, не запускает сеть и не доказывает совместимость реальных версий.

\n

Три слоя доказательства

\n
Что доказывает каждая проверка
СлойЧто проверяемЧто означает PASSЧего PASS не означает
Schema matchПоля, типы, enum, обязательность и nullable-границы.Ответ соответствует описанной форме.Consumer может завершить свой пользовательский сценарий.
Semantic expectationМинимальное значение, нужное конкретному consumer.Ответ содержит предусловие выбранного действия.Provider действительно обработал запрос.
Provider verificationInteraction исполняется на provider в названном состоянии.Запущенный provider вернул ожидаемый ответ для этого contract.Проверены все клиенты, методы и варианты данных.
\n

Разделение помогает остановить неправильный вывод. Если schema match проходит, а semantic expectation падает, не надо немедленно запрещать null во всём API. Сначала определите, принадлежит ли требование одному consumer или общему доменному контракту. Если provider verification не запускался, нельзя называть ответ совместимым только по файлу с примером.

\n

Как записать смысловой контракт

\n

Начните с действия consumer. Не пишите «поле должно быть корректным». Напишите: «экран продления показывает дату и разрешает продолжение, если подписка активна». Затем назовите request, provider state и минимальный response. Например: provider state — «подписка sub-42 активна и имеет будущую дату»; request — GET /v1/subscriptions/sub-42; обязательное предусловие — renewalAt содержит будущую дату в ISO-формате.

\n

Такой contract не обязан описывать весь домен. Его задача — защитить один реально используемый сценарий. Чем меньше interaction, тем проще понять, какой change сломал ожидание. Но минимальность не должна удалять важное условие. Если consumer принимает решение по дате, дату надо проверять как значение, а не оставлять только как nullable-тип.

\n
const response = {\n  id: 'sub-42',\n  state: 'active',\n  renewalAt: '2026-09-30T00:00:00Z',\n};\n\nexpect(response.state).toBe('active');\nexpect(response.renewalAt).toMatch(\n  /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$/\n);\nexpect(Date.parse(response.renewalAt)).toBeGreaterThan(Date.now());
\n

Код выше — учебная проверка значения. Она не является готовым Pact-тестом: здесь нет consumer client, mock server, contract broker и provider verification. В рабочем тесте assertion должен проходить через реальный код доступа consumer, чтобы contract отражал его запрос и его решение, а не отдельно созданный объект.

\n

Почему нужен provider verification

\n

Consumer-тест формулирует ожидание и может записать interaction в contract. Provider verification берёт этот contract, подготавливает названное состояние, отправляет запрос запущенному provider и сравнивает фактический ответ с ожиданием. Это замыкает связь между тем, что нужно consumer, и тем, что действительно возвращает provider.

\n

У provider state должна быть ясная граница. Запись «есть активная подписка» недостаточна, если не указано, есть ли дата, кому принадлежит запись и какие зависимости должны быть доступны. Подготовка состояния не должна превращаться в случайное ручное редактирование общей базы. Иначе тест может пройти один раз и перестать объяснять, почему.

\n

Проверка provider отвечает на узкий вопрос: удовлетворяет ли конкретная версия provider конкретному набору interactions в подготовленном состоянии. Она не проверяет производительность, авторизацию всех ролей, миграцию каждой записи, UI и не вошедшие в contract клиенты. Эта граница должна попасть в решение о выпуске.

\n
\"Схема
Consumer формулирует потребность, contract фиксирует request и expectation, provider verification исполняет interaction в названном состоянии. Схема учебная: она не является сетевой трассой и не доказывает запуск конкретного сервиса.
\n

Симптом → причина → проверка → действие

\n
Карта разбора расхождения consumer и provider
СимптомПричинаПроверкаДействие
Схема зелёная, экран не показывает действие.Смысловое предусловие не записано: допустимый null стал непригодным для consumer.Назвать решение, которое принимает экран, и минимальное значение для него.Добавить semantic expectation для конкретного сценария или изменить общий контракт после согласования владельцев.
Provider verification падает на пустом поле.Provider state не создаёт данные, обещанные interaction.Проверить подготовку состояния, идентификатор записи и фактический response.Исправить state setup или уточнить contract; не добавлять случайный default в assertion.
Consumer-тест проходит, provider verification не запускался.Проверили mock или сохранённый JSON, но не реальный provider.Найти результат verifier, версию contract, версию provider и номер interaction.Запустить проверку на управляемом provider и опубликовать результат рядом с contract.
Один contract прошёл, другой consumer сломался.Общее поле использовалось с разными ожиданиями.Составить список consumer и сравнить их semantic expectations.Разделить endpoint или поле, версионировать изменение либо добавить совместимое новое поле.
Тест падает только на старых данных.Новый смысл поля не поддерживает исторические записи.Проверить варианты данных до миграции и после неё.Добавить миграцию, fallback с явным сроком или запрет выпуска до готовности данных.
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите endpoint, статус, поля ответа, consumer-сценарий и цену отказа. Формулировка «API несовместим» слишком широка.
  2. Отделите форму от смысла. Проверьте schema match отдельно. Укажите, какие значения схема разрешает и какое из них не подходит выбранному consumer.
  3. Назовите предусловие. Запишите действие пользователя и минимальный response, без которого действие должно исчезнуть или перейти в понятный отрицательный путь.
  4. Определите provider state. Укажите идентификатор данных, состояние зависимостей и способ подготовки. Не ссылайтесь на «обычную тестовую базу» без воспроизводимого описания.
  5. Сформируйте interaction. Включите только нужный request и response, но сохраните все поля, по которым consumer принимает решение. Привяжите contract к версии consumer.
  6. Запустите provider verification. Исполните interaction на конкретной версии provider. Сохраните результат, версию, состояние и номер проверки.
  7. Проверьте отрицательный путь. Ответ с active и null должен привести к заранее определённому поведению: безопасному сообщению, скрытому действию или отказу с причиной. Не превращайте отсутствие данных в успех.
  8. Примите решение о выпуске. Разрешайте изменение только для перечисленных consumer и проверенных состояний. Для остальных клиентов оставьте совместимое поле, новую версию или план миграции.
\n

Ограничения

\n

Контрактные тесты не доказывают, что API корректен во всех ситуациях. Они проверяют выбранные interactions. Слишком широкий contract становится хрупким и плохо показывает причину отказа. Слишком узкий contract пропускает важное решение consumer. Баланс задаёт реальное использование: защищайте действия, за которые отвечает клиент.

\n

Provider verification не заменяет интеграционные тесты с настоящими зависимостями, тесты авторизации, нагрузочные проверки и наблюдение после выпуска. Mock может скрыть неверный timeout или ошибку сериализации. Проверка схемы может пройти для даты, которая формально валидна, но уже просрочена. Временные правила и миграции требуют отдельных проверок.

\n

Примеры в статье учебные. Они не запускались против production, не измеряют частоту отказов и не сообщают о совместимости конкретных сервисов. Для реального изменения укажите версии, подготовьте изолированное состояние и сохраните фактический результат verifier. Если запуск не выполнялся, напишите «не проверено», а не «совместимо».

\n

Критерий готовности

\n

Изменение готово к выпуску, когда для каждого затронутого consumer записаны его сценарий, request, provider state и semantic expectation; schema match и provider verification имеют отдельные результаты; отрицательный путь проверяет непригодное значение; а решение связано с конкретными версиями provider и consumer. Для примера это означает: ответ с будущей датой проходит сценарий продления, ответ с null не выдаётся за успех, а фактический provider verification подтверждает interaction на подготовленном состоянии. Если есть только зелёная схема или mock, доказательство ещё не завершено.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/163.json b/editorial/agent-rewrites/163.json new file mode 100644 index 0000000..6942994 --- /dev/null +++ b/editorial/agent-rewrites/163.json @@ -0,0 +1,7 @@ +{ + "index": 163, + "slug": "editorial-2023-06-field-secrets-supply-chain", + "title": "Когда deploy виден, а происхождение нет: проверяем цепочку поставки", + "excerpt": "Практический разбор разрыва между исходным кодом, сборкой, digest, декларацией и deploy input. Секретная граница, отрицательный путь и критерий, после которого выпуск можно проверять дальше.", + "contentHtml": "

В отчёте CI есть успешная сборка. В registry лежит образ. Deploy указывает на digest. Но команда не может ответить, из какого commit собран этот digest, какой builder его выпустил и какая декларация относится именно к нему. Ошибка проявляется поздно: релиз уже обсуждают, а provenance приходится восстанавливать по разным системам.

\n

Цена разрыва — не только задержка. Команда может принять чужой образ за результат доверенной сборки. Она может отозвать не тот credential, откатить не тот digest или назвать подписанную декларацию доказательством факта, которого она не проверяет. Секрет при этом способен попасть в логи, слой образа, кеш или артефакт CI.

\n

Тезис простой: deploy сам по себе показывает только выбранный вход. Без сопоставления source revision → builder identity → secret boundary → artifact digest → declaration → deploy input нельзя утверждать происхождение результата. Каждый переход требует своего evidence. Отсутствующий переход переводит выпуск в ручной review, а не в подтверждённое provenance.

\n

Механизм разрыва

\n

Цепочка поставки состоит из разных утверждений. Commit отвечает на вопрос о входном коде. Builder и его identity отвечают на вопрос о процессе сборки. Secret boundary показывает, какие данные могли попасть в процесс и куда им запрещено уходить. Digest связывает байты артефакта с конкретным output. Declaration описывает claims о сборке. Deploy input показывает, что именно пытались применить.

\n

Соседнее утверждение не заменяет пропущенное. Имя job не доказывает, что job выполнила сборку. Digest не доказывает commit. Декларация не доказывает, что её subject попал в deploy. Подпись подтверждает целостность подписанного объекта при корректной проверке, но не превращает любой текст в наблюдение среды.

\n

Такой разбор нужен и для секретов. Секрет не должен проходить через Dockerfile, командную строку, переменную, которую печатает shell, или общий кеш. Значение может быть скрыто в логе, но остаться в слое образа. Оно может исчезнуть из образа, но сохраниться в артефакте или history. Поэтому проверяют не только содержимое файла, но и границы процесса.

\n

Минимальный контракт проверки

\n

Начните с одной карточки выпуска. В ней достаточно шести полей: commit, builder, digest, declaration subject, deploy input и граница секрета. Для каждого поля запишите источник, время получения и допустимый способ просмотра. Не копируйте значение секрета. Нужен факт его отсутствия или контролируемого использования, а не само значение.

\n
const release = {\n  sourceRevision: 'abc123',\n  builderIdentity: 'ci.example/build-prod',\n  artifactDigest: 'sha256:...',\n  declarationSubject: 'sha256:...',\n  deployInput: 'sha256:...'\n};\n\nconst sameArtifact =\n  release.artifactDigest === release.declarationSubject &&\n  release.artifactDigest === release.deployInput;\n\nif (!sameArtifact) {\n  throw new Error('manual review: artifact links do not match');\n}\n\n// Учебный пример. Он не проверяет подпись, CI, registry или production.\n// Реальные форматы полей и правила доверия задаёт конкретная среда.\n
\n

Код показывает только одну проверяемую связь: одинаковый digest в артефакте, декларации и deploy input. Он не доказывает, что commit действительно участвовал в сборке. Он не проверяет identity builder, подпись, policy или содержание секрета. Если хотя бы одно поле недоступно, безопасный результат этого примера — ручной review.

\n

Симптом → причина → проверка → действие

\n
Как сузить разрыв в цепочке
СимптомПричинаПроверкаДействие
Deploy содержит digest, но нет commitАртефакт отделён от записи сборкиСопоставьте digest с output конкретной jobОстановите вывод о provenance до появления связи
Declaration есть, subject не совпадаетВыбрана декларация другого артефактаСравните subject digest и deploy inputПереведите выпуск в manual review
Builder указан именем jobНет проверяемой identity и доверенной границыНайдите issuer, workflow и policy проверкиНазначьте отдельную проверку builder
Секрет исчез из лога, но попал в image historyЗначение передали в Dockerfile или командной строкеПроверьте слои, history, cache и exportУдалите секрет из build input и смените credential
Есть digest и подпись, но нет deploy linkПодписали объект, не проверив его использованиеСверьте точный digest с manifest deployНе называйте подпись доказательством release
\n

Секретная граница начинается до сборки

\n

Сборка должна получать секрет только там, где он нужен, и только на время операции. Не задавайте его через ARG, если значение может попасть в историю слоёв. Не выводите окружение командой вроде env в диагностический лог. Не сохраняйте рабочий каталог с credential в артефакт CI. Не передавайте секрет в шаг, который собирает публичный output.

\n

Учебный фрагмент ниже показывает безопасную мысль, а не готовую конфигурацию конкретного CI:

\n
# Учебный пример: имя секрета передаётся в действие, значение не печатается.\n# Фактический синтаксис зависит от CI и secret store.\nrun: ./publish.sh\nenv:\n  REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}\n\n# publish.sh не выполняет: set -x, env, printenv, cat /proc/*/environ\n# и не складывает каталог с credential в артефакты.
\n

Этот пример не доказывает отсутствие утечки. Его нужно дополнить проверкой логов, временных файлов, кеша, image history и прав доступа. Если токен уже появился в публичном output или общем registry, удаление строки из конфигурации не закрывает инцидент. Сначала ограничьте доступ и смените credential по процедуре владельца.

\n

Digest связывает байты, но не всю историю

\n

Digest полезен потому, что связывает имя output с конкретным содержимым. Поэтому release должен хранить полный digest, а не только tag вроде latest. Tag может указывать на другой объект после публикации. Но digest остаётся только якорем. Он не сообщает, кто собрал образ, с каким исходным кодом и какие входы получил builder.

\n

Проверяйте цепочку в прямом порядке, даже если проблема обнаружилась на deploy. Найдите commit. Найдите запись builder. Получите digest output. Сверьте subject декларации. Сверьте manifest deploy. После этого отдельно проверьте, какие данные видел процесс сборки и где они могли сохраниться. Такой порядок не позволяет начать с красивой декларации и подогнать под неё остальные факты.

\n
\"Дерево
Схема показывает порядок сопоставления. Это учебная иллюстрация процедуры, а не CI-отчёт, policy или доказательство конкретного выпуска.
\n

Отрицательный путь

\n

Проверка должна явно описывать отказ. Если declaration subject отличается от deploy digest, не выбирайте ближайший digest по времени. Если builder не имеет проверяемой identity, не принимайте название workflow за identity. Если secret попал в слой, не ограничивайтесь удалением тега: образ и связанные кеши уже требуют отдельной обработки.

\n

Неудача проверки не всегда означает компрометацию. Она означает, что текущих данных недостаточно для заявленного вывода. Это важное различие. Статус not-verified честнее, чем pass, построенный на совпадении имён. Дальнейшее действие выбирают по риску: остановка выпуска, получение evidence, смена credential или rollback к известному digest.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите, какая связь отсутствует: commit → build, build → digest, digest → declaration или declaration → deploy.
  2. Определите спорный output. Используйте полный digest и точный manifest. Не заменяйте их tag или названием job.
  3. Проверьте источник. Сопоставьте commit, workflow, builder identity и время выполнения с одной записью сборки.
  4. Проверьте subject. Сравните digest декларации с digest артефакта и deploy input посимвольно.
  5. Проверьте secret boundary. Ищите значение и его следы в логах, слоях, history, cache, временных файлах и exported artifacts.
  6. Проверьте отрицательный путь. Подставьте другой digest, пропустите declaration или отзовите доступ к secret store. Проверка должна остановиться, а не выбрать ближайший объект.
  7. Выберите действие. При отсутствии связи остановите следующий шаг. При подтверждённой утечке ограничьте доступ и смените credential. Rollback выполняйте только к известному и совместимому кандидату.
  8. Запишите критерий. Укажите, какой новый evidence переводит статус из ручного review в проверенный результат.
\n

Ограничения

\n

Provenance не заменяет сканирование уязвимостей, контроль доступа, защиту registry и проверку содержания артефакта. Подпись не заменяет проверку subject и trusted identity. Digest не гарантирует безопасный исходный код. Secret store не защищает от вывода значения в лог, если build step печатает окружение.

\n

Учебные примеры в статье не выполняют криптографическую проверку, не обращаются к CI, registry или production и не дают production-результатов. Формат declaration, issuer, policy и процедура отзыва зависят от ваших инструментов. Не объявляйте соответствие SLSA или SSDF по одному найденному полю. Сначала проверьте применимый профиль и границы заявленного уровня.

\n

Rollback тоже имеет границу. Он может вернуть известный deploy input, но не удаляет уже скачанный образ, не отзывает credential и не исправляет запись в чужом кеше. Эти действия требуют отдельной операционной процедуры и владельцев. Если известного кандидата нет, безопаснее остановить выпуск и сохранить минимальное evidence.

\n

Проверяемый критерий готовности

\n

Проверка готова, если команда показывает одну карточку выпуска и отвечает на пять вопросов: какой commit вошёл в сборку, какой builder выполнил её, какой digest получен, какой declaration subject с ним совпадает и какой digest указан в deploy. Дополнительно команда показывает, где проверена секретная граница и какой отрицательный сценарий остановил выпуск.

\n

Критерий не требует утверждать больше, чем доказано. Если любой ответ опирается на имя, tag, текст в ticket или декларацию без независимого сопоставления, статус остаётся not-verified. Если все связи проверены допустимыми evidence, отрицательный путь блокирует несоответствие, а план обработки секрета известен, следующий шаг можно принимать в рамках policy конкретной системы.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/164.json b/editorial/agent-rewrites/164.json new file mode 100644 index 0000000..84e452f --- /dev/null +++ b/editorial/agent-rewrites/164.json @@ -0,0 +1,7 @@ +{ + "index": 164, + "slug": "editorial-2023-06-mechanism-secrets-supply-chain", + "title": "Attestation не доказывает provenance: как связать секрет, сборку и артефакт", + "excerpt": "Файл attestation рядом с образом ещё не подтверждает его происхождение. Разбираем границы секрета, digest, builder identity и проверку, которая связывает provenance с тем, что действительно попадает в deploy.", + "contentHtml": "

После выкладки в системе лежит образ и файл attestation. Команда открывает файл, видит source revision и builder, затем помечает релиз как проверенный. Позже выясняется, что statement ссылается на другой digest, identity сборщика никто не проверял, а deploy получил образ по тегу latest. Ошибка стоит дорого: нельзя уверенно определить затронутый артефакт, выбрать безопасный rollback и объяснить аудитору, какой факт подтверждён.

\n

Проблема усиливается, когда в ту же декларацию добавляют сведения о секрете. Доступ CI к секрету не доказывает, что значение не попало в слой образа, cache, metadata или журнал. Provenance отвечает за происхождение output. Secret boundary отвечает за путь доступа к чувствительному значению. Это разные утверждения с разными проверками.

\n

Тезис статьи простой: attestation становится полезным evidence только после независимого сопоставления subject с artifact digest, проверки доверенной identity и связи digest с входом deploy. Наличие файла, подписи или знакомого названия инструмента не заменяет эти операции.

\n

Механизм цепочки

\n

Разложите delivery-поток на отдельные факты. Source revision обозначает вход сборки. Builder identity обозначает исполнителя, которому разрешено выпускать результат. Secret boundary задаёт этап, который может получить ссылку или значение, и этапы, которым оно недоступно. Artifact digest обозначает конкретный набор байтов. Attestation statement заявляет свойства этого output. Verification проверяет statement с заданными правилами доверия. Deploy input показывает, что именно система пыталась запустить.

\n

Нельзя вывести один факт из соседнего. Digest не рассказывает, кто собрал образ. Source revision не доказывает, что именно он попал в output. Подписанная attestation не подтверждает provenance, пока проверка не установила доверенную identity, допустимый формат, claims и тот же subject. Тег образа тоже не заменяет digest: тег может указывать на новый результат.

\n
Уровень утверждения и следующий способ проверки
УровеньЧто можно записатьЧего это не доказываетСледующая проверка
SourceВыбрана ревизия abc123.Сборка использовала именно её.Сопоставить revision с invocation build.
BuilderУказана identity CI.Identity доверена и реально запускала build.Проверить identity и контекст запуска.
ArtifactИзвестен digest образа.Этот digest отправили в deploy.Сверить digest с release input.
AttestationStatement содержит subject.Statement подписан и правдив.Проверить подпись, signer и claims.
Secret boundaryСборка получает секрет на названном этапе.Значение не попало в output.Проверить конкретный путь передачи и места хранения.
\n

Почему секрет нельзя смешивать с provenance

\n

Секрет должен жить внутри ограниченной границы. Например, job получает короткоживущий токен через secret manager, использует его для чтения зависимости и не записывает значение в environment, артефакт сборки или лог. Даже такая схема описывает только ожидаемый путь. Она не доказывает отсутствие утечки без проверки конкретного pipeline и его output.

\n

Особенно опасны аргументы командной строки, переменные, которые CI печатает при ошибке, кеши package manager и Docker layers. Секрет может исчезнуть из финального файла, но остаться в промежуточном слое. Поэтому вопрос «секрет есть в образе?» слишком широк. Сначала назовите образ, digest, слой или metadata и способ проверки. Если evidence нет, статус должен быть not-observed, а не «утечки нет».

\n
\"Схема
Учебная схема разделяет declaration и verification. Она не является журналом CI, результатом подписи или доказательством отсутствия секрета в образе.
\n

Минимальный пример сопоставления

\n

Ниже учебный пример. Он работает только с заранее заданными строками, не читает CI, registry или secret manager и не выполняет deploy. Его задача — показать отрицательный путь: statement про другой subject нельзя принять.

\n
const artifact = {\n  digest: 'sha256:artifact-a',\n  deployInput: 'sha256:artifact-a',\n};\n\nconst statement = {\n  subjectDigest: 'sha256:artifact-b',\n  builder: 'ci.example/build',\n};\n\nconst sameArtifact =\n  artifact.digest === statement.subjectDigest &&\n  artifact.digest === artifact.deployInput;\n\nif (!sameArtifact) {\n  throw new Error('manual review: subject is not the deploy artifact');\n}
\n

Проверка выше не устанавливает, что builder доверенный, подпись действительна или сборка использовала указанную ревизию. Она ловит только несоответствие subject и deploy input. В рабочей системе нужен проверяемый формат attestation, доверенная политика identity, источник digest и результат запуска verifier. Если хотя бы одно звено не наблюдалось, не повышайте статус до verified.

\n

Симптом → причина → проверка → действие

\n
Карта диагностики разрыва в цепочке поставки
СимптомПричинаПроверкаДействие
Attestation есть, но deploy использует тег.Релиз не сохранил digest как вход.Найти фактический digest, переданный в deploy.Остановить вывод о provenance и привязать release к digest.
Subject statement не равен digest образа.Statement собран для другого output или перепутан.Сравнить subject, digest registry и deploy input.Не использовать statement; запросить корректное evidence.
Builder указан, но signer не проверен.Identity смешали с результатом verification.Проверить signer, trust policy и контекст запуска.Оставить статус not-verified до отдельной проверки.
Секрет доступен build, но нет сведений о слоях.Граница доступа описана общо.Проверить command line, logs, cache и layers выбранного output.Сузить исследование до одного digest и не публиковать значение секрета.
Локальная декларация выглядит полной.Текст приняли за наблюдаемый факт среды.Для каждого поля найти источник и время проверки.Отделить declaration от evidence и назначить владельца проверки.
Нужен срочный rollback.Неизвестно, какой output был применён.Связать release record с digest и известным кандидатом.Сначала остановить следующий шаг; rollback выполнять только при известном безопасном кандидате.
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите конкретный разрыв: тег вместо digest, другой subject, непроверенная identity или неизвестный путь секрета.
  2. Назовите предмет проверки. Укажите source revision, builder, artifact digest и deploy input. Не копируйте в задачу секреты, токены и полные журналы.
  3. Сверьте артефакт. Сравните digest из registry, statement и release record. Любое расхождение переводит решение в ручной review.
  4. Проверьте attestation. Установите формат, subject, signer, trust policy и обязательные claims. Отметьте отдельно, что действительно проверил verifier.
  5. Проверьте границу секрета. Назовите job, этап, разрешённый способ доступа и места, где значение могло сохраниться. Не заменяйте проверку списком общих запретов.
  6. Проверьте отрицательный путь. Для другого subject, неизвестной identity или не подтверждённой secret boundary должно быть понятное действие: остановка, ручной review или безопасный отказ.
  7. Свяжите решение с deploy. Разрешайте выпуск только для digest, который прошёл требуемые проверки и совпадает с фактическим входом релиза.
  8. Сохраните минимальное evidence. Зафиксируйте версии, идентификаторы, время, результат проверки и владельца. Чувствительные значения оставьте в контролируемом хранилище.
\n

Ограничения

\n

Provenance не доказывает отсутствие уязвимостей, добросовестность исходного кода или безопасность всех зависимостей. Она описывает происхождение и условия получения output в пределах выбранной модели. Если builder записывает неверные сведения, downstream-проверка должна учитывать доверие к builder и его identity.

\n

Attestation не заменяет сканирование, review зависимостей, контроль доступа, ротацию секретов, тесты и наблюдение после выпуска. Подпись подтверждает целостность statement относительно ключа или identity. Она не делает claims истинными сама по себе. Digest связывает байты, но не объясняет, почему эти байты допустимы.

\n

Учебный код и таблица не запускались против production и не сообщают результат конкретного pipeline. Источники ниже дают официальные модели и спецификации, но не доказывают соответствие вашего проекта. При отсутствии реального verifier корректная формулировка — «не проверено».

\n

Проверяемый критерий готовности

\n

Цепочка готова к решению о выпуске, когда source revision, builder identity, artifact digest и deploy input связаны конкретными записями; subject attestation совпадает с digest; signer и claims проверены по названной trust policy; путь секрета ограничен и проверен для выбранного output; отрицательные случаи переводят решение в ручной review. Если есть только файл attestation, зелёный CI или тег образа, доказательство не завершено.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/165.json b/editorial/agent-rewrites/165.json new file mode 100644 index 0000000..d1358a3 --- /dev/null +++ b/editorial/agent-rewrites/165.json @@ -0,0 +1,7 @@ +{ + "index": 165, + "slug": "editorial-2023-06-practice-secrets-supply-chain", + "title": "Секрет в CI и digest образа: как связать звенья цепочки поставки", + "excerpt": "Секрет не должен становиться частью образа, а deploy должен ссылаться на тот же digest, который прошёл проверки. Разбираем границы, evidence и отрицательный путь.", + "contentHtml": "

В релизе есть запись о deploy, но никто не может быстро ответить на четыре вопроса: из какой ревизии собрали образ, какой процесс его собрал, использовал ли build секрет и какой digest действительно запустили. Симптом часто выглядит безобидно: pipeline зелёный, сервис работает, а расследование останавливается на фразе «образ собрал CI». Цена ошибки появляется позже. Если токен попал в слой образа или deploy взял соседний тег, команда не может надёжно определить затронутый артефакт, отозвать доступ и объяснить происхождение выпуска.

\n

Тезис простой: цепочку поставки нужно проверять как связь фактов, а не как набор названий инструментов. Ревизия исходников, идентичность сборщика, граница секрета, digest образа, утверждение о происхождении и вход deploy должны иметь общий ключ и понятного владельца проверки. Декларация «образ подписан» не заменяет сопоставление subject с digest. Наличие переменной `TOKEN` в CI не доказывает, что её значение не попало в лог или слой образа.

\n

Механизм: секрет проходит этап, но не должен проходить в результат

\n

Секрет нужен build только на коротком шаге: например, чтобы скачать закрытую зависимость. Процесс должен получить ссылку на секрет, использовать её внутри команды и не записать значение в переменную окружения, слой, кэш или stdout. Образ после этого содержит приложение и публичные настройки, но не credential. В Docker BuildKit для такой границы используют secret mount. Build argument и обычная переменная окружения для этого не подходят: они могут сохраниться в истории сборки или финальном образе.

\n
# syntax=docker/dockerfile:1\nFROM node:22-alpine AS build\nWORKDIR /app\nCOPY package*.json ./\n\nRUN --mount=type=secret,id=npmrc,target=/root/.npmrc \\\n    npm ci --ignore-scripts\n\nCOPY . .\nRUN npm run build\n\nFROM nginx:alpine\nCOPY --from=build /app/dist /usr/share/nginx/html
\n

Команда запуска должна передать секрет отдельно от контекста сборки. В учебном примере ниже имя файла условное. Не подставляйте настоящий токен в статью, shell history или CI log.

\n
DOCKER_BUILDKIT=1 docker build \\\n  --secret id=npmrc,src=/path/to/temporary/npmrc \\\n  --tag example/app:build-123 .\n\n# После сборки получить digest из registry и сохранить его\n# как значение, с которым сравниваются attestation и deploy.
\n

Этот код показывает границу передачи. Он не доказывает, что конкретный runner настроен безопасно, что registry доверенный и что deploy использовал правильный digest. Эти утверждения требуют наблюдаемых записей из вашей среды.

\n

Один пример связи фактов

\n

Представьте выпуск `release-123`. Исходная ревизия — `git:abc123`. Сборщик сообщает identity `ci/build-prod`. Registry возвращает digest `sha256:7f...`. Attestation имеет subject с тем же digest и ссылается на `git:abc123`. Deploy получает не тег `build-123`, а полный digest. Тогда расследование может пройти по одной цепочке. Если хотя бы одно звено хранит только свободный текст, связь становится гипотезой.

\n

Тег удобен для человека, но изменяем. Digest адресует конкретный результат. Поэтому тег можно показывать в интерфейсе, а проверяемым входом выкладки считать digest. Если attestation относится к `sha256:91...`, а deploy запускает `sha256:7f...`, выпуск нужно остановить. Нельзя исправить несовпадение новым комментарием в release.

\n
\"Границы
Карта границ доверия. Она помогает назвать проверяемые факты, но не является журналом реального CI и не подтверждает подпись образа.
\n

Симптомы и действия

\n
Как перейти от сигнала к проверке
СимптомПричинаПроверкаДействие
В логах виден фрагмент токенаСекрет попал в stdout, debug или командную строкуПроверить логи шага и маскирование, затем поискать значение в слоях и артефактахОтозвать credential, очистить путь вывода и повторить сборку без утечки
В Dockerfile есть `ARG TOKEN`Секрет передаётся как параметр и может остаться в историиПроверить history и metadata образаПерейти на secret mount и выпустить новый digest
Attestation и deploy ссылаются на разные digestВыкладка использует изменяемый тег или другой outputСравнить точные subject и deploy inputОстановить выпуск, выбрать проверенный digest, выяснить источник расхождения
Есть подпись, но нет записи о builderПроверяют целостность statement, но не происхождение сборкиПроверить identity подписанта и поля provenanceРазделить проверку подписи, builder и исходной ревизии
После deploy нельзя найти исходную ревизиюRelease хранит только номер задачи или короткий тегСверить metadata образа, CI run и commitСделать revision обязательным полем evidence
\n

Порядок действий

\n
  1. Выберите один выпуск и зафиксируйте его точный deploy input. Если система принимает тег, получите digest, который фактически использовал runtime.
  2. Найдите исходную ревизию, из которой собрали этот digest. Не подменяйте её веткой: ветка меняется, commit остаётся идентификатором состояния.
  3. Определите identity builder и сохраните ссылку на конкретный запуск. Запись «собрано CI» недостаточна.
  4. Проверьте границу секрета: где его запросили, какой шаг получил доступ и какие файлы, слои, логи и кэши могли его сохранить.
  5. Сопоставьте subject attestation с digest образа. Затем отдельно проверьте подпись, доверенную identity и ожидаемую ревизию.
  6. Сравните этот же digest с входом deploy. При несовпадении не продолжайте выпуск и не заменяйте digest повторным тегированием.
  7. Зафиксируйте результат и владельца следующей проверки. Для каждого неизвестного факта оставьте статус «не подтверждено».
\n

Отрицательный путь: что делать при разрыве

\n

Наиболее опасная ветка начинается с частичного успеха. Образ собрался, тесты прошли, а subject attestation не совпал с digest deploy. В этот момент нельзя считать выпуск безопасным из-за зелёного pipeline. Остановите продвижение, сохраните безопасные метаданные, определите последний проверенный digest и выясните, где возникло расхождение: в registry, в выборе тега, в подготовке attestation или в конфигурации deploy.

\n

Если секрет уже попал в лог или образ, удаление строки не возвращает безопасность. Отзовите и замените credential по правилам вашей платформы. Удалите доступный артефакт, проверьте кэши и логи, а затем соберите новый образ с другим digest. Не утверждайте, что утечки не было, если проверка охватила только git и не охватила registry или CI.

\n

Ограничения

\n

Пример с Docker — учебный. В нём нет настоящего секрета, registry, CI run, подписи или deploy; плейсхолдер `/path/to/temporary/npmrc` нельзя использовать как production-рецепт. Secret mount снижает риск записи значения в финальный слой, но не защищает от команды, которая сама печатает секрет, сохраняет его в собранный файл или отправляет его в сеть. Secret scanning помогает обнаружить известные шаблоны, но не доказывает отсутствие всех credential.

\n

Provenance описывает заявленные входы и исполнителя. Оно не делает builder доверенным само по себе. Подпись подтверждает связь statement с ключом или доверенной identity, но не превращает любое утверждение в факт. Полная проверка зависит от политики организации, runner, registry, формата attestation и правил deploy. Поэтому статья не заявляет production-результатов и не заменяет проверку конкретной платформы.

\n

Критерий готовности

\n

Материал можно считать применённым к одному выпуску, когда команда без устных пояснений показывает: commit исходников, identity builder, границу доступа к секрету, digest образа, проверенное соответствие subject этому digest и тот же digest на входе deploy. Для отрицательного пути есть запись о том, что происходит при несовпадении. Если хотя бы одного поля нет или его нельзя проверить по первичному источнику, выпуск не помечают как подтверждённый.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/166.json b/editorial/agent-rewrites/166.json new file mode 100644 index 0000000..05495b0 --- /dev/null +++ b/editorial/agent-rewrites/166.json @@ -0,0 +1,7 @@ +{ + "index": 166, + "slug": "editorial-2023-05-field-static-analysis", + "title": "Шумное правило статического анализа: как принять обратимое решение", + "excerpt": "Один результат статического анализа не объясняет, нужно ли менять правило или подавлять сигнал. Разбираем контекст, scope, срок, rollback и проверяемый критерий готовности.", + "contentHtml": "

В CI появляется результат правила, которое ищет передачу недоверенного значения в построение команды. Команда открывает строку, видит безопасный для своего сценария путь и предлагает выключить правило. На следующем запуске исчезают все результаты этой категории. Цена ошибки — потеря сигнала в коде, который ещё никто не проверил, и отсутствие ответа на простой вопрос: почему правило стало тише и кто разрешил это изменение.

\n

Обратная ошибка тоже стоит дорого. Если каждое совпадение называть уязвимостью, review получает ложную срочность. Инженеры начинают закрывать предупреждения по тексту сообщения. После нескольких таких итераций доверие к анализатору падает. Поэтому результат анализатора — это повод проверить контекст, а не готовый вердикт.

\n

Сначала отделите результат от вывода

\n

SARIF хранит сведения об инструменте, правиле, результате и позиции в файле. Эти поля отвечают на вопрос «где и по какой гипотезе сработал анализатор». Они не доказывают, что ветка исполняется, значение пришло из сети или команда действительно запускается. Для этого нужен контекст проекта: источник значения, путь до опасного вызова, граница доверия, владелец кода и область действия решения.

\n

Возьмём узкую учебную гипотезу: значение из параметра запроса передают в функцию, которая строит команду. Фрагмент показывает форму, которую правило может искать. Он не является результатом реального сканирования и не доказывает уязвимость.

\n
function runReport(request) {\n  const reportName = request.query.name;\n  return runShell(`report --name ${reportName}`);\n}\n\n// Учебный контекст: нужно отдельно проверить источник,\n// экранирование, достижимость ветки и фактический sink.
\n

У этого совпадения есть несколько независимых вопросов. Может ли внешний пользователь менять request.query.name? Проверяет ли код значение до вызова? Принимает ли runShell строку как команду или передаёт аргументы безопасным массивом? Попадает ли функция в собираемый артефакт? Пока ответов нет, допустимы только формулировки «результат требует проверки» и «контекст неполный».

\n

Три действия вместо глобального выключателя

\n

keep оставляет результат видимым. Выбирайте его, когда сигнал понятен, но контекст ещё не собран. Это не признание уязвимости и не отказ от исправления. Это сохранение наблюдаемости до следующей проверки.

\n

tune меняет гипотезу правила. Такое действие нужно, если правило захватывает форму, которая не соответствует его назначению: например, оно не отличает безопасный массив аргументов от конкатенации строки. Tune требует новой версии правила, короткого описания diff и проверки того, какие совпадения перестанут появляться.

\n

suppress временно ограничивает один идентифицируемый результат. У него должны быть точный fingerprint, узкий scope, владелец, причина и дата окончания. Suppress не делает код безопасным. Он только задаёт политику отображения конкретного сигнала.

\n

Глобальное disable не заменяет ни одно из этих действий. Оно меняет поведение правила для текущих и будущих результатов. Если проекту действительно нужна такая смена policy, её надо рассматривать отдельно: назвать категорию, оценить потерю сигнала, назначить владельца и определить способ вернуть правило. Нельзя прятать решение уровня policy в комментарии к одному результату.

\n

Минимальный контракт решения

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Один результат выглядит безопаснымНет источника значения и trust boundaryСопоставить ruleId, revision, URI, строку, fingerprint и путь данныхОставить keep до сбора контекста
Правило срабатывает на безопасном APIГипотеза не различает строку и массив аргументовПрочитать intent правила и проверить diff на минимальной паре примеровВыбрать tune с новой revision
Один результат блокирует выпускИсключение не имеет точного scope или срокаПроверить fingerprint, owner, reviewBy и expiresOnРазрешить только scoped suppress
Предлагают выключить всё правилоРезультат одного участка смешан с policy категорииОценить будущие результаты и отдельный rollback policyВынести global disable в отдельное решение
После изменения непонятно, что вернулосьRollback удаляет запись, но не повторяет анализСверить config diff и повторный отчёт на том же commitВернуть точное исключение или прежнюю revision и повторить проверку
\n

Как записать evidence

\n

Запись должна быть короткой, но достаточной для повторной проверки. Для любого действия укажите owner, reason, action и reviewBy. Для tune добавьте новую ruleRevision и описание изменения. Для suppress добавьте точный fingerprint, scope и expiresOn. Дата следующего review не должна быть позже даты окончания исключения.

\n

Причина «шум» ничего не объясняет. Хорошая причина связывает решение с проверяемым фактом: «вызов получает массив аргументов после нормализации; правило ожидает конкатенацию строки; diff проверен на двух учебных формах». В настоящем проекте сюда добавляют ссылку на задачу, commit или сохранённый контекст без секретов. Не добавляйте в публичную запись токены, пользовательские данные и полный фрагмент чувствительного кода.

\n

Учебная проверка отрицательных путей

\n

Небольшой synthetic-пример полезен, когда нужно проверить сам контракт решения. Он должен отклонять неполные варианты: глобальное отключение, suppress без fingerprint, suppress без срока, tune без новой revision и context без trust boundary. Следующий фрагмент запускает только детерминированную проверку объектов в памяти. Он не читает репозиторий, не загружает rule pack, не запускает Semgrep, не меняет CI и не сообщает о найденной уязвимости.

\n
const decision = {\n  action: 'suppress',\n  owner: 'security-review',\n  reason: 'Проверен один synthetic result',\n  fingerprint: 'synthetic-fingerprint-command-001',\n  scope: 'exact-result',\n  reviewBy: '2023-05-20',\n  expiresOn: '2023-05-27'\n};\n\nconst valid =\n  decision.action === 'suppress' &&\n  decision.fingerprint &&\n  decision.scope === 'exact-result' &&\n  decision.reviewBy <= decision.expiresOn;\n\nconsole.log(valid ? 'plan-valid' : 'plan-invalid');\n// Synthetic plan only: configuration is not applied.
\n

Проверка должна быть полезна прежде всего отрицательным исходом. Если убрать fingerprint, изменить scope на общий или поставить reviewBy после expiresOn, план обязан стать недействительным. Если тест проходит при action: 'disable-globally', контракт слишком слабый. Это проверка формы решения, а не доказательство качества правила и не оценка безопасности приложения.

\n
\"Гейт
Сначала собирается контекст результата, затем выбирается узкое действие. Схема показывает policy-контракт, а не запуск анализатора, реальные findings или эффект в production.
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Сохраните ruleId, revision, URI, строку, уровень, сообщение и fingerprint. Не начинайте с изменения конфигурации.
  2. Восстановите контекст. Найдите источник значения, путь до sink, trust boundary, владельца кода и артефакт, в который попадает модуль.
  3. Проверьте intent правила. Прочитайте описание, версию и diff. Отдельно отметьте, что результат показывает, а чего не показывает.
  4. Выберите действие. Используйте keep для неполного контекста, tune для неверной гипотезы, scoped suppress для одного проверенного результата. Global disable вынесите в отдельную policy.
  5. Заполните evidence. Добавьте owner, reason, scope, fingerprint и даты. Для tune укажите новую revision и ожидаемую границу.
  6. Проверьте отрицательный путь. Убедитесь, что неполный контекст, общий suppress и просроченные даты отклоняются.
  7. Подготовьте rollback. Для suppress удалите точное исключение и повторите анализ. Для tune верните прежнюю revision и сравните diff. Не считайте rollback выполненным по одному изменению файла.
\n

Ограничения

\n

Статический анализ не видит весь runtime-контекст. Правило может не знать о конфигурации, feature flag, генерации кода, маршруте данных, правах пользователя и фактическом deploy-артефакте. SARIF не превращает позицию в файле в доказательство исполнения. Одинаковая строка может быть опасной в одном сервисе и безопасной в другом.

\n

Формат исключений и fingerprint зависит от конкретного анализатора и версии CLI. Не переносите поля из учебного объекта в конфигурацию без проверки официальной документации. Не называйте synthetic result находкой, не заявляйте снижение числа ложных срабатываний без измерения и не утверждайте, что опасные случаи не потеряны без проверки на выбранном наборе кода.

\n

Rollback тоже имеет границу. Он возвращает видимость правила или убирает scoped exception. Он не отменяет уже выпущенный код и не доказывает безопасность старого состояния. Если исключение успело скрыть другие результаты, их нужно искать отдельным повторным анализом.

\n

Критерий готовности

\n

Решение готово, когда другой инженер может по записи ответить на пять вопросов: какой result разбирали, какую гипотезу проверяли, почему выбрали keep, tune или suppress, кто и когда пересматривает решение, как вернуть прежнюю видимость. Для tune должна существовать новая revision и проверенный diff. Для suppress должны совпадать fingerprint и scope, а expiry должна быть будущей. Для rollback должен быть выполнен повторный анализ на том же commit или явно зафиксировано, почему это невозможно.

\n

Если хотя бы один ответ отсутствует, глобальное выключение не является исправлением. Оставьте сигнал видимым, назначьте владельца и доберите контекст. Так статический анализ остаётся управляемым источником технических сигналов, а не безымянным переключателем громкости.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/167.json b/editorial/agent-rewrites/167.json new file mode 100644 index 0000000..3775977 --- /dev/null +++ b/editorial/agent-rewrites/167.json @@ -0,0 +1,7 @@ +{ + "index": 167, + "slug": "editorial-2023-05-mechanism-static-analysis", + "title": "SARIF без контекста: как читать результат статического анализа", + "excerpt": "SARIF переносит результат проверки, но не принимает решение за команду. Разбираем границы между правилом, совпадением, строкой в коде и контекстом, который нужен для действия.", + "contentHtml": "

В pull request появляется результат статического анализа. В нём есть ruleId, сообщение, URI файла и номер строки. Один инженер предлагает заблокировать слияние. Другой называет результат ложным срабатыванием и хочет отключить правило. Оба решения преждевременны: файл описывает наблюдение инструмента, но не объясняет, что происходит в приложении.

\n

Цена ошибки зависит от выбранного обхода. Глобальное отключение убирает сигнал и для следующих участков кода. Безусловное блокирование превращает каждое совпадение формы в аварию. Команда тратит время на споры, а важный результат может затеряться среди шумных комментариев. Нужна простая граница: формат хранит данные, правило формулирует гипотезу, результат указывает на совпадение, а решение требует контекста проекта.

\n

Что именно сообщает анализатор

\n

SARIF 2.1.0 — формат обмена результатами статического анализа. В нём можно передать версию формата, инструмент, набор правил, результат и позицию в артефакте. Это общий контейнер для CI, анализатора и просмотрщика. Он не знает, является ли участок достижимым в нужном релизе, кто владеет компонентом и разрешено ли исключение в конкретной команде.

\n

Правило задаёт проверяемую гипотезу. Например: «значение из условно недоверенного источника передали в построение команды». Совпадение с шаблоном показывает только то, что форма кода похожа на гипотезу. Оно не доказывает источник значения, исполнение ветки или наличие уязвимости.

\n

Результат связывает гипотезу с наблюдением. ruleId показывает, какое правило сработало. message объясняет, что заметил инструмент. fingerprint помогает сопоставить результат между запусками. location указывает на файл и строку. Эти поля нужны для навигации и повторной проверки. Они не заменяют проверку исходника и границ данных.

\n
Граница между наблюдением и решением
СлойПример данныхЧто это означаетЧего не доказывает
Форматversion: 2.1.0Как читать logКачество проверки
Правилоid, revision, levelКакая гипотеза заданаРиск именно в этом месте
РезультатruleId, message, fingerprintКакое совпадение найденоДостижимость и влияние
ПозицияURI и номер строкиГде искать наблюдениеЧто код исполняется
Контекстasset, boundary, owner, scopeВ каких условиях принимать решениеПолное покрытие сценариев
\n

Минимальный контекст для review

\n

Чтобы выбрать действие, добавьте к результату пять полей. asset называет компонент или поток данных. entryPoint показывает предполагаемую точку входа. trustBoundary фиксирует, почему значение считают недоверенным. owner указывает роль или человека, который может подтвердить устройство компонента. releaseScope связывает проверку с изменением, веткой или релизом.

\n

Поле может быть неизвестно. Тогда запишите это прямо. Если не найден entry point, статус должен быть «контекст неполный», а не «безопасно». Если неизвестна граница доверия, нельзя объявлять значение проверенным. Такая запись сохраняет отрицательный путь: отсутствие доказательств не превращается ни в finding, ни в false positive.

\n

Пример: результат не равен вердикту

\n

Ниже приведён искусственный объект в памяти. Он не читает файл, не запускает Semgrep, не вызывает shell и не описывает настоящий finding. Значения src/demo-command.js, строки и fingerprint нужны только для показа связей между полями.

\n
const result = {\n  ruleId: 'demo.untrusted-command-construction',\n  message: { text: 'Проверить передачу значения в команду' },\n  partialFingerprints: {\n    primaryLocationLineHash: 'demo-fingerprint-001'\n  },\n  locations: [{\n    physicalLocation: {\n      artifactLocation: { uri: 'src/demo-command.js' },\n      region: { startLine: 14 }\n    }\n  }]\n};\n\nconst context = {\n  asset: 'demo-export-job',\n  entryPoint: 'demo-http-handler',\n  trustBoundary: 'demo-request-parameter',\n  owner: 'demo-security-owner',\n  releaseScope: 'demo-change-2023-05'\n};
\n

Объект результата отвечает на вопрос «что и где совпало». Контекст отвечает на вопрос «какие условия нужно проверить перед действием». В примере нет исходного файла и нет доказательства, что строка исполняется. Поэтому допустимый вывод ограничен: нужно открыть соответствующую ревизию кода, проверить поток значения и подтвердить владельца.

\n
\"Схема
Правило задаёт гипотезу, result указывает на совпадение, а project context связывает его с конкретным решением. Иллюстрация не показывает реальный запуск анализатора.
\n

Симптом → причина → проверка → действие

\n
Диагностика спорного результата
СимптомПричинаПроверкаДействие
Один ruleId повторяется в разных файлахСинтаксическая форма шире проектного контекстаСверить intent и revision правила, затем проверить источники значенийОставить сигнал или уточнить правило с новой revision
В сообщении есть строка, но нет решенияLocation приняли за доказательство исполненияПроверить актуальную ревизию, entry point и достижимость веткиЗаписать контекст; не повышать result до вердикта
Команда хочет убрать правило целикомШум одного результата смешали с политикой для всех файловСравнить scope исключения с областью будущих результатовВыбрать точечное исключение или изменить pattern
Результат исчез после обновленияНет fingerprint и версии правила в записи reviewСопоставить base commit, tool version и revisionПовторить проверку и сохранить исходный result
Никто не подтверждает безопасностьУ контекста нет owner или trust boundaryНазначить владельца и явно отметить неизвестные поляОставить result видимым до получения evidence
\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Сохраните ruleId, revision анализатора, fingerprint, URI, строку и commit. Не добавляйте вывод о риске, которого нет в данных.
  2. Прочитайте intent правила. Определите, какую форму оно ищет, какие языки и файлы входят в scope, какие условия считаются исключением.
  3. Проверьте исходник. Откройте ту же ревизию файла. Найдите entry point, источник значения, преобразования и вызов, на котором сработало правило.
  4. Заполните контекст. Назовите asset, trust boundary, owner и release scope. Неизвестные значения пометьте как неизвестные.
  5. Выберите узкое действие. Keep оставляет правило без изменений. Tune меняет гипотезу и получает новую revision. Scoped suppress ограничивает конкретный идентифицируемый результат и хранит причину, owner и дату пересмотра.
  6. Проверьте отрицательный путь. Если контекст не собран, не отключайте правило и не называйте совпадение подтверждённой уязвимостью. Назначьте следующий проверяемый шаг.
  7. Зафиксируйте rollback. Для изменения policy сохраните прежнюю revision и область действия. Возврат должен быть отдельным изменением конфигурации, а не устной договорённостью.
\n

Почему location и severity недостаточны

\n

Строка в SARIF может устареть между анализом и review. Файл мог измениться, ветка могла не попасть в релиз, а код мог быть недостижимым при нужной конфигурации. Поэтому location — это адрес для проверки, а не доказательство runtime-пути.

\n

Severity тоже не является итоговой оценкой. Уровень правила задаёт ожидаемую реакцию инструмента. Он не учитывает бизнес-ценность asset, права вызывающего кода, компенсирующие проверки и область релиза. Переносить его напрямую в слово «критично» нельзя.

\n

Fingerprint полезен для повторного review, но это не score риска. Он помогает увидеть, что один результат сохранился, переместился или исчез. Причину изменения нужно искать в diff, версии правила и коде, а не в самом fingerprint.

\n

Ограничения метода

\n

Разделение слоёв не даёт гарантии, что анализатор найдёт все ошибки. SARIF может быть неполным или заполненным по-разному разными producer. Static analysis может не знать о динамической загрузке, feature flag, сгенерированном коде и runtime-конфигурации. Контекстная запись не заменяет тест, ручной data-flow review, проверку доступа или воспроизводимый запуск инструмента.

\n

Не каждое правило стоит расширять. Более широкий pattern может поднять шум и увеличить стоимость review. Не каждое исключение стоит запрещать. Узкое, временное исключение с понятным объектом иногда лучше, чем изменение общего правила ради одного безопасного участка. Важны область действия, владелец, причина и дата повторной проверки.

\n

Пример в этой статье синтетический. Он проверяет только смысл полей и порядок рассуждения. Он не сообщает число срабатываний, coverage, false-positive rate, production effect или факт запуска в каком-либо репозитории.

\n

Проверяемый критерий готовности

\n

Результат можно передавать в review, когда выполнены четыре условия: правило и его revision известны; location проверена на актуальном commit; asset, trust boundary и owner записаны либо явно отмечены как неизвестные; выбранное действие имеет scope и способ отмены. Для tune должна существовать новая revision и описание изменённой гипотезы. Для scoped suppress нужны точный объект результата, причина и дата пересмотра.

\n

Если хотя бы одно условие не выполнено, готовый статус — «контекст не собран». Это проверяемый результат: указан недостающий факт, назначен владелец и определён следующий шаг. Такой статус сохраняет сигнал и не обещает того, чего не подтверждают данные.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/168.json b/editorial/agent-rewrites/168.json new file mode 100644 index 0000000..edb8fb6 --- /dev/null +++ b/editorial/agent-rewrites/168.json @@ -0,0 +1,7 @@ +{ + "index": 168, + "slug": "editorial-2023-05-practice-static-analysis", + "title": "Шумное правило статического анализа: как принять решение по одному сигналу", + "excerpt": "Статический анализ показывает совпадение, а не готовый вердикт. Разбираем один сигнал по rule, result, location и контексту, затем выбираем проверяемое действие без глобального отключения защиты.", + "contentHtml": "

В pull request появляется предупреждение: правило увидело передачу значения в функцию, которая строит команду. Строка выглядит безопасно. Значение приходит из внутреннего объекта, ветка закрыта проверкой, а правило повторяется в десятках файлов. После нескольких таких комментариев команда просит выключить его целиком.

\n

Симптом понятен: статический анализ тормозит review и смешивает полезные находки с шумом. Цена ошибки выше, чем время на один комментарий. Глобальное отключение убирает сигнал для следующего участка, который никто ещё не видел. Автоматическое объявление каждой строки уязвимостью создаёт другую проблему: инженеры перестают различать риск и форму совпадения.

\n

Рабочий тезис простой: результат анализатора — это начало проверки, а не её конец. Сначала нужно отделить правило, результат, позицию и контекст. Потом выбрать одно из трёх действий: оставить сигнал, уточнить правило или временно ограничить один результат. Если контекст не собран, сигнал остаётся видимым.

\n

Что именно сообщает анализатор

\n

Правило описывает синтаксическую или семантическую гипотезу. Например, оно ищет передачу условно недоверенного значения в runShell. Результат сообщает, что гипотеза совпала в конкретном месте. Позиция даёт URI, строку и иногда отпечаток. Ни одно из этих полей не говорит само по себе, что ветка исполняется, значение действительно приходит извне или команда достигнет production.

\n

SARIF 2.1.0 полезен как формат обмена этими фактами. В нём можно связать инструмент, версию правила, result, location и fingerprint. Формат не добавляет сведения, которых инструмент не собирал. Поэтому ruleId нельзя читать как готовый security verdict, а startLine — как доказательство достижимости.

\n
Как читать один сигнал статического анализа
СлойСимптомПричинаПроверкаДействие
ПравилоОдинаковый ruleId повторяется в разных модуляхПаттерн шире ожидаемого сценарияПрочитать intent, revision и diff правилаОставить или уточнить pattern
ResultЕсть сообщение и строка, но нет решенияСовпадение приняли за вывод о кодеСверить fingerprint и версию инструментаДобавить контекст, не ставить verdict
LocationУказан URI, но файл уже изменилсяРезультат относится к другой ревизииПроверить commit, строку и entry pointПовторить анализ на актуальной ревизии
КонтекстНепонятно, откуда пришло значениеTrust boundary не записанаНазначить владельца и назвать источникОставить сигнал видимым
РешениеПредлагают выключить правило глобальноТочечный результат смешали с политикойПроверить scope, срок и rollbackВыбрать keep, tune или scoped suppress
\n

Учебный пример: форма совпадения

\n

Ниже приведён синтетический фрагмент. Он нужен, чтобы показать границу между совпадением и выводом. Имена файла, строки и правило вымышлены. Пример не читает репозиторий, не запускает анализатор и не доказывает наличие уязвимости.

\n
function exportReport(request) {\n  const command = request.options.command;\n\n  if (!ALLOWED_COMMANDS.has(command)) {\n    throw new Error('unsupported command');\n  }\n\n  return runShell(command);\n}
\n

Правило demo.untrusted-command-construction может отметить вызов runShell(command). Синтаксически это разумный сигнал: функция получает значение, которое прошло через объект запроса. Но по одному совпадению нельзя установить, что request контролирует внешний пользователь, что проверка ALLOWED_COMMANDS корректна или что функция вызывается в интересующем артефакте.

\n

Проверка должна идти по цепочке данных. Нужно найти источник request, определить границу доверия, проверить содержимое allowlist и проследить вызов до entry point. Если любое звено неизвестно, запись должна сказать «контекст неполный». Это точнее, чем «ложное срабатывание»: отсутствие данных не доказывает безопасность.

\n

В учебной модели результат можно представить так: ruleId связывает совпадение с правилом, revision фиксирует его версию, uri и startLine указывают место, а fingerprint помогает сопоставить тот же результат после повторного запуска. Fingerprint не является оценкой риска. Он не заменяет чтение актуального исходника.

\n
\"Воронка
Учебная воронка разбора одного сигнала. Она показывает порядок классификации и не утверждает наличие findings, coverage или production-эффекта.
\n

Контекст, без которого решение преждевременно

\n

Для одного сигнала достаточно короткой context record. Поле asset называет компонент или артефакт. entryPoint показывает, откуда начинается путь. trustBoundary объясняет, почему значение считают недоверенным. owner называет человека или роль, которая может подтвердить устройство компонента. releaseScope связывает решение с ревизией или изменением, а не со всем продуктом.

\n

Эти поля не обязаны быть заполнены сразу. Но неизвестное нужно записать как неизвестное. Если не найден entry point, нельзя утверждать, что код недостижим. Если неясен источник данных, нельзя утверждать, что значение безопасно. Если результат относится к generated code, сначала нужно выяснить, какой исходный файл владеет поведением. Контекст не превращает сигнал в уязвимость, но делает следующий вопрос проверяемым.

\n

Три действия после классификации

\n

Keep. Правило и результат остаются видимыми. Это правильный исход, когда риск не исключён или данных ещё не хватает. В комментарии достаточно указать, какое поле контекста отсутствует и кто его проверит.

\n

Tune. Правило меняют, когда сама гипотеза слишком широка. Например, pattern можно ограничить известным небезопасным sink или потребовать явного признака внешнего источника. Изменение должно получить новую revision и описание того, какие будущие совпадения оно перестанет показывать. «Стало меньше шума» не объясняет trade-off.

\n

Scoped suppress. Один результат временно исключают, когда правило нужно сохранить, а конкретный участок уже проверен. Исключение должно ссылаться на точный fingerprint или другую устойчивую идентификацию, иметь scope, владельца, причину и дату пересмотра. Срок не должен превращать временное решение в бессрочное разрешение.

\n

Действия по порядку

\n
  1. Сохраните ruleId, revision правила, fingerprint, URI, строку и ревизию исходника. Не добавляйте в запись вывод о безопасности.
  2. Прочитайте intent правила и его diff. Уточните язык, область файлов и условие, которое вызывает совпадение.
  3. Восстановите путь данных: источник, преобразования, проверка, sink и entry point. Для каждого шага отметьте подтверждённое и неизвестное.
  4. Заполните asset, trust boundary, owner и release scope. Если поле неизвестно, поставьте статус context-incomplete.
  5. Выберите keep, tune или точечный suppress. Запишите причину, scope, срок пересмотра и требуемый rollback.
  6. Повторите анализ на актуальной ревизии. Проверьте, что tune изменил ожидаемую форму, а suppress не скрыл соседние результаты.
  7. Проверьте отрицательный путь: глобальное действие disable-globally должно быть отклонено политикой, а неполный контекст не должен превращаться в «безопасно».
\n

Почему исключение не лечит неточное правило

\n

Suppression решает вопрос об одном уже идентифицированном результате. Tune решает вопрос о гипотезе, которую правило применяет к будущим участкам. Если команда раздаёт исключения там, где pattern неправильно понимает boundary, она сохраняет старую ошибку и постепенно теряет карту покрытия. Если команда переписывает правило ради одного проверенного участка, она может скрыть реальные сигналы в других модулях.

\n

Не стоит путать и другой отрицательный путь. Если анализатор показал результат на synthetic fixture, это доказывает только то, что учебный объект соответствует заданной форме. PASS у такого fixture не означает, что scanner читал файл, запускал ветку или получил finding в реальном проекте. Код примера ограничен учебной задачей и не является production-рецептом.

\n

Ограничения метода

\n

Статический анализ не видит автоматически весь runtime-контекст. Feature flag может скрыть путь. Generated code может отличаться от исходного шаблона. Динамический импорт, конфигурация окружения и права доступа могут изменить достижимость. SARIF сохраняет результат инструмента, но не подтверждает корректность правила, полноту проекта и отсутствие других путей к sink.

\n

Метод также не даёт production-метрику. Он не сообщает precision, recall, coverage, число предотвращённых инцидентов или время до исправления. Для таких утверждений нужны отдельные данные: запуски на определённых ревизиях, правила подсчёта и независимая проверка. В этой статье таких измерений нет.

\n

Критерий готовности

\n

Разбор одного сигнала готов, если другой инженер может повторить решение без устного контекста. В записи есть ruleId и revision, точный result, проверенная ревизия исходника, путь от источника до sink, владелец, scope и выбранное действие. Для tune виден diff правила. Для suppress видны идентификатор результата, причина и срок пересмотра. Для keep ясно, какая проверка ещё не выполнена.

\n

Отдельно проверьте, что повторный запуск не создаёт новый необъяснимый сигнал, что соседние результаты не исчезли из-за широкого исключения и что rollback можно выполнить отдельным diff. Если одно из этих условий не выполнено, решение ещё не закрыто. Сигнал лучше оставить видимым, чем скрыть неизвестное за удобной зелёной проверкой.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/169.json b/editorial/agent-rewrites/169.json new file mode 100644 index 0000000..13ba5fe --- /dev/null +++ b/editorial/agent-rewrites/169.json @@ -0,0 +1,7 @@ +{ + "index": 169, + "slug": "editorial-2023-04-field-dependency-security", + "title": "Уязвимая зависимость в дереве: как доказать безопасное обновление", + "excerpt": "Почему предупреждение сканера не равно доказанной уязвимости в runtime и как проверить обновление зависимости по дереву, окружению, сценарию запуска и откату.", + "contentHtml": "

Сканер сообщает об уязвимой версии пакета. Пакет не указан в package.json, поэтому команда решает, что он не используется. Через несколько часов обновление ломает сборку: изменилось транзитивное дерево, peer-зависимость перестала разрешаться, а новый пакет требует другой Node.js. Цена ошибки — остановленный деплой, срочный откат и неясный ответ на вопрос, какой артефакт уже попал в окружение.

\n

Обратная ошибка тоже дорогая. Команда удаляет пакет из манифеста, получает зелёный install и закрывает предупреждение. Но уязвимый модуль остаётся в lockfile или в другом production-артефакте. Исправление должно отвечать на два разных вопроса: входит ли компонент в поставляемый артефакт и что изменится после его обновления.

\n

Тезис: версия не является доказательством

\n

Безопасное обновление — это не замена одной строки в манифесте. Это проверяемая цепочка: идентифицированный компонент, зафиксированное дерево, воспроизводимая установка, проверка приложения в целевом runtime и готовый путь возврата.

\n

Уязвимость обычно приходит через несколько уровней. Приложение зависит от http-client. Он зависит от parser. Advisory указывает на старую версию parser. Вызов метода может находиться далеко от корневого кода, но пакет всё равно входит в установленное дерево. Обратное также верно: запись в lockfile ещё не доказывает, что компонент вошёл в собранный образ или реально загружен процессом.

\n

Как устроена проверка

\n

Сначала отделите четыре объекта. Манифест описывает намерение проекта. Lockfile фиксирует разрешённое дерево и версии записей. SBOM описывает состав конкретного артефакта, если его построили из этого артефакта. Runtime-наблюдение показывает, что произошло при запуске. Эти источники отвечают на разные вопросы и не заменяют друг друга.

\n

Начните с baseline. Запишите commit, package manager, версию Node.js, команду установки и digest исходного артефакта. Затем назовите candidate: пакет, исходную версию, новую версию и advisory. Для транзитивной зависимости добавьте прямую цепочку родителей. Без baseline нельзя понять, удалил ли update уязвимый узел или только переставил его в другое место.

\n
Проверки безопасности обновления
ПроверкаЧто она доказываетЧто сохранить
Dependency treeКакие записи разрешил установщик и кто приводит к уязвимому пакетуDiff lockfile и команду получения дерева
Clean installЧто зафиксированный проект устанавливается в чистой средеВерсию инструмента, exit code и лог
Application testsЧто выбранные контракты приложения не изменилисьНабор тестов и окружение запуска
Runtime smokeЧто сервис стартует и проходит важный сценарийЗапрос, ответ, лог и trace или метрику
Artifact scanЧто проверенный образ или архив не содержит запрещённую версиюDigest артефакта и результат сканирования
RollbackЧто команда может вернуть baseline без новой догадкиСтарый digest, lockfile и условие остановки
\n

Пример с транзитивным пакетом

\n

Следующий фрагмент — учебный. Он не читает настоящий lockfile и не устанавливает пакеты. Он показывает, почему проверка должна искать не только прямые зависимости. В реальном проекте результат нужно получить командой package manager и сопоставить с образом, который будет выпущен.

\n
const tree = {\n  name: 'checkout-service',\n  version: '1.4.0',\n  dependencies: {\n    'http-client': {\n      version: '4.2.0',\n      dependencies: {\n        parser: { version: '1.0.0' }\n      }\n    }\n  }\n};\n\nfunction findPackage(node, name, path = [node.name]) {\n  if (node.name === name) return { version: node.version, path };\n\n  for (const [childName, child] of Object.entries(node.dependencies ?? {})) {\n    const found = findPackage(child, name, [...path, childName]);\n    if (found) return found;\n  }\n\n  return null;\n}\n\nconsole.log(findPackage(tree, 'parser'));\n// { version: '1.0.0', path: [\n//   'checkout-service', 'http-client', 'parser'\n// ] }
\n

Результат примера означает только одно: узел найден в заданной структуре. Он не доказывает наличие CVE, достижимость опасного кода, состав production-образа или безопасность версии 1.0.1. Чтобы сделать вывод о конкретной системе, добавьте источник advisory, реальный lockfile и digest артефакта.

\n

Иллюстрация цепочки доказательств

\n
\"Цепочка
Порядок проверок отделяет состав дерева от поведения приложения. Зелёный install не открывает выпуск без runtime smoke и проверки итогового артефакта.
\n

Симптом → причина → проверка → действие

\n
Диагностика типичных ложных выводов
СимптомПричинаПроверкаДействие
Пакет не виден в package.jsonОн транзитивныйПостроить дерево от production rootОбновить родителя или добавить безопасное разрешение с проверкой результата
Lockfile обновился, но advisory осталсяResolver выбрал другую ветку или копию пакетаНайти все записи имени и версииРазобрать каждого родителя и пересчитать artifact contents
npm ci зелёный, сервис не стартуетНе совпал runtime, peer range, native addon или module formatЗапустить smoke в том же образе и с тем же Node.jsОстановить выпуск, сузить update или подготовить совместимый runtime
Сканер образа не находит старый пакетПроверен не тот digestСопоставить digest скана и release candidateПересканировать именно публикуемый артефакт
Откат возвращает версию, но данные не читаютсяUpdate изменил формат или внешний протоколПроверить обратную совместимость миграцииРазделить изменение формата и замену зависимости
\n

Порядок действий

\n
  1. Скопируйте advisory и укажите источник, пакет, затронутый диапазон и дату проверки. Не называйте компонент уязвимым для приложения, пока не проверили его путь в артефакте.
  2. Зафиксируйте baseline: commit, lockfile, package manager, Node.js, platform и digest текущего кандидата на выпуск.
  3. Постройте dependency tree для production-команды. Найдите прямой путь к каждой копии компонента и проверьте optional и peer-ветки.
  4. Сформируйте минимальный candidate update. Измените только необходимые записи, затем прочитайте весь diff lockfile: версии, integrity, источники, scripts и соседние разрешения.
  5. В чистом окружении выполните установку с теми же флагами, которые использует pipeline. Сохраните команду и exit code. Не превращайте warning о runtime в PASS.
  6. Запустите тесты границ, которых касается пакет: startup, обработка входных данных, сетевой вызов, сборка, CLI или browser bundle. Название теста должно объяснять проверяемый контракт.
  7. Сделайте smoke в образе-кандидате. Проверьте старт, один безопасный сценарий и отрицательный путь. Например, некорректный вход должен получить ожидаемый отказ, а не попасть в обработчик.
  8. Сканируйте образ или архив по immutable digest. Сверьте состав скана с lockfile и SBOM, если SBOM создаёт ваш pipeline. Различие источников — сигнал к расследованию, а не повод выбрать удобный результат.
  9. Перед публикацией проверьте rollback на уровне артефакта. Если изменение затрагивает миграцию, формат данных или внешний протокол, остановите независимый откат версии и подготовьте совместимый план.
\n

Почему engines и lockfile недостаточны

\n

Поле engines выражает заявленный диапазон. Оно не запускает приложение, не проверяет native addon и не показывает, какая ветка разрешилась в конкретной платформе. При мягкой настройке package manager несовпадение может остаться предупреждением. Поэтому engine check полезен как ранний фильтр, но не как итог.

\n

Lockfile даёт воспроизводимую точку для установки. Он не является снимком уже работающего процесса. Сборка может исключить пакет, добавить его в другой слой, заменить optional dependency или использовать иной lockfile. Состав проверяйте на выходном артефакте, а поведение — в целевом runtime.

\n

Ограничения и отрицательный путь

\n

Dependency scanner не знает автоматически, может ли атакующий достичь уязвимого вызова. Runtime smoke не доказывает отсутствие всех опасных путей. SBOM не доказывает, что его создали из того же digest, который публикуют. Результаты нужно связывать с конкретным commit и артефактом.

\n

Если компонент найден, но его путь не достигается в вашем сценарии, это не разрешение игнорировать advisory. Зафиксируйте границу утверждения: «путь не достигнут в проверенном сценарии». Затем проверьте другие entry point, worker, CLI, background job и режимы сборки. Если среда не позволяет выполнить нужный сценарий, статус должен остаться неизвестным. Не заменяйте его словом «безопасно».

\n

Если clean install не проходит, не чините результат добавлением случайного флага. Сначала сравните manifest и lockfile, peer range и runtime. Если smoke не проходит после установки, возврат к baseline предпочтительнее выпуска с неясной совместимостью. Если digest скана не совпадает с digest релиза, остановите публикацию.

\n

Проверяемый критерий готовности

\n

Обновление готово к выпуску, когда команда может предъявить один набор связанных доказательств: advisory и scope, baseline и candidate, полный diff дерева, успешную чистую установку, тесты затронутого контракта, smoke в целевом образе, результат сканирования того же digest и проверяемый rollback. Каждый результат имеет команду, окружение и exit status или наблюдаемый ответ.

\n

Любой пропущенный элемент помечается явно: not run, not applicable с причиной или unknown. Слово PASS допустимо только для реально выполненной проверки. Если доказательства относятся к другому commit, образу или runtime, обновление не готово.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/170.json b/editorial/agent-rewrites/170.json new file mode 100644 index 0000000..a17345f --- /dev/null +++ b/editorial/agent-rewrites/170.json @@ -0,0 +1,7 @@ +{ + "index": 170, + "slug": "editorial-2023-04-mechanism-dependency-security", + "title": "Безопасность зависимостей: как связать advisory, lockfile и SBOM", + "excerpt": "Совпадение имени пакета с advisory ещё не доказывает риск для выпуска. Разбираем, что фиксируют manifest, lockfile и SBOM, как проверить путь зависимости и когда обновление можно считать готовым.", + "contentHtml": "

Сканер сообщает об advisory для транзитивного пакета. Команда меняет строку в package.json, получает зелёный pull request и закрывает задачу. Через день выясняется, что lockfile не изменился, SBOM относится к предыдущему образу, а пакет присутствует только в optional-ветке для другой платформы. Ошибка стоит времени на ложную аварию или, хуже, оставляет настоящий риск без владельца. В обоих случаях команда не может ответить на простой вопрос: какой компонент попал в конкретный артефакт и где он может быть достигнут?

\n

Тезис статьи прост: безопасность зависимости проверяют не по одному имени и не по одному файлу. Сначала связывают внешний сигнал с точной записью в resolved tree. Затем связывают эту запись с SBOM того же build. После этого отдельно проверяют достижимость и поведение в нужном runtime-сценарии. package.json, lockfile, SBOM и runtime evidence описывают разные границы. Совпадение двух списков полезно, но само по себе не закрывает риск.

\n

Четыре вопроса вместо одного

\n

Manifest отвечает на вопрос «что проект просит установить». Для прямой зависимости он хранит имя и допустимый диапазон версий. Запись \"demo-shell\": \"^1.0.0\" не говорит, какая версия окажется в текущем дереве.

\n

Lockfile отвечает на другой вопрос: какое дерево выбрал установщик при конкретных правилах разрешения. Для npm это точное представление дерева, созданного установкой. В нём видны транзитивные пакеты, версии, источники и integrity-поля. Но lockfile не является журналом уже запущенного процесса. Его нужно связать с commit, командой установки и артефактом, который действительно собирает pipeline.

\n

SBOM отвечает на вопрос «какие компоненты заявлены для named artifact и как они связаны». Он может быть независим от конкретного package manager и пригоден для анализа поставки. Но файл без build ID, digest или другой неизменяемой привязки не доказывает состав текущего образа. Runtime evidence отвечает на четвёртый вопрос: что загрузилось и выполнилось в выбранном сценарии. Ни один из этих слоёв не заменяет остальные.

\n
Граница ответственности артефактов
АртефактГлавный вопросПроверкаЧего не доказывает
package.jsonКакую direct dependency просит проект?Diff manifest и review диапазонаТочное resolved tree
package-lock.jsonКакое дерево выбрал installer?Diff lockfile и clean installЗагрузку модуля в процессе
SBOMКакие компоненты заявлены для артефакта?Component records и artifact identityДостижимость по коду и конфигурации
Runtime evidenceЧто произошло в named scenario?Тест, trace или controlled smokeВсе возможные пути приложения
AdvisoryКакой внешний сигнал надо разобрать?ID, источник, package и version scopeВоздействие на этот сервис
\n

Учебный graph: почему одного совпадения мало

\n

Рассмотрим ограниченный fixture. demo-service зависит от demo-shell, а demo-shell — от demo-parser. Advisory указывает на demo-parser@1.0.0. В synthetic lockfile пакет есть. В synthetic SBOM есть запись для того же имени и версии. Это подтверждает пересечение двух заданных массивов. Это не подтверждает, что пакет установлен в production, достижим из реального entry point или уязвим именно в таком контексте.

\n
const locked = packages.find(\n  (item) => item.name === advisory.packageName\n    && item.version === advisory.affectedVersion,\n);\n\nconst recorded = components.some(\n  (item) => item.name === advisory.packageName\n    && item.version === advisory.affectedVersion,\n);\n\nreturn {\n  lockfile: locked ? 'entry-matched' : 'entry-not-found',\n  sbom: recorded ? 'component-matched' : 'component-not-found',\n  reachability: 'not-assessed',\n  vulnerabilityStatus: 'not-determined',\n};
\n

Код намеренно не читает настоящий lockfile, не запускает scanner и не строит call graph. Он показывает важную границу: найденная строка — это факт о входных данных, а не вердикт о безопасности. Если имя отсутствует в lockfile, вопрос меняется на provenance advisory или на несовпадение версии. Если имя есть в lockfile, но отсутствует в SBOM нужного build, нужно проверять генерацию и identity артефакта. Если оба списка совпали, всё ещё остаётся путь использования.

\n
\"Схема
Схема разделяет намерение проекта, resolved tree, инвентарь named artifact и наблюдение runtime. Это иллюстрация учебной модели, а не SBOM или запись production-запуска.
\n

Симптом → причина → проверка → действие

\n
Диагностика несвязанной цепочки поставки
СимптомПричинаПроверкаДействие
Изменился package.json, но lockfile остался прежнимПроверили намерение, но не разрешённое деревоСравнить direct range, lockfile entry и install commandПересобрать candidate tree и review diff
Lockfile и SBOM показывают разные версииSBOM создан из другого commit или buildСверить source revision, artifact digest и время генерацииСгенерировать SBOM для того же candidate artifact
Пакет есть в SBOM, но не найден в пути вызоваСостав поставки смешан с reachabilityПроверить import, entry point, flags и optional branchОставить риск в review до доказанного исключения
После patch update изменилось много строк lockfileResolver обновил транзитивное деревоРазделить added, removed, version и integrity changesПроверить каждую существенную ветку и тесты
Fixture завершился PASSPASS проверяет только synthetic contractПосмотреть статусы provenance, runtime и deploymentНе прикладывать PASS как доказательство production safety
\n

Порядок проверки advisory

\n
  1. Сохраните immutable источник сигнала: URL или ID advisory, имя пакета, affected range и дату получения. Не называйте совпадение имени подтверждённой уязвимостью.
  2. Найдите exact package и version в lockfile того commit, из которого собирается candidate. Запишите путь в дереве: direct dependency, parent и транзитивная ветка.
  3. Сопоставьте компонент с SBOM, сгенерированным для того же артефакта. Зафиксируйте format, generator, source revision и artifact digest.
  4. Проверьте достижимость отдельным методом. Ищите entry point, imports, conditional exports, feature flags, optional dependencies и платформенные ветки. Если метод не покрывает часть пути, запишите unknown.
  5. Сформируйте candidate update и просмотрите полный lockfile diff. Убедитесь, что изменение не добавило другой риск и не изменило дерево шире, чем ожидалось.
  6. Выполните clean install с правилами pipeline, затем проектные тесты и короткий runtime smoke для сценария, где используется затронутая ветка. Результат должен ссылаться на конкретный build.
  7. Запишите решение и rollback. Если доказательств не хватает, статус должен быть «требует проверки проекта», а не «ложное срабатывание» и не «безопасно».
\n

Почему обновление иногда увеличивает область риска

\n

Patch update не ограничивается одной строкой manifest. Новый диапазон может выбрать другую транзитивную версию. Installer может иначе обработать optional dependency на другой платформе. Lifecycle script может изменить содержимое build. Поэтому после обновления смотрят lockfile diff, а не только diff package.json. Важны added и removed packages, version shifts, integrity и source fields.

\n

Отдельная ловушка — SBOM, который лежит рядом с репозиторием, но не рядом с выпуском. Такой документ может быть полезен для разработки и одновременно бесполезен для ответа о deployed image. Минимальный критерий свежести задаёт сам pipeline: SBOM создан из того же source revision и относится к тому же artifact digest, что и release candidate. Это инженерный критерий, который нужно реализовать, а не свойство любого файла с названием SBOM.

\n

Ограничения и отрицательный путь

\n

Учебная модель не знает реальную advisory database, формат конкретного SBOM, private registry, зависимости ОС, native addons, bundle, generated source или runtime configuration. Она не вычисляет vulnerable range и не доказывает отсутствие эксплуатации. Даже реальный путь импорта не равен доказанной уязвимости: нужно понимать, достигается ли опасный код с контролируемыми входными данными и при каких настройках.

\n

Отрицательный путь обязателен. Advisory может не совпасть с exact resolved version. Компонент может быть в lockfile, но отсутствовать в named artifact. SBOM может быть старше образа. Достижимость может зависеть от выключенного по умолчанию флага. В каждом случае нельзя подменять неизвестность уверенным исключением. Укажите, что именно не проверено, каким методом это проверят и кто владеет следующим действием.

\n

Проверяемый критерий готовности

\n

Обновление готово к решению, когда для одного candidate release можно восстановить всю цепочку: advisory → exact package@version → lockfile commit → SBOM → artifact digest → проверенный runtime-сценарий. Для каждого перехода есть ссылка или сохранённый результат. Lockfile diff просмотрен. Clean install и проектные тесты завершились ожидаемо. Неизвестные пути явно перечислены. Rollback описывает, какой артефакт и какую версию возвращают.

\n

Если хотя бы одно звено отсутствует, работа может быть готова к следующему этапу, но не к утверждению безопасности выпуска. Это не бюрократическая формальность. Такая запись позволяет отличить реальный риск от неполного inventory и не потерять его при следующем обновлении.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/171.json b/editorial/agent-rewrites/171.json new file mode 100644 index 0000000..d164f35 --- /dev/null +++ b/editorial/agent-rewrites/171.json @@ -0,0 +1,7 @@ +{ + "index": 171, + "slug": "editorial-2023-04-practice-dependency-security", + "title": "Advisory зависимости: как доказать, что сигнал относится к вашему выпуску", + "excerpt": "Совпадение package и версии с advisory ещё не доказывает риск в приложении. Разбираем lockfile, SBOM, достижимость, обновление и критерий готовности без выдуманных production-выводов.", + "contentHtml": "

Сканер сообщает: пакет совпал с advisory. Команда видит имя и версию, ставит задачу «срочно обновить» и меняет dependency. Через час lockfile разрастается, сборка падает, а никто не может ответить на главный вопрос: этот пакет попал в поставленный артефакт и был ли достижим уязвимый код? Цена ошибки двойная. Ложная тревога задерживает релиз и создаёт шум. Непроверенный сигнал оставляет настоящий риск без владельца.

\n

Тезис простой: advisory — это вход для расследования, а не вердикт о приложении. Сначала разделите четыре факта: внешний advisory, точную запись в lockfile, компонент в SBOM и путь от entry point до нужного кода. Потом проверяйте обновление отдельными gates. Пока связь между этими фактами не доказана, корректный статус — «требует проверки», а не «безопасно» и не «уязвимо в production».

\n

Что именно фиксирует каждый артефакт

\n

Advisory сообщает, какие имена и диапазоны версий описывает источник. Он не знает конфигурацию вашего сервиса. Manifest показывает намерение автора: прямую зависимость и допустимый range. Lockfile показывает дерево, которое resolver выбрал для конкретного состояния проекта. Это важный снимок, но не журнал уже запущенного процесса.

\n

SBOM описывает компоненты и связи выбранного артефакта. Он отвечает на вопрос «что заявлено в этом build», если документ действительно связан с commit и digest. Runtime evidence отвечает на другой вопрос: что загрузил конкретный процесс. Эти слои можно сопоставить, но нельзя заменить один другим. Наличие строки в lockfile не доказывает наличие строки в образе. Наличие компонента в SBOM не доказывает вызов уязвимой функции.

\n
Разделяйте наблюдение, вывод и действие
СимптомПричинаПроверкаДействие
Advisory совпал с package@versionВнешний сигнал приняли за факт о сервисеСохранить URL, дату, диапазон и источникОткрыть triage, не объявляя production-уязвимость
Пакет есть в lockfileResolved tree смешали с deployed artifactНайти путь в дереве и сверить build identityПроверить SBOM того же артефакта
Пакет есть в SBOMInventory приняли за runtime traceСверить компонент с образом и режимом запускаПроверить импорт или загрузку в нужной конфигурации
Прямого импорта нетЗабыли транзитивный, optional или dynamic pathПроверить graph, plugin registration и feature flagОписать достижимость как гипотезу с методом проверки
После update изменилось много записейResolver пересобрал дерево, а scope не зафиксировалиРазобрать lockfile diff и peer/optional branchesСузить изменение или отдельно подтвердить совместимость
\n

Минимальная цепочка доказательств

\n

Начните с exact package и version. Запишите commit, в котором возник сигнал, и место записи в lockfile. Если пакет транзитивный, сохраните parent path: без него невозможно понять, какая прямая зависимость привела компонент в дерево. Затем найдите SBOM, созданный для candidate build. У него должен быть устойчивый идентификатор: commit, image digest или иной идентификатор артефакта. Файл с названием sbom.json без такой связи — только неподтверждённый документ.

\n

После этого сформулируйте путь достижимости. Не пишите «пакет используется». Пишите: «entry point A при конфигурации B импортирует модуль C, который вызывает ветку D». Для статического графа это возможная связь. Для теста — путь выбранного сценария. Для trace — наблюдение одного запуска. У каждого метода есть границы. Ни один метод сам по себе не перечисляет все платформы, флаги и входы.

\n
advisory URL\n  -> package@version\n  -> lockfile path\n  -> SBOM component + artifact digest\n  -> entry point + configuration\n  -> reachability evidence\n  -> decision with owner
\n

Если звено отсутствует, обозначьте его как unknown. Не заполняйте пробел догадкой. Такой формат помогает review: следующий человек видит не только вывод, но и место, где доказательство заканчивается.

\n

Учебный пример: совпадение строк не равно уязвимости

\n

Ниже приведён ограниченный учебный пример. Имена demo-service, demo-parser и SYNTHETIC-ADVISORY-001 вымышлены. Они не взяты из registry, scanner, lockfile, SBOM или production trace. Код только сравнивает заранее заданные значения в памяти. Он не устанавливает пакет, не строит image и не запускает приложение.

\n
const input = {\n  advisory: { id: 'SYNTHETIC-ADVISORY-001', package: 'demo-parser', version: '1.0.0' },\n  lockfile: [{ package: 'demo-parser', version: '1.0.0' }],\n  sbom: [{ package: 'demo-parser', version: '1.0.0' }],\n  entryPoint: 'demo-http-handler',\n  path: 'demo-http-handler -> demo-shell -> demo-parser'\n};\n\nconst listedInLockfile = input.lockfile.some((x) =>\n  x.package === input.advisory.package && x.version === input.advisory.version\n);\nconst listedInSbom = input.sbom.some((x) =>\n  x.package === input.advisory.package && x.version === input.advisory.version\n);\n\nconsole.log({ listedInLockfile, listedInSbom,\n  reachability: 'not-assessed-by-example',\n  vulnerability: 'not-determined-by-example'\n});
\n

Даже если обе проверки вернут true, пример доказывает только совпадение полей в двух массивах. Он не доказывает, что demo-parser попал в образ, был загружен процессом или достиг уязвимой функции. Отрицательный путь важнее положительного: отсутствие записи в lockfile не доказывает отсутствие компонента в другом build, а наличие записи не доказывает runtime-достижимость. Учебный PASS нельзя прикладывать к production ticket как результат сканирования.

\n
\"Схема
Схема разделяет внешний advisory, инвентарь компонентов и гипотезу достижимости. Иллюстрация не содержит данных production и не заменяет evidence проекта.
\n

Порядок проверки

\n
  1. Зафиксируйте сигнал. Сохраните advisory URL или идентификатор базы, дату, package, точную версию и затронутый диапазон. Не переписывайте формулировку источника в более сильный вывод.
  2. Найдите baseline. Назовите commit и lockfile, которые относятся к проверяемому build. Для транзитивного пакета сохраните полный путь от direct dependency.
  3. Сверьте инвентарь. Найдите SBOM candidate artifact и проверьте его provenance: commit, digest, формат и генератор. Если связь с артефактом не доказана, статус SBOM — «не подтверждён».
  4. Опишите путь. Укажите entry point, режим, feature flag, dynamic import или plugin registration. Для каждого утверждения назовите метод: граф, тест, trace или ручная проверка.
  5. Проверьте отрицательную ветку. Убедитесь, что пакет не только отсутствует в прямом импорте, но и не приходит через другой parent, optional dependency, build-time branch или старый образ.
  6. Сформируйте candidate update. Запишите from/to version, ожидаемый lockfile diff, peer dependencies, optional branches, lifecycle scripts и rollback на baseline.
  7. Пройдите gates. Выполните чистую установку по lockfile, targeted tests и контролируемый runtime smoke в названном окружении. Сохраните ссылки на результаты каждого gate.
  8. Примите решение. Закройте задачу только с доказанными границами: исправлено и проверено, не найдено в конкретном артефакте или требует дополнительного project review.
\n

Почему быстрое обновление часто не является исправлением

\n

Изменение одной строки в manifest может перестроить транзитивное дерево. Меняются peer dependencies, optional packages, integrity, module format и lifecycle scripts. Поэтому смотрите не только на целевую версию, но и на весь lockfile diff. Если diff неожиданно широк, сначала объясните каждое изменение. Большой diff не делает update неправильным, но увеличивает объём доказательств.

\n

Команда чистой установки проверяет install contract для конкретного lockfile. Она не доказывает старт сервиса, работу native addon, browser bundle или вызов нужной функции. Тест проверяет выбранные сценарии. Smoke показывает поведение названного окружения. Только вместе эти результаты дают основание принять выпуск. Если smoke выполнить нельзя, это ограничение процесса, а не доказательство совместимости.

\n

Ограничения

\n

Описанная схема не вычисляет exploitability и не заменяет security research. Она не знает private registry, ОС-зависимости, контейнерные слои, generated code, runtime flags и все возможные входы. SBOM может быть неполным. Статический граф может включать недостижимую ветку. Trace может пропустить редкий сценарий. Поэтому вывод всегда должен содержать scope: какой commit, build, режим и метод проверены.

\n

Не закрывайте alert фразой «пакет не импортируется напрямую». Не закрывайте его и фразой «версия обновлена». В первом случае остаются транзитивные и динамические пути. Во втором остаются новый graph, совместимость и факт выпуска. Если доказательств не хватает, оставьте владельца и следующий проверяемый шаг.

\n

Критерий готовности

\n

Разбор готов, когда другой инженер без устного контекста может восстановить цепочку: advisory → exact package@version → lockfile path → SBOM и immutable artifact → entry point/configuration → evidence достижимости → результаты install, tests и smoke → решение и rollback. Каждый результат имеет ссылку или явно отмечен как unknown. Ни один учебный PASS не выдан за production-факт. Если хотя бы одно звено не подтверждено, задача не закрыта как безопасная: она остаётся ограниченным расследованием с назначенным владельцем.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/172.json b/editorial/agent-rewrites/172.json new file mode 100644 index 0000000..dbb174a --- /dev/null +++ b/editorial/agent-rewrites/172.json @@ -0,0 +1,7 @@ +{ + "index": 172, + "slug": "editorial-2023-03-field-csrf-cors", + "title": "CORS, preflight и CSRF: как найти настоящую причину ошибки cookie API", + "excerpt": "Интерфейс теряет ответ, OPTIONS получает отказ, а mutation заканчивается 403. Разбираем границы CORS и CSRF, проверяем контракт по фактам и не снимаем защиту ради быстрого зелёного теста.", + "contentHtml": "

После переноса frontend на новый host интерфейс перестаёт читать ответ API. В консоли появляется CORS error. Другой запрос получает 403 от CSRF middleware. Иногда сервер уже выполнил mutation, а браузер только скрыл ответ. Ошибка стоит дорого: команда может открыть API для любого origin, отключить проверку токена или повторить действие пользователя, не понимая, был ли запрос принят.

\n

Тезис простой: CORS и CSRF проверяют разные свойства запроса. CORS ограничивает, какой origin может прочитать cross-origin response из браузера. CSRF защищает изменение состояния от запроса без доказательства намерения пользователя. Один заголовок CORS не заменяет CSRF-токен. CSRF-токен не чинит отсутствующий preflight contract. Сначала нужно восстановить request contract, потом исправлять ровно его нарушенную часть.

\n

Начните с наблюдаемого симптома

\n

Зафиксируйте одну операцию. Запишите origin страницы, URL API, method, content type, режим credentials и имена пользовательских заголовков. Добавьте status, видимые response headers и безопасный корреляционный идентификатор. Запись «браузер ругается на CORS» слишком коротка: она не показывает, был ли preflight, дошёл ли actual request до приложения и выполнился ли side effect.

\n

Не берите для проверки production cookie. В учебном или тестовом окружении создайте отдельную сессию, выберите тестовую запись и заранее опишите допустимый side effect. Если controlled browser check пока невозможен, так и напишите: контракт проверен статически, сеть и сессия не проверены. Недостающий evidence — это результат диагностики, а не повод объявлять защиту рабочей.

\n

Механизм: три границы, которые нельзя склеивать

\n

CORS. Браузер сравнивает origin страницы с политикой ответа. Origin включает схему, host и port. Поэтому https://app.example.test и https://app.example.test:8443 — разные значения. Для credentialed response сервер должен вернуть конкретный разрешённый origin и Access-Control-Allow-Credentials: true. Значение * не является заменой allow-list для запроса с credentials.

\n

Preflight. Перед некоторыми cross-origin запросами браузер отправляет OPTIONS с вопросом о method и заголовках. Custom header вроде X-CSRF-Token, method PATCH и многие варианты JSON меняют request shape. Ответ OPTIONS должен разрешить только нужные method и header. Успешный OPTIONS не доказывает, что CSRF-токен валиден: он проверяет возможность выполнить запрос по CORS-контракту.

\n

CSRF. Cookie может автоматически приложиться к запросу, созданному другим сайтом. Серверу нужно отдельное доказательство, что запрос сформировало разрешённое приложение. В token-based схеме сервер выдаёт непредсказуемый токен, а затем сравнивает его с токеном в сессии или с корректно связанным double-submit значением до mutation. Отсутствующий или неверный токен должен остановить side effect.

\n
// Учебный пример контракта. Он не является готовым middleware.\nconst allowedOrigin = 'https://app.example.test';\n\nfunction corsHeaders(requestOrigin) {\n  if (requestOrigin !== allowedOrigin) return {};\n\n  return {\n    'Access-Control-Allow-Origin': allowedOrigin,\n    'Access-Control-Allow-Credentials': 'true',\n    'Vary': 'Origin',\n  };\n}\n\nfunction preflightHeaders(requestMethod, requestHeaders) {\n  const methods = ['POST'];\n  const headers = ['Content-Type', 'X-CSRF-Token'];\n\n  if (!methods.includes(requestMethod)) return null;\n  if (requestHeaders.some((name) => !headers.includes(name))) return null;\n\n  return {\n    'Access-Control-Allow-Methods': 'POST',\n    'Access-Control-Allow-Headers': 'Content-Type, X-CSRF-Token',\n  };\n}\n\nfunction authorizeMutation(session, token) {\n  if (!token || token !== session.csrfToken) {\n    return { status: 403, reason: 'csrf-token-mismatch' };\n  }\n  return { status: 204 };\n}
\n

Этот код показывает только идею: exact origin, узкий preflight и проверка токена до изменения состояния. Он не решает rotation, хранение сессии, кэширование HTML, logout, права на ресурс, обработку прокси и защиту от XSS. В production используйте contract и тесты выбранного framework. Не переносите учебный фрагмент в приложение без проверки его жизненного цикла.

\n

Симптом → причина → проверка → действие

\n
СимптомВероятная причинаПроверкаДействие
JavaScript не читает responseOrigin отсутствует в allow-listСверить полный origin с Access-Control-Allow-OriginДобавить только нужный origin
Credentials response заблокированWildcard или нет Allow-CredentialsПроверить пару заголовков и режим credentialsВернуть exact origin и явный credentials contract
OPTIONS получает отказMethod или custom header не разрешёнСопоставить request headers с ответом preflightРазрешить один нужный method/header
POST form получил 403CSRF-токен отсутствует или не совпалПроверить server reason до mutationИсправить выдачу и передачу токена
Mutation прошёл, UI увидел errorServer action и CORS visibility различаютсяСверить application log и browser evidenceИсправить CORS, не снимая CSRF
403 после token checkНет business permissionРазделить CSRF reason и authorization reasonИсправить policy ресурса
\n

Иллюстрация маршрута

\n
\"Маршрут
Схема разделяет origin policy, credentialed response, preflight и серверную проверку CSRF. Это учебная иллюстрация, а не trace реального запроса.
\n

Смотрите на рисунок как на карту evidence. Сначала фиксируйте вход запроса и его status. Затем определяйте, был ли OPTIONS и дошёл ли actual request до приложения. После этого отдельно проверяйте token и permission. Console message нельзя использовать как доказательство того, что сервер не видел запрос.

\n

Разберите credentials и preflight отдельно

\n

Если frontend действительно работает на другом origin и использует cookie, проверьте две пары. Первая пара — client credentials mode и Access-Control-Allow-Credentials: true. Вторая — точный source origin и Access-Control-Allow-Origin. Заголовки должны описывать согласованный контракт, а динамический ответ по origin должен учитывать кэширование, обычно через Vary: Origin.

\n

Затем выпишите фактический method и имена заголовков из client code. Не разрешайте «все методы и все headers» ради того, чтобы прекратить ошибку. Широкий ответ ухудшает review и превращает ошибку клиента в незаметно разрешённый путь. Если используется form-shaped POST, проверьте его отдельно: отсутствие preflight не означает отсутствие CSRF-риска.

\n

Если запрос требует preflight, actual request может не начаться после отказа OPTIONS. Для другого request shape сервер может принять HTTP-запрос, но браузер не даст JavaScript прочитать response. Поэтому нужны оба слоя: browser DevTools или HAR и proxy/application evidence с корреляционным идентификатором. Один слой не заменяет второй.

\n

Маршрут проверки

\n
  1. Зафиксируйте симптом. Выберите одну failing операцию и назовите цену ошибки: потерянный response, отклонённый mutation или возможный side effect без видимого результата.
  2. Опишите request contract. Запишите source origin, target URL, method, content type, credentials и custom headers. Отдельно отметьте, что пока неизвестно.
  3. Проверьте CORS. Сопоставьте origin с ответом. Для credentials проверьте exact value, а не wildcard. Проверьте Vary: Origin, если ответ зависит от входного origin.
  4. Проверьте OPTIONS. Если request shape требует preflight, сравните фактические method и header names с узким allow-list. Не делайте вывод о CSRF по результату OPTIONS.
  5. Проверьте server-side proof. Убедитесь, что missing или mismatch token отклоняется до mutation. Причину CSRF не смешивайте с причиной отсутствия права.
  6. Исправьте одну причину. Добавьте exact origin, один method/header или недостающую передачу token. Не меняйте одновременно CORS на глобально permissive и CSRF на disabled.
  7. Повторите положительный и отрицательный путь. Разрешённый запрос должен получить ожидаемый response. Запрос без token, с неверным token и с чужим origin должен остаться запрещённым в соответствующем слое.
  8. Оставьте проверку. Закрепите route test для missing/mismatch token и проверяемую конфигурацию CORS. Для нового frontend origin нужен отдельный review, а не копия существующей строки.
\n

Отрицательный путь важнее зелёного запроса

\n

Проверка только успешного запроса не доказывает защиту. Учебный тест должен явно показывать, что origin с другим port не получает credentialed response, неизвестный header не проходит preflight, а POST без token не меняет состояние. Это assertions над моделью контракта. Они не запускают gateway, браузер, framework middleware или настоящую session store.

\n

После PASS такого теста корректная формулировка звучит так: «проверены заданные правила контракта и отрицательные ветки». Нельзя писать «CORS и CSRF проверены в сети», если не было controlled browser/API evidence. Нельзя переносить в заметку production cookie, token values и идентификаторы реальных пользователей.

\n

Ограничения и критерий готовности

\n

Этот маршрут не заменяет XSS review, аудит cookie attributes, CSP, authorization test или penetration test. XSS на доверенном origin меняет картину: чужой скрипт может использовать доступные ему API и токены. CORS и CSRF не защищают от выполнения вредоносного JavaScript внутри собственного origin. Не существует универсального значения TTL токена, набора SameSite или списка trusted origins: решение зависит от framework, browser support, session model и threat model.

\n

Endpoint готов к review, когда видны четыре доказательства: точный разрешённый origin; корректный credentialed response и preflight contract, если они нужны; server-side rejection без CSRF proof до side effect; отдельная проверка business permission. Если есть только CORS header, работа не готова. Если есть только token test, frontend всё ещё может не прочитать response. Если есть только fixture PASS, нет доказательства интеграции. Эти слои дополняют друг друга и не заменяют друг друга.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/173.json b/editorial/agent-rewrites/173.json new file mode 100644 index 0000000..2a54279 --- /dev/null +++ b/editorial/agent-rewrites/173.json @@ -0,0 +1,7 @@ +{ + "index": 173, + "slug": "editorial-2023-03-mechanism-csrf-cors", + "title": "CSRF и CORS: как разделить доступ к ответу и право изменить данные", + "excerpt": "CORS решает, может ли чужой origin прочитать ответ. CSRF проверяет, разрешено ли cookie-аутентифицированному запросу менять состояние. Разбираем origin, credentials, preflight и отрицательные проверки на одном endpoint.", + "contentHtml": "

После переноса frontend на новый домен приложение начинает показывать CORS error. Одни запросы не видны JavaScript, другие получают 403, а часть операций, судя по логам, всё же доходит до API. Ошибка провоцирует опасную правку: разрешить *, принять любой origin или отключить CSRF middleware. Цена — не только сломанный интерфейс. Сервер может открыть ответ лишнему сайту или принять изменение от страницы, которая не выражала доверенный intent.

\n

Главный тезис простой: CORS и CSRF работают на разных границах. CORS ограничивает доступ браузерного кода к cross-origin response. CSRF защищает state-changing запрос, который использует учетные данные пользователя, например cookie сессии. Preflight проверяет форму запроса. Он не подтверждает token, пользователя или право на объект.

\n

Сначала зафиксируйте наблюдаемый симптом

\n

Не начинайте с заголовка, которого не хватает. Возьмите одну операцию и запишите пять значений: полный origin страницы, URL API, method, content type и имена request headers. Добавьте статус ответа и те response headers, которые видны в DevTools или на proxy boundary. Сообщение в консоли полезно как сигнал, но не объясняет, был ли отправлен actual request, скрыл ли браузер ответ или сервер отклонил mutation.

\n

Например, страница находится на https://app.example.test, а API — на https://api.example.test. Клиент вызывает fetch() с credentials: 'include' и отправляет X-CSRF-Token. Если OPTIONS не разрешает method или header, actual request может не начаться. Если preflight прошел, это ещё не значит, что token совпал с сессией. Если token совпал, пользователь всё ещё может не иметь права на конкретную запись.

\n

Механизм: три независимых слоя

\n

Origin. Браузер сравнивает tuple из scheme, host и port. https://app.example.test и http://app.example.test имеют разные origins. Порт 8443 тоже меняет tuple. Path, query и fragment в origin не входят. Поэтому проверка вида host.endsWith('example.test') слишком широка: она превращает любой поддомен в доверенный источник.

\n

Origin — это техническая граница браузера, а не готовая модель бизнес-доверия. Даже точный https://admin.example.test не должен автоматически получать права user API. Его добавляют в allow-list только для конкретной причины, endpoint и набора данных.

\n

CORS. Сервер сообщает браузеру, какому source origin можно отдать response браузерному коду. Для credentialed response нужен точный Access-Control-Allow-Origin и Access-Control-Allow-Credentials: true. Значение * нельзя совмещать с запросом, который использует credentials. Это правило отвечает за видимость представления ответа. Оно не авторизует mutation и не проверяет CSRF token.

\n

CSRF. Cookie отправляется браузером автоматически по своим правилам. Сам факт наличия cookie не доказывает, что пользователь намеренно вызвал действие из доверенного интерфейса. Сервер должен проверить token, строгую origin policy или другой подходящий proof до побочного эффекта. После этого он отдельно проверяет authorization: имеет ли actor право выполнить действие над данным объектом.

\n

Preflight. Браузер отправляет OPTIONS, когда форма cross-origin запроса выходит за CORS safelist. PATCH, JSON content type и custom header часто приводят к preflight. Успешный OPTIONS разрешает форму следующего запроса для указанного origin. Он не сравнивает CSRF token с сессией и не проверяет бизнес-права. Обычный form-shaped POST может не иметь preflight, хотя меняет состояние. Поэтому CSRF нельзя строить на предположении, что опасный запрос обязательно виден как OPTIONS.

\n
\"Три
Схема помогает разделить tuple origin, доступ к response и серверную проверку state-changing запроса. Это учебная иллюстрация, а не трасса браузера и не доказательство доставки cookie.
\n

Конкретный пример

\n

Рассмотрим cookie-аутентифицированный endpoint PATCH /profile. Клиент живет на разрешенном origin и передает token в custom header. Учебный сервер сначала проверяет origin и token, затем право пользователя. Код показывает порядок условий, но не заменяет middleware, браузерный тест и проверку cookie attributes.

\n
const trustedOrigins = new Set([\n  'https://app.example.test',\n]);\n\nfunction updateProfile(request, session) {\n  const origin = request.headers.get('Origin');\n  const token = request.headers.get('X-CSRF-Token');\n\n  if (!trustedOrigins.has(origin)) {\n    return deny(403, 'untrusted-origin');\n  }\n  if (!token || !constantTimeEqual(token, session.csrfToken)) {\n    return deny(403, 'csrf-failed');\n  }\n  if (!session.user.can('profile:update')) {\n    return deny(403, 'not-authorized');\n  }\n\n  return applyProfileChange(request.body);\n}
\n

В этом примере constantTimeEqual, выдача token, rotation, logout и обработка ошибок должны существовать в реальном компоненте безопасности. Фрагмент намеренно учебный. Он показывает, что CORS response и CSRF proof находятся рядом, но не являются одной проверкой. Он также сохраняет отрицательный путь: неверный origin, отсутствующий token и отсутствие permission не должны доходить до applyProfileChange.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
JavaScript не читает responseOrigin не разрешен или заголовок не совпалСверить полный scheme, host и port с exact Access-Control-Allow-OriginДобавить только нужный origin и проверить cache policy
Credentials response заблокированИспользован wildcard или отсутствует credentials headerСопоставить credentials: 'include' с двумя response headersВернуть exact origin и Access-Control-Allow-Credentials: true
OPTIONS получает 403Не разрешены method или custom headerСверить Access-Control-Request-Method и Access-Control-Request-HeadersРазрешить узкий фактический набор, не все методы и headers
POST form получает 403CSRF proof отсутствует или не совпалПосмотреть server reason до mutation и проверить token flowИсправить выдачу и передачу token; CSRF не отключать
UI видит CORS error, а запись измениласьСервер выполнил action, но браузер скрыл responseСопоставить server log, status и browser network evidenceИсправить CORS visibility и отдельно сохранить CSRF/authorization
Token принят, но ответ 403Не пройдена бизнес-авторизацияРазделить причины CSRF и permission в server logИсправить policy ресурса, а не расширять CORS
\n

Порядок проверки одного endpoint

\n
  1. Зафиксируйте failing operation и цену ошибки: данные не читаются, mutation отклоняется или action мог выполниться без видимого response.
  2. Составьте точный request contract: source origin, target URL, method, content type, credentials mode и header names. Не заменяйте эти значения фразой «запрос с фронта».
  3. Разделите этапы. Отдельно проверьте exact CORS response, отдельно OPTIONS, отдельно server-side CSRF proof и отдельно authorization.
  4. Добавьте отрицательные проверки: другой scheme, другой port, незнакомый subdomain, отсутствующий token и token от другой сессии. Для каждого варианта ожидайте отказ до side effect.
  5. Проверьте реальный browser flow в тестовой среде с безопасной test session. Сопоставьте DevTools или HAR с proxy/application status. Не переносите production cookie, token и пользовательские данные в фикстуру.
  6. После исправления повторите исходный happy path и тот же отрицательный path. Успешный CORS response не отменяет CSRF assertion, а успешный token test не доказывает, что frontend прочитает ответ.
  7. Закрепите узкое правило тестом и конфигурацией с понятным owner. Новый frontend origin должен проходить отдельный security review, а не появляться копированием существующей строки.
\n

Ограничения

\n

Атрибуты cookie, SameSite policy, third-party cookie restrictions, redirects, proxy cache и режим приватности браузера влияют на фактическую доставку credentials. Поэтому нельзя заключить из одного response header, что cookie была отправлена. Нельзя и заключить из отсутствия OPTIONS, что запрос безопасен: form submission и некоторые safelisted shapes способны менять состояние.

\n

CSRF не защищает от XSS на уже доверенном origin. Скрипт, который получил выполнение в приложении, может использовать доступные ему API и token. CSRF также не заменяет authorization, rate limiting, audit log, CSP или контроль webhook и service-to-service клиентов. Для каждого caller нужен явный authentication contract.

\n

Учебный код ограничен. Он не реализует Fetch, не моделирует браузер, не проверяет все byte-level ограничения заголовков, не учитывает CORS cache и не подтверждает конкретную версию framework. Реальный результат появляется только из browser/integration проверки в контролируемой среде и server evidence.

\n

Проверяемый критерий готовности

\n

Endpoint готов к изменению, если reviewer может назвать разрешенный source origin, увидеть exact credentialed CORS contract, воспроизвести нужный preflight или доказать его отсутствие, получить отказ без CSRF proof и отдельно подтвердить permission check. В тестовой среде server log показывает, что отрицательные ветки не вызвали side effect. Если есть только CORS header, работа не готова. Если есть только unit test token, не доказана интеграция с браузером. Если есть только ручной happy path, не защищен отрицательный путь.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/174.json b/editorial/agent-rewrites/174.json new file mode 100644 index 0000000..ad60cd2 --- /dev/null +++ b/editorial/agent-rewrites/174.json @@ -0,0 +1,7 @@ +{ + "index": 174, + "slug": "editorial-2023-03-practice-csrf-cors", + "title": "Cookie API и виджет: как не перепутать CORS с CSRF", + "excerpt": "Разберите CORS и CSRF по отдельности: один механизм управляет доступом JavaScript к ответу, другой решает на сервере, можно ли принять изменение данных.", + "contentHtml": "

Виджет на https://app.example.test вызывает API на https://api.example.test. После релиза в DevTools появляется CORS error. Пользователь не видит профиль, а команда предлагает поставить Access-Control-Allow-Origin: *. Если API использует cookie, это не исправление. Браузер всё равно скроет credentialed response, а попытка отключить CSRF-проверку может открыть изменение данных с чужой страницы.

\n

Цена ошибки двойная. Рабочий интерфейс перестаёт получать данные. Одновременно сервер может начать принимать state-changing запрос только по cookie. Тогда злоумышленник не обязан читать ответ: ему достаточно заставить браузер жертвы отправить перевод, сменить адрес или удалить запись.

\n

Тезис: CORS и CSRF отвечают на разные вопросы. CORS определяет, получит ли JavaScript cross-origin доступ к response. CSRF-защита проверяет на сервере, действительно ли запрос на изменение состояния пришёл из разрешённого сценария. Исправляйте эти контуры раздельно.

\n

Механизм по шагам

\n

Сначала браузер определяет origin. Это комбинация scheme, host и port. Для https://app.example.test и https://api.example.test host различается, поэтому запрос cross-origin. Путь и query в origin не входят. Общий registrable domain тоже не делает два приложения одним origin.

\n

Клиент может запросить credentials: например, передать cookie через fetch(url, { credentials: 'include' }). Это только намерение клиента. Сервер должен вернуть точный Access-Control-Allow-Origin для разрешённого origin и Access-Control-Allow-Credentials: true, если браузер должен открыть response JavaScript-коду. Wildcard * не совместим с credentialed CORS.

\n

CORS не является authorization. Успешная проверка CORS не означает, что пользователь вошёл, имеет право менять конкретный ресурс или передал CSRF-доказательство. Сервер должен выполнить аутентификацию и authorization независимо от CORS.

\n

CSRF появляется потому, что браузер может приложить cookie к cross-site запросу. Простая HTML-форма способна отправить POST с application/x-www-form-urlencoded без доступа к ответу и без preflight. Если endpoint меняет состояние только по cookie, такой запрос опасен.

\n

Для stateful API сервер обычно хранит CSRF-token в сессии и требует его в form field или custom header. Обработчик сравнивает token до mutation. Отсутствующий или неверный token даёт отказ. Origin или Referer check может добавить защиту, но не заменяет token, authorization и проверку бизнес-прав.

\n

Конкретный пример

\n

Ниже учебный пример политики. Он не открывает сеть, не создаёт cookie, не запускает браузер и не доказывает поведение конкретного production API. В нём показаны две независимые проверки: CORS для чтения ответа и CSRF для изменения состояния.

\n
const corsOrigins = new Set(['https://app.example.test']);\nconst csrfOrigins = new Set([\n  'https://app.example.test',\n  'https://api.example.test',\n]);\n\nfunction checkRequest({ origin, credentials, method, csrfToken }) {\n  const cors = corsOrigins.has(origin) &&\n    (!credentials || origin !== '*');\n\n  const safeMethod = new Set(['GET', 'HEAD', 'OPTIONS']).has(method);\n  const csrf = safeMethod ||\n    (csrfOrigins.has(origin) && csrfToken === 'token-from-session');\n\n  return { cors: cors ? 'allow' : 'deny', csrf: csrf ? 'allow' : 'deny' };\n}\n\n// Учебные проверки, не production-конфигурация:\ncheckRequest({\n  origin: 'https://app.example.test',\n  credentials: true,\n  method: 'POST',\n  csrfToken: 'token-from-session',\n}); // { cors: 'allow', csrf: 'allow' }\n\ncheckRequest({\n  origin: 'https://evil.example',\n  credentials: true,\n  method: 'POST',\n  csrfToken: undefined,\n}); // { cors: 'deny', csrf: 'deny' }
\n

Строка token-from-session здесь условна. В реальном приложении token должен быть непредсказуемым, связанным с сессией и сгенерированным безопасным источником случайности. Сравнение выполняет сервер до побочного эффекта. Не переносите этот код в middleware без проверки cookie policy, proxy и framework-контракта.

\n

Preflight полезен для диагностики формы запроса. Custom header вроде X-CSRF-Token или нестандартный content type часто вызывает OPTIONS-проверку. Но preflight не подтверждает token, сессию и право пользователя. Он проверяет, разрешает ли CORS-политика указанный origin, method и header. Поэтому нельзя считать preflight самостоятельной CSRF-защитой.

\n
\"Схема:
CORS открывает JavaScript доступ к ответу только для точного origin. CSRF отдельно требует доказательство до изменения данных. Иллюстрация учебная: она не показывает реальный браузерный запуск или лог API.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
В консоли CORS error при cookie-запросеWildcard или отсутствует точный originСверить Origin, Access-Control-Allow-Origin, credentials и Vary: OriginОставить allow-list точных origin; не подставлять любое входное значение
OPTIONS проходит, POST меняет данные без tokenPreflight ошибочно приняли за CSRF-защитуОтправить form-shaped POST без custom header и проверить серверный ответПроверять CSRF-token до mutation для всех state-changing методов
403 после добавления tokenToken не связан с текущей сессией или не доходит через proxyПроверить источник token, cookie, заголовок, нормализацию и точку отказаИсправить передачу и проверку; не отключать middleware целиком
Данные не читаются, но запись всё равно меняетсяCORS скрывает response, но сервер принимает запрос по cookieСмотреть server log и статус actual request отдельно от сообщения браузераДобавить серверный CSRF-контроль и authorization
\n

Порядок действий

\n
  1. Зафиксируйте страницу-источник, API URL, scheme, host и port. Не называйте два адреса одним «сайтом» без проверки origin.
  2. Повторите запрос без изменений конфигурации. Сохраните method, content type, request headers, response status и серверный log.
  3. Проверьте CORS отдельно. Для credentialed запроса нужен точный разрешённый origin и согласованный credentials response. Для публичного non-credentialed ресурса wildcard может быть уместен, но это другой контракт.
  4. Перечислите все методы и endpoints, которые меняют состояние. Для каждого укажите источник CSRF-token и место проверки до mutation.
  5. Проверьте отрицательный путь: POST из чужого origin, POST без token, неверный token и form-shaped POST без preflight должны получить отказ или не изменить состояние.
  6. Проверьте authorization после CSRF. Валидный token не даёт пользователю право менять чужой ресурс.
  7. Проверьте реальный browser flow с двумя контролируемыми origin. Учебная функция выше годится для проверки логики ветвлений, но не заменяет integration test.
  8. Добавьте регрессионные проверки и наблюдение за отказами. В журнале не записывайте полный token и cookie.
\n

Ограничения

\n

Эта схема предполагает cookie-based authentication и браузерный клиент. Она не описывает OAuth bearer token в заголовке, webhook, native app или server-to-server вызов. Для таких клиентов модель угроз и способ доказать полномочия будут другими.

\n

SameSite помогает ограничить отправку cookie, но не отменяет серверную проверку. Его результат зависит от атрибутов cookie, браузера, контекста навигации и окружения. Origin может отсутствовать или иметь значение null. Proxy может изменить набор видимых заголовков. Эти случаи нужно включить в отдельную политику, а не молча считать безопасными.

\n

XSS в доверенном origin может обойти многие CSRF-меры, потому что вредоносный код действует внутри разрешённого контекста. Поэтому исправление CSRF не заменяет защиту от XSS, управление cookie и контроль прав.

\n

Проверяемый критерий готовности

\n

Сценарий готов, если команда может предъявить для одного state-changing endpoint четыре независимых доказательства: точный allow-list origin, ожидаемый CORS response, проверку CSRF-token до mutation и отказ при чужом origin или неверном token. В реальном browser test легитимный запрос читает response и меняет только разрешённый ресурс. Отрицательные запросы получают 403 или эквивалентный отказ, а состояние не меняется. Ни один из этих результатов нельзя заменять одним сообщением CORS в консоли.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/175.json b/editorial/agent-rewrites/175.json new file mode 100644 index 0000000..cfa2f05 --- /dev/null +++ b/editorial/agent-rewrites/175.json @@ -0,0 +1 @@ +{"index":175,"slug":"editorial-2023-02-field-sessions-auth","title":"Старый session ID после rotation: как проверить logout и не закрыть новую сессию","excerpt":"После renewal старая вкладка может отправить прежний ID, а logout — изменить только интерфейс. Разбираем границу между cookie и серверной записью, stale-события и проверяемый маршрут без выдуманного browser trace.","contentHtml":"

Симптом обычно выглядит безобидно: в одной вкладке нажали logout, а другая ещё открывает защищённый экран. Другой вариант — после renewal запрос из старой вкладки получает ошибку, а поздний logout неожиданно закрывает уже новую сессию. По одному экрану нельзя понять, какой ID пришёл на сервер, какой ID считался текущим и какой scope имел logout.

\n

Цена ошибки — не только плохой UX. Если сервер принимает старый ID после rotation, украденный или повторно отправленный идентификатор остаётся рабочим. Если logout по старому ID отзывает successor, пользователь теряет новую сессию после обычной сетевой задержки. Если система очищает только cookie, интерфейс говорит «вы вышли», но следующий запрос может пройти.

\n

Тезис статьи простой: cookie переносит идентификатор, но не принимает решение о доступе. Серверная запись хранит статус ID, его поколение и связь с lineage. Перед защищённым действием сервер принимает только current active ID. Rotation заменяет current ID. Logout отзывает запись в согласованном scope и отдельно просит браузер очистить cookie. Эти события нельзя свести к одной строке или одному флагу.

\n

Сначала разделите четыре объекта

\n

Cookie — это транспорт. Браузер решает, приложить ли её к запросу с учётом имени, host, пути, Secure и SameSite. Сервер получает строку и должен найти запись. Наличие cookie не доказывает, что запись active, что ID current или что пользователь имеет право выполнить операцию.

\n

Session record — источник решения о состоянии конкретного ID. Минимальная учебная запись содержит id, lineage, generation и status. Значение active означает, что ID может пройти проверку сессии. Значение rotated означает, что запись известна, но заменена successor. Значение revoked означает, что сессия отозвана. unknown — это отсутствие записи.

\n

Lineage связывает последовательность ID одной сессии. У неё есть один current pointer. До rotation pointer указывает на fixture-s-1. После успешной rotation он указывает на fixture-s-2. Старый ID можно хранить ограниченное время для диагностики и явного reject, но он не должен снова становиться current.

\n

Authorization остаётся отдельным слоем. Active session отвечает на вопрос «какая сессия предъявлена?». Проверка прав отвечает на вопрос «может ли она выполнить это действие?». Не выдавайте active ID больше полномочий, чем описывает политика handler.

\n
Контракт одной сессии
ОбъектЧто он решаетМинимальная проверкаЧего он не доказывает
CookieКакой ID браузер отправитname, host, Path и атрибуты scopeЧто сервер считает ID active
Session recordПринимать ли предъявленный ID сейчасstatus и совпадение с currentЧто у сессии есть нужное право
Lineage pointerКакой ID является successorОдин current после rotationЧто logout означает для всех устройств
AuthorizationМожно ли выполнить конкретное действиеПроверка права после session checkЧто cookie доставлена безопасно
\n

Почему logout не заканчивается очисткой cookie

\n

У logout две разные обязанности. Сервер должен изменить состояние записи и перестать принимать отозванный ID. Клиент должен получить Set-Cookie с тем же именем и тем же scope, но с пустым значением и сроком в прошлом. Первая ветвь закрывает доступ. Вторая убирает удобный носитель ID из браузера.

\n

Если выполнена только клиентская ветвь, сохранённый запрос, другой клиент или уже отправленный заголовок всё ещё может предъявить прежний ID. Если выполнена только серверная ветвь, доступ уже закрыт, но интерфейс может продолжать отправлять cookie до следующего ответа. Это разные симптомы и разные проверки.

\n

Scope очистки должен совпадать со scope выдачи. Имя и путь не являются единственными деталями: при использовании Domain он тоже входит в совпадение. Учебный контракт использует __Host-session, Secure, HttpOnly, SameSite=Lax, Path=/ и не использует Domain. Если реальному продукту нужен общий cookie на нескольких поддоменах, этот выбор уже не подходит и требует отдельного контракта.

\n
Cookie: __Host-session=fixture-s-2\n\n// server-side decision, учебная модель\nrecord.status === 'active'\n  && currentByLineage[record.lineage] === record.id\n  && can(record, action)\n\n// logout-current\nrecord.status = 'revoked'\ndelete currentByLineage[record.lineage]\nSet-Cookie: __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT
\n

Это учебный фрагмент. Он не задаёт формат production cookie, не генерирует секрет и не доказывает, что конкретный framework или браузер применит ответ именно так. Его задача — отделить решение сервера от доставки значения браузером.

\n

Rotation меняет право предъявления

\n

Rotation начинается с проверки текущей записи. Сервер находит предъявленный ID, проверяет его статус и сравнивает с current pointer. Только после этого он переводит predecessor в rotated, создаёт successor с новым поколением и обновляет pointer. Ответ выдаёт cookie с successor.

\n

Старый ID не является неизвестным. Сервер может знать его lineage и successor, но всё равно должен отклонить его до защищённого эффекта. Внешний ответ может быть одинаковым для разных причин, однако журнал проверки должен отличать unknown, rotated и revoked. Иначе расследование не покажет, действительно ли rotation вывела старый ID из обращения.

\n

Параллельные запросы требуют отдельной гарантии: транзакции, conditional update, compare-and-swap или эквивалентного механизма хранилища. Нужен один исход — один запрос создаёт successor, другой получает stale/rejected. Синхронный fixture проверяет порядок вызовов в памяти и не моделирует гонку, распределённый cache, задержку базы или порядок HTTP-ответов.

\n
\"Жизненный
Иллюстрация показывает правило для одной lineage и синхронных учебных вызовов. Это не browser trace, не журнал инцидента и не доказательство порядка реальных HTTP-ответов.
\n

Отрицательный путь важнее зелёного login

\n

Положительный сценарий показывает, что новый ID работает. Он не показывает, что старый больше не работает. Поэтому после rotation предъявите predecessor и проверьте reject до protected effect. Затем предъявите successor и проверьте обычный доступ. После logout текущего ID повторите запрос и ожидайте reject.

\n

Отдельно проверьте stale logout. При политике logout-current logout с predecessor не должен отзывать successor. Это не универсальная истина для всех продуктов. Если бизнесу нужен logout всей lineage или всех устройств, назовите scope явно, найдите все записи и проверьте другой инвариант. Нельзя получить такую семантику случайно из обработчика, который просто принимает любой известный ID.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый ID проходит после renewalHandler не проверяет current или record не стал rotatedСопоставить безопасный ID, status и current pointer на момент обработкиОтклонять rotated ID до эффекта и атомарно менять pointer
Logout меняет только экранОчищена cookie, но сервер не revoke-нул recordПроверить audit logout и status той же записиRevoke на сервере, затем вернуть clear cookie
Поздний logout закрывает новую сессиюStale ID трактуется как currentСравнить предъявленный ID с currentByLineageВыбрать logout-current или явный более широкий scope
После clear видна другая cookieIssue и clear расходятся по Path или DomainСравнить атрибуты Set-Cookie без значенияПовторить name, Path и Domain при наличии
Сбой только в одном браузереОтличается cookie policy или порядок ответовПовторить разрешённый сценарий на конкретной версии и собрать метаданныеНе менять server contract до подтверждения различия
\n

Учебный fixture: что он доказывает

\n

Минимальный fixture создаёт fixture-s-1, выполняет rotation и получает fixture-s-2. Повторная rotation со старым ID возвращает reject. Stale logout со старым ID также возвращает reject и оставляет successor active. Logout с текущим ID переводит successor в revoked и удаляет current pointer.

\n
const first = createTeachingSessionState();\nconst replacement = rotateTeachingSession(first, 'fixture-s-1');\nconst staleRotation = rotateTeachingSession(replacement.state, 'fixture-s-1');\nconst staleLogout = logoutTeachingSession(replacement.state, 'fixture-s-1');\n\nif (staleRotation.accepted || staleLogout.accepted) {\n  throw new Error('old ID changed current session');\n}\nif (replacement.state.records['fixture-s-2'].status !== 'active') {\n  throw new Error('successor is not active');\n}
\n

Пример ограничен памятью процесса. Он не проверяет HTTP endpoint, реальный Set-Cookie, браузерное хранилище, TLS, random ID, серверные часы, CSRF, reauthentication, несколько устройств, распределённую блокировку или конкурентные запросы. Команда node web/scripts/upgrade-2023-02.mjs --verify-fixture подтверждает assertions учебной модели, а не состояние неизвестной production-системы.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите внешний status, защищённый эффект, безопасную корреляцию операции и момент обработки. Не называйте историю browser trace, если его не собирали.
  2. Спрячьте секрет. Используйте surrogate ID и не помещайте bearer value в лог, таблицу или снимок.
  3. Назовите состояние. Разделите cookie delivery, server record, lineage pointer и authorization. Для каждого объекта назначьте владельца.
  4. Проверьте fixture. Выполните положительные и отрицательные переходы: rotation, stale rotation, stale logout и logout current.
  5. Проверьте реализацию. В разрешённой среде сравните status и current pointer до protected effect. Отдельно сверяйте атрибуты issue и clear cookie.
  6. Проверьте гонку. Для реального хранилища зафиксируйте гарантию, которая не допускает двух successor и не позволяет stale событию изменить новую запись.
  7. Выберите scope logout. Зафиксируйте logout-current, logout-lineage или logout-all как разные операции с разными проверками.
  8. Сделайте малый diff. Исправляйте подтверждённый слой: серверный reject, атомарность, cookie scope, audit или UX после reject.
  9. Опишите откат. Не возвращайте retired ID в active ради быстрого rollback. Подготовьте совместимую миграцию или откат экрана при сохранённом reject старого ID.
\n

Ограничения и критерий готовности

\n

Cookie flags не заменяют server-side validation. Secure ограничивает канал доставки, HttpOnly ограничивает доступ через browser API, а SameSite ограничивает часть cross-site отправок. Ни один из них не проверяет статус записи, право на действие или успешность logout. Max-Age и Expires задают срок хранения у user agent, но не должны быть единственным серверным timeout.

\n

Эта модель не выбирает SQL, cache, signed token или конкретный framework. Она требует только одного current ID и явного решения для stale состояния. Если система не хранит lineage, можно выбрать другую реализацию, но тогда нужно доказать, как она отличает повторный старый ID от текущего и как предотвращает позднее изменение successor.

\n

Материал готов к применению как проверяемая схема, если команда может показать: predecessor и successor без секретов; status каждого в момент запроса; current pointer; scope logout; результат stale rotation и stale logout; совпадение name, Path и Domain у issue/clear cookie; и отдельную гарантию для конкурентной rotation. Один зелёный fixture или исчезнувшая cookie этот критерий не закрывают.

\n

Проверяемые источники

\n"} diff --git a/editorial/agent-rewrites/176.json b/editorial/agent-rewrites/176.json new file mode 100644 index 0000000..2d8edff --- /dev/null +++ b/editorial/agent-rewrites/176.json @@ -0,0 +1,7 @@ +{ + "index": 176, + "slug": "editorial-2023-02-mechanism-sessions-auth", + "title": "Сессия после rotation и logout: кто решает, действителен ли запрос", + "excerpt": "Cookie переносит идентификатор, но не принимает решение о доступе. Разбираем серверную запись сессии, смену current ID, logout и проверяемый отрицательный путь со старым идентификатором.", + "contentHtml": "

Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс показывает успешный ответ, хотя сервер уже должен был закрыть сессию. В другом варианте после rotation старый запрос получает новый доступ, потому что обработчик проверяет только наличие cookie. Цена ошибки — изменение данных после отзыва доступа, потеря новой сессии поздней операцией со старым ID и расследование, в котором нельзя назвать источник истины.

\n

Тезис статьи прост: cookie доставляет непрозрачный ID, а серверная запись принимает решение. Сервер хранит статус записи, связь между версиями сессии и current ID. Rotation заменяет current ID и делает старый ID недействительным. Logout отзывает запись и очищает cookie с тем же scope. Ни один флаг cookie не заменяет эти проверки.

\n
\"Контракт
Учебная схема разделяет доставку cookie и решение сервера. Она не показывает настоящий браузерный trace, reverse proxy или достаточность защиты от CSRF.
\n

Четыре факта, которые нельзя склеивать

\n

Аутентификация отвечает на вопрос «кто прошёл вход?». В этой модели она не представлена. Сервис может получать identity из другого механизма, но затем всё равно проверяет сессию.

\n

Cookie delivery отвечает на другой вопрос: какой ID браузер приложил к запросу. Имя, домен, путь, Secure и SameSite влияют на доставку. Наличие cookie не доказывает, что запись существует, не отозвана и относится к текущей версии.

\n

Server record хранит status, срок и поколение. Обработчик находит запись, проверяет active status, expiry и current ID, а потом передаёт контекст в authorization. Authorization отдельно решает, может ли identity выполнить конкретное действие.

\n
Слои сессии и границы решения
СлойВопросПроверкаЧего он не доказывает
Cookie deliveryКакой ID пришёл?Заголовок и scopeЧто ID действителен
Server recordПринимать ли ID?status, expiry, currentПраво на действие
LineageКакой ID заменил старый?generation или successorАтомарность двух запросов
AuthorizationМожно ли выполнить операцию?роль, ресурс, действиеБезопасность cookie
\n

Границы флагов cookie

\n

Secure ограничивает отправку по защищённому каналу. Он не шифрует запись сессии и не отзывает её. HttpOnly скрывает значение от обычного JavaScript API. Он уменьшает риск кражи через клиентский код, но не устраняет XSS и не запрещает браузеру приложить cookie.

\n

SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты, а не замена CSRF-проверке mutation endpoint. Сценарии с embed, federation и несколькими доменами требуют отдельного решения.

\n

Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Сервер не должен принимать ID только потому, что cookie пришла, и не должен считать удаление cookie доказательством logout. Browser lifetime и server expiry — разные часы.

\n

Префикс __Host- подходит для cookie одного host: нужны Secure, Path=/ и отсутствие Domain. Это не граница авторизации. Административный маршрут всё равно проверяет права на сервере. Для нескольких поддоменов нужен другой scope и явное описание его владельца.

\n

Rotation заменяет current ID

\n

Rotation меняет идентификатор, который сервер считает текущим. Операция переводит старую запись в rotated, создаёт successor и передвигает указатель current. Старый ID не получает новый TTL. Он возвращает отказ вроде stale-session.

\n
const current = sessions.findById(request.cookies[SESSION_NAME]);\n\nif (!current || current.status !== 'active' || current.id !== lineage.currentId) {\n  return response.status(401).json({ error: 'stale-session' });\n}\n\nconst next = sessions.rotateAtomically({ oldId: current.id, expectedGeneration: current.generation });\nresponse.setHeader('Set-Cookie', serializeSessionCookie(next.id));
\n

Это учебный фрагмент. Он показывает порядок проверки и условие compare-and-swap, но не готовый adapter для конкретной базы. В реальном хранилище нужно определить транзакцию, уникальность successor и поведение повторной доставки.

\n

Без линейзации два запроса могут прочитать один active ID и выпустить двух successor. Нельзя лечить эту гонку увеличением TTL. Нужен атомарный переход от ожидаемого поколения.

\n

Logout отзывает серверную запись

\n

Logout-current отзывает одну текущую запись. Logout-lineage отзывает цепочку одного входа. Logout-all отзывает все записи identity. Это разные операции. Endpoint должен назвать scope. Иначе поздний logout старой вкладки выключит новую сессию или оставит действующий successor.

\n

После отзыва сервер очищает cookie с тем же именем, доменом и путём. Это улучшает UX и уменьшает повторные запросы. Источником истины остаётся status серверной записи.

\n
const result = logoutCurrent({ presentedId: request.cookies[SESSION_NAME], lineageId: request.sessionLineage });\nif (result.kind === 'stale-session') return clearCookie(response).status(401).end();\nsessions.revoke(result.currentId);\nreturn clearCookie(response).status(204).end();
\n

Старый ID после rotation не должен отзывать successor, если выбран logout-current. Это отрицательный путь. Happy path с текущим ID его не проверяет.

\n

Симптом → причина → проверка → действие

\n
Диагностика рассинхронизации cookie и серверной записи
СимптомПричинаПроверкаДействие
Cookie есть, но ответ 401Запись revoked, rotated или expiredСопоставить ID, status и currentВернуть единый reject и очистить cookie
Старый запрос изменил данныеПроверили наличие ID, но не status/currentПовторить запрос после rotationПроверять запись до эффекта
Старая вкладка выключила новуюLogout отзывает всю lineageСопоставить ID и scope в логеЯвно выбрать logout-current или logout-all
Стали действующими два IDRotation не линеаризованаОтправить два запроса одного generationДобавить транзакцию или conditional update
Прошла cross-site mutationSameSite принят за полную CSRF-защитуПроверить Origin и CSRF-контрактДобавить отдельную серверную проверку
\n

Порядок проверки

\n
  1. Назовите защищённый handler и его действие.
  2. Опишите владельца browser delivery, server record, lineage и authorization.
  3. Зафиксируйте active, rotated, revoked и expired и ответ для каждого состояния.
  4. Проверьте вход, запрос с current ID и успешную rotation.
  5. Проверьте старый ID после rotation: он получает reject и не меняет successor.
  6. Проверьте выбранный logout scope. Старый logout не меняет новую сессию при logout-current.
  7. Проверьте Set-Cookie и очистку в разрешённом интеграционном окружении. Unit fixture не доказывает поведение браузера.
  8. Сопоставьте active session с отдельной authorization-проверкой.
\n

Ограничения модели

\n

Пример не генерирует секреты, не читает cookie jar и не отправляет HTTP. Имена fixture-s-1, generation и фиксированный TTL учебные. Их нельзя копировать как production ID. Модель не покрывает распределённые блокировки, clock skew, несколько устройств, CORS, CSRF policy, reauthentication и reverse proxy.

\n

Документы IETF и NIST описывают протокол и термины, но не доказывают корректность конкретной платформы. Browser test не доказывает атомарность базы. Storage test не доказывает scope Set-Cookie. Эти границы проверяют отдельно.

\n

Проверяемый критерий готовности

\n

Механизм готов к интеграционной проверке, если для одного handler видны четыре результата: current ID проходит до authorization; rotated ID получает reject без изменения successor; выбранный logout отзывает ровно ожидаемые записи; сервер принимает решение независимо от наличия cookie. В отчёте есть correlation ID, lineage, generation и причина отказа, но нет самого секрета.

\n

Если один результат нельзя показать отдельно, контракт ещё не определён. Сначала фиксируют владельца перехода и состояние, затем выбирают хранилище и браузерный сценарий.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/177.json b/editorial/agent-rewrites/177.json new file mode 100644 index 0000000..6b7cfcb --- /dev/null +++ b/editorial/agent-rewrites/177.json @@ -0,0 +1,7 @@ +{ + "index": 177, + "slug": "editorial-2023-02-practice-sessions-auth", + "title": "Сессия после rotation и logout: один контракт для cookie и сервера", + "excerpt": "Как отделить cookie от серверной сессии, не принять старый ID после rotation и доказать logout проверкой доступа, а не только очисткой браузера.", + "contentHtml": "

Пользователь нажимает «Сохранить» после logout в другой вкладке. Интерфейс получает успешный ответ, хотя сессию уже должны были закрыть. В другом варианте старый запрос приходит после rotation и снова проходит, потому что обработчик проверяет только наличие cookie. Ошибка выглядит случайной. Цена ошибки измерима: сервер меняет данные после отзыва доступа, новая сессия может исчезнуть из-за позднего ответа, а расследование не знает, какой ID считался действующим.

\\\n

Тезис простой: cookie переносит непрозрачный ID, но не принимает решение о доступе. Сервер хранит запись сессии, её статус, срок и связь с текущей версией. Rotation заменяет current ID и делает старый ID непригодным. Logout отзывает серверную запись и отправляет браузеру cookie с тем же scope в прошлом. Эти действия связаны, но не заменяют друг друга.

\\\n

Разделите четыре разных факта

\\\n

Аутентификация отвечает на вопрос «кто прошёл вход». Эта статья не моделирует сам вход. После него приложение создаёт серверную сессию и связывает её с identity. Дальше каждый защищённый запрос проходит несколько границ. Если их склеить в одну проверку if (cookie), система начнёт путать носитель, состояние и право.

\\\n

Первый факт — доставка cookie. User agent прикладывает значение, если имя, host, Path, Secure и SameSite подходят запросу. Это только входная строка. Она может быть старой, отозванной или украденной. Даже отсутствие cookie не доказывает, что серверная запись исчезла.

\\\n

Второй факт — серверная запись. Она хранит ID или его безопасный отпечаток, identity, статус active, rotated или revoked, срок действия и поколение. Обработчик сначала находит запись и проверяет её. Только после этого он передаёт подтверждённый контекст в authorization.

\\\n

Третий факт — lineage. Это связь последовательных версий одной сессии. Она отвечает на вопрос «какой ID сейчас current». После rotation у линии должен остаться один current ID. Старый ID можно сохранить для диагностики, но нельзя снова сделать его действующим.

\\\n

Четвёртый факт — право на операцию. Active session не означает право менять профиль, выплачивать деньги или читать административные данные. Authorization отдельно проверяет subject, ресурс и действие. Наличие cookie не даёт ни одной из этих гарантий.

\\\n
Границы контракта одной сессии
СлойВопросПроверкаЧего он не доказывает
Cookie deliveryКакой ID пришёл?Заголовок, имя и scopeЧто ID действителен
Session recordПринимать ли ID?status, expiry и current pointerПраво на конкретное действие
LineageКакой ID заменил старый?generation и successorЧто два запроса выполнятся по порядку
AuthorizationРазрешена ли операция?роль, ресурс и действиеЧто cookie настроена безопасно
\\\n

Cookie flags не являются авторизацией

\\\n

Secure ограничивает отправку cookie защищённым каналом. Он не шифрует запись в базе и не отзывает её при logout. HttpOnly убирает значение из обычного JavaScript API. Он уменьшает поверхность кражи через клиентский код, но не устраняет XSS и не запрещает серверу ошибочно принимать старый ID.

\\\n

SameSite=Lax ограничивает часть cross-site отправок. Это слой защиты браузера, а не проверка mutation endpoint. Потоки с несколькими доменами, embed или внешним провайдером могут потребовать другую политику. Нельзя объявлять запрос безопасным только по одному flag.

\\\n

Max-Age и Expires задают срок хранения в user agent. Браузер может удалить cookie раньше. Серверный timeout живёт в другом месте и должен проверяться независимо. Поэтому «cookie ещё пришла» не означает «сессия ещё активна», а «cookie исчезла» не означает «logout дошёл до сервера».

\\\n

Префикс __Host- подходит для host-only cookie: нужны Secure, Path=/ и отсутствие Domain. Это фиксирует область доставки. Префикс не выдаёт identity, не проверяет права и не закрывает доступ после отзыва записи. Если cookie должна работать на нескольких поддоменах, такой scope не подходит.

\\\n
\"Жизненный
Учебная схема разделяет серверный переход active → rotated → revoked и доставку нового или очищенного cookie. Это не trace браузера и не доказательство поведения конкретного приложения.
\\\n

Rotation должен менять current ID атомарно

\\\n

Rotation нужен, когда сервис хочет заменить предъявляемый идентификатор: после входа, повышения доверия или другого заданного события. Сначала сервер проверяет, что пришёл current ID. Затем одна операция помечает predecessor как rotated, создаёт successor с новым поколением и передвигает pointer. Ответ выдаёт cookie successor.

\\\n

Поздний запрос со старым ID должен получить отказ вроде stale-session. Он не должен продлевать старую запись, повторно создавать successor или менять данные. Нельзя решать эту задачу одним TTL. TTL отвечает за срок, а rotation — за замену владельца current ID.

\\\n

В реальном хранилище нужна линейзация: транзакция, conditional update, compare-and-swap или эквивалентная гарантия. Два параллельных запроса не должны выпустить два current successor. Учебный пример ниже фиксирует требование к переходам, но не моделирует конкурентность, браузер, сеть или базу.

\\\n
const current = sessions.findById(request.cookies[SESSION_NAME]);\\\n\\\nif (!current || current.status !== 'active' || current.expiresAt <= now) {\\\n  return response.status(401).end();\\\n}\\\n\\\nconst successor = rotateOnce(current); // transaction or compare-and-swap\\\nsetCookie(response, SESSION_NAME, successor.id, {\\\n  secure: true,\\\n  httpOnly: true,\\\n  sameSite: 'lax',\\\n  path: '/',\\\n});\\\n\\\n// A later request with current.id must return stale-session.\\\nreturn response.json({ status: 'rotated' });
\\\n

Этот код — учебная форма контракта. В нём нет настоящих секретов, обработки ошибок хранилища и выбора политики reauthentication. Функция rotateOnce должна сделать проверку и переход одной защищённой операцией. Если она только читает запись, а потом отдельно пишет новую, пример не решает гонку.

\\\n

Logout состоит из двух действий

\\\n

Серверная ветвь закрывает доступ. Она находит предъявленный current ID, помечает запись revoked и убирает его из current pointer. Повторный запрос с тем же ID получает отказ. Если запрос уже stale, он не должен отозвать successor: иначе поздний ответ в старой вкладке выключит новую сессию.

\\\n

Клиентская ветвь убирает удобный носитель. Ответ возвращает пустое значение с теми же именем, host, Path и, если он был, Domain. Дата истечения должна быть в прошлом. Совпадение scope важно: clear cookie с другим Path может оставить исходное значение.

\\\n

Эти ветви доказывают разное. Clear cookie улучшает состояние браузера и интерфейс. Только server-side reject доказывает, что отозванный ID больше не принимают. Если logout защищён от CSRF, это отдельная проверка. SameSite не заменяет её во всех потоках.

\\\n

Симптом → причина → проверка → действие

\\\n
Диагностика рассинхронизации сессии
СимптомПричинаПроверкаДействие
Старый ID проходит после rotationHandler проверяет наличие cookie, а не status и currentОтправить predecessor после успешного rotationОтклонять rotated ID до protected effect
Logout меняет только интерфейсУдалили cookie, но не отозвали записьПовторить запрос с сохранённым IDRevoke-ить запись и проверить ответ 401/403
Поздний logout закрывает новую сессиюОперация не различает stale и currentСначала сделать rotation, затем logout старым IDВыбрать scope logout-current, lineage или all и закрепить его
Cookie живёт не там, где её чистятПри issue и clear различаются Path, Domain или hostСравнить оба Set-Cookie по каждому атрибутуСформировать clear из того же scope-контракта
Два запроса создают два successorRotation разделён между чтением и записьюПроверить concurrent path в разрешённой средеДобавить транзакционную или conditional линейзацию
\\\n

Порядок проверки

\\\n
  1. Назовите один поток: issue, protected request, rotation или logout. Зафиксируйте identity, session ID, status, expiry и current pointer.
  2. Снимите наблюдаемое поведение до исправления. Не называйте проблему инцидентом без запроса, ответа и безопасного идентификатора корреляции.
  3. Проверьте cookie delivery отдельно: имя, Secure, HttpOnly, SameSite, Path, Domain и срок хранения. Запишите, какой вопрос каждый flag не решает.
  4. Проверьте server record до действия: неизвестный, expired, rotated и revoked ID должны идти по отказному пути.
  5. Проверьте rotation на predecessor и successor. После перехода должен существовать один current ID, а старый не должен продлеваться.
  6. Проверьте logout текущим и stale ID. Current закрывает выбранный scope. Stale не выключает successor, если политика этого не требует.
  7. Проверьте authorization после session validation. Active session должна давать только явно разрешённые действия.
  8. Проверьте гонку и ошибку хранилища в разрешённой интеграционной среде. Учебный пример не заменяет этот сценарий.
\\\n

Ограничения и критерий готовности

\\\n

Модель не выбирает SQL, кеш, signed token или framework store. Она не моделирует несколько устройств, вкладки, clock skew, reverse proxy, reauthentication, CSRF-токены и сетевой порядок ответов. Идентификаторы в примере учебные. Их нельзя использовать в production. Для logout всех устройств нужна отдельная операция, которая явно выбирает identity и все её записи.

\\\n

Работа готова, если можно показать четыре независимых доказательства: запрос с revoked или rotated ID получает отказ; rotation оставляет ровно один current ID; logout очищает cookie с тем же scope; active session без подходящего authorization получает отказ. Для конкурентного rotation дополнительно нужен тест, который не допускает двух successor. Если доказательство есть только в UI или только в памяти, контракт ещё не проверен.

\\\n

Проверяемые источники

\\\n" +} diff --git a/editorial/agent-rewrites/178.json b/editorial/agent-rewrites/178.json new file mode 100644 index 0000000..e5bef67 --- /dev/null +++ b/editorial/agent-rewrites/178.json @@ -0,0 +1,6 @@ +{ + "index": 178, + "slug": "editorial-2023-01-field-threat-model", + "title": "Модель угроз для одного потока: от симптома до проверяемого контроля", + "excerpt": "Как разобрать security-sensitive изменение, когда команда предлагает контроль, но не называет актив, границу и злоупотребление. На примере одного API-потока — схема, код, таблица диагностики и критерий готовности.", + "contentHtml": "

В ревью появляется знакомая фраза: «давайте добавим подпись», «закроем endpoint» или «поставим ограничение». Но никто не может ответить, какие данные защищаются и какой запрос должен быть отклонён. Команда выбирает технологию до того, как описывает угрозу. Ошибка стоит дороже лишней строки кода: контроль может сломать легитимный поток, не закрыть нужное злоупотребление и оставить владельца без доказательства результата.

\n

Модель угроз нужна не для красивой схемы. Она связывает четыре вещи: ценный актив, источник запроса, границу доверия и действие, которое не должно пройти. Из этой связи следует контроль и проверка. Если связь не записана, «добавить безопасность» остаётся пожеланием. Если проверка не содержит отрицательного случая, команда не знает, работает ли защита.

\n

Начните с наблюдаемого симптома

\n

Опишите не тревогу, а факт. Например: обработчик принимает запрос на изменение заявки, но контракт не говорит, как он отвергает неподтверждённый вход. Это не доказывает уязвимость. Факт только показывает пробел: у изменения нет явного условия отказа и нет артефакта, который его подтверждает.

\n

Затем зафиксируйте цену ошибки. Неподписанный запрос может изменить чужую заявку, если другая проверка не перекрывает этот путь. Слишком общий контроль может, наоборот, отвергать запросы клиентов и создавать обходной ручной процесс. В обоих случаях команда спорит о механизме, пока не назвала объект защиты и допустимое поведение.

\n

Для первого прохода достаточно одного потока. Возьмём учебный пример: browser-client отправляет запрос в public-api-to-handler, handler записывает change-request в хранилище. Asset — заявка на изменение. Boundary — место, где публичный вход становится внутренним вызовом обработчика. Abuse — неподтверждённый запрос пытается изменить заявку. Это условная модель. Она не описывает конкретный продукт и не доказывает безопасность production-системы.

\n
browser-client\n      |\n      | request\n      v\n public-api-to-handler   <-- boundary\n      |\n      v\n    handler ----> change-request store (asset)\n\nabuse: неподтверждённый запрос меняет заявку\ncontrol: обработчик отклоняет вход без проверяемого подтверждения\nevidence: тест показывает отказ такого входа
\n

Схема полезна только тогда, когда каждый элемент ведёт к вопросу. Asset отвечает, какое свойство нельзя потерять. Boundary показывает, где меняются предположения о доверии. Abuse описывает действие нарушителя. Control формулирует решение. Evidence показывает, что решение проверили. Слово «система» не заменяет ни один из этих элементов.

\n

Симптом → причина → проверка → действие

\n
Диагностика неполной модели угроз
СимптомПричинаПроверкаДействие
Назван control, но нет assetВыбрали привычный механизмЧто потеряет свойство при злоупотреблении?Назвать один актив и его свойство
Есть threat, но нет boundaryНе указано место защитного решенияГде вход перестаёт быть доверенным?Поставить границу на DFD и описать переход
Есть control, но нет отрицательной веткиПроверяли только успешный путьКакой вход обязан получить отказ?Добавить тест на отказ и ожидаемый результат
В evidence написано «проверено»Не назван метод и артефактЧто увидит независимый проверяющий?Указать test, log, trace или ручной шаг
Rollback означает «вернуть всё»Модель смешана с поставкойКакие файлы, права и данные меняются?Разделить исходный snapshot и release-план
\n

Таблица отсекает ложную полноту. Заполненная строка не означает, что риск мал. Она означает, что следующий вопрос имеет адресата и ожидаемый ответ. Если ответ не находится, оставьте поле пустым и остановите выбор контроля. «Неизвестно» полезнее, чем выдуманное «защищено».

\n

Сначала граница, потом механизм

\n

Проведите границу там, где меняются правила доверия. Для публичного API это может быть вход в handler, но не всегда. Если gateway уже проверяет подпись, а handler получает внутренний вызов, модель должна показать обе границы и владельца каждой проверки. Если вы назвали границей сеть только потому, что она видна на архитектурной схеме, контроль может оказаться не на том участке потока.

\n

В примере ниже signed-request — лишь учебная гипотеза. Её обещание узкое: handler принимает запрос только после проверяемого подтверждения. Это не синоним шифрования транспорта, аутентификации пользователя или авторизации операции. В настоящем API могут потребоваться другой протокол, nonce, защита от повторной отправки, проверка полномочий и журналирование. Статья не выбирает их за владельца системы.

\n
\"Маршрут
Маршрут вопросов для одного потока. Рисунок показывает порядок диагностики, а не карту реального продукта, отчёт сканера или план развёртывания.
\n

Проверьте минимальный контракт кодом

\n

Маленькая функция может поймать механические пропуски до обсуждения реализации. Она принимает actor, asset, boundary, abuse и один из заранее названных типов контроля. Для принятой записи возвращает evidence и snapshot для учебного отката. Такой код проверяет только структуру модели. Он не ходит в сеть, не проверяет ключ, не вызывает API и не оценивает риск.

\n
const plan = planTeachingThreatModel({\n  actor: 'browser-client',\n  asset: 'change-request',\n  boundary: 'public-api-to-handler',\n  abuse: 'unsigned request changes a request',\n  control: 'signed-request'\n});\n\nif (!plan.accepted) throw new Error(plan.reason);\nif (plan.evidence.length !== 1) throw new Error('missing evidence');\nif (plan.rollback.snapshot.asset !== 'change-request') {\n  throw new Error('rollback snapshot is incomplete');\n}
\n

В этом фрагменте есть намеренный отрицательный путь. Пустой asset, неизвестный control или отсутствие boundary должны вернуть отказ, а не «почти принятую» модель. Название функции и ответ отражают учебный контракт. Не переносите его в production без отдельной проверки требований, реализации, секретов, прав и совместимости.

\n

После успешной проверки контракта найдите реальную точку потока. Сопоставьте имя asset с полем документации или схемы, boundary — с middleware, gateway или handler, а evidence — с конкретным тестом или журналом. Если сопоставление не получается, локальный PASS ничего не говорит о приложении. Он только говорит, что четыре строки заполнены.

\n

Порядок действий

\n
  1. Зафиксируйте симптом одной фразой: какой вход, какой обработчик и какое решение сейчас не имеют проверяемого условия.
  2. Назовите один asset и свойство, которое нужно сохранить. Не используйте «данные» или «безопасность» без уточнения.
  3. Опишите actor и abuse как действие. Например: внешний клиент повторяет запрос и меняет чужую заявку.
  4. Нарисуйте границу потока и назначьте владельца решения на каждой стороне. Проверьте, не дублируют ли два слоя одну и ту же проверку.
  5. Сформулируйте control как наблюдаемое поведение отказа. «Используем подпись» слабее, чем «неподтверждённый запрос получает отказ до записи».
  6. Прогоните минимальный контракт на полном и неполном входе. Сохраните результат и причину отказа.
  7. Добавьте проверку реализации с явными данными, окружением и ожидаемым результатом. Отдельно укажите, что тест не проверяет.
  8. Подготовьте rollback до выпуска. Для модели сохраните snapshot; для продукта опишите обратимые изменения, совместимость, права, ключи и наблюдение.
  9. Попросите независимого участника воспроизвести проверку по записи. Если ему приходится угадывать вход или критерий PASS, change не готов.
\n

Когда путь нужно остановить

\n

Остановите выбор контроля, если asset неизвестен или принадлежит нескольким владельцам. Нельзя оценить ущерб, пока не ясно, какое свойство защищается. Остановите работу, если boundary спорна и команда не может показать, где именно принимается решение. В этом случае уточните поток и ответственность, а не добавляйте второй механизм наугад.

\n

Остановите объявление готовности, если есть только успешный тест. Контроль, который пропускает хороший запрос, ещё не показывает, что плохой запрос получает отказ. Нужны отрицательный вход, ожидаемый код или состояние, а также подтверждение, что запись не изменилась.

\n

Не называйте локальную функцию security review, pentest или compliance evidence. Она не видит production, реальные роли, конфигурацию, ротацию ключей, повторную отправку, лимиты и операционные журналы. Учебный пример помогает проверить форму рассуждения. Он не заменяет анализ системы и согласование риска.

\n

Проверяемый критерий готовности

\n

Один поток готов к следующему этапу, когда независимый проверяющий по записи может назвать asset, actor, boundary и abuse; найти контроль в конкретном месте; запустить отрицательную проверку; увидеть ожидаемый отказ до изменения asset; определить сохранённый snapshot и условия отката. Если хотя бы один пункт требует устного пояснения автора, модель ещё не завершена.

\n

Критерий не означает «угроз больше нет». Он означает, что команда понимает выбранный риск, границу утверждения и следующий реальный тест. Результат может быть «контроль не выбран», «проверка не выполнена» или «нужен владелец». Это корректные исходы. Они лучше фиктивного PASS, который не связан с поведением приложения.

\n

Проверяемые источники

\n"} \ No newline at end of file diff --git a/editorial/agent-rewrites/179.json b/editorial/agent-rewrites/179.json new file mode 100644 index 0000000..175b49f --- /dev/null +++ b/editorial/agent-rewrites/179.json @@ -0,0 +1,7 @@ +{ + "index": 179, + "slug": "editorial-2023-01-mechanism-threat-model", + "title": "Модель угроз без догадок: связать актив, риск и проверяемый контроль", + "excerpt": "Как превратить разговор о безопасности в проверяемую связь: назвать актив, границу и злоупотребление, выбрать контроль и заранее определить evidence для отрицательного пути.", + "contentHtml": "

В ревью появляется знакомый симптом: в изменении уже назван контроль — подпись, шифрование, rate limit или дополнительная проверка, — но никто не может ответить, какой актив он защищает и какой вход должен быть отвергнут. Обсуждение быстро сводится к вкусу: один инженер предлагает JWT, другой — mTLS, третий — ещё один middleware. Цена ошибки — ложное закрытие риска. Команда может потратить время на защиту не той границы, а при откате не сможет объяснить, какую гарантию она снимает.

\n

Тезис статьи простой: модель угроз полезна только тогда, когда связывает четыре наблюдаемых вещи — актив, злоупотребление, контроль и evidence. Название технологии не заменяет эту связь. Сначала нужно зафиксировать, что важно сохранить, какое действие нарушает это свойство, где система принимает решение и чем команда увидит отказ. Такой минимальный контракт не доказывает безопасность продукта. Он делает решение проверяемым и показывает, чего ещё не хватает.

\n

Механизм: от актива к доказательству

\n

Возьмём учебный поток: browser-client отправляет change-request через границу public-api-to-handler, обработчик принимает или отклоняет запрос, затем записывает его в store. Актив — не «данные вообще», а конкретный change-request. Злоупотребление — отправить запрос без valid signature. Контроль — проверять подпись до записи. Evidence — наблюдаемый отказ на неподписанном входе.

\n

У каждого поля есть один вопрос. Asset отвечает: «что потеряет свойство при ошибке?». Abuse отвечает: «какое действие мы пытаемся остановить?». Boundary отвечает: «где вход перестаёт быть доверенным?». Control отвечает: «какое решение примет система?». Evidence отвечает: «что увидит проверяющий и по какому признаку скажет pass или fail?». Если поле заменяют словами «система», «безопасность» или «проверено», модель теряет смысл.

\n
Минимальная связь в одном потоке
ЭлементУчебное значениеПроверяемый вопросЧто не следует из записи
Actorbrowser-clientкто формирует вход?личность пользователя и его права
Assetchange-requestчто нужно сохранить?классификация всех данных продукта
Boundarypublic-api-to-handlerгде меняется доверие к входу?полная топология сети
Abuseunsigned requestкакое действие не должно пройти?полный список атак
Controlsigned-requestкакое условие проверяет handler?достаточность криптографии
Evidencelocal rejection checkкакой отказ можно наблюдать?результат production-проверки или pentest
\n

Конкретный пример: строгий контракт для отрицательного пути

\n

Ниже — учебный JavaScript-пример. Он не создаёт ключи, не подписывает HTTP-запросы и не обращается к базе. Функция проверяет только полноту описания потока. Это полезно для иллюстрации механизма: пропущенный актив или неизвестный контроль не превращаются в молчаливую догадку.

\n
function planThreatModel(input) {\n  const required = ['actor', 'asset', 'boundary', 'abuse', 'control', 'evidence'];\n  const missing = required.filter((key) => !input[key]);\n\n  if (missing.length > 0) {\n    return { status: 'reject', reason: `missing: ${missing.join(', ')}` };\n  }\n\n  const allowedControls = ['signed-request', 'explicit-authorization'];\n  if (!allowedControls.includes(input.control)) {\n    return { status: 'reject', reason: 'unknown-control' };\n  }\n\n  return {\n    status: 'accept',\n    contract: {\n      asset: input.asset,\n      abuse: input.abuse,\n      control: input.control,\n      evidence: input.evidence\n    }\n  };\n}\n\n// Учебный вызов: это не проверка реального endpoint.\nplanThreatModel({\n  actor: 'browser-client',\n  asset: 'change-request',\n  boundary: 'public-api-to-handler',\n  abuse: 'unsigned request',\n  control: 'signed-request',\n  evidence: 'local rejection check'\n});
\n

Положительный результат здесь означает только то, что запись содержит нужные поля и знакомый контроль. Он не означает, что подпись реализована правильно, ключи защищены, права проверены или запрос нельзя подделать другим способом. Отрицательный путь важнее happy path: пустой asset, отсутствующая boundary и неизвестный control должны остановить описание. В рабочем коде такой контракт нужно дополнить тестом самого handler, а затем отдельно проверить интеграцию и эксплуатационные границы.

\n
\"Учебная
Учебная схема показывает связь change-request, unsigned request, signed-request и local rejection check. Она не описывает реальную сеть, ключи или результаты проверки.
\n

Симптом → причина → проверка → действие

\n
Диагностика неполной модели
СимптомПричинаПроверкаДействие
Есть control, но нет assetвыбрали привычную технологиюкакой объект теряет свойство?назвать один asset до выбора механизма
Есть threat, но нет boundaryне указано место решениягде вход меняет статус доверия?показать одну границу на потоке
Есть control, но нет evidenceне описан отрицательный путькакой вход должен получить отказ?добавить test, log или ручную проверку
В evidence написано «проверено»не назван метод и результатчто именно увидит другой инженер?указать вход, ожидаемый отказ и артефакт
Rollback означает «вернуть всё»модель смешана с выпускомкакие поля и ресурсы реально меняются?разделить snapshot договора и план отката
\n

Порядок действий

\n
  1. Сузьте область. Возьмите один поток, один актив и одну границу. Не пытайтесь описать весь продукт одной диаграммой.
  2. Назовите злоупотребление глаголом. Формулировка «отправить запрос без действительной подписи» проверяемее, чем слово «подделка».
  3. Определите решение. Запишите, в какой точке handler должен принять или отклонить вход. Не выдавайте имя технологии за условие.
  4. Опишите evidence. Укажите конкретный вход, ожидаемый результат и артефакт: unit test, журнал решения или согласованную ручную проверку.
  5. Проверьте отрицательную ветку. Передайте пустой asset, неверный вход или неизвестный control. Система должна отказать явно, а не достроить контекст.
  6. Отделите модель от реализации. Сверьте контракт с реальным кодом, конфигурацией и владельцем ключей. Учебный пример не заменяет эти проверки.
  7. Запишите откат. Назовите, что возвращается при удалении контроля. Если меняются ключи, миграции, очереди или совместимость, нужен отдельный операционный план.
\n

Ограничения и отрицательный путь

\n

Малая модель не ранжирует риск, не считает вероятность, не строит attack tree и не перечисляет все trust zones. Она не проверяет криптографический протокол, identity provider, права доступа, хранение секретов или журналирование. Если актив связан с персональными данными, платежами или критичной операцией, потребуется более подробная классификация, согласованный владелец риска и независимая проверка.

\n

Контроль может оказаться неверным даже при полной записи. Подпись не заменяет авторизацию. Шифрование канала не доказывает целостность бизнес-операции. Rate limit не решает проблему подмены личности. Нельзя переносить учебное signed-request на реальный API без проверки протокола, ключей, времени жизни, повторной отправки и обработки ошибок. Отрицательный результат модели — повод остановиться и уточнить решение, а не повод подобрать другое модное слово.

\n

Проверяемый критерий готовности

\n

Модель готова к следующему этапу, если независимый инженер без устных пояснений может назвать asset, злоупотребление, boundary, контроль и evidence; воспроизвести отрицательную проверку; увидеть однозначный pass или fail; и понять, что именно откатывается. Это критерий полноты записи, а не сертификат безопасности. Для реализации нужен отдельный критерий: тест должен пройти на настоящем handler с разрешённым входом и явно заданным неподписанным входом, а результат должен принадлежать согласованному артефакту проверки.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/180.json b/editorial/agent-rewrites/180.json new file mode 100644 index 0000000..41d1e89 --- /dev/null +++ b/editorial/agent-rewrites/180.json @@ -0,0 +1,7 @@ +{ + "index": 180, + "slug": "editorial-2023-01-practice-threat-model", + "title": "Модель угроз до чек-листа: назвать актив, поток и границу", + "excerpt": "Практический способ разобрать один поток до выбора контроля: назвать актив, злоупотребление, границу доверия и проверяемое действие.", + "contentHtml": "

Команда получает задачу: добавить подпись, шифрование или ограничение частоты. Через день в изменении уже есть middleware, но никто не может ответить на два вопроса: какой актив защищает контроль и на какой границе он срабатывает. В продакшене это дорого. Лишняя проверка ломает легитимный поток. Слабая проверка пропускает изменение данных. При инциденте команда не может связать отказ, запись в журнале и исходную угрозу.

\n

Тезис статьи простой: модель угроз начинается не с перечня контролей. Сначала нужно назвать один поток, актив, злоупотребление и границу доверия. Потом выбрать контроль и сразу описать наблюдаемое доказательство его работы. Такой порядок не доказывает безопасность системы. Он делает решение проверяемым и показывает отрицательный путь.

\n

Малый поток вместо общей тревоги

\n

Рассмотрим учебный поток. browser-client отправляет change-request через границу public-api-to-handler. Обработчик записывает запрос в хранилище. Предполагаемое злоупотребление — отправить запрос без корректной подписи. Это не карта реальной сети и не результат аудита. Пример нужен, чтобы проверить способ рассуждения на одном объекте.

\n

В потоке есть пять разных понятий. Актив — объект, потеря или изменение которого имеет цену. Актор — источник действия. Граница доверия — место, где нельзя переносить прежнее предположение о входе. Злоупотребление — конкретное нежелательное действие. Контроль — правило, которое должно изменить это действие или его последствия.

\n
Минимальная запись одного потока
ПолеУчебное значениеПроверочный вопросЧего запись не доказывает
Акторbrowser-clientКто формирует вход?Личность пользователя
Активchange-requestЧто нельзя потерять или исказить?Классификацию всех данных
Границаpublic-api-to-handlerГде вход перестаёт быть доверенным?Полную топологию сети
ЗлоупотреблениеЗапрос без подписиКакое действие нужно остановить?Полный список атак
КонтрольПроверка подписиКакое решение меняет поток?Достаточность реализации
ДоказательствоОтказ неподписанного запросаЧто можно наблюдать?Результат проверки продакшена
\n

Механизм: граница задаёт место решения

\n

Граница доверия не обязана совпадать с границей сервиса. Она появляется там, где обработчик меняет отношение к данным. До границы запрос принадлежит внешнему актору. После неё приложение может использовать его для записи, очереди или вызова другого сервиса. Если обработчик принимает тело запроса как уже проверенное, злоумышленник получает возможность подменить актив ещё до проверки.

\n

Поэтому контроль нужно ставить рядом с переходом, который он защищает. Проверка подписи на входе отвечает на один вопрос: можно ли принимать этот запрос как созданный доверенным источником? Она не отвечает на другие вопросы. Подпись не проверяет право пользователя изменить конкретный объект. Она не заменяет валидацию схемы, защиту от повторной отправки и журналирование решения.

\n

Контроль становится полезным, когда его связывают с условием отказа. В нашем примере условие выглядит так: запрос пересёк границу, но подпись отсутствует или не проходит проверку. Ожидаемое действие — не передавать запрос обработчику записи. Доказательство — наблюдаемый ответ с отказом и, если это предусмотрено политикой, запись причины без секретов.

\n
\"Учебный
Схема связывает источник, актив, границу, злоупотребление, контроль и доказательство. Она не показывает реальную сеть, ключи, роли, журналирование или результат аудита.
\n

Конкретный пример: строгая запись модели

\n

Ниже приведён учебный JavaScript-код. Он только проверяет полноту записи в памяти. Он не создаёт ключи, не проверяет криптографическую подпись и не обращается к HTTP или хранилищу. Его задача — не дать пропустить пустой актив, неизвестную границу или контроль без названного условия.

\n
function planThreatModel(input) {\n  const required = ['actor', 'asset', 'boundary', 'abuse'];\n\n  for (const field of required) {\n    if (typeof input?.[field] !== 'string' || !input[field].trim()) {\n      return { accepted: false, reason: 'missing-' + field };\n    }\n  }\n\n  if (input.control !== 'signed-request') {\n    return { accepted: false, reason: 'unknown-control' };\n  }\n\n  return {\n    accepted: true,\n    flow: {\n      source: input.actor,\n      asset: input.asset,\n      boundary: input.boundary,\n      abuse: input.abuse\n    },\n    control: {\n      id: input.control,\n      evidence: 'unsigned request is rejected'\n    }\n  };\n}\n\nconst plan = planThreatModel({\n  actor: 'browser-client',\n  asset: 'change-request',\n  boundary: 'public-api-to-handler',\n  abuse: 'send request without a valid signature',\n  control: 'signed-request'\n});\n\nconsole.log(plan.accepted); // true\nconsole.log(plan.control.evidence);
\n

Положительный результат означает только то, что все поля заполнены и контроль известен модели. Он не означает, что подпись действительно проверяется. Для этого нужен отдельный тест обработчика с конкретным форматом подписи, ключом, временем действия и ожидаемым кодом отказа. Учебный пример намеренно не прячет эти границы за общим словом «безопасно».

\n

Отрицательный путь важнее удачного примера

\n

Если поле asset пустое, функция должна вернуть missing-asset. Если граница не названа, результатом будет missing-boundary. Если вместо явного контроля передать строку encrypt-everything, модель должна вернуть unknown-control. Она не должна угадывать, что автор имел в виду. Молчаливое угадывание превращает неполное описание в ложное согласие.

\n

В реальном приложении отрицательный путь проходит дальше. Неподписанный запрос должен остановиться до записи в хранилище. Ответ не должен раскрывать секрет, внутренний идентификатор ключа или причину, которая помогает перебору. Журнал должен содержать достаточно данных для расследования: время, тип решения, корреляционный идентификатор и безопасный контекст. Формат зависит от системы. Важно сначала назвать ожидаемое поведение.

\n

Симптом → причина → проверка → действие

\n
Диагностика неполной модели угроз
СимптомПричинаПроверкаДействие
В PR написано «добавить подпись»Контроль выбран раньше активаПопросить назвать объект, который изменится при атакеЗаписать актив и операцию отдельно
Все говорят о сервисе целикомНе названа граница потокаНарисовать источник, процесс, хранилище и переход доверияПоставить решение на конкретный переход
Есть только happy pathНе описан вход, который должен быть отклонёнПередать пустую или неверную подписьЗафиксировать код отказа и отсутствие записи
Тест проверяет ответ, но не состояниеКонтроль отделён от последствияПроверить, что запрос не попал в storeДобавить проверку побочного эффекта
В журнале написано «security check failed»Событие нельзя связать с потокомНайти correlation ID и безопасные поля контекстаДобавить наблюдаемое решение без секрета
Ссылка на стандарт заменяет решениеКаталог принят за проект системыСпросить, какое требование применено к этому активуОставить ссылку как источник, а не как доказательство
\n

Порядок действий

\n
  1. Выберите один поток. Ограничьте разбор одной операцией: например, отправкой изменения профиля или созданием платежного поручения. Не начинайте с инвентаризации всего продукта.
  2. Назовите актив. Запишите конкретный объект и нежелательное свойство: потеря конфиденциальности, целостности или доступности.
  3. Проведите границу. Укажите место, где данные становятся входом для следующего компонента. Название должно быть понятным без знания внутреннего жаргона.
  4. Сформулируйте злоупотребление. Опишите действие, а не абстрактную «атаку»: отправить запрос без подписи, повторить старый запрос или изменить чужой идентификатор.
  5. Выберите контроль. Свяжите его с условием отказа. Если контроль не меняет описанный поток, вернитесь к границе и активу.
  6. Назначьте доказательство. Выберите тест, лог, ручную проверку или иной наблюдаемый результат. Он должен показывать именно выбранное условие, а не общий статус сборки.
  7. Проверьте побочный эффект. Для отказа убедитесь, что данные не записались, сообщение не ушло в очередь и повторная попытка не получила новый побочный эффект без основания.
  8. Разберите ограничения. Запишите, что остаётся за пределами модели: ключи, права, конкурирующие запросы, прокси, кеши и операционный откат.
\n

Ограничения и отрицательные выводы

\n

Малый DFD не перечисляет все активы и злоупотребления. Он не ранжирует риск и не выбирает владельца решения. Он не проверяет настройки прокси, identity provider, хранилища ключей, очереди и сетевые ACL. Если поток зависит от повторов, параллельных запросов или доставки через несколько доменов, одной строки о подписи недостаточно.

\n

Подпись также имеет условия применимости. Нужно определить, кто подписывает запрос, как выбирают ключ, как действуют при ротации, какой срок допустим и как предотвращают повторное использование сообщения. Если система уже доверяет серверному каналу, подпись может решать другую задачу или быть лишней. Нельзя переносить учебный контроль в продакшен только потому, что он выглядит конкретно.

\n

Не называйте локальную проверку security review. Не называйте отказ одного тестового запроса доказательством отсутствия уязвимостей. Считайте модель готовой только для следующего шага, а не для закрытия всей темы.

\n

Проверяемый критерий готовности

\n

Один поток можно считать подготовленным к инженерной проверке, если другой разработчик без устных пояснений находит в записи актор, актив, границу, злоупотребление, контроль и доказательство. Для отрицательного входа указаны ожидаемый отказ и отсутствие побочного эффекта. Для контроля названо хотя бы одно ограничение применимости. Для настоящей системы отдельно проверены формат входа, права, ключи и конкурентные переходы.

\n

Практический тест готовности короткий: передайте запись коллеге, который не видел обсуждение. Попросите его указать, где остановится неподписанный запрос и какое состояние останется неизменным. Если ответы расходятся, модель ещё не описывает контракт. Если ответы совпадают, можно переходить к реализации и отдельным проверкам границы.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/181.json b/editorial/agent-rewrites/181.json new file mode 100644 index 0000000..b392c39 --- /dev/null +++ b/editorial/agent-rewrites/181.json @@ -0,0 +1,7 @@ +{ + "index": 181, + "slug": "editorial-2022-12-field-user-research", + "title": "Как остановить фичу без подтверждённой пользовательской проблемы", + "excerpt": "Макет и оценка не доказывают, что интерфейс нужно менять. Разбираем путь от наблюдаемого симптома к вопросу, гипотезе и проверяемому решению.", + "contentHtml": "

Команда приносит на планирование готовый макет: переставить поле, добавить подсказку и выпустить изменение в ближайшем спринте. На вопрос «что сейчас не получается у человека?» звучит ответ «пользователи путаются». Источника нет. Неясно, где это наблюдали, что именно сделал человек и что команда приняла за причину.

\n

Цена ошибки — не только потраченные часы. Команда может выпустить лишний элемент, усложнить экран и сохранить исходную преграду. После релиза она не сможет честно сказать, что изменилось: новая подсказка могла совпасть с сезонностью, другой версией трафика или просто не попасть в тот сценарий, где возникала трудность.

\n

Тезис: исследование пользовательской проблемы начинается с разделения уровней. Сначала зафиксируйте наблюдение и его источник. Затем назовите возможные объяснения. После этого сформулируйте гипотезу и способ её проверить. Решение — один из результатов проверки, а не исходная точка. Если связка не складывается, остановить фичу безопаснее, чем сделать предположение убедительнее.

\n

Отделите симптом от объяснения

\n

Фраза «люди не понимают форму, поэтому нужен tooltip» смешивает несколько утверждений. «Поле осталось пустым» может быть наблюдаемым фактом, если известно, где это увидели. «Человек не понял назначение поля» — уже интерпретация. «Нужен tooltip» — решение. Ни одно из следующих утверждений автоматически не следует из предыдущего.

\n

Начните с глагола и контекста. Что человек сделал? На каком шаге? В каком состоянии экрана? Что система показала? Какой результат ожидался? Записывайте буквальный признак, а не диагноз. «Три карточки из учебной выборки сохранились без значения в обязательном поле» точнее, чем «пользователи не умеют заполнять форму». Объём и способ получения такой записи тоже важны: единичный просмотр не становится общей закономерностью.

\n

Источник нужен для повторной проверки. Им может быть заметка с согласованной сессии, запись обращения в поддержку, доступный аналитический сигнал или специально проведённый тест. Источник не добавляет факту репрезентативность сам по себе. Он только показывает, откуда взялось наблюдение и какие границы у него есть.

\n
\"Маршрут
Схема показывает границу между наблюдаемым фактом и решением. При отсутствии источника решение возвращается к открытому вопросу; рисунок не изображает реальных пользователей или результаты исследования.
\n

Одна запись должна сохранять уровни

\n

Удобно хранить разбор в пяти полях: observation, source, interpretation, hypothesis и decision. Эти имена не требуют отдельного инструмента. Они задают порядок мышления и не позволяют спрятать решение внутри описания проблемы.

\n
const caseNote = {\n  observation: 'В учебной карточке поле "Срок" осталось пустым.',\n  source: 'Разбор согласованного прототипа, запись R-04.',\n  interpretation: 'Причина может быть в подписи, порядке полей или условии показа.',\n  hypothesis: 'Если подпись не объясняет ожидаемый формат, участник попросит пояснение.',\n  decision: {\n    status: 'defer-solution',\n    nextCheck: 'Проверить понимание подписи на прототипе без подсказки.'\n  }\n};
\n

Пример учебный. Он не содержит интервью, продуктовой аналитики, персональных данных или результата реального теста. Запись R-04 — условный идентификатор. Код не запускает исследование и не вычисляет частотность. Он только показывает, как не смешивать то, что увидели, с тем, что пока предполагаем.

\n

Важна и последовательность. Нельзя принять решение «выпустить подсказку», если нет ни наблюдения, ни источника. Нельзя назвать гипотезу подтверждённой, если опыт ещё не проведён. Можно заранее описать ожидаемый признак и условия, при которых гипотеза окажется неверной. Это делает следующий шаг проверяемым.

\n

Симптом → причина → проверка → действие

\n
Быстрый разбор задачи с готовым UI-решением
СимптомПричинаПроверкаДействие
«Надо сделать проще»Решение подменило описание трудностиПопросить конкретный шаг, контекст и источникОтложить макет и сформулировать вопрос
«Пользователь путается»Наблюдение смешали с мотивомСверить буквальное действие и возможные альтернативыСохранить факт, не утверждать причину
Поле часто пустоеНеизвестны знаменатель, сценарий и обязательностьРазделить состояния формы и проверить источник сигналаНе объявлять проблему общей без контекста
«Так попросили»Запрос приняли за evidenceУточнить, кто, когда и в какой задаче это сказалНазвать ограничение источника и задать вопрос
Нужно выпустить срочноОперационное ограничение выдали за вывод исследованияПроверить обратимость и отдельно записать рискСделать минимальный обратимый шаг или остановить решение
\n

Пример: почему одного сигнала мало

\n

Допустим, в учебном прототипе человек не заполнил поле «Срок». Возможны разные причины: подпись непонятна, формат ожидается в другом виде, поле появляется слишком поздно или значение сейчас не нужно для его задачи. Один и тот же симптом допускает несколько объяснений. Если сразу добавить подсказку, команда проверит только собственную догадку.

\n

Сформулируйте проверку так, чтобы она различала варианты. Например: показать тот же прототип без подсказки и попросить человека выполнить задачу своим способом. Наблюдаемый критерий — не ответ на вопрос «понравился ли экран», а действие: смог ли человек назвать назначение поля, выбрать формат и понять, что произойдёт после сохранения. Если участник просит помощь, запишите момент и точную формулировку затруднения. Если не просит, это не доказывает, что проблема отсутствует во всех контекстах.

\n

Можно выбрать другой метод. Существующий сигнал поддержки подходит для вопроса о повторяющихся обращениях, но не объясняет каждую причину. Лог показывает событие, но не мотив. Интервью помогает узнать контекст, но не измеряет частоту. Тест прототипа показывает выполнение конкретной задачи, но не гарантирует поведение в production. Метод выбирают по вопросу, а не по привычке команды.

\n

Не создавайте имитацию evidence

\n

Слабую запись часто пытаются усилить деталями: придумывают цитату, добавляют процент, называют человека «типичным пользователем» или превращают один случай в тренд. Это не улучшает исследование. Такие детали нельзя проверить по исходному материалу, а решение получает ложный вес.

\n

Учебные данные тоже должны оставаться учебными. Если пример нужен для объяснения структуры, прямо укажите его границу. Не называйте синтетическую карточку реальным участником. Не приписывайте макету эффект. Не сообщайте, что конверсия выросла, если в статье нет измерения. Корректное «неизвестно» полезнее точного числа без источника.

\n

Есть и отрицательный путь. Иногда изменение нужно выпустить по юридической, операционной или договорной причине, даже если пользовательская проблема не исследована. Это допустимое ограничение решения, но не доказательство потребности. Разделите две записи: почему выпуск обязателен и что ещё неизвестно о пользовательском опыте. После этого определите минимальный риск, обратимость и следующий способ узнать больше.

\n

Когда нужен rollback решения

\n

Rollback в этом контексте не означает стереть неудобное наблюдение. Он означает убрать необоснованный переход к фиче и сохранить вопрос, источник и неопределённость. Вернитесь к rollback, если источник недоступен, если одна интерпретация выдана за факт, если гипотеза не имеет отрицательного исхода или если предложенный опыт проверяет только вкус команды.

\n

Откат не должен превращаться в вечное ожидание идеального исследования. Задача не обязана получить большой план. Ей достаточно следующего малого опыта, который различает две причины, или честного статуса «пока неизвестно». Если такого опыта нет, кодировать решение рано. Если риск высок, сначала выберите обратимый способ проверить вопрос, а не необратимую перестройку экрана.

\n

Порядок действий

\n
  1. Назовите пользовательскую задачу. Опишите, какой исход должен получить человек и на каком шаге возникает сомнение.
  2. Запишите наблюдаемый симптом. Используйте действие, состояние и контекст. Не добавляйте мотив, которого источник не показывает.
  3. Укажите источник и его границы. Назовите артефакт, период и доступный контекст. Отдельно отметьте, чего источник не доказывает.
  4. Сформулируйте минимум две интерпретации. Так команда увидит, что решение не следует из одного объяснения.
  5. Выберите гипотезу. Запишите условие и ожидаемый наблюдаемый признак. Добавьте исход, который её опровергнет.
  6. Подберите метод. Свяжите метод с вопросом: понять контекст, проверить выполнение, найти повторяемый сигнал или сравнить варианты.
  7. Отделите решение от результата. До проверки статусом может быть defer-solution, а не «готово к разработке».
  8. Проверьте отрицательный путь. Спросите, что делаете при отсутствии источника, противоречивых сигналах или обязательном внешнем сроке.
  9. Сохраните критерий готовности. Другой человек должен понять, какой факт переводит задачу к решению и какой факт её останавливает.
\n

Ограничения метода

\n

Разделение уровней не делает источник качественным автоматически. Оно не устраняет bias набора участников, не задаёт размер выборки и не заменяет согласие на участие, защиту персональных данных или правила хранения записей. Эти вопросы зависят от продукта, риска и выбранного метода.

\n

Наблюдение поведения не равно объяснению мотива. Слова участника не равно измерение частоты. Сигнал аналитики не равно доказательство удобства. Даже повторяемый симптом не выбирает UI-решение без проверки контекста. Поэтому вывод статьи должен оставаться уже, чем исходный вопрос: «в этом сценарии обнаружен такой-то признак», а не «все пользователи сталкиваются с проблемой».

\n

Учебный код выше также имеет узкую границу. Он показывает схему записи на литералах и не подключён к исследовательскому хранилищу, трекеру, аналитике или системе согласий. Его нельзя использовать как готовый процесс и нельзя считать запуском исследования. Production-решение требует отдельной политики доступа, удаления, аудита и связи с первичными материалами.

\n

Проверяемый критерий готовности

\n

Разбор готов к решению, когда в нём есть одна пользовательская задача, наблюдаемый симптом, доступный источник, границы источника, минимум две интерпретации, проверяемая гипотеза, метод и отрицательный исход. Для решения указаны обратимость и причина выбора. Для остановки указаны недостающий факт и следующий вопрос. Ни один учебный пример не выдан за результат реального исследования.

\n

Проверка проста: другой инженер или дизайнер читает запись без устного пояснения и отвечает на три вопроса. Что произошло? Откуда это известно? Какой результат изменит решение? Если он видит только готовый макет и уверенное объяснение, проблема ещё не исследована. Если он может повторить путь от факта к следующему опыту, материал готов для обсуждения.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/182.json b/editorial/agent-rewrites/182.json new file mode 100644 index 0000000..7db339a --- /dev/null +++ b/editorial/agent-rewrites/182.json @@ -0,0 +1,7 @@ +{ + "index": 182, + "slug": "editorial-2022-12-mechanism-user-research", + "title": "Как не принять догадку о пользователе за основание для интерфейса", + "excerpt": "Практический контракт для user research: отделяем наблюдение от его смысла, проверяем гипотезу и откладываем UI-решение, пока цепочка evidence не выдержит проверку.", + "contentHtml": "

Команда получает задачу: «сделать кнопку заметнее, потому что пользователи её не видят». Макет уже приложен, оценка разработки готова, а источник утверждения никто не может открыть. После релиза кнопка меняется, но команда не знает, исчезла ли трудность: люди могли не понимать термин, не иметь права на действие, потерять данные в форме или вообще не доходить до этого экрана. Цена ошибки — не только один спринт. В продукте закрепляется решение без критерия, а следующий спор снова начинается с мнений.

\n

Решение нельзя использовать как доказательство проблемы. Сначала разделите четыре уровня: наблюдение, источник, интерпретацию и гипотезу. Только после этого выбирайте следующий опыт или небольшое обратимое изменение. Граница показывает, что команда знает, а чего пока не знает.

\n

Механизм: четыре уровня одной записи

\n

Наблюдение описывает то, что можно увидеть в конкретном контексте: человек оставил обязательное поле пустым, прервал действие после сообщения об ошибке, спросил о назначении элемента. Наблюдение не содержит мотива. Фраза «человек не понял поле» уже объясняет поведение и потому не является чистым наблюдением.

\n

Источник позволяет другому человеку сверить наблюдение. Это может быть запись исследовательской сессии, заметка поддержки, согласованный лог или другой доступный артефакт. Само наличие источника не доказывает частотность, репрезентативность или причину. Оно отвечает только на вопрос: откуда взялась строка и в каких границах её можно читать.

\n

Интерпретация связывает наблюдение с возможным объяснением. Она должна сохранять неопределённость: «пустое поле может быть связано с термином, порядком полей или отсутствием нужных данных». Хорошая интерпретация допускает альтернативы. Если она сразу называет мотив установленным, команда теряет часть проверки.

\n

Гипотеза превращает интерпретацию в условие, которое можно подтвердить или опровергнуть. Например: «если люди не понимают назначение поля, то на прототипе без подсказки они будут задавать вопрос или выбирать неверный вариант». Гипотеза не равна задаче «добавить tooltip». Она задаёт, что нужно узнать и какое наблюдение изменит решение.

\n
Границы между evidence и решением
УровеньЧто записываемЧего запись не доказываетПереход
НаблюдениеКонкретное действие в контекстеПричину и частотностьДобавить источник
ИсточникГде это зафиксированоЧто так ведут себя всеПроверить границы
ИнтерпретацияОсторожное объяснениеУстановленный мотивСформулировать вопрос
ГипотезаУсловие и ожидаемый сигналГотовый UI и production-эффектВыбрать опыт
РешениеОбратимое изменениеПодтверждённую пользуПроверить outcome
\n

Пример: решение не перепрыгивает через гипотезу

\n

Ниже — учебная модель переходов. Она не проводит интервью, не подключается к аналитике и не создаёт данные о реальных людях. Её задача — не дать функции записать план эксперимента до появления интерпретации и гипотезы. В production этот код нельзя считать системой хранения исследований.

\n
function planExperiment(record, nextExperiment) { if (!record.interpretation || !record.hypothesis) return { kind: 'decision-rejected' }; return { kind: 'experiment-planned', decision: 'defer-solution', nextExperiment }; }
\n

Если source пуст, запись наблюдения должна завершиться отказом, а не созданием карточки с неизвестным происхождением. Если вызвать planExperiment раньше гипотезы, результатом должен быть decision-rejected. После добавления интерпретации и гипотезы модель может вернуть defer-solution с описанием следующего опыта. Это всё ещё план. Он не означает, что опыт проведён.

\n

В записи должны различаться статусы «хотим узнать», «проверили», «получили сигнал» и «приняли решение». Смешивание статусов создаёт ложную уверенность. Локальная проверка контракта полезна только для порядка переходов. Она не оценивает качество исследования.

\n
Контракт учебной evidence-модели: observation требует source, interpretation требует записи, hypothesis требует interpretation, experiment требует hypothesis, rollback возвращает предыдущее состояние
Схема показывает учебные переходы записи. Она не изображает реальных участников, исследовательскую сессию или product analytics.
\n

Симптом → причина → проверка → действие

\n
Диагностическая карта перед изменением интерфейса
СимптомПричина смешенияПроверкаДействие
«Пользователи не понимают»Наблюдение смешали с мотивомНайти действие и источникПереписать как наблюдение
«Добавим подсказку»Решение появилось раньше вопросаНазвать ожидаемое изменение поведенияОтложить UI и написать гипотезу
«Так просили»Мнение приняли за evidenceУточнить контекст высказыванияНазвать источник и ограничение
«Проверим макет»Метод выбран раньше целиСформулировать вопросВыбрать метод под вопрос
«Тест прошёл»План приняли за результатПроверить участников и сигналРазделить план и outcome
«Данных мало»Неопределённость скрылиПроверить обратимость и цену ошибкиУменьшить изменение или сделать rollback
\n

Порядок работы

\n
  1. Возьмите утверждение из задачи и уберите готовое решение. Оставьте проблему, которую нужно подтвердить.
  2. Запишите наблюдаемое действие без объяснения мотива. Укажите экран, сценарий, данные и момент сбоя.
  3. Прикрепите источник, который другой человек может открыть или проверить. Запишите, чего источник не показывает.
  4. Добавьте альтернативную интерпретацию. Не выбирайте одну только потому, что она ведёт к удобному компоненту.
  5. Сформулируйте гипотезу как условие с ожидаемым наблюдением. Не используйте «точно» и «всегда» без подтверждения.
  6. Выберите небольшой опыт, который отвечает на вопрос, а не просит оценить выбранный макет. Запишите, какой результат изменит решение.
  7. Проверьте отрицательный путь: нет source, нет interpretation, нет гипотезы, источник недоступен или опыт проверяет только вкус.
  8. Если связка не выдерживает проверку, верните статус defer-solution. Сохраните вопрос и источник, отмените необоснованный переход к интерфейсу.
\n

Rollback и границы

\n

Rollback не стирает неудобное evidence. Он отменяет решение, которое не связано с наблюдением проверяемой гипотезы. Снимок состояния возвращает запись к моменту до синтетического изменения: можно снять ошибочный план или убрать UI-вариант. Исходный источник остаётся доступным, если правила хранения позволяют его сохранять.

\n

Контракт не спасает от слабого источника. Одно наблюдение может оказаться случайным. Гипотеза может проверять не ту группу людей. Локальный тест не оценивает recruitment, доступность интерфейса, размер выборки, смещение исследователя или юридические требования. Эти вопросы требуют отдельного процесса и владельца.

\n

Не превращайте поля записи в бюрократию для каждого изменения. Низкорисковый текстовый патч и дорогая перестройка сервиса имеют разную цену ошибки. Но чем дороже откат, тем опаснее фраза без источника. Если выпуск обязателен по операционной или правовой причине, назовите это ограничением. Не выдавайте вынужденный выпуск за подтверждённую пользовательскую пользу.

\n

Проверяемый критерий готовности

\n

Переход к решению готов, если проверяющий без автора может ответить: что наблюдали; где это зафиксировано; какие объяснения рассматриваются; какая гипотеза будет опровергнута конкретным сигналом; что команда сделает при каждом исходе. Должно быть понятно и то, какой результат ещё не получен. Если в карточке есть только макет и слово «понятнее», готовности нет.

\n

Удалите из записи решение и проверьте, остаётся ли понятна исходная проблема. Затем уберите гипотезу и спросите, может ли команда назвать следующий опыт без выбора компонента. Если смысл исчезает после удаления одного поля, уровни склеены. Вернитесь к наблюдению, зафиксируйте неизвестное и не увеличивайте точность искусственно.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/183.json b/editorial/agent-rewrites/183.json new file mode 100644 index 0000000..e4395a6 --- /dev/null +++ b/editorial/agent-rewrites/183.json @@ -0,0 +1,7 @@ +{ + "index": 183, + "slug": "editorial-2022-12-practice-user-research", + "title": "Как проверить пользовательскую проблему до изменения интерфейса", + "excerpt": "Команда часто начинает с готового UI-решения, хотя проблема ещё не описана. Разбираем цепочку от наблюдения до проверяемого следующего шага и показываем, когда изменение нужно отложить.", + "contentHtml": "

Команда получает просьбу «сделать форму понятнее» и сразу выбирает средство: добавить подсказку, поменять подпись, переставить поля. Через спринт появляется новый экран, но никто не может точно сказать, какую трудность он устраняет. Пользователь по-прежнему останавливается на том же шаге, а разработчики тратят время на поддержку решения, которое приняли до проверки проблемы.

\n

Симптом здесь простой: задача содержит ответ, но не содержит наблюдаемого вопроса. Цена ошибки — не только лишняя разработка. Команда закрепляет предположение как факт, теряет исходный контекст и не может проверить результат после изменения. Поэтому пользовательское исследование нужно начинать с разделения утверждений: что увидели, откуда узнали, как это объясняем и что ещё надо проверить.

\n

Тезис: сначала зафиксируйте проблему, потом выбирайте интерфейс

\n

Рабочая запись состоит из пяти уровней. Наблюдение описывает действие или высказывание без объяснения причины. Источник показывает, где это наблюдение можно сверить. Интерпретация предлагает возможное объяснение. Гипотеза связывает объяснение с ожидаемым результатом проверки. Решение или следующий опыт появляется только после этих уровней.

\n

Такой порядок не превращает исследование в бюрократию. Он защищает от подмены. Фраза «люди не понимают поле, поэтому нужен tooltip» склеивает три разных шага. «Не понимают» требует источника. «Поэтому» выдаёт интерпретацию за доказанную причину. «Нужен tooltip» уже закрывает пространство альтернатив. Разделение возвращает вопрос: какое наблюдение подтвердит или опровергнет это объяснение?

\n

Механизм: evidence не должен менять уровень сам

\n
Разбор типичного симптома
СимптомПричинаПроверкаДействие
«Нужно сделать проще»Решение записали вместо пользовательской трудностиНайти конкретный шаг, на котором возникает препятствиеОтложить макет и сформулировать вопрос
«Пользователь путается»Наблюдение смешали с мотивомОтделить буквальное действие от объясненийСохранить действие и добавить альтернативы
«Добавим подсказку»Гипотезу приняли за готовый ответНазвать ожидаемое изменение поведенияПроверить прототип или другой обратимый вариант
«Так попросили»Авторитет заменил источникУточнить контекст и доступность записиОграничить силу вывода
«Выпустим быстро»Скорость подменяет критерий успехаПроверить, что будет считаться улучшениемВыбрать малый шаг с понятным откатом
\n

Источник не обязан быть числом. Им может быть запись согласованного наблюдения, обращение в поддержку, результат тестовой сессии или артефакт, доступный команде. Но слова «все знают» источником не являются. Без контекста нельзя понять, кто видел ситуацию, в каком сценарии и что именно произошло. Один случай может быть важным сигналом, но он не доказывает частотность и не описывает всех пользователей.

\n

Интерпретация должна сохранять неопределённость. «Поле оставили пустым, потому что подпись непонятна» — слишком сильная формулировка, если зафиксировано только пустое поле. Надёжнее написать: «Поле оставили пустым; возможная причина — непонятная подпись, но возможны и другие причины». После этого гипотеза становится проверяемой: «Если подпись мешает понять назначение поля, участники будут чаще правильно объяснять его назначение в варианте с новой подписью». Это ещё не результат и не обещание эффекта.

\n

Конкретный пример: карточка evidence в коде

\n

Ниже — учебный пример на JavaScript. Он не подключён к продукту, не читает аналитику и не описывает реальных участников. Модель полезна как минимальный контракт для записи: нельзя добавить наблюдение без источника, принять решение до гипотезы или стереть исходную запись при откате.

\n
const evidence = {\n  observation: 'Обязательное поле осталось пустым',\n  source: 'session-note-17',\n  interpretation: 'Причина пока не установлена',\n  hypothesis: 'Если подпись неясна, новая подпись изменит понимание поля',\n  nextCheck: 'Показать два варианта подписи и попросить объяснить назначение поля',\n  decision: 'defer-solution'\n};\n\nfunction canPlan(record) {\n  return Boolean(\n    record.observation &&\n    record.source &&\n    record.interpretation &&\n    record.hypothesis &&\n    record.nextCheck\n  );\n}\n\nif (!canPlan(evidence)) {\n  throw new Error('Нужны observation, source, interpretation, hypothesis и nextCheck');\n}
\n

Поле decision здесь намеренно равно defer-solution. Оно не означает, что работу отменили навсегда. Оно означает, что команда не выдаёт выбранный интерфейс за доказанный ответ. Следующий шаг должен узнавать больше, а не маскировать нехватку данных. Если проверка подтвердит гипотезу, команда сравнит варианты. Если опровергнет, она сохранит время на неправильный патч.

\n

Что делать, если доказательство не складывается

\n

Отрицательный путь важен не меньше положительного. Источник может оказаться недоступен. Наблюдение может не отличаться от общей оценки. Гипотеза может не содержать условия, при котором она окажется неверной. В каждом случае остановите переход к реализации. Сохраните исходное наблюдение, явно запишите неизвестное и назначьте способ получить недостающий контекст.

\n

Rollback означает отмену решения, а не удаление неудобного свидетельства. Если команда уже выбрала подсказку, но не может связать её с проверяемой проблемой, отложите подсказку и вернитесь к карточке evidence. Не переписывайте наблюдение так, чтобы оно оправдывало выбранный компонент. Иначе процесс создаёт красивую историю вместо знания.

\n
\"Схема
Учебная схема: решение появляется после проверки цепочки evidence; при разрыве связи команда возвращается к вопросу, а не маскирует неизвестное новым UI.
\n

Порядок действий

\n
  1. Выпишите из задачи все утверждения и разделите действия, причины, пожелания и решения.
  2. Оставьте буквальное наблюдение: что произошло, на каком шаге и в каком контексте.
  3. Добавьте источник, который другой член команды может открыть и проверить.
  4. Запишите две или больше возможных интерпретации. Не выбирайте первую как факт.
  5. Сформулируйте гипотезу с условием и ожидаемым наблюдением.
  6. Выберите следующий опыт, который может подтвердить или опровергнуть гипотезу.
  7. До результата оставьте решение обратимым или установите статус defer-solution.
  8. После проверки обновите карточку: сохраните результат, границы вывода и решение, которое действительно следует из evidence.
\n

Ограничения метода

\n

Эта схема не выбирает метод исследования автоматически. Вопрос о понимании текста может потребовать проверки удобства, вопрос о причине ухода — другого источника и другого контекста. Один артефакт не даёт репрезентативности, не оценивает размер выборки и не доказывает влияние на конверсию. Учебный объект в примере не заменяет согласие участников, правила хранения данных и требования к приватности.

\n

Не переносите единичное действие на всю аудиторию. Не называйте лог доказательством без владельца, определения события и контекста. Не добавляйте выдуманные цитаты, проценты и результаты, чтобы запись выглядела убедительнее. Если продукт обязан изменить экран по внешнему требованию, зафиксируйте это как ограничение. Обязательное решение всё равно не становится результатом исследования.

\n

Проверяемый критерий готовности

\n

Карточка готова к обсуждению изменения, когда другой человек может открыть источник, отличить наблюдение от интерпретации, назвать условие, при котором гипотеза неверна, и повторить следующий опыт по записи. В тексте нет утверждения «пользователи хотят X», если источник показывает только действие Y. Есть явный отрицательный путь: что команда делает при недоступном источнике или опровергнутой гипотезе. До выполнения этих условий обсуждайте вопрос и следующий опыт, а не детали компонента.

\n

Практическая проверка занимает один проход по ближайшей задаче с готовым UI-ответом. Уберите решение из первой строки. Найдите наблюдение и источник. Если их нет, результат уже полезен: команда обнаружила, что проблема не подтверждена. Если они есть, добавьте альтернативу и обратимый следующий шаг. Готовность измеряется не уверенностью формулировки, а тем, можно ли проверить переход от наблюдения к действию.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/184.json b/editorial/agent-rewrites/184.json new file mode 100644 index 0000000..41de991 --- /dev/null +++ b/editorial/agent-rewrites/184.json @@ -0,0 +1,7 @@ +{ + "index": 184, + "slug": "editorial-2022-11-field-bitrix-performance", + "title": "Когда тормозит Bitrix-страница: как найти границу проблемы и не сломать кеш", + "excerpt": "Практический разбор медленной Bitrix-страницы: отделяем запрос, компонент, кеш и шаблон, проверяем ключи результата и принимаем только обратимые решения.", + "contentHtml": "

Пользователь открывает каталог, ждёт дольше обычного и обновляет страницу. В чате появляется короткий диагноз: «тормозит Bitrix». После него разработчик уменьшает JavaScript, администратор очищает весь кеш, а владелец сервиса просит увеличить сервер. Страница может не измениться, зато команда теряет исходное состояние. Нельзя понять, что проверяли, какой слой дал задержку и что безопасно вернуть. Цена ошибки — лишняя нагрузка, повторная работа и риск показать одному посетителю результат другого.

\n

Тезис простой: производительность Bitrix-страницы нужно разбирать по границам, а не по названию платформы. Сначала фиксируем один маршрут и его входы. Затем разделяем компонент, его кеш и шаблон. Только после этого выбираем небольшой diff и критерий проверки. Если вход кеша неизвестен, правильное действие — остановиться и уточнить контракт, а не отключать кеш наугад.

\n

Механизм: что именно формирует ответ

\n

У страницы есть несколько последовательных слоёв. HTTP-запрос выбирает маршрут и параметры. Компонент получает эти параметры и строит данные. Встроенное кеширование решает, можно ли вернуть сохранённый результат или нужно выполнить код заново. Шаблон превращает результат компонента в HTML. Браузер получает уже собранный ответ и отдельно тратит время на его разбор, стили и скрипты.

\n

Эти слои связаны, но не доказывают друг друга. Имя шаблона не показывает, какой SQL выполнился. Большой HTML не доказывает, что база медленная. Настройка кеширования не доказывает, что конкретный запрос получил cache hit. В Bitrix метод StartResultCache возвращает false, когда действующий результат можно вывести, и true, когда компонент должен сформировать результат. Это контракт ветвления, а не замер времени страницы.

\n

Ключ кеша должен учитывать каждый вход, который меняет HTML. Если результат зависит от сайта, компонента, шаблона, параметров и сегмента посетителя, эти различия нельзя скрыть в коде шаблона. Иначе две разные страницы могут использовать один сохранённый результат. Если часть результата нужна после чтения кеша, компонент может передать выбранные ключи через SetResultCacheKeys. Это управляет составом данных, доступных после кеширования; оно не ускоряет произвольный SQL и не исправляет неверный ключ.

\n

Учебный пример с отрицательной веткой

\n

Ниже — ограниченный пример. Он не запускает Bitrix, не обращается к базе и не измеряет ответ. Модель только проверяет, что перед изменением шаблона назван полный набор входов кеша. В реальном проекте список нужно подтвердить по компоненту, параметрам и условиям, которые действительно меняют HTML.

\n
<?php\n$requiredKeyParts = [\n    'SITE_ID',\n    'component',\n    'template',\n    'arParams',\n    'visitor-segment',\n];\n\n$declaredKeyParts = [\n    'SITE_ID',\n    'component',\n    'template',\n    'arParams',\n];\n\n$missing = array_values(array_diff($requiredKeyParts, $declaredKeyParts));\n\nif ($missing !== []) {\n    throw new RuntimeException(\n        'change-blocked: missing cache input ' . implode(', ', $missing)\n    );\n}\n\n$rollbackTemplate = 'catalog-grid';\n$nextTemplate = 'catalog-grid-minimal';
\n

В этом учебном запуске действие блокируется из-за visitor-segment. Это не означает, что именно сегмент замедляет страницу или что он обязательно должен входить в ключ. Это означает только одно: пока неизвестно, меняет ли он результат и где учтён, менять кеш или шаблон рано. Отрицательная ветка защищает от правки, которая выглядит локальной, но меняет данные для разных вариантов запроса.

\n

Если контракт подтверждён, шаблон можно менять как отдельный обратимый diff. В описании сохраняют старый владелец шаблона, новый владелец и условие возврата. После изменения повторяют тот же вход. Сравнение другого URL, другой роли или очищенного кеша не отвечает на исходный вопрос.

\n
\"Маршрут
Схема показывает порядок проверки границ. Она не является профилем живой страницы и не содержит production-таймингов.
\n

Симптом → причина → проверка → действие

\n
Диагностическая карта перед изменением Bitrix-страницы
СимптомВероятная причинаПроверкаДействие
Каталог медленный на одном URLСмешаны маршрут и общий разговор о платформеЗафиксировать URL, параметры, роль и вариант страницыПовторить один и тот же вход
Очистка кеша временно меняет поведениеИзменили состояние, но не нашли зависимостьСверить ветку кеша и полный список входовНе очищать весь кеш как доказательство
В ключе нет внешнего признакаHTML зависит от данных, которых нет в контрактеПроверить, меняет ли признак результат компонентаОстановить diff и назвать недостающий вход
Виноватым объявили шаблонВидимый файл приняли за источник задержкиОтделить сбор данных от рендера HTMLСобрать артефакт именно на нужной границе
После оптимизации цифра измениласьСравнили разные условия или разные кеш-состоянияПовторить исходный сценарий и способ измеренияОставить только подтверждённый diff
Нет доступа к профилю или логуГипотезу пытаются выдать за фактЗаписать ограничение и владельца следующего шагаНе утверждать причину без артефакта
\n

Как читать компонентный кеш

\n

Начните с точки подключения компонента и его параметров. Запишите имя компонента, имя шаблона, время кеширования, режим обновления и все условия, которые меняют данные или HTML. Проверьте, не добавляет ли шаблон зависимость от авторизации, группы пользователя, языка, региона, cookie или внешнего сегмента. Каждый такой признак должен иметь понятное место в контракте. Если он не влияет на результат, это тоже нужно обосновать.

\n

Затем отделите две задачи. Первая — решить, можно ли повторно использовать результат. Вторая — определить, какие данные должны быть доступны при выводе этого результата. SetResultCacheKeys относится ко второй задаче. Нельзя применять его как универсальную настройку производительности. Если компонент не кешируется или ключ строится неполно, список полей не устранит повторные вычисления.

\n

Режим «не кешировать» полезен для изоляции гипотезы только при контролируемом тесте и с понятным ограничением. На рабочем трафике он может увеличить число обращений к базе и время выполнения компонента. Ручная очистка кеша также не объясняет причину: она лишь переводит компонент в другую ветку на следующем запросе. После любого такого эксперимента верните исходный режим и зафиксируйте, что именно изменилось.

\n

Порядок диагностики

\n
  1. Запишите наблюдаемый симптом: маршрут, вариант страницы, условия доступа и шаг, на котором пользователь ждёт. Не добавляйте миллисекунды, SQL или cache hit-rate, если их не измеряли.
  2. Назначьте четыре границы: request, component, cache и template. Для каждой укажите владельца и один доступный артефакт: код, конфигурацию, лог или разрешённый профиль.
  3. Составьте cache contract. Перечислите параметры и внешние признаки, которые могут менять HTML. Неизвестный вход пометьте как неизвестный, а не удаляйте из записи.
  4. Проверьте отрицательный путь. Если нет владельца компонента, недоступен лог, различаются входы или неизвестен внешний признак, остановите изменение и сформулируйте недостающий факт.
  5. Выберите одну гипотезу и один артефакт, который отличит её от соседней. Не запускайте сразу очистку кеша, переписывание шаблона и изменение SQL.
  6. Сделайте один обратимый diff. Сохраните старый шаблон, старый режим и точку возврата. Не объединяйте в один шаг изменение ключа, TTL, параметров и структуры HTML.
  7. Повторите исходный сценарий тем же способом. Отдельно сравните правильность HTML, доступность данных и измеряемый сигнал. Один изменившийся показатель не доказывает улучшение всей цепочки.
  8. Зафиксируйте результат. Если гипотеза не подтверждена, верните diff и оставьте запись «не подтверждено». Если подтверждена, сохраните evidence и критерий, по которому изменение можно будет проверить снова.
\n

Ограничения и случаи остановки

\n

Эта схема не заменяет профилирование. Без реального запроса нельзя назвать тяжёлый SQL. Без лога нельзя утверждать длительность PHP. Без повторяемого браузерного сценария нельзя объяснить задержку загрузки скриптов. Документация Bitrix описывает API и режимы платформы, но не знает самописный компонент, его интеграции и данные конкретного сайта.

\n

Остановитесь, если один и тот же URL получает разный HTML по неописанному условию, если компонент меняет состояние вне своего контракта, если эксперимент запрещён на рабочем трафике или если результат нельзя безопасно откатить. В таком случае полезный итог — не «причина найдена», а точное ограничение: какой факт отсутствует, где его получить и кто отвечает за следующий шаг.

\n

Учебный код выше нельзя переносить в production как готовую оптимизацию. В нём нет проверки версии ядра, политики персональных данных, реального состава параметров, схемы инвалидирования и нагрузки. Его роль — показать stop condition. Рабочее решение требует локальной проверки и отдельного плана возврата.

\n

Проверяемый критерий готовности

\n

Диагностика готова к изменению, если другой инженер без устного объяснения может ответить на пять вопросов: какой вход воспроизводят; какой компонент и шаблон рассматривают; какие данные входят в ключ; какой артефакт подтверждает гипотезу; что вернут при отрицательном результате. После diff можно повторить тот же сценарий, увидеть тот же ожидаемый сигнал и однозначно вернуть предыдущее состояние.

\n

Если на любой вопрос отвечает «обычно Bitrix делает так», работа не готова. Замените общую фразу конкретным неизвестным: «не установлено, влияет ли группа пользователя на HTML», «не подтверждён владелец шаблона» или «нет разрешённого профиля для этого маршрута». Такая формулировка не обещает ускорение. Она делает следующий шаг проверяемым.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/185.json b/editorial/agent-rewrites/185.json new file mode 100644 index 0000000..1e14bde --- /dev/null +++ b/editorial/agent-rewrites/185.json @@ -0,0 +1,7 @@ +{ + "index": 185, + "slug": "editorial-2022-11-mechanism-bitrix-performance", + "title": "Почему кешированный компонент Bitrix не гарантирует быструю страницу", + "excerpt": "Медленная Bitrix-страница начинается не с размера JavaScript. Разберите маршрут, компонент, ключ кеша и шаблон, чтобы найти измеряемую причину и не нарушить выдачу для разных состояний пользователя.", + "contentHtml": "

Каталог открывается заметно дольше обычного. В DevTools виден большой HTML, сервер возвращает ответ без явной ошибки, а команда спорит о размере JavaScript. Через час отключают кеш компонента. Время почти не меняется, зато база получает больше запросов. Пользователь по-прежнему ждёт, а команда теряет защиту от повторной работы.

\n

Цена ошибки здесь двойная. Неверная оптимизация не убирает задержку. Неполный ключ кеша может отдать одному посетителю HTML, рассчитанный для другого состояния. Поэтому фраза «компонент закеширован» ничего не говорит о полной скорости страницы и сама по себе не доказывает корректность результата.

\n

Рабочий тезис такой: сначала нужно восстановить контракт результата, затем измерить названную границу. Для одного маршрута разделите вход запроса, компонент, решение о встроенном кеше и шаблон. У каждого слоя должен быть свой факт проверки. Если зависимость HTML неизвестна, изменение кеша останавливается.

\n

Механизм: что делает встроенный кеш компонента

\n

CBitrixComponent::StartResultCache отвечает за ветку компонента. При действительном кеше метод возвращает false и использует сохранённый результат. При недействительном кеше он возвращает true, после чего компонент получает данные и подключает шаблон. Документация называет базовые части зависимости: SITE_ID, имя компонента, имя шаблона и входные $arParams. Дополнительное условие передают отдельно через второй параметр.

\n

Этот контракт описывает решение компонента. Он не сообщает, сколько занял PHP, какой запрос выполнила база, прочитался ли файл кеша и сколько времени занял браузер. Вызов с ожидаемой веткой повторного использования тоже не равен наблюдаемому cache hit на конкретном HTTP-запросе. Для такого вывода нужен отдельный артефакт: профиль, лог или другой разрешённый инструмент среды.

\n

SetResultCacheKeys решает соседнюю задачу. Метод определяет, какие части $arResult сохраняются при встроенном кешировании. Если его не вызвать, ядро сериализует весь результат. Это влияет на объём и состав сохранённых данных. Метод не добавляет отсутствующее условие в ключ и не доказывает, что шаблон перестал влиять на HTML.

\n
Четыре границы, которые нельзя смешивать
СлойЧто фиксируемЧего это не доказываетПервое действие
МаршрутURL и одинаковый вариант входаКакой PHP или SQL исполнилсяПовторить один вход
КомпонентИмя и фактические параметрыЧто он единственный источник задержкиНазвать владельца
КешРежим и полный список зависимостейРеальный hit-rate и время чтенияСверить ключ с output
ШаблонИмя и данные, которые он выводитЕго длительность в миллисекундахВыбрать измерение границы
\n

Как неполный ключ ломает результат

\n

Представим каталог, где блок цены зависит от сегмента посетителя. Компонент получает IBLOCK_ID, сортировку и постраничность. Эти параметры входят в $arParams. Сегмент хранится отдельно и влияет на HTML: один посетитель видит персональную цену, другой — обычную.

\n

Если сегмент не участвует в зависимости, два разных результата становятся одним кешированным результатом. Первый запрос создаёт HTML. Следующий запрос может получить его, хотя условие вывода изменилось. Очистка кеша исправит уже сохранённое значение только временно. При следующем построении ошибка повторится. Увеличение TTL делает риск дольше, а отключение кеша маскирует его ценой дополнительных запросов.

\n

Проверяйте каждую переменную по выходу компонента. Если она меняет HTML, она должна быть частью контракта результата: через $arParams или через дополнительный идентификатор зависимости. Если переменная влияет только на срок обновления данных, это другой список. Не смешивайте ключ результата и invalidation.

\n
\"Схема
Схема показывает границы контракта. Она не является профилем сервера и не показывает реальный cache hit.
\n

Учебный пример на PHP

\n

Пример показывает безопасную форму проверки. Он не измеряет production, не обращается к Bitrix и не утверждает, что конкретный URL использует этот путь. В реальном компоненте состав $extraCacheId нужно получить из фактических условий, которые меняют HTML.

\n
$extraCacheId = implode(':', [\n    (string) $visitorSegment,\n    (string) $priceMode,\n]);\n\nif ($this->StartResultCache(false, $extraCacheId)) {\n    $arResult = loadCatalogItems($arParams);\n\n    if ($arResult['ITEMS'] === []) {\n        $this->AbortResultCache();\n    } else {\n        $this->SetResultCacheKeys(['ITEMS', 'SECTION_ID']);\n        $this->IncludeComponentTemplate();\n    }\n}
\n

Здесь дополнительный идентификатор участвует только потому, что сегмент и режим цены объявлены входами, меняющими выдачу. Код не должен собирать ключ из случайных данных, которые не относятся к результату. Если значение недоступно на этой границе, безопасный путь — не угадывать его, а остановить изменение и найти источник условия.

\n

AbortResultCache нужен для отрицательного результата, который не следует сохранять как обычную выдачу. Например, запись может отсутствовать, а запрос с произвольным идентификатором не должен заполнять кеш бесконечными пустыми вариантами. Конкретное решение зависит от компонента. Главное — не считать любой выход из ветки успешным построением кеша.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для одной страницы
СимптомПричинаПроверкаДействие
Отключение кеша не ускорило ответЗадержка находится в другом слоеРазделить TTFB, PHP, SQL, HTML и браузер разрешённым профилемВернуть кеш и измерить названную границу
Разные посетители видят один вариант блокаУсловие HTML не вошло в ключСравнить output contract и дополнительные параметрыДобавить зависимость или временно запретить кеширование
Кеш большой и медленно обновляетсяВ $arResult сохраняются лишние веткиПроверить вызов SetResultCacheKeys и данные шаблонаОставить только нужные ключи после проверки шаблона
После очистки проблема возвращаетсяИсправлен симптом, а не контрактПовторить сценарий после нового построенияНайти источник меняющегося HTML
Страница стала другой после замены шаблонаСравнение сделано с другим outputСопоставить входы, шаблон и сегмент посетителяОткатить один diff и начать сравнение заново
\n

Порядок проверки

\n
  1. Зафиксируйте один маршрут, параметры запроса и состояние пользователя. Не меняйте URL, сегмент и пагинацию между сравнениями.
  2. Назовите компонент и шаблон. Найдите место вызова StartResultCache, второй параметр и вызовы SetResultCacheKeys.
  3. Составьте список всех значений, которые могут менять HTML. Отдельно отметьте источник каждого значения.
  4. Сверьте список с $arParams и дополнительным идентификатором кеша. При пропуске не меняйте TTL и не очищайте весь кеш как «проверку».
  5. Выберите один наблюдаемый артефакт для одной границы: разрешённый профиль, лог, SQL trace или замер ответа. Запишите условия замера.
  6. Измените один параметр или один шаблон. До изменения назовите rollback: прежний шаблон, прежний ключ или прежняя настройка.
  7. Повторите тот же сценарий с теми же входами. Сравните корректность HTML и выбранный показатель, а не только субъективное ощущение.
  8. Если зависимость не доказана, верните изменение и оформите недостающий факт. Остановка — результат проверки, а не неудача.
\n

Ограничения

\n

Встроенный кеш компонента ускоряет только ту работу, которую он действительно может повторно использовать. Он не устраняет медленный SQL, тяжёлый PHP, большой ответ, блокирующий ресурс браузера или внешний API. Страница может иметь несколько компонентов с разными ключами и сроками жизни. Один успешный компонентный кеш не описывает весь запрос.

\n

Нельзя выводить production-результат из учебного кода. Без замера не называйте миллисекунды, процент ускорения, hit-rate и экономию ресурсов. Без проверки выдачи не объявляйте ключ полным. Без владельца значения не добавляйте его в зависимость наугад: случайный ключ ухудшит повторное использование и усложнит invalidation.

\n

Отрицательный путь обязателен. Если внешний признак меняет HTML, но его источник и область действия неясны, изменение блокируется. Если доступного измерения нет, можно проверить контракт и корректность, но нельзя заявлять улучшение скорости. В таком случае следующий шаг — получить один названный артефакт на согласованном стенде.

\n

Критерий готовности

\n

Изменение готово, когда для одного маршрута выполнены четыре условия. Команда перечисляет все входы, меняющие HTML. Каждый вход попадает в согласованный контракт или явно исключён как не влияющий на результат. Выбранный профиль или замер повторяется с теми же условиями и показывает сравнимый показатель. После изменения проверены разные состояния пользователя и назван rollback.

\n

Если хотя бы одно условие не выполнено, результатом должна быть остановка или дополнительное наблюдение, а не утверждение «страница ускорена». Такая граница сохраняет корректность выдачи и не позволяет одному неясному симптому превратиться в глобальную настройку кеша.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/186.json b/editorial/agent-rewrites/186.json new file mode 100644 index 0000000..35d52dc --- /dev/null +++ b/editorial/agent-rewrites/186.json @@ -0,0 +1,7 @@ +{ + "index": 186, + "slug": "editorial-2022-11-practice-bitrix-performance", + "title": "Производительность Bitrix-страницы: как найти узкое место без оптимизации наугад", + "excerpt": "Разделяем маршрут, компонент, кеш и шаблон, проверяем зависимость результата и выбираем одно обратимое действие вместо отключения кеша вслепую.", + "contentHtml": "

Страница каталога открывается медленно, а в разговоре звучит только одно объяснение: «тормозит Bitrix». Такой диагноз ничего не проверяет. Он не показывает, какой URL воспроизводит симптом, какой компонент формирует блок, где срабатывает кеш и какой шаблон отдаёт HTML. Цена ошибки — лишние запросы к базе, отключённый кеш, переписанный шаблон и тот же медленный экран. После нескольких изменений команда уже не знает, что именно помогло или что безопасно вернуть.

\n

Начинайте с наблюдаемого факта. Запишите маршрут, вариант входа, компонент, шаблон и условие, при котором HTML меняется. Если часть сведений неизвестна, оставьте её неизвестной. Это лучше, чем заменить пробел догадкой. Производительность страницы нельзя объяснить одним слоем: запрос, компонент, кеш и рендеринг связаны, но проверяются отдельно.

\n

Тезис: сначала отделите границы, потом меняйте код

\n

Одна правка должна отвечать на один вопрос. Например: «входит ли группа пользователя в зависимость кеша этого компонента?» Это проверяемый вопрос. «Почему Bitrix медленный?» — нет. Пока вопрос не ограничен, нельзя выбрать ни инструмент, ни безопасное действие.

\n

Разделите страницу на четыре границы. Маршрут задаёт вход. Компонент принимает параметры и получает данные. Кеш решает, нужно ли снова выполнять вычисление и сохраняет ли результат. Шаблон превращает результат компонента в HTML. Большой HTML не доказывает медленный SQL. Наличие кеша в настройках не доказывает cache hit на нужном запросе. Видимый шаблон не доказывает, что он стал причиной задержки.

\n
Что проверять до правки страницы
СимптомВозможная причинаПроверкаДействие
Один и тот же каталог долго формирует ответДорогая ветка компонента или промах кешаПовторить тот же URL и записать компонент, параметры и режим кешаПолучить один разрешённый серверный или прикладной артефакт, не меняя TTL
Разные пользователи видят разный HTMLГруппа, право или сегмент не вошли в ключСверить все условия output с additionalCacheID и параметрамиДобавить зависимость или остановить изменение до уточнения контракта
После очистки кеша первый запрос снова тяжёлыйОчистка убрала результат, но не устранила стоимость построенияСравнить холодный и повторный вход на одном маршрутеИскать стоимость формирования, а не считать очистку исправлением
Кеш работает, но HTML всё ещё избыточенВ результат попадают лишние данныеПроверить, нужен ли компоненту полный arResult и вызван ли SetResultCacheKeysСократить сохраняемые данные только после проверки шаблона
После изменения исчезают данные для части посетителейНарушена зависимость результата или изменён шаблонПовторить исходный вход и сравнить HTML и условия доступаВернуть один diff и разобрать недостающий input
\n

Как устроено кеширование компонента

\n

Встроенное кеширование Bitrix начинается с StartResultCache. При действующем кеше метод возвращает false и отдаёт сохранённый результат. При недействующем кеше он возвращает true, после чего компонент получает данные и подключает шаблон. По документации базовая зависимость включает сайт, имя компонента, имя шаблона и входные параметры $arParams. Дополнительное условие передают отдельно.

\n

Это контракт, а не измерение скорости. Он говорит, от чего должен зависеть результат, но не сообщает, сколько заняло выполнение запроса и был ли конкретный запрос cache hit. Если HTML зависит от группы пользователя, права доступа, языка или другого значения, такого условия нельзя оставлять только в PHP-ветке. Оно должно участвовать в зависимости результата. Иначе один сохранённый HTML может попасть к другому варианту посетителя.

\n

SetResultCacheKeys решает другую задачу. Метод задаёт поля $arResult, которые нужно сохранить для использования после чтения кеша. Если его не вызвать внутри участка с StartResultCache, ядро может сериализовать весь результат. Лишние данные увеличивают размер кеша, но сам факт большого кеша ещё не доказывает, что он является главным узким местом страницы.

\n
\"Схема
Один запрос проходит несколько границ. Проверяйте их по отдельности: объявленная зависимость кеша не заменяет измерение времени ответа.
\n

Учебный пример компонента

\n

Ниже показана сокращённая схема, а не готовый код для конкретного проекта. Число инфоблока, параметры и поле группы вы должны заменить фактическими значениями. Пример нужен, чтобы увидеть место проверки зависимости и отрицательный путь. Он не сообщает production-результаты и не измеряет время.

\n
if ($this->StartResultCache(false, [$USER->GetGroups(), $arParams['LANGUAGE_ID']])) {\n    $this->arResult = loadCatalogItems($arParams['IBLOCK_ID']);\n\n    if (!$this->arResult) {\n        $this->AbortResultCache();\n        return;\n    }\n\n    $this->SetResultCacheKeys(['SECTION_ID', 'ITEM_COUNT']);\n    $this->IncludeComponentTemplate();\n}
\n

В этом учебном варианте группа пользователя и язык входят в дополнительную зависимость, потому что они могут менять результат. Пустой результат останавливает кеширование через AbortResultCache: иначе отрицательный или неполный ответ может сохраниться как обычный. Нельзя переносить этот список в проект без проверки. Если HTML зависит от другой переменной, её нужно назвать и проверить отдельно.

\n

Обратный путь так же важен, как успешный. Если вы не знаете, меняет ли условие HTML, не увеличивайте срок кеша и не выключайте кеш полностью. Сначала найдите владельца компонента, прочитайте параметры и определите output contract. Если разрешённого артефакта нет, корректное действие — остановить оптимизацию и зафиксировать недостающий факт.

\n

Порядок диагностики

\n
  1. Зафиксируйте симптом. Укажите один URL, вариант запроса и наблюдаемое поведение. Не добавляйте выдуманные миллисекунды, SQL или cache hit-rate.
  2. Назовите границу. Найдите подключаемый компонент, его шаблон и параметры. Отдельно запишите условия, которые могут менять HTML или доступ к данным.
  3. Сверьте контракт кеша. Проверьте базовые входы и дополнительные зависимости StartResultCache. Не называйте кеш рабочим только потому, что в параметрах стоит ненулевой CACHE_TIME.
  4. Выберите один артефакт. Используйте доступный в проекте лог, профиль, трассировку или замер. Он должен различать две гипотезы: например, промах кеша и дорогую подготовку данных.
  5. Сделайте один узкий diff. Меняйте только один параметр, зависимость или шаблон. До изменения запишите, что вернуть, если результат не подтверждён.
  6. Повторите тот же вход. Сравните прежний и новый артефакт на том же URL, варианте пользователя и наборе данных. Если вход изменился, это новый эксперимент.
\n

Быстрые исправления, которые скрывают причину

\n

Очистка всего кеша меняет состояние системы, но не объясняет стоимость формирования. После очистки первый запрос закономерно может быть тяжёлым. Выключение кеша убирает один путь и добавляет нагрузку на базу и PHP. Это не диагностика. Увеличение CACHE_TIME может уменьшить число построений, но закрепит неверный HTML, если ключ неполон.

\n

Переписывать шаблон только потому, что он виден в каталоге файлов, тоже рискованно. Шаблон отвечает за вывод, но не обязан отвечать за дорогой запрос. Сначала проверьте, что компонент уже получил данные и сколько данных он сохраняет. И наоборот: уменьшение arResult не исправит медленный SQL, если запрос выполняется до формирования результата.

\n

Не смешивайте в одной правке кеш, SQL, PHP и браузер. Иначе положительный результат нельзя связать с одним изменением. Если гипотеза не подтверждается, верните именно этот diff и повторите исходный вход. Откат всей страницы или массовая очистка кеша уничтожают полезный контекст.

\n

Ограничения и критерий готовности

\n

Документация Bitrix описывает API кеширования, но не знает самописный компонент, версию PHP, структуру базы, настройки окружения и реальные условия пользователя. Учебный код не заменяет профиль. Названия IBLOCK_ID, LANGUAGE_ID и группы в примере не являются данными конкретного сайта. Статья также не утверждает, что любая Bitrix-страница станет быстрее после добавления кеша.

\n

Диагностика готова, когда выполнены все четыре условия: выбран один воспроизводимый маршрут; назван компонент и его шаблон; перечислены входы, которые меняют результат и кеш; сохранён артефакт до и после одного обратимого изменения. Действие считается успешным только при повторном замере того же входа и при отсутствии нового нарушения содержимого или доступа. Если хотя бы одного условия нет, готов не результат, а следующий вопрос.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/187.json b/editorial/agent-rewrites/187.json new file mode 100644 index 0000000..74be7c0 --- /dev/null +++ b/editorial/agent-rewrites/187.json @@ -0,0 +1,7 @@ +{ + "index": 187, + "slug": "editorial-2022-10-field-ui-tests", + "title": "UI-тест после клика: ждать состояние, а не время", + "excerpt": "Как разобрать flaky UI-тест: связать действие с наблюдаемым состоянием, отсеять устаревший ответ и проверить отказ без ложного успеха.", + "contentHtml": "

UI-тест иногда падает после успешного клика: поле заполнилось, кнопка нажалась, а проверка не нашла сообщение «Сохранено». В коде обычно стоит wait(1000) или увеличенный timeout. На быстрой машине тест успевает увидеть результат. На занятой машине он ждёт слишком мало. После увеличения паузы тот же дефект просто проявляется позже. Цена ошибки — зелёный CI без доверия, медленная диагностика и риск выпустить сценарий, который теряет данные или принимает не тот ответ.

\n

Причина часто лежит не в скорости. Тест не знает, какое состояние должно наступить после действия. Он ждёт время, исчезновение spinner или случайный текст. Надёжная проверка связывает четыре факта: действие пользователя, переход состояния, видимое подтверждение и ветку отказа. Таймаут ограничивает ожидание. Он не заменяет условие готовности.

\n

Сначала опишите наблюдаемый симптом

\n

Запишите падение буквально. Какое действие выполнил тест? Что он проверял сразу после действия? Какой элемент искал? Был ли между ними sleep? Не начинайте с гипотезы «сервер медленный». Один timeout может скрывать несколько причин: запрос не отправился, submit сработал дважды, ответ относится к старой попытке, ошибка не попала в интерфейс или assertion ждёт внутренний флаг.

\n

Полезная граница проходит между переходом и наблюдением. Переход меняет состояние приложения: форма переходит из editing в submitting. Наблюдение сообщает об этом пользователю: статус получает роль status и имя «Отправка ожидает подтверждения». Assertion проверяет наблюдение. Если тест знает только, что после клика прошла секунда, он не проверяет переход.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
sleep после submitнет названного pending-состоянияпосле submit проверить submittingдобавить видимый status
успех иногда старыйответ не связан с попыткойсравнить requestIdигнорировать stale reply
два клика создают два запросанет guard на переходеповторить submit в submittingзаблокировать второй переход
ошибка заканчивается timeoutнет recovery-состоянияпередать отказ без подтвержденияпоказать alert и сохранить черновик
тест ждёт private flagпроверка не видит пользовательский результатсопоставить flag и доступное имяутверждать public contract
\n

Механизм: состояние владеет результатом

\n

Рассмотрим форму, которая отправляет текст. После submit приложение создаёт идентификатор попытки и переходит в submitting. Пока подтверждение не пришло, повторный submit запрещён. Ответ считается текущим только тогда, когда его requestId совпадает с идентификатором состояния. Успешный ответ переводит форму в saved. Отсутствующий или отрицательный ответ переводит её в recovery-required, а не в ложный успех.

\n

Идентификатор нужен не для красоты. Пользователь может быстро повторить действие после ошибки. Первый ответ способен прийти после второй попытки. Без корреляции приложение примет старый ответ за новый и закроет форму с неверным результатом. Поэтому проверка должна включать отрицательный путь: stale reply не меняет состояние, а отказ оставляет данные для восстановления.

\n
\"Схема
Состояние формы и ответ сервера разделены идентификатором попытки. Иллюстрация показывает учебную модель, а не trace реального браузера.
\n

Конкретный пример

\n

Ниже — учебная модель переходов. Она не открывает страницу, не отправляет HTTP-запрос и не доказывает свойства production-кода. В ней оставлены только условия, которые нужно увидеть в настоящем компоненте и затем перенести в browser-тест.

\n
const pending = { phase: 'submitting', requestId: 'request-01' }; const reply = { requestId: 'request-01', outcome: 'accepted' }; const saved = reply.requestId === pending.requestId && reply.outcome === 'accepted' ? { ...pending, phase: 'saved' } : pending;
\n

В этой строке saved.phase станет saved только для текущего ответа. Если заменить идентификатор на request-00, состояние останется submitting. В настоящей модели добавьте отдельную ветку recovery-required для отказа и сохраните текст черновика. Значения request-01 и accepted — проектные значения учебного примера. Реальное приложение может использовать UUID, серверную версию или другой токен.

\n

В browser-тесте проверяйте доступный результат, если он составляет часть интерфейсного контракта:

\n
await page.getByRole('button', { name: 'Сохранить' }).click(); await expect(page.getByRole('status')).toHaveText('Отправка ожидает подтверждения'); // Учебный пример: способ контроля ответа зависит от стенда. await expect(page.getByRole('status')).toHaveText('Изменение сохранено');
\n

Playwright повторяет web assertion до выполнения условия или истечения timeout. Это помогает пережить нормальную асинхронность. Но инструмент не угадывает правильное условие. toBeVisible() на spinner может пройти, когда работа только началась. Assertion на произвольный текст «Успех» может поймать старое сообщение. В тесте должны совпасть действие, state и наблюдаемая семантика.

\n

Порядок исправления

\n
  1. Зафиксируйте исходное падение: действие, assertion, locator, timeout и последний видимый статус.
  2. Нарисуйте короткую шкалу без времени: editing → submitting → saved или recovery-required.
  3. Назовите публичные наблюдения для pending, успеха и отказа. Выберите роль и имя, которые нужны пользователю, а не только тесту.
  4. Добавьте проверку переходов рядом с владельцем состояния. Покройте повторный submit, stale reply и возврат к редактированию после отказа.
  5. Выберите разрешённый способ управлять ответом в тестовом окружении. Учебный пример не означает, что такой контроль подходит каждому стенду.
  6. Замените sleep на assertion, которое ждёт конкретный condition. Укажите специальный timeout только после того, как условие стало правильным.
  7. Проверьте отрицательную ветку. Ошибка должна быть видна, черновик — сохранён, а старый ответ — не менять новую попытку.
  8. Удаляйте новый state и assertion вместе, если согласованный UX не принимает переход. Не оставляйте locator или data-testid без владельца.
\n

Ограничения

\n

Модель не описывает router, кеш, несколько вкладок, повторную отправку, авторизацию, локализацию, сетевые ретраи и серверную идемпотентность. Она не говорит, что любой stale reply нужно молча отбросить. В некоторых системах нужен повторный read, журнал конфликта или reconciliation с сервером. Эти решения относятся к доменному протоколу, а не к одному UI-тесту.

\n

Роль status и alert в примере — требование к наблюдаемому контракту. Она не заменяет полноценную проверку доступности. Тест может найти доступное имя и всё равно пропустить плохой фокус, неверный порядок чтения или недоступную ошибку. Для этого нужны отдельные проверки и ручная оценка интерфейса.

\n

Учебный код не измеряет flake rate, длительность CI или влияние на production. После изменения такие утверждения требуют отдельного отчёта: версия раннера и браузера, окружение, число запусков, результаты и известные исключения. Здесь проверяется только логика переходов и выбранный пользовательский сигнал.

\n

Критерий готовности

\n

Изменение готово, если любой reviewer может пройти сценарий по состояниям и ответить на четыре вопроса. Какое действие запускает переход? Какой видимый факт подтверждает pending и success? Что происходит при отказе? Почему старый ответ не может подтвердить новую попытку? В коде должны существовать проверки этих веток, а browser-тест должен ждать состояние, а не прошедшее время.

\n

Формулируйте результат узко: «тест ждёт status с именем “Изменение сохранено” после ответа текущей попытки и проверяет recovery при отказе». Не пишите «флак устранён», если нет повторяемого измерения. Такая формулировка связывает изменение с наблюдаемым условием и оставляет место для следующего дефекта.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/188.json b/editorial/agent-rewrites/188.json new file mode 100644 index 0000000..c1b3ee7 --- /dev/null +++ b/editorial/agent-rewrites/188.json @@ -0,0 +1,7 @@ +{ + "index": 188, + "slug": "editorial-2022-10-mechanism-ui-tests", + "title": "Механика UI-теста: наблюдаемое состояние вместо случайной паузы", + "excerpt": "Как связать действие пользователя, переход состояния и проверяемый результат, чтобы UI-тест объяснял сбой, а не маскировал его ожиданием.", + "contentHtml": "

UI-тест нажимает «Сохранить», ждёт секунду и иногда падает на проверке результата. На быстрой машине он успевает увидеть новый экран. На занятой машине проверка срабатывает раньше. Если увеличить паузу, тест станет медленнее, но не умнее. Цена ошибки — флак в CI, повторный запуск и потеря доверия к зелёному результату. При настоящем дефекте команда получает сигнал поздно, потому что тест не знает, какого состояния он ждёт.

\n

Тезис простой: UI-тест должен ждать не время, а наблюдаемое состояние, которое означает завершение пользовательского действия. Страница должна назвать переход. Тест должен проверить это имя через доступную семантику или другой устойчивый контракт. Переход, наблюдение и assertion остаются отдельными слоями. Если их смешать, timeout превращается в замену модели.

\n

Механизм: от действия к результату

\n

Рассмотрим форму с текстовым полем и кнопкой отправки. Пользователь вводит текст. Состояние формы остаётся editing. После отправки приложение переходит в submitting и связывает попытку с идентификатором запроса. Только подтверждение этой попытки переводит форму в saved. Если подтверждение не пришло или относится к старой попытке, интерфейс показывает recovery-required, а не объявляет успех.

\n

В этой схеме есть четыре разных факта. Клик сообщает о намерении. Переход меняет модель. UI делает переход видимым. Assertion проверяет видимый результат. Клик не доказывает отправку. Исчезновение кнопки не доказывает сохранение. Истёкшая секунда не доказывает ни один из этих фактов.

\n
Что ждёт проверка после отправки формы
СлойПримерЧего он не доказываетНужная проверка
ДействиеПользователь нажал «Сохранить»Ответ принятПроверить переход в pending
Переходsubmitting и request-01Результат виден человекуПроверить guard и связь ответа с запросом
Наблюдениеrole=status, имя «Сохранение выполняется»Сеть работала без ошибокПроверить доступный результат сценария
ОтветПодтверждение с тем же идентификаторомЛюбой экран с текстом «Готово» корректенПроверить переход в saved
\n

Почему одной переменной loading недостаточно

\n

Флаг isLoading отвечает только на вопрос о занятом состоянии. Он не различает первую и вторую попытку, не защищает от двойной отправки и не объясняет, что делать после ошибки. Для асинхронной формы нужен корреляционный признак. В учебном примере это requestId. Первый submit получает request-01. Ответ с request-00 считается устаревшим и не меняет экран.

\n

Тот же guard закрывает повторный submit. Пока состояние равно submitting, второй клик не создаёт новый запрос. В реальном интерфейсе кнопка может стать disabled, но смысл правила должен жить в переходе состояния, а не только в DOM. Иначе другой обработчик или быстрый повторный клик обойдёт визуальную блокировку.

\n

Отрицательный путь важен не меньше happy path. Если сервер не подтвердил запрос, форма не должна показывать «Сохранено». Она должна сохранить черновик, показать понятное восстановление и дать действие, предусмотренное продуктом: повторить, проверить результат или вернуться к редактированию. Конкретная политика зависит от системы. Тест обязан проверить, что ложного успеха нет.

\n

Учебный пример: переходы без браузера

\n

Следующий код ограничен локальной моделью. Он не открывает страницу, не отправляет HTTP-запрос и не показывает результат реального продукта. Его задача — сделать инварианты видимыми до написания browser-теста: повторная отправка блокируется, старый ответ игнорируется, отсутствие подтверждения ведёт к восстановлению.

\n
const state = { phase: 'editing', text: 'Согласовать условия', requestId: null };\n\nfunction submit(current) {\n  if (current.phase === 'submitting') {\n    return { ...current, event: 'submit-blocked' };\n  }\n  return { ...current, phase: 'submitting', requestId: 'request-01' };\n}\n\nfunction acknowledge(current, id) {\n  if (current.phase !== 'submitting' || id !== current.requestId) {\n    return { ...current, event: 'stale-acknowledgement' };\n  }\n  return { ...current, phase: 'saved', event: 'saved' };\n}\n\nfunction fail(current, id) {\n  if (current.phase !== 'submitting' || id !== current.requestId) {\n    return { ...current, event: 'stale-failure' };\n  }\n  return { ...current, phase: 'recovery-required', event: 'recovery' };\n}
\n

Код намеренно не решает transport, retry и доступность. Он фиксирует границу переходов. После него browser-тест может проверять факты интерфейса: появился статус отправки, второй submit не изменил попытку, корректный ответ показал сохранение, а устаревший ответ не перекрыл новую форму.

\n
\"Схема
Схема отделяет действие, переход, наблюдение и отрицательный путь. Это учебная модель, а не trace браузера и не результат production-прогона.
\n

Как выглядит browser assertion

\n

После того как страница получила понятный контракт, assertion выражает пользовательский факт. В Playwright пример может выглядеть так:

\n
await page.getByRole('button', { name: 'Сохранить' }).click();\nawait expect(page.getByRole('status'))\n  .toHaveText('Сохранение выполняется');\n\n// Контролируемый ответ тестового окружения приходит здесь.\nawait expect(page.getByRole('status'))\n  .toHaveText('Изменение сохранено');
\n

Вызов toHaveText ждёт условие до установленного timeout. Это полезно только тогда, когда условие связано с переходом, который важен пользователю. Проверка «кнопка исчезла» может пройти из-за закрытия модального окна, ошибки рендера или смены маршрута. Она не заменяет статус результата.

\n

Выбирайте locator по смыслу. Роль и имя подходят для состояния, которое должен распознать пользователь. data-testid уместен для технического узла, у которого нет пользовательской семантики. Ни один locator не исправит отсутствующий contract. Если экран не различает pending, success и recovery, автоматизация будет угадывать состояние по косвенным признакам.

\n

Симптом → причина → проверка → действие

\n
Карта диагностики нестабильной UI-проверки
СимптомПричинаПроверкаДействие
После click стоит wait(1000)Нет названного pending-состоянияНайти видимый результат до и после отправкиДобавить status и assertion на него
Тест ждёт исчезновения кнопкиDOM-признак подменяет бизнес-результатПроверить, что будет при server errorУтвердить success и recovery отдельно
Два быстрых click создают два эффектаGuard живёт только в UIПовторить submit в состоянии submittingЗапретить переход и проверить один request id
Поздний ответ показывает старый успехОтвет не связан с попыткойПередать устаревший requestIdИгнорировать ответ или отправить его в согласованный recovery-путь
После ошибки нечего повторитьФорма очистила черновик до подтвержденияПроверить текст и snapshot после failureСохранить черновик и назвать действие восстановления
\n

Порядок действий

\n
  1. Запишите симптом. Сохраните текст падения, действие перед ним и текущий assertion. Не увеличивайте timeout до диагностики.
  2. Назовите состояния. Опишите pending, success и recovery словами, которые понимает пользователь.
  3. Назначьте владельца перехода. Укажите, какой обработчик создаёт request id, кто принимает ответ и кто меняет видимый статус.
  4. Проверьте отрицательный путь. Подайте пустой ввод, повторный submit, устаревший ответ и отсутствие подтверждения.
  5. Добавьте локальные проверки модели. Убедитесь, что guard, correlation и rollback не зависят от времени и DOM.
  6. Настройте контролируемый browser-вход. В тестовом окружении зафиксируйте разрешённый способ получить success и failure. Не выдавайте локальную модель за e2e.
  7. Поставьте assertion на observable state. Проверяйте role, имя и текст результата. Timeout оставьте ограничителем, а не условием успеха.
  8. Проверьте обратимость. Если новый contract расходится с UX, откатите его вместе с assertion. Не оставляйте паузу как постоянный обход.
\n

Ограничения

\n

Модель не знает о re-render, планировщике фреймворка, локализации, авторизации, нескольких вкладках и фактической доставке ответа. request-01 — учебное имя, а не требование к production-протоколу. В реальной системе поздний ответ может требовать журнала, повторного чтения данных или server reconciliation, а не молчаливого игнорирования.

\n

Роль status или alert нельзя добавлять только ради теста. Доступное сообщение должно соответствовать срочности и поведению интерфейса. Источник текста, локализация и фокус требуют отдельной проверки. UI-тест подтверждает выбранный contract, но не сертифицирует всю доступность страницы.

\n

Учебный пример не доказывает, что флак исчез, не измеряет длительность CI и не сообщает production-результаты. Для такого вывода нужны воспроизводимые запуски, версия браузера и runner, окружение, история падений и правило сравнения. Без этих данных корректно утверждать только то, что assertion теперь ждёт названное состояние.

\n

Проверяемый критерий готовности

\n

Изменение готово, если один сценарий проходит по четырём наблюдаемым веткам: pending появляется после действия, корректное подтверждение показывает success, повторная отправка не создаёт второй переход, а устаревший или отсутствующий ответ не показывает ложный успех. Для каждой ветки есть assertion на public UI contract. В коде нет произвольной паузы, которая заменяет отсутствующее состояние. Локальные проверки модели и browser-тест имеют явно описанные границы.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/189.json b/editorial/agent-rewrites/189.json new file mode 100644 index 0000000..3a67758 --- /dev/null +++ b/editorial/agent-rewrites/189.json @@ -0,0 +1,7 @@ +{ + "index": 189, + "slug": "editorial-2022-10-practice-ui-tests", + "title": "UI-тест ждёт состояние, а не секунду", + "excerpt": "Как заменить хрупкую паузу после действия на наблюдаемый контракт, проверить отрицательный путь и понять, когда сценарий действительно готов.", + "contentHtml": "

UI-тест нажимает «Сохранить», ждёт секунду и ищет сообщение «Сохранено». На ноутбуке разработчика он проходит. На загруженном CI падает по таймауту. После увеличения паузы тест становится медленнее, но не умнее: сеть может ответить раньше, позже или ошибкой. В каждом случае одна секунда сообщает только о времени. Она не сообщает, принял ли интерфейс действие.

\n

Цена ошибки двойная. CI даёт ложный красный сигнал, а команда начинает повторять запуск и увеличивать timeout. Реальный дефект теряется среди случайных падений. Иногда тест зелёный, хотя пользователь получил старое сообщение или второй запрос. Надёжный UI-тест должен ждать факт, который видит пользователь, и проверять переход к этому факту.

\n

Тезис: ожидать нужно наблюдаемое состояние

\n

После действия у страницы должен быть короткий и понятный контракт. Например: поле редактируется; отправка ожидает подтверждения; изменение сохранено; сценарий требует восстановления. У каждого состояния есть владелец, доступное представление и условие перехода. Тест проверяет это представление. Он не угадывает готовность по прошедшему времени, исчезновению CSS-класса или случайной смене DOM.

\n

У контракта есть три слоя. Действие пользователя создаёт намерение. Логика приложения принимает или отклоняет переход и связывает его с конкретной попыткой. Интерфейс показывает результат через текст, роль или другое доступное наблюдение. Если один слой пропущен, тест начинает читать внутренний флаг либо ждать паузу. Оба варианта дают слабый оракул.

\n

Механизм на простом сценарии

\n

Рассмотрим форму с одним полем. Пользователь вводит текст и нажимает кнопку. До ответа сервера состояние должно стать submitting. Повторный submit в этом состоянии не создаёт вторую попытку. Ответ принимается только для текущего requestId. Тогда поздний ответ старой попытки не сможет показать успех поверх нового редактирования.

\n

В учебной модели ниже нет браузера, сети и таймера. Она показывает только переходы состояния. Поэтому пример нельзя выдавать за результат запуска настоящего e2e-теста. Его задача уже: сделать правила явными до выбора локаторов и способа управления ответом.

\n
import { expect, test } from '@playwright/test';\n\ntest('сохраняет профиль после подтверждения', async ({ page }) => {\n  await page.goto('/profile');\n  await page.getByLabel('Почта').fill('user@example.test');\n  await page.getByRole('button', { name: 'Сохранить' }).click();\n\n  await expect(page.getByRole('status'))\n    .toHaveText('Отправка ожидает подтверждения');\n\n  // Здесь тестовое окружение даёт ответ именно этой попытке.\n  await expect(page.getByRole('status'))\n    .toHaveText('Изменение сохранено');\n});
\n

В примере первая проверка важна не меньше второй. Она доказывает, что click привёл к состоянию ожидания, а не просто вернул управление обработчику. Вторая проверка подтверждает итог. Если приложение не показывает промежуточное состояние, это не повод добавить sleep. Сначала нужно решить, что пользователь должен увидеть во время операции.

\n

Не называйте успешным любой найденный текст. Сообщение от предыдущего действия может остаться в DOM. Лучше связать его с текущей попыткой, очистить старое состояние при новом submit и проверить роль вместе с доступным именем. Если локализация меняет текст, зафиксируйте отдельный стабильный контракт, но не прячьте смысл в техническом атрибуте, который понятен только тесту.

\n
\"Переходы
Учебная схема отделяет действие, ожидание подтверждения, успешный ответ и восстановление. Она не показывает браузерный trace и не доказывает работу конкретного приложения.
\n

Симптом → причина → проверка → действие

\n
Диагностика нестабильной UI-проверки
СимптомПричинаПроверкаДействие
После click стоит wait(1000)Нет условия готовностиНазвать состояние, которое должен увидеть пользовательДобавить status или alert и ждать его
Тест видит старый successСообщение не связано с попыткойОчистить состояние и проверить новый requestIdСопоставлять ответ с текущим переходом
Два click дают два запросаНет guard в состоянии отправкиПовторить действие до ответаЗаблокировать переход из submitting
Поздний ответ меняет новый экранНе отбрасывается stale replyПередать ответ старого requestId после нового submitИгнорировать чужой ответ и записать сигнал
После ошибки нечего повторитьСбой очищает черновикПроверить текст и доступное действие восстановленияОставить данные, показать alert и определить retry
\n

Почему auto-retrying assertion не заменяет контракт

\n

Playwright умеет повторно проверять web-assertion до успеха или истечения timeout. Это полезный механизм: тест не обязан угадывать задержку рендера. Но инструмент не знает, означает ли исчезнувшая кнопка сохранение, закрытие диалога или ошибку. Он также не отличит текущий ответ от старого. Неправильное условие, которое повторяется дольше, остаётся неправильным условием.

\n

Выбирайте assertion по пользовательскому факту. toHaveText проверяет сообщение, toBeVisible — наличие видимого состояния, toBeDisabled — запрет повторного действия. Эти проверки отвечают на разные вопросы. Не заменяйте их общим isVisible() в обычном асинхронном сценарии: такой вызов возвращает снимок в момент вызова и сам по себе не ждёт нужного состояния.

\n

Локатор по роли и имени обычно лучше связывает тест с доступным интерфейсом. data-testid остаётся допустимым для технического контейнера, сложного виджета или случая, где публичная семантика не подходит. Выбор должен быть осознанным. Не добавляйте скрытый атрибут только для того, чтобы не формулировать пользовательский результат.

\n

Порядок действий

\n
  1. Зафиксируйте исходный симптом: действие, текущую проверку, timeout, сообщение падения и состояние страницы.
  2. Удалите объяснение «медленно» и назовите недостающий факт: запрос принят, отправка заблокирована, результат сохранён или ошибка объяснена.
  3. Опишите переходы вокруг действия: исходное состояние, pending, success и recovery. Для каждого укажите владельца и видимое наблюдение.
  4. Проверьте отрицательный путь: пустой ввод, двойной submit, отказ ответа, повтор после ошибки и поздний ответ старой попытки.
  5. Добавьте в интерфейс одну устойчивую семантическую точку наблюдения. Согласуйте текст, роль и доступное имя с UX и доступностью.
  6. В browser-тесте дождитесь промежуточного и конечного состояния через auto-retrying assertion. Не переносите учебную модель как доказательство работы страницы.
  7. Запустите сценарий с контролируемым успехом и отказом. Сохраните версию браузера, окружение, команду и отчёт, если делаете вывод о стабильности.
  8. Если контракт оказался неверным, откатите его вместе с assertion. Не оставляйте фиксированную паузу как постоянный запасной путь.
\n

Отрицательный путь и восстановление

\n

Ошибку нельзя сводить к красному фону. Пользователь должен понять, что произошло и что можно сделать дальше. Для учебного сценария достаточно состояния recovery-required: черновик остаётся, успешный результат не появляется, а экран предлагает повторить операцию или вернуться к редактированию. В production причина может быть сетевой, серверной или связанной с правами. UI-тест не должен смешивать эти причины, если продукт показывает их по-разному.

\n

Rollback тоже имеет смысл только при ясном контракте. Возврат к редактированию не означает, что запрос отменён на сервере. Если сервер мог принять операцию, нужен отдельный reconciliation или повторное чтение. Поэтому тест проверяет ограниченный факт: интерфейс не объявляет успех без подтверждения и не теряет ввод при выбранном сценарии восстановления. Он не доказывает согласованность всех систем.

\n

Ограничения

\n

Ожидание состояния не устраняет все причины нестабильности. Тест может падать из-за неверных данных, авторизации, гонки между вкладками, недоступного сервиса или дефекта самого интерфейса. Доступное имя может зависеть от локали. Сетевой ответ может прийти дважды. Каждый такой случай требует отдельного контракта и отдельной проверки.

\n

Не объявляйте флак исправленным после одного зелёного запуска. Учебный код не даёт production-статистику. Документация инструмента объясняет поведение assertion, но не подтверждает состояние вашего приложения. Проверяемый вывод должен быть узким: «тест ждёт named state и проверяет текущий результат», а не «сценарий теперь всегда стабилен».

\n

Критерий готовности

\n

Сценарий готов, если после каждого значимого действия существует проверяемое состояние с понятным смыслом; assertion ждёт это состояние, а не прошедшее время; повторная отправка и устаревший ответ имеют определённый исход; ошибка оставляет пользователю объяснимый путь восстановления; локатор не скрывает отсутствие публичного результата. В отчёте отдельно указано, что проверено локально, а что подтверждено реальным browser-запуском.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/190.json b/editorial/agent-rewrites/190.json new file mode 100644 index 0000000..c025f8b --- /dev/null +++ b/editorial/agent-rewrites/190.json @@ -0,0 +1,7 @@ +{ + "index": 190, + "slug": "editorial-2022-09-field-mobile-ux", + "title": "Мобильный интерфейс: как вернуть скрытое действие и не сломать откат", + "excerpt": "Если на узком экране основной шаг виден только при наведении или теряется за таблицей, сначала восстановите наблюдаемое действие, затем проверьте причину и только после этого меняйте layout.", + "contentHtml": "

Пользователь открывает карточку на телефоне и видит данные, но не может перейти к подтверждению. Кнопка появляется только при наведении, уезжает за горизонтальный scroll или растворяется после смены ориентации. Иногда экран выглядит аккуратно: шрифт не наезжает, карточки складываются в одну колонку. Но сценарий всё равно обрывается. Цена ошибки — не только плохой отзыв. Человек может повторить операцию, обратиться в поддержку или подтвердить не то действие. Команда потратит релиз на косметический breakpoint и сохранит исходную зависимость.

\n

Тезис простой: мобильный интерфейс готов, когда основной путь остаётся видимым, понятным и обратимым при зафиксированных условиях ввода и ширины. Media query помогает выбрать представление. Она не доказывает, что действие достижимо. Для проверки нужно разделить четыре вещи: наблюдаемый симптом, техническую причину, проверку реализации и действие с понятным откатом.

\n

Начните с конкретного разрыва

\n

Фраза «мобильная версия неудобна» не задаёт следующую проверку. Запишите один разрыв: «в состоянии narrow карточка показывает summary, но переход к details доступен только через hover» или «confirm находится справа от таблицы, а контейнер не сообщает, что его можно прокрутить». Добавьте экран, состояние данных, viewport, масштаб, язык, способ ввода и ожидаемый результат, если эти факты известны. Не дополняйте отчёт модельным телефоном или выдуманным пользовательским наблюдением.

\n

Затем отделите симптом от причины. Скрытая кнопка может быть следствием display, переполнения, неверного component state, условного рендера или решения считать hover достаточным маршрутом. Один и тот же симптом требует разных действий. Если сразу менять ширину breakpoint, вы проверите только одну гипотезу и можете замаскировать ошибку состояния.

\n

Механизм: среда, компонент и продуктовый контракт

\n

Среда сообщает ограниченные свойства. Media Queries Level 4 описывает media features, включая pointer, hover, any-pointer и any-hover. Эти признаки относятся к возможностям указателей и user agent. Они не описывают намерение человека, его зрение, размер пальца или то, заметил ли он control. В гибридном устройстве primary pointer и любой доступный pointer могут вести к разным значениям.

\n

Компонент отвечает за маршрут и состояние. В нём должны существовать label действия, summary перед необратимым шагом, details до подтверждения, ошибки и отмена. Продуктовый контракт отвечает за правило: главное действие нельзя оставлять только в hover-ветке, если выбранный путь должен работать без наведения. Это правило не следует автоматически из CSS-спецификации. Его нужно явно принять для конкретной операции.

\n

Такое разделение убирает ложный вывод «hover: none означает touch-only». Запрос описывает capability среды, а не тип человека. Он может включить дополнительную панель или изменить плотность layout. Он не должен удалять единственный путь к действию. Pointer Events задаёт модель событий, но наличие события не подтверждает, что пользователь увидел control или смог завершить операцию.

\n
Разделение границ перед исправлением
СлойЧто можно наблюдатьЧто проверятьЧего нельзя заключать
Средаmedia feature или тип pointerкакое представление применилосьчто человеку удобно
КомпонентDOM, CSS и stateвидимы ли label, details и confirmчто сценарий прошёл на всех устройствах
Продуктглавный и необратимый шагесть ли явный маршрут и отменачто изменится конверсия
Исследованиенаблюдение по описанному методуусловия, выборка и сигналчто локальная fixture заменила людей
\n

Учебный пример: запретить hover-only до браузерной проверки

\n

Ниже — ограниченный пример. Он принимает заранее заданный профиль и контракт действия. Он не вызывает matchMedia, не рендерит DOM, не создаёт pointer events и не сообщает о поведении пользователей. Его задача — проверить порядок решения: если основной шаг зависит только от hover в профиле без надёжного наведения, модель отклоняет маршрут и сохраняет текущий путь.

\n
function chooseMobileRoute(profile, action) {\n  if (!profile || !action?.label) {\n    return { status: 'invalid-contract' };\n  }\n\n  const narrowInput = profile.width === 'narrow' || profile.hover === 'none';\n  const hoverOnly = action.activation === 'hover-only';\n\n  if (narrowInput && hoverOnly) {\n    return { status: 'blocked', reason: 'explicit-control-required', rollback: 'keep-current-path' };\n  }\n\n  return { status: 'allowed', route: ['summary', 'details', 'confirm'] };\n}
\n

В этом примере blocked не означает, что браузер сломан. Он означает, что выбранный контракт не принимает единственный hover-маршрут при заданном учебном профиле. Значение keep-current-path тоже не запускает автоматический rollback. Оно запрещает принять новую ветку без решения о безопасном возвращении. В рабочем приложении откат может быть feature flag, старым route, revert commit или другим механизмом. Его выбирает владелец состояния операции.

\n
\"Диагностический
Схема показывает границу между локальным контрактом и проверкой реальной реализации. Она не изображает снимок браузера и не содержит production-результатов.
\n

Симптом → причина → проверка → действие

\n
Диагностическая карта мобильного маршрута
СимптомПричинаПроверкаДействие
Кнопка видна только при hoverГлавный путь привязали к hover-веткеПроверить DOM, CSS visibility и keyboard pathДобавить видимый labelled control
Confirm уехал за таблицуКонтейнер скрывает overflow или порядок выбран неверноПроверить размеры, scroll boundary и порядок шаговВынести confirm после summary/details или дать явный scroll
Иконка есть, смысл неясенLabel заменили декоративным знакомПроверить accessible name и текст до кликаВернуть короткую метку и предмет действия
После раскрытия теряется focusState меняет DOM без управляемого focusПройти клавиатурой и проверить focus orderСохранить фокус и объявить новое состояние
После исправления непонятно, что откатыватьНовый route смешан с серверным состояниемНазвать владельца состояния и необратимый шагСохранить прежний route до проверки и описать rollback
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите одно недоступное действие и условия, в которых оно исчезает. Не заменяйте описание ярлыком «плохой mobile UX».
  2. Найдите владельца перехода. Посмотрите DOM, component state, CSS visibility, overflow и route contract. Установите, построен ли control и только скрыт или он вообще не рендерится.
  3. Отделите capability от предположения. Если код читает media features, запишите, что именно они сообщают. Не называйте их классификатором пользователя и не делайте из них доказательство удобства.
  4. Проверьте контракт. Убедитесь, что путь сохраняет summary → details → confirm, имеет label, ошибку, отмену и явный способ активации. Учебный код проверяет только этот порядок.
  5. Исправьте маршрут. Добавьте видимый control, сохраните контекст перед подтверждением и не прячьте отмену или ошибку за hover. Не начинайте с декоративных отступов.
  6. Проверьте реализацию в браузере. Зафиксируйте viewport, zoom, шрифты, локаль, данные, клавиатуру, pointer и orientation. Пройдите normal, loading, error, empty и long-content состояния.
  7. Проверьте отрицательный путь. Остановитесь, если неизвестен владелец state, отсутствует label, данные меняются конкурентно, focus теряется или новый путь может повторить необратимое действие.
  8. Оформите откат. До выпуска назовите старый route, условие остановки и способ возврата. Не удаляйте прежнюю ветку только потому, что локальная модель вернула allowed.
\n

Что проверка должна показать

\n

В browser-check основной action должен быть виден без hover. Перед ним должна быть понятна предметная операция. Details должны открываться явным управляемым способом. После открытия focus не должен исчезать, а ошибка должна оставаться читаемой в узком контейнере. Если таблица требует прокрутки, интерфейс должен сообщать о границе и не прятать единственный confirm за ней. Эти утверждения относятся к конкретной реализации и набору условий проверки.

\n

Проверьте не только узкий viewport. Медленный шрифт, длинная локаль, крупный системный текст, пустые данные, длинное имя и landscape могут сломать маршрут иначе, чем условные 320 или 375 CSS-пикселей. Проверяйте фактический текст. «Одна колонка» не является доказательством reflow, а большой touch target не объясняет, что именно он подтверждает.

\n

Нужен usability-test — проведите отдельный метод с задачей, условиями набора и способом записи наблюдений. Нужен accessibility review — зафиксируйте критерии и scope. Учебная функция и unit assertion не заменяют ни то ни другое. Нельзя писать «девять пользователей успешно прошли сценарий», если есть только девять проходов функции.

\n

Ограничения и отрицательный путь

\n

Media query не решает проблему данных, авторизации, сетевой задержки, серверного конфликта или повторного подтверждения. Pointer event не проверяет зрительную заметность, фокус, semantics и размер текста. Browser test не доказывает потребности всех аудиторий. Accessibility-критерий не обещает коммерческий эффект. Каждый результат нужно связывать с тем артефактом и условиями, которые его получили.

\n

Остановитесь, если причина остаётся неразличимой. Например, если кнопка не видна, но неизвестно, отсутствует ли она в DOM или скрыта стилем, сначала получите этот факт. Остановитесь также, если новый маршрут меняет смысл операции и откат не возвращает прежнее состояние. Нельзя называть отсутствие production-данных доказательством отсутствия проблемы. В этом случае записывают неизвестное и не усиливают формулировку.

\n

Проверяемый критерий готовности

\n

Работа готова к следующему этапу, если другой инженер без устного пояснения может ответить на пять вопросов: какое действие было недоступно; при каких условиях; где находится причина; каким тестом проверяется исправление; что произойдёт при отказе или откате. В browser-check есть видимый label, путь без hover, сохранённый focus, обработанные loading/error состояния и проверенная граница overflow. В задаче указаны владелец состояния и точка остановки.

\n

Если остаётся только фраза «на телефоне стало лучше», готовности нет. Если модель прошла, но браузерный путь не проверен, готов локальный контракт, а не интерфейс. Если браузерный путь прошёл, но неизвестно, как вернуть старый route, выпуск не готов. Такой критерий не обещает успех продукта. Он показывает, что команда проверила именно тот разрыв, который заявила, и умеет остановиться на отрицательной ветке.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/191.json b/editorial/agent-rewrites/191.json new file mode 100644 index 0000000..87f254b --- /dev/null +++ b/editorial/agent-rewrites/191.json @@ -0,0 +1,7 @@ +{ + "index": 191, + "slug": "editorial-2022-09-mechanism-mobile-ux", + "title": "Мобильный интерфейс без скрытого действия: как разделить среду и маршрут", + "excerpt": "На узком экране действие часто исчезает не из-за одного breakpoint. Разбираем границу между возможностями ввода, состоянием компонента и обязательным маршрутом, а затем проверяем отрицательный путь.", + "contentHtml": "

На десктопе карточка показывает «Открыть итог» после наведения. На телефоне указателя нет, а текстовой кнопки нет тоже. Человек видит данные, но не видит следующего шага. Похожий сбой возникает у таблицы: действие остаётся в последней колонке за пределами экрана. Ещё один вариант — подтверждение появляется только после hover, хотя экранная клавиатура уже закрыла часть интерфейса. Цена ошибки — незавершённая операция, повторное обращение в поддержку или неверное подтверждение без нужного контекста.

\n

Один media query не исправляет этот класс проблем. Он меняет раскладку, но не отвечает, какое действие должно остаться доступным. Рабочий тезис проще: возможности среды подсказывают адаптацию, а маршрут компонента должен явно описывать обязательные шаги. Нельзя строить единственный путь на hover, широкой таблице или предположении о двух руках.

\n

Механизм: четыре границы одного экрана

\n

Сначала есть среда. Media Queries описывают характеристики user agent и устройства: ширину, pointer и hover capability. Это свойства среды, а не заключение о том, что конкретному человеку удобно. На одном устройстве могут быть доступны разные способы ввода. Поэтому hover: none не означает «в системе никогда не будет hover», а pointer: coarse не измеряет размер пальца и точность действия.

\n

Дальше работает компонент. Он строит summary, details, confirm и состояния loading, error, disabled. Его задача — сохранить порядок и смысл действия в конкретной разметке. Затем продукт решает, какой переход является главным. Если подтверждение обязательно, его нельзя оставлять единственным эффектом наведения. Наконец, отдельная проверка реализации показывает, как этот контракт ведёт себя в браузере и с реальным содержимым.

\n
Граница ответственности в мобильном маршруте
СлойЧто известноЧто можно решитьЧего нельзя заключать
СредаДоступны значения media features или события pointerПодобрать раскладку и дополнительное улучшениеЧто конкретному человеку удобно действовать
КомпонентОпределены шаги и состояния маршрутаПоказать явный control и сохранить контекстЧто разметка уже доступна во всех браузерах
ПродуктНазвано обязательное действиеЗапретить hover-only как единственный переходЧто изменится конверсия или скорость
ПроверкаЕсть условия, сценарий и ожидаемый результатПровести browser, keyboard или accessibility checkЧто локальная функция заменила исследование
\n

Такое разделение локализует ошибку. Если кнопка исчезла, можно проверить CSS visibility, состояние компонента, overflow и сам продуктовый маршрут. Без границы всё называют «мобильным UX», а исправление сводится к случайному breakpoint. После него экран может выглядеть аккуратно и всё равно оставаться незавершённым.

\n
Четыре слоя мобильного маршрута: среда ввода, маршрут компонента, продуктовое решение и отдельная проверка
Схема отделяет capability среды от маршрута и проверки. Профиль narrow/coarse/no-hover в примере задаётся явно и не считывается из браузера.
\n

Пример: контракт не принимает скрытый переход

\n

Ниже приведена учебная функция. Она не создаёт DOM, не запускает браузер, не читает matchMedia, не получает pointer events и не измеряет удобство. Профиль — входные данные примера. Функция проверяет только одно правило: на узком маршруте главное действие должно иметь явный control. Этот код не является production-адаптером и не заменяет browser test.

\n
function planMobilePath(profile, action) {\n  const empty = {\n    accepted: false,\n    platform: 'not-observed',\n    usability: 'not-measured',\n  };\n\n  const validProfile = profile\n    && ['narrow', 'wide'].includes(profile.viewport)\n    && ['coarse', 'fine'].includes(profile.primaryPointer)\n    && ['available', 'unavailable'].includes(profile.hover);\n\n  if (!validProfile || !action?.id || !action?.label) {\n    return { ...empty, reason: 'invalid-contract' };\n  }\n\n  const requiresExplicitControl =\n    profile.viewport === 'narrow'\n    || profile.primaryPointer === 'coarse'\n    || profile.hover === 'unavailable';\n\n  const steps = ['summary', 'details', 'confirm'];\n\n  if (action.activation === 'hover-only' && requiresExplicitControl) {\n    return {\n      ...empty,\n      reason: 'hover-only-blocked',\n      steps,\n      rollback: 'keep-current-path',\n    };\n  }\n\n  return {\n    ...empty,\n    accepted: true,\n    reason: 'explicit-route-accepted',\n    steps,\n  };\n}\n\nconst profile = {\n  viewport: 'narrow',\n  primaryPointer: 'coarse',\n  hover: 'unavailable',\n};\n\nplanMobilePath(profile, {\n  id: 'confirm-order',\n  label: 'Подтвердить заказ',\n  activation: 'explicit-control',\n});\n// accepted: true, steps: summary -> details -> confirm\n\nplanMobilePath(profile, {\n  id: 'open-summary',\n  label: 'Открыть итог',\n  activation: 'hover-only',\n});\n// accepted: false, reason: hover-only-blocked
\n

В первой ветке функция принимает маршрут, потому что действие имеет метку и явный способ активации. Во второй она возвращает отказ. Это полезная локальная проверка: код не может случайно объявить hover-only допустимым для выбранного профиля. Но результат ничего не говорит о настоящем viewport, фокусе, размерах touch target, screen reader, локали или сетевой задержке.

\n

Отрицательный путь здесь важнее зелёной ветки. Неполный профиль возвращает invalid-contract. Пустая метка также останавливает переход. Для hover-only сохраняется keep-current-path. Это не автоматический rollback приложения. Это запрет считать новый маршрут принятым. Реальный откат выбирают отдельно: feature flag, возврат коммита, старый route или ограниченный rollout.

\n

Симптом → причина → проверка → действие

\n
Диагностическая карта скрытого действия
СимптомПричинаПроверкаДействие
Меню видно только при наведенииГлавный переход привязали к hoverЕсть ли видимая кнопка или ссылка в маршрутеДобавить explicit control и оставить контекст
Кнопка находится справа за экраномДействие закрепили за широкой таблицейПроверить overflow и порядок summary/details/confirmВынести confirm в доступный поток
На hybrid input состояния расходятсяPrimary capability приняли за все доступные способы вводаСравнить primary и any capability, не делая вывод о человекеОставить hover как улучшение, но не как единственный путь
После адаптации пропал контекстСразу сжали layout и спрятали обязательные данныеПроверить, что summary виден до действияВернуть краткий итог и вынести детали в отдельный шаг
Изменение нельзя безопасно отменитьУдалили старую ветку до проверки нового маршрутаЕсть ли сохранённый current path и владелец откатаСначала сохранить обратимый переход
Локальный тест зелёный, экран сломанКонтракт приняли за проверку реализацииПовторить сценарий в браузере с фактическими даннымиДобавить browser, keyboard или accessibility check
\n

Порядок действий

\n
  1. Опишите симптом. Назовите конкретное состояние: что не видно, какой переход требует hover, где появляется горизонтальная прокрутка и какое действие нельзя завершить.
  2. Зафиксируйте условия. Укажите экран, данные, локаль, viewport, масштаб, способ ввода и состояние компонента. Не подставляйте значения, которых никто не наблюдал.
  3. Разделите причину. Проверьте media rule, DOM, visibility, overflow, component state и продуктовое решение. Не объявляйте любую проблему проблемой ширины.
  4. Опишите маршрут. Запишите summary, details, confirm, отмену, loading и error. Для каждого шага назовите видимый control и ожидаемый результат.
  5. Проверьте отрицательную ветку. Передайте неполный профиль, пустую метку и hover-only. Ожидайте отказа, а не молчаливого построения нового пути.
  6. Проверьте среду отдельно. Если реализация использует media features или pointer events, получите фактические значения в браузере и сохраните условия запуска.
  7. Проверьте реализацию. Пройдите маршрут клавиатурой, на узком и широком контейнере, с длинным текстом и ошибкой. Убедитесь, что focus и контекст не исчезают.
  8. Согласуйте откат. Определите, как вернуть current path, кто принимает решение и какие данные нельзя потерять. Не называйте поле rollback готовым откатом сервера.
\n

Ограничения

\n

Профиль narrow не равен конкретным 320, 375 или 412 CSS-pixels. Реальный breakpoint зависит от содержимого, шрифта, локали, масштаба и layout. Его нельзя вывести из этой функции. Значения coarse и unavailable также не описывают навык человека и не заменяют проверку assistive technology.

\n

Media Queries помогают выбрать presentation, но не определяют бизнес-логику завершения операции. Pointer Events описывают модель событий, но не обещают, что control заметен или физически достижим. WCAG задаёт проверяемые критерии для конкретного контента и реализации, а соответствие нельзя вывести из одной unit-проверки. В учебном примере нет production-трафика, пользовательских наблюдений, конверсии и доказательства доступности.

\n

Нельзя считать любой explicit control достаточным. Он может быть слишком мал, терять фокус, иметь неясную метку, открывать устаревшие details или отправлять действие повторно. Нельзя исправлять скрытый маршрут только увеличением кнопки. Сначала восстановите смысл и порядок, затем проверяйте визуальные и технические свойства.

\n

Проверяемый критерий готовности

\n

Маршрут готов к следующей проверке, если другой инженер без устного объяснения может показать summary, открыть details, выполнить confirm, увидеть loading и error, отменить действие и назвать путь отката. Главный переход доступен без hover. Условия браузерной проверки записаны. Длинный текст и узкий контейнер не скрывают control. Локальная модель отклоняет неполный профиль и hover-only.

\n

Это ещё не заявление о доступности всего продукта и не доказательство роста метрики. Для такого вывода нужны отдельные browser, accessibility и usability-проверки с их методами и ограничениями. Проверяемый результат этой статьи уже: обязательное действие не зависит от одной capability, а граница между моделью и реальной средой остаётся явной.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/192.json b/editorial/agent-rewrites/192.json new file mode 100644 index 0000000..91fce62 --- /dev/null +++ b/editorial/agent-rewrites/192.json @@ -0,0 +1,7 @@ +{ + "index": 192, + "slug": "editorial-2022-09-practice-mobile-ux", + "title": "Мобильное действие без скрытого шага: как убрать зависимость от hover", + "excerpt": "Если главное действие исчезает на узком экране, ищите не «неправильный breakpoint», а потерянный маршрут. Разбираем явный mobile-путь, отрицательную ветку и проверку, которую можно повторить.", + "contentHtml": "

На телефоне пользователь открывает экран заказа, видит сумму, но не находит переход к подтверждению. На десктопе переход появляется при наведении на строку таблицы. В другом варианте кнопка остаётся в последней колонке и уезжает за горизонтальный scroll. Симптом один: человек не может завершить действие, хотя данные уже введены.

\n

Цена ошибки не сводится к плохому впечатлению. Незавершённая форма создаёт обращение в поддержку. Скрытая команда провоцирует повторный ввод. Если команда временно увеличивает кнопку, но теряет детали перед подтверждением, пользователь получает новый путь с другим риском. Релиз при этом трудно проверить: непонятно, какой элемент владел переходом и при каком состоянии он исчезал.

\n

Тезис статьи простой: мобильный сценарий нужно проверять как маршрут, а не как набор размеров. У основного действия должны быть видимый контекст, явный переход к деталям и отдельное подтверждение. Hover может улучшать обзор, но не должен оставаться единственным способом добраться до операции. Это правило не доказывает удобство интерфейса. Оно задаёт минимальный контракт, который можно проверить в коде и затем подтвердить в браузере.

\n

Сначала восстановите маршрут

\n

Возьмём одно действие: проверить данные заказа и подтвердить его. Не переделываем весь экран. Сначала описываем три состояния. summary показывает предмет операции и краткий контекст. details открывает данные, которые нужно проверить до необратимого шага. confirm содержит явный control с понятной меткой.

\n

Такое разбиение отделяет содержание от раскладки. В узком контейнере строки могут перейти в столбец, а детали — открываться во вкладке или disclosure. Но порядок решения сохраняется. Человек сначала понимает, что изменится, потом смотрит данные и только затем подтверждает. Если компонент пропускает первый или второй шаг, изменение CSS не исправит потерю смысла.

\n
Минимальный контракт мобильного действия
СостояниеЧто должно быть видноЧто проверяемЧего контракт не обещает
summaryназвание операции и краткий контекстсостояние существует первымчто любой перевод поместится в одну строку
detailsявный переход к даннымпереход не требует hoverчто выбранный accordion удобен всем
confirmвидимая метка основного действияcontrol достижим явным вводомразмер зоны нажатия и качество текста
rollbackсохранённый рабочий путьнепринятый маршрут не вытесняет текущийавтоматический откат серверного состояния
\n

Слово «видимый» здесь означает часть проверяемого договора. Оно не означает, что элемент уже имеет правильный размер, цвет, фокус или положение. Эти свойства требуют отдельной проверки разметки и рендера. Контракт не заменяет accessibility review и usability study.

\n

Механизм: почему hover ломает действие

\n

Hover описывает возможность среды указателя, а не намерение человека. Один пользователь может подключить мышь к узкому экрану. Другой может использовать клавиатуру, экранную лупу или другой способ ввода. Поэтому условие hover: none не равно утверждению «здесь есть только touch». Оно лишь сообщает характеристику pointing device, которую браузер определил для среды.

\n

Проблема появляется, когда команда превращает эту характеристику в единственный путь. Иконка показывает tooltip только при наведении. Строка становится ссылкой только в :hover. Действие раскрывается по перемещению указателя, но не имеет кнопки и не получает фокус. На экране телефона этот путь может исчезнуть, а на клавиатуре — остаться недостижимым.

\n

Безопаснее разделить основной control и подсказку. Основной control присутствует в DOM и имеет текстовую метку. Hover может показать дополнительные сведения или подсветить область. Если hover недоступен, сведения остаются достижимыми через явную кнопку или ссылку. Такой механизм работает и на широкой странице: desktop получает ускоренное обнаружение, но не теряет базовый маршрут.

\n

Учебный пример с отрицательной веткой

\n

Ниже — маленькая модель маршрута. Она принимает три проектных значения: ширина контекста, характеристику основного указателя и доступность hover. Эти строки задаёт тест, а не браузер. Модель не читает matchMedia, не создаёт DOM, не отправляет pointer events и не измеряет людей. Поэтому её результат ограничен проверкой порядка шагов и отказа от hover-only.

\n
function planMobilePath(profile, action) {\n  const empty = {\n    platform: 'not-observed',\n    usability: 'not-measured',\n    steps: [],\n  };\n\n  const validProfile = profile\n    && ['narrow', 'wide'].includes(profile.viewport)\n    && ['coarse', 'fine'].includes(profile.primaryPointer)\n    && ['available', 'unavailable'].includes(profile.hover);\n\n  if (!validProfile || !action?.id || !action?.label) {\n    return { ...empty, accepted: false, reason: 'invalid-contract' };\n  }\n\n  const explicit = profile.viewport === 'narrow'\n    || profile.primaryPointer === 'coarse'\n    || profile.hover === 'unavailable';\n\n  const steps = ['summary', 'details', 'confirm'];\n\n  if (action.activation === 'hover-only' && explicit) {\n    return {\n      ...empty,\n      accepted: false,\n      reason: 'hover-only-blocked',\n      steps,\n      rollback: 'keep-current-path',\n    };\n  }\n\n  return {\n    ...empty,\n    accepted: true,\n    reason: 'explicit-route',\n    steps,\n    rollback: 'replace-route-only',\n  };\n}\n\nconst route = planMobilePath(\n  { viewport: 'narrow', primaryPointer: 'coarse', hover: 'unavailable' },\n  { id: 'confirm-order', label: 'Подтвердить заказ', activation: 'explicit' },\n);\n\nconsole.log(route.accepted); // true\nconsole.log(route.steps); // summary, details, confirm\n\nconst rejected = planMobilePath(\n  { viewport: 'narrow', primaryPointer: 'coarse', hover: 'unavailable' },\n  { id: 'open-details', label: 'Открыть итог', activation: 'hover-only' },\n);\n\nconsole.log(rejected.reason); // hover-only-blocked
\n

Положительная ветка подтверждает только три вещи: входной контракт корректен, шаги идут в заданном порядке, явное действие принято. Отрицательная ветка важнее для ревью. Она запрещает объявить маршрут принятым, если на выбранном профиле единственный переход зависит от hover. Значение keep-current-path не откатывает приложение само по себе. Оно говорит, что текущий путь пока нельзя заменять этим учебным маршрутом.

\n
\"Схема
Иллюстрация показывает границу контракта. Это схема переходов, а не снимок браузера и не результат теста пользователей.
\n

Симптом → причина → проверка → действие

\n
Рабочая таблица диагностики
СимптомПричинаПроверкаДействие
Команда видна только при наведенииhover владеет переходомнайти DOM-элемент, focus state и visibility rulesдобавить явный button или link
Кнопка находится в последней колонкетаблица требует ширины, которой нетпроверить overflow и порядок данных на узком контейнеревынести основное действие из таблицы, детали оставить отдельно
После раскрытия нет понятного подтвержденияdetails и commit смешаны в одном controlпроверить состояния до и после раскрытияразделить просмотр и необратимое действие
Новый путь вызывает сомнениезамена сделана без безопасной границыописать старый route и условия возвратасохранить current path до отдельной проверки
«Мобильный» тест зелёный, UI не проверенучебную модель приняли за browser testпосмотреть, какие данные реально создаёт тестдобавить отдельную проверку браузера с фиксированными условиями
\n

Таблица нужна для смены уровня разговора. Запись «на телефоне неудобно» не объясняет, что наблюдалось. Запись «в состоянии A переход к details появляется только после hover» уже задаёт проверку. В реальном отчёте добавьте URL или экран, браузер, viewport, масштаб, локаль, способ ввода, начальное состояние и тестовые данные. Не заполняйте неизвестные поля догадками.

\n

Порядок исправления

\n
  1. Зафиксируйте симптом. Назовите одно состояние, в котором основное действие не видно или достижимо только через hover. Отделите наблюдение от предположения о пользователях.
  2. Найдите владельца перехода. Проверьте DOM, состояние компонента, правила видимости, overflow и обработчики. Не меняйте breakpoint, пока не ясно, какой элемент должен вести дальше.
  3. Опишите контракт. Запишите summary, details и confirm. Для каждого шага укажите видимую метку, ожидаемый результат и способ отмены.
  4. Проверьте отрицательный путь. Передайте модели narrow/coarse/unavailable и действие с hover-only. Ожидайте отказ с причиной hover-only-blocked. Если тест принимает путь, контракт ослаблен.
  5. Добавьте явный control. Оставьте контекст перед подтверждением. Не прячьте ошибку, отмену или важные данные в другом hover-меню.
  6. Проверьте реализацию в браузере. Зафиксируйте viewport, zoom, шрифты, локаль, данные, клавиатуру и способ указателя. Проверьте render, focus, keyboard path, раскрытие details и состояние ошибки.
  7. Определите откат. До выпуска сохраните способ вернуть текущий путь: флаг, отдельная ветка, revert или ограниченный rollout. Учебная модель не меняет сервер и не выполняет откат за команду.
\n

Ограничения и критерий готовности

\n

Контракт не выбирает breakpoint и не задаёт универсальный размер зоны нажатия. Он не описывает экранную клавиатуру, поворот экрана, длинные локализованные строки, screen reader, ошибку сети или конкурирующее обновление данных. Он также не доказывает, что человеку понятна метка «Продолжить». Эти вопросы относятся к реализации, доступности и исследованию.

\n

Нельзя объявлять production-эффектом число успешных assertion. В примере нет пользователей, реального устройства, телеметрии и замера времени. Нельзя называть строку narrow конкретным viewport в CSS pixels. Нельзя трактовать hover: none как запрет мыши. Нельзя использовать учебный отказ как автоматическое решение для серверной операции.

\n

Сценарий готов к следующей проверке, если выполнены четыре условия. Для основного действия существует видимый control с предметной меткой. До confirm доступен отдельный summary и путь к details. На заданном учебном профиле hover-only отклоняется, а на реализацию не распространяется обещание usability. В браузере другой инженер может повторить описанное состояние по зафиксированным входам и увидеть тот же порядок: summary → details → confirm.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/193.json b/editorial/agent-rewrites/193.json new file mode 100644 index 0000000..aa84e9e --- /dev/null +++ b/editorial/agent-rewrites/193.json @@ -0,0 +1,7 @@ +{ + "index": 193, + "slug": "editorial-2022-08-field-media-performance", + "title": "Когда маленькая картинка тормозит страницу: как найти причину в media-слоте", + "excerpt": "Изображение может быть маленьким на экране и большим в сети. Разбираем source mapping, геометрию, priority и безопасную проверку одной обратимой правки.", + "contentHtml": "

Карточка товара занимает большую часть сетевого бюджета, хотя её изображение на экране имеет ширину всего 320 пикселей. Hero появляется поздно. При загрузке фильтра соседний текст прыгает вниз. В отчёте кто-то пишет: «картинка тормозит страницу» — и предлагает добавить priority или заменить файл.

\n

Такой диагноз слишком широк. Ошибка может находиться в исходнике, в расчёте размера слота, в разметке, в CSS или в составе первого экрана. Если поменять CDN-вариант, размеры и атрибуты сразу, команда потеряет связь между причиной и результатом. Цена ошибки — лишний трафик, поздний полезный контент и релиз, который невозможно уверенно откатить.

\n

Тезис: медиа оптимизируют не по размеру файла и не по одному атрибуту. Сначала нужно описать конкретный слот, затем проверить его геометрию и выбор ресурса, после этого сопоставить намерение приложения с фактическим браузерным наблюдением. Одна гипотеза требует одной обратимой правки и повторного прогона в тех же условиях.

\n

Механизм: один слот, несколько независимых решений

\n

Media-слот — это не только URL картинки. У него есть содержимое, ожидаемый размер, вариант кадрирования, плотность экрана, момент появления и место в композиции страницы. Эти решения связаны, но не заменяют друг друга.

\n

Сначала браузер получает HTML и видит элемент img. Атрибуты srcset и sizes дают ему набор кандидатов и ожидаемую ширину слота. Браузер сопоставляет эту подсказку с viewport и плотностью экрана, а затем выбирает ресурс. Если sizes описывает слот как 960 пикселей, хотя фактическая колонка занимает 320, браузер может выбрать слишком крупный кандидат. Малый видимый результат не означает малый сетевой расход.

\n

Геометрия решает другую задачу. Атрибуты width и height задают intrinsic ratio изображения. Когда браузер знает соотношение сторон до загрузки, он может заранее зарезервировать место. Это снижает риск скачка соседнего контента. Но наличие атрибутов не доказывает отсутствие layout shift: контейнер, CSS, шрифт и ветка состояния могут изменить фактический layout.

\n

Priority тоже имеет две стороны. Код может считать hero критичным и отдать ему соответствующее намерение. Это не равно доказанному порядку сетевых запросов или paint. На порядок влияют разметка, другие ресурсы, браузер, кеш и состояние страницы. Поэтому строка конфигурации — гипотеза о намерении, а trace или performance-запись — наблюдение в конкретных условиях.

\n

Пример: описать слот до изменения страницы

\n

Ниже — учебный пример разметки. Он показывает контракт компонента: слот имеет размеры, набор кандидатов и текст для доступности. Пример не открывает страницу, не загружает файл и не измеряет LCP или CLS.

\n
<img\n  src=\"/media/product-640.avif\"\n  srcset=\"/media/product-320.avif 320w,\n          /media/product-640.avif 640w,\n          /media/product-960.avif 960w\"\n  sizes=\"(max-width: 600px) 320px, 640px\"\n  width=\"640\"\n  height=\"480\"\n  alt=\"Чёрный рюкзак на белом фоне\"\n/>
\n

В этом примере sizes должен соответствовать реальной ширине слота, а не желаемому размеру самого большого кандидата. Если карточка на узком экране занимает 320 CSS-пикселей, это нужно проверить через фактический layout. Если мобильная композиция показывает другой фрагмент изображения, одного srcset недостаточно: понадобится art direction через picture и отдельные source.

\n

Нельзя объявлять учебный объект доказательством загрузки. Проверка вроде «в данных есть loaded» говорит только о состоянии модели. Она не подтверждает, что браузер получил response, декодировал изображение и нарисовал его. Для этого нужен отдельный прогон страницы и зафиксированные условия.

\n

Симптомы, причины и действия

\n
Диагностика одного media-слота
СимптомВозможная причинаПроверкаДействие
Маленькая картинка скачивает большой файлНеверный sizes, отсутствует подходящий кандидат или CDN всегда отдаёт desktop-вариантСравнить rendered width, выбранный URL, response size и значения srcset/sizesИсправить mapping или подсказку размера; повторить тот же viewport
Контент прыгает после загрузкиНет известного ratio, контейнер меняет размер или CSS переопределяет рамкуПроверить DOM и computed layout до и после загрузки в одном сценарииЗадать корректные размеры или ratio; проверить фактический layout
Hero появляется поздноРесурс конкурирует с другими запросами, находится далеко в разметке или выбран слишком тяжёлый вариантСнять trace с route, viewport, кешем и браузером; найти request и paint-событияИзменить только один слой: source, композицию или загрузочный путь
Атрибут priority не дал ожидаемого эффектаНамерение приложения приняли за гарантию планировщика браузераРазделить config intent и фактический порядок запросов в traceОставить наблюдаемый результат, проверить конкурирующие ресурсы и не добавлять второй флаг без гипотезы
Изображение стало резким, но объект обрезанПоменяли resolution switching вместо art directionСравнить crop на целевом viewport и описание содержимого в altИспользовать отдельный источник для композиции, сохранив fallback
\n

Иллюстрация границы диагностики

\n
\"Диагностическая
Один симптом может иметь разные причины. Сначала выбирается слой проверки, затем меняется один параметр. Если наблюдение не подтвердило гипотезу, возвращается прежнее значение.
\n

Схема полезна как ограничитель области. Поздний hero нельзя автоматически объяснить отсутствием размеров. Скачок layout нельзя исправить только сменой формата. Большой response нельзя объявить проблемой priority, пока не проверены кандидат и фактическая ширина слота.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите один URL, один media-слот, viewport, браузер, состояние кеша и наблюдаемое событие. Формулировка «страница медленная» не подходит.
  2. Опишите контракт. Укажите содержимое, src, кандидаты srcset, sizes, размеры, alt, предполагаемый crop и намерение critical или deferred. Называйте намерение именно намерением.
  3. Проверьте данные и разметку. Убедитесь, что ширина и высота положительны, ratio соответствует изображению, fallback существует, а alt описывает изображение, а не имя файла.
  4. Проверьте браузерный слой. Откройте тот же сценарий и сравните фактический rendered width, выбранный ресурс, response, порядок запросов и момент появления. Учебная fixture не заменяет этот шаг.
  5. Выберите одну гипотезу. Например: «на ширине 320 пикселей выбирается кандидат 960w из-за неверного sizes». Не объединяйте её с изменением CSS и priority.
  6. Внесите обратимую правку. Сохраните прежний source mapping или значение атрибута. Не удаляйте evidence: старое наблюдение нужно для сравнения и отката.
  7. Повторите прогон. Используйте те же route, viewport, браузер и cache state. Если гипотеза не подтвердилась, откатите правку и расширьте проверку, а не наслаивайте следующую оптимизацию.
\n

Ограничения и отрицательный путь

\n

Размеры изображения помогают резервировать место, но не исправляют layout, который меняется из-за шрифта, рекламы, пользовательского состояния или позднего CSS. srcset и sizes уменьшают лишний download только при корректном описании слота и доступных кандидатах. picture решает art direction, но добавляет варианты, которые нужно проверить на содержимое и fallback.

\n

Не делайте вывод о LCP по времени ответа картинки. LCP — это наблюдение о крупнейшем отрисованном элементе в конкретной загрузке. Запрос мог завершиться раньше paint, а другой элемент мог стать кандидатом. Актуальная спецификация W3C описывает API и его ограничения, но не гарантирует результат конкретной страницы.

\n

Если trace не подтверждает гипотезу, отрицательный путь имеет конкретный вид: вернуть прежнее значение, сохранить условия прогона, отметить гипотезу как неподтверждённую и выбрать следующий слой. Если картинка всё ещё поздняя после уменьшения ресурса, проверяйте композицию страницы и конкурирующие запросы. Если скачок остаётся после добавления размеров, проверяйте реальный контейнер и CSS. Если source стал меньше, но crop ухудшился, откатите source mapping и решайте задачу art direction.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда для одного слота сохранены четыре вещи: воспроизводимый симптом с условиями; проверенная причина или явно отвергнутая гипотеза; одна правка с понятным откатом; повторный результат в том же сценарии. Для source должны быть видны выбранный URL и размер response. Для геометрии — фактическая рамка до и после загрузки. Для priority — разделённые intent и browser observation.

\n

Не называйте работу завершённой по формулировке «стало быстрее» и не добавляйте выдуманный процент. Учебный HTML подтверждает только структуру примера. Production-вывод требует реальной страницы, реального браузерного прогона и сохранённого артефакта наблюдения. Такой критерий не обещает, что медиа больше никогда не станет проблемой. Он делает следующую ошибку обнаружимой и позволяет откатить спорную правку.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/194.json b/editorial/agent-rewrites/194.json new file mode 100644 index 0000000..78cdede --- /dev/null +++ b/editorial/agent-rewrites/194.json @@ -0,0 +1,7 @@ +{ + "index": 194, + "slug": "editorial-2022-08-mechanism-media-performance", + "title": "Медиа без ложных метрик: как отделить контракт компонента от наблюдения браузера", + "excerpt": "Изображение может иметь правильный размер в данных и всё равно поздно попасть на экран. Разбираем слои media slot, границы LCP-наблюдения, учебный контракт и обратимую проверку.", + "contentHtml": "

Карточка товара занимает большую часть трафика, хотя изображение на экране выглядит маленьким. В другом случае hero получает width и height, но первый экран всё равно меняет геометрию. Команда видит слово loaded, добавляет preload или меняет priority и ждёт улучшения. Через неделю никто не может сказать, что именно изменило результат: источник, разметка, CSS, очередь загрузки или условия замера. Цена ошибки — лишний трафик, поздний главный контент и релиз, который сложнее откатить, чем проверить.

\n

Тезис статьи простой: контракт media slot и наблюдение браузера — разные факты. Компонент может объявить источник, альтернативный текст и ожидаемую геометрию. Страница может выразить намерение загрузить slot раньше или позже. Браузер сам решает, когда получить ресурс, декодировать его, разместить и нарисовать. Только отдельный прогон страницы показывает, что произошло в конкретной среде. Если склеить эти слои одним флагом, локальная проверка начнёт выдавать обещание о поведении браузера.

\n

Механизм: пять слоёв одного media slot

\n

Начните с одного slot, например product-card/main-image. У него есть пять независимых вопросов. Какой ресурс описывает разметка? Какую рамку обещают данные? Считает ли приложение slot важным для первого экрана? На каком этапе локальная модель разрешает переход? Что реально увидел браузер при заданных URL, viewport и состоянии кэша?

\n
Слои контракта и граница доказательства
СлойВладелецПроверяемый фактЧего он не доказывает
MarkupКомпонентЕсть src и alt для slotРесурс уже запрошен или нарисован
GeometryДанные и layout contractШирина и высота положительныРеальный CSS box и отсутствие сдвига
Load intentКомпозиция страницыSlot помечен как critical или deferred candidateФактический приоритет scheduler
Declared transitionУчебная модельЛокальный порядок load → decodeСетевой ответ и API декодирования изображения
External observationБраузерный прогонЕсть запись с условиями и значениемПричина результата без анализа страницы и trace
\n

Таблица нужна не для усложнения названий. Она не даёт assertion одного слоя использовать как доказательство другого. Положительные dimensions защищают вход компонента. Они не равны отсутствию CLS. Label critical-candidate фиксирует решение приложения. Он не сообщает, какой ресурс браузер выбрал первым. Запись LCP принадлежит наблюдению страницы, а не объекту из unit-теста.

\n

Почему loaded недостаточно

\n

Слово loaded часто скрывает несколько переходов. Ресурс можно объявить кандидатом на загрузку. Затем можно получить ответ. После ответа изображение нужно декодировать. Только после этого браузер использует результат в отрисовке, а наблюдатель может сохранить запись. Эти этапы не обязаны завершаться одновременно.

\n

В учебной модели полезно разделить их явно. Она не создаёт DOM, не отправляет запрос, не вызывает PerformanceObserver и не измеряет LCP или CLS. Она проверяет только порядок переходов. Это безопасный пример: он объясняет инвариант и не выдаёт себя за профиль браузера.

\n
const media = {\n  phase: 'planned',\n  intent: 'critical-candidate',\n  geometry: { width: 960, height: 540 },\n};\n\nfunction markLoad(state, token) {\n  if (state.phase !== 'planned' || token !== 'teaching-load-ok') {\n    return { ...state, event: 'load-rejected' };\n  }\n  return { ...state, phase: 'declared-loaded', event: 'load-declared' };\n}\n\nfunction markDecode(state, token) {\n  if (state.phase !== 'declared-loaded' || token !== 'teaching-decode-ok') {\n    return { ...state, event: 'decode-rejected' };\n  }\n  return { ...state, phase: 'declared-decoded', event: 'decode-declared' };\n}\n\nconst loaded = markLoad(media, 'teaching-load-ok');\nconst decoded = markDecode(loaded, 'teaching-decode-ok');
\n

Имена teaching-load-ok и teaching-decode-ok намеренно говорят об ограничении. Это токены локальной модели. В реальном коде их заменят события, Promise или адаптер платформы. Нельзя вызвать эту функцию вместо загрузки картинки и затем утверждать, что браузер показал изображение.

\n

Та же граница относится к геометрии. Проверка width > 0 полезна, когда данные приходят из CMS или API. Она ловит пустое значение до рендера. Но она не видит aspect-ratio, grid, container query, смену шрифта и динамический контент. Поэтому честное имя проверки — geometryDeclared, а не noLayoutShift.

\n
\"Схема
Слои media slot нельзя свести к одному флагу. Схема показывает границу между данными компонента, намерением страницы и наблюдением браузера.
\n

Как наблюдать реальную страницу

\n

Когда нужен LCP, запускайте наблюдение в странице, а не в объекте контракта. Следующий код — пример регистрации наблюдателя. Он показывает, откуда берётся запись, но сам по себе не даёт результата для вашего URL. Для корректного вывода нужно сохранить маршрут, viewport, браузер, состояние кэша и момент завершения сценария.

\n
const entries = [];\n\nconst observer = new PerformanceObserver((list) => {\n  entries.push(...list.getEntries());\n});\n\nobserver.observe({\n  type: 'largest-contentful-paint',\n  buffered: true,\n});\n\n// В конце сценария сохраняют последний кандидат\n// вместе с URL, viewport и состоянием кэша.
\n

Запись наблюдателя отвечает на вопрос «какой кандидат и в какой момент был замечен». Она не отвечает на вопрос «почему он пришёл поздно». Причину ищут по цепочке: время ответа, выбор варианта, момент обнаружения ресурса, декодирование, блокирующие ресурсы и layout. Не называйте число LCP результатом оптимизации, пока не определены условия сравнения и не повторён тот же сценарий.

\n

Намерение страницы тоже нужно проверять отдельно. Если hero помечен critical, это полезная подсказка для архитектуры и code review. Но browser scheduler учитывает документ, сеть, кэш, тип ресурса и конкурентов. Строка в конфигурации не заменяет trace. Если наблюдение расходится с intent, сначала зафиксируйте расхождение, затем меняйте один слой.

\n

Симптом → причина → проверка → действие

\n
Карта диагностики медиа и ложных performance-выводов
СимптомПричинаПроверкаДействие
Маленькая картинка скачивает большой файлSlot получил неподходящий source или variantСопоставить slot, URL, размер ответа и выбранный форматИсправить mapping и повторить тот же сценарий
Карточка сдвигает соседний контентГеометрия отсутствует или меняется после рендераПроверить dimensions в данных и фактический DOM/layoutЗадать устойчивую рамку; отдельно проверить layout shift
Hero объявлен critical, но поздно появляетсяIntent принят за факт приоритетаСохранить trace с route, viewport и cache stateПроверять resource discovery и конкурентов, а не только label
Unit-тест говорит «медиа готово»Локальный переход назван браузерным результатомПроверить, создаются ли DOM, сеть и observerПереименовать assertion и добавить отдельный page-level прогон
После правки нет сравнимого эффектаОдновременно изменены source, CSS и priorityСравнить diff и условия запускаОткатить лишние изменения и повторить одну гипотезу
\n

Порядок действий

\n
  1. Запишите симптом. Выберите один URL, один media slot и один наблюдаемый дефект: поздний hero, лишний объём или сдвиг layout.
  2. Отделите намерение от факта. Выпишите ожидаемые source, dimensions и intent. Рядом укажите, что действительно видно в браузерном прогоне.
  3. Проверьте контракт. Убедитесь, что markup содержит корректный src и alt, geometry положительна, а названия assertion не обещают LCP или CLS.
  4. Проверьте отрицательный путь. Удалите dimensions, подставьте старый ответ, выберите неверный variant или воспроизведите позднее появление. Система должна отказать явно, а не показать ложную готовность.
  5. Снимите наблюдение. Зафиксируйте browser/version, route, viewport, cache state, сетевые условия и trace. Не смешивайте данные разных сценариев.
  6. Измените один слой. Выберите source mapping, geometry, композицию страницы или intent. Сохраните прежнее значение для отката.
  7. Повторите тот же прогон. Сравнивайте одинаковые условия. Если гипотеза не подтверждена, верните узкую правку и расширьте проверку.
  8. Оставьте критерий. Запишите, какой факт считается исправлением и какой отрицательный путь обязан оставаться безопасным.
\n

Ограничения

\n

Эта модель не описывает все способы доставки медиа. За её пределами остаются srcset и sizes, CSS background, poster видео, lazy loading, preload, CDN-переговоры, кэш, ошибки декодирования, cross-origin timing, EXIF orientation и доступность. Каждый новый слой требует собственного входа и собственной проверки.

\n

Положительная geometry не гарантирует отсутствие layout shift. Правильный source не гарантирует лучший LCP. Наблюдение LCP не объясняет всю воспринимаемую скорость и не заменяет полевые данные. Учебный код не доказывает, что конкретная страница стала быстрее. Без реального прогона нельзя сообщать проценты, секунды или изменение конверсии.

\n

Отрицательный путь тоже предметен. Если источник недоступен, нужен согласованный fallback. Если dimensions не пришли, компонент должен выбрать безопасную рамку или явно показать ошибку. Если браузер не поддерживает нужный тип наблюдения, отчёт должен отметить отсутствие данных. Не подменяйте неизвестность нулём и не называйте пропущенное наблюдение успешным.

\n

Проверяемый критерий готовности

\n

Изменение готово, если для выбранного slot сохранены контракт и browser report. Контракт отдельно подтверждает markup, положительную geometry и намерение страницы. Report содержит одинаковые условия запуска и показывает наблюдаемый результат. Неверный порядок переходов, отсутствующая геометрия и недоступный source не приводят к ложному состоянию «готово». Изменён один слой, его эффект можно сравнить, а прежний mapping можно вернуть без переписывания остальных слоёв.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/195.json b/editorial/agent-rewrites/195.json new file mode 100644 index 0000000..e862a64 --- /dev/null +++ b/editorial/agent-rewrites/195.json @@ -0,0 +1,7 @@ +{ + "index": 195, + "slug": "editorial-2022-08-practice-media-performance", + "title": "Медиа в первом экране: разделите разметку, геометрию и измерение", + "excerpt": "Поздний hero и скачок карточки часто начинаются с одной ошибки: команда смешивает описание ресурса, резервирование места и браузерное наблюдение. Разбираем контракт media slot, отрицательный путь и проверяемый критерий готовности.", + "contentHtml": "

У большого изображения в первом экране обычно видны три симптома: hero появляется поздно, карточка прыгает после загрузки или браузер выбирает не тот ресурс. Цена ошибки выше одной медленной картинки. Пользователь видит пустое место или меняющийся контент. Команда меняет loading, размеры и приоритет одновременно, а затем не может понять, что сработало.

\n

Тезис простой: медиа-слот нужно проверять как несколько независимых контрактов. Разметка описывает ресурс. Геометрия резервирует место. Загрузка и декодирование относятся к пути платформы. LCP и layout shift появляются только в наблюдаемом браузерном сценарии. Если свести всё к флагу loaded, тест легко выдаст правильный ответ на неправильный вопрос.

\n

Сначала отделите симптом от причины

\n

Возьмём карточку товара. В данных есть изображение размером 2400×1600, а компонент показывает его в области 320×213. Ниже по странице лежат ещё двадцать таких карточек. Пользователь открывает страницу на телефоне. Сеть получает тяжёлый файл, контейнер сначала не имеет высоты, а после ответа соседний текст сдвигается.

\n

Эта ситуация содержит как минимум три разные задачи. Нужно выбрать источник, подходящий слоту. Нужно объявить ожидаемую геометрию. Нужно проверить, что произошло в реальной странице. Уменьшение файла не исправит отсутствие высоты. width и height не докажут, что картинка стала кандидатом LCP. Учебный unit-тест не покажет, что layout shift исчез.

\n

Механизм media slot

\n

Для одного изображения зафиксируйте четыре поля.

\n\n

Порядок важен. Без разметки нельзя планировать ресурс. Без геометрии нельзя обещать стабильный контейнер. После загрузки ещё не следует автоматически делать вывод о готовых пикселях. А запись с числом, переданная в локальную модель, не становится измерением браузера.

\n

Учебный пример: контракт, а не браузер

\n

Следующая модель ограничена памятью процесса. Она не создаёт DOM, не открывает сеть, не вызывает decode() и не запускает PerformanceObserver. Её задача — проверить порядок переходов и не дать названию функции обещать больше, чем оно делает.

\n
const model = {\n  markup: null,\n  geometry: null,\n  request: { phase: 'not-planned', intent: null },\n  decode: 'not-requested',\n};\n\nfunction declareMarkup(state, descriptor) {\n  if (!descriptor.src.startsWith('/media/')) {\n    return { ...state, event: 'markup-rejected' };\n  }\n  return { ...state, markup: descriptor, event: 'markup-declared' };\n}\n\nfunction reserveGeometry(state, width, height) {\n  if (!Number.isInteger(width) || !Number.isInteger(height)\n      || width < 1 || height < 1) {\n    return { ...state, event: 'geometry-rejected' };\n  }\n  return { ...state, geometry: { width, height }, event: 'geometry-reserved' };\n}\n\nfunction planLoad(state, intent) {\n  if (!state.markup) return { ...state, event: 'load-blocked-no-markup' };\n  if (!['critical-candidate', 'deferred-candidate'].includes(intent)) {\n    return { ...state, event: 'intent-rejected' };\n  }\n  return { ...state, request: { phase: 'planned', intent }, event: 'load-planned' };\n}\n\nfunction markDecode(state) {\n  if (state.request.phase !== 'declared-loaded') {\n    return { ...state, event: 'decode-rejected-before-load' };\n  }\n  return { ...state, decode: 'declared-decoded', event: 'decode-declared' };\n}
\n

В настоящей реализации переход declared-loaded должен быть связан с платформенным событием или адаптером приложения. В примере этого перехода нет намеренно: он показывает границу модели. Вызов markDecode до загрузки отклоняется. Нулевая геометрия не резервирует место. Неверный источник не попадает в контракт.

\n
\"Схема
Контракт делает границы явными. Схема не является DOM-деревом, browser trace или измерением LCP и CLS.
\n

Симптом → причина → проверка → действие

\n
Диагностика проблем одного media slot
СимптомПричинаПроверкаДействие
Пустое место до heroРазметка или источник появляются поздноПроверить HTML, resource timing и порядок запросовИсправить композицию и сопоставление source-to-slot
Карточка сдвигается после ответаУ контейнера нет устойчивой geometryПроверить dimensions в данных и фактический DOM в выбранном viewportДобавить ratio или положительные width/height; затем проверить страницу
Hero приходит после второстепенных файловIntent приняли за реальный priorityСопоставить resource trace, cache state и конкурирующие запросыУточнить источник и проверить browser-сценарий, а не менять label вслепую
Тест сообщает «готово» слишком раноFixture смешивает load, decode и paintПосмотреть, какой API и какой слой реально вызываетсяРазделить локальные переходы и вынести метрику в отдельный прогон
После ошибки слот исчезаетНет отрицательного пути для недоступного медиаОтключить ресурс или вернуть ошибку загрузкиСохранить geometry, alt и понятный fallback
\n

Почему width и height не закрывают весь вопрос

\n

Атрибуты размеров дают браузеру исходные данные для соотношения сторон. Это полезная часть контракта. Но компонент может обрезать изображение через object-fit, менять рамку в grid или скрывать слот после действия пользователя. Соседний блок может загрузить шрифт и изменить высоту. Поэтому unit-проверка должна говорить geometryReserved, а не noLayoutShift.

\n

Для разных представлений нужны разные размеры. Hero, карточка и миниатюра могут использовать один оригинал, но не одну рамку. Если высота неизвестна, запишите правило fallback. Не переносите размеры исходного файла в UI автоматически. Иначе большой оригинал создаст ложное чувство точности, а маленький слот всё равно будет рассчитан неверно.

\n

Почему intent не равен приоритету

\n

Метка critical-candidate полезна в code review. Она заставляет ответить, почему ресурс нужен до первого действия пользователя. Но это не команда браузеру скачать файл первым. На порядок влияют разметка, другие ресурсы, кеш, соединение, браузер и версия движка. В отчёте разделяйте намерение и факт: «компонент пометил слот критичным» — одно утверждение; «в этом запуске ресурс получил такой путь и такую отрисовку» — другое.

\n

Для отложенного медиа отрицательный путь не исчезает. Отложенный слот всё ещё должен иметь alt, geometry, состояние ошибки и понятное поведение при возвращении в viewport. Если продукту нужен placeholder, его тоже нужно описать. Не прячьте отсутствие ресурса за бесконечным skeleton без условия завершения.

\n

Порядок действий

\n
  1. Запишите один симптом. Укажите URL, slot, viewport и действие пользователя. Формулировка «страница медленная» слишком широка.
  2. Разложите контракт. Найдите markup, geometry, intent и источник observation. Отметьте отсутствующее поле.
  3. Проверьте отрицательный путь. Подайте неизвестный source, нулевую высоту, недоступный ответ и повторный вход в отложенный слот.
  4. Запустите локальную проверку. Убедитесь, что geometry отклоняет невалидные значения, decode не следует до declared load, а fixture не называет себя LCP.
  5. Проведите браузерный прогон. Зафиксируйте браузер, версию, viewport, cache state, сеть, URL и список конкурирующих ресурсов.
  6. Сделайте одну правку. Меняйте geometry, source mapping или композицию. Не меняйте все слои одновременно.
  7. Повторите тот же сценарий. Сравните только наблюдаемые факты. Если результат не подтверждает гипотезу, верните одну правку и обновите причину.
  8. Оставьте доказательство. Сохраните локальный результат, browser report и правило отката рядом с изменением.
\n

Ограничения

\n

Эта схема не моделирует srcset selection, CSS background, video poster, CDN negotiation, preload, lazy loading, кеширование, ошибки декодирования, server rendering и accessibility tree. Она не обещает, что размеры устранили CLS, и не определяет, какой ресурс станет LCP.

\n

Текущий LCP остаётся свойством конкретного документа и запуска. Кандидат может измениться до пользовательского ввода, а результат зависит от содержимого viewport и условий загрузки. Поэтому учебное число value: 1 или зелёный PASS не являются production-результатом. Без реального trace нельзя писать «LCP улучшился на N миллисекунд».

\n

Не всякая проблема требует немедленной смены источника. Если hero поздний из-за серверной композиции, новый формат файла не устранит причину. Если layout меняется из-за контейнера, атрибуты изображения не заменят исправление CSS или данных. Контракт помогает выбрать слой, но не принимает решение за продукт.

\n

Проверяемый критерий готовности

\n

Изменение готово, если для одного media slot выполнены все условия: markup содержит ожидаемый ресурс и alt; geometry принимает только валидную рамку; intent объяснён и не выдан за browser priority; отрицательный путь сохраняет понятный fallback; локальная fixture проверяет только свой порядок; браузерный прогон содержит условия и наблюдаемый результат. В отчёте отдельно названы факт, интерпретация и оставшееся ограничение. Если хотя бы один слой подтверждён только словом «работает», задача не готова.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/196.json b/editorial/agent-rewrites/196.json new file mode 100644 index 0000000..c3baa7e --- /dev/null +++ b/editorial/agent-rewrites/196.json @@ -0,0 +1,7 @@ +{ + "index": 196, + "slug": "editorial-2022-07-field-poor-network", + "title": "Плохая сеть: как сохранить черновик и не перепутать результат запроса", + "excerpt": "При обрыве сети интерфейс не знает, дошла ли операция до сервера. Разделяем черновик, попытку и подтверждение, чтобы не потерять текст и не создать опасный повтор.", + "contentHtml": "

Пользователь нажимает «Сохранить», но ответ не приходит. Кнопка остаётся активной, поэтому он нажимает ещё раз. Через минуту появляется ошибка, а после обновления страницы новый текст исчезает. Иногда сервер всё же принял первую попытку, и повтор создаёт вторую операцию. Цена ошибки — потерянный черновик, дубль действия и спор с пользователем, которому интерфейс показал неверный результат.

\\n

Тезис: плохая сеть требует не одного большего timeout, а явного контракта состояния. Интерфейс должен отдельно хранить версию черновика, логическую операцию и конкретную попытку доставки. Отсутствие ответа означает только «результат не подтверждён этим клиентом». Это не доказательство, что сервер ничего не сделал.

\\n

Что именно ломается

\\n

Обычная форма часто сводит всё к двум флагам: isLoading и isSuccess. Такая модель скрывает важное различие. Пользовательский текст может быть сохранён локально, запрос может быть отправлен, ответ может потеряться, а новый текст может появиться до прихода старого ответа. Один boolean не связывает эти события.

\\n

Нужно разделить три объекта. draftVersion обозначает содержимое, которое человек сейчас видит. requestKey обозначает одну логическую операцию, например «сохранить этот черновик». attemptKey обозначает конкретную передачу этой операции. При retry логическая операция может остаться прежней, а попытка должна измениться. Ответ обязан содержать или позволять сопоставить версию и операцию. Иначе поздний ответ может закрыть форму или очистить уже новый текст.

\\n
type SubmitState = {\\n  phase: 'idle' | 'awaiting-ack' | 'unknown' | 'acknowledged';\\n  draftVersion: number;\\n  requestKey: string | null;\\n  attemptKey: string | null;\\n  acknowledgedVersion: number | null;\\n};\\n\\nfunction acceptAck(state, ack) {\\n  if (ack.requestKey !== state.requestKey) return state;\\n  if (ack.draftVersion !== state.draftVersion) return state;\\n  return { ...state, phase: 'acknowledged', acknowledgedVersion: ack.draftVersion };\\n}
\\n

Код — учебный пример. Он не выполняет HTTP-запрос, не создаёт idempotency key на сервере и не заменяет интеграционный тест. Его задача — показать отрицательный путь: неподходящий ответ не меняет состояние. В рабочем приложении правила сопоставления должны совпадать с API-контрактом, а не жить только в компоненте.

\\n

Симптом → причина → проверка → действие

\\n
Диагностика формы при неустойчивом соединении
СимптомПричинаПроверкаДействие
После клика нет ответаUI смешал ожидание и неизвестный результатПроверить phase, requestKey, attemptKey и журнал ответаПоказать «результат неизвестен» и оставить черновик
Кнопка допускает второй кликНет ограничения повторной попыткиСравнить число отправок с числом логических операцийЗаблокировать обычный повтор до явного решения
Старый ответ очищает новый текстОтвет проверяют только по времени приходаСопоставить draftVersion и requestKey перед commitИгнорировать stale response
Ошибка говорит «не сохранено»Transport error выдали за domain resultУстановить, был ли application acknowledgementРазделить «не подтверждено» и «отклонено»
Черновик пропадает после offlineЛокальная копия создаётся после отправкиПроверить момент persistence и ошибку хранилищаСохранять до отправки и показывать сбой persistence отдельно
\\n

Почему timeout не решает проблему

\\n

Timeout ограничивает время ожидания клиента. Он не отменяет уже принятую сервером операцию и не сообщает, обработал ли её worker. Если клиент получил timeout, у него есть факт об отсутствии ответа в заданное время. У него нет факта о результате предметной операции.

\\n

Поэтому увеличивать timeout можно только после проверки причин. Длинное ожидание может ухудшить UX и увеличить число повторных кликов. Короткое ожидание может быстрее перевести форму в unknown, но это полезно только при сохранённом черновике и понятном маршруте восстановления. Transport-level ошибка и application-level отказ также различаются. Например, HTTP-ответ с ошибкой валидации сообщает, что сервер обработал запрос и отклонил данные. Обрыв соединения этого не сообщает.

\\n

HTTP-метод тоже не даёт полного решения. Идемпотентность метода — свойство семантики HTTP, но дубль предметной операции зависит от API. Если endpoint создаёт платёж, заказ или запись, серверу может понадобиться собственный ключ идемпотентности и политика хранения результата. Строка attempt-02 в клиентской модели не становится такой защитой автоматически.

\\n
\"Схема
Схема показывает состояния пользовательского интерфейса. Она не является сетевой трассировкой и не доказывает, что конкретный сервер принял запрос.
\\n

Учебный сценарий

\\n

Рассмотрим форму с текстом «Вернуть черновик без потери». До отправки приложение записывает локальную копию с версией 7. Затем создаёт логическую операцию request-7 и попытку attempt-1. Соединение обрывается после отправки. Клиент переводит форму в unknown, не удаляет локальную копию и показывает два действия: проверить результат или повторить по правилам API.

\\n

Если пользователь изменил текст, версия становится 8. Поздний ответ для версии 7 больше не может очистить экран: reducer проверяет версию перед commit. Это важнее самого порядка событий. В распределённой системе ответ старой попытки может прийти после нового ввода даже при нормальном соединении.

\\n

Если API поддерживает безопасный повтор, клиент отправляет ту же логическую операцию с новой попыткой и тем же согласованным ключом операции. Если API не описывает такую гарантию, UI не должен обещать «повторить безопасно». Он может сохранить черновик, предложить открыть историю или передать проверку оператору. Отрицательный путь — не дефект текста кнопки. Это часть контракта.

\\n

Порядок действий

\\n
  1. Запишите наблюдение. Зафиксируйте, что видит пользователь: нет ответа, ошибка, дубль, исчезновение текста или поздний успех.
  2. Сохраните вход. Перед отправкой сохраните текущий draft и его версию. Отдельно обработайте ошибку локального хранилища.
  3. Назовите операцию. Создайте request key, связанный с предметным действием, и не меняйте его при обычном retry.
  4. Назовите попытку. Для каждой передачи создайте attempt key и запишите время, версию, endpoint и итог наблюдения.
  5. Разделите фазы. Используйте как минимум ожидание подтверждения, неизвестный результат, подтверждение и отказ. Не называйте неизвестное состояние успехом или неуспехом.
  6. Защитите commit. Перед применением ответа сравните ключ операции и версию черновика. Старый ответ не должен менять новую форму.
  7. Проверьте retry. Уточните у API-владельца, допускает ли операция повтор, каким ключом он защищён и сколько попыток разрешено.
  8. Проверьте реальный экран. Пройдите offline, медленное соединение, обрыв после отправки, поздний ответ и изменение текста во время ожидания.
  9. Сохраните отрицательный результат. Если подтверждение нельзя получить, оставьте черновик и объясните, что именно нужно проверить. Не удаляйте данные ради чистого UI.
\\n

Что проверять в коде и логах

\\n

В клиентском логе каждая отправка должна иметь request key, attempt key, draft version и phase. Логируйте переходы, а не только exception. Полезная последовательность выглядит так: draft-v7 → awaiting-ack → unknown → retry-attempt-2 → acknowledged-v7. Если вместо неё видна только строка «save failed», расследование снова начнётся с догадки.

\\n

В серверном логе нужен тот же контекст или явное правило корреляции. Проверьте, что обработчик отличает повтор того же ключа от новой операции. Убедитесь, что ответ содержит версию или другую проверяемую связь с payload. Если backend не возвращает такой контекст, frontend не сможет надёжно защититься от каждого stale response. Это ограничение нужно записать, а не скрывать дополнительным timeout.

\\n

Для локального теста достаточно нескольких переходов. Ввод версии 1 сохраняет копию. Offline блокирует новую попытку. Отсутствие acknowledgement переводит форму в unknown. Retry не стирает копию. Ответ версии 1 после ввода версии 2 не меняет текст. Эти тесты проверяют state machine. Они не доказывают качество реальной сети, работу браузера или доступность сервера.

\\n

Ограничения и безопасный отрицательный путь

\\n

Статья не выбирает конкретный HTTP-метод, не проектирует очередь, не обещает фоновой доставки и не вводит серверную идемпотентность. Service worker, IndexedDB, фоновые sync-механизмы и локальное шифрование имеют собственные жизненные циклы и ошибки. Их нельзя добавить как синоним надёжности. Сначала нужен контракт результата, затем конкретная реализация persistence или retry.

\\n

Модель также не решает конфликт двух вкладок, вход с другого устройства, истёкшую авторизацию, изменение схемы API и ручное исправление уже созданной операции. Если эти случаи возможны, состояние unknown должно вести к отдельному процессу проверки. Нельзя автоматически повторять перевод, заказ или платёж только потому, что UI не увидел ответ.

\\n

Если локальное сохранение не удалось, безопасный путь отличается от сетевого. Не показывайте «сохранено на устройстве». Оставьте текст в памяти только до понятного предупреждения, предложите копирование или остановите отправку. Если подтверждение сервера неизвестно, не называйте операцию отменённой. Сначала сохраните данные пользователя и покажите границу знания системы.

\\n

Проверяемый критерий готовности

\\n

Форма готова к следующему инженерному шагу, когда для одного сценария можно ответить на пять вопросов: какая версия текста отправлена, какая логическая операция выполняется, какая попытка дала ответ, что именно подтверждает сервер и что увидит человек при отсутствии подтверждения.

\\n

Проверка считается пройденной, если тест с поздним ответом не меняет новый черновик, offline не удаляет сохранённую копию, неизвестный результат не становится успехом, а повтор использует согласованный с API механизм. Другой инженер должен воспроизвести эти переходы по логам и тесту без устного пояснения. Если хотя бы один ключ или отрицательный путь не назван, форму нельзя считать защищённой от плохой сети.

\\n

Проверяемые источники

\\n" +} diff --git a/editorial/agent-rewrites/197.json b/editorial/agent-rewrites/197.json new file mode 100644 index 0000000..e9a60f7 --- /dev/null +++ b/editorial/agent-rewrites/197.json @@ -0,0 +1 @@ +{"index":197,"slug":"editorial-2022-07-mechanism-poor-network","title":"Плохая сеть: как не потерять черновик после повтора","excerpt":"Запрос может уйти, а подтверждение — не прийти. Разбираем неизвестный исход, разделяем версию черновика, логическую операцию и попытку, а затем проверяем поздние ответы и безопасный повтор.","contentHtml":"

Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, но экран не знает, дошёл ли запрос до сервера. Человек нажимает ещё раз. Пока второй запрос ждёт ответа, приходит первый. Если обработчик связывает ответ только с формой, он может закрыть редактор, очистить новый текст или показать успех для операции, которую никто не подтвердил. Цена ошибки — потерянная работа, дублирующее изменение на сервере и расследование по одному скриншоту без доказательств.

\n

Плохая сеть не означает только медленный канал. Запрос может завершиться на сервере, но ответ потеряется. Ответ может прийти позже нового ввода. Браузер может сообщить об ошибке транспорта, хотя предметная операция уже изменила данные. Поэтому «нет ответа» не равно «операция не выполнена».

\n

Тезис статьи: интерфейс должен хранить отдельно версию черновика, логическую операцию и конкретную попытку. Состояние unknown-outcome должно быть видимым. Повтор разрешается только по явному правилу. Поздний ответ меняет экран только после проверки ключей и версии. Этот механизм защищает локальный текст; он не доказывает результат на сервере без согласованного API.

\n

Сначала разделите четыре состояния

\n

draft.version — версия текста, которую редактирует пользователь. Она меняется после содержательного ввода. Если пользователь дописал абзац, текущая версия уже не та, которую отправила первая попытка.

\n

requestKey — идентификатор одной логической операции. Он отвечает на вопрос «что именно пользователь хочет завершить?». При разрешённом повторе request key сохраняется. Если создать новый ключ при каждом клике, сервер не сможет распознать повтор, если его контракт поддерживает идемпотентность.

\n

attemptKey — идентификатор запуска. Он отвечает на вопрос «какой ответ сейчас ожидает интерфейс?». Первая попытка получает attempt-01, разрешённый повтор — attempt-02. Старый ответ с первым ключом не должен коммититься в состояние, которое ждёт второй.

\n

acknowledgement — сообщение, которое API определило как подтверждение операции. Завершение локального обработчика, исчезновение спиннера и статус «запрос отправлен» подтверждением не являются. В payload нужны данные, по которым клиент проверит request key, attempt key и принятую версию.

\n
Границы состояния в учебном контракте
СущностьКогда меняетсяЧто защищаетЧего не доказывает
draft.versionПри изменении текстаНовый текст от очистки старым ответомЧто текст принят сервером
requestKeyПри создании новой логической операцииСвязь повтора с исходным намерениемЧто сервер умеет дедупликацию
attemptKeyПри каждой разрешённой попыткеПоздний ответ другой попыткиЧто запрос дошёл до сервера
acknowledgementПосле ответа, прошедшего проверкиПереход в acknowledgedЧто последующий ввод тоже сохранён
\n

Почему одного isPending недостаточно

\n

Флаг isPending описывает только наличие ожидания. Он не говорит, какая версия текста отправлена и какой ответ ещё допустим. После timeout флаг обычно сбрасывают. Если запрос всё ещё выполняется, его поздний ответ получает возможность изменить уже новое состояние.

\n

Кнопка disabled тоже не является защитой. Событие могло попасть в очередь до блокировки. Другой обработчик может вызвать отправку напрямую. Восстановление страницы может загрузить старое pending-состояние. Правило нужно разместить на переходе состояния, а не только в визуальном элементе.

\n

Удобная минимальная машина имеет состояния idle, awaiting-ack, unknown-outcome, acknowledged и recovery-required. В awaiting-ack второй запуск блокируется. В unknown-outcome пользователь видит, что результат не установлен. Переход в acknowledged разрешает только проверенный acknowledgement. Переход в recovery-required сохраняет черновик и предлагает сверить результат, если повтор небезопасен.

\n

Учебный пример с поздним ответом

\n

Ниже не сетевой клиент и не production-код. Это компактная модель переходов. Она показывает только условие, при котором ответ может изменить локальное состояние.

\n
const state = {\n  phase: 'awaiting-ack',\n  draftVersion: 2,\n  requestKey: 'request-17',\n  attemptKey: 'attempt-02',\n  submittedVersion: 1,\n};\n\nfunction acceptAck(current, ack) {\n  if (ack.requestKey !== current.requestKey) {\n    return { ...current, phase: 'stale-ack' };\n  }\n  if (ack.attemptKey !== current.attemptKey) {\n    return { ...current, phase: 'stale-ack' };\n  }\n  if (ack.acceptedVersion !== current.submittedVersion) {\n    return { ...current, phase: 'invalid-ack' };\n  }\n\n  return {\n    ...current,\n    phase: current.draftVersion === ack.acceptedVersion\n      ? 'acknowledged'\n      : 'acknowledged-newer-draft-retained',\n  };\n}\n\nconst lateAck = {\n  requestKey: 'request-17',\n  attemptKey: 'attempt-01',\n  acceptedVersion: 1,\n};\n\nconst next = acceptAck(state, lateAck);\n// next.phase === 'stale-ack'; draft version 2 не меняется
\n

В примере первый ответ пришёл после второго запуска. Его request key совпадает, но attempt key устарел. Функция не очищает черновик и не показывает успех. Если позже придёт acknowledgement для attempt-02, он подтвердит только версию 1. Версия 2 останется черновиком. Это важная граница: подтверждение старой отправки не подтверждает последующий ввод.

\n

В реальном приложении состояние может лежать в reducer, store или другом слое. Названия не важны. Важно, чтобы каждый commit проверял владельца ответа и accepted version в одном месте. Не полагайтесь на порядок прихода событий.

\n
\"Схема
Иллюстрация показывает учебный контракт: новый текст сохраняет свою версию, повтор использует тот же request key и новый attempt key, а устаревший ответ не меняет текущий черновик.
\n

Неизвестный исход и повтор

\n

После transport error интерфейс знает только, что не получил ожидаемый ответ. Он не знает, отменил ли сервер операцию. Поэтому безопасный экран не подменяет неизвестный исход ошибкой без оговорки. Он оставляет черновик, показывает состояние проверки и выбирает действие по цене повтора.

\n

Для обычного текста продукт может разрешить один повтор. Он использует прежний request key и новый attempt key. Сервер должен понимать этот контракт, если повтор должен быть идемпотентным. Для финансового или иного необратимого действия повтор может быть запрещён. Тогда интерфейс переводит пользователя в recovery-required и даёт проверить результат через отдельный статусный путь.

\n

Бесконечный retry опасен. Он создаёт нагрузку, дублирует внешние эффекты и скрывает неизвестный результат за серией одинаковых попыток. Лимит, задержка, ручное подтверждение и способ сверки должны быть частью предметного контракта, а не случайным числом в обработчике.

\n

Симптом → причина → проверка → действие

\n
Диагностическая таблица для плохой сети
СимптомПричинаПроверкаДействие
Новый текст исчез после старого ответаОтвет не связан с attempt key или versionСравнить ключи и accepted version перед очисткой draftОтклонять stale ack и хранить новый draft отдельно
Два клика создали две операцииRetry создаёт новый request keyСопоставить ключи в запросах и на стороне APIРазделить logical request и attempt; согласовать дедупликацию
UI показывает успех сразу после кликаЗавершение handler-а принято за acknowledgementНайти место, где phase меняется на successРазрешать success только после проверки payload
После timeout пользователь повторяет необратимое действиеНет состояния unknown-outcome и recovery-путиСмоделировать потерю ответа после отправкиПоказать неизвестный исход и дать сверить результат
Старый ответ закрывает формуОбработчик смотрит только на форму, а не на попыткуЗаписать draft version, request key и attempt key каждого событияПроверять владельца ответа до каждого перехода
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите видимый текст, действие пользователя и цену ошибки. Не называйте проблему «offline», пока не отделили ошибку транспорта от неизвестного результата.
  2. Назовите версии. Выведите безопасные surrogate-значения для draft version, request key и attempt key. Не записывайте bearer-токены и чувствительный текст.
  3. Разделите переходы. Найдите места, где код создаёт попытку, очищает draft, показывает success и разрешает retry. Для каждого перехода укажите владельца.
  4. Проверьте отрицательный путь. Запустите сценарий: первая попытка без acknowledgement, новый ввод, повтор с новым attempt key, поздний ответ первой попытки. Ожидайте stale-результат без изменения нового draft.
  5. Проверьте положительный путь. Передайте acknowledgement с текущими request key, attempt key и accepted version. Убедитесь, что только он переводит состояние в acknowledged.
  6. Выберите политику повтора. Для каждой операции укажите лимит, сохранение request key, новый attempt key и действие при unknown-outcome. Для необратимого эффекта добавьте отдельную сверку.
  7. Согласуйте API. Определите, как сервер распознаёт повтор, что возвращает acknowledgement и как клиент получает статус операции после потерянного ответа.
  8. Проверьте реальную среду. Проведите отдельный browser/network-сценарий с указанными версиями браузера, приложения, API и способом потери ответа. Учебный код не заменяет этот результат.
  9. Оставьте наблюдение. Измеряйте долю unknown-outcome, stale acknowledgement, duplicate attempt и recovery-required. Значения ключей не должны раскрывать секреты.
\n

Service Worker не отменяет контракт

\n

Фоновый worker может помочь с очередью или доставкой, но он не превращает неизвестный исход в подтверждённый. У Service Workers есть событийный жизненный цикл. User agent может остановить worker, когда нет события или когда выполнение нарушает ограничения. Поэтому нельзя обещать пользователю «фоновая часть точно завершит сохранение», если приложение не хранит очередь, не описывает acknowledgement и не умеет восстановить состояние.

\n

Если worker участвует в повторе, добавьте к контракту владельца очереди, срок хранения, версию черновика, правило дедупликации и сообщение для открытой страницы. Проверяйте обновление worker, перезагрузку и несколько вкладок отдельно. Worker в этой модели не запускается; модель не содержит реального сетевого прогона.

\n

Ограничения и критерий готовности

\n

Модель не решает конфликт двух вкладок, авторизацию, обновление токена, распределённое хранилище, шифрование локального текста, CRDT и серверную транзакцию. Она не определяет, какой HTTP-метод или заголовок должен использовать конкретный API. Идемпотентность метода не гарантирует идемпотентность вашей предметной операции. Это надо доказать контрактом сервиса.

\n

Учебный код ограничен памятью процесса. Он не создаёт HTTP-запрос, не имитирует задержку, не проверяет браузер и не заявляет production-результат. Названия requestKey, attemptKey и фаз — проектные значения. Их можно заменить, но нельзя убрать саму проверку связи ответа с операцией и версией.

\n

Критерий готовности закрыт, если команда может показать пять вещей: после потерянного ответа черновик остаётся доступным; повтор не создаёт новую логическую операцию без явного решения; поздний ответ старой попытки не меняет новую версию; acknowledgement старой версии не очищает новый текст; для необратимого эффекта существует отдельная проверка результата. Эти утверждения должны подтверждаться тестом или документированным сценарием с условиями. Один зелёный экран и исчезнувшая кнопка ожидания не являются доказательством.

\n

Проверяемые источники

\n"} diff --git a/editorial/agent-rewrites/198.json b/editorial/agent-rewrites/198.json new file mode 100644 index 0000000..034c43f --- /dev/null +++ b/editorial/agent-rewrites/198.json @@ -0,0 +1,7 @@ +{ + "index": 198, + "slug": "editorial-2022-07-practice-poor-network", + "title": "Плохая сеть: как не объявить неизвестный результат успехом", + "excerpt": "Практический контракт для формы, которая сохраняет черновик, различает попытку и подтверждение и безопасно переживает потерю ответа.", + "contentHtml": "

Пользователь нажимает «Сохранить». Кнопка перестаёт крутиться, экран показывает зелёное сообщение, а соединение в этот момент уже потеряло ответ. При следующем открытии формы текст пропадает. При повторном нажатии приложение может создать вторую операцию. Это не косметический дефект интерфейса. Человек теряет работу, а команда получает состояние, которое нельзя объяснить одним HTTP-кодом.

\n

Похожий симптом возникает и без полного offline. Запрос ушёл, сервер мог его принять, но клиент не дождался ответа. Таймаут сообщает только о том, что клиент не получил результат вовремя. Он не доказывает, что предметное действие не произошло. Поэтому цена ошибки появляется на стыке транспорта и бизнес-состояния: приложение превращает «результат неизвестен» в «ошибка» или «успех».

\n

Тезис: успех подтверждает предметная операция, а не кнопка

\n

Интерфейс должен разделять четыре сущности: текущий черновик, логический запрос, отдельную попытку доставки и подтверждение операции. Черновик принадлежит пользователю. Логический запрос описывает одно намерение: сохранить версию 3. Попытка показывает, сколько раз клиент пробовал доставить это намерение. Подтверждение приходит от предметного API и указывает, какую версию оно приняло.

\n

Такое разделение сохраняет отрицательный путь. Если ответ не пришёл, UI не очищает черновик и не показывает success. Он переходит в unknown-outcome, оставляет текст доступным и предлагает проверку или один осознанный повтор. Если позже приходит старый ответ, он не должен стереть новую версию. Поздний payload — это событие, которое нужно классифицировать, а не безусловно применить.

\n

Механизм состояния

\n

Для каждой отправки зафиксируйте draftVersion, requestKey и attemptKey. draftVersion меняется после редактирования. requestKey остаётся одним и тем же для логического сохранения. attemptKey меняется при разрешённом retry. Acknowledgement должен содержать ключ логического запроса и принятую версию. Обработчик сравнивает их с текущим состоянием до очистки черновика.

\n

Ключ запроса не обязан называться именно так. В одном API это может быть operationId, в другом — идемпотency key. Важно назначение: повтор доставки не должен незаметно менять смысл операции. Серверный контракт должен решить, что происходит при повторном ключе. Клиент не может получить идемпотентность из одного только заголовка, если сервер его игнорирует.

\n
function applyAcknowledgement(state, ack) {\n  if (ack.requestKey !== state.requestKey) {\n    return { ...state, event: 'stale-request' };\n  }\n\n  if (ack.acceptedVersion < state.draftVersion) {\n    return { ...state, event: 'newer-draft-retained' };\n  }\n\n  if (state.phase === 'confirmed') {\n    return { ...state, event: 'duplicate-acknowledgement' };\n  }\n\n  return {\n    ...state,\n    phase: 'confirmed',\n    confirmedVersion: ack.acceptedVersion,\n    event: 'confirmed',\n  };\n}
\n

Это учебный JavaScript-пример. Он не вызывает fetch, не имитирует задержку и не доказывает, что сервер принял запрос. Его задача — показать безопасный порядок проверки. Сначала сопоставляются идентификаторы. Потом сравниваются версии. Только после этого можно менять видимое состояние. В production нужно связать этот переход с реальным API, хранилищем и журналом событий.

\n
\"Схема
Схема показывает контракт retry: тот же логический запрос получает новую попытку, а acknowledgement проверяется по ключам и версии. Это иллюстрация состояний, не трасса реального сервиса.
\n

Как читать симптомы

\n

Не начинайте с увеличения таймаута. Сначала назовите наблюдаемый факт. «Пользователь видит успех без подтверждения» полезнее, чем «сеть нестабильна». «Старый ответ очищает новый текст» указывает на гонку версий. «Повтор создаёт две записи» требует проверки серверного контракта, а не только блокировки кнопки.

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Зелёный success без ответа APIUI завершает операцию после окончания локального обработчикаСопоставить место показа success с acknowledgement и ключамиПоказывать ожидание до подтверждения; при отсутствии ответа — unknown outcome
После timeout черновик исчезОчистка выполняется до подтвержденияЗаписать draft version до отправки и после ошибки транспортаОставлять persisted draft до принятия конкретной версии
Двойная запись после повторного кликаRetry создаёт новый логический запрос или сервер не распознаёт ключСравнить request key у попыток и поведение API при повтореСохранить logical key; согласовать идемпотентность на сервере
Старый ответ стирает новый текстОбработчик проверяет факт ответа, но не versionДать первой попытке ответить после редактирования второй версииСчитать старый ack stale и сохранить новый draft
Offline блокирует отправку и удаляет текстСостояние сети связано с очисткой формыПроверить ветку без запуска transportОставить черновик; retry разрешать только после явного online-сигнала
\n

Порядок внедрения

\n
  1. Опишите один сценарий: пользователь сохраняет конкретную версию текста, а не абстрактную форму.
  2. Разделите в модели draft, request, attempt, acknowledgement и фазу UI. Не храните их в одном boolean.
  3. Назначьте стабильный логический ключ и новый ключ каждой попытке. Запишите, как API обрабатывает повтор.
  4. Добавьте ветку без acknowledgement. Она должна сохранить черновик и показать неизвестный результат.
  5. Поставьте проверку ключей и версии перед каждым commit, очисткой формы и переходом в success.
  6. Смоделируйте поздний ответ первой попытки после изменения черновика. Убедитесь, что новый текст остался.
  7. Проведите отдельный browser/network сценарий на контролируемом окружении. Запишите условия, а не выдавайте учебный пример за production-результат.
\n

Что считать подтверждением

\n

HTTP-ответ сам по себе не всегда равен предметному подтверждению. Статус показывает результат протокольного обмена, но прикладное действие может требовать идентификатора операции, принятой версии или чтения состояния после записи. Для простого черновика достаточно ответа с ключом запроса и версией. Для платежа, бронирования или выдачи права нужен более строгий контракт и отдельный путь проверки.

\n

Не путайте индикатор navigator.onLine с подтверждением. Он может подсказать, стоит ли планировать новую попытку, но не сообщает, обработан ли уже отправленный запрос. Service worker тоже не является гарантией доставки. Он может помочь с жизненным циклом фоновой работы, однако приложение всё равно должно описать persistence, повтор и подтверждение.

\n

Ограничения и отрицательный путь

\n

Эта схема не решает конфликт двух вкладок, восстановление после удаления данных, безопасность локального черновика или согласование нескольких устройств. Она не делает неидемпотентный endpoint безопасным. Она также не определяет, что делать с чувствительным текстом после выхода пользователя. Эти решения требуют отдельной политики хранения, авторизации и серверного контракта.

\n

Бесконечный retry не является исправлением. Он может умножить операции, нагрузить API и скрыть неизвестный результат. Если сервер не принимает логический ключ, безопаснее оставить черновик и дать пользователю путь ручной проверки, чем обещать автоматическое восстановление. Если подтверждение пришло для старой версии, нельзя удалять новую работу только потому, что payload формально успешен.

\n

Проверяемый критерий готовности

\n

Изменение готово, если на одном контролируемом сценарии можно показать четыре факта: отсутствие ответа не переводит UI в success; текст сохраняется после неизвестного результата; разрешённый retry сохраняет логический ключ; поздний acknowledgement старой версии не меняет новый черновик. Каждый факт должен быть виден в тесте, журнале переходов или воспроизводимом сценарии с названными условиями. Пока команда может доказать только «кнопка перестала крутиться», контракт не готов.

\n

Проверка должна завершаться не обещанием доступности сети, а наблюдаемым состоянием. Укажите версию черновика, ключ запроса, ключ попытки, phase и причину перехода. Не записывайте в диагностический журнал сам чувствительный текст. Если API не возвращает нужные данные, сначала измените контракт или честно оставьте состояние unknown.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/199.json b/editorial/agent-rewrites/199.json new file mode 100644 index 0000000..47a86ae --- /dev/null +++ b/editorial/agent-rewrites/199.json @@ -0,0 +1,7 @@ +{ + "index": 199, + "slug": "editorial-2022-06-field-form-errors", + "title": "Когда форма показывает старую ошибку: snapshots, retry и безопасный rollback", + "excerpt": "Поздний ответ сервера может вернуть ошибку уже исправленного поля и стереть полезный контекст. Разбираем связь между значениями, попыткой и ответом, а затем проверяем retry, duplicate и локальный rollback.", + "contentHtml": "

Пользователь исправляет email и нажимает «Отправить». Через секунду форма снова показывает ошибку для старого значения. Иногда рядом появляется второй результат: кнопка разрешила два retry, а после успеха старый ответ снова очистил поле. Цена ошибки — не только раздражение. Человек повторяет уже правильное действие, теряет введённые данные и может несколько раз отправить одну операцию.

\n

Корень проблемы обычно не в тексте сообщения и не в одной кнопке. Интерфейс применяет ответ без проверки того, к какой попытке и к каким значениям он относится. Переменная isLoading говорит только о наличии работы. Она не отвечает на три важных вопроса: какой снимок ушёл, активна ли ещё попытка и имеет ли этот ответ право менять текущую форму.

\n

Надёжное правило простое: ответ меняет форму только после проверки attemptId и версии значений. Локальный retry создаёт новую попытку. Локальный rollback возвращает последний принятый снимок, но не отменяет запись на сервере. Эти действия должны иметь разные переходы и разные проверки.

\n

Наблюдаемый сбой и его механизм

\n

Рассмотрим форму с одним полем email. В момент отправки приложение сохраняет не копию ссылки на объект, а неизменяемый снимок: значение, версию поля и идентификатор попытки. Пусть текущий снимок имеет emailVersion = 4, а отправка получает attemptId = 17. Пользователь меняет email. Версия становится 5, но ответ попытки 17 всё ещё несёт версию 4.

\n

Если обработчик сравнивает только имя поля, он применит старую ошибку к новому значению. Если обработчик сравнивает только attemptId, он не увидит, что поле уже изменилось. Нужны обе проверки. Ответ старой попытки с несовпадающей версией получает статус stale-response-ignored и не создаёт сообщение в интерфейсе.

\n

Серверная ошибка и локальная ошибка тоже решают разные задачи. Браузер может остановить submit из-за пустого или неверно записанного email. Сервер может отклонить уже синтаксически правильное значение, например потому, что адрес занят. В первом случае запрос не должен создаваться. Во втором сообщение должно быть связано с конкретным полем и объяснять следующий шаг.

\n
Диагностика ошибки формы
СимптомПричинаПроверкаДействие
Старая ошибка вернулась после editОтвет сопоставили только с именем поляСравнить версию снимка и текущую версиюПометить ответ устаревшим и не менять UI
Два retry ушли подрядХранится только boolean loadingНайти активный attemptId в stateОтклонить второй retry до завершения первой попытки
Ошибка есть, но поле не названоОбщий toast заменил связь с контроломПроверить field, текст и идентификатор сообщенияПоказать ошибку у поля и общий статус отдельно
Успех применился дваждыЗавершённая попытка осталась активнойПовторно передать тот же ответИгнорировать duplicate без изменения принятого снимка
Rollback обещает отмену операцииЛокальный undo смешали с API-компенсациейПроверить, вызывает ли rollback сетевой adapterОграничить его локальными значениями или описать отдельный API
\n

Три состояния, которые нельзя заменять одним флагом

\n

Первое состояние — текущие значения формы. Они меняются при вводе. Второе — активная попытка с собственным идентификатором и снимком версий. Третье — последний принятый снимок. Он нужен, если интерфейс предлагает вернуть локальное состояние после неудачной правки. У каждого состояния свой жизненный цикл.

\n

При submit приложение сначала проверяет локальные ограничения. Если email некорректен, оно показывает ошибку и не создаёт попытку. Если попытка уже активна, retry останавливается. Иначе создаётся новый снимок. Ответ можно применить только при совпадении идентификатора и версий. После успеха попытка закрывается, а её значения становятся последним принятым снимком.

\n

При изменении поля версия увеличивается. Это важнее, чем очистить старый текст через setError(null). Очистка меняет видимый результат, но не создаёт доказательство, что поздний callback больше не подходит. Версия даёт обработчику такое доказательство.

\n
type Snapshot = {\n  values: { email: string };\n  versions: { email: number };\n  attemptId: number;\n};\n\nfunction acceptResponse(state, response) {\n  const active = state.activeAttempt;\n  if (!active || active.attemptId !== response.attemptId) {\n    return { ...state, log: [...state.log, 'duplicate-or-unknown'] };\n  }\n  if (active.snapshot.versions.email !== state.versions.email) {\n    return { ...state, activeAttempt: null, log: [...state.log, 'stale-response-ignored'] };\n  }\n  if (response.ok) {\n    return {\n      ...state,\n      activeAttempt: null,\n      accepted: active.snapshot,\n      error: null\n    };\n  }\n  return {\n    ...state,\n    activeAttempt: null,\n    error: { field: 'email', message: response.message }\n  };\n}
\n

Это учебный фрагмент reducer. Он не выполняет HTTP-запрос, не задаёт формат API и не доказывает exactly-once доставку. Его задача — показать границу, на которой интерфейс решает, может ли ответ изменить локальное состояние. В реальном компоненте ответ адаптера должен попасть в этот же контракт, а не напрямую вызвать очистку поля.

\n

Схема переходов

\n
\"Схема
Ответ проверяет попытку и версию до того, как меняет форму. Rollback возвращает только локальный принятый снимок.
\n

На схеме важна отрицательная ветка. Устаревший ответ не является новой ошибкой пользователя. Он может попасть в технический журнал, но не должен снова показываться в поле. Duplicate тоже не является новым успехом. После закрытия попытки повторный callback не имеет активного адресата.

\n

Пример последовательности

\n

Сначала пользователь вводит неправильный email. Локальная проверка выставляет aria-invalid, связывает сообщение с контролом и блокирует submit. Затем пользователь исправляет значение. Приложение очищает сообщение, увеличивает версию и создаёт попытку 18 со снимком версии 5.

\n

Пока попытка 18 активна, второй клик не создаёт попытку 19. Это не только защита от двойного клика. Новый идентификатор усложнил бы причинную связь: оба ответа могли бы менять одно поле, а порядок callback не обязан совпадать с порядком кликов.

\n

После изменения email версия становится 6. Ответ попытки 18 приходит с версией 5 и получает stale-response-ignored. Пользователь не видит старое сообщение. Если сервер должен проверить новое значение, retry создаёт новую попытку 19 и новый снимок. Успешный ответ закрывает её и сохраняет принятые значения.

\n

Если тот же успешный ответ приходит повторно, обработчик не должен повторно очищать ошибки, запускать переход или менять accepted snapshot. Такой ответ можно учесть в локальном журнале как duplicate. Это проверка идемпотентности reducer, а не гарантия сети.

\n
  1. Зафиксируйте симптом. Выберите один путь: edit после submit, два retry или повторный ответ. Запишите порядок событий и видимый текст.
  2. Снимите входы. Сохраните значения и версии в момент submit. Без этого нельзя доказать, что ответ устарел.
  3. Разделите локальную и серверную проверку. Некорректное поле не создаёт попытку. Серверный отказ приходит только после принятого локального ввода.
  4. Добавьте активную попытку. Храните attemptId и snapshot. Второй retry должен останавливаться до создания нового идентификатора.
  5. Проверяйте ответ в два шага. Сначала сравните attemptId, затем версии. Только после этого применяйте error или success.
  6. Свяжите ошибку с полем. Храните имя поля, понятный текст и следующий шаг. Общий статус формы не заменяет сообщение у контрола.
  7. Отделите rollback. Возвращайте последний локальный accepted snapshot, увеличивайте версии и разрешайте действие один раз. Не называйте это отменой серверной операции.
  8. Проверьте настоящий интерфейс. Пройдите клавиатурой и в целевых браузерах. Отдельно проверьте фокус, объявление сообщения и сетевой adapter.
\n

Ограничения

\n

Эта модель не решает upload, debounce, optimistic update, автоматические повторы, отмену HTTP, несколько вкладок и составные операции. У них есть дополнительные идентификаторы, границы владения и правила конфликтов. Для заказа, платежа или изменения прав локального rollback недостаточно: нужен серверный статус, политика отмены и журнал операции.

\n

Учебный код не моделирует реальную сеть, задержку, браузер, screen reader или production telemetry. Положительный результат его проверок означает только то, что перечисленные переходы reducer соблюдают заданные правила. Он не подтверждает доступность готового UI и не измеряет число ошибок пользователей.

\n

Проверяемый критерий готовности

\n

Изменение готово, если тесты отдельно подтверждают четыре отрицательных перехода: локально неверное значение не создаёт попытку; retry поверх активной попытки не создаёт второй запрос; поздний ответ не меняет исправленное поле; duplicate не меняет принятый снимок. Затем ручная проверка подтверждает, что сообщение связано с полем и не теряется при смене фокуса.

\n

Если хотя бы один переход проверяется только через happy path, причина сбоя остаётся недоказанной. Сначала зафиксируйте контракт состояния. Потом подключайте транспорт и проверяйте его отдельно. Так сообщение формы остаётся следствием актуального ввода, а не случайного порядка callback.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/200.json b/editorial/agent-rewrites/200.json new file mode 100644 index 0000000..c43f6d9 --- /dev/null +++ b/editorial/agent-rewrites/200.json @@ -0,0 +1,7 @@ +{ + "index": 200, + "slug": "editorial-2022-06-mechanism-form-errors", + "title": "Ошибка формы должна относиться к тому вводу, который человек видит", + "excerpt": "Как связать значение поля, попытку отправки и сообщение об ошибке, чтобы поздний ответ сервера не переписал исправленный ввод.", + "contentHtml": "

Человек вводит неправильный email и нажимает «Отправить». Форма отправляет запрос. Пока сервер отвечает, человек исправляет адрес и нажимает кнопку снова. Затем рядом с новым адресом появляется сообщение «Адрес уже занят», хотя сервер проверял старое значение. Визуально форма показывает ошибку. По смыслу она лжёт.

\n

Цена ошибки выше, чем стоимость ещё одного текста под полем. Пользователь может исправить правильный адрес, повторить отправку несколько раз или решить, что сервис потерял данные. Поддержка получает неясный вопрос. Команда видит случайный дефект: он появляется только при определённом порядке ввода и ответов.

\n

Тезис статьи прост: сообщение об ошибке, поле, которое оно описывает, и попытка отправки должны принадлежать одному снимку данных. Нельзя применять ответ только потому, что он пришёл последним или называет то же поле. Надёжная форма хранит attemptId, версии значений и отдельный semantic payload. Старый ответ можно показать в журнале диагностики, но нельзя возвращать его в текущее поле.

\n

Как возникает рассинхронизация

\n

У формы есть как минимум два времени. Первое — время ввода: значение email меняется с каждым редактированием. Второе — время запроса: сервер получает снимок и отвечает позже. Эти часы не обязаны идти в одном порядке. Ответ первой попытки может прийти после ответа второй.

\n

Наивный обработчик часто выглядит так:

\n
function onServerResult(result) {\n  if (result.errors.email) {\n    state.emailError = result.errors.email;\n  }\n}\n\n// Любой поздний ответ может переписать состояние текущего поля.\n
\n

В этом коде нет связи между result и значением, которое сейчас лежит в поле. Имя email совпадает, но снимки могут различаться. Обработчик знает только содержание ответа, а не его право менять состояние.

\n

Вторая ошибка — складывать разные причины в одну строку form.error. Локальная проверка отвечает на вопрос «можно ли отправлять этот ввод». Серверная проверка отвечает на вопрос «принял ли сервер конкретный снимок». Ошибка сети отвечает на вопрос «доставлен ли результат». У этих сигналов разные владельцы и срок жизни.

\n

Минимальный контракт состояния

\n

При каждой правке поля увеличивайте его version. Перед отправкой сохраните неизменяемый снимок значений и версий. Одновременно выдайте попытке новый attemptId. Ответ имеет право изменить форму только тогда, когда совпали оба условия: попытка ещё активна, а версии ответа совпадают с текущими версиями проверенных полей.

\n
const form = {\n  values: { email: 'person@example.test' },\n  versions: { email: 1 },\n  activeAttempt: null,\n  errors: { local: null, server: null },\n};\n\nfunction changeEmail(next) {\n  form.values.email = next;\n  form.versions.email += 1;\n  form.errors.local = validateEmail(next);\n  form.errors.server = null;\n}\n\nfunction beginSubmit() {\n  if (form.errors.local || form.activeAttempt) return null;\n  const attempt = {\n    attemptId: 1,\n    values: { ...form.values },\n    versions: { ...form.versions },\n  };\n  form.activeAttempt = attempt;\n  return attempt;\n}\n\nfunction receiveServerResult(result) {\n  const active = form.activeAttempt;\n  if (!active || result.attemptId !== active.attemptId) {\n    return { kind: 'unknown-attempt-ignored' };\n  }\n  if (result.versions.email !== form.versions.email) {\n    form.activeAttempt = null;\n    return { kind: 'stale-response-ignored' };\n  }\n  form.activeAttempt = null;\n  form.errors.server = result.errors?.email ?? null;\n  return { kind: 'applied' };\n}
\n

Это учебный пример. Он не выполняет HTTP-запрос, не создаёт DOM, не измеряет задержку и не доказывает порядок сетевых пакетов. Значение attemptId: 1 здесь не является серверным идентификатором и не заменяет ключ идемпотентности API. Пример проверяет только право ответа изменить локальное состояние.

\n

При новой правке серверная ошибка очищается сразу. Это не означает, что сервер уже принял новое значение. Это означает, что старое сообщение больше не относится к текущему вводу. После этого пользователь может отправить новый снимок. Если старый ответ придёт позднее, сравнение версий остановит его.

\n

Сообщение об ошибке состоит из нескольких связей

\n

Текст сам по себе не даёт полю семантической связи. Компонент должен знать, какое поле не прошло проверку, какой элемент содержит описание и какое действие ожидается от пользователя. Удобно собирать эти данные одним переходом состояния:

\n
const fieldError = {\n  field: 'email',\n  attemptId: 2,\n  version: 3,\n  messageId: 'email-server-error',\n  message: 'Этот адрес уже используется. Укажите другой адрес.',\n  invalid: true,\n};
\n

Из этого объекта можно построить HTML. Например:

\n
<label for="email">Email</label>\n<input id="email" name="email" type="email"\n  aria-invalid="true"\n  aria-errormessage="email-server-error">\n<p id="email-server-error">\n  Этот адрес уже используется. Укажите другой адрес.\n</p>
\n

Разметка — не замена state-контракту. Если после новой правки атрибут остался, а текст относится к старому ответу, интерфейс всё ещё сообщает неверную причину. И наоборот: корректный объект в unit-тесте не доказывает, что реальный DOM связывает элементы именно так или что конкретная вспомогательная технология озвучит их ожидаемым образом.

\n

Симптомы и действия

\n
Диагностика несвязанной ошибки формы
СимптомПричинаПроверкаДействие
Старое сообщение появляется после правкиОтвет сопоставляют только по имени поляСравнить версии снимка и текущего значенияПометить ответ stale и не менять ошибку
Вторая отправка создаётся поверх первойСостояние хранит только boolean loadingПроверить активный attempt до создания новогоЗаблокировать повтор или явно определить очередь
Ошибка есть, но поле не названоСерверный текст потерял field keyНайти источник сообщения и его идентификаторХранить field-specific record отдельно от общего статуса
Красный текст остался после исправленияОчистка зависит от submit, а не от editИзменить поле после ошибки и посмотреть stateСбрасывать связанную server error при новой версии
Один ответ применился дваждыОбработчик не закрывает active attemptПовторно передать тот же resultИгнорировать ответ без активной попытки
Форма очищается после отказаUI заменяет values ответом или пересоздаёт модельСравнить values до submit и после errorХранить снимок и возвращать пользователю введённые данные
\n

Иллюстрация механизма

\n
\"Связь
Схема показывает локальный контракт данных. Она не описывает реальную задержку сети и не является результатом проверки screen reader.
\n

На схеме важна граница stale response. Ответ не уничтожается и не считается ошибкой транспорта. Он просто теряет право менять пользовательский state. Это отрицательный путь, который должен быть виден в тесте. Без него happy path легко создаёт ложное ощущение готовности.

\n

Порядок внедрения и проверки

\n
  1. Зафиксируйте симптом. Воспроизведите один порядок: submit, edit, затем поздний ответ. Запишите значение поля, текст ошибки и состояние кнопки.
  2. Назовите владельцев. Отдельно определите, где живут values, versions, active attempt, локальная ошибка и серверная ошибка. Не начинайте с общего объекта «всё состояние формы».
  3. Сохраните снимок. В момент submit запишите values, версии и новый attemptId. Не вычисляйте их заново при получении ответа.
  4. Закройте повтор. Пока attempt активен, второй submit должен получить явный результат вроде retry-blocked-active-attempt или попасть в заранее определённую очередь.
  5. Проверьте право ответа. Сначала сравните attemptId. Затем сравните версии полей. Только после этого применяйте server error или success.
  6. Очистите старое сообщение. Новая версия поля должна убрать связанную server error. Не удаляйте введённое значение только потому, что сервер вернул отказ.
  7. Соберите semantic payload. Свяжите поле, состояние invalid, идентификатор текста и понятное действие. Проверьте реальную разметку, а не только JavaScript-объект.
  8. Проверьте отрицательный путь. Передайте ответ старой попытки после правки. Убедитесь, что значение, сообщение и semantic association не изменились.
\n

Где модель заканчивается

\n

Контракт с версиями подходит для независимых полей и обычной повторной отправки. Он не решает автоматически зависимые поля, загрузку файлов, debounce, optimistic update, несколько вкладок и автоматические retry. Для каждого такого механизма нужно описать, какой снимок считается актуальным и кто может закрыть попытку.

\n

Локальный rollback также имеет узкую границу. Он может вернуть поле к последнему принятому локальному снимку. Он не отменяет запись на сервере и не делает компенсирующий запрос. Для платежа, заказа или изменения прав нужна отдельная серверная операция, правила конфликта и аудит. Нельзя назвать возврат значения в input отменой доменного действия.

\n

Доступность требует отдельной проверки. W3C требует идентифицировать поле с ошибкой и описать её текстом, но наличие объекта fieldError не заменяет проверку DOM, клавиатуры и выбранных вспомогательных технологий. Не обещайте результат, который не измеряли.

\n

Проверяемый критерий готовности

\n

Контракт готов, если автоматическая проверка проходит четыре отрицательные ветки: локально невалидное значение не создаёт попытку; повтор поверх активной попытки не создаёт вторую; ответ со старой версией не меняет текущую ошибку; повторная доставка уже применённого ответа не меняет состояние. Отдельная проверка реального UI должна подтверждать сохранение введённых данных, связь поля с текстом ошибки и восстановление фокуса по правилам продукта.

\n

Если проходит только успешная отправка, работа не закончена. Успех не проверяет причинную связь. Готовность означает, что каждый показанный текст можно связать с конкретным полем, конкретной попыткой и текущим значением, а старый результат не способен переписать новый ввод.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/201.json b/editorial/agent-rewrites/201.json new file mode 100644 index 0000000..d1299af --- /dev/null +++ b/editorial/agent-rewrites/201.json @@ -0,0 +1,7 @@ +{ + "index": 201, + "slug": "editorial-2022-06-practice-form-errors", + "title": "Старая ошибка формы после новой правки: как связать ответ с тем вводом, который сервер проверял", + "excerpt": "Если пользователь исправил поле, поздний ответ прежней отправки не должен менять текущую форму. Разбираем локальную проверку, версии полей, attemptId и честный retry.", + "contentHtml": "

Пользователь вводит old@example.test и нажимает «Сохранить». Сервер отвечает: «Адрес уже занят». Пока ответ идёт, пользователь меняет поле на new@example.test. Затем интерфейс показывает ту же ошибку рядом с новым адресом. Поле выглядит заполненным, но сообщение относится к другому значению.

\n

Цена ошибки измерима на уровне действия. Человек может повторно исправлять корректный адрес, закрыть форму или отправить её ещё раз, не понимая, какой запрос сейчас активен. В регистрациях, платежах и заказах такое поведение подрывает доверие и может привести к повторному действию с побочным эффектом.

\n

Тезис: ошибка принадлежит не имени поля, а снимку ввода и конкретной попытке отправки. Поэтому ответ можно применить только тогда, когда он относится к текущей версии поля. Для этого нужно разделить локальную проверку, состояние отправки и результат сервера; при каждой правке увеличивать версию поля; каждой отправке выдавать attemptId; устаревший ответ явно игнорировать.

\n

Как возникает рассинхронизация

\n

У формы есть два времени. Первое — время ввода. Значение меняется при каждом действии пользователя. Второе — время ответа. Запрос может закончиться после нескольких новых правок. Если обработчик знает только имя поля, он записывает результат в актуальное состояние, хотя сервер проверял прежний снимок.

\n

Правило «применяем последний пришедший ответ» не защищает форму: последним может прийти самый старый запрос. Правило «последняя правка побеждает» тоже неполно: оно способно скрыть ошибку, которая действительно относится к текущему значению. Нужна проверка принадлежности: совпадают ли версия поля и идентификатор попытки с теми, что сохранены в состоянии.

\n

Локальная ошибка имеет другой источник. Пустое обязательное поле или строку без доменной части email можно проверить до отправки. В этом случае запрос не создаётся. Серверная ошибка появляется только после ответа на конкретный снимок. Ошибка транспорта описывает доставку ответа, а не значение одного поля. Если положить всё в form.error, код потеряет правило очистки и следующий шаг.

\n

Контракт состояния

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый текст появился после правкиОтвет сопоставлен только по имени поляСравнить версии из запроса и текущего поляПометить ответ как stale и не создавать server error
Две отправки идут одновременноRetry не проверяет active attemptПроверить переходы submitting и retryЗаблокировать повтор до завершения попытки
Локальная ошибка ушла на серверConstraint validation смешана с submitУбедиться, что невалидный снимок не получил attemptIdПоказать причину у поля и остановить отправку
Ошибка осталась после изменения значенияServer error очищается только после ответаИзменить поле и проверить state до нового ответаСнять ошибку прежней версии
Повторный ответ меняет успехНет границы принятой попыткиПередать duplicate после successИгнорировать ответ без active attempt
\n

Минимальная запись поля может выглядеть так: { value, version, localError, serverError }. В состоянии формы отдельно нужны activeAttempt и последний принятый снимок. При правке меняются value и version, а ошибка сервера для прежней версии исчезает. При отправке код копирует значения и версии в attempt. Он не должен читать актуальное поле в момент ответа: это уже может быть другой ввод.

\n

Пример: ответ проверяется до изменения state

\n
function editEmail(form, value) {\n  const field = form.fields.email;\n  return { ...form, fields: { ...form.fields, email: {\n    value, version: field.version + 1,\n    localError: validateEmail(value), serverError: null\n  }}};\n}\n\nfunction beginSubmit(form) {\n  const email = form.fields.email;\n  if (email.localError) return { kind: 'blocked-local-error' };\n  const attempt = { id: form.nextAttemptId,\n    values: { email: email.value }, versions: { email: email.version } };\n  return { ...form, activeAttempt: attempt, nextAttemptId: attempt.id + 1 };\n}\n\nfunction receiveResult(form, result) {\n  const attempt = form.activeAttempt;\n  if (!attempt || attempt.id !== result.attemptId) return ignore(form);\n  const current = form.fields.email;\n  if (attempt.versions.email !== current.version) {\n    return { ...form, activeAttempt: null, log: [...form.log, 'stale-response-ignored'] };\n  }\n  if (!result.ok) return { ...form, activeAttempt: null,\n    fields: { ...form.fields, email: { ...current, serverError: result.message } } };\n  return { ...form, activeAttempt: null, accepted: attempt.values };\n}
\n

Это учебный пример для in-memory reducer. Он не отправляет HTTP-запрос, не отменяет fetch, не создаёт DOM и не доказывает порядок сетевых пакетов. Строка Адрес уже занят в тесте должна быть входом result.message, а не утверждением о production-ответе. В реальном приложении сетевой адаптер обязан передать в reducer тот же attemptId и снимок версий.

\n

Почему stale-ответ нужно завершать

\n

Устаревший ответ нельзя применять к полю. Но его нельзя и просто забыть, оставив форму в вечном pending. Если пользователь уже ввёл новое значение, активная попытка прежнего снимка больше не должна блокировать текущий retry. Переход stale-response-ignored снимает activeAttempt, пишет диагностический сигнал и не создаёт ошибку.

\n

После этого пользователь может отправить текущий валидный снимок. Новый retry получает новый attemptId и новые версии. Это не означает, что браузер отменил старый запрос. Отмена зависит от API и стоимости работы сервера. Даже отменённый запрос не заменяет проверку ответа: гонки и повторная доставка должны иметь безопасный результат.

\n
Автомат формы: отправка хранит attemptId и версии, правка делает ответ устаревшим, совпавший ответ ведёт к ошибке или успеху
Схема показывает контракт данных, а не измеренную задержку и не поведение конкретного HTTP-клиента.
\n

Порядок исправления

\n
  1. Зафиксируйте симптом. Воспроизведите отправку, правку до ответа и появление старой ошибки. Запишите значение поля до отправки и после правки.
  2. Найдите владельца. Отметьте обработчик ответа и место записи ошибки. Если запись использует только имя поля, граница недостаточна.
  3. Разделите проверки. Локальную невалидность обработайте до создания попытки. Ошибку сервера храните вместе с идентификатором попытки и версиями снимка.
  4. Добавьте версии. Увеличивайте версию при каждой правке. Копируйте версии в attempt, а не вычисляйте их после ответа.
  5. Закройте повтор. Пока есть active attempt, не запускайте второй submit. После stale, server error или success переход должен быть явным.
  6. Проверьте отрицательный путь. Передайте старый ответ после новой правки. Он не должен менять значение, ошибку или принятый снимок.
  7. Проверьте браузер. Отдельно подтвердите реальный DOM, фокус, сообщение и связь поля с ошибкой. Fixture этого не проверяет.
\n

Связь с разметкой ошибки

\n

Состояние данных не заменяет доступную разметку. Если интерфейс использует aria-invalid и aria-errormessage, атрибуты должны появляться только для ошибки текущей версии. При новой правке связь с прежним сообщением нужно убрать. Идентификатор попытки сам по себе не делает сообщение доступным и не подтверждает, что вспомогательная технология его объявила.

\n

Для нативных контролов сначала используйте возможности HTML: правильный тип, required, pattern и constraint validation. Скрипт может дополнить этот слой серверным результатом, но не должен подменять его общей строкой без указания поля. Сообщение должно объяснять причину и действие, если сервер действительно сообщает о проблеме текущего снимка.

\n

Ограничения

\n

Схема рассчитана на одну форму и одного владельца состояния. Она не решает конфликт между двумя вкладками, offline, rate limit, CSRF, составные поля, загрузку файлов и оптимистическое сохранение. Для нескольких полей версия должна быть частью снимка каждого поля или общей версии формы; выбор зависит от того, какие поля сервер проверяет вместе.

\n

attemptId интерфейса не является idempotency key. Он защищает применение ответа внутри формы. Идемпотентность на сервере требует отдельного контракта. Нельзя считать, что новый UI-идентификатор предотвращает повторную оплату или создание ресурса.

\n

Пример не содержит production-метрик. Его проверяемый результат уже: устаревший ответ не меняет текущий ввод; совпавший ответ меняет только соответствующий снимок; duplicate после success не меняет accepted state; локально невалидный ввод не создаёт попытку.

\n

Критерий готовности

\n

Исправление готово, если команда воспроизводит четыре сценария с однозначными переходами: локальная ошибка блокирует submit без attemptId; правка во время отправки делает прежний ответ stale; совпавший server error появляется только у проверенного значения; успешный retry создаёт новый accepted snapshot, а старый duplicate ничего не меняет. Браузерная проверка дополнительно подтверждает, что сообщение видно, связано с нужным контролом и очищается после новой правки.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/202.json b/editorial/agent-rewrites/202.json new file mode 100644 index 0000000..16394f1 --- /dev/null +++ b/editorial/agent-rewrites/202.json @@ -0,0 +1,7 @@ +{ + "index": 202, + "slug": "editorial-2022-05-field-design-system", + "title": "Как менять токен дизайн-системы и не сломать соседний экран", + "excerpt": "Практический разбор правки токена: как зафиксировать симптом, оценить радиус изменения, проверить состояния кнопки и оставить точный путь к откату.", + "contentHtml": "

Цвет primary-кнопки меняют в одной строке, а ошибка появляется на другом экране. На форме оплаты пропадает focus ring. В диалоге отмены кнопка получает цвет действия подтверждения. В третьем месте новый токен не применяется, потому что компонент ждёт другое имя. Визуально это похоже на одну проблему. Технически это разные сбои контракта.

\n

Цена ошибки растёт вместе с радиусом общего токена. Локальная правка исправляет один usage. Глобальная правка меняет все usages, включая те, которые не попали в поиск. Если команда не знает список мест и состояний, она не может объяснить diff, выбрать безопасный откат или отличить новый вариант от случайного исключения.

\n

Тезис. Токен нельзя менять по одному скриншоту. Сначала свяжите симптом с ролью компонента, состоянием, именем токена и известными usages. Затем проверьте допустимость значения. Только после этого меняйте один слой и сохраняйте прежнее значение. Такой порядок делает изменение ограниченным, наблюдаемым и обратимым.

\n

Механизм: токен имеет владельца и радиус

\n

Дизайн-токен — не просто цветовая константа. Он выражает роль: например, фон primary-кнопки в обычном состоянии. Роль связывает значение с компонентом и состоянием. Если код использует #2457D6 напрямую, он обходит эту связь. Если код ссылается на неизвестное имя, система получает второй, неописанный способ задать ту же роль.

\n

У одного значения есть четыре границы. Первая — имя роли, например button.primary.background. Вторая — состояние: default, hover, focus-visible, disabled или loading. Третья — компонент, который действительно может использовать эту роль. Четвёртая — набор известных мест в коде. Пропуск любой границы превращает косметическую правку в догадку.

\n

Состояния нельзя восстановить из одного цвета. Кнопка может сохранить фон и потерять outline при переходе на клавиатуру. Она может выглядеть одинаково в спокойном состоянии, но стать неразличимой при отключении. Поэтому inventory должен хранить не только имя токена, но и ожидаемые состояния. Для ссылки, переключателя и destructive-действия нужен отдельный контракт, а не необязательный флаг в универсальной кнопке.

\n

Симптом → причина → проверка → действие

\n
Минимальная диагностика разрыва контракта
СимптомПричинаПроверкаДействие
На одном экране другой синийLiteral обошёл именованный токенНайти значение и сравнить с ролью в inventoryЗаменить подтверждённый usage
После refactor исчез focus ringСостояние не входит в матрицуПройти кнопку клавиатуройВернуть focus-visible в контракт
Новое имя не работаетИмя отсутствует в словаре ролиПроверить декларацию и чтениеОстановить правку или добавить роль
Review назван visual-проверкой без снимкаСписок входов перепутали с результатомПроверить браузер, viewport и diffНе выдавать зелёный статус без артефакта
Платёжный экран изменился вместе с профилемОбщий токен исправляли без радиусаСопоставить usages и ролиВернуть прежнее значение и отделить variant
\n

Инвентарь до правки

\n

Начните с короткой таблицы известных usages. Для каждого места запишите компонент, роль, состояние, имя и способ задания значения. Например, profile-save использует button.primary.background в состояниях default, hover, focus-visible, disabled и loading. dialog-cancel может быть secondary-кнопкой. Похожая разметка не делает эти usages одной ролью.

\n

Поиск по имени и hex-значению даёт кандидатов, но не доказывает полноту inventory. Стиль может прийти из темы, CSS-переменной, inline-значения или обёртки. Автоматический поиск не определяет, является ли ссылка кнопкой по смыслу. Поэтому результат поиска нужно проверить на уровне entry point и фактического состояния. Учебный пример ниже ограничен тремя usages и не изображает полный обход настоящего репозитория.

\n

Проверка входа раньше изменения

\n

Неверная конфигурация должна остановиться до записи. В примере есть словарь разрешённых ролей и простая проверка формата: цвет передаётся как #RRGGBB. Это учебное правило, а не универсальный валидатор CSS. Его задача — не дать срочной правке молча создать новый ключ или принять случайную строку.

\n
const tokens = {'button.primary.background':'#2457D6'};\nfunction correctToken(name, nextValue) {\n  if (!(name in tokens)) return {ok:false, reason:'unknown-token'};\n  if (!/^#[0-9A-F]{6}$/i.test(nextValue)) return {ok:false, reason:'invalid-color'};\n  return {ok:true, name, previousValue:tokens[name], nextValue};\n}\nfunction rollbackToken(change) {\n  return {ok:change.ok, name:change.name, nextValue:change.previousValue};\n}\nconst change = correctToken('button.primary.background','#1D4ED8');\nconst restored = rollbackToken(change);
\n

Функция не меняет объект сама. Она возвращает решение с именем, прежним и новым значением. Отдельный слой применяет решение после проверки inventory. Валидатор не знает, подходит ли новый синий для оплаты, профиля или тёмной темы. Он проверяет только форму и существование роли.

\n

В учебном коде change.nextValue равен #1D4ED8, а restored.nextValue равен #2457D6. Пример не запускает CSS, не открывает браузер и не измеряет контраст. Он показывает только две операции: неизвестное имя отклоняется, а принятая правка хранит точное прежнее значение. В настоящем проекте формат, запись и права должны соответствовать его контракту.

\n
Схема изменения токена: inventory ведёт к проверке роли и состояния, неверная конфигурация останавливается, допустимая правка сохраняет прежнее значение для отката
Диагностический маршрут токена. Схема показывает порядок проверки и возврата значения; она не является результатом visual-регрессионного запуска.
\n

Почему список входов не равен проверке интерфейса

\n

До реального запуска можно составить список входов: viewport 375 и 1280, состояния default, hover, focus-visible, disabled, loading, светлая и тёмная темы. Список полезен как граница проверки: он заставляет назвать объём работы и не забыть состояние.

\n

Но список не видит пиксели, cascade, media query, шрифт или порядок фокуса. Заполненный JSON не доказывает, что браузер применил нужную переменную. Для visual-проверки нужны страница, браузер, viewport, baseline, новый снимок, правило допустимого diff и сохранённый результат. Для доступности нужны keyboard route и выбранная комбинация браузера и вспомогательной технологии. Если артефакта нет, проверку нельзя называть успешной.

\n

Порядок исправления

\n
  1. Зафиксируйте симптом. Укажите экран, компонент, состояние, ожидаемое и фактическое значение.
  2. Определите роль. Проверьте, что control действительно primary-кнопка. Для другого смысла нужен отдельный контракт.
  3. Соберите inventory. Найдите usages по имени, роли и literal-значению. Отметьте подтверждённые места.
  4. Проверьте состояния. Пройдите клавиатурой focus-visible, затем проверьте disabled и loading. Default не заменяет остальные состояния.
  5. Проверьте вход. Отклоните неизвестный token и неверный формат. Не добавляйте новый ключ как временное исключение.
  6. Сделайте одну правку. Сохраните previous value, измените выбранную роль и запишите причину.
  7. Проверьте отрицательный путь. Передайте неизвестное имя, неверное значение и отмену принятой правки. Ни один путь не должен менять baseline молча.
  8. Проверьте интерфейс. Соберите CSS, откройте нужные viewport, пройдите клавиатурой и сохраните снимок или иной артефакт.
\n

Ограничения

\n

Этот подход не вычисляет контраст и не заменяет ручную проверку. Он не решает каскад тем, локализацию, media queries, пользовательские настройки, разные браузеры и конкурирующие изменения. Он также не определяет, является ли новый цвет хорошим дизайном. Он отвечает на более узкий вопрос: существует ли роль, что именно она затрагивает и можно ли вернуть прежнее значение.

\n

Один общий токен не всегда лучше двух. Если профиль и оплата имеют разные требования к смыслу, состояниям или риску ошибки, их нужно разделить после подтверждения usages. Нельзя объявлять variant только потому, что один экран случайно выглядит иначе. Сначала исключите literal, неправильное состояние и неверное имя.

\n

Rollback не безопасен во всех слоях. Возврат значения токена не отменяет опубликованный CSS, не откатывает сборку и не компенсирует действие пользователя. Для этих уровней нужны собственные процедуры и артефакты. Здесь rollback ограничен одной парой name → previousValue.

\n

Критерий готовности

\n

Правка готова, если команда может показать inventory затронутых usages, матрицу состояний и запись с прежним значением. Неизвестный token и неверный формат останавливаются без изменения. Допустимая правка меняет только выбранную роль. Отдельный запуск подтверждает нужные viewport и состояния реальным артефактом. Повторный просмотр возвращает прежнее значение по сохранённому previousValue, а не по памяти или догадке.

\n

Если visual или accessibility запуск ещё не выполнен, критерий должен прямо это показывать. Нельзя превращать объявленный объём проверки в результат. Для учебного примера достаточно проверить решения функции и отрицательные входы. Для своего интерфейса добавьте браузерную проверку, историю baseline и ссылку на сохранённый diff.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/203.json b/editorial/agent-rewrites/203.json new file mode 100644 index 0000000..a99c2b9 --- /dev/null +++ b/editorial/agent-rewrites/203.json @@ -0,0 +1,7 @@ +{ + "index": 203, + "slug": "editorial-2022-05-mechanism-design-system", + "title": "Контракт кнопки: как не потерять поведение между токеном и интерфейсом", + "excerpt": "Кнопка расходится по цвету, состояниям и доступности, когда разные слои владеют одним действием. Разбираем малый контракт, отрицательный путь и критерий готовности изменения.", + "contentHtml": "

На одном экране кнопка «Сохранить» показывает loading, на другом принимает второй клик. В третьем варианте клавиатурный фокус почти не виден. Иконка выглядит одинаково, но её смысл зависит от скрытого текста. Такие сбои часто появляются после небольшой правки: кто-то поменял цвет, другой слой добавил disabled, а локальный компонент оставил старый обработчик.

\n

Цена ошибки выше, чем разница в CSS. Повторный клик может отправить действие дважды. Потерянный focus мешает пройти форму без мыши. Неясное имя кнопки ломает сценарий для screen reader. Команда при этом видит несколько похожих компонентов и не знает, какой из них задаёт правило.

\n

Тезис. Малой дизайн-системе нужен не большой каталог компонентов, а явный контракт одного control. Контракт разделяет четыре вида данных: значения токенов, допустимые состояния, семантику и известные места применения. Каждый слой имеет владельца. Ни один слой не притворяется проверкой браузера.

\n

Механизм: четыре слоя одного действия

\n

Токены хранят именованные значения: фон, цвет текста, контур фокуса, радиус и отступ. Токен не знает, завершился ли запрос. Если в нём появляется флаг loading, он начинает управлять чужим поведением.

\n

Слой состояний перечисляет допустимые ветки: default, hover, focus-visible, disabled и loading. Список не доказывает, что браузер реально показал каждую ветку. Он только не даёт молча забыть обязательный сценарий.

\n

Семантический слой описывает name, role, текущее состояние и признак недоступности. Цвет не заменяет эти поля. Серый фон может означать disabled, loading или ошибочный стиль. Для простой кнопки стоит начать с native <button>: он уже имеет базовую клавиатурную модель. role="button" на другом элементе не воспроизводит её автоматически.

\n

Инвентарь хранит известные usage. Например, profile-save, billing-pay и dialog-cancel могут выглядеть похоже, но иметь разный риск. Инвентарь показывает предполагаемый радиус правки. Он не доказывает, что в репозитории нет четвёртого usage.

\n
Границы малого контракта кнопки
СлойВладеетНе доказываетПроверка за границей
ТокеныИмена и значения background, foreground, focus ring, radius, gapЧто собранный CSS применился во всех вариантахСобранный CSS и visual diff в выбранной среде
СостоянияНабор default, hover, focus-visible, disabled, loadingЧто пользователь увидел каждую веткуСценарий мышью и клавиатурой или автоматизация
СемантикаИмя, роль, state и disabledЧто screen reader произнёс ожидаемую фразуDOM, accessibility tree и выбранная технология
ИнвентарьЯвно известные места примененияПолноту поиска по кодовой базеПоиск, классификация и review миграции
Visual payloadViewports, states и имена токенов для будущего запускаScreenshot, diff, score или regressionРеальный runner с сохранённым артефактом
\n

Почему имя CSS-переменной не создаёт семантику

\n

CSS Custom Properties задают пользовательские свойства и позволяют подставлять их через var(). Спецификация не решает, что означает имя. --button-primary-background технически допустимо, но этого мало. Команда должна отдельно договориться, для какой роли живёт значение, кто его меняет и какие usage зависят от него.

\n

Полезная цепочка выглядит так: button.primary.background → primary button → конкретные usage. Она не требует одного способа реализации. Значение может попасть в CSS custom property, объект темы или stylesheet. Важно сохранить связь между ролью и радиусом изменения.

\n

Это также объясняет отрицательный путь. Если usage просит button.primary.shadow, а такого токена нет, валидатор должен вернуть ошибку. Быстрое добавление нового ключа скрывает решение о дизайне и расширяет общий API. Если значение имеет неверный формат, например brand-blue вместо ожидаемого учебного #RRGGBB, изменение тоже нужно остановить.

\n

Семантика и состояние не выводятся из цвета

\n

Поле state в контракте — объявленное намерение. Оно не равно состоянию DOM. Запись loading ещё не блокирует клик, не меняет доступность и не объявляет прогресс. Эти эффекты должен реализовать компонент, а затем пройти отдельную проверку.

\n

Для иконки без видимого текста нужно задать доступное имя. Для кнопки с текстом «Сохранить» имя обычно берётся из текста. Для icon-only control потребуется label или другая согласованная семантика. Не стоит рассчитывать на имя файла, title в CSS или цвет рядом с иконкой.

\n

Если команда выбирает custom host вместо native button, она принимает дополнительный долг: нужно проверить фокус, активацию клавишей Enter или Space, disabled-поведение и передачу имени. В таких случаях запись role="button" — только часть условия.

\n

Исполнимый пример: проверка данных до UI

\n

Ниже демонстрационный JavaScript-фрагмент. Он проверяет только согласованность описания. В нём нет DOM, CSS cascade, браузера и assistive technology. Успешный результат не означает, что кнопка доступна или визуально одинакова.

\n
const system = {\n  tokens: {\n    'button.primary.background': '#2457D6',\n    'button.primary.foreground': '#FFFFFF',\n    'button.focus.ring': '#111827'\n  },\n  requiredStates: ['default', 'hover', 'focus-visible', 'disabled', 'loading'],\n  button: { name: 'Сохранить', role: 'button', state: 'default', disabled: false },\n  usage: ['profile-save', 'billing-pay', 'dialog-cancel'],\n  visualPayload: { viewports: [375, 1280], states: ['default', 'focus-visible'] }\n};\n\nfunction checkContract(value) {\n  const statesOk = value.requiredStates.includes('focus-visible');\n  const semanticsOk = value.button.role === 'button' && value.button.name.length > 0;\n  const tokensOk = Object.keys(value.tokens).every((name) => name.startsWith('button.'));\n  const usageOk = value.usage.length > 0;\n  return { ok: statesOk && semanticsOk && tokensOk && usageOk };\n}\n\nconsole.log(checkContract(system)); // { ok: true }
\n

Этот код полезен как ранняя защита границы. Если удалить focus-visible, имя кнопки или добавить токен с неизвестным namespace, проверка должна стать красной. Но она не проверяет повторный submit, computed style, contrast, реальное имя в accessibility tree или поведение на телефоне.

\n
Схема контракта primary button: токены, обязательные состояния, семантические поля и инвентарь usage соединены до внешних проверок браузера и доступности.
Контракт удерживает четыре слоя отдельно. Внешние проверки подтверждают то, чего модель данных сама увидеть не может.
\n

Симптом → причина → проверка → действие

\n
Диагностика расхождения кнопки
СимптомПричинаПроверкаДействие
Loading показывает правильный цвет, но второй клик проходитСостояние описали в style layer, а обработчик не использует егоДва последовательных события и проверка network callsСвязать loading с блокировкой действия и проверить повторный submit
В одном usage пропал focus ringВ state matrix нет focus-visible или его токен заменил literalПройти сценарий только клавиатурой и посмотреть computed styleВернуть обязательное состояние и named token, затем повторить сценарий
Две кнопки с одинаковым цветом имеют разный смыслРоль вывели из visual variantСравнить name, role, state и действие в DOMРазделить contract или исправить семантические поля
Новый token проходит локальный review, но ломает сборкуUsage ссылается на undeclared keyСверить ссылку с реестром имён и запустить валидаторОстановить change; сначала объявить роль и владельца токена
В задаче есть «visual passed», но нет снимкаPayload перепутали с результатом runnerНайти screenshot, diff, browser и commit в артефактеПометить проверку как не выполненную и запустить её отдельно
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Назовите один control, одно состояние, один usage и наблюдаемое действие. Не начинайте с общего «кнопки выглядят по-разному».
  2. Определите цену. Запишите, что может произойти: повторная операция, недоступная клавиатура, неверное имя или незаметный общий blast radius.
  3. Разложите владельцев. Отдельно выпишите токен, state matrix, semantic fields и inventory. Если один файл владеет всем сразу, отметьте это как риск.
  4. Проверьте отрицательный путь. Перед допустимой правкой отправьте неизвестный token и неверное значение. Убедитесь, что валидатор отказал и baseline не изменился.
  5. Сделайте одну правку. Вынесите literal в существующий токен, добавьте пропущенное state или исправьте native host. Не совмещайте это с полной миграцией библиотеки.
  6. Проверьте реальный control. Откройте страницу в согласованных browser и viewport, пройдите mouse и keyboard сценарии, проверьте DOM и accessibility tree.
  7. Сохраните артефакты. Для visual проверки нужны screenshot и diff; для доступности — зафиксированный сценарий и инструмент. Payload оставьте списком входов.
  8. Подготовьте откат. Запишите прежнее значение токена и ограничьте изменение известным inventory. Если появился неожиданный effect, верните только эту правку.
\n

Ограничения

\n

Малый контракт не заменяет дизайн-систему целиком. Он не решает темизацию, dark mode, локализацию, responsive layout, сложную анимацию, права пользователя и сетевой submit. Он не определяет, какие действия должны быть destructive или toggle. Сходство радиуса и цвета не делает controls одним компонентом.

\n

Инвентарь остаётся неполным, если команда не проверила кодовую базу. Результат поиска тоже не идеален: стилизованная ссылка может выглядеть как button, а роль может появиться через обёртку. Поэтому inventory нужно помечать как подтверждённый или предполагаемый.

\n

Демонстрационные значения в коде выбраны для объяснения механизма. Они не являются production-рекомендацией и не показывают реальный результат visual, accessibility или пользовательского теста. Такой предел делает вывод проверяемым: мы утверждаем согласованность модели, а не качество конкретного интерфейса.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда одновременно выполнены четыре условия: валидатор принимает contract и отклоняет отрицательный пример без изменения baseline; нужный usage внесён в инвентарь; реальный control проходит keyboard, pointer и accessibility-проверку в согласованной среде; visual-проверка имеет сохранённые screenshot и diff либо явно отмечена как ещё не выполненная. Дополнительно известны прежнее значение токена и точка отката.

\n

Если отсутствует хотя бы один внешний артефакт, готовность ограничивается моделью данных. Нельзя называть её visual или accessibility regression result. Этот язык сохраняет связь между тем, что команда описала, и тем, что она действительно наблюдала.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/204.json b/editorial/agent-rewrites/204.json new file mode 100644 index 0000000..5d026a0 --- /dev/null +++ b/editorial/agent-rewrites/204.json @@ -0,0 +1,7 @@ +{ + "index": 204, + "slug": "editorial-2022-05-practice-design-system", + "title": "Маленькая дизайн-система: как закрепить контракт кнопки", + "excerpt": "Кнопки расходятся по цвету, фокусу и поведению, когда их контракт разбросан по разным слоям. Разбираем малую схему с токенами, состояниями, проверками и обратимой правкой.", + "contentHtml": "

Проблема: на одном экране primary button получает цвет из локального CSS, на другом теряет видимый фокус, а на третьем остаётся доступной во время загрузки; пользователь видит непредсказуемое действие, а команда платит повторной правкой и риском сломать соседний сценарий.

\n

Причина обычно не в том, что в проекте мало компонентов. У одной кнопки нет общего проверяемого контракта. Цвет живёт в стилях, состояние — в обработчике, текст — в разметке, а список мест использования никто не держит. Поэтому безопасная на вид замена цвета может затронуть оплату, профиль и диалог одновременно.

\n

Начинайте с одной повторяющейся primary button. Зафиксируйте её роль, значения, состояния и известные места применения. Такой малый contract не заменяет всю дизайн-систему. Он ограничивает изменение так, чтобы его можно было проверить и откатить.

\n

Тезис: сначала договор, потом каталог

\n

Каталог компонентов полезен, когда команда уже несколько раз приняла одно и то же решение. До этого каталог часто только прячет расхождения за общим названием. Две кнопки могут называться Button, но иметь разные правила loading, разные accessible name и разные последствия для отправки формы.

\n

Малый контракт отвечает на четыре вопроса. Какое значение меняется? Какие состояния поддерживает control? Как пользователь понимает его смысл? Какие существующие места попадут под правку? Если на один вопрос нет ответа, компонент ещё нельзя считать единым.

\n

Механизм малого контракта

\n

Разделите данные на четыре слоя. Named tokens хранят значения и их роль. State matrix перечисляет допустимые состояния. Semantic fields описывают имя и поведение control. Usage inventory показывает известный радиус изменения. Каждый слой проверяется отдельно, но вместе они дают узкий договор.

\n

Для primary button достаточно начать с пяти token names: button.primary.background, button.primary.foreground, button.primary.focus-ring, button.primary.radius и button.primary.gap. Названия в примере — учебное соглашение. В настоящем проекте они должны соответствовать принятой системе именования.

\n

State matrix может содержать default, hover, focus-visible, disabled и loading. Не каждое действие обязано поддерживать каждую ветку. Но отсутствие состояния должно быть решением. Если операция синхронная и loading ей не нужен, это нужно записать. Пустая ветка, которую команда просто забыла, станет дефектом позже.

\n

Semantic fields не выводятся из цвета. Серый цвет может означать disabled, loading, неактивную вкладку или случайный override. Контракт хранит имя действия, роль, объявленное состояние и признак disabled отдельно. Для обычного действия выбирайте native button: браузер уже даёт базовую роль и клавиатурную модель.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Один синий цвет задан разными hex-значениямиЗначение не связано с ролью компонентаНайти literal values и сравнить их usageВвести один named token для primary button
Фокус или loading появляется только после жалобыСостояния не объявлены заранееСверить state matrix с кодом и сценариямиДобавить состояние или явно исключить его
Иконка выглядит как кнопка, но действие неясноИмя control выводят из картинкиПроверить текстовое имя и native semanticsОставить текст или задать проверяемое accessible name
Правка профиля меняет оплатуНеизвестны точки примененияСоставить inventory с контекстом и состояниемСузить diff и повторить проверку мест
\n

Токен задаёт роль, а не просто переменную

\n

CSS Custom Properties дают именованные свойства и подстановку через var(). Браузер понимает их синтаксис, но не знает, что значение относится к primary button и кто отвечает за его изменение. Смысл появляется в соглашении команды и в повторяемом применении.

\n

Например, --button-primary-background можно передать компоненту как CSS custom property. Но это не означает, что любой синий фон должен использовать тот же token. Primary, destructive и secondary button имеют разные роли, даже если сегодня два значения совпадают. Роль помогает менять одно решение без неожиданного каскадного эффекта.

\n

При проверке ищите четыре связи: имя token, значение, владелец и потребители. Если рядом с компонентом появляется новый #2457D6, решите, что это: новый вариант, локальное исключение или обход существующего contract. Временное значение тоже получает срок и способ отката. Иначе временное исключение быстро станет вторым стандартом.

\n

Конкретный пример

\n

Разметка должна сохранять смысл действия независимо от цвета и иконки. Этот фрагмент учебный: он показывает границу контракта, а не готовую библиотеку.

\n
<button\n  type=\"submit\"\n  class=\"button button--primary\"\n  data-state=\"default\"\n>\n  Сохранить изменения\n</button>
\n

В реальном интерфейсе нужно проверить переходы default → loading → default или error. Пока запрос выполняется, повторная отправка должна иметь явное правило. После ошибки пользователь должен понимать, что произошло и какое действие доступно дальше. Эти решения нельзя прятать только в opacity.

\n

Если вместо native element используется div role=\"button\", одной ARIA-роли недостаточно. Понадобятся клавиатурное управление, focus behavior, disabled semantics и проверка имени. Если custom host не даёт нужного поведения, это отрицательный результат: вернитесь к native button, а не добавляйте ещё один слой стилизации.

\n

Проверка данных до проверки страницы

\n

Сначала можно проверить сам контракт: все ли обязательные token names объявлены, все ли состояния перечислены, есть ли у control имя и роль, заполнены ли известные usage. Такой тест быстро ловит пропущенное поле и не требует рендера.

\n
const requiredStates = [\n  'default', 'hover', 'focus-visible', 'disabled', 'loading'\n];\n\nconst contract = {\n  name: 'Сохранить изменения',\n  role: 'button',\n  state: 'default',\n  permittedStates: requiredStates\n};\n\nconst missingStates = requiredStates.filter(\n  (state) => !contract.permittedStates.includes(state)\n);\n\nconst ok = missingStates.length === 0\n  && contract.role === 'button'\n  && Boolean(contract.name);
\n

Результат true здесь означает только согласованность объекта. Код не создаёт DOM, не собирает CSS, не измеряет контраст, не проверяет порядок фокуса и не говорит, что screen reader произнесёт ожидаемое имя. Не называйте такую проверку visual regression или доказательством соответствия WCAG. Для этих утверждений нужна наблюдаемая страница и отдельный артефакт.

\n
\"Схема
Схема разделяет значения, состояния, семантику и места применения. Объявленный contract не является снимком интерфейса и не заменяет проверку в браузере.
\n

Порядок действий

\n
  1. Выберите один control. Найдите повторяющуюся primary button с расхождением цвета, focus или label. Не включайте сразу все кнопки.
  2. Опишите симптом. Запишите экран, состояние и действие пользователя. Отделите наблюдаемую разницу от гипотезы о причине.
  3. Составьте inventory. Для каждого найденного usage укажите контекст, имя, роль, состояние и локальные overrides. Поиск по коду остаётся отдельной проверкой полноты.
  4. Объявите contract. Назовите tokens и state matrix. Для каждой неприменимой ветки запишите решение.
  5. Проверьте данные. Убедитесь, что нет пропущенных token, состояния или semantic name. Зафиксируйте, чего этот тест не наблюдает.
  6. Проверьте страницу. Соберите CSS, пройдите клавиатурный сценарий, проверьте DOM и accessibility tree, затем выполните согласованную visual-проверку на нужных viewport.
  7. Сделайте правку обратимой. Сохраните прежнее значение token и список затронутых usage. При неожиданном эффекте откатите точечную правку, а не весь unrelated CSS.
\n

Отрицательный путь: похожий control не всегда тот же

\n

Предположим, три формы используют разные обязательные поля, цвета ошибок и правила disabled. Общий token не исправит проблему. Эти формы могут выглядеть похоже, но иметь разные state matrix и разные требования к имени. В таком случае не расширяйте primary button новым набором optional props только ради повторного названия.

\n

Сначала зафиксируйте различие в поведении. Если оно устойчиво, создайте отдельную роль или variant с собственным contract. Если различие появилось из-за случайного override, удалите override и верните control к исходному contract. Это дешевле, чем превращать исключение в универсальный API.

\n

Ограничения и критерий готовности

\n

Малый contract не строит темизацию, не мигрирует legacy CSS, не выбирает типографику бренда и не доказывает, что найден каждый usage. Он также не заменяет ручную проверку доступности и visual comparison. Значения, имена и места из примеров учебные. Production-результат можно утверждать только после реального запуска и сохранённого наблюдения.

\n

Критерий готовности проверяемый: для выбранной primary button каждый обязательный state имеет решение; каждый token имеет имя и место потребления; каждый найденный usage имеет name, role и state; проверка данных проходит; реальная страница отдельно подтверждает keyboard, focus-visible, DOM semantics и visual diff; rollback возвращает прежнее значение. Если хотя бы один пункт неизвестен, contract не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/205.json b/editorial/agent-rewrites/205.json new file mode 100644 index 0000000..7830538 --- /dev/null +++ b/editorial/agent-rewrites/205.json @@ -0,0 +1,7 @@ +{ + "index": 205, + "slug": "editorial-2022-04-field-accessible-interface", + "title": "Доступный control после правки: как не потерять фокус, состояние и обратный путь", + "excerpt": "Визуально исправный control может потерять смысл для клавиатуры и screen reader. Разбираем расхождение состояний на примере панели настроек и задаём проверяемый путь исправления.", + "contentHtml": "

Кнопка открывает панель настроек. Пользователь выбирает SMS, нажимает «Сохранить», панель закрывается. Глаз видит галочку и новый текст. Но фокус исчезает, имя группы не читается, а повторное открытие показывает старое значение. Другой вариант хуже: ошибка подсвечивается, но уже изменила сохранённую настройку. Такой дефект легко пропустить при проверке мышью. Цена ошибки — потерянный путь навигации, неверное действие и повторная работа пользователя. Для критичного интерфейса это может остановить операцию целиком.

\n

Тезис простой: доступность control — это не набор атрибутов рядом с CSS. Это согласованный контракт состояния, имени, роли, фокуса и результата. Один источник состояния должен объяснять, что сейчас выбрано. Каждая ветка должна иметь понятный следующий фокус. Успех и ошибка должны менять разные данные. Если эти условия не записаны, визуальный патч легко создаёт недоступный интерфейс.

\n

Сначала разделите черновик и сохранённое значение

\n

Панель выбора имеет как минимум два значения. draftChannel показывает выбор внутри открытой панели. savedChannel отражает настройку, которая уже принята продуктом. Пока пользователь не подтвердил выбор, эти значения могут различаться. Ошибка валидации не должна менять savedChannel. Успешное сохранение сначала запоминает старое значение, затем коммитит новое и только после этого закрывает панель.

\n

У control есть и другие части контракта: имя и роль группы, состояние выбранной опции, trigger, target для ошибки, target для возврата после закрытия и сообщение о результате. Их не нужно хранить в шести независимых переменных. Они должны выводиться из одной модели переходов. Тогда проверка отвечает на конкретный вопрос: какое действие изменило состояние и куда попал фокус после этого действия.

\n
const state = {\n  open: true,\n  draftChannel: 'sms',\n  savedChannel: 'email',\n  previousChannel: null,\n  focusTarget: 'channel-sms',\n  status: ''\n};\n\nfunction save(state) {\n  if (!['email', 'sms'].includes(state.draftChannel)) {\n    return { ...state, focusTarget: 'channel-error', status: 'Выберите канал' };\n  }\n\n  return {\n    ...state,\n    open: false,\n    previousChannel: state.savedChannel,\n    savedChannel: state.draftChannel,\n    focusTarget: 'preferences-trigger',\n    status: 'Канал сохранён'\n  };\n}
\n

Это учебный пример переходов. Он не создаёт DOM, не отправляет события клавиатуры и не доказывает, что конкретная технология озвучит сообщение. В нём важна граница: невалидная ветка не коммитит значение, а успешная ветка сохраняет предыдущее значение до закрытия панели. В реальном компоненте эти переходы нужно связать с семантической разметкой и проверить в выбранных браузерах и assistive technology.

\n

Почему одинаковый симптом скрывает разные причины

\n

После клика пользователь может увидеть новую галочку и всё равно получить старое состояние в accessibility tree. Так происходит, когда CSS меняет класс, а aria-checked получает значение из другой переменной. Фокус может исчезнуть не из-за screen reader, а потому, что компонент удалили до того, как выбрали return target. Сообщение может быть видно, но не иметь программно определяемого статуса. Поэтому диагностика должна начинаться с последовательности переходов, а не с перебора атрибутов.

\n
Диагностика разрыва контракта control
СимптомПричинаПроверкаДействие
После закрытия фокус неясенПанель удаляется до выбора return targetЗаписать активный элемент до и после closeВыбрать существующий target до unmount и проверить его клавиатурой
Галочка видна, но состояние староеCSS и семантика используют разные источникиСравнить visual value и declared value после одного выбораВывести оба значения из одного state owner
Ошибка есть только для мышиСообщение не связано с полем и фокусомПроверить имя, описание, target ошибки и порядок переходаСвязать ошибку с control и направить фокус на исправление
Старое значение изменилось при ошибкеDraft и committed state склееныВыполнить invalid branch и сравнить saved valueОстановить commit до успешной проверки
Toast виден, но результат непредсказуемТекст создаётся отдельно от result stateНайти место формирования статуса и его семантическую границуСформировать статус в контракте, затем проверить платформенный output
\n

Один пример и отрицательный путь

\n

Рассмотрим панель «Канал уведомлений». Trigger имеет имя «Настроить уведомления» и сообщает, открыта ли панель. Группа выбора получает имя «Канал уведомлений». В ней есть две radio options: Email и SMS. Кнопка «Сохранить» подтверждает draft. При успехе панель закрывается, focus возвращается на trigger, а статус сообщает о принятом действии. Это не единственный возможный маршрут. Если после сохранения появляется отдельный результат, продукт может направить фокус туда. Важно принять решение до удаления панели.

\n

Отрицательная ветка начинается с невозможного или неподдерживаемого значения. Панель остаётся открытой. savedChannel не меняется. Сообщение об ошибке связано с местом исправления. Фокус получает понятный target. Пользователь может изменить draft и повторить действие. Если обработчик сначала записывает значение, а потом проверяет его, последующее сообщение уже не исправит неверное состояние.

\n
\"Схема
Учебная схема разделяет invalid branch и valid save. Она показывает границы состояния, фокуса и отмены; фактический DOM и речь screen reader нужно проверять отдельно.
\n

Порядок проверки

\n
  1. Опишите наблюдаемый сбой. Запишите один симптом: фокус потерян, состояние не совпало с визуальным выбором или ошибка не дала следующего шага. Не называйте причину до проверки.
  2. Назовите контракт. Зафиксируйте имя, роль, выбранное состояние, draft value, committed value, trigger, target ошибки и return target.
  3. Проследите переход. Найдите в коде места, где меняются CSS, семантическое состояние, сохранённое значение, фокус и статус. Проверьте порядок операций.
  4. Проверьте отрицательную ветку. Передайте невалидный draft. Убедитесь, что committed value не изменился, панель не закрылась, а фокус получил исправляемый target.
  5. Проверьте успешную ветку. Сохраните новый выбор. Убедитесь, что старое значение сохранено для предусмотренной отмены, панель закрылась, а return target существует.
  6. Проверьте реальный интерфейс. Пройдите сценарий только клавиатурой. Затем проверьте DOM, доступное имя, роль, состояние, порядок фокуса и статус в согласованной комбинации браузера и assistive technology.
  7. Оставьте обратимый diff. Вносите одно изменение за раз. После каждого изменения повторяйте обе ветки. Если результат ухудшился, верните прежнюю разметку или state transition и сохраните причину отката.
\n

Что этот подход не доказывает

\n

Учебная модель не симулирует браузер. Поле focusTarget не равно вызову HTMLElement.focus(). Строка status не доказывает конкретную фразу, которую услышит пользователь. Unit-тест переходов не проверяет tab order соседних компонентов, видимый focus indicator, порталы, конфликт сочетаний клавиш или поведение нескольких вкладок.

\n

Undo для такой панели тоже ограничен. Он может хранить одно предыдущее значение, но не решает конфликт серверных изменений, offline, retry и прав доступа. Если сохранение идёт по сети, нужно отдельно решить, отменяется ли запрос или создаётся compensating change. Нельзя называть локальную отмену глобальным rollback.

\n

Native control часто даёт готовую семантику и клавиатурное поведение. Custom widget нужен только при ясной причине. Если его оставляют, keyboard contract, focus route и state mapping должны быть частью того же изменения. Атрибут role без соответствующего поведения не делает элемент доступным.

\n

Проверяемый критерий готовности

\n

Изменение готово, когда одна и та же проверяемая последовательность проходит для двух веток. При ошибке сохранённое значение остаётся прежним, панель остаётся доступной для исправления, сообщение связано с control, а фокус имеет существующий target. При успехе выбранное значение становится сохранённым, старое значение доступно для предусмотренной отмены, панель закрывается предсказуемо, а фокус возвращается на заранее выбранный target или на явно обоснованный результат. В согласованной среде проверки DOM и keyboard path подтверждают этот контракт. Если проверена только модель или только визуальный экран, готовность ещё не доказана.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/206.json b/editorial/agent-rewrites/206.json new file mode 100644 index 0000000..5190aad --- /dev/null +++ b/editorial/agent-rewrites/206.json @@ -0,0 +1,7 @@ +{ + "index": 206, + "slug": "editorial-2022-04-mechanism-accessible-interface", + "title": "Доступный control начинается с контракта состояния, имени и фокуса", + "excerpt": "Как связать семантику, состояние и маршрут клавиатуры в одном интерактивном компоненте и не принять роль ARIA или учебный тест за доказательство доступности.", + "contentHtml": "

Компонент может выглядеть исправным и ломаться уже на первом действии без мыши. Кнопка открывает панель, но после закрытия фокус исчезает. Выбор подсвечивается, но его доступное состояние не меняется. Ошибка в поле видна цветом, но сохранённое значение уже перезаписано. Человек теряет точку продолжения и не понимает, что произошло. Команда получает дефект, который трудно воспроизвести: глазами интерфейс выглядит готовым.

\n

Цена ошибки растёт на каждом слое. Пользователь повторяет действие или покидает сценарий. Тест проверяет только текст и пропускает потерянный фокус. Следующая правка добавляет ещё один эффект и ещё одну копию состояния. Разметка содержит role=\"button\", но не содержит поведения, которое это слово обещает.

\n

Тезис статьи простой: доступный control нужно проектировать как единый контракт. Контракт отвечает на пять вопросов: какое имя услышит пользователь, какую роль и состояние получит технология, кто владеет значением, какие клавиши меняют его и куда переходит фокус после каждого исхода. Если ответ существует только в CSS или в отдельном обработчике, интерфейс уже имеет разрыв.

\n

Сначала семантика, затем внешний вид

\n

Нативный button обычно безопаснее произвольного div. Браузер уже знает, что элемент участвует в фокусе и активируется клавиатурой. Нативные input, label, fieldset и legend также дают связи, которые иначе придётся собрать вручную. Это не отменяет проверку имени, порядка и состояния. Это уменьшает число обязанностей автора.

\n

ARIA не превращает произвольный элемент в рабочий виджет. role=\"button\" сообщает семантику, но не добавляет обработку Enter и Space, видимый фокус, управление tabindex или возврат фокуса. role=\"radio\" не выбирает соседний вариант и не синхронизирует aria-checked с моделью. Поэтому custom control принимают целиком: разметка, обработчики, переходы состояния, клавиатурный маршрут и проверка входят в один набор изменений.

\n

Для группы каналов уведомлений достаточно сначала проверить, нужен ли custom widget. Если обычный fieldset с двумя radio подходит дизайну, он снимает часть риска. Если компонент обязан использовать ARIA-паттерн, его группа получает имя, каждый вариант получает имя и согласованное состояние, а клавиши следуют выбранному паттерну. Нельзя смешать поведение обычной группы и toolbar только потому, что так удобнее обработчику.

\n

Механизм: одно состояние, несколько представлений

\n

У панели есть два разных значения. channel — черновой выбор внутри открытой панели. savedChannel — последнее подтверждённое значение. Пока пользователь не нажал «Сохранить», черновик может меняться, а сохранённое значение должно оставаться прежним. Это правило защищает отрицательный путь: неверный выбор, закрытие без сохранения и отмена не должны менять результат.

\n

Отдельно хранится маршрут фокуса. В учебной модели это строка, например preferences-trigger или notification-email. Она не вызывает HTMLElement.focus() и не делает утверждений о браузере. Она фиксирует решение компонента: после открытия входом служит первый control, после ошибки — конкретное поле, после успешного закрытия — исходная кнопка. Реальный DOM и реальный accessibility tree требуют отдельной проверки.

\n
const panel = {\n  open: false,\n  channel: 'email',\n  savedChannel: 'email',\n  focus: 'preferences-trigger',\n  status: ''\n};\n\nfunction choose(next) {\n  if (!['email', 'sms'].includes(next)) {\n    panel.focus = 'notification-email';\n    panel.status = 'Выберите доступный канал';\n    return false;\n  }\n\n  panel.channel = next;\n  panel.focus = 'notification-apply';\n  return true;\n}\n\nfunction save() {\n  panel.savedChannel = panel.channel;\n  panel.open = false;\n  panel.focus = 'preferences-trigger';\n  panel.status = `Канал сохранён: ${panel.savedChannel}`;\n}
\n

Код показывает границу, а не готовый компонент. В нём нет DOM, таймеров, live region, браузера и screen reader. Он проверяет только порядок: недопустимое значение не коммитится, допустимое значение переводит маршрут к сохранению, а успешное сохранение возвращает пользователя к trigger. Если заменить panel.savedChannel = panel.channel на обновление до валидации, учебная проверка должна зафиксировать нарушение.

\n

В production обновление состояния и разметки должно иметь один источник истины. Визуальный selected, aria-checked, текст статуса и доступность кнопки сохранения не должны вычисляться из разных копий. Если UI показывает SMS, а семантическое состояние всё ещё говорит Email, проблема не в недостающем атрибуте. Проблема в том, что переход состояния разделили между владельцами.

\n

Симптом → причина → проверка → действие

\n
Диагностика разрыва контракта интерактивного компонента
СимптомПричинаПроверкаДействие
Tab пропускает вход или фокус исчезает после закрытияCustom host не имеет полного keyboard contract; return target не сохранён до unmountПройти Tab и Shift+Tab; записать вход, ошибку, закрытие и фактический focused elementВернуть native host либо определить keyboard path и явный return target
Активный вариант виден, но технология получает старое значениеCSS-класс и semantic state читают разные источникиСравнить visual state, accessible tree и значение state после одного выбораВыводить оба представления из одного state owner
Неверный ввод меняет сохранённые настройкиЧерновик и committed value склееныПодать значение вне allow-list и проверить savedChannel до и после ветки ошибкиВалидировать до commit; вернуть фокус к исправляемому control
Toast виден, но результат не понятен без зренияСообщение существует только как цвет или визуальный popupПроверить программно определяемый status в выбранной связке браузера и assistive technologyСвязать сообщение с изменением состояния и зафиксировать среду проверки
Роль добавили, но Enter и Space ведут себя по-разномуСемантика скопирована без поведения паттернаСопоставить обработчики и порядок клавиш с выбранным APG-паттерномИспользовать native control или реализовать весь паттерн, включая фокус
\n

Иллюстрация маршрута

\n
\"Схема
Схема показывает, какие данные должны идти вместе: имя и роль control, выбранное состояние, результат сохранения и маршрут фокуса. Иллюстрация учебная. Она не является снимком accessibility tree и не доказывает, что screen reader произнесёт статус.
\n

Схему удобно читать слева направо. Trigger открывает панель. Группа получает имя «Канал уведомлений». Вариант меняет черновик и semantic state. Ошибка возвращает маршрут к control, который нужно исправить. Успешное сохранение обновляет committed value, закрывает панель и возвращает фокус к trigger. Если в коде нет одной из этих стрелок, её нельзя считать «само собой разумеющейся».

\n

Порядок действий

\n
  1. Опишите сценарий. Назовите trigger, группу, варианты, действие сохранения, статус и точку возврата. Запишите нормальный и отрицательный путь.
  2. Выберите host. Проверьте, закрывает ли нативный элемент задачу. Не заменяйте его на div ради удобного CSS без оценки новой ответственности.
  3. Разведите состояния. Отделите draft от committed value. Опишите момент commit и условия, при которых он не происходит.
  4. Соберите semantic contract. Для каждого control зафиксируйте accessible name, role, state и связь с группой. Для статуса укажите, какое сообщение формируется.
  5. Опишите keyboard route. Укажите результат Tab, Shift+Tab, Enter, Space и стрелок только для тех ролей, которым они нужны. Укажите фокус после открытия, ошибки, отмены и сохранения.
  6. Защитите отрицательный путь. Подайте недопустимое значение, закройте панель без save и повторите действие undo, если оно предусмотрено. Сохранённое значение не должно измениться случайно.
  7. Проверьте реализацию. Сначала осмотрите DOM и доступное дерево выбранного браузера. Затем пройдите клавиатурный сценарий. После этого проверьте статус в согласованной связке браузера и assistive technology.
  8. Запишите границы. Укажите браузер, версию технологии, язык, сценарий и ограничения результата. Учебная модель может подсказать инвариант, но не заменяет эту запись.
\n

Ограничения и отрицательный путь

\n

Контракт одной панели не описывает весь продукт. Он не проверяет контраст, размер цели касания, локализацию, виртуальный курсор, модальные окна, сложную таблицу или конфликт глобальных горячих клавиш. Паттерн radio group нельзя автоматически перенести на combobox, menu или grid: у них другие роли, клавиши и правила фокуса.

\n

Даже правильный aria-label не исправляет неясное действие. Даже пройденный автоматический аудит не доказывает удобство сценария. APG даёт практический паттерн, но не является единственным доказательством для конкретной реализации. Нельзя заявлять production-результат по учебному коду или по наличию атрибута.

\n

Отрицательный путь важнее красивого happy path. Если save падает, сервер отвечает с ошибкой или компонент размонтируется, команда должна знать, останется ли draft, что увидит пользователь и куда уйдёт фокус. Если ответа нет, это не «крайний случай». Это незаписанная часть контракта. Её лучше определить до интеграции с сетью и до широкого рефакторинга.

\n

Критерий готовности

\n

Компонент можно считать готовым к следующей проверке, когда для каждого интерактивного control записаны имя и роль, state имеет одного владельца, draft не меняет committed value до commit, а keyboard route покрывает вход, ошибку, отмену и успешное завершение. На реальной странице тест должен подтвердить ожидаемый порядок фокуса и согласованность visual state с доступным состоянием.

\n

Проверяемый результат выглядит так: недопустимое действие оставляет сохранённое значение прежним; допустимое действие меняет его ровно один раз; после закрытия фокус оказывается на объявленной точке; status существует в программно определяемой форме; повторный undo не откатывает новое состояние. Для каждого пункта есть шаг, наблюдаемый результат и среда. Если есть только фраза «компонент доступен», проверка ещё не закончена.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/207.json b/editorial/agent-rewrites/207.json new file mode 100644 index 0000000..1d59c86 --- /dev/null +++ b/editorial/agent-rewrites/207.json @@ -0,0 +1,7 @@ +{ + "index": 207, + "slug": "editorial-2022-04-practice-accessible-interface", + "title": "Доступный интерфейс начинается с маршрута фокуса", + "excerpt": "Как связать имя, роль, состояние и фокус в одном интерактивном сценарии и проверить ошибку до того, как пользователь потеряет управление формой.", + "contentHtml": "

Панель настроек открывается по клику, но после нажатия Tab фокус уходит в неизвестное место. Клавиатурный пользователь не понимает, что панель появилась, а после сохранения фокус исчезает вместе с удалённым узлом. Другая частая ошибка выглядит тише: выбранный канал отмечен цветом и галочкой, но его имя и состояние не попадают в семантический слой. Цена ошибки — потерянное действие, повторный ввод и невозможность понять, сохранились ли данные.

Доступный интерфейс нужно проверять как маршрут состояния. Для каждого шага задайте имя элемента, его роль, текущее значение, точку фокуса и результат действия. Если одно из этих свойств живёт отдельно, компонент может выглядеть исправным и всё равно ломаться без мыши.

Тезис: у действия есть контракт

Интерактивный компонент не сводится к разметке и стилям. Его контракт описывает, что человек видит и что получает в ответ. Кнопка открытия имеет понятное имя и принимает фокус. Панель получает доступное имя. Группа вариантов сообщает выбранное значение. Ошибка оставляет прежнее сохранённое значение и даёт понятную точку исправления. После успешного закрытия фокус возвращается на кнопку, которая открыла панель, если сценарий не требует другой точки.

Этот порядок связывает четыре слоя: семантику, состояние, клавиатурное поведение и визуальный сигнал. Атрибут role не добавляет обработчик клавиши, не рисует focus ring и не возвращает фокус после удаления элемента. Если проект заменяет нативный button на div, он берёт на себя весь недостающий контракт. Поэтому первый выбор — сохранить нативный элемент. Custom control нужен только тогда, когда его поведение описано и проверено целиком.

Сценарий панели уведомлений

Рассмотрим небольшую панель «Настроить уведомления». На странице есть кнопка открытия. В панели пользователь выбирает один канал: Email или SMS. Кнопка «Сохранить» подтверждает выбор. Внутри нужно различать черновое значение и сохранённое значение. Черновик меняется при выборе. Сохранённое значение меняется только после подтверждения.

При открытии фокус переходит на заголовок или первый пригодный для действия элемент — выбор зависит от паттерна и размера панели. Для этой панели выберем первый вариант Email. При недопустимом выборе, например carrier-pigeon, система не меняет сохранённый канал. Ошибка связывается с полем и оставляет фокус на месте исправления. После сохранения панель закрывается, фокус возвращается на notifications-trigger, а статус сообщает о результате в доступной форме.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
После открытия Tab продолжает идти по страницеПанель не получила точку входа, а фокус остался на trigger или ушёл в фонОткрыть панель клавиатурой и назвать первый ожидаемый focus targetЗадать маршрут входа и удерживать фокус в модальной области, если она действительно modal
Вариант отмечен цветом, но выбор не читаетсяVisual state и semantic state вычисляются из разных источниковСравнить значение в state с aria-checked или нативным checkedВывести оба сигнала из одного значения
Ошибка видна рядом с полем, но её не находят без мышиСообщение передаётся только цветом или не связано с controlПроверить label, связь ошибки и маршрут к invalid valueСохранить старое значение, назвать ошибку и вернуть фокус к исправлению
После сохранения клавиатура теряет позициюFocused node удалили вместе с панельюЗафиксировать activeElement до открытия и после закрытияВернуть фокус на trigger либо на логичную следующую точку
Статус есть в DOM, но результат неясенТекст статуса не связан с изменением состояния или заявлен без проверкиПроверить момент обновления и содержимое status regionОбновлять короткое сообщение после результата и отдельно проверить поддерживаемую связку браузера с assistive technology

Минимальная семантическая разметка

Нативная форма уже даёт много нужного поведения. Подпись связана с полем через for и id. Кнопка имеет явный тип. Группа вариантов имеет видимый заголовок. Не добавляйте ARIA ради похожего HTML: сначала проверьте, что нативный элемент выражает требуемый смысл.

<fieldset>\n  <legend>Канал уведомлений</legend>\n  <label>\n    <input type="radio" name="channel" value="email" checked>\n    Email\n  </label>\n  <label>\n    <input type="radio" name="channel" value="sms">\n    SMS\n  </label>\n  <p id="channel-error" role="alert"></p>\n</fieldset>\n<button type="submit">Сохранить</button>

Это учебный фрагмент. Он показывает связь подписи, группы и ошибки, но не доказывает поведение конкретного приложения. В рабочем коде нужно проверить порядок фокуса, стили состояния, локализацию, отправку формы и поведение при асинхронном сохранении. Если панель является модальным диалогом, добавьте корректные границы диалога, название и обработку закрытия; не называйте обычный раскрывающийся блок modal только ради атрибута.

Граница между состоянием и сообщением

Компоненту полезно хранить состояния, которые нельзя смешивать. channel — текущий выбор. savedChannel — подтверждённое значение. focusTarget — ожидаемая точка маршрута. statusText — объявленный результат. Если обработчик сразу записывает выбор в savedChannel, отрицательная ветка становится опасной: ошибка или отмена уже меняет данные.

Ниже учебная модель. Она не создаёт DOM, не отправляет события клавиатуры и не утверждает, что screen reader произнёс строку. Её задача — показать инвариант: недопустимое значение не меняет сохранённое, а успешное закрытие имеет явный return target.

const state = {\n  channel: 'email',\n  savedChannel: 'email',\n  focusTarget: 'notifications-trigger',\n  statusText: ''\n};\n\nfunction chooseChannel(value) {\n  if (!['email', 'sms'].includes(value)) {\n    state.statusText = 'Выберите Email или SMS';\n    state.focusTarget = 'channel-email';\n    return { ok: false, savedChannel: state.savedChannel };\n  }\n  state.channel = value;\n  state.focusTarget = 'save-notifications';\n  return { ok: true, savedChannel: state.savedChannel };\n}\n\nfunction save() {\n  state.savedChannel = state.channel;\n  state.focusTarget = 'notifications-trigger';\n  state.statusText = 'Канал уведомлений сохранён';\n  return { ok: true, returnFocus: state.focusTarget };\n}

Модель ограничена намеренно. Она проверяет переходы данных, но не browser accessibility tree. Реальная проверка должна подтвердить, что DOM отражает эти переходы: selected state меняется на том же шаге, ошибка связана с нужным control, а activeElement получает ожидаемую точку после открытия и закрытия.

Маршрут фокуса панели уведомлений: кнопка открытия ведёт к Email, ошибка возвращает к выбору, сохранение возвращает на кнопку.
Маршрут показывает две ветки: исправление ошибочного выбора и возврат после успешного закрытия. Это схема ожидаемого поведения, а не запись работы screen reader.

Порядок проверки

  1. Запишите наблюдаемый симптом и цену ошибки. Например: после открытия панели клавиатура продолжает обходить фон, а пользователь не может понять, где находится.
  2. Назовите каждый интерактивный элемент. Для него укажите доступное имя, роль, значение и действие. Если имя нельзя сформулировать одним предложением, остановите проверку разметки.
  3. Разделите черновое и сохранённое состояние. Проверьте, что invalid input и отмена не меняют подтверждённое значение.
  4. Опишите маршрут фокуса для входа, выбора, ошибки, сохранения и закрытия. Заранее назовите activeElement после каждого перехода.
  5. Пройдите сценарий только клавиатурой: Tab, Shift+Tab, Enter и Space там, где они предусмотрены выбранным паттерном. Убедитесь, что видимый focus indicator не закрывает контент и не исчезает.
  6. Проверьте DOM и accessibility tree в поддерживаемом браузере. Сверьте name, role, state, label, error и status с записанным контрактом.
  7. Проверьте отрицательный путь: неизвестное значение, пустой ввод, ошибка сохранения и закрытие без подтверждения. Зафиксируйте, что остаётся неизменным и куда возвращается фокус.
  8. Проверьте одну-две целевые связки браузера и ассистивной технологии. Запишите версии и фактический результат; объявленный текст в state не заменяет это наблюдение.
  9. После исправления повторите тот же маршрут и добавьте автоматическую проверку для стабильных переходов состояния. Автотест не должен выдавать проверку DOM за исследование пользовательского опыта.

Ограничения

Контракт одного компонента не делает доступным весь продукт. Он не проверяет контраст, масштабирование, порядок заголовков, локализацию, touch target, виртуальный курсор, тайм-ауты и работу при нестабильной сети. Он также не заменяет тестирование с людьми, которые используют разные способы ввода и разные assistive technology.

Нативный элемент снижает объём собственной логики, но не отменяет проверку. Можно скрыть focus ring стилями, дать кнопке пустое имя, обновить текст без изменения состояния или закрыть панель раньше завершения сохранения. ARIA помогает передать семантику, но не сообщает, что UX удобен и что реальная программа чтения экрана произнесёт ожидаемую фразу.

Учебный код выше не является production-результатом. Он не измеряет долю ошибок, скорость исправления и совместимость со всеми браузерами. Не подставляйте такие примеры в отчёт как доказательство качества. Production-критерий должен опираться на фактический DOM, клавиатурный проход и зафиксированную комбинацию браузера с assistive technology.

Критерий готовности

Сценарий готов к выпуску, когда команда может показать один и тот же проверяемый маршрут: открыть панель, увидеть фокус, назвать control, выбрать значение, получить понятную ошибку без изменения сохранённых данных, успешно сохранить и вернуть фокус на логичную точку. Для каждого перехода есть наблюдаемое свойство DOM или state. Для объявлений есть отдельная проверка поддерживаемой связки браузера и assistive technology. Если хотя бы один переход описан словами «должно работать», а не наблюдаемым результатом, компонент ещё не готов.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/208.json b/editorial/agent-rewrites/208.json new file mode 100644 index 0000000..dceb344 --- /dev/null +++ b/editorial/agent-rewrites/208.json @@ -0,0 +1,7 @@ +{ + "index": 208, + "slug": "editorial-2022-03-field-browser-rendering", + "title": "Тяжёлый кадр в браузере: как найти причину и проверить обратимую правку", + "excerpt": "Если интерфейс дёргается, широкий участок Performance trace ещё не объясняет причину. Разбираем один пользовательский шаг через DOM-результат, владельца изменения, узкий диапазон записи и rollback.", + "contentHtml": "

Пользователь нажимает «Сохранить», карточка должна перейти из card is-pending в card is-ready, но интерфейс на мгновение замирает. В Performance panel виден широкий участок main thread с JavaScript, style, layout и paint. Если назвать весь участок «медленным рендером», команда может удалить нужную мутацию, перенести работу в другой callback или оптимизировать не тот экран. Цена ошибки — потерянный визуальный результат, новый дефект вместо ускорения и повторное расследование после релиза.

\n

Тезис простой: тяжёлый кадр нужно разбирать от наблюдаемого результата к конкретному владельцу изменения. Сначала зафиксируйте, что изменилось в DOM. Затем свяжите одно действие пользователя с узким диапазоном trace. После этого проверьте одну гипотезу маленькой обратимой правкой. Только новая запись, в которой сохранился DOM-результат, позволяет обсуждать эффект правки.

\n

Механизм: результат важнее ярлыка в trace

\n

Браузер не получает от приложения готовую команду «сделай layout». Код меняет DOM, CSS-классы, inline-стили, текст или геометрию. User agent сам обновляет представление документа и планирует нужные этапы. В trace эти этапы показываются как события выбранной версии браузера. Поэтому слова style, layout, paint и composite помогают сформулировать вопрос, но сами по себе не указывают владельца.

\n

Владелец находится в коде и в контракте результата. Для примера контракт выглядит так: пользователь выполняет один submit; target карточки остаётся тем же; className меняется с card is-pending на card is-ready; при ошибке класс не меняется; rollback возвращает исходное состояние. Если trace стал короче, но класс больше не меняется, оптимизация не прошла функциональную проверку.

\n
const before = card.className;\n\nfunction onSave(card, response) {\n  if (!response.ok) {\n    return { result: 'error', className: before };\n  }\n\n  const nextClassName = 'card is-ready';\n  card.className = nextClassName;\n  return { result: 'ready', className: nextClassName };\n}\n\nfunction rollback(card, before) {\n  card.className = before;\n}
\n

Код учебный. Он не измеряет длительность, не вызывает настоящий network request и не доказывает, что браузер нарисовал карточку в конкретном кадре. Его задача — показать границу наблюдения: до оптимизации известны before и after, а у изменения есть явная отмена. В рабочем компоненте тот же контракт нужно связать с реальным обработчиком, состоянием ошибки и снимком DOM.

\n

Симптом → причина → проверка → действие

\n
Диагностика одного тяжёлого кадра
СимптомПричинаПроверкаДействие
Кнопка отвечает с задержкойВ запись попали соседние timers, сеть или расширениеПовторить один submit на чистом экране и выделить его interactionОтделить диапазон действия от соседних событий
Trace широкий, причина неяснаИщут общий ярлык вместо связи с DOM-результатомСравнить target, property, before и afterВернуться к mutation и назвать одного владельца
После правки график спокойнее, класс не меняетсяУдалили функциональную мутацию вместе с лишней работойПроверить DOM after тем же сценариемОткатить правку и разделить визуальный результат и оптимизацию
Появился layout, но действие не меняло геометриюПроверяется не тот range или другой код прочитал geometryНайти вызов чтения размеров и его стекПроверить один geometry read, а не весь layout
Повторная запись даёт другой traceИзменились CPU, viewport, cache, данные или версия браузераЗафиксировать среду и повторить сценарий несколько разСравнивать только согласованные условия и сохранять запись
«Улучшение» нельзя отменитьНе определены старое состояние и путь rollbackВернуть before отдельным запуском и проверить DOMСделать изменение локальным, переключаемым или отдельным commit
\n

Пример: одна interaction и один DOM-result

\n

Представим форму сохранения карточки. До действия в DOM есть <article class=\"card is-pending\">. Пользователь нажимает кнопку. Обработчик отправляет данные и после успешного ответа добавляет is-ready. В учебном сценарии тяжёлую работу связывают с чтением геометрии сразу после записи стиля. Это гипотеза, а не утверждение о любом браузере: она должна быть подтверждена стеком вызовов и новой записью.

\n
function updateCard(card) {\n  card.classList.add('is-ready');\n  const height = card.getBoundingClientRect().height;\n  logLayoutDependentValue(height);\n}
\n

Такой порядок может потребовать дополнительной работы по обновлению стилей и layout, но по одному фрагменту нельзя заключить, что именно он вызвал задержку. Возможно, размер уже был нужен другому коду. Возможно, запись попала в другой пользовательский сценарий. Проверка должна ответить на узкий вопрос: связан ли этот обработчик и этот read с выбранным участком записи, и сохраняется ли is-ready после изменения?

\n

Отрицательный путь обязателен. Если сервер вернул ошибку, карточка должна остаться is-pending, сообщение об ошибке должно остаться доступным пользователю, а trace нельзя объявлять успешным только потому, что в нём стало меньше событий. Если выбранный элемент уже имел is-ready, действие может оказаться no-op. Тогда новая запись не проверяет переход состояния: сначала нужно вернуть исходный state и повторить сценарий.

\n
\"Схема
Учебная схема связывает DOM before/after с одной interaction и одной гипотезой. Ветка no-op останавливает разбор, а rollback возвращает исходный результат. Схема не является снимком DevTools trace и не содержит production-метрик.
\n

Как читать Performance panel

\n

Начинайте запись на уже подготовленной странице. Выполните одно действие и остановите запись сразу после появления ожидаемого результата. Не включайте в один профиль загрузку, несколько кликов и длинный период ожидания. Чистое воспроизведение не делает измерение идеальным, но уменьшает число несвязанных событий.

\n

После записи сначала найдите пользовательский шаг, а не самый длинный прямоугольник. Выделите interaction и проверьте, какие вызовы находятся под ней на main thread. Затем сопоставьте их с обработчиком. Скриншот кадра помогает увидеть, что видел пользователь, но не доказывает причину. Summary показывает разбиение работы, а flame chart — порядок событий и стек вызовов. Ни один из этих видов не заменяет сравнение DOM before/after.

\n

Если подозрение связано со стилями или геометрией, ограничьте вопрос. «Почему layout длинный?» слишком широко. «Какой вызов прочитал геометрию после записи класса в этом обработчике?» уже можно проверить. Если выбранный range не содержит такого обработчика, остановитесь: trace и код не связаны. Если mutation не даёт ожидаемый DOM-result, остановитесь ещё раньше.

\n

Обратимая правка

\n

У первой правки должны быть четыре поля: один owner, ожидаемый DOM after, способ проверить trace и путь rollback. Например, временно вынести один geometry read из обработчика, локально выключить визуальный эффект через feature toggle или изменить один selector. Не меняйте одновременно рендер списка, кеш, анимацию и порядок сетевых запросов. Иначе новая запись не скажет, какое изменение повлияло на результат.

\n

После правки повторите тот же сценарий в той же среде. Проверьте две вещи: карточка по-прежнему получает is-ready, а выбранный участок записи изменился так, как предполагала гипотеза. Если DOM сохранился, но trace не подтверждает гипотезу, это не провал. Это отрицательный результат: выберите другой owner или верните код. Если DOM сломался, rollback должен вернуть исходное поведение, а не просто удалить строку из diff.

\n
const baseClassName = 'card is-pending';\n\nfunction applyForward(card) {\n  card.className = 'card is-ready';\n}\n\nfunction applyRollback(card) {\n  card.className = baseClassName;\n}\n\nfunction assertResult(card, expected) {\n  if (card.className !== expected) {\n    throw new Error(`unexpected DOM result: ${card.className}`);\n  }\n}
\n

Этот пример проверяет только наблюдаемое состояние. Он не измеряет FPS, не моделирует планировщик и не доказывает отсутствие layout. Production-решение требует реальной записи и подходящего набора пользовательских сценариев. Учебный код полезен как каркас вопроса, но не как evidence.

\n

Порядок действий

\n
  1. Опишите симптом. Запишите действие, видимый сбой и цену ошибки. Например: «после submit карточка появляется с задержкой, а при ошибке состояние остаётся неясным».
  2. Зафиксируйте контракт. Сохраните target, property, DOM before, DOM after для успеха и DOM after для ошибки. Укажите, что считается no-op.
  3. Подготовьте среду. Зафиксируйте браузер, viewport, CPU throttling, данные, cache и наличие расширений. Снимите запись на чистом экране.
  4. Запишите одну interaction. Выполните один пользовательский шаг. Остановите запись сразу после visual result. Не смешивайте несколько гипотез.
  5. Свяжите trace с кодом. Найдите обработчик, mutation и выбранный range. Если связи нет, не называйте причину.
  6. Сформулируйте гипотезу. Назовите одного owner и один проверяемый вопрос: например, влияет ли конкретный geometry read на работу после mutation.
  7. Внесите одну обратимую правку. Измените небольшой участок, сохраните способ rollback и не меняйте контракт результата.
  8. Повторите запись. Сравните согласованные диапазоны, но не переносите synthetic units или локальные числа на production.
  9. Проверьте обе ветки. Успешный путь должен дать ожидаемый DOM after. Ошибка и no-op не должны маскироваться под успешный переход.
  10. Зафиксируйте вывод. Оставьте среду, шаг, before/after, ссылку на код, range, гипотезу, результат повторной записи и способ rollback.
\n

Ограничения

\n

Разбор Performance panel относится к конкретной среде. Браузер, версия DevTools, устройство, throttling, размер viewport, расширения, cache, данные и состояние страницы меняют запись. Даже повтор одного сценария может дать другой порядок и длительность работы. Поэтому нельзя переносить вывод из Chrome на Firefox или Safari без отдельной проверки и нельзя считать локальную запись полевым измерением пользовательского опыта.

\n

Названия этапов в trace не образуют универсальный авторский pipeline. HTML Standard описывает поведение user agent, а DevTools показывает представление записи конкретного инструмента. requestAnimationFrame не является гарантией, что весь нужный rendering завершится до следующей строки. CSS-анимация, compositor, изображения, шрифты, layout containment, сеть и расширения могут изменить картину.

\n

Не объявляйте улучшением уменьшение одного участка без функциональной проверки. Работа может переместиться в другой callback, появиться при другом viewport или проявиться только на слабом устройстве. Если после правки исчезла карточка, сообщение или error state, более короткая timeline ничего не доказывает. Если rollback возвращает только класс, но не возвращает сетевой или серверный результат, это локальная отмена, а не полный rollback операции.

\n

Проверяемый критерий готовности

\n

Исследование готово, когда другой инженер может повторить один сценарий в указанной среде, увидеть DOM before/after, открыть выбранный range и найти связь с конкретной mutation. Для правки записаны owner, ожидаемый результат, наблюдаемый эффект и rollback. Успешная ветка сохраняет функциональный DOM-result. Ошибка и no-op проходят отдельно и не выдаются за успешное изменение. Если этих артефактов нет, формулировка «ускорили рендер» остаётся предположением.

\n

Минимальный итог можно проверить четырьмя вопросами: что сделал пользователь; какой DOM-результат изменился; какой код-владелец связан с range; что произошло после обратимой правки. На каждый вопрос должна быть ссылка на повторяемый шаг или сохранённую запись. Числа из учебного примера, цвет блока в trace и субъективное ощущение плавности не заменяют эту проверку.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/209.json b/editorial/agent-rewrites/209.json new file mode 100644 index 0000000..08cdb2c --- /dev/null +++ b/editorial/agent-rewrites/209.json @@ -0,0 +1,7 @@ +{ + "index": 209, + "slug": "editorial-2022-03-mechanism-browser-rendering", + "title": "Почему браузер не успевает отрисовать изменение интерфейса", + "excerpt": "Разбор рывка после изменения DOM: как отделить работу JavaScript от style, layout, paint и composite, проверить одну запись и не исправить производительность ценой сломанного состояния интерфейса.", + "contentHtml": "

Кнопка меняет класс карточки, но экран отвечает с рывком. В Performance panel виден длинный участок работы. Команда называет виновником то JavaScript, то CSS и сразу меняет код. Это опасный диагноз. Если убрать обработчик, интерфейс может стать плавнее только потому, что карточка перестанет переходить в состояние «готово». Цена ошибки — потерянный пользовательский результат, лишний релиз и новая задержка, которую теперь труднее связать с причиной.

\n

Тезис статьи простой: изменение DOM — это вход, а видимое состояние — выход. Между ними браузер выполняет несколько видов работы. Их удобно разделить на вопросы: кто сделал mutation, какие стили стали применимы, нужна ли новая геометрия, какую область надо закрасить и как собрать результат на экране. Этот порядок помогает сузить гипотезу. Он не является обещанием одинакового внутреннего конвейера во всех браузерах.

\n

Что происходит после изменения DOM

\n

Рассмотрим учебный, но реалистичный сценарий. После отправки формы обработчик меняет класс карточки с card is-pending на card is-ready. Новый класс меняет цвет, высоту и подпись. Сначала нужно доказать сам факт изменения. Если className не изменился или выбран не тот элемент, искать дорогой paint бессмысленно.

\n
const card = document.querySelector('.card');\n\nfunction markReady() {\n  card.className = 'card is-ready';\n}\n\nbutton.addEventListener('click', markReady);
\n

После mutation браузер пересчитывает применимые стили. Если свойства влияют на геометрию, он может пересчитать положение и размеры элементов. Затем он подготавливает пиксели для изменившейся области. В некоторых случаях отдельные слои можно собрать без полной перекраски. В DevTools эти этапы могут отображаться разными событиями, объединяться или отсутствовать в выбранном диапазоне. Поэтому названия style, layout, paint и composite ниже — рабочие labels для расследования, а не API-контракт.

\n

JavaScript часто запускает цепочку, но не равен всей цепочке. Обратная ошибка тоже встречается: строку CSS считают причиной только потому, что после неё виден layout. Нужна связь с действием пользователя, конкретным DOM-result и выбранным диапазоном записи.

\n

Один пример: как чтение геометрии усиливает работу

\n

Проблема становится заметнее, когда код сначала меняет стиль, а затем немедленно читает геометрию. Браузер ещё может откладывать расчёт. Чтение offsetHeight требует актуального значения, поэтому движок вынужден завершить нужную часть расчёта прямо внутри обработчика.

\n
function resizeCard() {\n  card.style.width = '320px';\n  const height = card.offsetHeight;\n  card.style.height = `${height}px`;\n}
\n

Этот пример учебный. Он не доказывает, что каждый вызов приведёт к forced layout в каждом движке. Он показывает условие для проверки: запись стиля и чтение геометрии идут рядом, а между ними нет границы, на которой можно увидеть фактическую работу. В реальном расследовании надо открыть запись выбранного браузера и проверить, какой обработчик вызвал layout и какие узлы попали в расчёт.

\n

Без чтения записи нельзя объявлять свойство «дорогим» навсегда. Иногда браузер обновит только часть дерева. Иногда layout уже был нужен по другой причине. Иногда изменение попадёт в отдельный слой и не потребует полной перекраски. Поэтому заменять всё на transform или удалять visual state без проверки результата — такой же плохой путь, как игнорировать layout.

\n

Как читать причинную цепочку

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
После клика меняется не тот элементНеверный target или selectorСравнить DOM before/after в Elements и код обработчикаИсправить контракт изменения; не оптимизировать trace
Долгий участок появляется после записи классаНовый стиль требует расчёта геометрии или paintВыделить одну interaction и посмотреть связанные Layout/Paint eventsПроверить одно свойство или правило и повторить запись
Layout находится внутри обработчикаЧтение геометрии после записи стиляПерейти из события к строке кода, которая читает размер или позициюРазнести запись и чтение, либо проверить другой способ обновления
График стал короче, но UI не меняетсяОптимизация убрала функциональный resultСверить ожидаемый className, текст и размеры after-stateОткатить изменение и выбрать более узкую гипотезу
В разных браузерах видны разные событияРазные движки и версии по-разному показывают внутреннюю работуЗафиксировать браузер, версию, сценарий и диапазон записиСравнивать результат и пользовательский симптом, а не одинаковые labels
\n

Иллюстрация одной проверки

\n
\"Схема
Схема разделяет вопросы расследования. Она не утверждает, что выбранный браузер всегда создаёт пять отдельных событий с такими именами.
\n

Начните слева с DOM-контракта: какой элемент меняется, какое свойство записывается, что было до изменения и какой результат ожидается. Затем связывайте каждый следующий вопрос с одной и той же записью. Если after-state не совпал с ожиданием, проблема ещё функциональная. Если результат совпал, ищите работу, которая возникла после конкретной mutation. Только после этого выбирайте правку.

\n

Порядок расследования

\n
  1. Опишите симптом одним предложением: действие, видимый сбой и цену для пользователя.
  2. Зафиксируйте DOM before и after. Укажите target, свойство и обработчик.
  3. Откройте Performance panel в согласованном браузере и запишите только один сценарий, без серии лишних кликов.
  4. Найдите interaction и сузьте диапазон до действия. Не делайте вывод по всей временной шкале.
  5. Сопоставьте событие с кодом. Проверьте, есть ли после mutation чтение геометрии, массовое изменение DOM или правило, меняющее размеры.
  6. Сформулируйте одну гипотезу и измените только один owner: обработчик, правило CSS или способ обновления свойства.
  7. Повторите тот же сценарий. Сравните не только длительность работы, но и DOM after, визуальный результат и отсутствие нового симптома.
  8. Сохраните способ отката. Если гипотеза не подтверждается, верните прежний код и начните с другого участка цепочки.
\n

Отрицательный путь: когда оптимизировать нечего

\n

Есть два честных случая остановки. Первый — no-op: код записывает то же значение, которое уже установлено. Нового DOM-result нет, поэтому нельзя строить историю «дорогого кадра» только по факту клика. Проверьте, не повторяется ли действие из-за двойного обработчика или лишнего рендера.

\n

Второй — invalid update. Обработчик выбирает отсутствующий элемент, передаёт не то свойство или рассчитывает значение из устаревшего состояния. Такой путь нельзя считать быстрым кадром с нулевой стоимостью. Сначала исправьте контракт и повторите запись. Иначе оптимизация скроет функциональную ошибку.

\n

Ограничения модели

\n

Учебная цепочка не отвечает на вопрос, какое CSS-свойство всегда дёшево. Стоимость зависит от дерева, размера изменённой области, движка, версии браузера, устройства, шрифтов, изображений, анимации и соседней работы. composite также не означает автоматически «бесплатно»: сборка слоёв использует ресурсы устройства и может стать узким местом.

\n

Нельзя переносить примерные числа из диаграмм или одного trace в production-бюджет. Нельзя объявлять проблему доказанной по цвету события в панели. Нельзя сравнивать две записи, если изменились браузер, throttling, состояние данных или сам DOM-result. Источники объясняют терминологию и инструмент. Они не заменяют запись именно вашего сценария.

\n

Проверяемый критерий готовности

\n

Расследование готово, когда есть один воспроизводимый сценарий, ссылка на обработчик, DOM before/after, выделенный диапазон записи и одна подтверждённая или опровергнутая гипотеза. После правки тот же сценарий даёт ожидаемый after-state, а выбранная работа уменьшилась или стала понятнее без переноса симптома в другое место. Если это учебный пример, так и пометьте его. Не называйте условные значения миллисекундами и не выдавайте их за результат production-измерения.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/210.json b/editorial/agent-rewrites/210.json new file mode 100644 index 0000000..34a9627 --- /dev/null +++ b/editorial/agent-rewrites/210.json @@ -0,0 +1,7 @@ +{ + "index": 210, + "slug": "editorial-2022-03-practice-browser-rendering", + "title": "Медленный рендер: как связать DOM-изменение с причиной в кадре", + "excerpt": "Если интерфейс дёргается после одного действия, не называйте виновником весь браузер. Зафиксируйте DOM-изменение, отделите JavaScript от работы rendering pipeline и проверьте гипотезу одной записью Performance panel.", + "contentHtml": "

После нажатия «Сохранить» карточка должна сменить состояние, но экран на мгновение замирает. Иногда задержка заметна только на слабом ноутбуке. Иногда она появляется после добавления списка, тени или анимации. Команда видит слово «медленный рендер» и начинает менять всё сразу: переносит обработчик, добавляет debounce, убирает CSS и обвиняет фреймворк. Цена такой ошибки — потраченные часы и изменённый интерфейс без доказательства, что причина исчезла.

\n

Рабочий тезис проще: исследуйте одно пользовательское действие и один наблюдаемый результат DOM. Сначала зафиксируйте, что сделал JavaScript. Затем проверьте, какая работа последовала за этим изменением в конкретной записи. Слова style, layout, paint и composite удобны как вопросы к записи. Они не гарантируют одинаковый внутренний порядок во всех браузерах и не заменяют trace.

\n

Симптом и граница задачи

\n

Возьмём карточку заказа с кнопкой save-card. До клика у неё класс card is-pending. После успешного ответа интерфейс должен получить card is-ready. Сетевой запрос в этой статье не является предметом измерения. Нас интересует момент, когда обработчик применяет новый класс, и работа, которую браузер выполняет, чтобы показать результат.

\n

У такой задачи есть проверяемая граница: один клик, один target, одно свойство и два состояния. Не «страница тормозит», а «после клика на save-card меняется className с X на Y». Граница не ускоряет страницу сама. Она убирает лишние события из расследования и позволяет повторить тот же сценарий после правки.

\n
\"Учебная
Учебная timeline помогает задать порядок вопросов. Она не измеряет миллисекунды и не является записью Performance panel.
\n

Механизм: от записи JavaScript к кадру

\n

JavaScript меняет состояние документа. Например, обработчик записывает новое значение className. После этого user agent может пересчитать применимые стили, геометрию и визуальное представление. Часть работы может быть объединена, отложена или выполнена иначе. Приложение не получает универсальный контракт «после каждой записи всегда будут пять событий».

\n

Для диагностики полезна учебная цепочка. js-mutation означает изменение DOM. style задаёт вопрос о применимых стилях. layout — вопрос о геометрии. paint — вопрос об обновлении визуального представления. composite — вопрос о сборке результата. Это карта рассуждения, а не названия, которые нужно буквально искать в каждом trace.

\n

Модель помогает увидеть и отрицательный путь. Если обработчик записал тот же класс, новый visual result мог не появиться. Если выбранный элемент не меняется, длинный участок записи может относиться к таймеру, соседней анимации или расширению браузера. Если после правки исчезла задержка, но исчез и обязательный класс is-ready, это не исправление. Функциональный результат и производительность нужно проверять вместе.

\n

Минимальный пример

\n

Ниже — учебный код. Он показывает границу действия и ставит метки, чтобы связать запись с обработчиком. Он не обещает конкретное время, FPS, INP или порядок внутренних событий браузера.

\n
const card = document.querySelector('#save-card');\n\nfunction setCardState(from, to) {\n  if (!card || card.className !== from) {\n    return false;\n  }\n\n  performance.mark('card-state-before');\n  card.className = to;\n  performance.mark('card-state-after');\n  performance.measure(\n    'card-state-change',\n    'card-state-before',\n    'card-state-after'\n  );\n  return true;\n}\n\nbutton.addEventListener('click', () => {\n  setCardState('card is-pending', 'card is-ready');\n});
\n

Метки ограничивают время JavaScript между двумя точками. Они не доказывают длительность layout или paint. Для этого нужна запись исполнения в выбранной версии браузера. В реальном проекте сохраните также состояние до и после. Иначе вы можете сравнить две записи, в которых обработчик сделал разные вещи.

\n

Симптом → причина → проверка → действие

\n
Рабочая таблица для одного DOM-изменения
СимптомПричинаПроверкаДействие
После клика интерфейс отвечает рывкомВ одну проблему смешаны handler, layout и соседние событияЗаписать один клик и выделить его диапазонСузить запись до interaction и одного target
Длинный участок есть, но владелец неясенНе зафиксированы before и afterПроверить класс в Elements и место записи в кодеОбновить контракт действия или остановить исследование
После изменения CSS стало «быстрее»Исчез визуальный результат, а не причинаСверить className, геометрию и screenshotОткатить правку и проверить другой участок
В модели нет новой работыfrom и to совпали либо выбран не тот элементПроверить no-op и повторить действие с чистым состояниемНе искать layout в записи без новой мутации
Стадии trace отличаются от схемыУчебные labels приняли за API браузераСверить документацию версии и фактические событияОписать наблюдаемую работу, не переименовывать её насильно
\n

Порядок проверки

\n
  1. Опишите симптом как действие и наблюдаемый эффект: «после клика на save-card экран отвечает неровно, класс должен стать card is-ready».
  2. Зафиксируйте target, свойство, значение до и значение после. Проверьте исходный DOM в Elements, а не только исходный код.
  3. Откройте страницу в согласованном состоянии. Остановите лишние действия, очистите состояние сценария и запишите только один клик в Performance panel.
  4. Найдите interaction и узкий диапазон вокруг неё. Сначала проверьте handler и DOM-result, затем рассматривайте связанные участки rendering.
  5. Выберите одну гипотезу: лишний geometry read, широкий selector, дорогая визуальная область или лишняя работа JavaScript. Не меняйте несколько причин за один эксперимент.
  6. Внесите обратимую правку. Сохраните ожидаемый DOM-result и опишите, какое наблюдение должно измениться, если гипотеза верна.
  7. Повторите тот же сценарий в той же среде. Сравните запись, функциональный результат и отрицательный путь: no-op, ошибка или неверный target.
  8. Сохраните trace или краткую ссылку на результат вместе с версией браузера и условиями запуска. Без этих условий цифры нельзя честно сравнивать.
\n

Что делать, если гипотеза не подтверждается

\n

Если DOM после клика не соответствует контракту, остановитесь. Вы исследуете другой путь. Если метка JavaScript есть, а ожидаемой работы после неё нет, не делайте вывод «рендер бесплатный»: работу могли объединить или перенести. Если запись показывает длинный участок вне выбранной interaction, не приписывайте его кнопке. Сначала повторите сценарий с чистым состоянием.

\n

Не отключайте проверку стилей, не удаляйте обязательный визуальный результат и не добавляйте таймер как доказательство. requestAnimationFrame может помочь синхронизировать учебный эксперимент с обновлением кадра, но он не превращает callback в измерение причины. PerformanceObserver сообщает о поддерживаемых performance entries; он также не раскрывает весь внутренний pipeline и не заменяет запись DevTools.

\n

Ограничения

\n

Стоимость работы зависит от DOM, CSS, размера обновляемой области, устройства, браузера и состояния страницы. Один trace не описывает все устройства. Chrome DevTools показывает собственные представления и меняет интерфейс между версиями. Firefox и Safari могут группировать или называть события иначе. Даже в одном браузере кеш, шрифты, расширения, throttling и фоновые задачи меняют результат.

\n

Учебный пример с пятью labels и synthetic units нужен только для проверки порядка рассуждения. Synthetic units не являются миллисекундами, CPU time, FPS, LCP, INP или production-данными. В статье нет утверждения, что конкретная правка ускорила реальный продукт. Реальный вывод появляется только после повторяемой записи на нужном сценарии и подтверждения, что функциональный результат сохранился.

\n

Критерий готовности

\n

Диагностика готова, если выполнены четыре условия: записано одно действие; DOM before и after совпадают с контрактом; выбранный участок trace связан с этим действием и описан фактическими, а не учебными названиями; после обратимой правки повторная запись сравнима с исходной, а карточка сохраняет состояние is-ready. Если хотя бы одно условие не выполнено, результатом является новая гипотеза, а не заявление «рендер исправлен».

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/211.json b/editorial/agent-rewrites/211.json new file mode 100644 index 0000000..a730ab4 --- /dev/null +++ b/editorial/agent-rewrites/211.json @@ -0,0 +1,7 @@ +{ + "index": 211, + "slug": "editorial-2022-02-field-performance-budget", + "title": "Когда total зелёный, а компонент красный: как проверять бюджет производительности", + "excerpt": "Общий score может пройти, пока отдельная часть маршрута уже превысила допуск. Разбираем контракт сравнения, named budgets, отрицательный путь и проверяемое действие без выдуманных production-выводов.", + "contentHtml": "

Страница не стала заметно медленнее по общему числу, но один этап маршрута пересёк свой предел. CI показывает зелёный total, а отчёт рядом отмечает красный scriptTicks. Команда пропускает изменение, потому что итог выглядит безопасным. Цена ошибки — накопленная деградация: следующий релиз добавит ещё один небольшой расход, а найти момент поломки будет уже трудно.

\n

Бюджет производительности нужен не для одного красивого числа. Он разделяет маршрут на именованные части и проверяет каждую часть в одинаковых условиях. Если total равен 93 при допустимых 98, а scriptTicks равен 34 при допустимых 30, результат должен быть отказом компонента. Зелёная сумма не отменяет красную ветку. Это учебный пример с условными единицами. Он не сообщает скорость реальной страницы.

\n

Тезис: сначала контракт, потом цифры

\n

Сравнение baseline и candidate имеет смысл только при одном контракте. Контракт включает маршрут, сценарий, версию рабочей нагрузки, единицу измерения и условия запуска. К условиям относятся, например, состояние кеша, набор данных и профиль устройства. Если хотя бы одно условие изменилось, различие чисел нельзя назвать регрессией. Сначала нужно вернуть сопоставимость.

\n

После проверки контракта система проверяет named components. Для каждого компонента нужны четыре значения: фактическое значение, limit, tolerance и итоговый allowed. Формула проста: allowed = limit + tolerance. Компонент проходит, если actual <= allowed. Aggregate вычисляется отдельно. Он показывает запас общего бюджета, но не получает права скрывать отказ части.

\n

Такой порядок ограничивает вывод. Comparable PASS означает только то, что известные проверки прошли. Comparable FAIL означает, что при одинаковых условиях хотя бы один именованный компонент превысил предел. Несопоставимый вход не означает FAIL и не означает PASS. Он означает, что сравнение остановилось до интерпретации.

\n

Механизм на коротком примере

\n

Представим маршрут /training/checkout/review и сценарий анонимной корзины с одним товаром. В бюджете заданы четыре учебных компонента. В baseline скрипты занимают 26 условных тиков. В candidate — 34. Limit равен 28, tolerance — 2, поэтому allowed равен 30. Aggregate candidate равен 93 при aggregate allowed 98.

\n
const budget = {\n  components: {\n    documentTicks: { limit: 26, tolerance: 1 },\n    styleTicks: { limit: 16, tolerance: 1 },\n    scriptTicks: { limit: 28, tolerance: 2 },\n    renderTicks: { limit: 22, tolerance: 1 }\n  },\n  aggregateAllowed: 98\n};\n\nconst candidate = {\n  route: \"/training/checkout/review\",\n  scenario: \"anonymous-cart-with-one-item\",\n  conditions: { cache: \"warm\", workloadVersion: 1 },\n  timingFields: {\n    documentTicks: 24,\n    styleTicks: 15,\n    scriptTicks: 34,\n    renderTicks: 20\n  }\n};\n\nconst scriptAllowed =\n  budget.components.scriptTicks.limit +\n  budget.components.scriptTicks.tolerance;\n\nconsole.log(candidate.timingFields.scriptTicks <= scriptAllowed);\n// false: 34 > 30\nconsole.log(93 <= budget.aggregateAllowed);\n// true: aggregate не скрывает failed component
\n

В реальном коде проверка должна вернуть не только boolean. Отчёту нужны имя компонента, actual, allowed, status и граница следующего действия. Иначе человеку придётся восстановить причину по общей сумме. Это снова превращает бюджет в декоративный показатель.

\n

Следующий шаг после такого отказа не обязан быть большим. Сначала проверьте вход, который принадлежит scriptTicks: размер изменившегося bundle, новый dynamic import, число обработанных элементов или другой заранее выбранный источник. Если источник не определён, не называйте библиотеку причиной. Число 34 показывает нарушение допуска, но не объясняет его.

\n

Как читать отчёт

\n

Читайте результат сверху вниз. Сначала откройте validation и убедитесь, что baseline и candidate сравнимы. Затем посмотрите список failed components. После этого прочитайте actual и allowed конкретной ветки. Aggregate оставьте напоследок. Такой порядок не даёт зелёному total занять место решения.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Total PASS, component FAILСумма скрывает распределение расходовСравнить actual с allowed по имениСчитать overall FAIL и исследовать одну границу
Входы отличаютсяBaseline и candidate несопоставимыСверить route, scenario и conditionsОстановить verdict и повторить с одним контрактом
Все components PASS, total растётОбщий запас сокращаетсяСравнить aggregate с его пределомНайти компонент с наибольшим вкладом
Один компонент скачет между запускамиШум измерения или нестабильное условиеПроверить повторяемость и профиль запускаУточнить протокол до изменения limit
Причина не видна в отчётеБюджет хранит только числоПроверить наличие source и owner boundaryДобавить один наблюдаемый вход, а не гипотезу
\n

Иллюстрация границ вывода

\n
\"Схема
Сначала проверяется сопоставимость входов, затем named component и aggregate. Учебная схема показывает порядок решения, а не измерение конкретного production-маршрута.
\n

Иллюстрация важна из-за отрицательного пути. Если cache или scenario изменились, стрелка не должна вести к красной метрике. Система должна вернуть состояние «сравнение недействительно» и объяснить, какое условие разошлось. Если этого не сделать, изменение среды выглядит как изменение приложения.

\n

Порядок действий

\n
  1. Запишите симптом. Сохраните route, scenario, baseline, candidate и точное имя компонента. Не начинайте с предположения о виновной библиотеке.
  2. Проверьте контракт. Сверьте версию рабочей нагрузки, единицы, кеш, данные, профиль запуска и остальные объявленные условия. Любое различие блокирует числовой verdict.
  3. Проверьте allocation. Для каждого компонента посчитайте allowed = limit + tolerance. Укажите actual и границу рядом.
  4. Отделите component от aggregate. Сначала сформируйте список failed components. Общую сумму используйте как контекст, не как разрешение пропустить красную ветку.
  5. Сузьте исследование. Выберите одну границу: bundle, route input, dynamic import, обработку данных или другую реально наблюдаемую часть. Следующий сбор должен различать хотя бы две гипотезы.
  6. Повторите тот же compare. После небольшого изменения сохраните прежний контракт. Если маршрут, сценарий или условия изменились, создайте новый baseline и укажите причину, а не сравнивайте несопоставимые числа.
  7. Зафиксируйте предел вывода. Напишите, что результат подтверждает и чего не подтверждает. Учебные ticks не превращайте в browser trace, пользовательскую метрику или SLA.
\n

Как связать учебные поля с браузером

\n

Платформа даёт реальные источники, но не готовую таблицу для любого проекта. PerformanceNavigationTiming описывает временные отметки навигации текущего документа. User Timing даёт named marks и measures. Эти интерфейсы помогают выбрать источник для конкретного production-поля. Они не говорят, что условный scriptTicks равен времени выполнения JavaScript или что четыре учебных тика уже собраны браузером.

\n

Перед переносом модели составьте mapping для каждого поля: имя бюджета, источник, момент получения, единица, условия доступности, преобразование и владелец. Если поле зависит от браузера, укажите поддержку и fallback. Если данных нет, верните отсутствие данных. Не подставляйте похожее число из другого API только потому, что оно удобно для формулы.

\n

Например, navigation entry может описать загрузку документа, но не объяснить стоимость долгого обработчика после загрузки. Для пользовательского взаимодействия нужен отдельный источник и отдельная методика. Смешивание этих наблюдений в один total создаёт точный, но бессмысленный score. Named budget полезен только там, где его граница совпадает с тем, что действительно измеряется.

\n

Ограничения и отрицательный путь

\n

Бюджет не доказывает, что страница быстрая для всех пользователей. Один запуск не заменяет распределение по устройствам, сетям и сценариям. Aggregate не заменяет пользовательские метрики. Synthetic compare не является браузерным trace, если браузер не выполнял заданную процедуру. Учебные числа в примере нельзя выдавать за наблюдения реального сервиса.

\n

Есть и обратный путь после отказа. Если повторная проверка показывает, что изменился cache, а не код, не повышайте limit и не объявляйте regression. Исправьте условия и повторите сравнение. Если условия одинаковы, но компонент снова превышает allowed, собирайте один конкретный источник на его границе. Если источник не позволяет отличить причины, это ограничение знания, а не повод написать более сильный вывод.

\n

Повышение limit допустимо только как отдельное решение. Оно меняет защиту от роста и должно иметь владельца, причину и новый ожидаемый предел. Нельзя лечить failed component увеличением aggregate: это убирает сигнал, но не уменьшает стоимость маршрута.

\n

Проверяемый критерий готовности

\n

Проверка готова, когда система выполняет четыре условия. Она останавливает compare при различии контракта. Она показывает каждый component actual, limit, tolerance и allowed. Она возвращает FAIL, если хотя бы один named component превысил allowed, даже при зелёном aggregate. Она выводит следующее узкое действие и явно отделяет измеренное от неизвестного.

\n

Для учебного примера критерий можно проверить так: одинаковые route, scenario и conditions дают scriptTicks=34, allowed=30, aggregate=93, aggregateAllowed=98 и общий статус FAIL; изменение только cache или scenario даёт состояние несопоставимости; ни один из этих результатов не называется production-измерением. Для реального маршрута к этому набору добавьте источник каждого поля и повторяемый протокол запуска.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/212.json b/editorial/agent-rewrites/212.json new file mode 100644 index 0000000..6ea1728 --- /dev/null +++ b/editorial/agent-rewrites/212.json @@ -0,0 +1,7 @@ +{ + "index": 212, + "slug": "editorial-2022-02-mechanism-performance-budget", + "title": "Бюджет производительности маршрута: почему общий PASS скрывает регрессию", + "excerpt": "Практическая модель бюджета маршрута: фиксируем условия, сравниваем именованные компоненты с допуском и останавливаем проверку, если входы больше не сопоставимы.", + "contentHtml": "

После небольшого изменения команда запускает проверку и получает PASS. Общая сумма работы выросла с 85 до 93 условных единиц, но осталась ниже общего лимита 98. Через несколько дней пользователи замечают, что первый экран стал реагировать позже. В отчёте нет явного нарушения: зелёный итог скрыл рост в одном компоненте.

\n

Цена такой ошибки — не только лишние миллисекунды. Команда выбирает неверное действие. Она меняет кеш, сеть или весь маршрут, хотя регрессия появилась в одном участке. Потом сравнения начинают проводиться в разных условиях, а бюджет теряет смысл.

\n

Тезис: бюджет маршрута должен проверять именованные части до общей суммы. Общий итог нужен как дополнительный ограничитель. Он не отменяет нарушение отдельного компонента.

\n

Что именно защищает бюджет

\n

Бюджет — это не одно число в конфигурации. Он связывает маршрут, пользовательский сценарий, версию сборки, условия запуска и измеряемые части работы. Эти поля образуют measurement contract. Если контракт не записан, два числа «до» и «после» могут описывать разные события.

\n

В учебной модели ниже четыре поля: documentTicks, styleTicks, scriptTicks и renderTicks. Суффикс Ticks намеренный. Это условные значения локального примера. Они не являются LCP, TTFB, INP, DOMContentLoaded или результатом браузерного профиля.

\n

Настоящий браузер предоставляет другие записи. Например, Navigation Timing описывает навигацию документа, а Resource Timing — загрузку ресурсов. Сначала нужно получить такую запись из поддерживаемого API, затем явно сопоставить её поля с внутренней схемой. Нельзя переименовать произвольное число в LCP и получить от этого реальную метрику.

\n

Механизм сравнения

\n

Сравнение проходит четыре слоя.

\n
  1. Контракт. Проверяем одинаковые маршрут, сценарий, версию workload и условия. В условия входят, например, тип навигации, cache policy, viewport, браузер и сеть.
  2. Snapshot. Сохраняем baseline с той же схемой полей. Baseline — это не «последний удачный отчёт», а точка отсчёта для конкретного контракта.
  3. Компоненты. Для каждого именованного поля проверяем собственный limit и tolerance. Результат должен показывать имя, baseline, candidate, allowed и delta.
  4. Решение. Overall получает FAIL, если нарушен хотя бы один компонент. Aggregate читается после component checks и остаётся вторичным guard.
\n

Допуск — часть правила, а не скрытая скидка. При limit = 28 и tolerance = 2 порог равен 30. В отчёте нужно сохранить оба значения. Иначе нельзя отличить осознанный допуск от случайно изменённого лимита.

\n
const contract = {\n  route: '/checkout',\n  scenario: 'open-and-submit',\n  conditions: {\n    browser: 'chromium',\n    viewport: '390x844',\n    cache: 'cold',\n    network: 'fixed-4g'\n  }\n};\n\nconst baseline = {\n  documentTicks: 18,\n  styleTicks: 15,\n  scriptTicks: 26,\n  renderTicks: 26\n};\n\nconst candidate = {\n  documentTicks: 20,\n  styleTicks: 16,\n  scriptTicks: 34,\n  renderTicks: 23\n};\n\nconst rules = {\n  documentTicks: { limit: 20, tolerance: 2 },\n  styleTicks: { limit: 18, tolerance: 2 },\n  scriptTicks: { limit: 28, tolerance: 2 },\n  renderTicks: { limit: 30, tolerance: 2 }\n};
\n

В этом примере baseline равен 85, candidate — 93. Общий allowed равен 98, поэтому aggregate проходит. Но scriptTicks вырос с 26 до 34. Его allowed равен 30. Компонент нарушен, значит итоговая проверка должна вернуть FAIL. Разница между 34 и 28 равна 6. Разница между 34 и allowed равна 4. Обе величины полезны, если отчёт называет их однозначно.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Общий PASS, но один участок выросAggregate скрывает component failureСравнить каждый field с его allowedОстановить итог как FAIL и открыть только failed component
До и после нельзя честно сравнитьРазличаются cache, route, browser или scenarioСравнить все поля contractВернуть status measurement-contract-invalid и переснять candidate
Отчёт меняется от запуска к запускуУсловия не зафиксированы или шум выше toleranceПовторить сценарий и записать условияИзменить способ измерения или обосновать tolerance
Красный компонент приводит к глобальной оптимизацииДиагностика не ограничена именем поляПроверить input и границу failed componentСделать одну локальную проверку, не менять весь стек
Учебный отчёт называют browser metricВнутреннее поле не сопоставлено с APIНайти источник и mapping для значенияПереименовать поле или добавить реальный сбор
\n

Почему нужен отрицательный путь

\n

Представим, что baseline снят с cold cache, а candidate — с warm cache. Candidate может оказаться быстрее. Это не доказательство улучшения: изменился вход. То же происходит, если baseline относится к /checkout, а candidate — к /cart, или если один запуск включает авторизацию, а другой нет.

\n

Правильный результат в таком случае — не FAIL и не PASS. Сравнение нужно остановить с причиной measurement-contract-invalid. Component map остаётся пустым, aggregate не получает performance-смысл, а следующий шаг — восстановить один контракт и повторить измерение.

\n

Не стоит автоматически нормализовать несовпадение. Если система молча заменит warm на cold или возьмёт последний baseline, она создаст удобный, но ложный verdict. Лучше потерять один результат, чем принять несопоставимые числа за регрессию или улучшение.

\n
\"Учебное
Учебная иллюстрация: зелёный aggregate не отменяет красный named component. Значения показывают условные ticks, а не результаты production-профиля.
\n

Как связать модель с браузерным измерением

\n

Модель полезна только после явной границы между сбором и решением. Сбор получает официальную запись API. Адаптер выбирает поля, нормализует единицы и сохраняет условия. Сравниватель работает уже с проверенной внутренней схемой.

\n
const navigation = performance.getEntriesByType('navigation')[0];\n\nconst observation = {\n  source: 'PerformanceNavigationTiming',\n  fields: {\n    responseEnd: navigation.responseEnd,\n    domInteractive: navigation.domInteractive,\n    loadEventEnd: navigation.loadEventEnd\n  },\n  conditions: {\n    route: location.pathname,\n    navigationType: navigation.type\n  }\n};\n\n// Учебный пример: здесь нет решения о PASS/FAIL.\n// Сначала нужен отдельный mapping в схему проекта.
\n

Этот код показывает границу, а не готовый production-сборщик. Он не учитывает отправку данных, sampling, privacy, доступность API и различия браузеров. Он также не превращает три timestamp в четыре условных компонента автоматически. Mapping должен описывать формулу, единицы, поддержку браузеров и условия применимости.

\n

Если проекту нужен ресурсный бюджет, следует получить Resource Timing и отдельно решить, какие ресурсы входят в контракт. Документная навигация и загрузка каждого ресурса отвечают на разные вопросы. Смешивать их в один total без правила агрегации нельзя.

\n

Порядок работы

\n
  1. Назвать маршрут и пользовательский сценарий. Не использовать «страница в целом».
  2. Записать условия: браузер, viewport, сеть, cache policy, версия сборки и тип навигации.
  3. Определить поля и единицы. Для каждого поля указать источник, limit и tolerance.
  4. Снять baseline и сохранить его вместе с contract. Не заменять его последним удачным запуском.
  5. Снять candidate в тех же условиях. При изменении условий завершить проверку на validation error.
  6. Сначала проверить наличие, набор и диапазон полей. Затем сравнить компоненты.
  7. Посчитать aggregate только для контекста. Он не должен маскировать component failure.
  8. Для первого нарушения назначить одну ограниченную проверку: конкретный input, ресурс или границу маршрута.
  9. После изменения повторить измерение с тем же контрактом и сравнить новый candidate с тем же baseline.
\n

Ограничения модели

\n

Учебные ticks не дают сведений о реальном количестве пользователей, SLA, полевых перцентилях или влиянии устройства. Даже настоящий browser trace не объясняет сам по себе причину регрессии. Он показывает наблюдение. Причину нужно искать в ресурсах, коде, серверном ответе, cache и сценарии.

\n

Один запуск не описывает шум. Число tolerance нельзя выбрать по привычке. Его обосновывают повторениями, средой, источником данных и ценой ложного срабатывания. Слишком большой допуск прячет регрессию. Слишком маленький превращает проверку в шумный сигнал.

\n

Бюджет также не заменяет продуктовую проверку. Быстрый маршрут может быть функционально неполным, а снижение одной метрики может ухудшить другой сценарий. Поэтому контракт должен ограничивать именно тот путь, который команда защищает, а рядом должны существовать проверки корректности.

\n

Проверяемый критерий готовности

\n

Изменение готово, если маршрут и сценарий названы; условия сохранены; baseline и candidate имеют одну схему; каждое поле имеет limit и tolerance; отчёт показывает component checks отдельно от aggregate; mismatch условий останавливает сравнение; failed component ведёт к одной ограниченной проверке; повторный candidate снят в том же contract.

\n

Зелёный total сам по себе этому критерию не соответствует. Проверка считается полезной только тогда, когда по её результату можно понять, что именно нарушено, что нужно проверить дальше и почему сравнение вообще допустимо.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/213.json b/editorial/agent-rewrites/213.json new file mode 100644 index 0000000..dd3258e --- /dev/null +++ b/editorial/agent-rewrites/213.json @@ -0,0 +1,7 @@ +{ + "index": 213, + "slug": "editorial-2022-02-practice-performance-budget", + "title": "Бюджет производительности: как не спрятать регрессию за общим PASS", + "excerpt": "Как описать один пользовательский путь, разделить его бюджет на именованные части и остановить изменение, которое превышает допуск, даже если общий результат ещё выглядит зелёным.", + "contentHtml": "

На странице оформления следующий шаг появляется позже, чем ожидал пользователь. В отчёте при этом стоит зелёный PASS: общий размер страницы и суммарное время не вышли за предел. Команда добавляет небольшой виджет, ещё один стиль и дополнительный обработчик. Каждый коммит проходит проверку. Через несколько релизов один участок пути забирает весь запас, а итоговая цифра продолжает скрывать это смещение.

\n

Цена ошибки — не абстрактная «медленная страница». Пользователь дольше ждёт перехода, чаще повторяет действие или закрывает вкладку. Инженер тратит время на поиск причины среди уже принятых изменений. Если бюджет проверяет только сумму, он не отвечает на главный вопрос: какой компонент потратил запас и что именно нужно остановить?

\n

Тезис статьи простой: бюджет производительности должен описывать конкретный пользовательский путь, условия измерения и несколько именованных ограничений. Общая сумма полезна как дополнительный предохранитель. Она не должна отменять провал отдельной части.

\n

Что именно ограничивает бюджет

\n

Бюджет — это набор заранее названных пределов. Он может ограничивать размер JavaScript, число запросов, время навигации или другую метрику, которая связана с выбранным сценарием. Число имеет смысл только вместе с путём и условиями. Лимит для каталога нельзя без проверки перенести на оформление заказа: у страниц разные данные, изображения и порядок действий.

\n

Сначала назовите вход. В учебном примере это маршрут /training/checkout/review и сценарий anonymous-cart-with-one-item. Это не production-маршрут и не результат настоящего запуска. Длинное имя нужно намеренно: другой товар, авторизация, локаль или feature flag могут создать другой набор ресурсов.

\n

Затем зафиксируйте условия. Запишите версию сборки, тип сети, состояние кеша, устройство или профиль CPU, способ запуска и источник чисел. Если часть условий неизвестна, не подставляйте правдоподобное значение. Пометка «не измерялось» лучше, чем вывод о браузере по одному числу из учебного объекта.

\n
Контракт бюджета для одного пути
Часть контрактаПример значенияЧто проверяетЧего не доказывает
Route и scenario/training/checkout/review, одна корзинаСравнивает один и тот же входКачество всех страниц
УсловияСборка, сеть и cache записаныПоказывает сопоставимость замеровПоведение другой среды
Компонентыdocument, style, script, renderНаходит участок, который забрал запасАвтоматически не объясняет причину
ДопускLimit и tolerance у каждого имениДаёт правило остановкиУниверсальную норму для любого продукта
TotalВторичный предел суммыЗамечает общий ростРазрешение на провал компонента
\n

Почему одна сумма даёт ложный PASS

\n

Представьте четыре части с пределами 26, 17, 30 и 22 условных единицы. Их допустимая сумма равна 95. Baseline содержит 24, 15, 26 и 20. Candidate меняет только script: 34 вместо 26. Сумма candidate равна 93. По total он проходит. По правилу для script он превышает предел 30 и должен получить FAIL.

\n

Такой пример показывает перенос затрат. Остальные части не стали дешевле из-за того, что скрипт стал тяжелее. Если проверять только сумму, рост в одном месте можно компенсировать случайным уменьшением в другом. Для пользователя это не всегда равноценная замена: лишний JavaScript может задержать обработчик, а уменьшение изображения не вернёт время, потерянное на главном потоке.

\n
\"Схема
Схема разделяет именованные части и общий предел. Учебная иллюстрация объясняет порядок проверки; она не показывает реальный trace, браузерный запуск или production-метрику.
\n

Учебная модель сравнения

\n

Ниже код намеренно работает с числами, а не с браузером. Единица ticks придумана для примера. Она показывает инвариант проверки: провал компонента нельзя замаскировать зелёной суммой.

\n
const budget = {\n  route: '/training/checkout/review',\n  scenario: 'anonymous-cart-with-one-item',\n  components: {\n    documentTicks: { limit: 26, tolerance: 1 },\n    styleTicks: { limit: 17, tolerance: 1 },\n    scriptTicks: { limit: 30, tolerance: 2 },\n    renderTicks: { limit: 22, tolerance: 1 },\n  },\n  totalTicks: { limit: 95, tolerance: 3 },\n};\n\nconst baseline = {\n  documentTicks: 24,\n  styleTicks: 15,\n  scriptTicks: 26,\n  renderTicks: 20,\n};\n\nconst candidate = {\n  ...baseline,\n  scriptTicks: 34,\n};\n\nfunction checkBudget(snapshot, contract) {\n  const components = Object.entries(contract.components).map(\n    ([name, rule]) => ({\n      name,\n      value: snapshot[name],\n      allowed: rule.limit + rule.tolerance,\n      pass: snapshot[name] <= rule.limit + rule.tolerance,\n    }),\n  );\n\n  const total = Object.values(snapshot).reduce((sum, value) => sum + value, 0);\n  const totalAllowed = contract.totalTicks.limit + contract.totalTicks.tolerance;\n\n  return {\n    components,\n    total,\n    totalPass: total <= totalAllowed,\n    pass: components.every((item) => item.pass) && total <= totalAllowed,\n  };\n}\n\nconsole.log(checkBudget(candidate, budget));
\n

В этой модели scriptTicks равен 34, а его allowed равен 32. Total равен 93, а его allowed равен 98. Полный результат обязан быть FAIL. Код не измеряет FCP, LCP, INP, сетевую задержку или работу CPU. Он не вызывает Lighthouse и не заменяет browser trace. В рабочем проекте поля нужно связать с конкретным источником измерения, иначе они остаются внутренней условной шкалой.

\n

Симптомы и действия

\n
Диагностика регрессии бюджета
СимптомПричинаПроверкаДействие
Total проходит, component падаетРост скрыт внутри суммыСравнить каждую именованную часть с её allowedОстановить изменение и найти владельца части
Два запуска дают разные выводыНе совпали сеть, cache, CPU или данныеСопоставить conditions и route в обоих snapshotsРазделить сценарии или повторить измерение в фиксированных условиях
Число выросло, но причина неизвестнаБюджет хранит результат без состава ресурсовПроверить размер chunk, список запросов и источник метрикиДобавить детализацию, а не повышать лимит вслепую
Провал появляется только у одной локалиСценарии смешаны в одном jobЗапустить одинаковый route для каждой локали отдельноСделать локаль частью scenario или выделить отдельный budget
Старый snapshot проходит после смены сборщикаBaseline получен в другой версии средыСравнить версию сборки и формат измеренияСоздать новый baseline и сохранить причину замены
Команда повышает лимит после каждого FAILЛимит используют как способ убрать сигналПроверить изменение ресурса и обоснование нового допускаСначала устранить рост или зафиксировать осознанное исключение
\n

Как выбрать измерение

\n

Размер ресурсов и число запросов удобно проверять в сборке. Они дают ранний сигнал до открытия страницы в браузере. Такой сигнал отвечает на вопрос о составе артефакта, но не говорит, как быстро пользователь увидит или сможет использовать экран.

\n

Временные метрики нужно получать из инструмента, который действительно их измеряет. API Navigation Timing даёт события навигации. PerformanceResourceTiming помогает увидеть интервалы загрузки отдельных ресурсов. Для интеракций нужны отдельные данные о событиях и отрисовке. Не называйте сумму размеров «временем до интерактивности» и не называйте синтетический snapshot полевой метрикой.

\n

Сопоставимость важнее количества цифр. Один замер на быстром ноутбуке не устанавливает предел для всех телефонов. Среднее значение может скрыть хвост распределения. Если бюджет защищает конкретный путь, проверяйте тот же путь, тот же набор данных и тот же класс условий. Если это невозможно, добавьте в результат причину несопоставимости и остановите автоматическое сравнение.

\n

Порядок внедрения

\n
  1. Выберите один путь, который связан с действием пользователя, и запишите route, scenario и ожидаемый результат.
  2. Определите источник каждого числа: размер артефакта, browser timing, synthetic run или field data. Не смешивайте эти классы.
  3. Зафиксируйте условия запуска: версия сборки, сеть, cache, устройство, данные и feature flags.
  4. Разделите бюджет на части, которыми можно управлять отдельно: document, style, script, render или другой состав, подходящий пути.
  5. Снимите baseline и сохраните его рядом с условиями. Не называйте его «истиной» без диапазона и повторных запусков.
  6. Добавьте candidate-проверку для каждой части и только после неё — проверку total.
  7. Прогоните положительный и отрицательный сценарии: обычный candidate должен пройти, а превышение одного компонента при зелёном total должно упасть.
  8. Для каждого FAIL назначьте действие: уменьшить ресурс, убрать запрос, отложить код, изменить сценарий или осознанно пересмотреть контракт.
\n

Ограничения

\n

Бюджет не исправляет медленную страницу сам. Он только превращает выбранное ожидание в сигнал. Плохой маршрут, неверный baseline или неописанные условия дадут точный ответ на неверный вопрос.

\n

Лимиты зависят от продукта. Экран с фотографиями, форма оплаты и текстовая статья имеют разный состав ресурсов. Одного числа для всех страниц обычно недостаточно. Разделяйте budgets по типу пути, но не создавайте отдельный лимит для каждого случайного варианта: так сигнал распадётся и станет необслуживаемым.

\n

Лабораторные данные и данные реальных пользователей дополняют друг друга. Лабораторный запуск помогает сравнивать изменения в одинаковой среде. Полевые данные показывают разброс устройств, сетей и поведения. Учебный код из статьи не содержит ни тех, ни других данных. Он проверяет только логику контракта.

\n

Иногда рост оправдан функцией. Тогда измените бюджет вместе с описанием причины, владельцем и способом повторной проверки. Не повышайте лимит только для того, чтобы вернуть зелёный статус.

\n

Проверяемый критерий готовности

\n

Контракт готов, если другой инженер может открыть запись budget и ответить на четыре вопроса: какой путь проверяется, в каких условиях, какая часть превысила допуск и какое действие следует выполнить. Автоматическая проверка должна завершаться FAIL, когда один компонент превышает свой allowed, даже если total остаётся внутри общего предела.

\n

Минимальный приёмочный набор выглядит так: baseline и candidate имеют одинаковые route, scenario и conditions; каждое значение неотрицательно и принадлежит известному полю; результат печатает component verdict и total verdict; положительный пример проходит; отрицательный пример из учебного кода падает на scriptTicks. Это проверяет механизм, но не заявляет production-эффект.

\n

Проверяемые источники

\n\n

Эти источники описывают инструменты и понятие бюджета. Они не подтверждают числа из учебного примера и не дают готовый лимит для конкретного продукта.

" +} diff --git a/editorial/agent-rewrites/214.json b/editorial/agent-rewrites/214.json new file mode 100644 index 0000000..7481abe --- /dev/null +++ b/editorial/agent-rewrites/214.json @@ -0,0 +1,7 @@ +{ + "index": 214, + "slug": "editorial-2022-01-field-ssr-csr", + "title": "SSR и CSR: как разобрать повторный fetch после первого экрана", + "excerpt": "HTML уже содержит данные, но после hydrate браузер снова обращается к API. Разбираем четыре причины симптома, порядок проверки и безопасный путь для mismatch.", + "contentHtml": "

Проблема заметна в браузере: сервер уже отдал список, пользователь видит первый экран, а сразу после hydrate в Network появляется второй запрос к тому же API. Иногда содержимое меняется, иногда экран мигает, иногда запрос только расходует соединение. Цена ошибки — не один лишний round trip. Команда может принять рассинхронизацию разметки за обычное обновление, скрыть её новым ответом и оставить причину в следующем релизе.

\n

Один симптом не доказывает одну причину. Initial snapshot мог не попасть в bootstrap. Разметка и состояние могли прийти из разных версий. Компонент мог проигнорировать переданные данные. Наконец, fetch мог быть правильным: пользователь сменил фильтр или явно обновил страницу. Сначала нужно назвать trigger и владельца состояния. Только после этого выбирают действие.

\n

Тезис: SSR отдаёт начальное состояние, CSR продолжает работу

\n

SSR формирует HTML на сервере. В него обычно попадает представление состояния, которое сервер получил для конкретного route, пользователя, locale и набора флагов. CSR подключает обработчики, восстанавливает состояние и обслуживает следующие действия в браузере. Эти этапы связаны, но не равны.

\n

Если браузер начинает обычную загрузку до того, как принял initial snapshot, он создаёт вторую операцию вместо продолжения первой. Поэтому вопрос «как убрать fetch» поставлен слишком широко. Правильный вопрос: «какой вход разрешил этот fetch и согласуется ли он с ответом SSR?» Ответ должен быть виден в данных и в порядке переходов, а не только в названии метода.

\n

Механизм: четыре версии одного симптома

\n

Первая ветка — snapshot отсутствует. Сервер отдал HTML, но bootstrap получил пустое состояние. Hook видит cache miss и запускает обычный запрос. Это дефект передачи initial data.

\n

Вторая ветка — snapshot есть, но контракт расходится. Сервер создал HTML для entry-r7, а сериализованное состояние или client code ожидает entry-r8. Причиной могут быть разные ответы upstream, cache boundary, locale, cookie, permission или feature flag. Нельзя исправить такой разрыв без выбора источника истины.

\n

Третья ветка — версии совпадают, но владелец состояния не использует snapshot. Например, effect запускает загрузку при mount без проверки, что данные уже приняты. Здесь запрос лишний, хотя серверный ответ согласован.

\n

Четвёртая ветка — явное действие пользователя. Новый фильтр, переход по странице, нажатие refresh или подписка на новую ревизию меняют вход. Такой fetch не является ошибкой hydrate. Ему нужны отдельный trigger, новый input и понятный владелец.

\n

Полезный контракт можно записать так: serverVersion === serializedVersion разрешает принять snapshot; mismatch сначала становится наблюдаемым событием; новый запрос запускается только после принятия snapshot или после named user trigger. Это проектное правило. React задаёт требования к согласованной разметке, но не знает вашу схему версий и не решает, кто владеет бизнес-состоянием.

\n

Минимальный пример сравнения

\n

Ниже — учебная функция. Она не запускает React, не читает DOM и не отправляет HTTP-запрос. Функция показывает только порядок: сначала разобрать snapshot, затем сравнить версии, затем выбрать действие. Значения entry-r7 и entry-r8 придуманы для примера.

\n
function decideHydration({ markupVersion, snapshot, trigger }) {\n  if (!snapshot) {\n    return { action: 'record-missing-snapshot', fetch: false };\n  }\n\n  if (markupVersion !== snapshot.version) {\n    return {\n      action: 'record-mismatch-before-mutation',\n      expected: markupVersion,\n      received: snapshot.version,\n      fetch: false,\n    };\n  }\n\n  if (trigger === 'user-refresh' || trigger === 'filter-change') {\n    return { action: 'start-explicit-refresh', fetch: true };\n  }\n\n  return { action: 'accept-snapshot', fetch: false };\n}\n\nconst result = decideHydration({\n  markupVersion: 'entry-r7',\n  snapshot: { version: 'entry-r7', items: ['A'] },\n  trigger: 'mount',\n});\n\nconsole.log(result);\n// { action: 'accept-snapshot', fetch: false }
\n

В mismatch-пути функция не пытается угадать свежий ответ. Она возвращает две версии и останавливается до изменения client state. Это не готовый recovery UX. Приложение должно отдельно решить, показать ли сообщение, повторить чтение по безопасному правилу или передать случай владельцу данных. Важен порядок: диагностика не должна исчезнуть внутри общего loading.

\n
Диагностика повторного client fetch
СимптомПричинаПроверкаДействие
HTML есть, snapshot отсутствуетInitial state не передали в bootstrapНайти сериализованный payload и проверить его наличие до mountПередать один именованный snapshot; повторить сценарий
Markup и payload несут разные версииSSR и client получили разные входы или ответыСохранить обе версии, route, locale, cookie и флаги до mutationОстановить неявное обновление; выбрать recovery path
Версии совпадают, fetch идёт на mountHook игнорирует initial stateПосмотреть owner состояния и условие запуска effectПринять snapshot до запроса; оставить fetch только для нового trigger
Fetch следует за filter-changeПользователь изменил inputСвязать запрос с событием и новым параметромОставить запрос, но отделить его от hydration
Контент меняется без действияГонка snapshot и client response или скрытый refreshСопоставить timestamps, operation id и источник каждого ответаЗащитить порядок применения; не считать последний ответ автоматически верным
\n
Диагностика повторного client fetch: проверка snapshot и версии ведёт к принятию состояния, записи mismatch или отдельному пользовательскому обновлению.
Сначала определяется источник запроса. Совпавший snapshot принимается без второго fetch; mismatch фиксируется до mutation; явное действие пользователя создаёт отдельную операцию.
\n

Что собрать до изменения кода

\n

Начните с одного document response. Запишите route input, identity контекста, locale, source version, marker в HTML, serialized version, имя bootstrap и trigger запроса. Если значения нет, так и отметьте. Не подставляйте выдуманные LCP, время ответа или production-результат. Для этой диагностики важнее причинная цепочка, чем красивый waterfall.

\n

Проверьте, что marker и snapshot описывают один источник. Версия без связи с данными мало полезна: она может оставаться одинаковой при разных permission или feature flag. Если эти входы меняют HTML, включите их в контракт или разделите route variant. Если вход не должен влиять на SSR, не позволяйте ему незаметно менять state после hydrate.

\n

Затем найдите место, где создаётся запрос. Смотрите не только на URL. Нужны условие запуска, текущий state, operation id и причина перехода. Если effect вызывает fetch на каждый mount, он должен сначала проверить accepted snapshot. Если запрос приходит от пользовательского события, передайте это событие явно, а не выводите trigger из факта, что компонент уже смонтирован.

\n

Порядок действий

\n
  1. Зафиксируйте симптом: какой HTML виден, когда появляется запрос, меняется ли содержимое и было ли действие пользователя.
  2. Найдите initial snapshot в границе bootstrap. Проверьте, что он относится к тому же route и документу, что и HTML.
  3. Сравните source version, markup marker и serialized version до любого изменения client state.
  4. Если snapshot отсутствует, исправьте передачу данных и добавьте проверку, которая ловит пустой bootstrap.
  5. Если версии различаются, сохраните expected и received, остановите неявный fetch и назначьте владельца recovery. Не закрывайте mismatch свежим ответом.
  6. Если версии совпадают, проверьте effect и cache owner. Принятие snapshot должно предшествовать запросу на mount.
  7. Если запрос вызвал пользовательский input, сохраните trigger и новый параметр. Такой запрос проверяйте как отдельную операцию.
  8. Проверьте отрицательный путь: разный locale, cookie, permission, feature flag, медленный ответ и повторный клик не должны приводить к бесконтрольной гонке.
  9. Сравните до и после число запросов, порядок применения ответа и состояние при mismatch. Считайте исправление завершённым только после проверки критерия готовности.
\n

Когда второй fetch уместен

\n

Повторная загрузка уместна, если появилась новая причина. Пользователь сменил фильтр. Истёк явно заданный freshness window. Подписка сообщила о новой ревизии. UI вошёл в режим, которого не было в SSR. В каждом случае сохраняйте новый input и отдельную operation id. Initial snapshot остаётся состоянием первого ответа, а client fetch становится следующей операцией.

\n

Не отключайте SSR только потому, что не нашли владельца второго запроса. Не добавляйте setTimeout, чтобы «дать hydrate закончить». Не подавляйте warning без доказательства, что разметка безопасно различается. Такие меры меняют время симптома, но не объясняют, какой источник сформировал первый экран.

\n

Ограничения

\n

Сравнение строк версий не доказывает равенство всего HTML. Разметка может расходиться из-за timezone, случайных значений, даты, порядка элементов, разных прав или ответа внешней системы. Для каждого входа нужно решить, входит ли он в initial contract. Если нет, перенесите зависимый фрагмент в контролируемую клиентскую ветку.

\n

Учебный код не является React-компонентом и не заменяет browser test. Он не моделирует DOM mutation, concurrent rendering, cache headers, сеть, retry и восстановление после ошибки. Реальный тест должен проверить конкретный renderer, сериализацию и пользовательский сценарий. Нельзя объявлять production-эффектом то, что показала только эта функция.

\n

Материал ограничен историческим API React 17, доступным на январь 2022 года. Современные API и фреймворки могут менять детали bootstrap и hydration. Общий принцип остаётся проверяемым: сначала согласовать initial contract, затем применить snapshot, затем запускать новый запрос по названной причине.

\n

Проверяемый критерий готовности

\n

Разбор готов, если для каждого повторного fetch можно назвать trigger, owner, source version и serialized version. Тест показывает, что совпавший snapshot не создаёт второй запрос на mount, отсутствующий snapshot остаётся видимым дефектом, mismatch фиксируется до mutation, а filter-change создаёт отдельный запрос с новым input. Если команда может сказать только «браузер обновил данные», причина ещё не установлена.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/215.json b/editorial/agent-rewrites/215.json new file mode 100644 index 0000000..b36ed01 --- /dev/null +++ b/editorial/agent-rewrites/215.json @@ -0,0 +1,7 @@ +{ + "index": 215, + "slug": "editorial-2022-01-mechanism-ssr-csr", + "title": "SSR и CSR без двойного состояния: как сохранить границу данных", + "excerpt": "Первый экран уже содержит данные, но после гидрации браузер запрашивает их снова и может заменить интерфейс. Разбираем владельцев source, HTML, snapshot и client state, а затем проверяем mismatch до любого автоматического восстановления.", + "contentHtml": "

Пользователь открывает страницу. Сервер отдаёт HTML с карточкой товара и ценой. Через мгновение JavaScript запускает ещё один запрос. Цена меняется, кнопка на секунду исчезает, а в Network появляются два ответа. Иногда это выглядит как нормальная гидрация. Иногда второй ответ приходит из другого кеша или с другим правом доступа и подменяет первый экран.

\n

Ошибка стоит дороже одного запроса. Команда теряет доверие к SSR, увеличивает время до стабильного интерфейса и начинает чинить симптом: отключает серверный рендер, ставит таймер или всегда делает refetch. После этого причина рассинхронизации остаётся. Следующий баг возникает на другом locale, cookie или feature flag.

\n

Тезис: HTML, сериализованный snapshot и состояние клиента — разные артефакты. У каждого должен быть владелец, версия и момент жизни. Hydrate принимает snapshot как initial state только после проверки контракта. Новый client fetch начинается только из отдельного условия. Если версии не совпали, сначала фиксируем mismatch. Не маскируем его свежим ответом.

\n

Что происходит между сервером и браузером

\n

Server request читает source. Это может быть база, API или серверный кеш. Renderer использует результат и строит HTML. Сам HTML показывает элементы, но не обязан содержать всю информацию, которая нужна клиентскому коду для продолжения работы. Поэтому приложение передаёт рядом сериализованный snapshot.

\n

Snapshot — не общий кеш приложения. Это переносимое начальное состояние для конкретного document response. Он должен быть связан с тем же контекстом, который сформировал HTML: route, locale, пользователь, права, feature flags и версия данных. Если эти входы влияют на разметку, их нельзя считать случайными деталями.

\n

Hydrate присоединяет логику к уже существующей разметке. В React это не второй независимый SSR. Клиентское дерево должно давать тот же первоначальный вывод, что и серверное. В противном случае браузер начинает исправлять несовпадение или сообщает о нём, а команда получает неясный переход между состояниями.

\n

После принятия snapshot владельцем initial state становится клиентское приложение. Это не означает, что snapshot владеет всей будущей историей данных. Он объясняет только первый экран. Следующий запрос должен иметь новый input и отдельную причину: действие пользователя, истёкший freshness window, подписка или согласованный recovery path.

\n

Минимальный контракт

\n

Назовём версию entry-r7. Сервер читает запись, использует её для HTML и кладёт ту же версию в snapshot. Bootstrap сравнивает marker разметки и версию snapshot до создания нового client state. В учебном примере ниже нет React, DOM и сети. Код показывает порядок переходов, а не готовую библиотеку.

\n
const serverEnvelope = {\n  source: { version: 'entry-r7', data: { price: 100 } },\n  markupVersion: 'entry-r7',\n  serializedSnapshot: JSON.stringify({\n    schema: 1,\n    version: 'entry-r7',\n    data: { price: 100 }\n  })\n};\n\nconst snapshot = JSON.parse(serverEnvelope.serializedSnapshot);\nconst sameVersion = serverEnvelope.markupVersion === snapshot.version;\n\nif (sameVersion) {\n  clientState = snapshot.data;\n  // Не запускаем fetch только из-за mount.\n} else {\n  diagnostic = {\n    kind: 'hydration-mismatch',\n    expected: serverEnvelope.markupVersion,\n    received: snapshot.version\n  };\n  // Recovery выбирается отдельно после записи причины.\n}
\n

Важна не строка сравнения сама по себе. Важен момент, когда она выполняется. Если hook сначала создаёт пустой store, а затем effect читает snapshot, fetch уже получил право изменить экран. Guard оказался слишком поздним. Правильная граница находится до начальной мутации состояния.

\n

Версия также не должна быть декоративным timestamp. Если сервер формирует HTML из записи пользователя A, а snapshot берётся из общего кеша пользователя B, одинаковая строка времени не исправит контракт. Версия должна отвечать на вопрос: из какого чтения и какого контекста появились оба представления?

\n

Владелец и срок жизни артефактов

\n
Переход данных от SSR к CSR
АртефактКто создаётКто читаетСрок жизниОпасная подмена
sourceserver requestrendererдо формирования ответасчитать source доступным браузеру
HTMLserver rendererпользователь и hydrateдо изменения DOMсчитать HTML полным client state
serialized snapshotserver responsebootstrapдо принятия initial stateсчитать его общим кешем всех страниц
client statehydrateинтерактивный UIпо правилам приложениясоздать пустым без проверки snapshot
client fetchявный client triggerбраузерновая операциязапускать на каждый mount
\n

Таблица нужна для расследования. Если невозможно назвать владельца значения, нельзя надёжно объяснить, почему оно изменилось. Если неизвестен срок жизни, нельзя отличить устаревший snapshot от нового состояния. Эти два вопроса полезнее общего флага loading.

\n
\"Переход
Граница данных: сервер передаёт HTML и snapshot из одного чтения, а браузер принимает snapshot только после проверки версии. Стрелка к client fetch обозначает новую операцию, а не продолжение server request.
\n

Симптом не называет причину

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
После hydrate виден повторный запросBootstrap не получил initial snapshot или hook его игнорируетНайти serialized version и условие запуска fetchПередать named snapshot и пропустить fetch при принятой версии
Первый экран меняется без действия пользователяHTML и snapshot созданы из разных чтенийСравнить source version, markup marker и snapshot versionОстановить скрытую мутацию, записать mismatch и выбрать recovery path
Есть warning о hydration mismatchРазные данные, locale, timezone, browser API или markupСравнить server input и первый client renderСделать вывод детерминированным или перенести различие после согласованного первого прохода
Fetch следует после фильтраПользователь создал новый inputСвязать запрос с событием и параметрами фильтраОставить fetch, но не называть его исправлением гидрации
\n

Один и тот же URL в Network не объясняет три первые строки. Для расследования сохраняйте порядок: какой ответ пришёл, какая версия была в markup, какую версию прочитал bootstrap и что запустило fetch. Без trigger второй запрос нельзя классифицировать.

\n

Mismatch и отрицательный путь

\n

Представим, что HTML помечен как entry-r8, а snapshot содержит entry-r7. Без guard приложение может принять snapshot, затем получить свежий ответ и показать его как исправление. Такой путь убирает свидетельство. Мы уже не знаем, почему HTML и данные разошлись: сработал кеш документа, сменился пользователь, неправильно собрался ключ или разные сервисы прочитали source в разные моменты.

\n

Отрицательный путь должен быть заметен. Сначала создаём diagnostic с двумя версиями. Затем не запускаем markup mutation, client state mutation и автоматический fetch. После записи выбираем recovery. Это может быть повторный запрос с тем же контекстом, показ ошибки или контролируемый client-only переход. Выбор зависит от продукта. Нельзя выдавать его за универсальное правило React.

\n

Для отсутствующего snapshot причина другая. Тут нечего сравнивать. Следует исправить передачу initial state или явно объявить страницу client-rendered. Для совпавшего snapshot повторный запрос тоже не всегда ошибка: freshness policy может требовать обновления. Но тогда policy должна быть названа, измерима и отделена от hydrate.

\n

Порядок действий

\n
  1. Зафиксируйте один document response: route, locale, identity-контекст, permissions, feature flags и время формирования.
  2. Запишите source version, marker в HTML, serialized snapshot version и место, где bootstrap создаёт client state.
  3. Разделите причины повторного запроса: отсутствует snapshot, версии расходятся, hook игнорирует snapshot или пользователь создал новый input.
  4. Поставьте сравнение версий до первой client state mutation. При совпадении используйте snapshot как initial state.
  5. При mismatch сохраните обе версии и trigger. Запретите неявный fetch до выбора отдельного recovery path.
  6. Проверьте первый client render на том же наборе данных, что и server render. Уберите nondeterministic output из общего прохода.
  7. Добавьте browser-тест для совпадения разметки и отдельный тест для отрицательного пути. Учебная in-memory проверка не заменяет эти тесты.
\n

Ограничения модели

\n

В примере версия и JSON придуманы для обучения. Они не являются production-данными. Fixture не запускает React, не открывает браузер, не измеряет latency и не подтверждает результат конкретного framework. Её задача — закрепить порядок: принять snapshot при совпадении и оставить mismatch видимым до mutation.

\n

Официальная документация React описывает серверный рендер HTML и гидрацию существующей разметки. Она не задаёт поля source, markupVersion или политику client fetch. Это проектный контракт. Не следует ссылаться на документацию React как на доказательство того, что конкретный кеш, serializer или recovery path безопасен.

\n

Сервер и клиент могут честно получить разные входы. Cookie, locale, timezone, permission set, random value, текущая дата и feature flag часто меняют вывод. Если различие неизбежно, первый render должен оставаться согласованным, а клиентская часть может изменить интерфейс после hydrate отдельным шагом. Такой двухпроходный путь добавляет работу и может быть заметен на медленной сети. Он не отменяет проверку.

\n

Не лечите mismatch через случайный setTimeout, отключение SSR или бездумный suppressHydrationWarning. Эти меры могут скрыть сообщение, но не назначают владельца данных и не объясняют цену расхождения. Сначала сохраните факты. Потом выберите минимальное обратимое изменение.

\n

Проверяемый критерий готовности

\n

Решение готово, когда для одного документированного сценария можно показать четыре связанных факта: source и HTML созданы из согласованного контекста; snapshot содержит ту же версию; первый client render не меняет содержимое сам по себе; любой последующий fetch имеет названный trigger и новый input. Для mismatch есть отдельный наблюдаемый результат, а не тихий fallback.

\n

Проверка должна проходить в двух ветках. В matching case Network не содержит автоматического запроса только из-за mount, а initial state берётся из snapshot. В mismatch case система сохраняет expected и received, не выполняет mutation до recovery и позволяет найти владельца следующего действия. Только после этого можно оценивать UX, кеширование и производительность конкретного приложения.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/216.json b/editorial/agent-rewrites/216.json new file mode 100644 index 0000000..490c173 --- /dev/null +++ b/editorial/agent-rewrites/216.json @@ -0,0 +1,7 @@ +{ + "index": 216, + "slug": "editorial-2022-01-practice-ssr-csr", + "title": "SSR и CSR без рассинхронизации: как принять snapshot и не запустить второй запрос", + "excerpt": "Сервер уже показал данные, но клиент снова их загружает и меняет первый экран. Разбираем владельцев состояния, версию snapshot, проверку hydrate и явный путь для mismatch.", + "contentHtml": "

Симптом появляется сразу после загрузки страницы: сервер уже отдал карточку с данными, а после запуска JavaScript браузер повторяет тот же запрос. Иногда ответы совпадают, и ошибка остаётся незаметной. При задержке или изменении записи пользователь видит один текст, затем другой. В DevTools появляются два запроса, а команда не может объяснить, какой из них владел первым экраном. Цена ошибки — лишний сетевой путь, более длинная критическая цепочка и риск тихого рассинхрона между HTML и клиентским состоянием.

\n

Тезис простой: SSR и CSR не должны конкурировать за начальное состояние. Сервер читает source, создаёт HTML и передаёт рядом сериализованный snapshot. Клиент принимает snapshot только после проверки его версии. При совпадении он пропускает начальный fetch. При несовпадении он сначала фиксирует проблему и останавливает неявную мутацию. Новый запрос допустим только как явно выбранное восстановление.

\n

Механизм: четыре фазы и четыре владельца

\n

SSR — это серверный запрос и HTML, который браузер получает первым. HTML показывает результат, но сам по себе не говорит клиентскому коду, из какого чтения он появился. Поэтому ответ должен передать и snapshot начального состояния. Snapshot — не общий кеш приложения. Это данные для конкретного server response.

\n

Hydrate связывает клиентскую логику с уже существующей разметкой. На этой границе нужно сравнить маркер версии HTML и версию snapshot. Версия может быть номером ревизии, ETag, версией набора фильтров или другим значением, которое сервер умеет получить вместе с данными. Нельзя сравнивать только время запроса: два чтения могут иметь одинаковую секунду, но разные права, locale или feature flags.

\n
Кто владеет данными на первом проходе
ФазаВладелецВходДопустимое действиеОшибка
SSR/sourceserver-requestЗапись и её версияПрочитать source и создать HTMLHTML без понятной версии
Ответserialized-snapshotPayload и версия SSRПередать initial state рядом с HTMLВерсия потерялась при сериализации
Hydratebrowser-transitionВерсия разметки и snapshotСравнить до изменения состоянияРазные версии приняты молча
Client fetchbrowser-transitionЯвное решение после проверкиПропустить запрос или начать recovery pathFetch запускается на каждый mount
\n

Минимальный пример с версией

\n

Ниже — учебная JavaScript-модель. Она работает только с объектами и JSON. В ней нет React, Next.js, DOM, сети, таймера и реального браузерного hydrate. Пример показывает порядок владения данными и помогает написать проверку границы. Он не доказывает LCP, latency или поведение конкретного hook.

\n
const source = {\n  id: 'entry-42',\n  version: 'entry-r7',\n  title: 'Training entry',\n  status: 'published',\n};\n\nconst snapshot = JSON.stringify({\n  schema: 'ssr-snapshot-v1',\n  version: source.version,\n  payload: {\n    id: source.id,\n    title: source.title,\n    status: source.status,\n  },\n});\n\nfunction planHydration(markupVersion, serializedSnapshot) {\n  const parsed = JSON.parse(serializedSnapshot);\n\n  if (markupVersion !== parsed.version) {\n    return {\n      outcome: 'mismatch-recorded-before-mutation',\n      diagnostic: {\n        expectedMarkupVersion: markupVersion,\n        serializedVersion: parsed.version,\n      },\n      clientFetch: { performed: false, action: 'not-started' },\n    };\n  }\n\n  return {\n    outcome: 'snapshot-accepted-without-second-fetch',\n    stateOwner: 'serialized-snapshot',\n    initialState: parsed.payload,\n    clientFetch: {\n      performed: false,\n      action: 'skipped-snapshot-version-matched',\n    },\n  };\n}\n\nconst matching = planHydration(source.version, snapshot);\nconsole.log(matching.outcome);\n// snapshot-accepted-without-second-fetch\n\nconst mismatch = planHydration('entry-r8', snapshot);\nconsole.log(mismatch.diagnostic);\n// { expectedMarkupVersion: 'entry-r8', serializedVersion: 'entry-r7' }
\n

В совпадающей ветке snapshot становится входом для initial state. Второй запрос не стартует только потому, что его запуск запрещает правило, а не потому, что эффект случайно выполнился в нужном порядке. В ветке mismatch код не подменяет старые данные новым ответом. Он сохраняет обе версии и оставляет recovery path следующему слою.

\n
Жизненный цикл SSR и CSR: server request создаёт HTML и snapshot, hydrate сравнивает версии, а client fetch пропускается при совпадении и останавливается при mismatch.
Граница между server request, сериализацией, hydrate и client fetch. Красная ветка означает диагностируемое несовпадение, а не автоматическое обновление.
\n

Диагностика: симптом → причина → проверка → действие

\n
Что делать при наблюдаемом симптоме
СимптомПричинаПроверкаДействие
После hydrate повторяется запрос за той же записьюКлиент создаёт пустой initial state и не читает snapshotСопоставить время HTML, snapshot и первый fetchПередать snapshot в initial state; fetch запускать только после явного условия
Текст меняется сразу после загрузкиHTML и snapshot пришли из разных чтений sourceЗаписать обе версии и входы: user, locale, flagsИсправить общий источник либо остановить переход при mismatch
Hydrate выдаёт предупреждениеКлиент строит другую разметкуСравнить server markup и первый client render без fetchУбрать нестабильное значение из рендера или передать его через snapshot
После «исправления» причина не виднаАвтоматический fetch маскирует расхождениеВременно записать markupVersion, serializedVersion и actionСначала сохранить diagnostic, затем выбрать refresh или сообщение
\n

Проверка должна охватывать не только версию записи. Если HTML зависит от пользователя, языка, часового пояса, прав или эксперимента, эти входы тоже входят в контракт. Одинаковый entry-r7 не означает одинаковый результат для двух пользователей. Полезный snapshot хранит либо нормализованные входы, либо достаточно данных, чтобы клиент мог проверить их отдельно.

\n

Порядок внедрения

\n
  1. Зафиксируйте симптом в браузере: URL, время первого HTML, повторный запрос, видимое изменение и входы страницы.
  2. Назовите владельца каждого значения: source, HTML, snapshot, client state и следующий fetch. Не называйте их одним словом «кеш».
  3. Выберите версию, которую сервер получает рядом с source. Передайте её в snapshot вместе с payload и схемой формата.
  4. Поставьте сравнение до mutation и до запуска client fetch. Запишите ожидаемую и фактическую версии.
  5. Для совпадения примите snapshot как initial state и проверьте, что повторный fetch не выполняется.
  6. Для mismatch остановите неявное обновление. Отдельно выберите recovery path: повторить чтение, показать ошибку или отрендерить управляемый fallback.
  7. Добавьте тесты на совпадение, mismatch и изменение входа. В каждом тесте проверяйте не только итоговый экран, но и количество запросов.
  8. Проверьте медленную сеть, отключённый JavaScript, устаревший HTML, разные locale и пользователя без права на часть данных.
\n

Отрицательный путь важнее счастливого

\n

Соблазнительный обход — всегда делать client fetch после mount. Он действительно может показать свежую запись, но стирает вопрос о рассинхроне. Причина может быть в кеше HTML, неправильном ключе сериализации, смене пользователя, locale или feature configuration. Автоматический запрос превращает диагностируемый mismatch в незаметную замену состояния.

\n

Другой обход — принять snapshot без проверки. Он экономит запрос, но может показать устаревшие или чужие данные, если документ и payload прошли разные границы кеширования. Третий обход — сравнивать только HTML-строку. Это дорого, хрупко и не объясняет, какие входы породили результат. Сравнивайте версионированный контракт, а не случайный побочный признак.

\n

Ограничения

\n

SSR не делает страницу автоматически быстрой. Сервер может дольше формировать ответ, а сериализованный payload увеличивает документ. CSR остаётся правильным выбором для данных, которые появляются только после действия пользователя, зависят от браузерного API или часто меняются. Не стоит передавать в snapshot секреты, которые нельзя отдавать клиенту. Не стоит принимать данные из snapshot для другого пользователя или другого набора прав.

\n

Версия snapshot не заменяет авторизацию, схему данных и обработку ошибок сети. Она только обозначает связь между двумя фазами одного чтения. Для потокового SSR, частичной гидрации и нескольких независимых источников понадобятся отдельные версии и правила владельца. Эта статья не утверждает, что React или любой фреймворк сам реализует описанный протокол.

\n

Критерий готовности

\n

Решение готово, если на одном документе можно показать четыре значения: версию source, маркер HTML, версию snapshot и действие hydrate. При совпадении тест подтверждает, что snapshot принят, initial state построен из него, а повторный запрос не ушёл. При несовпадении тест подтверждает, что обе версии записаны, mutation не началась и recovery path назван явно. В браузере это видно в сетевом журнале и диагностической записи. Без этих доказательств «SSR без второго запроса» остаётся предположением.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/217.json b/editorial/agent-rewrites/217.json new file mode 100644 index 0000000..ca07a5d --- /dev/null +++ b/editorial/agent-rewrites/217.json @@ -0,0 +1,7 @@ +{ + "index": 217, + "slug": "editorial-2021-12-field-architecture-review", + "title": "Как проверить архитектурное решение, пока ошибка ещё обратима", + "excerpt": "Пошаговая диагностика случая, когда запись сохранена, но публичное чтение её не показывает. Разбираем границы решения, канонический write, intent, повторную доставку и read contract.", + "contentHtml": "

Симптом выглядит просто: пользователь сохраняет заметку, получает успешный ответ, а затем не видит её в списке или по публичной ссылке. Команда сразу подозревает очередь, кеш или базу и повторяет операцию. Это опасный первый шаг. Повтор может создать вторую запись, второй intent или две проекции. После этого уже трудно установить, какая операция была исходной и на какой границе возникла ошибка. Цена ошибки — потеря версии, двойной побочный эффект и более дорогое расследование.

\n

Тезис статьи простой: архитектурное решение нужно проверять как причинную цепочку, а не как набор технологий. Сначала фиксируют наблюдаемый факт и идентификаторы. Затем отдельно проверяют решение, каноническую запись, намерение публикации, доставку и публичный контракт чтения. На каждой границе должно быть понятно, какое действие разрешено, а какое запрещено.

\n

Механизм: одна запись, несколько границ

\n

Сохранение данных и их публичное чтение часто проходят через разные состояния. Каноническая запись хранит источник истины. Intent сообщает, что для неё нужно выполнить следующий эффект. Relay переносит intent в read model. Публичный запрос читает уже проекцию, поиск или кеш. Успех на одной границе не доказывает успех на другой.

\n

Для диагностики нужна пара идентификаторов: noteId и revision. Один и тот же объект может иметь несколько версий. Если повторять действие только по заголовку или времени, система не отличит новую версию от повторной доставки старой. Для побочного эффекта нужен отдельный стабильный ключ. В учебном примере ниже ключ строится из идентификатора записи и версии:

\n
const intentKey = `${note.id}:${note.revision}`;\n\nconst intent = {\n  key: intentKey,\n  noteId: note.id,\n  revision: note.revision,\n  effect: 'publish',\n};\n\n// Пример учебный: он показывает форму данных,\n// но не заменяет транзакцию, очередь или БД.
\n

Такой ключ не решает проблему сам по себе. Consumer должен хранить информацию о принятом ключе и сравнивать версию проекции с версией источника. Если ключ уже применён, повторный вызов должен вернуть тот же смысловой результат, а не создать новый эффект. Если версия отличается, система должна остановиться и передать случай на проверку конфликта.

\n

Сначала отделите факт от гипотезы

\n

Фраза «публикация сломалась» уже содержит гипотезу. Факт короче: «после ответа 200 запрос GET /public/notes/42 не вернул revision 3 в 14:05:12». К факту добавляют способ чтения, область видимости, фильтры и момент проверки. Иначе команда сравнивает разные запросы и принимает различие контрактов за потерю данных.

\n

Минимальная карточка наблюдения должна отвечать на пять вопросов: какой объект изменяли, какую версию ожидали, где прочитали результат, что получили и какое состояние уже подтверждено. Не нужно сразу собирать все логи. Нужны данные, которые отделяют canonical write от relay и relay от read contract.

\n
Симптом → причина → проверка → действие
СимптомВероятная границаПроверкаДействие
Нет noteId или revisionНаблюдение неполноеСверить запрос, ответ и запись операцииОстановить повтор; восстановить идентификаторы
Есть intent, но нет канонической записиWrite pathПроверить commit source и порядок записиНе запускать relay; исправить запись источника
Есть note, intent отсутствуетГраница write → publishПроверить правило создания intent для точной revisionНе создавать копию; вернуть случай к границе записи
Intent pendingRelay pathНайти owner, ключ, статус job или receiptНаблюдать обработку; source не менять
Intent применён, projection стараяRead projectionСравнить key, revision и результат consumerРазрешить controlled replay только выбранного ключа
Projection свежая, public read пустRead contractПроверить scope, filter, права, кеш и queryЗакончить ветку relay; исследовать запрос чтения
\n

Таблица не ставит диагноз по одному признаку. Она ограничивает следующий шаг. Пока не найдено подтверждение канонической записи, нельзя обсуждать повторную доставку. Пока projection совпадает с источником, нельзя объявлять relay причиной пустого списка. Такой порядок сохраняет возможность отката и не смешивает владельцев разных компонентов.

\n

Решение должно фиксировать границы

\n

Архитектурная запись нужна не для длинного описания системы. Она фиксирует контекст, выбранный вариант, обязательные требования, допущения, последствия и способ пересмотра. Если команда записала только «используем outbox», она не ответила на главные вопросы: где заканчивается транзакция, кто читает intent, какой ключ считается идемпотентным и что делать при конфликте версий.

\n
Decision: public projection обновляется через intent и relay\nContext: canonical note уже записана, public read может быть отложенным\nMUST: повтор одного intent не создаёт второй effect\nMUST NOT: исправление удаляет source без отдельного доказательства\nAssumption: storage commit и intent имеют одну объявленную границу\nCheck: noteId + revision + intentKey видны в диагностике\nReview when: меняются storage, consumer или read contract
\n

Это учебная форма записи. Она не объявляет распределённую транзакцию и не доказывает exactly-once delivery. Её задача — сделать проверяемыми условия и отрицательный путь. Если допущение не подтверждено, решение получает статус «нужно проверить», а не превращается в разрешение на изменение данных.

\n

Особенно полезно отделять обязательное требование от предпочтения. «Повтор не должен создавать двойной эффект» — требование. «Используем конкретный брокер» — вариант. Если выбранный брокер меняется, требование остаётся, а решение пересматривают по тем же проверкам. Так архитектура не привязывается к названию инструмента.

\n

Канонический write и intent нельзя считать одним эффектом

\n

В простом варианте запись note и intent выполняют в одной транзакционной границе. В более сложном варианте они могут попасть в разные хранилища или пройти через отдельный сервис. Тогда нужно честно назвать окно расхождения. Наличие двух успешных ответов от разных API не является доказательством общей атомарности.

\n

Если note есть, а intent отсутствует, сначала проверяют границу записи: правило публикации, commit, обработчик после записи и разрешённый способ восстановления. Не создают новую note с другим id. Иначе исходная запись останется без intent, а новая начнёт отдельную причинную цепочку. Это удваивает проблему вместо восстановления состояния.

\n
function publishCandidate(state, note) {\n  const key = `${note.id}:${note.revision}`;\n\n  if (!state.decisionAccepted) {\n    return { status: 'blocked', reason: 'decision-missing' };\n  }\n\n  if (state.acceptedKeys.has(key)) {\n    return { status: 'already-accepted', key };\n  }\n\n  state.intents.push({ key, noteId: note.id, revision: note.revision });\n  return { status: 'intent-recorded', key };\n}
\n

Код иллюстративный. Он показывает две проверки: решение должно быть принято до изменения, а ключ должен быть стабильным. Массивы в памяти не защищают от падения процесса, гонки или частичной записи. В рабочей системе эти свойства нужно выразить средствами выбранного хранилища и проверить отдельным тестом.

\n

Pending не означает потерю

\n

Состояние pending означает только одно: существует разрешённое намерение, для которого ещё нет подтверждённого read effect. В зависимости от системы evidence может быть строкой outbox, статусом job, offset, receipt или записью consumer. Если такого следа нет, нельзя называть случай pending по интуиции. Нужно вернуться к write boundary.

\n

Нельзя очищать source, чтобы «запустить процесс заново». Нельзя менять revision, чтобы скрыть конфликт. Нельзя отправлять широкий replay без ограничения ключом. Пока owner relay не подтвердил результат, безопасное действие — сохранить исходное состояние и собрать недостающий evidence.

\n
\"Дерево
Граница причины меняется только после подтверждения предыдущего состояния.
\n

Когда допустим controlled replay

\n

Повторная доставка допустима, когда известны intentKey, noteId, revision, владелец проекции и результат предыдущей попытки. Relay должен проверять ключ до выполнения эффекта. Если ключ уже принят, consumer не создаёт вторую проекцию. Если ключ неизвестен, он применяет только заявленную версию и записывает результат.

\n

Replay не исправляет неверный контракт чтения. Если проекция уже содержит revision 3, но поиск её не возвращает, повтор relay не добавляет доказательств. Нужно проверить фильтр, tenant, права, кеш, формат идентификатора и конкретный query. Публичный список и прямое чтение по id могут иметь разные правила. Это отдельная причина, а не продолжение relay.

\n

Отрицательный путь важнее happy path. Если revision проекции новее источника, обработчик не должен молча откатывать её старой доставкой. Если один ключ связан с другим payload, обработчик не должен считать запрос повтором. Он должен вернуть конфликт и сохранить обе версии для разбора. Иначе идемпотентность превращается в тихое подавление ошибки.

\n

Порядок действий

\n
  1. Зафиксировать точный симптом: запрос, время, noteId, ожидаемую revision, scope и полученный ответ.
  2. Проверить решение: есть ли контекст, требования, допущения, выбранный вариант и явный отрицательный путь.
  3. Проверить каноническую запись по noteId и revision. Не создавать замену до завершения этой проверки.
  4. Проверить intent для точного ключа noteId:revision. Если ключа нет, исследовать write boundary, а не очередь.
  5. Если intent pending, найти owner relay и разрешённое evidence обработки. Source не изменять.
  6. Если intent применён, сравнить revision проекции с revision источника. Replay ограничить одним ключом и одним известным consumer.
  7. Если проекция свежая, проверить read contract: scope, filter, права, кеш, формат и query.
  8. Записать результат проверки и только затем выполнить обратимое действие на найденной границе.
\n

Порядок важен потому, что каждая операция меняет наблюдаемое состояние. Удаление или повтор записи до фиксации evidence уничтожает исходный контекст. Диагностика должна вести к меньшему числу возможных причин, а не создавать новые варианты.

\n

Ограничения метода

\n

Эта схема не делает eventual consistency мгновенной. Она не заменяет мониторинг, резервное копирование, контроль прав или тестирование отказа брокера. Она также не доказывает атомарность между независимыми системами. Если commit и intent находятся в разных хранилищах, нужно отдельно описать окно расхождения и способ сверки.

\n

Пример с Map и массивом применим только для объяснения формы состояний. Он не учитывает несколько процессов, конкурирующие записи, рестарт, сетевой timeout и повтор после неизвестного результата. В реальной системе идентификатор операции должен сохраняться достаточно долго, чтобы обработчик отличал поздний повтор от нового намерения. Политику хранения ключей выбирают по сроку возможного повтора и риску побочного эффекта.

\n

Метод не разрешает менять требования задним числом. Если выяснилось, что задержка недопустима, это новое требование к решению. Если public read должен быть строго синхронным, outbox с отложенной проекцией может не подходить. В таком случае фиксируют несовпадение и пересматривают вариант, а не маскируют его дополнительными retry.

\n

Критерий готовности

\n

Разбор готов, когда другой инженер может повторить его без устного контекста. В записи есть исходный симптом, идентификаторы, граница причины, evidence проверки, разрешённое действие и ограничение примера. Для конкретного случая должны быть видны noteId, revision, intentKey и результат public read. Для повторной доставки отдельно указаны owner, условие идемпотентности и ожидаемый результат повторного запроса.

\n

Проверяемый критерий можно сформулировать так: один и тот же intentKey при двух одинаковых доставках даёт не более одного эффекта, а доставка старой revision не затирает более новую проекцию. Для пустого public read критерий другой: после подтверждённой свежей проекции найдено объяснение в scope, filter, правах, кеше или query. Если ни один критерий нельзя проверить по сохранённому evidence, разбор ещё не закончен.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/218.json b/editorial/agent-rewrites/218.json new file mode 100644 index 0000000..91e9eb5 --- /dev/null +++ b/editorial/agent-rewrites/218.json @@ -0,0 +1,7 @@ +{ + "index": 218, + "slug": "editorial-2021-12-mechanism-architecture-review", + "title": "Архитектурное решение как проверяемый контракт: запись, повтор и откат", + "excerpt": "Схема не объясняет, что происходит после сбоя. Разбираем, как связать требования, evidence и assumption, отделить запись намерения от публичного эффекта и проверить безопасный повтор.", + "contentHtml": "

Симптом появляется после первой ошибки: рабочая заметка сохранена, но публичная версия не обновилась. Команда повторяет отправку. Иногда читатель получает свежий текст, иногда появляются две публикации, а иногда повтор скрывает исходную причину. На схеме стрелка «записать → отправить» выглядит одной операцией, хотя между ними уже есть граница хранения, очередь или отдельный процесс.

\n

Цена ошибки — не только лишний запрос. Без отдельного следа намерения нельзя понять, была ли публикация разрешена, потерялось ли сообщение или уже сломался public read. Нельзя безопасно выбрать retry и нельзя доказать, что повтор не создаст второй эффект.

\n

Тезис статьи простой: архитектурное решение должно описывать не любимую технологию, а проверяемый контракт. В нём есть контекст, требования, варианты, evidence, assumption, последствия и обратимый путь выхода. Для публикации заметки это означает три разные сущности: каноническую запись, intent публикации и публичную проекцию.

\n

Что именно нужно зафиксировать

\n

Каноническая запись принадлежит write path. Она хранит исходную заметку и её revision. Intent означает: для этой revision разрешено создать публичный эффект. Projection принадлежит read path. Она может появиться позже и иметь собственное состояние.

\n

Такое разделение не делает систему надёжной автоматически. Оно делает отказ различимым. До relay видны note и pending intent. После relay можно сравнить ключ, revision и projection. Если projection уже совпадает с note, повторная обработка intent не отвечает на проблему читателя: нужно проверить query, фильтр, права или cache.

\n

Сначала запишите требования словами, которые можно проверить. В учебном кейсе они такие: каноническая запись существует; intent сохранён отдельно; public read независим; задержка чтения допустима и видима; повтор использует ключ. Отдельно запишите non-goals: распределённая транзакция, выбор брокера, production-SLO и exactly-once delivery не следуют из слова outbox.

\n
Минимальный контракт архитектурного решения
ПолеЧто фиксируетПроверкаОграничение
ContextКакая запись и какой public read расходятсяНазваны source, reader и граница публикацииНе описывает всю платформу
RequirementОбязательное свойство потокаУ свойства есть наблюдение или assertionНе является пожеланием команды
EvidenceФакт и область его наблюденияУказаны claim, источник и scopeНе подтверждает соседнюю систему
AssumptionНепроверенное условие и цена ошибкиЕсть consequence, если условие ложноНельзя выдавать за гарантию
ReversibilityКак остановить или заменить вариантНазвано действие без удаления sourceОткат требует проверки среды
\n

Механизм: requirements сначала, вариант потом

\n

Сравнивать архитектуры по общему score опасно. Score скрывает потерянное условие. Лучше вернуть gaps — конкретные требования, которые вариант не закрывает.

\n
const brief = {\n  requirements: [\n    'canonical-write-record',\n    'recorded-publication-intent',\n    'independent-public-read',\n    'delayed-read-is-explicit',\n    'replay-key-is-explicit',\n  ],\n};\n\nfunction compare(option) {\n  const gaps = brief.requirements\n    .filter((name) => !option.supports.includes(name));\n\n  return {\n    id: option.id,\n    accepted: gaps.length === 0,\n    gaps,\n    reversibleBy: option.reversibleBy,\n  };\n}
\n

Рассмотрим три варианта. Синхронная запись с проекцией подходит, если read model локальна и её обновление входит в одну понятную границу. Она не закрывает текущий brief, если intent обязан пережить сбой отдельно. Синхронный query сохраняет меньше состояний, но не даёт независимого public read. Transactional outbox закрывает brief, если БД действительно записывает note и intent в одной локальной транзакции, а relay работает после commit.

\n

Последнее условие нельзя получить из примера на JavaScript. Небольшая функция с двумя `Map` показывает policy, но не моделирует блокировки, crash window, commit, брокер, сеть или права. Поэтому в решении нужно написать assumption: «учебная операция применяет пару note и intent вместе». Consequence звучит жёстко: если реальное хранилище не даёт такую границу, выбранный вариант нельзя переносить без нового разбора.

\n
\"Требования
Схема показывает владельцев состояния и место, где решение можно опровергнуть.
\n

Запись намерения не равна публикации

\n

Write path должен сохранить две связанные записи: note с `id` и `revision`, затем intent с ключом `noteId:revision`. Public projection не создаётся внутри этой операции. Она появляется только после обработки intent.

\n
function commitPublication(state, decisionId, note) {\n  if (!state.decisions.has(decisionId)) {\n    return { ok: false, reason: 'decision-missing' };\n  }\n\n  const key = `${note.id}:${note.revision}`;\n  if (state.intents.has(key)) {\n    return { ok: false, reason: 'intent-already-recorded' };\n  }\n\n  const notes = new Map(state.notes);\n  const intents = new Map(state.intents);\n  notes.set(note.id, note);\n  intents.set(key, { key, noteId: note.id, revision: note.revision, status: 'pending' });\n  state.notes = notes;\n  state.intents = intents;\n\n  return { ok: true, key };\n}\n\nfunction relayPublication(state, key) {\n  if (state.acceptedKeys.has(key)) {\n    return { status: 'duplicate-relay-suppressed' };\n  }\n\n  const intent = state.intents.get(key);\n  const note = intent && state.notes.get(intent.noteId);\n  if (!intent || !note || note.revision !== intent.revision) {\n    return { status: 'source-or-revision-mismatch' };\n  }\n\n  state.projection.set(note.id, { ...note });\n  state.acceptedKeys.add(key);\n  return { status: 'projection-recorded' };\n}
\n

Ключ защищает только этот effect внутри описанной модели. Он не делает внешний email, webhook или платёж идемпотентным. Если relay успел вызвать внешний сервис, а затем упал до записи receipt, повтор может снова вызвать внешний effect. Для каждого consumer нужен отдельный контракт: его ключ, срок хранения, ответ на конфликт и проверка результата.

\n

Важен и отрицательный путь. Нет decision — write path останавливается. Нет canonical note — relay не создаёт замену с новым id. Нет intent — нельзя лечить проблему повторной отправкой из UI. Pending intent — нужно проверить relay и его владельца. Projection с правильной revision — нужно закончить эту ветку и исследовать read contract.

\n

Симптомы и действия

\n

Диагностика должна сохранять состояние до исправления. Не удаляйте source, пока не записаны decision id, note id, revision, intent key и observed public read. Иначе повторная попытка может убрать единственное evidence.

\n
Диагностика потока публикации
СимптомПричинаПроверкаДействие
Нет записи в public readIntent ещё pendingНайти точный ключ и статус relayПроверить worker и сохранить intent
Повтор создаёт две проекцииНет стабильного ключа или receiptСравнить key, revision и историю effectДобавить idempotency contract consumer
Intent есть, note нетWrite boundary не атомарнаСопоставить commit и запись обеих сущностейОстановить relay; пересмотреть storage boundary
Projection совпадает, но UI старыйПроблема в query, cache, scope или правахПрочитать projection прямым запросом и повторить public queryПередать расследование owner read path
Revision не совпадаетСтарый intent или гонка версийСравнить note, intent и projection по revisionНе применять старый intent к новой записи
\n

Порядок действий

\n
  1. Зафиксируйте один симптом: какая заметка не видна, какая revision ожидается и какой public read проверяли.
  2. Назовите владельцев: source of truth, write path, intent store, relay и public read.
  3. Сформулируйте requirements и non-goals. Не подменяйте требование названием технологии.
  4. Сравните минимум три допустимых варианта по gaps, а для каждого запишите обратимое действие.
  5. Разделите evidence и assumption. Для assumption укажите consequence и условие пересмотра.
  6. Зафиксируйте decision до реализации. Если выбранный вариант не закрывает requirement, остановите работу.
  7. Проверьте write path на паре note плюс intent. В реальной БД подтвердите границу транзакции, а не переносите вывод из `Map`.
  8. Проверьте relay по ключу `noteId:revision`. Повтор того же ключа должен иметь явно заданный результат.
  9. Пройдите отрицательные ветки: missing decision, missing note, missing intent, pending relay, stale revision и projection без видимости в UI.
  10. Остановите изменение, если результат не наблюдаем. Сначала добавьте evidence, затем выбирайте corrective action.
\n

Ограничения и отрицательный путь

\n

Transactional outbox не является универсальным ответом. Он добавляет состояния, таблицу или журнал intent, relay, retry policy, receipt и наблюдение. Если public read может читать каноническую запись без отдельной задержки, синхронный query может быть дешевле. Если projection локальна и ошибка её обновления не требует отдельного recovery path, синхронная запись может быть достаточной.

\n

Не называйте локальную пару записей распределённой транзакцией. Не обещайте exactly-once, если внешний consumer не предъявляет receipt и правило дедупликации. Не используйте retry без ответа на вопрос, какой повтор безопасен. AWS отдельно связывает повтор с идемпотентностью операции; это не означает, что любой endpoint безопасен для повторного вызова.

\n

Ключ `noteId:revision` защищает одну версию заметки. Он не решает конфликт двух разных намерений, изменение схемы, истечение retention, ручное исправление projection или зависимость от времени. Такие условия должны попасть в следующий decision или в контракт конкретного consumer.

\n

Проверяемый критерий готовности

\n

Решение готово к реализации, если другой инженер без устного контекста может назвать source of truth, requirement, выбранный вариант, каждый gap альтернатив, evidence и assumption. Для одного потока он может показать note, intent key, статус relay и projection revision. Повтор известного ключа имеет проверяемый результат. При отсутствии decision, note или intent система не создаёт замену молча. Если projection совпадает с source, расследование переключается на read contract.

\n

Это критерий формы решения, а не production-результат. Его нужно подтвердить тестом на конкретном хранилище и consumer, затем отдельно измерить ошибки, задержки и recovery. До этого архитектура остаётся условной и должна так называться.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/219.json b/editorial/agent-rewrites/219.json new file mode 100644 index 0000000..283360d --- /dev/null +++ b/editorial/agent-rewrites/219.json @@ -0,0 +1,7 @@ +{ + "index": 219, + "slug": "editorial-2021-12-practice-architecture-review", + "title": "Граница публикации: как связать запись, событие и публичное чтение", + "excerpt": "Рабочая заметка может сохраниться, но не попасть в публичное чтение. Разбираем двойную запись, transactional outbox, повторную доставку и проверки, которые отделяют доказанный факт от предположения.", + "contentHtml": "

Симптом выглядит просто: автор сохраняет рабочую заметку, API отвечает успехом, но публичный список не показывает новую версию. Иногда запись появляется через минуту. Иногда её нет после перезапуска relay. Иногда пользователь видит старый текст, хотя в административном экране уже открыт новый. Цена ошибки — потерянное доверие к публикации и дорогая диагностика. Команда может повторить отправку, получить дубль или затереть свежую версию старой.

\n

Обычно причина не в одном медленном запросе. Сервис записывает заметку в базу, затем отдельно отправляет событие или обновляет read-модель. Между этими действиями есть окно сбоя. Процесс может завершиться после commit и до отправки. Брокер может принять сообщение, но relay не сохранить результат. Consumer может обработать событие дважды. Если система не различает эти факты, наблюдаемая задержка превращается в спор о технологиях.

\n

Тезис: разделите каноническую запись и эффект чтения

\n

У операции публикации должны быть разные владельцы. Каноническая заметка хранит то, что автор сохранил. Publication intent фиксирует решение передать конкретную версию в публичный контур. Relay доставляет intent. Read-модель показывает результат доставки. Эти объекты связаны ключом, но не становятся одной сущностью только потому, что их создал один HTTP-запрос.

\n

Такой раздел полезен, когда изменение базы и уведомление другого процесса нельзя выполнить одной локальной операцией. Transactional outbox закрывает первую границу: заметка и intent попадают в одну транзакцию базы. Затем отдельный relay читает подтверждённый intent и отправляет событие. Это не даёт exactly-once. Consumer всё равно должен переживать повтор, а публичное чтение — явно допускать задержку.

\n

Если публичный список может читать каноническую таблицу напрямую, outbox может быть лишним. Если read-модель обязана существовать отдельно, а факт намерения нельзя потерять, синхронная отправка после commit оставляет опасное окно. Выбор определяется этими условиями, а не названием паттерна.

\n

Механизм: четыре состояния одной публикации

\n

Сначала появляется note. Это каноническая версия с идентификатором, номером ревизии и текстом. В той же транзакции создаётся publication_intent с ключом noteId:revision. После commit intent становится доступен relay. Relay публикует его и отмечает попытку. Consumer создаёт или обновляет публичную проекцию по тому же ключу.

\n

Из этого следуют четыре проверяемых состояния. note-only означает, что запись есть, а intent нет. Это ошибка границы транзакции или неполный старый путь. intent-pending означает, что намерение зафиксировано, но эффект чтения ещё не подтверждён. Это задержка или сбой relay. intent-relayed означает, что событие отправляли, но проекция может быть устаревшей или consumer мог отклонить данные. projection-current означает, что публичный контур содержит ту же ревизию.

\n

Не называйте intent-pending потерей данных. Не называйте intent-relayed публикацией, пока не проверена проекция. Не называйте ответ HTTP доказательством последнего состояния. Каждый переход должен оставлять след, по которому можно восстановить ключ, ревизию и владельца следующего действия.

\n

Учебный пример границы записи

\n

Ниже — ограниченный учебный пример. Он показывает форму транзакции и идентификатора, но не заменяет конкретную СУБД, уровни изоляции, права, retry-политику или контракт брокера. Таблицы условны. В реальной системе обе вставки должны выполняться в одной транзакции того хранилища, которое действительно владеет заметкой.

\n
BEGIN;\n\nINSERT INTO notes (id, revision, body)\nVALUES ('n-42', 7, 'Текст рабочей заметки');\n\nINSERT INTO publication_intents (key, note_id, revision, status)\nVALUES ('n-42:7', 'n-42', 7, 'pending');\n\nCOMMIT;\n\n-- Relay читает только committed intents.\n-- Consumer применяет n-42:7 идемпотентно.
\n

Ключ n-42:7 важнее случайного UUID для эффекта публикации. Он отвечает на вопрос, какую именно ревизию можно повторить. Уникальное ограничение на пару note_id, revision не позволяет незаметно создать две канонические версии с одним номером. Уникальный ключ intent не позволяет одному решению породить два разных задания.

\n

Relay должен сначала прочитать committed intent, затем передать событие и безопасно повторить попытку после временного отказа. Если отметка «отправлено» записывается до передачи, падение между отметкой и отправкой может скрыть событие. Если отметка записывается после передачи, повтор возможен. Второй вариант требует идемпотентного consumer, но делает потерю обнаруживаемой и устранимой.

\n

Отрицательный путь начинается с отсутствующего решения. Если в записи нет intent, нельзя задним числом считать публикацию разрешённой только потому, что заметка видна оператору. Нужно остановить автоматический replay, найти границу старого write path и отдельно решить, можно ли безопасно создать intent для конкретной ревизии. Иначе восстановление превратится в новую запись поверх неизвестного состояния.

\n
Диагностика публикации заметки
СимптомПричинаПроверкаДействие
Каноническая заметка есть, intent нетДвойная запись выполнена разными операциями или сработал старый путьСопоставить note id, revision, commit и код write pathОстановить replay; принять отдельное решение о создании intent
Intent pending дольше допустимого окнаRelay не читает committed строки или повторная доставка завершается отказомПроверить статус, попытки, время последнего чтения и текст ошибки по ключуПовторить только этот ключ после устранения причины; не создавать новую ревизию
Событие отправлено, проекция стараяConsumer отклонил событие, применил старую версию или не обновил read-модельСопоставить envelope, revision, consumer result и текущую revision проекцииИсправить порядок или контракт consumer; replay делать идемпотентно
Одна ревизия видна дваждыConsumer не защищён от повторной доставкиНайти deduplication key и две операции примененияСделать применение условным по ключу; удалить дубль только по подтверждённому правилу
Публичное чтение показывает старый текст после новогоЗапрос читает stale projection или получает кэшированную версиюСравнить revision источника, projection, cache headers и scope запросаЯвно показать задержку или перестроить projection; не обещать мгновенную видимость
\n
Граница публикации заметки: транзакция сохраняет каноническую запись и intent, relay передаёт ключ, consumer обновляет публичную проекцию.
Каноническая запись и intent фиксируются вместе. Relay и consumer могут повторяться, поэтому ключ ревизии проходит через весь путь.
\n

Как читать доказательства

\n

Для каждой публикации соберите не общий лог, а причинную цепочку. Нужны noteId, revision, operation id, ключ intent, время commit, число попыток relay, результат передачи, результат consumer и revision публичной проекции. Если одно поле отсутствует, это не доказательство успеха. Это пробел наблюдаемости, который нужно назвать отдельно.

\n

Разделяйте факт и предположение. Факт: строка intent существует после commit. Факт: consumer вернул подтверждение для ключа n-42:7. Предположение: после этого пользователь увидит новую версию в любом публичном запросе. Последний вывод требует проверки read scope, кэша, фильтров и версии API. Наличие строки в проекции не доказывает, что endpoint читает именно её.

\n

Срок задержки тоже должен иметь владельца. Если интерфейс допускает eventual consistency, он должен отличать «публикация принята» от «публикация видна в списке». Если интерфейс обязан показывать новую версию до ответа, отдельная проекция может быть неподходящей. В этом случае нужно вернуть чтение к каноническому источнику или изменить контракт. Нельзя получить строгую видимость, просто добавив ещё один retry.

\n

Порядок действий

\n
  1. Зафиксируйте симптом с конкретными noteId и revision: что ответил write API, что вернул public read и когда появились оба ответа.
  2. Найдите каноническую запись и проверьте её revision, владельца и время commit. Не меняйте данные во время первичной проверки.
  3. Проверьте наличие intent с ключом noteId:revision. Если ключа нет, остановите автоматическое восстановление и разберите write boundary.
  4. Если intent есть, проверьте, что он вошёл в ту же транзакцию, что и note. Смотрите на реальный commit, а не на локальный объект в памяти.
  5. Проверьте relay: видит ли он только committed строки, сохраняет ли попытки и может ли повторить один ключ без создания нового intent.
  6. Проверьте consumer: принимает ли он revision, отклоняет ли старую версию и повторяет ли эффект без дубля.
  7. Сравните revision в public projection и в ответе endpoint. Отдельно проверьте scope, фильтр, кэш и авторизацию.
  8. Разберите медленный и отрицательный путь: сбой после commit, отказ брокера, повторное событие, старый consumer и отсутствующий intent.
  9. После исправления повторите тот же ключ и тот же сценарий. Зафиксируйте, какой переход изменился и какое наблюдение подтверждает результат.
\n

Когда outbox не нужен

\n

Не добавляйте outbox к каждой таблице. Он не оправдан, если изменение и чтение находятся в одной локальной транзакции, отдельного события нет, а публичный контракт может читать каноническую запись. Дополнительная таблица в этом случае увеличивает число состояний и операций без нового требования.

\n

Outbox также не решает задачу, если запись должна атомарно изменять несколько независимых сервисов. Локальная транзакция не пересекает границу этих сервисов. Нужны другой протокол координации, компенсация или согласованный процесс. Называть outbox распределённой транзакцией опасно: он фиксирует локальное намерение, а не подтверждает каждый внешний эффект.

\n

CDC может заменить отдельную outbox-таблицу, если конкретная база и платформа гарантируют нужный порядок, полноту изменений и повторную обработку. Это не бесплатный путь. Нужно проверить, какое событие формируется, как определяется ревизия, где хранится offset и что произойдёт при восстановлении consumer.

\n

Ограничения

\n

Материал не обещает мгновенную консистентность и не доказывает надёжность конкретного брокера. Учебный SQL не является готовой схемой миграции. Он не описывает блокировки, индексы, partitioning, TTL, шифрование, права доступа и нагрузочные пределы. Эти свойства проверяют по документации и конфигурации выбранной системы.

\n

Идемпотентность по ключу публикации защищает один эффект, но не отменяет побочные действия. Отправка письма, webhook, инвалидирование внешнего кэша и изменение поискового индекса имеют собственные ключи и правила повторения. Если consumer вызывает несколько эффектов, каждый из них должен иметь подтверждаемую защиту от дубля или компенсирующую операцию.

\n

Нельзя объявлять результатом исправления отсутствие одного симптома в одном браузере. Проверка должна охватывать новую ревизию, повторную доставку, задержку, падение relay после commit и старую проекцию. Если эти ветки не наблюдаемы, система может выглядеть исправной и снова потерять публикацию при следующем отказе.

\n

Проверяемый критерий готовности

\n

Решение готово к применению, если для каждой публикации можно ответить на пять вопросов: где лежит каноническая ревизия, где зафиксирован intent, какой ключ повторяет операцию, какая revision подтверждена consumer и что увидит public read при задержке. Проверка должна показать, что note и intent либо фиксируются вместе, либо операция откатывается; повтор одного ключа не создаёт второй публичный эффект; отсутствующий intent не запускает слепой replay; старая проекция остаётся различимой от свежей.

\n

Если на любой вопрос ответ строится на словах «обычно», «в конце концов» или «очередь сама доставит», граница ещё не доказана. Следующее действие — не добавить ещё одну попытку, а найти отсутствующий факт, назначить его владельца и проверить его тем же ключом публикации.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/220.json b/editorial/agent-rewrites/220.json new file mode 100644 index 0000000..5ad9abf --- /dev/null +++ b/editorial/agent-rewrites/220.json @@ -0,0 +1,7 @@ +{ + "index": 220, + "slug": "editorial-2021-11-field-load-testing", + "title": "Нагрузочный сигнал: как отличить проблему среды от узкого места", + "excerpt": "Рост ошибок или задержки после нагрузочного теста ещё не указывает на bottleneck. Разбираем evidence, workload и границы измерения, а затем выбираем одно проверяемое действие.", + "contentHtml": "

После нагрузочного теста команда видит знакомую картину: доля ошибок выросла, p95 стал хуже, а график нагрузки похож на вчерашний. Кто-то сразу увеличивает пул соединений. Кто-то меняет таймаут. Кто-то запускает тест ещё раз с большим числом виртуальных пользователей. Через час система получила несколько изменений, но причина сигнала осталась неизвестной.

\n

Цена ошибки — не только лишние минуты на расследование. Неверный лимит может скрыть отказ зависимости. Повторный прогон в другой среде создаёт ложное сравнение. Исправление кода по агрегату без сценария может ухудшить обычный путь пользователя. В итоге команда получает красивое число без доказательства того, что именно оно измерило.

\n

Тезис простой: нагрузочный сигнал становится основанием для изменения только тогда, когда его можно связать с конкретным endpoint, workload, средой, данными и критерием завершения. До этого signal — повод для проверки. Диагностика должна разделить три ветки: изменилась среда, неполон сценарий или проявилась граница системы. Учебный пример ниже показывает порядок рассуждения. Он не является результатом production-прогона.

\n

Механизм: один aggregate скрывает несколько причин

\n

Итоговый error rate складывает ответы разных маршрутов, фаз и наборов данных. То же происходит с latency. В одном запуске p95 мог вырасти из-за медленного endpoint. В другом — из-за короткого burst, который пришёл в другой момент. В третьем — из-за retry в клиенте. Если в отчёте есть только total, эти случаи выглядят одинаково.

\n

Сначала зафиксируйте intent запроса: метод, путь, параметры и ожидаемый ответ. Затем запишите identity среды: build, конфигурацию, версию зависимостей, лимиты и внешний сервис, к которому обращается приложение. Для данных укажите версию набора и правило изменения. Наконец, разделите профиль на фазы. Warm-up проверяет договор запуска. Steady удерживает повторяемый участок. Step проверяет заранее выбранное изменение. Recovery показывает, что система или модель вернулась к меньшему плану.

\n

Эта структура нужна не для красивого отчёта. Она связывает наблюдение с действием. Если identity среды отличается, нельзя делать вывод о коде. Если пропущен step, нельзя объяснить переход. Если нет recovery, нельзя утверждать, что после пика система вернулась в исходное состояние. Если сигнал не содержит измеряемой метрики, его нельзя называть latency или error rate.

\n

Конкретный пример: порог и контекст должны жить рядом

\n

Ниже — учебный фрагмент для Grafana k6. Он показывает контракт теста: две именованные метрики, два порога и один endpoint. Адрес замените на разрешённый тестовый стенд. Числа выбраны только для объяснения синтаксиса. Они не описывают допустимые значения вашего сервиса.

\n
import http from 'k6/http';\n\nexport const options = {\n  thresholds: {\n    http_req_failed: ['rate<0.01'],\n    http_req_duration: ['p(95)<400'],\n  },\n};\n\nexport default function () {\n  const response = http.get(\n    'https://test.example.invalid/catalog/neutral-item'\n  );\n\n  if (response.status !== 200) {\n    console.log(`unexpected status: ${response.status}`);\n  }\n}
\n

Порог отвечает на вопрос «прошёл ли тест по заданному правилу». Он не отвечает на вопрос «почему правило нарушено». Если threshold упал, сохраните сырые результаты и контекст запуска. Не меняйте одновременно маршрут, число виртуальных пользователей, таймаут и конфигурацию базы. Иначе следующий прогон уже не проверит исходную гипотезу.

\n

В этом примере нет собственного результата. Условный домен не предназначен для настоящего вызова, а значения 1% и 400 мс — учебные. Реальный threshold должен следовать из контракта сервиса или SLO. Отдельно проверьте, что метрика собрана именно для нужного маршрута, а не для суммы всех запросов. В k6 порог привязан к имени метрики и агрегированию; это полезная граница между критерием pass/fail и объяснением причины.

\n

Как читать сигнал

\n

Начните с environment drift. Сверьте manifest запуска с manifest ожидаемого стенда. Важны не только слова stage и preprod. Проверьте commit, переменные окружения, лимиты контейнера, размер пула, версии базы и поведение внешних зависимостей. Если один из этих элементов изменился, сохраните различие и остановите вывод о приложении. Это отрицательный путь: иногда результат нельзя классифицировать, и честное «недостаточно evidence» лучше неподтверждённого bottleneck.

\n

Затем ищите scenario gap. Сравните endpoint, порядок фаз, данные, интенсивность и время каждой фазы. Общее число запросов не заменяет arrival pattern. Пять тысяч запросов за короткий burst и пять тысяч равномерных запросов проверяют разные очереди. Данные из пустого кэша и данные из прогретого кэша тоже создают разные условия. Если запуск не записывает эти параметры, сначала восстановите карточку workload.

\n

Третья ветка — граница системы. Для неё нужны независимые признаки: распределение задержки по маршруту, доля ответов по статусу, загрузка CPU и памяти, очередь, pool wait, обращения к базе и состояние зависимости. Один график не доказывает причину. Даже совпадение по времени — только гипотеза. Проверка должна изменить одну переменную или добавить один сигнал, который способен подтвердить или опровергнуть ветку.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Ошибка выросла после запускаДругая среда или зависимостьСверить build, config, лимиты и версии по manifestНе менять код; повторить сравнение в одной identity среды
p95 хуже, но total похожИзменился профиль или маршрутРазложить результат по endpoint, фазе, статусу и даннымВосстановить workload и повторить одну фазу
Отказы начинаются только на stepОчередь, pool или лимит зависимостиСопоставить время отказов с wait, saturation и статусамиПроверить одну границу отдельным прогоном
После нагрузки сигнал не исчезаетНакопившееся состояние или неполный recoveryПроверить очистку, backlog, кэш и фазу recoveryОстановить расширение нагрузки и восстановить состояние
Есть только один aggregateНедостаточный evidenceПроверить наличие intent, среды, данных, фаз и stop criterionОставить verdict неизвестным и собрать недостающие поля
\n
\"Диагностическое
Схема показывает порядок чтения сигнала. Учебная ветка не объявляет production-причину: её нужно подтвердить отдельным измерением.
\n

Порядок расследования

\n
  1. Сохраните raw output, время запуска, commit, конфигурацию и версию инструмента. Не перезаписывайте первый результат.
  2. Назовите сигнал точно: error rate, статус ответа, p95, pool wait или другой измеряемый показатель. Не подменяйте его общим словом «тормозит».
  3. Сверьте identity среды и внешних зависимостей. При расхождении остановите сравнение и зафиксируйте drift.
  4. Разложите workload по маршрутам, фазам, данным и интенсивности. Проверьте, что запуск действительно повторяет заявленный путь пользователя.
  5. Сформулируйте одну гипотезу о границе. Укажите, какой независимый сигнал должен измениться, если гипотеза верна.
  6. Проведите один обратимый эксперимент. Измените одну переменную и сохраните прежний пакет evidence для сравнения.
  7. Сделайте вывод только по заранее объявленному критерию. Если evidence не хватает, оставьте результат неопределённым и запишите следующий измеримый шаг.
\n

Этот порядок защищает от отрицательного пути. Если среда не совпала, не стоит чинить SQL. Если сценарий неполон, не стоит увеличивать нагрузку. Если latency в запуске не собиралась, нельзя восстановить её из количества rejected units. Если причина не подтверждена, не следует превращать гипотезу в рекомендацию по архитектуре.

\n

Ограничения

\n

Учебный код не моделирует ваш сервер, сеть, DNS, TLS, браузер, базу, кэш, планировщик, очередь, пользователей или capacity. Учебные числа не являются benchmark. Порог из примера не переносите в production без владельца SLO. Документация k6 объясняет синтаксис thresholds, но не знает контракта вашего endpoint. OpenTelemetry описывает инструменты и модель метрик, но сама спецификация не создаёт наблюдение там, где приложение его не экспортирует.

\n

Нагрузочный тест также не заменяет функциональную проверку. Успешный статус может скрывать неверное тело ответа. Низкая задержка может быть следствием кэша, которого нет у пользователя. Стабильный p95 не доказывает отсутствие редких отказов. Поэтому критерий должен включать нужные assertions, окно наблюдения, допустимый error rate и условия восстановления. Их значения задаёт владелец сервиса, а не пример из этой статьи.

\n

Проверяемый критерий готовности

\n

Расследование готово, когда другой инженер может открыть один пакет и ответить на пять вопросов: какой endpoint проверяли, в какой среде, с какими данными, каким профилем и по какому критерию приняли результат. В пакете есть raw output, версия инструмента, конфигурация, разрез по фазам и статусам, выбранная гипотеза, проверяющий сигнал и результат одного эксперимента. Для подтверждённой причины действие связано с этим сигналом. Для неподтверждённой причины verdict остаётся «недостаточно evidence».

\n

Практический финальный тест прост: удалите из отчёта график total и попробуйте восстановить решение по остальным данным. Если решение исчезло, измерение было слишком грубым. Если маршрут, среда, причина и следующий шаг остаются видимыми, нагрузочный сигнал стал рабочим evidence, а не поводом для случайной настройки.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/221.json b/editorial/agent-rewrites/221.json new file mode 100644 index 0000000..f60598d --- /dev/null +++ b/editorial/agent-rewrites/221.json @@ -0,0 +1,7 @@ +{ + "index": 221, + "slug": "editorial-2021-11-mechanism-load-testing", + "title": "Почему «много запросов» не является нагрузочной моделью", + "excerpt": "Нагрузка помогает найти предел системы только тогда, когда известны сценарий, среда, данные и критерий остановки. Разбираем, как отделить план входа от выполненной работы, проверить отрицательный путь и не выдать учебную модель за benchmark.", + "contentHtml": "

В отчёте после теста появляется одна цифра: «мы отправили 100 000 запросов». Через час команда видит рост времени ответа и начинает менять таймауты, пул соединений или SQL. Но из этой цифры не видно, какой endpoint вызывали, когда возникали запросы, какие данные читали, что считали завершённой работой и в какой среде шёл запуск. Ошибка стоит дорого: можно потратить релиз на исправление несуществующего узкого места и при этом оставить настоящий предел без проверки.

\n

Тезис простой: нагрузочная модель описывает не объём запросов, а договор между входом, системой и наблюдением. Минимальный договор содержит endpoint, данные, среду, порядок сегментов, критерий остановки и пакет свидетельств. Без него aggregate показывает только объём, но не объясняет причину и не подсказывает безопасное действие.

\n

Что именно нужно разделить

\n

Сначала отделите намерение создать вход от факта выполненной работы. В плане есть запланированный слот: например, одна операция чтения каталога в сегменте steady. В реальном запуске слот может стать HTTP-запросом, ошибкой клиента, таймаутом или вообще не стартовать. Эти события нельзя складывать в одну величину и называть её throughput.

\n

Вторая граница проходит между учебной моделью и настоящим тестом. В учебном примере можно материализовать слоты в принятые и отклонённые units, чтобы проверить порядок и остановку. Такой код не создаёт часы, сеть, сервер, очередь, базу или telemetry. Он проверяет форму рассуждения. Чтобы говорить о latency, error rate или capacity, нужен отдельный запуск с реальным источником времени, конфигурацией инструмента, данными и raw output.

\n

Третья граница — между профилем и результатом. Профиль задаёт переход warm-up → steady → step → recovery. Результат показывает, что произошло на каждом переходе. Один total скрывает порядок. Два запуска с одинаковым total могут иметь разные входы и разные причины ухудшения.

\n

Минимальная модель workload

\n

Запишите пять полей до запуска. endpointIntent ограничивает предмет и метод. dataSetup описывает набор данных и его identity. environment фиксирует build, конфигурацию и внешние зависимости. segments задают порядок и объём входа. stopCriterion объясняет, когда эксперимент заканчивается и какой результат считается достаточным.

\n

К этим полям добавьте evidencePacket. Он связывает план с наблюдением: хранит identity среды и данных, имена сегментов, число запланированных слотов, принятые и отклонённые units, состояние остановки и диагностические пометки. Пакет не заменяет результат инструмента. Он не делает fixture trace и не создаёт production evidence. Он не даёт потерять условия, пока команда читает результат.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Есть только «много запросов» и один totalAggregate выдали за workloadНазвать endpoint, данные, сегменты и критерий остановкиОтклонить сравнение и восстановить карточку сценария
На графике выросло время ответаНеизвестно, измерялся ли тот же endpoint в той же средеСверить environment identity, build, конфигурацию и raw signalПовторить один сценарий после фиксации среды
Часть входа исчезла из результатаНе сведены planned slots и completed unitsПроверить равенство planned = accepted + rejectedИсправить reconciliation до анализа причин
Fixture показывает bottleneckУчебный limit приняли за узкое место сервисаПроверить наличие часов, сервера, сети и источника метрикиНазвать результат synthetic boundary и подготовить реальный tool-run
Threshold не пройденКритерий не связан с конкретной метрикой и цельюПроверить имя метрики, единицу, окно и условие abortОставить один измеримый критерий и повторить тест
\n

Пример: отрицательный путь должен быть виден

\n

Ниже учебная модель для одного нейтрального endpoint. Она намеренно не выполняет HTTP-запрос. Сегмент step планирует пять слотов, но принимает только три по заранее объявленному synthetic limit. Два слота остаются отклонёнными. Это позволяет проверить отрицательный путь: нагрузка не исчезла между планом и packet, а recovery идёт после границы.

\n
const profile = {\n  endpointIntent: { method: 'GET', path: '/catalog' },\n  environment: 'isolated-training-envelope-v1',\n  segments: [\n    { name: 'warm-up', planned: 2, accepted: 2, rejected: 0 },\n    { name: 'steady', planned: 4, accepted: 4, rejected: 0 },\n    { name: 'step', planned: 5, accepted: 3, rejected: 2,\n      syntheticLimit: 3 },\n    { name: 'recovery', planned: 2, accepted: 2, rejected: 0 }\n  ],\n  stopCriterion: 'packet-reconciled-after-recovery'\n};\n\nfor (const segment of profile.segments) {\n  if (segment.planned !== segment.accepted + segment.rejected) {\n    throw new Error(`Unreconciled segment: ${segment.name}`);\n  }\n}\n\nconst bareCount = { planned: 100000 };\nconst acceptedAsLoadModel =\n  'endpointIntent' in bareCount && 'stopCriterion' in bareCount;\nconsole.assert(acceptedAsLoadModel === false);
\n

Assertion в конце не измеряет производительность. Она защищает термин. Объект с одним счётчиком не получает статус нагрузочной модели. В коде нет fetch, часов, процесса и внешнего состояния. Поэтому его результат нельзя читать как latency, throughput, error rate или capacity. Если добавить реальный вызов, это станет уже другой проверкой с другими условиями.

\n
\"Схема
Схема показывает границу между планом входа и evidence packet. Красная ветка с одним aggregate отклоняется. Synthetic units не являются latency или throughput.
\n

Как читать arrival

\n

Arrival — это правило появления следующего входа. Оно не равно completed work. При модели с фиксированным числом виртуальных пользователей следующая итерация может зависеть от завершения предыдущей. При модели с открытым потоком инструмент старается поддержать заданный arrival rate и отдельно показывает, успевает ли система обрабатывать вход. Значение зависит от конкретного executor, версии и конфигурации. Переносить смысл одного параметра между инструментами нельзя.

\n

Поэтому сначала назовите вопрос. Проверяете поведение endpoint при росте входа? Проверяете сохранение времени ответа при фиксированном потоке? Проверяете прохождение порога ошибок? Для каждого вопроса нужны свои единицы и свой stop criterion. Слово «нагрузка» без этого выбора слишком широко.

\n

Почему observability начинается с имён

\n

Хорошая метрика имеет смысл до того, как попадёт на dashboard. Назовите endpoint, service, environment, segment и единицу. Не называйте synthetic rejection ошибкой HTTP. Не называйте число слотов RPS, если у него нет времени. Не смешивайте клиентский timeout с ответом сервера. Такие запреты короче, чем последующее расследование неверного графика.

\n

Смысл полей должен сохраняться при агрегации. Если два результата нельзя сопоставить по endpoint, данным, среде и профилю, их нельзя честно сравнить по одному p95 или total. Пакет свидетельств нужен именно для этого: он показывает, какие условия совпали, а какие изменились.

\n

Порядок действий

\n
  1. Сформулируйте вопрос теста одним предложением и назовите endpoint, метод и ожидаемый результат.
  2. Зафиксируйте data setup: identity набора, объём, состояние и способ восстановления.
  3. Опишите environment envelope: build, конфигурацию, зависимости, лимиты и место запуска.
  4. Разложите вход на именованные сегменты warm-up, steady, step и recovery либо объясните другой порядок.
  5. Для каждого сегмента укажите единицу входа и правило arrival. Отдельно запишите, что считается completed work.
  6. Задайте один stop criterion, связанный с конкретной метрикой или с явной проверкой полноты packet.
  7. Запустите инструмент отдельно от учебной модели и сохраните версию, конфигурацию и raw output.
  8. Сведите planned slots с accepted и rejected units. При несовпадении остановите анализ и исправьте данные.
  9. Сравните результат только с запуском, у которого совпадают endpoint, данные, среда и профиль. После изменения условий дайте запуску новую identity.
\n

Ограничения

\n

Эта статья не сообщает, какую нагрузку выдержит конкретный сервис. Учебный пример не создаёт пользователей, соединения, DNS, TLS, сеть, очередь, CPU, память, базу, cache, browser или production incident. Synthetic limit показывает только заранее заданную границу модели. Rejected unit не означает HTTP error. Recovery в списке не доказывает восстановление реальной системы.

\n

Официальная документация инструмента всё равно нужна перед запуском. Сценарии k6 описывают разные профили workload, а thresholds задают pass/fail criteria для конкретных метрик. OpenTelemetry задаёт общие имена и смысл атрибутов, но не превращает любую локальную цифру в корректную телеметрию. Эти источники помогают выбрать термин и критерий. Они не заменяют проверку вашей среды и данных.

\n

Проверяемый критерий готовности

\n

Материал и реальный запуск готовы к анализу, если независимый инженер может по packet ответить на шесть вопросов: какой endpoint проверяли; какие данные использовали; в какой среде; как появлялся вход; что считалось выполненной работой; почему тест остановился. Для каждого сегмента сходятся planned = accepted + rejected. Для каждой заявленной метрики указаны источник, единица и окно. Если хотя бы один ответ отсутствует, результат — не verdict о системе, а незавершённый эксперимент.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/222.json b/editorial/agent-rewrites/222.json new file mode 100644 index 0000000..78d4bbb --- /dev/null +++ b/editorial/agent-rewrites/222.json @@ -0,0 +1,7 @@ +{ + "index": 222, + "slug": "editorial-2021-11-practice-load-testing", + "title": "Нагрузочное тестирование: как связать профиль, измерение и решение", + "excerpt": "Нагрузочный тест даёт полезный ответ только тогда, когда команда заранее описала сценарий, среду, данные и критерий успеха. Разбираем практическую схему, пример на k6 и отрицательный путь, который не позволяет выдать учебный счётчик за capacity.", + "contentHtml": "

Проблема: после запуска команда видит растущую задержку и ошибки, но не может сказать, какой сценарий их вызвал; цена ошибки — неверный фикс, повторный прогон на другой среде и пропущенный отказ в production.

\n

Нагрузочное тестирование проверяет не абстрактное «быстрее или медленнее», а поведение системы при заданном потоке работы. Поэтому сначала описывают workload, затем выбирают инструмент и метрики. Workload отвечает на четыре вопроса: какой запрос выполняется, с какими данными, с какой моделью поступления и до какой границы продолжается проверка. Если хотя бы один ответ потерян, итоговый RPS мало что объясняет.

\n

Тезис простой: нагрузочный тест становится инженерным доказательством только тогда, когда его вход, наблюдение и решение связаны одним контрактом. Число запросов без контекста не показывает ни причину задержки, ни безопасный следующий шаг.

\n

Механизм: профиль меняет условия проверки

\n

Запросы создают конкуренцию за ограниченные ресурсы. Это могут быть соединения к базе, worker-потоки, CPU, память, дисковый ввод-вывод или лимит внешнего API. Пока запас ресурса велик, время ответа растёт слабо. Когда очередь становится длиннее, система начинает накапливать работу. Затем появляются таймауты, ретраи и ошибки. Ретраи увеличивают поток и могут ускорить отказ.

\n

Один итоговый счётчик скрывает переходы между этими состояниями. Профиль сохраняет их: короткий прогрев проверяет сам сценарий, стабильный участок даёт повторяемую базу, ступенька проверяет заранее выбранную границу, а восстановление показывает, уходит ли очередь после снижения входа. Названия сегментов не важны сами по себе. Важно объяснить роль каждого сегмента и его условия.

\n

Нужно также различать закрытую и открытую модели. В закрытой модели следующий шаг обычно начинается после завершения предыдущего. Замедление системы уменьшает фактический поток. В открытой модели новые итерации стартуют по расписанию независимо от ответа системы. Замедление увеличивает число одновременно работающих итераций. Эти модели отвечают на разные вопросы. Их нельзя сравнивать только по числу виртуальных пользователей.

\n
Что должно быть видно в нагрузочном тесте
ПолеПримерЗачем оно нужно
СценарийGET /catalog/items, доля чтений 80%Показывает, какую работу моделирует тест
ДанныеВерсия набора, размер ответа, правило очисткиОтделяет эффект данных от эффекта кода
Модель входа30 итераций в секунду в течение 5 минутОбъясняет, что означает скорость
Наблюдениеp95, p99, доля ошибок, насыщение CPUСвязывает симптом с ресурсом
Порогp95 < 300 мс, ошибки < 1%Превращает график в проверяемое решение
\n

Пример: сначала договор, потом запуск

\n

Допустим, нужно проверить чтение каталога перед изменением индекса. Учебный сценарий ниже показывает форму проверки. Он не обращается к реальному сервису и не подтверждает его производительность. Адрес, длительность, скорость и пороги здесь демонстрационные. В рабочем тесте их заменяют условиями конкретного сервиса.

\n
import http from 'k6/http';\nimport { check, sleep } from 'k6';\n\nexport const options = {\n  scenarios: {\n    catalog_read: {\n      executor: 'ramping-arrival-rate',\n      startRate: 5,\n      timeUnit: '1s',\n      preAllocatedVUs: 10,\n      maxVUs: 50,\n      stages: [\n        { target: 5, duration: '30s' },\n        { target: 30, duration: '2m' },\n        { target: 5, duration: '30s' }\n      ]\n    }\n  },\n  thresholds: {\n    http_req_failed: ['rate<0.01'],\n    http_req_duration: ['p(95)<300']\n  }\n};\n\nexport default function () {\n  const response = http.get(`${__ENV.BASE_URL}/catalog/items`);\n  check(response, {\n    'status is 200': (r) => r.status === 200,\n    'body is not empty': (r) => r.body.length > 0\n  });\n  sleep(1);\n}
\n

В этом примере ramping-arrival-rate задаёт изменение скорости старта итераций, а не обещает, что сервер завершит их с той же скоростью. Параметр maxVUs ограничивает ресурс самого генератора. Если виртуальных пользователей не хватает, тест может не поддержать заданный arrival rate. Это отдельный сигнал о конфигурации теста, а не доказательство отказа приложения.

\n

Порог тоже не равен факту. Запись p(95)<300 задаёт правило pass/fail для конкретной метрики. Она не говорит, почему порог нарушен. Причину ищут по временной связи с очередями, CPU, базой, внешними вызовами и лимитами. Если порог не был согласован до запуска, команда легко выбирает удобное объяснение уже после графика.

\n

Как читать симптом

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
p95 растёт, RPS почти не меняетсяОчередь внутри сервиса или зависимость отвечает медленнееСопоставить latency с queue depth, CPU, pool и временем внешних вызововНайти первую насыщенную очередь; не увеличивать таймаут вслепую
Ошибки появляются только на ступениПересечён лимит соединений, workers или внешнего APIПроверить лимиты и распределение ошибок по endpoint и кодуИзменить лимит или сценарий только после подтверждения владельца ресурса
График выглядит лучше после увеличения VU генератораГенератор был bottleneck и не подавал заявленный потокСравнить запланированный и фактически начатый arrival rateУвеличить ресурс генератора и повторить тот же сценарий
Тест стабилен, но production падаетНе совпали данные, зависимости, размер ответа или модель входаСравнить версии среды и workload по карточке запускаЗакрыть различия; не объявлять стенд доказательством production capacity
После снижения входа ошибки продолжаютсяОчередь не успевает опустеть или ретраи продолжают потокНаблюдать recovery, backlog и число повторных попытокОстановить тест по лимиту и разобрать остаточную работу отдельно
\n

Иллюстрация профиля

\n
\"Профиль
Учебная схема показывает порядок сегментов. Красная граница в ступеньке — условие примера, а не найденный предел реальной системы. Схема не содержит измерений RPS, latency или throughput.
\n

Иллюстрация полезна как проверка полноты. Если на ней нельзя подписать источник входа, границу сегмента и условие остановки, этих данных, скорее всего, нет и в описании запуска. Сегменты должны быть достаточно длинными для выбранного наблюдения. Короткая ступенька может показать только разогрев клиента, кэша или соединительного пула.

\n

Порядок действий

\n
  1. Выберите один пользовательский сценарий и запишите метод, путь, доли операций и ожидаемый результат. Не начинайте с общего «нагрузить сервис».
  2. Зафиксируйте среду: версии приложения и зависимостей, размер инстансов, число реплик, лимиты, сеть и внешние системы. Для каждой зависимости укажите, настоящая она или заменённая.
  3. Подготовьте данные. Запишите версию набора, распределение размеров, правила уникальности, очистку и влияние кэша. Не используйте обезличенный набор, если его форма отличается от рабочей.
  4. Выберите модель входа. Укажите закрытую или открытую модель, начальную скорость, ступени, длительность и ресурс генератора.
  5. Назначьте метрики до запуска. Минимум: процент ошибок, p95 и p99, фактически начатые итерации, CPU, память, очереди и состояние зависимостей.
  6. Сформулируйте порог и остановку. Запишите, какое нарушение прекращает тест, какие данные сохраняются и кто принимает решение о повторе.
  7. Проведите короткий smoke-запуск. Проверьте статус, тело ответа, авторизацию, корреляционные идентификаторы и отсутствие утечки данных.
  8. Запустите профиль и сохраните конфигурацию вместе с сырыми результатами. Не округляйте длительность и не переписывайте параметры вручную после запуска.
  9. Разберите отрицательный путь. Проверьте, что происходит при исчерпании пула, ответе 5xx, таймауте и недоступности зависимости. Убедитесь, что ретраи не превращают отказ в неконтролируемый поток.
  10. Сделайте вывод только по совпадающему набору условий. Если изменилась среда, данные, версия сценария или модель входа, создайте новый запуск и не сравнивайте его с прежним как одну серию.
\n

Ограничения и отрицательный путь

\n

Нагрузочный тест не измеряет «мощность приложения» вообще. Он измеряет ответ системы на конкретный workload в конкретной среде. Учебная модель с четырьмя сегментами не моделирует сеть, TLS, DNS, планировщик, базу, кэш, балансировщик, реальные пользовательские данные или распределённый генератор. Искусственно отклонённая итерация в такой модели не является HTTP-ошибкой и не превращается в error rate.

\n

Даже реальный запуск может дать ложное спокойствие. Кэш прогрелся, база использовала другой план, тестовый набор меньше рабочего, а внешняя система не участвовала. Обратная ситуация тоже возможна: генератор упёрся в CPU, и сервис получил меньше входа, чем было заявлено. Поэтому отрицательный результат нужно сохранять с контекстом, а положительный — ограничивать теми же условиями.

\n

Не лечите симптом увеличением таймаута, числа ретраев или VU, пока не проверили источник задержки. Таймаут может уменьшить число видимых ошибок и увеличить незавершённую работу. Ретрай может улучшить долю успешных ответов для клиента и одновременно удвоить нагрузку на зависимость. Любое действие проверяйте повторным прогоном с тем же профилем.

\n

Проверяемый критерий готовности

\n

Тест готов к инженерному решению, если другой человек может по его записи восстановить сценарий, среду, данные, модель входа, пороги и остановку; фактический вход отделён от возможностей генератора; результаты привязаны к версиям и времени; а каждый вывод отвечает на вопрос, который был сформулирован до запуска. Для изменения индекса этого достаточно, чтобы сравнить два запуска на одинаковом контракте. Для заявления о production capacity потребуются отдельные условия, масштаб и согласование владельцев системы.

\n

Практический финал должен быть коротким: «при такой модели входа и таких данных p95 пересёк порог на такой ступени; одновременно вырос такой ресурс; повтор с теми же условиями подтвердил или опроверг результат». Если в эту фразу нельзя вставить источник наблюдения и границу применимости, тест ещё не закончен.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/223.json b/editorial/agent-rewrites/223.json new file mode 100644 index 0000000..9408e7f --- /dev/null +++ b/editorial/agent-rewrites/223.json @@ -0,0 +1,7 @@ +{ + "index": 223, + "slug": "editorial-2021-10-field-d-runtime-service", + "title": "Пауза в D-сервисе: как отделить allocation от GC, FFI и I/O", + "excerpt": "Редкий пик latency в D-сервисе нельзя объяснить одним словом «runtime». Разбираем один request: сохраняем результат и error path, отделяем allocation от условной GC-границы и проверяем FFI/I/O до изменения конфигурации.", + "contentHtml": "

Сервис отвечает быстро на обычном запросе, но иногда один request выходит за ожидаемое время. В trace рядом видны обработка входа, вычисление и внешние границы. Команда замечает рост временных объектов и сразу связывает его с паузой GC. Другой инженер обвиняет FFI или запись аудита, хотя вызов ещё не доказан. Третий меняет настройки runtime до того, как сохранил исходный результат.

\n

Цена ошибки — потерянная причинность. После нескольких изменений нельзя понять, что изменило latency, а что только скрыло симптом. Глобальное отключение GC может увеличить память и не устранить блокировку на I/O. Оптимизация промежуточного буфера может нарушить error response. Диагностика должна сначала сохранить контракт handler, а затем сузить одну проверяемую гипотезу.

\n

Тезис статьи простой: allocation, работа GC, FFI и I/O — разные наблюдаемые границы. В учебном примере ниже request получает result, stage trace и условные allocation units. Эти units не являются байтами, миллисекундами или данными production. Они нужны только для того, чтобы сравнить два пути при одинаковом результате. Реальный вывод о паузе появляется после профилирования конкретного процесса в зафиксированной среде.

\n

Механизм: один request содержит несколько вопросов

\n

Разделите request на decode, validation, compute и encode. Decode нормализует вход. Validation решает, можно ли выполнять операцию. Compute получает промежуточное состояние и считает результат. Encode формирует success или error response. Такой порядок делает результат проверяемым: если после оптимизации изменился body или status, обсуждать экономию allocation рано.

\n

Рядом с основным путём находятся внешние границы. FFI означает намерение вызвать foreign function. I/O означает намерение записать или прочитать данные. Запись границы в trace не доказывает, что вызов состоялся. Для реального вызова нужны owner, формат аргументов, lifetime, ownership, error contract, timeout и способ отмены. ABI помогает описать совместимость вызова, но не сообщает стоимость конкретной функции.

\n

GC имеет ещё одну границу. D может выделять память в управляемой куче, а collector возвращает неиспользуемые объекты. Срабатывание сбора зависит от состояния процесса и настроек. В trace приложения нужно отличать факт выделения, факт наблюдаемого сбора и гипотезу о влиянии сбора на request. Эти факты нельзя заменить одним label вроде gc-boundary.

\n
\"Маршрут
Схема показывает порядок проверки. Условная граница allocation задаёт вопрос для профайлера, но не измеряет паузу настоящего D runtime.
\n

Конкретный пример: одинаковый контракт, разный временный state

\n

Рассмотрим вход {"requestId":"runtime-d-training-42","operation":"add","left":19,"right":23}. Обработчик должен вернуть status 200 и body {"requestId":"runtime-d-training-42","total":42}. Вариант allocation-heavy создаёт несколько промежуточных структур. Вариант reuse повторно использует локальное состояние. В модели оба выполняют 9 work units и возвращают один body.

\n

Для сравнения зададим budget 6. Heavy получает 12 условных allocation units и пересекает budget. Reuse получает 3 units. В модельной записи heavy получает отметку gc-boundary, потому что его счётчик пересёк порог 8. Это не сообщение о том, что collector действительно остановил поток. Это только сигнал: в реальном сервисе стоит проверить allocation и GC отдельным инструментом.

\n
struct Request {\n    string requestId;\n    string operation;\n    int left;\n    int right;\n}\n\nstruct Response {\n    string requestId;\n    int total;\n}\n\nResponse handle(Request request) {\n    if (request.operation != "add") {\n        throw new ValidationError("unsupported operation");\n    }\n\n    return Response(request.requestId, request.left + request.right);\n}
\n

Код выше — сокращённый учебный фрагмент. Он показывает контракт результата, а не устройство конкретного сервиса и не гарантирует отсутствие allocation. Реальный D compiler может оптимизировать код иначе. Вызов serializer, логгера, базы или foreign function в этот фрагмент не входит. Поэтому нельзя по нему объявлять latency или выбирать флаг runtime.

\n

Отрицательный путь обязателен. Для входа с operation: "divide" validation должна вернуть status 400 и error body. Такой вход не должен доходить до compute, FFI или I/O. Если после оптимизации invalid input стал success, исчез или начал вызывать внешнюю систему, уменьшение units не имеет значения: изменился контракт ошибки.

\n

Симптом → причина → проверка → действие

\n
Как классифицировать наблюдение до изменения runtime
СимптомПричинаПроверкаДействие
Редкий latency spike совпал с ростом allocationВыделение ошибочно принято за паузу collectorСохранить process profile, версию D compiler/DRuntime, вход и распределение времениНе менять GC по одному trace; проверить allocation и pause раздельно
В trace есть gc-boundaryПорог модели выдан за факт работы GCПроверить источник записи и единицу измеренияНазвать запись условным сигналом и выбрать реальный profiler
Есть FFI или I/O boundaryГраница записана как intent, но вызов не подтверждёнПроверить invocation, owner, аргументы, timeout и errorДобавить отдельный contract test; не обвинять зависимость без вызова
После reuse body изменилсяОптимизация затронула handler contractСравнить status, headers, success body и error body на одинаковых входахОстановить оптимизацию и вернуть равный результат
Invalid input проходит computeСломана validation boundary или проверяется только happy pathПовторить тот же invalid sample и посмотреть stage traceВосстановить error route до измерения allocation
Настройка runtime улучшила один прогонИзменилось сразу несколько условий экспериментаСравнить build, платформу, лимиты, вход, concurrency и методОткатить широкий change и повторить одну гипотезу
\n

Как читать trace

\n

Начните с результата. Для valid input запишите status, body и request id. Для invalid input запишите status, error code и сообщение, достаточное для диагностики. Уберите секреты и персональные данные до сохранения trace. Результат связывает стадии с внешним контрактом и не даёт считать любой меньший счётчик улучшением.

\n

Затем проверьте порядок стадий. Valid request должен пройти decode, validation, compute и encode. Invalid request должен пройти decode, validation-error и encode-error. FFI и I/O должны быть либо явно вызваны с подтверждаемым результатом, либо отмечены как неисполненные намерения. Если trace смешивает эти случаи, сначала исправьте наблюдаемость.

\n

После этого сравните два варианта только при равных условиях. Вход, response, status и work units должны совпадать. Меняется одна гипотеза: временное состояние на выбранном участке. Если одновременно изменились serializer, формат ответа и runtime flags, эксперимент не изолирован. Его результат нельзя приписать reuse.

\n

Слово «пауза» требует фактического временного сигнала. Нужны timestamps или профиль с понятной методикой, а также связь участка профиля с request. Allocation count без времени не отвечает на вопрос о latency. GC-настройка без повторяемого входа не отвечает на вопрос о причине. Совпадение двух графиков во времени остаётся гипотезой, пока независимая проверка не разделит их.

\n

Порядок действий

\n
  1. Опишите один симптом: endpoint, входной класс, ожидаемый status/body и наблюдаемое отклонение. Не начинайте с ярлыка «медленный runtime».
  2. Сохраните valid и invalid samples, удалив чувствительные значения. Для каждого sample запишите result, error route и request id.
  3. Добавьте stage trace вокруг decode, validation, compute и encode. Отдельно отметьте FFI/I/O intent и поле, подтверждающее или отрицающее invocation.
  4. Сравните два варианта на одном input. Сначала проверьте одинаковые result, status и work units. Только затем смотрите на allocation units.
  5. Выберите одну гипотезу: лишнее временное состояние, фактический GC, FFI или I/O. Для каждой гипотезы заранее запишите наблюдение, которое её опровергнет.
  6. Если меняется только локальный temporary path, внесите обратимую правку. Не отключайте GC, не меняйте allocator и не переписывайте ABI boundary в том же эксперименте.
  7. Повторите valid и invalid samples в той же среде. Сверьте body, status, stage trace и выбранный сигнал измерения.
  8. Если вопрос касается реальной производительности, сохраните версию compiler и DRuntime, платформу, конфигурацию, workload, метод профилирования и raw output. Без этого сравнение нельзя воспроизвести.
\n

Если гипотеза не подтверждается

\n

Если profile не показывает GC в момент spike, не нужно доказывать первоначальную версию. Проверьте очередь, блокировку, syscall, FFI и I/O по отдельности. Если FFI entry есть только как invocation: not-performed, зависимость не является подтверждённой причиной. Если время растёт на invalid path, сначала исследуйте validation и формирование ошибки.

\n

Если reuse уменьшил units, но изменил body, верните contract. Если body совпал, а latency не изменилась, это нормальный результат: учебный allocation signal мог не быть bottleneck. Если глобальная настройка дала улучшение только на одном наборе данных, остановите перенос вывода на другие входы. Отрицательный результат экономит больше времени, чем уверенная, но неподтверждённая причина.

\n

Ограничения

\n

D specification описывает автоматическое управление памятью, доступные ограничения и взаимодействие с foreign code. Она не профилирует ваш процесс. Атрибут @nogc ограничивает вызовы, которые могут выделять память через GC, но сам по себе не делает безопасными FFI, I/O или внешние allocator-ы. D ABI описывает форму взаимодействия с C ABI целевой системы, но не ownership, блокировку и latency конкретной функции.

\n

Учебные значения 3, 12, 6 и 8 units не имеют единицы времени. Диаграмма и код не являются benchmark, нагрузочным тестом, отчётом об инциденте или результатом production. Один trace не описывает другие устройства, входы, версии compiler, режимы линковки, лимиты процесса и concurrency. Нельзя строить SLA из этого примера и нельзя переносить его verdict между runtime.

\n

Отключение GC — отдельное архитектурное решение. Оно может повлиять на память, lifetime и работу других потоков. Любое такое изменение требует реального профиля, теста contract и плана возврата. Название @nogc не является разрешением убрать collector вокруг кода, который вызывает неизвестные функции или работает с внешней памятью.

\n

Проверяемый критерий готовности

\n

Разбор готов, если выполнены пять условий. Для valid и invalid входов сохранены ожидаемые result и error route. Stage trace показывает, где заканчивается handler и начинаются внешние границы. Сравниваемые варианты имеют одинаковые input, status, body и work units. Каждый вывод о времени опирается на фактический profile с описанными условиями. После правки повторный прогон подтверждает contract и выбранное наблюдение.

\n

Если profile не подтверждает GC, итогом должно быть «GC не доказан», а не новая догадка. Если вызов FFI или I/O не состоялся, итогом должно быть «граница не проверена», а не обвинение зависимости. Если результат различается, итогом должна быть остановка оптимизации. Такой критерий закрывает именно диагностику, а не желание назвать сервис быстрым.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/224.json b/editorial/agent-rewrites/224.json new file mode 100644 index 0000000..0ad5f72 --- /dev/null +++ b/editorial/agent-rewrites/224.json @@ -0,0 +1,7 @@ +{ + "index": 224, + "slug": "editorial-2021-10-mechanism-d-runtime-service", + "title": "Пауза в D-сервисе: как отделить GC от FFI и I/O", + "excerpt": "Один медленный request не доказывает, что виноват DRuntime. Разбираем путь decode → validation → compute → encode, отмечаем allocation pressure, GC, FFI и I/O и проверяем гипотезу без опасной глобальной настройки.", + "contentHtml": "

Сервис отвечает дольше обычного. В trace виден всплеск временных объектов, а рядом работает вызов внешней библиотеки. Команда говорит: «это GC в D». После этого легко отключить сборщик, переписать handler или увеличить таймаут. Ни одно действие не следует из одного симптома. Цена ошибки — потерянный запрос, рост памяти, зависший поток или часы оптимизации участка, который вообще не выполнялся.

\n

Надёжный разбор начинается с одного request path. Нужно разделить четыре шага: decode, validation, compute и encode. Рядом надо явно отметить границы GC, FFI и I/O. Тогда вопрос меняется с «почему D медленный?» на «какой участок создал данные, какой запросил память, а какой вышел за пределы процесса?». Такой вопрос можно проверить.

\n

Что именно делает DRuntime

\n

В обычном D-коде динамические массивы, строки и некоторые объекты используют память, которой управляет сборщик. Документация D описывает automatic memory management как часть языка, но не обещает фиксированную задержку коллекции. Сборщик может удерживать память, остановить известные ему потоки на время сканирования и вернуть управление после обработки недостижимых объектов. Поэтому allocation и pause — разные наблюдения.

\n

Allocation pressure означает, что путь часто просит новую память или создаёт много временных значений. Это гипотеза о причине. GC pause — наблюдение о работе сборщика и времени остановки. Чтобы связать одно с другим, нужны одинаковый вход, профиль процесса и повторяемый способ измерения. Число созданных объектов в учебной модели не является байтами, latency или временем CPU.

\n

Атрибут @nogc полезен как проверяемое ограничение для отдельной функции. Компилятор запрещает в ней операции, которые обращаются к GC напрямую или через неразрешённый вызов. Но @nogc не делает автоматом безопасным FFI, не отменяет блокировку сокета и не доказывает отсутствие аллокаций в библиотеке, вызванной за другой границей. Это контракт участка D-кода, а не сертификат всего request path.

\n

Модель одного запроса

\n

Рассмотрим учебный endpoint, который принимает два целых числа и возвращает их сумму. Вход — sum(19, 23). Успешный ответ — {"requestId":"runtime-training-42","total":42}. Ошибочный вход должен остановиться после validation и вернуть код ошибки. В модель добавим два варианта: allocation-heavy создаёт больше временных значений, а reuse переиспользует промежуточный буфер. Оба обязаны выполнить одинаковые этапы и вернуть одинаковый результат.

\n
Симптом, причина, проверка и действие
СимптомПричина-гипотезаПроверкаДействие
Растёт число временных объектовПовторное создание строк или массивовСравнить allocation profile на одинаковом входеУменьшить временное состояние локально и повторить тест
Есть пауза около allocationGC начал цикл после запроса памятиСопоставить профиль GC, thread stop и request traceПроверить размер и частоту allocation; не отключать GC глобально
Долгий участок после перехода в CFFI-вызов блокирует или копирует данныеЗамерить границу до и после foreign callПроверить ABI, ownership, timeout и error route
Путь ждёт внешнюю системуСеть, файл или database I/OРазделить время ожидания и вычисленияПроверить timeout, retry и отмену отдельно от GC
Валидный и невалидный входы имеют один traceОшибка проходит в computeЗапустить error input и проверить отсутствие computeЗакрыть error route тестом до оптимизации
\n
\"Путь
Схема показывает, где возникает allocation pressure и где request покидает D-код. Иллюстрация не содержит production-телеметрии: границы и числа относятся к учебному примеру.
\n

В такой модели удобно ввести условный бюджет в шесть allocation units. Heavy-вариант получает двенадцать units, reuse — три. Это не лимит DRuntime. Это только порог, который помогает проверить развилку. Оба варианта должны иметь одинаковые work units, status и body. Если reuse «выигрывает» за счёт пропущенного validation или укороченного ответа, сравнение недействительно.

\n

Конкретный код: граница, а не волшебная кнопка

\n

Ниже — маленький пример на D. Он показывает, как отметить функцию, которая не должна обращаться к GC, и как оставить внешний вызов за отдельным контрактом. Код учебный: он не выполняет сеть, не вызывает C-библиотеку и не измеряет задержку.

\n
import core.stdc.stdlib : malloc, free;\n\n@nogc nothrow\nint addChecked(int left, int right)\n{\n    // Здесь нет dynamic array, new или вызова неизвестной функции.\n    return left + right;\n}\n\nextern(C) @nogc nothrow\nint foreign_sum(const int* value);\n\nint handle(Request request)\n{\n    auto total = addChecked(request.left, request.right);\n    // foreign_sum и ownership указателя требуют отдельного contract test.\n    return total;\n}
\n

Аннотация ограничивает только то, что компилятор может проверить в этой функции и её вызовах. Она не сообщает, сколько времени займёт foreign_sum, кто освобождает указатель и может ли внешняя библиотека ждать сеть. Если API принимает память GC, надо согласовать срок жизни и корень, который сборщик видит. Если библиотека владеет буфером, D-код не должен освобождать его своим аллокатором. Нарушение ownership — самостоятельная ошибка, даже когда GC не запускался.

\n

Не стоит начинать с глобального GC.disable(). Такой вызов меняет условия всего процесса. Он может убрать наблюдаемую сборку на коротком тесте, но оставить рост heap и перенести проблему на более поздний request. Локальная гипотеза должна проверяться локальным изменением: убрать временную конкатенацию, переиспользовать буфер, ограничить размер входа или вынести внешний вызов за измеренную границу. Настройка runtime допустима только после профиля, с лимитом памяти и планом возврата.

\n

Как читать trace

\n

Сначала зафиксируйте вход, результат и ошибочный результат. Затем запишите этапы в порядке выполнения. У валидного запроса должны быть decode → validation → compute → encode. У невалидного — decode → validation → encode-error; compute, FFI и I/O не должны появляться без отдельной причины.

\n

После этого добавьте allocation units и рабочие units. В учебном примере heavy даёт 12 allocation units и пересекает порог 8, reuse даёт 3 и остаётся ниже. У обоих девять work units. Запись gc-boundary означает, что модель пересекла порог. Она не означает паузу, остановку потока или реальный запуск сборщика. Для production-вывода нужны данные настоящего DRuntime и настоящего процесса.

\n
const trace = [\n    'decode',\n    'validation:ok',\n    'compute',\n    'allocation:12 units',\n    'gc-boundary:observed-in-model',\n    'ffi:not-performed',\n    'io:not-performed',\n    'encode:success',\n];\n\nassert(trace[$ - 1] == 'encode:success');
\n

Запись ffi:not-performed важна. Граница может быть частью архитектуры, но в данном прогоне вызов не выполнялся. Иначе читатель приписывает внешней библиотеке задержку, которой в тесте не было. То же относится к I/O. Намерение обратиться к database не является ожиданием ответа database.

\n

Порядок действий

\n
  1. Назовите один endpoint, один вход, success body и error body. Не используйте «медленный runtime» как единственный симптом.
  2. Разметьте путь как decode, validation, compute и encode. Отдельно отметьте GC, FFI и I/O.
  3. Запустите валидный и невалидный входы. Проверьте status, body и порядок этапов.
  4. Сравните варианты только при равных work units. Зафиксируйте allocation profile и выбранный порог.
  5. Если меняется только локальное временное состояние, внесите обратимую правку и повторите тот же набор входов.
  6. Если след указывает на FFI или I/O, измерьте границу отдельно и проверьте ownership, ABI, timeout и retry.
  7. Только после этого собирайте реальный профиль с версиями compiler и DRuntime, платформой, размером входа и методом измерения.
\n

Ограничения и отрицательный путь

\n

Модель не запускает D compiler, GC, HTTP, сеть, файл, database, foreign code или profiler. Она не измеряет throughput, latency, RSS, pause time, thread scheduling или размер heap. Учебные allocation units нельзя переносить в настройки процесса. Схема также не доказывает, что переиспользование буфера полезно для любого размера входа: оно может увеличить сложность, удерживать память дольше и создать ошибку среза.

\n

Отрицательный путь должен остаться видимым. Если validation не проходит, handler не должен вызывать compute. Если allocation budget превышен, это повод остановить конкретную гипотезу и собрать профиль, а не повод отключить GC. Если FFI не имеет ясного ownership или timeout, безопасное действие — не расширять его использование. Если I/O не разделено на connect, wait и decode, сначала уточните измерение. Неопределённость — результат проверки, а не разрешение угадывать.

\n

Готовность можно проверить тремя условиями. Один и тот же валидный вход даёт один и тот же status и body до и после изменения. Невалидный вход останавливается до compute и не создаёт скрытый внешний вызов. Для заявленной границы сохранён trace с версией инструмента, окружением и фактическим измерением; учебная модель явно помечена как модель. Если хотя бы одно условие не выполнено, оптимизация не закрыта.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/225.json b/editorial/agent-rewrites/225.json new file mode 100644 index 0000000..7677c2f --- /dev/null +++ b/editorial/agent-rewrites/225.json @@ -0,0 +1,7 @@ +{ + "index": 225, + "slug": "editorial-2021-10-practice-d-runtime-service", + "title": "D-runtime в сервисе: как найти лишнее выделение на одном запросе", + "excerpt": "Если endpoint обвиняют в медленном D-runtime, начните с одного запроса: зафиксируйте результат, отделите GC от FFI и I/O, а затем проверьте гипотезу измерением, а не настройкой вслепую.", + "contentHtml": "

Endpoint иногда отвечает заметно дольше обычного, а в профиле рядом с ним появляется GC. Команда называет причиной «медленный D-runtime» и меняет глобальные настройки сборщика. Симптом может исчезнуть на коротком тесте, но контракт запроса и границы внешних вызовов остаются непроверенными. Цена ошибки — лишний риск в каждом запросе: можно получить больше памяти, длиннее паузы или несовместимость с библиотекой, не доказав, что исправлен нужный участок.

\n

Надёжнее начать с одного request path. Зафиксируйте вход, успешный ответ и ошибочный путь. Затем отделите временные объекты от GC boundary, FFI и I/O. Сначала нужно доказать, что два варианта делают одну работу и возвращают один результат. Только после этого имеет смысл обсуждать allocation, настройки DRuntime или переписывание горячего участка.

\n

Тезис: runtime — это граница вопроса, а не причина

\n

Слово «runtime» скрывает несколько разных механизмов. D-код может создать временный массив. Сборщик может увидеть доступные объекты. Foreign function может выделить память по собственному контракту. I/O может заблокировать поток. Эти события находятся рядом в трассе запроса, но не имеют одной проверки.

\n

Узкий вопрос выглядит так: «При одинаковых входе и ответе создаёт ли этот этап больше временного состояния, чем выбранный бюджет?» Такой вопрос допускает проверку. Вопрос «D тормозит?» не говорит, что измерять и какое действие будет правильным.

\n

Механизм одного request path

\n

Разделите запрос на четыре этапа: decode, validate, compute и encode. Для каждого запишите вход и выход. Рядом отметьте границы, которые не принадлежат учебному примеру: вызов C-библиотеки, сеть, файл или база данных. Граница должна остаться в trace даже тогда, когда вызов ещё не выполняется.

\n

В учебной модели allocation units — условные единицы счёта. Они не равны байтам, времени CPU, latency или числу запусков GC. Бюджет 6 означает только одно: выбранный вариант превышает порог, заданный примером. Реальный порог нужно выбрать для конкретного endpoint и подтвердить инструментом в конкретной версии компилятора, DRuntime и окружения.

\n
Карточка проверки одного запроса
ПолеЧто фиксируемЧто проверяем
Входsum(19, 23), request idОба варианта получают одинаковые данные
Ответ{"requestId":"runtime-d-42","total":42}Оптимизация не меняет контракт
РаботаОдинаковое число work unitsСравнение не прячет другую алгоритмическую работу
AllocationУсловные units и budgetВидно превышение выбранного порога
ГраницыGC, FFI, I/O с явным статусомНеисполненный внешний вызов не принимают за измеренный
\n

Вариант allocation-heavy может получить 12 units, а вариант reuse — 3. Если оба возвращают тот же JSON и выполняют то же число work units, модель выделяет одну гипотезу: промежуточное состояние. Она не доказывает, что второй вариант быстрее на production-трафике.

\n

Пример: сохраняем ответ и меняем только промежуточное состояние

\n

Ниже показан маленький пример на D. Он ограничен вычислением суммы. В нём нет HTTP, сериализации, GC-профайлера и FFI. Комментарии помечают места, где реальный сервис должен добавить отдельную проверку.

\n
struct Response {\n    string requestId;\n    int total;\n}\n\nResponse handle(int left, int right) {\n    // Учебный контракт: результат зависит только от входа.\n    return Response(\"runtime-d-42\", left + right);\n}\n\n@nogc int compute(int left, int right) {\n    // Здесь нет операций, которые требуют GC-аллокации.\n    return left + right;\n}\n\nvoid requestTrace() {\n    // decode -> validate -> compute -> encode\n    // FFI: not performed; I/O: not performed.\n    auto response = handle(19, 23);\n    assert(response.total == 42);\n}
\n

Аннотация @nogc полезна как ограничение на вызываемый D-код: компилятор проверяет конструкции и вызовы, которые могут потребовать GC-аллокации. Но она не делает безопасной неизвестную внешнюю функцию. Она также не доказывает отсутствие пауз сборщика во всём процессе. Другой поток может работать с GC, а FFI-библиотека может иметь собственный allocator. Поэтому атрибут — часть границы, а не итоговый performance report.

\n

Если в реальном коде encode создаёт строку, это нужно увидеть в trace и подтвердить профилем. Нельзя вывести размер выделения из одного только названия функции. Нельзя считать вызов C безопасным по факту успешной компиляции: проверьте calling convention, layout, ownership, освобождение памяти и возможность блокировки.

\n
\"Схема
Маршрут одного запроса. Граница GC на схеме означает точку проверки, а не подтверждённую паузу. FFI и I/O отмечены как внешние границы, пока вызов не измерен.
\n

Симптом → причина → проверка → действие

\n
СимптомПричина-гипотезаПроверкаДействие
Растёт allocation на успешном запросеВременное состояние создаётся на decode или encodeСравнить одинаковый input, output, work units и allocation traceУбрать промежуточную копию или переиспользовать буфер; повторить проверку контракта
В трассе есть GC boundaryВыбранный путь пересёк условный порогПроверить реальный профиль с версией compiler, DRuntime и платформойИзменять локальный участок только после измерения; не менять глобальную настройку вслепую
После FFI меняется задержкаБиблиотека выделяет память или блокирует потокПроверить ABI, ownership, allocator и время вызова отдельноСогласовать контракт с владельцем библиотеки; не приписывать эффект GC
Endpoint медленный только при ошибкеВ error path остаётся дополнительная сериализация или retryПрогнать невалидный input и сравнить trace с успешным путёмИсправить error path и добавить отдельный критерий для него
Учебный тест зелёный, сервис всё ещё медленныйМодель не содержит HTTP, concurrency, backpressure или I/OВоспроизвести один живой endpoint с реальным профилемСохранить модель как гипотезу, а ответ получить инструментом на нужной среде
\n

Порядок проверки

\n
  1. Зафиксируйте симптом. Назовите endpoint, вход, status, body и условие, при котором проявляется проблема. Не начинайте с ярлыка «runtime D».
  2. Сузьте границу. Выберите один участок: временные объекты, GC, FFI или I/O. Остальные границы запишите как неизвестные или неисполненные.
  3. Сохраните контракт. Сравните успешный и ошибочный путь. Для успешного пути зафиксируйте body, для ошибочного — status и форму ошибки.
  4. Соберите базовый trace. Запишите вход, work units, allocation units, выбранный budget и версии compiler, DRuntime, ОС и библиотеки.
  5. Проверьте альтернативу. Измените только промежуточное состояние. Если меняются body или work units, сравнение allocation преждевременно.
  6. Измерьте реальную среду. Запустите подходящий profiler или benchmark на том же endpoint. Учебные units не подменяют байты, CPU time, pauses и latency.
  7. Проверьте отрицательный путь. Повторите тест с невалидным input, ошибкой FFI и отказом I/O, если эти границы входят в endpoint.
  8. Сформулируйте действие. Меняйте локальный код только при подтверждённой гипотезе. После изменения повторите базовый и отрицательный сценарии.
\n

Ограничения

\n

Такой разбор не моделирует поведение всей системы. Он не описывает распределение памяти, алгоритм сборщика, scheduler, конкуренцию потоков, backpressure, сетевые повторы, сериализацию настоящего протокола, лимиты контейнера или нагрузку пользователей. Он не подтверждает production-результат и не заменяет нагрузочный тест.

\n

@nogc ограничивает D-код, но не отменяет правила внешнего ABI и не запрещает другой части процесса запускать сборку. FFI boundary требует отдельного договора о типах и владении памятью. I/O boundary требует отдельного измерения ожидания и поведения при обрыве. Если эти условия неизвестны, честное действие — оставить их неизвестными и назначить проверку, а не заполнить пробел предположением.

\n

Критерий готовности

\n

Проверка закрыта, когда для одного входа сохранены базовый trace и trace после изменения; успешный ответ совпадает; error path проверен; выбранная причина подтверждена подходящим измерением; версии и условия запуска записаны; FFI и I/O имеют явный статус; а действие не опирается на учебные allocation units как на production-метрику. Если хотя бы одно условие не выполнено, результат — новая гипотеза, а не доказанное исправление.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/226.json b/editorial/agent-rewrites/226.json new file mode 100644 index 0000000..f655f48 --- /dev/null +++ b/editorial/agent-rewrites/226.json @@ -0,0 +1,7 @@ +{ + "index": 226, + "slug": "editorial-2021-09-field-api-versioning", + "title": "Когда поле исчезло: как диагностировать несовместимость API", + "excerpt": "Клиент получил успешный HTTP-ответ, но не смог прочитать данные. Разбираем missing field, смену семантики и unknown request field, а затем выбираем обратимое действие.", + "contentHtml": "

Клиент получает HTTP 200, но экран заказа остаётся пустым. В логах нет сетевой ошибки. Через несколько минут выясняется: backend заменил поле totalMinor на amountMinor, а старый reader всё ещё ищет первое имя. Похожий симптом возникает и при другой причине: сервер оставил имя, но поменял значение status с confirmed на accepted. В обоих случаях транспорт работает. Ломается договор о данных.

\n

Цена ошибки — не только один красный экран. Если откатить сервер вслепую, можно вернуть старое поле, но потерять уже опубликованный новый путь. Если молча игнорировать неизвестное поле запроса, пользователь выберет доставку, а заказ сохранит прежнюю настройку. Если повторить state-changing request без проверки идемпотентности, система может создать второй эффект. Поэтому версия API должна описывать совместимость представления и операций, а не только номер в URL.

\n

Тезис: сначала нужно зафиксировать конкретный body и первое нарушенное правило, затем проверить старого reader-а на candidate response. Только после этого выбирается действие: сохранить legacy-поле, вернуть прежнее значение, отклонить неизвестный key или поставить retirement на паузу.

\n

Что именно считается изменением

\n

Рассмотрим учебный endpoint POST /api/orders/{orderId}/confirm. Пример ограничен проверкой формы и семантики JSON. Он не изображает реальный production-трафик, базу данных или работу авторизации.

\n

Ответ v1 содержит обязательные поля id, status и totalMinor. Для status reader принимает значение confirmed. Ответ v2 может дополнительно содержать deliveryWindow. Если v1 reader получает это дополнительное поле, он может его не использовать: обязательные поля остались на месте, а смысл прежних полей не изменился.

\n

Удаление обязательного поля — structural break. Переименование totalMinor — тот же break, даже если новое поле содержит ровно ту же сумму. Замена confirmed на accepted — semantic break. Она требует решения о vocabulary, а не нового JSON-пути. Добавление optional-поля — additive change только для reader-а, который действительно умеет пережить неизвестный ключ.

\n

У request другой набор правил. confirmationCode обязателен. deliveryPreference optional: отсутствие означает «не менять», а null означает «очистить». Опечатка deliveryPrefrence — неизвестный key. Если сервер его молча пропустит, запрос формально завершится, но намерение пользователя исчезнет.

\n

Механизм проверки

\n

Разделите ответ на transport layer, shape и meaning. HTTP status говорит, дошёл ли запрос и какой общий результат сообщил сервер. JSON reader проверяет обязательные keys и их типы. Доменный слой проверяет допустимые значения и связывает их с действием интерфейса. Успешный status на первом слое не доказывает успех на двух следующих.

\n
function readV1Response(body) {\n  for (const key of ['id', 'status', 'totalMinor']) {\n    if (!(key in body)) return { ok: false, error: 'missing:' + key };\n  }\n\n  if (body.status !== 'confirmed') {\n    return { ok: false, error: 'unsupported-status:' + body.status };\n  }\n\n  if (!Number.isInteger(body.totalMinor) || body.totalMinor < 0) {\n    return { ok: false, error: 'invalid-totalMinor' };\n  }\n\n  return { ok: true, value: {\n    id: body.id,\n    status: body.status,\n    totalMinor: body.totalMinor,\n  } };\n}\n\n// Учебный reader: неизвестные response fields не используются.\n// Это правило нужно подтвердить для конкретного parser-а проекта.
\n

Функция намеренно не делает fetch и не повторяет запрос. Она принимает уже полученное представление и возвращает первую проверяемую причину отказа. Такой порядок важен: сообщение missing:totalMinor полезнее общего «не удалось разобрать ответ». Он связывает симптом с контрактом, но не утверждает, почему backend изменил body.

\n

Для request нужно отдельно решить политику unknown keys. Строгий validator лучше молчаливого игнорирования, если операция меняет состояние. В учебной модели он различает отсутствие optional-поля, явный null и неизвестное имя:

\n
function readConfirmRequest(body) {\n  const allowed = new Set(['confirmationCode', 'deliveryPreference']);\n  const unknown = Object.keys(body).filter((key) => !allowed.has(key));\n  if (unknown.length) return { ok: false, error: 'unknown-request-field' };\n  if (typeof body.confirmationCode !== 'string' || !body.confirmationCode) {\n    return { ok: false, error: 'missing-confirmationCode' };\n  }\n\n  return {\n    ok: true,\n    preference: Object.prototype.hasOwnProperty.call(body, 'deliveryPreference')\n      ? body.deliveryPreference\n      : 'unchanged',\n  };\n}
\n

Этот код не задаёт универсальную политику для всех API. В одном проекте неизвестные поля могут быть разрешены для forward compatibility. В другом они должны приводить к 400, чтобы опечатка не превращалась в потерянное намерение. Решение нужно записать в контракте и проверить на реальном parser-е, а не выводить из одного примера.

\n

Симптом → причина → проверка → действие

\n
Минимальная матрица диагностики
СимптомПричинаПроверкаДействие
v1 не показывает суммуtotalMinor удалили или переименовалиЗапустить v1 reader на сохранённом candidate bodyСохранить legacy field и остановить retirement
HTTP 200, но клиент выбрал другую веткуstatus изменил семантикуСверить vocabulary и допустимые значения reader-аВернуть прежнее значение или подготовить явный переход
Новое предпочтение не применилосьОпечатка или unknown request fieldПроверить keys, presence, absence и nullОтклонить запрос и исправить contract или client
v2 не показывает окно доставкиПерепутали absent, null и неполный objectПроверить три формы одним reader-омУточнить смысл optional field и сохранить старый response
\n

Матрица ускоряет сортировку, но не заменяет исходный body. Для каждого случая сохраните method, endpoint, revision, sanitized request, status, response body и версию reader-а. Секреты и персональные данные удаляйте до записи. Не заменяйте эти данные пересказом вроде «после релиза всё сломалось»: такой пересказ не позволяет отличить удаление поля от смены значения.

\n
\"Диагностическое
Рисунок 1. Диагностика начинается с сохранённого request и response. Rollback или retirement идут после проверки совместимости.
\n

Почему retirement нужно уметь остановить

\n

Предположим, команда хочет удалить totalMinor после перехода на amountMinor. Сначала запускается старый reader на candidate response. Если он возвращает missing:totalMinor, это не повод менять данные или повторять операцию. Это сигнал: старый consumer ещё зависит от поля либо переходный договор не доказан.

\n

Безопасное действие — зафиксировать retirement-paused-before-change, оставить legacy representation и указать условие повторной проверки. Такой шаг обратим: он не требует восстанавливать состояние после уже выполненной мутации. Если причина — semantic change, нужно вернуть прежнее значение или расширить reader с явным маппингом. Если причина — unknown request key, нельзя добавлять молчаливый fallback только ради зелёного HTTP status.

\n

Заголовок deprecation тоже не заменяет проверку. Он может сообщить потребителю, что ресурс планируют вывести, но не доказывает, что потребитель найден, миграция завершена или старый parser принимает новую форму. Retirement имеет смысл только после проверки всех известных consumers и условия отката.

\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите один contract case: endpoint, method, revision, sanitized request, response, HTTP status и видимое поведение клиента. Не смешивайте в один вывод несколько приложений.
  2. Назовите первое нарушенное правило. Проверьте обязательный response key, тип, допустимое значение, форму optional object и неизвестные request keys. Отделите отсутствие от null.
  3. Сравните представления. Запустите старый и новый reader на base, additive и candidate response. Для request отдельно проверьте обязательное поле, optional absence, optional null и опечатку.
  4. Выберите обратимое действие. При failed retirement gate сохраните legacy field и остановите removal. При semantic break согласуйте vocabulary. При unknown request field верните явный отказ, если этого требует contract.
  5. Добавьте защиту. Оставьте compatibility test для старого reader-а, проверку нового reader-а и наблюдаемый сигнал для rejected request. Повтор state-changing operation разрешайте только по отдельному правилу идемпотентности.
\n

Ограничения

\n

Примеры выше учебные. Они не доказывают результат в production и не моделируют авторизацию, rate limit, cache, proxy, сериализацию undefined, retry policy, базу или фактическое распространение мобильного клиента. Не каждый JSON parser игнорирует неизвестные response fields. Не каждый сервер обязан отвергать неизвестные request fields. Эти свойства нужно проверить в конкретном стеке.

\n

OpenAPI описывает форму входа и выхода, но сама спецификация не выполняет миграцию и не находит всех потребителей. Версия URL также не гарантирует совместимость: два разных пути могут использовать один несовместимый reader, а один путь может безопасно обслуживать additive response. Готовность retirement нельзя выводить из даты релиза или единственного успешного запроса.

\n

Проверяемый критерий готовности

\n

Изменение готово к рассмотрению, когда старый reader проходит на каждом сохранённом response, новый reader проходит на новой форме, request validator различает отсутствие, null и unknown key, а отрицательные проверки возвращают ожидаемые ошибки. Для removal отдельно доказано, что известные consumers больше не зависят от legacy field и что есть обратимое действие до первой несовместимой мутации. Если хотя бы одно условие не выполнено, retirement остаётся на паузе.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/227.json b/editorial/agent-rewrites/227.json new file mode 100644 index 0000000..bb1171a --- /dev/null +++ b/editorial/agent-rewrites/227.json @@ -0,0 +1,7 @@ +{ + "index": 227, + "slug": "editorial-2021-09-mechanism-api-versioning", + "title": "Совместимость API: проверяйте request и response отдельно", + "excerpt": "Одинаковая JSON-схема не гарантирует совместимость: значение может сменить смысл, а новое поле — потеряться в старом сервере. Разбираем направления проверки, безопасное additive-изменение и остановку несовместимого retirement.", + "contentHtml": "

Сбой часто выглядит безобидно: сервер отвечает HTTP 200, JSON успешно разбирается, но старый клиент выбирает не ту ветку. Например, v1 ожидал status: 'confirmed', а получил status: 'accepted'. Тип и имя поля не изменились. Изменился смысл. Пользователь может увидеть неверный статус заказа, а мониторинг отметит запрос как успешный.

\n

Есть и обратный случай. Новый клиент отправляет deliveryPreference, а старый сервер молча выбрасывает неизвестный ключ. Опечатка deliveryPrefrence выглядит для человека почти так же, но намерение теряется без ошибки. Цена такой ошибки — не только один неправильный ответ. Команда теряет границу между старым и новым контрактом, а затем пытается лечить её новым URL, повтором запроса или срочным откатом.

\n

Тезис статьи простой: совместимость API — это проверяемый договор между конкретным writer и reader. Response нужно проверять от нового сервера к старому клиенту. Request — от нового клиента к текущему серверу. Одна проверка схемы не заменяет эти два теста и не знает прикладной смысл строковых значений.

\n

Механизм: кто кого читает

\n

Рассмотрим учебный endpoint POST /api/orders/{orderId}/confirm. Ответ v1 содержит обязательные поля id, status и totalMinor. Клиент v1 принимает только значение confirmed. Ответ v2 может дополнительно содержать deliveryWindow. Это additive-изменение безопасно только для reader, который игнорирует неизвестное поле после проверки обязательных полей.

\n

У запроса другие правила. confirmationCode обязателен. deliveryPreference optional. Если ключ отсутствует, сервер сохраняет прежнее предпочтение. Если ключ равен null, сервер очищает его. Неизвестный ключ сервер отклоняет. Так опечатка становится наблюдаемым отказом, а не тихой потерей намерения.

\n
Два направления совместимости
ПотокДопустимое изменениеЧто проверяемСтоп-сигнал
v1 client ← current responseДобавлен optional deliveryWindowv1 сохраняет прежний view и принимает totalMinorИсчезло totalMinor или изменился смысл status
v2 client ← current responsedeliveryWindow absent, null или objectТри состояния различаютсяСтрока или неполный object
v1 client → current serviceНет нового optional ключаЗапрос принят, preference не меняетсяСтарый клиент обязан прислать новое поле
v2 client → current serviceПередан допустимый deliveryPreferenceЗначение проходит словарьUnknown key или опечатка
\n

В этом договоре отсутствие и null не равны. В response отсутствие означает, что representation не предлагает окно. null означает, что сервис проверил условие и сообщает: окна нет. В request отсутствие сохраняет прежнее значение, а null очищает его. Такая семантика должна быть написана рядом со схемой и покрыта проверкой. Сам JSON не объясняет намерение.

\n

Конкретный пример

\n

Reader должен проверять обязательные поля и смысл значения, а не только наличие ключей. Ниже — учебный JavaScript-фрагмент. Он не обращается к HTTP и не доказывает поведение production-сервиса. Его задача — показать границу, которую следует перенести в контрактный тест конкретного reader.

\n
function readV1Response(body) {\n  for (const key of ['id', 'status', 'totalMinor']) {\n    if (!(key in body)) return { ok: false, reason: 'missing:' + key };\n  }\n  if (body.status !== 'confirmed') {\n    return { ok: false, reason: 'unsupported-status-semantics' };\n  }\n  if (!Number.isInteger(body.totalMinor)) {\n    return { ok: false, reason: 'invalid-totalMinor' };\n  }\n  return {\n    ok: true,\n    view: { id: body.id, status: body.status, totalMinor: body.totalMinor },\n  };\n}\n\nconst additive = {\n  id: 'order-417', status: 'confirmed', totalMinor: 1500,\n  deliveryWindow: { from: '2021-09-14T10:00:00Z', to: '2021-09-14T12:00:00Z' },\n};\n\nconsole.log(readV1Response(additive).ok); // true: лишнее поле не попало в view\nconsole.log(readV1Response({\n  id: 'order-417', status: 'confirmed', amountMinor: 1500,\n}).ok); // false: totalMinor нельзя переименовать молча\nconsole.log(readV1Response({\n  id: 'order-417', status: 'accepted', totalMinor: 1500,\n}).ok); // false: одинаковый type не сохраняет смысл\n
\n

Положительный результат относится только к этому reader: он выбирает известные поля и игнорирует новое response-поле. Нельзя объявлять additive-изменение универсально безопасным. Клиент, который хранит весь объект, использует строгую схему или делает exhaustive match, может сломаться от добавленного ключа. Сначала нужно проверить фактическое поведение потребителя.

\n

Для request проверяется другая функция. Она принимает старый body без optional поля, принимает новый body с допустимым значением и отклоняет неизвестный key. Это защищает от опечаток. Но строгий request validator не даёт права требовать новое поле от всех старых клиентов: обязательность определяется контрактом конкретной операции и периодом поддержки.

\n
Матрица совместимости v1 и v2 клиентов: additive deliveryWindow проходит, удаление totalMinor и semantic change status отклоняются, request проверяется отдельным направлением.
Рисунок 1. Совместимость проверяется на пересечении reader и writer. Добавление окна проходит только при сохранении старого view; удаление обязательного поля и смена смысла статуса останавливают переход.
\n

Симптом → причина → проверка → действие

\n
Диагностическая карта изменения API
СимптомПричинаПроверкаДействие
HTTP 200, но клиент показывает другую веткуstatus сохранил type, но сменил смыслПрогнать старый reader на candidate response и проверить допустимые значенияВернуть прежнюю семантику или подготовить явный переход; не считать schema diff достаточным
Старый клиент не видит суммуtotalMinor удалён или переименован в amountMinorСравнить обязательные поля v1 с новым bodyОставить legacy-поле; retirement остановить до миграции всех readers
Новое предпочтение не применилосьСервер молча проигнорировал unknown request key или опечаткуПроверить список ключей и различить отсутствие, null и значениеОтклонять неизвестные ключи и исправить request contract
v2 не показывает окноAbsent и null склеились или object неполныйПрогнать reader на трёх representation: absent, null, objectЗафиксировать семантику состояний и сохранить старый ответ до её проверки
\n

Почему одного /v2 недостаточно

\n

Новый URL отделяет документацию или deployment, но сам не переводит клиента. Старый endpoint можно сломать внутри прежнего адреса. Новый endpoint можно сохранить совместимым. Поэтому номер в URL — инструмент маршрутизации, а не доказательство договора.

\n

Schema diff полезен для структурных изменений. Он может заметить исчезновение required key. Он не знает, что confirmed и accepted означают разные переходы, что unknown request key должен быть ошибкой или что null получил отдельное бизнес-значение. Эти правила принадлежат reader, writer и операции.

\n

OpenAPI помогает записать input и output, но описание не запускает проверку старого клиента. В OpenAPI 3.1 Schema Object описывает форму данных. Контрактный тест должен дополнить его реальными samples и отрицательными случаями: удалённым обязательным полем, изменённым значением, неизвестным request key, null и неправильной формой object.

\n

Порядок действий

\n
  1. Назовите endpoint, method, поддерживаемые readers и writers. Не начинайте с выбора /v2.
  2. Зафиксируйте базовые request и response samples без секретов. Отдельно запишите обязательные поля, optional поля, допустимые значения и смысл отсутствия.
  3. Разделите тесты на два направления: старый client читает новый response; текущий service принимает старый и новый request.
  4. Добавьте положительный additive-case. Проверьте, что v1 сохраняет прежний view, а v2 различает absent, null и object.
  5. Добавьте отрицательные cases: удаление totalMinor, смена смысла status, неизвестный ключ и неправильная форма deliveryWindow.
  6. Проверьте фактические parser rules. Не переносите политику «игнорировать unknown response field» на клиентов, для которых она не доказана.
  7. Перед retirement прогоните candidate на всех поддерживаемых readers. Если обязательное поле исчезло или семантика изменилась, остановите retirement без изменения legacy-контракта.
  8. После успешной проверки объявите границу поддержки: representation, request, срок, owner и условие повторной проверки. Сигнал Sunset может дополнить коммуникацию, но не заменяет миграцию.
\n

Ограничения и отрицательный путь

\n

Учебный пример не проверяет framework serialization, gateway, SDK, авторизацию, cache, rate limit, retries или фактическое распространение мобильного клиента. Он не даёт production-результатов и не доказывает SLA. Для state-changing endpoint отдельно проверяйте idempotency и повторную отправку: совместимость body не делает повтор безопасным.

\n

Если candidate удаляет totalMinor, не добавляйте немедленно новый URL и не подменяйте поле на лету без владельца. Оставьте legacy response, сохраните факт отказа и выясните, какой reader ещё зависит от поля. Если request validator нашёл deliveryPrefrence, не повторяйте запрос вслепую: сначала исправьте имя и определите, применилось ли состояние. Отрицательный путь должен прекращать изменение, а не маскировать нарушение.

\n

Готовность доказана, когда для каждого поддерживаемого направления есть базовый sample, additive-case и отрицательный case. Старый reader принимает новый response только при сохранении обязательных полей и смысла значений. Текущий service принимает старый request без нового optional key. Unknown request key, removal required field и semantic change завершаются понятным отказом до retirement. Команда может повторить эти проверки на фиксированных входах и назвать owner каждого оставшегося потребителя.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/228.json b/editorial/agent-rewrites/228.json new file mode 100644 index 0000000..1d3acac --- /dev/null +++ b/editorial/agent-rewrites/228.json @@ -0,0 +1,7 @@ +{ + "index": 228, + "slug": "editorial-2021-09-practice-api-versioning", + "title": "Версионирование API: как изменить контракт и не сломать клиентов", + "excerpt": "Пошаговая схема для изменения request и response: найти реальный разрыв, проверить старого и нового потребителя, а опасное удаление поставить на паузу.", + "contentHtml": "

После выпуска новой версии API старое мобильное приложение перестало показывать сумму заказа. HTTP-ответ оставался успешным, сервер не сообщал об ошибке, а в JSON вместо обязательного totalMinor появилось новое поле amountMinor. Пользователь видел пустой экран или не мог подтвердить заказ. Команда потеряла время на поиск в логах, потому что сервер считал запрос обработанным. Цена ошибки — не только один сломанный экран. Это срочный релиз, ручная сверка данных и риск повторить тот же разрыв в другом клиенте.

\n

Тезис простой: версионирование API — это управление договором между writer и reader. Номер в URL помогает разделить маршруты, но не доказывает совместимость. Перед изменением нужно отдельно проверить request и response, обязательные поля, допустимые значения и смысл ответа. Если проверка не проходит, старый договор остаётся действующим, а удаление откладывается.

\n

Что именно считается версией

\n

В одной фразе «мы обновили API» часто смешивают четыре слоя. Первый — версия документа OpenAPI. Второй — публичный маршрут, например /api/orders или /api/v2/orders. Третий — форма сообщения: ключи, типы, обязательность и значения. Четвёртый — поведение клиента: какую ветку он выбирает после статуса confirmed, что делает при отсутствии поля и как обрабатывает неизвестный ключ.

\n

Эти слои связаны, но не заменяют друг друга. OpenAPI может описать поле, но не знает, что клиент использует status как разрешение показать кнопку. Сегмент /v2 может направить запрос на другой обработчик, но не мигрирует сохранённые приложения. Поэтому сначала фиксируют фактический contract surface: кто отправляет request, кто читает response, какие значения обязательны и какое отсутствие считается нормальным.

\n
Слои изменения и проверка перед выпуском
СлойПримерРискПроверка
Маршрут/api/orders → /api/v2/ordersСтарый клиент продолжает ходить в прежний маршрутСоставить список потребителей каждого маршрута
ResponseДобавить deliveryWindowСтрогий parser может отвергнуть новый ключПрогнать реальный reader на additive response
RequestДобавить deliveryPreferenceСтарый сервер не знает поле или молча его теряетПроверить v1 request и каждое новое значение
Смыслconfirmed → acceptedТип остался string, но ветка клиента измениласьПроверить переходы состояния и пользовательское действие
\n

Модель на одном endpoint

\n

Возьмём учебный endpoint POST /api/orders/{orderId}/confirm. Это ограниченный пример, а не описание production-сервиса. Версия v1 отправляет только confirmationCode. Новый сервер обязан принять такой request. Версия v2 может добавить deliveryPreference. Отсутствие поля означает «не менять настройку», null — «очистить настройку», а строки weekday и weekend задают значение.

\n
function acceptRequest(request) {\n  const known = new Set(['confirmationCode', 'deliveryPreference']);\n  const unknown = Object.keys(request).filter((key) => !known.has(key));\n\n  if (unknown.length) return { ok: false, reason: 'unknown-field' };\n  if (!request.confirmationCode) {\n    return { ok: false, reason: 'confirmationCode-required' };\n  }\n  if (!Object.hasOwn(request, 'deliveryPreference')) {\n    return { ok: true, preference: 'unchanged' };\n  }\n  if (request.deliveryPreference === null) {\n    return { ok: true, preference: 'clear' };\n  }\n  if (!['weekday', 'weekend'].includes(request.deliveryPreference)) {\n    return { ok: false, reason: 'invalid-preference' };\n  }\n  return { ok: true, preference: 'set' };\n}
\n

Функция показывает направление проверки. Новый сервер читает старый request. Он принимает обязательный код без нового поля, но не принимает опечатку deliveryPrefrence. Он различает отсутствие, null и строку. В реальном сервисе эти правила должны жить в его валидаторе и тестах. Здесь код служит учебной моделью.

\n

Response проверяют в обратную сторону. Старый клиент должен прочитать текущий ответ, пока команда обещает его поддержку. В базовом ответе обязательны id, status и totalMinor. Новое поле deliveryWindow можно добавить только после проверки конкретного v1 reader-а. Если reader строго сравнивает набор ключей, additive change тоже ломает договор. Если он игнорирует неизвестные поля, это свойство нужно зафиксировать тестом, а не считать общим правилом.

\n
const baseResponse = {\n  id: 'order-17',\n  status: 'confirmed',\n  totalMinor: 129900,\n};\n\nconst additiveResponse = {\n  ...baseResponse,\n  deliveryWindow: { from: '2026-08-03T10:00:00Z', to: '2026-08-03T12:00:00Z' },\n};\n\nfunction readV1(response) {\n  if (typeof response.id !== 'string') throw new Error('id');\n  if (response.status !== 'confirmed') throw new Error('status');\n  if (!Number.isInteger(response.totalMinor)) throw new Error('totalMinor');\n  return response.totalMinor;\n}\n\nreadV1(baseResponse);       // учебный пример: проходит\nreadV1(additiveResponse);   // проходит только при tolerant reader
\n

Последняя строка не универсальна. Данный reader обращается только к нужным полям, поэтому в этой модели новый ключ ему не мешает. Другой клиент может десериализовать JSON строгой схемой и отклонить тот же ответ. Проверять нужно поведение своего reader-а.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Экран не показывает суммуУдалено или переименовано обязательное полеСравнить base и candidate response на v1 reader-еВернуть legacy field и остановить retirement
HTTP 200, но неверная ветка клиентаИзменён смысл допустимого значения statusПроверить переходы по значениям, а не только JSON typeСохранить старый смысл или выпустить явный новый contract
Новое предпочтение не применилосьСтарый сервер отбросил неизвестное request-полеПроверить ответ валидатора и итоговое состояниеДождаться поддержки writer-а или использовать отдельный маршрут
Сервис принимает опечаткуUnknown request fields разрешены молчаОтправить deliveryPrefrence и проверить отказОтклонять неизвестные поля с понятной причиной
Старый клиент падает после добавления поляParser строгий, хотя изменение считали additiveПрогнать реальный parser на полном candidate responseСохранить форму ответа или расширить поддержку reader-а
\n

Rollout и отрицательный путь

\n

Безопасный rollout не начинается с удаления старого ключа. Сначала фиксируют базовый response и v1 request. Затем добавляют новое поле в response и проверяют старого reader-а. После этого проверяют v2 request на новом сервере. Только потом обсуждают retirement. Удаление totalMinor проходит через тот же v1 compatibility test. Если тест отклоняет candidate response, legacy field остаётся, а выпуск останавливается до изменения состояния.

\n
Маршрут эволюции API: базовый контракт, additive response, v2 request и безопасная пауза перед удалением totalMinor
Рисунок 1. Сначала проверяют additive-изменение и новый request. Удаление обязательного поля останавливается на v1 gate.
\n

Это отрицательный путь, а не исключение. Отказ теста сообщает: команда пока не доказала совместимость. Нельзя превращать его в разрешение на выпуск с флагом «проверим позже». Сохраняют прежний response, записывают вход и результат проверки, затем уточняют владельца клиента или договор миграции. Если нужный клиент неизвестен, это причина расширить инвентаризацию, а не причина считать его отсутствующим.

\n

Для вывода из эксплуатации можно сообщить клиентам о будущем сроке. Заголовок Sunset из RFC 8594 помогает передать намерение для ресурса, но сам по себе не доказывает миграцию и не отключает endpoint. Нужны также список потребителей, срок поддержки, новая форма ответа и проверяемое условие удаления.

\n

Порядок действий

\n
  1. Зафиксируйте request и response, которые реально использует старый клиент. Запишите обязательные поля, типы, значения и поведение при отсутствии.
  2. Разделите проверку на два направления: новый сервер читает старый request; старый клиент читает новый response.
  3. Классифицируйте изменение. Добавление ключа, удаление ключа, переименование и смена смысла требуют разных проверок.
  4. Проверьте additive response конкретным v1 reader-ом. Не делайте вывод по одной схеме OpenAPI.
  5. Проверьте новый request: отсутствие optional-поля, null, допустимые значения и опечатку неизвестного ключа.
  6. Добавьте наблюдаемый guard: contract test, проверку на границе сервиса или другой автоматический сигнал, который блокирует опасное изменение.
  7. Только после зелёных проверок объявите поддержку новой формы. Retirement запускайте последним и оставьте обратимый шаг.
\n

Ограничения модели

\n

Учебный endpoint не проверяет настоящий HTTP-трафик, gateway, кеш, авторизацию, SDK, базу и несколько одновременных обновлений. Он не отвечает на вопрос о retry для операции, которая меняет состояние. Для такого endpoint отдельно фиксируют idempotency key, допустимый повтор и способ сверить результат. Модель также не говорит, нужно ли использовать URL-версию, media type или заголовок. Выбор зависит от числа потребителей, размера breaking change, маршрутизации и способа поддержки клиентов.

\n

OpenAPI описывает документ и его контракт, но поле openapi — это версия спецификации, а info.version — версия описываемого API. Ни одно поле не заменяет тест reader-а. HTTP задаёт семантику request, response и status code, но смысл confirmed или deliveryWindow принадлежит вашему договору.

\n

Критерий готовности

\n

Изменение готово к выпуску, когда команда может воспроизвести его на сохранённых примерах и получить четыре проверяемых результата: v1 request принимается новым сервером; v1 reader принимает обещанный response; v2 request валидирует отсутствие, null, допустимые значения и неизвестный ключ; candidate removal или semantic change автоматически отклоняется. Для каждого результата есть вход, ожидаемый ответ и владелец исправления. Пока хотя бы один пункт не доказан, старый contract не удаляют.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/229.json b/editorial/agent-rewrites/229.json new file mode 100644 index 0000000..cf9c034 --- /dev/null +++ b/editorial/agent-rewrites/229.json @@ -0,0 +1,7 @@ +{ + "index": 229, + "slug": "editorial-2021-08-field-storage-contracts", + "title": "Reader упал после записи: как проверить контракт хранилища", + "excerpt": "Практический разбор сбоя после изменения записи: как отличить неверный тип, отсутствие поля, сужение старых значений и смену смысла, не уничтожить evidence и вернуть дефект в compatibility test.", + "contentHtml": "

Reader падает сразу после записи profile.settings. В логе появляется unexpected value. Producer уже выпустил новую версию записи, а старый consumer не знает, что с ней делать. Ошибка стоит дороже одного красного запроса: поспешный default, массовая перезапись или бездумный rollback могут стереть разницу между отсутствующим полем, явным null и новым смыслом старой строки. После этого нельзя точно сказать, что записал producer и что именно сломал reader.

\n

Главный тезис прост: контракт хранилища нужно проверять как пару writer/reader на конкретных образцах. Название v2 ничего не гарантирует. Совместимость сохраняется только там, где известны обязательные поля, допустимые значения, смысл каждого значения и поведение при старой записи. Сначала сохраняют безопасное evidence. Потом классифицируют разрыв. И только после этого выбирают исправление.

\n

Что считать контрактом

\n

Запись — это не только набор ключей и типов. Контракт включает имя поля, его наличие, допустимые значения, семантику этих значений и реакцию reader на неизвестное поле. Для profile.settings можно зафиксировать небольшой договор: id, displayName и settings обязательны; timezone добавляется как optional; emailDigest принимает только off, weekly и daily. Старый reader может игнорировать новый optional key, но не обязан угадывать смысл существующего key.

\n

Совместимость имеет направление. Reader v1 читает запись writer v2 только если новый key не меняет обязательный core и старый reader безопасно игнорирует добавление. Reader v2 читает запись writer v1 только если он умеет обработать отсутствие нового optional key. Это две разные проверки. Успешное чтение в одну сторону не доказывает успех в другую.

\n

Сначала собираем evidence

\n

До изменения записи или конфигурации сохраните идентификатор записи, label версии writer, имя reader, путь ошибки, список ключей и состояние спорного поля. Значения профиля не нужны для первой классификации. Для персональных данных применяйте redaction и действующую политику доступа. Цель — восстановить форму записи и ожидаемую пару версий, а не скопировать содержимое в общий лог.

\n
const owns = (value, key) =>\n  Object.prototype.hasOwnProperty.call(value, key);\n\nfunction collectEvidence(record, error) {\n  const timezoneState = !owns(record, 'timezone')\n    ? 'absent'\n    : record.timezone === null\n      ? 'explicit-null'\n      : typeof record.timezone;\n\n  return {\n    recordId: record.id,\n    writer: record.schema,\n    fieldNames: Object.keys(record).sort(),\n    errorPath: error.path,\n    timezoneState,\n  };\n}\n\n// Не выводим значения profile и адреса в общий лог.
\n

Сортировка ключей нужна для стабильного вывода. Она не задаёт порядок чтения. JSON object не должен использовать порядок членов как межсистемный договор. Если перестановка ключей меняет результат, consumer зависит от свойства, которого формат не обещает. Это отдельная ошибка, даже если все типы выглядят правильно.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
timezone содержит числоtype breakКлюч есть, тип — numberОстановить writer для такого значения и исправить проверку. Не подставлять строку наугад.
Старая запись без timezone отвергнутаpresence breakКлюч отсутствует в образце v1Вернуть ветку absent. Не записывать null поверх старых данных.
Старое daily больше не читаетсяnarrowingLegacy writer создавал это допустимое значениеРасширить reader или откатить его договор. Не переписывать записи массово.
weekly читается с другим эффектомsemantic breakФорма и тип прежние, смысл изменилсяОстановить producer и выделить новый key или управляемый переход.
\n

Разделяем отсутствие, null и ошибочное значение

\n

Три состояния часто ошибочно сводят к одному default. Но у них разные причины и разные действия. Отсутствующий timezone означает, что старый writer не передал настройку. timezone: null может означать явную очистку, если это прописано в договоре. timezone: 3 нарушает тип. Reader должен различать состояния, иначе rollback начнёт менять историю данных.

\n
const oldRecord = {\n  id: 'profile-17',\n  settings: { emailDigest: 'weekly' },\n};\n\nconst clearRecord = { ...oldRecord, timezone: null };\nconst brokenRecord = { ...oldRecord, timezone: 3 };\n\nreadProfileV2(oldRecord).timezone.state;\n// 'absent'\nreadProfileV2(clearRecord).timezone.state;\n// 'explicit-null'\nreadProfileV2(brokenRecord);\n// throws: timezone must be a string or null
\n

Учебный пример работает только с объектами в памяти. Он показывает требуемые состояния, но не проверяет конкретную базу, сериализатор, репликацию, права, retention или скорость обработки. В реальном сервисе те же образцы нужно пропустить через настоящий reader и validator. Локальный пример нельзя выдавать за гарантию всей системы.

\n

Если текущий reader не различает эти ветки, исправьте договор или reader до изменения записей. Перезапись absent в null придумывает факт явной очистки. Замена числа на строку придумывает значение. Оба действия уничтожают исходный сигнал и усложняют расследование следующего сбоя.

\n
\"Дерево
К действию переходят после классификации разрыва. Остановка producer не означает удаление уже записанных данных.
\n

Когда останавливать producer

\n

Producer нужно остановить, если он продолжает создавать записи, которые активный reader не может безопасно интерпретировать. При type break это ограничивает число новых ошибочных записей. При semantic break это прекращает смешение старого и нового смысла под одним key. Механизм остановки зависит от системы: release, конфигурация, очередь или права. Статья не приписывает ей универсальный рубильник.

\n

Для additive change остановка может не понадобиться. Если compatibility matrix доказывает, что старый reader игнорирует новый optional key, writer может продолжить работу. Но это решение следует из проверки пары версий, а не из слова optional в схеме. Если зелёной пары нет, безопаснее остановить рост спорных записей до восстановления reader или подготовки перехода.

\n
Выбор действия без потери исходного значения
УсловиеДопустимое действиеСохраняемНе делаем
Добавлен optional keyОставить tolerant reader и при необходимости остановить writerСтарый и новый образцыНе удалять key из всех записей без правила
Reader сузил emailDigestВернуть прежнее допустимое множество или readerОбразец с daily и verdictНе заменять daily другим значением массово
Существующее значение получило новый смыслОстановить producer и спроектировать переходСтарый и новый смысл, owner решенияНе считать rollback кода rollback данных
Значение имеет неверный типЗаблокировать такой путь writer и исправить validatorОшибочный образец и error pathНе маскировать ошибку fallback-строкой
\n

Почему rollback кода не откатывает смысл

\n

Если v2 добавила независимый optional key, возврат бинарника обычно не требует удаления новых записей. Старый reader может читать прежний core, а новый reader — core и дополнительный key. Но если producer начал использовать weekly в новом смысле, запись не содержит метки, которая восстановит старую трактовку. Возврат старого кода прочитает ту же строку и снова придаст ей старый смысл. Это не возвращает данные в прошлое.

\n

При semantic break нужны остановка producer, сохранение evidence и решение владельца данных. Иногда нужен новый key с новой семантикой. Иногда — явная миграция с версиями и обратимым этапом. Нельзя обещать snapshots, транзакции или реплики, если их свойства конкретной системы не проверены. Нельзя заменять это решение скрытым cleanup.

\n

Возвращаем случай в compatibility test

\n

Каждый найденный разрыв должен стать фиксированным образцом и проверкой ожидаемого результата. Для type break добавьте timezone: 3 и ожидайте rejection. Для старой записи без поля ожидайте absent. Для narrowing сохраните legacy daily и запретите reader, который его отвергает. Для semantic break зафиксируйте старый смысл и ожидайте остановки до отдельного решения.

\n
const cases = [\n  ['v1 record without timezone', oldRecord, 'accept'],\n  ['explicit clear', clearRecord, 'accept'],\n  ['wrong timezone type', brokenRecord, 'reject'],\n];\n\nfor (const [name, record, expected] of cases) {\n  const actual = tryRead(record);\n  if (actual !== expected) {\n    throw new Error(`${name}: expected ${expected}, got ${actual}`);\n  }\n}\n\n// Это проверка контракта на образцах, не интеграция со storage.
\n

Матрица должна содержать зелёные и красные пары. Одни успешные примеры показывают только то, что reader умеет принять известный input. Отрицательный пример доказывает, что опасная правка действительно остановится. Добавили правило — добавили sample и assertion. Изменили смысл — изменили контракт явно, а не только комментарий.

\n

Порядок действий

\n
  1. Зафиксируйте record id, writer, reader, error path, список ключей и состояние спорного поля без вывода чувствительных значений.
  2. Сравните активную пару writer/reader с ожидаемым контрактом и сохраните исходный образец до любой перезаписи.
  3. Классифицируйте разрыв: type, absence/null, narrowing или semantic. Не называйте отсутствие поля ошибкой типа.
  4. Проверьте, зависит ли результат от порядка ключей. Если зависит, уберите такую зависимость из reader.
  5. Если producer продолжает создавать несовместимые записи, остановите его доступным для системы способом.
  6. Выберите действие: восстановить reader, вернуть допустимое множество, исправить validator или выделить новый key для нового смысла.
  7. Добавьте старый, новый и отрицательный образцы в compatibility matrix. Зафиксируйте ожидаемые accept и reject.
  8. Повторите локальную проверку, затем отдельно выполните integration test настоящего формата и хранилища.
\n

Ограничения и критерий готовности

\n

Эта схема не выбирает формат хранения и не делает JSON Schema универсальной политикой миграции. JSON Schema проверяет структурные утверждения, но не знает, какое бизнес-значение у строки и какие writer ещё живут в системе. Avro имеет собственные правила разрешения writer и reader schema, но они относятся к Avro, а не автоматически к произвольному JSON object. Реальное хранилище добавляет свои свойства: атомарность, репликацию, доступ, retention, лимиты и порядок доставки.

\n

Разбор готов, когда команда может повторить его на обезличенном образце и получить тот же verdict. Для каждой поддерживаемой пары есть базовый sample, additive-case и отрицательный case. Старый reader принимает только доказанно безопасную новую запись. Новый reader обрабатывает старую запись без обязательного поля. Invalid type, narrowing и semantic change дают ожидаемый отказ или отдельный контролируемый переход. В журнале остаются writer, reader, error path и owner решения. Production-эффект из учебных примеров не следует.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/230.json b/editorial/agent-rewrites/230.json new file mode 100644 index 0000000..0e7761a --- /dev/null +++ b/editorial/agent-rewrites/230.json @@ -0,0 +1,7 @@ +{ + "index": 230, + "slug": "editorial-2021-08-mechanism-storage-contracts", + "title": "Контракт хранилища: как менять запись, не ломая старого reader", + "excerpt": "Совместимость хранилища зависит не только от JSON-синтаксиса. Разбираем presence, тип и смысл поля, безопасный порядок rollout и отрицательные проверки для writer/reader.", + "contentHtml": "

Сбой reader после обычной записи часто выглядит как проблема базы: объект сохранился, но приложение не может его прочитать. Лог сообщает о неожиданном типе, отсутствующем поле или недопустимом значении. Ошибка кажется локальной. На деле она может затронуть все записи, которые создал новый writer.

\n

Цена неверного исправления выше одного исключения. Если reader молча подставит default, команда потеряет различие между старой записью без поля и новой записью, которая явно очистила его. Если writer переиспользует старое значение в новом смысле, откат кода не вернёт смысл уже записанных данных. Следующий сервис увидит ту же строку и интерпретирует её по-своему.

\n

Тезис статьи простой: контракт хранилища нужно проверять как договор между writer и reader. JSON задаёт форму передачи, но не описывает совместимость, presence и бизнес-смысл. Для безопасного изменения сначала расширяют reader, затем writer. Любое сужение допустимых значений, обязательности или смысла проходит отдельную проверку и не считается additive-изменением.

\n

Что именно входит в контракт

\n

Рассмотрим учебную запись профиля. Её ядро существует в версии v1:

\n
{\n  \"id\": \"profile-17\",\n  \"displayName\": \"Ada\",\n  \"settings\": {\"emailDigest\": \"weekly\"}\n}
\n

В v2 команда хочет добавить часовой пояс. На первый взгляд достаточно дописать timezone. Но нужно зафиксировать четыре разных правила.

\n
Четыре слоя контракта profile.settings
СлойПравилоЧто проверяетЧего не доказывает
ФорматОбъект состоит из пар имя/значениеТекст можно разобратьСмысл и версии
Формаid — непустая строка; timezone — строка, null или отсутствуетТип и presenceСмысл старого значения
СовместимостьНовый optional key не ломает v1 readerПару writer/readerРеальный rollout
Семантикаweekly означает периодичность сводкиПереиспользование значенияРешение владельца предметной области
\n

Эти слои нельзя заменять друг другом. Валидный JSON может нарушать контракт. Строка может иметь правильный тип, но новый reader может понимать её иначе. Schema может разрешать отсутствие поля, но приложение обязано знать, означает ли оно «старый writer не сообщил значение», «значение неизвестно» или «пользователь очистил его».

\n

Absent, null и значение — разные состояния

\n

В учебном договоре отсутствие timezone означает, что v1 writer о нём не знал. null означает явную очистку. Непустая строка означает заданный часовой пояс. Эти состояния нельзя свести к одному JavaScript default.

\n
function readTimezone(record) {\n  if (!Object.prototype.hasOwnProperty.call(record, 'timezone')) {\n    return { state: 'absent' };\n  }\n\n  if (record.timezone === null) {\n    return { state: 'explicit-null' };\n  }\n\n  if (typeof record.timezone === 'string' && record.timezone.length > 0) {\n    return { state: 'value', value: record.timezone };\n  }\n\n  throw new Error('timezone must be absent, null, or a non-empty string');\n}
\n

Проверка собственного свойства важна. Значение из прототипа не является частью сохранённой записи. Пустая строка тоже не становится корректным значением только потому, что имеет тип string. В реальной системе допустимый справочник часовых поясов и его нормализация принадлежат владельцу поля. Этот пример проверяет границу договора, а не справочник и не реальное хранилище.

\n

Порядок ключей также не является контрактом. Reader должен обращаться к именам полей. Учебная проверка с переставленными ключами должна дать тот же результат:

\n
const reordered = {\n  settings: { emailDigest: 'weekly' },\n  timezone: 'Europe/Moscow',\n  displayName: 'Ada',\n  id: 'profile-17',\n};\n\nconst result = readProfile(reordered);\n// result.id === 'profile-17'
\n

Если библиотека или протокол специально фиксирует порядок, это отдельное правило конкретного формата. Его нельзя вывести из обычного JSON-объекта.

\n
\"Схема
Сначала reader учится читать старые и новые записи. Только после этого writer добавляет новый optional key.
\n

Безопасное additive-изменение

\n

Совместимым считается изменение, которое не запрещает ни одно прежнее корректное состояние и не меняет смысл существующего значения. Добавление optional timezone может быть таким изменением, если v1 reader игнорирует неизвестные optional fields, а v2 reader умеет читать запись без этого key.

\n

Направление важно. Новый writer может встретиться со старым reader. Старый writer может встретиться с новым reader. Мобильное приложение, очередь и фоновый job часто обновляются в разное время. Поэтому одного теста «новый код читает новую запись» недостаточно.

\n
Минимальная compatibility matrix для учебного контракта
WriterReaderОжидаемый результатПочему
v1v1acceptБазовая пара
v1v2accept, absentНовому reader не хватает только optional key
v2v2acceptОбе версии знают поле
v2v1accept только при tolerant readerСтарый reader должен игнорировать unknown optional key
v1v2 с required timezonerejectНовый reader сузил старый договор
\n

В последней строке форма записи ещё похожа на прежнюю, но совместимость уже нарушена. Старый writer не мог передать обязательный для нового reader key. Название «v2» не исправляет эту проблему.

\n

Отрицательный путь нельзя прятать

\n

Положительный тест показывает, что happy path работает. Отрицательный тест показывает, где система должна остановиться. Для этого контракта нужны как минимум четыре отказа.

\n\n

Последний случай особенно опасен. Тип, имя и набор символов могут остаться теми же. Форматный валидатор даст зелёный результат, хотя два потребителя примут разные решения. Такой дефект нельзя исправить выбором другого JSON parser.

\n

Симптом → причина → проверка → действие

\n
Маршрут первичной диагностики
СимптомПричинаПроверкаДействие
timezone имеет числоType breakСверить фактический type и версию writerОстановить создание новых неправильных записей; исправить validator
Старая запись не содержит keyPresence breakПрогнать v1 sample через v2 readerВернуть ветку absent; не записывать null поверх evidence
daily отвергнутNarrowingСравнить старый список значений с новымРасширить reader или остановить rollout; не переписывать legacy массово
weekly даёт другой эффектSemantic breakСверить owner и описание смысла поляОстановить producer; ввести новое поле или отдельный переход
Новая запись читается, старая — нетReader проверяет только v2 shapeПроверить обе стороны matrixСначала сделать reader tolerant, затем менять writer
\n

Порядок действий

\n
  1. Сохранить безопасное evidence: id записи, версии writer и reader, список ключей, путь ошибки и состояние спорного поля. Не писать в общий лог весь профиль.
  2. Назвать владельца поля и зафиксировать смысл. Для timezone отдельно описать absent, null, непустую строку и неверный type.
  3. Собрать samples старого writer, нового writer, старого reader и нового reader. Проверить направление «старый writer → новый reader».
  4. Если изменение additive, выпустить tolerant reader до writer. Проверить чтение старых записей и новой записи старым reader.
  5. Добавить отрицательные samples для type break, required field, narrowing и semantic change. У каждого sample должен быть ожидаемый accept или reject.
  6. Если producer продолжает создавать записи, которые активный reader не понимает, остановить producer доступным в конкретной системе способом. Это может быть release, конфигурация, очередь или право записи.
  7. Разделить rollback кода и rollback данных. Для нового optional key возврат writer не обязан удалять уже записанное поле. Для смены смысла одного key простой откат бинарника не восстанавливает прежнюю семантику.
  8. После локального теста добавить integration-проверку выбранного validator, storage или broker. Учебная fixture сама по себе не доказывает их поведение.
\n

Ограничения и критерий готовности

\n

Эта модель не выбирает базу, брокер или schema registry. Она не обещает, что любой storage engine игнорирует неизвестные поля. Она не учитывает retention, backfill, права, размер записи, транзакции, репликацию и задержки. Эти свойства проверяются отдельно на реальном выбранном компоненте.

\n

Примеры в статье учебные. Они не запускают production reader, не выполняют миграцию, не останавливают сервис и не содержат production-метрик. Поэтому нельзя заявлять, что конкретный rollout уже безопасен. Безопасность появляется после проверки реальных samples и матрицы поддерживаемых пар.

\n

Критерий готовности проверяем: каждая поддерживаемая пара writer/reader имеет явный verdict, v1 запись проходит новый reader, optional v2 поле не ломает старый reader, отрицательные samples отклоняются в ожидаемых местах, а для смены смысла назначен отдельный владелец перехода. Если хотя бы одно условие неизвестно, изменение ещё не готово к rollout.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/231.json b/editorial/agent-rewrites/231.json new file mode 100644 index 0000000..2c215bb --- /dev/null +++ b/editorial/agent-rewrites/231.json @@ -0,0 +1,7 @@ +{ + "index": 231, + "slug": "editorial-2021-08-practice-storage-contracts", + "title": "Контракт хранилища: как менять profile/settings без поломки старых читателей", + "excerpt": "JSON задаёт форму, но не объясняет смысл поля, различие между absent и null и границы совместимости. Разбираем контракт profile/settings, матрицу writer/reader и безопасный порядок изменения.", + "contentHtml": "

Сбой начинается после обычного релиза. Новый writer добавляет timezone в profile/settings. Старый reader получает запись и либо падает на неизвестном поле, либо принимает отсутствие timezone за явную очистку. Пользователь видит сброшенную настройку или ошибку загрузки профиля. Команда тратит время на восстановление уже записанных данных.

\n

Цена ошибки выше, чем один неудачный запрос. Новая версия пишет данные, которые ещё не умеют читать все потребители. Если поле уже меняет смысл, откат кода не возвращает старый смысл автоматически. Поэтому формат записи нельзя считать полным контрактом. Нужны правила владения, presence, типов, значений, версий и отката.

\n

Тезис: хранилище обязано сохранять договор, а не только байты

\n

В этой статье договор описывает запись profile.settings. Он не зависит от конкретной базы, файла или очереди. Объект имеет стабильный идентификатор profile.id. Поле settings.emailDigest хранит режим частоты сводки: off, weekly или daily. Новое поле timezone необязательно. Для него различаются три состояния: ключ отсутствует, ключ содержит null, ключ содержит непустую строку.

\n

Это учебная модель. Она работает на объектах в памяти и показывает границу между writer и reader. Она не проверяет выбранную БД, репликацию, миграционный job, сеть или реальный rollout. Такие проверки появляются только после привязки правил к конкретной системе.

\n

Что входит в контракт

\n

Сначала назовите владельца записи. Владелец отвечает за смысл полей и список поддерживаемых потребителей. Затем запишите обязательные ключи, допустимые типы и наборы значений. Отдельной строкой опишите отсутствие ключа и null. Наконец, перечислите пары версий, которые могут работать одновременно.

\n
const profileSettingsContract = {\n  owner: 'profile-settings',\n  identity: 'profile.id',\n  required: ['id', 'displayName', 'settings.emailDigest'],\n  optional: { timezone: 'absent | null | non-empty string' },\n  values: { emailDigest: ['off', 'weekly', 'daily'] },\n  compatibility: [\n    'writer-v1 -> reader-v2',\n    'writer-v2 -> reader-v1',\n    'writer-v2 -> reader-v2'\n  ]\n};
\n

Поле типа «строка» всё ещё может нарушать договор. Значение weekly должно сохранять смысл режима сводки. Если тот же текст начинают использовать как метку маркетингового сегмента, структурный валидатор не заметит проблему. Это semantic break: тип прежний, а поведение потребителя изменилось.

\n
Минимальная карточка договора profile.settings
ЧастьПравилоПроверкаГраница
idНепустая строка, одна запись профиляПроверить тип и связь с profileНе доказывает существование профиля в БД
emailDigestoff, weekly или dailyПроверить множество значенийСтрока сама не раскрывает смысл
timezoneAbsent, null или непустая строкаПроверить наличие собственного ключаНельзя без правила заменить absent на null
ВерсииСтарые и новые пары читают поддерживаемые записиПрогнать матрицу samplesНе покрывает неизвестного consumer
\n

Почему absent не равно null

\n

Старый writer, который не знает о timezone, не передаёт этот ключ. Такое состояние сообщает только об отсутствии данных в версии writer. Оно не означает, что пользователь очистил часовой пояс. Явное null может означать очистку, если именно это установил владелец контракта. Значение-строка означает заданный часовой пояс. Пустая строка запрещена: для неё нет определённого смысла.

\n
const hasOwn = (object, key) =>\n  Object.prototype.hasOwnProperty.call(object, key);\n\nfunction readTimezone(record) {\n  if (!hasOwn(record, 'timezone')) return { state: 'absent' };\n  if (record.timezone === null) return { state: 'explicit-null' };\n  if (typeof record.timezone === 'string' && record.timezone.length > 0) {\n    return { state: 'value', value: record.timezone };\n  }\n  throw new Error('timezone violates profile/settings contract');\n}\n\nreadTimezone({ id: 'profile-17' });\nreadTimezone({ id: 'profile-17', timezone: null });
\n

Проверка if (record.timezone) здесь ошибочна. Она смешивает отсутствие ключа, null и пустую строку. Проверка собственного ключа сохраняет наблюдаемое состояние. Reader должен либо вернуть известный результат, либо остановить интерпретацию. Молчаливый fallback прячет нарушение и записывает неверное решение в следующий слой.

\n
\"Матрица
Дополнительное поле безопасно только для проверенной пары writer и reader. Схема иллюстрирует правила статьи, а не гарантии конкретного хранилища.
\n

Совместимость проверяют в обе стороны

\n

Пусть writer v1 пишет только обязательные поля. Writer v2 добавляет timezone, не меняя существующие значения. Reader v1 читает известные обязательные поля и игнорирует неизвестное необязательное поле. Reader v2 понимает старую запись, возвращает для неё состояние absent и умеет обработать значение и явный null.

\n
const compatibility = [\n  ['writer v1', 'reader v1', 'accept'],\n  ['writer v1', 'reader v2', 'accept: timezone absent'],\n  ['writer v2 additive', 'reader v1', 'accept: unknown optional'],\n  ['writer v2', 'reader v2', 'accept: value and explicit null'],\n  ['writer v1 daily', 'narrowed reader', 'reject before release']\n];
\n

Неизвестное поле можно игнорировать только тогда, когда оно не влияет на старое обязательное поведение. Если новый writer добавил timezone, а старый reader теперь должен изменить расчёт уведомлений, изменение уже не additive. Если новый reader перестал принимать старое daily, он сузил множество допустимых значений. Обе ситуации требуют остановки выпуска и отдельного переходного договора.

\n

Симптом → причина → проверка → действие

\n
Диагностика разрыва контракта
СимптомПричинаПроверкаДействие
Профиль не читается после записиReader отвергает новое поле или типСравнить record, версии и путь ошибкиДобавить reader, который понимает старый record; writer не включать раньше него
Часовой пояс сбросилсяAbsent трактовали как явный clearПроверить наличие собственного ключа и исходный writerРазвести ветви absent и null, добавить samples обоих состояний
Старая сводка перестала работатьСузили допустимые значения или сменили смыслПрогнать все старые значения через новый readerОтклонить narrowing или выпустить отдельный перевод смысла
После отката остаются неверные настройкиWriter уже записал новый смыслНайти записи новой версии и сравнить семантикуОстановить producer, сохранить samples и планировать контролируемое преобразование
\n

Порядок изменения

\n
  1. Зафиксируйте текущий договор: owner, обязательные поля, допустимые значения и смысл каждого значения.
  2. Соберите несколько обезличенных старых records. Для каждого укажите writer и ожидаемый результат чтения.
  3. Разделите новые состояния. Для timezone отдельно опишите absent, null и строку.
  4. Проверьте новый reader на старых records. Он не должен превращать отсутствие нового ключа в другое состояние.
  5. Добавьте новый writer только после проверки старого reader. Новое поле должно быть необязательным для старого пути.
  6. Проверьте новую пару и отрицательные случаи: неправильный тип, пустую строку, narrowing и смену смысла.
  7. Перед удалением legacy-пути подтвердите, что поддерживаемых старых consumers и records больше нет. Одной новой версии схемы для этого недостаточно.
\n

Отрицательный путь и откат

\n

Удачная запись не доказывает совместимость. Нужны примеры, которые должны быть отклонены. timezone: 42 нарушает тип. Пустая строка нарушает ограничение значения. Reader, который принимает только off и weekly, нарушает совместимость со старым writer, если тот законно писал daily. Reader, который читает weekly как маркетинговую метку, нарушает смысл.

\n

При additive-изменении откат обычно ограничивается остановкой нового writer: reader уже понимает старые и новые записи. При semantic change такой откат недостаточен. Старые байты уже несут новое значение. Сначала остановите producer, зафиксируйте затронутые records и определите обратимое преобразование. Учебный код статьи не выполняет такое преобразование и не подтверждает, что оно безопасно для конкретной системы.

\n

Ограничения модели

\n

JSON не описывает владельца поля, срок поддержки версии или бизнес-смысл строки. Валидатор схемы может проверить структуру, тип и часть ограничений, но не узнает, что weekly нельзя переиспользовать. Не каждый reader должен игнорировать неизвестные поля: это решение зависит от критичности данных и правил формата. В некоторых системах безопаснее отклонить запись, чем потерять неизвестное обязательное поведение.

\n

Порядок ключей объекта не используется как сигнал версии. Reader обращается к именам, а не к позиции. Если системе нужен канонический текст для подписи или хеша, это отдельный договор сериализации. Статья также не утверждает наличие SLA, метрик, успешной миграции или production-результата: приведённые записи и проверки учебные.

\n

Проверяемый критерий готовности

\n

Изменение готово к выпуску, если владелец может назвать поддерживаемые пары writer/reader, новый reader проходит все старые samples, старый reader не зависит от нового optional поля, а отрицательные samples останавливают type break, narrowing и semantic break. Для timezone проверка должна различать absent, явный null и непустую строку. Для отката должен существовать понятный стоп-сигнал producer и способ обнаружить уже записанные новые значения.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/232.json b/editorial/agent-rewrites/232.json new file mode 100644 index 0000000..9eeb4bb --- /dev/null +++ b/editorial/agent-rewrites/232.json @@ -0,0 +1,7 @@ +{ + "index": 232, + "slug": "editorial-2021-07-field-search-indexing", + "title": "Документ сохранён, но не найден: как отделить задержку индекса от ошибки запроса", + "excerpt": "Карточка уже открывается, но поиск не возвращает новый документ. Разбираем путь от source до видимой выдачи, проверяем version и refresh и выбираем безопасное действие без удаления исходной записи.", + "contentHtml": "

Новый товар сохранён: карточка открывается по id, но поиск по названию возвращает пустой список. Через несколько минут товар появляется сам. Ошибка выглядит временной, но её цена вполне материальна: пользователь не находит доступный товар, оператор запускает дорогую повторную индексацию, а удаление и повторное создание записи стирают след исходного состояния. После такого вмешательства уже трудно понять, не была ли причина в запросе, фильтре или правах.

\n

Главный тезис прост: source и search отвечают на разные вопросы. Успешное чтение записи по id доказывает, что источник существует. Оно не доказывает, что текущая версия уже попала в поисковую видимую проекцию. Пустой search до refresh может быть нормальной задержкой. Пустой search после подтверждённой видимости указывает на другой участок: поле, анализатор, filter, scope или права.

\n

Механизм: четыре состояния вместо одного «индекса»

\n

Полезно разложить путь документа на состояния. source хранит исходную запись и её version. ingest принимает работу на построение поисковой проекции. pending содержит подготовленную версию, которую ещё не видит обычный search. visible — снимок, по которому выполняется запрос. Между состояниями есть переходы. Каждый переход имеет свой ключ, время и результат.

\n

Предположим, source содержит документ product-42 с version 7. Ingest получает ключ product-42:7. Повторная постановка с тем же ключом не должна создавать вторую работу. После обработки version 7 может оказаться в pending, но search всё ещё вернёт ноль. Только после перехода видимости запрос получает право увидеть эту версию. Если видимая version равна 7, а query по-прежнему пуст, refresh уже не объясняет симптом.

\n
source(version=7)\n  -> ingest(key="product-42:7")\n  -> pending(version=7)\n  -> refresh\n  -> visible(version=7)\n  -> search(query, scope, filter)\n\nПравило диагностики:\nsource read != search hit
\n

Version нужна не для красоты. Она связывает источник, работу и проекцию. Если лог содержит только название товара, две последовательные правки выглядят одинаково. Если лог содержит id:version, можно увидеть, что старая работа не должна перезаписать новую, а повторная постановка относится к той же версии. Timestamp помогает оценить задержку, но не заменяет порядок версий.

\n

Учебный пример: запрос до и после refresh

\n

Ниже — минимальная модель. Она не подключается к Elasticsearch или базе и не описывает производительность. Код показывает только причинную цепочку: документ уже есть в source, затем появляется в pending, но search начинает возвращать его лишь после явного перехода видимости.

\n
const source = new Map();\nconst pending = new Map();\nconst visible = new Map();\n\nsource.set('product-42', {\n  id: 'product-42',\n  version: 7,\n  title: 'Свежие яблоки',\n});\n\npending.set('product-42', source.get('product-42'));\n\nfunction search(term) {\n  return [...visible.values()].filter((doc) =>\n    doc.title.toLowerCase().includes(term.toLowerCase()),\n  );\n}\n\nconsole.log(search('яблоки')); // []\n\nfor (const [id, doc] of pending) {\n  visible.set(id, doc);\n}\n\nconsole.log(search('яблоки')); // [{ id: 'product-42', version: 7, ... }]
\n

В реальном движке переход видимости может происходить автоматически по настройке refresh interval или по явной операции. Его стоимость и охват зависят от версии, размера индекса и нагрузки. Поэтому учебный вызов visible.set нельзя переносить в production как готовую команду. Он нужен, чтобы не смешивать две проверки: «проекция построена» и «проекция доступна этому запросу».

\n
\"Дерево
Проверяйте путь от source к query по границам. Если текущая version уже видима, переходите к контракту запроса.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
По id source отсутствуетОшибка записи или неверный idСверить id, version и результат сохраненияОстановиться. Не создавать копию до проверки write path
Source есть, ingest не подтверждёнРабота не поставлена или потерянаНайти ключ id:version и результат постановкиСохранить evidence и проверить очередь по её policy
Pending содержит текущую version, search пустПроекция ещё не стала видимойСравнить время обработки и refresh или признак видимостиПрименить согласованную policy. Не удалять source
Visible содержит старую versionУстаревшая работа или нарушение порядкаСравнить source.version, event.version и visible.versionПоставить текущую version идемпотентно и сохранить старый след
Visible содержит текущую version, search пустОшибка query contractПроверить поле, analyzer, filter, scope, routing и праваИсправить только найденный участок и повторить исходный query
\n

Таблица задаёт порядок, а не заменяет доказательство. Один пустой ответ не говорит, где произошёл сбой. Если начать с фильтра, можно не заметить, что ingest вообще не принял version. Если сразу вызвать широкий reindex, можно получить хороший результат без понимания причины. Такой результат не защищает следующий релиз.

\n

Как отличить задержку видимости от ошибки запроса

\n

Сначала зафиксируйте точный query: индекс или alias, поле, текст, фильтры, сортировку, routing и права. Затем получите source по id и запишите version. После этого найдите ту же version в состоянии проекции. Если она находится только в pending, причина ещё находится до refresh. Если она видима, повторная постановка не добавит новых фактов.

\n

Проверяйте отрицательный путь отдельно. Для документа version 7 ожидайте пустой search до подтверждённого refresh. После refresh ожидайте ровно один hit с version 7. Для повторного ingest с ключом product-42:7 ожидайте подавление дубликата. Для старой version 6 ожидайте отказ от отката видимого документа. Эти ожидания проверяют механизм, но не обещают SLA, hit rate или поведение кластера.

\n

Особенно опасно считать Get API и search одним чтением. В Elasticsearch документ можно получить по id раньше, чем он становится доступен обычному поиску. Эта разница полезна для диагностики, но она не доказывает, что пользовательский query исправен. Пользователь видит не внутренний Get, а выдачу с её анализатором, фильтрами и ограничениями доступа.

\n

Порядок действий

\n
  1. Запишите id, source version, точный query, индекс или alias, параметры фильтра и время наблюдения. Уберите из evidence секреты и лишние персональные данные.
  2. Проверьте source по id. Если записи нет, остановите поисковое расследование и выясните write path. Не создавайте дубликат для проверки.
  3. Проверьте ключ ingest в формате id:version. Сопоставьте постановку, обработку и повторные попытки. Не называйте успешной постановкой сам факт отправки запроса.
  4. Найдите version в pending или видимой проекции. Если текущая version ждёт refresh, зафиксируйте время и примените только заранее выбранную policy.
  5. Если видима старая version, сравните порядок версий и найдите источник старой работы. Повторную постановку делайте идемпотентной и не стирайте исходное evidence.
  6. Если видима текущая version, проверьте query contract: поле, mapping, нормализацию текста, analyzer, filter, scope, routing и права.
  7. Повторите исходный query без дополнительных изменений. Сравните id и version в результате. Затем добавьте проверку на отрицательный путь в тест или runbook проекта.
\n

Ограничения

\n

Эта схема не моделирует shards, replicas, alias, persistence, конкуренцию, сбои сети, очередь с гарантией доставки, права доступа и анализатор конкретного движка. Она не даёт точного срока, за который документ обязан появиться в поиске. Термин «near real time» не означает мгновенную видимость. Реальную задержку нужно измерять в собственной конфигурации по timestamps и результатам фиксированного query.

\n

Не каждый пустой поиск требует refresh. Явное обновление может увеличить нагрузку и замедлить indexing. Не каждый устаревший hit исправляется повторной постановкой: причиной может быть задержанная старая работа, неверный alias или фильтр. Не каждый найденный source можно показывать всем: query обязан соблюдать scope и права. Если эти границы не записаны, оператор легко превращает временный обход в постоянную нагрузку.

\n

Проверяемый критерий готовности

\n

Диагностика готова, когда для одного тестового документа можно показать непрерывную историю id → version → ingest key → pending или visible → точный query → результат. В истории есть отрицательный случай до refresh, положительный случай после него, подавление дубликата и отказ от отката старой version. Исправление считается доказанным только тогда, когда исходный query возвращает ожидаемый id и текущую version, а запись не удаляли ради получения этого результата.

\n

Для production этого критерия недостаточно: добавьте метрики задержки, ошибки ingest и долю документов, которые не достигают видимости в допустимое время. Но порядок расследования останется тем же. Сначала защитите source и восстановите цепочку фактов. Потом меняйте тот слой, который не выполнил свой контракт.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/233.json b/editorial/agent-rewrites/233.json new file mode 100644 index 0000000..ce80b76 --- /dev/null +++ b/editorial/agent-rewrites/233.json @@ -0,0 +1,7 @@ +{ + "index": 233, + "slug": "editorial-2021-07-mechanism-search-indexing", + "title": "Индексация и поиск: почему сохранённая запись ещё не видна", + "excerpt": "Запись уже открывается по прямой ссылке, но пропала из поиска. Разбираем путь source → ingest → index → refresh → query, проверяем устаревшую версию и выбираем безопасное действие.", + "contentHtml": "

Новая карточка открывается по прямой ссылке, но поиск её не возвращает. Или поиск показывает старый текст, хотя форма сохранения ответила успешно. Это наблюдаемый симптом, а не одна причина. Если сразу повторить запись, запустить полный reindex или включить принудительный refresh, система получит лишнюю нагрузку, а расследование потеряет исходную версию. В худшем случае появятся дубликаты: источник содержит одну запись, а индекс — старую и новую проекции.

\n

Главный тезис простой: сохранение и видимость в поиске — разные события. Source of truth отвечает за доменные данные. Индексатор строит поисковую проекцию. Refresh открывает подготовленные данные для search. Запрос проверяет ещё и поле, фильтр, область поиска и права. Пока мы не знаем, на каком переходе остановилась нужная версия, исправлять запрос рано.

\n

Механизм: одна запись проходит несколько состояний

\n

Для карточки достаточно успешной записи в источнике. Для фоновой обработки нужен принятый event с идентификатором и версией. Для полнотекстового поиска нужен документ в индексе и момент, когда его сегмент стал видимым для search. Эти состояния нельзя заменить одним флагом saved. У них разные владельцы и разные проверки.

\n
Состояние документа и доказательство готовности
СостояниеЧто уже доказаноЧего ещё нет
source-of-truthПо id читается актуальный текст и versionSearch ещё не обязан видеть запись
ingest queueЕсть задача построить проекцию для пары id:versionИндексатор ещё не применил задачу
pending indexАктуальный документ подготовлен индексаторомВидимый снимок поиска может оставаться старым
visible indexДокумент доступен конкретному queryЭто не доказывает работу другого alias, фильтра или окружения
\n

Такая схема не требует четырёх отдельных сервисов. В маленьком приложении несколько состояний могут жить в одном процессе. Граница всё равно существует: код должен различать «данные сохранены», «работа принята», «проекция подготовлена» и «поиск может вернуть документ». Если граница скрыта, команда принимает задержку за потерю данных или лечит фильтр повторной индексацией.

\n

Версия защищает от запоздалой работы

\n

Один идентификатор не описывает порядок изменений. Представим запись article-42. Сначала источник получает версию 6, затем версию 7. Event для версии 6 задержался в очереди. Если индексатор обработает его после версии 7 и не проверит номер, поиск снова покажет старый текст. Поэтому event должен нести как минимум id и version, а обработчик должен сравнивать event с текущим источником.

\n
const source = {\n  id: 'article-42',\n  version: 7,\n  title: 'Контракт свежести выдачи',\n};\n\nconst event = {\n  key: `${source.id}:${source.version}`,\n  id: source.id,\n  version: source.version,\n};\n\nconst current = sourceOfTruth.get(event.id);\nif (!current || current.version !== event.version) {\n  return { state: 'stale-ingest-skipped', event };\n}\n\nreturn {\n  state: 'indexed-pending-refresh',\n  document: { ...current, indexedVersion: event.version },\n};
\n

Код выше — учебный пример. В нём нет брокера, транзакции, конкурентных consumer-ов и долговечной очереди. Он показывает только порядок проверки: сначала найти источник, затем сравнить версию, затем подготовить проекцию. В реальном движке нужна его собственная политика конфликтов и повторов. Простая проверка в приложении не заменяет optimistic concurrency control, если несколько писателей меняют один документ одновременно.

\n

Ключ article-42:7 также делает повтор заметным. Повторная доставка того же события не должна создавать новую смысловую запись. Это идемпотентность на границе ingest. Она не гарантирует порядок всех событий, поэтому version check остаётся обязательным. Нельзя считать повторный вызов доказательством исправления: сначала нужно выяснить, было ли исходное событие принято, обработано или отклонено как устаревшее.

\n

Refresh меняет видимость, а не источник

\n

Подготовленный документ может находиться в состоянии pending. Запрос к visible index в этот момент вернёт ноль результатов или старую версию. Refresh переносит подготовленные изменения в структуру, которую использует search. Он не исправляет неправильный id, не добавляет отсутствующее поле и не отменяет фильтр доступа.

\n
// Учебная модель: здесь Map заменяет поисковый индекс.\nconst pendingIndex = new Map();\nconst visibleIndex = new Map();\n\nfunction refreshSearch() {\n  for (const [id, document] of pendingIndex) {\n    visibleIndex.set(id, { ...document, visibleAt: 'refresh-1' });\n  }\n  pendingIndex.clear();\n}\n\nfunction search(query) {\n  return [...visibleIndex.values()].filter((document) =>\n    `${document.title} ${document.body}`.toLowerCase().includes(query.toLowerCase()),\n  );\n}
\n

В учебной модели refresh синхронен и бесплатен. В рабочем Elasticsearch это отдельный механизм. Текущая документация Elastic описывает near-real-time поиск: изменения становятся видимыми после refresh, а не в тот же момент, когда API записи вернул ответ. Поэтому параметр refresh=true нельзя превращать в безусловную кнопку на каждом write-path. Частые принудительные refresh увеличивают работу индекса и могут ухудшить пропускную способность.

\n

Если пользовательский сценарий требует дождаться появления только что записанного документа, обычно проверяют контракт конкретного API и окружения. В Elasticsearch параметр refresh=wait_for ждёт обычного refresh и не обязан запускать немедленный refresh для каждой записи. Это не универсальная рекомендация для любого движка. Сначала нужно подтвердить версию, нагрузку и допустимую задержку в своём проекте.

\n
\"Временная
Одна учебная запись проходит четыре границы. Точки времени показывают порядок проверки, а не обещают задержку в production.
\n

Прямое чтение и search проверяют разные маршруты

\n

Когда карточка открывается по id, это подтверждает путь чтения источника или realtime get. Когда текстовый поиск возвращает карточку, он подтверждает query path. Между ними могут отличаться индекс, alias, фильтры, анализ текста, права и момент обновления. Поэтому отчёт «запись существует» не закрывает инцидент с пустой выдачей.

\n

Для диагностики запишите точный запрос. Слово поиска, поле, фильтр, alias и окружение важнее общего сообщения «не находится». В учебной функции выше поиск — простая проверка подстроки. Он не моделирует токенизацию, stemming, synonyms, routing, permissions или кэш. Успешный результат примера доказывает только причинную цепочку fixture, а не поведение настоящего анализатора.

\n

Симптом → причина → проверка → действие

\n
Безопасный маршрут от наблюдения к следующей проверке
СимптомВероятная причинаПроверкаДействие
По id нет записиОшибка записи или неверный идентификаторСверить результат write, id и version в источникеОстановиться. Не создавать копию для проверки
Источник есть, event ждётЗадержка или отказ ingestНайти ключ id:version и время постановкиСохранить evidence и проверить consumer
Pending есть, search пустRefresh ещё не открыл проекциюСравнить время подготовки и видимостиПрименить согласованную policy или локальный тест
Search возвращает старую versionЗапоздалый event или конфликт порядкаСравнить source.version, event.version и visible.versionПоставить текущую версию идемпотентно, сохранив след
Visible version актуальна, hit пустОшибка query contractПроверить scope, alias, filter, поле, анализатор и праваИсправлять запрос или mapping точечно
\n

Таблица задаёт порядок, а не угадывает причину по одному симптому. Сначала докажите наличие источника. Затем найдите судьбу конкретной пары id:version. После этого проверяйте видимость. Только при актуальной видимой версии переходите к запросу. Если начать с reindex, можно потратить ресурсы и не заметить, что источник отсутствует или consumer получает старое событие.

\n

Отрицательный путь: документ есть, но не виден

\n

Рассмотрим учебный сценарий. Источник сохранил article-42 с version 7. Event принят один раз. Индексатор подготовил version 7. До refresh query возвращает 0 hits. Это не доказывает потерю документа. Доказательства указывают на состояние awaiting-refresh. После refresh тот же query возвращает одну version 7.

\n
const report = diagnoseVisibility('article-42');\n\nif (report.stage === 'awaiting-refresh') {\n  // Не удаляем source и не создаём второй документ.\n  console.log({\n    sourceVersion: report.sourceVersion,\n    pendingVersion: report.pendingVersion,\n    action: 'measure-refresh-gap',\n  });\n}\n\nif (report.stage === 'query-contract') {\n  // Видимость доказана. Проверяем область и условия запроса.\n  console.log('inspect scope, filter and analyzer');\n}
\n

У этого примера нет production-результата. Значения времени, количество попаданий и имя состояния нужны, чтобы проверить порядок переходов в тесте. В настоящей системе вместо Map понадобятся журналы записи, метрики ingest, сведения о target индекса и безопасный запрос по тестовому документу. Не подставляйте условные миллисекунды в SLA.

\n

Порядок действий

\n
  1. Сохраните id, source version, точный текстовый query, alias или индекс, фильтры и время наблюдения. Не меняйте данные до появления этого минимального следа.
  2. Проверьте источник по id. Если записи нет, расследуйте write-path и идентификатор. Не создавайте дубликат «для проверки».
  3. Найдите event с ключом id:version. Уточните, принят ли он, обработан ли, повторён ли или отброшен как устаревший.
  4. Если проекция pending, измерьте промежуток до видимости. Применяйте только политику своего движка; локальный принудительный refresh не объявляет production исправленным.
  5. Если видимая версия старая, сравните порядок событий и текущую version. Повторите только актуальную работу с идемпотентным ключом и сохраните старый evidence.
  6. Если видимая версия актуальна, проверьте query contract: поле, analyzer, scope, alias, фильтр, права и окружение.
  7. Повторите исходный запрос после точечной правки. Запишите результат и добавьте тот же отрицательный путь в интеграционный тест или наблюдение.
\n

Ограничения и критерий готовности

\n

Эта модель не описывает все свойства поисковой системы. Она не проверяет шарды, реплики, alias, durable queue, конкурентные записи, mapping, analyzer, права, кэш, сетевые сбои и восстановление после отказа. Она также не говорит, какая задержка допустима для конкретного продукта. Эти решения зависят от движка, версии, нагрузки и пользовательского контракта.

\n

Решение готово, когда для одного безопасного тестового документа можно показать цепочку evidence: источник содержит ожидаемую version; event имеет ключ id:version; индексатор не принимает запоздалую версию; момент видимости измерен; исходный query после refresh возвращает нужный документ; при актуальной видимой версии проверен query contract. Отдельно должен существовать отрицательный тест: до видимости поиск не выдаёт документ, а диагностика не предлагает удалить источник или создать копию.

\n

Такой критерий не обещает мгновенный поиск. Он показывает, где проходит граница ответственности и какое действие можно выполнить без разрушения исходных данных. Сохранение записи остаётся фактом источника. Видимость в поиске становится отдельным проверяемым фактом.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/234.json b/editorial/agent-rewrites/234.json new file mode 100644 index 0000000..c978de6 --- /dev/null +++ b/editorial/agent-rewrites/234.json @@ -0,0 +1,7 @@ +{ + "index": 234, + "slug": "editorial-2021-07-practice-search-indexing", + "title": "Индексация и поиск: почему сохранённая запись ещё не видна", + "excerpt": "Карточка открывается по прямой ссылке, но поиск возвращает пустой результат или старую версию. Разбираем границы source, ingest, индексирования и refresh, а затем показываем проверяемый маршрут диагностики.", + "contentHtml": "

Карточка уже открывается по прямой ссылке, но поиск по её заголовку возвращает пустой результат. Иногда пользователь видит старое название. Иногда один документ появляется дважды. Цена ошибки быстро растёт: редактор повторяет сохранение, оператор запускает повторную индексацию, а система получает дубликаты и лишнюю нагрузку. При этом исходная запись могла сохраниться правильно.

\n

Тезис простой: запись в source и видимость в поисковой выдаче — разные события. Между ними стоят ingest, обработчик, индексная проекция и переход видимости. У каждого шага должен быть свой признак успеха. Если свести всё к флагу saved, причина исчезнувшего результата останется неизвестной.

\n

Механизм: источник, проекция и запрос

\n

Источник данных владеет содержимым и версией документа. Поисковая проекция хранит форму, удобную для запроса: нормализованный текст, поля фильтра и идентификатор. Проекция не заменяет источник. Она строится после изменения источника и может стать видимой позже.

\n

Для расследования разделите путь на четыре состояния. source содержит текущую карточку. ingest содержит намерение построить проекцию для пары id:version. pending index содержит подготовленный документ, который ещё не возвращает обычный query. visible index содержит снимок, доступный поиску. В настоящем проекте эти состояния могут жить в разных сервисах. В статье они показаны в памяти, чтобы сделать переходы наблюдаемыми.

\n
Состояния одной карточки
СостояниеЧто уже доказаноЧего ещё нет
sourceСодержимое и версия сохраненыПоисковый запрос видит документ
ingestПоставлена работа для конкретной версииПроекция обработана
pending indexДокумент подготовлен индексаторомОн доступен обычному query
visible indexЗапрос может вернуть эту версиюДругие фильтры и области поиска корректны
\n

Версия нужна для порядка обновлений. Событие с version: 6 не должно молча переписать документ, который source уже поднял до версии 7. Ключ article-17:7 задаёт и границу повтора. Одинаковая работа должна распознаваться как повтор, а не как новая независимая задача.

\n

Учебный пример на JavaScript

\n

Ниже приведён ограниченный пример. Он не запускает Elasticsearch, не моделирует брокер и не даёт production-метрик. Он показывает только контракт переходов: источник сохраняется, ingest дедуплицируется, индексатор проверяет версию, а query начинает видеть документ после отдельного refresh.

\n
const source = new Map(); const acceptedIngest = new Set(); const pending = new Map(); const visible = new Map(); function saveArticle(article, savedAtMs) { source.set(article.id, { ...article, savedAtMs }); return { id: article.id, version: article.version }; } function enqueueArticle(id, version, enqueuedAtMs) { const key = `${id}:${version}`; if (acceptedIngest.has(key)) return { status: 'duplicate', key }; acceptedIngest.add(key); pending.set(key, { id, version, enqueuedAtMs }); return { status: 'queued', key }; }
\n

Обработчик сначала читает текущий source. Если событие старее записи, он завершает работу без записи устаревшего текста. Это отрицательный путь: очередь может доставить старое событие после более нового. Проверка версии не делает очередь надёжной и не заменяет транзакцию. Она только не даёт устаревшему сообщению притвориться текущим.

\n
function consumeArticle(id, version, indexedAtMs) { const article = source.get(id); if (!article || article.version !== version) return { status: 'stale-or-missing', id, version }; const key = `${id}:${version}`; pending.set(key, { id, version, title: article.title, text: article.body.toLowerCase(), indexedAtMs }); return { status: 'prepared', key }; }
\n

На этом месте документ ещё не обязан находиться в выдаче. Учебный refreshSearch переносит подготовленную проекцию в видимую. В реальном движке название и стоимость операции зависят от версии, настроек и нагрузки. Поэтому нельзя превращать явный refresh в универсальную кнопку после каждого изменения.

\n
function refreshSearch(visibleAtMs) { for (const [key, document] of pending) visible.set(key, { ...document, visibleAtMs }); pending.clear(); } function searchArticles(term) { const needle = term.toLowerCase(); return [...visible.values()].filter((document) => document.text.includes(needle)); }
\n
Путь документа от source-of-truth через ingest и pending index к visible index и поисковому запросу
Сохранение источника и видимость в выдаче разделены явным переходом. Схема иллюстрирует учебную модель, а не устройство конкретного кластера.
\n

Как читать симптом

\n

Если прямая ссылка не открывает карточку, начинать с refresh нельзя. Сначала проверьте source. Если source есть, но ingest отсутствует, проблема находится между записью и постановкой работы. Если ingest есть, а pending пуст, смотрите обработчик и отказ по версии. Если pending есть, а query пуст, проверяйте переход видимости. Если visible содержит документ, но выдача всё равно пуста, причина уже в тексте, фильтре, alias, области поиска или контракте запроса.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Карточка не открывается по idSource не сохранён или читается не тот владелецПрочитать source по id и сравнить versionИсправить запись или маршрут чтения; не запускать reindex
Source есть, ingest нетНе создано событие или потерян переход writer → ingestСопоставить запись и ключ id:versionВосстановить постановку по согласованной политике и добавить наблюдение
Ingest есть, pending пустОбработчик упал, пропустил задачу или отклонил старую versionПроверить статус consume и текущую version sourceРазобрать ошибку; не считать повтор записи лечением
Pending есть, query пустПроекция ещё не стала видимойСравнить indexedAtMs и visibleAtMsДождаться штатного refresh или применить документированный режим ожидания
Visible есть, результата нетФильтр, поле, анализатор или область запроса не совпадаетВыполнить минимальный query без лишнего фильтраИсправить контракт запроса, mapping или данные
Две карточки с одним смысломПовтор обработан как новый документ или ключ не учитывает versionСравнить document id, ingest key и версииЗакрепить idempotency key и удалить дубликаты отдельной процедурой
\n

Задержка должна иметь точки измерения

\n

Слово «лаг» ничего не объясняет без начала и конца измерения. Для одной выдачи зафиксируйте savedAtMs, enqueuedAtMs, indexedAtMs и visibleAtMs. Разности покажут, где копится время: при постановке, обработке или открытии сегмента для поиска.

\n
const timeline = { savedAtMs: 100, enqueuedAtMs: 110, indexedAtMs: 125, visibleAtMs: 160 }; const totalDelay = timeline.visibleAtMs - timeline.savedAtMs; const visibilityDelay = timeline.visibleAtMs - timeline.indexedAtMs; // totalDelay === 60; числа выбраны для учебной арифметики
\n

Эти числа не являются замером и не задают SLA. В рабочем коде время должно приходить из событий и логов, а не из fixture. Для пользователя может быть важнее доля запросов, которые видят актуальную version, чем средняя задержка. Выберите метрику по сценарию. Не смешивайте задержку pipeline с ошибкой фильтра.

\n

Порядок действий

\n
  1. Выберите один конкретный сценарий: например, поиск опубликованной статьи по заголовку.
  2. Запишите source id, текущую version и момент сохранения. Проверьте прямое чтение.
  3. Найдите ingest key id:version и состояние постановки. Убедитесь, что повтор не создаёт вторую работу.
  4. Проверьте обработчик: он должен принять текущую version и отклонить устаревшее событие с понятным статусом.
  5. Снимите время подготовки pending-проекции. Не называйте её видимой, пока query этого не подтверждает.
  6. Проверьте политику refresh выбранного движка. Для синхронного сценария используйте только документированный режим и оцените его стоимость.
  7. Повторите тот же query после перехода видимости и сравните id, version, фильтры и число результатов.
  8. Если visible уже содержит документ, прекратите повторную индексацию и расследуйте контракт запроса.
\n

Ограничения и отрицательный путь

\n

Пустой поиск до refresh может быть нормальным состоянием. Он становится ошибкой только тогда, когда нарушен согласованный договор свежести. Для новостной ленты допустимо near-real-time обновление. Для экрана подтверждения заказа может потребоваться чтение источника или явное ожидание видимости. Один и тот же параметр нельзя назначить всем сценариям.

\n

Явный refresh способен увеличить нагрузку. Официальная документация Elasticsearch описывает refresh=false как режим без действий refresh, wait_for как ожидание ближайшего refresh, а true как немедленный refresh затронутых shard. Поэтому успешный запрос индексации с настройкой по умолчанию не равен мгновенной доступности в search, но и принудительный refresh не должен автоматически появляться в каждом write-path.

\n

Учебный пример не знает о репликах, alias, mapping, анализаторах, shard allocation, сетевых сбоях и повторной доставке между процессами. Он не доказывает идемпотентность вашего producer. Он также не обещает production-результат. Эти границы нужно проверять на выбранной версии движка и на обезличенном документе.

\n

Проверяемый критерий готовности

\n

Сценарий готов, если команда может назвать владельца source и проекции, показать текущую version, найти ingest key и объяснить каждую точку времени. Для нового документа видны: сохранённый source, одна постановка, результат consume и момент видимости. Для старого события есть проверенный отказ. Для пустого query после visible есть отдельный тест фильтра и текста. Повторное сохранение не используется как универсальное исправление.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/235.json b/editorial/agent-rewrites/235.json new file mode 100644 index 0000000..a48ab32 --- /dev/null +++ b/editorial/agent-rewrites/235.json @@ -0,0 +1,7 @@ +{ + "index": 235, + "slug": "editorial-2021-06-field-event-driven", + "title": "Replay событий: как не создать второй effect и не потерять контракт схемы", + "excerpt": "Повторная доставка события не должна превращаться в новый факт. Разбираем identity, ledger, совместимость схемы и controlled replay на учебном примере, который явно отделяет duplicate от новой версии consumer.", + "contentHtml": "

После сбоя consumer команда часто видит один и тот же симптом: нужно проиграть события ещё раз. Оператор запускает replay, consumer снова получает запись, а в storage появляется второй result. Если replay получил новый event id, система уже не отличает повтор старого факта от нового факта. Если id сохранился, но consumer не ведёт ledger, он всё равно может повторить effect. Цена ошибки — не только дубль строки. Платёж, письмо или изменение внешней системы могут выполниться дважды. Затем команда теряет ответ на главный вопрос: какой contract создал каждый результат.

\n

Тезис простой: replay должен сохранять логическую identity исходного события, а consumer должен проверять схему и receipt до effect. Один и тот же source:id в том же consumer contract даёт duplicate и подавляется. Новый расчёт требует нового явно названного contract или resultVersion. Неизвестная схема останавливает обработку. Это не обещание exactly-once. Это проверяемая граница, которая не даёт техническому повтору притвориться новым бизнес-фактом.

\n

Что именно повторяется

\n

Событие описывает уже произошедший факт. В учебном примере оно содержит source, id, type, subject, occurredAt, schemaVersion и data. Источник и id вместе образуют identity: training://orders/order-104:evt-order-104-paid-01. Новый запуск может иметь отдельную операционную причину, но эта причина не должна менять исходные поля.

\n

Consumer contract отвечает на другой вопрос: как этот consumer интерпретирует payload. Пусть orders-projection@1 читает orderId и status, а orders-projection@2 дополнительно читает paymentReference. Один event может законно дать два локальных projection result, если домен разрешает хранить обе версии. Но это не значит, что любой внешний effect можно повторить дважды. Для email, платежа или HTTP-вызова нужен отдельный idempotency contract на внешней границе.

\n
const ledgerKey = [consumerId, event.source, event.id].join(':');\n\nif (!acceptedSchemaVersions.includes(event.schemaVersion)) {\n  return { state: 'contract-update-required', effectAllowed: false };\n}\n\nif (ledger.has(ledgerKey)) {\n  return { state: 'duplicate-or-replay-suppressed', effectAllowed: false };\n}\n\nconst result = project(event, consumerId);\nledger.set(ledgerKey, { resultVersion, result });\nreturn { state: 'effect-recorded', effectAllowed: true };
\n

Этот фрагмент показывает порядок, а не готовую библиотеку. В реальной системе запись receipt и effect должны иметь согласованный storage contract. Map процесса не защищает от падения между внешним вызовом и записью ledger. Если такая аварийная граница существует, автоматический replay нельзя объявлять безопасным без отдельного решения.

\n

Пример: один event и три delivery

\n

Пусть пришёл учебный event order.status.changed со схемой v1. Первый consumer записывает результат для orders-projection@1. Сеть повторяет delivery. Ledger видит тот же ключ и подавляет второй result. Оператор запускает controlled replay. Ключ не меняется, поэтому replay тоже подавляется. Затем команда включает orders-projection@2. Это уже другой declared contract. Он может получить отдельный projection result, если такое решение принято явно и не смешано с внешним effect.

\n
Решение для одного учебного event
DeliveryIdentityПроверкаРезультат
initialтот же source:idключа нетзаписать один result
duplicateтот же source:idключ есть у того же consumerподавить новый effect
controlled replayтот же source:idключ есть у того же consumerсохранить evidence, не писать второй result
new contractтот же eventдругой consumerId и resultVersionотдельный projection, если он разрешён
schema v3новый или повторный eventверсия не принята consumerостановить effect
\n

Нельзя удалять старую receipt перед историческим replay. Иначе новая запись скроет, что старый contract уже обработал event. Нельзя и автоматически считать любой новый contract безопасным: два projection допустимы не во всех доменах. Сначала называют effect и его владельца, потом выбирают ключ, receipt и способ восстановления.

\n
\"Дерево
Порядок проверки отделяет повтор того же result от новой явно объявленной интерпретации. Схема учебная и не описывает конкретный broker или production-систему.
\n

Схема проверяется до ledger

\n

Проверка identity не заменяет проверку schema. Если event v3 содержит поле state, которого consumer не знает, отсутствие ключа в ledger не даёт права выполнить effect. Сначала consumer проверяет envelope и accepted schema versions. Затем выбирает contract. Только после этого он читает ledger. Отказ должен сохранить причину: unknown type, unsupported schema или invalid field. Такой след отличает остановленный event от потерянной delivery.

\n

Добавление поля может быть совместимым для старого reader, если reader его игнорирует и обязательные поля сохраняют смысл. Новый reader может представить отсутствие поля как null или default, но это решение должно быть частью его contract. Переименование status в state нельзя выдавать за additive change. Учебный пример проверяет только эту пару контрактов; он не доказывает совместимость Avro, JSON Schema или вашей registry без отдельного теста.

\n

Симптом → причина → проверка → действие

\n
Диагностика перед replay
СимптомПричинаПроверкаДействие
Второй result для того же eventНет ledger или ключ построен без sourceСравнить consumer, source, id и receiptСделать identity явной и остановить повторный effect
Replay выглядит как новое событиеОператор заменил исходный idСопоставить event log и операционную запись replayВернуть исходную identity, причину хранить отдельно
Старый event не читается новым consumerНет правила absence/defaultПрогнать writer v1 через reader v2Добавить явный compatibility rule или manual route
Unknown schema записывает effectLedger читается до проверки contractПроверить порядок веток и отрицательный тест v3Запрещать effect до accepted schema
Внешний вызов повторился после сбояReceipt и effect не образуют атомарную границуСмоделировать crash между вызовамиОстановить auto-replay и согласовать внешний idempotency key
\n

Порядок controlled replay

\n
  1. Соберите evidence packet. Зафиксируйте исходные source, id, type, subject, schema version, consumer contract и причину replay.
  2. Проверьте envelope. Отклоните пустые поля, неожиданный source и неизвестный type до чтения payload и до любого effect.
  3. Проверьте схему и reader contract. Назовите принятые версии, правила отсутствующих полей и resultVersion. Не угадывайте новое поле по похожему имени.
  4. Постройте ledger key. Включите consumer identity, source и event id. Отдельно запишите, какой ключ защищает внешний business effect.
  5. Подавите duplicate. Если тот же contract уже имеет receipt, не удаляйте её и не создавайте новый id. Сохраните delivery evidence.
  6. Объявите новую интерпретацию. Если нужен новый projection, используйте новый contract или resultVersion и получите явное решение о допустимости второго результата.
  7. Проверьте аварийное окно. На интеграционном стенде повторите crash между effect и receipt, retries broker и восстановление consumer. Для внешнего сервиса подтвердите его фактический idempotency contract.
\n

Ограничения и критерий готовности

\n

Учебный пример хранит состояние в Map процесса Node. Он не запускает broker, schema registry, database, Kubernetes, HTTP или внешний платёж. Он не измеряет throughput и не доказывает exactly-once. Идентификаторы, даты, source, payload и результаты вымышлены. Реальная гарантия зависит от transaction boundary, retry policy, partitioning, storage и внешних API. Kafka отдельно предупреждает о возможности duplicate при retry; это ограничивает формулировку, но не даёт готовой архитектуры. CloudEvents описывает envelope и protocol binding, а не receipt бизнес-операции.

\n

Готовность проверяется не фразой «replay прошёл». Для выбранного consumer должны воспроизводиться четыре результата: initial delivery создаёт один result; duplicate и controlled replay не создают второй; разрешённый новый contract создаёт отдельный versioned result; неизвестная schema останавливается без effect. Для внешней операции добавьте доказательство её idempotency key или ручной stop. Если хотя бы один результат нельзя объяснить по event identity, contract, ledger и receipt, replay ещё не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/236.json b/editorial/agent-rewrites/236.json new file mode 100644 index 0000000..b649faf --- /dev/null +++ b/editorial/agent-rewrites/236.json @@ -0,0 +1,7 @@ +{ + "index": 236, + "slug": "editorial-2021-06-mechanism-event-driven", + "title": "Как менять схему события и не сломать consumer", + "excerpt": "Валидный JSON не гарантирует совместимость события. Разбираем writer и reader contract, безопасное добавление поля, replay, версионирование результата и отказ без тихой подмены данных.", + "contentHtml": "

Симптом появляется после обычного релиза: producer добавляет поле в событие, JSON проходит парсер, а старый consumer начинает падать или записывает неверное состояние. Хуже, когда он не падает. Он принимает новое значение за старое и создаёт правдоподобный результат. В журнале остаётся только «обработка успешна». Затем трудно понять, какие заказы затронуты и каким правилом их пересчитать. Цена ошибки — потерянная история, повторные побочные эффекты и ручная сверка данных.

\n

Тезис простой: совместимость проверяют не у формата вообще, а у пары writer и reader. Producer описывает, что отправил. Consumer заранее объявляет, что умеет читать и как поступает с отсутствующим или неизвестным полем. Если смысл устойчивого поля изменился, это новый контракт, даже когда тип и JSON-ключ остались прежними.

\n

Механизм: событие, контракт и результат

\n

Событие фиксирует уже произошедший факт. В envelope нужны как минимум идентификатор, источник, тип, время и версия payload. В учебной модели заказ передаёт два устойчивых поля: orderId и status. Версия 2 добавляет необязательный paymentReference. Consumer версии 1 читает только устойчивую пару. Consumer версии 2 читает оба поля и представляет отсутствие ссылки как null.

\n

Такой пример не запускает broker и не имитирует production delivery. Все значения, идентификаторы и результаты вымышлены. Состояние хранится в памяти процесса. Код показывает только границу контракта и маршрут для неизвестной версии.

\n
const eventV2 = {\n  id: 'evt-order-104-paid-02',\n  source: 'training://orders/order-104',\n  type: 'order.status.changed',\n  schemaVersion: 2,\n  data: {\n    orderId: 'order-104',\n    status: 'paid',\n    paymentReference: 'training-pay-77'\n  }\n};\n\nconst consumerV1 = {\n  id: 'orders-projection@1',\n  acceptedSchemaVersions: [1, 2],\n  fields: ['orderId', 'status'],\n  resultVersion: 'orders-projection.1'\n};
\n

Здесь число 2 не означает версию Kafka, библиотеки или deploy. Это версия прикладной схемы. Массив fields — часть reader contract. Он делает намерение явным: consumer не обязан использовать каждое поле, которое встретил в payload.

\n

Когда добавление поля безопасно

\n

Добавочное поле совместимо с конкретным reader только при трёх условиях. Reader не использует неизвестные поля для обязательной валидации. Новое поле не меняет смысл прежних полей. Consumer может честно обработать отсутствие поля, например назначить документированный default или оставить значение пустым.

\n

Переименование status в state этим условиям не отвечает. Даже если оба поля имеют тип string, старый consumer не знает, что settled равно paid. Та же проблема возникает при смене единицы измерения, часового пояса, валюты или смысла enum. Формально валидный payload может быть семантически несовместим.

\n

Обратное направление тоже нужно проверять. Новый reader должен уметь прочитать старый event, в котором нет paymentReference. Если значение обязательно для действия, default не подходит. Тогда новый reader должен вернуть отказ или выбрать маршрут миграции. Молчаливое заполнение пустого значения опасно: effect может выглядеть успешным, хотя обязательная часть факта исчезла.

\n
Диагностика изменения схемы
СимптомПричинаПроверкаДействие
Старый consumer отклоняет v2Он считает любое неизвестное поле ошибкойОтправить v2 в изолированный reader testИзменить contract явно или выпустить новую версию consumer
Ошибок нет, но projection невернаПоменялся смысл stable fieldСравнить определения поля и два реальных значения на границеОстановить effect и сделать новый contract
Новый consumer теряет данные старого eventНет правила для отсутствующего поляПрогнать v1 payload через v2 readerДобавить явный default или вернуть отказ
Replay создаёт второй resultРезультат не связан с source и event idПовторить тот же вход с тем же idДобавить ledger и идемпотентный ключ effect
Неизвестная версия вызывает действиеConsumer принимает любое число schemaVersionОтправить v3 с изменённым полемВернуть contract-update-required без effect
\n

Результат должен объяснять replay

\n

Одной schemaVersion недостаточно. Один event могут прочитать два consumer или тот же consumer после исправления. Результат должен хранить event.id, source, inputSchemaVersion, consumerId и resultVersion. Первое поле связывает запись с фактом. Версия входа показывает форму payload. Идентификатор consumer отделяет независимые projections. Версия результата показывает, каким правилом создана запись.

\n
{\n  \"eventId\": \"evt-order-104-paid-02\",\n  \"source\": \"training://orders/order-104\",\n  \"inputSchemaVersion\": 2,\n  \"consumerId\": \"orders-projection@1\",\n  \"resultVersion\": \"orders-projection.1\",\n  \"decision\": \"effect-recorded\",\n  \"projection\": {\n    \"orderId\": \"order-104\",\n    \"status\": \"paid\"\n  }\n}
\n

resultVersion не должен быть случайным timestamp или номером сборки. Это имя интерпретации. При replay оно отвечает на вопрос: старое или новое правило породило projection? Ledger должен хранить ключ вроде consumerId:source:eventId. Повтор того же входа возвращает duplicate-or-replay-suppressed и не выполняет effect второй раз.

\n

Отрицательный путь важнее счастливого

\n

Permissive consumer, который принимает любую версию и берёт знакомые ключи, работает до первого изменения смысла. Представим v3: producer заменил status на state. Envelope остаётся похожим, но reader не может доказать, что новое значение означает прежнее действие. Он должен сохранить вход для расследования, записать причину отказа и не менять projection.

\n
function project(event, contract) {\n  if (!contract.acceptedSchemaVersions.includes(event.schemaVersion)) {\n    return {\n      decision: 'contract-update-required',\n      effect: 'blocked',\n      eventId: event.id\n    };\n  }\n\n  const projection = {\n    orderId: event.data.orderId,\n    status: event.data.status\n  };\n\n  return {\n    decision: 'effect-recorded',\n    effect: projection,\n    resultVersion: contract.resultVersion\n  };\n}
\n

Проверка версии не заменяет проверку значения. Для денег, дат, enum и ссылок нужны отдельные правила. Если v2 содержит пустой paymentReference, это не повод автоматически принять событие только потому, что версия разрешена. Сначала проверьте тип, обязательность и диапазон. Потом решайте, можно ли выполнять effect.

\n
\"Матрица
Матрица показывает направления writer и reader. Она не обещает, что одна версия формата подходит всем consumer.
\n

Порядок изменения

\n
  1. Зафиксируйте один старый event и один будущий event. Не начинайте с массового изменения producer.
  2. Назовите stable fields: имя, тип, единицу и смысл. Любое изменение смысла считайте новым контрактом.
  3. Проверьте новый writer со старым reader. Отдельно запишите, какие поля reader игнорирует и почему это безопасно.
  4. Проверьте старый writer с новым reader. Для каждого отсутствующего поля задайте default, отдельный route или отказ.
  5. Добавьте к результату event id, source, input schema version, consumer id и result version.
  6. Повторите тот же event с тем же id. Убедитесь, что ledger подавляет второй effect.
  7. Отправьте неизвестную версию. Ожидайте сохранённый отказ без записи бизнес-результата.
  8. Только после этих проверок выбирайте serializer, registry, broker и retry policy. Запишите их реальные guarantees отдельно.
\n

Ограничения

\n

Учебный код не доказывает backwards compatibility в конкретной библиотеке. Он не проверяет Avro bytes, JSON Schema, schema registry, partition, offset, transaction, retention, авторизацию, шифрование, сетевые retry или SLA доставки. Он также не доказывает exactly-once. Повторная доставка и повторная запись результата требуют отдельной модели idempotency.

\n

CloudEvents стандартизирует envelope и context attributes, но не знает бизнес-смысл status. Schema resolution в Avro помогает сопоставлять writer и reader schema, но не решает, допустима ли замена одного доменного значения другим. Документация Kafka предупреждает о duplicate при retry, но сама настройка producer не делает effect идемпотентным. Поэтому общий стандарт не отменяет локальный reader contract.

\n

Критерий готовности проверяемый: для выбранного event type есть два направленных теста совместимости, replay сохраняет исходный id и не создаёт второй effect, а неизвестная версия даёт отказ без изменения projection. В результате можно восстановить event id, input schema version, consumer id и result version. Если хотя бы одно поле приходится угадывать по времени или логам, изменение схемы ещё не готово.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/237.json b/editorial/agent-rewrites/237.json new file mode 100644 index 0000000..7d283c4 --- /dev/null +++ b/editorial/agent-rewrites/237.json @@ -0,0 +1,7 @@ +{ + "index": 237, + "slug": "editorial-2021-06-practice-event-driven", + "title": "Событийный контракт: как пережить повтор, новую схему и неизвестный consumer", + "excerpt": "Очередь передаёт данные, но не объясняет их смысл и не защищает эффект от повтора. Разбираем envelope, версию схемы, consumer contract и проверку, которая останавливает опасный вход до изменения состояния.", + "contentHtml": "

Симптом появляется после подключения второго consumer. Заказ уже сменил статус, но обработчик не может ответить на четыре вопроса: кто создал событие, к какому объекту оно относится, какую схему он получил и записывал ли этот вход результат раньше. Иногда сообщение приходит дважды после повтора отправки. Иногда producer добавляет поле, а старый consumer принимает JSON и неверно трактует новое значение. Цена ошибки — не только красный лог. Система может дважды отправить письмо, повторно изменить внешний объект или записать правдоподобную, но неверную проекцию.

\n

Тезис простой: событийный контракт должен разделять факт, доставку и эффект. Producer фиксирует устойчивый envelope и версию payload. Consumer объявляет принимаемые версии и ключ идемпотентности. Неизвестная схема меняет маршрут на отказ или ручную обработку. Повтор той же доставки не получает новый event id. Такой контракт не даёт обещания exactly-once. Он оставляет доказательство, которое позволяет безопасно принять решение.

\n

Событие не равно доставке

\n

Событие описывает факт: например, заказ перешёл в состояние paid. Доставка описывает попытку передать этот факт конкретному consumer. Одно событие может попасть к нему дважды. Другой consumer может прочитать тот же факт позже. Поэтому время обработки не заменяет идентичность входа. Поля id и source связывают повтор с исходным сообщением, а type и subject ограничивают его смысл.

\n

Envelope отвечает за координаты события. Payload отвечает за данные конкретного типа. Результат consumer отвечает за уже применённую интерпретацию. Не смешивайте эти слои. Если producer положит в envelope всю предметную модель, любое изменение заказа станет изменением общего транспорта. Если consumer сохранит только итоговое число, расследование потеряет исходную схему и версию обработчика.

\n
const event = {\n  contractVersion: 1,\n  id: 'evt-104',\n  source: 'training://orders/checkout',\n  type: 'order.status.changed',\n  subject: 'order-104',\n  occurredAt: '2026-07-31T12:00:00Z',\n  data: {\n    schemaVersion: 1,\n    orderId: 'order-104',\n    status: 'paid'\n  }\n};\n\nconst resultKey = `${consumerId}:${event.source}:${event.id}`;
\n

Идентификаторы и значения в примере учебные. Код не подключается к брокеру, не моделирует базу и не доказывает гарантию доставки. Он показывает границу контракта: один логический вход сохраняет один id, а результат связывает его с конкретным consumer.

\n

Envelope проверяется до payload

\n

Сначала проверьте обязательные поля envelope. Неполный источник, пустой тип или чужой namespace нельзя компенсировать удачным JSON. Consumer должен вернуть наблюдаемую причину и не выполнять эффект. В реальном сервисе к проверке добавятся размер сообщения, права, tenant, подпись и ограничения транспорта. Этот пример ограничен полями, которые нужны для разбора повторной доставки.

\n
function validateEnvelope(input) {\n  const required = ['id', 'source', 'type', 'subject', 'contractVersion'];\n  for (const field of required) {\n    if (typeof input[field] !== 'string' || input[field] === '') {\n      return { ok: false, reason: `missing-${field}` };\n    }\n  }\n\n  if (!input.source.startsWith('training://orders/')) {\n    return { ok: false, reason: 'source-outside-orders-boundary' };\n  }\n\n  if (!Number.isInteger(input.data?.schemaVersion)) {\n    return { ok: false, reason: 'missing-schema-version' };\n  }\n\n  return { ok: true };\n}
\n

Порядок важен. Сначала проверка границы, затем выбор consumer contract, затем чтение payload, затем запись результата. Иначе обработчик может частично изменить состояние и только потом обнаружить, что не знает владельца события или его версию.

\n
\"Producer
Схема разделяет факт, передачу и результат. Брокер на ней обозначает границу транспорта, а не конкретную гарантию продукта.
\n

Версия схемы задаёт право на чтение

\n

Число schemaVersion имеет смысл только рядом с правилами reader. Пусть версия 1 содержит orderId и status, а версия 2 добавляет необязательное поле paymentReference. Consumer v1 может безопасно проигнорировать это поле, если он заранее объявил, что читает только устойчивую пару. Consumer v2 может прочитать его и вернуть null, если получил старую запись без этого поля.

\n

Добавление поля не всегда совместимо. Если новое значение меняет смысл старого поля, меняется контракт. Переименование status в state не становится безопасным потому, что оба значения имеют тип string. То же относится к смене единицы измерения, enum, валюты и правил округления. Для такого изменения нужен новый contract или адаптер с отдельной проверкой.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Один эффект появился дваждыПовтор доставки не связан с receiptСравнить source:id и ключ consumerПодавить повтор, сохранив evidence
Старый consumer падает на новом сообщенииProducer изменил обязательное поле или смыслСопоставить writer schema и accepted versionsОстановить вход или выпустить адаптер
Новый consumer видит пустое полеВ старой схеме поля не былоПроверить явное правило defaultВернуть ограниченный null или отказ
JSON валиден, результат неверенИзменился смысл значения, но версия осталась прежнейСверить семантику stable fields с владельцем доменаПоднять версию и запретить угадывание
Нельзя понять, какой код создал записьРезультат не хранит consumer contractНайти inputSchemaVersion и resultVersionДобавить их в receipt до replay
\n

Consumer обязан объявить контракт

\n

Минимальное описание consumer содержит четыре части: версии схемы, которые он принимает; поля, которые он читает; результат, который он создаёт; исход для неизвестной версии. Например, orders-projection@1 принимает схемы 1 и 2, читает только orderId и status, а для версии 3 возвращает contract-update-required. Он не пытается найти похожее поле и не превращает незнакомое значение в default.

\n
function apply(event, consumer, ledger) {\n  const envelope = validateEnvelope(event);\n  if (!envelope.ok) return { state: 'rejected', reason: envelope.reason };\n\n  if (!consumer.acceptedSchemaVersions.includes(event.data.schemaVersion)) {\n    return { state: 'contract-update-required', effect: false };\n  }\n\n  const key = `${consumer.id}:${event.source}:${event.id}`;\n  if (ledger.has(key)) {\n    return { state: 'duplicate-or-replay-suppressed', key, effect: false };\n  }\n\n  const projection = consumer.project(event.data);\n  const result = {\n    key,\n    inputSchemaVersion: event.data.schemaVersion,\n    resultVersion: consumer.resultVersion,\n    projection\n  };\n  ledger.set(key, result);\n  return { state: 'effect-recorded', result };\n}
\n

В этом учебном коде Map заменяет ledger только для демонстрации последовательности. В рабочей системе запись receipt и внешний эффект могут пересекать границу транзакции. Тогда одной проверки в памяти недостаточно: нужен конкретный storage contract, идемпотентный API внешней стороны или ручной маршрут для неопределённого результата.

\n

Повтор не должен менять идентичность

\n

Controlled replay повторяет тот же вход, поэтому сохраняет source, id, type, subject и версию payload. Создание нового id ради обхода duplicate превращает технический повтор в новый факт. Это опасная подмена: consumer уже не отличит восстановление от новой команды.

\n

Два разных consumer могут законно создать две проекции по одному событию. Но новый результат должен иметь другой явно объявленный consumerId и resultVersion. Это не разрешение повторить платеж или письмо. Внешний эффект требует отдельного ключа намерения и подтверждения того, что произошло на границе сервиса.

\n

Порядок внедрения

\n
  1. Назовите один наблюдаемый симптом и цену ошибки. Не начинайте с выбора брокера.
  2. Опишите факт, который producer передаёт, и отделите его от попытки доставки.
  3. Зафиксируйте envelope: id, source, type, subject, contractVersion и время факта.
  4. Опишите payload schema и stable fields. Для каждого поля укажите тип и смысл.
  5. Запишите accepted schema versions, projection, consumerId, resultVersion и ключ ledger.
  6. Проверьте старую схему с новым consumer и новую схему со старым consumer. Отдельно проверьте изменение смысла.
  7. Прогоните initial delivery, duplicate и controlled replay. Убедитесь, что replay не получает новый event id.
  8. Для неизвестной версии верните отказ без эффекта и сохраните причину вместе с идентификатором входа.
  9. Только после этого выберите broker, serializer, storage и правила retry. Запишите их реальные ограничения отдельно от контракта.
\n

Ограничения и отрицательный путь

\n

Этот материал не обещает ordering, exactly-once, отсутствие duplicate, бесконечный retention или атомарность между брокером и базой. Он не заменяет outbox, schema registry, transaction, authorization и integration test. CloudEvents помогает стандартизировать контекст события, но не знает, какой бизнес-эффект допустим. Kafka может повторить отправку при неопределённом результате; настройки producer не отменяют идемпотентность приложения и внешней системы. Avro описывает совместное чтение writer и reader schema, но не решает смысл доменных значений.

\n

Отрицательный путь обязателен. Если consumer не знает schema version, source не входит в его границу или значение изменило смысл, он не должен «попробовать как раньше». Сохраните вход и причину отказа без эффекта. Если внешний эффект мог завершиться, остановите автоматический replay до проверки receipt. Явный отказ дешевле тихой записи, которую потом нельзя доказать.

\n

Критерий готовности

\n

Контракт готов к подключению реального транспорта, когда для одного учебного события можно без догадок показать envelope, payload schema, accepted versions, consumer id, ключ повтора и результат с inputSchemaVersion и resultVersion. Проверка должна дать три наблюдаемых исхода: первая доставка записывает один result, повтор того же source:id не создаёт второй result, неизвестная версия возвращает отказ без эффекта. Если любой исход виден только по времени лога или требует создать новый id, граница ещё не готова.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/238.json b/editorial/agent-rewrites/238.json new file mode 100644 index 0000000..2513ab6 --- /dev/null +++ b/editorial/agent-rewrites/238.json @@ -0,0 +1,7 @@ +{ + "index": 238, + "slug": "editorial-2021-05-field-distributed-locks", + "title": "Поздний владелец lease: как остановить stale write", + "excerpt": "Истёкший lease не останавливает worker, который уже выполняет работу. Разбираем stale write, fencing token и атомарную проверку ресурса на конкретном interleaving.", + "contentHtml": "

В журнале появляется странная последовательность: worker-B записал новое значение, а через несколько секунд worker-A вернул успешный ответ для той же записи. Команда видит два захвата одного lock и начинает менять TTL. Но часто provider не нарушал контракт. Worker-A получил lease, остановился на паузе, дождался expiry и продолжил работу. Ресурс принял его позднюю запись, потому что ничего не знал о поколении lock. Цена ошибки — откат состояния, повторная отправка платежа или потеря результата более нового worker.

\n

Тезис статьи простой: distributed lock координирует владельцев, но не защищает внешний ресурс от уже запущенного старого владельца. Для этой границы нужен fencing token. Worker передаёт token вместе с записью. Ресурс хранит последний принятый token и отклоняет меньший. Учебные значения, workers и времена ниже вымышлены. Пример работает в памяти и не доказывает поведение конкретного кластера.

\n

Сначала разделите две ответственности

\n

Lock authority отвечает на вопрос: «кому выдать следующее владение именем?». Lease даёт этому владению срок жизни. Keepalive продлевает срок, пока authority получает подтверждения от клиента.

\n

Защищаемый ресурс отвечает на другой вопрос: «может ли этот запрос изменить состояние после уже принятого поколения?». Здесь живёт compare-and-set, условный UPDATE, версия строки или другая транзакционная проверка. Если ресурс не выполняет такую проверку, token остаётся полем в логе.

\n
Поколения владельцев на одной записи
СобытиеСостояние authorityСостояние ресурсаОжидаемое решение
A получил token 1lease-A активенпоследний token меньше 1можно начать работу
lease-A истёкимя можно выдать сноваA может ещё выполнять кодне считать A остановленным
B получил token 2lease-B активенtoken 2 ещё не принятпередать token 2 в write
B записал значениеauthority может не участвоватьhighest token равен 2сохранить значение B
A прислал token 1lease-A уже недействителенhighest token равен 2отклонить stale write
\n

Как возникает stale write

\n

Проверка lease в начале функции защищает только момент проверки. Она не переносится на будущую запись. Пауза может возникнуть из-за GC, медленной базы, debugger, сетевого ожидания или планировщика. За это время authority выдаст новое владение. Локальная переменная A всё ещё содержит старый leaseId, поэтому код продолжит работу, если перед write нет отдельного барьера.

\n
t=0   A: acquire(lock) -> token=1, lease=active\nt=4s  A: строит результат и останавливается\nt=5s  authority: lease A истёк\nt=5s  B: acquire(lock) -> token=2\nt=6s  B: write(item, token=2) -> accepted\nt=7s  A: write(item, token=1) -> должен быть rejected
\n

Последняя строка не исправляется повторной проверкой lock в A. Между такой проверкой и фактическим write снова появится окно. Ресурс должен проверить token в той же операции, которая меняет его состояние.

\n

Минимальная защита на стороне ресурса

\n

Учебная модель хранит два поля: значение и highest accepted token. Новый запрос проходит только при token больше сохранённого. Сравнение и запись должны быть атомарными относительно других writers. В SQL это обычно означает условие в одном UPDATE и проверку числа изменённых строк.

\n
UPDATE jobs\nSET result = :result, accepted_fence_token = :token\nWHERE job_id = :job_id\n  AND accepted_fence_token < :token;
\n

Если UPDATE изменил ноль строк, запрос не получил право менять ресурс. Причины нужно различать: token мог быть старым, запись могла исчезнуть, а запрос мог повториться с тем же token. Не превращайте все нулевые результаты в retry. Повтор старого token не станет новым от повторной доставки.

\n

Равенство тоже важно. Условие <= отвергает повтор с тем же поколением. Это не решает идемпотентность бизнес-операции. Для неё нужен отдельный operation id и журнал уже применённых эффектов. Owner, fence token и idempotency key отвечают на разные вопросы.

\n
Дерево диагностики stale write: фиксируются resource key и token, затем различаются старый token, повтор операции, ошибочный release и отсутствие fencing
Диагностика начинается с состояния ресурса: без сохранённого highest token нельзя доказать, что поздний запрос был безопасно отклонён.
\n

Симптом → причина → проверка → действие

\n
Матрица разбора конкурентной записи
СимптомПричинаПроверкаДействие
Старый worker пишет после новогоРесурс не проверяет tokenНайти highest token до и после writeДобавить атомарное сравнение при записи
Старый worker снимает lock Brelease ищет только по lock nameСверить ownerId, leaseId и tokenОсвобождать только конкретный handle
Один результат применился дваждыПовтор доставки, а не stale generationСравнить operation id и effect ledgerВвести отдельный idempotency contract
Оба запроса отклоненыToken относится не к той resource keyСопоставить ключ конфликта и линию поколенийСузить область lock и fencing до одного эффекта
Ошибку видят только по времени логовНет состояния решения на ресурсеПроверить запись принятого tokenЛогировать решение рядом с условным write
\n

Release тоже должен быть условным

\n

После expiry A может проснуться и отправить release. Если authority удаляет lock только по имени, A способен снять lease-B. Это отдельная ошибка. Fencing защищает запись ресурса, но не делает старый release безопасным.

\n
release(lockName, leaseId, ownerId, token)\n  if active.lockName != lockName: reject\n  if active.leaseId != leaseId: reject\n  if active.ownerId != ownerId: reject\n  delete active
\n

Конкретный provider может использовать другой handle. Нельзя переносить названия полей как готовый API. Переносим только инвариант: старый владелец не должен менять состояние нового владельца.

\n

Порядок проверки

\n
  1. Остановите автоматический replay подозрительного request и сохраните reference на исходную операцию по правилам хранения данных.
  2. Зафиксируйте одну resource key, ownerId, leaseId, fence token и highest token непосредственно до решения.
  3. Воспроизведите interleaving: A получает token 1, lease истекает, B получает token 2, B пишет, A возвращается.
  4. Проверьте, что запись B и проверка token 2 происходят атомарно в одной ресурсной границе.
  5. Убедитесь, что поздний token 1 получает явный stale rejection и не меняет значение.
  6. Повторите сценарий для release и проверьте, что handle A не освобождает lease B.
  7. Отдельно проверьте duplicate operation по operation id. Не называйте этот результат доказательством fencing.
\n

Ограничения

\n

Fencing не останавливает worker и не отменяет уже отправленный HTTP-запрос. Он не делает workflow exactly-once. Он не упорядочивает разные resource keys и не защищает внешний API, который не умеет принимать и проверять token. Для нескольких эффектов понадобятся разные контракты: fencing для одной записи, idempotency для повторного вызова, компенсация для уже принятого внешнего эффекта.

\n

Lease тоже не равен сигналу остановки процесса. В документации etcd expiry удаляет связанные ключи и освобождает lock, если сервер не получает keepalive. Документация не обещает отмену локальной функции клиента. Поэтому claim «lease истёк, A больше ничего не сделает» неверен.

\n

Эта статья не проверяет etcd, Redis, ZooKeeper, provider SDK, сеть, clock drift, cluster, нагрузку или production. Учебный interleaving показывает только нужный инвариант. Перед выпуском на реальном ресурсе нужно доказать, что его транзакция действительно отклоняет меньший token.

\n

Критерий готовности

\n

Работа готова, если интеграционный тест на выбранном ресурсе проходит четыре проверки: token 2 принимается; поздний token 1 не меняет значение; повторный token 1 получает явный отказ; старый release не снимает lease нового owner. В отчёте должны быть resource key, token до и после, причина отказа и сохранённое итоговое значение. Если хотя бы одного поля нет, тест подтверждает только наличие lock, но не защиту от stale write.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/239.json b/editorial/agent-rewrites/239.json new file mode 100644 index 0000000..0b556f2 --- /dev/null +++ b/editorial/agent-rewrites/239.json @@ -0,0 +1,7 @@ +{ + "index": 239, + "slug": "editorial-2021-05-mechanism-distributed-locks", + "title": "Почему distributed lock не защищает позднюю запись", + "excerpt": "Lease координирует владельцев, но не останавливает проснувшийся процесс. Разбираем fencing token, атомарную проверку на ресурсе и диагностику stale write.", + "contentHtml": "

В журнале появляется странная последовательность: worker-A получил lock, надолго замолчал, worker-B получил тот же ресурс и записал новый результат, а затем A проснулся и тоже получил успешный ответ на запись. В базе снова лежит старое значение. В очереди возникла повторная команда. Внешний API принял действие от процесса, который уже не владел именем ресурса.

\n

Цена ошибки — не только одна неверная строка. Команда может решить, что lock-сервис выдал два владения одновременно, увеличить TTL и оставить настоящую дыру. Старый процесс не обязан знать, что его lease истёк. Проверка «lock был захвачен в начале» не защищает операцию, которая завершается позже.

\n

Тезис: lease отвечает за координацию владельцев, а fencing token защищает финальную запись. Ресурс должен хранить последнее принятое поколение и атомарно отклонять запрос с меньшим или равным token. Все worker, lease, token и времена ниже учебные: пример не запускает etcd, Redis, ZooKeeper, базу или сеть.

\n

Два разных вопроса

\n

Lock authority отвечает: «кому сейчас выдано владение именем?» Он может выдать lease-1 worker-A, дождаться expiry и выдать lease-2 worker-B. Lease ограничивает время владения. Keepalive продлевает его только пока authority получает подтверждения.

\n

Защищаемый ресурс отвечает иначе: «можно ли этому запросу изменить моё состояние после уже принятого поколения?» Ресурсом может быть строка в базе, объектное хранилище, файл или сервер, который принимает команду. Если он не проверяет поколение сам, наличие lock в другом сервисе не влияет на его решение.

\n
Граница ответственности lock и ресурса
СобытиеЗнает authorityЗнает ресурсДействие
A получил token 1lease-1 активенничегопередать token вместе с записью
lease-1 истёкимя доступноA всё ещё может ждатьне считать A остановленным
B получил token 2lease-2 активенничегопередать token вместе с записью
ресурс принял token 2может не участвоватьhighest token равен 2сохранить новое значение
поздний запрос A с token 1может видеть lease-2token 1 устарелотклонить запись
\n

Ресурсу не нужно снова спрашивать authority, жив ли lease-A. Такой запрос добавляет сетевой вызов и новую гонку. Достаточно собственного факта: token 2 уже принят для этого resource key. Это узкая гарантия. Она не останавливает A и не делает всю бизнес-операцию exactly-once.

\n

Механизм fencing token

\n

При каждом новом захвате authority выдаёт монотонное поколение. Token 1 относится к lease-A, token 2 — к lease-B. Request несёт token до точки, где появляется эффект. Ресурс хранит acceptedFenceToken. Условие записи выглядит так:

\n
function write(resource, request) {\n  if (request.fenceToken <= resource.acceptedFenceToken) {\n    return { status: 'rejected-stale-fence' };\n  }\n  resource.acceptedFenceToken = request.fenceToken;\n  resource.value = request.value;\n  return { status: 'accepted' };\n}
\n

Это псевдокод. В SQL условие и присваивание должны быть одной операцией, например UPDATE ... SET value = $1, fence_token = $2 WHERE id = $3 AND fence_token < $2. По числу изменённых строк видно, принял ли ресурс запрос. Отдельное чтение token, пауза и последующий write оставляют ту же гонку.

\n

Строгое сравнение важно. Равный token не делает request новым. Повтор доставки с тем же token требует отдельного operationId и идемпотентного журнала. ownerId показывает автора, leaseId связывает release с конкретным захватом, fenceToken задаёт порядок поколений, а operationId различает повторы одной операции.

\n
\"Схема
Authority выдаёт поколения, но обязательная защита появляется в ресурсе: он сравнивает token с последним принятым значением.
\n

Пошаговый пример гонки

\n

Пусть A получил token 1 и начал расчёт. Расчёт должен был занять меньше lease, но процесс остановился на сборке мусора или на медленном вызове базы. Lease истёк. B получил token 2, закончил расчёт и записал v2. A продолжил работу и отправил уже вычисленный v1.

\n
t=0    A: acquire(resource-7) -> lease-A, token=1\nt=5    A: compute()        -> процесс остановился\nt=10   authority: lease-A expired\nt=11   B: acquire(resource-7) -> lease-B, token=2\nt=12   B: write(v2, token=2)  -> accepted; highest=2\nt=13   A: write(v1, token=1)  -> rejected; highest=2
\n

Небезопасный ресурс делает иначе:

\n
resource.value = 'v2'; // B\nresource.value = 'v1'; // A: поздняя запись проходит
\n

Здесь ресурс не знает о lock и не отличает старый запрос от нового. Лог о том, что A когда-то владел lock, не исправит данные после записи. Если API не предоставляет условную запись, версию или серверную проверку, перенесите эффект на контролируемую транзакционную границу либо явно примите, что lock только координирует работу.

\n

Симптом → причина → проверка → действие

\n
Диагностика распределённой блокировки
СимптомПричинаПроверкаДействие
Старый worker получил 200 после новогоресурс не проверяет tokenсравнить token запроса и highest tokenдобавить атомарный reject stale write
Старый release снял lock Brelease проверяет только имясверить owner, leaseId и token текущего захватаотклонять старый handle
После retry получился duplicatefencing принят за идемпотентностьнайти operationId и журнал эффектадобавить идемпотентный контракт отдельно
Растёт доля busylease не продлевается или ресурс долго занятразделить acquire, keepalive и write latencyнастроить TTL по измерениям, не скрывать отказ
Нет token в запросе ресурсаграница защиты осталась в authorityпроследить поля до финального writeпередавать и сохранять поколение на ресурсе
\n

Порядок внедрения и проверки

\n
  1. Назовите один resource key и финальный write, который создаёт риск. Не прячьте несколько ресурсов за одним общим lock name.
  2. Опишите, что завершает владение: release, expiry, revoke или потеря сессии. Зафиксируйте исход каждого варианта.
  3. Сохраните точный handle захвата: owner, leaseId и выданный fence token. Старый handle не должен освобождать новый lease.
  4. Выберите источник монотонных поколений для каждого resource key. Не выводите token из wall clock и не называйте произвольный уникальный key fencing token без контракта.
  5. Сделайте сравнение token и запись эффекта одной атомарной операцией на ресурсе. Возвращайте отдельный результат stale, а не успешный ответ или общий timeout.
  6. Проверьте interleaving: A получил token 1 и остановился, lease истёк, B получил token 2 и записал, поздний A был отклонён.
  7. Отдельно проверьте повтор доставки, retry и идемпотентность. Fencing задаёт порядок владельцев, но не отвечает за повтор одного бизнес-эффекта.
  8. После этого испытайте provider на версии API, TTL, keepalive, revoke, сбоях связи и нагрузке. Учебный пример не заменяет интеграционный тест.
\n

Ограничения и критерий готовности

\n

Fencing не отзывает уже выполняющийся код. Он не отменяет запрос, ушедший без проверки, и не исправляет запись, принятую до добавления правила. Он не гарантирует согласованность между двумя независимыми ресурсами. Для нескольких записей нужна общая транзакционная граница, протокол саги или другая выбранная модель.

\n

Keepalive сокращает вероятность expiry во время нормальной работы, но не доказывает, что поздний request безопасен. Между подтверждением lease и финальным write остаются паузы, сетевые разрывы и очереди. Provider key тоже не становится fencing token автоматически: важны порядок поколений и проверка на стороне получателя.

\n

Учебные фрагменты не дают production-результатов. Они не измеряют задержки, clock drift, SLA, пропускную способность или поведение кластера. Проверяемый критерий готовности таков: после принятого token 2 любой запрос с token 1 получает явный stale-результат, значение ресурса остаётся результатом token 2, а старый release не меняет lease B.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/240.json b/editorial/agent-rewrites/240.json new file mode 100644 index 0000000..57a019d --- /dev/null +++ b/editorial/agent-rewrites/240.json @@ -0,0 +1,7 @@ +{ + "index": 240, + "slug": "editorial-2021-05-practice-distributed-locks", + "title": "Распределённая блокировка: почему lease не защищает позднюю запись", + "excerpt": "Старый worker может продолжить работу после истечения lease. Разбираем, как fencing token защищает ресурс, почему одного lock недостаточно и как проверить отрицательный сценарий до интеграции.", + "contentHtml": "

В журнале появляется странная последовательность: worker-A получил блокировку, worker-B выполнил ту же задачу после истечения времени, а затем worker-A вернулся и снова записал результат. Оба запроса могут завершиться успешно. Пользователь увидит старые данные, повторную отправку или две операции, которые должны были быть взаимоисключающими. Цена ошибки — не лишняя строка в логе. Это потеря свежего состояния и сложное восстановление, если поздняя запись уже ушла во внешнюю систему.

\n

Причина обычно не в том, что lock-сервис выдал два права одновременно. Lease даёт владельцу временное окно. Он не останавливает зависший процесс, не отменяет запрос в сети и не заставляет базу данных проверять состояние lock-сервиса. Поэтому распределённая блокировка защищает критическую секцию только вместе с защитой самого ресурса. Для этой границы нужен fencing token — монотонное поколение, которое ресурс сравнивает с последним принятым поколением.

\n

Что именно блокирует lease

\n

Пусть два worker-а пересобирают отчёт report:417. Lock authority хранит имя ресурса, текущий handle захвата и срок lease. Worker-A получает lease-1 и token 1. Затем процесс останавливается на паузе сборщика мусора, зависает на сетевом вызове или теряет связь с authority. Lease истекает. Worker-B получает lease-2 и token 2.

\n

На этом этапе worker-A не знает, что его право закончилось. Он может продолжить вычисление и отправить ранее подготовленный запрос. Если база или API принимают запись без проверки token, запрос worker-A перезапишет результат worker-B. Значит, проверка «lock был взят перед началом работы» не доказывает безопасность записи. Между проверкой и эффектом проходит время, за которое владелец может измениться.

\n
Контракт распределённой блокировки
ПолеГде проверяемЧто оно подтверждаетЧего оно не подтверждает
lockNamelock authorityWorker-ы спорят за один предметВсе связанные ресурсы используют ту же границу
ownerIdжурнал и диагностикаКто отправил запросПроцесс всё ещё имеет право писать
leaseIdоперация releaseОсвобождается именно текущий захватСтарый запрос исчез из сети
fenceTokenзащищаемый ресурсПоколение запросаЗапрос автоматически отменён
acceptedFenceTokenатомарная запись ресурсаСтарое поколение будет отклоненоОперация стала exactly-once
\n

ownerId нужен оператору. leaseId защищает release от запоздалого процесса. fenceToken защищает конечную запись. Не стоит подменять token временем на часах worker-а или случайным ключом lock-сервиса. Нужен порядок поколений для одного доменного ресурса. Ресурс должен хранить последнее принятое значение и сравнивать его с token в той же атомарной операции, которая создаёт эффект.

\n

Учебный interleaving

\n

Ниже — ограниченный учебный пример. Он не запускает etcd, Redis, базу, сеть или реальный cluster. Числа и ручные отметки времени нужны только для воспроизведения порядка событий.

\n
const resource = {\n  acceptedFenceToken: 0,\n  value: null,\n};\n\nfunction protectedWrite(token, value) {\n  if (token <= resource.acceptedFenceToken) {\n    return { status: 'rejected-stale-fence' };\n  }\n\n  resource.acceptedFenceToken = token;\n  resource.value = value;\n  return { status: 'accepted' };\n}\n\n// worker-A: lease-1, token 1; затем процесс остановился\n// worker-B: lease-2, token 2; запись пришла первой\nconsole.log(protectedWrite(2, 'report-v2-from-worker-B'));\nconsole.log(protectedWrite(1, 'old-report-from-worker-A'));\n// accepted\n// rejected-stale-fence
\n

Проверка должна выполняться на стороне ресурса. Если token сравнивается в worker-е отдельным чтением, гонка остаётся: оба worker-а могут прочитать одно и то же старое значение, а затем записать новое. В базе это обычно означает условное обновление внутри транзакции, проверку версии строки или другой атомарный механизм. Для файла нужен контролируемый сервер записи. Для внешнего API нужен контракт, который принимает поколение и отклоняет устаревшее. Если такой границы нет, lock остаётся средством координации, но не доказательством защиты данных.

\n
\"Учебная
Учебный interleaving: истечение lease освобождает имя для нового владельца, но не отзывает уже созданную работу старого процесса.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Старый результат перезаписал новыйРесурс принимает запись без fencingСравнить token запроса и последнее принятое поколениеДобавить атомарный reject устаревшего token или перенести эффект в ресурс с такой проверкой
Старый worker снял новую блокировкуRelease ищет только по имениСопоставить leaseId и текущий handleОтклонять release, если handle больше не принадлежит владельцу
Операция зависает после expiryНет отдельного таймаута работы или отменыПроверить длительность критической секции и путь отменыОграничить работу, обновлять lease по контракту или возвращать ошибку владельцу
Одинаковая задача выполнена дваждыFencing не равен идемпотентностиПроверить ключ операции и повторы после ответаДобавить идемпотентный эффект; не выдавать его за замену fencing
Нельзя объяснить отказЛоги не связывают worker, lease и ресурсНайти ownerId, leaseId, token и результат записиЛогировать корреляционный пакет без секретов и персональных данных
\n

Порядок проверки

\n
  1. Назовите один предмет блокировки и один ресурс, который меняется. Если операция трогает несколько ресурсов, опишите их границы отдельно.
  2. Зафиксируйте, что означает окончание владения: release, expiry, отзыв сессии или событие, определённое вашим provider.
  3. Получите уникальный handle текущего захвата. Старый worker не должен снять новый lease только потому, что знает имя lock.
  4. Выберите монотонный fencing token для каждого ресурса. Не выводите его из wall clock.
  5. Встройте сравнение token и запись эффекта в одну атомарную операцию ресурса.
  6. Воспроизведите отрицательный путь: token 1 остановился, lease истёк, token 2 записал результат, token 1 пришёл поздно.
  7. Проверьте повтор запроса и ошибку release отдельно. Они дополняют fencing и не следуют из него автоматически.
  8. Только после этого проверяйте TTL, keepalive, reconnect и отказ provider в тесте конкретной интеграции.
\n

Ограничения

\n

Fencing не делает операцию exactly-once. Он не отменяет старую работу и не исправляет побочный эффект, который уже произошёл до проверки. Он также не решает конфликт, если два действия законно должны выполняться параллельно. В этом случае им нужен разный ключ ресурса или другой протокол.

\n

Короткий lease нельзя объявлять безопасным только потому, что обычная операция обычно укладывается в его срок. Нужно учитывать паузы процесса, задержку сети, очередь на сервере, повторные попытки и время ответа защищаемого ресурса. Длинный lease уменьшает число ложных истечений, но увеличивает окно ожидания после сбоя. Ни один TTL не заменяет проверку поздней записи.

\n

Учебный код выше не подтверждает поведение конкретного провайдера и не содержит production-результатов. Он проверяет только контракт: после принятия token 2 запрос с token 1 получает явный отказ. Если выбранная база или API не умеют выполнить такой reject, это нужно записать как ограничение дизайна, а не скрывать увеличением TTL.

\n

Критерий готовности

\n

Механизм готов к интеграционной проверке, когда для одного доменного ресурса можно показать четыре наблюдаемых факта: новый владелец получает новое поколение; ресурс атомарно принимает его; поздний запрос старого владельца получает отдельный отказ и не меняет значение; старый release не снимает новый lease. В тестовом отчёте должны остаться token, leaseId, порядок событий и итоговое значение ресурса. Без этих фактов утверждение «распределённая блокировка защищает критическую секцию» слишком сильное.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/241.json b/editorial/agent-rewrites/241.json new file mode 100644 index 0000000..5ebf987 --- /dev/null +++ b/editorial/agent-rewrites/241.json @@ -0,0 +1,7 @@ +{ + "index": 241, + "slug": "editorial-2021-04-field-data-consistency", + "title": "Когда сервисы видят разные данные: диагностика рассинхронизации по версиям и событиям", + "excerpt": "Заказ уже отменён в owner-сервисе, но downstream-система всё ещё разрешает отгрузку. Разбираем, как отличить задержку события от ошибки consumer-а и какое evidence собрать до replay, correction или компенсации.", + "contentHtml": "

Пользователь отменил заказ, а экран отгрузки ещё показывает его готовым. Через несколько минут статус обычно меняется, но иногда фоновый consumer пропускает событие, применяет его раньше предыдущего или отмечает доставку выполненной до записи projection. Цена ошибки — не только неверный текст в интерфейсе. Система может отправить товар, открыть доступ, списать деньги или создать вторую компенсацию.

\n

Не исправляйте такой случай ручной записью статуса во второй сервис. Сначала остановите рискованный эффект и соберите факты для одного object id. Иначе вы затрёте gap, потеряете event id и лишите себя ответа на вопрос, почему копии разошлись.

\n

Тезис: одинаковый статус не доказывает одинаковое состояние

\n

В распределённой системе один сервис владеет решением, а другие держат производные представления. Назовём первый сервис owner, а такое представление — projection. Между записью owner и применением события в projection существует граница времени. Она может быть нормальной задержкой. Она может быть отказом доставки. Она может быть ошибкой порядка или локальной транзакции.

\n

Проверяйте не только значение state, но и его версию. Owner cancelled v3 и projection awaiting-reservation v2 показывают, что projection не видит часть истории. Если обе системы хранят cancelled, но одна имеет v2, а другая v3, downstream всё ещё может работать по устаревшим правилам. State без версии — неполное доказательство.

\n

Какие факты собрать до изменения данных

\n

Для первого разбора достаточно связанного evidence packet. В него входят objectId, owner state и version, projection state и version, source и event id, а также причина перехода и compensation key, если система выполняла компенсацию. Каждый факт должен иметь источник: строка базы, запись delivery, consumer ledger или лог конкретной операции.

\n
{\n  \"objectId\": \"order-417\",\n  \"owner\": {\"state\": \"cancelled\", \"version\": 3},\n  \"projection\": {\"state\": \"awaiting-reservation\", \"version\": 2},\n  \"event\": {\"id\": \"evt-order-417-cancelled-v3\", \"source\": \"order-service\"},\n  \"reason\": \"training-reservation-rejected\",\n  \"compensationKey\": \"order-417:v3:reservation\"\n}
\n

Значения в примере учебные. Они не описывают production-инцидент и не доказывают доставку через конкретный брокер. Пример показывает минимальный формат проверки. Его можно перенести на HTTP-события, очередь сообщений или запись в журнале, если ваш контракт хранит те же факты.

\n

Симптом → причина → проверка → действие

\n
Диагностика одного расхождения без ручного затирания evidence
СимптомВероятная причинаПроверкаДействие
Owner v3, projection v2Missing или deferred eventНайти delivery v3 и ожидаемую v2Сохранить gap; применить v2, затем контролируемый replay v3
v3 пришла раньше v2Нарушен порядок или consumer не дождался версииПроверить deferred-хранилище и sequenceОтложить v3; не применять её поверх v1
Один event id виден дваждыRetry доставкиСверить consumer ledger и state transitionПодавить duplicate; проверить, что effect не повторился
Та же version, другой event idКонфликт контрактаСравнить source, payload и правило переходаОтклонить конфликт и передать owner-у
Owner cancelled, projection readyToShipУстаревшее представлениеСверить версии и evidence резерваЗаблокировать отгрузку до доказанного состояния
Compensation key уже существуетПовторное решение по тому же входуСверить object id, version и reasonВернуть существующее решение; не создавать вторую компенсацию
\n

Механизм: версия отделяет gap от устаревшей копии

\n

Owner увеличивает версию при каждом принятом доменном решении. Событие переносит эту версию и стабильный event id. Projection принимает событие только по правилу перехода. Если пришла ожидаемая следующая версия, consumer применяет её. Если версия больше ожидаемой, он сохраняет gap или deferred event. Если версия меньше текущей, он не возвращает state назад. Если версия совпадает, но event id другой, это не duplicate: это конфликт, который требует отдельного решения.

\n
function acceptEvent(projection, event) {\n  if (event.version < projection.version) {\n    return { status: 'stale-event-rejected' };\n  }\n\n  if (event.version === projection.version) {\n    if (event.id === projection.lastEventId) {\n      return { status: 'duplicate-event-suppressed' };\n    }\n    return { status: 'same-version-conflict' };\n  }\n\n  if (event.version > projection.version + 1) {\n    return { status: 'gap-recorded', expected: projection.version + 1 };\n  }\n\n  return { status: 'apply', version: event.version, eventId: event.id };\n}
\n

Это учебная функция. Она не запускает broker, не пишет базу и не делает транзакцию между owner и projection. Её задача — явно разделить четыре пути, которые часто смешивают одной операцией «синхронизировать статус». Реальный consumer должен атомарно или иным проверяемым способом связать запись ledger и изменение projection. Если ledger помечен раньше state update, появится ложное «event уже применён». Если state записан раньше ledger, retry должен быть безопасен.

\n

Иллюстрация маршрута диагностики

\n
\"Схема
Сначала блокируется рискованный следующий шаг, затем сохраняется evidence. Только после этого выбирают replay, correction или ручное решение.
\n

Для события cancelled v3, которое пришло раньше v2, безопасный ответ — не «подождать ещё минуту». Consumer должен сохранить v3 вместе с ожидаемой версией 2. Когда v2 появится, он применяет v2 и повторно рассматривает v3. Если транспорт не гарантирует получение v2, нужен отдельный контракт поиска пропущенного перехода или ручной маршрут восстановления. Нельзя объявлять v3 конечным состоянием, пока не проверен порядок.

\n

Компенсация не заменяет подтверждение внешнего эффекта

\n

Компенсация — новое доменное решение, а не удаление старой записи. Например, owner получает training-reservation-rejected, проверяет orderId и исходную версию, затем создаёт решение cancelled v3 с одним compensationKey. Уникальный ключ защищает owner от повторной записи одного и того же решения.

\n

Но ключ не доказывает, что внешний резерв отменён. Timeout означает только, что ответ не получен. Внешняя система могла принять отмену, отклонить её или выполнить исходный резерв до обрыва связи. Поэтому результат компенсации должен иметь собственное evidence: ответ владельца внешнего эффекта, подтверждённый event или ручное решение с журналом. Не называйте компенсацию завершённой только потому, что запись с ключом появилась в локальной базе.

\n
BEGIN;\n  INSERT INTO compensation_ledger (compensation_key, order_id, source_version)\n  VALUES ('order-417:v3:reservation', 'order-417', 2)\n  ON CONFLICT (compensation_key) DO NOTHING;\n\n  INSERT INTO order_events (order_id, state, version)\n  VALUES ('order-417', 'cancelled', 3);\nCOMMIT;
\n

Учебный SQL показывает локальное уникальное ограничение и идемпотентный путь записи. Он не делает атомарными изменения в другой базе, отправку сообщения и внешний вызов. Реализация должна отдельно определить границы транзакции, retry и восстановление после частичного успеха.

\n

Право на следующий эффект

\n

Projection не должна разрешать отгрузку только по локальному флагу readyToShip. Перед необратимым или дорогим действием она сверяет owner state, свою версию и нужное локальное evidence. В учебном контракте отгрузка разрешена, когда состояние paid, версии равны, а резерв подтверждён. После cancelled v3 функция возвращает запрет, даже если старый UI-флаг ещё не очищен.

\n
function canShip(owner, projection) {\n  return owner.state === 'paid'\n    && projection.state === 'readyToShip'\n    && owner.version === projection.version\n    && projection.reservationEvidence === 'confirmed';\n}
\n

Это не универсальное бизнес-правило. В некоторых доменах действие обратимо. В других нужен manual decision или отдельная компенсация. Универсальным остаётся вопрос: какой эффект нужно запретить, пока состояние не подтверждено владельцем и актуальной версией?

\n

Порядок действий

\n
  1. Остановить отгрузку, доступ, списание или другой рискованный эффект для конкретного object id.
  2. Сохранить owner state и version, projection state и version, source, event id, reason и compensation key.
  3. Сравнить версии. При owner > projection искать missing или deferred event. При равных версиях проверять правило перехода и локальный effect evidence.
  4. Проверить consumer ledger отдельно от projection. Duplicate event id, stale version и same-version conflict не сводить к одной операции удаления.
  5. Проверить компенсацию: известны ли причина и исходная версия, уникален ли ключ, создано ли новое owner state.
  6. Выбрать действие по контракту: controlled replay, поиск пропущенного события, correction с журналом или manual decision. Не повторять внешний timeout вслепую.
  7. После восстановления проверить найденную границу отдельным тестом или интеграционной проверкой: gap, duplicate, stale event, split между ledger и effect или неопределённый внешний результат.
\n

Ограничения

\n

Версия и event id не гарантируют доставку. Они делают пропуск и повтор различимыми. Уникальное ограничение защищает локальную запись, но не несколько сервисов сразу. Eventual consistency не означает, что любое запаздывание безопасно: следующий эффект может произойти в неправильном состоянии. Replay не является исправлением сам по себе. Он безопасен только при известном контракте идемпотентности и сохранённом порядке.

\n

Статья не утверждает production-результаты. Кодовые фрагменты учебные. Они не запускают реальную базу, брокер, внешний резерв, HTTP-клиент или deployment. Для рабочей системы отдельно проверяйте storage, transport, retry policy, транзакционный порядок и владельца внешнего действия.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда для одного object id можно воспроизвести цепочку owner decision → event delivery → consumer transition и ответить на четыре вопроса: какая версия является последней, какой event id её переносит, почему projection отстаёт или расходится и какой рискованный эффект заблокирован. После исправления повторная доставка не создаёт второй transition или компенсацию, stale event не возвращает состояние назад, а same-version conflict не применяется молча. Если хотя бы один ответ основан на предположении, случай ещё не закрыт.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/242.json b/editorial/agent-rewrites/242.json new file mode 100644 index 0000000..69f2079 --- /dev/null +++ b/editorial/agent-rewrites/242.json @@ -0,0 +1,7 @@ +{ + "index": 242, + "slug": "editorial-2021-04-mechanism-data-consistency", + "title": "Согласованность между сервисами: почему event id не заменяет версию и компенсацию", + "excerpt": "Повтор сообщения, пропущенная версия и неизвестный результат действия создают разные виды расхождения. Учебный пример показывает, как owner, consumer и компенсация удерживают состояние от отката и повторного эффекта.", + "contentHtml": "

Сервис заказов уже показывает cancelled v3, а сервис исполнения всё ещё хранит awaiting-reservation v2. Оператор видит два правдивых ответа для одного заказа. Проблема начинается, когда второй сервис продолжает работу по старой проекции: готовит отгрузку, повторяет резерв или отправляет пользователю неверный результат. Цена ошибки — не только задержка. Старое состояние может запустить необратимое действие.

Такое расхождение часто называют одной фразой: «данные не синхронны». Она скрывает три разных случая. Consumer мог получить тот же event второй раз. Он мог получить новую версию раньше предыдущей. Внешнее действие могло завершиться неизвестно, а код решил автоматически отменить заказ. Для этих случаев нужны разные ключи, проверки и пути отказа.

Тезис: согласованность начинается с границы решения

Один сервис должен владеть доменным состоянием. Назовём его order-service. Он принимает решение, что заказ оплачен или отменён, и выпускает последовательные версии. Сервис fulfillment владеет только своей проекцией: резервом, складом и готовностью к отгрузке. Он не переписывает состояние заказа по своему локальному таймауту.

Временное расхождение допустимо, если система знает четыре факта: кто владеет состоянием, какую версию принял owner, какую версию применил consumer и какое действие запрещено до сверки. Если этих фактов нет, «eventual consistency» становится оправданием для угадывания.

Контракт учебного заказа на границе сервисов
ФактВладелецДоказательствоРазрешённое действие
paid v2order-serviceorder id, version, event idсоздать проекцию ожидания резерва
отказ резерварешение owner-аreason, исходная version, compensation keyсоздать новое решение или остановить разбор
cancelled v3order-serviceновая version и событиеприменить после закрытия gap
готовность к отгрузкеfulfillmentсовпавшая version и reservation evidenceразрешить локальный шаг
owner version > projection versionзадержкаgap и сохранённое событиеждать, найти пропуск или передать на разбор

Инвариант формулируется через действие: отгрузка запрещена, пока проекция исполнения не применит версию owner-а и не имеет доказательства успешного резерва. Это полезнее, чем требование мгновенно сделать все копии одинаковыми. Сервис может временно показывать старый статус, но не должен на его основе совершать дорогой шаг.

Три ключа, три вопроса

event id отвечает на вопрос о доставке: применялся ли этот конкретный конверт? Для него подходит ключ вроде source + id. orderVersion отвечает на вопрос о последовательности: какой переход должен быть следующим для одного заказа? compensationKey отвечает на вопрос о решении: создавалась ли уже эта компенсация по этой причине и исходной версии?

Эти ключи нельзя слить в один. Повтор одного event и новая версия с тем же order id — разные случаи. Два разных event id могут описывать одну и ту же версию, что является конфликтом контракта. Один event id не доказывает, что внешний резерв освобождён. Уникальность записи в локальной таблице также не подтверждает доставку в другой сервис.

const decision = inspect(projection, event); // duplicate, gap, stale или next-version\\nif (decision.action === 'next-version') applyAtomically(projection, event);\\nif (decision.action === 'gap') deferWithEvidence(projection, event);\\nif (decision.action === 'duplicate') keepStateUnchanged();

Код учебный. Он показывает только ветвление после чтения фактов. В нём нет очереди, базы и повторной доставки. Реальная проверка должна быть атомарной с записью версии и ledger обработанных событий в выбранной границе хранения.

Пример: версия пришла не по порядку

Пусть owner записал paid v2. Затем резерв вернул контролируемый отказ. Owner создаёт новое решение cancelled v3, записывает причину и один compensationKey. Consumer получает событие v3 раньше v2. Он не должен применить отмену поверх v1: v2 может содержать обязательный переход или факт, который объясняет дальнейшее решение.

\"Owner
Gap — это фиксируемое ожидание: consumer откладывает v3, применяет v2, затем повторяет v3. Повтор того же event не создаёт нового перехода.

В projection появляется запись: «ожидалась v2, пришла v3». Статус остаётся на v1, а событие v3 сохраняется вместе с evidence. После доставки v2 consumer выполняет переход v1 → v2, затем достаёт v3 и выполняет v2 → v3. Порядок проверяет контракт проекции, а не удачная сортировка сообщений.

Если v2 не приходит, автоматический путь заканчивается. Можно запросить повтор owner-а, найти событие по журналу или передать объект на ручной разбор. Нельзя считать gap безопасным по таймауту. Нельзя подменять проекцию строкой cancelled, если при этом исчезает факт пропущенной версии.

Симптом → причина → проверка → действие

Диагностика расхождения для одного object id
СимптомПричинаПроверкаДействие
Один event виден дваждыduplicate доставкиесть ли его ключ в consumer ledgerподавить повтор и проверить отсутствие state change
Пришла v3, projection на v1version gapесть ли deferred event и evidence ожидаемой v2отложить v3, найти v2, затем replay
Два разных event имеют v3конфликт версиисовпадают ли source и transition ruleотклонить второй event и передать owner-у
Owner отменён, UI готов к отгрузкестарый локальный флагравны ли версии и есть ли reservation evidenceзаблокировать отгрузку и собрать факты
Внешний резерв дал timeoutрезультат неизвестенесть ли подтверждение или operation keyне отменять автоматически; выполнить сверку
Повторно создаётся компенсациянет уникального ключа решенияесть ли запись по order, version и reasonсделать owner ledger идемпотентным локально

Timeout не равен отказу. Внешняя система могла принять запрос и потерять ответ. Если автоматически создать компенсацию, можно получить двойной эффект: резерв создан, заказ отменён, а повторная попытка создаёт ещё одну операцию. Без evidence безопаснее удержать состояние и запустить сверку.

Компенсация — новое решение, а не распределённый rollback

Компенсация не стирает paid v2. Owner сохраняет историю и создаёт следующий переход cancelled v3 по узкому набору причин. В учебной модели допустима причина training-reservation-rejected, если она относится к версии v2. Неизвестный timeout не проходит это условие.

function createCompensation(order, failure, ledger) { return failure.reason === 'training-reservation-rejected' && failure.orderVersion === order.version && !ledger.has(order.id + ':' + order.version) ? 'create-cancelled-v3' : 'manual-review'; }

Вызов с тем же входом должен вернуть уже записанное решение, а не создать вторую отмену. Это защита одного решения в одной учебной границе. Она не делает внешний API exactly-once. Для внешнего эффекта нужен отдельный operation key, владелец результата и способ проверить, что произошло после потери ответа.

Локальная транзакция не пересекает границу сервиса

В одной базе можно обновить owner и записать ledger компенсации в одной транзакции. Уникальное ограничение защищает повтор записи в этой базе. Изоляция транзакции помогает согласовать конкурентные изменения внутри неё. Но та же транзакция не отправляет надёжно сообщение в отдельный broker и не отменяет внешний резерв одной командой.

BEGIN; UPDATE orders SET state = 'cancelled', version = 3 WHERE id = 'order-417' AND state = 'paid' AND version = 2; INSERT INTO compensation_ledger (compensation_key) VALUES ('compensation:order-417:reservation:v2') ON CONFLICT (compensation_key) DO NOTHING; COMMIT;

Этот SQL — только граница локального owner-а. Он не гарантирует, что consumer увидит событие, что сообщение не потеряется и что внешний резерв уже освобождён. Нельзя приписывать учебному SQL свойства, которых в нём нет.

Порядок действий

  1. Выбрать owner для каждого доменного состояния и зафиксировать его право менять состояние.
  2. Назвать рискованное следующее действие: отгрузка, доступ, списание или письмо. Сформулировать запрет через state, version и evidence.
  3. Разделить event id, version объекта и compensation key. Для каждого указать место хранения.
  4. В consumer хранить последнюю применённую версию и обработанные event id. При gap сохранять evidence и не менять projection.
  5. Описать узкий список причин для компенсации. Не переводить неизвестный отказ в отмену автоматически.
  6. Защитить повтор компенсации локальным ключом. Отдельно защитить consumer от duplicate event.
  7. Проверить fixture для duplicate, gap, stale event, same-version conflict и неизвестного timeout.
  8. После этого проверить реальные database, broker и внешний API интеграционными тестами.

Ограничения и критерий готовности

Все id, версии, причины и состояния в статье учебные. Пример не запускает PostgreSQL, Kafka, HTTP, внешний резерв, два независимых процесса, CI или production build. Он не измеряет задержку и не доказывает отсутствие потери сообщений. Иллюстрация показывает логику state machine, а не topology конкретной платформы.

Критерий готовности можно проверить на одном тестовом заказе. Для каждого расхождения доступны owner state и version, projection state и version, source с event id, запись gap или duplicate, а также compensation key и reason. При повторе event состояние не меняется второй раз. При gap consumer не применяет более позднюю версию. При неизвестном timeout система не создаёт автоматическую компенсацию. Рискованное действие остаётся заблокированным без равной версии и evidence. Если хотя бы один факт нельзя получить из журнала или хранилища, контракт ещё не готов к безопасному восстановлению.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/243.json b/editorial/agent-rewrites/243.json new file mode 100644 index 0000000..a855b4f --- /dev/null +++ b/editorial/agent-rewrites/243.json @@ -0,0 +1,7 @@ +{ + "index": 243, + "slug": "editorial-2021-04-practice-data-consistency", + "title": "Согласованность данных между сервисами: версия, владелец и безопасное действие", + "excerpt": "Один сервис уже отменил заказ, а другой всё ещё готовит его к отгрузке. Разбираем, как отделить owner-состояние от проекции, пережить gap и не превратить повтор события в новый бизнес-эффект.", + "contentHtml": "

Симптом выглядит так: order-service показывает cancelled v3, а fulfillment всё ещё хранит awaiting-reservation v2. Оба сервиса говорят об одном заказе, но видят разные версии. Цена ошибки возникает в следующей строке кода: второй сервис разрешает отгрузку по старому флагу, повторно просит резерв или отправляет пользователю неверное уведомление. Ручная правка статуса скрывает причину и может создать второй эффект.

\n

Главный тезис простой: согласованность между сервисами начинается не с одинаковых строк в таблицах. Нужны владелец доменного состояния, монотонная версия, доказательство события и инвариант следующего рискованного действия. Проекция может временно отставать. Она не должна использовать отставание как разрешение на действие.

\n

Сначала отделите владельца от проекции

\n

Владелец, или owner, принимает доменное решение. В нашем примере order-service владеет состоянием заказа. Только он переводит paid v2 в cancelled v3. fulfillment владеет своим локальным состоянием: получил ли он заказ, удалось ли зарезервировать товар, можно ли передавать его на склад. Он не переписывает заказ задним числом.

\n

Это разделение не делает систему синхронной. Оно делает расхождение проверяемым. Если версия owner-а больше версии проекции, consumer должен объяснить gap: событие задержалось, пришло не по порядку, было отфильтровано или не записало локальный эффект. Поле status без источника и версии такого объяснения не даёт.

\n
Минимальный контракт заказа на границе сервисов
ФактВладелецДоказательствоРазрешённое действие
paid v2order-serviceorder id, version, event idПостроить проекцию ожидания резерва
Отказ резерваorder-serviceПричина, исходная версия, compensationKeyПринять новое решение или оставить заказ на проверке
cancelled v3order-serviceНовое событие и версия 3Применить в проекции после версии 2
Готовность к отгрузкеfulfillmentТа же версия и evidence резерваРазрешить локальный шаг
\n

Полезный инвариант звучит конкретно: отгрузка запрещена, если версия проекции не равна версии owner-а или нет явного доказательства резерва. Он запрещает действие в момент, когда ошибка ещё обратима. Формулировка «данные когда-нибудь сойдутся» для обработчика бесполезна.

\n

Версия защищает порядок, id защищает повтор

\n

Событие должно переносить не только новый статус. Ему нужны идентификатор, источник, тип, объект и версия этого объекта. В учебном примере используется такой конверт:

\n
const event = {\n  id: 'evt-order-417-cancelled-v3',\n  source: 'training/order-service',\n  type: 'training.order.cancelled',\n  subject: 'order-417',\n  orderVersion: 3,\n  reason: 'reservation-rejected',\n};
\n

Здесь source + id отвечает на один вопрос: применялся ли уже этот конверт. orderVersion отвечает на другой: допустим ли переход для данной проекции. Нельзя заменить одно другим. Два разных event id могут описывать одну и ту же версию и конфликтовать. Один event id может прийти повторно, но не должен создать новый переход.

\n

Если проекция имеет версию 1 и получает событие версии 3, она не должна молча записать cancelled. Событие нужно сохранить как отложенное, зафиксировать ожидаемую версию 2 и выбрать маршрут: дождаться доставки, запросить replay или передать запись на ручную сверку. Автоматически пропускать версию можно только при явно описанном контракте, который доказывает безопасность такого пропуска.

\n

Механизм: owner принимает решение, consumer догоняет его

\n

Представим короткую последовательность. Owner записал paid v2 и выпустил событие. Consumer применил его, поэтому его проекция также имеет версию 2. Резерв вернул подтверждённый отказ. Owner в своей локальной транзакции создал запись компенсации и новое состояние cancelled v3. Затем событие версии 3 пришло в consumer раньше версии 2 из-за задержки доставки.

\n

Consumer видит gap и оставляет проекцию на версии 1. Он не выдаёт отмену за применённое состояние и не разрешает отгрузку. Когда приходит версия 2, consumer применяет ровно следующий переход. После этого он повторно рассматривает отложенную версию 3. Повтор версии 2 или 3 подавляется по event id и версии. Если другая запись претендует на уже занятую версию, это конфликт, а не обычный retry.

\n
function applyOrderEvent(projection, event) {\n  const eventKey = `${event.source}:${event.id}`;\n\n  if (projection.appliedEventIds.has(eventKey)) {\n    return { kind: 'duplicate', projection };\n  }\n\n  if (event.orderVersion <= projection.version) {\n    return { kind: 'stale-or-conflict', projection };\n  }\n\n  if (event.orderVersion !== projection.version + 1) {\n    projection.deferred.set(event.orderVersion, event);\n    return { kind: 'version-gap', expected: projection.version + 1, projection };\n  }\n\n  projection.state = event.type === 'training.order.cancelled'\n    ? 'cancelled'\n    : 'awaiting-reservation';\n  projection.version = event.orderVersion;\n  projection.appliedEventIds.add(eventKey);\n  return { kind: 'applied', projection };\n}
\n

Код выше — учебный пример в памяти. Он не подключается к broker, базе, HTTP или внешнему резерву. Его задача — показать границу решения: duplicate не меняет состояние, gap не маскируется статусом, последовательное событие применяет ровно один переход. В настоящем consumer-е ledger события и запись проекции должны иметь согласованный способ фиксации. Иначе ledger может сказать «применено», пока обновление проекции потерялось.

\n

Симптом → причина → проверка → действие

\n
Диагностика расхождения одного объекта
СимптомПричинаПроверкаДействие
Owner v3, projection v2Consumer ещё не применил новую версиюСверить order id, версии, source и event idСохранить gap и выполнить согласованный replay
Пришла v3, ожидалась v2События пришли не по порядкуПроверить deferred-запись и наличие v2Не применять v3; после v2 повторить обработку
Один event id виден дваждыПовтор доставкиНайти ключ в consumer ledgerПодавить повтор и проверить отсутствие второго эффекта
Две записи претендуют на одну версиюКонфликт owner-контракта или sourceСравнить payload, source, id и правило переходаОтклонить конфликт и передать его владельцу состояния
Projection готова к отгрузке, owner cancelledЛокальный флаг устарелСверить version и reservation evidenceЗаблокировать отгрузку, затем собрать evidence packet
\n

Последняя строка важнее красивого статуса в интерфейсе. Если owner уже отменил заказ, старый флаг readyToShip не должен иметь самостоятельной силы. Сначала блокируется рискованный эффект. Потом проверяются owner version, projection version, event id и причина решения. Ручная коррекция проекции допустима только после сохранения этих фактов и понятного маршрута восстановления.

\n

Локальная транзакция не становится распределённой

\n

Owner может атомарно изменить заказ и ledger компенсации в одной базе. Это защищает локальную границу. Такая транзакция не доказывает, что consumer получил событие, внешний резерв отменился или письмо ушло. Для каждого внешнего эффекта нужен собственный ключ повторения, владелец и evidence результата.

\n
BEGIN;\n\nUPDATE orders\nSET status = 'cancelled', version = version + 1\nWHERE id = 'order-417' AND status = 'paid' AND version = 2;\n\nINSERT INTO compensation_ledger (compensation_key, order_id, order_version)\nVALUES ('compensation:order-417:reservation:v2', 'order-417', 2)\nON CONFLICT (compensation_key) DO NOTHING;\n\nCOMMIT;
\n

Запрос защищает переход одного owner-а и повтор записи в его ledger. Он не делает две базы атомарными. Если внешний вызов вернул timeout, этого недостаточно для автоматической отмены: операция могла завершиться на другой стороне. Неизвестный результат должен вести к сверке, а не к предположению.

\n
\"Схема:
Расхождение версий — отдельное состояние ожидания. Оно не даёт проекции права на следующий необратимый шаг.
\n

Порядок внедрения

\n
  1. Назовите owner каждого доменного состояния. Зафиксируйте сервис, который имеет право менять его.
  2. Выберите одно рискованное действие: отгрузка, выдача доступа, письмо или счёт. Запишите state, version и evidence, без которых оно запрещено.
  3. Добавьте к событию стабильные id, source, type, subject и версию объекта. Не смешивайте id доставки с бизнес-ключом эффекта.
  4. Храните в consumer последнюю применённую версию, ledger event id и отложенные версии. Для gap задайте срок и маршрут восстановления.
  5. Разделите duplicate, stale и same-version conflict. Для каждого результата задайте отдельный сигнал и владельца.
  6. Защитите компенсацию ключом именно решения owner-а. Отдельно защитите внешний эффект, если он существует.
  7. Проверьте отрицательный путь: отсутствует v2, пришла повторная v3, внешний вызов вернул timeout, проекция предлагает отгрузку после отмены.
  8. Только после этого подключайте конкретные базу, broker и интеграционные тесты. Учебный in-memory пример не заменяет их.
\n

Ограничения и критерий готовности

\n

Эта модель не обещает exactly-once для бизнеса, нулевую задержку, сохранность каждого сообщения или автоматическую компенсацию внешней операции. Версия защищает порядок одного owner-а, а не всей системы. Event id защищает повтор конверта, а не повтор платежа, письма или резерва. Локальная транзакция защищает одну базу, а не сеть между сервисами.

\n

Критерий готовности проверяем на одном тестовом заказе. По журналу должны восстанавливаться owner id и version, применённый event id, версия проекции, причина gap и compensation key. При v3 раньше v2 проекция не должна двигаться дальше ожидаемой версии. При повторе v3 бизнес-эффект не должен выполняться второй раз. При owner=cancelled отгрузка должна вернуть отказ даже при старом локальном флаге. Если хотя бы один факт нельзя найти без ручной догадки, контракт ещё не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/244.json b/editorial/agent-rewrites/244.json new file mode 100644 index 0000000..47aa43c --- /dev/null +++ b/editorial/agent-rewrites/244.json @@ -0,0 +1,7 @@ +{ + "index": 244, + "slug": "editorial-2021-03-field-queues", + "title": "Poison message: как остановить бесконечный retry и не потерять задачу", + "excerpt": "Consumer снова и снова получает одно сообщение, очередь не движется, а команда не знает, был ли уже создан эффект. Разбираем ограниченный retry, идемпотентный ключ и ручной маршрут для poison message.", + "contentHtml": "

Consumer получает одно и то же сообщение, пишет одинаковую ошибку и возвращает его в очередь. Полезные задачи ждут за ним, нагрузка растёт, а оператор видит только новый красный лог. Если worker успел создать внешний эффект до сбоя, повтор может отправить письмо, списать деньги или открыть заявку ещё раз. Если сообщение удалить, исчезнет контекст, по которому можно понять, что произошло.

\n

Цена ошибки складывается из двух частей. Бесконечный retry расходует ресурсы и маскирует неисправный вход. Без проверки эффекта повтор создаёт дубликат. Поэтому poison message — не любое сообщение с ошибкой. Это сообщение, для которого текущий consumer исчерпал доказанные автоматические действия и должен остановиться, сохранив данные для решения.

\n

Главный тезис

\n

Очередь не исправляет обработку. Она только отделяет приём работы от её выполнения. Consumer должен явно различать временный сбой, непригодный вход, уже выполненный эффект и неизвестную причину. Для временного сбоя подходит ограниченный retry. Для остальных ветвей нужен сохранённый контекст, а иногда — ручная проверка. Подтверждать доставку можно после того, как обработчик выполнил нужное действие или записал безопасное состояние.

\n

В этой статье используется учебная модель. Она не подключается к RabbitMQ, Kafka, базе, сети или внешнему API. Имена msg-order-417, invoice-417:reminder и задержка 1000 мс нужны для объяснения инвариантов. Они не являются настройками production и не дают измерений пропускной способности.

\n

Как возникает poison message

\n

Сначала broker передаёт delivery consumer-у. Обработчик проверяет payload, читает доменные данные и выполняет эффект. Затем он подтверждает обработку. Если процесс падает до подтверждения, broker может доставить сообщение снова. Это полезно при временном сбое, но опасно, если причина лежит в самом payload или если эффект уже произошёл, а подтверждение потерялось.

\n

Один счётчик попыток не даёт диагноза. Ошибка таймаута может исчезнуть после короткой задержки. Неизвестная версия схемы не станет корректной от десяти повторов. Пропущенный sequence key может требовать ожидания предыдущего сообщения, а может указывать на потерянное состояние. Неправильный подход выглядит так: любое исключение превращают в retry, а после роста очереди увеличивают лимит. Так система дольше повторяет тот же неверный шаг.

\n

У обработки должны быть две независимые проверки. Первая отвечает, можно ли сейчас трактовать вход. Вторая отвечает, был ли уже создан доменный эффект. Только после них выбирают retry, подтверждение дубликата или ручной маршрут.

\n

Минимальный контракт сообщения

\n

Для безопасного разбора нужны идентификаторы, которые не меняются между доставками. messageId связывает обработку с исходной доставкой. effectKey обозначает доменный эффект и помогает подавить повтор. sequenceKey задаёт порядок, если события нельзя выполнять независимо. Версия payload позволяет отличить известную схему от входа, который consumer не умеет читать.

\n
const message = {\n  messageId: 'msg-order-417',\n  effectKey: 'invoice-417:reminder',\n  sequenceKey: 'invoice-417',\n  payload: { schema: '2021-03', invoiceId: '417' },\n};\n\nconst retryPolicy = {\n  maxAttempts: 2,\n  delaysMs: [1000],\n};\n\nfunction recordEffectOnce(ledger, effectKey) {\n  if (ledger.has(effectKey)) return 'duplicate-effect-suppressed';\n  ledger.add(effectKey);\n  return 'effect-recorded';\n}
\n

Этот код — учебный пример в памяти. Set не заменяет транзакционный ledger. В настоящем сервисе ключ должен проверяться в хранилище с гарантией, соответствующей доменному эффекту. Для письма может хватить уникального ключа операции. Для платежа потребуются правила провайдера, статус операции и отдельная сверка. Нельзя переносить этот фрагмент в production без определения владельца состояния и границы записи.

\n

Классификация перед retry

\n

Классификация должна быть маленькой и явной. Известный временный класс получает конечную политику. Известная терминальная причина останавливает автоматический маршрут. Неизвестная причина не становится временной по умолчанию. Это консервативный выбор: он задерживает одну задачу, но сохраняет возможность понять, что с ней случилось.

\n
function classifyFailure(error) {\n  if (error.code === 'dependency-not-ready') return 'temporary';\n  if (error.code === 'schema-not-supported') return 'terminal';\n  if (error.code === 'sequence-gap') return 'terminal';\n  return 'unknown';\n}\n\nconst kind = classifyFailure({ code: 'schema-not-supported' });\n// kind === 'terminal': новый retry не выбирается
\n

Ограниченный backoff нужен для временного класса, а не для успокоения метрики. В учебной policy две попытки и одна задержка. В реальном проекте лимит зависит от timeout зависимости, времени жизни данных, пропускной способности и цены повторного эффекта. Эти значения надо записать рядом с контрактом и проверить на отрицательном пути: зависимость не отвечает, попытки заканчиваются, сообщение не остаётся в бесконечном цикле.

\n

Симптомы и действия

\n
Диагностика одной неуспешной обработки
СимптомПричинаПроверкаДействие
Одна ошибка повторяется с коротким интерваломНеограниченный retry или requeueСравнить messageId, attempts и интервал между доставкамиОграничить попытки и добавить backoff
Payload не читается текущим consumerНеизвестная версия схемыСверить schema с поддержанными версиямиСоздать manual record и остановить цикл
Эффект уже есть, receipt потерянСбой между эффектом и подтверждениемПроверить ledger по effectKeyПодтвердить duplicate без нового эффекта
Следующее сообщение нельзя выполнить по порядкуПропущен sequenceKeyПроверить предыдущую последовательность и её статусПеренести в manual review с причиной sequence-gap
Лимит попыток исчерпанPolicy не получила успешный исходПроверить attempts, reason и применённые задержкиСохранить terminal record с владельцем решения
\n

Последняя колонка не обещает, что причина устранена. Она фиксирует следующий безопасный шаг. Manual review не равно dead-letter queue конкретного broker. Это логическая запись или маршрут, который должен хранить ссылку на исходное сообщение, ключ эффекта, причину, число попыток и обязательную проверку перед replay.

\n
Диагностический маршрут poison message: validation, ограниченный retry, проверка дубликата и manual review
Автоматический маршрут заканчивается там, где правило обработки больше не доказано.
\n

Terminal record сохраняет контекст

\n

Записывайте terminal record до удаления доставки и до ручного replay. Минимальный набор полей связывает решение с исходным входом: messageId, effectKey, terminalReason, attempts, время наблюдения и requiredCheck. Ссылку на payload храните только в пределах политики данных. Секреты и полные персональные данные не должны попадать в свободный текст ошибки.

\n
const manualRecord = {\n  messageId: message.messageId,\n  effectKey: message.effectKey,\n  terminalReason: 'schema-not-supported',\n  attempts: 2,\n  requiredCheck: 'confirm-schema-or-cancel-effect',\n  state: 'manual-review',\n};
\n

Ручной маршрут должен иметь три разных результата. Отмена фиксирует, что эффект не нужен. Исправление входа создаёт новый контролируемый запуск по правилам домена. Replay разрешён только после проверки, что эффект не был создан, или после явного решения, как избежать второго эффекта. Кнопка «попробовать ещё раз» без этих условий лишь переносит poison message обратно в цикл.

\n

Порядок действий

\n
  1. Зафиксировать messageId, effectKey, sequenceKey, номер попытки, причину и время до удаления сообщения или нового replay.
  2. Проверить ledger или другой разрешённый источник эффекта. Если ключ уже обработан, не выполнять доменное действие второй раз.
  3. Сопоставить причину с явной классификацией: temporary, terminal или unknown. Не считать unknown временным без доказательства.
  4. Для temporary применить только записанные лимит и backoff. После лимита остановить автоматический цикл.
  5. Для terminal и unknown создать manual record с причиной, контекстом и обязательной проверкой.
  6. Выбрать одно ручное решение: отменить эффект, исправить вход или подготовить controlled replay с проверкой ключа и порядка.
  7. Добавить тест или наблюдение, которое отличает эту ветвь от уже известных случаев. Не подменять проверку новым сообщением в логе.
\n

Ограничения и отрицательный путь

\n

Подтверждение после записи эффекта не делает всю систему exactly-once. Между хранилищем и внешним API всё ещё может быть сбой. Ledger может быть недоступен. Внешняя система может принять запрос и не вернуть ответ. Поэтому для каждого эффекта нужна собственная стратегия: идемпотентный ключ провайдера, reconciliation, статусная модель или ручная сверка. Queue policy не выбирает её автоматически.

\n

Порядок тоже имеет цену. Если сообщения одной сущности должны выполняться последовательно, параллельные consumer-ы могут ускорить независимые задачи, но не должны незаметно обгонять друг друга. Если broker requeue-ит сообщение без ограничения, один poison может блокировать полезную работу или создавать шум. Если dead-letter route не настроен, reject может удалить сообщение. Проверяйте фактический контракт выбранного broker, а не переносите поведение из учебной модели.

\n

Эта статья не описывает production-инцидент и не утверждает, что приведённая policy достаточна для платежей, уведомлений или биллинга. Учебный код показывает только три инварианта: retry конечен, duplicate не создаёт второй эффект, а непонятный вход получает сохранённый ручной маршрут. Реальную готовность надо доказывать интеграционным тестом, проверкой прав и наблюдением за фактической очередью.

\n

Проверяемый критерий готовности

\n

Решение готово к ограниченному внедрению, когда для одной тестовой доставки можно показать полный след: исходный идентификатор, причину, номер попытки, применённую задержку, проверку effectKey и итоговое состояние. На временном сбое появляется не более заданного числа повторов. На неизвестной схеме создаётся manual record. На повторной доставке после успешного эффекта новый эффект не создаётся. При каждом исходе оператор видит, кто и почему может выполнить следующий шаг.

\n

Если хотя бы один из этих фактов нельзя получить из лога, хранилища или теста, автоматический маршрут ещё не доказан. Остановите расширение retry, восстановите контекст и сначала уточните границу ответственности.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/245.json b/editorial/agent-rewrites/245.json new file mode 100644 index 0000000..9ade09c --- /dev/null +++ b/editorial/agent-rewrites/245.json @@ -0,0 +1,7 @@ +{ + "index": 245, + "slug": "editorial-2021-03-mechanism-queues", + "title": "Очереди задач: почему повторная доставка не должна повторять эффект", + "excerpt": "Сообщение может прийти повторно после уже выполненной операции. Разбираем границу между delivery и доменным эффектом, идемпотентный ключ, порядок retry и безопасный terminal route.", + "contentHtml": "

Симптом заметен не в очереди, а в результате: клиент получил два письма, счёт создался дважды или один заказ перешёл в неверный статус. В логах при этом видны два запуска одного обработчика. Команда часто обвиняет broker и пытается отключить повторную доставку. Это опасное решение. Вместе с повтором можно потерять задачу, если первый consumer успел выполнить часть работы, но не успел подтвердить delivery. Цена ошибки зависит от эффекта: лишнее уведомление можно отменить, второе списание — уже нет.

\n

Тезис статьи простой: подтверждение относится к текущей доставке сообщения, а идемпотентность относится к доменному эффекту. Эти границы нужно проектировать отдельно. Consumer должен переживать повтор одного намерения, хранить ключ эффекта и принимать решение о retry после проверки причины. Выбранная очередь помогает доставить работу, но не делает внешнюю операцию атомарной и не обещает exactly-once для всей системы.

\n

Механизм: три состояния вместо одного флага

\n

У одной задачи есть как минимум три разных идентификатора и состояния. messageId обозначает конкретное сообщение в транспорте. effectKey обозначает доменный эффект, например отправку напоминания по счёту. sequenceKey обозначает сущность, для которой важен порядок: счёт, заказ или профиль.

\n

Broker отвечает за доставку сообщения и его подтверждение. Consumer отвечает за выполнение операции. Доменное хранилище отвечает за факт эффекта. Если процесс упал после записи в хранилище, но до ack, broker имеет право доставить сообщение ещё раз. Если код связывает повтор с новым эффектом, он превращает штатное восстановление в дубль.

\n
delivery: messageId = msg-81, attempt = 2\neffect: effectKey = invoice-417:reminder, state = recorded\norder: sequenceKey = invoice-417, next = 8\nack: acknowledge current delivery after effect decision
\n

Такая модель не говорит, что повтор всегда произойдёт. Она говорит, что код не должен ломаться, если подтверждение потерялось, соединение закрылось или consumer завершился в неудобный момент. При ручном подтверждении неподтверждённая доставка обычно возвращается в работу после закрытия канала или соединения. Поэтому запись эффекта должна предшествовать ack, а проверка повторного эффекта — предшествовать новой записи.

\n

Где появляется duplicate

\n

Рассмотрим учебный пример без подключения к broker. Сообщение просит отправить одно напоминание по счёту. Первая попытка вызывает временную ошибку и уходит на retry. Вторая попытка записывает эффект в ledger. Сразу после записи процесс теряет соединение. Consumer не знает, дошёл ли ack. Broker считает доставку неподтверждённой и запускает её снова.

\n

На третьем запуске новый код сначала ищет effectKey. Ledger возвращает существующую запись. Consumer не отправляет новое напоминание и завершает текущую доставку как обработанную. В логах остаётся факт duplicate, но в домене появляется одна запись. Это ожидаемый результат recovery, а не доказательство exactly-once.

\n
async function handle(message, ledger) {\n  const key = message.effectKey;\n  const existing = await ledger.find(key);\n\n  if (existing) {\n    await ledger.recordDelivery(message.messageId, 'duplicate-effect-suppressed');\n    return { ack: true, effect: 'not-repeated' };\n  }\n\n  await ledger.recordEffect({\n    effectKey: key,\n    messageId: message.messageId,\n    type: message.type,\n  });\n\n  return { ack: true, effect: 'recorded' };\n}
\n

Код учебный. Он показывает порядок решений, но не заменяет транзакцию, уникальный индекс или API внешнего сервиса. В рабочей системе find и recordEffect должны защищать одну границу состояния. Иначе два параллельных consumer могут одновременно не найти ключ и оба создать эффект. Для базы это обычно означает уникальное ограничение по effectKey и обработку конфликта как duplicate. Для внешнего HTTP-вызова нужен поддержанный внешней системой idempotency key или ручной контроль. Локальный Map не может отменить уже отправленное письмо.

\n
\"Схема
Подтверждение завершает текущую доставку. Ledger защищает доменный эффект от повторной записи.
\n

Симптом → причина → проверка → действие

\n
Диагностика повторов и остановившихся задач
СимптомПричинаПроверкаДействие
Один эффект записан дваждыНет уникального effectKey или проверка не атомарнаСравнить ключи и найти две записи в одном интервалеДобавить уникальное ограничение и трактовать конфликт как duplicate
Задача пропала после падения workerAck отправили до записи эффекта или включили auto-ackСопоставить время ack, запись эффекта и завершение процессаПодтверждать после успешной границы обработки; для неизвестного исхода включить recovery
Очередь быстро растётRetry повторяет постоянную ошибку или consumer не успеваетРазделить transient и permanent причины, посмотреть attempts и latencyЗадать лимит попыток, backoff и terminal route
Статус вернулся назадПараллельные сообщения нарушили порядок для одной сущностиСгруппировать события по sequenceKey и сравнить номераПроверять следующий номер; gap отправлять на разбор, а не угадывать
Оператор повторил опасную задачу вслепуюTerminal запись не содержит причины и ключа эффектаПроверить содержимое ручного маршрутаСохранять messageId, effectKey, attempts, reason и requiredCheck
\n

Retry не исправляет постоянную ошибку

\n

Retry подходит для ограниченного класса отказов: временно недоступна зависимость, закончился connection pool или сработал rate limit. Он не исправляет неправильный формат сообщения, отсутствующий обязательный атрибут или нарушение бизнес-правила. Такой вход будет падать снова. Без лимита consumer создаст requeue loop, нагрузит broker и отложит диагностику.

\n

Политика должна различать причину, число попыток и следующий исход. Backoff снижает плотность повторов, но не сообщает, когда ошибка стала постоянной. После лимита попыток сообщение нужно перевести в явный terminal route: dead-letter queue, quarantine или ручной разбор. Название зависит от продукта. Контракт должен оставаться одинаковым: задача перестала исполняться автоматически, а причина и контекст сохранены.

\n
const policy = {\n  transient: { delaysMs: [1000, 4000, 16000], terminal: 'manual-review' },\n  permanent: { delaysMs: [], terminal: 'quarantine' },\n};\n\nfunction nextAction(error, attempt) {\n  const rule = error.kind === 'transient' ? policy.transient : policy.permanent;\n  if (attempt < rule.delaysMs.length) return { type: 'retry', delayMs: rule.delaysMs[attempt] };\n  return { type: 'terminal', route: rule.terminal };\n}
\n

Значения в примере учебные. Их нельзя переносить в production без проверки SLA зависимости, лимитов broker и допустимого времени ожидания. Отдельно измеряйте число повторов, возраст самой старой задачи и долю terminal исходов. Среднее время обработки может выглядеть нормальным, пока небольшой поток poison messages держит ресурсы и скрывает реальную причину.

\n

Порядок для одной сущности

\n

Очередь не обязана сохранять общий порядок всех задач. Обычно нужен порядок только внутри одного ключа. Для invoice-417 событие с номером 8 можно применить после номера 7. Событие с номером 10 нельзя молча применить раньше 9, если доменная модель не допускает пропуск. Для разных счетов искусственная последовательность только уменьшит параллелизм.

\n

Порядок должен иметь владельца и проверяемое правило. Partition, routing key или один worker могут помочь доставке, но не заменяют проверку состояния. После перезапуска consumer должен снова понять, какой номер уже принят. Если предыдущего события нет, выберите один из исходов: подождать ограниченное время, запросить восстановление или отправить gap в manual route. Бесконечный retry здесь маскирует потерю данных.

\n

Порядок внедрения

\n
  1. Опишите доменный эффект одним предложением и выберите его владельца: запись, уведомление, переход статуса или внешний вызов.
  2. Сформируйте messageId, effectKey и при необходимости sequenceKey. Зафиксируйте, какие сообщения законно создают разные эффекты.
  3. Определите границу записи эффекта. Для базы используйте подходящую транзакцию и уникальное ограничение; для внешнего API проверьте поддержку идемпотентного ключа.
  4. Поставьте проверку существующего эффекта перед новой записью. Конфликт уникальности обработайте как duplicate, а не как бесконечный retry.
  5. Подтверждайте delivery только после принятого решения по эффекту. Не смешивайте ack с обещанием отката внешней операции.
  6. Разделите временные и постоянные ошибки. Добавьте backoff, лимит попыток и terminal route с причиной и контекстом.
  7. Определите правило порядка для каждой сущности, где оно нужно. Для gap задайте ограниченный и наблюдаемый исход.
  8. Проверьте recovery-сценарий: эффект записан, ack неизвестен, сообщение пришло снова. Убедитесь, что повтор не создаёт второй эффект.
\n

Ограничения

\n

Эта схема не делает распределённую систему атомарной. Если запись в локальной базе и вызов внешнего API идут в разных системах, между ними остаётся окно неопределённости. В нём возможны внешний успех без локальной записи, локальная запись без внешнего успеха и повтор после сетевого таймаута. Решение выбирают по доменному риску: outbox, API с идемпотентным ключом, сверка состояния или ручная операция. Ни один вариант не следует объявлять универсальным без проверки конкретных границ.

\n

Порядок тоже ограничен областью ключа. Один partition или один consumer не создаёт общий порядок между независимыми сущностями. Флаг redelivered не доказывает, что сообщение ранее полностью обработали, и отсутствие такого флага не доказывает обратное. Наблюдайте историю delivery, но принимайте решение по состоянию эффекта.

\n

Проверяемый критерий готовности

\n

Считайте контракт готовым, когда контролируемый тест проходит один и тот же сценарий: consumer записывает эффект, теряет знание об ack, получает повторное сообщение и оставляет ровно один доменный эффект; лог и ledger связывают оба запуска с одним effectKey; постоянная ошибка после лимита попадает в terminal route с причиной; gap не меняет состояние раньше времени. Тест должен выполняться на выбранном broker, хранилище и клиенте проекта. Учебный пример выше проверяет только порядок решений и не заменяет эту интеграционную проверку.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/246.json b/editorial/agent-rewrites/246.json new file mode 100644 index 0000000..b3a046a --- /dev/null +++ b/editorial/agent-rewrites/246.json @@ -0,0 +1,7 @@ +{ + "index": 246, + "slug": "editorial-2021-03-practice-queues", + "title": "Очередь задач без иллюзий: повтор, порядок и право на эффект", + "excerpt": "Двойная отправка и застрявшие сообщения начинаются не с выбора брокера, а с неявного контракта. Разбираем идентификаторы, локальный порядок, ограниченный retry, защиту эффекта и ручной маршрут.", + "contentHtml": "

Письмо ушло дважды. Изменение статуса пришло раньше предыдущего. Сообщение с новой схемой возвращается к одному и тому же worker-у. Эти симптомы появляются после первого успешного запуска очереди, когда команда уже умеет принимать работу, но ещё не договорилась, что считать выполнением.

\n

Цена ошибки — повторный платёж, второй файл, неверный статус или потерянная задача. Ещё дороже ручной разбор без ответа на три вопроса: какой эффект уже создан, кому принадлежит порядок и почему сообщение нельзя обработать автоматически.

\n

Тезис: очередь переносит работу, но не задаёт её контракт

\n

Очередь отделяет того, кто создаёт задачу, от того, кто выполняет действие. Producer записывает намерение. Consumer читает его позже и создаёт побочный эффект: отправляет письмо, меняет запись, вызывает внешний API или формирует файл. Между этими моментами может оборваться процесс. Consumer может успеть создать эффект, но не успеть подтвердить доставку. Брокер тогда вправе выдать ту же задачу снова.

\n

Поэтому сообщение нужно связывать не только с payload. В конверте должны быть наблюдаемый id, ключ конкретного эффекта effectKey, ключ объекта, внутри которого важен порядок, и версия схемы. Эти поля отвечают на разные вопросы. Один случайный UUID не заменяет все четыре правила.

\n
Минимальный контракт задачи
Поле или правилоВопросПроверкаДействие при нарушении
idКак найти историю этой задачи?Значение непустое и не меняется при повторной доставкеОстановить обработку и сохранить вход
effectKeyКакой эффект нельзя создать второй раз?Поискать ключ в ledger до внешнего действияПодавить duplicate или передать на разбор
sequenceKey и номерВ каком порядке применяются изменения?Сравнить с последним принятым номером этого объектаОтложить gap или остановить конфликт
retry policyКакая ошибка действительно временная?Сопоставить причину с известным классом и лимитомСделать ограниченный retry или завершить автоматический путь
manual routeКуда попадёт непонятный вход?Есть причина, попытки и следующий обязательный checkСохранить запись, не удалять сообщение молча
\n

id нужен для расследования. effectKey защищает результат. sequenceKey ограничивает область порядка. Retry управляет временем, но не исправляет неправильные данные. Если смешать эти роли, логика начинает принимать решение по полю, которое для него не предназначено.

\n

Механизм: сначала признать сообщение, потом создавать эффект

\n

Возьмём учебную задачу о напоминании по счёту. Значения ниже вымышлены. Функции работают только с объектами в памяти Node.js. Они не подключаются к RabbitMQ, Kafka, базе или внешнему API. Их задача — сделать границы решения видимыми.

\n
const message = {\n  id: 'msg-order-417',\n  kind: 'invoice.reminder',\n  effectKey: 'invoice-417:reminder',\n  sequenceKey: 'invoice-417',\n  sequence: 4,\n  payload: { invoiceId: '417', schema: '2021-03' }\n};
\n

Consumer сначала проверяет форму сообщения и версию payload. Затем он проверяет порядок, если домен его требует. После этого он ищет effectKey в ledger. Только если ключ отсутствует, обработчик получает право создать эффект и записать подтверждение. В рабочей системе запись ledger и изменение локального состояния должны иметь согласованную транзакционную границу. Внешний вызов всё равно требует отдельного правила неопределённого ответа.

\n
function decide(message, state) {\n  if (!message?.id || !message.effectKey) {\n    return { status: 'manual-review', reason: 'missing-identity' };\n  }\n\n  if (message.payload?.schema !== '2021-03') {\n    return { status: 'manual-review', reason: 'unsupported-schema' };\n  }\n\n  const last = state.lastSequence[message.sequenceKey] ?? 0;\n  if (message.sequence <= last) {\n    return state.effects.has(message.effectKey)\n      ? { status: 'duplicate-suppressed' }\n      : { status: 'stale-or-conflicting' };\n  }\n\n  if (message.sequence > last + 1) {\n    return { status: 'manual-review', reason: 'sequence-gap' };\n  }\n\n  if (state.effects.has(message.effectKey)) {\n    return { status: 'duplicate-suppressed' };\n  }\n\n  return { status: 'apply-and-record' };\n}
\n

Функция не говорит, что делать с внешним сервисом. Она только разделяет исходы. Повтор с уже записанным эффектом не запускает второе действие. Пропуск номера не разрешает обработать более новое изменение поверх неизвестного состояния. Старая запись не возвращает объект назад. Неизвестная схема не становится временной ошибкой из-за удобства.

\n

Порядок принадлежит объекту, а не всей очереди

\n

Две независимые задачи можно выполнять параллельно. Но изменения одного счёта или заказа часто имеют порядок. Задача с номером 12 может зависеть от результата 11. Это не означает, что нужно остановить всю очередь. Правило действует внутри sequenceKey. Worker может обрабатывать другие ключи, пока сообщение с gap ждёт пропущенную запись или ручного решения.

\n

Не называйте порядок гарантированным только потому, что брокер хранит сообщения последовательно. При нескольких consumer-ах доставка и завершение обработки могут пересечься. Даже один consumer может потерять подтверждение после побочного эффекта. Отдельно проверяйте порядок доставки, порядок применения и порядок записи результата. Это три разных свойства.

\n
\"Жизненный
Retry — один из ограниченных исходов обработки. Он не должен быть бесконечным маршрутом по умолчанию.
\n

Retry нужен для временной причины

\n

Временная причина имеет наблюдаемое условие и предел ожидания. Например, учебная зависимость не ответила на первом вызове, а контракт допускает одну повторную попытку через 1000 мс. Это не доказательство восстановления сервиса и не универсальная настройка. Это только ограниченная ветка модели.

\n

Неподдерживаемая схема, пропущенная последовательность и отсутствующий ключ не становятся временными от повторения. Если классификация не уверена, сохраните сообщение для проверки. Бесконечный requeue создаёт шум, удерживает worker и скрывает полезные задачи.

\n
function nextAttempt(attempt, reason) {\n  const temporary = reason === 'dependency-not-ready';\n  if (!temporary) return { status: 'manual-review', reason };\n  if (attempt >= 2) return { status: 'manual-review', reason: 'retry-limit' };\n  return { status: 'retry', delayMs: 1000 };\n}
\n

Лимит выбирают по времени ожидания зависимости, допустимой нагрузке и цене ручного разбора. Число из примера нельзя переносить в рабочую систему без этих проверок. После исчерпания лимита автоматический маршрут заканчивается. Новое решение должно появиться явно: исправить вход, отменить эффект или подготовить контролируемый replay.

\n

Симптом → причина → проверка → действие

\n
Диагностика одной задачи до нового запуска
СимптомПричинаПроверкаДействие
Эффект появился дваждыПодтверждение потерялось после действияСверить effectKey, ledger и время эффектаПодавить duplicate; отдельно расследовать неопределённый первый ответ
Номер 12 пришёл до 11Gap или параллельное завершениеНайти 11, deferred-запись и владельца порядкаНе применять 12; сохранить контекст до восстановления порядка
Одна ошибка повторяетсяTerminal input ошибочно признан временнымПроверить класс причины и счётчик попытокОстановить цикл; создать manual record
Сообщение исчезлоAck отправлен до записи результатаСопоставить момент ack с записью эффектаИсправить порядок подтверждения; восстановить задачу из журнала
Старая задача меняет новое состояниеНет проверки версии или sequenceСверить последний номер объекта и id сообщенияОтклонить stale input; не перезаписывать состояние назад
\n

Отрицательный путь: эффект создан, подтверждения нет

\n

Самый неприятный случай не выглядит как обычная ошибка. Consumer вызвал внешний API. API мог принять запрос, но ответ пропал по сети. Consumer не знает результата и не должен автоматически считать его неуспешным. Повтор без ключа создаст второй эффект. Ack без проверки может потерять задачу. У этой ситуации должен быть отдельный статус, например effect-result-unknown, и способ проверить внешний факт.

\n

Ledger помогает только там, где он действительно связан с эффектом. Локальная запись «мы собирались отправить письмо» не доказывает, что письмо принял внешний провайдер. Для денег, доступа и других дорогих действий нужен provider id, ответ владельца эффекта или ручное решение с журналом. Не подменяйте отсутствие ответа успехом.

\n

Порядок действий

\n
  1. Зафиксировать id, effectKey, sequenceKey, номер, payload version, попытку и время.
  2. Остановить рискованный следующий эффект, если состояние задачи или результат внешнего вызова неизвестны.
  3. Проверить схему и обязательные поля. Непонятный вход не отправлять в бесконечный retry.
  4. Сверить последний номер конкретного объекта. При gap сохранить сообщение и найти пропущенный переход.
  5. Проверить ledger до повторного эффекта. Duplicate завершить без нового действия.
  6. Классифицировать ошибку. Для известной временной причины применить записанный лимит и задержку.
  7. Для terminal, unknown или исчерпанного retry создать manual record с причиной, попытками и обязательной проверкой.
  8. После исправления подготовить новый контролируемый запуск. Сохранить ключ эффекта и проверить, что порядок не нарушен.
\n

Ограничения и критерий готовности

\n

Идемпотентный ключ не делает два независимых сервиса одной транзакцией. Ack не гарантирует, что внешний эффект завершён. Версия не гарантирует доставку следующего события. Dead-letter маршрут не заменяет владельца решения. Гарантия «exactly once» от транспорта не означает, что прикладной эффект невозможно повторить. Эти свойства нужно проверять на границах конкретной системы.

\n

Пример выше учебный. Он не запускает брокер, базу, сеть или внешний сервис и не сообщает измерений нагрузки. Его можно использовать только для проверки формы контракта: duplicate не создаёт второй эффект, gap не применяется молча, stale input не откатывает состояние, а непонятное сообщение получает конечный маршрут.

\n

Контракт готов к реализации, когда для одной задачи можно показать цепочку «сообщение → решение consumer-а → запись эффекта → подтверждение» и ответить, что происходит при обрыве после каждого шага. Повторная доставка с тем же effectKey не создаёт новый результат. Gap сохраняется и не меняет состояние. Ошибка вне временного класса прекращает retry. По этим четырём проверкам решение можно обсуждать с владельцем домена и выбирать конкретный broker.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/247.json b/editorial/agent-rewrites/247.json new file mode 100644 index 0000000..b707890 --- /dev/null +++ b/editorial/agent-rewrites/247.json @@ -0,0 +1,7 @@ +{ + "index": 247, + "slug": "editorial-2021-02-field-cache-invalidation", + "title": "Инвалидация кеша: как доказать, что читатель получил старую проекцию", + "excerpt": "Пользователь видит старое значение после записи в source. Разбираем key, version, delayed event и visibility, чтобы выбрать точечное действие вместо очистки всего кеша.", + "contentHtml": "

Пользователь меняет имя, получает успешный ответ, а соседняя страница ещё час показывает старое значение. Команда очищает весь кеш. Симптом исчезает, но причина остаётся неизвестной: событие задержалось, запрос попал в другой key, проекция не обновилась или браузер показывает старый ответ. Цена ошибки — не только одна жалоба. Полная очистка создаёт лишнюю нагрузку, стирает след диагностики и может скрыть проблему до следующего изменения.

\n

Кеш нельзя считать текущим только потому, что запись в него существует. Текущесть должна следовать из контракта: какой source владеет данными, какой key обозначает проекцию, какая version попала в entry и имеет ли читатель право получить эту проекцию. Если этих фактов нет, команда спорит о TTL и purge, не проверяя состояние.

\n

Тезис: инвалидируйте состояние, а не симптом

\n

Для одного read path достаточно связать source id, монотонную version, read key и cache entry. При чтении сравните version source с version entry. Если entry старше, пересоберите проекцию или удалите её. Если source стал недоступен публичному читателю, сначала запретите ответ и удалите entry. Позднее событие не должно удалять уже актуальную entry.

\n

Эта схема относится к кешу прикладной проекции. Она не делает базу, брокер и HTTP-кеш одной системой. Один и тот же объект может иметь разные проекции для языка, tenant или роли. В таком случае каждый контекст входит в контракт key и проверяется отдельно.

\n

Механизм старого чтения

\n

Пусть source хранит запись guide-42. После первой записи приложение строит публичную проекцию версии 1 и сохраняет её под ключом article:public:guide-42. Затем владелец записывает версию 2. Cache entry всё ещё содержит версию 1. Событие ArticleChanged(2) может задержаться, но read уже видит рассогласование и не должен вернуть v1 как обычный hit.

\n

Значение version должен выдавать владелец source. Не назначайте его в consumer-е и не используйте timestamp, если несколько записей могут получить одинаковое время. При успешной записи source version увеличивается. Событие несёт id и ту же version. Consumer удаляет entry только когда её версия меньше версии события.

\n
function applyInvalidation(cache, key, eventVersion) {\n  const entry = cache.get(key);\n  if (!entry) return 'nothing-to-remove';\n  if (entry.sourceVersion < eventVersion) {\n    cache.delete(key);\n    return 'removed-stale-entry';\n  }\n  return 'kept-current-entry';\n}\n\nfunction readPublic(source, cache, id) {\n  const current = source.get(id);\n  const key = `article:public:${id}`;\n  const entry = cache.get(key);\n\n  if (current.visibility !== 'public') {\n    cache.delete(key);\n    return { status: 'not-visible' };\n  }\n  if (!entry || entry.sourceVersion < current.version) {\n    const projection = {\n      id: current.id,\n      title: current.title,\n      summary: current.summary,\n      sourceVersion: current.version,\n    };\n    cache.set(key, projection);\n    return { status: 'rebuilt', projection };\n  }\n  return { status: 'hit-current', projection: entry };\n}
\n

Код показывает только порядок решений. Map не заменяет Redis или транзакционное хранилище. В реальном read source может быть дорогим или отставать на реплике. Тогда нужно отдельно описать, откуда приходит version, какой stale window допустим и какую гарантию получает читатель. Нельзя переносить этот фрагмент в production без проверки атомарности записи, прав и конкурирующих обновлений.

\n

Соберите доказательства до purge

\n

Начните с одной жалобы и одной попытки чтения. Запишите source id, current version, вычисленный key, version entry, состояние события и visibility. Значения можно обезличить. Важно сохранить связь между ними. Если видны только два заголовка, старый и новый, нельзя отличить устаревшую entry от чтения другой проекции.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
source v2, entry v1, key совпадаетСобытие задержалось или read не проверяет versionСравнить версии до и после controlled readДобавить version guard или точечную инвалидацию
source v2, но key отличаетсяВ key пропущен язык, tenant или reader scopeВывести keys для двух проекций одного idИсправить builder и проверить отсутствие collision
source private v3, entry public v2Visibility проверяется после cache hitИзменить только visibility и повторить public readЗапретить ответ и удалить entry до delivery события
source v2, entry v2, текст всё ещё старыйОшибка renderer, клиента или другого cache layerСверить projection fields и цепочку ответаИскать следующий слой, не очищать этот key вслепую
Позднее событие удаляет v2Consumer удаляет entry при любом событииПроверить условие entry.sourceVersion < event.versionОставить entry для равной version
\n
\"Схема
Один старый текст не доказывает одну причину. Сначала соберите состояние, затем выберите ветку действия.
\n

Задержанное событие и отрицательный путь

\n

Событие обновления помогает быстро удалить entry, но read не должен зависеть от идеальной доставки. В последовательности ниже событие v2 приходит после read. До события cache содержит v1. Read сравнивает версии, строит v2 и сохраняет её. Когда consumer получает v2, он видит равные версии и оставляет entry. Это защищает от лишнего miss и от повторной пересборки.

\n
build v1               cache: v1\nwrite source v2        event: pending, cache: v1\nread before delivery   result: rebuilt v2\ndeliver ArticleChanged2 result: current entry kept\nread after delivery    result: hit v2\nwrite visibility private result: deny + evict
\n

Отрицательный путь важнее счастливого hit. Если source стал private, старая public entry нельзя отдавать до прихода события. Иначе задержка доставки превращается в утечку уже запрещённого представления. TTL не решает эту задачу: пять минут freshness не дают права показывать данные после изменения visibility.

\n

Версия также не защищает состав проекции. Не копируйте весь source object в public cache через spread. Явно перечислите поля, которые разрешены читателю. В примере это id, title и summary. Поле editorNote не должно попасть в entry даже при правильной version.

\n

HTTP-кеш — соседний слой

\n

HTTP validator и прикладная version решают разные задачи. ETag описывает выбранное HTTP-представление. If-None-Match позволяет запросу проверить его и получить 304. Source version описывает порядок изменения записи у владельца. Эти значения можно связать, но нельзя считать взаимозаменяемыми. Разные язык, роль или формат ответа дадут разные представления.

\n

Успешный unsafe HTTP-запрос инвалидирует target URI в том cache, который его обработал, но это не очищает автоматически браузерный, reverse-proxy и прикладной кеши. Для каждого слоя назовите key, validator или purge contract. Затем проверьте конкретный ответ. Статус 200 от origin сам по себе не доказывает свежесть всех downstream-слоёв.

\n

Порядок действий

\n
  1. Зафиксируйте симптом: какой reader получил какую старую проекцию и какова цена ошибки.
  2. Сохраните source id, current version, read key, cache version, event state и visibility до очистки.
  3. Проверьте, что key принадлежит нужному reader scope и включает каждый параметр, меняющий результат.
  4. Сравните source version и cache version на контролируемом чтении. Не называйте cache hit корректным, пока версии не сопоставлены.
  5. Для старой entry выполните rebuild или targeted invalidation. Не удаляйте весь namespace без причины.
  6. Для изменения visibility проверьте deny и eviction до доставки события.
  7. Проверьте delayed event: равная version не должна удалять актуальную entry; более новая version должна удалить старую.
  8. Если версии совпадают, перенесите диагностику на renderer, клиент, HTTP или другой cache layer.
\n

Ограничения и критерий готовности

\n

Пример использует один объект и память процесса. Он не проверяет Redis eviction, broker retries, outbox, репликацию, CDN, браузерный cache, multi-region и транзакцию между source и событием. Он также не даёт production latency, hit-rate или гарантии отсутствия stale-read. Эти свойства требуют отдельного теста на выбранном storage и реального маршрута.

\n

Решение готово к проверке на интеграционном маршруте, когда для одной тестовой записи видны все шесть фактов: source id, source version, key, cache version, event state и reader visibility. После записи v2 read не возвращает v1 как current hit. Позднее событие v2 сохраняет уже построенную v2. После переключения в private public read не возвращает значение и удаляет public entry. Если хотя бы одно условие нельзя доказать логом или тестом, сначала добавьте наблюдаемость, а не увеличивайте TTL и не включайте глобальный purge.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/248.json b/editorial/agent-rewrites/248.json new file mode 100644 index 0000000..7534ad9 --- /dev/null +++ b/editorial/agent-rewrites/248.json @@ -0,0 +1,7 @@ +{ + "index": 248, + "slug": "editorial-2021-02-mechanism-cache-invalidation", + "title": "Инвалидация кеша: как не вернуть известную устаревшую версию", + "excerpt": "Кеш может быть быстрым и при этом выдавать старую запись после успешного обновления. Разбираем versioned invalidation, границы ключа, запаздывающее событие и проверку, которая не считает stale-значение cache hit.", + "contentHtml": "

Пользователь меняет имя профиля, запрос на запись отвечает успешно, а следующая страница всё ещё показывает прежнее имя. Через несколько секунд всё исправляется само. За это время поддержка получает жалобу, оператор видит разные данные в соседних экранах, а команда спорит о том, был ли сбой в базе или в CDN.

Цена ошибки зависит от данных. Для профиля это недоверие к интерфейсу. Для цены или лимита — неверное решение. Для прав доступа — потенциальная утечка. Самый опасный случай начинается после успешной записи: источник уже знает новую версию, но кеш считает старую запись свежей.

Тезис простой: TTL отвечает на вопрос «как долго entry может жить», но не на вопрос «какую версию источник считает текущей». Надёжная инвалидация связывает владельца данных, ключ проекции, версию, событие изменения и правило read. Cache hit разрешён только тогда, когда версия и область выдачи совпадают с источником.

Сначала разделите источник и проекцию

Источник владеет фактом. Он записывает профиль и увеличивает его версию. Кеш хранит производную публичную проекцию. Он не становится владельцем профиля только потому, что умеет быстро вернуть JSON.

source: { id: 42, version: 2, visibility: &quot;public&quot;, name: &quot;Ирина&quot; } cache: { key: &quot;profile:public:42&quot;, sourceVersion: 1, name: &quot;Ира&quot; } event: { type: &quot;ProfileChanged&quot;, id: 42, version: 2 } current hit =&gt; cache.sourceVersion === source.version &amp;&amp; source.visibility === &quot;public&quot;

В примере источник уже содержит v2, а entry построена из v1. Наличие ключа и неистёкший TTL ничего не меняют. Если read вернёт «Ира», он выдаст известную старую проекцию. Правильное действие — перестроить entry из v2 или отказаться от ответа, если новая запись недоступна.

Почему одного события недостаточно

После записи команда отправляет событие и ждёт, что consumer удалит ключ. Это полезный быстрый путь, но не гарантия момента очистки. Брокер может задержать доставку. Consumer может повторить сообщение. Два слоя кеша могут получить его в разное время. Событие может прийти после того, как read уже построил новую entry.

Поэтому событие не должно быть единственным барьером. Его задача — ускорить очистку. Read должен закрыть окно между записью v2 и применением события. Для одного владельца и одного ключа consumer применяет событие только к более старой entry:

function invalidate(entry, event) { if (!entry) return &quot;miss&quot;; if (entry.sourceVersion &lt; event.version) { cache.delete(entry.key); return &quot;evicted-stale&quot;; } return &quot;kept-current&quot;; }

Сравнение защищает от запоздалого сообщения. Если read уже собрал v2, позднее событие v2 не должно удалять current entry. Если пришло событие v3, entry v2 устарела и её можно удалить. Это правило предполагает, что один source owner выдаёт монотонные версии для конкретного объекта. При нескольких владельцах нужна другая схема: например, составная версия или явная модель конфликтов.

Ключ описывает границу ответа

Ключ должен включать каждое условие, которое меняет выдаваемую проекцию или право её увидеть. Для публичного профиля это может быть profile:public:42. Если ответ зависит от языка, региона, роли или набора полей, эти границы должны быть отражены в ключе либо проверены до cache hit.

Нельзя просто добавить в ключ все доступные параметры. Лишнее поле дробит кеш и ухудшает диагностику. Отсутствующее значимое поле смешивает варианты, которые нельзя смешивать. Если запись стала private, старая public entry должна исчезнуть даже при неистёкшем TTL.

Диагностика устаревшей выдачи
СимптомПричинаПроверкаДействие
После записи видна старая версияRead доверяет TTL или наличию ключаСравнить entry.sourceVersion и версию source в одном запросеПерестроить entry при несовпадении
Кеш очищается с задержкойСобытие ждёт брокер или consumerСопоставить время write, publish, apply и следующего readОставить version guard на read, а событие использовать как ускоритель
Новая entry удаляется повторноConsumer удаляет ключ для любого eventОтправить event v2 после построения entry v2Удалять только при entry.version &lt; event.version
Private-данные видны из public endpointVisibility не проверяется перед hitСменить public на private при живой public entryСначала проверить право выдачи, затем evict и вернуть отказ
Разные пользователи получают один ответВ ключе нет tenant, роли или другого влияющего признакаПовторить запросы с двумя наборами прав и сравнить keyРасширить ключ или запретить кеширование такой проекции
Матрица состояний source и cache: current hit, stale rebuild, miss-built и отказ при private visibility
Матрица показывает решение read для пяти комбинаций версии источника, кеша, события и видимости. Это схема состояний, а не измерение задержки или hit-rate.

Read должен проверять версию до возврата

Упрощённый read-path выглядит так: получить source, проверить visibility, получить entry, сравнить версии, вернуть entry или построить новую. Порядок важен. Проверка доступа после cache hit уже слишком поздно, а проверка только TTL не знает о последней записи.

async function readPublicProfile(id) { const record = await source.get(id); const key = `profile:public:${id}`; if (!record || record.visibility !== &quot;public&quot;) { await cache.delete(key); return { status: &quot;not-visible&quot; }; } const entry = await cache.get(key); if (entry?.sourceVersion === record.version) return { status: &quot;hit-current&quot;, value: entry.value }; const value = { id: record.id, name: record.name }; await cache.set(key, { sourceVersion: record.version, value }); return { status: entry ? &quot;stale-rebuilt&quot; : &quot;miss-built&quot;, value }; }

Код демонстрационный. Он показывает контракт одного объекта и одной public-проекции, а не готовую библиотеку кеширования. В рабочей системе нужно определить, где хранится версия, как читается источник, что происходит при replica lag и может ли public path обращаться к source. Если такой read слишком дорог, нельзя молча убрать проверку и оставить прежнее обещание. Нужно явно принять допустимое окно stale и доказать его отдельным тестом.

TTL остаётся полезным, но решает другую задачу

TTL ограничивает срок жизни entry. Он помогает освобождать память, уменьшать риск вечного старого значения и задавать верхнюю границу для систем, где источник нельзя проверять на каждом чтении. Но TTL не знает, что запись изменилась через миллисекунду после построения entry.

Если бизнес допускает stale-ответ не дольше минуты, TTL может быть частью контракта. Если после успешной записи пользователь должен сразу увидеть новую цену, одного TTL недостаточно. Нужны version check, событие, явная очистка или другой протокол с такой гарантией. Название директивы не переносит гарантию из HTTP-кеша в кеш приложения.

Порядок действий

  1. Выберите один читательский сценарий и назовите цену stale-ответа: неверное имя, цена, лимит или право доступа.
  2. Назначьте source owner. Только он создаёт новую версию и определяет видимость записи.
  3. Опишите публичную проекцию whitelist-ом. Не копируйте в кеш весь объект источника.
  4. Соберите ключ из идентификатора и всех признаков, которые меняют ответ или право его увидеть.
  5. После успешной записи создайте событие с идентификатором и той же версией. Не выдавайте факт публикации за момент применения во всех слоях.
  6. На read проверьте существование и visibility источника до возврата entry.
  7. Сравните версии. При несовпадении перестройте проекцию или примените документированный безопасный отказ.
  8. В consumer удаляйте только entry с версией меньше версии события.
  9. Проверьте задержанное событие, повторную доставку и смену public на private.
  10. Зафиксируйте статусы hit-current, stale-rebuilt, miss-built, not-visible. Они отличают правильную перестройку от обычного cache miss.

Ограничения и отрицательный путь

Versioned invalidation не делает распределённую систему строго согласованной автоматически. Если source и cache читаются из реплик с разной задержкой, read может увидеть старую версию источника и принять старую entry за current. Если событие потеряло key или версию, consumer не сможет безопасно удалить нужную проекцию. Если одна запись питает несколько ключей, одного сравнения недостаточно: нужен список зависимостей или общий namespace.

Отрицательный путь должен быть безопасным. При недоступном source нельзя возвращать старую private-проекцию через public key. При неизвестной версии нельзя считать entry current. При неоднозначном ключе лучше сделать miss или отказать в выдаче, чем смешать варианты. При разрешённом stale нужно назвать срок и показать, где он измеряется.

Не переносите код выше в рабочую систему без проверки хранилища, конкурентных записей, прав доступа, сериализации и отказов сети. Пример ограничен одной записью, одним владельцем и одной проекцией. Его цель — сделать порядок решений проверяемым.

Проверяемый критерий готовности

Механизм готов для выбранного пути, если команда может назвать пять вещей: владельца source, точный cache key, источник версии, формат события и условие current hit. Автоматическая проверка должна воспроизвести четыре состояния: v1/v1 возвращает current, source v2 при cache v1 перестраивает ответ до delivery события, запоздалое событие v2 не удаляет entry v2, а private source удаляет public entry и не возвращает значение.

Отдельно проверьте журналы времени write, publish, apply и read. Не подменяйте это проверкой «в итоге стало правильно». Нужен результат каждого состояния и причина решения. Тогда инвалидация перестаёт быть надеждой на таймер и становится контрактом, который можно нарушить, обнаружить и исправить.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/249.json b/editorial/agent-rewrites/249.json new file mode 100644 index 0000000..6338d73 --- /dev/null +++ b/editorial/agent-rewrites/249.json @@ -0,0 +1,7 @@ +{ + "index": 249, + "slug": "editorial-2021-02-practice-cache-invalidation", + "title": "Инвалидация кеша: как не вернуть устаревшие данные", + "excerpt": "Источник уже хранит новую версию, а читатель получает старую карточку. Разбираем ключ, версию, событие изменения и проверку чтения на коротком контролируемом примере.", + "contentHtml": "

Пользователь меняет название статьи. В базе уже лежит новая строка, но соседняя страница ещё показывает старый заголовок. Иногда устаревший ответ живёт минуты, иногда — до истечения TTL. Цена ошибки зависит от данных: читатель может увидеть старый статус заказа, прежнюю цену или публичную карточку снятого с публикации материала.

\n

Первый порыв — увеличить частоту очистки или удалить случайный ключ после жалобы. Это убирает один симптом, но не объясняет, какой ответ устарел и почему следующий запрос снова получил его. Надёжнее считать cache entry актуальной только тогда, когда она относится к нужной проекции и собрана из текущей версии источника.

\n

Тезис: TTL не сообщает, что данные изменились

\n

TTL отвечает на один вопрос: сколько времени запись можно хранить. Он не отвечает на другой: была ли запись изменена после того, как её положили в кеш. Если источник сменился через секунду после записи, десятиминутный TTL оставит старую проекцию ещё на 599 секунд.

\n

Инвалидация должна связывать четыре факта: владельца исходной записи, ключ читательской проекции, версию записи и событие изменения. На чтении система проверяет, что эти факты согласованы. Событие ускоряет удаление старой entry, но не должно быть единственной защитой: оно может задержаться, потеряться или прийти после следующего изменения.

\n

Механизм на одной публичной карточке

\n

Возьмём запись guide-42. Ею владеет source service. Запись имеет поля title, visibility и монотонную version. Публичный читатель получает только id, title и sourceVersion. Внутреннее поле editorNote не должно попасть в кеш даже при успешном cache hit.

\n

Ключ описывает не объект вообще, а конкретный ответ. Поэтому article:public:guide-42 лучше, чем article:guide-42. В первом варианте видна область чтения. Если позже появятся язык, tenant или роль, каждый параметр нужно добавить только после проверки: меняет ли он содержание и право получить ответ.

\n
Контракт одной cache entry
ФактПримерПроверка
Источникguide-42, version 2Только владелец создаёт следующую версию
Ключarticle:public:guide-42В ключе указана публичная область
Проекцияid, title, sourceVersionВ ней нет editorNote
СобытиеArticleChanged, version 2Версия события сравнивается с entry
Условие hitentry version равна source versionИначе ответ пересобирается
\n

Запись источника и событие должны нести одну версию. Тогда consumer может отличить старое уведомление от нового. Timestamp не всегда подходит: разные часы, точность округления и повторная доставка усложняют сравнение. Версия выражает порядок изменений одного владельца.

\n

Конкретный пример

\n

Ниже — учебный фрагмент на обычном JavaScript. Он использует только Map и синтетические данные. Фрагмент показывает контракт и порядок проверок, но не является готовым адаптером для Redis, CDN, брокера или конкретного фреймворка.

\n
const source = new Map([\n  ['guide-42', {\n    id: 'guide-42',\n    title: 'Версия 1',\n    editorNote: 'internal',\n    visibility: 'public',\n    version: 1,\n  }],\n]);\nconst cache = new Map();\n\nfunction publicKey(id) {\n  return `article:public:${id}`;\n}\n\nfunction publicProjection(record) {\n  return {\n    id: record.id,\n    title: record.title,\n    sourceVersion: record.version,\n  };\n}\n\nfunction readPublic(id) {\n  const record = source.get(id);\n  const key = publicKey(id);\n  const entry = cache.get(key);\n\n  if (!record || record.visibility !== 'public') {\n    cache.delete(key);\n    return { status: 'not-visible' };\n  }\n\n  if (entry && entry.sourceVersion === record.version) {\n    return { status: 'hit-current', projection: entry.projection };\n  }\n\n  const projection = publicProjection(record);\n  cache.set(key, {\n    sourceVersion: record.version,\n    projection,\n  });\n  return { status: entry ? 'stale-rebuilt' : 'miss-built', projection };\n}
\n

Первое чтение создаёт entry версии 1. Затем источник получает заголовок «Версия 2» и увеличивает version. Если событие ещё не дошло, следующий readPublic всё равно видит рассогласование и пересобирает проекцию. Он не называет старую entry актуальным hit.

\n
source.set('guide-42', {\n  id: 'guide-42',\n  title: 'Версия 2',\n  editorNote: 'internal',\n  visibility: 'public',\n  version: 2,\n});\n\nreadPublic('guide-42').status;\n// 'stale-rebuilt'\n\nsource.get('guide-42').visibility = 'private';\nsource.get('guide-42').version = 3;\n\nreadPublic('guide-42').status;\n// 'not-visible'
\n

Изменение visibility требует особой ветки. Нельзя ждать только event consumer-а: до его запуска публичный ключ уже опасен. Read должен проверить право показа, удалить недопустимую entry и вернуть отказ. Кеш не заменяет авторизацию.

\n
\"Схема
Версия на read-path защищает от устаревшей entry в промежутке между записью источника и доставкой события.
\n

Симптом → причина → проверка → действие

\n
Диагностика stale-read
СимптомПричинаПроверкаДействие
Источник v2, entry v1, ключ совпадаетСобытие задержалось или read не сравнивает версииВоспроизвести write → delayed event → readДобавить version guard или точечную очистку
Источник v2, но читается другой ключКлюч не содержит область, язык или tenantВывести ключи двух проекций и сравнить поляИсправить контракт ключа и тест collision
Источник private, entry publicVisibility проверяется после cache hitСменить только visibility и повторить public readСначала отказать и удалить entry
Источник v2, entry v2, UI старыйКеш не доказан как причинаСверить projection и слой rendererПроверить другой кеш или клиентское состояние
Позднее событие удаляет entry v2Consumer удаляет по любому событиюПроверить условие entry.version < event.versionНе удалять current entry
\n

Порядок действий

\n
  1. Опишите симптом: какой читатель получил какую старую проекцию и чем это опасно.
  2. Зафиксируйте source id, текущую версию, вычисленный read key, версию cache entry, состояние события и visibility.
  3. Проверьте, что ключ относится к нужной области чтения и включает все параметры, которые меняют ответ.
  4. Сравните версии до очистки ключа. Если source новее entry, воспроизведите чтение при задержанном событии.
  5. Проверьте projection whitelist. В кеше должны лежать только поля, разрешённые этой проекцией.
  6. Отдельно проверьте изменение visibility. Public read должен отказать до доставки события.
  7. Сделайте consumer идемпотентным: запоздалое событие не удаляет entry, собранную из более новой версии.
  8. Для HTTP-слоя отдельно проверьте cache key, Vary, ETag или другой validator. Не смешивайте их с внутренней version без явного контракта.
\n

Ограничения

\n

Синхронное сравнение source и cache в примере не обещает строгую согласованность распределённой системы. В настоящем сервисе источник, кеш, брокер, реплика и CDN имеют разные задержки. Если read не может получить текущую версию, нужно документировать допустимое stale window и использовать выбранный механизм validation. Нельзя объявлять проблему решённой только потому, что событие записалось в журнал.

\n

TTL остаётся полезным предохранителем от бесконечной жизни entry. Он ограничивает ущерб при сбое очистки, но не заменяет событие и проверку версии. Полная очистка кеша тоже не универсальное решение: она создаёт всплеск запросов к источнику и не исправляет неправильный key или ошибочную проекцию.

\n

HTTP-кеш имеет собственные правила. RFC 9111 описывает freshness, validation, cache key и invalidation ответов. RFC 9110 описывает семантику HTTP и условные запросы. Эти правила помогают спроектировать HTTP-слой, но не превращают прикладную Map в HTTP-кеш и не проверяют права пользователя.

\n

Критерий готовности

\n

Для одной выбранной проекции можно назвать владельца источника, точный ключ, версию, событие и условие current hit. Контролируемый тест подтверждает три отрицательных пути: source v2 не возвращает entry v1 как hit до доставки события; запоздалое событие не удаляет entry v2; private source не выдаётся через public key. Только после этого имеет смысл подключать реальный cache store и проверять тот же контракт на интеграционном маршруте.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/250.json b/editorial/agent-rewrites/250.json new file mode 100644 index 0000000..67d0cb4 --- /dev/null +++ b/editorial/agent-rewrites/250.json @@ -0,0 +1,7 @@ +{ + "index": 250, + "slug": "editorial-2021-01-field-transactions", + "title": "Граница транзакции PostgreSQL: как сохранить cross-row инвариант", + "excerpt": "Два успешных запроса могут вместе оставить ночную смену без дежурного. Разбираем scope проверки, row-level locks, deadlock и полный retry для SERIALIZABLE.", + "contentHtml": "

Два запроса могут вернуть HTTP 200, а смена всё равно останется без дежурного. Представим две активные строки в таблице on_call: Анна и Борис. Каждый сотрудник может снять себя с ночного дежурства. Правило системы — после успешного commit должен остаться хотя бы один активный человек. T1 читает две активные строки и выключает Анну. T2 почти одновременно читает те же две строки и выключает Бориса. Они меняют разные записи, поэтому локальная проверка каждой операции проходит. Вместе они оставляют ноль активных сотрудников. Цена ошибки — не только неверная строка. Следующий процесс увидит допустимый на уровне схемы, но запрещённый бизнесом итог и начнёт работать без обязательного участника.

Тезис простой: BEGIN сам по себе не защищает правило между строками. Нужно определить полный scope инварианта, поместить чтение, проверку и запись в одну короткую transaction и выбрать механизм, который все writers соблюдают. Для фиксированного набора строк подойдёт единый порядок SELECT ... FOR UPDATE. Для более сложной зависимости подойдёт SERIALIZABLE, но тогда приложение обязано повторять всю операцию после подтверждённого serialization failure. Повтор одного последнего UPDATE не исправляет устаревшее решение.

Что именно сломалось

Проблема начинается с cross-row инварианта: count(enabled rows for one shift) >= 1. Поле enabled описывает одну строку. Инвариант описывает набор строк одной смены. Если приложение проверяет только текущего врача, оно не видит, кто ещё дежурит. Если оно читает весь набор, но делает проверку и запись в разных границах, другой writer может изменить набор между этими действиями.

В стандартном для PostgreSQL уровне READ COMMITTED обычный SELECT получает снимок на момент начала statement. Два SELECT внутри одной transaction не обязаны видеть одинаковое состояние. Даже если оба запроса прочитали корректный снимок, это ещё не разрешает им независимо принять решения по одному общему правилу. Snapshot отвечает на вопрос «что вижу», а не на вопрос «можно ли мой будущий commit сочетать с другим commit».

Обычный CHECK тоже не является универсальным решением. Он проверяет новую или изменённую строку. Он не поддерживает постоянное правило, которое зависит от других строк таблицы. Если правило можно выразить через UNIQUE, EXCLUDE или FOREIGN KEY, лучше использовать constraint. Для правила «в каждой смене остаётся хотя бы один активный» обычно нужен согласованный протокол чтения и записи.

Схема диагностики конфликта транзакций: write skew, неполный scope блокировки, deadlock и serialization failure
Схема переводит общий симптом в четыре проверяемые формы: несовместимые решения, неполный scope, разный порядок блокировок и SQLSTATE 40001.

Механизм: snapshot, строки и commit

Наивный путь выглядит так: запрос считает активных сотрудников, приложение принимает решение, затем выключает текущего сотрудника. Временная шкала может быть такой: T1 читает active_count = 2; T2 читает active_count = 2; T1 выключает Анну и фиксирует результат; T2 выключает Бориса и фиксирует результат. Ни один statement не увидел грязное чтение. Ошибка возникла потому, что два разрешённых по отдельности решения нельзя объединить в один допустимый итог.

-- Учебный anti-pattern. Этот SQL не запускался при подготовке статьи. BEGIN; SELECT count(*) AS active_count FROM on_call WHERE shift = :shift AND enabled = true; -- Приложение отдельно решает, можно ли выключить себя. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Две разные UPDATE-строки не защищают правило active_count >= 1.

У этого пути две границы. Первая — набор строк, который участвует в проверке. Вторая — момент, когда результат становится committed. Если другой writer использует другой predicate, проверяет только одну строку или пишет вне этой transaction, приложение не имеет общего протокола. Название endpoint и наличие BEGIN этого не меняют.

Явная граница строк

Если scope известен, можно сначала заблокировать весь набор, затем посчитать его в той же transaction. В примере scope — все дежурные ночной смены. Стабильный ORDER BY doctor задаёт единый порядок получения нескольких строк. T2 будет ждать, пока T1 завершит transaction. После пробуждения T2 должна читать состояние, которое теперь включает commit T1, и отказаться от своей записи, если активным остался только один человек.

-- Учебный protocol для фиксированного набора строк. Команды не выполнялись на PostgreSQL при подготовке статьи. BEGIN; SELECT doctor, enabled FROM on_call WHERE shift = :shift ORDER BY doctor FOR UPDATE; -- Проверяем count по возвращённому набору в этой же transaction. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- Все writers этого правила должны использовать тот же scope и порядок.

FOR UPDATE блокирует возвращённые строки до окончания transaction. Он не блокирует абстрактную «смену» и не заставляет чужой код использовать тот же запрос. Поэтому нужно сравнить predicate бизнес-правила с predicate блокировки. Если инвариант зависит от строк с другой ролью, региона или временного интервала, запрос должен покрыть и их. Если возможна вставка новой строки, блокировка существующих строк может не описывать весь риск. В такой ситуации меняют модель или выбирают другой механизм.

Ожидание блокировки и deadlock — разные симптомы. Ожидание T2 за строками T1 может быть штатной частью протокола. Deadlock возникает, когда T1 держит A и ждёт B, а T2 держит B и ждёт A. Единый порядок получения строк уменьшает такую возможность. Если сервер всё же отменил transaction, повторяют отменённую операцию только после проверки причины. Бесконечный цикл вокруг любой ошибки маскирует нарушения данных, сетевые сбои и ошибки валидации.

Serializable и полный retry

SERIALIZABLE полезен, когда правило зависит от чтений и его трудно свести к небольшому заранее известному набору строк. PostgreSQL допускает commit только для результата, который можно объяснить некоторым последовательным порядком операций. Это не означает, что обе concurrent transaction всегда завершатся успешно. При опасной зависимости одна может получить serialization failure с SQLSTATE 40001.

-- Учебный shape. Нужна адаптация к драйверу и проектной policy. BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT doctor, enabled FROM on_call WHERE shift = :shift; -- Здесь выполняются проверка invariant и нужная запись. UPDATE on_call SET enabled = false WHERE shift = :shift AND doctor = :current_doctor; COMMIT; -- При 40001 повторяется вся операция, а не только UPDATE.

Полный retry означает новый BEGIN, новые чтения, новую проверку, новую запись и новый COMMIT. T2 после отката не может использовать решение «в смене два активных», которое она получила до commit T1. Она должна получить новый snapshot и заново увидеть один активный ряд. Если условие больше не выполняется, операция возвращает отказ без записи.

async function retryWholeOperation(runOnce) { for (let attempt = 1; attempt <= 3; attempt += 1) { try { return await runOnce(); } catch (error) { if (error.code !== '40001' || attempt === 3) throw error; } } } // Учебный shape: runOnce должен включать reads, decision, writes и commit. // Внешний side effect нельзя бездумно помещать внутрь такого retry.

Этот фрагмент не является готовым адаптером драйвера. В реальной системе нужны rollback, deadline, backoff, журнал причины и политика после исчерпания попыток. Письмо, публикация события или HTTP-вызов внешнего сервиса не откатываются транзакцией PostgreSQL. Если такой side effect произошёл до commit, повтор может отправить его дважды. Сначала фиксируют данные, затем публикуют внешний эффект отдельным механизмом с собственным контрактом идемпотентности.

Симптом → причина → проверка → действие

Диагностика конкурентной операции
СимптомПричинаПроверкаДействие
Два запроса успешны, invariant нарушенОба приняли решение по одному старому состояниюЗафиксировать interleaving: два read, два write, два commitОбъединить read, check и write в общий protocol
FOR UPDATE есть, ошибка остаётсяЗаблокированный set меньше set инвариантаСравнить оба predicate и всех writersРасширить scope или изменить модель правила
Запрос долго ждётДругой writer держит ту же строкуСопоставить statement, transaction и lock в pg_locksСократить transaction и принять ожидаемое ожидание
Deadlock и отмена transactionРазные пути берут строки в разном порядкеВыписать порядок A/B для каждого writerУстановить один order и повторять только подтверждённую отмену
SQLSTATE 40001Serializable обнаружил опасную зависимостьПроверить outcome transaction и момент side effectПовторить всю operation с новым snapshot
После retry появился duplicate effectВнешняя публикация попала внутрь повторяемой границыНайти её точное место относительно commitВынести публикацию и задать idempotency contract

Порядок проверки

  1. Запишите invariant как условие после успешного commit. Для примера: в каждой ночной смене остаётся минимум один активный дежурный.
  2. Выпишите полный scope: shift, role, region, временной диапазон и возможные новые строки. Одного doctor_id недостаточно.
  3. Найдите все writers этого набора. Один обходящий protocol UPDATE обесценивает защиту остальных путей.
  4. Запишите фиксированный schedule двух операций: что прочла каждая, когда приняла решение, кто ждал и какой commit произошёл первым.
  5. Проверьте уровень изоляции до первого query. SET TRANSACTION нельзя переносить после чтения.
  6. Для явной блокировки проверьте полный returned set и единый order. Для SERIALIZABLE проверьте обработку 40001.
  7. Убедитесь, что retry повторяет чтения, проверку и запись. Не повторяйте устаревший blind write.
  8. Сначала запустите быструю учебную модель, затем integration test на разрешённой PostgreSQL с двумя sessions и заранее согласованным cleanup.
  9. Отдельно проверьте side effects после commit. В критерий готовности включите отказ, retry и поведение после исчерпания попыток.

Ограничения и критерий готовности

Учебный пример фиксирует две строки, одну смену и один schedule. Он не измеряет latency, throughput, contention или длительность locks. Он не показывает план PostgreSQL, поведение конкретного драйвера и все writers приложения. SQL-фрагменты выше не выполнялись при подготовке текста. Поэтому нельзя переносить их как готовую production-конфигурацию и нельзя объявлять проблему доказанной только по fixture.

Для одной строки часто достаточно атомарного UPDATE ... WHERE с проверяемым условием. Для cross-row правила нужен полный scope. Constraint может быть лучше transaction protocol, если правило выразимо на уровне схемы. Явная блокировка подходит при известном наборе. SERIALIZABLE подходит при сложной зависимости, если операция безопасно повторяется. Ни один вариант не отменяет необходимость перечислить writers и внешние эффекты.

Операция готова к выпуску, когда integration test на целевой версии PostgreSQL запускает два конкурентных пути и подтверждает запрещённый наивный schedule, выбранный механизм, правильный итог после ожидания или retry, SQLSTATE для отменённой transaction и отсутствие duplicate side effect. Дополнительно тест должен показать, что обходящий writer не остаётся незамеченным. Это проверяемый критерий. Наличие BEGIN в коде таким критерием не является.

Граница транзакции заканчивается там, где заканчивается состояние базы и её protocol. Назовите invariant, scope, порядок locks и момент commit. После этого ошибка перестаёт быть загадочным «конфликтом транзакций»: её можно свести к неполному набору, разному порядку, ожидаемой блокировке или serialization failure и выбрать конкретное действие.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/251.json b/editorial/agent-rewrites/251.json new file mode 100644 index 0000000..00d22a3 --- /dev/null +++ b/editorial/agent-rewrites/251.json @@ -0,0 +1,7 @@ +{ + "index": 251, + "slug": "editorial-2021-01-mechanism-transactions", + "title": "Почему транзакция не спасает сама по себе: snapshot, блокировки и retry в PostgreSQL", + "excerpt": "Два запроса могут честно выполнить SELECT и COMMIT, но нарушить общее правило данных. Разбираем, что видит snapshot, какие строки покрывает FOR UPDATE и почему Serializable требует повторить всю операцию.", + "contentHtml": "

Симптом выглядит безопасно: два запроса вернули успех, оба завершились COMMIT, но после этого в смене не осталось дежурного врача. Первый запрос выключил Анну, второй — Бориса. Каждый проверил строку, которую менял. Ошибка появилась в общем правиле: для одной смены должен оставаться хотя бы один активный врач. Цена ошибки — не только неверная строка в таблице. Следующий запрос доверяет этому состоянию и может принять решение без ответственного человека.

\n

Тезис простой: BEGIN не описывает бизнес-инвариант. PostgreSQL защищает согласованность транзакции, но приложение должно выбрать способ защиты общего правила. В одном случае хватит атомарного UPDATE. В другом нужен фиксированный набор строк с SELECT ... FOR UPDATE. Для сложной проверки можно выбрать Serializable, но тогда код обязан повторить всю операцию после serialization failure. Повтор последней команды не исправляет старое решение.

\n

Что именно видит транзакция

\n

В PostgreSQL 13 по умолчанию действует Read Committed. Обычный SELECT видит данные, зафиксированные до начала этого statement. Поэтому два SELECT в одной транзакции могут получить разные snapshots. Между ними другая транзакция успеет выполнить UPDATE и COMMIT. Это не dirty read: незакоммиченные данные по-прежнему скрыты. Меняется момент, на который смотрит каждый запрос.

\n

Repeatable Read сохраняет snapshot после первого запроса транзакции. Последующие чтения видят одну картину. Но стабильное чтение не означает право безопасно изменить строки, которые участвуют в общем инварианте. Если две транзакции прочитали один набор и приняли несовместимые решения, PostgreSQL может отменить одну из них. Код должен обработать отказ.

\n

Serializable добавляет более сильное условие для успешного commit: результат параллельных транзакций должен быть объясним некоторым последовательным порядком. Это не пропуск для любого запроса. При конфликте PostgreSQL завершает одну транзакцию ошибкой сериализации, обычно с SQLSTATE 40001. Вызвавший код получает возможность начать операцию с новым snapshot.

\n
\"Матрица
Snapshot отвечает за видимость, блокировка — за конфликт на возвращённых строках, а Serializable — за допустимость успешного общего результата.
\n

Блокировка защищает возвращённые строки

\n

SELECT ... FOR UPDATE ставит блокировку на строки, которые вернул запрос. Конфликтующий UPDATE, DELETE или другой запрос с блокировкой будет ждать завершения первой транзакции. PostgreSQL не угадывает, какие строки составляют ваш инвариант. Если правило относится к двум врачам, а запрос зафиксировал только текущего врача, второй остаётся вне протокола.

\n

У всех writers должен быть один и тот же scope. Они должны выбирать один набор строк, использовать одинаковый порядок и удерживать блокировку до изменения и commit. ORDER BY помогает сделать порядок явным, но не расширяет набор. Если predicate допускает новые строки, одного row lock может быть мало: вставка, не попавшая в result, не ждёт блокировку уже выбранных строк.

\n

Учебный пример: запретить отключение последнего врача

\n

Ниже приведён учебный SQL-пример для объяснения протокола. Он не сообщает production-латентность, количество конфликтов или поведение конкретного сервиса. Пусть таблица содержит shift, doctor и enabled. Операция должна отключить врача только тогда, когда после изменения останется хотя бы один активный врач.

\n
BEGIN;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = $1\nORDER BY doctor\nFOR UPDATE;\n\nSELECT count(*)\nFROM on_call\nWHERE shift = $1 AND enabled = true;\n\n-- Приложение принимает решение по полному набору строк.\nUPDATE on_call\nSET enabled = false\nWHERE shift = $1 AND doctor = $2;\n\nCOMMIT;
\n

Протокол работает только при дисциплине всех writers. Каждый маршрут, который меняет активность врача для той же смены, должен брать тот же набор строк и тот же порядок. Если один путь делает прямой UPDATE, он обходит договорённость. Для одного счётчика часто безопаснее выразить правило атомарным условным обновлением и проверить число изменённых строк. Не добавляйте широкую блокировку, пока не назвали точный invariant.

\n

Когда нужен полный retry

\n

При Serializable транзакция должна быть повторяемой целиком. Новый запуск заново выполняет чтения, проверку, решение, записи и commit. Только так решение строится на новом snapshot.

\n
async function disableDoctor(shift, doctor) {\n  for (let attempt = 1; attempt <= 3; attempt += 1) {\n    try {\n      await db.begin({ isolation: 'serializable' });\n      const rows = await db.query(\n        'SELECT doctor, enabled FROM on_call WHERE shift = $1 ORDER BY doctor',\n        [shift],\n      );\n      const active = rows.filter((row) => row.enabled).length;\n      if (active <= 1) throw new Error('last active doctor');\n      await db.query(\n        'UPDATE on_call SET enabled = false WHERE shift = $1 AND doctor = $2',\n        [shift, doctor],\n      );\n      await db.commit();\n      return;\n    } catch (error) {\n      await db.rollback();\n      if (error.sqlState !== '40001' || attempt === 3) throw error;\n    }\n  }\n}
\n

Код иллюстрирует границу retry, а не готовый клиентский API. Методы begin, rollback и поле sqlState зависят от драйвера. При повторе нельзя повторно отправить письмо, списать деньги или опубликовать событие до успешного commit без отдельного механизма идемпотентности. Внешний side effect не откатывается вместе с PostgreSQL.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Два запроса изменили разные строки и нарушили правилоПроверка охватила неполный набор или writers используют разные путиСравнить predicate, строки result и SQL всех writersСузить invariant до точного scope и выбрать общий lock protocol либо атомарный запрос
Вторая транзакция долго ждётОна конфликтует с row lock первой транзакцииСопоставить pg_stat_activity, ожидание и удерживающую транзакциюСократить работу между lock и commit; проверить порядок взятия нескольких строк
Два чтения в одном BEGIN вернули разные данныеРаботает Read Committed, snapshot создаётся для каждого statementЗаписать время начала обоих statements и commit конкурирующего запросаСделать операцию атомарной или задать isolation до первого запроса
Транзакция падает с SQLSTATE 40001Serializable обнаружил конфликт сериализацииПроверить SQLSTATE и границу операции в логахПовторить весь read/decide/write/commit; ограничить число попыток
Правило нарушается после добавления новой записиFOR UPDATE зафиксировал существующие rows, но не описал insertПроверить predicate и конкурентный INSERTПересмотреть модель: constraint, другой lock scope или Serializable
\n

Порядок проверки

\n
  1. Запишите invariant в форме допустимого и запрещённого committed result. Например: для каждой смены active_count >= 1.
  2. Выпишите все rows и predicates, от которых зависит решение. Отдельно перечислите каждый writer, включая фоновые задачи и административные команды.
  3. Проверьте, можно ли выразить правило одним атомарным SQL statement. Если да, измерьте и проверьте именно этот путь.
  4. Если нужен набор строк, зафиксируйте его через один query, задайте стабильный порядок и удерживайте lock до commit. Проверьте два конкурентных соединения.
  5. Если scope нельзя надёжно перечислить, рассмотрите Serializable. Задайте уровень до первого query и обработайте только ожидаемые serialization failures.
  6. Отделите retryable часть от внешних действий. Идемпотентность, outbox или другой механизм нужны там, где commit не может отменить уже отправленный эффект.
  7. Зафиксируйте результат интеграционным тестом на реальной версии PostgreSQL. Учебная fixture объясняет schedule, но не заменяет две сессии базы данных.
\n

Ограничения

\n

Уровень изоляции не исправит неверный invariant и не заставит старый код соблюдать новый protocol. Row lock не защищает строки, которые query не вернул. Serializable может увеличить число отказов и повторов; их частота зависит от длительности транзакции, индексов, плана и конкурентной нагрузки. Учебный пример не даёт production-результата и не задаёт число retry для всех систем.

\n

Граница PostgreSQL заканчивается на состоянии базы. Письмо, вызов HTTP-сервиса и сообщение в очереди не откатываются автоматически. Не публикуйте такие эффекты до подтверждённого commit или связывайте их с базой через отдельный надёжный протокол.

\n

Критерий готовности

\n

Операция готова, когда тест с двумя конкурентными сессиями подтверждает invariant после каждого успешного commit, ожидаемый конфликт даёт понятный outcome, а отказ 40001 повторяет всю операцию и не дублирует внешний эффект. В логах видны transaction id, число попыток и причина отказа. Если любой writer обходит описанный scope, критерий не выполнен.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/252.json b/editorial/agent-rewrites/252.json new file mode 100644 index 0000000..3e91c81 --- /dev/null +++ b/editorial/agent-rewrites/252.json @@ -0,0 +1,7 @@ +{ + "index": 252, + "slug": "editorial-2021-01-practice-transactions", + "title": "Граница транзакции PostgreSQL: как сохранить правило между строками", + "excerpt": "Два запроса могут успешно изменить разные строки и вместе нарушить одно бизнес-правило. Разбираем write skew, область блокировки и полный retry на учебном примере PostgreSQL.", + "contentHtml": "

Два запроса вернули 200 OK, но смена осталась без дежурного. Первый запрос выключил Анну, второй — Бориса. Каждый изменил свою строку. Ошибки базы нет. Ошибка появилась в общем правиле: после любой операции должен остаться хотя бы один активный дежурный. Цена такого сбоя — не только неверная строка. Следующий процесс увидит допустимое на уровне типов состояние и примет решение на ложных данных.

\n

Причина скрывается между строками. Каждый запрос сначала читает число активных дежурных, потом меняет одну строку. При двух активных оба запроса принимают решение «можно». Если они не видят изменение друг друга, оба commit проходят. Транзакция сама по себе не защищает predicate, который приложение проверило до записи.

\n

Тезис статьи простой: граница транзакции должна охватывать чтение, проверку и запись, а механизм должен покрывать все строки, от которых зависит invariant. Для известного набора строк подойдёт единый порядок SELECT ... FOR UPDATE. Для более сложного read/write-пути может подойти Serializable с повтором всей операции после 40001. Ни один вариант не доказывает корректность, пока остальные writers не соблюдают тот же протокол.

\n

Что именно нарушается

\n

Пусть таблица хранит дежурства одной смены.

\n
CREATE TABLE on_call (\n  shift text NOT NULL,\n  doctor text NOT NULL,\n  enabled boolean NOT NULL,\n  PRIMARY KEY (shift, doctor)\n);\n\n-- Для каждой смены после успешного commit:\n-- число строк с enabled = true должно быть не меньше одного.
\n

Транзакции T1 и T2 начинают работу почти одновременно. Обе читают activeCount = 2. T1 выключает Анну. T2 выключает Бориса. Записи не конфликтуют: это разные primary key. Но решения конфликтуют по смыслу. После двух commit значение равно нулю.

\n

Это write skew. Система не потеряла обновление одной колонки. Она приняла два независимых изменения, которые несовместимы вместе. Дополнительный SELECT count(*) перед commit не исправляет путь. В режиме Read Committed следующий statement может получить новый snapshot, но сам факт чтения не блокирует другого writer и не отменяет уже принятое решение.

\n

Момент чтения важнее названия уровня

\n

В PostgreSQL обычный Read Committed создаёт видимость для каждого statement. Два запроса в одной транзакции могут увидеть разные committed состояния. Repeatable Read удерживает snapshot после первого запроса, но стабильное чтение не превращает cross-row проверку в последовательное выполнение. Serializable добавляет более сильное требование к успешным результатам: они должны быть объяснимы некоторым последовательным порядком. За это одна из конфликтующих транзакций может быть отменена.

\n

Уровень изоляции задают до первого запроса текущей транзакции. Если приложение сначала выполнило «безобидный» SELECT, а затем пытается изменить уровень, оно уже выбрало границу видимости. Поэтому настройка и контракт retry должны находиться рядом с началом операции.

\n
BEGIN;\nSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = :shift\nORDER BY doctor;\n\n-- Проверяем invariant и выполняем write в этой же транзакции.\nUPDATE on_call\nSET enabled = false\nWHERE shift = :shift AND doctor = :current_doctor;\n\nCOMMIT;
\n

Этот фрагмент показывает форму операции. Он не является выполненным запросом и не подтверждает поведение конкретной схемы. Если PostgreSQL отменит транзакцию с SQLSTATE 40001, приложение должно повторить BEGIN → reads → check → writes → COMMIT целиком. Повтор одного UPDATE использует старое решение и обходит защиту.

\n

Явная граница строк

\n

Когда набор строк известен и невелик, можно сначала заблокировать его в одном порядке, затем проверить правило и выполнить изменение. FOR UPDATE блокирует возвращённые строки для конфликтующих writers до завершения транзакции. Обычный SELECT такой блокировки не ставит.

\n
BEGIN;\n\nSELECT doctor, enabled\nFROM on_call\nWHERE shift = :shift\nORDER BY doctor\nFOR UPDATE;\n\n-- Проверка видит именно заблокированный набор строк.\n-- Если activeCount <= 1, операцию отклоняем.\nUPDATE on_call\nSET enabled = false\nWHERE shift = :shift AND doctor = :current_doctor;\n\nCOMMIT;
\n

Блокировка защищает результат запроса, а не слово «смена». Если проверка учитывает строки для ролей primary и backup, а lock-запрос выбирает только primary, доказательство неполно. Если другой endpoint меняет те же строки без FOR UPDATE, он обходит протокол. Все writers должны использовать совместимую область и одинаковый порядок. Иначе возможны неполная защита или deadlock.

\n
\"Временная
Одна и та же операция даёт разные результаты в зависимости от границы. Рисунок показывает учебный schedule, а не трассировку реальной базы.
\n

Симптом → причина → проверка → действие

\n
Диагностика нарушения cross-row правила
СимптомПричинаПроверкаДействие
Два успеха, правило ложноДва read/write-пути приняли решение по одному старому условиюВоспроизвести порядок T1 read, T2 read, T1 commit, T2 commitСобрать check и write в общий протокол, затем проверить реальный SQL двумя sessions
FOR UPDATE не помогЗаблокированный набор меньше набора invariantСравнить predicate проверки и predicate lock-запросаРасширить scope или изменить модель правила
Запрос ждёт или получает deadlockРазные writers берут несколько строк в разном порядкеВыписать порядок захвата для каждого пути и сопоставить с lock-диагностикойВвести единый порядок, сократить транзакцию, повторять только отменённую работу
40001Serializable обнаружил несовместимую зависимостьПроверить код ошибки, границу транзакции и отсутствие внешнего эффекта до commitПовторить всю операцию с новым snapshot или вернуть контролируемый отказ
Retry создал дубликат во внешней системеПовтор пересёк границу базы и внешнего side effectНайти момент публикации эффекта относительно commitОтделить retryable часть от публикации и задать отдельный idempotency contract
\n

Порядок действий

\n
  1. Сформулируйте invariant после commit одним предложением. Для примера: «в смене остаётся минимум один активный дежурный».
  2. Выпишите полный predicate и все строки, от которых зависит решение. Не заменяйте их одним target ID.
  3. Найдите каждый writer: endpoint, job, админский скрипт и миграцию, которые могут менять эти строки.
  4. Определите минимальный механизм. Для одной строки используйте атомарное изменение или constraint; для известного набора — единый row-lock protocol; для сложного read/write-пути — Serializable с full retry.
  5. Поместите чтение, проверку и запись в короткую транзакцию. Не держите lock во время HTTP-вызова или ожидания пользователя.
  6. Если выбрали FOR UPDATE, зафиксируйте scope и ORDER BY для всех writers. Если выбрали Serializable, повторяйте только подтверждённый 40001.
  7. Проверьте отрицательный путь: после первого commit второе решение должно перечитать состояние и отказаться от записи, дождаться корректного результата или получить контролируемую ошибку.
  8. Проведите integration test с двумя реальными sessions на поддерживаемой версии PostgreSQL и сохраните SQLSTATE, commit outcome и итоговое число активных строк.
\n

Ограничения и критерий готовности

\n

Учебный пример не измеряет latency, throughput, длительность ожидания или число блокировок. Он не заменяет integration test, план запроса и проверку всех writers. Стоимость row locks зависит от размера набора, индексов, длительности транзакции и конкурентной нагрузки. Стоимость Serializable зависит от частоты отмен и возможности безопасно повторить работу. Нельзя переносить результат примера на схему, где появились новые predicate, роли или внешние side effects.

\n

Готовность проверяема. Для поддерживаемой версии базы два конкурентных запуска не оставляют запрещённое состояние; при конфликте второй путь либо ждёт и повторно проверяет invariant, либо получает документированный 40001 и безопасно повторяет всю операцию. Ни один внешний эффект не публикуется до успешного commit без отдельного idempotency-механизма. Если это не подтверждено тестом с реальным SQL, граница транзакции остаётся гипотезой.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/253.json b/editorial/agent-rewrites/253.json new file mode 100644 index 0000000..07afe3e --- /dev/null +++ b/editorial/agent-rewrites/253.json @@ -0,0 +1,7 @@ +{ + "index": 253, + "slug": "editorial-2020-12-field-incident-review", + "title": "Разбор инцидента: как не принять обход за исправление", + "excerpt": "Запрос возвращает blocked, потому что обязательное поле теряется на границе mapper. Разбираем, как отделить факт от гипотезы, проверить обратимый обход и оставить защиту от повторения.", + "contentHtml": "

Запрос preview-order возвращает blocked, хотя вход выглядит допустимым. После преобразования объекта пропадает обязательное поле currency. Команда возвращает прежний mapper, получает allowed и закрывает задачу. Цена ошибки проявится позже: новый mapper снова попадёт в путь, поле снова исчезнет, а старый обход уже будут считать исправлением.

\n

Восстановление сервиса и устранение причины — разные события. Временное действие должно иметь условие запуска, ожидаемый результат и путь отмены. Отдельно нужно записать тест или контракт, который не даст ошибке вернуться. Иначе отчёт сохраняет уверенность, но не сохраняет способ проверки.

\n

Граница примера

\n

Рассмотрим только переход от mapper к границе pricing-adapter. Это учебная fixture в памяти. Она не обращается к сети, базе, очереди или реальному API. Вход содержит сумму и не содержит валюту. Новый mapper возвращает объект без currency. Затем preview-order отвечает blocked. Мы проверяем форму рассуждения, а не заявляем production-результат.

\n
const input = { amount: 1000, itemId: 'demo-1' };\\n\\nconst mapped = newMapper(input);\\nif (!mapped.currency) {\\n  return { status: 'blocked', reason: 'currency_missing' };\\n}\\n\\nconst fallback = oldMapper(input);\\nreturn { status: 'allowed', currency: fallback.currency };
\n

Код намеренно короткий. Он показывает место, где исчезает значение. Он не доказывает, что именно mapper стал единственной причиной отказа. Для такого вывода нужны наблюдения до и после границы, а также проверка альтернативных причин.

\n

Механизм: факт, гипотеза, действие, проверка

\n

Наблюдение описывает то, что можно увидеть. «После mapper поле отсутствует» — наблюдение. «Новый mapper не переносит поле» — гипотеза. Она связывает два факта, но остаётся изменяемой. Если поле исчезло раньше, гипотеза не выдержит проверки, а факты останутся полезными.

\n

Действие должно менять одну понятную переменную. В примере это возврат к прежнему mapper на ограниченной ветке. Проверка должна измерять именно это действие: fixture получает тот же вход, возвращает allowed и сохраняет currency. Такая проверка не доказывает исправность всех заказов. Она подтверждает только заданный сценарий.

\n

Профилактика отвечает на другой вопрос: что поймает повторение? Здесь нужен контрактный тест, который отклоняет результат mapper без обязательного поля. Пока тест не написан и не прошёл, профилактика остаётся предложением. Статус planned честнее слова «готово», если изменение ещё не появилось в коде.

\n
Как читать сигнал и выбирать следующий шаг
СимптомПричинаПроверкаДействие
preview-order вернул blockedНарушен контракт обязательного поля или сработала другая ветка отказаСохранить ответ и проверить вход на границе mapperНе менять retry и timeout до локализации причины
После mapper нет currencyНовый mapper мог отбросить полеСравнить вход, результат нового mapper и результат прежнегоСформулировать узкую гипотезу H1
Прежний mapper даёт allowedОбход возвращает известный контракт, но причина не устраненаПовторить fixture на том же входе и проверить сохранение поляОставить обход ограниченным и записать условие отмены
Проверка обхода не прошлаГипотеза неполна или отказ вызван другой границейСобрать новое наблюдение до следующего измененияОтменить вывод и проверить альтернативную ветку
Ошибка возвращается после изменения mapperНет автоматической защиты контрактаЗапустить тест на обязательное поле в точке передачиДобавить контрактный тест и связать его с готовностью
\n

Почему нельзя лечить все симптомы сразу

\n

Увеличение timeout скрывает медленный ответ, но не возвращает пропавшее поле. Дополнительный retry повторяет тот же неверный объект и может умножить побочный эффект. Одновременная правка mapper, retry и timeout стирает причинную связь: если результат изменится, станет непонятно, какая правка помогла.

\n

Это не запрет на retry или timeout. Они уместны, когда наблюдение указывает на временную сетевую ошибку или ограничение времени ответа. Но тогда у решения должны быть собственный trigger и собственная проверка. Инструмент выбирают по наблюдаемому механизму, а не по привычке.

\n
Цикл разбора инцидента: наблюдение, ограниченный обход, проверка и профилактика
Ограниченный обход снижает влияние сейчас. Контрактная проверка снижает риск повторения позже.
\n

Отрицательный путь

\n

Предположим, возврат к прежнему mapper не дал allowed. Это не повод назвать прежний код неисправным. Новый факт говорит лишь о том, что выбранный обход не подтвердил гипотезу. Поле могло отсутствовать уже во входе. Отказ мог зависеть от другой обязательной величины. Вторая проверка должна отличить эти варианты.

\n

Хороший разбор не прячет отрицательный результат. Он сохраняет его рядом с условием, при котором проверка должна была пройти. Так следующий инженер не повторит тот же обход вслепую. Формулировка «A1 не подтвердил H1 на входе E1» полезнее, чем «фикс не сработал»: первая фраза указывает границу нового исследования.

\n

Порядок действий

\n
  1. Назвать один симптом и одну границу. В примере это blocked на переходе к pricing-adapter.
  2. Собрать факты до изменения: вход, результат mapper, ответ и сохранённые поля.
  3. Разделить факт и гипотезу. Не записывать возможную причину как доказанное наблюдение.
  4. Выбрать одно обратимое действие. Указать trigger, ожидаемый результат и условие отмены.
  5. Повторить тот же учебный сценарий или безопасный контролируемый запрос.
  6. Проверить результат только в пределах входа и среды. Не переносить его на неизвестные пути.
  7. Добавить профилактику с отдельным критерием: тест должен падать на результате mapper без currency.
  8. Закрыть работу только после проверки действия и профилактики. Если проверка отрицательна, начать новый цикл с наблюдения.
\n

Ограничения

\n

Fixture не измеряет доступность, нагрузку, время восстановления, права доступа или поведение внешней зависимости. Она не заменяет журнал, мониторинг, резервирование и процедуру отката. Asset на схеме объясняет последовательность, но не является доказательством результата. Учебный код ограничен одним объектом и одним контрактом.

\n

Нельзя объявлять production-инцидент исправленным по одному успешному примеру. В реальной системе нужно подтвердить границу на фактическом запросе, проверить безопасный rollout и посмотреть на отрицательные случаи. Нельзя также превращать разбор в поиск виноватого: смена автора mapper не объясняет, почему контракт оказался без защиты.

\n

Критерий готовности

\n

Работа готова, когда выполнены четыре условия. Симптом воспроизводится на известном входе. Действие меняет только заявленную границу и проходит свою проверку. Отрицательный путь записан и приводит к новой гипотезе, а не к повторению того же обхода. Наконец, автоматическая проверка падает на результате mapper без currency и проходит на корректном результате.

\n

Такой критерий не обещает, что система больше никогда не откажет. Он делает утверждение уже: конкретный контракт виден, действие проверено, а известный способ регрессии получает защиту.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/254.json b/editorial/agent-rewrites/254.json new file mode 100644 index 0000000..1208f29 --- /dev/null +++ b/editorial/agent-rewrites/254.json @@ -0,0 +1,7 @@ +{ + "index": 254, + "slug": "editorial-2020-12-mechanism-incident-review", + "title": "Разбор инцидента: как не перепутать симптом с причиной", + "excerpt": "После восстановления сервиса команда часто фиксирует обход как окончательное решение. Разбираем цепочку от наблюдения до проверки и показываем, как сохранить отрицательный результат и превратить вывод в проверяемое действие.", + "contentHtml": "

Сервис вернул ошибку, очередь выросла, а после отката показатели снова стали нормальными. На этом месте разбор часто заканчивается фразой: «вернули старую настройку и добавили тест». Через месяц тот же симптом появляется после другого изменения. Команда повторяет обход, но не знает, что именно сломалось и какой сигнал подтверждает исправление.

\n

Цена ошибки — не только повторный сбой. Временное действие становится частью обычного пути. Оно может скрывать потерю данных, увеличивать задержку или переносить отказ на следующую границу. Отчёт сохраняет уверенный рассказ, но не сохраняет ход проверки. Следующий инженер восстанавливает историю по памяти.

\n

Надёжный разбор разделяет пять состояний: наблюдение, гипотезу, действие, проверку и профилактику. Наблюдение описывает факт. Гипотеза связывает факты и остаётся опровержимой. Действие меняет одну ограниченную часть системы. Проверка измеряет результат этого действия. Профилактика меняет будущий путь и считается выполненной только после отдельной проверки.

\n

Сначала фиксируем границу и симптом

\n

Разбор начинается не со слова «причина». Сначала нужно назвать границу, на которой появился эффект. Это может быть переход между mapper и адаптером, запись в базу, публикация сообщения или вызов внешнего API. Затем нужно записать наблюдаемый результат и вход, на котором он возник.

\n

Рассмотрим учебный пример. Объект заказа проходит через mapper и приходит в pricing-adapter. Адаптер ожидает обязательное поле currency. После изменения mapper поле исчезает, а учебная операция preview-order возвращает blocked. Эти два факта не доказывают, что mapper — единственная причина. Они задают узкую ветку для проверки.

\n

Учебный пример ниже синтетический. Он не описывает production-систему, реальный трафик, время восстановления или фактический инцидент. Его задача — показать форму рассуждения на маленьком входе.

\n

Механизм: пять состояний не смешиваются

\n
Симптом → причина → проверка → действие
СимптомПричина или гипотезаПроверкаДействие
preview-order вернул blockedПосле mapper отсутствует обязательное полеСравнить вход и объект на границе адаптераЗафиксировать потерю поля и не расширять retry без отдельной причины
currency отсутствует после преобразованияНовый mapper не перенёс полеПодать минимальный объект в старый и новый mapperВременно вернуть известный вариант, если это обратимо и разрешено
Старый mapper вернул allowedОбход работает только для данного входаПроверить наличие currency и ожидаемый результатОставить обход временным и назначить контрактную проверку
Новая версия снова теряет полеКонтракт не защищает обязательный атрибутЗапустить проверку на минимальном входе до измененияОтклонять преобразование без currency
\n

В первой строке есть симптом, но нет диагноза. Во второй появляется гипотеза. В третьей проверка подтверждает только выбранное действие для конкретного входа. Она не доказывает, что исправлены все пути заказа. В четвёртой появляется профилактика. Такая граница не даёт назвать совпадение устранением причины.

\n

Наблюдение должно пережить неудачную гипотезу. Запись «после преобразования поле отсутствует» останется верной, даже если поле пропало раньше mapper. Запись «mapper отбросил поле» уже утверждает больше, чем показал сигнал. Если проверка опровергнет эту гипотезу, меняется гипотеза, а не прошлое наблюдение.

\n

Конкретный пример

\n

Минимальный код должен показывать только учебный контракт. Он не имитирует сеть и не создаёт видимость реального журнала:

\n
const input = { amount: 1250, currency: 'RUB' };\n\nfunction mapOrder(order) {\n  return { amount: order.amount };\n}\n\nfunction validateForPricing(order) {\n  return order.currency ? 'allowed' : 'blocked';\n}\n\nconst mapped = mapOrder(input);\nconst symptom = validateForPricing(mapped);\n\nif (symptom !== 'blocked') throw new Error('expected the exercise symptom');\nif ('currency' in mapped) throw new Error('the field should be absent here');\n\n// Учебный обÑ\nод: используем известное преобразование.\nconst restored = { amount: input.amount, currency: input.currency };\nconst check = validateForPricing(restored);\n\nif (check !== 'allowed') throw new Error('the bounded check failed');
\n

Этот код показывает две разные вещи. Сначала он воспроизводит симптом: mapper возвращает объект без currency. Затем он проверяет обратимый обход на том же входе. Успешный allowed подтверждает только действие для учебного сценария. Он не подтверждает новую реализацию, все валюты, другие версии контракта или production-эффект.

\n

Не стоит добавлять к этому же действию повторные попытки, увеличенный таймаут и новый кеш. Каждая мера меняет другую переменную. Если результат улучшится, команда не узнает, что повлияло на него. Retry уместен, когда наблюдение указывает на временную ошибку и есть защита от дублей. Таймаут уместен, когда проверена задержка, а не потеря поля. Для каждой меры нужна отдельная гипотеза и отдельная проверка.

\n
\"Схема
Проверяемая цепочка отделяет временный обход от профилактики и не назначает виноватого.
\n

Решение должно иметь условие и ожидаемый сигнал

\n

Во время инцидента действие выбирают быстро. Это не отменяет его границ. Запишите три вещи: какой симптом запускает действие, что именно изменится и какой сигнал должен появиться. Добавьте условие возврата. Тогда через час можно отличить результат действия от случайного совпадения.

\n

В учебном случае решение выглядит так: если на границе адаптера нет currency, не расширять retry и не увеличивать timeout; временно использовать известное преобразование; проверить наличие поля и статус allowed на том же входе; при отрицательном результате вернуться к новой гипотезе. Это не утверждает, что retry или timeout вредны всегда. Они просто не проверяют данную гипотезу.

\n

Проверка обязана иметь отрицательный путь. Если старый mapper тоже возвращает blocked, обход не подтвердился. Нельзя переписать это как «система всё равно восстановилась». Нужно сохранить новый факт: действие не объяснило симптом. Затем проверить вход до mapper, версию контракта и другую ветку обработки. Отрицательный результат сокращает пространство поиска.

\n

Профилактика меняет будущий путь

\n

Фраза «добавить тест» не описывает работу. Назовите защищаемый контракт, место проверки и ожидаемый отказ. Например: преобразование должно отклоняться, если не передало currency; проверка должна выполняться на границе pricing-adapter; минимальный вход должен приводить к явной ошибке до вызова адаптера.

\n

Профилактика не закрыта в момент, когда её записали. Она готова после того, как изменение появилось в коде, проверка запускается в нужном пути и отрицательный сценарий действительно ломает сборку или тест. До этого состояние нужно назвать планом. Иначе отчёт приписывает системе защиту, которой ещё нет.

\n

Порядок действий

\n
  1. Назовите одну границу и один наблюдаемый симптом. Запишите вход и время наблюдения.
  2. Снимите данные до изменения: поля объекта, ответ, код ошибки, версию и доступный контекст.
  3. Сформулируйте одну гипотезу, которая объясняет конкретное наблюдение и допускает маленький эксперимент.
  4. Выберите обратимое действие с ограниченным радиусом. Запишите trigger, ожидаемый сигнал и условие возврата.
  5. Проверьте действие на том же входе. Не расширяйте результат на неизвестные пути.
  6. Если проверка отрицательна, сохраните этот факт и вернитесь к следующей гипотезе. Не объявляйте обход успешным задним числом.
  7. Сформулируйте профилактику как изменение контракта, теста или наблюдения. Укажите отдельный критерий её выполнения.
\n

Ограничения модели

\n

Разделение состояний не заменяет мониторинг, резервирование, контроль доступа, процедуру отката и техническое расследование. Оно не вычисляет корневую причину автоматически. Одна ошибка может иметь несколько условий: потерю поля, отсутствие проверки и слишком широкий обход. Для каждого условия нужны собственные наблюдения и проверки.

\n

Учебный код не измеряет доступность, задержку, нагрузку, время восстановления или влияние на пользователей. Он не подтверждает, что конкретный mapper вызвал реальный отказ. В production нужно проверить трассировку поля, фактический контракт адаптера, версию схемы, права, данные и поведение внешних зависимостей. Если этих данных нет, вывод нужно ограничить известным сценарием.

\n

Критерий готовности

\n

Разбор можно считать технически готовым, когда другой инженер без устного пересказа может назвать симптом, увидеть evidence, понять проверяемую гипотезу, повторить действие на ограниченном входе и получить ожидаемый сигнал. Он также должен видеть, что не проверено, какой отрицательный результат меняет направление поиска и какое изменение защищает систему в будущем.

\n

Практическая финальная проверка проста: удалите из текста слова «исправили» и «добавили тест» и замените их конкретными условиями. Если после этого остаются вход, граница, действие, сигнал, откат и критерий профилактики, запись пригодна для работы. Если остаётся только уверенный пересказ, расследование ещё не закончено.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/255.json b/editorial/agent-rewrites/255.json new file mode 100644 index 0000000..6ff983f --- /dev/null +++ b/editorial/agent-rewrites/255.json @@ -0,0 +1,7 @@ +{ + "index": 255, + "slug": "editorial-2020-12-practice-incident-review", + "title": "Разбор инцидента: как отделить факт от поспешного исправления", + "excerpt": "Сервис отвечает ошибкой, команда меняет timeout, а причина остаётся неизвестной. Разбираем учебный случай через наблюдение, гипотезу, ограниченное действие, проверку и профилактику.", + "contentHtml": "

Сервис предварительного расчёта заказа начал возвращать blocked вместо цены. Ошибка видна пользователю, но её граница не видна инженеру: поле могло исчезнуть во входе, mapper-е или контракте downstream-сервиса. Цена поспешной правки — не только несколько минут простоя. Новый retry может увеличить нагрузку, timeout может спрятать отказ, а возврат старой версии может оставить причину без защиты. Через месяц тот же дефект вернётся под другим сообщением.

\n

Тезис: разбор начинается с сохранения наблюдаемого факта. Затем команда формулирует одну проверяемую гипотезу, выбирает ограниченное действие и заранее называет проверку. Временный обход не становится причиной, а успешный ответ не доказывает готовность системы. Такая последовательность нужна и маленькому модулю, и аварии, в которой участвуют несколько команд.

\n

Сначала зафиксировать симптом

\n

Симптом отвечает на вопрос «что заметил пользователь или мониторинг?». Запишите маршрут, состояние, окно времени и цену повторения. Например: POST /preview-order возвращает blocked для заказа с валютой RUB; расчёт не показывается; повторная попытка не помогает. Это достаточно точное начало. Фраза «сломался mapper» уже содержит гипотезу, поэтому её нельзя использовать как исходный факт.

\n

Следом за симптомом нужны одно или два наблюдения. У каждого должна быть граница и способ повторного получения. В учебном случае первое наблюдение относится к результату вызова, второе — к входу адаптера. Пока мы не знаем, где исчезло поле. Наблюдение должно пережить смену гипотезы: если виноватым окажется не mapper, запись E2 всё равно останется верной.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
preview-order возвращает blockedОбязательное поле не дошло до адаптераСравнить вход до и после mapper-аОстановить новый mapper на учебной ветке
В логах есть «mapper error»Сообщение приняли за доказанную причинуНайти точку записи и исходное значение поляНе менять timeout до проверки контракта
После возврата версии ответ стал allowedОбход вернул старое поведение, но причина не подтвержденаПовторить контролируемый сценарий и проверить границуОставить результат как mitigation, открыть проверку причины
Ошибка исчезла на одном запросеОдин успех приняли за устойчивое исправлениеПроверить отрицательный путь и повторНе объявлять готовность без критерия
\n

Механизм: пять разных записей

\n

Хорошая временная шкала не пересказывает события задним числом. Она связывает разные типы утверждений. Симптом показывает ущерб. Наблюдение фиксирует значение на границе. Гипотеза объясняет одно наблюдение, но остаётся изменяемой. Действие меняет ограниченный участок. Проверка измеряет эффект именно этого действия. Профилактика меняет будущий путь и имеет собственную проверку.

\n

Если написать «mapper сломал заказ, мы откатили его и добавили тест», читателю приходится восстанавливать всю причинную цепочку. Неясно, видел ли кто-то отсутствие поля. Неясно, что именно откатили. Неясно, какой тест должен пройти. Разделение записей делает вывод скромнее, зато его можно проверить.

\n
const incident = [{ id: 'E1', kind: 'observation', value: 'preview-order -> blocked' }, { id: 'E2', kind: 'observation', value: 'pricingInput.currency === undefined' }, { id: 'H1', kind: 'hypothesis', basedOn: ['E2'], value: 'mapper drops currency' }, { id: 'A1', kind: 'action', basedOn: ['H1'], value: 'use known mapper in demo branch' }, { id: 'V1', kind: 'verification', basedOn: ['A1'], expect: 'state === allowed' }]; const validOrder = incident.every((event, position, all) => position === 0 || all[position - 1].id !== event.id);
\n

Код выше — учебный пример в памяти процесса. Он не подключается к HTTP, очереди, базе, логам или production. Его задача — показать форму записи: позднее событие ссылается на более раннее, а проверка относится к действию. Такая модель не доказывает причину автоматически. Она не даёт перепутать гипотезу с наблюдением и не позволяет назвать «готово» без ожидаемого результата.

\n
Учебная временная шкала разбора: симптом, наблюдения, гипотеза, ограниченное действие, проверка и отдельная профилактика
Схема показывает зависимость событий. Наблюдение предшествует гипотезе, действие — проверке, а профилактика не выдаётся за выполненную работу.
\n

Пример: обязательное поле исчезло на границе

\n

Представим два mapper-а. Старый переносит amount и currency. Новый собирает объект из разрешённого списка полей, но в список попал только amount. Downstream-адаптер принимает объект, проверяет валюту и возвращает blocked. Мы видим только конечное состояние, поэтому нельзя сразу утверждать, что ошибка возникла в новом mapper-е.

\n
function mapPreview(input) { return { amount: input.amount }; } function checkPricingInput(mapped) { if (mapped.currency === undefined) return { state: 'blocked', reason: 'currency-missing' }; return { state: 'allowed' }; } const mapped = mapPreview({ amount: 100, currency: 'RUB' }); const result = checkPricingInput(mapped);
\n

Этот фрагмент ограничен учебным сценарием. Он не описывает конкретную библиотеку валидации и не сообщает о реальном инциденте. Проверка причинной версии должна смотреть на вход и выход границы, а не только на финальный ответ. Минимальный тест может сравнить объект до mapper-а с объектом после него и явно потребовать currency.

\n
const input = { amount: 100, currency: 'RUB' }; const mapped = mapPreview(input); if (mapped.currency !== input.currency) throw new Error('currency was lost at mapper boundary');
\n

Если этот тест падает, гипотеза получает сильное подтверждение, но сам тест не показывает, как исправлять код. Ограниченное действие может вернуть известный mapper только на отдельной ветке или выключить новый маршрут. После него нужно повторить тот же вход и проверить ожидаемое состояние. Если тест не падает, H1 надо заменить: поле исчезло раньше, вход сформирован неверно или downstream читает другое имя.

\n

Действие не равно устранению причины

\n

Во время инцидента допустимо сначала остановить ухудшение. Такое действие называют mitigation: оно снижает воздействие, но не закрывает причинный вопрос. Запись должна показать границу действия. «Вернули старый mapper» лучше записать как «для маршрута preview-order отключили новый mapper; другие маршруты не изменяли; ожидаем повторный ответ allowed на контролируемом входе».

\n

Не выбирайте retry или timeout только потому, что они привычны. Повтор помогает при временном отказе, но не возвращает пропущенное поле. Больший timeout меняет длительность ожидания, но не контракт. Если наблюдение указывает на отсутствие значения, проверка должна пройти через значение. Если новое наблюдение укажет на 503 от зависимости, появится другая гипотеза и другая проверка.

\n

Отрицательный путь обязателен. Если контрольный вызов снова дал allowed, проверьте заказ без валюты. Он должен получить предсказуемый отказ, а не пройти дальше. Проверьте повтор после возврата. Проверьте, что изменение не коснулось маршрутов, которые не участвовали в симптоме. Иначе команда докажет только один удачный ответ.

\n

Кто и что держит во время сбоя

\n

Фактологичный разбор не ищет виноватого человека, но требует технической ответственности. Должны быть понятны владелец решения, исполнитель изменения и канал обновлений. Один человек держит общую картину и решает, что делать дальше. Другой выполняет изменение. Третий, если есть такая возможность, фиксирует состояние и проверяет результаты. Для маленькой команды роли могут совмещаться, но функции нельзя потерять.

\n

Это особенно важно, когда несколько инженеров одновременно меняют систему. Если каждый «просто посмотрит» и запустит свою правку, временная шкала перестанет объяснять результат. Запишите действие до запуска, его область и ожидаемый сигнал. Не смешивайте в одной команде откат, изменение конфигурации и проверку нового mapper-а. Иначе после успеха нельзя будет понять, что именно помогло.

\n

Порядок действий

\n
  1. Зафиксируйте маршрут, состояние, окно времени и цену повторения. Не называйте причину.
  2. Сохраните один или два факта на границах: вход, выход, код ответа, поле или лог с точным местом записи.
  3. Проверьте, что факты можно получить снова без изменения системы. Если нельзя, отметьте пробел.
  4. Сформулируйте одну гипотезу и укажите, на какое наблюдение она опирается.
  5. Выберите ограниченное действие с владельцем, областью, условием отмены и ожидаемым результатом.
  6. Проверьте действие контролируемым сценарием. Повторите успешный и отрицательный путь.
  7. Если проверка не прошла, отмените действие в разрешённых границах или смените гипотезу. Не переписывайте исходное наблюдение.
  8. Разделите mitigation и исправление причины. Для каждого укажите собственный статус и следующий check.
  9. Добавьте профилактику: обязательное поле в контракт, тест на границе, сигнал или runbook с владельцем.
  10. Закройте разбор только после проверки профилактики и явного критерия готовности.
\n

Ограничения

\n

Пять типов записи не заменяют мониторинг, резервирование, коммуникацию или анализ безопасности. Они не определяют допустимое время восстановления и не говорят, какой rollback безопасен для конкретной базы. При внешнем побочном эффекте нужно отдельно проверить идемпотентность, повторную доставку и состояние данных. Выключение маршрута не отменяет уже отправленный платёж, созданный заказ или опубликованное сообщение.

\n

Учебные значения и имена в коде вымышлены. Здесь нет production-метрик, реального трафика, подтверждённого времени восстановления или доказательства, что конкретная команда применяла этот сценарий. Не переносите allowed, blocked и выбранный mapper в свою систему без проверки её контракта. Если граница неизвестна, безопасное действие — остановить расширение изменения и собрать недостающие данные.

\n

Не всякий сбой требует большого postmortem. Но если затронут пользовательский путь, вовлечена вторая команда, повторяется ошибка или исправление меняет состояние данных, короткая запись должна превратиться в отдельный разбор с владельцем и follow-up. Нельзя объявлять профилактику выполненной только потому, что её записали.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда другой инженер без устного пересказа может назвать исходный симптом, увидеть два наблюдения, проследить ссылку от гипотезы к действию и найти проверку действия. Для отрицательного пути известны ожидаемый отказ и граница его действия. Mitigation отделена от устранения причины. Профилактика имеет владельца, срок или очередь и собственный способ проверки.

\n

Практический критерий простой: запись позволяет повторить контрольный сценарий, получить ожидаемый результат, увидеть предсказуемый отказ на плохом входе и объяснить, что изменится при провале проверки. Если причина ещё неизвестна, это честно указано. Если действие временное, указана граница возврата. Если хотя бы одного элемента нет, разбор не готов к закрытию.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/256.json b/editorial/agent-rewrites/256.json new file mode 100644 index 0000000..e6b82be --- /dev/null +++ b/editorial/agent-rewrites/256.json @@ -0,0 +1,7 @@ +{ + "index": 256, + "slug": "editorial-2020-11-field-security-baseline", + "title": "CSRF не должен быть обещанием: как проверить защиту административного POST", + "excerpt": "Старый административный POST может принять действие из чужого контекста, даже если в чеклисте написано «CSRF включён». Разбираем механизм, отрицательные проверки и критерий готовности без сканирования живого приложения.", + "contentHtml": "

Симптом простой: в release checklist написано «CSRF включён», но никто не может показать запрос, который сервер отклонил из чужого контекста. Старый маршрут POST /admin/profile принимает cookie сессии и обновляет профиль. Форма работает, тест на успешный ответ зелёный, а проверка происхождения запроса отсутствует или живёт только в новом controller. Цена ошибки — не красивый warning. Сайт злоумышленника может заставить браузер администратора отправить нежелательное изменение с его действующей сессией. Ошибка меняет данные до того, как её заметят в журнале.

\n

Тезис статьи такой: baseline безопасности нужно доказывать не наличием настройки, а независимым отказом на каждом опасном входе. Для одного state-changing маршрута достаточно описать контракт запроса, поставить server-side gate до побочного эффекта, проверить положительный и отрицательные случаи и отдельно подтвердить заголовки ответа. Ниже — учебный пример. Он не проверяет живой сайт, не заменяет аудит и не утверждает production-результат.

\n

Механизм: cookie подтверждает сессию, но не намерение

\n

Браузер автоматически прикладывает cookie сессии к запросам к знакомому сайту. Сервер видит знакомого пользователя, но из одной cookie не узнаёт, сам ли пользователь нажал кнопку. Запрос мог начаться на другом сайте. CSRF-токен добавляет второй сигнал: форма или клиент получают значение через доверенный контекст, а сервер сравнивает его с ожидаемым значением до изменения состояния.

\n

Проверка должна находиться на сервере и идти до вызова, который пишет в базу, отправляет платёж, меняет роль или запускает другой необратимый эффект. Проверка в JavaScript не заменяет серверную: скрипт можно не выполнить, изменить или обойти. SameSite у cookie снижает часть cross-site запросов, но не превращает маршрут в универсально защищённый контракт. Для критичного действия нужны явные условия метода, сессии, CSRF-токена и полномочия.

\n
async function updateProfile(request, response) {\n  if (request.method !== 'POST') {\n    return response.status(405).end();\n  }\n\n  const session = await readSession(request);\n  if (!session) {\n    return response.status(401).end();\n  }\n\n  if (!sameOrigin(request) || !validCsrfToken(request, session)) {\n    return response.status(403).end();\n  }\n\n  if (!session.permissions.includes('profile:update')) {\n    return response.status(403).end();\n  }\n\n  await profiles.update(session.userId, request.body);\n  return response.status(204).end();\n}
\n

Это учебный код, а не готовая библиотека. Функция validCsrfToken должна сравнивать значения безопасным способом и иметь понятную область действия. sameOrigin не должен доверять произвольному заголовку от клиента без политики приложения. В реальном проекте нужно учесть reverse proxy, смену схемы, несколько доменов и способ доставки формы. Сначала зафиксируйте эти условия. Потом пишите проверку.

\n

Что именно нужно доказать

\n

У маршрута есть актив: профиль администратора. Есть субъект: пользователь с сессией. Есть действие: изменение профиля. Есть вход: method, path, cookie, origin, token и permission. Есть эффект: запись в хранилище. Такая карта ограничивает проверку. Мы не заявляем, что проверили всё приложение. Мы заявляем только то, что четыре конкретных входа дают ожидаемые решения, а побочный эффект вызывается после gates.

\n

Положительный случай нужен, чтобы не принять полный запрет за защиту. Сервер должен разрешить запрос с правильным method, доверенным origin, действующей сессией, совпадающим token и правом profile:update. Три отрицательных случая меняют по одному условию. Чужой origin должен получить 403. Пустой или неверный token должен получить 403. Сессия без write permission тоже должна получить 403. Если менять сразу несколько полей, причина решения потеряется.

\n
\"Схема
Учебная схема связывает каждый симптом с узкой проверкой и следующим действием. Asset показывает порядок рассуждения, а не результат проверки живого сервера.
\n

Симптом → причина → проверка → действие

\n
Учебная матрица для POST /admin/profile
СимптомПричинаПроверкаДействие
Чужой origin получает успешный ответOrigin не входит в gate или проверяется после updateПодать только Origin: https://other.example.test и ожидать 403Поставить проверку до побочного эффекта и перечислить допустимые origin
Пустой token проходитПоле потерялось между формой и controller или проверка стала необязательнойПередать пустой csrfToken при прочих правильных поляхТребовать совпадение с серверным значением; сам token не писать в лог
Read-only пользователь меняет профильUI-роль приняли за server-side permissionОставить сессию действующей, заменить permission на profile:readПроверять profile:update перед update и покрыть соседние write-маршруты
Ответ не содержит ожидаемой policyЗаголовок добавляется только для главной страницы или теряется на proxyПроверить один разрешённый ответ на стенде и сравнить поля contractЗакрепить область заголовка, проверить версию сервера и путь доставки
\n

Матрица не назначает severity и не выдаёт сертификат соответствия. Она связывает наблюдение с одной гипотезой. Если foreign origin получил 403 в локальном unit-тесте, это доказывает только решение этой функции на её входе. Оно не доказывает, что reverse proxy пропустит тот же заголовок, что браузер отправит его так же или что другой маршрут не обновляет тот же объект.

\n

Контракт запроса и ответа

\n

Сначала запишите безопасный учебный trace. Не используйте реальные cookie, токены, адреса пользователей и идентификаторы записей. Пример ниже показывает форму данных и порядок ответа. Он не является командой для запуска против чужого или production-сервера.

\n
POST /admin/profile HTTP/1.1\nHost: admin.example.test\nOrigin: https://admin.example.test\nCookie: sid=TRAINING_SESSION\nContent-Type: application/x-www-form-urlencoded\n\ncsrfToken=TRAINING_TOKEN&displayName=Example
\n
HTTP/1.1 204 No Content\nContent-Security-Policy: default-src 'self'; frame-ancestors 'none'\nSet-Cookie: sid=TRAINING_SESSION; Path=/; Secure; HttpOnly; SameSite=Lax
\n

В этом trace значения синтетические. Заголовок Origin помогает определить контекст запроса, но не заменяет token и permission. HttpOnly ограничивает доступ к cookie из JavaScript, Secure требует защищённого транспорта, а SameSite задаёт правила отправки cookie. Эти атрибуты уменьшают поверхность атаки, но не исправляют ошибку авторизации в controller. CSP ограничивает ресурсы, которые браузер может загрузить, и помогает с отдельными классами атак. Она не заставляет сервер отличать намеренный POST от подделанного.

\n

Не смешивайте response contract с доказательством доставки. Если header есть в fixture, он есть в fixture. Чтобы говорить о стенде, нужно получить один фактический ответ через разрешённый канал, учесть proxy и записать версию компонентов. Без этого формулировка должна быть «ожидаемая policy», а не «policy включена».

\n

Порядок действий

\n
  1. Назовите один актив, один state-changing маршрут и один эффект. Формулировка «проверить весь сайт» слишком широка для первого baseline.
  2. Опишите положительный request contract: method, path, origin, наличие сессии, CSRF-токен и требуемое permission. Не включайте секреты.
  3. Проверьте, что каждый gate выполняется на сервере до записи или другого побочного эффекта. Зафиксируйте порядок в коде или тесте.
  4. Сделайте один разрешённый synthetic request и минимум три отрицательных. В каждом отрицательном случае меняйте только одно условие.
  5. Проверьте status, отсутствие вызова update для отказанных случаев и безопасный объём логирования. Токены и cookie в логи не попадают.
  6. Сверьте фактический ответ разрешённого стенда с response contract: Set-Cookie, CSP и область применения заголовков. Не переносите вывод на другие маршруты без отдельной проверки.
  7. Если отрицательный случай разрешён или побочный эффект вызывается до проверки, остановите выпуск этого маршрута. Сначала верните явный gate, затем повторите весь набор.
\n

Как разбирать отрицательный путь

\n

Если чужой origin прошёл, не добавляйте CSP в надежде закрыть проблему. CSP управляет поведением браузера, а решение о записи принимает сервер. Найдите место, где controller вызывает update, и поставьте проверку до него. Затем добавьте тест, который меняет только origin. Если тест проходит, он фиксирует именно этот регресс.

\n

Если пустой token прошёл, проверьте связку формы и controller. Частая ошибка — условие вида «проверить token, если поле присутствует». Для state-changing действия отсутствие поля должно быть отказом. Не исправляйте симптом, отключая проверку для старого клиента. Сначала выясните контракт клиента и выберите явную совместимую миграцию.

\n

Если read-only пользователь прошёл, ищите владельца полномочия на сервере. Скрытая кнопка в интерфейсе не является authorization. Permission должен проверяться для конкретного действия и конкретного ресурса. Нужен также отрицательный тест для соседнего пользователя, чтобы случайная подмена идентификатора не открыла чужой профиль.

\n

Если ожидаемый header пропал, отделите генерацию ответа от доставки. Проверьте route, middleware, reverse proxy и кэш. Не называйте policy действующей, пока разрешённый стенд не вернул её фактически. Если header добавляет proxy, проверьте, что внутренний ответ не обходится другим путём.

\n

Ограничения учебной проверки

\n

Этот материал не сканирует приложение и не проверяет CVE. Он не оценивает TLS, пароли, загрузку файлов, CORS, SQL-инъекции, clickjacking во всех браузерах, гонки, восстановление после ошибки или другие endpoints. Он не подтверждает соответствие OWASP ASVS и не даёт production-результат. Synthetic request показывает форму рассуждения и границу теста. Для реального вывода нужны владелец системы, разрешённый стенд, фактический HTTP-ответ, журналы без секретов и отдельный охват остальных маршрутов.

\n

Есть и технические ограничения. Сравнение origin зависит от схемы, host и proxy-конфигурации. Политика cookie зависит от домена, пути, TLS и сценария авторизации. CSRF-токен должен иметь жизненный цикл, привязанный к выбранной модели сессии. Нельзя копировать код из примера без проверки фреймворка, версии сервера и способа маршрутизации. Учебный фрагмент ограничен намеренно: он показывает механизм, но не скрывает места, где проект должен принять собственное решение.

\n

Проверяемый критерий готовности

\n

Baseline для этого маршрута готов, когда положительный synthetic request проходит, каждый отрицательный случай получает ожидаемый отказ, а update не вызывается ни для одного отказа. На разрешённом стенде фактический ответ совпадает с зафиксированным response contract. В записи проверки указаны маршрут, граница вывода, версия компонентов и оставшиеся непроверенные пути. Если хотя бы один из этих пунктов отсутствует, результат нужно назвать частичным.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/257.json b/editorial/agent-rewrites/257.json new file mode 100644 index 0000000..80525b7 --- /dev/null +++ b/editorial/agent-rewrites/257.json @@ -0,0 +1,7 @@ +{ + "index": 257, + "slug": "editorial-2020-11-mechanism-security-baseline", + "title": "Граница запроса: как собрать минимальный security baseline для state change", + "excerpt": "Один защищаемый POST показывает, почему сессия, CSRF-контекст, permission и response policy решают разные задачи. В статье есть учебный контракт, матрица проверки и критерий готовности без иллюзии production-аудита.", + "contentHtml": "

Симптом выглядит безобидно: старый POST /admin/profile возвращает успешный ответ, если браузер прислал cookie сессии. При этом запрос с чужого origin или ролью только для чтения тоже доходит до изменения display name. В логах есть «пользователь вошёл», а отдельного доказательства права на действие нет. Цена ошибки — не только изменённое поле. Команда принимает identity за authorization, считает наличие headers защитой маршрута и выпускает код с ложным verdict.

\n

Тезис простой: минимальный security baseline — это не список cookie flags и не один заголовок. Это цепочка независимых решений до побочного эффекта: сервер узнаёт субъекта, проверяет контекст state change, проверяет право на конкретный ресурс и только потом меняет состояние. Ответ добавляет свои ограничения для браузера, но не заменяет проверки запроса.

\n

Что именно защищает baseline

\n

Возьмём учебную HTML-форму на origin https://admin.example.test. Она меняет имя профиля. Активом здесь является запись профиля, а опасным эффектом — запись нового значения в базу. Нам не нужно сразу описывать весь сайт. Для первой проверки достаточно назвать один маршрут, один актив, одного субъекта и один эффект.

\n

Сессия отвечает на вопрос «кого сервер узнал». Она не отвечает на вопрос «может ли этот субъект менять профиль». Origin и CSRF-токен связывают запрос с ожидаемым контекстом формы. Они не выдают permission. Permission отвечает на вопрос «разрешено ли этому субъекту именно это действие». CSP и атрибуты cookie ограничивают поведение браузера и передачу состояния. Они не валидируют бизнес-операцию.

\n

Такое разделение помогает найти отрицательный путь. Если неизвестная сессия получает 401, это проверяет только identity gate. Если известная сессия с permission profile:read получает 403, срабатывает authorization gate. Если запрос из чужого origin или без токена получает 403 до вызова update, проверяется контекст формы. Один положительный ответ не доказывает ни один из этих отрицательных сценариев.

\n
\"Схема
Границы запроса идут последовательно. Cookie сообщает о состоянии сессии, permission разрешает действие, а CSP остаётся дополнительным ограничением ответа.
\n

Механизм: решения до побочного эффекта

\n

Удобно представить обработчик как функцию, которая сначала строит решение, а потом вызывает запись. Каждый gate должен вернуть понятный отказ. Нельзя обновлять профиль, а затем выяснять, был ли origin допустимым. Нельзя переносить permission в шаблон или проверять его только в JavaScript: клиент может отправить запрос напрямую.

\n
async function updateProfile(request) {\n  const session = await sessions.find(request.cookies.sid);\n  if (!session) return response(401);\n\n  if (request.origin !== 'https://admin.example.test') {\n    return response(403);\n  }\n\n  if (!csrf.verify(request.body.csrf, session.csrfSecret)) {\n    return response(403);\n  }\n\n  if (!session.permissions.includes('profile:write')) {\n    return response(403);\n  }\n\n  await profiles.update(session.userId, {\n    displayName: validateDisplayName(request.body.displayName),\n  });\n\n  return response(204, {\n    'Content-Security-Policy': \"default-src 'self'; frame-ancestors 'none'\",\n    'Set-Cookie': 'sid=...; Path=/; Secure; HttpOnly; SameSite=Lax',\n  });\n}
\n

Это не готовая библиотека и не рекомендация копировать код без адаптации. Пример показывает порядок. `sessions.find` подтверждает субъекта. Проверка origin и токена защищает контекст формы. `profile:write` проверяет право. `validateDisplayName` отвечает за контракт данных и всё равно не заменяет authorization. `response` формирует учебный контракт ответа. В production нужно использовать проверенные средства управления сессиями и CSRF, единый формат ошибок и атомарное правило: ни один отказ не вызывает update.

\n

Код также показывает отрицательный путь. При пустом токене обработчик завершится до изменения. При роли profile:read он завершится до изменения. При неизвестной сессии не появится запись в профиле. Учебный пример не запускает сервер, не открывает сеть и не утверждает, что настоящий браузер получил эти headers. Его verdict ограничен моделью входов и порядком решений.

\n

Cookie, CSP и permission нельзя смешивать

\n

Cookie переносит состояние между запросами. Атрибут Secure ограничивает отправку cookie HTTPS-сценарием, HttpOnly запрещает доступ к нему из обычного JavaScript, а SameSite задаёт правила отправки в cross-site-контексте. Эти свойства снижают риск, но не создают право profile:write. Сервер должен извлечь субъект из сессии и отдельно проверить разрешение.

\n

CSP ограничивает ресурсы, которые страница может загружать или исполнять. Политика с frame-ancestors 'none' помогает запретить встраивание страницы в frame. Она остаётся defense in depth. CSP не исправляет небезопасный SQL, не проверяет роль и не останавливает запрос, который уже прошёл application gate. Если header добавляет proxy, проверяйте его на реальном маршруте и на ошибочных ответах отдельно. Наличие строки в конфигурации не доказывает доставку header.

\n

Permission тоже имеет границу. Роль должна проверяться на сервере рядом с операцией, которая меняет актив. Общая проверка «пользователь администратор» часто слишком широка: разные административные функции имеют разные владельцы и последствия. Назовите минимальное разрешение. Для этой формы это profile:write, а не абстрактное admin.

\n

Матрица диагностики

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Запрос с чужого origin меняет профильНет проверки контекста формы или она стоит после updateОтправить тот же учебный input с другим origin и проверить отсутствие вызова updateПоставить server-side origin/CSRF gate до побочного эффекта
Роль read-only получает успешный ответСессия считается достаточной авторизациейОставить session валидной и заменить permission на profile:readПроверять точное право profile:write рядом с операцией
Cookie есть, но поведение сессии различаетсяНеполный или слишком широкий cookie contractПроверить Set-Cookie, HTTPS, область Path и cross-site-сценарийСузить атрибуты и подтвердить их на фактическом ответе сервера
CSP записан в конфигурации, но страница грузит лишний ресурсHeader не дошёл, policy шире нужного или маршрут обходит proxyПосмотреть response headers на странице и на ошибке, затем сопоставить policy с assetsИсправить точку выдачи или policy; не считать конфигурационный diff доказательством
Отказ виден клиенту, но запись уже появиласьПроверка выполняется после изменения или ошибка скрывает частичный успехСравнить audit/event и состояние профиля после каждого отрицательного inputСделать все gates предварительными и проверить транзакционную границу
\n

Матрица полезна тем, что связывает наблюдаемый симптом с одним экспериментом. Не меняйте одновременно cookie, middleware и permission. Если изменились три границы, следующий результат не показывает, что именно исправило проблему. Для каждого отрицательного случая меняйте одно условие и фиксируйте ожидаемый статус, отсутствие побочного эффекта и причину отказа.

\n

Порядок первой проверки

\n
  1. Назовите маршрут, актив и операцию. Запишите, какое состояние изменится и кто владеет этим состоянием.
  2. Опишите минимальный request contract без секретов: метод, path, учебный origin, факт сессии, CSRF-токен и permission.
  3. Сделайте один положительный случай и минимум три отрицательных: неизвестная сессия, чужой origin или пустой токен, валидная сессия с read-only permission.
  4. Проверьте, что каждый отрицательный случай останавливается до вызова update. Статус ответа сам по себе недостаточен: состояние должно остаться прежним.
  5. Проверьте response contract на успехе и отказе: cookie attributes, CSP, формат ошибки и отсутствие утечки лишних данных. Отдельно подтвердите фактические headers на разрешённом стенде.
  6. Запишите границы verdict. Укажите, что проверено в модели, а что требует отдельной проверки browser, proxy, TLS, API-клиентов и других маршрутов.
\n

Ограничения и отрицательный путь

\n

Этот baseline не является полным аудитом приложения. Он не проверяет хранение паролей, восстановление аккаунта, загрузку файлов, SQL injection, XSS в каждом выводе, CORS, rate limiting, управление секретами, зависимости, TLS, логи, резервное копирование и внешний сетевой периметр. Он также не доказывает, что CSP совместима со всеми клиентами, а cookie действительно доставляется через каждый proxy.

\n

Нельзя расширять verdict учебного теста фразой «маршрут защищён». Корректнее сказать: «в заданной модели запрос с неизвестной сессией, неверным контекстом и недостаточным permission не достигает state change; response contract содержит проверяемые поля». Для production нужны интеграционные проверки, реальные браузеры, наблюдение за отказами и отдельная модель угроз для каждого нового актива.

\n

Отрицательный путь важнее красивого положительного примера. Если update вызывается хотя бы при одном неверном входе, baseline не готов. Если header присутствует только на 200, а на 4xx исчезает, это отдельный дефект response policy. Если permission проверяется в UI, а не в обработчике, контроль отсутствует там, где его может обойти клиент.

\n

Проверяемый критерий готовности

\n

Маршрут можно считать прошедшим этот baseline, когда для одного явно названного state change сохранены положительный и отрицательные сценарии, каждый gate выполняется до побочного эффекта, а проверка подтверждает неизменность актива после отказа. На разрешённом стенде отдельно видны фактические cookie и CSP headers. В отчёте есть ссылка на маршрут, ожидаемые решения, полученные статусы, границы учебной модели и список непроверенных угроз. Если хотя бы одно из этих условий отсутствует, результат — не «безопасно», а «нужно продолжить проверку».

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/258.json b/editorial/agent-rewrites/258.json new file mode 100644 index 0000000..b09e640 --- /dev/null +++ b/editorial/agent-rewrites/258.json @@ -0,0 +1,7 @@ +{ + "index": 258, + "slug": "editorial-2020-11-practice-security-baseline", + "title": "Старая админка без CSRF: как проверить минимальный контур защиты", + "excerpt": "Один state-changing маршрут показывает, где заканчивается сессия, начинаются CSRF и permission, зачем нужен CSP и чем подтверждать отрицательный путь.", + "contentHtml": "

Симптом у старой админки простой: пользователь открывает форму, нажимает «Сохранить», и профиль меняется. Но тот же маршрут принимает запрос без CSRF-токена. В логах виден только действующий сеанс, поэтому команда принимает успешный ответ за доказательство безопасности. Цена ошибки зависит от права пользователя: посторонняя страница может заставить его браузер отправить действие с cookie, а запрос с известной сессией может изменить данные без явного намерения. Для администратора это может означать смену реквизитов, добавление пользователя или изменение настроек.

\n

Тезис статьи короткий: минимальный security baseline строится вокруг конкретного state change. Сначала сервер проверяет, кого он узнал. Затем проверяет контекст формы и право на действие. Только после этого меняет состояние. Cookie-флаги и CSP дополняют этот контур, но не заменяют ни CSRF-проверку, ни серверную авторизацию. Учебный пример ниже не доказывает защиту production-системы. Он показывает модель, которую можно проверить на одном маршруте.

\n

Где возникает ошибка

\n

Рассмотрим учебный маршрут POST /admin/profile. Он меняет только displayName. Браузер отправляет cookie с сессией автоматически. Если сервер решает «сессия есть — разрешаем», запрос с другого сайта выглядит для него почти так же, как запрос из формы админки. Поле Origin помогает проверить источник, а CSRF-токен добавляет значение, которого атакующий сайт не должен знать. Эти проверки относятся к контексту формы. Permission отвечает на другой вопрос: имеет ли распознанный пользователь право менять профиль.

\n

Порядок важен. Сначала отбрасываем неподходящий метод и маршрут. Потом получаем сессию. Для HTML-формы проверяем разрешённый origin и токен. Затем проверяем profile:write. Функция обновления не должна вызываться до последней проверки. Если permission спрятан только в интерфейсе, любой клиент сможет обратиться к endpoint напрямую. Если токен проверяется после update, защита появляется в отчёте, но не в поведении.

\n
\"Связь
Минимальный контур связывает действие с угрозой, контролем и наблюдаемым доказательством. Успешный ответ проверяет доступность функции, а отрицательные запросы проверяют границы.
\n

Один маршрут и несколько независимых контролей

\n

У сессии, CSRF, permission и CSP разные владельцы. Сессия связывает cookie с субъектом. CSRF-контроль проверяет, что state change пришёл в ожидаемом контексте. Permission ограничивает действие субъекта. CSP ограничивает ресурсы, которые браузер может загрузить на странице. Нельзя заменить permission атрибутом HttpOnly или закрыть XSS одной директивой CSP.

\n
function updateProfile(request) {\n  if (request.method !== 'POST' || request.path !== '/admin/profile') {\n    return { status: 404, reason: 'route-not-found' };\n  }\n\n  const session = sessions.find(request.cookies.sid);\n  if (!session) return { status: 401, reason: 'unknown-session' };\n\n  if (request.origin !== 'https://admin.example.test') {\n    return { status: 403, reason: 'unexpected-origin' };\n  }\n\n  if (!csrf.verify(session.id, request.body.csrfToken)) {\n    return { status: 403, reason: 'invalid-csrf-token' };\n  }\n\n  if (!session.permissions.includes('profile:write')) {\n    return { status: 403, reason: 'insufficient-permission' };\n  }\n\n  profileStore.setDisplayName(session.userId, request.body.displayName);\n  return { status: 204 };\n}
\n

Код намеренно похож на обычный серверный псевдокод. В нём нет настоящей библиотеки сессий, способа выдачи токена или базы данных. Это учебный пример: он показывает границу между проверкой и побочным эффектом. В реальном приложении токен должен генерировать и проверять поддерживаемый механизм фреймворка или отдельная проверенная библиотека. Значение из примера нельзя копировать как секрет. Также нужно валидировать и безопасно выводить displayName; CSRF не защищает от XSS и некорректной обработки ввода.

\n

Симптомы и действия

\n
Проверка одного state-changing маршрута
СимптомПричинаПроверкаДействие
Любой POST с cookie получает успехСессия принята за доказательство намеренияОтправить запрос без токена и с чужим origin на тестовом стендеОтклонять запрос до update и добавить отрицательный тест
Проверка права есть только в UIКнопка скрывает действие, но endpoint не авторизует егоВызвать маршрут ролью profile:readПроверять profile:write на сервере
CSRF включён на одной формеЗащита привязана к компоненту, а не к карте измененийПеречислить POST, PUT, PATCH и DELETE, которые меняют состояниеДля каждого маршрута определить контекст и механизм защиты
После CSP страница ломаетсяPolicy добавили без списка реальных ресурсовСравнить нарушения с нужными script, style, image и frameНачать с узкой policy, исправить зависимости и только потом включать enforcement
В отчёте есть cookie flags, но нет факта отказаНастройку приняли за наблюдаемое поведениеПроверить response headers и четыре ветки решенияХранить контракт ответа и положительный/отрицательные сценарии отдельно
\n

Cookie и CSP не должны маскировать главную проверку

\n

Для сессии обычно задают Secure и HttpOnly. Первый ограничивает отправку cookie HTTPS-сценарием, второй не даёт обычному JavaScript прочитать значение. Узкий Path может уменьшить область отправки. Но ни один из этих атрибутов не говорит, что пользователь имеет право на конкретный update. Сервер всё равно должен разобрать сессию и выполнить permission check. Для критического действия могут потребоваться повторная аутентификация или одноразовое подтверждение.

\n

CSP задаёт браузеру список разрешённых источников и может уменьшить последствия инъекции или встраивания страницы. Учебный вариант можно начать так:

\n
Content-Security-Policy:\n  default-src 'self';\n  script-src 'self';\n  object-src 'none';\n  base-uri 'self';\n  frame-ancestors 'none'
\n

Эта policy может сломать inline-скрипты, внешний analytics и embedded widget. Поэтому не добавляйте домены по одной ошибке в консоли. Сначала выпишите ресурсы конкретной страницы, проверьте, нужны ли они активу, и примените policy в режиме наблюдения, если это поддерживает ваша схема развёртывания. CSP не исправляет небезопасное экранирование, валидацию входа или доверие к пользовательскому HTML.

\n

Проверяемый отрицательный путь

\n

Положительный сценарий отвечает только на вопрос «маршрут работает». Для baseline важнее сохранить отказ там, где контроль должен сработать. На учебной среде нужны минимум четыре входа: корректная сессия, origin и токен с правом получают 204; отсутствующий токен получает 403; чужой origin получает 403; роль без profile:write получает 403. Не проверяйте этот пример на чужом сервисе и не используйте настоящие cookie или персональные данные.

\n

Логируйте причину отказа без токена и секретов. Для диагностики достаточно идентификатора маршрута, результата проверки и request id. Не записывайте значение CSRF-токена, session ID или полный body. Если реальный proxy переписывает заголовки, проверяйте также финальный HTTP-ответ разрешённого стенда. Unit-тест функции и проверка ответа веб-сервера доказывают разные вещи.

\n

Порядок внедрения

\n
  1. Назовите один актив и один маршрут, который меняет его состояние. Запишите клиентов маршрута: HTML-форма, API или интеграция.
  2. Разделите решения: сессия, контекст формы, permission и response policy. Для каждого укажите вход, владельца и момент проверки.
  3. Поставьте все server-side gates перед вызовом функции, которая меняет данные. Не полагайтесь на скрытую кнопку или проверку в JavaScript.
  4. Подключите CSRF-механизм фреймворка, если он есть. Для собственной схемы отдельно опишите выдачу, срок жизни, привязку и сравнение токена.
  5. Опишите cookie attributes и CSP для реального маршрута. Сверьте policy с ресурсами страницы, браузерами и конфигурацией веб-сервера.
  6. Добавьте положительный тест и отрицательные тесты для пустого токена, чужого origin, неизвестной сессии и недостаточного permission.
  7. Проверьте финальный ответ на разрешённом стенде. Сохраните только безопасные поля: статус, выбранный маршрут, тип отказа и request id.
  8. Запишите непокрытые зоны: XSS, SQL injection, upload, CORS, rate limit, восстановление аккаунта, TLS, секреты, зависимости и сетевой периметр.
\n

Ограничения и критерий готовности

\n

Этот baseline не является полным аудитом. Он не проверяет криптографию, конфигурацию CDN, реальный браузерный cache, доступность cookie на всех поддоменах, API-интеграции или права каждой роли. Origin может отсутствовать в некоторых допустимых контекстах, а один и тот же endpoint может обслуживать разные типы клиентов. В таком случае нельзя бездумно расширять исключения: нужно разделить контракты и описать отдельную модель угрозы.

\n

Работу можно считать готовой для одного маршрута, если документирован актив, а до изменения состояния проходят отдельные проверки сессии, контекста и permission; четыре учебных отрицательных/положительных сценария дают ожидаемые статусы; финальный ответ содержит согласованные cookie и CSP-поля; журнал не раскрывает секреты; команда явно записала непокрытые области. Это проверяемый критерий для узкого участка, а не заявление, что всё приложение защищено.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/259.json b/editorial/agent-rewrites/259.json new file mode 100644 index 0000000..124ce20 --- /dev/null +++ b/editorial/agent-rewrites/259.json @@ -0,0 +1,7 @@ +{ + "index": 259, + "slug": "editorial-2020-10-field-backup-recovery", + "title": "Резервная копия не равна восстановлению: как проверить backup без риска для источника", + "excerpt": "Архив можно увидеть и скачать, но это ещё не доказательство восстановления. Разбираем manifest, checksum, область данных, изолированную цель и проверку результата на учебном PostgreSQL-сценарии.", + "contentHtml": "

Симптом знакомый: в хранилище лежит свежий архив, команда видит его размер и дату, но никто не может уверенно сказать, что произойдёт после restore. Неясно, какие схемы попали в файл, совпадают ли его байты с теми, что указаны в журнале, куда пойдёт восстановление и чем считать результат успешным.

\n

Цена такой ошибки появляется в аварии. Оператор выбирает архив по имени, запускает привычную команду и обнаруживает неполный набор таблиц. Или восстанавливает данные поверх источника. В этот момент резервная копия не помогает: она превращается в ещё один объект, которому нужно доверять без проверки.

\n

Тезис статьи простой: backup готов к применению только тогда, когда команда может проверить его scope, целостность, безопасность цели и смысл результата. Наличие файла доказывает наличие файла. Оно не доказывает, что приложение сможет продолжить работу.

\n

Что именно нужно доказать

\n

Восстановление состоит из нескольких независимых вопросов. Первый — что архив выбран правильно. Второй — что его содержимое не изменилось после создания. Третий — что в него попала нужная область базы. Четвёртый — что команда не затронет источник. Пятый — что после восстановления появились ожидаемые схемы, таблицы и данные.

\n

Эти вопросы связывает короткий manifest. В нём хранят идентификатор архива, формат, область данных, исключения, SHA-256 и проверки результата. Manifest не заменяет сам архив и не делает восстановление автоматическим. Он задаёт договор, с которым можно сравнить фактический файл и фактический кандидат.

\n
{\n  "backupId": "training-catalog-2020-10-a",\n  "format": "pg_dump custom",\n  "artifact": {\n    "file": "training-catalog.dump",\n    "sha256": "..."\n  },\n  "scope": {\n    "includes": ["schema:catalog", "schema:reference"],\n    "excludes": ["cluster roles", "tablespaces", "external files"]\n  },\n  "checks": {\n    "relations": ["catalog.items", "reference.codes"],\n    "rows": { "catalog.items": 3, "reference.codes": 2 }\n  },\n  "target": "isolated training candidate only"\n}
\n

Пример учебный. Имена, числа и значение хеша вымышлены. Поле excludes здесь не декоративно: логический dump одной базы не следует выдавать за копию всего кластера. Роли, tablespaces и внешние файлы требуют отдельного решения и отдельной проверки.

\n
Схема проверки резервного архива: scope, checksum, безопасная цель и проверки восстановленных данных
Сначала проверяется договор архива и его байты, затем цель восстановления, затем структура и данные кандидата.
\n

Почему checksum не заменяет restore

\n

SHA-256 помогает ответить на узкий вопрос: совпадают ли байты фактического файла с ожидаемым digest. Если значение изменилось, файл нельзя считать тем же артефактом. Причиной может быть неполная передача, повреждение, подмена или выбор другого файла под похожим именем.

\n

Но одинаковый digest не доказывает правильный scope. Можно безошибочно сохранить неполный dump и получить идеальное совпадение checksum. Поэтому проверка идёт в два слоя: сначала bytes, потом содержимое архива и результат восстановления.

\n

Симптомы и безопасные действия

\n
Первая классификация проблемы до повторного restore
СимптомПричинаПроверкаДействие
Архив есть, но scope не описанФайл создавали без явного договораСверить список ожидаемых схем и исключений с командой созданияОстановить drill и уточнить scope
SHA-256 не совпадаетВыбран другой или повреждённый файлЗаново вычислить digest фактического файлаНе читать и не восстанавливать файл; получить доверенный артефакт
В списке нет нужной relationОна не попала в dump или фильтр сузил областьСравнить pg_restore --list с manifestИсправить создание архива или оформить зависимость отдельно
Цель совпадает с источникомRunbook не закрепил изоляциюПроверить имя базы и запрет перезаписи до командыНе запускать; создать отдельного кандидата
Restore завершился, но таблица пустаНеполный scope, неверная версия или слабый checkВыполнить read-only запросы к кандидату и сравнить expected rowsЗафиксировать отрицательный verdict и исправить договор
\n

Конкретный маршрут для PostgreSQL

\n

Для учебного custom archive можно сначала получить список объектов, не меняя базу. Команда pg_restore --list показывает, что инструмент видит внутри non-plain archive. Этот список нужно сравнить с manifest, а не с памятью оператора. Если ожидалась reference.codes, а её нет, повторный запуск restore не добавит объект.

\n
# Учебные имена. Команды не выполнялись этой статьёй.\nsha256sum training-catalog.dump\npg_restore --list training-catalog.dump\n\n# Только после проверок — отдельный кандидат.\ncreatedb training_restore_candidate\npg_restore --dbname=training_restore_candidate training-catalog.dump
\n

Перед последней командой нужно подтвердить, что training_restore_candidate не является источником и не содержит нужных рабочих данных. Если безопасной цели нет, правильный результат — «restore не проверен». Нельзя превращать отсутствие стенда в разрешение на риск.

\n

После восстановления проверяют не только код возврата процесса. Нужны конкретные read-only проверки: существуют ли ожидаемые схемы, совпадает ли набор relations, имеют ли таблицы ожидаемый тип и не пусты ли обязательные справочники. Количество строк — учебный критерий, а не обещание для настоящей базы. В production оно меняется, поэтому ожидаемое значение должно зависеть от снимка и бизнес-условия.

\n

Порядок действий

\n
  1. Назвать backup ID и прочитать manifest целиком: формат, файл, scope, исключения, digest, цель и checks.
  2. Сверить область восстановления с вопросом. Если нужная схема не включена, назвать это пробелом дизайна, а не ошибкой pg_restore.
  3. Вычислить SHA-256 фактического файла. При несовпадении остановить маршрут до просмотра содержимого.
  4. Получить список архива через pg_restore --list и сравнить его с ожидаемыми relations.
  5. Подтвердить изолированную базу-кандидат и запретить перезапись источника. Проверку цели выполнить до команды restore.
  6. Восстановить архив только в кандидата. Сохранить время начала, время окончания, версию инструмента и итог команды.
  7. Выполнить структурные и смысловые read-only checks. Сравнить фактический результат с manifest, не меняя expected values после факта.
  8. Записать непокрытые области: роли, tablespaces, внешние файлы, журналы изменений, требования к потере данных и допустимое время восстановления.
\n

Отрицательный путь важнее зелёного

\n

Хорошая проверка должна остановиться на плохом входе. Изменённый файл должен отклоняться по checksum. Суженный scope должен отклоняться по сравнению manifest и списка объектов. Источник должен быть недопустимой целью. Пустая relation должна приводить к отрицательному verdict, если приложение требует данные.

\n

Нельзя исправлять отрицательный результат подменой evidence. Не следует менять хеш, чтобы пройти gate, добавлять строки вручную после восстановления или переписывать expected count после обнаружения расхождения. Эти действия скрывают причину и делают следующий запуск менее надёжным.

\n

Ограничения метода

\n

Логический dump PostgreSQL покрывает не все способы восстановления. Копирование файлов кластера, continuous archiving и point-in-time recovery имеют другие предпосылки. Команда, которая проверила custom archive одной базы, не доказала восстановление всего кластера и не измерила готовность приложения.

\n

Учебный сценарий не проверяет сеть, права, секреты, размер production-данных, скорость передачи, репликацию, WAL, внешние object storage и работу зависимых сервисов. Он также не даёт RPO или RTO. Время учебной команды становится RTO только после измерения на разрешённой цели с описанными условиями. Дата архива сама по себе не является RPO.

\n

Если drill не может затронуть реальное окружение, это не недостаток статьи. Нужно отделить проверенный учебный контракт от непроверенной инфраструктуры и назначить следующий безопасный эксперимент. Такой итог точнее, чем заявление о полной готовности к аварии.

\n

Критерий готовности

\n

Минимальный проверяемый критерий выглядит так: команда предъявляет manifest и фактический archive; SHA-256 совпадает; список объектов покрывает заявленный scope; restore выполнен только в изолированной цели; источник не изменился; структурные и смысловые checks прошли; время и версия инструмента записаны; непокрытые области явно перечислены.

\n

Если хотя бы один пункт неизвестен, verdict должен быть ограниченным: «архив найден», «байты совпали» или «учебная база восстановилась». Нельзя сокращать его до «резервное копирование работает». Копия становится рабочим механизмом только там, где команда может повторить путь, увидеть отрицательный результат и безопасно остановиться.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/260.json b/editorial/agent-rewrites/260.json new file mode 100644 index 0000000..afe962f --- /dev/null +++ b/editorial/agent-rewrites/260.json @@ -0,0 +1,7 @@ +{ + "index": 260, + "slug": "editorial-2020-10-mechanism-backup-recovery", + "title": "Резервная копия PostgreSQL: как доказать, что её можно восстановить", + "excerpt": "Файл с совпавшим SHA-256 ещё не является рабочей резервной копией. Разбираем scope, manifest и безопасный restore на учебном примере PostgreSQL.", + "contentHtml": "

В хранилище лежит свежий файл catalog.dump. SHA-256 совпадает с записью в журнале. Во время сбоя оператор запускает восстановление, а нужной роли нет, таблица пуста или архив относится к другой схеме. Цена ошибки — потерянное время в самом дорогом окне и риск направить restore в источник, который ещё содержит единственную рабочую копию.

\n

Проблема не в одной команде. Слово «backup» смешивает четыре разных факта: данные прочитали, файл записали, файл можно разобрать, после restore получился нужный набор объектов. Checksum подтверждает только неизменность байтов. Код выхода 0 подтверждает только завершение конкретного процесса. Ни один из них не отвечает на вопрос, сможет ли приложение использовать восстановленную базу.

\n

Тезис простой: резервная копия должна иметь контракт. В контракте записывают границы данных, формат архива, контрольную сумму, ожидаемые объекты, исключения, безопасную цель и проверки после восстановления. Restore считается успешным не после запуска команды, а после прохождения этих проверок в изолированной цели.

\n

Сначала определите, что именно копируется

\n

pg_dump создаёт логический dump одной базы. Это не снимок всего кластера. Роли и другие cluster-wide объекты требуют отдельного решения и могут входить в область pg_dumpall. Tablespaces, файлы на диске, секреты и данные внешних систем также не появляются в обычном dump автоматически. Если они нужны приложению, их надо назвать отдельными артефактами или явно исключить из критерия.

\n

Выборочная копия схемы уменьшает размер, но увеличивает число предположений. Таблица может ссылаться на тип, функцию или другую схему, которую фильтр не включил. Поэтому строка scope.includes должна описывать не удобный путь команды, а минимальный набор, который нужен целевой базе. Если зависимость неизвестна, это пробел контракта, а не повод считать архив «почти полным».

\n
pg_dump --format=custom --file=training-catalog.dump training_catalog\npg_restore --list training-catalog.dump\nshasum -a 256 training-catalog.dump
\n

Команды выше — учебный пример. Имена базы и файла вымышлены. Они показывают форму последовательности, но не доказывают, что команда выполнялась и что архив пригоден для production-восстановления. Сначала зафиксируйте область, затем создавайте артефакт.

\n

Manifest связывает создание и восстановление

\n

Без manifest оператор выбирает файл по имени и дате. Такой выбор не описывает содержимое. Минимальная запись должна позволять другому человеку ответить на пять вопросов: какой это артефакт, в каком он формате, какие байты проверять, что входит в scope и каким наблюдением подтвердить результат.

\n
{\n  \"manifestVersion\": 1,\n  \"backupId\": \"training-catalog-2020-10-a\",\n  \"artifact\": {\"file\": \"training-catalog.dump\", \"format\": \"pg_dump custom (-Fc)\", \"sha256\": \"<hash>\"},\n  \"scope\": {\"includes\": [\"schema:catalog\", \"schema:reference\"], \"excludes\": [\"cluster roles\", \"tablespaces\", \"external files\"]},\n  \"restoreChecks\": {\"relations\": [\"catalog.items\", \"reference.codes\"], \"rows\": {\"catalog.items\": 3, \"reference.codes\": 2}},\n  \"safety\": \"только изолированный учебный кандидат\"\n}
\n

Поле artifact.sha256 относится к файлу до restore. Поля restoreChecks относятся к базе после restore. Их нельзя заменить одним флагом complete: true. Если байты изменились, маршрут останавливается до чтения архива. Если байты целы, но список объектов не совпадает, нужно разбирать scope или выбрать правильный артефакт.

\n
\"Путь
Restore проходит несколько границ. Каждая граница может остановить маршрут и сохранить конкретную причину отказа.
\n

Checksum проверяет файл, а не смысл

\n

SHA-256 полезен на границе хранения и передачи. Он обнаруживает изменённый, повреждённый или перепутанный файл. При несовпадении digest действие одно: остановить restore и получить доверенный артефакт заново. Нельзя «проверить дальше», потому что следующие результаты уже относятся к байтам, которым нельзя доверять.

\n

Совпавший digest не видит неверный scope. Два файла могут быть целыми и одинаково непригодными: оба могли содержать только одну из двух требуемых схем. Поэтому checksum — gate целостности, а не verdict восстановления. Оглавление архива и checks после restore отвечают на другие вопросы.

\n

Симптом → причина → проверка → действие

\n
Диагностика резервной копии и restore
СимптомПричинаПроверкаДействие
SHA-256 не совпалФайл изменился, повреждён или выбран не тот артефактПовторить digest и сверить путь с manifestОстановить restore, получить архив из доверенного источника
Архив читается, нужной relation нетScope слишком узкий или выбран другой dumpСравнить pg_restore --list с scope.includesИсправить путь создания или выпустить новую версию manifest
Роль отсутствует после restoreCluster-wide объект не входил в dump базыПроверить scope.excludes и список требуемых ролейВосстановить роли отдельным согласованным шагом или изменить контракт
Restore завершился, таблица пустаDump неполный, выбран не тот объект или check не соответствует даннымВыполнить безопасный row count и сверить его с manifestОстановить ввод данных, найти расхождение до переключения
Команда направлена в рабочую базуНе доказана изоляция целиПроверить адрес, имя и отдельные credentials кандидатаНе запускать restore; создать и явно подтвердить безопасную цель
\n

Таблица задаёт отрицательный путь. Любое расхождение переводит операцию в остановку. Нельзя продолжать до следующего шага только потому, что архив «свежий» или команда раньше уже работала. Причина должна попасть в запись проверки вместе с действием, которое вернёт маршрут к доверенному состоянию.

\n

Restore выполняйте в изолированном кандидате

\n

Цель должна быть отдельной базой или отдельным кластером с понятным именем, доступом и запретом на перезапись источника. Перед командой проверьте подключение и сохраните фактический endpoint. Учебный пример ниже не запускает PostgreSQL. Он показывает порядок и границу безопасности.

\n
# Учебная последовательность. Источник не перезаписывается.\ncreatedb training_restore_candidate\npg_restore --dbname=training_restore_candidate --exit-on-error training-catalog.dump\npsql training_restore_candidate --command=\"SELECT to_regclass('catalog.items');\"\npsql training_restore_candidate --command=\"SELECT count(*) FROM catalog.items;\"
\n

Флаг --exit-on-error не превращает restore в доказательство успеха. Он делает ранний отказ заметнее. После команды нужно отдельно проверить ожидаемые relations, версию схемы и безопасный набор синтетических строк. Проверка не должна менять источник, отправлять письмо, списывать деньги или обращаться к внешней системе.

\n

Порядок действий

\n
  1. Сформулируйте scope одним предложением: какая база, схемы и зависимые объекты должны появиться после restore.
  2. Перечислите исключения: роли, tablespaces, внешние файлы, секреты и другие ресурсы, которые не входят в этот артефакт.
  3. Создайте архив выбранного формата и запишите имя, размер, версию инструмента и SHA-256 в manifest.
  4. Прочитайте список архива и сравните его с ожидаемыми объектами. Несовпадение останавливает проверку.
  5. Создайте изолированный кандидат. Проверьте адрес, credentials и отсутствие маршрута записи в источник.
  6. Выполните restore с сохранением stderr и кода выхода. Не трактуйте пустой stderr как полную проверку.
  7. Запустите structural checks: relations, владельцы и версия схемы. Затем запустите безопасные semantic checks из manifest.
  8. Запишите verdict и список непроверенных областей. Только после этого решайте, нужна ли отдельная проверка ролей, внешних файлов, retention или переключения.
\n

Ограничения и критерий готовности

\n

Этот учебный маршрут не измеряет RPO, RTO, скорость выгрузки, стоимость хранения или длительность restore. Он не проверяет шифрование, права доступа, репликацию и автоматическое расписание. Logical dump не заменяет физическое резервирование, continuous archiving или план восстановления всего кластера. Подход также не отвечает за данные, которые живут вне PostgreSQL.

\n

Не называйте пример production-результатом. Учебные имена, числа строк, checksum и результаты здесь не являются отчётом о реальной базе. В рабочей системе значения должен получить сам запуск на разрешённом стенде. Версию PostgreSQL и версию клиента нужно записать рядом с результатом: формат и поведение инструментов должны соответствовать поддерживаемой конфигурации.

\n

Критерий готовности проверяемый: для конкретного manifest другой оператор может найти нужный архив, подтвердить его digest, увидеть заявленные объекты, восстановить его только в изолированной цели и получить ожидаемые checks. При изменённом файле, неполном scope, неизвестной цели или расхождении результата маршрут останавливается с понятной причиной. Пока это не доказано повторяемым drill, в наличии есть файл, но нет подтверждённой резервной копии.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/261.json b/editorial/agent-rewrites/261.json new file mode 100644 index 0000000..1fe77be --- /dev/null +++ b/editorial/agent-rewrites/261.json @@ -0,0 +1,7 @@ +{ + "index": 261, + "slug": "editorial-2020-10-practice-backup-recovery", + "title": "Резервная копия не равна восстановлению: как проверить PostgreSQL-архив", + "excerpt": "Файл backup может существовать, читаться и всё равно не содержать нужный объём данных. Разбираем scope, checksum и безопасный restore в изолированную базу.", + "contentHtml": "

Ночью задача резервного копирования завершилась без ошибки. Утром в хранилище появился файл с сегодняшней датой. Это хороший признак, но не доказательство восстановления. Пока никто не прочитал архив, не сверил его состав и не поднял копию в отдельной базе, команда знает только одно: программа записала какой-то файл.

\n

Цена ошибки проявится во время сбоя. Архив может содержать одну схему вместо двух, не включать роль или зависимость, а команда восстановления может указывать на исходную базу. Тогда оператор теряет время, рискует перезаписать рабочие данные и узнаёт границы копии в худший момент. Тезис статьи простой: backup считается пригодным не по имени файла и не по коду выхода команды, а по проверяемому маршруту от scope до безопасного restore.

\n

Сначала фиксируем вопрос восстановления

\n

Резервная копия отвечает на вопрос «что удалось сохранить». Восстановление отвечает на другой вопрос: «можно ли из этих байтов собрать нужный набор объектов в разрешённой цели». Между вопросами стоят формат архива, версия инструмента, зависимости, права, роли, tablespaces и внешние файлы.

\n

В этом примере рассматриваем только логический dump одной учебной базы PostgreSQL. Нужный набор состоит из схем catalog и reference. Роли кластера, tablespaces и файлы вне базы в scope не входят. Если scope не записан заранее, после сбоя легко принять неполную копию за полную.

\n
Схема проверки резервной копии: scope, archive, manifest, checksum, изолированная цель и проверки после restore
Проверка начинается с описанного scope. Checksum проверяет байты архива, а запросы после restore проверяют состав результата.
\n

Scope отделяет нужную копию от похожего файла

\n

У каждой копии должна быть граница. Запишите базу, формат, включённые схемы и исключения до запуска dump. Поле complete без расшифровки почти бесполезно: оно сообщает мнение оператора, но не показывает, какие объекты вошли в архив.

\n
Диагностика backup и restore
СимптомПричинаПроверкаДействие
Файл есть, но ожидаемой таблицы нет в спискеВыбран не тот архив или слишком узкий scopepg_restore --list и manifestОстановить restore и исправить scope
Checksum не совпалФайл изменился, обрезан или перепутанПовторно вычислить SHA-256 тех же байтовНе восстанавливать; получить архив заново
Restore завершился, но схема пустаяDump не включил зависимость или данныеПроверить scope, список объектов и row countРасширить scope или изменить контракт
Команда требует роль или tablespaceОбъект относится к кластеру, а не только к базеСверить область dump и требования targetПодготовить отдельную проверку
Цель не удаётся отличить от источникаВ runbook нет защитной границыПроверить имя и подключениеНе запускать restore до создания кандидата
\n

Таблица задаёт порядок мышления. Каждый симптом получает собственную проверку. Размер файла не заменяет ни одну из них: большой архив может быть неполным, а маленький — правильным для узкого scope.

\n

Manifest связывает архив с ожидаемым результатом

\n

Положите рядом с архивом manifest. В нём достаточно хранить идентификатор копии, имя и формат файла, размер, SHA-256, includes, excludes и проверки после восстановления. Не записывайте туда пароль, секрет или рабочую строку подключения. Manifest описывает артефакт и ожидаемое доказательство, а не выдаёт доступ.

\n
manifestVersion: 1\nbackupId: training-catalog-2020-10-a\nartifact.file: training-catalog-2020-10.dump\nartifact.format: pg_dump custom (-Fc)\nartifact.sha256: учебное значение после создания\nscope.includes: schema:catalog, schema:reference\nscope.excludes: cluster roles, tablespaces, external files\nrestoreChecks: catalog.items, reference.codes\nsafety: только изолированная учебная база
\n

Поле sha256 отвечает только за целостность байтов. Совпадение hash не говорит, что архив содержит нужные данные. Для этого manifest хранит отдельные structural checks: ожидаемые relations, версию схемы и безопасные счётчики строк. Такой разрыв показывает, на каком шаге сломался маршрут.

\n

Учебная последовательность создания архива

\n

Команды ниже демонстрируют форму проверки. Они не запускались против рабочей базы. Имена training_catalog, training-catalog-2020-10.dump и все данные в примере учебные. Перед применением нужно выбрать разрешённую аутентификацию, проверить версию клиента и убедиться, что процесс не получает больше прав, чем требуется.

\n
# Учебные команды, без адреса и секретов окружения\npg_dump --format=custom --file=training-catalog-2020-10.dump training_catalog\nshasum -a 256 training-catalog-2020-10.dump\npg_restore --list training-catalog-2020-10.dump
\n

Формат custom предназначен для чтения через pg_restore. Если используется plain SQL, маршрут будет другим. Нельзя взять команду для одного формата и считать её универсальной. Версию pg_dump фиксируйте рядом с manifest: совместимость клиента, сервера и целевой базы нужно проверять в конкретной среде.

\n

Список из pg_restore --list — ранняя остановка. Он помогает заметить архив другой базы, неожиданное имя схемы или отсутствующую relation до подключения к кандидату. Но список не доказывает, что данные восстановятся и что приложение сможет работать. После него нужен отдельный restore и проверки результата.

\n

Restore выполняем только в изолированную цель

\n

Безопасная цель должна иметь отдельное имя, отдельное подключение и понятного владельца. В учебном маршруте она называется training_restore_candidate. Перед командой оператор проверяет, что подключение не ведёт к источнику. Если это нельзя доказать, восстановление не начинают.

\n
# Только учебный изолированный кандидат\ncreatedb training_restore_candidate\npg_restore --dbname=training_restore_candidate training-catalog-2020-10.dump\npsql --dbname=training_restore_candidate --command='SELECT count(*) FROM catalog.items;'\npsql --dbname=training_restore_candidate --command='SELECT count(*) FROM reference.codes;'
\n

Эти запросы дают только учебный пример. Они не сообщают реальное время восстановления, полноту кластера или готовность приложения. В рабочем проекте критерии подбирают по контракту данных: проверяют существование критичных relations, версию схемы, небольшой синтетический набор или чтение безопасного reference-объекта. Запрос не должен менять источник, отправлять побочный эффект во внешнюю систему или требовать ресурс, которого нет в заявленном scope.

\n

Порядок первого restore drill

\n
  1. Назовите цель восстановления: какая база, схемы и данные должны появиться после restore. Отдельно запишите роли, tablespaces и внешние файлы, которые не проверяются.
  2. Создайте logical archive выбранным инструментом. Сохраните его формат, имя, размер, версию клиента и SHA-256 в manifest.
  3. Сверьте checksum. При расхождении остановитесь до pg_restore; изменённый файл нельзя проверять восстановлением.
  4. Прочитайте оглавление архива. Сопоставьте его с includes и ожидаемыми relations. Несовпадение означает проблему scope или источника.
  5. Подготовьте изолированного кандидата. Проверьте имя цели, права и отсутствие маршрута записи в исходную базу.
  6. Выполните restore в кандидате. Сохраните код выхода и диагностический вывод, но не считайте код 0 единственным доказательством.
  7. Запустите безопасные structural и semantic checks. Сравните результат с manifest и запишите, что именно не входило в проверку.
  8. Повторите drill после изменения команды, scope, версии инструмента или структуры базы. Старое доказательство не подтверждает новый маршрут.
\n

Отрицательный путь важнее зелёной отметки

\n

Если hash не совпал, проблема находится до восстановления. Не меняйте параметры target и не запускайте архив «на удачу». Получите файл заново, проверьте источник и обновите manifest только после подтверждения.

\n

Если hash совпал, но в оглавлении нет reference.codes, байты целы, а scope неверен для заявленной цели. Checksum сработал правильно: он не обязан обнаруживать отсутствие объекта. Исправьте команду dump или измените контракт восстановления. Не добавляйте недостающую таблицу вручную и не называйте такой результат полным restore.

\n

Если restore завершился, но row count или версия схемы не совпали, target получил результат, который не отвечает контракту. Сохраните симптом, проверьте миграции и повторите тест с исправленным архивом. Если цель оказалась исходной базой, остановите процедуру и разберите права и runbook до следующей попытки.

\n

Ограничения и критерий готовности

\n

Эта практика не проверяет point-in-time recovery, непрерывное архивирование WAL, retention, шифрование, резервирование самого хранилища, кластерные роли, tablespaces или файлы вне PostgreSQL. Логический dump одной базы не становится копией всего кластера. Учебный кандидат не даёт production-метрик и не заменяет аварийное упражнение с согласованным окном.

\n

Первый drill можно считать завершённым, если сохранены четыре независимых факта: scope совпал с целью; checksum совпал с manifest; архив содержит ожидаемые объекты; restore в изолированный кандидат прошёл заранее заданные безопасные checks. В журнале также перечислены исключения и версия инструмента. Если хотя бы один факт отсутствует, статус должен быть «проверка не завершена», а не «backup готов».

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/262.json b/editorial/agent-rewrites/262.json new file mode 100644 index 0000000..5c48294 --- /dev/null +++ b/editorial/agent-rewrites/262.json @@ -0,0 +1,7 @@ +{ + "index": 262, + "slug": "editorial-2020-09-field-tracing-basics", + "title": "Как читать распределённый trace: duration, parent и critical path", + "excerpt": "Практический разбор медленного запроса: как проверить связность trace, не сложить вложенные интервалы дважды и выбрать следующую проверку вместо поспешного увеличения timeout.", + "contentHtml": "

Пользователь ждёт ответ API 240 мс, а в trace видит несколько span: gateway, catalog, pricing и inventory. Команда складывает все duration, получает 720 мс и начинает искать «лишнюю» задержку. Другая команда видит самый длинный span inventory и сразу увеличивает timeout. Обе реакции могут ошибиться. Первая удваивает время вложенных операций. Вторая меняет лимит, но не проверяет, кто удерживает ответ.

\n

Цена ошибки — не только несколько миллисекунд в отчёте. Неверный диагноз закрепляет плохую конфигурацию, маскирует потерю контекста и переносит проблему на следующий релиз. Правильный разбор начинается с наблюдаемых полей: один ли это trace, кто родитель span, где начинается и заканчивается каждый интервал, какая ветка завершается последней.

\n

Тезис: сначала связность, потом арифметика

\n

Trace показывает путь операции через границы компонентов. Span описывает отдельный участок этого пути. Parent связывает участок с предыдущей операцией. Duration равен разности end - start. Эти факты позволяют объяснить задержку, но только если записи относятся к одной логической истории и используют сопоставимую шкалу времени.

\n

Родительский span обычно включает интервалы своих children. Поэтому duration родителя и duration child нельзя складывать как последовательные операции. Если две дочерние ветки перекрываются, их время тоже не складывается. Сначала нужно найти интервал, который удерживает ответ до конца, затем проверить его собственные участки.

\n

Учебный пример с одной шкалой

\n

Ниже — controlled example. Все значения придуманы для объяснения метода. Это не замер реального сервиса и не production-результат. Корневой span gateway.handle живёт от 0 до 240 мс. Он вызывает catalog.lookup от 20 до 220 мс. Внутри catalog две параллельные ветки: pricing.read от 30 до 70 мс и inventory.fetch от 30 до 200 мс. Inventory запускает inventory.adapter от 100 до 170 мс.

\n
Синтетический waterfall одного trace
SpanParentИнтервалDuration
gateway.handleroot0–240 ms240 ms
catalog.lookupgateway.handle20–220 ms200 ms
pricing.readcatalog.lookup30–70 ms40 ms
inventory.fetchcatalog.lookup30–200 ms170 ms
inventory.adapterinventory.fetch100–170 ms70 ms
\n

Из таблицы нельзя заключить, что запрос занял 240 + 200 + 40 + 170 + 70 мс. Children находятся внутри parent. Pricing и inventory идут параллельно с 30 до 70 мс. Поэтому pricing не добавляет 40 мс после inventory. Корневой ответ заканчивается в 240 мс, а не в сумме всех строк.

\n

Как duration превращается в проверяемый вывод

\n

Сначала проверьте форму интервалов. Для каждого span должно выполняться end >= start. Child должен находиться внутри parent, если модель использует обычное дерево вложенных операций. Если child выходит за границы родителя, это не повод сразу рисовать красную полосу. Причиной может быть ошибка закрытия span, асинхронная работа после ответа или неверная модель связи. Запишите факт и остановите арифметику до выяснения.

\n
const spans = [\n  { name: 'gateway.handle', parent: null, start: 0, end: 240 },\n  { name: 'catalog.lookup', parent: 'gateway.handle', start: 20, end: 220 },\n  { name: 'pricing.read', parent: 'catalog.lookup', start: 30, end: 70 },\n  { name: 'inventory.fetch', parent: 'catalog.lookup', start: 30, end: 200 },\n  { name: 'inventory.adapter', parent: 'inventory.fetch', start: 100, end: 170 },\n];\n\nfunction duration(span) {\n  return span.end - span.start;\n}\n\nfunction isInside(child, parent) {\n  return child.start >= parent.start && child.end <= parent.end;\n}
\n

Эта функция проверяет только учебные интервалы. Она не исправляет часы разных машин, не восстанавливает потерянный span и не доказывает, что transport передал контекст. В настоящей системе сначала установите, откуда пришли timestamps и как инструмент описывает асинхронные связи.

\n
\"Учебный
Схема показывает только controlled example. Pricing и inventory пересекаются, поэтому их inclusive duration нельзя складывать. Позднее завершение inventory определяет конец ветки catalog.
\n

Critical path без двойного счёта

\n

В этом примере последний child catalog — inventory.fetch: он заканчивается в 200 мс, тогда как pricing заканчивается в 70 мс. Поэтому путь до конца ответа проходит через gateway.handle → catalog.lookup → inventory.fetch. Adapter находится внутри inventory и помогает объяснить его работу, но его 70 мс уже входят в 170 мс inventory.

\n

Для более точной проверки разложите выбранные интервалы на exclusive-участки. У gateway остаются 40 мс вне catalog: 0–20 и 220–240. У catalog остаются 30 мс вне объединения children: 20–30 и 200–220. У inventory остаются 100 мс вне adapter: 30–100 и 170–200. Adapter даёт 70 мс собственного участка. Сумма 40 + 30 + 100 + 70 = 240 мс совпадает с root duration. Это проверка разложения конкретной модели, а не универсальный алгоритм для любого trace-хранилища.

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Все duration в сумме больше rootВложенные span посчитали повторноСверить интервалы parent и childСчитать overlap и exclusive-участки
У соседнего сервиса новый trace-idContext не передали или создали новый rootСравнить traceparent на границеПроверить inject/extract и решение о новой границе
Child заканчивается после parentSpan закрыли рано или работа асинхроннаСопоставить lifecycle операции и timestampsИсправить закрытие либо описать link/async-модель
Самый длинный span не объясняет ответОн перекрывается с другой веткойНайти ветку с последним endПроверить critical path, а не максимум duration
Trace обрывается на proxySampler, фильтр или transport не сохранил contextСравнить входной и исходящий carrierДобавить точечную проверку boundary и не обещать полноту
\n

Контекст на границе сервиса

\n

Связность trace не появляется из названий span. Клиент должен передать контекст, а принимающая сторона — извлечь его и создать новый child. Для W3C Trace Context заголовок traceparent содержит version, trace-id, parent-id и trace-flags. Пример ниже синтетический. Он показывает форму данных, но не является рабочим токеном и не доказывает прохождение через конкретный proxy.

\n
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01\n\n// gateway: inject current span context\n// catalog: extract traceparent, create child span\n// inventory: inject catalog child as the next parent
\n

Если catalog создаёт новый root, downstream trace может выглядеть аккуратно, но связь с gateway потеряна. Если parser принимает нулевой или неверно оформленный ID, система получает ложную иерархию. Если выборка не сохраняет часть span, trace остаётся неполным. В каждом случае отрицательный путь важнее красивого графика: нужно уметь сказать «связь не доказана».

\n

Порядок действий

\n
  1. Сформулируйте симптом без причины: какой ответ задержан, в каком диапазоне и какую ошибку может вызвать неверная правка.
  2. Возьмите один trace и выпишите trace-id, span-id, parent-id, start и end для нужных записей.
  3. Проверьте, что записи относятся к одной истории и parent образует допустимое дерево.
  4. Проверьте интервалы: duration равен end - start, child не выходит за parent в выбранной модели.
  5. Нарисуйте waterfall и отметьте перекрытия. Не складывайте inclusive duration.
  6. Найдите ветку с самым поздним завершением и разложите её на exclusive-участки.
  7. Выберите одну следующую проверку: transport boundary, adapter, sampler или источник времени.
  8. Повторите тот же trace-level анализ после изменения и сравните заранее выбранный сигнал.
\n

Ограничения метода

\n

Обычное дерево span плохо описывает fan-out с несколькими родителями, очередь, retry и работу, которая продолжается после ответа. Для таких случаев нужны links или другая модель причинности. Нельзя объявлять critical path доказанным, если timestamps пришли с несинхронизированных часов. Нельзя считать отсутствие span доказательством отсутствия работы: его мог отфильтровать sampler.

\n

Trace также не заменяет профиль CPU, план базы данных, сетевой capture или бизнес-метрику. Он показывает наблюдаемую структуру и интервалы выбранной instrumentation. Если span широк, следующая проверка должна сузить его до конкретного adapter или внешнего вызова. Если контекст потерян, сначала восстановите boundary, а не оптимизируйте случайный участок.

\n

Критерий готовности

\n

Разбор готов, когда для одного проверочного запроса выполнены четыре условия: все использованные записи имеют объяснимую связь с root; для каждого вывода указана опора в trace; арифметика не считает вложенные или параллельные интервалы дважды; после изменения есть повторяемая проверка того же симптома и отрицательного пути. Если хотя бы одно условие не выполнено, честный результат — «причина пока не доказана». Такой ответ полезнее, чем уверенный, но неверный виновник.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/263.json b/editorial/agent-rewrites/263.json new file mode 100644 index 0000000..0e6ae46 --- /dev/null +++ b/editorial/agent-rewrites/263.json @@ -0,0 +1,7 @@ +{ + "index": 263, + "slug": "editorial-2020-09-mechanism-tracing-basics", + "title": "Trace context без иллюзий: как связать запрос и найти задержку", + "excerpt": "Если gateway и downstream видят разные trace, waterfall превращается в набор несвязанных чисел. Разбираем traceparent, parent/child span, синтетический пример и безопасный порядок проверки.", + "contentHtml": "

Симптом появляется после первого внедрения структурных логов: gateway сообщает о запросе, catalog сообщает о своей операции, inventory сообщает о таймауте, но нельзя доказать, что эти записи относятся к одной истории. Время ответа — 240 миллисекунд, а в логах видны 200, 170 и 70 миллисекунд. Команда выбирает самый большой показатель и меняет timeout. Цена ошибки — лишние повторы, перегрузка downstream и задержка, которую так и не измерили.

\n

Причина обычно не в dashboard. Контекст запроса потерялся на границе между процессами или parent/child связали неправильно. Trace-id заменили новым значением, span-id скопировали из родителя, а вложенные duration сложили повторно. Тезис простой: трассировка становится полезной только тогда, когда система сохраняет один trace-id, создаёт новый span на каждой операции, передаёт текущий span как parent и проверяет интервалы до диагноза.

\n

Что именно связывает trace context

\n

Trace — логическая история запроса. Span — одна операция внутри этой истории. У span есть имя, начало, конец, span-id и ссылка на parent. Trace-id общий для всех связанных span. Span-id различает gateway, catalog и inventory. Parent-id отвечает на вопрос «какая операция породила эту работу», но не заменяет trace-id.

\n

На HTTP-границе контекст нужно превратить в переносимые данные. W3C Trace Context описывает для этого заголовок traceparent. В учебной версии 00 он имеет четыре части: версию, trace-id, parent-id и trace-flags. Получатель извлекает входной parent-id, создаёт новый span-id для своей операции и передаёт дальше уже свой span-id. Trace-id при этом остаётся прежним.

\n
Схема передачи trace context: gateway передаёт свой span-id, catalog создаёт дочерний span и передаёт дальше новый span-id при неизменном trace-id
На каждой синхронной границе меняется текущий span-id. Общий trace-id сохраняет принадлежность операций к одной истории.
\n

Это не бизнес-заголовок. В traceparent нельзя переносить токен, email, полный URL или текст исключения. Контекст должен описывать связь операций. Данные для логов и диагностики живут по отдельным правилам. Особенно опасно бездумно доверять входному контексту публичного клиента: внешний отправитель не должен одним флагом управлять внутренней стоимостью сбора.

\n

Минимальный пример

\n

Ниже — учебная модель. Она не открывает сеть, не подключается к collector и не показывает production-данные. В ней gateway создаёт root span, catalog становится его child, а inventory и pricing идут параллельно внутри catalog. Значения времени выбраны только для проверки арифметики.

\n
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';\nconst gateway = { spanId: 'a111111111111111', parentSpanId: null,\n  startMs: 0, endMs: 240 };\n\n// Gateway передаёт свой текущий span как parent следующей операции.\nconst traceparent =\n  `00-${traceId}-${gateway.spanId}-01`;\n\n// Catalog создаёт новый span, но сохраняет traceId.\nconst catalog = { spanId: 'b222222222222222',\n  parentSpanId: gateway.spanId, startMs: 20, endMs: 220 };\n\n// Дочерние операции catalog перекрываются во времени.\nconst pricing = { parentSpanId: catalog.spanId, startMs: 30, endMs: 70 };\nconst inventory = { parentSpanId: catalog.spanId, startMs: 30, endMs: 200 };
\n

В этой модели root длится 240 миллисекунд. Catalog занимает 200, pricing — 40, inventory — 170. Adapter внутри inventory может занимать 70 миллисекунд. Эти числа нельзя сложить: adapter уже входит в inventory, а pricing идёт параллельно с inventory. Inclusive duration показывает полный интервал операции вместе с ожиданием дочерних span. Он не показывает самостоятельное время без детей.

\n

Если gateway и catalog получили разные trace-id, дерево распалось. Если catalog сохранил span-id gateway как собственный, две операции стали неразличимы. Если parent у inventory ссылается на далёкого предка, а не на непосредственный catalog, визуальный граф может выглядеть правдоподобно, но причинность станет ложной. Проверка должна ловить эти ошибки на данных, а не по цветам интерфейса.

\n

Проверяем header до создания span

\n

Parser должен сначала проверить форму входа. Для version 00 нужны четыре части, lowercase hex, ненулевой trace-id длиной 32 символа и ненулевой parent-id длиной 16 символов. Неизвестную версию нельзя угадывать по первым символам. Невалидный контекст нужно отклонить или обработать по заранее описанной политике, а не превратить в доверенный parent.

\n
function parseTraceparent(value) {\n  const parts = String(value).split('-');\n  if (parts.length !== 4 || parts[0] !== '00') {\n    throw new Error('unsupported traceparent');\n  }\n\n  const [, traceId, parentId, flags] = parts;\n  if (!/^[0-9a-f]{32}$/.test(traceId) || /^0+$/.test(traceId)) {\n    throw new Error('invalid trace-id');\n  }\n  if (!/^[0-9a-f]{16}$/.test(parentId) || /^0+$/.test(parentId)) {\n    throw new Error('invalid parent-id');\n  }\n  if (!/^[0-9a-f]{2}$/.test(flags)) {\n    throw new Error('invalid trace-flags');\n  }\n  return { traceId, parentId, flags };\n}
\n

Функция ограничена учебной задачей: version 00, строковый carrier и базовая валидация. Она не заменяет библиотеку трассировки. Реальный adapter должен учитывать правила конкретного HTTP-клиента, сервера, прокси и фреймворка. Если middleware уже извлекает контекст и создаёт span, второй слой может породить дубликаты. Сначала нужно установить владельца extract, владельца inject и место создания root.

\n

Симптом → причина → проверка → действие

\n
Разбор типичных разрывов в одном синхронном trace
СимптомПричинаПроверкаДействие
Gateway и catalog имеют разные trace-idПолучатель создал новый root вместо childСверить trace-id и parent-id в обеих spanИсправить extract и создание child на границе
У двух операций один span-idПолучатель скопировал ID родителяПроверить уникальность span-id в одной историиГенерировать новый span-id для каждой операции
Заголовок принят, но граф пустойКонтекст передали, а span не записали или не экспортировалиРазделить проверку propagation, recording и exportДобавить отдельный тест на каждый слой
Сумма дочерних duration больше ответаПерекрывающиеся интервалы сложили как последовательныеНанести start/end на одну шкалу времениСчитать critical path и exclusive time, а не сумму строк
Нулевой или чужой trace-id проходит дальшеParser проверяет только число частейПодать отрицательные header-примеры до создания spanОстановить обработку или создать новый root по политике границы
\n

Как читать учебный waterfall

\n

Допустим, waterfall содержит пять span: gateway.handle от 0 до 240, catalog.lookup от 20 до 220, pricing.read от 30 до 70, inventory.fetch от 30 до 200 и inventory.adapter от 100 до 170 миллисекунд. У всех один trace-id. Каждый child имеет существующего parent и лежит внутри его интервала. Это минимальный набор условий, чтобы обсуждать дерево и время вместе.

\n

Поздний конец inventory — 200 миллисекунд. Pricing заканчивается на 70 и не удерживает catalog до его конца. Adapter заканчивается на 170 и находится внутри inventory. Поэтому учебный critical path проходит через gateway, catalog и inventory, а затем через adapter только как вложенный участок. Это не означает, что adapter — production bottleneck. Это означает лишь, что в данной модели он находится на поздней последовательной ветви.

\n

Exclusive time можно получить, вычтя объединение дочерних интервалов из интервала parent. Для inventory это 170 минус 70, то есть 100 миллисекунд вне adapter. Для root остаётся 40 миллисекунд вне catalog. Такой расчёт помогает не считать одно ожидание дважды, но требует общей шкалы времени и корректных границ. При clock skew, неполных timestamp и асинхронной очереди результат нельзя считать доказанным.

\n

Порядок действий

\n
  1. Выбрать одну синхронную границу, например gateway → catalog. Не начинать с массовой автоинструментации.
  2. Назначить владельцев: кто создаёт root, кто извлекает incoming context, кто создаёт child и кто внедряет outgoing context.
  3. Зафиксировать учебный trace-id, список span и ожидаемые parent-id. Не класть в идентификаторы пользовательские данные.
  4. Проверить положительный round-trip: inject сохраняет trace-id и текущий span-id, extract возвращает их без изменения.
  5. Проверить отрицательный путь: нулевые ID, неверную длину, uppercase, неизвестную версию и лишнее поле. До создания span вход должен получить предсказуемый отказ.
  6. Запустить controlled fixture с одной шкалой времени. Проверить один root, уникальность span-id, существование parent и containment child.
  7. Сделать transport test конкретного клиента или сервера. Проверить не только carrier, но и фактическую границу, где он проходит.
  8. Только после этого смотреть duration. Сначала — конец root, затем прямые children, перекрытия и собственное время.
  9. Записать, что не проверено: collector, sampling, storage, clock synchronization, очередь, retries и fan-out.
\n

Отрицательный путь и ограничения

\n

Зелёный пример показывает, как механизм работает при правильных данных. Нужнее отрицательный: нулевой trace-id должен быть отвергнут; изменённый parent-id не должен незаметно связать операцию с чужим span; неизвестная версия не должна интерпретироваться как version 00. Если входной контекст нельзя доверенно обработать, система должна иметь явное решение: отклонить его, пропустить операцию без связи или начать новый root.

\n

Trace context не создаёт наблюдаемость сам. Заголовок может пройти прокси, но span не попадёт в exporter. Exporter может работать, но библиотека не создаст span вокруг важной операции. Sampling может убрать часть истории. Эти случаи требуют разных проверок. Нельзя объявлять propagation исправной только потому, что строка header дошла до обработчика.

\n

Модель выше не подходит без изменений для очереди, fan-out, batch и продолжения работы после HTTP-ответа. У асинхронной операции может не быть одного parent, который полностью охватывает её время. Появляются links, отдельные правила корреляции и несколько часов. Пока эти условия не проверены, нельзя рисовать уверенный critical path по простой вложенности.

\n

Статья также не обещает production latency. Все интервалы в примере синтетические. Они нужны, чтобы проверить связность, арифметику и отрицательные сценарии. Реальный вывод требует transport test и trace, полученного в разрешённом окружении с известной версией библиотек.

\n

Проверяемый критерий готовности

\n

Минимальный критерий такой: выбранная граница имеет владельца extract и inject; положительный round-trip сохраняет trace-id и меняет текущий span-id по правилам; отрицательные header-ы не создают ложные связи; fixture содержит один root и уникальные span-id; parent существует; child укладывается в parent там, где это предусмотрено моделью; duration не складывают поверх перекрытий; непроверенные production-условия перечислены.

\n

Если хотя бы один пункт неизвестен, результат нужно назвать ограниченно: «формат разобран», «учебное дерево связно» или «transport boundary прошла тест». Фраза «трассировка работает» шире доказательств. Готовность начинается там, где команда может повторить проверку, увидеть красный отрицательный путь и безопасно остановиться.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/264.json b/editorial/agent-rewrites/264.json new file mode 100644 index 0000000..96d5dbb --- /dev/null +++ b/editorial/agent-rewrites/264.json @@ -0,0 +1,7 @@ +{ + "index": 264, + "slug": "editorial-2020-09-practice-tracing-basics", + "title": "Трассировка запроса: как найти задержку по одному trace", + "excerpt": "Разбираем разорванный trace: как передать trace context через границу сервисов, связать parent и child span, прочитать waterfall и остановиться, если данных недостаточно.", + "contentHtml": "

Сервис отвечает успешно, но один и тот же endpoint то укладывается в 40 миллисекунд, то ждёт почти секунду. В логах есть записи gateway, catalog и inventory. Связать их с одним запросом нельзя: у строк разные идентификаторы, а время запуска не совпадает. Цена ошибки — менять timeout, retry или запрос к базе вслепую. Можно убрать один видимый симптом и оставить настоящую задержку на следующем участке.

\n

Тезис. Трассировка помогает не потому, что добавляет ещё один лог. Она связывает операции в дерево: общий trace-id описывает одну историю, span-id описывает отдельную операцию, а parent-id показывает прямую связь. На транспортной границе сервис должен передать контекст, создать свой span и передать уже его как родителя следующей операции. Если граница не передала контекст, waterfall распадается и вывод о причине задержки становится гипотезой.

\n

Как читать один trace

\n

Представим учебный запрос к каталогу. Gateway принимает HTTP-запрос и создаёт span gateway.handle. Затем он вызывает catalog. Catalog получает trace context, создаёт catalog.lookup с родителем gateway и запускает два дочерних участка: pricing.read и inventory.fetch. Inventory, в свою очередь, вызывает адаптер.

\n

В такой модели один trace содержит пять span. У всех один trace-id. Каждый span имеет собственный span-id. У дочернего span parent-span-id равен идентификатору операции, которая его вызвала. Эта связь важнее красивого имени сервиса: она показывает, кто породил ожидание.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Строки лога нельзя собрать в запросКомпоненты создают разные trace-idСравнить trace-id у входящего и исходящего spanПередавать контекст через границу
Child span существует, но parent неизвестенСервис создал span без текущего контекстаПроверить parent-span-id и порядок времениСоздавать child из извлечённого context
Waterfall показывает невозможное перекрытиеСложили вложенные durationПроверить интервалы start/end и вложенностьСчитать critical path, а не сумму всех span
Trace пропал после proxy или очередиCarrier не прошёл через транспортСравнить header до отправки и после полученияПроверить конкретный adapter или carrier
Задержка есть, но причина не виднаНужный участок не создаёт span или не попал в samplingПроверить покрытие, sampling и экспортНе объявлять виновника без следующего сигнала
\n

Контекст передаётся через границу

\n

Внутри одного процесса контекст можно передать аргументом функции или средствами SDK. После HTTP-вызова, сообщения очереди или фоновой задачи получатель сам его не угадает. Для HTTP используется carrier. Один распространённый вариант — W3C traceparent. В версии 00 он содержит version, trace-id, parent-id и trace-flags.

\n
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01\n\n// gateway отправляет свой span как parent:\nconst outgoing = \\`00-\\${traceId}-\\${gatewaySpanId}-01\\`;\n\n// catalog создаёт новый span, но сохраняет traceId:\nconst catalogSpan = {\n  traceId,\n  spanId: '00f067aa0ba902b7',\n  parentSpanId: gatewaySpanId,\n};
\n

Код выше — учебный пример. Он не открывает сеть, не заменяет SDK и не доказывает, что конкретный proxy сохранит заголовок. Его задача — показать два инварианта: trace-id остаётся тем же, а текущий span-id меняется на каждой участвующей границе.

\n

Получатель должен проверить формат до использования. Trace-id и parent-id имеют фиксированную длину и не могут быть нулевыми. Неверный header нельзя принимать как доверенный контекст. Если header отсутствует, сервис начинает новую историю или применяет явно заданную политику. Нельзя молча приписывать запрос к случайному trace.

\n
\"Схема
На границе меняется parent span, но trace-id остаётся общим. Схема показывает контракт передачи, а не выполнение реального запроса.
\n

Waterfall показывает путь, а не сумму строк

\n

Пусть в учебном примере gateway.handle идёт от 0 до 240 миллисекунд, catalog.lookup — от 20 до 220, pricing.read — от 30 до 70, а inventory.fetch — от 30 до 200. Внутри inventory адаптер занимает интервал от 100 до 170 миллисекунд. Эти числа придуманы для объяснения и не являются измерениями production.

\n

Pricing и inventory стартуют одновременно. Поэтому конец запроса определяется веткой inventory, а не суммой 40 и 170 миллисекунд. Время parent включает время дочерних span. Если сложить 240, 200, 40, 170 и 70, получится число, которое не описывает ни задержку запроса, ни critical path. Для анализа нужно найти цепочку зависимых операций и отдельно посмотреть участки parent, которые не закрыли child.

\n
\"Учебный
В учебном waterfall поздно завершающаяся ветка inventory определяет критический путь. Вывод применим только при общей шкале времени и корректных parent-child связях.
\n

Порядок действий

\n
  1. Зафиксируйте симптом. Запишите endpoint, примерный диапазон задержки, код ответа, входные условия и время наблюдения.
  2. Выберите одну историю. Найдите trace конкретного запроса по доступному идентификатору. Не смешивайте записи разных попыток.
  3. Проверьте корень. Убедитесь, что root span имеет начало и конец, а его trace-id не меняется внутри истории.
  4. Проверьте границы. Для каждого HTTP-вызова или сообщения сравните отправленный carrier с полученным. Отметьте место, где связь исчезает.
  5. Проверьте parent. У каждого child должен существовать ожидаемый родитель. Временной интервал child не должен выходить за границу parent без отдельного объяснения.
  6. Постройте критический путь. Отделите последовательные операции от параллельных. Не складывайте вложенные duration.
  7. Назовите следующий сигнал. Если самый длинный span — adapter, проверьте его запрос, очередь или внешний ответ. Один span не доказывает причину внутри себя.
  8. Проверьте отрицательную ветку. Если trace отсутствует, sampling исключил запрос или часы расходятся, остановите вывод и соберите недостающий сигнал.
  9. Сравните повтор. Повторите тот же сценарий с теми же входными условиями и убедитесь, что вывод не зависит от одной удачной истории.
\n

Что трассировка не доказывает

\n

Наличие trace-id не означает, что история полная. Sampling может отбросить запрос. Экспорт может задержаться или завершиться ошибкой. Уровень логирования может скрыть событие. Прокси может удалить заголовок. Очередь может использовать другой carrier. Для каждой границы нужна отдельная проверка.

\n

Трассировка также не показывает автоматически бизнес-причину. Длинный span базы может быть следствием блокировки, плохого плана, холодного соединения или внешнего лимита. Название inventory.fetch не различает эти случаи. Следующий шаг должен читать собственный сигнал участка: план запроса, размер очереди, код внешнего ответа или время подключения.

\n

Не помещайте в trace context пароль, токен, email, полный URL с параметрами или тело запроса. Trace-id служит для связи операций. Доступ к trace и правила хранения должны учитывать, что span attributes часто попадают в журналы и хранилища наблюдаемости.

\n

Проверяемый критерий готовности

\n

Разбор готов, если другой инженер может по одному запросу воспроизвести четыре факта: все нужные span имеют общий trace-id; каждая транспортная граница показывает отправленный и полученный context; parent-child связи согласуются с временем; критический путь объясняет задержку без сложения перекрывающихся интервалов. Для найденного участка существует следующий проверяемый сигнал.

\n

Если хотя бы один факт неизвестен, результатом должна быть запись «причина не доказана» и конкретный следующий fetch, лог или измерение. Это не провал метода. Это правильная граница вывода: trace показывает путь запроса, но не разрешает придумывать отсутствующие данные.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/265.json b/editorial/agent-rewrites/265.json new file mode 100644 index 0000000..c5fb677 --- /dev/null +++ b/editorial/agent-rewrites/265.json @@ -0,0 +1,7 @@ +{ + "index": 265, + "slug": "editorial-2020-08-field-metrics-basics", + "title": "Метрики приложения: как превратить график в проверяемое действие", + "excerpt": "Пользователь видит ошибку, а общий график запросов не объясняет причину. Разбираем counter, окно, labels и проверку учебного порога без выдуманных production-выводов.", + "contentHtml": "

Пользователь сообщает: checkout иногда завершается ошибкой. На dashboard линия requests_total растёт ровно, аварий в логах нет. Команда смотрит на общий график и не понимает, что проверять дальше. Цена ошибки — не только пропущенный сбой. Можно поднять шумный alert, увеличить timeout без причины или потратить час на чтение логов, которые не связаны с нужной операцией.

\n

Тезис простой: метрика помогает принять решение только тогда, когда её границы совпадают с вопросом. Нужно назвать операцию, событие, окно и следующий шаг. Один общий счётчик показывает, что система что-то делала. Он не показывает, сколько ошибок произошло в checkout и можно ли связать их с конкретным запросом.

\n

Сначала отделите событие от состояния

\n

Для завершённых запросов подходит counter. Он накапливает события и обычно растёт. Для вопроса «сколько ошибок checkout появилось за десять минут» нужен прирост counter, а не его последняя сырая величина. Для вопроса «сколько запросов выполняется сейчас» нужен gauge. Для вопроса «как распределилась длительность» нужна отдельная метрика длительности, например histogram.

\n

Последнее значение store_http_requests_total зависит от времени жизни процесса. Оно не равно числу ошибок за выбранное окно. В PromQL такой вопрос выражает increase():

\n
# Учебный запрос. Не production-alert без проверки окружения.\nsum(increase(store_http_requests_total{operation="checkout", outcome="error"}[10m])) >= 2
\n

Выражение выбирает series с двумя bounded labels, считает прирост за десять минут и сравнивает его с учебной границей. Оно не сообщает причину ошибки. После срабатывания нужно открыть связанный журнал, trace или изолированный сценарий. Если метрика не покрывает пользовательский путь, scrape пропущен или label выбран неверно, нулевой результат не доказывает, что пользовательская ошибка исчезла.

\n

Labels должны помогать сузить вопрос

\n

operation=checkout отделяет логическую операцию. outcome=error отделяет ошибку от успешного завершения. Набор ограничен заранее: операции и исходы берутся из небольшого списка. Такой label позволяет агрегировать данные и сравнивать ветки.

\n

Не добавляйте в labels request_id, email, полный URL или другой идентификатор, который почти уникален для каждого события. Каждая комбинация labels создаёт отдельную time series. Уникальный идентификатор превратит counter в поток почти одноразовых рядов. График потеряет агрегирование, а Prometheus получит лишнюю память, CPU, диск и сетевой трафик. Конкретный запрос ищут в журнале по корреляционному идентификатору.

\n
# Хорошая граница метрики\nstore_http_requests_total{operation="checkout", outcome="error"}\n\n# Плохая граница: уникальный label разрушает агрегацию\nstore_http_requests_total{operation="checkout", request_id="8f2d..."}
\n

Если нужна доля ошибок, знаменатель должен описывать ту же границу. Ошибки, посчитанные внутри приложения, нельзя без проверки делить на все попытки, посчитанные на proxy. Сначала убедитесь, что обе величины относятся к одной операции, одному времени и одной точке завершения.

\n

Учебная серия и её пределы

\n

Ниже приведены выдуманные snapshots. Они нужны только для объяснения расчёта. В этом примере counter ошибки checkout равен нулю в 10:00, единице в 10:05 и двум в 10:10:

\n
# Не production telemetry. Значения заданы для учебного примера.\nstore_http_requests_total{operation="checkout",outcome="error"}\n2020-08-01T10:00:00Z  0\n2020-08-01T10:05:00Z  1\n2020-08-01T10:10:00Z  2\n\n# В упрощённой модели: 2 - 0 = 2\n# Учебная граница >= 2 пересечена.
\n

В учебной модели прирост равен двум. В настоящем Prometheus increase() учитывает диапазон samples и корректирует counter reset после перезапуска target. Поэтому простая разность крайних точек полезна для объяснения, но не заменяет выполнение PromQL на сервере. На результат также влияют scrape interval, пропуски scrape и границы range vector.

\n
Схема диагностики метрики: от общего графика к counter ошибок checkout, проверке окна и связанному журналу
Порог переводит общий симптом в ограниченную проверку. Он не называет причину и не заменяет журнал.
\n

Симптом → причина → проверка → действие

\n
Как не сделать из одной линии графика ложный диагноз
СимптомВозможная причинаПроверкаДействие
Пользователь сообщает об ошибке, а общий requests_total растётСчётчик смешивает операции и исходыПроверить metric name и доступные labelsВыбрать bounded operation и outcome; не менять timeout
increase(...[10m]) = 0В series не было прироста или путь не инструментированСверить operation с жалобой, время scrape и targetНе объявлять систему здоровой; проверить журнал и покрытие
Последняя величина counter большаяПроцесс давно работаетСравнить прирост за окно, а не абсолютное значениеИспользовать increase()
У каждой ошибки отдельная seriesВ label попал request ID или полный URLНайти динамические значения и посчитать seriesУбрать уникальное поле из metric contract; оставить его в логе
Порог срабатывает после редкого scrapeОкно и частота сбора не согласованыСопоставить range vector, scrape interval и пропускиИзменить окно или сбор после проверки владельца alert
Две ошибки есть, но причина неизвестнаМетрика агрегирует событие без контекстаНайти trace или журнал по времени и correlation IDОткрыть ограниченную ветку расследования
\n

Таблица разделяет наблюдение и действие. Значение два в учебном примере — не универсальный порог. В production порог зависит от объёма трафика, допустимой доли ошибок, стоимости ложного сигнала, времени реакции и владельца. Если эти условия не названы, число выглядит точным, но не является рабочим правилом.

\n

Порядок проверки

\n
  1. Запишите симптом без диагноза: какой пользовательский путь нарушен, когда это происходит и чем опасна ошибка.
  2. Назовите событие, которое увеличивает counter. Для checkout это завершённый запрос с известным исходом, а не начало попытки.
  3. Выберите тип метрики. Counter отвечает за накопленные события, gauge — за текущее состояние, histogram — за распределение наблюдений.
  4. Проверьте labels. Оставьте ограниченные значения операции и исхода. Уникальные идентификаторы отправьте в журнал или trace context.
  5. Согласуйте окно с частотой scrape. Укажите, что считается приростом и на какой границе он измеряется.
  6. Проверьте учебные snapshots арифметикой. Отдельно выполните PromQL на сервере метрик и проверьте результат на реальном наборе series.
  7. Если порог пересечён, найдите один связанный журнал или trace. Сравните время, operation, outcome и correlation ID.
  8. Только после этого меняйте код, timeout, retry или правило alert. Зафиксируйте критерий отката.
\n

Ограничения и отрицательный путь

\n

Метрика не видит событие, которое не дошло до точки instrumentation. Ошибка в браузере может произойти до приложения. Пропущенный scrape оставит окно неполным. Перезапуск процесса изменит сырое значение counter, хотя корректная функция запроса умеет учитывать reset. Неправильный label может спрятать нужную операцию в общей series.

\n

Поэтому ноль ошибок не равен нулю пользовательских проблем. Проверьте, что target жив, series существует, timestamp попадает в окно, а журнал содержит тот же путь. Если условия не выполняются, честный результат — «сигнал недостаточен», а не «система исправна». Это отрицательное решение экономит время и не создаёт ложной уверенности.

\n

Учебные snapshots и код выше не измеряют память Prometheus, latency, нагрузку, доставку alert или поведение exporter. Они не подтверждают production-результаты. Их задача — показать связь между операционным вопросом, metric type, labels, окном и проверкой. Для реального внедрения нужен отдельный стенд с известным scrape, контролируемым запросом и сохранённым ответом PromQL.

\n

Проверяемый критерий готовности

\n

Разбор готов, если другой инженер может повторить его без устной подсказки: назвать series и её labels, объяснить момент увеличения counter, выполнить запрос с указанным окном, увидеть результат на доступном наборе данных и перейти от срабатывания к одному связанному журналу или trace. Дополнительно он должен уметь показать отрицательный путь: почему ноль не закрывает жалобу, если metric не покрывает endpoint или scrape пропущен.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/266.json b/editorial/agent-rewrites/266.json new file mode 100644 index 0000000..65f54ba --- /dev/null +++ b/editorial/agent-rewrites/266.json @@ -0,0 +1,7 @@ +{ + "index": 266, + "slug": "editorial-2020-08-mechanism-metrics-basics", + "title": "Лейблы Prometheus: где заканчивается полезная размерность", + "excerpt": "Метрика помогает сравнивать операции, пока её labels имеют ограниченный набор значений. Разбираем cardinality, безопасный контракт для кода, запрос по counter и проверку случая, когда сигнал становится слишком дорогим.", + "contentHtml": "

На графике растёт число временных рядов, запросы к Prometheus начинают отвечать медленнее, а полезный разрез всё равно не находится. Частая причина — в label попало значение, которое меняется почти на каждый запрос: полный URL, идентификатор пользователя или request ID. Каждое новое сочетание label создаёт отдельный time series. Цена ошибки — память, CPU, место на диске и потеря доверия к мониторингу. Команда видит много данных, но не получает короткий ответ на операционный вопрос.

\n

Тезис простой: label должен описывать небольшой и заранее понятный набор вариантов. Если значение растёт вместе с числом пользователей или запросов, это не dimension для метрики. Такой контекст нужно искать в логах или трассировке. В Prometheus полезная размерность заканчивается там, где число комбинаций становится непредсказуемым или не связано с действием оператора.

\n

Как label превращается в time series

\n

Имя метрики само по себе не определяет ряд. Ряд задаёт имя плюс полный набор label. Записи store_http_requests_total{operation=\"checkout\",outcome=\"success\"} и store_http_requests_total{operation=\"checkout\",outcome=\"error\"} — два разных ряда. Если добавить user_id, каждый пользователь создаст новый ряд. Если добавить request_id, почти каждый запрос создаст новый ряд.

\n

Для учебного сигнала допустимы три операции и два исхода. Верхняя граница равна 3 × 2 = 6 комбинациям до учёта других labels, например instance и job. Это число можно проверить заранее. Для полного URL такой расчёт невозможен: число значений следует из трафика, параметров и маршрутов. Для email или UUID граница также отсутствует. Поэтому проблема возникает не в синтаксисе метрики, а в контракте данных.

\n
\"Граница
Учебная схема: стабильные значения оставляют число рядов ограниченным, уникальные значения переносят контекст в другой сигнал.
\n

Сначала операционный вопрос

\n

Метрика должна помогать принять решение. Например: «В какой операции за последние десять минут появились ошибки?» Для этого нужны завершённые запросы, стабильное имя операции и нормализованный исход. Не нужны пользователь, текст исключения или URL с query-параметрами. Если вопрос другой, меняется и контракт. «Сколько задач сейчас выполняется?» требует gauge. «Сколько запросов завершилось?» требует counter. «Как распределилась длительность?» требует наблюдений длительности, обычно histogram или summary.

\n

Counter накапливает события и может сброситься при перезапуске процесса. Сырым значением удобно проверять экспорт, но для окна обычно используют функцию над counter. В учебном запросе ниже increase() отвечает на вопрос о числе ошибок за интервал. Это не готовый alert и не production-порог. Число два выбрано только для того, чтобы граница была видна на короткой серии.

\n
Контракт учебной метрики и границы применения
ЭлементРешениеПроверкаОграничение
Метрика запросовstore_http_requests_total, counterСчитаем только завершённые операцииНе объясняет причину ошибки
operationcatalog, checkout, profileПроверяем allowlist до записиНовый маршрут требует изменения контракта
outcomesuccess или errorНормализуем исход в одном местеНе заменяет код HTTP или тип ошибки
Контекст запросаЛог или trace с request IDСвязываем событие по trace IDНе добавляем уникальный ID в metric label
\n

Минимальный контракт в коде

\n

Контракт лучше закрепить рядом с точкой записи. Псевдокод ниже не зависит от конкретной client library. Он показывает порядок проверки: сначала нормализуем значения, затем обновляем счётчики. Все значения синтетические. Код не запускает Prometheus и не сообщает ничего о реальной нагрузке.

\n
const allowedOperations = new Set([\"catalog\", \"checkout\", \"profile\"]);\nconst allowedOutcomes = new Set([\"success\", \"error\"]);\n\nfunction recordFinishedRequest(event) {\n  if (!allowedOperations.has(event.operation)) {\n    throw new Error(\"unknown operation\");\n  }\n  if (!allowedOutcomes.has(event.outcome)) {\n    throw new Error(\"unknown outcome\");\n  }\n\n  requestsTotal.inc({\n    operation: event.operation,\n    outcome: event.outcome,\n  });\n  requestDuration.observe(\n    { operation: event.operation },\n    event.durationSeconds,\n  );\n}\n\n// Не добавляем сюда user_id, request_id, email, полный URL\n// или произвольный текст ошибки.
\n

Ошибку неизвестного значения нельзя молча превращать в новый label. Иначе опечатка или новый маршрут незаметно расширит набор рядов. В одном проекте допустимо вернуть событие в общий обработчик ошибок, в другом — записать его в лог и не обновлять эту метрику. Выбор зависит от контракта сервиса. Важно, чтобы отказ был видимым и не создавал бесконечный словарь значений.

\n

Запрос и отрицательный путь

\n

После экспорта можно собрать число завершённых ошибок по операции:

\n
sum by (operation) (\n  increase(store_http_requests_total{outcome=\"error\"}[10m])\n)\n\n# Учебный запрос: ищем серию с уникальным label.\nstore_http_requests_total{request_id=\"any-value\"}
\n

Первое выражение агрегирует ограниченные серии и оставляет операцию для сравнения. Второе выражение — не рекомендация, а отрицательный пример. Запрос по request_id может найти отдельный ряд, но сам способ записи создаёт новый ряд для каждого ID. Через короткое время поиск конкретного запроса станет дороже, а сборщик будет хранить данные, которые лучше подходят логам или trace. Метрика отвечает на вопрос «сколько и где», лог или trace — «какой именно запрос и почему».

\n

Есть и менее очевидный отрицательный путь. Разработчик заменяет стабильное имя маршрута на полный URL, чтобы увидеть параметры. В итоге /orders/1 и /orders/2 получают разные значения. Нормализованный маршрут решает только часть задачи: список маршрутов всё равно должен быть ограничен, а редкие динамические значения не должны проходить в label без оценки cardinality.

\n

Симптомы и действия

\n
Диагностическая таблица для label cardinality
СимптомВероятная причинаПроверкаДействие
Число рядов быстро растёт после релизаВ label попало уникальное или почти уникальное значениеПосчитать distinct values по новым labels и сравнить с diff кодаУдалить label, нормализовать значение или перенести контекст в лог
График показывает слишком много линийЗапрос сохранил лишние labels и не агрегирует ихОткрыть табличный результат и проверить число series до агрегацииОставить только dimension, по которой принимается действие; добавить sum by
Нельзя отличить ошибку одной операции от другойСчётчик не содержит стабильного operation labelСравнить экспорт и вопрос, который должен поддержать dashboardДобавить ограниченный allowlist operation
По метрике ищут конкретный запросMetric используют вместо логов или трассировкиПроверить наличие request ID и текста ошибки в labelОставить агрегат в Prometheus, связать его с trace ID в другом сигнале
Новые значения появляются без изменения схемыLabel принимает пользовательский ввод или свободный текстПроверить источник каждого label и список допустимых значенийВвести нормализацию и явный отказ для неизвестного значения
\n

Порядок проверки

\n
  1. Сформулируйте один вопрос, на который должна ответить метрика. Запишите, какое действие последует после ответа.
  2. Назначьте границу события: например, завершение HTTP-операции. Не смешивайте попытку, ответ клиента и результат фоновой очереди.
  3. Перечислите labels и для каждого укажите источник и полный список допустимых значений.
  4. Посчитайте верхнюю границу комбинаций. Умножьте размеры ограниченных наборов и отдельно учтите labels, которые добавляет окружение.
  5. Проверьте кодовую точку записи. Не допускайте URL, UUID, email, request ID и текста ошибки в label.
  6. Экспортируйте несколько учебных событий и убедитесь, что одинаковые значения дают один ряд, а неизвестные значения не проходят молча.
  7. Проверьте запрос в табличном режиме. Сначала посмотрите число возвращённых series, затем добавьте агрегацию и проверьте оставшиеся labels.
  8. Отдельно проверьте отрицательный путь: неизвестная операция, динамический URL и запрос с уникальным ID должны приводить к явному отказу или к другому сигналу.
  9. Только после этих проверок выбирайте правило или dashboard. Учебный порог нельзя переносить в production без данных о норме, окне, пропусках и стоимости ошибки.
\n

Ограничения

\n

Cardinality — не единственный риск. Даже ограниченный label может быть бесполезным, если команда не знает, какое действие следует из его значения. Метрика не показывает стек ошибки, тело запроса, пользователя или порядок событий. Для этого нужны логи и трассировка. Она также не гарантирует, что scrape не пропустил точку, что все экземпляры используют одну версию схемы или что выбранный порог отражает SLO.

\n

У Prometheus нет универсального безопасного числа для любой системы. Официальные рекомендации предлагают держать cardinality низкой и отдельно расследовать метрики, которые могут вырасти до больших значений. Фактический предел зависит от числа targets, scrape interval, retention, количества метрик и ресурсов. Поэтому число «шесть комбинаций» выше относится только к учебному сигналу; это не расчёт ёмкости конкретного кластера.

\n

Проверяемый критерий готовности

\n

Схема готова к следующему этапу, если команда может показать четыре результата: список labels с источниками и верхней границей значений; экспорт учебных событий без уникальных labels; запрос, который оставляет только нужную dimension; и отрицательный тест, в котором неизвестное или уникальное значение не создаёт новый ряд молча. После этого отдельно проверяют нагрузку, правила хранения и production-порог. До этих измерений материал остаётся проверкой контракта, а не доказательством эксплуатационного результата.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/267.json b/editorial/agent-rewrites/267.json new file mode 100644 index 0000000..e98adb7 --- /dev/null +++ b/editorial/agent-rewrites/267.json @@ -0,0 +1 @@ +{"index":267,"slug":"editorial-2020-08-practice-metrics-basics","title":"Метрики приложения: как превратить сбой в проверяемый сигнал","excerpt":"Общий график запросов не отвечает, где возникла ошибка и что проверять дальше. Разбираем небольшой контракт для HTTP-операции: тип метрики, ограниченные labels, учебный запрос и явное действие после порога.","contentHtml":"

Симптом выглядит безобидно: график HTTP-запросов растёт, но из него нельзя понять, в какой операции появились ошибки. Команда открывает несколько дашбордов, сверяет несвязанные пики и вручную ищет нужные строки в логах. За это время растёт очередь разбора, задерживается релиз, а исправление опирается на догадки. Если в метрику добавить полный URL, пользователя и текст исключения, сигнал станет ещё дороже: Prometheus получит множество почти уникальных рядов, но причина сбоя не станет яснее.

Первая метрика должна отвечать на один операционный вопрос и вести к следующей проверке. Для HTTP-операции достаточно зафиксировать завершение, стабильное имя операции и исход success или error. Затем можно посчитать ошибки за окно и решить, открыть ли связанный лог или повторить сценарий. Такая метрика не заменяет трассировку, журнал и проверку клиента. Она сужает поиск и делает его воспроизводимым.

Сначала вопрос, потом имя

Запишите вопрос до кода: «Были ли ошибки завершения checkout за последние десять минут и какую проверку выполнить, если их не меньше двух?» В нём есть операция, исход, окно и действие. Общее число запросов отвечает только на вопрос «сколько раз обработчик завершился». Оно не разделяет каталог, checkout и профиль. Поэтому счётчик нужно привязать к месту, где обработчик уже знает результат.

Увеличивайте счётчик после завершения операции. Если поставить его в начале обработчика, он будет считать попытки. Это тоже полезная величина, но её нельзя молча называть числом успешных запросов. Ошибку учитывайте в той же границе, где определяете исход. Тогда число попыток, число ошибок и длительность относятся к одному событию. Если исход приходит из внешней системы позже, границу надо описать отдельно.

В этой статье разбирается только серверная HTTP-граница. Доступность браузера, DNS, сеть, база, очередь и ручное действие оператора остаются отдельными сигналами. Один counter не доказывает, что пользователь увидел корректный экран. Он показывает, что выбранная операция завершилась с указанным исходом.

Контракт сигнала для учебного сценария
ВеличинаТип и единицаLabelsВопросНе доказывает
store_http_requests_totalcounter, завершённые запросыoperation, outcomeКакая операция и с каким исходом завершилась?Причину ошибки и путь до пользователя
store_http_request_duration_secondshistogram или summary, секундыoperationКак распределяется длительность?Причину медленного ответа
Учебное условие >= 2increase() за 10 минутoperation=checkout, outcome=errorПересекла ли фикстура границу?Production-порог и SLO

Тип метрики следует из состояния

store_http_requests_total — накопительный счётчик. Он увеличивается при событии и может вернуться к нулю после перезапуска процесса. Сырое значение counter редко отвечает на вопрос о недавнем окне. Для количества событий за интервал применяют функцию над изменением счётчика. В учебном выражении ниже используется increase(); для скорости событий обычно применяют rate().

Gauge подходит для состояния, которое может расти и уменьшаться: текущего числа запросов в работе, свободной памяти или температуры. Ставить gauge для числа ошибок за весь срок работы процесса неправильно: обновление может затереть накопленную историю. Выбирайте тип по форме величины, а не по тому, как удобнее вызвать метод библиотеки.

Для длительности храните секунды и собирайте распределение наблюдений. Histogram даёт buckets, сумму и количество наблюдений. Summary также считает сумму и количество, но его квантили имеют другую семантику и требуют отдельного выбора. В первом сигнале не нужно обещать p95 или SLO. Сначала договоритесь, что операция имеет стабильное имя и что единица времени одинакова везде.

Суффикс _total показывает накопительную природу counter. В имени длительности есть _seconds. Не смешивайте миллисекунды и секунды под одним именем. Иначе запросы будут синтаксически корректными, но сравнение значений станет ложным.

Labels должны отвечать на вопрос

Каждая уникальная комбинация имени метрики и labels образует отдельный time series. Поэтому operation должен брать значения из короткого словаря: например, catalog, checkout, profile. outcome может принимать два значения: success и error. Такой набор можно перечислить заранее и проверить в коде.

Не добавляйте в этот counter user_id, email, полный URL, request ID, текст исключения или произвольный статус из запроса. Эти данные нужны для поиска конкретного события. Поместите их в лог или трассу и свяжите записи через correlation ID. Label должен разделять агрегированные ветки, а не хранить историю отдельного пользователя.

Если значение label нельзя перечислить заранее, остановитесь и проверьте его смысл. Сколько рядов оно добавит? Какой запрос использует его? Можно ли нормализовать маршрут до шаблона, например /orders/:id, вместо полного URL с идентификатором заказа? Ответы должны быть в контракте до публикации метрики.

Учебный пример: четыре события и два snapshots

Ниже не клиент Prometheus и не результат нагрузки. Это учебный набор с выдуманными значениями. Он проверяет разрешённые labels, суммирование ошибок и поведение при неизвестной операции. В реальном сервисе инкремент выполняет выбранная библиотека, а результат нужно проверить на нужной версии сервера и с реальным интервалом scrape.

const allowedOperations = new Set(['catalog', 'checkout', 'profile']);<br>const allowedOutcomes = new Set(['success', 'error']);<br><br>const events = [{ operation: 'checkout', outcome: 'success', durationSeconds: 0.42 }, { operation: 'checkout', outcome: 'error', durationSeconds: 0.90 }, { operation: 'catalog', outcome: 'success', durationSeconds: 0.18 }, { operation: 'checkout', outcome: 'error', durationSeconds: 1.10 }];<br><br>for (const event of events) {<br>  if (!allowedOperations.has(event.operation)) throw new Error('unknown operation');<br>  if (!allowedOutcomes.has(event.outcome)) throw new Error('unknown outcome');<br>  if (event.durationSeconds &lt; 0) throw new Error('invalid duration');<br>}<br><br>// Учебный результат: checkout/error = 2.<br>// Это не production-данные и не готовый alert rule.

Проверка отбрасывает неизвестную операцию до публикации значения. Это отрицательный путь, и он важнее красивой строки exposition. Если новый endpoint молча создаёт label, дашборд может продолжить работать, а стоимость хранения и смысл агрегации изменятся незаметно. В настоящем коде ошибку нужно вернуть вызывающему слою или записать в отдельный технический сигнал.

\"Схема
Сигнал отделяет факт завершения операции от следующего шага расследования.

Запрос и порог

Для учебной серии запрос может выглядеть так:

sum(increase(store_http_requests_total{ operation=\"checkout\", outcome=\"error\" }[10m])) >= 2

Выражение суммирует изменение counter за десять минут и проверяет условие. Число 2 выбрано только для упражнения: одна ошибка показывает, что label работает, две переводят пример в другую ветку. Оно не получено из трафика, не является допустимой долей ошибок и не должно копироваться в production alert.

Запрос зависит от точек scrape и от обработки reset counter. Локальная разность двух чисел в памяти не доказывает, что production PromQL вернёт такое же значение. При перезапуске процесса, задержке scrape или отсутствии ряда результат требует отдельной проверки. Нулевое значение также не всегда означает «ошибок не было»: ряд мог ещё не появиться.

Симптом → причина → проверка → действие

Диагностика первой метрики
СимптомПричинаПроверкаДействие
Есть общий рост, но не видно операцииНет bounded label operationПосмотреть series и словарь операцийДобавить короткий словарь и тест неизвестного значения
График растёт числом рядовВ label попал ID, URL или другой unbounded valueНайти label с почти уникальными значениямиПеренести контекст в лог или trace
Ошибки считают попыткиCounter увеличивается до определения исходаСопоставить точку инкремента с завершениемРазделить attempts и outcomes или перенести инкремент
Порог ломается после перезапускаСырые значения counter сравнивают как gaugeПроверить reset и range vectorИспользовать rate() или increase()
Нет записи до первой ошибкиРяд появляется только после событияЗапросить известный labelset заранееИнициализировать нулевую серию, если это оправдано

Порядок внедрения

  1. Запишите вопрос: операция, исход, окно и действие после границы.
  2. Выберите границу завершения и определите успех и ошибку.
  3. Составьте allowlist labels. Оставьте измерения, которые можно перечислить и агрегировать.
  4. Назовите метрики по одной величине и одной базовой единице. Проверьте _total и _seconds.
  5. Добавьте проверки неизвестной операции, исхода и отрицательной длительности. Убедитесь, что labels не содержат пользовательских значений.
  6. Прогоните контролируемую серию и сравните ожидаемое число ошибок с exposition или API библиотеки.
  7. Проверьте PromQL на тестовом Prometheus. Смоделируйте reset, отсутствие ряда и задержку scrape.
  8. Привяжите каждую ветку порога к действию: открыть лог, повторить сценарий, проверить релиз или ничего не делать при учебном нуле.

Ограничения

Эта схема не показывает причину ошибки. Для неё понадобятся логи, трассы, код ответа, версия релиза и контекст внешних зависимостей. Она не измеряет пользовательский опыт, если запрос не дошёл до сервера или ответ испортился в браузере. Она не выбирает за команду SLO и не говорит, сколько ложных срабатываний допустимо.

Порог нельзя назначать по удобному числу. Нужны период наблюдения, стоимость ошибки, ожидаемый трафик и владелец реакции. Если трафик почти нулевой, две ошибки и две тысячи ошибок имеют одинаковое значение в абсолютном counter, но разный смысл для продукта. Для сравнения сервисов нужна доля ошибок или другой согласованный показатель, а не копирование окна.

Учебные операции, события, длительности и порог выдуманы. Здесь нет production-нагрузки, измеренного уменьшения инцидентов или готового alert. Проверяемый результат скромнее: один вопрос превращён в контракт, а контракт можно прогнать, запросить и связать с конкретным следующим действием.

Критерий готовности

Сигнал готов к первой проверке, если другой инженер без устного объяснения может назвать границу события, перечислить допустимые labels, воспроизвести учебные значения, получить ожидаемый результат запроса и пройти отрицательный путь с неизвестным label. После reset и пропущенного ряда команда понимает, что означает «нет данных», а что — «ошибок не было». Если пункт не выполняется, уточняйте контракт и проверку, а не добавляйте новые labels.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/268.json b/editorial/agent-rewrites/268.json new file mode 100644 index 0000000..4150382 --- /dev/null +++ b/editorial/agent-rewrites/268.json @@ -0,0 +1 @@ +{"index":268,"slug":"editorial-2020-07-field-structured-logs","title":"Структурированные логи: поля, которым можно доверять","excerpt":"Как связать событие с запросом, удалить чувствительные данные до сериализации и не превратить журнал в набор уникальных строк.","contentHtml":"

Авария начинается с простого поиска. Пользователь сообщает, что заказ не оформился. В журнале есть ошибка, но её нельзя связать с конкретным запросом: одно сообщение содержит длинный URL, другое — весь объект запроса, третье — текст исключения с номером заказа. Иногда рядом оказывается заголовок, похожий на токен. Диагностика останавливается. Инженер читает тысячи строк вручную, а затем ещё проверяет, не утёк ли секрет.

\n

Цена ошибки складывается из трёх частей. Команда дольше восстанавливает цепочку событий. Система хранения получает лишний объём и множество уникальных значений. Доступ к журналу открывает данные, которые не требовались для ответа на диагностический вопрос. Строка с красивым текстом не решает ни одну из этих проблем сама по себе.

\n

Тезис: структурированный лог — это небольшой контракт события. В нём есть устойчивое имя события, владелец операции, идентификатор связи и ограниченный набор нормализованных полей. Контракт нужно сформировать до сериализации. Тогда поиск использует поля, redaction видит структуру объекта, а каждое добавленное значение можно объяснить.

\n

Что именно делает лог полезным

\n

Сначала назовём вопрос. Например: «какой запрос привёл к отказу адаптера?» Для него нужны request_id, имя события, сервис, маршрут-шаблон, код результата и нормализованный код причины. Полное тело запроса не нужно. Текст исключения тоже не нужен, если в нём нет устойчивого кода, который можно проверить.

\n

request_id связывает записи одного входного действия. Он подходит для поиска одной истории. Он не доказывает причинность и не заменяет трассировку: два сервиса могут записать события с разной задержкой, а фоновой задаче может потребоваться новый operation_id. Это ограничение важно назвать до внедрения, иначе один идентификатор начнут использовать как универсальную модель системы.

\n

У остальных полей другой режим. event описывает тип события и должен иметь небольшой словарь. route содержит шаблон, а не конкретный путь с идентификатором заказа. service обозначает владельца границы, а не имя pod или локальный путь. error_code называет известную причину из ограниченного набора. Эти поля подходят для фильтра и группы.

\n
Назначение полей структурированного события
ПолеРольДопустимое значениеЧего не делать
request_idнайти одну историюстабильный идентификатор запросаиспользовать как метрику-группу
eventназвать тип событияadapter.response.rejectedвставлять номер заказа или текст ошибки
routeсравнить обработчики/orders/:orderIdписать полный URL и query string
error_codeразделить причиныSCHEMA_MISMATCHсохранять произвольное сообщение исключения
authorizationне нужна для поиска событияне записывать или заменить на [REDACTED]передавать сырой объект headers
\n

Redaction выполняется до JSON.stringify

\n

Поздняя маскировка ломается на границе строк. Если сначала выполнить JSON.stringify(request), а потом искать секрет регулярным выражением, правило зависит от вложенности, регистра ключа и формата значения. Неожиданный объект легко попадёт в stdout целиком. Маска должна получить объект, пройти известные ключи и только затем передать безопасную копию сериализатору.

\n
const sensitiveKeys = new Set(['authorization', 'cookie', 'password', 'token', 'secret']);\n\nfunction redact(value, key = '') {\n  if (sensitiveKeys.has(key.toLowerCase())) return '[REDACTED]';\n  if (Array.isArray(value)) return value.map((item) => redact(item));\n  if (value && typeof value === 'object') {\n    return Object.fromEntries(\n      Object.entries(value).map(([name, item]) => [name, redact(item, name)]),\n    );\n  }\n  return value;\n}\n\nconst trainingContext = {\n  request_id: 'req-demo-01',\n  event: 'http.request.completed',\n  headers: { authorization: 'synthetic-placeholder' },\n  http: { route: '/orders/:orderId', status_code: 202 },\n};\n\nconsole.log(JSON.stringify(redact(trainingContext)));\n// Учебный пример: placeholder не является реальным секретом.
\n

В результате учебного вызова значение headers.authorization должно стать [REDACTED]. Проверять нужно именно строку, которая уйдёт в поток вывода. Проверка внутреннего объекта недостаточна: код может безопасно изменить копию, а затем случайно залогировать исходную. Также нельзя считать список ключей полной защитой. Интеграция может назвать поле credential, вложить секрет в строку или передать его под другим именем.

\n

Надёжнее не передавать логгеру сырой запрос. Контроллер выбирает метод, шаблон маршрута и код ответа. Адаптер выбирает своё имя и нормализованный код ошибки. Redaction остаётся второй границей, а не разрешением писать любой JSON. Если поле не нужно для конкретного вопроса, его удаляют, а не маскируют «на всякий случай».

\n

Кардинальность определяет качество поиска

\n

Кардинальность — это число разных значений поля. Для event она должна быть низкой. Иначе вместо одного фильтра adapter.response.rejected появятся сотни имён: order.7421.failed, order.7422.failed и так далее. Журнал сохранит все строки, но перестанет давать устойчивую группу. Полный URL, свободный текст исключения и имя пользователя создают ту же проблему.

\n

Высокая кардинальность иногда нужна. У request_id она намеренно высокая, потому что поле находит одну историю. Ошибка возникает, когда его начинают использовать для агрегирования или строят по нему долговременный отчёт. Для группы нужны устойчивые поля. Для единичного расследования нужен идентификатор с ограниченным сроком и понятной областью действия.

\n
\"Дерево
Поле проходит проверку вопросом «зачем оно нужно?». Если значение не ведёт к проверяемому действию, оно не входит в событие. Иллюстрация показывает учебную схему, а не карту конкретной production-системы.
\n

Один учебный сбой

\n

Ниже приведён синтетический JSONL-пример. Он показывает форму данных, а не результат реального инцидента. Один request_id связывает вход, отказ адаптера и ответ gateway. Внешняя причина сведена к коду. Номер заказа и тело запроса отсутствуют.

\n
{\"timestamp\":\"2020-07-14T09:30:11.001Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.received\",\"request_id\":\"req-demo-01\",\"http\":{\"route\":\"/orders/:orderId\"}}\n{\"timestamp\":\"2020-07-14T09:30:11.024Z\",\"level\":\"warn\",\"service\":\"demo-catalog-api\",\"environment\":\"training\",\"event\":\"adapter.response.rejected\",\"request_id\":\"req-demo-01\",\"adapter\":{\"name\":\"training-inventory\",\"error_code\":\"SCHEMA_MISMATCH\"}}\n{\"timestamp\":\"2020-07-14T09:30:11.042Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.completed\",\"request_id\":\"req-demo-01\",\"http\":{\"status_code\":502}}\n\njq -c 'select(.request_id == \"req-demo-01\")' training.jsonl\n# Учебный запрос: он не доказывает поведение production.
\n

По этой цепочке можно проверить только форму расследования: найти три записи, увидеть известный код и отделить владельца отказа от gateway. Нельзя делать вывод о частоте 502, времени восстановления или работе реального адаптера. Для таких выводов нужны данные конкретной среды и отдельные измерения. Учебный пример полезен тем, что фиксирует минимальный контракт без ложной статистики.

\n

Симптом → причина → проверка → действие

\n
Диагностика типичных дефектов журнала
СимптомПричинаПроверкаДействие
Ошибка найдена, но запрос не находитсяrequest_id теряется на HTTP-границесравнить записи клиента и сервиса по одному учебному идентификаторупередавать контекст явно через исходящий клиент
В событии виден токен или cookieсырой объект сериализовали раньше redactionпроверить финальную stdout-строку на запрещённые значениявыбирать поля вручную и маскировать до сериализации
Каждая ошибка создаёт новый eventдинамическое имя содержит id или свободный текстпосчитать варианты event на учебной выборкеоставить устойчивое имя и добавить ограниченный error_code
Группа по сервису постоянно меняетсяв service попали pod, путь или версия процессасравнить значение с владельцем логической границыотделить service от instance и deployment-метаданных
Лог красивый, но JSON не разбираетсястрока собрана вручную или содержит неэкранированный вводпрогнать каждую строку через JSON.parseсериализовать объект штатным JSON-генератором и санитизировать ввод
\n

Порядок внедрения

\n
  1. Сформулировать один диагностический вопрос и границу операции.
  2. Назначить владельца request_id и описать поведение для отсутствующего или невалидного значения.
  3. Составить маленький словарь событий, маршрутов и кодов ошибок. Не добавлять динамику в имена.
  4. Выбрать безопасные поля на каждой границе. Не передавать полный request, headers, body или объект пользователя.
  5. Применить redaction к синтетическому объекту до сериализации и проверить итоговый JSON.
  6. Сделать три связанные учебные записи от двух модулей и найти их по одному request_id.
  7. Проверить отрицательный путь: ошибка обработчика, отсутствующий идентификатор, невалидный заголовок и неожиданный внешний текст.
  8. Зафиксировать критерий готовности в тесте или проверяемой команде, а затем проверить реальные форматы конкретного логгера.
\n

Ограничения

\n

Структурированный лог не создаёт трассировку. Он не показывает дочерние spans, не исправляет рассинхрон часов и не объясняет фоновые задачи, если для них не определён отдельный идентификатор. Для распределённого критического пути понадобится трассировочный контракт.

\n

Redaction защищает только известные формы. Он не распознаёт любой секрет и не заменяет классификацию данных, права доступа, срок хранения и контроль конфигурации. Высокая кардинальность не всегда вредна: уникальный идентификатор полезен для одной истории, но опасен как поле длительной агрегации. Правило зависит от назначения поля.

\n

Критерий готовности проверяемый: каждая учебная строка проходит JSON.parse; три связанные записи находятся по одному request_id; event, service и route не содержат динамических идентификаторов; запрещённые ключи не выходят в исходном виде; отрицательный путь сохраняет нормализованный error_code. Если хотя бы одно условие не выполняется, контракт ещё не готов.

\n

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/269.json b/editorial/agent-rewrites/269.json new file mode 100644 index 0000000..6bb6691 --- /dev/null +++ b/editorial/agent-rewrites/269.json @@ -0,0 +1,7 @@ +{ + "index": 269, + "slug": "editorial-2020-07-mechanism-structured-logs", + "title": "Один request_id через границы: как связать журнал без ложной трассировки", + "excerpt": "Если gateway, API и worker пишут рядом, но не связывают события, диагностика превращается в догадку. Разбираем владельца request_id, передачу контекста, отрицательный путь и границы корреляции.", + "contentHtml": "

Симптом появляется во время разбора сбоя: gateway вернул 502, API записал ошибку адаптера, а worker сообщил о повторе операции. Время у строк почти одинаковое, но соседний запрос мог пройти в тот же момент. Нельзя доказать, что записи относятся к одной операции. Цена ошибки — изменить таймаут не того сервиса, повторить уже принятую запись или закрыть инцидент без найденной границы потери контекста.

\n

Тезис статьи простой: структурированный лог полезен только тогда, когда событие можно связать с проверяемым действием. Для одного HTTP-запроса достаточно начать с внутреннего request_id. Входная граница принимает или создаёт один идентификатор, проверяет его, передаёт через исходящий вызов и добавляет в дочерний логгер. Это корреляция, а не распределённая трассировка. Она не создаёт spans, не объясняет критический путь и не доказывает причинность.

\n
\"Схема
Один ключ проходит через входную границу и исходящий HTTP-вызов. Схема показывает корреляцию событий, но не изображает spans и не измеряет критический путь.
\n

Владелец идентификатора находится на входе

\n

Идентификатор должен иметь одного владельца. Для HTTP им становится middleware или обработчик, который первым принимает запрос. Он читает X-Request-Id, но не копирует заголовок вслепую. Сначала проверяет длину и допустимые символы. Если значение отсутствует или не подходит формату, граница создаёт новое. Внутренний сервис не должен незаметно заменить этот ключ своим.

\n

Входной заголовок — служебный контекст, а не имя пользователя, номер заказа или секрет. Не помещайте в него персональные данные. Не разрешайте управляющие символы и произвольную длинную строку. В учебном примере формат читаемый: req-demo-YYYYMMDD-NN. В рабочем сервисе формат может быть UUID или другим принятым значением. Важны единый владелец, единая проверка и одно значение на путь запроса.

\n
Диагностика потери связи между событиями
СимптомПричинаПроверкаДействие
Gateway и API пишут разные request_idВнутренний модуль сгенерировал новый ключСверить входной заголовок и код создания контекстаОставить генерацию только на входной границе
Событие API не находится по ключу gatewayИсходящий клиент не передал заголовокПроверить фактические headers учебного вызоваПередать контекст явно в сигнатуру клиента
Ошибка есть, но request_id отсутствуетВетка catch создала новый логгерСделать контролируемую ошибку и найти её событиеИспользовать тот же дочерний логгер
Две попытки выглядят как два запросаДля локальной операции нет operation_idСверить request_id, номер попытки и состояниеДобавить operation_id, не заменяя request_id
Ключ совпадает, но причина неяснаКорреляцию приняли за причинностьПроверить параллельные ветви, часы и порядок событийВвести отдельный контракт трассировки
\n

Передача контекста — это зависимость

\n

Глобальная переменная для «текущего запроса» ломается при конкуренции. Второй запрос перезаписывает значение, пока первый ещё выполняется. События получают чужой ключ. Ещё одна хрупкая схема — передавать строку неявно и надеяться, что каждый вызывающий код добавит её в лог. Место передачи скрывается, а ошибка проявляется только в отдельной ветке.

\n

Передавайте небольшой контекст явно. Он может содержать request_id и дочерний логгер. Функция, которая вызывает другой сервис, получает его параметром. Такой контракт виден в сигнатуре, проверяется тестом и читается в ревью. Библиотека логирования помогает прикрепить поля к дочернему логгеру, но не знает, какой клиент вы вызовете и где создаётся новая операция.

\n
function isRequestId(value) {\n  return typeof value === 'string'\n    && /^req-demo-[0-9]{8}-[0-9]{2}$/.test(value);\n}\n\nfunction makeRequestContext(baseLogger, incoming) {\n  const requestId = isRequestId(incoming)\n    ? incoming\n    : 'req-demo-20200714-01';\n\n  return {\n    request_id: requestId,\n    log: baseLogger.child({\n      request_id: requestId,\n      service: 'demo-gateway',\n      environment: 'training'\n    })\n  };\n}\n\nasync function reserve(context, client) {\n  context.log.info(\n    { event: 'catalog.reserve.started' },\n    'Synthetic reservation started'\n  );\n\n  return client.post('/training/reservations', {\n    headers: { 'x-request-id': context.request_id }\n  });\n}
\n

Код учебный. Фиксированное значение делает пример воспроизводимым. Оно не заменяет генератор случайных или криптографически стойких идентификаторов и не моделирует сервер, конкуренцию или сохранение контекста в очереди. В реальном коде не отражайте входное значение клиенту без правил валидации и не записывайте в лог полный объект запроса.

\n

У дочерней операции может быть собственный operation_id. Например, один запрос запускает две попытки резервирования. request_id отвечает на вопрос «к какому входному действию относится событие?». operation_id отвечает на вопрос «какая локальная попытка его создала?». Не подменяйте один ключ другим. Если вопрос не требует различать попытки, дополнительное поле только увеличит схему.

\n

Один синтетический путь в JSONL

\n

Следующий набор полностью учебный. Идентификатор, время, маршрут и сообщения придуманы для проверки механизма. Это не выгрузка production-журнала и не результат измерения реального сервиса.

\n
{\"timestamp\":\"2020-07-14T09:30:11.001Z\",\"service\":\"demo-gateway\",\"level\":\"info\",\"event\":\"http.request.received\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request accepted\"}\n{\"timestamp\":\"2020-07-14T09:30:11.018Z\",\"service\":\"demo-catalog-api\",\"level\":\"info\",\"event\":\"catalog.reserve.started\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic reservation started\"}\n{\"timestamp\":\"2020-07-14T09:30:11.042Z\",\"service\":\"demo-gateway\",\"level\":\"info\",\"event\":\"http.request.completed\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request completed\"}\n\njq -s 'map(select(.request_id == \"req-demo-20200714-01\")) | sort_by(.timestamp)' training.jsonl
\n

Ожидаемый учебный результат — три записи: вход, локальная операция и завершение. Если строка API имеет другой ключ, ищите повторную генерацию на исходящем вызове. Если строки API нет, проверяйте передачу заголовка и наличие события. Если завершение повторилось, разбирайте повторный вход или локальные попытки. Поиск по времени здесь только помогает читать вывод. Он не доказывает принадлежность.

\n

Ошибочный путь должен быть виден

\n

Самый важный лог часто теряет контекст в обработчике исключения. Код ловит ошибку, создаёт новый логгер и записывает только текст. Для пользователя ответ может быть правильным, но для диагностики связь исчезает. Проверка должна специально вызвать контролируемую ошибку и убедиться, что в событии остались request_id, service, стабильный event и нормализованный код ошибки.

\n

Не сериализуйте исключение целиком без фильтра. В нём могут оказаться заголовки, токены, cookie или тело запроса. Сначала выберите поля, нужные для решения: класс ошибки, внутренний код, безопасное описание и место сбоя. Защита должна работать до сериализации. Вложенный логгер облегчает передачу контекста, а redaction ограничивает чувствительные поля; ни один из механизмов не исправляет неверно выбранную схему.

\n

Отложенная задача требует отдельного решения. Если очередь действительно начинает новую операцию, не выдавайте её событие за непрерывное продолжение HTTP-запроса. Можно сохранить ссылку на исходный request_id как связь с инициатором, а попытке назначить собственный operation_id. Если обработчик живёт дольше HTTP-контекста, глобальная переменная особенно опасна. В этой статье очередь не реализуется: граница зафиксирована как ограничение примера.

\n

Корреляция не равна трассировке

\n

Одинаковый request_id показывает принадлежность событий одному входному действию. Он не доказывает порядок на уровне сети и процессора. Две ветви могут идти параллельно. Часы разных машин могут расходиться. Асинхронный обработчик может записать событие после ответа. Сортировка JSONL по timestamp не превращает журнал в граф причин.

\n

Если нужно найти критический путь, измерить ожидание между сервисами или связать дочерние операции в нескольких процессах, нужен отдельный trace-контракт. В нём описывают родительские и дочерние spans, перенос контекста, sampling и проверку экспортируемых данных. Нельзя назвать простой внутренний заголовок распределённой трассировкой только потому, что его значение повторяется в нескольких логах.

\n

Порядок внедрения и проверки

\n
  1. Назовите входную HTTP-границу и объявите её владельцем request_id.
  2. Опишите допустимый формат, длину и действие при отсутствии или невалидном заголовке.
  3. Создайте дочерний логгер на границе и добавьте request_id в обязательный набор событий.
  4. Передайте контекст явно через исходящий HTTP-клиент; проверьте заголовок на обеих сторонах.
  5. Сделайте синтетический путь с событиями входа, локальной операции и завершения.
  6. Проверьте отрицательные случаи: потерянный заголовок, повторная генерация, ошибка в catch и две локальные попытки.
  7. Отдельно проверьте redaction и убедитесь, что ключ не содержит пользователя, заказ или секрет.
  8. Решите, нужен ли operation_id или уже требуется полноценный trace-контракт.
\n

Ограничения и критерий готовности

\n

Эта схема не даёт метрик latency, SLO, распределённых spans, гарантии доставки логов или доказательства причинности. Она не решает передачу контекста через каждую очередь и не определяет retention. Она также не отменяет валидацию входных заголовков, контроль доступа и правила удаления чувствительных данных. Переход на библиотеку сам по себе не закрывает ни одну из этих границ.

\n

Механизм готов для заявленного узкого вопроса, если выполнены четыре условия. Входная граница единолично владеет ключом. Два сервиса получают один и тот же request_id через проверенный HTTP-вызов. Синтетический успешный и ошибочный пути дают события, которые находятся одним фильтром. Тест отдельно фиксирует потерю или подмену ключа и не принимает совпадение времени за доказательство. Если хотя бы одно условие не выполнено, корреляция не подтверждена.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/270.json b/editorial/agent-rewrites/270.json new file mode 100644 index 0000000..ec7782c --- /dev/null +++ b/editorial/agent-rewrites/270.json @@ -0,0 +1,6 @@ +{ + "index": 270, + "slug": "editorial-2020-07-practice-structured-logs", + "title": "Структурированные логи: как связать один запрос и быстро найти ошибку", + "excerpt": "Контракт JSON-события, единый request_id и проверка отрицательного пути помогают найти запрос без поиска по случайному тексту. Разбираем форму записи, границы корреляции и redaction.", + "contentHtml": "

В журнале появляются три строки: «ошибка оплаты», «запрос завершён» и стек исключения. Все записи стоят рядом, но неизвестно, относятся ли они к одному запросу. Инженер ищет по минуте, маршруту и фрагменту текста. Он легко связывает чужие события. Цена ошибки — неверная правка, повторный инцидент и потерянное время во время сбоя.

\n

Причина обычно не в отсутствии логов. Приложение пишет слишком мало устойчивых признаков. Сообщение меняется от версии к версии, порядок строк зависит от параллельной работы, а полный объект запроса содержит лишние и чувствительные данные. Одна строка не даёт надёжного ключа поиска.

\n

Рабочий минимум — контракт одного JSON-события и один request_id на входное HTTP-действие. Обязательные поля имеют постоянные имена. Локальный модуль добавляет только свой контекст. Такой журнал отвечает на узкий вопрос: какие известные события принадлежат этому запросу? Он не заменяет распределённую трассировку и не доказывает причинность.

\n

Из чего состоит событие

\n

Структурированный лог — это объект, а не строка, которую потом приходится разбирать регулярным выражением. Человек читает короткое поле message. Поиск и агрегация используют event, service, уровень и идентификатор запроса. JSON сам по себе ничего не гарантирует. Поля становятся полезными только тогда, когда команда закрепила их смысл и форму.

\n
Минимальный контракт учебного события
ПолеКто задаётФормаВопрос
timestampлоггерUTC ISO-8601Когда произошло событие?
levelоперацияdebug, info, warn, errorНасколько срочно его смотреть?
serviceконфигурацияустойчивое имяГде оно произошло?
environmentконфигурациянапример, trainingНе смешаны ли контуры?
eventвладелец операцииnoun.verbЧто произошло?
request_idHTTP-границаодно проверенное значениеКакие записи относятся к запросу?
messageвладелец операциикороткий текстЧто увидит читатель?
\n

Общие поля живут в корне объекта. Контекст операции — во вложенном блоке http, job или adapter. Не передавайте в логгер целиком req, ответ базы или объект пользователя. Форма такого объекта меняется без предупреждения. В ней могут оказаться заголовки, тело запроса и секреты. Выберите несколько полей, которые закрывают конкретный вопрос.

\n
\"Схема
Общий контракт остаётся коротким, а локальный HTTP-контекст находится в отдельном блоке. Учебная иллюстрация показывает форму записи, а не результат production-наблюдения.
\n

Собираем запись до вывода

\n

Функция формирования события должна принимать только известный контекст. Она проверяет обязательные ключи до сериализации. Это не универсальная библиотека и не схема всей системы. В учебном примере время и идентификатор заданы явно, чтобы результат можно было повторить. В рабочем приложении логгер обычно добавляет время сам.

\n
const required = [\n  'timestamp', 'level', 'service', 'environment',\n  'event', 'request_id', 'message'\n];\n\nfunction buildEvent(base, local) {\n  const event = { ...base, ...local };\n  const missing = required.filter((name) => event[name] === undefined);\n  if (missing.length) {\n    throw new Error('missing log fields: ' + missing.join(', '));\n  }\n  return event;\n}\n\nconst entry = buildEvent(\n  {\n    timestamp: '2020-07-14T09:30:11.042Z',\n    level: 'info',\n    service: 'demo-catalog-api',\n    environment: 'training',\n    event: 'http.request.completed',\n    request_id: 'req-demo-20200714-01',\n    message: 'Synthetic request completed'\n  },\n  { http: { method: 'POST', route: '/training/orders/:orderId', status_code: 202 } }\n);\n\nprocess.stdout.write(JSON.stringify(entry) + '\\n');
\n

route здесь — шаблон, а не полный URL с номером заказа. Так сто заказов не создают сто новых значений маршрута. Имя event тоже выбирают из небольшого словаря: http.request.received, order.validation.failed, http.request.completed. Не включайте в имя номер ошибки, текст исключения или идентификатор пользователя. Поле перестанет быть пригодным для группировки.

\n

Проверка формы и проверка цепочки — разные проверки. Первая смотрит один объект: есть ли ключи, допустим ли уровень, замаскированы ли чувствительные значения. Вторая смотрит несколько событий: совпадает ли request_id, есть ли ожидаемые начало и завершение, не пропал ли контекст на границе сервиса. Если первая проверка падает, исправляют builder. Если вторая — место передачи контекста.

\n

Один синтетический запрос

\n

Ниже приведены учебные записи. Они не взяты из production, не описывают реального пользователя и не доказывают работу конкретной системы. Их задача — показать минимальный запрос, который можно выбрать по одному ключу.

\n
{\"timestamp\":\"2020-07-14T09:30:11.001Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.received\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request accepted\"}\n{\"timestamp\":\"2020-07-14T09:30:11.021Z\",\"level\":\"info\",\"service\":\"demo-catalog-api\",\"environment\":\"training\",\"event\":\"order.validation.completed\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic order passed validation\"}\n{\"timestamp\":\"2020-07-14T09:30:11.042Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.completed\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request completed\"}\n\n# Учебный поиск в JSONL, не production-команда:\njq -c 'select(.request_id == \"req-demo-20200714-01\")' training.jsonl
\n

Ожидаемый результат содержит три записи и один идентификатор. Если запись API отсутствует, проверяют передачу контекста и наличие события в этом модуле. Если API пишет другой идентификатор, ищут вторую генерацию на исходящем вызове. Если завершение повторяется, проверяют повторный вызов обработчика. Эти выводы ограничены учебным набором. По ним нельзя утверждать, что все реальные сервисы системы пишут логи.

\n

Симптом → причина → проверка → действие

\n
Короткий маршрут диагностики
СимптомПричинаПроверкаДействие
Ошибка есть, но запрос не найтиНет общего request_idСравнить обязательные поля у соседних событийНазначить владельца идентификатора на HTTP-входе
Один модуль виден, второй нетКонтекст не передан через HTTP-клиентПроверить заголовок и событие на обеих сторонахПередавать контекст явно в сигнатуре адаптера
JSON валиден, поиск ломаетсяrequestId вместо request_id или свободное имя событияПроверить ключи и словарь событийИсправить builder и добавить проверку схемы
Лог содержит токен или cookieСериализовали сырой объект запросаПроверить запись до stdout и fixture redactionИсключить поле или заменить значение до JSON
События рядом по времени, но связь не доказанаВремя ошибочно приняли за корреляциюНайти общий идентификатор в каждой записиНе делать вывод без ключа; для причинности нужна трассировка
\n

Не теряем request_id на границах

\n

Идентификатор создаёт или принимает первая HTTP-граница. Она проверяет длину и допустимые символы. Внутренний формат учебного примера — req-demo-YYYYMMDD-NN. В рабочем коде формат может быть другим, но владельцем остаётся один слой. Клиентский заголовок нельзя без проверки копировать в журнал: он может быть слишком длинным, содержать управляющие символы или использоваться для загрязнения поиска.

\n

После входа создают дочерний логгер с общими полями и передают его в следующий модуль. Не храните текущий идентификатор в глобальной переменной. Два параллельных запроса перезапишут значение. Не генерируйте новый ключ в адаптере. Иначе каждая запись будет выглядеть аккуратно, но цепочка распадётся.

\n

Обработчик ошибки должен использовать тот же контекст. Частая ошибка — создать новый логгер внутри catch и записать стек без request_id. Для диагностики это самое дорогое место: именно важное событие выпадает из поиска. Сериализуйте нормализованный код ошибки и короткое сообщение. Полный объект исключения может содержать запрос, заголовки и данные внешней системы.

\n

Фоновая задача требует отдельного решения. Она может продолжать исходное действие, а может быть новым действием. Механически копировать request_id нельзя: это создаёт ложную непрерывную трассу. Если связь нужна, храните её как явную ссылку, а для попытки используйте отдельный operation_id. В этой статье фоновая очередь не моделируется.

\n

Что нельзя смешивать

\n

request_id полезен для поиска одной истории. Он высококардинален и плохо подходит для графика. event и service имеют ограниченный словарь и подходят для подсчёта повторяющихся случаев. Не превращайте уникальный идентификатор в имя метрики или тег каждого агрегата.

\n

Корреляция не равна причинности. Одинаковый ключ показывает принадлежность одному входному действию, но не показывает точный порядок параллельных операций. Часы машин могут расходиться. Отложенная запись может появиться позже. Если нужен критический путь, дочерние операции или межсервисные задержки, нужна отдельная модель трассировки. Сортировка строк по времени эту модель не создаёт.

\n

Redaction выполняют до сериализации. Маска в интерфейсе просмотра недостаточна: секрет уже мог попасть в файл, транспорт или резервную копию. Минимальный список для отдельной проверки — authorization, cookie, password, token и secret. Реальный список зависит от приложения и должен жить рядом с кодом формирования записи.

\n

Порядок действий

\n
  1. Выберите один HTTP-маршрут и назовите владельца request_id.
  2. Зафиксируйте обязательные поля и словарь из нескольких имён событий.
  3. Добавьте валидатор идентификатора и явное поведение для отсутствующего или неверного входного значения.
  4. Создайте дочерний логгер на входе и передайте контекст через исходящий клиент.
  5. Соберите три синтетические записи от двух модулей с одним ключом.
  6. Проверьте форму каждого объекта и поиск всей цепочки в JSONL.
  7. Добавьте redaction до сериализации и отдельную отрицательную проверку для секретного поля.
  8. Только после успешной проверки перенесите контракт на соседний маршрут.
\n

Ограничения и критерий готовности

\n

Минимальный контракт не показывает работу сервиса, полноту журнала, доставку записи, задержку коллектора или реальный пользовательский эффект. Учебный fixture не читает production-логи, не отправляет сеть и не устанавливает причину инцидента. Синтетические значения нельзя выдавать за измеренные результаты.

\n

Практика готова, когда разрешённый учебный запрос возвращает все ожидаемые события по одному request_id; каждый объект проходит проверку обязательных полей; отрицательный тест обнаруживает потерю или замену ключа; redaction не выпускает заданные чувствительные значения; команда может назвать границу, на которой нужно искать пропажу контекста. Если хотя бы один пункт не выполнен, контракт ещё не даёт проверяемой диагностики.

\n

Проверяемые источники

\n"} diff --git a/editorial/agent-rewrites/271.json b/editorial/agent-rewrites/271.json new file mode 100644 index 0000000..16369ed --- /dev/null +++ b/editorial/agent-rewrites/271.json @@ -0,0 +1,7 @@ +{ + "index": 271, + "slug": "editorial-2020-06-field-retry-idempotency", + "title": "Потерянный ответ и повтор запроса: как не создать второй эффект", + "excerpt": "Timeout сообщает только о потерянном ответе. Разбираем связь requestId и Idempotency-Key, replay сохранённого результата, конфликт payload и границу, за которой автоматический retry нужно остановить.", + "contentHtml": "

Пользователь отправляет заявку, ждёт две секунды и видит timeout. Он нажимает кнопку ещё раз. Через минуту в системе появляются две заявки, два письма или два списания. В логах первой попытки уже есть 201 Created, но браузер его не получил. Цена ошибки — не только дубль. Команда тратит время на ручное удаление, пользователь не понимает, какая запись настоящая, а повторная попытка может уйти во внешнюю систему, где откат невозможен.

\n

Timeout не доказывает, что сервер ничего не сделал. Он говорит только, что конкретный клиент не дождался события в своём лимите. Запрос мог не выйти из клиента, мог застрять в proxy, мог завершить запись после закрытия соединения или мог сохранить результат и потерять ответ на обратном пути.

\n

Тезис простой: безопасный retry повторяет один пользовательский intent, а не последний HTTP-пакет. Для этого сервер связывает попытки по одному Idempotency-Key, проверяет тот же значимый payload и сохраняет terminal-результат. requestId при этом меняется на каждой сетевой попытке. Если сервис не умеет отличить повтор от новой команды, автоматический retry после неизвестного исхода нужно остановить.

\n

Сначала разделите попытку и intent

\n

requestId отвечает на вопрос «какой сетевой проход мы сейчас видим?». Его создают для запроса, который прошёл через клиент, proxy и приложение. При повторе он должен быть новым. Это помогает собрать логи именно этой доставки.

\n

Idempotency-Key отвечает на другой вопрос: «какие доставки относятся к одному действию пользователя?». Клиент сохраняет его рядом с payload с момента подтверждения формы до terminal-результата. Повтор после timeout отправляет тот же ключ и те же значимые поля. Если пользователь изменил заявку, появился новый intent. Старый ключ нельзя переиспользовать для нового тела.

\n

Заголовок не делает POST идемпотентным сам по себе. Контракт должен описать область уникальности ключа, способ сравнения тела, срок хранения записи и ответ для повтора. В одной области ключ может быть уникален для пользователя, клиента, заказа или другого владельца операции. Нельзя выбрать область по удобству таблицы: слишком широкая область блокирует чужие операции, слишком узкая пропускает дубль.

\n
\"Временная
Новый requestId показывает новую доставку. Тот же Idempotency-Key связывает её с прежним intent.
\n

Механизм: резерв, эффект, replay

\n

Серверу нужна запись состояния операции, а не флаг seen=true. Минимальная модель содержит область ключа, сам ключ, отпечаток значимого payload, состояние, HTTP-статус, безопасное тело ответа, идентификатор результата и срок хранения.

\n

Первый запрос атомарно резервирует ключ в состоянии in_progress. Только владелец резерва может выполнить бизнес-эффект. Параллельный запрос с тем же ключом не запускает обработчик второй раз: он получает документированный ответ «операция выполняется» или читает результат после завершения. Запрос с другим отпечатком получает конфликт до бизнес-эффекта.

\n

После эффекта сервис сохраняет completed и данные, которые нужны для повторного ответа. Для операции внутри одной базы запись ключа, бизнес-объект и terminal-результат стоит зафиксировать одной транзакцией. Тогда повтор видит либо согласованный результат, либо отсутствие всей операции. Само наличие уникального индекса не заменяет обработку состояний, но защищает от гонки двух процессов.

\n
POST /v1/demo-requests HTTP/1.1\nIdempotency-Key: demo-key-271-a7f1\nX-Request-Id: req-51\nContent-Type: application/json\n\n{\"topic\":\"bundle review\",\"note\":\"учебная заявка\"}\n\n// Ответ потерян для клиента. Повтор:\nPOST /v1/demo-requests HTTP/1.1\nIdempotency-Key: demo-key-271-a7f1\nX-Request-Id: req-52\nContent-Type: application/json\n\n{\"topic\":\"bundle review\",\"note\":\"учебная заявка\"}\n\n// Ожидаемый контракт учебного примера:\nHTTP/1.1 201 Created\n{\"requestId\":\"demo-request-42\",\"state\":\"accepted\"}
\n

Это учебный пример. Адрес, ключ, идентификаторы и тело не относятся к production-сервису. Он проверяет только логическое свойство: один ключ и один payload дают один результат, а повтор возвращает тот же результат. Если первый запрос завершился эффектом, но ответ не дошёл до клиента, второй вызов не создаёт новую запись.

\n

Симптом → причина → проверка → действие

\n
Диагностика повтора по одному пользовательскому intent
СимптомПричинаПроверкаДействие
После timeout появились две записиПовтор получил новый ключ или резерв не был атомарнымСверить ключи, отпечатки payload и terminal-записиОстановить blind retry; добавить уникальную границу и тест гонки
В логах есть 201, UI показал ошибкуРезультат сохранился после отправки или до потери ответаСопоставить время записи, response и client timeoutПовторить тем же ключом и вернуть сохранённый result ID
Тот же ключ пришёл с другим теломКлюч переиспользовали для нового intent или клиент изменил payloadСравнить canonical payload или его fingerprintВернуть отдельный conflict без запуска обработчика
Повтор видит in_progressПервая попытка ещё работает или оборвалась до terminal-записиПроверить владельца резерва, heartbeat и дедлайнВернуть состояние по контракту; не удалять запись ради нового запуска
Внутри базы дубля нет, снаружи он естьВнешний вызов был до сохранения terminal-состоянияНайти стабильный business ID и запись поставщикаОстановить retry до внешнего ключа или маршрута сверки результата
\n

Для первой проверки не нужна полноценная distributed tracing система. Достаточно безопасных полей в существующих журналах: время, маршрут, requestId, нормализованный идентификатор ключа, решение дедупликации, состояние и result ID. Полный payload, cookie, authorization и секрет самого ключа в общий лог не кладут. Они расширяют риск утечки и редко помогают отличить replay от второго эффекта.

\n

Бюджет времени не отменяет результат

\n

У операции должен быть общий дедлайн intent. В него входят ожидание первой попытки, пауза, допустимый retry и время, за которое интерфейс покажет неопределённый исход. Каждый новый retry не должен начинать этот бюджет заново. Иначе перегруженная зависимость получает бесконечный поток одинаковых команд.

\n

Нужно отдельно проверить отношения таймеров. Клиент может закрыть ожидание раньше proxy. Proxy может продолжить запрос после закрытия вкладки. Сервер может записать результат после client timeout. Это допустимые варианты доставки. Ошибка появляется там, где следующий запрос не несёт связь с первым intent и превращается в новую команду.

\n

Статус сам по себе не выбирает действие. 503 может сопровождаться Retry-After, но он не расширяет общий deadline. 4xx обычно останавливает автоматический повтор, однако плохой контракт сервера может сохранить эффект до формирования ответа. Поэтому ключ, состояние операции и журнал важнее простого списка кодов.

\n

Отрицательный путь: когда повтор запрещён

\n

Если сервис не хранит idempotency-запись, клиент не знает, был ли эффект. В этом случае нельзя честно сказать «повтор безопасен». Покажите неопределённый исход, сохраните данные формы и передайте операцию в путь проверки статуса. Для платежа, заказа, письма или другой необратимой команды это безопаснее второго POST вслепую.

\n

Внешняя система создаёт отдельную границу. Локальная транзакция не откатывает HTTP-вызов поставщику. Сервис может вызвать поставщика, получить результат, упасть до записи completed и после рестарта увидеть только in_progress. Удалить такую строку и повторить вызов — способ создать дубль. Нужен стабильный внешний идентификатор, idempotency-механизм поставщика или проверка результата по business ID.

\n

Срок хранения ключа тоже часть контракта. Он должен покрывать максимальный retry-budget, задержки промежуточных звеньев и разрешённую задержку повторной отправки. Очищать можно terminal-записи после окончания окна. Удалять зависший in_progress без процедуры восстановления нельзя: очистка скрывает неизвестный эффект, а не устраняет его.

\n

Порядок проверки

\n
  1. Выбрать один intent и записать его область, значимый payload и общий deadline. Не начинать с агрегированной метрики timeout.
  2. Найти для каждой попытки отдельный requestId и общий Idempotency-Key. Отсутствующее поле отметить как пробел доказательств.
  3. Проверить canonical payload и fingerprint. Изменение значимого поля должно дать conflict до бизнес-обработчика.
  4. Проверить атомарный резерв одного ключа двумя конкурентными запросами. Только один запрос получает право выполнить эффект.
  5. Смоделировать потерю ответа после сохранения результата. Повтор должен вернуть тот же статус и result ID, а число эффектов должно остаться равным одному.
  6. Проверить состояния in_progress, completed и conflict. Для каждого состояния заранее записать машинный ответ и действие клиента.
  7. Если эффект внешний, остановить автоматический retry и найти внешний ключ или маршрут чтения результата. Не объявлять локальную запись доказательством внешнего успеха.
  8. Зафиксировать срок хранения terminal-записи и отдельный сценарий восстановления зависшего in_progress. Проверить, что уборка не запускает повторную команду.
\n

Проверяемый критерий готовности

\n

Контракт готов, если независимый инженер может по одному intent ответить на пять вопросов: какой ключ связывает попытки, какой payload считается тем же, кто владеет эффектом, какой ответ получает replay и что происходит при неизвестном исходе. Тест с потерянным ответом показывает ровно один эффект и тот же result ID на повторе. Тест с другим payload получает conflict до эффекта. Два конкурентных запроса не создают две terminal-записи.

\n

Для внешнего эффекта дополнительно существует проверяемый путь сверки после рестарта или обрыва между вызовом и записью. Если такого пути нет, готовность не подтверждена, даже если локальный unit test зелёный. Это граница механизма: идемпотентность уменьшает повтор одного intent, но не даёт гарантии exactly once между независимыми системами.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/272.json b/editorial/agent-rewrites/272.json new file mode 100644 index 0000000..d40ebd6 --- /dev/null +++ b/editorial/agent-rewrites/272.json @@ -0,0 +1,7 @@ +{ + "index": 272, + "slug": "editorial-2020-06-mechanism-retry-idempotency", + "title": "Идемпотентный retry: как не выполнить одну команду дважды", + "excerpt": "Клиент получил timeout, но сервер мог уже создать результат. Разбираем scope ключа, fingerprint payload, конкурентный запрос, сохранённый ответ и границу внешнего эффекта.", + "contentHtml": "

Симптом знакомый: пользователь нажал «Создать», увидел timeout и нажал ещё раз. В базе появились две заявки. Иногда дубль возникает без второго клика: клиент повторяет POST после разрыва соединения, пока первый обработчик ещё работает. Цена ошибки зависит от операции. Две строки в черновике можно удалить. Два платежа, письма или заказа требуют ручного разбора и возврата денег.

\n

Timeout сообщает только об ответе, которого клиент не увидел. Он не доказывает, что сервер не принял запрос. Между принятием команды и доставкой ответа есть база, worker, proxy и сеть. Любой участок может завершить работу, а следующий участок — потерять результат.

\n

Тезис статьи простой: retry безопасен только тогда, когда сервис связывает повтор с тем же intent и хранит решение операции. Для этого нужны scope ключа, fingerprint значимых данных, атомарный захват ключа, состояние in_progress и сохранённый terminal-ответ. Повтор не должен снова запускать бизнес-обработчик.

\n

Что именно делает идемпотентность

\n

Идемпотентность не означает «ответ всегда одинаковый» и не означает «в системе не будет побочных эффектов». В HTTP это свойство намеренного эффекта метода: несколько одинаковых запросов должны дать тот же эффект, что один. RFC 9110 относит к идемпотентным PUT, DELETE и безопасные методы. POST сам по себе таким свойством не обладает.

\n

Для POST сервис может добавить собственный контракт. Клиент создаёт ключ на один пользовательский intent и сохраняет его до получения окончательного результата. При сетевом сбое он отправляет тот же payload с тем же ключом. Сервер узнаёт повтор, возвращает прежний результат и не создаёт новый объект.

\n

Ключ относится к действию, а не к сетевой попытке. requestId может меняться на каждом HTTP-проходе. Idempotency-Key должен оставаться тем же для одного intent. Если на retry сгенерировать новый ключ, сервер увидит новую команду и защита не сработает.

\n

Scope и fingerprint

\n

Один ключ не обязан быть уникальным во всей системе. Сервис задаёт scope. В учебном примере это actor_id, имя операции и idem_key. Одинаковая строка ключа у двух пользователей не должна открыть один результат. Одинаковая строка в другой операции не должна заблокировать её.

\n

Одного scope мало. Клиент может по ошибке повторно использовать ключ с другим payload. Поэтому при первом запросе сервис вычисляет fingerprint по каноническим значимым полям. Повтор с тем же ключом и тем же fingerprint — кандидат на replay. Тот же ключ с другим fingerprint — конфликт. Обработчик не запускается.

\n

Канонизация входит в API-контракт. Нужно заранее определить, какие поля влияют на эффект. Порядок ключей JSON, пробелы и display-поля не должны случайно превращать тот же intent в другой. Поля авторизации, которые задают scope, не смешивают с payload fingerprint: субъект проверяется отдельно.

\n
\"Диаграмма
Ключ связывает scope, fingerprint и одно решение. Он не кэширует успех вообще: он защищает конкретный intent.
\n

Состояния записи

\n

Минимальная запись хранит scope, fingerprint, состояние, срок действия и данные replay. Для terminal-состояния достаточно статуса, безопасного тела ответа и идентификатора результата. Сохранять в этой таблице токены, cookies и полный запрос не нужно. Слишком бедная запись тоже опасна: если в ней нет результата, повтор снова вынужден угадывать.

\n
Что нужно сохранить для одного ключа
ПолеЗадачаЧто проверить
actor_id + operation + idem_keyОписывает scope и захватывает intentВ базе есть составное уникальное ограничение
payload_hashОтличает retry от подмены payloadЗначимое изменение даёт конфликт до обработчика
stateРазделяет in_progress и terminalПараллельный запрос не начинает второй handler
status_code + response_bodyВозвращает тот же наблюдаемый результатГотовый retry получает тот же result ID
expires_atЗадаёт окно повторного распознаванияTTL длиннее согласованного retry budget
\n

Сервис не должен считать запись seen=true достаточной. Такой флаг не отвечает на три вопроса: обработчик ещё выполняется, операция завершилась ошибкой или результат уже сохранён? Разные ответы требуют разных действий. in_progress нельзя молча трактовать как новый запуск.

\n

Атомарный захват в хранилище

\n

Проверка в памяти процесса не защищает второй pod, worker или рестарт. Уникальность должна жить в устойчивом хранилище, где конкурирующие запросы видят один результат. Первый запрос вставляет запись в scope. Только тот, кто успешно вставил строку, получает право вызвать бизнес-обработчик.

\n
BEGIN;\n\nINSERT INTO idempotency_keys (\n  actor_id, operation, idem_key, payload_hash, state, expires_at\n) VALUES ($1, $2, $3, $4, 'in_progress', $5)\nON CONFLICT (actor_id, operation, idem_key) DO NOTHING;\n\n-- если вставлена строка: этот запрос владеет обработкой\n-- если строка уже была: читаем state и payload_hash\n\nCOMMIT;
\n

SQL — учебная иллюстрация, а не готовая схема для конкретного проекта. Драйвер должен надёжно различать вставку и конфликт. После конфликта сервис читает существующую запись и выбирает ветку. Он не вызывает основной обработчик до этого решения.

\n

Четыре наблюдаемые ветки

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Первый запрос вставил записьКлюч свободен в заданном scopeТекущая транзакция получила право обработкиЗапустить один handler и завершить запись terminal-результатом
Повтор видит completedПервый запрос уже сохранил решениеЕсть status, body и result IDВернуть сохранённый HTTP-ответ без нового эффекта
Повтор видит in_progressПервый handler ещё не завершил договорTerminal-записи нет, ключ занятВернуть документированный conflict или статус ожидания; не запускать второй handler
Тот же ключ имеет другой hashКлюч повторно использовали для другого intentСравнить hash до business actionВернуть 409 Conflict с машинной причиной; попросить новый intent
Два result ID имеют один scope/keyЗахват неатомарен или часть writers обошла контрактСопоставить SQL, pod, время и внешние вызовыОстановить blind retry и исправить общую границу конкурентного доступа
\n

Для in_progress API выбирает одну семантику и документирует её. Можно вернуть конфликт с полем code=operation_in_progress. Можно дать клиенту endpoint проверки статуса. Нельзя скрыть этот случай за обычным retry без ключа: он снова создаст гонку.

\n

Сохраняем terminal-ответ

\n

Обработчик должен завершить запись тем результатом, который увидит клиент. Если объект и запись ключа лежат в одной базе, их можно сохранить одной транзакцией. После commit повтор найдёт оба факта и вернёт status и result ID. Если процесс упал до commit, следующий запрос увидит незавершённую транзакцию и сможет начать обработку по правилам базы.

\n
async function createOnce(input, key, actorId) {\n  const hash = fingerprint(input);\n  const claim = await reserve(actorId, 'demo.create', key, hash);\n\n  if (claim.kind === 'replay') return claim.response;\n  if (claim.kind === 'conflict') throw new HttpError(409, 'key_payload_mismatch');\n  if (claim.kind === 'in_progress') throw new HttpError(409, 'operation_in_progress');\n\n  try {\n    const result = await createResultAndFinishAtomically(input, claim.id);\n    return result.response;\n  } catch (error) {\n    await markTerminalFailure(claim.id, safeError(error));\n    throw error;\n  }\n}
\n

Псевдокод показывает порядок, но не обещает конкретный статус ошибки, библиотеку базы или способ восстановления. На практике нужно решить, какие ошибки считаются terminal. Если обработчик не стартовал из-за валидации, ключ можно не резервировать или сохранить отказ по отдельному контракту. Если внешний эффект уже принят, удалять запись после исключения опасно.

\n

Внешняя система ломает локальную атомарность

\n

Локальная таблица не делает внешний вызов exactly once. Сервис может отправить запрос поставщику, получить результат, а затем упасть до сохранения completed. После рестарта запись выглядит зависшей, хотя внешний объект уже создан. Очистка ключа и новый вызов могут создать дубль.

\n

Для внешнего эффекта нужен второй договор. Используйте устойчивый business ID, детерминированное имя объекта, idempotency key поставщика или endpoint проверки состояния. Восстановление in_progress должно сначала узнать, был ли эффект принят, и только потом решать, допустим ли повтор. Если такой проверки нет, автоматический retry нужно остановить и передать неопределённый исход в безопасный маршрут.

\n

Это отрицательный путь механизма. Идемпотентность не отменяет уже отправленное письмо и не откатывает платёж в другой системе. Она лишь даёт локальную запись, с которой можно продолжить расследование. Граница транзакции должна быть явно отмечена в архитектуре.

\n

Порядок проверки

\n
  1. Запишите один intent: субъект, операцию, значимые поля payload и допустимое окно retry.
  2. Разделите идентификаторы: новый requestId для каждой доставки, один Idempotency-Key для одного intent.
  3. Определите канонизацию и вычислите fingerprint. Проверьте, что изменение значимого поля даёт conflict до эффекта.
  4. Добавьте составное уникальное ограничение в устойчивое хранилище. Запустите два конкурентных запроса с одним scope и одним ключом.
  5. Проверьте все четыре ветки: claim, replay, in-progress и payload mismatch. Для каждой зафиксируйте HTTP-статус и машинную причину.
  6. Сымитируйте потерю ответа после сохранения результата. Повторите тот же payload с тем же ключом и подтвердите один result ID.
  7. Перезапустите процесс после начала внешнего вызова. Проверьте статус внешнего эффекта до любого повторного вызова.
  8. Проверьте TTL только на terminal-записях. Убедитесь, что поздний повтор после очистки явно запрещён или создаёт новый intent по документации.
\n

Ограничения

\n

Ключ не заменяет аутентификацию, авторизацию, валидацию, rate limit и защиту от перегрузки. Он не спасает, если разные writers используют разные scope или один путь вызывает внешний эффект до резервирования записи. Он также не гарантирует exactly once между двумя независимыми системами.

\n

Нельзя выбрать TTL по удобству таблицы. Он должен учитывать максимальный retry budget клиента, задержку proxy и время, после которого API запрещает поздний повтор. Слишком короткий TTL превращает поздний retry в новый эффект. Слишком длинный TTL удерживает результат и чувствительные данные без необходимости.

\n

Все адреса, SQL, ключи, result ID и ответы ниже учебные. Реальный endpoint, база, proxy, нагрузка и платёжная интеграция здесь не запускались. Перед внедрением нужно проверить конкурентные транзакции, размер response body, правила хранения данных и поведение каждого внешнего поставщика.

\n

Критерий готовности

\n

Механизм готов, когда два конкурентных запроса с одним scope и ключом создают один effect; повтор после искусственно потерянного ответа возвращает сохранённый result ID; другой payload с тем же ключом получает conflict до обработчика; зависший внешний вызов имеет отдельный статусный маршрут; а TTL не открывает неоговорённый поздний retry. Эти условия должны быть проверены интеграционным тестом в выбранном стеке и видны в безопасных логах.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/273.json b/editorial/agent-rewrites/273.json new file mode 100644 index 0000000..7f20b2d --- /dev/null +++ b/editorial/agent-rewrites/273.json @@ -0,0 +1,7 @@ +{ + "index": 273, + "slug": "editorial-2020-06-practice-retry-idempotency", + "title": "Повтор HTTP-запроса без второго эффекта: ключ, результат и бюджет", + "excerpt": "Timeout не доказывает, что сервер ничего не сделал. Разбираем, как связать один пользовательский intent с idempotency key, ограниченным retry и повторяемым результатом.", + "contentHtml": "

Пользователь нажимает «Отправить». Браузер ждёт ответ, затем показывает timeout. Пользователь нажимает ещё раз. Первая команда могла уже создать заказ, заявку или письмо. Ответ потерялся между сервисом и браузером. Теперь система получила два запроса и не знает, считать ли их одной операцией. Цена ошибки — двойной эффект, неверный статус на экране и ручное выяснение, что именно произошло.

\n

Отключённая кнопка не закрывает проблему. Запрос повторит браузер после обновления страницы, клиентская библиотека после разрыва соединения или прокси после сетевого сбоя. Без контракта повтор создаёт новую команду. С контрактом он возвращает результат уже начатой команды.

\n

Тезис: повторяют intent, а не сетевой пакет

\n

Безопасный retry начинается до первого HTTP-вызова. Клиент выделяет один пользовательский intent: конкретную операцию, набор значимых полей, пользователя и срок действия. Для intent он создаёт idempotency key. Все попытки этой операции передают тот же ключ и тот же payload. Новый набор полей получает новый intent и новый ключ.

\n

Ключ не делает POST идемпотентным сам по себе. Сервер должен принять решение по паре «scope ключа и fingerprint запроса», атомарно занять операцию, выполнить эффект один раз и сохранить terminal-ответ. Повтор с тем же ключом получает сохранённый ответ. Повтор с тем же ключом, но другим payload получает конфликт. Параллельный запрос видит состояние in_progress, а не запускает второй обработчик.

\n

Механизм по шагам

\n

Timeout сообщает только об отсутствии ответа у клиента к дедлайну. Он не сообщает, дошёл ли запрос до сервера, завершилась ли запись и был ли отправлен ответ. Поэтому после timeout нельзя создавать новый ключ и нельзя автоматически считать операцию отменённой. Клиент либо повторяет тот же intent в пределах общего бюджета, либо показывает неопределённый исход и предлагает получить статус отдельным read-запросом.

\n

На сервере нужен scope. Для учебной заявки его можно составить из аутентифицированного субъекта, имени операции и ключа. Это не даёт одинаковой строке ключа связывать действия разных пользователей или разных операций. Fingerprint строят по значимым полям. Его сравнение защищает от ошибки, когда клиент повторно использовал старый ключ для уже изменённой формы.

\n
\"Путь
Timeout означает, что ответ не наблюдался. Он не означает, что эффект не произошёл. Повтор с тем же ключом должен вернуть прежний результат.
\n

У записи операции есть как минимум четыре состояния: ключ не найден, операция выполняется, операция завершена с сохранённым ответом и ключ отклонён из-за другого payload. Одного поля seen=true недостаточно. По нему нельзя понять, ждать ли первый запрос, повторить ли готовый ответ или показать причину отказа.

\n

Пример запроса и состояния клиента

\n

Ниже приведён учебный пример. Домен api.example.invalid, идентификатор заявки и тайминги вымышлены. Код показывает границу ответственности: ключ создаётся один раз, deadline относится ко всему intent, а не к каждой попытке.

\n
const intent = {\n  key: crypto.randomUUID(),\n  payload: { reportType: 'bundle-size', branch: 'main' },\n  deadline: Date.now() + 8_000,\n};\n\nasync function sendWithRetry() {\n  for (const attempt of [1, 2]) {\n    const left = intent.deadline - Date.now();\n    if (left <= 0) return { status: 'unknown', key: intent.key };\n\n    try {\n      const response = await fetch('https://api.example.invalid/reports', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n          'Idempotency-Key': intent.key,\n          'X-Client-Attempt': String(attempt),\n        },\n        body: JSON.stringify(intent.payload),\n        signal: AbortSignal.timeout(left),\n      });\n\n      if (response.ok) return response.json();\n      if (![408, 429, 502, 503, 504].includes(response.status)) {\n        return { status: 'rejected', code: response.status };\n      }\n    } catch (error) {\n      if (attempt === 2) return { status: 'unknown', key: intent.key };\n    }\n  }\n\n  return { status: 'unknown', key: intent.key };\n}
\n

X-Client-Attempt помогает читать журнал, но не определяет идентичность операции. Если включить номер попытки в idempotency key, второй запрос станет новой командой. Если при каждом timeout заново запускать восьмисекундный таймер, клиент сможет повторять запросы бесконечно долго и усилит нагрузку на уже нестабильную зависимость.

\n

Сервис должен хранить ключ вместе со scope, fingerprint, состоянием, HTTP-кодом и телом terminal-ответа. При первой попытке он резервирует запись до выполнения побочного эффекта. После успеха или окончательной ошибки он заполняет результат. При повторе с тем же fingerprint сервис отдаёт эту запись. Учебный код не утверждает, что конкретная база или транспорт уже дают такую атомарность: её нужно обеспечить отдельно.

\n

Симптом → причина → проверка → действие

\n
Диагностика повторов для одной операции
СимптомПричинаПроверкаДействие
После timeout появились две записиПовтор получил новый ключ или сервер не дедуплицирует запросыСопоставить ключ, scope и счётчик побочных эффектовСоздавать ключ до первого вызова и атомарно резервировать intent
Повтор получает конфликт из-за payloadСтарый ключ использовали для изменённой формыСравнить fingerprint значимых полейЗакрыть старый intent и создать новый ключ после изменения данных
Два параллельных запроса выполняются одновременноПроверка ключа и запись in_progress не атомарныЗапустить два запроса с одним key и проверить порядок в хранилищеДобавить уникальное ограничение и отдельную ветку для уже занятой операции
Клиент повторяет запросы до бесконечностиУ каждой попытки собственный timeout, нет общего deadlineПосчитать время от создания intent до последнего вызоваЗадать общий бюджет и конечное число попыток
Повтор вернул пустой или другой результатСервис сохранил только факт ключа, но не terminal-ответСравнить код, тело и идентификатор результата первой и второй попыткиСохранять и воспроизводить ответ по контракту операции
Пользователь видит ошибку, хотя эффект созданUI трактует отсутствие ответа как rollbackПроверить серверный журнал и status-маршрут по ключуПоказывать неопределённый исход и дать безопасный путь проверки
\n

Порядок внедрения

\n
  1. Выберите одну операцию с побочным эффектом. Зафиксируйте, что именно считается одним intent и какие поля определяют результат.
  2. Создайте ключ до первого вызова. Проверьте двойной клик, обновление страницы и повтор после сетевого timeout.
  3. Определите scope ключа: субъект, операция и срок хранения. Не используйте номер попытки или номер пользователя как единственный ключ.
  4. Посчитайте fingerprint значимых полей. При несовпадении payload остановите запрос до побочного эффекта.
  5. Атомарно резервируйте in_progress. Параллельный запрос должен ждать, получить документированный конфликт или прочитать статус.
  6. Сохраните terminal-код, тело и идентификатор результата. Повтор с тем же key должен получить тот же наблюдаемый результат.
  7. Задайте общий deadline и короткую policy retry для временных ошибок. После бюджета не отправляйте новую команду вслепую.
  8. Проверьте сценарий «операция завершилась, ответ потерялся». Счётчик эффекта должен остаться равен одному, а повтор — вернуть тот же result ID.
\n

Отрицательный путь и ограничения

\n

Не каждый отказ можно повторять. Ошибка валидации требует исправить payload. Ошибка авторизации требует обновить права или сессию. Повтор 4xx без изменения причины только создаёт шум. Коды 502, 503 и 504 могут указывать на временную проблему, но сами по себе не доказывают, что сервер не выполнил операцию.

\n

Идемпотентность одной границы не распространяется на внешнюю систему. Если обработчик сначала создаёт запись у себя, а затем отправляет письмо в сервис без ключа, повторная доставка письма всё ещё может дать дубль. Нужны outbox, устойчивый бизнес-идентификатор или поддержка дедупликации у поставщика. Если внешний эффект необратим и его результат неизвестен, безопаснее остановиться и получить статус, чем слепо отправить новую команду.

\n

Ключ не заменяет авторизацию, шифрование и контроль доступа. Он не должен раскрывать результат другому субъекту. Запись ключей нужно очищать по документированному сроку, а чувствительные поля нельзя бездумно помещать в журналы. Учебные значения из примера нельзя переносить в рабочие лимиты, схемы хранения или правила повторов без измерений конкретной системы.

\n

Проверяемый критерий готовности

\n

Операция готова к ограниченному retry, если команда может воспроизвести четыре сценария: успешный первый ответ, timeout после выполнения, параллельный повтор и изменение payload под старым ключом. В первом сценарии клиент получает результат. Во втором система создаёт один эффект и возвращает тот же result ID. В третьем выполняется один обработчик. В четвёртом сервер отклоняет запрос до побочного эффекта.

\n

Отдельно проверьте бюджет: после его исчерпания нет новых попыток, UI показывает неопределённый исход или использует предусмотренный status-маршрут. Такой критерий наблюдаем по журналу запросов, состояниям записи и счётчику эффекта. Он не зависит от предположения, что потерянный ответ означает отменённую операцию.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/274.json b/editorial/agent-rewrites/274.json new file mode 100644 index 0000000..11adafb --- /dev/null +++ b/editorial/agent-rewrites/274.json @@ -0,0 +1,7 @@ +{ + "index": 274, + "slug": "editorial-2020-05-field-background-jobs", + "title": "Фоновая задача продублировала экспорт: порядок записи, ack и карантин ошибок", + "excerpt": "Разбираем два сбоя фонового worker: повторный внешний эффект после потери ack и бесконечный requeue для постоянной ошибки. Показываем порядок действий, журнал и критерий готовности.", + "contentHtml": "

Пользователь запускает экспорт и через несколько минут получает два одинаковых файла. В той же очереди задача с неизвестным типом отчёта появляется снова сразу после отказа worker. Первая ошибка создаёт лишний внешний эффект: приходится выяснять, какой файл считать правильным, и чистить дубликаты. Вторая забивает worker одинаковыми исключениями. Полезные задачи ждут, а журнал растёт быстрее, чем его успевает читать дежурный инженер.

\n

Тезис такой: доставка сообщения и выполнение бизнес-операции — разные факты. Broker может доставить одну бизнес-задачу повторно, если не увидел подтверждение. Worker обязан сделать повтор безопасным по устойчивому ключу. Он также обязан отличать временный сбой от постоянной ошибки входных данных. Для первой ошибки нужен идемпотентный результат и правильный порядок записи. Для второй — ограничение попыток и карантин, а не безусловный requeue.

\n

Что именно повторяется

\n

Пусть запрос создаёт задачу с jobId = export-42-2020-05. Producer публикует сообщение, а worker получает delivery с отдельным deliveryTag. Первый идентификатор относится к бизнес-операции. Второй относится к конкретной доставке на канале. Новый deliveryTag не означает новый экспорт.

\n

Worker начинает работу, сохраняет файл и переводит задачу в succeeded. Затем соединение с broker обрывается до ack. Для broker доставка осталась неподтверждённой. После восстановления consumer получает её снова. Если обработчик каждый раз вызывает экспорт, он создаёт второй файл. Если имя строится из текущего времени, по имени файла невозможно доказать, что это повтор.

\n

Вторая задача содержит reportKind = unknown. Worker не может обработать её ни сейчас, ни через секунду: причина находится во входных данных. Если обработчик на любое исключение отвечает nack(requeue=true), broker снова выдаёт то же сообщение. Так возникает hot loop. Повтор помогает только тогда, когда новая попытка может изменить исход.

\n
\"Схема
Проверяйте один jobId от публикации до результата. Так видно, на каком переходе возник повтор или остановилась задача.
\n

Порядок подтверждения

\n

Подтверждение не является сигналом «код вошёл в функцию». Для ручного ack оно должно означать, что consumer выполнил работу, за которую берёт ответственность. Если ack отправить до записи результата, сбой после ack может потерять работу. Если записать результат, но не сделать повтор безопасным, сбой до ack создаст дубль. Поэтому сначала фиксируют бизнес-результат, затем подтверждают delivery.

\n

Одного порядка недостаточно. Повтор должен найти тот же объект по jobId, прочитать terminal state и вернуть уже сохранённый результат. Внешний ключ результата тоже должен быть детерминированным: например, reports/export-42-2020-05.csv. Это защищает путь к файлу, но не любой внешний эффект. Email-провайдер или другой HTTP-сервис должен поддерживать собственный idempotency key либо получать вызов через отдельный надёжный протокол.

\n

Учебный обработчик

\n

Ниже — учебный псевдокод. Он показывает границу ответственности, но не является готовым клиентом RabbitMQ, не задаёт транзакционный API базы и не сообщает показатели production-нагрузки.

\n
async function handle(delivery) {\n  const { jobId, reportKind } = delivery.payload;\n  const job = await jobs.lockById(jobId);\n\n  if (job.state === 'succeeded') {\n    await delivery.ack();\n    return job.resultKey;\n  }\n\n  if (!isKnownReport(reportKind)) {\n    await jobs.markQuarantined(jobId, {\n      reason: 'unknown_report_kind',\n      payloadVersion: delivery.payload.version,\n    });\n    await delivery.nack({ requeue: false });\n    return;\n  }\n\n  const resultKey = `reports/${jobId}.csv`;\n  await exportReport({ reportKind, resultKey });\n  await jobs.markSucceeded(jobId, resultKey);\n  await delivery.ack();\n}
\n

Проверка terminal state стоит до внешнего действия. Блокировка или другой механизм защиты строки должен охватывать чтение состояния и резервирование работы. В реальной системе нужно решить, что происходит при падении между записью файла и markSucceeded. Обычно результат пишут во временный объект, затем атомарно публикуют его по детерминированному ключу и сохраняют состояние. Конкретный storage может потребовать иной протокол.

\n

Ветвь постоянной ошибки не вызывает экспорт и не отправляет сообщение обратно в основную очередь. Она сохраняет причину и версию входных данных до отрицательного подтверждения. Затем transport направляет сообщение в настроенный dead-letter route либо в другой согласованный карантин. Если такого маршрута нет, requeue=false не создаёт архив для разбора автоматически: сообщение может быть отброшено в зависимости от конфигурации.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Для одного jobId появились два файлаРезультат создаётся по случайному имени или проверка состояния стоит после эффектаСопоставить jobId, resultKey, порядок result_saved, succeeded и ack_sentЧитать terminal state до экспорта и сохранять результат по детерминированному ключу
Повторная доставка приходит после успешной записиСоединение оборвалось до ackСверить журнал broker, delivery tag и запись задачиОбработать повтор как тот же jobId, не запускать внешний эффект снова
attempt растёт с одной и той же причинойПостоянную ошибку приняли за временнуюСравнить payload и код причины на соседних попыткахСохранить причину, перевести задачу в quarantined, отключить requeue
После nack сообщение исчезлоДля очереди не настроен dead-letter маршрут или сообщение не должно хранитьсяПроверить policy, binding, routing key и журнал публикацииНастроить проверяемый карантин либо явно принять потерю как контракт
Задача долго стоит в очередиНе сработал outbox, producer публикует не туда или worker не читает bindingНайти запись job, marker публикации и факт первой доставкиПроверить dispatcher и маршрут до handler; не менять handler без delivery
\n

Таблица задаёт порядок расследования. Сначала привяжите наблюдение к одному jobId. Не начинайте с увеличения timeout или числа retry. Эти изменения не исправят случайное имя результата, неизвестный reportKind или неверный binding.

\n

Почему ack не даёт exactly-once

\n

Ручное подтверждение помогает выбрать границу ответственности, но не превращает распределённую систему в exactly-once механизм. Между записью результата и ack остаётся окно сбоя. Между отправкой ack и получением его broker тоже есть сеть. Система с повторной доставкой обычно даёт at-least-once обработку: сообщение могут обработать снова, поэтому handler должен быть идемпотентным.

\n

Признак redelivered полезен для диагностики, но он не заменяет бизнес-ключ. При повторной публикации похожее сообщение может выглядеть как новая доставка. Бизнес-правило должно опираться на jobId, уникальный ключ результата и состояние операции. Если worker выполняют несколько экземпляров, lock и уникальное ограничение должны защищать одну и ту же область.

\n

Для временной ошибки используйте ограниченный retry с задержкой. Временной может быть недоступность хранилища, краткий сетевой отказ или ещё не созданная зависимая запись. Сохраняйте attempt, код причины и время следующей попытки. После лимита не отправляйте сообщение в бесконечный цикл. Оставьте его в карантине, чтобы инженер мог исправить данные или код и принять решение о повторном запуске.

\n

Порядок действий

\n
  1. Выберите один повторившийся или застрявший jobId. Соберите запись задачи, payload, outbox, delivery и журнал worker в одной временной шкале.
  2. Разделите идентификаторы: бизнес-ключ операции, delivery tag, attempt и ключ внешнего результата. Не считайте новый delivery новой операцией.
  3. Проверьте порядок переходов. Зафиксируйте, когда появились result_saved, terminal state и ack_sent. Не запускайте экспорт повторно, пока не проверили сохранённый результат.
  4. Проверьте идемпотентность: повторная доставка того же jobId должна прочитать terminal state и завершиться без нового файла, письма или внешнего вызова.
  5. Классифицируйте ошибку. Если вход невалиден или тип неизвестен, сохраните постоянную причину и не делайте requeue. Если зависимость может восстановиться, задайте конечный лимит и задержку.
  6. Проверьте карантин на выбранном broker: policy, exchange, binding, routing key и права публикации. Отрицательное подтверждение без проверенного маршрута не считается обработанным исходом.
  7. Проверьте отрицательный путь на интеграционном стенде: оборвите соединение после записи результата и до ack, затем подайте повтор. Отдельно отправьте невалидный payload и убедитесь, что после лимита он перестаёт возвращаться в основную очередь.
  8. Добавьте наблюдаемость: jobId, attempt, delivery tag, event, resultKey, reason и длительность. Не записывайте в журнал секреты и полное содержимое пользовательского отчёта.
\n

Ограничения

\n

Описанный алгоритм не говорит, какая база или очередь нужна проекту. Row lock защищает только выбранную запись и не заменяет уникальное ограничение во внешнем хранилище. Состояние в базе и файл в object storage не откатываются одной транзакцией. Если процесс остановился после загрузки файла, нужна проверка существующего ключа и политика очистки незавершённых объектов.

\n

Лимит retry нельзя выбрать одинаковым для всех задач. Слишком малый лимит превращает краткий сбой в карантин. Слишком большой растягивает задержку и маскирует постоянную ошибку. Backoff и лимит должны учитывать стоимость работы, срок жизни входных данных и допустимую задержку пользователя.

\n

Учебный код не доказывает поведение конкретной версии broker, client library или policy. Проверяйте ack, redelivery, nack, dead-lettering и восстановление соединения на той конфигурации, которую действительно запускает сервис. Не называйте результат готовым, если проверили только in-memory переходы.

\n

Критерий готовности

\n

Решение готово, когда два независимых сценария проходят на выбранном окружении. После сбоя между сохранением результата и ack повторная доставка того же jobId не создаёт второй внешний эффект и возвращает один проверяемый resultKey. После постоянной ошибки payload задача достигает quarantined не позднее заданного лимита, причина сохраняется, а сообщение не возвращается в основную очередь. В журнале можно восстановить порядок событий без догадок.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/275.json b/editorial/agent-rewrites/275.json new file mode 100644 index 0000000..1e4061b --- /dev/null +++ b/editorial/agent-rewrites/275.json @@ -0,0 +1,7 @@ +{ + "index": 275, + "slug": "editorial-2020-05-mechanism-background-jobs", + "title": "Фоновая задача без дублей: delivery, ack и карантин", + "excerpt": "Как отделить бизнес-состояние фоновой задачи от доставки сообщения, поставить ack после результата и остановить бесконечный retry для неисправимых данных.", + "contentHtml": "

Пользователь запускает экспорт отчёта и получает два одинаковых файла. В другом случае worker вызывает внешний API, после чего сообщение исчезает, а результата нет. Оба симптома появляются на одной границе: приложение путает бизнес-задачу с отдельной доставкой сообщения. Цена ошибки — повторный платный вызов, дублирующий файл или письмо, потерянная работа и очередь, забитая одной неисправимой записью.

\n

Рабочая модель разделяет эти объекты. Бизнес-задача имеет стабильный jobId, состояние и ключ результата. Broker доставляет сообщение с собственным delivery tag. Worker проверяет состояние, выполняет эффект с устойчивым ключом, сохраняет результат и только потом подтверждает конкретную доставку через ack. Если связь оборвётся до подтверждения, broker может доставить сообщение снова. Повтор не должен создавать новый эффект для уже завершённого jobId.

\n

Delivery и задача отвечают на разные вопросы

\n

Delivery отвечает на вопрос broker: кто сейчас отвечает за эту копию сообщения? Его жизненный цикл заканчивается на ack, reject или закрытии канала. При закрытии канала неподтверждённая доставка может вернуться в очередь.

\n

Задача отвечает на вопрос приложения: что попросил пользователь, на какой попытке находится операция и где лежит результат. Её состояние должно жить в базе или другом устойчивом хранилище. Нельзя использовать delivery tag как идентификатор задачи. Tag относится к каналу и может измениться при следующей доставке той же бизнес-операции.

\n
Идентификаторы фоновой обработки
ОбъектГде живётКогда меняетсяДля чего нужен
jobIdзапись задачи, payload, журналне меняется между повторамисостояние, дедупликация и результат
delivery tagканал consumerпри новой доставкеточный ack или nack
attemptзапись задачи или retry-сообщениепри разрешённой новой попыткелимит повторов и диагностика
resultKeyбаза и хранилище результатаодин раз при успехедоказательство готового эффекта
\n

Из этого разделения следует ограничение: модель даёт at-least-once delivery, а не глобальный «ровно один раз». Сообщение может прийти повторно. Поэтому эффект должен быть идемпотентным в границе задачи. Для отчёта это может быть путь reports/{jobId}.csv и уникальная запись результата. Для внешнего API нужен его собственный idempotency key. Локальная таблица не отменяет уже отправленный запрос в чужую систему.

\n
\"Схема
Повтор относится к delivery, а состояние — к jobId. Сначала приложение сохраняет решение, затем broker получает подтверждение или отказ.
\n

Минимальный контракт задачи

\n

До публикации сообщения приложение создаёт запись задачи и outbox-событие в одной транзакции. Outbox хранит намерение опубликовать сообщение, пока dispatcher не получит подтверждение от выбранного broker-клиента. Такой порядок закрывает отдельную дыру: задача уже видна пользователю, но процесс публикации ещё не завершён.

\n
// Учебный псевдокод: это не готовый API RabbitMQ.\\nasync function requestExport(input, db) {\\n  const jobId = stableId(input.accountId, input.period);\\n  await db.transaction(async (tx) => {\\n    await tx.insertJob({ id: jobId, state: 'queued', attempt: 0, resultKey: null });\\n    await tx.insertOutbox({ type: 'report.export.requested', jobId, publishedAt: null });\\n  });\\n  return { accepted: true, jobId };\\n}
\n

Пример учебный. Он показывает контракт, а не измеренную производительность и не конкретную библиотеку. Функция принимает намерение и возвращает jobId. Она не держит HTTP-соединение до окончания экспорта. Dispatcher отдельно публикует событие и отмечает publishedAt после подтверждения своего клиентского API.

\n

Ack ставим после устойчивого результата

\n

Ранний ack сообщает broker, что доставка обработана. Если отправить его сразу после чтения сообщения, а затем получить ошибку базы, файлового хранилища или внешнего API, broker удалит delivery, хотя бизнес-результата нет. Это путь к потере работы.

\n

Поздний ack оставляет другое окно. Worker может сохранить результат, а соединение оборвётся до подтверждения. Broker доставит сообщение повторно. Второй worker должен прочитать terminal state и завершить только новое delivery. Он не должен повторять экспорт.

\n
// Учебный обработчик одного delivery.\\nasync function handleDelivery(delivery, jobs, broker) {\\n  const job = await jobs.findForUpdate(delivery.jobId);\\n  if (job.state === 'succeeded' || job.state === 'quarantined') {\\n    await broker.ack(delivery.tag);\\n    return;\\n  }\\n  try {\\n    await jobs.markRunning(job.id, delivery.attempt);\\n    const resultKey = await writeReportOnce(job.id, job.payload);\\n    await jobs.markSucceeded(job.id, resultKey);\\n    await broker.ack(delivery.tag);\\n  } catch (error) {\\n    const nextAttempt = delivery.attempt + 1;\\n    await jobs.markRetryOrQuarantine(job.id, nextAttempt, error.code);\\n    await broker.nack(delivery.tag, { requeue: nextAttempt < 3 });\\n  }\\n}
\n

Порядок в примере — часть контракта. Состояние succeeded проверяется до эффекта. Результат получает детерминированный ключ. Статус успеха сохраняется до ack. Для внешнего вызова нужна такая же защита на стороне API: ключ операции, уникальное ограничение или запрос статуса по прежнему ключу.

\n

Симптом → причина → проверка → действие

\n
Карта диагностики одного jobId
СимптомПричинаПроверкаДействие
Есть resultKey, но нет ackСвязь оборвалась после результатаСравнить порядок result_saved и ack_sentПри повторе прочитать terminal state и подтвердить только delivery
Два файла для одного jobIdСлучайное имя или поздняя проверкаСопоставить имена файлов с журналом workerИспользовать resultKey и проверять статус до эффекта
attempt растёт с одной причинойПостоянную ошибку отправляют в requeueПовторить validation на сохранённом payloadПеревести задачу в quarantined и прекратить requeue
Задача долго queuedНе сработал outbox или маршрутПроверить outbox, marker публикации и bindingИсправить dispatcher или маршрут, не менять handler вслепую
\n

Проверку ведут по одному jobId. В журнале достаточно событий received, result_saved, retry_scheduled, quarantined и ack_sent. Рядом пишут попытку, признак redelivery и безопасную причину. Полный payload, токены и пользовательские документы в журнал не кладут.

\n

Retry нужен не для любой ошибки

\n

Повтор оправдан, если новое время может изменить исход: зависимость временно недоступна, сработал сетевой timeout до ответа или ожидаемая запись ещё не появилась. Но timeout после отправки запроса не доказывает, что внешний эффект не состоялся. Такой вызов повторяют только с ключом идемпотентности или после проверки статуса операции.

\n

Невалидный JSON, неизвестная версия события и отсутствующее обязательное поле повтором не исправятся. Бесконечный nack(requeue=true) создаёт горячий redelivery loop. Он занимает worker и прячет полезные сообщения за одной постоянной ошибкой.

\n

Для retry задают максимальное число попыток, причину последнего перехода и время следующего допуска. Задержка может использовать retry-очередь с TTL или другой механизм выбранного клиента. Важно, чтобы worker знал текущую попытку и не возвращал неисправимую запись в основной маршрут без изменения причины.

\n

Карантин для poison message

\n

Poison message — сообщение, которое текущий consumer не может обработать автоматически. Worker сначала сохраняет причину и состояние quarantined, затем отклоняет delivery без requeue. При настроенном dead-letter exchange broker направит сообщение на отдельный маршрут. Без такой конфигурации оно может быть отброшено, поэтому карантин должен быть проверяемой частью инфраструктуры, а не только словом в коде.

\n

Карантин не означает успех. Он означает, что автоматический путь остановился с понятной причиной. Владелец может исправить payload и переиздать задачу, обновить consumer или отменить операцию. Автоматически читать карантин обратно в основную очередь без исправления причины нельзя: loop вернётся.

\n

Порядок действий

\n
  1. Определить стабильный jobId и записывать его в задачу, событие, результат и журнал.
  2. Разделить состояния queued, running, retry_wait, succeeded и quarantined.
  3. Проверить согласованное создание outbox и задачи, а также публикацию после подтверждения broker-клиента.
  4. Поставить проверку terminal state до необратимого эффекта.
  5. Сохранить результат и resultKey до ack конкретного delivery.
  6. Разделить временные, неопределённые и постоянные ошибки; для каждой задать проверку и лимит.
  7. Для постоянной ошибки записать quarantined, отправить отказ без requeue и проверить dead-letter маршрут.
  8. Прогнать повторную доставку после сохранённого результата и убедиться, что второй эффект не создаётся.
\n

Ограничения

\n

Эта схема не делает систему ровно-однократной. Два worker могут одновременно увидеть незахваченную задачу, если хранилище не даёт блокировку или уникальное ограничение. Внешний сервис может принять запрос и не вернуть ответ. Broker может иметь другую семантику подтверждений. Поэтому порядок нужно сверить с версией клиента, типом очереди и реальной политикой dead-lettering.

\n

Псевдокод выше не открывает соединение с RabbitMQ, не измеряет throughput и не является production-тестом. Он ограничен учебной иллюстрацией переходов. Интеграционная проверка должна использовать выбранный broker, несколько worker, падение до ack, повтор после результата и невалидный payload после лимита.

\n

Критерий готовности

\n

Решение готово, когда для одного заранее известного jobId журнал показывает устойчивый результат до первого ack, повторную доставку с новым tag и отсутствие второго эффекта. Для временной ошибки видны ограниченные попытки и следующий допуск. Для невалидного payload видны причина, состояние quarantined и отсутствие немедленного requeue. Эти свойства должны воспроизводиться на интеграционном стенде выбранного broker, а не только в unit-тесте.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/276.json b/editorial/agent-rewrites/276.json new file mode 100644 index 0000000..ebc78f1 --- /dev/null +++ b/editorial/agent-rewrites/276.json @@ -0,0 +1,7 @@ +{ + "index": 276, + "slug": "editorial-2020-05-practice-background-jobs", + "title": "Фоновые задачи без потерь: запись намерения, повтор и результат", + "excerpt": "Как вынести долгую операцию из HTTP-запроса и не потерять её между базой, брокером и worker: состояние задачи, outbox, идемпотентность, ack и карантин.", + "contentHtml": "

Пользователь запускает экспорт и получает 202 Accepted. Через десять минут файла нет. В журнале виден входящий запрос, но непонятно, создали ли задачу, отправили ли сообщение и дошёл ли worker до записи результата. В другом варианте файл уже создан, worker падает до ack, а повторная доставка создаёт второй файл или повторно отправляет письмо. Ошибка стоит дорого: поддержка не может назвать состояние операции, разработчик не отличает потерю сообщения от дубля, а клиент повторяет опасный запрос.

\n

Тезис простой: очередь не хранит бизнес-результат. Она переносит delivery от publisher к consumer. Смысл операции должен жить в записи приложения с устойчивым jobId. HTTP фиксирует намерение, dispatcher публикует сообщение, worker выполняет работу, сохраняет результат и только потом подтверждает delivery. После этого повтор считается обычной веткой, а не аварией, которую можно исключить настройкой.

\n

Механизм: четыре разных факта

\n

У фоновой операции есть несколько границ. Запись задачи означает, что система приняла намерение. Запись outbox означает, что публикацию можно возобновить после перезапуска. Delivery означает, что брокер передал сообщение конкретному consumer. Сохранённый результат означает, что бизнес-действие завершилось. ack подтверждает только конкретное delivery. Он не доказывает, что файл существует, письмо ушло или строка в базе обновилась.

\n

У каждой записи должен быть один идентификатор операции. В учебном примере это export-42. В строке задачи удобно хранить state, attempt, входной тип, ключ результата и последнюю безопасную для журнала ошибку. Секреты и полный payload в журнал не попадают. Пользователь получает jobId, а затем читает статус по нему. Ответ «принято» не должен маскироваться под «готово».

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Есть queued, но нет deliveryDispatcher не прочитал outbox или не получил подтверждение publisherНайти outbox по jobId, проверить publishedAt и ошибку отправкиПовторить публикацию; не создавать новую задачу
Одно сообщение приходит несколько разПроцесс упал после результата и до ack либо соединение закрылосьСравнить attempt, state и ключ результатаВернуть сохранённый результат и подтвердить повтор; повторить работу только при отсутствии результата
Очередь быстро растётWorker медленнее входящего потока или удерживает слишком много deliveryСопоставить время обработки, backlog, prefetch и число активных workerОграничить параллелизм, добавить worker или принять backpressure
Одна задача бесконечно возвращаетсяНевалидный payload или постоянная ошибка зависимостиПосчитать попытки и сгруппировать lastErrorCodeОграничить retry и направить сообщение в карантин
Задача в succeeded, но результата нетСостояние записали раньше внешнего эффекта или ключ результата не проверяетсяПроверить объект по стабильному ключу и порядок транзакцийНе ставить успех до durable result; добавить восстановление или ручной разбор
\n
Жизненный цикл фоновой задачи: HTTP сохраняет задачу и outbox, dispatcher публикует сообщение, worker сохраняет результат, затем отправляет ack; ошибка уходит в повтор или карантин.
У операции есть отдельные границы: запись намерения, публикация, delivery, результат и подтверждение. Схема показывает, где искать потерю и почему ack стоит после результата.
\n

Запись и публикация

\n

Наивная последовательность выглядит так: сначала вставить задачу в базу, затем отправить сообщение. Сбой между двумя действиями оставляет строку queued без delivery. Обратный порядок создаёт другую дыру: worker получает сообщение, пока транзакция с задачей ещё не зафиксирована. Распределённая транзакция между базой и брокером решает не каждый сценарий и усложняет маленький сервис.

\n

Практичный компромисс — сохранить задачу и outbox в одной транзакции приложения. Отдельный dispatcher выбирает outbox без отметки публикации. Он отправляет короткое сообщение { jobId, type, version } и ставит publishedAt только после подтверждения выбранного клиента брокера. Если dispatcher умер после отправки, но до отметки, он отправит сообщение снова. Поэтому consumer обязан выдерживать duplicate. Уникальность результата обеспечит не брокер, а контракт бизнес-операции.

\n
// Учебный псевдокод. Это не готовый клиент брокера.\nasync function requestExport(input, db) {\n  const jobId = `export-${input.accountId}-${input.period}`;\n\n  await db.transaction(async (tx) => {\n    await tx.insertJob({\n      id: jobId,\n      type: 'report.export',\n      state: 'queued',\n      attempt: 0,\n      resultKey: null\n    });\n    await tx.insertOutbox({\n      type: 'report.export.requested',\n      jobId,\n      version: 1,\n      publishedAt: null\n    });\n  });\n\n  return { accepted: true, jobId };\n}
\n

Здесь jobId намеренно стабилен для выбранного набора входных данных. Реальный проект должен решить, допустимы ли два экспорта одного периода. Если допустимы, идентификатор включает уникальный request key. Если нет, уникальный индекс или условная вставка должны остановить второй запуск. Нельзя получить идемпотентность только из названия очереди.

\n

Worker и порядок ack

\n

Worker читает запись задачи по jobId, а не доверяет payload как единственному источнику состояния. Он проверяет финальные состояния. Для succeeded достаточно подтвердить повторное delivery. Для quarantined нужно подтвердить delivery и оставить причину доступной оператору. Для активной задачи worker выполняет действие с устойчивым ключом результата.

\n

Безопасный порядок для учебного экспорта такой: взять задачу, пометить попытку, создать файл по ключу reports/export-42.csv, проверить, что запись устойчива, перевести задачу в succeeded, отправить ack. Сбой после сохранения файла и до ack вызовет повтор. Повтор увидит существующий ключ, не создаст второй файл и подтвердит новое delivery. Сбой до сохранения результата оставит задачу для повторной попытки.

\n
// Учебный обработчик. API jobs и broker абстрактен.\nasync function handleDelivery(delivery, jobs, broker) {\n  const job = await jobs.findForUpdate(delivery.jobId);\n\n  if (job.state === 'succeeded' || job.state === 'quarantined') {\n    await broker.ack(delivery.tag);\n    return;\n  }\n\n  try {\n    await jobs.markRunning(job.id, delivery.attempt);\n    const resultKey = `reports/${job.id}.csv`;\n    await writeReportOnce(resultKey, job.payload);\n    await jobs.markSucceeded(job.id, resultKey);\n    await broker.ack(delivery.tag);\n  } catch (error) {\n    const nextAttempt = delivery.attempt + 1;\n    await jobs.markRetryOrQuarantine(job.id, nextAttempt, error.code);\n    await broker.nack(delivery.tag, { requeue: nextAttempt < 3 });\n  }\n}
\n

Функция writeReportOnce здесь обозначает контракт, а не готовую библиотеку. Она может использовать уникальный ключ объекта, условную вставку или идемпотентный endpoint внешнего сервиса. Если внешний сервис не поддерживает повтор безопасно, состояние задачи не может в одиночку отменить уже отправленное письмо или платёж. Для такого эффекта нужен ключ идемпотентности на внешней стороне либо отдельный статусный протокол.

\n

Повтор, backpressure и карантин

\n

Повтор подходит для временной ошибки: короткого сетевого сбоя, временной недоступности зависимости или превышения лимита. Он не исправляет неверную схему сообщения. Для постоянной ошибки нужен предел попыток, задержка между ними и отдельная очередь карантина. Иначе poison message снова попадает в начало очереди, worker тратит время на одну и ту же ошибку, а полезные задачи ждут.

\n

Не ставьте минимальную задержку без причины. Три мгновенных повтора могут усилить аварию зависимости. Для временной ошибки задайте ограниченное число попыток и возрастающую задержку. Для ошибки валидации отправляйте задачу сразу в карантин. В записи сохраняйте код причины и номер последней попытки, но не полный ответ внешней системы с персональными данными.

\n

Очередь не заменяет ограничение нагрузки. Если worker получает новые delivery быстрее, чем завершает старые, растут backlog и память. Ограничение prefetch уменьшает число незавершённых delivery на consumer, но не ускоряет обработку. Если одна задача блокирует worker надолго, отделите её очередь или задайте лимит времени. Таймаут должен переводить задачу в понятное состояние, а не просто обрывать функцию без записи.

\n

Порядок внедрения

\n
  1. Назовите одну операцию и её границу. Запишите, что пользователь считает «принято», «выполнено» и «отклонено».
  2. Создайте модель задачи с устойчивым jobId, состоянием, попыткой, ключом результата и безопасной причиной ошибки.
  3. Сохраните задачу и outbox в одной транзакции. Верните клиенту 202 и jobId, а не обещание готового результата.
  4. Настройте dispatcher, который повторяет неопубликованный outbox и отмечает публикацию только после подтверждения клиента брокера.
  5. Сделайте worker идемпотентным по jobId или ключу результата. Сначала сохраните бизнес-результат и состояние, затем отправьте ack.
  6. Разделите временные и постоянные ошибки. Для временных задайте лимит и задержку, для постоянных — карантин с причиной.
  7. Добавьте статусные поля и журнал переходов: received, running, retry, succeeded, quarantined. Не записывайте секреты и лишний payload.
  8. Проверьте четыре сбоя отдельно: падение до публикации, после публикации, после результата до ack и на третьей неудачной попытке.
\n

Ограничения

\n

Эта схема не даёт exactly-once. Она даёт повторяемость и место, где проверить результат. Между сохранением результата и ack всегда остаётся окно, поэтому бизнес-действие должно выдерживать duplicate. Outbox не делает публикацию атомарной с брокером. Publisher confirm подтверждает взаимодействие publisher с узлом брокера, а не обработку сообщения worker.

\n

Пример не содержит настоящего подключения к RabbitMQ, настройки очереди, TLS, прав, транзакций конкретной СУБД или измеренных значений latency. Имена export-42, ключ файла и число попыток — учебные данные. Их нельзя выдавать за результат нагрузочного теста. В реальной системе отдельно проверяют срок хранения outbox, рост карантина, восстановление после рестарта, размер payload, таймаут внешнего API и удаление чувствительных данных.

\n

Если действие необратимо, например платёж или отправка письма, запись результата после вызова может не решить двойное выполнение. Нужен idempotency key, который принимает внешний сервис, или промежуточный статус с ручным подтверждением. Если контракт отсутствует, безопаснее остановить автоматический retry и передать операцию в разбор, чем обещать автоматическую надёжность.

\n

Критерий готовности

\n

Решение готово, когда по одному jobId можно определить: записано ли намерение, опубликован ли outbox, сколько было delivery, какой результат сохранён и почему задача остановилась. Тестовый повтор после сбоя между результатом и ack не создаёт второй результат. Невалидная задача после заданного числа попыток попадает в карантин. Сбой dispatcher не теряет outbox. Эти проверки должны видеть состояние базы, сообщения и результат, а не только код ответа HTTP.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/277.json b/editorial/agent-rewrites/277.json new file mode 100644 index 0000000..75c6d56 --- /dev/null +++ b/editorial/agent-rewrites/277.json @@ -0,0 +1,7 @@ +{ + "index": 277, + "slug": "editorial-2020-04-field-reverse-proxy", + "title": "Reverse proxy: как отделить 502, 504, неверную схему и адрес клиента", + "excerpt": "504, ссылка с http вместо https и одинаковый IP в логах — разные ветки диагностики. Разбираем один proxy-hop, безопасный access-log и порядок проверки без правок вслепую.", + "contentHtml": "

Клиент получает 504. Приложение строит ссылку с http, хотя пользователь открыл сайт по HTTPS. В access-log все пользователи приходят с одним IP. Эти симптомы часто называют одной проблемой reverse proxy и начинают увеличивать таймауты. Цена ошибки — медленный ответ для всех маршрутов, неверные redirect и потеря реального адреса клиента в расследовании. Иногда такая правка ещё и позволяет внешнему клиенту подменить forwarded-заголовок.

\n

Reverse proxy создаёт отдельный HTTP-hop между клиентом и приложением. Внешний TLS может завершиться на proxy, а до приложения пойдёт обычный HTTP. Приложение увидит адрес proxy как непосредственный peer. Это ожидаемо. Ошибка появляется, когда код принимает внутреннюю схему за внешнюю или считает первый элемент X-Forwarded-For достоверным без проверки источника.

\n

Тезис: status показывает ветку, а не причину

\n

Один status не объясняет, где сломался запрос. 504 указывает, что proxy не получил нужный ответ вовремя. Это ещё не доказательство проблем базы, GC или внешнего API. 502 означает, что proxy не смог отдать корректный ответ upstream в своей конфигурации. Причина может быть в маршруте, соединении, формате ответа или недоступном процессе.

\n

Разбор нужно вести по одному URL и одному вопросу. Для 504 вопрос звучит так: «получил ли proxy заголовки ответа upstream до истечения лимита чтения?» Для схемы: «какой hop завершил TLS и какое поле читает приложение?» Для адреса: «какой источник имеет право заменить адрес непосредственного peer?» Такие вопросы отделяют наблюдение от догадки.

\n

Сначала фиксируем путь запроса

\n

Нарисуйте минимальную цепочку: клиент, внешний балансировщик или Nginx, затем upstream-приложение. Отдельно отметьте место TLS termination. Если перед вашим Nginx есть ещё один proxy, он становится частью договора. Запишите, кто добавляет или перезаписывает Forwarded, X-Forwarded-For и X-Forwarded-Proto. Не смешивайте заголовок из внешнего запроса с тем, что сформировал доверенный hop.

\n

Для первой проверки выберите /health, техническую страницу или отдельный стендовый endpoint без пользовательских данных. Добавьте безопасный диагностический токен, если приложение умеет связать его с записью в журнале. Запрос должен быть повторяемым. Если лог приложения не содержит токен, запишите это как неизвестное. Не восстанавливайте отсутствующие факты по времени ответа.

\n
Карта диагностики reverse proxy
СимптомПричинаПроверкаДействие
504 на одном endpointProxy не дождался чтения ответа upstreamСопоставить request_time, upstream_header_time, upstream_response_time и лог приложения по одному токенуИсправить задержку upstream или отдельно пересмотреть лимит этого endpoint
502 после изменения proxy_passНеверный маршрут, URI или некорректный ответ upstreamПроверить итоговый location, адрес upstream и upstream_statusИсправить одну границу маршрута; не лечить 502 увеличением read timeout
Приложение строит ссылку с httpTLS завершился раньше, а приложение читает внутреннюю схемуСверить внешний маршрут, $scheme proxy и поле, которое читает фреймворкЗафиксировать один доверенный forwarded-сигнал и его источник
У всех пользователей один IPПриложение видит peer proxy или real IP настроен без доверенной границыПроверить прямой доступ к приложению и список доверенных proxyНастроить real IP только для известных источников; не брать первый header вслепую
\n

В таблице нет действия «перезапустить всё». Перезапуск может убрать временный эффект, но не доказывает причину. Не меняйте одновременно таймаут, пул соединений, retry и код обработки заголовков. Иначе следующий запрос не покажет, какая правка повлияла на результат.

\n

Что именно измеряет proxy

\n

Для upstream полезны четыре времени. upstream_connect_time показывает время соединения. upstream_header_time — время до заголовков ответа. upstream_response_time — время до завершения чтения ответа. request_time включает обработку запроса на стороне proxy. Значение - не равно нулю: соответствующая стадия могла не завершиться или upstream мог не ответить.

\n

Учебный формат журнала должен сохранять эту развилку и не собирать лишние данные. Не добавляйте authorization, cookie, тело запроса и реальный IP, если они не нужны для конкретной проверки. Пример ниже синтетический. Его значения не описывают production-систему.

\n
log_format proxy_boundary '$request_method $uri status=$status '\n                          'request=$request_time upstream=$upstream_addr '\n                          'upstream_status=$upstream_status '\n                          'connect=$upstream_connect_time '\n                          'header=$upstream_header_time '\n                          'response=$upstream_response_time';\n\naccess_log /path/to/proxy-boundary.log proxy_boundary;
\n
# Синтетическая строка для чтения полей, не реальный access-log\nGET /health status=504 request=3.001 upstream=<backend> upstream_status=-\nconnect=0.001 header=- response=3.001
\n

Эта строка поддерживает гипотезу: proxy быстро установил соединение, но не получил заголовки ответа до своего лимита. Она не называет причину задержки. Следующий шаг — проверить конфигурацию конкретного location и запись приложения для того же запроса. Если upstream успел выполнить работу, увеличение таймаута только скроет задержку и увеличит число одновременно занятых соединений.

\n

Схема, адрес и заголовки требуют разных правил

\n

На внутреннем hop-е $scheme может быть http, даже если внешний клиент использовал HTTPS. Приложение должно получить внешний факт через согласованный заголовок. Но заголовок безопасен только тогда, когда внешний клиент не может напрямую передать его приложению и когда proxy передаёт его по понятному правилу.

\n

Для адреса действует другая граница. set_real_ip_from описывает доверенные источники, а не всех возможных клиентов. Если приложение доступно в обход proxy, клиент может отправить собственный X-Forwarded-For. В этом случае чтение первого значения превращает пользовательский ввод в идентификатор клиента. Сначала закройте обход или определите его в архитектуре, затем настройте цепочку proxy. Не смешивайте Forwarded и X-Forwarded-* как будто они всегда образуют одну достоверную последовательность.

\n
Граница доверия между клиентом, reverse proxy и приложением для схемы и адреса клиента
Схему и адрес клиента проверяют через разные договоры: источник forwarded-заголовка и список доверенных proxy должны быть известны заранее.
\n

Учебная конфигурация

\n

Ниже приведён ограниченный пример для изолированного стенда. Имена, адрес, порт и значения не относятся к рабочему контуру. В рабочей системе нужно проверить итоговую конфигурацию после всех include, а не только фрагмент из одного файла.

\n
# Учебный пример; не переносить без проверки топологии\nupstream app_backend {\n    server 127.0.0.1:3000;\n}\n\nserver {\n    listen 8080;\n    server_name _;\n\n    location / {\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_connect_timeout 3s;\n        proxy_send_timeout 10s;\n        proxy_read_timeout 15s;\n        proxy_pass http://app_backend;\n    }\n}
\n

Эта конфигурация показывает механизм, но не выбирает правильные значения для нагрузки. proxy_read_timeout ограничивает паузу между чтениями ответа. Для streaming endpoint это не обязательно полная длительность передачи. Если приложение отправляет части ответа с большими паузами, критерий готовности должен учитывать этот режим отдельно.

\n

Порядок проверки

\n
  1. Зафиксируйте один симптом, один URL без чувствительных данных и цену повторения ошибки.
  2. Отметьте на схеме клиент, каждый proxy-hop, upstream и место TLS termination.
  3. Проверьте итоговые proxy_pass, proxy_set_header и таймауты конкретного location.
  4. Включите безопасный access-log с request_time и upstream-полями на согласованном стенде.
  5. Выполните один повторяемый запрос с диагностическим токеном; сопоставьте proxy-log и лог приложения.
  6. Измените только подтверждённую границу, повторите тот же маршрут и сравните те же поля.
  7. Если результат не изменился, откатите правку и перейдите к следующей гипотезе из таблицы.
\n

Для проверки заголовков используйте только подстановки стенда. Команда ниже не запускалась и не подтверждает доступность адреса.

\n
# Учебный запрос; заменить только URL и Host изолированного стенда\ncurl -i --max-time 5 \\\n  '<PROXY_URL>/health' \\\n  -H 'Host: <HOST>' \\\n  -H 'X-Debug-Token: proxy-study-2020-04'
\n

Проверяйте не только статус 200. Для схемы сравните значение, которое использовал код, с договором proxy. Для адреса убедитесь, что приложение получило значение только от доверенной цепочки. Для 504 сопоставьте границу времени с upstream и журналом приложения. Если исходная запись отсутствует, оставьте это неизвестным и не объявляйте гипотезу доказанной.

\n

Ограничения и критерий готовности

\n

Статья не заменяет документацию конкретного фреймворка, балансировщика или версии Nginx. Она не выбирает таймаут без данных о нагрузке и не делает forwarded-заголовок достоверным сам по себе. Примеры журнала, времени, адреса 127.0.0.1 и URL являются учебными. Реальные Nginx, curl, browser, staging и production для этого материала не запускались.

\n

Проверка готова, когда для одного стендового маршрута зафиксированы цепочка hop-ов, место TLS termination, источник каждого forwarded-поля и безопасные proxy-времена. Повторный запрос даёт тот же ожидаемый контракт. Изменение одной причины меняет ожидаемый сигнал, а неверная гипотеза не маскируется перезапуском. В рабочем контуре дополнительно проверяют отсутствие прямого обхода proxy, политику журналирования и план отката.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/278.json b/editorial/agent-rewrites/278.json new file mode 100644 index 0000000..e6a1ad0 --- /dev/null +++ b/editorial/agent-rewrites/278.json @@ -0,0 +1,7 @@ +{ + "index": 278, + "slug": "editorial-2020-04-mechanism-reverse-proxy", + "title": "Reverse proxy под капотом: два соединения, заголовки и таймауты", + "excerpt": "Приложение видит не внешний запрос, а новый upstream-hop. Разбираем, как proxy меняет Host, forwarded-заголовки и время ожидания, и как проверить границу без догадок.", + "contentHtml": "

Проблема часто выглядит как ошибка приложения. Пользователь открыл HTTPS, а код строит ссылку с http. В журнале все посетители имеют один адрес. После добавления proxy часть запросов заканчивается 502 или 504. Цена ошибки — не только один неудачный ответ. Неверная схема ломает редиректы и cookie. Ошибка в адресе клиента ломает лимиты, аудит и расследование. Слишком большой таймаут дольше держит соединения и маскирует зависший upstream.

\n

Тезис статьи простой: reverse proxy не является прозрачным проводом. Он принимает внешний HTTP-запрос, завершает один hop и создаёт новый запрос к upstream. На новой границе меняются peer address, часть заголовков, момент подключения и правила ожидания. Поэтому приложение нужно настраивать не на «оригинальный запрос вообще», а на явный договор: какие поля proxy передаёт, откуда они пришли, чему приложение доверяет и какой сигнал подтверждает каждый вывод.

\n

Механизм: у одного запроса есть два hop-а

\n

Клиент устанавливает соединение с proxy. Proxy выбирает server и location, проверяет маршрут, может завершить TLS, изменить URI, добавить заголовки и буферизовать тело. Затем proxy устанавливает отдельное соединение с upstream. Для приложения непосредственным соседом становится proxy, а не браузер. Это нормально. Ошибка возникает, когда код принимает внутренний peer или клиентский header за внешний факт без проверки границы.

\n

Схема работает так же. Если TLS завершается на Nginx, внешний hop может быть HTTPS, а соединение Nginx с приложением — HTTP. Значение $scheme на этом Nginx описывает именно вход в него. Если TLS завершился раньше, оно может не совпасть со схемой, которую видел клиент. В таком случае нужен один доверенный источник исходной схемы. Нельзя позволять приложению выбирать между несколькими заголовками по ситуации.

\n

То же относится к адресу. $remote_addr на Nginx обозначает непосредственную удалённую сторону входного соединения. Если перед Nginx стоит балансировщик, это может быть адрес балансировщика. X-Forwarded-For может содержать цепочку, которую сформировал предыдущий proxy или прислал клиент. Само имя заголовка не делает значение достоверным.

\n
Симптом → причина → проверка → действие
СимптомПричина или гипотезаПроверкаДействие
Приложение строит ссылку с httpTLS завершился на proxy, а приложение не получило согласованный признак схемыСверить место TLS termination, $scheme и поле, которое читает приложениеОставить один доверенный forwarded-сигнал и закрыть прямой обход приложения
Все пользователи имеют один адресПриложение видит peer proxy или неверно разбирает forwarded-цепочкуНарисовать hop-ы и проверить доверенные источники до включения realipЗадать список доверенных proxy; не брать первый элемент заголовка вслепую
502 появился после изменения маршрутаНеверный proxy_pass, URI, порт или недоступный upstreamПроверить итоговый location, адрес upstream и его statusИсправить маршрут; не увеличивать proxy_read_timeout
504 появляется только на одном endpointProxy не получил следующий байт ответа в пределах read timeoutСопоставить request_time, upstream-времена и безопасную запись приложенияНайти паузу в upstream или изменить лимит только для этого типа endpoint
Потоковый ответ обрывается при долгой паузеproxy_read_timeout ограничивает паузу между чтениями, а не всю передачуИзмерить интервалы между частями ответаВыделить отдельную policy для streaming; не переносить общий JSON-лимит
\n

Таблица отделяет наблюдение от диагноза. Status 504 говорит о результате ожидания proxy, но не называет базу, GC, внешний API или код обработчика. Status 502 говорит о проблеме на пути к корректному upstream-ответу, но не выбирает между маршрутом, портом и самим процессом. Один сигнал даёт ветку проверки, а не готовое объяснение.

\n

Учебная конфигурация границы

\n

Ниже приведён минимальный пример для изолированного стенда. Имена, адрес 127.0.0.1 и значения таймаутов учебные. Пример показывает места договора, а не готовую production-конфигурацию.

\n
upstream app_backend { server 127.0.0.1:3000; } server { listen 8080; server_name _; location / { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection &quot;&quot;; proxy_connect_timeout 3s; proxy_send_timeout 10s; proxy_read_timeout 15s; proxy_pass http://app_backend; } }
\n

proxy_set_header Host $host задаёт значение явно. X-Forwarded-For расширяет цепочку, а не доказывает, что каждый её элемент заслуживает доверия. X-Forwarded-Proto $scheme описывает схему входного hop-а именно этого Nginx. Если перед ним есть другой TLS-терминатор, контракт должен учитывать его. Нельзя копировать строку https только потому, что внешняя страница открывается по HTTPS.

\n

Таймауты отвечают на разные паузы. proxy_connect_timeout относится к установлению соединения с upstream. proxy_send_timeout относится к последовательным операциям записи запроса. proxy_read_timeout относится к паузе между последовательными операциями чтения ответа. Поэтому длительность ответа и read timeout — разные величины. Streaming может длиться дольше лимита, если upstream регулярно отправляет данные. Ответ без следующего байта может оборваться раньше, чем команда ожидает.

\n
\"Схема
Header становится полезным сигналом только вместе с источником и правилом доверия. Строка без границы не доказывает ни схему, ни адрес клиента.
\n

Как связать два hop-а в журнале

\n

Для первого разбора достаточно access-log с URI, итоговым status, $request_time, адресом upstream, его status и временем подключения, заголовков и ответа. Такой формат отвечает на узкий вопрос: proxy не подключился к upstream или подключился, но не дождался заголовков. Не нужно записывать cookie, authorization, тело запроса или полный query string. Лишние данные не делают гипотезу точнее.

\n
log_format proxy_boundary &quot;$request_method $uri status=$status request=$request_time upstream=$upstream_addr upstream_status=$upstream_status connect=$upstream_connect_time header=$upstream_header_time response=$upstream_response_time&quot;; access_log /path/to/proxy-boundary.log proxy_boundary; GET /health status=504 request=3.001 upstream=&lt;backend&gt; upstream_status=- connect=0.001 header=- response=3.001;
\n

Синтетическая строка поддерживает гипотезу: соединение установилось быстро, но proxy не получил заголовки ответа до своего лимита. Она не доказывает причину внутри приложения. Следующий шаг — проверить конкретный endpoint и безопасный токен в логе приложения. Если записи нет, это неизвестное, а не повод сочинять её содержание.

\n

Порядок проверки

\n
  1. Записать один симптом, один безопасный URL и цену ошибки. Не объединять неверную схему, адрес и 504 в одну причину.
  2. Нарисовать реальные hop-ы: клиент, TLS termination, Nginx, возможный предыдущий proxy и upstream. Отдельно указать, кто имеет право выставлять forwarded-поля.
  3. Проверить итоговый location, proxy_pass и все proxy_set_header. Смотреть нужно собранную конфигурацию, а не только фрагмент include-файла.
  4. Разделить connect, send и read timeout. Для выбранного endpoint указать, какую паузу ограничивает каждое значение.
  5. Выполнить один учебный запрос к маршруту без пользовательских данных. Сверить status и ответные заголовки с access-log и записью приложения по безопасному токену.
  6. Изменить одну подтверждённую границу и повторить тот же запрос. Если результат не изменился, сохранить отрицательный вывод и перейти к следующей строке таблицы.
  7. Зафиксировать откат и новый сигнал наблюдения. Увеличение таймаута без ожидаемого изменения в журнале не считается объяснённым исправлением.
\n

Ограничения и отрицательный путь

\n

Эта модель не делает forwarded-заголовок безопасным. Доверие задаёт топология и конфигурация. В Nginx realip-модуле адрес клиента меняется только для источников, которые явно разрешены через set_real_ip_from. Если прямой запрос может обойти доверенный proxy, приложение должно считать такие поля недоверенными или закрыть этот путь на сети.

\n

Модель также не выбирает таймауты за команду. Значение зависит от типа endpoint, бюджета клиента, лимита приложения, поведения upstream и числа одновременных соединений. Длинный timeout может уменьшить число быстрых ошибок, но увеличить очередь и расход ресурсов. Retry добавляет ещё одну попытку и может повторить неидемпотентное действие. Его нельзя добавлять как универсальное средство против 504.

\n

Отрицательный путь обязателен. Если после явного Host приложение всё ещё строит неверную ссылку, проверяют поле, которое читает код, и предыдущий TLS-терминатор. Если после изменения read timeout status не изменился, проверяют маршрут, доступность upstream и клиентский timeout. Если адрес остаётся адресом proxy, проверяют доверенную цепочку и прямой доступ. «Перезапустили — стало нормально» не подтверждает ни одну из этих гипотез.

\n

Проверяемый критерий готовности

\n

Граница готова, если команда может повторить один безопасный запрос и получить доказательства по каждому hop-у: внешний status и схема, итоговый набор переданных headers, запись proxy с request/upstream-временами и согласованная запись приложения. Для адреса известен список доверенных proxy. Для каждого timeout названа конкретная пауза. Отрицательный тест с недоверенным прямым header не меняет схему, права или адрес клиента. Если хотя бы одно условие не проверено, конфигурация ещё не готова к переносу в рабочий контур.

\n

Все конфигурации, команды и значения времени в статье учебные. Nginx, curl, staging и production здесь не запускались. Перед применением нужно проверить версию Nginx, реальную топологию, владельца конфигурации, политику журналирования и возможность безопасного отката.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/279.json b/editorial/agent-rewrites/279.json new file mode 100644 index 0000000..53a836c --- /dev/null +++ b/editorial/agent-rewrites/279.json @@ -0,0 +1,7 @@ +{ + "index": 279, + "slug": "editorial-2020-04-practice-reverse-proxy", + "title": "Reverse proxy перед приложением: как проверить границу HTTP", + "excerpt": "Приложение за proxy видит не тот сокет, что клиент. Разбираем Host, forwarded-заголовки и таймауты по hop-ам, а затем проверяем контракт одним безопасным маршрутом.", + "contentHtml": "

Запрос приходит по HTTPS, но приложение строит редирект на HTTP. В журнале все посетители имеют один IP. Иногда тот же endpoint отвечает 200, а иногда получает 504. Эти симптомы похожи на ошибку приложения, хотя причина часто находится на границе между клиентом, reverse proxy и upstream.

\n

Цена ошибки растёт быстро. Неверная схема ломает абсолютные ссылки и secure-cookie. Неверный адрес клиента портит rate limit и расследование инцидента. Непонятый таймаут превращает медленный ответ в спор о том, «упал ли backend». Исправлять эти симптомы одним новым заголовком опасно: proxy создаёт отдельное соединение и меняет контекст запроса.

\n

Тезис: проверяйте каждый hop отдельно

\n

Reverse proxy не является прозрачным проводом. Он принимает одно HTTP-соединение от клиента и открывает другое соединение к приложению. Для Nginx непосредственный peer — клиент или предыдущий proxy. Для приложения непосредственный peer — Nginx. На границе могут измениться Host, схема, цепочка адресов, момент ожидания и видимый статус.

\n

Рабочая проверка поэтому должна отвечать на четыре разных вопроса. Что отправил клиент? Что Nginx передал upstream? Что приложение прочитало? Что записали оба журнала? Пока эти ответы смешаны, статус 502 или 504 остаётся только симптомом.

\n

Механизм двух соединений

\n

Предположим, TLS завершается на Nginx. Внешний hop выглядит так: клиент подключается к Nginx по HTTPS. Внутренний hop может идти к приложению по HTTP. Само по себе это нормально. Приложение не узнает внешнюю схему из внутреннего сокета. Оно узнает её только из согласованного forwarded-заголовка или из другого доверенного контракта.

\n

То же относится к адресу. $remote_addr на Nginx обозначает адрес непосредственного источника входного соединения. Если перед Nginx уже стоит балансировщик, это может быть адрес балансировщика. Заголовок X-Forwarded-For не становится истинным только потому, что его прислал клиент. Приложение должно принимать его от заранее определённого доверенного proxy, а не от любого HTTP-подключения.

\n

Host отвечает за другой класс ошибок. Приложение может выбирать tenant, строить редирект или проверять origin по этому полю. Если proxy не передал внешний host явно, upstream получит значение, отличное от того, что ввёл пользователь. Сначала фиксируют ожидаемый контракт, потом выбирают директиву и адаптер фреймворка. Обратный порядок порождает угадывание.

\n
Диагностика границы reverse proxy
СимптомПричинаПроверкаДействие
Редирект ведёт на httpTLS завершился на proxy, а приложение не получило согласованную схемуСопоставить внешний URL, forwarded-заголовок и поле, которое читает приложениеПередать один явный признак схемы и разрешить его только от доверенного proxy
Все клиенты имеют IP proxyПриложение пишет peer address внутреннего соединенияСравнить remote address на proxy с цепочкой адресов в upstreamНастроить доверенную цепочку real IP; не брать первое значение из любого заголовка
Пропал tenant или изменился hostUpstream получил другой Host или URI после proxy_passЗаписать host и URI на proxy и в приложении для одного тестового запросаЯвно зафиксировать Host и правило преобразования URI
504 после ровного интервалаProxy не дождался следующего события upstreamСравнить connect, header и response time с таймаутами и логом приложенияОпределить, какой этап превысил бюджет; не увеличивать все таймауты сразу
Снаружи 502, в приложении нет записиСбой соединения до обработки запроса приложениемПроверить upstream address, connect time и доступность процессаИсправить маршрут или состояние upstream; не искать ошибку в контроллере
\n

Учебная конфигурация

\n

Ниже показана малая конфигурация для изолированного стенда. Имя upstream, порт, путь журнала и значения таймаутов учебные. Этот фрагмент не является готовым production-рецептом. Его задача — сделать границу видимой и дать каждой директиве проверяемый смысл.

\n
upstream app_backend {\n    server 127.0.0.1:3000;\n}\n\nserver {\n    listen 8080;\n    server_name _;\n\n    location / {\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Connection \"\";\n\n        proxy_connect_timeout 3s;\n        proxy_send_timeout 10s;\n        proxy_read_timeout 15s;\n        proxy_pass http://app_backend;\n    }\n}
\n

proxy_connect_timeout отвечает за установление соединения с upstream. proxy_send_timeout ограничивает паузы при передаче запроса. proxy_read_timeout ограничивает паузу между последовательными чтениями ответа. Последняя директива не задаёт полную длительность endpoint. Потоковый ответ может идти дольше, если upstream регулярно отправляет данные. Тихий ответ может оборваться раньше.

\n

В примере X-Forwarded-For дополняет цепочку, а не безусловно заменяет её. Но это ещё не политика доверия. Если приложение доступно в обход Nginx, клиент сможет прислать такой заголовок напрямую. Сначала закрывают обход или фильтруют источник, затем включают обработку forwarded-данных. Для real IP отдельно перечисляют доверенные сети.

\n
\"Клиент
Один внешний запрос даёт как минимум два HTTP-hop-а. Ищите изменение сигнала на конкретной границе, а не в абстрактном «сервере».
\n

Логи должны доказывать путь

\n

Статус ответа без времени и upstream-контекста мало помогает. Для учебной проверки достаточно записать метод, URI, итоговый статус, адрес upstream, полное время запроса и интервалы подключения, получения заголовков и ответа. В журнал нельзя добавлять секреты, cookie и произвольное тело запроса.

\n
log_format proxy_boundary '$request_method $uri status=$status '\n                          'request=$request_time upstream=$upstream_addr '\n                          'upstream_status=$upstream_status '\n                          'connect=$upstream_connect_time '\n                          'header=$upstream_header_time '\n                          'response=$upstream_response_time';\n\naccess_log /path/to/proxy-boundary.log proxy_boundary;
\n

Синтетическая строка ниже показывает способ чтения полей. Она не получена от реального Nginx и не доказывает причину сама по себе.

\n
GET /health status=504 request=3.001 upstream=<backend> upstream_status=-\nconnect=0.001 header=- response=3.001
\n

Такой результат сужает поиск: proxy установил соединение, но не получил ответ в пределах лимита. Дальше проверяют лог приложения и его собственный timeout. Если upstream_status отсутствует, это не доказательство, что приложение не запустилось: нужно проверить формат журнала и точный этап отказа.

\n

Проверка одним безопасным маршрутом

\n

Для контракта не нужен полный smoke-тест. Выберите endpoint без пользовательских данных, например /health на учебном стенде. Добавьте диагностический токен, который можно найти в журнале proxy и в журнале приложения. Токен не заменяет аутентификацию и не должен содержать секрет.

\n
# Учебный запрос. URL и Host нужно заменить значениями из изолированного стенда.\ncurl -i --max-time 5 \\\n  'https://<proxy-host>/health' \\\n  -H 'Host: <public-host>' \\\n  -H 'X-Debug-Token: proxy-study-2020-04'
\n

Положительный результат состоит не только из 200. Внешний ответ должен иметь ожидаемые статус и заголовки. В записи Nginx должен быть тот же безопасный токен. В записи приложения должен быть тот же токен, ожидаемый Host и ожидаемая схема. Если хотя бы одна запись отсутствует, проверка не подтверждает весь путь.

\n

Порядок действий

\n
  1. Запишите один симптом и его цену: неверный редирект, потерянный адрес, 502/504 или неожиданный timeout.
  2. Нарисуйте реальные hop-ы: кто принимает внешний запрос, где завершается TLS и кто является upstream.
  3. Назначьте владельца каждому сигналу: Host, схема, forwarded-цепочка, время подключения и время ответа.
  4. Определите доверенную границу. Укажите, кто имеет право выставлять forwarded-заголовки и может ли клиент обойти proxy.
  5. Соберите минимальную конфигурацию, проверьте её синтаксис и не меняйте одновременно route, код приложения и все таймауты.
  6. Выполните один учебный запрос. Сопоставьте внешний ответ, запись proxy и запись приложения по безопасному токену.
  7. Если гипотеза не подтверждается, откатите одну изменённую строку и проверьте следующий hop. Не превращайте увеличение таймаута в финальное решение без объяснения.
\n

Ограничения и отрицательный путь

\n

Такая схема не решает проблемы, которые находятся за пределами HTTP-контракта. Она не доказывает корректность балансировки, TLS-сертификата, DNS, firewall, размера буфера или поведения нескольких upstream. Она также не делает forwarded-заголовки безопасными при открытом прямом доступе к приложению.

\n

Если внешний статус 200, но приложение всё равно строит неправильный URL, проверяйте не сеть, а поле, которое использует код. Если в proxy есть запрос, а в приложении нет записи, проверяйте соединение, маршрутизацию и ранний отказ. Если приложение пишет запрос, но proxy отдаёт 504, сравнивайте интервалы между байтами и общий бюджет маршрута. В каждом отрицательном пути меняйте одну гипотезу и сохраняйте наблюдаемый результат.

\n

Критерий готовности

\n

Граница готова, когда один безопасный запрос проходит через ожидаемые hop-ы, внешний ответ соответствует договору, proxy и приложение связываются по диагностическому токену, Host и схема читаются ожидаемо, а таймаут можно объяснить конкретным этапом. Конфигурация имеет проверенный синтаксис, прямой обход запрещён или явно учтён, а для неуспешного результата есть обратный шаг.

\n

Все значения в примерах — учебные. Здесь не заявлены запуск Nginx, выполнение curl, проверка browser, staging или production. Перед применением в проекте подтвердите топологию, доверенные сети, таймаут приложения и правила хранения журналов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/280.json b/editorial/agent-rewrites/280.json new file mode 100644 index 0000000..69a6929 --- /dev/null +++ b/editorial/agent-rewrites/280.json @@ -0,0 +1,7 @@ +{ + "index": 280, + "slug": "editorial-2020-03-field-ci-pipeline", + "title": "CI/CD: как не отправить в deploy результат другой сборки", + "excerpt": "После зелёного build на стенд попадает другой каталог. Разбираем границу между сборкой и deploy, передаём один artifact, сверяем commit и останавливаем выпуск до сетевого действия.", + "contentHtml": "

После merge job verify и build завершаются успешно, но на staging нет ожидаемого файла. Иногда deploy завершается зелёным, а приложение открывает старую версию. Иногда падает команда доставки с сообщением о пропущенном каталоге. Повторный запуск может убрать симптом и одновременно скрыть причину.

\n

Цена ошибки — потеря связи между проверенным и отправленным результатом. Команда не знает, какой commit собрался, какой каталог попал в deploy и какие зависимости использовал runner. В таком состоянии нельзя уверенно повторить сбой или доказать, что исправление относится к нужному релизу.

\n

Тезис простой: результат сборки должен создаваться один раз, передаваться как artifact и проверяться перед внешним действием. Job deploy не должна заново получать исходники и выполнять npm ci или npm run build. Она должна получить конкретный output от job build, сверить его с commit pipeline и остановиться при любом расхождении.

\n

Где ломается граница

\n

В плохой конфигурации build собирает приложение, а deploy снова делает checkout, устанавливает зависимости и собирает каталог. Две строки build passed тогда относятся к разным запускам. Между ними могут измениться cache, образ runner, версия package manager, переменные окружения и рабочая директория.

\n

Совпадение commit не доказывает совпадение output. Сборка зависит не только от Git-дерева. Важны lockfile, версия Node.js, настройки bundler, переменные окружения и порядок команд. Поэтому повторная сборка в deploy создаёт вторую точку производства релизного результата.

\n

В GitLab job artifact задаёт явную границу: ранняя job сохраняет каталог, поздняя job получает копию этого каталога. Поле dependencies ограничивает список job, чьи artifacts нужно скачать. Это делает происхождение файлов видимым в YAML и в логах.

\n

Антипример и рабочая схема

\n

Ниже учебный пример. Он показывает механизм, но не описывает реальный production-инцидент и не доказывает длительность, надёжность или успешность выкладки.

\n
build:\n  stage: build\n  script:\n    - npm ci\n    - npm run build\n  artifacts:\n    paths:\n      - dist/\n\ndeploy_staging:\n  stage: deploy\n  script:\n    - npm ci\n    - npm run build\n    - ./deploy-staging.sh dist/
\n

В этом варианте deploy_staging не использует dist/ от build. Он создаёт новый каталог. Даже если команда обычно получает тот же результат, pipeline не хранит доказательство этого равенства.

\n

Исправление переносит единственную сборку в build. Там же создаются идентификатор commit и контрольные суммы. Deploy скачивает artifact, проверяет его и только потом вызывает скрипт, который меняет внешнюю среду.

\n
build:\n  stage: build\n  script:\n    - npm ci\n    - npm run build\n    - printf '%s\\n' \"$CI_COMMIT_SHA\" > dist/REVISION\n    - (cd dist && find . -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)\n  artifacts:\n    paths:\n      - dist/\n\ndeploy_staging:\n  stage: deploy\n  dependencies:\n    - build\n  script:\n    - test -f dist/REVISION\n    - test \"$(cat dist/REVISION)\" = \"$CI_COMMIT_SHA\"\n    - (cd dist && sha256sum -c SHA256SUMS)\n    - test -f dist/index.html\n    - ./deploy-staging.sh dist/
\n

Команды в примере предполагают POSIX shell и каталог dist/. Название входного файла, способ доставки и формат checksum нужно заменить на правила конкретного приложения. Сам принцип не меняется: artifact создаёт один job, deploy только читает и проверяет его.

\n
\"Схема
Граница между build и deploy: неизвестный artifact не должен становиться сетевым действием.
\n

Как читать симптом

\n
СимптомПричинаПроверкаДействие
dist/REVISION отсутствуетBuild не создаёт контрактный файл или artifact не содержит путьПроверить script build и artifacts:pathsОстановить deploy, исправить состав результата
Revision отличается от $CI_COMMIT_SHADeploy читает старый или чужой каталогСравнить файл из artifact с переменной jobСоздать pipeline нужного commit и найти источник каталога
sha256sum -c завершается с ошибкойФайл изменился после manifest или передан неполный наборПроверить момент создания manifest и список filesНе вызывать delivery-script; собрать новый artifact
Deploy запускает npm run buildРезультат build не передаётся как dependencyПрочитать YAML и список скачанных artifactsУбрать повторную сборку, указать dependencies: [build]
Verify не прошёлОшибка теста, install или окруженияПосмотреть exit code и отчёт jobИсправить причину; build не использовать как обход
\n

«Вероятная причина» в таблице не равна доказанной. Например, checksum может не сойтись из-за того, что manifest создали до появления последнего файла. Проверка должна отделить этот случай от подмены каталога. Пока причина неизвестна, deploy остаётся заблокированным.

\n

Проверка до внешнего действия

\n

Последняя команда job имеет побочный эффект: она отправляет файлы, вызывает API или изменяет staging. До неё pipeline должен проверить четыре свойства. Artifact пришёл от ожидаемого job. В нём есть revision. Revision совпадает с commit pipeline. Контрольные суммы и обязательные файлы сходятся.

\n

Проверки должны быть жёсткими. test -f, сравнение строк и sha256sum -c должны возвращать ненулевой код при ошибке. Не стоит превращать mismatch в предупреждение или добавлять || true. Иначе лог покажет проблему, но pipeline продолжит движение к сетевой команде.

\n

Manual deploy не заменяет эти проверки. Ручное подтверждение отвечает на вопрос «можно ли сейчас запускать этот шаг», но не доказывает происхождение каталога. Оно полезно после автоматических gate, а не вместо них.

\n

Порядок действий

\n
  1. Остановить deploy до команды, которая меняет внешнюю среду. Записать имя job, revision и стадию сбоя.
  2. Найти в YAML все вызовы npm ci, npm install и npm run build. Для релизного каталога должен остаться один владелец.
  3. В build-job создать output, REVISION и checksum-manifest после появления всех файлов.
  4. Включить каталог в artifacts:paths. В deploy-job указать dependency на build и удалить повторную сборку.
  5. Добавить проверки существования, revision, checksum и обязательного файла. Каждую проверку оставить до delivery-script.
  6. Провести отрицательные проверки: убрать artifact, изменить revision и испортить файл после создания manifest. Во всех случаях job должна завершиться до внешнего действия.
  7. Отдельно проверить staging с согласованными доступами и откатом. Результат staging не переносить на production без новой проверки.
\n

Ограничения

\n

Artifact не делает сборку воспроизводимой сам по себе. Он сохраняет уже созданный результат. Для воспроизводимости дополнительно нужны зафиксированные зависимости, контролируемый образ runner и понятные переменные окружения.

\n

Checksum подтверждает целостность набора файлов после создания manifest. Он не подтверждает права доступа, безопасность секретов, корректность бизнес-логики или совместимость приложения со staging. Smoke-тесты и rollback решают другие задачи.

\n

dependencies подходит для простой последовательной схемы. При переходе к needs, нескольким build-job, child pipeline или межпроектным artifacts нужно отдельно проверить, откуда deploy получает файлы. Название job само по себе не является доказательством правильного источника.

\n

Критерий готовности

\n

Изменение готово, если для одного pipeline можно показать commit, job-источник, список artifact и checksum-manifest. Deploy не выполняет сборку повторно. При отсутствии файла, mismatch revision или неверной checksum он завершается до delivery-script. При корректном artifact он выполняет только согласованный staging-шаг.

\n

Это проверяемый критерий, а не обещание production-результата. Он показывает, что pipeline знает происхождение отправляемого каталога и умеет остановиться до внешнего действия.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/281.json b/editorial/agent-rewrites/281.json new file mode 100644 index 0000000..beedf74 --- /dev/null +++ b/editorial/agent-rewrites/281.json @@ -0,0 +1,7 @@ +{ + "index": 281, + "slug": "editorial-2020-03-mechanism-ci-pipeline", + "title": "Артефакт как контракт CI/CD: как не отправить непроверенную сборку", + "excerpt": "Зелёные job ещё не доказывают, что deploy отправит тот же каталог, который проверял build. Разбираем границу между checkout, cache и artifact и ставим проверку до сетевого действия.", + "contentHtml": "

Симптом появляется в момент поставки: job verify и build завершились успешно, а на staging нет ожидаемого файла или приложение выглядит не так, как после проверки. Лог показывает зелёные статусы, но не отвечает на главный вопрос: какой каталог проверили и какой каталог отправили. Цена ошибки — повторная сборка, потерянное время и откат, который тоже приходится собирать заново. В худшем случае команда принимает непроверенный результат за тот, что прошёл CI.

\n

Причина обычно не в одном флаге GitLab. Pipeline смешивает checkout, cache, рабочую директорию и release artifact. Пока deploy может заново вызвать npm run build, связь между проверкой и доставкой остаётся предположением. Надёжная граница проще: один job создаёт артефакт, следующие job получают этот артефакт, проверяют его происхождение и не собирают другой каталог.

\n

Что именно должен гарантировать pipeline

\n

Минимальный pipeline отвечает на четыре разных вопроса. Checkout подтверждает исходный commit. Установка зависимостей подтверждает согласованный lockfile. Build создаёт конкретный набор файлов. Deploy отправляет именно этот набор и останавливается до сетевого вызова, если доказательство неполно.

\n

Эти состояния нельзя подменять друг другом. Cache ускоряет установку, но может исчезнуть или устареть. Рабочая директория существует только внутри job. Успешная команда сборки говорит, что команда завершилась с нулевым кодом, но не говорит, какие файлы были приложены к следующему job. Artifact нужен как явный интерфейс между job: он переносит результат, а не надежду на одинаковое окружение.

\n
\"Схема
Путь выпуска должен быть виден по границам: проверки, сборка одного каталога, проверка artifact, затем внешний эффект.
\n

Почему одного commit недостаточно

\n

Один SHA связывает pipeline с исходниками, но не описывает все входы сборки. Результат зависит от lockfile, версии Node.js, package manager, образа runner, переменных и настроек bundler. Если deploy запускает сборку повторно, любой из этих входов может отличаться. Даже одинаковый commit не доказывает одинаковый output.

\n

На раннем шаге полезно намеренно делать установку строгой. npm ci использует существующий lockfile и завершается ошибкой при конфликте с manifest. Это выгоднее молчаливого обновления зависимостей: pipeline останавливается там, где нарушен входной контракт. Cache .npm/ можно подключить для скорости, но результат не должен зависеть от его наличия.

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
В deploy нет dist/index.htmlПуть не указан в artifact или job не получил artifactСверить artifacts:paths, имя job и dependenciesИсправить передачу файлов; не запускать повторный build
REVISION не равен $CI_COMMIT_SHAВзята сборка другого pipeline или checkout пересобран отдельноВывести значение файла и переменной до delivery-командыОстановить job и создать pipeline для нужного commit
Checksum не проходитФайл изменился после создания manifest или manifest неполонВыполнить sha256sum -c внутри полученного artifactНе выполнять сетевой шаг; исправить состав artifact
npm ci завершается ошибкойManifest и lockfile расходятся или не совпадает версия package managerПроверить файлы и версии на чистом runnerИсправить входы; не подменять их cache
Job зелёный, но результат не подтверждёнПроверяли команду, а не содержимое релизного каталогаПоказать список artifact и обязательные файлыДобавить явный контракт и проверку до deploy
\n

Таблица задаёт отрицательный путь. Неизвестное состояние не превращается в сетевой эффект. Retry может повторить случайный результат, но не объяснит, какой вход изменился. Сначала проверяют факт, затем меняют конфигурацию.

\n

Stages задают порядок, dependencies задают вход

\n

Ключ stages показывает порядок работ. Например, verify идёт перед build, а build — перед release. Но стадии сами по себе не описывают файлы, которые получает job. Для этого нужен явный список artifact-зависимостей.

\n

В минимальной схеме build собирает checkout и не получает файлы от verify. Поэтому для него уместно dependencies: []. Deploy получает artifact только от build. Если deploy содержит собственный npm ci и npm run build, граница снова исчезает: job проверяет один результат, а отправляет другой.

\n
image: node:12-alpine\n\nstages:\n  - verify\n  - build\n  - release\n\nverify:\n  stage: verify\n  script:\n    - npm ci --cache .npm --prefer-offline\n    - npm run lint\n    - npm test\n\nbuild:\n  stage: build\n  dependencies: []\n  script:\n    - npm ci --cache .npm --prefer-offline\n    - npm run build\n    - printf \"%s\\n\" \"$CI_COMMIT_SHA\" > dist/REVISION\n    - (cd dist && sha256sum * > SHA256SUMS)\n  artifacts:\n    name: \"web-$CI_COMMIT_SHA\"\n    paths:\n      - dist/\n    expire_in: 7 days\n\ndeploy_staging:\n  stage: release\n  dependencies:\n    - build\n  script:\n    - test -f dist/REVISION\n    - test \"$(cat dist/REVISION)\" = \"$CI_COMMIT_SHA\"\n    - (cd dist && sha256sum -c SHA256SUMS)\n    - test -f dist/index.html\n    - ./scripts/deploy-staging dist/\n  when: manual\n  allow_failure: false
\n

Код показывает учебную структуру, а не готовый production-файл. Образ, команды, имя выходного каталога и поведение manual job нужно сверить с конкретным GitLab Runner. В строке checksum используется простой плоский каталог и GNU-команда. Для вложенных файлов, другого shell или другой операционной системы нужен отдельный вариант проверки.

\n

REVISION и checksum — разные проверки

\n

Файл REVISION связывает содержимое каталога с commit, который выполняет pipeline. Если в нём другой SHA, deploy читает не тот результат или файл создан не из текущего checkout. Это проверка происхождения на уровне диагностики. Она не является цифровой подписью и не заменяет контроль доступа.

\n

SHA256SUMS проверяет, что файлы не изменились после создания manifest. Сначала build записывает все обязательные файлы, затем создаёт manifest, затем прикладывает каталог. Deploy проверяет manifest после передачи artifact и до вызова delivery script. Если manifest покрывает только часть каталога, нельзя называть весь artifact проверенным.

\n
# scripts/check-release-artifact.sh\nset -eu\n\ntest -f dist/REVISION\ntest -f dist/SHA256SUMS\ntest \"$(cat dist/REVISION)\" = \"$CI_COMMIT_SHA\"\n\n(cd dist && sha256sum -c SHA256SUMS)\ntest -f dist/index.html
\n

У каждой команды есть цена отказа. Отсутствующий файл останавливает job. Несовпадение revision останавливает job. Ошибка checksum останавливает job. Поэтому проверка должна стоять перед первой командой, которая открывает соединение со staging или production. В лог можно вывести SHA и названия проверенных файлов. Секреты, токены и полные URL доступа в него попадать не должны.

\n

Ручной gate не исправляет плохой artifact

\n

when: manual создаёт паузу перед внешним действием. Оператор может посмотреть результат verify, состав artifact и revision. Но ручное нажатие не доказывает качество каталога. Если deploy получил неизвестный набор файлов, человек лишь вручную запускает неизвестный набор файлов.

\n

Поэтому manual gate ставят после автоматических проверок. Для staging он может быть полезен как ограничитель частых изменений. Для production понадобятся отдельные правила доступа, rollback, миграции данных, наблюдение и согласование. Эта статья не утверждает, что минимальная схема покрывает их. Она закрывает более узкий вопрос: deploy не должен незаметно создавать новый результат после проверки.

\n

Порядок действий

\n
  1. Зафиксировать commit, команду сборки и путь итогового каталога. Не начинать с cache и ускорения.
  2. Проверить на чистом runner, что manifest и lockfile согласованы, а npm ci завершается без изменения lockfile.
  3. Добавить verify с существующими lint и test-командами. Ненулевой exit code должен блокировать следующую стадию.
  4. Оставить одному job право создавать release output. После сборки записать REVISION, создать checksum-manifest и приложить только нужный каталог.
  5. В deploy указать dependency на build и убрать из него установку зависимостей и повторную сборку.
  6. До delivery-команды проверить revision, checksum и обязательные файлы. При любом сбое не нажимать retry как замену расследованию.
  7. Проверить отрицательный путь: отсутствие artifact, подмена revision и изменение файла после manifest должны завершать job до сетевого вызова.
  8. Только затем провести согласованный staging-прогон. Его результат не выдавать за production-проверку.
\n

Ограничения и критерий готовности

\n

Этот механизм не доказывает, что пользовательский сценарий работает. Lint не заменяет тесты. Тесты не заменяют smoke на стенде. Checksum не заменяет авторизацию, резервное копирование и rollback. Артефакт также может истечь по сроку хранения, а синтаксис GitLab зависит от версии сервера и Runner. Эти условия нужно проверять отдельно.

\n

Минимальный pipeline готов, когда один запуск показывает один commit, один job-владелец release output и один переданный artifact. Deploy не содержит повторной сборки. До сетевой команды автоматически проверяются REVISION, manifest и обязательный файл. Три отрицательные проверки — отсутствующий artifact, неверный revision и испорченный файл — останавливают job. Если эти условия нельзя показать по YAML и логам, зелёный статус ещё не означает готовый выпуск.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/282.json b/editorial/agent-rewrites/282.json new file mode 100644 index 0000000..af6f67a --- /dev/null +++ b/editorial/agent-rewrites/282.json @@ -0,0 +1,7 @@ +{ + "index": 282, + "slug": "editorial-2020-03-practice-ci-pipeline", + "title": "Минимальный CI/CD pipeline: как не отправить непроверенную сборку", + "excerpt": "Практический разбор GitLab CI/CD: отделяем проверки от сборки, передаём deploy один артефакт и останавливаем выпуск, если его происхождение нельзя подтвердить.", + "contentHtml": "

Симптом знакомый: pipeline зелёный, deploy тоже завершился успешно, но на стенде оказались не те файлы. Иногда deploy заново запускает npm run build. Иногда он читает каталог из cache. Иногда в логе нет ответа на вопрос, из какого commit собран bundle. Цена ошибки — не только один неудачный релиз. Команда тратит время на сравнение каталогов, откладывает откат и рискует повторно отправить тот же неизвестный результат.

\n

Причина обычно не в числе job. Pipeline не назвал единственный результат выпуска. Проверка прошла над одним состоянием, а доставка взяла другое. Исправление начинается с контракта: конкретный commit и lockfile входят в pipeline; job verify проверяет код; job build один раз создаёт каталог; job deploy получает именно этот каталог и не пересобирает проект.

\n

Тезис: deploy должен доставлять объект, а не повторять процесс

\n

У pipeline есть четыре разных состояния. Checkout содержит исходники текущего запуска. Cache ускоряет работу и может исчезнуть без потери корректности. Артефакт — результат конкретного job, прикреплённый к запуску. Deploy создаёт побочный эффект: отправляет артефакт в среду. Эти состояния нельзя смешивать.

\n

Если deploy снова запускает сборку, он становится вторым build-job. У него могут отличаться образ, переменные, lockfile, время получения зависимостей и содержимое cache. Даже при том же SHA он способен получить другой результат. Тестировался один каталог, а отправился другой. Поэтому deploy должен читать результат build и завершаться ошибкой до сетевого вызова, если результат отсутствует или не проходит проверку.

\n

Учебная схема ниже рассчитана на GitLab CI/CD и Node.js. Она показывает границы и проверки, а не готовый production-файл. В ней нет credentials, реального сервера, измерений времени и утверждений о надёжности конкретной команды. Версии GitLab Runner, Node.js и shell нужно сверить с вашим окружением.

\n
\"Схема
Выпуск проходит по одной цепочке: commit и lockfile, проверки, один build-артефакт, затем остановка перед доставкой.
\n

Механизм: входы, доказательства и граница побочного эффекта

\n

Сначала назовите входы. Минимальный набор — revision исходников, lockfile, образ job и команды из package.json. Переменные окружения тоже могут менять результат. Не обязательно стабилизировать все параметры сразу, но их нельзя прятать за фразой «на CI работает иначе».

\n

npm ci полезен для ранней остановки. Он требует существующий lockfile и завершается ошибкой, если manifest и lockfile расходятся. Команда не чинит lockfile сама и не оставляет старый node_modules как доказательство корректности. Это не гарантия воспроизводимости всей сборки: внешний registry, native-модули и версия Node.js остаются отдельными входами.

\n

После verify job build создаёт dist/. Внутрь стоит положить файл REVISION со значением CI_COMMIT_SHA и manifest с контрольными суммами. Так deploy может ответить на два узких вопроса: какой commit породил каталог и не изменились ли его файлы после сборки. Checksum не проверяет бизнес-логику, настройки сервера или безопасность канала. Он только проверяет происхождение и целостность заявленного набора.

\n

Конкретный пример конфигурации

\n

Это учебный пример для простого приложения, которое публикует dist/. Имена job и команды нужно заменить на реальные команды проекта. Ключи и поведение следует проверить через CI Lint и документацию версии GitLab на вашей установке.

\n
image: node:20-alpine\n\nstages:\n  - verify\n  - build\n  - release\n\ncache:\n  key: \"$CI_COMMIT_REF_SLUG\"\n  paths:\n    - .npm/\n\nverify:\n  stage: verify\n  script:\n    - npm ci --cache .npm --prefer-offline\n    - npm run lint\n    - npm test\n\nbuild:\n  stage: build\n  script:\n    - npm ci --cache .npm --prefer-offline\n    - npm run build\n    - printf '%s\\n' \"$CI_COMMIT_SHA\" > dist/REVISION\n    - (cd dist && find . -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS)\n  artifacts:\n    paths:\n      - dist/\n    expire_in: 1 week\n\ndeploy_staging:\n  stage: release\n  when: manual\n  allow_failure: false\n  dependencies:\n    - build\n  script:\n    - set -eu\n    - test \"$(cat dist/REVISION)\" = \"$CI_COMMIT_SHA\"\n    - (cd dist && sha256sum -c SHA256SUMS)\n    - ./scripts/deploy-staging dist/
\n

cache здесь хранит только npm-кэш. Его отсутствие должно замедлить job, но не изменить контракт результата. artifacts прикрепляет каталог к job build. dependencies ограничивает вход deploy этим job. when: manual оставляет явную остановку перед побочным эффектом. Доступ к staging и секреты должны приходить из защищённых настроек CI, а не из YAML.

\n

В примере build повторяет npm ci в отдельном job. Это намеренно консервативный вариант: job не зависит от случайного рабочего каталога verify. Платформа может поддерживать другой способ передачи зависимостей, но оптимизация должна сохранять доказуемую границу. Сначала подтвердите цепочку, потом сокращайте повторную работу измерениями.

\n

Симптомы и точечная диагностика

\n
Что наблюдать до изменения pipeline
СимптомПричинаПроверкаДействие
Зелёный build, другой bundle на стендеdeploy пересобирает checkout или читает cacheНайти build-команды в deploy и вывести источник distПередать artifact через dependencies и убрать вторую сборку
Deploy не находит distartifact не создан, истёк или не скачанПроверить paths, срок хранения и список dependenciesОстановить deploy, исправить передачу artifact
REVISION не совпадает с CI_COMMIT_SHAСмешаны pipeline, ветка или каталогСравнить значение файла, переменную job и commit в интерфейсеНе отправлять файлы; запустить pipeline для нужного revision
npm ci падает до тестовManifest и lockfile расходятся или не совпали флаги npmЗапустить чистую установку тем же образом и прочитать первую ошибкуОбновить lockfile осознанно и закоммитить согласованную пару
Checksum не проходитФайл изменился после build или manifest не соответствует каталогуПроверить содержимое dist и команду создания SHA256SUMSЗавершить job до upload и расследовать источник изменения
\n

Таблица не заменяет логи. Она задаёт короткий маршрут: сначала определить, какой объект потерялся, затем проверить конкретную границу. Не добавляйте retry, новый cache или вторую сборку, пока не назван симптом. Повтор запуска скрывает нестабильность и не доказывает, что проверка и доставка использовали один результат.

\n

Порядок внедрения

\n
  1. Зафиксируйте текущую команду build, путь результата и commit, на котором выполняется проверка.
  2. Проверьте чистый runner: npm ci должен работать с сохранённым lockfile. Исправьте расхождение manifest и lockfile до настройки deploy.
  3. Добавьте verify с реальными lint и test-командами. Ненулевой exit code должен блокировать build.
  4. Соберите dist/ один раз и добавьте REVISION и SHA256SUMS. Прикрепите каталог как artifact.
  5. Настройте deploy только от build. До сетевого вызова сравните revision и контрольные суммы.
  6. Оставьте manual gate для учебного staging-прогона. Отдельно проверьте провалы test, отсутствие artifact и несовпадение revision.
\n

Ограничения и отрицательный путь

\n

Эта схема не решает rollback, миграции базы, стратегию production-раскатки, smoke-тесты после доставки и мониторинг. Artifact имеет срок хранения. Если он нужен для отката, его следует сохранять в подходящем registry или хранилище с правилами доступа и именованием. Нельзя считать недельный срок из примера политикой релизов.

\n

Checksum не защищает от скомпрометированного runner и не подтверждает, что сервер применил файлы. Manual job не заменяет review прав доступа. npm ci не фиксирует версию Node.js и состояние внешнего registry. Эти ограничения не делают минимальный pipeline бесполезным. Они показывают, какие вопросы он не закрывает.

\n

Отрицательный путь обязателен. Если lockfile расходится, verify должен остановиться. Если build не создал artifact, deploy не должен строить заново. Если revision или checksum не совпали, сетевой deploy не должен запускаться. Если manual gate не подтверждён, побочный эффект не происходит. Именно эти остановки делают ошибку наблюдаемой и ограничивают её цену.

\n

Критерий готовности

\n

Учебная реализация готова, когда один запуск на staging показывает цепочку «commit → verify → build → artifact → manual deploy», а журнал позволяет назвать revision и состав artifact. Отдельно должны быть подтверждены три отказа: провал verify блокирует build; отсутствие или несовпадение artifact блокирует deploy; deploy не вызывает build. Это проверяемое условие. Оно не выдаёт учебный прогон за production-результат и оставляет понятный следующий шаг для hardening.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/283.json b/editorial/agent-rewrites/283.json new file mode 100644 index 0000000..06cd215 --- /dev/null +++ b/editorial/agent-rewrites/283.json @@ -0,0 +1,7 @@ +{ + "index": 283, + "slug": "editorial-2020-02-field-configs-secrets", + "title": "Токен попал в лог: как провести ротацию и закрыть путь утечки", + "excerpt": "Удалить строку из кода недостаточно, если credential уже попал в лог, образ или историю Git. Разбираем учебный incident: ограничиваем распространение, меняем потребителей, отзываем старое значение и проверяем, что ошибка не вернулась.", + "contentHtml": "

Сервис отвечает ошибкой 500, а в централизованном логе рядом с request ID виден заголовок Authorization. Поиск по логам находит ту же строку ещё в нескольких записях. Это не просто неудачный формат диагностики. Пока credential действует, читатель лога может использовать его как доступ к внешней системе. Цена ошибки — отзыв ключа, переключение всех потребителей, разбор копий в CI и backup, а иногда и вынужденное окно простоя.

\n

Первый импульс обычно неверен: удалить поле из логгера, стереть найденную запись и закрыть задачу. Эти действия убирают симптом, но не меняют уже выданное значение. Секрет мог попасть в другой лог, error-reporting, Docker-образ, артефакт сборки или историю Git. Значит, incident закрывается не после commit, а после проверки границ распространения и отзыва старого credential.

\n

Тезис: конфигурация и credential живут в разных контурах

\n

Настройка описывает поведение приложения: имя окружения, URL зависимости, уровень логирования, таймаут. Credential даёт право действовать от имени приложения. У этих данных разные требования к хранению, доставке и журналированию. Шаблон конфигурации можно положить рядом с кодом. Значение токена должно приходить через защищённый канал запуска и не должно попадать в image, commit или диагностический объект.

\n

Ротация решает другой вопрос: какое значение сейчас может принимать провайдер. Redaction решает вопрос вывода: какие поля можно показать оператору. Cleanup решает вопрос доступных копий. Нельзя подменять одно другим. Маска не отзывает токен. Удаление файла не очищает backup. Новый commit не делает историческое значение недействительным.

\n

Как возникает утечка

\n

В приложении есть обычный путь запроса и путь ошибки. Обычный путь передаёт заголовки HTTP-клиенту. Путь ошибки добавляет request context в JSON для лога. Если сериализатор не знает, какие поля чувствительны, он копирует объект целиком. Так credential покидает границу процесса и начинает жить в системах, которые команда могла не учитывать.

\n

Механизм легко проверить на учебном значении. Функция должна принимать структуру заголовков, заменять чувствительные поля и сохранять безопасный request ID. В коде ниже нет реального доступа и нет настоящего токена. Строка DEMO_NOT_A_REAL_TOKEN ограничивает пример: она проверяет форму результата, но не доказывает безопасность production-логгера.

\n
function redactHeaders(headers) {\n  const result = {};\n\n  for (const [name, value] of Object.entries(headers)) {\n    const sensitive = /authorization|cookie|token|secret|password/i.test(name);\n    result[name] = sensitive ? '[REDACTED]' : value;\n  }\n\n  return result;\n}\n\nconst sample = {\n  authorization: 'Bearer DEMO_NOT_A_REAL_TOKEN',\n  'x-request-id': 'sample-2020-02'\n};\n\nconsole.log(redactHeaders(sample));\n// authorization: '[REDACTED]'\n// x-request-id: 'sample-2020-02'
\n

Отрицательная проверка важнее красивого positive case. Тест должен убедиться, что исходная строка отсутствует в сериализованном результате, а request ID остался. Тест не должен печатать вход до redaction: иначе сам тест создаёт новую копию утечки. В реальном приложении нужно проверить все error paths и все сериализаторы, а не только функцию из одного модуля.

\n

Симптом → причина → проверка → действие

\n
Учебная матрица первичной диагностики
СимптомПричинаПроверкаДействие
В логе виден AuthorizationОшибка сериализует headers без redactionВоспроизвести только на фиктивном значении и проверить весь error pathОстановить новый вывод, добавить маску и ограничить доступ к найденным записям
Строку удалили, но credential всё ещё принимаетсяCleanup перепутали с отзывомПолучить у провайдера статус старой пары через разрешённый каналВыпустить replacement, переключить consumers и отозвать старую пару
После deploy один worker получает 401Consumer не получил новую версию конфигурацииПроверить имя версии и redacted startup record у каждого ownerОстановить revoke для неизвестного consumer, доставить новую конфигурацию и повторить проверку
Токен найден в image или artifactСекрет вошёл в build context, ENV, ARG или файл результатаПроверить manifest и слои только в разрешённом контуре, не копируя значение в issueУдалить путь доставки, заменить credential и отдельно оценить retention образа или artifact
Команда говорит «утечки больше нет»Не определены носители и граница доказательстваСопоставить список известных носителей, consumers и время revokeОставить неизвестные копии открытым риском с владельцем и не объявлять incident закрытым
\n

Матрица нужна до изменения конфигурации. Она не требует собирать секрет в одном месте. В карточке incident достаточно имени переменной, типа credential, времени обнаружения, носителя, request ID и ссылки на закрытый канал владельца. Само значение, его полный hash и частичные фрагменты не стоит копировать в чат или issue: каждая новая копия получает отдельный срок хранения и круг читателей.

\n

Иллюстрация границы

\n
\"Схема
Ротация — последовательность зависимых действий. Старый credential отзывают после проверки новой поставки, а cleanup следов ведут отдельным контролируемым шагом.
\n

Владелец сервиса и владелец credential могут быть разными людьми. Дежурный разработчик видит запись, но не всегда имеет право менять ключ у провайдера. Worker может запускаться редко и не попасть в быстрый smoke test. Поэтому список consumers строят по имени переменной и контракту доставки: web-процесс, worker, cron, локальная инструкция, CI job и тестовый контур. Неизвестный consumer — это причина остановиться, а не повод предположить, что он неважен.

\n

Сначала закрываем новый поток, затем меняем значение

\n

Первое техническое изменение должно остановить появление новых копий. Уберите сериализацию заголовков из общего error path или направьте её через redaction. Ограничьте доступ к конкретному поисковому запросу и сохраните только безопасные поля: request ID, timestamp, версию сервиса и тип ошибки. Не удаляйте все логи вслепую. Они нужны для определения масштаба, но расследование должно проходить в разрешённом контуре.

\n

После этого проверьте путь доставки. Credential не должен находиться в Dockerfile, tracked .env, build artifact, публичном config endpoint или переменной, которую приложение возвращает в debug-ответе. Учебный шаблон может выглядеть так:

\n
# config.example.env — шаблон без действующих значений\nAPP_ENV=development\nPAYMENTS_API_URL=https://gateway.invalid\nPAYMENTS_TOKEN=DEMO_ONLY_NOT_A_SECRET\nLOG_LEVEL=info\n\n# В запуске PAYMENTS_TOKEN приходит отдельным защищённым каналом.
\n

Пример не задаёт способ хранения для конкретной платформы. В одном контуре это secret store, в другом — защищённая переменная job или механизм оркестратора. Важно наблюдаемое свойство: образ и репозиторий содержат имя настройки и безопасный placeholder, а runtime получает значение отдельно. Проверка должна смотреть не только исходный файл, но и итоговый image, artifact и логи сборки.

\n

Ротация с двумя активными парами

\n

Если провайдер поддерживает две активные пары, безопасный порядок выглядит так: создать новую пару, доставить её всем consumers, проверить каждый процесс, затем отозвать старую. Проверка не должна печатать token. Достаточно ID версии, успешного разрешённого запроса и redacted startup record. После revoke повторно проверьте старый путь: запрос с прежней парой должен быть отклонён провайдером. Это проверка состояния credential, а не доказательство отсутствия всех копий.

\n

Если провайдер не допускает overlap, сначала согласуйте окно переключения. Остановите consumers, замените значение, запустите узкую функциональную проверку и зафиксируйте длительность простоя. Не обещайте бесшовную ротацию там, где API провайдера допускает только одну активную пару. Если потребитель не может подтвердить новую конфигурацию, отложите revoke и передайте риск владельцу. Молчаливый отзыв создаст отказ, который сложнее отличить от исходного incident.

\n

Новая пара должна иметь собственный идентификатор и владельца. В записи не нужен secret value. Нужны version ID, список consumers, момент доставки, результат проверки и момент revoke. Так команда может доказать порядок действий, не создавая ещё один защищаемый документ с credential.

\n

Порядок действий

\n
  1. Создайте закрытую запись incident: имя credential, время, носитель, request ID, сервис и владельцы. Значение не копируйте.
  2. Остановите новый поток: исправьте serializer или логгер, ограничьте доступ к найденному логу и оставьте безопасные диагностические поля.
  3. Составьте список consumers по имени переменной и назначьте owner каждому процессу: web, worker, cron, CI и тестовый контур.
  4. Проверьте способ замены у провайдера. Выберите две активные пары или согласованное окно простоя.
  5. Создайте replacement через разрешённый канал. Не записывайте значение в commit, issue, image, artifact или общий чат.
  6. Доставьте новую конфигурацию каждому consumer и выполните его узкую функциональную проверку. Сохраните только ID версии и результат.
  7. Отзовите старый credential после проверки всех известных consumers. Если owner отсутствует, остановитесь и эскалируйте риск.
  8. Проверьте repository, image, CI log, error-reporting и logging path на следы старой схемы. Cleanup retention выполняйте отдельной согласованной процедурой.
  9. Добавьте отрицательный тест redaction и проверку, что диагностический результат не содержит исходного значения. Закройте incident только после проверки revoke и списка остаточных рисков.
\n

Ограничения и отрицательный путь

\n

Эта схема не выполняет ротацию реального сервиса, не открывает provider portal и не подтверждает состояние production. Код использует фиктивную строку. Иллюстрация показывает порядок, а не успешный результат конкретной команды. Документы провайдера могут задавать другой срок действия, лимит активных пар или порядок отзыва; эти условия нужно проверить до изменения.

\n

Схема также не обещает найти все копии. Она помогает назвать известные носители и неизвестность. Логи, backups, error-reporting и старые images могут иметь отдельные retention policy. Нельзя удалять их без владельца и согласованного способа восстановления. Нельзя считать зелёный тест доказательством, что все consumers обновились. Нельзя считать новый commit доказательством, что старый credential больше не действует.

\n

Если после исправления один worker получает 401, путь не продолжается автоматически. Остановите revoke для оставшихся consumers, проверьте версию конфигурации и owner, затем повторите проверку. Если провайдер не подтверждает revoke, incident остаётся открытым. Если найден новый носитель, расширьте карту распространения и отдельно оцените его доступ. Отрицательный путь должен быть таким же конкретным, как успешный.

\n

Проверяемый критерий готовности

\n

Инцидент можно считать технически закрытым, когда выполнены все четыре условия: старая пара отозвана провайдером; каждый известный consumer подтвердил новую версию безопасным результатом; error path не выдаёт чувствительные поля; список проверенных носителей и остаточных неизвестных записан с владельцами. Если хотя бы одно условие не выполнено, статус должен оставаться открытым или ограниченным, а следующая проверка должна иметь конкретного владельца.

\n

Такой критерий не говорит, что утечки не было и что все копии уничтожены. Он фиксирует только проверяемое состояние: старый доступ больше не принимается, новая поставка работает у известных потребителей, диагностический путь не повторяет ошибку, а неизвестность не скрыта за словом «готово».

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/284.json b/editorial/agent-rewrites/284.json new file mode 100644 index 0000000..9fd603e --- /dev/null +++ b/editorial/agent-rewrites/284.json @@ -0,0 +1,7 @@ +{ + "index": 284, + "slug": "editorial-2020-02-mechanism-configs-secrets", + "title": "Как конфигурация доходит до процесса и превращается в утечку", + "excerpt": "Сервис получает настройки через несколько границ: Git, сборку, образ, delivery и runtime. Разбираем, где значение должно остановиться, почему .gitignore не удаляет секрет из истории и как проверить путь без раскрытия credential.", + "contentHtml": "

Симптом виден после выпуска: сервис в development работает, а в production получает пустой URL, неверный режим или старый токен. Иногда приложение отвечает ошибкой, и обработчик добавляет в JSON весь объект конфигурации. В нём оказывается credential. Цена ошибки — простой, отзыв доступа, выпуск нового значения и поиск всех мест, куда попал старый секрет. Удалить одну строку из кода уже недостаточно.

\n

Проблема возникает раньше runtime. Значение проходит через репозиторий, build context, job CI, Docker image, переменные процесса и систему логирования. У каждой границы своя аудитория и свой срок жизни. Если считать их одним «env», команда не видит, где значение скопировалось и где его можно прочитать.

\n

Тезис: секрет должен попасть только в нужный процесс

\n

Код хранит имя настройки и правила проверки. Сборка создаёт один и тот же артефакт для разных контуров. Delivery передаёт значение выбранному процессу. Loader проверяет обязательные имена на старте. Логи показывают идентификатор конфигурации и маскируют значения. Такой маршрут не делает секрет невидимым для владельца процесса, но сокращает число носителей и облегчает проверку.

\n

Переменная окружения — канал доставки, а не хранилище с гарантией секретности. Её может прочитать wrapper, дочерний процесс, crash handler или диагностический код. Поэтому важно не только «не коммитить пароль», но и не копировать его в образ, bundle, аргументы команды и общий лог.

\n

Пять границ одного значения

\n
Что происходит с конфигурацией на каждом этапе
ГраницаЧто допустимоКто видитОпасная ошибка
Git и шаблонИмена, описание, фиктивные defaultsРазработчики и клоны репозиторияРеальный token в .env или примере конфигурации
Build context и CIИсходники и несекретные параметрыСборщик, job log, cacheprintenv, token в аргументе или echo
Docker imageКод и безопасные runtime defaultsRegistry и любой читатель образаCredential в ENV, ARG или generated bundle
RuntimeНужные процессу настройкиПроцесс и ограниченный контур запускаЛюбой модуль читает окружение и печатает его целиком
Логи и incidentИмена, request ID, revision, маскиПоддержка, мониторинг, участники incidentHeaders, env или config object в диагностике
\n

Одна и та же строка может пересечь все пять границ. Но ей не нужно этого делать. Например, APP_ENV может жить в образе как безопасный default. PAYMENTS_TOKEN должен появиться только при запуске и остаться доступным процессу, которому он нужен. Если token попал в Git или image, считать его «спрятанным» уже нельзя.

\n
\"Путь
Схема показывает границы пути. Репозиторий хранит имена, delivery подаёт значение отдельно, loader проверяет контракт, а лог получает безопасный отчёт.
\n

Учебный пример: код, образ и runtime

\n

Ниже учебный пример. Значение DEMO_ONLY_NOT_A_SECRET не даёт доступа к сервису. В настоящем контуре токен приходит по отдельному защищённому каналу.

\n
const required = [\"APP_ENV\", \"PAYMENTS_API_URL\", \"PAYMENTS_TOKEN\"];\n\nfunction readRequired(name, env) {\n  const value = env[name];\n  if (!value) throw new Error(\"Missing required setting: \" + name);\n  return value;\n}\n\nexport function loadConfig(env = process.env) {\n  for (const name of required) readRequired(name, env);\n  return {\n    appEnv: env.APP_ENV,\n    paymentsApiUrl: env.PAYMENTS_API_URL,\n    paymentsToken: env.PAYMENTS_TOKEN,\n    logLevel: env.LOG_LEVEL || \"info\",\n  };\n}\n\nexport function safeConfigReport(config) {\n  return {\n    appEnv: config.appEnv,\n    paymentsApiUrl: config.paymentsApiUrl,\n    paymentsToken: \"[REDACTED]\",\n    logLevel: config.logLevel,\n  };\n}
\n

Loader останавливает процесс до первого запроса, если обязательное имя отсутствует. Ошибка содержит имя поля, но не его значение. После загрузки модули получают готовый объект конфигурации и не обходят проверку через прямые чтения process.env. Это уменьшает число мест, где можно случайно сериализовать окружение.

\n
FROM node:12-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --only=production\nCOPY . .\nENV APP_ENV=production\nCMD [\"node\", \"server.js\"]\n\n# PAYMENTS_TOKEN не передаём через ARG или ENV Dockerfile.\n# Контур запуска подаёт его процессу отдельно от образа.
\n

Здесь APP_ENV — безопасный пример runtime default. В Dockerfile нет настоящего адреса платежей и нет token. Docker сохраняет значения ENV в окружении контейнеров, созданных из образа. ARG не следует использовать для credentials: значение может быть видно в истории сборки и связанных метаданных. Если секрет нужен именно во время сборки, применяют специальный механизм secret mount, но для обычного runtime-секрета сборка ему не нужна.

\n

Проверять loader можно без deployment. Передайте ему объект с APP_ENV=staging, адресом https://gateway.invalid и фиктивным token. Удалите обязательное поле и ожидайте ошибку с его именем. Отдельно вызовите safeConfigReport и проверьте маску. Это проверяет контракт кода. Оно не доказывает права доступа, ротацию и безопасность конкретного CI.

\n

Почему .gitignore создаёт ложное чувство защиты

\n

Правило .env.local помогает не добавить новый локальный файл. Но Git применяет ignore к намеренно неотслеживаемым путям. Если файл уже tracked, новое правило не удалит его из index и не очистит историю. При обнаружении credential в commit нужно считать его скомпрометированным: удалить строку мало, сначала отозвать старое значение и выпустить новое.

\n

Проверка должна разделять два вопроса. git check-ignore -v .env.local показывает, какое правило защищает локальный путь. git ls-files --error-unmatch .env.local не должен находить этот файл среди tracked. Эти команды не проверяют Docker context, CI cache, registry и логи. Для каждой поверхности нужен отдельный check.

\n

Симптом → причина → проверка → действие

\n
Точечная диагностика пути конфигурации
СимптомПричинаПроверкаДействие
Пустой обязательный ключDelivery не передал имя или loader использует другой приоритетПроверить имена и safe startup report без valuesИсправить источник и остановить запуск до первого запроса
Разные URL в средахСборка зафиксировала значение вместо runtime deliveryОсмотреть image, bundle и итоговый набор имёнВынести адрес в runtime-конфигурацию
Token виден в imageЗначение попало в ENV, ARG, слой или contextПроверить Dockerfile, history и содержимое context без печати tokenОтозвать token, пересобрать image без него
Token виден в CI logКоманда напечатала окружение или аргументПоискать имена полей и команды вывода в job definitionУдалить вывод, ограничить маскирование и ротировать credential
Секрет в JSON-ошибкеSerializer получил config или headers целикомНегативный тест на error path с фиктивным tokenСериализовать allowlist полей и вернуть [REDACTED]
Старый token всё ещё действуетИсправили носитель, но не отозвали credentialПроверить статус у владельца доступа и всех consumersВыпустить новую пару, переключить consumers, отозвать старую
\n

Порядок действий

\n
  1. Зафиксировать наблюдаемый симптом: пустое имя, неверный URL, credential в image или value в логе. Не менять одновременно код и job.
  2. Составить карту переменных: имя, класс значения, потребитель, источник, владелец смены и допустимый носитель.
  3. Проверить границу Git. В шаблоне оставить имена и фиктивные defaults. Если credential уже tracked, начать с отзыва и ротации.
  4. Проверить build boundary. Осмотреть Dockerfile, scripts, generated bundle и job log. Не использовать printenv как диагностику.
  5. Проверить image. Убедиться, что token не попал в ENV, ARG, слой или build context. Учесть, что удаление файла в следующем слое не отменяет предыдущую историю.
  6. Проверить delivery. Для каждого обязательного имени назвать источник и владельца. В release record сохранить только revision или идентификатор набора.
  7. Проверить runtime loader. Отсутствующее поле должно остановить запуск, а safe report — показать маску вместо значения.
  8. Проверить отрицательный путь: ошибка, retry, debug endpoint, HTTP logger и issue-шаблон не должны копировать env, headers или config object целиком.
  9. Проверить зависимый сценарий с тестовым credential или безопасным тестовым контуром. Не считать зелёный deploy доказательством отсутствия утечки.
\n

Ограничения

\n

Loader не создаёт секрет и не управляет правами. Маска в логе не защищает человека, у которого уже есть доступ к окружению процесса. Ignore-файл не очищает историю. Отдельный канал delivery не гарантирует безопасность, если job печатает его содержимое или выдаёт доступ лишним читателям.

\n

Описанный порядок не выбирает за проект конкретное secret-хранилище. Маленькая команда может использовать защищённый файл на host, CI secret или другой доступный механизм. Требование остаётся тем же: значение имеет владельца, приходит после выбора образа, не попадает в Git и diagnostics, а при утечке его можно быстро отозвать. Учебный код не является production-рецептом и не заменяет threat model, права доступа и процедуру ротации.

\n

Проверяемый критерий готовности

\n

Проверка завершена, если команда может показать без раскрытия значения: где хранится имя, откуда runtime получает token, какой код остановит запуск при пустом поле, какой отчёт маскирует credential, какие проверки исключают его из Git и image, и кто отзовёт старое значение при утечке. Дополнительно негативный сценарий должен подтвердить, что ошибка и лог не содержат token. Если на любой вопрос нет конкретного ответа, путь конфигурации ещё не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/285.json b/editorial/agent-rewrites/285.json new file mode 100644 index 0000000..06c1644 --- /dev/null +++ b/editorial/agent-rewrites/285.json @@ -0,0 +1,7 @@ +{ + "index": 285, + "slug": "editorial-2020-02-practice-configs-secrets", + "title": "Настройки и секреты: как провести конфигурацию до процесса без утечки", + "excerpt": "Сервис запускается локально, но в CI получает пустой URL, а токен может остаться в Git, Docker-образе или логе. Разбираем границы конфигурации, проверяем их короткими командами и задаём критерий готовности.", + "contentHtml": "

Сервис запускается на ноутбуке, но после выпуска получает пустой URL или неверный уровень логирования. Команда открывает CI-лог, печатает окружение и находит там токен. Иногда секрет уже попал в Docker-образ или старый commit. Цена ошибки складывается из простоя и ротации доступа. Нужно выпустить новую пару ключей, найти всех потребителей старой, проверить кэш и логи, а затем повторить релиз. Обычная настройка превращается в инцидент.

\n

Причина обычно не в синтаксисе .env. Проект смешивает два разных класса данных: настройка меняет поведение, а секрет даёт право на действие. Оба значения проходят через несколько поверхностей: репозиторий, сборку, image, канал доставки, процесс и журнал. У каждой поверхности своя аудитория и срок жизни. Без явной границы секрет перемещается туда, где его удобнее отладить.

\n

Тезис простой: храните в коде имена и безопасные примеры, передавайте чувствительные значения при запуске, валидируйте их на границе runtime и выводите только безопасный отчёт. Это не заменяет систему управления секретами и права доступа. Зато такой контракт делает утечку заметной до первого запроса.

\n

Разделите значение по назначению

\n

LOG_LEVEL=info — настройка поведения. Её имя и пример можно хранить рядом с кодом. PAYMENTS_API_URL — адрес зависимости. Он не обязан быть секретом, но внутренний адрес всё равно может раскрывать топологию. PAYMENTS_TOKEN — credential. Его имя нужно приложению, а значение не нужно репозиторию, образу или общему логу. CONFIG_REVISION — технический идентификатор набора. Он помогает сопоставить запуск и конфигурацию, но не заменяет секрет.

\n
Минимальный контракт конфигурации
КлассПримерДопустимый носительДиагностика
ПоведениеLOG_LEVEL=infoШаблон, документация, runtimeИмя и выбранное значение
АдресPAYMENTS_API_URLШаблон с фиктивным адресом и runtimeИмя; значение только при безопасной видимости
СекретPAYMENTS_TOKENОтдельный защищённый канал запускаИмя и [REDACTED]
ИдентификаторCONFIG_REVISION=sample-42Шаблон и запись выпускаИдентификатор набора без credential
\n

Таблица задаёт рабочую гипотезу, а не универсальную классификацию. Внутренний URL может быть чувствительным. Идентификатор может раскрывать детали релиза. Перед переносом значения спросите: кто его читает, сколько оно живёт, нужно ли ему попадать в этот носитель и кто отвечает за замену.

\n
\"Схема
Конфигурация проходит четыре границы. Секрет останавливается в runtime и не становится частью репозитория, образа или общего диагностического вывода.
\n

Как значение доходит до процесса

\n

Репозиторий должен содержать шаблон. В нём есть имена, назначение и фиктивные defaults. Настоящий токен в шаблон не подставляют. Build собирает код и зависимости. Он не должен запекать credential в bundle или Docker image. Delivery выбирает конкретную среду и передаёт runtime нужные значения своим защищённым способом. Loader получает строки, проверяет обязательные имена и создаёт объект, который код использует дальше.

\n

Переменная окружения — только канал передачи. Она не гарантирует секретность. Процесс может передать окружение дочерней команде, библиотека может записать его в ошибку, а shell-скрипт может напечатать команду целиком. Поэтому проверяйте не только источник, но и все места, куда значение копируется.

\n

Учебный пример: шаблон и loader

\n

Ниже приведён учебный пример. Значение токена фиктивное, домен .invalid не обозначает реальный сервис. Пример показывает границу контракта и не доказывает безопасность конкретного production-контура.

\n
# config.example.env — можно хранить в репозитории\nAPP_ENV=development\nPAYMENTS_API_URL=https://gateway.invalid\nPAYMENTS_TOKEN=DEMO_ONLY_NOT_A_SECRET\nLOG_LEVEL=info\nCONFIG_REVISION=sample-42
\n
const required = [\"APP_ENV\", \"PAYMENTS_API_URL\", \"PAYMENTS_TOKEN\"];\n\nexport function loadConfig(env = process.env) {\n  for (const name of required) {\n    if (!env[name]) {\n      throw new Error(`Missing required setting: ${name}`);\n    }\n  }\n\n  return {\n    appEnv: env.APP_ENV,\n    paymentsApiUrl: env.PAYMENTS_API_URL,\n    paymentsToken: env.PAYMENTS_TOKEN,\n    logLevel: env.LOG_LEVEL || \"info\",\n    revision: env.CONFIG_REVISION || \"unknown\",\n  };\n}\n\nexport function safeConfigReport(config) {\n  return {\n    appEnv: config.appEnv,\n    paymentsApiUrl: config.paymentsApiUrl,\n    paymentsToken: \"[REDACTED]\",\n    logLevel: config.logLevel,\n    revision: config.revision,\n  };\n}
\n

Loader останавливает запуск до первого внешнего запроса, если обязательное имя отсутствует. Ошибка сообщает имя настройки, но не значение. Отчёт сохраняет полезные поля и маскирует token. Маска не закрывает доступ к процессу. Она убирает один распространённый путь случайной публикации через лог или support-диагностику.

\n

Тест loader может передать обычный объект вместо process.env. Один сценарий удаляет PAYMENTS_TOKEN и ожидает ошибку. Другой проверяет URL и уровень логирования. Третий проверяет, что отчёт содержит [REDACTED]. Эти сценарии не обращаются к платёжной системе и не используют настоящий credential.

\n

Почему одного .gitignore недостаточно

\n

Добавьте локальный файл в .gitignore, чтобы новый .env.local не попал в индекс случайно. Но ignore-правило не удаляет уже отслеживаемый файл и не стирает значение из истории. Если секрет был закоммичен, cleanup файла не завершает работу. Сначала отзовите credential и выпустите новый. Затем проверьте историю, кэши, артефакты и журналы по правилам вашего контура.

\n
git check-ignore -v .env.local\ngit ls-files --error-unmatch .env.local
\n

Первая команда показывает правило для неотслеживаемого пути. Вторая должна завершиться ошибкой, если файл не tracked. Эти команды проверяют только Git. Они ничего не говорят о Docker context, CI cache и старых образах. Для каждой поверхности нужен отдельный check.

\n

Симптом → причина → проверка → действие

\n
Короткая диагностика конфигурации
СимптомПричинаПроверкаДействие
Локально работает, в среде пустой URLИмя или источник не совпадает между средамиСверить контракт loader и итоговые имена без вывода значенийЗафиксировать источник и добавить fail-fast на старте
Токен виден в commitФайл был tracked или значение попало в шаблонПроверить git ls-files и историюОтозвать токен, выпустить новый, затем удалить носители
Токен виден в imageCredential передали через ENV, ARG или build scriptПроверить Dockerfile, build args, слои и метаданные образаУбрать значение из build и передавать его при запуске
Ошибка содержит весь envLogger или serializer получил объект окружения целикомПройти error path и поискать dump в коде и логахПередавать safe report и тестировать редактирование полей
После смены ключа часть запросов падаетПотребители используют разные каналы или версииСопоставить revision, владельцев и сроки действияСоставить порядок ротации и проверить каждый потребитель
\n

Порядок проверки перед выпуском

\n
  1. Составьте инвентарь переменных. Для каждой запишите класс, потребителя, источник, срок жизни и владельца. Не переносите неизвестное значение «на всякий случай».
  2. Создайте versioned-шаблон. Оставьте имена, безопасные defaults и фиктивные адреса. Не вставляйте реальные значения даже в комментарии и учебные fixtures.
  3. Добавьте один loader на границе runtime. Он проверяет обязательные имена, типы и допустимые значения. Он не печатает credential при ошибке.
  4. Проверьте канал delivery. Зафиксируйте, кто выдаёт секрет процессу, кто меняет его и как команда получает уведомление о ротации.
  5. Проверьте build boundary. Уберите секреты из Dockerfile, build args, generated bundle, shell-команд и CI-комментариев. Проверьте, что лог не печатает окружение.
  6. Проверьте отрицательный путь. Удалите обязательное значение, прервите запуск и убедитесь, что процесс не сделал внешний запрос и не раскрыл значение.
  7. Запустите зависимый учебный или тестовый сценарий с фиктивным credential. Сопоставьте только результат, имя конфигурации и безопасный идентификатор revision.
\n

Ограничения и отрицательный путь

\n

Этот порядок не выбирает за команду vault, CI-секреты или файл на хосте. У разных контуров разные требования к доступу, аудиту, резервированию и ротации. Переменная окружения не становится безопасной только потому, что она не видна в исходниках. Пользователь процесса, администратор хоста и библиотека с правом чтения окружения всё ещё могут получить её.

\n

Если credential уже утёк, не ограничивайтесь добавлением .gitignore или маской в новом логе. Такой путь отрицателен: старый ключ продолжает действовать, а старые копии остаются доступными. Сначала отзовите и замените credential. Потом определите носители и сроки удаления. После этого добавьте проверку, которая не даст повторить тот же путь.

\n

Проверяемый критерий готовности

\n

Работа готова, если команда может назвать источник каждого обязательного значения, показать versioned-шаблон без реальных секретов, запустить loader с фиктивными данными и получить безопасный отчёт. В Git локальный файл не tracked. В Dockerfile и build output нет credential. При отсутствии обязательного значения процесс завершается до внешнего запроса. В логах остаются имя поля, результат проверки и revision, но не значение. Если хотя бы один пункт нельзя проверить повторяемой командой или тестом, конфигурация ещё не готова к выпуску.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/286.json b/editorial/agent-rewrites/286.json new file mode 100644 index 0000000..9f7ae36 --- /dev/null +++ b/editorial/agent-rewrites/286.json @@ -0,0 +1,7 @@ +{ + "index": 286, + "slug": "editorial-2020-01-field-docker-local", + "title": "Чистый запуск Docker: как найти старое состояние и не потерять данные", + "excerpt": "После изменения Dockerfile или миграции локальный стек продолжает показывать старый результат. Разбираем, где живёт состояние, как проверить каждый слой и когда чистый запуск действительно безопасен.", + "contentHtml": "

Симптом знакомый: вы меняете миграцию или Dockerfile, выполняете docker-compose up, а приложение продолжает видеть старую схему или старую версию файлов. На новом ноутбуке тот же репозиторий ведёт себя иначе. Цена ошибки — не только потерянный час. Команда может удалить нужные локальные данные, исправить не тот слой и закрепить в README опасную команду вроде глобального prune.

\n

Тезис простой: чистый запуск Docker — это контрольный эксперимент, а не уборка всего daemon. Сначала нужно доказать, где осталось старое состояние. Потом удалить или пересоздать только этот слой. Контейнер, образ, bind mount и named volume живут по разным правилам. Повторный up не делает их одинаково свежими.

\n

Где остаётся старое состояние

\n

Контейнер хранит собственный записываемый слой и параметры запуска. Если контейнер пересоздали, этот слой исчезает. Образ хранит слои, созданные при сборке. Изменение Dockerfile или lock-файла не меняет уже собранный образ само по себе. Bind mount показывает контейнеру выбранный путь с хоста. Он обычно даёт текущие файлы рабочей копии, но только по правильному пути. Named volume хранит данные независимо от жизненного цикла контейнера. Для PostgreSQL там остаются таблицы, роли и история миграций.

\n

Эти слои дают разные симптомы. Старый код после изменения Dockerfile указывает на старый образ или контейнер. Старые таблицы после пересоздания контейнера указывают на volume. Файл есть на хосте, но процесс читает старую копию, если mount направлен в другой каталог. Поэтому команда docker-compose down -v может устранить один симптом и одновременно уничтожить полезные данные, не доказав причину.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старая схема базы после нового контейнераNamed volume пережил контейнерСверить volume в docker-compose config и mounts через docker inspectСохранить данные или удалить только volume локального проекта
Новый Dockerfile не меняет версию приложенияИспользуется старый образ или контейнерПроверить время сборки, image ID и command в docker-compose ps и inspectПересобрать и пересоздать только нужный сервис
Контейнер не видит изменённый файлНеверный путь bind mount или рабочий каталогСопоставить путь на хосте, destination mount, working_dir и командуИсправить путь; volume базы здесь не поможет
После очистки ошибка осталасьПричина в переменной, адресе сервиса или кодеПовторить тот же запрос и сравнить безопасный лог конфигурацииВернуть сохранённые данные и перейти к следующей гипотезе
\n

Сначала зафиксируйте факты

\n

До разрушительной команды нужен снимок проекта. Назовите Compose-проект, сервис, который даёт симптом, и данные, которые нельзя потерять. Затем соберите итоговую конфигурацию. В ней видны подставленные переменные, сервисы, volumes, пути и команды. Это важнее исходного YAML: Compose мог получить значение из окружения или файла .env.

\n
docker-compose config --services\ndocker-compose config\ndocker-compose ps\ndocker-compose logs --tail=50 web db
\n

Сохраните вывод до изменения. Если проблема связана с базой, дополнительно проверьте имя volume и его привязку к контейнеру. Если проблема связана с кодом, проверьте image ID, дату сборки и mount. Не печатайте пароль базы в общий лог. Учебные значения в примере ниже нужны только для локального стенда и не задают правило хранения секретов.

\n

Минимальный пример Compose

\n

В этом учебном фрагменте исходники приходят через bind mount, а PostgreSQL пишет данные в named volume. Сервисы разделены: очистка базы не должна автоматически означать удаление исходников или образа.

\n
version: '3.7'\n\nservices:\n  web:\n    build: .\n    command: php -S 0.0.0.0:8080 -t public\n    ports:\n      - '8080:8080'\n    environment:\n      DATABASE_URL: postgres://app:app@db:5432/app\n    volumes:\n      - .:/srv/app\n\n  db:\n    image: postgres:12.1-alpine\n    environment:\n      POSTGRES_DB: app\n      POSTGRES_USER: app\n      POSTGRES_PASSWORD: app\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n\nvolumes:\n  postgres_data:
\n

Строка .:/srv/app не копирует исходники в образ. Она связывает текущий каталог хоста с путём внутри контейнера. Если процесс работает из /app, а mount направлен в /srv/app, приложение может читать другой набор файлов. Строка postgres_data:/var/lib/postgresql/data делает состояние базы постоянным между пересозданиями контейнера. Поэтому обычный up возвращает ту же схему, пока volume существует.

\n
\"Схема
Чистый запуск отделяет образ, контейнер, bind mount и named volume. Удаляется только подтверждённый источник старого состояния.
\n

Выберите действие по гипотезе

\n

Если изменился Dockerfile, lock-файл или команда запуска, начните с пересборки образа и пересоздания сервиса. Удалять volume базы для этого не нужно. Учебная проверка может выглядеть так:

\n
docker-compose build --no-cache web\ndocker-compose up -d --force-recreate web\ndocker-compose exec web php -v\ndocker-compose logs --tail=50 web
\n

Если изменились исходники под bind mount, сборка может вообще не быть причиной. Сверьте файл внутри контейнера с файлом на хосте. Если они различаются, проверьте путь, рабочий каталог и способ запуска. Не заменяйте bind mount удалением named volume: это два разных источника.

\n

Если проверка доказывает, что старые таблицы живут в postgres_data, перед удалением сохраните нужные данные. Для тестовой базы допустим отдельный разрушительный сценарий с известным Compose-проектом. Команда с -v должна быть ограничена этим проектом, а не заменяться docker volume prune. В рабочем проекте остановитесь, если не можете назвать volume и подтвердить, что его содержимое восстановимо.

\n

Маршрут чистого эксперимента

\n
  1. Сформулируйте симптом и слой, который должен измениться: образ, контейнер, bind mount или named volume.
  2. Зафиксируйте имя Compose-проекта, список сервисов, итоговый config, состояние контейнеров и короткие логи.
  3. Проверьте путь данных и сохраните нужный локальный дамп. Если безопасность данных не доказана, не удаляйте volume.
  4. Выполните одно ограниченное действие: пересоберите образ, пересоздайте сервис или удалите только подтверждённый volume тестовой базы.
  5. Запустите тот же стек и пройдите один контрольный маршрут: миграцию, запрос к известной таблице или URL с заранее описанным ответом.
  6. Сравните результат с исходным симптомом. Запишите, какой слой подтвердился или исключился, и только потом выбирайте следующую гипотезу.
\n

Отрицательный путь: очистка не помогла

\n

Представим, что volume удалён, база создана заново, а приложение всё ещё получает ошибку подключения. Из этого следует только одно: старый volume больше не объясняет симптом. Не нужно повторять очистку и расширять радиус удаления. Проверьте значение DATABASE_URL, имя сервиса db, готовность PostgreSQL и код миграции. Внутри контейнера localhost означает сам контейнер, а не соседний сервис. Для Compose-сети адресом базы служит имя сервиса.

\n

Если после пересборки и пересоздания сервис всё ещё запускает старую версию, проверьте, не перекрывает ли образ bind mount, не используется ли другой Compose-файл и не запущен ли контейнер вне текущего проекта. Успешный статус Up не доказывает готовность приложения. Нужен ответ контрольного маршрута и лог, который подтверждает именно проверяемое условие.

\n

Ограничения и критерий готовности

\n

Чистый локальный запуск не проверяет production-обновление базы, резервное копирование или совместимость версий Docker. Он не доказывает, что миграция безопасна для существующих данных. Синтаксис docker-compose сохранён в командах как исторический вариант для исходного контекста; в современных установках используется docker compose. Перед копированием примера проверьте версию CLI и формат проекта.

\n

Сценарий готов к использованию, если выполнены четыре условия: команда называет удаляемый слой; вывод до операции сохранён; данные либо не нужны, либо восстановимы; контрольный маршрут даёт заранее определённый результат после запуска. Если хотя бы одно условие не выполнено, это не чистый эксперимент, а рискованное удаление состояния.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/287.json b/editorial/agent-rewrites/287.json new file mode 100644 index 0000000..0152c64 --- /dev/null +++ b/editorial/agent-rewrites/287.json @@ -0,0 +1,7 @@ +{ + "index": 287, + "slug": "editorial-2020-01-mechanism-docker-local", + "title": "Почему локальный Docker ломается: адреса, файлы и состояние", + "excerpt": "Контейнер может быть запущен и всё равно не видеть базу, файл или переменную. Разбираем границы Compose и порядок проверки, который отделяет проблему хоста от проблемы контейнера.", + "contentHtml": "

Проблема обычно выглядит так: контейнер api имеет статус Up, но запрос к базе завершается ошибкой; приложение ищет файл и получает ENOENT; переменная есть в терминале, но внутри процесса её нет. Цена ошибки — потерянное время и неверное исправление. Команда меняет Dockerfile, удаляет том или добавляет повторные попытки, хотя причина лежит в адресе, пути монтирования или способе передачи конфигурации.

\n

Главный тезис прост: Docker изолирует процесс и описывает его окружение, но не делает все пространства одинаковыми. Хост, Docker daemon, файловая система контейнера, сеть Compose и окружение процесса имеют разные правила. Нужно назвать наблюдателя для каждого факта. Тогда localhost, путь файла и значение переменной перестают быть двусмысленными.

\n

Сначала восстановите маршрут запроса

\n

Возьмём учебный стек из двух сервисов. Браузер на хосте обращается к опубликованному порту api. Процесс api ищет базу по имени db во внутренней сети Compose. PostgreSQL хранит данные в named volume. Запрос проходит через три адреса и два типа хранилища. Статус контейнера сообщает только о запуске процесса. Он не доказывает готовность базы, правильность переменной или наличие файла по нужному пути.

\n

У слова localhost нет одного смысла. На хосте оно означает хост, где работает браузер или скрипт. В контейнере api оно означает сам контейнер api. Оно не означает контейнер db. В стандартной сети Compose сервисы находят друг друга по именам сервисов, поэтому клиент внутри api подключается к db:5432. Опубликованный порт нужен клиенту с хоста, а не соседнему контейнеру.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
ECONNREFUSED localhost:5432 внутри apiКлиент обращается к себеПоказать host внутри apiИспользовать db в общей сети
ENOTFOUND dbПроцесс вне сети Composedocker compose ps и docker inspectЗапустить сервисы одним проектом
ENOENT /srv/api/server.jsПуть mount не совпал с командойСверить pwd, mount и working_dirСогласовать пути хоста и контейнера
Переменная пустаяПодстановка не стала env процессаdocker compose config и проверка внутриЯвно задать environment или env_file
Остались старые данныеNamed volume пережил контейнерdocker volume lsОписать безопасный чистый старт
\n

Минимальная конфигурация

\n

Ниже учебный пример. Он показывает связи, а не готовую production-среду. API получает рабочий каталог, код с хоста и адрес базы. PostgreSQL не публикует порт наружу: его клиент находится в той же сети. Пароль dev-only демонстрационный и не должен переходить в реальную среду.

\n
services:\n  api:\n    build: .\n    working_dir: /srv/api\n    command: node server.js\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - .:/srv/api\n    environment:\n      NODE_ENV: development\n      DATABASE_HOST: db\n      DATABASE_PORT: \"5432\"\n    depends_on:\n      - db\n\n  db:\n    image: postgres:16-alpine\n    environment:\n      POSTGRES_DB: app\n      POSTGRES_USER: app\n      POSTGRES_PASSWORD: dev-only\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n\nvolumes:\n  postgres_data:
\n

Имя db работает только внутри сети, к которой подключён api. Compose создаёт сеть проекта и регистрирует сервисы во внутреннем DNS. IP контейнера не является контрактом: после пересоздания он может измениться. Поэтому адрес базы задают именем сервиса, а не результатом разового docker inspect. Если API запустили отдельно через docker run, связь с сетью Compose могла исчезнуть.

\n

depends_on выражает порядок запуска, но не готовность PostgreSQL принимать соединения. База может ещё выполнять инициализацию. Учебный пример должен проверять реальное подключение после старта. Статус Up не равен готовности.

\n
\"Схема
Иллюстрация разделяет хостовый порт, процесс API, сервисное имя базы и постоянное хранилище. Один localhost не заменяет эти границы.
\n

Почему переменная есть, но приложение её не видит

\n

Compose сначала собирает итоговую конфигурацию. Для подстановки он может взять значение из оболочки или файла .env. Только явная передача в environment или env_file делает значение окружением процесса. Поэтому проверка должна иметь два наблюдения: итоговый YAML и фактическое окружение внутри api.

\n
docker compose config\ndocker compose up -d\ndocker compose ps\ndocker compose logs --tail=100 api\ndocker compose exec api sh -lc 'printf "DATABASE_HOST=%s\\n" "$DATABASE_HOST"'\ndocker compose exec api getent hosts db
\n

Первая команда показывает, что Compose подставил в конфигурацию. Последняя проверяет DNS из пространства контейнера. Между ними остаётся вопрос: использует ли приложение эту переменную. Для этого смотрят безопасный диагностический лог или выполняют известный endpoint. Не печатайте весь env, если рядом есть секреты. Наличие значения и право показать значение — разные решения.

\n

Файл на хосте не равен файлу в контейнере

\n

Bind mount связывает конкретный путь хоста с конкретным путём внутри контейнера. Если проект смонтирован в /app, а команда запуска ищет /srv/api/server.js, процесс не обязан увидеть код. Монтирование поверх непустой директории также скрывает содержимое образа под mount.

\n

Проверьте вместе working_dir, путь в command и target mount. Затем зайдите в контейнер: выполните pwd, ls -la и проверку нужного файла. Проверка рабочей копии на хосте отвечает только на вопрос о хосте. Она не доказывает, что контейнер получил тот же файл.

\n

Named volume решает другую задачу. Он хранит данные вне жизненного цикла контейнера. Поэтому пересоздание db не обязано создавать пустую базу. Это удобно для локальной работы и опасно для эксперимента, которому нужно чистое состояние. Удаление volume разрушительно: сначала зафиксируйте имя и подтвердите, что данные можно потерять.

\n

Порядок проверки

\n
  1. Опишите один сбой: клиент, адрес, путь, команда и точный текст ошибки. Не начинайте с «Docker не работает».
  2. Определите наблюдателя: хостовый shell, браузер, процесс api или база.
  3. Выполните docker compose config. Сверьте имена сервисов, переменные, рабочий каталог и mount.
  4. Проверьте docker compose ps и логи. Отделите живой процесс от готовой зависимости.
  5. Изнутри api проверьте имя db, порт и безопасное значение переменной. Не заменяйте проверку хостовым localhost.
  6. Сверьте путь файла внутри контейнера с working_dir, командой запуска и target bind mount.
  7. Проверьте named volume. Для пустого старта используйте отдельную подтверждённую процедуру.
  8. Измените одну границу и повторите тот же запрос. Запишите, какой факт изменился.
\n

Ограничения механизма

\n

Compose не исправляет несовместимые версии, права доступа, ошибки миграций и неверные credentials. Сервисное имя не делает сеть доступной процессу, который запустили вне проекта. Bind mount не синхронизирует произвольные пути. Named volume не является резервной копией. Эти условия проверяют отдельно.

\n

Пример не запускался в конкретном проекте читателя и не доказывает production-надежность. Он ограничен локальной схемой из API, PostgreSQL, внутренней сети и двух видов хранилища. Реальная готовность требует своих образов, миграций, прав, версии Compose и проверки отказа зависимости. Учебный пароль и команду запуска нельзя переносить без адаптации.

\n

Проверяемый критерий готовности

\n

Локальная среда готова, когда один сценарий на чистом checkout даёт четыре результата: Compose показывает ожидаемую конфигурацию; api разрешает db; приложение устанавливает соединение; нужный файл читается внутри контейнера. Отдельно зафиксируйте сохранение данных после пересоздания и разрешённый чистый старт. Если результат заменён фразой «контейнер запущен», проверка не закончена.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/288.json b/editorial/agent-rewrites/288.json new file mode 100644 index 0000000..1c12b6b --- /dev/null +++ b/editorial/agent-rewrites/288.json @@ -0,0 +1,7 @@ +{ + "index": 288, + "slug": "editorial-2020-01-practice-docker-local", + "title": "Локальная разработка в Docker: как разделить код, данные и сеть", + "excerpt": "Контейнеры не делают окружение одинаковым сами по себе. Разбираем bind mount, named volume, сервисные имена и порядок проверки локального Compose-сценария.", + "contentHtml": "

Проблема: приложение запускается у одного разработчика, но на другом не видит базу или читает старые файлы; ошибка съедает часы диагностики и маскирует реальные изменения в коде. Docker помогает только тогда, когда команда фиксирует границы среды: образ, контейнер, mount, volume, сеть, порт и переменные. Если смешать эти слои, контейнер лишь перенесёт прежнюю неопределённость в новый YAML.

\n

Тезис простой: локальная среда должна описывать не только команду запуска, но и владельца каждого состояния. Исходники обычно остаются на хосте и приходят в контейнер через bind mount. Зависимости и данные базы живут в named volume. Сервисы обращаются друг к другу по именам Compose, а браузер обращается к опубликованному порту хоста. Такой контракт можно проверить короткими командами и одним заранее выбранным запросом.

\n

Механизм: что именно изолирует Docker

\n

Образ содержит файловую систему и установленные пакеты на момент сборки. Контейнер запускает процесс из образа, добавляя переменные, сеть и mounts. Bind mount связывает каталог хоста с путём внутри контейнера. Он удобен для редактирования кода, но перекрывает содержимое целевого пути. Поэтому зависимости, записанные в образе в /srv/app/node_modules, могут исчезнуть из видимости после mount .:/srv/app.

\n

Named volume принадлежит Docker и не зависит от каталога рабочей копии. Он подходит для данных PostgreSQL и для зависимостей, которые должны собираться в Linux-контейнере, а не в node_modules хоста. Volume не решает миграции и не обновляется от смены lock-файла сам по себе. После изменения зависимостей нужен явный шаг установки.

\n

Compose создаёт сеть проекта. Внутри неё сервис доступен по имени, например db. Адрес localhost внутри контейнера означает этот же контейнер, а не ноутбук и не соседний сервис. Запись ports: 3000:3000 связывает порт хоста с портом контейнера для входа с хоста. Она не превращает localhost в адрес базы для приложения.

\n
\"Схема
Один локальный контракт разделяет код, производные файлы, данные и сетевой путь.
\n

Конкретный пример Compose

\n

Ниже учебная конфигурация для приложения web и PostgreSQL. Она показывает границы локального запуска. Она не описывает production: пароль приведён только для изолированного учебного примера, а версия образа служит фиксированной точкой эксперимента. В рабочем проекте значения берут из согласованного файла окружения и секретного хранилища.

\n
services:\n  web:\n    build: .\n    working_dir: /srv/app\n    command: npm run dev -- --host 0.0.0.0\n    environment:\n      DATABASE_URL: postgres://app:app@db:5432/app\n    ports:\n      - \"3000:3000\"\n    volumes:\n      - .:/srv/app\n      - app_node_modules:/srv/app/node_modules\n    depends_on:\n      - db\n\n  db:\n    image: postgres:16-alpine\n    environment:\n      POSTGRES_DB: app\n      POSTGRES_USER: app\n      POSTGRES_PASSWORD: app\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n\nvolumes:\n  app_node_modules:\n  postgres_data:
\n

В этой схеме браузер открывает http://localhost:3000. Процесс web ищет PostgreSQL по имени db. База не публикует порт наружу, потому что для этого маршрута она нужна только приложению. Если администратору нужен доступ с хоста, публикацию добавляют отдельным решением и проверяют её риск. Наличие depends_on задаёт порядок запуска контейнеров, но не доказывает готовность базы принимать соединения.

\n

Симптомы и точечная проверка

\n
СимптомПричинаПроверкаДействие
Код изменился на хосте, но web отдаёт старый ответРабочая копия не смонтирована в фактический каталог процесса или mount перекрытСверить working_dir, путь запуска и volumes в выводе docker compose configСмонтировать код в каталог, из которого читает процесс; перезапустить только web
web не подключается к базе по localhostВнутри web localhost указывает на webПроверить строку подключения и выполнить DNS-проверку имени db из контейнераИспользовать сервисное имя db и внутренний порт 5432
После смены ветки модули падают с ошибкой платформыВ контейнер подставлен host node_modulesПосмотреть mount для /srv/app/node_modules и платформу бинарного модуляХранить зависимости в named volume и переустановить их внутри контейнера
База показывает старую схемуДанные остались в named volumeСопоставить имя volume, путь PostgreSQL и применённые миграцииПрименить миграцию; очищать volume только после проверки данных и цели
Порт занят или браузер не получает ответПорт хоста занят либо процесс слушает только loopback внутри контейнераПроверить docker compose ps, логи web и привязку dev-сервера к 0.0.0.0Освободить или изменить порт хоста; исправить bind адрес процесса
\n

Таблица задаёт порядок расследования. Сначала подтверждаем слой, который способен объяснить симптом. Пересборка образа не исправляет неправильный сервисный адрес. Удаление volume не исправляет bind mount. Полная переустановка может скрыть причину и уничтожить полезное локальное состояние.

\n

Порядок действий

\n
  1. Зафиксировать версии Docker и Compose, имя проекта и критерий готовности. Для учебного примера критерий такой: web отвечает на один URL, а запрос из web к db:5432 проходит после применения миграций.
  2. Выполнить docker compose config и проверить итоговые переменные, build-контекст, рабочие каталоги, mounts, имена сервисов и опубликованные порты. Смотрим раскрытую конфигурацию, а не только исходный YAML.
  3. Запустить docker compose up -d. Затем выполнить docker compose ps и снять короткий фрагмент docker compose logs --tail=50 web db. Статус Up означает жизнь процесса, но не готовность приложения.
  4. Проверить маршрут с хоста: открыть http://localhost:3000 или выполнить curl. Если он не работает, исследовать публикацию порта и bind адрес web.
  5. Проверить маршрут из контейнера: выполнить диагностическую команду через docker compose exec web и обратиться к db, а не к localhost. Так проверяется внутренняя сеть, а не браузерный путь.
  6. При ошибке изменить один слой за раз. После каждого изменения повторить тот же запрос и сохранить наблюдение. Иначе нельзя связать исправление с причиной.
  7. Отдельно проверить чистый запуск на учебных данных. Если нужен docker compose down -v, сначала вывести список проекта и убедиться, что volume не содержит нужных данных.
\n

Отрицательный путь и ограничения

\n

Если база не готова к моменту запуска web, depends_on не обязан ждать успешного подключения. Нужна проверка готовности, повтор соединения или миграционная команда проекта. Нельзя объявлять сервис готовым только потому, что контейнер имеет статус Up.

\n

Bind mount зависит от файловой системы хоста и настроек Docker daemon. Скорость, права, уведомления об изменениях и поведение симлинков могут различаться на Linux, macOS и Windows. Named volume изолирует зависимости, но не устраняет различия архитектуры CPU и версий runtime. Образ, lock-файл и версия Compose должны оставаться частью проверяемого контракта.

\n

Удаление volume — разрушительное действие для локальных данных. Учебный PostgreSQL можно создать заново, рабочую базу нужно сначала экспортировать или сохранить другим согласованным способом. Не используйте очистку как универсальный рецепт. Она допустима только когда подтверждено, что старое состояние и есть проверяемая причина.

\n

Проверяемый критерий готовности

\n

Сценарий готов, если на выбранной машине выполняются все условия: docker compose config показывает ожидаемые значения; docker compose ps показывает нужные сервисы; web отвечает по одному URL; из web разрешается имя db и проходит тестовое соединение; код, изменённый на хосте, виден процессу; состояние базы переживает обычный перезапуск; разрушительный clean-start не входит в обычный путь. Этот критерий не утверждает переносимость на любую систему и не заменяет production-проверки. Он доказывает только тот локальный контракт, который описан в статье.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/289.json b/editorial/agent-rewrites/289.json new file mode 100644 index 0000000..aeec38a --- /dev/null +++ b/editorial/agent-rewrites/289.json @@ -0,0 +1,7 @@ +{ + "index": 289, + "slug": "editorial-2019-12-field-code-ownership", + "title": "Когда у ошибки четыре владельца: как разбирать код на стыке модулей", + "excerpt": "Неизвестный статус проходит через gateway и checkout, а команда ищет автора последней строки. Разделяем историю кода, владельца решения, маршрут review и проверку после merge.", + "contentHtml": "

Checkout получает от gateway значение unknown, но показывает пользователю успешную оплату. В логах есть ответ интеграции, в коде есть ветка по умолчанию, а в задаче первым делом ищут автора строки через git blame. Ошибка превращается в спор: человек из checkout менял parser, команда gateway владеет API, а после merge никто не обязан проверить результат.

\n

Цена такого смешения — не только задержка. Неизвестное состояние может попасть в успешный сценарий, повторить неверное действие и породить второй дефект после исправления. Команда тратит время на поиск виноватого, но не фиксирует, кто принимает решение о контракте и кто проверяет его на рабочем пути.

\n

Тезис статьи простой: у одного дефекта могут быть разные владельцы. Исторический автор помогает восстановить контекст. Владелец пути кода знает, где менять поведение. Владелец решения определяет смысл статуса. Reviewer проверяет конкретный риск, а отдельный исполнитель подтверждает результат после merge. Эти роли могут совпасть, но их нельзя считать совпавшими без проверки.

\n

Что именно показывает каждый след

\n

git blame отвечает на исторический вопрос: какая revision последней изменила строку и кто её изменил. Это полезная точка входа. Автор мог сделать перенос, форматирование или механический рефакторинг. Из одной строки нельзя вывести, кто сегодня отвечает за смысл статуса.

\n

git log --follow добавляет контекст: какие изменения проходили через файл и где лежала прежняя версия. История помогает найти обсуждение, тест или документ контракта. Она не назначает текущего владельца. После переноса кода автор строки и эксперт по интеграции часто расходятся.

\n

В GitHub файл CODEOWNERS задаёт маршрут для запроса review по совпавшему пути. Для pull request используется версия файла из base branch. Последнее подходящее правило имеет приоритет. Это автоматизирует приглашение reviewer, но не доказывает, что человек согласовал семантику API или проверил поведение после merge.

\n
Четыре следа одного дефекта
След или рольНа какой вопрос отвечаетЧего не доказываетСледующее действие
git blameКто последним изменил строку?Кто владеет текущим правилом?Прочитать diff и связанные изменения
Путь кодаГде находится поведение и соседние переходы?Что новое значение означает для продукта?Найти тест, контракт и владельца модуля
CODEOWNERSКого платформа запросит на review?Что reviewer действительно подтвердил?Проверить base branch, правило и доступ команды
Review и follow-upЧто проверили до merge и кто проверит результат?Что ошибка исчезла сама по себе?Записать решение и наблюдаемый критерий
\n

Таблица задаёт границы ответственности. Она не требует создавать четыре должности. Один разработчик может закрыть все роли в маленьком модуле. На стыке gateway и checkout роли расходятся чаще, поэтому их нужно назвать в задаче или описании pull request.

\n
\"Учебная
Учебная схема: исторический автор, владелец решения, reviewer и исполнитель проверки отвечают за разные вопросы.
\n

Учебный сценарий на стыке gateway и checkout

\n

Ниже приведён искусственный пример. Он показывает маршрут расследования и не описывает реальный инцидент, пользователей, команду или production-результат. Предположим, gateway возвращает JSON с полем status, а checkout переводит его в состояние экрана.

\n
const viewByStatus = {\n  paid: 'success',\n  pending: 'waiting',\n  failed: 'error',\n};\n\nexport function getView(status) {\n  return viewByStatus[status] || 'success';\n}
\n

Ошибка находится в значении по умолчанию. Если gateway добавит review, checkout покажет успех. Даже если автор этой строки давно ушёл из команды, вопрос остаётся техническим: допустимо ли считать неизвестный статус успешным? Владелец контракта должен ответить «нет» или обосновать другое правило.

\n

Безопаснее сделать неизвестное значение отдельным состоянием и сохранить его для диагностики:

\n
const viewByStatus = {\n  paid: 'success',\n  pending: 'waiting',\n  failed: 'error',\n};\n\nexport function getView(status) {\n  return viewByStatus[status] || 'unknown';\n}\n\nexport function canConfirmPayment(status) {\n  return status === 'paid';\n}
\n

В этом учебном коде unknown не разрешает подтверждение оплаты. Названия состояний, способ отображения и политика повторного запроса зависят от конкретного продукта. Нельзя переносить их в рабочую систему без проверки контракта. Но граница решения ясна: только явное значение paid открывает успешное действие.

\n

Проверка должна покрыть и отрицательный путь:

\n
test('does not treat an unknown status as paid', () => {\n  expect(getView('review')).toBe('unknown');\n  expect(canConfirmPayment('review')).toBe(false);\n});
\n

Этот тест не доказывает, что gateway всегда присылает корректные данные. Он доказывает только выбранный локальный контракт: неизвестное значение не становится успешным. Отдельно нужно решить, где логировать исходный статус, как показать пользователю безопасное состояние и кто проверит экран после merge.

\n

Разбор симптома

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
В задаче назначен автор строкиИсторию приняли за владение решениемСравнить git blame с контрактом и текущей командой модуляНазвать отдельно owner решения и owner пути
Reviewer не получил запросПуть не совпал или CODEOWNERS изменён только в feature branchПроверить файл в base branch, порядок правил и доступ командыИсправить маршрут или запросить review вручную
Review зелёный, но статус всё ещё неверенReviewer проверил форму diff, а не смысл контрактаНайти в review конкретный вопрос о unknown и тест отрицательного случаяДобавить проверяемое решение и повторить review
После merge нет ответа о результатеFollow-up не назначили до измененияНайти сигнал, срок и исполнителя проверкиСоздать отдельное действие; не закрывать технический след одним merge
Неизвестный статус открывает оплатуВетка по умолчанию разрешает successПодменить вход в тесте на новое значениеСделать allowlist для успешного состояния и заблокировать отрицательный путь
\n

Проверка должна отделять факт от гипотезы. Если CODEOWNERS не отправил запрос, сначала проверяют расположение файла и правила. Если тест не проходит, сначала фиксируют вход и ожидаемый результат. Имя последнего автора не закрывает ни один из этих вопросов.

\n

Как оформить минимальный контракт

\n

Для учебного примера достаточно записать четыре поля в задаче:

\n
symptom: checkout shows success for an unknown gateway status\ndecision owner: gateway-contract team\ncode path owner: checkout team\nreview questions:\n  - may an unknown status enable confirmation?\n  - does the fallback preserve a safe user state?\nfollow-up owner: checkout on staging
\n

Имена здесь условные. В настоящем проекте нужно указать реальные команды, путь, тест и канал проверки. Поле decision owner означает не «кто будет чинить», а «кто может подтвердить смысл допустимых состояний». Поле follow-up owner означает не гарантию успеха, а конкретное действие после merge.

\n

Для такого дерева путей учебная конфигурация может выглядеть так:

\n
*                          @example/platform\n/web/checkout/             @example/checkout\n/web/checkout/gateway/     @example/payments\n/docs/checkout-contract.md @example/payments @example/checkout\n/.github/CODEOWNERS        @example/repository-admins
\n

Псевдонимы вымышлены. Смысл примера — показать порядок: узкий путь gateway расположен после общего пути checkout. На GitHub несколько owners должны находиться в одной строке. Правило не заменяет проверку доступа и не переносит владельца решения из документа в платформу автоматически.

\n

Порядок действий

\n
  1. Записать точный симптом: вход gateway, значение статуса, экран и неверное действие. Не начинать с имени сотрудника.
  2. Ограничить расследование нужным путем и строками. Выполнить git blame -L, затем прочитать diff и историю связанных файлов через git log.
  3. Найти контракт, тест или документ, где определены допустимые статусы. Если правило не сформулировано, сначала назначить владельца решения.
  4. Назвать отдельно владельца решения, владельца пути, reviewer и follow-up. Разрешить одному человеку закрыть несколько ролей, но записать это явно.
  5. Проверить CODEOWNERS в base branch. Убедиться, что совпадает нужный путь, последнее правило имеет ожидаемый приоритет, а команда имеет требуемый доступ.
  6. Добавить тест на прошлый сбой и отрицательный тест для неизвестного значения. Успешный путь должен проходить только при явном допустимом статусе.
  7. В описании change задать reviewer конкретные вопросы о контракте и побочных переходах. «Approve» без предмета не подтверждает выбранную семантику.
  8. До merge назначить проверку после merge: контур, сигнал, срок и исполнитель. Если проверка недоступна, открыть связанную задачу и сохранить это ограничение.
  9. Закрыть дефект только после записи результата. При отрицательном результате создать новую задачу с тем же входом и причиной, а не переписывать историю.
\n

Ограничения

\n

CODEOWNERS зависит от хостинга. Синтаксис и поведение GitHub нельзя без проверки переносить в GitLab, Bitbucket или внутреннюю платформу. Если автоматического маршрута нет, карта ролей в задаче всё равно работает, но людей нужно запросить вручную.

\n

История Git может потерять смысл после массового форматирования, squash, переноса или копирования кода. Опции -M и -C помогают искать перемещённые строки, но не восстанавливают решение, которого никогда не записали. Контракт и тест остаются отдельными источниками фактов.

\n

Review не является эксплуатационной проверкой. Approval подтверждает состояние предложенного diff по правилам платформы и команды. Он не показывает, что gateway прислал нужное значение в рабочем контуре и что экран обработал его без побочного эффекта. Это нужно проверять отдельно.

\n

Учебный тест на unknown не доказывает корректность платежного процесса, безопасность логов или полноту всех статусов. Нельзя объявлять production-исправление по результату локального примера. Для реального выпуска понадобятся согласованный контракт, интеграционная проверка, доступный сигнал и план отката.

\n

Критерий готовности

\n

Разбор готов, если команда может показать четыре вещи: источник исторического факта, владельца решения с формулировкой контракта, маршрут reviewer для изменённых путей и отдельную запись о проверке после merge. Тест должен падать, если неизвестный статус открывает success. При несовпадении пути или отсутствии владельца pipeline review должен остановить продвижение либо явно показать ручной шаг.

\n

Этот критерий не обещает production-результат. Он проверяет происхождение решения и отрицательный путь: команда знает, кто отвечает на каждый вопрос, а неизвестное значение не проходит в успешное действие молча.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/290.json b/editorial/agent-rewrites/290.json new file mode 100644 index 0000000..260f02a --- /dev/null +++ b/editorial/agent-rewrites/290.json @@ -0,0 +1,7 @@ +{ + "index": 290, + "slug": "editorial-2019-12-mechanism-code-ownership", + "title": "Кому принадлежит изменение: Git, CODEOWNERS и границы ответственности", + "excerpt": "Последний автор строки не всегда знает смысл контракта, а CODEOWNERS не заменяет review. Разбираем, как разделить историю, маршрут проверки, решение и контроль после merge.", + "contentHtml": "

Ошибка на стыке модулей часто начинается с простой задержки. В задаче уже указан файл, в git blame видно имя, но исправление ждёт ответа. Разработчик считает владельцем последнего автора строки. Автор помнит рефакторинг и не знает, кто принимает решение по контракту. Pull request получает формальный approval, а после merge никто не проверяет спорный ответ. Цена ошибки — не только потерянные часы. Неизвестный статус может попасть в ветку успеха, изменение контракта — пройти без нужного специалиста, а тот же дефект вернуться в соседнем файле.

\n

Тезис простой: «владелец кода» — не одна роль. История Git отвечает на вопрос «кто менял». CODEOWNERS отвечает на вопрос «кого запросить по пути». Review отвечает на вопрос «какое решение принято для diff». Отдельное назначение нужно для проверки после merge. Если смешать эти ответы, команда получает имя вместо ответственности. Если разделить их, каждый риск получает проверяемый маршрут.

\n

Симптом начинается не с имени

\n

Формулировка «у модуля нет владельца» слишком общая. Начните с наблюдаемого поведения: gateway вернул unknown, checkout показал успешную оплату; retry записал старый результат поверх нового; изменение файла прошло без человека, который знает допустимые статусы. В такой записи есть вход, неверный результат и цена. Она помогает найти границу решения, а не назначить виноватого.

\n

Пример ниже искусственный. Он не описывает production-инцидент, настоящих людей или измеренный эффект. В нём обработчик получает ответ платёжного gateway. Статусы paid и declined известны, остальные значения должны остановить переход и остаться видимыми для диагностики.

\n
function nextState(response) {\n  if (response.status === 'paid') return 'success';\n  if (response.status === 'declined') return 'failure';\n  return 'unknown';\n}\n\n// Искусственный пример: unknown не считается success.\nconst state = nextState({ status: 'pending_review' });\nconsole.log(state); // unknown
\n

Здесь есть как минимум два вопроса. Владелец контракта решает, допустим ли отдельный unknown, можно ли повторить запрос и какие данные сохранить. Владелец пути кода проверяет обработчик, переходы интерфейса и тест. Эти люди могут совпасть, но совпадение надо подтвердить, а не предположить по истории строки.

\n

Четыре следа вместо одного «owner»

\n

git blame показывает revision и автора, которые последними изменили строку. Это полезный вход в расследование. Строка могла прийти из рефакторинга, переноса файла или форматирования. Поэтому blame не доказывает, что автор принимает сегодняшнее бизнес-решение. git log добавляет историю пути и связанных изменений, но также не назначает текущую ответственность.

\n

CODEOWNERS работает по другому принципу. Это правило хостинга, которое сопоставляет путь с пользователями или командами и может автоматически запросить их review. В GitHub файл ищут в `.github/`, корне или `docs/`; используется первый найденный вариант. Для запроса review важен CODEOWNERS из base branch pull request. Правило в feature branch само по себе не гарантирует нужный маршрут.

\n

Review тоже имеет узкую границу. Comment оставляет замечание, Approve сообщает, что изменение готово к merge, Request changes блокирует принятие до исправления. Ни один статус не записывает, кто проверит поведение после выпуска. Поэтому follow-up надо назначить отдельно: это может быть проверка лога, сценария или отдельная техническая задача.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Задача уходит к автору строкиИсторию приняли за текущего владельца решенияgit blame -L, затем diff и связанный контрактСохранить автора как факт и отдельно назначить decision owner
Нужный reviewer не получил запросНе совпал путь, выбран не тот base branch или owner не имеет доступаПроверить расположение файла, регистр пути, порядок правил и праваИсправить точное правило CODEOWNERS и открыть новый review-маршрут
Есть approval, но спорный статус не разобранReview проверил форму diff, а не риск контрактаНайти в обсуждении ответ на вопрос о unknownЗапросить конкретное подтверждение или отправить Request changes
После merge никто не знает результатFollow-up не был частью записи измененияПроверить назначенного исполнителя и ожидаемый сигналСоздать отдельное действие; не закрывать задачу по одному факту merge
\n

Как работает маршрут CODEOWNERS

\n

В примере общий маршрут задаёт запасного reviewer, а более узкие пути уточняют область. Правила вымышлены и приведены только для объяснения синтаксиса. В GitHub последнее совпавшее правило имеет больший приоритет. Несколько owners для одного пути записывают в одной строке. Отрицание через ! и диапазоны в квадратных скобках нельзя переносить из `.gitignore` без проверки: в CODEOWNERS они не работают как в gitignore.

\n
# Искусственный пример, не конфигурация рабочего репозитория.\n*                                      @example/platform\n/web/checkout/                       @example/checkout\n/web/checkout/gateway/               @example/payments\n/docs/checkout-contract.md           @example/payments @example/checkout\n/.github/CODEOWNERS                   @example/repository-admins
\n

Для изменения /web/checkout/gateway/status.js сначала проверяют, какое правило совпало и кто имеет право получить review. Для изменения документа контракта нужны оба указанных направления, если именно они обладают знаниями о семантике и потребителе. Это не означает, что оба approval обязательны: политика ветки может требовать approval любого code owner. Если риск требует двух разных решений, это надо записать в правилах change и запросить обоих людей явно.

\n
\"Схема
История помогает найти контекст, CODEOWNERS направляет запрос, review проверяет diff, а результат после merge требует отдельного действия.
\n

Пример записи решения

\n

Короткая запись в issue или pull request должна связывать риск с ролью. Для искусственного сценария достаточно такого контракта:

\n
symptom: unknown status enters success flow\ndecision_owner: payments-contract\ncode_path_owner: checkout\nreview_questions:\n  - Is unknown a separate state?\n  - Can retry create a second transition?\nfollow_up_owner: checkout\nready_when:\n  - unknown is visible in the test\n  - success transition rejects unknown\n  - review answers both questions\n  - follow-up has a recorded result
\n

Эта запись не создаёт новую должность и не обещает реальный production-сигнал. Она задаёт границу проверки. Если проект не использует CODEOWNERS, те же поля можно хранить в задаче и запросить reviewer вручную. Механизм маршрутизации изменится, но вопросы останутся теми же.

\n

Порядок действий

\n
  1. Запишите вход, наблюдаемый симптом, неверный результат и цену ошибки. Не начинайте с поиска человека.
  2. Ограничьте историю нужным диапазоном строк и путём: используйте git blame -L, затем git log --follow -- path и связанные документы. Не переносите имя из истории в поле decision owner.
  3. Назначьте владельца решения по контракту. Он должен ответить, что означает неизвестный статус и какие переходы допустимы.
  4. Определите владельца пути кода и проверьте CODEOWNERS из base branch. Убедитесь, что путь написан с правильным регистром, правило расположено в поддерживаемом файле, а команда имеет нужный доступ.
  5. Сформулируйте вопросы review по риску: семантика контракта, побочные переходы, отрицательный тест. Общий текст «проверьте код» не закрывает эти вопросы.
  6. До merge назначьте follow-up и ожидаемый сигнал. Это может быть результат тестового сценария или проверка доступного контура; не называйте непроверенное production-результатом.
  7. После merge запишите результат. Если сигнал не подтверждён, оставьте задачу открытой или создайте связанную. Статус «merged» означает состояние кода, а не доказательство исправления симптома.
\n

Отрицательный путь и ограничения

\n

Отрицательный путь должен ломать удобную гипотезу. Удалите владельца из учебной записи: критерий готовности должен перестать выполняться. Подставьте неизвестный статус: он не должен перейти в success. Перенесите файл в соседний каталог: маршрут CODEOWNERS должен измениться или проверка должна сообщить о несоответствии. Если любой из этих тестов проходит без назначенного решения, критерий слишком слабый.

\n

Есть и ограничения механизма. CODEOWNERS знает путь, но не знает, кто владеет внешним API или продуктовым смыслом. Git знает историю, но не знает будущую ответственность. Review фиксирует решение по конкретному diff и может устареть после существенной правки. Автоматический запрос не равен прочитанному review. Эти границы нельзя закрыть ещё одним wildcard-правилом.

\n

Минимальная рабочая карта выглядит так: один симптом, один владелец решения, владелец пути, reviewer с вопросом по риску, отрицательный тест и отдельный follow-up. Один человек может занимать все роли. Готовность проверяется не количеством имён, а свидетельствами: неизвестный вход остаётся отдельным состоянием, review отвечает на заявленные вопросы, а последующее действие имеет записанный результат или явную новую задачу.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/291.json b/editorial/agent-rewrites/291.json new file mode 100644 index 0000000..26be990 --- /dev/null +++ b/editorial/agent-rewrites/291.json @@ -0,0 +1,7 @@ +{ + "index": 291, + "slug": "editorial-2019-12-practice-code-ownership", + "title": "Код не равен решению: как назначить владельцев на стыке модулей", + "excerpt": "Ошибка проходит через несколько модулей, а команда ищет ответственного по последнему коммиту. Разделяем владельца решения, путь кода, review и проверку после merge.", + "contentHtml": "

Ошибка появляется на стыке checkout и платежного gateway. В журнале виден неизвестный статус, а экран показывает успешную оплату. Один разработчик ищет строку в parser, другой открывает последний коммит, третий ждёт ответа команды интеграции. Исправление задерживается, а следующий дефект повторяет ту же ошибку в соседнем файле. Цена — неверное состояние для пользователя, ручное расследование и потерянное время нескольких команд.

\n

Последний автор строки не обязан быть владельцем решения. Он мог перенести код, исправить форматирование или добавить временный обход. История Git отвечает на вопрос «кто менял строку». Она не отвечает на вопрос «что должен означать новый статус». Владелец пути отвечает за место изменения. Владелец решения отвечает за смысл контракта. Reviewer проверяет заданный риск. После merge отдельный человек проверяет результат.

\n

Тезис: владение нужно разложить по вопросам

\n

Слово «владелец» слишком грубое для дефекта, который пересекает границы. Назначьте четыре роли. Владелец решения подтверждает допустимое поведение. Владелец пути кода знает реализацию и соседние переходы. Reviewer отвечает на конкретный вопрос в pull request. Владелец последующего действия проверяет сигнал после merge. Один человек может взять все роли. Но карточка должна показывать их отдельно.

\n

Начинайте с наблюдаемого симптома. Запишите вход, неверный результат и место, где его видно. Формулировка «сломался checkout» не помогает. Формулировка «gateway вернул unknown, а обработчик перевёл его в success» задаёт границу расследования. В ней есть поведение, которое нужно подтвердить или отвергнуть.

\n

Механизм: четыре следа вместо одного имени

\n

git blame показывает revision и автора последнего изменения строки. Используйте его, чтобы найти контекст. Затем откройте git log --follow для пути и связанные документы. Не переносите найденное имя в поле владельца решения автоматически. Исторический факт полезен, но он не заменяет договорённость о статусах.

\n

CODEOWNERS решает другую задачу. В GitHub файл сопоставляет пути с пользователями или командами и может автоматически запросить review. Правило берут из base branch pull request. Поэтому новая строка в feature branch ещё не доказывает, что маршрут сработает. Более узкое совпадение должно стоять после общего. Учебные имена ниже вымышлены и не относятся к рабочему репозиторию.

\n
# .github/CODEOWNERS\n*                              @example/platform-review\n/web/checkout/                 @example/checkout\n/web/checkout/gateway/         @example/payments\n/docs/checkout-contract.md     @example/payments @example/checkout\n/.github/CODEOWNERS            @example/repository-admins
\n

В этом примере изменение gateway направляется команде платежей, а изменение контракта — двум областям. Последняя строка защищает сам маршрут. В реальном репозитории замените псевдонимы доступными пользователями или командами. Если у команды нет прав на репозиторий, автоматический запрос не создаёт знания и не исправляет процесс.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Последний автор не отвечаетИсторический автор принят за владельца решенияСравнить blame с diff, контрактом и тестамиНазначить decision owner по смыслу статуса
Review не пришёлПуть не совпал или CODEOWNERS взят не из base branchПроверить расположение, регистр, права и правилоИсправить маршрут или запросить reviewer вручную
PR approved, но риск осталсяОдобрение не содержит проверяемого вопросаНайти ответ на контракт и отрицательный путьПерезапросить review с двумя конкретными вопросами
После merge нет результатаFollow-up не назначенПроверить задачу, сигнал и срок проверкиСоздать отдельное действие с исходом
\n
\"Схема
Один дефект проходит через четыре поверхности ответственности. История Git даёт контекст, но не назначает владельца решения.
\n

Учебный пример: неизвестный статус

\n

Ниже — учебный сценарий. Он не описывает production-инцидент и не утверждает, что кто-то видел такие метрики. Пусть gateway возвращает unknown. Обработчик должен сохранить это состояние и показать безопасный экран, а не считать значение успешным.

\n
function mapGatewayStatus(status) {\n  if (status === 'paid') return { state: 'success' };\n  if (status === 'pending') return { state: 'pending' };\n  return { state: 'unknown', diagnostic: 'gateway-status-unmapped' };\n}\n\nconst result = mapGatewayStatus('unknown');\n// result.state === 'unknown'; success недопустим
\n

Владелец решения подтверждает смысл unknown: это не оплата и не отказ, а состояние, которое требует безопасного отображения и диагностического следа. Владелец пути проверяет обработчик, retry и соседний экран. Reviewer задаёт два вопроса: не попадает ли неизвестное значение в success и сохраняется ли идентификатор для расследования. Follow-up owner проверяет запланированный сигнал после merge.

\n

Если contract owner не найден, патч нельзя считать готовым. Исправление может выглядеть разумно, но команда всё ещё не знает, какое поведение считать правильным. Отрицательный путь здесь важнее красивой ветки успеха: тест должен явно отвергать unknown → success.

\n

Порядок действий

\n
  1. Запишите один симптом: вход, неверный результат, путь и цену ошибки. Не начинайте с поиска имени.
  2. Соберите узкую историю через git blame -L, git log --follow и просмотр связанных контрактов. Отделите факт изменения от гипотезы о владении.
  3. Назначьте владельца решения и владельца пути. Если смысл статуса не подтверждён, сначала получите решение, а затем меняйте код.
  4. Проверьте маршрут CODEOWNERS на base branch. Убедитесь, что путь совпадает, регистр верен, а users и teams имеют нужные права.
  5. Откройте небольшой change с тестом отрицательного случая. В описании укажите риск и вопрос каждому reviewer.
  6. После review зафиксируйте, что именно одобрено. Comment, Approve и Request changes — разные состояния; появление имени в списке не равно ответу на риск.
  7. Назначьте follow-up до merge. После выпуска запишите подтверждённый сигнал, откат или новую связанную задачу.
\n

Ограничения

\n

CODEOWNERS не является каталогом экспертизы. Он знает путь и правила платформы. Он не подтверждает бизнес-смысл изменения и не проверяет production. Не делайте правило * единственной картой знания: оно направит запрос, но не покажет, кто решает спорный контракт.

\n

GitHub — только один вариант реализации. В другой системе может не быть CODEOWNERS или автоматического запроса. Тогда сохраните ту же модель в issue или шаблоне pull request: decision owner, code path owner, reviewer, follow-up owner. Меняется инструмент, но не вопросы, на которые должен ответить change.

\n

Не превращайте карту владения в процесс ради процесса. Для маленького модуля достаточно четырёх полей, одного теста и двух конкретных вопросов. Обновляйте маршрут, когда каталог переезжает или команда меняет границу. Устаревший список создаёт ложное чувство контроля.

\n

Проверяемый критерий готовности

\n

Работа готова, если независимый разработчик может открыть задачу и найти четыре ответа: кто подтвердил смысл решения, где лежит изменённый путь, кто проверил риск и кто проверит результат после merge. Тест не допускает превращения unknown в success. Review содержит ответ на контрактный вопрос. Маршрут ownership проверен в base branch. После merge есть записанный сигнал или отдельная задача с назначенным исполнителем.

\n

Если хотя бы одного ответа нет, закрывайте кодовый change только как промежуточный результат. Это честнее, чем назвать исправление завершённым по одному факту merge.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/292.json b/editorial/agent-rewrites/292.json new file mode 100644 index 0000000..3a74e5c --- /dev/null +++ b/editorial/agent-rewrites/292.json @@ -0,0 +1,7 @@ +{ + "index": 292, + "slug": "editorial-2019-11-field-reproducible-builds", + "title": "Два разных dist из одного commit: как найти первый разрыв сборки", + "excerpt": "Один commit даёт разные release-файлы у двух разработчиков. Разбираем, как отделить dependency drift, конфигурацию и generated data, а затем подтвердить исправление двумя чистыми прогонами.", + "contentHtml": "

Разработчик собрал release и получил vendors.abc.js. Коллега взял тот же commit и получил vendors.xyz.js. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится позже: rollback не знает, какой набор файлов проверяли, CDN хранит два набора assets под одной версией, а расследование начинается с догадок о cache и webpack.

\n

Разные байты не доказывают ошибку сборщика. Сначала нужно сравнить входы и найти первый отличающийся файл. В этом материале используется учебный сценарий для legacy-проекта на npm 6 и webpack 4. Он не сообщает о production-запуске и не заменяет проверку конкретного проекта. Метод применим там, где команда может получить два чистых каталога, записать окружение и сравнить полный deployable output.

\n

Тезис: hash результата начинается с контракта входов

\n

Воспроизводимая сборка — это не одинаковое имя файла и не один совпавший hash. Это договор о том, какие bytes и настройки входят в функцию build. В минимальный договор входят commit, lockfile, версия Node и npm, команда, режим webpack, значимые переменные и исходные generated data. Если один вход не записан, одинаковый commit ещё не означает одинаковый запуск.

\n

package-lock.json фиксирует дерево npm, но не фиксирует операционную систему, дату в баннере, значение NODE_ENV или код webpack-конфигурации. npm ci помогает отделить drift зависимостей: он требует lockfile, проверяет соответствие package.json и удаляет существующий node_modules. После этого всё равно остаются runtime, config и generated output.

\n

Сначала фиксируем наблюдаемые факты

\n

Не начинайте с ручной очистки cache. Она может убрать старый файл, но не объяснит, почему два прогона разошлись. Возьмите две новые копии одного commit. Для каждой сохраните короткую карточку. Полный process.env в журнал не пишите: он может содержать token или auth-настройки.

\n\n\n\n\n\n\n\n\n\n\n
Карточка двух чистых прогонов
ПолеПрогон AПрогон BЧто показывает отличие
Commitgit rev-parse HEADgit rev-parse HEADРазный commit прекращает сравнение output.
LockfileSHA-256 package-lock.jsonSHA-256 package-lock.jsonРазный digest означает разное дерево зависимостей.
Runtimeверсия Node и npmверсия Node и npmРазная версия становится первой проверяемой гипотезой.
Командаnpm run build и modeта же командаРазный mode меняет конфигурацию и набор chunks.
Manifestpath и SHA-256 каждого файлата же формаПервый differing path показывает границу поиска.
\n

Таблица не делает окружения одинаковыми. Она показывает, на каком слое они уже различаются. Если lockfile digest разный, не обсуждайте module ID: сначала разберите dependency tree. Если все входы совпадают, а первым расходится index.html, откройте генератор HTML. Не обновляйте npm наугад.

\n

Механизм: четыре слоя, которые часто смешивают

\n

Первый слой — зависимости. Без lockfile или после npm install с изменением дерева допустимый диапазон версии может привести к другому транзитивному пакету. Копирование чужого node_modules скрывает drift и привязывает результат к непроверяемому каталогу. Действие простое: остановить сравнение, согласовать один lockfile и повторить чистую установку.

\n

Второй слой — runtime. Node и npm влияют на установку, скрипты и поведение инструментов. Зафиксируйте версии командами node --version и npm --version. Если они различаются, повторите тест на одной версии. Совпавший output после этого не доказывает, что старые среды эквивалентны; он только устраняет одну гипотезу.

\n

Третий слой — конфигурация. Один процесс получил NODE_ENV=production, другой не получил переменную. Или webpack config прочитал PUBLIC_PATH без явного значения по умолчанию. Тогда меняются source map, public URL, chunks или минификация. Требуемые переменные нужно проверять до сборки и выводить в журнал только по whitelist.

\n

Четвёртый слой — generated data. Представьте banner с текущей датой в главном bundle. Чистая установка не исправит это различие: dependency tree уже одинаков. Если дата нужна пользователю, она должна быть явным входом release и попасть в карточку. Если она нужна только для аудита, храните её рядом с доказательством сборки, а не в deployable asset. Исключить файл из сравнения без объяснения — не решение.

\n

Есть ещё один контрпример. В webpack runtime хранит связи между chunks и module IDs. Небольшое изменение графа модулей может сдвинуть hash нескольких chunks. Это не повод сразу менять optimization. Сначала сравните source graph и найдите самый ранний differing path. Имя файла с contenthash — сигнал о содержимом asset, но не доказательство равенства всего dist.

\n
\"Диагностическая
Порядок сравнения не обвиняет webpack заранее: он ведёт к первому фактическому расхождению.
\n

Пример: канонический manifest

\n

Сравнивайте не размер одного main.js, а список всех файлов, которые действительно уходят в deploy. Для каждого path вычислите SHA-256. Перед hash отсортируйте entries по path. Тогда порядок обхода каталога не создаст ложное различие.

\n
import { createHash } from 'node:crypto';\n\nfunction manifestHash(entries) {\n  const canonical = [...entries]\n    .sort((a, b) => a.path.localeCompare(b.path))\n    .map(({ path, sha256 }) => `${path}\\t${sha256}`)\n    .join('\\n');\n\n  return createHash('sha256').update(canonical).digest('hex');\n}\n\nconst first = manifestHash(firstEntries);\nconst second = manifestHash(secondEntries);\nif (first !== second) console.error('compare the first differing path');
\n

Код проверяет только представление manifest. Он не проверяет, что браузер открыл приложение, что registry отдал ожидаемый пакет или что release безопасен. Учебный fixture может проверить два свойства: перестановка одинаковых entries сохраняет hash, а изменение bytes одного sample bundle меняет hash. Это fixture-only результат, не hash настоящего проекта.

\n

Симптом → причина → проверка → действие

\n\n\n\n\n\n\n\n\n\n\n
Маршрут диагностики по первому наблюдаемому различию
СимптомВероятная причинаПроверкаДействие
Lockfile digest различается.Разные dependency trees.Сравнить package-lock.json и историю изменения.Выбрать один lockfile, затем снова выполнить npm ci.
Chunks и source maps имеют разный набор.Разный mode или runtime.Сверить Node/npm, команду и whitelisted variables.Сделать mode и обязательные переменные явными.
Первым расходится HTML с датой.Нестабильный generated data.Открыть bytes и найти источник timestamp.Передать дату явно или вынести её из deployable asset.
Многие chunks меняются после малого edit.Изменился graph, runtime или module IDs.Сравнить source diff и первый differing path.Проверить runtime/chunk strategy на малом эксперименте.
Разошёлся один файл при равных входах.Скрытый генератор или недописанный input contract.Повторить два чистых прогона и открыть генератор файла.Назначить владельца входа; не исключать файл молча.
\n

Порядок действий

\n
    \n
  1. Возьмите две новые копии одного commit. До установки запишите состояние дерева, SHA-256 lockfile, Node/npm и build-команду.
  2. \n
  3. Запустите npm ci в каждой копии. Если команда остановилась из-за lockfile, сначала исправьте рассинхронизацию.
  4. \n
  5. Выполните одну и ту же сборку. Сохраните mode и выбранные значения переменных без секретов.
  6. \n
  7. Составьте отсортированные manifest только для deployable-файлов. Для каждого path сохраните размер и SHA-256.
  8. \n
  9. Сравните manifest. Откройте первый differing path, его bytes и генератор.
  10. \n
  11. Отнесите отличие к dependency tree, runtime/config или generated data. Меняйте один слой за раз.
  12. \n
  13. Повторите оба чистых прогона после изменения. Сохраните карточки, manifest и короткий diff.
  14. \n
  15. Если output снова различается, не скрывайте файл фильтром. Вернитесь к новому первому различию.
  16. \n
\n

Что не сработает как объяснение

\n

Удалить cache вручную можно как санитарный шаг, но это не причина. Выполнить npm update перед повтором — значит изменить dependency tree и потерять исходный эксперимент. Добавить timestamp в имя asset — значит гарантировать разные paths. Сравнить только размер bundle — значит пропустить разные bytes, HTML, CSS и дополнительные chunks. Зафиксировать одну прямую зависимость недостаточно, если транзитивное дерево осталось свободным.

\n

Ограничения и критерий готовности

\n

Два совпавших manifest не доказывают корректность приложения. Они показывают, что выбранные bytes совпали при записанных входах. Метод также не обнаружит различие, если команда забыла включить файл в deployable manifest. Поэтому список output должен исходить из реального маршрута выкладки, а не из удобного glob.

\n

Работа готова, когда команда может ответить на четыре вопроса без устного контекста: какие входы записываются; где лежат два журнала; как строится полный manifest; какое действие следует из первого различия. Проверяемый критерий — два чистых прогона на одном commit с одинаковыми lockfile/runtime/config и одинаковым manifest всех deployable-файлов. Если результат не совпал, готовность не объявляется: карточка должна содержать новый first differing path и следующую проверку.

\n

Исправление должно оставаться обратимым. Для обязательного BUILD_VERSION задайте явную ошибку при отсутствии и безопасное значение для локальной разработки, если оно действительно допустимо. Не встраивайте скрытый fallback. Тогда следующий разбор начнётся с видимого входа, а не с вопроса, какая машина случайно собрала правильный release.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/293.json b/editorial/agent-rewrites/293.json new file mode 100644 index 0000000..88065d8 --- /dev/null +++ b/editorial/agent-rewrites/293.json @@ -0,0 +1,7 @@ +{ + "index": 293, + "slug": "editorial-2019-11-mechanism-reproducible-builds", + "title": "Почему один commit даёт разные bundle: механизм воспроизводимой сборки", + "excerpt": "Одинаковый commit не гарантирует одинаковый bundle. Разбираем границы входов сборки, канонический manifest и способ найти первый байт, который расходится между двумя чистыми прогонами.", + "contentHtml": "

Разработчик собрал release и получил main.abc.js. Коллега взял тот же commit и получил main.xyz.js. Приложение запускается в обоих случаях, поэтому проблему легко отложить. Цена ошибки проявится при rollback, проверке cache и расследовании инцидента: команда не знает, какой набор файлов проверяли, а один номер версии скрывает два разных результата.

\n

Разные bundle не доказывают ошибку webpack. Сначала нужно сравнить входы и найти первый файл, в котором расходятся байты. Тезис статьи простой: воспроизводимая сборка — это свойство конкретной команды с явным контрактом входов. Если два чистых прогона получают один commit, одно дерево зависимостей, один runtime, одну конфигурацию и одну generated data, их deployable output должен совпасть. Если вход не зафиксирован, одинаковый Git hash ничего не доказывает.

\n

Сборка — функция с внешними аргументами

\n

Полезная модель выглядит так: artifact = build(source, dependencies, runtime, config, environment, generatedData). Git фиксирует source и часть config. Lockfile фиксирует разрешённое дерево пакетов, если сборка действительно использует этот lockfile. Runtime включает Node, npm и системные особенности native-зависимостей. Config включает webpack mode, public path, entry и настройки plugins. Environment включает locale, timezone и разрешённые переменные. Generated data включает дату, список файлов, случайный идентификатор или ответ внешнего сервиса.

\n

Эта модель не требует заморозить всю машину. Она задаёт вопрос для каждого отличившегося байта: какой аргумент его породил? Ответ должен вести к проверке или к явному исключению из контракта. Молчаливое исключение не делает сборку воспроизводимой. Оно только прячет часть результата.

\n\n\n\n\n\n\n\n\n\n\n
Граница входов: симптом, след и владелец действия
ВходПример дрейфаПроверяемый следДействие
SourceДругой commit или незаписанный generated file.git rev-parse HEAD, git status --short.Зафиксировать файл или исключить его по правилу репозитория.
DependenciesТранзитивный пакет попал под semver-диапазон.SHA-256 lockfile и журнал чистой установки.Согласовать один lockfile и повторить установку.
RuntimeРазные Node или npm меняют установку и инструменты.node --version, npm --version.Задать поддерживаемую версию и способ её получить.
ConfigРазный mode, public path или значение DefinePlugin.Команда и whitelist build-переменных.Сделать режим и обязательные параметры явными.
Generated dataДата, абсолютный путь, порядок чтения или случайный ID.Diff файла и его генератора.Передать значение явно или документировать исключение.
\n

Полный process.env в журнал не нужен. Он может содержать token и auth-настройки. Сохраняйте только белый список: версии инструментов, команду, режим, hash lockfile, registry host без учётных данных и значения, которые реально влияют на output. Если после этого manifest расходится, сравнивайте байты и раскрывайте следующий вход, а не печатайте все секреты.

\n

Что фиксирует lockfile, а что оставляет открытым

\n

package.json описывает желаемые диапазоны версий. package-lock.json описывает выбранное дерево, resolved location и integrity. Поэтому одна прямая зависимость в manifest не гарантирует неизменность транзитивных пакетов. Если два прогона используют разные lockfile, это уже разные входы. Не следует обсуждать module ID, пока это отличие не устранено.

\n

npm ci полезен для диагностики тем, что не пытается подправить lockfile под manifest. Он устанавливает дерево из lockfile и останавливается при несовпадении. Это правильный отрицательный результат: проверяемого входа пока нет. Удаление lockfile, переход на npm install или копирование чужого node_modules убирают симптом ценой потери эксперимента.

\n

Почему contenthash не заменяет сравнение output

\n

Webpack использует [contenthash] как отпечаток содержимого asset. Разные имена bundle показывают, что соответствующие bytes изменились. Но одинаковое имя одного файла не доказывает, что совпали HTML, CSS, source map и остальные chunks. И наоборот, небольшое изменение графа модулей может изменить runtime и несколько имён сразу.

\n

В legacy-конфигурации runtime и manifest могут попасть в entry chunk. Тогда повторная сборка способна дать другой hash даже при одинаковом исходном коде. Выделение runtime в отдельный chunk и стабильные module IDs уменьшают шум, но не исправляют дату в banner, разный mode или внешний список файлов. Сначала нужно установить причину различия. Затем можно менять стратегию chunks.

\n
\"Схема
Lockfile необходим, но недостаточен: после сборки сравнивают канонический manifest всех файлов, которые действительно уходят в deploy.
\n

Пример: канонический manifest

\n

Сравнивайте не размер main.js, а список всех файлов, которые потребляет выкладка или браузер. Для каждого path вычислите SHA-256. Затем отсортируйте строки по path. Сортировка убирает ложное различие, которое создаёт разный порядок обхода каталога.

\n
import { createHash } from 'node:crypto';\n\nfunction manifestHash(entries) {\n  const canonical = [...entries]\n    .sort((a, b) => a.path.localeCompare(b.path))\n    .map(({ path, sha256 }) => `${path}\\t${sha256}`)\n    .join('\\n');\n\n  return createHash('sha256').update(canonical).digest('hex');\n}\n\nconst first = manifestHash(firstEntries);\nconst second = manifestHash(secondEntries);\nif (first !== second) {\n  console.error('compare the first differing path');\n}
\n

Код отвечает на один вопрос: одинаково ли представление выбранного набора файлов. Он не проверяет, что приложение работает, что registry выдал ожидаемый tarball или что release безопасен. Учебный пример можно проверить на двух массивах: перестановка одинаковых entries сохраняет digest, а изменение одного sample byte меняет digest. Эти результаты относятся только к примеру. Их нельзя выдавать за результат npm, webpack или production-системы.

\n

Симптом → причина → проверка → действие

\n\n\n\n\n\n\n\n\n\n\n
Маршрут от первого симптома к следующей проверке
СимптомПричинаПроверкаДействие
Разный hash lockfile.Разные dependency trees.Сравнить lockfile и историю его изменения.Выбрать один lockfile, затем выполнить npm ci.
Разный набор chunks.Разный mode, runtime или entry.Сверить Node/npm, команду и whitelist переменных.Сделать mode и обязательные параметры явными.
Первым расходится HTML.Timestamp, public path или другой generated data.Открыть bytes и найти генератор поля.Передать значение явно или убрать его из deployable asset.
Многие chunks меняются после малого edit.Изменился граф модулей или runtime.Сравнить source diff и первый differing path.Проверить module IDs и runtime на малом изменении.
Один файл расходится при равных входах.Скрытый генератор или неполный контракт.Повторить чистые прогоны и открыть генератор файла.Назначить вход и владельца; не фильтровать файл молча.
\n

Порядок действий

\n
    \n
  1. Создайте две новые копии одного commit. До установки запишите состояние дерева, hash lockfile, версии Node/npm и команду сборки.
  2. \n
  3. Запустите npm ci в каждой копии. Если команда остановилась из-за рассинхронизации manifest и lockfile, сначала исправьте её отдельным изменением.
  4. \n
  5. Выполните одну и ту же команду сборки. Сохраните mode и разрешённые значения переменных без секретов.
  6. \n
  7. Составьте manifest всех deployable-файлов. Для каждого path сохраните размер и SHA-256, затем отсортируйте записи.
  8. \n
  9. Сравните список путей. Если файл появился или исчез, проверьте entry, mode, plugin и условие генерации.
  10. \n
  11. Для общего path сравните SHA-256, затем сам файл. Для text asset используйте diff; для binary зафиксируйте размер и источник.
  12. \n
  13. Отнесите первое различие к dependencies, runtime, config или generated data. Меняйте один слой за раз.
  14. \n
  15. После исправления повторите оба чистых прогона и сохраните карточки, manifest и короткий diff.
  16. \n
  17. Если output снова различается, не добавляйте фильтр. Вернитесь к новому первому differing path.
  18. \n
\n

Отрицательный путь и ограничения

\n

Очистка cache может быть полезной санитарной операцией, но не объясняет расхождение. npm update перед повтором меняет dependency tree и разрушает исходное сравнение. Timestamp в имени asset гарантирует разные paths. Сравнение размеров пропускает разные bytes. Фиксация только webpack не устраняет разный Node, mode или данные plugin.

\n

Два совпавших manifest не доказывают корректность приложения и не распространяют вывод на все операционные системы. Они показывают, что выбранные bytes совпали при записанных входах. Метод также не обнаружит файл, который ошибочно не включили в manifest. Поэтому список output должен исходить из реального маршрута выкладки, а не из удобного glob.

\n

Критерий готовности

\n

Проверка готова, когда другой разработчик без устного контекста может найти четыре артефакта: карточки двух прогонов, hash lockfile, whitelist значимых входов и полный manifest deployable output. Критерий результата — два чистых прогона на одном commit с одинаковыми lockfile, runtime, config и manifest. Если manifest не совпал, готовность не объявляется: запись должна содержать первый differing path и следующую проверку.

\n

Если обязательный вход отсутствует, сборка должна завершиться видимой ошибкой. Скрытый fallback возвращает проблему в следующий release. Безопасное значение для локальной разработки допустимо только там, где оно не попадает в deployable output и явно отмечено как локальное. В production-like проверке лучше остановиться, чем собрать убедительно неправильный artifact.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/294.json b/editorial/agent-rewrites/294.json new file mode 100644 index 0000000..9a81fed --- /dev/null +++ b/editorial/agent-rewrites/294.json @@ -0,0 +1,7 @@ +{ + "index": 294, + "slug": "editorial-2019-11-practice-reproducible-builds", + "title": "Один commit — два dist: как доказать воспроизводимость сборки", + "excerpt": "Одинаковый commit иногда даёт разные assets. Разбираем входы webpack-сборки, чистую установку npm и manifest SHA-256, который показывает место расхождения.", + "contentHtml": "

Один и тот же commit собирают два раза, а в dist появляются разные файлы. Меняются имя chunk, размер bundle или даже байты при одинаковом размере. Команда снова очищает cache и запускает build. Иногда это временно скрывает симптом. Причина остаётся. Цена ошибки растёт на релизе: review видит один результат, CI публикует другой, а откат нельзя связать с точным набором входов.

\n

Воспроизводимость не означает, что любой компьютер всегда выдаст одинаковый файл. Это проверяемое утверждение о конкретном маршруте: два чистых прогона с одинаковыми зафиксированными входами должны дать одинаковый набор deployable-байтов. Если результат различается, журнал должен показать, какой вход изменился, либо какой output содержит недетерминированное значение. Такой предел превращает спор о «странном webpack» в проверку.

\n

Тезис: сравнивать нужно входы и весь результат

\n

Удобно считать сборку функцией: artifact = build(source, dependencies, runtime, config, environment, generatedData). Git фиксирует source и часть config. Lockfile фиксирует выбранное дерево зависимостей. Node и npm задают runtime и поведение install-скриптов. Команда, mode, define-переменные и project .npmrc меняют конфигурацию. Banner с датой, случайный ID, абсолютный путь или порядок чтения каталога добавляют generated data.

\n

Одинаковый Git hash проверяет только один аргумент. Одинаковое имя main.[contenthash].js проверяет не весь output. Поэтому контроль состоит из двух частей: записать безопасный минимум входов и посчитать канонический manifest всех файлов, которые действительно попадают в поставку. Manifest содержит путь и SHA-256 байтов каждого файла. Сортировка по пути убирает шум файловой системы.

\n

Сначала закрыть дрейф зависимостей

\n

npm install может пересчитать дерево и изменить lockfile. Для обычной разработки это ожидаемо. Для сравнения двух сборок это лишнее изменение эксперимента. npm ci требует lockfile, проверяет его согласованность с package.json, удаляет существующий node_modules и устанавливает описанное дерево. Если команда остановилась из-за рассинхронизации, это полезный результат: проверяемого входа пока нет.

\n

Чистая установка не замораживает всё окружение. npm читает параметры из CLI, переменных среды, .npmrc и package.json. Запишите версию Node, версию npm, hash lockfile, безопасное имя registry, полную build-команду и hash конфигурации. Не копируйте в журнал весь env. Токен, пароль и приватный URL с учётными данными превращают диагностический лог в риск.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Разный набор или размер assetsДругой commit, незаписанный или generated filegit rev-parse HEAD, git status --shortСобрать из точного commit; generated source включить или исключить явно
Расходятся vendor или loader-файлыДругое дерево зависимостейHash package-lock.json, результат npm ciИсправить manifest и lockfile отдельным diff
Один прогон падает или меняет native outputДругой Node, npm, ОС или архитектураnode --version, npm --version, платформаЗадать поддерживаемую среду и повторить
Отличаются mode, public path или HTMLРазная команда или конфигурацияКоманда, config, .npmrc, build-переменныеСделать параметр явным и записать безопасное значение
Меняются дата, путь, порядок или IDНедетерминированные generated dataПервый diff и код, который его пишетЗафиксировать значение, убрать из output или задать исключение
Главный bundle совпал, релиз различаетсяПроверили не весь deployable outputManifest всех файловСравнить строки manifest, а не один размер
\n

Учебный пример: канонический manifest

\n

Следующий код — учебный пример для disposable-копии проекта. Он не запускает сборку, не подписывает release и не доказывает свойство production-артефакта. Его задача уже и проще: получить одинаковое представление одного набора файлов независимо от порядка чтения каталога. В реальном проекте передайте в функцию байты каждого файла из заранее определённой директории deployable output.

\n
import { createHash } from 'node:crypto';\n\nfunction sha256(bytes) {\n  return createHash('sha256').update(bytes).digest('hex');\n}\n\nfunction manifest(files) {\n  return files\n    .map(({ path, bytes }) => sha256(bytes) + '  ' + path)\n    .sort()\n    .join('\\\\n') + '\\\\n';\n}\n\nconst first = manifest([\n  { path: 'dist/main.js', bytes: Buffer.from('same\\\\n') },\n  { path: 'dist/index.html', bytes: Buffer.from('<script>main</script>\\\\n') },\n]);\n\nconst second = manifest([\n  { path: 'dist/index.html', bytes: Buffer.from('<script>main</script>\\\\n') },\n  { path: 'dist/main.js', bytes: Buffer.from('same\\\\n') },\n]);\n\nconsole.log(first === second); // true для этих учебных входов
\n

Важны три детали. Hash считается по байтам, а не по отображаемому размеру. В строку входит относительный путь, потому что исчезнувший или переименованный файл тоже меняет результат. Перед сравнением строки сортируются, потому что порядок обхода каталога не является контрактом. В настоящем отчёте сохраняйте сам manifest рядом с командами и версиями. Один итоговый digest удобен для статуса, но diff строк нужен для расследования.

\n

Как читать расхождение

\n

Если hash lockfile различается, остановитесь на зависимостях. Нет смысла сравнивать webpack, пока два процесса установили разные деревья. Если lockfile совпадает, сравните Node, npm, ОС, архитектуру и параметры установки. Native-пакет или lifecycle script может зависеть от платформы. Если входы совпадают, откройте первый различившийся path в manifest и найдите код, который его формирует.

\n

Дата в banner, абсолютный путь и случайное значение требуют отдельного решения. Не маскируйте их через удаление строк из manifest без правила. Если файл не поставляется пользователю, исключите его из явного allowlist. Если поставляется, зафиксируйте источник значения или уберите его из результата. Если разный output допустим по контракту, проверяйте не полное равенство, а заранее описанный инвариант. Молчаливое исключение не является воспроизводимостью.

\n

Webpack связывает [contenthash] с содержимым asset, но изменение порядка разрешения модулей может затронуть module IDs, vendor chunk и runtime. Поэтому одинаковый размер не доказывает одинаковые байты, а совпавший hash одного файла не доказывает совпадение HTML, CSS, source map и дополнительных chunks. Сначала определите, какие файлы потребляет deploy. Именно этот список сравнивайте.

\n
\"Схема
Повторяемость начинается с явного набора входов. Manifest нужен для сравнения всего deployable output, а не одного главного bundle.
\n

Порядок проверки

\n
  1. Выберите одну настоящую build-команду и точный deployable-каталог. Запишите commit, состояние дерева, Node, npm и hash lockfile.
  2. Создайте две свежие копии того же commit. Не используйте уже существующий node_modules и не переносите готовый dist во второй прогон.
  3. Выполните npm ci. При ошибке согласованности остановитесь и исправьте manifest или lockfile отдельным diff.
  4. Запустите одну и ту же build-команду с одинаковыми безопасно записанными параметрами.
  5. Постройте отсортированный manifest по всем файлам, которые входят в поставку. Проверьте число строк и сравните diff.
  6. При различии классифицируйте первый diff как source, dependency, runtime, config или generated data. Исправляйте одну причину за раз.
  7. Повторите два чистых прогона и сохраните входы, manifest и короткое объяснение результата без секретов.
\n

Что не сработает

\n

Очистка cache не исправляет другой lockfile, дату в banner или путь в source map. npm update перед вторым прогоном меняет объект сравнения. Жёсткая версия одной прямой зависимости не заменяет lockfile для транзитивного дерева. Сравнение размера main.js пропускает разные байты и остальные файлы. Замена имени output на timestamp делает результат менее воспроизводимым.

\n

Ограничения

\n

Два совпавших прогона не доказывают равенство на другой ОС, CPU, версии libc, registry или будущей версии toolchain. SHA-256 manifest не заменяет подпись, проверку происхождения, тесты и проверку поведения приложения. Если сборка использует внешний API, текущую дату, системный часовой пояс или случайность, их нужно включить в контракт или убрать из пути поставки. Если это невозможно, честный результат звучит так: «полное равенство не обещается; проверяется такой-то инвариант».

\n

Эта статья не содержит production-прогона и не выдаёт учебный hash за результат сайта. Реальный критерий зависит от выбранной команды и allowlist output. Нельзя закрыть проблему фразой «manifest однажды совпал».

\n

Критерий готовности

\n

Проверка готова, когда другой инженер получает точный commit, hash lockfile, версии runtime, build-команду, список deployable-файлов и два manifest. Он может повторить два чистых прогона без догадок. При совпадении записано узкое утверждение: «для этих входов два manifest совпали». При расхождении указан первый различившийся файл, причина или следующий проверяемый вход. Это проверяемый предел результата, а не обещание детерминизма всей системы.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/295.json b/editorial/agent-rewrites/295.json new file mode 100644 index 0000000..fef6fb9 --- /dev/null +++ b/editorial/agent-rewrites/295.json @@ -0,0 +1,7 @@ +{ + "index": 295, + "slug": "editorial-2019-10-field-typescript-migration", + "title": "Граница между legacy JavaScript и TypeScript: миграция одного API-потока", + "excerpt": "Безопасный переход начинается не с переименования файлов, а с контракта на границе API. Разбираем transport, runtime-проверку, type-check, отрицательный путь и откат для одного legacy-потока.", + "contentHtml": "

Legacy-модуль загружает участника, возвращает response.body, а экран сразу читает email. Пока сервер отвечает ожидаемым объектом, код выглядит рабочим. После изменения API экран получает undefined или падает в formatter. Если начать миграцию с массового переименования файлов, неизвестный payload быстро превращается в any. Сборка проходит, но ошибка переезжает дальше по графу. Цена — сломанный экран, трудный откат и новый код, которому компилятор уже не помогает.

\n

Тезис статьи простой: переносите за один раз одну границу данных. Оставьте транспорт на JavaScript, добавьте TypeScript-нормализатор с входом unknown, а экрану отдавайте только проверенный Member. Это учебная схема для одного API-потока. Она не сообщает о production-результатах и не заменяет контрактный тест конкретного сервиса.

\n

Что именно нужно изменить

\n

Транспорт отвечает за запрос и ответ библиотеки. Он не должен обещать экрану, что сеть вернула нужную модель. Нормализатор отвечает за форму данных. Он принимает неизвестное значение, проверяет обязательные поля и возвращает либо модель, либо явный отказ. Экран отвечает за отображение успеха и ошибки. Такое разделение даёт каждому слою одну проверяемую обязанность.

\n

TypeScript проверяет связи между модулями во время сборки. Он не вставляет проверки типов в JavaScript, который приходит по сети. Поэтому тип Member должен появиться после runtime-проверки, а не рядом с необработанным response.body. Это и есть механизм миграции: статическая проверка защищает код после шва, runtime-проверка защищает сам шов.

\n
\"Поток
Один шов между транспортом и экраном позволяет проверять и откатывать миграцию независимо от остального клиента.
\n

Минимальный пример

\n

Сначала оставим legacy-транспорт на месте. В настоящем проекте имена request и response зависят от библиотеки. Ниже они обозначают учебный внешний контекст, а не готовый клиент для копирования.

\n
// api.js\n// @ts-check\nexport async function loadMember(memberId) {\n  const response = await request('/members/' + memberId);\n  return response.body;\n}\n\n// member.ts\nexport type Member = {\n  id: string;\n  email: string;\n  status: 'active' | 'blocked';\n};\n\nexport function normalizeMember(value: unknown): Member | null {\n  if (!value || typeof value !== 'object') return null;\n\n  const record = value as Record<string, unknown>;\n  if (typeof record.id !== 'string') return null;\n  if (typeof record.email !== 'string') return null;\n  if (record.status !== 'active' && record.status !== 'blocked') return null;\n\n  return {\n    id: record.id,\n    email: record.email,\n    status: record.status,\n  };\n}\n
\n

Приведение к Record<string, unknown> здесь не доказывает форму объекта. Оно только разрешает читать неизвестные ключи после проверки, что значение — объект. Доказательство дают следующие проверки. Если поле отсутствует или имеет другой тип, функция возвращает null.

\n
// member-screen.ts\nimport { loadMember } from './api.js';\nimport { normalizeMember } from './member';\n\nexport async function showMember(memberId: string): Promise<string> {\n  const payload = await loadMember(memberId);\n  const member = normalizeMember(payload);\n\n  if (!member) return 'Не удалось загрузить участника';\n  return member.email + ' (' + member.status + ')';\n}
\n

В учебном примере экран получает только Member или обрабатывает отказ. В рабочем интерфейсе вместо строки может появиться error state, повторная загрузка или переход на страницу ошибки. Решение зависит от продукта. Неизменным остаётся условие: экран не читает поля у неясного payload напрямую.

\n

Почему массовое переименование не решает задачу

\n

Расширение .js на .ts меняет файл, но не источник данных. Компилятор видит объявленный тип, а сервер продолжает присылать bytes. Если разработчик поставит any на ответ, ошибка исчезнет только из отчёта TypeScript. Если включить строгие настройки сразу во всём дереве, команда получит сотни несвязанных диагностик и потеряет границу первой миграции.

\n

Постепенный путь оставляет JavaScript и TypeScript в одном проекте. allowJs разрешает включать JavaScript-файлы вместе с TypeScript. checkJs добавляет диагностику для JavaScript, а локальный комментарий @ts-check ограничивает первый шаг одним файлом. Эти настройки помогают расширять область проверки, но не создают runtime-валидацию и не описывают неизвестный API автоматически.

\n

Симптом → причина → проверка → действие

\n\n\n\n\n\n\n\n\n\n\n
Диагностика одной миграционной границы
СимптомПричинаПроверкаДействие
Экран падает на body.email.Экран читает необработанный ответ.Поставить fixture без email и пройти путь ошибки.Передать payload через нормализатор.
Новый файл заполнен any.Неясен внешний контракт или его обходят ради сборки.Найти первое место, где значение теряет форму.Описать границу через unknown или отложить поток до исследования API.
tsc проходит, но серверный ответ ломает UI.Статический тип приняли за runtime-проверку.Подать строку, null и объект с неверным статусом.Проверять поля в нормализаторе.
После rename ломается production build.Изменились module target, output path или порядок pipeline.Сравнить старую build-команду и артефакты с новой.Вернуть один слой изменения и запускать type-check отдельно.
При отказе нечего откатывать.Одновременно заменили transport, bundler и экран.Разделить diff на границы и определить обратную связь.Откатить импорт нормализатора, не трогая transport.
\n

Таблица отделяет вопрос от сигнала. Прошедший type-check отвечает за связи в коде. Fixture отвечает за несколько известных входов. Существующая сборка отвечает за выпускной артефакт. Smoke-сценарий отвечает за один пользовательский путь. Ни один gate не доказывает всё сразу.

\n

Проверка отрицательного пути

\n

Положительный пример показывает только счастливый ответ. Для границы важнее отказ. Минимальный набор учебных входов — корректный объект, объект без email и объект с неизвестным status. Ожидаемые результаты — Member, null, null. Это ожидаемое поведение функции в примере, а не результат запуска настоящего API.

\n
const valid = {\n  id: 'm-1',\n  email: 'user@example.test',\n  status: 'active',\n};\n\nnormalizeMember(valid); // Member\nnormalizeMember({ id: 'm-1', status: 'active' }); // null\nnormalizeMember({ ...valid, status: 'pending' }); // null
\n

Если продукт различает «неполный ответ» и «временную ошибку сети», одного null мало. Верните объект с кодом причины или выберите тип Result. Не добавляйте эту детализацию в учебный пример без требования продукта. Важно сохранить отрицательный путь видимым и тестируемым.

\n

Порядок действий

\n
    \n
  1. Выберите один API-метод и перечислите поля, которые реально читает экран. Не расширяйте модель предположениями.
  2. \n
  3. Зафиксируйте текущую build-команду, module target и путь артефактов. Это точка сравнения для отката.
  4. \n
  5. Оставьте transport на JavaScript. При необходимости добавьте @ts-check только в этот файл и включите allowJs.
  6. \n
  7. Создайте TypeScript-нормализатор с входом unknown. Сначала проверьте объект, затем обязательные поля и допустимые варианты.
  8. \n
  9. Измените экран так, чтобы он принимал только проверенную модель. Не пропускайте ответ через any ради зелёного type-check.
  10. \n
  11. Проверьте корректный payload и минимум два отрицательных входа. Сохраните ожидаемые результаты рядом с функцией или в тесте.
  12. \n
  13. Запустите выбранный type-check, затем прежнюю сборку, затем smoke-сценарий экрана. Записывайте scope каждой проверки.
  14. \n
  15. Если gate упал, откатите связь нормализатора с экраном. Не меняйте одновременно транспорт и pipeline, пока не найден первый сигнал.
  16. \n
\n

Ограничения и отрицательный путь миграции

\n

Эта схема не исправляет плохой API. Если сервер иногда отдаёт разные формы, нормализатор обнаружит расхождение, но не решит, какая форма правильна. Нужен владелец контракта и отдельное решение о совместимости. Если внешний ответ нельзя проверить без сетевого запроса, добавьте контрактный тест или контролируемый тестовый ответ в инструментах проекта.

\n

Не следует объявлять готовность только потому, что TypeScript-компилятор не показал ошибок. Типы стираются при компиляции. Они не проверяют JSON во время выполнения, не проверяют права доступа и не гарантируют, что браузер отрисует экран. Не следует и включать strict во всём репозитории как замену выбору границы: это может быть отдельная партия с собственным объёмом и планом отката.

\n

Миграцию лучше остановить, если команда не может назвать форму входа, не может повторить отрицательный ответ или не может сохранить старый выпускной маршрут. Оставить такой модуль JavaScript — допустимый результат. Непроверенный TypeScript-слой с any создаёт иллюзию контроля и усложняет следующую попытку.

\n

Критерий готовности

\n

Одна граница готова, когда transport остаётся подключаемым к прежнему pipeline, экран получает только модель после проверки, корректный вход проходит, два выбранных отрицательных входа отклоняются, type-check и прежняя сборка проходят, а smoke-сценарий показывает ожидаемое состояние. Кроме того, команда должна уметь удалить импорт нормализатора и вернуть старый экран одним небольшим изменением.

\n

Этого критерия достаточно для одной партии. Он не утверждает, что весь проект переведён на TypeScript, что API стабилен или что выпуск безопасен во всех сценариях. Он даёт проверяемый ответ на узкий вопрос: защищена ли выбранная граница данных и можно ли вернуть прежний путь без массового отката.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/296.json b/editorial/agent-rewrites/296.json new file mode 100644 index 0000000..6e3dd4b --- /dev/null +++ b/editorial/agent-rewrites/296.json @@ -0,0 +1,7 @@ +{ + "index": 296, + "slug": "editorial-2019-10-mechanism-typescript-migration", + "title": "Миграция на TypeScript: где заканчивается тип и начинается runtime", + "excerpt": "Файл с расширением .ts ещё не защищает приложение от неверного JSON. Разбираем границу между внешним значением и типизированным кодом, роль unknown, риск any и постепенный переход без массового переписывания.", + "contentHtml": "

После переименования нескольких файлов в .ts редактор показывает подсказки, а сборка проходит. Но ответ API с пропущенным email всё равно доходит до экрана и ломает рендер. На следующем шаге появляется any: ошибка компилятора исчезает, однако исчезает и место, где код должен был остановить неверное значение. Цена ошибки — не только один сбой. Команда теряет границу ответственности, а следующий дефект ищет по стеку и логам, а не по контракту входа.

\n

Тезис простой: TypeScript проверяет связи в исходном коде, но не проверяет внешний объект во время выполнения. Типы, интерфейсы и аннотации удаляются из JavaScript. Поэтому миграция должна начинаться с границы данных: принять внешний вход как неизвестный, выполнить runtime-проверку, выпустить небольшую модель и только затем передать её в типизированное ядро. Переименование файлов — лишь способ подключить этот контур, а не доказательство его наличия.

\n

Механизм: что остаётся после компиляции

\n

Компилятор видит тип Profile и может сообщить об опечатке в имени поля. Браузер или Node.js этого типа не видит. После компиляции остаётся исполняемый код: чтение свойств, вызов функций и условия. Если значение пришло из сети, из localStorage или от старой JavaScript-библиотеки, его форма не стала надёжной от соседнего объявления interface.

\n
type Profile = { id: string; email: string };\n\nfunction label(profile: Profile): string {\n  return profile.id + \" <\" + profile.email + \">\";\n}\n\n// После компиляции проверка формы исчезает:\nfunction label(profile) {\n  return profile.id + \" <\" + profile.email + \">\";\n}
\n

Этот пример учебный. Он показывает отрицательный путь: если runtime должен отклонить объект без email, в коде нет такого условия. Тип описывает ожидание вызывающего кода, но не доказывает, что сеть это ожидание выполнила.

\n
\"Схема
Типизированное ядро получает модель после проверки. Поток с any пропускает неизвестную форму дальше и переносит ошибку к потребителю.
\n

Почему unknown полезнее any на входе

\n

unknown честно сообщает: значение может иметь любую форму. До сужения TypeScript не разрешает читать его свойства. Это небольшое препятствие появляется в правильном месте — у входа. Автор обязан назвать проверяемые поля и допустимые значения.

\n

any действует наоборот. Он разрешает цепочку вроде payload.user.email.toLowerCase(), даже если ни одно звено не доказано. Ошибка компилятора пропадает, но runtime-защиты не появляется. Временный any допустим только как явно записанный долг: с причиной, владельцем и условием удаления. Без этого он маскирует незавершённую границу.

\n
type Member = {\n  id: string;\n  email: string;\n  status: \"active\" | \"blocked\";\n};\n\nfunction isMember(value: unknown): value is Member {\n  if (!value || typeof value !== \"object\") return false;\n\n  const record = value as { [key: string]: unknown };\n  return typeof record.id === \"string\"\n    && typeof record.email === \"string\"\n    && (record.status === \"active\" || record.status === \"blocked\");\n}\n\nexport function normalizeMember(value: unknown): Member | null {\n  return isMember(value) ? value : null;\n}
\n

Проверка намеренно минимальна. Она не пытается описать весь ответ сервера. Она проверяет только поля, от которых зависит текущий экран. Если приходит status: \"archived\", функция возвращает null; вызывающий код должен выбрать состояние ошибки, повторный запрос или безопасное сообщение. Нормализатор не решает UX и не заменяет контракт сервиса. Он не позволяет произвольному объекту притвориться Member.

\n

Симптомы и диагностика

\n
От симптома к проверяемому действию
СимптомПричинаПроверкаДействие
Файл стал .ts, но плохой JSON проходитТипы не исполняются в runtimeПередать объект без обязательного поляДобавить проверку у входа и тест отказа
Ошибки исчезли после добавления anyПроверка отключена для цепочки значенияНайти первое присваивание any и его потребителейЗаменить вход на unknown, сузить форму явно
Включён allowJs, но старый JS молчитРазрешение входных файлов не равно диагностикеДобавить ошибочную операцию в .jsВключить checkJs локально или через конфигурацию
После миграции сломался buildВместе изменились target, module или путь outputСравнить команду и артефакт с исходной точкойВернуть одну ось изменения и проверять прежний build
\n

Постепенный переход через один шов

\n

Выберите участок, где данные переходят между владельцами: HTTP-ответ становится моделью экрана, форма становится командой API, конфигурация становится объектом приложения. Изолированный helper без внешнего входа даст меньше сигнала. На выбранной границе должны быть источник, минимальная форма, место проверки и получатель.

\n

В старом потоке транспорт может остаться JavaScript. Он получает ответ и возвращает тело. Новый TypeScript-модуль принимает unknown, вызывает нормализатор и отдаёт экрану только Member. Так команда меняет один контракт, а не транспорт, экран, bundler и формат модулей одновременно.

\n
// member-api.js — существующий транспорт\n// @ts-check\n/** @param {string} memberId */\nfunction loadMember(memberId) {\n  return request(\"/members/\" + memberId).then(function (response) {\n    return response.body;\n  });\n}\nmodule.exports = { loadMember };\n\n// member-screen.ts — новый узкий шов\nimport { loadMember } from \"./member-api\";\nimport { normalizeMember } from \"./normalize-member\";\n\nexport function showMember(memberId: string): Promise<string> {\n  return loadMember(memberId).then((payload: unknown) => {\n    const member = normalizeMember(payload);\n    if (!member) throw new Error(\"Ответ не соответствует контракту\");\n    return member.email;\n  });\n}
\n

Код выше — учебная fixture. В ней намеренно не определён транспортный клиент. Она проверяет другое условие: экран не получает response.body напрямую. В настоящем приложении вместо throw может быть объект результата или переход в состояние ошибки. Решение зависит от UI, но неизвестный payload не должен становиться моделью молча.

\n

Порядок действий

\n
  1. Зафиксировать исходную точку: версию TypeScript, команду сборки, формат output и один smoke-сценарий.
  2. Найти первый внешний вход и описать минимальный контракт для конкретного потребителя.
  3. Добавить runtime-проверку на обязательные поля и отрицательную fixture с неполным объектом.
  4. Оставить соседний JavaScript в сборке через allowJs; новый модуль перевести отдельно.
  5. Заменить any на unknown на выбранном входе и сузить значение проверяемым предикатом.
  6. Включить checkJs только на понятном участке: массовый запуск может открыть старый долг вместо проверки новой границы.
  7. Запустить type-check, отрицательный тест нормализатора, прежний build и smoke-сценарий.
  8. Записать оставшиеся any как отдельные долги с причиной и владельцем, а не считать их доказательством завершения.
\n

Ограничения

\n

Статические типы не проверяют сервер и не заменяют schema validation, контрактные тесты или наблюдение после выпуска. Runtime-предикат тоже не знает всех бизнес-правил: он подтверждает минимальную форму, нужную текущей операции. Если контракт меняется, проверку нужно обновить вместе с потребителем.

\n

Исторический проект может использовать TypeScript 3.5 и старый bundler. Нельзя переносить настройки target, module и разрешение модулей из современного шаблона без сравнения output. Обновление компилятора — отдельная ось риска. Новые diagnostics не следует приписывать одному переименованию файла.

\n

Иногда лучший результат первой итерации — не переводить модуль. Если внешний API ещё не понятен, оставьте JavaScript, включите локальный @ts-check и сначала зафиксируйте фактический вход. Честно отложенный шов полезнее TypeScript-файла, который заполнен any и не останавливает данные.

\n

Критерий готовности

\n

Итерация готова, если для выбранной границы можно показать четыре результата: неполный или неверный payload отклоняется runtime-кодом; типизированный модуль принимает только проверенную модель; type-check сообщает об ошибке в несовместимом использовании; исходный build и smoke-сценарий проходят с тем же ожидаемым output. Если есть только зелёная компиляция или только расширение .ts, миграция ещё не доказала пользу.

\n

Проверяемые источники

\n\"" +} diff --git a/editorial/agent-rewrites/297.json b/editorial/agent-rewrites/297.json new file mode 100644 index 0000000..84544f5 --- /dev/null +++ b/editorial/agent-rewrites/297.json @@ -0,0 +1,7 @@ +{ + "index": 297, + "slug": "editorial-2019-10-practice-typescript-migration", + "title": "Переход на TypeScript без остановки JavaScript-проекта", + "excerpt": "Пошаговый способ перевести один контракт данных на TypeScript: сохранить существующую сборку, проверить внешний JSON и не прятать ошибки за массовым any.", + "contentHtml": "

Симптом неудачной миграции виден в pull request: десятки файлов получают расширение .ts, ошибки компилятора закрываются через any, а команда всё ещё не знает, проверяется ли ответ API. Цена ошибки — не только большой diff. Сборка может сохранить зелёный статус, пока экран получает объект без обязательного поля. Дефект обнаружится в браузере или у пользователя, а точка, где исчезла гарантия, уже потеряна.

\n

Переход на TypeScript лучше начинать не с каталога файлов, а с границы данных. Выберите один внешний вход, опишите форму значения, поставьте проверку и передайте результат в новый или уже существующий модуль. При таком порядке JavaScript может оставаться частью проекта. Команда получает конкретный сигнал: неверное значение остановилось, рабочая сборка сохранилась, а новая типизация защищает реальный сценарий.

\n

Что именно нужно сохранить

\n

До изменения зафиксируйте текущий рабочий путь. Запишите команду сборки, папку её результата и короткий smoke-сценарий. Для фронтенда это может быть открытие страницы, загрузка карточки и отправка формы. Такой baseline нужен не для отчётности. Он отделяет проблему миграции от случайного изменения сборщика, формата модулей или маршрута импорта.

\n

Не смешивайте в одной итерации четыре изменения: переименование файла, новый module format, замену bundler и строгие настройки всего репозитория. Если после этого перестанет работать импорт, вы не узнаете, какая перемена стала причиной. Первая задача должна оставить старый runtime-путь и изменить только один проверяемый контракт.

\n
\"Постепенный
Переход идёт от внешней границы к следующему модулю. Сборка и пользовательский сценарий остаются отдельными контрольными точками.
\n

Граница данных важнее процента файлов

\n

Полезная первая граница имеет четыре свойства. Известен источник значения. Назван минимальный контракт. Есть код или тест, который отклоняет плохой вход. Понятен получатель результата. Ответ HTTP для карточки обычно подходит лучше внутреннего helper: ошибка на такой границе быстро доходит до экрана и заметна в сценарии.

\n
Выбор первой границы миграции
КандидатРиск без проверкиМинимальный контрактПервое действие
Ответ APIЭкран читает отсутствующее полеid, email, statusНормализовать вход перед рендером
Параметры формыСтрока уходит в команду как неверное значениеСостояние формы и команда отправкиСобрать явный объект команды
КонфигурацияПустой ключ ломает запускОбязательные строки и допустимые значенияПроверить объект в загрузчике
Внутренний helperОшибка редко пересекает границуЛокальные аргументыОставить на следующую очередь
\n

Тип интерфейса не проверяет данные, пришедшие по сети. JSON уже существует в runtime до того, как TypeScript увидит результат функции. Поэтому на внешнем краю нужен обычный исполняемый код: проверка типа, набора полей и допустимых значений. После неё тип помогает остальному коду не повторять те же предположения.

\n

Сохраняем JavaScript в сборке

\n

В учебном примере ниже компилятор видит оба расширения. allowJs позволяет включить существующие JavaScript-файлы рядом с TypeScript. noEmit оставляет выпуск за текущей production-сборкой. Это не готовый конфиг для любого проекта. Значения target, module и include должны соответствовать вашему runtime и структуре исходников.

\n
{ \\\"compilerOptions\\\": { \\\"target\\\": \\\"es5\\\", \\\"module\\\": \\\"commonjs\\\", \\\"allowJs\\\": true, \\\"checkJs\\\": false, \\\"noEmit\\\": true }, \\\"include\\\": [\\\"src/**/*\\\"] }
\n

На первом шаге не включайте checkJs во всём старом дереве без оценки объёма. Этот флаг сообщает об ошибках в JavaScript-файлах, которые входят в проект. Для точечного старта добавьте // @ts-check в один выбранный файл. Так вы получите ограниченный список диагностик и не превратите миграцию в инвентаризацию всего исторического долга.

\n

Проверяем внешний объект до типизированного кода

\n

Ниже — учебный пример. Он не описывает конкретный API и не утверждает, что такой ответ уже существует в рабочей системе. Функция принимает unknown, проверяет нужные поля и возвращает объект только после успешной проверки. Значение с отсутствующим email не проходит дальше.

\n
type Account = { id: string; email: string; status: \\\"active\\\" | \\\"blocked\\\" }; function isAccount(value: unknown): value is Account { if (!value || typeof value !== \\\"object\\\") return false; const record = value as Record<string, unknown>; return typeof record.id === \\\"string\\\" && typeof record.email === \\\"string\\\" && (record.status === \\\"active\\\" || record.status === \\\"blocked\\\"); } export function normalizeAccount(value: unknown): Account | null { return isAccount(value) ? value : null; }
\n

Здесь есть важная отрицательная ветка. Если сервер вернёт status: \\\"archived\\\", функция вернёт null. Дальше приложение должно явно решить, что показывать: сообщение об ошибке, безопасное состояние или повторный запрос. Это решение нельзя заменить утверждением типа. Если заменить unknown на any, компилятор разрешит читать поля без доказательства, и граница снова станет невидимой.

\n

Когда новый TypeScript-модуль импортирует старый JavaScript, типы не делают старый код runtime-безопасным. Они описывают отношения, которые compiler может проверить в исходниках. Сеть, local storage, DOM и callback сторонней библиотеки остаются внешними входами. Их проверяют там, где они входят в собственный контракт.

\n

Симптом → причина → проверка → действие

\n
Диагностика постепенной миграции
СимптомПричинаПроверкаДействие
Сотни ошибок после включения проверкиcheckJs включили на всё деревоСравнить список файлов и размер диагностикиОставить локальный @ts-check и выделить отдельную очередь
Сборка прошла, карточка падает на полеТип описал JSON, но не проверил егоПередать fixture без обязательного поляДобавить runtime-нормализатор и отрицательный тест
Ошибки исчезли после добавления anyНеизвестность перенесли в следующий модульНайти переходы any через границуЗаменить их на контракт или явно записанный временный долг
После переименования сломался импортОдновременно изменился путь или формат модулейСравнить emitted output и baseline-сборкуВернуть лишнюю перемену в отдельный шаг
Команда считает прогресс по расширениямМетрика не связана с даннымиДля каждого файла назвать вход, контракт и получателяСчитать только подтверждённые границы
\n

Порядок первого выпуска

\n
  1. Запишите действующую команду сборки, ожидаемый артефакт и один smoke-сценарий. Зафиксируйте их до изменения.
  2. Выберите одну границу: ответ API, форму или конфигурацию. Перечислите только поля, от которых зависит текущий сценарий.
  3. Добавьте отрицательную fixture: неполный объект, неверное значение enum или пустой обязательный ключ. Зафиксируйте ожидаемый отказ.
  4. Подключите TypeScript к существующему дереву через allowJs и безопасный режим вывода. Проверьте, что compiler действительно видит выбранный файл.
  5. Включите @ts-check в одном JavaScript-файле или переведите одну функцию в .ts. Не закрывайте новую диагностику массовым any.
  6. Запустите type-check, затем обычную production-сборку и smoke-сценарий. Успехом считайте только набор всех трёх проверок.
  7. Сохраните границу и способ отката. Следующий модуль добавляйте после того, как понятно, где проверяется первый.
\n

Ограничения и отрицательный путь

\n

Постепенная миграция не уменьшает автоматически количество ошибок в старом JavaScript. Она ограничивает область новой проверки. Если модуль вызывают разные страницы с несовместимыми аргументами, сначала нужно найти реальный контракт или разделить адаптеры. Один тип для всех вызовов только скроет различия.

\n

Не обещайте полную строгую типизацию по числу переименованных файлов. Не называйте production-готовым учебный tsconfig.json или пример нормализатора. Не включайте строгие флаги на весь репозиторий, если не оценили объём исправлений и не подготовили путь возврата. Массовый any также не является планом отката: он оставляет код исполняемым, но убирает полезную проверку.

\n

Если выбранная граница не даёт воспроизводимой отрицательной проверки, остановитесь. Перенос файла сам по себе не доказывает пользу. Вернитесь к контракту, найдите входное значение и сформулируйте случай, который должен быть отклонён. Если это невозможно сделать без изменения backend, сборщика или публичного API, вынесите зависимость в отдельную задачу.

\n

Проверяемый критерий готовности

\n

Первая итерация готова, если команда может показать четыре факта: существующая сборка и smoke-сценарий проходят; выбранный файл входит в type-check; неполный или неверный вход не попадает в типизированный модуль; изменение можно откатить без массового восстановления дерева. Число файлов с расширением .ts в этот критерий не входит.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/298.json b/editorial/agent-rewrites/298.json new file mode 100644 index 0000000..8e70fd9 --- /dev/null +++ b/editorial/agent-rewrites/298.json @@ -0,0 +1,7 @@ +{ + "index": 298, + "slug": "editorial-2019-09-field-accessibility-basics", + "title": "Ошибка поля формы: текст, состояние и фокус должны быть одной системой", + "excerpt": "Красная рамка не объясняет ошибку и не возвращает пользователя к месту исправления. Разбираем контракт поля после submit: label, текст ошибки, aria-invalid, фокус и проверка в браузере.", + "contentHtml": "

После отправки формы у поля появляется красная рамка, но фокус остаётся на кнопке. Пользователь клавиатуры не знает, какое поле исправлять. При этом текст ошибки может быть в DOM, но скрыт, не связан с input или повторяет только слово «Ошибка». Цена такого дефекта — сорванная регистрация, повторный ввод и новая переделка общего компонента формы.

\n

Тезис простой: ошибка поля — это не цвет, а согласованный маршрут. Валидатор определяет сбой, интерфейс называет его текстом, поле получает состояние, а фокус указывает следующий шаг. Если одна часть цепочки отстаёт, мышь ещё может скрыть проблему, но клавиатурный путь и вспомогательная технология теряют контекст.

\n

Механизм: что должно измениться после submit

\n

Возьмём поле «Почта». До отправки у него есть доступное имя из связанного label, подсказка о формате и обычное состояние. После неудачного submit приложение добавляет конкретное сообщение, ставит aria-invalid=\"true\" и выбирает точку фокуса. Эти изменения должны произойти из одного результата валидации.

\n

HTML связывает label и control через уникальную пару for/id или через вложение input в label. Текст рядом с полем без этой связи остаётся визуальной подписью. Placeholder тоже не заменяет label: он исчезает при вводе и не должен нести имя control.

\n

aria-describedby связывает input с подсказкой и сообщением об ошибке по их идентификаторам. Список ссылок должен указывать на существующие элементы с актуальным текстом. aria-invalid сообщает, что текущее значение не прошло проверку. Дополнительный aria-errormessage требует того же состояния и не отменяет видимый текст.

\n

Симптом → причина → проверка → действие

\n
Диагностика ошибки одного поля после submit
СимптомПричинаПроверкаДействие
Есть только красная рамкаСостояние выражено цветомОтключить CSS и найти текстовое сообщениеВывести объяснение и способ исправления
Текст есть, input его не получаетНет связи по ID или ссылка устарелаСверить aria-describedby с ID видимого сообщенияСинхронизировать атрибут и рендер сообщения
Фокус остаётся на submitОбработчик не выбрал следующий шагЗаписать document.activeElement сразу после submitПеревести фокус на первое ошибочное поле или summary
Поле объявлено ошибочным слишком раноВалидатор срабатывает на каждый символОткрыть пустую форму и проверить начальное состояниеСтавить aria-invalid после заданного события, например submit
Ошибка исчезла, но старый ID осталсяТекст и состояние обновляются разными веткамиИсправить значение и проверить DOM и Accessibility treeУдалить сообщение, ссылку и invalid state одной операцией
\n

Таблица описывает диагностику, а не готовый UX для любой формы. Для длинной анкеты можно сначала сфокусировать summary со ссылками на ошибки. Для короткой формы быстрее отправить фокус в первое неверное поле. Решение нужно выбрать один раз и проверить на всей странице. Фокус не следует перемещать при каждом нажатии клавиши: это прерывает ввод.

\n

Рабочий пример: один владелец состояния

\n

Ниже учебный пример для локальной формы. Он не проверяет серверный API и не доказывает озвучивание сообщения конкретным screen reader. Его задача — показать, как связать валидатор, текст, состояние и фокус. Значения формата и выбор первого поля — проектные решения.

\n
const form = document.querySelector('[data-signup]');\nconst email = document.querySelector('#email');\nconst hint = document.querySelector('#email-hint');\nconst error = document.querySelector('#email-error');\n\nfunction setEmailError(message) {\n  const invalid = Boolean(message);\n\n  email.setAttribute('aria-invalid', String(invalid));\n  email.setAttribute(\n    'aria-describedby',\n    invalid ? 'email-hint email-error' : 'email-hint',\n  );\n  error.hidden = !invalid;\n  error.textContent = message || '';\n}\n\nform.addEventListener('submit', (event) => {\n  event.preventDefault();\n  const value = email.value.trim();\n\n  if (!value || !value.includes('@')) {\n    setEmailError('Укажите почту в формате name@example.com.');\n    email.focus();\n    return;\n  }\n\n  setEmailError('');\n  // Учебный пример: здесь могла бы начаться отправка формы.\n});
\n

Разметка для этого обработчика должна содержать уникальный ID и связанную подпись:

\n
<form data-signup>\n  <label for=\"email\">Почта</label>\n  <input id=\"email\" name=\"email\" type=\"email\"\n    aria-describedby=\"email-hint\">\n  <p id=\"email-hint\">Например, name@example.com</p>\n  <p id=\"email-error\" role=\"alert\" hidden></p>\n  <button type=\"submit\">Продолжить</button>\n</form>
\n

Валидация здесь намеренно грубая. Проверка символа @ не является полной проверкой адреса, а сервер всё равно остаётся источником окончательного решения. В рабочем коде нельзя считать запрос успешным только потому, что клиентская ветка прошла. Серверная ошибка должна попасть в тот же контракт: текст, состояние поля и предсказуемый фокус.

\n

Для сообщения я использовал role=\"alert\", потому что оно появляется после действия пользователя. Это не универсальная команда озвучить любой текст. Если сообщение уже находится рядом с control и пользователь получает фокус на поле, достаточно проверить связку aria-describedby. Живой регион добавляют только там, где он действительно нужен, иначе одна ошибка может прозвучать дважды.

\n

Иллюстрация маршрута

\n
\"Схема
Ошибка становится полезной, когда сообщение, состояние input и решение о фокусе меняются согласованно.
\n

Схема показывает направление данных, а не результат реального прогона. В браузере нужно отдельно подтвердить, что сообщение видно, поле сохраняет фокус и его вычисленные имя, роль и описание соответствуют ожиданию.

\n

Проверка клавиатурой и в дереве доступности

\n

DOM-проверка находит атрибуты, но не отвечает на весь вопрос. Клавиатура показывает маршрут и видимый фокус. Accessibility tree показывает представление control, которое собрал браузер. Проверяйте оба слоя на собранной странице, а не только JSX или шаблон.

\n
  1. Откройте форму в чистом состоянии. Перейдите к полю клавишей Tab и проверьте видимый индикатор фокуса. Убедитесь, что подпись кликабельна и связана с input.
  2. Оставьте почту пустой или введите учебное неверное значение. Нажмите Tab до кнопки и активируйте submit клавиатурой.
  3. Проверьте текст ошибки. Он должен назвать проблему и следующий шаг: «Укажите почту в формате name@example.com» лучше, чем «Неверно».
  4. Запишите активный элемент после submit. Для выбранного варианта это первое ошибочное поле. Если продукт использует summary, проверьте ссылку из summary к этому полю.
  5. В DevTools откройте Accessibility tree для input. Сверьте role, accessible name, description и invalid state. Не подменяйте эти значения исходным HTML.
  6. Исправьте значение и повторите submit. Проверьте, что текст ошибки скрыт, aria-invalid сброшен, ссылка на error удалена или обновлена, а фокус не прыгает без причины.
  7. Повторите сценарий с серверной ошибкой и с медленным ответом. Зафиксируйте, кто владеет состоянием ожидания и в какой момент ошибка становится актуальной.
\n

Ограничения и отрицательный путь

\n

Этот пример покрывает одно текстовое поле и одну ошибку после submit. Он не решает групповые ошибки, радиокнопки, сложные маски, вложенные формы, ошибки сети и серверные сообщения, которые приходят после смены страницы. Для каждого такого случая нужен отдельный маршрут.

\n

Если сервер отвечает после того, как пользователь уже исправил значение, старый ответ нельзя безусловно применять. Сравните запрос с актуальным значением или отмените устаревший запрос. Иначе исправленное поле снова станет ошибочным. Во время ожидания покажите состояние загрузки и не меняйте фокус без действия, которое пользователь может объяснить.

\n

Не добавляйте положительный tabindex, чтобы скрыть неверный порядок DOM. Сначала расставьте элементы в логическом порядке. Не заменяйте нативный input кастомным div с role без причины: вместе с ролью придётся восстановить фокус, клавиатурные команды, значение и состояния.

\n

Нельзя объявлять всю страницу соответствующей WCAG после проверки одной формы. Здесь проверяются идентификация ошибки, видимый фокус, имя поля и маршрут исправления. Контраст, масштабирование, язык, автозаполнение и остальные экраны требуют своих проверок.

\n

Проверяемый критерий готовности

\n

Контракт готов, если тестировщик без мыши повторяет один и тот же сценарий и получает четыре наблюдаемых результата: ошибочное поле названо текстом, сообщение связано с input, invalid state отражён в браузере, а фокус ведёт к выбранной точке исправления. После корректного значения все четыре состояния возвращаются в валидный вид. Эти условия можно закрепить DOM-тестом и ручным проходом в поддерживаемом браузере.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/299.json b/editorial/agent-rewrites/299.json new file mode 100644 index 0000000..01705f7 --- /dev/null +++ b/editorial/agent-rewrites/299.json @@ -0,0 +1,7 @@ +{ + "index": 299, + "slug": "editorial-2019-09-mechanism-accessibility-basics", + "title": "Почему DOM не доказывает доступность: имя, роль и фокус", + "excerpt": "В разметке есть input и кнопка, но пользователь всё равно может потерять фокус или не понять ошибку. Разбираем, как браузер превращает HTML в доступное представление и что проверить на живой форме.", + "contentHtml": "

Симптом обычно появляется на готовом экране. Мышью форма работает: поле принимает текст, кнопка отправляет данные, красная рамка показывает ошибку. После перехода клавишей Tab фокус пропадает на фоне. У поля «Почта» нет понятного имени. После submit остаётся только цвет, а текст причины не связан с input. Цена ошибки — сорванная операция для пользователя и дорогое исправление общего компонента после того, как его скопировали на несколько страниц.

\n

Тезис простой: DOM — это вход для механизма доступности, а не его итог. Браузер читает порядок узлов, нативные элементы, подписи и ARIA-атрибуты. Затем строит представление с ролью, именем, описанием и состояниями. Пользователь и вспомогательная технология взаимодействуют с этим представлением через браузер. Поэтому наличие узла в инспекторе не доказывает, что control получил имя, доступен с клавиатуры или объявляет актуальную ошибку.

\n

Как работает механизм

\n

У механизма есть несколько последовательных слоёв. HTML создаёт элементы и связывает их отношениями. Браузер определяет, какие элементы интерактивны, в каком порядке они получают фокус и какую нативную роль имеют. Текущее состояние страницы меняет доступное представление: поле может стать недействительным, ошибка может появиться или исчезнуть, фокус может перейти к другому control. Затем браузер передаёт эти сведения платформенному accessibility API.

\n

Разрыв возникает, когда проверяют только первый слой. Разработчик видит input с id и текст рядом с ним. Пользователь видит поле без подписи, если рядом стоит обычный span, а не связанный label. Разработчик видит обработчик click на div. Пользователь клавиатуры не получает нативную активацию, предсказуемый фокус и обязательное поведение кнопки. Разработчик видит элемент с aria-describedby. Пользователь не получает описание, если ссылка указывает на несуществующий ID или текст остаётся скрытым.

\n
Схема преобразования HTML в доступное представление: связанный label и input дают проверяемые имя, роль, описание и состояние, а несвязанный текст оставляет имя пустым
DOM задаёт входные данные. Проверять нужно вычисленное представление выбранного control и его поведение в клавиатурном маршруте.
\n

Имя начинается с HTML-связи

\n

Видимая подпись и программное имя должны описывать один и тот же control. Для обычного поля начните с нативного HTML: у label должен быть атрибут for, равный id поля, либо control должен быть вложен в label. Соседний span не создаёт такую связь. Уникальный id тоже не создаёт имя сам по себе.

\n
<form id='profile-form' novalidate>\n  <label for='profile-email'>Рабочая почта</label>\n  <p id='profile-email-hint'>Ссылка для входа придёт на этот адрес.</p>\n  <input\n    id='profile-email'\n    name='email'\n    type='email'\n    autocomplete='email'\n    required\n    aria-describedby='profile-email-hint profile-email-error'\n    aria-errormessage='profile-email-error'\n    aria-invalid='false'\n  />\n  <p id='profile-email-error' hidden></p>\n  <button type='submit'>Сохранить</button>\n</form>\n\nconst form = document.querySelector('#profile-form');\nconst email = document.querySelector('#profile-email');\nconst error = document.querySelector('#profile-email-error');\n\nfunction setEmailError(message) {\n  const invalid = Boolean(message);\n  email.setAttribute('aria-invalid', String(invalid));\n  error.hidden = !invalid;\n  error.textContent = message;\n}\n\nform.addEventListener('submit', (event) => {\n  event.preventDefault();\n  const message = email.validity.valid\n    ? ''\n    : 'Введите адрес в формате name@example.com';\n  setEmailError(message);\n  if (message) email.focus();\n});
\n

Пример учебный. Он не выполняет серверную проверку, не имитирует сеть и не доказывает, что конкретная вспомогательная технология произнесёт сообщение. Его задача — показать единый переход состояния. При ошибке меняются текст, видимость и aria-invalid. Фокус возвращается в поле после неудачной отправки. В реальном продукте сервер остаётся источником окончательного решения, а клиентский текст не должен обещать проверку, которой он не выполняет.

\n

aria-describedby связывает control с подсказкой и текстом ошибки. Все ID должны существовать, а текст должен быть понятен без цвета. aria-errormessage указывает на сообщение об ошибке и используется вместе с aria-invalid. Атрибуты не создают содержимое и не исправляют неверную логику фокуса. Если error-элемент остаётся скрытым после ошибки или содержит только «Неверно», формальная связь не даёт следующего действия.

\n

Симптом → причина → проверка → действие

\n
Минимальная диагностика одной формы
СимптомПричинаПроверкаДействие
После Tab не видно активный элементoutline отключён без равноценного индикатораПройти маршрут на настоящем фоне и записать activeElementВернуть заметный :focus-стиль и проверить контраст индикатора
У поля нет понятного имениРядом стоит span или for указывает не на этот idСверить label/for/id и имя поля в Accessibility treeИсправить нативную связь; не добавлять случайный aria-label
Кнопка работает только мышьюДействие повесили на div или spanАктивировать Tab, Enter и Space в поддерживаемом браузереИспользовать button или отдельно реализовать весь keyboard-контракт
Есть красная рамка, но нет объясненияОшибка выражена только CSS-классомОтправить пустую форму и найти видимый текст рядом с полемВывести причину, связать её с control и обновить invalid state
Порядок прыгает между блокамиПоложительный tabindex отделил фокус от DOM-порядкаСравнить Tab и Shift+Tab с визуальным порядком страницыСобрать логичный DOM и убрать положительные значения tabindex
\n

Таблица помогает выбрать малую проверку, но не заменяет полный аудит. Она не измеряет контраст всего интерфейса, масштабирование текста, жесты, язык страницы или сложное модальное окно. Дерево доступности также не заменяет прогон с screen reader. Сначала фиксируйте наблюдение: браузер, версия, шаг, ожидаемое и фактическое состояние. Не записывайте «ошибка озвучивается», пока вы действительно не прошли этот сценарий с выбранной связкой браузера и вспомогательной технологии.

\n

Фокус связывает смысл и действие

\n

Порядок фокуса — это порядок выполнения операции. Если пользователь после поля попадает на декоративный узел, скрытую кнопку или другой блок, он теряет модель страницы. WCAG требует сохранять смысл и работоспособность последовательной навигации. Поэтому сначала проверяйте порядок документа и нативные элементы. Положительный tabindex создаёт отдельный порядок и быстро ломается после добавления нового control.

\n

Видимый фокус тоже функционален. CSS вроде outline: none допустим только вместе с равноценным индикатором. Цвет рамки должен отличаться от фона и соседних состояний. На длинной форме проверьте, что sticky-панель или прокрутка не закрывают сфокусированный элемент. Это отдельная проверка: наличие фокуса в DOM ещё не означает, что его можно увидеть.

\n

Порядок действий

\n
  1. Выберите один реальный сценарий: поиск, регистрация или сохранение профиля. Назовите начальное действие, поля, кнопку и ожидаемый результат.
  2. Прочитайте разметку только для первой гипотезы. Проверьте нативные label, input, button, существование ID и отсутствие положительных tabindex.
  3. Откройте страницу в поддерживаемом браузере и пройдите Tab без мыши. Запишите порядок, видимость фокуса и возможность активировать действие с клавиатуры.
  4. На каждом спорном control откройте панель Accessibility. Сверьте role, name, description и состояния с текстом и состоянием страницы.
  5. Вызовите отрицательный путь: оставьте обязательное поле пустым или введите учебное неверное значение. Проверьте текст ошибки, связь по ID, aria-invalid и решение о следующем фокусе.
  6. Сделайте одну минимальную правку и повторите маршрут с чистого состояния. В результат добавьте браузер, версию, шаги и фактическое наблюдение.
\n

Что проверка не обещает

\n

Нативный HTML не делает сложный виджет доступным автоматически. Диалог, комбобокс, вкладки и drag-and-drop имеют дополнительные клавиатурные правила и состояния. Их нельзя закрыть копированием атрибутов из примера формы.

\n

ARIA не заменяет нативную семантику. Добавленный role='button' не превращает div в полноценную кнопку: автору всё равно нужно обеспечить фокус, активацию Enter и Space, состояние и доступное имя. Там, где подходит button, он уменьшает количество правил и поверхность ошибки.

\n

Проверка одного браузера не доказывает совместимость со всеми браузерами и технологиями. Панель Accessibility показывает представление конкретной среды. Screen reader может иметь собственные особенности. Проверяйте поддерживаемые комбинации из матрицы проекта и разделяйте факты от предположений.

\n

Критерий готовности

\n

Форма готова к этому уровню проверки, если один человек может пройти выбранный сценарий без мыши и без угадывания. На каждом шаге виден фокус. У каждого control есть ожидаемые role и name. Подсказка и ошибка имеют понятный текст и действующие ID-связи. После неудачного submit пользователь попадает в заранее выбранное место и понимает следующий шаг. Эти факты записаны для конкретного браузера. Отдельно отмечено, какие проверки со screen reader ещё не выполнены.

\n

Такой критерий не означает соответствие всей странице WCAG. Он закрывает один проверяемый контракт формы: HTML задаёт семантику, браузер строит доступное представление, клавиатура проверяет маршрут, а отрицательный путь проверяет состояние ошибки. Если любой из этих фактов не подтверждён, задача ещё не готова.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/300.json b/editorial/agent-rewrites/300.json new file mode 100644 index 0000000..8e6a562 --- /dev/null +++ b/editorial/agent-rewrites/300.json @@ -0,0 +1,7 @@ +{ + "index": 300, + "slug": "editorial-2019-09-practice-accessibility-basics", + "title": "Базовая доступность формы: имя, фокус и понятная ошибка", + "excerpt": "Форма может работать мышью и всё равно оставлять пользователя без имени поля, видимого фокуса и объяснения ошибки. Разбираем короткий проверяемый маршрут и правки в нативном HTML.", + "contentHtml": "

Форма выглядит готовой, пока её не проходят клавишей Tab. Фокус пропадает на светлом фоне. Поле с подписью «Почта» не получает понятного имени. После отправки появляется красная рамка, но причина ошибки остаётся неизвестной. Мышью такой экран ещё можно пройти. С клавиатурой пользователь теряет маршрут и не понимает, что исправить.

\n

Цена ошибки растёт после первого релиза. Дефект повторяется на каждой форме с тем же компонентом. Поддержка получает вопросы о «неработающей кнопке». Команда добавляет случайные tabindex и ARIA-атрибуты, не понимая, какое состояние они меняют. Базовую доступность выгоднее проверять до распространения компонента: по одному действию, видимому симптому и повторяемому результату.

\n

Тезис: проверяйте маршрут, а не наличие атрибутов

\n

Для формы нужны три наблюдаемых свойства. Пользователь должен видеть, где находится фокус. Браузер должен вычислять для control имя, связанное с его назначением. Ошибка должна появляться текстом и быть связана с ошибочным полем. DOM помогает найти причину, но сам по себе не доказывает результат. Проверка начинается с живого маршрута клавиатуры, затем продолжается в панели Accessibility.

\n

Нативный HTML задаёт большую часть механизма. label связывает подпись с полем через пару for/id или через вложение control. button уже умеет получать фокус и активироваться клавишей. Браузер использует эти отношения, чтобы построить доступное представление текущей страницы. ARIA уточняет имя, описание и состояние, но не заменяет сломанный порядок DOM и не превращает произвольный div в готовую кнопку.

\n

Наблюдаемый пример: поле с подсказкой и ошибкой

\n

Ниже приведён самостоятельный учебный пример. Он не заявляет результат для конкретного продукта и не заменяет проверку в поддерживаемой матрице браузеров. Форма не отправляет данные на сервер. Обработчик показывает, как синхронизировать текст ошибки, состояние поля и фокус после неудачной отправки.

\n
<form id=\"profile-form\" novalidate>\n  <label for=\"profile-email\">Рабочая почта</label>\n  <p id=\"profile-email-hint\">На этот адрес придёт ссылка.</p>\n  <input id=\"profile-email\" name=\"email\" type=\"email\" required\n    aria-describedby=\"profile-email-hint profile-email-error\"\n    aria-errormessage=\"profile-email-error\" aria-invalid=\"false\" />\n  <p id=\"profile-email-error\" hidden></p>\n  <button type=\"submit\">Сохранить</button>\n</form>\n\nconst form = document.querySelector('#profile-form');\nconst email = document.querySelector('#profile-email');\nconst error = document.querySelector('#profile-email-error');\n\nfunction setEmailError(message) {\n  const invalid = Boolean(message);\n  email.setAttribute('aria-invalid', String(invalid));\n  error.hidden = !invalid;\n  error.textContent = message;\n}\n\nform.addEventListener('submit', (event) => {\n  event.preventDefault();\n  const message = email.validity.valid\n    ? ''\n    : 'Введите адрес в формате name@example.com';\n  setEmailError(message);\n  if (message) email.focus();\n});
\n

В разметке есть две разные связи. label даёт полю имя «Рабочая почта». aria-describedby перечисляет идентификаторы подсказки и места ошибки. Когда значение неверно, обработчик ставит aria-invalid=\"true\", показывает текст и возвращает фокус в поле. Пользователь получает причину и может сразу исправить значение.

\n

Порядок имеет значение. Ошибка не должна существовать только в цвете рамки: человек с нарушением цветового восприятия может её не заметить, а вспомогательная технология не получит объяснения. Текст должен назвать проблему и, если это уместно, ожидаемый формат. Если приложение рисует общий summary, он тоже должен вести к ошибочному полю. Не оставляйте пользователя в верхней части страницы без понятного следующего шага.

\n

Симптомы и точечные проверки

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
После Tab не видно активный controlУбран outline или контраст фокуса слабыйПройти маршрут на реальном фоне и наблюдать активный элементВернуть заметный :focus-стиль и проверить его на светлой и тёмной поверхностях
Подпись видна, но имя поля пустоеРядом стоит span, либо for не совпадает с idОткрыть поле в панели Accessibility и сравнить name с видимой подписьюИспользовать связанный label; убрать дублирующие и расходящиеся имена
Кнопка доступна мышью, но не клавиатуройДействие повесили на div без полной клавиатурной моделиДойти до элемента Tab и активировать Enter или Space по правилам проектаЗаменить элемент на button или отдельно реализовать и проверить всю модель
После submit видна только красная рамкаОшибка не выведена текстом или состояние не синхронизированоПроверить текст, aria-invalid, описание и положение фокусаПоказать понятный текст, связать его с полем и выбрать один предсказуемый фокус
Tab прыгает в неожиданном порядкеПоложительные значения tabindex отделили фокус от DOMЗаписать порядок вперёд и назад, сравнить его с визуальным и смысловым порядкомВернуть логичный DOM и убрать положительные номера, если для них нет строгой причины
\n

Каждая строка отделяет факт от догадки. Если в панели нет имени, сначала исправляйте связь label и control. Если имя есть, но сообщение об ошибке не читается в выбранной вспомогательной технологии, одной правки HTML недостаточно: нужно проверить динамическое обновление и выбранный API. Не подменяйте один результат другим.

\n

Почему DOM не равен доступному представлению

\n

DOM отвечает на вопрос, какие узлы создал код и в каком порядке. Клавиатурный маршрут отвечает, к каким узлам пользователь может прийти. Панель Accessibility показывает, какое имя, роль, описание и состояние браузер вычислил для выбранного элемента. Эти слои связаны, но могут расходиться.

\n

Например, такой фрагмент виден на экране, но не создаёт формальную подпись:

\n
<span class=\"field-title\">Поиск</span>\n<input id=\"query\" type=\"search\" />
\n

Исправленный вариант задаёт связь явно:

\n
<label for=\"query-good\">Поиск</label>\n<input id=\"query-good\" type=\"search\" />
\n

Та же граница действует для интерактивных элементов. <div tabindex=\"0\">Сохранить</div> только добавляет узел в последовательность фокуса. Он не даёт автоматически роли кнопки, активации Space, корректного состояния или поведения формы. <button type=\"button\">Сохранить</button> передаёт браузеру больше нужной семантики. Чем меньше самодельных правил, тем короче маршрут проверки.

\n
\"Схема
Клавиатурный маршрут формы: фокус ведёт к названному полю, затем к действию; неудачная отправка показывает текстовую ошибку и возвращает фокус. Схема показывает порядок наблюдений, а не результат конкретного браузера.
\n

Порядок действий

\n
  1. Выберите одну форму и назовите операцию: например, ввести рабочую почту и сохранить профиль. Зафиксируйте ожидаемый результат без общих слов.
  2. Откройте страницу в браузере из поддерживаемой матрицы. Начните с начала документа и не ставьте курсор мышью внутрь формы.
  3. Нажимайте Tab. Запишите последовательность: ссылка пропуска блока, поле, следующая часть формы, кнопка. На каждом шаге проверьте видимый фокус.
  4. Нажмите Shift+Tab из кнопки. Обратный маршрут должен возвращать к ожидаемому control, а не перескакивать через часть формы.
  5. Оставьте обязательное поле пустым. Активируйте submit клавиатурой. Запишите, куда попал фокус и какой текст объясняет ошибку.
  6. Откройте активное поле в панели Accessibility. Сверьте role, name, description и invalid state с тем, что видит пользователь.
  7. Исправьте одну причину: нативный элемент, связь label, порядок DOM, стиль фокуса или состояние ошибки. Не добавляйте ARIA наугад.
  8. Повторите тот же маршрут вперёд и назад. Если менялся динамический текст, отдельно проверьте его в выбранной связке браузера и вспомогательной технологии.
\n

Ограничения и отрицательный путь

\n

Этот маршрут закрывает узкий риск: базовое управление формой с клавиатуры, понятное имя control и текстовую ошибку. Он не измеряет контраст, масштабирование, жесты, язык страницы, ловушку фокуса в диалоге и все критерии WCAG. Для них нужны отдельные сценарии.

\n

Панель Accessibility зависит от браузера и версии. Её снимок показывает наблюдение конкретного user agent, а не универсальную гарантию для всех платформ. Наличие ARIA-атрибута тоже не доказывает полезный опыт. Плохой текст, неожиданное положение ошибки или недоступный динамический статус сохраняют проблему.

\n

Если поле проходит DOM-проверку, но пользователь со screen reader не получает ожидаемое сообщение, отрицательный путь не закрыт. Повторите сценарий на согласованной связке браузера и вспомогательной технологии. Зафиксируйте, что именно прозвучало и куда переместился фокус. Если такого прогона нет, честный вывод ограничивается проверкой разметки и поведения клавиатуры.

\n

Проверяемый критерий готовности

\n

Форму можно считать прошедшей этот базовый контроль, если один и тот же человек повторяет сценарий без мыши: видит каждый переход фокуса, проходит элементы в смысловом порядке, активирует отправку, получает текстовую причину ошибки, видит ошибочное поле и возвращается к нему предсказуемо. В панели Accessibility у поля есть ожидаемые role, name, description и invalid state. Браузер и версия записаны рядом с результатом.

\n

Это не сертификат доступности и не обещание одинакового поведения на всех устройствах. Это узкий критерий, который можно проверить снова после изменения компонента. Если хотя бы один пункт не выполнен, форма остаётся в отрицательном пути: сначала исправьте наблюдаемый симптом, затем повторите весь маршрут.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/301.json b/editorial/agent-rewrites/301.json new file mode 100644 index 0000000..b4bceaa --- /dev/null +++ b/editorial/agent-rewrites/301.json @@ -0,0 +1,7 @@ +{ + "index": 301, + "slug": "editorial-2019-08-field-frontend-performance", + "title": "Пустой первый экран при быстром HTML: как найти задержку в цепочке загрузки", + "excerpt": "Сервер отвечает быстро, но пользователь видит пустой каркас. Разбираем, как отделить позднее обнаружение ресурса, работу JavaScript, CSS и decode изображения и проверить исправление без выдуманных production-цифр.", + "contentHtml": "

Сервер отвечает за 180 мс, HTML уже пришёл, но пользователь всё ещё видит фон и пустой каркас. Карточка товара появляется позже: вместе с названием, ценой и изображением. Команда сжимает hero, уменьшает бандл или ставит preload, но экран почти не меняется. Цена ошибки — не только потерянные миллисекунды. Каждый случай запускает новую случайную правку, усложняет загрузочный путь и оставляет следующему разработчику неверную причину.

\n

Тезис простой: быстрый ответ HTML не равен быстрому полезному экрану. Нужно найти первую зависимость, которая удерживает именно этот экран. Ресурс может поздно обнаружиться, ждать CSS, ждать свободный main thread, пройти decode или быть скрыт классом загрузки. Эти причины похожи в браузере, но требуют разных проверок и действий.

\n

Сначала определите полезный экран

\n

Назовите результат до открытия DevTools. В учебном примере полезный экран содержит название товара, цену и hero рядом с кнопкой заказа. Spinner и пустой skeleton не считаются результатом: они показывают состояние приложения, но не дают пользователю нужной информации. Зафиксируйте DOM-признаки: видимый [data-product-card], непустой [data-product-title], цену и готовое изображение.

\n

Зафиксируйте один URL, viewport, вариант сборки и режим кэша. Повторите Reload и сохраните Network, screenshot и запись Performance. Лабораторные числа ниже — учебный пример. Они не описывают реальный сайт, устройство или production-выборку.

\n

Механизм: экран ждёт зависимые границы

\n

Браузер получает документ, строит DOM, находит стили и скрипты, выполняет JavaScript, рассчитывает геометрию, декодирует изображения и рисует кадр. Часть работы идёт параллельно. Но полезный элемент появляется только после своих зависимостей. Если URL hero появляется после запуска приложения, сжатие готового файла не исправит позднее обнаружение. Если запрос заканчивается рано, но main thread занят синхронным bootstrap, сеть не является первой причиной. Если изображение готово, но класс is-loading скрывает карточку, ищите условие рендера.

\n

Разделите путь на владельцев: документ и сеть, CSS, JavaScript на main thread, DOM и layout, изображение от запроса до paint. Не суммируйте полосы автоматически. Они могут перекрываться. Ищите разрыв между фактом «ресурс готов» и фактом «полезный элемент виден».

\n
ПолосаДанные учебного профиляМожно утверждатьНельзя утверждать
Документ и сеть180 мс, 18 КБОтвет документа выделен отдельной границейЧто сервер любого сайта отвечает за 180 мс
JavaScript165 мс, 96 КБЕсть отдельная работа parse и executeЧто любой такой bundle блокирует ровно 165 мс
CSS90 мс, 24 КБСтили имеют собственный этап готовностиЧто stylesheet всегда блокирует весь интервал
Hero45 мс, 72 КБУ изображения есть путь request, decode и paintЧто responseEnd равен моменту видимости
\n

Таблица задаёт язык, но не даёт диагноза. Фраза «hero поздний» должна означать конкретный факт: запрос стартовал после app.js, decode закончился после нужного кадра или изображение готово, но DOM его скрывает.

\n

Наблюдаемый пример: отделяем ресурс от рендера

\n

Проверка выполняется после Reload в локальном учебном профиле. Она помогает увидеть состояние, но не заменяет trace. Поздняя ручная проверка не доказывает, что состояние было таким во время первого paint.

\n
const hero = document.querySelector('[data-product-hero]');\nconst card = document.querySelector('[data-product-card]');\nconst css = document.querySelector('link[href*=app.css]');\nconsole.table({ heroSrc: hero?.currentSrc || '', heroComplete: hero?.complete || false, heroNaturalWidth: hero?.naturalWidth || 0, stylesheetReady: Boolean(css?.sheet), cardHidden: card?.classList.contains('is-loading') || false });\nperformance.mark('product-card-visible');
\n

img.complete показывает состояние загрузки, но не доказывает, что пользователь увидел изображение. naturalWidth помогает отличить готовое изображение от элемента без декодированных данных. css.sheet не сообщает стоимость layout. User Timing ставит именованную границу, которую нужно сопоставить с trace.

\n

Если heroSrc пуст, ищите источник URL: HTML, состояние приложения, CSS или API. Если URL появился после bootstrap, причина — позднее обнаружение. Если URL был в HTML и запрос начался рано, смотрите main thread и paint. Если изображение готово, а cardHidden равен true, проверяйте переход состояния и отрицательный путь.

\n

Симптомы и минимальные проверки

\n
СимптомПричинаПроверкаДействие
Hero начинается после app.jsURL создаёт приложениеСравнить initiator и момент появления URLПередать критичный URL в HTML или изменить контракт данных; preload применять только после подтверждения
Hero готов, но экран ждёт длинный scriptingBootstrap выполняет некритичную работуОткрыть Bottom-up и Call Tree до screenshotОтложить второстепенный виджет, разбить синхронную работу, оставить fallback
CSS завершается поздноСтиль найден поздно или конкурирует за сетьПроверить link, @import и traceУбрать import-цепочку после проверки визуального результата
Изображение готово, но виден skeletonDOM или класс состояния скрывает карточкуСопоставить атрибуты, переход состояния и screenshotРазделить готовность данных и рекомендаций; не скрывать цену и заказ
Сеть быстрая, кадр позднийDecode, layout или paint ждут main threadСопоставить responseEnd, decode, layout и кадрСократить лишние чтения и записи layout; проверить размер изображения
\n

Как читать запись без догадок

\n

Начинайте с screenshot и DOM, а не с самого большого файла. От полезного элемента идите назад: какой URL, стиль, состояние и код нужны, чтобы он стал видимым. Затем идите от документа вперёд: когда стартовали CSS, app.js и hero. Так вес ресурса не подменяет его место в критическом пути.

\n

В Chrome DevTools откройте Performance и запишите один Reload. В Network проверьте initiator hero и порядок запросов. В main thread найдите длинные задачи до screenshot. При наличии source map сопоставьте функцию с исходным модулем. При отсутствии source map не придумывайте имя компонента: запишите скомпилированный ресурс и отдельный вопрос на восстановление соответствия.

\n

Сделайте один обратимый эксперимент. Например, отключите второстепенный виджет локальным флагом и повторите тот же Reload. Если screenshot сдвинулся, измерьте исчезнувшую работу и проверьте карточку без виджета. Если экран не изменился, верните эксперимент и исключите гипотезу. Не удаляйте половину bootstrap и не называйте результат оптимизацией без контрольного сравнения.

\n
Диагностика первого экрана: сеть, JavaScript, CSS, decode и paint
Диагностика начинается с полезного screenshot. Каждая ветвь получает свою проверку. Иллюстрация показывает учебную модель и не является trace конкретного продукта.
\n

Учебный фикс с отрицательным путём

\n

Предположим, trace подтвердил: рекомендации синхронно строятся до карточки, хотя цена и заказ от них не зависят. Учебный вариант переносит рекомендации после показа основной карточки. Он не заявляет производственный результат и не задаёт универсальный API.

\n
showProductCard({ title, price, hero });\nperformance.mark('product-card-visible');\nloadRecommendationsLater().then(renderRecommendations).catch(() => { renderRecommendationsFallback(); });
\n

После изменения проверяют не только исчезновение scripting. Карточка должна показать цену, кнопку и изображение без рекомендаций. Ошибка второстепенного запроса не должна скрыть основной товар. Если перенос меняет порядок аналитики, focus, доступность или layout, это новый контракт. Его проверяют на медленной сети и при отказе запроса.

\n

Порядок действий

\n
  1. Опишите полезный первый экран и проверяемые DOM-признаки.
  2. Зафиксируйте URL, viewport, сборку, кэш и повторяемый Reload.
  3. Сохраните Network, screenshot и Performance trace до изменения.
  4. Проверьте момент обнаружения CSS, JavaScript и hero, затем main thread, decode, layout и paint.
  5. Назовите один разрыв: позднее обнаружение, scripting, стиль, состояние DOM или изображение.
  6. Проведите один узкий обратимый эксперимент с ожидаемым сдвигом.
  7. Повторите сценарий и проверьте полезный экран, fallback, ошибку и визуальную стабильность.
  8. Запишите лабораторный вывод с условиями. Полевые числа добавляйте только после отдельного сбора данных.
\n

Ограничения и критерий готовности

\n

Учебные значения не являются измерением production. Один trace не описывает все устройства, сети и состояния кэша. Размер бандла не равен времени блокировки, responseEnd не равен paint, а пользовательская метрика не появляется от одного performance.mark. Если причина не доказана, честный результат — список исключённых гипотез и следующий точный вопрос.

\n

Правка готова, когда один сценарий повторяется до и после изменения, полезный экран проходит DOM- и screenshot-критерий, подтверждена конкретная причина, а отрицательный путь не скрывает основную функцию. В отчёте есть условия, trace, изменённая граница и предел вывода. Формулировка «в лабораторном Reload карточка стала видимой раньше после переноса второстепенной работы» проверяема. Формулировка «всем пользователям стало быстрее» требует другой выборки.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/302.json b/editorial/agent-rewrites/302.json new file mode 100644 index 0000000..f327e6e --- /dev/null +++ b/editorial/agent-rewrites/302.json @@ -0,0 +1,7 @@ +{ + "index": 302, + "slug": "editorial-2019-08-mechanism-frontend-performance", + "title": "Почему быстрый HTML не гарантирует быстрый экран", + "excerpt": "Первый байт, запрос ресурса и видимый экран — разные границы. Разбираем критический путь первой загрузки, находим владельца задержки и проверяем исправление без выдуманных production-результатов.", + "contentHtml": "

Сервер отвечает быстро: время до первого байта выглядит небольшим, HTML весит немного, а в Network нет огромных файлов. Но пользователь видит пустой фон, skeleton или неактивную кнопку. Иногда hero-картинка уже завершила загрузку, а на экране её всё ещё нет. Цена ошибки — недели случайных оптимизаций: сжимают изображения, меняют кеш и режут bundle, хотя задержка находится в другой границе.

\n

Тезис простой: быстрый ответ origin не равен быстрому полезному экрану. Браузер проходит цепочку зависимостей. Он получает и разбирает HTML, обнаруживает CSS и скрипты, строит DOM, выполняет код, считает layout, декодирует изображение и только потом рисует результат. Запросы могут идти параллельно. Зависимости между ними — нет. Критический путь — это не рейтинг самых больших файлов, а путь ресурсов и работ, без которых выбранный экран не может стать полезным.

\n

Механизм задержки

\n

HTML приходит потоком. Парсер может обнаружить <link>, <script> и <img> до конца документа. Поэтому место URL влияет на время старта запроса. Если адрес hero появляется только после выполнения приложения, браузер не мог начать загрузку раньше. Если внешний CSS скрыт за динамическим импортом, разметка есть, но правила для расчёта и отображения приходят поздно.

\n

У JavaScript есть две разные задержки. Первая — ожидание обнаружения, передачи и декодирования файла. Вторая — работа главного потока: parse, compile, execute, создание DOM и последующие style/layout. Маленький по gzip скрипт может надолго занять main thread. Большой файл может не удерживать первый экран, если его загрузили после первичного рендера. Размер помогает найти гипотезу, но не доказывает причину.

\n

CSS тоже не является украшением после HTML. Он задаёт размеры, видимость и раскладку элементов. Класс is-loading, который снимается только после bootstrap, превращает готовую разметку в пустой экран. Изображение имеет ещё несколько границ после responseEnd: decode, доступный main thread, layout и paint. Поэтому завершение запроса не означает момент видимости.

\n

Минимальный пример

\n

Ниже учебный фрагмент. Он показывает, как сделать критические зависимости наблюдаемыми в начальном документе. Он не обещает одинакового результата на разных устройствах и не заменяет профиль реальной страницы.

\n
<link rel=\"stylesheet\" href=\"/assets/app.css\">\n\n<main class=\"product-page\">\n  <h1>Название товара</h1>\n  <p class=\"price\">1 990 ₽</p>\n  <img\n    src=\"/assets/hero-960.webp\"\n    width=\"960\"\n    height=\"640\"\n    alt=\"Товар на нейтральном фоне\"\n  >\n  <button type=\"button\">Купить</button>\n</main>\n\n<script type=\"module\" src=\"/assets/app.js\"></script>
\n

Здесь браузер видит stylesheet и изображение без запуска приложения. Размеры картинки заранее известны, поэтому layout не обязан ждать её пикселей, чтобы зарезервировать место. Это не повод добавлять preload ко всем изображениям. Предзагрузка оправдана только для доказанно критического ресурса. Иначе она конкурирует с HTML, CSS и скриптом. Hero нельзя механически помечать loading=\"lazy\": lazy loading намеренно откладывает запрос.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
HTML готов, hero стартует после app.jsURL изображения создаёт JavaScriptСравнить startTime hero и момент обнаружения app.js в waterfallВывести критический URL в HTML или доказать, что hero не относится к первому экрану
Ресурс пришёл, экран пуст до конца bootstrapКласс загрузки или каркас снимается кодомОткрыть DOM и trace main thread, найти условие скрытияОтрисовать безопасный каркас раньше, второстепенную инициализацию отложить
app.js пришёл быстро, но paint позднийДлинный scripting, лишний DOM или повторный layoutНайти длинную задачу и функцию-владельца в Performance traceУбрать работу с критического пути, разделить чтение и запись DOM, повторить профиль
CSS завершён после разметкиПозднее обнаружение, import-цепочка или конкуренция запросовСопоставить startTime и responseEnd stylesheet с первым полезным кадромСократить цепочку и конкуренцию; не переносить весь CSS inline без проверки
responseEnd картинки ранний, изображение видно поздноDecode, занятый main thread, размер контейнера или paintСверить screenshot, trace, размеры элемента и текущий источник картинкиУменьшить подходящий вариант изображения или освободить поток после доказательства причины
LCP улучшился в лаборатории, интерфейс не стал полезнееВыбранный элемент не отражает задачу пользователяНазвать полезную интеракцию и проверить её отдельно от одной метрикиОставить LCP диагностикой, а готовность определить через видимый контент и действие
\n

Как читать измерения

\n

PerformanceNavigationTiming описывает навигацию документа. PerformanceResourceTiming показывает отдельные ресурсы. Эти API отвечают на вопрос «когда произошло событие», но не строят полный граф причин. Высокий responseEnd изображения не доказывает, что оно удерживало экран. Длинная запись ресурса не доказывает дорогой JavaScript. Запись нужно сопоставлять с DOM, waterfall и дорожкой main thread.

\n
performance.mark('catalog: render-start');\nrenderCatalog(shellData);\nperformance.mark('catalog: render-end');\nperformance.measure(\n  'catalog: initial-render',\n  'catalog: render-start',\n  'catalog: render-end',\n);\n\nconsole.table(\n  performance.getEntriesByName('catalog: initial-render')\n    .map(({ duration }) => ({ duration: Math.round(duration) })),\n);
\n

Это учебный пример User Timing. Он измеряет только участок, который обрамляют две метки. Он не измеряет сеть, не заменяет trace и не превращает одну локальную запись в production-вывод. На реальном проекте имя операции должно соответствовать фактическому участку, а сбор таких меток должен учитывать приватность и объём данных.

\n
\"Критическая
Существующий asset показывает зависимые границы критического пути. Параллельная загрузка сокращает ожидание, но не отменяет зависимость между обнаружением, готовностью стилей, свободным main thread и paint.
\n

Порядок расследования

\n
  1. Назвать полезный первый экран. Записать конкретный результат: например, заголовок, цену и доступную кнопку, а не просто исчезнувший spinner.
  2. Выбрать один удерживаемый элемент или интеракцию и пройти от него назад к HTML, CSS, JavaScript, изображению, шрифту и данным.
  3. Проверить исходный документ. Установить, когда браузер впервые увидел каждый критический URL и не создаётся ли он только приложением.
  4. Открыть waterfall и отметить startTime, responseEnd и конкуренцию. Отдельно отметить CSS, скрипт и ресурс элемента.
  5. Открыть Performance trace. Найти scripting, style, layout и paint между готовностью ресурса и появлением полезного кадра.
  6. Сделать одно обратимое изменение на самой ранней разорванной границе. Затем повторить тот же URL, viewport, сеть и состояние кеша.
  7. Если результат не изменился, вернуть гипотезу в список и проверить следующую границу. Не оставлять оптимизацию только потому, что она выглядит правдоподобно.
\n

Ограничения и отрицательный путь

\n

Одинаковый код даёт разные трассы на разных браузерах, устройствах, сетях и состояниях кеша. Лабораторный запуск показывает механизм, но не описывает полевую аудиторию. Один тёплый запуск не задаёт бюджет релиза. LCP полезен для видимости крупного элемента, но не сообщает, стала ли готова нужная пользователю операция. Navigation Timing и Resource Timing не содержат полного объяснения работы рендера.

\n

Иногда критический ресурс действительно нельзя вынести в HTML: его адрес зависит от ответа сервера, прав или варианта эксперимента. Тогда не надо подменять ограничение фиктивным preload. Зафиксируйте зависимость, измерьте её отдельно и определите допустимый fallback. Если изображение не является частью первого экрана, поздний запрос не является дефектом. Если после изменения waterfall не сдвинулся и main thread остался тем же, фикс не доказан.

\n

Проверяемый критерий готовности

\n

Расследование завершено, когда названы полезный экран и граница-владелец задержки, приложен профиль с теми же условиями, а изменение сдвинуло именно эту границу. Повторный запуск должен показать тот же или лучший момент появления выбранного результата без регрессии доступности и функциональности. Для production-решения отдельно нужны полевые данные и согласованный бюджет. Без такого доказательства статья о «быстром HTML» остаётся догадкой.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/303.json b/editorial/agent-rewrites/303.json new file mode 100644 index 0000000..baf997c --- /dev/null +++ b/editorial/agent-rewrites/303.json @@ -0,0 +1,7 @@ +{ + "index": 303, + "slug": "editorial-2019-08-practice-frontend-performance", + "title": "Первая загрузка: как найти владельца задержки", + "excerpt": "Пустой первый экран не объясняется словом «тяжёлая страница». Разбираем сеть, CSS, JavaScript и изображения по отдельным проверкам и выбираем одно изменение с воспроизводимым критерием.", + "contentHtml": "

Симптом знаком: сервер отвечает быстро, но пользователь видит белый экран или неподвижную карточку. В Network нет огромного файла, а кнопка появляется после долгой паузы. Команда уменьшает бандл, добавляет кэш или меняет формат картинок. Следующий запуск выглядит почти так же. Ошибка в диагнозе: слово «медленно» скрывает несколько разных задержек. Цена ошибки — релиз без доказанного эффекта, лишняя сложность в критическом пути и потерянное время пользователя до первого полезного действия.

\n

Тезис простой: первую загрузку нужно измерять по владельцам работы, а не по одному событию load и не по размеру gzip-файла. В одном воспроизводимом сценарии отдельно проверяют документ и сеть, обнаружение ресурсов, CSS, JavaScript на главном потоке, декодирование изображения и paint. Эти работы могут идти параллельно. Их нельзя бездумно сложить в одну сумму. Профиль нужен не для красивого числа, а для следующего проверяемого вопроса.

\n

Сначала определяем полезный экран

\n

«Страница загрузилась» — слабое условие. Для диагностики нужно назвать результат глазами пользователя: например, «на экране видны заголовок товара, цена и кнопка заказа». Такой результат имеет границу. Он может наступить до load, если второстепенные изображения ещё загружаются, и после DOMContentLoaded, если приложение только начинает строить DOM.

\n

Navigation Timing описывает путь текущего документа: ответ, построение DOM, обработчики DOMContentLoaded и load. Resource Timing описывает отдельные ресурсы. Ни один из этих интерфейсов сам по себе не говорит, что конкретный блок уже виден и готов к действию. Для этого нужны screenshot в trace, DOM и участок main thread рядом с моментом появления блока.

\n

Современный термин LCP удобен как словарь для крупного элемента в viewport, но он не заменяет старый trace и не даёт права придумывать значение метрики. Если замера нет, нужно писать «проверяем момент появления hero» и сохранять условия запуска. Лабораторный профиль отвечает на вопрос о выбранном сценарии. Он не описывает распределение опыта всех пользователей.

\n

Разводим задержку по механизмам

\n

Документ может прийти рано, но скрыть критический URL за JavaScript. CSS может загрузиться, но затем длинный layout задержит отрисовку. JavaScript может быть маленьким в передаче, но занять главный поток на parse, compile и execute. Изображение может завершить передачу, но ждать decode, стилей или свободного main thread. Одинаковый симптом требует разных действий.

\n
СимптомПричинаПроверкаДействие
HTML быстро пришёл, экран пустойКритический CSS, DOM или изображение обнаруживается поздноСравнить responseStart документа, первый screenshot и старт ресурсовВывести минимальный каркас и критический URL в начальный HTML либо найти блокирующую работу
Длинный scripting после app.jsСинхронная инициализация, большой список или сторонний кодОткрыть main-thread trace и привязать участок к функцииОтложить второстепенный путь, сократить работу до первого экрана и повторить профиль
Hero скачан, но не виденDecode, style, layout или paint ждут занятый главный потокСопоставить URL с screenshot, decode и paintПроверить реальные пиксели и момент отрисовки; менять формат только при подтверждённой цене decode
Водопад долго не затихаетПосле первого экрана идут независимые второстепенные ресурсыОтметить, какие запросы нужны выбранному экрануНе оптимизировать idle как замену полезности; отложить запросы, которые не помогают действию
\n

Таблица задаёт порядок расследования. Она не обещает, что одна причина окажется единственной. Важна связь между наблюдением и проверкой. Если действие не меняет именно участок задержки, оно не подтверждает гипотезу.

\n

Фиксируем условия до Reload

\n

Запишите URL, действие пользователя, viewport, состояние кэша, Service Worker, ограничение CPU и сети, версию сборки и определение полезного экрана. Не смешивайте в одном сравнении холодный и тёплый кэш. Не меняйте одновременно URL, сборку и throttling. Иначе новый профиль нельзя будет честно сравнить со старым.

\n

Одного локального запуска достаточно для поиска участка работы, но недостаточно для утверждения о production. Расширение viewport, реальные устройства, фоновые вкладки и сеть меняют результат. После локального эксперимента нужен отдельный полевой источник, если команда принимает решение о приоритете по пользовательскому эффекту. Не выдавайте один trace за статистику.

\n
const navigation = performance.getEntriesByType('navigation')[0];\nconst resources = performance.getEntriesByType('resource').map((entry) => ({\n  path: new URL(entry.name).pathname,\n  initiator: entry.initiatorType,\n  duration: Math.round(entry.duration),\n  transferSize: entry.transferSize,\n  encodedBodySize: entry.encodedBodySize,\n}));\n\nconsole.table({\n  responseStart: Math.round(navigation?.responseStart ?? 0),\n  domInteractive: Math.round(navigation?.domInteractive ?? 0),\n  domContentLoaded: Math.round(navigation?.domContentLoadedEventEnd ?? 0),\n});\nconsole.table(resources);
\n

Код — учебный пример для текущего документа. Он не измеряет время выполнения JavaScript, CSSOM, layout или paint. Он помогает увидеть границы Navigation Timing и ресурсы, доступные браузеру. Поля размера и сетевых этапов могут быть ограничены для другого origin без Timing-Allow-Origin. Переиспользованное соединение и политика доступа также объясняют нулевые отдельные значения. Ноль не доказывает отсутствие сети.

\n

В общий лог нельзя бездумно отправлять полный URL: query-параметры могут содержать идентификаторы и пользовательские данные. Для отчёта оставьте путь, тип инициатора, округлённые значения, хэш сборки и условия. Сохраните screenshot и ссылку на участок trace. Результат должен позволить другому инженеру проверить тот же вопрос, а не только поверить выводу.

\n

Сеть отвечает только за свой участок

\n

У сетевой задержки есть обнаружение, очередь, соединение, запрос и передача. Размер файла влияет на передачу, но не объясняет поздний старт. Если hero появляется только после boot-кода, сжатие картинки не устранит задержку обнаружения. Если документ ждёт redirect, правка JavaScript не сократит этот переход. Сначала найдите URL на waterfall и сравните его старт с моментом чтения HTML.

\n

Критический ресурс должен быть виден там, где браузер может его обнаружить. Для hero это может быть обычный img в начальной разметке. Для стилей — прямой link. preload применяйте только к ресурсу, который доказанно нужен первому экрану. Лишний preload создаёт конкурента для документа или CSS. loading=\"lazy\" у первого значимого изображения также нельзя ставить по привычке: атрибут намеренно откладывает запрос.

\n

JavaScript задерживает экран двумя способами

\n

Первый способ — позднее обнаружение или получение файла. Второй — работа после получения: parse, compile, execute, создание DOM и повторные проходы style/layout. В Network эти случаи могут выглядеть одинаково. В Performance trace они различаются. Длинный scripting сразу после app.js указывает на работу главного потока. Поздний старт app.js указывает на обнаружение, очередь или сеть.

\n

Учебный пример: приложение получает данные каталога, строит 500 строк и сразу измеряет каждый узел, меняя класс после каждого чтения. Такой код может быть компактным, но вызывает чередование чтения и записи и несколько layout-проходов. Без trace нельзя утверждать, что именно этот фрагмент виноват. Проверка — найти функцию в main thread, отложить строки за пределы первого экрана или сгруппировать операции, а затем повторить тот же сценарий.

\n
performance.mark('catalog:render-start');\nrenderCatalog(initialItems);\nperformance.mark('catalog:render-end');\nperformance.measure(\n  'catalog:initial-render',\n  'catalog:render-start',\n  'catalog:render-end',\n); 
\n

performance.mark и performance.measure добавляют ориентиры для собственного кода. Они не ускоряют работу и не заменяют trace. Не ставьте отметку после всех запросов, если хотите понять первый экран. Отмечайте узкий участок и проверяйте, что он действительно относится к выбранному действию.

\n

CSS и изображение заканчиваются после передачи байтов

\n

CSS участвует в визуальной готовности. Браузеру нужны правила, чтобы рассчитать размеры и положение элементов. Внешняя таблица может завершиться поздно, а большой DOM — растянуть style и layout уже после её прихода. Поэтому фраза «HTML есть» не означает «экран готов». Ищите в trace окончание CSS, style/layout и первый screenshot.

\n

У изображения есть размер ответа, декодирование, место в layout и paint. Большой desktop-файл на маленьком viewport создаёт лишнюю работу даже при быстрой сети. Но менять формат или добавлять preload стоит после связи URL с конкретным визуальным элементом. Картинка, которая не нужна до первого действия, не должна конкурировать с CSS и скриптом только потому, что она заметна в исходнике.

\n
\"Четыре
Профиль показывает самостоятельных владельцев времени. Дорожки пересекаются, поэтому их длительности не образуют готовую сумму.
\n

Иллюстрация — схема механизма, а не запись реального сайта. Она помогает не потерять decode и paint после responseEnd. Для реального вывода нужен trace с тем же URL и условиями.

\n

Порядок действий

\n
  1. Назовите полезный экран и действие, которое должно стать доступным.
  2. Зафиксируйте URL, сборку, viewport, кэш, Service Worker и ограничения CPU и сети.
  3. Снимите trace с Network, main thread и screenshot. Не меняйте код до первого профиля.
  4. Отметьте момент ответа HTML, старт критических ресурсов, длинные участки scripting, style/layout, decode и paint.
  5. Выберите одного владельца задержки. Если доказательств несколько, начните с участка, который блокирует полезный экран.
  6. Сформулируйте одно обратимое изменение. Для preload, code split, lazy loading и нового формата заранее назовите возможный отрицательный эффект.
  7. Повторите тот же сценарий после изменения и сравните только заранее выбранный критерий.
  8. Оставьте краткий отчёт: условия, симптом, причина как гипотеза, проверка, действие и результат.
\n

Отрицательный путь и ограничения

\n

Иногда ответ HTML быстрый, критические ресурсы стартуют рано, а полезный экран всё равно не появляется. Тогда не нужно повторно сжимать сеть. Проверьте, не скрывает ли приложение разметку до завершения инициализации, не падает ли обработчик, не ждёт ли UI данные, которые не нужны для каркаса, и не занял ли главный поток сторонний код. Быстрый сервер не отменяет ошибки рендера.

\n

Нельзя объявлять универсальный бюджет по одному локальному запуску. Нельзя складывать параллельные полосы trace как последовательные этапы. Нельзя считать load эквивалентом видимости, transferSize — стоимостью JavaScript, а responseEnd — моментом paint. Данные другого origin могут быть неполными. Современные метрики зависят от браузера, viewport и фактического элемента. Эти ограничения не отменяют диагностику; они задают честные границы вывода.

\n

Проверяемый критерий готовности

\n

Изменение готово к следующему этапу, если на том же URL и при тех же зафиксированных условиях повторный профиль показывает более раннее появление выбранного полезного экрана, а участок названного владельца уменьшился или исчез. При этом не появился новый блокирующий участок: например, scripting не просто сменился на поздний запрос, а hero не стал видимым ценой пустого каркаса. Если критерий не выполнен, верните изменение или сформулируйте новую гипотезу. «Стало быстрее» без привязанного участка и условия ничего не доказывает.

\n

Так первая загрузка превращается из жалобы в короткий эксперимент. Вы не угадываете виновника по размеру файла. Вы фиксируете симптом, находите границу механизма, меняете один критический путь и проверяете тот же экран повторно.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/304.json b/editorial/agent-rewrites/304.json new file mode 100644 index 0000000..70f8552 --- /dev/null +++ b/editorial/agent-rewrites/304.json @@ -0,0 +1,7 @@ +{ + "index": 304, + "slug": "editorial-2019-07-field-sql-indexes", + "title": "Индекс есть, Seq Scan остался: как проверить форму запроса в PostgreSQL", + "excerpt": "Индекс на created_at не гарантирует быстрый запрос за день. Разбираем селективность, статистику, выражения и partial index через один проверяемый маршрут.", + "contentHtml": "

В таблице уже есть B-tree индекс на created_at, но выборка за один день всё равно читает таблицу целиком. Добавление второго индекса не меняет план. Запрос остаётся медленным на большой таблице, а каждая новая структура увеличивает размер базы и цену INSERT/UPDATE. Ошибка здесь стоит дороже, чем одна задержка: команда начинает подбирать индексы по названию узла, не проверив, что именно ищет запрос.

\n

Главный тезис простой: индекс помогает только тогда, когда планировщик видит подходящую форму условия, правильно оценивает долю строк и считает путь дешевле полного чтения. Поэтому сначала нужен точный SQL и план, потом проверка статистики и предиката, и только затем DDL. Seq Scan сам по себе не доказывает проблему.

\n

Что на самом деле сравнивает планировщик

\n

Планировщик строит дерево узлов. Нижние узлы получают строки из таблицы или индекса. Верхние узлы фильтруют, сортируют, соединяют и ограничивают результат. Для каждого узла PostgreSQL показывает оценку стоимости и приблизительное число строк. Стоимость — внутренняя величина планировщика, а не миллисекунды ответа.

\n

Индексный путь не бесплатен. Сначала сервер читает индекс, затем находит страницы таблицы и получает строки. Если условие возвращает большую часть таблицы, последовательное чтение может оказаться дешевле. Если запрос возвращает мало строк, индекс часто выигрывает. Решение зависит от размера таблицы, расположения страниц, кэша, сортировки, LIMIT и статистики.

\n
EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, created_at, amount\nFROM work_orders\nWHERE created_at >= timestamp '2019-07-10 00:00:00'\n  AND created_at <  timestamp '2019-07-11 00:00:00'\nORDER BY created_at DESC\nLIMIT 50;
\n

В учебном примере запрос читает один день, использует полуоткрытый интервал и возвращает первые 50 строк. Это не замер production. Реальные значения cost, rows, actual time и Buffers нужно получать на совместимом тестовом контуре с данными, близкими к рабочим.

\n

Первый подозреваемый — форма WHERE

\n

Индекс хранит значения исходного столбца. Запрос ниже вычисляет выражение для каждой строки:

\n
CREATE INDEX work_orders_created_idx\nON work_orders (created_at);\n\nSELECT id\nFROM work_orders\nWHERE created_at::date = DATE '2019-07-10';
\n

Предикат сравнивает created_at::date, а ключ индекса содержит created_at. Это разные формы. Планировщик может применить индекс на выражении, если такой индекс существует, но обычный индекс на исходной колонке не обязан подходить для этого условия.

\n

Сначала нужно определить смысл «за один день». Для timestamp without time zone можно задать границы прямо. Для timestamptz границы зависят от часового пояса бизнеса. Механическая замена cast на диапазон без выбора зоны способна вернуть записи соседнего календарного дня или пропустить часть нужных записей.

\n
-- Учебный вариант для timestamp without time zone.\nSELECT id\nFROM work_orders\nWHERE created_at >= timestamp '2019-07-10 00:00:00'\n  AND created_at <  timestamp '2019-07-11 00:00:00';\n\n-- Вариант для устойчивого query shape, если выражение менять нельзя.\nCREATE INDEX work_orders_created_date_idx\nON work_orders ((created_at::date));
\n

Диапазон сохраняет исходный ключ и обычно проще сопоставляется с B-tree. Индекс на выражении имеет другой компромисс: PostgreSQL вычисляет выражение и поддерживает его при изменениях строк. Он оправдан, если один и тот же выраженный предикат повторяется и переписать его без изменения смысла нельзя.

\n
\"Схема
Форма предиката важнее самого факта наличия индекса. Сначала сверяем смысл границ дня и текст условия, затем сравниваем планы.
\n

Симптомы, причины и проверки

\n
Диагностическая карта для запроса по дате
СимптомПричинаПроверкаДействие
Индекс есть, но Seq Scan возвращает почти всю таблицуНизкая селективностьСравнить rows и actual rows, проверить долю результатаОставить полный scan или уменьшить объём запроса; не добавлять дубликат
Оценка — сотни строк, факт — сотни тысячУстаревшая или грубая статистикаНайти первый узел расхождения и посмотреть pg_statsВыполнить целевой ANALYZE, затем повторить тот же план
created_at::date при ключе (created_at)Запрос использует выражение, индекс — исходное значениеСопоставить indexdef с Index Cond и FilterВыбрать корректный диапазон или обоснованный expression index
Partial index не участвует в запросеПланировщик не доказал его predicate для данного SQLСравнить условие индекса и запрос до подстановки параметровСделать условие доказуемым, сменить индекс или отказаться от partial index
\n

Таблица задаёт порядок проверки, но не выбирает действие автоматически. Например, частое значение state = 'ready' может возвращать 90 процентов строк. Индекс на таком значении не обязан ускорить чтение. Если экрану нужны только первые 20 записей, проверяйте сортировку и LIMIT. Если экран запрашивает весь экспорт, индекс не уменьшит объём результата.

\n

Проверяем оценку строк и статистику

\n

Ключевой сигнал — не название scan, а первое существенное расхождение между ожидаемыми и фактическими строками. Если узел ожидал 100 строк, а получил 100 000, планировщик мог выбрать неподходящий путь из-за неверной оценки. Причины включают изменения распределения, недостаточную выборку и зависимость между столбцами.

\n
SELECT schemaname, tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\n  AND tablename = 'work_orders';\n\nSELECT attname, n_distinct, most_common_vals, most_common_freqs\nFROM pg_stats\nWHERE schemaname = 'public'\n  AND tablename = 'work_orders';\n\nANALYZE work_orders;
\n

pg_indexes показывает фактическое определение индекса. pg_stats даёт читаемое представление статистики столбцов. ANALYZE обновляет входные данные планировщика, но не обещает индексный узел. После него нужно снять тот же план и сравнить строки, буферы и время.

\n

Если запрос фильтрует связанные столбцы, обычная статистика по каждому столбцу может не описывать их зависимость. Расширенную статистику стоит рассматривать только после доказанного расхождения. Создание объектов «на всякий случай» увеличивает стоимость обслуживания и затрудняет следующий диагноз.

\n

Partial index и доказуемое условие

\n

Partial index хранит только строки, которые проходят его predicate. Это полезно для небольшой устойчивой части таблицы. Например, очередь может часто читать свежие записи со статусом waiting:

\n
CREATE INDEX work_orders_waiting_created_idx\nON work_orders (created_at)\nWHERE state = 'waiting';\n\nSELECT id, created_at\nFROM work_orders\nWHERE state = 'waiting'\n  AND created_at >= timestamp '2019-07-10 00:00:00'\n  AND created_at <  timestamp '2019-07-11 00:00:00';
\n

Запрос содержит условие, которое явно включает predicate индекса. Но похожая запись не гарантирует сопоставление во всех случаях. Планировщик проверяет условие на этапе планирования и не доказывает произвольную эквивалентность всех выражений. Параметризованный запрос требует отдельной проверки: неизвестное значение параметра не всегда позволяет доказать, что state = 'waiting' истинно.

\n
PREPARE orders_by_state(text) AS\nSELECT id\nFROM work_orders\nWHERE state = $1;
\n

Из этого примера нельзя делать абсолютный вывод, что partial index никогда не используется с PREPARE. Нужно снять реальный план в режиме, который применяет клиент, и проверить конкретные параметры. Если общий query shape не доказывает predicate, partial index не должен быть единственной ставкой для маршрута.

\n

Как читать EXPLAIN ANALYZE

\n

EXPLAIN ANALYZE выполняет запрос и добавляет фактические строки, время и число повторов узла. Поэтому его нельзя запускать на изменяющем запросе только ради просмотра плана. Для учебного UPDATE нужна транзакция с ROLLBACK, а для рабочей системы — согласованный безопасный контур и оценка нагрузки.

\n

Читайте дерево снизу вверх. Сначала найдите scan, который получает строки. Затем проверьте, что попало в Index Cond, а что осталось в Filter. Условие в Index Cond ограничивает поиск по индексу. Условие в Filter проверяется после получения строк, поэтому индекс мог не уменьшить основной объём чтения.

\n

Учитывайте loops. Значения actual rows и actual time на узле обычно показываются в расчёте на один проход. Вложенный узел, который выполняется тысячи раз, может создавать основную цену даже при маленьком времени одного прохода. Buffers помогает отличить работу с уже загруженными страницами от чтения с диска, но не заменяет измерение полного запроса.

\n

Порядок действий

\n
  1. Сохраните точный SQL из реального места вызова, типы параметров, ORDER BY, LIMIT и ожидаемый объём результата.
  2. Проверьте определение индексов через pg_indexes и сопоставьте ключи с фактическим WHERE.
  3. Снимите обычный EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) на подходящем контуре.
  4. Найдите первый узел с заметным расхождением rows и actual rows. Если расхождения нет, рассмотрите Seq Scan как возможный правильный выбор.
  5. Проверьте форму предиката: cast, функцию, порядок составного ключа, границы времени и условие partial index.
  6. Если оценки устарели, выполните целевой ANALYZE и переснимите тот же план. Не совмещайте этот шаг с несколькими изменениями DDL.
  7. Сделайте одну минимальную правку: диапазон, expression index, partial index или изменение объёма результата. После этого сравните план, строки, loops, buffers и стоимость записи.
\n

Ограничения и отрицательный путь

\n

Нельзя обещать, что один индекс навсегда даст Index Scan. Данные, настройки стоимости, версия PostgreSQL, кэш и параметры запроса меняются. Один снимок плана отвечает только за конкретные входы и состояние сервера.

\n

Нельзя превращать значение cost в миллисекунды. Нельзя считать быстрым любой план с индексом. Нельзя переписывать календарную дату без явной временной зоны. Нельзя добавлять expression или partial index без оценки размера, времени построения и цены будущих изменений строк.

\n

Если после целевого ANALYZE оценка близка к факту, предикат совпадает с ключом, а результат занимает большую долю таблицы, остановитесь. В этом случае Seq Scan может быть честным и более дешёвым маршрутом. Ищите уменьшение результата, пагинацию или лишнюю работу приложения, а не ещё один индекс.

\n

Критерий готовности

\n

Диагностика закончена, когда сохранены точный SQL и параметры, определение индекса, версия PostgreSQL, план до изменения и план после него. В отчёте видно первое расхождение оценок, объяснено действие Index Cond/Filter и названа цена для записи. Если правка не дала подтверждённого улучшения или нарушила смысл даты, её не следует объявлять решением.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/305.json b/editorial/agent-rewrites/305.json new file mode 100644 index 0000000..4ff3889 --- /dev/null +++ b/editorial/agent-rewrites/305.json @@ -0,0 +1,7 @@ +{ + "index": 305, + "slug": "editorial-2019-07-mechanism-sql-indexes", + "title": "EXPLAIN ANALYZE: как понять, почему PostgreSQL выбрал этот план", + "excerpt": "Медленный SQL не всегда требует нового индекса. Разбираем дерево плана, сравниваем оценку с фактом, проверяем статистику и выбираем действие по данным, а не по названию узла.", + "contentHtml": "

Запрос к каталогу внезапно стал медленным. В тикете появляется короткий вывод: «PostgreSQL выбрал Seq Scan, нужен индекс». Команда добавляет ключ, но задержка не исчезает. Записи и обновления становятся тяжелее, диск занят больше, а причина остаётся. Цена ошибки — новый объект в базе, который постоянно поддерживают, но не сокращает работу нужного запроса.

\n

План запроса не обещает использовать индекс. Он описывает расчёт планировщика: сколько строк, страниц и операций тот ожидает увидеть на каждом пути. EXPLAIN показывает расчёт. EXPLAIN ANALYZE выполняет запрос и добавляет наблюдаемый факт. Диагноз начинается там, где эти два слоя расходятся.

\n

Механизм: план сравнивает варианты доступа

\n

Планировщик собирает дерево операторов. Нижний узел читает таблицу или индекс. Его результат получает родительский узел. Дальше сервер фильтрует строки, соединяет наборы, сортирует их или считает агрегат. Верхний узел выдаёт ответ клиенту. Медленный верхний узел не обязательно является причиной: он может ждать большой поток строк от ребёнка.

\n

Seq Scan читает таблицу последовательно. Index Scan ищет записи через индекс, а затем часто обращается к таблице за остальными колонками. Если условие возвращает большую долю строк, последовательное чтение может стоить дешевле. Если условие редкое и есть подходящий ключ, индекс может резко уменьшить объём чтения. Оба решения могут быть правильными для одной таблицы и разных параметров.

\n

Индекс хранит дополнительную структуру. INSERT и UPDATE должны поддерживать её. Индекс занимает место и может увеличивать стоимость записи. Поэтому наличие индекса отвечает только на вопрос «есть ли кандидат», но не на вопрос «выгоден ли он для этого запроса».

\n

Сначала различаем оценку и факт

\n

Обычный EXPLAIN строит план без выполнения statement. В строке cost=a..b указаны условные единицы планировщика, а не миллисекунды. rows — ожидаемое число строк на конкретном узле. width — оценка средней ширины строки.

\n

EXPLAIN ANALYZE запускает statement. Он добавляет actual time, actual rows и loops. Фактические значения времени и строк на узле усредняются по одному запуску. Если внутренний узел получил loops=10000, его малое время нельзя читать как единственный вызов. Сначала учитывают число повторов, затем смотрят, сколько буферов затронуто.

\n

Опция BUFFERS показывает работу с буферами PostgreSQL. Она помогает отличить большое чтение страниц от повторного CPU-фильтра, но не является чистым замером диска и не заменяет профиль приложения. План нужно сохранять вместе с SQL, параметрами, версией PostgreSQL и настройками стоимости. Иначе сравнение до и после легко выдаёт ложный вывод.

\n
Первый проход по строкам EXPLAIN ANALYZE
СимптомПричинаПроверкаДействие
Seq Scan при частом значенииПредикат оставляет большую часть таблицыСравнить actual rows с размером таблицы и повторить запрос с редким значениемНе форсировать индекс; проверить объём результата и форму запроса
rows сильно меньше actual rowsСтатистика устарела или не описывает распределениеСнять план с фактами, посмотреть pg_stats, проверить момент последнего анализаОбновить статистику и повторить тот же план
Есть индекс, но условие содержит функциюИндекс хранит исходное значение, а запрос вычисляет другоеСопоставить ключ индекса с выражением в WHEREПереписать условие диапазоном или обоснованно создать expression index
Есть Index Cond, но фильтр удаляет почти всёИндекс сузил доступ недостаточно, отбор произошёл поздноСравнить строки после доступа и после FilterПроверить составной ключ, порядок колонок и альтернативный запрос
Внутренний узел быстрый, но loops великNested loop повторяет работу тысячи разУмножить вклад узла на число запусков и посмотреть BUFFERSИсследовать порядок соединения и объём промежуточного набора
\n

Учебный пример: один ключ, два результата

\n

Следующий пример предназначен только для отдельной лабораторной базы. Он не содержит production-измерений и не задаёт ожидаемых миллисекунд. Таблица имеет миллион строк: статус ready встречается часто, а waiting — редко. Оба запроса используют один и тот же индекс. Разный план объясняется долей результата, а не тем, что индекс «сломался».

\n
CREATE TABLE work_orders (\n  id bigint PRIMARY KEY,\n  state text NOT NULL,\n  created_at timestamp NOT NULL,\n  amount integer NOT NULL\n);\n\nCREATE INDEX work_orders_state_idx\n  ON work_orders (state);\n\nANALYZE work_orders;\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'ready';
\n

Сначала смотрите, сколько строк реально возвращает каждый запрос. Для редкого waiting индекс может сократить чтение. Для частого ready серверу может быть дешевле пройти таблицу один раз. Не переносите результат этой фикстуры на свой сервер: ширина строк, кэш, физический порядок данных, настройки стоимости и версия меняют расчёт.

\n

Если планировщик ожидал десять строк, а получил сто тысяч, ошибка оценки может повлиять и на последующие JOIN. Nested loop, выбранный для маленького набора, становится дорогим при большом фактическом наборе. Ищите первый узел снизу, где rows перестал быть похож на actual rows. Не исправляйте каждый верхний узел по очереди.

\n

Статистика и форма предиката

\n

Планировщик не пересчитывает точную долю значений перед каждым SELECT. Он использует статистику, которую собирает ANALYZE. После массовой загрузки или изменения распределения статусов оценка может отстать от данных. Проверьте её отдельно:

\n
SELECT attname, n_distinct, most_common_vals, most_common_freqs\nFROM pg_stats\nWHERE schemaname = 'public'\n  AND tablename = 'work_orders'\n  AND attname IN ('state', 'created_at');\n\nANALYZE work_orders;
\n

ANALYZE не обещает сменить Seq Scan на Index Scan. Он обновляет вход для следующего расчёта. Если оценка и факт после этого сблизились, а последовательное чтение осталось, это нормальный результат. Индекс может быть невыгоден для массового ответа.

\n

Форма условия тоже важна. Индекс на created_at и условие created_at::date = DATE '2019-07-10' не являются одной и той же операцией доступа. Без подходящего expression index планировщик может не использовать обычный ключ так, как ожидает автор запроса. Для диапазона можно проверить эквивалентную по смыслу форму:

\n
SELECT id, amount\nFROM work_orders\nWHERE created_at >= TIMESTAMP '2019-07-10 00:00:00'\n  AND created_at <  TIMESTAMP '2019-07-11 00:00:00';
\n

Это учебное преобразование требует проверки часового пояса и границ периода. Скорость не оправдывает изменение смысла даты. Если запрос всегда возвращает почти всю таблицу, новый ключ не решит проблему объёма. Тогда проверяют LIMIT, пагинацию, пакетную обработку или отдельную витрину.

\n
\"Дерево
План читается как поток данных. Сначала найдите первый узел, где оценка перестала совпадать с фактом.
\n

Осторожность с EXPLAIN ANALYZE

\n

EXPLAIN ANALYZE выполняет statement. Для SELECT это всё равно может означать тяжёлую нагрузку. Для INSERT, UPDATE и DELETE выполнение меняет данные. Учебный изменяющий запрос запускают только в разрешённом контуре и внутри транзакции с откатом:

\n
BEGIN;\n\nEXPLAIN (ANALYZE, BUFFERS)\nUPDATE work_orders\nSET amount = amount + 1\nWHERE state = 'waiting';\n\nROLLBACK;
\n

Откат защищает данные в этом примере, но не отменяет нагрузку от выполнения. Для чужой production-базы сначала согласуйте время, объём и безопасный способ измерения. Если такой запуск запрещён, используйте обычный EXPLAIN, снимок с реплики или тестовый контур. Не объявляйте неполный план доказательством причины.

\n

Порядок действий

\n
  1. Запишите точный SQL, значения параметров, цель запроса и наблюдаемый симптом: задержку, рост I/O или неверный объём результата.
  2. Снимите обычный EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS). Для изменяющего statement заранее определите транзакционную границу.
  3. Прочитайте дерево от результата к входам. Найдите узел, который создаёт большой поток строк, но не называйте его причиной без сравнения оценки и факта.
  4. Сопоставьте rows и actual rows на каждом ключевом узле. При loops > 1 учтите повторения.
  5. Разделите Index Cond и Filter. Проверьте, что именно отсекает индекс и сколько строк остаётся для поздней проверки.
  6. Проверьте свежесть статистики и форму предиката. Выполните целевой ANALYZE, если это безопасно, и повторите тот же запрос.
  7. Измените одну подтверждённую причину: предикат, статистику, состав индекса или объём работы. Не добавляйте несколько ключей одновременно.
  8. Сравните планы в тех же условиях и проверьте отрицательный путь: частое значение, пустой результат, большой диапазон и ошибку параметра.
\n

Ограничения и критерий готовности

\n

Одинаковый SQL может получить другой план после изменения данных, настроек памяти, стоимости I/O, версии PostgreSQL или параметров. cost не равен времени ответа приложения. Один запуск не описывает распределение задержек. Учебная фикстура не доказывает production-результат. Принудительное отключение enable_seqscan может показать альтернативу для исследования, но не лечит статистику и не является постоянным исправлением.

\n

Диагностика готова, когда сохранены точный запрос и контекст, подтверждён первый существенный разрыв между оценкой и фактом либо объяснена высокая доля результата, а выбранное действие повторно проверено тем же сценарием. Для изменения нужен сравнимый план до и после. Если Seq Scan остался, но он честно дешевле и возвращает нужный объём данных, готовый результат — оставить его и зафиксировать почему.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/306.json b/editorial/agent-rewrites/306.json new file mode 100644 index 0000000..9cde539 --- /dev/null +++ b/editorial/agent-rewrites/306.json @@ -0,0 +1,7 @@ +{ + "index": 306, + "slug": "editorial-2019-07-practice-sql-indexes", + "title": "Индекс есть, а запрос медленный: как читать план PostgreSQL", + "excerpt": "Индекс не обязан ускорять каждый запрос. Разбираем Seq Scan, селективность, статистику и форму условия через EXPLAIN ANALYZE, а затем выбираем действие по данным.", + "contentHtml": "

Симптом выглядит как противоречие: на orders создали индекс по state, но список заказов всё ещё отвечает медленно. В плане виден Seq Scan, поэтому команда добавляет второй индекс или запрещает последовательное чтение. Цена ошибки растёт с каждой записью: индексы занимают место, замедляют INSERT и UPDATE, а задержка запроса остаётся.

\n

Тезис простой: индекс — только один из вариантов доступа. PostgreSQL выбирает его не по факту существования, а по ожидаемой стоимости конкретного запроса на конкретных данных. Сначала сравните оценку строк с фактом, долю результата и форму предиката. После этого станет ясно, нужен ли индекс, свежая статистика, другой WHERE или правильный последовательный проход.

\n

Механизм: индекс не обещает короткий путь

\n

B-tree хранит ключи отдельно от таблицы и помогает найти часть строк по подходящему сравнению. Затем сервер часто обращается к таблице за остальными колонками и проверяет видимость строк. Если условие возвращает большую долю таблицы, точечных обращений становится много. Один последовательный проход может стоить дешевле.

\n

Поэтому Seq Scan не равен ошибке. Он означает выбранный способ прочитать таблицу. Index Scan тоже не равен ускорению: поиск может вернуть мало строк, но повториться тысячи раз внутри Nested Loop или привести к дорогому чтению heap. Смотрите на строки, циклы, буферы и время всего плана.

\n

У индекса есть и другая цена. PostgreSQL поддерживает его при изменении таблицы, а построение и хранение требуют ресурсов. Индекс по колонке с двумя значениями не становится полезным только потому, что колонка участвует в каждом запросе. Если значение встречается почти в каждой строке, такой ключ мало сокращает чтение.

\n

Учебный пример: частое и редкое значение

\n

Ниже — самостоятельный пример для отдельной учебной базы. Он создаёт перекошенное распределение: ready встречается часто, а waiting — редко. Запросы имеют одинаковую форму и используют один индекс. Числа показывают устройство примера, а не результат измерения в production. Фактический план нужно снять на своей версии PostgreSQL и со своими данными.

\n
CREATE TABLE work_orders (\n  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  state text NOT NULL,\n  amount integer NOT NULL,\n  created_at timestamp NOT NULL\n);\n\nINSERT INTO work_orders (state, amount, created_at)\nSELECT CASE WHEN n % 100 = 0 THEN 'waiting' ELSE 'ready' END,\n       100 + (n % 500),\n       timestamp '2019-07-01' + (n % 31) * interval '1 day'\nFROM generate_series(1, 1000000) AS n;\n\nCREATE INDEX work_orders_state_idx ON work_orders (state);\nANALYZE work_orders;\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount FROM work_orders WHERE state = 'ready';\n\nEXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount FROM work_orders WHERE state = 'waiting';
\n

Для ready условие оставляет почти всю таблицу. Индекс должен привести к строкам, а затем серверу всё равно придётся прочитать большую их часть. Для waiting результат меньше, поэтому индексный путь может оказаться выгоднее. Фиксированной границы вроде «индекс полезен до пяти процентов» нет. На выбор влияют размер строки, расположение страниц, кэш, LIMIT, нужные колонки, стоимость I/O и версия сервера.

\n
\"Селективность
Один индекс даёт два разных результата: редкое значение сокращает чтение, частое может сделать последовательный проход дешевле.
\n

Симптом → причина → проверка → действие

\n
Диагностика запроса по наблюдаемому плану
СимптомПричинаПроверкаДействие
Seq Scan для частого значенияУсловие возвращает большую долю таблицыСравнить rows с размером таблицы; запустить контраст для редкого значенияНе форсировать индекс; уменьшить выборку, добавить пагинацию или оставить план
Оценка сильно расходится с actual rowsСтатистика устарела или плохо описывает распределениеПосмотреть pg_stats, выполнить целевой ANALYZE и повторить планСначала обновить статистику, затем оценить дальнейшее изменение
Индекс есть, но в условии функция или castФорма предиката не совпадает с ключомСверить выражение в WHERE с определением индексаПереписать условие без потери смысла или обоснованно создать expression index
Частичный индекс не выбранПланировщик не может доказать соответствие predicateСверить predicate в pg_indexes с точным SQLСделать условие совместимым или удалить лишнюю структуру
Index Scan медленный внутри Nested LoopДешёвый одиночный поиск многократно повторяетсяПроверить actual rows, actual time и loops на узлахПроверить соединение, кардинальность и объём входа, а не добавлять индекс вслепую
\n

Как читать EXPLAIN ANALYZE

\n

Начните с верхнего узла и спускайтесь к источникам строк. В каждом важном узле сопоставьте rows=... с actual ... rows=.... Первое заметное расхождение часто объясняет последующие решения: планировщик ожидал маленький вход, получил большой и выбрал дорогой способ соединения или сортировки.

\n

cost — условные единицы планировщика, а не миллисекунды. actual time появляется при выполнении запроса. При loops > 1 узел запускался многократно, поэтому его вклад нельзя читать по одной строке. BUFFERS показывает затронутые буферы и помогает отличить чтение страниц от вычислительной работы. Не выбирайте действие по одному слову Index или Seq.

\n

EXPLAIN ANALYZE действительно запускает statement. Для SELECT это может быть тяжёлая нагрузка. Для UPDATE, DELETE и INSERT это ещё и изменение данных. Такие проверки проводят в безопасном контуре или в транзакции с откатом, если это разрешено условиями эксперимента. Обычный EXPLAIN подходит для первого безопасного снимка выбранного плана.

\n
EXPLAIN (ANALYZE, BUFFERS)\nSELECT id, amount\nFROM work_orders\nWHERE state = 'waiting';
\n

Сохраняйте SQL, параметры и версию сервера рядом с планом. План зависит от данных, статистики и настроек стоимости. Даже повторный запуск ANALYZE может слегка изменить оценки: статистика строится по выборке, а не по полному пересчёту каждой строки.

\n

Статистика и форма условия

\n

Планировщик не пересчитывает точное распределение строк перед каждым запросом. Он использует статистику таблицы. После массовой загрузки или смены значений оценка может устареть. Проверьте её точечно:

\n
SELECT attname, n_distinct, most_common_vals, most_common_freqs\nFROM pg_stats\nWHERE schemaname = 'public'\n  AND tablename = 'work_orders'\n  AND attname = 'state';\n\nANALYZE work_orders;\n\nSELECT indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\n  AND tablename = 'work_orders';
\n

ANALYZE не обязан заменить Seq Scan на Index Scan. Его задача — дать планировщику более свежую основу для оценки. Если после обновления оценка стала близка к факту, а последовательное чтение осталось, это может быть правильный результат. Исправлять здесь нечего.

\n

Отдельно проверьте форму условия. Индекс по created_at не равен индексу по date(created_at). Условие с функцией, неявным приведением типов или другим оператором может не дать ожидаемый путь. Для временного диапазона часто понятнее использовать полуоткрытые границы:

\n
SELECT id\nFROM orders\nWHERE created_at >= timestamp '2019-07-10 00:00:00'\n  AND created_at <  timestamp '2019-07-11 00:00:00';
\n

Это учебная форма запроса. В рабочей системе границы должны соответствовать типу времени и бизнес-часовому поясу. Нельзя менять смысл фильтра ради красивого плана.

\n

Порядок действий

\n
  1. Запишите точный SQL, реальные параметры, нужный объём результата и симптом: задержку, чтение буферов или рост нагрузки.
  2. Снимите обычный EXPLAIN, затем безопасный EXPLAIN (ANALYZE, BUFFERS) с теми же параметрами. Сохраните версию PostgreSQL и важные настройки стоимости.
  3. Прочитайте дерево сверху вниз. Сверьте estimated rows, actual rows, loops и buffers. Отметьте первый узел, где оценка перестала быть правдоподобной.
  4. Проверьте долю результата. Частое значение, широкий диапазон и выборка без LIMIT могут честно требовать последовательного чтения.
  5. Проверьте статистику через pg_stats, выполните целевой ANALYZE и повторите тот же запрос. Меняйте одну гипотезу за раз.
  6. Сверьте ключ индекса, оператор, cast, функцию, partial predicate, сортировку и список возвращаемых колонок. Только теперь выбирайте переписывание запроса, другой индекс или отсутствие изменения.
  7. Повторите замер на целевом контуре и проверьте цену решения для записи, диска и соседних запросов.
\n

Ограничения и отрицательный путь

\n

Одинаковый SQL может получить другой план после изменения данных, кэша, настроек стоимости, версии сервера или параметров. Учебная таблица не доказывает production-эффект. Фактические миллисекунды из одного запуска не становятся SLA. Их нельзя обещать без повторяемого сценария и контекста.

\n

Не отключайте enable_seqscan как постоянный ремонт. Временное отключение может показать альтернативный план для диагностики, но не делает данные селективнее и может ухудшить другие запросы. Не создавайте partial или expression index только потому, что название выглядит точным. Индекс имеет стоимость построения, хранения и поддержания.

\n

Иногда итог расследования отрицательный: новый индекс не нужен. Если условие выбирает почти всю таблицу, а оценка близка к факту, Seq Scan может быть оптимальным. Тогда улучшайте границу выборки, добавляйте пагинацию или меняйте способ выдачи данных. Не маскируйте большой результат индексом.

\n

Проверяемый критерий готовности

\n

Работа готова, когда сохранены два плана с одинаковым SQL и параметрами, названа первая подтверждённая причина, а действие повторно проверено на целевом контуре. В отчёте должны быть estimated rows, actual rows, loops, buffers, версия PostgreSQL и размер данных. Если индекс оставили, указана его цена для записи и диска. Если оставили Seq Scan, объяснено, почему он дешевле. Такой результат можно проверить снова, а не защищать по скриншоту.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/307.json b/editorial/agent-rewrites/307.json new file mode 100644 index 0000000..c7cc5cc --- /dev/null +++ b/editorial/agent-rewrites/307.json @@ -0,0 +1,7 @@ +{ + "index": 307, + "slug": "editorial-2019-06-field-rest-api", + "title": "Контракт REST API: как не принять сломанный ответ за пустые данные", + "excerpt": "Если API возвращает 200 без nextCursor или 400 в формате HTML, клиент теряет границу между нормальным результатом и ошибкой. Разбираем контракт ответа, локальные проверки и честный переход к тестовому стенду.", + "contentHtml": "

Симптом часто выглядит безобидно: список заказов открывается, но кнопка «Ещё» исчезает раньше времени. В другом случае форма показывает общий баннер вместо сообщения о неверном cursor. В логах при этом есть успешный JSON и HTTP 200. Цена ошибки — потерянные записи, повторные запросы и неверное состояние интерфейса. Пользователь видит пустой экран там, где данные просто не прошли границу контракта.

\n

Причина обычно не в JSON как таковом. Клиент проверяет только то, что тело удалось распарсить. Он не проверяет статус, media type, обязательные поля и смысл специальных значений. Поэтому «ответ пришёл» ошибочно превращается в «ответ пригоден».

\n

Тезис: контракт начинается с HTTP-ответа

\n

Для каждой операции нужно описать не только набор полей, но и сочетание статуса, заголовков и тела. Возьмём учебный endpoint GET /api/v1/orders. Успешный ответ возвращает массив items и объект page. Поле page.nextCursor обязательно присутствует: строка означает, что следующую страницу можно запросить, а null означает конец списка.

\n

Некорректный cursor не должен превращаться в пустой список. Для него нужен отдельный ответ, например 400 с media type application/problem+json. Клиент сначала выбирает ветку по HTTP-статусу и типу содержимого, а затем проверяет форму тела этой ветки.

\n

Механизм проверки

\n

Разделите проверку на два шага. Сначала проверьте транспортную оболочку: статус и media type. Затем проверьте тело, которое разрешено для этого статуса. Такой порядок не даёт обработчику разобрать problem document как страницу списка и породить вторичную ошибку вроде items.map is not a function.

\n

Media type сравнивайте без случайных параметров. Заголовок application/json; charset=utf-8 имеет тот же основной тип, что и application/json, если контракт разрешает параметры. Полную строку стоит сравнивать только тогда, когда это отдельное требование протокола.

\n
function assertOrdersPage(response) {\n  assert(response.status === 200, 'ожидался HTTP 200');\n  assert(\n    mediaType(response.headers['content-type']) === 'application/json',\n    'ожидался application/json',\n  );\n  assert(Array.isArray(response.body.items), 'items должен быть массивом');\n  assert(response.body.page, 'page обязателен');\n  assert(\n    Object.prototype.hasOwnProperty.call(response.body.page, 'nextCursor'),\n    'page.nextCursor должен присутствовать',\n  );\n  assert(\n    response.body.page.nextCursor === null ||\n      typeof response.body.page.nextCursor === 'string',\n    'nextCursor должен быть строкой или null',\n  );\n}
\n

Функция принимает обычный объект response. Она не выполняет сеть и не подтверждает работу endpoint. Это локальная проверка формы ответа. Её задача — принять корректную последнюю страницу и обязательно отклонить страницу без nextCursor. Отрицательный пример нужен не для полноты отчёта, а для проверки силы самой защиты.

\n

Граница между отсутствием и null

\n

У optional-поля есть как минимум два разных состояния. Если customer отсутствует, сервер сообщает: для этого заказа поле не входит в представление. Если он возвращает null, сервер сообщает другое: поле известно, но значения нет. Интерфейс может обрабатывать эти состояния одинаково, но контракт не должен разрешать оба варианта случайно.

\n

В учебном договоре customer необязателен. При наличии он должен быть объектом с строковыми id и name. Значение null считается ошибкой. Это не универсальное правило. Если бизнес-смысл требует nullable-поля, его нужно явно описать в схеме и проверить отдельным случаем.

\n
function assertCustomer(order) {\n  const present = Object.prototype.hasOwnProperty.call(order, 'customer');\n  if (!present) return;\n\n  assert(order.customer !== null, 'customer не должен быть null');\n  assert(typeof order.customer === 'object', 'customer должен быть объектом');\n  assert(typeof order.customer.id === 'string', 'customer.id обязателен');\n  assert(typeof order.customer.name === 'string', 'customer.name обязателен');\n}
\n

Не добавляйте в клиент скрытую третью трактовку. Иначе backend может изменить сериализацию, а frontend начнёт угадывать намерение сервера. В результате один экран покажет запасной текст, другой упадёт на вложенном свойстве, а тесты останутся зелёными.

\n
\"Схема
Локальная fixture проверяет форму заранее заданного ответа. Запрос к тестовому серверу остаётся отдельным этапом.
\n

Минимальная матрица случаев

\n
СимптомПричинаПроверкаДействие
Кнопка «Ещё» пропалаВ ответе 200 нет page.nextCursorПроверить наличие ключа, а затем тип string или nullОтклонить ответ и исправить сериализацию или схему
Ошибка стала общим баннером400 пришёл не как problem documentСверить статус и media type application/problem+jsonРазвести обработчики success и error
Карточка падает на customer.idПоле стало null или имеет другой типПроверить optional-правило и обязательные поля объектаСогласовать nullable-семантику и обновить контракт
Тест пропускает плохой ответПроверка смотрит только на валидный JSONЗапустить намеренно испорченный case без nextCursorДобавить assertion до сетевой проверки
\n

Локальная fixture не заменяет HTTP-запрос

\n

Локальные response objects дают быстрый и повторяемый тест. Они показывают, что код клиента распознаёт согласованные формы и не принимает известную регрессию. Но fixture не проверяет gateway, авторизацию, права, сериализатор, задержку или фактический Content-Type сервера.

\n

После локальной проверки выполните тот же запрос на разрешённом тестовом URL. Не подставляйте в пример production-адрес, токен или cookies. Сохраните заголовки и тело без секретов. Ниже показан учебный шаблон; его результат нельзя считать заранее известным.

\n
# Только разрешённый тестовый URL и безопасная авторизация.\ncurl -sS -D /tmp/orders.headers -o /tmp/orders.json \\\n  -H 'Accept: application/json, application/problem+json' \\\n  'https://api.example.test/api/v1/orders?limit=2'\n\ngrep -Ei '^(HTTP/|content-type:)' /tmp/orders.headers\nnode -e \"const fs=require('fs'); const body=JSON.parse(fs.readFileSync('/tmp/orders.json')); console.log(body.page || body.type)\"
\n

Сначала зафиксируйте фактический status. Затем проверьте media type. Только после этого выбирайте schema success или problem. Если сервер вернул другой status, не называйте ответ пустым результатом. Если status совпал, но тело расходится со схемой, сравните required-поля, nullable-правила и версию описания API.

\n

Порядок действий

\n
  1. Опишите один наблюдаемый сбой: какой запрос выполнен, какой status пришёл и что сделал клиент.
  2. Сформулируйте допустимый success-ответ: status, media type, обязательные поля и допустимые типы.
  3. Опишите отдельную ветку управляемой ошибки, включая problem media type и стабильный код причины.
  4. Добавьте три локальных случая: валидную последнюю страницу, валидную ошибку и ответ, который обязан быть отклонён.
  5. Проверьте status и media type до чтения полей тела.
  6. Зафиксируйте правило для каждого optional-поля: отсутствие, null или оба варианта.
  7. Сверьте fixture с OpenAPI-описанием и устраните расхождение в одном согласованном источнике.
  8. Выполните запрос на тестовом стенде, сохраните проверяемые факты и не переносите его результат на production.
  9. Если данные расходятся, изменяйте схему, сервер или mapper осознанно, а не ослабляйте assertion до зелёного результата.
\n

Ограничения

\n

Такой контрактный тест не измеряет latency и не доказывает корректность пагинации при изменении данных между запросами. Он не проверяет безопасность cursor, права доступа и работу браузерного сценария. Он также не заменяет интеграционный тест с реальным gateway. Его область уже: обнаружить, что известная операция возвращает статус, тип или структуру, которую клиент не умеет безопасно трактовать.

\n

Не стоит описывать каждое неизвестное поле как ошибку. Клиент может игнорировать расширения, если контракт это разрешает. Но обязательные поля, status и media type должны иметь точное правило. Нельзя одновременно говорить «поле optional» и строить код так, будто оно всегда существует.

\n

Проверяемый критерий готовности

\n

Сценарий готов, если локальная проверка принимает согласованные success и problem cases, отклоняет известный плохой ответ с понятной причиной, а сетевой запрос на разрешённом тестовом стенде даёт сохранённые status, media type и тело для сравнения. В отчёте отдельно указано, что именно проверила fixture и что подтвердил сервер. После этого изменение контракта становится видимым событием, а не случайным падением интерфейса.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/308.json b/editorial/agent-rewrites/308.json new file mode 100644 index 0000000..79efdad --- /dev/null +++ b/editorial/agent-rewrites/308.json @@ -0,0 +1,7 @@ +{ + "index": 308, + "slug": "editorial-2019-06-mechanism-rest-api", + "title": "REST API без угадывания: как зафиксировать контракт операции", + "excerpt": "Старый клиент получает валидный JSON, но показывает пустой экран, теряет последнюю страницу или повторяет ошибку. Разбираем контракт одной REST-операции: статусы, схемы, problem details, обязательные поля и cursor-пагинацию.", + "contentHtml": "

Список заказов перестал листаться после изменения backend. URL не менялся. Клиент по-прежнему получает JSON с HTTP-статусом 200, но кнопка «Ещё» исчезает, карточка падает на customer.name, а ошибка неверного курсора выглядит как пустой список. Цена ошибки — не только один дефект интерфейса. Пользователь не понимает, сохранилось ли действие. Клиент повторяет запрос. Команда тратит время на поиск «сломанного URL», хотя разошлись смысл статуса и структура ответа.

\n

Тезис простой: REST-контракт описывает не маршрут, а наблюдаемый результат операции. В нём есть метод, параметры, допустимые статусы, Content-Type, форма тела, обязательность полей и правило для повторной попытки. Если записать только GET /api/v1/orders, клиент всё равно будет угадывать остальное. Угадывание превращает совместимость в случайность.

\n

Механизм: операция состоит из границ

\n

Разделите запрос на четыре границы. Первая — вход: какие параметры принимает сервер и что считается неверным значением. Вторая — транспортный результат: какой HTTP-статус сообщает успех, ошибку клиента или временный сбой. Третья — представление: какой тип содержимого и какие поля находятся в теле. Четвёртая — действие клиента: показать данные, исправить ввод, очистить курсор, повторить запрос или остановиться.

\n

Эти границы связаны. Статус 200 сообщает, что операция завершилась успешно, но не говорит, где лежит следующий курсор. Поле nextCursor сообщает, что страница продолжается, но не объясняет, как выглядит ошибка. Строка status внутри JSON не должна отменять HTTP-статус: прокси, кеш и библиотека клиента читают прежде всего протокол.

\n

Возьмём учебную операцию GET /api/v1/orders. Параметр limit задаёт размер страницы. Параметр cursor непрозрачен: сервер выдал строку, клиент передал её обратно и не разбирает её содержимое. Успешное тело всегда содержит items и page. Внутри page значение nextCursor равно строке, если есть следующая страница, и null, если текущая страница последняя. Отсутствие ключа не означает третий случай.

\n
GET /api/v1/orders?limit=2 HTTP/1.1\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\n  \"items\": [\n    {\n      \"id\": \"ord_1042\",\n      \"status\": \"paid\",\n      \"total\": { \"amount\": 9900, \"currency\": \"RUB\" }\n    }\n  ],\n  \"page\": { \"limit\": 2, \"nextCursor\": \"ord_1042\" }\n}
\n

Пример учебный. Он показывает форму договора и не доказывает задержку, доступность, права доступа или состав реальных заказов. Поле id, состояние заказа, сумма и объект page образуют ядро, которое клиент может читать без угадывания. Если backend добавляет необязательное поле, терпимый клиент может его проигнорировать. Если backend удаляет обязательное поле или меняет его тип, это уже несовместимое изменение для такого клиента.

\n

Статус и тело ошибки

\n

Неверный курсор — ошибка запроса, а не пустой результат. Ответ 200 с items: [] смешивает два смысла: данных действительно нет или сервер не смог понять параметр. Клиент не может безопасно выбрать действие. Для ошибки параметра используем 400 Bad Request и отдельное представление application/problem+json.

\n
HTTP/1.1 400 Bad Request\nContent-Type: application/problem+json\n\n{\n  \"type\": \"https://api.example.test/problems/invalid-cursor\",\n  \"title\": \"Параметр cursor недействителен\",\n  \"status\": 400,\n  \"detail\": \"Курсор не принадлежит этому списку заказов\",\n  \"instance\": \"/api/v1/orders?limit=2&cursor=broken\",\n  \"errors\": [\n    { \"path\": \"query.cursor\", \"code\": \"invalid_cursor\" }\n  ]\n}
\n

В RFC 7807 поля type, title, detail и instance образуют переносимую модель problem details. errors в примере — расширение конкретного API, а не универсальное поле. Клиент связывает действие с машинным ключом type или errors[].code, а не с текстом title. Текст можно локализовать и изменить без смены поведения.

\n

Поле status в problem document дублирует HTTP-статус. Поэтому сервер и клиент должны считать HTTP-статус транспортной границей, а тело — детализацией. Если посредник изменил статус, эти значения могут расходиться. Клиент не должен превращать такой конфликт в успешный ответ или в пустой список. Он должен записать диагностический контекст и применить общий путь ошибки.

\n

Симптом → причина → проверка → действие

\n
Диагностика контракта одной операции
СимптомПричинаПроверкаДействие
Кнопка «Ещё» исчезает до конца данныхПоследняя страница смешана с пустым результатом или потерян nextCursorСравнить ответы первой и последней страниц; проверить наличие ключа и значение nullЗафиксировать page.nextCursor как обязательное поле с явным правилом конца
Клиент падает на вложенном полеНеясно, обязательны ли customer и его свойстваСверить схему, реальные тела ответа и место чтения поляСделать поле required либо обработать его отсутствие в одном месте
Ошибка параметра выглядит как пустой списокСервер вернул 200 с ошибкой в JSONПроверить HTTP-статус и Content-Type в сетевом журналеВернуть 400 и problem document; не повторять тот же запрос
Повторный запрос создаёт дубликат действияКлиент не знает, безопасен ли повтор и был ли запрос принятПроверить метод, статус, идемпотентность и поведение после таймаутаОписать правило повтора; для записи использовать ключ идемпотентности, если он нужен
Генератор принимает поле, которое сервер отвергаетСхема и исполнение разошлисьСопоставить OpenAPI, валидатор и фактическое тело на каждом статусеСинхронно изменить схему и обработчик; добавить проверку на границе
\n

Таблица не выбирает решение сама. Например, отсутствие customer может быть допустимым для списка и недопустимым для экрана деталей. Тогда это две разные операции или две явно описанные схемы. Нельзя объявлять объект «опциональным» только потому, что один старый ответ его не содержал. Нужно назвать условия, при которых поле присутствует, и поведение клиента при его отсутствии.

\n

Как записать договор в OpenAPI

\n

OpenAPI связывает путь и метод с параметрами и ответами. Каждый ответ получает описание, тип содержимого и схему тела. Благодаря этому в одном месте видно, что 200 — это страница заказов, 400 — problem document, а nextCursor имеет тип string или null. Спецификация не запускает сервер сама и не доказывает его соответствие. Она делает расхождение заметным и даёт вход для валидатора.

\n
paths:\n  /api/v1/orders:\n    get:\n      parameters:\n        - in: query\n          name: limit\n          required: false\n          schema:\n            type: integer\n            minimum: 1\n            maximum: 100\n        - in: query\n          name: cursor\n          required: false\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Страница заказов\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/OrdersPage'\n        \"400\":\n          description: Неверный параметр\n          content:\n            application/problem+json:\n              schema:\n                $ref: '#/components/schemas/Problem'\n\ncomponents:\n  schemas:\n    OrdersPage:\n      type: object\n      required: [items, page]\n      properties:\n        items:\n          type: array\n          items: { $ref: '#/components/schemas/Order' }\n        page:\n          type: object\n          required: [limit, nextCursor]\n          properties:\n            limit: { type: integer }\n            nextCursor:\n              type: string\n              nullable: true
\n

В OpenAPI 3.0.2 required относится к свойствам объекта. Это не означает, что значение каждого свойства не может быть null. Для cursor нужны два решения: ключ nextCursor приходит всегда, а его значение может быть строкой или null. Так клиент отличает конец списка от повреждённого тела, где сервер забыл ключ.

\n

Схема должна описывать каждый допустимый статус операции, включая тело ошибки, если клиент использует его для решения. Если API иногда возвращает 204, этот вариант тоже нужно записать. Если сервер отдаёт 500 с HTML от прокси, это инфраструктурный путь, а не повод заявить, что ошибка имеет тот же контракт, что и 400. Клиенту нужен общий fallback для неизвестного содержимого.

\n
\"Матрица
Существующая схема показывает связь операции, статуса, типа содержимого и полей тела. Маршрут — только начало договора.
\n

Повтор, идемпотентность и отрицательный путь

\n

Таймаут не сообщает, обработал ли сервер запрос. Для чтения GET повтор обычно не меняет ресурс, но клиент всё равно должен ограничить число попыток и различать сетевой сбой, 429 и 400. Неверный параметр повторять бессмысленно. Для записи нельзя переносить правило GET автоматически: повтор POST может создать две операции, если сервер принял первый запрос, а ответ потерялся.

\n

Для записи нужен отдельный контракт идемпотентности. Один из вариантов — заголовок с ключом операции, который сервер связывает с результатом. Это учебная модель, а не обязательное требование HTTP. Если проект выбирает такой механизм, нужно описать срок хранения ключа, конфликт повторного ключа с другим телом и статус при повторе. Без этих условий слово «идемпотентный» не даёт клиенту действия.

\n

Отрицательный путь важен не меньше happy path. Проверьте отсутствующий cursor, неверный тип limit, слишком большое значение, пустую страницу, последнюю страницу, неизвестный problem type, неожиданный Content-Type и сетевой таймаут. Если система не знает, что делать с ответом, она должна остановить автоматический повтор и оставить диагностический след. Притвориться успешным пустым ответом безопаснее не становится.

\n

Порядок действий

\n
  1. Выберите одну операцию и сохраните точный метод, путь, query-параметры, заголовки и пример реального вызова.
  2. Назовите наблюдаемый успех: какие данные и какая интеракция должны быть доступны пользователю.
  3. Составьте карту статусов. Для каждого статуса укажите Content-Type, тело и действие клиента.
  4. Зафиксируйте обязательные поля, допустимый null, отсутствие ключа и правило cursor-пагинации.
  5. Запишите операцию в OpenAPI и добавьте схемы для успеха и ошибок. Не оставляйте тело как безымянный object.
  6. Проверьте учебные примеры валидатором схемы и сравните их с кодом сериализации. Эта проверка не заменяет интеграционный запрос.
  7. Прогоните отрицательные случаи: неверные параметры, последнюю страницу, сетевой сбой и неожиданный тип содержимого.
  8. Согласуйте изменение как совместимое или несовместимое. При несовместимом изменении задайте версию, переход или адаптер до выкладки клиента.
\n

Ограничения

\n

OpenAPI не исправляет сервер и не гарантирует, что прокси не изменит ответ. Валидатор проверяет только то, что ему передали, и может поддерживать не все возможности выбранной версии схемы. Локальный пример не показывает права доступа, нагрузку, задержку, порядок данных или поведение кеша. Для этих свойств нужны отдельные проверки на подходящем контуре.

\n

HTTP-статус не заменяет доменную ошибку, а problem document не заменяет правила интерфейса. Не стоит создавать отдельный тип ошибки для каждой фразы. Тип должен объяснять устойчивый класс проблемы и способ реакции. Не стоит объявлять cursor прозрачным идентификатором, если сервер оставляет за собой право менять его формат.

\n

Если после проверки схема совпадает с сериализатором, но клиент всё равно показывает неверный экран, ищите ошибку в преобразовании данных или состоянии интерфейса. Не добавляйте новый статус и не меняйте форму JSON без доказательства, что граница находится в API. Контракт помогает локализовать проблему, но не делает каждый дефект проблемой контракта.

\n

Проверяемый критерий готовности

\n

Операция готова к интеграции, когда для каждого заявленного статуса есть проверяемое тело или явно указанное отсутствие тела; клиент знает действие для успеха, неверного параметра, повторяемого сбоя и неизвестного ответа; схема совпадает с фактической сериализацией; последняя страница отличается от пустого результата явным nextCursor: null. В артефакте должны остаться запросы, ответы и результаты отрицательных проверок. Без этого «контракт есть» означает только наличие файла.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/309.json b/editorial/agent-rewrites/309.json new file mode 100644 index 0000000..c5abdac --- /dev/null +++ b/editorial/agent-rewrites/309.json @@ -0,0 +1,7 @@ +{ + "index": 309, + "slug": "editorial-2019-06-practice-rest-api", + "title": "REST API без угадываний: как зафиксировать ответ операции", + "excerpt": "Список ломается не из-за URL, а из-за неявных правил: 200 скрывает ошибку, курсор исчезает на последней странице, а необязательное поле приходит то пустым, то отсутствующим. Разбираем контракт одной REST-операции и проверяем его по статусу, Content-Type и форме JSON.", + "contentHtml": "

Список заказов загружается без ошибки сети, но кнопка «Ещё» исчезает после первой страницы. На другой ветке карточка падает при чтении customer.name. Форма после отправки получает HTTP 200 и показывает общий сбой, потому что в JSON лежит error. Адрес /api/v1/orders не менялся. Изменился договор между клиентом и сервером, но его никто не записал.

\n

Цена ошибки — не только красный экран. Клиент может повторить выполненное действие. Пользователь не понимает, сохранился ли заказ. Поддержка получает неполное объяснение. Разработчики видят валидный JSON и спорят о смысле его полей. Чем больше клиентов у операции, тем дороже угадывание.

\n

REST-контракт описывает не URL, а наблюдаемый результат операции. Для каждого входа нужно зафиксировать статус, Content-Type, форму тела и действие клиента. В учебном примере возьмём GET /api/v1/orders. Он возвращает страницу заказов, принимает limit и непрозрачный cursor. Пример не обращается к реальному серверу и не доказывает поведение production.

\n

Симптом показывает незаписанную границу

\n

Фраза «метод возвращает JSON» слишком общая. Она не говорит, что означает пустой список, как обозначается конец пагинации и где искать ошибку параметра. Она также не отвечает, может ли ключ отсутствовать или должен иметь значение null. Без этих решений разные клиенты создают разные правила.

\n

HTTP различает успешный результат, ошибку запроса и ошибку сервера. Код 400 сообщает о проблеме в запросе, а 5xx — о сбое на стороне сервера. Поле { "ok": false } внутри ответа 200 не заменяет HTTP-статус. Оно заставляет каждый клиент самостоятельно решать, когда успешный ответ нужно считать ошибкой.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
«Ещё» исчезает раноКонец списка выводят из длины массиваПроверить page.nextCursorВозвращать cursor или явный null
Карточка падает на полеНеясно, отсутствует ли customer или равен nullСверить required и JSONВыбрать одно правило
400 парсят как списокКлиент смотрит только на телоСравнить статус, media type и схемуВетвить обработку по коду
Ошибка даёт общий баннерНет стабильного кода причиныПроверить type и errorsПривязать действие к машинному коду
\n

Минимальный контракт списка

\n

У limit должен быть диапазон, например от 1 до 100. cursor может отсутствовать на первом запросе и остаётся непрозрачной строкой. Клиент передаёт его обратно, но не извлекает из него дату, идентификатор или номер страницы.

\n

Успешный ответ всегда содержит items и page. В page.nextCursor строка означает, что следующая страница доступна. null означает конец текущего снимка. Отсутствие ключа не используем как третий сигнал. Это решение проекта, а не универсальное требование REST. Вместо него можно выбрать offset или заголовок Link, но смешивать способы не стоит.

\n
GET /api/v1/orders?limit=2 HTTP/1.1
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json

{
  "items": [{ "id": "ord_1042", "status": "paid", "total": { "amount": 9900, "currency": "RUB" }, "customer": { "id": "cus_17", "name": "Ирина" } }],
  "page": { "limit": 2, "nextCursor": "ord_1042" }
}
\n

В ответе id, status, total и page образуют обязательное ядро. Если заказ может прийти без клиента, ключ customer не входит в required. При наличии он должен быть объектом с согласованными полями. Нельзя одновременно обещать «ключ отсутствует», «ключ равен null» и «ключ всегда объект». Для клиента это три разных состояния.

\n

Статус и тело ошибки читаем вместе

\n

Пусть cursor принадлежит другой выборке или имеет неверный формат. Сервер не может построить корректную страницу, поэтому учебный контракт возвращает 400. Тело использует Problem Details. Стандарт задаёт поля type, title, status, detail и instance. Проект может добавить расширение errors.

\n
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://api.example.test/problems/invalid-cursor",
  "title": "Недействительный cursor",
  "status": 400,
  "detail": "Курсор не принадлежит этому списку",
  "instance": "/api/v1/orders?limit=2&cursor=broken",
  "errors": [{ "path": "query.cursor", "code": "invalid_cursor" }]
}
\n

Клиент не должен принимать решение по title или изменчивому detail. Для ветвления подходит type или errors[].code. При invalid_cursor интерфейс может удалить сохранённый cursor и загрузить первую страницу. Нераспознанная проблема должна вести на общий путь ошибки, а не превращаться в пустой список.

\n
\"Карта
Контракт начинается на входе запроса и заканчивается проверяемой формой ответа. Один URL не описывает эти границы.
\n

Записываем правила в OpenAPI

\n

Комментарий в контроллере быстро расходится с реальным ответом. OpenAPI связывает операцию, параметры и responses в одном документе. У 200 указываем application/json и схему страницы. У 400 — application/problem+json и схему проблемы. Документ не доказывает, что живой сервер соблюдает YAML, но делает расхождение видимым.

\n
/api/v1/orders:
  get:
    parameters:
      - in: query
        name: limit
        schema: { type: integer, minimum: 1, maximum: 100 }
      - in: query
        name: cursor
        schema: { type: string }
    responses:
      "200":
        description: Страница заказов
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrdersPage" }
      "400":
        description: Неверный limit или cursor
        content:
          application/problem+json:
            schema: { $ref: "#/components/schemas/Problem" }
\n

В схеме страницы items и page входят в required. Внутри page обязательны limit и nextCursor. Если конец списка обозначает null, это нужно выразить в используемой версии схемы и проверить выбранным инструментом. Если команда предпочитает отсутствие ключа, это тоже надо записать. Сериализатор не должен выбирать смысл молча.

\n

Проверяем договор на наблюдаемом примере

\n

Проверка должна ловить не только невалидный JSON. Для 200 сравните статус, media type, массив items, объект page и наличие nextCursor. Для ошибки сравните 400, application/problem+json, поле type, совпадение status с HTTP-кодом и стабильный код причины. Отдельно отправьте последнюю страницу и убедитесь, что она возвращает nextCursor: null, а не пропускает ключ.

\n

Учебные JSON проверяют форму, но не подтверждают авторизацию, маршрутизацию, таймауты, кеши или поведение базы. Для живой проверки нужен запрос к тестовому серверу с теми же ожиданиями. Результат запроса нельзя заменять обещанием из документации.

\n

Порядок действий

\n
  1. Выберите одну операцию и опишите, какое действие она выполняет.
  2. Назовите параметры, типы, диапазоны и правило передачи cursor или offset.
  3. Для каждого результата зафиксируйте HTTP-статус, Content-Type и тело.
  4. Разведите обязательное поле, отсутствующий ключ и null; оставьте выбранные варианты.
  5. Добавьте пример успеха, ошибки и последней страницы.
  6. Опишите операцию в OpenAPI и сверьте схему с примерами.
  7. Выполните те же случаи на тестовом сервере и сравните статус, заголовок и тело.
  8. Проверьте старых клиентов перед изменением required-поля, типа, статуса или формата ошибки.
\n

Ограничения

\n

Контракт ответа не решает авторизацию, идемпотентность команд, лимиты нагрузки и версионирование всего API. Cursor не становится безопасным токеном только потому, что клиент не разбирает его содержимое. У 400, 401, 409 и 5xx могут быть разные причины и действия. OpenAPI также не делает изменение схемы обратно совместимым автоматически.

\n

Добавление необязательного поля обычно требует меньше миграции, чем удаление обязательного. Замена строки объектом, перенос ошибки из 400 в 200 и замена null на отсутствие ключа меняют наблюдаемое поведение. Для таких изменений нужны проверка потребителей и явный переход. Иначе синтаксически корректный JSON снова скроет смысловую поломку.

\n

Проверяемый критерий готовности

\n

Операция готова, когда независимый разработчик по контракту может предсказать ответ для первого запроса, неполного параметра и последней страницы, а затем подтвердить прогноз запросом к тестовому серверу. Для каждого случая совпадают HTTP-статус, Content-Type и обязательная форма тела. Клиент обрабатывает неизвестную ошибку как ошибку, не теряет последнюю страницу и не обращается к отсутствующему полю без явного правила.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/310.json b/editorial/agent-rewrites/310.json new file mode 100644 index 0000000..74a9a38 --- /dev/null +++ b/editorial/agent-rewrites/310.json @@ -0,0 +1,7 @@ +{ + "index": 310, + "slug": "editorial-2019-05-field-http-caching", + "title": "Один URL, два ответа: как не склеить варианты в HTTP-кэше", + "excerpt": "После включения CDN пользователь получает не тот язык или старую версию страницы. Разбираем cache key, Vary и ETag на контролируемом примере и проверяем результат через origin и edge.", + "contentHtml": "

После деплоя /catalog должен отдавать английский HTML по запросу с Accept-Language: en. Но часть пользователей видит русский текст. Иногда проблема выглядит мягче: новая подпись уже есть на origin, а публичный адрес ещё отдаёт старую. Цена ошибки — неверный интерфейс, потерянное доверие и часы на спор между frontend, backend и CDN. Если в ответе есть персональные данные, ошибка кэша становится утечкой.

\n

Тезис простой: кэш хранит не «URL вообще», а представление ответа для конкретного запроса. Чтобы использовать сохранённый ответ безопасно, нужно согласовать четыре вещи: какие входы меняют тело, как они попадают в cache key, сколько ответ считается свежим и как сервер подтверждает его версию. Один правильный заголовок не исправляет расхождение между приложением и edge.

\n

Механизм: представление, вариант и свежесть

\n

В учебном примере тело /catalog зависит только от Accept-Language. Русский и английский ответы имеют один URL, но являются разными представлениями. Origin должен сообщить это через Vary: Accept-Language. Кэш использует указанные поля запроса, чтобы отличить сохранённые варианты. Если поле не указано, edge может считать русский и английский ответы одним объектом.

\n

Cache-Control задаёт правила хранения и повторного использования. max-age ограничивает свежесть ответа для обычного кэша. s-maxage позволяет отдельно задать срок для shared cache, если это нужно архитектуре. private запрещает общий кэш для ответа, который нельзя отдавать другой сессии. no-store запрещает хранение, но не превращает любой публичный ответ в персональный.

\n

После истечения свежести кэш может не получать всё тело заново. Он отправляет условный запрос с валидатором. Для этого сервер возвращает ETag, а клиент или кэш повторяет его в If-None-Match. Если представление не изменилось, сервер отвечает 304 Not Modified. Если изменилось, он возвращает новое тело и новый валидатор. Проверяйте ETag внутри одного варианта: русский ETag нельзя интерпретировать запросом без того же языка.

\n
GET /catalog HTTP/1.1\nHost: www.example.test\nAccept-Language: en\n\nHTTP/1.1 200 OK\nCache-Control: public, max-age=60\nVary: Accept-Language\nETag: \"catalog-en-v18\"\n\n<h1>Catalog</h1>
\n

Это учебный обмен. Значение ETag придумано для примера и не является результатом измерения. В реальном сервисе валидатор должен меняться вместе с выбранным представлением, а не с каждым запросом из-за случайного идентификатора.

\n

Сначала докажите различие на origin

\n

Начните с безопасного тестового домена. Снимите два ответа, изменив только язык. Сохраните заголовки и тела отдельно. Не добавляйте cookies, User-Agent и экспериментальные параметры одновременно: иначе нельзя понять, какой вход изменил результат.

\n
curl -sS -D /tmp/catalog-ru.headers \\\n  -o /tmp/catalog-ru.html \\\n  -H \"Accept-Language: ru\" \\\n  https://origin.example.test/catalog\n\ncurl -sS -D /tmp/catalog-en.headers \\\n  -o /tmp/catalog-en.html \\\n  -H \"Accept-Language: en\" \\\n  https://origin.example.test/catalog\n\nsha256sum /tmp/catalog-ru.html /tmp/catalog-en.html\ngrep -Ei \"^(cache-control|vary|etag|last-modified):\" /tmp/catalog-ru.headers\ngrep -Ei \"^(cache-control|vary|etag|last-modified):\" /tmp/catalog-en.headers
\n

Команды предназначены для стенда и не выполнены в рамках этой статьи. На origin ожидайте разные тела, если язык действительно влияет на HTML. При этом Vary должен назвать Accept-Language. Если тела одинаковые, не добавляйте Vary по догадке: лишний вариант уменьшит эффективность кэша. Если origin уже отдаёт неверный язык, не начинайте с purge CDN. Исправьте выбор представления или передачу заголовка.

\n
\"Проверка
Один URL может иметь несколько представлений. Язык должен участвовать в проверяемом договоре кэширования.
\n

Потом сравните публичный путь

\n

Повторите те же запросы через CDN. Сравните статус, тело и ключевые заголовки. Edge может добавлять служебные поля, например Age, но не должен терять смысл Vary, срока свежести и валидатора. Отсутствие Age само по себе не доказывает, что origin был вызван.

\n
for lang in ru en; do\n  curl -sS -D \"/tmp/edge-$lang.headers\" \\\n    -o \"/tmp/edge-$lang.html\" \\\n    -H \"Accept-Language: $lang\" \\\n    https://www.example.test/catalog\ndone\n\nsha256sum /tmp/edge-ru.html /tmp/edge-en.html\ngrep -Ei \"^(cache-control|vary|etag|age):\" /tmp/edge-ru.headers\ngrep -Ei \"^(cache-control|vary|etag|age):\" /tmp/edge-en.headers
\n

Если origin различает ответы, а edge отдаёт одинаковое тело, ищите расхождение в настройке cache key или в том, как провайдер обрабатывает Vary. Не каждый CDN одинаково строит ключ по заголовкам. Документация провайдера важна, но её нужно подтвердить двумя реальными запросами на тестовом URL.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Origin отдаёт один языкПриложение не использует вход или прокси его теряетДва запроса к origin, тела и входящие заголовкиИсправить маршрутизацию или генерацию; CDN не менять
Origin различает, но Vary отсутствуетHTTP-договор не описывает зависимостьСравнить тела и response headersДобавить реальный вход в Vary; повторить тест
Origin корректен, edge склеивает вариантыCache key не учитывает язык или правило провайдера не применилосьОдинаковые запросы через публичный URL, digest телИсправить правило CDN и проверить два холодных варианта
Новая версия приходит только после purgeСлишком длинный TTL или неработает revalidationТот же язык, ETag и If-None-Match после выбранного срокаНастроить TTL и валидатор; purge оставить аварийным инструментом
Личная страница попадает в общий кэшОтвет помечен как публичный или ключ неполонПроверить cookies, Authorization и Cache-ControlРазделить публичный shell и данные либо использовать private/no-store
\n

Проверяем ETag без ложного вывода

\n

Выберите один язык и короткий срок на тестовом стенде. Сначала получите ETag. Затем отправьте условный запрос с тем же Accept-Language и этим ETag.

\n
curl -sS -D - -o /dev/null \\\n  -H \"Accept-Language: ru\" \\\n  -H 'If-None-Match: \"catalog-ru-v18\"' \\\n  https://www.example.test/catalog
\n

Если представление не менялось и валидатор совпадает, ожидайте 304. После контролируемого изменения текста ожидайте 200 с новым телом и новым ETag. Если сервер всегда возвращает 200, проверьте, не меняется ли ETag из-за времени или случайного поля. Если сервер возвращает 304 после изменения, валидатор слишком грубый. Это учебные критерии; конкретные статусы нужно подтвердить на вашем стенде.

\n

Порядок действий

\n
  1. Выберите публичный тестовый URL без персональных данных и назовите единственный вход, который должен менять тело.
  2. Снимите два ответа origin: сохраните заголовки, тела и значения входов отдельно.
  3. Сравните тела. Если они различаются, проверьте Vary и не расширяйте ключ случайными полями.
  4. Повторите те же два запроса через CDN. Зафиксируйте digest тел, Cache-Control, Vary, ETag и доступные служебные поля.
  5. Для одного варианта выполните условный запрос с If-None-Match после выбранного срока свежести.
  6. Исправьте слой, на котором появилось расхождение: приложение, proxy или cache key. Не начинайте с полной очистки кэша.
  7. Повторите положительный и отрицательный сценарии: правильный язык и попытку получить другой язык из того же URL.
  8. Оставьте команды и ожидаемые признаки в runbook или автоматической проверке.
\n

Ограничения

\n

Один заголовок не описывает всю конфигурацию CDN. Провайдер может иметь собственные правила нормализации, дополнительные части ключа и отдельные механизмы purge. Их нужно проверять по документации и на вашем тестовом URL.

\n

Если тело зависит от cookie, Authorization, географии, устройства или эксперимента, добавьте каждый реальный вариант в модель. Не превращайте Vary: Cookie в универсальный ремонт: он может раздробить кэш и не решить задачу персональных данных. Для личного HTML безопаснее отказаться от shared cache.

\n

Сценарий не доказывает production-результаты и не заменяет нагрузочные тесты. Учебные домены, ETag и digest выше нужны только для воспроизводимой проверки механизма. Переход к production требует согласованного TTL, политики очистки, наблюдаемости и владельца каждого правила.

\n

Проверяемый критерий готовности

\n

Работа готова, когда два запроса к origin дают ожидаемые представления, response headers объявляют реальные входы через Vary, а два запроса через CDN возвращают соответствующие тела, а не первый сохранённый вариант. Для одного языка условный запрос возвращает 304 без изменения представления и 200 с новым ETag после контролируемого изменения. Персональный ответ не доступен через общий кэш. Все четыре результата записаны в повторяемую проверку.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/311.json b/editorial/agent-rewrites/311.json new file mode 100644 index 0000000..0e161f0 --- /dev/null +++ b/editorial/agent-rewrites/311.json @@ -0,0 +1,7 @@ +{ + "index": 311, + "slug": "editorial-2019-05-mechanism-http-caching", + "title": "HTTP-кэш: почему правильный TTL всё равно отдаёт неправильный ответ", + "excerpt": "Кэш проверяет не только срок хранения. Разбираем ключ варианта, Vary, ETag и границы browser cache, proxy и CDN на наблюдаемом сценарии.", + "contentHtml": "

После релиза пользователь открывает знакомый адрес и видит старый JavaScript или вчерашний язык страницы. В ответе есть Cache-Control: max-age=60, поэтому команда ждёт обновления через минуту. Но один клиент получает новый файл, другой — старый, а CDN продолжает отвечать из своего кэша. Ошибка стоит дороже, чем лишний запрос: пользователь видит неверное состояние, релиз выглядит сломанным, а команда начинает очищать кэш вслепую.

\n

Причина часто не в числе 60. Кэш хранит представление ответа для конкретного запроса. На это представление влияют URL, query-параметры, заголовки, cookies и правила посредника. Cache-Control описывает свежесть и допустимое хранение. Он не исправляет неполный ключ, не различает личные данные и не заставляет каждый слой сети забыть старую запись.

\n

Тезис: срок не заменяет ключ

\n

У каждого кэшируемого ответа должны быть три понятных свойства: какой запрос выбирает ответ, как долго его можно использовать без проверки и как подтвердить, что сохранённое представление ещё подходит. В HTTP этим ролям обычно соответствуют ключ варианта, директивы Cache-Control и валидатор вроде ETag. Заголовок Vary сообщает, какие поля запроса участвовали в выборе представления.

\n

Представьте публичную страницу /catalog. Origin формирует русский или английский HTML по Accept-Language. Для русского запроса кэш сохранил ответ. Следующий английский запрос получит тот же ответ, если посредник использует только URL и не учитывает язык. Этот ответ может быть свежим по max-age, но он всё равно неверен для запроса. Увеличение или уменьшение TTL не меняет ошибочный ключ.

\n

Другая ситуация возникает у файла /assets/app.js. Если содержимое меняется, а имя остаётся тем же, длинный TTL закрепляет старый файл. Безопасный долгий срок требует нового URL при каждом изменении, например app.4f91.js. Здесь кэш не обязан угадывать выпуск: новый адрес отделяет новое представление от старого.

\n

Как кэш принимает решение

\n

Клиент отправляет запрос. Кэш ищет сохранённый ответ, который подходит этому запросу. Сначала он проверяет соответствие варианта. Затем оценивает свежесть. Свежий ответ можно вернуть без origin. Несвежий ответ не обязательно нужно загружать заново: кэш может отправить условный запрос с If-None-Match. Origin ответит 304 Not Modified, если представление не изменилось, или 200 с новым телом, если изменилось.

\n

ETag не очищает запись и не делает ответ свежим. Он позволяет сравнить сохранённое представление с текущим. no-cache тоже не означает «не хранить»: директива требует повторной проверки перед использованием. no-store запрещает хранение ответа в соответствии с правилами кэша. private ограничивает использование частным кэшем и подходит для ответа, который нельзя отдавать общему кэшу.

\n

У ответа может быть несколько точек наблюдения: приложение, reverse proxy, CDN и браузер. Заголовок, снятый на origin, не доказывает, что тот же заголовок дошёл до браузера. Посредник может изменить TTL, ключ или правило обхода. Поэтому проверка должна сравнивать один сценарий на каждой доступной границе.

\n

Учебный пример: два варианта одного URL

\n

Ниже приведены команды для безопасного тестового домена. Это учебный сценарий, а не результат измерения production. Он меняет только один вход — язык — и сохраняет тело и заголовки отдельно. В реальной команде замените адрес на тестовый URL без личных данных.

\n
# Запросы к origin: меняется только Accept-Language.\ncurl -sS -D /tmp/catalog-ru.headers -o /tmp/catalog-ru.html \\\n  -H 'Accept-Language: ru' https://origin.example.test/catalog\n\ncurl -sS -D /tmp/catalog-en.headers -o /tmp/catalog-en.html \\\n  -H 'Accept-Language: en' https://origin.example.test/catalog\n\n# Сначала сравниваем договор, затем тело.\ngrep -Ei '^(cache-control|vary|etag|age):' /tmp/catalog-ru.headers\ngrep -Ei '^(cache-control|vary|etag|age):' /tmp/catalog-en.headers\nsha256sum /tmp/catalog-ru.html /tmp/catalog-en.html\n\n# Учебная проверка сохранённого варианта.\ncurl -sS -D - -o /dev/null \\\n  -H 'Accept-Language: ru' \\\n  -H 'If-None-Match: "catalog-ru-v18"' \\\n  https://origin.example.test/catalog
\n

Если тела различаются по языку, в ответе ожидается Vary: Accept-Language либо эквивалентное явно настроенное правило cache key. Если origin возвращает один язык для обоих запросов, CDN пока не проверяем: источник уже нарушает ожидаемый контракт. Если origin корректен, а публичный адрес склеивает ответы, ищем расхождение в CDN или reverse proxy.

\n

Значение ETag в команде условное. Его нужно взять из предыдущего ответа для того же языка. Нельзя использовать русский валидатор для английского варианта. При неизменном русском представлении ожидаем 304 после повторной проверки. После изменения русского HTML ожидаем новый 200 и новый валидатор. Конкретный статус и заголовки нужно подтвердить на вашем стенде.

\n

Симптомы и действия

\n
Диагностика HTTP-кэша по наблюдаемому симптому
СимптомПричинаПроверкаДействие
После релиза старый JavaScriptПостоянный URL и длинный TTLСравнить URL из нового HTML и digest файлаДобавить fingerprint или сократить контракт свежести
Английский запрос получает русский HTMLЯзык не входит в вариант кэшаСделать два запроса с разным Accept-Language и сравнить VaryИсправить ключ варианта или отказаться от общего кэша
Всегда приходит полный ответ после истечения TTLНет валидатора или условный запрос не доходит до originПовторить запрос с прежним ETag и проверить путь через proxyНастроить устойчивый валидатор и проверить каждый слой
Публичный ответ отличается от originCDN переписывает заголовки или использует другой ключСнять одинаковые заголовки через origin и публичный адресСверить правило CDN с HTTP-контрактом origin
Данные одного пользователя видит другойПерсональный ответ попал в общий кэшПроверить Cookie, авторизацию и Cache-Control на тестовых сессияхВыбрать private, no-store или разделить публичную и личную части
\n

Иллюстрация ключа и свежести

\n
\"Схема
Кэш сначала выбирает подходящее представление, затем проверяет его свежесть. Только после этого срабатывает повторная проверка через валидатор.
\n

Порядок проверки

\n
  1. Выберите один тестовый URL и запишите допустимую давность ответа. Для цены, HTML, asset и личных данных это могут быть разные правила.
  2. Выпишите все входы, которые меняют тело: query, язык, кодирование, cookie, авторизацию, эксперимент или географию.
  3. Снимите ответ origin и публичный ответ одним методом. Сохраните статус, тело или его digest, Cache-Control, ETag, Vary и Age, если поле есть.
  4. Проверьте два безопасных варианта, меняя только один вход. Разные тела требуют разных вариантов; одинаковые тела не требуют добавлять Vary по предположению.
  5. Дождитесь истечения выбранного срока на тестовом стенде или используйте короткий TTL. Повторите запрос с прежним ETag и зафиксируйте ожидаемый 304 либо новый 200.
  6. После изменения правила повторите тот же сценарий через origin, proxy, CDN и браузер, если эти границы доступны. Не очищайте кэш до первого снимка: иначе исчезнет доказательство исходного поведения.
  7. Проверьте отрицательный путь: персональный ответ, неизвестный язык, изменение asset и запрос без валидатора. Для каждого случая заранее запишите безопасный результат.
\n

Ограничения

\n

Эта модель объясняет HTTP-контракт, но не знает настройки конкретного CDN. Поставщик может иметь собственный cache key, отдельный TTL и правила обхода. Браузер может показать результат навигационной истории или service worker, а не обычного HTTP-кэша. Заголовок Age может отсутствовать и не доказывает отсутствие хранения. Поэтому один ответ curl не описывает весь маршрут пользователя.

\n

Vary не заменяет явную проверку персонализации. Если тело зависит от cookie, эксперимента или пользователя, перечисление большого набора полей дробит кэш и может оставить риск утечки. Для личного HTML чаще безопаснее не использовать общий кэш. Для публичной локализации нужен ограниченный список вариантов и тест на каждый поддерживаемый язык.

\n

Учебный пример не обещает конкретное ускорение и не доказывает работу вашей инфраструктуры. Проверяемый результат должен появиться только после запуска команд на контролируемом домене и сравнения ответов. Не публикуйте в диагностике токены, cookie, персональные query-параметры и внутренние адреса.

\n

Критерий готовности

\n

Работу можно считать завершённой, когда для каждого выбранного URL есть короткая карточка: входы, допустимая давность, ожидаемые директивы, валидатор и граница, на которой это проверяется. Два запроса с разными входами не смешивают тела. После истечения срока неизменённое представление даёт ожидаемую условную проверку, а изменённое — новый ответ. Новый asset получает новый URL. Персональный ответ не попадает в общий кэш. Если хотя бы один пункт нельзя показать заголовками и телом ответа, контракт ещё не доказан.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/312.json b/editorial/agent-rewrites/312.json new file mode 100644 index 0000000..f7efff3 --- /dev/null +++ b/editorial/agent-rewrites/312.json @@ -0,0 +1,7 @@ +{ + "index": 312, + "slug": "editorial-2019-05-practice-http-caching", + "title": "HTTP-кэширование без устаревших ответов: контракт для HTML, assets и API", + "excerpt": "После релиза браузер показывает старый HTML, JavaScript или данные API. Разбираем, как связать cache key, свежесть, валидаторы и проверку ответа, чтобы кэш ускорял сайт, а не скрывал ошибку.", + "contentHtml": "

После релиза пользователь открывает карточку товара и видит вчерашнюю цену. Страница загрузилась быстро. В DevTools почти нет ошибок. Но HTML ссылается на старый JavaScript, а CDN отдаёт прежний JSON. Команда меняет max-age и не понимает, какой слой сохранил ответ.

\n

Цена ошибки выше, чем лишний запрос. Пользователь принимает решение по неверным данным. Новый код может работать со старой разметкой. Если ответ персональный, общий кэш может показать его другому пользователю. Простое «очистим кэш» убирает симптом, но не объясняет причину.

\n

Тезис статьи простой: HTTP-кэширование — это контракт ресурса, а не одно число в заголовке. Контракт описывает ключ ответа, допустимую давность, правила повторной проверки и границы хранения. Пока эти четыре пункта не проверены, Cache-Control не доказывает, что приложение отдаёт нужную версию.

\n

Сначала определите, что именно кэшируется

\n

Кэш хранит представление ответа. Минимальный ключ включает метод запроса и целевой URI. На представление могут влиять заголовки запроса, например Accept-Language, параметры, cookie или авторизация. Если русский и английский HTML приходят по одному URL, кэш должен различать варианты. Для этого используют Vary или явно настраивают cache key на промежуточном слое.

\n

Стабильный URL и изменяемое содержимое требуют осторожного договора. HTML по адресу /catalog обычно должен быстро перепроверяться, потому что он содержит ссылки на текущие assets. Файл /assets/app.4f91.js может храниться долго: при изменении содержимого сборка создаёт новый URL. Ответ /api/catalog?category=12 можно кэшировать на короткий срок только после проверки его входов и допустимой давности.

\n
РесурсУсловиеПример договораРиск
Общий HTMLТело не зависит от пользователяno-cache и валидаторСтарая ссылка на asset
Файл с хешем в имениНовый байтовый состав получает новый URLpublic, max-age=31536000, immutableСтарый код живёт по постоянному адресу
Общий API-ответКлюч учитывает все вариантыКороткий max-age или s-maxageУстаревшие или смешанные данные
Персональный ответТело зависит от сессииprivate или no-storeУтечка через shared cache
\n

Это не универсальная таблица заголовков. Она задаёт порядок решения. Сначала найдите входы, которые меняют тело. Затем назовите максимальную допустимую давность. Только после этого выбирайте директивы.

\n

Свежесть не равна обновлению

\n

Свежий ответ кэш может использовать без обращения к origin. Когда срок истёк, ответ становится устаревшим, но это не обязательно означает повторную передачу всего тела. Кэш или браузер отправляет условный запрос с валидатором. При совпадении ETag origin отвечает 304 Not Modified. Тело остаётся в кэше, а представление считается подтверждённым. При изменении origin отдаёт 200 с новым телом и новым валидатором.

\n

no-cache разрешает хранить ответ, но требует проверки перед повторным использованием. no-store запрещает сохранять ответ и применяется, когда само хранение создаёт риск. private запрещает использовать ответ shared cache, но не отменяет хранение в браузере. Эти директивы нельзя заменять друг другом ради «самой свежей страницы».

\n

Длинный TTL безопасен для файла с версией в URL, а не для любого статического файла. Если сервер всегда отдаёт /assets/app.js, годовой max-age закрепит старые байты. Если имя меняется вместе со сборкой, старый URL становится отдельным ресурсом, а новый HTML получает новый ключ.

\n
HTTP/1.1 200 OK\nContent-Type: application/json\nCache-Control: public, max-age=30, s-maxage=120\nETag: \"catalog-202-17\"\nVary: Accept-Language\n\n{\"items\":[{\"id\":42,\"name\":\"Example\"}]}
\n

Пример учебный. Он применим только к публичному каталогу, если язык действительно меняет представление, а 30 секунд — согласованная допустимая давность. Если цена зависит от пользователя, промокода или cookie, public здесь неверен. Пример не сообщает production-результат и не заменяет проверку CDN.

\n

Проверяйте путь ответа, а не только origin

\n

У ответа может быть несколько границ: приложение, reverse proxy, CDN и браузер. Origin может прислать правильный заголовок, а промежуточный слой — заменить TTL, изменить ключ или не передать условный запрос. Поэтому один запрос к localhost не подтверждает поведение публичного адреса.

\n
# Снимите обычный ответ и сохраните заголовки.\ncurl -sS -D /tmp/catalog.headers -o /tmp/catalog.body \\\n  https://example.test/api/catalog?category=12\n\n# Подставьте ETag из первого ответа.\ncurl -sS -D - -o /dev/null \\\n  -H 'If-None-Match: \"catalog-202-17\"' \\\n  https://example.test/api/catalog?category=12
\n

В реальном проекте сравните Cache-Control, ETag, Last-Modified, Vary, Age, статус и тело. Сначала выполните запрос через публичный путь. Затем повторите его на тестовом origin, если такой путь доступен. Разница между ответами указывает на слой, где контракт изменился.

\n
\"Путь
Кэширование требует разных договоров для документа, файла сборки и API. Важен весь путь от запроса до origin.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
После релиза старый JavaScriptПостоянный URL и долгий TTLСравнить URL asset в новом HTML и Age ответаДобавить fingerprint и публиковать новый URL
Всегда приходит полный ответНет валидатора или не проходит условный запросПовторить запрос с If-None-Match и проверить статусНастроить ETag либо Last-Modified; найти слой, который удаляет заголовок
Английский запрос получает русский текстКлюч не учитывает языкСделать два запроса с разным Accept-Language и сравнить VaryДобавить вариант в ключ или отключить общий кэш для ответа
Устаревшая цена после TTLКэш настроен дольше допустимого или edge не применил заголовок originСравнить Age, Cache-Control и настройки CDNСократить TTL на нужном слое и оставить измеряемый путь обновления
Данные одной сессии видны другойПерсональный ответ разрешён shared cacheПроверить тело и заголовки на двух тестовых сессияхИспользовать private или no-store; разделить публичную и личную части
\n

Порядок действий

\n
  1. Выберите один проблемный URL. Зафиксируйте метод, query-параметры и заголовки, которые могут менять ответ.
  2. Опишите ошибку в терминах пользователя: какая старая версия допустима и сколько времени.
  3. Разделите HTML, assets и API. Не переносите договор одного типа ресурса на другой.
  4. Проверьте cache key. Для вариантов используйте Vary или явное правило shared cache; для персональных ответов исключите общее хранение.
  5. Выберите механизм. Для стабильного URL используйте короткую свежесть и валидатор. Для неизменяемого по URL файла меняйте имя при изменении байтов. Для чувствительных данных запретите хранение, если private недостаточно.
  6. Снимите обычный ответ и выполните условный запрос. Сохраните статус, тело или факт 304 и ключевые заголовки.
  7. Повторите проверку через публичный CDN и после следующего изменения. Очистку кэша используйте только как отдельный способ восстановления, а не как доказательство корректной настройки.
\n

Ограничения и отрицательный путь

\n

HTTP-заголовки не управляют всеми промежуточными правилами. CDN может иметь собственный TTL, cache key и исключения для query-параметров. Браузер может использовать историю навигации иначе, чем обычный reload. Nginx применяет add_header с учётом кода ответа и наследования конфигурации; вложенный location может изменить ожидаемый набор заголовков. Это нужно проверять на фактическом ответе.

\n

Если после изменения конфигурации старый ответ всё ещё приходит, не увеличивайте TTL и не объявляйте инвалидацию успешной. Проверьте, тот ли URL запрашивается, тот ли слой отвечает, не меняется ли cache key, есть ли Age и передаётся ли If-None-Match. Если ответ персональный или входы не известны, остановите общий кэш. Без полного ключа безопасного TTL не существует.

\n

Критерий готовности

\n

Настройка готова, когда для каждого из трёх типов ресурса есть записанный контракт: URL, входы, допустимая давность, директивы и слой хранения. Для неизменённого ответа условный запрос даёт 304 или другой явно согласованный результат. После изменения HTML получает новый asset URL, API не смешивает варианты, а персональный ответ не попадает в shared cache. Эти условия проверяются повторяемыми запросами, а не очисткой браузера и не ощущением, что страница «стала быстрее».

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/313.json b/editorial/agent-rewrites/313.json new file mode 100644 index 0000000..40c9183 --- /dev/null +++ b/editorial/agent-rewrites/313.json @@ -0,0 +1,7 @@ +{ + "index": 313, + "slug": "editorial-2019-04-field-forms-validation", + "title": "Почему поздняя проверка логина ломает состояние формы", + "excerpt": "Асинхронная проверка логина может показать ошибку для уже изменённого значения. Разбираем гонку ответов, версионирование состояния, привязку ошибки к полю и границы клиентской валидации.", + "contentHtml": "

Пользователь вводит ivan. Форма отправляет проверку. Через мгновение он меняет значение на ivanka. Быстрый ответ сообщает, что новое имя свободно. Интерфейс становится зелёным. Затем приходит медленный ответ для старого значения и рисует ошибку «логин занят» уже под ivanka. Пользователь не понимает, что исправлять. Корректный ввод блокируется, а команда получает противоречивое состояние, которое трудно воспроизвести.

\n

Причина не в случайности сети. Два запроса завершились в допустимом порядке, но обработчик применил любой ответ к текущему полю. Ответ не нёс доказательства, что его значение всё ещё актуально. Тезис простой: асинхронный ответ получает право менять интерфейс только тогда, когда его версия совпадает с версией текущего ввода. Клиентская проверка помогает человеку, но сервер всё равно повторяет правило при сохранении.

\n

Механизм гонки

\n

У поля есть как минимум три независимых факта: текущее значение, состояние локальной проверки и результат удалённой проверки. Ошибка логина относится к конкретному значению. Если хранить только общий флаг isValid, связь теряется. Если хранить только последний ответ, порядок доставки подменяет порядок ввода.

\n

Версия делает эту связь явной. При каждом новом вводе версия увеличивается. Запрос получает копию версии в момент отправки. Обработчик сравнивает копию с текущей версией перед изменением состояния. Старый ответ можно дополнительно отменить через AbortController, но отмена экономит работу транспорта. Она не заменяет проверку версии: ответ мог уже завершиться, а сервер мог принять запрос до отмены.

\n

Учебный пример с обратными задержками

\n

Ниже — автономный учебный пример. Он не обращается к API и не показывает результат production-системы. Задержки намеренно заданы в коде: старая проверка приходит позже новой. Такой fixture проверяет только право ответа менять состояние.

\n
function delayResult(value, delayMs) {\n  return new Promise(function (resolve) {\n    setTimeout(function () {\n      resolve({ value: value, available: value !== 'ivan' });\n    }, delayMs);\n  });\n}\n\nvar state = { value: '', requestId: 0, phase: 'idle', error: '' };\n\nfunction checkLogin(value, delayMs) {\n  var requestId = state.requestId + 1;\n  state = { value: value, requestId: requestId, phase: 'checking', error: '' };\n\n  return delayResult(value, delayMs).then(function (answer) {\n    if (requestId !== state.requestId || value !== state.value) {\n      return { applied: false, reason: 'stale-response' };\n    }\n\n    state = {\n      value: value,\n      requestId: requestId,\n      phase: answer.available ? 'valid' : 'invalid',\n      error: answer.available ? '' : 'Этот логин уже занят'\n    };\n\n    return { applied: true, state: state };\n  });\n}\n\ncheckLogin('ivan', 30);\ncheckLogin('ivanka', 5);
\n

После запуска второй ответ может примениться первым. Он устанавливает value: 'ivanka' и фазу valid. Первый ответ возвращает stale-response и не меняет состояние. Проверять нужно весь итоговый объект, а не только флаг applied: значение, номер версии, фазу и текст ошибки. Иначе тест может пропустить смешение нового значения со старой ошибкой.

\n

В рабочем коде состояние обычно живёт в React, Vue, небольшой машине состояний или собственном контроллере. Название инструмента не меняет правило. На новом input нужно сначала зафиксировать новое значение, увеличить версию и очистить удалённую ошибку. Только после этого следует отправлять проверку. При закрытии формы экземпляр также должен перестать принимать ответы. Для этого подходит отмена запроса, увеличение версии при уничтожении или проверка активного экземпляра.

\n

Плохой обработчик и минимальная правка

\n
// Плохо: последний по времени ответ всегда меняет поле.\nfunction applyAnswer(answer) {\n  state.phase = answer.available ? 'valid' : 'invalid';\n  state.error = answer.available ? '' : 'Этот логин уже занят';\n  render(state);\n}\n\n// Лучше: ответ меняет поле только для текущей версии.\nfunction applyAnswer(requestId, value, answer) {\n  if (requestId !== state.requestId || value !== state.value) return;\n\n  state.phase = answer.available ? 'valid' : 'invalid';\n  state.error = answer.available ? '' : 'Этот логин уже занят';\n  render(state);\n}
\n

Проверка версии — не блокировка и не гарантия доступности имени. Она защищает только границу между вводом и рендером. Сервер может ответить «свободно», а другой клиент займёт имя до отправки формы. Поэтому POST повторяет правило и возвращает ошибку конфликта, если имя уже занято. Клиент применяет такой ответ к той версии формы, которая была отправлена. Если человек успел изменить поле, старый результат снова нельзя показывать над новым значением.

\n

Куда помещать ошибку API

\n

Ответ login_taken относится к полю логина. Его нужно преобразовать в ошибку конкретного поля, а не выводить только в общий toast. Удобный контракт может выглядеть так: fields.login содержит список сообщений, form содержит общие ошибки, а неизвестный ключ попадает в безопасный общий путь и журнал диагностики. Нельзя молча приклеивать неизвестную ошибку к первому полю.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Красная ошибка появляется после зелёного состоянияСтарый ответ не связан с версией вводаЗаписать value и requestId каждого запросаСравнивать версию перед render
Ошибка видна, но непонятно, какое поле исправлятьAPI-ошибка выведена только в общем баннереПроверить ключ поля в ответеРендерить field error рядом с input
Новый ввод сохраняет старую ошибкуremoteError не очищается на inputИзменить значение после отказа и проверить DOMОчистить ошибку до запуска новой проверки
Debounce уменьшил запросы, но гонка осталасьУшедшие запросы всё ещё завершаются в любом порядкеЗадать две обратные задержкиОставить version check; debounce считать оптимизацией
Поле зелёное, но POST отклонёнПредварительная проверка не резервирует имяПовторить конфликт на сервере при сохраненииОбработать серверную field error
После закрытия модального окна меняется экранОтвет записывает состояние уничтоженной формыЗакрыть форму до завершения запросаОтменить запрос или инвалидировать версию
\n

Доступная разметка ошибки

\n

Ошибка должна быть рядом с полем и связана с ним в DOM. Метка задаёт имя элемента. aria-invalid отражает текущее ошибочное состояние. Устойчивый ID связывает поле с текстом ошибки. В учебной разметке ниже ошибка показана для актуального значения ivanka. В реальном экране эти атрибуты меняются вместе с состоянием поля.

\n
<label for='login'>Логин</label>\n<input\n  id='login'\n  name='login'\n  required\n  pattern='[A-Za-z0-9_]{3,20}'\n  aria-describedby='login-hint login-error'\n  aria-errormessage='login-error'\n  aria-invalid='true'\n  value='ivanka'\n>\n<p id='login-hint'>От 3 до 20 букв, цифр или _.</p>\n<p id='login-error' role='alert'>Этот логин уже занят</p>
\n

При следующем вводе нужно снять aria-invalid, очистить текст ошибки и только затем запустить новый запрос. Иначе зрячий пользователь увидит новое значение, а скринридер получит старое сообщение как описание этого значения. Контейнер ошибки должен существовать предсказуемо, оставаться видимым при ошибке и не содержать текст от предыдущей версии.

\n

Нативные ограничения HTML помогают отсеять пустое или явно неверное значение. required и pattern не проверяют занятость имени на сервере. Удалённый отказ храните отдельно от ValidityState. Если применяется setCustomValidity(), очищайте его на новом вводе и не используйте его как замену серверному правилу.

\n
Временная диаграмма проверки логина: быстрый ответ для ivanka приходит раньше медленного ответа для ivan, а сравнение версий отклоняет старый результат
Время ответа не определяет его актуальность. Интерфейс обновляет только ответ, чья версия совпала с текущим вводом.
\n

Порядок действий

\n
  1. Запишите точную последовательность: первое значение, второе значение, порядок ответов и текст, который увидел пользователь.
  2. Найдите единственное место, где promise меняет состояние поля. Проверьте, передаёт ли оно value и requestId.
  3. Соберите учебный fixture с обратными задержками. В итоговом результате храните value, phase, requestId и error.
  4. Увеличивайте версию на каждом input. Очищайте remoteError до нового render и до запуска проверки.
  5. Сравнивайте requestId и value в обработчике ответа. При несовпадении возвращайте состояние без изменений.
  6. Проверьте ошибки API: известный ключ поля, общая ошибка формы и неизвестный ключ должны иметь разные маршруты.
  7. Проверьте клавиатурный сценарий и Accessibility tree. У поля должны быть имя, актуальное значение, состояние invalid и видимая связанная ошибка.
  8. Повторите проверку перед POST на сервере. Проверьте конфликт между предварительной проверкой и фактическим сохранением.
\n

Ограничения и отрицательный путь

\n

Debounce сокращает частоту запросов, но не делает старый ответ безопасным. AbortController отменяет поддерживаемую операцию, но уже готовый ответ или серверный побочный эффект требуют отдельной защиты. Версия решает задачу согласованности UI. Она не решает авторизацию, резервирование имени, нормализацию регистра или правила хранения данных.

\n

Если API не возвращает имя поля, нельзя достоверно показать ошибку под конкретным input. Не угадывайте поле по тексту сообщения. Покажите нейтральную ошибку формы, сохраните технический код и договоритесь об изменении контракта. Если компонент уже уничтожен, поздний ответ должен завершиться без render. Если версия потеряна при восстановлении состояния, безопаснее остановить применение ответа, чем принять его как текущий.

\n

Учебный код не доказывает работу браузера, выбранного фреймворка или реального транспорта. Его проверяемый результат ограничен порядком двух promise. Браузерный прогон, клавиатурный сценарий, Accessibility tree и серверный конфликт требуют отдельных проверок на настоящем контуре. В статье нет production-метрик и нет утверждения о запуске такого контура.

\n

Проверяемый критерий готовности

\n

Исправление готово, если тест с двумя обратными задержками оставляет последнее значение, его requestId, корректную фазу и пустую старую ошибку. Ответ для прежнего значения не вызывает render. После изменения input ошибка очищается. Ответ API с известным ключом появляется у нужного поля, а неизвестный ключ не приклеивается к случайному input.

\n

На реальном экране проверяющий должен пройти форму клавиатурой, увидеть метку и ошибку, открыть Accessibility tree и подтвердить связи по ID. Отдельный тест должен показать, что сервер отклоняет конфликт даже после положительной предварительной проверки. Если хотя бы один из этих пунктов не проверен, готовность ограничивается локальной логикой и не распространяется на весь пользовательский сценарий.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/314.json b/editorial/agent-rewrites/314.json new file mode 100644 index 0000000..c411fde --- /dev/null +++ b/editorial/agent-rewrites/314.json @@ -0,0 +1,7 @@ +{ + "index": 314, + "slug": "editorial-2019-04-mechanism-forms-validation", + "title": "Валидация формы: кто принимает решение и как не показать старую ошибку", + "excerpt": "Клиент быстро проверяет формат, сервер принимает окончательное решение, а асинхронный ответ должен относиться к текущему значению поля. Разбираем состояния формы, гонку запросов и проверяемый путь от input до submit.", + "contentHtml": "

Пользователь вводит ivan, получает сообщение «логин занят», меняет значение на ivanka, а через мгновение видит ту же ошибку. Другой вариант: поле подсвечено зелёным, кнопка отправки активна, но API отклоняет запрос. Цена ошибки — потерянное время, повторные попытки и недоверие к форме. Для команды это ещё и лишние флаги: isValid, isLoading и error начинают описывать разные значения одного поля.

\n

Тезис: валидация формы — это договор между браузером, клиентским состоянием и сервером. Браузер быстро проверяет ограничения HTML. Клиент показывает понятный результат и связывает его с полем. Сервер проверяет вход снова и решает, можно ли сохранить данные. Каждый асинхронный результат должен нести версию значения, для которого он получен. Поздний ответ старой версии нужно отбросить.

\n

Три уровня проверки

\n

Нативная проверка отвечает на локальный вопрос: заполнено ли обязательное поле, соответствует ли значение типу, проходит ли длину или pattern. Эти правила доступны через Constraint Validation API. Они удобны для ранней обратной связи и не требуют запроса.

\n

Клиентская логика добавляет состояние интерфейса. Она решает, когда показывать ошибку, куда поставить фокус и как отличить проверку от отправки. Она также принимает ответы API и очищает старую ошибку после нового ввода.

\n

Сервер отвечает за окончательный контракт. Он знает права пользователя, занятость логина, состояние базы, лимиты и правила, которых может не быть в браузере. Проверка на клиенте не защищает API: запрос можно отправить напрямую или изменить в инструментах разработчика.

\n

Эти уровни не заменяют друг друга. Если клиент скопировал серверную регулярку, это не делает значение разрешённым. Если браузер считает поле корректным, сервер всё ещё может отказать. Если API вернул ошибку, её нужно показать рядом с тем полем, которое пользователь может исправить.

\n

Состояние поля вместо одного boolean

\n

Для примера возьмём поле логина. Его состояние содержит value, номер версии version, локальную ошибку localError, ошибку API remoteError и фазу. Фаза может быть editing, client-invalid, checking, remote-invalid или ready.

\n

Версия растёт на каждом изменении значения. Ответ проверки сохраняет номер версии при отправке запроса. При завершении обработчик сравнивает этот номер с текущим. Такое сравнение защищает состояние от гонки: скорость ответа больше не определяет, какая ошибка останется на экране.

\n
Состояния одного поля
ФазаЧто означаетЧто можно показатьДопустимый переход
editingЗначение меняется или ещё не проверено удалённоТекущее значение и подсказкаinput → локальная проверка
client-invalidНарушено локальное правилоТекст ошибки у поляinput → editing или новая проверка
checkingЗапрос относится к текущей версии«Проверяем…» без старой ошибкиответ той же версии → ready или remote-invalid
remote-invalidAPI отклонил текущую версиюОтвет API у поляinput → editing
readyИзвестные проверки пройденыРазрешение продолжитьinput → editing; submit → отправка
\n

Таблица нужна не для отображения названий фаз. Она запрещает противоречия. Поле не должно одновременно быть ready и хранить ошибку для старого значения. checking не означает, что сервер уже разрешил отправку. После нового input старая ошибка API больше не описывает экран.

\n

Локальная проверка и граница сервера

\n

Нативные ограничения задают на элементе формы. Пример ниже учебный: регулярное выражение показывает простое правило для логина и не утверждает, что так устроен production API. Реальный контракт может разрешать Unicode, нормализовать регистр или применять дополнительные ограничения.

\n
function localLoginError(value) {\n  if (!value) return 'Введите логин';\n  if (!/^[a-z0-9_]{3,20}$/i.test(value)) {\n    return 'От 3 до 20 букв, цифр или _';\n  }\n  return '';\n}\n\nfunction onInput(state, nextValue) {\n  const localError = localLoginError(nextValue);\n\n  return {\n    value: nextValue,\n    version: state.version + 1,\n    phase: localError ? 'client-invalid' : 'editing',\n    localError,\n    remoteError: '',\n  };\n}
\n

Обработчик увеличивает версию и очищает remoteError в одном переходе. Нельзя оставить сообщение «логин занят» рядом с новым значением, а потом надеяться, что следующий ответ его исправит. Экран должен перестать утверждать старый факт сразу после ввода.

\n

Не каждое локально корректное значение нужно проверять сетью на каждую букву. Сначала примените дешёвые ограничения, затем выберите момент удалённой проверки: потеря фокуса, пауза после ввода или отправка формы. Debounce уменьшает число запросов, но не решает гонку. Даже один запрос может завершиться после следующего значения.

\n

Защита от устаревшего ответа

\n

При старте запроса сохраните checkedVersion. В момент ответа получите актуальное состояние из владельца формы и сравните номера. Нельзя сравнивать ответ со старым объектом из замыкания: такой объект всегда может совпасть сам с собой.

\n
function applyRemoteAnswer(current, result) {\n  if (current.version !== result.checkedVersion) {\n    return current; // учебный пример: ответ устарел\n  }\n\n  return {\n    ...current,\n    phase: result.available ? 'ready' : 'remote-invalid',\n    remoteError: result.available ? '' : 'Этот логин уже занят',\n  };\n}\n\n// Учебный порядок ответов:\n// request 1: value=ivan, version=1, available=false\n// request 2: value=ivanka, version=2, available=true\n// request 2 может прийти первым; request 1 не меняет finalState.
\n

Если первый запрос пришёл после второго, обработчик не должен показывать ошибку. Это не отказ API и не исключение сети. Результат устарел. Его можно учесть в диагностике транспорта, но нельзя применять к текущему полю.

\n

AbortController полезен, когда транспорт умеет отменять ненужный запрос. Он экономит ресурсы, но не заменяет сравнение версий: отмена может прийти поздно, сервер может уже обработать запрос, а другой адаптер может игнорировать сигнал.

\n
\"Состояния
Новый input увеличивает версию. Ответ применяется только тогда, когда его версия совпадает с текущей.
\n

Симптом → причина → проверка → действие

\n
Диагностика ошибок валидации
СимптомПричинаПроверкаДействие
Старая ошибка возвращается после нового вводаОтвет не связан с версией значенияЗамедлить первый запрос и проверить порядок ответовДобавить version и отбрасывать устаревший ответ
Кнопка активна до окончания проверкиisValid учитывает только локальный форматОтправить форму в фазе checkingЖдать проверку или валидировать условие на submit
API отказал зелёному полюКлиент принял локальное правило за серверный контрактСравнить тело запроса и код/ключ ошибки APIПоказать серверную ошибку и оставить сервер источником истины
Ошибка видна только рамкойНет текста и связи сообщения с контроломПройти поле клавиатурой и проверить accessible treeДобавить label, видимый текст и связь по ID
Неизвестная ошибка исчезаетКлиент пытается приклеить неизвестный ключ к случайному полюВернуть от API ошибку без известного имени поляПоказать общий блок формы и сохранить техническую диагностику
\n

Submit — отдельный переход

\n

Отправка формы не равна проверке поля. Сначала браузер может выполнить constraint validation. Затем клиент решает, есть ли локальные ошибки и незавершённые проверки. После этого запрос сохранения уходит на сервер, который снова проверяет весь вход.

\n

Для фазы checking выберите одну политику. Можно дождаться текущей проверки. Можно разрешить submit и принять окончательный ответ API. Можно временно отключить кнопку, если интерфейс объясняет причину и не блокирует исправление. Нельзя показывать готовность только потому, что регулярное выражение прошло.

\n

Серверная проверка должна быть атомарной с сохранением там, где это важно. Предварительный запрос «логин свободен» не резервирует логин. Другой пользователь может занять его до submit. Ответ сохранения имеет приоритет над предварительным ответом.

\n

Ошибка должна быть доступна

\n

У поля есть видимое имя через label. Подсказка и сообщение об ошибке получают устойчивые ID. При ошибке контрол получает aria-invalid=\"true\", а связь с сообщением задаётся атрибутом описания или сообщения об ошибке. Цвет рамки остаётся дополнительным сигналом.

\n
<label for=\"login\">Логин</label>\n<input id=\"login\"\n       name=\"login\"\n       aria-invalid=\"true\"\n       aria-errormessage=\"login-error\"\n       aria-describedby=\"login-hint\" />\n<div id=\"login-hint\">От 3 до 20 символов.</div>\n<div id=\"login-error\">Этот логин уже занят.</div>
\n

Это учебный фрагмент разметки. После интеграции проверьте, что сообщение действительно отображается, связь не дублируется и фокус остаётся понятным после submit. Не добавляйте role=\"alert\" на всю форму: длинное сообщение создаёт шум. Срочное изменение статуса должно быть коротким и уместным.

\n

Порядок внедрения и проверки

\n
  1. Выписать поля формы и разделить правила на локальные, серверные и общие для формы.
  2. Назвать владельца состояния. Хранить value, фазу, ошибки и версию в одном согласованном месте.
  3. Добавить нативные ограничения, если они честно описывают контракт: required, тип, длину или pattern.
  4. На каждом input увеличивать версию, пересчитывать локальную ошибку и очищать старую ошибку API.
  5. Перед сетевой проверкой сохранить версию. В ответе сравнить её с актуальным состоянием до изменения UI.
  6. Явно решить поведение submit в фазе checking. Не считать незавершённую проверку готовностью.
  7. Связать label, подсказку и ошибку с контролом. Проверить клавиатуру, фокус и текст, а не только цвет.
  8. Проверить отрицательные случаи: старый ответ после нового ввода, неизвестный ключ API, ошибка сети и отказ сохранения при предварительно свободном значении.
  9. Записать критерий готовности и границу возврата. Если команда не может повторить проверку, форма не готова.
\n

Ограничения

\n

Версия защищает состояние интерфейса, но не делает запрос идемпотентным и не защищает базу от конкурирующей записи. Серверная проверка остаётся обязательной.

\n

Нативный текст браузерной ошибки может различаться. Если нужен единый текст, добавьте собственное видимое сообщение, но не удаляйте полезную семантику HTML без причины.

\n

Сложная форма может иметь автомат формы и автоматы отдельных полей. Это не отменяет явной связи: submit должен знать, какие поля ещё проверяются и кто возвращает окончательный отказ.

\n

Учебные логины, задержки и результаты в коде не являются production-измерениями. Они показывают порядок переходов и отрицательный путь. Перед выпуском нужны реальные ответы API, браузерная проверка и проверка доступности.

\n

Проверяемый критерий готовности

\n

Форма готова, если для каждого поля можно назвать владельца значения, локальное правило, серверное условие, фазу, номер версии и место сообщения. При двух ответах в обратном порядке старый ответ не меняет новое значение. При отказе API ошибка появляется у правильного поля или в общем блоке, если ключ неизвестен. При клавиатурной проверке поле имеет имя, текст ошибки доступен и фокус не теряется.

\n

Достаточное доказательство — воспроизводимый сценарий с пустым полем, неверным форматом, текущей серверной ошибкой, устаревшим ответом и отказом submit. Если хотя бы один сценарий оставляет на экране вердикт для другого значения, правило актуальности не внедрено.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/315.json b/editorial/agent-rewrites/315.json new file mode 100644 index 0000000..a9516eb --- /dev/null +++ b/editorial/agent-rewrites/315.json @@ -0,0 +1,7 @@ +{ + "index": 315, + "slug": "editorial-2019-04-practice-forms-validation", + "title": "Валидация формы без ложного успеха: поле, API и устаревший ответ", + "excerpt": "Браузер считает поле корректным, а API отклоняет его или возвращает ошибку уже для старого значения. Разбираем границы проверки, контракт ошибок, доступную разметку и защиту от гонки ответов.", + "contentHtml": "

Форма показывает зелёную почту, пользователь нажимает «Сохранить», а API отвечает 422. В другом варианте человек быстро меняет логин: новый ответ говорит «свободен», затем поздний ответ для старого значения рисует ошибку под новым. Сообщение иногда попадает в общий баннер и не объясняет, какое поле исправлять. Цена ошибки — лишний запрос, потерянное введённое значение и неверное решение пользователя. Для регистрации или платежа это может означать отказ в корректной операции.

\n

Тезис простой: клиентская валидация ускоряет обратную связь, но не принимает бизнес-решение. Сервер проверяет данные снова. Интерфейс должен знать, к какому полю относится отказ и к какой версии значения он относится. Если эти границы не зафиксированы, новая регулярка не исправит расхождение.

\n

Что именно проверяет каждый слой

\n

HTML отсекает очевидное: пустое обязательное поле, неверный тип, длину и pattern. У контрола есть объект validity. Методы checkValidity() и reportValidity() помогают проверить нативные ограничения формы. Это полезный ранний фильтр. Он не знает, занят ли логин, есть ли у пользователя право на действие или изменилось ли состояние записи на сервере.

\n

Клиентский код собирает состояния и решает, где показать результат. Он может очистить старую серверную ошибку после ввода, дождаться проверки доступности и не применить ответ старой версии. Но он не должен объявлять значение принятым только потому, что локальная проверка прошла. Запрос можно отправить вне страницы, а правила базы меняются независимо от JavaScript.

\n

API владеет нормализацией и бизнес-условиями. Ему не следует возвращать только строку «что-то не так»: экрану будет некуда её привязать. У ошибки нужен стабильный ключ поля и код. Человеческий текст остаётся текстом, а не идентификатором маршрутизации.

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Поле зелёное, API отвечает 422Клиент проверил формат, сервер — бизнес-условиеСравнить локальные ограничения с контрактом ответаПоказать серверную ошибку у поля и оставить сервер источником истины
Ошибка относится к соседнему полюКод ищет текст или использует неизвестный ключПроверить карту field → error и список полейИзвестные ключи привязать к input, неизвестные оставить общей ошибкой
Старый ответ стирает новый вводОбработчик применяет любой завершившийся запросЗамедлить первый ответ и быстро изменить значениеСравнивать номер запроса или версию значения до render
Ошибка видна только красной рамкойНет текста, label или связи с контроломПроверить клавиатуру и accessibility treeДобавить видимое сообщение, aria-invalid и связь по ID
\n

Контракт ошибки должен указывать поле

\n

Для учебного примера представим ответ API при отправке формы. Это локальный договор приложения, а не встроенный формат браузера:

\n
{\n  \"code\": \"VALIDATION_FAILED\",\n  \"fields\": {\n    \"email\": [\n      { \"code\": \"email_taken\", \"message\": \"Этот адрес уже используется\" }\n    ]\n  },\n  \"form\": []\n}
\n

Клиенту нужен адаптер. Он принимает только известные имена полей и отделяет ошибку формы от ошибки input. Не ищите слово «занят» в сообщении. Не приклеивайте неизвестный ключ к первому полю: так ошибка API превращается в ложную подсказку.

\n
function mapServerErrors(payload, knownFields) {\n  var result = { fields: {}, form: [] };\n  var fields = payload && payload.fields ? payload.fields : {};\n\n  Object.keys(fields).forEach(function (name) {\n    var item = fields[name] && fields[name][0];\n    if (knownFields.indexOf(name) === -1 || !item || !item.message) {\n      result.form.push('Сервер вернул ошибку без известного поля');\n      return;\n    }\n    result.fields[name] = item.message;\n  });\n\n  return result;\n}\n\nvar errors = mapServerErrors(apiPayload, ['email', 'password']);
\n

Код учебный. Он выбирает первое сообщение и не решает локализацию, вложенные массивы или несколько ошибок на одном поле. В рабочей форме эти решения фиксируют отдельно. Текст сообщения вставляют как текст. Нельзя принимать его за HTML без явной, проверенной причины.

\n

Ошибка должна принадлежать текущему input

\n

У поля есть видимый label, постоянная подсказка и контейнер ошибки. aria-describedby связывает input с описанием по ID. Когда значение не прошло проверку, интерфейс добавляет aria-invalid=\"true\" и показывает сообщение. Если проект применяет aria-errormessage, его связывают с видимым элементом ошибки и используют только при невалидном состоянии.

\n
<label for=\"email\">Почта</label>\n<input id=\"email\" name=\"email\" type=\"email\"\n  required autocomplete=\"email\"\n  aria-describedby=\"email-hint email-error\"\n  aria-errormessage=\"email-error\"\n  aria-invalid=\"true\">\n<p id=\"email-hint\">Укажем адрес для входа.</p>\n<p id=\"email-error\">Этот адрес уже используется</p>
\n

В валидном состоянии ошибку скрывают способом, который не оставляет устаревший текст доступным как актуальное сообщение, а aria-invalid убирают или ставят в false. Красный цвет не заменяет текст. Фокус после submit можно перевести на первое проблемное поле, но при каждом вводе не нужно превращать сообщение в срочное объявление. role=\"alert\" применяют к короткому динамическому статусу, а не ко всей форме.

\n

Поздний ответ проверяет не то значение

\n

Рассмотрим учебный сценарий без настоящей сети. Пользователь вводит ivan, запрос получает задержку 30 мс. Затем ввод меняется на ivanka, второй запрос получает задержку 5 мс. Если первый ответ означает «занято», он придёт позже. Обработчик должен знать, что его запрос устарел.

\n
var latestRequestId = 0;\n\nfunction checkLogin(value, checkAvailability) {\n  var requestId = latestRequestId + 1;\n  latestRequestId = requestId;\n  render({ value: value, phase: 'checking', error: '' });\n\n  return checkAvailability(value).then(function (answer) {\n    if (requestId !== latestRequestId) {\n      return { applied: false, ignored: 'stale-response' };\n    }\n\n    render({\n      value: value,\n      phase: answer.available ? 'valid' : 'invalid',\n      error: answer.available ? '' : 'Этот логин уже занят'\n    });\n    return { applied: true };\n  });\n}
\n

Номер запроса должен принадлежать конкретному экземпляру поля или форме. Глобальный счётчик всего сайта создаст взаимное влияние, если на странице появятся два независимых поля. В React, Vue или другом фреймворке принцип не меняется: обработчик сравнивает свой номер с актуальным состоянием владельца.

\n

AbortController может отменить сетевую работу и сэкономить ресурсы. Он не заменяет проверку номера. Ответ мог уже разрешиться, транспорт может не поддерживать сигнал, а причина ошибки может быть локальной. Сначала защищают состояние, затем добавляют отмену как оптимизацию.

\n
\"Схема
Граница проходит между локальным ограничением, контрактом API и отображением ошибки конкретного поля. Поздний ответ не должен менять состояние нового значения.
\n

Порядок внедрения

\n
  1. Выпишите поля формы и разделите для каждого локальное ограничение, серверное условие и общий сбой формы.
  2. Согласуйте с API стабильные ключи полей и коды ошибок. Для неизвестного ключа выберите общий контейнер, а не случайный input.
  3. Добавьте честные нативные ограничения: required, тип, длину и pattern. Не копируйте всю бизнес-логику в браузер.
  4. Сделайте у каждого поля label, устойчивые ID подсказки и ошибки. Показывайте текст, а не только цвет; при ошибке обновляйте aria-invalid.
  5. При каждом изменении значения очищайте старую серверную ошибку и увеличивайте версию. При старте async-проверки сохраните эту версию.
  6. В обработчике ответа сравните версию с текущим состоянием до любого изменения UI. Отмену запроса добавляйте только после этой защиты.
  7. Проверьте submit для пустого значения, неверного формата, известной ошибки поля, неизвестного ключа, сетевой ошибки и ответов в обратном порядке.
\n

Ограничения

\n

Проверка доступности логина до сохранения не резервирует логин. Другой запрос может занять его между двумя операциями. Сервер обязан проверить условие в момент записи и вернуть ошибку поля. Нативный текст браузера может отличаться; единый текст можно показать самостоятельно, сохранив полезные ограничения HTML.

\n

Проверка версии защищает состояние интерфейса, но не делает операцию сохранения идемпотентной и не отменяет транзакцию. Для зависимых полей нужно решить, очищает ли изменение одного поля результат другого. Для нескольких сообщений нужно определить порядок и способ показа. ARIA не исправляет отсутствие label, понятного текста или корректного фокуса.

\n

Примеры выше учебные. Они не измеряют задержки, поддержку конкретного браузера или поведение реального API. Сценарий с задержками проверяет только порядок применения ответов. Разметку нужно проверить в целевом браузере, с клавиатурой и используемым скринридером.

\n

Проверяемый критерий готовности

\n

Форма готова, если для каждого отказа можно назвать владельца правила, ключ поля и видимое место сообщения. При изменении значения старая ошибка исчезает или помечается устаревшей. При обратном порядке ответов финальное состояние содержит новое значение и только его результат. При серверном отказе пользователь видит текст у правильного input, может перейти к нему с клавиатуры и исправить значение. Сервер повторяет все критичные проверки независимо от браузера.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/316.json b/editorial/agent-rewrites/316.json new file mode 100644 index 0000000..2bf02a2 --- /dev/null +++ b/editorial/agent-rewrites/316.json @@ -0,0 +1,7 @@ +{ + "index": 316, + "slug": "editorial-2019-03-field-event-loop", + "title": "Зависший фильтр: как отделить блокировку event loop от гонки ответов", + "excerpt": "Фильтр может тормозить по двум разным причинам: тяжёлая работа блокирует главный поток, а старый async-ответ перезаписывает новый. Разбираем обе ветки по трассе, коду и проверяемому критерию готовности.", + "contentHtml": "

Пользователь вводит строку в поле поиска. Список перестаёт реагировать, а через мгновение показывает не последний запрос, а предыдущий. Цена ошибки — не только раздражение. Тяжёлый экранный код задерживает ввод, а устаревший ответ может показать неверные данные или вернуть человеку уже отменённый выбор.

У симптома две независимые причины. Синхронный parse, фильтр, сортировка или рендер занимают главный поток. Другой запрос завершается позже и получает право изменить экран, хотя уже не относится к текущему вводу. Если назвать всё «тормозами event loop», команда добавит задержку и не исправит ни блокировку, ни порядок результатов.

Тезис простой: сначала нужно построить трассу, затем разделить время CPU и актуальность результата. Promise не создаёт второй поток и не упорядочивает независимые ответы. Для CPU-работы нужны меньший объём, другой алгоритм, порции или worker. Для ответа нужен явный признак актуальности и, при необходимости, отмена.

Что происходит между вводом и экраном

Обработчик ввода запускает новый поиск. До первого await он работает синхронно. После готовности Promise продолжение функции попадает в очередь Promise jobs, а браузер выполняет его на том же агенте. Сетевой ответ может прийти в любом порядке. Успешный старый запрос не знает, что пользователь уже ввёл другой текст.

В браузерной модели текущая task выполняет JavaScript до конца. После неё выполняется microtask checkpoint. Если функция внутри этого участка сортирует большой массив или создаёт тысячи узлов, интерфейс ждёт. Передача той же функции в Promise.resolve().then() только меняет очередь. Она не прерывает вычисление.

let activeRun = 0;\n\nfunction trace(runId, label, extra = {}) {\n  console.log({\n    runId,\n    label,\n    at: performance.now(),\n    ...extra,\n  });\n}\n\nasync function refreshSearch(query) {\n  const runId = ++activeRun;\n  trace(runId, 'start', { queryLength: query.length });\n\n  const response = await fetch('/api/search?q=' + encodeURIComponent(query));\n  trace(runId, 'response', { status: response.status });\n\n  const payload = await response.json();\n  trace(runId, 'parsed', { count: payload.items.length });\n\n  if (runId !== activeRun) {\n    trace(runId, 'drop-stale');\n    return;\n  }\n\n  renderResults(payload.items);\n  trace(runId, 'commit-current');\n}

Это учебный пример. Он не измеряет реальный продукт и не обещает конкретной задержки. runId задаёт правило: экран принимает только последний запуск. Проверка нужна даже при отмене запроса. Отмена может произойти после того, как ответ уже начал выполняться. В production-логи не следует бездумно отправлять строку поиска; здесь в трассу попадает только её длина.

Симптом → причина → проверка → действие

СимптомПричинаПроверкаДействие
Поле не принимает ввод во время фильтраДлинный синхронный JS, layout или commitPerformance trace и метки вокруг parse, filter, sort, renderСократить работу, изменить алгоритм, нарезать её или перенести CPU-часть в worker
Старый список заменяет новыйПоздний async-ответ не проверяет актуальностьСравнить runId в start, response и commitОтбросить устаревший результат; добавить AbortController, если это поддерживает контракт
Promise callback опережает timerОбычный microtask checkpointЗаписать источник callback и относительный порядокНе добавлять timeout для «исправления порядка»; выразить зависимость через Promise
Короткий JS, но commit запаздываетLayout, сторонний скрипт или ограничение фоновой вкладкиПроверить полный trace и состояние вкладкиНайти владельца времени до переписывания бизнес-кода

Таблица нужна для выбора следующей проверки. Debounce может уменьшить число запусков, но не доказывает причину фриза. Он не делает одну тяжёлую сортировку дешёвой и не защищает от позднего ответа, если правило актуальности отсутствует.

\"Трасса
Сначала фиксируем порядок событий, затем выбираем действие для конкретной причины.

Измеряем синхронную границу

Трасса запроса не показывает, сколько времени съел локальный код после ответа. Поставьте метки вокруг названных операций. Не используйте одну метку processData: по ней нельзя сопоставить лог с профилем.

function measure(label, work) {\n  const startedAt = performance.now();\n  const value = work();\n  const duration = performance.now() - startedAt;\n\n  console.log({ label, duration });\n  return value;\n}\n\nfunction prepareCurrentItems(items) {\n  const filtered = measure('sync: filter', () => filterItems(items));\n  const sorted = measure('sync: sort', () => sortItems(filtered));\n  return measure('sync: view-model', () => makeViewModels(sorted));\n}

Helper измеряет только тело функции. Он не покажет сборку мусора, перерасчёт стилей или работу виджета между вызовами. Поэтому после локальной оценки нужен Performance trace того же сценария. Если длинный участок принадлежит sortItems, исправляйте алгоритм или объём. Если время уходит в layout, worker не устранит всю задержку.

Порог 50 миллисекунд из Long Tasks API — полезный технический ориентир для длинной задачи, а не готовый бюджет конкретного экрана. Чувствительность зависит от устройства, частоты ввода и сценария. Число в критерии готовности нужно получить на выбранном воспроизводимом входе и согласовать для этого интерфейса.

Почему наивный фикс не работает

function refreshBad(items) {\n  return Promise.resolve(items)\n    .then(prepareCurrentItems)\n    .then(renderResults);\n}

В этом варианте prepareCurrentItems всё равно выполняется целиком на главном потоке. Promise отложил старт до microtask, но не разрешил браузеру прервать сортировку. Цикл microtask также может задержать следующую task, если постоянно добавляет новые продолжения.

Если алгоритм уже выбран правильно, работу можно разделить на порции. Каждая порция заканчивается, а следующая становится отдельной task. Это учебная конструкция, а не готовая библиотека: она требует правила отмены, контроля памяти и единственного commit.

function refreshWithSlices(items, onDone) {\n  let index = 0;\n  const prepared = [];\n\n  function runSlice() {\n    const deadline = performance.now() + 8;\n\n    while (index < items.length && performance.now() < deadline) {\n      prepared.push(normalizeItem(items[index]));\n      index += 1;\n    }\n\n    if (index < items.length) {\n      setTimeout(runSlice, 0);\n      return;\n    }\n\n    onDone(prepared);\n  }\n\n  runSlice();\n}

Порции дают браузеру возможность обработать другой ввод, но не уменьшают общее число операций. Таймер с нулевой задержкой не обещает кадр в конкретный момент. Если вычисление можно сделать дешевле, сначала меняйте алгоритм. Worker изолирует CPU-работу, но требует обмена данными и не может напрямую менять DOM окна.

Порядок действий

  1. Выберите один сценарий: последовательность строк, фиксированный ответ и действие пользователя. Не смешивайте несколько дефектов.
  2. Добавьте runId и performance.now() вокруг запроса, parse, локальной обработки и commit.
  3. Повторите сценарий на одном входе. Запишите относительный порядок start, response, parsed и commit. Не усредняйте разные трассы без объяснения расхождения.
  4. Откройте Performance trace и найдите длинный участок. Отделите ваш JS от layout, GC и стороннего кода.
  5. Если commit устарел, введите проверку актуальности и подходящую отмену. Если блокирует CPU, сократите работу, измените алгоритм, используйте порции или worker.
  6. Повторите тот же сценарий после правки. Проверьте также отрицательный путь: старый запрос завершается последним, а его данные не меняют экран.

Ограничения

Номер запуска защищает экран, но старый запрос всё равно может расходовать сеть и сервер. Для дорогих запросов нужна поддерживаемая отмена и отдельная политика на сервере. Нарезка работы меняет промежуточные состояния. Нельзя показывать частичный список без явного UX-решения. Worker требует сериализации данных и контроля версии результата. Фоновая вкладка, throttling, layout и сторонние скрипты меняют наблюдаемое время. Проверяйте их отдельно.

Не называйте учебные числа production-результатом. В статье нет утверждения об ускорении конкретного продукта. Воспроизводимый ответ, выбранное устройство и trace нужны, чтобы сделать такой вывод в своей среде.

Критерий готовности

Разбор готов, если на одном зафиксированном сценарии trace показывает владельца задержки, текущий runId единственный может выполнить commit, устаревший запуск не меняет экран, а измеренный синхронный участок укладывается в заранее согласованный бюджет. Повторный прогон должен подтвердить оба пути: актуальный ответ отображается, поздний старый ответ отбрасывается. Только после этого можно считать исправленной причину, а не один удачный порядок событий.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/317.json b/editorial/agent-rewrites/317.json new file mode 100644 index 0000000..b765eaf --- /dev/null +++ b/editorial/agent-rewrites/317.json @@ -0,0 +1,7 @@ +{ + "index": 317, + "slug": "editorial-2019-03-mechanism-event-loop", + "title": "Event loop без магии: где Promise ждёт, а интерфейс блокируется", + "excerpt": "Кнопка не отвечает, таймер срабатывает позже ожидания, а Promise обгоняет setTimeout. Разбираем стек, microtask и task, затем выбираем проверяемое исправление для данных и CPU-работы.", + "contentHtml": "

Кнопка нажата, но экран меняется через несколько сотен миллисекунд. В Console строка из Promise.then появляется раньше строки из setTimeout. Команда добавляет ещё один await и ждёт, что браузер успеет нарисовать состояние. Иногда это меняет порядок логов. Зависание остаётся.

\n

Цена ошибки заметна сразу: пользователь повторяет клик, отправляет форму дважды или видит устаревшие данные. Таймер превращается в случайную паузу. Логи перестают объяснять причину. В коде становится больше ожиданий, но главный поток выполняет ту же синхронную работу.

\n

Тезис статьи простой: event loop не прерывает текущий JavaScript. Promise откладывает продолжение до microtask checkpoint. Таймер ждёт будущую task. Ни одна из этих границ сама по себе не делает вычисление параллельным и не обещает кадр. Чтобы исправить сбой, определите источник работы, измерьте синхронный участок и выберите нужную границу.

\n

Что выполняется прямо сейчас

\n

Когда браузер вызывает обработчик, код выполняется синхронно. Функция вызывает вложенные функции и возвращает управление. Пока стек не опустел, другой обработчик этого же JavaScript-агента не начнёт выполняться. Браузер не вставит обработку клика в середину цикла только потому, что пользователь ждёт.

\n

Это относится и к коду внутри Promise. Вызов Promise.resolve().then(commit) не запускает commit в отдельном потоке. Он ставит реакцию Promise в очередь Jobs, а host выполняет её на подходящей microtask-границе. До этой границы текущая синхронная функция должна закончиться.

\n

Task приходит из host-источника. Так работают, например, пользовательские события и callback таймера. Истёкшая задержка делает timer callback доступным для выбора. Она не вставляет callback в середину текущего стека и не задаёт точный момент запуска. Между задачами браузер может выполнять служебную работу и выбирать rendering по собственной модели.

\n

Термин «одна очередь» слишком груб для диагностики. У языка есть Jobs и host hook для Promise-реакций. У платформы есть event loop, очереди задач и microtask checkpoint. Для прикладного расследования достаточно различать код на стеке, накопленные microtasks и будущую task. Ошибка обычно появляется на переходе между ними.

\n
СимптомПричинаПроверкаДействие
then срабатывает раньше таймераPromise reaction выполняется на microtask checkpoint до следующей taskЗаписать метки до и после стека, внутри then и timer callbackЗафиксировать зависимость данными, а не случайной задержкой
Кнопка не отвечает во время thenВ continuation выполняется длинный синхронный цикл или разбор данныхСнять performance trace и измерить начало и конец функцииУпростить алгоритм, ограничить порцию или выбрать worker
setTimeout(fn, 0) запускается поздноТекущий стек, microtasks или другие задачи заняли потокПроверить источник длинного участка и фактическое время callbackУбрать блокировку, не рассчитывать на точное число миллисекунд
Старый ответ перезаписывает новыйНезависимые Promise завершились в неожиданном порядкеПрисвоить операциям runId и вывести его до commitОтменять устаревшую работу или принимать только актуальный результат
\n

Таблица начинает расследование с наблюдаемого факта. Она не заменяет описание конкретного host. Порядок Promise и таймера в коротком примере можно проверить точно. Порядок сетевого callback, input и timer между разными источниками нельзя превращать в контракт без проверки среды и API.

\n

Минимальный пример порядка

\n

Следующий код проверяет только порядок. Он не измеряет производительность страницы и не доказывает, что таймер всегда срабатывает в определённом месте. Важны границы стека и microtask checkpoint.

\n
const order = []; const mark = (name) => order.push(name); mark('sync: start'); setTimeout(() => { mark('task: timer'); console.log(order.join(' -> ')); }, 0); Promise.resolve().then(() => { mark('microtask: first'); Promise.resolve().then(() => mark('microtask: nested')); }); mark('sync: end');
\n

В учебном запуске обе sync-метки появляются первыми. Затем выполняется первая Promise-реакция и добавленная ею microtask. После checkpoint доступна timer task. Ожидаемый относительный порядок: sync: start → sync: end → microtask: first → microtask: nested → task: timer.

\n

Причина важнее чисел. setTimeout(fn, 0) не означает «вызвать после текущей строки». Он означает «сделать callback доступным для будущей задачи после минимальной задержки, если host сможет её выбрать». Если код создаёт новые microtasks, они могут отложить следующий шаг event loop.

\n

Пример ограничен одним сценарием в браузере. Он не утверждает, что любой сетевой callback уступает timer или что отрисовка всегда происходит между двумя строками. В рабочем логе называйте источник: input: click, promise: parsed response, timer: debounce. Тогда лог можно сопоставить с профилем.

\n

Почему Promise не спасает от длинной работы

\n

Типичная правка разделяет нормализацию и сортировку resolved Promise:

\n
async function refresh(rows) { const normalized = normalizeRows(rows); await Promise.resolve(); const ranked = rankRows(normalized); renderRows(ranked); }
\n

Вызов await Promise.resolve() разделяет функцию на два продолжения, но не ограничивает время normalizeRows и rankRows. Каждая функция выполняется без прерывания. Сортировка, JSON-разбор или цикл по тысячам записей по-прежнему занимают main thread. Input и отрисовка ждут возврата управления.

\n

Даже Promise.resolve().then(() => normalizeRows(rows)) только меняет момент старта. Вычисление остаётся на том же потоке. Это отрицательный путь: Promise полезен для результата и ошибок, но не является worker и не делает CPU-код параллельным.

\n

Отдельно проверяйте DOM. Быстрый JavaScript может запустить дорогие style recalculation, layout или paint. Если trace показывает браузерную работу после серии изменений DOM, добавление таймера в Promise-цепочку не доказывает исправление. Сначала уменьшите количество изменений или объедините commit.

\n

Как уступить управление осмысленно

\n

Если алгоритм нельзя сразу заменить или перенести в worker, работу можно нарезать на порции. Порция должна закончиться сама. Следующая порция должна попасть в будущую task, чтобы event loop получил возможность выбрать ожидающий input.

\n
function processInSlices(rows, onDone) { const result = []; let index = 0; function runSlice() { const deadline = performance.now() + 8; while (index < rows.length && performance.now() < deadline) { result.push(normalizeRow(rows[index])); index += 1; } if (index < rows.length) { setTimeout(runSlice, 0); return; } onDone(result); } runSlice(); }
\n

Восемь миллисекунд здесь — параметр учебного опыта, а не обещание для каждого устройства. Граница зависит от данных, фоновой нагрузки и стоимости commit. Большая порция снова задержит input. Слишком мелкая порция увеличит накладные расходы и может ухудшить DOM-обновления.

\n

Нарезка не уменьшает общее число операций. Она создаёт точки, в которых браузер получает выбор. Если пользователь меняет фильтр, старые порции нужно отменять или помечать устаревшими. Иначе новый результат появится на экране, а старый процесс позже применит свой commit.

\n

Worker подходит для CPU-работы, которая не требует прямого доступа к DOM. Он добавляет стоимость сериализации данных и обмена сообщениями. Сначала измерьте, что блокирует main thread. Не выносите код в worker только потому, что в нём есть слово async.

\n
\"Длинный
Чтобы интерфейс получил шанс обработать input, текущая порция должна завершиться и вернуть управление event loop. Promise сам по себе этого не гарантирует.
\n

Как измерить, а не угадать

\n

Поставьте отметки вокруг подозрительной функции. Используйте performance.now() для длительности внутри одного процесса. Записывайте источник, размер входа и идентификатор запуска.

\n
function measure(label, work) { const startedAt = performance.now(); const value = work(); const finishedAt = performance.now(); console.log({ label, duration: finishedAt - startedAt }); return value; }
\n

Этот фрагмент — учебный измеритель. Он не даёт production-результата и не заменяет performance trace. В trace ищите связь между input, длинным JavaScript-участком и commit. Сравнивайте одинаковый сценарий: тот же объём данных, тот же браузер и понятное состояние кеша. Одно удачное открытие страницы не подтверждает исправление.

\n

Если задержка появляется после нескольких кликов, добавьте runId. При старте увеличивайте номер. Перед применением результата сравнивайте его с текущим. Так проверяется отрицательный путь, где старый Promise завершился позже нового. Порядок завершения независимых запросов нельзя выводить из порядка запуска.

\n

Порядок действий

\n
  1. Записать симптом одним предложением: какая реакция, какой callback и какая задержка наблюдаются.
  2. Разделить работу по источнику: текущий вызов, Promise reaction, timer, input, сетевой ответ или worker message.
  3. Поставить метки до и после синхронной функции, внутри Promise-цепочки и внутри timer callback.
  4. Повторить минимальный сценарий без лишнего кода. Проверить относительный порядок, но не принять его за гарантию всех API.
  5. Если есть фриз, снять trace и измерить main-thread участок. Отделить JavaScript от layout, paint, сборки мусора и стороннего скрипта.
  6. Для зависимости данных вернуть Promise и передать результат явно. Для CPU-работы изменить алгоритм, ограничить порции или выбрать worker.
  7. Для порций определить отмену и правило актуальности результата. Проверить старый запуск после нового.
  8. Повторить прежний сценарий с прежним входом и сохранить метки вместе с длительностью.
\n

Ограничения

\n

Модель описывает браузерный host, а не любой JavaScript runtime. Node.js имеет собственные фазы event loop и правила планирования. Нельзя переносить вывод из окна браузера на сервер без проверки соответствующей документации.

\n

Microtask не равна паузе для rendering. Длинная цепочка Promise может задержать timer и input. Timer не равен кадру. Браузер может увеличить задержку в фоновой вкладке, при нагрузке или из-за throttling.

\n

Нарезка помогает отзывчивости, но не исправляет лишнюю сортировку. Worker изолирует вычисление, но требует обмена сообщениями и не может напрямую менять DOM окна. performance.now() помогает сравнить участки, но числа зависят от окружения.

\n

Критерий готовности проверяемый: для заданного сценария лог показывает источник и актуальность результата; trace показывает, что прежний длинный участок исчез, сократился или получил ограниченные границы; отрицательный путь не применяет устаревший commit. Формулировка «добавили await» таким критерием не является.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/318.json b/editorial/agent-rewrites/318.json new file mode 100644 index 0000000..6d0ee13 --- /dev/null +++ b/editorial/agent-rewrites/318.json @@ -0,0 +1,7 @@ +{ + "index": 318, + "slug": "editorial-2019-03-practice-event-loop", + "title": "Event loop без догадок: как отличить порядок callback от блокировки UI", + "excerpt": "Promise-обработчик может прийти раньше таймера, а интерфейс — перестать отвечать. Разбираем две причины по наблюдаемым меткам, профилю и явному критерию готовности.", + "contentHtml": "

Симптом обычно формулируют неточно: «асинхронность поменяла порядок» или «таймер не сработал вовремя». На странице это выглядит конкретнее: обработчик Promise.then пишет в лог раньше setTimeout, а после импорта данных кнопка несколько мгновений не отвечает. Если начать менять задержки на глаз, можно скрыть один запуск и оставить ту же блокировку на другом устройстве. Цена ошибки — потерянное действие пользователя и повторная отправка данных.

\n

Ниже не «объяснение магии Promise», а маленький воспроизводимый маршрут. Мы сначала записываем порядок синхронных строк, Promise-реакции и timer callback. Потом отдельно создаём длинную синхронную работу и измеряем её границы через performance.now(). Так одна проблема распадается на две: неожиданная очередность и занятый главный поток.

\n

Что именно наблюдаем

\n

В браузерном коде есть как минимум текущий вызов JavaScript, задачи, которые выбирает event loop, и microtask checkpoint. Promise-реакция не прерывает уже исполняющуюся функцию. Она попадает в работу после того, как текущий стек освободится. Callback таймера тоже не появляется в середине этой функции: истекшая задержка делает его кандидатом на будущую задачу. Отсюда первое правило: «через ноль миллисекунд» означает не «немедленно».

\n

Нельзя сводить всё к одной универсальной очереди. HTML-стандарт оперирует очередями задач и источниками задач; браузер выбирает, что исполнить дальше по собственному алгоритму. Поэтому для реального сбоя важно записать происхождение каждого callback: пользовательское событие, timer, сетевое завершение, Promise-цепочка или ваш прямой вызов. Один только номер строки в Console не доказывает порядок между разными источниками.

\n
Наблюдаемый фрагментКуда попадает работаЧто не обещает механизмПервая проверка
Обычный вызов функцииТекущий стек JavaScriptЧто браузер отрисует до возврата из функцииПоставить отметки до и после вызова
Promise.resolve().then(...)Promise Job, который host запускает как microtaskПрерывание текущей синхронной функцииСравнить место отметки с концом текущего стека
setTimeout(fn, 0)Будущая задача после истечения минимальной задержкиТочный момент запуска и превосходство над другими источниками задачЗаписать метку внутри callback, не только перед постановкой
Тяжёлый цикл или parse JSONТот же текущий стекРеакцию на input, пока цикл не вернул управлениеИзмерить начало и конец работы, посмотреть performance trace
\n

Эта таблица не заменяет спецификацию конкретного API. Она нужна для первого разворота расследования. Если в лог попал fetch, сначала устанавливаем, что именно логируется: момент старта запроса, Promise после ответа или собственная функция разбора. Обещание «всё async» здесь бесполезно — важна граница, на которой ваш код вернул управление браузеру.

\n

Минимальный опыт с порядком callback

\n

Откройте чистую вкладку браузера и вставьте пример в Console либо во временный модуль страницы. Он не измеряет скорость сети и не сравнивает браузеры. Он фиксирует только относительный порядок четырёх точек внутри одного сценария. Время сохраняем рядом с названием, но проверяем именно список меток: абсолютные миллисекунды зависят от нагрузки и точности часов.

\n
const marks = [];\n\nfunction mark(label) {\n  marks.push({ label, at: performance.now() });\n}\n\nmark('A: sync start');\n\nsetTimeout(() => {\n  mark('C: timer task');\n  console.table(marks);\n}, 0);\n\nPromise.resolve().then(() => {\n  mark('B: Promise microtask');\n});\n\nmark('D: sync end');
\n

Для этого опыта ожидаемая причинная запись — A, затем D, затем B, затем C. Сначала заканчивается текущий синхронный фрагмент. После него браузер выполняет microtask checkpoint, в котором может отработать Promise-реакция. Таймерная задача берётся позже, когда event loop выберет следующую доступную работу. Не подменяйте это ожидание тестом вроде «разница всегда ровно 0 или 4 ms»: такого договора у кода нет.

\n

Полезнее добавить к каждой отметке источник. В проекте через неделю появится ещё один then или debounce, и голый лог 1, 2, 3 перестанет объяснять причину. Название search: parsed response или filter: timer commit делает цепочку пригодной для диффа между двумя запусками. Временную диагностику затем удаляем либо оставляем под локальным флагом, чтобы не слать шум в production-логи.

\n
\"Схема
Минимальный опыт проверяет последовательность A → D → B → C; задержка таймера не является обещанием точной секунды запуска.
\n

Опыт с зависанием: Promise не выносит вычисление

\n

Вторая ошибка звучит так: «обернём тяжёлую функцию в Promise — интерфейс перестанет виснуть». Если внутри Promise сразу выполняется большой цикл, всё остаётся на том же главном потоке. Promise.resolve().then(run) лишь переносит начало run в microtask; сама функция всё равно занимает поток целиком, пока не вернёт управление. Пользовательский input и следующая отрисовка ждут эту границу.

\n
function blockFor(milliseconds) {\n  const startedAt = performance.now();\n\n  while (performance.now() - startedAt < milliseconds) {\n    // Искусственная нагрузка для опыта. Результат вычисления не важен.\n  }\n}\n\ndocument.querySelector('.js-run-check').addEventListener('click', () => {\n  const startedAt = performance.now();\n  blockFor(120);\n  const finishedAt = performance.now();\n\n  console.log('sync work duration', finishedAt - startedAt);\n});
\n

Число 120 здесь — параметр искусственного опыта, не обещание метрики для сайта. После клика смотрим два факта: длительность, записанную самим сценарием, и виден ли этот участок в профиле производительности. Пока выполняется цикл, другой click handler на этой же странице не начнёт JavaScript-работу. Если нужно сравнить правки, запускайте один и тот же сценарий с той же входной строкой и фиксируйте условия: браузер, профиль CPU и размер данных.

\n

Не пытайтесь обнаружить это периодическим «пингом таймера» в боевом коде. Он может сам менять картину и не укажет, какой стек занял время. Для расследования достаточно trace в инструментах браузера; для поддерживаемых сред можно отдельно проверить доступность PerformanceObserver с типом longtask. API длинных задач описывает порог 50 ms, но отсутствие записи не оправдывает ощущаемую пользователем задержку и не заменяет trace.

\n

Как отделить очередь от блокирующего кода

\n

Сбой порядка и зависание часто встречаются рядом, но лечатся по-разному. Если лог показывает, что значение из Promise приходит раньше timer callback, это может быть штатный порядок microtask и задачи. Правка состоит в явной зависимости: вызвать следующий шаг в нужном then, вернуть Promise из функции или хранить состояние в одном месте. Добавлять случайную задержку нельзя: она создаёт гонку, а не контракт.

\n

Если callback начинает работать поздно, а в профиле перед ним виден длинный синхронный участок, причина другая. Находим работу, которую можно сократить, разбить на куски или перенести в worker. Перенос в setTimeout даёт event loop возможность выбрать другие задачи между порциями, но не делает вычисление быстрым и не гарантирует кадр после каждой порции. Сначала измеряем одну порцию, затем выбираем её размер по данным, а не по красивому числу в коде.

\n

Последовательность проверки

\n
  1. Сформулировать один наблюдаемый симптом: какая кнопка, какой лог или какое состояние оказалось не в том порядке. Сохранить входные данные и шаги воспроизведения.
  2. Поставить именованные отметки до прямого вызова, после него, внутри Promise-реакции и внутри timer callback. К отметке добавить performance.now() и источник события.
  3. Проверить относительный порядок на минимальном примере. Не переносить его автоматически на сетевой ответ, worker или другой tab.
  4. Если есть задержка интерфейса, снять performance trace и найти участок синхронной работы на main thread. Отделить ваш код от layout, GC и стороннего скрипта.
  5. Для зависимости по данным вернуть Promise или передать результат явным аргументом. Для CPU-работы сократить, нарезать или вынести расчёт; не маскировать причину дополнительным timeout.
  6. Повторить сценарий с прежними входными данными. Критерий готовности — понятный порядок меток и измеренная граница тяжёлой работы, а не один случай без ошибки.
\n

Практический выбор действия

\n

Если функция должна начаться строго после HTTP-ответа, пусть вызывающая сторона получает Promise и строит дальнейший шаг в его цепочке. Если пользователь меняет фильтр несколько раз, добавьте номер запроса или отмену там, где это поддерживается, — не рассчитывайте, что Promise упорядочит независимые ответы сети. Если на главном потоке происходит сортировка тысяч записей, сначала проверяем, не нужна ли сортировка полностью, затем рассматриваем порции или worker. Это три разные задачи, хотя в Console они могут выглядеть одинаковым «опозданием».

\n

Текст намеренно не называет универсальный размер порции и не обещает, что один API лечит каждый freeze. Размер зависит от объёма данных, устройства, текущего DOM и конкурирующей работы. Хорошая техническая заметка оставляет читателю не рецепт «добавить timeout», а инструмент: измерить порядок, обнаружить синхронную границу и выбрать действие, соответствующее именно ей.

\n

Ограничения опыта

\n\n

Итог

\n

Promise не выполняется «раньше всего», а таймер не запускается «ровно через ноль». Сначала заканчивается текущий JavaScript, затем выполняется доступная microtask-работа, после чего event loop выбирает будущую задачу. Когда экран завис, ищем не слово async, а длинную синхронную границу. Этот порядок делает диагноз проверяемым: лог даёт последовательность, профиль даёт длительность, а исправление привязано к конкретной причине.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/319.json b/editorial/agent-rewrites/319.json new file mode 100644 index 0000000..5c7d286 --- /dev/null +++ b/editorial/agent-rewrites/319.json @@ -0,0 +1,7 @@ +{ + "index": 319, + "slug": "editorial-2019-02-field-es-modules", + "title": "ES-модули в браузере: найдите второй запуск по фактическому URL", + "excerpt": "Виджет запускается дважды или импорт получает HTML вместо JavaScript. Разбираем module identity, разрешение относительных путей и проверку через Network.", + "contentHtml": "

Страница загрузилась, но обработчик сработал два раза. Пользователь видит два запроса, двойное уведомление или повторную подписку. В другом варианте кнопка молчит: HTML пришёл со статусом 200, а модуль не выполнился. Цена ошибки — не только сломанный экран. Дублированная инициализация может отправить форму дважды, создать две подписки на одно событие и оставить состояние, которое трудно удалить.

\n

Первый вывод обычно звучит так: «браузер дважды запустил один модуль» или «import не работает». Оба вывода преждевременны. Сначала нужно получить фактический URL каждого модуля и ответ сервера. Браузер строит граф модулей из разрешённых URL. Две разные строки в исходнике могут дать один URL. Одна и та же строка может дать разные URL, если её импортируют из разных каталогов.

\n

Тезис простой: диагностируйте не количество строк <script>, а пару «документ → разрешённый URL модуля». Если пара отличается, браузер может загрузить и вычислить разные записи. Если URL один, ищите другой документ, второй entry или побочный эффект в classic-скрипте. Если ответ не JavaScript, проверяйте раздачу ресурса, даже когда HTTP-статус равен 200.

\n

Как браузер строит граф модулей

\n

Тег <script type=\"module\" src=\"./catalog/main.js\"> задаёт entry. Браузер разрешает адрес относительно URL документа, загружает entry и разбирает его статические import. Каждый импорт разрешается относительно URL файла, в котором он записан. Поэтому ./init.js из /demo/catalog/main.js означает /demo/catalog/init.js, а из /demo/admin/main.js — /demo/admin/init.js.

\n

После разрешения браузер использует module map документа. Для практической диагностики важна его ключевая часть: URL и тип модуля. Одинаковый разрешённый URL в одном документе не равен двум независимым модулям только потому, что импорт встретился дважды. Но query, fragment или другой путь меняют URL. Например, /assets/init.js и /assets/init.js?entry=admin — разные записи графа. Одинаковый файл на диске не делает их одной module identity.

\n

Эта модель объясняет частую ловушку. В шаблоне оставляют module entry и старый bundle. Оба файла вызывают startWidget(), хотя разработчик считает bundle запасным вариантом. В другой ловушке один entry импортирует ./init.js, а второй — тот же файл через алиас или query. Счётчик в коде виджетов растёт, и команда исправляет обработчик вместо точки входа.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Обработчик сработал дваждыДва entry или два разных URL модуляСверить Request URL и Initiator в NetworkОставить одного владельца инициализации
Два тега указывают на один URLДублирование ещё не доказаноПроверить iframe, второй документ и classic bundleНайти фактический вызов побочного эффекта
Импорт дал 404Путь считается от другого импортёраОткрыть фактический URL запросаИсправить спецификатор в файле-импортёре
Статус 200, но MIME-ошибкаSPA fallback вернул index.htmlПосмотреть Response и Content-TypeРазделить маршрут приложения и каталог assets
Импорт с другого origin заблокированОтвет не проходит CORS для модульного запросаПроверить origin и заголовки ответаНастроить разрешённый origin или использовать тот же origin
\n

Таблица задаёт порядок проверки, но не заменяет факты. Статус 200 сообщает только об успешном HTTP-ответе. Он не доказывает, что сервер отдал JavaScript. Для модуля важны тело ответа, MIME, редиректы и точный URL. Ошибка CORS также не доказывает неправильный путь: ресурс может существовать, но быть запрещён для текущего origin.

\n

Учебная проверка через import.meta.url

\n

Ниже учебный пример. Он помогает сравнить identity в одной странице. Он не является трассой production-сайта и не заменяет Network. Код временно ставит отметку в window и выводит URL. После диагностики отметку нужно удалить или заменить нормальным наблюдением без глобального состояния.

\n
// assets/init.js\nconst moduleUrl = import.meta.url;\nconst registry = window.__moduleRuns || (window.__moduleRuns = {});\nconst count = (registry[moduleUrl] || 0) + 1;\nregistry[moduleUrl] = count;\nconsole.log('[module-check]', { moduleUrl, count });\n\nexport function mount(root) {\n  root.dataset.moduleUrl = moduleUrl;\n  root.textContent = 'widget started';\n}\n\n// catalog/main.js\nimport { mount } from '../assets/init.js';\nmount(document.querySelector('#catalog'));\n\n// admin/main.js\nimport { mount } from '../assets/init.js?entry=admin';\nmount(document.querySelector('#admin'));
\n

В этом учебном примере ожидаются две записи, потому что URL различаются query-параметром. Это не ошибка само по себе. Ошибка появляется, если оба entry должны обслуживать один контейнер и один побочный эффект. Тогда сначала убирают лишний entry или приводят импорты к одному намеренному URL. Запрещать query без проверки нельзя: его может добавлять версия ресурса или отдельный вариант загрузки.

\n

Изменим только путь, чтобы увидеть другую причину. Если admin/main.js содержит import '../assets/init.js', его фактический URL зависит от расположения самого файла. Перенос entry в /demo/v2/admin/main.js меняет результат даже при неизменной строке import. Путь в исходнике — это инструкция для разрешения, а не абсолютная ссылка на файл в репозитории.

\n

Неверный URL может вернуть HTML. Например, сервер приложения отвечает своим index.html на неизвестный путь, чтобы поддержать клиентскую маршрутизацию. Браузер получает 200, но модульный загрузчик ожидает JavaScript. В Console появится MIME-ошибка или синтаксическая ошибка HTML. Исправлять компонент в React или повторять импорт в этом случае бессмысленно: сначала нужно исправить адрес или правило раздачи.

\n
\"Схема
Сначала сравните фактические URL. Только затем решайте, является ли второй запуск ошибкой, отдельным entry или намеренным вариантом ресурса.
\n

Что считать одним запуском

\n

Запуск модуля и запуск функции внутри модуля — разные события. Браузер может вычислить модуль один раз, а код страницы вызовет экспортированную функцию дважды. Верно и обратное: два разных URL могут вычислить один и тот же текст файла дважды, потому что для графа это разные адреса. Поэтому отметку ставят в самом модуле и отдельно логируют место, где вызывается mount().

\n

Третий вариант создаёт новый документ. Перезагрузка iframe, открытие страницы во втором окне и worker имеют отдельные контексты. Один и тот же URL в основном документе и iframe не означает один runtime. Если в Console видны одинаковые URL, добавьте к записи имя документа или контекста. Иначе можно потратить время на поиск несуществующего повторного импорта.

\n

Classic-скрипт тоже может выполнять ту же работу. Связка с nomodule рассчитана на старые браузеры, но ошибка в условии, ручная вставка или сборочный шаблон могут оставить оба пути активными. Проверяйте не только module map, но и все вызовы функции, которая создаёт подписку, запрос или DOM-узел.

\n

Порядок диагностики

\n
  1. Запишите симптом: какой обработчик, запрос или DOM-узел повторился. Сохраните адрес страницы и способ воспроизведения.
  2. Временно добавьте в модуль отметку с import.meta.url и счётчиком. Не записывайте в неё пользовательские данные.
  3. Откройте Network, очистите фильтр и перезагрузите страницу. Для каждого module request сохраните Request URL, Initiator, статус, redirect и Content-Type.
  4. Сгруппируйте записи по документу и URL. Разные URL ведут к поиску query, алиаса, другого каталога или второго entry.
  5. Если URL один, проверьте iframe, worker, повторный bootstrap и classic-скрипт. Отдельно найдите все вызовы функции с побочным эффектом.
  6. Если ответ содержит HTML или неверный MIME, исправьте путь либо серверную раздачу assets. После этого повторите запрос напрямую и через страницу.
  7. Удалите учебный счётчик и повторите чистую загрузку. Зафиксируйте один владелец инициализации, фактический URL entry и ожидаемое число вызовов.
\n

Ограничения

\n

Нативные модули не превращают любой путь в пакетный импорт. В браузере относительный путь должен разрешиться в URL, а сервер должен вернуть доступный JavaScript. Спецификаторы вроде имени npm-пакета требуют отдельного механизма, например import map, либо заранее собранного файла. Наличие import в исходнике не означает, что браузер умеет читать структуру node_modules.

\n

Кэш, service worker и редирект могут менять наблюдаемую картину. Для первого прохода отключите сохранение кэша в DevTools и проверьте, кто был Initiator. Service worker проверяйте отдельно: он может изменить тело ответа, но не отменяет необходимость сверить конечный URL и MIME.

\n

Поддержка браузеров зависит от целевой среды. Учебный код использует module scripts и import.meta.url; его нельзя автоматически объявить совместимым со всеми старыми браузерами. Если нужен fallback, его проектируют как отдельный entry и проверяют, что два entry не выполняют один побочный эффект одновременно.

\n

Критерий готовности

\n

Диагностика завершена, когда после чистой загрузки каждый документ имеет ожидаемый entry, каждый импорт имеет проверенный Request URL, ответ содержит JavaScript с корректным MIME, а функция инициализации принадлежит одному владельцу. Для отрицательного пути отдельно доказано, что неверный URL не маскируется HTML-ответом со статусом 200. Повторный тест должен показать ожидаемое число подписок и запросов, а не только отсутствие ошибки в Console.

\n

Так «модуль запускается дважды» превращается в короткую цепочку доказательств: документ, entry, разрешённый URL, ответ, побочный эффект. Каждый шаг можно проверить отдельно. Это быстрее, чем менять обработчики и конфигурацию наугад.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/320.json b/editorial/agent-rewrites/320.json new file mode 100644 index 0000000..3b75e57 --- /dev/null +++ b/editorial/agent-rewrites/320.json @@ -0,0 +1,7 @@ +{ + "index": 320, + "slug": "editorial-2019-02-mechanism-es-modules", + "title": "ES-модули в браузере и Webpack: где разрешается import", + "excerpt": "Одинаковый синтаксис import проходит разные границы. Разбираем, как браузер строит URL графа модулей, что добавляет Webpack и как быстро найти причину 404, MIME-ошибки или «модуль не найден».", + "contentHtml": "

Сборка проходит, но страница падает в браузере: Failed to resolve module specifier, 404 на зависимость или ответ с index.html вместо JavaScript. Иногда приложение запускается дважды после добавления второго тега script. Цена ошибки — не только сломанный экран. Команда тратит время на настройку Webpack, хотя браузер не получил корректный URL, либо чинит путь в HTML, хотя проблема возникла ещё на этапе сборки.

\n

Главный тезис прост: слово import одинаково выглядит в исходнике, но его разрешают разные системы. Native-модуль передаёт спецификатор браузеру. Браузер превращает его в URL, загружает граф и проверяет ответы сервера. Webpack читает этот же исходник раньше, находит пакет по своим правилам и выпускает готовый asset. Поэтому диагноз начинается с вопроса: какой именно файл сейчас читает import — браузер или сборщик?

\n

Две границы одного import

\n

В native-сценарии HTML подключает entry-файл как модуль. Например, браузер получает /demo/assets/main.js. Внутри файл содержит import { apiRoot } from './config.js'. Спецификатор разрешается относительно URL импортёра, то есть относительно /demo/assets/main.js. Запрос уйдёт на /demo/assets/config.js. Адрес страницы /demo/index.html здесь не является базой.

\n

Браузер не ищет файл в node_modules и не применяет resolve.extensions из Webpack. Для учебного native-сценария ./config.js — URL-подобный адрес, а config не обязан автоматически превратиться в config.js. Bare-спецификатор вроде date-kit тоже не становится URL сам по себе. Для него нужен отдельный механизм отображения, например import map, либо сборка.

\n

Webpack работает до браузера. Он читает import { format } from 'date-kit', проверяет alias, package exports, расширения и loaders, а затем включает код в bundle или отдельный chunk. В HTML браузер может увидеть только /assets/app.8f3c.js. Он не знает, как Webpack нашёл date-kit. Если пакет не разрешился, ошибка относится к сборке. Если asset загрузился, но браузер получил HTML или заблокировал cross-origin-запрос, ошибка относится к доставке.

\n
Запись importNative-браузерWebpackПроверка
./format.jsURL от файла-импортёраМожет оставить путь или включить файл в bundleСверить Request URL и путь entry
/assets/format.jsURL от origin страницыМожет обработать как путь проектаСопоставить public path и URL ответа
date-kitНе готовый URL без дополнительного отображенияИщет пакет, alias или поле package.jsonИскать причину на этапе сборки
./formatНе обязан добавлять .jsМожет подобрать расширение по resolveПроверить точный URI отдельно
\n

Эта таблица разделяет наблюдения, а не предлагает универсальную конфигурацию. Сборщик может изменить любой результат до отправки к браузеру. Но его правила не становятся правилами native-модулей только потому, что исходная строка выглядит одинаково.

\n

Почему путь считают от импортёра

\n

Рассмотрим минимальный каталог. Он намеренно использует явные расширения и не зависит от Webpack.

\n
<!-- /demo/pages/index.html -->\n<script type=\"module\" src=\"../assets/app/main.js\"></script>\n\n// /demo/assets/app/main.js\nimport { apiRoot } from './config.js';\nconsole.log('API:', apiRoot);\n\n// /demo/assets/app/config.js\nexport const apiRoot = '/api/v1';
\n

После открытия страницы Network должен показать запрос к /demo/assets/app/main.js, затем к /demo/assets/app/config.js. Если написать в main.js import './config', браузер отправит запрос к адресу без суффикса. Сервер может ответить 404. SPA-правило может вернуть статус 200 и тело index.html. В обоих случаях ошибка находится в URL или в раздаче ассетов, а не в tree shaking.

\n

Та же папка в Webpack может собраться без расширения. Это не противоречие. Webpack применил свой resolve.extensions до появления браузерного запроса. Чтобы увидеть границу, сравните исходную строку, сообщение сборщика и фактический Request URL. Нельзя использовать успешное разрешение в bundle как доказательство, что тот же файл можно подключить напрямую.

\n

Граф модулей и порядок вычисления

\n

Статический import не является вызовом, который выполняется в середине тела файла. Среда сначала строит связи графа. Зависимость должна быть найдена и подготовлена до вычисления модуля, который её импортирует. Поэтому строка после import не может заранее создать глобальную переменную для импортируемого файла.

\n
// config.js\nconsole.log('1. вычисляется config.js');\nexport const apiRoot = '/api/v1';\n\n// main.js\nimport { apiRoot } from './config.js';\nconsole.log('2. main.js получил ' + apiRoot);
\n

В учебной странице сначала появится сообщение из config.js, затем сообщение из main.js. Это не делает побочные эффекты верхнего уровня хорошим способом инициализации. Если два entry меняют один window-объект, результат становится хрупким. Надёжнее оставить одного владельца запуска и передать ему явную функцию.

\n

У module script без async браузер учитывает готовность графа при запуске. Атрибут async меняет момент выполнения относительно документа и других скриптов. Webpack может добавить runtime, динамические чанки и собственный порядок загрузки. Эти детали относятся к его asset-графу. Они не меняют смысл ошибки native import и не исправляют неверный HTTP-ответ.

\n

Симптомы, причины и действия

\n
СимптомПричинаПроверкаДействие
Failed to resolve module specifier на bare-имениNative-браузер не получил отображение имени пакета в URLОткрыть исходный HTML и Network; проверить тип запускаИспользовать URL, import map или bundle
404 на ./config.jsПуть считают от HTML, а не от импортёра, или файл не раздаётсяСравнить URL main.js, Request URL и дерево assetsИсправить спецификатор или маршрут статики
200, но MIME-ошибкаСервер вернул HTML, неверный MIME или SPA fallbackПосмотреть Response, Content-Type и redirectНастроить раздачу JavaScript; не менять alias
Cross-origin import заблокированОтвет не прошёл CORS-проверку модуляПроверить origin, заголовки и фактический URL ответаИсправить политику сервера или разместить asset в нужном origin
Пакет не найден при сборкеОшибка resolver, alias, версии или package exportsСохранить сообщение Webpack и stats той же сборкиИсправить конфигурацию или зависимость до публикации bundle
Инициализация повториласьДва entry, разные URL модуля или второй документСравнить import.meta.url, теги, iframe и NetworkОставить одного владельца запуска или явно разделить entry
\n

Один текст ошибки может иметь несколько причин. Например, статус 200 не доказывает, что модуль загрузился: сервер мог вернуть HTML. Два тега с одинаковым URL тоже не доказывают двойное вычисление. Сначала зафиксируйте URL и тело ответа, затем делайте вывод о графе.

\n

Учебная проверка URL и identity

\n

Следующий фрагмент нужен только для локальной учебной страницы. Он показывает URL, который среда передала модулю, и число отметок для этого URL. Это не production-метрика и не замена Network.

\n
// assets/init.js — временная учебная диагностика\nconst url = import.meta.url;\nconst runs = window.__moduleRuns || (window.__moduleRuns = {});\nruns[url] = (runs[url] || 0) + 1;\nconsole.log('[module-check]', { url, count: runs[url] });\n\nexport function startWidget(root) {\n  root.textContent = 'widget started';\n}\n\n// assets/main.js\nimport { startWidget } from './init.js';\nstartWidget(document.querySelector('#widget'));
\n

При одном entry ожидается одна запись с URL вроде .../assets/init.js. Если появились /assets/init.js и /assets/init.js?variant=second, это два разных адреса, а значит, их нужно рассматривать как две module identity. Query может быть осознанным cache busting и не является ошибкой сам по себе. Ошибка возникает, когда второй адрес незаметно запускает тот же побочный эффект.

\n

Если один URL отмечен дважды, проверяйте не только module map. Посмотрите iframe, повторную загрузку документа, classic-скрипт с тем же действием и второй bootstrap. Лог помогает построить гипотезу. Окончательный вывод требует фактических URL, initiator и ответа сервера.

\n
\"Граница
Одна строка import проходит разные этапы. Native-браузер идёт от URL импортёра к сетевому графу. Webpack сначала разрешает зависимости, затем отдаёт браузеру готовый asset.
\n

Порядок проверки

\n
  1. Зафиксируйте симптом, адрес страницы и способ запуска: прямой type=\"module\" или production-bundle.
  2. Для native-сценария выпишите полный URL entry и каждого failed import. Считайте относительный путь от файла-импортёра.
  3. В Network проверьте статус, итоговый URL после redirect, Content-Type, Response и Initiator для entry и зависимости.
  4. Если спецификатор — bare-имя или не содержит нужного расширения, решите, должен ли его обработать import map, серверный маршрут или Webpack.
  5. Для сборки сохраните сообщение resolver и stats той же версии lock-файла. Проверьте resolved-путь, alias, chunk и asset, не перенося эти правила в Console браузера.
  6. Если важен порядок запуска, добавьте временную учебную отметку с import.meta.url, сравните URL и уберите отметку после диагноза.
  7. Повторите проверку после одной правки. Готовность подтверждается чистой загрузкой страницы без ошибки разрешения, корректным ответом каждого модуля и одним понятным владельцем инициализации.
\n

Ограничения

\n

Native-модули требуют браузерной поддержки module scripts. Для старого браузера можно выпускать отдельный classic-артефакт с nomodule, но это второй путь доставки, который проверяют отдельно. Наличие fallback не исправляет неверный native URL.

\n

CORS, redirect, CSP, service worker и серверный rewrite могут изменить наблюдаемую загрузку. Проверяйте итоговый ответ, а не только исходную строку в файле. Отключение защиты браузера не подтверждает рабочую политику сервера.

\n

Webpack здесь служит конкретным примером сборщика. Другие инструменты иначе называют chunks и настраивают resolver, но граница сохраняется: до браузера инструмент строит свой граф, после браузер загружает опубликованные URL. Учебные фрагменты показывают механизм и форму наблюдения. Они не сообщают production-результаты и не заменяют трассу конкретного приложения.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/321.json b/editorial/agent-rewrites/321.json new file mode 100644 index 0000000..c0f891f --- /dev/null +++ b/editorial/agent-rewrites/321.json @@ -0,0 +1 @@ +{"index":321,"slug":"editorial-2019-02-practice-es-modules","title":"ES-модули в браузере: как найти причину 404, MIME и CORS","excerpt":"Страница загрузила HTML, но интерфейс не запустился: браузер не нашёл entry-модуль или его зависимость. Разбираем нативный граф ES-модулей, проверяем URL и ответ сервера, а затем фиксируем критерий готовности без сборщика.","contentHtml":"

HTML открылся, но кнопка не появилась. В Console видна ошибка импорта, а в Network — 404, ответ с HTML или отказ CORS. Цена ошибки — не только один пустой экран. Если заменить модуль готовым bundle наугад, причина останется в URL или сервере и проявится на следующем экране. Команда потратит время на повторные сборки, хотя браузер не получил нужный JavaScript.

\n

Тезис простой: нативный ES-модуль запускается только после успешной загрузки всего графа. Нужно проверить entry, URL каждого импорта, HTTP-ответ и результат выполнения. Синтаксис import не настраивает Webpack, не ищет файл в node_modules и не исправляет SPA-маршрутизацию. Ниже — самостоятельный учебный пример. Он показывает механизм и порядок проверки, но не утверждает, что так устроен конкретный production-сайт.

\n

Что именно загружает браузер

\n

В HTML ставят <script type=\"module\" src=\"./assets/app.js\">. Значение module меняет режим скрипта. Браузер получает URL entry, читает его статические импорты, строит граф и загружает зависимости. Затем он вычисляет модули в порядке их связей. Объявления верхнего уровня модуля не становятся случайными свойствами window. Связь между файлами задают экспорт и импорт.

\n

Здесь есть две границы. ECMAScript описывает модуль, его экспорты и зависимости. HTML и Fetch описывают загрузку ресурса, URL, CORS и момент запуска. Сборщик может заранее разрешить имя пакета, объединить файлы и создать chunk. Нативный браузер этого не делает только потому, что встретил слово import.

\n

Статический импорт — это не вызов функции в середине тела модуля. Зависимость должна быть доступна до вычисления импортёра. Поэтому ошибка в message.js не позволяет считать app.js готовым. Один console.log в entry не закрывает проверку: лог может не появиться из-за неверного URL, а появившийся лог не доказывает правильный ответ всех зависимостей.

\n

Симптом → причина → проверка → действие

\n
Матрица диагностики нативного графа
СимптомПричинаПроверкаДействие
HTML есть, интерфейс пустEntry не загрузился или не вычислилсяNetwork: запрос app.js; Console: текст ошибкиПроверить type=\"module\", URL, статус и ответ entry
Импорт отвечает 404Относительный путь указывает не в ту папкуСкопировать фактический Request URL из NetworkИсправить спецификатор в файле-импортёре
Статус 200, но модуль не запускаетсяСервер вернул HTML fallback или неверный MIMEОткрыть Response и проверить Content-TypeОтделить маршрут assets от маршрута приложения
Другой origin блокирует импортОтвет не проходит CORS-проверку модуляПроверить origin и CORS-заголовки ответаРазрешить нужный origin или отдать файл с того же origin
Работает после сборки, не работает напрямуюСборщик добавил resolution, alias или расширениеСравнить исходный import с URL native-запросаЛибо дать браузеру URL, либо запускать проверенный bundle
\n

404 и MIME-ошибку нельзя лечить одной перестановкой импортов. Сначала сохраняют Request URL, статус, redirect, Response и Content-Type. Если ответ содержит разметку страницы, сервер вернул не модуль. Статус 200 не превращает HTML в JavaScript.

\n

Минимальный пример с тремя файлами

\n

Пример рассчитан на локальный HTTP-сервер. Открытие через file:// не является проверкой веб-доставки: у файлового URL другая модель origin, а поведение доступа к зависимостям отличается от HTTP. В каталоге должны лежать index.html, assets/app.js и assets/message.js.

\n
<!-- index.html -->\n<!doctype html>\n<meta charset=\"utf-8\">\n<main>\n  <h1>Статус загрузки</h1>\n  <output id=\"status\">ожидание</output>\n</main>\n<script type=\"module\" src=\"./assets/app.js\"></script>\n\n// assets/message.js\nexport function message(name) {\n  return \"модуль \" + name + \" получен\";\n}\n\n// assets/app.js\nimport { message } from \"./message.js\";\n\nconst status = document.querySelector(\"#status\");\nstatus.textContent = message(\"app.js\");\nconsole.log(\"Учебный entry:\", import.meta.url);
\n

Это учебная схема, а не трасса реального проекта. После запуска браузер должен запросить assets/app.js, затем assets/message.js. В #status появляется строка из экспорта. В Console виден URL entry. Для готовности нужны все три наблюдения: два корректных сетевых ответа, DOM-результат и отсутствие ошибки разрешения.

\n

Как вычисляется относительный путь

\n

Спецификатор ./message.js считается от URL файла, который его содержит. Если entry находится по адресу /demo/assets/app.js, браузер запрашивает /demo/assets/message.js. Он не считает путь от index.html и не обязан добавлять расширение. Поэтому ./message — это другой URL, а не сокращённая запись ./message.js.

\n
// /demo/pages/index.html\n<script type=\"module\" src=\"../assets/app.js\"></script>\n\n// /demo/assets/app.js\nimport { message } from \"./message.js\";\n\n// Браузер ищет: /demo/assets/message.js\n// Он не ищет: /demo/message.js\n// и не добавляет .js к import \"./message\"
\n

Практическая проверка не требует догадки. Откройте failed request, определите URL импортёра и сравните его каталог с каталогом ожидаемого файла. Если файл лежит в другом месте, исправьте один спецификатор или структуру каталогов. Если файл существует, но приходит HTML, ищите правило раздачи статических файлов и SPA fallback.

\n

Bare-имя вроде date-fns не является обычным URL для этого учебного native-сценария. Webpack может найти пакет по node_modules, alias и полю resolve. Браузер без import map или другого явно настроенного механизма не получает эти правила. Не переносите конфигурацию сборщика в Console браузера.

\n

Сервер входит в контракт модуля

\n

Браузер получает модуль как ресурс по URL. Для того же origin всё равно важны статус, итоговый URL после redirect и тип содержимого. Для другого origin добавляется CORS-проверка. Ошибка MIME или CORS находится на границе доставки. Её не исправит добавление ещё одного import и не объяснит отсутствие файла в JavaScript-коде.

\n

Особенно часто ломается SPA fallback. Маршрутизатор знает, что неизвестный путь страницы надо заменить на index.html. Но запрос /assets/message.js должен получить JavaScript, а не тот же HTML. Если Network показывает 200, откройте Response. Содержимое важнее одного status code.

\n

При cross-origin загрузке проверяйте точный origin страницы и заголовки ответа. Не отключайте защиту браузера как способ доказать исправность production. Такая настройка скрывает проблему, а не проверяет серверный контракт. Надёжнее временно отдать учебные файлы с того же origin и затем отдельно проверить разрешённый cross-origin путь.

\n
\"Учебный
Граф проверяют по цепочке: entry, зависимость, ответ сервера и изменение DOM. Иллюстрация показывает учебный маршрут, а не измерение production.
\n

Момент запуска и async

\n

Module script без async ведёт себя как отложенный скрипт относительно разбора документа: браузер может загружать граф параллельно, а вычисляет его после разбора HTML. Поэтому в примере элемент #status уже существует. Это не повод считать любой верхнеуровневый код безопасным. Если модулю нужен элемент, состояние или другой bootstrap, условие должно быть видно в коде.

\n

Атрибут async меняет момент вычисления. Скрипт может выполниться сразу после готовности графа, ещё до конца разбора документа. Для виджета это создаёт отрицательный путь: querySelector вернёт null, хотя URL и MIME исправны. Не добавляйте async как универсальное ускорение. Сначала проверьте, что код не зависит от ещё не разобранной разметки.

\n

Для старых браузеров можно держать classic-версию с nomodule. Это отдельный артефакт и отдельный сценарий. Нельзя считать fallback проверенным только потому, что современный браузер успешно выполнил module script.

\n

Порядок проверки

\n
  1. Зафиксировать симптом: пустой элемент, ошибка Console, 404, MIME или CORS. Сохранить адрес страницы и время перезагрузки.
  2. Отдать пример через HTTP-сервер и открыть URL страницы. Не использовать file:// как эквивалент веб-среды.
  3. Проверить HTML: оставить один entry с type=\"module\" и точным src. Не подключать bundle «для надёжности».
  4. В Network включить сохранение запросов и перезагрузить страницу. Записать Request URL, статус, redirect, Response и Content-Type для entry и каждой зависимости.
  5. Для каждого относительного импорта считать путь от файла-импортёра. Проверить расширение и фактическое расположение файла.
  6. Сверить Console и DOM. Успехом считать отсутствие ошибок загрузки, строку в #status и лог только как дополнительную отметку, а не как единственное доказательство.
  7. Если ответ пришёл с другого origin, проверить CORS-заголовки. Если ответ содержит HTML, исправить раздачу assets, а не менять JavaScript наугад.
  8. Повторить тот же сценарий после одной правки. Затем удалить учебные отметки и отдельно проверить fallback, если он нужен продукту.
\n

Ограничения и отрицательный путь

\n

Нативные модули не заменяют сборку во всех проектах. Старый браузер может не поддерживать module scripts. Приложению могут требоваться transpile, polyfill, code splitting, tree shaking или обработка пакетов. Эти задачи принадлежат сборщику и не решаются добавлением type=\"module\".

\n

Даже успешный минимальный пример не доказывает производительность страницы. Он не измеряет размер ответа, кэш, CDN, время CPU и реальный порядок виджетов. Он доказывает только маршрут загрузки простого графа. Учебные логи и import.meta.url нельзя переносить в production без отдельного решения о наблюдаемости.

\n

Если entry загрузился, но DOM не изменился, не объявляйте виноватым URL. Проверьте исключение внутри модуля, наличие элемента, порядок запуска и данные функции. Если два запроса имеют 200, но один ответ — HTML, причина всё ещё на серверной границе. Если native-сценарий не поддерживается целевой средой, отрицательный результат честно ведёт к сборке и fallback, а не к бесконечным правкам пути.

\n

Проверяемый критерий готовности

\n

Проверка готова, когда другой инженер повторяет её с чистой перезагрузки и получает тот же результат. В Network видны entry и все статические зависимости с ожидаемыми URL, допустимыми статусами и JavaScript-ответами. Console не содержит ошибок разрешения, MIME и CORS. DOM содержит ожидаемую строку. Отдельно зафиксирован отрицательный путь: неверный URL даёт обнаруживаемую ошибку, а HTML fallback не принимается за рабочий модуль.

\n

Для production-страницы критерий дополняют целевой браузер, политика cross-origin, нужный fallback и способ доставки. Если эти условия не заданы, готовым считается только учебный маршрут, а не вся система. Такой результат проверяем: он связывает симптом с конкретным запросом, запрос с причиной, а действие — с наблюдаемым изменением.

\n

Проверяемые источники

\n"} diff --git a/editorial/agent-rewrites/322.json b/editorial/agent-rewrites/322.json new file mode 100644 index 0000000..966bd2d --- /dev/null +++ b/editorial/agent-rewrites/322.json @@ -0,0 +1,7 @@ +{ + "index": 322, + "slug": "editorial-2019-01-field-jquery-webpack", + "title": "jQuery и Webpack: как вернуть legacy-плагин после production-сборки", + "excerpt": "Старый jQuery-плагин работает в development, но пропадает после production-сборки. Разбираем порядок исполнения, глобальный window.jQuery и второй экземпляр зависимости, затем проверяем исправление в браузере и stats.json.", + "contentHtml": "

В development поле с маской номера работает. После production-сборки вызов $('.js-phone').legacyMask() падает с ошибкой о неизвестном методе. Иногда ошибка выглядит иначе: window.jQuery равен undefined, а иногда маска не падает, но не меняет поле. Цена ошибки — не только красная строка в Console. Пользователь не может ввести номер, форма отбрасывает корректное значение, а релиз приходится откатывать или срочно пересобирать.

\n

Главный тезис прост: production не «ломает» jQuery сам по себе. Сборка выявляет скрытый контракт старого плагина. Плагин может ожидать глобальный window.jQuery, запуститься до создания этого глобала или расширить другой экземпляр jQuery. Поэтому нужно проверять не только наличие файла в bundle, но и три факта в рантайме: какой объект получил плагин, когда он выполнился и тем ли объектом пользуется приложение.

\n

Что именно теряется

\n

Большинство старых jQuery-плагинов добавляет метод в $.fn. Для диагностики важен не общий вопрос «загрузился ли inputmask», а конкретная проверка: typeof $.fn.legacyMask равен function или нет. Если метод отсутствует, плагин не установил расширение на тот объект, который вызывает приложение.

\n

Модульный импорт и глобальная переменная — разные механизмы. Строка import $ from 'jquery' даёт модулю ссылку на экспорт пакета. Она не обязана записывать ту же ссылку в window.jQuery. ProvidePlugin тоже решает более узкую задачу: Webpack подставляет модуль вместо свободного идентификатора в обработанных модулях. Это не универсальная команда присвоить значение свойству window.

\n

Разница проявляется на границе legacy-кода. Старый файл часто написан как самовызывающаяся функция и читает глобал при выполнении:

\n
(function installLegacyMask(root) {\n  var $ = root.jQuery;\n\n  if (!$ || !$.fn) {\n    throw new Error('legacyMask expects window.jQuery');\n  }\n\n  $.fn.legacyMask = function legacyMask() {\n    return this.addClass('has-legacy-mask');\n  };\n}(window));
\n

Это учебный пример. Он не сообщает результат конкретного production-проекта. Он показывает контракт: к моменту выполнения файла в window.jQuery должен лежать объект с прототипом fn. Если объект появится позже, плагин уже не узнает о нём.

\n

Почему порядок импортов обманывает

\n

Такой код выглядит последовательным, но задаёт неправильный порядок:

\n
import $ from 'jquery';\nimport './vendor/legacy-mask';\n\nwindow.jQuery = window.$ = $;\n\n$('.js-phone').legacyMask();
\n

Статические импорты образуют граф зависимостей. Тело модуля не является сценой, на которой Webpack выполняет все строки сверху вниз до разбора следующего импорта. Legacy-файл может выполниться до присваивания в window. В development это иногда скрывает внешний тег script или другой entry, который случайно создаёт глобал раньше.

\n

Безопаснее ограничить старую зависимость маленьким адаптером. Он импортирует один объект jQuery, публикует его в глобальной области и только потом запускает side effect старого файла:

\n
// src/legacy-jquery-bridge.js\nimport $ from 'jquery';\n\nwindow.jQuery = $;\nwindow.$ = $;\nrequire('./vendor/legacy-mask');\n\nexport default $;\n\n// src/bootstrap.js\nimport $ from './legacy-jquery-bridge';\n\n$('.js-phone').legacyMask();
\n

Вызов require() здесь учебный и локальный. Он нужен, чтобы явно показать порядок запуска legacy-файла. Это не рекомендация смешивать CommonJS и ES-модули во всём новом коде. Новый компонент должен принимать зависимость импортом и не менять глобальную область.

\n

Три причины одного симптома

\n
СимптомПричинаПроверкаДействие
window.jQuery пуст до запуска плагинаИмпорт существует только внутри модуляПоставить остановку перед legacy-файлом и вывести window.jQueryСоздать один bridge и загрузить плагин после записи глобала
Метод есть у глобала, но отсутствует у импортированного $Плагин расширил другой экземпляр jQueryСравнить window.jQuery === $ и оба значения typeof $.fn.legacyMaskУбрать второй путь зависимости или выровнять его разрешение
Метода нет ни у глобала, ни у импортаФайл не попал в чанк, получил ошибку или выполнился до bridgeПроверить Console, Network и modules в stats-файлеИсправить entry/порядок, затем повторить production-проверку
Сбой появляется только на странице со вторым entryОбщий модуль попал в разные графы или версии jQuery различаютсяНайти resolved-пути и chunks для jqueryДедуплицировать зависимость, настроить общую часть и проверить рантайм
\n

Таблица задаёт дерево гипотез, а не готовый диагноз. Тот же симптом дают 404 чанка, CSP, старый кеш CDN и несовместимая версия плагина. Поэтому не стоит начинать с отключения минификации. Сначала нужно подтвердить объект и порядок, затем проверить доставку файлов.

\n
\"Схема
Сначала создайте общий объект jQuery, затем выполните legacy-плагин и только после этого проверяйте метод на $.fn.
\n

Как доказать, что экземпляр один

\n

Проверки в браузерной консоли должны сравнивать ссылки, а не только версии. Два объекта могут иметь одну и ту же строку версии и разные прототипы. Плагин добавит метод одному объекту, а приложение вызовет другой.

\n
import $ from './legacy-jquery-bridge';\n\nconst report = {\n  hasGlobal: Boolean(window.jQuery),\n  sameInstance: window.jQuery === $,\n  pluginOnImport: typeof $.fn.legacyMask,\n  pluginOnGlobal: window.jQuery\n    ? typeof window.jQuery.fn.legacyMask\n    : 'no-global',\n};\n\nconsole.table(report);\nconsole.assert(report.sameInstance, 'different jQuery instances');\nconsole.assert(\n  report.pluginOnImport === 'function',\n  'legacy plugin is not installed on the app instance',\n);
\n

Ожидаемый результат учебной схемы: hasGlobal и sameInstance равны true, оба поля плагина имеют значение function. Это проверяемое условие, а не обещание производительности. В конкретном проекте нужно также убедиться, что браузер загрузил именно новые entry и lazy-чаны.

\n

Как найти дубликат в графе

\n

Команда npm ls jquery показывает дерево пакетов, но не доказывает, что браузер создал два объекта. Совместимые зависимости сборщик может объединить. Обратная ситуация тоже возможна: один и тот же пакет войдёт в разные chunks по разным resolved-путям. Для точного ответа нужен production-статс того же lock-файла и ссылка на фактически загруженные чанки.

\n
# Учебный маршрут диагностики. Это не результат запуска в статье.\nnpx webpack --mode production --profile --json > dist/stats.json\nnpm ls jquery\n\n# В stats.json ищите resolved-пути, modules и chunks с jquery.\n# В браузере сравните window.jQuery и импорт из bridge.
\n

Статистика Webpack содержит assets, chunks и modules. Если она показывает несколько путей к jQuery, это повод изучить граф, но не окончательное доказательство дубликата в рантайме. Закрыть гипотезу можно только вместе с равенством объектов, наличием метода и Network-проверкой загруженных файлов. Если различаются версии, сначала закрепите совместимую версию и проверьте плагин на ней. Если различаются entry, настройте общую зависимость только после проверки всех страниц.

\n

Порядок действий

\n
  1. Зафиксируйте точный симптом: страницу, селектор, имя метода, текст ошибки, версию ассетов и commit сборки.
  2. Перед вызовом плагина выведите window.jQuery, импортированный $, результат сравнения ссылок и тип метода в $.fn.
  3. Откройте Network. Проверьте ответы entry и lazy-чанов, hash файлов и отсутствие старого HTML или кешированного bundle.
  4. Соберите production-статистику на том же lock-файле. Найдите все resolved-пути, chunks и причины подключения jQuery.
  5. Если плагин читает глобал, добавьте один bridge до его side effect. Если плагин поддерживает импорт, уберите глобальную зависимость.
  6. Повторите проверку на чистой странице, в каждом entry и в сценарии, где виджет загружается лениво.
\n

Ограничения

\n

Bridge применим в браузерном коде, где старый файл действительно читает window.jQuery. В SSR, worker и тестовом окружении глобальный объект может отсутствовать. Адаптер должен выполняться только в браузере или получать объект окружения явно.

\n

Глобальная jQuery остаётся техническим слоем совместимости. Она увеличивает связанность и усложняет порядок загрузки. Для нового кода лучше использовать явный импорт и передавать зависимость через модульный интерфейс. Не добавляйте ProvidePlugin, если проблема вызвана только отсутствующим window.jQuery: он может скрыть свободный идентификатор, но не исправить внешний контракт.

\n

Source map помогает расследованию, но может раскрыть пути и исходный код. Храните диагностический stats-файл и карты в защищённом месте, если политика проекта не разрешает их публикацию. Не объявляйте учебную проверку результатом production-мониторинга: без запуска на конкретной сборке нельзя утверждать, что её chunks загружены или что пользовательский сценарий исправлен.

\n

Критерий готовности

\n

Исправление готово, когда в production-сборке, на чистом браузерном профиле и для каждого затронутого entry одновременно выполняются четыре условия: window.jQuery существует до запуска legacy-файла; window.jQuery === $; typeof $.fn.legacyMask === 'function'; Network показывает актуальные chunks без 404. Дополнительно проверяется реальное поле, а не только Console.

\n

Если любое условие не выполнено, проблема не закрыта. Ошибка могла исчезнуть из-за случайного порядка или кеша. Если все условия выполнены, граница между модульным кодом и legacy-плагином стала явной: один объект создаётся, bridge публикует его, плагин расширяет его прототип, приложение вызывает тот же объект.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/323.json b/editorial/agent-rewrites/323.json new file mode 100644 index 0000000..e420e23 --- /dev/null +++ b/editorial/agent-rewrites/323.json @@ -0,0 +1,7 @@ +{ + "index": 323, + "slug": "editorial-2019-01-mechanism-jquery-webpack", + "title": "jQuery в Webpack: почему legacy-плагин теряет глобальный объект", + "excerpt": "В development форма работает, а production-сборка получает undefined вместо window.jQuery. Разбираем разницу между ProvidePlugin и глобальным объектом, порядок запуска legacy-плагина и проверку фактических production-ассетов.", + "contentHtml": "

В development поле телефона принимает ввод, а после production-сборки браузер сообщает, что window.jQuery не определён. Иногда ошибка выглядит иначе: jQuery существует, но у поля нет метода старого плагина. Локальная форма работает, релизная — нет. Цена ошибки — сломанная форма на реальном маршруте и повторная сборка с догадками о порядке скриптов.

\n

Тезис простой: доступный в модуле идентификатор $ и свойство window.jQuery — разные контракты. Webpack может подставить модуль в код, который обращается к свободному имени. Legacy-плагин может читать глобальный объект сразу при загрузке. Тогда важны не только пакет и конфигурация, но и момент выполнения каждого файла.

\n

Сначала зафиксируйте симптом

\n

Проверьте один и тот же сценарий в development и production: открыть страницу, найти поле, дождаться загрузки, ввести значение и посмотреть консоль. Запишите точное место отказа. Ошибка window.jQuery is undefined указывает на глобальный контракт. Ошибка $(...).inputmask is not a function может означать ранний запуск плагина, вторую копию jQuery или отсутствие регистрации метода.

\n

Учебный пример ниже использует старый jQuery-плагин, который выполняется во время загрузки файла. Это не утверждение о поведении каждой версии inputmask. Конкретный пакет нужно проверить в своей версии: прочитать entry файла, поставить остановку перед инициализацией и посмотреть, какое имя он читает.

\n

Механизм: два слоя видимости

\n

Модуль получает свои импорты через граф зависимостей. Глобальный объект живёт в окружении страницы. Когда код пишет import $ from 'jquery', он получает локальную переменную. Эта строка сама по себе не обязана создать window.$ или window.jQuery. Присваивание в window делает отдельный мост.

\n

ProvidePlugin работает на этапе сборки. Webpack подставляет модуль, когда встречает свободный идентификатор в анализируемом модуле. Поэтому конфигурация с $ и jQuery помогает исходному коду, который вызывает их без явного импорта. Если библиотека ищет именно window.jQuery, задайте этот контракт явно или создайте его в bootstrap-модуле.

\n

Порядок тоже имеет значение. Статический импорт объявляет зависимость до тела текущего модуля. Если импортированный плагин выполняет проверку сразу, он может прочитать глобал до строки, которая должна его создать. Вызов CommonJS require() в учебном примере расположен после присваивания, чтобы граница была видна в runtime. Это приём для legacy-шва, а не рекомендация строить новый код на глобальных переменных.

\n
import $ from 'jquery';\n\nfunction exposeJQuery(jq) {\n  if (window.jQuery && window.jQuery !== jq) {\n    throw new Error('Two jQuery instances reached the page');\n  }\n\n  window.$ = jq;\n  window.jQuery = jq;\n}\n\nexposeJQuery($);\nrequire('inputmask/dist/jquery.inputmask');\nrequire('./legacy-form');
\n

Пример учебный. Он предполагает, что плагин имеет CommonJS-совместимый вход и читает глобал во время загрузки. Если пакет экспортирует фабрику, требует вызова инициализации или использует другой путь, адаптируйте только точку подключения. Не переносите этот код в проект без проверки entry и версии зависимости.

\n

Что делает ProvidePlugin

\n
const webpack = require('webpack');\n\nmodule.exports = {\n  mode: 'production',\n  entry: {\n    site: './src/bootstrap-legacy.js',\n  },\n  plugins: [\n    new webpack.ProvidePlugin({\n      $: 'jquery',\n      jQuery: 'jquery',\n    }),\n  ],\n};
\n

В таком варианте Webpack обслуживает свободные имена внутри модулей, которые он анализирует. Конфигурация не доказывает, что в момент загрузки внешнего legacy-файла уже существует window.jQuery. Она также не доказывает, что HTML подключил одну копию jQuery. Поэтому после настройки проверяйте три факта отдельно: какое имя читает плагин, какой объект назначен глобалу и сколько экземпляров попало в production-граф.

\n

Если зависимость действительно требует глобальное свойство, ProvidePlugin можно настроить на window.jQuery. На старых сборках всё равно полезно оставить явный bootstrap: он показывает владельца глобала, позволяет проверить конфликт экземпляров и задаёт точку перед запуском plugin-кода. Оба подхода требуют проверки конкретного пакета и собранного результата.

\n

Симптомы и минимальные проверки

\n
СимптомПричинаПроверкаДействие
В development работает, в production — undefinedВ dev глобал случайно создаёт layout или внешний scriptСравнить production HTML, Network и initiatorУбрать случайный источник или назначить глобал в явном bootstrap
В модуле есть $, плагин не видит window.jQueryProvidePlugin подставил локальный идентификатор, но не выполнен нужный глобальный контрактПоставить остановку перед загрузкой plugin и проверить window.jQueryСоздать глобал до runtime-загрузки плагина или настроить точное сопоставление
После splitChunks метод пропалHTML, runtime и chunks выпущены не одним набором или изменился порядок исполненияСверить имена фактических ассетов и stats-файлПубликовать HTML и assets как один выпуск; не угадывать имя vendor-файла
На странице две копии jQueryОдна пришла из layout или CDN, вторая — из bundleПроверить window.jQuery === $ и модули с именем jqueryОставить одного владельца и объяснить каждую копию в графе
Глобал есть, но метода нетПлагин не загрузился, выполнился до моста или подключён не тот entryПроверить Network, экспорт plugin и момент регистрации методаИсправить точку подключения; не маскировать отказ повторным вызовом
\n
Порядок загрузки production-ассетов: runtime, chunk с jQuery, bootstrap, legacy-плагин и форма
Иллюстрация показывает учебную модель: runtime и общий chunk загружаются, bootstrap назначает window.jQuery, затем запускается legacy-плагин. Красная ветка обозначает ранний запуск до создания глобала.
\n

Проверяю собранный граф, а не только исходники

\n

Исходный файл показывает намерение, но не фактический порядок production-страницы. После сборки откройте HTML и перечислите стартовые script-теги. Затем в DevTools проверьте запросы, initiator и ошибки выполнения. Если используется runtime Webpack, убедитесь, что он подгружает нужные chunks до вызова bootstrap. Не подставляйте вручную вчерашнее имя vendors~site.js: hashed-имя и набор chunks зависят от конфигурации.

\n

Для учебной проверки можно получить stats-файл и найти в нём модули jQuery:

\n
webpack --mode production --profile --json > dist/stats.json
\n

Команда не выдаёт готовый диагноз. В stats-файле ищите все вхождения jQuery, связь с entry и причины появления chunks. Повторное вхождение требует объяснения, но само число строк не доказывает наличие двух runtime-экземпляров. Сопоставьте граф с проверкой объектов в браузере.

\n

Порядок действий

\n
  1. Зафиксируйте production-симптом на одном URL и одном сценарии формы.
  2. Прочитайте исходный entry legacy-плагина и определите, читает ли он window.jQuery, свободное имя или экспорт функции.
  3. Сравните development и production HTML, script-теги, runtime и chunks.
  4. До запуска плагина проверьте window.jQuery, window.$ и равенство глобала импортированному объекту.
  5. Проверьте Network и initiator: все стартовые ассеты должны прийти без 404 и из одного выпуска.
  6. Соберите stats-файл и найдите все модули jQuery, их entry и причины дублирования.
  7. Если плагин читает глобал при загрузке, назначьте его в bootstrap и вызывайте runtime require() после присваивания.
  8. После фикса очистите кэш, повторите открытие страницы и проверьте регистрацию метода, ввод в поле и отрицательный путь загрузки.
\n

Отрицательный путь и ограничения

\n

Проверяйте не только успешную форму. Если legacy-плагин не загрузился, приложение не должно тихо показать видимость исправной маски. Добавьте явную ошибку в development, fallback для поля и сообщение, которое не блокирует ввод без необходимости. Если загрузка optional-части падает, основная форма должна сохранить понятное состояние.

\n

Глобальный jQuery остаётся техническим долгом. Новые модули лучше писать с явными импортами и локальными зависимостями. Не отключайте splitChunks только потому, что после миграции проявился сбой. Сначала докажите, что нарушен порядок, дублируется библиотека или HTML ссылается на несовместимый набор ассетов.

\n

Версии Webpack, формат пакета, loader и способ генерации HTML меняют детали. Поэтому статья не обещает фиксированное имя chunk и не утверждает конкретный production-результат. Учебная проверка применима только после сверки с версией проекта, исходником плагина и фактическим dist.

\n

Критерий готовности

\n

Исправление готово, когда один и тот же production-сценарий проходит после очистки кэша, window.jQuery равен ожидаемому экземпляру, legacy-плагин регистрирует нужный метод, а форма работает без внешнего случайного script. В Network нет 404, HTML и chunks принадлежат одному выпуску, а stats-файл объясняет каждую копию jQuery. Отказ optional-плагина не скрывает состояние формы.

\n

Если хотя бы один факт не подтверждён, результатом остаётся гипотеза. Не называйте её исправлением. Сначала вернитесь к моменту чтения глобала и отделите проблему видимости от проблемы порядка, графа или DOM.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/324.json b/editorial/agent-rewrites/324.json new file mode 100644 index 0000000..f4dc574 --- /dev/null +++ b/editorial/agent-rewrites/324.json @@ -0,0 +1,10 @@ +{ + "index": 324, + "slug": "использование-jquery-в-webpack", + "title": "jQuery в Webpack: как связать модульный код и legacy-плагины", + "excerpt": "После миграции на Webpack плагин видит $ только на одной странице или получает другой объект jQuery. Разбираем границы ProvidePlugin, window.jQuery и externals и заканчиваем проверяемым критерием готовности.", + "contentHtml": "

После переноса старого фронтенда в Webpack кнопка с inputmask перестаёт работать. В консоли появляется jQuery is not defined, $(...).inputmask is not a function или плагин загружается только на странице с «правильным» порядком скриптов. Ошибка часто выглядит случайной: новый модуль импортирует jQuery, а legacy-файл ищет её в window. Цена ошибки — сломанная форма, дублированная библиотека в нескольких бандлах и релиз, который нельзя уверенно проверить одной сборкой.

\n

Тезис простой: сначала нужно назвать потребителя jQuery, затем выбрать один способ доставки для каждого entry. Явный import связывает зависимость с графом модулей. ProvidePlugin помогает старому коду, который обращается к свободным $ или jQuery. Глобальный объект нужен только тем потребителям, которые действительно читают window.jQuery. CDN и externals образуют другой контракт: Webpack больше не кладёт библиотеку в бандл и ждёт готового глобального объекта снаружи.

\n

Симптом начинается с границы видимости

\n

Модуль с import $ from 'jquery' получает значение из графа Webpack. Это локальная переменная модуля. Она не обещает, что отдельный <script> в HTML увидит window.$. Обратное тоже верно: наличие window.jQuery не добавляет зависимость в граф и не делает её доступной каждому исходному файлу без настройки.

\n

ProvidePlugin решает третий случай. Webpack замечает свободный идентификатор в модуле и автоматически подставляет импорт. Поэтому код с $(selector) может собраться без строки import. Это не запись переменной в глобальный объект браузера и не исправление порядка независимых тегов script.

\n
\"Схема
Один entry должен доставить один объект jQuery раньше плагина, который его использует. Иллюстрация показывает границу между графом модулей и глобальным API браузера.
\n

Три способа доставки

\n

В проекте с несколькими entry не стоит начинать с единого глобального правила. Сначала составьте карту потребителей.

\n
СпособЧто получает кодКогда подходитЧего он не делает
Явный importЛокальный объект из графа WebpackНовый код и код, который можно менятьНе создаёт window.jQuery сам по себе
ProvidePluginАвтоматически подставленный импорт для свободного имениLegacy-модули с $ или jQueryНе чинит внешний script и не гарантирует порядок загрузки
externals или CDNГлобальный объект, предоставленный HTML или платформойОдна контролируемая внешняя загрузкаНе включает jQuery в бандл и не проверяет URL CDN
\n

Для переходного проекта обычно работает связка: новый код импортирует jQuery явно, а ограниченный legacy-слой получает ProvidePlugin. Если плагин проверяет именно window.jQuery, добавьте глобальный экспорт в одном entry. Не смешивайте этот режим с CDN без причины. Иначе один бандл будет использовать пакет, а другой — внешний файл.

\n

Минимальная конфигурация для переходного проекта

\n

Ниже учебный пример. В нём два entry, один пакет jquery и legacy-код, который ещё использует свободное имя. Пример не измеряет размер бандла, не доказывает совместимость конкретного плагина и не заменяет проверку вашей версии Webpack и jQuery.

\n
const webpack = require('webpack');\n\nmodule.exports = {\n  entry: {\n    legacy: './src/legacy-entry.js',\n    modern: './src/modern-entry.js'\n  },\n  plugins: [\n    new webpack.ProvidePlugin({\n      $: 'jquery',\n      jQuery: 'jquery'\n    })\n  ]\n};
\n

В новом модуле зависимость остаётся видимой:

\n
import $ from 'jquery';\n\nexport function mount(form) {\n  return $(form).find('[data-mask]').length;\n}
\n

В legacy-модуле строка импорта может отсутствовать:

\n
$('.phone').inputmask('+7 (999) 999-99-99');
\n

Оба фрагмента должны ссылаться на один экземпляр, если Webpack собирает их в общий runtime или правильно выносит общий модуль. Это нужно проверить в фактической конфигурации. Само совпадение версий в package.json такого доказательства не даёт.

\n

Когда нужен window.jQuery

\n

Некоторые старые плагины не экспортируют функцию как модуль. Они выполняются сразу и ищут window.jQuery. Для такого кода сделайте отдельный модуль-инициализатор и импортируйте его до плагина:

\n
// src/jquery-global.js\nimport $ from 'jquery';\n\nwindow.$ = $;\nwindow.jQuery = $;\n\n// src/legacy-entry.js\nimport './jquery-global';\nimport 'inputmask/dist/jquery.inputmask';\nimport './legacy-app';
\n

Такой порядок относится к импортам внутри одного entry. Он не управляет отдельным CDN-скриптом, который HTML загрузит позже. В браузере проверьте именно window.jQuery перед и после подключения плагина, а затем вызовите метод плагина на реальном элементе формы.

\n

Отрицательный путь: CDN и externals

\n

Внешняя загрузка имеет смысл, когда платформа уже отдаёт jQuery или несколько приложений должны использовать один URL. Тогда Webpack не должен одновременно включать пакет в тот же бандл. Пример для глобального объекта:

\n
module.exports = {\n  externals: {\n    jquery: 'jQuery'\n  }\n};
\n

Теперь import $ from 'jquery' в собранном коде означает обращение к внешнему jQuery. HTML обязан загрузить библиотеку раньше бандла:

\n
<script src=\"https://cdn.example.test/jquery.min.js\"></script>\n<script src=\"/assets/legacy.js\"></script>
\n

URL в примере учебный. Не подставляйте его в рабочую страницу. Для production нужны зафиксированная версия, контроль доступности, политика безопасности и понятный план отказа. Если CDN недоступен, Webpack не сможет компенсировать это настройкой ProvidePlugin. Если браузер загрузит две версии jQuery, плагины могут зарегистрироваться в одном объекте, а приложение — работать с другим.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
jQuery is not definedПлагин выполняется до глобального объектаПорядок Network и typeof window.jQuery перед импортомСобрать entry с инициализатором или исправить порядок внешних scripts
$(...).inputmask is not a functionПлагин получил другой объект или не загрузилсяСравнить window.jQuery === $ и проверить регистрацию $.fn.inputmaskОставить один источник jQuery и импортировать плагин после него
Работает только на одной страницеProvidePlugin действует только в собранных модулях этого entryСравнить entry, HTML и содержимое chunk-файловДобавить зависимость в нужный entry, а не рассчитывать на соседний бандл
Размер растёт после добавления второго entryНет общего chunk или настроены две независимые поставкиПосмотреть stats и число включённых модулей jqueryНастроить общую доставку только после проверки поведения legacy-кода
CDN-версия ломает плагинВерсия или порядок внешней загрузки не совпадает с контрактомПроверить фактический URL, версию и момент выполнения плагинаЗафиксировать совместимую версию или вернуть пакет в граф Webpack
\n

Порядок действий

\n
  1. Найдите все обращения к $, jQuery и window.jQuery. Отдельно отметьте модули, которые выполняются сразу при импорте.
  2. Для каждого entry выберите источник: пакет в графе Webpack или внешний глобальный script. Не оставляйте выбор на уровне случайного HTML-порядка.
  3. Оставьте явный import $ from 'jquery' в новом коде. Добавьте ProvidePlugin только к переходному слою, который нельзя быстро изменить.
  4. Если плагин читает window.jQuery, создайте один инициализатор и импортируйте его перед этим плагином.
  5. Соберите каждый entry и проверьте, что в нём есть ожидаемая зависимость или явно описан externals.
  6. Откройте реальную форму. Проверьте window.jQuery, $.fn.jquery, метод legacy-плагина и обработчик пользовательского события.
  7. После миграции потребителя удалите лишнюю глобальную настройку и повторите проверку. Иначе временная совместимость станет постоянной зависимостью.
\n

Ограничения

\n

ProvidePlugin не превращает любой свободный идентификатор в безопасную архитектуру. Он скрывает зависимость в исходном файле и усложняет перенос модуля в другой сборщик. Используйте его как переходный слой и уменьшайте область действия.

\n

Один объект jQuery не гарантирует совместимость. Плагин может требовать конкретную версию, поддерживать только старый API или конфликтовать с noConflict. Проверяйте реальный сценарий, а не только успешную компиляцию.

\n

В серверном рендеринге и Web Worker нет обычного window. Код, который без проверки обращается к window.jQuery, должен выполняться только в браузерном entry. Это отдельное ограничение окружения, а не проблема Webpack.

\n

Проверяемый критерий готовности

\n

Работу можно считать законченной, когда каждый entry имеет один документированный источник jQuery, сборка не содержит непреднамеренной второй копии, legacy-плагин получает тот же объект, что и приложение, а форма проходит реальный сценарий ввода. Дополнительно зафиксируйте проверку для страницы без legacy-кода: она не должна получать глобальную зависимость только потому, что она есть в соседнем бандле.

\n

Проверяемые источники

\n", + "contentHtml": "

После переноса старого фронтенда в Webpack кнопка с inputmask перестаёт работать. В консоли появляется jQuery is not defined, $(...).inputmask is not a function или плагин загружается только на странице с «правильным» порядком скриптов. Ошибка часто выглядит случайной: новый модуль импортирует jQuery, а legacy-файл ищет её в window. Цена ошибки — сломанная форма, дублированная библиотека в нескольких бандлах и релиз, который нельзя уверенно проверить одной сборкой.

\n

Тезис простой: сначала нужно назвать потребителя jQuery, затем выбрать один способ доставки для каждого entry. Явный import связывает зависимость с графом модулей. ProvidePlugin помогает старому коду, который обращается к свободным $ или jQuery. Глобальный объект нужен только тем потребителям, которые действительно читают window.jQuery. CDN и externals образуют другой контракт: Webpack больше не кладёт библиотеку в бандл и ждёт готового глобального объекта снаружи.

\n

Симптом начинается с границы видимости

\n

Модуль с import $ from 'jquery' получает значение из графа Webpack. Это локальная переменная модуля. Она не обещает, что отдельный <script> в HTML увидит window.$. Обратное тоже верно: наличие window.jQuery не добавляет зависимость в граф и не делает её доступной каждому исходному файлу без настройки.

\n

ProvidePlugin решает третий случай. Webpack замечает свободный идентификатор в модуле и автоматически подставляет импорт. Поэтому код с $(selector) может собраться без строки import. Это не запись переменной в глобальный объект браузера и не исправление порядка независимых тегов script.

\n
\"Схема
Один entry должен доставить один объект jQuery раньше плагина, который его использует. Иллюстрация показывает границу между графом модулей и глобальным API браузера.
\n

Три способа доставки

\n

В проекте с несколькими entry не стоит начинать с единого глобального правила. Сначала составьте карту потребителей.

\n
СпособЧто получает кодКогда подходитЧего он не делает
Явный importЛокальный объект из графа WebpackНовый код и код, который можно менятьНе создаёт window.jQuery сам по себе
ProvidePluginАвтоматически подставленный импорт для свободного имениLegacy-модули с $ или jQueryНе чинит внешний script и не гарантирует порядок загрузки
externals или CDNГлобальный объект, предоставленный HTML или платформойОдна контролируемая внешняя загрузкаНе включает jQuery в бандл и не проверяет URL CDN
\n

Для переходного проекта обычно работает связка: новый код импортирует jQuery явно, а ограниченный legacy-слой получает ProvidePlugin. Если плагин проверяет именно window.jQuery, добавьте глобальный экспорт в одном entry. Не смешивайте этот режим с CDN без причины. Иначе один бандл будет использовать пакет, а другой — внешний файл.

\n

Минимальная конфигурация для переходного проекта

\n

Ниже учебный пример. В нём два entry, один пакет jquery и legacy-код, который ещё использует свободное имя. Пример не измеряет размер бандла, не доказывает совместимость конкретного плагина и не заменяет проверку вашей версии Webpack и jQuery.

\n
const webpack = require('webpack');\n\nmodule.exports = {\n  entry: {\n    legacy: './src/legacy-entry.js',\n    modern: './src/modern-entry.js'\n  },\n  plugins: [\n    new webpack.ProvidePlugin({\n      $: 'jquery',\n      jQuery: 'jquery'\n    })\n  ]\n};
\n

В новом модуле зависимость остаётся видимой:

\n
import $ from 'jquery';\n\nexport function mount(form) {\n  return $(form).find('[data-mask]').length;\n}
\n

В legacy-модуле строка импорта может отсутствовать:

\n
$('.phone').inputmask('+7 (999) 999-99-99');
\n

Оба фрагмента должны ссылаться на один экземпляр, если Webpack собирает их в общий runtime или правильно выносит общий модуль. Это нужно проверить в фактической конфигурации. Само совпадение версий в package.json такого доказательства не даёт.

\n

Когда нужен window.jQuery

\n

Некоторые старые плагины не экспортируют функцию как модуль. Они выполняются сразу и ищут window.jQuery. Для такого кода сделайте отдельный модуль-инициализатор и импортируйте его до плагина:

\n
// src/jquery-global.js\nimport $ from 'jquery';\n\nwindow.$ = $;\nwindow.jQuery = $;\n\n// src/legacy-entry.js\nimport './jquery-global';\nimport 'inputmask/dist/jquery.inputmask';\nimport './legacy-app';
\n

Такой порядок относится к импортам внутри одного entry. Он не управляет отдельным CDN-скриптом, который HTML загрузит позже. В браузере проверьте именно window.jQuery перед и после подключения плагина, а затем вызовите метод плагина на реальном элементе формы.

\n

Отрицательный путь: CDN и externals

\n

Внешняя загрузка имеет смысл, когда платформа уже отдаёт jQuery или несколько приложений должны использовать один URL. Тогда Webpack не должен одновременно включать пакет в тот же бандл. Пример для глобального объекта:

\n
module.exports = {\n  externals: {\n    jquery: 'jQuery'\n  }\n};
\n

Теперь import $ from 'jquery' в собранном коде означает обращение к внешнему jQuery. HTML обязан загрузить библиотеку раньше бандла:

\n
<script src=\"https://cdn.example.test/jquery.min.js\"></script>\n<script src=\"/assets/legacy.js\"></script>
\n

URL в примере учебный. Не подставляйте его в рабочую страницу. Для production нужны зафиксированная версия, контроль доступности, политика безопасности и понятный план отказа. Если CDN недоступен, Webpack не сможет компенсировать это настройкой ProvidePlugin. Если браузер загрузит две версии jQuery, плагины могут зарегистрироваться в одном объекте, а приложение — работать с другим.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
jQuery is not definedПлагин выполняется до глобального объектаПорядок Network и typeof window.jQuery перед импортомСобрать entry с инициализатором или исправить порядок внешних scripts
$(...).inputmask is not a functionПлагин получил другой объект или не загрузилсяСравнить window.jQuery === $ и проверить регистрацию $.fn.inputmaskОставить один источник jQuery и импортировать плагин после него
Работает только на одной страницеProvidePlugin действует только в собранных модулях этого entryСравнить entry, HTML и содержимое chunk-файловДобавить зависимость в нужный entry, а не рассчитывать на соседний бандл
Размер растёт после добавления второго entryНет общего chunk или настроены две независимые поставкиПосмотреть stats и число включённых модулей jqueryНастроить общую доставку только после проверки поведения legacy-кода
CDN-версия ломает плагинВерсия или порядок внешней загрузки не совпадает с контрактомПроверить фактический URL, версию и момент выполнения плагинаЗафиксировать совместимую версию или вернуть пакет в граф Webpack
\n

Порядок действий

\n
  1. Найдите все обращения к $, jQuery и window.jQuery. Отдельно отметьте модули, которые выполняются сразу при импорте.
  2. Для каждого entry выберите источник: пакет в графе Webpack или внешний глобальный script. Не оставляйте выбор на уровне случайного HTML-порядка.
  3. Оставьте явный import $ from 'jquery' в новом коде. Добавьте ProvidePlugin только к переходному слою, который нельзя быстро изменить.
  4. Если плагин читает window.jQuery, создайте один инициализатор и импортируйте его перед этим плагином.
  5. Соберите каждый entry и проверьте, что в нём есть ожидаемая зависимость или явно описан externals.
  6. Откройте реальную форму. Проверьте window.jQuery, $.fn.jquery, метод legacy-плагина и обработчик пользовательского события.
  7. После миграции потребителя удалите лишнюю глобальную настройку и повторите проверку. Иначе временная совместимость станет постоянной зависимостью.
\n

Ограничения

\n

ProvidePlugin не превращает любой свободный идентификатор в безопасную архитектуру. Он скрывает зависимость в исходном файле и усложняет перенос модуля в другой сборщик. Используйте его как переходный слой и уменьшайте область действия.

\n

Один объект jQuery не гарантирует совместимость. Плагин может требовать конкретную версию, поддерживать только старый API или конфликтовать с noConflict. Проверяйте реальный сценарий, а не только успешную компиляцию.

\n

В серверном рендеринге и Web Worker нет обычного window. Код, который без проверки обращается к window.jQuery, должен выполняться только в браузерном entry. Это отдельное ограничение окружения, а не проблема Webpack.

\n

Проверяемый критерий готовности

\n

Работу можно считать законченной, когда каждый entry имеет один документированный источник jQuery, сборка не содержит непреднамеренной второй копии, legacy-плагин получает тот же объект, что и приложение, а форма проходит реальный сценарий ввода. Дополнительно зафиксируйте проверку для страницы без legacy-кода: она не должна получать глобальную зависимость только потому, что она есть в соседнем бандле.

\n

Проверяемые источники

\n", + "contentHtml": "

После переноса старого фронтенда в Webpack кнопка с inputmask перестаёт работать. В консоли появляется jQuery is not defined, $(...).inputmask is not a function или плагин загружается только на странице с «правильным» порядком скриптов. Ошибка часто выглядит случайной: новый модуль импортирует jQuery, а legacy-файл ищет её в window. Цена ошибки — сломанная форма, дублированная библиотека в нескольких бандлах и релиз, который нельзя уверенно проверить одной сборкой.

\n

Тезис простой: сначала нужно назвать потребителя jQuery, затем выбрать один способ доставки для каждого entry. Явный import связывает зависимость с графом модулей. ProvidePlugin помогает старому коду, который обращается к свободным $ или jQuery. Глобальный объект нужен только тем потребителям, которые действительно читают window.jQuery. CDN и externals образуют другой контракт: Webpack больше не кладёт библиотеку в бандл и ждёт готового глобального объекта снаружи.

\n

Симптом начинается с границы видимости

\n

Модуль с import $ from 'jquery' получает значение из графа Webpack. Это локальная переменная модуля. Она не обещает, что отдельный <script> в HTML увидит window.$. Обратное тоже верно: наличие window.jQuery не добавляет зависимость в граф и не делает её доступной каждому исходному файлу без настройки.

\n

ProvidePlugin решает третий случай. Webpack замечает свободный идентификатор в модуле и автоматически подставляет импорт. Поэтому код с $(selector) может собраться без строки import. Это не запись переменной в глобальный объект браузера и не исправление порядка независимых тегов script.

\n
\"Схема
Один entry должен доставить один объект jQuery раньше плагина, который его использует. Иллюстрация показывает границу между графом модулей и глобальным API браузера.
\n

Три способа доставки

\n

В проекте с несколькими entry не стоит начинать с единого глобального правила. Сначала составьте карту потребителей.

\n
СпособЧто получает кодКогда подходитЧего он не делает
Явный importЛокальный объект из графа WebpackНовый код и код, который можно менятьНе создаёт window.jQuery сам по себе
ProvidePluginАвтоматически подставленный импорт для свободного имениLegacy-модули с $ или jQueryНе чинит внешний script и не гарантирует порядок загрузки
externals или CDNГлобальный объект, предоставленный HTML или платформойОдна контролируемая внешняя загрузкаНе включает jQuery в бандл и не проверяет URL CDN
\n

Для переходного проекта обычно работает связка: новый код импортирует jQuery явно, а ограниченный legacy-слой получает ProvidePlugin. Если плагин проверяет именно window.jQuery, добавьте глобальный экспорт в одном entry. Не смешивайте этот режим с CDN без причины. Иначе один бандл будет использовать пакет, а другой — внешний файл.

\n

Минимальная конфигурация для переходного проекта

\n

Ниже учебный пример. В нём два entry, один пакет jquery и legacy-код, который ещё использует свободное имя. Пример не измеряет размер бандла, не доказывает совместимость конкретного плагина и не заменяет проверку вашей версии Webpack и jQuery.

\n
const webpack = require('webpack');\n\nmodule.exports = {\n  entry: {\n    legacy: './src/legacy-entry.js',\n    modern: './src/modern-entry.js'\n  },\n  plugins: [\n    new webpack.ProvidePlugin({\n      $: 'jquery',\n      jQuery: 'jquery'\n    })\n  ]\n};
\n

В новом модуле зависимость остаётся видимой:

\n
import $ from 'jquery';\n\nexport function mount(form) {\n  return $(form).find('[data-mask]').length;\n}
\n

В legacy-модуле строка импорта может отсутствовать:

\n
$('.phone').inputmask('+7 (999) 999-99-99');
\n

Оба фрагмента должны ссылаться на один экземпляр, если Webpack собирает их в общий runtime или правильно выносит общий модуль. Это нужно проверить в фактической конфигурации. Само совпадение версий в package.json такого доказательства не даёт.

\n

Когда нужен window.jQuery

\n

Некоторые старые плагины не экспортируют функцию как модуль. Они выполняются сразу и ищут window.jQuery. Для такого кода сделайте отдельный модуль-инициализатор и импортируйте его до плагина:

\n
// src/jquery-global.js\nimport $ from 'jquery';\n\nwindow.$ = $;\nwindow.jQuery = $;\n\n// src/legacy-entry.js\nimport './jquery-global';\nimport 'inputmask/dist/jquery.inputmask';\nimport './legacy-app';
\n

Такой порядок относится к импортам внутри одного entry. Он не управляет отдельным CDN-скриптом, который HTML загрузит позже. В браузере проверьте именно window.jQuery перед и после подключения плагина, а затем вызовите метод плагина на реальном элементе формы.

\n

Отрицательный путь: CDN и externals

\n

Внешняя загрузка имеет смысл, когда платформа уже отдаёт jQuery или несколько приложений должны использовать один URL. Тогда Webpack не должен одновременно включать пакет в тот же бандл. Пример для глобального объекта:

\n
module.exports = {\n  externals: {\n    jquery: 'jQuery'\n  }\n};
\n

Теперь import $ from 'jquery' в собранном коде означает обращение к внешнему jQuery. HTML обязан загрузить библиотеку раньше бандла:

\n
<script src=\"https://cdn.example.test/jquery.min.js\"></script>\n<script src=\"/assets/legacy.js\"></script>
\n

URL в примере учебный. Не подставляйте его в рабочую страницу. Для production нужны зафиксированная версия, контроль доступности, политика безопасности и понятный план отказа. Если CDN недоступен, Webpack не сможет компенсировать это настройкой ProvidePlugin. Если браузер загрузит две версии jQuery, плагины могут зарегистрироваться в одном объекте, а приложение — работать с другим.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
jQuery is not definedПлагин выполняется до глобального объектаПорядок Network и typeof window.jQuery перед импортомСобрать entry с инициализатором или исправить порядок внешних scripts
$(...).inputmask is not a functionПлагин получил другой объект или не загрузилсяСравнить window.jQuery === $ и проверить регистрацию $.fn.inputmaskОставить один источник jQuery и импортировать плагин после него
Работает только на одной страницеProvidePlugin действует только в собранных модулях этого entryСравнить entry, HTML и содержимое chunk-файловДобавить зависимость в нужный entry, а не рассчитывать на соседний бандл
Размер растёт после добавления второго entryНет общего chunk или настроены две независимые поставкиПосмотреть stats и число включённых модулей jqueryНастроить общую доставку только после проверки поведения legacy-кода
CDN-версия ломает плагинВерсия или порядок внешней загрузки не совпадает с контрактомПроверить фактический URL, версию и момент выполнения плагинаЗафиксировать совместимую версию или вернуть пакет в граф Webpack
\n

Порядок действий

\n
  1. Найдите все обращения к $, jQuery и window.jQuery. Отдельно отметьте модули, которые выполняются сразу при импорте.
  2. Для каждого entry выберите источник: пакет в графе Webpack или внешний глобальный script. Не оставляйте выбор на уровне случайного HTML-порядка.
  3. Оставьте явный import $ from 'jquery' в новом коде. Добавьте ProvidePlugin только к переходному слою, который нельзя быстро изменить.
  4. Если плагин читает window.jQuery, создайте один инициализатор и импортируйте его перед этим плагином.
  5. Соберите каждый entry и проверьте, что в нём есть ожидаемая зависимость или явно описан externals.
  6. Откройте реальную форму. Проверьте window.jQuery, $.fn.jquery, метод legacy-плагина и обработчик пользовательского события.
  7. После миграции потребителя удалите лишнюю глобальную настройку и повторите проверку. Иначе временная совместимость станет постоянной зависимостью.
\n

Ограничения

\n

ProvidePlugin не превращает любой свободный идентификатор в безопасную архитектуру. Он скрывает зависимость в исходном файле и усложняет перенос модуля в другой сборщик. Используйте его как переходный слой и уменьшайте область действия.

\n

Один объект jQuery не гарантирует совместимость. Плагин может требовать конкретную версию, поддерживать только старый API или конфликтовать с noConflict. Проверяйте реальный сценарий, а не только успешную компиляцию.

\n

В серверном рендеринге и Web Worker нет обычного window. Код, который без проверки обращается к window.jQuery, должен выполняться только в браузерном entry. Это отдельное ограничение окружения, а не проблема Webpack.

\n

Проверяемый критерий готовности

\n

Работу можно считать законченной, когда каждый entry имеет один документированный источник jQuery, сборка не содержит непреднамеренной второй копии, legacy-плагин получает тот же объект, что и приложение, а форма проходит реальный сценарий ввода. Дополнительно зафиксируйте проверку для страницы без legacy-кода: она не должна получать глобальную зависимость только потому, что она есть в соседнем бандле.

\n

Проверяемые источники

\n", + "contentHtml": "

После переноса старого фронтенда в Webpack кнопка с inputmask перестаёт работать. В консоли появляется jQuery is not defined, $(...).inputmask is not a function или плагин загружается только на странице с «правильным» порядком скриптов. Ошибка часто выглядит случайной: новый модуль импортирует jQuery, а legacy-файл ищет её в window. Цена ошибки — сломанная форма, дублированная библиотека в нескольких бандлах и релиз, который нельзя уверенно проверить одной сборкой.

\n

Тезис простой: сначала нужно назвать потребителя jQuery, затем выбрать один способ доставки для каждого entry. Явный import связывает зависимость с графом модулей. ProvidePlugin помогает старому коду, который обращается к свободным $ или jQuery. Глобальный объект нужен только тем потребителям, которые действительно читают window.jQuery. CDN и externals образуют другой контракт: Webpack больше не кладёт библиотеку в бандл и ждёт готового глобального объекта снаружи.

\n

Симптом начинается с границы видимости

\n

Модуль с import $ from 'jquery' получает значение из графа Webpack. Это локальная переменная модуля. Она не обещает, что отдельный <script> в HTML увидит window.$. Обратное тоже верно: наличие window.jQuery не добавляет зависимость в граф и не делает её доступной каждому исходному файлу без настройки.

\n

ProvidePlugin решает третий случай. Webpack замечает свободный идентификатор в модуле и автоматически подставляет импорт. Поэтому код с $(selector) может собраться без строки import. Это не запись переменной в глобальный объект браузера и не исправление порядка независимых тегов script.

\n
\"Схема
Один entry должен доставить один объект jQuery раньше плагина, который его использует. Иллюстрация показывает границу между графом модулей и глобальным API браузера.
\n

Три способа доставки

\n

В проекте с несколькими entry не стоит начинать с единого глобального правила. Сначала составьте карту потребителей.

\n
СпособЧто получает кодКогда подходитЧего он не делает
Явный importЛокальный объект из графа WebpackНовый код и код, который можно менятьНе создаёт window.jQuery сам по себе
ProvidePluginАвтоматически подставленный импорт для свободного имениLegacy-модули с $ или jQueryНе чинит внешний script и не гарантирует порядок загрузки
externals или CDNГлобальный объект, предоставленный HTML или платформойОдна контролируемая внешняя загрузкаНе включает jQuery в бандл и не проверяет URL CDN
\n

Для переходного проекта обычно работает связка: новый код импортирует jQuery явно, а ограниченный legacy-слой получает ProvidePlugin. Если плагин проверяет именно window.jQuery, добавьте глобальный экспорт в одном entry. Не смешивайте этот режим с CDN без причины. Иначе один бандл будет использовать пакет, а другой — внешний файл.

\n

Минимальная конфигурация для переходного проекта

\n

Ниже учебный пример. В нём два entry, один пакет jquery и legacy-код, который ещё использует свободное имя. Пример не измеряет размер бандла, не доказывает совместимость конкретного плагина и не заменяет проверку вашей версии Webpack и jQuery.

\n
const webpack = require('webpack');\n\nmodule.exports = {\n  entry: {\n    legacy: './src/legacy-entry.js',\n    modern: './src/modern-entry.js'\n  },\n  plugins: [\n    new webpack.ProvidePlugin({\n      $: 'jquery',\n      jQuery: 'jquery'\n    })\n  ]\n};
\n

В новом модуле зависимость остаётся видимой:

\n
import $ from 'jquery';\n\nexport function mount(form) {\n  return $(form).find('[data-mask]').length;\n}
\n

В legacy-модуле строка импорта может отсутствовать:

\n
$('.phone').inputmask('+7 (999) 999-99-99');
\n

Оба фрагмента должны ссылаться на один экземпляр, если Webpack собирает их в общий runtime или правильно выносит общий модуль. Это нужно проверить в фактической конфигурации. Само совпадение версий в package.json такого доказательства не даёт.

\n

Когда нужен window.jQuery

\n

Некоторые старые плагины не экспортируют функцию как модуль. Они выполняются сразу и ищут window.jQuery. Для такого кода сделайте отдельный модуль-инициализатор и импортируйте его до плагина:

\n
// src/jquery-global.js\nimport $ from 'jquery';\n\nwindow.$ = $;\nwindow.jQuery = $;\n\n// src/legacy-entry.js\nimport './jquery-global';\nimport 'inputmask/dist/jquery.inputmask';\nimport './legacy-app';
\n

Такой порядок относится к импортам внутри одного entry. Он не управляет отдельным CDN-скриптом, который HTML загрузит позже. В браузере проверьте именно window.jQuery перед и после подключения плагина, а затем вызовите метод плагина на реальном элементе формы.

\n

Отрицательный путь: CDN и externals

\n

Внешняя загрузка имеет смысл, когда платформа уже отдаёт jQuery или несколько приложений должны использовать один URL. Тогда Webpack не должен одновременно включать пакет в тот же бандл. Пример для глобального объекта:

\n
module.exports = {\n  externals: {\n    jquery: 'jQuery'\n  }\n};
\n

Теперь import $ from 'jquery' в собранном коде означает обращение к внешнему jQuery. HTML обязан загрузить библиотеку раньше бандла:

\n
<script src=\"https://cdn.example.test/jquery.min.js\"></script>\n<script src=\"/assets/legacy.js\"></script>
\n

URL в примере учебный. Не подставляйте его в рабочую страницу. Для production нужны зафиксированная версия, контроль доступности, политика безопасности и понятный план отказа. Если CDN недоступен, Webpack не сможет компенсировать это настройкой ProvidePlugin. Если браузер загрузит две версии jQuery, плагины могут зарегистрироваться в одном объекте, а приложение — работать с другим.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
jQuery is not definedПлагин выполняется до глобального объектаПорядок Network и typeof window.jQuery перед импортомСобрать entry с инициализатором или исправить порядок внешних scripts
$(...).inputmask is not a functionПлагин получил другой объект или не загрузилсяСравнить window.jQuery === $ и проверить регистрацию $.fn.inputmaskОставить один источник jQuery и импортировать плагин после него
Работает только на одной страницеProvidePlugin действует только в собранных модулях этого entryСравнить entry, HTML и содержимое chunk-файловДобавить зависимость в нужный entry, а не рассчитывать на соседний бандл
Размер растёт после добавления второго entryНет общего chunk или настроены две независимые поставкиПосмотреть stats и число включённых модулей jqueryНастроить общую доставку только после проверки поведения legacy-кода
CDN-версия ломает плагинВерсия или порядок внешней загрузки не совпадает с контрактомПроверить фактический URL, версию и момент выполнения плагинаЗафиксировать совместимую версию или вернуть пакет в граф Webpack
\n

Порядок действий

\n
  1. Найдите все обращения к $, jQuery и window.jQuery. Отдельно отметьте модули, которые выполняются сразу при импорте.
  2. Для каждого entry выберите источник: пакет в графе Webpack или внешний глобальный script. Не оставляйте выбор на уровне случайного HTML-порядка.
  3. Оставьте явный import $ from 'jquery' в новом коде. Добавьте ProvidePlugin только к переходному слою, который нельзя быстро изменить.
  4. Если плагин читает window.jQuery, создайте один инициализатор и импортируйте его перед этим плагином.
  5. Соберите каждый entry и проверьте, что в нём есть ожидаемая зависимость или явно описан externals.
  6. Откройте реальную форму. Проверьте window.jQuery, $.fn.jquery, метод legacy-плагина и обработчик пользовательского события.
  7. После миграции потребителя удалите лишнюю глобальную настройку и повторите проверку. Иначе временная совместимость станет постоянной зависимостью.
\n

Ограничения

\n

ProvidePlugin не превращает любой свободный идентификатор в безопасную архитектуру. Он скрывает зависимость в исходном файле и усложняет перенос модуля в другой сборщик. Используйте его как переходный слой и уменьшайте область действия.

\n

Один объект jQuery не гарантирует совместимость. Плагин может требовать конкретную версию, поддерживать только старый API или конфликтовать с noConflict. Проверяйте реальный сценарий, а не только успешную компиляцию.

\n

В серверном рендеринге и Web Worker нет обычного window. Код, который без проверки обращается к window.jQuery, должен выполняться только в браузерном entry. Это отдельное ограничение окружения, а не проблема Webpack.

\n

Проверяемый критерий готовности

\n

Работу можно считать законченной, когда каждый entry имеет один документированный источник jQuery, сборка не содержит непреднамеренной второй копии, legacy-плагин получает тот же объект, что и приложение, а форма проходит реальный сценарий ввода. Дополнительно зафиксируйте проверку для страницы без legacy-кода: она не должна получать глобальную зависимость только потому, что она есть в соседнем бандле.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/325.json b/editorial/agent-rewrites/325.json new file mode 100644 index 0000000..0f5cb04 --- /dev/null +++ b/editorial/agent-rewrites/325.json @@ -0,0 +1,7 @@ +{ + "index": 325, + "slug": "editorial-2018-12-field-legacy-refactoring", + "title": "Bitrix: как заменить генерацию CODE в legacy-коде и не потерять исходное значение", + "excerpt": "Точечная замена обработчика Bitrix начинается с одного элемента: фиксируем исходный CODE, проверяем инфоблок, записываем только нужное поле и читаем результат обратно. Отдельно разбираем отрицательный путь и осторожный откат.", + "contentHtml": "

После изменения формы карточка товара открывается по старому адресу, а новая запись получает другой URL. Иногда поле CODE остаётся пустым. Иногда его меняет обработчик, о котором забыли. Цена ошибки — 404 для опубликованной страницы, сломанная ссылка в выгрузке и риск затереть чужое изменение при поспешном откате.

Причина часто лежит в одном legacy-обработчике. Он читает запрос, строит символьный код, обновляет элемент инфоблока и показывает сообщение формы. Успешный ответ формы не доказывает, что изменился правильный элемент. Ответ true от API не доказывает, что после событий в базе лежит ожидаемая строка.

Тезис: первую замену ограничивают одним вызовом и одним полем. До записи подтверждают ID и IBLOCK_ID. После записи снова читают элемент по тому же ID. Откат маршрута и восстановление данных считают разными операциями.

Где возникает расхождение

Форма передаёт ID и название. Старый обработчик вызывает legacyUpdateCode(). Функция транслитерирует название и передаёт результат в CIBlockElement::Update. Рядом могут работать импорт, cron-задача и обработчик события. Они используют похожий код, но принимают разные входы.

Если сразу заменить функцию во всех местах, после сбоя неясно, что сработало: неверный ID, другой инфоблок, правило транслитерации или обработчик Bitrix. Сначала нужен контролируемый шов. Он получает выбранный ID и название, знает ожидаемый инфоблок, меняет только CODE и возвращает результат вызывающему коду. HTML, редирект и отправка письма остаются снаружи.

\"Схема
Сначала проверяем один элемент и сохраняем его исходный CODE. При расхождении выключаем новый маршрут. Старое значение восстанавливаем только после проверки, что его не изменил другой процесс.

Контракт операции

До рефакторинга выпишите контракт старого вызова. Входом будут положительный ID, название для расчёта, ожидаемый ID инфоблока и разрешённый контур. Результатом — исходный код, рассчитанный код и фактический код после чтения. Отказ должен прекращать текущий путь, а не превращаться в сообщение об успехе.

В учебном примере числа 7 и 451 условны. Их нельзя переносить в рабочую систему. Они показывают, что проверка должна называть конкретный инфоблок и выбранный элемент, а не принимать эти значения из формы.

СимптомПричинаПроверкаДействие
CODE пустПустое название или пустой результат транслитерацииПроверить строку до UpdateОстановить запись
Изменился не тот элементID пришёл из формы без проверкиПрочитать ID и IBLOCK_ID по фильтруНе вызывать writer
Код после успеха другойСобытие или другая запись изменила полеСделать повторную выборкуВыключить новый маршрут
Откат затирает новое значениеИсходный снимок устарелСравнить текущее поле с результатом опытаНе восстанавливать автоматически

Таблица задаёт порядок расследования. Нельзя начинать с восстановления старого значения, пока неизвестно, кто записал текущее. Откат маршрута влияет на следующие вызовы. Откат данных меняет сам элемент и требует проверки снимка.

Выделяем writer с одной ответственностью

Ниже ограниченный учебный фрагмент для legacy Bitrix API. Он не является кодом конкретного production-проекта и не утверждает, что параметры подходят вашему каталогу. Метод подключает модуль, выбирает элемент, проверяет инфоблок, записывает только CODE и читает элемент ещё раз.

<?php\nfinal class CheckedCodeWriter\n{\n    private $iblockId;\n    public function __construct($iblockId) { $this->iblockId = (int) $iblockId; }\n    public function writeFromName($elementId, $name)\n    {\n        if (!CModule::IncludeModule('iblock')) throw new RuntimeException('module unavailable');\n        $before = $this->find((int) $elementId);\n        if (!$before || (int) $before['IBLOCK_ID'] !== $this->iblockId) {\n            throw new RuntimeException('unexpected element or iblock');\n        }\n        $code = CUtil::translit(trim((string) $name), 'ru', array(\n            'change_case' => 'L', 'replace_space' => '-',\n            'replace_other' => '-', 'delete_repeat_replace' => true, 'max_len' => 100,\n        ));\n        if ($code === '') throw new InvalidArgumentException('CODE is empty');\n        $element = new CIBlockElement();\n        if (!$element->Update((int) $elementId, array('CODE' => $code))) {\n            throw new RuntimeException($element->LAST_ERROR ?: 'Update failed');\n        }\n        $after = $this->find((int) $elementId);\n        if (!$after || (string) $after['CODE'] !== $code) {\n            throw new RuntimeException('CODE differs after Update');\n        }\n        return array('before' => $before['CODE'], 'after' => $after['CODE']);\n    }\n    private function find($elementId)\n    {\n        $result = CIBlockElement::GetList(array(), array('ID' => $elementId), false, false,\n            array('ID', 'IBLOCK_ID', 'CODE'));\n        return $result->Fetch();\n    }\n}

Фрагмент показывает границу, а не готовую библиотеку. Он не проверяет права, уникальность кода, SEO-правила, торговые предложения или кеш. Он также не делает атомарной запись в инфоблок и вызов внешней системы. Эти условия добавляют отдельными проверками.

Почему нужно читать элемент после Update

Проверка булевого ответа полезна, но недостаточна. До обновления могут выполняться обработчики. OnBeforeIBlockElementUpdate может изменить поля или отменить изменение. После записи могут сработать другие действия. Поэтому проверяем не только вызов, но и состояние выбранного элемента.

Повторная выборка не доказывает согласованность всего каталога. Она отвечает на узкий вопрос: у элемента с этим ID находится ожидаемый CODE. Если ответ отрицательный, новый путь не готов. Сначала выключаем его для следующих запросов, затем разбираем вход, события и конкурирующие записи.

Подключаем новый путь без массовой замены

Старый обработчик может остаться точкой входа. Переключатель имеет безопасное значение по умолчанию и ограничивает новый маршрут выбранным сценарием:

<?php\ndefined('USE_CHECKED_CODE_WRITER') || define('USE_CHECKED_CODE_WRITER', false);\nfunction updateCodeForScenario($elementId, $name)\n{\n    if (USE_CHECKED_CODE_WRITER !== true) return legacyUpdateCode($elementId, $name);\n    if ((int) $elementId !== 451) throw new RuntimeException('example ID only');\n    return (new CheckedCodeWriter(7))->writeFromName($elementId, $name);\n}

В рабочем проекте ограничение задают конфигурацией и правилами доступа, а не параметром URL. Если тестового элемента нет, нельзя включать новый маршрут на рабочей карточке ради быстрой проверки.

Порядок точечной замены

  1. Найти известные вызовы старого обработчика: форму, импорт, cron и события. Не считать список полным без поиска по проекту.
  2. Выбрать разрешённый тестовый элемент и записать его ID, IBLOCK_ID и исходный CODE.
  3. Зафиксировать правила расчёта и проверить, что результат не пустой.
  4. Вынести проверку элемента, запись одного поля и повторную выборку в writer.
  5. Оставить старый маршрут включённым по умолчанию.
  6. Выполнить операцию на тестовом контуре и сравнить фактический код с ожидаемым.
  7. При расхождении выключить новый маршрут и проверить события до восстановления данных.
  8. Расширять охват по одному вызову, сохраняя для каждого свои входы и критерии.

Отрицательный путь и откат

Неверный ID, чужой инфоблок, пустое название, ошибка Update и несовпадение после чтения должны останавливать текущую операцию. Нельзя показывать сообщение «сохранено», если writer вернул исключение. Нельзя вызывать второй Update в обработчике ошибки без установленной причины.

Откат состоит из двух решений. Сначала вернуть флаг нового маршрута в безопасное состояние. Затем решить, нужно ли восстанавливать поле. Сравните текущее значение с тем, которое должен был поставить опыт. Если поле отличается, его мог изменить редактор или импорт. Автоматическая запись старого снимка тогда опасна.

Ограничения метода

Точечный writer не превращает старый модуль в новую архитектуру и не заменяет аудит всех источников записи. Один успешный элемент не проверяет дубликаты, разные языки, спецсимволы, права, индексацию и ссылки, построенные по старому коду.

Не передавайте в Update лишние поля ради полноты примера. Чем шире массив изменения, тем больше поверхность побочных эффектов. Если проект передаёт свойства элемента, отдельно изучите их контракт и обработчики.

Подход не решает транзакцию между Bitrix и внешним API. Ошибка внешней отправки не отменяет автоматически уже выполненную запись. Повтор и расхождение нужно описать отдельным процессом.

Критерий готовности

Замену можно считать готовой для выбранного сценария, если writer принимает проверяемый ID, подтверждает инфоблок, меняет только заявленное поле, не пропускает ошибку как успех, читает элемент после Update, получает ожидаемый CODE и отключается одной понятной настройкой. Для расхождения должен существовать запрет на слепой откат.

Это локальный критерий, а не доказательство готовности всей миграции. Для следующего вызова нужны новый снимок, новый ожидаемый результат и отдельная проверка. Если условия нельзя показать на одном разрешённом элементе, расширять замену нельзя.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/326.json b/editorial/agent-rewrites/326.json new file mode 100644 index 0000000..547cdef --- /dev/null +++ b/editorial/agent-rewrites/326.json @@ -0,0 +1,7 @@ +{ + "index": 326, + "slug": "editorial-2018-12-mechanism-legacy-refactoring", + "title": "Bitrix: как безопасно вынести запись из смешанного save.php", + "excerpt": "Старый обработчик формы может изменить инфоблок, отправить уведомление и показать HTML в одном проходе. Разбираем, как отделить запись элемента, увидеть отрицательный путь и проверить локальный рефакторинг без ложного сообщения об успехе.", + "contentHtml": "

Форма сообщает «ошибка», но название товара уже изменилось. Пользователь нажимает «Сохранить» ещё раз и повторно запускает письмо или внешний вызов. Цена ошибки — дублирование побочного эффекта, потеря причины и спор о том, какое состояние считать правильным.

\n

Так происходит, когда старый save.php читает $_POST, вызывает CIBlockElement::Update, отправляет уведомление и выводит HTML в одной функции. Первый рефакторинг такого файла должен разделить не все слои системы, а один наблюдаемый переход. Сначала отделяем запись элемента от формы и последующих действий. Затем проверяем фактическое значение повторным чтением.

\n

Симптом начинается раньше сообщения

\n

Один HTTP-запрос может оставить несколько следов. Входные поля приходят из формы. Инфоблок хранит изменённое поле. Почтовый или сетевой вызов оставляет след во внешней системе. Браузер получает только последний ответ обработчика. Если поздний вызов падает, этот ответ не рассказывает, что произошло с инфоблоком несколькими строками выше.

\n

Длина файла здесь ничего не решает. Небольшая функция тоже опасна, если она сама читает глобальный массив, меняет данные и решает, какой HTML вывести. Для диагностики разделите три вопроса: вход допустим, запись состоялась, следующий побочный эффект выполнен. Один текст «сохранено» не может честно отвечать на все три.

\n
\"Смешанный
Смешанный обработчик скрывает границу между записью и последующим действием. Поздний сбой не доказывает, что ранняя запись не произошла.
\n

Механизм: Update и ответ формы живут на разных границах

\n

CIBlockElement::Update возвращает логический результат и принимает массив полей. До записи Bitrix вызывает обработчики, которые могут изменить параметры или отменить операцию. После попытки обновления работают обработчики другого этапа. Поэтому результат метода и текст, который позже печатает форма, нельзя считать одним событием.

\n

Если Update вернул ошибку, обработчик может показать её и остановиться. Если он вернул успех, это подтверждает результат вызова метода, но не успешную отправку письма и не согласованность внешнего каталога. Если после успеха возникло исключение, поле уже могло измениться. Повтор всей формы в таком состоянии опаснее, чем повтор чтения.

\n
СимптомПричинаПроверкаДействие
Браузер показал ошибку, но поле изменилосьСбой произошёл после UpdateСнова прочитать элемент по ID и сравнить полеНе повторять POST; разобрать поздний вызов
Пустой ID выглядит как ошибка BitrixГлобальный вход привели к целому числу поздноПроверить ID и обязательные поля до записиВернуть ошибку валидации без записи
Значение отличается от расчётаОбработчик изменил вход или другой процесс записал полеПроверить обработчики и повторную выборкуОстановить расширение шва
Повтор формы отправил два уведомленияВнешний эффект не имеет отдельного статусаРазвести результат записи и уведомленияОпределить политику повтора
Затронуты лишние свойстваВ Update передали широкий массивСравнить ключи с контрактомПередавать только нужное поле
\n

Учебный пример смешанного обработчика

\n

Следующий фрагмент — учебный пример, ограниченный объяснением механизма. Он не взят из конкретного production-проекта и не доказывает наличие функции sendPartnerNotice в вашем сайте. В нём намеренно оставлены типичные границы старого файла.

\n
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $elementId = (int) $_POST['ID'];
    $name = trim((string) $_POST['NAME']);

    if ($elementId <= 0 || $name === '') {
        echo 'Заполните ID и название.';
        return;
    }

    CModule::IncludeModule('iblock');
    $element = new CIBlockElement();
    if (!$element->Update($elementId, array('NAME' => $name))) {
        echo $element->LAST_ERROR;
        return;
    }

    sendPartnerNotice($elementId, $name);
    echo 'Сохранено';
}
\n

У примера три отрицательных пути. Валидация останавливает запрос до записи. Bitrix может вернуть отказ. Внешний вызов может завершиться ошибкой после успешной записи. Последний путь нельзя исправить строкой «повторить Update»: она не делает внешнюю операцию безопасной и скрывает, что поле уже изменилось.

\n

Первый шов: функция возвращает только результат записи

\n

Вынесенная функция получает нормализованные значения аргументами. Она не знает о форме, не печатает HTML и не вызывает интеграцию. Её контракт узкий: вернуть ID после успешного обновления или остановить путь исключением. Это не новая архитектура. Это точка проверки входа, API и ошибки.

\n
<?php
function saveProductName($elementId, $name)
{
    if (!CModule::IncludeModule('iblock')) {
        throw new RuntimeException('Module iblock is unavailable');
    }

    $elementId = (int) $elementId;
    $name = trim((string) $name);
    if ($elementId <= 0 || $name === '') {
        throw new InvalidArgumentException('ID and NAME are required');
    }

    $element = new CIBlockElement();
    if (!$element->Update($elementId, array('NAME' => $name))) {
        throw new RuntimeException($element->LAST_ERROR ?: 'Element update failed');
    }

    return $elementId;
}

try {
    $savedId = saveProductName($_POST['ID'], $_POST['NAME']);
    $message = 'Карточка сохранена: ' . $savedId;
} catch (InvalidArgumentException $error) {
    $message = $error->getMessage();
} catch (RuntimeException $error) {
    $message = $error->getMessage();
}
\n

Фрагмент остаётся учебным. Он не заменяет правила доступа, CSRF-защиту, журналирование и обработку конкретной версии Bitrix. Перед переносом сохраните контракт формы и проверьте зарегистрированные обработчики.

\n

После успешной записи следующий эффект вызывают явно. Если уведомление допустимо только для изменённого элемента, его место видно после saveProductName. Если уведомление упало, сообщение должно назвать именно эту проблему. Нельзя превращать её в «элемент не сохранён», если повторное чтение показывает обратное.

\n

Обработчики и набор полей

\n

OnBeforeIBlockElementUpdate получает параметры до изменения и может их переопределить или отменить операцию. Обработчик после попытки обновления может сработать и при неудаче, поэтому в нём нужно смотреть результат, а не только факт вызова. Эти события объясняют расхождение между переданным массивом и состоянием элемента.

\n

Передавайте в Update только поля текущей задачи. Широкий массив повышает цену ошибки: обработчик видит лишнее, а свойства могут получить нежелательное значение. Узкий массив не гарантирует атомарность, но сокращает поверхность изменения. Если старый код обновляет имя, не добавляйте туда свойства и разделы «для полноты».

\n

Проверяйте не только ответ метода. После разрешённого тестового вызова снова выберите тот же элемент по ID и сравните фактическое поле с ожидаемым. Это не производственный результат и не доказательство безопасности всех вызовов. Это локальная проверка одного входа, инфоблока и поля.

\n

Порядок локальной переделки

\n
  1. Запишите один симптом: например, ошибка формы не отвечает, изменилось ли имя.
  2. Перечислите побочные эффекты старого обработчика в фактическом порядке: запись, уведомление, кеш, HTML и внешние вызовы.
  3. Выберите один эффект для первого шва и зафиксируйте ID, инфоблок, поле, успех и отказ.
  4. Проверьте зарегистрированные обработчики Bitrix и все места вызова участка.
  5. Вынесите запись в функцию с явными аргументами. Уберите из неё $_POST, echo и сторонние вызовы.
  6. Подключите функцию одним вызовом, оставив неисследованные действия в прежнем порядке.
  7. На разрешённом тестовом элементе сохраните исходное значение, выполните один вызов и прочитайте поле обратно.
  8. Проверьте пустой вход, неверный ID, отказ обработчика и ошибку позднего эффекта.
  9. Если значение расходится с ожидаемым, остановите расширение. Не добавляйте повторную запись наугад.
\n

Отрицательный путь важнее зелёного сообщения

\n

Нужно различать четыре состояния: вход отклонён, запись отклонена, запись подтверждена, поздний эффект завершился отдельно. Первые два не должны менять элемент. Третье подтверждается повторным чтением. Четвёртое сообщает о частичном результате и не заставляет пользователя повторять весь запрос.

\n

Откат маршрута и откат данных — разные действия. Вернуть старую функцию для следующих запросов можно быстро. Восстановить старое поле безопасно только после проверки, что его не изменил другой процесс. Сохранённое до теста значение не даёт права перезаписать карточку поверх работы редактора или импорта.

\n

Ограничения метода

\n

Этот шов не создаёт транзакцию между инфоблоком и внешним API. Он не делает повтор сетевого запроса идемпотентным. Он не устраняет гонки, кеш, права доступа и все обработчики проекта. Если операция требует согласованного изменения нескольких систем, нужен отдельный контракт: ключ операции, допустимый повтор, состояние частичного выполнения и владелец восстановления.

\n

Не нужно выносить каждую строку PHP в класс. Если один вызов уже имеет ясный вход и не скрывает побочные эффекты, функции достаточно. Шов оправдан там, где смешение мешает ответить на вопрос о состоянии данных.

\n

Критерий готовности

\n

Локальный рефакторинг готов, когда для одного выбранного элемента выполнены все условия: вход проходит отдельную проверку; функция меняет только заявленное поле; результат Update обработан явно; обработчики просмотрены; фактическое значение подтверждено повторной выборкой; поздний эффект имеет отдельный результат; отрицательный путь не запускает повторную запись; при расхождении есть действие остановки или возврата.

\n

Если хотя бы один пункт не проверен, готов только шов к следующему исследованию. Это лучше зелёного сообщения, которое скрывает уже изменённые данные.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/327.json b/editorial/agent-rewrites/327.json new file mode 100644 index 0000000..7f26905 --- /dev/null +++ b/editorial/agent-rewrites/327.json @@ -0,0 +1,7 @@ +{ + "index": 327, + "slug": "editorial-2018-12-practice-legacy-refactoring", + "title": "Bitrix: безопасная замена одного Update в legacy-коде", + "excerpt": "Как выделить узкий шов вокруг CIBlockElement::Update, проверить вход, не задеть чужой инфоблок и доказать результат повторной выборкой.", + "contentHtml": "

В старом Bitrix-обработчике карточка товара иногда получает пустой символьный код. Страница после сохранения отвечает успешно, но прежний URL перестаёт вести к элементу. Ошибка может проявиться позже: её поймает импорт, кеш или шаблон, который строит ссылку из поля CODE. Цена одной неверной правки — 404, потерянный переход из поиска и сложное расследование, потому что обработчик уже смешивает форму, запись и вывод сообщения.

\n

Проблему часто пытаются решить заменой всех вызовов на новый класс. Это увеличивает область риска. Надёжнее сначала выделить один переход: вход формы → выбранный элемент → изменение одного поля → повторное чтение. Такой шов не переписывает модуль. Он делает результат конкретного вызова наблюдаемым.

\n

Тезис: узкий шов должен доказывать одну запись

\n

Шов вокруг CIBlockElement::Update принимает ID элемента, ожидаемый ID инфоблока и новый CODE. До записи он проверяет вход и читает текущий элемент. После записи он снова читает тот же ID и сравнивает фактическое значение с ожидаемым. Если условие не выполнено, путь останавливается с ошибкой.

\n

Шов не отвечает за HTML, редирект, отправку писем и очистку кеша. Старый обработчик может оставить эти действия рядом. Но он не должен считать их доказательством успешной записи. Сообщение в браузере показывает результат HTTP-сценария, а повторная выборка показывает состояние элемента.

\n
\"Схема
Шов охватывает только переход к полю CODE. Форма и внешние действия остаются за его границей.
\n

Механизм: ограничить вход, поле и результат

\n

Сначала назовите контракт. ID должен быть положительным целым числом. Новый код после trim() не должен быть пустым. Выборка по ID должна вернуть элемент. Его IBLOCK_ID должен совпасть с ожидаемым. В вызов Update передаём только CODE, а не весь массив формы.

\n

Проверка инфоблока защищает от особенно неприятной ошибки: правильный ID может относиться к другой сущности. Передача всех полей также опасна. Пустое свойство из формы или устаревшее значение из массива может затереть данные, которых не было в задаче. Чем уже массив изменения, тем короче след операции.

\n
СимптомПричинаПроверкаДействие
URL стал пустым или изменился неожиданноВ Update попал пустой или чужой CODEСравнить вход, IBLOCK_ID и значение после записиОстановить путь и передавать только проверенный CODE
Метод вернул успех, но поле не совпалоОбработчик события изменил данные после вызоваСнова выбрать элемент по тому же IDРазобрать обработчики до расширения замены
Ошибка формы появилась после сохраненияПосле записи упало письмо, кеш или внешняя интеграцияРазделить результат записи и результат следующего действияНе повторять Update; показать отдельную ошибку
Проверка работает в одном месте и ломается в другомОдин legacy-вызов имеет несколько источников входаНайти все вызовы и записать их предусловияПереключать вызовы по одному
Откат затирает новое значениеСтарый снимок уже не соответствует текущему состояниюСравнить текущее поле со значением, записанным экспериментомНе восстанавливать поле автоматически при расхождении
\n

Учебный пример шва

\n

Ниже приведён учебный пример для старого Bitrix API и PHP 7.2. Он не утверждает, что такой код уже запускался в production. ID инфоблока, права, обработчики событий и правила уникальности нужно проверить в конкретном проекте.

\n
<?php\n\nfinal class ProductCodeWriter\n{\n    private $expectedIblockId;\n\n    public function __construct($expectedIblockId)\n    {\n        $this->expectedIblockId = (int) $expectedIblockId;\n    }\n\n    public function write($elementId, $code)\n    {\n        if (!CModule::IncludeModule('iblock')) {\n            throw new RuntimeException('Модуль iblock недоступен');\n        }\n\n        $elementId = (int) $elementId;\n        $code = trim((string) $code);\n        if ($elementId <= 0 || $code === '') {\n            throw new InvalidArgumentException('Нужны ID элемента и непустой CODE');\n        }\n\n        $before = $this->find($elementId);\n        if (!$before || (int) $before['IBLOCK_ID'] !== $this->expectedIblockId) {\n            throw new RuntimeException('Элемент не найден в ожидаемом инфоблоке');\n        }\n\n        if ((string) $before['CODE'] === $code) {\n            return array('changed' => false, 'code' => $code);\n        }\n\n        $element = new CIBlockElement();\n        if (!$element->Update($elementId, array('CODE' => $code))) {\n            throw new RuntimeException($element->LAST_ERROR ?: 'Update завершился ошибкой');\n        }\n\n        $after = $this->find($elementId);\n        if (!$after || (string) $after['CODE'] !== $code) {\n            throw new RuntimeException('CODE не подтвердился после Update');\n        }\n\n        return array('changed' => true, 'code' => $after['CODE']);\n    }\n\n    private function find($elementId)\n    {\n        $result = CIBlockElement::GetList(\n            array(),\n            array('ID' => (int) $elementId),\n            false,\n            false,\n            array('ID', 'IBLOCK_ID', 'CODE')\n        );\n\n        return $result->Fetch();\n    }\n}
\n

Важны четыре границы. Класс не читает глобальный $_POST. Он не печатает сообщение. Он не принимает решение за внешний сервис. Он не возвращает true только потому, что метод API не сообщил об ошибке. Повторная выборка делает условие готовности явным.

\n

Если код уже совпадает, метод возвращает changed: false. Это нормальный результат идемпотентного повторного вызова. Не нужно писать в базу второй раз ради сообщения «сохранено». Если Update вернул false, исключение содержит LAST_ERROR, когда Bitrix его заполнил. Продолжать к письму или редиректу после такого отказа нельзя.

\n

Подключение из старого обработчика

\n

Старый файл может сохранить свою проверку запроса и выбор шаблона. Меняется только прямой вызов API. Класс возвращает отчёт, а обработчик решает, как показать его пользователю.

\n
try {\n    $writer = new ProductCodeWriter(7);\n    $report = $writer->write($_POST['ID'], $_POST['CODE']);\n\n    $message = $report['changed']\n        ? 'Символьный код обновлён.'\n        : 'Символьный код уже совпадает.';\n} catch (InvalidArgumentException $error) {\n    $message = $error->getMessage();\n} catch (RuntimeException $error) {\n    $message = $error->getMessage();\n}
\n

Число 7 — пример, а не значение для копирования. В рабочем проекте его берут из конфигурации сценария и проверяют по данным элемента. Если обработчик после этой конструкции отправляет письмо, ошибка письма не означает, что CODE не записался. Логи и сообщение должны различать две операции.

\n

Порядок проверки

\n
  1. Найдите прямой вызов CIBlockElement::Update и запишите его источники входа: форма, импорт, cron или событие.
  2. Выберите один элемент на разрешённом тестовом контуре. Не используйте рабочую карточку только ради быстрого эксперимента.
  3. Сохраните ID элемента, ID инфоблока, старый CODE и ожидаемый новый код.
  4. Проверьте отрицательные входы: нулевой ID, пустой код, неизвестный элемент и элемент из другого инфоблока.
  5. Вызовите шов для одного элемента и отдельно зафиксируйте его отчёт.
  6. Снова выберите элемент через API и сравните фактический CODE с ожидаемым.
  7. Проверьте путь после записи отдельно: письмо, кеш, редирект или интеграция не должны маскировать результат шва.
  8. Если значение расходится, отключите новый маршрут для следующих вызовов и разберите обработчики событий. Не добавляйте второй Update «на всякий случай».
  9. Только после этого подключайте следующий источник входа и повторяйте проверку с его собственными условиями.
\n

Ограничения и отрицательный путь

\n

Шов не делает CODE уникальным. Если два элемента получают один код, правило нужно проверять отдельным запросом и учитывать политику проекта. Шов также не решает права доступа, SEO-правила, синхронизацию торговых предложений и очистку кеша. Эти обязанности нельзя приписывать одной функции записи.

\n

У Bitrix есть обработчики до и после изменения. Обработчик до записи может изменить поле или отменить операцию. Обработчик после записи может запустить другой эффект. Поэтому успешный возврат Update и подтверждённый CODE доказывают только состояние выбранного элемента в момент повторного чтения. Они не доказывают согласованность поиска и внешнего каталога.

\n

Откат маршрута и откат данных — разные действия. Возврат к старому классу защищает следующие вызовы, но не возвращает уже изменённое поле. Восстанавливать старый CODE можно только после проверки, что текущим значением остаётся результат этого эксперимента. Если его изменил редактор или импорт, остановитесь и согласуйте восстановление. Молчаливый откат может затереть более новое изменение.

\n

Не каждый вызов требует класса. Если функция уже получает явные аргументы, меняет одно поле и возвращает результат, дополнительная оболочка не даст пользы. Выделяйте шов там, где операция смешана с формой, повторяется или нуждается в отдельной проверке. Цель — не увеличить число файлов, а сделать границу проверяемой.

\n

Критерий готовности

\n

Локальная замена готова, если для выбранного вызова выполнены все условия: вход явно ограничен; проверен ожидаемый инфоблок; в Update передано только нужное поле; отрицательные пути останавливают сценарий; результат повторно прочитан по тому же ID; обработчики и действия после записи не выданы за доказательство успешного сохранения. После расхождения существует понятный путь отключения нового маршрута, а восстановление данных не выполняется поверх чужого изменения.

\n

Это не сертификат безопасности всего legacy-модуля. Это проверяемое утверждение об одной операции. Когда оно подтверждено, следующий участок можно разбирать отдельно.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/328.json b/editorial/agent-rewrites/328.json new file mode 100644 index 0000000..552e283 --- /dev/null +++ b/editorial/agent-rewrites/328.json @@ -0,0 +1,7 @@ +{ + "index": 328, + "slug": "editorial-2018-11-field-php-integration-tests", + "title": "PHP: зелёный тест и нерабочая форма — как проверить БД, HTTP и конфигурацию", + "excerpt": "Модульный тест может пройти, пока форма падает на настоящем DSN, SQL или HTTP-ответе. Разбираем короткую интеграционную трассу PHP с тестовой БД, локальным callback и явным критерием готовности.", + "contentHtml": "

Форма регистрации отвечает 500 после отправки, хотя unit-тест сервиса зелёный. Иногда запись в БД создаётся, но уведомление не уходит. Иногда запрос даже не доходит до базы. Цена ошибки — потерянное время на исправление бизнес-логики и риск замаскировать проблему новым mock-ом. Такой тест снова станет зелёным, но не проверит DSN, SQL, cURL и код ответа.

\n

Интеграционный тест нужен там, где ошибка возникает на стыке компонентов. Для PHP это может быть путь «конфигурация → PDO → тестовая БД → сервис → локальный HTTP callback». Unit-тест оставляем для правил внутри класса. Интеграционный тест проходит через настоящий адаптер и проверяет наблюдаемый результат. Ниже — учебный пример. Он не вызывает партнёрский URL и не утверждает, что команды уже выполнялись в production.

\n
\"Трасса
Один учебный идентификатор связывает запись в БД, HTTP-попытку и проверку ответа. Это помогает разделить соседние ошибки, а не заменить наблюдение общим «тест упал».
\n

Что именно ломает зелёный тест

\n

Unit-тест обычно передаёт сервису память вместо репозитория и spy вместо HTTP-клиента. Он проверяет порядок вызовов: сначала создать регистрацию, потом отправить уведомление. Это полезное утверждение, но оно не открывает PDO, не читает переменную окружения и не получает ответ сервера.

\n
<?php\n$repository = new MemoryRegistrationRepository();\n$callback = new SpyCallbackClient();\n$service = new RegistrationService($repository, $callback);\n\n$service->register('registration-test-42', 'anna@example.test');\n\n$this->assertSame(\n    [['registration-test-42', 42]],\n    $callback->messages\n);
\n

Этот код может пройти при пустом DSN, отсутствии таблицы и неверном URL callback. В нём нет дефекта. Ошибка появляется, когда его называют проверкой всей регистрации. Название теста не расширяет его границу.

\n

Интеграционный тест фиксирует более узкий контракт: тестовая конфигурация разрешает соединение; репозиторий записывает валидные поля; чтение возвращает их без потери типа; клиент отправляет запрос на локальный endpoint; код принимает только ожидаемый статус и тело. Почта, браузер и доступность партнёра остаются другими контрактами.

\n

Сначала отделите среду от кода

\n

Тест должен остановиться до соединения, если не задана test-only конфигурация. Не используйте production DSN как значение по умолчанию. Префикс TEST_ не заменяет права доступа, но делает намерение видимым. Отдельный пользователь БД, отдельная схема и запрет на production DNS важнее проверки имени переменной.

\n
<?php\nfinal class TestPdo\n{\n    public static function fromEnvironment(): PDO\n    {\n        $dsn = (string) getenv('TEST_DATABASE_DSN');\n        $user = (string) getenv('TEST_DATABASE_USER');\n        $password = (string) getenv('TEST_DATABASE_PASSWORD');\n\n        if ($dsn === '' || strpos($dsn, 'test') === false) {\n            throw new RuntimeException(\n                'TEST_DATABASE_DSN must point to an isolated test database'\n            );\n        }\n\n        return new PDO($dsn, $user, $password, [\n            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,\n            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,\n        ]);\n    }\n}
\n

Проверка подстроки test — только учебный предохранитель от очевидной ошибки. Она не доказывает изоляцию. В рабочем проекте проверяйте разрешённый хост, имя схемы и пользователя отдельными настройками запуска. Пароль не выводите в исключение и отчёт.

\n

Разделите симптом, причину, проверку и действие

\n
СимптомПричинаПроверкаДействие
Тест падает до INSERTПустой DSN, драйвер или праваВывести безопасное имя схемы и тип исключенияПроверить test-only окружение и миграцию
INSERT проходит, чтение пустоеНеверный столбец, фильтр или схемаПрочитать запись тем же репозиторием по IDСравнить SQL, миграцию и типы результата
В логах нет HTTP-попыткиСервис остановился после ошибки БДСвязать шаги одним request IDСначала исправить границу БД
HTTP вернул 200 вместо ожидаемого 202Маршрут или callback не тотПроверить URL без секретов и HTTP-кодИсправить test-only URL или договор ответа
Повторный прогон видит старые данныеТранзакция не откатилась или второе соединение обошло еёПроверить inTransaction() и соединенияОткатывать PDO и отдельно чистить следы HTTP
\n

Логируйте только то, что помогает выбрать следующую проверку: учебный ID, тип ошибки, имя тестовой схемы, HTTP-код. Не записывайте токены, пароли и полное тело запроса, если оно может содержать персональные данные.

\n

Настоящий PDO, но только тестовая база

\n

Тест ниже предполагает, что таблица уже создана миграцией в выделенной схеме. Миграцию не запускайте внутри сценария: DDL может сделать неявный commit, и откат данных перестанет быть надёжным. Проверяем один путь записи и чтения через публичные методы репозитория.

\n
<?php\nfinal class RegistrationIntegrationTest extends TestCase\n{\n    private PDO $pdo;\n\n    protected function setUp(): void\n    {\n        $this->pdo = TestPdo::fromEnvironment();\n        $this->pdo->beginTransaction();\n    }\n\n    protected function tearDown(): void\n    {\n        if ($this->pdo->inTransaction()) {\n            $this->pdo->rollBack();\n        }\n    }\n\n    public function testStoresAndReadsRegistration(): void\n    {\n        $repository = new PdoRegistrationRepository($this->pdo);\n        $id = $repository->create(\n            'registration-test-42',\n            'anna@example.test'\n        );\n\n        $stored = $repository->findByRequestId('registration-test-42');\n\n        self::assertSame($id, (int) $stored['id']);\n        self::assertSame('anna@example.test', $stored['email']);\n    }\n}
\n

Доказательство появляется после чтения обратно. Если create() перепутал поля, миграция отличается от ожидания или findByRequestId() меняет имя ключа, тест падает на конкретной границе. Если соединение не открывается, это тоже результат: окружение не выполнило контракт.

\n

Транзакция очищает изменения только в этом соединении. Запись, сделанная вторым PDO, очередью или внешним сервисом, не исчезнет после rollBack(). MySQL и некоторые другие СУБД также могут фиксировать DDL неявно. Поэтому схему готовьте заранее, а сетевые следы удаляйте отдельным шагом.

\n

Проверьте HTTP без вызова партнёра

\n

Для проверки cURL достаточно локального callback. В учебной конфигурации TEST_CALLBACK_URL указывает на 127.0.0.1. Обработчик принимает JSON, проверяет формат ID и отвечает 202. Это доказывает работу нашего клиента и обработку конкретного ответа. Это не доказывает доступность партнёрского API, его SLA или production-сертификат.

\n
<?php\n// tests/fixtures/callback.php\n$requestId = $_SERVER['HTTP_X_TEST_REQUEST_ID'] ?? '';\nif (!preg_match('/^[a-z0-9-]{1,40}$/', $requestId)) {\n    http_response_code(400);\n    echo 'bad request id';\n    return;\n}\n\nfile_put_contents(\n    sys_get_temp_dir() . '/callback-' . $requestId . '.json',\n    file_get_contents('php://input')\n);\nheader('Content-Type: application/json');\nhttp_response_code(202);\necho json_encode(['accepted' => true]);\n\n// Учебный запуск: php -S 127.0.0.1:8088 -t tests/fixtures
\n

Клиент должен различать транспортную ошибку и HTTP-ответ. Непустое тело не означает успех. curl_exec() может вернуть false; код ответа надо получить через curl_getinfo() и сравнить с договором.

\n
<?php\n$handle = curl_init((string) getenv('TEST_CALLBACK_URL'));\ncurl_setopt_array($handle, [\n    CURLOPT_POST => true,\n    CURLOPT_HTTPHEADER => [\n        'Content-Type: application/json',\n        'X-Test-Request-Id: registration-test-42',\n    ],\n    CURLOPT_POSTFIELDS => json_encode(['registrationId' => $id]),\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_TIMEOUT => 3,\n]);\n\n$body = curl_exec($handle);\n$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);\n$error = curl_error($handle);\ncurl_close($handle);\n\nif ($body === false || $status !== 202 || $body !== '{\"accepted\":true}') {\n    throw new RuntimeException(\n        'Test callback failed: status=' . $status . ' error=' . $error\n    );\n}
\n

Учебный allowlist 127.0.0.1 намеренно узкий. Если PHPUnit работает в контейнере, localhost внутри него может не совпасть с localhost хоста. Тогда задайте отдельное имя test-only сервиса, разрешите только его и проверьте, из какого сетевого пространства идёт запрос. Не подставляйте URL партнёра как запасной вариант.

\n

Порядок действий

\n
  1. Создайте отдельную тестовую схему и пользователя без доступа к production. Сохраните DSN только в test-only окружении.
  2. Примените миграцию к тестовой схеме отдельной командой и проверьте версию схемы до запуска PHPUnit.
  3. Запустите локальный callback на свободном адресе. Убедитесь, что его каталог принадлежит тестам и не содержит рабочих данных.
  4. Передайте один ID, например registration-test-42, в запись БД и заголовок HTTP. Не печатайте пароль и секреты.
  5. Запустите один интеграционный класс. Если он падает, сначала определите границу: конфигурация, PDO, SQL, транспорт или ответ.
  6. После успешного теста проверьте откат строки в БД и удаление локального JSON-файла. Если второе соединение оставляет данные, исправьте изоляцию.
  7. Добавьте отдельный сценарий только для нового контракта: уникальность, таймаут или ошибочный статус. Не превращайте один тест в проверку всего приложения.
\n

Ограничения и отрицательный путь

\n

Этот тест не проверяет HTML-форму, браузерную валидацию, cron, доставку письма и реальную доступность партнёра. Для формы нужен отдельный пользовательский или HTTP-тест. Для партнёра нужен согласованный стенд или контрактный тест. Один локальный callback не может заменить эти проверки.

\n

Если отдельной базы нет, честный результат — «контур не готов», а не зелёный unit-тест с названием integration. Если код сам создаёт PDO внутри сервиса, сначала вынесите фабрику или передайте адаптер через конструктор. Иначе тест не сможет доказать, что сервис использовал именно безопасное соединение.

\n

Если ответ callback изменился, исправляйте договор или клиент после проверки причины. Не принимайте любой код от 200 до 299 без решения о семантике ответа. Если сеть недоступна, не повторяйте запрос бесконечно: короткий timeout должен показать проблему и остановить сценарий.

\n

Критерий готовности

\n

Сценарий готов, когда он проходит на чистой тестовой схеме, читает созданную запись обратно через настоящий репозиторий, получает ожидаемый статус локального callback и после завершения не оставляет данные в БД и временной директории. При пустом DSN, неверной схеме, недоступном callback и неожиданном статусе он падает с различимым сообщением. Этот критерий проверяем командой проекта, а не объявляем по наличию файла теста.

\n

Такой интеграционный тест не делает систему безошибочной. Он делает одну границу наблюдаемой. Unit-тест отвечает за локальное правило. Тест с PDO и локальным HTTP-обработчиком отвечает за связку конфигурации, БД, транспорта и ответа. Их зелёный результат имеет смысл только в пределах этих явно названных условий.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/329.json b/editorial/agent-rewrites/329.json new file mode 100644 index 0000000..7132f42 --- /dev/null +++ b/editorial/agent-rewrites/329.json @@ -0,0 +1,7 @@ +{ + "index": 329, + "slug": "editorial-2018-11-mechanism-php-integration-tests", + "title": "PHP-тесты: где mock заканчивается и начинается настоящая интеграция", + "excerpt": "Зелёный unit-тест не доказывает, что PHP отправил правильный SQL, открыл нужную базу и разобрал ответ драйвера. Разбираем границу между подстановкой и настоящим переходом через PDO.", + "contentHtml": "

Все unit-тесты сервиса проходят, но первая реальная запись падает с ошибкой SQL. Иногда приложение подключается не к той базе. Иногда запрос записывает значение не в тот столбец. Цена ошибки — ложная уверенность: команда меняет бизнес-логику, хотя тест ни разу не прошёл через PDO, схему и конфигурацию.

\n

Причина обычно проста. Тест подменяет репозиторий и заранее говорит ему вернуть true или массив. Такой тест проверяет реакцию сервиса на известный ответ. Он не проверяет, сможет ли настоящий репозиторий получить этот ответ. Тезис статьи короткий: unit-тест и integration-тест отвечают на разные вопросы. Первый изолирует правило. Второй оставляет настоящий переход там, где важен контракт между двумя частями системы.

\n

Механизм: граница проходит по побочному эффекту

\n

Unit-тест оставляет под контролем один класс. Соседей он заменяет объектами, которые возвращают заданные значения. Это полезно для проверки правил: запретить дубликат, вычислить скидку, выбрать ветку ошибки. Тест быстро показывает, что сервис делает при конкретном входе.

\n

Integration-тест оставляет настоящим один внешний переход. Для PHP-репозитория это путь PHP → PDO → тестовая БД → PDO → PHP. Внутри него работают драйвер, SQL, типы столбцов, индексы и преобразование результата. Для HTTP-клиента граница будет другой: URL, cURL, код ответа и разбор тела на управляемом endpoint. Не нужно поднимать весь сайт, если вопрос касается одного адаптера.

\n

Mock не плох и не хорош сам по себе. Ошибка появляется, когда его ответ считают доказательством работы ресурса. Mock не увидит отсутствующую миграцию, неверный DSN, ошибочное имя столбца, отсутствие PDO-драйвера или код ответа 500. Поэтому один сценарий часто нужно разделить на два теста: локальное правило и настоящий переход.

\n
\"Граница
Unit-тест проверяет решение сервиса. Integration-тест проверяет договор на границе с настоящим адаптером. Пунктир нельзя пересекать незаметно.
\n

Пример: регистрация и проверка занятого email

\n

Пусть сервис регистрации не должен создавать пользователя с уже занятым адресом. Локальное правило можно проверить без базы. Подставной репозиторий сообщает, что адрес существует, а сервис должен выбросить исключение. Этот пример учебный: он показывает только вопрос сервиса и не утверждает, что выполнялся в production.

\n
<?php\nuse PHPUnit\\Framework\\TestCase;\n\ninterface CustomerLookup\n{\n    public function existsByEmail(string $email): bool;\n}\n\nfinal class RegistrationService\n{\n    private $customers;\n\n    public function __construct(CustomerLookup $customers)\n    {\n        $this->customers = $customers;\n    }\n\n    public function register(string $email): void\n    {\n        if ($this->customers->existsByEmail($email)) {\n            throw new DomainException('Email is already registered');\n        }\n    }\n}\n\nfinal class RegistrationServiceTest extends TestCase\n{\n    public function testRejectsExistingEmail(): void\n    {\n        $customers = $this->createMock(CustomerLookup::class);\n        $customers->method('existsByEmail')\n            ->with('anna@example.test')\n            ->willReturn(true);\n\n        $service = new RegistrationService($customers);\n        $this->expectException(DomainException::class);\n        $service->register('anna@example.test');\n    }\n}
\n

Если этот тест зелёный, мы знаем только одно: при ответе true сервис отклоняет регистрацию. Мы не знаем, вернёт ли настоящий запрос true. Не знаем, совпадает ли схема с SQL. Не знаем, прочитал ли bootstrap переменную окружения. Именно поэтому следующий тест должен вызвать реальный адаптер.

\n

Настоящий переход через PDO

\n

Интеграционный тест репозитория подключается к отдельной тестовой схеме. В ней заранее есть таблица customers с полями id, email и name. Тест кладёт известную строку через PDO, вызывает публичный метод адаптера и проверяет результат. Ожидание не задаёт mock. Его возвращает настоящий запрос.

\n

Конфигурация должна быть test-only. Не подставляйте production DSN по умолчанию. Отдельный пользователь должен не иметь доступа к рабочей схеме. Пароль нельзя хранить в коде. Проверка имени базы в примере ниже — лишь аварийный барьер от очевидной ошибки. Она не заменяет права доступа, отдельную сеть и безопасное окружение.

\n
<?php\nfinal class PdoCustomerLookupIntegrationTest extends TestCase\n{\n    /** @var PDO */\n    private $pdo;\n\n    protected function setUp(): void\n    {\n        $dsn = (string) getenv('TEST_DATABASE_DSN');\n        if ($dsn === '' || strpos($dsn, 'test') === false) {\n            $this->markTestSkipped('An isolated test DSN is required');\n        }\n\n        $this->pdo = new PDO(\n            $dsn,\n            (string) getenv('TEST_DATABASE_USER'),\n            (string) getenv('TEST_DATABASE_PASSWORD'),\n            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,\n             PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]\n        );\n        $this->pdo->beginTransaction();\n        $this->pdo->prepare(\n            'INSERT INTO customers (email, name) VALUES (?, ?)'\n        )->execute(['anna@example.test', 'Анна']);\n    }\n\n    protected function tearDown(): void\n    {\n        if ($this->pdo instanceof PDO && $this->pdo->inTransaction()) {\n            $this->pdo->rollBack();\n        }\n    }\n\n    public function testFindsExistingEmail(): void\n    {\n        $lookup = new PdoCustomerLookup($this->pdo);\n\n        self::assertTrue($lookup->existsByEmail('anna@example.test'));\n        self::assertFalse($lookup->existsByEmail('missing@example.test'));\n    }\n}
\n

Это учебный каркас, а не готовый файл для любого проекта. В нём предполагаются PHPUnit, PDO, заранее применённая миграция и класс PdoCustomerLookup. Он не создаёт контейнер, не содержит пароль и не вызывает рабочую БД. Команда должна подставить собственную тестовую схему и сверить синтаксис с закреплённой версией PHP и PHPUnit.

\n

Транзакция помогает убрать изменения данных после проверки. Она не очищает записи, созданные другим соединением, очередью или внешним сервисом. Она также не даёт универсального отката DDL: конкретная СУБД может неявно зафиксировать CREATE TABLE или DROP TABLE. Миграцию и подготовку схемы поэтому выполняют отдельным шагом.

\n

Симптом → причина → проверка → действие

\n
Диагностика границы между подстановкой и интеграцией
СимптомПричинаПроверкаДействие
Unit зелёный, INSERT падаетMock скрывает SQL, схему или драйверЗапустить один репозиторий с test-only PDO и настоящей таблицейДобавить узкий integration-тест записи и чтения
Тест читает пустой массивИмя столбца или ключ результата изменилсяПроверить SQL, схему и фактический FETCH_ASSOCИсправить адаптер или миграцию; не менять ожидание на пустое
Тест пропускается на CIНет DSN, пользователя или PDO-драйвераПроверить test-only переменные и безопасную доступность схемыНастроить изолированную среду либо честно оставить проверку отложенной
После теста остаются строкиЗапись сделана вне текущей транзакцииСравнить соединение репозитория с соединением тестаПередать PDO или фабрику явно; добавить уборку отдельного ресурса
Mock проверяет вызов HTTPЗапрос не ушёл к управляемому endpointПроверить URL, статус и тело на локальном endpointОставить mock для правила, а сетевой контракт покрыть отдельно
\n

Порядок проверки

\n
  1. Зафиксируйте симптом, который прошёл мимо unit-теста: ошибка SQL, неверное значение, пустой результат или неправильный статус HTTP.
  2. Назовите владельца границы: репозиторий PDO, HTTP-клиент, файловый адаптер или загрузчик конфигурации.
  3. Оставьте настоящий только этот переход. Остальные части замените простыми контролируемыми объектами.
  4. Подготовьте test-only ресурс: отдельную схему, локальный endpoint или временный каталог. Production-ресурс не используйте.
  5. Проверьте положительный путь и один отрицательный. Для БД это найденный и отсутствующий email; для HTTP — ожидаемый статус и отказ.
  6. Ограничьте время и объём данных. Один тест должен объяснять одну границу, а не поднимать БД, очередь, письмо и HTML одновременно.
  7. Убедитесь, что после теста не остаются строки, файлы, запросы или фоновые задачи. Если очистка невозможна, сделайте след операции уникальным и удаляемым.
  8. Сохраните unit-тест рядом с integration-тестом. Они дополняют друг друга: один защищает правило, другой — склейку с ресурсом.
\n

Ограничения и отрицательный путь

\n

Integration-тест репозитория не доказывает, что HTML-форма передала правильное поле, cron запустился, письмо доставлено или партнёрский API доступен. Для каждого перехода нужна своя граница. Один зелёный тест не превращает mock в реальную проверку и не подтверждает состояние production.

\n

Если изолированной БД нет, нельзя назвать объект в памяти интеграцией. Корректный отрицательный путь — остановить проверку с понятной причиной и создать безопасную среду позже. Если тест упал на подключении, не меняйте ожидаемое значение ради зелёного отчёта. Сначала исправьте DSN, права или драйвер. Если упал SQL, проверьте миграцию и имена столбцов. Если внешний endpoint вернул ошибку, не отправляйте запрос в production из CI.

\n

Исторический код может использовать PHP 7.2 и PHPUnit 7.5. Эти версии не следует выбирать для нового проекта только по этому примеру. Зафиксируйте фактические версии в composer.lock и проверьте методы жизненного цикла, mock-объектов и настройки PDO по документации вашей версии.

\n

Проверяемый критерий готовности

\n

Граница готова, если команда может назвать вход, настоящий ресурс, ожидаемый результат и безопасную уборку. Тест должен действительно выполнить запрос через PDO, прочитать запись тем же адаптером и показать отрицательный поиск. При отсутствии test-only DSN он должен остановиться объяснимо, а не подключиться к значению по умолчанию. Только после этого зелёный результат означает проверенный переход, а не удачный ответ подстановки.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/330.json b/editorial/agent-rewrites/330.json new file mode 100644 index 0000000..2843f44 --- /dev/null +++ b/editorial/agent-rewrites/330.json @@ -0,0 +1,7 @@ +{ + "index": 330, + "slug": "editorial-2018-11-practice-php-integration-tests", + "title": "PHP-интеграционный тест репозитория: проверить запись, чтение и границы отката", + "excerpt": "Unit-тест может быть зелёным, пока настоящий PDO не увидит схему базы. Разбираем узкий интеграционный тест PHP-репозитория: изолированное подключение, запись, чтение, откат и проверяемый предел его доказательств.", + "contentHtml": "

Unit-тест сервиса зелёный, но после отправки формы запись в таблице получает пустое поле. Иногда метод записи возвращает ID, а следующий вызов не находит строку. Другой вариант — тест проходит локально и падает на CI из-за DSN, прав пользователя или другой схемы. Цена ошибки — ложная уверенность перед релизом. Команда ищет дефект в бизнес-логике, хотя PHP, PDO, SQL и таблица никогда не проходили один путь вместе.

\n

Интеграционный тест репозитория должен оставить настоящим только нужный переход: PHP → PDO → изолированная тестовая БД → PDO → PHP. Он записывает данные через публичный метод репозитория, читает их тем же адаптером и проверяет результат. Такой тест не доказывает работу всей формы, очереди или production-БД. Он фиксирует один контракт и показывает, на какой границе он нарушился.

\n

Ниже приведён учебный пример для PHP и PHPUnit. В нём нет рабочего пароля, готового контейнера и обещания результата в конкретной среде. Названия таблицы, драйвер, способ запуска БД и версия PHPUnit зависят от проекта. Код показывает принцип, а не универсальную конфигурацию.

\n

Механизм: где заканчивается unit-тест

\n

Unit-тест проверяет решение одного класса. Репозиторий в нём можно заменить заглушкой, а ответ заглушки задать заранее. Это правильно, если вопрос звучит так: «запретит ли сервис дубликат?» Но заглушка не выполняет SQL, не читает схему и не проверяет настройки PDO.

\n

Интеграционный тест отвечает на другой вопрос: «сможет ли этот адаптер записать и прочитать данные через настоящий драйвер?» Поэтому он использует тестовую БД и реальную схему. Его граница должна быть узкой. Не нужно добавлять браузер, отправку почты и внешний API. Каждый новый ресурс добавляет собственную причину падения.

\n
\"Схема
Учебный контракт проходит через настоящий PDO и тестовую схему. Откат очищает изменения данных, но не отменяет каждый тип операции.
\n

Контракт до кода

\n

Возьмём таблицу customers с полями id, email и name. Тест получает адрес anna@example.test и имя Анна. Метод add() возвращает числовой ID. Метод findById() по этому ID возвращает те же значения. После теста строка не должна остаться в общей тестовой базе.

\n

Это не проверка всех запросов репозитория. Она проверяет минимальный маршрут записи и чтения. Если проект дополнительно нормализует регистр, проверяет уникальность или преобразует даты, для каждого такого правила нужен отдельный сценарий. Не прячьте несколько разных утверждений в одном тесте: тогда ошибка перестаёт указывать на конкретный контракт.

\n
СимптомПричинаПроверкаДействие
Не создаётся PDOПустой DSN, неверный драйвер или тест обращается не к той средеВывести безопасный идентификатор БД и проверить переменные TEST_*Остановить тест без значения по умолчанию; выдать отдельную ошибку конфигурации
INSERT проходит, чтение пустоеПерепутан столбец, имя ключа или схема отличается от миграцииПрочитать запись через findById() и сравнить каждое полеСверить SQL, схему и преобразование результата; не добавлять mock
После запуска остаются строкиНет транзакции, был commit или запись сделана другим соединениемПроверить inTransaction() и состояние БД отдельным запросомОткатывать тот же PDO; вынести DDL и чужие соединения за пределы сценария
Тест падает только параллельноОбщая схема и одинаковые данные пересекаются между процессамиЗапустить один тест и сравнить данные с параллельным запускомДать каждому процессу схему или уникальный набор данных
Unit-тест зелёный, SQL ломаетсяРепозиторий заменён заглушкойЗапустить узкий тест с настоящим PDO и тестовой схемойОставить unit-тест для правил, добавить отдельную интеграционную проверку адаптера
\n

Изолированное подключение

\n

Подключение должно быть явным. Не зашивайте в тест строку вроде mysql:host=localhost;dbname=site. По ней нельзя понять, безопасна ли база. Не используйте production DSN как запасной вариант. Если переменная отсутствует, тест обязан завершиться до первого запроса.

\n

Проверка подстроки test ниже защищает только от очевидной опечатки. Она не заменяет права доступа. Надёжнее создать отдельного пользователя без доступа к рабочей схеме, использовать отдельную сеть и передавать секреты через CI. Учебный фрагмент намеренно не содержит пароль.

\n
<?php final class TestPdo { public static function fromEnvironment(): PDO { $dsn = (string) getenv('TEST_DATABASE_DSN'); $user = (string) getenv('TEST_DATABASE_USER'); $password = (string) getenv('TEST_DATABASE_PASSWORD'); if ($dsn === '' || strpos($dsn, 'test') === false) { throw new RuntimeException('TEST_DATABASE_DSN must name an isolated test database'); } return new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); } }
\n

В реальном проекте дополнительно проверьте имя базы через конфигурацию окружения и права пользователя. Не печатайте пароль и полный DSN в лог. Сообщения теста должны помогать определить среду, но не раскрывать секреты.

\n

Один настоящий путь записи и чтения

\n

Тест ниже вызывает публичные методы CustomerRepository. Он не сравнивает SQL-строку с её копией в тесте. Доказательством служит результат чтения из БД. Если метод перепутает поля, схема не примет значение или преобразование результата изменит ключ, проверка должна упасть.

\n
<?php final class CustomerRepositoryIntegrationTest extends TestCase { private PDO $pdo; protected function setUp(): void { $this->pdo = TestPdo::fromEnvironment(); $this->pdo->beginTransaction(); } protected function tearDown(): void { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } } public function testStoresAndReadsCustomer(): void { $repository = new CustomerRepository($this->pdo); $id = $repository->add('anna@example.test', 'Анна'); $stored = $repository->findById($id); self::assertIsInt($id); self::assertSame('anna@example.test', $stored['email']); self::assertSame('Анна', $stored['name']); } }
\n

Полевая версия класса должна принимать PDO через конструктор. Если репозиторий создаёт новое соединение внутри add(), транзакция теста не контролирует его изменения. Передайте соединение или фабрику явно. Иначе зелёный откат может скрывать оставшиеся строки.

\n

Очистка и отрицательный путь

\n

beginTransaction() отключает autocommit для соединения. rollBack() отменяет изменения данных и возвращает соединение в autocommit. Это подходит для короткого теста с INSERT, UPDATE и DELETE. Проверка inTransaction() в tearDown() не вызывает ошибку, если подготовка завершилась раньше открытия транзакции.

\n

Транзакция не является универсальной уборкой. Некоторые СУБД выполняют неявный commit для DDL, например CREATE TABLE и DROP TABLE. Поэтому миграцию схемы выполняйте отдельным подготовительным шагом. Откат одного PDO также не уберёт запись, созданную вторым соединением, очередью или HTTP-сервисом. Это отрицательный путь: если граница не контролируется, не называйте тест изолированным.

\n

Порядок действий

\n
  1. Создайте отдельную тестовую схему и пользователя. Запретите этому пользователю доступ к рабочей базе.
  2. Примените к тестовой схеме ту версию миграции, которую должен видеть репозиторий. Не создавайте таблицу молча внутри теста.
  3. Передайте DSN, пользователя и пароль через тестовое окружение с префиксом TEST_. При пустом или подозрительном DSN остановите запуск.
  4. Откройте один PDO с режимом исключений и начните транзакцию в setUp().
  5. Вызовите один публичный метод записи и сохраните его ID. Не проверяйте внутренние свойства репозитория.
  6. Прочитайте запись тем же репозиторием и сравните ID, адрес и имя. Каждое важное поле должно иметь отдельное утверждение.
  7. В tearDown() откатите транзакцию, если она ещё открыта. Отдельно проверьте, что код не создаёт второе неконтролируемое соединение.
  8. Запустите сначала один класс, затем тот же сценарий в режиме проекта. При падении разделите ошибку конфигурации, подключения, SQL, схемы и ожидания результата.
\n

Ограничения

\n

Такой тест не проверяет HTML-форму, CSRF, маршрутизацию, очередь, письмо, cron и доступность партнёрского API. Для этих переходов нужны другие тесты с другими границами. Не превращайте репозиторный тест в сквозной сценарий: он станет медленнее, а ошибка — менее локальной.

\n

Общая БД плохо подходит для параллельных запусков без изоляции. Одинаковый email может столкнуться с данными соседнего процесса. Используйте отдельную схему на процесс, транзакции с контролируемым соединением или уникальные учебные значения. Если проект пока не может дать безопасную БД, честный результат — «интеграционная проверка заблокирована окружением». Mock, переименованный в integration test, пробел не закрывает.

\n

Учебный пример предполагает, что таблица уже существует и поддерживает транзакции. Нельзя переносить его в production с проверкой имени базы как единственной защитой. Нельзя считать тест доказательством миграций, если миграция не участвует в подготовке тестовой схемы.

\n

Критерий готовности

\n

Сценарий готов, если на чистой изолированной тестовой схеме он создаёт запись через настоящий репозиторий, читает ожидаемые поля через настоящий PDO, удаляет изменения после завершения и падает с различимым сообщением при неверном DSN или схеме. Повторный запуск не зависит от данных предыдущего запуска. При остановленной или недоступной тестовой БД тест сообщает об окружении, а не выдаёт ложный зелёный результат.

\n

Этого достаточно для первого контракта. Следующий тест добавляйте только под новое правило: уникальность адреса, преобразование даты или обработка ошибки драйвера. Сохраняйте границу узкой. Тогда падение покажет не абстрактную «проблему интеграции», а конкретный разрыв между кодом и ресурсом.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/331.json b/editorial/agent-rewrites/331.json new file mode 100644 index 0000000..763bbbb --- /dev/null +++ b/editorial/agent-rewrites/331.json @@ -0,0 +1,7 @@ +{ + "index": 331, + "slug": "editorial-2018-10-field-image-workflow", + "title": "Bitrix: как не потерять изображение между preview и сохранением формы", + "excerpt": "Preview показывает выбранный файл, но не доказывает, что Bitrix получил его и записал в PREVIEW_PICTURE. Разбираем контракт legacy-формы, multipart-запрос, серверные ветки замены и удаления и проверки после обновления элемента.", + "contentHtml": "

Редактор показывает новую фотографию, пользователь нажимает «Сохранить», а после перезагрузки карточка снова открывается со старой. Иногда поле изображения становится пустым. В файловом хранилище при этом остаются лишние загрузки без связи с элементом. Оператор повторяет действие, а разработчик видит только успешный preview и ответ HTTP 200. Ошибка стоит времени пользователя, мусора в хранилище и риска опубликовать карточку не с тем изображением.

\n

Тезис простой: preview — состояние DOM, а не подтверждение данных. Для сохранения нужны три отдельные границы: браузер должен отправить файл как multipart-часть, сервер должен принять и проверить его, а Bitrix должен успешно обновить поле элемента. Если одна граница скрыта за jQuery или FileInput, интерфейс может выглядеть исправным при потерянном результате.

\n

Механизм: у одного изображения несколько состояний

\n

До отправки браузер владеет выбранным объектом File. Preview владеет только картинкой на экране и текстом статуса. После загрузки сервер регистрирует файл и получает ID. Только затем обработчик может передать файл или файловый массив в CIBlockElement::Update(). Поле PREVIEW_PICTURE принадлежит элементу инфоблока, поэтому его значение нужно проверить отдельным чтением после обновления.

\n

В legacy-шаблоне часто живут обычный input type=file, скрытый ID, HTML редактора и Ajax-перерисовка одного контейнера. У каждого поля должна быть одна роль. Скрытый PHOTO_ID не превращает выбранный в браузере файл в сохранённый. Если контрол загружает файл отдельным запросом, его временный ID нужно связать с конкретным пользователем и элементом. Нельзя брать «последний файл» из базы: параллельный запрос может изменить этот результат.

\n
Поле или сигналКто владеетЧто означает
CATALOG_PREVIEWбраузер и multipart POSTкандидат на новую картинку
DELETE_PICTURE=1явное действие пользователязапросить удаление текущей картинки
.js-photo-stateDOM и jQueryтолько текст статуса, не ID файла
PREVIEW_PICTUREэлемент инфоблокаподтверждённая ссылка на файл
\n
\"Контракт
Контракт формы: файл приходит в multipart POST, preview остаётся сигналом DOM, а результатом становится значение PREVIEW_PICTURE после успешного обновления.
\n

Конкретный пример формы и обработчика

\n

Ниже учебный пример для элемента инфоблока с одним изображением. Он показывает границы данных, но не заменяет политику доступа и проверки конкретного проекта. Форма должна явно указать multipart/form-data, а имя поля должно совпасть с ключом в $_FILES.

\n
<?php\nuse Bitrix\\Main\\UI\\FileInput;\n\n$currentId = (int) $arResult['PREVIEW_PICTURE'];\n?>\n<form method=\"post\" enctype=\"multipart/form-data\" id=\"catalog-photo-form\">\n  <?php\n  echo FileInput::createInstance(array(\n      'id' => 'catalog_preview',\n      'name' => 'CATALOG_PREVIEW',\n      'upload' => true,\n      'allowUpload' => FileInput::UPLOAD_IMAGES,\n      'maxCount' => 1,\n      'delete' => true,\n  ))->show($currentId);\n  ?>\n  <label><input type=\"checkbox\" name=\"DELETE_PICTURE\" value=\"1\"> удалить</label>\n  <p class=\"js-photo-state\" aria-live=\"polite\"></p>\n  <button type=\"submit\">Сохранить</button>\n</form>
\n

Если форма отправляется Ajax-ом, собирайте её через FormData. Не вызывайте serialize() для передачи файла. Не задавайте вручную заголовок Content-Type: multipart/form-data: браузер должен добавить boundary. После отправки смотрите в Network имя file-поля, размер части, код ответа и тело ответа. Статус 200 означает только, что сервер ответил, а не то, что элемент обновился.

\n
var form = document.getElementById('catalog-photo-form');\n\nform.addEventListener('submit', function (event) {\n  event.preventDefault();\n  var data = new FormData(form);\n\n  fetch('/admin/catalog/photo.php', {\n    method: 'POST',\n    body: data\n  });\n});
\n

jQuery нужен для поведения интерфейса, но не для хранения результата. При Ajax-перерисовке обработчик поля может исчезнуть. Делегирование от стабильного контейнера сохраняет событие, а namespace позволяет снять только свой обработчик:

\n
(function ($) {\n  var root = document;\n\n  $(root).off('change.photoWorkflow', 'input[name=CATALOG_PREVIEW]')\n    .on('change.photoWorkflow', 'input[name=CATALOG_PREVIEW]', function () {\n      var file = this.files && this.files[0];\n      $('.js-photo-state').text(file\n        ? 'Выбран файл: ' + file.name + '. Сохранение ещё не выполнено.'\n        : 'Новый файл не выбран.');\n    });\n}(jQuery));
\n

Сообщение намеренно говорит «сохранение ещё не выполнено». Это удерживает границу между экранным событием и серверным результатом. Не записывайте имя файла, data URL или размер в поле, которое сервер трактует как ID.

\n

Сервер выбирает одну ветку

\n

Для редактирования изображения нужны три нормальные операции: оставить старое, заменить новым или удалить. Новый файл и удаление в одном запросе — конфликт, который лучше отклонить. Пустой input type=file не означает удаление: пользователь мог не менять картинку, а DOM мог перерисоваться.

\n
function updatePreviewPicture($elementId, array $post, array $files)\n{\n    $upload = $files['CATALOG_PREVIEW'] ?? array();\n    $hasNewFile = ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;\n    $delete = ($post['DELETE_PICTURE'] ?? '') === '1';\n\n    if ($hasNewFile && $delete) {\n        throw new RuntimeException('Выберите замену или удаление');\n    }\n    if (!$hasNewFile && !$delete) {\n        return; // оставить текущее значение\n    }\n\n    $fields = array(\n        'PREVIEW_PICTURE' => $hasNewFile\n            ? $upload\n            : array('del' => 'Y'),\n    );\n    $element = new CIBlockElement();\n\n    if (!$element->Update((int) $elementId, $fields)) {\n        throw new RuntimeException($element->LAST_ERROR);\n    }\n}
\n

Для старых версий Bitrix формат входного файла и политика удаления могут отличаться. Если файл уже зарегистрирован отдельно, используйте подтверждённый ID и соберите файловый массив через CFile::MakeFileArray(). После Update() проверяйте не только возврат метода, но и LAST_ERROR. Для файлового свойства, а не поля PREVIEW_PICTURE, формат PROPERTY_VALUES нужно сверить отдельно.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Preview новый, карточка стараяфайл не попал в POST или обработчик не вызвал UpdateNetwork: multipart-часть и серверный лог ID элементаисправить enctype, имя поля или ветку сохранения
Ответ 200, поле пустоеошибка скрыта за ответом или неверный формат поляпроверить JSON/HTML ответа, Update() и LAST_ERRORвозвращать ошибку формы и перечитать элемент
Ajax работает только со второго разаserialize() не передаёт файл или повторно создан обработчикпроверить Request Payload и число срабатываний changeиспользовать FormData и namespace-делегирование
Удаление срабатывает самоудаление выведено из пустого previewсравнить явный флаг удаления с состоянием DOMпередавать отдельный флаг и отклонять конфликт
В хранилище много сиротских файловотдельная загрузка завершилась без сохранения элементасопоставить ID временного файла с запросом и элементомввести владельца черновика и регламент очистки
\n

Проверяю путь в порядке отказа

\n
  1. Открываю карточку с известным текущим ID изображения и фиксирую его до изменения.
  2. Отправляю форму без нового файла и без удаления. После свежего чтения элемента ID должен остаться прежним.
  3. Выбираю небольшой учебный JPEG и проверяю в Network имя поля, multipart-часть и размер файла.
  4. Проверяю ответ обработчика, результат Update() и текст LAST_ERROR, если метод вернул false.
  5. Открываю карточку новым HTTP-запросом и сравниваю URL изображения с ожидаемым файлом.
  6. Отправляю явное удаление без нового файла и проверяю, что сработала именно ветка удаления.
  7. Отправляю новый файл вместе с удалением. Ожидаю понятную ошибку формы, а не выбор ветки по порядку полей.
  8. Перерисовываю контейнер Ajax-ом и меняю файл ещё раз. Событие должно обработаться один раз.
\n

Ограничения

\n

Пример не задаёт универсальные MIME-типы, размеры, права и антивирусную проверку. Эти правила зависят от версии Bitrix, настроек PHP, роли пользователя и требований каталога. Проверка расширения в браузере не защищает сервер. На сервере нужно проверять размер, фактический тип, ошибки загрузки, доступ к элементу и допустимость операции для пользователя.

\n

Если FileInput загружает файл отдельным запросом, путь через $_FILES неприменим без адаптации. Сначала определите, какое значение возвращает контрол и где хранится временный файл. При отмене формы не оставляйте такой ID без владельца. Для небольшой синхронной формы не нужны очереди, но нужна явная политика очистки незавершённых загрузок.

\n

Проверяемый критерий готовности

\n

Интеграция готова, если форма делает один понятный запрос, новый файл проходит серверные проверки, Update() возвращает успех, а свежая страница показывает новое изображение. Сохранение без нового файла оставляет старое значение. Явное удаление меняет поле только по флагу пользователя. Конфликт «новый файл плюс удаление» даёт ошибку. В браузере после Ajax-перерисовки нет двойного обработчика. Эти условия проверяются на тестовой записи; они не являются заявлением о результате production.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/332.json b/editorial/agent-rewrites/332.json new file mode 100644 index 0000000..f97b121 --- /dev/null +++ b/editorial/agent-rewrites/332.json @@ -0,0 +1,7 @@ +{ + "index": 332, + "slug": "bitrix-api-add-foto-editor", + "title": "Bitrix API: как встроить редактор изображений и сохранить файл в элементе", + "excerpt": "FileInput показывает выбранную картинку, но не сохраняет её сам. Разбираем контракт формы, проверку загрузки и привязку файла к PREVIEW_PICTURE в Bitrix.", + "contentHtml": "

В форме Bitrix пользователь выбирает новую фотографию и сразу видит preview. После нажатия «Сохранить» страница открывается со старым изображением. Иногда поле остаётся пустым. В файловом хранилище при этом появляются новые записи, которые не привязаны к элементу.

\n

Цена ошибки выше, чем у сломанной кнопки. Менеджер считает карточку обновлённой, а каталог продолжает показывать старую обложку. Повторная загрузка создаёт мусорные файлы. При массовом редактировании ошибка превращается в неверные фотографии, ручную сверку и восстановление данных.

\n

Тезис. Bitrix\\Main\\UI\\FileInput решает задачу интерфейса. Он не доказывает, что файл записан в нужное поле сущности. Готовность наступает только после трёх подтверждений: запрос принял ожидаемый файл, Bitrix зарегистрировал его и повторное чтение элемента вернуло этот ID в PREVIEW_PICTURE или другое согласованное свойство.

\n

Где возникает разрыв

\n

У одной картинки есть несколько состояний. Браузер хранит выбранный объект File. HTML-форма передаёт его как multipart-часть. PHP получает массив в $_FILES. Bitrix создаёт запись в b_file и возвращает числовой ID. Элемент инфоблока хранит ссылку на этот ID в поле изображения. Ни один этап не заменяет следующий.

\n

Preview относится к DOM. Он может измениться до отправки формы, после Ajax-перерисовки или даже при ошибочном ответе сервера. Поэтому текст «файл выбран» и URL картинки в интерфейсе нельзя использовать как доказательство сохранения. Сервер должен получить конкретное поле, проверить его и записать связь.

\n
Состояния изображения и границы проверки
СостояниеПричина сбояПроверкаДействие
Preview обновилсяИзменился только DOMОткрыть Network и найти multipart-полеНе считать файл сохранённым
В $_FILES нет поляНеверное name или форма без enctypeПроверить имя input, метод и Request PayloadИсправить контракт формы
Upload завершился ошибкойЛимит PHP, размер или права каталогаПроверить error, size и серверный логВернуть ошибку формы и остановить запись
Есть ID файла, но карточка прежняяНе вызван Update() или передано не то полеПовторно прочитать элемент и поле изображенияСохранить файловый массив в сущность
После удаления картинка вернуласьУдаление выведено из пустого previewПроверить явный флаг удаления в POSTРазвести ветки «оставить», «заменить» и «удалить»
\n
\"Интерфейс
Preview помогает выбрать файл, но итогом считается только связь зарегистрированного ID с элементом Bitrix.
\n

Контракт FileInput

\n

Контрол создают на сервере. Имя поля выбирают вместе с обработчиком. Если шаблон отправляет CATALOG_PREVIEW, PHP не должен ждать picture. Такая ошибка выглядит как «Bitrix не загрузил картинку», хотя файл просто не попал в ожидаемую ветку.

\n

Ниже учебный пример для формы с одной картинкой. Он показывает состав параметров, а не готовую конфигурацию конкретного проекта. Константы, доступность источников и набор опций нужно сверить с версией модуля main и установленными правами.

\n
<?php\nuse Bitrix\\Main\\UI\\FileInput;\n\n$currentFileId = (int) $arResult['PREVIEW_PICTURE'];\n\necho FileInput::createInstance(array(\n    'id' => 'catalog_preview',\n    'name' => 'CATALOG_PREVIEW',\n    'upload' => true,\n    'allowUpload' => FileInput::UPLOAD_IMAGES,\n    'maxCount' => 1,\n    'maxSize' => 5 * 1024 * 1024,\n    'delete' => true,\n))->show($currentFileId);\n?>
\n

name связывает разметку с серверным кодом. upload включает загрузку. allowUpload сужает сценарий до изображений, если это поддерживает установленная версия API. maxCount и maxSize уменьшают число ошибочных действий в интерфейсе. Они не заменяют серверную проверку: запрос можно отправить вручную.

\n

delete только даёт пользователю возможность выбрать удаление через контрол. Правило хранения остаётся в обработчике. Для редактирования существующего элемента нужно заранее решить, что означает отсутствие нового файла: обычно сохранить старую картинку. Явное удаление передают отдельным признаком.

\n

Форма должна отправить файл

\n

Обычная HTML-форма с файлом использует method=\"post\" и enctype=\"multipart/form-data\". Для Ajax нужен объект FormData. Вызов jQuery serialize() собирает текстовые поля, но не переносит бинарное содержимое файла. Если заменить multipart-запрос сериализацией, preview останется, а сервер получит только остальную форму.

\n

В legacy-шаблоне частая ошибка появляется после .html(). Старый input удаляют, новый вставляют, а обработчик остаётся привязан к уничтоженному узлу. Повторная инициализация добавляет второй обработчик. Делегируйте событие стабильному контейнеру и снимайте только свой namespace.

\n
(function ($) {\n  function bindPhotoEditor(root) {\n    var $root = $(root);\n\n    $root.off('change.photoEditor', 'input[name=CATALOG_PREVIEW]')\n      .on('change.photoEditor', 'input[name=CATALOG_PREVIEW]', function () {\n        var file = this.files && this.files[0];\n        $root.find('.js-photo-status').text(\n          file ? 'Файл выбран. Сохранение ещё не выполнено.' : 'Файл не выбран.'\n        );\n      });\n  }\n\n  $(function () {\n    bindPhotoEditor(document);\n  });\n}(jQuery));
\n

Этот фрагмент учебный. Он меняет статус в DOM и не создаёт ID файла. Проверить его можно по одному простому признаку: после повторной отрисовки контейнера изменение файла вызывает один статус, а Network показывает один запрос при сохранении. Общий off('change') здесь опасен: он может снять обработчики других компонентов.

\n

Серверный путь: файл, затем связь

\n

Обработчик не должен брать «последний созданный файл» из базы. В форме одновременно работают несколько пользователей. Связь строят из данных конкретного запроса и конкретного элемента.

\n

Сначала проверяют результат загрузки и входные ограничения. Поле type из запроса — это заявление клиента. Нужны серверные проверки размера, расширения, MIME и содержимого изображения. Перечень допустимых форматов зависит от продукта. Не принимайте его только потому, что контрол показал кнопку выбора картинки.

\n
<?php\n\nfunction validateImageUpload(array $upload): array\n{\n    if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n        throw new RuntimeException('Загрузка изображения не завершена');\n    }\n\n    $size = (int) ($upload['size'] ?? 0);\n    if ($size < 1 || $size > 5 * 1024 * 1024) {\n        throw new RuntimeException('Размер изображения не подходит');\n    }\n\n    if (!isset($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name'])) {\n        throw new RuntimeException('Временный файл не подтверждён PHP');\n    }\n\n    return $upload;\n}\n\n$upload = validateImageUpload($_FILES['CATALOG_PREVIEW'] ?? array());\n$fileId = (int) CFile::SaveFile($upload, 'catalog');\nif ($fileId < 1 || !CFile::GetFileArray($fileId)) {\n    throw new RuntimeException('Bitrix не зарегистрировал файл');\n}
\n

Код выше ограничен учебным примером. Он не знает вашу авторизацию, CSRF-защиту, политику форматов, антивирус, каталог хранения и способ очистки временных файлов. В рабочем обработчике эти условия должны быть явными. Если FileInput в вашей версии сначала загружает файл отдельным запросом, не копируйте ветку с $_FILES: сначала определите, какое значение вернул контрол и кто владеет временным ID.

\n

После регистрации ID нужно превратить в файловый массив для API элемента. Для поля PREVIEW_PICTURE недостаточно положить число в произвольное поле. Старый API Bitrix ожидает структуру файлового значения и возвращает ошибку через результат обновления и LAST_ERROR.

\n
<?php\n\n$element = new CIBlockElement();\n$picture = CFile::MakeFileArray($fileId);\n\nif (!$picture) {\n    throw new RuntimeException('Не удалось собрать файловый массив');\n}\n\n$updated = $element->Update($elementId, array(\n    'PREVIEW_PICTURE' => $picture,\n));\n\nif (!$updated) {\n    throw new RuntimeException($element->LAST_ERROR ?: 'Элемент не обновлён');\n}
\n

Сохранение файла и обновление элемента — разные операции. Первая может завершиться успешно, а вторая — нет из-за прав, неверного ID, ошибки поля или обработчика события. Тогда в b_file останется незакреплённая запись. Политику очистки таких файлов определите отдельно. Не удаляйте старую картинку до успешной привязки новой, если откат не гарантирован.

\n

Три состояния редактирования

\n

Обновление существующей картинки должно различать три команды. Новый файл означает замену после успешной проверки. Отсутствие нового файла и отсутствие флага удаления означает «оставить как есть». Явный флаг удаления означает очистить поле. Новый файл вместе с удалением — конфликт, который лучше отклонить, чем разрешить случайным порядком полей.

\n

Это правило нельзя выводить из пустого preview. DOM может очиститься после ошибки JavaScript или перерисовки формы, хотя пользователь не просил удалять изображение. Смысл операции передаёт серверное поле, которое обработчик проверяет явно.

\n
<?php\n\n$hasNewFile = isset($_FILES['CATALOG_PREVIEW'])\n    && ($_FILES['CATALOG_PREVIEW']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;\n$deleteRequested = ($_POST['DELETE_PICTURE'] ?? '') === '1';\n\nif ($hasNewFile && $deleteRequested) {\n    throw new RuntimeException('Выберите новый файл или удаление');\n}\n\nif (!$hasNewFile && !$deleteRequested) {\n    // Старое изображение остаётся. Update() для поля не вызываем.\n    return;\n}\n\n$fields = $deleteRequested\n    ? array('PREVIEW_PICTURE' => array('del' => 'Y'))\n    : array('PREVIEW_PICTURE' => CFile::MakeFileArray($fileId));\n\n$element = new CIBlockElement();\nif (!$element->Update((int) $elementId, $fields)) {\n    throw new RuntimeException($element->LAST_ERROR);\n}
\n

Фрагмент показывает отрицательный путь, но не является готовым endpoint. В реальном коде до него должны выполняться проверка сессии, права на конкретный элемент, CSRF, нормализация входа и проверка принадлежности временного файла пользователю или черновику. Для свойства типа «Файл» формат обновления может отличаться от поля элемента. Сверяйте документацию именно для своего метода.

\n

Проверка после записи

\n

Ответ 200 и положительный ID не закрывают задачу. После Update() нужно прочитать элемент новым запросом. Читайте то же поле, которое использует страница. Старый объект, заполненный до POST, может содержать прежнее значение.

\n

Проверка должна сравнить ожидаемый ID с фактическим. Затем откройте карточку через отдельный HTTP-запрос и проверьте видимое изображение. Этот шаг ловит обработчики событий, кеш, неверный инфоблок и запись не в то свойство.

\n

Порядок действий

\n
  1. Назовите элемент, поле изображения и пользователя, который имеет право его менять.
  2. Зафиксируйте текущий ID картинки и правило для случая без нового файла.
  3. Выведите FileInput с согласованным name, одной картинкой и лимитами интерфейса.
  4. Проверьте HTML формы: multipart/form-data, ожидаемое имя поля и отсутствие дубликатов input после Ajax.
  5. Отправьте небольшой учебный файл и проверьте в Network multipart-часть, код ответа и тело ответа.
  6. На сервере проверьте права, код загрузки, размер, временный путь, MIME и содержимое файла.
  7. Зарегистрируйте файл через согласованный API Bitrix и проверьте положительный ID.
  8. Передайте файловое значение в CIBlockElement::Update() и обработайте LAST_ERROR.
  9. Повторно прочитайте элемент и сравните новое поле изображения с ожидаемым ID.
  10. Повторите тест без файла, с явным удалением и с конфликтом «новый файл плюс удаление».
  11. Только после проверок решите, как очищать незакреплённые файлы и временную диагностику.
\n

Ограничения

\n

Параметры FileInput, константы разрешённых типов и формат возвращаемого значения зависят от версии Bitrix. Старое ядро может требовать другой способ подготовки файлового массива. Проверяйте установленный API, а не переносите пример по названию метода.

\n

Лимит в пять мегабайт в коде — учебное число. Он не доказывает подходящий размер для вашей витрины. На результат также влияют upload_max_filesize, post_max_size, права каталога, обратный прокси и антивирусная проверка. Несовпадение этих ограничений проявляется до Update().

\n

Сохранение ID файла не заменяет проверку владельца. Если файл создаётся отдельным Ajax-запросом, временный ID нужно связать с пользователем и элементом или черновиком. Нельзя принять любой ID из скрытого поля и назначить его чужой карточке.

\n

Примеры выше не сообщают production-результаты и не обещают совместимость с конкретной установкой. Они показывают проверяемую последовательность. В рабочем проекте добавьте журнал ошибки без содержимого файла и персональных данных, а также тесты для успешной загрузки, отказа и повторного чтения.

\n

Критерий готовности

\n

Интеграция готова, если после выбора учебного изображения форма делает один ожидаемый multipart-запрос, сервер принимает только разрешённый вход, Bitrix возвращает ID файла, Update() завершается успешно, а свежая страница показывает именно этот файл. При отправке без нового файла старая картинка остаётся. При явном удалении поле очищается. При конфликте действий сервер возвращает понятную ошибку и не меняет элемент.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/333.json b/editorial/agent-rewrites/333.json new file mode 100644 index 0000000..d63c368 --- /dev/null +++ b/editorial/agent-rewrites/333.json @@ -0,0 +1,7 @@ +{ + "index": 333, + "slug": "editorial-2018-10-mechanism-image-workflow", + "title": "Bitrix: почему preview не означает сохранённую картинку", + "excerpt": "В форме уже виден новый preview, но карточка после сохранения показывает старое изображение. Разделяем браузерный файл, запись в b_file и ссылку PREVIEW_PICTURE, чтобы найти разрыв и проверить результат.", + "contentHtml": "

В форме товара появляется новая картинка. Пользователь нажимает «Сохранить», открывает карточку и видит старую обложку. Иногда интерфейс сообщает об успехе, хотя сервер сохранил файл отдельно и не связал его с элементом инфоблока.

\n

Цена ошибки выше, чем один неудачный POST. Оператор повторяет загрузку, каталог показывает устаревшие данные, а в хранилище остаются лишние файлы. При разборе инцидента команда видит preview и решает, что загрузка прошла. Но preview доказывает только состояние браузера.

\n

Тезис статьи простой: изображение проходит несколько границ. Браузер показывает выбранный File. PHP получает multipart-данные. Bitrix регистрирует файл и выдаёт числовой ID. Элемент инфоблока хранит ссылку на этот ID. Успех нужно проверять на каждой границе. Последний обязательный факт — после обновления элемент возвращает ожидаемый PREVIEW_PICTURE.

\n
\"Состояния
Preview принадлежит интерфейсу. Подтверждённая картинка появляется только после связи ID файла с элементом инфоблока.
\n

Четыре состояния вместо одного слова «картинка»

\n

Первое состояние живёт в браузере. Диалог выбора создал объект File, а JavaScript показал его имя, размер или локальный preview. В этот момент сервер ещё ничего не знает. Перезагрузка страницы удалит это состояние.

\n

Второе состояние возникает в HTTP-запросе. Сервер получает поле из multipart/form-data. Имя поля может отличаться от имени, которое видит разработчик в шаблоне: Ajax, вложенная структура и повторная отрисовка часто меняют фактический POST. Поэтому проверяют не DOM, а сетевой запрос и PHP-массив.

\n

Третье состояние создаёт Bitrix. CFile::SaveFile() принимает файловый массив, сохраняет файл и регистрирует его в b_file. Положительный ID означает, что у приложения появился зарегистрированный файл. Он ещё не означает, что файл стал изображением товара.

\n

Четвёртое состояние хранит сам элемент. В поле PREVIEW_PICTURE лежит ссылка на зарегистрированный файл. Её меняет операция обновления элемента. Если обработчик только вызвал SaveFile(), карточка останется со старым ID.

\n
Граница, доказательство и следующий шаг
СостояниеВладелецДоказательствоСледующий шаг
Файл выбранБраузер и DOMЕсть имя, размер или previewНе считать сохранением
Данные пришлиPHP-обработчикОжидаемое поле и UPLOAD_ERR_OKПроверить размер, тип, права и передать дальше
Файл зарегистрированb_fileCFile::SaveFile() вернул ID, GetFileArray() его находитСобрать файловый массив для элемента
Карточка измененаЭлемент инфоблокаCIBlockElement::Update() вернул true, свежее чтение вернуло новый IDПоказать результат новым запросом
\n

Контрол задаёт интерфейс, а не контракт хранения

\n

В старой установке Bitrix форма может использовать \\Bitrix\\Main\\UI\\FileInput. Контрол рисует поля, кнопки, preview и JavaScript. Метод show() выводит интерфейс для текущего ID, но не обновляет элемент инфоблока сам по себе. На границе обработчика всё равно нужны имя поля, файловый массив и явное действие: заменить, оставить или удалить.

\n

Ниже учебный фрагмент для одной картинки. Он показывает настройки интерфейса и не заменяет серверную проверку. Название поля должно совпасть с тем, что реально приходит в POST. Если проект использует другую версию Bitrix или собственный Ajax-адаптер, сначала смотрят фактический запрос.

\n
<?php\nuse Bitrix\\Main\\UI\\FileInput;\n\n$currentFileId = (int) $arResult['PREVIEW_PICTURE'];\n\necho FileInput::createInstance([\n    'id' => 'catalog_preview',\n    'name' => 'CATALOG[PREVIEW_PICTURE]',\n    'upload' => true,\n    'allowUpload' => FileInput::UPLOAD_IMAGES,\n    'medialib' => false,\n    'fileDialog' => true,\n    'cloud' => false,\n    'delete' => true,\n    'edit' => true,\n    'maxCount' => 1,\n    'maxSize' => 5 * 1024 * 1024,\n])->show($currentFileId);\n?>
\n

maxSize помогает интерфейсу показать предел, но пользователь может изменить запрос вручную. allowUpload ограничивает сценарий контрола, но не доверенный источник данных. Сервер заново проверяет размер, фактический тип содержимого, расширение, права и лимит PHP. Заголовок Content-Type нельзя принимать за доказательство типа файла.

\n

Сначала регистрируем файл, потом меняем элемент

\n

Обработчик должен различать ошибку загрузки и ошибку привязки. Не сохраняйте локальный путь из браузера и не используйте имя файла как идентификатор. Получите файловый массив, проверьте его, зарегистрируйте файл, затем соберите массив для Update().

\n
function saveCatalogImage(array $upload): int\n{\n    if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {\n        throw new RuntimeException('Upload did not finish');\n    }\n\n    $size = (int) ($upload['size'] ?? 0);\n    if ($size < 1 || $size > 5 * 1024 * 1024) {\n        throw new RuntimeException('Image size is outside the limit');\n    }\n\n    // Учебный пример: production-код должен добавить проверку типа,\n    // расширения, прав, CSRF и настроек хранилища.\n    $upload['MODULE_ID'] = 'catalog';\n    $fileId = (int) CFile::SaveFile($upload, 'catalog');\n\n    if ($fileId < 1 || !CFile::GetFileArray($fileId)) {\n        throw new RuntimeException('Registered file was not found');\n    }\n\n    return $fileId;\n}\n\n$fileId = saveCatalogImage($_FILES['CATALOG_PREVIEW']);\n$element = new CIBlockElement();\n$picture = CFile::MakeFileArray($fileId);\n\n$updated = $element->Update($elementId, [\n    'PREVIEW_PICTURE' => $picture,\n]);\n\nif (!$updated) {\n    throw new RuntimeException($element->LAST_ERROR);\n}
\n

Это учебный пример порядка операций. В конкретном проекте нужно проверить формат массива, версию API, модуль, права и обработчики событий. GetFileArray() отделяет зарегистрированный ID от случайного числа. MakeFileArray() готовит описание существующего файла. Update() отдельно сообщает, удалось ли изменить элемент. Один положительный ответ не заменяет два других.

\n

Если регистрация файла прошла, а Update() вернул ошибку, не показывайте пользователю успех. Новый файл уже может существовать без связи с карточкой. Политика очистки таких файлов зависит от проекта: временное состояние, очередь уборки или безопасная ручная обработка. Нельзя удалять файл вслепую, если другой элемент успел получить тот же ID.

\n

Симптом → причина → проверка → действие

\n
Диагностика формы с изображением
СимптомПричинаПроверкаДействие
Preview сменился, карточка нетОбновился только DOMСравнить ID до и после свежего чтения элементаПередать новый файловый массив в Update()
POST пустойНет multipart, поле переименовано или Ajax отправляет другой наборПосмотреть Network и $_FILES без содержимого файлаИсправить контракт формы и обработчик
Есть ID, но файл не находитсяСохранение вернуло невалидный результат или ID прочитан не из того поляВызвать GetFileArray($fileId)Остановить привязку и записать безопасную причину
Файл есть, поле староеВызвали SaveFile(), но не обновили элементПроверить вызов Update() и LAST_ERRORРазделить этапы и проверять оба ответа
После ошибки растёт число файловФайл зарегистрирован до неудачной привязкиСопоставить ID файла с элементом и временем операцииОпределить безопасную уборку сиротских записей
Загружается не изображениеДоверие расширению или заголовку клиентаПроверить содержимое, размер, расширение и серверные ограниченияОтклонить файл до регистрации
Удаляется старая картинка без командыПустой preview приняли за флаг удаленияРазличить отсутствие нового файла и явное DELETEСохранить старую картинку, если удаление не подтверждено
\n

Отрицательный путь: нет нового файла и есть удаление

\n

Отсутствие нового файла не равно удалению. Пользователь мог открыть форму, ничего не выбрать и нажать «Сохранить». Для такого запроса правило должно быть явным: нет нового файла и нет флага удаления — оставить старую картинку; есть новый файл — заменить после успешной регистрации и обновления; есть явный флаг удаления — удалить по согласованному контракту.

\n

Не выводите решение из пустого preview. DOM может исчезнуть после Ajax-перерисовки, ошибки загрузки или закрытия диалога. Удаление должно приходить отдельным проверяемым полем, а сервер должен проверить право пользователя и принадлежность текущего файла элементу.

\n

Если FileInput сначала загружает файл отдельным запросом, а потом форма сохраняет карточку, временный ID нельзя считать готовым результатом. Пользователь может закрыть вкладку между запросами. Временный объект должен иметь понятный статус и срок жизни. Финальная операция должна повторно проверить владельца и связь с элементом.

\n

Порядок проверки

\n
  1. Зафиксируйте ID элемента и текущий ID PREVIEW_PICTURE до изменения.
  2. Проверьте форму: method=\"post\", enctype=\"multipart/form-data\", имя поля и CSRF-контракт.
  3. Выберите небольшой учебный JPEG и сравните имя поля в DOM, Network и PHP-массиве.
  4. Проверьте серверные размер, фактический тип, расширение, права и ограничения PHP до вызова SaveFile().
  5. Сохраните файл и подтвердите ID через GetFileArray(). При ошибке остановите процесс.
  6. Соберите файловый массив через MakeFileArray() и вызовите CIBlockElement::Update().
  7. Проверьте оба результата: true от обновления и пустой LAST_ERROR при успехе.
  8. Повторно прочитайте элемент новым запросом и сравните его PREVIEW_PICTURE с ожидаемым ID.
  9. Отдельно проверьте три отрицательных случая: пустой POST, файл неверного типа и явное удаление без нового файла.
  10. Уберите временные логи или оставьте только безопасные идентификаторы операции, элемента, файла и причину отказа.
\n

Ограничения модели

\n

Код выше не является готовым обработчиком для любой версии Bitrix. Он не описывает транзакцию между файловым хранилищем и элементом, антивирус, ресайз, CDN, дисковую квоту, свойства инфоблока, несколько файлов и конкурентное редактирование. Для свойства типа «Файл» формат PROPERTY_VALUES проверяют отдельно. Для административной формы отдельно проверяют права и события Bitrix.

\n

Учебные имена catalog, CATALOG_PREVIEW, лимит 5 MiB и фиксированный сценарий с одной картинкой не являются требованиями production. Пример не доказывает, что конкретная установка принимает любой JPEG, и не даёт production-результатов. Его задача — показать порядок и точки проверки.

\n

Официальная документация Bitrix описывает API, но не знает правила вашего каталога. OWASP перечисляет меры для загрузки файлов, однако набор контролей зависит от угроз, типа данных и архитектуры. Без проверки реального POST, прав и настроек окружения нельзя объявлять интеграцию готовой.

\n

Проверяемый критерий готовности

\n

Сценарий готов к интеграционной проверке, когда для одного тестового элемента можно показать четыре факта: сервер получил ожидаемый файловый массив; Bitrix зарегистрировал новый ID; Update() вернул успех; свежее чтение элемента вернуло этот ID в PREVIEW_PICTURE. Дополнительно пустая отправка сохраняет старый файл, неверный тип отклоняется, а явное удаление не возникает из пустого preview.

\n

Если виден только preview или только ID в b_file, работа не завершена. Источник истины для карточки — поле элемента после успешного обновления. Именно его нужно проверять новым запросом.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/334.json b/editorial/agent-rewrites/334.json new file mode 100644 index 0000000..5ef7053 --- /dev/null +++ b/editorial/agent-rewrites/334.json @@ -0,0 +1,7 @@ +{ + "index": 334, + "slug": "editorial-2018-09-field-windows-dev-env", + "title": "Windows: как доказать, что «работает на моей машине» связано с окружением", + "excerpt": "Одинаковая команда может запускать разные файлы и получать разный вывод. Разбираем порядок проверки PATH, приоритета PowerShell, версии инструмента и кодировки без переустановки среды.", + "contentHtml": "

Симптом знакомый: один и тот же commit и одна команда проходят на машине коллеги, но падают на вашей. Иногда команда запускается, но показывает другую версию инструмента. Иногда сборка верна, а лог в консоли превращается в нечитаемый текст. Цена ошибки начинается не с исправления, а с поспешной реакции. Переустановка Node, очистка кеша и правка системного PATH меняют сразу несколько условий. После этого трудно восстановить исходную причину.

\n

Тезис статьи простой: имя команды не доказывает, какой файл и какой процесс её выполняет. В PowerShell на результат влияют приоритет команд, функции и alias, порядок каталогов в PATH, наследование переменных новым процессом и отдельный слой кодировки консоли. Поэтому диагностика должна сначала зафиксировать наблюдения, затем проверить одну гипотезу обратимым действием.

\n

Сначала исключите различия проекта

\n

Сравнение окружений имеет смысл только при одинаковом входе. Зафиксируйте commit, состояние рабочей копии, lock-файл, каталог запуска и точный текст команды. Если на одной машине изменён package-lock.json, установлен другой набор зависимостей или команда запущена из другого каталога, это уже самостоятельная причина. Не смешивайте её с вопросом о Windows.

\n
git rev-parse --short HEAD\ngit status --short\nGet-Location\nnode --version\nnpm --version\nnpm run build
\n

Этот фрагмент — учебный шаблон. Название команды проекта и набор проверок зависят от репозитория. Сохраните полный вывод на обеих машинах. Секреты, токены и личные части путей перед передачей другому человеку удалите.

\n

Механизм выбора команды

\n

PowerShell ищет команду не по абстрактному имени, а по правилам приоритета. Функция или alias может скрыть приложение. Если выбрано приложение, PowerShell ищет исполняемый файл в каталогах из переменной PATH. Первый подходящий каталог имеет значение. Переменная в текущем процессе не обязана совпадать с тем, что вы изменили в системных настройках несколько минут назад.

\n

Сначала покажите все кандидаты и тип найденного объекта. Затем покажите фактическую версию. Эти команды отвечают на разные вопросы:

\n
Get-Command node -All |\n  Select-Object CommandType, Name, Version, Definition |\n  Format-Table -AutoSize\n\nwhere.exe node\nnode --version\n$env:Path -split ';'
\n

Get-Command показывает, что выберет текущая сессия PowerShell. where.exe помогает увидеть исполняемые файлы, которые находятся в путях поиска Windows. Если первый результат — Function или Alias, список файлов не объясняет поведение команды. Если результаты указывают на разные node.exe, сравните путь и версию. Если путь и версия совпали, прекратите менять PATH и переходите к следующему факту.

\n

Учебный пример: два node.exe

\n

Представим две машины с одним репозиторием. На машине A Get-Command node -All первым показывает C:\\Tools\\node-18\\node.exe. На машине B первым идёт C:\\Program Files\\nodejs\\node.exe. Это не означает, что машина A или B настроена правильно. Сначала нужно посмотреть, какую версию требует проект, и сопоставить её с node --version.

\n
ПолеМашина AМашина BВывод
Первый кандидатC:\\Tools\\node-18\\node.exeC:\\Program Files\\nodejs\\node.exeОдинаковое имя команды ведёт к разным файлам
ВерсияУчебное значение v18.xУчебное значение v20.xВерсия может менять поведение сборки
Порядок PATHКаталог Tools стоит раньшеКаталог Tools отсутствуетРазличие объясняет выбор, но не требование проекта
Исходная командаУчебно завершается ошибкойУчебно проходитНужна проверка гипотезы, а не удаление среды
\n

Значения в таблице условные. Это пример способа рассуждать, а не отчёт о production-системе. Если проект требует другую версию, источник требования должен находиться в его README, lock-файле, менеджере версий или принятой инструкции установки.

\n

Снимок окружения без лишнего шума

\n

Полный дамп среды часто содержит слишком много данных. Для сравнения достаточно сохранить версию PowerShell, кодовую страницу, настройки вывода, PATHEXT, элементы PATH и кандидатов нужных команд. Важен порядок элементов. Превращайте каждую папку в отдельную строку, чтобы отличие было видно в обычном diff.

\n
$snapshot = [ordered]@{\n  powershell = $PSVersionTable.PSVersion.ToString()\n  codePage = (chcp)\n  outputEncoding = [Console]::OutputEncoding.WebName\n  path = @($env:Path -split ';')\n  commands = @(Get-Command node -All | ForEach-Object {\n    [ordered]@{\n      type = $_.CommandType.ToString()\n      name = $_.Name\n      definition = $_.Definition\n      version = $_.Version.ToString()\n    }\n  })\n}\n$snapshot | ConvertTo-Json -Depth 5
\n

В реальном скрипте обработайте отсутствие версии и отсутствие кандидата. Не записывайте в общий файл весь набор переменных без фильтра. Значения PATH могут раскрыть имена пользователей, внутренние каталоги и служебные адреса. Для учебного запуска достаточно вывести JSON в файл и сравнить очищенные копии.

\n
\"Схема
Диагностический маршрут: одинаковый вход, два очищенных снимка, одно отличие, обратимый опыт и повтор исходной команды.
\n

Как не спутать похожие симптомы

\n
СимптомПричинаПроверкаДействие
node --version возвращает старую версиюДругой файл оказался первым в PATHGet-Command node -All, where.exe nodeВременно поставить одобренный каталог первым в текущем процессе
where.exe показывает файлы, но PowerShell ведёт себя иначеКоманду перехватывает функция или aliasПосмотреть CommandType; сравнить сессии с профилем и без негоОткрыть новый PowerShell с -NoProfile
Только одна консоль показывает битый текстРазличается кодовая страница или кодировка выводаchcp, [Console]::OutputEncodingПовторить одну команду в новой консоли; не менять PATH
После правки переменной результат прежнийСтарый процесс унаследовал прежнее значениеПроверить новый процесс и его $env:PathЗакрыть старое окно, повторить проверку в новом
Путь и версия совпали, сборка всё ещё падаетПричина лежит в зависимостях, правах, сети или конфигурацииLock-файл, полный лог, доступ к каталогу и сетевой запросОстановить изменения PATH и расследовать следующий слой
\n

Обратимая проверка гипотезы

\n

Если сравнение указывает на порядок PATH, меняйте только процессную переменную в новом PowerShell. Такой эксперимент не редактирует системную конфигурацию и исчезает после закрытия окна. Путь ниже условный. Подставляйте каталог, который назван в документации проекта или одобренном способе установки.

\n
$requiredNode = 'C:\\Tools\\node-18'\n$nodePath = Join-Path $requiredNode 'node.exe'\nif (-not (Test-Path -LiteralPath $nodePath)) {\n  throw \"Не найден учебный node.exe: $nodePath\"\n}\n\n$env:Path = $requiredNode + ';' + $env:Path\nGet-Command node -All |\n  Select-Object CommandType, Definition, Version |\n  Format-Table -AutoSize\nnode --version\n\n# Здесь повторяется та же команда, что дала исходную ошибку.\nnpm run build
\n

Если версия изменилась и исходная команда стала проходить, гипотеза о выборе бинарника получила подтверждение. Это ещё не готовое постоянное исправление. Зафиксируйте требуемую версию в проекте и выберите согласованный способ поставки. Если версия не изменилась, не переносите каталог в пользовательский или системный PATH. Если команда всё равно падает, путь был не причиной или существует дополнительная причина.

\n

Кодировка — отдельная проверка

\n

chcp показывает активную кодовую страницу консоли. Команды, запущенные после её изменения, могут получить новое значение, а уже работающие процессы сохраняют прежнее. Это не делает chcp 65001 универсальным лечением. Программа может читать файл в другой кодировке, задавать собственный вывод или писать результат не в консоль.

\n

Проверяйте один и тот же текст в одинаковой команде. Сравните chcp, [Console]::OutputEncoding, способ записи файла и редактор, который открывает файл. Если путь и версия совпадают, а ломается только сохранённый файл, расследуйте кодировку файла. Если ломается только окно терминала, расследуйте консоль. Не меняйте два слоя одновременно.

\n

Порядок действий

\n
  1. Зафиксируйте commit, lock-файл, каталог запуска, команду и полный текст ошибки.
  2. Повторите команду на обеих машинах без предварительной правки настроек.
  3. Снимите версию PowerShell, кодовую страницу, кодировку вывода, PATH, PATHEXT и всех кандидатов нужного инструмента.
  4. Удалите секреты и персональные пути из снимков, затем сравните одинаковые поля.
  5. Выберите одно отличие, которое прямо связано с симптомом: кандидат, версия, процесс или кодировка.
  6. Проверьте отличие в новом PowerShell с -NoProfile и временным изменением только текущего $env:Path, если это гипотеза о пути.
  7. Повторите исходную команду и сохраните результат проверки.
  8. Только после подтверждения внесите постоянное изменение в README, менеджер версий или согласованный установщик.
\n

Ограничения и отрицательный путь

\n

Одинаковый путь к бинарнику не гарантирует одинаковую сборку. Различия могут быть в разрядности, DLL, правах доступа, сертификате прокси, сетевом маршруте, кеше, антивирусе, окончаниях строк и содержимом рабочей копии. После совпадения пути и версии это не повод продолжать менять окружение. Зафиксируйте следующий наблюдаемый симптом и перейдите к нему.

\n

Не отключайте защиту, политику запуска или антивирус ради проверки «на всякий случай». Не скачивайте исполняемый файл из случайного каталога. Не копируйте чужой полный PATH поверх своего. Если временный опыт не изменил исходный результат, отрицательный вывод полезен: выбранная гипотеза не подтверждена, а доказательства сохранены.

\n

Проверяемый критерий готовности

\n

Разбор завершён, когда другой разработчик может повторить команду из того же commit и получить тот же результат на новой сессии. Для этого остаются четыре проверяемых факта: указан выбранный бинарник и его версия; описан источник требования к версии; команда проходит после чистого запуска или зафиксирован следующий отдельный отказ; постоянное изменение не зависит от ручной настройки конкретного компьютера.

\n

Фраза «на моей машине работает» после такого разбора превращается в проверяемое утверждение. У неё есть команда, вход, процесс, путь поиска и результат. Если утверждение не подтверждается, следующий шаг выбирают по наблюдаемому отличию, а не по числу переустановленных инструментов.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/335.json b/editorial/agent-rewrites/335.json new file mode 100644 index 0000000..e81b700 --- /dev/null +++ b/editorial/agent-rewrites/335.json @@ -0,0 +1,7 @@ +{ + "index": 335, + "slug": "editorial-2018-09-mechanism-windows-dev-env", + "title": "Windows: почему одна команда запускает не тот бинарник", + "excerpt": "PowerShell выбирает команду из нескольких слоёв: профиля, alias, функций, PATH и PATHEXT. Разбираем, как увидеть фактический выбор, отделить кодировку консоли и проверить гипотезу без глобальной правки системы.", + "contentHtml": "

Симптом: в одной консоли node --version показывает ожидаемую версию, а в другой — старую. Иногда команда отрабатывает, но русское сообщение превращается в нечитаемые символы. Цена ошибки — не только одна неудачная сборка. Можно исправить не тот node.exe, повредить текстовый файл или навсегда засорить системный PATH ради разовой проверки.

\n

Имя команды не равно конкретному файлу. PowerShell учитывает команды текущей сессии, затем ищет приложения через переменные среды. Кодировка вывода живёт рядом, но не управляет выбором бинарника. Эти слои надо проверять раздельно.

\n

Тезис: сначала докажите, что именно запустилось

\n

Первый вопрос диагностики: какой объект получил это имя в текущем процессе? Им может оказаться alias, функция, cmdlet, скрипт или приложение. Только последний вариант связывает команду с файлом на диске.

\n

PATH задаёт каталоги для поиска исполняемых файлов. В Windows каталоги разделяет точка с запятой. PATHEXT перечисляет расширения, которые оболочка считает исполняемыми. Поэтому команда без расширения может привести к tool.exe или tool.cmd. До поиска в PATH PowerShell может выбрать функцию или alias с тем же именем.

\n

Механизм процесса и наследование

\n

Каждый процесс получает собственный набор переменных среды. Дочерний процесс наследует копию от родителя. Если в PowerShell выполнить $env:Path = ..., изменится текущая сессия и программы, запущенные из неё. Системные настройки от этого не меняются.

\n

Изменение в окне настроек Windows не обновляет уже открытый терминал. Новая консоль получит новое значение, старая продолжит работать со старым. Поэтому после правки нужно открыть новый процесс. Иначе проверка сравнивает ожидание с устаревшим снимком.

\n
Схема выбора команды в PowerShell: процесс получает PATH и PATHEXT, Get-Command показывает команды, where.exe ищет файлы, а кодовая страница проверяется отдельно
Путь к бинарнику и отображение текста — две ветви одной диагностики. Совпадение версии не доказывает, что консоль правильно прочитала файл.
\n

Get-Command и where.exe отвечают на разные вопросы

\n

Get-Command -Name node -All показывает все найденные команды в порядке приоритета PowerShell. Колонка CommandType отделяет приложение от функции, alias, cmdlet и скрипта. Первый элемент — кандидат, который оболочка выберет при обычном вводе имени.

\n

where.exe node ищет файлы в текущем каталоге и каталогах из PATH. Он полезен для инвентаризации физических файлов, но не видит функцию из профиля PowerShell и не описывает полный приоритет оболочки. Поэтому одна команда не заменяет другую.

\n
$names = @('node', 'npm', 'php', 'git')\nforeach ($name in $names) {\n  Write-Host ('== ' + $name + ' ==')\n  Get-Command -Name $name -All -ErrorAction SilentlyContinue |\n    Select-Object CommandType, Name, Version, Source, Definition |\n    Format-Table -AutoSize\n  where.exe $name 2>$null\n  if (Get-Command -Name $name -ErrorAction SilentlyContinue) {\n    & $name --version\n    Write-Host ('exitCode=' + $LASTEXITCODE)\n  }\n}
\n

Учебный пример показывает способ наблюдения, а не production-результат. В реальном проекте список должен соответствовать README и lock-файлам. Аргумент --version подходит не каждой утилите. Для такой команды укажите документированный аргумент.

\n

Проверка гипотезы через процессный PATH

\n

Безопасная гипотеза звучит так: проект запускает старый файл, потому что он раньше нужного каталога в PATH. Её проверяют в текущем процессе, не меняя системные настройки.

\n
$requiredTools = 'C:\\Tools\\node-20'\nif (-not (Test-Path (Join-Path $requiredTools 'node.exe'))) {\n  throw ('Нет node.exe: ' + $requiredTools)\n}\n$env:Path = $requiredTools + ';' + $env:Path\nGet-Command node -All | Select-Object CommandType, Definition, Version\nnode --version\n# Затем повторяется исходная команда проекта.
\n

Путь в примере условный. Его нельзя копировать без требования проекта и проверки файла. Команды меняют только текущий PowerShell. Если версия и исходная ошибка не изменились, гипотеза не подтверждена. Окно можно закрыть без отката постоянных настроек.

\n

Кодовая страница — отдельная ветвь

\n

chcp показывает активную кодовую страницу консоли. [Console]::OutputEncoding.WebName показывает настройку вывода .NET. Кодировка сохранённого файла — третья сущность. Она может не совпадать ни с одной из двух.

\n

Нечитаемый текст только в одной консоли не доказывает неправильный бинарник. Сначала зафиксируйте кодовую страницу, настройку вывода и байты конкретного файла. Не переключайте системный язык и не добавляйте UTF-8 в глобальные настройки до проверки. Старое приложение может ожидать другую кодировку.

\n
& cmd.exe /d /c chcp\n[Console]::OutputEncoding.WebName\n[Text.Encoding]::Default.WebName\n$env:Path = 'C:\\Tools\\node-20;' + $env:Path\nGet-Command node -All\nnode --version
\n

Эти команды дают снимок процесса и консоли. Они не исправляют кодировку файла и не доказывают совместимость всей сборки. Если проблема относится к файлу, откройте его байты или настройку конкретной программы. Если к выводу — повторите запуск в новом процессе.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
node --version показывает старую версиюДругой кандидат стоит раньше в PATHGet-Command node -All и where.exe nodeВременно поставить документированный каталог первым
where.exe показывает файлы, но запускается функцияПрофиль перекрывает приложениеCommandType и запуск с -NoProfileПроверить профиль, не менять PATH наугад
Русский вывод нечитаем только в одной консолиРазличается кодовая страница или OutputEncodingchcp и свойства ConsoleСравнить новый процесс и кодировку файла
После правки результат прежнийРаботает старый процессНовая сессия без профиляПовторить исходную команду в новом окне
Версия совпала, но сборка падаетПричина в lock-файле, правах, DLL или сетиПолный лог следующей ошибкиПрекратить правку PATH
\n

Порядок действий

\n
  1. Сохраните точную команду, каталог запуска, commit и полный текст ошибки. Не меняйте систему до первого снимка.
  2. В той же консоли выполните Get-Command -All, where.exe и команду версии.
  3. Откройте новый PowerShell с -NoProfile и повторите наблюдения.
  4. Если расходится путь, добавьте нужный каталог только в $env:Path текущего процесса.
  5. Повторите исходную команду и запишите версию, ошибку и код возврата.
  6. Если текст расходится, отдельно зафиксируйте chcp, OutputEncoding и кодировку файла.
  7. Постоянную настройку меняйте только после подтверждённого временного опыта и опишите требование в README.
\n

Ограничения и отрицательный путь

\n

Совпадение команды, пути и версии не делает машины одинаковыми. На результат влияют разрядность, DLL, права, антивирус, прокси, кеш, локальные файлы и политика выполнения. Не отключайте защиту и не переустанавливайте Windows, пока отдельное наблюдение не укажет на такую причину.

\n

chcp 65001 не является универсальным решением. Старые программы могут читать ввод и писать вывод по собственным правилам. Если смена кодовой страницы не меняет симптом, вернитесь к байтам файла и API чтения. Если временный PATH не меняет сборку, перестаньте редактировать PATH.

\n

Проверяемый критерий готовности

\n

Диагностика завершена, когда можно показать, какой объект выбран, какой файл напечатал проверенную версию и какая кодировка относится к проблемному тексту. После исправления новый PowerShell повторяет исходную команду с тем же commit, получает требуемую версию и не возвращает исходную ошибку. Если совпала только версия, готовность не достигнута.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/336.json b/editorial/agent-rewrites/336.json new file mode 100644 index 0000000..e578cb0 --- /dev/null +++ b/editorial/agent-rewrites/336.json @@ -0,0 +1,7 @@ +{ + "index": 336, + "slug": "editorial-2018-09-practice-windows-dev-env", + "title": "Windows-окружение без гадания: как найти настоящий бинарник и сравнить запуск", + "excerpt": "Если одна Windows-машина запускает проект, а другая не находит команду или выбирает другую версию, сначала снимите процессное окружение. Разбираем PATH, профиль PowerShell, кодовую страницу и обратимую проверку без глобальной переустановки.", + "contentHtml": "

Симптом знаком: на одном компьютере npm run build проходит, а на другом команда не находится, запускает старый Node или печатает нечитаемый лог. Код и commit совпадают. Разработчик видит только имя node, но Windows выбирает конкретный файл из конкретного процесса. Цена ошибки — потерянные часы и испорченные доказательства. Если сразу переустановить Node или переписать системный PATH, изменятся несколько условий сразу. После этого трудно понять, что действительно помогло.

\n

Тезис простой: сравнивать нужно не список установленных программ, а путь запуска команды. В него входят процессные переменные, порядок каталогов в PATH, тип найденной команды, фактический путь к файлу, вывод версии и состояние консоли. Такой снимок не делает машины одинаковыми. Он сужает причину до наблюдаемого различия, которое можно проверить одним обратимым действием.

\n

Механизм: имя команды не равно файлу

\n

PowerShell ищет команды в текущей сессии. Результатом может быть alias, функция, cmdlet, скрипт или приложение. Для приложения поиск использует переменную PATH и расширения из PATHEXT. Первый найденный вариант становится тем, что запустит команда. Поэтому строка «Node установлен» не отвечает на вопрос «какой node.exe запустился сейчас?».

\n

Переменные среды имеют область процесса, пользователя и компьютера. Новая PowerShell-сессия получает значения от родительского процесса. Уже открытое окно не обязано увидеть изменение, сделанное в настройках Windows. Если добавить каталог в системный PATH, а затем повторить команду в старом окне, проверка может измерить старое состояние. Для диагностики сначала используйте новый процесс, а постоянную настройку не меняйте.

\n

Кодовая страница — отдельный слой. Она влияет на то, как консоль показывает текст, но не выбирает бинарник. Если путь и версия совпадают, нечитаемый вывод не доказывает проблему PATH. Аналогично, совпадающий node --version не доказывает, что проект получает одинаковые права, сертификаты, зависимости или настройки редактора.

\n
\"Схема
Снимок фиксирует путь запуска команды. Он не является копией компьютера и не должен содержать секреты.
\n

Минимальный снимок

\n

Снимок должен отвечать на четыре вопроса: какая PowerShell выполняет команду; какие каталоги участвуют в поиске; какие кандидаты возвращает Get-Command -All; что сообщает сам инструмент. Отдельно запишите кодовую страницу и кодировку вывода. Не собирайте весь Env:: в нём могут оказаться токены, прокси, внутренние адреса и персональные пути.

\n
Что фиксировать при сравнении двух запусков
ПолеПроверкаЧто объясняетОграничение
Версия и редакция PowerShell$PSVersionTable.PSVersion, $PSVersionTable.PSEditionДоступные команды и различия поведения оболочкиНе доказывает версию проекта
Порядок PATH$env:Path -split ';'Какая папка может дать первый бинарникСам по себе путь не говорит, что файл исправен
Кандидаты командыGet-Command node -AllAlias, функцию и приложения по одному имениНе проверяет зависимости приложения
Фактическая версияnode --versionЧто ответил запущенный инструментНе заменяет lock-файл и требования проекта
Состояние консолиcmd /c chcp, [Console]::OutputEncoding.WebNameПричину различий в отображении текстаНе меняет кодировку файлов и редактора
\n

Пример сбора отчёта

\n

Ниже — ограниченный учебный скрипт для PowerShell 5.1. Он собирает выбранные поля и версии четырёх команд. Пример не устанавливает инструменты, не исправляет PATH и не создаёт production-данные. Замените список команд на требования конкретного проекта. Если проект использует только PHP, отсутствие Node в отчёте нормально.

\n
# Capture-Environment.ps1\n$ErrorActionPreference = 'Stop'\n$toolChecks = @(\n  @{ name = 'node'; arguments = @('--version') },\n  @{ name = 'npm'; arguments = @('--version') },\n  @{ name = 'php'; arguments = @('--version') },\n  @{ name = 'git'; arguments = @('--version') }\n)\n\nfunction Get-Candidates($name) {\n  @(Get-Command -Name $name -All -ErrorAction SilentlyContinue |\n    ForEach-Object {\n      [ordered]@{\n        type = $_.CommandType.ToString()\n        name = $_.Name\n        definition = $_.Definition\n        source = $_.Source\n        version = if ($_.Version) { $_.Version.ToString() } else { $null }\n      }\n    })\n}\n\nfunction Get-VersionOutput($name, $arguments) {\n  if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { return @('NOT FOUND') }\n  try {\n    $lines = @(& $name @arguments 2>&1 | Select-Object -First 3 |\n      ForEach-Object { $_.ToString() })\n    return @($lines + ('exitCode=' + $LASTEXITCODE))\n  } catch {\n    return @('FAILED: ' + $_.Exception.Message)\n  }\n}\n\n$report = [ordered]@{\n  formatVersion = 1\n  capturedAt = (Get-Date).ToString('o')\n  powershell = [ordered]@{\n    version = $PSVersionTable.PSVersion.ToString()\n    edition = $PSVersionTable.PSEdition\n  }\n  console = [ordered]@{\n    codePage = ((cmd.exe /d /c chcp) -join ' ').Trim()\n    outputEncoding = [Console]::OutputEncoding.WebName\n  }\n  environment = [ordered]@{\n    pathEntries = @($env:Path -split ';' | Where-Object { $_ })\n    pathext = $env:PATHEXT\n  }\n  commands = @($toolChecks | ForEach-Object {\n    [ordered]@{\n      name = $_.name\n      candidates = Get-Candidates $_.name\n      versionOutput = Get-VersionOutput $_.name $_.arguments\n    }\n  })\n}\n\n$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath '.\\environment-snapshot.json' -Encoding UTF8
\n

Запускайте его из каталога проекта без профиля, чтобы пользовательская функция или alias не скрыли реальную картину:

\n
powershell.exe -NoProfile -File .\\tools\\Capture-Environment.ps1\nGet-Content .\\environment-snapshot.json -Raw | ConvertFrom-Json | Format-List\nGet-Command node -All | Select-Object CommandType, Name, Version, Definition
\n

Перед отправкой JSON удалите имя пользователя, внутренние каталоги, URL прокси и любые значения, которые относятся к доступу. Сохраните исходный файл только там, где это разрешено. Если отчёт нужен для сравнения, полезнее заменить часть пути на <USER>, чем публиковать личные данные. Не добавляйте снимок в репозиторий, пока не проверили его содержимое.

\n

Симптом → причина → проверка → действие

\n
Диагностическая карта для Windows-окружения
СимптомПричинаПроверкаДействие
Команда не найденаКаталог отсутствует в процессном PATH или программа не установлена$env:Path -split ';', Get-Command name -AllСверить требование проекта и проверить одобренную установку; не дописывать путь наугад
Запускается не та версияСтарая папка стоит раньше в PATHСравнить первый Definition, весь список кандидатов и name --versionВ новом окне временно проверить документированный каталог в начале процессного $env:Path
Вместо приложения найдена функция или aliasПрофиль PowerShell подменяет имяСравнить обычный запуск с powershell.exe -NoProfileИспользовать явный путь или исправить профиль после подтверждения причины
После изменения ничего не поменялосьКоманда выполняется в старом процессеСнять отчёт из новой сессии без профиляПовторить одну исходную команду; не делать вывод о неработающем исправлении по старому окну
Лог нечитаемРазличаются кодовая страница или кодировка выводаchcp, OutputEncoding и способ записи файлаПроверить консоль и файл раздельно; не менять PATH
Путь и версия совпадают, сборка всё ещё падаетПричина находится в зависимостях, правах, сети или конфигурацииLock-файл, полный лог, права каталога и следующий симптомПрекратить правку окружения и продолжить расследование по новому факту
\n

Сравнение двух машин

\n

Сначала сделайте входы сравнимыми: один commit, одна команда, один рабочий каталог и одинаковый lock-файл. На обеих машинах сохраните очищенные снимки. Затем сравните только поля, относящиеся к запуску. В учебном примере машина A первой находит C:\\Tools\\node-6\\node.exe, а машина B — C:\\Program Files\\nodejs\\node.exe. Это доказывает различие поиска, но не доказывает, какая версия нужна проекту. Требование должно прийти из README, lock-файла или официальной документации проекта.

\n
function Get-SnapshotLines($path) {\n  $s = Get-Content $path -Raw | ConvertFrom-Json\n  $lines = @(\n    'powershell=' + $s.powershell.version\n    'codePage=' + $s.console.codePage\n    'outputEncoding=' + $s.console.outputEncoding\n    'pathext=' + $s.environment.pathext\n  )\n  foreach ($entry in $s.environment.pathEntries) { $lines += 'path=' + $entry }\n  foreach ($tool in $s.commands) {\n    $first = @($tool.candidates | Select-Object -First 1)\n    if ($first.Count -eq 0) { $lines += $tool.name + '=NOT FOUND'; continue }\n    $lines += ('{0}={1}|{2}|{3}' -f $tool.name, $first[0].type, $first[0].definition, $first[0].version)\n  }\n  return $lines\n}\nCompare-Object (Get-SnapshotLines '.\\machine-a.json') (Get-SnapshotLines '.\\machine-b.json')
\n

Compare-Object показывает различающиеся строки. Он не ставит диагноз. Если отличаются десять каталогов, выберите тот, который связан с исходной ошибкой, и проверьте его отдельно. Сначала сравните первый кандидат и версию. Только затем смотрите кодовую страницу или второстепенные различия.

\n

Обратимая проверка PATH

\n

Допустим, документация проекта требует Node из конкретного каталога, а снимок показывает старый бинарник. В новом PowerShell добавьте проверенный каталог только в процессную переменную. Учебный путь ниже условный. Его нельзя копировать в рабочую машину без подтверждения версии и расположения файла.

\n
$requiredNode = 'C:\\Tools\\node-8'\nif (-not (Test-Path (Join-Path $requiredNode 'node.exe'))) {\n  throw 'node.exe not found: ' + $requiredNode\n}\n$env:Path = $requiredNode + ';' + $env:Path\nGet-Command node -All | Select-Object CommandType, Definition\nnode --version\nnpm run build
\n

Если версия изменилась и исходная ошибка исчезла, гипотеза о порядке PATH получила подтверждение в этой сессии. Это ещё не разрешение менять системные настройки. Сначала зафиксируйте требование проекта и способ установки. Если результат не изменился, верните внимание к логу. Не оставляйте случайный каталог в постоянном PATH и не скачивайте исполняемый файл из непроверенного источника.

\n

Порядок действий

\n
  1. Запишите commit, рабочий каталог, команду и полный текст ошибки до любых изменений.
  2. Назовите инструменты, которые действительно нужны проекту, и их требуемые версии.
  3. Снимите выбранные поля из обычной PowerShell-сессии.
  4. Повторите снимок в новом окне с -NoProfile, если есть подозрение на alias, функцию или профиль.
  5. Сравните порядок PATH, PATHEXT, кандидатов команды, путь первого приложения и результат --version.
  6. Выберите одно отличие, которое прямо связано с ошибкой. Не исправляйте остальные различия одновременно.
  7. Проверьте гипотезу в новом процессе через временный $env:Path или другой обратимый шаг.
  8. Повторите исходную команду и сохраните результат вместе с новым снимком.
  9. Только после подтверждения обновите README или согласованный установочный механизм. Закройте временную сессию и убедитесь, что глобальные настройки не изменились.
\n

Ограничения и отрицательный путь

\n

Одинаковый снимок не гарантирует одинаковый запуск. На результат влияют разрядность, DLL, права каталога, антивирус, прокси, сертификаты, кеши, редактор, локальные файлы и сетевой доступ. Снимок не видит все эти причины. Он только помогает исключить подмену команды и ошибку поиска.

\n

Если команда не найдена, не добавляйте в PATH первую папку из загрузок. Сначала проверьте официальную инструкцию проекта, наличие нужного файла и архитектуру. Если путь и версия совпали, не продолжайте менять PATH ради другого симптома. Если отчёт содержит секрет, удалите его из копии до публикации и сообщите владельцу доступа по принятому каналу.

\n

Если новый сеанс с нужным каталогом не меняет исходную ошибку, отрицательный результат важен. Он исключает одну гипотезу. Верните постоянные настройки в исходное состояние, не скрывайте неудачную проверку и исследуйте следующий наблюдаемый факт: лог пакетного менеджера, права, сертификат или конфигурацию проекта.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда другой разработчик без устного объяснения может назвать исходную команду и ошибку, показать очищенный снимок, указать первый найденный бинарник, сопоставить его с требуемой версией и повторить обратимую проверку в новом сеансе. Должно быть видно, что изменилось и что не изменилось. Если остаётся только фраза «у меня работает», диагностика не закончена.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/337.json b/editorial/agent-rewrites/337.json new file mode 100644 index 0000000..6d14467 --- /dev/null +++ b/editorial/agent-rewrites/337.json @@ -0,0 +1,7 @@ +{ + "index": 337, + "slug": "editorial-2018-08-field-tls-ca", + "title": "PHP cURL: как заменить устаревший CA bundle без отключения TLS-проверки", + "excerpt": "Старый PHP-клиент перестал доверять HTTPS-партнёру. Разбираем, какой CA bundle использует процесс, как проверить новый файл и переключить его с понятным откатом.", + "contentHtml": "

После замены сертификата у партнёра PHP-процесс начинает возвращать ошибку проверки TLS. Браузер на той же машине продолжает открывать сайт. Если ответить на сбой строкой CURLOPT_SSL_VERIFYPEER => false, запросы снова пойдут, но клиент перестанет проверять личность узла. Атакующий сможет подменить endpoint, а приложение примет его ответ как ответ партнёра. Цена ошибки — не красный лог, а потеря границы доверия.

\n

В этой ситуации меняют не «сертификат вообще», а конкретный источник доверия, который читает PHP. Сначала нужно доказать, какой SAPI, libcurl, TLS-библиотека и CA file участвуют в запросе. Потом — подготовить версионный bundle, проверить цепочку на тестовом endpoint и переключить ровно одну настройку. В конце тот же PHP-клиент должен выполнить безопасный запрос с включённой проверкой.

\n

Почему браузер даёт ложное сравнение

\n

Браузер и PHP могут использовать разные хранилища корневых сертификатов. Браузер часто читает системное хранилище через собственный сетевой стек. PHP extension cURL может быть собран с OpenSSL или другой TLS-библиотекой и получить CA file из настроек сборки, curl.cainfo или явной опции CURLOPT_CAINFO.

\n

Поэтому наблюдение «в браузере работает» сужает поиск, но не объясняет отказ PHP. Оно говорит только о том, что один клиент построил доверенную цепочку для выбранного имени. Второй клиент мог не найти корневой CA, читать старый файл или отправить другой SNI.

\n

Сначала фиксирую активное окружение

\n

Отчёт снимайте тем же PHP-SAPI, который обслуживает приложение. Командный curl из shell не заменяет PHP-FPM или Apache module. В учебном примере функция возвращает технические признаки. Она не должна печатать содержимое ответа API и не должна быть доступна из публичного HTTP-маршрута.

\n
<?php\nfunction tlsEnvironmentReport($candidateBundle)\n{\n    $version = curl_version();\n\n    return array(\n        'php' => PHP_VERSION,\n        'sapi' => PHP_SAPI,\n        'libcurl' => $version['version'],\n        'tls_library' => $version['ssl_version'],\n        'curl_cainfo' => ini_get('curl.cainfo'),\n        'candidate_readable' => is_readable($candidateBundle),\n        'candidate_size' => is_readable($candidateBundle) ? filesize($candidateBundle) : null,\n    );\n}\n?>
\n

curl_version() показывает версию libcurl и TLS-библиотеки, с которой работает расширение. ini_get('curl.cainfo') показывает значение настройки PHP, но не отменяет явный CURLOPT_CAINFO в обёртке клиента. Путь, владелец и права проверяйте от имени пользователя процесса. Если файл не читается, до TLS-диагностики ещё не дошли.

\n

Нахожу единственную точку выбора CA file

\n

В старом приложении источник доверия обычно находится в одном из трёх мест. Сначала ищите CURLOPT_CAINFO в HTTP-обёртке. Затем проверяйте curl.cainfo в фактически загруженном php.ini. Если оба значения пусты, остаётся системный default, зависящий от сборки libcurl и пакетов окружения.

\n

Не меняйте все уровни сразу. Иначе следующий процесс может читать старый файл, а расследование потеряет причинную связь. Зафиксируйте текущий путь, контрольную сумму файла, PHP-SAPI и способ запуска. Секреты и полный сетевой ответ в такой отчёт не включайте.

\n
Диагностика отказа TLS в PHP cURL
СимптомПричинаПроверкаДействие
Браузер работает, PHP не доверяет сертификатуРазные trust store или старый CA bundleСравнить curl_version(), curl.cainfo и явный CURLOPT_CAINFOПодготовить новый bundle и переключить подтверждённый источник
Кандидатный файл не читаетсяНеверный путь, владелец или праваПроверить is_readable() от имени PHP-процессаИсправить поставку файла и права, не менять TLS-флаги
Цепочка не строится в openssl verifyНет intermediate, неверный CA или повреждённый файлРазделить leaf, intermediate и trust anchorИсправить цепочку сервера или источник CA
Ошибка остаётся только на одном имениHostname mismatch или неправильный SNIПовторить openssl s_client с -servernameИсправить URL/SNI или конфигурацию сервера
Запрос проходит после отключения verificationСкрытая ошибка модели доверияПроверить код на CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOSTУдалить обход и вернуть явные безопасные значения
\n

Готовлю bundle как версионный артефакт

\n

Берите публичный CA bundle из официального источника или CA-пакет, который поставляет ваша операционная система. Для частного партнёрского CA используйте подтверждённый root от владельца сервиса. Не сохраняйте leaf-сертификат из случайного TLS-ответа как корневой: leaf меняется при ротации, а доверие к нему означает другое.

\n

Храните новый файл рядом со старым под отдельным именем, например ca-bundle-2026-08.pem. Имя с версией облегчает ревью и откат. Перед переключением проверьте размер, PEM-структуру и контрольную сумму по принятой процедуре. Эти проверки подтверждают целостность файла, но сами по себе не доказывают, что любой endpoint теперь доверен.

\n
\"Схема
CA bundle меняется как конфигурационный артефакт: кандидат проверяется до переключения, а результат подтверждается тем же клиентом.
\n

Проверяю цепочку до изменения приложения

\n

Снимите материалы на тестовом хосте. Укажите настоящее имя тестового endpoint и его SNI. Ниже приведён учебный шаблон: он не содержит реального вывода и не утверждает, что конкретная цепочка уже валидна.

\n
# Команда выполняется в закрытом тестовом окружении.\nopenssl s_client \\\n  -connect api.partner.example:443 \\\n  -servername api.partner.example \\\n  -showcerts < /dev/null\n\n# leaf.pem и intermediate.pem получают из проверенного ответа.\n# ca-bundle-2026-08.pem — кандидат из утверждённого источника.\nopenssl verify \\\n  -purpose sslserver \\\n  -CAfile /opt/app/certs/ca-bundle-2026-08.pem \\\n  -untrusted ./intermediate.pem \\\n  ./leaf.pem
\n

Опция -showcerts показывает сертификаты, присланные сервером. Это не готовый вердикт доверия. В openssl verify параметр -CAfile задаёт доверенные сертификаты, а -untrusted — промежуточные сертификаты для построения цепочки. Такое разделение не позволяет случайно превратить intermediate в trust anchor.

\n

Если проверка не проходит, не объявляйте bundle единственной причиной. Сервер может не прислать intermediate. Тест мог использовать неверное имя. Частный CA может отсутствовать в публичном хранилище по замыслу. В каждом случае сначала исправьте соответствующий объект. Новый bundle не лечит плохую серверную цепочку и не должен превращать hostname mismatch в успех.

\n

Переключаю ровно один источник

\n

Для независимого клиента удобно передать абсолютный путь через CURLOPT_CAINFO. Старый и новый файлы остаются рядом до завершения проверки. Путь не должен зависеть от параметров HTTP-запроса и не должен загружаться из сети во время запуска приложения.

\n
<?php\nfunction partnerRequest($url, $bundle)\n{\n    if (!is_readable($bundle)) {\n        throw new RuntimeException('Configured CA bundle is not readable');\n    }\n\n    $handle = curl_init($url);\n    curl_setopt_array($handle, array(\n        CURLOPT_RETURNTRANSFER => true,\n        CURLOPT_CAINFO => $bundle,\n        CURLOPT_SSL_VERIFYPEER => true,\n        CURLOPT_SSL_VERIFYHOST => 2,\n        CURLOPT_CONNECTTIMEOUT => 5,\n        CURLOPT_TIMEOUT => 15,\n    ));\n\n    $body = curl_exec($handle);\n    $errno = curl_errno($handle);\n    $error = curl_error($handle);\n    curl_close($handle);\n\n    if ($body === false) {\n        throw new RuntimeException('Partner TLS request failed: ' . $errno . ' ' . $error);\n    }\n\n    return $body;\n}\n?>
\n

Значения таймаутов в примере учебные. Подберите их по контракту конкретного API. Важны три свойства кода: bundle задаётся явно, peer verification включена, проверка имени требует значение 2. Ошибку и номер ошибки сохраняйте до curl_close(), иначе диагностика станет беднее.

\n

Порядок изменения

\n
  1. Зафиксируйте исходный hostname, текст ошибки, PHP-SAPI, версии PHP/libcurl/TLS и текущий источник CA file.
  2. Подготовьте новый bundle под версионным именем из утверждённого источника. Проверьте чтение файла пользователем PHP-процесса.
  3. Получите leaf и intermediate тестового сервера с правильным -servername. Выполните openssl verify с новым -CAfile.
  4. Переключите только явный CURLOPT_CAINFO или только curl.cainfo. Не меняйте оба уровня в одном шаге.
  5. Если изменился php.ini, штатно перезапустите PHP-FPM или другой SAPI. Проверьте, что новый процесс загрузил ожидаемую настройку.
  6. Выполните безопасный read-only или health-запрос тем же PHP-клиентом. Зафиксируйте результат проверки, версию bundle и путь отката.
\n

Что не является исправлением

\n

CURLOPT_SSL_VERIFYPEER => false не обновляет trust store. Он убирает проверку peer и маскирует причину. CURLOPT_SSL_VERIFYHOST => 0 или 1 не исправляет имя сертификата. Не загружайте новый bundle по URL при каждом запуске: так состав доверия меняется без контролируемой поставки.

\n

Не добавляйте в публичный bundle любой сертификат, который встретился в ответе сервера. Для частного CA нужна проверка владельца endpoint и отдельное управление корневым сертификатом. Если сервер присылает неполную цепочку, исправление находится на сервере. Если проблема связана с уязвимой версией PHP, libcurl или TLS-библиотеки, обновление CA не заменяет обновление стека.

\n

Ограничения и критерий готовности

\n

Этот порядок рассчитан на случай, когда endpoint использует публичный или подтверждённый частный CA, а PHP-клиент умеет читать выбранный bundle. Он не обещает исправить отзыв сертификата, просроченный leaf, неверное имя, неправильный SNI, отсутствующий intermediate или несовместимую TLS-политику. Такие отказы должны остаться отказами.

\n

Работа готова, когда тот же PHP-SAPI после перезапуска читает ожидаемый версионный bundle, тестовая цепочка строится с правильным SNI, контролируемый запрос завершается без ошибки cURL, а в коде нет ветки, ослабляющей peer или hostname verification. В журнале релиза есть версия файла, путь настройки, дата проверки и команда отката. HTTP 200 полезен, но недостаточен: он не доказывает, что TLS-проверка была включена.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/338.json b/editorial/agent-rewrites/338.json new file mode 100644 index 0000000..50ad546 --- /dev/null +++ b/editorial/agent-rewrites/338.json @@ -0,0 +1,7 @@ +{ + "index": 338, + "slug": "editorial-2018-08-mechanism-tls-ca", + "title": "TLS в PHP cURL: как отличить CA bundle, hostname и SNI", + "excerpt": "PHP cURL может отклонить HTTPS-соединение даже тогда, когда сайт открывается в браузере. Разбираем цепочку сертификатов, имя хоста и SNI, а затем проверяем каждую причину отдельно.", + "contentHtml": "

PHP cURL получает сертификат от HTTPS-сервера, но останавливает запрос с ошибкой проверки. В браузере тот же адрес открывается. Команда пробует добавить повтор, заменить имя на IP или поставить CURLOPT_SSL_VERIFYPEER => false. Запрос начинает проходить, но клиент больше не подтверждает личность сервера. Цена ошибки — отправить токен, персональные данные или платёжный запрос не тому узлу.

У такого отказа нет одной универсальной причины. Клиент строит цепочку до доверенного корня, проверяет имя в сертификате и получает сертификат для нужного виртуального хоста. Эти проверки связаны в одном TLS-сеансе, но принадлежат разным сторонам. Если разделить их, диагностика превращается из перебора настроек в короткий набор проверок.

Главный тезис: TLS-проверка состоит из отдельных условий

CA bundle отвечает на вопрос «каким центрам сертификации доверяет этот процесс». Серверная цепочка отвечает на вопрос «можно ли от конечного сертификата дойти до такого центра». Проверка hostname отвечает на вопрос «выдан ли сертификат имени из URL». SNI помогает серверу выбрать сертификат до того, как клиент увидит ответ.

Браузер и PHP могут использовать разные TLS-библиотеки, хранилища и прокси. Поэтому результат в браузере не доказывает, что PHP видит тот же bundle и тот же виртуальный хост. Сначала фиксируйте URL и окружение процесса, затем проверяйте серверный ответ и локальное доверие.

\"Схема
Учебная схема: SNI влияет на выбор сертификата сервером, а CA bundle и hostname проверяются клиентом.

Что происходит между URL и HTTP

cURL сначала разбирает URL. Из него он получает имя, порт и путь. Для https://api.example.test/v1/ping hostname — api.example.test. Это имя участвует в TLS-переговорах. Заголовок HTTP Host появляется позже. Он не исправляет сертификат, который уже был выбран и проверен на TLS-уровне.

В ClientHello TLS-клиент обычно передаёт SNI с тем же именем. Сервер использует SNI, чтобы выбрать конфигурацию виртуального хоста. На одном IP могут жить десятки сайтов. Если имя не передано или передано неверно, сервер может вернуть сертификат default-vhost. Этот сертификат может иметь корректную подпись, но не покрывать имя из URL.

После ответа сервера клиент получает конечный сертификат сайта и, как правило, промежуточные сертификаты. Корневой сертификат обычно уже находится в локальном хранилище. Клиент строит путь от leaf через intermediate к доверенному корню. Если intermediate не прислан, а локальный bundle не содержит его как доверенный якорь, путь может не построиться.

ЧастьКто отвечаетЧто проверяетсяТипичный сбой
Hostname в URLКод и конфигурация PHPИмя покрыто SAN сертификатаВ URL указан IP или чужое имя
SNITLS-клиент и серверный vhostВыбран сертификат нужного сайтаОтдан default-vhost
Leaf и intermediateHTTPS-серверСтроится путь сертификатовНе прислан intermediate
CA bundleОкружение PHPКорень считается довереннымФайл отсутствует или недоступен

Сначала снимите наблюдаемый ответ сервера

Для диагностики нужен hostname из настоящего URL приложения. Не подставляйте IP, если хотите проверить рабочий маршрут. Команда ниже — учебный пример с вымышленным доменом. Она не доказывает состояние какого-либо production-сервера, а показывает способ получить список сертификатов с заданным SNI.

openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts < /dev/null

В выводе найдите конечный сертификат и промежуточные сертификаты. -showcerts показывает сертификаты, присланные сервером. Он не означает, что OpenSSL уже построил доверенную цепочку. Сохраните версию OpenSSL, hostname и саму команду рядом с результатом. Не публикуйте приватные ключи и секретные заголовки из диагностического окружения.

Затем выполните тот же учебный запрос без SNI только как контрольное сравнение:

openssl s_client -connect api.example.test:443 -noservername -showcerts < /dev/null

Если leaf различается, сервер выбирает разные виртуальные хосты. Это не повод добавлять полученный сертификат в CA bundle. Проверьте DNS, URL, балансировщик и конфигурацию vhost. Если сертификаты одинаковы, переходите к цепочке и локальному trust store.

Проверьте цепочку без PHP и HTTP

Разделите сохранённый ответ на leaf.pem и intermediate.pem. Доверенный bundle храните отдельно в ca-bundle.pem. В учебной команде -CAfile задаёт доверенные корни, а -untrusted добавляет сертификаты для построения пути. Intermediate не становится доверенным только потому, что его прислал сервер.

openssl verify -purpose sslserver -CAfile ./ca-bundle.pem -untrusted ./intermediate.pem ./leaf.pem

Успех этой команды означает, что для этих файлов OpenSSL построил допустимую цепочку. Он не подтверждает hostname рабочего URL и не проверяет, что PHP загрузил именно этот файл. Ошибка «unable to get local issuer certificate» может означать неполный ответ сервера, отсутствующий корень или другой bundle. Одна строка ошибки не выбирает ветку сама.

Проверьте срок действия, назначение сертификата и имя в его SAN. Не переносите intermediate в список корней ради зелёного результата. Если сервер не отправляет нужный intermediate, исправление принадлежит владельцу HTTPS-сервера. Если частный корень нужен внутреннему сервису, его распространяет владелец инфраструктуры через управляемый пакет или секрет.

Проверьте тот же URL в PHP cURL

В PHP явно задайте путь к bundle только для учебного примера. В рабочей системе путь должен приходить из конфигурации или управляемого окружения. Проверка peer и hostname должна оставаться включённой.

<?php $ch = curl_init('https://api.example.test/v1/ping'); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CAINFO => '/opt/app/certs/ca-bundle.pem', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2]); $body = curl_exec($ch); if ($body === false) { throw new RuntimeException(curl_errno($ch) . ': ' . curl_error($ch)); } curl_close($ch);

Если CLI cURL проходит, а PHP нет, сравните curl_version(), TLS backend, путь CAfile, права чтения и пользователя процесса. Браузер мог использовать системное хранилище, а PHP — файл из сборки libcurl или значение curl.cainfo. Проверяйте фактический процесс, а не только интерактивную shell-сессию.

Симптом → причина → проверка → действие

СимптомПричинаПроверкаДействие
С SNI приходит ожидаемый leaf, но verify не строит путьНет intermediate или корня в bundleРазделить PEM и запустить verifyИсправить серверную цепочку или CA store
Leaf меняется при отключении SNIВыбран другой TLS virtual hostСравнить два запуска s_clientИсправить hostname, DNS или vhost
Цепочка проходит, PHP отклоняет имяHostname не покрыт SANСверить URL с SAN сертификатаИсправить URL или перевыпустить сертификат
CLI проходит, PHP отклоняетРазные bundle, backend или праваСравнить curl_version и путь файлаНастроить PHP-окружение
После -k запрос проходитПроверка отключена, причина не найденаПовторить с включёнными проверкамиНе использовать обход; вернуть доверенную цепочку

Порядок действий

  1. Зафиксируйте точный URL, hostname, порт, код PHP, пользователя процесса и версии PHP, libcurl и TLS backend.
  2. Запустите openssl s_client с hostname в -servername и сохраните присланные сертификаты.
  3. Сравните leaf с запуском без SNI, если есть подозрение на неправильный виртуальный хост.
  4. Разделите leaf и intermediate и запустите openssl verify с тем CA bundle, который вы проверяете.
  5. Сверьте hostname URL с SAN сертификата. Не заменяйте URL на IP и не пытайтесь лечить TLS заголовком HTTP Host.
  6. Повторите тот же URL в PHP при CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.
  7. Передайте исправление правильному владельцу: серверу нужен intermediate, окружению нужен управляемый CA bundle, а сертификату или URL нужно корректное имя.

Ограничения и отрицательный путь

Формат сообщения и детали TLS зависят от версии OpenSSL, libcurl, операционной системы и backend. Поэтому сравнивайте не только текст ошибки. Записывайте команду, имя, версии, путь bundle и сертификаты, которые действительно пришли.

Успешная проверка цепочки не означает, что сервис доступен по сети, отвечает на HTTP или разрешён политикой организации. Проверка отзыва, pinning, прокси и клиентских сертификатов добавляют отдельные условия. Эта статья не заменяет их настройку.

Не принимайте CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0 или curl -k как исправление. Такой тест может подтвердить, что отказ находится в проверке TLS, но он не подтверждает безопасность соединения. После эксперимента верните проверки и продолжите поиск причины.

Частный CA также нельзя добавлять в общий публичный bundle без границы доверия. Доступный веб-процесс должен читать файл, но пользователи и загружаемые файлы не должны менять его. Если сервер прислал неполную цепочку, добавление intermediate в корневой store маскирует ошибку поставки.

Проверяемый критерий готовности

Исправление готово, когда тот же PHP-код обращается к тому же hostname при включённых peer и hostname checks, а TLS-сеанс завершается без обходов. Дополнительно зафиксированы версия TLS backend, источник CA bundle и владелец серверной цепочки. Если запрос всё ещё падает, команда может показать, где именно расхождение: SNI, цепочка, hostname или локальное доверие. Это проверяемый результат, а не обещание production-эффекта.

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/339.json b/editorial/agent-rewrites/339.json new file mode 100644 index 0000000..996ce23 --- /dev/null +++ b/editorial/agent-rewrites/339.json @@ -0,0 +1,7 @@ +{ + "index": 339, + "slug": "editorial-2018-08-practice-tls-ca", + "title": "PHP cURL: как исправить ошибку проверки TLS без отключения защиты", + "excerpt": "PHP cURL возвращает ошибку сертификата, хотя адрес открывается в браузере. Разбираем CA bundle, цепочку, SNI и hostname, а затем проверяем исправление тем же запросом.", + "contentHtml": "

PHP cURL возвращает false, а в ошибке написано SSL certificate problem. В браузере тот же адрес открывается. Ошибка часто заканчивается быстрым обходом: разработчик выставляет CURLOPT_SSL_VERIFYPEER в false и получает ответ от API. Это не исправление. Клиент перестаёт проверять, кому он отправляет токен, персональные данные или платёжные параметры. Цена ошибки — не только сбой запроса, но и возможность незаметно установить TLS-соединение с чужим сервером.

\n

Правильный путь начинается с факта отказа. Нужно выяснить, не читается ли локальный CA bundle, не отсутствует ли промежуточный сертификат, не выбран ли другой виртуальный хост по SNI и подходит ли имя из URL сертификату. Эти причины дают похожие сообщения, но требуют разных владельцев и действий.

\n

Тезис: TLS-проверка состоит из независимых условий

\n

HTTPS-клиент проверяет не просто строку в адресе. Он получает сертификат сервера, строит цепочку до доверенного корня из локального хранилища и сверяет имя хоста с сертификатом. До этого сервер может выбрать сертификат по SNI — имени, которое клиент передаёт в начале TLS-диалога. Ошибка в любом звене останавливает запрос.

\n

CA bundle отвечает на вопрос «каким центрам сертификации доверяет этот процесс?». Серверная цепочка отвечает на вопрос «прислал ли сервер сертификаты, нужные для построения пути?». Hostname отвечает на вопрос «выдан ли сертификат именно этому имени?». SNI помогает серверу выбрать нужный виртуальный хост. Нельзя исправить одну проблему настройкой, предназначенной для другой.

\n

Сначала фиксирую отказ в PHP

\n

Диагностика должна выполняться тем же PHP-процессом и с тем же URL, который использует приложение. Браузер и команда curl в shell могут работать с другими версиями TLS-библиотеки, другими хранилищами и другим пользователем ОС. Это полезные контрольные точки, но не доказательство для PHP.

\n
<?php\n\n$url = 'https://api.partner.example/v1/ping';\n$caFile = '/opt/app/certs/ca-bundle.pem';\n\nif (!is_readable($caFile)) {\n    throw new RuntimeException('CA bundle is not readable: ' . $caFile);\n}\n\n$handle = curl_init($url);\n$verbose = fopen('php://temp', 'w+');\n\ncurl_setopt_array($handle, [\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_CAINFO => $caFile,\n    CURLOPT_SSL_VERIFYPEER => true,\n    CURLOPT_SSL_VERIFYHOST => 2,\n    CURLOPT_VERBOSE => true,\n    CURLOPT_STDERR => $verbose,\n    CURLOPT_CONNECTTIMEOUT => 5,\n    CURLOPT_TIMEOUT => 15,\n]);\n\n$body = curl_exec($handle);\n$errno = curl_errno($handle);\n$error = curl_error($handle);\nrewind($verbose);\n$trace = stream_get_contents($verbose);\n\ncurl_close($handle);\nfclose($verbose);\n\nif ($body === false) {\n    throw new RuntimeException('cURL error ' . $errno . ': ' . $error);\n}\n\necho $body;
\n

Вызовы curl_errno() и curl_error() стоят до curl_close(). Номер и текст нужно сохранять вместе с hostname, временем, версией PHP cURL и идентификатором запроса. Verbose-трасса подходит для закрытого стенда. В рабочий лог её можно писать только после удаления токенов, заголовков авторизации и тела запроса.

\n

Путь к CA bundle должен приходить из конфигурации окружения. Не принимайте его из HTTP-параметра. Файл должен быть доступен пользователю PHP-FPM или Apache и не должен находиться в каталоге, который раздаёт веб-сервер.

\n

Разделяю симптом, причину, проверку и действие

\n
СимптомПричинаПроверкаДействие
Ошибка о невозможности проверить peer, часто код 60Нет доверенного корня, неполная цепочка или неверное имяСнять сертификаты с SNI, проверить путь через тот же CA bundle и сверить hostnameИсправить bundle, серверную цепочку или адрес; оставить проверки включёнными
Ошибка чтения CA-файла, часто код 77Файл отсутствует, путь относительный или service-user не имеет доступаis_readable(), абсолютный путь и права каждого каталогаИсправить доставку файла и права процесса PHP
CLI проходит, PHP отказываетРазные libcurl, TLS-библиотеки, CA store или пользователь ОСcurl_version() в PHP, curl -V в shell, явный --cacertСравнить окружения и задать CA bundle именно PHP
Браузер проходит, PHP отказываетБраузер использует системное или собственное хранилищеПовторить запрос из PHP с явным CURLOPT_CAINFOНе считать браузер контрольным результатом; исправить PHP-окружение
Сертификаты меняются при запуске с разным именемРазные виртуальные хосты и SNIopenssl s_client с hostname из URL и без подмены на IPИсправить URL, DNS или конфигурацию TLS на сервере
\n

Сравниваю версии и хранилища

\n

Сначала смотрю, чем собран PHP-модуль. Это отделяет ошибку приложения от различий окружения.

\n
$version = curl_version();\n\nprintf('libcurl: %s\\n', $version['version']);\nprintf('TLS library: %s\\n', $version['ssl_version']);\nprintf('curl.cainfo: %s\\n', ini_get('curl.cainfo') ?: '(not set)');
\n

Затем выполняю учебную CLI-команду с тестовым адресом и явным файлом. Адрес api.partner.example не является производственным сервером. В реальной проверке его заменяют точным hostname из конфигурации приложения.

\n
curl -v \\\n  --cacert /opt/app/certs/ca-bundle.pem \\\n  https://api.partner.example/v1/ping
\n

Если CLI проходит, а PHP нет, сравниваю не только файл. Проверяю пользователя процесса, права на каталог, значение curl.cainfo, версию curl_version() и наличие прокси. Если оба клиента отказывают, перехожу к серверной цепочке и имени. Повторный запуск с отключённой проверкой не добавляет диагностического факта.

\n

Проверяю серверную цепочку и SNI

\n

Для виртуального хоста передаю серверу имя из URL. Команда ниже — учебный пример для закрытого стенда. Она показывает сертификаты, которые сервер прислал в ответ. Она не заменяет проверку цепочки.

\n
openssl s_client \\\n  -connect api.partner.example:443 \\\n  -servername api.partner.example \\\n  -showcerts \\\n  </dev/null
\n

Из вывода выписываю конечный сертификат и каждый intermediate. Корневой сертификат обычно хранится у клиента и не обязан приходить от сервера. Если сервер не прислал нужный intermediate, исправление принадлежит владельцу HTTPS-сервера. Не добавляю промежуточный сертификат в корневой trust store как постоянный обход: это смешивает две разные роли.

\n

Серверный список можно проверить отдельно. В примере leaf.pem содержит сертификат сайта, intermediate.pem — промежуточный сертификат, а ca-bundle.pem — доверенные корни. Имена файлов условны.

\n
openssl verify \\\n  -purpose sslserver \\\n  -CAfile ./ca-bundle.pem \\\n  -untrusted ./intermediate.pem \\\n  ./leaf.pem
\n

Успешная команда подтверждает построение цепочки для выбранного набора доверия. Она не подтверждает, что сертификат подходит hostname. Это условие проверяю отдельно тем же URL из PHP. IP-адрес не заменяет имя: сертификат должен содержать этот IP как IP-адрес, а не только DNS-имя.

\n

Исправляю только подтверждённую ветку

\n

Для одного вызова указываю абсолютный путь через CURLOPT_CAINFO. Для всего PHP-окружения можно задать абсолютный путь в curl.cainfo. После изменения конфигурации перезапускаю тот процесс PHP, который реально выполняет запрос. Изменение CLI-конфигурации не меняет настройки PHP-FPM автоматически.

\n
; php.ini. Пример, не универсальное имя каталога.\ncurl.cainfo='/opt/app/certs/ca-bundle.pem'
\n

Bundle беру из управляемого источника: пакета операционной системы, согласованного хранилища проекта или официального канала поставщика. Если API использует частный CA, добавляю доверенный корень по процедуре владельца сервиса. Не копирую leaf-сертификат из браузера и не собираю хранилище из случайных файлов. Leaf может быть перевыпущен, а доверие должно принадлежать контролируемому CA.

\n
Схема диагностики ошибки TLS в PHP cURL: ошибка, версия клиента, CA bundle, серверная цепочка с SNI, исправление и повторная проверка
Сначала фиксируем отказ и отделяем окружение от сервера. Только после этого меняем CA bundle или исправляем цепочку.
\n

Порядок действий

\n
  1. Повторить тот же запрос в PHP на закрытом стенде и сохранить код, текст ошибки, hostname, версии и безопасную трассу.
  2. Проверить абсолютный путь к CA bundle, чтение файла пользователем PHP и значение curl.cainfo.
  3. Сравнить PHP cURL с CLI через curl_version(), curl -V и явный --cacert.
  4. Получить серверный список сертификатов через openssl s_client с -servername из URL.
  5. Разделить PEM-блоки и проверить цепочку через openssl verify, не смешивая -CAfile и -untrusted.
  6. Проверить hostname в URL и SAN сертификата. Не подставлять IP как диагностический shortcut.
  7. Исправить только подтверждённую причину: путь или права, клиентский bundle, серверную цепочку, DNS или сертификат.
  8. Перезапустить нужный PHP-процесс и повторить исходный запрос при CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.
\n

Ограничения и отрицательный путь

\n

CA bundle не исправит неверную дату на машине, отозванный сертификат, несовместимую политику TLS или неправильный hostname. Если корень неизвестен и его нельзя добавить через управляемый процесс, запрос должен оставаться отклонённым. Это честный результат, а не повод включать CURLOPT_SSL_VERIFYPEER => false.

\n

Проверка сертификата не превращает API в надёжный сервис. После TLS остаются ошибки DNS, прокси, HTTP, авторизации и формата ответа. Здесь рассматривается только граница установления доверенного TLS-соединения. Учебные команды и адреса из статьи не доказывают доступность какого-либо production-сервиса.

\n

Проверяемый критерий готовности

\n

Исправление готово, если исходный PHP-код устанавливает соединение с тем же hostname, строит цепочку через согласованный CA bundle и проходит проверку имени при включённых peer и hostname checks. Путь к bundle доступен следующему разработчику, а проверка воспроизводится тем же пользователем и тем же PHP-процессом. Если запрос всё ещё падает, результатом должны быть конкретные факты: код и текст ошибки, версия клиента, серверный список с SNI, проверка цепочки и проверенное имя. Тогда следующий шаг адресует причину, а не скрывает её.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/340.json b/editorial/agent-rewrites/340.json new file mode 100644 index 0000000..0cb0e02 --- /dev/null +++ b/editorial/agent-rewrites/340.json @@ -0,0 +1,7 @@ +{ + "index": 340, + "slug": "editorial-2018-07-field-http-timeouts", + "title": "PHP cURL: как найти стадию HTTP-таймаута и не повторить операцию дважды", + "excerpt": "Ошибка cURL 28 не говорит, где остановился запрос. Разбираем временную шкалу PHP cURL, отличаем сбой соединения от позднего первого байта и выбираем безопасное действие для повторяемой и изменяющей операции.", + "contentHtml": "

Ночная синхронизация завершилась сообщением cURL error 28. Утром команда видит только «таймаут партнёра». Неизвестно, не ответил DNS, не установился TLS, партнёр не начал ответ или тело уже началось, но не успело передаться. Повтор запуска кажется очевидным. Для POST он может создать вторую заявку. Цена ошибки — не только пропущенная выгрузка. Система теряет знание о состоянии данных.

\n

Тезис простой: общий таймаут не объясняет причину. Нужна временная шкала одного вызова: NAMELOOKUP_TIME, CONNECT_TIME, APPCONNECT_TIME, STARTTRANSFER_TIME, TOTAL_TIME, код cURL и HTTP-код. Эти поля показывают, что клиент успел увидеть. Они не заменяют логи партнёра, но превращают «зависло» в проверяемую гипотезу.

\n

Сначала фиксируем симптом и границу таймаута

\n

Соберите данные до изменения конфигурации. Запишите безопасный идентификатор операции, метод, путь API, схему и host без секретных параметров, пороги connect и total, curl_errno, curl_error и HTTP-код. Сохраняйте trace и для успешного запроса. Без нормального пути сравнение с ошибкой превращается в догадку.

\n

HTTP-код 0 означает только одно: клиент не получил HTTP-статус. Это не доказательство медленного SQL у партнёра. Ненулевой код означает, что сервер успел прислать статус. Ответ 500 или 429 нужно разбирать по контракту API, а не называть транспортным таймаутом.

\n

Не кладите в общий журнал токен, пароль, полный URL с query-параметрами, тело запроса и полный ответ. Для расследования обычно хватает пути, идентификатора операции, кодов и чисел времени. Если нужен фрагмент тела, заранее определите поля и замаскируйте значения.

\n
\"Временная
Одна ошибка может остановить разные стадии запроса. Повтор зависит от состояния операции, а не от текста ошибки.
\n

Что измеряет PHP cURL

\n

Функция curl_exec возвращает управление после успеха или ошибки. До curl_close можно получить код ошибки и сведения о переносе через curl_getinfo. Поля времени накопительные. Нельзя складывать их как независимые интервалы. STARTTRANSFER_TIME отсчитывается от начала вызова и означает момент получения первого байта, а не момент разбора JSON приложением.

\n
<?php\n\nfunction traceCurl($curl, $operationId, array $limits) {\n    return array(\n        'operation_id' => $operationId,\n        'curl_errno' => curl_errno($curl),\n        'curl_error' => curl_error($curl),\n        'http_code' => curl_getinfo($curl, CURLINFO_HTTP_CODE),\n        'name_lookup' => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),\n        'connect' => curl_getinfo($curl, CURLINFO_CONNECT_TIME),\n        'app_connect' => curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),\n        'start_transfer' => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),\n        'total' => curl_getinfo($curl, CURLINFO_TOTAL_TIME),\n        'connect_limit' => $limits['connect'],\n        'total_limit' => $limits['total'],\n    );\n}\n\n$limits = array('connect' => 2, 'total' => 8);\n$curl = curl_init($partnerUrl);\ncurl_setopt_array($curl, array(\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_CONNECTTIMEOUT => $limits['connect'],\n    CURLOPT_TIMEOUT => $limits['total'],\n));\n\n$body = curl_exec($curl);\n$trace = traceCurl($curl, $operationId, $limits);\ncurl_close($curl);
\n

Код показывает формат учебного trace, а не готовую библиотеку логирования. В production отдельно проверьте версию PHP и libcurl, типы полей и маскирование. После curl_exec сохраните trace даже при ошибке. Иначе обработчик оставит только текст исключения и потеряет стадию сбоя.

\n

Разделяем задержки на тестовом стенде

\n

Проверяйте обработчик на локальном сервере. Не ждите, пока настоящий партнёр случайно замедлится. Учебный маршрут ниже создаёт две задержки. Первый маршрут задерживает первый байт. Второй отправляет начало тела и задерживает хвост. Это не модель интернета и не результат production-наблюдений. Цель примера — увидеть разницу между STARTTRANSFER_TIME и TOTAL_TIME на своём клиенте.

\n
<?php\n// router.php\n$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);\nheader('Content-Type: application/json');\n\nif ($path === '/slow-first-byte') {\n    usleep(4000000);\n    echo json_encode(array('ok' => true));\n    return;\n}\n\nif ($path === '/slow-body') {\n    echo '{\"items\":[';\n    flush();\n    usleep(4000000);\n    echo '1]}';\n    return;\n}\n\necho json_encode(array('ok' => true));
\n

Запустите учебный сервер командой php -S 127.0.0.1:8080 router.php. Вызов /slow-first-byte должен превысить общий предел, если он меньше четырёх секунд. У /slow-body первый байт может прийти быстро, а общий предел сработает позже. Поведение flush() зависит от SAPI и прокси. Поэтому проверяйте этот пример локально и не используйте его как доказательство поведения production-балансировщика.

\n

Читаем временную шкалу

\n
СимптомПричина, которую проверяемПроверкаДействие
HTTP 0, connect близок к пределуDNS, маршрут, TCP или TLS не завершилисьСравнить lookup, connect и app connect; проверить host, DNS, маршрут и сертификатИсправить доступность или передать партнёру точные времена; не увеличивать общий предел вслепую
connect мал, первый байт приходит поздноСоединение установлено, обработчик партнёра не начал ответСопоставить start transfer с connect и отправить ID операции партнёруПроверить очередь и обработчик; изменить предел только после согласования бюджета
Первый байт ранний, total близок к пределуТело медленное, большое или буферизуетсяСравнить размер ответа, скорость и завершение чтения на тестовом стендеУменьшить выборку, разделить выгрузку или настроить передачу по контракту
HTTP 500 или 429Сервер ответил статусомПрочитать код, заголовки и безопасное тело по контрактуПрименить правила API для ошибки, лимита и повтора
\n

Для HTTPS поле APPCONNECT_TIME помогает увидеть завершение TLS. На HTTP оно может быть нулевым. Ноль нельзя трактовать как «TLS занял ноль секунд», если запрос не использует TLS. Время после получения тела — разбор JSON, запись в базу и ответ вызывающему коду — в эту шкалу нужно добавить отдельными измерениями.

\n

Меняем одну границу за раз

\n

Если trace указывает на поздний первый байт, увеличенный timeout лишь дольше скрывает задержку партнёра. Если после уменьшения ответа TOTAL_TIME сократился, вы улучшили передачу, но не доказали, что ускорился обработчик. Если CONNECT_TIME близок к пределу, настройка размера JSON не исправит DNS или TLS.

\n

Сначала воспроизведите тот же сценарий. Затем измените один параметр: адрес, размер ответа, connect timeout или общий timeout. После этого сравните trace по той же стадии. Такой эксперимент отделяет причину от случайного удачного ответа. Не меняйте одновременно DNS, retry, размер ответа и лимиты: результат нельзя будет интерпретировать.

\n

Решаем, можно ли повторять операцию

\n

После таймаута POST /orders клиент не знает, успел ли партнёр создать заказ до обрыва ответа. Повтор может создать второй заказ. Метод POST не становится безопасным только потому, что cURL вернул ошибку. Безопасность повтора задаёт контракт конкретной операции.

\n

Для чтения ограниченный повтор обычно допустим, если API допускает его и общий бюджет не исчерпан. Для изменения состояния нужен постоянный ключ операции и документированная дедупликация у партнёра. Если ключ есть, после неизвестного результата сначала запросите статус по тому же ключу. Если ключа и проверки статуса нет, пометьте результат как неопределённый. Не запускайте второй create-вызов автоматически.

\n
<?php\n\nfunction actionAfterTimeout($method, $hasOperationKey, $canCheckStatus) {\n    if ($method === 'GET' || $method === 'HEAD') {\n        return 'one_limited_retry';\n    }\n\n    if ($hasOperationKey && $canCheckStatus) {\n        return 'check_operation_status';\n    }\n\n    return 'mark_result_unknown';\n}
\n

Это учебная развилка. Она не заменяет описание API, лимиты повторов, дедупликацию и требования к очереди. Для денежных операций, заказов и других необратимых действий решение должен подтверждать владелец контракта.

\n

Порядок проверки

\n
  1. Зафиксируйте метод, безопасный ID операции, путь, host, пороги, cURL error, HTTP-код и все накопительные времена.
  2. Определите последнюю достигнутую стадию: соединение, первый байт или завершение тела.
  3. Сверьте trace с успешным запросом того же маршрута и с логом партнёра, если он доступен.
  4. Воспроизведите задержку на локальном учебном сервере и убедитесь, что обработчик различает первый байт и тело.
  5. Проверьте одну внешнюю гипотезу: DNS/TLS, ожидание обработчика или размер и скорость ответа.
  6. Для изменяющей операции проверьте ключ и запрос статуса до любого повтора.
  7. Измените один предел или параметр контракта, повторите тот же тест и сравните trace.
\n

Ограничения и критерий готовности

\n

Клиентский trace не показывает внутреннюю очередь партнёра, его SQL и работу прокси. Повторно используемое соединение может сделать connect коротким, хотя обработчик всё ещё отвечает поздно. Вызов из очереди имеет собственный deadline и собственные повторы. Их нужно учитывать отдельно. Большой ответ может завершить HTTP-перенос, а затем упасть на разборе или записи в базу. Это уже другой участок цепочки.

\n

Разбор готов, когда для одного тестового сценария видны cURL-код, HTTP-код, пороги и временная шкала; для задержки до первого байта и задержки тела есть отдельная проверка; выбранный предел связан с конкретной стадией; а изменяющая операция не повторяется при неизвестном результате без ключа и проверки статуса. Формат trace и маскирование секретов должны пройти проверку владельца интеграции. Production-результат из учебного примера не следует.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/341.json b/editorial/agent-rewrites/341.json new file mode 100644 index 0000000..33bbe02 --- /dev/null +++ b/editorial/agent-rewrites/341.json @@ -0,0 +1,7 @@ +{ + "index": 341, + "slug": "editorial-2018-07-mechanism-http-timeouts", + "title": "PHP cURL: как понять, на какой стадии сработал таймаут", + "excerpt": "Ошибка cURL 28 сообщает о сработавшем ограничении, но не называет стадию запроса. Разбираем connect timeout, общий предел, медленный ответ и условия безопасного повтора.", + "contentHtml": "

Симптом знакомый: страница ждёт ответ партнёрского API, PHP-процесс занимает worker, а в журнале появляется только cURL error 28. Команда увеличивает таймаут с десяти до шестидесяти секунд. Ошибка не исчезает. Пользователь ждёт дольше, пул PHP быстрее заполняется, а причина всё ещё неизвестна. Если запрос создаёт заказ или платёж, автоматический повтор может добавить вторую операцию.

\n

Рабочее правило такое: таймаут нужно читать как границу стадии, а не как общее название сбоя. CURLOPT_CONNECTTIMEOUT ограничивает начальную фазу соединения. CURLOPT_TIMEOUT ограничивает весь перенос. Пара CURLOPT_LOW_SPEED_LIMIT и CURLOPT_LOW_SPEED_TIME останавливает уже начавшийся слишком медленный перенос. Код 28 может быть итогом любого из этих условий.

\n

Сначала отделяем стадии запроса

\n

libcurl начинает с разрешения имени. Затем он устанавливает TCP-соединение и для HTTPS выполняет TLS-рукопожатие. Только после этого приложение партнёра получает шанс обработать HTTP-запрос. Поэтому connect timeout может закончиться на DNS, маршруте или TLS. Это не доказывает медленный SQL и не доказывает, что партнёр увидел запрос.

\n

После соединения libcurl ждёт первый байт ответа. Большая задержка здесь обычно означает очередь, обработчик или другой промежуточный слой. Когда первый байт уже пришёл, начинается получение тела. Тело может идти медленно из-за размера ответа, прокси или канала. Эти случаи требуют разных проверок.

\n
Стадии HTTP-запроса libcurl: соединение, ожидание первого байта и получение тела
Один HTTP-вызов имеет несколько границ. Общий предел охватывает путь целиком, а специальные условия помогают остановить конкретную проблему.
\n

Что именно ограничивают опции cURL

\n

CURLOPT_CONNECTTIMEOUT задаёт предел начальной фазы соединения. Для HTTPS в неё входит TLS. Значение не прибавляется к общему пределу. Если общий timeout равен двум секундам, а connect timeout — четырём, вызов завершится не позднее двух секунд. Общий предел уже истёк, даже если соединение ещё не установилось.

\n

CURLOPT_TIMEOUT действует от начала до конца переноса. Он включает соединение, ожидание ответа и чтение тела. Это бюджет всего синхронного сценария. Его связывают с тем, сколько времени экран или вызывающий сервис вправе ждать внешний результат. Учебные значения из примера ниже не являются рекомендацией для production.

\n

У обычного вызова нет отдельной универсальной опции «ждать чтения ещё N секунд после первого байта». Пара low speed отвечает на другую задачу. Она завершает перенос, если средняя скорость опускается ниже выбранного порога в течение выбранного времени. Такой порог может остановить зависший поток, но может также прервать легитимно медленную выгрузку.

\n
<?php\n\n$curl = curl_init('https://partner.example/api/catalog');\ncurl_setopt_array($curl, array(\n    CURLOPT_RETURNTRANSFER => true,\n    CURLOPT_CONNECTTIMEOUT => 2,\n    CURLOPT_TIMEOUT => 8,\n    CURLOPT_LOW_SPEED_LIMIT => 100,\n    CURLOPT_LOW_SPEED_TIME => 3,\n));\n\n$body = curl_exec($curl);\n$errno = curl_errno($curl);\n$error = curl_error($curl);\n$info = curl_getinfo($curl);\ncurl_close($curl);\n\n// Учебный пример: числа нужно проверить на контракте конкретного API.\n// Код 28 сам по себе не называет стадию таймаута.
\n

Вызов выше задаёт общий бюджет восемь секунд и более короткую границу соединения. Если ответ партнёра начался, но почти застыл, low speed может остановить его раньше общего предела. Смысл настройки виден только вместе с измерениями и журналом. Одного числа в конфигурации недостаточно.

\n

Как читать временную шкалу

\n

После curl_exec вызов curl_getinfo возвращает накопленные отметки времени. CURLINFO_NAMELOOKUP_TIME показывает время от старта до завершения разрешения имени. CURLINFO_CONNECT_TIME — время до установления соединения. Для HTTPS CURLINFO_APPCONNECT_TIME может показать завершение TLS. CURLINFO_STARTTRANSFER_TIME — время до первого байта. CURLINFO_TOTAL_TIME — весь перенос.

\n

Это накопленные значения, а не длительности отдельных участков. Если соединение завершилось за 0,20 секунды, а первый байт пришёл за 6,80, ожидание после соединения заняло примерно 6,60 секунды. Вычитание помогает назвать стадию. Нулевой APPCONNECT_TIME для обычного HTTP не означает ошибку: TLS там нет.

\n
<?php\n\nfunction transferTimes($curl) {\n    return array(\n        'name_lookup' => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME),\n        'connect' => curl_getinfo($curl, CURLINFO_CONNECT_TIME),\n        'app_connect' => curl_getinfo($curl, CURLINFO_APPCONNECT_TIME),\n        'start_transfer' => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME),\n        'total' => curl_getinfo($curl, CURLINFO_TOTAL_TIME),\n    );\n}
\n

Учебный рисунок connect = 0.18, start_transfer = 7.91, total = 8.00 означает: соединение прошло быстро, первый байт не пришёл до общего предела. Рисунок start_transfer = 0.30, total = 8.00 означает другое: тело не завершилось в пределах бюджета. Это примеры формы диагностики, а не результаты конкретного production-сервиса.

\n

Симптом → причина → проверка → действие

\n
СимптомВероятная причинаПроверкаДействие
Код 28, HTTP-код 0, не завершился connectDNS, маршрут, TCP или TLSСопоставить NAMELOOKUP, CONNECT, APPCONNECT и проверить узел из той же сетиИсправить адрес или сетевой путь; не увеличивать общий timeout вслепую
Соединение быстрое, первый байт почти у общего пределаОчередь или обработчик партнёраСравнить CONNECT и STARTTRANSFER; проверить серверный request IDРазобрать SLA партнёра или перейти на асинхронную операцию
Первый байт пришёл быстро, тело идёт до лимитаБольшой ответ, прокси или низкая скоростьСравнить STARTTRANSFER и TOTAL, размер тела и LOW_SPEED-порогСократить ответ, получать его частями или изменить обоснованный бюджет
HTTP-код не ноль, но клиент сообщает ошибку контрактаСервер ответил, проблема не в транспортном таймаутеСохранить статус, заголовки без секретов и тело по правилам маскированияИсправить обработку HTTP-кода; не лечить его настройкой cURL
После 28 хочется повторить POSTСостояние операции неизвестноПроверить идемпотентность, ключ операции и endpoint статусаСначала запросить статус; повторять только по договору API
\n

Лог должен позволять пройти эту таблицу без догадок. Записывайте метод, путь без секретных параметров, корреляционный идентификатор, cURL error, HTTP-код, установленные пороги, размер полученного тела и временные отметки. Пароли, токены и полный чувствительный ответ в общий журнал не кладите.

\n

Почему error 28 не даёт готового диагноза

\n

CURLE_OPERATION_TIMEDOUT означает, что достигнуто условие таймаута. Код не различает DNS, ожидание первого байта и медленное тело. Строка curl_error может дать дополнительное описание, но она тоже не заменяет временную шкалу. Сохраняйте ошибку вместе с конфигурацией вызова. Иначе через неделю нельзя будет понять, какой предел сработал.

\n

HTTP-код равен нулю, если libcurl не получил HTTP-статус. Ненулевой код означает, что до приложения дошёл ответ с HTTP-статусом, даже если этот статус ошибочный. Это не абсолютная модель всех прокси и обрывов, поэтому данные нужно читать вместе с логами, но она сразу отделяет отсутствие ответа от ответа с ошибкой.

\n

Повторять можно не ошибку, а безопасную операцию

\n

Таймаут завершает наблюдение клиента. Он не доказывает, что сервер не выполнил запрос. Сервер мог сохранить заказ и потерять соединение перед отправкой ответа. Если клиент повторит создание, появится дубль.

\n

Безопасный автоматический повтор требует идемпотентной семантики. GET и PUT обычно относятся к идемпотентным методам по HTTP, но конкретный API может добавлять побочные эффекты. POST нельзя считать безопасным только по названию. Его можно повторять, если API документирует ключ операции, дедупликацию и одинаковый результат для повторных попыток. Ключ должен сохраняться между попытками.

\n

Если договор не даёт такой гарантии, после timeout сначала проверяют статус операции по внешнему идентификатору. Если endpoint статуса отсутствует, событие переводят в ручную или отложенную обработку. Увеличение таймаута не решает неизвестное состояние.

\n

Порядок проверки

\n
  1. Опишите бюджет сценария: сколько экран или вызывающий сервис может ждать внешний ответ.
  2. Задайте отдельный connect timeout, который меньше либо равен общему пределу.
  3. В тестовой среде воспроизведите отсутствие соединения, задержку до первого байта и медленное тело раздельно.
  4. Для каждого запуска сохраните cURL error, HTTP-код, пороги и NAMELOOKUP, CONNECT, APPCONNECT, STARTTRANSFER, TOTAL.
  5. Сопоставьте временную шкалу с одной стадией. Не меняйте DNS, общий timeout и retry одновременно.
  6. Перед повтором проверьте метод, ключ операции и endpoint статуса.
  7. Измените только подтверждённую границу и повторите тот же тестовый сценарий.
\n

Ограничения и критерий готовности

\n

Времена libcurl описывают путь клиента. Они не показывают внутреннюю очередь партнёра, его базу, работу CDN или прокси. Повторное соединение может сделать connect time маленьким. Параллельный вызов имеет отдельную очередь. Эти случаи требуют дополнительных метрик.

\n

Разбор готов, когда для каждого тестового таймаута команда может назвать стадию, показать соответствующие временные отметки и объяснить, почему выбранное действие не создаёт вторую операцию. Для production-настройки дополнительно нужны измеренный размер ответа, допустимый бюджет сценария, безопасный журнал и документированный контракт повтора. Пока хотя бы один из этих пунктов отсутствует, число timeout остаётся предположением.

\n

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/342.json b/editorial/agent-rewrites/342.json new file mode 100644 index 0000000..60a52a8 --- /dev/null +++ b/editorial/agent-rewrites/342.json @@ -0,0 +1,7 @@ +{ + "index": 342, + "slug": "editorial-2018-07-practice-http-timeouts", + "title": "PHP cURL: как ограничить время HTTP-запроса и не повторить операцию дважды", + "excerpt": "Таймаут HTTP-запроса состоит из нескольких границ. Разбираем connect timeout, общий бюджет, медленное тело ответа, диагностику и безопасное правило повтора в PHP cURL.", + "contentHtml": "

Симптом знакомый: страница ждёт цену от партнёрского API, PHP-процесс не освобождается, а пользователь видит пустой блок или ошибку шлюза. В журнале остаётся только «timeout». Команда увеличивает лимит с десяти до шестидесяти секунд, и проблема выглядит тише. На деле рабочий процесс занят в шесть раз дольше. При нагрузке это уменьшает пул доступных процессов и задерживает другие запросы. Если вызов меняет состояние партнёра, повтор после таймаута ещё и может создать второй заказ.

\n+

Тезис простой: HTTP-вызову нужен общий бюджет, короткая граница установления соединения и отдельное правило для слишком медленного тела. Эти настройки отвечают на разные вопросы. Код должен сохранить результат cURL, HTTP-код и временные отметки. Только после этого можно решить, где искать причину и разрешён ли повтор.

\n+

Что именно ограничивает таймаут

\n+

Рассмотрим обычный синхронный вызов PHP cURL. Пусть экран может ждать внешний ответ восемь секунд. Из них две секунды отдадим на DNS, TCP и TLS. Остаток оставим на ожидание первого байта и передачу тела. Это учебная модель. Её нельзя переносить в другой API без размера ответа, нагрузки и договора с партнёром.

\n+

CURLOPT_CONNECTTIMEOUT ограничивает начальную фазу соединения. Она включает разрешение имени и переговоры до установленного соединения. CURLOPT_TIMEOUT ограничивает весь перенос от начала до конца. Поэтому два и восемь секунд не складываются. Если общий предел меньше connect timeout, сработает общий предел. Пара CURLOPT_LOW_SPEED_LIMIT и CURLOPT_LOW_SPEED_TIME нужна для другого случая: ответ уже идёт, но средняя скорость остаётся ниже порога.

\n+
\"Шкала
Общий бюджет охватывает весь перенос. Connect timeout ограничивает начальную фазу, а low-speed проверяет слишком медленную передачу.
\n+

Минимальный клиент с измерениями

\n+

Настройки сами по себе не объясняют ошибку. После curl_exec нужно прочитать код ошибки, HTTP-статус и временные отметки до curl_close. HTTP-ответ 500, полученный за полсекунды, не является транспортным таймаутом. При сетевом сбое HTTP-код обычно равен нулю. Это разные ветки обработки.

\n+
<?php\n+function requestPartnerPrice($url, $requestId) { $curl = curl_init($url); curl_setopt_array($curl, array(CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => array('Accept: application/json', 'X-Request-Id: ' . $requestId), CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 8, CURLOPT_LOW_SPEED_LIMIT => 100, CURLOPT_LOW_SPEED_TIME => 3)); $body = curl_exec($curl); $result = array('body' => $body, 'curl_errno' => curl_errno($curl), 'curl_error' => curl_error($curl), 'http_code' => curl_getinfo($curl, CURLINFO_HTTP_CODE), 'name_lookup' => curl_getinfo($curl, CURLINFO_NAMELOOKUP_TIME), 'connect' => curl_getinfo($curl, CURLINFO_CONNECT_TIME), 'start_transfer' => curl_getinfo($curl, CURLINFO_STARTTRANSFER_TIME), 'total' => curl_getinfo($curl, CURLINFO_TOTAL_TIME)); curl_close($curl); return $result; }
\n+

В журнале достаточно безопасного идентификатора, метода, порогов, кода cURL, HTTP-кода и времён. Тело ответа и заголовки с токенами туда не попадают. start_transfer показывает момент первого байта, а total — длительность всего переноса. Эти значения относятся к cURL и не включают работу PHP до вызова и после разбора JSON.

\n+

Симптомы ведут к разным проверкам

\n+
СимптомПричинаПроверкаДействие
HTTP-код 0, connect близок к лимитуDNS, маршрут, TCP или TLS не завершилисьСравнить name lookup и connect, проверить адрес и сертификатИсправить сеть или узел; не увеличивать ожидание тела
Соединение быстрое, первый байт позднийПартнёр долго ставит запрос в очередь или обрабатывает егоСопоставить connect и start_transfer с его логомПроверить очередь, уменьшить запрос или согласовать SLA
Первый байт ранний, total упирается в пределТело велико, канал медленный или прокси буферизует ответСравнить размер тела, total и low-speed событияУменьшить ответ или проверить прокси
HTTP 500 или 429 пришёл быстроСервер ответил статусомПрочитать контракт статуса и тело ошибкиОбработать статус по API, не включать транспортный повтор
Таймаут после POSTЗапрос мог выполниться, а ответ потерялсяНайти операцию по ключу или запросить её статусНе отправлять POST снова без дедупликации
\n+

Почему error 28 не объясняет всё

\n+

Код cURL 28 означает, что достигнуто одно из условий таймаута. Он не сообщает одной строкой, на какой стадии остановился вызов. Если connect почти равен двум секундам, а HTTP-код нулевой, проблема находится до ответа партнёра. Если соединение установлено быстро, но start_transfer подходит к восьми секундам, ищут задержку обработки. Если первый байт пришёл рано, а total достиг общего предела, анализируют тело и канал.

\n+

Не стоит складывать накопительные времена. CURLINFO_NAMELOOKUP_TIME, CURLINFO_CONNECT_TIME и CURLINFO_STARTTRANSFER_TIME отсчитываются от начала переноса. Длительность отдельной фазы получают сравнением отметок. Для HTTPS CURLINFO_APPCONNECT_TIME помогает увидеть завершение TLS. На обычном HTTP оно может быть нулевым.

\n+

Повтор — это решение о семантике

\n+

Таймаут не доказывает, что сервер ничего не сделал. Запрос мог дойти до обработчика, операция могла завершиться, а ответ мог потеряться на обратном пути. GET или HEAD можно повторить ограниченно, если в бюджете осталось время и повтор не создаёт побочного эффекта. Один контролируемый повтор не означает бесконечную очередь попыток.

\n+

Для POST, который создаёт заказ, платёж или заявку, автоматический повтор без договора опасен. Нужен постоянный ключ операции, документированная дедупликация у партнёра или запрос статуса уже начатой операции. Если ни одного условия нет, результат помечают как неизвестный и передают на разбор. Лучше задержать одну операцию, чем создать две.

\n+
<?php\n+function canRetryRead($method, $transportFailure, $attempt, $secondsLeft) { $readOnly = in_array($method, array('GET', 'HEAD'), true); return $readOnly && $transportFailure && $attempt === 1 && $secondsLeft >= 2; }\n+// Для POST нужен отдельный ключ операции и проверка статуса.
\n+

Порядок настройки и проверки

\n+
  1. Назовите сценарий и внешний бюджет: сколько времени допустимо ждать экрану, очереди или фоновой задаче.
  2. Поставьте общий CURLOPT_TIMEOUT ниже лимита PHP и шлюза. Внутри него задайте короткий CURLOPT_CONNECTTIMEOUT.
  3. Сохраните безопасный ID, метод, cURL error, HTTP-код, пороги и временные отметки. Не записывайте секреты и полное тело.
  4. Проверьте на тестовом адресе недоступный хост, задержку до первого байта и медленную передачу тела. Учебный стенд не доказывает поведение production-прокси.
  5. Для GET и HEAD зафиксируйте число повторов и оставшееся время. Для POST сначала проверьте ключ операции или статус, а не отправляйте тот же запрос снова.
  6. Меняйте одну границу за раз. После изменения повторите тот же сценарий и сравните ту же временную отметку.
\n+

Ограничения примера

\n+

Значения 2, 8, 100 и 3 — учебные. На реальные числа влияют размер ответа, параллельность PHP-процессов, повторное использование соединения, прокси, DNS-кэш и договор с партнёром. Общий таймаут клиента не отменяет лимит балансировщика. Лимит шлюза может сработать раньше PHP. Клиентский trace не показывает внутреннюю очередь партнёра. Для этого нужны его логи и общий идентификатор операции.

\n+

Low-speed настройки не являются буквальным «таймаутом чтения N секунд». Они проверяют среднюю скорость ниже порога за период. Для маленького JSON и большого файла нужны разные пороги. Буферизация SAPI или прокси может изменить момент доставки первого байта, поэтому задержки проверяют на той же цепочке, где работает приложение.

\n+

Критерий готовности

\n+

Настройка готова, если для каждого тестового сценария журнал показывает одну понятную ветку: соединение, ожидание первого байта, передача тела или HTTP-статус. Общий предел не превышает лимит вызывающего слоя. Правило повтора записано отдельно для каждого метода. После искусственного таймаута GET не делает больше разрешённого числа попыток, а POST не повторяется, пока приложение не подтвердит ключ операции или статус. Это проверяемый результат, а не обещание, что внешний сервис больше никогда не задержится.

\n+

Проверяемые источники

" +} diff --git a/editorial/agent-rewrites/343.json b/editorial/agent-rewrites/343.json new file mode 100644 index 0000000..2fa86bd --- /dev/null +++ b/editorial/agent-rewrites/343.json @@ -0,0 +1 @@ +{"index":343,"slug":"editorial-2018-06-field-webpack-entry","title":"Webpack 4: почему новый entry раздувает bundle и как это доказать","excerpt":"После добавления entry сборка может вырасти по ожидаемой причине, из-за дублирования модулей или из-за ошибочного HTML. Разбираем stats.json, граф chunks и сетевой след страницы, затем выбираем точечную настройку.","contentHtml":"

Симптом появляется сразу после добавления второй точки входа: в dist возникает новый admin.[contenthash].js, а site.[contenthash].js тоже становится тяжелее. Иногда обычная страница ещё и запрашивает административный файл. Пользователь скачивает код, которым не воспользуется, а команда начинает менять splitChunks вслепую. Цена ошибки — лишний трафик в критическом пути, более долгий первый запуск и риск получить сломанный runtime.

Тезис простой: новый entry сам по себе не доказывает дублирование. Он добавляет новый старт в граф зависимостей. Дублирование возникает, когда один модуль достижим из нескольких начальных chunks и сборка не вынесла его в общий chunk. Отдельная причина — неправильный список script в HTML. Поэтому нужно проверить три слоя: emitted-ассеты, связи modules/chunks и реальные запросы страницы.

Что именно делает entry

Webpack начинает обход графа с каждой точки входа. Для site он проходит импорты страницы, для admin — импорты панели. Если обе ветки доходят до одного пакета, например react или общего модуля приложения, этот пакет входит в область обеих веток. В Webpack 4 он не обязан автоматически стать одним отдельным файлом для initial chunks.

Важно различать entry, chunk и asset. entry — старт обхода. chunk — внутренняя группа модулей, которую Webpack планирует загрузить вместе. asset — файл, записанный в выходной каталог. HTML может подключить несколько assets одного entrypoint, а один asset может быть частью отношения с несколькими chunks. Сравнение только размеров файлов скрывает эту связь.

Предположим, приложение обслуживает две HTML-страницы. Обычная страница должна загружать site, административная — admin. Учебная конфигурация ниже показывает модель. Она не утверждает, что такой порог или имя cache group подходят конкретному проекту.

module.exports = { mode: 'production', entry: { site: './src/site.js', admin: './src/admin.js' }, optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all' } } }, runtimeChunk: 'single' } };

В этом примере chunks: 'all' расширяет область работы оптимизатора, а runtimeChunk: 'single' выносит служебный runtime в общий файл. Ни одна из опций не исправляет неверный HTML. Если шаблон подключает admin на /, браузер скачает его независимо от того, насколько аккуратно собран граф.

Сначала фиксирую сравнение

Снимки нужно делать в одинаковых условиях. Режим, минификация, source map, версия webpack, версия webpack-cli и плагины меняют результат сильнее, чем небольшая правка entry. Возьмите один коммит и сохраните два файла: stats-before.json до добавления entry и stats-after.json после него. Запускайте локальный бинарник проекта, а не случайную глобальную версию.

./node_modules/.bin/webpack --mode production --profile --json > stats-after.json\n# Затем верните прежнее значение entry и повторите ту же команду для stats-before.json.

Снимок должен быть валидным JSON. Логи из конфигурации не должны попадать в stdout. Параметр --profile добавляет время сборки по модулям. Для ответа о размере он необязателен, но полезен, если новый entry одновременно замедлил компиляцию.

Схема диагностики Webpack: два entry ведут к chunks и assets, затем HTML и Network подтверждают фактическую загрузку
Stats описывает результат компиляции. HTML и Network показывают, какие файлы получает конкретная страница. Нужны оба наблюдения.

Читаю stats по слоям

Первый слой — список assets. Сравните имя, размер и принадлежность к chunks. Новый admin.[contenthash].js ожидаем: у новой страницы должен появиться собственный код. Вопрос начинается там, где старый initial asset вырос или в нём повторился крупный модуль.

Второй слой — modules. Найдите модули, у которых массив chunks содержит больше одного идентификатора. Это сильный сигнал повторной достижимости, но не окончательный вывод о сетевой загрузке. Модуль может находиться в async chunk, в runtime-связи или в структуре, которую браузер не запрашивает на данной странице.

Учебный скрипт ниже печатает кандидатов на повтор. Он рассчитан на форму stats, которую выдаёт совместимая версия Webpack 4. Формат stats меняется между версиями, поэтому перед применением проверьте поля своего файла.

const fs = require('fs'); const stats = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); const repeated = (stats.modules || []).filter((module) => Array.isArray(module.chunks) && module.chunks.length > 1).sort((a, b) => (b.size || 0) - (a.size || 0)); for (const module of repeated.slice(0, 30)) console.log((module.size || 0) + '\\t' + module.name + '\\t' + module.chunks.join(','));

Третий слой — entrypoints и chunks. Свяжите найденный модуль с конкретными стартами. Если общий пакет нужен обеим страницам, вынесение может уменьшить повтор в initial assets. Если модуль нужен только admin, переносить его в vendors нельзя: обычная страница начнёт загружать чужой код.

Симптомы и точечные действия

Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Появился новый admin-asset, а site почти не изменилсяДобавилась отдельная страница, а не дублированиеСравнить assets и список файлов обычного HTMLОставить entry; не менять splitChunks без повторного модуля
site вырос, крупный пакет есть в двух initial chunksОбе точки входа достигают общий модульСопоставить modules[].chunks с entrypointsПроверить cache group или явную зависимость; пересобрать
Обычная страница запрашивает adminШаблон или HTML-плагин подключает чужой entryПосмотреть script-теги и Network на /Исправить карту assets страницы до оптимизации chunks
Размер вырос только в developmentСравниваются разные режимы, source map или профилиПовторить два production-снимка одной командойСчитать выводом только сопоставимый результат
Модуль отмечен в нескольких chunks, но запросов больше не сталоСигнал относится к графу, а не к загрузке выбранной страницыПроверить entrypoint и Network с пустым кешемНе выносить модуль автоматически; оценить его реальную загрузку

Когда менять splitChunks

Настройка оправдана после двух доказательств. Во-первых, один и тот же достаточно крупный код действительно принадлежит двум нужным начальным путям. Во-вторых, каждая страница сможет получить общий chunk без лишнего запроса или ошибки runtime. Размер общего файла сам по себе не задаёт выгоду: один дополнительный запрос может оказаться дороже небольшого повторения.

Для пакетов из node_modules часто начинают с отдельной cache group. Для прикладного общего кода сначала проверьте его границу. Если модуль нужен только редкому действию внутри панели, динамический import() может быть лучше начального общего chunk. Если две страницы имеют разные сроки жизни кеша, единый файл может чаще инвалидироваться и ухудшить повторные визиты.

В старой конфигурации Webpack 4 нельзя механически копировать пример из свежей документации. Сверьте доступные опции и фактический формат stats. Например, современная схема dependOn и настройки runtime могут отличаться от проекта на Webpack 4. Версия сборщика — часть условия эксперимента, а не примечание в конце.

Порядок проверки

  1. Выписать HTML-документы и назначить каждому ровно те entry, которые ему нужны.
  2. Зафиксировать версию webpack, webpack-cli, режим, source map и одинаковый коммит.
  3. Сохранить stats-before.json и stats-after.json одной командой сборки.
  4. Сравнить assets: новые файлы, изменение размеров и связанные chunks.
  5. Найти крупные модули с несколькими chunk-идентификаторами и связать их с конкретными entrypoints.
  6. Открыть обычную и административную страницы с очищенным кешем; проверить script-теги и Network.
  7. Изменить одну cache group или один HTML-маршрут, затем создать stats-fixed.json.
  8. Повторить ту же проверку для обеих страниц и отдельно пройти отрицательный путь: обычная страница не должна загружать admin-код.

Ограничения и критерий готовности

Stats показывает компиляцию, а не реальную стоимость передачи. Он не учитывает в полном объёме gzip или Brotli, HTTP-кеш, CDN, приоритеты загрузки и время исполнения. Network показывает запросы конкретного браузерного сценария, но не доказывает поведение всех страниц и устройств. Для производительности нужен отдельный замер, а не вывод из суммы файлов в dist.

Метод также не решает проблему неправильного контракта HTML, нескольких runtime или несовместимого загрузчика. Если две script-последовательности инициализируют один модуль независимо, оптимизация размера может оставить ошибку выполнения. Проверяйте порядок тегов, runtime и консоль браузера после изменения.

Учебные команды и конфиг выше не являются production-рецептом. Подставьте реальные пути проекта, зафиксируйте версию и подберите пороги по измерению. Не объявляйте оптимизацию успешной только потому, что сборка завершилась с кодом 0.

Готовность проверяема: stats-fixed.json подтверждает ожидаемое распределение модулей; обычный HTML не содержит admin-script; Network обычной страницы не запрашивает административный asset; административная страница получает все нужные chunks; обе страницы проходят загрузку без ошибок runtime. Если хотя бы одно условие не выполнено, причина не доказана.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/344.json b/editorial/agent-rewrites/344.json new file mode 100644 index 0000000..a4f30fc --- /dev/null +++ b/editorial/agent-rewrites/344.json @@ -0,0 +1,7 @@ +{ + "index": 344, + "slug": "editorial-2018-06-mechanism-webpack-entry", + "title": "Webpack 4: почему общий модуль попадает в два entry bundle", + "excerpt": "После добавления второго entry общий модуль может оказаться в обоих стартовых bundle. Разбираем граф зависимостей, смысл массива entry, splitChunks и проверку результата через stats и HTML.", + "contentHtml": "

После добавления admin.js production-сборка начинает отдавать два больших стартовых файла. В обоих находится date-format.js. Иногда обычная страница ещё и загружает скрипт админки. Цена ошибки — лишние байты в критическом пути, второй runtime и код, который браузер скачивает, но не выполняет. Если исправить только имя файла или перенести модуль в другую папку, причина останется.

\n

Тезис: Webpack строит граф от каждого entry. Один и тот же модуль попадает в два начальных bundle, когда оба графа до него доходят. Место файла в репозитории не определяет границу bundle. Сначала нужно установить реальные точки запуска. Затем — решить, какие общие части выделить через optimization.splitChunks, а какие оставить в странице или загрузить позже.

\n

Что именно означает entry

\n

Entry задаёт начало обхода. Webpack читает entry, проходит его import и require, затем повторяет обход для найденных модулей и ассетов. Так появляется внутренний граф зависимостей. Output-файл — только один из результатов этого графа.

\n

Объект с ключами site и admin означает два самостоятельных старта. Обычно это верно для двух HTML-документов: сервер отдаёт каталог с одним сценарием и панель с другим. Если один HTML подключает оба entry, конфигурация описывает не две страницы, а два старта внутри одной страницы. Это отдельная ошибка.

\n
\"Два
Два entry проходят к своим модулям и оба достигают общих зависимостей. Граф объясняет дублирование лучше, чем список файлов в dist.
\n

Минимальный пример

\n
// src/site.js\nimport { formatDate } from './shared/date-format';\nimport { mountSearch } from './site/search';\n\nmountSearch(formatDate);\n\n// src/admin.js\nimport { formatDate } from './shared/date-format';\nimport { mountReport } from './admin/report';\n\nmountReport(formatDate);\n\n// src/shared/date-format.js\nexport function formatDate(date) {\n  return date.getFullYear() + '-'\n    + String(date.getMonth() + 1).padStart(2, '0');\n}
\n

Здесь site/search нужен только сайту, а admin/report — только панели. shared/date-format достижим из обоих стартов. Webpack видит две цепочки, а не один «общий файл». До правила разделения общий модуль может попасть в каждый initial chunk.

\n

Пример учебный. Он показывает направление рёбер и не сообщает размер bundle, время сборки или результат конкретного production-проекта. Размеры нужно измерять в своей версии Webpack и в одинаковом режиме.

\n

Массив entry не создаёт вторую страницу

\n

У массива другой контракт. Запись entry: ['./src/polyfills.js', './src/site.js'] создаёт один multi-main entry. Webpack загружает файлы в указанном порядке и включает их зависимости в один стартовый граф. Это подходит для полифиллов или подготовительного кода, который всегда нужен сайту.

\n
module.exports = {\n  // Один entry и один стартовый граф.\n  entry: ['./src/polyfills.js', './src/site.js'],\n\n  // Два entry и два независимых старта.\n  // Это имеет смысл при двух HTML-документах.\n  // entry: {\n  //   site: './src/site.js',\n  //   admin: './src/admin.js',\n  // },\n};
\n

Третий вариант vendor: ['jquery'] не делает библиотеку страницей. В Webpack 4 отдельный vendor entry — наследие старой схемы с CommonsChunkPlugin. Для общего кода используйте splitChunks, если это соответствует размеру и загрузке проекта. Не создавайте фиктивную точку запуска только для того, чтобы получить имя файла.

\n

Как разделить общий участок графа

\n

В Webpack 4 разделение задаёт optimization.splitChunks. Правило может искать модули, которые используются в нескольких chunks, и собирать их в отдельный chunk. Оно не обязано выносить каждый общий импорт: на решение влияют тип chunk, минимальный размер, лимиты запросов и cache group.

\n
module.exports = {\n  entry: {\n    site: './src/site.js',\n    admin: './src/admin.js',\n  },\n  optimization: {\n    splitChunks: {\n      chunks: 'all',\n      cacheGroups: {\n        common: {\n          name: 'common',\n          minChunks: 2,\n          minSize: 0,\n          chunks: 'all',\n        },\n      },\n    },\n  },\n};
\n

minSize: 0 стоит в примере только для видимости механизма. В рабочем проекте нулевой порог может создать слишком много маленьких запросов. Сначала найдите повторяющийся модуль и его размер. Потом выберите порог, который оправдан кешированием и числом запросов. Не принимайте появление файла common.js за доказательство ускорения.

\n

Runtime не равен общему модулю

\n

После разделения в каждом entry всё ещё может быть служебный код Webpack. Runtime хранит сведения о модулях и загрузке chunks. Это не то же самое, что date-format.js или библиотека из node_modules. Для нескольких страниц можно отдельно рассмотреть runtimeChunk: 'single', но это решение меняет служебный слой, а не прикладную зависимость.

\n

Если одна HTML-страница включает два runtime, импортированные модули могут инициализироваться в разных контекстах. Два script-тега не являются нейтральным способом «подключить ещё один модуль». Для одной страницы оставьте один настоящий старт, а дополнительное поведение импортируйте из него. Если код не нужен при первом открытии, рассмотрите динамический import().

\n

Симптом → причина → проверка → действие

\n
Диагностика дублирования и лишней загрузки
СимптомПричинаПроверкаДействие
Один модуль виден в двух initial chunksОба entry достигают его, общего правила нет или оно не сработалоПосмотреть modules, chunks и entrypoints в statsПроверить splitChunks, размер и cache group
В dist появился admin.jsДобавился отдельный entry, а не обязательно лишняя загрузкаСверить script-теги HTML страницы сайтаУбрать чужой entry из шаблона
Массив entry приняли за две страницыMulti-main entry ошибочно смешали с object syntaxПроверить число HTML-документов и ключей entryОставить массив для одного старта или разделить страницы объектом
После splitChunks выросло число файловПорог слишком низкий или группа дробит мелкие модулиСравнить размер chunks, количество запросов и кешированиеПоднять порог или сузить cache group
Bundle большой, но страница его не запрашиваетАссет существует в сборке, но не входит в этот entrypointОткрыть Network и исходный HTML конкретного URLНе оптимизировать неиспользуемый страницей ассет
\n

Проверка на учебной сборке

\n

Ниже — пример проверки, а не production-результат. Снимки нужно получить одной версией локального webpack-cli, в одном режиме и на одном наборе исходников. Сравнение development и production скрывает причину за минификацией, source map и разными плагинами.

\n
./node_modules/.bin/webpack --mode production --profile --json > stats-after.json\n\nnode -e "const s=require('./stats-after.json');\nfor (const m of s.modules || []) {\n  if ((m.chunks || []).length > 1) {\n    console.log(m.size, m.name, m.chunks.join(','));\n  }\n}"
\n

Список модулей в нескольких chunks — повод для проверки, а не готовый диагноз. Один модуль может легитимно участвовать в начальном и асинхронном пути. Сопоставьте его с entrypoint. Затем откройте HTML и Network для каждой страницы. В Network видны реальные запросы, а stats описывает компиляцию.

\n

Порядок действий

\n
  1. Запишите все HTML-документы и по одному ожидаемому сценарию для каждого.
  2. Для каждого документа найдите фактические script-теги и назовите единственный настоящий старт.
  3. Сверьте это с конфигурацией entry. Убедитесь, что массив означает подготовку одного старта, а объект — независимые страницы.
  4. Снимите production stats до изменения и после него одной командой сборки.
  5. Найдите модуль, который повторяется в initial chunks, и проверьте, действительно ли он нужен обеим страницам.
  6. Добавьте минимальное правило splitChunks. Не создавайте vendor entry для библиотеки.
  7. Повторите сборку и проверьте состав entrypoints, размер chunks и число запросов.
  8. Откройте каждый HTML в браузере. Проверьте Network, порядок script-тегов и отсутствие чужого entry.
  9. Если код нужен только после действия пользователя, сравните общий initial chunk с динамическим import().
\n

Отрицательный путь и ограничения

\n

Не каждый общий модуль нужно выносить. Маленький модуль может добавить отдельный запрос и не дать выигрыша. Большая библиотека, нужная только модальному окну, не должна попадать в стартовый общий chunk. Для неё лучше проверить отложенную загрузку.

\n

Эта статья описывает модель Webpack 4. Современная документация содержит дополнительные поля entry, например dependOn и runtime. Их нельзя механически переносить в конфигурацию Webpack 4. Сначала определите версию сборщика и сверяйтесь с документацией этой версии.

\n

Stats показывает компиляцию, но не доказывает скорость сети. Размер asset может отличаться от переданных байтов после сжатия и кеша. Network показывает один URL и один момент. Для вывода о performance нужны одинаковые условия измерения и отдельный критерий.

\n

Критерий готовности

\n

Сборка готова, когда другой инженер без устного пояснения может назвать HTML-документ, его entry, общий chunk и причины его появления. В stats видны ожидаемые entrypoints. В HTML страницы нет чужого entry. В Network запрашиваются только runtime, общие chunks и код этой страницы. Для каждого вынесенного модуля есть объяснение размера и причины загрузки.

\n

Если один из этих ответов неизвестен, работу нельзя считать законченной. Сначала восстановите связь «HTML → entry → graph → chunk → запрос». Только после этого меняйте пороги, runtime или структуру импортов.

\n

Проверяемые источники

\n