From 35861641f807a02b5c205608fb592e20ee7cad7f Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 1 Jul 2026 16:42:02 +0200 Subject: [PATCH 1/5] fix(offboarding): make global reassignment per-workspace and optional (#9863) * fix(offboarding): make global reassignment per-workspace and optional Co-Authored-By: Claude Opus 4.8 (1M context) * fix(offboarding): handle sole-member workspaces in workspace-level removal Co-Authored-By: Claude Opus 4.8 (1M context) * fix(offboarding): show close action instead of dead-end in reassign-only sole-member case Co-Authored-By: Claude Opus 4.8 (1M context) * fix(offboarding): prevent no-op success in global reassign-only with no reassignable workspace Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../GlobalUserOffboardingModal.svelte | 102 ++++++++++++------ .../components/UserOffboardingModal.svelte | 69 +++++++----- 2 files changed, 113 insertions(+), 58 deletions(-) diff --git a/frontend/src/lib/components/GlobalUserOffboardingModal.svelte b/frontend/src/lib/components/GlobalUserOffboardingModal.svelte index 12af797140..bc2cc5b996 100644 --- a/frontend/src/lib/components/GlobalUserOffboardingModal.svelte +++ b/frontend/src/lib/components/GlobalUserOffboardingModal.svelte @@ -34,6 +34,7 @@ let wsConfigs: Record< string, { + reassign: boolean targetKind: 'user' | 'folder' selectedUser: string | undefined selectedFolder: string | undefined @@ -71,16 +72,21 @@ UserService.listUsernames({ workspace: wp.workspace_id }), FolderService.listFolders({ workspace: wp.workspace_id }) ]) + const users = usernamesList + .filter((u: string) => u !== wp.username) + .map((u: string) => ({ label: u, value: u })) return { workspace_id: wp.workspace_id, config: { + // Reassignment requires another workspace user to own items and back + // triggers/runnables; with no other user (e.g. single-member forks) it + // is impossible, so default off and leave items as-is. + reassign: users.length > 0, targetKind: 'user' as const, selectedUser: undefined as string | undefined, selectedFolder: undefined as string | undefined, selectedOperator: undefined as string | undefined, - users: usernamesList - .filter((u: string) => u !== wp.username) - .map((u: string) => ({ label: u, value: u })), + users, folders: foldersList.map((f: { name: string }) => ({ label: f.name, value: f.name @@ -112,15 +118,28 @@ : undefined } + // At least one workspace can be reassigned (has another assignable user). + let anyReassignableWorkspace = $derived( + workspacesWithItems.some((wp) => (wsConfigs[wp.workspace_id]?.users.length ?? 0) > 0) + ) + // At least one workspace is actually selected for reassignment. + let anyWorkspaceReassigned = $derived( + workspacesWithItems.some((wp) => wsConfigs[wp.workspace_id]?.reassign) + ) + let canSubmit = $derived( - !doReassign || + (!doReassign || workspacesWithItems.every((wp) => { - const target = getReassignTo(wp.workspace_id) const cfg = wsConfigs[wp.workspace_id] + if (!cfg?.reassign) return true + const target = getReassignTo(wp.workspace_id) if (!target) return false if (!cfg?.selectedOperator) return false return true - }) + })) && + // Reassign-only runs (no deletion) must reassign at least one workspace, + // otherwise the request is an empty no-op reported as success. + (deleteUser || anyWorkspaceReassigned) ) async function submit() { @@ -129,13 +148,16 @@ try { const reassignments: Record = {} - for (const wp of workspacesWithItems) { - const target = getReassignTo(wp.workspace_id) - const cfg = wsConfigs[wp.workspace_id] - if (target) { - reassignments[wp.workspace_id] = { - reassign_to: target, - new_on_behalf_of_user: cfg?.selectedOperator + if (doReassign) { + for (const wp of workspacesWithItems) { + const cfg = wsConfigs[wp.workspace_id] + if (!cfg?.reassign) continue + const target = getReassignTo(wp.workspace_id) + if (target) { + reassignments[wp.workspace_id] = { + reassign_to: target, + new_on_behalf_of_user: cfg?.selectedOperator + } } } } @@ -222,7 +244,7 @@ {/if} {#if doReassign} - {#each workspacesWithItems as wp} + {#each workspacesWithItems as wp (wp.workspace_id)} {@const cfg = wsConfigs[wp.workspace_id]}
@@ -230,23 +252,39 @@ {wp.workspace_id} ({wp.username}) + {#if cfg} + + {/if}
{#if cfg} - + {#if cfg.users.length === 0} +

+ No other users in this workspace. Items will be left as-is. +

+ {:else if cfg.reassign} + + {:else} +

Items will be left as-is.

+ {/if} {/if}
{/each} @@ -283,7 +321,7 @@ different user/folder.

    - {#each conflicts as conflict} + {#each conflicts as conflict, i (i)}
  • {conflict}
  • {/each}
@@ -293,7 +331,11 @@ {/if}
- {#if workspacesWithItems.length > 0 || deleteUser} + {#if !deleteUser && workspacesWithItems.length > 0 && !anyReassignableWorkspace} + {#if !loading} + + {/if} + {:else if workspacesWithItems.length > 0 || deleteUser}
diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index feadf3056d..9c1475f3a6 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -31,6 +31,8 @@ export interface UserExt { folders_owners: string[] is_service_account?: boolean impersonating_email?: string + // true when the user is a superadmin viewing a workspace they are not a member of + non_member?: boolean } export interface UserWorkspace { From efb62c5e9997726c35c96b8ad8cdd87445ee4d25 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 1 Jul 2026 17:38:31 +0200 Subject: [PATCH 3/5] enforce CE workspace limit when unarchiving (#9865) * fix(workspaces): enforce CE workspace limit when unarchiving Unarchiving a workspace re-activates a soft-deleted (deleted = true) workspace, effectively bringing it back to the active set. On CE this bypassed the 2-workspace cap that create_workspace enforces, letting a user exceed the limit by archiving and re-unarchiving. Run the same _check_nb_of_workspaces guard before flipping deleted back to false. The workspace being restored is still deleted = true at that point, so it is correctly excluded from the count. Fixes WIN-2119 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(workspaces): cap CE archived workspaces at 1 Complements the unarchive-limit fix: without a cap on archived workspaces, a CE user could stockpile many soft-deleted workspaces (each of which still occupies its workspace id and can later be unarchived). Refuse a new archive on CE when an archived workspace already exists, mirroring the create/unarchive workspace-count guards. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../windmill-api-workspaces/src/workspaces.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 7dcff7a9d2..7102b040d3 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3818,6 +3818,21 @@ async fn _check_nb_of_workspaces(db: &DB) -> Result<()> { return Ok(()); } +async fn _check_nb_of_archived_workspaces(db: &DB) -> Result<()> { + let nb_archived = sqlx::query_scalar!( + "SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = true", + ) + .fetch_one(db) + .await?; + if nb_archived.unwrap_or(0) >= 1 { + return Err(Error::BadRequest( + "You have reached the maximum number of archived workspaces (1) without an enterprise license. Permanently delete or unarchive the existing archived workspace first" + .to_string(), + )); + } + return Ok(()); +} + async fn create_workspace( authed: ApiAuthed, Extension(db): Extension, @@ -5680,6 +5695,11 @@ async fn archive_workspace( ) -> Result { require_admin(authed.is_admin, &authed.username)?; + // CE caps the number of archived (soft-deleted) workspaces so archiving can't be used to + // stockpile hidden workspaces. Enforced here so a second archive is refused up front. + #[cfg(not(feature = "enterprise"))] + _check_nb_of_archived_workspaces(&db).await?; + // If this is an attached dev workspace, archiving it leaves the prod with no active dev (the // unique index and user_workspaces both ignore deleted=true), so clear the prod's // dev_workspace_lock too. Gate it on prod-admin since it removes prod's protection rule (mirrors @@ -5792,6 +5812,13 @@ async fn unarchive_workspace( authed: ApiAuthed, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + + // Unarchiving re-activates a soft-deleted workspace, so it must respect the + // same CE workspace-count cap as creating one. The archived workspace is + // deleted = true and thus excluded from the count until it is restored. + #[cfg(not(feature = "enterprise"))] + _check_nb_of_workspaces(&db).await?; + let mut tx = db.begin().await?; sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id) .execute(&mut *tx) From d7be355290b5487e7df5ec9fed31abb85cdd50fd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 1 Jul 2026 16:23:20 +0000 Subject: [PATCH 4/5] sqlx nits --- ...1f7f387f5055c47f493271d26731336257384.json | 10 +-- ...e2d85231b889743de637eb35e756da29ad47b.json | 64 ------------------- ...86f0ecfcf63daa0b185994a410eb2fe41fad9.json | 23 ------- ...cbddf85e55f6c7ef226cdd66e025c002bac98.json | 20 ++++++ 4 files changed, 25 insertions(+), 92 deletions(-) delete mode 100644 backend/.sqlx/query-6fbd5826ae1fafd7bbb218324e3e2d85231b889743de637eb35e756da29ad47b.json delete mode 100644 backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json create mode 100644 backend/.sqlx/query-fc1663ecda7167dc12c0fd5a206cbddf85e55f6c7ef226cdd66e025c002bac98.json diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-6fbd5826ae1fafd7bbb218324e3e2d85231b889743de637eb35e756da29ad47b.json b/backend/.sqlx/query-6fbd5826ae1fafd7bbb218324e3e2d85231b889743de637eb35e756da29ad47b.json deleted file mode 100644 index 1e39bfdaab..0000000000 --- a/backend/.sqlx/query-6fbd5826ae1fafd7bbb218324e3e2d85231b889743de637eb35e756da29ad47b.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\",\n d.created_at as \"draft_saved_at!\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "username?", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "draft_saved_at!", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - { - "Custom": { - "name": "draft_kind", - "kind": { - "Enum": [ - "script", - "flow", - "app", - "raw_app", - "resource", - "variable", - "trigger_schedule", - "trigger_webhook", - "trigger_default_email", - "trigger_email", - "trigger_http", - "trigger_websocket", - "trigger_postgres", - "trigger_kafka", - "trigger_nats", - "trigger_mqtt", - "trigger_sqs", - "trigger_gcp", - "trigger_azure", - "trigger_poll", - "trigger_cli", - "trigger_nextcloud", - "trigger_google", - "trigger_github", - "data_pipeline" - ] - } - } - }, - "Text" - ] - }, - "nullable": [ - null, - false - ] - }, - "hash": "6fbd5826ae1fafd7bbb218324e3e2d85231b889743de637eb35e756da29ad47b" -} diff --git a/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json deleted file mode 100644 index 6ae3f60e49..0000000000 --- a/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9" -} diff --git a/backend/.sqlx/query-fc1663ecda7167dc12c0fd5a206cbddf85e55f6c7ef226cdd66e025c002bac98.json b/backend/.sqlx/query-fc1663ecda7167dc12c0fd5a206cbddf85e55f6c7ef226cdd66e025c002bac98.json new file mode 100644 index 0000000000..626c541556 --- /dev/null +++ b/backend/.sqlx/query-fc1663ecda7167dc12c0fd5a206cbddf85e55f6c7ef226cdd66e025c002bac98.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM workspace WHERE id != 'admins' AND deleted = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "fc1663ecda7167dc12c0fd5a206cbddf85e55f6c7ef226cdd66e025c002bac98" +} From 6a6f12960e29c314d11ad541519c71412f42567b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 1 Jul 2026 18:36:55 +0200 Subject: [PATCH 5/5] fix(forks): reset diff tally on trigger delete + guard compare visibility for admins (#9866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a trigger left a stale `workspace_diff` row: `delete_trigger` (the generic TriggerCrud handler) was the only delete path that never called `handle_deployment_metadata`, unlike every other kind. Because `compare_workspaces` trusts a cached `has_changes=true` row for non-script/flow kinds and the visibility filter then drops it (the trigger no longer exists), a deleted trigger became a phantom "ahead" item that flipped `all_ahead_items_visible` to false — hiding the deploy button and showing a "changes not visible to your user" warning that even a superadmin could not resolve (`reset_diff_tally` doesn't clear a `has_changes=true` row either). - delete_trigger now re-tallies via handle_deployment_metadata, so the next compare re-evaluates and corrects/removes the row (matches resource/variable/ folder/schedule deletes). - compare_workspaces forces the visibility flags true per side for anyone who sees that side in full: target/fork admin (or superadmin) for ahead items, source/parent admin (or superadmin) for behind items. The flag is a pure visibility guarantee — the deploy itself is authorized separately — so for such users a dropped diff is provably a phantom, never a permission gap. - Add a regression test asserting a phantom trigger diff row no longer blocks a superadmin while still (conservatively) warning a partial-context user. Co-authored-by: Claude Opus 4.8 (1M context) --- .../tests/workspace_comparison.rs | 123 +++++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 18 +++ backend/windmill-trigger/src/handler.rs | 20 +++ 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index 88ffe6bb26..f0e77d2038 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -1399,7 +1399,9 @@ async fn test_delete_fork_purges_workspace_diff(db: Pool) -> anyhow::R // Delete the fork through the real handler. let delete_response = client .client() - .delete(&format!("{base_url}/workspaces/delete/wm-fork-test-workspace")) + .delete(&format!( + "{base_url}/workspaces/delete/wm-fork-test-workspace" + )) .send() .await?; assert!( @@ -1521,3 +1523,122 @@ async fn test_create_fork_purges_stale_diff_state(db: Pool) -> anyhow: Ok(()) } + +/// Regression: a stale/phantom trigger diff row must never block a privileged +/// user's deploy. Triggers (unlike scripts/flows) are not re-validated by +/// `compare_workspaces`, so a cached `has_changes=true` row for a trigger that +/// no longer exists in the table is trusted, then dropped by the visibility +/// filter (the row is gone) — flipping `all_ahead_items_visible` to false and +/// hiding the deploy button. This used to happen even for a superadmin, because +/// the item's absence is indistinguishable from a permission-hidden item. +/// +/// The blast-radius guard forces the flag true for anyone who sees the relevant +/// side in full: a target/fork admin (or superadmin) for ahead items. A regular +/// user with no such visibility still gets the (conservative) warning, since we +/// cannot tell a phantom from a genuine permission gap on their behalf. +/// +/// The diff row and visibility query are OSS, so this runs on any build (no +/// tally needed — the row is inserted directly, mimicking a delete that left the +/// diff behind). +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_workspaces_phantom_trigger_shortfuse( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{port}/api"); + let superadmin = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + let non_admin = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN_2".to_string(), + ); + + // Fork of test-workspace (INSERT directly; we only need the pair to exist). + sqlx::query!( + "INSERT INTO workspace (id, name, owner, parent_workspace_id) + VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')" + ) + .execute(&db) + .await?; + sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')") + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO workspace_key(workspace_id, kind, key) + VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')" + ) + .execute(&db) + .await?; + + // Phantom rows: cached diffs for http_triggers with no backing row (the exact + // state a trigger delete used to leave behind before it reset the tally). One + // ahead (fork side), one behind (source side) so both guard branches are hit. + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork) + VALUES + ('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost', 'http_trigger', 1, 0, true, false, true), + ('test-workspace', 'wm-fork-test-workspace', 'f/rt/ghost_behind', 'http_trigger', 0, 1, true, true, false)" + ) + .execute(&db) + .await?; + + // Superadmin: the guard forces `all_ahead_items_visible = true`, and the + // non-existent trigger is not surfaced as a diff. + let comparison: serde_json::Value = superadmin + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace" + )) + .send() + .await? + .json() + .await?; + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(true), + "phantom trigger row must not trip the 'not visible' warning for a superadmin: {comparison}" + ); + assert_eq!( + comparison["all_behind_items_visible"].as_bool(), + Some(true), + "phantom behind trigger row must not trip the warning for a superadmin: {comparison}" + ); + assert!( + !comparison["diffs"] + .as_array() + .unwrap() + .iter() + .any(|d| d["path"] == "f/rt/ghost" || d["path"] == "f/rt/ghost_behind"), + "non-existent triggers must not be surfaced as diffs: {comparison}" + ); + + // Non-superadmin, non-fork-admin member of the source: no full-visibility + // guarantee, so the warning still (conservatively) fires. + let comparison: serde_json::Value = non_admin + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace" + )) + .send() + .await? + .json() + .await?; + assert_eq!( + comparison["all_ahead_items_visible"].as_bool(), + Some(false), + "a user without full visibility must not be short-circuited by the guard: {comparison}" + ); + assert_eq!( + comparison["all_behind_items_visible"].as_bool(), + Some(false), + "the behind-side guard must not fire for a non-admin either: {comparison}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 7102b040d3..072029d401 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -7292,6 +7292,24 @@ async fn compare_workspaces( .map(|s| s.behind) .fold(0, |acc, s| acc + s.try_into().unwrap_or(0)); + // Blast-radius guard for the "changes not visible to your user" warning + // (which hides the deploy button entirely). The flag is a pure visibility + // guarantee — the actual deploy/update is separately authorized against the + // target workspace's create/update endpoints — so it must be forced true for + // anyone who, as a rule, sees every item on the relevant side (RLS is bypassed + // for admins); any diff the visibility filter dropped for them is provably a + // stale/phantom row, never a permission gap. Crucially the two sides live in + // different workspaces: ahead items are the fork's own changes (gated by the + // TARGET/fork admin), behind items physically live in the parent (gated by the + // SOURCE/parent admin). A target admin does NOT see the parent side, so it must + // not clear the behind flag, and vice versa. `target_admin` already folds in + // superadmin; `source_admin` (parent side) ORs it in explicitly. + let is_super_admin = windmill_common::auth::is_super_admin_email(&db, &authed.email).await?; + let target_admin = fork_authed.is_admin; + let source_admin = is_super_admin || authed.is_admin; + let all_ahead_items_visible = all_ahead_items_visible || target_admin; + let all_behind_items_visible = all_behind_items_visible || source_admin; + return Ok(Json(WorkspaceComparison { all_ahead_items_visible, all_behind_items_visible, diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 65072bc9ab..ed3b89fc65 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -905,6 +905,26 @@ async fn delete_trigger( tx.commit().await?; + // Reset the fork/parent workspace_diff tally for this path, exactly as + // create/update and every other kind's delete does. Without this a deleted + // trigger leaves its cached `has_changes=true` diff row behind: the compare + // trusts it (triggers aren't re-validated like scripts/flows), then drops it + // as it no longer exists in the table — a phantom "ahead" item that reads as + // "changes not visible to your user" and hides the deploy button, even for + // superadmins. Re-tallying sets has_changes=NULL so the next compare + // re-evaluates and corrects/removes the row. + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &workspace_id, + T::get_deployed_object(path.to_string(), None), + Some(format!("{} '{}' deleted", T::DEPLOYMENT_NAME, path)), + true, + None, + ) + .await?; + // Trigger gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. delete_all_drafts_for_path(&db, &workspace_id, T::user_draft_item_kind(), path).await?;