# ADR-0007: render to HTML, and do not declare the port yet status: accepted date: 2026-08-02 decided by: agent, under the standing loop authorization. **Two items are explicitly reserved for the maintainer and are not decided here** — see §Reserved. tier: L (structural L — the declaration was made when this pass would create a capability port; chaos d4=1 → no override. Review removed the port, and the tier was **not** re-derived — see §Consequences) references: [CB-RES-0006](../research/CB-RES-0006-render-port.md), `history/260802-render-port-{research,challenge,response}.md`, [INTENT.md](../INTENT.md) §Implementation order, [ArchitectureBlueprint.md](../specs/ArchitectureBlueprint.md) §Port pattern, [ADR-0004](ADR-0004-am4-ratification.md) (AM-4 budgets), [CB-EV-0009](../evidence/CB-EV-0009-inspectable-table.md) (the inspector) ## Context INTENT stage 1 is *"Inspectable 2D table — card/token/hand/relationship- graph visualization, drag-to-propose, debug inspector, hot-seat play."* CB-WP-0011 shipped the debug inspector. This decides how the other three get drawn. The obvious shape — `cb-render-api` / `cb-render-null` / `cb-render-wgpu`, straight from ArchitectureBlueprint §Port pattern — did not survive review. Two things went wrong with the survey that produced it, and both are recorded because the decision below is only trustworthy given what was wrong with the reasoning that first reached it. ## Decision 1 — render by emitting HTML, SVG and JavaScript `cb-render-html` emits a document; the browser draws it. SVG covers cards, tokens and the relationship graph natively — the graph being the one element every Rust 2D toolkit would have left us to hand-roll. Pointer events cover drag-to-propose. One tab covers hot-seat. **Rejected alternatives, with measured marginal cost against the 23-crate shipped-runtime base:** | rejected | marginal lines | why not | |---|---:|---| | `egui` + `eframe` | 2,782,849 | 30× the corrected headroom | | `wgpu` + `winit` | 1,741,979 | stage 2's, and stage 2 should buy it | | `softbuffer` + `tiny-skia` + `winit` | 1,147,081 | windowing dominates the rasterizer | | `ratatui` + `crossterm` | 1,067,013 | costs more than a GPU game framework; `rustix` → `linux-raw-sys` at 479,901 | | `macroquad` | 480,501 | cheapest GPU-era stack, still 5.2× | | `sdl2` | 191,973 + a C library | see Decision 3 | | **`fltk`** | **140,079** + a C library | **the real floor. Affordable if the target moved — rejected on allocation, not cost** | | `tiny-skia` alone | 83,956 | fits, but rasterizes without windowing or input | **The reason is allocation, not affordability.** `fltk` is 1.5× the corrected headroom — a negotiation, not an impossibility. But `wgpu` at 1,741,979 is named by INTENT for stage 2, is unavoidable, and is **twelve times** the cost of the stage-1 toolkit it would replace. Spending 1.5× the remaining budget on windowing that stage 2 discards is the wrong allocation whether or not the budget can be stretched. This argument does not depend on AM-4a's target being 250,000; it depends only on stage 2's bill exceeding stage 1's benefit, which is measured and true by 12×. The first draft of the survey argued affordability — "128×, two orders of magnitude, not a near miss." That was false, drawn from a candidate list containing no cheap windowed toolkits. It is recorded here because a decision whose stated reason was wrong once should carry the correction where the decision lives, not only where the survey does. ## Decision 2 — do **not** declare `cb-render-api` or `cb-render-null` INTENT: *"No concept becomes canonical merely because it looks general. It becomes canonical after surviving a second concrete use."* A port designed against one implementation that emits whole documents acquires a document's shape — retained mode, full state per update, string identity, no frame timing — and stage 2 finds it unimplementable and rewrites it. `CommitWindow` is already in this repo with a delete-by date and two declined second-use opportunities, for exactly this error. `cb-render-html` therefore ships against the **existing `Project` trait**, which is a real interface with real implementations. The port is declared at stage 2, derived from two implementations rather than imagined from one. `cb-render-null` goes with it: a null implementation of an interface that does not exist is a null implementation of nothing. **This overrides ArchitectureBlueprint §Port pattern for this capability, and only for its timing.** The blueprint says every important capability ships null, reference and optimized implementations; it does not say when the interface is fixed. Where the two rules met, INTENT's second-use rule won, because it is the one INTENT states without exception. ## Decision 3 — AM-4 counts what the project causes to be acquired Scoring the browser at zero is true under AM-4a and suspect in substance. If a target can be satisfied by relocating a dependency somewhere the target does not look, it constrains nothing — and the same move was already flattering `sdl2` and `fltk`, whose `.rs` counts exclude the C libraries that do the work. > **AM-4 counts third-party code the project causes to be acquired.** It > does not count runtimes the user already has independently of us — the > kernel, the system C library, the shell, the terminal, a web browser. It > **does** count a library our build or install instructions cause to be > fetched, pinned, or linked, whether or not its source is Rust. Consequences, applied consistently: a browser is not counted; **`sdl2` and `fltk` are counted at more than their Rust bindings**, since they oblige a user to install a `-dev` package. The rule makes the target harder to satisfy, not easier, which is the only direction a rule proposed by its beneficiary should be trusted to run. ## Decision 4 — AM-4a's proc-macro defect is a separate change AM-4a counts `--edges normal`, which includes proc-macro crates that run in the compiler and never reach a binary: `syn` (66,916), `proc-macro2`, `quote`, `unicode-ident`, `serde_derive` — **89,048 lines, 36.2%** of the "shipped-runtime" figure. Real headroom is **92,798**, not the 3,750 this repo has cited in every pass that mentioned it, including CB-WP-0011's reason for deferring this declaration. The metric should count `--edges normal,no-proc-macro`. **Filed as its own change, not bundled here.** A budget correction that arrives attached to the request it unblocks is indistinguishable from motivated reasoning even when it is right — and it is right, which is why it must arrive separately. ## Decision 5 — six controls bind the implementation | # | control | why | |---|---|---| | 1 | unguessable token minted per process, required on every request | a loopback listener is reachable by any process **and any web page the user visits** | | 2 | `Origin` / `Sec-Fetch-Site` checked, rejected by default | cross-origin POST to `127.0.0.1` is the attack, not a hypothetical | | 3 | listener bound explicitly to `127.0.0.1` | not `0.0.0.0` by default | | 4 | **a test that a token-less request is refused**, plus an M-D1-MUT mutation removing the check and turning it red | 1–3 without 4 are three claims and no evidence | | 5 | **JavaScript may not construct commands** — the page reports raw pointer facts (`down on id`, `up on id`); Rust decides what command they mean | confines JS to input transport, making the decision testable in Rust against synthetic events | | 6 | **the coverage gate crosses the language boundary** — the HTML counterpart of `every_view_field_is_classified` asserts over the **parsed emitted document** | asserting over the Rust that emits the document reproduces CB-WP-0011's defect one layer up | Controls 5 and 6 exist because CB-WP-0011 established that a renderer's defect class is silent omission, and this decision moves the interactive half of stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT` cannot reach. Without them, this ADR would spend a pass's finding one pass after paying for it. **K13 binds unchanged.** The renderer consumes a projection and can never feed back into validation; a drag that proposes a move goes through the same command path a CLI move takes. ## Reserved for the maintainer — not decided here 1. **AM-4a is incompatible with INTENT stage 2.** `wgpu` + `winit` is 1,741,979 marginal lines against a **250,000** total target — 7× the whole budget, 19× the corrected headroom. No sequencing, feature-gating or metric correction closes that. Either the target moves, the render port sits outside the AM-4a configuration by an argued rule, or stage 2 changes. **A pass that discovers a budget conflict and also settles it has reviewed nothing**, so this pass does not settle it. 2. **Whether Decision 3's acquisition rule is the right rule**, given it is proposed by the pass that benefits from it. It is written to cost more than it saves, but that is an argument, not a ratification. ## Consequences - Stage 1 ships without a capability port. That is a deliberate deferral with a named trigger (stage 2's `wgpu` implementation), not an omission. - **The tier is now wrong and was not re-derived.** Tier L's structural trigger was "creates a new capability port"; review removed the port. Re-rolling would make the tier a function of the outcome, which is the one thing a tier declaration must not be. The pass runs at L. This is the CHAOS calibration window's second entry, and unlike the first it comes from a *non*-override: full-weight review deleted its own trigger. - A second language enters the build, gated only by control 6. If the emitted JavaScript grows beyond input transport, control 5 has failed and the decision should be revisited rather than the control widened. - `cb-play` acquires a third mode. CB-EV-0009 §5 flagged that a third mode is the second use at which its single-binary shape should be reconsidered; that reconsideration is now due.