clay-borg/decisions/ADR-0007-render-html-not-a-port.md
tegwick 883b608f36 CB-WP-0012-T03: ADR-0007 — render to HTML, and do not declare the port yet
Five decisions:

1. cb-render-html emits HTML/SVG/JS; the browser draws it. Rejected on
   measured marginal cost: egui+eframe 2,782,849; wgpu+winit 1,741,979;
   ratatui 1,067,013; macroquad 480,501; sdl2 191,973; fltk 140,079.
   The reason is allocation, not affordability — fltk is only 1.5x the
   corrected headroom, but wgpu is 12x fltk, unavoidable, and named by
   INTENT for stage 2. Stage 1 should not buy windowing stage 2 discards.

2. No cb-render-api and no cb-render-null. A port designed against one
   document-emitting implementation acquires a document's shape and
   stage 2 rewrites it. INTENT's second-use rule beats the blueprint's
   port pattern on timing. cb-render-html ships against Project.

3. AM-4 counts third-party code the project causes to be ACQUIRED, not
   runtimes the user already has. Browsers are not counted; sdl2 and
   fltk are counted at MORE than their Rust bindings. The rule costs
   more than it saves, which is the only reason to trust it here.

4. AM-4a's proc-macro defect (89,048 lines, 36.2%) is filed separately,
   not bundled with the request it unblocks.

5. Six controls bind the implementation, including a mutation-backed
   test that a token-less loopback request is refused, a bar on JS
   constructing commands, and a coverage gate asserting over the parsed
   emitted document rather than the Rust that emits it.

Reserved for the maintainer: AM-4a exceeds INTENT stage 2 by 7x and
cannot survive it; and whether decision 3's rule is the right one.

The hard gate held — no render implementation code before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:13:35 +02:00

9.8 KiB
Raw Blame History

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, history/260802-render-port-{research,challenge,response}.md, INTENT.md §Implementation order, ArchitectureBlueprint.md §Port pattern, ADR-0004 (AM-4 budgets), CB-EV-0009 (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; rustixlinux-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_derive89,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 13 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.