diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 09d06a05f..07514d356 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -42,6 +42,7 @@ on: - tests/Unit/ServerParityWorkerReplacementContractTest.mjs - tests/Unit/ServerParityOperatorMetricsContractTest.mjs - tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs + - tests/Unit/ServerParityNexusOperationsContractTest.mjs - tests/Unit/ServerParityRecordLocalActivityContractTest.mjs - tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs - tests/Unit/ServerParityActivityCancellationAckContractTest.mjs @@ -1804,7 +1805,7 @@ jobs: bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityRecordLocalActivityContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityWorkflowImportContractTest.mjs tests/Unit/ServerParityWaterlineImportContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityCancellationScopesContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityRecordLocalActivityContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityWorkflowImportContractTest.mjs tests/Unit/ServerParityWaterlineImportContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityCancellationScopesContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs tests/Unit/ServerParityNexusOperationsContractTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index c1773dec0..471dda2a4 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -82,11 +82,12 @@ Updated October 24, 2026. ([#737](https://github.com/durable-workflow/server/issues/737)). No expectation is edited to make native pass. -- **Parity surface.** Native serves 143 of the 175 API route methods in +- **Parity surface.** Native serves 145 of the 175 API route methods in published 2.11.1's route table, with message streams, update validation, activity cancellation acknowledgement, worker sessions, workflow-task - cancellation scopes, and embedded v2 and Waterline v1 workflow import. An - execution test sends each one through the native router, and the ones + cancellation scopes, caller-indexed Nexus operation reads, and embedded v2 + and Waterline v1 workflow import. An execution test sends each one through + the native router, and the ones still answering `501 rust_capability_not_implemented` must match [`unserved-php-routes.v2.11.1.txt`](../rust/tests/fixtures/unserved-php-routes.v2.11.1.txt). Unlike published PHP, native's embedded v2 import refuses a bundle whose @@ -163,8 +164,11 @@ any other unknown path or method answers PHP's own 404 or 405 - Service endpoints. (The service catalog reads from Server 2.11 are served: native never holds catalog rows, so every list is PHP's empty page and every identity its not-found answer; the `service-catalog` fixture checks 37 cases.) - - Nexus operations. - Bridge adapters. + - (Caller-indexed Nexus operation reads are served, with the + `nexus-operations` fixture. Native writes no service calls and takeover + refuses a source that holds any, so every list is PHP's empty answer; an + execution test seeds rows for the row shape and newest-first order.) ## Known gaps found in review diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index db8c7e212..a504edd29 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -1607,6 +1607,7 @@ impl Runtime { delegate!(heartbeat_activity(task_id: &str, body: Value, protocol: &str) -> Result); delegate!(activity_status(task_id: &str, body: Value, protocol: &str) -> Result); delegate!(renew_activity_lease(task_id: &str, body: Value) -> Result); + delegate!(nexus_operations(workflow_id: &str, run_id: Option<&str>, limit: i64) -> Result>); delegate!(import_existing_run(run_id: &str) -> Result>); delegate!(import_write(plan: &super::workflow_import::Plan) -> std::result::Result); delegate!(waterline_existing(workflow_id: &str, run_id: &str) -> Result); diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index de7d1b89c..fd0571f1e 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -111,6 +111,8 @@ pub fn router(runtime: Runtime) -> Router { get(super::run_observation::selected), ) .route("/api/workflows/{workflow_id}/runs", get(list_runs)) + .route("/api/workflows/{workflow_id}/nexus-operations", get(nexus_operations_current)) + .route("/api/workflows/{workflow_id}/runs/{run_id}/nexus-operations", get(nexus_operations_run)) .route("/api/workflows/{workflow_id}/cancel", post(cancel_current)) .route( "/api/workflows/{workflow_id}/terminate", @@ -775,7 +777,9 @@ fn workflow_route(rest: &str) -> Option { | [_, "runs", _, "history", "export"] | [_, "runs", _, "streams"] | [_, "runs", _, "streams", _] - | [_, "runs", _, "streams", _, "items"] => Some(READ), + | [_, "runs", _, "streams", _, "items"] + | [_, "nexus-operations"] + | [_, "runs", _, "nexus-operations"] => Some(READ), [_, "signal" | "query" | "update", _] | [_, "runs", _, "signal" | "query" | "update", _] | [_, "repair" | "request-cancellation"] @@ -1191,6 +1195,35 @@ async fn describe_current( )) } +/// `GET /api/workflows/{workflow_id}/nexus-operations`. +async fn nexus_operations_current( + Extension(runtime): Extension, + Path(workflow_id): Path, + Query(query): Query>, +) -> Result> { + nexus_operations(runtime, &workflow_id, None, query).await +} + +/// `GET /api/workflows/{workflow_id}/runs/{run_id}/nexus-operations`. +async fn nexus_operations_run( + Extension(runtime): Extension, + Path((workflow_id, run_id)): Path<(String, String)>, + Query(query): Query>, +) -> Result> { + nexus_operations(runtime, &workflow_id, Some(&run_id), query).await +} + +async fn nexus_operations( + runtime: Runtime, + workflow_id: &str, + run_id: Option<&str>, + query: std::collections::HashMap, +) -> Result> { + let limit = super::nexus_operations::list_limit(query.get("limit").map(String::as_str)); + let calls = runtime.nexus_operations(workflow_id, run_id, limit).await?; + Ok(Json(super::nexus_operations::listing(workflow_id, run_id, runtime.namespace(), limit, calls))) +} + async fn list_runs( Extension(runtime): Extension, Path(workflow): Path, diff --git a/rust/src/runtime/laravel_validation.rs b/rust/src/runtime/laravel_validation.rs index 6ba4c1c2d..8fadac01a 100644 --- a/rust/src/runtime/laravel_validation.rs +++ b/rust/src/runtime/laravel_validation.rs @@ -155,7 +155,7 @@ fn php_string(value: &Value) -> Option { } /// PHP 8's `is_numeric` for a request value. -fn php_numeric(value: &Value) -> Option { +pub(super) fn php_numeric(value: &Value) -> Option { match value { Value::Number(number) => number.as_f64(), Value::String(text) => { diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index 92de93e11..dc0595f9d 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -80,6 +80,7 @@ mod memos; pub mod mysql; mod namespace_cleanup; mod namespaces; +mod nexus_operations; mod operator_dashboard; mod operator_metrics; mod payload_cleanup; diff --git a/rust/src/runtime/nexus_operations.rs b/rust/src/runtime/nexus_operations.rs new file mode 100644 index 000000000..3d1ffb2f5 --- /dev/null +++ b/rust/src/runtime/nexus_operations.rs @@ -0,0 +1,162 @@ +//! Caller-indexed Nexus operation reads, as published Server 2.11.1 serves +//! them (`ServiceCatalogController::nexusOperationsForWorkflow` and +//! `nexusOperationsForRun`): the namespace's outbound service calls of one +//! workflow, or of one of its runs, newest first. Neither route checks that +//! the workflow or run exists. Native starts no Nexus operations and takeover +//! refuses a source that holds any service call, so today every list is +//! empty; rows a later service-call port writes render as PHP renders them. +use super::Result; +use serde_json::{Map, Value, json}; + +/// `config('server.limits.max_nexus_operations_per_caller')`. Native refuses +/// any other DW_MAX_NEXUS_OPERATIONS_PER_CALLER at startup. +const CALLER_LIMIT: i64 = 200; + +/// `nexusListLimit`: the configured cap unless the query asks for a number; +/// a numeric request is cast like PHP's `(int)`, raised to 1 and capped. +pub(crate) fn list_limit(requested: Option<&str>) -> i64 { + // Laravel trims the query string and turns an empty value into null. + let Some(text) = requested.map(super::laravel_validation::laravel_trim).filter(|text| !text.is_empty()) else { + return CALLER_LIMIT; + }; + let Some(number) = super::laravel_validation::php_numeric(&json!(text)) else { + return CALLER_LIMIT; + }; + // `(int)` of a numeric string truncates toward zero and saturates. + let value = if number.is_nan() { 0 } else { number.trunc().clamp(i64::MIN as f64, i64::MAX as f64) as i64 }; + if value < 1 { 1 } else { value.min(CALLER_LIMIT) } +} + +/// PHP's `json_encode` of a decoded JSON column: an empty map is `[]`. +fn php_array(value: Value) -> Value { + match value { + Value::Object(fields) if fields.is_empty() => json!([]), + Value::Object(fields) => Value::Object(fields.into_iter().map(|(key, value)| (key, php_array(value))).collect()), + Value::Array(items) => Value::Array(items.into_iter().map(php_array).collect()), + other => other, + } +} + +/// `metadataString`: a trimmed, non-empty string field of the metadata. +fn metadata_string(metadata: &Value, key: &str) -> Value { + metadata.get(key).and_then(Value::as_str).map(str::trim).filter(|text| !text.is_empty()).map_or(Value::Null, |text| json!(text)) +} + +/// `serviceCallAttempts`: the first `service_call_attempts` list in the +/// call's metadata, then its outcome metadata, keeping only the maps. +fn attempts(metadata: &Value, outcome_metadata: &Value) -> Vec { + for container in [metadata, outcome_metadata] { + match container.get("service_call_attempts") { + Some(Value::Array(items)) => return items.iter().filter(|item| item.is_object() || item.is_array()).cloned().collect(), + Some(Value::Object(items)) => return items.values().filter(|item| item.is_object() || item.is_array()).cloned().collect(), + _ => {} + } + } + Vec::new() +} + +store_methods! { + /// The caller rows and the resolved limit, newest first. + pub(crate) async fn nexus_operations(&self, workflow_id: &str, run_id: Option<&str>, limit: i64) -> Result> { + let rows = match run_id { + Some(run_id) => Self::query("SELECT * FROM workflow_service_calls WHERE caller_namespace=$1 AND caller_workflow_instance_id=$2 AND caller_workflow_run_id=$3 ORDER BY accepted_at DESC,created_at DESC LIMIT $4") + .bind(self.namespace.as_ref()).bind(workflow_id).bind(run_id).bind(limit).fetch_all(&self.pool).await?, + None => Self::query("SELECT * FROM workflow_service_calls WHERE caller_namespace=$1 AND caller_workflow_instance_id=$2 ORDER BY accepted_at DESC,created_at DESC LIMIT $3") + .bind(self.namespace.as_ref()).bind(workflow_id).bind(limit).fetch_all(&self.pool).await?, + }; + let iso = |row: &DB::Row, field: &str| -> Result { + // Carbon's `toIso8601String`: seconds and a numeric offset. + Ok(DB::optional_instant(row, field)? + .map_or(Value::Null, |at| json!(at.to_rfc3339_opts(chrono::SecondsFormat::Secs, false)))) + }; + let mut calls = Vec::with_capacity(rows.len()); + for row in &rows { + let text = |field: &str| -> Result { Ok(DB::optional_string(row, field)?.map_or(Value::Null, Value::from)) }; + let document = |field: &str| -> Result { Ok(DB::optional_document_row(row, field)?.map(php_array).unwrap_or(Value::Null)) }; + let metadata = document("metadata")?; + let outcome_metadata = document("outcome_metadata")?; + let attempts = attempts(&metadata, &outcome_metadata); + let mut call = Map::new(); + for (name, value) in [ + ("service_call_id", text("id")?), + ("caller_namespace", text("caller_namespace")?), + ("caller_workflow_instance_id", text("caller_workflow_instance_id")?), + ("caller_workflow_run_id", text("caller_workflow_run_id")?), + ("target_namespace", text("target_namespace")?), + ("endpoint_name", text("endpoint_name")?), + ("service_name", text("service_name")?), + ("operation_name", text("operation_name")?), + ("operation_mode", text("operation_mode")?), + ("status", text("status")?), + ("outcome", text("outcome")?), + ("outcome_category", text("outcome_category")?), + ("outcome_reason", text("outcome_reason")?), + ("outcome_message", text("outcome_message")?), + ("service_error_type", metadata_string(&outcome_metadata, "service_error_type")), + ("caller_observed_error_type", metadata_string(&outcome_metadata, "caller_observed_error_type")), + ("typed_error_message", metadata_string(&outcome_metadata, "typed_error_message")), + // PHP lists the metadata before the three fields read from it. + ("outcome_metadata", outcome_metadata), + ("resolved_binding_kind", text("resolved_binding_kind")?), + ("resolved_target_reference", text("resolved_target_reference")?), + ("linked_workflow_instance_id", text("linked_workflow_instance_id")?), + ("linked_workflow_run_id", text("linked_workflow_run_id")?), + ("linked_workflow_update_id", text("linked_workflow_update_id")?), + ("idempotency_key", text("idempotency_key")?), + ("retry_policy", document("retry_policy")?), + ("retry_attempt_count", json!(attempts.len())), + ("service_call_attempts", Value::Array(attempts)), + ("failure_message", text("failure_message")?), + ("caller_principal_subject", text("caller_principal_subject")?), + ("caller_principal_method", text("caller_principal_method")?), + ("accepted_at", iso(row, "accepted_at")?), + ("started_at", iso(row, "started_at")?), + ("completed_at", iso(row, "completed_at")?), + ("failed_at", iso(row, "failed_at")?), + ("cancelled_at", iso(row, "cancelled_at")?), + ] { + call.insert(name.into(), value); + } + calls.push(Value::Object(call)); + } + Ok(calls) + } +} + +/// The response body, in PHP's field order. +pub(crate) fn listing(workflow_id: &str, run_id: Option<&str>, namespace: &str, limit: i64, calls: Vec) -> Value { + let mut body = Map::new(); + body.insert("workflow_id".into(), json!(workflow_id)); + if let Some(run_id) = run_id { + body.insert("workflow_run_id".into(), json!(run_id)); + } + body.insert("caller_namespace".into(), json!(namespace)); + body.insert("count".into(), json!(calls.len())); + body.insert("limit".into(), json!(limit)); + body.insert("nexus_operations".into(), Value::Array(calls)); + Value::Object(body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limits_cast_like_php() { + for (requested, limit) in [(None, 200), (Some(""), 200), (Some(" "), 200), (Some("abc"), 200), (Some("5"), 5), + (Some(" 7 "), 7), (Some("0"), 1), (Some("-3"), 1), (Some("2.7"), 2), (Some("1e2"), 100), (Some("1000"), 200), + (Some("99999999999999999999"), 200), (Some("-0.5"), 1), (Some("0x1A"), 200)] { + assert_eq!(list_limit(requested), limit, "{requested:?}"); + } + } + + #[test] + fn decoded_columns_reencode_like_php() { + assert_eq!(php_array(json!({})), json!([])); + assert_eq!(php_array(json!({"a": {}, "b": [{}]})), json!({"a": [], "b": [[]]})); + assert_eq!(metadata_string(&json!({"service_error_type": " x "}), "service_error_type"), json!("x")); + assert_eq!(metadata_string(&json!({"service_error_type": " "}), "service_error_type"), Value::Null); + assert_eq!(attempts(&json!({"service_call_attempts": [{"n": 1}, 2]}), &json!({})), vec![json!({"n": 1})]); + assert_eq!(attempts(&json!({}), &json!({"service_call_attempts": [{"n": 2}]})), vec![json!({"n": 2})]); + } +} diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 6d8a4de76..816707a7d 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -214,6 +214,8 @@ mod waterline_projection_guards; mod operator_dashboard; #[path = "execution/waterline_reads.rs"] mod waterline_reads; +#[path = "execution/nexus_operations.rs"] +mod nexus_operations; #[path = "execution/task_queue_build_ids.rs"] mod task_queue_build_ids; #[path = "execution/start_timeouts.rs"] diff --git a/rust/tests/execution/nexus_operations.rs b/rust/tests/execution/nexus_operations.rs new file mode 100644 index 000000000..bd74b3757 --- /dev/null +++ b/rust/tests/execution/nexus_operations.rs @@ -0,0 +1,40 @@ +use super::*; + +// Caller-indexed Nexus operation reads list the namespace's service calls of +// one workflow, or one run, newest first in PHP's row shape. Native writes no +// service calls yet, so the rows are seeded; the empty case is the shared +// `nexus-operations` fixture's. +#[tokio::test] +async fn nexus_operation_reads_list_seeded_calls_newest_first() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + namespaces::create(&app, "callers").await; + // Test-owned literals only; no request input reaches this SQL. + for (id, namespace, run, accepted) in [ + ("call-old", "default", "run-a", "2026-10-25 06:00:00"), + ("call-new", "default", "run-b", "2026-10-25 06:00:05"), + ("call-other", "callers", "run-a", "2026-10-25 06:00:09"), + ] { + database.execute(sqlx::AssertSqlSafe(format!( + "INSERT INTO workflow_service_calls(id,endpoint_name,service_name,operation_name,caller_namespace,caller_workflow_instance_id,caller_workflow_run_id,status,operation_mode,outcome_metadata,metadata,accepted_at,created_at,updated_at) \ + VALUES ('{id}','billing','invoices','charge','{namespace}','caller-wf','{run}','completed','sync','{{}}','{{\"service_call_attempts\":[{{\"attempt\":1}},\"skip\"]}}','{accepted}','{accepted}','{accepted}')" + ))).await; + } + let (status, all) = request(&app, "GET", "/api/workflows/caller-wf/nexus-operations", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{all}"); + assert_eq!((all["caller_namespace"].clone(), all["count"].clone(), all["limit"].clone()), (json!("default"), json!(2), json!(200)), "{all}"); + let ids: Vec<_> = all["nexus_operations"].as_array().unwrap().iter().map(|call| call["service_call_id"].clone()).collect(); + assert_eq!(ids, [json!("call-new"), json!("call-old")], "newest first, this namespace only"); + let newest = &all["nexus_operations"][0]; + assert_eq!(newest["outcome_metadata"], json!([]), "an empty map renders as PHP's []"); + assert_eq!((newest["retry_attempt_count"].clone(), newest["service_call_attempts"].clone()), (json!(1), json!([{"attempt":1}]))); + assert_eq!(newest["accepted_at"], json!("2026-10-25T06:00:05+00:00")); + let (status, limited) = request(&app, "GET", "/api/workflows/caller-wf/nexus-operations?limit=1", Value::Null).await; + assert_eq!((status, limited["count"].clone(), limited["limit"].clone()), (StatusCode::OK, json!(1), json!(1)), "{limited}"); + let (status, run) = request(&app, "GET", "/api/workflows/caller-wf/runs/run-a/nexus-operations", Value::Null).await; + assert_eq!((status, run["workflow_run_id"].clone(), run["count"].clone()), (StatusCode::OK, json!("run-a"), json!(1)), "{run}"); + assert_eq!(run["nexus_operations"][0]["service_call_id"], "call-old"); + let (status, other) = namespaces::scoped(&app, "callers", "GET", "/api/workflows/caller-wf/nexus-operations", Value::Null).await; + assert_eq!((status, other["count"].clone()), (StatusCode::OK, json!(1)), "{other}"); + assert_eq!(other["nexus_operations"][0]["service_call_id"], "call-other"); +} diff --git a/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt b/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt index 2705cc909..e204a261d 100644 --- a/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt +++ b/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt @@ -29,7 +29,5 @@ POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/acknowledg POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/control POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/heartbeat POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/outcome -GET /api/workflows/{workflowId}/nexus-operations -GET /api/workflows/{workflowId}/runs/{runId}/nexus-operations POST /api/workflows/{workflowId}/runs/{runId}/streams/{streamName}/close POST /api/workflows/{workflowId}/runs/{runId}/streams/{streamName}/items diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index d76a45a35..6227b4b7a 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -13,6 +13,7 @@ import {checkLateWorkflowCompletion} from './late-workflow-completion-contract.m import {checkSearchAttributes} from './search-attribute-contract.mjs'; import {checkStorageTest} from './storage-test-contract.mjs'; import {checkActivityLeaseRenewal} from './activity-lease-renewal-contract.mjs'; +import {checkNexusOperations} from './nexus-operations-contract.mjs'; import {checkRecordLocalActivity} from './record-local-activity-contract.mjs'; import {checkWorkflowPollRequest} from './workflow-poll-request-contract.mjs'; import {checkWorkerPollFence} from './worker-poll-fence-contract.mjs'; @@ -162,6 +163,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const searchAttributes = fixture.search_attributes ? checkSearchAttributes(fixture, observation, workflowId) : null; const storageTest = fixture.storage_test ? checkStorageTest(fixture, observation, workflowId) : null; const activityLeaseRenewal = fixture.activity_lease_renewal ? checkActivityLeaseRenewal(fixture, observation, workflowId) : null; + const nexusOperations = fixture.nexus_operations ? checkNexusOperations(fixture, observation, workflowId) : null; const recordLocalActivity = fixture.record_local_activity ? checkRecordLocalActivity(fixture, observation, workflowId) : null; const workflowPollRequest = fixture.workflow_poll_request ? checkWorkflowPollRequest(fixture, observation, workflowId) : null; const workerPollFence = fixture.worker_poll_fence ? checkWorkerPollFence(fixture, observation, workflowId) : null; @@ -729,6 +731,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(searchAttributes ? {search_attributes: searchAttributes} : {}), ...(storageTest ? {storage_test: storageTest} : {}), ...(activityLeaseRenewal ? {activity_lease_renewal: activityLeaseRenewal} : {}), + ...(nexusOperations ? {nexus_operations: nexusOperations} : {}), ...(recordLocalActivity ? {record_local_activity: recordLocalActivity} : {}), ...(workflowPollRequest ? {workflow_poll_request: workflowPollRequest} : {}), ...(workerPollFence ? {worker_poll_fence: workerPollFence} : {}), diff --git a/scripts/conformance/server-parity/nexus-operations-contract.mjs b/scripts/conformance/server-parity/nexus-operations-contract.mjs new file mode 100644 index 000000000..60e80ab4f --- /dev/null +++ b/scripts/conformance/server-parity/nexus-operations-contract.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; + +// Reviewed caller-indexed Nexus operation reads (ServiceCatalogController:: +// nexusOperationsForWorkflow and nexusOperationsForRun), recorded against +// published Server 2.11.1. The fixture's `expected` holds the whole +// normalized observation, with the probe's workflow prefix as `{id}` and its +// run as `{run}`. Every step compares exactly. +export function checkNexusOperations(fixture, observation, workflowId) { + const state = observation.nexus_operations; + const projection = {scope: 'nexus_operations'}; + assert.ok(state && typeof state === 'object', 'actual nexus operations observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_nexus_operations'}, + 'embedded nexus operations explicitly inapplicable'); + return projection; + } + const definition = fixture.nexus_operations; + const actual = JSON.parse(JSON.stringify(state).replaceAll(workflowId, '{id}')); + for (const [step, expected] of Object.entries(definition.expected)) { + assert.deepStrictEqual(actual[step], expected, `nexus operations ${step}`); + } + assert.deepStrictEqual(Object.keys(actual).sort(), Object.keys(definition.expected).sort(), 'nexus operations steps'); + return projection; +} diff --git a/scripts/conformance/server-parity/nexus-operations-probe.php b/scripts/conformance/server-parity/nexus-operations-probe.php new file mode 100644 index 000000000..f75d66d9e --- /dev/null +++ b/scripts/conformance/server-parity/nexus-operations-probe.php @@ -0,0 +1,56 @@ +createNamespace($namespace, 'nexus operations λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + $http = new NexusOperationsHttp(['base_uri' => rtrim($url, '/'), 'http_errors' => false, 'connect_timeout' => 1, 'timeout' => 30]); + $raw = static function (string $path, ?string $tenant = null) use ($http, $token): array { + $headers = array_filter(['Authorization' => $token === null ? null : 'Bearer '.$token, + 'Accept' => 'application/json', 'X-Namespace' => $tenant, + 'X-Durable-Workflow-Control-Plane-Version' => '2']); + $response = $http->request('GET', $path, ['headers' => $headers]); + $decoded = json_decode((string) $response->getBody(), true); + if (is_array($decoded)) { + unset($decoded['server_capabilities'], $decoded['control_plane'], $decoded['protocol_version']); + } + + return ['status' => $response->getStatusCode(), 'response' => $decoded]; + }; + $describe = $raw('/api/workflows/'.rawurlencode($workflowId)); + $runId = $describe['response']['run_id'] ?? null; + if (! is_string($runId) || $runId === '') { + throw new RuntimeException('Nexus operations: the probe workflow has no run: '.json_encode($describe)); + } + $label = static function (array $recorded) use ($runId): array { + return json_decode(str_replace(json_encode($runId), '"{run}"', json_encode($recorded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), true); + }; + $workflow = '/api/workflows/'.rawurlencode($workflowId).'/nexus-operations'; + $run = '/api/workflows/'.rawurlencode($workflowId).'/runs/'.rawurlencode($runId).'/nexus-operations'; + $state = ['applicable' => true, 'namespace' => $namespace]; + $state['workflow'] = $label($raw($workflow)); + $state['run'] = $label($raw($run)); + $state['unknown_workflow'] = $raw('/api/workflows/'.rawurlencode($workflowId.'-nxo-missing').'/nexus-operations'); + $state['unknown_run'] = $label($raw('/api/workflows/'.rawurlencode($workflowId).'/runs/01NOSUCHRUN000000000000000/nexus-operations')); + $state['other_namespace'] = $label($raw($workflow, $namespace)); + $state['other_namespace_run'] = $label($raw($run, $namespace)); + foreach (['5' => 'five', '0' => 'zero', '-3' => 'negative', 'abc' => 'text', '1000' => 'above_cap', '2.7' => 'fraction', + '' => 'empty', '1e2' => 'exponent', ' 7 ' => 'padded'] as $limit => $name) { + $state['limit_'.$name] = $label($raw($workflow.'?limit='.rawurlencode((string) $limit))); + } + $state['run_limit_five'] = $label($raw($run.'?limit=5')); + + return $state; +} diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index c111becc9..d39f4f734 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -431,6 +431,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $searchAttributes = isset($fixture['search_attributes']) ? finishHttpSearchAttributes($client, $fixture, $workflowId, $url) : null; $storageTest = isset($fixture['storage_test']) ? finishHttpStorageTest($client, $fixture, $workflowId, $url) : null; $activityLeaseRenewal = isset($fixture['activity_lease_renewal']) ? finishHttpActivityLeaseRenewal($client, $fixture, $workflowId, $url) : null; + $nexusOperations = isset($fixture['nexus_operations']) ? finishHttpNexusOperations($client, $fixture, $workflowId, $url) : null; $recordLocalActivity = isset($fixture['record_local_activity']) ? finishHttpRecordLocalActivity($client, $fixture, $workflowId, $url) : null; $workflowPollRequest = isset($fixture['workflow_poll_request']) ? finishHttpWorkflowPollRequest($client, $fixture, $workflowId, $url) : null; $workerPollFence = isset($fixture['worker_poll_fence']) ? finishHttpWorkerPollFence($client, $fixture, $workflowId, $url) : null; @@ -551,6 +552,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'search_attributes' => $searchAttributes, 'storage_test' => $storageTest, 'activity_lease_renewal' => $activityLeaseRenewal, + 'nexus_operations' => $nexusOperations, 'record_local_activity' => $recordLocalActivity, 'workflow_poll_request' => $workflowPollRequest, 'worker_poll_fence' => $workerPollFence, @@ -874,6 +876,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/search-attribute-probe.php'; require __DIR__.'/storage-test-probe.php'; require __DIR__.'/activity-lease-renewal-probe.php'; + require __DIR__.'/nexus-operations-probe.php'; require __DIR__.'/record-local-activity-probe.php'; require __DIR__.'/workflow-poll-request-probe.php'; require __DIR__.'/worker-poll-fence-probe.php'; @@ -1054,6 +1057,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['activity_lease_renewal'])) { $observation['activity_lease_renewal'] = ['applicable' => false, 'reason' => 'embedded_has_no_worker_protocol']; } + if ($mode === 'embedded' && isset($fixture['nexus_operations'])) { + $observation['nexus_operations'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_nexus_operations']; + } if ($mode === 'embedded' && isset($fixture['record_local_activity'])) { $observation['record_local_activity'] = ['applicable' => false, 'reason' => 'embedded_has_no_worker_protocol']; } diff --git a/tests/Fixtures/ServerParity/nexus-operations.json b/tests/Fixtures/ServerParity/nexus-operations.json new file mode 100644 index 000000000..f071b3654 --- /dev/null +++ b/tests/Fixtures/ServerParity/nexus-operations.json @@ -0,0 +1,205 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "nexus-operations", + "review_state": "reviewed", + "workflow_type": "parity.v1.echo", + "input": { + "message": "lease renewal λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "lease renewal λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "nexus_operations": { + "expected": { + "applicable": true, + "namespace": "{id}-nxo", + "workflow": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "run": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_workflow": { + "status": 200, + "response": { + "workflow_id": "{id}-nxo-missing", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_run": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "01NOSUCHRUN000000000000000", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "{id}-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace_run": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "{run}", + "caller_namespace": "{id}-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_five": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + }, + "limit_zero": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_negative": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_text": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_above_cap": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_fraction": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 2, + "nexus_operations": [] + } + }, + "limit_empty": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_exponent": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 100, + "nexus_operations": [] + } + }, + "limit_padded": { + "status": 200, + "response": { + "workflow_id": "{id}", + "caller_namespace": "default", + "count": 0, + "limit": 7, + "nexus_operations": [] + } + }, + "run_limit_five": { + "status": 200, + "response": { + "workflow_id": "{id}", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + } + } + }, + "contract": "Caller-indexed Nexus operation reads (ServiceCatalogController::nexusOperationsForWorkflow and nexusOperationsForRun) as published Server 2.11.1 serves them: the workflow's and one run's outbound service calls in the request's namespace, newest first, with no check that the workflow or run exists; the caller namespace, count and resolved limit (the configured 200 unless a numeric `limit` asks for less, cast like PHP's (int), at least 1). Nothing starts a Nexus operation here, so every list is empty. Embedded applications have no HTTP surface." +} diff --git a/tests/Fixtures/ServerParityExpected/nexus-operations-observation.json b/tests/Fixtures/ServerParityExpected/nexus-operations-observation.json new file mode 100644 index 000000000..103c939cc --- /dev/null +++ b/tests/Fixtures/ServerParityExpected/nexus-operations-observation.json @@ -0,0 +1,168 @@ +{ + "applicable": true, + "namespace": "pr1-nexus-operations-nxo", + "workflow": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "run": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_workflow": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations-nxo-missing", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "unknown_run": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "01NOSUCHRUN000000000000000", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "pr1-nexus-operations-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "other_namespace_run": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "{run}", + "caller_namespace": "pr1-nexus-operations-nxo", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_five": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + }, + "limit_zero": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_negative": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 1, + "nexus_operations": [] + } + }, + "limit_text": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_above_cap": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_fraction": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 2, + "nexus_operations": [] + } + }, + "limit_empty": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 200, + "nexus_operations": [] + } + }, + "limit_exponent": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 100, + "nexus_operations": [] + } + }, + "limit_padded": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "caller_namespace": "default", + "count": 0, + "limit": 7, + "nexus_operations": [] + } + }, + "run_limit_five": { + "status": 200, + "response": { + "workflow_id": "pr1-nexus-operations", + "workflow_run_id": "{run}", + "caller_namespace": "default", + "count": 0, + "limit": 5, + "nexus_operations": [] + } + } +} diff --git a/tests/Unit/ServerParityNexusOperationsContractTest.mjs b/tests/Unit/ServerParityNexusOperationsContractTest.mjs new file mode 100644 index 000000000..b2f10eb13 --- /dev/null +++ b/tests/Unit/ServerParityNexusOperationsContractTest.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import {checkNexusOperations} from '../../scripts/conformance/server-parity/nexus-operations-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/nexus-operations.json', import.meta.url))); +// The observation published Server 2.11.1 returned for this fixture. +const recorded = JSON.parse(readFileSync( + new URL('../Fixtures/ServerParityExpected/nexus-operations-observation.json', import.meta.url))); +const id = 'pr1-nexus-operations'; +const observation = (change = () => {}) => { + const state = structuredClone(recorded); + change(state); + return {mode: 'http', nexus_operations: state}; +}; +const check = state => checkNexusOperations(fixture, state, id); + +test('the published 2.11.1 recording satisfies the contract', () => { + assert.deepStrictEqual(check(observation()), {scope: 'nexus_operations'}); +}); + +test('embedded applications are explicitly inapplicable', () => { + assert.deepStrictEqual(check({mode: 'embedded', nexus_operations: + {applicable: false, reason: 'embedded_has_no_http_nexus_operations'}}), {scope: 'nexus_operations'}); +}); + +for (const [label, change, message] of [ + // Native answered 501 for both routes before this fixture. + ['an unimplemented route', state => { + state.workflow = {status: 501, response: {reason: 'rust_capability_not_implemented'}}; + }, /nexus operations workflow/], + ['an unknown workflow refused as not found', state => { + state.unknown_workflow = {status: 404, response: {reason: 'instance_not_found'}}; + }, /nexus operations unknown_workflow/], + ['the default namespace for every request', state => { + state.other_namespace.response.caller_namespace = 'default'; + }, /nexus operations other_namespace/], + ['a zero limit kept as zero', state => { + state.limit_zero.response.limit = 0; + }, /nexus operations limit_zero/], + ['an exponent read as its leading digit', state => { + state.limit_exponent.response.limit = 1; + }, /nexus operations limit_exponent/], + ['an uncapped limit', state => { + state.limit_above_cap.response.limit = 1000; + }, /nexus operations limit_above_cap/], + ['a run listing without its run id', state => { + delete state.run.response.workflow_run_id; + }, /nexus operations run/], + ['a missing step', state => { + delete state.limit_padded; + }, /nexus operations limit_padded/], +]) { + test(`the contract refuses ${label}`, () => { + assert.throws(() => check(observation(change)), message); + }); +}