Skip to main content

zeppelin_embed/lifecycle/
namespace_batch.rs

1//! Namespace publication through one durable root decision.
2//!
3//! ZE-256 batches stage changes in stable participant directories. Local
4//! acceptance keeps prepared WAL frames committed after root retirement.
5//! Explicit cascades share publication but retain logical deletion and sealing.
6//! ZE-239 copy routes remain readable.
7use super::lock::StoreLock;
8use super::{AccessMode, OpenOptions, Store, StoreError};
9use crate::ingest::{DocId, IngestDocument};
10use crate::meta::Predicate;
11use crate::vfs::{StdVfs, SyncKind, Vfs};
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use xxhash_rust::xxh3::xxh3_64;
16mod portable;
17mod relocate;
18pub use portable::NamespaceRootId;
19pub use relocate::namespace_relocate;
20#[cfg(any(test, feature = "test-seams"))]
21pub use relocate::namespace_relocate_with_steps;
22
23// Constructed only by the coordinator for its own unpublished participant.
24pub(crate) struct PrivatePreparation {
25    root_id: NamespaceRootId,
26    name: String,
27    directory: PathBuf,
28}
29
30impl PrivatePreparation {
31    fn new(
32        root_id: NamespaceRootId,
33        name: &str,
34        directory: &Path,
35        _coordinator: &StoreLock,
36    ) -> Self {
37        Self {
38            root_id,
39            name: name.to_owned(),
40            directory: directory.to_path_buf(),
41        }
42    }
43}
44
45pub(super) fn validate_private_preparation(
46    vfs: &dyn Vfs,
47    directory: &Path,
48    authority: Option<&PrivatePreparation>,
49) -> Result<(), StoreError> {
50    if portable::authority_with_preparation(vfs, directory, authority)?.is_none() {
51        return Err(invalid(
52            directory,
53            "private preparation requires portable authority",
54        ));
55    }
56    Ok(())
57}
58
59const RECORD: &str = ".ze-namespaces";
60const REFERENCE: &str = ".ze-namespace-root";
61const PREPARED: &str = ".ze-prepared";
62const MAGIC: &[u8] = b"ZENS0001";
63const MAX_RECORD: usize = 1024 * 1024;
64static NEXT: AtomicU64 = AtomicU64::new(0);
65type Routes = BTreeMap<String, String>;
66
67/// One namespace's ordered upserts, deletes, then predicate delete.
68pub struct NamespaceMutation {
69    /// Existing direct child namespace name, using the namespace-open grammar.
70    pub name: String,
71    /// Existing namespace's schema, epoch and tokenizer declaration.
72    pub options: OpenOptions,
73    /// Documents to upsert.
74    pub upserts: Vec<IngestDocument>,
75    /// IDs to delete.
76    pub deletes: Vec<DocId>,
77    /// Predicate evaluated after the explicit changes, in the private state.
78    pub delete_where: Option<Predicate>,
79}
80
81/// A same-process writable participant and its ordered mutations.
82pub struct LiveNamespaceMutation<'a> {
83    /// Live writable handle for the named namespace.
84    pub store: &'a Store,
85    /// Namespace declaration and ordered changes.
86    pub mutation: NamespaceMutation,
87}
88
89/// Commits changes through caller-owned writable namespace handles.
90pub fn namespace_batch_live(
91    root: &Path,
92    participants: Vec<LiveNamespaceMutation<'_>>,
93) -> Result<Vec<u64>, StoreError> {
94    execute_live(root, participants, &StdVfs, &mut |_| Ok(()), None, false)
95}
96
97/// Explicit protocol interruption seam for deterministic tests.
98#[cfg(any(test, feature = "test-seams"))]
99#[doc(hidden)]
100pub fn namespace_batch_live_with_steps(
101    root: &Path,
102    participants: Vec<LiveNamespaceMutation<'_>>,
103    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
104) -> Result<Vec<u64>, StoreError> {
105    execute_live(root, participants, &StdVfs, step, None, false)
106}
107
108/// Protocol VFS seam. Participant handles must use the supplied VFS too.
109#[cfg(any(test, feature = "test-seams"))]
110#[doc(hidden)]
111pub fn namespace_batch_live_on_vfs(
112    root: &Path,
113    participants: Vec<LiveNamespaceMutation<'_>>,
114    vfs: &dyn Vfs,
115) -> Result<Vec<u64>, StoreError> {
116    execute_live(root, participants, vfs, &mut |_| Ok(()), None, false)
117}
118
119fn execute_live(
120    root: &Path,
121    mut participants: Vec<LiveNamespaceMutation<'_>>,
122    vfs: &dyn Vfs,
123    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
124    coordinator: Option<&StoreLock>,
125    logical_delete: bool,
126) -> Result<Vec<u64>, StoreError> {
127    use crate::ingest::wal_payload::{self, TransactionBinding};
128    use std::sync::Arc;
129    let root = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
130    let minimum = if logical_delete { 1 } else { 2 };
131    if !(minimum..=128).contains(&participants.len()) {
132        return Err(invalid(
133            &root,
134            "namespace batch requires 2..128 participants",
135        ));
136    }
137    let _coordinator = match coordinator {
138        Some(_) => None,
139        None => Some(StoreLock::acquire(&root).map_err(StoreError::Lock)?),
140    };
141    reclamation::run(vfs, &root, step)?;
142    let deleting = participants
143        .iter()
144        .any(|p| !p.mutation.deletes.is_empty() || p.mutation.delete_where.is_some());
145    if deleting && !logical_delete {
146        reclamation::require_retired_erased(vfs, &root)?;
147    }
148    normalize_accepted(vfs, &root, step)?;
149    let (mut root_id, existing_descriptor) = root_record_vfs(vfs, &root)?;
150    if read_optional_vfs(vfs, &root.join(RECORD))?.is_none() {
151        root_id = Some(NamespaceRootId::generate().map_err(|e| io(&root, e))?);
152    }
153    let existing_routes = match existing_descriptor {
154        RootDescriptor::Legacy(routes) => routes,
155        RootDescriptor::Staged(_) => return Err(invalid(&root, "pending adoption")),
156    };
157    let order = participants
158        .iter()
159        .enumerate()
160        .map(|(index, p)| (p.mutation.name.clone(), index))
161        .collect::<BTreeMap<_, _>>();
162    if order.len() != participants.len() {
163        return Err(invalid(&root, "duplicate participant"));
164    }
165    let mut identities = std::collections::BTreeSet::new();
166    for (name, index) in &order {
167        let p = participants
168            .get(*index)
169            .ok_or_else(|| invalid(&root, "participant index"))?;
170        if !name_valid(name) || p.mutation.options.access_mode != AccessMode::ReadWrite {
171            return Err(invalid(&root, "invalid or read-only participant"));
172        }
173        let logical = root.join(name);
174        if std::fs::symlink_metadata(&logical)
175            .map_err(|e| io(&logical, e))?
176            .file_type()
177            .is_symlink()
178        {
179            return Err(invalid(
180                &logical,
181                "namespace links are not transaction participants",
182            ));
183        }
184        let selected = std::fs::canonicalize(resolve(&logical)?).map_err(|e| io(&logical, e))?;
185        let directory = std::fs::canonicalize(&p.store.directory).map_err(|e| io(&logical, e))?;
186        if selected != directory || !identities.insert(directory) {
187            return Err(invalid(&logical, "foreign or aliased namespace handle"));
188        }
189    }
190    // Match the existing maintenance -> state -> writer -> WAL -> active order.
191    // Each level is acquired in canonical namespace order, including reversed inputs.
192    let ordered = order
193        .values()
194        .map(|index| {
195            participants
196                .get(*index)
197                .ok_or_else(|| invalid(&root, "participant index"))
198        })
199        .collect::<Result<Vec<_>, _>>()?;
200    let mut maintenance = Vec::new();
201    let mut states = Vec::new();
202    let mut owners = Vec::new();
203    let mut wals = Vec::new();
204    let mut actives = Vec::new();
205    for p in &ordered {
206        maintenance.push(
207            p.store
208                .maintenance
209                .lock()
210                .map_err(|_| invalid(&root, "maintenance lock"))?,
211        );
212    }
213    for p in &ordered {
214        let state = p
215            .store
216            .state
217            .lock()
218            .map_err(|_| invalid(&root, "state lock"))?;
219        match *state {
220            super::StoreState::Open => {}
221            super::StoreState::Closing => return Err(StoreError::Closing),
222            super::StoreState::Closed => return Err(StoreError::Closed),
223        }
224        states.push(state);
225    }
226    for p in &ordered {
227        let owner = p
228            .store
229            .writer_lock
230            .lock()
231            .map_err(|_| invalid(&root, "writer lock"))?;
232        if owner.is_none() {
233            return Err(StoreError::ReadOnly);
234        }
235        owners.push(owner);
236    }
237    for p in &ordered {
238        wals.push(
239            p.store
240                .wal_writer
241                .lock()
242                .map_err(|_| invalid(&root, "WAL lock"))?,
243        );
244    }
245    for p in &ordered {
246        actives.push(
247            p.store
248                .active
249                .lock()
250                .map_err(|_| invalid(&root, "active lock"))?,
251        );
252    }
253    let mut source_snapshots = Vec::new();
254    // All declarations and mutations validate before any WAL append/publication.
255    for ((p, wal), active) in ordered.iter().zip(&wals).zip(&actives) {
256        // Check every participant before staging, including an all-no-op batch.
257        // An unarmed health check cannot poison another healthy participant.
258        wal.as_ref()
259            .ok_or(StoreError::ReadOnly)?
260            .manifest_publication()?
261            .complete();
262        if read_optional_vfs(
263            p.store.vfs.as_ref(),
264            &p.store.directory.join(crate::ingest::PURGE_INTENT_FILE),
265        )?
266        .is_some()
267        {
268            return Err(StoreError::StoreBusy {
269                path: p.store.directory.clone(),
270            });
271        }
272        active.as_ref().ok_or(StoreError::Closed)?;
273        if !p.mutation.deletes.is_empty() || p.mutation.delete_where.is_some() {
274            p.store.require_no_snapshot_views()?;
275        }
276        let snapshot = p
277            .store
278            .snapshot
279            .read()
280            .map_err(|_| invalid(&root, "snapshot lock"))?
281            .as_ref()
282            .cloned()
283            .ok_or(StoreError::Closed)?;
284        super::resolve_open_schema(
285            true,
286            &snapshot,
287            p.mutation.options.schema.as_ref(),
288            AccessMode::ReadOnly,
289        )?;
290        super::validate_epoch_identity(
291            p.store.epoch_identity(),
292            p.mutation
293                .options
294                .epoch
295                .as_ref()
296                .map(crate::epoch::StoreEpoch::identity),
297            true,
298            AccessMode::ReadOnly,
299        )?;
300        let tokenizer = crate::fts::tokenizer::Analyzer::new(
301            p.mutation
302                .options
303                .tokenizer
304                .clone()
305                .unwrap_or_else(crate::fts::tokenizer::TokenizerConfig::text_default),
306        )
307        .map_err(StoreError::Tokenizer)?;
308        if tokenizer.config() != p.store.tokenizer.config() {
309            return Err(invalid(&root, "participant tokenizer mismatch"));
310        }
311        source_snapshots.push(snapshot);
312    }
313    let mut staged = Vec::new();
314    for (((p, wal), active), snapshot) in ordered
315        .iter()
316        .zip(&wals)
317        .zip(&actives)
318        .zip(&source_snapshots)
319    {
320        let writer = wal.as_ref().ok_or(StoreError::ReadOnly)?;
321        let current = active.as_ref().ok_or(StoreError::Closed)?;
322        match p
323            .store
324            .stage_namespace(&p.mutation, current, snapshot, writer.durable_end())
325        {
326            Ok(stage) => staged.push(stage),
327            Err(error) => {
328                for (prior, stage) in ordered.iter().zip(staged) {
329                    cleanup_stage(prior.store, stage, None)?;
330                }
331                return Err(invalid(&p.store.directory, &error.to_string()));
332            }
333        }
334    }
335    let transaction = (u128::from(std::process::id()) << 96)
336        | (u128::from(
337            std::time::SystemTime::now()
338                .duration_since(std::time::UNIX_EPOCH)
339                .map_err(|_| invalid(&root, "transaction clock"))?
340                .as_nanos() as u64,
341        ) << 32)
342        | u128::from(NEXT.fetch_add(1, Ordering::Relaxed));
343    let mut descriptor = StagedDescriptor(BTreeMap::new(), existing_routes);
344    let mut lengths = Vec::new();
345    let mut selections = Vec::new();
346    for (p, stage) in ordered.iter().zip(&staged) {
347        let wal_path = p.store.directory.join("wal.ze");
348        lengths.push(p.store.vfs.open(&wal_path).map_err(|e| io(&wal_path, e))?);
349        if stage.records.is_empty() {
350            selections.push(None);
351            continue;
352        }
353        let bytes =
354            crate::manifest::encode_manifest(&stage.manifest).map_err(StoreError::Manifest)?;
355        let first_seq = wals
356            .get(selections.len())
357            .and_then(|wal| wal.as_ref())
358            .ok_or(StoreError::ReadOnly)?
359            .durable_end()
360            .checked_add(1)
361            .ok_or(StoreError::GenerationOverflow)?;
362        let binding = TransactionBinding {
363            transaction,
364            participant: match root_id {
365                Some(id) => portable::participant_id(id, &p.mutation.name)?,
366                None => participant_identity(&root, &p.mutation.name)?,
367            },
368            first_seq,
369            last_seq: first_seq
370                .checked_add(stage.records.len() as u64 - 1)
371                .ok_or(StoreError::GenerationOverflow)?,
372            manifest_digest: xxh3_64(&bytes),
373            final_generation: stage.active.generation,
374        };
375        let selection = StagedSelection {
376            manifest: format!(".ze-manifest-{transaction}"),
377            binding,
378            logical_delete,
379        };
380        descriptor
381            .0
382            .insert(p.mutation.name.clone(), selection.clone());
383        selections.push(Some(selection));
384    }
385    if descriptor.0.is_empty() {
386        let generations = ordered
387            .iter()
388            .zip(&staged)
389            .map(|(p, s)| (p.mutation.name.clone(), s.active.generation))
390            .collect::<BTreeMap<_, _>>();
391        return participants
392            .iter()
393            .map(|p| {
394                generations
395                    .get(&p.mutation.name)
396                    .copied()
397                    .ok_or_else(|| invalid(&root, "missing generation"))
398            })
399            .collect();
400    }
401    let decision = encode_root_record(root_id, &RootDescriptor::Staged(descriptor.clone()))?;
402    let prepared = (|| {
403        if root_id.is_some() {
404            if read_optional_vfs(vfs, &root.join(RECORD))?.is_none() {
405                publish(
406                    vfs,
407                    &root,
408                    &encode_root_record(root_id, &RootDescriptor::Legacy(Routes::new()))?,
409                    step,
410                )?;
411                step("bootstrap root published").map_err(|e| io(&root, e))?;
412            } else {
413                // Visibility after an interrupted rename does not prove that
414                // the root entry is durable. References must never outlive it.
415                sync_dir(vfs, &root, step)?;
416            }
417            for p in &ordered {
418                let logical = root.join(&p.mutation.name);
419                let reference = logical.join(REFERENCE);
420                if read_optional_vfs(vfs, &reference)?.is_none() {
421                    publish_reference(
422                        vfs,
423                        &logical,
424                        &encode_participant_reference(root_id, &root, &p.mutation.name, 1)?,
425                        step,
426                    )?;
427                    step("participant reference installed").map_err(|e| io(&logical, e))?;
428                }
429            }
430        }
431        for (((p, stage), selection), wal) in ordered
432            .iter()
433            .zip(&mut staged)
434            .zip(&selections)
435            .zip(&mut wals)
436        {
437            let Some(selection) = selection else {
438                continue;
439            };
440            let bytes =
441                crate::manifest::encode_manifest(&stage.manifest).map_err(StoreError::Manifest)?;
442            durable_write(
443                p.store.vfs.as_ref(),
444                &p.store.directory.join(&selection.manifest),
445                &bytes,
446                step,
447            )?;
448            sync_dir(p.store.vfs.as_ref(), &p.store.directory, step)?;
449            let count = u32::try_from(stage.records.len())
450                .map_err(|_| invalid(&root, "prepared member count"))?;
451            let frames = stage
452                .records
453                .iter()
454                .zip(0_u32..)
455                .map(|((op, payload), index)| {
456                    wal_payload::encode_prepared(selection.binding, index, count, *op, payload)
457                        .map_err(|e| invalid(&root, &e.to_string()))
458                })
459                .collect::<Result<Vec<_>, _>>()?;
460            let refs = frames
461                .iter()
462                .map(|frame| (wal_payload::PREPARED_MUTATION_V1, frame.as_slice()))
463                .collect::<Vec<_>>();
464            wal.as_mut()
465                .ok_or(StoreError::ReadOnly)?
466                .commit_many(&refs)?;
467            step("prepared append").map_err(|e| io(&p.store.directory, e))?;
468            p.store
469                .vfs
470                .sync(&p.store.directory.join("wal.ze"), SyncKind::Full)
471                .map_err(|e| io(&p.store.directory, e))?;
472            step("prepared WAL sync").map_err(|e| io(&p.store.directory, e))?;
473            let active = Arc::get_mut(&mut stage.active.segment)
474                .ok_or_else(|| invalid(&root, "private active ownership"))?;
475            for (row, seq) in stage.rows.iter().zip(selection.binding.first_seq..) {
476                for row in row {
477                    active.set_sequence(*row, crate::wal::LogSeq::new(seq))?;
478                }
479            }
480        }
481        if root_id.is_none() {
482            if read_optional_vfs(vfs, &root.join(RECORD))?.is_none() {
483                publish(
484                    vfs,
485                    &root,
486                    &encode_root_record(root_id, &RootDescriptor::Legacy(Routes::new()))?,
487                    step,
488                )?;
489            }
490            for p in &ordered {
491                let logical = root.join(&p.mutation.name);
492                let reference = logical.join(REFERENCE);
493                if read_optional_vfs(vfs, &reference)?.is_none() {
494                    durable_write(
495                        vfs,
496                        &reference,
497                        &encode_participant_reference(root_id, &root, &p.mutation.name, 1)?,
498                        step,
499                    )?;
500                    sync_dir(vfs, &logical, step)?;
501                }
502            }
503        }
504        Ok::<_, StoreError>(())
505    })();
506    if let Err(error) = prepared {
507        rollback_stages(&ordered, staged, &selections, &lengths, &mut wals)?;
508        return Err(error);
509    }
510    let mut snapshots = Vec::new();
511    for p in &ordered {
512        match p.store.snapshot.write() {
513            Ok(snapshot) => snapshots.push(snapshot),
514            Err(_) => {
515                rollback_stages(&ordered, staged, &selections, &lengths, &mut wals)?;
516                return Err(invalid(&root, "snapshot lock"));
517            }
518        }
519    }
520    let mut publication_attempted = false;
521    let published = (|| {
522        let temporary = root.join(".ze-namespaces.tmp");
523        durable_write(vfs, &temporary, &decision, step)?;
524        let publications = wals
525            .iter()
526            .map(|wal| {
527                wal.as_ref()
528                    .ok_or(StoreError::ReadOnly)?
529                    .manifest_publication()
530                    .map(|publication| publication.arm())
531            })
532            .collect::<Result<Vec<_>, StoreError>>()?;
533        publication_attempted = true;
534        vfs.rename(&temporary, &root.join(RECORD))
535            .map_err(|e| io(&root, e))?;
536        step("commit rename").map_err(|e| io(&root, e))?;
537        sync_dir(vfs, &root, step)?;
538        Ok(publications)
539    })();
540    let mut publications = match published {
541        Ok(publications) => publications,
542        Err(error) => {
543            if publication_attempted {
544                for wal in &mut wals {
545                    **wal = None;
546                }
547            } else {
548                rollback_stages(&ordered, staged, &selections, &lengths, &mut wals)?;
549            }
550            return Err(error);
551        }
552    };
553    // Once publication is attempted its outcome may be indeterminate. Never
554    // truncate prepared evidence in this branch; fence queued writes instead.
555    let adopted = (|| {
556        for ((active, stage), snapshot) in actives.iter_mut().zip(&staged).zip(&mut snapshots) {
557            **active = Some(crate::ingest::ActiveState {
558                generation: stage.active.generation,
559                segment: Arc::clone(&stage.active.segment),
560            });
561            **snapshot = Some(Arc::clone(&stage.snapshot));
562        }
563        step("live states installed").map_err(|e| io(&root, e))?;
564        for ((p, stage), selection) in ordered.iter().zip(&staged).zip(&selections) {
565            if let Some(selection) = selection {
566                accept(
567                    p.store.vfs.as_ref(),
568                    &p.store.directory,
569                    selection,
570                    &stage.manifest,
571                    step,
572                )?;
573            }
574        }
575        publish(
576            vfs,
577            &root,
578            &encode_root_record(root_id, &RootDescriptor::Legacy(descriptor.1.clone()))?,
579            step,
580        )
581    })();
582    if let Err(error) = adopted {
583        for wal in &mut wals {
584            **wal = None;
585        }
586        return Err(error);
587    }
588    drop(snapshots);
589    drop(actives);
590    let mut completed = BTreeMap::new();
591    for ((p, stage), wal) in ordered.iter().zip(&staged).zip(&mut wals) {
592        let generation = if read_optional_vfs(
593            p.store.vfs.as_ref(),
594            &p.store.directory.join(crate::ingest::PURGE_INTENT_FILE),
595        )?
596        .is_some()
597        {
598            match p
599                .store
600                .complete_namespace_purge_locked(wal.as_mut().ok_or(StoreError::ReadOnly)?)
601            {
602                Ok(generation) => generation,
603                Err(error) => {
604                    for wal in &mut wals {
605                        **wal = None;
606                    }
607                    return Err(StoreError::PurgeRecovery {
608                        detail: error.to_string(),
609                    });
610                }
611            }
612        } else {
613            stage.active.generation
614        };
615        // A participant's purge may have replaced its WAL writer. Carry the
616        // same publication fence on that replacement through final cleanup.
617        publications.push(
618            wal.as_ref()
619                .ok_or(StoreError::ReadOnly)?
620                .manifest_publication()?
621                .arm(),
622        );
623        completed.insert(p.mutation.name.clone(), generation);
624    }
625    if deleting && !logical_delete {
626        reclamation::run(vfs, &root, step)?;
627        reclamation::require_retired_erased(vfs, &root)?;
628    }
629    for publication in publications {
630        publication.complete();
631    }
632    let by_name = completed;
633    drop(wals);
634    drop(owners);
635    drop(states);
636    drop(maintenance);
637    drop(ordered);
638    participants
639        .iter_mut()
640        .map(|p| {
641            by_name
642                .get(&p.mutation.name)
643                .copied()
644                .ok_or_else(|| invalid(&root, "missing generation"))
645        })
646        .collect()
647}
648
649fn cleanup_stage(
650    store: &Store,
651    stage: crate::ingest::NamespaceStage,
652    selection: Option<&StagedSelection>,
653) -> Result<(), StoreError> {
654    let mut paths = stage
655        .replacements
656        .iter()
657        .map(|id| store.directory.join(id.file_name()))
658        .collect::<Vec<_>>();
659    if let Some(selection) = selection {
660        paths.push(store.directory.join(&selection.manifest));
661    }
662    for path in paths {
663        match store.vfs.delete(&path) {
664            Ok(()) => {}
665            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
666            Err(e) => return Err(io(&path, e)),
667        }
668    }
669    store
670        .vfs
671        .sync(&store.directory, SyncKind::Full)
672        .map_err(|e| io(&store.directory, e))
673}
674
675pub(super) fn io(path: &Path, source: std::io::Error) -> StoreError {
676    StoreError::Io {
677        path: path.to_path_buf(),
678        source,
679    }
680}
681pub(crate) fn invalid(path: &Path, message: &str) -> StoreError {
682    io(
683        path,
684        std::io::Error::new(std::io::ErrorKind::InvalidData, message),
685    )
686}
687pub(super) fn name_valid(name: &str) -> bool {
688    name.as_bytes()
689        .first()
690        .is_some_and(u8::is_ascii_alphanumeric)
691        && name.len() <= 255
692        && name
693            .bytes()
694            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
695}
696pub(super) fn envelope(body: &[u8]) -> Vec<u8> {
697    let mut bytes = MAGIC.to_vec();
698    bytes.extend_from_slice(body);
699    bytes.extend_from_slice(&xxh3_64(&bytes).to_le_bytes());
700    bytes
701}
702pub(super) fn body<'a>(path: &Path, bytes: &'a [u8]) -> Result<&'a [u8], StoreError> {
703    let end = bytes
704        .len()
705        .checked_sub(8)
706        .ok_or_else(|| invalid(path, "short namespace record"))?;
707    let prefix = bytes
708        .get(..end)
709        .ok_or_else(|| invalid(path, "namespace record bounds"))?;
710    if bytes.len() > MAX_RECORD
711        || !prefix.starts_with(MAGIC)
712        || bytes.get(end..) != Some(xxh3_64(prefix).to_le_bytes().as_slice())
713    {
714        return Err(invalid(path, "namespace record checksum/version"));
715    }
716    prefix
717        .get(MAGIC.len()..)
718        .ok_or_else(|| invalid(path, "namespace record header"))
719}
720pub(super) fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, StoreError> {
721    match StdVfs.open(path) {
722        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
723        Err(e) => Err(io(path, e)),
724        Ok(n) if n > MAX_RECORD as u64 => Err(invalid(path, "namespace record too large")),
725        Ok(_) => StdVfs.read(path).map(Some).map_err(|e| io(path, e)),
726    }
727}
728fn encode(routes: &Routes) -> Vec<u8> {
729    // Names and destinations are restricted ASCII without separators except
730    // the one slash separating a preparation directory from its namespace.
731    let mut text = String::new();
732    for (name, destination) in routes {
733        text.push_str(name);
734        text.push('\t');
735        text.push_str(destination);
736        text.push('\n');
737    }
738    envelope(text.as_bytes())
739}
740#[derive(Clone, Debug, Eq, PartialEq)]
741enum RootDescriptor {
742    Legacy(Routes),
743    Staged(StagedDescriptor),
744}
745fn routes(root: &Path) -> Result<Routes, StoreError> {
746    match root_descriptor_vfs(&StdVfs, root)? {
747        RootDescriptor::Legacy(routes) => Ok(routes),
748        RootDescriptor::Staged(_) => Err(invalid(
749            &root.join(RECORD),
750            "staged manifest requires transaction adoption",
751        )),
752    }
753}
754fn root_descriptor(root: &Path) -> Result<RootDescriptor, StoreError> {
755    root_descriptor_vfs(&StdVfs, root)
756}
757fn root_descriptor_vfs(vfs: &dyn Vfs, root: &Path) -> Result<RootDescriptor, StoreError> {
758    root_record_vfs(vfs, root).map(|(_, descriptor)| descriptor)
759}
760fn encode_root_record(
761    id: Option<NamespaceRootId>,
762    descriptor: &RootDescriptor,
763) -> Result<Vec<u8>, StoreError> {
764    match id {
765        Some(id) => portable::encode_root(id, descriptor),
766        None => match descriptor {
767            RootDescriptor::Legacy(routes) => Ok(encode(routes)),
768            RootDescriptor::Staged(staged) => encode_staged(staged),
769        },
770    }
771}
772
773fn encode_participant_reference(
774    id: Option<NamespaceRootId>,
775    root: &Path,
776    name: &str,
777    parent_depth: u8,
778) -> Result<Vec<u8>, StoreError> {
779    match id {
780        Some(root_id) => portable::encode_reference(&portable::Reference {
781            root_id,
782            parent_depth,
783            name: name.to_owned(),
784        }),
785        None => Ok(envelope(
786            root.to_str()
787                .ok_or_else(|| invalid(root, "root UTF-8"))?
788                .as_bytes(),
789        )),
790    }
791}
792
793fn root_record_vfs(
794    vfs: &dyn Vfs,
795    root: &Path,
796) -> Result<(Option<NamespaceRootId>, RootDescriptor), StoreError> {
797    let path = root.join(RECORD);
798    let Some(bytes) = read_optional_vfs(vfs, &path)? else {
799        return Ok((None, RootDescriptor::Legacy(Routes::new())));
800    };
801    if bytes.starts_with(portable::ROOT_MAGIC) {
802        return portable::decode_root(&path, &bytes).map(|(id, descriptor)| (Some(id), descriptor));
803    }
804    decode_descriptor(&path, &bytes).map(|descriptor| (None, descriptor))
805}
806fn decode_descriptor(path: &Path, bytes: &[u8]) -> Result<RootDescriptor, StoreError> {
807    if bytes.starts_with(STAGED_MAGIC) || bytes.starts_with(LOGICAL_STAGED_MAGIC) {
808        return decode_staged(path, bytes).map(RootDescriptor::Staged);
809    }
810    decode_routes(path, bytes).map(RootDescriptor::Legacy)
811}
812fn decode_routes(path: &Path, bytes: &[u8]) -> Result<Routes, StoreError> {
813    let text = std::str::from_utf8(body(path, bytes)?)
814        .map_err(|_| invalid(path, "namespace record UTF-8"))?;
815    let mut result = Routes::new();
816    for line in text.split_terminator('\n') {
817        let (name, destination) = line
818            .split_once('\t')
819            .ok_or_else(|| invalid(path, "namespace route"))?;
820        let (transaction, child) = destination
821            .split_once('/')
822            .ok_or_else(|| invalid(path, "namespace destination"))?;
823        if !name_valid(name)
824            || child != name
825            || !transaction.starts_with(".ze-batch-")
826            || !transaction
827                .bytes()
828                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
829            || transaction.contains("..")
830            || result
831                .insert(name.to_owned(), destination.to_owned())
832                .is_some()
833        {
834            return Err(invalid(path, "invalid or duplicate namespace route"));
835        }
836    }
837    if encode(&result) != bytes {
838        return Err(invalid(path, "noncanonical namespace record"));
839    }
840    Ok(result)
841}
842
843// Missing logical paths can still resolve through a committed namespace route.
844pub(super) fn validate_store_path(path: &Path) -> Result<(), StoreError> {
845    match std::fs::metadata(path) {
846        Ok(metadata) if !metadata.is_dir() => {
847            return Err(StoreError::NotDirectory {
848                path: path.to_path_buf(),
849            });
850        }
851        Ok(_) => {}
852        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
853        Err(error) => return Err(io(path, error)),
854    }
855    Ok(())
856}
857
858/// Selects one complete committed namespace. No sibling is opened or repaired.
859pub(super) fn resolve(path: &Path) -> Result<PathBuf, StoreError> {
860    validate_store_path(path)?;
861    let Some(parent) = path.parent() else {
862        return Ok(path.to_path_buf());
863    };
864    let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
865        return Ok(path.to_path_buf());
866    };
867    let portable = portable::authority(&StdVfs, path)?;
868    let parent = portable.map_or(parent, |(root, _, _)| root);
869    let reference = if portable.is_none() {
870        read_optional(&path.join(REFERENCE))?
871    } else {
872        None
873    };
874    if let Some(reference) = reference {
875        let expected = body(&path.join(REFERENCE), &reference)?;
876        let canonical = std::fs::canonicalize(parent).map_err(|e| io(parent, e))?;
877        if canonical.to_str().map(str::as_bytes) != Some(expected)
878            || read_optional(&parent.join(RECORD))?.is_none()
879        {
880            return Err(invalid(
881                path,
882                "namespace requires its original transaction root",
883            ));
884        }
885    }
886    let selected = match root_descriptor(parent)? {
887        RootDescriptor::Legacy(mut routes) => routes.remove(name),
888        RootDescriptor::Staged(mut descriptor) => descriptor.1.remove(name),
889    };
890    match selected {
891        None => Ok(path.to_path_buf()),
892        Some(destination) => {
893            let selected = parent.join(&destination);
894            if let Some((_, _, id)) = portable
895                && portable::authority(&StdVfs, &selected)?.map(|(_, _, selected_id)| selected_id)
896                    != Some(id)
897            {
898                return Err(invalid(&selected, "foreign portable route participant"));
899            }
900            let transaction = selected
901                .parent()
902                .ok_or_else(|| invalid(path, "missing transaction directory"))?;
903            let intent_path = transaction.join("intent.ze");
904            let intent = read_optional(&intent_path)?
905                .ok_or_else(|| invalid(&intent_path, "missing transaction intent"))?;
906            let intent_body = std::str::from_utf8(body(&intent_path, &intent)?)
907                .map_err(|_| invalid(&intent_path, "intent UTF-8"))?;
908            if !intent_body
909                .lines()
910                .any(|line| line == format!("{name}\t{destination}"))
911            {
912                return Err(invalid(
913                    &intent_path,
914                    "decision disagrees with preparation intent",
915                ));
916            }
917            let prepared_path = selected.join(PREPARED);
918            let bytes = read_optional(&prepared_path)?
919                .ok_or_else(|| invalid(&prepared_path, "missing namespace preparation"))?;
920            if body(&prepared_path, &bytes)? != destination.as_bytes() {
921                return Err(invalid(
922                    &prepared_path,
923                    "namespace preparation identity mismatch",
924                ));
925            }
926            // Ordinary store open may create or accept an empty store. A
927            // committed preparation is never such a store: these files were
928            // required and synced before its decision, so loss is corruption.
929            for filename in ["manifest.ze", "wal.ze"] {
930                let required = selected.join(filename);
931                let length = StdVfs.open(&required).map_err(|e| io(&required, e))?;
932                if length == 0 {
933                    return Err(invalid(&required, "empty committed preparation file"));
934                }
935            }
936            Ok(selected)
937        }
938    }
939}
940
941pub(super) fn durable_write(
942    vfs: &dyn Vfs,
943    path: &Path,
944    bytes: &[u8],
945    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
946) -> Result<(), StoreError> {
947    vfs.write(path, bytes).map_err(|e| io(path, e))?;
948    step("write").map_err(|e| io(path, e))?;
949    vfs.sync(path, SyncKind::Full).map_err(|e| io(path, e))?;
950    step("file sync").map_err(|e| io(path, e))
951}
952// The final reference is always absent or complete, including during process death.
953fn publish_reference(
954    vfs: &dyn Vfs,
955    directory: &Path,
956    bytes: &[u8],
957    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
958) -> Result<(), StoreError> {
959    let temporary = directory.join(".ze-namespace-root.tmp");
960    let reference = directory.join(REFERENCE);
961    durable_write(vfs, &temporary, bytes, step)?;
962    vfs.rename(&temporary, &reference)
963        .map_err(|e| io(&reference, e))?;
964    step("reference rename").map_err(|e| io(&reference, e))?;
965    sync_dir(vfs, directory, step)
966}
967
968pub(super) fn sync_dir(
969    vfs: &dyn Vfs,
970    path: &Path,
971    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
972) -> Result<(), StoreError> {
973    vfs.sync(path, SyncKind::Full).map_err(|e| io(path, e))?;
974    step("directory sync").map_err(|e| io(path, e))
975}
976
977pub(super) fn sync_portable_publication_for_open(
978    vfs: &dyn Vfs,
979    directory: &Path,
980) -> Result<(), StoreError> {
981    if let Some((root, _, _)) = portable::authority(vfs, directory)? {
982        // A converted root can be visible after rename without its name being
983        // durable. Complete that publication before recovery or later writes.
984        if let Some(parent) = root.parent() {
985            let parent = if parent.as_os_str().is_empty() {
986                Path::new(".")
987            } else {
988                parent
989            };
990            sync_dir(vfs, parent, &mut |_| Ok(()))?;
991        }
992        sync_dir(vfs, root, &mut |_| Ok(()))?;
993    }
994    Ok(())
995}
996
997fn publish(
998    vfs: &dyn Vfs,
999    root: &Path,
1000    bytes: &[u8],
1001    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1002) -> Result<(), StoreError> {
1003    let temporary = root.join(".ze-namespaces.tmp");
1004    durable_write(vfs, &temporary, bytes, step)?;
1005    vfs.rename(&temporary, &root.join(RECORD))
1006        .map_err(|e| io(root, e))?;
1007    step("commit rename").map_err(|e| io(root, e))?;
1008    sync_dir(vfs, root, step)
1009}
1010
1011/// Atomically commits ordered namespace mutations through one root rename.
1012///
1013/// This path API opens closed writers and shares the incremental live protocol.
1014/// Use [`namespace_batch_live`] when writable handles are already open. Data,
1015/// decision and local acceptance are fully synced before success is returned.
1016/// An error once publication is attempted is indeterminate; reopen before
1017/// retrying. Deleted bytes and superseded artifacts are retained in this scope.
1018/// Generations follow input order. Ordinary writes perform no root I/O.
1019pub fn namespace_batch(
1020    root: &Path,
1021    mutations: Vec<NamespaceMutation>,
1022) -> Result<Vec<u64>, StoreError> {
1023    execute(root, mutations, Operation::Batch, &mut |_| Ok(()))
1024}
1025
1026/// Protocol interruption seam for kill/fault tests; never enabled by environment.
1027#[cfg(any(test, feature = "test-seams"))]
1028#[doc(hidden)]
1029pub fn namespace_batch_with_steps(
1030    root: &Path,
1031    mutations: Vec<NamespaceMutation>,
1032    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1033) -> Result<Vec<u64>, StoreError> {
1034    execute(root, mutations, Operation::Batch, step)
1035}
1036
1037/// Durably declares a cascade while owning the root and participant writer locks.
1038/// Participants supply the existing parent/child specs; mutation fields must be empty.
1039pub fn namespace_declare_cascade(
1040    root: &Path,
1041    participants: Vec<NamespaceMutation>,
1042    rule: super::CascadeRule,
1043) -> Result<(), StoreError> {
1044    execute(
1045        root,
1046        participants,
1047        Operation::Declare(rule),
1048        &mut |_| Ok(()),
1049    )
1050    .map(|_| ())
1051}
1052
1053/// Deletes explicit IDs and all declared transitive dependants in one root commit.
1054/// Supply every reachable namespace with its existing options. Only `deletes`
1055/// may be populated. All ZE-239 closed-writer and logical-deletion limits apply.
1056pub fn namespace_delete_cascade(
1057    root: &Path,
1058    participants: Vec<NamespaceMutation>,
1059) -> Result<Vec<u64>, StoreError> {
1060    execute(root, participants, Operation::Cascade, &mut |_| Ok(()))
1061}
1062
1063/// Protocol interruption seam for cascade kill tests.
1064#[cfg(any(test, feature = "test-seams"))]
1065#[doc(hidden)]
1066pub fn namespace_delete_cascade_with_steps(
1067    root: &Path,
1068    participants: Vec<NamespaceMutation>,
1069    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1070) -> Result<Vec<u64>, StoreError> {
1071    execute(root, participants, Operation::Cascade, step)
1072}
1073
1074enum Operation {
1075    Batch,
1076    Cascade,
1077    Declare(super::CascadeRule),
1078}
1079
1080fn execute(
1081    root: &Path,
1082    mut mutations: Vec<NamespaceMutation>,
1083    operation: Operation,
1084    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1085) -> Result<Vec<u64>, StoreError> {
1086    let root = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
1087    let minimum = if matches!(operation, Operation::Batch) {
1088        2
1089    } else {
1090        1
1091    };
1092    if mutations.len() < minimum || mutations.len() > 128 {
1093        return Err(invalid(
1094            &root,
1095            "namespace batch requires 2..128 participants",
1096        ));
1097    }
1098    if !matches!(operation, Operation::Batch)
1099        && mutations.iter().any(|m| {
1100            !m.upserts.is_empty()
1101                || m.delete_where.is_some()
1102                || (matches!(operation, Operation::Declare(_)) && !m.deletes.is_empty())
1103        })
1104    {
1105        return Err(invalid(
1106            &root,
1107            "cascade operations reject upserts/filters; declarations reject deletes",
1108        ));
1109    }
1110    let _coordinator = StoreLock::acquire(&root).map_err(StoreError::Lock)?;
1111    reclamation::run(&StdVfs, &root, step)?;
1112    if root_record_vfs(&StdVfs, &root)?.0.is_some() {
1113        normalize_accepted(&StdVfs, &root, step)?;
1114    }
1115    routes(&root)?;
1116    let mut ordered = BTreeMap::new();
1117    for (index, mutation) in mutations.iter().enumerate() {
1118        if !name_valid(&mutation.name)
1119            || ordered.insert(mutation.name.clone(), index).is_some()
1120            || mutation.options.access_mode != AccessMode::ReadWrite
1121        {
1122            return Err(invalid(
1123                &root,
1124                "invalid, duplicate, or read-only namespace participant",
1125            ));
1126        }
1127    }
1128    // Open closed participants in canonical order and retain their writer locks.
1129    let mut sources = BTreeMap::new();
1130    for (name, index) in &ordered {
1131        let mutation = mutations
1132            .get(*index)
1133            .ok_or_else(|| invalid(&root, "participant index"))?;
1134        let path = root.join(name);
1135        if std::fs::symlink_metadata(&path)
1136            .map_err(|e| io(&path, e))?
1137            .file_type()
1138            .is_symlink()
1139        {
1140            return Err(invalid(
1141                &path,
1142                "namespace links are not transaction participants",
1143            ));
1144        }
1145        let mut options = mutation.options.clone();
1146        options.access_mode = AccessMode::ReadOnly;
1147        // Validate declarations without allowing additive schema evolution.
1148        drop(Store::open(&path, options)?);
1149        let mut options = mutation.options.clone();
1150        if matches!(operation, Operation::Cascade) {
1151            options.durability_mode = super::durability::DurabilityMode::Durable;
1152            options.commit_tier = super::durability::CommitTier::Durable;
1153        }
1154        sources.insert(name.clone(), Store::open(&path, options)?);
1155    }
1156    match operation {
1157        Operation::Declare(rule) => {
1158            super::cascade::declare(&root, &sources, rule, step)?;
1159            return Ok(Vec::new());
1160        }
1161        Operation::Cascade => super::cascade::expand(&root, &sources, &mut mutations)?,
1162        Operation::Batch => {}
1163    }
1164    let logical_delete = matches!(operation, Operation::Cascade);
1165    let input_order = mutations.iter().map(|m| m.name.clone()).collect::<Vec<_>>();
1166    let live = mutations
1167        .into_iter()
1168        .map(|mutation| {
1169            sources
1170                .get(&mutation.name)
1171                .map(|store| LiveNamespaceMutation { store, mutation })
1172                .ok_or_else(|| invalid(&root, "missing source"))
1173        })
1174        .collect::<Result<Vec<_>, _>>()?;
1175    let generations = execute_live(
1176        &root,
1177        live,
1178        &StdVfs,
1179        step,
1180        Some(&_coordinator),
1181        logical_delete,
1182    )?;
1183    if !logical_delete {
1184        return Ok(generations);
1185    }
1186    // Keep the cascade's delete-then-seal generations, including participants
1187    // with no matching deletes. Publication is already durable; any checkpoint
1188    // failure is indeterminate, as with other post-publication failures.
1189    let mut generations = Vec::new();
1190    for name in input_order {
1191        let store = sources
1192            .get(&name)
1193            .ok_or_else(|| invalid(&root, "missing source"))?;
1194        generations.push(store.seal()?);
1195        step("cascade checkpoint").map_err(|e| io(&root, e))?;
1196    }
1197    Ok(generations)
1198}
1199
1200#[cfg(test)]
1201#[allow(clippy::expect_used)]
1202mod tests {
1203    use super::*;
1204    use crate::vfs::crash::{CrashVfs, MemoryVfs};
1205
1206    #[test]
1207    fn root_decision_power_cuts_never_publish_a_torn_or_partial_selection() {
1208        let root = Path::new("/root");
1209        let before = encode(&Routes::new());
1210        let after = encode(&Routes::from([
1211            ("a".into(), ".ze-batch-1/a".into()),
1212            ("b".into(), ".ze-batch-1/b".into()),
1213        ]));
1214        let memory = MemoryVfs::new();
1215        memory
1216            .insert(root.join(RECORD), before.clone())
1217            .expect("seed decision");
1218        let crash = CrashVfs::new(memory).expect("recorder");
1219        publish(&crash, root, &after, &mut |_| Ok(())).expect("publish");
1220        let states = crash.crash_states().expect("power cuts");
1221        assert!(!states.was_capped());
1222        for state in states.iter() {
1223            let bytes = state
1224                .vfs()
1225                .read(&root.join(RECORD))
1226                .expect("previous or new root");
1227            assert!(bytes == before || bytes == after, "{:?}", state.kind());
1228        }
1229        eprintln!("ZE-239 root power-cut states: {}", states.len());
1230    }
1231}
1232
1233#[cfg(test)]
1234#[allow(clippy::expect_used)]
1235mod descriptor_tests {
1236    use super::*;
1237    #[test]
1238    fn nonparticipant_route_survives_v2_publish() {
1239        let temporary = tempfile::tempdir().expect("root");
1240        let root = temporary.path();
1241        let destination = ".ze-batch-1/c";
1242        let selected = root.join(destination);
1243        std::fs::create_dir_all(&selected).expect("selected directory");
1244        std::fs::create_dir(root.join("c")).expect("logical directory");
1245        let routes = Routes::from([("c".into(), destination.into())]);
1246        std::fs::write(root.join(".ze-batch-1/intent.ze"), encode(&routes)).expect("intent");
1247        std::fs::write(selected.join(PREPARED), envelope(destination.as_bytes()))
1248            .expect("prepared marker");
1249        for filename in ["manifest.ze", "wal.ze"] {
1250            std::fs::write(selected.join(filename), b"retained").expect("payload");
1251        }
1252        publish(&StdVfs, root, &encode(&routes), &mut |_| Ok(())).expect("legacy publish");
1253        assert_eq!(resolve(&root.join("c")).expect("legacy resolve"), selected);
1254        let descriptor = StagedDescriptor(
1255            BTreeMap::from([(
1256                "a".into(),
1257                StagedSelection {
1258                    logical_delete: false,
1259                    manifest: ".ze-manifest-1".into(),
1260                    binding: crate::ingest::wal_payload::TransactionBinding {
1261                        transaction: 1,
1262                        participant: 2,
1263                        first_seq: 3,
1264                        last_seq: 3,
1265                        manifest_digest: 4,
1266                        final_generation: 5,
1267                    },
1268                },
1269            )]),
1270            routes,
1271        );
1272        publish(
1273            &StdVfs,
1274            root,
1275            &encode_staged(&descriptor).expect("v2"),
1276            &mut |_| Ok(()),
1277        )
1278        .expect("v2 publish");
1279        assert_eq!(resolve(&root.join("c")).expect("v2 resolve"), selected);
1280    }
1281
1282    #[test]
1283    #[allow(clippy::indexing_slicing)]
1284    fn staged_descriptor_round_trips_and_old_reader_rejects() {
1285        let binding = crate::ingest::wal_payload::TransactionBinding {
1286            transaction: 1,
1287            participant: 2,
1288            first_seq: 3,
1289            last_seq: 3,
1290            manifest_digest: 4,
1291            final_generation: 5,
1292        };
1293        let descriptor = StagedDescriptor(
1294            BTreeMap::from([(
1295                "a".into(),
1296                StagedSelection {
1297                    logical_delete: false,
1298                    manifest: ".ze-manifest-1".into(),
1299                    binding,
1300                },
1301            )]),
1302            Routes::new(),
1303        );
1304        let bytes = encode_staged(&descriptor).expect("encode");
1305        assert_eq!(
1306            bytes.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1307            "5a454e5330303032010000000100610e002e7a652d6d616e69666573742d3101000000000000000000000000000000020000000000000000000000000000000300000000000000030000000000000004000000000000000500000000000000100000005a454e5330303031a3d24bff963b33d2986d4df50a4f2e73"
1308        );
1309        for end in 0..bytes.len() {
1310            assert!(decode_staged(Path::new("root"), &bytes[..end]).is_err());
1311        }
1312        let mut bad = bytes.clone();
1313        bad[7] = b'3';
1314        assert!(decode_staged(Path::new("root"), &bad).is_err());
1315        let mut bad = bytes.clone();
1316        bad[40] ^= 1;
1317        assert!(decode_staged(Path::new("root"), &bad).is_err());
1318        assert!(body(Path::new("root"), &bytes).is_err());
1319        assert_eq!(
1320            decode_staged(Path::new("root"), &bytes).expect("decode"),
1321            descriptor
1322        );
1323        let mut logical = descriptor;
1324        logical.0.get_mut("a").expect("participant").logical_delete = true;
1325        let bytes = encode_staged(&logical).expect("logical encode");
1326        assert!(bytes.starts_with(LOGICAL_STAGED_MAGIC));
1327        assert!(body(Path::new("root"), &bytes).is_err());
1328        assert_eq!(
1329            decode_staged(Path::new("root"), &bytes).expect("logical decode"),
1330            logical
1331        );
1332    }
1333}
1334
1335// ZE-256 v2 carries staged selections and the complete existing route table.
1336// V1 readers reject the distinct magic before interpreting any route.
1337// V3 retains the V2 layout but deletes logically. Older readers must reject
1338// it rather than adopting the physical-purge obligations of V2.
1339const STAGED_MAGIC: &[u8] = b"ZENS0002";
1340const LOGICAL_STAGED_MAGIC: &[u8] = b"ZENS0003";
1341#[derive(Clone, Debug, Eq, PartialEq)]
1342struct StagedSelection {
1343    logical_delete: bool,
1344    manifest: String,
1345    binding: crate::ingest::wal_payload::TransactionBinding,
1346}
1347#[derive(Clone, Debug, Eq, PartialEq)]
1348struct StagedDescriptor(BTreeMap<String, StagedSelection>, Routes);
1349
1350fn encode_staged(descriptor: &StagedDescriptor) -> Result<Vec<u8>, StoreError> {
1351    let path = Path::new(RECORD);
1352    let logical_delete = descriptor
1353        .0
1354        .values()
1355        .next()
1356        .is_some_and(|s| s.logical_delete);
1357    let mut bytes = if logical_delete {
1358        LOGICAL_STAGED_MAGIC
1359    } else {
1360        STAGED_MAGIC
1361    }
1362    .to_vec();
1363    let count =
1364        u32::try_from(descriptor.0.len()).map_err(|_| invalid(path, "participant count"))?;
1365    if count == 0 {
1366        return Err(invalid(path, "empty staged decision"));
1367    }
1368    bytes.extend_from_slice(&count.to_le_bytes());
1369    let mut transaction = None;
1370    let mut identities = std::collections::BTreeSet::new();
1371    for (name, selection) in &descriptor.0 {
1372        if selection.logical_delete != logical_delete
1373            || !name_valid(name)
1374            || !selection.manifest.starts_with(".ze-manifest-")
1375            || !name_valid(selection.manifest.trim_start_matches('.'))
1376            || selection.manifest.contains("..")
1377            || transaction.is_some_and(|id| id != selection.binding.transaction)
1378            || !identities.insert(selection.binding.participant)
1379        {
1380            return Err(invalid(path, "staged participant identity or manifest"));
1381        }
1382        transaction = Some(selection.binding.transaction);
1383        for text in [name, &selection.manifest] {
1384            let length =
1385                u16::try_from(text.len()).map_err(|_| invalid(path, "selection length"))?;
1386            bytes.extend_from_slice(&length.to_le_bytes());
1387            bytes.extend_from_slice(text.as_bytes());
1388        }
1389        bytes.extend_from_slice(
1390            &selection
1391                .binding
1392                .encode()
1393                .map_err(|_| invalid(path, "transaction binding"))?,
1394        );
1395    }
1396    let routes = encode(&descriptor.1);
1397    let length = u32::try_from(routes.len()).map_err(|_| invalid(path, "route table length"))?;
1398    bytes.extend_from_slice(&length.to_le_bytes());
1399    bytes.extend_from_slice(&routes);
1400    bytes.extend_from_slice(&xxh3_64(&bytes).to_le_bytes());
1401    if bytes.len() > MAX_RECORD {
1402        return Err(invalid(path, "staged decision too large"));
1403    }
1404    Ok(bytes)
1405}
1406fn decode_staged(path: &Path, bytes: &[u8]) -> Result<StagedDescriptor, StoreError> {
1407    let end = bytes
1408        .len()
1409        .checked_sub(8)
1410        .ok_or_else(|| invalid(path, "short staged decision"))?;
1411    let prefix = bytes
1412        .get(..end)
1413        .ok_or_else(|| invalid(path, "staged bounds"))?;
1414    if bytes.len() > MAX_RECORD
1415        || !(prefix.starts_with(STAGED_MAGIC) || prefix.starts_with(LOGICAL_STAGED_MAGIC))
1416        || bytes.get(end..) != Some(xxh3_64(prefix).to_le_bytes().as_slice())
1417    {
1418        return Err(invalid(path, "staged checksum/version"));
1419    }
1420    let mut remaining = prefix
1421        .get(8..)
1422        .ok_or_else(|| invalid(path, "staged header"))?;
1423    fn take<'a>(path: &Path, bytes: &mut &'a [u8], n: usize) -> Result<&'a [u8], StoreError> {
1424        let result = bytes
1425            .get(..n)
1426            .ok_or_else(|| invalid(path, "truncated staged decision"))?;
1427        *bytes = bytes
1428            .get(n..)
1429            .ok_or_else(|| invalid(path, "staged bounds"))?;
1430        Ok(result)
1431    }
1432    let count = u32::from_le_bytes(
1433        take(path, &mut remaining, 4)?
1434            .try_into()
1435            .map_err(|_| invalid(path, "count"))?,
1436    );
1437    let mut result = BTreeMap::new();
1438    for _ in 0..count {
1439        let mut texts = Vec::new();
1440        for _ in 0..2 {
1441            let n = u16::from_le_bytes(
1442                take(path, &mut remaining, 2)?
1443                    .try_into()
1444                    .map_err(|_| invalid(path, "length"))?,
1445            );
1446            texts.push(
1447                std::str::from_utf8(take(path, &mut remaining, usize::from(n))?)
1448                    .map_err(|_| invalid(path, "selection UTF-8"))?
1449                    .to_owned(),
1450            );
1451        }
1452        let mut texts = texts.into_iter();
1453        let name = texts.next().ok_or_else(|| invalid(path, "name"))?;
1454        let manifest = texts.next().ok_or_else(|| invalid(path, "manifest"))?;
1455        let binding =
1456            crate::ingest::wal_payload::TransactionBinding::decode(take(path, &mut remaining, 64)?)
1457                .map_err(|_| invalid(path, "transaction binding"))?;
1458        if result
1459            .insert(
1460                name,
1461                StagedSelection {
1462                    manifest,
1463                    binding,
1464                    logical_delete: prefix.starts_with(LOGICAL_STAGED_MAGIC),
1465                },
1466            )
1467            .is_some()
1468        {
1469            return Err(invalid(path, "duplicate participant"));
1470        }
1471    }
1472    let route_length = u32::from_le_bytes(
1473        take(path, &mut remaining, 4)?
1474            .try_into()
1475            .map_err(|_| invalid(path, "route table length"))?,
1476    );
1477    let route_length =
1478        usize::try_from(route_length).map_err(|_| invalid(path, "route table length"))?;
1479    let routes = decode_routes(path, take(path, &mut remaining, route_length)?)?;
1480    let descriptor = StagedDescriptor(result, routes);
1481    if !remaining.is_empty() || encode_staged(&descriptor)? != bytes {
1482        return Err(invalid(path, "noncanonical staged decision"));
1483    }
1484    Ok(descriptor)
1485}
1486
1487fn read_optional_vfs(vfs: &dyn Vfs, path: &Path) -> Result<Option<Vec<u8>>, StoreError> {
1488    match vfs.open(path) {
1489        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1490        Err(e) => Err(io(path, e)),
1491        Ok(n) if n > MAX_RECORD as u64 => Err(invalid(path, "namespace record too large")),
1492        Ok(_) => vfs.read(path).map(Some).map_err(|e| io(path, e)),
1493    }
1494}
1495
1496fn location(directory: &Path) -> Result<(&Path, &str), StoreError> {
1497    let name = directory
1498        .file_name()
1499        .and_then(|name| name.to_str())
1500        .ok_or_else(|| invalid(directory, "participant name"))?;
1501    let parent = directory
1502        .parent()
1503        .ok_or_else(|| invalid(directory, "participant root"))?;
1504    let root = if parent
1505        .file_name()
1506        .and_then(|name| name.to_str())
1507        .is_some_and(|name| name.starts_with(".ze-batch-"))
1508    {
1509        parent
1510            .parent()
1511            .ok_or_else(|| invalid(directory, "route root"))?
1512    } else {
1513        parent
1514    };
1515    Ok((root, name))
1516}
1517fn reader_location<'a>(
1518    vfs: &dyn Vfs,
1519    directory: &'a Path,
1520    preparation: Option<&PrivatePreparation>,
1521) -> Result<(&'a Path, &'a str, Option<NamespaceRootId>), StoreError> {
1522    if let Some((root, name, id)) =
1523        portable::authority_with_preparation(vfs, directory, preparation)?
1524    {
1525        return Ok((root, name, Some(id)));
1526    }
1527    location(directory).map(|(root, name)| (root, name, None))
1528}
1529fn reader_participant_identity(
1530    vfs: &dyn Vfs,
1531    directory: &Path,
1532    preparation: Option<&PrivatePreparation>,
1533) -> Result<u128, StoreError> {
1534    let (root, name, id) = reader_location(vfs, directory, preparation)?;
1535    match id {
1536        Some(id) => portable::participant_id(id, name),
1537        None => participant_identity(root, name),
1538    }
1539}
1540fn acceptance_path(
1541    directory: &Path,
1542    binding: crate::ingest::wal_payload::TransactionBinding,
1543) -> PathBuf {
1544    directory.join(format!(".ze-accepted-{}", binding.transaction))
1545}
1546fn accepted(
1547    vfs: &dyn Vfs,
1548    directory: &Path,
1549    binding: crate::ingest::wal_payload::TransactionBinding,
1550) -> Result<bool, StoreError> {
1551    let path = acceptance_path(directory, binding);
1552    let Some(bytes) = read_optional_vfs(vfs, &path)? else {
1553        return Ok(false);
1554    };
1555    let recorded = crate::ingest::wal_payload::TransactionBinding::decode(body(&path, &bytes)?)
1556        .map_err(|e| invalid(&path, &e.to_string()))?;
1557    if recorded != binding {
1558        return Err(invalid(&path, "local acceptance binding mismatch"));
1559    }
1560    Ok(true)
1561}
1562// The committed root authorizes purges before local acceptance is written.
1563// Local acceptance retains that authority after root retirement.
1564pub(crate) fn owns_purge_obligation(
1565    vfs: &dyn Vfs,
1566    directory: &Path,
1567    token: u64,
1568    preparation: Option<&PrivatePreparation>,
1569) -> Result<bool, StoreError> {
1570    for path in vfs.list(directory).map_err(|e| io(directory, e))? {
1571        let Some(transaction) = path
1572            .file_name()
1573            .and_then(|name| name.to_str())
1574            .and_then(|name| name.strip_prefix(".ze-accepted-"))
1575            .and_then(|name| name.parse::<u128>().ok())
1576        else {
1577            continue;
1578        };
1579        if xxh3_64(&transaction.to_le_bytes()) != token {
1580            continue;
1581        }
1582        let bytes = vfs.read(&path).map_err(|e| io(&path, e))?;
1583        let binding = crate::ingest::wal_payload::TransactionBinding::decode(body(&path, &bytes)?)
1584            .map_err(|e| invalid(&path, &e.to_string()))?;
1585        if binding.transaction != transaction {
1586            return Err(invalid(&path, "purge acceptance transaction mismatch"));
1587        }
1588        return Ok(true);
1589    }
1590    if preparation.is_none()
1591        && let Some(selected) = selection(vfs, directory)?
1592        && xxh3_64(&selected.binding.transaction.to_le_bytes()) == token
1593    {
1594        staged_manifest(vfs, directory, &selected)?;
1595        return Ok(true);
1596    }
1597    Ok(false)
1598}
1599
1600fn selection(vfs: &dyn Vfs, directory: &Path) -> Result<Option<StagedSelection>, StoreError> {
1601    let (root, name, _) = reader_location(vfs, directory, None)?;
1602    let RootDescriptor::Staged(mut descriptor) = root_descriptor_vfs(vfs, root)? else {
1603        return Ok(None);
1604    };
1605    let Some(selected) = descriptor.0.remove(name) else {
1606        return Ok(None);
1607    };
1608    let identity = reader_participant_identity(vfs, directory, None)?;
1609    if selected.binding.participant != identity {
1610        return Err(invalid(directory, "participant identity mismatch"));
1611    }
1612    Ok(Some(selected))
1613}
1614fn selected_manifest(
1615    vfs: &dyn Vfs,
1616    directory: &Path,
1617    selected: &StagedSelection,
1618) -> Result<crate::manifest::Manifest, StoreError> {
1619    let path = directory.join(&selected.manifest);
1620    let bytes = vfs.read(&path).map_err(|e| io(&path, e))?;
1621    if xxh3_64(&bytes) != selected.binding.manifest_digest {
1622        return Err(invalid(&path, "staged manifest digest mismatch"));
1623    }
1624    let manifest = crate::manifest::decode_manifest(&path.display().to_string(), &bytes)
1625        .map_err(StoreError::Manifest)?;
1626    if manifest.generation != selected.binding.final_generation {
1627        return Err(invalid(&path, "staged generation mismatch"));
1628    }
1629    Ok(manifest)
1630}
1631
1632fn staged_manifest(
1633    vfs: &dyn Vfs,
1634    directory: &Path,
1635    selected: &StagedSelection,
1636) -> Result<crate::manifest::Manifest, StoreError> {
1637    let manifest = selected_manifest(vfs, directory, selected)?;
1638    let path = directory.join(&selected.manifest);
1639    let wal = crate::wal::WalReader::open(vfs, &directory.join("wal.ze"))
1640        .map_err(StoreError::Wal)?
1641        .into_clean()
1642        .map_err(StoreError::WalRecovery)?;
1643    // Exact complete gated range is necessary before any canonical adoption.
1644    let selected_records = wal
1645        .records()
1646        .iter()
1647        .filter(|r| (selected.binding.first_seq..=selected.binding.last_seq).contains(&r.seq.get()))
1648        .collect::<Vec<_>>();
1649    let member_count = selected
1650        .binding
1651        .last_seq
1652        .checked_sub(selected.binding.first_seq)
1653        .and_then(|count| count.checked_add(1))
1654        .ok_or_else(|| invalid(&path, "invalid committed prepared range"))?;
1655    if selected_records.len() as u64 != member_count {
1656        return Err(invalid(&path, "committed prepared range incomplete"));
1657    }
1658    for (record, index) in selected_records.iter().zip(0_u32..) {
1659        let payload = record
1660            .payload()
1661            .map_err(|e| invalid(&path, &e.to_string()))?;
1662        let member = crate::ingest::wal_payload::decode_prepared(payload)
1663            .map_err(|e| invalid(&path, &e.to_string()))?;
1664        if record.op != crate::ingest::wal_payload::PREPARED_MUTATION_V1
1665            || member.binding != selected.binding
1666            || member.index != index
1667        {
1668            return Err(invalid(&path, "committed prepared binding mismatch"));
1669        }
1670    }
1671    Ok(manifest)
1672}
1673
1674pub(super) fn manifest_for_open(
1675    vfs: &dyn Vfs,
1676    directory: &Path,
1677    preparation: Option<&PrivatePreparation>,
1678) -> Result<PathBuf, StoreError> {
1679    let canonical = directory.join("manifest.ze");
1680    if vfs
1681        .open(&canonical)
1682        .is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
1683        && vfs
1684            .list(directory)
1685            .map_err(|e| io(directory, e))?
1686            .iter()
1687            .any(|path| {
1688                path.file_name()
1689                    .and_then(|name| name.to_str())
1690                    .is_some_and(|name| name.starts_with(".ze-accepted-"))
1691            })
1692    {
1693        return Err(invalid(&canonical, "missing locally accepted manifest"));
1694    }
1695    if preparation.is_some() {
1696        portable::authority_with_preparation(vfs, directory, preparation)?;
1697        return Ok(canonical);
1698    }
1699    match selection(vfs, directory)? {
1700        Some(selected) if !accepted(vfs, directory, selected.binding)? => {
1701            staged_manifest(vfs, directory, &selected)?;
1702            Ok(directory.join(selected.manifest))
1703        }
1704        _ => Ok(directory.join("manifest.ze")),
1705    }
1706}
1707fn accept(
1708    vfs: &dyn Vfs,
1709    directory: &Path,
1710    selected: &StagedSelection,
1711    manifest: &crate::manifest::Manifest,
1712    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1713) -> Result<(), StoreError> {
1714    // These frames were synced before the root commit and validated against
1715    // its binding. Derive obligations only for this committed transaction.
1716    let wal = crate::wal::WalReader::open(vfs, &directory.join("wal.ze"))
1717        .map_err(StoreError::Wal)?
1718        .into_clean()
1719        .map_err(StoreError::WalRecovery)?;
1720    let mut ids = Vec::new();
1721    for record in wal.records().iter().filter(|record| {
1722        (selected.binding.first_seq..=selected.binding.last_seq).contains(&record.seq.get())
1723    }) {
1724        let payload = record
1725            .payload()
1726            .map_err(|e| invalid(directory, &e.to_string()))?;
1727        let member = crate::ingest::wal_payload::decode_prepared(payload)
1728            .map_err(|e| invalid(directory, &e.to_string()))?;
1729        if member.binding != selected.binding {
1730            return Err(invalid(directory, "purge binding mismatch"));
1731        }
1732        if let crate::ingest::wal_payload::MutationPayload::Delete(deleted) = member.mutation {
1733            ids.extend(deleted);
1734        }
1735    }
1736    ids.sort_unstable();
1737    ids.dedup();
1738    if !selected.logical_delete {
1739        crate::ingest::namespace_purge_intent(vfs, directory, selected.binding.transaction, ids)
1740            .map_err(|e| StoreError::PurgeRecovery {
1741                detail: e.to_string(),
1742            })?;
1743        step("purge obligation adopted").map_err(|e| io(directory, e))?;
1744    }
1745    let bytes = crate::manifest::encode_manifest(manifest).map_err(StoreError::Manifest)?;
1746    let temporary = directory.join(".manifest.ze.tmp");
1747    durable_write(vfs, &temporary, &bytes, step)?;
1748    vfs.rename(&temporary, &directory.join("manifest.ze"))
1749        .map_err(|e| io(directory, e))?;
1750    step("accept manifest rename").map_err(|e| io(directory, e))?;
1751    sync_dir(vfs, directory, step)?;
1752    let binding = selected
1753        .binding
1754        .encode()
1755        .map_err(|e| invalid(directory, &e.to_string()))?;
1756    let committed = acceptance_path(directory, selected.binding);
1757    let temporary = committed.with_extension("tmp");
1758    durable_write(vfs, &temporary, &envelope(&binding), step)?;
1759    vfs.rename(&temporary, &committed)
1760        .map_err(|e| io(&committed, e))?;
1761    step("accept binding rename").map_err(|e| io(&committed, e))?;
1762    sync_dir(vfs, directory, step)
1763}
1764
1765pub(super) fn adopt_for_open(vfs: &dyn Vfs, directory: &Path) -> Result<(), StoreError> {
1766    if let Some(selected) = selection(vfs, directory)? {
1767        let (root, _, _) = reader_location(vfs, directory, None)?;
1768        // A surviving rename is not proof that its directory entry was synced.
1769        // Persist both authorities before admitting even a derived-mode writer.
1770        vfs.sync(root, SyncKind::Full).map_err(|e| io(root, e))?;
1771        if accepted(vfs, directory, selected.binding)? {
1772            vfs.sync(directory, SyncKind::Full)
1773                .map_err(|e| io(directory, e))?;
1774        } else {
1775            let manifest = staged_manifest(vfs, directory, &selected)?;
1776            accept(vfs, directory, &selected, &manifest, &mut |_| Ok(()))?;
1777        }
1778    }
1779    Ok(())
1780}
1781fn normalize_accepted(
1782    vfs: &dyn Vfs,
1783    root: &Path,
1784    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1785) -> Result<(), StoreError> {
1786    let (root_id, descriptor) = root_record_vfs(vfs, root)?;
1787    let RootDescriptor::Staged(descriptor) = descriptor else {
1788        return Ok(());
1789    };
1790    for (name, selected) in &descriptor.0 {
1791        let directory = root.join(descriptor.1.get(name).map_or(name.as_str(), String::as_str));
1792        if !accepted(vfs, &directory, selected.binding)? {
1793            return Err(StoreError::StoreBusy { path: directory });
1794        }
1795        sync_dir(vfs, &directory, step)?;
1796    }
1797    publish(
1798        vfs,
1799        root,
1800        &encode_root_record(root_id, &RootDescriptor::Legacy(descriptor.1))?,
1801        step,
1802    )
1803}
1804
1805pub(crate) fn transaction_decisions(
1806    vfs: &dyn Vfs,
1807    directory: &Path,
1808    records: &[crate::wal::VisibleRecord],
1809    absorbed: u64,
1810    preparation: Option<&PrivatePreparation>,
1811) -> Result<BTreeMap<u128, crate::ingest::wal_payload::TransactionBinding>, StoreError> {
1812    let mut decisions = BTreeMap::new();
1813    let mut checked = std::collections::BTreeSet::new();
1814    for record in records.iter().filter(|r| {
1815        r.seq.get() > absorbed && r.op == crate::ingest::wal_payload::PREPARED_MUTATION_V1
1816    }) {
1817        let payload = record.payload().map_err(|source| StoreError::WalRecord {
1818            seq: record.seq,
1819            source,
1820        })?;
1821        let member = crate::ingest::wal_payload::decode_prepared(payload).map_err(|source| {
1822            StoreError::WalMutation {
1823                seq: record.seq,
1824                op: record.op,
1825                source,
1826            }
1827        })?;
1828        if !checked.insert(member.binding.transaction) {
1829            continue;
1830        }
1831        let identity = reader_participant_identity(vfs, directory, preparation)?;
1832        if member.binding.participant != identity {
1833            return Err(StoreError::WalMutation {
1834                seq: record.seq,
1835                op: record.op,
1836                source: crate::ingest::wal_payload::PayloadError::TransactionBinding,
1837            });
1838        }
1839        if accepted(vfs, directory, member.binding)? {
1840            decisions.insert(member.binding.transaction, member.binding);
1841        } else if preparation.is_none()
1842            && let Some(selected) = selection(vfs, directory)?
1843            && selected.binding.transaction == member.binding.transaction
1844        {
1845            if selected.binding != member.binding {
1846                return Err(invalid(directory, "root prepared binding mismatch"));
1847            }
1848            staged_manifest(vfs, directory, &selected)?;
1849            decisions.insert(member.binding.transaction, selected.binding);
1850        }
1851    }
1852    Ok(decisions)
1853}
1854
1855fn participant_identity(root: &Path, name: &str) -> Result<u128, StoreError> {
1856    let canonical = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
1857    Ok(xxhash_rust::xxh3::xxh3_128(
1858        format!("{}\t{name}", canonical.display()).as_bytes(),
1859    ))
1860}
1861
1862fn rollback_stages(
1863    ordered: &[&LiveNamespaceMutation<'_>],
1864    staged: Vec<crate::ingest::NamespaceStage>,
1865    selections: &[Option<StagedSelection>],
1866    lengths: &[u64],
1867    wals: &mut [std::sync::MutexGuard<'_, Option<crate::ingest::StoreWal>>],
1868) -> Result<(), StoreError> {
1869    let result = (|| {
1870        for ((((p, stage), selection), length), wal) in ordered
1871            .iter()
1872            .zip(staged)
1873            .zip(selections)
1874            .zip(lengths)
1875            .zip(wals.iter_mut())
1876        {
1877            wal.as_mut()
1878                .ok_or(StoreError::ReadOnly)?
1879                .abort_namespace_suffix(
1880                    p.store.vfs.as_ref(),
1881                    &p.store.directory.join("wal.ze"),
1882                    *length,
1883                    p.store.durability_policy,
1884                    stage.manifest.log_seq,
1885                    &p.store.accounting,
1886                )?;
1887            cleanup_stage(p.store, stage, selection.as_ref())?;
1888        }
1889        Ok(())
1890    })();
1891    if result.is_err() {
1892        for wal in wals {
1893            **wal = None;
1894        }
1895    }
1896    result
1897}
1898
1899/// Reclaims unreachable namespace transaction artifacts.
1900#[cfg(any(test, feature = "test-seams"))]
1901#[doc(hidden)]
1902pub fn namespace_reclaim(root: &Path) -> Result<(), StoreError> {
1903    let root = std::fs::canonicalize(root).map_err(|e| io(root, e))?;
1904    let _owner = StoreLock::acquire(&root).map_err(StoreError::Lock)?;
1905    reclamation::run(&StdVfs, &root, &mut |_| Ok(()))
1906}
1907
1908#[cfg(test)]
1909mod portable_tests;
1910mod reclamation;
1911pub(super) use reclamation::{
1912    admission as reader_admission, for_open as reclaim_for_open, lease as reader_lease,
1913    refuse as refuse_retired,
1914};
1915
1916#[cfg(any(test, feature = "test-seams"))]
1917#[doc(hidden)]
1918pub fn namespace_reclaim_on_vfs(
1919    root: &Path,
1920    vfs: &dyn Vfs,
1921    step: &mut dyn FnMut(&str) -> std::io::Result<()>,
1922) -> Result<(), StoreError> {
1923    let _owner = StoreLock::acquire(root).map_err(StoreError::Lock)?;
1924    reclamation::run(vfs, root, step)
1925}