Skip to main content

memstead_base/engine/
export_html.rs

1//! `export --format html` (first-author-path plan 11): one
2//! self-contained HTML file per mem — the read surface for
3//! non-operators. A file you hand to a person: no server, no account,
4//! no installed anything.
5//!
6//! Hard lines:
7//! - **Self-contained**: zero network requests on open. External
8//!   *links* (`<a href>`) are passive and stay clickable; external
9//!   *resources* (images, media) are degraded to plain links naming
10//!   their target — user markdown citing a web image must not make
11//!   the export dial home. Inline styling only, one file, no asset
12//!   directories.
13//! - **Sanitised**: raw HTML in user markdown is escaped as text —
14//!   the export never embeds or executes user-supplied markup.
15//! - **Deterministic** given (store, export date): entities ordered
16//!   by (type, id); one date stamp; re-exports diff cleanly.
17//! - **Read-only projection of one mem**: cross-mem edges render as
18//!   labelled references, stubs render marked, and a read-only mount
19//!   exports with its trust class stated in the identity block.
20
21use std::collections::BTreeMap;
22use std::fmt::Write as _;
23
24use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
25
26use crate::engine::Engine;
27use crate::entity::Entity;
28use crate::workspace::MountCapability;
29
30/// Minimal HTML escaping for text and attribute positions.
31fn esc(s: &str) -> String {
32    s.replace('&', "&amp;")
33        .replace('<', "&lt;")
34        .replace('>', "&gt;")
35        .replace('"', "&quot;")
36}
37
38/// Resolve `[[target]]` wiki-links in a section body BEFORE markdown
39/// parsing: an in-mem target that exists becomes a markdown link to
40/// its in-document anchor; a cross-mem target becomes a labelled
41/// plain-text reference; a dangling target stays plain text (no
42/// dangling anchors, mechanically guaranteed).
43fn resolve_wiki_links(body: &str, mem: &str, exported_ids: &[String]) -> String {
44    let mut out = String::with_capacity(body.len());
45    let mut rest = body;
46    while let Some(start) = rest.find("[[") {
47        out.push_str(&rest[..start]);
48        let after = &rest[start + 2..];
49        match after.find("]]") {
50            None => {
51                out.push_str(&rest[start..]);
52                rest = "";
53                break;
54            }
55            Some(end) => {
56                let target = &after[..end];
57                let full_id = if target.contains("--") {
58                    target.to_string()
59                } else {
60                    format!("{mem}--{target}")
61                };
62                if exported_ids.iter().any(|id| id == &full_id) {
63                    // In-document anchor (markdown link keeps the
64                    // rendering pipeline uniform).
65                    let _ = write!(out, "[{target}](#{full_id})");
66                } else if full_id.starts_with(&format!("{mem}--")) {
67                    // Dangling in-mem reference — plain text, marked.
68                    let _ = write!(out, "{target} *(unresolved)*");
69                } else {
70                    // Cross-mem reference — labelled, never an anchor.
71                    let _ = write!(out, "{full_id} *(other mem)*");
72                }
73                rest = &after[end + 2..];
74            }
75        }
76    }
77    out.push_str(rest);
78    out
79}
80
81/// Percent-decode a URL fragment for comparison against raw entity
82/// ids (pulldown-cmark percent-encodes non-ASCII hrefs; the `id`
83/// attributes we emit stay raw UTF-8 — browsers decode fragments, so
84/// equality must too).
85fn percent_decode(s: &str) -> String {
86    let bytes = s.as_bytes();
87    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
88    let mut i = 0;
89    while i < bytes.len() {
90        if bytes[i] == b'%'
91            && i + 2 < bytes.len()
92            && let (Some(h), Some(l)) = (
93                (bytes[i + 1] as char).to_digit(16),
94                (bytes[i + 2] as char).to_digit(16),
95            )
96        {
97            out.push((h * 16 + l) as u8);
98            i += 3;
99        } else {
100            out.push(bytes[i]);
101            i += 1;
102        }
103    }
104    String::from_utf8_lossy(&out).into_owned()
105}
106
107/// Whether a link destination may render as a clickable `<a href>`.
108/// Passive web links (http/https/mailto) stay clickable; a fragment
109/// link is allowed only when it resolves to an exported entity id (no
110/// dangling in-document anchors, mechanically); every other scheme —
111/// `javascript:`, `data:`, `file:`, vendor schemes — is neutralised
112/// to text: the handed-over file must never execute user-supplied
113/// script, not even on click.
114fn link_dest_allowed(dest: &str, exported_ids: &[String]) -> bool {
115    if let Some(frag) = dest.strip_prefix('#') {
116        let decoded = percent_decode(frag);
117        return exported_ids.iter().any(|id| id == &decoded);
118    }
119    let lower = dest.trim().to_ascii_lowercase();
120    lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")
121}
122
123/// Render one markdown body to sanitised HTML: raw HTML becomes
124/// escaped text, images (external or not) degrade to plain links
125/// naming their target, link destinations outside the allowed set
126/// (http/https/mailto/resolvable fragments) neutralise to plain text,
127/// and everything else renders through the CommonMark machinery the
128/// engine already carries.
129fn markdown_to_safe_html(md: &str, exported_ids: &[String]) -> String {
130    let parser = Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH);
131    let mut events: Vec<Event> = Vec::new();
132    let mut skipping_image: Option<(String, String)> = None; // (url, alt)
133    // Depth-tracked suppression of disallowed links: the wrapper is
134    // dropped, inner text stays, the destination surfaces as text.
135    let mut suppressed_link: Option<String> = None;
136    for ev in parser {
137        if let Some((_url, alt)) = skipping_image.as_mut() {
138            match ev {
139                Event::End(TagEnd::Image) => {
140                    let (url, alt) = skipping_image.take().unwrap();
141                    let label = if alt.trim().is_empty() {
142                        format!("image: {url}")
143                    } else {
144                        format!("image: {alt} ({url})")
145                    };
146                    if link_dest_allowed(&url, exported_ids) {
147                        events.push(Event::Start(Tag::Link {
148                            link_type: pulldown_cmark::LinkType::Inline,
149                            dest_url: url.clone().into(),
150                            title: "".into(),
151                            id: "".into(),
152                        }));
153                        events.push(Event::Text(label.into()));
154                        events.push(Event::End(TagEnd::Link));
155                    } else {
156                        // Disallowed scheme on an image: text only.
157                        events.push(Event::Text(format!("[{label}]").into()));
158                    }
159                }
160                Event::Text(t) => alt.push_str(&t),
161                _ => {}
162            }
163            continue;
164        }
165        match ev {
166            // Raw HTML never passes through — escaped as visible text.
167            Event::Html(s) | Event::InlineHtml(s) => {
168                events.push(Event::Text(s));
169            }
170            // Images are resources: degrade to a labelled passive link.
171            Event::Start(Tag::Image { dest_url, .. }) => {
172                skipping_image = Some((dest_url.to_string(), String::new()));
173            }
174            Event::Start(Tag::Link { dest_url, .. }) if suppressed_link.is_none() => {
175                let dest = dest_url.to_string();
176                if link_dest_allowed(&dest, exported_ids) {
177                    events.push(Event::Start(Tag::Link {
178                        link_type: pulldown_cmark::LinkType::Inline,
179                        dest_url: dest.into(),
180                        title: "".into(),
181                        id: "".into(),
182                    }));
183                } else {
184                    suppressed_link = Some(dest);
185                }
186            }
187            Event::End(TagEnd::Link) if suppressed_link.is_some() => {
188                let dest = suppressed_link.take().unwrap();
189                events.push(Event::Text(format!(" ({dest} — link removed)").into()));
190            }
191            other => events.push(other),
192        }
193    }
194    let mut html = String::new();
195    pulldown_cmark::html::push_html(&mut html, events.into_iter());
196    html
197}
198
199impl Engine {
200    /// Render one mem as a single self-contained HTML document.
201    /// `export_date` is an ISO date stamped once in the identity
202    /// block — the only environmental input besides the store.
203    pub fn render_html_export(
204        &self,
205        mem: &str,
206        export_date: &str,
207    ) -> Result<String, crate::engine::EngineError> {
208        let mounted = self
209            .mounts
210            .iter()
211            .find(|m| m.mount.mem == mem)
212            .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
213        let third_party = mounted.mount.capability == MountCapability::ReadOnly;
214        let config = self.mem_config_for(mem);
215        let schema_ref = self
216            .schemas
217            .get(mem)
218            .map(|s| {
219                let (n, v) = s.id();
220                format!("{n}@{v}")
221            })
222            .unwrap_or_else(|| "(unresolved)".to_string());
223
224        // Entities of this mem, deterministic order: (type, id).
225        // Stubs are collected separately and rendered marked.
226        let mut entities: Vec<&Entity> = self
227            .store
228            .all_entities()
229            .filter(|e| e.mem == mem && !e.stub)
230            .collect();
231        entities.sort_by(|a, b| {
232            a.entity_type
233                .cmp(&b.entity_type)
234                .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
235        });
236        let mut stubs: Vec<&Entity> = self
237            .store
238            .all_entities()
239            .filter(|e| e.mem == mem && e.stub)
240            .collect();
241        stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
242        let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
243
244        let mut out = String::new();
245        out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
246        out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
247        let doc_title = config
248            .and_then(|c| c.title.clone())
249            .unwrap_or_else(|| mem.to_string());
250        let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
251        out.push_str(
252            "<style>\n\
253             body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
254             max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
255             h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
256             section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
257             table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
258             table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
259             table.meta td:first-child{color:#555;}\n\
260             nav ul{columns:2;list-style:none;padding-left:0;}\n\
261             nav li{margin:.15rem 0;}\n\
262             .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
263             border-radius:4px;font-size:.95rem;}\n\
264             .badge{display:inline-block;background:#eee;border-radius:3px;\
265             padding:0 .4rem;font-size:.8rem;color:#555;}\n\
266             .stub{color:#888;font-style:italic;}\n\
267             .reltable{font-size:.9rem;}\n\
268             @media print{nav ul{columns:1;}}\n\
269             </style>\n</head>\n<body>\n",
270        );
271
272        // Identity block.
273        let _ = write!(
274            out,
275            "<h1>{}</h1>\n<div class=\"identity\">\n",
276            esc(&doc_title)
277        );
278        let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
279        if let Some(desc) = config.and_then(|c| c.description.as_deref())
280            && !desc.is_empty()
281        {
282            let _ = writeln!(
283                out,
284                "<div><strong>Description:</strong> {}</div>",
285                esc(desc)
286            );
287        }
288        if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
289            let _ = writeln!(
290                out,
291                "<div><strong>Subject:</strong> {}</div>",
292                esc(&subject.scope)
293            );
294        }
295        let _ = writeln!(
296            out,
297            "<div><strong>Schema:</strong> {}</div>",
298            esc(&schema_ref)
299        );
300        let trust = if third_party {
301            "third-party (read-only mount — someone else's published content, quoted here)"
302        } else {
303            "first-party (writable mem of this workspace)"
304        };
305        let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
306        let _ = write!(
307            out,
308            "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
309            esc(export_date),
310            entities.len()
311        );
312
313        // Type-grouped navigation index.
314        let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
315        for e in &entities {
316            by_type.entry(e.entity_type.as_str()).or_default().push(e);
317        }
318        out.push_str("<nav>\n<h2>Index</h2>\n");
319        for (ty, list) in &by_type {
320            let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
321            for e in list {
322                let _ = writeln!(
323                    out,
324                    "<li><a href=\"#{}\">{}</a></li>",
325                    esc(e.id.as_ref()),
326                    esc(&e.title)
327                );
328            }
329            out.push_str("</ul>\n");
330        }
331        out.push_str("</nav>\n");
332
333        // Entities.
334        for e in &entities {
335            let _ = write!(
336                out,
337                "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
338                esc(e.id.as_ref()),
339                esc(&e.title),
340                esc(&e.entity_type),
341                esc(e.id.as_ref()),
342            );
343            if !e.metadata.is_empty() {
344                out.push_str("<table class=\"meta\">\n");
345                for (k, v) in &e.metadata {
346                    let _ = writeln!(
347                        out,
348                        "<tr><td>{}</td><td>{}</td></tr>",
349                        esc(k),
350                        esc(&v.to_frontmatter_string())
351                    );
352                }
353                out.push_str("</table>\n");
354            }
355            for (key, body) in &e.sections {
356                if body.trim().is_empty() {
357                    continue;
358                }
359                let _ = writeln!(out, "<h3>{}</h3>", esc(key));
360                let resolved = resolve_wiki_links(body, mem, &exported_ids);
361                out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
362            }
363            if !e.relationships.is_empty() {
364                out.push_str("<h3>relationships</h3>\n<ul class=\"reltable\">\n");
365                let mut rels = e.relationships.clone();
366                rels.sort_by(|a, b| {
367                    a.rel_type
368                        .cmp(&b.rel_type)
369                        .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
370                });
371                for r in &rels {
372                    let target_id = r.target.to_string();
373                    let in_doc = exported_ids.iter().any(|id| id == &target_id);
374                    let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
375                    if in_doc {
376                        let _ = writeln!(
377                            out,
378                            "<li>{} → <a href=\"#{}\">{}</a></li>",
379                            esc(&r.rel_type),
380                            esc(&target_id),
381                            esc(&target_id)
382                        );
383                    } else if is_stub_target {
384                        let _ = writeln!(
385                            out,
386                            "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
387                            esc(&r.rel_type),
388                            esc(&target_id)
389                        );
390                    } else {
391                        let _ = writeln!(
392                            out,
393                            "<li>{} → {} <span class=\"badge\">other mem</span></li>",
394                            esc(&r.rel_type),
395                            esc(&target_id)
396                        );
397                    }
398                }
399                out.push_str("</ul>\n");
400            }
401            out.push_str("</section>\n");
402        }
403
404        if !stubs.is_empty() {
405            out.push_str(
406                "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
407            );
408            for s in &stubs {
409                let _ = writeln!(
410                    out,
411                    "<li class=\"stub\">{} — referenced but never written</li>",
412                    esc(s.id.as_ref())
413                );
414            }
415            out.push_str("</ul>\n</section>\n");
416        }
417
418        out.push_str("</body>\n</html>\n");
419        Ok(out)
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use tempfile::TempDir;
427
428    use crate::backend::MemBackend;
429    use crate::engine::test_helpers::{cli_actor, folder_mount};
430    use crate::storage::FilesystemMemWriter;
431    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
432
433    /// Pre-boot on-disk fixture: the renderer is a read surface, so
434    /// the fixture is written as existing markdown (including a
435    /// cross-mem wiki-link and hostile content that the write path
436    /// would gate) and the engine boots over it.
437    fn fixture_engine(tmp: &TempDir) -> Engine {
438        let mem_dir = tmp.path().to_path_buf();
439        std::fs::write(
440            mem_dir.join("bösenberg-söhne-rev-21.md"),
441            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Bösenberg & Söhne — Rev. 2.1\n\n## Identity\n\nCited in [[target-entity]] and [[über-ziele]] and cross-mem [[other--far-away]].\n\n<script>alert('x')</script>\n\n![diagram](https://evil.example/x.png)\n\nSee [docs](https://example.org/page), [broken](#bogus-frag), [evil](javascript:alert(2)).\n\n## Purpose\n\nZweck mit Umlauten: äöüß.\n",
442        )
443        .unwrap();
444        std::fs::write(
445            mem_dir.join("über-ziele.md"),
446            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Über-Ziele\n\n## Identity\n\nUmlaut-slug anchor target.\n\n## Purpose\n\nP.\n",
447        )
448        .unwrap();
449        std::fs::write(
450            mem_dir.join("target-entity.md"),
451            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Target Entity\n\n## Identity\n\nThe link target.\n\n## Purpose\n\nAnchors resolve here.\n",
452        )
453        .unwrap();
454        let writer = FilesystemMemWriter::new(mem_dir.clone());
455        Engine::from_mounts(vec![(
456            folder_mount("specs", mem_dir),
457            Box::new(writer) as Box<dyn MemBackend>,
458        )])
459        .unwrap()
460    }
461
462    /// Every `href="#..."` in the document must point at an existing
463    /// `id="..."` — the no-dangling-anchor complement, mechanical.
464    fn assert_no_dangling_anchors(html: &str) {
465        let mut ids: Vec<&str> = Vec::new();
466        for part in html.split("id=\"").skip(1) {
467            if let Some(end) = part.find('"') {
468                ids.push(&part[..end]);
469            }
470        }
471        for part in html.split("href=\"#").skip(1) {
472            if let Some(end) = part.find('"') {
473                let anchor = percent_decode(&part[..end]);
474                assert!(
475                    ids.iter().any(|id| *id == anchor),
476                    "dangling in-document anchor #{anchor}"
477                );
478            }
479        }
480    }
481
482    /// Criteria 1–4 over one fixture: rendering, sanitisation,
483    /// resource degradation, anchors, determinism, localized change.
484    #[test]
485    fn html_export_renders_sanitises_and_stays_self_contained() {
486        let tmp = TempDir::new().unwrap();
487        let mut engine = fixture_engine(&tmp);
488
489        let html = engine.render_html_export("specs", "2026-08-10").unwrap();
490
491        // Identity block + index + entities.
492        assert!(
493            html.contains("<strong>Mem:</strong> specs"),
494            "identity block"
495        );
496        assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
497        assert!(html.contains("<nav>"), "type-grouped index");
498        assert!(
499            html.contains("Bösenberg &amp; Söhne — Rev. 2.1"),
500            "widened title escaped: {html}"
501        );
502        assert!(html.contains("äöüß"), "umlauts verbatim");
503
504        // Sanitisation: no script tag survives; the text is escaped.
505        assert!(!html.contains("<script>"), "raw HTML must not pass through");
506        assert!(html.contains("&lt;script&gt;"), "escaped as visible text");
507
508        // External image degraded to a passive link; external links stay.
509        assert!(!html.contains("<img"), "no image element: {html}");
510        assert!(
511            html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
512            "image degraded to labelled link: {html}"
513        );
514        assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
515
516        // Wiki-links: in-mem → anchor; cross-mem → labelled, no anchor.
517        assert!(
518            html.contains("href=\"#specs--target-entity\""),
519            "in-doc anchor"
520        );
521        assert!(html.contains("other--far-away"), "cross-mem labelled");
522        assert!(
523            !html.contains("href=\"#other--far-away\""),
524            "cross-mem never an anchor"
525        );
526
527        // Umlaut-slug wiki-link: pulldown percent-encodes the href;
528        // the checker decodes, so this is the form that used to be
529        // untested.
530        assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
531
532        // User fragment link to nowhere: neutralised to text — never
533        // a dangling anchor.
534        assert!(
535            !html.contains("href=\"#bogus-frag\""),
536            "dangling fragment neutralised"
537        );
538        assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
539
540        // javascript: scheme: never a clickable href — even on click,
541        // the handed-over file must not execute user script.
542        assert!(
543            !html.contains("href=\"javascript:"),
544            "javascript scheme stripped: {html}"
545        );
546        assert!(
547            html.contains("link removed"),
548            "neutralised destination surfaced"
549        );
550        assert_no_dangling_anchors(&html);
551
552        // Stub marking: the cross-mem auto-stub never lands in a
553        // folder mem without policy — but an in-mem stub does.
554        // (See the relationships list: targets that are stubs are
555        // marked; asserted in the wiki-link block above via absence.)
556
557        // Zero external resources: nothing in the markup fetches.
558        for fetching in [
559            "<img",
560            "<video",
561            "<audio",
562            "<iframe",
563            "<link ",
564            "<script src",
565            "@import",
566            "url(",
567        ] {
568            assert!(
569                !html.contains(fetching),
570                "self-containment violated by {fetching}"
571            );
572        }
573
574        // Determinism: same store + date → same bytes.
575        let again = engine.render_html_export("specs", "2026-08-10").unwrap();
576        assert_eq!(html, again, "byte-deterministic");
577
578        // Localized change: edit one entity; the untouched entity's
579        // section block stays byte-identical.
580        let untouched_block = {
581            let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
582            let end = html[start..].find("</section>").unwrap() + start;
583            html[start..end].to_string()
584        };
585        let (actor, client) = cli_actor();
586        let mut edit = crate::engine::UpdateEntityArgs {
587            anchors: Vec::new(),
588            id: crate::entity::EntityId::new("specs", "target-entity"),
589            expected_hash: None,
590            sections: indexmap::IndexMap::from_iter([(
591                "purpose".to_string(),
592                "Geändert.".to_string(),
593            )]),
594            append_sections: indexmap::IndexMap::new(),
595            patch_sections: indexmap::IndexMap::new(),
596            metadata: indexmap::IndexMap::new(),
597            metadata_unset: Vec::new(),
598            declare_relations: Vec::new(),
599            dry_run: false,
600            relations_unset: Vec::new(),
601            anchors_unset: Vec::new(),
602        };
603        let _ = &mut edit;
604        engine
605            .update_entity(edit, actor, Some(&client), None)
606            .expect("edit lands");
607        let after = engine.render_html_export("specs", "2026-08-10").unwrap();
608        assert_ne!(html, after, "edit changes the export");
609        assert!(
610            after.contains(&untouched_block),
611            "untouched entity's region byte-identical after the edit"
612        );
613        assert!(after.contains("Geändert."), "edited content present");
614    }
615
616    /// Criterion 5: a read-only mount exports with its trust class in
617    /// the identity block. Criterion 6's refusal parity: unknown mem
618    /// refuses UNKNOWN_MEM like the other formats.
619    #[test]
620    fn read_only_origin_stated_and_unknown_mem_refuses() {
621        let tmp = TempDir::new().unwrap();
622        let mem_dir = tmp.path().to_path_buf();
623        std::fs::write(
624            mem_dir.join("note.md"),
625            "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Foreign Note\n\n## Identity\n\nI.\n\n## Purpose\n\nP.\n",
626        )
627        .unwrap();
628        let writer = FilesystemMemWriter::new(mem_dir.clone());
629        let mount = Mount {
630            mem: "foreign".to_string(),
631            schema: Some("default@1.0.0".parse().unwrap()),
632            storage: MountStorage::Folder { path: mem_dir },
633            capability: MountCapability::ReadOnly,
634            lifecycle: MountLifecycle::Eager,
635            cross_linkable: true,
636            migration_target: None,
637        };
638        let engine =
639            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
640        let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
641        assert!(
642            html.contains("third-party (read-only mount"),
643            "trust class stated: {html}"
644        );
645
646        let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
647        assert_eq!(err.code(), "UNKNOWN_MEM");
648    }
649}