diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -129,6 +129,29 @@ cd apps/gridcraft-web && trunk serve --release     # http://127.0.0.1:8771  (?sa
 You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the
 browser's file picker (dropping files works too) and Save downloads the workbook.
 
+A same-origin parent can embed the editor with `?host=parent` and open complete native JSON
+with `gridcraft:open` (`name: "workbook.json"`, `bytes: ArrayBuffer`). After `gridcraft:ready`,
+send `gridcraft:export-native` with an opaque string `requestId`; `gridcraft:native-exported`
+returns that ID, the exported `document` and `revision`, and complete native JSON `bytes`, or `error`.
+The revision identifies the state after any pending draft was committed. Export commits pending drafts
+through the engine and leaves the unsaved state intact: the host remains responsible for durable
+storage. `gridcraft:changed` reports every active document revision, including further edits while
+already dirty. `gridcraft:validate-native` (`requestId`, `bytes`) returns
+`gridcraft:native-validated` (`requestId`, `valid`, `error`) without changing the document.
+Validation accepts at most 32 MiB, checked before copying the input bytes. `gridcraft:closed`
+reports that no workbook is active.
+These messages are accepted only from the same-origin parent. They do not grant permission to
+store a document or turn the ordinary XLSX Save action into a durable acknowledgement.
+
+Hosts that durably store native snapshots can opt into `?host=parent&save=managed`.
+Workbook Save (including Ctrl+S) then sends `gridcraft:save-request` (`document`, `revision`)
+and leaves unsaved changes intact. Export a native snapshot using a fresh `requestId` unique
+across frame reloads. After storing it durably, send `gridcraft:save-ack` with that exact
+`requestId`, `document`, and `revision` from `gridcraft:native-exported`. Only the latest
+export can be acknowledged. A matching acknowledgement records the exported workbook as the
+saved baseline; edits made after that export remain unsaved. Old or foreign acknowledgements
+are ignored. Without `save=managed`, ordinary XLSX Save behavior remains unchanged.
+
 ### Ribbon access keys
 
 Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
diff --git a/apps/gridcraft-web/src/host.rs b/apps/gridcraft-web/src/host.rs
--- a/apps/gridcraft-web/src/host.rs
+++ b/apps/gridcraft-web/src/host.rs
@@ -8,7 +8,21 @@
 //!   file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
 //!   as PDF. The parent stores it and reports failures itself.
 //! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
+//! - `gridcraft:changed` (to the parent): `{ document, revision, dirty }` when the active
+//!   workbook changes. Document and revision are opaque strings; edits while already dirty
+//!   still notify the host, so it can debounce a fresh native export.
 //! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
+//! - `gridcraft:export-native` (from the parent): `{ requestId }`; replies with
+//!   `gridcraft:native-exported` `{ requestId, document, revision, bytes }` (native JSON ArrayBuffer), or `{ requestId, error }`.
+//!   Pending drafts are committed first. Export does not claim a durable save or clear dirty state.
+//! - `gridcraft:validate-native` (from the parent): `{ requestId, bytes }`; replies with
+//!   `gridcraft:native-validated` `{ requestId, valid, error? }` without changing the document.
+//!
+//! - `gridcraft:closed` (to the parent): no workbook is active.
+//! - With `?host=parent&save=managed`, workbook Save emits `gridcraft:save-request`
+//!   `{ document, revision }` without marking it saved. After durable storage of the latest
+//!   native export, send `gridcraft:save-ack` `{ requestId, document, revision }`.
+//!   Only that exact exported workbook becomes the saved baseline; later edits remain dirty.
 //!
 //! Only the parent window on the page's own origin is heard or answered. In host mode the page
 //! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
@@ -28,8 +42,17 @@ const WORKBOOK_EXTS: &[&str] = &["xlsx"];
 /// The longest user name taken from `?author=`.
 const MAX_AUTHOR_CHARS: usize = 200;
 
