From 7fd022cc3c64d750841dd2110f86ac34a913c95b Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:09:38 -0600 Subject: [PATCH] Serve client_compatibility and supported_sdk_versions from native (#1005) Published PHP 2.11.8 advertises both in cluster info; native left them out and named them in native_unqualified. The CLI reads client_compatibility and skips every check while it is absent. Native now serves both from one table of its installed-qualified ranges (sdk-php >=3.8.1,<4.0.0; sdk-python >=2.13.0,<3.0.0; sdk-rust >=4.10.3,<5.0.0; cli >=2.0.0,<3.0.0), so they can never disagree. The client_compatibility block cites only the protocols native serves beside it: the auth composition contract, the control plane with its request contract, and worker protocol 1.20, read from the same values. It does not cite PHP's skew refusal matrix or external execution contracts, which native does not publish and the CLI would check. The cluster-capabilities fixture pins native's block and both implementations' ranges, with each native bound's qualification source. The contract also checks that each client's two ranges agree and that native cites only served protocols; unit tests reject native 3.0.5's missing block, a PHP-copied block, disagreeing ranges and an unknown implementation. Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-port.md | 7 +- rust/src/runtime/cluster_info.rs | 106 +++++++++++++++- rust/src/runtime/http.rs | 6 +- rust/tests/execution/activity_results.rs | 7 +- .../cluster-capabilities-contract.mjs | 32 +++++ .../cluster-capabilities-probe.php | 13 ++ .../ServerParity/cluster-capabilities.json | 114 ++++++++++++++++-- ...rParityClusterCapabilitiesContractTest.mjs | 61 ++++++++++ 8 files changed, 329 insertions(+), 17 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 073d9e4ea..9d7174d50 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -337,9 +337,10 @@ contract fixture before its correction. `cluster-capabilities` fixture qualifies the activity result read against published 2.11.7. Native advertises no manifest it does not publish and no response compression. `native_unqualified` names each member it still - leaves out: `supported_sdk_versions`, `client_compatibility`, `topology`, - the diagnostics view, the external payload transaction limit and the - conformance manifests. + leaves out: `topology`, the diagnostics view, the external payload + transaction limit and the conformance manifests. Since server#1005 (3.1.0) + native serves `supported_sdk_versions` and `client_compatibility` from one + source with its own qualified ranges, citing only the protocols it serves. - **Fairness within a priority tier** (October 31, closed). Published PHP tries each batch of a poll's candidates (10 at the default `max_tasks_per_poll`) in `TaskFairnessScheduler` order: fairness classes diff --git a/rust/src/runtime/cluster_info.rs b/rust/src/runtime/cluster_info.rs index fa5617c5f..fa7f787ba 100644 --- a/rust/src/runtime/cluster_info.rs +++ b/rust/src/runtime/cluster_info.rs @@ -127,12 +127,68 @@ pub(super) fn discovery() -> Value { "task_repair_path": "/api/system/repair"}) } +/// Native-qualified client ranges: the single source of both +/// `supported_sdk_versions` and `client_compatibility.clients`, so the two can +/// never disagree (server#1005). Each lower bound is the oldest release that +/// passed installed acceptance against native; the cluster-capabilities +/// fixture records the source of each. The upper bound is the next major. +const CLIENT_RANGES: [(&str, &str, &str); 4] = [ + ("php", "sdk-php", ">=3.8.1,<4.0.0"), + ("python", "sdk-python", ">=2.13.0,<3.0.0"), + ("rust", "sdk-rust", ">=4.10.3,<5.0.0"), + ("cli", "cli", ">=2.0.0,<3.0.0"), +]; + +/// `supported_sdk_versions`, keyed as published PHP keys it. +pub(super) fn supported_sdk_versions() -> Value { + Value::Object( + CLIENT_RANGES + .iter() + .map(|(key, _, range)| ((*key).to_owned(), json!(range))) + .collect(), + ) +} + +/// `client_compatibility` (published PHP's `ClientCompatibility::info`, schema +/// version 2), citing only protocols native serves: the auth composition +/// contract, the control plane with its request contract, and the worker +/// protocol. Their identities are read from the values cluster info serves, so +/// the block cannot cite one native does not. PHP's skew refusal matrix and +/// external execution contracts are not published by native, so they are not +/// cited: the CLI checks every protocol the block requires. +pub(super) fn client_compatibility(auth_composition: &Value, request_contract: &Value) -> Value { + let identity = |contract: &Value| json!({"schema": contract["schema"], "version": contract["version"]}); + let served = ["auth_composition.version", "control_plane.version", "control_plane.request_contract"]; + let mut clients = serde_json::Map::new(); + // PHP's order: sdk-php, cli, sdk-python, sdk-rust. + for name in ["sdk-php", "cli", "sdk-python", "sdk-rust"] { + let (_, _, range) = CLIENT_RANGES.iter().find(|(_, client, _)| *client == name).expect("every client has a range"); + let mut requires: Vec<&str> = served.to_vec(); + if name != "cli" { + requires.push("worker_protocol.version"); + } + clients.insert(name.to_owned(), json!({"supported_versions": range, "requires": requires})); + } + json!({ + "schema": "durable-workflow.v2.client-compatibility", + "version": 2, + "authority": "protocol_manifests", + "top_level_version_role": "informational", + "fail_closed": true, + "required_protocols": { + "auth_composition": identity(auth_composition), + "control_plane": {"version": "2", "header": "X-Durable-Workflow-Control-Plane-Version", + "request_contract": identity(request_contract)}, + "worker_protocol": {"version": super::WORKER_PROTOCOL_VERSION, "header": "X-Durable-Workflow-Protocol-Version"}, + }, + "clients": clients, + }) +} + /// Members published PHP's cluster info carries that native leaves out, each /// with the reason. pub(super) fn unqualified() -> Value { json!({ - "supported_sdk_versions": "Native has not qualified SDK version ranges of its own.", - "client_compatibility": "Native publishes no client compatibility matrix yet.", "topology": "Native runs as one process role and publishes no topology manifest.", "discovery.diagnostics_query": "Native serves no include=diagnostics view.", "discovery.diagnostics_path": "Native serves no include=diagnostics view.", @@ -165,4 +221,50 @@ mod tests { fn the_version_is_never_empty() { assert!(!version().is_empty()); } + + fn served_compatibility() -> Value { + let auth: Value = serde_json::from_str(include_str!("auth-composition.v1.json")).unwrap(); + client_compatibility(&auth, &super::super::visibility::request_contract()) + } + + #[test] + fn supported_sdk_versions_and_client_compatibility_never_disagree() { + // server#1005: one source, so every client's range is the same in both. + let compatibility = served_compatibility(); + let ranges = supported_sdk_versions(); + for (key, client) in [("php", "sdk-php"), ("python", "sdk-python"), ("rust", "sdk-rust"), ("cli", "cli")] { + assert_eq!(compatibility["clients"][client]["supported_versions"], ranges[key], "{client}"); + } + assert_eq!(ranges.as_object().unwrap().len(), 4); + assert_eq!(compatibility["clients"].as_object().unwrap().len(), 4); + assert_eq!(ranges, json!({"php": ">=3.8.1,<4.0.0", "python": ">=2.13.0,<3.0.0", + "rust": ">=4.10.3,<5.0.0", "cli": ">=2.0.0,<3.0.0"})); + } + + #[test] + fn client_compatibility_cites_only_protocols_native_serves() { + let compatibility = served_compatibility(); + assert_eq!(compatibility["authority"], "protocol_manifests"); + assert_eq!(compatibility["fail_closed"], true); + let required = compatibility["required_protocols"].as_object().unwrap(); + let mut keys: Vec<&str> = required.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!(keys, ["auth_composition", "control_plane", "worker_protocol"]); + // The identities are the ones cluster info serves beside the block. + assert_eq!(required["auth_composition"], json!({"schema": "durable-workflow.v2.auth-composition.contract", "version": 1})); + assert_eq!(required["control_plane"]["request_contract"], + json!({"schema": "durable-workflow.v2.control-plane-request.contract", "version": 1})); + assert_eq!(required["worker_protocol"], json!({"version": super::super::WORKER_PROTOCOL_VERSION, + "header": "X-Durable-Workflow-Protocol-Version"})); + // No external execution contract is cited anywhere: native publishes none. + assert!(!compatibility.to_string().contains("external_"), "{compatibility}"); + // Every client requirement resolves to a cited protocol member. + for (client, entry) in compatibility["clients"].as_object().unwrap() { + for requirement in entry["requires"].as_array().unwrap() { + let (protocol, member) = requirement.as_str().unwrap().split_once('.').unwrap(); + assert!(required.get(protocol).and_then(|value| value.get(member)).is_some(), + "{client} requires {protocol}.{member}, which the block does not cite"); + } + } + } } diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 90dabf957..f3fc180d3 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -1079,13 +1079,17 @@ async fn cluster( let namespace = runtime.in_namespace(&discovery_namespace(&headers, &uri)).namespace_discovery().await?; let limits = super::payload_discovery::limits()?; let structural_limits = runtime.cluster_structural_limits(&metrics_config(&runtime)).await?; + let request_contract = super::visibility::request_contract(); + let client_compatibility = super::cluster_info::client_compatibility(&auth_composition, &request_contract); Ok(Json( json!({"version": super::cluster_info::version(), "implementation": "rust", "development": super::cluster_info::build_identity().0, "capabilities": super::cluster_info::capabilities(), "structural_limits": structural_limits, "discovery": super::cluster_info::discovery(), "native_unqualified": super::cluster_info::unqualified(), "server_id": super::payload_discovery::server_id(), "default_namespace": "default", "namespace": namespace, "limits": limits, - "control_plane": {"version": "2", "request_contract": super::visibility::request_contract()}, "worker_protocol": {"version": super::WORKER_PROTOCOL_VERSION, "server_capabilities": capabilities()}, + "supported_sdk_versions": super::cluster_info::supported_sdk_versions(), + "client_compatibility": client_compatibility, + "control_plane": {"version": "2", "request_contract": request_contract}, "worker_protocol": {"version": super::WORKER_PROTOCOL_VERSION, "server_capabilities": capabilities()}, "auth_composition_contract": auth_composition, "principal_attribution_contract": principal_attribution, "service_catalog_read_contract": service_catalog, diff --git a/rust/tests/execution/activity_results.rs b/rust/tests/execution/activity_results.rs index 2c9270943..86a50a276 100644 --- a/rust/tests/execution/activity_results.rs +++ b/rust/tests/execution/activity_results.rs @@ -129,7 +129,12 @@ async fn activity_result_read_preserves_encoded_values_and_closed_parent_history assert_eq!(info["structural_limits"]["pending_signal_count"], json!(5000), "{info}"); assert_eq!(info["discovery"]["task_repair_path"], json!("/api/system/repair"), "{info}"); assert!(info["native_unqualified"]["topology"].is_string(), "{info}"); - assert!(info.get("supported_sdk_versions").is_none(), "{info}"); + // Native's own qualified ranges, from the same source as + // client_compatibility (server#1005). + assert_eq!(info["supported_sdk_versions"]["rust"], json!(">=4.10.3,<5.0.0"), "{info}"); + assert_eq!(info["client_compatibility"]["clients"]["sdk-rust"]["supported_versions"], + info["supported_sdk_versions"]["rust"], "{info}"); + assert!(info["native_unqualified"].get("client_compatibility").is_none(), "{info}"); reopened.close().await; database.remove().await; } diff --git a/scripts/conformance/server-parity/cluster-capabilities-contract.mjs b/scripts/conformance/server-parity/cluster-capabilities-contract.mjs index 3dbfa5380..0b5cb2999 100644 --- a/scripts/conformance/server-parity/cluster-capabilities-contract.mjs +++ b/scripts/conformance/server-parity/cluster-capabilities-contract.mjs @@ -40,6 +40,38 @@ export function checkClusterCapabilities(fixture, observation) { } const {implementation, ...rest} = state; assert.ok(implementation === null || implementation === 'rust', 'published PHP or native'); + checkClientManifests(rest, implementation); assert.deepStrictEqual(rest, expectedFor(definition, implementation), `cluster capabilities (${implementation ?? 'php'})`); return projection; } + +// The protocols native serves beside its client_compatibility block, and so +// the only ones the block may cite (server#1005). The CLI checks every +// protocol the block requires. +const NATIVE_SERVED_PROTOCOLS = ['auth_composition', 'control_plane', 'worker_protocol']; + +/// Independent of the reviewed values: each client's range is the same in +/// supported_sdk_versions and client_compatibility, and native's block cites +/// only protocols native serves, with every client requirement resolving. +export function checkClientManifests(state, implementation) { + const ranges = state.client_ranges; + assert.ok(ranges && typeof ranges === 'object', 'client ranges recorded'); + for (const [client, {supported_sdk_versions: sdk, client_compatibility: matrix}] of Object.entries(ranges)) { + assert.ok(typeof sdk === 'string' && sdk !== '', `${client}: supported_sdk_versions range`); + assert.equal(matrix, sdk, `${client}: client_compatibility and supported_sdk_versions agree`); + } + if (implementation !== 'rust') return; + const block = state.native_client_compatibility; + assert.ok(block && typeof block === 'object', 'native client_compatibility block'); + assert.equal(block.authority, 'protocol_manifests', 'native compatibility authority'); + const required = block.required_protocols; + assert.deepStrictEqual(Object.keys(required).sort(), NATIVE_SERVED_PROTOCOLS, 'native cites only protocols it serves'); + assert.ok(!JSON.stringify(block).includes('external_'), 'native cites no external execution contract'); + for (const [client, entry] of Object.entries(block.clients)) { + for (const requirement of entry.requires) { + const [protocol, member] = requirement.split('.'); + assert.ok(required[protocol] && Object.hasOwn(required[protocol], member), + `${client} requires ${requirement}, which the block does not cite`); + } + } +} diff --git a/scripts/conformance/server-parity/cluster-capabilities-probe.php b/scripts/conformance/server-parity/cluster-capabilities-probe.php index 56d5b0d99..bcc221729 100644 --- a/scripts/conformance/server-parity/cluster-capabilities-probe.php +++ b/scripts/conformance/server-parity/cluster-capabilities-probe.php @@ -53,6 +53,19 @@ function finishHttpClusterCapabilities(Client $admin, array $fixture, string $wo static fn (string $member): bool => array_key_exists($member, $info), $definition['php_only_members'])), 'native_unqualified' => array_keys($info['native_unqualified'] ?? [])]; sort($state['native_unqualified']); + // Each client's range as both members state it (server#1005): the two + // must agree. Native's full client_compatibility block is pinned too. + $compatibility = is_array($info['client_compatibility'] ?? null) ? $info['client_compatibility'] : []; + $state['client_ranges'] = []; + foreach (['php' => 'sdk-php', 'python' => 'sdk-python', 'rust' => 'sdk-rust', 'cli' => 'cli'] as $key => $client) { + $state['client_ranges'][$key] = [ + 'supported_sdk_versions' => $info['supported_sdk_versions'][$key] ?? null, + 'client_compatibility' => $compatibility['clients'][$client]['supported_versions'] ?? null, + ]; + } + if (($info['implementation'] ?? null) === 'rust') { + $state['native_client_compatibility'] = $info['client_compatibility'] ?? 'absent'; + } $empty = ['codec' => 'avro', 'blob' => $definition['empty_blob']]; $id = $workflowId.'-cc-run'; diff --git a/tests/Fixtures/ServerParity/cluster-capabilities.json b/tests/Fixtures/ServerParity/cluster-capabilities.json index 059b1e675..9b63ef0ef 100644 --- a/tests/Fixtures/ServerParity/cluster-capabilities.json +++ b/tests/Fixtures/ServerParity/cluster-capabilities.json @@ -190,6 +190,24 @@ "message": "Activity not found in this run." } } + }, + "client_ranges": { + "php": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + }, + "python": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + }, + "rust": { + "supported_sdk_versions": ">=2.0.0,<4.0.0", + "client_compatibility": ">=2.0.0,<4.0.0" + }, + "cli": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + } } }, "native_differences": { @@ -293,14 +311,6 @@ "value": "{absent}", "reason": "Native serves no include=diagnostics view." }, - "present.supported_sdk_versions": { - "value": false, - "reason": "Native leaves this member out and names it in native_unqualified." - }, - "present.client_compatibility": { - "value": false, - "reason": "Native leaves this member out and names it in native_unqualified." - }, "present.topology": { "value": false, "reason": "Native leaves this member out and names it in native_unqualified." @@ -327,21 +337,105 @@ }, "native_unqualified": { "value": [ - "client_compatibility", "conformance_manifests", "discovery.diagnostics_path", "discovery.diagnostics_query", "structural_limits.external_payload_transaction_bytes", - "supported_sdk_versions", "topology" ], "reason": "Native names each member it leaves out." + }, + "client_ranges": { + "value": { + "php": { + "supported_sdk_versions": ">=3.8.1,<4.0.0", + "client_compatibility": ">=3.8.1,<4.0.0" + }, + "python": { + "supported_sdk_versions": ">=2.13.0,<3.0.0", + "client_compatibility": ">=2.13.0,<3.0.0" + }, + "rust": { + "supported_sdk_versions": ">=4.10.3,<5.0.0", + "client_compatibility": ">=4.10.3,<5.0.0" + }, + "cli": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + } + }, + "reason": "Native states the ranges its installed qualification supports, from one source for both members (server#1005). sdk-php >=3.8.1: 3.8.0 lost activity claims with heartbeat_timeout under lease maintenance (server#985), fixed in 3.8.1; 3.8.3 passed installed acceptance against native 3.0.4 (https://github.com/durable-workflow/sdk-php/pull/243#issuecomment-6612840439). sdk-python >=2.13.0: 2.13.0 passed installed acceptance against native 3.0.1 (https://github.com/durable-workflow/sdk-python/issues/228, sdk-python#168). sdk-rust >=4.10.3: 4.10.2 failed native describe parity (server#967, fixed by server#968 in 3.0.2); 4.10.3 declares and is qualified on native from 3.0.2 (https://github.com/durable-workflow/sdk-rust/pull/160, harness sdk-rust#155). cli >=2.0.0,<3.0.0: the Rust CLI ships as a 2.x minor (cli#72); its raw worker commands need native 3.1.0 (server#1001). Each upper bound is the next major." } }, "native_additions": { "capabilities.child_workflow_start_turn": { "value": true, "reason": "Native serves start_child_workflow await: \"started\", a parent turn when the child start commits (Server #966); published PHP 2.x has no such mode." + }, + "native_client_compatibility": { + "value": { + "schema": "durable-workflow.v2.client-compatibility", + "version": 2, + "authority": "protocol_manifests", + "top_level_version_role": "informational", + "fail_closed": true, + "required_protocols": { + "auth_composition": { + "schema": "durable-workflow.v2.auth-composition.contract", + "version": 1 + }, + "control_plane": { + "version": "2", + "header": "X-Durable-Workflow-Control-Plane-Version", + "request_contract": { + "schema": "durable-workflow.v2.control-plane-request.contract", + "version": 1 + } + }, + "worker_protocol": { + "version": "1.20", + "header": "X-Durable-Workflow-Protocol-Version" + } + }, + "clients": { + "sdk-php": { + "supported_versions": ">=3.8.1,<4.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract", + "worker_protocol.version" + ] + }, + "cli": { + "supported_versions": ">=2.0.0,<3.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract" + ] + }, + "sdk-python": { + "supported_versions": ">=2.13.0,<3.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract", + "worker_protocol.version" + ] + }, + "sdk-rust": { + "supported_versions": ">=4.10.3,<5.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract", + "worker_protocol.version" + ] + } + } + }, + "reason": "Native's client_compatibility (server#1005) cites only protocols it serves beside the block: the auth composition contract, the control plane with its request contract, and worker protocol 1.20. Published PHP's block also cites the skew refusal matrix and five worker_protocol external execution contracts, which native does not publish; the CLI checks every protocol the block requires, so native must not cite them." } } } diff --git a/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs b/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs index c32cd6dfa..97d6e89fd 100644 --- a/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs +++ b/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs @@ -57,3 +57,64 @@ for (const [name, mutate] of [ const raw = observation('rust'); mutate(raw.cluster_capabilities); assert.throws(() => checkClusterCapabilities(fixture, raw)); }); + +// server#1005: native serves client_compatibility and supported_sdk_versions +// from one source, citing only protocols it serves. +test('both implementations state each client range the same way in both members', () => { + for (const implementation of [null, 'rust']) { + for (const [client, columns] of Object.entries(expectedFor(definition, implementation).client_ranges)) { + assert.equal(columns.client_compatibility, columns.supported_sdk_versions, `${implementation ?? 'php'} ${client}`); + } + } + assert.equal(expectedFor(definition, null).client_ranges.rust.supported_sdk_versions, '>=2.0.0,<4.0.0', 'published PHP'); + assert.equal(expectedFor(definition, 'rust').client_ranges.rust.supported_sdk_versions, '>=4.10.3,<5.0.0', 'native'); +}); + +test('every native range bound names its qualification source', () => { + const reason = definition.native_differences.client_ranges.reason; + for (const source of ['sdk-php/pull/243', 'server#985', 'sdk-python/issues/228', 'sdk-rust/pull/160', 'cli#72']) { + assert.ok(reason.includes(source), source); + } +}); + +const phpLikeBlock = state => { + const block = state.native_client_compatibility; + block.required_protocols.worker_protocol.external_task_input_contract = {schema: 'durable-workflow.v2.external-task-input.contract', version: 1}; + block.clients.cli.requires.push('worker_protocol.external_task_input_contract'); +}; +for (const [name, mutate] of [ + ['native 3.0.5 without a client compatibility block', state => { + state.native_client_compatibility = 'absent'; + for (const columns of Object.values(state.client_ranges)) { + columns.supported_sdk_versions = null; columns.client_compatibility = null; + } + }], + ['native citing an external execution contract it does not publish, as published PHP does', phpLikeBlock], + ['native citing the skew refusal matrix', state => { + state.native_client_compatibility.required_protocols.skew_refusal_matrix = {schema: 'x', version: 1}; + }], + ['a client requirement the block does not cite', state => { + state.native_client_compatibility.clients['sdk-rust'].requires.push('control_plane.unserved'); + }], + ['a client range that differs between the two members', state => { + state.client_ranges.php.client_compatibility = '>=3.0.0,<4.0.0'; + }], + ['a native range that differs from the reviewed one in both members', state => { + state.client_ranges.rust = {supported_sdk_versions: '>=4.10.2,<5.0.0', client_compatibility: '>=4.10.2,<5.0.0'}; + }], +]) test(`the contract rejects ${name}`, () => { + const raw = observation('rust'); mutate(raw.cluster_capabilities); + assert.throws(() => checkClusterCapabilities(fixture, raw)); +}); + +test('published PHP ranges that disagree between the two members are rejected', () => { + const raw = observation(null); + raw.cluster_capabilities.client_ranges.cli.client_compatibility = '>=1.0.0,<2.0.0'; + assert.throws(() => checkClusterCapabilities(fixture, raw)); +}); + +test('an unknown implementation is refused', () => { + const raw = observation('rust'); + raw.cluster_capabilities.implementation = 'native'; + assert.throws(() => checkClusterCapabilities(fixture, raw), /published PHP or native/); +});