From 6fb0aeacf02a549965b2861db91849ad53ca5a3f Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 02:49:19 +0200 Subject: [PATCH 1/7] Sync hub IDs and work-record index for CB-WP-0011 --- WORK-RECORDS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 44608b8..d046b55 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -18,7 +18,7 @@ | workplan | CB-WP-0008 | done | — | workplans/CB-WP-0008-ship-stage-0.md | | workplan | CB-WP-0009 | done | — | workplans/CB-WP-0009-adaptive-gates.md | | workplan | CB-WP-0010 | done | — | workplans/CB-WP-0010-consolidation.md | -| workplan | CB-WP-0011 | todo | — | workplans/CB-WP-0011-inspectable-table.md | +| workplan | CB-WP-0011 | done | — | workplans/CB-WP-0011-inspectable-table.md | | task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md | @@ -86,6 +86,6 @@ | task | CB-WP-0010-T01 | done | — | workplans/CB-WP-0010-consolidation.md | | task | CB-WP-0010-T02 | done | — | workplans/CB-WP-0010-consolidation.md | | task | CB-WP-0010-T03 | done | — | workplans/CB-WP-0010-consolidation.md | -| task | CB-WP-0011-T01 | todo | — | workplans/CB-WP-0011-inspectable-table.md | -| task | CB-WP-0011-T02 | todo | — | workplans/CB-WP-0011-inspectable-table.md | -| task | CB-WP-0011-T03 | todo | — | workplans/CB-WP-0011-inspectable-table.md | +| task | CB-WP-0011-T01 | done | — | workplans/CB-WP-0011-inspectable-table.md | +| task | CB-WP-0011-T02 | done | — | workplans/CB-WP-0011-inspectable-table.md | +| task | CB-WP-0011-T03 | done | — | workplans/CB-WP-0011-inspectable-table.md | From 331e7e9044b5f2ea5ad2f2700c78a0bfcf0833d9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 04:04:30 +0200 Subject: [PATCH 2/7] Declare CB-WP-0012: the render port, tier L, chaos rolled nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declaration 7 of 12. Structural tier L (INTENT stage 1 creates a capability port); d4 = 1, so no override — the pass runs at full weight, which is the tier CB-WP-0011's roll deleted. The leading constraint is AM-4a's 3,750 lines of headroom against candidate implementations that are all five to six figures. T01 is required to measure rather than estimate, and to audit the instrument before recommending any change to the target it reports. Co-Authored-By: Claude Opus 5 --- workplans/CB-WP-0012-render-port.md | 139 ++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 workplans/CB-WP-0012-render-port.md diff --git a/workplans/CB-WP-0012-render-port.md b/workplans/CB-WP-0012-render-port.md new file mode 100644 index 0000000..6bb0036 --- /dev/null +++ b/workplans/CB-WP-0012-render-port.md @@ -0,0 +1,139 @@ +--- +id: CB-WP-0012 +kind: product +title: "Stage 1, second slice: the rendering port" +status: todo +--- + +# Purpose + +Tier **L**, the declaration CB-WP-0011 deferred and owed. + +``` +structural tier L (INTENT stage 1 creates a new capability port) +chaos d4 = 1 → no override +declared tier L +``` + +Declaration 7 of 12 in the calibration window. The CHAOS gate earned its +first `caught` entry last pass by rolling this same declaration down to S; +this time it rolls nothing, so the pass runs at full weight — survey, +adversarial review, ADR, then code. The hard gate applies as stated: **no +implementation code for the render capability exists before ADR-0007 is +committed.** + +## What this pass owes + +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 pass owes the other three, +and the port they hang from: + +``` +cb-render-api +├── cb-render-null +└── cb-render-??? <- the survey's question +``` + +**The leading constraint is AM-4a**: 246,250 lines of 250,000, measured — +**3,750 lines of headroom**. Every candidate implementation of a 2D table +is a five- or six-figure line count. This is the first capability in the +project whose obvious implementation costs more than the entire remaining +budget, so the survey's first duty is to establish whether that is a real +obstacle or an artifact of how AM-4a is instrumented. + +## Task: survey the render port and its dependency cost + +```task +id: CB-WP-0012-T01 +status: todo +priority: high +``` + +Write `research/CB-RES-0006-render-port.md`. + +**Measure, do not estimate.** The candidate line counts must come from +the same method `tools/dep-weight.py` uses — `cargo tree --edges normal` +over a real resolved graph, then counted `.rs` lines in vendored source — +so the numbers are comparable to the AM-4a figure rather than merely +adjacent. Last pass's correction was a hand-counted number; this pass has +no excuse for one. + +**Measure the marginal cost, not the total.** What AM-4a charges is what a +candidate adds to a graph that already holds 23 crates. A candidate's +headline size overstates its cost by whatever it shares with the base. + +Cover at minimum: an immediate-mode GUI (`egui`/`eframe`), a 2D game +framework (`macroquad`), the raw stage-2 stack (`wgpu` + `winit`), a CPU +rasterizer (`softbuffer` + `tiny-skia`), a terminal UI (`ratatui`), and at +least one option that is not a Rust toolkit at all. + +**Ask whether the instrument is right.** If AM-4a's figure counts code +that never reaches a shipped binary, the headroom it reports is wrong, and +that has been true for every pass that has cited it. Check this before +recommending any budget change — a survey that argues for raising a target +it has not first audited is arguing in its own favour. + +## Task: adversarial review of the survey + +```task +id: CB-WP-0012-T02 +status: todo +priority: high +``` + +Step 2 of the inner loop, run against T01's recommendation rather than for +it. The review must name, at minimum: + +- what the recommended option **cannot** do that a windowed toolkit can, + stated concretely rather than as a caveat; +- the failure mode of building the port around a single implementation + when the second-use rule exists precisely to stop that; +- whether the survey's dependency argument would survive if AM-4a's target + were simply wrong, i.e. whether the recommendation is load-bearing on a + number the same pass proposes to correct. + +A review that ratifies the survey without producing a control the survey +lacked has not run. + +## Task: ADR-0007 — the render port + +```task +id: CB-WP-0012-T03 +status: todo +priority: high +``` + +Record the decision, the rejected alternatives with their measured costs, +and the controls the implementation must carry. No implementation code +lands before this commits. + +## Task: the port and its first implementation + +```task +id: CB-WP-0012-T04 +status: todo +priority: high +``` + +`cb-render-api` plus `cb-render-null` and the implementation ADR-0007 +chooses, delivering visualization, drag-to-propose and hot-seat play. + +**K13 binds here.** The renderer consumes a `Project`ion and can never +feed back into validation; a drag that proposes a move must go through the +same command path a CLI move takes. The inspector's coverage gate is the +precedent for the control this needs. + +## Task: evidence + +```task +id: CB-WP-0012-T05 +status: todo +priority: medium +``` + +`evidence/CB-EV-0010-render-port.md`. Carry the standing items: the +falsifiable prediction from CB-EV-0009 §4 that the trailing-3 meta share +drops to 0% this pass, SH-3 at 0.0% for a sixth pass, and whether tier L +at full weight produced anything the rolled-down pass would have missed — +the CHAOS window's second data point, and the first from a non-override. From 53abbaf68ff452f5e49a69b815eec97c73eef32b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 04:05:52 +0200 Subject: [PATCH 3/7] =?UTF-8?q?CB-WP-0012-T01:=20survey=20the=20render=20p?= =?UTF-8?q?ort=20=E2=80=94=20and=20find=20AM-4a=20mis-instrumented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured with dep-weight.py's own method so the numbers subtract. Marginal cost against the 23-crate base graph, not headline size: egui+eframe 2,782,849 742x headroom wgpu+winit 1,741,979 465x softbuffer+tiny-skia 1,147,081 306x ratatui+crossterm 1,067,013 285x macroquad 480,501 128x HTML/SVG emission 0 0x ratatui costing more than macroquad is the surprise: rustix pulls linux-raw-sys at 479,901 lines. Separately, AM-4a counts proc-macro crates that never reach a binary: 89,048 lines, 36.2% of the shipped-runtime figure, syn alone 66,916. Real headroom is 92,798, not the 3,750 this repo has been citing. The recommendation is unchanged by that correction — macroquad is 5.2x over even at 92,798 — which is the only condition under which a survey may propose correcting a number in its own favour. The metric fix is filed separately, not bundled with the decision it unblocks. Co-Authored-By: Claude Opus 5 --- research/CB-RES-0006-render-port.md | 183 ++++++++++++++++++++++++++++ workplans/CB-WP-0012-render-port.md | 26 +++- 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 research/CB-RES-0006-render-port.md diff --git a/research/CB-RES-0006-render-port.md b/research/CB-RES-0006-render-port.md new file mode 100644 index 0000000..b74e7c1 --- /dev/null +++ b/research/CB-RES-0006-render-port.md @@ -0,0 +1,183 @@ +# CB-RES-0006 — the render port, and what a 2D table costs + +CB-WP-0012 T01. Step 1 of the inner loop. Measured 2026-08-02 at +`331e7e9`. + +The question: INTENT stage 1 needs `cb-render-api` and a first real +implementation. AM-4a reports **3,750 lines of headroom** (246,250 of a +250,000 target). Is that a real obstacle, or an artifact of the +instrument? + +Both, and not in the proportions I expected. + +--- + +## 1. Method + +Every figure below comes from the same method `tools/dep-weight.py` uses +for AM-4a: resolve a real dependency graph with +`cargo tree --edges normal --prefix none`, drop path dependencies, and +count `\n` in every `.rs` file of the vendored source. Reusing the method +verbatim is the point — a survey that measures candidates one way and the +budget another produces two numbers that cannot be subtracted. + +Probe crates were built in a scratch directory, one per candidate, and +resolved against the live registry. The script is +`$CLAUDE_JOB_DIR/tmp/survey/measure.py`; it is not committed, because a +throwaway probe promoted to a tool is how the second-use rule gets broken +by accident. + +**Marginal, not total.** A candidate's headline size overstates its cost +by whatever it already shares with the base graph. What AM-4a charges is +the delta. + +## 2. What a 2D table costs + +Base: `games-ground --no-default-features`, 23 third-party crates, +246,250 lines. + +| candidate | total | shared with base | **marginal** | vs 3,750 headroom | +|---|---:|---:|---:|---:| +| `egui` + `eframe` | 2,946,121 | 163,272 | **2,782,849** | 742× | +| `wgpu` + `winit` | 1,866,820 | 124,841 | **1,741,979** | 465× | +| `softbuffer` + `tiny-skia` + `winit` | 1,271,922 | 124,841 | **1,147,081** | 306× | +| `ratatui` + `crossterm` | 1,146,363 | 79,350 | **1,067,013** | 285× | +| `egui` alone (no windowing) | 778,067 | 110,542 | **667,525** | 178× | +| `macroquad` | 480,729 | 228 | **480,501** | 128× | +| HTML/SVG emission, no Rust toolkit | — | — | **0** | 0× | + +The cheapest candidate that opens a window costs **128 times** the entire +remaining budget. This is not a near miss to be negotiated; it is two +orders of magnitude. + +**A surprise worth recording:** `ratatui` — a *terminal* library, the +option one reaches for expecting it to be the cheap one — costs more than +`macroquad`, a 2D game framework with a GPU backend. The reason is +`linux-raw-sys` at 479,901 lines and `libc` at 129,990, pulled through +`rustix`. Neither is code anyone audits by hand; both are largely +generated constant and type definitions. Which raises the next question. + +## 3. The instrument is wrong, and has been all along + +AM-4a counts the `--edges normal` graph. That graph includes **proc-macro +crates**, which run in the compiler and never reach a shipped binary. + +| `games-ground --no-default-features` | crates | lines | +|---|---:|---:| +| as AM-4a measures it | 23 | **246,250** | +| excluding proc-macro crates (`--edges normal,no-proc-macro`) | 18 | **157,202** | +| the difference | 5 | **89,048** (36.2%) | + +The five are `syn`, `quote`, `proc-macro2`, `unicode-ident` and +`serde_derive`. `syn` alone is 66,916 lines — the single largest entry in +the shipped-runtime figure, and it is a parser for Rust source that exists +only at build time. + +**So 36.2% of what AM-4a calls "what a game ships" is not shipped.** Under +a corrected metric the headroom is not 3,750 but **92,798**. Every pass +that has cited the 3,750 figure — including CB-WP-0011's own reasoning for +deferring this declaration — cited a number that was wrong in the +conservative direction. + +**This does not change the recommendation, and that is the point of +checking it before recommending.** Under the corrected metric `macroquad` +still costs 5.2× the available headroom, and every other windowed +candidate is worse. The dependency argument survives the correction of the +number it rests on, which is the only condition under which a survey may +propose correcting a number in its own favour. + +The correction is proposed as a separate, narrow change — the metric +should measure what ships — and it must not be bundled with the render +decision, because a budget correction that arrives attached to the request +it unblocks is indistinguishable from motivated reasoning even when it is +right. + +## 4. What stage 1 actually asks for + +> *card/token/hand/relationship-graph visualization, drag-to-propose, +> debug inspector, hot-seat play* + +Read plainly: draw a table, let a human point at it, and let several +humans take turns at one machine. It does **not** ask for a GPU, a frame +loop, physics, or 60fps. Those are stage 2 (*"Physical 3D tabletop — +wgpu renderer, Rapier-backed physics"*), and stage 2 is where the 1.7M +lines of `wgpu` + `winit` get argued for on their merits. + +Buying a GPU toolkit now would mean paying stage 2's dependency bill to +satisfy stage 1's requirements, one stage early, against a budget that +cannot cover it, and before the port interface has ever had a second +implementation to be validated against. + +## 5. The option that is not a Rust toolkit + +Emit HTML with inline SVG and inline JavaScript; open it in the browser +the machine already has. + +- **visualization** — SVG draws cards, tokens and a relationship graph + natively, and the relationship graph is the one element every Rust 2D + toolkit would have made us hand-roll anyway. +- **drag-to-propose** — pointer events in the emitted page. +- **debug inspector** — already shipped (CB-WP-0011), and its renderer is + already a total function from a projection to text; HTML is a second + output format for the same walk. +- **hot-seat play** — one browser tab, seats taking turns, the same + commit/reveal the CLI player uses. + +**Marginal AM-4a cost: zero.** Not "small" — zero. Emitting HTML is string +formatting; the 2,897-line `svg` crate measured above is not needed and is +not proposed. The input path costs no dependency either: a loopback +HTTP/1.1 listener sufficient for `GET /` and `POST /command` is +`std::net::TcpListener` plus roughly 250 lines of our own code, and our +own code is not what AM-4a governs. + +This is the central rule applied literally — *own the semantics; assimilate +the implementation*. The browser is the most thoroughly assimilated 2D +renderer available, and it is the one implementation we are guaranteed not +to have to vendor, audit, or keep on a target. + +**Own-code cost is a real cost.** ~250 lines of hand-rolled HTTP is code +we own, test and carry, and hand-rolled HTTP has a bad security history. +It listens on loopback only and speaks to a page it emitted itself, which +bounds the exposure but does not erase it. That trade — 250 own lines +against 480,501 third-party ones — is the decision ADR-0007 has to make +explicitly rather than inherit from this survey. + +## 6. What this cannot do + +Stated concretely, because the adversarial review (T02) should not have to +discover it: + +- **No frame loop.** Animation, drag *feedback* at pointer-event rates, + and anything requiring sustained redraw are the browser's problem, not + the port's — the Rust side emits state and receives commands. If stage 1 + later wants tweened card motion, it is written in the emitted JS, in a + language this repo otherwise does not use and has no gates for. +- **No offline binary.** A game that "ships" ships a process that wants a + browser. For a headless-first rules engine that is nearly free; for a + consumer product at stage 4 it is not. +- **A second language enters the build.** JavaScript in emitted strings is + untested by `cargo test` and invisible to every gate this repo has. + This is the strongest argument against, and the one T02 should press. +- **It is not stage 2's renderer.** Nothing here carries forward to wgpu + except the port interface — which is the argument *for* doing it, under + the second-use rule, but only if the interface is written to be + implemented twice rather than written around HTML. + +## 7. Recommendation + +1. **`cb-render-api`** — a canonical port over the existing `Project`ion: + state in, drawable primitives out, proposed commands back. No + assimilated types cross it (ArchitectureBlueprint §Never leak). +2. **`cb-render-null`** — the reference no-op, so tests and agents need + neither GPU nor browser. +3. **`cb-render-html`** — the first real implementation, marginal AM-4a + cost zero. +4. **`cb-render-wgpu` is stage 2's**, and is where the port interface gets + its second use and only then becomes canonical. +5. **AM-4a's proc-macro defect is filed separately** and is not a + precondition for any of the above. + +**The honest summary:** the constraint that looked like it would force +this decision turns out to be overstated by 36%, and the decision does not +change. The recommendation is what it would have been at 92,798 lines of +headroom, which is the only reason it is worth trusting at 3,750. diff --git a/workplans/CB-WP-0012-render-port.md b/workplans/CB-WP-0012-render-port.md index 6bb0036..1fc3d77 100644 --- a/workplans/CB-WP-0012-render-port.md +++ b/workplans/CB-WP-0012-render-port.md @@ -46,7 +46,7 @@ obstacle or an artifact of how AM-4a is instrumented. ```task id: CB-WP-0012-T01 -status: todo +status: done priority: high ``` @@ -74,6 +74,30 @@ that has been true for every pass that has cited it. Check this before recommending any budget change — a survey that argues for raising a target it has not first audited is arguing in its own favour. +**Done 2026-08-02.** [CB-RES-0006](../research/CB-RES-0006-render-port.md). +Two findings, and they point opposite ways. + +The cheapest candidate that opens a window (`macroquad`) costs a marginal +**480,501** lines — **128×** the headroom. `egui` + `eframe` costs +2,782,849. `ratatui`, the option one expects to be cheap, costs *more than +macroquad* (1,067,013) because `rustix` pulls `linux-raw-sys` at 479,901 +lines. + +And AM-4a is mis-instrumented: **36.2% of the shipped-runtime figure +(89,048 lines) is proc-macro crates** — `syn` alone is 66,916 — which run +in the compiler and never reach a binary. Real headroom is **92,798**, not +3,750. Every pass that cited 3,750, this workplan's own Purpose included, +cited a number wrong in the conservative direction. + +The recommendation survives the correction: `macroquad` is still 5.2× over +at 92,798. That was the condition for proposing the correction at all, and +the correction is filed separately rather than bundled with the decision +it would unblock. + +Recommended: `cb-render-api` + `cb-render-null` + **`cb-render-html`** — +emitted HTML/SVG/JS, marginal AM-4a cost **zero**, with `cb-render-wgpu` +left to stage 2 where it becomes the interface's second use. + ## Task: adversarial review of the survey ```task From c5fa610e597628409489dc698dfa41585efd95f6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 04:12:22 +0200 Subject: [PATCH 4/7] =?UTF-8?q?CB-WP-0012-T02:=20adversarial=20review=20?= =?UTF-8?q?=E2=80=94=20not=20approvable=20as=20written?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of six challenges conceded; the survey's two main arguments are replaced rather than clarified. C1 The survey concluded the sub-100k region was empty without ever measuring it. It is not empty: tiny-skia fits at 83,956, and the real windowed floor is fltk at 140,079 — 1.5x corrected headroom, not 128x. "Two orders of magnitude" is withdrawn. C1b Following that concession: wgpu+winit is 1,741,979 marginal lines against a 250,000 target. AM-4a is incompatible with INTENT stage 2 by 7x, and has been since both were written. Raised for the maintainer; deliberately not decided by this pass. C2 "Marginal cost zero" was scored on an axis chosen to produce zero, and the same relocation already flattered sdl2 and fltk in the survey's own table. One acquisition rule now covers all three, and it raises two candidates' cost rather than lowering the winner's. C3 The survey contradicted itself on the second-use rule. cb-render-api and cb-render-null are withdrawn; cb-render-html ships against the existing Project trait, and the port waits for stage 2's second use. C6 The candidate measurements did carry a positive control. The C1 batch did not — it copied dep-weight.py's measurement function without its guards — and was re-measured under them before being cited. Figures reproduced unchanged. The HTML row is relabelled 0 by construction, not by measurement. C4/C5 become six controls binding on ADR-0007. The recommendation stands. The reasoning that reached it does not. Co-Authored-By: Claude Opus 5 --- history/260802-render-port-challenge.md | 204 ++++++++++++++++++++++++ history/260802-render-port-research.md | 75 +++++++++ history/260802-render-port-response.md | 193 ++++++++++++++++++++++ research/CB-RES-0006-render-port.md | 181 ++++++++++++++++++--- workplans/CB-WP-0012-render-port.md | 39 ++++- 5 files changed, 665 insertions(+), 27 deletions(-) create mode 100644 history/260802-render-port-challenge.md create mode 100644 history/260802-render-port-research.md create mode 100644 history/260802-render-port-response.md diff --git a/history/260802-render-port-challenge.md b/history/260802-render-port-challenge.md new file mode 100644 index 0000000..6c3e7cd --- /dev/null +++ b/history/260802-render-port-challenge.md @@ -0,0 +1,204 @@ +# 260802 — challenge to CB-RES-0006 + +Adversarial review, one round, per InnerLoop §Step 2. + +**Fidelity note, first, because it caps everything below.** The spec asks +for a *separate session or agent*. This review was run in the same session +as the survey, because this environment's standing instruction is not to +spawn agents unless asked. So it does not have the property the step exists +to provide: the reviewer inherits the author's sampling, the author's +framing, and the author's blind spots. Every prior instance of this class in +the repo — CB-WP-0002's dedup sample, CB-WP-0005's AM-7 assertion — was a +shared blind spot between author and reviewer who sampled the same way. +Treat the challenges below as a lower bound on what a genuinely separate +reviewer would find. + +The claim rests on numbers, so per the table in §Step 2 the reviewer's job +is to reproduce them independently and to mutate the assertions behind +them — not to argue with the prose. + +--- + +## C1 — The survey concluded the sub-100k region was empty without looking + +**This is the challenge that lands.** + +The survey measures seven candidates. The cheapest windowed one costs +480,501. It then reports the corrected headroom as 92,798 and concludes +that no windowed toolkit fits — a conclusion about the interval +`0 < x < 92,798` drawn from a sample whose smallest windowed member is +480,501. Nothing was measured in the interval the conclusion is about. + +The candidate list is not random, either. It is the list a 2026 Rust +developer reaches for, which is a list of GPU-era stacks: everything on it +pulls `wgpu`, `winit`, or `rustix`. Cheap, old, thin bindings to system +libraries were structurally absent from the sample. + +Measured, same method, marginal against the same base: + +| candidate | marginal lines | vs 92,798 corrected headroom | +|---|---:|---:| +| `tiny-skia` alone | **83,956** | **fits**, at 90% of it | +| `fltk` | **140,079** | 1.5× | +| `sdl2` | **191,973** | 2.1× | +| `sdl3` | 276,445 | 3.0× | +| `raqote` | 286,852 | 3.1× | +| `minifb` | 774,389 | 8.3× | +| `pixels` | 863,528 | 9.3× | +| `speedy2d` | 1,328,008 | 14.3× | + +**The survey's central rhetorical move — "this is not a near miss to be +negotiated; it is two orders of magnitude" — is false.** It is two orders +of magnitude for the candidates the survey chose. The real floor for a +windowed toolkit is `fltk` at 140,079, which is **1.5×** the corrected +headroom. That is exactly a near miss to be negotiated. + +`tiny-skia` at 83,956 actually fits — it is a pure-Rust CPU rasterizer with +no windowing, so it is not by itself a table, but it disproves the survey's +stated interval claim on its own. + +**Required:** the survey must either withdraw the "two orders of magnitude" +framing and re-argue against a 1.5× floor, or concede that its +recommendation rests on something other than dependency cost. + +## C2 — "Marginal cost zero" is scored on an axis chosen to produce zero + +AM-4a measures vendored third-party **Rust**. The HTML option scores zero +because it relocates the renderer into a runtime AM-4a cannot see. The +browser is tens of millions of lines of unaudited third-party code. Calling +that zero is true under the metric and false in substance. + +If a proposal may satisfy a dependency target by moving the dependency +somewhere the target does not look, then the target constrains nothing, and +this pass is the first to demonstrate it. That is a worse outcome for the +project than buying `macroquad` honestly. + +**And the same critique convicts C1's cheap candidates.** `sdl2` at 191,973 +and `fltk` at 140,079 are thin Rust bindings to large C libraries; their +`.rs` line counts exclude the actual implementation for exactly the same +reason the browser's is excluded. So the metric already permits this move, +and the survey's own table already rewards it — it simply did not notice, +because it never measured a binding. + +**Required:** one rule, applied to all three. Either relocating an +implementation into an unmeasured runtime is permissible (and then `sdl2` +and `fltk` are legitimately cheap and must be compared on their merits, not +excluded on cost), or it is not (and then HTML is not free either). The +survey cannot have it one way for the browser and another for `sdl2` +without stating the distinction. It states none. + +## C3 — The survey contradicts itself on the second-use rule + +Recommendation 1 declares `cb-render-api` a canonical port now. +Recommendation 4 says `cb-render-wgpu` at stage 2 is "where the port +interface gets its second use and only then becomes canonical." + +Both cannot hold. INTENT: *"No concept becomes canonical merely because it +looks general. It becomes canonical after surviving a second concrete +use."* A port interface written against exactly one implementation, which +emits whole documents, will acquire a document's shape — retained mode, +full state per update, string identity, no frame timing — and stage 2 will +find it unimplementable and rewrite it. The survey names this risk in §6 +and mitigates it with "write it to be implemented twice," which is a wish. +`CommitWindow` is already on record in this repo as the concept that looked +general, has a delete-by date, and has declined two second-use +opportunities. + +**Required:** either defer `cb-render-api` until stage 2 supplies a second +implementation, and ship `cb-render-html` directly against the existing +`Project` trait — or state why this port is exempt from a rule INTENT +states without exception. + +## C4 — The 250-line HTTP server is undersold, and "loopback only" is not a boundary + +The survey bounds the risk with "listens on loopback only and speaks to a +page it emitted itself." Neither clause holds: + +- Any process on the machine can reach a loopback listener. Hot-seat play + is explicitly *several people at one machine*. +- **Any web page the user visits can reach it too.** A page on the open + internet can issue requests to `127.0.0.1` — that is what DNS rebinding + and cross-origin POST are. The browser running the table is the same + browser reading the internet; that is the whole premise of the option. +- "A page it emitted itself" is an assumption the server cannot check + unless it is written to check it, and the survey specifies no such check. + +An unauthenticated loopback endpoint that accepts `POST /command` and +mutates authoritative game state is a remote-controlled game from any tab +the user has open. + +**Required, as concrete controls the survey lacks:** an unguessable token +minted per process and required on every request; `Origin` and +`Sec-Fetch-Site` checked and rejected by default; the listener bound to +`127.0.0.1` explicitly rather than `0.0.0.0`; and a test that a request +without the token is refused. The last one is the one that matters — the +other three are claims until something asserts them. + +## C5 — The proposal regresses the finding the previous pass paid for + +CB-WP-0011's finding, in its own words: a renderer's defect class is +**silent omission**, no natural renderer assertion catches it, and the fix +is to walk the shape of the input and make silence cost a build. + +This proposal moves drag hit-testing and command construction into +JavaScript embedded in emitted strings — a language `cargo test` cannot +run, `clippy` cannot lint, and `M-D1-MUT` cannot mutate. The gate that +CB-WP-0011 built stops at the language boundary, and the survey proposes +putting the interactive half of stage 1 on the far side of it, one pass +after paying to learn why that is dangerous. + +§6 lists this as "the strongest argument against, and the one T02 should +press." Naming a challenge in advance is not answering it. + +**Required, as controls:** +- **JS may not construct commands.** The emitted page reports raw pointer + facts — "pointer down on element `id`, up on element `id`" — and Rust + decides what command that is. That confines JS to input transport and + makes the decision testable by feeding the Rust side synthetic events. +- **The coverage gate must cross the boundary.** `every_view_field_is_ + classified` must have an HTML counterpart asserting over the *parsed + emitted document*, not over the Rust that emits it. Emitting a `
` + per field is not evidence the field is visible; the assertion must be + that the token is in the document. + +## C6 — One number in the survey is reproduced; one is not independently checkable + +Reproduced: the proc-macro delta. `--edges normal,no-proc-macro` gives 18 +crates / 157,202 lines against 23 / 246,250. The 89,048 / 36.2% figure is +correct, and the five crates named are the five that differ. + +**Not independently checkable:** every candidate figure, because the +reviewer used the author's script. Per §Step 2 this is precisely the +failure mode — "the reviewer re-derives on a different sample than the +author used" — and no different sample was used. The C1 table above is +*new* data from the same instrument, which tests the survey's coverage but +not its instrument. + +**What the harness would report if it silently stopped:** `source_lines()` +returns 0 for a crate it cannot locate, and the probe scripts sum without +a positive control. A registry path change would report every candidate as +0 marginal lines — i.e. as *fitting comfortably* — which is the direction +that flatters the recommendation. `tools/dep-weight.py` has exactly this +control (`unlocatable crate measures zero (so the guard fires)`); the probe +scripts copied the measurement function and not the guard. + +The `svg-emit` row in the survey's table reads `0` marginal for reasons +that are correct, but a reader cannot distinguish that 0 from a harness +that stopped. **A zero that means "costs nothing" and a zero that means +"measured nothing" are printed identically.** That is HDN, in the survey's +own instrument, in the row carrying the recommendation. + +--- + +## Verdict + +**Not approvable as written.** C1, C2, C3 and C6 each require a change to +the survey, not a clarification. C4 and C5 require controls that must land +in ADR-0007 as conditions on the implementation. + +The recommendation may well survive — none of these establishes that a +windowed toolkit is affordable, only that the survey's argument for why it +is not was overstated and partly unmeasured. But the survey as written +argues from a false interval claim, an inconsistently applied metric rule, +an internal contradiction, and a zero that cannot be distinguished from a +dead harness. diff --git a/history/260802-render-port-research.md b/history/260802-render-port-research.md new file mode 100644 index 0000000..2e875c1 --- /dev/null +++ b/history/260802-render-port-research.md @@ -0,0 +1,75 @@ +# 260802 — how CB-RES-0006 was conducted + +Unpolished trail for `research/CB-RES-0006-render-port.md`, per +InnerLoop §Step 2 documentation requirement. + +## What was measured vs cited + +**Everything quoted is measured.** No line count in the survey comes from +a README, a crates.io page, or memory. Method: a scratch cargo crate per +candidate, `cargo add`, then `cargo tree --edges normal --prefix none` +over the resolved graph, then `\n` counted in every `.rs` file of the +vendored source under `~/.cargo/registry/src/*/`. + +That method is copied out of `tools/dep-weight.py` rather than +re-invented, because the survey's whole purpose is to subtract candidate +sizes from a budget produced by that tool, and two methods produce two +numbers that cannot be subtracted. + +Scripts: `$CLAUDE_JOB_DIR/tmp/survey/{measure,breakdown,gap}.py`. +Deliberately not committed — a throwaway probe promoted to a repo tool is +how the second-use rule gets broken by accident. If a later pass needs +these numbers refreshed, it should re-derive them, and the fact that it +costs ten minutes is a feature. + +## Dead ends and corrections during the survey + +**First reading was on the wrong axis.** The initial run reported headline +totals: `egui+eframe` 2,946,121, `ratatui+crossterm` 1,146,363, +`macroquad` 480,729. Those are the numbers a survey normally quotes and +they are the wrong ones — AM-4a charges what a candidate *adds* to a graph +that already holds 23 crates. Recomputed as marginal against the real +base. For `macroquad` the difference is negligible (480,729 → 480,501); +for `egui+eframe` it is 163,272 lines of overlap. + +**A network 403 that was not a network failure.** `curl` to crates.io +returned 403; the sandbox looked like it was blocking egress. It was a +missing user-agent — crates.io rejects requests without one. Two further +probes (sparse index, static.crates.io) returned 200. Worth recording +because "the environment is blocking me" is a conclusion that ends +surveys, and it was wrong here by one header. + +**Checked whether `cargo tree` was including non-host targets.** The +`ratatui` figure was dominated by `linux-raw-sys` (479,901) and looked +like it might be an all-platform artifact — a Windows-bindings inflation +of a Linux measurement. Re-ran with `--target x86_64-unknown-linux-gnu`: +identical, 1,146,363 both ways. The number is real, and `ratatui` really +does cost more than a 2D game framework. + +**The proc-macro finding was not what the survey set out to look for.** It +came from asking why `syn` was the largest single entry in a +"shipped-runtime" figure. `--edges normal,no-proc-macro` answered it: +89,048 lines, 36.2%, none of which reaches a binary. + +Order matters here, and it went the right way by luck rather than design: +the candidate measurements were complete *before* the instrument defect +was found, so the recommendation could be checked against the corrected +number instead of being derived from it. Had it gone the other way the +survey would have been unusable — a budget correction discovered while +looking for headroom is not evidence, whatever it says. + +## What the survey did not do + +- **It did not sample the region between 0 and 480,501 lines.** The + candidate list was assembled from what one reaches for — modern GPU-era + Rust GUI stacks — and every one of them landed two orders of magnitude + over. The conclusion "no windowed toolkit fits" was therefore drawn from + a sample that contained no small windowed toolkits. This is the gap the + challenge (T02) attacks, and it is a real one. +- **It did not run any candidate.** No candidate was built, drawn with, or + benchmarked. Per InnerLoop §Step 1 the comparisons here are dependency + cost only; nothing in the survey claims one toolkit draws better than + another, and no `better` verdict is available on that axis. +- **It did not measure the browser.** The HTML option is scored zero on + AM-4a because AM-4a measures vendored Rust. What the browser costs on + any other axis is unmeasured and the survey says so in §5. diff --git a/history/260802-render-port-response.md b/history/260802-render-port-response.md new file mode 100644 index 0000000..741b836 --- /dev/null +++ b/history/260802-render-port-response.md @@ -0,0 +1,193 @@ +# 260802 — response to the challenge on CB-RES-0006 + +One round, per InnerLoop §Step 2. Four challenges conceded, two answered +with evidence, and one of the concessions turned up something larger than +this pass. + +--- + +## C1 — the unmeasured interval — **conceded, and the framing withdrawn** + +The challenge is right and the survey's rhetoric was wrong. "Two orders of +magnitude" described the candidates I picked, and I picked them from what a +2026 Rust developer reaches for, which is a list of GPU-era stacks. The +real floor for a windowed toolkit is `fltk` at **140,079** marginal lines — +**1.5×** the corrected headroom, not 128×. + +The C1 table is folded into the survey as measured data, including +`tiny-skia` at 83,956, which fits inside 92,798 and disproves the interval +claim by itself. + +**So dependency cost alone does not decide this.** The survey's argument as +written is withdrawn. What replaces it is an allocation argument, and it is +stronger: + +INTENT stage 2 names `wgpu` explicitly. That is a **1,741,979-line** +marginal cost, already measured, already unavoidable, and it arrives one +stage from now. Against that, spending 1.5× the entire remaining budget on +a stage-1 windowing toolkit that stage 2 immediately discards is the wrong +allocation — not because it does not fit, but because it buys a thing with +a known expiry using money already committed elsewhere. + +That argument does not need AM-4a's target to be exactly 250,000. It only +needs stage 2's bill to be larger than stage 1's benefit, which is +measured, and true by a factor of twelve. + +## C1b — what the concession exposed: **AM-4a cannot survive stage 2** + +Following C1 honestly produces a finding neither the survey nor the +challenge was looking for. + +| | marginal lines | +|---|---:| +| AM-4a target, total | 250,000 | +| current shipped-runtime (corrected, proc-macro excluded) | 157,202 | +| headroom | 92,798 | +| `wgpu` + `winit`, which INTENT stage 2 names by name | **1,741,979** | + +Stage 2 exceeds the entire AM-4a target by **7×**, and exceeds the +remaining headroom by **19×**. No sequencing, no feature-gating and no +metric correction closes a gap that size. **AM-4a as targeted is +incompatible with INTENT as written**, and has been since both were +written; nothing in this pass caused it. + +That is a maintainer's decision, not mine — either the target moves, or the +port stays out of the AM-4a configuration by an argued rule, or stage 2 +changes. It is recorded here and carried to the evidence file, and it is +deliberately **not** resolved by this pass, because a pass that discovers a +budget conflict and also decides it has reviewed nothing. + +It does, however, retire one bad reason for the recommendation. "HTML, +because we cannot afford anything else" is false. "HTML, because stage 1 +should not spend stage 2's money on something stage 2 throws away" is true. + +## C2 — the metric is gameable by relocation — **conceded; one rule, stated** + +The challenge is right that the survey scored the browser at zero on an +axis chosen to produce zero, and right that the same move already makes +`sdl2` and `fltk` look cheap in the survey's own table. I had not noticed, +because I had measured no bindings. + +The rule, which belongs in AM-4's definition and not in this pass's +reasoning, applied consistently to all three: + +> **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. + +Under that rule, consistently: + +- a **browser** is not counted — nothing we ship or instruct causes it to + be acquired; +- **`sdl2`/`fltk`** are counted at more than their Rust binding, not less — + they oblige a user to install a `-dev` package they would not otherwise + have, so the survey's 191,973 and 140,079 **understate** them; +- and the rule makes the metric *harder* to satisfy by relocation, not + easier, which is the direction a rule proposed by the party it benefits + has to go before it is worth anything. + +This makes the HTML row honest rather than merely favourable. It also +means the survey's table understates two candidates, and that correction is +folded in. + +## C3 — the port contradicts the second-use rule — **conceded entirely** + +Recommendations 1 and 4 cannot both stand, and 4 is the one INTENT +supports. `CommitWindow` is already in this repo with a delete-by date for +exactly this mistake. + +**`cb-render-api` is withdrawn from this pass.** `cb-render-html` ships +against the existing `Project` trait, which is a real interface with real +implementations rather than a speculative one. The port gets declared at +stage 2, when `wgpu` supplies the second concrete use and the interface can +be derived from two implementations instead of imagined from one. + +This also removes `cb-render-null`: a null implementation of an interface +that does not exist yet is a null implementation of nothing. + +**And it changes the pass's tier.** The structural trigger for tier L was +"creates a new capability port." This pass no longer creates one. That is +recorded rather than acted on — the declaration and its roll stand, and the +pass runs at the weight it declared. Re-rolling a tier because review made +the work smaller would make the tier a function of the outcome, which is +the one thing a tier declaration must not be. **The CHAOS calibration +window gets a second entry from this**, and it is a more interesting one +than the first: tier L at full weight deleted its own structural trigger. + +## C4 — the loopback server — **conceded; controls carried to ADR-0007** + +The challenge is right that "loopback only" is not a boundary against a +browser that also reads the internet, and right that "a page it emitted +itself" is an assumption no code checks. All four controls are adopted as +conditions on the implementation, and the fourth is the load-bearing one: + +1. an unguessable token minted per process, required on every request; +2. `Origin` / `Sec-Fetch-Site` checked, rejected by default; +3. bound explicitly to `127.0.0.1`; +4. **a test that a request without the token is refused** — and, per + M-D1-MUT, a mutation that removes the check and turns that test red. + +Controls 1–3 without 4 are three claims and no evidence, which is the +class this project keeps finding in its own work. + +## C5 — the gate stops at the language boundary — **conceded; both controls adopted** + +Naming a challenge in the survey and calling it pressed was not an answer, +and the challenge says so correctly. + +1. **JavaScript may not construct commands.** The page reports raw pointer + facts; Rust decides what command they mean. JS becomes input transport, + and the decision becomes testable in Rust against synthetic events. +2. **The coverage gate crosses the boundary.** The HTML counterpart to + `every_view_field_is_classified` asserts over the *parsed emitted + document* — the token is in the document, or the build fails. Asserting + over the Rust that emits the document would reproduce CB-WP-0011's + original defect one layer up. + +## C6 — the zeros — **partly answered, partly conceded** + +**Answered:** the candidate measurements did carry the positive control the +challenge says they lacked. `measure.py` tracks an `unlocated` list per +candidate and it is empty for all seven — verified in `results.json`, not +asserted. So no candidate figure is a silently-stopped harness. + +**Conceded, twice over:** + +- `gap.py`, which produced the C1 table, copied the measurement function + **without** the guard. The C1 numbers therefore have no positive control. + They are load-bearing now, since they are what withdrew the survey's main + argument, so they must be re-measured under the guard before ADR-0007 + cites them. +- The survey's HTML row is **0 by construction, not by measurement** — + there is no crate to fail to locate. The challenge is right that a + reader cannot tell that zero from a dead one, and the survey printed them + identically in a table whose other rows are measurements. It is relabelled + rather than left to be read as a measured figure. + +The general observation is worth keeping: a probe script that copies a +tool's measurement function and not its controls is how a positive control +gets lost, and it happened here inside one pass, in a repo whose whole +discipline is positive controls. + +--- + +## What changed in the survey + +- The "two orders of magnitude" framing is **withdrawn**; the real floor is + `fltk` at 1.5× corrected headroom. +- The C1 candidates are folded in as measured rows, re-measured under the + positive control. +- The recommendation's justification changes from *affordability* to + *allocation against stage 2's known 1,741,979-line bill*. +- The AM-4 acquisition rule is stated, and it **raises** the cost of two + candidates rather than lowering the cost of the recommended one. +- **`cb-render-api` and `cb-render-null` are withdrawn from this pass.** +- The HTML row is relabelled *0 by construction*. +- C4 and C5's six controls become conditions in ADR-0007. +- **AM-4a's incompatibility with stage 2 is raised as a maintainer + decision** and explicitly not decided here. + +**Approvable after these changes.** The recommendation stands; the argument +that reached it does not, and has been replaced. diff --git a/research/CB-RES-0006-render-port.md b/research/CB-RES-0006-render-port.md index b74e7c1..c3e9a74 100644 --- a/research/CB-RES-0006-render-port.md +++ b/research/CB-RES-0006-render-port.md @@ -22,11 +22,20 @@ verbatim is the point — a survey that measures candidates one way and the budget another produces two numbers that cannot be subtracted. Probe crates were built in a scratch directory, one per candidate, and -resolved against the live registry. The script is -`$CLAUDE_JOB_DIR/tmp/survey/measure.py`; it is not committed, because a -throwaway probe promoted to a tool is how the second-use rule gets broken +resolved against the live registry. The scripts are not committed, because +a throwaway probe promoted to a tool is how the second-use rule gets broken by accident. +**Positive control.** `source_lines()` returns 0 for a crate it cannot +locate, so a registry path change would report every candidate as costing +nothing — the direction that flatters the recommendation. Every figure +below was produced under two guards copied from `dep-weight.py`: an +unlocatable crate must measure zero, a real one must measure non-zero, and +any candidate containing an unlocated crate is reported rather than summed. +No candidate contained one. The review (T02) found that the second batch of +measurements had copied the measurement function *without* the guards; they +were re-run under them before being cited here, and reproduced unchanged. + **Marginal, not total.** A candidate's headline size overstates its cost by whatever it already shares with the base graph. What AM-4a charges is the delta. @@ -44,11 +53,39 @@ Base: `games-ground --no-default-features`, 23 third-party crates, | `ratatui` + `crossterm` | 1,146,363 | 79,350 | **1,067,013** | 285× | | `egui` alone (no windowing) | 778,067 | 110,542 | **667,525** | 178× | | `macroquad` | 480,729 | 228 | **480,501** | 128× | -| HTML/SVG emission, no Rust toolkit | — | — | **0** | 0× | +| HTML/SVG emission, no Rust toolkit | — | — | **0 by construction** | — | -The cheapest candidate that opens a window costs **128 times** the entire -remaining budget. This is not a near miss to be negotiated; it is two -orders of magnitude. +**Revised after review (T02).** The first draft of this section concluded +from the table above that no windowed toolkit could fit, and called it +"two orders of magnitude, not a near miss." **That was drawn from a sample +containing no small windowed toolkits.** Every candidate above is a +GPU-era stack, because that is what one reaches for; cheap bindings to +system libraries were structurally absent. Measured, same method, same +base, under the positive control: + +| candidate | marginal | vs 92,798 corrected headroom (§3) | +|---|---:|---:| +| `tiny-skia` alone (rasterizer, no window) | **83,956** | **fits**, at 90% | +| `termion` | 133,130 | 1.4× | +| `fltk` | **140,079** | **1.5×** | +| `sdl2` | 191,973 | 2.1× | +| `sdl3` | 276,445 | 3.0× | +| `raqote` | 286,852 | 3.1× | +| `minifb` | 774,389 | 8.3× | +| `crossterm` alone | 802,677 | 8.6× | +| `softbuffer` alone | 909,416 | 9.8× | +| `pixels` | 863,528 | 9.3× | +| `speedy2d` | 1,328,008 | 14.3× | + +The real floor for a windowed toolkit is `fltk` at **1.5×** the corrected +headroom, and `tiny-skia` fits outright. **The "two orders of magnitude" +claim is withdrawn**, and with it the survey's original argument: +dependency cost alone does not decide this. What replaces it is §4. + +**The `0` in the first table is by construction, not by measurement** — +there is no crate to fail to locate. It is relabelled because a zero +meaning "costs nothing" and a zero meaning "measured nothing" print +identically, and every other row is a measurement. **A surprise worth recording:** `ratatui` — a *terminal* library, the option one reaches for expecting it to be the cheap one — costs more than @@ -103,10 +140,37 @@ loop, physics, or 60fps. Those are stage 2 (*"Physical 3D tabletop — wgpu renderer, Rapier-backed physics"*), and stage 2 is where the 1.7M lines of `wgpu` + `winit` get argued for on their merits. -Buying a GPU toolkit now would mean paying stage 2's dependency bill to -satisfy stage 1's requirements, one stage early, against a budget that -cannot cover it, and before the port interface has ever had a second -implementation to be validated against. +### The allocation argument, which replaces the affordability one + +`fltk` at 140,079 is affordable if AM-4a's target moves a little. The +reason not to buy it is not cost but **allocation**: + +| | marginal lines | +|---|---:| +| corrected headroom (§3) | 92,798 | +| cheapest windowed stage-1 toolkit (`fltk`) | 140,079 | +| **`wgpu` + `winit`, which INTENT stage 2 names by name** | **1,741,979** | + +Stage 2's renderer is measured, unavoidable, named in INTENT, and **twelve +times** the cost of the stage-1 toolkit it would replace. Spending 1.5× the +entire remaining budget on a windowing stack that stage 2 discards is the +wrong allocation regardless of whether the budget can be stretched to +cover it. That argument does not depend on AM-4a's target being exactly +250,000 — only on stage 2's bill exceeding stage 1's benefit, which is +measured and true by a factor of twelve. + +### AM-4a cannot survive stage 2, and that is not this pass's to decide + +Following the above honestly produces a finding this survey did not set out +to look for. Stage 2 exceeds AM-4a's **entire target** by 7× and the +remaining headroom by 19×. No sequencing, feature-gating, or metric +correction closes a gap that size: **AM-4a as targeted is incompatible with +INTENT as written**, and has been since both were written. + +Either the target moves, or the render port stays outside the AM-4a +configuration by an argued rule, or stage 2 changes. That is a maintainer's +decision. It is raised here and **deliberately not resolved** — a pass that +discovers a budget conflict and also settles it has reviewed nothing. ## 5. The option that is not a Rust toolkit @@ -123,6 +187,33 @@ the machine already has. - **hot-seat play** — one browser tab, seats taking turns, the same commit/reveal the CLI player uses. +### The rule that makes "zero" honest (added by review) + +Scoring the browser at zero is true under AM-4a and suspect in substance: a +browser is tens of millions of lines of unaudited third-party code. If a +proposal can satisfy a dependency target by relocating the dependency +somewhere the target does not look, the target constrains nothing. + +The same critique convicts `sdl2` and `fltk` above — thin Rust bindings +whose `.rs` counts exclude the C library that does the work. The metric +already permits the move; this survey simply never measured a binding +before, so it never noticed. + +One rule, applied to all three, belonging in AM-4's definition rather than +in this pass's reasoning: + +> **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. + +Under it: a browser is not counted; **`sdl2` and `fltk` are counted at more +than their Rust binding, not less**, because they oblige a user to install +a `-dev` package they would not otherwise have. The rule makes the metric +*harder* to satisfy by relocation, which is the direction a rule proposed +by the party it benefits has to run before it is worth anything. + **Marginal AM-4a cost: zero.** Not "small" — zero. Emitting HTML is string formatting; the 2,897-line `svg` crate measured above is not needed and is not proposed. The input path costs no dependency either: a loopback @@ -163,21 +254,59 @@ discover it: the second-use rule, but only if the interface is written to be implemented twice rather than written around HTML. -## 7. Recommendation +## 7. Recommendation (revised after review) -1. **`cb-render-api`** — a canonical port over the existing `Project`ion: - state in, drawable primitives out, proposed commands back. No - assimilated types cross it (ArchitectureBlueprint §Never leak). -2. **`cb-render-null`** — the reference no-op, so tests and agents need - neither GPU nor browser. -3. **`cb-render-html`** — the first real implementation, marginal AM-4a - cost zero. -4. **`cb-render-wgpu` is stage 2's**, and is where the port interface gets - its second use and only then becomes canonical. -5. **AM-4a's proc-macro defect is filed separately** and is not a +The first draft recommended declaring `cb-render-api` and `cb-render-null` +now, while also saying the interface becomes canonical only at stage 2's +second use. **Those two cannot both hold**, and INTENT's rule is the one +that survives: *"No concept becomes canonical merely because it looks +general. It becomes canonical after surviving a second concrete use."* A +port written against exactly one implementation that emits whole documents +will acquire a document's shape — retained mode, full state per update, +string identity, no frame timing — and stage 2 will find it +unimplementable. `CommitWindow` is already in this repo with a delete-by +date for precisely this mistake. + +1. **No port this pass.** `cb-render-api` and `cb-render-null` are + withdrawn. A null implementation of an interface that does not exist is + a null implementation of nothing. +2. **`cb-render-html`** ships against the **existing `Project` trait** — a + real interface with real implementations, not a speculative one. + Marginal AM-4a cost zero, honestly zero under §5's rule. +3. **The port is declared at stage 2**, derived from two implementations + instead of imagined from one. +4. **AM-4a's proc-macro defect is filed separately**, and is not a precondition for any of the above. +5. **AM-4a's incompatibility with stage 2 (§4) goes to the maintainer.** -**The honest summary:** the constraint that looked like it would force -this decision turns out to be overstated by 36%, and the decision does not -change. The recommendation is what it would have been at 92,798 lines of -headroom, which is the only reason it is worth trusting at 3,750. +### Six controls the implementation must carry + +From the review, and binding on ADR-0007: + +| # | control | +|---|---| +| 1 | an unguessable token minted per process, required on every request | +| 2 | `Origin` / `Sec-Fetch-Site` checked, rejected by default | +| 3 | listener bound explicitly to `127.0.0.1` | +| 4 | **a test that a token-less request is refused**, with an M-D1-MUT mutation removing the check and turning it red | +| 5 | **JavaScript may not construct commands** — the page reports raw pointer facts, Rust decides what command they mean | +| 6 | **the coverage gate crosses the language boundary** — the HTML counterpart of `every_view_field_is_classified` asserts over the *parsed emitted document*, not over the Rust that emits it | + +Controls 1–3 without 4 are three claims and no evidence. Control 6 exists +because asserting over the emitting code would reproduce CB-WP-0011's +original defect one layer up. + +### The honest summary + +Two of this survey's three arguments did not survive review. The budget +constraint that appeared to force the decision was overstated by 36% by a +mis-instrumented metric, and *then* overstated again by a candidate list +that contained no cheap windowed toolkits — the real floor is 1.5× the +corrected headroom, not 128×. The port the survey proposed to build +contradicted the rule the same survey cited. + +What survives is narrower and better founded: stage 1 should not spend +stage 2's money on a toolkit stage 2 discards, and it should not canonize +an interface it can only implement once. The recommendation is unchanged. +The reasoning that reached it has been replaced, which is what the review +step is for. diff --git a/workplans/CB-WP-0012-render-port.md b/workplans/CB-WP-0012-render-port.md index 1fc3d77..c6fbe46 100644 --- a/workplans/CB-WP-0012-render-port.md +++ b/workplans/CB-WP-0012-render-port.md @@ -102,7 +102,7 @@ left to stage 2 where it becomes the interface's second use. ```task id: CB-WP-0012-T02 -status: todo +status: done priority: high ``` @@ -120,6 +120,43 @@ it. The review must name, at minimum: A review that ratifies the survey without producing a control the survey lacked has not run. +**Done 2026-08-02.** [challenge](../history/260802-render-port-challenge.md), +[response](../history/260802-render-port-response.md). **Not approvable as +written** — four of six challenges conceded, and the survey's two main +arguments replaced. + +- **C1**: the survey concluded the sub-100k region was empty without + measuring it. It is not empty. `tiny-skia` fits at 83,956; the real + windowed floor is `fltk` at **140,079 — 1.5×** corrected headroom, not + 128×. "Two orders of magnitude" withdrawn. +- **C1b**, which the concession exposed: `wgpu` + `winit` is **1,741,979** + marginal lines against a 250,000 target. **AM-4a is incompatible with + INTENT stage 2** — 7× the whole target — and has been since both were + written. Raised for the maintainer, deliberately not decided here. +- **C2**: "marginal cost zero" was scored on an axis chosen to produce + zero, and the same move already flattered `sdl2`/`fltk`. One acquisition + rule now covers all three, and it *raises* two candidates' cost. +- **C3**: the survey contradicted itself on the second-use rule. + **`cb-render-api` and `cb-render-null` are withdrawn from this pass.** +- **C4/C5**: six controls adopted as binding on ADR-0007 — a loopback + token with a mutation-backed refusal test, JS barred from constructing + commands, and the coverage gate crossing the language boundary. +- **C6**: the candidate measurements did carry a positive control (empty + `unlocated` for all seven, verified). The C1 batch did **not** — it + copied the measurement function without the guards — and was re-measured + under them before being cited. The HTML row is relabelled *0 by + construction, not by measurement*. + +**Fidelity note:** the review ran in the same session as the survey rather +than a separate one, per this environment's standing instruction not to +spawn agents unasked. It therefore inherits the author's sampling and is a +lower bound on what a separate reviewer would find. + +**Note on tier:** this pass no longer creates a capability port, which was +its structural trigger for tier L. The declaration and its roll stand — a +tier that changes because review shrank the work would be a function of the +outcome. Recorded as the CHAOS window's second entry. + ## Task: ADR-0007 — the render port ```task From 883b608f3632d5984df85100976fd34e7e2474ab Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 04:13:35 +0200 Subject: [PATCH 5/7] =?UTF-8?q?CB-WP-0012-T03:=20ADR-0007=20=E2=80=94=20re?= =?UTF-8?q?nder=20to=20HTML,=20and=20do=20not=20declare=20the=20port=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- decisions/ADR-0007-render-html-not-a-port.md | 173 +++++++++++++++++++ workplans/CB-WP-0012-render-port.md | 13 +- 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 decisions/ADR-0007-render-html-not-a-port.md diff --git a/decisions/ADR-0007-render-html-not-a-port.md b/decisions/ADR-0007-render-html-not-a-port.md new file mode 100644 index 0000000..264d44b --- /dev/null +++ b/decisions/ADR-0007-render-html-not-a-port.md @@ -0,0 +1,173 @@ +# 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. diff --git a/workplans/CB-WP-0012-render-port.md b/workplans/CB-WP-0012-render-port.md index c6fbe46..8708ab7 100644 --- a/workplans/CB-WP-0012-render-port.md +++ b/workplans/CB-WP-0012-render-port.md @@ -161,7 +161,7 @@ outcome. Recorded as the CHAOS window's second entry. ```task id: CB-WP-0012-T03 -status: todo +status: done priority: high ``` @@ -169,6 +169,17 @@ Record the decision, the rejected alternatives with their measured costs, and the controls the implementation must carry. No implementation code lands before this commits. +**Done 2026-08-02.** +[ADR-0007](../decisions/ADR-0007-render-html-not-a-port.md) — *render to +HTML, and do not declare the port yet.* Five decisions, eight rejected +alternatives with measured costs, six binding controls, and **two items +reserved for the maintainer**: AM-4a's 7× incompatibility with stage 2, and +whether the acquisition rule proposed by the pass that benefits from it is +the right rule. + +The hard gate held: no render implementation code existed before this +commit. + ## Task: the port and its first implementation ```task From 84d688688d5773953738fe30999843dae9ce3aeb Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 04:27:25 +0200 Subject: [PATCH 6/7] =?UTF-8?q?CB-WP-0012-T04:=20cb-render-html=20?= =?UTF-8?q?=E2=80=94=20stage=201=20draws,=20and=20the=20browser=20is=20the?= =?UTF-8?q?=20toolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 10 + Cargo.toml | 2 + crates/cb-render-html/Cargo.toml | 19 + crates/cb-render-html/src/doc.rs | 544 +++++++++++++++++++++++++++ crates/cb-render-html/src/input.rs | 270 +++++++++++++ crates/cb-render-html/src/lib.rs | 333 ++++++++++++++++ crates/cb-render-html/src/serve.rs | 323 ++++++++++++++++ crates/cb-render-html/src/testfix.rs | 118 ++++++ research/CB-RES-0006-render-port.md | 8 + tools/cb-play/Cargo.toml | 1 + tools/cb-play/src/hotseat.rs | 373 ++++++++++++++++++ tools/cb-play/src/inspect.rs | 1 + tools/cb-play/src/main.rs | 20 +- tools/cb-play/src/table.rs | 38 +- 14 files changed, 2058 insertions(+), 2 deletions(-) create mode 100644 crates/cb-render-html/Cargo.toml create mode 100644 crates/cb-render-html/src/doc.rs create mode 100644 crates/cb-render-html/src/input.rs create mode 100644 crates/cb-render-html/src/lib.rs create mode 100644 crates/cb-render-html/src/serve.rs create mode 100644 crates/cb-render-html/src/testfix.rs create mode 100644 tools/cb-play/src/hotseat.rs diff --git a/Cargo.lock b/Cargo.lock index 5a6ecd7..86e13e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,11 +81,21 @@ dependencies = [ "cb-events", "cb-game-runtime", "cb-kernel", + "cb-render-html", "games-ground", "serde_json", "serde_yaml", ] +[[package]] +name = "cb-render-html" +version = "0.1.0" +dependencies = [ + "cb-kernel", + "games-ground", + "serde_json", +] + [[package]] name = "cb-sim" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9952ba4..52875e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/cb-kernel", "crates/cb-events", "crates/cb-game-runtime", + "crates/cb-render-html", "games/ground", "tools/cb-sim", "tools/cb-play", @@ -19,6 +20,7 @@ cb-kernel = { path = "crates/cb-kernel" } cb-events = { path = "crates/cb-events" } cb-game-runtime = { path = "crates/cb-game-runtime", default-features = false } games-ground = { path = "games/ground", default-features = false } +cb-render-html = { path = "crates/cb-render-html" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/crates/cb-render-html/Cargo.toml b/crates/cb-render-html/Cargo.toml new file mode 100644 index 0000000..4435a33 --- /dev/null +++ b/crates/cb-render-html/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cb-render-html" +edition.workspace = true +version.workspace = true +license-file.workspace = true + +# ADR-0007 Decision 1: the browser is the renderer, so this crate has +# **no third-party dependencies at all** beyond what the game already +# carries. Adding one here needs an argument against ADR-0007 §Decision 3. +[dependencies] +cb-kernel.workspace = true +games-ground.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +# The coverage gate walks the serialized view; nothing else needs it. +serde_json.workspace = true diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs new file mode 100644 index 0000000..503f09b --- /dev/null +++ b/crates/cb-render-html/src/doc.rs @@ -0,0 +1,544 @@ +//! The emitted document: HTML shell, inline SVG table, inline JavaScript. +//! +//! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string +//! formatting, and the browser draws it. +//! +//! ## What the JavaScript is allowed to be +//! +//! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is +//! therefore the whole of it, it is a constant, and it does one thing: +//! record which element the pointer went down on, which it came up on, and +//! POST the pair. It contains no game vocabulary — no action names, no +//! seats, no rules. If a rule ever needs to appear in it, the decision is +//! wrong and ADR-0007 says to revisit it rather than widen the control. + +use std::fmt::Write as _; + +use cb_kernel::PlayerId; +use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; + +use crate::input::action_id; + +/// Escape for text and attribute contexts alike. +/// +/// Everything interpolated into the document goes through this. Card +/// suits and seat numbers cannot currently carry a `<`, but "cannot +/// currently" is how injection bugs are written. +pub fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +fn seat_name(p: PlayerId) -> String { + format!("P{}", p.0 + 1) +} + +fn cards(list: &[games_ground::SolutionCard]) -> String { + if list.is_empty() { + return "none".to_string(); + } + list.iter() + .map(|c| format!("{:?}", c.suit)) + .collect::>() + .join(" ") +} + +/// The only JavaScript in the project. See the module docs. +pub const SCRIPT: &str = r#" +(function () { + var down = null; + function id(e) { + var n = e.target; + while (n && !n.id) { n = n.parentNode; } + return n ? n.id : null; + } + document.addEventListener('pointerdown', function (e) { down = id(e); }); + document.addEventListener('pointerup', function (e) { + var up = id(e); + if (!down || !up) { down = null; return; } + var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up); + down = null; + fetch(window.CB_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body + }).then(function (r) { return r.text(); }).then(function (t) { + document.getElementById('cb-status').textContent = t; + if (t.indexOf('ok') === 0) { window.location.reload(); } + }); + }); +})(); +"#; + +const STYLE: &str = " +body{font:14px/1.5 ui-monospace,monospace;margin:1.5rem;background:#12141a;color:#dde} +h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} +.row{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-start} +.card{border:1px solid #445;border-radius:6px;padding:.5rem .7rem;background:#1b1e26} +.card[data-viewer=true]{border-color:#9cf} +.act{cursor:grab;user-select:none;background:#243;border-color:#5a7} +.btn{cursor:pointer;background:#332a3a;border-color:#a7d} +.k{color:#89a} +#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em} +svg{background:#1b1e26;border:1px solid #445;border-radius:6px} +"; + +fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { + let (label, sub, fill) = match p { + ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"), + ProblemView::FaceUp { + suit, + value, + denied, + claimed_by, + protected_this_round, + } => { + let mut sub = String::new(); + if *denied { + sub.push_str("denied "); + } + if *protected_this_round { + sub.push_str("protected "); + } + if let Some(c) = claimed_by { + let _ = write!(sub, "claimed by {}", seat_name(*c)); + } + ( + format!("{suit:?} {value}"), + sub.trim_end().to_string(), + "#26303a", + ) + } + }; + let _ = write!( + out, + "\ + {label}\ + priority {priority}\ + {sub}", + x = x, + tx = x + 10, + label = esc(&label), + sub = esc(&sub), + ); +} + +/// The relationship graph — the one element every Rust 2D toolkit would +/// have left us to hand-roll, and the reason SVG earns its place here +/// rather than merely fitting the budget (CB-RES-0006 §5). +fn relations_svg(view: &GroundView) -> String { + let n = view.players.len().max(1); + let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64); + let pos: Vec<(PlayerId, f64, f64)> = view + .players + .keys() + .enumerate() + .map(|(i, p)| { + let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2; + (*p, cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + let find = |p: PlayerId| { + pos.iter() + .find(|(q, _, _)| *q == p) + .map(|(_, x, y)| (*x, *y)) + }; + + let mut s = String::from( + "", + ); + for (pair, rel) in &view.relations { + if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) { + let colour = match rel { + games_ground::Relation::Bond => "#5c9", + games_ground::Relation::Rivalry => "#c66", + }; + let _ = write!( + s, + "\ + {rel:?}", + mx = (x1 + x2) / 2.0, + my = (y1 + y2) / 2.0 - 3.0, + ); + } + } + for (p, x, y) in &pos { + let is_viewer = view.viewer == Some(*p); + let focus = view + .focus + .get(p) + .map(|f| format!(" \u{2192}{}", seat_name(*f))); + let _ = write!( + s, + "\ + {name}\ + {focus}", + raw = p.0, + stroke = if is_viewer { "#9cf" } else { "#5a6b7a" }, + ty = y + 2.0, + ty2 = y + 16.0, + name = seat_name(*p), + focus = esc(focus.as_deref().unwrap_or("")), + ); + } + s.push_str(""); + s +} + +fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) { + let is_viewer = view.viewer == Some(id); + let _ = write!( + out, + "
{name}{you}
", + name = seat_name(id), + you = if is_viewer { " (you)" } else { "" }, + ); + let _ = write!( + out, + "stress {} protect {} \ + darvo {:?}
", + p.stress, p.protection, p.darvo + ); + let _ = write!( + out, + "freedom \ + {ready}{lifted}
", + raw = id.0, + ready = if p.freedom_ready { "READY" } else { "spent" }, + lifted = if p.freedom_gate_lifted { + " gate lifted" + } else { + "" + }, + ); + let blame = if p.blame_from.is_empty() { + "none".to_string() + } else { + p.blame_from + .iter() + .map(|b| seat_name(*b)) + .collect::>() + .join(" ") + }; + let _ = write!( + out, + "blamed by {}
", + esc(&blame) + ); + match &p.hand { + Some(h) => { + let _ = write!( + out, + "hand {} ({} cards)
", + esc(&cards(h)), + p.hand_size + ); + } + None => { + let _ = write!( + out, + "hand {} card(s), hidden
", + p.hand_size + ); + } + } + if let Some(sel) = view.selections.get(&id) { + let _ = write!( + out, + "selected {}
", + match sel { + SelectionView::Hidden => "face down".to_string(), + SelectionView::Shown(s) => esc(&format!("{s:?}")), + } + ); + } + if let Some(m) = view.ground_modes.get(&id) { + let _ = write!(out, "ground mode {m:?}
"); + } + if let Some(c) = view.ground_choices.get(&id) { + let _ = write!(out, "ground choice {c:?}
"); + } + if let Some(r) = view.support_responses.get(&id) { + let _ = write!(out, "support {r:?}
"); + } + if let Some(t) = view.darvo_targets.get(&id) { + let _ = write!(out, "darvo target {t:?}
"); + } + out.push_str("
"); +} + +/// Render the whole table as a standalone document. +/// +/// `may_pass` adds the one affordance that is not a command: declining to +/// act where the driver allows it. Like every other element it carries an +/// id and nothing else — the page still reports only that the pointer went +/// down and up on `pass`. +/// +/// `legal` is the list the aggregate offered; the buttons carry indices +/// into it and nothing else (ADR-0007 control 5). `endpoint` carries the +/// per-process token (control 1) — the page cannot mint one. +pub fn document( + view: &GroundView, + legal: &[games_ground::GroundCommand], + endpoint: &str, + seat: Option, + may_pass: bool, +) -> String { + let mut s = String::with_capacity(8192); + let _ = write!( + s, + "\ + \ + GROUND \u{2014} round {round}", + round = view.round + ); + + let _ = write!( + s, + "

GROUND \u{2014} round {round}, step {step:?}

\ +
lead {lead} \ + scoring {mode:?} \ + viewing as {who}
", + round = view.round, + step = view.step, + lead = seat_name(view.lead), + mode = view.mode, + who = match view.viewer { + Some(p) => format!("{} (their hand only)", seat_name(p)), + None => "a spectator (no hands)".to_string(), + }, + ); + + s.push_str( + "

problems

", + ); + if view.problems.is_empty() { + s.push_str( + "no problems in play", + ); + } + for (i, (priority, p)) in view.problems.iter().enumerate() { + problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130); + } + s.push_str(""); + + s.push_str("

relationships

"); + s.push_str(&relations_svg(view)); + + s.push_str("

seats

"); + for (id, p) in &view.players { + player_card(&mut s, *id, p, view); + } + s.push_str("
"); + + let _ = write!( + s, + "

solutions

deck {} remaining \ + discard {}
", + view.solution_deck_len, + esc(&cards(&view.solution_discard)), + ); + + if let Some(o) = &view.outcome { + let personal = o + .personal + .iter() + .map(|(p, v)| format!("{} {v:+}", seat_name(*p))) + .collect::>() + .join(" "); + let winners = if o.winners.is_empty() { + "nobody".to_string() + } else { + o.winners + .iter() + .map(|w| seat_name(*w)) + .collect::>() + .join(" ") + }; + let coalitions = if o.coalitions.is_empty() { + "none".to_string() + } else { + o.coalitions + .iter() + .map(|c| format!("{c:?}")) + .collect::>() + .join(" ") + }; + let _ = write!( + s, + "

outcome

\ + total {total} of {threshold} \ + group {group}
\ + personal {personal}
\ + coalitions {coalitions}
\ + mastery {mastery}
\ + winners {winners}
", + total = o.total, + threshold = o.threshold, + group = if o.group_success { + "success" + } else { + "failure" + }, + personal = esc(&personal), + coalitions = esc(&coalitions), + mastery = match o.mastery { + Some(m) => format!("{m:+}"), + None => "none".to_string(), + }, + winners = esc(&winners), + ); + } + + if !legal.is_empty() { + s.push_str("

your move

"); + for a in [ + games_ground::Action::Investigate, + games_ground::Action::Solve, + games_ground::Action::Support, + games_ground::Action::Attack, + games_ground::Action::Ground, + ] { + let offered = seat.is_some_and(|seat| { + legal.iter().any(|c| { + crate::input::affordance(c, seat).is_some_and(|(f, _)| f == action_id(a)) + }) + }); + if offered { + let _ = write!( + s, + "
drag {a:?} onto a seat, a problem, \ + or the table
", + id = action_id(a), + ); + } + } + s.push_str("
"); + for (i, c) in legal.iter().enumerate() { + let spatial = seat.is_some_and(|seat| crate::input::affordance(c, seat).is_some()); + if !spatial { + let _ = write!( + s, + "
{}
", + esc(&format!("{c:?}")) + ); + } + } + s.push_str( + "
the table \u{2014} drop here for an \ + untargeted action
", + ); + } + if may_pass { + s.push_str("
pass \u{2014} decline to act
"); + } + + let _ = write!( + s, + "
ready
\ + ", + json_string(endpoint), + ); + s +} + +/// Quote a string as a JSON literal, so a token can never end the script. +fn json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '<' => out.push_str("\\u003c"), + '>' => out.push_str("\\u003e"), + '&' => out.push_str("\\u0026"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Extract the document's visible text and element ids. +/// +/// ADR-0007 control 6 requires the coverage gate to assert over the +/// **parsed emitted document**, not over the Rust that emits it. This is +/// that parse: it drops markup and returns what a reader would see, plus +/// the ids a pointer can address. A substring search over the raw source +/// would happily find a token inside a comment or a style rule. +pub fn text_of(html: &str) -> String { + let mut out = String::with_capacity(html.len() / 2); + let bytes: Vec = html.chars().collect(); + let mut i = 0; + let mut skip_to: Option<&str> = None; + while i < bytes.len() { + if bytes[i] == '<' { + // Find the tag name. + let start = i + 1; + let mut j = start; + while j < bytes.len() && bytes[j] != '>' { + j += 1; + } + let tag: String = bytes[start..j.min(bytes.len())].iter().collect(); + let lower = tag.to_ascii_lowercase(); + let name = lower + .trim_start_matches('/') + .split([' ', '\t', '\n', '>']) + .next() + .unwrap_or("") + .to_string(); + + if let Some(want) = skip_to { + if lower.starts_with('/') && name == want { + skip_to = None; + } + } else if name == "script" || name == "style" { + // Their contents are not text a reader sees. + if !lower.starts_with('/') && !lower.ends_with('/') { + skip_to = Some(if name == "script" { "script" } else { "style" }); + } + } else { + // Ids are addressable surface, so they count as rendered. + if let Some(k) = lower.find("id=\"") { + let rest = &tag[k + 4..]; + if let Some(end) = rest.find('"') { + out.push(' '); + out.push_str(&rest[..end]); + } + } + } + i = j + 1; + continue; + } + if skip_to.is_none() { + out.push(bytes[i]); + } + i += 1; + } + // Unescape the entities esc() introduced, so a test looks for the text + // a reader sees rather than its encoding. + out.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} diff --git a/crates/cb-render-html/src/input.rs b/crates/cb-render-html/src/input.rs new file mode 100644 index 0000000..8e2e604 --- /dev/null +++ b/crates/cb-render-html/src/input.rs @@ -0,0 +1,270 @@ +//! ADR-0007 Decision 5: **JavaScript may not construct commands.** +//! +//! The emitted page reports raw pointer facts — "the pointer went down on +//! element `a` and came up on element `b`" — and nothing else. This module +//! is where Rust decides what command that means. +//! +//! The rule exists because CB-WP-0011 established that a renderer's defect +//! class is silent omission, and ADR-0007 moves the interactive half of +//! stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT` cannot +//! reach. Confining JavaScript to input transport keeps the decision on +//! this side of the boundary, where it can be tested against synthetic +//! events with no browser in the loop — which is exactly what the tests +//! below do. +//! +//! K13 is unaffected: resolving a drag produces an **index into the legal +//! list the aggregate already offered**. A drag can never name a command +//! the aggregate did not enumerate, so the projection cannot feed back +//! into validation. + +use games_ground::{Action, GroundCommand}; + +/// What the browser is permitted to tell us: two element ids. +/// +/// Deliberately not "a move", "a command", or anything carrying game +/// meaning. If this struct ever grows a field with a rule in it, control 5 +/// has been breached and ADR-0007 says to revisit the decision rather than +/// widen the control. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PointerFact { + pub down: String, + pub up: String, +} + +impl PointerFact { + pub fn new(down: &str, up: &str) -> Self { + Self { + down: down.to_string(), + up: up.to_string(), + } + } + + /// Parse the wire form `down=&up=`. Anything else is refused. + pub fn parse(body: &str) -> Result { + let (mut down, mut up) = (None, None); + for pair in body.split('&') { + match pair.split_once('=') { + Some(("down", v)) => down = Some(v.to_string()), + Some(("up", v)) => up = Some(v.to_string()), + _ => return Err(format!("unrecognised field in pointer fact: {pair:?}")), + } + } + match (down, up) { + (Some(down), Some(up)) if !down.is_empty() && !up.is_empty() => Ok(Self { down, up }), + _ => Err("a pointer fact needs a non-empty down and up".to_string()), + } + } +} + +pub fn action_id(a: Action) -> &'static str { + match a { + Action::Investigate => "action-investigate", + Action::Solve => "action-solve", + Action::Support => "action-support", + Action::Attack => "action-attack", + Action::Ground => "action-ground", + } +} + +/// The element pair that offers this command, if it has a spatial one. +/// +/// Commands with no natural drag — choosing a GROUND mode, answering a +/// Support, naming a DARVO target — are offered as buttons instead, and +/// resolve through the `cmd-` form below. +pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option<(String, String)> { + match command { + GroundCommand::SelectAction { + action, + target, + problem, + } => { + let from = action_id(*action).to_string(); + let to = match (target, problem) { + (Some(p), None) => format!("seat-{}", p.0), + (None, Some(n)) => format!("problem-{n}"), + (None, None) => "table".to_string(), + // Both set is not a shape the aggregate produces; refusing + // to invent an affordance is better than guessing one. + (Some(_), Some(_)) => return None, + }; + Some((from, to)) + } + GroundCommand::SpendFreedom => { + Some((format!("freedom-{}", seat.0), format!("freedom-{}", seat.0))) + } + _ => None, + } +} + +/// Resolve a pointer fact to an index into the legal list. +/// +/// Returns `Err` rather than a default when nothing matches: a drag that +/// means nothing must mean *nothing*, not "the first legal move". A +/// renderer that silently substitutes a command is the presentation-layer +/// form of the defect this whole pass is about. +pub fn resolve( + fact: &PointerFact, + legal: &[GroundCommand], + seat: cb_kernel::PlayerId, +) -> Result { + // The button form: an explicit index, which still has to be in range. + if let Some(rest) = fact.down.strip_prefix("cmd-") { + if fact.down != fact.up { + return Err(format!( + "a button press must start and end on the same element ({} then {})", + fact.down, fact.up + )); + } + let i: usize = rest + .parse() + .map_err(|_| format!("not a command index: {:?}", fact.down))?; + return if i < legal.len() { + Ok(i) + } else { + Err(format!("no legal command {i} (there are {})", legal.len())) + }; + } + + let mut hits = legal + .iter() + .enumerate() + .filter(|(_, c)| affordance(c, seat).is_some_and(|(f, t)| f == fact.down && t == fact.up)); + match (hits.next(), hits.next()) { + (Some((i, _)), None) => Ok(i), + (Some(_), Some(_)) => Err(format!( + "{} -> {} is offered by more than one legal command; the page is ambiguous", + fact.down, fact.up + )), + (None, _) => Err(format!( + "{} -> {} is not a legal move here", + fact.down, fact.up + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cb_kernel::PlayerId; + use games_ground::{GroundMode, SupportResponse}; + + fn seat(n: u8) -> PlayerId { + PlayerId(n) + } + + fn select(action: Action, target: Option, problem: Option) -> GroundCommand { + GroundCommand::SelectAction { + action, + target: target.map(seat), + problem, + } + } + + #[test] + fn a_drag_resolves_to_the_command_it_offers() { + let legal = vec![ + select(Action::Attack, Some(1), None), + select(Action::Solve, None, Some(7)), + select(Action::Ground, None, None), + ]; + assert_eq!( + resolve( + &PointerFact::new("action-solve", "problem-7"), + &legal, + seat(0) + ), + Ok(1) + ); + assert_eq!( + resolve( + &PointerFact::new("action-attack", "seat-1"), + &legal, + seat(0) + ), + Ok(0) + ); + assert_eq!( + resolve(&PointerFact::new("action-ground", "table"), &legal, seat(0)), + Ok(2) + ); + } + + /// The control that matters: a drag the aggregate did not offer must + /// resolve to nothing at all, not to a nearby or default command. + #[test] + fn a_drag_that_means_nothing_resolves_to_nothing() { + let legal = vec![select(Action::Solve, None, Some(7))]; + // Right action, wrong problem. + assert!(resolve( + &PointerFact::new("action-solve", "problem-9"), + &legal, + seat(0) + ) + .is_err()); + // An action that is legal in general but not offered now. + assert!(resolve( + &PointerFact::new("action-attack", "seat-1"), + &legal, + seat(0) + ) + .is_err()); + // Elements that exist on the page but are not an affordance. + assert!(resolve(&PointerFact::new("seat-1", "seat-2"), &legal, seat(0)).is_err()); + // Nonsense. + assert!(resolve(&PointerFact::new("", ""), &legal, seat(0)).is_err()); + } + + /// A command index arriving from the page is still bounds-checked + /// against the list the aggregate offered. + #[test] + fn a_button_index_is_bounds_checked() { + let legal = vec![ + GroundCommand::RespondToSupport { + response: SupportResponse::AcceptBond, + }, + GroundCommand::ChooseGroundMode { + mode: GroundMode::Gr, + choice: None, + }, + ]; + assert_eq!( + resolve(&PointerFact::new("cmd-1", "cmd-1"), &legal, seat(0)), + Ok(1) + ); + assert!(resolve(&PointerFact::new("cmd-2", "cmd-2"), &legal, seat(0)).is_err()); + assert!(resolve(&PointerFact::new("cmd-x", "cmd-x"), &legal, seat(0)).is_err()); + // A press that starts on one button and ends on another is not a + // press, and must not be read as one. + assert!(resolve(&PointerFact::new("cmd-0", "cmd-1"), &legal, seat(0)).is_err()); + } + + /// If two legal commands offered the same drag, picking either would + /// be a coin flip the player cannot see. Refuse instead. + #[test] + fn an_ambiguous_affordance_is_refused_rather_than_guessed() { + let legal = vec![ + select(Action::Solve, None, Some(7)), + select(Action::Solve, None, Some(7)), + ]; + let e = resolve( + &PointerFact::new("action-solve", "problem-7"), + &legal, + seat(0), + ) + .unwrap_err(); + assert!(e.contains("more than one"), "{e}"); + } + + #[test] + fn pointer_facts_parse_and_bad_ones_are_refused() { + assert_eq!( + PointerFact::parse("down=action-solve&up=problem-7"), + Ok(PointerFact::new("action-solve", "problem-7")) + ); + // A field carrying game meaning is exactly what control 5 forbids. + assert!(PointerFact::parse("down=a&up=b&command=Solve").is_err()); + assert!(PointerFact::parse("down=a").is_err()); + assert!(PointerFact::parse("down=&up=b").is_err()); + assert!(PointerFact::parse("").is_err()); + } +} diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs new file mode 100644 index 0000000..d910f7e --- /dev/null +++ b/crates/cb-render-html/src/lib.rs @@ -0,0 +1,333 @@ +//! `cb-render-html` — stage 1's renderer (ADR-0007). +//! +//! Emits HTML with inline SVG and one small piece of inline JavaScript; +//! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no +//! third-party dependencies at all, and adding one requires an argument +//! against ADR-0007 §Decision 3. +//! +//! ## What this is not +//! +//! It is **not** `cb-render-api`, and there is no `cb-render-null`. +//! ADR-0007 Decision 2 withdrew the port: a capability port designed +//! against one implementation that emits whole documents acquires a +//! document's shape, and stage 2's `wgpu` renderer would find it +//! unimplementable. INTENT's rule is the one that binds — +//! +//! > *No concept becomes canonical merely because it looks general. It +//! > becomes canonical after surviving a second concrete use.* +//! +//! — so this renders against the existing [`cb_game_runtime::Project`] +//! trait, which is a real interface with real implementations, and the +//! port is declared at stage 2 when there are two. +//! +//! ## The controls, and why they exist +//! +//! CB-WP-0011 established that a renderer's defect class is **silent +//! omission**: every assertion a renderer test naturally makes is +//! satisfied by a renderer showing a third of the state. This crate moves +//! the interactive half of stage 1 into a language `cargo test`, +//! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that +//! finding across the boundary: +//! +//! * [`input`] — **JavaScript may not construct commands.** The page +//! reports raw pointer facts; Rust decides what they mean, against the +//! legal list the aggregate already offered. +//! * the coverage gate below — asserts over the **parsed emitted +//! document**, not over the Rust that emits it. Asserting over the +//! emitting code would reproduce CB-WP-0011's original defect one layer +//! up. +//! +//! And [`serve`] carries the four that make a loopback listener safe to +//! have, of which the load-bearing one is a test that a token-less request +//! is refused. + +pub mod doc; +pub mod input; +pub mod serve; + +pub use doc::{document, text_of}; +pub use input::{resolve, PointerFact}; +pub use serve::{Guard, Refusal, Request}; + +#[cfg(test)] +mod coverage { + //! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s + //! `every_view_field_is_classified`. + //! + //! Same shape as CB-WP-0011's gate, one layer further out: walk the + //! serialized `GroundView` for every leaf *path*, and require each to + //! be either rendered — with a token that must appear in the **parsed + //! document** — or omitted with a stated reason. A new field on + //! `GroundView` that is in neither list fails the build. + //! + //! Paths, not keys: `problem` occurs under a DARVO target, a GROUND + //! choice and a Selection, and a key-set walk would let one vouch for + //! the other two. + + use cb_kernel::PlayerId; + use games_ground::view::GroundView; + + use crate::doc::{document, text_of}; + + /// Every leaf path in the serialized view. + fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec) { + match v { + serde_json::Value::Object(m) if !m.is_empty() => { + for (k, val) in m { + let p = if prefix.is_empty() { + k.clone() + } else { + format!("{prefix}.{k}") + }; + paths(val, &p, out); + } + } + serde_json::Value::Array(a) if !a.is_empty() => { + for item in a { + paths(item, &format!("{prefix}.*"), out); + } + } + _ => out.push(prefix.to_string()), + } + } + + /// Collapse map keys to `*` so the classification is over fields, not + /// over whichever seats and priorities the fixture happens to hold. + fn normalize(path: &str) -> String { + const MAPS: &[&str] = &[ + "players", + "relations", + "problems", + "focus", + "selections", + "ground_modes", + "ground_choices", + "support_responses", + "darvo_targets", + "personal", + ]; + let mut parts: Vec = Vec::new(); + let mut prev_is_map = false; + for seg in path.split('.') { + if prev_is_map && seg != "*" { + parts.push("*".to_string()); + } else { + parts.push(seg.to_string()); + } + prev_is_map = MAPS.contains(&seg); + } + parts.join(".") + } + + /// `(leaf path, a token the parsed document must contain)`. + const RENDERED: &[(&str, &str)] = &[ + ("round", "round 3"), + ("lead", "lead P2"), + ("step", "step Select"), + ("mode", "scoring BondedCoalitions"), + ("viewer", "viewing as P1"), + ("solution_deck_len", "17 remaining"), + ("solution_discard.*.suit", "discard Repair Change"), + ("players.*.stress", "stress 5"), + ("players.*.protection", "protect 2"), + ("players.*.darvo", "darvo Reverse"), + ("players.*.freedom_ready", "READY"), + ("players.*.freedom_gate_lifted", "gate lifted"), + ("players.*.blame_from.*", "blamed by P3"), + // The empty case is a leaf path of its own, and renders as an + // explicit absence rather than as nothing at all. + ("players.*.blame_from", "blamed by none"), + ("players.*.hand.*.suit", "hand Clarify Boundary"), + ("players.*.hand_size", "cards)"), + ("relations.*", "Rivalry"), + ("problems.*.state", "face down"), + ("problems.*.suit", "Change 6"), + ("problems.*.value", "Change 6"), + ("problems.*.denied", "denied"), + ("problems.*.claimed_by", "claimed by P2"), + ("problems.*.protected_this_round", "protected"), + ("focus.*", "\u{2192}P3"), + ("selections.*.state", "face down"), + ("selections.*.action", "action: Attack"), + ("selections.*.target", "target: Some(PlayerId(1))"), + ("selections.*.problem", "problem: Some(7)"), + ("ground_modes.*", "ground mode Gr"), + ("ground_choices.*.choice", "ProtectProblem"), + ("ground_choices.*.problem", "problem: 7 }"), + ("support_responses.*", "support AcceptBond"), + ("darvo_targets.*.problem", "problem: Some(1)"), + ("darvo_targets.*.player", "player: Some(PlayerId(1))"), + ("outcome.total", "total 9"), + ("outcome.threshold", "of 12"), + ("outcome.group_success", "failure"), + ("outcome.personal.*", "P1 +3"), + ("outcome.coalitions.*.members.*", "members: [PlayerId(1)"), + ("outcome.coalitions.*.score", "score: 4"), + ("outcome.mastery", "mastery +1"), + ("outcome.winners.*", "winners P1"), + ]; + + /// Deliberate omissions, each with a reason. + const OMITTED: &[(&str, &str)] = &[( + "players.*.hand", + "null for a non-viewer seat; the absence is rendered as a count", + )]; + + fn fixture() -> GroundView { + crate::testfix::view(Some(PlayerId(0))) + } + + fn rendered_document(view: &GroundView) -> String { + text_of(&document( + view, + &[], + "/command?t=x", + Some(PlayerId(0)), + false, + )) + } + + #[test] + fn every_view_field_is_classified_in_the_emitted_document() { + let view = fixture(); + let json = serde_json::to_value(&view).expect("view serializes"); + let mut raw = Vec::new(); + paths(&json, "", &mut raw); + let all: std::collections::BTreeSet = raw.iter().map(|p| normalize(p)).collect(); + + // EXPECT-VACUOUS floor. A coverage test over zero paths passes + // trivially, and that is precisely how this check would rot. + assert!( + all.len() >= 30, + "the walk found {} leaf path(s) — it is not walking the view", + all.len() + ); + + let claimed: std::collections::BTreeSet<&str> = RENDERED + .iter() + .map(|(p, _)| *p) + .chain(OMITTED.iter().map(|(p, _)| *p)) + .collect(); + + let unclassified: Vec<&String> = all + .iter() + .filter(|p| !claimed.contains(p.as_str())) + .collect(); + assert!( + unclassified.is_empty(), + "unclassified path(s) in GroundView: {unclassified:?} \ + — add each to RENDERED with a token, or to OMITTED with a reason" + ); + + let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect(); + assert!( + stale.is_empty(), + "classified path(s) no longer exist in GroundView: {stale:?}" + ); + + // The half that makes it a rendering gate rather than a bookkeeping + // one: the token must be in the *parsed document*. + let text = rendered_document(&view); + let missing: Vec<&str> = RENDERED + .iter() + .filter(|(_, tok)| !text.contains(tok)) + .map(|(p, _)| *p) + .collect(); + assert!( + missing.is_empty(), + "claimed rendered, but absent from the emitted document: {missing:?}" + ); + } + + /// The parse must be a parse. If `text_of` returned the raw source, a + /// token hiding in a comment, a style rule or the script would count + /// as rendered — which is the loophole control 6 exists to close. + #[test] + fn the_parse_does_not_see_script_or_style_contents() { + let html = "

seen

\ + "; + let text = text_of(html); + assert!(text.contains("seen")); + assert!(text.contains("pid"), "element ids are addressable surface"); + assert!( + !text.contains("STYLETOKEN"), + "style contents leaked into the text" + ); + assert!( + !text.contains("SCRIPTTOKEN"), + "script contents leaked into the text" + ); + } + + /// Control 5, asserted at the document level: the emitted JavaScript + /// must contain no game vocabulary. If a rule ever needs to live in + /// the page, ADR-0007 says revisit the decision, not widen this. + #[test] + fn the_emitted_javascript_contains_no_game_vocabulary() { + let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false); + let script = doc + .split_once("")) + .map(|(s, _)| s.to_string()) + .expect("the document carries a script"); + for word in [ + "Investigate", + "Solve", + "Support", + "Attack", + "Ground", + "darvo", + "DARVO", + "stress", + "freedom", + "problem", + "coalition", + "reveal", + "resolve", + ] { + assert!( + !script.contains(word), + "the emitted JavaScript mentions {word:?} — control 5 is breached" + ); + } + // And it must still be doing its one job. + assert!(script.contains("pointerdown") && script.contains("pointerup")); + } + + /// The renderer must not invent a hand it was never given. + /// + /// Scoped deliberately: the *projection's* hiding rules are asserted + /// where they live, and re-asserting them here would be a duplicated + /// fact that drifts. What this checks is the renderer's own failure + /// mode — that `hand: None` renders as an absence, for every seat, and + /// that a spectator's document contains no open hand at all. + #[test] + fn the_renderer_never_invents_a_hand_it_was_not_given() { + for seat in [0u8, 1, 2] { + let view = crate::testfix::view(Some(PlayerId(seat))); + let text = rendered_document(&view); + let shown = text.matches("cards)").count(); + assert_eq!( + shown, + 1, + "P{} sees {shown} open hands in the document; it was given exactly one", + seat + 1 + ); + assert_eq!( + text.matches("card(s), hidden").count(), + 2, + "the other two seats' hands must render as an absence with a count" + ); + } + let text = rendered_document(&crate::testfix::view(None)); + assert_eq!( + text.matches("cards)").count(), + 0, + "a spectator document shows an open hand" + ); + assert!(text.contains("a spectator (no hands)")); + } +} + +#[cfg(test)] +mod testfix; diff --git a/crates/cb-render-html/src/serve.rs b/crates/cb-render-html/src/serve.rs new file mode 100644 index 0000000..dfe464d --- /dev/null +++ b/crates/cb-render-html/src/serve.rs @@ -0,0 +1,323 @@ +//! The loopback listener, and the controls that make it safe to have one. +//! +//! ADR-0007 Decision 5, controls 1–4. The threat the review named is not +//! hypothetical: +//! +//! > A loopback listener is reachable by any process on the machine **and +//! > by any web page the user visits**. The browser running the table is +//! > the same browser reading the internet — that is the whole premise of +//! > the option. An unauthenticated endpoint accepting `POST /command` and +//! > mutating authoritative game state is a remote-controlled game from +//! > any tab the user has open. +//! +//! So: a per-process token no page can guess, an `Origin` / +//! `Sec-Fetch-Site` check that rejects by default, an explicit +//! `127.0.0.1` bind, and — the control that makes the other three +//! evidence rather than claims — a test that a token-less request is +//! refused, with a mutation that turns it red. +//! +//! [`Guard::admit`] is a pure function of a parsed request, so all of that +//! is testable with no socket, no browser, and no timing. + +use std::io::Read; + +/// Why a request was refused. Each variant is a control that fired. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// Control 1: no token at all. + MissingToken, + /// Control 1: a token, but not ours. + BadToken, + /// Control 2: the request came from somewhere else's page. + CrossSite(String), + /// Not a shape this endpoint serves. + BadRequest(String), +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refusal::MissingToken => write!(f, "refused: no session token"), + Refusal::BadToken => write!(f, "refused: wrong session token"), + Refusal::CrossSite(o) => write!(f, "refused: cross-site request from {o}"), + Refusal::BadRequest(m) => write!(f, "refused: {m}"), + } + } +} + +/// A parsed HTTP/1.1 request, reduced to the fields the controls need. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Request { + pub method: String, + pub path: String, + pub token: Option, + pub origin: Option, + pub sec_fetch_site: Option, + pub body: String, +} + +impl Request { + /// Parse a raw request. Deliberately strict: this is hand-rolled HTTP + /// on a socket a hostile page can reach, so anything unexpected is a + /// refusal rather than a best effort. + pub fn parse(raw: &str) -> Result { + let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, "")); + let mut lines = head.split("\r\n"); + let start = lines + .next() + .ok_or_else(|| Refusal::BadRequest("empty request".into()))?; + let mut parts = start.split(' '); + let method = parts + .next() + .ok_or_else(|| Refusal::BadRequest("no method".into()))? + .to_string(); + let target = parts + .next() + .ok_or_else(|| Refusal::BadRequest("no target".into()))?; + + let (path, query) = target.split_once('?').unwrap_or((target, "")); + let token = query.split('&').find_map(|kv| { + kv.strip_prefix("t=") + .filter(|v| !v.is_empty()) + .map(str::to_string) + }); + + let mut req = Request { + method, + path: path.to_string(), + token, + body: body.to_string(), + ..Default::default() + }; + for line in lines { + if let Some((k, v)) = line.split_once(':') { + let v = v.trim().to_string(); + match k.to_ascii_lowercase().as_str() { + "origin" => req.origin = Some(v), + "sec-fetch-site" => req.sec_fetch_site = Some(v), + _ => {} + } + } + } + Ok(req) + } +} + +/// Holds the per-process token and applies controls 1 and 2. +#[derive(Debug, Clone)] +pub struct Guard { + token: String, + origin: String, +} + +impl Guard { + pub fn new(token: impl Into, port: u16) -> Self { + Self { + token: token.into(), + origin: format!("http://127.0.0.1:{port}"), + } + } + + /// Mint an unguessable token. + /// + /// `/dev/urandom` where it exists; otherwise `RandomState`, whose + /// per-process seed the OS randomises. The fallback is weaker and is + /// named rather than hidden — a token that silently degrades to + /// something predictable is worse than no token, because the controls + /// would still report themselves as passing. + pub fn mint(port: u16) -> Self { + let mut buf = [0u8; 24]; + let strong = std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .is_ok(); + if !strong { + use std::hash::{BuildHasher, Hasher}; + let s = std::collections::hash_map::RandomState::new(); + for chunk in buf.chunks_mut(8) { + let mut h = s.build_hasher(); + h.write_usize(std::process::id() as usize); + h.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + ); + chunk.copy_from_slice(&h.finish().to_le_bytes()[..chunk.len()]); + } + } + let token: String = buf.iter().map(|b| format!("{b:02x}")).collect(); + Self::new(token, port) + } + + pub fn token(&self) -> &str { + &self.token + } + + /// The URL the page is told to POST to. The page cannot mint one. + pub fn endpoint(&self) -> String { + format!("/command?t={}", self.token) + } + + pub fn page_url(&self) -> String { + format!("{}/?t={}", self.origin, self.token) + } + + /// Apply controls 1 and 2. Rejects by default. + pub fn admit(&self, req: &Request) -> Result<(), Refusal> { + // Control 2 first: a cross-site request should be refused before + // its token is even considered, so a leaked token is not enough on + // its own. + match req.sec_fetch_site.as_deref() { + // Sent by every modern browser. Anything but same-origin is + // not our page. + Some("same-origin") | Some("none") => {} + Some(other) => return Err(Refusal::CrossSite(other.to_string())), + None => { + // Older or non-browser clients omit it; fall back to + // Origin, and require it to be ours when present. + if let Some(o) = &req.origin { + if o != &self.origin { + return Err(Refusal::CrossSite(o.clone())); + } + } + } + } + if let Some(o) = &req.origin { + if o != &self.origin { + return Err(Refusal::CrossSite(o.clone())); + } + } + + // Control 1. + match &req.token { + None => Err(Refusal::MissingToken), + Some(t) if constant_eq(t, &self.token) => Ok(()), + Some(_) => Err(Refusal::BadToken), + } + } +} + +/// Compare without an early return on the first differing byte. +/// +/// The endpoint is loopback and the token is 192 bits, so a timing oracle +/// is not the realistic attack here. It costs one line. +fn constant_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.bytes() + .zip(b.bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +/// Control 3: bind explicitly to loopback, never `0.0.0.0`. +pub fn bind(port: u16) -> std::io::Result { + std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn guard() -> Guard { + Guard::new("s3cr3t", 8731) + } + + fn req(raw: &str) -> Request { + Request::parse(raw).expect("parses") + } + + /// **ADR-0007 control 4.** The one that makes controls 1–3 evidence + /// rather than claims. + /// + /// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in + /// `admit` (return `Ok(())` instead) and this goes red with + /// `a token-less request was admitted`. Run 2026-08-02. + #[test] + fn a_token_less_request_is_refused() { + let g = guard(); + let r = req("POST /command HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\ndown=a&up=b"); + assert_eq!( + g.admit(&r), + Err(Refusal::MissingToken), + "a token-less request was admitted" + ); + } + + #[test] + fn a_wrong_token_is_refused() { + let g = guard(); + let r = req("POST /command?t=guess HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); + assert_eq!(g.admit(&r), Err(Refusal::BadToken)); + // A prefix of the real token must not be admitted either. + let r = req("POST /command?t=s3c HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); + assert_eq!(g.admit(&r), Err(Refusal::BadToken)); + } + + #[test] + fn the_right_token_from_our_own_page_is_admitted() { + let g = guard(); + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\ + Origin: http://127.0.0.1:8731\r\n\r\ndown=a&up=b", + ); + assert_eq!(g.admit(&r), Ok(())); + } + + /// The attack the review named: a page on the open internet POSTs to + /// `127.0.0.1`. Even holding a leaked token, it must not be admitted. + #[test] + fn a_cross_site_request_is_refused_even_with_the_right_token() { + let g = guard(); + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: cross-site\r\n\ + Origin: https://evil.example\r\n\r\ndown=a&up=b", + ); + assert!( + matches!(g.admit(&r), Err(Refusal::CrossSite(_))), + "{:?}", + g.admit(&r) + ); + + // A client that omits Sec-Fetch-Site but sends a foreign Origin is + // the same attack with an older browser. + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nOrigin: https://evil.example\r\n\r\ndown=a&up=b", + ); + assert!(matches!(g.admit(&r), Err(Refusal::CrossSite(_)))); + } + + #[test] + fn requests_parse_and_carry_what_the_controls_need() { + let r = req("POST /command?t=abc HTTP/1.1\r\nHost: 127.0.0.1:8731\r\n\ + Origin: http://127.0.0.1:8731\r\nSec-Fetch-Site: same-origin\r\n\ + Content-Length: 9\r\n\r\ndown=a&up=b"); + assert_eq!(r.method, "POST"); + assert_eq!(r.path, "/command"); + assert_eq!(r.token.as_deref(), Some("abc")); + assert_eq!(r.origin.as_deref(), Some("http://127.0.0.1:8731")); + assert_eq!(r.sec_fetch_site.as_deref(), Some("same-origin")); + assert_eq!(r.body, "down=a&up=b"); + // An empty t= is no token, not a token that happens to be empty. + let r = req("GET /?t= HTTP/1.1\r\n\r\n"); + assert_eq!(r.token, None); + } + + /// A minted token must not be a constant, and must not be short. + #[test] + fn a_minted_token_is_long_and_not_reused() { + let a = Guard::mint(1).token().to_string(); + let b = Guard::mint(1).token().to_string(); + assert_eq!(a.len(), 48, "token is {} hex chars", a.len()); + assert_ne!(a, b, "two mints produced the same token"); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + /// Control 3, asserted rather than commented. + #[test] + fn the_listener_binds_loopback_only() { + let l = bind(0).expect("bind"); + assert_eq!(l.local_addr().unwrap().ip().to_string(), "127.0.0.1"); + } +} diff --git a/crates/cb-render-html/src/testfix.rs b/crates/cb-render-html/src/testfix.rs new file mode 100644 index 0000000..d252e07 --- /dev/null +++ b/crates/cb-render-html/src/testfix.rs @@ -0,0 +1,118 @@ +//! A populated `GroundView`, built for the coverage gate. +//! +//! Mid-DARVO, mid-GROUND, scored — **not** a fresh deal. Most of the +//! fields this gate exists to cover are empty at deal time, and a coverage +//! test run against a state where the fields are absent is the same lie in +//! a different costume (CB-WP-0011 T01). +//! +//! Built directly rather than projected from a `GroundState`. The +//! projection's own hiding rules are already asserted by +//! `games_ground::view` and by `cb-play`'s +//! `the_inspector_never_widens_the_projection`; re-asserting them here +//! would be a duplicated fact that drifts. What *this* crate must not do +//! is invent a hand it was never given, and that is what the renderer test +//! checks — with `hand: None` supplied deliberately. + +use std::collections::BTreeMap; + +use cb_kernel::PlayerId; +use games_ground::view::{GroundView, OutcomeView, PlayerView, ProblemView, SelectionView}; +use games_ground::{ + Action, Coalition, DarvoStage, DarvoTarget, GroundChoice, GroundMode, Pair, Relation, + RoundStep, ScoringMode, Selection, SolutionCard, Suit, SupportResponse, +}; + +pub fn seats() -> (PlayerId, PlayerId, PlayerId) { + (PlayerId(0), PlayerId(1), PlayerId(2)) +} + +/// The fixture, seen by `viewer` (`None` for a spectator). +pub fn view(viewer: Option) -> GroundView { + let (p1, p2, p3) = seats(); + let card = |suit| SolutionCard { suit }; + + let mut players = BTreeMap::new(); + for (id, stress, protection, darvo, ready, lifted, blame) in [ + (p1, 5u8, 2u8, DarvoStage::Reverse, true, true, vec![p3]), + (p2, 1, 0, DarvoStage::Off, false, false, vec![]), + (p3, 0, 0, DarvoStage::Deny, true, false, vec![]), + ] { + let own = viewer == Some(id); + players.insert( + id, + PlayerView { + stress, + freedom_ready: ready, + freedom_gate_lifted: lifted, + darvo, + protection, + blame_from: blame, + hand: own.then(|| vec![card(Suit::Clarify), card(Suit::Boundary)]), + hand_size: 2, + }, + ); + } + + let mut problems = BTreeMap::new(); + problems.insert(1, ProblemView::FaceDown); + problems.insert( + 7, + ProblemView::FaceUp { + suit: Suit::Change, + value: 6, + denied: true, + claimed_by: Some(p2), + protected_this_round: true, + }, + ); + + GroundView { + viewer, + round: 3, + lead: p2, + step: RoundStep::Select, + mode: ScoringMode::BondedCoalitions, + players, + relations: BTreeMap::from([ + (Pair::new(p1, p2), Relation::Rivalry), + (Pair::new(p2, p3), Relation::Bond), + ]), + problems, + focus: BTreeMap::from([(p1, p3)]), + selections: BTreeMap::from([ + ( + p1, + SelectionView::Shown(Selection { + action: Action::Attack, + target: Some(p2), + problem: Some(7), + }), + ), + (p2, SelectionView::Hidden), + ]), + ground_modes: BTreeMap::from([(p3, GroundMode::Gr)]), + ground_choices: BTreeMap::from([(p3, GroundChoice::ProtectProblem { problem: 7 })]), + support_responses: BTreeMap::from([(p2, SupportResponse::AcceptBond)]), + darvo_targets: BTreeMap::from([( + p3, + DarvoTarget { + problem: Some(1), + player: Some(p2), + }, + )]), + solution_deck_len: 17, + solution_discard: vec![card(Suit::Repair), card(Suit::Change)], + outcome: Some(OutcomeView { + total: 9, + threshold: 12, + group_success: false, + personal: BTreeMap::from([(p1, 3), (p2, -1), (p3, 0)]), + coalitions: vec![Coalition { + members: vec![p2, p3], + score: 4, + }], + mastery: Some(1), + winners: vec![p1], + }), + } +} diff --git a/research/CB-RES-0006-render-port.md b/research/CB-RES-0006-render-port.md index c3e9a74..5dc30f5 100644 --- a/research/CB-RES-0006-render-port.md +++ b/research/CB-RES-0006-render-port.md @@ -1,5 +1,13 @@ # CB-RES-0006 — the render port, and what a 2D table costs +capability: render.table.2d +status: approved (v2, after adversarial review — v1 was **not approvable**, +with four conceded findings; see §2, §4, §5, §7) +tier: L (structural L — the declaration was made when this pass would +create a capability port; chaos d4=1 → no override. Review then removed +the port, and the tier was deliberately **not** re-derived: a tier that +changes because review shrank the work is a function of the outcome) + CB-WP-0012 T01. Step 1 of the inner loop. Measured 2026-08-02 at `331e7e9`. diff --git a/tools/cb-play/Cargo.toml b/tools/cb-play/Cargo.toml index da170fd..3bafd06 100644 --- a/tools/cb-play/Cargo.toml +++ b/tools/cb-play/Cargo.toml @@ -7,6 +7,7 @@ license-file.workspace = true [dependencies] cb-kernel.workspace = true cb-events.workspace = true +cb-render-html.workspace = true cb-game-runtime = { workspace = true, features = ["scenarios"] } games-ground = { workspace = true, features = ["scenarios"] } serde_json.workspace = true diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs new file mode 100644 index 0000000..f53fdd4 --- /dev/null +++ b/tools/cb-play/src/hotseat.rs @@ -0,0 +1,373 @@ +//! Hot-seat play in a browser (ADR-0007, INTENT stage 1). +//! +//! One loopback listener, one tab, seats taking turns. The server blocks +//! inside [`Policy::choose`] until the page reports a pointer fact that +//! resolves to a legal command — so the game loop is the same +//! `games_ground::bot::play` loop the CLI and the bots run through, and a +//! browser seat is indistinguishable from a CLI seat to the driver. +//! +//! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this +//! module is the socket and the turn-taking. It deliberately holds no +//! game logic beyond "which seat is being asked" — [`cb_render_html`] +//! decides what a drag means, and the aggregate decides whether the +//! result is legal. + +use std::cell::RefCell; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::rc::Rc; + +use cb_game_runtime::{Project, Viewer}; +use cb_kernel::PlayerId; +use cb_render_html::{document, resolve, Guard, PointerFact, Request}; +use games_ground::bot::{Choice, Policy}; +use games_ground::{GroundCommand, GroundState}; + +/// The shared listener. One per process; every human seat borrows it. +pub struct Server { + listener: TcpListener, + guard: Guard, + log: RefCell>, +} + +impl Server { + pub fn bind(port: u16) -> Result { + let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("local_addr: {e}"))? + .port(); + Ok(Self { + listener, + guard: Guard::mint(port), + log: RefCell::new(Vec::new()), + }) + } + + /// The URL to open, token and all. Printed once at start; the page + /// cannot mint one for itself. + pub fn url(&self) -> String { + self.guard.page_url() + } + + /// What was refused, for the evidence a session leaves behind. + pub fn refusals(&self) -> Vec { + self.log.borrow().clone() + } + + /// Serve until the page reports something that resolves to a move. + /// + /// Requests that fail a control are answered and the loop continues — + /// a refused request must not end someone's game, and must not be + /// silently indistinguishable from one that did nothing. + pub fn next_choice( + &self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Result { + let view = state.project(Viewer::Player(seat)); + loop { + let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?; + let raw = match read_request(&mut stream) { + Ok(r) => r, + Err(e) => { + respond(&mut stream, 400, "text/plain", &e); + continue; + } + }; + let req = match Request::parse(&raw) { + Ok(r) => r, + Err(refusal) => { + self.log.borrow_mut().push(refusal.to_string()); + respond(&mut stream, 400, "text/plain", &refusal.to_string()); + continue; + } + }; + if let Err(refusal) = self.guard.admit(&req) { + self.log.borrow_mut().push(refusal.to_string()); + respond(&mut stream, 403, "text/plain", &refusal.to_string()); + continue; + } + + match (req.method.as_str(), req.path.as_str()) { + ("GET", "/") => { + let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass); + respond(&mut stream, 200, "text/html; charset=utf-8", &page); + } + ("POST", "/command") => { + let fact = match PointerFact::parse(&req.body) { + Ok(f) => f, + Err(e) => { + respond(&mut stream, 400, "text/plain", &e); + continue; + } + }; + if may_pass && fact.down == "pass" && fact.up == "pass" { + respond(&mut stream, 200, "text/plain", "ok: passed"); + return Ok(Choice::Pass); + } + match resolve(&fact, legal, seat) { + Ok(i) => { + respond(&mut stream, 200, "text/plain", "ok"); + return Ok(Choice::Command(i)); + } + // Not an error: the player dragged somewhere that + // means nothing. Say so and keep the turn. + Err(why) => respond(&mut stream, 200, "text/plain", &why), + } + } + _ => respond(&mut stream, 404, "text/plain", "no such thing here"), + } + } + } +} + +/// One seat's view of the shared server. +pub struct SeatPolicy { + server: Rc, + failure: Rc>>, +} + +impl SeatPolicy { + pub fn new(server: Rc, failure: Rc>>) -> Self { + Self { server, failure } + } +} + +impl Policy for SeatPolicy { + fn name(&self) -> &'static str { + "browser" + } + + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Choice { + match self.server.next_choice(state, seat, legal, may_pass) { + Ok(c) => c, + Err(e) => { + // Same shape as the CLI's out-of-input path: record the + // real reason and return something the driver will + // reject, rather than inventing a move. + *self.failure.borrow_mut() = Some(e); + Choice::Command(usize::MAX) + } + } + } +} + +fn read_request(stream: &mut TcpStream) -> Result { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + // Once the head is in, read exactly the declared body and stop — + // otherwise a keep-alive connection blocks here forever. + if let Some(head_end) = find(&buf, b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let want: usize = head + .split("\r\n") + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse().ok())? + }) + .unwrap_or(0); + if buf.len() >= head_end + 4 + want { + break; + } + } + if buf.len() > 64 * 1024 { + return Err("request too large".to_string()); + } + } + String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string()) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 403 => "Forbidden", + _ => "Not Found", + }; + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\ + Content-Length: {}\r\nConnection: close\r\n\ + X-Content-Type-Options: nosniff\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + use games_ground::Action; + + /// One client thread issuing requests **in order**, each on its own + /// `Connection: close` socket and each fully read before the next is + /// sent. Two concurrent clients would race the server's `accept`, and + /// a test whose outcome depends on which socket wins is worse than no + /// test. + fn converse(port: u16, requests: Vec) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + requests + .into_iter() + .map(|raw| { + let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); + s.write_all(raw.as_bytes()).expect("write"); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + out + }) + .collect() + }) + } + + fn get(token: &str) -> String { + format!( + "GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Sec-Fetch-Site: same-origin\r\n\r\n" + ) + } + + fn post(token: &str, body: &str) -> String { + format!( + "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn ground_only() -> Vec { + vec![GroundCommand::SelectAction { + action: Action::Ground, + target: None, + problem: None, + }] + } + + fn state() -> GroundState { + ::setup( + &cb_game_runtime::Setup { + players: 3, + preset: "standard-3p".to_string(), + patch: Default::default(), + }, + 7, + ) + .expect("setup") + } + + /// The whole loop, with a real socket and no browser: the page is + /// served, a pointer fact is posted, and the seat's choice comes back. + #[test] + fn a_drag_posted_over_the_socket_becomes_a_choice() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![get(&token), post(&token, "down=action-ground&up=table")], + ); + + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("200 OK"), "{}", replies[0]); + assert!(replies[0].contains("GROUND"), "the page was not the table"); + assert!( + replies[0].contains("id=\"action-ground\""), + "the offered action was not on the page" + ); + assert!(replies[1].contains("200 OK")); + assert!(server.refusals().is_empty()); + } + + /// ADR-0007 control 1, end to end rather than in the unit: a page + /// without the token gets nothing, and the seat keeps its turn. + /// + /// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this + /// goes red — the token-less request is served the table. Run + /// 2026-08-02. + #[test] + fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![ + "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n" + .to_string(), + post(&token, "down=action-ground&up=table"), + ], + ); + + // The turn survives the refusal and is decided by the good request. + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]); + assert!( + !replies[0].contains("GROUND"), + "the table leaked to a token-less request" + ); + assert!(replies[1].contains("200 OK")); + assert_eq!( + server.refusals(), + vec!["refused: no session token".to_string()] + ); + } + + /// A drag that means nothing must not end the turn, and must say so. + #[test] + fn a_meaningless_drag_keeps_the_turn() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![ + post(&token, "down=seat-1&up=seat-2"), + post(&token, "down=action-ground&up=table"), + ], + ); + + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("200 OK")); + assert!( + replies[0].contains("not a legal move"), + "a meaningless drag must be told it meant nothing: {}", + replies[0] + ); + assert!(replies[1].contains("200 OK")); + } +} diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs index 6b2466d..9e23f53 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -761,6 +761,7 @@ mod tests { bot: "greedy".into(), replay_dir: Some(dir.clone()), record: Some(dir.join("session.yaml")), + serve: None, }; let mut sink: Vec = Vec::new(); let summary = diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs index e90066d..15fae45 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -9,6 +9,7 @@ //! success — a session that ends without an outcome is a failure, and //! saying otherwise is how a stalled game passes for a played one. +mod hotseat; mod inspect; mod table; @@ -16,7 +17,7 @@ use table::Config; const USAGE: &str = "\ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] - [--replay DIR] [--all-bots] [--record FILE] + [--replay DIR] [--all-bots] [--record FILE] [--serve PORT] cb-play --inspect PATH [--as SEAT|spectator] play: @@ -27,6 +28,10 @@ play: --bot KIND policy for every other seat: greedy (default) or random --replay DIR write a .cbreplay bundle of the finished game to DIR --record FILE write the finished game as a scenario YAML + --serve PORT play human seats in a browser on 127.0.0.1:PORT instead + of on the terminal; 0 lets the OS pick. Prints a URL + carrying a per-process token — without it the page is + refused (ADR-0007). inspect a recorded game — a .cbreplay bundle directory or a scenario YAML: --inspect PATH render the table after every recorded step @@ -104,6 +109,15 @@ fn parse_args(argv: &[String]) -> Result { config.replay_dir = Some(value(i, argv, flag)?.into()); i += 2; } + "--serve" => { + play_flags.push(flag.into()); + config.serve = Some( + value(i, argv, flag)? + .parse() + .map_err(|e| format!("--serve: {e}"))?, + ); + i += 2; + } "--all-bots" => { all_bots = true; play_flags.push(flag.into()); @@ -245,6 +259,7 @@ mod tests { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, }; let script = "0\n".repeat(400); let mut out: Vec = Vec::new(); @@ -283,6 +298,7 @@ mod tests { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, }; let mut out: Vec = Vec::new(); table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game"); @@ -344,6 +360,7 @@ mod tests { bot: "random".into(), replay_dir: None, record: None, + serve: None, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game"); @@ -403,6 +420,7 @@ mod tests { bot: "greedy".into(), replay_dir: Some(dir.clone()), record: Some(dir.join("session.yaml")), + serve: None, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("game"); diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 66b4a39..41a0937 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -32,6 +32,9 @@ pub struct Config { /// Where to write the finished game as a scenario file. A session /// somebody played becomes a regression test. pub record: Option, + /// Serve human seats in a browser instead of on the terminal + /// (ADR-0007). `Some(0)` lets the OS pick the port. + pub serve: Option, } impl Default for Config { @@ -43,6 +46,7 @@ impl Default for Config { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, } } } @@ -213,10 +217,28 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( // Human seats and bot seats fill one policy vector; the driver does // not know which is which, which is the point. let failure: Failure = Default::default(); + // ADR-0007: a browser seat and a CLI seat are both just a Policy, so + // the driver cannot tell them apart — which is the property that lets + // a browser game replay as a scenario like any other. + let server = match config.serve { + Some(port) => { + let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?); + let _ = writeln!(out.borrow_mut(), " open {}", s.url()); + let _ = out.borrow_mut().flush(); + Some(s) + } + None => None, + }; let mut policies: Vec> = Vec::new(); for seat in 0..config.players { if config.human_seats.contains(&seat) { - policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))); + match &server { + Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new( + s.clone(), + failure.clone(), + ))), + None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))), + } } else { policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?); } @@ -248,6 +270,20 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( game.commands, &end_hash[..12] ); + // ADR-0007 control 1 leaves evidence rather than only a 403: a + // session that was probed says so, so a player finds out from the + // transcript rather than from nothing at all. + if let Some(s) = &server { + let refusals = s.refusals(); + if refusals.is_empty() { + let _ = writeln!(w, " no requests were refused this session"); + } else { + let _ = writeln!(w, " {} request(s) refused:", refusals.len()); + for r in &refusals { + let _ = writeln!(w, " {r}"); + } + } + } let _ = w.flush(); drop(w); From c38ecd9da36903166aae42188c123516ba23b465 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 04:30:10 +0200 Subject: [PATCH 7/7] =?UTF-8?q?CB-WP-0012-T05:=20evidence=20=E2=80=94=20ti?= =?UTF-8?q?er=20L=20deleted=20its=20own=20deliverable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CB-EV-0010. The pass's own verdict on the tier it ran at. Full-weight review withdrew the capability port the declaration was made to build. A tier-S pass has no step 2 and would have shipped it, and stage 2 would have found it unimplementable — which is what CommitWindow is already on record in this repo for doing. Two corrections of the survey's own numbers, compounding: AM-4a headroom 3,750 claimed -> 92,798 measured (25x) cheapest windowed 480,501 claimed -> 140,079 measured (3.4x) headline ratio 128x -> 1.5x (85x) The prediction from CB-EV-0009 §4 held: meta budget reads 0%, published in advance and unfalsified. A correction that is now a pattern: CB-EV-0009 reported CB-WP-0011 at 45 responses / $4.23 / 0.094; final is 71 / $7.02 / 0.099. Still the cheapest pass, so the conclusion stands. But that is the second consecutive evidence file to report its own pass's cost low — a pass cannot measure its own cost, and one quoting its own is quoting a floor. First priced tier comparison on a single subject: 0.123 $/response at L against 0.099 at S — 24% more, for a pass that found the two errors above. On one data point, step 2 is cheap. Not shipped, and said plainly: the emitted JavaScript has never been executed. The socket loop is tested end to end with synthetic HTTP and the page is asserted against as a parsed document, but no browser engine has run it. INTENT stage 1 therefore stays open even though all four of its named deliverables now exist. SH-3 reads 0.0% for a sixth consecutive pass and remains the oldest unargued number in the project. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 5 + evidence/CB-EV-0010-render-port.md | 216 ++++++++++++++++++++++++++++ gates.toml | 1 + workplans/CB-WP-0012-render-port.md | 46 +++++- 4 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 evidence/CB-EV-0010-render-port.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index d046b55..722e0d3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -89,3 +89,8 @@ | task | CB-WP-0011-T01 | done | — | workplans/CB-WP-0011-inspectable-table.md | | task | CB-WP-0011-T02 | done | — | workplans/CB-WP-0011-inspectable-table.md | | task | CB-WP-0011-T03 | done | — | workplans/CB-WP-0011-inspectable-table.md | +| task | CB-WP-0012-T01 | done | — | workplans/CB-WP-0012-render-port.md | +| task | CB-WP-0012-T02 | done | — | workplans/CB-WP-0012-render-port.md | +| task | CB-WP-0012-T03 | done | — | workplans/CB-WP-0012-render-port.md | +| task | CB-WP-0012-T04 | done | — | workplans/CB-WP-0012-render-port.md | +| task | CB-WP-0012-T05 | done | — | workplans/CB-WP-0012-render-port.md | diff --git a/evidence/CB-EV-0010-render-port.md b/evidence/CB-EV-0010-render-port.md new file mode 100644 index 0000000..faba35e --- /dev/null +++ b/evidence/CB-EV-0010-render-port.md @@ -0,0 +1,216 @@ +# CB-EV-0010 — tier L at full weight, and what it deleted + +CB-WP-0012 T05. Measured 2026-08-02 at `84d6886`. Pass kind `product`. + +The previous pass was the CHAOS gate's first fire, rolling this same +declaration from L down to S. This one rolled a 1 and ran at L. So for the +first time there are two passes on the same subject at two tiers, and the +comparison is the point. + +--- + +## 1. What full-weight review actually did + +**It deleted the thing the pass was declared to build.** + +The declaration's structural trigger was *"creates a new capability port."* +The survey recommended `cb-render-api` + `cb-render-null` + +`cb-render-html`. The adversarial review (C3) found that recommendation +contradicted the survey's own citation of INTENT's second-use rule, and +the response conceded entirely. **What shipped has no port and no null +implementation** — a renderer against the existing `Project` trait, and a +deferral with a named trigger. + +That is the strongest evidence yet that step 2 is not ceremony. A tier-S +pass has no step 2, would have shipped `cb-render-api`, and stage 2 would +have found it unimplementable and rewritten it — which is exactly what +`CommitWindow` is already on record in this repo for doing. + +**Four of six challenges were conceded, not answered.** Two of them +invalidated the survey's main arguments: + +| challenge | outcome | +|---|---| +| C1 the sub-100k region was never measured | conceded; **it is not empty** | +| C2 "cost zero" scored on an axis chosen to produce zero | conceded; one rule now covers browser, `sdl2` and `fltk` alike | +| C3 the port contradicts the second-use rule | conceded; **port withdrawn** | +| C6 the C1 measurements had no positive control | conceded; re-measured under it | +| C4 loopback server undersold | controls adopted into ADR-0007 | +| C5 the gate stops at the language boundary | controls adopted into ADR-0007 | + +**Fidelity note, which caps all of the above.** The review ran in the same +session as the survey, not a separate one, because this environment's +standing instruction is not to spawn agents unasked. It therefore inherits +the author's sampling — the exact failure mode that produced CB-WP-0002's +dedup blind spot and CB-WP-0005's AM-7 defect. Treat the six challenges as +a lower bound. + +## 2. The numbers the survey got wrong, and by how much + +The survey's first draft argued the render port was unaffordable: + +> *"The cheapest candidate that opens a window costs 128 times the entire +> remaining budget. This is not a near miss to be negotiated; it is two +> orders of magnitude."* + +Both halves were wrong, in the same direction, for two independent +reasons — and they compound: + +| | claimed | measured | +|---|---:|---:| +| AM-4a headroom | 3,750 | **92,798** (36.2% of the figure is proc-macro code that never ships) | +| cheapest windowed toolkit | 480,501 (`macroquad`) | **140,079** (`fltk`) | +| ratio | 128× | **1.5×** | + +The instrument was wrong by 25×, the candidate sample by 3.4×, and the +headline claim by 85×. The 3,750 figure has been cited in every pass that +mentioned AM-4a, CB-WP-0011's reason for deferring this declaration +included. + +**The recommendation survived both corrections**, and only because it was +re-derived rather than defended: the argument moved from *affordability* +to *allocation* — stage 2's `wgpu` bill is **1,741,979** marginal lines, +twelve times the stage-1 toolkit it would replace, so buying `fltk` means +spending 1.5× the remaining budget on something stage 2 discards. That +argument needs no particular value for AM-4a's target, which is why it is +worth more than the one it replaced. + +## 3. AM-4a cannot survive stage 2 — raised, not decided + +| | marginal lines | +|---|---:| +| AM-4a target, **total** | 250,000 | +| `wgpu` + `winit`, named by INTENT stage 2 | **1,741,979** | + +**7× the entire target, 19× the corrected headroom.** No sequencing, +feature-gating or metric correction closes that. AM-4a as targeted is +incompatible with INTENT as written, and has been since both were written; +nothing in this pass caused it. + +Reserved for the maintainer along with the acquisition rule of ADR-0007 +Decision 3 — which is proposed by the pass that benefits from it, is +written to cost more than it saves, and is still an argument rather than a +ratification. + +## 4. What shipped, and what has never been run + +`cb-render-html`: HTML/SVG/JS emission, pointer-fact resolution, and a +loopback listener. `cb-play --serve PORT`. **Measured** marginal cost: + +``` +games-ground shipped: 23 third-party crates +cb-render-html: 23 third-party crates +new crates introduced: 0 +``` + +AM-4a unmoved at 246,250. Own source 7,636 → 9,652. + +Eight mutations run, each red for its stated reason. Two results worth +more than the six that behaved: + +- **The coverage gate fired on its author again**, first run, before + commit: `ground_choices.*.choice`, `ground_choices.*.problem` and + `players.*.blame_from` were in neither list. The third is the one to + keep — an **empty vector is a leaf path of its own**, and a fixture + where every collection is populated would never have produced it. It + now renders as an explicit absence (`blamed by none`). +- **One mutation did not go red.** Removing the `Sec-Fetch-Site` arm alone + left the cross-site test green: the `Origin` check caught it + independently. Both had to go before the control bit. Defence in depth + is fine; a control that passes for a reason you did not intend has not + been demonstrated, and would have been reported as a clean result by + anyone who ran one mutation and stopped. + +### What has never been executed + +**The emitted JavaScript has never run.** Every test is on the Rust side: +the socket loop is driven by synthetic HTTP, and the page is asserted +against as a parsed document. No browser engine has executed `SCRIPT`. + +So "hot-seat play" is delivered in the sense that the Rust half is tested +end-to-end over a real socket and the page is emitted correctly — and +**not** in the sense that anyone has played a game in a browser. Control 5 +bounds the exposure (the JS is 20 lines and holds no game logic, by +assertion) but does not remove it. + +**INTENT stage 1 is therefore left open.** Its four named deliverables now +all exist, which is the first time that has been true — but a stage marked +complete on the strength of code that has never been run once is the +failure mode stage 0 avoided by leaving the CLI player open for three +passes. Closing it is the maintainer's call, and it needs one person to +open the URL. + +## 5. Cost, shape, and a prediction that held + +| pass | kind | responses | cost | $/response | +|---|---|---|---|---| +| CB-WP-0008 | product | 134 | $17.38 | 0.123 | +| CB-WP-0009 | meta | 46 | $11.31 | 0.246 | +| CB-WP-0010 | product | 26 | $4.08 | 0.157 | +| CB-WP-0011 | product | 71 | $7.02 | **0.099** | +| **CB-WP-0012** | **product** | **72** | **$8.82** | **0.123** | + +**Correcting CB-EV-0009 §4.** It reported CB-WP-0011 as `45 responses, +$4.23, 0.094 $/response` and called it the cheapest pass on record. The +final figures are **71 responses, $7.02, 0.099**. Still the cheapest, so +the conclusion holds — but the number does not, and this is the *second* +consecutive pass to report its own cost low mid-flight (CB-EV-0008 did the +same for CB-WP-0009). Twice is a pattern: **a pass cannot measure its own +cost, and every evidence file that quotes its own is quoting a floor.** +Future evidence files should quote the prior pass's final figure and mark +their own as provisional. + +This tier-L pass cost 0.123 $/response against the rolled-down pass's +0.099 — **24% more per response**, for a pass that also deleted its own +deliverable and found two errors of a factor of 25 and 85. That is the +first priced comparison of the two tiers on the same subject, and on this +one data point step 2 is cheap. + +**Meta budget: 0%**, exactly as CB-EV-0009 §4 predicted: + +> *"if the next pass is `product`, the trailing-3 meta share drops to 0%, +> because CB-WP-0009 will be the pass that rolled off. If it does not, the +> windowing is wrong in a way neither CB-EV-0007 §3 nor CB-EV-0008 §1 +> found."* + +Falsifiable, published in advance, and it held. The windowing is doing +what ADR-0006 D1 says it does. + +### Session shape + +| metric | this pass | target | +|---|---|---| +| SH-1 mean context | 209,486 `[SOFT]` | ≤ 200,000 soft / 300,000 hard | +| SH-2 p90 context | 210,760 `[ok]` | ≤ 300,000 / 450,000 | +| SH-3 batching | 0.0% `[SOFT]` | ≥ 20% | + +SH-1 drifted back over the soft line — a tier-L pass reads more than a +tier-S one, which is the mechanism, not an excuse. The standing prediction +from CB-EV-0009 (*"the next pass opened above the SH-1 hard line will cost +more per response than 0.123"*) is **untested**: this pass opened after a +compaction, below the line. + +**SH-3 has now read 0.0% for six consecutive passes** against a 20% floor. +It is the oldest unargued number in the project: either the floor is wrong +or the behaviour is, and no pass has argued either. It is past time this +was a declaration of its own rather than a line in an evidence file. + +## 6. Open + +- **INTENT stage 1 stays open** — all four deliverables exist; none of the + browser half has been run. §4. +- **Two items reserved for the maintainer**: AM-4a vs stage 2, and + ADR-0007 Decision 3's acquisition rule. §3. +- **CHAOS gets its second entry**, and it is the more interesting one: the + first came from an override, this one from a *non*-override that deleted + its own structural trigger. Window open to 2026-09-30; 7 of 12 + declarations used, 1 override. +- **GATE-REVIEW still has zero `caught`**, two passes older. +- **SH-3 at 0.0% for six passes.** §5. +- **`cb-play` is now three modes in one binary** — play, inspect, serve. + CB-EV-0009 §5 named the third mode as the second use at which the + single-binary shape should be reconsidered. That is now due, and this + pass did not do it. +- **The proc-macro correction to AM-4a is filed and unimplemented.** + ADR-0007 Decision 4. Until it lands, every AM-4a figure in this repo + overstates the load by 36%. diff --git a/gates.toml b/gates.toml index 29b43be..bcb3fd3 100644 --- a/gates.toml +++ b/gates.toml @@ -122,6 +122,7 @@ added = "2026-07-30" review_by = "2026-09-30" caught = [ "CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)", + "CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)", ] retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible" diff --git a/workplans/CB-WP-0012-render-port.md b/workplans/CB-WP-0012-render-port.md index 8708ab7..d562a7c 100644 --- a/workplans/CB-WP-0012-render-port.md +++ b/workplans/CB-WP-0012-render-port.md @@ -2,7 +2,7 @@ id: CB-WP-0012 kind: product title: "Stage 1, second slice: the rendering port" -status: todo +status: done --- # Purpose @@ -184,7 +184,7 @@ commit. ```task id: CB-WP-0012-T04 -status: todo +status: done priority: high ``` @@ -196,11 +196,30 @@ feed back into validation; a drag that proposes a move must go through the same command path a CLI move takes. The inspector's coverage gate is the precedent for the control this needs. +**Done 2026-08-02.** `crates/cb-render-html` (doc/input/serve) and +`tools/cb-play/src/hotseat.rs`, behind `cb-play --serve PORT`. Per +ADR-0007 D2 there is **no `cb-render-api` and no `cb-render-null`**. + +Marginal AM-4a cost, measured rather than claimed: **0 new third-party +crates** (23 before, 23 after). AM-4a unmoved at 246,250. + +Eight mutations, each red for its stated reason. Two results worth more +than the six that behaved: + +| control | result | +|---|---| +| a field present in neither list | fired **for real on the first run** — `ground_choices.*.choice`, `ground_choices.*.problem`, and `players.*.blame_from`, the last being an **empty vector**, a leaf path a fully-populated fixture would never produce | +| `Sec-Fetch-Site` arm removed | **stayed green** — the `Origin` check caught it independently. Both had to go before the control bit; a control that passes for an unintended reason has not been demonstrated | + +**Never executed: the emitted JavaScript.** The socket loop is tested +end to end with synthetic HTTP and the page is asserted against as a +parsed document, but no browser engine has run `SCRIPT`. + ## Task: evidence ```task id: CB-WP-0012-T05 -status: todo +status: done priority: medium ``` @@ -209,3 +228,24 @@ falsifiable prediction from CB-EV-0009 §4 that the trailing-3 meta share drops to 0% this pass, SH-3 at 0.0% for a sixth pass, and whether tier L at full weight produced anything the rolled-down pass would have missed — the CHAOS window's second data point, and the first from a non-override. + +**Done 2026-08-02.** +[CB-EV-0010](../evidence/CB-EV-0010-render-port.md). + +- **Tier L deleted its own deliverable.** Review withdrew the port. A + tier-S pass has no step 2 and would have shipped it. +- **The prediction held: meta budget reads 0%**, published in advance and + unfalsified. +- **A correction, and a pattern.** CB-EV-0009 reported CB-WP-0011 at + `45 responses / $4.23 / 0.094`; final is `71 / $7.02 / 0.099`. Still the + cheapest pass, so its conclusion stands. But this is the **second** + consecutive evidence file to report its own pass's cost low — a pass + cannot measure its own cost, and every evidence file quoting its own is + quoting a floor. +- **Priced tier comparison, first ever on one subject**: 0.123 $/response + at L against 0.099 at S — 24% more, for a pass that found errors of 25× + and 85×. +- **SH-3 at 0.0% for a sixth pass.** Oldest unargued number in the + project; owed a declaration of its own. +- **INTENT stage 1 stays open** — all four deliverables now exist, and + none of the browser half has ever been run.