+enum NativeRequest {
+    Export(String),
+    Validate(String, Vec<u8>),
+    RequestSave,
+    Acknowledge(String, String, String),
+}
+
 #[derive(Clone)]
 pub struct Bridge {
+    managed: bool,
+    managed_saves: Rc<RefCell<crate::managed_save::ManagedSaves>>,
     window: web_sys::Window,
     parent: web_sys::Window,
     origin: String,
@@ -37,18 +60,30 @@ pub struct Bridge {
     saved: Rc<RefCell<Vec<String>>>,
     /// The unsaved-changes state last reported to the parent.
     reported: Rc<Cell<Option<bool>>>,
+    native: Rc<RefCell<Vec<NativeRequest>>>,
+    reported_revision: Rc<Cell<Option<(u64, u64)>>>,
 }
 
 impl Bridge {
     /// `None` when the page isn't framed (there is no parent to talk to).
-    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) {
             return None;
         }
         let origin = window.location().origin().ok()?;
-        Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
+        Some(Self {
+            managed,
+            managed_saves: Rc::default(),
+            window,
+            parent,
+            origin,
+            saved: Rc::default(),
+            reported: Rc::default(),
+            native: Rc::default(),
+            reported_revision: Rc::default(),
+        })
     }
 
     /// Send saved and exported files to the parent instead of downloading them, and hear files
@@ -82,6 +117,64 @@ impl Bridge {
     /// After each frame: a workbook handed to the parent this frame counts as saved under the
     /// name it was saved as, and the parent hears when unsaved changes appear or go away.
     pub fn sync(&self, app: &mut SheetApp) {
+        let requests = std::mem::take(&mut *self.native.borrow_mut());
+        for request in requests {
+            match request {
+                NativeRequest::Export(id) => match crate::native_document::export(app) {
+                    Ok(snapshot) => {
+                        if self.managed
+                            && let Err(error) = self.managed_saves.borrow_mut().remember(&id, &snapshot, app)
+                        {
+                            self.send(&[
+                                ("type", "gridcraft:native-exported".into()),
+                                ("requestId", id.as_str().into()),
+                                ("error", error.as_str().into()),
+                            ]);
+                            continue;
+                        }
+                        let buffer = js_sys::Uint8Array::from(snapshot.bytes.as_slice()).buffer();
+                        if let Some(message) = message(&[
+                            ("type", "gridcraft:native-exported".into()),
+                            ("requestId", id.as_str().into()),
+                            ("document", snapshot.document.as_str().into()),
+                            ("revision", snapshot.revision.as_str().into()),
+                            ("bytes", buffer.clone().into()),
+                        ]) {
+                            let _ = self.parent.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer));
+                        }
+                    }
+                    Err(error) => {
+                        self.send(&[
+                            ("type", "gridcraft:native-exported".into()),
+                            ("requestId", id.as_str().into()),
+                            ("error", error.as_str().into()),
+                        ]);
+                    }
+                },
+                NativeRequest::RequestSave => {
+                    if let Some(doc) = app.session.active() {
+                        self.send(&[
+                            ("type", "gridcraft:save-request".into()),
+                            ("document", doc.uid.to_string().into()),
+                            ("revision", doc.revision.to_string().into()),
+                        ]);
+                    }
+                }
+                NativeRequest::Acknowledge(id, document, revision) => {
+                    self.managed_saves.borrow_mut().acknowledge(app, &id, &document, &revision);
+                }
+                NativeRequest::Validate(id, bytes) => {
+                    let result = crate::native_document::validate(&bytes);
+                    let error = result.as_ref().err().map(String::as_str).unwrap_or("");
+                    self.send(&[
+                        ("type", "gridcraft:native-validated".into()),
+                        ("requestId", id.as_str().into()),
+                        ("valid", result.is_ok().into()),
+                        ("error", error.into()),
+                    ]);
+                }
+            }
+        }
         let saved = std::mem::take(&mut *self.saved.borrow_mut());
         if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
             && let Some(doc) = app.session.active_mut()
@@ -95,10 +188,31 @@ impl Bridge {
         if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
             self.reported.set(Some(dirty));
         }
