function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } const p = (text) => '
' + text + '
'; const h2 = (text) => '' + escapeHtml(text) + '';
const ol = (items) => '| ' + item + ' | ').join('') + '
|---|
| ' + item + ' | ').join('') + '
messageCode исчез из одного ответвления. Цена ошибки — рассинхрон между UI и API: пользователь видит устаревшее состояние, а команда ищет проблему в сети, хотя нарушение уже произошло на границе данных.'),
p('Граница должна отвечать на два разных вопроса. Первый: завершился ли обмен по HTTP? Второй: можно ли безопасно использовать представление для конкретного экрана? Нельзя сводить их к одному boolean ok. В этой статье разберём минимальную screen model, проверим её на чистой функции и разложим действия при статусах 2xx. Подход одинаково применим в браузерном клиенте, BFF и интеграционном тесте.'),
h2('HTTP-успех и успех контракта'),
p('RFC 9110 описывает значение статус-кода как часть HTTP-сообщения. Код 200 сообщает, что запрос обработан успешно на уровне протокола и приложения, но конкретное содержимое ответа всё равно определяется контрактом ресурса. Клиент не получает права угадать форму по имени endpoint или по тому, что предыдущая версия возвращала похожий JSON.'),
p('Поэтому полезно держать в коде последовательность: сначала принять статус, затем проверить media type, потом разобрать JSON, после этого валидировать поля и только в конце передать результат в view-model. Порядок кажется длиннее прямого await response.json(), но он локализует ошибку. Если payload не соответствует договорённости, компонент не обязан угадывать fallback и не превращает частично прочитанные данные в доменный факт.'),
figure('/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg', 'Поток из пяти шагов: HTTP status, Content-Type, JSON parse, проверка screen model и рендер. Красная ветка останавливает обработку при ошибке контракта.', 'Валидатор стоит между транспортом и компонентом. Он не решает, как выглядит интерфейс; он не даёт невалидному payload пройти дальше.'),
table('Минимальная screen model для одного состояния заказа', ['Поле', 'Тип', 'Зачем UI', 'Что делать при ошибке'], [
['status', 'ready | pending | blocked', 'выбрать состояние экрана', 'не выбирать состояние по умолчанию молча'],
['allowedActions', 'массив известных команд', 'показать доступные действия', 'скрыть команды и записать нарушение'],
['messageCode', 'непустая строка', 'выбрать локализованное сообщение', 'показать безопасный общий текст'],
['Content-Type', 'application/json', 'понять формат представления', 'не вызывать JSON parser вслепую'],
['HTTP status', 'целое 100–599', 'разделить transport и application result', 'зафиксировать код до чтения тела'],
]),
h2('Контракт должен быть маленьким, но решаемым'),
p('Полная DTO предметной области редко нужна экрану. Если компонент читает двадцать полей, это не означает, что screen model должна копировать все двадцать. Выберите минимальный набор, который позволяет принять решение: состояние, разрешённые команды и ключ сообщения. Любое поле должно иметь владельца и правило изменения. Это снижает связность: backend может менять внутреннее представление, не заставляя UI разбирать чужой aggregate.'),
p('При этом «минимальный» не значит «неформальный». Для каждого значения задайте допустимый словарь или тип. status: "ok" удобен только до появления второго смысла слова ok. Перечень ready | pending | blocked делает расширение видимым: новый статус потребует решения о рендеринге, тесте и обратной совместимости. Массив действий также должен быть закрытым или версионируемым; неизвестная команда не должна появляться как активная кнопка.'),
h2('Проверка до рендера'),
code("import { checkScreenResponse } from './upgrade-2026-09.mjs';\n\nconst response = {\n status: 'ready',\n allowedActions: ['edit'],\n messageCode: 'order.ready',\n};\n\nconsole.log(checkScreenResponse(response));\n// { valid: true, errors: [], source: 'response-boundary-validator' }\n\nconsole.log(checkScreenResponse({ status: 'done', allowedActions: 'edit' }));\n// { valid: false, errors: ['status-must-be-known', ...], source: 'response-boundary-validator' }"),
p('Функция принимает копию JSON-совместимого значения и возвращает причины, а не исключение с произвольным текстом. Это не замена JSON Schema или типам на этапе сборки: runtime-проверка нужна потому, что HTTP приносит данные из-за границы процесса. В реальном клиенте результат следует связать с error boundary, telemetry без payload и понятным сообщением для пользователя. Самое важное — не передавать невалидное значение в компонент, который считает его достоверным.'),
p('Не стоит делать validator чрезмерно умным. Он не должен сверять бизнес-правила, запрашивать второй endpoint или самостоятельно исправлять поле. Если status пришёл как "ready ", trim может скрыть нарушение контракта. Исправление допустимо на границе, только если это явно часть формата: например, нормализация регистра для заголовка. Чем больше молчаливых преобразований, тем труднее понять, что на самом деле отправил сервер.'),
h2('204, JSON и пустое тело'),
p('Классическая ошибка — общий helper, который всегда вызывает response.json(). Для 204 это некорректное ожидание: успешный ответ не обязан иметь representation. Команда «удалить» может завершиться 204 и потребовать повторного чтения списка; команда «получить экран» обычно возвращает представление. Эти два случая нельзя различать по URL или по тому, что parser иногда падает. Правило должно быть частью операции.'),
p('Заголовок Content-Type тоже не подтверждает, что JSON валиден. Он сообщает заявленный формат, а не соответствие вашему screen contract. Поэтому проверка media type — ранняя ветка, а runtime validation — следующая. Ошибку формата следует отделять от сетевой ошибки: повтор запроса не исправит payload, который сервер стабильно формирует неправильно.'),
h2('Рантайм-проверка и OpenAPI'),
p('OpenAPI удобна как единый источник описания HTTP-интерфейса: она помогает связать ответ операции с компонентной схемой и генерировать типы. Но сгенерированный TypeScript-интерфейс не проверяет JSON во время выполнения. Если сервисы релизятся независимо, нужен контрактный тест или runtime validator на границе. Иначе компилятор подтвердит только то, что разработчик написал в исходниках.'),
p('JSON Schema может описать типы, обязательность и перечисления, а валидатор — применить эту схему к фактическому payload. В небольшом клиенте допустима ручная функция, как в примере, если у неё есть тесты на неизвестный статус, неправильный массив и пустой message code. Выбор библиотеки — вторичен. Сначала зафиксируйте, какую ошибку должен увидеть пользователь и какие данные нельзя пропускать в view.'),
h2('Как внедрить границу без большого переписывания'),
ol([
'Найдите один endpoint, где UI уже угадывает поля или использует fallback после исключения parser.',
'Выпишите screen model из фактических решений компонента: состояние, доступные действия и сообщение.',
'Добавьте проверки статуса и Content-Type до разбора тела; отдельно обработайте 204.',
'Добавьте runtime validation для обязательных полей и неизвестных enum-значений.',
'Напишите четыре теста: валидный ответ, неизвестный статус, неверный тип actions и успешный ответ без JSON.',
'Покажите пользователю безопасное состояние ошибки, а в технический канал передайте только код нарушения и request id.',
]),
h2('Ограничения и следующий шаг'),
p('Валидатор не доказывает, что значение истинно в домене, и не заменяет authorization. Он проверяет форму ответа и право клиента использовать эту форму. Он также не решает миграцию старого поля: для этого нужны версия схемы, совместимый период и тесты обеих сторон. Если один endpoint обслуживает несколько экранов, лучше назвать две read model, чем снова передать в UI универсальный объект.'),
p('Следующий практический шаг — добавить в контракт тест с неизвестным статусом и проверить, что компонент не показывает «готово» по умолчанию. После этого полезно сравнить generated types с runtime schema и зафиксировать расхождение в CI. Важный результат — не ещё один helper, а видимая граница, после которой UI работает только с проверенной моделью.'),
h2('Проверяемые источники'), sourceList([
{ key: 'http', use: 'Использован для различения семантики статус-кода и содержимого representation.', boundary: 'Не описывает screen model конкретного продукта и не заменяет runtime validation.' },
{ key: 'openapi', use: 'Использован как формат описания HTTP-операции и схемы ответа.', boundary: 'Сгенерированный тип не является проверкой фактического JSON.' },
{ key: 'problem', use: 'Использован для идеи структурировать ошибки HTTP отдельным типом.', boundary: 'Не назначает локальные коды UI и не выбирает retry policy.' },
]),
].join(''),
};
const mechanism = {
readingMinutes: 13,
tags: ['frontend', 'backend', 'http', 'errors'],
slug: 'editorial-2026-09-mechanism-frontend-backend-boundary',
title: 'Состояние экрана не угадывают по статусу: разделяем query, command и ошибку',
excerpt: 'Практическая схема для UI и BFF: как не превращать 409, 422, 429 и 500 в один красный toast и не терять следующий шаг.',
contentHtml: [
p('Проблема начинается, когда UI трактует любой ответ не-200 как «сервер упал»: пользователь получает бесполезный toast, а команда теряет причину отказа. Обратная крайность не лучше: компонент считает каждый 2xx подтверждением операции и показывает новое состояние до чтения модели. Цена такой путаницы — повторные команды, неверные подсказки и разбор инцидента по снимкам интерфейса вместо точного HTTP-контракта.'),
p('Надёжная граница разделяет три намерения: query читает representation, command просит изменить состояние, а error envelope объясняет, почему переход не состоялся или что делать дальше. Статус HTTP — важная часть решения, но он не должен единолично выбирать текст, кнопку повторить и новый screen state. Ниже — таблица семантик и чистая функция, которую можно покрыть тестами без браузера.'),
h2('Один ответ — несколько уровней смысла'),
p('Запрос к экрану и команда изменения могут использовать один транспорт, но у них разные последствия. Query обычно превращает валидное представление в UI. Command сначала подтверждает, что запрос принят на уровне операции, а затем либо возвращает новую модель, либо требует повторного чтения. Если эти пути слить в один handler, команда легко станет локальным флагом «успешно», хотя сервер вернул только принятие запроса.'),
figure('/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg', 'Матрица связывает HTTP-результат с правом UI: 2xx даёт право читать representation, 409 и 422 — показать исправляемую причину, 429 и 5xx — применить отдельную политику повторов, а отправка команды сама по себе не меняет screen state.', 'У каждого результата есть следующий шаг и запрет. Это меньше похоже на универсальный обработчик, зато не скрывает смысл ответа.'),
table('Решение по HTTP-результату', ['Результат', 'Что можно заключить', 'Следующее действие UI', 'Чего нельзя делать'], [
['200 + valid JSON', 'representation соответствует схеме', 'обновить view из модели', 'добавлять локальные доменные поля'],
['204', 'операция завершилась без representation', 'инвалидировать или перечитать ресурс', 'вызывать JSON parser'],
['409', 'текущее состояние конфликтует с командой', 'показать причину и предложить перечитать', 'повторять без изменения входа'],
['422', 'вход не прошёл прикладную проверку', 'подсветить исправляемые поля', 'называть это сетевым сбоем'],
['429 / 5xx', 'возможен временный отказ', 'использовать явную retry policy', 'делать бесконечный retry'],
]),
h2('Conflict, validation и server failure'),
p('409 Conflict — не синоним 500. Он сообщает, что запрос нельзя завершить в текущем состоянии ресурса; UI часто может перечитать модель, показать конфликт или попросить пользователя выбрать действие. 422 удобно использовать для валидного по синтаксису, но неприемлемого по правилам входа. Смысл ответа зависит от API, поэтому клиент должен читать структурированный problem detail, а не выводить причину из одной цифры.'),
p('429 говорит о временном ограничении частоты, а 5xx — о проблеме на стороне сервера или его зависимости. Оба случая могут быть повторяемыми, но не одинаково: Retry-After, идемпотентность команды, бюджет попыток и состояние формы должны быть явными. Автоматический retry POST без idempotency boundary способен создать две операции. «Повторить запрос» — это политика, а не универсальная реакция на красный статус.'),
h2('Problem Details как envelope'),
p('RFC 9457 предлагает формат Problem Details с полями вроде type, title, status, detail и instance. Он не диктует, какой текст показывать и можно ли повторять команду. Приложение может добавить ограниченный code и список безопасных действий, если это описано его контрактом. Важно отделить машинный тип от свободного detail: последний может быть непригоден для локализации или содержать внутреннюю информацию.'),
p('UI должен преобразовать envelope в свою модель сообщения один раз. Например, order.version-conflict превращается в «Обновить данные» и кнопку перечитать, а order.invalid-address — в подсветку поля. Компоненты не должны сравнивать строки title и detail. Это делает поведение устойчивым к переводу, изменению формулировки и разделению API между несколькими клиентами.'),
h2('Воспроизводимая классификация'),
code("import { classifyHttpResponse } from './upgrade-2026-09.mjs';\n\nconsole.log(classifyHttpResponse({\n status: 409,\n contentType: 'application/problem+json',\n body: { type: 'https://example.test/problems/version-conflict' },\n}));\n// { kind: 'domain-rejection', next: 'read-problem-type-and-show-recoverable-action' }\n\nconsole.log(classifyHttpResponse({\n status: 200, contentType: 'application/json',\n body: { status: 'ready', allowedActions: ['edit'], messageCode: 'order.ready' },\n}));\n// { kind: 'screen-model-ready', next: 'render-from-contract' }"),
p('Функция классифицирует только названные свойства наблюдения. Она не объявляет операцию успешной по наличию поля detail и не делает retry автоматически. Для приложения это удобная точка тестирования: одна таблица входов проверяет, что 409 не попал в ветку network failure, а 200 с неподходящей model не прошёл в render.'),
p('В реальном клиенте стоит добавить request id к технической записи и убрать из неё тело problem detail, если оно может содержать пользовательский ввод. Для пользователя нужны локализованный message code и один следующий шаг. Такой минимум лучше длинного сообщения, которое перечисляет внутренний stack trace и оставляет человека без решения.'),
h2('Query и command не обязаны возвращать одно и то же'),
p('После команды возможны три формы: новая screen model в ответе, 202 с идентификатором отслеживания или 204 без представления. Нельзя написать общий handler «после POST обновить store» и считать задачу решённой. Для 202 нужна отдельная модель состояния операции и правило polling или push. Для 204 нужно определить, какой ресурс инвалидировать и когда перечитать его. Контракт должен назвать форму, иначе каждый клиент изобретёт свою.'),
p('Идемпотентность относится к смыслу команды, а не к тому, как выглядит кнопка. Повтор с тем же ключом может вернуть тот же результат или безопасно сообщить о предыдущем выполнении; без такой договорённости retry после timeout не даёт клиенту права считать, что команда не дошла. Timeout — это неизвестный исход, а не отрицательный исход. UI должен показать это различие и дать безопасный путь синхронизации.'),
h2('Шесть тестов, которые окупаются'),
ol([
'Валидный 200 с полной screen model: компонент получает ровно разрешённые действия.',
'200 с неизвестным status: render не вызывается, ошибка формы отделена от transport.',
'204: parser не запускается, ресурс помечается для повторного чтения.',
'409 с problem type: пользователь получает действие перечитать, а не автоматический бесконечный retry.',
'422 с кодом поля: ошибка привязывается к input, а не к общему toast.',
'429 и 500: политика повторов ограничена бюджетом и учитывает идемпотентность команды.',
]),
h2('Ограничения и следующий шаг'),
p('Классификатор не проектирует бизнес-статусы и не заменяет спецификацию API. Один и тот же HTTP-код может иметь разный прикладной смысл для разных операций; поэтому таблицу нужно привязать к конкретному контракту. Problem Details тоже не решает authorization, приватность и наблюдаемость. Он даёт форму envelope, а не готовую политику продукта.'),
p('Следующий шаг — выбрать один command с потенциальным повтором, добавить в контракт ответ при неизвестном исходе и написать тест на timeout. Если после этого UI всё ещё меняет state до query, граница не закончена. Нужен не новый флаг, а явный переход: команда отправлена, результат неизвестен, read model перечитана или получен problem detail.'),
h2('Проверяемые источники'), sourceList([
{ key: 'http', use: 'Использован для семантики классов HTTP-ответов и различия transport/application meaning.', boundary: 'Не назначает конкретные статусы для бизнес-операции.' },
{ key: 'problem', use: 'Использован для структуры Problem Details и разделения machine type и human detail.', boundary: 'Не определяет локализацию, retry или доступность действия.' },
{ key: 'openapi', use: 'Использован для идеи явно описывать response variants у операции.', boundary: 'Не гарантирует, что реализация и фактический payload совпадают.' },
]),
].join(''),
};
const field = {
readingMinutes: 14,
tags: ['frontend', 'backend', 'debugging', 'http'],
slug: 'editorial-2026-09-field-frontend-backend-boundary',
title: 'UI и API расходятся: полевой протокол диагностики без догадок',
excerpt: 'Пошаговый разбор рассинхрона: какие четыре факта снять на границе, как отличить stale state от неверного ответа и где остановить расследование.',
contentHtml: [
p('Симптом «кнопка показывает одно, API — другое» редко объясняется одной строкой. В браузере мог остаться старый store, прокси мог вернуть не тот Content-Type, команда могла завершиться 409, а компонент — считать любой ответ подтверждением. Цена бессистемной диагностики — повторные ручные проверки и исправление не той стороны: команда меняет сервер, когда проблема в render input, или переписывает UI, когда контракт уже нарушен.'),
p('Нужен короткий протокол, который фиксирует наблюдаемые факты в правильном порядке: пользовательское намерение, запрос, ответ и фактический вход в рендер. Нельзя начинать с гипотезы «кэш виноват» или «backend сломался». В статье соберём безопасную карточку расследования, дадим чистую функцию для сравнения данных и покажем, какие выводы разрешены на каждом шаге.'),
h2('Четыре снимка одной операции'),
p('Первый снимок — намерение: какой action выбрал пользователь и с какими нормализованными параметрами. Не нужно сохранять весь ввод; достаточно request id, operation name и безопасного класса входа. Второй — исходящий HTTP: method, route template, статус и заголовок correlation/request id без query-параметров и секретов. Третий — ответ: status, Content-Type, размер и проверенная форма тела. Четвёртый — объект, который реально получил компонент после adapter.'),
figure('/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg', 'Цикл диагностики из четырёх снимков: intent, request, response, render input. Ветви ведут к stale UI, нарушению контракта или корректному обновлению.', 'Сравнивать нужно соседние границы. Снимок сети без render input не объясняет, почему экран остался прежним.'),
table('Карточка диагностики рассинхрона', ['Факт', 'Минимальное поле', 'Безопасный пример', 'Какой вопрос закрывает'], [
['Intent', 'operation + input class', 'update-address / valid-form', 'какую команду хотел выполнить UI?'],
['Request', 'method + route template + request id', 'PATCH /orders/:id + req-42', 'что действительно ушло за границу?'],
['Response', 'status + media type + contract result', '409 + problem + valid envelope', 'что сервер сообщил на уровне контракта?'],
['Render input', 'view model + revision', 'blocked + version 18', 'что именно получил компонент?'],
['Decision', 'next action', 'refetch / fix field / inspect adapter', 'какой следующий тест даст различие?'],
]),
h2('Сначала сравнить, потом объяснять'),
p('Если response valid, а render input старый, ищите adapter, cache key или race между двумя запросами. Если render input совпадает с ответом, но на экране другое, проверяйте selector, memoization и локальный optimistic state. Если response invalid, UI не обязан его отображать; источник проблемы находится на контрактной границе. Если status 409, не называйте его «ошибкой сети»: запрос дошёл, но переход не состоялся по прикладной причине.'),
p('Полезно сохранять revision или version, если API их предоставляет. Timestamp не заменяет версию: часы разных процессов могут отличаться, а более поздний ответ не всегда относится к более новому состоянию. Сравнение версии помогает обнаружить race: запрос A ушёл первым, B — вторым, но B вернулся раньше. Без явного правила store может принять старый ответ последним.'),
h2('Проверяемый пример сравнения'),
code("import { diagnoseBoundaryObservation } from './upgrade-2026-09.mjs';\n\nconsole.log(diagnoseBoundaryObservation({\n response: {\n status: 202,\n contentType: 'application/json',\n body: { status: 'blocked', allowedActions: ['retry'], messageCode: 'order.sync_required' },\n },\n ui: { renderedStatus: 'ready' },\n}));\n// { status: 'ui-api-state-mismatch', next: 'compare-render-input-with-response-body' }"),
p('Эта функция не читает браузер и не делает сетевой запрос. Она полезна как тест для диагностического слоя: при одинаковом контракте она отличает расхождение render input от ошибки формы. В приложении фактические снимки нужно получать из инструментов с фильтрацией персональных данных. Не записывайте body целиком в лог только потому, что так проще: request id и hash безопасного класса часто достаточно, чтобы связать события.'),
p('При отсутствии response функция возвращает отдельное состояние, а не «UI stale». Это важная дисциплина. Нельзя приписывать причину месту, которое ещё не наблюдали. Так же нельзя считать отсутствие нового render proof того, что backend не ответил: событие могло потеряться в adapter, отмениться при unmount или быть отброшено как устаревшее.'),
h2('Сигналы cache и race'),
p('Cache обычно выдаёт повторяемость: один и тот же ключ возвращает прежнюю версию, хотя network request уже получил новую. Race выдаёт зависимость от порядка: обновление состояния меняется при задержке одного из ответов. Для проверки cache сравните request key, revision и источник данных. Для race искусственно задержите только тестовый ответ и проверьте правило принятия версии. Не делайте вывод о поведении пользователя по одному снимку.'),
p('Если команда использует optimistic UI, назовите два состояния: локальное подтверждение взаимодействия и подтверждённая read model. Пока ответ не прочитан, на кнопке допустимо показать spinner или disable, но нельзя менять общий status на «ready» только потому, что click handler завершился. После ошибки optimistic patch должен быть снят или помечен как требующий решения. Иначе следующий render унаследует ложную модель.'),
h2('HTTP-инструменты и граница их показаний'),
p('DevTools Network показывает запрос и ответ в конкретном браузере. curl позволяет повторить HTTP-обмен, но не воспроизводит selector, cookie policy или race компонента. Логи BFF показывают серверную обработку, но не доказывают, что браузер получил именно этот response. Каждый инструмент закрывает свою границу. Скриншот одного слоя нельзя использовать как доказательство другого.'),
p('Для curl-проверки сохраняйте только безопасные заголовки и заменяйте реальные идентификаторы тестовыми. Проверяйте статус и Content-Type, затем тело на соответствие schema. Если endpoint требует авторизацию, используйте тестовый токен с ограниченным сроком; не вставляйте секрет в статью, shell history или issue. В production не повторяйте команду изменения без знания идемпотентности.'),
h2('Пример минимальной curl-проверки'),
code("curl --fail-with-body --silent --show-error \\\n -H 'Accept: application/json' \\\n -H 'X-Request-Id: req-42' \\\n 'https://api.example.test/orders/42' \\\n | jq '{status, allowedActions, messageCode}'"),
p('Команда подходит для чтения тестового ресурса, а не для бездумного повторения mutation. --fail-with-body не валидирует screen model и не превращает ошибку в успех; jq только выбирает поля для просмотра. В рабочем расследовании замените host, путь и идентификатор на разрешённые тестовые значения и сохраните рядом HTTP-статус и Content-Type. Если ответ не JSON, это отдельная находка, а не повод считать jq виноватым.'),
h2('Порядок расследования'),
ol([
'Зафиксировать operation name, request id и класс входа без персональных значений.',
'Снять method, route template, HTTP status и Content-Type; не повторять mutation до проверки идемпотентности.',
'Проверить response по контракту и отделить transport failure от problem detail и domain rejection.',
'Сравнить проверенный response с render input: version, status, allowed actions и message code.',
'Если они расходятся, проверить adapter, cache key, optimistic patch и порядок ответов.',
'Сформулировать один следующий тест, который различает две оставшиеся гипотезы, и только затем менять код.',
]),
h2('Когда остановить расследование'),
p('Остановитесь, если для следующего вывода нужны неполученные данные: реальный body с персональными полями, доступ к production-логам или повторная команда с неизвестным эффектом. Это не бюрократия, а граница доказательства. Попросите владельца системы дать безопасный correlation id, redacted response или тестовый reproduction. Нельзя заполнять пробел правдоподобной историей о кэше или сервере.'),
p('Если четыре снимка согласованы, а пользователь всё ещё видит другое, расследование переходит в слой представления: selector, memoization, hydration или CSS. Если несогласован только response, проблема остаётся у producer или gateway. Если request отличается от intent, ищите mapping формы. Такая классификация сокращает область поиска и делает исправление проверяемым.'),
h2('Ограничения и следующий шаг'),
p('Протокол не заменяет distributed tracing, contract testing и security review. Он не сообщает, что данные можно хранить сколько угодно, и не разрешает логировать тело ответа. Его задача уже: разделить наблюдения по границам и не назвать гипотезу фактом. Для сложной асинхронной команды добавьте message id, version и отдельный статус обработки; не растягивайте одну HTTP-карточку на очередь.'),
p('Следующий шаг — сделать один интеграционный тест, который сохраняет четыре безопасных поля и воспроизводит stale render input. Затем добавьте regression test на неверный Content-Type и test на более старую version. Когда эти тесты проходят, команда получает не красивый отчёт, а короткий маршрут от симптома к конкретной границе.'),
h2('Проверяемые источники'), sourceList([
{ key: 'http', use: 'Использован для различения ответа HTTP, representation и прикладной семантики статусов.', boundary: 'Не показывает состояние конкретного браузера или store.' },
{ key: 'fetch', use: 'Использован для границы между Fetch response и решением приложения об обработке body.', boundary: 'Стандарт не задаёт ваш adapter, cache policy или UI render.' },
{ key: 'problem', use: 'Использован для структурированного problem envelope при диагностике 4xx/5xx.', boundary: 'Не является логом конкретного сервиса и не заменяет redaction policy.' },
]),
].join(''),
};
export const revisions = [practice, mechanism, field];
export function verifyRevisionsAgainstFixture() {
const checks = [
checkScreenResponse({ status: 'ready', allowedActions: ['edit'], messageCode: 'order.ready' }).valid,
!checkScreenResponse({ status: 'unknown', allowedActions: 'edit' }).valid,
classifyHttpResponse({ status: 204 }).kind === 'success-without-representation',
classifyHttpResponse({ status: 409, contentType: 'application/problem+json', body: {} }).kind === 'domain-rejection',
diagnoseBoundaryObservation({ response: { status: 200, contentType: 'application/json', body: { status: 'ready', allowedActions: [], messageCode: 'ok' } }, ui: { renderedStatus: 'pending' } }).status === 'ui-api-state-mismatch',
];
const articleChecks = revisions.map((revision) => {
const text = bodyText(revision.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк|затрат|потер)/i.test(text.slice(0, 900)) && /