{ "index": 56, "slug": "editorial-2026-06-mechanism-multi-runtime", "title": "Один payload, три runtime: как сохранить смысл на границе", "excerpt": "Похожая структура данных не делает PHP, JavaScript и D взаимозаменяемыми. Разбираем контракт type, error и time, fail-closed проверку и границы вывода.", "contentHtml": "

Наблюдаемый симптом — один и тот же заказ получает разные решения. В одном заказе три участника видят почти одинаковый JSON: amount, currency и status. PHP считает status=ready успешным завершением. JavaScript проверяет только наличие строки и продолжает обработку. D ждёт отдельный код результата и оставляет операцию незавершённой. На экране это один payload, но решения уже расходятся.

\n

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

\n

Здесь полезно проверять не сходство объектов, а сохранение смысла. Минимальная граница состоит из версии схемы, именованного типа значения, режима ошибки и шкалы времени. Если хотя бы одна ось не описана, адаптер должен остановиться с конкретной причиной. Такой подход не делает языки одинаковыми; он показывает, в каком месте они перестают быть сопоставимыми.

\n

Сначала отделим форму от смысла

\n

JSON описывает синтаксическую форму обмена, но не решает, что означает поле amount или можно ли повторить операцию. Число 4200 может быть суммой в копейках, лимитом или идентификатором. Строка ready может быть именем состояния, текстом интерфейса или значением, которое случайно прошло нестрогое сравнение. Одинаковый shape не является доказательством одинакового поведения.

\n

Поэтому полезная запись на границе называет смысл явно. В учебном примере value.tag фиксирует тип полезной нагрузки, amountMinor — целое число в минимальных денежных единицах, а currency — код валюты. Это проектные решения конкретного примера, а не свойства PHP, JavaScript или D. Их нужно закрепить в API-схеме и тестах своего продукта.

\n

Версия схемы отвечает на вопрос «какую форму мы сейчас проверяем». Она не заменяет версию runtime и библиотеки. Если PHP меняет правила приведения скаляров, а JavaScript или D иначе представляют ошибку, одна версия JSON не устраняет различие. Версия нужна, чтобы не подменять новый договор старым объектом; остальные зависимости проверяются отдельно.

\n
\"Матрица
Четыре строки отвечают на четыре разных вопроса: назван ли тип значения, описан ли режим ошибки, определена ли шкала времени и выполнено ли точное сопоставление. Красная точка означает остановку проверки, а не дефект конкретного языка.
\n

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

\n

type должен быть именованным тегом, а не выводом из соседних полей. Без него адаптер не знает, является ли 4200 суммой или лимитом. Тег также не заменяет проверку содержимого: после order-ready всё равно нужно проверить диапазон суммы, код валюты и обязательные поля.

\n

error описывает не текст сообщения, а режим завершения. В примере есть semantics, code и retry. Пара code: null и semantics: named-envelope означает только отсутствие кода в этой записи. Она не доказывает, что в системе не было ошибки. Поле retry сообщает намерение или результат политики, но не делает повтор безопасным без ключа идемпотентности и правила побочных эффектов.

\n

time должен назвать шкалу и обе границы интервала. В локальном примере используются фиксированные логические такты: по ним можно проверить порядок opened <= closed. Это не миллисекунды и не измерение производительности. Для latency понадобятся источник часов, единицы измерения, точки старта и окончания, а также правило, где именно начинается операция.

\n

adapter фиксирует перевод между внутренним представлением и этим envelope. Участник не может объявить себя совместимым по одному имени: проверяются версия, tag, режим ошибки, шкала времени и способ mapping. Если в одном месте происходит coercion, это отдельное правило преобразования с тестами, а не «точное» сопоставление.

\n

Что действительно различается в runtime

\n

Официальная документация PHP прямо описывает важную ловушку: по умолчанию скалярные значения могут быть приведены к объявленному типу, а declare(strict_types=1) меняет проверку для вызовов из конкретного файла. Несовпадение может закончиться TypeError. Поэтому PHP-тип параметра нельзя автоматически считать описанием внешнего JSON-контракта: поведение зависит от места вызова и от того, где стоит граница сериализации.

\n

Спецификация ECMAScript описывает language types и completion records JavaScript. Это модель выполнения программы, а не готовый HTTP-envelope с полями code и retry. Преобразование исключения или результата в такой envelope — решение адаптера. Его нельзя приписать самому JSON или назвать общим свойством всех трёх runtime.

\n

Спецификация D описывает собственную модель ошибок и раскрутки стека. Она помогает понять поведение D-кода внутри его среды, но не задаёт внешний договор с PHP и JavaScript. На транспортной границе нужно отдельно решить, какие ошибки становятся error.code, что происходит с незавершённой операцией и кто может инициировать повтор.

\n

Это различие важно для расследования. Фраза «язык вернул ошибку» слишком широка. Нужно записать наблюдаемый слой: тип значения до сериализации, байты или JSON на транспорте, результат десериализации, решение адаптера и побочный эффект операции. Только так можно понять, где исчезло поле или возникло неявное приведение.

\n

Воспроизводимая fail-closed проверка

\n

Ниже — самостоятельный пример для Node.js 18+. Он проверяет объект в памяти и не вызывает сеть, часы, PHP или D. Все значения внутри него учебные. Смысл функции в другом: неполная запись получает именованную причину остановки, а положительный результат появляется только при полном наборе независимых признаков.

\n
const validRecord = {\n  schemaVersion: 'boundary-1',\n  operation: 'create-order',\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 checkEnvelope(record, participants) {\n  if (!record || record.schemaVersion !== 'boundary-1' ||\n      record.operation !== 'create-order') {\n    return 'stop-incomplete-contract';\n  }\n  const requiredModels = new Set(['php', 'javascript', 'd']);\n  const actualModels = new Set(participants.map((item) => item.model));\n  if (actualModels.size !== 3 ||\n      [...requiredModels].some((model) => !actualModels.has(model))) {\n    return 'stop-incomplete-adapter-set';\n  }\n  if (record.value?.tag !== 'order-ready' ||\n      !Number.isInteger(record.value?.amountMinor) ||\n      typeof record.value?.currency !== 'string') {\n    return 'stop-incomplete-value';\n  }\n  if (record.error?.semantics !== 'named-envelope' ||\n      !Object.hasOwn(record.error, 'code') ||\n      typeof record.error.retry !== 'string') {\n    return 'stop-incomplete-error';\n  }\n  if (record.time?.basis !== 'fixed-logical-ticks' ||\n      !Number.isInteger(record.time.opened) ||\n      !Number.isInteger(record.time.closed) ||\n      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\nconst copy = () => JSON.parse(JSON.stringify(validRecord));\nconsole.log(checkEnvelope(validRecord, adapters));\nconst unknownTag = copy();\nunknownTag.value.tag = 'order-paid';\nconsole.log(checkEnvelope(unknownTag, adapters));\nconst mismatchedAdapter = adapters.map((item) => ({ ...item }));\nmismatchedAdapter[1].valueTag = 'order-paid';\nconsole.log(checkEnvelope(validRecord, mismatchedAdapter));\n\n// accepted-fixed-contract\n// stop-incomplete-value\n// stop-incomparable-adapter
\n

Проверка начинается с операции и набора участников, поэтому три записи одного и того же runtime не проходят как «три среды». Затем она валидирует значение, ошибку и время по отдельности. Вызов Object.hasOwn не даёт превратить отсутствие кода в неявный default. На последнем шаге адаптеры сравниваются с записью, а не друг с другом: взаимное совпадение трёх одинаково ошибочных переводов не считается доказательством.

\n

Пример можно запустить, сохранив код в чистый файл и выполнив node checker.mjs. Для настоящего API следует заменить учебную запись схемой продукта, добавить проверку неизвестных полей и зафиксировать правила сериализации. Результат функции не является сертификатом совместимости: он показывает, что именно проверено и где проверка отказалась делать вывод.

\n

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

\n
Минимальная карта расследования неоднозначного payload
СимптомВероятная причинаПроверкаДействие
Одинаковый shape даёт разные решенияНе названы tag, версия или единицы полейСверить обязательные поля до бизнес-веткиРасширить envelope; не выводить смысл из shape
Один участник бросает ошибку, другие возвращают объектСмешаны режимы завершенияСопоставить semantics, code и момент завершенияЗадокументировать адаптер или остановить mapping
После таймаута создаётся второй заказretry есть, а идемпотентность не определенаПовторить вызов с тем же ключом на тестовой записиЗапретить автоматический повтор до правила эффекта
Отчёт говорит «быстрее», но метрики нетЛогические такты приняты за latencyПроверить basis, часы, единицы и границыУдалить вывод о скорости или завести отдельное измерение
Неполный объект считается успешнымВалидатор подставляет defaultУдалить поле и проверить точную stop-причинуСделать обязательность явной и покрыть missing-case
\n

Отрицательный путь должен быть частью договора

\n

Положительная запись показывает только счастливую ветку. Дисциплину проверяют изменения, которые инженер обязан отвергнуть. Удалите schemaVersion или замените operation: функция возвращает stop-incomplete-contract. Она не угадывает версию по текущей сборке и не пытается «помочь» вызывающему коду.

\n

Замените value.tag на неизвестное значение. Ожидаемая причина — stop-incomplete-value: запись больше не соответствует выбранному типу. Чтобы проверить несовместимый адаптер, измените у JavaScript-участника valueTag. Тогда функция вернёт stop-incomparable-adapter. То же происходит, если JavaScript сообщает thrown-value вместо named-envelope. Нельзя объявить эти варианты равными только потому, что оба заканчиваются словом «ошибка».

\n

Поставьте time.closed: null или поменяйте basis на wall-clock. Результат — stop-undetermined-time-boundary. Если нужна настоящая длительность, добавьте отдельный контракт с единицами и источником измерения. Не переводите условный такт в миллисекунды задним числом.

\n

Проверьте также дубликат модели: замените D-участника вторым PHP-участником. Функция вернёт stop-incomplete-adapter-set. Это маленькая деталь, но без неё матрица доказывает лишь три строки данных, а не участие трёх заявленных runtime. Такой отрицательный тест должен жить рядом с положительным и запускаться на каждое изменение схемы.

\n

Как встроить проверку в реальный обмен

\n
  1. Назовите операцию и владельца её бизнес-смысла. «Три языка совместимы» — слишком широкое утверждение для теста.
  2. Опишите версию схемы, tag, единицы чисел, обязательные поля, допустимый null и поведение неизвестных полей.
  3. Разделите результат и ошибку. Для каждого error code укажите, сохранён ли эффект, разрешён ли повтор и кто принимает решение.
  4. Выберите одну шкалу времени для каждого измерения. Порядковые метки, календарные даты и latency не смешивайте в одном поле.
  5. Зафиксируйте вход и выход каждого адаптера. Логируйте идентификатор операции, но не подменяйте им доказательство корректности.
  6. Запустите положительный случай и минимум четыре отрицательных: неполная схема, неизвестный tag, смешанная ошибка и незакрытый интервал.
  7. Проверьте побочные эффекты отдельно: повторная доставка, транзакция, дедупликация и восстановление после таймаута не следуют из формы JSON.
  8. Только после этого подключайте transport, конкретные версии runtime и наблюдаемость. Результат локального теста не переносите на production без отдельного измерения.
\n

Ограничения применимости

\n

Матрица проверяет согласованность выбранного envelope, а не эквивалентность языков. Она не описывает сборщик мусора, правила ABI, сериализатор, планировщик, права, транзакции, порядок доставки сообщений или лимиты сети. Каждое из этих свойств может изменить результат и требует собственной проверки.

\n

Даже официальный документ языка отвечает на вопрос о языке, а не о вашем API. PHP может привести скаляр до вызова функции; JavaScript может завершить функцию значением или исключением; D использует свою модель ошибок. Между этим поведением и внешним JSON находится ваш код. Именно его контракт и тесты должны объяснить, что увидит соседний участник.

\n

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

\n

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

\n

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

\n

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

\n

Дополнительно должны выполняться три условия: каждый runtime проходит через явный адаптер; преобразования записаны и покрыты тестом; положительный результат не утверждает latency, deployment или успешную транзакцию, если эти свойства отдельно не наблюдались. Если одно поле приходится угадывать, правильный результат проверки — отказ с именем причины, а не зелёная строка для удобства отчёта.

\n

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

\n" }