diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2112,6 +2112,7 @@ dependencies = [
  "log",
  "rfd",
  "serde_json",
+ "sha2 0.11.1",
  "wasm-bindgen",
  "wasm-bindgen-futures",
  "web-sys",
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -129,6 +129,49 @@ 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`). Add an optional
+`requestId` for a correlated `gridcraft:opened` reply (`requestId`, `document`, `revision`, or
+`requestId`, `error`). It is sent only after the engine actually imports the workbook, not when
+bytes are queued; overlapping correlated imports are rejected. Unsaved work and pending native
+drafts refuse the import before they are committed or replaced. Wait for this reply before
+associating a document with a host save destination. 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 or pending cell/text-box draft change,
+including further edits while already dirty. Revisions are bounded opaque string equality tokens,
+never JavaScript numbers. An idle host may request export without Enter; the engine flushes its
+pending drafts before the export reply and corresponding changed event identify the same state. `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.
+
+For an opaque sandboxed iframe, use `?host=port&parent_origin=https%3A%2F%2Fhost.example`
+(and `&save=managed` for durable native saves). Serve it at a pinned
+`/b/<64 lowercase hex digits>/index.html` path; a prefix before `/b/` is allowed.
+The public assets must permit cross-origin loading from the opaque frame. The editor sends only
+`{type:"gridcraft:hello", protocol:1, bundle}` over the window channel, addressed to the
+configured canonical HTTP(S) parent origin. The parent must check the iframe source, opaque
+origin and expected bundle pin, then send `{type:"gridcraft:connect", protocol:1, bundle}`
+with exactly one transferred MessagePort. Only the exact parent and origin can connect, once.
+All existing document requests and notifications then use structured objects on that private
+port, including transferred ArrayBuffer bytes up to 32 MiB. Subsequent window messages are
+ignored. The origin and bundle handshake bind the transport; they grant no storage authority
+or runtime credentials. The legacy `host=parent` mode retains its existing same-origin policy.
+
 ### Ribbon access keys
 
 Press and release **left Alt** on Windows/Linux/BSD, or press **F10** on any platform,
diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml
--- a/apps/gridcraft-web/Cargo.toml
+++ b/apps/gridcraft-web/Cargo.toml
@@ -19,6 +19,7 @@ gridcraft-ui-egui = { workspace = true }
 egui = { workspace = true }
 log = { workspace = true }
 serde_json = { workspace = true }
+sha2 = { workspace = true }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
@@ -26,4 +27,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", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "MessagePort", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
diff --git a/apps/gridcraft-web/src/draft_state.rs b/apps/gridcraft-web/src/draft_state.rs
new file mode 100644
--- /dev/null
+++ b/apps/gridcraft-web/src/draft_state.rs
@@ -0,0 +1,162 @@
+//! Bounded host revision pulses include pending native editor drafts.
+
+use gridcraft_engine::core::CellRef;
+use gridcraft_ui_egui::SheetApp;
+use sha2::{Digest, Sha256};
+
+#[derive(PartialEq)]
+struct Fingerprint {
+    active: Option<(u64, u64)>,
+    cell: Option<(u64, usize, CellRef, [u8; 32])>,
+    text_box: Option<(u32, [u8; 32])>,
+}
+
+fn changed_cell(app: &SheetApp) -> bool {
+    app.editor.as_ref().is_some_and(|edit| edit.original.as_ref().is_none_or(|original| original != &edit.text))
+}
+
+/// Draft buffers are native editor state, not yet part of `DocState::wb`.
+pub fn dirty(app: &SheetApp) -> bool {
+    app.session.documents().iter().any(|doc| doc.is_dirty())
+        || changed_cell(app)
+        || app.text_box_editor.as_ref().is_some_and(|edit| edit.is_modified())
+}
+
+/// Constant-size activity state: never clone or retain an input-sized draft.
+/// The counter is an opaque equality token, not a JavaScript number or a lease.
+#[derive(Default)]
+pub struct Revisions {
+    previous: Option<Fingerprint>,
+    serial: u64,
+}
+
+impl Revisions {
+    pub fn observe(&mut self, app: &SheetApp) -> bool {
+        let fingerprint = Fingerprint {
+            active: app.session.active().map(|doc| (doc.uid, doc.revision)),
+            cell: app
+                .editor
+                .as_ref()
+                .filter(|_| changed_cell(app))
+                .map(|edit| (edit.doc, edit.sheet, edit.cell, Sha256::digest(edit.text.as_bytes()).into())),
+            text_box: app
+                .text_box_editor
+                .as_ref()
+                .filter(|edit| edit.is_modified())
+                .map(|edit| (edit.id, Sha256::digest(edit.text.as_bytes()).into())),
+        };
+        if self.previous.as_ref() == Some(&fingerprint) {
+            return false;
+        }
+        self.previous = Some(fingerprint);
+        self.serial = self.serial.wrapping_add(1);
+        true
+    }
+
+    pub fn sequence(&self) -> u64 {
+        self.serial
+    }
+
+    pub fn revision(&self) -> String {
+        self.sequence().to_string()
+    }
+}
+
+#[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();
+        let mut app = SheetApp::new(session, Services::default());
+        app.run("cell.set", json!({"cell":"A1", "input":"first"})).unwrap();
+        app
+    }
+
+    #[test]
+    fn pending_cell_survives_older_ack_and_every_text_edit_pulses_without_enter() {
+        let mut app = app();
+        let mut revisions = super::Revisions::default();
+        revisions.observe(&app);
+        let mut saves = crate::managed_save::ManagedSaves::default();
+        let first = crate::native_document::export(&mut app).unwrap();
+        let first_revision = revisions.revision();
+        saves.remember("first", &first, &app, &first_revision).unwrap();
+        let engine_revision = app.session.active().unwrap().revision;
+        app.begin_edit(Some("second".into()), false);
+        assert!(revisions.observe(&app));
+        let pending = revisions.revision();
+        assert_ne!(first_revision, pending);
+        assert!(!revisions.observe(&app));
+        app.editor.as_mut().unwrap().text.push('!');
+        assert!(revisions.observe(&app));
+        assert_ne!(pending, revisions.revision());
+        assert_eq!(engine_revision, app.session.active().unwrap().revision);
+        assert!(saves.acknowledge(&mut app, "first", &first.document, &first_revision));
+        assert!(!app.session.active().unwrap().is_dirty());
+        assert!(super::dirty(&app), "a pending cell is unsaved even after the older workbook ACK");
+        let second = crate::native_document::export(&mut app).unwrap();
+        assert!(revisions.observe(&app), "export flushes the pending native cell before its revision is returned");
+        let second_revision = revisions.revision();
+        saves.remember("second", &second, &app, &second_revision).unwrap();
+        assert!(saves.acknowledge(&mut app, "second", &second.document, &second_revision));
+        assert!(!super::dirty(&app));
+    }
+
+    #[test]
+    fn unchanged_editor_is_clean_and_invalid_draft_stays_dirty_until_cancelled() {
+        let mut app = app();
+        let first = crate::native_document::export(&mut app).unwrap();
+        let mut saves = crate::managed_save::ManagedSaves::default();
+        saves.remember("first", &first, &app, &first.revision).unwrap();
+        assert!(saves.acknowledge(&mut app, "first", &first.document, &first.revision));
+        app.begin_edit(None, false);
+        assert!(!super::dirty(&app));
+        app.editor.as_mut().unwrap().text = "=1+".into();
+        assert!(super::dirty(&app));
+        assert!(crate::native_document::export(&mut app).is_err());
+        assert!(super::dirty(&app));
+        app.cancel_edit();
+        assert!(!super::dirty(&app));
+    }
+
+    #[test]
+    fn pending_text_box_survives_ack_and_flushes_through_the_native_engine() {
+        let mut app = app();
+        let id = app.run("insert.textBox", json!({"at":"B2","text":"original"})).unwrap()["shape"].as_u64().unwrap() as u32;
+        let first = crate::native_document::export(&mut app).unwrap();
+        let mut saves = crate::managed_save::ManagedSaves::default();
+        saves.remember("first", &first, &app, &first.revision).unwrap();
+        assert!(saves.acknowledge(&mut app, "first", &first.document, &first.revision));
+        assert!(app.begin_text_box_edit(id));
+        assert!(!super::dirty(&app));
+        let mut revisions = super::Revisions::default();
+        revisions.observe(&app);
+        let before = revisions.revision();
+        saves.remember("older", &first, &app, &before).unwrap();
+        app.text_box_editor.as_mut().unwrap().text = "pending text".into();
+        assert!(revisions.observe(&app));
+        assert_ne!(before, revisions.revision());
+        assert!(saves.acknowledge(&mut app, "older", &first.document, &before));
+        assert!(!app.session.active().unwrap().is_dirty());
+        assert!(super::dirty(&app));
+        let snapshot = crate::native_document::export(&mut app).unwrap();
+        let (wb, _) = gridcraft_engine::io::open_bytes("snapshot.json", &snapshot.bytes).unwrap();
+        assert_eq!(wb.active().unwrap().shapes.iter().find(|shape| shape.id == id).unwrap().text, "pending text");
+        assert!(app.text_box_editor.is_none());
+    }
+
+    #[test]
+    fn draft_counter_wraps_with_a_bounded_opaque_string() {
+        let mut app = app();
+        let mut revisions = super::Revisions::default();
+        revisions.observe(&app);
+        revisions.serial = u64::MAX;
+        app.begin_edit(Some("another".into()), false);
+        assert!(revisions.observe(&app));
+        assert_eq!(revisions.revision(), "0");
+    }
+}
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
@@ -4,13 +4,38 @@
 //! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
 //! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
 //!   `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