+        let revision = app.session.active().map(|doc| (doc.uid, doc.revision));
+        if revision != self.reported_revision.get() {
+            if let Some((document, revision)) = revision {
+                self.send(&[
+                    ("type", "gridcraft:changed".into()),
+                    ("document", document.to_string().into()),
+                    ("revision", revision.to_string().into()),
+                    ("dirty", dirty.into()),
+                ]);
+            } else {
+                self.send(&[("type", "gridcraft:closed".into())]);
+            }
+            self.reported_revision.set(revision);
+        }
     }
 
     fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
         let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
+        if self.managed && is_workbook(&name) {
+            if self.native.borrow().len() >= 8 {
+                return Err("The native document request queue is full.".into());
+            }
+            self.native.borrow_mut().push(NativeRequest::RequestSave);
+            return Ok(());
+        }
         let buffer = js_sys::Uint8Array::from(bytes).buffer();
         let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())])
             .ok_or_else(|| "couldn't build the message".to_string())?;
@@ -112,12 +226,57 @@ impl Bridge {
     fn listen(&self, inbox: Inbox, ctx: egui::Context) {
         let parent = self.parent.clone();
         let origin = self.origin.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 string(&data, "type").as_deref() != Some("gridcraft:open") {
+            let kind = string(&data, "type");
+            if kind.as_deref() == Some("gridcraft:save-ack") {
+                if !bridge.managed || bridge.native.borrow().len() >= 8 {
+                    return;
+                }
+                let field = |key| string(&data, key).filter(|value| !value.is_empty() && value.len() <= 128);
+                let (Some(id), Some(document), Some(revision)) = (field("requestId"), field("document"), field("revision")) else { return };
+                bridge.native.borrow_mut().push(NativeRequest::Acknowledge(id, document, revision));
+                ctx.request_repaint();
+                return;
+            }
+            if matches!(kind.as_deref(), Some("gridcraft:export-native" | "gridcraft:validate-native")) {
+                let Some(id) = string(&data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };
+                let response =
+                    if kind.as_deref() == Some("gridcraft:export-native") { "gridcraft:native-exported" } else { "gridcraft:native-validated" };
+                if bridge.native.borrow().len() >= 8 {
+                    bridge.send(&[
+                        ("type", response.into()),
+                        ("requestId", id.as_str().into()),
+                        ("valid", false.into()),
+                        ("error", "The native document request queue is full.".into()),
+                    ]);
+                    return;
+                }
+                let request = if kind.as_deref() == Some("gridcraft:export-native") {
+                    NativeRequest::Export(id)
+                } else {
+                    match native_bytes(&data) {
+                        Ok(bytes) => NativeRequest::Validate(id, bytes),
+                        Err(error) => {
+                            bridge.send(&[
+                                ("type", response.into()),
+                                ("requestId", id.as_str().into()),
+                                ("valid", false.into()),
+                                ("error", error.as_str().into()),
+                            ]);
+                            return;
+                        }
+                    }
+                };
+                bridge.native.borrow_mut().push(request);
+                ctx.request_repaint();
+                return;
+            }
+            if kind.as_deref() != Some("gridcraft:open") {
                 return;
             }
             let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
@@ -164,3 +323,17 @@ fn bytes(data: &JsValue) -> Option<Vec<u8>> {
         None
     }
 }
+
+/// The validation RPC checks size before allocating or copying the incoming payload.
+fn native_bytes(data: &JsValue) -> Result<Vec<u8>, String> {
+    let value = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).map_err(|_| "Native workbook bytes are required.".to_string())?;
+    let array = if let Some(array) = value.dyn_ref::<js_sys::Uint8Array>() {
+        array.clone()
+    } else if value.is_instance_of::<js_sys::ArrayBuffer>() {
+        js_sys::Uint8Array::new(&value)
+    } else {
+        return Err("Native workbook bytes are required.".into());
+    };
+    crate::native_document::validate_length(array.length() as usize)?;
+    Ok(array.to_vec())
+}
diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs
--- a/apps/gridcraft-web/src/main.rs
+++ b/apps/gridcraft-web/src/main.rs
@@ -19,6 +19,10 @@
 #[cfg(target_arch = "wasm32")]
 mod host;
 #[cfg(any(target_arch = "wasm32", test))]
+mod managed_save;
+#[cfg(any(target_arch = "wasm32", test))]
+mod native_document;
+#[cfg(any(target_arch = "wasm32", test))]
 mod prefs;
 #[cfg(target_arch = "wasm32")]
 mod web;
diff --git a/apps/gridcraft-web/src/managed_save.rs b/apps/gridcraft-web/src/managed_save.rs
new file mode 100644
--- /dev/null
+++ b/apps/gridcraft-web/src/managed_save.rs
@@ -0,0 +1,124 @@
+//! A managed host only acknowledges the exact native snapshot it durably stored.
+
+use std::sync::Arc;
+
+use gridcraft_engine::model::Workbook;
+use gridcraft_ui_egui::SheetApp;
+
+use crate::native_document::Snapshot;
+
+struct Pending {
+    request: String,
+    document: String,
+    revision: String,
+    workbook: Arc<Workbook>,
+}
+
+/// Retains only the latest exported baseline. Exporting and requesting a save
+/// never change `DocState::saved`; only a matching host acknowledgement does.
+#[derive(Default)]
+pub struct ManagedSaves {
+    pending: Option<Pending>,
+}
+
+impl ManagedSaves {
+    pub fn remember(&mut self, request: &str, snapshot: &Snapshot, app: &SheetApp) -> Result<(), String> {
+        let doc = app.session.active().ok_or_else(|| "No workbook is open.".to_string())?;
+        if doc.uid.to_string() != snapshot.document || doc.revision.to_string() != snapshot.revision {
+            return Err("The native snapshot no longer identifies the active document.".into());
+        }
+        self.pending = Some(Pending {
+            request: request.to_string(),
+            document: snapshot.document.clone(),
+            revision: snapshot.revision.clone(),
+            workbook: doc.wb.clone(),
+        });
+        Ok(())
+    }
+
+    pub fn acknowledge(&mut self, app: &mut SheetApp, request: &str, document: &str, revision: &str) -> bool {
+        let Some(pending) = &self.pending else { return false };
+        if pending.request != request
+            || pending.document != document
+            || pending.revision != revision
+            || app.session.active().is_none_or(|doc| doc.uid.to_string() != document)
+        {
+            return false;
+        }
+        let Some(pending) = self.pending.take() else { return false };
+        let Some(doc) = app.session.active_mut() else { return false };
+        // Later edits remain different from this exact exported workbook.
+        doc.saved = pending.workbook;
+        true
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use gridcraft_engine::Session;
+    use gridcraft_ui_egui::{Services, SheetApp};
+    use serde_json::json;
+    use std::sync::Arc;
+
+    fn app() -> SheetApp {
+        let mut session = Session::new();
+        session.new_workbook();
+        let mut app = SheetApp::new(session, Services::default());
+        app.run("cell.set", json!({"cell":"A1", "input":"first"})).unwrap();
+        app
+    }
+
+    #[test]
+    fn acknowledgement_retains_later_edits_then_exact_latest_snapshot_clears_dirty() {
+        let mut app = app();
+        let mut saves = super::ManagedSaves::default();
+        let first = crate::native_document::export(&mut app).unwrap();
+        saves.remember("request-1", &first, &app).unwrap();
+        assert!(app.session.active().unwrap().is_dirty());
+        app.run("cell.set", json!({"cell":"A1", "input":"second"})).unwrap();
+        assert!(saves.acknowledge(&mut app, "request-1", &first.document, &first.revision));
+        assert!(app.session.active().unwrap().is_dirty());
+        let second = crate::native_document::export(&mut app).unwrap();
+        saves.remember("request-2", &second, &app).unwrap();
+        assert!(saves.acknowledge(&mut app, "request-2", &second.document, &second.revision));
+        assert!(!app.session.active().unwrap().is_dirty());
+    }
+
+    #[test]
+    fn old_or_foreign_acknowledgement_cannot_replace_the_latest_baseline() {
+        let mut app = app();
+        let mut saves = super::ManagedSaves::default();
+        let first = crate::native_document::export(&mut app).unwrap();
+        saves.remember("request-1", &first, &app).unwrap();
+        app.run("cell.set", json!({"cell":"A1", "input":"second"})).unwrap();
+        let second = crate::native_document::export(&mut app).unwrap();
+        saves.remember("request-2", &second, &app).unwrap();
+        let baseline = app.session.active().unwrap().saved.clone();
+        for (id, document, revision) in [
+            ("request-1", first.document.as_str(), first.revision.as_str()),
+            ("wrong-request", second.document.as_str(), second.revision.as_str()),
+            ("request-2", "wrong-document", second.revision.as_str()),
+            ("request-2", second.document.as_str(), "wrong-revision"),
+        ] {
+            assert!(!saves.acknowledge(&mut app, id, document, revision));
+            assert!(Arc::ptr_eq(&app.session.active().unwrap().saved, &baseline));
+        }
+        assert!(app.session.active().unwrap().is_dirty());
+        assert!(saves.acknowledge(&mut app, "request-2", &second.document, &second.revision));
+        assert!(!app.session.active().unwrap().is_dirty());
+    }
+
+    #[test]
+    fn an_acknowledgement_for_another_document_leaves_its_baseline_untouched() {
+        let mut app = app();
+        let mut saves = super::ManagedSaves::default();
+        let first = crate::native_document::export(&mut app).unwrap();
+        saves.remember("request-1", &first, &app).unwrap();
+        app.session.new_workbook();
+        app.run("cell.set", json!({"cell":"A1", "input":"new document"})).unwrap();
+        let baseline = app.session.active().unwrap().saved.clone();
+        assert!(!saves.acknowledge(&mut app, "request-1", &first.document, &first.revision));
+        assert!(Arc::ptr_eq(&app.session.active().unwrap().saved, &baseline));
+        assert!(app.session.active().unwrap().is_dirty());
+    }
+}
diff --git a/apps/gridcraft-web/src/native_document.rs b/apps/gridcraft-web/src/native_document.rs
new file mode 100644
--- /dev/null
+++ b/apps/gridcraft-web/src/native_document.rs
@@ -0,0 +1,130 @@
+//! Whole native documents for same-origin embedding hosts.
+
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+pub struct Snapshot {
+    pub bytes: Vec<u8>,
+    pub document: String,
+    pub revision: String,
+}
+
+/// Encode the complete native workbook through the same command as desktop/CLI.
+/// `SheetApp::run` settles pending cell and text-box drafts before file commands.
+/// Export is not a durable save and deliberately leaves the dirty baseline alone.
+pub fn export(app: &mut SheetApp) -> Result<Snapshot, String> {
+    let encoded = app.run("file.saveBytes", json!({"format": "json"}))?;
+    let bytes = encoded
+        .get("base64")
+        .and_then(serde_json::Value::as_str)
+        .and_then(gridcraft_engine::io::base64_decode)
+        .ok_or_else(|| "The engine did not return a native workbook.".to_string())?;
+    validate(&bytes)?;
+    // A pending draft may have advanced the revision during the file command.
+    let doc = app.session.active().ok_or_else(|| "No workbook is open.".to_string())?;
+    Ok(Snapshot { bytes, document: doc.uid.to_string(), revision: doc.revision.to_string() })
+}
+
+/// Limit the new host validation RPC before crossing the JS-to-Rust copy boundary.
+/// Native export and the legacy file-open path keep their existing behavior.
+pub fn validate_length(length: usize) -> Result<(), String> {
+    if length > 32 * 1024 * 1024 {
+        return Err("Native validation accepts at most 32 MiB.".into());
+    }
+    Ok(())
+}
+
+/// Decode using the application's engine without changing the open workbook.
+pub fn validate(bytes: &[u8]) -> Result<(), String> {
+    // The file reader also sniffs XLSX irrespective of the supplied extension.
+    // This endpoint promises native JSON, not an arbitrary import.
+    serde_json::from_slice::<serde_json::Value>(bytes).map_err(|e| e.to_string())?;
+    let (_, warnings) = gridcraft_engine::io::open_bytes("snapshot.json", bytes).map_err(|e| e.to_string())?;
+    if !warnings.is_empty() {
+        return Err(warnings.join("\n"));
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use gridcraft_engine::Session;
+    use gridcraft_ui_egui::{Services, SheetApp};
+    use serde_json::json;
+
+    fn app() -> SheetApp {
+        let mut session = Session::new();
+        session.new_workbook();
+        SheetApp::new(session, Services::default())
+    }
+
+    #[test]
+    fn native_export_commits_the_cell_draft_without_claiming_durable_save() {
+        let mut app = app();
+        app.run("cell.set", json!({"cell":"A1", "input":"21"})).unwrap();
+        app.run("cell.set", json!({"cell":"B1", "input":"=A1*2"})).unwrap();
+        app.run("home.bold", json!({"range":"A1", "on":true})).unwrap();
+        app.run("home.numberFormat", json!({"range":"B1", "code":"0.00"})).unwrap();
+        app.run("home.mergeCells", json!({"range":"A4:C4"})).unwrap();
+        app.run("home.rowHeight", json!({"rows":"5:5", "height":42})).unwrap();
+        app.run("home.insertSheet", json!({"name":"Second"})).unwrap();
+        app.run("cell.set", json!({"cell":"A1", "input":"Second sheet"})).unwrap();
+        app.run("sheet.activate", json!({"sheet":"Sheet1"})).unwrap();
+        app.run("selection.set", json!({"cell":"A1"})).unwrap();
+        let before = app.session.active().unwrap().revision;
+        let document = app.session.active().unwrap().uid.to_string();
+        app.begin_edit(Some("22".into()), false);
+        let snapshot = super::export(&mut app).unwrap();
+        assert_eq!(snapshot.document, document);
+        assert!(app.session.active().unwrap().revision > before);
+        assert_eq!(snapshot.revision, app.session.active().unwrap().revision.to_string());
+        let bytes = snapshot.bytes;
+        assert!(app.editor.is_none());
+        assert!(app.session.active().unwrap().is_dirty());
+        let (wb, _) = gridcraft_engine::io::open_bytes("snapshot.json", &bytes).unwrap();
+        // Formula AST caches and rebuilt style indices are intentionally not persisted.
+        assert_eq!(serde_json::to_value(&wb).unwrap(), serde_json::to_value(app.session.active().unwrap().wb.as_ref()).unwrap());
+        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
+        assert!(json.get("sheets").is_some());
+    }
+
+    #[test]
+    fn native_validation_length_boundary_is_checked_without_allocating_payloads() {
+        assert!(super::validate_length(31 * 1024 * 1024).is_ok());
+        assert!(super::validate_length(32 * 1024 * 1024).is_ok());
+        assert!(super::validate_length(32 * 1024 * 1024 + 1).is_err());
+    }
+
+    #[test]
+    fn native_validation_rejects_invalid_documents() {
+        assert!(super::validate(b"not JSON").is_err());
+        assert!(super::validate(br#"{"sheets":"broken"}"#).is_err());
+    }
+
+    #[test]
+    fn rejected_draft_is_retained_instead_of_exporting_an_older_cell() {
+        let mut app = app();
+        app.begin_edit(Some("=1+".into()), false);
+        assert!(super::export(&mut app).is_err());
+        assert!(app.editor.is_some());
+    }
+
+    #[test]
+    fn native_validation_does_not_accept_an_xlsx_disguised_as_json() {
+        let mut session = Session::new();
+        session.new_workbook();
+        let encoded = session.execute("file.saveBytes", json!({"format":"xlsx"})).unwrap();
+        let bytes = gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap();
+        assert!(super::validate(&bytes).is_err());
+    }
+
+    #[test]
+    fn native_export_has_no_four_mib_checkpoint_limit() {
+        let mut app = app();
+        let values: Vec<Vec<String>> = (0..130).map(|_| vec!["x".repeat(32767)]).collect();
+        app.run("range.setValues", json!({"range":"A1", "values":values})).unwrap();
+        let bytes = super::export(&mut app).unwrap().bytes;
+        assert!(bytes.len() > 4 * 1024 * 1024);
+        super::validate(&bytes).unwrap();
+    }
+}
diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs
--- a/apps/gridcraft-web/src/web.rs
+++ b/apps/gridcraft-web/src/web.rs
@@ -39,7 +39,11 @@ pub fn start() {
         }
         // `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
         // workbooks in it and stores what it saves (`host`).
-        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 result = eframe::WebRunner::new()
             .start(
