diff --git a/Cargo.toml b/Cargo.toml index e12333f..8a6f08a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/ogar-doc-ir", "crates/ogar-a2ui-frame", "crates/ogar-from-docv1", + "crates/ogar-render-typst", ] [workspace.package] diff --git a/crates/ogar-class-view/Cargo.toml b/crates/ogar-class-view/Cargo.toml index 7619f3d..7bd6e27 100644 --- a/crates/ogar-class-view/Cargo.toml +++ b/crates/ogar-class-view/Cargo.toml @@ -7,4 +7,5 @@ description = "Bridge crate: lifts `ogar_vocab::Class` (canonical AR shape, code [dependencies] ogar-vocab = { path = "../ogar-vocab" } -lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } +# FLOAT: branch = "main" → PR branch until lance-graph #776 merges (flip back to main then). +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "claude/ogar-docir-architecture-jjzlig" } diff --git a/crates/ogar-doc-ir/Cargo.toml b/crates/ogar-doc-ir/Cargo.toml index 0d661f3..abe4f32 100644 --- a/crates/ogar-doc-ir/Cargo.toml +++ b/crates/ogar-doc-ir/Cargo.toml @@ -20,7 +20,8 @@ serde_json = "1.0" # neutral IR never depends on any one consumer — the arrow points OUT, never in. # Wired via `git branch = "main"`, the same float-then-flip form the sibling # `ogar-class-view` / `ogar-from-ruff` / `ogar-render-askama` crates use. -lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main", optional = true } +# FLOAT: branch = "main" → PR branch until lance-graph #776 merges (flip back to main then). +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "claude/ogar-docir-architecture-jjzlig", optional = true } [features] default = [] diff --git a/crates/ogar-doc-ir/src/lib.rs b/crates/ogar-doc-ir/src/lib.rs index 182395c..4bb75f8 100644 --- a/crates/ogar-doc-ir/src/lib.rs +++ b/crates/ogar-doc-ir/src/lib.rs @@ -77,6 +77,8 @@ use serde::{Deserialize, Serialize}; pub mod compose; #[cfg(feature = "classview")] pub mod project; +#[cfg(feature = "classview")] +pub mod resolve; /// Perceptual-IR version marker. [`from_json`] refuses any other value. /// A `doc.v2` is a NEW marker + (possibly) new [`RegionKind`]s, never a diff --git a/crates/ogar-doc-ir/src/resolve.rs b/crates/ogar-doc-ir/src/resolve.rs new file mode 100644 index 0000000..ecbf4c4 --- /dev/null +++ b/crates/ogar-doc-ir/src/resolve.rs @@ -0,0 +1,536 @@ +//! `resolve` — the **ObjectSlot resolver**: a composed document +//! ([`DocCompose`]) becomes a renderer-neutral [`ResolvedDoc`] by resolving +//! every slot through the contract's rail walk +//! (`lance_graph_contract::selection::walk_rails`). +//! +//! # What this closes +//! +//! The composition brick ([`crate::compose`]) stores *addresses and projection +//! choices*; this module is the missing verb: **resolve**. Each +//! [`ObjectSlot`] resolves to its live object (or its snapshot fallback), +//! projects it through its named view (`ClassView × WideFieldMask` — the +//! contract's `ViewRegistry` masks), and emits plain `(label, value)` rows +//! that ANY renderer consumes — the askama HTML surface and the Typst page +//! drink from the same [`ResolvedDoc`]. The ActionText discipline holds end +//! to end: the document stored a typed reference; the object's CURRENT state +//! is read at resolve time; a missing object renders its explicit fallback. +//! +//! # Dependency inversion (house shape) +//! +//! The resolver owns none of the domain: the consumer implements +//! [`DocObjectSource`] (its object graph via the contract's `RailGraph`, its +//! value store, its view bindings), exactly as `PlannerContract` / +//! `MailboxSoaView` invert. An observation document (a tesseract-rs `doc.v1`) +//! participates by being a NODE of the consumer's graph whose presence/values +//! come from [`crate::project`] (`field_mask` / `masked_values`) — the retina +//! and the live objects flow through the SAME walk. +//! +//! # Nesting = masks × the walk (operator-ruled) +//! +//! There is no query document anywhere: a slot names a view (a persisted mask +//! constant); nested projection is the rail walk — at each hop the target's +//! own classid resolves its own view via [`DocObjectSource::view_for_class`]. +//! The slot's wire masks (`field_mask`/`wide_mask_words`) are the persisted +//! copy of the named view's recipe; the registry is authoritative at resolve +//! time (persisted-query semantics). Slot-local narrowing is future `DocOp` +//! territory, deliberately not invented here. + +use lance_graph_contract::class_view::{ClassId, ClassView}; +use lance_graph_contract::selection::{RailGraph, ViewId, ViewRegistry, walk_rails}; + +use crate::compose::{ + ComposeError, DocCompose, DocNode, NodeId, ObjectRef, ObjectSlot, ResolutionMode, + format_ogar_uri, +}; + +/// One projected field of a resolved slot — renderer-neutral `(label, value)` +/// plus its mask `position` (the layout address) and rail-walk `depth` +/// (root = 0; a nested `assignee { name }` row carries depth 1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedField { + /// The field's mask position — its layout address. + pub position: u8, + /// Nesting depth (root object = 0; each rail hop adds 1). + pub depth: usize, + /// Display label, late-resolved from the `ClassView` field basis. + pub label: String, + /// Formatted value text, read from the consumer's store at resolve time. + pub value: String, +} + +/// How one slot resolved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SlotOutcome { + /// The target resolved; `fields` are the projected rows in walk order. + Resolved { + /// The root object's class (its own classid chose the projection). + class: ClassId, + /// The projected rows (view mask ∩ presence, nested via rails). + fields: Vec, + }, + /// The target was unresolvable and the slot carried a snapshot fallback — + /// the explicit ActionText missing-object path, surfaced as the + /// content-address (hex sha256) for the renderer to show. + Fallback { + /// Lowercase hex of the snapshot's sha256. + content_sha256_hex: String, + }, + /// The target was unresolvable and NO fallback exists (or the named view + /// is unknown). Renderers must show an explicit marker — never silence. + Unresolvable, +} + +/// One resolved slot: the address it came from, the named projection it asked +/// for, and the outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedSlot { + /// The slot's `ogar://` address (with its resolution mode) — provenance. + pub uri: String, + /// The named view (`"user.card"`, …) the slot asked for. + pub class_view: String, + /// What resolution produced. + pub outcome: SlotOutcome, +} + +/// One block of the resolved document, in document order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedBlock { + /// A section heading's text. + Heading(String), + /// A run of plain text. + Text(String), + /// A resolved projection portal. + Slot(ResolvedSlot), +} + +/// The renderer-neutral resolved document: what every renderer consumes. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ResolvedDoc { + /// Blocks in document order. + pub blocks: Vec, +} + +/// The consumer-owned resolution surface — dependency inversion: the resolver +/// owns the *procedure*, the consumer owns the graph, the values, and the +/// view bindings. +pub trait DocObjectSource { + /// The consumer's object graph (the contract's `RailGraph`). + type Graph: RailGraph; + + /// The graph itself. + fn graph(&self) -> &Self::Graph; + + /// Resolve a slot's target address (honouring its [`ResolutionMode`] — + /// `Live` reads head; `Revision`/`Snapshot` read pinned state) to a graph + /// key, or `None` when the object is gone/unknown (→ fallback path). + fn lookup( + &self, + target: &ObjectRef, + mode: &ResolutionMode, + ) -> Option<::Key>; + + /// The formatted value of `key`'s field at `position`, or `None` for a + /// position with no scalar display value (e.g. a rail position — its + /// content is the nested rows, not a cell of its own). + fn value_of(&self, key: ::Key, position: u8) -> Option; + + /// Resolve a slot's named view (`"user.card"`) to a registry id. + fn view_by_name(&self, name: &str) -> Option; + + /// The view a NESTED hop's class projects through (the per-class binding + /// the walk uses below the root). `None` prunes that subtree. + fn view_for_class(&self, class: ClassId) -> Option; +} + +/// Resolve a composed document into renderer-neutral blocks. +/// +/// Validates the composition first (same gate as [`DocCompose::validate`]); +/// then walks the node tree in document order, resolving every +/// [`DocNode::ObjectSlot`] via [`resolve_slot`]. +/// +/// # Errors +/// +/// Propagates [`ComposeError`] from validation — a malformed composition is +/// refused loudly, never partially rendered. +pub fn resolve_doc( + doc: &DocCompose, + source: &S, + class_view: &V, + registry: &ViewRegistry, + max_depth: usize, +) -> Result +where + S: DocObjectSource, + V: ClassView, +{ + doc.validate()?; + let mut out = ResolvedDoc::default(); + walk_doc_node( + doc, doc.root, source, class_view, registry, max_depth, &mut out, + ); + Ok(out) +} + +/// Resolve one slot: target → (walk → rows) | fallback | unresolvable. +pub fn resolve_slot( + slot: &ObjectSlot, + source: &S, + class_view: &V, + registry: &ViewRegistry, + max_depth: usize, +) -> ResolvedSlot +where + S: DocObjectSource, + V: ClassView, +{ + let uri = format_ogar_uri(&slot.target, &slot.resolution); + + // Root-view/class agreement (codex P2 on #222): the slot's named view must + // project the ROOT's own class. A stale or cross-class view name would + // otherwise yield a silently-EMPTY `Resolved` (the walker prunes the + // foreign mask fail-closed), which reads as "object rendered with no + // fields" instead of "slot did not resolve". Fail closed into the + // fallback/unresolvable arm so the outcome says what actually happened. + let resolved = match ( + source.lookup(&slot.target, &slot.resolution), + source.view_by_name(&slot.class_view), + ) { + (Some(root), Some(root_view)) => { + let root_class = source.graph().class_of(root); + registry + .get(root_view) + .is_some_and(|v| v.class == root_class) + .then_some((root, root_view, root_class)) + } + _ => None, + }; + + let outcome = match resolved { + Some((root, root_view, root_class)) => { + let graph = source.graph(); + let mut fields = Vec::new(); + walk_rails( + graph, + class_view, + registry, + // The slot's named view governs the ROOT; nested hops resolve + // per-class via the consumer's binding. + |class| { + if class == root_class { + Some(root_view) + } else { + source.view_for_class(class) + } + }, + root, + max_depth, + &mut |visit| { + // A position with no scalar value (a rail) contributes its + // nested rows instead of a cell of its own. + if let Some(value) = source.value_of(visit.key, visit.position) { + let label = class_view + .field_label(visit.class, visit.position) + .unwrap_or("") + .to_string(); + fields.push(ResolvedField { + position: visit.position, + depth: visit.depth, + label, + value, + }); + } + }, + ); + SlotOutcome::Resolved { + class: root_class, + fields, + } + } + _ => match &slot.fallback { + Some(snap) => SlotOutcome::Fallback { + content_sha256_hex: hex32(&snap.content_sha256), + }, + None => SlotOutcome::Unresolvable, + }, + }; + + ResolvedSlot { + uri, + class_view: slot.class_view.clone(), + outcome, + } +} + +fn walk_doc_node( + doc: &DocCompose, + id: NodeId, + source: &S, + class_view: &V, + registry: &ViewRegistry, + max_depth: usize, + out: &mut ResolvedDoc, +) where + S: DocObjectSource, + V: ClassView, +{ + let Some(node) = doc.nodes.get(id.0 as usize) else { + return; // validate() already refused out-of-range ids + }; + match node { + DocNode::Document { children } => { + for &c in children { + walk_doc_node(doc, c, source, class_view, registry, max_depth, out); + } + } + DocNode::Section { heading, children } => { + if let Some(h) = heading + && let Some(DocNode::Text { text }) = doc.nodes.get(h.0 as usize) + { + out.blocks.push(ResolvedBlock::Heading(text.clone())); + } + for &c in children { + walk_doc_node(doc, c, source, class_view, registry, max_depth, out); + } + } + DocNode::Paragraph { children } => { + for &c in children { + walk_doc_node(doc, c, source, class_view, registry, max_depth, out); + } + } + DocNode::Text { text } => out.blocks.push(ResolvedBlock::Text(text.clone())), + DocNode::ObjectSlot { slot } => out.blocks.push(ResolvedBlock::Slot(resolve_slot( + slot, source, class_view, registry, max_depth, + ))), + } +} + +/// Lowercase hex of a 32-byte digest (zero-dep; the fallback surface). +fn hex32(bytes: &[u8; 32]) -> String { + let mut s = String::with_capacity(64); + for b in bytes { + use core::fmt::Write; + let _ = write!(s, "{b:02x}"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compose::SnapshotRef; + use lance_graph_contract::class_view::WideFieldMask; + use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; + use lance_graph_contract::selection::NamedView; + + const USER: ClassId = 1; + const WP: ClassId = 2; + + struct TestView; + impl ClassView for TestView { + fn fields(&self, class: ClassId) -> &[FieldRef] { + use std::sync::OnceLock; + static USER_F: OnceLock> = OnceLock::new(); + static WP_F: OnceLock> = OnceLock::new(); + match class { + USER => USER_F.get_or_init(|| { + vec![ + FieldRef::new("u:name", "name"), + FieldRef::new("u:email", "email"), + ] + }), + _ => WP_F.get_or_init(|| { + vec![ + FieldRef::new("wp:subject", "subject"), + FieldRef::new("wp:assignee", "assignee"), + ] + }), + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Card + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } + } + + #[derive(Clone, Copy, PartialEq, Eq, Hash)] + enum K { + Alice, + Wp1, + } + + struct G; + impl RailGraph for G { + type Key = K; + fn class_of(&self, key: K) -> ClassId { + match key { + K::Alice => USER, + K::Wp1 => WP, + } + } + fn present_mask(&self, _key: K) -> WideFieldMask { + WideFieldMask::from(0b11) + } + fn rail_target(&self, key: K, position: u8) -> Option { + match (key, position) { + (K::Wp1, 1) => Some(K::Alice), + _ => None, + } + } + } + + struct Src { + registry_names: Vec<(&'static str, ViewId)>, + graph: G, + } + impl DocObjectSource for Src { + type Graph = G; + fn graph(&self) -> &G { + &self.graph + } + fn lookup(&self, target: &ObjectRef, _mode: &ResolutionMode) -> Option { + match target.id.as_str() { + "alice" => Some(K::Alice), + "wp1" => Some(K::Wp1), + _ => None, + } + } + fn value_of(&self, key: K, position: u8) -> Option { + match (key, position) { + (K::Alice, 0) => Some("Alice".into()), + (K::Alice, 1) => Some("alice@example.test".into()), + (K::Wp1, 0) => Some("Install fire door".into()), + (K::Wp1, 1) => None, // rail position: no scalar cell + _ => None, + } + } + fn view_by_name(&self, name: &str) -> Option { + self.registry_names + .iter() + .find(|(n, _)| *n == name) + .map(|(_, id)| *id) + } + fn view_for_class(&self, class: ClassId) -> Option { + match class { + USER => self.view_by_name("user.inline"), + _ => None, + } + } + } + + fn slot(target_id: &str, view: &str, fallback: Option) -> ObjectSlot { + ObjectSlot { + target: ObjectRef { + app: "openproject".into(), + class: "work-package".into(), + id: target_id.into(), + }, + class_view: view.into(), + field_mask: 0, + wide_mask_words: vec![], + resolution: ResolutionMode::Live, + fallback, + } + } + + fn setup() -> (Src, ViewRegistry) { + let mut registry = ViewRegistry::new(); + let inline = registry.register(NamedView::new( + USER, + WideFieldMask::from(0b01), // name only + DisplayTemplate::Card, + )); + let summary = registry.register(NamedView::new( + WP, + WideFieldMask::from(0b11), // subject + assignee rail + DisplayTemplate::Detail, + )); + let src = Src { + registry_names: vec![("user.inline", inline), ("work_package.summary", summary)], + graph: G, + }; + (src, registry) + } + + #[test] + fn slot_resolves_with_nested_rail_hop() { + let (src, registry) = setup(); + let s = slot("wp1", "work_package.summary", None); + let r = resolve_slot(&s, &src, &TestView, ®istry, 4); + let SlotOutcome::Resolved { class, fields } = &r.outcome else { + panic!("expected Resolved, got {:?}", r.outcome); + }; + assert_eq!(*class, WP); + // subject at depth 0, nested alice name at depth 1; the rail position + // itself contributes no cell; email is masked out by user.inline. + assert_eq!(fields.len(), 2); + assert_eq!((fields[0].label.as_str(), fields[0].depth), ("subject", 0)); + assert_eq!(fields[0].value, "Install fire door"); + assert_eq!((fields[1].label.as_str(), fields[1].depth), ("name", 1)); + assert_eq!(fields[1].value, "Alice"); + } + + #[test] + fn missing_target_uses_snapshot_fallback() { + let (src, registry) = setup(); + let s = slot( + "ghost", + "work_package.summary", + Some(SnapshotRef { + content_sha256: [0xAB; 32], + }), + ); + let r = resolve_slot(&s, &src, &TestView, ®istry, 4); + assert_eq!( + r.outcome, + SlotOutcome::Fallback { + content_sha256_hex: "ab".repeat(32) + } + ); + } + + #[test] + fn missing_target_without_fallback_is_unresolvable() { + let (src, registry) = setup(); + let s = slot("ghost", "work_package.summary", None); + let r = resolve_slot(&s, &src, &TestView, ®istry, 4); + assert_eq!(r.outcome, SlotOutcome::Unresolvable); + } + + #[test] + fn unknown_view_is_unresolvable_not_a_panic() { + let (src, registry) = setup(); + let s = slot("wp1", "no.such.view", None); + let r = resolve_slot(&s, &src, &TestView, ®istry, 4); + assert_eq!(r.outcome, SlotOutcome::Unresolvable); + } + + /// Falsifier (codex P2 on #222): a slot whose named view projects a + /// DIFFERENT class than its target must fail closed — Unresolvable (or the + /// fallback), never a silently-EMPTY `Resolved`. + #[test] + fn cross_class_root_view_is_unresolvable_not_empty_resolved() { + let (src, registry) = setup(); + // Target wp1 (class WP) with the USER-class "user.inline" view. + let s = slot("wp1", "user.inline", None); + let r = resolve_slot(&s, &src, &TestView, ®istry, 4); + assert_eq!(r.outcome, SlotOutcome::Unresolvable); + + // With a fallback attached, the same mismatch takes the fallback arm. + let s = slot( + "wp1", + "user.inline", + Some(SnapshotRef { + content_sha256: [0xCD; 32], + }), + ); + let r = resolve_slot(&s, &src, &TestView, ®istry, 4); + assert_eq!( + r.outcome, + SlotOutcome::Fallback { + content_sha256_hex: "cd".repeat(32) + } + ); + } +} diff --git a/crates/ogar-from-ruff/Cargo.toml b/crates/ogar-from-ruff/Cargo.toml index b4989a0..2196ae8 100644 --- a/crates/ogar-from-ruff/Cargo.toml +++ b/crates/ogar-from-ruff/Cargo.toml @@ -22,7 +22,8 @@ ogar-vocab = { path = "../ogar-vocab" } # The zero-dep contract carries the V3 byte surface (FacetCascade / NodeGuid / # NodeRow / NodeRowPacket / ValueTenant). Git-floats on `main` like the two # `ruff_spo_*` deps + ogar-class-view's own contract dep (D-NEVER-PIN-BUMP). -lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main", optional = true } +# FLOAT: branch = "main" → PR branch until lance-graph #776 merges (flip back to main then). +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "claude/ogar-docir-architecture-jjzlig", optional = true } # Operator ruling: rev pins on AdaWorldAPI-internal git deps are retired — # they were "out of desperation to lock what hadn't converged in transpiler # sink-in yet." Drift protection is the fuse tests, not pins. Floats on the diff --git a/crates/ogar-render-askama/Cargo.toml b/crates/ogar-render-askama/Cargo.toml index 262e467..bad7225 100644 --- a/crates/ogar-render-askama/Cargo.toml +++ b/crates/ogar-render-askama/Cargo.toml @@ -7,7 +7,8 @@ description = "Build-time askama codegen harness over the canonical layer — on [dependencies] ogar-vocab = { path = "../ogar-vocab" } -lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } +# FLOAT: branch = "main" → PR branch until lance-graph #776 merges (flip back to main then). +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "claude/ogar-docir-architecture-jjzlig" } askama = "0.12" [dev-dependencies] diff --git a/crates/ogar-render-typst/Cargo.toml b/crates/ogar-render-typst/Cargo.toml new file mode 100644 index 0000000..1fd81c9 --- /dev/null +++ b/crates/ogar-render-typst/Cargo.toml @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: MIT +[package] +name = "ogar-render-typst" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Typst SOURCE emitter over the same FieldView rows the askama surface renders — the paged/archival projection of a resolved OGAR document (one surface, two renderers). Emits .typ text only; no typst compiler dependency." + +[dependencies] +# ONE SURFACE, TWO RENDERERS: this crate consumes the SAME `FieldView` rows the +# askama HTML surface renders (the a2ui #207 brick). It emits Typst SOURCE text +# — the paged/`@revision`-pinned projection — and deliberately does NOT embed +# the typst compiler (a heavy dep tree); compiling .typ → PDF is the consumer's +# egress step. +ogar-render-askama = { path = "../ogar-render-askama" } + +[dev-dependencies] +# The dual-render proof (tests/dual_render.rs): one resolved DocCompose → +# askama HTML AND Typst source, same rows, plus a tesseract-rs doc.v1 +# observation participating via ogar-doc-ir's project masking. +ogar-doc-ir = { path = "../ogar-doc-ir", features = ["classview"] } +# FLOAT: branch = "main" → PR branch until lance-graph #776 merges (flip back to main then). +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "claude/ogar-docir-architecture-jjzlig" } diff --git a/crates/ogar-render-typst/src/lib.rs b/crates/ogar-render-typst/src/lib.rs new file mode 100644 index 0000000..bae510d --- /dev/null +++ b/crates/ogar-render-typst/src/lib.rs @@ -0,0 +1,179 @@ +//! `ogar-render-typst` — the **paged projection**: emit Typst SOURCE from the +//! same [`FieldView`] rows the askama HTML surface renders. +//! +//! # One surface, two renderers +//! +//! The askama crate renders a projected `ClassView × WideFieldMask` instance as +//! a *living screen* (`render_field_view`, `@live`). This crate renders the +//! SAME rows as a *page* — the record projection: signed reports, invoices, +//! planning baselines, the `@revision:N`-pinned documents of the composition +//! layer's `ResolutionMode`. Nothing here queries a domain store; the rows +//! arrive already resolved (the renderer-independence hinge). +//! +//! # Source emission only — deliberately +//! +//! This crate emits `.typ` TEXT and stops. Emitting markup source is render +//! egress exactly like askama emitting HTML text; embedding the typst +//! *compiler* (fonts, layout, a heavy dep tree) is a separate, feature-gated +//! decision for a consumer that wants in-binary PDF. Keeping the emitter +//! text-only keeps this crate as light as the surface it mirrors. + +use ogar_render_askama::FieldView; + +/// Escape a text run for safe interpolation into Typst markup. +/// +/// Typst's active characters in markup context are escaped with a backslash — +/// the set below covers the markup-significant ASCII characters (Typst accepts +/// a backslash escape before any of them). Newlines are preserved. +/// +/// Additionally, Typst recognizes `=` (heading), `-` (list item), and `+` +/// (enum item) as BLOCK markers at the start of a line: user data beginning a +/// line with one of them would otherwise become a heading/list in the emitted +/// page (codex P2 on #222). They are escaped in line-start position only — +/// mid-line they are inert and stay readable. The escape is conservative at +/// the start of the string too: `\=` renders identically to `=` when the +/// context turns out to be mid-line. +#[must_use] +pub fn escape_typst(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut at_line_start = true; + for c in s.chars() { + match c { + // Markup-active characters in Typst content mode. + '\\' | '#' | '*' | '_' | '`' | '$' | '<' | '>' | '@' | '[' | ']' | '/' | '~' | '\'' + | '"' => { + out.push('\\'); + out.push(c); + at_line_start = false; + } + // Line-start block markers: heading / list / enum. + '=' | '-' | '+' if at_line_start => { + out.push('\\'); + out.push(c); + at_line_start = false; + } + '\n' => { + out.push(c); + at_line_start = true; + } + _ => { + out.push(c); + at_line_start = false; + } + } + } + out +} + +/// Emit one resolved field-view as a Typst fragment: a bold caption line and a +/// two-column table of `(label, value)` rows — the paged sibling of the askama +/// `
` surface. `class_view` is stamped as a small +/// caption suffix so the page carries its projection provenance (which named +/// view produced it), mirroring the HTML surface's `data-*` addressing. +#[must_use] +pub fn emit_field_view(class_view: &str, title: &str, fields: &[FieldView]) -> String { + let mut out = String::new(); + out.push_str("#block[\n"); + out.push_str(&format!( + "*{}* #text(size: 0.8em)[({})]\n", + escape_typst(title), + escape_typst(class_view) + )); + out.push_str("#table(\n columns: 2,\n"); + for f in fields { + out.push_str(&format!( + " [{}], [{}],\n", + escape_typst(&f.label), + escape_typst(&f.value) + )); + } + out.push_str(")\n]\n"); + out +} + +/// Emit an explicit unresolved-slot marker — the paged form of the ActionText +/// missing-object fallback: the snapshot's content address is shown, never +/// silently dropped. +#[must_use] +pub fn emit_fallback(class_view: &str, content_sha256_hex: &str) -> String { + format!( + "#block[\n_unresolved {}_ — snapshot #raw(\"sha256:{}\")\n]\n", + escape_typst(class_view), + escape_typst(content_sha256_hex) + ) +} + +/// Emit a section heading. +#[must_use] +pub fn emit_heading(text: &str) -> String { + format!("= {}\n", escape_typst(text)) +} + +/// Emit a plain text run as a paragraph line. +#[must_use] +pub fn emit_text(text: &str) -> String { + format!("{}\n", escape_typst(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fv(position: u8, label: &str, value: &str) -> FieldView { + FieldView { + position, + label: label.into(), + predicate: String::new(), + value: value.into(), + } + } + + #[test] + fn field_view_fragment_carries_labels_values_and_provenance() { + let t = emit_field_view( + "user.card", + "Alice", + &[fv(0, "name", "Alice"), fv(2, "role", "engineer")], + ); + assert!(t.contains("*Alice*")); + assert!(t.contains("(user.card)")); + assert!(t.contains("[name], [Alice],")); + assert!(t.contains("[role], [engineer],")); + } + + #[test] + fn markup_active_characters_are_escaped() { + let t = emit_field_view("v", "T", &[fv(0, "amount", "#100 *net* [EUR] @q3")]); + assert!(t.contains(r"\#100 \*net\* \[EUR\] \@q3"), "got: {t}"); + // and the structural table brackets are still intact + assert!(t.contains("#table(")); + } + + #[test] + fn fallback_marker_is_explicit() { + let t = emit_fallback("work_package.inline", &"ab".repeat(32)); + assert!(t.contains("unresolved")); + assert!(t.contains("sha256:abab")); + } + + #[test] + fn heading_and_text_escape() { + assert_eq!(emit_heading("A & B"), "= A & B\n"); + assert!(emit_text("cost: $5").contains(r"\$5")); + } + + /// Falsifier (codex P2 on #222): user data starting a line with a Typst + /// block marker must not become a heading/list/enum in the emitted page. + #[test] + fn line_start_block_markers_are_escaped() { + // Start of string. + assert_eq!(emit_text("= fake heading"), "\\= fake heading\n"); + // After an embedded newline — each line start is guarded. + assert_eq!(emit_text("- item\n+ item"), "\\- item\n\\+ item\n"); + // Mid-line the same characters are inert and stay unescaped. + assert_eq!(emit_text("a - b = c + d"), "a - b = c + d\n"); + // A multi-line value inside a field view cannot smuggle a heading in. + let t = emit_field_view("v", "T", &[fv(0, "note", "x\n= injected")]); + assert!(t.contains("x\n\\= injected"), "got: {t}"); + } +} diff --git a/crates/ogar-render-typst/tests/dual_render.rs b/crates/ogar-render-typst/tests/dual_render.rs new file mode 100644 index 0000000..80171cd --- /dev/null +++ b/crates/ogar-render-typst/tests/dual_render.rs @@ -0,0 +1,562 @@ +//! **The dual-render proof** — the initial-prompt vertical slice, executable: +//! +//! ONE composed document (`DocCompose`) containing typed projection portals — +//! a `User`, a `WorkPackage` (whose summary view rail-hops to its assignee), +//! an `Attachment`, a tesseract-rs `doc.v1` OBSERVATION (participating through +//! `ogar_doc_ir::project` masking — retina → same walk), and one DELETED +//! target exercising the snapshot fallback — is resolved ONCE +//! (`ogar_doc_ir::resolve`, riding `lance_graph_contract::selection`), then +//! rendered through TWO genuinely different projections from the SAME rows: +//! +//! - the askama HTML surface (`ogar_render_askama::render_field_view`, `@live`) +//! - the Typst page source (`ogar_render_typst`, the `@revision`/archival leg) +//! +//! Renderer independence is the assertion: both outputs carry the same facts, +//! both omit the same masked-out fields, and neither renderer touched a +//! domain store. + +use ogar_doc_ir::compose::{ + DOC_COMPOSE_VERSION, DocCompose, DocNode, NodeId, ObjectRef, ObjectSlot, ResolutionMode, + SnapshotRef, +}; +use ogar_doc_ir::project; +use ogar_doc_ir::resolve::{ + DocObjectSource, ResolvedBlock, ResolvedSlot, SlotOutcome, resolve_doc, +}; +use ogar_doc_ir::{BBoxRail, DOC_IR_VERSION, DocIr, Geometry, Provenance, Rail, TypedField}; +use ogar_render_askama::{FieldView, render_field_view}; +use ogar_render_typst as typst; + +use lance_graph_contract::class_view::{ClassId, ClassView, WideFieldMask}; +use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; +use lance_graph_contract::selection::{NamedView, RailGraph, ViewId, ViewRegistry}; + +// ── the slice's classes ──────────────────────────────────────────────────── + +const USER: ClassId = 1; +const WORK_PACKAGE: ClassId = 2; +const ATTACHMENT: ClassId = 3; +const DOC_SCAN: ClassId = 4; // the observation document class + +struct SliceView; +impl ClassView for SliceView { + fn fields(&self, class: ClassId) -> &[FieldRef] { + use std::sync::OnceLock; + static USER_F: OnceLock> = OnceLock::new(); + static WP_F: OnceLock> = OnceLock::new(); + static ATT_F: OnceLock> = OnceLock::new(); + static SCAN_F: OnceLock> = OnceLock::new(); + match class { + USER => USER_F.get_or_init(|| { + vec![ + FieldRef::new("u:name", "name"), + FieldRef::new("u:email", "email"), + FieldRef::new("u:role", "role"), + ] + }), + WORK_PACKAGE => WP_F.get_or_init(|| { + vec![ + FieldRef::new("wp:subject", "subject"), + FieldRef::new("wp:status", "status"), + FieldRef::new("wp:assignee", "assignee"), + FieldRef::new("wp:progress", "progress"), + ] + }), + ATTACHMENT => ATT_F.get_or_init(|| { + vec![ + FieldRef::new("att:filename", "filename"), + FieldRef::new("att:size", "size"), + ] + }), + _ => SCAN_F.get_or_init(|| { + vec![ + FieldRef::new("inv:netto", "netto"), + FieldRef::new("inv:ust", "ust"), + FieldRef::new("inv:iban", "iban"), + ] + }), + } + } + fn template(&self, _class: ClassId) -> DisplayTemplate { + DisplayTemplate::Card + } + fn dolce_category_id(&self, _class: ClassId) -> u8 { + 0 + } +} + +// ── the consumer's object graph (live objects + the observation node) ────── + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Key { + Alice, + Wp1, + Spec, + Invoice, // the tesseract-rs doc.v1 observation, as a graph node +} + +/// The scanned invoice as the pixel retina reported it — a `doc.v1` with +/// `netto` and `iban` READ but `ust` NOT read (absent = cleared presence bit, +/// never a sentinel). +fn invoice_observation() -> DocIr { + let bbox = BBoxRail { + tl: Rail { x: 0, y: 0 }, + br: Rail { x: 40, y: 8 }, + }; + DocIr { + version: DOC_IR_VERSION.to_string(), + source: Provenance::Ocr, + geometry: Geometry::Rendered, + content_sha256: [0x51; 32], + mime: "image/png".to_string(), + pages: vec![], + fields: vec![ + TypedField { + key: "netto".into(), + value: "100.00".into(), + bbox, + confidence: 214, + }, + TypedField { + key: "iban".into(), + value: "DE00 0000 0000 0000 0000 00".into(), + bbox, + confidence: 201, + }, + ], + } +} + +struct SliceGraph { + invoice: DocIr, +} + +impl RailGraph for SliceGraph { + type Key = Key; + fn class_of(&self, key: Key) -> ClassId { + match key { + Key::Alice => USER, + Key::Wp1 => WORK_PACKAGE, + Key::Spec => ATTACHMENT, + Key::Invoice => DOC_SCAN, + } + } + fn present_mask(&self, key: Key) -> WideFieldMask { + match key { + Key::Alice => WideFieldMask::from(0b111), + Key::Wp1 => WideFieldMask::from(0b1111), + Key::Spec => WideFieldMask::from(0b11), + // The observation's presence comes from the RETINA, through the + // existing project masking: netto+iban present, ust ABSENT. + Key::Invoice => project::field_mask(&self.invoice, &SliceView, DOC_SCAN), + } + } + fn rail_target(&self, key: Key, position: u8) -> Option { + match (key, position) { + (Key::Wp1, 2) => Some(Key::Alice), // assignee rail + _ => None, + } + } +} + +struct SliceSource { + graph: SliceGraph, + names: Vec<(&'static str, ViewId)>, +} + +impl DocObjectSource for SliceSource { + type Graph = SliceGraph; + fn graph(&self) -> &SliceGraph { + &self.graph + } + fn lookup(&self, target: &ObjectRef, _mode: &ResolutionMode) -> Option { + match (target.class.as_str(), target.id.as_str()) { + ("user", "alice") => Some(Key::Alice), + ("work-package", "wp1") => Some(Key::Wp1), + ("attachment", "spec-pdf") => Some(Key::Spec), + ("drawing", "invoice-scan") => Some(Key::Invoice), + _ => None, // deleted / unknown → fallback path + } + } + fn value_of(&self, key: Key, position: u8) -> Option { + match (key, position) { + (Key::Alice, 0) => Some("Alice Muster".into()), + (Key::Alice, 1) => Some("alice@example.test".into()), + (Key::Alice, 2) => Some("engineer".into()), + (Key::Wp1, 0) => Some("Install fire door".into()), + (Key::Wp1, 1) => Some("in progress".into()), + (Key::Wp1, 2) => None, // the assignee RAIL: nested rows, no cell + (Key::Wp1, 3) => Some("60%".into()), + (Key::Spec, 0) => Some("fire-door-spec.pdf".into()), + (Key::Spec, 1) => Some("412 KiB".into()), + // The observation's values are the retina's typed fields, addressed + // through the mask — the same project::masked_values surface. + (Key::Invoice, p) => project::masked_values(&self.graph.invoice, &SliceView, DOC_SCAN) + .iter() + .find(|mf| mf.position == p) + .map(|mf| mf.value.to_string()), + _ => None, + } + } + fn view_by_name(&self, name: &str) -> Option { + self.names.iter().find(|(n, _)| *n == name).map(|(_, v)| *v) + } + fn view_for_class(&self, class: ClassId) -> Option { + // Nested hops render compactly: a railed-to User appears inline. + match class { + USER => self.view_by_name("user.inline"), + _ => None, + } + } +} + +// ── the fragment library (the initial prompt's named views) ──────────────── + +fn views() -> (ViewRegistry, Vec<(&'static str, ViewId)>) { + let mut r = ViewRegistry::new(); + let mut names = Vec::new(); + let mut reg = |names: &mut Vec<(&'static str, ViewId)>, n, v| { + let id = r.register(v); + names.push((n, id)); + id + }; + reg( + &mut names, + "user.inline", + NamedView::new(USER, WideFieldMask::from(0b001), DisplayTemplate::Card), + ); + reg( + &mut names, + "user.card", + NamedView::new(USER, WideFieldMask::from(0b111), DisplayTemplate::Card), + ); + reg( + &mut names, + "work_package.inline", + NamedView::new( + WORK_PACKAGE, + WideFieldMask::from(0b0011), + DisplayTemplate::Card, + ), + ); + reg( + &mut names, + "work_package.summary", + NamedView::new( + WORK_PACKAGE, + WideFieldMask::from(0b1111), + DisplayTemplate::Detail, + ), + ); + reg( + &mut names, + "attachment.inline", + NamedView::new(ATTACHMENT, WideFieldMask::from(0b01), DisplayTemplate::Card), + ); + reg( + &mut names, + "attachment.preview", + NamedView::new(ATTACHMENT, WideFieldMask::from(0b11), DisplayTemplate::Card), + ); + reg( + &mut names, + "document.scan", + NamedView::new( + DOC_SCAN, + WideFieldMask::from(0b111), + DisplayTemplate::Detail, + ), + ); + (r, names) +} + +// ── the composed document ────────────────────────────────────────────────── + +fn slot(app: &str, class: &str, id: &str, view: &str, mode: ResolutionMode) -> ObjectSlot { + ObjectSlot { + target: ObjectRef { + app: app.into(), + class: class.into(), + id: id.into(), + }, + class_view: view.into(), + field_mask: 0, + wide_mask_words: vec![], + resolution: mode, + fallback: None, + } +} + +fn compose() -> DocCompose { + let mut nodes = Vec::new(); + let mut push = |n: DocNode| { + nodes.push(n); + NodeId((nodes.len() - 1) as u32) + }; + + let heading = push(DocNode::Text { + text: "Install fire door — work record".into(), + }); + let t1 = push(DocNode::Text { + text: "Work item:".into(), + }); + let s_wp = push(DocNode::ObjectSlot { + slot: slot( + "openproject", + "work-package", + "wp1", + "work_package.summary", + ResolutionMode::Live, + ), + }); + let t2 = push(DocNode::Text { + text: "Reported by:".into(), + }); + let s_user = push(DocNode::ObjectSlot { + slot: slot( + "openproject", + "user", + "alice", + "user.inline", + ResolutionMode::Live, + ), + }); + let s_att = push(DocNode::ObjectSlot { + slot: slot( + "openproject", + "attachment", + "spec-pdf", + "attachment.preview", + ResolutionMode::Live, + ), + }); + let t3 = push(DocNode::Text { + text: "Scanned supplier invoice (pixel retina):".into(), + }); + let s_scan = push(DocNode::ObjectSlot { + slot: slot( + "document", + "drawing", + "invoice-scan", + "document.scan", + ResolutionMode::Snapshot([0x51; 32]), + ), + }); + let s_ghost = push(DocNode::ObjectSlot { + slot: ObjectSlot { + fallback: Some(SnapshotRef { + content_sha256: [0xAB; 32], + }), + ..slot( + "openproject", + "work-package", + "wp-deleted", + "work_package.inline", + ResolutionMode::Live, + ) + }, + }); + let p1 = push(DocNode::Paragraph { + children: vec![t1, s_wp], + }); + let p2 = push(DocNode::Paragraph { + children: vec![t2, s_user, s_att], + }); + let p3 = push(DocNode::Paragraph { + children: vec![t3, s_scan, s_ghost], + }); + let section = push(DocNode::Section { + heading: Some(heading), + children: vec![p1, p2, p3], + }); + let root = push(DocNode::Document { + children: vec![section], + }); + + DocCompose { + version: DOC_COMPOSE_VERSION.to_string(), + nodes, + root, + } +} + +// ── the two renderers over the SAME resolved rows ────────────────────────── + +fn slot_rows(rs: &ResolvedSlot) -> Vec { + match &rs.outcome { + SlotOutcome::Resolved { fields, .. } => fields + .iter() + .map(|f| FieldView { + position: f.position, + label: if f.depth == 0 { + f.label.clone() + } else { + // nested rows carry their depth in the label, renderer-neutrally + format!("{} ({})", f.label, "nested") + }, + predicate: String::new(), + value: f.value.clone(), + }) + .collect(), + _ => Vec::new(), + } +} + +fn render_html(blocks: &[ResolvedBlock]) -> String { + let mut html = String::new(); + for b in blocks { + match b { + ResolvedBlock::Heading(t) => html.push_str(&format!("

{t}

\n")), + ResolvedBlock::Text(t) => html.push_str(&format!("

{t}

\n")), + ResolvedBlock::Slot(rs) => match &rs.outcome { + SlotOutcome::Resolved { class, .. } => { + let rows = slot_rows(rs); + let title = rows.first().map(|r| r.value.clone()).unwrap_or_default(); + html.push_str( + &render_field_view(*class, &rs.class_view, &rs.uri, &title, &rows, &[]) + .expect("askama render"), + ); + html.push('\n'); + } + SlotOutcome::Fallback { content_sha256_hex } => html.push_str(&format!( + "

unresolved {} — snapshot sha256:{}

\n", + rs.class_view, content_sha256_hex + )), + SlotOutcome::Unresolvable => { + html.push_str(&format!("

{}

\n", rs.uri)); + } + }, + } + } + html +} + +fn render_typst(blocks: &[ResolvedBlock]) -> String { + let mut out = String::new(); + for b in blocks { + match b { + ResolvedBlock::Heading(t) => out.push_str(&typst::emit_heading(t)), + ResolvedBlock::Text(t) => out.push_str(&typst::emit_text(t)), + ResolvedBlock::Slot(rs) => match &rs.outcome { + SlotOutcome::Resolved { .. } => { + let rows = slot_rows(rs); + let title = rows.first().map(|r| r.value.clone()).unwrap_or_default(); + out.push_str(&typst::emit_field_view(&rs.class_view, &title, &rows)); + } + SlotOutcome::Fallback { content_sha256_hex } => { + out.push_str(&typst::emit_fallback(&rs.class_view, content_sha256_hex)); + } + SlotOutcome::Unresolvable => { + out.push_str(&typst::emit_text(&format!("unresolvable: {}", rs.uri))); + } + }, + } + } + out +} + +// ── THE PROOF ────────────────────────────────────────────────────────────── + +#[test] +fn one_document_two_projections_same_facts() { + let (registry, names) = views(); + let source = SliceSource { + graph: SliceGraph { + invoice: invoice_observation(), + }, + names, + }; + + let doc = compose(); + let resolved = resolve_doc(&doc, &source, &SliceView, ®istry, 4).expect("valid composition"); + + let html = render_html(&resolved.blocks); + let page = render_typst(&resolved.blocks); + + println!("──── askama HTML (@live surface) ────\n{html}"); + println!("──── Typst source (paged projection) ────\n{page}"); + + // The same facts appear in BOTH projections. + for fact in [ + "Install fire door", // wp subject + "in progress", // wp status + "60%", // wp progress + "Alice Muster", // the RAIL HOP: assignee resolved at depth 1 + "fire-door-spec.pdf", // attachment.preview filename + "412 KiB", // attachment.preview size + "100.00", // observation netto (retina fact) + ] { + assert!(html.contains(fact), "HTML missing fact {fact:?}"); + assert!(page.contains(fact), "Typst missing fact {fact:?}"); + } + // The observation IBAN appears in both (Typst escapes nothing in digits). + assert!(html.contains("DE00 0000 0000 0000 0000 00")); + assert!(page.contains("DE00 0000 0000 0000 0000 00")); + + // The fallback is explicit in BOTH projections (deleted target). + assert!(html.contains(&"ab".repeat(32)), "HTML fallback sha missing"); + assert!( + page.contains(&"ab".repeat(32)), + "Typst fallback sha missing" + ); + + // Masking holds in BOTH projections: user.inline selects ONLY name — + // Alice's email and role never reach either surface. + assert!(!html.contains("alice@example.test")); + assert!(!page.contains("alice@example.test")); + assert!(!html.contains("engineer")); + assert!(!page.contains("engineer")); + + // Retina presence holds in BOTH: `ust` was never read by the OCR — + // its bit is clear, so no `ust` row exists anywhere (C2: absence is a + // cleared bit, not an empty cell). + assert!(!html.contains(">ust<"), "HTML shows an ust row"); + assert!(!page.contains("[ust]"), "Typst shows an ust row"); + + // Document prose survived in order in both. + for text in [ + "Install fire door — work record", + "Reported by:", + "Scanned supplier invoice", + ] { + assert!(html.contains(text) || html.contains(&text.replace("—", "—"))); + assert!(page.contains(&typst::emit_text(text).trim_end().to_string())); + } +} + +#[test] +fn nested_rail_row_is_depth_one() { + let (registry, names) = views(); + let source = SliceSource { + graph: SliceGraph { + invoice: invoice_observation(), + }, + names, + }; + let doc = compose(); + let resolved = resolve_doc(&doc, &source, &SliceView, ®istry, 4).unwrap(); + + let wp_slot = resolved + .blocks + .iter() + .find_map(|b| match b { + ResolvedBlock::Slot(rs) if rs.class_view == "work_package.summary" => Some(rs), + _ => None, + }) + .expect("wp slot present"); + let SlotOutcome::Resolved { fields, .. } = &wp_slot.outcome else { + panic!("wp must resolve"); + }; + let alice = fields + .iter() + .find(|f| f.value == "Alice Muster") + .expect("assignee resolved through the rail"); + assert_eq!(alice.depth, 1, "the rail hop is depth 1"); + assert_eq!(alice.label, "name"); + // and the rail position itself contributed no cell of its own + assert!( + fields + .iter() + .all(|f| !(f.depth == 0 && f.label == "assignee")) + ); +} diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 8d934ea..a289e74 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1550,3 +1550,28 @@ isolation. The map's job is to keep them visible. `ogar-bim` **semantic-object** half (a `Wall` node + `document-inline` ClassView, addressable via `ObjectSlot`) can proceed independently of the mesh pipeline. Naming discipline: widen the shipped carrier, never fork it. + +- **[D-DOCIR-DUAL-RENDER] the DocIr vertical slice CLOSES: ObjectSlot → resolve + (rail walk) → the SAME rows through askama HTML AND Typst source; the retina + participates through project masking; the fallback is exercised** — `[G]` + (BUILT + INSPECTED, 2026-07-20) — home: `ogar-doc-ir::resolve` (the missing + VERB over the composition brick) + NEW crate `ogar-render-typst` (paged + projection, SOURCE emitter only — no typst compiler dep) + the executable + proof `ogar-render-typst/tests/dual_render.rs` — depends: D-DOCIR-COMPOSE + (the brick), lance-graph #776 `selection` (walk_rails/NamedView/ViewRegistry; + contract deps FLOAT on that PR branch, flip to main on merge), #220 `project` + (the observation masking). Mechanism: `resolve_doc` walks the composition; + each slot's named view governs its root, nested hops resolve per-class + (`DocObjectSource` dependency inversion — consumer owns graph/values/ + bindings); an OCR `doc.v1` is just a graph NODE whose presence/values are + `project::{field_mask, masked_values}` — retina and live objects flow the + SAME walk. Proof (inspected output, 6/6 + 26/26 green, clippy/fmt clean): + one `DocCompose` (User + WorkPackage + Attachment + scanned-invoice slots + + a deleted target) renders to BOTH surfaces with identical facts — the + assignee rail hop lands at depth 1 in both; `user.inline` masks email/role + out of both; the retina's unread `ust` is absent from both (cleared presence + bit); the deleted slot shows its sha256 snapshot fallback in both. Typst = + the `@revision`/archival leg (ResolutionMode pairing: live surface ↔ Live, + page ↔ Revision/Snapshot); compiling `.typ`→PDF stays a consumer egress + (deliberately no compiler dep). Deferred, named: `DocOp` (editor authority), + Lance-versioned `Revision(n)` lookup, `FieldView` enum widening.