···1717//! Multi-op commits are handled because all ops are considered together: a key
1818//! is a "survivor" only if it is visible AND not in the deleted set.
19192020+use std::ops::Bound;
2021use std::collections::HashSet;
21222222-use jacquard_api::com_atproto::sync::subscribe_repos::RepoOp;
2323+use jacquard_api::com_atproto::sync::subscribe_repos::{RepoOp, RepoOpAction};
2324use jacquard_common::types::string::Nsid;
2525+use repo_stream::{MemCar, WalkItem};
2426use repo_stream::{DriverBuilder, JacquardLoadError, WalkError};
2727+use tracing::error;
25282629use super::Span;
2730···3134 Load(#[from] JacquardLoadError),
3235 #[error("MST walk error: {0}")]
3336 Walk(#[from] WalkError),
3737+ #[error("bad data in repo: {0}")]
3838+ InvalidData(String),
3439}
35403641type Result<T> = std::result::Result<T, MstMortalityError>;
37423843type KeySpan = Span<String>;
39444545+#[derive(Debug, PartialEq)]
4646+pub enum Existence {
4747+ Yes(String),
4848+ No,
4949+ Uncertain,
5050+}
5151+5252+impl KeySpan {
5353+ pub fn left_of(&self, key: &str) -> Existence {
5454+ self
5555+ .things
5656+ .range(..key.to_string())
5757+ .next_back()
5858+ .map(|(k, gap)| if *gap { Existence::Uncertain } else { Existence::Yes(k.clone()) })
5959+ .unwrap_or(if self.gap_before { Existence::Uncertain } else { Existence::No })
6060+ }
6161+ pub fn right_of(&self, key: &str, left: &Existence) -> Existence {
6262+ if let Some(gap_after) = self.things.get(key) {
6363+ // the key itself exists so we can just look right
6464+ if *gap_after {
6565+ return Existence::Uncertain;
6666+ }
6767+ // no gap after so it's not uncertain, just down to existence
6868+ return self
6969+ .things
7070+ .range((Bound::Excluded(key.to_string()), Bound::Unbounded))
7171+ .next()
7272+ .map(|(k, _)| Existence::Yes(k.to_string()))
7373+ .unwrap_or(Existence::No)
7474+ }
7575+ // the key does not exist
7676+ if *left == Existence::Uncertain {
7777+ // we're in a gap, so the right key is uncertain
7878+ // *technically* there is a next-legal-atproto-key we could check here to *be* certain (TODO)
7979+ return Existence::Uncertain;
8080+ }
8181+ // we're not in a gap, so we have certainty about what's next
8282+ self
8383+ .things
8484+ .range((Bound::Excluded(key.to_string()), Bound::Unbounded))
8585+ .next()
8686+ .map(|(k, _)| Existence::Yes(k.clone()))
8787+ .unwrap_or(Existence::No)
8888+ }
8989+ // pub fn is_lonely(key: &str) -> MortalityResult {
9090+ // // first: get left and right keys
9191+9292+ // let falls_in_gap = self
9393+ // .things
9494+ // .range(..k) // exclusive range: all keys lex-before us
9595+ // .next_back() // take the closest previous one
9696+ // .map(|(_, gap_after)| gap_after) // if it existed, find out if it had a gap after
9797+ // .unwrap_or(&self.gap_before); // no before-key: span starts with gap?
9898+ // todo!()
9999+ // }
100100+}
101101+102102+/// extract a span of collection NSIDs (with possible gaps) from a CAR slice
103103+fn span_from_slice(car: &mut MemCar) -> Result<KeySpan> {
104104+ let mut prev_gap = false;
105105+ let mut prev_key = None;
106106+107107+ let mut span = KeySpan::empty();
108108+109109+ while let Some(item) = car.next()? {
110110+ assert!(
111111+ !matches!(item, WalkItem::Node { .. }),
112112+ "car.next() does not return found nodes"
113113+ );
114114+115115+ let Some(key) = item.key() else {
116116+ prev_gap = true;
117117+ continue;
118118+ };
119119+120120+ if let Some(prev) = prev_key {
121121+ span.things.insert(prev, prev_gap);
122122+ } else {
123123+ span.gap_before = prev_gap;
124124+ }
125125+126126+ prev_gap = false;
127127+ prev_key = Some(key.clone());
128128+ }
129129+130130+ if let Some(prev) = prev_key {
131131+ span.things.insert(prev, prev_gap);
132132+ } else {
133133+ span.gap_before = prev_gap;
134134+ }
135135+136136+ Ok(span)
137137+}
138138+139139+/// Returns true iff the span proves there are no hidden keys with the given prefix.
140140+///
141141+/// Conditions:
142142+/// (a) At least one key with the prefix is visible in the span.
143143+/// (b) `left_of` the first visible prefixed key is NOT Uncertain (no hidden keys before it).
144144+/// (c) Every visible prefixed key has `gap_after = false` (no hidden keys within or after).
145145+fn can_prove_complete_coverage(span: &KeySpan, prefix: &str) -> bool {
146146+ let first_k = span
147147+ .things
148148+ .range(prefix.to_string()..)
149149+ .next()
150150+ .filter(|(k, _)| k.starts_with(prefix))
151151+ .map(|(k, _)| k.as_str());
152152+153153+ let first_k = match first_k {
154154+ Some(k) => k,
155155+ None => return false,
156156+ };
157157+158158+ if matches!(span.left_of(first_k), Existence::Uncertain) {
159159+ return false;
160160+ }
161161+162162+ for (k, gap_after) in span.things.range(prefix.to_string()..) {
163163+ if !k.starts_with(prefix) {
164164+ break;
165165+ }
166166+ if *gap_after {
167167+ return false;
168168+ }
169169+ }
170170+ true
171171+}
172172+40173/// Collect every MST leaf path visible in a (possibly partial) CAR.
41174///
42175/// Uses `next_keys()` which silently skips subtrees whose MST node blocks are
43176/// absent, so this works on both full and proof-only CARs.
177177+#[cfg(test)]
44178fn collect_visible_paths(parsed: jacquard_repo::car::reader::ParsedCar) -> Result<Vec<String>> {
45179 let mut car = DriverBuilder::new().load_jacquard_parsed_car(parsed)?;
46180 let mut visible = Vec::new();
···50184 Ok(visible)
51185}
52186187187+/// Result of [`extract`].
188188+pub struct ExtractResult {
189189+ /// Collections newly created in this commit (all pre-existing neighbours
190190+ /// were absent from the proof, or the proof covers the full range).
191191+ pub born: Vec<Nsid<'static>>,
192192+ /// Collections fully deleted in this commit (proof covers the full range).
193193+ pub died: Vec<Nsid<'static>>,
194194+ /// True when a possible collection death could not be confirmed because the
195195+ /// CAR proof has gaps that could hide surviving keys. The caller should
196196+ /// queue a full-repo resync to verify the current collection state.
197197+ pub needs_resync: bool,
198198+}
199199+53200/// Walk the partial CAR's MST to detect which collections are newly added
54201/// ("born") or fully removed ("died") by this commit.
5555-///
5656-/// Returns `(born, died)` — both lists may be empty.
57202pub fn extract(
58203 ops: &[RepoOp<'_>],
59204 parsed: jacquard_repo::car::reader::ParsedCar,
6060-) -> Result<(Vec<Nsid<'static>>, Vec<Nsid<'static>>)> {
205205+) -> Result<ExtractResult> {
206206+ // ── Check for duplicate paths (protocol violation in untrusted data) ──────
207207+ let mut seen: HashSet<&str> = HashSet::with_capacity(ops.len());
208208+ for op in ops {
209209+ if !seen.insert(op.path.as_ref()) {
210210+ metrics::counter!(
211211+ "lightrail_mortality_invalid_ops_total",
212212+ "reason" => "duplicate_path"
213213+ )
214214+ .increment(1);
215215+ error!(path = %op.path, "duplicate path in commit ops; skipping commit");
216216+ return Err(MstMortalityError::InvalidData(format!(
217217+ "duplicate op path: {}",
218218+ op.path
219219+ )));
220220+ }
221221+ }
222222+61223 // ── Build create/delete path sets ────────────────────────────────────────
6262- let mut created: HashSet<String> = HashSet::new();
6363- let mut deleted: HashSet<String> = HashSet::new();
224224+ let mut creates: HashSet<&str> = HashSet::new();
225225+ let mut deletes: HashSet<&str> = HashSet::new();
64226 for op in ops {
6565- match op.action.as_ref() {
6666- "create" => {
6767- created.insert(op.path.to_string());
227227+ match op.action {
228228+ RepoOpAction::Create => {
229229+ creates.insert(op.path.as_ref());
68230 }
6969- "delete" => {
7070- deleted.insert(op.path.to_string());
231231+ RepoOpAction::Delete => {
232232+ deletes.insert(op.path.as_ref());
71233 }
72234 _ => {} // updates don't affect collection mortality
73235 }
74236 }
752377676- if created.is_empty() && deleted.is_empty() {
7777- return Ok((vec![], vec![]));
238238+ if creates.is_empty() && deletes.is_empty() {
239239+ return Ok(ExtractResult { born: vec![], died: vec![], needs_resync: false });
78240 }
792418080- // ── Walk the partial CAR's MST to collect visible leaf keys ──────────────
8181- let visible = collect_visible_paths(parsed)?;
242242+ // ── Walk the partial CAR's MST to build a gap-aware key span ─────────────
243243+ let mut mem_car = DriverBuilder::new().load_jacquard_parsed_car(parsed)?;
244244+ let span = span_from_slice(&mut mem_car)?;
822458383- // ── Check collection death (all visible keys in C are being deleted) ──────
8484- let deleted_collections: HashSet<&str> = deleted
246246+ // ── Check collection death ────────────────────────────────────────────────
247247+ // A collection died iff all visible keys in it are being deleted AND
248248+ // the span proves there are no hidden surviving keys.
249249+ let mut died: Vec<Nsid<'static>> = Vec::new();
250250+ let mut needs_resync = false;
251251+ for coll in deletes
85252 .iter()
86253 .filter_map(|p| p.split_once('/').map(|(c, _)| c))
8787- .collect();
8888-8989- let mut died: Vec<Nsid<'static>> = Vec::new();
9090- for coll in deleted_collections {
254254+ .collect::<HashSet<_>>()
255255+ {
91256 let prefix = format!("{coll}/");
9292- let has_survivor = visible
9393- .iter()
9494- .any(|k| k.starts_with(&prefix) && !deleted.contains(k.as_str()));
9595- if !has_survivor && let Ok(nsid) = Nsid::new_owned(coll) {
257257+ let has_survivor = span
258258+ .things
259259+ .range(prefix.clone()..)
260260+ .take_while(|(k, _)| k.starts_with(&prefix))
261261+ .any(|(k, _)| !deletes.contains(k.as_str()));
262262+ if has_survivor {
263263+ continue;
264264+ }
265265+ if !can_prove_complete_coverage(&span, &prefix) {
266266+ // Possible death but the CAR has gaps — we can't confirm. Request
267267+ // a full-repo resync so the caller can reconcile the index later.
268268+ metrics::counter!("lightrail_mortality_unproven_total", "kind" => "death")
269269+ .increment(1);
270270+ error!(collection = coll, "possible collection death unproven due to MST gaps");
271271+ needs_resync = true;
272272+ continue;
273273+ }
274274+ if let Ok(nsid) = Nsid::new_owned(coll) {
96275 died.push(nsid);
97276 }
98277 }
99278100100- // ── Check collection birth (all visible keys in C are being created) ──────
101101- let created_collections: HashSet<&str> = created
279279+ // ── Check collection birth ────────────────────────────────────────────────
280280+ // A collection was born iff all visible keys in it are being created AND
281281+ // the span proves there are no hidden pre-existing keys.
282282+ // If the proof has gaps, we still record the birth — false positives here
283283+ // are harmless (the index entry already exists or the resync will fix it).
284284+ let mut born: Vec<Nsid<'static>> = Vec::new();
285285+ for coll in creates
102286 .iter()
103287 .filter_map(|p| p.split_once('/').map(|(c, _)| c))
104104- .collect();
105105-106106- let mut born: Vec<Nsid<'static>> = Vec::new();
107107- for coll in created_collections {
288288+ .collect::<HashSet<_>>()
289289+ {
108290 let prefix = format!("{coll}/");
109109- let has_preexisting = visible
110110- .iter()
111111- .any(|k| k.starts_with(&prefix) && !created.contains(k.as_str()));
112112- if !has_preexisting && let Ok(nsid) = Nsid::new_owned(coll) {
291291+ let has_preexisting = span
292292+ .things
293293+ .range(prefix.clone()..)
294294+ .take_while(|(k, _)| k.starts_with(&prefix))
295295+ .any(|(k, _)| !creates.contains(k.as_str()));
296296+ if has_preexisting {
297297+ continue;
298298+ }
299299+ if !can_prove_complete_coverage(&span, &prefix) {
300300+ metrics::counter!("lightrail_mortality_unproven_total", "kind" => "birth")
301301+ .increment(1);
302302+ error!(
303303+ collection = coll,
304304+ "possible collection birth unproven due to MST gaps"
305305+ );
306306+ // Fall through: include the birth anyway. Spurious births are
307307+ // idempotent (index insert is a blind overwrite); false negatives
308308+ // would silently drop new collections from the index.
309309+ }
310310+ if let Ok(nsid) = Nsid::new_owned(coll) {
113311 born.push(nsid);
114312 }
115313 }
116314117117- Ok((born, died))
315315+ Ok(ExtractResult { born, died, needs_resync })
118316}
119317120318#[cfg(test)]
121319mod tests {
122320 use super::*;
321321+ use std::collections::BTreeMap;
123322 use std::sync::Arc;
124323125324 use bytes::Bytes;
325325+ use cid::Cid as IpldCid;
126326 use jacquard_api::com_atproto::sync::subscribe_repos::RepoOpAction;
127327 use jacquard_common::CowStr;
128328 use jacquard_common::types::string::Did;
129329 use jacquard_common::types::tid::Tid;
330330+ use jacquard_repo::car::reader::ParsedCar;
130331 use jacquard_repo::commit::Commit;
131332 use jacquard_repo::{BlockStore, MemoryBlockStore, Mst, car::write_car_bytes};
132333···187388 #[tokio::test]
188389 async fn empty_ops_returns_empty() {
189390 let parsed = make_parsed_car(&["app.bsky.feed.post/abc123"]).await;
190190- let (born, died) = extract(&[], parsed).unwrap();
391391+ let ExtractResult { born, died, .. } = extract(&[], parsed).unwrap();
191392 assert!(born.is_empty());
192393 assert!(died.is_empty());
193394 }
···202403 prev: None,
203404 extra_data: Default::default(),
204405 }];
205205- let (born, died) = extract(&ops, parsed).unwrap();
406406+ let ExtractResult { born, died, .. } = extract(&ops, parsed).unwrap();
206407 assert!(born.is_empty());
207408 assert!(died.is_empty());
208409 }
···216417 // CAR contains only the created key → no preexisting neighbours.
217418 let parsed = make_parsed_car(&["app.bsky.feed.post/abc123"]).await;
218419 let ops = [op_create("app.bsky.feed.post/abc123")];
219219- let (born, died) = extract(&ops, parsed).unwrap();
420420+ let ExtractResult { born, died, .. } = extract(&ops, parsed).unwrap();
220421 assert_eq!(born, vec![nsid("app.bsky.feed.post")]);
221422 assert!(died.is_empty());
222423 }
···227428 let parsed =
228429 make_parsed_car(&["app.bsky.feed.post/abc123", "app.bsky.feed.post/def456"]).await;
229430 let ops = [op_create("app.bsky.feed.post/abc123")];
230230- let (born, died) = extract(&ops, parsed).unwrap();
431431+ let ExtractResult { born, died, .. } = extract(&ops, parsed).unwrap();
231432 assert!(born.is_empty());
232433 assert!(died.is_empty());
233434 }
···240441 async fn collection_dies_when_last_key_deleted() {
241442 let parsed = make_parsed_car(&["app.bsky.feed.post/abc123"]).await;
242443 let ops = [op_delete("app.bsky.feed.post/abc123")];
243243- let (born, died) = extract(&ops, parsed).unwrap();
444444+ let ExtractResult { born, died, .. } = extract(&ops, parsed).unwrap();
244445 assert!(born.is_empty());
245446 assert_eq!(died, vec![nsid("app.bsky.feed.post")]);
246447 }
···251452 let parsed =
252453 make_parsed_car(&["app.bsky.feed.post/abc123", "app.bsky.feed.post/def456"]).await;
253454 let ops = [op_delete("app.bsky.feed.post/abc123")];
254254- let (born, died) = extract(&ops, parsed).unwrap();
455455+ let ExtractResult { born, died, .. } = extract(&ops, parsed).unwrap();
255456 assert!(born.is_empty());
256457 assert!(died.is_empty());
257458 }
···271472 op_create("app.bsky.feed.post/abc123"),
272473 op_delete("app.bsky.graph.follow/old"),
273474 ];
274274- let (mut born, mut died) = extract(&ops, parsed).unwrap();
475475+ let ExtractResult { mut born, mut died, .. } = extract(&ops, parsed).unwrap();
275476 born.sort_unstable();
276477 died.sort_unstable();
277478 assert_eq!(born, vec![nsid("app.bsky.feed.post")]);
278479 assert_eq!(died, vec![nsid("app.bsky.graph.follow")]);
279480 }
481481+482482+ // ---------------------------------------------------------------------------
483483+ // Duplicate path detection
484484+ // ---------------------------------------------------------------------------
485485+486486+ #[tokio::test]
487487+ async fn duplicate_op_paths_returns_error() {
488488+ let parsed = make_parsed_car(&["app.bsky.feed.post/abc"]).await;
489489+ let ops = [
490490+ op_create("app.bsky.feed.post/abc"),
491491+ op_delete("app.bsky.feed.post/abc"),
492492+ ];
493493+ assert!(matches!(
494494+ extract(&ops, parsed),
495495+ Err(MstMortalityError::InvalidData(_))
496496+ ));
497497+ }
498498+499499+ // ---------------------------------------------------------------------------
500500+ // Gap suppression
501501+ // ---------------------------------------------------------------------------
502502+503503+ /// Build a sparse ParsedCar containing only the root MST node and commit.
504504+ ///
505505+ /// All subtree node blocks are absent, so subtrees surface as
506506+ /// `MissingSubtree` during the walk. Useful for testing gap detection.
507507+ async fn make_root_only_parsed_car(keys: &[&str]) -> ParsedCar {
508508+ let storage = Arc::new(MemoryBlockStore::new());
509509+ let mut mst = Mst::new(storage.clone());
510510+ let dummy_cid = storage.put(b"record").await.unwrap();
511511+ for key in keys {
512512+ mst = mst.add(key, dummy_cid).await.unwrap();
513513+ }
514514+ let (mst_root, all_blocks) = mst.collect_blocks().await.unwrap();
515515+ let commit = Commit {
516516+ did: Did::new_owned("did:web:example.com").unwrap(),
517517+ version: 3,
518518+ data: mst_root,
519519+ rev: Tid::now_0(),
520520+ prev: None,
521521+ sig: Bytes::from(vec![0u8; 64]),
522522+ };
523523+ let commit_cid = commit.to_cid().unwrap();
524524+ let commit_cbor = Bytes::from(commit.to_cbor().unwrap());
525525+ let root_bytes = all_blocks
526526+ .get(&mst_root)
527527+ .expect("root MST node not in blocks")
528528+ .clone();
529529+ let mut sparse: BTreeMap<IpldCid, Bytes> = BTreeMap::new();
530530+ sparse.insert(commit_cid, commit_cbor);
531531+ sparse.insert(mst_root, root_bytes);
532532+ ParsedCar {
533533+ root: commit_cid,
534534+ blocks: sparse,
535535+ }
536536+ }
537537+538538+ // `app.bsky.feed.post/454397e440ec` is a known layer-4 key (sits in the
539539+ // MST root node). Adding `app.bsky.feed.post/aaa` (layer 0, sorts after
540540+ // the layer-4 key lexicographically) creates a right child subtree.
541541+ // With root-only blocks, walking produces:
542542+ // Leaf(app.bsky.feed.post/454397e440ec) | MissingSubtree
543543+ // so the layer-4 key has gap_after=true — the span cannot prove complete
544544+ // coverage of the collection.
545545+546546+ #[tokio::test]
547547+ async fn gap_suppresses_death_and_sets_needs_resync() {
548548+ let parsed = make_root_only_parsed_car(&[
549549+ "app.bsky.feed.post/454397e440ec", // layer 4 — visible in root
550550+ "app.bsky.feed.post/aaa", // layer 0 — hidden behind right child
551551+ ])
552552+ .await;
553553+ // Delete only the visible key; the hidden sibling might be a survivor.
554554+ let ops = [op_delete("app.bsky.feed.post/454397e440ec")];
555555+ let ExtractResult { born, died, needs_resync } = extract(&ops, parsed).unwrap();
556556+ assert!(born.is_empty(), "unexpected birth: {born:?}");
557557+ assert!(died.is_empty(), "death declared despite gap: {died:?}");
558558+ assert!(needs_resync, "needs_resync should be set when death is unproven");
559559+ }
560560+561561+ #[tokio::test]
562562+ async fn gap_does_not_suppress_birth() {
563563+ let parsed = make_root_only_parsed_car(&[
564564+ "app.bsky.feed.post/454397e440ec", // layer 4 — visible in root
565565+ "app.bsky.feed.post/aaa", // layer 0 — hidden behind right child
566566+ ])
567567+ .await;
568568+ // Create only the visible key; gap can't prove no pre-existing keys exist,
569569+ // but we include the birth anyway (false positives are idempotent).
570570+ let ops = [op_create("app.bsky.feed.post/454397e440ec")];
571571+ let ExtractResult { born, died, needs_resync } = extract(&ops, parsed).unwrap();
572572+ assert_eq!(born, vec![nsid("app.bsky.feed.post")], "birth should be included despite gap");
573573+ assert!(died.is_empty(), "unexpected death: {died:?}");
574574+ assert!(!needs_resync, "needs_resync should not be set for unproven birth");
575575+ }
576576+}
577577+578578+// =============================================================================
579579+// KeySpan::left_of / right_of unit tests
580580+// =============================================================================
581581+582582+#[cfg(test)]
583583+mod keyspan_tests {
584584+ use super::{Existence, KeySpan};
585585+586586+ // ── helpers ──────────────────────────────────────────────────────────────
587587+588588+ fn span(gap_before: bool, things: &[(&str, bool)]) -> KeySpan {
589589+ KeySpan {
590590+ gap_before,
591591+ things: things.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
592592+ }
593593+ }
594594+595595+ fn yes(s: &str) -> Existence {
596596+ Existence::Yes(s.to_string())
597597+ }
598598+599599+ // ── left_of ──────────────────────────────────────────────────────────────
600600+601601+ // Empty span, no gap → nothing to the left of anything.
602602+ #[test]
603603+ fn left_of_empty_no_gap() {
604604+ assert_eq!(span(false, &[]).left_of("a/r1"), Existence::No);
605605+ }
606606+607607+ // Empty span that's all-gap → anything could be to the left.
608608+ #[test]
609609+ fn left_of_empty_all_gap() {
610610+ assert_eq!(span(true, &[]).left_of("a/r1"), Existence::Uncertain);
611611+ }
612612+613613+ // Predecessor exists with no gap after → definite left neighbour.
614614+ #[test]
615615+ fn left_of_predecessor_no_gap() {
616616+ let s = span(false, &[("a/r1", false)]);
617617+ assert_eq!(s.left_of("b/r1"), yes("a/r1"));
618618+ }
619619+620620+ // Predecessor exists but has gap_after → left is uncertain.
621621+ #[test]
622622+ fn left_of_predecessor_gap_after() {
623623+ let s = span(false, &[("a/r1", true)]);
624624+ assert_eq!(s.left_of("b/r1"), Existence::Uncertain);
625625+ }
626626+627627+ // Key sits before the first span entry, no gap_before → nothing to the left.
628628+ #[test]
629629+ fn left_of_before_first_key_no_gap_before() {
630630+ let s = span(false, &[("c/r1", false)]);
631631+ assert_eq!(s.left_of("a/r1"), Existence::No);
632632+ }
633633+634634+ // Key sits before the first span entry but gap_before is set → uncertain.
635635+ #[test]
636636+ fn left_of_before_first_key_gap_before() {
637637+ let s = span(true, &[("c/r1", false)]);
638638+ assert_eq!(s.left_of("a/r1"), Existence::Uncertain);
639639+ }
640640+641641+ // Key sits between two span entries; the one to the left has no gap_after.
642642+ #[test]
643643+ fn left_of_between_two_keys_no_gap() {
644644+ let s = span(false, &[("a/r1", false), ("c/r1", false)]);
645645+ assert_eq!(s.left_of("b/r1"), yes("a/r1"));
646646+ }
647647+648648+ // Key sits between two span entries; the left one has a gap_after.
649649+ #[test]
650650+ fn left_of_between_two_keys_gap() {
651651+ let s = span(false, &[("a/r1", true), ("c/r1", false)]);
652652+ assert_eq!(s.left_of("b/r1"), Existence::Uncertain);
653653+ }
654654+655655+ // `left_of` a key that itself IS in the span — range `..key` is exclusive,
656656+ // so it finds what comes before it, not the key itself.
657657+ #[test]
658658+ fn left_of_key_itself_in_span_nothing_before() {
659659+ let s = span(false, &[("b/r1", false)]);
660660+ assert_eq!(s.left_of("b/r1"), Existence::No);
661661+ }
662662+663663+ #[test]
664664+ fn left_of_key_itself_in_span_predecessor_exists() {
665665+ let s = span(false, &[("a/r1", false), ("b/r1", false)]);
666666+ assert_eq!(s.left_of("b/r1"), yes("a/r1"));
667667+ }
668668+669669+ // ── right_of ─────────────────────────────────────────────────────────────
670670+671671+ // Key is in the span and has a gap after it → uncertain what's to the right.
672672+ #[test]
673673+ fn right_of_key_in_span_gap_after() {
674674+ let s = span(false, &[("a/r1", true)]);
675675+ assert_eq!(s.right_of("a/r1", &Existence::No), Existence::Uncertain);
676676+ }
677677+678678+ // Key is in the span, no gap after, another key follows.
679679+ #[test]
680680+ fn right_of_key_in_span_no_gap_next_exists() {
681681+ let s = span(false, &[("a/r1", false), ("c/r1", false)]);
682682+ assert_eq!(s.right_of("a/r1", &Existence::No), yes("c/r1"));
683683+ }
684684+685685+ // Key is in the span, no gap after, it's the last entry.
686686+ #[test]
687687+ fn right_of_key_in_span_no_gap_last() {
688688+ let s = span(false, &[("a/r1", false)]);
689689+ assert_eq!(s.right_of("a/r1", &Existence::No), Existence::No);
690690+ }
691691+692692+ // Key is NOT in the span; left says we're in a gap → uncertain.
693693+ #[test]
694694+ fn right_of_key_not_in_span_left_uncertain() {
695695+ let s = span(false, &[("a/r1", true), ("c/r1", false)]);
696696+ assert_eq!(s.right_of("b/r1", &Existence::Uncertain), Existence::Uncertain);
697697+ }
698698+699699+ // Key is NOT in the span; left is definite → look for the next key to the right.
700700+ #[test]
701701+ fn right_of_key_not_in_span_left_certain_next_exists() {
702702+ let s = span(false, &[("a/r1", false), ("c/r1", false)]);
703703+ assert_eq!(s.right_of("b/r1", &yes("a/r1")), yes("c/r1"));
704704+ }
705705+706706+ // Key is NOT in the span; left is No (before first entry, no gap) → look right.
707707+ #[test]
708708+ fn right_of_key_not_in_span_left_no_next_exists() {
709709+ let s = span(false, &[("c/r1", false)]);
710710+ assert_eq!(s.right_of("b/r1", &Existence::No), yes("c/r1"));
711711+ }
712712+713713+ // Key is NOT in the span; left is certain but there's nothing further right.
714714+ #[test]
715715+ fn right_of_key_not_in_span_left_certain_no_next() {
716716+ let s = span(false, &[("a/r1", false)]);
717717+ assert_eq!(s.right_of("b/r1", &yes("a/r1")), Existence::No);
718718+ }
719719+720720+ // Empty all-gap span: right of anything is uncertain.
721721+ #[test]
722722+ fn right_of_empty_all_gap() {
723723+ assert_eq!(
724724+ span(true, &[]).right_of("a/r1", &Existence::Uncertain),
725725+ Existence::Uncertain
726726+ );
727727+ }
728728+729729+ // Empty no-gap span: right of anything is No.
730730+ #[test]
731731+ fn right_of_empty_no_gap() {
732732+ assert_eq!(
733733+ span(false, &[]).right_of("a/r1", &Existence::No),
734734+ Existence::No
735735+ );
736736+ }
737737+738738+ // ── combined: left_of feeds right_of ─────────────────────────────────────
739739+740740+ // The typical call pattern: compute left first, then use it for right.
741741+ // Span: gap | "a/r1" | "c/r1" | no-gap
742742+ // For key "b/r1" (not present): left is Uncertain (gap before b), right is Uncertain.
743743+ #[test]
744744+ fn combined_key_in_gap_both_uncertain() {
745745+ let s = span(false, &[("a/r1", true), ("c/r1", false)]);
746746+ let left = s.left_of("b/r1");
747747+ assert_eq!(left, Existence::Uncertain);
748748+ assert_eq!(s.right_of("b/r1", &left), Existence::Uncertain);
749749+ }
750750+751751+ // Span: "a/r1" | "b/r1" | "c/r1" all no-gap.
752752+ // For key "b/r1" (present): left is Yes("a/r1"), right is Yes("c/r1").
753753+ #[test]
754754+ fn combined_key_present_both_certain() {
755755+ let s = span(false, &[("a/r1", false), ("b/r1", false), ("c/r1", false)]);
756756+ let left = s.left_of("b/r1");
757757+ assert_eq!(left, yes("a/r1"));
758758+ assert_eq!(s.right_of("b/r1", &left), yes("c/r1"));
759759+ }
280760}
281761282762// =============================================================================
···307787308788#[cfg(test)]
309789mod fixture_tests {
310310- use super::{collect_visible_paths, extract};
790790+ use super::{collect_visible_paths, extract, ExtractResult};
311791 use std::collections::{BTreeMap, HashSet};
312792 use std::sync::Arc;
313793···5561036 }))
5571037 .collect();
5581038559559- let (born, died) = extract(&ops, parsed).unwrap();
10391039+ let ExtractResult { born, died, .. } = extract(&ops, parsed).unwrap();
5601040 // Adding to an existing app.bsky.feed.post collection:
5611041 // the adjacent key (3lon5cqsbwrj2) must be visible → no birth.
5621042 if !born.is_empty() || !died.is_empty() {
+1-1
src/mst/slice_tricks.rs
···121121 while let Some(item) = car.next()? {
122122 assert!(
123123 !matches!(item, WalkItem::Node { .. }),
124124- "car.next() does not return nodes"
124124+ "car.next() does not return found nodes"
125125 );
126126 let Some(key) = item.key() else {
127127 prev_gap = true;
+22-1
src/sync/firehose/commit_event.rs
···160160 metrics::histogram!("lightrail_commit_ops").record(commit.ops.len() as f64);
161161162162 // ── Collection birth/death detection ─────────────────────────────────────
163163- let (born, died) = crate::mst::mortality::extract(&commit.ops, parsed_clone)?;
163163+ let crate::mst::mortality::ExtractResult { born, died, needs_resync } =
164164+ crate::mst::mortality::extract(&commit.ops, parsed_clone)?;
164165165166 // ── Steps 6–9: Blocking storage checks + repo_prev update ───────────────
166167 let db = db.clone();
···186187 new_mst_root_bytes,
187188 born,
188189 died,
190190+ needs_resync,
189191 pds_host,
190192 current_mode: pds_mode,
191193 },
···362364 new_mst_root_bytes: Vec<u8>,
363365 born: Vec<Nsid<'static>>,
364366 died: Vec<Nsid<'static>>,
367367+ needs_resync: bool,
365368 pds_host: Option<Host>,
366369 current_mode: Sync11Mode,
367370}
···382385 new_mst_root_bytes,
383386 born,
384387 died,
388388+ needs_resync,
385389 pds_host,
386390 current_mode,
387391 }: ValidationState,
···466470 }
467471 for coll in died {
468472 storage::collection_index::remove_into(&mut batch, db, &did, coll);
473473+ }
474474+475475+ // If mortality detection found a possible-but-unproven collection death,
476476+ // queue a full-repo resync so the index can be reconciled once we have
477477+ // a complete view of the repo's current MST.
478478+ if needs_resync {
479479+ storage::resync_queue::enqueue_into(
480480+ &mut batch,
481481+ db,
482482+ crate::util::unix_now(),
483483+ &crate::storage::resync_queue::ResyncItem {
484484+ did: did.clone(),
485485+ retry_count: 0,
486486+ retry_reason: "possible collection death unproven due to MST gaps".to_string(),
487487+ commit_cbor: vec![],
488488+ },
489489+ );
469490 }
470491471492 // Upgrade PDS to strict on first prevData-bearing commit — atomically with