Compare commits

...

7 commits

Author SHA1 Message Date
c38ecd9da3 CB-WP-0012-T05: evidence — tier L deleted its own deliverable
Some checks failed
ci / check (push) Failing after 4s
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 <noreply@anthropic.com>
2026-08-02 04:30:10 +02:00
84d688688d CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
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 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
883b608f36 CB-WP-0012-T03: ADR-0007 — render to HTML, and do not declare the port yet
Five decisions:

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:13:35 +02:00
c5fa610e59 CB-WP-0012-T02: adversarial review — not approvable as written
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 <noreply@anthropic.com>
2026-08-02 04:12:22 +02:00
53abbaf68f CB-WP-0012-T01: survey the render port — and find AM-4a mis-instrumented
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 <noreply@anthropic.com>
2026-08-02 04:05:52 +02:00
331e7e9044 Declare CB-WP-0012: the render port, tier L, chaos rolled nothing
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 <noreply@anthropic.com>
2026-08-02 04:04:30 +02:00
6fb0aeacf0 Sync hub IDs and work-record index for CB-WP-0011 2026-08-02 02:49:19 +02:00
22 changed files with 3492 additions and 6 deletions

10
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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,11 @@
| 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 |
| 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 |

View file

@ -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

View file

@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => 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::<Vec<_>>()
.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,
"<g id=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
fill=\"{fill}\" stroke=\"#5a6b7a\"/>\
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\
<text x=\"{tx}\" y=\"74\" fill=\"#fc9\" font-size=\"11\">{sub}</text></g>",
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(
"<svg width=\"400\" height=\"300\" role=\"img\" aria-label=\"relationship graph\">",
);
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,
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
stroke=\"{colour}\" stroke-width=\"2\"/>\
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\">{rel:?}</text>",
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,
"<g id=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \
stroke=\"{stroke}\" stroke-width=\"2\"/>\
<text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \
text-anchor=\"middle\">{name}</text>\
<text x=\"{x:.0}\" y=\"{ty2:.0}\" fill=\"#89a\" font-size=\"10\" \
text-anchor=\"middle\">{focus}</text></g>",
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("</svg>");
s
}
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
let is_viewer = view.viewer == Some(id);
let _ = write!(
out,
"<div class=\"card\" data-viewer=\"{is_viewer}\"><b>{name}</b>{you}<br>",
name = seat_name(id),
you = if is_viewer { " (you)" } else { "" },
);
let _ = write!(
out,
"<span class=\"k\">stress</span> {} <span class=\"k\">protect</span> {} \
<span class=\"k\">darvo</span> {:?}<br>",
p.stress, p.protection, p.darvo
);
let _ = write!(
out,
"<span id=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
{ready}{lifted}</span><br>",
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::<Vec<_>>()
.join(" ")
};
let _ = write!(
out,
"<span class=\"k\">blamed by</span> {}<br>",
esc(&blame)
);
match &p.hand {
Some(h) => {
let _ = write!(
out,
"<span class=\"k\">hand</span> {} ({} cards)<br>",
esc(&cards(h)),
p.hand_size
);
}
None => {
let _ = write!(
out,
"<span class=\"k\">hand</span> {} card(s), hidden<br>",
p.hand_size
);
}
}
if let Some(sel) = view.selections.get(&id) {
let _ = write!(
out,
"<span class=\"k\">selected</span> {}<br>",
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, "<span class=\"k\">ground mode</span> {m:?}<br>");
}
if let Some(c) = view.ground_choices.get(&id) {
let _ = write!(out, "<span class=\"k\">ground choice</span> {c:?}<br>");
}
if let Some(r) = view.support_responses.get(&id) {
let _ = write!(out, "<span class=\"k\">support</span> {r:?}<br>");
}
if let Some(t) = view.darvo_targets.get(&id) {
let _ = write!(out, "<span class=\"k\">darvo target</span> {t:?}<br>");
}
out.push_str("</div>");
}
/// 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<PlayerId>,
may_pass: bool,
) -> String {
let mut s = String::with_capacity(8192);
let _ = write!(
s,
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>GROUND \u{2014} round {round}</title><style>{STYLE}</style></head><body>",
round = view.round
);
let _ = write!(
s,
"<h1>GROUND \u{2014} round {round}, step {step:?}</h1>\
<div><span class=\"k\">lead</span> {lead} \
<span class=\"k\">scoring</span> {mode:?} \
<span class=\"k\">viewing as</span> {who}</div>",
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(
"<h2>problems</h2><svg width=\"760\" height=\"100\" role=\"img\" aria-label=\"problems\">",
);
if view.problems.is_empty() {
s.push_str(
"<text x=\"12\" y=\"52\" fill=\"#89a\" font-size=\"12\">no problems in play</text>",
);
}
for (i, (priority, p)) in view.problems.iter().enumerate() {
problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130);
}
s.push_str("</svg>");
s.push_str("<h2>relationships</h2>");
s.push_str(&relations_svg(view));
s.push_str("<h2>seats</h2><div class=\"row\">");
for (id, p) in &view.players {
player_card(&mut s, *id, p, view);
}
s.push_str("</div>");
let _ = write!(
s,
"<h2>solutions</h2><div><span class=\"k\">deck</span> {} remaining \
<span class=\"k\">discard</span> {}</div>",
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::<Vec<_>>()
.join(" ");
let winners = if o.winners.is_empty() {
"nobody".to_string()
} else {
o.winners
.iter()
.map(|w| seat_name(*w))
.collect::<Vec<_>>()
.join(" ")
};
let coalitions = if o.coalitions.is_empty() {
"none".to_string()
} else {
o.coalitions
.iter()
.map(|c| format!("{c:?}"))
.collect::<Vec<_>>()
.join(" ")
};
let _ = write!(
s,
"<h2>outcome</h2><div class=\"card\">\
<span class=\"k\">total</span> {total} of {threshold} \
<span class=\"k\">group</span> {group}<br>\
<span class=\"k\">personal</span> {personal}<br>\
<span class=\"k\">coalitions</span> {coalitions}<br>\
<span class=\"k\">mastery</span> {mastery}<br>\
<span class=\"k\">winners</span> {winners}</div>",
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("<h2>your move</h2><div class=\"row\">");
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,
"<div class=\"card act\" id=\"{id}\">drag {a:?} onto a seat, a problem, \
or the table</div>",
id = action_id(a),
);
}
}
s.push_str("</div><div class=\"row\">");
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,
"<div class=\"card btn\" id=\"cmd-{i}\">{}</div>",
esc(&format!("{c:?}"))
);
}
}
s.push_str(
"</div><div class=\"card\" id=\"table\">the table \u{2014} drop here for an \
untargeted action</div>",
);
}
if may_pass {
s.push_str("<div class=\"card btn\" id=\"pass\">pass \u{2014} decline to act</div>");
}
let _ = write!(
s,
"<div id=\"cb-status\">ready</div>\
<script>window.CB_ENDPOINT={};</script><script>{SCRIPT}</script></body></html>",
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<char> = 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("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&")
}