+//!   With optional `requestId`, `gridcraft:opened` replies with `{ requestId, document, revision }`
+//!   only after actual engine import, or `{ requestId, error }`. One correlated import may be pending.
+//!   Correlated import refuses unsaved work or pending native drafts before settling them.
 //! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
 //!   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 or pending cell/text-box draft changes. Revision is a bounded opaque activity
+//!   string, never a JavaScript number. 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.
 //!
-//! Only the parent window on the page's own origin is heard or answered. In host mode the page
+//! - `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.
+//!
+//! `?host=port&parent_origin=<canonical HTTP(S) origin>` opts into a one-shot private
+//! MessagePort instead. Only a content-free `{type:"gridcraft:hello",protocol:1,bundle}`
+//! uses the window channel. The bundle comes from the last `/b/<64hex>/index.html`
+//! path segment. The exact parent and origin must connect with that bundle and
+//! exactly one port; all document messages then use the port, with a 32 MiB byte limit.
+//! Later window frames are ignored. This transport grants no runtime credentials.
+//!
+//! In legacy window mode, only the parent 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.
 
 use std::cell::{Cell, RefCell};
@@ -28,8 +53,26 @@ const WORKBOOK_EXTS: &[&str] = &["xlsx"];
 /// The longest user name taken from `?author=`.
 const MAX_AUTHOR_CHARS: usize = 200;
 
