diff --git a/crates/interchange/README.md b/crates/interchange/README.md
--- a/crates/interchange/README.md
+++ b/crates/interchange/README.md
@@ -60,8 +60,19 @@ Premiere Pro does not write FCPXML.
 | AAF | a composition mob of its own (not tagged top-level); the nest's clips are source clips that point at it, at its first track of their kind. With embedded or consolidated audio the media inside the nest is prepared too (`NestNeeds::Inside`) | a sequence the clips use, read once however many clips use it |
 | OMF | the nested sequence's sound, mixed by the engine with what is on the clip (`NestNeeds::Render`), as one clip named after the sequence. Without that essence: a gap, and the report names the sequence | media in the document |
 
-A sequence that claims to be inside itself (a damaged project or file) is a gap with a report
-entry in both directions; nothing recurses without a bound.
+A project sequence that claims to be inside itself is exported as a gap with a report entry.
+The AAF importer likewise reports recursive nested compositions as gaps.
+
+OMF import rejects cycles in sequence/effect inputs and in master/file-source segment lookups
+with an OMF parse error. Segment nesting is limited to 32 edges (64 during reference discovery).
+Source-mob chains exceeding 16 edges, including cycles, likewise return an error instead of audio gaps.
+Discovery, composition slots, expansion and source-segment lookups share a 65,536-visit budget
+for the whole document. Materialised lists in early-exit source lookups, effect slots and gain
+points also consume that budget.
+Repeated references consume that budget even when they produce no clips. References on the
+current recursion path detect cycles; reusing a segment elsewhere still preserves every edit
+in its original order. Exceeding a depth or work limit returns an error rather than a partial
+import. These limits bound graph traversal; Bento/AAF value-size amplification is a separate concern.
 
 The AAF reader also accepts Premiere Pro's layout of a composition: all video tracks in ONE slot
 (a nested scope with a segment per track, lowest first), and "top-level" tagged on nested
@@ -108,3 +119,7 @@ OMF carries no video at all.
   nested compositions tagged top-level) reads back as the same edit.
 - `src/omf/tests.rs`: the Bento label and TOC, the required properties of every OMF class
   written, reference resolution, embedded WAVE data, sequence lengths in samples.
