{ "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, но решения уже расходятся.
Цена расхождения появляется после сбоя. Клиент может повторить уже принятый заказ, worker — пропустить отказ, а оператор — связать события по совпавшим полям, хотя они относятся к разным стадиям. Попытка быстро исправить ситуацию обычно выглядит невинно: привести значение к строке, подставить код по умолчанию или включить retry. Но каждое молчаливое преобразование переносит неопределённость дальше по цепочке.
\nЗдесь полезно проверять не сходство объектов, а сохранение смысла. Минимальная граница состоит из версии схемы, именованного типа значения, режима ошибки и шкалы времени. Если хотя бы одна ось не описана, адаптер должен остановиться с конкретной причиной. Такой подход не делает языки одинаковыми; он показывает, в каком месте они перестают быть сопоставимыми.
\nJSON описывает синтаксическую форму обмена, но не решает, что означает поле amount или можно ли повторить операцию. Число 4200 может быть суммой в копейках, лимитом или идентификатором. Строка ready может быть именем состояния, текстом интерфейса или значением, которое случайно прошло нестрогое сравнение. Одинаковый shape не является доказательством одинакового поведения.
Поэтому полезная запись на границе называет смысл явно. В учебном примере value.tag фиксирует тип полезной нагрузки, amountMinor — целое число в минимальных денежных единицах, а currency — код валюты. Это проектные решения конкретного примера, а не свойства PHP, JavaScript или D. Их нужно закрепить в API-схеме и тестах своего продукта.
Версия схемы отвечает на вопрос «какую форму мы сейчас проверяем». Она не заменяет версию runtime и библиотеки. Если PHP меняет правила приведения скаляров, а JavaScript или D иначе представляют ошибку, одна версия JSON не устраняет различие. Версия нужна, чтобы не подменять новый договор старым объектом; остальные зависимости проверяются отдельно.
\ntype должен быть именованным тегом, а не выводом из соседних полей. Без него адаптер не знает, является ли 4200 суммой или лимитом. Тег также не заменяет проверку содержимого: после order-ready всё равно нужно проверить диапазон суммы, код валюты и обязательные поля.
error описывает не текст сообщения, а режим завершения. В примере есть semantics, code и retry. Пара code: null и semantics: named-envelope означает только отсутствие кода в этой записи. Она не доказывает, что в системе не было ошибки. Поле retry сообщает намерение или результат политики, но не делает повтор безопасным без ключа идемпотентности и правила побочных эффектов.
time должен назвать шкалу и обе границы интервала. В локальном примере используются фиксированные логические такты: по ним можно проверить порядок opened <= closed. Это не миллисекунды и не измерение производительности. Для latency понадобятся источник часов, единицы измерения, точки старта и окончания, а также правило, где именно начинается операция.
adapter фиксирует перевод между внутренним представлением и этим envelope. Участник не может объявить себя совместимым по одному имени: проверяются версия, tag, режим ошибки, шкала времени и способ mapping. Если в одном месте происходит coercion, это отдельное правило преобразования с тестами, а не «точное» сопоставление.
Официальная документация PHP прямо описывает важную ловушку: по умолчанию скалярные значения могут быть приведены к объявленному типу, а declare(strict_types=1) меняет проверку для вызовов из конкретного файла. Несовпадение может закончиться TypeError. Поэтому PHP-тип параметра нельзя автоматически считать описанием внешнего JSON-контракта: поведение зависит от места вызова и от того, где стоит граница сериализации.
Спецификация ECMAScript описывает language types и completion records JavaScript. Это модель выполнения программы, а не готовый HTTP-envelope с полями code и retry. Преобразование исключения или результата в такой envelope — решение адаптера. Его нельзя приписать самому JSON или назвать общим свойством всех трёх runtime.
Спецификация D описывает собственную модель ошибок и раскрутки стека. Она помогает понять поведение D-кода внутри его среды, но не задаёт внешний договор с PHP и JavaScript. На транспортной границе нужно отдельно решить, какие ошибки становятся error.code, что происходит с незавершённой операцией и кто может инициировать повтор.
Это различие важно для расследования. Фраза «язык вернул ошибку» слишком широка. Нужно записать наблюдаемый слой: тип значения до сериализации, байты или JSON на транспорте, результат десериализации, решение адаптера и побочный эффект операции. Только так можно понять, где исчезло поле или возникло неявное приведение.
\nНиже — самостоятельный пример для Node.js 18+. Он проверяет объект в памяти и не вызывает сеть, часы, PHP или D. Все значения внутри него учебные. Смысл функции в другом: неполная запись получает именованную причину остановки, а положительный результат появляется только при полном наборе независимых признаков.
\nconst 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. На последнем шаге адаптеры сравниваются с записью, а не друг с другом: взаимное совпадение трёх одинаково ошибочных переводов не считается доказательством.
Пример можно запустить, сохранив код в чистый файл и выполнив node checker.mjs. Для настоящего API следует заменить учебную запись схемой продукта, добавить проверку неизвестных полей и зафиксировать правила сериализации. Результат функции не является сертификатом совместимости: он показывает, что именно проверено и где проверка отказалась делать вывод.
| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Одинаковый shape даёт разные решения | Не названы tag, версия или единицы полей | Сверить обязательные поля до бизнес-ветки | Расширить envelope; не выводить смысл из shape |
| Один участник бросает ошибку, другие возвращают объект | Смешаны режимы завершения | Сопоставить semantics, code и момент завершения | Задокументировать адаптер или остановить mapping |
| После таймаута создаётся второй заказ | retry есть, а идемпотентность не определена | Повторить вызов с тем же ключом на тестовой записи | Запретить автоматический повтор до правила эффекта |
| Отчёт говорит «быстрее», но метрики нет | Логические такты приняты за latency | Проверить basis, часы, единицы и границы | Удалить вывод о скорости или завести отдельное измерение |
| Неполный объект считается успешным | Валидатор подставляет default | Удалить поле и проверить точную stop-причину | Сделать обязательность явной и покрыть missing-case |
Положительная запись показывает только счастливую ветку. Дисциплину проверяют изменения, которые инженер обязан отвергнуть. Удалите schemaVersion или замените operation: функция возвращает stop-incomplete-contract. Она не угадывает версию по текущей сборке и не пытается «помочь» вызывающему коду.
Замените value.tag на неизвестное значение. Ожидаемая причина — stop-incomplete-value: запись больше не соответствует выбранному типу. Чтобы проверить несовместимый адаптер, измените у JavaScript-участника valueTag. Тогда функция вернёт stop-incomparable-adapter. То же происходит, если JavaScript сообщает thrown-value вместо named-envelope. Нельзя объявить эти варианты равными только потому, что оба заканчиваются словом «ошибка».
Поставьте time.closed: null или поменяйте basis на wall-clock. Результат — stop-undetermined-time-boundary. Если нужна настоящая длительность, добавьте отдельный контракт с единицами и источником измерения. Не переводите условный такт в миллисекунды задним числом.
Проверьте также дубликат модели: замените D-участника вторым PHP-участником. Функция вернёт stop-incomplete-adapter-set. Это маленькая деталь, но без неё матрица доказывает лишь три строки данных, а не участие трёх заявленных runtime. Такой отрицательный тест должен жить рядом с положительным и запускаться на каждое изменение схемы.
null и поведение неизвестных полей.Матрица проверяет согласованность выбранного envelope, а не эквивалентность языков. Она не описывает сборщик мусора, правила ABI, сериализатор, планировщик, права, транзакции, порядок доставки сообщений или лимиты сети. Каждое из этих свойств может изменить результат и требует собственной проверки.
\nДаже официальный документ языка отвечает на вопрос о языке, а не о вашем API. PHP может привести скаляр до вызова функции; JavaScript может завершить функцию значением или исключением; D использует свою модель ошибок. Между этим поведением и внешним JSON находится ваш код. Именно его контракт и тесты должны объяснить, что увидит соседний участник.
\nУчебный пример допускает только три фиксированные модели и одну шкалу времени. В рабочем проекте может быть больше адаптеров, несколько версий схемы или асинхронная доставка. Тогда нужно версионировать набор правил и явно описать совместимость между версиями. Нельзя расширить список участников молча и сохранить старый критерий готовности.
\nНаконец, наличие error.code не доказывает, что операция безопасна для повтора. Для этого нужны идемпотентный ключ, граница фиксации эффекта и тест повторной доставки. Если такие условия не помещаются в текущую запись, вывод ограничивается проверкой формы и не распространяется на бизнес-результат.
Граница готова к интеграционному тесту, если инженер без устного пояснения может назвать операцию, версию схемы, допустимый tag, единицы каждого поля, режим ошибки, смысл retry и шкалу времени. Для каждого отрицательного случая заранее известна точная причина остановки.
\nДополнительно должны выполняться три условия: каждый runtime проходит через явный адаптер; преобразования записаны и покрыты тестом; положительный результат не утверждает latency, deployment или успешную транзакцию, если эти свойства отдельно не наблюдались. Если одно поле приходится угадывать, правильный результат проверки — отказ с именем причины, а не зелёная строка для удобства отчёта.
\nTypeError. Конкретный boundary зависит от места вызова и версии PHP.