diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs
--- a/crates/ui-egui/src/control/tests.rs
+++ b/crates/ui-egui/src/control/tests.rs
@@ -1406,6 +1406,56 @@ fn automation_confirming_a_file_writing_dialog_is_judged_as_its_file_command() {
     assert!(r.is_err_and(|e| e.contains("file.save")));
 }
 
+/// Answering the unsaved-changes prompt with Save writes the document back like File › Save,
+/// so automation is judged for `file.save` there too, through a control request or synthetic
+/// input alike: a client allowed to close a document can't write it over its own file. A
+/// user's Save still writes and closes.
+#[test]
+fn automation_saving_from_the_unsaved_changes_prompt_is_judged_as_file_save() {
+    // The desktop policy (crates/automation/src/workspace.rs) lets automation close documents
+    // but refuses every file command that reads or writes a file, File › Save included.
+    fn policy(id: &str, _: &Value) -> photocraft_engine::Result<()> {
+        if id.starts_with("file.") && !matches!(id, "file.new" | "file.close" | "file.closeAll" | "file.closeOthers") {
+            Err(photocraft_engine::EngineError::Other(format!("automation command `{id}` is disabled")))
+        } else {
+            Ok(())
+        }
+    }
+    let prompted = || {
+        let (mut app, ctx, written, _) = gated_app_recording_writes();
+        app.services.automation_authorize = Some(policy);
+        app.services.automation_command = Some(Box::new(|id, params| policy(id, params).map_err(|e| e.to_string())));
+        let own_file = std::env::temp_dir().join("outside").join("x.psd").to_string_lossy().into_owned();
+        app.session.active_mut().unwrap().path = Some(own_file.clone());
+        app.session.execute("layer.new.layer", json!({})).unwrap();
+        let r = call(&mut app, &ctx, "ui.menu.invoke", json!({"command": "file.close"}));
+        assert!(app.discard.is_some(), "closing a dirty document asks first: {r}");
+        (app, ctx, written, own_file)
+    };
+    for synthetic in [false, true] {
+        let (mut app, ctx, written, _) = prompted();
+        let error = if synthetic {
+            app.automation_input = true;
+            let r = crate::discard_ui::answer_control(&mut app, &ctx, "save");
+            app.automation_input = false;
+            r.err().unwrap_or_default()
+        } else {
+            let r = call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog": crate::discard_ui::CONTROL_ID, "choice": "save"}));
+            r["error"].as_str().unwrap_or_default().to_string()
+        };
+        assert!(written.borrow().is_empty(), "synthetic={synthetic}: nothing is written: {:?}", written.borrow());
+        assert!(error.contains("file.save"), "synthetic={synthetic}: {error:?}");
+        assert!(app.discard.is_some(), "synthetic={synthetic}: the prompt stays up");
+        assert_eq!(app.session.documents().len(), 1, "synthetic={synthetic}: the document stays open");
+    }
+    // The user answering Save writes the document back and closes it.
+    let (mut app, ctx, written, own_file) = prompted();
+    crate::discard_ui::answer_control(&mut app, &ctx, "save").unwrap();
+    assert_eq!(*written.borrow(), vec![own_file]);
+    assert!(app.discard.is_none());
+    assert!(app.session.documents().is_empty());
+}
+
 #[test]
 fn app_open_opens_a_directory_pcraft_and_falls_back_for_the_zip_form() {
     use photocraft_doc::Document;
diff --git a/crates/ui-egui/src/dialogs/discard_ui.rs b/crates/ui-egui/src/dialogs/discard_ui.rs
--- a/crates/ui-egui/src/dialogs/discard_ui.rs
+++ b/crates/ui-egui/src/dialogs/discard_ui.rs
@@ -334,19 +334,40 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
     if answer.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Enter)) {
         answer = buttons.iter().find(|b| b.0 == ButtonRole::Default).map(|b| b.4);
     }
-    if let Some(answer) = answer {
-        apply(app, ctx, doc, answer);
+    if let Some(answer) = answer
+        && let Err(e) = apply(app, ctx, doc, answer)
+    {
+        app.ui.status = e;
+        app.ui.status_error = true;
     }
 }
 
-/// Acts on the answer about `doc`, the document the prompt is asking about.
-fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId, answer: Answer) {
+/// Acts on the answer about `doc`, the document the prompt is asking about. Save is refused
+/// when automation may not run File › Save (see [`authorize_save`]); the prompt stays up.
+fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId, answer: Answer) -> Result<(), String> {
     match answer {
         Answer::Cancel => app.discard = None,
         Answer::Discard => advance(app, ctx),
-        Answer::Save if save(app, ctx, doc) => advance(app, ctx),
-        Answer::Save => {}
+        Answer::Save => {
+            authorize_save(app)?;
+            if save(app, ctx, doc) {
+                advance(app, ctx);
+            }
+        }
+    }
+    Ok(())
+}
+
+/// Save writes the document back over its own file, as File › Save does, so automation (a
+/// control request's gate or synthetic input) is judged for `file.save` before it runs: a client
+/// allowed to close a document can't use the prompt to write a file its policy keeps it from.
+fn authorize_save(app: &PhotocraftApp) -> Result<(), String> {
+    if (app.automation_input || app.session.authorize.is_some())
+        && let Some(authorize) = app.services.automation_command.as_ref()
+    {
+        authorize("file.save", &json!({}))?;
     }
+    Ok(())
 }
 
 /// The id `ui.inspect` lists the prompt under; `ui.dialog.confirm` and `ui.dialog.cancel` answer
@@ -394,7 +415,7 @@ pub fn answer_control(app: &mut PhotocraftApp, ctx: &egui::Context, choice: &str
             return Err(format!("unknown choice `{other}`: {choices}"));
         }
     };
-    apply(app, ctx, doc, answer);
+    apply(app, ctx, doc, answer)?;
     Ok(json!({"open": app.discard.is_some(), "fileDialog": app.file_dialog_open()}))
 }
 
