collective

Browse / github.com/storytold/wordcraft / patch

feat(web): add capability-bound private port hosting

Web host: private capability port for opaque sandbox embeds The existing `host=parent` bridge deliberately requires a same-origin parent. An embedding page using `sandbox="allow-scripts"` with a credentialless iframe instead gives the editor an opaque origin; it needs an opt-in capability transport without relaxing that sandbox or the legacy window checks. This follows #4573, #4584 and #4590. Use `host=port&parent_origin=<canonical HTTP(S) origin>`, with the bundle digest taken from the immutable `/b/<64 lowercase hex>/index.html` path (optionally under `/_editor` for a same-host asset mount, or `/workbook-editor`). The public window channel should ca

Status
open
Score
0.00
Adopters
0 other operators
Author
Srbino (imported from pull request #4603, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+1315 −46 in 16 files
Review
pending
Shared
2026-10-28
Id
4e32d9c92d45b50c46b2fc70

How it went

No other operator has reported using it yet.

Diff

raw · apps/wordcraft-web/Cargo.toml apps/wordcraft-web/src/capability.rs apps/wordcraft-web/src/host.rs apps/wordcraft-web/src/main.rs apps/wordcraft-web/src/managed.rs apps/wordcraft-web/src/snapshot.rs apps/wordcraft-web/src/web.rs crates/engine/src/tests.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/app/files.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panes/comments.rs crates/ui-egui/src/services.rs crates/ui-egui/src/tests.rs

diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml--- a/apps/wordcraft-web/Cargo.toml+++ b/apps/wordcraft-web/Cargo.toml@@ -29,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3"-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "MessagePort", "UrlSearchParams"] }diff --git a/apps/wordcraft-web/src/capability.rs b/apps/wordcraft-web/src/capability.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft-web/src/capability.rs@@ -0,0 +1,109 @@+//! Control-only handshake and size bounds for an opt-in private native port.++/// Native documents use the host's version limit, independently of small control messages.+pub const MAX_NATIVE_BYTES: usize = 32 * 1024 * 1024;++pub fn native_len_ok(length: usize) -> bool {+    length > 0 && length <= MAX_NATIVE_BYTES+}++/// The immutable bundle comes from the fetched path, never a query supplied by the parent.+pub fn bundle_from_path(path: &str) -> Option<&str> {+    let path = path.strip_prefix("/_editor").or_else(|| path.strip_prefix("/workbook-editor")).unwrap_or(path);+    let bundle = path.strip_prefix("/b/")?.strip_suffix("/index.html")?;+    (bundle.len() == 64 && bundle.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))).then_some(bundle)+}++/// `protocol` and `origin` are obtained from the browser's URL parser. Require the supplied+/// string to already be the canonical HTTP(S) origin, without path, credentials or query.+pub fn canonical_origin(input: &str, protocol: &str, origin: &str) -> bool {+    matches!(protocol, "http:" | "https:") && input == origin+}++pub fn connect_matches(value: &serde_json::Value, expected_bundle: &str, ports: usize) -> bool {+    let Some(fields) = value.as_object() else { return false };+    ports == 1+        && fields.len() == 3+        && value.get("type").and_then(serde_json::Value::as_str) == Some("wordcraft:connect")+        && value.get("protocol").and_then(serde_json::Value::as_u64) == Some(1)+        && value.get("bundle").and_then(serde_json::Value::as_str) == Some(expected_bundle)+}++/// Read JS byteLength as a Number before any u32 conversion: large buffers must not wrap.+pub fn native_js_len_ok(length: f64) -> bool {+    length.is_finite() && length.fract() == 0. && length > 0. && length <= MAX_NATIVE_BYTES as f64+}++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;+    const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";++    #[test]+    fn bundle_is_taken_only_from_a_qualified_path() {+        assert_eq!(bundle_from_path(&format!("/b/{DIGEST}/index.html")), Some(DIGEST));+        assert_eq!(bundle_from_path(&format!("/workbook-editor/b/{DIGEST}/index.html")), Some(DIGEST));+        for path in [+            format!("/other/b/{DIGEST}/index.html"),+            format!("/b/{DIGEST}/other.html"),+            format!("/b/{DIGEST}/index.html/"),+            format!("/b/{}/index.html", DIGEST.to_uppercase()),+            format!("/b/{DIGEST}/../index.html"),+        ] {+            assert_eq!(bundle_from_path(&path), None, "{path}");+        }+    }+    #[test]+    fn connect_has_exactly_three_keys_matching_one_bundle_and_one_port() {+        let good = json!({"type":"wordcraft:connect","protocol":1,"bundle":DIGEST});+        assert!(connect_matches(&good, DIGEST, 1));+        assert!(!connect_matches(&good, DIGEST, 0));+        assert!(!connect_matches(&good, DIGEST, 2));+        assert!(!connect_matches(&good, &"a".repeat(64), 1));+        for bad in [+            json!({"type":"wordcraft:connect","protocol":1,"bundle":DIGEST,"extra":true}),+            json!({"type":"wordcraft:connect","protocol":2,"bundle":DIGEST}),+            json!({"type":"wordcraft:open","protocol":1,"bundle":DIGEST}),+            json!({"type":"wordcraft:connect","protocol":1}),+            json!([]),+        ] {+            assert!(!connect_matches(&bad, DIGEST, 1));+        }+    }+    #[test]+    fn parent_origin_must_be_the_exact_canonical_http_origin() {+        assert!(canonical_origin("https://host.test", "https:", "https://host.test"));+        assert!(canonical_origin("http://127.0.0.1:8090", "http:", "http://127.0.0.1:8090"));+        for (raw, protocol, parsed) in [+            ("null", "file:", "null"),+            ("https://host.test/", "https:", "https://host.test"),+            ("https://host.test/path", "https:", "https://host.test"),+            ("https://user@host.test", "https:", "https://host.test"),+            ("HTTPS://HOST.TEST", "https:", "https://host.test"),+            ("ftp://host.test", "ftp:", "ftp://host.test"),+        ] {+            assert!(!canonical_origin(raw, protocol, parsed));+        }+    }+    #[test]+    fn native_binary_limit_accepts_more_than_four_mib_but_never_more_than_32() {+        assert!(!native_len_ok(0));+        assert!(native_len_ok(4 * 1024 * 1024 + 1));+        assert!(native_len_ok(32 * 1024 * 1024));+        assert!(!native_len_ok(32 * 1024 * 1024 + 1));+    }+    #[test]+    fn qualified_same_host_editor_prefix_is_accepted() {+        assert_eq!(bundle_from_path(&format!("/_editor/b/{DIGEST}/index.html")), Some(DIGEST));+    }++    #[test]+    fn native_js_buffer_lengths_do_not_wrap_at_four_gib() {+        assert!(native_js_len_ok(4_194_305.));+        assert!(native_js_len_ok(33_554_432.));+        for length in [0., -1., 0.5, f64::NAN, f64::INFINITY, 33_554_433., 4_294_967_297.] {+            assert!(!native_js_len_ok(length), "{length}");+        }+    }+}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,98 @@ //!   - 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.+//! Legacy parent mode only hears and answers the parent window on the page's own origin.+//! `?host=port&parent_origin=<canonical HTTP(S) origin>` instead supports an opaque sandbox.+//! Its immutable path must be `/b/<64 lowercase hex>/index.html`, optionally prefixed by+//! `/_editor` (same-host assets) or `/workbook-editor`. After child load it sends only `{type:"wordcraft:hello",protocol:1,bundle}`+//! over the window channel. One exact-parent, configured-origin connect with exactly those three+//! keys (`type:"wordcraft:connect"`) and one transferred MessagePort is accepted once. All+//! document content and subsequent events use that private port, never a window fallback.+//! Document identities and revisions are local to this editor instance. Hosts must retire old+//! ports and callbacks on navigation, never reusing an old ACK for a new iframe instance.+//! Port mode enables managed saves; native binary frames are nonempty and at most 32 MiB.+//! Correlated Open also checks this bound before copying JS bytes; legacy ID-less window Open+//! retains its behavior. 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)]+struct PrivatePort {+    bundle: String,+    endpoint: Rc<RefCell<Option<web_sys::MessagePort>>>,+    hello_sent: Rc<Cell<bool>>,+    ready: Rc<Cell<bool>>,+}+ #[derive(Clone)] pub struct Bridge {+    port: Option<PrivatePort>,+    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 +127,48 @@ impl Bridge {         if parent.location().origin().ok()? != origin {             return None;         }-        Some(Self { window, parent, origin })+        Some(Self {+            port: None,+            managed,+            saves: AsyncSaves::default(),+            managed_saves: Rc::default(),+            acknowledgements: Rc::default(),+            window,+            parent,+            origin,+            snapshot: Rc::default(),+            open: Rc::default(),+            observed: Rc::default(),+        })+    }++    /// Opaque iframe profile: trust only a canonical configured parent origin and one private+    /// capability port. The legacy constructor's same-origin checks remain unchanged.+    pub fn new_port(parent_origin: &str) -> Option<Self> {+        let window = web_sys::window()?;+        let parent = window.parent().ok().flatten()?;+        if js_sys::Object::is(&parent, &window) {+            return None;+        }+        let parsed = web_sys::Url::new(parent_origin).ok()?;+        if !crate::capability::canonical_origin(parent_origin, &parsed.protocol(), &parsed.origin()) {+            return None;+        }+        let path = window.location().pathname().ok()?;+        let bundle = crate::capability::bundle_from_path(&path)?.to_owned();+        Some(Self {+            port: Some(PrivatePort { bundle, endpoint: Rc::default(), hello_sent: Rc::default(), ready: Rc::default() }),+            managed: true,+            saves: AsyncSaves::default(),+            managed_saves: Rc::default(),+            acknowledgements: Rc::default(),+            window,+            parent,+            origin: parent_origin.into(),+            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 +182,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| {@@ -92,6 +232,9 @@ impl Bridge {     }      pub fn post_ready(&self) {+        if let Some(port) = &self.port {+            port.ready.set(true);+        }         self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);     } @@ -101,6 +244,9 @@ impl Bridge {     }      fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {+        if self.port.is_some() && !crate::capability::native_len_ok(bytes.len()) {+            return Err("native file must be nonempty and at most 32 MiB".into());+        }         let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());         let buffer = js_sys::Uint8Array::from(bytes).buffer();         let message = message(&[@@ -110,37 +256,315 @@ impl Bridge {             ("bytes", buffer.clone().into()),         ])         .ok_or_else(|| "couldn't build the message".to_string())?;-        self.parent-            .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))-            .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+        self.transfer(&message, &buffer)     }      /// Hear `wordcraft:open` from the parent and queue the file like a dropped one.     fn listen(&self, inbox: Inbox, ctx: egui::Context) {+        let snapshot = self.snapshot.clone();+        let open = self.open.clone();+        let bridge = self.clone();+        let request_ctx = ctx.clone();+        let receive: Rc<dyn Fn(JsValue)> = Rc::new(move |data: JsValue| {+            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);+                        request_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);+                request_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 bytes = match bytes(&data, bridge.port.is_some() || request_id.is_some()) {+                Ok(bytes) => bytes,+                Err(error) => {+                    if let Some(id) = request_id {+                        bridge.post_open_error(&id, &error);+                    }+                    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));+            }+            request_ctx.request_repaint();+        });+        if self.port.is_some() {+            self.listen_port(receive, ctx);+            return;+        }         let parent = self.parent.clone();         let origin = self.origin.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;             }+            receive(event.data());+        });+        if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {+            on_message.forget();+        }+    }++    fn listen_port(&self, receive: Rc<dyn Fn(JsValue)>, ctx: egui::Context) {+        let bridge = self.clone();+        let on_connect = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {+            let Some(state) = &bridge.port else {+                return;+            };+            if !state.hello_sent.get()+                || state.endpoint.borrow().is_some()+                || event.origin() != bridge.origin+                || !event.source().is_some_and(|s| js_sys::Object::is(&s, &bridge.parent))+            {+                return;+            }             let data = event.data();-            if string(&data, "type").as_deref() != Some("wordcraft:open") {+            let Some(object) = data.dyn_ref::<js_sys::Object>() else {+                return;+            };+            if js_sys::Object::keys(object).length() != 3+                || string(&data, "type").as_deref() != Some("wordcraft:connect")+                || string(&data, "bundle").as_deref() != Some(state.bundle.as_str())+                || js_sys::Reflect::get(&data, &"protocol".into()).ok().and_then(|v| v.as_f64()) != Some(1.)+            {                 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));+            // Only three bounded primitive control fields can reach JSON.stringify; native+            // document content is always binary and never passes through this conversion.+            let valid = js_sys::JSON::stringify(&data)+                .ok()+                .and_then(|v| v.as_string())+                .and_then(|v| serde_json::from_str::<serde_json::Value>(&v).ok())+                .is_some_and(|v| crate::capability::connect_matches(&v, &state.bundle, event.ports().length() as usize));+            if !valid {+                return;+            }+            let Ok(port) = event.ports().get(0).dyn_into::<web_sys::MessagePort>() else {+                return;+            };+            let receive = receive.clone();+            let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| receive(event.data()));+            port.set_onmessage(Some(on_message.as_ref().unchecked_ref()));+            port.start();+            *state.endpoint.borrow_mut() = Some(port);+            on_message.forget();+            if state.ready.get() {+                bridge.post_ready();+            }             ctx.request_repaint();         });-        if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {-            // The listener lives as long as the page.-            on_message.forget();+        if self.window.add_event_listener_with_callback("message", on_connect.as_ref().unchecked_ref()).is_ok() {+            on_connect.forget();+            if self.window.document().is_some_and(|d| d.ready_state() == "complete") {+                self.defer_hello();+            } else {+                let bridge = self.clone();+                let on_load = Closure::<dyn FnMut(web_sys::Event)>::new(move |_| bridge.defer_hello());+                if self.window.add_event_listener_with_callback("load", on_load.as_ref().unchecked_ref()).is_ok() {+                    on_load.forget();+                }+            }         }     } -    /// Post a message to the parent; false if it couldn't be sent.+    fn defer_hello(&self) {+        let bridge = self.clone();+        let callback = Closure::<dyn FnMut()>::once(move || {+            let Some(port) = &bridge.port else {+                return;+            };+            if port.hello_sent.get() {+                return;+            }+            let Some(value) = message(&[("type", "wordcraft:hello".into()), ("protocol", 1.into()), ("bundle", port.bundle.as_str().into())]) else {+                return;+            };+            if bridge.parent.post_message(&value, &bridge.origin).is_ok() {+                port.hello_sent.set(true);+            }+        });+        // A task after child load also lets the parent's iframe load counter advance before+        // its strict handshake verifier receives the hello.+        if self.window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.as_ref().unchecked_ref(), 0).is_ok() {+            callback.forget();+        }+    }++    /// 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| {+                if self.port.is_some() && !crate::capability::native_len_ok(bytes.len()) {+                    return Err("native snapshot must be nonempty and at most 32 MiB".into());+                }+                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.transfer(&value, &buffer)?;+                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())]);+    }++    /// Port mode never falls back to the public window channel for content or events.     fn send(&self, fields: &[(&str, JsValue)]) -> bool {-        message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())+        message(fields).is_some_and(|m| match &self.port {+            Some(state) => state.endpoint.borrow().as_ref().is_some_and(|port| port.post_message(&m).is_ok()),+            None => self.parent.post_message(&m, &self.origin).is_ok(),+        })+    }++    fn transfer(&self, value: &JsValue, buffer: &js_sys::ArrayBuffer) -> Result<(), String> {+        let transfer = js_sys::Array::of1(buffer);+        match &self.port {+            Some(state) => state+                .endpoint+                .borrow()+                .as_ref()+                .ok_or_else(|| "private editor port is not connected".to_string())?+                .post_message_with_transferable(value, &transfer),+            None => self.parent.post_message_with_transfer(value, &self.origin, &transfer),+        }+        .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))     } } @@ -156,14 +580,31 @@ fn string(data: &JsValue, key: &str) -> Option<String> {     js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string() } -/// `bytes` as an `ArrayBuffer` or `Uint8Array`.-fn bytes(data: &JsValue) -> Option<Vec<u8>> {-    let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;-    if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {-        Some(array.to_vec())+/// Check native bounds before copying any JS buffer into WASM. Legacy ID-less window Open+/// retains its original behavior; correlated imports and all private-port imports are bounded.+fn bytes(data: &JsValue, bounded: bool) -> Result<Vec<u8>, String> {+    let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).map_err(|_| "open requires document bytes")?;+    let array = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {+        array.clone()     } else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {-        Some(js_sys::Uint8Array::new(&bytes).to_vec())+        js_sys::Uint8Array::new(&bytes)     } else {-        None+        return Err("open requires document bytes".into());+    };+    if bounded+        && (!array.buffer().is_instance_of::<js_sys::ArrayBuffer>()+            || !native_byte_length(array.as_ref())+            || !native_byte_length(array.buffer().as_ref()))+    {+        return Err("native document must be nonempty and at most 32 MiB".into());     }+    Ok(array.to_vec())+}++fn bounded_string(data: &JsValue, key: &str, max: usize) -> Option<String> {+    string(data, key).filter(|value| !value.is_empty() && value.len() <= max)+}++fn native_byte_length(value: &JsValue) -> bool {+    js_sys::Reflect::get(value, &"byteLength".into()).ok().and_then(|value| value.as_f64()).is_some_and(crate::capability::native_js_len_ok) }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,13 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg(any(target_arch = "wasm32", test))]+mod capability;+#[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.rsnew 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.rsnew 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 = match query_param("host").as_deref() {+            Some("parent") => crate::host::Bridge::new(query_param("save").as_deref() == Some("managed")),+            Some("port") => query_param("parent_origin").and_then(|origin| crate::host::Bridge::new_port(&origin)),+            _ => 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());+}