diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs
--- a/apps/wordcraft-web/src/host.rs
+++ b/apps/wordcraft-web/src/host.rs
@@ -5,7 +5,10 @@
 //! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
 //!   listening; send a file now.
 //! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
-//!   `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
+//!   `Uint8Array`. Without a request ID it opens like a picked or dropped document. An optional
+//!   bounded `requestId` requests a correlated import: unsaved/unconfirmed work is refused, and
+//!   `wordcraft:opened {requestId, document, revision}` is sent only after the actual import, or
+//!   `{requestId, error}` on failure. One correlated import can be pending.
 //! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
 //!   every file WordCraft would otherwise download. The parent stores it and reports failures
 //!   itself. `saveAs` says whether the file becomes the document's file:
@@ -18,34 +21,77 @@
 //!   - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
 //!     Export Page as PNG: `false`. They are copies; the document's file stays the same.
 //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
+//! - `wordcraft:changed` (to the parent): `{ document, revision }` after every native document revision,
+//!   including further edits while `dirty` stays true. Revision is an opaque string that also
+//!   distinguishes replacing the document. Document is a separate opaque identity string; hosts
+//!   must refuse to store snapshots under a different document's identity.
+//! - `wordcraft:snapshot` (from the parent): `{ requestId }`, a nonempty string of at most 128
+//!   UTF-8 bytes. Requests the complete native JSON. Pending comment/reply text is committed as
+//!   for File Save; dirty state is never cleared and storage is not acknowledged.
+//! - `wordcraft:snapshot-result` (to the parent): `{ requestId, document, revision, bytes }` (a transferred
+//!   `ArrayBuffer` of native JSON), or `{ requestId, error }`. At most one request is pending;
+//!   another receives an error. The native Document parser checks the serialized snapshot.
+//!   Neither snapshot nor changed grants durable Saved. Reopen native bytes with `wordcraft:open`
+//!   and a `.json` name. The host guards unsaved work before replacing a document.
+//! With `&save=managed`, Save instead emits `wordcraft:save-request
+//! {requestId, document, revision}` without stamping, exporting or marking the document saved.
+//! The host requests a native snapshot with that requestId and, only after durable storage,
+//! answers `wordcraft:saved {requestId, document, revision}` for that exported snapshot. Only
+//! an exact current document/revision (including pending comment drafts) clears dirty. An older
+//! known manual save completes its ticket while newer edits remain unsaved. Unknown ACKs do
+//! nothing. `wordcraft:save-failed {requestId, error}` ends only its matching manual ticket;
+//! error is nonempty and at most 1024 UTF-8 bytes. One manual save may be pending. Save As and
+//! Export remain downloadable copies and never acknowledge storage of the native document.
+//!
 //! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
 //!
 //! Only the parent window on the page's own origin is heard or answered. In host mode the parent
 //! guards leaving the page, and `?author=` names the user for comments and tracked changes.
 
-use std::cell::Cell;
+use std::cell::{Cell, RefCell};
 use std::rc::Rc;
 
+use crate::managed::{ManagedSaves, SnapshotKey};
 use wasm_bindgen::JsCast as _;
 use wasm_bindgen::JsValue;
 use wasm_bindgen::closure::Closure;
+use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
 use wordcraft_ui_egui::{Inbox, Services, WordApp};
 
+#[derive(Clone, PartialEq, Eq)]
+enum Acknowledgement {
+    Saved(SnapshotKey),
+    Failed(String, String),
+}
+
 /// The longest user name taken from `?author=`.
 const MAX_AUTHOR_CHARS: usize = 200;
 
