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