Skip to main content

vole_document/field/
mod.rs

1//! The persistent procedural document field (Phase 11).
2//!
3//! A [`Field`] is the queryable, content-addressed procedural substrate behind a
4//! document. It is opened from a [`FieldStore`] by a [`FieldId`] and can:
5//!
6//! * materialize the exact original source bytes ([`Field::materialize_exact`]),
7//!   exactly as the standalone `.voldoc` form does, and
8//! * serve derived/structured observations from the persisted seed DAG without
9//!   reconstructing the whole document.
10//!
11//! The exact archival authority is the serialized `.voldoc` descriptor, stored as
12//! one blob; the seed DAG and the hierarchical index are additional persisted
13//! state. A field never weakens `materialize(root) == original_bytes` (ADR-0024).
14
15pub mod build;
16pub mod cache;
17pub mod capabilities;
18pub mod dag;
19pub mod derive;
20pub mod document_format;
21pub mod edit;
22pub mod explain;
23pub mod external;
24pub mod index;
25/// Phase 16.1: the single shipped DEFLATE inflate seam (`zlib-rs`). One backend
26/// serves both PDF zlib (RFC 1950) and ZIP raw DEFLATE (RFC 1951) members.
27pub(crate) mod inflate;
28pub mod ingest;
29#[cfg(feature = "package")]
30pub mod ingest_package;
31pub mod manifest;
32pub mod node;
33pub mod observe;
34#[cfg(feature = "opc")]
35pub mod opc;
36pub mod partial;
37pub mod plan;
38pub(crate) mod prof;
39pub mod promote;
40pub mod provenance;
41pub mod resource;
42pub mod session;
43pub mod share;
44
45pub use manifest::{FieldId, FieldRoot};
46
47/// Whether the env-gated warm-session open profiler is enabled. This is
48/// measurement scaffolding only: it never changes an observation, a byte, or a
49/// decision, and the default path pays a single env lookup per open.
50pub(crate) fn warm_prof_enabled() -> bool {
51    std::env::var_os("VOLE_PROFILE_OPEN").is_some()
52}
53
54use std::fs;
55use std::path::{Path, PathBuf};
56use std::rc::Rc;
57
58#[cfg(feature = "entropyfs-store")]
59use std::sync::Arc;
60
61#[cfg(feature = "entropyfs-store")]
62use entropyfs::engine::BlobId;
63
64use crate::container::ParsedDescriptor;
65use crate::error::{Error, Result};
66use crate::limits::Limits;
67#[cfg(feature = "entropyfs-store")]
68use crate::store::{EntropyFsStore, map_engine_error};
69use crate::store::{
70    FsSeedStore, Id, IoCounters, IoSnapshot, NodeId, PackedSeedStore, SeedStore, SyncPolicy,
71};
72
73#[cfg(feature = "entropyfs-store")]
74use self::manifest::FIELD_ROOT_DOMAIN;
75use self::node::{NodeKind, SeedNode};
76
77/// The canonical universe string for a Phase-11 field.
78pub const FIELD_UNIVERSE: &str = "vole-document;universe;phase11;exact-bytes;dra-8;opaque+entropy+pdf+channels+offsets+packed+packed-channels+deflate-replay-preflate-0.7.6-experimental+observation-index-v1+seek-directory-v1+external-objects-v1+procedural-seed-field-v1+hier-index-v1";
79
80/// The canonical universe string for a Phase-12 **package** (ZIP/OCF/OPC) field.
81///
82/// It is the Phase-11 universe plus an explicit `package-v1` marker. The marker is
83/// recorded in the manifest's `universe_id`; it is **not** a new wire record and
84/// does not change the exact `.voldoc` descriptor, which remains the ordinary
85/// exact form.
86#[cfg(feature = "package")]
87pub const PACKAGE_UNIVERSE: &str = "vole-document;universe;phase11;exact-bytes;dra-8;opaque+entropy+pdf+channels+offsets+packed+packed-channels+deflate-replay-preflate-0.7.6-experimental+observation-index-v1+seek-directory-v1+external-objects-v1+procedural-seed-field-v1+hier-index-v1+package-v1";
88
89/// A cheaply cloneable handle to a field's seed substrate.
90///
91/// Cloning shares the *same* underlying substrate and I/O counters, so an
92/// observation can mint a detached seed handle without borrowing the
93/// [`FieldStore`] that owns it (the evaluation core holds `&mut FieldStore`).
94/// Every variant owns a reference-counted engine or a path — never a second lock:
95/// an `entropyfs-store` field uses exactly **one** EntropyFS engine for its
96/// descriptors, manifests, and seed nodes, so there is no per-observation reopen.
97#[derive(Clone)]
98pub(crate) enum SeedSubstrate {
99    /// Plain files under `<root>/seed` ([`FsSeedStore`]).
100    Fs { root: PathBuf, io: IoCounters },
101    /// Packed segments under `<root>/fieldpack` ([`PackedSeedStore`]).
102    Packed { store: Rc<PackedSeedStore> },
103    /// One engine blob per node, through the store's shared EntropyFS engine.
104    #[cfg(feature = "entropyfs-store")]
105    EntropyFs {
106        store: Arc<EntropyFsStore>,
107        io: IoCounters,
108    },
109}
110
111impl SeedStore for SeedSubstrate {
112    fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
113        match self {
114            SeedSubstrate::Fs { root, io } => {
115                FsSeedStore::open_with_io(root, io.handle())?.put_node(canonical)
116            }
117            SeedSubstrate::Packed { store } => store.insert(canonical),
118            #[cfg(feature = "entropyfs-store")]
119            SeedSubstrate::EntropyFs { store, .. } => store.seed_put(canonical),
120        }
121    }
122
123    fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
124        match self {
125            SeedSubstrate::Fs { root, io } => {
126                FsSeedStore::open_with_io(root, io.handle())?.get_node(id)
127            }
128            SeedSubstrate::Packed { store } => store.fetch(id),
129            #[cfg(feature = "entropyfs-store")]
130            SeedSubstrate::EntropyFs { store, io } => {
131                let bytes = store.seed_get(id)?;
132                io.add_seed(bytes.len() as u64);
133                Ok(bytes)
134            }
135        }
136    }
137
138    fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
139        match self {
140            SeedSubstrate::Fs { root, io } => {
141                FsSeedStore::open_with_io(root, io.handle())?.get_node_range(id, offset, len)
142            }
143            SeedSubstrate::Packed { store } => store.fetch_range(id, offset, len),
144            #[cfg(feature = "entropyfs-store")]
145            SeedSubstrate::EntropyFs { store, io } => {
146                let bytes = store.seed_get_range(id, offset, len)?;
147                io.add_seed(bytes.len() as u64);
148                Ok(bytes)
149            }
150        }
151    }
152
153    fn contains_node(&self, id: &NodeId) -> Result<bool> {
154        match self {
155            SeedSubstrate::Fs { root, io } => {
156                FsSeedStore::open_with_io(root, io.handle())?.contains_node(id)
157            }
158            SeedSubstrate::Packed { store } => store.has(id),
159            #[cfg(feature = "entropyfs-store")]
160            SeedSubstrate::EntropyFs { store, .. } => store.seed_contains(id),
161        }
162    }
163
164    fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
165        match self {
166            SeedSubstrate::Fs { root, io } => {
167                FsSeedStore::open_with_io(root, io.handle())?.list_nodes()
168            }
169            SeedSubstrate::Packed { store } => store.entries(),
170            #[cfg(feature = "entropyfs-store")]
171            SeedSubstrate::EntropyFs { store, .. } => store.seed_list(),
172        }
173    }
174}
175
176/// Where a field's descriptor and manifest blobs live.
177///
178/// The `FieldStore` API never leaks which backend is in use: both store and fetch
179/// by content id (`Id` for descriptors, `FieldId` for manifests). Only [`stats`]
180/// distinguish them.
181///
182/// [`stats`]: crate::field::observe::ObserveStats
183enum BlobBackend {
184    /// Plain files: `descriptor/` and `field/` under the store root.
185    Fs,
186    /// One engine: a descriptor is stored raw (so the engine's `BlobId` equals the
187    /// descriptor `Id`), a manifest is stored domain-prefixed with
188    /// [`FIELD_ROOT_DOMAIN`] (so the engine's `BlobId` equals the manifest
189    /// `FieldId`). Both namespaces are content-addressed and cannot collide with a
190    /// seed node, whose bytes carry a different domain prefix.
191    #[cfg(feature = "entropyfs-store")]
192    EntropyFs(Arc<EntropyFsStore>),
193}
194
195impl BlobBackend {
196    fn descriptor_put(&self, root: &Path, bytes: &[u8]) -> Result<Id> {
197        match self {
198            BlobBackend::Fs => {
199                let id = Id::of(bytes);
200                let path = root.join("descriptor").join(id.to_hex());
201                if !path.exists() {
202                    write_atomic(&path, bytes)?;
203                }
204                Ok(id)
205            }
206            #[cfg(feature = "entropyfs-store")]
207            BlobBackend::EntropyFs(store) => {
208                let id = Id::of(bytes);
209                let blob = store
210                    .engine()
211                    .put_blob(bytes)
212                    .map_err(|e| map_engine_error("put_blob", &e))?;
213                debug_assert_eq!(blob.as_bytes(), id.as_bytes());
214                Ok(id)
215            }
216        }
217    }
218
219    fn descriptor_get(&self, root: &Path, id: &Id) -> Result<Vec<u8>> {
220        match self {
221            BlobBackend::Fs => {
222                let path = root.join("descriptor").join(id.to_hex());
223                fs::read(&path).map_err(|e| {
224                    if e.kind() == std::io::ErrorKind::NotFound {
225                        Error::missing_external_object(format!(
226                            "descriptor blob {id} is not present"
227                        ))
228                    } else {
229                        Error::io(format!("reading descriptor blob {id}: {e}"))
230                    }
231                })
232            }
233            #[cfg(feature = "entropyfs-store")]
234            BlobBackend::EntropyFs(store) => store
235                .engine()
236                .get_blob(BlobId::new(*id.as_bytes()))
237                .map_err(|e| map_engine_error("get_blob", &e)),
238        }
239    }
240
241    fn field_put(&self, root: &Path, manifest: &FieldRoot) -> Result<FieldId> {
242        let bytes = manifest.encode_canonical();
243        let id = FieldId::of_manifest(&bytes);
244        match self {
245            BlobBackend::Fs => {
246                let path = root.join("field").join(id.to_hex());
247                if !path.exists() {
248                    write_atomic(&path, &bytes)?;
249                }
250                Ok(id)
251            }
252            #[cfg(feature = "entropyfs-store")]
253            BlobBackend::EntropyFs(store) => {
254                let mut prefixed = Vec::with_capacity(FIELD_ROOT_DOMAIN.len() + bytes.len());
255                prefixed.extend_from_slice(FIELD_ROOT_DOMAIN);
256                prefixed.extend_from_slice(&bytes);
257                let blob = store
258                    .engine()
259                    .put_blob(&prefixed)
260                    .map_err(|e| map_engine_error("put_blob", &e))?;
261                debug_assert_eq!(blob.as_bytes(), id.as_bytes());
262                Ok(id)
263            }
264        }
265    }
266
267    fn field_get(&self, root: &Path, id: &FieldId) -> Result<Vec<u8>> {
268        match self {
269            BlobBackend::Fs => {
270                let path = root.join("field").join(id.to_hex());
271                fs::read(&path).map_err(|e| {
272                    if e.kind() == std::io::ErrorKind::NotFound {
273                        Error::missing_external_object(format!("field {id} is not present"))
274                    } else {
275                        Error::io(format!("reading field {id}: {e}"))
276                    }
277                })
278            }
279            #[cfg(feature = "entropyfs-store")]
280            BlobBackend::EntropyFs(store) => {
281                let blob = store
282                    .engine()
283                    .get_blob(BlobId::new(*id.as_bytes()))
284                    .map_err(|e| map_engine_error("get_blob", &e))?;
285                let rest = blob.strip_prefix(FIELD_ROOT_DOMAIN).ok_or_else(|| {
286                    Error::integrity_mismatch(format!(
287                        "field manifest {id} is missing its domain prefix"
288                    ))
289                })?;
290                Ok(rest.to_vec())
291            }
292        }
293    }
294}
295
296/// On-disk layout of a field store. Every namespace is content-addressed.
297///
298/// ```text
299/// <root>/
300///   descriptor/<64-hex>       serialized .voldoc descriptor blobs
301///   field/<64-hex>            canonical field manifests
302///   seed/<aa>/<bb>/<64-hex>   canonical seed nodes (FsSeedStore)
303///   fieldpack/seg-N.pack      canonical seed nodes, packed (PackedSeedStore)
304///   index/<64-hex>            hierarchical index nodes (11.3)
305///   cache/                    derived observation cache (11.8)
306///   promoted/                 opt-in durable promoted intermediates (15.6)
307/// ```
308///
309/// A packed store ([`FieldStore::open_packed`]) replaces the `seed/` namespace
310/// with `fieldpack/` (append-only segments plus an immutable per-segment index)
311/// while leaving `descriptor/`, `field/`, `index/`, and `cache/` as files, so the
312/// seek-based partial lane and `FieldId` are unchanged.
313///
314/// An EntropyFS-backed store ([`FieldStore::open_entropyfs`], feature
315/// `entropyfs-store`) keeps the descriptor, manifest, and seed namespaces in
316/// `entropyfs/` (one engine blob each) and `index/`/`cache/` as files. The seed
317/// DAG is therefore persisted **one node per engine blob**, never as one coarse
318/// blob; EntropyFS sees only opaque bytes and VOLE owns node semantics (ADR-0025).
319/// Advisory engine accounting for an EntropyFS-backed field store.
320///
321/// These are EntropyFS's own numbers, not VOLE's; they are reported so a court can
322/// witness that the seed DAG is many individual engine blobs rather than one. They
323/// carry **no** decoder authority and no exactness meaning.
324#[cfg(feature = "entropyfs-store")]
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub struct EngineStoreStats {
327    /// Files in the engine blob namespace (one per descriptor / manifest / node).
328    pub blob_count: u64,
329    /// Sum of materialized logical bytes across reachable inodes.
330    pub logical_bytes: u64,
331    /// Sum of segment-file lengths (physical store bytes).
332    pub physical_used_bytes: u64,
333}
334
335pub struct FieldStore {
336    root: PathBuf,
337    backend: BlobBackend,
338    seeds: SeedSubstrate,
339    io: IoCounters,
340    /// Opt-in durable promotion policy (Phase 15.6); disabled by default.
341    promote: promote::PromotePolicy,
342    /// The shared promotion governor for this store handle (Phase 15.6).
343    governor: promote::Governor,
344}
345
346impl std::fmt::Debug for FieldStore {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        f.debug_struct("FieldStore")
349            .field("root", &self.root)
350            .finish_non_exhaustive()
351    }
352}
353
354impl FieldStore {
355    /// Create or open a field store rooted at `root` (the filesystem backend).
356    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
357        let root = root.as_ref().to_path_buf();
358        crate::store::durable::create_dir_all(&root.join("descriptor"))?;
359        crate::store::durable::create_dir_all(&root.join("field"))?;
360        crate::store::durable::create_dir_all(&root.join("index"))?;
361        crate::store::durable::create_dir_all(&root.join("cache"))?;
362        let io = IoCounters::new();
363        let seeds = SeedSubstrate::Fs {
364            root: root.clone(),
365            io: io.handle(),
366        };
367        Ok(FieldStore {
368            root,
369            backend: BlobBackend::Fs,
370            seeds,
371            io,
372            promote: promote::PromotePolicy::default(),
373            governor: promote::Governor::default(),
374        })
375    }
376
377    /// Create or open a field store whose descriptor, manifest, and seed
378    /// namespaces are served by one embedded EntropyFS engine at
379    /// `root/entropyfs` (feature `entropyfs-store`).
380    ///
381    /// The hierarchical observation index and the disposable derived cache remain
382    /// files under `<root>/index` and `<root>/cache`. Exactly one engine is opened;
383    /// the seed substrate and every observation share it by reference count, so a
384    /// field never opens a second engine over the same directory (which would
385    /// deadlock on the engine's exclusive lock).
386    #[cfg(feature = "entropyfs-store")]
387    pub fn open_entropyfs(root: impl AsRef<Path>) -> Result<Self> {
388        let root = root.as_ref().to_path_buf();
389        crate::store::durable::create_dir_all(&root.join("index"))?;
390        crate::store::durable::create_dir_all(&root.join("cache"))?;
391        let engine_root = root.join("entropyfs");
392        crate::store::durable::create_dir_all(&engine_root)?;
393        // An empty engine directory is a fresh store; anything else is an
394        // existing one. (`Engine::open` cannot open a directory with no store.)
395        let fresh = fs::read_dir(&engine_root)?.next().is_none();
396        let engine = if fresh {
397            EntropyFsStore::create(&engine_root)?
398        } else {
399            EntropyFsStore::open(&engine_root)?
400        };
401        let engine = Arc::new(engine);
402        let io = IoCounters::new();
403        let seeds = SeedSubstrate::EntropyFs {
404            store: Arc::clone(&engine),
405            io: io.handle(),
406        };
407        Ok(FieldStore {
408            root,
409            backend: BlobBackend::EntropyFs(engine),
410            seeds,
411            io,
412            promote: promote::PromotePolicy::default(),
413            governor: promote::Governor::default(),
414        })
415    }
416
417    /// Create or open a field store whose **seed namespace** is a packed segment
418    /// store (`fieldpack/`) instead of one file per node.
419    ///
420    /// The descriptor, manifest, hierarchical index, and cache namespaces remain
421    /// plain files, so the seek-based partial-descriptor lane and `FieldId` are
422    /// unchanged; only `<root>/seed` is replaced by `<root>/fieldpack`. A packed
423    /// store must therefore be opened with the matching CLI flag (`--packed`),
424    /// exactly like `--entropyfs`.
425    pub fn open_packed(root: impl AsRef<Path>) -> Result<Self> {
426        Self::open_packed_with_policy(root, SyncPolicy::default())
427    }
428
429    /// Like [`FieldStore::open_packed`], but with an explicit packed-store
430    /// durability [`SyncPolicy`]. The default is [`SyncPolicy::Batch`];
431    /// [`SyncPolicy::Each`] restores one sync per seed node. The policy is
432    /// irrelevant for the other backends and ignored by them.
433    pub fn open_packed_with_policy(root: impl AsRef<Path>, policy: SyncPolicy) -> Result<Self> {
434        let root = root.as_ref().to_path_buf();
435        crate::store::durable::create_dir_all(&root.join("descriptor"))?;
436        crate::store::durable::create_dir_all(&root.join("field"))?;
437        crate::store::durable::create_dir_all(&root.join("index"))?;
438        crate::store::durable::create_dir_all(&root.join("cache"))?;
439        // No `root/seed`; the `fieldpack/` directory is created by the writer.
440        let io = IoCounters::new();
441        let packed = Rc::new(PackedSeedStore::open_write_with_policy(
442            &root,
443            io.handle(),
444            policy,
445        )?);
446        let seeds = SeedSubstrate::Packed { store: packed };
447        Ok(FieldStore {
448            root,
449            backend: BlobBackend::Fs,
450            seeds,
451            io,
452            promote: promote::PromotePolicy::default(),
453            governor: promote::Governor::default(),
454        })
455    }
456
457    /// The physical-I/O counters shared by this store and its seed substrate.
458    ///
459    /// Every descriptor/manifest/index/seed read made through any handle derived
460    /// from this store is attributed here; an observation snapshots it before and
461    /// after to report its own physical bytes (review fix #1).
462    pub fn io(&self) -> &IoCounters {
463        &self.io
464    }
465
466    /// The store root directory.
467    pub fn root(&self) -> &Path {
468        &self.root
469    }
470
471    /// A detached, cheaply cloned seed handle that shares this store's substrate
472    /// and I/O counters (used by observations).
473    pub(crate) fn seed_substrate(&self) -> SeedSubstrate {
474        self.seeds.clone()
475    }
476
477    /// Whether the descriptor blob is a filesystem file supporting seek-based
478    /// partial reads. The EntropyFS backend stores it as an engine blob, so the
479    /// partial lane is unavailable and observations read the full descriptor.
480    pub(crate) fn supports_partial_descriptor(&self) -> bool {
481        matches!(self.backend, BlobBackend::Fs)
482    }
483
484    /// The seed store (for advanced callers and courts).
485    pub fn seeds(&self) -> &dyn SeedStore {
486        &self.seeds
487    }
488
489    /// Mutable seed store access.
490    pub fn seeds_mut(&mut self) -> &mut dyn SeedStore {
491        &mut self.seeds
492    }
493
494    /// Open the disposable derived observation cache (11.8) under this store.
495    ///
496    /// The cache is never normative: it can always be deleted and observations
497    /// remain correct by recomputation (ADR-0027).
498    pub fn cache(&self) -> Result<cache::DerivedCache> {
499        cache::DerivedCache::open(self.root.join("cache"))
500    }
501
502    /// Set the opt-in durable promotion policy (Phase 15.6). The default is
503    /// [`promote::PromotePolicy::default`] (disabled), so every existing path is
504    /// byte-identical until a caller explicitly enables it.
505    pub fn set_promote(&mut self, policy: promote::PromotePolicy) {
506        // The governor the observation cache actually reads holds its own copy of
507        // the policy; keep the two in sync or an opt-in has no effect.
508        self.governor.set_policy(policy);
509        self.promote = policy;
510    }
511
512    /// The active promotion policy (disabled by default).
513    pub fn promote_policy(&self) -> promote::PromotePolicy {
514        self.promote
515    }
516
517    /// The shared promotion governor (Phase 15.6), cloned for a governed cache.
518    pub(crate) fn governor(&self) -> promote::Governor {
519        self.governor.clone()
520    }
521
522    /// Open the durable promoted store (Phase 15.6) under this store's
523    /// `promoted/` directory. Never normative and never consulted by
524    /// `materialize --exact`.
525    pub fn promoted(&self) -> Result<promote::PromotedStore> {
526        promote::PromotedStore::open(self.root.join("promoted"))
527    }
528
529    /// Make every acknowledged write power-durable before the handle is dropped.
530    ///
531    /// The EntropyFS engine acks a `put_blob` at rename but does not barrier, so a
532    /// fresh open in another process can miss an unpublishied epoch. Calling this
533    /// after a mutation closes that gap. The filesystem backend writes atomically
534    /// (`tmp -> fsync -> rename`) and needs no barrier.
535    pub fn sync(&self) -> Result<()> {
536        match &self.backend {
537            BlobBackend::Fs => {}
538            #[cfg(feature = "entropyfs-store")]
539            BlobBackend::EntropyFs(store) => store.sync()?,
540        }
541        // A packed seed substrate keeps an open append segment; sealing it here
542        // makes every acknowledged node durable and readable by a fresh open.
543        if let SeedSubstrate::Packed { store } = &self.seeds {
544            store.seal()?;
545        }
546        Ok(())
547    }
548
549    /// Advisory engine accounting for an EntropyFS-backed store, or `None` for
550    /// the filesystem backend.
551    ///
552    /// `blob_count` is the number of files in the engine's blob namespace: every
553    /// descriptor, manifest, and seed node is exactly one blob, so a seed DAG of
554    /// `n` nodes adds `n` blobs (never one coarse blob). This is a snapshot, not a
555    /// claim that the engine understands procedural state (ADR-0025).
556    #[cfg(feature = "entropyfs-store")]
557    pub fn engine_stats(&self) -> Result<Option<EngineStoreStats>> {
558        match &self.backend {
559            BlobBackend::Fs => Ok(None),
560            BlobBackend::EntropyFs(store) => {
561                let m = store
562                    .engine()
563                    .metrics()
564                    .map_err(|e| map_engine_error("metrics", &e))?;
565                Ok(Some(EngineStoreStats {
566                    blob_count: m.accounting.blob_count,
567                    logical_bytes: m.accounting.logical_bytes,
568                    physical_used_bytes: m.accounting.physical_used_bytes,
569                }))
570            }
571        }
572    }
573
574    /// The filesystem path of a descriptor blob, or `None` when the backend keeps
575    /// it as an engine blob (in which case the partial lane is unavailable).
576    pub(crate) fn descriptor_path(&self, id: &Id) -> Option<PathBuf> {
577        match self.backend {
578            BlobBackend::Fs => Some(self.root.join("descriptor").join(id.to_hex())),
579            #[cfg(feature = "entropyfs-store")]
580            BlobBackend::EntropyFs(_) => None,
581        }
582    }
583
584    /// Store a serialized `.voldoc` descriptor as a content-addressed blob.
585    ///
586    /// Returns the blob's [`Id`] (`BLAKE3-256(bytes)`), which is what the field
587    /// manifest binds. Idempotent.
588    pub fn put_descriptor(&mut self, bytes: &[u8]) -> Result<Id> {
589        self.backend.descriptor_put(&self.root, bytes)
590    }
591
592    /// Fetch a descriptor blob, verifying its content id.
593    pub fn get_descriptor(&self, id: &Id) -> Result<Vec<u8>> {
594        let bytes = self.backend.descriptor_get(&self.root, id)?;
595        let actual = Id::of(&bytes);
596        if actual != *id {
597            return Err(Error::integrity_mismatch(format!(
598                "descriptor blob {id} hashes to {actual}"
599            )));
600        }
601        self.io.add_descriptor(bytes.len() as u64);
602        Ok(bytes)
603    }
604
605    /// Store a canonical field manifest.
606    ///
607    /// Durability ordering: a published manifest must never reference seed bytes
608    /// that are not yet on stable storage. The packed substrate batches its
609    /// per-node syncs ([`SyncPolicy::Batch`]), so barrier the open segment before
610    /// publishing. This is a no-op for the filesystem and EntropyFS substrates
611    /// (which make each node durable in `put_node`).
612    pub fn put_field(&mut self, manifest: &FieldRoot) -> Result<FieldId> {
613        if let SeedSubstrate::Packed { store } = &self.seeds {
614            #[cfg(feature = "fault-inject")]
615            crate::fault::hit("manifest.before_flush");
616            store.flush()?;
617            #[cfg(feature = "fault-inject")]
618            crate::fault::hit("manifest.after_flush");
619        }
620        let id = self.backend.field_put(&self.root, manifest)?;
621        #[cfg(feature = "fault-inject")]
622        crate::fault::hit("manifest.after_publish");
623        Ok(id)
624    }
625
626    /// Fetch a field manifest, verifying its content id and universe.
627    pub fn get_field(&self, id: &FieldId) -> Result<FieldRoot> {
628        let bytes = self.backend.field_get(&self.root, id)?;
629        let manifest = FieldRoot::decode_canonical(&bytes)?;
630        if manifest.content_id() != *id {
631            return Err(Error::integrity_mismatch(format!(
632                "field {id} manifest content id mismatch"
633            )));
634        }
635        self.io.add_manifest(bytes.len() as u64);
636        Ok(manifest)
637    }
638
639    /// List every stored field manifest id.
640    ///
641    /// The filesystem backend reads the `field/` directory. The EntropyFS backend
642    /// **declines** with `UnsupportedFeature`: the engine exposes no per-blob
643    /// enumeration, so a manifest cannot be listed — but any manifest remains
644    /// openable by its `FieldId` (`get_field`).
645    pub fn list_fields(&self) -> Result<Vec<FieldId>> {
646        if !matches!(self.backend, BlobBackend::Fs) {
647            return Err(Error::unsupported_feature(
648                "EntropyFS exposes no per-blob enumeration; a field store backed by it \
649                 cannot list its manifests (open a field by its FieldId instead)",
650            ));
651        }
652        let dir = self.root.join("field");
653        let mut out = Vec::new();
654        for entry in fs::read_dir(&dir)?.flatten() {
655            if let Some(name) = entry.file_name().to_str()
656                && let Ok(id) = FieldId::from_hex(name)
657            {
658                out.push(id);
659            }
660        }
661        out.sort_unstable();
662        Ok(out)
663    }
664
665    /// The external-context sidecar path for a field: `<root>/external/<hex>`.
666    ///
667    /// This namespace is deliberately disjoint from `descriptor/`, `field/`,
668    /// `index/`, `cache/`, `seed/`/`fieldpack/`, and `promoted/`: an external
669    /// record can be added or removed without touching any document-derived
670    /// state, and no document-derived path ever reads it.
671    pub(crate) fn external_context_path(&self, id: &FieldId) -> PathBuf {
672        self.root.join("external").join(id.to_hex())
673    }
674
675    /// Attach (or replace) an [`external::ExternalContext`] beside a field.
676    ///
677    /// The record is written atomically into the `external/` namespace. This is
678    /// **not** an exactness-path write: it never touches the descriptor, the
679    /// seed DAG, the index, the cache, or the manifest, and a field with no
680    /// context is still fully exact.
681    pub fn put_external_context(
682        &self,
683        id: &FieldId,
684        ctx: &external::ExternalContext,
685    ) -> Result<()> {
686        let bytes = ctx.encode_canonical();
687        write_atomic(&self.external_context_path(id), &bytes)
688    }
689
690    /// Read the attached [`external::ExternalContext`], or `None` when none is
691    /// attached. A malformed record fails closed rather than answering.
692    pub fn get_external_context(&self, id: &FieldId) -> Result<Option<external::ExternalContext>> {
693        match fs::read(self.external_context_path(id)) {
694            Ok(bytes) => external::ExternalContext::decode_canonical(&bytes).map(Some),
695            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
696            Err(e) => Err(e.into()),
697        }
698    }
699
700    /// Remove the attached external context. Returns whether one was present.
701    /// Removing it restores the pre-attach state exactly: the field's
702    /// document-derived observations and exact materialization are untouched.
703    pub fn clear_external_context(&self, id: &FieldId) -> Result<bool> {
704        match fs::remove_file(self.external_context_path(id)) {
705            Ok(()) => Ok(true),
706            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
707            Err(e) => Err(e.into()),
708        }
709    }
710
711    /// Ingest a serialized `.voldoc` descriptor as a new field.
712    ///
713    /// Stage A (durable exact capture): store the descriptor blob and an exact
714    /// `DocumentExact` root node, then write the manifest. Deeper Stage-B
715    /// procedural nodes are added by [`crate::field::ingest`].
716    pub fn ingest(&mut self, descriptor_bytes: &[u8], limits: Limits) -> Result<FieldId> {
717        // The descriptor must parse and materialize exactly: ingest never invents
718        // authority it cannot reproduce.
719        let parsed = crate::container::Descriptor::parse(descriptor_bytes, limits)?;
720        let source = crate::materialize::materialize(&parsed, limits)?;
721        self.ingest_parsed(descriptor_bytes, &parsed, &source)
722    }
723
724    /// Ingest a descriptor, verifying it materializes to a caller-supplied
725    /// `source`.
726    ///
727    /// Identical to [`FieldStore::ingest`] except the materialized source is
728    /// byte-compared to `source` instead of being dropped. A direct build
729    /// (`field-build`) already holds the exact source and proved
730    /// `materialize(descriptor) == source` in the encode court; passing the
731    /// original bytes here keeps that one non-negotiable check (length + byte
732    /// equality, hence SHA-256) while letting the caller scan the source rather
733    /// than a freshly materialized copy. The authority stored is unchanged.
734    pub fn ingest_verified(
735        &mut self,
736        descriptor_bytes: &[u8],
737        source: &[u8],
738        limits: Limits,
739    ) -> Result<FieldId> {
740        let mut parsed = crate::container::Descriptor::parse(descriptor_bytes, limits)?;
741        let materialized = crate::materialize::materialize_in_place(&mut parsed, limits)?;
742        if materialized.len() != source.len() || materialized != source {
743            return Err(Error::reconstruction_mismatch(format!(
744                "descriptor materialized {} bytes that differ from the supplied {} byte source",
745                materialized.len(),
746                source.len()
747            )));
748        }
749        self.ingest_parsed(descriptor_bytes, &parsed, source)
750    }
751
752    /// Shared tail of [`FieldStore::ingest`] / [`FieldStore::ingest_verified`]:
753    /// store the parsed descriptor and an exact `DocumentExact` root node, then
754    /// write the manifest. `source` must already be the exact materialization of
755    /// `parsed` (both callers establish this).
756    fn ingest_parsed(
757        &mut self,
758        descriptor_bytes: &[u8],
759        parsed: &ParsedDescriptor,
760        source: &[u8],
761    ) -> Result<FieldId> {
762        let descriptor_id = self.put_descriptor(descriptor_bytes)?;
763
764        // The exact root's output is the whole source, so its identity must be a
765        // function of the source *bytes* (not of the reconstruction program): two
766        // different descriptors that reconstruct the same source must share this
767        // root, and two different sources must never share it (the shared derived
768        // cache keys on the node id alone).
769        let root = SeedNode::new(
770            NodeKind::DocumentExact,
771            source.len() as u64,
772            crate::integrity::sha256(source).to_vec(),
773            Vec::new(),
774            "field:document-exact",
775        );
776        let root_id = self.seeds.put_node(&root.encode_canonical())?;
777
778        let manifest = FieldRoot {
779            universe_id: crate::container::universe_id_from_str(FIELD_UNIVERSE),
780            source_sha256: parsed.descriptor.source_sha256,
781            source_len: parsed.descriptor.source_len,
782            descriptor_id: *descriptor_id.as_bytes(),
783            root_node: root_id,
784            index_root: manifest::ABSENT_ROOT,
785            node_count: 1,
786            index_node_count: 0,
787            provenance: format!("field:ingest;{}", parsed.descriptor.format_basis),
788        };
789        self.put_field(&manifest)
790    }
791}
792
793/// Write bytes to `path` atomically (`tmp -> fsync -> rename -> dir fsync`).
794///
795/// The final directory `fsync` (Phase 23, GAP 1) makes the rename itself durable:
796/// on strict POSIX a rename is lost across a power cut unless the containing
797/// directory is synced, so a published manifest could otherwise vanish even
798/// though its bytes were synced. [`crate::store::DirSyncPolicy::Off`] skips it.
799pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
800    let dir = path
801        .parent()
802        .ok_or_else(|| Error::internal_invariant("atomic write path has no parent"))?;
803    crate::store::durable::create_dir_all(dir)?;
804    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("blob");
805    let tmp = dir.join(format!(".{name}.tmp-{}", std::process::id()));
806    {
807        let mut f = crate::store::durable::create_file(&tmp)?;
808        crate::store::durable::write_all(&mut f, &tmp, bytes)?;
809        crate::store::durable::sync_all(&f, &tmp)?;
810    }
811    crate::store::durable::rename(&tmp, path)?;
812    crate::store::durable::sync_dir(dir)?;
813    Ok(())
814}
815
816/// An opened field: the manifest plus its parsed exact descriptor.
817pub struct Field {
818    store_root: PathBuf,
819    manifest: FieldRoot,
820    parsed: ParsedDescriptor,
821    descriptor_bytes: Vec<u8>,
822    /// A detached seed handle sharing the store's substrate, so a field opened
823    /// from an EntropyFS-backed store materializes its seed nodes through the
824    /// same engine (never a second opener).
825    seeds: SeedSubstrate,
826    /// The physical bytes this `open` fetched to load the manifest and the
827    /// descriptor blob. An observation attributes exactly these to its own
828    /// `descriptor_bytes_read`/`manifest_bytes_read` (review fix #1/#2).
829    open_io: IoSnapshot,
830}
831
832impl std::fmt::Debug for Field {
833    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
834        f.debug_struct("Field")
835            .field("manifest", &self.manifest.content_id())
836            .field("source_len", &self.manifest.source_len)
837            .finish_non_exhaustive()
838    }
839}
840
841impl Field {
842    /// Open a field by id, loading and verifying its descriptor.
843    pub fn open(store: &FieldStore, id: &FieldId, limits: Limits) -> Result<Field> {
844        let prof = warm_prof_enabled();
845        let t0 = std::time::Instant::now();
846        let io_before = store.io().snapshot();
847        let manifest = store.get_field(id)?;
848        let t_manifest = t0.elapsed();
849        let field = Field::open_after_manifest(store, manifest, io_before, limits)?;
850        if prof {
851            eprintln!(
852                "[vole-profile] manifest_read_us={} field_open_total_us={}",
853                t_manifest.as_micros(),
854                t0.elapsed().as_micros()
855            );
856        }
857        Ok(field)
858    }
859
860    /// The body of [`Field::open`] from an already-read manifest. `io_before` is
861    /// the snapshot `open_io` is measured from: pass one taken before the
862    /// manifest read to charge it here (the ordinary path), or after it to charge
863    /// it elsewhere (the narrow probe, which already counted it in `base_io`).
864    pub(crate) fn open_after_manifest(
865        store: &FieldStore,
866        manifest: FieldRoot,
867        io_before: IoSnapshot,
868        limits: Limits,
869    ) -> Result<Field> {
870        let prof = warm_prof_enabled();
871        let t0 = std::time::Instant::now();
872        let descriptor_bytes = store.get_descriptor(&Id::from_bytes(manifest.descriptor_id))?;
873        let t_read = t0.elapsed();
874        let open_io = io_before.delta(&store.io().snapshot());
875        let mut parsed = crate::container::Descriptor::parse(&descriptor_bytes, limits)?;
876        if prof {
877            eprintln!(
878                "[vole-profile] descriptor_read_us={} descriptor_parse_us={} descriptor_len={}",
879                t_read.as_micros(),
880                t0.elapsed().as_micros().saturating_sub(t_read.as_micros()),
881                descriptor_bytes.len()
882            );
883        }
884        parsed.universe_id = manifest.universe_id;
885        // The manifest must agree with the descriptor it binds.
886        if parsed.descriptor.source_len != manifest.source_len
887            || parsed.descriptor.source_sha256 != manifest.source_sha256
888        {
889            return Err(Error::integrity_mismatch(
890                "field manifest does not match its descriptor's declared source",
891            ));
892        }
893        Ok(Field {
894            store_root: store.root().to_path_buf(),
895            manifest,
896            parsed,
897            descriptor_bytes,
898            seeds: store.seed_substrate(),
899            open_io,
900        })
901    }
902
903    /// The physical bytes fetched to open this field (manifest + descriptor).
904    pub(crate) fn open_io(&self) -> IoSnapshot {
905        self.open_io
906    }
907
908    /// The field manifest.
909    pub fn manifest(&self) -> &FieldRoot {
910        &self.manifest
911    }
912
913    /// The field id.
914    pub fn id(&self) -> FieldId {
915        self.manifest.content_id()
916    }
917
918    /// The parsed exact descriptor.
919    pub fn parsed(&self) -> &ParsedDescriptor {
920        &self.parsed
921    }
922
923    /// The raw serialized descriptor bytes.
924    pub fn descriptor_bytes(&self) -> &[u8] {
925        &self.descriptor_bytes
926    }
927
928    /// The store root this field was opened from.
929    pub fn store_root(&self) -> &Path {
930        &self.store_root
931    }
932
933    /// Materialize the exact original source bytes.
934    ///
935    /// This is the archival authority: it is byte-identical to the standalone
936    /// `.voldoc` materialization and is the only path that verifies the
937    /// whole-source SHA-256.
938    pub fn materialize_exact(&self, limits: Limits) -> Result<Vec<u8>> {
939        crate::materialize::materialize(&self.parsed, limits)
940    }
941
942    /// Materialize a seed node's output by id, using the field's own seed handle
943    /// (which for an EntropyFS-backed store is the shared engine, not a reopen).
944    pub fn materialize_node(
945        &self,
946        id: &NodeId,
947        limits: Limits,
948        budget: &mut dag::EvalBudget,
949    ) -> Result<Vec<u8>> {
950        let node = dag::load_node(&self.seeds, id)?;
951        dag::materialize_node(
952            &self.parsed,
953            &self.seeds,
954            &node,
955            limits,
956            budget,
957            node.limits.max_depth,
958        )
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    fn temp_root(label: &str) -> PathBuf {
967        let mut p = std::env::temp_dir();
968        p.push(format!(
969            "vole-field-{label}-{}-{}",
970            std::process::id(),
971            std::time::SystemTime::now()
972                .duration_since(std::time::UNIX_EPOCH)
973                .unwrap()
974                .as_nanos()
975        ));
976        p
977    }
978
979    fn tiny_descriptor() -> Vec<u8> {
980        use crate::container::{Descriptor, ObjectSource};
981        use crate::dra::{Op, Program};
982        let source = b"the exact field bytes";
983        let d = Descriptor {
984            universe: crate::container::UNIVERSE.to_string(),
985            source_format: crate::SOURCE_FORMAT_OPAQUE,
986            format_basis: "opaque:field-test".to_string(),
987            models: vec![],
988            channels: vec![],
989            objects: vec![ObjectSource::Inline(source.to_vec())],
990            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
991            observation_index: None,
992            seek_directory: false,
993            checkpoints: None,
994            source_sha256: crate::integrity::sha256(source),
995            source_len: source.len() as u64,
996        };
997        d.serialize().unwrap().0
998    }
999
1000    #[test]
1001    fn ingest_then_materialize_is_exact() {
1002        let root = temp_root("rt");
1003        let mut store = FieldStore::open(&root).unwrap();
1004        let bytes = tiny_descriptor();
1005        let id = store.ingest(&bytes, Limits::DEFAULT).unwrap();
1006        let field = Field::open(&store, &id, Limits::DEFAULT).unwrap();
1007        assert_eq!(
1008            field.materialize_exact(Limits::DEFAULT).unwrap(),
1009            b"the exact field bytes"
1010        );
1011        // The root node also materializes the exact source.
1012        let mut budget = dag::EvalBudget::default();
1013        let out = field
1014            .materialize_node(&field.manifest.root_node, Limits::DEFAULT, &mut budget)
1015            .unwrap();
1016        assert_eq!(out, b"the exact field bytes");
1017        fs::remove_dir_all(&root).ok();
1018    }
1019
1020    #[test]
1021    fn field_id_is_stable_and_reopenable() {
1022        let root = temp_root("reopen");
1023        let bytes = tiny_descriptor();
1024        let id1 = {
1025            let mut store = FieldStore::open(&root).unwrap();
1026            store.ingest(&bytes, Limits::DEFAULT).unwrap()
1027        };
1028        let store = FieldStore::open(&root).unwrap();
1029        let m = store.get_field(&id1).unwrap();
1030        assert_eq!(m.content_id(), id1);
1031        assert_eq!(store.list_fields().unwrap(), vec![id1]);
1032        fs::remove_dir_all(&root).ok();
1033    }
1034
1035    #[test]
1036    fn promotion_is_a_noop_by_default() {
1037        use crate::field::observe::{self, ObserveRequest, Representation, Selector};
1038        let root = temp_root("promote-off");
1039        let mut store = FieldStore::open(&root).unwrap();
1040        let id = store.ingest(&tiny_descriptor(), Limits::DEFAULT).unwrap();
1041        let req = ObserveRequest::new(
1042            Selector::ByteRange { offset: 4, len: 5 },
1043            Representation::ExactBytes,
1044        );
1045        let (a1, _, _) = observe::observe(&mut store, &id, &req, Limits::DEFAULT).unwrap();
1046        let (a2, _, _) = observe::observe(&mut store, &id, &req, Limits::DEFAULT).unwrap();
1047        assert_eq!(a1, a2);
1048        // With promotion off, the durable namespace is never even created.
1049        assert!(!root.join("promoted").exists(), "promotion must be opt-in");
1050        fs::remove_dir_all(&root).ok();
1051    }
1052
1053    #[test]
1054    fn promotion_preserves_the_answer_and_the_durable_store_serves_it() {
1055        use crate::field::observe::{self, ObserveRequest, Representation, Selector};
1056        use crate::field::promote::PromotePolicy;
1057        let root = temp_root("promote-on");
1058        let mut store = FieldStore::open(&root).unwrap();
1059        // A forcing policy: one reuse event is enough and rent is zero.
1060        store.set_promote(PromotePolicy {
1061            enabled: true,
1062            min_hits: 1,
1063            rent_per_byte: 0,
1064            ..PromotePolicy::default()
1065        });
1066        let id = store.ingest(&tiny_descriptor(), Limits::DEFAULT).unwrap();
1067        let req = ObserveRequest::new(
1068            Selector::ByteRange { offset: 4, len: 5 },
1069            Representation::ExactBytes,
1070        );
1071        let (cold, _, _) = observe::observe(&mut store, &id, &req, Limits::DEFAULT).unwrap();
1072        let (warm, _, _) = observe::observe(&mut store, &id, &req, Limits::DEFAULT).unwrap();
1073        assert_eq!(cold, warm, "promotion must not change the answer");
1074        // The second observation's reuse promoted the source-slice node durably.
1075        let promoted = store.promoted().unwrap();
1076        let ob = promoted.output_bytes().unwrap();
1077        assert!(
1078            ob > 0,
1079            "the reused node must be in the durable store (output_bytes={ob}, promoted_dir_exists={})",
1080            root.join("promoted").exists()
1081        );
1082        // Drop the disposable cache: only the durable store can serve it now.
1083        crate::field::cache::DerivedCache::open(root.join("cache"))
1084            .unwrap()
1085            .clear()
1086            .unwrap();
1087        let (after, _, _) = observe::observe(&mut store, &id, &req, Limits::DEFAULT).unwrap();
1088        assert_eq!(cold, after, "the promoted store must yield the same answer");
1089        fs::remove_dir_all(&root).ok();
1090    }
1091}
1092
1093/// The EntropyFS-backed field store: the seed DAG persisted one engine blob per
1094/// node, through the same engine as the descriptor and manifest namespaces.
1095#[cfg(all(test, feature = "entropyfs-store"))]
1096mod entropyfs_field_tests {
1097    use super::*;
1098    use crate::field::observe::{self, ObserveRequest, Representation, Selector};
1099    use crate::field::provenance::AnswerValue;
1100    use crate::store::seed_closure;
1101
1102    fn temp_root(label: &str) -> PathBuf {
1103        let mut p = std::env::temp_dir();
1104        p.push(format!(
1105            "vole-field-entropyfs-{label}-{}-{}",
1106            std::process::id(),
1107            std::time::SystemTime::now()
1108                .duration_since(std::time::UNIX_EPOCH)
1109                .unwrap()
1110                .as_nanos()
1111        ));
1112        p
1113    }
1114
1115    fn tiny_descriptor() -> Vec<u8> {
1116        use crate::container::{Descriptor, ObjectSource};
1117        use crate::dra::{Op, Program};
1118        let source = b"the exact field bytes";
1119        let d = Descriptor {
1120            universe: crate::container::UNIVERSE.to_string(),
1121            source_format: crate::SOURCE_FORMAT_OPAQUE,
1122            format_basis: "opaque:field-entropyfs-test".to_string(),
1123            models: vec![],
1124            channels: vec![],
1125            objects: vec![ObjectSource::Inline(source.to_vec())],
1126            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
1127            observation_index: None,
1128            seek_directory: false,
1129            checkpoints: None,
1130            source_sha256: crate::integrity::sha256(source),
1131            source_len: source.len() as u64,
1132        };
1133        d.serialize().unwrap().0
1134    }
1135
1136    #[test]
1137    fn ingest_observe_and_exact_materialize_through_the_engine() {
1138        let root = temp_root("rt");
1139        let descriptor = tiny_descriptor();
1140        let expected = b"the exact field bytes";
1141        let mut store = FieldStore::open_entropyfs(&root).unwrap();
1142        let id = store.ingest(&descriptor, Limits::DEFAULT).unwrap();
1143        let field = Field::open(&store, &id, Limits::DEFAULT).unwrap();
1144        assert_eq!(field.materialize_exact(Limits::DEFAULT).unwrap(), expected);
1145        // The DocumentExact seed node materializes through the engine itself.
1146        let mut budget = dag::EvalBudget::default();
1147        assert_eq!(
1148            field
1149                .materialize_node(&field.manifest().root_node, Limits::DEFAULT, &mut budget)
1150                .unwrap(),
1151            expected
1152        );
1153        // A narrow observation resolves against the engine-backed seed substrate.
1154        let req = ObserveRequest::new(
1155            Selector::ByteRange { offset: 4, len: 5 },
1156            Representation::ExactBytes,
1157        );
1158        let (answer, _stats, _) = observe::observe(&mut store, &id, &req, Limits::DEFAULT).unwrap();
1159        match answer.value {
1160            AnswerValue::Bytes(b) => assert_eq!(b, b"exact"),
1161            other => panic!("expected bytes, got {other:?}"),
1162        }
1163        // `list_fields` cannot enumerate the engine namespace, but the manifest is
1164        // openable by id.
1165        assert_eq!(
1166            store.list_fields().unwrap_err().class(),
1167            crate::ErrorClass::UnsupportedFeature
1168        );
1169        std::fs::remove_dir_all(&root).ok();
1170    }
1171
1172    #[test]
1173    fn cross_process_reopen_reads_what_a_previous_handle_wrote() {
1174        let root = temp_root("reopen");
1175        let descriptor = tiny_descriptor();
1176        // The first handle (a first process) writes, then is dropped, releasing the
1177        // engine's exclusive lock.
1178        let (id, root_node) = {
1179            let mut store = FieldStore::open_entropyfs(&root).unwrap();
1180            let id = store.ingest(&descriptor, Limits::DEFAULT).unwrap();
1181            let node = store.get_field(&id).unwrap().root_node;
1182            // Barrier so the engine publishes the epoch before this handle drops.
1183            store.sync().unwrap();
1184            (id, node)
1185        };
1186        // A brand-new handle over the same directory (a second process) reads it.
1187        let store = FieldStore::open_entropyfs(&root).unwrap();
1188        let manifest = store.get_field(&id).unwrap();
1189        assert_eq!(manifest.content_id(), id);
1190        let field = Field::open(&store, &id, Limits::DEFAULT).unwrap();
1191        assert_eq!(
1192            field.materialize_exact(Limits::DEFAULT).unwrap(),
1193            b"the exact field bytes"
1194        );
1195        let mut budget = dag::EvalBudget::default();
1196        assert_eq!(
1197            field
1198                .materialize_node(&root_node, Limits::DEFAULT, &mut budget)
1199                .unwrap(),
1200            b"the exact field bytes"
1201        );
1202        std::fs::remove_dir_all(&root).ok();
1203    }
1204
1205    #[test]
1206    fn list_nodes_declines_so_gc_cannot_sweep() {
1207        let root = temp_root("gc");
1208        let mut store = FieldStore::open_entropyfs(&root).unwrap();
1209        let id = store.ingest(&tiny_descriptor(), Limits::DEFAULT).unwrap();
1210        let manifest = store.get_field(&id).unwrap();
1211        // A mark-and-sweep needs `list_nodes`, which the engine cannot provide.
1212        let e = store.seeds().list_nodes().unwrap_err();
1213        assert_eq!(e.class(), crate::ErrorClass::UnsupportedFeature);
1214        // The reachable closure is still derivable, because it descends by explicit
1215        // id and never enumerates the store.
1216        let reachable =
1217            seed_closure(store.seeds(), &[manifest.root_node], |_| Ok(Vec::new())).unwrap();
1218        assert!(reachable.contains(&manifest.root_node));
1219        std::fs::remove_dir_all(&root).ok();
1220    }
1221}
1222
1223/// The packed seed store (`fieldpack/`): must be semantically indistinguishable
1224/// from the reference filesystem store — same node ids, same bytes, same field
1225/// answers, and the same exact materialization.
1226#[cfg(test)]
1227mod packed_field_tests {
1228    use super::*;
1229    use crate::field::observe::{self, ObserveRequest, Representation, Selector};
1230    use crate::field::provenance::AnswerValue;
1231
1232    fn temp_root(label: &str) -> PathBuf {
1233        let mut p = std::env::temp_dir();
1234        p.push(format!(
1235            "vole-field-packed-{label}-{}-{}",
1236            std::process::id(),
1237            std::time::SystemTime::now()
1238                .duration_since(std::time::UNIX_EPOCH)
1239                .unwrap()
1240                .as_nanos()
1241        ));
1242        p
1243    }
1244
1245    fn tiny_descriptor() -> Vec<u8> {
1246        use crate::container::{Descriptor, ObjectSource};
1247        use crate::dra::{Op, Program};
1248        let source = b"the exact field bytes";
1249        let d = Descriptor {
1250            universe: crate::container::UNIVERSE.to_string(),
1251            source_format: crate::SOURCE_FORMAT_OPAQUE,
1252            format_basis: "opaque:field-packed-test".to_string(),
1253            models: vec![],
1254            channels: vec![],
1255            objects: vec![ObjectSource::Inline(source.to_vec())],
1256            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
1257            observation_index: None,
1258            seek_directory: false,
1259            checkpoints: None,
1260            source_sha256: crate::integrity::sha256(source),
1261            source_len: source.len() as u64,
1262        };
1263        d.serialize().unwrap().0
1264    }
1265
1266    #[test]
1267    fn packed_matches_fs_for_ingest_observe_and_exact_materialize() {
1268        let fs_root = temp_root("fs");
1269        let pk_root = temp_root("packed");
1270        let descriptor = tiny_descriptor();
1271        let expected = b"the exact field bytes";
1272
1273        let mut fs_store = FieldStore::open(&fs_root).unwrap();
1274        let fs_id = fs_store.ingest(&descriptor, Limits::DEFAULT).unwrap();
1275        fs_store.sync().unwrap();
1276
1277        let mut pk_store = FieldStore::open_packed(&pk_root).unwrap();
1278        let pk_id = pk_store.ingest(&descriptor, Limits::DEFAULT).unwrap();
1279        pk_store.sync().unwrap();
1280
1281        // The backend is deliberately not recorded in the manifest: the same
1282        // ingest yields a byte-identical FieldId under either backend.
1283        assert_eq!(
1284            fs_id, pk_id,
1285            "the packed backend must not change the FieldId"
1286        );
1287        assert_eq!(
1288            fs_store.get_field(&fs_id).unwrap().root_node,
1289            pk_store.get_field(&pk_id).unwrap().root_node,
1290            "node identity is content-derived and backend-independent"
1291        );
1292
1293        // `materialize --exact` is descriptor-driven and byte-identical.
1294        {
1295            let f = Field::open(&fs_store, &fs_id, Limits::DEFAULT).unwrap();
1296            assert_eq!(f.materialize_exact(Limits::DEFAULT).unwrap(), expected);
1297            let p = Field::open(&pk_store, &pk_id, Limits::DEFAULT).unwrap();
1298            assert_eq!(p.materialize_exact(Limits::DEFAULT).unwrap(), expected);
1299        }
1300
1301        // The same narrow observation resolves to the same bytes through both.
1302        let req = ObserveRequest::new(
1303            Selector::ByteRange { offset: 4, len: 5 },
1304            Representation::ExactBytes,
1305        );
1306        let (fs_ans, _, _) =
1307            observe::observe(&mut fs_store, &fs_id, &req, Limits::DEFAULT).unwrap();
1308        let (pk_ans, _, _) =
1309            observe::observe(&mut pk_store, &pk_id, &req, Limits::DEFAULT).unwrap();
1310        match (&fs_ans.value, &pk_ans.value) {
1311            (AnswerValue::Bytes(a), AnswerValue::Bytes(b)) => {
1312                assert_eq!(a, b, "packed and fs observations must agree");
1313                assert_eq!(b, b"exact");
1314            }
1315            other => panic!("expected byte answers, got {other:?}"),
1316        }
1317
1318        std::fs::remove_dir_all(&fs_root).ok();
1319        std::fs::remove_dir_all(&pk_root).ok();
1320    }
1321
1322    #[test]
1323    fn packed_reopen_serves_the_same_field() {
1324        let root = temp_root("reopen");
1325        let descriptor = tiny_descriptor();
1326        let id = {
1327            let mut store = FieldStore::open_packed(&root).unwrap();
1328            let id = store.ingest(&descriptor, Limits::DEFAULT).unwrap();
1329            // Seal so a fresh handle sees the nodes through the immutable index.
1330            store.sync().unwrap();
1331            id
1332        };
1333        let store = FieldStore::open_packed(&root).unwrap();
1334        let field = Field::open(&store, &id, Limits::DEFAULT).unwrap();
1335        assert_eq!(
1336            field.materialize_exact(Limits::DEFAULT).unwrap(),
1337            b"the exact field bytes"
1338        );
1339        std::fs::remove_dir_all(&root).ok();
1340    }
1341}