View file

@ -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=<id>&up=<id>`. Anything else is refused.
pub fn parse(body: &str) -> Result<Self, String> {
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-<i>` 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<usize, String> {
// 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<u8>, problem: Option<u32>) -> 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());
}
}

View file

@ -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<String>) {
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<String> = 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<String> = 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 = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
<script>var s='SCRIPTTOKEN';</script>";
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("<script>")
.and_then(|(_, r)| r.rsplit_once("</script>"))
.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;

View file

@ -0,0 +1,323 @@
//! The loopback listener, and the controls that make it safe to have one.
//!
//! ADR-0007 Decision 5, controls 14. 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<String>,
pub origin: Option<String>,
pub sec_fetch_site: Option<String>,
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<Self, Refusal> {
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<String>, 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> {
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 13 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");
}
}

View file

@ -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<PlayerId>) -> 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],
}),
}
}

View file

@ -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 | 13 without 4 are three claims and no evidence |
| 5 | **JavaScript may not construct commands** — the page reports raw pointer facts (`down on id`, `up on id`); Rust decides what command they mean | confines JS to input transport, making the decision testable in Rust against synthetic events |
| 6 | **the coverage gate crosses the language boundary** — the HTML counterpart of `every_view_field_is_classified` asserts over the **parsed emitted document** | asserting over the Rust that emits the document reproduces CB-WP-0011's defect one layer up |
Controls 5 and 6 exist because CB-WP-0011 established that a renderer's
defect class is silent omission, and this decision moves the interactive
half of stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT`
cannot reach. Without them, this ADR would spend a pass's finding one pass
after paying for it.
**K13 binds unchanged.** The renderer consumes a projection and can never
feed back into validation; a drag that proposes a move goes through the
same command path a CLI move takes.
## Reserved for the maintainer — not decided here
1. **AM-4a is incompatible with INTENT stage 2.** `wgpu` + `winit` is
1,741,979 marginal lines against a **250,000** total target — 7× the
whole budget, 19× the corrected headroom. No sequencing, feature-gating
or metric correction closes that. Either the target moves, the render
port sits outside the AM-4a configuration by an argued rule, or stage 2
changes. **A pass that discovers a budget conflict and also settles it
has reviewed nothing**, so this pass does not settle it.
2. **Whether Decision 3's acquisition rule is the right rule**, given it is
proposed by the pass that benefits from it. It is written to cost more
than it saves, but that is an argument, not a ratification.
## Consequences
- Stage 1 ships without a capability port. That is a deliberate deferral
with a named trigger (stage 2's `wgpu` implementation), not an omission.
- **The tier is now wrong and was not re-derived.** Tier L's structural
trigger was "creates a new capability port"; review removed the port.
Re-rolling would make the tier a function of the outcome, which is the
one thing a tier declaration must not be. The pass runs at L. This is the
CHAOS calibration window's second entry, and unlike the first it comes
from a *non*-override: full-weight review deleted its own trigger.
- A second language enters the build, gated only by control 6. If the
emitted JavaScript grows beyond input transport, control 5 has failed and
the decision should be revisited rather than the control widened.
- `cb-play` acquires a third mode. CB-EV-0009 §5 flagged that a third mode
is the second use at which its single-binary shape should be
reconsidered; that reconsideration is now due.

View file

@ -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%.

View file

@ -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"

View file

@ -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 `<div>`
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.

View file

@ -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.

View file

@ -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 13 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.

View file

@ -0,0 +1,320 @@
# 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`.
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 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.
## 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 by construction** | — |
**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
`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.
### 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
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.
### 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
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 (revised after review)
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.**
### 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 13 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.

View file

@ -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

View file

@ -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<Vec<String>>,
}
impl Server {
pub fn bind(port: u16) -> Result<Self, String> {
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<String> {
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<Choice, String> {
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<Server>,
failure: Rc<RefCell<Option<String>>>,
}
impl SeatPolicy {
pub fn new(server: Rc<Server>, failure: Rc<RefCell<Option<String>>>) -> 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<String, String> {
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<usize> {
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<String>) -> std::thread::JoinHandle<Vec<String>> {
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<GroundCommand> {
vec![GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
}]
}
fn state() -> GroundState {
<GroundState as cb_game_runtime::ScenarioGame>::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"));
}
}

View file

@ -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<u8> = Vec::new();
let summary =

View file

@ -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<Mode, String> {
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<u8> = Vec::new();
@ -283,6 +298,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
};
let mut out: Vec<u8> = 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<u8> = 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<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");

View file

@ -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<std::path::PathBuf>,
/// Serve human seats in a browser instead of on the terminal
/// (ADR-0007). `Some(0)` lets the OS pick the port.
pub serve: Option<u16>,
}
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<Box<dyn Policy + 'a>> = 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);

View file

@ -0,0 +1,251 @@
---
id: CB-WP-0012
kind: product
title: "Stage 1, second slice: the rendering port"
status: done
---
# 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: done
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.
**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
id: CB-WP-0012-T02
status: done
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.
**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
id: CB-WP-0012-T03
status: done
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.
**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
id: CB-WP-0012-T04
status: done
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.
**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: done
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.
**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.