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 cache;
16pub mod capabilities;
17pub mod dag;
18pub mod derive;
19pub mod document_format;
20pub mod edit;
21pub mod explain;
22pub mod index;
23pub mod ingest;
24#[cfg(feature = "package")]
25pub mod ingest_package;
26pub mod manifest;
27pub mod node;
28pub mod observe;
29#[cfg(feature = "opc")]
30pub mod opc;
31pub mod partial;
32pub mod plan;
33pub mod provenance;
34pub mod resource;
35pub mod share;
36
37pub use manifest::{FieldId, FieldRoot};
38
39use std::fs;
40use std::path::{Path, PathBuf};
41
42#[cfg(feature = "entropyfs-store")]
43use std::sync::Arc;
44
45#[cfg(feature = "entropyfs-store")]
46use entropyfs::engine::BlobId;
47
48use crate::container::ParsedDescriptor;
49use crate::error::{Error, Result};
50use crate::limits::Limits;
51#[cfg(feature = "entropyfs-store")]
52use crate::store::{EntropyFsStore, map_engine_error};
53use crate::store::{FsSeedStore, Id, IoCounters, IoSnapshot, NodeId, SeedStore};
54
55#[cfg(feature = "entropyfs-store")]
56use self::manifest::FIELD_ROOT_DOMAIN;
57use self::node::{NodeKind, SeedNode};
58
59/// The canonical universe string for a Phase-11 field.
60pub 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";
61
62/// The canonical universe string for a Phase-12 **package** (ZIP/OCF/OPC) field.
63///
64/// It is the Phase-11 universe plus an explicit `package-v1` marker. The marker is
65/// recorded in the manifest's `universe_id`; it is **not** a new wire record and
66/// does not change the exact `.voldoc` descriptor, which remains the ordinary
67/// exact form.
68#[cfg(feature = "package")]
69pub 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";
70
71/// A cheaply cloneable handle to a field's seed substrate.
72///
73/// Cloning shares the *same* underlying substrate and I/O counters, so an
74/// observation can mint a detached seed handle without borrowing the
75/// [`FieldStore`] that owns it (the evaluation core holds `&mut FieldStore`).
76/// Every variant owns a reference-counted engine or a path — never a second lock:
77/// an `entropyfs-store` field uses exactly **one** EntropyFS engine for its
78/// descriptors, manifests, and seed nodes, so there is no per-observation reopen.
79#[derive(Clone)]
80pub(crate) enum SeedSubstrate {
81    /// Plain files under `<root>/seed` ([`FsSeedStore`]).
82    Fs { root: PathBuf, io: IoCounters },
83    /// One engine blob per node, through the store's shared EntropyFS engine.
84    #[cfg(feature = "entropyfs-store")]
85    EntropyFs {
86        store: Arc<EntropyFsStore>,
87        io: IoCounters,
88    },
89}
90
91impl SeedStore for SeedSubstrate {
92    fn put_node(&mut self, canonical: &[u8]) -> Result<NodeId> {
93        match self {
94            SeedSubstrate::Fs { root, io } => {
95                FsSeedStore::open_with_io(root, io.handle())?.put_node(canonical)
96            }
97            #[cfg(feature = "entropyfs-store")]
98            SeedSubstrate::EntropyFs { store, .. } => store.seed_put(canonical),
99        }
100    }
101
102    fn get_node(&self, id: &NodeId) -> Result<Vec<u8>> {
103        match self {
104            SeedSubstrate::Fs { root, io } => {
105                FsSeedStore::open_with_io(root, io.handle())?.get_node(id)
106            }
107            #[cfg(feature = "entropyfs-store")]
108            SeedSubstrate::EntropyFs { store, io } => {
109                let bytes = store.seed_get(id)?;
110                io.add_seed(bytes.len() as u64);
111                Ok(bytes)
112            }
113        }
114    }
115
116    fn get_node_range(&self, id: &NodeId, offset: u64, len: u64) -> Result<Vec<u8>> {
117        match self {
118            SeedSubstrate::Fs { root, io } => {
119                FsSeedStore::open_with_io(root, io.handle())?.get_node_range(id, offset, len)
120            }
121            #[cfg(feature = "entropyfs-store")]
122            SeedSubstrate::EntropyFs { store, io } => {
123                let bytes = store.seed_get_range(id, offset, len)?;
124                io.add_seed(bytes.len() as u64);
125                Ok(bytes)
126            }
127        }
128    }
129
130    fn contains_node(&self, id: &NodeId) -> Result<bool> {
131        match self {
132            SeedSubstrate::Fs { root, io } => {
133                FsSeedStore::open_with_io(root, io.handle())?.contains_node(id)
134            }
135            #[cfg(feature = "entropyfs-store")]
136            SeedSubstrate::EntropyFs { store, .. } => store.seed_contains(id),
137        }
138    }
139
140    fn list_nodes(&self) -> Result<Vec<(NodeId, u64)>> {
141        match self {
142            SeedSubstrate::Fs { root, io } => {
143                FsSeedStore::open_with_io(root, io.handle())?.list_nodes()
144            }
145            #[cfg(feature = "entropyfs-store")]
146            SeedSubstrate::EntropyFs { store, .. } => store.seed_list(),
147        }
148    }
149}
150
151/// Where a field's descriptor and manifest blobs live.
152///
153/// The `FieldStore` API never leaks which backend is in use: both store and fetch
154/// by content id (`Id` for descriptors, `FieldId` for manifests). Only [`stats`]
155/// distinguish them.
156///
157/// [`stats`]: crate::field::observe::ObserveStats
158enum BlobBackend {
159    /// Plain files: `descriptor/` and `field/` under the store root.
160    Fs,
161    /// One engine: a descriptor is stored raw (so the engine's `BlobId` equals the
162    /// descriptor `Id`), a manifest is stored domain-prefixed with
163    /// [`FIELD_ROOT_DOMAIN`] (so the engine's `BlobId` equals the manifest
164    /// `FieldId`). Both namespaces are content-addressed and cannot collide with a
165    /// seed node, whose bytes carry a different domain prefix.
166    #[cfg(feature = "entropyfs-store")]
167    EntropyFs(Arc<EntropyFsStore>),
168}
169
170impl BlobBackend {
171    fn descriptor_put(&self, root: &Path, bytes: &[u8]) -> Result<Id> {
172        match self {
173            BlobBackend::Fs => {
174                let id = Id::of(bytes);
175                let path = root.join("descriptor").join(id.to_hex());
176                if !path.exists() {
177                    write_atomic(&path, bytes)?;
178                }
179                Ok(id)
180            }
181            #[cfg(feature = "entropyfs-store")]
182            BlobBackend::EntropyFs(store) => {
183                let id = Id::of(bytes);
184                let blob = store
185                    .engine()
186                    .put_blob(bytes)
187                    .map_err(|e| map_engine_error("put_blob", &e))?;
188                debug_assert_eq!(blob.as_bytes(), id.as_bytes());
189                Ok(id)
190            }
191        }
192    }
193
194    fn descriptor_get(&self, root: &Path, id: &Id) -> Result<Vec<u8>> {
195        match self {
196            BlobBackend::Fs => {
197                let path = root.join("descriptor").join(id.to_hex());
198                fs::read(&path).map_err(|e| {
199                    if e.kind() == std::io::ErrorKind::NotFound {
200                        Error::missing_external_object(format!(
201                            "descriptor blob {id} is not present"
202                        ))
203                    } else {
204                        Error::io(format!("reading descriptor blob {id}: {e}"))
205                    }
206                })
207            }
208            #[cfg(feature = "entropyfs-store")]
209            BlobBackend::EntropyFs(store) => store
210                .engine()
211                .get_blob(BlobId::new(*id.as_bytes()))
212                .map_err(|e| map_engine_error("get_blob", &e)),
213        }
214    }
215
216    fn field_put(&self, root: &Path, manifest: &FieldRoot) -> Result<FieldId> {
217        let bytes = manifest.encode_canonical();
218        let id = FieldId::of_manifest(&bytes);
219        match self {
220            BlobBackend::Fs => {
221                let path = root.join("field").join(id.to_hex());
222                if !path.exists() {
223                    write_atomic(&path, &bytes)?;
224                }
225                Ok(id)
226            }
227            #[cfg(feature = "entropyfs-store")]
228            BlobBackend::EntropyFs(store) => {
229                let mut prefixed = Vec::with_capacity(FIELD_ROOT_DOMAIN.len() + bytes.len());
230                prefixed.extend_from_slice(FIELD_ROOT_DOMAIN);
231                prefixed.extend_from_slice(&bytes);
232                let blob = store
233                    .engine()
234                    .put_blob(&prefixed)
235                    .map_err(|e| map_engine_error("put_blob", &e))?;
236                debug_assert_eq!(blob.as_bytes(), id.as_bytes());
237                Ok(id)
238            }
239        }
240    }
241
242    fn field_get(&self, root: &Path, id: &FieldId) -> Result<Vec<u8>> {
243        match self {
244            BlobBackend::Fs => {
245                let path = root.join("field").join(id.to_hex());
246                fs::read(&path).map_err(|e| {
247                    if e.kind() == std::io::ErrorKind::NotFound {
248                        Error::missing_external_object(format!("field {id} is not present"))
249                    } else {
250                        Error::io(format!("reading field {id}: {e}"))
251                    }
252                })
253            }
254            #[cfg(feature = "entropyfs-store")]
255            BlobBackend::EntropyFs(store) => {
256                let blob = store
257                    .engine()
258                    .get_blob(BlobId::new(*id.as_bytes()))
259                    .map_err(|e| map_engine_error("get_blob", &e))?;
260                let rest = blob.strip_prefix(FIELD_ROOT_DOMAIN).ok_or_else(|| {
261                    Error::integrity_mismatch(format!(
262                        "field manifest {id} is missing its domain prefix"
263                    ))
264                })?;
265                Ok(rest.to_vec())
266            }
267        }
268    }
269}
270
271/// On-disk layout of a field store. Every namespace is content-addressed.
272///
273/// ```text
274/// <root>/
275///   descriptor/<64-hex>       serialized .voldoc descriptor blobs
276///   field/<64-hex>            canonical field manifests
277///   seed/<aa>/<bb>/<64-hex>   canonical seed nodes (FsSeedStore)
278///   index/<64-hex>            hierarchical index nodes (11.3)
279///   cache/                    derived observation cache (11.8)
280/// ```
281///
282/// An EntropyFS-backed store ([`FieldStore::open_entropyfs`], feature
283/// `entropyfs-store`) keeps the descriptor, manifest, and seed namespaces in
284/// `entropyfs/` (one engine blob each) and `index/`/`cache/` as files. The seed
285/// DAG is therefore persisted **one node per engine blob**, never as one coarse
286/// blob; EntropyFS sees only opaque bytes and VOLE owns node semantics (ADR-0025).
287/// Advisory engine accounting for an EntropyFS-backed field store.
288///
289/// These are EntropyFS's own numbers, not VOLE's; they are reported so a court can
290/// witness that the seed DAG is many individual engine blobs rather than one. They
291/// carry **no** decoder authority and no exactness meaning.
292#[cfg(feature = "entropyfs-store")]
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct EngineStoreStats {
295    /// Files in the engine blob namespace (one per descriptor / manifest / node).
296    pub blob_count: u64,
297    /// Sum of materialized logical bytes across reachable inodes.
298    pub logical_bytes: u64,
299    /// Sum of segment-file lengths (physical store bytes).
300    pub physical_used_bytes: u64,
301}
302
303pub struct FieldStore {
304    root: PathBuf,
305    backend: BlobBackend,
306    seeds: SeedSubstrate,
307    io: IoCounters,
308}
309
310impl std::fmt::Debug for FieldStore {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        f.debug_struct("FieldStore")
313            .field("root", &self.root)
314            .finish_non_exhaustive()
315    }
316}
317
318impl FieldStore {
319    /// Create or open a field store rooted at `root` (the filesystem backend).
320    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
321        let root = root.as_ref().to_path_buf();
322        fs::create_dir_all(root.join("descriptor"))?;
323        fs::create_dir_all(root.join("field"))?;
324        fs::create_dir_all(root.join("index"))?;
325        fs::create_dir_all(root.join("cache"))?;
326        let io = IoCounters::new();
327        let seeds = SeedSubstrate::Fs {
328            root: root.clone(),
329            io: io.handle(),
330        };
331        Ok(FieldStore {
332            root,
333            backend: BlobBackend::Fs,
334            seeds,
335            io,
336        })
337    }
338
339    /// Create or open a field store whose descriptor, manifest, and seed
340    /// namespaces are served by one embedded EntropyFS engine at
341    /// `root/entropyfs` (feature `entropyfs-store`).
342    ///
343    /// The hierarchical observation index and the disposable derived cache remain
344    /// files under `<root>/index` and `<root>/cache`. Exactly one engine is opened;
345    /// the seed substrate and every observation share it by reference count, so a
346    /// field never opens a second engine over the same directory (which would
347    /// deadlock on the engine's exclusive lock).
348    #[cfg(feature = "entropyfs-store")]
349    pub fn open_entropyfs(root: impl AsRef<Path>) -> Result<Self> {
350        let root = root.as_ref().to_path_buf();
351        fs::create_dir_all(root.join("index"))?;
352        fs::create_dir_all(root.join("cache"))?;
353        let engine_root = root.join("entropyfs");
354        fs::create_dir_all(&engine_root)?;
355        // An empty engine directory is a fresh store; anything else is an
356        // existing one. (`Engine::open` cannot open a directory with no store.)
357        let fresh = fs::read_dir(&engine_root)?.next().is_none();
358        let engine = if fresh {
359            EntropyFsStore::create(&engine_root)?
360        } else {
361            EntropyFsStore::open(&engine_root)?
362        };
363        let engine = Arc::new(engine);
364        let io = IoCounters::new();
365        let seeds = SeedSubstrate::EntropyFs {
366            store: Arc::clone(&engine),
367            io: io.handle(),
368        };
369        Ok(FieldStore {
370            root,
371            backend: BlobBackend::EntropyFs(engine),
372            seeds,
373            io,
374        })
375    }
376
377    /// The physical-I/O counters shared by this store and its seed substrate.
378    ///
379    /// Every descriptor/manifest/index/seed read made through any handle derived
380    /// from this store is attributed here; an observation snapshots it before and
381    /// after to report its own physical bytes (review fix #1).
382    pub fn io(&self) -> &IoCounters {
383        &self.io
384    }
385
386    /// The store root directory.
387    pub fn root(&self) -> &Path {
388        &self.root
389    }
390
391    /// A detached, cheaply cloned seed handle that shares this store's substrate
392    /// and I/O counters (used by observations).
393    pub(crate) fn seed_substrate(&self) -> SeedSubstrate {
394        self.seeds.clone()
395    }
396
397    /// Whether the descriptor blob is a filesystem file supporting seek-based
398    /// partial reads. The EntropyFS backend stores it as an engine blob, so the
399    /// partial lane is unavailable and observations read the full descriptor.
400    pub(crate) fn supports_partial_descriptor(&self) -> bool {
401        matches!(self.backend, BlobBackend::Fs)
402    }
403
404    /// The seed store (for advanced callers and courts).
405    pub fn seeds(&self) -> &dyn SeedStore {
406        &self.seeds
407    }
408
409    /// Mutable seed store access.
410    pub fn seeds_mut(&mut self) -> &mut dyn SeedStore {
411        &mut self.seeds
412    }
413
414    /// Open the disposable derived observation cache (11.8) under this store.
415    ///
416    /// The cache is never normative: it can always be deleted and observations
417    /// remain correct by recomputation (ADR-0027).
418    pub fn cache(&self) -> Result<cache::DerivedCache> {
419        cache::DerivedCache::open(self.root.join("cache"))
420    }
421
422    /// Make every acknowledged write power-durable before the handle is dropped.
423    ///
424    /// The EntropyFS engine acks a `put_blob` at rename but does not barrier, so a
425    /// fresh open in another process can miss an unpublishied epoch. Calling this
426    /// after a mutation closes that gap. The filesystem backend writes atomically
427    /// (`tmp -> fsync -> rename`) and needs no barrier.
428    pub fn sync(&self) -> Result<()> {
429        match &self.backend {
430            BlobBackend::Fs => Ok(()),
431            #[cfg(feature = "entropyfs-store")]
432            BlobBackend::EntropyFs(store) => store.sync(),
433        }
434    }
435
436    /// Advisory engine accounting for an EntropyFS-backed store, or `None` for
437    /// the filesystem backend.
438    ///
439    /// `blob_count` is the number of files in the engine's blob namespace: every
440    /// descriptor, manifest, and seed node is exactly one blob, so a seed DAG of
441    /// `n` nodes adds `n` blobs (never one coarse blob). This is a snapshot, not a
442    /// claim that the engine understands procedural state (ADR-0025).
443    #[cfg(feature = "entropyfs-store")]
444    pub fn engine_stats(&self) -> Result<Option<EngineStoreStats>> {
445        match &self.backend {
446            BlobBackend::Fs => Ok(None),
447            BlobBackend::EntropyFs(store) => {
448                let m = store
449                    .engine()
450                    .metrics()
451                    .map_err(|e| map_engine_error("metrics", &e))?;
452                Ok(Some(EngineStoreStats {
453                    blob_count: m.accounting.blob_count,
454                    logical_bytes: m.accounting.logical_bytes,
455                    physical_used_bytes: m.accounting.physical_used_bytes,
456                }))
457            }
458        }
459    }
460
461    /// The filesystem path of a descriptor blob, or `None` when the backend keeps
462    /// it as an engine blob (in which case the partial lane is unavailable).
463    pub(crate) fn descriptor_path(&self, id: &Id) -> Option<PathBuf> {
464        match self.backend {
465            BlobBackend::Fs => Some(self.root.join("descriptor").join(id.to_hex())),
466            #[cfg(feature = "entropyfs-store")]
467            BlobBackend::EntropyFs(_) => None,
468        }
469    }
470
471    /// Store a serialized `.voldoc` descriptor as a content-addressed blob.
472    ///
473    /// Returns the blob's [`Id`] (`BLAKE3-256(bytes)`), which is what the field
474    /// manifest binds. Idempotent.
475    pub fn put_descriptor(&mut self, bytes: &[u8]) -> Result<Id> {
476        self.backend.descriptor_put(&self.root, bytes)
477    }
478
479    /// Fetch a descriptor blob, verifying its content id.
480    pub fn get_descriptor(&self, id: &Id) -> Result<Vec<u8>> {
481        let bytes = self.backend.descriptor_get(&self.root, id)?;
482        let actual = Id::of(&bytes);
483        if actual != *id {
484            return Err(Error::integrity_mismatch(format!(
485                "descriptor blob {id} hashes to {actual}"
486            )));
487        }
488        self.io.add_descriptor(bytes.len() as u64);
489        Ok(bytes)
490    }
491
492    /// Store a canonical field manifest.
493    pub fn put_field(&mut self, manifest: &FieldRoot) -> Result<FieldId> {
494        self.backend.field_put(&self.root, manifest)
495    }
496
497    /// Fetch a field manifest, verifying its content id and universe.
498    pub fn get_field(&self, id: &FieldId) -> Result<FieldRoot> {
499        let bytes = self.backend.field_get(&self.root, id)?;
500        let manifest = FieldRoot::decode_canonical(&bytes)?;
501        if manifest.content_id() != *id {
502            return Err(Error::integrity_mismatch(format!(
503                "field {id} manifest content id mismatch"
504            )));
505        }
506        self.io.add_manifest(bytes.len() as u64);
507        Ok(manifest)
508    }
509
510    /// List every stored field manifest id.
511    ///
512    /// The filesystem backend reads the `field/` directory. The EntropyFS backend
513    /// **declines** with `UnsupportedFeature`: the engine exposes no per-blob
514    /// enumeration, so a manifest cannot be listed — but any manifest remains
515    /// openable by its `FieldId` (`get_field`).
516    pub fn list_fields(&self) -> Result<Vec<FieldId>> {
517        if !matches!(self.backend, BlobBackend::Fs) {
518            return Err(Error::unsupported_feature(
519                "EntropyFS exposes no per-blob enumeration; a field store backed by it \
520                 cannot list its manifests (open a field by its FieldId instead)",
521            ));
522        }
523        let dir = self.root.join("field");
524        let mut out = Vec::new();
525        for entry in fs::read_dir(&dir)?.flatten() {
526            if let Some(name) = entry.file_name().to_str()
527                && let Ok(id) = FieldId::from_hex(name)
528            {
529                out.push(id);
530            }
531        }
532        out.sort_unstable();
533        Ok(out)
534    }
535
536    /// Ingest a serialized `.voldoc` descriptor as a new field.
537    ///
538    /// Stage A (durable exact capture): store the descriptor blob and an exact
539    /// `DocumentExact` root node, then write the manifest. Deeper Stage-B
540    /// procedural nodes are added by [`crate::field::ingest`].
541    pub fn ingest(&mut self, descriptor_bytes: &[u8], limits: Limits) -> Result<FieldId> {
542        // The descriptor must parse and materialize exactly: ingest never invents
543        // authority it cannot reproduce.
544        let parsed = crate::container::Descriptor::parse(descriptor_bytes, limits)?;
545        let source = crate::materialize::materialize(&parsed, limits)?;
546        let descriptor_id = self.put_descriptor(descriptor_bytes)?;
547
548        let root = SeedNode::new(
549            NodeKind::DocumentExact,
550            source.len() as u64,
551            Vec::new(),
552            Vec::new(),
553            "field:document-exact",
554        );
555        let root_id = self.seeds.put_node(&root.encode_canonical())?;
556
557        let manifest = FieldRoot {
558            universe_id: crate::container::universe_id_from_str(FIELD_UNIVERSE),
559            source_sha256: parsed.descriptor.source_sha256,
560            source_len: parsed.descriptor.source_len,
561            descriptor_id: *descriptor_id.as_bytes(),
562            root_node: root_id,
563            index_root: manifest::ABSENT_ROOT,
564            node_count: 1,
565            index_node_count: 0,
566            provenance: format!("field:ingest;{}", parsed.descriptor.format_basis),
567        };
568        self.put_field(&manifest)
569    }
570}
571
572/// Write bytes to `path` atomically (`tmp -> fsync -> rename`).
573pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
574    use std::io::Write;
575    let dir = path
576        .parent()
577        .ok_or_else(|| Error::internal_invariant("atomic write path has no parent"))?;
578    fs::create_dir_all(dir)?;
579    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("blob");
580    let tmp = dir.join(format!(".{name}.tmp-{}", std::process::id()));
581    {
582        let mut f = fs::File::create(&tmp)?;
583        f.write_all(bytes)?;
584        f.sync_all()?;
585    }
586    fs::rename(&tmp, path)?;
587    Ok(())
588}
589
590/// An opened field: the manifest plus its parsed exact descriptor.
591pub struct Field {
592    store_root: PathBuf,
593    manifest: FieldRoot,
594    parsed: ParsedDescriptor,
595    descriptor_bytes: Vec<u8>,
596    /// A detached seed handle sharing the store's substrate, so a field opened
597    /// from an EntropyFS-backed store materializes its seed nodes through the
598    /// same engine (never a second opener).
599    seeds: SeedSubstrate,
600    /// The physical bytes this `open` fetched to load the manifest and the
601    /// descriptor blob. An observation attributes exactly these to its own
602    /// `descriptor_bytes_read`/`manifest_bytes_read` (review fix #1/#2).
603    open_io: IoSnapshot,
604}
605
606impl std::fmt::Debug for Field {
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        f.debug_struct("Field")
609            .field("manifest", &self.manifest.content_id())
610            .field("source_len", &self.manifest.source_len)
611            .finish_non_exhaustive()
612    }
613}
614
615impl Field {
616    /// Open a field by id, loading and verifying its descriptor.
617    pub fn open(store: &FieldStore, id: &FieldId, limits: Limits) -> Result<Field> {
618        let io_before = store.io().snapshot();
619        let manifest = store.get_field(id)?;
620        Field::open_after_manifest(store, manifest, io_before, limits)
621    }
622
623    /// The body of [`Field::open`] from an already-read manifest. `io_before` is
624    /// the snapshot `open_io` is measured from: pass one taken before the
625    /// manifest read to charge it here (the ordinary path), or after it to charge
626    /// it elsewhere (the narrow probe, which already counted it in `base_io`).
627    pub(crate) fn open_after_manifest(
628        store: &FieldStore,
629        manifest: FieldRoot,
630        io_before: IoSnapshot,
631        limits: Limits,
632    ) -> Result<Field> {
633        let descriptor_bytes = store.get_descriptor(&Id::from_bytes(manifest.descriptor_id))?;
634        let open_io = io_before.delta(&store.io().snapshot());
635        let mut parsed = crate::container::Descriptor::parse(&descriptor_bytes, limits)?;
636        parsed.universe_id = manifest.universe_id;
637        // The manifest must agree with the descriptor it binds.
638        if parsed.descriptor.source_len != manifest.source_len
639            || parsed.descriptor.source_sha256 != manifest.source_sha256
640        {
641            return Err(Error::integrity_mismatch(
642                "field manifest does not match its descriptor's declared source",
643            ));
644        }
645        Ok(Field {
646            store_root: store.root().to_path_buf(),
647            manifest,
648            parsed,
649            descriptor_bytes,
650            seeds: store.seed_substrate(),
651            open_io,
652        })
653    }
654
655    /// The physical bytes fetched to open this field (manifest + descriptor).
656    pub(crate) fn open_io(&self) -> IoSnapshot {
657        self.open_io
658    }
659
660    /// The field manifest.
661    pub fn manifest(&self) -> &FieldRoot {
662        &self.manifest
663    }
664
665    /// The field id.
666    pub fn id(&self) -> FieldId {
667        self.manifest.content_id()
668    }
669
670    /// The parsed exact descriptor.
671    pub fn parsed(&self) -> &ParsedDescriptor {
672        &self.parsed
673    }
674
675    /// The raw serialized descriptor bytes.
676    pub fn descriptor_bytes(&self) -> &[u8] {
677        &self.descriptor_bytes
678    }
679
680    /// The store root this field was opened from.
681    pub fn store_root(&self) -> &Path {
682        &self.store_root
683    }
684
685    /// Materialize the exact original source bytes.
686    ///
687    /// This is the archival authority: it is byte-identical to the standalone
688    /// `.voldoc` materialization and is the only path that verifies the
689    /// whole-source SHA-256.
690    pub fn materialize_exact(&self, limits: Limits) -> Result<Vec<u8>> {
691        crate::materialize::materialize(&self.parsed, limits)
692    }
693
694    /// Materialize a seed node's output by id, using the field's own seed handle
695    /// (which for an EntropyFS-backed store is the shared engine, not a reopen).
696    pub fn materialize_node(
697        &self,
698        id: &NodeId,
699        limits: Limits,
700        budget: &mut dag::EvalBudget,
701    ) -> Result<Vec<u8>> {
702        let node = dag::load_node(&self.seeds, id)?;
703        dag::materialize_node(
704            &self.parsed,
705            &self.seeds,
706            &node,
707            limits,
708            budget,
709            node.limits.max_depth,
710        )
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn temp_root(label: &str) -> PathBuf {
719        let mut p = std::env::temp_dir();
720        p.push(format!(
721            "vole-field-{label}-{}-{}",
722            std::process::id(),
723            std::time::SystemTime::now()
724                .duration_since(std::time::UNIX_EPOCH)
725                .unwrap()
726                .as_nanos()
727        ));
728        p
729    }
730
731    fn tiny_descriptor() -> Vec<u8> {
732        use crate::container::{Descriptor, ObjectSource};
733        use crate::dra::{Op, Program};
734        let source = b"the exact field bytes";
735        let d = Descriptor {
736            universe: crate::container::UNIVERSE.to_string(),
737            source_format: crate::SOURCE_FORMAT_OPAQUE,
738            format_basis: "opaque:field-test".to_string(),
739            models: vec![],
740            channels: vec![],
741            objects: vec![ObjectSource::Inline(source.to_vec())],
742            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
743            observation_index: None,
744            seek_directory: false,
745            checkpoints: None,
746            source_sha256: crate::integrity::sha256(source),
747            source_len: source.len() as u64,
748        };
749        d.serialize().unwrap().0
750    }
751
752    #[test]
753    fn ingest_then_materialize_is_exact() {
754        let root = temp_root("rt");
755        let mut store = FieldStore::open(&root).unwrap();
756        let bytes = tiny_descriptor();
757        let id = store.ingest(&bytes, Limits::DEFAULT).unwrap();
758        let field = Field::open(&store, &id, Limits::DEFAULT).unwrap();
759        assert_eq!(
760            field.materialize_exact(Limits::DEFAULT).unwrap(),
761            b"the exact field bytes"
762        );
763        // The root node also materializes the exact source.
764        let mut budget = dag::EvalBudget::default();
765        let out = field
766            .materialize_node(&field.manifest.root_node, Limits::DEFAULT, &mut budget)
767            .unwrap();
768        assert_eq!(out, b"the exact field bytes");
769        fs::remove_dir_all(&root).ok();
770    }
771
772    #[test]
773    fn field_id_is_stable_and_reopenable() {
774        let root = temp_root("reopen");
775        let bytes = tiny_descriptor();
776        let id1 = {
777            let mut store = FieldStore::open(&root).unwrap();
778            store.ingest(&bytes, Limits::DEFAULT).unwrap()
779        };
780        let store = FieldStore::open(&root).unwrap();
781        let m = store.get_field(&id1).unwrap();
782        assert_eq!(m.content_id(), id1);
783        assert_eq!(store.list_fields().unwrap(), vec![id1]);
784        fs::remove_dir_all(&root).ok();
785    }
786}
787
788/// The EntropyFS-backed field store: the seed DAG persisted one engine blob per
789/// node, through the same engine as the descriptor and manifest namespaces.
790#[cfg(all(test, feature = "entropyfs-store"))]
791mod entropyfs_field_tests {
792    use super::*;
793    use crate::field::observe::{self, ObserveRequest, Representation, Selector};
794    use crate::field::provenance::AnswerValue;
795    use crate::store::seed_closure;
796
797    fn temp_root(label: &str) -> PathBuf {
798        let mut p = std::env::temp_dir();
799        p.push(format!(
800            "vole-field-entropyfs-{label}-{}-{}",
801            std::process::id(),
802            std::time::SystemTime::now()
803                .duration_since(std::time::UNIX_EPOCH)
804                .unwrap()
805                .as_nanos()
806        ));
807        p
808    }
809
810    fn tiny_descriptor() -> Vec<u8> {
811        use crate::container::{Descriptor, ObjectSource};
812        use crate::dra::{Op, Program};
813        let source = b"the exact field bytes";
814        let d = Descriptor {
815            universe: crate::container::UNIVERSE.to_string(),
816            source_format: crate::SOURCE_FORMAT_OPAQUE,
817            format_basis: "opaque:field-entropyfs-test".to_string(),
818            models: vec![],
819            channels: vec![],
820            objects: vec![ObjectSource::Inline(source.to_vec())],
821            program: Program::new(vec![Op::EmitObject { object_id: 0 }]),
822            observation_index: None,
823            seek_directory: false,
824            checkpoints: None,
825            source_sha256: crate::integrity::sha256(source),
826            source_len: source.len() as u64,
827        };
828        d.serialize().unwrap().0
829    }
830
831    #[test]
832    fn ingest_observe_and_exact_materialize_through_the_engine() {
833        let root = temp_root("rt");
834        let descriptor = tiny_descriptor();
835        let expected = b"the exact field bytes";
836        let mut store = FieldStore::open_entropyfs(&root).unwrap();
837        let id = store.ingest(&descriptor, Limits::DEFAULT).unwrap();
838        let field = Field::open(&store, &id, Limits::DEFAULT).unwrap();
839        assert_eq!(field.materialize_exact(Limits::DEFAULT).unwrap(), expected);
840        // The DocumentExact seed node materializes through the engine itself.
841        let mut budget = dag::EvalBudget::default();
842        assert_eq!(
843            field
844                .materialize_node(&field.manifest().root_node, Limits::DEFAULT, &mut budget)
845                .unwrap(),
846            expected
847        );
848        // A narrow observation resolves against the engine-backed seed substrate.
849        let req = ObserveRequest::new(
850            Selector::ByteRange { offset: 4, len: 5 },
851            Representation::ExactBytes,
852        );
853        let (answer, _stats, _) = observe::observe(&mut store, &id, &req, Limits::DEFAULT).unwrap();
854        match answer.value {
855            AnswerValue::Bytes(b) => assert_eq!(b, b"exact"),
856            other => panic!("expected bytes, got {other:?}"),
857        }
858        // `list_fields` cannot enumerate the engine namespace, but the manifest is
859        // openable by id.
860        assert_eq!(
861            store.list_fields().unwrap_err().class(),
862            crate::ErrorClass::UnsupportedFeature
863        );
864        std::fs::remove_dir_all(&root).ok();
865    }
866
867    #[test]
868    fn cross_process_reopen_reads_what_a_previous_handle_wrote() {
869        let root = temp_root("reopen");
870        let descriptor = tiny_descriptor();
871        // The first handle (a first process) writes, then is dropped, releasing the
872        // engine's exclusive lock.
873        let (id, root_node) = {
874            let mut store = FieldStore::open_entropyfs(&root).unwrap();
875            let id = store.ingest(&descriptor, Limits::DEFAULT).unwrap();
876            let node = store.get_field(&id).unwrap().root_node;
877            // Barrier so the engine publishes the epoch before this handle drops.
878            store.sync().unwrap();
879            (id, node)
880        };
881        // A brand-new handle over the same directory (a second process) reads it.
882        let store = FieldStore::open_entropyfs(&root).unwrap();
883        let manifest = store.get_field(&id).unwrap();
884        assert_eq!(manifest.content_id(), id);
885        let field = Field::open(&store, &id, Limits::DEFAULT).unwrap();
886        assert_eq!(
887            field.materialize_exact(Limits::DEFAULT).unwrap(),
888            b"the exact field bytes"
889        );
890        let mut budget = dag::EvalBudget::default();
891        assert_eq!(
892            field
893                .materialize_node(&root_node, Limits::DEFAULT, &mut budget)
894                .unwrap(),
895            b"the exact field bytes"
896        );
897        std::fs::remove_dir_all(&root).ok();
898    }
899
900    #[test]
901    fn list_nodes_declines_so_gc_cannot_sweep() {
902        let root = temp_root("gc");
903        let mut store = FieldStore::open_entropyfs(&root).unwrap();
904        let id = store.ingest(&tiny_descriptor(), Limits::DEFAULT).unwrap();
905        let manifest = store.get_field(&id).unwrap();
906        // A mark-and-sweep needs `list_nodes`, which the engine cannot provide.
907        let e = store.seeds().list_nodes().unwrap_err();
908        assert_eq!(e.class(), crate::ErrorClass::UnsupportedFeature);
909        // The reachable closure is still derivable, because it descends by explicit
910        // id and never enumerates the store.
911        let reachable =
912            seed_closure(store.seeds(), &[manifest.root_node], |_| Ok(Vec::new())).unwrap();
913        assert!(reachable.contains(&manifest.root_node));
914        std::fs::remove_dir_all(&root).ok();
915    }
916}