# 30-render-architecture — Cross-surface rendering architecture Status: **implemented contract, with explicitly listed future extensions**. The schema-v2 semantic timeline, result-scoped eligibility/evidence metadata, collaborative goal projection, deterministic fallback, and cross-surface delivery path are shipped in 3.0.10. The open questions at the end are not claims about missing core behavior. This document specifies the rendering architecture for vak's output. Result eligibility, evidence state, and collaborative goal state come from the shared outcome-directed runtime; this document owns only semantic projection and surface rendering. Scope boundary: everything below is ledger → projection → delivery, ending at the `DeliveryPacket` / `OutputTimeline` the surface receives. What the native (desktop/web) client does with a `Structured` or `Adaptive` item after that is `docs/design/57-adaptive-presentation-runtime.md` — the closed primitive vocabulary and runtime-pluggable packs — and the contributor walkthrough for adding or changing a renderer is `docs/design/67-presentation-renderer-guide.md`. There are no per-semantic-type client components; one generic renderer consumes the primitive tree. delivery system. It is the design contract that the implementation follows, replacing the ad-hoc wiring that left `project_structured_fences` as dead code and `DeliveryPacket.presentation` as inert data. ## Problem statement Three forces pull in different directions: 1. **Provider/model diversity.** Sessions walk a frozen route ladder across Anthropic, OpenAI, Google, and local providers. Turn-by-turn, the model that answered is not the model that will answer next. Raw provider JSON shapes (`choices[0].message.content` vs `content[0].text` vs `candidates[0].content`) are irreconcilable per-provider. Writing N adapters for N providers is an unbounded maintenance nightmare. 2. **Surface capability divergence.** Telegram accepts 7 HTML tags, 4096 chars, no headings/tables. Slack sends mrkdwn with `*bold*` / `_italic_` / ``, no headings/tables, ~4000 chars. Discord passes GFM but no hyperlinks in plain messages, no tables, 2000 chars. Desktop renders a schema-v2 semantic AST as typed React/SolidJS components. Each surface's protocol limits are immutable walls, not styling preferences. 3. **Durability without duplication.** The session ledger (JSONL) is the sole source of truth. Formatting must not become a second source of truth. Every rendering decision must be loss-accounted (native / fallback), and every rendered output must carry its `fallback_markdown` so a renderer bug never loses data. ## Design principles - **Self-declaration over inference.** Structured data enters the pipeline through a normalized envelope (`semantic_type` + `payload`). The envelope is model-agnostic and provider-agnostic — any model can emit it because the format is the same regardless of which provider wrapped the text content. - **One projection path.** The model output → `AnswerDraft` → `DeliveryPacket` pipeline produces channel-specific text (chunks) AND an optional semantic timeline (presentation), but the text projection is always derived from the same source through one function: `project_structured_fences()`. - **Coverage everywhere.** Every block in the compiled document carries a `Coverage` disposition: `Native` (rendered with full surface semantics) or `Fallback` (downgraded to text). Nothing is silently dropped. - **The host defines the vocabulary.** `DocumentBlock` (15 variants), `InlineNode` (9 variants), and `OutputContent` (9 variants) are closed enums owned by the host. Skills and plugins classify their data against this vocabulary — they never extend it. - **Capabilities are data classified against vocabulary.** Recipes, signal matchers, and renderer bindings are plugin-contributed data, not hardcoded tables. The recipe catalog accepts registrations from any installed plugin; more specific recipes shadow less specific ones (longest-signal-prefix wins, like routing tables). - **Level-triggered reconciliation.** Capability changes (new plugins, skill updates, renderer bindings) propagate at the next turn boundary via an immutable epoch published by the snapshot layer. Revocations take effect immediately and fail closed. ## Architecture: the rendering stack ``` ┌─────────────────────────────────────────────────────────────┐ │ Session Ledger (JSONL) — the immutable source of truth │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Message │ │ Activity │ │ ToolResult │ │ │ │ (markdown) │ │ (approval) │ │ (structured) │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Projection Layer (vak-server/src/projection.rs) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ snapshot() / live_event() / project_frame() │ │ │ │ │ │ │ │ 1. compile_markdown(source) → PresentationDocument │ │ │ │ 2. parse_fragment() on ```vak fences → Structured │ │ │ │ 3. signals_from_text() / signals_from_context() │ │ │ │ 4. PresentationPlanner::plan(signals) → recipe │ │ │ │ 5. DocumentCoverage: Native / Fallback per block │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Delivery Layer (vak-delivery) │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ render(job) → DeliveryPacket │ │ │ │ │ │ │ │ AnswerDraft │ │ │ │ ├── source_markdown (exact, preserved) │ │ │ │ ├── document: PresentationDocument (semantic AST) │ │ │ │ └── metadata: recipe_id, signals, coverage │ │ │ │ │ │ │ │ render_content() │ │ │ │ ├── Non-native surface: │ │ │ │ │ project_structured_fences(source) → text │ │ │ │ │ → markup_to_html/mrkdwn/discord(text) │ │ │ │ ├── Native surface (Json): │ │ │ │ │ output_document directly as JSON │ │ │ │ └── Native surface (Plain): │ │ │ │ render_plain(document) → text projection │ │ │ │ │ │ │ │ chunk_text / split_html_chunks / │ │ │ │ chunk_markdown_preserving_fences │ │ │ │ │ │ │ │ coverage: block_id → Rendered/Fallback │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Surface Projections │ │ │ │ chat (telegram/s/l) → DeliveryPacket.chunks │ │ └─ sequential channel messages, actions on final chunk │ │ │ │ desktop/web → /sessions/{id}/presentation SSE │ │ └─ OutputTimeline with OutputContent (Document, │ │ Structured, Artifact, Approval, etc.) │ │ │ │ webhook → DeliveryPacket (JSON payload) │ │ │ │ log → DeliveryPacket.chunks (plain text) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Durability Layer (vak-delivery/src/outbox.rs) │ │ Append-only JSONL per job_id, Pending → Delivered/DeadLetter │ │ Oldest-first replay, 10 attempts, then DLQ │ └─────────────────────────────────────────────────────────────┘ ``` ## Component specifications ### 30.1 The Normalized Output Envelope ```rust // The only model-authored rich block envelope pub struct StructuredOutput { pub semantic_type: String, // must be declared by an installed skill pub schema_version: u16, // PRESENTATION_SCHEMA_VERSION pub skill_id: String, // who provided this type pub skill_version: String, // content-addressed for determinism pub payload: Value, // validated against the skill's schema } ``` The envelope is recognized in three input forms: | Form | Where | Extraction | |---|---|---| | ```vak fence | Model text | `project_structured_fences()` finds ```vak blocks | | Bare JSON | Tool output / model text | `parse_fragment()` tries `serde_json::from_str` | | Tool declared | Tool wrapper output | `structured_outputs_from_tool_result()` checks `semantic_type` field | **Provider-agnostic invariant:** We never parse raw provider response JSON (`choices.[0].message.content`). The provider wraps text content; we unwrap one level to get text, then look for our envelope within that text. The envelope format is identical regardless of provider. **Preferred emission path: `emit_*_card` tool calls.** The "Tool declared" row above is how `vak-core/src/presentation_tools.rs`'s twelve `emit_*_card` tools reach this pipeline — one tool per payload *shape* (not per `semantic_type`; siblings that share a shape, e.g. every timeline-flavored type, share one tool), each with a JSON Schema precise enough to satisfy `SkillRegistry::validate()`. `execute()` validates the call's arguments against `SkillRegistry` and replies with a short ack (or a repairable tool error); the projection rebuilds the card from the call's own arguments (`card_output_from_call`), which the ledger records untruncated — the card is never carried in result text, of which an over-long result's request carries only a window (docs/design/68-context-engine.md §3). This is now the *preferred* path over writing a ```vak fence directly (`system-prompt.md`, `docs/design/07-prompt.md` v3.4.5): measured against the real local model this app ships (gemma4:e2b-mlx via Ollama), a free-text fence in prose parsed as valid JSON only ~20% of the time, against 100% for a tool call constrained by a precise schema — the schema is enforced as the model constructs the call, so a malformed card never reaches this layer at all. The fence path remains the fallback for a turn where no matching `emit_*_card` tool is present. **Presentation check.** Prompt guidance is advisory, and a strong model can still answer in prose what the app would have shown as a card. After the turn-loop's other repair checks, `Agent::run` asks an optional `AgentConfig::presentation_check` (set by `Core`) whether the final text reads as a card: `signals_from_text` → `RecipeCatalog::intended_outputs` (the best non-default recipe whose signals all match, independent of which outputs exist) → the offered `emit_*_card` tool that carries one of its primary types. If no card was emitted this run and no inline fence is present, the model gets one `[presentation-check]` nudge; it may decline by resending unchanged. No per-type rules live in the loop. ### 30.2 The Semantic Compiler `compile_markdown()` in `presentation.rs` uses `pulldown-cmark` with tables + strikethrough + tasklists + footnotes. It produces a closed `Node` tree, then projects to `DocumentBlock` variants: ``` Markdown → pulldown-cmark Node → DocumentBlock (15 variants) └─ Structured { output: StructuredOutput } created when a ```vak fence yields a validated StructuredOutput ``` The compiler preserves exact source markdown in `PresentationDocument.source_markdown` and records coverage: - `Native` — the block renders with full surface semantics - `Fallback` — the block degrades to text (e.g., a table on Telegram becomes a monospace grid inside `
`)

`unsafe_links_and_html_remain_inert` test confirms `