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