+- `src/omf/traversal_tests.rs`: synthetic Bento graphs exercise cyclic sequences/effects and
+  source lookups, repeated-reference/automation-point expansion, shared budgets across tracks/compositions,
+  the depth boundary, preserved repeated edits, and truncation/bit-flip mutations under
+  `catch_unwind`. The master-source cycle regression aborts with stack overflow before the fix.
diff --git a/crates/interchange/src/omf/mod.rs b/crates/interchange/src/omf/mod.rs
--- a/crates/interchange/src/omf/mod.rs
+++ b/crates/interchange/src/omf/mod.rs
@@ -68,3 +68,6 @@ pub fn sniff(bytes: &[u8]) -> bool {
 
 #[cfg(test)]
 mod tests;
+
+#[cfg(test)]
+mod traversal_tests;
diff --git a/crates/interchange/src/omf/read.rs b/crates/interchange/src/omf/read.rs
--- a/crates/interchange/src/omf/read.rs
+++ b/crates/interchange/src/omf/read.rs
@@ -10,7 +10,13 @@ use crate::{Error, Report, Result};
 
 type Uid = [u8; 12];
 
+// One budget for the whole import, including repeated references and source lookups.
 const MAX_REFERENCE_VISITS: usize = 65_536;
+const MAX_SEGMENT_DEPTH: usize = 32;
+
+fn traversal_error(message: &str) -> Error {
+    Error::Parse { format: "OMF", message: message.into() }
+}
 
 struct R<'a, 'b> {
     c: &'b Container<'a>,
@@ -21,6 +27,7 @@ struct R<'a, 'b> {
     media: HashMap<Uid, Vec<u8>>,
     doc: Document,
     sources: HashMap<(Uid, u32, String), usize>,
+    remaining_visits: usize,
 }
 
 pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
@@ -29,7 +36,16 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
     let class_of = |o: u32| c.get(o, "OMFI:OOBJ:ObjClass").and_then(|v| v.get(..4).map(|b| [b[0], b[1], b[2], b[3]]));
     let head = c.objects.keys().copied().find(|&o| class_of(o) == Some(*b"HEAD")).ok_or_else(|| perr("no OMF header object".into()))?;
     let be = c.get(head, "OMFI:HEAD:ByteOrder").is_none_or(|v| v.first() != Some(&0x49));
-    let mut r = R { c: &c, report, be, mobs: HashMap::new(), media: HashMap::new(), doc: Document::default(), sources: HashMap::new() };
+    let mut r = R {
+        c: &c,
+        report,
+        be,
+        mobs: HashMap::new(),
+        media: HashMap::new(),
+        doc: Document::default(),
+        sources: HashMap::new(),
+        remaining_visits: MAX_REFERENCE_VISITS,
+    };
     let mob_list = r.refs(head, "OMFI:HEAD:Mobs");
     let mob_list = if mob_list.is_empty() {
         c.objects.keys().copied().filter(|&o| matches!(class_of(o).as_ref(), Some(b"CMOB" | b"MMOB" | b"SMOB"))).collect()
@@ -54,9 +70,8 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
     let cmobs: Vec<u32> = mob_list.iter().copied().filter(|&m| class_of(m) == Some(*b"CMOB")).collect();
     let mut referenced = HashSet::new();
     let mut visited = HashSet::new();
-    let mut visits = 0;
     for &m in &cmobs {
-        r.collect_refs(m, &mut referenced, &mut visited, &mut visits, 0);
+        r.collect_refs(m, &mut referenced, &mut visited, 0)?;
     }
     let primary: Vec<u32> = r.refs(head, "OMFI:HEAD:PrimaryMobs").into_iter().filter(|m| cmobs.contains(m)).collect();
     let top: Vec<u32> = if !primary.is_empty() {
@@ -65,7 +80,7 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
         cmobs.iter().copied().filter(|m| r.uid(*m, "OMFI:MOBJ:MobID").is_none_or(|id| !referenced.contains(&id))).collect()
     };
     for m in top {
-        let comp = r.composition(m);
+        let comp = r.composition(m)?;
         r.doc.compositions.push(comp);
     }
     if r.doc.compositions.is_empty() {
@@ -75,6 +90,28 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
 }
 
 impl R<'_, '_> {
+    fn visit(&mut self) -> Result<()> {
+        self.spend_visits(1)
+    }
+
+    fn spend_visits(&mut self, count: usize) -> Result<()> {
+        self.remaining_visits =
+            self.remaining_visits.checked_sub(count).ok_or_else(|| traversal_error("reference traversal budget exceeded (65,536 visits)"))?;
+        Ok(())
+    }
+
+    fn enter_segment(&mut self, seg: u32, path: &mut Vec<u32>) -> Result<()> {
+        self.visit()?;
+        if path.contains(&seg) {
+            return Err(traversal_error("cycle in segment references"));
+        }
+        if path.len() > MAX_SEGMENT_DEPTH {
+            return Err(traversal_error("segment nesting exceeds depth limit (32 edges)"));
+        }
+        path.push(seg);
+        Ok(())
+    }
+
     fn class(&self, o: u32) -> [u8; 4] {
         self.c.get(o, "OMFI:OOBJ:ObjClass").and_then(|v| v.get(..4).map(|b| [b[0], b[1], b[2], b[3]])).unwrap_or(*b"????")
     }
@@ -126,11 +163,14 @@ impl R<'_, '_> {
         self.i32(o, "OMFI:CPNT:Length").unwrap_or(0).max(0)
     }
 
-    fn collect_refs(&self, o: u32, out: &mut HashSet<Uid>, visited: &mut HashSet<u32>, visits: &mut usize, depth: usize) {
-        if depth > 64 || !visited.insert(o) || *visits >= MAX_REFERENCE_VISITS {
-            return;
+    fn collect_refs(&mut self, o: u32, out: &mut HashSet<Uid>, visited: &mut HashSet<u32>, depth: usize) -> Result<()> {
+        self.visit()?;
+        if !visited.insert(o) {
+            return Ok(());
+        }
+        if depth > 64 {
+            return Err(traversal_error("reference nesting exceeds depth limit (64 edges)"));
         }
-        *visits += 1;
         if self.class(o) == *b"SCLP"
             && let Some(id) = self.uid(o, "OMFI:SCLP:SourceID")
         {
@@ -138,17 +178,19 @@ impl R<'_, '_> {
         }
         for p in ["OMFI:MOBJ:Slots", "OMFI:SEQU:Components", "OMFI:EFFE:EffectSlots"] {
             for c in self.refs(o, p) {
-                self.collect_refs(c, out, visited, visits, depth + 1);
+                self.collect_refs(c, out, visited, depth + 1)?;
             }
         }
         for p in ["OMFI:MSLT:Segment", "OMFI:ESLT:ArgValue"] {
             if let Some(c) = self.r#ref(o, p) {
-                self.collect_refs(c, out, visited, visits, depth + 1);
+                self.collect_refs(c, out, visited, depth + 1)?;
             }
         }
+        Ok(())
     }
 
-    fn composition(&mut self, m: u32) -> Composition {
+    fn composition(&mut self, m: u32) -> Result<Composition> {
+        self.visit()?;
         let name = self.string(m, "OMFI:MOBJ:Name").unwrap_or_else(|| "Sequence".into());
         let mut comp = Composition {
             name,
@@ -164,6 +206,7 @@ impl R<'_, '_> {
         let slots = self.refs(m, "OMFI:MOBJ:Slots");
         let mut na = 0;
         for s in slots {
+            self.visit()?;
             let Some(seg) = self.r#ref(s, "OMFI:MSLT:Segment") else { continue };
             let rate = self.rational(s, "OMFI:MSLT:EditRate").filter(|r| r.0 > 0 && r.1 > 0).unwrap_or((25, 1));
             let kind = self.data_kind(seg);
@@ -190,7 +233,7 @@ impl R<'_, '_> {
             if rate.1 == 1 && rate.0 >= 8000 {
                 comp.sample_rate = rate.0 as u32;
             }
-            let items = self.segment(seg, rate, 0);
+            let items = self.segment(seg, rate, &mut Vec::new())?;
             na += 1;
             let td = self.r#ref(s, "OMFI:MSLT:TrackDesc");
             let tname = td.and_then(|t| self.string(t, "OMFI:TRKD:TrackName")).unwrap_or_else(|| format!("A{na}"));
@@ -206,24 +249,22 @@ impl R<'_, '_> {
                 .min(6);
             comp.tracks.push(CTrack { kind: CKind::Sound, name: tname, number, channels, items });
         }
-        comp
+        Ok(comp)
     }
 
-    fn segment(&mut self, seg: u32, rate: (i64, i64), depth: usize) -> Vec<CItem> {
+    fn segment(&mut self, seg: u32, rate: (i64, i64), path: &mut Vec<u32>) -> Result<Vec<CItem>> {
+        self.enter_segment(seg, path)?;
         let len = units_to_ticks(self.len(seg), rate.0, rate.1);
-        if depth > 32 {
-            return vec![CItem::Filler(len)];
-        }
-        match &self.class(seg) {
+        let items = match &self.class(seg) {
             b"SEQU" => {
                 let mut out = Vec::new();
                 for c in self.refs(seg, "OMFI:SEQU:Components") {
-                    out.extend(self.segment(c, rate, depth + 1));
+                    out.extend(self.segment(c, rate, path)?);
                 }
                 out
             }
             b"FILL" => vec![CItem::Filler(len)],
-            b"SCLP" => vec![self.clip(seg, rate)],
+            b"SCLP" => vec![self.clip(seg, rate)?],
             b"TRAN" => {
                 let cut = units_to_ticks(self.i32(seg, "OMFI:TRAN:CutPoint").unwrap_or(0), rate.0, rate.1);
                 let effect =
@@ -233,18 +274,20 @@ impl R<'_, '_> {
             b"EFFE" => {
                 let kind = self.r#ref(seg, "OMFI:EFFE:EffectKind").and_then(|k| self.string(k, "OMFI:EDEF:EffectID")).unwrap_or_default();
                 let slots = self.refs(seg, "OMFI:EFFE:EffectSlots");
+                self.spend_visits(slots.len())?;
                 let arg =
                     |r: &Self, id: i64| slots.iter().copied().find(|&s| r.i32(s, "OMFI:ESLT:ArgID") == Some(id)).and_then(|s| r.r#ref(s, "OMFI:ESLT:ArgValue"));
                 let Some(input) = arg(self, -1).or_else(|| arg(self, 1).filter(|_| kind != "omfi:effect:MonoAudioGain")) else {
-                    return vec![CItem::Filler(len)];
+                    path.pop();
+                    return Ok(vec![CItem::Filler(len)]);
                 };
                 let gain = if kind == "omfi:effect:MonoAudioGain" {
-                    arg(self, 1).and_then(|v| self.gain(v, len))
+                    arg(self, 1).map(|v| self.gain(v, len)).transpose()?.flatten()
                 } else {
                     self.report.warn("OMF effects other than audio gain are not imported (their input is used)");
                     None
                 };
-                let mut items = self.segment(input, rate, depth + 1);
+                let mut items = self.segment(input, rate, path)?;
                 if let (Some(g), [CItem::Clip(c)]) = (gain, items.as_mut_slice()) {
                     c.gain = Some(g);
                 }
@@ -254,20 +297,23 @@ impl R<'_, '_> {
                 self.report.info("an unsupported OMF component was imported as a gap");
                 vec![CItem::Filler(len)]
             }
-        }
+        };
+        path.pop();
+        Ok(items)
     }
 
     fn amp(&self, v: &[u8]) -> Option<f64> {
         self.rational_of(v).filter(|r| r.1 != 0).map(|(n, d)| n as f64 / d as f64)
     }
 
-    fn gain(&mut self, v: u32, len: Tick) -> Option<Gain> {
-        match &self.class(v) {
+    fn gain(&mut self, v: u32, len: Tick) -> Result<Option<Gain>> {
+        Ok(match &self.class(v) {
             b"CVAL" => self.c.get(v, "OMFI:CVAL:Value").and_then(|d| self.amp(&d)).map(Gain::Constant),
             b"VVAL" => {
                 let linear = self.u16(v, "OMFI:VVAL:Interpolation") != Some(1);
-                let points: Vec<(Tick, f64)> = self
-                    .refs(v, "OMFI:VVAL:PointList")
+                let refs = self.refs(v, "OMFI:VVAL:PointList");
+                self.spend_visits(refs.len())?;
+                let points: Vec<(Tick, f64)> = refs
                     .into_iter()
                     .filter_map(|p| {
                         let (n, d) = self.rational(p, "OMFI:CTLP:Time").filter(|r| r.1 != 0)?;
@@ -278,72 +324,86 @@ impl R<'_, '_> {
                 (!points.is_empty()).then_some(Gain::Varying { linear, points })
             }
             _ => None,
-        }
+        })
     }
 
-    fn clip(&mut self, c: u32, rate: (i64, i64)) -> CItem {
+    fn clip(&mut self, c: u32, rate: (i64, i64)) -> Result<CItem> {
         let len = units_to_ticks(self.len(c), rate.0, rate.1);
         let start = units_to_ticks(self.i32(c, "OMFI:SCLP:StartTime").unwrap_or(0), rate.0, rate.1);
         let name = self.string(c, "FilmCraft:SCLP:ClipName").unwrap_or_default();
-        let (Some(id), Some(track)) = (self.uid(c, "OMFI:SCLP:SourceID"), self.u32(c, "OMFI:SCLP:SourceTrackID")) else { return CItem::Filler(len) };
+        let (Some(id), Some(track)) = (self.uid(c, "OMFI:SCLP:SourceID"), self.u32(c, "OMFI:SCLP:SourceTrackID")) else { return Ok(CItem::Filler(len)) };
         if id == [0; 12] {
-            return CItem::Filler(len);
+            return Ok(CItem::Filler(len));
         }
-        match self.resolve(id, track, start, None, 0) {
+        Ok(match self.resolve(id, track, start, None, 0)? {
             Some((source, t)) => CItem::Clip(CClip { len, source, start: t, gain: None, name }),
             None => CItem::Filler(len),
-        }
+        })
     }
 
-    fn slot_of(&self, mob: u32, track: u32) -> Option<u32> {
-        self.refs(mob, "OMFI:MOBJ:Slots")
-            .into_iter()
-            .find(|&s| self.r#ref(s, "OMFI:MSLT:TrackDesc").and_then(|t| self.u32(t, "OMFI:TRKD:TrackID")) == Some(track))
+    fn slot_of(&mut self, mob: u32, track: u32) -> Result<Option<u32>> {
+        let slots = self.refs(mob, "OMFI:MOBJ:Slots");
+        self.spend_visits(slots.len())?;
+        Ok(slots.into_iter().find(|&s| self.r#ref(s, "OMFI:MSLT:TrackDesc").and_then(|t| self.u32(t, "OMFI:TRKD:TrackID")) == Some(track)))
     }
 
-    fn first_clip(&self, seg: u32) -> Option<u32> {
+    fn first_clip(&mut self, seg: u32, path: &mut Vec<u32>) -> Result<Option<u32>> {
+        self.enter_segment(seg, path)?;
+        let mut clip = None;
         match &self.class(seg) {
-            b"SCLP" => Some(seg),
-            b"SEQU" => self.refs(seg, "OMFI:SEQU:Components").into_iter().find_map(|c| self.first_clip(c)),
-            _ => None,
+            b"SCLP" => clip = Some(seg),
+            b"SEQU" => {
+                let components = self.refs(seg, "OMFI:SEQU:Components");
+                // The list is materialised even when the first child is a clip.
+                self.spend_visits(components.len())?;
+                for c in components {
+                    clip = self.first_clip(c, path)?;
+                    if clip.is_some() {
+                        break;
+                    }
+                }
+            }
+            _ => {}
         }
+        path.pop();
+        Ok(clip)
     }
 
-    fn resolve(&mut self, id: Uid, track: u32, start: Tick, master: Option<String>, depth: usize) -> Option<(usize, Tick)> {
+    fn resolve(&mut self, id: Uid, track: u32, start: Tick, master: Option<String>, depth: usize) -> Result<Option<(usize, Tick)>> {
         if depth > 16 {
-            return None;
+            return Err(traversal_error("source chain exceeds depth limit (16 edges)"));
         }
         let Some(&mob) = self.mobs.get(&id) else {
             self.report.warn("a clip references a mob that is not in the file (imported as a gap)");
-            return None;
+            return Ok(None);
         };
-        let slot = self.slot_of(mob, track)?;
+        let Some(slot) = self.slot_of(mob, track)? else { return Ok(None) };
         let rate = self.rational(slot, "OMFI:MSLT:EditRate").filter(|r| r.0 > 0 && r.1 > 0).unwrap_or((48_000, 1));
-        let seg = self.r#ref(slot, "OMFI:MSLT:Segment")?;
+        let Some(seg) = self.r#ref(slot, "OMFI:MSLT:Segment") else { return Ok(None) };
         match &self.class(mob) {
             b"MMOB" => {
                 let key = master.unwrap_or_else(|| id.iter().map(|b| format!("{b:02x}")).collect());
-                let clip = self.first_clip(seg)?;
+                let Some(clip) = self.first_clip(seg, &mut Vec::new())? else { return Ok(None) };
                 let inner = units_to_ticks(self.i32(clip, "OMFI:SCLP:StartTime").unwrap_or(0), rate.0, rate.1);
-                let next = self.uid(clip, "OMFI:SCLP:SourceID")?;
-                let next_track = self.u32(clip, "OMFI:SCLP:SourceTrackID")?;
+                let Some(next) = self.uid(clip, "OMFI:SCLP:SourceID") else { return Ok(None) };
+                let Some(next_track) = self.u32(clip, "OMFI:SCLP:SourceTrackID") else { return Ok(None) };
                 let name = self.string(mob, "OMFI:MOBJ:Name");
-                let r = self.resolve(next, next_track, start + inner, Some(key), depth + 1)?;
+                let Some(r) = self.resolve(next, next_track, start + inner, Some(key), depth + 1)? else { return Ok(None) };
                 if let Some(n) = name {
                     self.doc.sources[r.0].name = n;
                 }
-                Some(r)
+                Ok(Some(r))
             }
             b"SMOB" => {
-                let desc = self.r#ref(mob, "OMFI:SMOB:MediaDescription")?;
+                let Some(desc) = self.r#ref(mob, "OMFI:SMOB:MediaDescription") else { return Ok(None) };
                 let sr = self.rational(desc, "OMFI:MDFL:SampleRate");
                 if sr.is_none() && !self.media.contains_key(&id) {
-                    return None; // a tape / physical source
+                    return Ok(None); // a tape / physical source
                 }
                 let key = master.unwrap_or_else(|| id.iter().map(|b| format!("{b:02x}")).collect());
                 let skey = (id, track, key.clone());
                 if let Some(&i) = self.sources.get(&skey) {
-                    return Some((i, start));
+                    return Ok(Some((i, start)));
                 }
                 let file_rate = sr.filter(|r| r.0 > 0 && r.1 > 0).unwrap_or(rate);
                 let length = units_to_ticks(self.i32(desc, "OMFI:MDFL:Length").unwrap_or(0), file_rate.0, file_rate.1);
@@ -368,7 +428,7 @@ impl R<'_, '_> {
                 let (file_channels, sample_rate, bits) = fmt.unwrap_or((1, file_rate.0 as u32, 16));
                 let channel = (file_channels > 1 && sound_slots > 1).then(|| track.saturating_sub(1).min(file_channels - 1));
                 // timecode of the physical source
-                let tc = self.first_clip(seg).and_then(|clip| {
+                let tc = self.first_clip(seg, &mut Vec::new())?.and_then(|clip| {
                     let tid = self.uid(clip, "OMFI:SCLP:SourceID")?;
                     let tape = *self.mobs.get(&tid)?;
                     let (start_tc, fps, trate) = self.refs(tape, "OMFI:MOBJ:Slots").into_iter().find_map(|s| {
@@ -416,11 +476,11 @@ impl R<'_, '_> {
                 let i = self.doc.sources.len();
                 self.doc.sources.push(s);
                 self.sources.insert(skey, i);
-                Some((i, start))
+                Ok(Some((i, start)))
             }
             _ => {
                 self.report.warn("nested OMF compositions are not supported (imported as gaps)");
-                None
+                Ok(None)
             }
         }
     }
diff --git a/crates/interchange/src/omf/traversal_tests.rs b/crates/interchange/src/omf/traversal_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/interchange/src/omf/traversal_tests.rs
@@ -0,0 +1,338 @@
+//! Small synthetic Bento graphs; no external media or binary fixtures.
+use super::{ImportOptions, bento::Writer, import, read};
+use crate::{Error, Report, comp::CItem};
+use filmcraft_time::Tick;
+
+struct Graph {
+    w: Writer,
+    head: u32,
+    mob: u32,
+    sound: u32,
+    mobs: Vec<u32>,
+}
+
+impl Graph {
+    fn new() -> Self {
+        let mut g = Self { w: Writer::new(), head: 0, mob: 0, sound: 0, mobs: Vec::new() };
+        g.head = g.obj(b"HEAD");
+        g.mob = g.obj(b"CMOB");
+        g.sound = g.obj(b"DDEF");
+        g.w.set(g.sound, "OMFI:DDEF:DataKindID", "omfi:UniqueName", b"omfi:data:Sound\0");
+        g.mobs.push(g.mob);
+        g
+    }
+    fn obj(&mut self, class: &[u8; 4]) -> u32 {
+        let o = self.w.new_object();
+        self.w.set(o, "OMFI:OOBJ:ObjClass", "omfi:ClassID", class);
+        o
+    }
+    fn component(&mut self, class: &[u8; 4]) -> u32 {
+        let o = self.obj(class);
+        self.one(o, "OMFI:CPNT:DataKind", self.sound);
+        self.w.set(o, "OMFI:CPNT:Length", "omfi:Length32", &1i32.to_be_bytes());
+        o
+    }
+    fn one(&mut self, o: u32, p: &str, target: u32) {
+        self.w.set(o, p, "omfi:ObjRef", &target.to_be_bytes());
+    }
+    fn refs(&mut self, o: u32, p: &str, targets: &[u32]) {
+        let mut bytes = u16::try_from(targets.len()).unwrap().to_be_bytes().to_vec();
+        for t in targets {
+            bytes.extend_from_slice(&t.to_be_bytes());
+        }
+        self.w.set(o, p, "omfi:ObjRefArray", &bytes);
+    }
+    fn sequence(&mut self, targets: &[u32]) -> u32 {
+        let o = self.component(b"SEQU");
+        self.refs(o, "OMFI:SEQU:Components", targets);
+        o
+    }
+    fn slot(&mut self, seg: u32) -> u32 {
+        let s = self.obj(b"MSLT");
+        self.one(s, "OMFI:MSLT:Segment", seg);
+        let t = self.obj(b"TRKD");
+        self.w.set(t, "OMFI:TRKD:TrackID", "omfi:UInt32", &1u32.to_be_bytes());
+        self.one(s, "OMFI:MSLT:TrackDesc", t);
+        s
+    }
+    fn source_clip(&mut self, class: &[u8; 4], seg: u32) -> u32 {
+        let mob = self.obj(class);
+        self.mobs.push(mob);
+        let mut uid = [0; 12];
+        uid[..4].copy_from_slice(&mob.to_be_bytes());
+        self.w.set(mob, "OMFI:MOBJ:MobID", "omfi:UID", &uid);
+        let slot = self.slot(seg);
+        self.refs(mob, "OMFI:MOBJ:Slots", &[slot]);
+        if class == b"SMOB" {
+            let desc = self.obj(b"WAVD");
+            self.one(mob, "OMFI:SMOB:MediaDescription", desc);
+            let mut rate = 48_000i32.to_be_bytes().to_vec();
+            rate.extend_from_slice(&1i32.to_be_bytes());
+            self.w.set(desc, "OMFI:MDFL:SampleRate", "omfi:Rational", &rate);
+        }
+        let clip = self.component(b"SCLP");
+        self.w.set(clip, "OMFI:SCLP:SourceID", "omfi:UID", &uid);
+        self.w.set(clip, "OMFI:SCLP:SourceTrackID", "omfi:UInt32", &1u32.to_be_bytes());
+        clip
+    }
+    fn finish(mut self, seg: u32) -> Vec<u8> {
+        let s = self.slot(seg);
+        self.refs(self.mob, "OMFI:MOBJ:Slots", &[s]);
+        self.refs(self.head, "OMFI:HEAD:PrimaryMobs", &[self.mob]);
+        let mobs = self.mobs.clone();
+        self.refs(self.head, "OMFI:HEAD:Mobs", &mobs);
+        self.w.finish()
+    }
+}
+
+fn rejected(bytes: &[u8], reason: &str) {
+    let result = std::panic::catch_unwind(|| import(bytes, &ImportOptions::default())).expect("OMF import panicked");
+    let Err(Error::Parse { format: "OMF", message }) = result else { panic!("expected OMF parse error") };
+    assert!(message.contains(reason), "{message}");
+}
+
+#[test]
+fn sequence_cycle_is_rejected() {
+    for fanout in [1, 2] {
+        let mut g = Graph::new();
+        let seq = g.component(b"SEQU");
+        g.refs(seq, "OMFI:SEQU:Components", &vec![seq; fanout]);
+        rejected(&g.finish(seq), "cycle");
+    }
+}
+
+#[test]
+fn repeated_dag_expansion_is_bounded() {
+    for empty in [false, true] {
+        let mut g = Graph::new();
+        let mut seg = if empty { g.sequence(&[]) } else { g.component(b"FILL") };
+        // Only 17 segment objects, but 131,071 visits even with no output items.
+        for _ in 0..16 {
+            seg = g.sequence(&[seg, seg]);
+        }
+        rejected(&g.finish(seg), "budget");
+    }
+}
+
+#[test]
+fn shared_segments_preserve_every_occurrence() {
+    let mut g = Graph::new();
+    let a = g.component(b"FILL");
+    let b = g.obj(b"FILL");
+    g.w.set(b, "OMFI:CPNT:Length", "omfi:Length32", &2i32.to_be_bytes());
+    let seq = g.sequence(&[a, b]);
+    let top = g.sequence(&[seq, a, seq]);
+    let bytes = g.finish(top);
+    let doc = read::read(&bytes, &mut Report::default()).unwrap();
+    let items = &doc.compositions[0].tracks[0].items;
+    let lengths: Vec<Tick> = items
+        .iter()
+        .map(|i| match i {
+            CItem::Filler(t) => *t,
+            _ => panic!("expected filler"),
+        })
+        .collect();
+    assert_eq!(lengths, [1, 2, 1, 1, 2].map(|n| Tick(n * filmcraft_time::TICKS_PER_SECOND / 25)));
+}
+
+#[test]
+fn effect_input_cycle_is_rejected() {
+    let mut g = Graph::new();
+    let effect = g.component(b"EFFE");
+    let seq = g.sequence(&[effect]);
+    let slot = g.obj(b"ESLT");
+    g.w.set(slot, "OMFI:ESLT:ArgID", "omfi:ArgIDType", &(-1i32).to_be_bytes());
+    g.one(slot, "OMFI:ESLT:ArgValue", seq);
+    g.refs(effect, "OMFI:EFFE:EffectSlots", &[slot]);
+    rejected(&g.finish(effect), "cycle");
+}
+
+#[test]
+fn segment_depth_limit_is_explicit() {
+    for levels in [32, 33] {
+        let mut g = Graph::new();
+        let mut seg = g.component(b"FILL");
+        for _ in 0..levels {
+            seg = g.sequence(&[seg]);
+        }
+        let bytes = g.finish(seg);
+        if levels == 32 {
+            assert!(import(&bytes, &ImportOptions::default()).is_ok());
+        } else {
+            rejected(&bytes, "depth limit");
+        }
+    }
+}
+
+#[test]
+fn master_sequence_cycle_is_rejected() {
+    let mut g = Graph::new();
+    let seq = g.component(b"SEQU");
+    g.refs(seq, "OMFI:SEQU:Components", &[seq]);
+    let clip = g.source_clip(b"MMOB", seq);
+    rejected(&g.finish(clip), "cycle");
+}
+
+#[test]
+fn source_timecode_sequence_cycle_is_rejected() {
+    let mut g = Graph::new();
+    let seq = g.component(b"SEQU");
+    g.refs(seq, "OMFI:SEQU:Components", &[seq]);
+    let clip = g.source_clip(b"SMOB", seq);
+    rejected(&g.finish(clip), "cycle");
+}
+
+#[test]
+fn source_lookup_expansion_is_bounded() {
+    let mut g = Graph::new();
+    let mut seg = g.component(b"FILL");
+    for _ in 0..16 {
+        seg = g.sequence(&[seg, seg]);
+    }
+    let clip = g.source_clip(b"MMOB", seg);
+    rejected(&g.finish(clip), "budget");
+}
+
+#[test]
+fn source_chain_depth_limit_preserves_audio_or_returns_an_error() {
+    for depth in [16, 17] {
+        let mut g = Graph::new();
+        let filler = g.component(b"FILL");
+        let mut clip = g.source_clip(b"SMOB", filler);
+        for _ in 0..depth {
+            clip = g.source_clip(b"MMOB", clip);
+        }
+        let bytes = g.finish(clip);
+        if depth == 16 {
+            let doc = read::read(&bytes, &mut Report::default()).unwrap();
+            assert_eq!(doc.sources.len(), 1);
+            assert!(matches!(doc.compositions[0].tracks[0].items.as_slice(), [CItem::Clip(c)] if c.source == 0));
+        } else {
+            rejected(&bytes, "source chain exceeds depth limit");
+        }
+    }
+}
+
+#[test]
+fn source_chain_cycles_return_an_error_instead_of_filler() {
+    for length in [1, 2] {
+        let mut g = Graph::new();
+        let start = g.component(b"SCLP");
+        let mut clip = start;
+        for _ in 0..length {
+            clip = g.source_clip(b"MMOB", clip);
+        }
+        // Close the chain by pointing its innermost clip back to the outermost master mob.
+        let mut uid = [0; 12];
+        uid[..4].copy_from_slice(&g.mobs.last().unwrap().to_be_bytes());
+        g.w.set(start, "OMFI:SCLP:SourceID", "omfi:UID", &uid);
+        g.w.set(start, "OMFI:SCLP:SourceTrackID", "omfi:UInt32", &1u32.to_be_bytes());
+        rejected(&g.finish(clip), "source chain exceeds depth limit");
+    }
+}
+
+#[test]
+fn budget_is_shared_across_tracks_and_compositions() {
+    for compositions in [false, true] {
+        let mut g = Graph::new();
+        let mut seg = g.component(b"FILL");
+        // Each expansion fits individually, but the whole document does not.
+        for _ in 0..14 {
+            seg = g.sequence(&[seg, seg]);
+        }
+        let slot = g.slot(seg);
+        if compositions {
+            g.refs(g.mob, "OMFI:MOBJ:Slots", &[slot]);
+            let second = g.obj(b"CMOB");
+            g.mobs.push(second);
+            g.mob = second;
+        } else {
+            g.refs(g.mob, "OMFI:MOBJ:Slots", &[slot, slot, slot]);
+        }
+        // Write the fixture directly so every property has only one value.
+        let mobs = g.mobs.clone();
+        g.refs(g.head, "OMFI:HEAD:Mobs", &mobs);
+        if compositions {
+            g.refs(g.mob, "OMFI:MOBJ:Slots", &[slot, slot]);
+        }
+        rejected(&g.w.finish(), "budget");
+    }
+}
+
+#[test]
+fn shared_source_clips_are_not_dropped() {
+    let mut g = Graph::new();
+    let filler = g.component(b"FILL");
+    let clip = g.source_clip(b"SMOB", filler);
+    let seq = g.sequence(&[clip, clip]);
+    let top = g.sequence(&[seq, clip, seq]);
+    let doc = read::read(&g.finish(top), &mut Report::default()).unwrap();
+    assert_eq!(doc.sources.len(), 1);
+    let items = &doc.compositions[0].tracks[0].items;
+    assert_eq!(items.len(), 5);
+    assert!(items.iter().all(|i| matches!(i, CItem::Clip(c) if c.source == 0)));
+}
+
+#[test]
+fn mutated_reference_graphs_never_panic() {
+    let mut g = Graph::new();
+    let filler = g.component(b"FILL");
+    let seq = g.sequence(&[filler, filler]);
+    let bytes = g.finish(seq);
+    for at in (0..bytes.len()).step_by(17) {
+        let result = std::panic::catch_unwind(|| import(&bytes[..at], &ImportOptions::default()));
+        assert!(result.is_ok(), "truncation at {at} panicked");
+        for mask in [1, 0x80, 0xff] {
+            let mut changed = bytes.clone();
+            changed[at] ^= mask;
+            let result = std::panic::catch_unwind(|| import(&changed, &ImportOptions::default()));
+            assert!(result.is_ok(), "mutation at {at} panicked");
+        }
+    }
+}
+
+#[test]
+fn repeated_discovery_references_consume_the_budget() {
+    let mut g = Graph::new();
+    let filler = g.component(b"FILL");
+    let seq = g.sequence(&vec![filler; u16::MAX as usize]);
+    rejected(&g.finish(seq), "budget");
+}
+
+#[test]
+fn early_source_lookup_still_charges_the_reference_list() {
+    let mut g = Graph::new();
+    let filler = g.component(b"FILL");
+    let source = g.source_clip(b"SMOB", filler);
+    let master_seq = g.sequence(&vec![source; 1024]);
+    let clip = g.source_clip(b"MMOB", master_seq);
+    let top = g.sequence(&vec![clip; 128]);
+    rejected(&g.finish(top), "budget");
+}
+
+#[test]
+fn repeated_gain_points_consume_the_budget() {
+    let mut g = Graph::new();
+    let filler = g.component(b"FILL");
+    let clip = g.source_clip(b"SMOB", filler);
+    let point = g.obj(b"CTLP");
+    let rational = [0, 0, 0, 1, 0, 0, 0, 1];
+    g.w.set(point, "OMFI:CTLP:Time", "omfi:Rational", &rational);
+    g.w.set(point, "OMFI:CTLP:Value", "omfi:DataValue", &rational);
+    let value = g.component(b"VVAL");
+    g.refs(value, "OMFI:VVAL:PointList", &vec![point; 1024]);
+    let effect = g.component(b"EFFE");
+    let kind = g.obj(b"EDEF");
+    g.w.set(kind, "OMFI:EDEF:EffectID", "omfi:UniqueName", b"omfi:effect:MonoAudioGain\0");
+    g.one(effect, "OMFI:EFFE:EffectKind", kind);
+    let mut slots = Vec::new();
+    for (id, arg) in [(-1i32, clip), (1, value)] {
+        let slot = g.obj(b"ESLT");
+        g.w.set(slot, "OMFI:ESLT:ArgID", "omfi:ArgIDType", &id.to_be_bytes());
+        g.one(slot, "OMFI:ESLT:ArgValue", arg);
+        slots.push(slot);
+    }
+    g.refs(effect, "OMFI:EFFE:EffectSlots", &slots);
+    let top = g.sequence(&vec![effect; 128]);
+    rejected(&g.finish(top), "budget");
+}