+enum NativeRequest {
+    Open(String, String, Vec<u8>),
+    Export(String),
+    Validate(String, Vec<u8>),
+    RequestSave,
+    Acknowledge(String, String, String),
+}
+
+struct PortState {
+    handshake: crate::port_handshake::Handshake,
+    channel: Option<web_sys::MessagePort>,
+}
+
 #[derive(Clone)]
 pub struct Bridge {
+    port: Option<Rc<RefCell<PortState>>>,
+    revisions: Rc<RefCell<crate::draft_state::Revisions>>,
+    importing: Rc<Cell<bool>>,
+    managed: bool,
+    managed_saves: Rc<RefCell<crate::managed_save::ManagedSaves>>,
     window: web_sys::Window,
     parent: web_sys::Window,
     origin: String,
@@ -37,28 +80,71 @@ 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 {
+            port: None,
+            revisions: Rc::default(),
+            importing: Rc::default(),
+            managed,
+            managed_saves: Rc::default(),
+            window,
+            parent,
+            origin,
+            saved: Rc::default(),
+            reported: Rc::default(),
+            native: Rc::default(),
+            reported_revision: Rc::default(),
+        })
+    }
+
+    /// Opt-in private transport for an opaque sandboxed frame. The configured
+    /// origin binds only the handshake; it never supplies runtime credentials.
+    pub fn new_port(managed: bool, parent_origin: &str) -> Option<Self> {
+        let url = web_sys::Url::new(parent_origin).ok()?;
+        if !matches!(url.protocol().as_str(), "http:" | "https:")
+            || url.origin() != parent_origin
+            || !url.username().is_empty()
+            || !url.password().is_empty()
+        {
+            return None;
+        }
+        let mut bridge = Self::new(managed)?;
+        let path = bridge.window.location().pathname().ok()?;
+        let bundle = crate::port_handshake::bundle_from_path(&path)?.to_string();
+        bridge.port = Some(Rc::new(RefCell::new(PortState {
+            handshake: crate::port_handshake::Handshake::new(parent_origin.to_string(), bundle),
+            channel: None,
+        })));
+        Some(bridge)
     }
 
     /// Send saved and exported files to the parent instead of downloading them, and hear files
     /// from it.
     pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
