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