diff --git a/crates/ui-egui/src/workspace_state/legacy.rs b/crates/ui-egui/src/workspace_state/legacy.rs
--- a/crates/ui-egui/src/workspace_state/legacy.rs
+++ b/crates/ui-egui/src/workspace_state/legacy.rs
@@ -25,6 +25,9 @@ use super::{Snapshot, VERSION};
 use crate::dock::{Layout, PanelKind, Workspace};
 use crate::state::UiState;
 
+#[cfg(test)]
+mod tests;
+
 #[derive(Deserialize)]
 enum OldSplitSize {
     Ratio(f32),
@@ -166,7 +169,7 @@ fn node(old: OldNode, depth: usize) -> Result<Node<PanelKind>, String> {
             first: Box::new(node(*a, depth.saturating_add(1))?),
             second: Box::new(node(*b, depth.saturating_add(1))?),
         },
-        OldNode::Stack { entries } => column(entries)?,
+        OldNode::Stack { entries } => column(entries, depth)?,
     })
 }
 
@@ -183,7 +186,7 @@ struct Frame {
 }
 
 /// A column of stacked frames as tab groups split top to bottom (see the module docs).
-fn column(entries: Vec<OldStackEntry>) -> Result<Node<PanelKind>, String> {
+fn column(entries: Vec<OldStackEntry>, depth: usize) -> Result<Node<PanelKind>, String> {
     let mut frames: Vec<Frame> = Vec::new();
     // Tabs of closed frames above the first open one.
     let mut waiting: Vec<PanelKind> = Vec::new();
@@ -199,6 +202,9 @@ fn column(entries: Vec<OldStackEntry>) -> Result<Node<PanelKind>, String> {
         }
         let active = entry.active.min(panels.len().saturating_sub(1));
         if entry.open {
+            if depth.saturating_add(frames.len()) > craft_ui::docking::MAX_DEPTH {
+                return Err("workspace dock tree exceeds supported limits".into());
+            }
             let height = entry.height.filter(|h| h.is_finite() && *h > 0.0).map(|h| h.min(MAX_FRAME));
             frames.push(Frame { panels, active, height });
         } else if let Some(above) = frames.last_mut() {
diff --git a/crates/ui-egui/src/workspace_state/legacy/tests.rs b/crates/ui-egui/src/workspace_state/legacy/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/workspace_state/legacy/tests.rs
@@ -0,0 +1,35 @@
+use super::*;
+
+fn stack(count: usize) -> OldNode {
+    OldNode::Stack {
+        entries: (0..count).map(|_| OldStackEntry { panel: Some(PanelKind::Info), panels: Vec::new(), active: 0, open: true, height: None }).collect(),
+    }
+}
+
+#[test]
+fn stacked_frames_respect_the_remaining_tree_depth() {
+    let limit = craft_ui::docking::MAX_DEPTH;
+    assert!(node(stack(limit + 1), 0).is_ok());
+    assert!(node(stack(limit + 2), 0).is_err());
+    assert!(node(stack(1), limit).is_ok());
+    assert!(node(stack(2), limit).is_err());
+    assert!(node(stack(limit), 2).is_err());
+}
+
+#[test]
+fn oversized_legacy_stack_load_does_not_abort() {
+    const CHILD: &str = "EFFECTCRAFT_TEST_LEGACY_STACK_CHILD";
+    if std::env::var_os(CHILD).is_some() {
+        let entry = serde_json::json!({"panel": "Info", "open": true});
+        let value = serde_json::json!({"workspaces": {"Oversized": {"Stack": {"entries": vec![entry; 20_000]}}}});
+        assert!(serde_json::to_vec(&value).unwrap().len() < super::super::MAX_BYTES);
+        assert!(migrate(value, &UiState::default()).is_err());
+        return;
+    }
+    let output = std::process::Command::new(std::env::current_exe().unwrap())
+        .args(["--exact", "workspace_state::legacy::tests::oversized_legacy_stack_load_does_not_abort", "--nocapture"])
+        .env(CHILD, "1")
+        .output()
+        .unwrap();
+    assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
+}
