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