From 8e28fd9d90a1c53de36429130b133a1f0d3a9dea Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 16:51:21 +0000 Subject: [PATCH 1/2] Update a namespace natively, as published PHP does Native serves PUT /api/namespaces/{name} with PHP's description and retention rules and its missing-namespace refusal. The namespace isolation fixture now updates a namespace through the published SDK. Part of #325. Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-port.md | 15 ++++ rust/src/runtime/execution.rs | 1 + rust/src/runtime/http.rs | 17 +++-- rust/src/runtime/namespaces.rs | 68 +++++++++++++++++++ rust/tests/execution/namespaces.rs | 54 +++++++++++++++ .../server-parity/namespace-contract.mjs | 12 ++++ .../server-parity/namespace-probe.php | 5 ++ 7 files changed, 168 insertions(+), 4 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 349f23089..e85d0b8be 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -152,6 +152,21 @@ support is not evidence of multi-node safety. Newest checkpoints first. Each entry is exact to the source, tree and runs it names, including unsuccessful observations. +October 20, namespace update: + +- **Source.** Native serves `PUT /api/namespaces/{name}`, as published PHP's + NamespaceController::update does. A description replaces the stored one + only when given. Retention follows PHP's rules: a forever namespace becoming + bounded needs `retention_days`; forever refuses days; bounded refuses null + days; days run 1–365. A missing namespace is `404 namespace_not_found`. + Only an admin may update a namespace. Native still has no retention pass, + so the stored retention is not enforced yet (see known gaps). +- **Checks.** The `namespace-isolation` probe now updates its first + namespace at the end through the published SDK, and updates a missing one. + The contract checks both. Locally, published PHP 2.5.14, embedded PHP and + native passed, and the comparison passed. A native test covers the + retention rules on SQLite. + October 19, before #478, #476, #494 and #460 merged: - **Open native PRs.** [#478](https://github.com/durable-workflow/server/pull/478) diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 5715137b8..024b6037f 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -289,6 +289,7 @@ impl Runtime { delegate!(describe_namespace(name: &str) -> Result); delegate!(create_namespace(body: Value) -> Result); delegate!(set_external_policy(name: &str, body: Value) -> Result); + delegate!(update_namespace(name: &str, body: Value) -> Result); delegate!( upload_external_local( local: crate::external_payload::LocalStorage, diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index d09fe705f..e931a08ab 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -29,7 +29,10 @@ pub fn router(runtime: Runtime) -> Router { "/api/namespaces", get(list_namespaces).post(create_namespace), ) - .route("/api/namespaces/{namespace}", get(describe_namespace)) + .route( + "/api/namespaces/{namespace}", + get(describe_namespace).put(update_namespace), + ) .route( "/api/namespaces/{namespace}/external-storage", axum::routing::put(super::external_transport::set_policy), @@ -268,9 +271,8 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) &["worker"] } else if path.starts_with("/api/workers/") || path == "/api/namespaces" && request.method() == Method::POST - || path.starts_with("/api/namespaces/") - && path.ends_with("/external-storage") - && request.method() == Method::PUT + // Updating a namespace or its external storage needs an admin. + || path.starts_with("/api/namespaces/") && request.method() == Method::PUT { &["admin"] } else { @@ -465,6 +467,13 @@ async fn describe_namespace( ) -> Result> { Ok(Json(runtime.describe_namespace(&namespace).await?)) } +async fn update_namespace( + Extension(runtime): Extension, + Path(namespace): Path, + Json(body): Json, +) -> Result> { + Ok(Json(runtime.update_namespace(&namespace, body).await?)) +} async fn create_namespace( Extension(runtime): Extension, Json(body): Json, diff --git a/rust/src/runtime/namespaces.rs b/rust/src/runtime/namespaces.rs index 6f0295244..03aecd39e 100644 --- a/rust/src/runtime/namespaces.rs +++ b/rust/src/runtime/namespaces.rs @@ -89,6 +89,74 @@ where Self::namespace_metadata(&row) } + /// PHP's NamespaceController::update: a description replaces the stored + /// one only when given, and retention follows retentionForUpdate. + pub(super) async fn update_namespace(&self, name: &str, body: Value) -> Result { + let invalid = || { + refuse( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_namespace_definition", + ) + }; + let name = name.to_ascii_lowercase(); + let description = match body.get("description") { + None | Some(Value::Null) => None, + Some(Value::String(text)) if text.chars().count() <= 1000 => Some(text.as_str()), + Some(_) => return Err(invalid()), + }; + let requested_mode = match body.get("retention_mode") { + None => None, + Some(Value::String(mode)) if matches!(mode.as_str(), "bounded" | "forever") => { + Some(mode.as_str()) + } + Some(_) => return Err(invalid()), + }; + let requested_days = match body.get("retention_days") { + None => None, + Some(Value::Null) => Some(None), + Some(value) => Some(Some( + value + .as_i64() + .filter(|days| (1..=365).contains(days)) + .ok_or_else(invalid)?, + )), + }; + let mut tx = self.begin().await?; + let row = Self::query("SELECT * FROM workflow_namespaces WHERE name=$1") + .bind(&name) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| RuntimeError::Protocol { + status: StatusCode::NOT_FOUND, + response: json!({"reason":"namespace_not_found","namespace":name,"message":format!("Namespace [{name}] not found.")}), + })?; + let mode = match requested_mode { + Some(mode) => mode.to_owned(), + None => DB::string(&row, "retention_mode")?, + }; + let days = match requested_days { + Some(days) => days, + None => DB::optional_number(&row, "retention_days")?, + }; + let forever = mode == "forever"; + if forever && requested_days.is_some_and(|days| days.is_some()) + || !forever && (requested_days == Some(None) || days.is_none()) + { + return Err(invalid()); + } + let days = if forever { 0 } else { days.unwrap_or_default() }; + Self::query("UPDATE workflow_namespaces SET description=COALESCE($1,description),retention_mode=$2,retention_days=NULLIF($3,0),updated_at=$4 WHERE name=$5") + .bind(description).bind(&mode).bind(days).bind(DB::bind_time(now())).bind(&name) + .execute(&mut *tx).await?; + let row = Self::query("SELECT * FROM workflow_namespaces WHERE name=$1") + .bind(&name) + .fetch_one(&mut *tx) + .await?; + let response = Self::namespace_metadata(&row)?; + tx.commit().await?; + Ok(response) + } + pub(super) async fn set_external_policy(&self, name: &str, body: Value) -> Result { let policy = super::external_transport::local_policy(body)?; let name = name.to_ascii_lowercase(); diff --git a/rust/tests/execution/namespaces.rs b/rust/tests/execution/namespaces.rs index 17dc71cd0..80e32e286 100644 --- a/rust/tests/execution/namespaces.rs +++ b/rust/tests/execution/namespaces.rs @@ -611,3 +611,57 @@ async fn same_worker_id_isolated_claims_completion_and_deregistration_by_namespa runtime.close().await; database.remove().await; } + +#[tokio::test] +async fn namespace_updates_follow_published_retention_rules() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + create(&app, "updated").await; + let update = |body: Value| { + let app = app.clone(); + async move { scoped(&app, "default", "PUT", "/api/namespaces/UPDATED", body).await } + }; + let (status, body) = update(json!({"description":"changed λ","retention_days":60})).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["name"], "updated"); + assert_eq!(body["description"], "changed λ"); + assert_eq!(body["retention_mode"], "bounded"); + assert_eq!(body["retention_days"], 60); + // A null description keeps the stored one. + let (status, body) = update(json!({"description":null,"retention_mode":"forever"})).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["description"], "changed λ"); + assert_eq!(body["retention_mode"], "forever"); + assert_eq!(body["retention_days"], Value::Null); + for refused in [ + json!({"retention_mode":"bounded"}), + json!({"retention_mode":"forever","retention_days":7}), + json!({"retention_mode":"bounded","retention_days":null}), + json!({"retention_days":366}), + json!({"retention_mode":"sometimes"}), + json!({"description":"λ".repeat(1001)}), + ] { + let (status, body) = update(refused.clone()).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{refused} {body}"); + } + let (status, body) = update(json!({"retention_mode":"bounded","retention_days":14})).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["retention_days"], 14); + let (status, body) = scoped(&app, "default", "PUT", "/api/namespaces/missing", json!({})).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["reason"], "namespace_not_found"); + assert_eq!(body["message"], "Namespace [missing] not found."); + let (_, described) = scoped( + &app, + "default", + "GET", + "/api/namespaces/updated", + Value::Null, + ) + .await; + assert_eq!(described["retention_days"], 14); + assert_eq!(described["description"], "changed λ"); + runtime.close().await; + database.remove().await; +} diff --git a/scripts/conformance/server-parity/namespace-contract.mjs b/scripts/conformance/server-parity/namespace-contract.mjs index fb2f180ac..ec6b32753 100644 --- a/scripts/conformance/server-parity/namespace-contract.mjs +++ b/scripts/conformance/server-parity/namespace-contract.mjs @@ -157,6 +157,18 @@ export function checkNamespaces(fixture, observation, workflowId, checkRun) { assert.deepStrictEqual(refusal.request, original.request, 'foreign completion retains actual original command and claim body'); assert.equal(original.status, 200, 'original namespace can still commit the authored completion'); } + const updated = state.updated; + const created = state.administration[0].created; + assert.deepStrictEqual([updated.name, updated.description, updated.status, updated.retention_mode, updated.retention_days, + updated.external_payload_storage, updated.created_at], + [names[0], definition.description + ' updated', 'active', 'bounded', definition.retention_days + 1, null, created.created_at], + 'a namespace update changes only its description and retention'); + assert.ok(Date.parse(updated.updated_at) >= Date.parse(created.updated_at), 'the update advances updated_at'); + const missing = state.update_missing; + const missingName = 'missing-' + names[0]; + assert.equal(missing.status, 404, 'updating a missing namespace is refused'); + assert.deepStrictEqual([missing.response.reason, missing.response.namespace, missing.response.message], + ['namespace_not_found', missingName, `Namespace [${missingName}] not found.`], 'missing namespace refusal'); } return {namespaces: names, runs: projections}; } diff --git a/scripts/conformance/server-parity/namespace-probe.php b/scripts/conformance/server-parity/namespace-probe.php index f6d1e0fa1..bc9eefd03 100644 --- a/scripts/conformance/server-parity/namespace-probe.php +++ b/scripts/conformance/server-parity/namespace-probe.php @@ -140,6 +140,11 @@ function finishHttpNamespaces(Client $admin, array $fixture, string $workflowId, $state['second_worker_heartbeat'] = $clients[1]->heartbeatWorker($state['worker_id']); } } + // Updates come last, so every earlier observation keeps the original metadata. + $state['updated'] = $admin->updateNamespace(strtoupper($names[0]), $definition['description'].' updated', + $definition['retention_days'] + 1)->raw; + $state['update_missing'] = namespaceReceipt('update-missing', static fn (): array => $admin->updateNamespace( + 'missing-'.$names[0], 'missing', 7)->raw); return $state; } From 8989535fb6ec889d5b6170d803454e76e2553601 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 20 Oct 2026 18:25:16 +0000 Subject: [PATCH 2/2] Describe the unenforced namespace retention without a dangling reference Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-port.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index f829c0d3f..dd7884a60 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -150,7 +150,7 @@ October 20, namespace update: bounded needs `retention_days`; forever refuses days; bounded refuses null days; days run 1–365. A missing namespace is `404 namespace_not_found`. Only an admin may update a namespace. Native still has no retention pass, - so the stored retention is not enforced yet (see known gaps). + so the stored retention is not enforced yet. - **Checks.** The `namespace-isolation` probe now updates its first namespace at the end through the published SDK, and updates a missing one. The contract checks both. Locally, published PHP 2.5.14, embedded PHP and