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