From e28a63c7908931524df70ca9d09d34bf513d7d0d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:21:06 +0000 Subject: [PATCH] Compare bridge-adapters exactly against published 2.11.7 Published 2.11.7 accepts and deduplicates a bridge update to an SDK-worker workflow (#747) and refuses a start for an instance another namespace holds as unavailable (#777). The fixture no longer carries the published-defect answers of 2.11.1 to 2.11.5, the contract compares every step exactly, and the stored observation is the 2.11.7 recording. Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-port.md | 18 +-- .../bridge-adapters-contract.mjs | 27 +--- .../ServerParity/bridge-adapters.json | 150 +----------------- .../bridge-adapters-observation.json | 24 ++- ...ServerParityBridgeAdaptersContractTest.mjs | 62 ++++---- 5 files changed, 56 insertions(+), 225 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 7a10f8229..846b75888 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -51,25 +51,25 @@ Updated October 27, 2026. fixtures accept published PHP's typed `429 long_poll_capacity_exhausted` poll refusal, and retry a locked Composer install after a transient download failure (the two #813 merge-group failures). - - Native's v1 projection byte meter in Core's canonical unit (#770) is - prepared on `rust-325-wip/canonical-history-bytes`. It lands with the - first reference that carries #812, because `waterline-import` records - `history_size_bytes` from the reference. + - `bridge-adapters` compares exactly: published 2.11.7 accepts and + deduplicates the bridge update (#747) and refuses a foreign instance as + unavailable (#777), so the published-defect answers of 2.11.1 to 2.11.5 + are no longer accepted. - **Still refused at takeover:** workflow-to-workflow messages (`workflow_messages`, written only by embedded PHP workflows) and external payload references. PHP keeps references in about 30 table and column locations in their URI form, so taking them over is its own project; the recommendation is that the release candidate keeps refusing them. -- **Shared Server fixtures.** Native, embedded and published PHP 2.11.5 are +- **Shared Server fixtures.** Native, embedded and published PHP 2.11.7 are compared on SQLite, MySQL 8.0, MariaDB 10.11 and PostgreSQL 17. Where the published reference itself misbehaves, an explicit boundary separates it from native, and the PHP owner has an issue for each case. Recent ones: - - #777, a bridge start for an instance another namespace holds: 2.11.1 - to 2.11.4 name that namespace's type, 2.11.5 the namespace difference; - #811 (after 2.11.5) answers "unavailable"; - - #747, a bridge update to an SDK-worker workflow; - #794, workflow-bound Nexus handlers. + Answered as intended by 2.11.7, so `bridge-adapters` compares exactly: + #747 (a bridge update to an SDK-worker workflow) and #777 (a bridge start + for an instance another namespace holds). + Closed by 2.11.5: Workflow #931 (`service-calls` repeats a key from another caller again, now refused 403), and the SQLite maintenance pause for `update-validation` (#723, #726). `service-calls` still records apart diff --git a/scripts/conformance/server-parity/bridge-adapters-contract.mjs b/scripts/conformance/server-parity/bridge-adapters-contract.mjs index 5cedca1f5..64dc34a8b 100644 --- a/scripts/conformance/server-parity/bridge-adapters-contract.mjs +++ b/scripts/conformance/server-parity/bridge-adapters-contract.mjs @@ -1,19 +1,16 @@ import assert from 'node:assert/strict'; // Reviewed bridge adapter webhooks (BridgeAdapterController), recorded -// against published Server 2.11.1. The fixture's `expected` holds the whole +// against published Server 2.11.7. The fixture's `expected` holds the whole // normalized observation, with the probe's workflow prefix as `{id}`, run IDs // as labels and derived workflow IDs as `{derived-}`. Every step // compares exactly. // -// Published defect boundaries (fixture `published_defects`): a bridge update -// skips external update admission, so an update to an SDK-worker workflow is -// rejected workflow_definition_unavailable; and a start for an id another -// namespace holds names that namespace's type (published 2.11.1 to 2.11.4) -// or the namespace difference (2.11.5), not the intended unavailable -// (Server #777). At each -// defect's steps the published answers or the intended ones are accepted, as -// a set per defect; nothing else is relaxed. +// Published 2.11.1 to 2.11.5 answered two sets of steps otherwise: a bridge +// update to an SDK-worker workflow was rejected workflow_definition_unavailable +// (Server #747), and a start for an id another namespace holds named that +// namespace's type or the namespace difference (Server #777). 2.11.7 gives +// the intended answers, so neither is accepted any more. export function checkBridgeAdapters(fixture, observation, workflowId) { const state = observation.bridge_adapters; const projection = {scope: 'bridge_adapters'}; @@ -25,18 +22,6 @@ export function checkBridgeAdapters(fixture, observation, workflowId) { } const definition = fixture.bridge_adapters; const actual = JSON.parse(JSON.stringify(state).replaceAll(workflowId, '{id}')); - for (const {published: defect} of Object.values(definition.published_defects)) { - const steps = Object.keys(defect); - let published = true; - try { - for (const step of steps) assert.deepStrictEqual(actual[step], defect[step]); - } catch { - published = false; - } - if (published) { - for (const step of steps) actual[step] = definition.expected[step]; - } - } for (const [step, expected] of Object.entries(definition.expected)) { assert.deepStrictEqual(actual[step], expected, `bridge adapters ${step}`); } diff --git a/tests/Fixtures/ServerParity/bridge-adapters.json b/tests/Fixtures/ServerParity/bridge-adapters.json index e7e66463a..5a3c9c3d4 100644 --- a/tests/Fixtures/ServerParity/bridge-adapters.json +++ b/tests/Fixtures/ServerParity/bridge-adapters.json @@ -34,154 +34,6 @@ "bridge_adapters": { "workflow_type": "parity.v1.bridge", "empty_blob": "wwHioz3/VYAiNwwA", - "published_defects": { - "update_skips_external_admission": { - "issue": "https://github.com/durable-workflow/server/issues/747", - "summary": "BridgeAdapterController::updateWorkflow calls WorkflowControlPlane::update directly and skips ExternalWorkflowUpdateAdmission, which WorkflowController::update tries first, so a bridge update to a workflow served by an SDK worker is rejected workflow_definition_unavailable and recorded as UpdateRejected, and its replay is rejected again.", - "published": { - "update": { - "status": 422, - "response": { - "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", - "version": 1, - "adapter": "acme.crm:v1", - "action": "update_workflow", - "accepted": false, - "outcome": "rejected", - "reason": "workflow_definition_unavailable", - "idempotency_key": "u-2", - "target": { - "workflow_id": "{id}-bra-order", - "update_name": "bump" - }, - "workflow_id": "{id}-bra-order", - "run_id": "run-2", - "control_plane_outcome": "rejected_workflow_definition_unavailable" - } - }, - "update_again": { - "status": 422, - "response": { - "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", - "version": 1, - "adapter": "acme.crm:v1", - "action": "update_workflow", - "accepted": false, - "outcome": "rejected", - "reason": "workflow_definition_unavailable", - "idempotency_key": "u-2", - "target": { - "workflow_id": "{id}-bra-order", - "update_name": "bump" - }, - "workflow_id": "{id}-bra-order", - "run_id": "run-2", - "control_plane_outcome": "rejected_workflow_definition_unavailable" - } - }, - "history_event_types": [ - "StartAccepted", - "WorkflowStarted", - "StartAccepted", - "StartRejected", - "SignalWaitOpened", - "SignalReceived", - "SignalReceived", - "UpdateRejected", - "UpdateRejected", - "UpdateRejected" - ] - } - }, - "foreign_instance_names_type": { - "issue": "https://github.com/durable-workflow/server/issues/777", - "summary": "DefaultWorkflowControlPlane::reserveInstance checks a reserved instance's type before its namespace, so a bridge start for an id another namespace holds is refused with that namespace's durable type named, and with a different-namespace message when the types match. Native checks the namespace first and refuses a foreign instance as unavailable, naming neither.", - "published": { - "start_other_type": { - "status": 422, - "response": { - "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", - "version": 1, - "adapter": "acme.crm:v1", - "action": "start_workflow", - "accepted": false, - "outcome": "rejected", - "reason": "unknown_target", - "idempotency_key": "k-6", - "message": "Workflow instance [{id}] is reserved for durable type [parity.v1.echo] and cannot be reused for [parity.v1.bridge].", - "target": { - "workflow_id": "{id}", - "workflow_type": "parity.v1.bridge", - "task_queue": "{id}-bra-queue" - } - } - }, - "start_other_namespace": { - "status": 422, - "response": { - "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", - "version": 1, - "adapter": "acme.crm:v1", - "action": "start_workflow", - "accepted": false, - "outcome": "rejected", - "reason": "unknown_target", - "idempotency_key": "k-7", - "message": "Workflow instance [{id}] cannot be reused with a different namespace.", - "target": { - "workflow_id": "{id}", - "workflow_type": "parity.v1.echo", - "task_queue": "{id}-bra-queue" - } - } - } - } - }, - "foreign_instance_names_namespace": { - "issue": "https://github.com/durable-workflow/server/issues/777", - "summary": "Published 2.11.5's Core 2.15.1 checks a reserved instance's namespace before its type, but its BridgeAdapterController still echoes the control plane's message, so a bridge start for an id another namespace holds is refused with a different-namespace message whatever its type. Server #811 (after 2.11.5) refuses it as unavailable, naming neither the type nor the namespace.", - "published": { - "start_other_type": { - "status": 422, - "response": { - "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", - "version": 1, - "adapter": "acme.crm:v1", - "action": "start_workflow", - "accepted": false, - "outcome": "rejected", - "reason": "unknown_target", - "idempotency_key": "k-6", - "message": "Workflow instance [{id}] cannot be reused with a different namespace.", - "target": { - "workflow_id": "{id}", - "workflow_type": "parity.v1.bridge", - "task_queue": "{id}-bra-queue" - } - } - }, - "start_other_namespace": { - "status": 422, - "response": { - "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", - "version": 1, - "adapter": "acme.crm:v1", - "action": "start_workflow", - "accepted": false, - "outcome": "rejected", - "reason": "unknown_target", - "idempotency_key": "k-7", - "message": "Workflow instance [{id}] cannot be reused with a different namespace.", - "target": { - "workflow_id": "{id}", - "workflow_type": "parity.v1.echo", - "task_queue": "{id}-bra-queue" - } - } - } - } - } - }, "expected": { "applicable": true, "namespace": "{id}-bra", @@ -628,5 +480,5 @@ ] } }, - "contract": "Bridge adapter webhooks (BridgeAdapterController) as published Server 2.11.1 serves them, in their own namespace: the adapter name, payload and action refusals (422, the target redacted to its routing fields); start_workflow accepted (202), returned as a duplicate (200) under the default policy, rejected under reject_duplicate (422), with a workflow ID derived from the adapter and the idempotency key when none is given, and on the default queue for an unconfigured type; signal_workflow and update_workflow refused for an unknown workflow or a malformed target, accepted (202), and deduplicated (200) by adapter, action, idempotency key and target name. Run IDs are labels in order of first appearance and derived workflow IDs are checked against PHP's derivation. Two published defects are bounded: a bridge update to an SDK-worker workflow skips external update admission and is rejected workflow_definition_unavailable, and a start for an id another namespace holds names that namespace's type (published 2.11.1 to 2.11.4) or the namespace difference (2.11.5); for each, the published answers or the intended ones (acceptance and deduplication; a foreign instance refused as unavailable) are accepted. Embedded applications have no HTTP bridge adapters." + "contract": "Bridge adapter webhooks (BridgeAdapterController) as published Server 2.11.7 serves them, in their own namespace: the adapter name, payload and action refusals (422, the target redacted to its routing fields); start_workflow accepted (202), returned as a duplicate (200) under the default policy, rejected under reject_duplicate (422), with a workflow ID derived from the adapter and the idempotency key when none is given, and on the default queue for an unconfigured type; signal_workflow and update_workflow refused for an unknown workflow or a malformed target, accepted (202), and deduplicated (200) by adapter, action, idempotency key and target name. Run IDs are labels in order of first appearance and derived workflow IDs are checked against PHP's derivation. A bridge update to an SDK-worker workflow passes external update admission like the regular update route, so it is accepted and deduplicated, and a start for an id another namespace holds is refused as unavailable, naming neither its type nor its namespace (#747 and #777; published 2.11.1 to 2.11.5 answered both otherwise). Every step compares exactly. Embedded applications have no HTTP bridge adapters." } diff --git a/tests/Fixtures/ServerParityExpected/bridge-adapters-observation.json b/tests/Fixtures/ServerParityExpected/bridge-adapters-observation.json index ad43d7217..0a28365b6 100644 --- a/tests/Fixtures/ServerParityExpected/bridge-adapters-observation.json +++ b/tests/Fixtures/ServerParityExpected/bridge-adapters-observation.json @@ -113,7 +113,7 @@ "outcome": "rejected", "reason": "unknown_target", "idempotency_key": "k-6", - "message": "Workflow instance [pr1-bridge-adapters] is reserved for durable type [parity.v1.echo] and cannot be reused for [parity.v1.bridge].", + "message": "Workflow instance [pr1-bridge-adapters] is unavailable.", "target": { "workflow_id": "pr1-bridge-adapters", "workflow_type": "parity.v1.bridge", @@ -132,7 +132,7 @@ "outcome": "rejected", "reason": "unknown_target", "idempotency_key": "k-7", - "message": "Workflow instance [pr1-bridge-adapters] cannot be reused with a different namespace.", + "message": "Workflow instance [pr1-bridge-adapters] is unavailable.", "target": { "workflow_id": "pr1-bridge-adapters", "workflow_type": "parity.v1.echo", @@ -375,35 +375,32 @@ } }, "update": { - "status": 422, + "status": 202, "response": { "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", "version": 1, "adapter": "acme.crm:v1", "action": "update_workflow", - "accepted": false, - "outcome": "rejected", - "reason": "workflow_definition_unavailable", + "accepted": true, + "outcome": "accepted", "idempotency_key": "u-2", "target": { "workflow_id": "pr1-bridge-adapters-bra-order", "update_name": "bump" }, "workflow_id": "pr1-bridge-adapters-bra-order", - "run_id": "run-2", - "control_plane_outcome": "rejected_workflow_definition_unavailable" + "run_id": "run-2" } }, "update_again": { - "status": 422, + "status": 200, "response": { "schema": "durable-workflow.v2.bridge-adapter-outcome.contract", "version": 1, "adapter": "acme.crm:v1", "action": "update_workflow", "accepted": false, - "outcome": "rejected", - "reason": "workflow_definition_unavailable", + "outcome": "duplicate", "idempotency_key": "u-2", "target": { "workflow_id": "pr1-bridge-adapters-bra-order", @@ -411,7 +408,7 @@ }, "workflow_id": "pr1-bridge-adapters-bra-order", "run_id": "run-2", - "control_plane_outcome": "rejected_workflow_definition_unavailable" + "control_plane_outcome": "deduped_existing_command" } }, "update_undeclared": { @@ -442,8 +439,7 @@ "SignalWaitOpened", "SignalReceived", "SignalReceived", - "UpdateRejected", - "UpdateRejected", + "UpdateAccepted", "UpdateRejected" ] } diff --git a/tests/Unit/ServerParityBridgeAdaptersContractTest.mjs b/tests/Unit/ServerParityBridgeAdaptersContractTest.mjs index 5ca0866fe..81188bc5c 100644 --- a/tests/Unit/ServerParityBridgeAdaptersContractTest.mjs +++ b/tests/Unit/ServerParityBridgeAdaptersContractTest.mjs @@ -4,38 +4,35 @@ import test from 'node:test'; import {checkBridgeAdapters} from '../../scripts/conformance/server-parity/bridge-adapters-contract.mjs'; const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/bridge-adapters.json', import.meta.url))); -// The observation published Server 2.11.1 returned for this fixture. +// The observation published Server 2.11.7 returned for this fixture. const recorded = JSON.parse(readFileSync( new URL('../Fixtures/ServerParityExpected/bridge-adapters-observation.json', import.meta.url))); const id = 'pr1-bridge-adapters'; -const observation = (change = () => {}, base = recorded) => { - const state = structuredClone(base); +const observation = (change = () => {}) => { + const state = structuredClone(recorded); change(state); return {mode: 'http', bridge_adapters: state}; }; const check = state => checkBridgeAdapters(fixture, state, id); -// The intended answers: the bridge update admitted like the regular route, -// and a foreign instance refused as unavailable. -const intended = JSON.parse(JSON.stringify(fixture.bridge_adapters.expected).replaceAll('{id}', id)); -test('the published 2.11.1 recording satisfies the contract', () => { - assert.deepStrictEqual(check(observation()), {scope: 'bridge_adapters'}); -}); +// Published 2.11.1 to 2.11.5 rejected the bridge update to an SDK-worker +// workflow (Server #747) and named a foreign instance's type or namespace +// (Server #777). Those answers are no longer accepted. +const rejectedUpdate = answer => ({status: 422, response: {...answer.response, accepted: false, outcome: 'rejected', + reason: 'workflow_definition_unavailable', control_plane_outcome: 'rejected_workflow_definition_unavailable'}}); +const reservedType = `Workflow instance [${id}] is reserved for durable type [parity.v1.echo] and cannot be reused for [parity.v1.bridge].`; +const otherNamespace = `Workflow instance [${id}] cannot be reused with a different namespace.`; -test('the intended update acceptance and deduplication are accepted too', () => { - assert.deepStrictEqual(check(observation(() => {}, intended)), {scope: 'bridge_adapters'}); - assert.equal(intended.update.status, 202); - assert.equal(intended.update_again.response.control_plane_outcome, 'deduped_existing_command'); +test('the published 2.11.7 recording satisfies the contract', () => { + assert.deepStrictEqual(check(observation()), {scope: 'bridge_adapters'}); }); -test('the intended foreign-instance refusal is accepted with either update answer', () => { - const foreign = state => { - state.start_other_type = structuredClone(intended.start_other_type); - state.start_other_namespace = structuredClone(intended.start_other_namespace); - }; - assert.deepStrictEqual(check(observation(foreign)), {scope: 'bridge_adapters'}); - assert.equal(intended.start_other_type.response.message, `Workflow instance [${id}] is unavailable.`); - assert.doesNotMatch(JSON.stringify(intended.start_other_type), /parity\.v1\.echo/); +test('the recording carries the intended update and foreign-instance answers', () => { + assert.equal(recorded.update.status, 202); + assert.equal(recorded.update_again.response.control_plane_outcome, 'deduped_existing_command'); + for (const step of ['start_other_type', 'start_other_namespace']) { + assert.equal(recorded[step].response.message, `Workflow instance [${id}] is unavailable.`, step); + } }); test('embedded applications are explicitly inapplicable', () => { @@ -48,13 +45,20 @@ for (const [label, change, message] of [ ['an unimplemented route', state => { state.start = {status: 501, response: {reason: 'rust_capability_not_implemented'}}; }, /bridge adapters start/], - ['only part of the published update defect', state => { - state.update = structuredClone(intended.update); - }, /bridge adapters update_again|bridge adapters history_event_types/], + ['the published update rejection', state => { + state.update = rejectedUpdate(state.update); + state.update_again = rejectedUpdate(state.update_again); + }, /bridge adapters update/], ['an accepted update that is not deduplicated', state => { - Object.assign(state, structuredClone({update: intended.update, history_event_types: intended.history_event_types})); - state.update_again = structuredClone(intended.update); + state.update_again = structuredClone(state.update); }, /bridge adapters update_again/], + ['a foreign instance refused with its type named', state => { + state.start_other_type.response.message = reservedType; + }, /bridge adapters start_other_type/], + ['a foreign instance refused with the namespace difference', state => { + state.start_other_type.response.message = otherNamespace; + state.start_other_namespace.response.message = otherNamespace; + }, /bridge adapters start_other_type/], ['a derived workflow id from another digest', state => { state.start_derived.response.workflow_id = 'bridge-acme.crm:v1-0000'; }, /bridge adapters start_derived/], @@ -70,12 +74,6 @@ for (const [label, change, message] of [ ['reject_duplicate treated as use_existing', state => { state.start_reject_duplicate = structuredClone(state.start_again); }, /bridge adapters start_reject_duplicate/], - ['only part of the published foreign-instance defect', state => { - state.start_other_type = structuredClone(intended.start_other_type); - }, /bridge adapters start_other_namespace/], - ['a foreign instance refused with its type named', state => { - state.start_other_namespace.response.message = state.start_other_type.response.message; - }, /bridge adapters start_other_type|bridge adapters start_other_namespace/], ['a missing step', state => { delete state.signal_bad_target; }, /bridge adapters signal_bad_target/],