-        self.listen(inbox, ctx);
+        if self.port.is_some() {
+            self.listen_port(inbox, ctx);
+        } else {
+            self.listen(inbox, ctx);
+        }
         let bridge = self.clone();
         services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
             if let Err(e) = bridge.post_save(name, bytes) {
                 log::error!("couldn't hand {name} to the page: {e}");
+                if bridge.port.is_some() {
+                    bridge.post_failed(&e);
+                }
             }
         }));
     }
@@ -72,6 +158,18 @@ impl Bridge {
     }
 
     pub fn post_ready(&self) {
+        if let Some(state) = &self.port {
+            let mut state = state.borrow_mut();
+            if state.channel.is_none() {
+                if let Some(hello) =
+                    message(&[("type", "gridcraft:hello".into()), ("protocol", 1.into()), ("bundle", state.handshake.bundle.as_str().into())])
+                    && self.parent.post_message(&hello, &state.handshake.parent_origin).is_ok()
+                {
+                    state.handshake.announced();
+                }
+                return;
+            }
+        }
         self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
     }
 
@@ -82,6 +180,102 @@ 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) {
+        self.revisions.borrow_mut().observe(app);
+        let requests = std::mem::take(&mut *self.native.borrow_mut());
+        for request in requests {
+            match request {
+                NativeRequest::Open(id, name, bytes) => {
+                    let result = crate::native_document::open(app, &name, &bytes).and_then(|opened| {
+                        if app
+                            .session
+                            .active()
+                            .is_none_or(|doc| doc.uid.to_string() != opened.document || doc.revision.to_string() != opened.revision)
+                        {
+                            return Err("The imported workbook is no longer active.".into());
+                        }
+                        Ok(opened)
+                    });
+                    self.importing.set(false);
+                    match result {
+                        Ok(opened) => {
+                            self.revisions.borrow_mut().observe(app);
+                            self.send(&[
+                                ("type", "gridcraft:opened".into()),
+                                ("requestId", id.as_str().into()),
+                                ("document", opened.document.as_str().into()),
+                                ("revision", self.revisions.borrow().revision().into()),
+                            ]);
+                        }
+                        Err(error) => self.open_failed(&id, &error),
+                    }
+                }
+                NativeRequest::Export(id) => match crate::native_document::export(app) {
+                    Ok(snapshot) => {
+                        self.revisions.borrow_mut().observe(app);
+                        let revision = self.revisions.borrow().revision();
+                        if self.port.is_some()
+                            && let Err(error) = crate::native_document::validate_length(snapshot.bytes.len())
+                        {
+                            self.send(&[
+                                ("type", "gridcraft:native-exported".into()),
+                                ("requestId", id.as_str().into()),
+                                ("error", error.as_str().into()),
+                            ]);
+                            continue;
+                        }
+                        if self.managed
+                            && let Err(error) = self.managed_saves.borrow_mut().remember(&id, &snapshot, app, &revision)
+                        {
+                            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", revision.as_str().into()),
+                            ("bytes", buffer.clone().into()),
+                        ]) {
+                            let _ = self.post(&message, Some(&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", self.revisions.borrow().revision().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()
@@ -91,20 +285,44 @@ impl Bridge {
                 doc.title = name.clone();
             }
         }
-        let dirty = app.session.documents().iter().any(|d| d.is_dirty());
+        self.revisions.borrow_mut().observe(app);
+        let dirty = crate::draft_state::dirty(app);
+        let initial = self.reported.get().is_none();
         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, self.revisions.borrow().sequence()));
+        if revision != self.reported_revision.get() || initial && revision.is_none() && self.port.is_some() {
+            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(());
+        }
+        if self.port.is_some() {
+            crate::native_document::validate_length(bytes.len())?;
+        }
         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())?;
-        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.post(&message, Some(&js_sys::Array::of1(&buffer)))?;
         self.saved.borrow_mut().push(name);
         Ok(())
     }
@@ -112,18 +330,12 @@ 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") {
-                return;
-            }
-            let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
-            let Some(bytes) = bytes(&data) else { return };
-            inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
-            ctx.request_repaint();
+            bridge.receive(&event.data(), &inbox, &ctx);
         });
         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.
