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                (hook.export_to_bytes)(
193                    gitdir,
194                    branch,
195                    mem_name,
196                    config,
197                    workspace_root,
198                    workspace_schemas_dir,
199                    provenance_bytes.as_deref(),
200                )
201                .map(|out| out.bytes)
202                .map_err(EngineError::Backend)
203            }
204            // In-memory mems have no directory to walk: list the
205            // entities from the backend (RAM) and seal them through the
206            // same storage-agnostic archive builder the folder path uses,
207            // so a session mem exports to a `.mem` that mounts
208            // standalone identically.
209            MountStorage::InMemory => {
210                let backend = mount.backend.as_ref();
211                let rels = backend.list_entities().map_err(EngineError::Backend)?;
212                let mut md_entries: Vec<(std::path::PathBuf, Vec<u8>)> =
213                    Vec::with_capacity(rels.len());
214                for rel in rels {
215                    if let Some(bytes) = backend.read_entity(&rel).map_err(EngineError::Backend)? {
216                        md_entries.push((rel, bytes));
217                    }
218                }
219                // Source per-entity provenance from the backend's mutation
220                // log so an in-memory mem exports a provenance-bearing
221                // `.mem` identical in shape to the folder/git-branch paths.
222                let provenance = backend
223                    .read_provenance(None)
224                    .ok()
225                    .and_then(|records| crate::ops::export::build_archive_provenance(&records));
226                crate::ops::export::export_entries_to_bytes(
227                    config,
228                    workspace_root,
229                    workspace_schemas_dir,
230                    mem_name,
231                    md_entries,
232                    provenance.as_ref(),
233                )
234                .map(|out| out.bytes)
235                .map_err(|e| {
236                    EngineError::Backend(crate::backend::BackendError::Other(format!(
237                        "export_mem_to_bytes: {e}"
238                    )))
239                })
240            }
241        }
242    }
243}
244
245/// Load the embedded `.memstead/schema/` package (if any) via
246/// `load_schema_from_memory`. Returns an empty vec when the archive
247/// carries no schema files — the boot resolver then falls back to the
248/// built-in catalogue for the schema pin. `pub(crate)` so the archive
249/// `SchemaSource` reads through the same loader.
250pub(crate) fn load_embedded_schemas(
251    schema_files: &[SchemaFile],
252) -> Result<Vec<Arc<Schema>>, FromArchiveBytesError> {
253    if schema_files.is_empty() {
254        return Ok(Vec::new());
255    }
256    let mut manifest: Option<&str> = None;
257    let mut types: Vec<(String, String)> = Vec::new();
258    for sf in schema_files {
259        if sf.archive_path == ".memstead/schema/schema.yaml" {
260            manifest = Some(&sf.content);
261        } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
262            && let Some(stem) = rest.strip_suffix(".yaml")
263        {
264            types.push((stem.to_string(), sf.content.clone()));
265        }
266    }
267    let Some(manifest_yaml) = manifest else {
268        return Err(FromArchiveBytesError::EmbeddedSchemaInvalid(
269            "embedded schema package present but `.memstead/schema/schema.yaml` missing"
270                .to_string(),
271        ));
272    };
273    let schema = load_schema_from_memory(manifest_yaml, &types)
274        .map_err(|e| FromArchiveBytesError::EmbeddedSchemaInvalid(e.to_string()))?;
275    Ok(vec![Arc::new(schema)])
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use std::path::Path;
282    use tempfile::TempDir;
283
284    use crate::backend::{BackendError, MemBackend};
285    use crate::engine::test_helpers::{cli_actor, empty_create_args, folder_mount};
286    use crate::storage::FilesystemMemWriter;
287    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
288
289    /// Seed a folder-backed mem with `.memstead/config.json` and N
290    /// entities (zero allowed); return the running engine + mem dir.
291    fn folder_mem_with_entities(tmp: &TempDir, titles: &[&str]) -> (Engine, std::path::PathBuf) {
292        let mem_dir = tmp.path().join("specs");
293        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
294        let config_body = r#"{
295            "format": 1,
296            "schema": "default@1.0.0",
297            "version": "1.0.0"
298        }"#;
299        std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
300
301        let writer = FilesystemMemWriter::new(mem_dir.clone());
302        let mut engine = Engine::from_mounts(vec![(
303            folder_mount("specs", mem_dir.clone()),
304            Box::new(writer) as Box<dyn MemBackend>,
305        )])
306        .unwrap();
307        let (actor, client) = cli_actor();
308        for t in titles {
309            engine
310                .create_entity(empty_create_args("specs", t), actor, Some(&client), None)
311                .unwrap();
312        }
313        (engine, mem_dir)
314    }
315
316    #[test]
317    fn export_to_bytes_produces_bytes_that_extract_cleanly() {
318        let tmp = TempDir::new().unwrap();
319        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
320        let bytes = engine.export_mem_to_bytes("specs").unwrap();
321        assert!(!bytes.is_empty(), "export bytes must be non-empty");
322        // The bytes validate against the archive ingress validator
323        // standalone — any consumer of sealed archives accepts them.
324        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
325        assert_eq!(entries.markdown_files.len(), 2);
326        let mut names: Vec<_> = entries
327            .markdown_files
328            .iter()
329            .map(|m| m.path.clone())
330            .collect();
331        names.sort();
332        assert_eq!(names, vec!["alpha.md".to_string(), "beta.md".to_string()]);
333    }
334
335    /// End-to-end producer → consumer round-trip: an entity created with
336    /// an authoring note exports per-entity provenance into the archive,
337    /// and a fresh engine that installs those bytes reads the rationale
338    /// back — matching the source. An entity created without a note is
339    /// absent from the payload (no fabricated provenance).
340    #[test]
341    fn export_carries_provenance_that_install_reads_back() {
342        let tmp = TempDir::new().unwrap();
343        let mem_dir = tmp.path().join("specs");
344        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
345        std::fs::write(
346            mem_dir.join(".memstead").join("config.json"),
347            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
348        )
349        .unwrap();
350        let writer = FilesystemMemWriter::new(mem_dir.clone());
351        let mut engine = Engine::from_mounts(vec![(
352            folder_mount("specs", mem_dir.clone()),
353            Box::new(writer) as Box<dyn MemBackend>,
354        )])
355        .unwrap();
356        let (actor, client) = cli_actor();
357        // Alpha carries a note; Beta deliberately does not.
358        engine
359            .create_entity(
360                empty_create_args("specs", "Alpha"),
361                actor,
362                Some(&client),
363                Some("why alpha exists"),
364            )
365            .unwrap();
366        engine
367            .create_entity(
368                empty_create_args("specs", "Beta"),
369                actor,
370                Some(&client),
371                None,
372            )
373            .unwrap();
374
375        let bytes = engine.export_mem_to_bytes("specs").unwrap();
376        // The archive carries the provenance payload.
377        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
378        assert!(
379            entries.provenance_bytes.is_some(),
380            "export must embed the provenance payload"
381        );
382
383        // The publish/install store path persists the *canonical* (re-packed)
384        // bytes, not the raw upload — so normalize must preserve the
385        // provenance member or it would be dropped before serving.
386        let validated =
387            crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
388        let canonical_entries =
389            extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
390        assert!(
391            canonical_entries.provenance_bytes.is_some(),
392            "normalize must preserve provenance through the canonical re-pack (publish store path)"
393        );
394
395        // Install the bytes into a fresh engine and read provenance back.
396        let installed = Engine::from_archive_bytes(bytes).unwrap();
397        let prov = installed
398            .archive_provenance_for("specs")
399            .expect("installed mem exposes provenance");
400        assert_eq!(
401            prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
402            Some("why alpha exists"),
403            "noted entity's rationale matches the source"
404        );
405        assert_eq!(
406            prov.entity("alpha").and_then(|r| r.kind.as_deref()),
407            Some("create"),
408        );
409        assert!(
410            prov.entity("beta").is_none(),
411            "entity authored without a note is absent — no fabricated provenance"
412        );
413    }
414
415    /// Size discipline: provenance scales with entity count (one current
416    /// rationale per entity, each ≤ the 280-char note cap), so for a
417    /// representative mem (~60 noted entities, larger than the live engine
418    /// seed's ~120 but with realistic notes) the provenance-bearing archive
419    /// stays well under the registry's 2 MB publish body limit, and the
420    /// provenance payload is a small fraction of the archive.
421    #[test]
422    fn provenance_bearing_archive_stays_within_publish_budget() {
423        const PUBLISH_BODY_LIMIT: usize = 2 * 1024 * 1024;
424        let tmp = TempDir::new().unwrap();
425        let mem_dir = tmp.path().join("specs");
426        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
427        std::fs::write(
428            mem_dir.join(".memstead").join("config.json"),
429            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
430        )
431        .unwrap();
432        let writer = FilesystemMemWriter::new(mem_dir.clone());
433        let mut engine = Engine::from_mounts(vec![(
434            folder_mount("specs", mem_dir.clone()),
435            Box::new(writer) as Box<dyn MemBackend>,
436        )])
437        .unwrap();
438        let (actor, client) = cli_actor();
439        // A realistic-length authoring note on every entity (near the
440        // 280-char cap) — the worst case for provenance size.
441        let note = "x".repeat(280);
442        for i in 0..60 {
443            engine
444                .create_entity(
445                    empty_create_args("specs", &format!("Entity {i}")),
446                    actor,
447                    Some(&client),
448                    Some(&note),
449                )
450                .unwrap();
451        }
452        let bytes = engine.export_mem_to_bytes("specs").unwrap();
453        assert!(
454            bytes.len() < PUBLISH_BODY_LIMIT,
455            "archive ({} B) must stay under the 2 MB publish limit",
456            bytes.len()
457        );
458        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
459        let prov = entries.provenance_bytes.expect("provenance present");
460        // Every entity's rationale travelled and the payload is a modest
461        // fraction of the archive, not a budget threat.
462        assert!(
463            prov.len() < PUBLISH_BODY_LIMIT / 4,
464            "provenance payload ({} B) is a small fraction of the budget",
465            prov.len()
466        );
467        let parsed = memstead_schema::ArchiveProvenance::from_archive_bytes(&prov).unwrap();
468        assert_eq!(
469            parsed.entities.len(),
470            60,
471            "every noted entity has provenance"
472        );
473    }
474
475    /// A mem slice carrying a
476    /// cross-mem edge (target lives in another mem, won't travel in
477    /// this single-mem archive) exports successfully — the archive is
478    /// still produced — and the export surfaces the dangling edge so the
479    /// operator sees, before sharing, exactly what `install` will reject.
480    /// AC1 (export warns, archive produced) + AC2 (export's condition ==
481    /// install's refusal) tested against one set of bytes.
482    #[test]
483    fn export_warns_on_cross_mem_edge_that_install_refuses() {
484        let tmp = TempDir::new().unwrap();
485        let (engine, mem_dir) = folder_mem_with_entities(&tmp, &[]);
486        // Hand-write a valid spec whose only blemish is a cross-mem
487        // USES edge into mem `other` — the folder export reads `.md`
488        // verbatim, so the edge lands in the archive.
489        let md = "\
490---
491type: spec
492created_date: 2026-01-15
493last_modified: 2026-01-15
494level: M0
495---
496# Broker
497
498## Identity
499
500A
501
502## Purpose
503
504B
505
506## Specifies
507
508C
509
510## Constraints
511
512D
513
514## Rationale
515
516E
517
518## Relationships
519
520- **USES**: [[other--thing]]
521";
522        std::fs::write(mem_dir.join("broker.md"), md).unwrap();
523
524        // The path-shaped export carries the dangling edge on its result
525        // and still writes the archive (AC1).
526        let out = tmp.path().join("specs.mem");
527        let result = engine.export_mem("specs", &out).unwrap();
528        assert!(out.is_file(), "archive must still be produced");
529        assert_eq!(
530            result.dangling_cross_mem_edges.len(),
531            1,
532            "export must surface the cross-mem edge: {:?}",
533            result.dangling_cross_mem_edges
534        );
535        let edge = &result.dangling_cross_mem_edges[0];
536        assert_eq!(edge.entity_path, "broker.md");
537        assert_eq!(edge.target_id, "other--thing");
538        assert_eq!(edge.target_mem, "other");
539
540        // AC2: the exact condition export warned on is what install
541        // refuses on — the strict validator rejects these same bytes.
542        let bytes = std::fs::read(&out).unwrap();
543        let err = crate::validator::validate_and_normalize_archive(&bytes).unwrap_err();
544        assert!(
545            matches!(
546                err,
547                crate::validator::ValidationError::CrossMemRelationship { .. }
548            ),
549            "install-side strict validation must refuse the same edge: {err:?}",
550        );
551    }
552
553    /// Complement: a self-contained export (no cross-mem edges) carries
554    /// no dangling-edge warnings.
555    #[test]
556    fn export_self_contained_mem_warns_nothing() {
557        let tmp = TempDir::new().unwrap();
558        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
559        let out = tmp.path().join("specs.mem");
560        let result = engine.export_mem("specs", &out).unwrap();
561        assert!(
562            result.dangling_cross_mem_edges.is_empty(),
563            "self-contained export must warn nothing: {:?}",
564            result.dangling_cross_mem_edges
565        );
566    }
567
568    #[test]
569    fn export_empty_mem_produces_valid_hydratable_archive() {
570        let tmp = TempDir::new().unwrap();
571        let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
572        let bytes = engine.export_mem_to_bytes("specs").unwrap();
573        // Validator accepts the empty case.
574        let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
575        assert!(entries.markdown_files.is_empty());
576        // Hydrate path accepts it too.
577        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
578        assert_eq!(hydrated.mem_names(), vec!["specs"]);
579        assert!(hydrated.store().is_empty());
580    }
581
582    #[test]
583    fn export_unknown_mem_returns_unknown_mem_error() {
584        let tmp = TempDir::new().unwrap();
585        let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
586        let err = engine.export_mem_to_bytes("missing").unwrap_err();
587        match err {
588            EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
589            other => panic!("expected UnknownMem, got {other:?}"),
590        }
591    }
592
593    #[test]
594    fn export_archive_backend_returns_sealed() {
595        // Seed by exporting a folder mem, then re-mount the produced
596        // archive as a read-only archive. The byte-export path on the
597        // archive mount refuses with the Sealed envelope — matches the
598        // existing path-based `export_mem` posture.
599        let tmp = TempDir::new().unwrap();
600        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
601        let bytes = engine.export_mem_to_bytes("specs").unwrap();
602
603        let archive_path = tmp.path().join("ext.mem");
604        std::fs::write(&archive_path, &bytes).unwrap();
605        let archive_engine = Engine::from_mounts(vec![(
606            Mount {
607                mem: "ext".to_string(),
608                schema: Some(memstead_schema::SchemaRef::new(
609                    "default",
610                    semver::Version::new(1, 0, 0),
611                )),
612                storage: MountStorage::Archive {
613                    path: archive_path.clone(),
614                },
615                capability: MountCapability::ReadOnly,
616                lifecycle: MountLifecycle::Lazy,
617                cross_linkable: false,
618                migration_target: None,
619            },
620            Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
621        )])
622        .unwrap();
623        let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
624        assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
625    }
626
627    #[test]
628    fn from_archive_bytes_refuses_non_zip_with_validation_error() {
629        let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
630        match err {
631            FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
632            other => panic!("expected Validation(Zip(_)), got {other:?}"),
633        }
634    }
635
636    #[test]
637    fn from_archive_bytes_refuses_oversized_with_size_cap() {
638        let tmp = TempDir::new().unwrap();
639        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
640        let bytes = engine.export_mem_to_bytes("specs").unwrap();
641
642        let mut limits = ValidatorLimits::DEFAULT;
643        limits.max_compressed_archive = 1;
644        let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
645        match err {
646            FromArchiveBytesError::Validation(
647                crate::validator::ValidationError::SizeCapExceeded { .. },
648            ) => {}
649            other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
650        }
651    }
652
653    #[test]
654    fn hydrated_engine_answers_reads_and_refuses_writes() {
655        let tmp = TempDir::new().unwrap();
656        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
657        let bytes = engine.export_mem_to_bytes("specs").unwrap();
658        let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
659
660        // Read surface — same titles surface from the hydrated state.
661        let hello = hydrated
662            .get_entity(&crate::EntityId::new("specs", "hello"))
663            .expect("hello entity must round-trip");
664        assert_eq!(hello.title, "Hello");
665        let world = hydrated
666            .get_entity(&crate::EntityId::new("specs", "world"))
667            .expect("world entity must round-trip");
668        assert_eq!(world.title, "World");
669
670        // Mutation surface — read-only mount refuses with the existing
671        // typed envelope (no new error categories on the hydrate path).
672        let (actor, client) = cli_actor();
673        let err = hydrated
674            .create_entity(
675                empty_create_args("specs", "Forbidden"),
676                actor,
677                Some(&client),
678                None,
679            )
680            .unwrap_err();
681        assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
682    }
683
684    #[test]
685    fn round_trip_preserves_entities_and_relations() {
686        // Build a multi-entity mem with a relation, export → hydrate,
687        // and confirm state equivalence: same ids, same content per
688        // entity, same relations.
689        let tmp = TempDir::new().unwrap();
690        let mem_dir = tmp.path().join("specs");
691        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
692        std::fs::write(
693            mem_dir.join(".memstead").join("config.json"),
694            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
695        )
696        .unwrap();
697        let writer = FilesystemMemWriter::new(mem_dir.clone());
698        let mut source = Engine::from_mounts(vec![(
699            folder_mount("specs", mem_dir),
700            Box::new(writer) as Box<dyn MemBackend>,
701        )])
702        .unwrap();
703        let (actor, client) = cli_actor();
704        let src = source
705            .create_entity(
706                empty_create_args("specs", "Source"),
707                actor,
708                Some(&client),
709                None,
710            )
711            .unwrap();
712        let tgt = source
713            .create_entity(
714                empty_create_args("specs", "Target"),
715                actor,
716                Some(&client),
717                None,
718            )
719            .unwrap();
720        source
721            .relate_entity(
722                crate::engine::RelateEntityArgs {
723                    source: src.id.clone(),
724                    expected_hash: Some(src.content_hash.clone()),
725                    rel_type: "USES".to_string(),
726                    target: tgt.id.clone(),
727                    remove: false,
728                    description: None,
729                },
730                actor,
731                Some(&client),
732                None,
733            )
734            .unwrap();
735
736        let bytes = source.export_mem_to_bytes("specs").unwrap();
737        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
738
739        // Same id set.
740        let mut src_ids: Vec<String> = source
741            .store()
742            .all_entities()
743            .map(|e| e.id.to_string())
744            .collect();
745        let mut hyd_ids: Vec<String> = hydrated
746            .store()
747            .all_entities()
748            .map(|e| e.id.to_string())
749            .collect();
750        src_ids.sort();
751        hyd_ids.sort();
752        assert_eq!(src_ids, hyd_ids);
753
754        // Same title + entity_type per id.
755        for id_str in &src_ids {
756            let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
757            let id = crate::EntityId::new(mem, slug);
758            let s = source.get_entity(&id).unwrap();
759            let h = hydrated.get_entity(&id).unwrap();
760            assert_eq!(s.title, h.title, "title differs for {id_str}");
761            assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
762        }
763
764        // Same outgoing relation set.
765        let src_edges: Vec<_> = source
766            .store()
767            .outgoing(&src.id)
768            .iter()
769            .map(|e| (e.rel_type.clone(), e.target.clone()))
770            .collect();
771        let hyd_edges: Vec<_> = hydrated
772            .store()
773            .outgoing(&src.id)
774            .iter()
775            .map(|e| (e.rel_type.clone(), e.target.clone()))
776            .collect();
777        assert_eq!(src_edges, hyd_edges);
778    }
779
780    #[test]
781    fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
782        // Determinism check — same source state must produce identical
783        // archive bytes through the export path, and re-exporting from
784        // the hydrated copy is not part of the contract (the hydrated
785        // engine is read-only) but the produced bytes from the source
786        // must be a fixpoint when re-fed.
787        let tmp = TempDir::new().unwrap();
788        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
789        let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
790        let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
791        assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
792    }
793
794    /// Compare two engines' state for the named mem. State
795    /// equivalence at minimum (per the round-trip AC): same entity
796    /// ids, same content per entity (title, type, metadata, sections,
797    /// content_hash), same relations (rel_type + target per source).
798    /// Shared by the fixture-sweep round-trip tests so they assert the
799    /// same invariant regardless of the fixture shape under test.
800    fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
801        let mut src_ids: Vec<String> = source
802            .store()
803            .all_entities()
804            .filter(|e| e.mem == mem)
805            .map(|e| e.id.to_string())
806            .collect();
807        let mut hyd_ids: Vec<String> = hydrated
808            .store()
809            .all_entities()
810            .filter(|e| e.mem == mem)
811            .map(|e| e.id.to_string())
812            .collect();
813        src_ids.sort();
814        hyd_ids.sort();
815        assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
816
817        for id_str in &src_ids {
818            let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
819            let id = crate::EntityId::new(v, slug);
820            let s = source.get_entity(&id).expect("source entity present");
821            let h = hydrated.get_entity(&id).expect("hydrated entity present");
822            assert_eq!(s.title, h.title, "title differs for {id_str}");
823            assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
824            assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
825            assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
826            assert_eq!(
827                s.content_hash, h.content_hash,
828                "content_hash differs for {id_str}",
829            );
830
831            let mut src_edges: Vec<_> = source
832                .store()
833                .outgoing(&id)
834                .iter()
835                .map(|e| (e.rel_type.clone(), e.target.to_string()))
836                .collect();
837            let mut hyd_edges: Vec<_> = hydrated
838                .store()
839                .outgoing(&id)
840                .iter()
841                .map(|e| (e.rel_type.clone(), e.target.to_string()))
842                .collect();
843            src_edges.sort();
844            hyd_edges.sort();
845            assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
846        }
847    }
848
849    /// Round-trip the engine state via export → hydrate, asserting
850    /// state equivalence against the input. Returns the hydrated
851    /// engine so individual tests can drive extra reads against it.
852    fn round_trip(source: &Engine, mem: &str) -> Engine {
853        let bytes = source.export_mem_to_bytes(mem).unwrap();
854        // The bytes pass the validator standalone — same invariant the
855        // bridge consumer relies on, asserted on every fixture so a
856        // future export change can't silently break ingress.
857        extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
858        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
859        assert_state_equivalent(source, &hydrated, mem);
860        hydrated
861    }
862
863    #[test]
864    fn fixture_sweep_round_trip_empty() {
865        let tmp = TempDir::new().unwrap();
866        let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
867        let hydrated = round_trip(&engine, "specs");
868        assert!(hydrated.store().is_empty());
869    }
870
871    #[test]
872    fn fixture_sweep_round_trip_single_entity() {
873        let tmp = TempDir::new().unwrap();
874        let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
875        round_trip(&engine, "specs");
876    }
877
878    #[test]
879    fn fixture_sweep_round_trip_multi_entity_no_relations() {
880        let tmp = TempDir::new().unwrap();
881        let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
882        round_trip(&engine, "specs");
883    }
884
885    #[test]
886    fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
887        let tmp = TempDir::new().unwrap();
888        let mem_dir = tmp.path().join("specs");
889        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
890        std::fs::write(
891            mem_dir.join(".memstead").join("config.json"),
892            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
893        )
894        .unwrap();
895        let writer = FilesystemMemWriter::new(mem_dir.clone());
896        let mut engine = Engine::from_mounts(vec![(
897            folder_mount("specs", mem_dir),
898            Box::new(writer) as Box<dyn MemBackend>,
899        )])
900        .unwrap();
901        let (actor, client) = cli_actor();
902
903        let mut sections = indexmap::IndexMap::new();
904        sections.insert("identity".to_string(), "A rich body.".to_string());
905        sections.insert(
906            "purpose".to_string(),
907            "To exercise the archive round-trip.".to_string(),
908        );
909        sections.insert(
910            "rationale".to_string(),
911            "Because the spec said so.".to_string(),
912        );
913
914        let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
915        metadata.insert("level".to_string(), "M0".to_string());
916
917        engine
918            .create_entity(
919                crate::engine::CreateEntityArgs {
920                    mem: "specs".to_string(),
921                    title: "Rich".to_string(),
922                    entity_type: "spec".to_string(),
923                    sections,
924                    metadata,
925                    relations: Vec::new(),
926                    dry_run: false,
927                },
928                actor,
929                Some(&client),
930                None,
931            )
932            .unwrap();
933        round_trip(&engine, "specs");
934    }
935
936    #[test]
937    fn fixture_sweep_round_trip_multi_entity_with_relations() {
938        let tmp = TempDir::new().unwrap();
939        let mem_dir = tmp.path().join("specs");
940        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
941        std::fs::write(
942            mem_dir.join(".memstead").join("config.json"),
943            r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
944        )
945        .unwrap();
946        let writer = FilesystemMemWriter::new(mem_dir.clone());
947        let mut engine = Engine::from_mounts(vec![(
948            folder_mount("specs", mem_dir),
949            Box::new(writer) as Box<dyn MemBackend>,
950        )])
951        .unwrap();
952        let (actor, client) = cli_actor();
953        let src = engine
954            .create_entity(
955                empty_create_args("specs", "Source"),
956                actor,
957                Some(&client),
958                None,
959            )
960            .unwrap();
961        let mid = engine
962            .create_entity(
963                empty_create_args("specs", "Middle"),
964                actor,
965                Some(&client),
966                None,
967            )
968            .unwrap();
969        let tgt = engine
970            .create_entity(
971                empty_create_args("specs", "Target"),
972                actor,
973                Some(&client),
974                None,
975            )
976            .unwrap();
977        // Two outgoing edges of different rel-types from the same
978        // source — the round-trip must preserve both.
979        engine
980            .relate_entity(
981                crate::engine::RelateEntityArgs {
982                    source: src.id.clone(),
983                    expected_hash: Some(src.content_hash.clone()),
984                    rel_type: "USES".to_string(),
985                    target: mid.id.clone(),
986                    remove: false,
987                    description: None,
988                },
989                actor,
990                Some(&client),
991                None,
992            )
993            .unwrap();
994        let src_after = engine
995            .get_entity(&src.id)
996            .expect("source must still resolve");
997        engine
998            .relate_entity(
999                crate::engine::RelateEntityArgs {
1000                    source: src.id.clone(),
1001                    expected_hash: Some(src_after.content_hash.clone()),
1002                    rel_type: "PART_OF".to_string(),
1003                    target: tgt.id.clone(),
1004                    remove: false,
1005                    description: None,
1006                },
1007                actor,
1008                Some(&client),
1009                None,
1010            )
1011            .unwrap();
1012        round_trip(&engine, "specs");
1013    }
1014
1015    #[test]
1016    fn read_entity_path_works_against_byte_backed_archive() {
1017        // Sanity: the byte-backed ArchiveBackend the hydrate path
1018        // constructs answers `read_entity` for every listed path.
1019        let tmp = TempDir::new().unwrap();
1020        let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
1021        let bytes = engine.export_mem_to_bytes("specs").unwrap();
1022        let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1023        let first = hydrated
1024            .get_entity(&crate::EntityId::new("specs", "first"))
1025            .expect("first must hydrate");
1026        assert_eq!(first.title, "First");
1027        // Path-based archive_path() returns None for byte-backed
1028        // backends — compile-time check that the contract holds.
1029        let backend = ArchiveBackend::from_bytes(Vec::new());
1030        let _: Option<&Path> = backend.archive_path();
1031    }
1032}