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