@@ -131,10 +343,174 @@ impl Bridge {
         }
     }
 
+    fn receive(&self, data: &JsValue, inbox: &Inbox, ctx: &egui::Context) {
+        let bridge = self;
+        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());
+        if js_sys::Reflect::get(data, &"requestId".into()).ok().is_some_and(|value| !value.is_undefined()) {
+            let Some(id) = string(data, "requestId").filter(|id| !id.is_empty() && id.len() <= 128) else { return };
+            if bridge.importing.get() || bridge.native.borrow().len() >= 8 {
+                bridge.open_failed(&id, "Another workbook import is pending.");
+                return;
+            }
+            let bytes = match native_bytes(data) {
+                Ok(bytes) => bytes,
+                Err(error) => {
+                    bridge.open_failed(&id, &error);
+                    return;
+                }
+            };
+            bridge.importing.set(true);
+            bridge.native.borrow_mut().push(NativeRequest::Open(id, name, bytes));
+            ctx.request_repaint();
+            return;
+        }
+        let bytes = if bridge.port.is_some() {
+            match native_bytes(data) {
+                Ok(bytes) => bytes,
+                Err(error) => {
+                    bridge.post_failed(&error);
+                    return;
+                }
+            }
+        } else {
+            let Some(bytes) = bytes(data) else { return };
+            bytes
+        };
+        inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
+        ctx.request_repaint();
+    }
+
+    fn open_failed(&self, id: &str, error: &str) {
+        self.send(&[("type", "gridcraft:opened".into()), ("requestId", id.into()), ("error", error.into())]);
+    }
+
+    fn listen_port(&self, inbox: Inbox, 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.borrow().channel.is_some() {
+                return; // All window frames are ignored after the one-shot bind.
+            }
+            let data = event.data();
+            if !closed_connect(&data) {
+                return;
+            }
+            let ports = event.ports();
+            let Ok(channel) = ports.get(0).dyn_into::<web_sys::MessagePort>() else { return };
+            let protocol = js_sys::Reflect::get(&data, &"protocol".into()).ok().and_then(|value| value.as_f64());
+            let parent = event.source().is_some_and(|source| js_sys::Object::is(&source, &bridge.parent));
+            if !state.borrow_mut().handshake.accept(
+                parent,
+                &event.origin(),
+                string(&data, "type").as_deref(),
+                protocol,
+                string(&data, "bundle").as_deref(),
+                ports.length() as usize,
+            ) {
+                return;
+            }
+            let receiver = bridge.clone();
+            let inbox = inbox.clone();
+            let ctx = ctx.clone();
+            let repaint = ctx.clone();
+            let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
+                receiver.receive(&event.data(), &inbox, &ctx);
+            });
+            channel.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
+            channel.start();
+            state.borrow_mut().channel = Some(channel);
+            on_message.forget(); // Exactly one private listener lives until the frame closes.
+            bridge.reported.set(None);
+            bridge.reported_revision.set(None);
+            bridge.post_ready();
+            repaint.request_repaint();
+        });
+        if self.window.add_event_listener_with_callback("message", on_connect.as_ref().unchecked_ref()).is_ok() {
+            on_connect.forget();
+        }
+    }
+
+    fn post(&self, value: &JsValue, transfer: Option<&js_sys::Array>) -> Result<(), String> {
+        let result = if let Some(state) = &self.port {
+            let state = state.borrow();
+            let channel = state.channel.as_ref().ok_or_else(|| "The private host port is not connected.".to_string())?;
+            match transfer {
+                Some(transfer) => channel.post_message_with_transferable(value, transfer),
+                None => channel.post_message(value),
+            }
+        } else {
+            match transfer {
+                Some(transfer) => self.parent.post_message_with_transfer(value, &self.origin, transfer),
+                None => self.parent.post_message(value, &self.origin),
+            }
+        };
+        result.map_err(|error| error.as_string().unwrap_or_else(|| format!("{error:?}")))
+    }
+
     /// Post a message to the parent; false if it couldn't be sent.
     fn send(&self, fields: &[(&str, JsValue)]) -> bool {
-        message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
+        message(fields).is_some_and(|m| self.post(&m, None).is_ok())
+    }
+}
+
+/// A closed plain-object handshake; inherited fields, arrays and extra keys
+/// cannot smuggle content or credentials onto the public window channel.
+fn closed_connect(value: &JsValue) -> bool {
+    if !value.is_object() || value.is_null() || js_sys::Array::is_array(value) {
+        return false;
     }
+    if !js_sys::Object::is(&js_sys::Object::get_prototype_of(value), &js_sys::Object::get_prototype_of(&js_sys::Object::new())) {
+        return false;
+    }
+    let keys = js_sys::Object::keys(value.unchecked_ref::<js_sys::Object>());
+    keys.length() == 3 && keys.iter().all(|key| key.as_string().is_some_and(|key| matches!(key.as_str(), "type" | "protocol" | "bundle")))
 }
 
 fn is_workbook(name: &str) -> bool {
@@ -164,3 +540,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
@@ -16,9 +16,17 @@
 //! interface size, editing options…) are kept in `localStorage` (`prefs`).
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 
+#[cfg(any(target_arch = "wasm32", test))]
+mod draft_state;
 #[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 port_handshake;
+#[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, revision: &str) -> 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: revision.to_string(),
+            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, &first.revision).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, &second.revision).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, &first.revision).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, &second.revision).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, &first.revision).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,229 @@
+//! Whole native documents for opt-in embedding hosts.
+
+use gridcraft_ui_egui::SheetApp;
+use serde_json::json;
+
+pub struct Snapshot {
+    pub bytes: Vec<u8>,
+    pub document: String,
+    pub revision: String,
+}
+
+pub struct Opened {
+    pub document: String,
+    pub revision: String,
+}
+
+/// A correlated host import completes through the real UI/engine command before
+/// its identity is returned. Merely enqueueing bytes in the file-picker inbox
+/// cannot prove which document was opened.
+pub fn open(app: &mut SheetApp, name: &str, bytes: &[u8]) -> Result<Opened, String> {
+    // File commands settle drafts. Refuse before that boundary so an error or
+    // a replacement cannot silently commit or discard unacknowledged work.
+    if crate::draft_state::dirty(app) || app.editor.is_some() || app.text_box_editor.is_some() {
+        return Err("Save or discard unsaved work and finish native drafts before opening another workbook.".into());
+    }
+    let result = app.run("file.open", json!({"name":name,"base64":gridcraft_engine::io::base64_encode(bytes)}))?;
+    if let Some(warnings) = result.get("warnings").and_then(serde_json::Value::as_array)
+        && !warnings.is_empty()
+    {
+        return Err(warnings.iter().filter_map(serde_json::Value::as_str).collect::<Vec<_>>().join("\n"));
+    }
+    let doc = app.session.active().ok_or_else(|| "The engine did not open a workbook.".to_string())?;
+    Ok(Opened { document: doc.uid.to_string(), revision: doc.revision.to_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 native RPC bytes before crossing the JS-to-Rust copy boundary.
+/// The private port also bounds file import/export; legacy window import stays unchanged.
+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 correlated_open_identity_belongs_to_the_import_not_a_later_file_new() {
+        let mut app = app();
+        let mut source = Session::new();
+        source.new_workbook();
+        source.execute("cell.set", json!({"cell":"A1","input":"import A"})).unwrap();
+        let encoded = source.execute("file.saveBytes", json!({"format":"json"})).unwrap();
+        let bytes = gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap();
+        let opened = super::open(&mut app, "owned.json", &bytes).unwrap();
+        assert_eq!(opened.document, app.session.active().unwrap().uid.to_string());
+        assert_eq!(opened.revision, app.session.active().unwrap().revision.to_string());
+        assert_eq!(app.run("cell.get", json!({"cell":"A1"})).unwrap()["value"], "import A");
+        app.run("file.new", json!({})).unwrap();
+        assert_ne!(opened.document, app.session.active().unwrap().uid.to_string());
+        let before = app.session.active().unwrap().uid;
+        assert!(super::open(&mut app, "invalid.json", b"invalid JSON").is_err());
+        assert_eq!(before, app.session.active().unwrap().uid);
+    }
+
+    fn imported_bytes(value: &str) -> Vec<u8> {
+        let mut source = Session::new();
+        source.new_workbook();
+        source.execute("cell.set", json!({"cell":"A1","input":value})).unwrap();
+        let encoded = source.execute("file.saveBytes", json!({"format":"json"})).unwrap();
+        gridcraft_engine::io::base64_decode(encoded["base64"].as_str().unwrap()).unwrap()
+    }
+
+    #[test]
+    fn same_import_name_with_new_bytes_opens_a_new_actual_document() {
+        let mut app = app();
+        let first = super::open(&mut app, "owned.json", &imported_bytes("import A")).unwrap();
+        let second = super::open(&mut app, "owned.json", &imported_bytes("import B")).unwrap();
+        assert_ne!(first.document, second.document);
+        assert_eq!(second.document, app.session.active().unwrap().uid.to_string());
+        assert_eq!(app.run("cell.get", json!({"cell":"A1"})).unwrap()["value"], "import B");
+    }
+
+    #[test]
+    fn owned_import_refuses_dirty_work_without_replacing_or_settling_it() {
+        let mut app = app();
+        app.run("cell.set", json!({"cell":"A1","input":"unsaved"})).unwrap();
+        let before = app.session.active().unwrap().wb.clone();
+        let uid = app.session.active().unwrap().uid;
+        assert!(super::open(&mut app, "owned.json", &imported_bytes("replacement")).is_err());
+        assert_eq!(uid, app.session.active().unwrap().uid);
+        assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().wb));
+        assert!(crate::draft_state::dirty(&app));
+    }
+
+    #[test]
+    fn owned_import_error_retains_pending_cell_without_committing_it() {
+        let mut app = app();
+        let before = app.session.active().unwrap().wb.clone();
+        app.begin_edit(Some("pending cell".into()), false);
+        assert!(super::open(&mut app, "invalid.json", b"invalid JSON").is_err());
+        assert_eq!(app.editor.as_ref().unwrap().text, "pending cell");
+        assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().wb));
+        assert!(crate::draft_state::dirty(&app));
+    }
+
+    #[test]
+    fn owned_import_refuses_pending_text_box_without_committing_it() {
+        let mut app = app();
+        let id = app.run("insert.textBox", json!({"at":"B2","text":"original"})).unwrap()["shape"].as_u64().unwrap() as u32;
+        let doc = app.session.active_mut().unwrap();
+        doc.saved = doc.wb.clone();
+        let before = doc.wb.clone();
+        assert!(app.begin_text_box_edit(id));
+        app.text_box_editor.as_mut().unwrap().text = "pending text box".into();
+        assert!(super::open(&mut app, "owned.json", &imported_bytes("replacement")).is_err());
+        assert_eq!(app.text_box_editor.as_ref().unwrap().text, "pending text box");
+        assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().wb));
+        assert!(crate::draft_state::dirty(&app));
+    }
+
+    #[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/port_handshake.rs b/apps/gridcraft-web/src/port_handshake.rs
new file mode 100644
--- /dev/null
+++ b/apps/gridcraft-web/src/port_handshake.rs
@@ -0,0 +1,77 @@
+//! One-shot binding of an opaque editor frame to its embedding parent.
+
+/// The bundle declares its identity from the last pinned deployment path segment.
+pub fn bundle_from_path(path: &str) -> Option<&str> {
+    let (_, tail) = path.rsplit_once("/b/")?;
+    let bundle = tail.strip_suffix("/index.html")?;
+    (bundle.len() == 64 && bundle.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))).then_some(bundle)
+}
+
+pub struct Handshake {
+    pub parent_origin: String,
+    pub bundle: String,
+    announced: bool,
+    connected: bool,
+}
+
+impl Handshake {
+    pub fn new(parent_origin: String, bundle: String) -> Self {
+        Self { parent_origin, bundle, announced: false, connected: false }
+    }
+
+    pub fn announced(&mut self) {
+        self.announced = true;
+    }
+
+    pub fn accept(&mut self, parent: bool, origin: &str, kind: Option<&str>, protocol: Option<f64>, bundle: Option<&str>, ports: usize) -> bool {
+        if !self.announced
+            || self.connected
+            || !parent
+            || origin != self.parent_origin
+            || kind != Some("gridcraft:connect")
+            || protocol != Some(1.0)
+            || bundle != Some(self.bundle.as_str())
+            || ports != 1
+        {
+            return false;
+        }
+        self.connected = true;
+        true
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn bundle_identity_is_the_last_pinned_bundle_path_segment() {
+        let hash = "a".repeat(64);
+        for prefix in ["", "/workbook-editor", "/b/old"] {
+            assert_eq!(super::bundle_from_path(&format!("{prefix}/b/{hash}/index.html")), Some(hash.as_str()));
+        }
+        for path in ["/index.html", "/b/short/index.html", "/b/ABC/index.html", "/b/a/other/index.html"] {
+            assert!(super::bundle_from_path(path).is_none());
+        }
+        assert!(super::bundle_from_path(&format!("/b/{}/index.html", "A".repeat(64))).is_none());
+    }
+
+    #[test]
+    fn only_exact_parent_origin_bundle_protocol_and_one_port_bind_once_after_hello() {
+        let hash = "a".repeat(64);
+        let mut binding = super::Handshake::new("https://host.example".into(), hash.clone());
+        assert!(!binding.accept(true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(&hash), 1));
+        binding.announced();
+        for (parent, origin, kind, protocol, bundle, ports) in [
+            (false, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 1),
+            (true, "https://other.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 1),
+            (true, "https://host.example", Some("gridcraft:open"), Some(1.0), Some(hash.as_str()), 1),
+            (true, "https://host.example", Some("gridcraft:connect"), Some(2.0), Some(hash.as_str()), 1),
+            (true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some("wrong"), 1),
+            (true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 0),
+            (true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(hash.as_str()), 2),
+        ] {
+            assert!(!binding.accept(parent, origin, kind, protocol, bundle, ports));
+        }
+        assert!(binding.accept(true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(&hash), 1));
+        assert!(!binding.accept(true, "https://host.example", Some("gridcraft:connect"), Some(1.0), Some(&hash), 1));
+    }
+}
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,12 @@ 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 managed = query_param("save").as_deref() == Some("managed");
+        let bridge = match query_param("host").as_deref() {
+            Some("parent") => crate::host::Bridge::new(managed),
+            Some("port") => query_param("parent_origin").and_then(|origin| crate::host::Bridge::new_port(managed, &origin)),
+            _ => None,
+        };
         let app_bridge = bridge.clone();
         let result = eframe::WebRunner::new()
             .start(
diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs
--- a/crates/ui-egui/src/text_box.rs
+++ b/crates/ui-egui/src/text_box.rs
@@ -17,6 +17,13 @@ pub struct EditState {
     rect: Option<Rect>,
 }
 
+impl EditState {
+    /// The native pending text differs from the engine's original text box.
+    pub fn is_modified(&self) -> bool {
+        self.text != self.original
+    }
+}
+
 /// Returns the stable id for the text-box editor.
 pub(crate) fn editor_id() -> egui::Id {
     egui::Id::unique("gridcraft.text_box_editor")