+struct PendingOpen {
+    request_id: String,
+    name: String,
+    bytes: Vec<u8>,
+}
+
 #[derive(Clone)]
 pub struct Bridge {
+    managed: bool,
+    saves: AsyncSaves,
+    managed_saves: Rc<RefCell<ManagedSaves>>,
+    acknowledgements: Rc<RefCell<Vec<Acknowledgement>>>,
     window: web_sys::Window,
     parent: web_sys::Window,
     origin: String,
+    snapshot: Rc<RefCell<Option<String>>>,
+    open: Rc<RefCell<Option<PendingOpen>>>,
+    observed: Rc<RefCell<crate::snapshot::RevisionObserver>>,
 }
 
 impl Bridge {
     /// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
     /// on another origin: messages only go to this origin, so the browser would drop every save
     /// without an error while the document showed as saved.
-    pub fn new() -> Option<Self> {
+    pub fn new(managed: bool) -> Option<Self> {
         let window = web_sys::window()?;
         let parent = window.parent().ok().flatten()?;
         if js_sys::Object::is(&parent, &window) {
@@ -60,7 +106,18 @@ impl Bridge {
         if parent.location().origin().ok()? != origin {
             return None;
         }
-        Some(Self { window, parent, origin })
+        Some(Self {
+            managed,
+            saves: AsyncSaves::default(),
+            managed_saves: Rc::default(),
+            acknowledgements: Rc::default(),
+            window,
+            parent,
+            origin,
+            snapshot: Rc::default(),
+            open: Rc::default(),
+            observed: Rc::default(),
+        })
     }
 
     /// Hand saved and exported files to the parent instead of downloading them, report unsaved
@@ -74,6 +131,38 @@ impl Bridge {
         services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
         // The browser's save picker is never used, so no save finishes later.
         services.async_saves = None;
+        if self.managed {
+            services.async_saves = Some(self.saves.clone());
+            let bridge = self.clone();
+            services.host_save_request = Some(Box::new(move |session, draft_revision| {
+                if bridge.managed_saves.borrow().pending() {
+                    return Err("native save already pending".into());
+                }
+                let ticket = bridge.saves.start();
+                let document = crate::snapshot::document(session);
+                let revision = crate::snapshot::revision(session, draft_revision);
+                let request_id = match bridge.managed_saves.borrow_mut().start(&document, ticket) {
+                    Ok(id) => id,
+                    Err(error) => {
+                        bridge.saves.finish(ticket, SaveOutcome::Failed(error.clone()));
+                        return Err(error);
+                    }
+                };
+                if bridge.send(&[
+                    ("type", "wordcraft:save-request".into()),
+                    ("requestId", request_id.as_str().into()),
+                    ("document", document.as_str().into()),
+                    ("revision", revision.as_str().into()),
+                ]) {
+                    Ok(ticket)
+                } else {
+                    bridge.managed_saves.borrow_mut().fail(&request_id);
+                    let error = "couldn't request native save".to_string();
+                    bridge.saves.finish(ticket, SaveOutcome::Failed(error.clone()));
+                    Err(error)
+                }
+            }));
+        }
         let bridge = self.clone();
         let reported: Rc<Cell<Option<bool>>> = Rc::default();
         services.on_dirty = Some(Box::new(move |dirty: bool| {
@@ -119,17 +208,95 @@ impl Bridge {
     fn listen(&self, inbox: Inbox, ctx: egui::Context) {
         let parent = self.parent.clone();
         let origin = self.origin.clone();
+        let snapshot = self.snapshot.clone();
+        let open = self.open.clone();
+        let bridge = self.clone();
         let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
             if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
                 return;
             }
             let data = event.data();
+            if bridge.managed {
+                let kind = string(&data, "type");
+                let acknowledgement = match kind.as_deref() {
+                    Some("wordcraft:saved") => {
+                        let Some(request_id) = bounded_string(&data, "requestId", 128) else { return };
+                        let Some(document) = bounded_string(&data, "document", 128) else { return };
+                        let Some(revision) = bounded_string(&data, "revision", 128) else { return };
+                        let key = SnapshotKey { request_id, document, revision };
+                        if !bridge.managed_saves.borrow().knows(&key) {
+                            return;
+                        }
+                        Some(Acknowledgement::Saved(key))
+                    }
+                    Some("wordcraft:save-failed") => {
+                        let Some(id) = bounded_string(&data, "requestId", 128) else { return };
+                        if !bridge.managed_saves.borrow().knows_failure(&id) {
+                            return;
+                        }
+                        let Some(error) = bounded_string(&data, "error", 1024) else { return };
+                        Some(Acknowledgement::Failed(id, error))
+                    }
+                    _ => None,
+                };
+                if let Some(acknowledgement) = acknowledgement {
+                    let mut pending = bridge.acknowledgements.borrow_mut();
+                    let id = match &acknowledgement {
+                        Acknowledgement::Saved(key) => &key.request_id,
+                        Acknowledgement::Failed(id, _) => id,
+                    };
+                    let already_pending = pending.iter().any(|old| match old {
+                        Acknowledgement::Saved(key) => &key.request_id == id,
+                        Acknowledgement::Failed(old_id, _) => old_id == id,
+                    });
+                    if pending.len() < 2 && !already_pending {
+                        pending.push(acknowledgement);
+                        ctx.request_repaint();
+                    }
+                    return;
+                }
+            }
+            if string(&data, "type").as_deref() == Some("wordcraft:snapshot") {
+                let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };
+                if snapshot.borrow().is_some() {
+                    bridge.send(&[
+                        ("type", "wordcraft:snapshot-result".into()),
+                        ("requestId", id.into()),
+                        ("error", "snapshot already pending".into()),
+                    ]);
+                    return;
+                }
+                *snapshot.borrow_mut() = Some(id);
+                ctx.request_repaint();
+                return;
+            }
             if string(&data, "type").as_deref() != Some("wordcraft:open") {
                 return;
             }
+            let request_id = if js_sys::Reflect::has(&data, &JsValue::from_str("requestId")).unwrap_or(false) {
+                let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };
+                Some(id)
+            } else {
+                None
+            };
+            if let Some(id) = &request_id
+                && open.borrow().is_some()
+            {
+                bridge.post_open_error(id, "open already pending");
+                return;
+            }
             let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
-            let Some(bytes) = bytes(&data) else { return };
-            inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
+            let Some(bytes) = bytes(&data) else {
+                if let Some(id) = request_id {
+                    bridge.post_open_error(&id, "open requires document bytes");
+                }
+                return;
+            };
+            if let Some(request_id) = request_id {
+                *open.borrow_mut() = Some(PendingOpen { request_id, name, bytes });
+            } else {
+                inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
+            }
             ctx.request_repaint();
         });
         if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
@@ -138,6 +305,96 @@ impl Bridge {
         }
     }
 
+    /// Report native revisions independently of dirty state, then answer a pending native
+    /// snapshot request after the app has processed its inbox and commands. Pending comment/reply
+    /// drafts are committed through the same commands as Save. Neither is a save
+    /// acknowledgement; the host must confirm its own durable storage separately.
+    pub fn observe(&self, app: &mut WordApp, ctx: &egui::Context) {
+        // Never flush drafts on ACK. Compare the current token first: a known old manual save
+        // completes its ticket, but newer edits (including unblurred comment text) stay dirty.
+        for acknowledgement in std::mem::take(&mut *self.acknowledgements.borrow_mut()) {
+            match acknowledgement {
+                Acknowledgement::Saved(key) => {
+                    let document = crate::snapshot::document(&app.session);
+                    let revision = crate::snapshot::revision(&app.session, app.native_draft_revision());
+                    if let Some(completion) = self.managed_saves.borrow_mut().acknowledge(&key, &document, &revision) {
+                        if completion.mark_saved {
+                            app.session.dirty = false;
+                        }
+                        if let Some(ticket) = completion.ticket {
+                            self.saves.finish(ticket, SaveOutcome::Saved);
+                        }
+                        ctx.request_repaint();
+                    }
+                }
+                Acknowledgement::Failed(id, error) => {
+                    if let Some(ticket) = self.managed_saves.borrow_mut().fail(&id) {
+                        self.saves.finish(ticket, SaveOutcome::Failed(error));
+                        ctx.request_repaint();
+                    }
+                }
+            }
+        }
+        if let Some(pending) = self.open.borrow_mut().take() {
+            match app.open_host_document(&pending.name, &pending.bytes) {
+                Ok(()) => {
+                    let document = crate::snapshot::document(&app.session);
+                    let revision = crate::snapshot::revision(&app.session, app.native_draft_revision());
+                    self.send(&[
+                        ("type", "wordcraft:opened".into()),
+                        ("requestId", pending.request_id.as_str().into()),
+                        ("document", document.as_str().into()),
+                        ("revision", revision.as_str().into()),
+                    ]);
+                }
+                Err(error) => self.post_open_error(&pending.request_id, &error),
+            }
+        }
+        let pending = self.snapshot.borrow_mut().take();
+        let prepared = if pending.is_some() { app.prepare_native_snapshot() } else { Ok(()) };
+        let session = &app.session;
+        let draft_revision = app.native_draft_revision();
+        let document = crate::snapshot::document(session);
+        let revision = crate::snapshot::revision(session, draft_revision);
+        if self.observed.borrow_mut().changed(session, draft_revision) {
+            self.send(&[("type", "wordcraft:changed".into()), ("document", document.as_str().into()), ("revision", revision.as_str().into())]);
+        }
+        let Some(id) = pending else { return };
+        let key = SnapshotKey { request_id: id.clone(), document: document.clone(), revision: revision.clone() };
+        let result = prepared
+            .and_then(|()| {
+                if self.managed {
+                    self.managed_saves.borrow().check_export(&key)?;
+                }
+                crate::snapshot::native_snapshot(session)
+            })
+            .and_then(|bytes| {
+                let buffer = js_sys::Uint8Array::from(bytes.as_slice()).buffer();
+                let value = message(&[
+                    ("type", "wordcraft:snapshot-result".into()),
+                    ("requestId", id.as_str().into()),
+                    ("document", document.as_str().into()),
+                    ("revision", revision.as_str().into()),
+                    ("bytes", buffer.clone().into()),
+                ])
+                .ok_or_else(|| "couldn't build the snapshot message".to_string())?;
+                self.parent
+                    .post_message_with_transfer(&value, &self.origin, &js_sys::Array::of1(&buffer))
+                    .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
+                if self.managed {
+                    self.managed_saves.borrow_mut().exported(key)?;
+                }
+                Ok(())
+            });
+        if let Err(error) = result {
+            self.send(&[("type", "wordcraft:snapshot-result".into()), ("requestId", id.as_str().into()), ("error", error.as_str().into())]);
+        }
+    }
+
+    fn post_open_error(&self, request_id: &str, error: &str) {
+        self.send(&[("type", "wordcraft:opened".into()), ("requestId", request_id.into()), ("error", error.into())]);
+    }
+
     /// Post a message to the parent; false if it couldn't be sent.
     fn send(&self, fields: &[(&str, JsValue)]) -> bool {
         message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
@@ -167,3 +424,7 @@ fn bytes(data: &JsValue) -> Option<Vec<u8>> {
         None
     }
 }
+
+fn bounded_string(data: &JsValue, key: &str, max: usize) -> Option<String> {
+    string(data, key).filter(|value| !value.is_empty() && value.len() <= max)
+}
diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs
--- a/apps/wordcraft-web/src/main.rs
+++ b/apps/wordcraft-web/src/main.rs
@@ -18,6 +18,11 @@ mod prefs;
 #[cfg(any(target_arch = "wasm32", test))]
 mod save_fallback;
 
+#[cfg(any(target_arch = "wasm32", test))]
+mod managed;
+#[cfg(any(target_arch = "wasm32", test))]
+mod snapshot;
+
 #[cfg(target_arch = "wasm32")]
 mod host;
 #[cfg(target_arch = "wasm32")]
diff --git a/apps/wordcraft-web/src/managed.rs b/apps/wordcraft-web/src/managed.rs
new file mode 100644
--- /dev/null
+++ b/apps/wordcraft-web/src/managed.rs
@@ -0,0 +1,159 @@
+//! Bounded native snapshot acknowledgements for opt-in host-managed saves.
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SnapshotKey {
+    pub request_id: String,
+    pub document: String,
+    pub revision: String,
+}
+
+struct ManualSave {
+    request_id: String,
+    document: String,
+    ticket: u64,
+    snapshot: Option<SnapshotKey>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Completion {
+    pub ticket: Option<u64>,
+    pub mark_saved: bool,
+}
+
+/// Keep one manual save independent of the latest automatic snapshot. A newer autosnapshot
+/// must not strand an older manual ticket or let its ACK mark newer edits saved.
+#[derive(Default)]
+pub struct ManagedSaves {
+    manual: Option<ManualSave>,
+    latest: Option<SnapshotKey>,
+}
+
+impl ManagedSaves {
+    pub fn pending(&self) -> bool {
+        self.manual.is_some()
+    }
+
+    pub fn start(&mut self, document: &str, ticket: u64) -> Result<String, String> {
+        if self.pending() {
+            return Err("native save already pending".into());
+        }
+        let request_id = format!("native-save-{ticket}");
+        self.manual = Some(ManualSave { request_id: request_id.clone(), document: document.into(), ticket, snapshot: None });
+        Ok(request_id)
+    }
+
+    pub fn check_export(&self, snapshot: &SnapshotKey) -> Result<(), String> {
+        if let Some(manual) = &self.manual
+            && manual.request_id == snapshot.request_id
+            && (manual.document != snapshot.document || manual.snapshot.as_ref().is_some_and(|old| old != snapshot))
+        {
+            return Err("manual save snapshot already bound to another document or revision".into());
+        }
+        Ok(())
+    }
+
+    /// Call only after the native snapshot was validated and successfully posted to the host.
+    pub fn exported(&mut self, snapshot: SnapshotKey) -> Result<(), String> {
+        self.check_export(&snapshot)?;
+        if let Some(manual) = &mut self.manual
+            && manual.request_id == snapshot.request_id
+        {
+            manual.snapshot = Some(snapshot);
+        } else {
+            self.latest = Some(snapshot);
+        }
+        Ok(())
+    }
+
+    pub fn knows(&self, snapshot: &SnapshotKey) -> bool {
+        self.manual.as_ref().and_then(|m| m.snapshot.as_ref()) == Some(snapshot) || self.latest.as_ref() == Some(snapshot)
+    }
+
+    pub fn knows_failure(&self, request_id: &str) -> bool {
+        self.manual.as_ref().is_some_and(|m| m.request_id == request_id)
+    }
+
+    pub fn acknowledge(&mut self, snapshot: &SnapshotKey, document: &str, revision: &str) -> Option<Completion> {
+        let ticket = if self.manual.as_ref().and_then(|m| m.snapshot.as_ref()) == Some(snapshot) {
+            self.manual.take().map(|m| m.ticket)
+        } else if self.latest.as_ref() == Some(snapshot) {
+            self.latest = None;
+            None
+        } else {
+            return None;
+        };
+        Some(Completion { ticket, mark_saved: snapshot.document == document && snapshot.revision == revision })
+    }
+
+    pub fn fail(&mut self, request_id: &str) -> Option<u64> {
+        if self.knows_failure(request_id) { self.manual.take().map(|m| m.ticket) } else { None }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    fn key(id: &str, revision: &str) -> SnapshotKey {
+        SnapshotKey { request_id: id.into(), document: "document".into(), revision: revision.into() }
+    }
+    #[test]
+    fn stale_manual_ack_settles_its_ticket_after_a_newer_autosnapshot() {
+        let mut saves = ManagedSaves::default();
+        let id = saves.start("document", 7).unwrap();
+        let manual = key(&id, "r1");
+        saves.exported(manual.clone()).unwrap();
+        saves.exported(key("auto", "r2")).unwrap();
+        assert_eq!(saves.acknowledge(&manual, "document", "r2"), Some(Completion { ticket: Some(7), mark_saved: false }));
+        assert!(saves.start("document", 8).is_ok());
+    }
+    #[test]
+    fn only_an_exported_exact_document_and_revision_can_mark_saved() {
+        let mut saves = ManagedSaves::default();
+        let id = saves.start("document", 7).unwrap();
+        let manual = key(&id, "r1");
+        assert!(!saves.knows(&manual));
+        assert_eq!(saves.acknowledge(&manual, "document", "r1"), None);
+        saves.exported(manual.clone()).unwrap();
+        assert!(saves.knows(&manual));
+        let mut wrong = manual.clone();
+        wrong.document = "other".into();
+        assert_eq!(saves.acknowledge(&wrong, "document", "r1"), None);
+        assert_eq!(saves.acknowledge(&key("unknown", "r1"), "document", "r1"), None);
+        assert!(saves.start("document", 8).is_err());
+        assert_eq!(saves.acknowledge(&manual, "document", "r1"), Some(Completion { ticket: Some(7), mark_saved: true }));
+        assert_eq!(saves.acknowledge(&manual, "document", "r1"), None);
+    }
+    #[test]
+    fn changed_draft_or_document_keeps_dirty_but_completes_the_known_ticket() {
+        for (document, revision) in [("document", "r1:draft2"), ("replacement", "r1")] {
+            let mut saves = ManagedSaves::default();
+            let id = saves.start("document", 4).unwrap();
+            let snapshot = key(&id, "r1");
+            saves.exported(snapshot.clone()).unwrap();
+            assert_eq!(saves.acknowledge(&snapshot, document, revision), Some(Completion { ticket: Some(4), mark_saved: false }));
+        }
+    }
+    #[test]
+    fn manual_snapshot_cannot_be_overwritten_and_unknown_failure_cannot_consume_it() {
+        let mut saves = ManagedSaves::default();
+        let id = saves.start("document", 4).unwrap();
+        saves.exported(key(&id, "r1")).unwrap();
+        assert!(saves.exported(key(&id, "r2")).is_err());
+        let mut wrong = key(&id, "r1");
+        wrong.document = "replacement".into();
+        assert!(saves.exported(wrong).is_err());
+        assert_eq!(saves.fail("unknown"), None);
+        assert_eq!(saves.fail(&id), Some(4));
+        assert_eq!(saves.fail(&id), None);
+        assert!(saves.start("document", 5).is_ok());
+    }
+    #[test]
+    fn autosnapshot_ack_has_no_manual_ticket_and_requires_exact_current_token() {
+        let mut saves = ManagedSaves::default();
+        let snapshot = key("auto", "r1");
+        saves.exported(snapshot.clone()).unwrap();
+        assert_eq!(saves.acknowledge(&snapshot, "document", "r2"), Some(Completion { ticket: None, mark_saved: false }));
+        saves.exported(snapshot.clone()).unwrap();
+        assert_eq!(saves.acknowledge(&snapshot, "document", "r1"), Some(Completion { ticket: None, mark_saved: true }));
+    }
+}
diff --git a/apps/wordcraft-web/src/snapshot.rs b/apps/wordcraft-web/src/snapshot.rs
new file mode 100644
--- /dev/null
+++ b/apps/wordcraft-web/src/snapshot.rs
@@ -0,0 +1,121 @@
+//! Native JSON snapshots for a host that stores complete editor versions.
+
+use wordcraft_engine::Session;
+
+/// Serialize only the native model, without changing dirty state, undo or file identity.
+/// Parsing our own output also rejects values (such as a non-finite required float) that
+/// serialize but cannot be opened as a native Document.
+pub fn native_snapshot(session: &Session) -> Result<Vec<u8>, String> {
+    let bytes = wordcraft_engine::io::save_bytes("snapshot.json", &session.doc)?;
+    let _: wordcraft_doc::Document = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
+    Ok(bytes)
+}
+
+/// Native session identity, kept separate from the opaque revision and out of JS numbers.
+pub fn document(session: &Session) -> String {
+    session.document_id().to_string()
+}
+
+/// An opaque token: include document identity, and keep u64 values out of JS numbers.
+pub fn revision(session: &Session, draft_revision: u64) -> String {
+    format!("{}:{}:{}", session.document_id(), session.rev(), draft_revision)
+}
+
+#[derive(Default)]
+pub struct RevisionObserver(Option<(u64, u64, u64)>);
+
+impl RevisionObserver {
+    pub fn changed(&mut self, session: &Session, draft_revision: u64) -> bool {
+        let current = (session.document_id(), session.rev(), draft_revision);
+        if self.0 == Some(current) {
+            return false;
+        }
+        self.0 = Some(current);
+        true
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use wordcraft_doc::{InlineObject, Part, PartKind};
+    use wordcraft_engine::Session;
+
+    #[test]
+    fn native_snapshot_preserves_complete_model_and_does_not_mark_saved() {
+        let mut session = Session::new(wordcraft_engine::sample::sample_document());
+        session.doc.parts.insert(
+            600,
+            Part {
+                kind: PartKind::Comment,
+                blocks: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Snapshot comment", Default::default()))],
+                ..Default::default()
+            },
+        );
+        session.doc.comments.insert(1, wordcraft_doc::Comment { author: "Tester".into(), part: 600, ..Default::default() });
+        let image = session.doc.add_media(vec![71; 3_200_000], "png");
+        let mut para = wordcraft_doc::Paragraph::with_text("\u{fffc}", Default::default());
+        para.objects.push(InlineObject::Image {
+            media: image,
+            w: 10.,
+            h: 10.,
+            alt: "Large image".into(),
+            float: Default::default(),
+            crop: [0.; 4],
+            ole: None,
+        });
+        session.doc.body.push(wordcraft_doc::para_block(para));
+        session.run("text.insert", &serde_json::json!({"text":"Edited "})).unwrap();
+        let document_id = session.document_id();
+        let revision = session.rev();
+        let undo_depth = session.undo_depth();
+        let before = serde_json::to_value(&session.doc).unwrap();
+        let bytes = native_snapshot(&session).unwrap();
+        assert!(bytes.len() > 4 * 1024 * 1024);
+        let reopened = wordcraft_engine::io::open_bytes("version.json", &bytes).unwrap();
+        assert_eq!(serde_json::to_value(&reopened).unwrap(), before);
+        assert_eq!(serde_json::to_value(&session.doc).unwrap(), before);
+        assert!(session.dirty);
+        assert_eq!(session.document_id(), document_id);
+        assert_eq!(session.rev(), revision);
+        assert_eq!(session.undo_depth(), undo_depth);
+    }
+    #[test]
+    fn changes_report_every_revision_even_while_the_document_stays_dirty() {
+        let mut session = Session::new(wordcraft_doc::Document::new());
+        let mut observer = RevisionObserver::default();
+        assert!(observer.changed(&session, 0));
+        assert!(!observer.changed(&session, 0));
+        session.run("text.insert", &serde_json::json!({"text":"first"})).unwrap();
+        assert!(session.dirty);
+        assert!(observer.changed(&session, 0));
+        let first = revision(&session, 0);
+        session.run("text.insert", &serde_json::json!({"text":"second"})).unwrap();
+        assert!(session.dirty);
+        assert!(observer.changed(&session, 0));
+        assert_ne!(revision(&session, 0), first);
+        assert!(!observer.changed(&session, 0));
+        session.set_document(wordcraft_doc::Document::new());
+        assert!(observer.changed(&session, 0));
+    }
+    #[test]
+    fn pending_draft_changes_are_reported_without_serializing_the_document() {
+        let session = Session::new(wordcraft_doc::Document::new());
+        let mut observer = RevisionObserver::default();
+        assert!(observer.changed(&session, 0));
+        assert!(observer.changed(&session, 1));
+        assert!(observer.changed(&session, 2));
+        assert!(!observer.changed(&session, 2));
+        assert_ne!(revision(&session, 1), revision(&session, 2));
+    }
+
+    #[test]
+    fn document_identity_is_an_opaque_string_stable_for_edits_and_new_for_replacement() {
+        let mut session = Session::new(wordcraft_doc::Document::new());
+        let original: String = document(&session);
+        session.run("text.insert", &serde_json::json!({"text":"edit"})).unwrap();
+        assert_eq!(document(&session), original);
+        session.set_document(wordcraft_doc::Document::new());
+        assert_ne!(document(&session), original);
+    }
+}
diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs
--- a/apps/wordcraft-web/src/web.rs
+++ b/apps/wordcraft-web/src/web.rs
@@ -29,7 +29,11 @@ pub fn start() {
         };
         // `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
         // what it saves (`host`). That page guards leaving it, so this one doesn't.
-        let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
+        let bridge = if query_param("host").as_deref() == Some("parent") {
+            crate::host::Bridge::new(query_param("save").as_deref() == Some("managed"))
+        } else {
+            None
+        };
         let app_bridge = bridge.clone();
         let dirty = Rc::new(Cell::new(false));
         let saves = AsyncSaves::default();
@@ -99,7 +103,7 @@ pub fn start() {
                         crate::host::Bridge::set_author(&mut app, &author);
                         prefs.keep_own_author(&app.session.author, &own);
                     }
-                    Ok(Box::new(WebShell { app, inbox, messages, prefs }))
+                    Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge }))
                 }),
             )
             .await;
@@ -134,6 +138,7 @@ struct WebShell {
     inbox: Inbox,
     messages: Rc<RefCell<Vec<String>>>,
     prefs: crate::prefs::PrefsSync,
+    bridge: Option<crate::host::Bridge>,
 }
 
 fn local_storage() -> Option<web_sys::Storage> {
@@ -155,6 +160,9 @@ impl eframe::App for WebShell {
             self.app.status(msg);
         }
         self.app.logic(ctx);
+        if let Some(bridge) = &self.bridge {
+            bridge.observe(&mut self.app, ctx);
+        }
         // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
         if let Some(json) = self.prefs.changed(&self.app) {
             match local_storage() {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -10865,10 +10865,11 @@ fn ime_body_epoch_marks_body_replacements_not_edits() {
     run(&mut s, "text.insert", json!({"text": " two"}));
     assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement");
 
+    let document_id = s.document_id();
     run(&mut s, "document.setText", json!({"text": "same text"}));
     let replaced = s.ime_body_epoch();
     assert!(replaced > first, "setText replaces the body under the same document id");
-    assert_eq!(s.document_id(), 1, "the document id itself is unchanged");
+    assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
 
     // The same text again still replaces the body: a composition must not survive on content
     // equality.
diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs
--- a/crates/ui-egui/src/app/commands.rs
+++ b/crates/ui-egui/src/app/commands.rs
@@ -141,8 +141,37 @@ impl WordApp {
             }
             return r;
         }
+        // Native host Save is an intent, not a download or a storage acknowledgement. Commit
+        // the same pending comment text as snapshot export before binding the ticket's token.
+        if id == "file.save" && self.services.host_save_request.is_some() {
+            self.prepare_native_snapshot()?;
+            let saves = self.services.async_saves.clone().ok_or("native host saves require asynchronous completion")?;
+            let _ = saves.take_started();
+            let Some(request) = &self.services.host_save_request else { return Err("native host save is unavailable".into()) };
+            let result = request(&self.session, self.native_draft_revision());
+            let started = saves.take_started();
+            let ticket = match result {
+                Ok(ticket) if started == Some(ticket) => ticket,
+                result => {
+                    let error = result.err().unwrap_or_else(|| "the host did not start its save ticket".into());
+                    if let Some(ticket) = started {
+                        saves.finish(ticket, async_saves::SaveOutcome::Failed(error.clone()));
+                    }
+                    return Err(error);
+                }
+            };
+            self.saves_in_flight.push(SaveInFlight {
+                ticket,
+                name: self.web_save_name(id),
+                document: self.session.document_id(),
+                marked_saved: false,
+                host_managed: true,
+            });
+            return Ok(json!({"pendingSave": ticket}));
+        }
         // Web: saving and exporting become downloads.
         if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
+            let host_managed = self.services.host_save_request.is_some();
             let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.web_save_name(id));
             let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
             // A save with a password encrypts this and later saves (`file.save`'s `password`).
@@ -152,15 +181,23 @@ impl WordApp {
                 if !wordcraft_engine::io::can_encrypt(&name) || pw.as_str().is_none_or(str::is_empty) {
                     return Err("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password, and it can't be empty".into());
                 }
-                self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;
+                if !host_managed {
+                    self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;
+                }
             }
             // A save is a save, download or not: advance the revision and modified stamp (#262).
-            if matches!(id, "file.save" | "file.saveAs") {
+            if !host_managed && matches!(id, "file.save" | "file.saveAs") {
                 self.session.stamp_save();
             }
             // Word documents are encrypted with the document's password, if it has one.
-            let password =
-                if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };
+            let password = if matches!(id, "file.save" | "file.saveAs") {
+                host_managed
+                    .then(|| params.get("password").and_then(Value::as_str))
+                    .flatten()
+                    .or_else(|| self.session.password.as_ref().map(wordcraft_engine::Password::as_str))
+            } else {
+                None
+            };
             let opts = wordcraft_engine::cmd::file::export_options(&self.session, &params);
             let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password, &opts)?;
             // A ticket left over from an earlier call isn't this save's.
@@ -170,7 +207,7 @@ impl WordApp {
             }
             // An export is a copy, and so is a save in a format that doesn't keep everything (a
             // page image, plain text): the document itself still has unsaved changes.
-            let marked_saved = matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
+            let marked_saved = !host_managed && matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name);
             // Save As tells the host page the document is now this file, which it only is when the
             // save marks it saved. A Save under a name the page never sent (an untitled document,
             // an imported .doc saved as .docx) is a new file too, never an overwrite.
@@ -198,7 +235,7 @@ impl WordApp {
             // the document's save isn't confirmed.
             if let Some(ticket) = saves.as_ref().and_then(async_saves::AsyncSaves::take_started) {
                 let document = self.session.document_id();
-                self.saves_in_flight.push(SaveInFlight { ticket, name: name.clone(), document, marked_saved });
+                self.saves_in_flight.push(SaveInFlight { ticket, name: name.clone(), document, marked_saved, host_managed: false });
                 return Ok(json!({"downloaded": name, "bytes": bytes.len(), "pendingSave": ticket}));
             }
             return Ok(json!({"downloaded": name, "bytes": bytes.len()}));
diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs
--- a/crates/ui-egui/src/app/files.rs
+++ b/crates/ui-egui/src/app/files.rs
@@ -61,6 +61,11 @@ impl WordApp {
     /// In the browser the save picker may write the file later ([`PromptSave::Later`], #1315).
     fn save_for_prompt(&mut self) -> Result<PromptSave, String> {
         let v = self.execute("file.save", json!({}))?;
+        if self.services.host_save_request.is_some()
+            && let Some(ticket) = v.get("pendingSave").and_then(Value::as_u64)
+        {
+            return Ok(PromptSave::Later(ticket));
+        }
         let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some();
         if !self.safely_saved(saved) {
             return Ok(PromptSave::NotSaved);
@@ -75,7 +80,7 @@ impl WordApp {
     /// looks saved, but nothing is written yet, so New/Open ask first.
     pub(crate) fn save_unconfirmed(&self) -> bool {
         let document = self.session.document_id();
-        self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == document)
+        self.saves_in_flight.iter().any(|s| (s.marked_saved || s.host_managed) && s.document == document)
     }
 
     /// The document's name as the Save Changes prompt shows it: its file's name, else its title.
@@ -128,7 +133,12 @@ impl WordApp {
                     }
                 }
                 if let async_saves::SaveOutcome::Failed(error) = &outcome {
-                    let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &save.name), ("error", error)]);
+                    let template = if save.host_managed {
+                        tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}")
+                    } else {
+                        tl!("Couldn't download {name}: {error}")
+                    };
+                    let msg = i18n::fmt(template, &[("name", &save.name), ("error", error)]);
                     log::error!("the save picker couldn't save {}: {error}", save.name);
                     self.status(msg);
                 }
