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