{ "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.
Цена такой ошибки — не только неверное сообщение. Клиент может повторить уже принятый заказ, worker может пропустить отказ, а расследование свяжет события по совпадающим полям, хотя они относятся к разным состояниям. Исправление обычно начинается с догадки: добавить retry, привести значение к строке или считать пустой код успехом. Каждая такая догадка расширяет зону риска.
\nТезис статьи простой: общий boundary contract должен называть не только поля, но и их смысл. Для минимальной проверки достаточно разделить три независимые оси: type отвечает за вид значения, error — за режим завершения, time — за определённую шкалу и порядок. Если одна ось не задана или подменена другой, проверка должна остановиться.
JSON переносит форму. Он не переносит договор о поведении. Число 4200 может означать сумму в копейках, лимит, внутренний идентификатор или случайный счётчик. Строка ready может быть именем состояния, текстом для интерфейса или результатом нестрогого сравнения. Без названного типа consumer вынужден угадывать.
Ошибки создают второй разрыв. Один runtime может вернуть объект результата с полем error. Другой может завершить операцию исключением. Третий может вернуть код и продолжить выполнение. Человек способен описать эти случаи одной фразой «обработка ошибки». Адаптеру такой фразы недостаточно: ему нужно знать, можно ли повторять операцию, сохранено ли значение и кто владеет решением.
Время создаёт третий разрыв. Длительность из monotonic clock нельзя без оговорки сравнивать с календарным timestamp. Два числа без шкалы не доказывают latency. Даже одинаковые начало и конец могут быть только порядковыми метками внутри тестового объекта, а не наблюдением работающей системы.
\ntype должен содержать named tag. Не выводите его из соседних полей. В примере order-ready — это фиксированная метка значения, а amountMinor и currency — дополнительные поля с собственной единицей и форматом. Если адаптер заменяет tag числом или оставляет его пустым, shape больше нельзя считать сохранённым.
error должен описывать режим завершения. Удобная минимальная форма — semantics, code и retry. Значение code: null означает отсутствие кода в данном envelope. Оно не означает «в системе ошибок нет». Значение retry: not-requested не запускает повтор и не обещает, что повтор безопасен. Для этого нужны отдельные правила идемпотентности.
time должен называть basis и обе границы интервала. Fixed logical ticks подходят для проверки порядка внутри заранее заданной записи. Они не являются миллисекундами. Если контракт требует наблюдаемую длительность, ему нужны источник измерения, единицы, точка начала и точка окончания. Нельзя подставить текущие часы, чтобы получить зелёный результат.
Три оси проверяются отдельно. Ошибка не сообщает тип значения. Числовое поле не задаёт единицу времени. Наличие timestamp не подтверждает, что операция завершилась успешно. Такое разделение кажется избыточным только до первого неоднозначного отказа.
\nНиже приведён ограниченный JavaScript-пример. Он проверяет только объект в памяти и возвращает причину остановки. Он не запускает PHP или D, не вызывает сеть, не читает часы, не измеряет производительность и не подтверждает поведение сервиса. Его задача — показать форму fail-closed проверки.
\nconst 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 и формат сериализации в этот объект не входят.
В реальном коде такой boundary нужно реализовать в согласованном контракте и покрыть тестами конкретного продукта. Нельзя скопировать функцию и объявить интеграцию проверенной. Учебный объект специально маленький: он помогает увидеть missing field и смешанную семантику, но не заменяет контракт API.
\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-причину владельцу контракта |
Положительный объект удобен, но он почти ничего не говорит о дисциплине контракта. Настоящая проверка начинается с испорченной записи. Удалите schemaVersion. Ожидаемый результат — stop-incomplete-contract. Не подставляйте текущую версию автоматически: иначе тест перестанет замечать несовместимый участник.
Замените у JavaScript-участника errorSemantics на thrown-value. Ожидаемый результат — stop-incomparable-adapter. Проверка не должна оборачивать исключение в envelope задним числом. Такое преобразование может быть правильным решением продукта, но тогда оно должно быть отдельным адаптером с названными правилами, а не скрытой операцией review.
Измените time.basis на wall-clock и оставьте closed: null. Ожидаемый результат — stop-undetermined-time-boundary. Нельзя сказать «интервал неизвестен, но примерно короткий». В этом объекте нет факта, который поддерживает такую оценку.
Такой путь защищает и от незаметной нормализации. Coercion может сделать данные удобнее для одного consumer, но скрыть различие между целым числом и tagged value. Если преобразование нужно, его надо назвать, версионировать и проверить как новую границу. Молчаливое приведение не является доказательством совместимости.
\nКонтрактная матрица не делает разные языки одинаковыми. Она не описывает сборщик мусора, правила приведения типов, исключения, ABI, сериализатор или планировщик. Эти свойства могут влиять на интеграцию и требуют отдельных источников и тестов. Матрица только не даёт спрятать их за одинаковым полем.
\nДаже официальный документ языка отвечает на вопрос о данном языке, а не о совместимости трёх систем. Например, строгая типизация PHP может изменить момент отказа, completion record ECMAScript описывает семантику спецификации JavaScript, а D документирует собственную обработку ошибок. Ни один из этих фактов сам по себе не доказывает общий API.
\nМатрица также не проверяет бизнес-смысл суммы, права пользователя, повторную доставку сообщения или транзакцию. Для них нужны свои поля, владельцы и отрицательные сценарии. Если boundary не может выразить важное условие, нельзя считать его достаточным только потому, что все участники прошли текущую проверку.
\nГраница готова к следующему техническому тесту, если другой инженер без устного пояснения может восстановить: какую операцию описывает запись, какой tag означает допустимое значение, как кодируется ошибка, что означает retry, какая шкала времени используется и какой результат даёт каждый отрицательный случай.
\nДополнительное условие — все три участника сохраняют contract shape без неявного приведения, а положительный результат не содержит утверждения о latency, deployment или реальном поведении среды. Если хотя бы одно поле приходится угадывать, проверка должна закончиться именованной stop-причиной. Это и есть полезный результат: команда видит границу знания до того, как похожий payload станет ошибочным действием.
\nTypeError в PHP. Поведение конкретного boundary зависит от его кода и версии.