Skip to main content

memstead_base/engine/
archive.rs

1//! Byte-based snapshot API: hydrate an engine from sealed `.mem`
2//! archive bytes, and export a mem's current state back to archive
3//! bytes.
4//!
5//! Bridge consumers and future browser-WASM replicas consume
6//! these two methods to ship the current state of a mem over HTTP
7//! without materialising a temp file. Both methods go through the
8//! existing validator + storage stack — same wire format, same caps,
9//! same refusal envelopes — but expose a single-call API that hides
10//! `ArchiveBackend` / `Mount` from the caller.
11//!
12//! Symmetric contract: bytes produced by [`Engine::export_mem_to_bytes`]
13//! hydrate cleanly into another [`Engine`] via
14//! [`Engine::from_archive_bytes`], and the resulting engine answers the
15//! read surface (`memstead_overview`, `memstead_search`, `memstead_entity`,
16//! `memstead_health`) with results indistinguishable from the source for
17//! the exported mem. Mutation methods refuse via the existing
18//! sealed-backend / read-only-mount envelope — no new error categories
19//! enter the surface here.
20
21use std::path::PathBuf;
22use std::sync::Arc;
23
24use memstead_schema::{Schema, load_schema_from_memory};
25
26use crate::backend::MemBackend;
27use crate::storage::ArchiveBackend;
28use crate::validator::ValidatorLimits;
29use crate::validator::archive::{ArchiveEntries, SchemaFile, extract_entries};
30use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
31
32use super::{Engine, EngineError};
33
34/// Errors surfaced by [`Engine::from_archive_bytes`].
35///
36/// The archive ingress validator's typed payload rides through as
37/// [`Self::Validation`] so the caller pattern-matches on the same
38/// variant `extract_entries` would surface standalone — the new API
39/// does not collapse validation failures into a generic error. The
40/// remaining variants cover the small ladder of engine-side failures
41/// (config parse, embedded schema load, downstream construction).
42#[derive(Debug, thiserror::Error)]
43pub enum FromArchiveBytesError {
44    /// Archive bytes failed validation by `extract_entries`. Carries
45    /// the typed [`crate::validator::ValidationError`] verbatim.
46    #[error("archive validation: {0}")]
47    Validation(#[from] crate::validator::ValidationError),
48    /// `.memstead/config.json` inside the archive could not be parsed as a
49    /// `PublishedMemConfig`. The archive bytes passed the
50    /// archive-level whitelist but the JSON shape failed.
51    #[error("invalid published config: {0}")]
52    InvalidConfig(String),
53    /// The embedded `.memstead/schema/` package failed to load via
54    /// `load_schema_from_memory`.
55    #[error("embedded schema failed to load: {0}")]
56    EmbeddedSchemaInvalid(String),
57    /// Downstream engine construction failed (e.g., schema pin not
58    /// resolved against builtins + embedded schemas).
59    #[error(transparent)]
60    Engine(#[from] EngineError),
61}
62
63impl Engine {
64    /// Hydrate an engine from sealed archive bytes (`.mem`).
65    ///
66    /// Validates the bytes through the archive ingress validator
67    /// (`extract_entries`), reads the embedded `.memstead/config.json` for
68    /// mem name + schema pin, loads any embedded schema package
69    /// (`.memstead/schema/`) into the engine's schema catalogue, and
70    /// constructs a single-mount read-only engine backed by the bytes.
71    /// No temp file, no on-disk artifact — the bytes are the storage.
72    ///
73    /// The resulting engine refuses mutations (`memstead_create`,
74    /// `memstead_update`, `memstead_delete`, `memstead_relate`, `memstead_rename`) via
75    /// the existing read-only-mount / sealed-backend envelope. Read
76    /// operations work for the embedded mem.
77    pub fn from_archive_bytes(bytes: Vec<u8>) -> Result<Self, FromArchiveBytesError> {
78        Self::from_archive_bytes_with_limits(bytes, &ValidatorLimits::DEFAULT)
79    }
80
81    /// Variant of [`Self::from_archive_bytes`] with caller-supplied
82    /// limits. Bridge / registry deployments tune the caps; the
83    /// default ladder ([`ValidatorLimits::DEFAULT`]) is what
84    /// `from_archive_bytes` picks.
85    pub fn from_archive_bytes_with_limits(
86        bytes: Vec<u8>,
87        limits: &ValidatorLimits,
88    ) -> Result<Self, FromArchiveBytesError> {
89        let entries = extract_entries(&bytes, limits)?;
90        let ArchiveEntries {
91            config_bytes,
92            schema_files,
93            ..
94        } = &entries;
95
96        let published: memstead_schema::PublishedMemConfig =
97            serde_json::from_slice(config_bytes)
98                .map_err(|e| FromArchiveBytesError::InvalidConfig(e.to_string()))?;
99
100        let extra_schemas = load_embedded_schemas(schema_files)?;
101
102        let mount = Mount {
103            mem: published.name.clone(),
104            schema: Some(published.schema.clone()),
105            storage: MountStorage::Archive {
106                path: PathBuf::new(),
107            },
108            capability: MountCapability::ReadOnly,
109            lifecycle: MountLifecycle::Eager,
110            cross_linkable: false,
111            migration_target: None,
112        };
113        let backend: Box<dyn MemBackend> = Box::new(ArchiveBackend::from_bytes(bytes));
114
115        let engine = Self::from_mounts_inner(vec![(mount, backend)], extra_schemas)?;
116        Ok(engine)
117    }
118
119    /// Export the named mem's current state as `.mem` archive bytes.
120    ///
121    /// Symmetric to [`Self::from_archive_bytes`]: a mem name in, a
122    /// self-contained byte buffer out. The bytes validate against
123    /// `extract_entries` standalone — any consumer of sealed archives
124    /// accepts them. Feeding the bytes back into
125    /// `Engine::from_archive_bytes` yields an engine that returns
126    /// identical reads against the exported mem.
127    ///
128    /// Returns [`EngineError::UnknownMem`] when the name resolves to
129    /// no mount; [`EngineError::Backend`] wrapping
130    /// [`crate::backend::BackendError::Sealed`] when the mem is
131    /// archive-mounted (already-an-archive, no meaningful re-export);
132    /// [`EngineError::InvalidInput`] when the mem has no loaded
133    /// `MemConfig`; [`EngineError::MemConfigIncomplete`] when the
134    /// loaded config is missing `version`. The git-branch byte-export
135    /// path lifts in a follow-up; today it surfaces as
136    /// [`EngineError::Backend`] wrapping the unmounted-hook message.
137    pub fn export_mem_to_bytes(&self, mem_name: &str) -> Result<Vec<u8>, EngineError> {
138        let mount = self
139            .mounts
140            .iter()
141            .find(|m| m.mount.mem == mem_name)
142            .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
143        let config = self.mem_config_for(mem_name).ok_or_else(|| {
144            EngineError::InvalidInput(format!(
145                "mem '{mem_name}' has no loaded MemConfig — cannot export"
146            ))
147        })?;
148        if config.version.is_none() {
149            return Err(EngineError::MemConfigIncomplete {
150                mem: mem_name.to_string(),
151                missing_fields: vec!["version".to_string()],
152            });
153        }
154        let workspace_root = self.workspace_root.as_deref();
155        // Fixed authored-schema location (the `schemas_dir` key is retired).
156        let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
157        let workspace_schemas_dir = fixed_schemas_dir.as_deref();
158        match &mount.mount.storage {
159            MountStorage::Folder { path } => crate::ops::export::export_mem_to_bytes(
160                path,
161                config,
162                workspace_root,
163                workspace_schemas_dir,
164                mem_name,
165            )
166            .map(|out| out.bytes)
167            .map_err(|e| {
168                EngineError::Backend(crate::backend::BackendError::Other(format!(
169                    "export_mem_to_bytes: {e}"
170                )))
171            }),
172            MountStorage::Archive { .. } => {
173                Err(EngineError::Backend(crate::backend::BackendError::Sealed))
174            }
175            MountStorage::GitBranch { gitdir, branch } => {
176                let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
177                    EngineError::Backend(crate::backend::BackendError::Other(
178                        "git-branch export hook not installed (full flavour not loaded)"
179                            .to_string(),
180                    ))
181                })?;
182                // Source per-entity provenance from the git-branch mutation
183                // log (commit trailers) via the mount's backend and hand the
184                // serialised payload to the hook to embed — the hook walks
185                // no history itself.
186                let provenance_bytes = mount
187                    .backend
188                    .read_provenance(None)
189                    .ok()
190                    .and_then(|records| crate::ops::export::build_archive_provenance(&records))
191                    .and_then(|prov| prov.to_archive_bytes().ok());
192                // Source the anchors sidecar from the branch tip so the
193                // git-branch `.mem` carries anchors like the other backends.
194                let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
195                (hook.export_to_bytes)(
196                    gitdir,
197                    branch,
198                    mem_name,
199                    config,
200                    workspace_root,
201                    workspace_schemas_dir,
202                    provenance_bytes.as_deref(),
203                    anchors_bytes.as_deref(),
204                )
205                .map(|out| out.bytes)
206                .map_err(EngineError::Backend)
207            }
208            // In-memory mems have no directory to walk: list the
209            // entities from the backend (RAM) and seal them through the
210            // same storage-agnostic archive builder the folder path uses,
211            // so a session mem exports to a `.mem` that mounts
212            // standalone identically.
213            MountStorage::InMemory => {
214                let backend = mount.backend.as_ref();
215                let rels = backend.list_entities().map_err(EngineError::Backend)?;
216                let mut md_entries: Vec<(std::path::PathBuf, Vec<u8>)> =
217                    Vec::with_capacity(rels.len());
218                for rel in rels {
219                    if let Some(bytes) = backend.read_entity(&rel).map_err(EngineError::Backend)? {
220                        md_entries.push((rel, bytes));
221                    }
222                }
223                // Source per-entity provenance from the backend's mutation
224                // log so an in-memory mem exports a provenance-bearing
225                // `.mem` identical in shape to the folder/git-branch paths.
226                let provenance = backend
227                    .read_provenance(None)
228                    .ok()
229                    .and_then(|records| crate::ops::export::build_archive_provenance(&records));
230                // Source the anchors sidecar from the in-memory backend so a
231                // sketch-session mem exports a `.mem` carrying its anchors —
232                // the serve session-export → re-import round-trip.
233                let anchors_bytes = backend
234                    .read_anchors_sidecar()
235                    .map_err(EngineError::Backend)?;
236                crate::ops::export::export_entries_to_bytes(
237                    config,
238                    workspace_root,
239                    workspace_schemas_dir,
240                    mem_name,
241                    md_entries,
242                    provenance.as_ref(),
243                    anchors_bytes.as_deref(),
244                )
245                .map(|out| out.bytes)
246                .map_err(|e| {
247                    EngineError::Backend(crate::backend::BackendError::Other(format!(
248                        "export_mem_to_bytes: {e}"
249                    )))
250                })
251            }
252        }
253    }
254}
255
256/// Load the embedded `.memstead/schema/` package (if any) via
257/// `load_schema_from_memory`. Returns an empty vec when the archive
258/// carries no schema files — the boot resolver then falls back to the
259/// built-in catalogue for the schema pin. `pub(crate)` so the archive
260/// `SchemaSource` reads through the same loader.
261pub(crate) fn load_embedded_schemas(
262    schema_files: &[SchemaFile],
263) -> Result<Vec<Arc<Schema>>, FromArchiveBytesError> {
264    if schema_files.is_empty() {
265        return Ok(Vec::new());
266    }
267    let mut manifest: Option<&str> = None;
268    let mut types: Vec<(String, String)> = Vec::new();
269    for sf in schema_files {
270        if sf.archive_path == ".memstead/schema/schema.yaml" {
271            manifest = Some(&sf.content);
272        } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
273            && let Some(stem) = rest.strip_suffix(".yaml")
274        {
275            types.push((stem.to_string(), sf.content.clone()));
276        }
277    }
278    let Some(manifest_yaml) = manifest else {
279        return Err(FromArchiveBytesError::EmbeddedSchemaInvalid(
280            "embedded schema package present but `.memstead/schema/schema.yaml` missing"
281                .to_string(),
282        ));
283    };
284    let schema = load_schema_from_memory(manifest_yaml, &types)
285        .map_err(|e| FromArchiveBytesError::EmbeddedSchemaInvalid(e.to_string()))?;
286    Ok(vec![Arc::new(schema)])
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::path::Path;
293    use tempfile::TempDir;
294
295    use crate::backend::{BackendError, MemBackend};
296    use crate::engine::test_helpers::{cli_actor, empty_create_args, folder_mount};
297    use crate::storage::FilesystemMemWriter;
298    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
299
300    /// Seed a folder-backed mem with `.memstead/config.json` and N
301    /// entities (zero allowed); return the running engine + mem dir.
302    fn folder_mem_with_entities(tmp: &TempDir, titles: &[&str]) -> (Engine, std::path::PathBuf) {
303        let mem_dir = tmp.path().join("specs");
304        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
305        let config_body = r#"{
306            "format": 1,
307            "schema": "default@1.0.0",
308            "version": "1.0.0"
309        }"#;
310        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
311
312        let writer = FilesystemMemWriter::new(mem_dir.clone());
313        let mut engine = Engine::from_mounts(vec![(
314            folder_mount("specs", mem_dir.clone()),
315            Box::new(writer) as Box<dyn MemBackend>,
316        )])
317        .unwrap();
318        let (actor, client) = cli_actor();
319        for t in titles {
320            engine
321                .create_entity(empty_create_args("specs", t), actor, Some(&client), None)
322                .unwrap();
323        }
324        (engine, mem_dir)
325    }
326
327    #[test]
328    fn export_to_bytes_produces_bytes_that_extract_cleanly() {
329        let tmp = TempDir::new().unwrap();
330        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
331        let bytes = engine.export_mem_to_bytes("specs").unwrap();
332        assert!(!bytes.is_empty(), "export bytes must be non-empty");
333        // The bytes validate against the archive ingress validator
334        // standalone — any consumer of sealed archives accepts them.
335        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
336        assert_eq!(entries.markdown_files.len(), 2);
337        let mut names: Vec<_> = entries
338            .markdown_files
339            .iter()
340            .map(|m| m.path.clone())
341            .collect();
342        names.sort();
343        assert_eq!(names, vec!["alpha.md".to_string(), "beta.md".to_string()]);
344    }
345
346    /// End-to-end producer → consumer round-trip: an entity created with
347    /// an authoring note exports per-entity provenance into the archive,
348    /// and a fresh engine that installs those bytes reads the rationale
349    /// back — matching the source. An entity created without a note is
350    /// absent from the payload (no fabricated provenance).
351    #[test]
352    fn export_carries_provenance_that_install_reads_back() {
353        let tmp = TempDir::new().unwrap();
354        let mem_dir = tmp.path().join("specs");
355        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
356        std::fs::write(
357            mem_dir.join(".memstead").join("config.json"),
358            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
359        )
360        .unwrap();
361        let writer = FilesystemMemWriter::new(mem_dir.clone());
362        let mut engine = Engine::from_mounts(vec![(
363            folder_mount("specs", mem_dir.clone()),
364            Box::new(writer) as Box<dyn MemBackend>,
365        )])
366        .unwrap();
367        let (actor, client) = cli_actor();
368        // Alpha carries a note; Beta deliberately does not.
369        engine
370            .create_entity(
371                empty_create_args("specs", "Alpha"),
372                actor,
373                Some(&client),
374                Some("why alpha exists"),
375            )
376            .unwrap();
377        engine
378            .create_entity(
379                empty_create_args("specs", "Beta"),
380                actor,
381                Some(&client),
382                None,
383            )
384            .unwrap();
385
386        let bytes = engine.export_mem_to_bytes("specs").unwrap();
387        // The archive carries the provenance payload.
388        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
389        assert!(
390            entries.provenance_bytes.is_some(),
391            "export must embed the provenance payload"
392        );
393
394        // The publish/install store path persists the *canonical* (re-packed)
395        // bytes, not the raw upload — so normalize must preserve the
396        // provenance member or it would be dropped before serving.
397        let validated =
398            crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
399        let canonical_entries =
400            extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
401        assert!(
402            canonical_entries.provenance_bytes.is_some(),
403            "normalize must preserve provenance through the canonical re-pack (publish store path)"
404        );
405
406        // Install the bytes into a fresh engine and read provenance back.
407        let installed = Engine::from_archive_bytes(bytes).unwrap();
408        let prov = installed
409            .archive_provenance_for("specs")
410            .expect("installed mem exposes provenance");
411        assert_eq!(
412            prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
413            Some("why alpha exists"),
414            "noted entity's rationale matches the source"
415        );
416        assert_eq!(
417            prov.entity("alpha").and_then(|r| r.kind.as_deref()),
418            Some("create"),
419        );
420        assert!(
421            prov.entity("beta").is_none(),
422            "entity authored without a note is absent — no fabricated provenance"
423        );
424    }
425
426    /// Inject an extra member into a zip archive, returning fresh bytes.
427    /// Export now embeds anchors natively (see
428    /// [`export_embeds_anchors_that_install_reads_back`]); this helper still
429    /// synthesises the member in isolation so the canonical-repack survival
430    /// test exercises the registry path independent of the export producer.
431    fn inject_zip_member(archive: &[u8], name: &str, content: &[u8]) -> Vec<u8> {
432        use std::io::{Read, Write};
433        let mut src = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
434        let mut out = Vec::new();
435        {
436            let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
437            let opts = zip::write::SimpleFileOptions::default()
438                .compression_method(zip::CompressionMethod::Deflated);
439            for i in 0..src.len() {
440                let mut f = src.by_index(i).unwrap();
441                let fname = f.name().to_string();
442                let mut buf = Vec::new();
443                f.read_to_end(&mut buf).unwrap();
444                w.start_file(fname, opts).unwrap();
445                w.write_all(&buf).unwrap();
446            }
447            w.start_file(name, opts).unwrap();
448            w.write_all(content).unwrap();
449            w.finish().unwrap();
450        }
451        out
452    }
453
454    /// Registry-leg survival: an anchors sidecar member threads verbatim
455    /// through `validate_and_normalize_archive`'s canonical re-pack (the
456    /// publish/install store path) rather than being silently stripped, and
457    /// the installed mem exposes the anchors.
458    #[test]
459    fn anchors_member_survives_canonical_repack_and_install() {
460        let tmp = TempDir::new().unwrap();
461        let (mut engine, _dir) = folder_mem_with_entities(&tmp, &["Alpha"]);
462        let _ = &mut engine;
463        let exported = engine.export_mem_to_bytes("specs").unwrap();
464
465        let anchors = br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
466        let with_anchors = inject_zip_member(&exported, ".memstead/anchors.json", anchors);
467
468        // Recognised at extract time.
469        let entries = extract_entries(&with_anchors, &ValidatorLimits::DEFAULT).unwrap();
470        assert_eq!(entries.anchors_bytes.as_deref(), Some(&anchors[..]));
471
472        // Threaded through the canonical re-pack (what publish stores).
473        let validated = crate::validator::validate_and_normalize_archive(&with_anchors)
474            .expect("archive with anchors re-validates");
475        let canonical =
476            extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
477        assert_eq!(
478            canonical.anchors_bytes.as_deref(),
479            Some(&anchors[..]),
480            "normalize must preserve the anchors member through the canonical re-pack"
481        );
482
483        // Installing the canonical bytes exposes the anchors on the mem.
484        let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
485        let ids = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
486        assert_eq!(ids.len(), 1);
487        assert_eq!(ids[0].artifact, "src/lib.rs");
488    }
489
490    /// End-to-end export leg (criterion 5): an entity created with an
491    /// `anchors[]` payload exports the anchors sidecar *natively* inside the
492    /// `.mem` archive (no injection), the canonical re-pack preserves it, and
493    /// a fresh engine that installs the bytes reads the anchor back — matching
494    /// the source. A mem with no anchors embeds no member.
495    #[test]
496    fn export_embeds_anchors_that_install_reads_back() {
497        let tmp = TempDir::new().unwrap();
498        let mem_dir = tmp.path().join("specs");
499        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
500        std::fs::write(
501            mem_dir.join(".memstead").join("config.json"),
502            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
503        )
504        .unwrap();
505        let writer = FilesystemMemWriter::new(mem_dir.clone());
506        let mut engine = Engine::from_mounts(vec![(
507            folder_mount("specs", mem_dir.clone()),
508            Box::new(writer) as Box<dyn MemBackend>,
509        )])
510        .unwrap();
511        let (actor, client) = cli_actor();
512
513        // Alpha carries a file anchor; Beta carries none.
514        let mut alpha = empty_create_args("specs", "Alpha");
515        alpha.anchors = vec![crate::anchor::AnchorInput {
516            artifact: Some("src/lib.rs".to_string()),
517            grain: Some("file".to_string()),
518            class: Some("anchored".to_string()),
519            hash: Some("h1".to_string()),
520            hash_stability: Some("stable".to_string()),
521            ..Default::default()
522        }];
523        engine
524            .create_entity(alpha, actor, Some(&client), None)
525            .unwrap();
526        engine
527            .create_entity(
528                empty_create_args("specs", "Beta"),
529                actor,
530                Some(&client),
531                None,
532            )
533            .unwrap();
534
535        // Export embeds the anchors sidecar natively (producer half of the
536        // recognised-member contract).
537        let bytes = engine.export_mem_to_bytes("specs").unwrap();
538        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
539        assert!(
540            entries.anchors_bytes.is_some(),
541            "export must embed the anchors sidecar when the mem has anchors"
542        );
543
544        // Canonical re-pack (publish store path) preserves it.
545        let validated =
546            crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
547        let canonical =
548            extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
549        assert!(
550            canonical.anchors_bytes.is_some(),
551            "normalize must preserve the exported anchors member"
552        );
553
554        // Install into a fresh engine and read the anchor back.
555        let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
556        let alpha_anchors = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
557        assert_eq!(alpha_anchors.len(), 1);
558        assert_eq!(alpha_anchors[0].artifact, "src/lib.rs");
559        assert_eq!(alpha_anchors[0].hash.as_deref(), Some("h1"));
560        // Beta had no anchors — none fabricated.
561        assert!(
562            installed
563                .entity_anchors(&crate::EntityId::new("specs", "beta"))
564                .is_empty(),
565            "an entity with no anchors exposes none after install"
566        );
567    }
568
569    /// Serve sketch-session leg (criterion 5): an anchored write into an
570    /// in-memory mem round-trips through session export → re-import. The
571    /// in-memory backend is exactly what serve mounts, so this proves the
572    /// serve session-export path carries anchors without a serve dependency.
573    #[test]
574    fn in_memory_mem_export_round_trips_anchors() {
575        use crate::storage::InMemoryBackend;
576        // A session-style in-memory mem is self-describing: a versioned config
577        // is written to the backend before boot so export can project it.
578        let backend = InMemoryBackend::new();
579        backend
580            .write_mem_config(br#"{"version":"0.1.0","schema":"default@1.0.0"}"#)
581            .unwrap();
582        let mount = Mount {
583            mem: "sketch".to_string(),
584            schema: Some("default@1.0.0".parse().unwrap()),
585            storage: MountStorage::InMemory,
586            capability: MountCapability::Write,
587            lifecycle: MountLifecycle::Eager,
588            cross_linkable: false,
589            migration_target: None,
590        };
591        let mut engine =
592            Engine::from_mounts(vec![(mount, Box::new(backend) as Box<dyn MemBackend>)]).unwrap();
593        let (actor, client) = cli_actor();
594
595        let mut args = empty_create_args("sketch", "Idea");
596        args.anchors = vec![crate::anchor::AnchorInput {
597            artifact: Some("notes/idea.md".to_string()),
598            grain: Some("file".to_string()),
599            class: Some("informed-by".to_string()),
600            ..Default::default()
601        }];
602        engine
603            .create_entity(args, actor, Some(&client), None)
604            .unwrap();
605
606        let bytes = engine.export_mem_to_bytes("sketch").unwrap();
607        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
608        assert!(
609            entries.anchors_bytes.is_some(),
610            "in-memory session export must carry the anchors sidecar"
611        );
612
613        let reimported = Engine::from_archive_bytes(bytes).unwrap();
614        let anchors = reimported.entity_anchors(&crate::EntityId::new("sketch", "idea"));
615        assert_eq!(anchors.len(), 1);
616        assert_eq!(anchors[0].artifact, "notes/idea.md");
617        assert_eq!(
618            anchors[0].class,
619            crate::anchor::AnchorProvenanceClass::InformedBy
620        );
621    }
622
623    /// Size discipline: provenance scales with entity count (one current
624    /// rationale per entity, each ≤ the 280-char note cap), so for a
625    /// representative mem (~60 noted entities, larger than the live engine
626    /// seed's ~120 but with realistic notes) the provenance-bearing archive
627    /// stays well under the registry's 2 MB publish body limit, and the
628    /// provenance payload is a small fraction of the archive.
629    #[test]
630    fn provenance_bearing_archive_stays_within_publish_budget() {
631        const PUBLISH_BODY_LIMIT: usize = 2 * 1024 * 1024;
632        let tmp = TempDir::new().unwrap();
633        let mem_dir = tmp.path().join("specs");
634        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
635        std::fs::write(
636            mem_dir.join(".memstead").join("config.json"),
637            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
638        )
639        .unwrap();
640        let writer = FilesystemMemWriter::new(mem_dir.clone());
641        let mut engine = Engine::from_mounts(vec![(
642            folder_mount("specs", mem_dir.clone()),
643            Box::new(writer) as Box<dyn MemBackend>,
644        )])
645        .unwrap();
646        let (actor, client) = cli_actor();
647        // A realistic-length authoring note on every entity (near the
648        // 280-char cap) — the worst case for provenance size.
649        let note = "x".repeat(280);
650        for i in 0..60 {
651            engine
652                .create_entity(
653                    empty_create_args("specs", &format!("Entity {i}")),
654                    actor,
655                    Some(&client),
656                    Some(&note),
657                )
658                .unwrap();
659        }
660        let bytes = engine.export_mem_to_bytes("specs").unwrap();
661        assert!(
662            bytes.len() < PUBLISH_BODY_LIMIT,
663            "archive ({} B) must stay under the 2 MB publish limit",
664            bytes.len()
665        );
666        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
667        let prov = entries.provenance_bytes.expect("provenance present");
668        // Every entity's rationale travelled and the payload is a modest
669        // fraction of the archive, not a budget threat.
670        assert!(
671            prov.len() < PUBLISH_BODY_LIMIT / 4,
672            "provenance payload ({} B) is a small fraction of the budget",
673            prov.len()
674        );
675        let parsed = memstead_schema::ArchiveProvenance::from_archive_bytes(&prov).unwrap();
676        assert_eq!(
677            parsed.entities.len(),
678            60,
679            "every noted entity has provenance"
680        );
681    }
682
683    /// A mem slice carrying a
684    /// cross-mem edge (target lives in another mem, won't travel in
685    /// this single-mem archive) exports successfully — the archive is
686    /// still produced — and the export surfaces the dangling edge so the
687    /// operator sees, before sharing, exactly what `install` will reject.
688    /// AC1 (export warns, archive produced) + AC2 (export's condition ==
689    /// install's refusal) tested against one set of bytes.
690    #[test]
691    fn export_warns_on_cross_mem_edge_that_install_refuses() {
692        let tmp = TempDir::new().unwrap();
693        let (engine, mem_dir) = folder_mem_with_entities(&tmp, &[]);
694        // Hand-write a valid spec whose only blemish is a cross-mem
695        // USES edge into mem `other` — the folder export reads `.md`
696        // verbatim, so the edge lands in the archive.
697        let md = "\
698---
699type: spec
700created_date: 2026-01-15
701last_modified: 2026-01-15
702level: M0
703---
704# Broker
705
706## Identity
707
708A
709
710## Purpose
711
712B
713
714## Specifies
715
716C
717
718## Constraints
719
720D
721
722## Rationale
723
724E
725
726## Relationships
727
728- **USES**: [[other--thing]]
729";
730        std::fs::write(mem_dir.join("broker.md"), md).unwrap();
731
732        // The path-shaped export carries the dangling edge on its result
733        // and still writes the archive (AC1).
734        let out = tmp.path().join("specs.mem");
735        let result = engine.export_mem("specs", &out).unwrap();
736        assert!(out.is_file(), "archive must still be produced");
737        assert_eq!(
738            result.dangling_cross_mem_edges.len(),
739            1,
740            "export must surface the cross-mem edge: {:?}",
741            result.dangling_cross_mem_edges
742        );
743        let edge = &result.dangling_cross_mem_edges[0];
744        assert_eq!(edge.entity_path, "broker.md");
745        assert_eq!(edge.target_id, "other--thing");
746        assert_eq!(edge.target_mem, "other");
747
748        // AC2: the exact condition export warned on is what install
749        // refuses on — the strict validator rejects these same bytes.
750        let bytes = std::fs::read(&out).unwrap();
751        let err = crate::validator::validate_and_normalize_archive(&bytes).unwrap_err();
752        assert!(
753            matches!(
754                err,
755                crate::validator::ValidationError::CrossMemRelationship { .. }
756            ),
757            "install-side strict validation must refuse the same edge: {err:?}",
758        );
759    }
760
761    /// Complement: a self-contained export (no cross-mem edges) carries
762    /// no dangling-edge warnings.
763    #[test]
764    fn export_self_contained_mem_warns_nothing() {
765        let tmp = TempDir::new().unwrap();
766        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
767        let out = tmp.path().join("specs.mem");
768        let result = engine.export_mem("specs", &out).unwrap();
769        assert!(
770            result.dangling_cross_mem_edges.is_empty(),
771            "self-contained export must warn nothing: {:?}",
772            result.dangling_cross_mem_edges
773        );
774    }
775
776    #[test]
777    fn export_empty_mem_produces_valid_hydratable_archive() {
778        let tmp = TempDir::new().unwrap();
779        let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
780        let bytes = engine.export_mem_to_bytes("specs").unwrap();
781        // Validator accepts the empty case.
782        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
783        assert!(entries.markdown_files.is_empty());
784        // Hydrate path accepts it too.
785        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
786        assert_eq!(hydrated.mem_names(), vec!["specs"]);
787        assert!(hydrated.store().is_empty());
788    }
789
790    #[test]
791    fn export_unknown_mem_returns_unknown_mem_error() {
792        let tmp = TempDir::new().unwrap();
793        let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
794        let err = engine.export_mem_to_bytes("missing").unwrap_err();
795        match err {
796            EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
797            other => panic!("expected UnknownMem, got {other:?}"),
798        }
799    }
800
801    #[test]
802    fn export_archive_backend_returns_sealed() {
803        // Seed by exporting a folder mem, then re-mount the produced
804        // archive as a read-only archive. The byte-export path on the
805        // archive mount refuses with the Sealed envelope — matches the
806        // existing path-based `export_mem` posture.
807        let tmp = TempDir::new().unwrap();
808        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
809        let bytes = engine.export_mem_to_bytes("specs").unwrap();
810
811        let archive_path = tmp.path().join("ext.mem");
812        std::fs::write(&archive_path, &bytes).unwrap();
813        let archive_engine = Engine::from_mounts(vec![(
814            Mount {
815                mem: "ext".to_string(),
816                schema: Some(memstead_schema::SchemaRef::new(
817                    "default",
818                    semver::Version::new(1, 0, 0),
819                )),
820                storage: MountStorage::Archive {
821                    path: archive_path.clone(),
822                },
823                capability: MountCapability::ReadOnly,
824                lifecycle: MountLifecycle::Lazy,
825                cross_linkable: false,
826                migration_target: None,
827            },
828            Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
829        )])
830        .unwrap();
831        let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
832        assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
833    }
834
835    #[test]
836    fn from_archive_bytes_refuses_non_zip_with_validation_error() {
837        let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
838        match err {
839            FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
840            other => panic!("expected Validation(Zip(_)), got {other:?}"),
841        }
842    }
843
844    #[test]
845    fn from_archive_bytes_refuses_oversized_with_size_cap() {
846        let tmp = TempDir::new().unwrap();
847        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
848        let bytes = engine.export_mem_to_bytes("specs").unwrap();
849
850        let mut limits = ValidatorLimits::DEFAULT;
851        limits.max_compressed_archive = 1;
852        let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
853        match err {
854            FromArchiveBytesError::Validation(
855                crate::validator::ValidationError::SizeCapExceeded { .. },
856            ) => {}
857            other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
858        }
859    }
860
861    #[test]
862    fn hydrated_engine_answers_reads_and_refuses_writes() {
863        let tmp = TempDir::new().unwrap();
864        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
865        let bytes = engine.export_mem_to_bytes("specs").unwrap();
866        let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
867
868        // Read surface — same titles surface from the hydrated state.
869        let hello = hydrated
870            .get_entity(&crate::EntityId::new("specs", "hello"))
871            .expect("hello entity must round-trip");
872        assert_eq!(hello.title, "Hello");
873        let world = hydrated
874            .get_entity(&crate::EntityId::new("specs", "world"))
875            .expect("world entity must round-trip");
876        assert_eq!(world.title, "World");
877
878        // Mutation surface — read-only mount refuses with the existing
879        // typed envelope (no new error categories on the hydrate path).
880        let (actor, client) = cli_actor();
881        let err = hydrated
882            .create_entity(
883                empty_create_args("specs", "Forbidden"),
884                actor,
885                Some(&client),
886                None,
887            )
888            .unwrap_err();
889        assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
890    }
891
892    #[test]
893    fn round_trip_preserves_entities_and_relations() {
894        // Build a multi-entity mem with a relation, export → hydrate,
895        // and confirm state equivalence: same ids, same content per
896        // entity, same relations.
897        let tmp = TempDir::new().unwrap();
898        let mem_dir = tmp.path().join("specs");
899        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
900        std::fs::write(
901            mem_dir.join(".memstead").join("config.json"),
902            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
903        )
904        .unwrap();
905        let writer = FilesystemMemWriter::new(mem_dir.clone());
906        let mut source = Engine::from_mounts(vec![(
907            folder_mount("specs", mem_dir),
908            Box::new(writer) as Box<dyn MemBackend>,
909        )])
910        .unwrap();
911        let (actor, client) = cli_actor();
912        let src = source
913            .create_entity(
914                empty_create_args("specs", "Source"),
915                actor,
916                Some(&client),
917                None,
918            )
919            .unwrap();
920        let tgt = source
921            .create_entity(
922                empty_create_args("specs", "Target"),
923                actor,
924                Some(&client),
925                None,
926            )
927            .unwrap();
928        source
929            .relate_entity(
930                crate::engine::RelateEntityArgs {
931                    source: src.id.clone(),
932                    expected_hash: Some(src.content_hash.clone()),
933                    rel_type: "USES".to_string(),
934                    target: tgt.id.clone(),
935                    remove: false,
936                    description: None,
937                },
938                actor,
939                Some(&client),
940                None,
941            )
942            .unwrap();
943
944        let bytes = source.export_mem_to_bytes("specs").unwrap();
945        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
946
947        // Same id set.
948        let mut src_ids: Vec<String> = source
949            .store()
950            .all_entities()
951            .map(|e| e.id.to_string())
952            .collect();
953        let mut hyd_ids: Vec<String> = hydrated
954            .store()
955            .all_entities()
956            .map(|e| e.id.to_string())
957            .collect();
958        src_ids.sort();
959        hyd_ids.sort();
960        assert_eq!(src_ids, hyd_ids);
961
962        // Same title + entity_type per id.
963        for id_str in &src_ids {
964            let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
965            let id = crate::EntityId::new(mem, slug);
966            let s = source.get_entity(&id).unwrap();
967            let h = hydrated.get_entity(&id).unwrap();
968            assert_eq!(s.title, h.title, "title differs for {id_str}");
969            assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
970        }
971
972        // Same outgoing relation set.
973        let src_edges: Vec<_> = source
974            .store()
975            .outgoing(&src.id)
976            .iter()
977            .map(|e| (e.rel_type.clone(), e.target.clone()))
978            .collect();
979        let hyd_edges: Vec<_> = hydrated
980            .store()
981            .outgoing(&src.id)
982            .iter()
983            .map(|e| (e.rel_type.clone(), e.target.clone()))
984            .collect();
985        assert_eq!(src_edges, hyd_edges);
986    }
987
988    #[test]
989    fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
990        // Determinism check — same source state must produce identical
991        // archive bytes through the export path, and re-exporting from
992        // the hydrated copy is not part of the contract (the hydrated
993        // engine is read-only) but the produced bytes from the source
994        // must be a fixpoint when re-fed.
995        let tmp = TempDir::new().unwrap();
996        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
997        let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
998        let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
999        assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
1000    }
1001
1002    /// Compare two engines' state for the named mem. State
1003    /// equivalence at minimum (per the round-trip AC): same entity
1004    /// ids, same content per entity (title, type, metadata, sections,
1005    /// content_hash), same relations (rel_type + target per source).
1006    /// Shared by the fixture-sweep round-trip tests so they assert the
1007    /// same invariant regardless of the fixture shape under test.
1008    fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
1009        let mut src_ids: Vec<String> = source
1010            .store()
1011            .all_entities()
1012            .filter(|e| e.mem == mem)
1013            .map(|e| e.id.to_string())
1014            .collect();
1015        let mut hyd_ids: Vec<String> = hydrated
1016            .store()
1017            .all_entities()
1018            .filter(|e| e.mem == mem)
1019            .map(|e| e.id.to_string())
1020            .collect();
1021        src_ids.sort();
1022        hyd_ids.sort();
1023        assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
1024
1025        for id_str in &src_ids {
1026            let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1027            let id = crate::EntityId::new(v, slug);
1028            let s = source.get_entity(&id).expect("source entity present");
1029            let h = hydrated.get_entity(&id).expect("hydrated entity present");
1030            assert_eq!(s.title, h.title, "title differs for {id_str}");
1031            assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1032            assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
1033            assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
1034            assert_eq!(
1035                s.content_hash, h.content_hash,
1036                "content_hash differs for {id_str}",
1037            );
1038
1039            let mut src_edges: Vec<_> = source
1040                .store()
1041                .outgoing(&id)
1042                .iter()
1043                .map(|e| (e.rel_type.clone(), e.target.to_string()))
1044                .collect();
1045            let mut hyd_edges: Vec<_> = hydrated
1046                .store()
1047                .outgoing(&id)
1048                .iter()
1049                .map(|e| (e.rel_type.clone(), e.target.to_string()))
1050                .collect();
1051            src_edges.sort();
1052            hyd_edges.sort();
1053            assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
1054        }
1055    }
1056
1057    /// Round-trip the engine state via export → hydrate, asserting
1058    /// state equivalence against the input. Returns the hydrated
1059    /// engine so individual tests can drive extra reads against it.
1060    fn round_trip(source: &Engine, mem: &str) -> Engine {
1061        let bytes = source.export_mem_to_bytes(mem).unwrap();
1062        // The bytes pass the validator standalone — same invariant the
1063        // bridge consumer relies on, asserted on every fixture so a
1064        // future export change can't silently break ingress.
1065        extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
1066        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1067        assert_state_equivalent(source, &hydrated, mem);
1068        hydrated
1069    }
1070
1071    #[test]
1072    fn fixture_sweep_round_trip_empty() {
1073        let tmp = TempDir::new().unwrap();
1074        let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1075        let hydrated = round_trip(&engine, "specs");
1076        assert!(hydrated.store().is_empty());
1077    }
1078
1079    #[test]
1080    fn fixture_sweep_round_trip_single_entity() {
1081        let tmp = TempDir::new().unwrap();
1082        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
1083        round_trip(&engine, "specs");
1084    }
1085
1086    #[test]
1087    fn fixture_sweep_round_trip_multi_entity_no_relations() {
1088        let tmp = TempDir::new().unwrap();
1089        let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
1090        round_trip(&engine, "specs");
1091    }
1092
1093    #[test]
1094    fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
1095        let tmp = TempDir::new().unwrap();
1096        let mem_dir = tmp.path().join("specs");
1097        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1098        std::fs::write(
1099            mem_dir.join(".memstead").join("config.json"),
1100            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1101        )
1102        .unwrap();
1103        let writer = FilesystemMemWriter::new(mem_dir.clone());
1104        let mut engine = Engine::from_mounts(vec![(
1105            folder_mount("specs", mem_dir),
1106            Box::new(writer) as Box<dyn MemBackend>,
1107        )])
1108        .unwrap();
1109        let (actor, client) = cli_actor();
1110
1111        let mut sections = indexmap::IndexMap::new();
1112        sections.insert("identity".to_string(), "A rich body.".to_string());
1113        sections.insert(
1114            "purpose".to_string(),
1115            "To exercise the archive round-trip.".to_string(),
1116        );
1117        sections.insert(
1118            "rationale".to_string(),
1119            "Because the spec said so.".to_string(),
1120        );
1121
1122        let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
1123        metadata.insert("level".to_string(), "M0".to_string());
1124
1125        engine
1126            .create_entity(
1127                crate::engine::CreateEntityArgs {
1128                    anchors: Vec::new(),
1129                    mem: "specs".to_string(),
1130                    title: "Rich".to_string(),
1131                    entity_type: "spec".to_string(),
1132                    sections,
1133                    metadata,
1134                    relations: Vec::new(),
1135                    dry_run: false,
1136                },
1137                actor,
1138                Some(&client),
1139                None,
1140            )
1141            .unwrap();
1142        round_trip(&engine, "specs");
1143    }
1144
1145    #[test]
1146    fn fixture_sweep_round_trip_multi_entity_with_relations() {
1147        let tmp = TempDir::new().unwrap();
1148        let mem_dir = tmp.path().join("specs");
1149        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1150        std::fs::write(
1151            mem_dir.join(".memstead").join("config.json"),
1152            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1153        )
1154        .unwrap();
1155        let writer = FilesystemMemWriter::new(mem_dir.clone());
1156        let mut engine = Engine::from_mounts(vec![(
1157            folder_mount("specs", mem_dir),
1158            Box::new(writer) as Box<dyn MemBackend>,
1159        )])
1160        .unwrap();
1161        let (actor, client) = cli_actor();
1162        let src = engine
1163            .create_entity(
1164                empty_create_args("specs", "Source"),
1165                actor,
1166                Some(&client),
1167                None,
1168            )
1169            .unwrap();
1170        let mid = engine
1171            .create_entity(
1172                empty_create_args("specs", "Middle"),
1173                actor,
1174                Some(&client),
1175                None,
1176            )
1177            .unwrap();
1178        let tgt = engine
1179            .create_entity(
1180                empty_create_args("specs", "Target"),
1181                actor,
1182                Some(&client),
1183                None,
1184            )
1185            .unwrap();
1186        // Two outgoing edges of different rel-types from the same
1187        // source — the round-trip must preserve both.
1188        engine
1189            .relate_entity(
1190                crate::engine::RelateEntityArgs {
1191                    source: src.id.clone(),
1192                    expected_hash: Some(src.content_hash.clone()),
1193                    rel_type: "USES".to_string(),
1194                    target: mid.id.clone(),
1195                    remove: false,
1196                    description: None,
1197                },
1198                actor,
1199                Some(&client),
1200                None,
1201            )
1202            .unwrap();
1203        let src_after = engine
1204            .get_entity(&src.id)
1205            .expect("source must still resolve");
1206        engine
1207            .relate_entity(
1208                crate::engine::RelateEntityArgs {
1209                    source: src.id.clone(),
1210                    expected_hash: Some(src_after.content_hash.clone()),
1211                    rel_type: "PART_OF".to_string(),
1212                    target: tgt.id.clone(),
1213                    remove: false,
1214                    description: None,
1215                },
1216                actor,
1217                Some(&client),
1218                None,
1219            )
1220            .unwrap();
1221        round_trip(&engine, "specs");
1222    }
1223
1224    #[test]
1225    fn read_entity_path_works_against_byte_backed_archive() {
1226        // Sanity: the byte-backed ArchiveBackend the hydrate path
1227        // constructs answers `read_entity` for every listed path.
1228        let tmp = TempDir::new().unwrap();
1229        let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
1230        let bytes = engine.export_mem_to_bytes("specs").unwrap();
1231        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1232        let first = hydrated
1233            .get_entity(&crate::EntityId::new("specs", "first"))
1234            .expect("first must hydrate");
1235        assert_eq!(first.title, "First");
1236        // Path-based archive_path() returns None for byte-backed
1237        // backends — compile-time check that the contract holds.
1238        let backend = ArchiveBackend::from_bytes(Vec::new());
1239        let _: Option<&Path> = backend.archive_path();
1240    }
1241}