@@ -140,7 +150,12 @@ impl WordApp {
             }
             match outcome {
                 // Only when nothing changed since and no later save of it is still unconfirmed.
-                async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => {
+                async_saves::SaveOutcome::Saved
+                    if !self.session.dirty
+                        && self.session.rev() == wait.rev
+                        && !self.save_unconfirmed()
+                        && (self.services.host_save_request.is_none() || !panes::has_comment_drafts(self)) =>
+                {
                     if let Err(e) = self.execute_user(&wait.then, wait.params) {
                         self.status(e);
                     }
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -20,6 +20,35 @@ impl WordApp {
         }
     }
 
+    /// Commit pending comment/reply text as a normal save does, without acknowledging any
+    /// storage or clearing dirty state. A refused draft stays local and returns an error.
+    pub fn prepare_native_snapshot(&mut self) -> Result<(), String> {
+        panes::flush_comment_drafts(self)
+    }
+
+    /// Import host-owned bytes synchronously, only when replacing the current document cannot
+    /// discard unsaved work. Unlike UI Open this never queues a Save Changes or picker dialog.
+    /// Success means the native engine has parsed and installed a new document, including when
+    /// the name matches an already opened file.
+    pub fn open_host_document(&mut self, name: &str, bytes: &[u8]) -> Result<(), String> {
+        if self.session.dirty || panes::has_comment_drafts(self) || self.save_unconfirmed() {
+            return Err("the current document has unsaved or unconfirmed changes".into());
+        }
+        let before = self.session.document_id();
+        let result = self.execute("file.open", json!({"path":name,"data":wordcraft_engine::cmd::insert::base64_encode(bytes)}))?;
+        if result.get("pending").is_some() || self.session.document_id() == before {
+            return Err("the host document was not opened".into());
+        }
+        // Match a successful ordinary inbox open: show the document instead of File Home.
+        self.ui.backstage = false;
+        Ok(())
+    }
+
+    /// Changes to pending comment/reply text, independent of the document's dirty flag.
+    pub fn native_draft_revision(&self) -> u64 {
+        self.comment_drafts.revision_for(self.session.document_id())
+    }
+
     /// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]).
     pub(super) fn report_dirty(&self) {
         if let Some(f) = &self.services.on_dirty {
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -384,7 +384,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
                 format!(
                     "{} — {}",
                     app.title_stem(),
-                    if app.session.dirty {
+                    if app.session.dirty || (app.services.host_save_request.is_some() && crate::panes::has_comment_drafts(app)) {
                         tl!("Edited")
                     } else if app.session.path.is_some() {
                         tl!("Saved")
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -101,7 +101,7 @@ mod state;
 use app::control_click_as_secondary;
 pub(crate) use app::{discards_document, works_without_document};
 pub use clock::now_ms;
-pub use services::{HostSaveFn, Inbox, Services};
+pub use services::{HostSaveFn, HostSaveRequestFn, Inbox, Services};
 pub use state::{AutoSaveBlock, DefaultApp, SaveChoice, keeps_everything};
 
 /// Web: a save the browser's picker hasn't finished ([`async_saves`]).
@@ -113,6 +113,8 @@ struct SaveInFlight {
     document: u64,
     /// Whether it marked the document saved (a Save in a format that keeps everything).
     marked_saved: bool,
+    /// Native host Save keeps dirty state intact, but still guards replacement until its ACK.
+    host_managed: bool,
 }
 
 /// Web: the command a Save Changes prompt holds back until its picker save ends.
diff --git a/crates/ui-egui/src/panes/comments.rs b/crates/ui-egui/src/panes/comments.rs
--- a/crates/ui-egui/src/panes/comments.rs
+++ b/crates/ui-egui/src/panes/comments.rs
@@ -72,6 +72,7 @@ pub(crate) struct CommentDrafts {
     document: u64,
     edits: BTreeMap<u32, String>,
     replies: BTreeMap<u32, String>,
+    revision: u64,
 }
 
 impl CommentDrafts {
@@ -85,6 +86,37 @@ impl CommentDrafts {
         self
     }
 
+    /// A cheap change token for unsaved text that is not in the native model yet.
+    pub(crate) fn revision_for(&self, document: u64) -> u64 {
+        if self.document == document { self.revision } else { 0 }
+    }
+
+    fn record_edit(&mut self, id: u32, text: String) {
+        if self.edits.get(&id) != Some(&text) {
+            self.edits.insert(id, text);
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
+    fn record_reply(&mut self, id: u32, text: String) {
+        if self.replies.get(&id) != Some(&text) {
+            self.replies.insert(id, text);
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
+    fn forget_edit(&mut self, id: u32) {
+        if self.edits.remove(&id).is_some() {
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
+    fn forget_reply(&mut self, id: u32) {
+        if self.replies.remove(&id).is_some() {
+            self.revision = self.revision.wrapping_add(1);
+        }
+    }
+
     /// Nothing typed and not yet written.
     pub(crate) fn is_empty(&self) -> bool {
         self.edits.is_empty() && self.replies.is_empty()
@@ -133,12 +165,12 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti
         r.request_focus();
     }
     if r.changed() {
-        drafts(app).edits.insert(id, text);
+        drafts(app).record_edit(id, text);
     }
     if r.lost_focus() {
         if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
             // Escape throws the draft away: the field shows the comment's text again.
-            drafts(app).edits.remove(&id);
+            drafts(app).forget_edit(id);
         } else {
             // A refusal is on the status line, and the draft stays in the field.
             let _ = commit_comment(app, id);
@@ -161,7 +193,7 @@ pub(crate) fn commit_comment(app: &mut WordApp, id: u32) -> Result<(), String> {
     {
         app.run("review.editComment", json!({"id": id, "text": text}))?;
     }
-    drafts(app).edits.remove(&id);
+    drafts(app).forget_edit(id);
     Ok(())
 }
 
@@ -193,11 +225,11 @@ pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option
         r.request_focus();
     }
     if r.changed() {
-        drafts(app).replies.insert(id, text);
+        drafts(app).record_reply(id, text);
     }
     if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
         // Escape throws the reply away and closes the field.
-        drafts(app).replies.remove(&id);
+        drafts(app).forget_reply(id);
         return true;
     }
     r.lost_focus() && commit_reply(app, id).is_ok()
@@ -211,7 +243,7 @@ pub(crate) fn commit_reply(app: &mut WordApp, id: u32) -> Result<(), String> {
         let root = thread_root(app, id);
         app.run("review.reply", json!({"id": root, "text": text.trim_end()}))?;
     }
-    drafts(app).replies.remove(&id);
+    drafts(app).forget_reply(id);
     Ok(())
 }
 
@@ -238,3 +270,110 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> {
     }
     first
 }
+
+#[cfg(test)]
+mod native_snapshot_tests {
+    use super::*;
+    use crate::Services;
+    use wordcraft_engine::Session;
+
+    #[test]
+    fn native_snapshot_commits_pending_comment_and_keeps_it_unsaved() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.run("review.newComment", json!({"text":"original"})).unwrap();
+        let id = *app.session.doc.comments.keys().next().unwrap();
+        let part = app.session.doc.comments[&id].part;
+        let before = app.session.rev();
+        drafts(&mut app).record_edit(id, "pending native comment".into());
+        assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "original");
+        app.prepare_native_snapshot().unwrap();
+        assert_eq!(app.session.doc.plain_text(StoryRef::Part(part)), "pending native comment");
+        assert!(app.session.dirty, "export is not a storage acknowledgement");
+        assert!(app.session.rev() > before);
+        assert!(!has_comment_drafts(&app));
+    }
+
+    #[test]
+    fn every_pending_comment_and_reply_edit_changes_the_native_draft_token() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.session.dirty = true;
+        let first = app.native_draft_revision();
+        drafts(&mut app).record_edit(0, "first".into());
+        let second = app.native_draft_revision();
+        drafts(&mut app).record_edit(0, "second".into());
+        let third = app.native_draft_revision();
+        drafts(&mut app).record_reply(0, "reply draft".into());
+        let fourth = app.native_draft_revision();
+        assert_ne!(first, second);
+        assert_ne!(second, third);
+        assert_ne!(third, fourth);
+        assert!(app.session.dirty);
+    }
+    #[test]
+    fn refused_pending_comment_stays_local_and_snapshot_preparation_fails() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.run("review.newComment", json!({"text":"original"})).unwrap();
+        let id = *app.session.doc.comments.keys().next().unwrap();
+        drafts(&mut app).record_edit(id, "pending retained".into());
+        app.session.run("review.restrict", &json!({"mode":"readOnly"})).unwrap();
+        let before = serde_json::to_value(&app.session.doc).unwrap();
+        assert!(app.prepare_native_snapshot().is_err());
+        assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
+        assert_eq!(comment_draft(&app, id).as_deref(), Some("pending retained"));
+    }
+
+    #[test]
+    fn correlated_host_open_refuses_pending_comment_without_flushing_or_replacing_it() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.run("review.newComment", json!({"text":"original"})).unwrap();
+        let id = *app.session.doc.comments.keys().next().unwrap();
+        app.session.dirty = false;
+        app.ui.backstage = true;
+        drafts(&mut app).record_edit(id, "pending retained".into());
+        let before = serde_json::to_value(&app.session.doc).unwrap();
+        let document = app.session.document_id();
+        let revision = app.native_draft_revision();
+        let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::new()).unwrap();
+        assert!(app.open_host_document("version.json", &bytes).is_err());
+        assert_eq!(app.session.document_id(), document);
+        assert_eq!(app.native_draft_revision(), revision);
+        assert!(app.ui.backstage);
+        assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before);
+        assert_eq!(comment_draft(&app, id).as_deref(), Some("pending retained"));
+    }
+    #[test]
+    fn stale_manual_ack_never_closes_a_new_comment_draft_after_an_earlier_auto_ack() {
+        let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+        app.run("review.newComment", json!({"text":"original native comment"})).unwrap();
+        let id = *app.session.doc.comments.keys().next().unwrap();
+        let document = app.session.document_id();
+        let saves = crate::async_saves::AsyncSaves::default();
+        let hook = saves.clone();
+        let ticket = std::rc::Rc::new(std::cell::Cell::new(0));
+        let seen = ticket.clone();
+        app.services.download = Some(Box::new(|_, _| Err("native managed save never downloads".into())));
+        app.services.async_saves = Some(saves.clone());
+        app.services.host_save_request = Some(Box::new(move |_, _| {
+            let id = hook.start();
+            seen.set(id);
+            Ok(id)
+        }));
+        app.run("file.close", json!({})).unwrap();
+        app.run("ui.saveChanges", json!({"answer":"save"})).unwrap();
+        assert_ne!(ticket.get(), 0);
+        // A separate, exact current auto-snapshot ACK arrived first. It legitimately clears
+        // Session.dirty; the original manual ticket is still waiting for its durable reply.
+        app.session.dirty = false;
+        drafts(&mut app).record_edit(id, "newer uncommitted comment".into());
+        let draft_revision = app.native_draft_revision();
+        // The bridge knows the old manual snapshot/ticket but its current draft token differs:
+        // settle that ticket as Saved without flushing or clearing the newer draft.
+        saves.finish(ticket.get(), crate::async_saves::SaveOutcome::Saved);
+        app.settle_async_saves();
+        assert!(app.has_document(), "Close must wait for the newer pending comment");
+        assert_eq!(app.session.document_id(), document);
+        assert_eq!(app.native_draft_revision(), draft_revision);
+        assert_eq!(comment_draft(&app, id).as_deref(), Some("newer uncommitted comment"));
+        assert!(matches!(app.dialog.as_ref(), Some(crate::dialogs::Dialog::SaveChanges { .. })));
+    }
+}
diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs
--- a/crates/ui-egui/src/services.rs
+++ b/crates/ui-egui/src/services.rs
@@ -42,6 +42,10 @@ pub struct Services {
     /// under its own name and format, as on the desktop, and a Save or Save As that keeps
     /// everything makes that name the document's file, so the next Save goes to it.
     pub host_save: Option<HostSaveFn>,
+    /// Opt-in native host persistence: Save requests an asynchronous ticket without exporting,
+    /// stamping metadata or marking the document saved. The hook starts the ticket in
+    /// `async_saves`; the host completes it only after durable storage (or failure).
+    pub host_save_request: Option<HostSaveRequestFn>,
     /// Web: told whether the document has unsaved changes after each pass, for the browser's
     /// leave-page guard (`beforeunload` runs between frames and can't ask the app).
     pub on_dirty: Option<Box<dyn Fn(bool)>>,
@@ -100,3 +104,7 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
 /// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
 /// the file, and the save failed.
 pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
+
+/// A native Save request: the current session and pending-draft revision, returning the ticket
+/// started in [`Services::async_saves`]. The session is read-only to the hook.
+pub type HostSaveRequestFn = Box<dyn Fn(&Session, u64) -> Result<u64, String>>;
diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs
--- a/crates/ui-egui/src/tests.rs
+++ b/crates/ui-egui/src/tests.rs
@@ -6972,3 +6972,196 @@ fn focus_hides_the_ribbon_and_escape_brings_it_back() {
     h.step();
     assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar");
 }
+
+#[test]
+fn correlated_host_open_finishes_the_actual_import_and_reimports_same_named_bytes() {
+    let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+    let original = a.session.document_id();
+    let first = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::from_text("first native body")).unwrap();
+    a.open_host_document("version.json", &first).unwrap();
+    let imported = a.session.document_id();
+    assert_ne!(imported, original);
+    assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "first native body");
+    let second = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::from_text("second native body")).unwrap();
+    a.open_host_document("version.json", &second).unwrap();
+    assert_ne!(a.session.document_id(), imported);
+    assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "second native body");
+    assert!(!a.session.dirty);
+}
+
+#[test]
+fn correlated_host_open_reports_parse_failure_without_replacing_the_document() {
+    let mut a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("retain original")), Services::default());
+    let original = a.session.document_id();
+    let before = serde_json::to_value(&a.session.doc).unwrap();
+    assert!(a.open_host_document("version.json", br#"{"body":"malformed"}"#).is_err());
+    assert_eq!(a.session.document_id(), original);
+    assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);
+}
+
+#[test]
+fn correlated_host_open_refuses_unsaved_work_without_queueing_a_replacement_dialog() {
+    let mut a = typed();
+    a.ui.backstage = true;
+    let original = a.session.document_id();
+    let before = serde_json::to_value(&a.session.doc).unwrap();
+    let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::new()).unwrap();
+    assert!(a.open_host_document("version.json", &bytes).is_err());
+    assert_eq!(a.session.document_id(), original);
+    assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);
+    assert!(a.session.dirty);
+    assert!(a.ui.backstage);
+    assert_eq!(prompt(&a), None);
+}
+
+#[test]
+fn correlated_host_open_refuses_a_save_that_is_still_unconfirmed() {
+    let (mut a, _saves, _last) = web_picker_app();
+    a.execute("file.save", json!({})).unwrap();
+    assert!(!a.session.dirty);
+    assert!(a.save_unconfirmed());
+    let before = serde_json::to_value(&a.session.doc).unwrap();
+    let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::new()).unwrap();
+    assert!(a.open_host_document("version.json", &bytes).is_err());
+    assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);
+    assert!(a.save_unconfirmed());
+}
+
+#[test]
+fn correlated_host_open_leaves_file_home_only_after_actual_success() {
+    let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
+    a.ui.backstage = true;
+    let original = a.session.document_id();
+    assert!(a.open_host_document("version.json", br#"{"body":"malformed"}"#).is_err());
+    assert!(a.ui.backstage);
+    assert_eq!(a.session.document_id(), original);
+    let bytes = wordcraft_engine::io::save_bytes("version.json", &wordcraft_doc::Document::from_text("show the native editor")).unwrap();
+    a.open_host_document("version.json", &bytes).unwrap();
+    assert!(!a.ui.backstage, "successful correlated host import leaves File Home");
+}
+
+fn managed_host_app() -> (WordApp, async_saves::AsyncSaves, std::rc::Rc<std::cell::RefCell<Vec<u64>>>) {
+    let mut a = typed();
+    let saves = async_saves::AsyncSaves::default();
+    let requests = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
+    let (hook, seen) = (saves.clone(), requests.clone());
+    a.services.download = Some(Box::new(|_, _| Err("native save must not download a package".into())));
+    a.services.host_save_request = Some(Box::new(move |_, _| {
+        let ticket = hook.start();
+        seen.borrow_mut().push(ticket);
+        Ok(ticket)
+    }));
+    a.services.async_saves = Some(saves.clone());
+    (a, saves, requests)
+}
+
+#[test]
+fn managed_native_save_request_leaves_document_dirty_and_does_not_stamp_or_export_it() {
+    let (mut a, saves, requests) = managed_host_app();
+    let before = serde_json::to_value(&a.session.doc).unwrap();
+    let path = a.session.path.clone();
+    let rev = a.session.rev();
+    let result = a.execute("file.save", json!({})).unwrap();
+    let ticket = requests.borrow()[0];
+    assert_eq!(result["pendingSave"], ticket);
+    assert_eq!(saves.pending(), 1);
+    assert!(a.session.dirty);
+    assert_eq!(a.session.path, path);
+    assert_eq!(a.session.rev(), rev);
+    assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);
+}
+
+#[test]
+fn managed_save_as_exports_a_copy_without_marking_the_native_document_saved() {
+    let (mut a, got) = host_app(false);
+    a.services.host_save_request = Some(Box::new(|_, _| Err("SaveAs exports a copy; no native save request".into())));
+    let before = serde_json::to_value(&a.session.doc).unwrap();
+    let path = a.session.path.clone();
+    a.execute("file.saveAs", json!({"path":"copy.docx"})).unwrap();
+    assert!(a.session.dirty);
+    assert_eq!(a.session.path, path);
+    assert_eq!(serde_json::to_value(&a.session.doc).unwrap(), before);
+    let got = got.borrow();
+    assert_eq!(got.len(), 1);
+    assert_eq!(got[0].0, "copy.docx");
+    assert!(!got[0].1, "download copy, never host file identity");
+    assert!(got[0].2 > 0);
+}
+
+#[test]
+fn managed_save_before_close_waits_for_a_confirmed_snapshot() {
+    let (mut a, saves, requests) = managed_host_app();
+    a.run("file.close", json!({})).unwrap();
+    let result = a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();
+    assert_eq!(result["pending"], "save");
+    a.settle_async_saves();
+    assert!(body_text(&a).contains(UNSAVED));
+    assert!(a.session.dirty);
+    // Simulate the bridge accepting an exact current snapshot ACK, then settling its ticket.
+    a.session.dirty = false;
+    saves.finish(requests.borrow()[0], async_saves::SaveOutcome::Saved);
+    a.settle_async_saves();
+    assert!(!body_text(&a).contains(UNSAVED));
+    assert_eq!(prompt(&a), None);
+}
+
+#[test]
+fn managed_save_failure_keeps_the_document_and_can_retry() {
+    let (mut a, saves, requests) = managed_host_app();
+    a.run("file.close", json!({})).unwrap();
+    a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();
+    let first = requests.borrow()[0];
+    saves.finish(first, async_saves::SaveOutcome::Failed("store unavailable".into()));
+    a.settle_async_saves();
+    assert!(a.session.dirty && body_text(&a).contains(UNSAVED));
+    assert_eq!(prompt(&a), Some("saveChanges"));
+    a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();
+    assert_ne!(requests.borrow()[1], first);
+    assert_eq!(saves.pending(), 1);
+}
+
+#[test]
+fn managed_stale_saved_ticket_settles_but_keeps_newer_edits_and_allows_manual_retry() {
+    let (mut a, saves, requests) = managed_host_app();
+    a.execute("file.save", json!({})).unwrap();
+    let first = requests.borrow()[0];
+    a.execute("text.insert", json!({"text":"newer edit"})).unwrap();
+    // Known old durable ACK: finish only its ticket; the bridge must not clear dirty.
+    saves.finish(first, async_saves::SaveOutcome::Saved);
+    a.settle_async_saves();
+    assert_eq!(saves.pending(), 0);
+    assert!(a.session.dirty);
+    a.execute("file.save", json!({})).unwrap();
+    assert_ne!(requests.borrow()[1], first);
+    assert_eq!(saves.pending(), 1);
+}
+
+#[test]
+fn managed_stale_saved_ticket_never_continues_close_over_newer_edits() {
+    let (mut a, saves, requests) = managed_host_app();
+    a.run("file.close", json!({})).unwrap();
+    a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();
+    let first = requests.borrow()[0];
+    a.execute("text.insert", json!({"text":"newer edit"})).unwrap();
+    saves.finish(first, async_saves::SaveOutcome::Saved);
+    a.settle_async_saves();
+    assert_eq!(saves.pending(), 0);
+    assert!(a.session.dirty && body_text(&a).contains(UNSAVED));
+    assert_eq!(prompt(&a), Some("saveChanges"));
+    a.run("ui.saveChanges", json!({"answer":"save"})).unwrap();
+    assert_ne!(requests.borrow()[1], first);
+    assert_eq!(saves.pending(), 1);
+}
+
+#[test]
+fn managed_save_of_a_clean_document_still_guards_replacement_until_its_ticket_finishes() {
+    let (mut a, saves, requests) = managed_host_app();
+    a.session.dirty = false;
+    a.execute("file.save", json!({})).unwrap();
+    assert!(a.save_unconfirmed());
+    a.run("file.close", json!({})).unwrap();
+    assert_eq!(prompt(&a), Some("saveChanges"));
+    saves.finish(requests.borrow()[0], async_saves::SaveOutcome::Saved);
+    a.settle_async_saves();
+    assert!(!a.save_unconfirmed());
+}
