241 lines
59 KiB
JavaScript
241 lines
59 KiB
JavaScript
function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
||
const p = (text) => '<p>' + text + '</p>';
|
||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||
function plainText(content) { return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); }
|
||
function bodyText(content) { return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
|
||
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
||
function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; }
|
||
|
||
const REFERENCES = deepFreeze({
|
||
openapi: { title: 'OpenAPI Specification 3.1.1', url: 'https://spec.openapis.org/oas/v3.1.1.html', version: 'версия 3.1.1, 24 октября 2024, версионированная спецификация' },
|
||
http: { title: 'RFC 9110: HTTP Semantics', url: 'https://www.rfc-editor.org/rfc/rfc9110.html', version: 'RFC 9110, июнь 2022, неизменяемый текст RFC' },
|
||
schema: { title: 'JSON Schema Core: draft 2020-12', url: 'https://json-schema.org/draft/2020-12/json-schema-core.html', version: 'draft 2020-12, Internet-Draft (work in progress), 16 июня 2022' },
|
||
});
|
||
function sourceList(entries) { return '<ul>' + entries.map(({ key, use, boundary }) => { const source = REFERENCES[key]; return '<li><a href="' + source.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(source.title) + '</a> — версия: ' + escapeHtml(source.version) + '. ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>'; }).join('') + '</ul>'; }
|
||
|
||
const FIXED_BOUNDARY_CASES = deepFreeze({
|
||
'named-screen-boundary-v1': {
|
||
id: 'named-screen-boundary-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||
ui: { localState: ['draft-filter', 'open-panel'], context: ['fixed-locale', 'fixed-view-permission'], forbiddenDomainClaims: [] },
|
||
api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } },
|
||
boundary: 'Named fixed synthetic JavaScript literal in memory. It describes no UI, user, HTTP request, network trace, file, secret, service, deployment or production state.',
|
||
},
|
||
'ui-claims-domain-state-v1': {
|
||
id: 'ui-claims-domain-state-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||
ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: ['choice-confirmed'] },
|
||
api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } }, boundary: 'Negative fixed synthetic literal only.',
|
||
},
|
||
'unnamed-command-v1': {
|
||
id: 'unnamed-command-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||
ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: [] },
|
||
api: { command: { name: '', intent: '', idempotency: '' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status'] }, error: { kind: 'named-problem', code: 'none', retry: 'not-requested' } }, boundary: 'Negative fixed synthetic literal only.',
|
||
},
|
||
'unactionable-error-v1': {
|
||
id: 'unactionable-error-v1', family: 'fixed-frontend-backend-boundary-v1',
|
||
ui: { localState: ['draft-filter'], context: ['fixed-locale'], forbiddenDomainClaims: [] },
|
||
api: { command: { name: 'fixed-submit-choice', intent: 'set-choice', idempotency: 'named-key-v1' }, readModel: { name: 'fixed-choice-screen-v1', fields: ['status', 'allowedActions', 'messageCode'] }, error: { kind: 'thrown-value', code: '', retry: '' } }, boundary: 'Negative fixed synthetic literal only.',
|
||
},
|
||
});
|
||
|
||
export function createFixedBoundaryCase(id = 'named-screen-boundary-v1') { const value = FIXED_BOUNDARY_CASES[id]; return value ? deepFreeze(clone(value)) : undefined; }
|
||
function stop(status, reason, nextAction) { return deepFreeze({ accepted: false, status, reasons: deepFreeze([reason]), nextAction, handoff: 'not-eligible-for-synthetic-boundary-review', productionEffect: 'not-attempted' }); }
|
||
export function reviewFixedStateOwnership(input) {
|
||
if (!Object.values(FIXED_BOUNDARY_CASES).some((known) => JSON.stringify(known) === JSON.stringify(input))) return stop('stop-unknown-fixed-input', 'input-is-not-a-known-named-fixed-literal', 'select-a-named-fixed-case');
|
||
if (!input.ui?.localState?.length || !input.ui?.context?.length) return stop('stop-ui-boundary-incomplete', 'local-state-or-context-is-not-named', 'name-local-state-and-context-separately');
|
||
if (input.ui.forbiddenDomainClaims?.length) return stop('stop-ui-claims-domain-state', 'ui-literal-claims-domain-state', 'remove-domain-claim-and-request-a-read-model');
|
||
const command = input.api?.command;
|
||
if (!command?.name || !command.intent || !command.idempotency) return stop('stop-command-not-named', 'command-intent-or-idempotency-key-is-missing', 'name-command-intent-and-idempotency-boundary');
|
||
const readModel = input.api?.readModel;
|
||
if (!readModel?.name || !['status', 'allowedActions', 'messageCode'].every((field) => readModel.fields?.includes(field))) return stop('stop-read-model-incomplete', 'screen-read-model-is-not-decidable', 'name-status-actions-and-message-code');
|
||
const error = input.api?.error;
|
||
if (error?.kind !== 'named-problem' || !Object.hasOwn(error, 'code') || !Object.hasOwn(error, 'retry')) return stop('stop-error-not-actionable', 'error-is-not-a-named-problem-envelope', 'name-problem-code-and-retry-boundary');
|
||
return deepFreeze({ accepted: true, status: 'synthetic-boundary-review-hand-off', caseId: input.id, uiOwns: deepFreeze([...input.ui.localState, ...input.ui.context]), apiOwns: deepFreeze([command.intent, readModel.name, 'named-problem-envelope']), boundary: input.boundary, productionEffect: 'not-attempted' });
|
||
}
|
||
export function explainFixedBoundaryInference(input) {
|
||
const review = reviewFixedStateOwnership(input);
|
||
if (!review.accepted) return review;
|
||
return deepFreeze({ status: 'synthetic-inference-boundary-explained', allowedConclusion: 'named-fields-support-only-a-synthetic-review-hand-off', forbiddenConclusion: 'no-real-ui-api-or-domain-state-is-observed', nextAction: 'hand-off-fixed-synthetic-boundary-review', productionEffect: 'not-attempted' });
|
||
}
|
||
export function runFixedBoundaryFixture() {
|
||
const cases = [
|
||
['named-screen-boundary-v1', 'synthetic-boundary-review-hand-off'], ['ui-claims-domain-state-v1', 'stop-ui-claims-domain-state'], ['unnamed-command-v1', 'stop-command-not-named'], ['unactionable-error-v1', 'stop-error-not-actionable'],
|
||
];
|
||
const checks = cases.map(([id, status]) => ({ id, expected: status, actual: reviewFixedStateOwnership(createFixedBoundaryCase(id)).status }));
|
||
const inference = explainFixedBoundaryInference(createFixedBoundaryCase('named-screen-boundary-v1'));
|
||
checks.push({ id: 'inference', expected: 'synthetic-inference-boundary-explained', actual: inference.status });
|
||
const accepted = checks.every((check) => check.expected === check.actual) && inference.productionEffect === 'not-attempted';
|
||
return deepFreeze({ passed: checks.filter((check) => check.expected === check.actual).length, total: checks.length, accepted, checks: deepFreeze(checks) });
|
||
}
|
||
|
||
const commonMeta = { readingMinutes: 10, tags: ['frontend', 'backend', 'contracts', 'architecture'] };
|
||
const practice = {
|
||
...commonMeta, slug: 'editorial-2026-09-practice-frontend-backend-boundary',
|
||
title: 'План на сентябрь: карта границы между экраном и доменом',
|
||
excerpt: 'Как до начала работ отделить локальное состояние UI от контекста и screen read model, не приписывая клиенту доменный факт.',
|
||
contentHtml: [
|
||
p('План на сентябрь 2026, составленный по источникам на 31 июля 2026: команда часто называет «состоянием экрана» всё, что лежит в store. В результате UI присваивает себе доменный факт — например, «выбор подтверждён» — до того, как этот факт вообще определён контрактом. Цена ошибки конкретна: спор о кнопке превращается в спор о правде системы, а повторный вход или иной клиент получает несовместимую версию того же решения.'),
|
||
p('Задача этого сценария не в том, чтобы заменить API красивой схемой. Нужно до реализации разложить один экран на владельцев: что живёт только в UI, что является контекстом запроса и что backend обязан вернуть как read model. Если не назвать владельца, локальная оптимизация станет скрытым протоколом. Её стоимость проявится при ошибке, повторной команде или добавлении второго интерфейса: договорённость придётся восстанавливать по веткам компонентов.'),
|
||
h2('Три слоя, которые нельзя называть одним словом'),
|
||
p('Локальное состояние отвечает на вопрос «как пользователь сейчас управляет представлением»: открыт ли panel, что набрано в фильтре, какая вкладка выбрана до отправки. Оно может исчезнуть при закрытии view без изменения домена. Контекст не является результатом операции: locale, явно выданное permission или feature boundary задают, как интерпретировать запрос. Их нельзя подменять полем ответа и нельзя вычислять из CSS или текста кнопки.'),
|
||
p('Доменное состояние отвечает на другой вопрос: что система считает действительным для предмета. У экрана нет оснований владеть им только потому, что он его показывает. В сентябрьском плане API возвращает не «всю сущность», а named screen read model: <code>status</code>, <code>allowedActions</code> и <code>messageCode</code>. Это достаточная форма для решения UI, но не лицензия выдумать новые переходы или причины отказа.'),
|
||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-state-ownership-map.svg', 'Схема из трёх колонок: UI владеет draft и открытой панелью, context содержит locale и permission, API возвращает status, allowed actions и message code. Стрелка команды идёт к API, а не к доменному факту внутри UI.', 'Карта владельцев не описывает настоящую систему. Это шаблон вопросов для сентябрьского design review.'),
|
||
table('Карта ответственности для одного synthetic screen scenario', ['Объект', 'Владелец', 'Допустимый вопрос', 'Недопустимая подмена'], [
|
||
['draft-filter', 'UI', 'что введено до команды?', 'назвать draft подтверждённым выбором'],
|
||
['fixed-locale', 'context', 'в каком формате показать код?', 'вывести locale из результата операции'],
|
||
['status', 'API read model', 'что разрешено показать?', 'превратить status в локальный флаг успеха'],
|
||
['fixed-submit-choice', 'API command', 'какое намерение отправлено?', 'описать команду как прямую мутацию store'],
|
||
['named-problem', 'contract', 'как UI может объяснить stop?', 'ловить произвольную thrown value'],
|
||
]),
|
||
h2('Карта начинается не с полей, а с права утверждать'),
|
||
p('У каждого поля полезно спросить: кто имеет право изменить его смысл и кто должен увидеть изменение после повторного чтения? Если ответ «только текущий компонент», это кандидат в local state. Если ответ зависит от входной среды, это context с явным источником. Если ответ должен быть одинаково интерпретирован разными клиентами, это доменный факт или его проекция, и экран лишь получает его через контракт.'),
|
||
p('Такое правило не требует тотального backend-for-frontend. Иногда read model собирается отдельным adapter, иногда один ресурс уже годится для нескольких view. Важно не место кода, а обязанность: читатель ответа понимает, какие action разрешены, без копирования rules в UI. Если для этого приходится держать в компоненте таблицу переходов, граница не названа; перенос HTTP-вызова сам по себе её не исправит.'),
|
||
h2('Как распознать присвоение доменного состояния'),
|
||
p('Первый маркер — optimistic flag с именем в прошедшем времени: <code>isConfirmed</code>, <code>wasPaid</code>, <code>hasAccess</code>. Сам по себе optimistic interaction не запрещён, но его следует назвать именно interaction: «команда отправлена», «локальная форма заблокирована», «ожидается новый read model». Когда такой флаг влияет на список разрешённых действий, он уже конкурирует с domain rule. План не требует убрать весь optimistic UI; он требует не выдавать временную реакцию за знание о предмете.'),
|
||
p('Второй маркер — context, который появляется только после разбора ответа. Locale и permission обычно задают способ показать или запросить информацию; status описывает то, что вернул contract. Если перемешать их, UI начинает зависеть от исторического порядка: сначала пришёл ответ, потом вычислился context, потом поменялся текст. В сентябрьской карте контекст именуется отдельно именно чтобы reviewer мог спросить, откуда он взят, не разбирая payload и не строя гипотезу о session.'),
|
||
p('Третий маркер — boolean, который скрывает причину. <code>canSubmit</code> полезен как derived view state, если он получен из <code>allowedActions</code> или локальной валидности формы. Он опасен как самостоятельный доменный флаг, потому что другой client уже не узнает, почему действие недоступно. Вместо одной универсальной причины model держит status и message code. Это не усложнение ради полноты: следующий экран получает явный вопрос, а не обязанность повторить условие.'),
|
||
p('Наконец, карта не требует, чтобы API знал каждую анимацию или фокус. Такие детали остаются у UI и не обязаны переживать новый read. Плохая граница получается не тогда, когда клиент что-то хранит, а когда он хранит факт, который должен быть согласован между потребителями. Этот критерий помогает не уйти в архитектурную религию: локальное остаётся локальным, общее имеет контракт, а context не маскируется под оба случая.'),
|
||
h2('Literal-проверка карты, а не имитация trace'),
|
||
code("import { createFixedBoundaryCase, reviewFixedStateOwnership } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('ui-claims-domain-state-v1');\nconst result = reviewFixedStateOwnership(input);\nconsole.log({ status: result.status, reason: result.reasons[0], effect: result.productionEffect });\n// { status: 'stop-ui-claims-domain-state', reason: 'ui-literal-claims-domain-state', effect: 'not-attempted' }"),
|
||
p('Этот snippet выполняется над named fixed synthetic literal, клонирует его через JSON и freeze-ит результат. Он не открывает browser, не читает сеть и не изображает настоящий экран. Полезный output здесь — stop с конкретной причиной. Положительная ветка функции даёт лишь <code>synthetic-boundary-review-hand-off</code>; она не означает approval, release или готовность к реализации.'),
|
||
p('Для автора M9 эта проверка важна ещё и как способ писать короче. Вместо абзаца о том, «кто за что отвечает», он может показать поле, владельца и stop reason. Но краткость не должна скрывать цену переноса: если <code>status</code> становится локальным, UI берёт на себя обязанность поддерживать его после другой команды, другой вкладки и другого клиента. В плановой карте эта обязанность не делегируется компоненту. Она остаётся открытым вопросом к контракту, пока отдельный read model не даст основание для вывода. Нельзя закрыть этот вопрос именем store или выбором фреймворка: оба решения определяют хранение, но не источник domain truth.'),
|
||
h2('Почему screen model не равна DTO «как есть»'),
|
||
p('DTO транспорта может быть удобной исходной формой, но его наличие не отвечает на вопрос view. Screen model должна иметь ясное назначение: что показать, что разрешить, как обозначить проблемное состояние. OAS 3.1.1 полезна именно как описание интерфейса, независимое от языка; она не предписывает слой или форму state management. Поэтому в плане мы используем спецификацию, чтобы закрепить поля контракта, а не чтобы объявить каждый JSON API готовым для UI.'),
|
||
p('В error path особенно легко смешать роли. HTTP status описывает результат обмена на уровне протокола, но UI всё ещё нуждается в именованной проблеме с <code>code</code> и <code>retry</code>. Нельзя вывести retry из «любого 5xx» без правила конкретного контракта. RFC 9110 задаёт семантику HTTP, а наша модель добавляет ограниченный project-level вопрос: может ли view показать следующий шаг, не угадывая смысл исключения.'),
|
||
h2('Сентябрьская последовательность'),
|
||
ol([
|
||
'Выбрать один будущий screen scenario и прямо записать: это план, а не описание уже существующего UI.',
|
||
'Выписать local state отдельно от context; у каждого элемента назвать допустимое время жизни.',
|
||
'Для доменного факта сформулировать один read question и один command intent без слов «обновить store».',
|
||
'Согласовать минимальный screen read model: status, allowed actions, message code и named problem envelope.',
|
||
'Прогнать fixed literal с domain claim; при stop удалить claim, а не добавлять ещё один optimistic flag.',
|
||
'Передать только synthetic review card с открытыми вопросами владельцу следующего design review.',
|
||
]),
|
||
h2('Границы плана и следующий шаг'),
|
||
p('Карта не доказывает, что конкретный endpoint хорошо спроектирован, и не сообщает о latency, правах реальных пользователей или поведении браузера. Она также не решает offline, cache invalidation и composition нескольких aggregates. JSON Schema Core объясняет, как фиксировать структуру документа, но не превращает validation в доменную корректность. Все перечисленные вопросы потребуют отдельного входа и собственных критериев.'),
|
||
p('Следующий шаг на сентябрь — завести второй named literal, в котором у read model нет <code>allowedActions</code>, и убедиться, что review останавливается как на неполной модели. Так команда проверит не только удачную карту, но и язык отказа. До этого нельзя делать вывод о реальном frontend, backend или состоянии выбора: в пакете есть только изолированная модель и план её обсуждения.'),
|
||
h2('Проверяемые источники'), sourceList([
|
||
{ key: 'openapi', use: 'Использован узко: OAS определяет язык-независимое описание HTTP API, поэтому contract можно обсуждать отдельно от реализации.', boundary: 'Не доказывает, что какой-либо endpoint или screen model существует.' },
|
||
{ key: 'http', use: 'Использован только для различения HTTP semantics и project-level problem envelope.', boundary: 'Не задаёт retry policy и не подтверждает исход конкретного запроса.' },
|
||
{ key: 'schema', use: 'Использован для мысли о явной структуре документа и vocabulary.', boundary: 'Schema validation не доказывает доменную корректность или UI-поведение.' },
|
||
]),
|
||
].join(''),
|
||
};
|
||
|
||
const mechanism = {
|
||
...commonMeta, slug: 'editorial-2026-09-mechanism-frontend-backend-boundary',
|
||
title: 'План на сентябрь: на что boundary даёт право сделать вывод',
|
||
excerpt: 'Как отличить query, command, read model и error envelope, чтобы UI не превращал транспортный ответ в неподтверждённый доменный вывод.',
|
||
contentHtml: [
|
||
p('Сентябрьский механизм начинается с неприятного симптома: один handler отправляет запрос, меняет локальный флаг и затем трактует любой ответ как подтверждение. Проблема не в количестве hooks. Проблема в незаконном выводе: UI делает доменное утверждение из факта отправки команды. Цена — не только неверная кнопка. При повторе, параллельном клиенте или ошибке команда перестаёт быть различимой, а support получает сообщение без понятной причины и следующего действия.'),
|
||
p('Это план для источников, известных на 31 июля 2026, а не отчёт о сентябрьской переделке. Он разделяет четыре формы: query запрашивает именованное представление, command выражает намерение, read model даёт экрану основания для показа, error envelope ограничивает реакцию. Ни одна форма не заменяет другую. Особенно опасно называть command «query с side effect» или считать status code готовой инструкцией для UI.'),
|
||
h2('Command не является правом изменить экран'),
|
||
p('Command полезен, когда его имя описывает намерение, а не способ работы с данными. В fixed literal это <code>fixed-submit-choice</code> с intent <code>set-choice</code> и named idempotency key. Имя не говорит, что выбор уже принят; оно лишь делает повторное намерение различимым в границе модели. Если name, intent или idempotency отсутствуют, review возвращает stop. Он не генерирует fallback и не пытается угадать безопасное значение.'),
|
||
p('Query в этом сценарии — вопрос о view, а не скрытая команда. Его ответом становится screen read model <code>fixed-choice-screen-v1</code>. Поля <code>status</code>, <code>allowedActions</code> и <code>messageCode</code> образуют достаточное основание для отображения; каждое имеет читателя в UI. Если добавить поле «для будущего», но не назвать, какой вывод оно поддерживает, оно не улучшает contract. Оно расширяет поверхность совместимости без проверяемой задачи.'),
|
||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-contract-responsibility-matrix.svg', 'Матрица из четырёх строк: query читает screen model, command передаёт intent, read model даёт status и allowed actions, error envelope даёт code и retry. Внизу красная строка запрещает UI делать доменный вывод из отправки команды.', 'Матрица показывает разные права на вывод: отправка command не равна новому domain fact.'),
|
||
table('Четыре формы на boundary', ['Форма', 'Кто формулирует', 'Что может заключить UI', 'Чего заключать нельзя'], [
|
||
['query', 'view contract', 'какой named model нужен сейчас', 'что домен изменился'],
|
||
['command', 'intent contract', 'какое намерение выражено', 'что намерение принято'],
|
||
['read model', 'API boundary', 'что показать и какие action названы', 'внутреннюю причину без поля'],
|
||
['problem envelope', 'error contract', 'какой message code и retry boundary есть', 'что ошибка transient без правила'],
|
||
]),
|
||
h2('Право на вывод — более точный тест, чем «где код»'),
|
||
p('В проекте можно разместить adapter в frontend repo и всё равно держать доменную границу корректной. И наоборот, endpoint на backend не спасает, если component сам вычисляет разрешённый переход. Поэтому на сентябрьской встрече полезно не спорить об именах слоёв, а взять каждую ветку и спросить: каким полем contract она обоснована? Если такого поля нет, ветка должна стать stop, запросом на новую модель или явным local interaction — но не скрытым business rule.'),
|
||
p('Read model не обязана копировать aggregate и не обязана быть вечной. Она обязана быть решаемой для своего consumer. В примере отсутствие любого из трёх полей останавливает review с <code>stop-read-model-incomplete</code>. Это сознательно строгая учебная форма: она не заявляет, что три поля достаточны для всех экранов. Она фиксирует минимум для одного named synthetic scenario и оставляет расширение отдельным решением.'),
|
||
h2('Error — контракт действия, а не украшение ответа'),
|
||
p('Thrown value удобна внутри языка, но на boundary она не даёт потребителю стабильного вопроса. В плановой модели допустим только <code>named-problem</code> с присутствующими ключами <code>code</code> и <code>retry</code>. Значение <code>none</code> или <code>not-requested</code> — fixed literal, не наблюдение. Оно не сообщает, что в сентябре ошибок не будет и не моделирует retry сети. Оно заставляет автора назвать семантику до того, как UI начнёт угадывать её по тексту.'),
|
||
p('HTTP status остаётся значимым, но не должен молча стать domain language. RFC 9110 описывает status codes и семантику сообщений; из него не следует, что конкретный product flow обязан показывать кнопку retry. Такая кнопка появляется только при named rule на boundary. Этот разрыв полезен: transport failures, validation problems и запрещённые действия перестают сливаться в один catch, а каждое расширение контракта имеет место для review.'),
|
||
h2('Где механизм намеренно останавливает вывод'),
|
||
p('Первый stop связан с неизвестным input. Функция сравнивает вход с finite набором named fixtures, а не пытается принять похожий object. Это ограничение похоже на избыточную осторожность, но оно защищает учебный пример от тихой эволюции: добавили поле, поменяли смысл <code>retry</code>, а старый reviewer всё ещё читает accepted status. Новый input должен получить имя и пройти тот же review. Так автор видит изменение контракта до того, как его назовут обратной совместимостью.'),
|
||
p('Второй stop касается команды без idempotency boundary. Мы не утверждаем, что любой production command обязан иметь определённый ключ: это зависит от предмета и реализации. Однако для выбранного synthetic scenario повтор намерения является значимой частью разговора, поэтому пустое поле запрещает hand-off. Такой gate не проектирует storage и не выбирает HTTP header. Он лишь не позволяет тексту говорить «повтор безопасен», пока в модели не названо, что именно различает повтор.'),
|
||
p('Третий stop возникает, когда read model не даёт decision fields. Это не требование возвращать все возможные данные. Скорее, это отрицание ложной экономии: нельзя заставить consumer догадаться о permitted action по отсутствию поля или по message text. Если action зависит от сложного правила, API может вернуть более точную проекцию; если правило локальное, его следует явно вынести в UI. В обоих случаях boundary становится предметом change, а не неявным соглашением между двумя ветками.'),
|
||
p('Именно поэтому позитивный ответ механизма маленький. Он перечисляет, что named fields позволяют только <code>synthetic-boundary-review-hand-off</code>. У него нет слова success, нет timestamp и нет результата команды. Чем меньше такой output похож на status системы, тем труднее использовать его как декоративное доказательство. Для статьи о будущем это важнее эффекта «работающий пример»: пример должен проверять формулировку, а не имитировать эксплуатацию.'),
|
||
h2('Буквальная проверка fail-closed'),
|
||
code("import { createFixedBoundaryCase, explainFixedBoundaryInference } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('unactionable-error-v1');\nconst result = explainFixedBoundaryInference(input);\nconsole.log({ status: result.status, reason: result.reasons[0], next: result.nextAction });\n// { status: 'stop-error-not-actionable', reason: 'error-is-not-a-named-problem-envelope', next: 'name-problem-code-and-retry-boundary' }"),
|
||
p('Код исполняется только над зафиксированным literal в памяти и возвращает deterministic stop. Он не делает HTTP-вызов, не читает trace и не создаёт реальное исключение. Даже accepted branch выдаёт лишь synthetic hand-off. Это важная дисциплина для текста о будущем: «модель допускает передачу review» и «команда внедрила boundary» — разные предложения; второе у этого пакета не имеет источника и поэтому запрещено.'),
|
||
p('Полезно отдельно проверить, не подменяет ли модель failure mode термином transport. Например, <code>named-problem</code> не обязан быть сериализацией RFC status и не обязан нести stack. Его задача в выбранном сценарии — сохранить код и границу retry, достаточные для следующей UI-ветки. Если consumer требуется причина для журнала, это новый contract question с собственным доступом и privacy boundary. Добавлять её «на всякий случай» в screen model нельзя: поля без читателя быстро становятся неявным интерфейсом для чужой логики. В частности, message code не должен превращаться в место, куда UI складывает технический текст, если contract не назвал его читателя и срок жизни.'),
|
||
h2('Как проводить design review в сентябре'),
|
||
ol([
|
||
'Назвать один сценарий и дату: это план на сентябрь, состояние источников ограничено 31 июля.',
|
||
'Записать command как intent с key для различимости повторов, не как обещание успешной мутации.',
|
||
'Сформулировать query и screen read model через выводы, которые реально нужны view.',
|
||
'Для каждой UI-ветки указать поле contract; ветку без поля заменить stop question.',
|
||
'Описать problem envelope отдельно от HTTP-статуса и назвать retry boundary.',
|
||
'Прогнать negative fixed literal и передать только synthetic hand-off с open question.',
|
||
]),
|
||
h2('Граница источников и граница механизма'),
|
||
p('OpenAPI 3.1.1 поддерживает идею явного описания операции и сообщений, но не вводит наше разделение local/context/domain state. JSON Schema Core поддерживает vocabulary и ссылки в schema, но не решает, какие fields нужны человеку на экране. Эти решения — проектная модель P103. Review отмечает это отдельно, чтобы ссылкой на стандарт не прикрыть архитектурный вкус как доказанный факт.'),
|
||
p('Механизм не покрывает streaming updates, eventual consistency, cache coherence, авторизацию и финансовую идемпотентность. Также он не измеряет удобство API и не обещает уменьшение числа ошибок. Для таких утверждений нужны реальные наблюдения, которых fixed literal не содержит. Следующий шаг — создать named case с корректным command, но без <code>messageCode</code>, и проверить точность stop reason; только потом обсуждать, нужен ли новый contract field.'),
|
||
h2('Проверяемые источники'), sourceList([
|
||
{ key: 'openapi', use: 'Использован для узкой роли: операция и сообщения могут быть описаны как интерфейс HTTP API.', boundary: 'Не устанавливает наши правила command/query или состав screen model.' },
|
||
{ key: 'http', use: 'Использован для разграничения HTTP semantics и application-level problem policy.', boundary: 'Не подтверждает retry, error rate или итог конкретного обмена.' },
|
||
{ key: 'schema', use: 'Использован как пример явной vocabulary contract-документа.', boundary: 'Не выводит business meaning из валидности JSON.' },
|
||
]),
|
||
].join(''),
|
||
};
|
||
|
||
const field = {
|
||
...commonMeta, slug: 'editorial-2026-09-field-frontend-backend-boundary',
|
||
title: 'Сценарий на сентябрь: synthetic hand-off для границы UI и API',
|
||
excerpt: 'Как подготовить безопасную карточку review без изображения production trace, чтобы несогласованный ownership останавливал обсуждение точной причиной.',
|
||
contentHtml: [
|
||
p('В сентябрьском сценарии проблема review часто не в отсутствии диаграммы, а в подмене evidence. Автор показывает «типичный ответ» и произносит, что экран уже согласован с API, хотя пример не имеет владельца, даты и отрицательной ветки. Цена такой уверенности — решение принимают по иллюстрации, которую нельзя повторить: при первом расхождении UI и backend спорят, была ли это договорённость или просто локальный mock.'),
|
||
p('Этот материал — план безопасного synthetic hand-off на сентябрь 2026 при состоянии источников на 31 июля. Он не описывает проведённый review и не имитирует UI-network trace. Вместо него используется named fixed literal с заранее известными полями. Положительный исход ограничен передачей карточки следующему reviewer; он не утверждает approval, deployment, публикацию или доступ к production.'),
|
||
h2('Карточка evidence должна содержать то, что может опровергнуть вывод'),
|
||
p('Поле <code>ui.localState</code> показывает, что interaction принадлежит экрану; <code>ui.context</code> фиксирует условия интерпретации; <code>api.command</code> называет намерение; <code>api.readModel</code> задаёт достаточное представление; <code>api.error</code> ограничивает реакцию. Эти поля не являются дампом данных. Они составлены так, чтобы reviewer мог отвергнуть карточку по одному отсутствующему условию, не достраивая его опытом или знанием настоящей системы.'),
|
||
p('Контрпример важнее красивой positive ветки. Если UI уже содержит <code>choice-confirmed</code> в <code>forbiddenDomainClaims</code>, функция не говорит «почти готово». Она возвращает <code>stop-ui-claims-domain-state</code>. Это не обвинение frontend и не факт о реальном приложении. Это свойство формального literal: boundary не позволяет выводу, пока claim не заменён read question или не закреплён соответствующим контрактом.'),
|
||
figure('/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg', 'Цикл из пяти шагов: fixed literal, проверка ownership, stop или counterexample, уточнённый literal и synthetic hand-off. Красная ветка от domain claim возвращает к вопросу о read model.', 'Evidence loop останавливает недоказанный вывод и возвращает его к именованному входу, а не к реальному окружению.'),
|
||
table('Поля безопасной hand-off карточки', ['Поле', 'Зачем оно нужно', 'Пример fixed value', 'Что оно не доказывает'], [
|
||
['localState', 'отделить interaction от domain', 'draft-filter', 'что пользователь действительно вводил фильтр'],
|
||
['context', 'назвать условие интерпретации', 'fixed-locale', 'реальное permission или session'],
|
||
['command', 'зафиксировать intent', 'fixed-submit-choice', 'успешное изменение'],
|
||
['readModel', 'дать основание UI-ветке', 'allowedActions', 'состояние настоящего экрана'],
|
||
['problem', 'сохранить stop semantics', 'named-problem', 'наличие сетевой ошибки'],
|
||
]),
|
||
h2('Что именно является evidence в synthetic работе'),
|
||
p('Evidence здесь — не лог браузера и не screenshot. Это связь между одним известным входом и результатом pure function. Вход создаётся JSON clone и deep freeze, поэтому caller не может тихо изменить fixture между проверкой и hand-off. Determinism полезен ровно в своей области: reviewer может запустить public export и получить тот же named stop или тот же limited hand-off. Он не может из этого узнать задержку сети, порядок событий или поведение реального кода.'),
|
||
p('Отдельно полезно писать границу прямо в output: <code>productionEffect: not-attempted</code>. Обычно такую строку считают избыточной, пока positive status не начинают читать как разрешение. Здесь статус <code>synthetic-boundary-review-hand-off</code> означает только полноту заранее заданной структуры. Он не утверждает, что ownership выбран верно для продукта, не меняет конфигурацию и не разрешает кому-либо отправлять command в настоящий сервис.'),
|
||
h2('Почему fixed literal лучше «правдоподобного» примера'),
|
||
p('Правдоподобный mock соблазняет деталями: URL, имя клиента, задержка, fragment response. Но чем точнее он похож на среду, тем проще читателю принять его за наблюдение. Для будущего месяца это особенно опасно: вымышленная trace визуально выглядит как выполненная работа. Named fixed literal отказывается от этой достоверности нарочно. Его values звучат искусственно — <code>fixed-locale</code>, <code>named-key-v1</code> — и этим не дают перепутать модель с фактом.'),
|
||
p('JSON clone перед freeze нужен не для демонстрации приёма JavaScript, а для границы владения fixture. Проверяемая функция не получает ссылку на скрыто изменяемый внутренний объект. Она получает новый JSON-compatible input, после чего вложенные поля заморожены. Это не защищает реальную память процесса и не является security control. Это минимальное свойство учебного примера: один reviewer не может случайно изменить <code>allowedActions</code> перед передачей карточки другому.'),
|
||
p('Отсутствие clock — ещё одна сознательная граница. В пакете нельзя сказать «после двух секунд» или «сначала пришёл response», потому что в literal нет времени и событий. Вместо таймлайна есть порядок review: вход, проверка, reason, next action. Такой порядок не претендует на causal trace. Он удобен, когда обсуждается качество контракта: причинность должна быть подтверждена отдельным наблюдением, а не дорисована последовательностью стрелок.'),
|
||
p('Наконец, synthetic hand-off не является документом согласования между командами. Он только делает disagreement видимым. Реальный agreement потребовал бы участников, версий, полномочий и, вероятно, изменения описания API; ни одного такого факта P103 не содержит. Поэтому карточка завершает свою работу словом hand-off, а не «решение принято». Это сохраняет полезную дистанцию между подготовкой разговора и внешним действием.'),
|
||
p('В поле особенно соблазнительно заменить отсутствие evidence словами «этот путь типичен». Для P103 это запрещённая экономия. Типичным может быть только named fixture, а не пользователь, экран, endpoint или последовательность пакетов. Если следующий reviewer хочет проверить реальный boundary, он должен создать отдельную задачу с разрешёнными inputs и получить доступ к ним в своём процессе. Синтетическая карточка не переносит ему ни данных, ни полномочий, зато точно фиксирует, какую неизвестность нельзя прятать за правдоподобным примером. Это сохраняет применимость материала: он учит форме вопроса, а не создаёт ложное знание о системе, которую не наблюдали.'),
|
||
h2('Буквальный пример передачи, а не одобрения'),
|
||
code("import { createFixedBoundaryCase, explainFixedBoundaryInference } from './upgrade-2026-09.mjs';\n\nconst input = createFixedBoundaryCase('named-screen-boundary-v1');\nconst result = explainFixedBoundaryInference(input);\nconsole.log({ status: result.status, next: result.nextAction, effect: result.productionEffect });\n// { status: 'synthetic-inference-boundary-explained', next: 'hand-off-fixed-synthetic-boundary-review', effect: 'not-attempted' }"),
|
||
p('Это максимальный положительный выход P103. Snippet использует только named fixed literals; он не запускает UI, не читает файл, не обращается к секрету и не делает запрос. Слово hand-off здесь означает передачу формулировки для дальнейшего review, а не передачу контроля над средой. Такой узкий output делает полезной и negative ветку: при дефекте она сообщает ровно, какого поля не хватает, вместо декоративного «проверить интеграцию». '),
|
||
h2('Как reviewer читает карточку'),
|
||
p('Сначала он ищет ownership conflict: доменное утверждение в UI, контекст, спрятанный в read model, или command без intent. Затем он читает constraints у error envelope: есть ли code и retry boundary, а не просто строка ошибки. После этого он проверяет вывод: соответствует ли status одному из явно названных состояний функции. Если карточка просит сделать шаг в окружении, которого literal не касается, review обязан остановиться — даже если описание кажется правдоподобным.'),
|
||
p('Эта процедура нарочно скупа на «истории успеха». В ней нет synthetic метрики, потому что числовой эффект легко спутать с наблюдением. В ней нет названия настоящей команды или endpoint, потому что их нельзя проверить из изолированного пакета. Вместо этого сохраняется контрпример с неназванной command и контрпример с неоперабельной ошибкой. Они учат лучше, чем happy path: показывают момент, когда автор обязан сменить вопрос.'),
|
||
h2('Порядок сентябрьского hand-off'),
|
||
ol([
|
||
'Пометить карточку как план/сценарий на сентябрь и записать cutoff источников 31.07.2026.',
|
||
'Собрать один fixed literal с local state, context, command, read model и error envelope.',
|
||
'Запустить positive literal и проверить, что output ограничен synthetic hand-off и not-attempted.',
|
||
'Запустить literal с UI domain claim и сохранить exact stop reason.',
|
||
'Запустить literal с пустым command или error envelope и не заменять stop догадкой.',
|
||
'Передать reviewer только вход, output, границы и открытый вопрос; не добавлять claims о real UI/API.',
|
||
]),
|
||
h2('Ограничения и следующий вопрос'),
|
||
p('Synthetic hand-off не заменяет контрактное тестирование, доступность, security review и проверку authorization. Он не определяет, где хранить cache, как кодировать проблему на wire и как версии читать старым клиентам. OAS и JSON Schema помогают описывать форму, RFC 9110 — смысл HTTP, но ни один из этих источников не подтверждает, что предлагаемая форма полезна конкретному человеку. P103 сознательно не делает такого заявления.'),
|
||
p('Следующий шаг — на отдельном design review создать ещё один fixed literal с известным несовпадением: command named, но read model без <code>allowedActions</code>. Если reason останется точным и не даст «продолжить на глаз», сценарий готов к обсуждению следующего контракта. Если нет, следует уточнить форму карточки. До независимой работы с настоящими inputs нельзя говорить, что сентябрьский boundary уже реализован, измерен или опубликован.'),
|
||
h2('Проверяемые источники'), sourceList([
|
||
{ key: 'openapi', use: 'Использован только как первичный источник для понятия описываемого HTTP API interface.', boundary: 'Не является evidence настоящего hand-off или release.' },
|
||
{ key: 'http', use: 'Использован для границы между protocol semantics и application-level decision.', boundary: 'Не сообщает о реальном response, user или сети.' },
|
||
{ key: 'schema', use: 'Использован для идеи явно структурированной проверяемой карточки.', boundary: 'Не подтверждает достоверность значения внутри fixed literal.' },
|
||
]),
|
||
].join(''),
|
||
};
|
||
|
||
export const revisions = [practice, mechanism, field];
|
||
export function verifyRevisionsAgainstFixture() {
|
||
const fixture = runFixedBoundaryFixture();
|
||
const articleChecks = revisions.map((revision) => { const text = bodyText(revision.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|издержк)/i.test(text.slice(0, 900)) && /<table>/.test(revision.contentHtml) && /<figure>/.test(revision.contentHtml) && /<pre><code>/.test(revision.contentHtml) && /<ol>/.test(revision.contentHtml); });
|
||
return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((revision) => [revision.slug, bodyText(revision.contentHtml).length])) });
|
||
}
|
||
if (process.argv.includes('--verify-fixture')) { const report = verifyRevisionsAgainstFixture(); console.log(JSON.stringify(report, null, 2)); if (!report.accepted) process.exitCode = 1; }
|
||
if (process.argv.includes('--print-revisions')) console.log(JSON.stringify(revisions));
|