diff --git a/Cargo.lock b/Cargo.lock index 5a6ecd7..86e13e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,11 +81,21 @@ dependencies = [ "cb-events", "cb-game-runtime", "cb-kernel", + "cb-render-html", "games-ground", "serde_json", "serde_yaml", ] +[[package]] +name = "cb-render-html" +version = "0.1.0" +dependencies = [ + "cb-kernel", + "games-ground", + "serde_json", +] + [[package]] name = "cb-sim" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9952ba4..52875e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/cb-kernel", "crates/cb-events", "crates/cb-game-runtime", + "crates/cb-render-html", "games/ground", "tools/cb-sim", "tools/cb-play", @@ -19,6 +20,7 @@ cb-kernel = { path = "crates/cb-kernel" } cb-events = { path = "crates/cb-events" } cb-game-runtime = { path = "crates/cb-game-runtime", default-features = false } games-ground = { path = "games/ground", default-features = false } +cb-render-html = { path = "crates/cb-render-html" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 44608b8..722e0d3 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -18,7 +18,7 @@ | workplan | CB-WP-0008 | done | — | workplans/CB-WP-0008-ship-stage-0.md | | workplan | CB-WP-0009 | done | — | workplans/CB-WP-0009-adaptive-gates.md | | workplan | CB-WP-0010 | done | — | workplans/CB-WP-0010-consolidation.md | -| workplan | CB-WP-0011 | todo | — | workplans/CB-WP-0011-inspectable-table.md | +| workplan | CB-WP-0011 | done | — | workplans/CB-WP-0011-inspectable-table.md | | task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md | @@ -86,6 +86,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 | diff --git a/crates/cb-render-html/Cargo.toml b/crates/cb-render-html/Cargo.toml new file mode 100644 index 0000000..4435a33 --- /dev/null +++ b/crates/cb-render-html/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cb-render-html" +edition.workspace = true +version.workspace = true +license-file.workspace = true + +# ADR-0007 Decision 1: the browser is the renderer, so this crate has +# **no third-party dependencies at all** beyond what the game already +# carries. Adding one here needs an argument against ADR-0007 §Decision 3. +[dependencies] +cb-kernel.workspace = true +games-ground.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +# The coverage gate walks the serialized view; nothing else needs it. +serde_json.workspace = true diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs new file mode 100644 index 0000000..503f09b --- /dev/null +++ b/crates/cb-render-html/src/doc.rs @@ -0,0 +1,544 @@ +//! The emitted document: HTML shell, inline SVG table, inline JavaScript. +//! +//! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string +//! formatting, and the browser draws it. +//! +//! ## What the JavaScript is allowed to be +//! +//! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is +//! therefore the whole of it, it is a constant, and it does one thing: +//! record which element the pointer went down on, which it came up on, and +//! POST the pair. It contains no game vocabulary — no action names, no +//! seats, no rules. If a rule ever needs to appear in it, the decision is +//! wrong and ADR-0007 says to revisit it rather than widen the control. + +use std::fmt::Write as _; + +use cb_kernel::PlayerId; +use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; + +use crate::input::action_id; + +/// Escape for text and attribute contexts alike. +/// +/// Everything interpolated into the document goes through this. Card +/// suits and seat numbers cannot currently carry a `<`, but "cannot +/// currently" is how injection bugs are written. +pub fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +fn seat_name(p: PlayerId) -> String { + format!("P{}", p.0 + 1) +} + +fn cards(list: &[games_ground::SolutionCard]) -> String { + if list.is_empty() { + return "none".to_string(); + } + list.iter() + .map(|c| format!("{:?}", c.suit)) + .collect::>() + .join(" ") +} + +/// The only JavaScript in the project. See the module docs. +pub const SCRIPT: &str = r#" +(function () { + var down = null; + function id(e) { + var n = e.target; + while (n && !n.id) { n = n.parentNode; } + return n ? n.id : null; + } + document.addEventListener('pointerdown', function (e) { down = id(e); }); + document.addEventListener('pointerup', function (e) { + var up = id(e); + if (!down || !up) { down = null; return; } + var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up); + down = null; + fetch(window.CB_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body + }).then(function (r) { return r.text(); }).then(function (t) { + document.getElementById('cb-status').textContent = t; + if (t.indexOf('ok') === 0) { window.location.reload(); } + }); + }); +})(); +"#; + +const STYLE: &str = " +body{font:14px/1.5 ui-monospace,monospace;margin:1.5rem;background:#12141a;color:#dde} +h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} +.row{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-start} +.card{border:1px solid #445;border-radius:6px;padding:.5rem .7rem;background:#1b1e26} +.card[data-viewer=true]{border-color:#9cf} +.act{cursor:grab;user-select:none;background:#243;border-color:#5a7} +.btn{cursor:pointer;background:#332a3a;border-color:#a7d} +.k{color:#89a} +#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em} +svg{background:#1b1e26;border:1px solid #445;border-radius:6px} +"; + +fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { + let (label, sub, fill) = match p { + ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"), + ProblemView::FaceUp { + suit, + value, + denied, + claimed_by, + protected_this_round, + } => { + let mut sub = String::new(); + if *denied { + sub.push_str("denied "); + } + if *protected_this_round { + sub.push_str("protected "); + } + if let Some(c) = claimed_by { + let _ = write!(sub, "claimed by {}", seat_name(*c)); + } + ( + format!("{suit:?} {value}"), + sub.trim_end().to_string(), + "#26303a", + ) + } + }; + let _ = write!( + out, + "\ + {label}\ + priority {priority}\ + {sub}", + x = x, + tx = x + 10, + label = esc(&label), + sub = esc(&sub), + ); +} + +/// The relationship graph — the one element every Rust 2D toolkit would +/// have left us to hand-roll, and the reason SVG earns its place here +/// rather than merely fitting the budget (CB-RES-0006 §5). +fn relations_svg(view: &GroundView) -> String { + let n = view.players.len().max(1); + let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64); + let pos: Vec<(PlayerId, f64, f64)> = view + .players + .keys() + .enumerate() + .map(|(i, p)| { + let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2; + (*p, cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + let find = |p: PlayerId| { + pos.iter() + .find(|(q, _, _)| *q == p) + .map(|(_, x, y)| (*x, *y)) + }; + + let mut s = String::from( + "", + ); + for (pair, rel) in &view.relations { + if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) { + let colour = match rel { + games_ground::Relation::Bond => "#5c9", + games_ground::Relation::Rivalry => "#c66", + }; + let _ = write!( + s, + "\ + {rel:?}", + mx = (x1 + x2) / 2.0, + my = (y1 + y2) / 2.0 - 3.0, + ); + } + } + for (p, x, y) in &pos { + let is_viewer = view.viewer == Some(*p); + let focus = view + .focus + .get(p) + .map(|f| format!(" \u{2192}{}", seat_name(*f))); + let _ = write!( + s, + "\ + {name}\ + {focus}", + raw = p.0, + stroke = if is_viewer { "#9cf" } else { "#5a6b7a" }, + ty = y + 2.0, + ty2 = y + 16.0, + name = seat_name(*p), + focus = esc(focus.as_deref().unwrap_or("")), + ); + } + s.push_str(""); + s +} + +fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) { + let is_viewer = view.viewer == Some(id); + let _ = write!( + out, + "
{name}{you}
", + name = seat_name(id), + you = if is_viewer { " (you)" } else { "" }, + ); + let _ = write!( + out, + "stress {} protect {} \ + darvo {:?}
", + p.stress, p.protection, p.darvo + ); + let _ = write!( + out, + "freedom \ + {ready}{lifted}
", + raw = id.0, + ready = if p.freedom_ready { "READY" } else { "spent" }, + lifted = if p.freedom_gate_lifted { + " gate lifted" + } else { + "" + }, + ); + let blame = if p.blame_from.is_empty() { + "none".to_string() + } else { + p.blame_from + .iter() + .map(|b| seat_name(*b)) + .collect::>() + .join(" ") + }; + let _ = write!( + out, + "blamed by {}
", + esc(&blame) + ); + match &p.hand { + Some(h) => { + let _ = write!( + out, + "hand {} ({} cards)
", + esc(&cards(h)), + p.hand_size + ); + } + None => { + let _ = write!( + out, + "hand {} card(s), hidden
", + p.hand_size + ); + } + } + if let Some(sel) = view.selections.get(&id) { + let _ = write!( + out, + "selected {}
", + match sel { + SelectionView::Hidden => "face down".to_string(), + SelectionView::Shown(s) => esc(&format!("{s:?}")), + } + ); + } + if let Some(m) = view.ground_modes.get(&id) { + let _ = write!(out, "ground mode {m:?}
"); + } + if let Some(c) = view.ground_choices.get(&id) { + let _ = write!(out, "ground choice {c:?}
"); + } + if let Some(r) = view.support_responses.get(&id) { + let _ = write!(out, "support {r:?}
"); + } + if let Some(t) = view.darvo_targets.get(&id) { + let _ = write!(out, "darvo target {t:?}
"); + } + out.push_str("
"); +} + +/// Render the whole table as a standalone document. +/// +/// `may_pass` adds the one affordance that is not a command: declining to +/// act where the driver allows it. Like every other element it carries an +/// id and nothing else — the page still reports only that the pointer went +/// down and up on `pass`. +/// +/// `legal` is the list the aggregate offered; the buttons carry indices +/// into it and nothing else (ADR-0007 control 5). `endpoint` carries the +/// per-process token (control 1) — the page cannot mint one. +pub fn document( + view: &GroundView, + legal: &[games_ground::GroundCommand], + endpoint: &str, + seat: Option, + may_pass: bool, +) -> String { + let mut s = String::with_capacity(8192); + let _ = write!( + s, + "\ + \ + GROUND \u{2014} round {round}", + round = view.round + ); + + let _ = write!( + s, + "

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

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

problems

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

relationships

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

seats

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

solutions

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

outcome

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

your move

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

seen

\ + "; + let text = text_of(html); + assert!(text.contains("seen")); + assert!(text.contains("pid"), "element ids are addressable surface"); + assert!( + !text.contains("STYLETOKEN"), + "style contents leaked into the text" + ); + assert!( + !text.contains("SCRIPTTOKEN"), + "script contents leaked into the text" + ); + } + + /// Control 5, asserted at the document level: the emitted JavaScript + /// must contain no game vocabulary. If a rule ever needs to live in + /// the page, ADR-0007 says revisit the decision, not widen this. + #[test] + fn the_emitted_javascript_contains_no_game_vocabulary() { + let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false); + let script = doc + .split_once("")) + .map(|(s, _)| s.to_string()) + .expect("the document carries a script"); + for word in [ + "Investigate", + "Solve", + "Support", + "Attack", + "Ground", + "darvo", + "DARVO", + "stress", + "freedom", + "problem", + "coalition", + "reveal", + "resolve", + ] { + assert!( + !script.contains(word), + "the emitted JavaScript mentions {word:?} — control 5 is breached" + ); + } + // And it must still be doing its one job. + assert!(script.contains("pointerdown") && script.contains("pointerup")); + } + + /// The renderer must not invent a hand it was never given. + /// + /// Scoped deliberately: the *projection's* hiding rules are asserted + /// where they live, and re-asserting them here would be a duplicated + /// fact that drifts. What this checks is the renderer's own failure + /// mode — that `hand: None` renders as an absence, for every seat, and + /// that a spectator's document contains no open hand at all. + #[test] + fn the_renderer_never_invents_a_hand_it_was_not_given() { + for seat in [0u8, 1, 2] { + let view = crate::testfix::view(Some(PlayerId(seat))); + let text = rendered_document(&view); + let shown = text.matches("cards)").count(); + assert_eq!( + shown, + 1, + "P{} sees {shown} open hands in the document; it was given exactly one", + seat + 1 + ); + assert_eq!( + text.matches("card(s), hidden").count(), + 2, + "the other two seats' hands must render as an absence with a count" + ); + } + let text = rendered_document(&crate::testfix::view(None)); + assert_eq!( + text.matches("cards)").count(), + 0, + "a spectator document shows an open hand" + ); + assert!(text.contains("a spectator (no hands)")); + } +} + +#[cfg(test)] +mod testfix; diff --git a/crates/cb-render-html/src/serve.rs b/crates/cb-render-html/src/serve.rs new file mode 100644 index 0000000..dfe464d --- /dev/null +++ b/crates/cb-render-html/src/serve.rs @@ -0,0 +1,323 @@ +//! The loopback listener, and the controls that make it safe to have one. +//! +//! ADR-0007 Decision 5, controls 1–4. The threat the review named is not +//! hypothetical: +//! +//! > A loopback listener is reachable by any process on the machine **and +//! > by any web page the user visits**. The browser running the table is +//! > the same browser reading the internet — that is the whole premise of +//! > the option. An unauthenticated endpoint accepting `POST /command` and +//! > mutating authoritative game state is a remote-controlled game from +//! > any tab the user has open. +//! +//! So: a per-process token no page can guess, an `Origin` / +//! `Sec-Fetch-Site` check that rejects by default, an explicit +//! `127.0.0.1` bind, and — the control that makes the other three +//! evidence rather than claims — a test that a token-less request is +//! refused, with a mutation that turns it red. +//! +//! [`Guard::admit`] is a pure function of a parsed request, so all of that +//! is testable with no socket, no browser, and no timing. + +use std::io::Read; + +/// Why a request was refused. Each variant is a control that fired. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// Control 1: no token at all. + MissingToken, + /// Control 1: a token, but not ours. + BadToken, + /// Control 2: the request came from somewhere else's page. + CrossSite(String), + /// Not a shape this endpoint serves. + BadRequest(String), +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refusal::MissingToken => write!(f, "refused: no session token"), + Refusal::BadToken => write!(f, "refused: wrong session token"), + Refusal::CrossSite(o) => write!(f, "refused: cross-site request from {o}"), + Refusal::BadRequest(m) => write!(f, "refused: {m}"), + } + } +} + +/// A parsed HTTP/1.1 request, reduced to the fields the controls need. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Request { + pub method: String, + pub path: String, + pub token: Option, + pub origin: Option, + pub sec_fetch_site: Option, + pub body: String, +} + +impl Request { + /// Parse a raw request. Deliberately strict: this is hand-rolled HTTP + /// on a socket a hostile page can reach, so anything unexpected is a + /// refusal rather than a best effort. + pub fn parse(raw: &str) -> Result { + let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, "")); + let mut lines = head.split("\r\n"); + let start = lines + .next() + .ok_or_else(|| Refusal::BadRequest("empty request".into()))?; + let mut parts = start.split(' '); + let method = parts + .next() + .ok_or_else(|| Refusal::BadRequest("no method".into()))? + .to_string(); + let target = parts + .next() + .ok_or_else(|| Refusal::BadRequest("no target".into()))?; + + let (path, query) = target.split_once('?').unwrap_or((target, "")); + let token = query.split('&').find_map(|kv| { + kv.strip_prefix("t=") + .filter(|v| !v.is_empty()) + .map(str::to_string) + }); + + let mut req = Request { + method, + path: path.to_string(), + token, + body: body.to_string(), + ..Default::default() + }; + for line in lines { + if let Some((k, v)) = line.split_once(':') { + let v = v.trim().to_string(); + match k.to_ascii_lowercase().as_str() { + "origin" => req.origin = Some(v), + "sec-fetch-site" => req.sec_fetch_site = Some(v), + _ => {} + } + } + } + Ok(req) + } +} + +/// Holds the per-process token and applies controls 1 and 2. +#[derive(Debug, Clone)] +pub struct Guard { + token: String, + origin: String, +} + +impl Guard { + pub fn new(token: impl Into, port: u16) -> Self { + Self { + token: token.into(), + origin: format!("http://127.0.0.1:{port}"), + } + } + + /// Mint an unguessable token. + /// + /// `/dev/urandom` where it exists; otherwise `RandomState`, whose + /// per-process seed the OS randomises. The fallback is weaker and is + /// named rather than hidden — a token that silently degrades to + /// something predictable is worse than no token, because the controls + /// would still report themselves as passing. + pub fn mint(port: u16) -> Self { + let mut buf = [0u8; 24]; + let strong = std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .is_ok(); + if !strong { + use std::hash::{BuildHasher, Hasher}; + let s = std::collections::hash_map::RandomState::new(); + for chunk in buf.chunks_mut(8) { + let mut h = s.build_hasher(); + h.write_usize(std::process::id() as usize); + h.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + ); + chunk.copy_from_slice(&h.finish().to_le_bytes()[..chunk.len()]); + } + } + let token: String = buf.iter().map(|b| format!("{b:02x}")).collect(); + Self::new(token, port) + } + + pub fn token(&self) -> &str { + &self.token + } + + /// The URL the page is told to POST to. The page cannot mint one. + pub fn endpoint(&self) -> String { + format!("/command?t={}", self.token) + } + + pub fn page_url(&self) -> String { + format!("{}/?t={}", self.origin, self.token) + } + + /// Apply controls 1 and 2. Rejects by default. + pub fn admit(&self, req: &Request) -> Result<(), Refusal> { + // Control 2 first: a cross-site request should be refused before + // its token is even considered, so a leaked token is not enough on + // its own. + match req.sec_fetch_site.as_deref() { + // Sent by every modern browser. Anything but same-origin is + // not our page. + Some("same-origin") | Some("none") => {} + Some(other) => return Err(Refusal::CrossSite(other.to_string())), + None => { + // Older or non-browser clients omit it; fall back to + // Origin, and require it to be ours when present. + if let Some(o) = &req.origin { + if o != &self.origin { + return Err(Refusal::CrossSite(o.clone())); + } + } + } + } + if let Some(o) = &req.origin { + if o != &self.origin { + return Err(Refusal::CrossSite(o.clone())); + } + } + + // Control 1. + match &req.token { + None => Err(Refusal::MissingToken), + Some(t) if constant_eq(t, &self.token) => Ok(()), + Some(_) => Err(Refusal::BadToken), + } + } +} + +/// Compare without an early return on the first differing byte. +/// +/// The endpoint is loopback and the token is 192 bits, so a timing oracle +/// is not the realistic attack here. It costs one line. +fn constant_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.bytes() + .zip(b.bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +/// Control 3: bind explicitly to loopback, never `0.0.0.0`. +pub fn bind(port: u16) -> std::io::Result { + std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn guard() -> Guard { + Guard::new("s3cr3t", 8731) + } + + fn req(raw: &str) -> Request { + Request::parse(raw).expect("parses") + } + + /// **ADR-0007 control 4.** The one that makes controls 1–3 evidence + /// rather than claims. + /// + /// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in + /// `admit` (return `Ok(())` instead) and this goes red with + /// `a token-less request was admitted`. Run 2026-08-02. + #[test] + fn a_token_less_request_is_refused() { + let g = guard(); + let r = req("POST /command HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\ndown=a&up=b"); + assert_eq!( + g.admit(&r), + Err(Refusal::MissingToken), + "a token-less request was admitted" + ); + } + + #[test] + fn a_wrong_token_is_refused() { + let g = guard(); + let r = req("POST /command?t=guess HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); + assert_eq!(g.admit(&r), Err(Refusal::BadToken)); + // A prefix of the real token must not be admitted either. + let r = req("POST /command?t=s3c HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); + assert_eq!(g.admit(&r), Err(Refusal::BadToken)); + } + + #[test] + fn the_right_token_from_our_own_page_is_admitted() { + let g = guard(); + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\ + Origin: http://127.0.0.1:8731\r\n\r\ndown=a&up=b", + ); + assert_eq!(g.admit(&r), Ok(())); + } + + /// The attack the review named: a page on the open internet POSTs to + /// `127.0.0.1`. Even holding a leaked token, it must not be admitted. + #[test] + fn a_cross_site_request_is_refused_even_with_the_right_token() { + let g = guard(); + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: cross-site\r\n\ + Origin: https://evil.example\r\n\r\ndown=a&up=b", + ); + assert!( + matches!(g.admit(&r), Err(Refusal::CrossSite(_))), + "{:?}", + g.admit(&r) + ); + + // A client that omits Sec-Fetch-Site but sends a foreign Origin is + // the same attack with an older browser. + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nOrigin: https://evil.example\r\n\r\ndown=a&up=b", + ); + assert!(matches!(g.admit(&r), Err(Refusal::CrossSite(_)))); + } + + #[test] + fn requests_parse_and_carry_what_the_controls_need() { + let r = req("POST /command?t=abc HTTP/1.1\r\nHost: 127.0.0.1:8731\r\n\ + Origin: http://127.0.0.1:8731\r\nSec-Fetch-Site: same-origin\r\n\ + Content-Length: 9\r\n\r\ndown=a&up=b"); + assert_eq!(r.method, "POST"); + assert_eq!(r.path, "/command"); + assert_eq!(r.token.as_deref(), Some("abc")); + assert_eq!(r.origin.as_deref(), Some("http://127.0.0.1:8731")); + assert_eq!(r.sec_fetch_site.as_deref(), Some("same-origin")); + assert_eq!(r.body, "down=a&up=b"); + // An empty t= is no token, not a token that happens to be empty. + let r = req("GET /?t= HTTP/1.1\r\n\r\n"); + assert_eq!(r.token, None); + } + + /// A minted token must not be a constant, and must not be short. + #[test] + fn a_minted_token_is_long_and_not_reused() { + let a = Guard::mint(1).token().to_string(); + let b = Guard::mint(1).token().to_string(); + assert_eq!(a.len(), 48, "token is {} hex chars", a.len()); + assert_ne!(a, b, "two mints produced the same token"); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + /// Control 3, asserted rather than commented. + #[test] + fn the_listener_binds_loopback_only() { + let l = bind(0).expect("bind"); + assert_eq!(l.local_addr().unwrap().ip().to_string(), "127.0.0.1"); + } +} diff --git a/crates/cb-render-html/src/testfix.rs b/crates/cb-render-html/src/testfix.rs new file mode 100644 index 0000000..d252e07 --- /dev/null +++ b/crates/cb-render-html/src/testfix.rs @@ -0,0 +1,118 @@ +//! A populated `GroundView`, built for the coverage gate. +//! +//! Mid-DARVO, mid-GROUND, scored — **not** a fresh deal. Most of the +//! fields this gate exists to cover are empty at deal time, and a coverage +//! test run against a state where the fields are absent is the same lie in +//! a different costume (CB-WP-0011 T01). +//! +//! Built directly rather than projected from a `GroundState`. The +//! projection's own hiding rules are already asserted by +//! `games_ground::view` and by `cb-play`'s +//! `the_inspector_never_widens_the_projection`; re-asserting them here +//! would be a duplicated fact that drifts. What *this* crate must not do +//! is invent a hand it was never given, and that is what the renderer test +//! checks — with `hand: None` supplied deliberately. + +use std::collections::BTreeMap; + +use cb_kernel::PlayerId; +use games_ground::view::{GroundView, OutcomeView, PlayerView, ProblemView, SelectionView}; +use games_ground::{ + Action, Coalition, DarvoStage, DarvoTarget, GroundChoice, GroundMode, Pair, Relation, + RoundStep, ScoringMode, Selection, SolutionCard, Suit, SupportResponse, +}; + +pub fn seats() -> (PlayerId, PlayerId, PlayerId) { + (PlayerId(0), PlayerId(1), PlayerId(2)) +} + +/// The fixture, seen by `viewer` (`None` for a spectator). +pub fn view(viewer: Option) -> GroundView { + let (p1, p2, p3) = seats(); + let card = |suit| SolutionCard { suit }; + + let mut players = BTreeMap::new(); + for (id, stress, protection, darvo, ready, lifted, blame) in [ + (p1, 5u8, 2u8, DarvoStage::Reverse, true, true, vec![p3]), + (p2, 1, 0, DarvoStage::Off, false, false, vec![]), + (p3, 0, 0, DarvoStage::Deny, true, false, vec![]), + ] { + let own = viewer == Some(id); + players.insert( + id, + PlayerView { + stress, + freedom_ready: ready, + freedom_gate_lifted: lifted, + darvo, + protection, + blame_from: blame, + hand: own.then(|| vec![card(Suit::Clarify), card(Suit::Boundary)]), + hand_size: 2, + }, + ); + } + + let mut problems = BTreeMap::new(); + problems.insert(1, ProblemView::FaceDown); + problems.insert( + 7, + ProblemView::FaceUp { + suit: Suit::Change, + value: 6, + denied: true, + claimed_by: Some(p2), + protected_this_round: true, + }, + ); + + GroundView { + viewer, + round: 3, + lead: p2, + step: RoundStep::Select, + mode: ScoringMode::BondedCoalitions, + players, + relations: BTreeMap::from([ + (Pair::new(p1, p2), Relation::Rivalry), + (Pair::new(p2, p3), Relation::Bond), + ]), + problems, + focus: BTreeMap::from([(p1, p3)]), + selections: BTreeMap::from([ + ( + p1, + SelectionView::Shown(Selection { + action: Action::Attack, + target: Some(p2), + problem: Some(7), + }), + ), + (p2, SelectionView::Hidden), + ]), + ground_modes: BTreeMap::from([(p3, GroundMode::Gr)]), + ground_choices: BTreeMap::from([(p3, GroundChoice::ProtectProblem { problem: 7 })]), + support_responses: BTreeMap::from([(p2, SupportResponse::AcceptBond)]), + darvo_targets: BTreeMap::from([( + p3, + DarvoTarget { + problem: Some(1), + player: Some(p2), + }, + )]), + solution_deck_len: 17, + solution_discard: vec![card(Suit::Repair), card(Suit::Change)], + outcome: Some(OutcomeView { + total: 9, + threshold: 12, + group_success: false, + personal: BTreeMap::from([(p1, 3), (p2, -1), (p3, 0)]), + coalitions: vec![Coalition { + members: vec![p2, p3], + score: 4, + }], + mastery: Some(1), + winners: vec![p1], + }), + } +} diff --git a/decisions/ADR-0007-render-html-not-a-port.md b/decisions/ADR-0007-render-html-not-a-port.md new file mode 100644 index 0000000..264d44b --- /dev/null +++ b/decisions/ADR-0007-render-html-not-a-port.md @@ -0,0 +1,173 @@ +# ADR-0007: render to HTML, and do not declare the port yet + +status: accepted +date: 2026-08-02 +decided by: agent, under the standing loop authorization. **Two items are +explicitly reserved for the maintainer and are not decided here** — see +§Reserved. +tier: L (structural L — the declaration was made when this pass would +create a capability port; chaos d4=1 → no override. Review removed the +port, and the tier was **not** re-derived — see §Consequences) +references: [CB-RES-0006](../research/CB-RES-0006-render-port.md), +`history/260802-render-port-{research,challenge,response}.md`, +[INTENT.md](../INTENT.md) §Implementation order, +[ArchitectureBlueprint.md](../specs/ArchitectureBlueprint.md) §Port pattern, +[ADR-0004](ADR-0004-am4-ratification.md) (AM-4 budgets), +[CB-EV-0009](../evidence/CB-EV-0009-inspectable-table.md) (the inspector) + +## Context + +INTENT stage 1 is *"Inspectable 2D table — card/token/hand/relationship- +graph visualization, drag-to-propose, debug inspector, hot-seat play."* +CB-WP-0011 shipped the debug inspector. This decides how the other three +get drawn. + +The obvious shape — `cb-render-api` / `cb-render-null` / `cb-render-wgpu`, +straight from ArchitectureBlueprint §Port pattern — did not survive review. +Two things went wrong with the survey that produced it, and both are +recorded because the decision below is only trustworthy given what was +wrong with the reasoning that first reached it. + +## Decision 1 — render by emitting HTML, SVG and JavaScript + +`cb-render-html` emits a document; the browser draws it. SVG covers cards, +tokens and the relationship graph natively — the graph being the one +element every Rust 2D toolkit would have left us to hand-roll. Pointer +events cover drag-to-propose. One tab covers hot-seat. + +**Rejected alternatives, with measured marginal cost against the +23-crate shipped-runtime base:** + +| rejected | marginal lines | why not | +|---|---:|---| +| `egui` + `eframe` | 2,782,849 | 30× the corrected headroom | +| `wgpu` + `winit` | 1,741,979 | stage 2's, and stage 2 should buy it | +| `softbuffer` + `tiny-skia` + `winit` | 1,147,081 | windowing dominates the rasterizer | +| `ratatui` + `crossterm` | 1,067,013 | costs more than a GPU game framework; `rustix` → `linux-raw-sys` at 479,901 | +| `macroquad` | 480,501 | cheapest GPU-era stack, still 5.2× | +| `sdl2` | 191,973 + a C library | see Decision 3 | +| **`fltk`** | **140,079** + a C library | **the real floor. Affordable if the target moved — rejected on allocation, not cost** | +| `tiny-skia` alone | 83,956 | fits, but rasterizes without windowing or input | + +**The reason is allocation, not affordability.** `fltk` is 1.5× the +corrected headroom — a negotiation, not an impossibility. But `wgpu` at +1,741,979 is named by INTENT for stage 2, is unavoidable, and is **twelve +times** the cost of the stage-1 toolkit it would replace. Spending 1.5× the +remaining budget on windowing that stage 2 discards is the wrong +allocation whether or not the budget can be stretched. This argument does +not depend on AM-4a's target being 250,000; it depends only on stage 2's +bill exceeding stage 1's benefit, which is measured and true by 12×. + +The first draft of the survey argued affordability — "128×, two orders of +magnitude, not a near miss." That was false, drawn from a candidate list +containing no cheap windowed toolkits. It is recorded here because a +decision whose stated reason was wrong once should carry the correction +where the decision lives, not only where the survey does. + +## Decision 2 — do **not** declare `cb-render-api` or `cb-render-null` + +INTENT: *"No concept becomes canonical merely because it looks general. It +becomes canonical after surviving a second concrete use."* + +A port designed against one implementation that emits whole documents +acquires a document's shape — retained mode, full state per update, string +identity, no frame timing — and stage 2 finds it unimplementable and +rewrites it. `CommitWindow` is already in this repo with a delete-by date +and two declined second-use opportunities, for exactly this error. + +`cb-render-html` therefore ships against the **existing `Project` trait**, +which is a real interface with real implementations. The port is declared +at stage 2, derived from two implementations rather than imagined from one. +`cb-render-null` goes with it: a null implementation of an interface that +does not exist is a null implementation of nothing. + +**This overrides ArchitectureBlueprint §Port pattern for this capability, +and only for its timing.** The blueprint says every important capability +ships null, reference and optimized implementations; it does not say when +the interface is fixed. Where the two rules met, INTENT's second-use rule +won, because it is the one INTENT states without exception. + +## Decision 3 — AM-4 counts what the project causes to be acquired + +Scoring the browser at zero is true under AM-4a and suspect in substance. +If a target can be satisfied by relocating a dependency somewhere the +target does not look, it constrains nothing — and the same move was already +flattering `sdl2` and `fltk`, whose `.rs` counts exclude the C libraries +that do the work. + +> **AM-4 counts third-party code the project causes to be acquired.** It +> does not count runtimes the user already has independently of us — the +> kernel, the system C library, the shell, the terminal, a web browser. It +> **does** count a library our build or install instructions cause to be +> fetched, pinned, or linked, whether or not its source is Rust. + +Consequences, applied consistently: a browser is not counted; **`sdl2` and +`fltk` are counted at more than their Rust bindings**, since they oblige a +user to install a `-dev` package. The rule makes the target harder to +satisfy, not easier, which is the only direction a rule proposed by its +beneficiary should be trusted to run. + +## Decision 4 — AM-4a's proc-macro defect is a separate change + +AM-4a counts `--edges normal`, which includes proc-macro crates that run in +the compiler and never reach a binary: `syn` (66,916), `proc-macro2`, +`quote`, `unicode-ident`, `serde_derive` — **89,048 lines, 36.2%** of the +"shipped-runtime" figure. Real headroom is **92,798**, not the 3,750 this +repo has cited in every pass that mentioned it, including CB-WP-0011's +reason for deferring this declaration. + +The metric should count `--edges normal,no-proc-macro`. **Filed as its own +change, not bundled here.** A budget correction that arrives attached to the +request it unblocks is indistinguishable from motivated reasoning even when +it is right — and it is right, which is why it must arrive separately. + +## Decision 5 — six controls bind the implementation + +| # | control | why | +|---|---|---| +| 1 | unguessable token minted per process, required on every request | a loopback listener is reachable by any process **and any web page the user visits** | +| 2 | `Origin` / `Sec-Fetch-Site` checked, rejected by default | cross-origin POST to `127.0.0.1` is the attack, not a hypothetical | +| 3 | listener bound explicitly to `127.0.0.1` | not `0.0.0.0` by default | +| 4 | **a test that a token-less request is refused**, plus an M-D1-MUT mutation removing the check and turning it red | 1–3 without 4 are three claims and no evidence | +| 5 | **JavaScript may not construct commands** — the page reports raw pointer facts (`down on id`, `up on id`); Rust decides what command they mean | confines JS to input transport, making the decision testable in Rust against synthetic events | +| 6 | **the coverage gate crosses the language boundary** — the HTML counterpart of `every_view_field_is_classified` asserts over the **parsed emitted document** | asserting over the Rust that emits the document reproduces CB-WP-0011's defect one layer up | + +Controls 5 and 6 exist because CB-WP-0011 established that a renderer's +defect class is silent omission, and this decision moves the interactive +half of stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT` +cannot reach. Without them, this ADR would spend a pass's finding one pass +after paying for it. + +**K13 binds unchanged.** The renderer consumes a projection and can never +feed back into validation; a drag that proposes a move goes through the +same command path a CLI move takes. + +## Reserved for the maintainer — not decided here + +1. **AM-4a is incompatible with INTENT stage 2.** `wgpu` + `winit` is + 1,741,979 marginal lines against a **250,000** total target — 7× the + whole budget, 19× the corrected headroom. No sequencing, feature-gating + or metric correction closes that. Either the target moves, the render + port sits outside the AM-4a configuration by an argued rule, or stage 2 + changes. **A pass that discovers a budget conflict and also settles it + has reviewed nothing**, so this pass does not settle it. +2. **Whether Decision 3's acquisition rule is the right rule**, given it is + proposed by the pass that benefits from it. It is written to cost more + than it saves, but that is an argument, not a ratification. + +## Consequences + +- Stage 1 ships without a capability port. That is a deliberate deferral + with a named trigger (stage 2's `wgpu` implementation), not an omission. +- **The tier is now wrong and was not re-derived.** Tier L's structural + trigger was "creates a new capability port"; review removed the port. + Re-rolling would make the tier a function of the outcome, which is the + one thing a tier declaration must not be. The pass runs at L. This is the + CHAOS calibration window's second entry, and unlike the first it comes + from a *non*-override: full-weight review deleted its own trigger. +- A second language enters the build, gated only by control 6. If the + emitted JavaScript grows beyond input transport, control 5 has failed and + the decision should be revisited rather than the control widened. +- `cb-play` acquires a third mode. CB-EV-0009 §5 flagged that a third mode + is the second use at which its single-binary shape should be + reconsidered; that reconsideration is now due. diff --git a/evidence/CB-EV-0010-render-port.md b/evidence/CB-EV-0010-render-port.md new file mode 100644 index 0000000..faba35e --- /dev/null +++ b/evidence/CB-EV-0010-render-port.md @@ -0,0 +1,216 @@ +# CB-EV-0010 — tier L at full weight, and what it deleted + +CB-WP-0012 T05. Measured 2026-08-02 at `84d6886`. Pass kind `product`. + +The previous pass was the CHAOS gate's first fire, rolling this same +declaration from L down to S. This one rolled a 1 and ran at L. So for the +first time there are two passes on the same subject at two tiers, and the +comparison is the point. + +--- + +## 1. What full-weight review actually did + +**It deleted the thing the pass was declared to build.** + +The declaration's structural trigger was *"creates a new capability port."* +The survey recommended `cb-render-api` + `cb-render-null` + +`cb-render-html`. The adversarial review (C3) found that recommendation +contradicted the survey's own citation of INTENT's second-use rule, and +the response conceded entirely. **What shipped has no port and no null +implementation** — a renderer against the existing `Project` trait, and a +deferral with a named trigger. + +That is the strongest evidence yet that step 2 is not ceremony. A tier-S +pass has no step 2, would have shipped `cb-render-api`, and stage 2 would +have found it unimplementable and rewritten it — which is exactly what +`CommitWindow` is already on record in this repo for doing. + +**Four of six challenges were conceded, not answered.** Two of them +invalidated the survey's main arguments: + +| challenge | outcome | +|---|---| +| C1 the sub-100k region was never measured | conceded; **it is not empty** | +| C2 "cost zero" scored on an axis chosen to produce zero | conceded; one rule now covers browser, `sdl2` and `fltk` alike | +| C3 the port contradicts the second-use rule | conceded; **port withdrawn** | +| C6 the C1 measurements had no positive control | conceded; re-measured under it | +| C4 loopback server undersold | controls adopted into ADR-0007 | +| C5 the gate stops at the language boundary | controls adopted into ADR-0007 | + +**Fidelity note, which caps all of the above.** The review ran in the same +session as the survey, not a separate one, because this environment's +standing instruction is not to spawn agents unasked. It therefore inherits +the author's sampling — the exact failure mode that produced CB-WP-0002's +dedup blind spot and CB-WP-0005's AM-7 defect. Treat the six challenges as +a lower bound. + +## 2. The numbers the survey got wrong, and by how much + +The survey's first draft argued the render port was unaffordable: + +> *"The cheapest candidate that opens a window costs 128 times the entire +> remaining budget. This is not a near miss to be negotiated; it is two +> orders of magnitude."* + +Both halves were wrong, in the same direction, for two independent +reasons — and they compound: + +| | claimed | measured | +|---|---:|---:| +| AM-4a headroom | 3,750 | **92,798** (36.2% of the figure is proc-macro code that never ships) | +| cheapest windowed toolkit | 480,501 (`macroquad`) | **140,079** (`fltk`) | +| ratio | 128× | **1.5×** | + +The instrument was wrong by 25×, the candidate sample by 3.4×, and the +headline claim by 85×. The 3,750 figure has been cited in every pass that +mentioned AM-4a, CB-WP-0011's reason for deferring this declaration +included. + +**The recommendation survived both corrections**, and only because it was +re-derived rather than defended: the argument moved from *affordability* +to *allocation* — stage 2's `wgpu` bill is **1,741,979** marginal lines, +twelve times the stage-1 toolkit it would replace, so buying `fltk` means +spending 1.5× the remaining budget on something stage 2 discards. That +argument needs no particular value for AM-4a's target, which is why it is +worth more than the one it replaced. + +## 3. AM-4a cannot survive stage 2 — raised, not decided + +| | marginal lines | +|---|---:| +| AM-4a target, **total** | 250,000 | +| `wgpu` + `winit`, named by INTENT stage 2 | **1,741,979** | + +**7× the entire target, 19× the corrected headroom.** No sequencing, +feature-gating or metric correction closes that. AM-4a as targeted is +incompatible with INTENT as written, and has been since both were written; +nothing in this pass caused it. + +Reserved for the maintainer along with the acquisition rule of ADR-0007 +Decision 3 — which is proposed by the pass that benefits from it, is +written to cost more than it saves, and is still an argument rather than a +ratification. + +## 4. What shipped, and what has never been run + +`cb-render-html`: HTML/SVG/JS emission, pointer-fact resolution, and a +loopback listener. `cb-play --serve PORT`. **Measured** marginal cost: + +``` +games-ground shipped: 23 third-party crates +cb-render-html: 23 third-party crates +new crates introduced: 0 +``` + +AM-4a unmoved at 246,250. Own source 7,636 → 9,652. + +Eight mutations run, each red for its stated reason. Two results worth +more than the six that behaved: + +- **The coverage gate fired on its author again**, first run, before + commit: `ground_choices.*.choice`, `ground_choices.*.problem` and + `players.*.blame_from` were in neither list. The third is the one to + keep — an **empty vector is a leaf path of its own**, and a fixture + where every collection is populated would never have produced it. It + now renders as an explicit absence (`blamed by none`). +- **One mutation did not go red.** Removing the `Sec-Fetch-Site` arm alone + left the cross-site test green: the `Origin` check caught it + independently. Both had to go before the control bit. Defence in depth + is fine; a control that passes for a reason you did not intend has not + been demonstrated, and would have been reported as a clean result by + anyone who ran one mutation and stopped. + +### What has never been executed + +**The emitted JavaScript has never run.** Every test is on the Rust side: +the socket loop is driven by synthetic HTTP, and the page is asserted +against as a parsed document. No browser engine has executed `SCRIPT`. + +So "hot-seat play" is delivered in the sense that the Rust half is tested +end-to-end over a real socket and the page is emitted correctly — and +**not** in the sense that anyone has played a game in a browser. Control 5 +bounds the exposure (the JS is 20 lines and holds no game logic, by +assertion) but does not remove it. + +**INTENT stage 1 is therefore left open.** Its four named deliverables now +all exist, which is the first time that has been true — but a stage marked +complete on the strength of code that has never been run once is the +failure mode stage 0 avoided by leaving the CLI player open for three +passes. Closing it is the maintainer's call, and it needs one person to +open the URL. + +## 5. Cost, shape, and a prediction that held + +| pass | kind | responses | cost | $/response | +|---|---|---|---|---| +| CB-WP-0008 | product | 134 | $17.38 | 0.123 | +| CB-WP-0009 | meta | 46 | $11.31 | 0.246 | +| CB-WP-0010 | product | 26 | $4.08 | 0.157 | +| CB-WP-0011 | product | 71 | $7.02 | **0.099** | +| **CB-WP-0012** | **product** | **72** | **$8.82** | **0.123** | + +**Correcting CB-EV-0009 §4.** It reported CB-WP-0011 as `45 responses, +$4.23, 0.094 $/response` and called it the cheapest pass on record. The +final figures are **71 responses, $7.02, 0.099**. Still the cheapest, so +the conclusion holds — but the number does not, and this is the *second* +consecutive pass to report its own cost low mid-flight (CB-EV-0008 did the +same for CB-WP-0009). Twice is a pattern: **a pass cannot measure its own +cost, and every evidence file that quotes its own is quoting a floor.** +Future evidence files should quote the prior pass's final figure and mark +their own as provisional. + +This tier-L pass cost 0.123 $/response against the rolled-down pass's +0.099 — **24% more per response**, for a pass that also deleted its own +deliverable and found two errors of a factor of 25 and 85. That is the +first priced comparison of the two tiers on the same subject, and on this +one data point step 2 is cheap. + +**Meta budget: 0%**, exactly as CB-EV-0009 §4 predicted: + +> *"if the next pass is `product`, the trailing-3 meta share drops to 0%, +> because CB-WP-0009 will be the pass that rolled off. If it does not, the +> windowing is wrong in a way neither CB-EV-0007 §3 nor CB-EV-0008 §1 +> found."* + +Falsifiable, published in advance, and it held. The windowing is doing +what ADR-0006 D1 says it does. + +### Session shape + +| metric | this pass | target | +|---|---|---| +| SH-1 mean context | 209,486 `[SOFT]` | ≤ 200,000 soft / 300,000 hard | +| SH-2 p90 context | 210,760 `[ok]` | ≤ 300,000 / 450,000 | +| SH-3 batching | 0.0% `[SOFT]` | ≥ 20% | + +SH-1 drifted back over the soft line — a tier-L pass reads more than a +tier-S one, which is the mechanism, not an excuse. The standing prediction +from CB-EV-0009 (*"the next pass opened above the SH-1 hard line will cost +more per response than 0.123"*) is **untested**: this pass opened after a +compaction, below the line. + +**SH-3 has now read 0.0% for six consecutive passes** against a 20% floor. +It is the oldest unargued number in the project: either the floor is wrong +or the behaviour is, and no pass has argued either. It is past time this +was a declaration of its own rather than a line in an evidence file. + +## 6. Open + +- **INTENT stage 1 stays open** — all four deliverables exist; none of the + browser half has been run. §4. +- **Two items reserved for the maintainer**: AM-4a vs stage 2, and + ADR-0007 Decision 3's acquisition rule. §3. +- **CHAOS gets its second entry**, and it is the more interesting one: the + first came from an override, this one from a *non*-override that deleted + its own structural trigger. Window open to 2026-09-30; 7 of 12 + declarations used, 1 override. +- **GATE-REVIEW still has zero `caught`**, two passes older. +- **SH-3 at 0.0% for six passes.** §5. +- **`cb-play` is now three modes in one binary** — play, inspect, serve. + CB-EV-0009 §5 named the third mode as the second use at which the + single-binary shape should be reconsidered. That is now due, and this + pass did not do it. +- **The proc-macro correction to AM-4a is filed and unimplemented.** + ADR-0007 Decision 4. Until it lands, every AM-4a figure in this repo + overstates the load by 36%. diff --git a/gates.toml b/gates.toml index 29b43be..bcb3fd3 100644 --- a/gates.toml +++ b/gates.toml @@ -122,6 +122,7 @@ added = "2026-07-30" review_by = "2026-09-30" caught = [ "CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)", + "CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)", ] retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible" diff --git a/history/260802-render-port-challenge.md b/history/260802-render-port-challenge.md new file mode 100644 index 0000000..6c3e7cd --- /dev/null +++ b/history/260802-render-port-challenge.md @@ -0,0 +1,204 @@ +# 260802 — challenge to CB-RES-0006 + +Adversarial review, one round, per InnerLoop §Step 2. + +**Fidelity note, first, because it caps everything below.** The spec asks +for a *separate session or agent*. This review was run in the same session +as the survey, because this environment's standing instruction is not to +spawn agents unless asked. So it does not have the property the step exists +to provide: the reviewer inherits the author's sampling, the author's +framing, and the author's blind spots. Every prior instance of this class in +the repo — CB-WP-0002's dedup sample, CB-WP-0005's AM-7 assertion — was a +shared blind spot between author and reviewer who sampled the same way. +Treat the challenges below as a lower bound on what a genuinely separate +reviewer would find. + +The claim rests on numbers, so per the table in §Step 2 the reviewer's job +is to reproduce them independently and to mutate the assertions behind +them — not to argue with the prose. + +--- + +## C1 — The survey concluded the sub-100k region was empty without looking + +**This is the challenge that lands.** + +The survey measures seven candidates. The cheapest windowed one costs +480,501. It then reports the corrected headroom as 92,798 and concludes +that no windowed toolkit fits — a conclusion about the interval +`0 < x < 92,798` drawn from a sample whose smallest windowed member is +480,501. Nothing was measured in the interval the conclusion is about. + +The candidate list is not random, either. It is the list a 2026 Rust +developer reaches for, which is a list of GPU-era stacks: everything on it +pulls `wgpu`, `winit`, or `rustix`. Cheap, old, thin bindings to system +libraries were structurally absent from the sample. + +Measured, same method, marginal against the same base: + +| candidate | marginal lines | vs 92,798 corrected headroom | +|---|---:|---:| +| `tiny-skia` alone | **83,956** | **fits**, at 90% of it | +| `fltk` | **140,079** | 1.5× | +| `sdl2` | **191,973** | 2.1× | +| `sdl3` | 276,445 | 3.0× | +| `raqote` | 286,852 | 3.1× | +| `minifb` | 774,389 | 8.3× | +| `pixels` | 863,528 | 9.3× | +| `speedy2d` | 1,328,008 | 14.3× | + +**The survey's central rhetorical move — "this is not a near miss to be +negotiated; it is two orders of magnitude" — is false.** It is two orders +of magnitude for the candidates the survey chose. The real floor for a +windowed toolkit is `fltk` at 140,079, which is **1.5×** the corrected +headroom. That is exactly a near miss to be negotiated. + +`tiny-skia` at 83,956 actually fits — it is a pure-Rust CPU rasterizer with +no windowing, so it is not by itself a table, but it disproves the survey's +stated interval claim on its own. + +**Required:** the survey must either withdraw the "two orders of magnitude" +framing and re-argue against a 1.5× floor, or concede that its +recommendation rests on something other than dependency cost. + +## C2 — "Marginal cost zero" is scored on an axis chosen to produce zero + +AM-4a measures vendored third-party **Rust**. The HTML option scores zero +because it relocates the renderer into a runtime AM-4a cannot see. The +browser is tens of millions of lines of unaudited third-party code. Calling +that zero is true under the metric and false in substance. + +If a proposal may satisfy a dependency target by moving the dependency +somewhere the target does not look, then the target constrains nothing, and +this pass is the first to demonstrate it. That is a worse outcome for the +project than buying `macroquad` honestly. + +**And the same critique convicts C1's cheap candidates.** `sdl2` at 191,973 +and `fltk` at 140,079 are thin Rust bindings to large C libraries; their +`.rs` line counts exclude the actual implementation for exactly the same +reason the browser's is excluded. So the metric already permits this move, +and the survey's own table already rewards it — it simply did not notice, +because it never measured a binding. + +**Required:** one rule, applied to all three. Either relocating an +implementation into an unmeasured runtime is permissible (and then `sdl2` +and `fltk` are legitimately cheap and must be compared on their merits, not +excluded on cost), or it is not (and then HTML is not free either). The +survey cannot have it one way for the browser and another for `sdl2` +without stating the distinction. It states none. + +## C3 — The survey contradicts itself on the second-use rule + +Recommendation 1 declares `cb-render-api` a canonical port now. +Recommendation 4 says `cb-render-wgpu` at stage 2 is "where the port +interface gets its second use and only then becomes canonical." + +Both cannot hold. INTENT: *"No concept becomes canonical merely because it +looks general. It becomes canonical after surviving a second concrete +use."* A port interface written against exactly one implementation, which +emits whole documents, will acquire a document's shape — retained mode, +full state per update, string identity, no frame timing — and stage 2 will +find it unimplementable and rewrite it. The survey names this risk in §6 +and mitigates it with "write it to be implemented twice," which is a wish. +`CommitWindow` is already on record in this repo as the concept that looked +general, has a delete-by date, and has declined two second-use +opportunities. + +**Required:** either defer `cb-render-api` until stage 2 supplies a second +implementation, and ship `cb-render-html` directly against the existing +`Project` trait — or state why this port is exempt from a rule INTENT +states without exception. + +## C4 — The 250-line HTTP server is undersold, and "loopback only" is not a boundary + +The survey bounds the risk with "listens on loopback only and speaks to a +page it emitted itself." Neither clause holds: + +- Any process on the machine can reach a loopback listener. Hot-seat play + is explicitly *several people at one machine*. +- **Any web page the user visits can reach it too.** A page on the open + internet can issue requests to `127.0.0.1` — that is what DNS rebinding + and cross-origin POST are. The browser running the table is the same + browser reading the internet; that is the whole premise of the option. +- "A page it emitted itself" is an assumption the server cannot check + unless it is written to check it, and the survey specifies no such check. + +An unauthenticated loopback endpoint that accepts `POST /command` and +mutates authoritative game state is a remote-controlled game from any tab +the user has open. + +**Required, as concrete controls the survey lacks:** an unguessable token +minted per process and required on every request; `Origin` and +`Sec-Fetch-Site` checked and rejected by default; the listener bound to +`127.0.0.1` explicitly rather than `0.0.0.0`; and a test that a request +without the token is refused. The last one is the one that matters — the +other three are claims until something asserts them. + +## C5 — The proposal regresses the finding the previous pass paid for + +CB-WP-0011's finding, in its own words: a renderer's defect class is +**silent omission**, no natural renderer assertion catches it, and the fix +is to walk the shape of the input and make silence cost a build. + +This proposal moves drag hit-testing and command construction into +JavaScript embedded in emitted strings — a language `cargo test` cannot +run, `clippy` cannot lint, and `M-D1-MUT` cannot mutate. The gate that +CB-WP-0011 built stops at the language boundary, and the survey proposes +putting the interactive half of stage 1 on the far side of it, one pass +after paying to learn why that is dangerous. + +§6 lists this as "the strongest argument against, and the one T02 should +press." Naming a challenge in advance is not answering it. + +**Required, as controls:** +- **JS may not construct commands.** The emitted page reports raw pointer + facts — "pointer down on element `id`, up on element `id`" — and Rust + decides what command that is. That confines JS to input transport and + makes the decision testable by feeding the Rust side synthetic events. +- **The coverage gate must cross the boundary.** `every_view_field_is_ + classified` must have an HTML counterpart asserting over the *parsed + emitted document*, not over the Rust that emits it. Emitting a `
` + per field is not evidence the field is visible; the assertion must be + that the token is in the document. + +## C6 — One number in the survey is reproduced; one is not independently checkable + +Reproduced: the proc-macro delta. `--edges normal,no-proc-macro` gives 18 +crates / 157,202 lines against 23 / 246,250. The 89,048 / 36.2% figure is +correct, and the five crates named are the five that differ. + +**Not independently checkable:** every candidate figure, because the +reviewer used the author's script. Per §Step 2 this is precisely the +failure mode — "the reviewer re-derives on a different sample than the +author used" — and no different sample was used. The C1 table above is +*new* data from the same instrument, which tests the survey's coverage but +not its instrument. + +**What the harness would report if it silently stopped:** `source_lines()` +returns 0 for a crate it cannot locate, and the probe scripts sum without +a positive control. A registry path change would report every candidate as +0 marginal lines — i.e. as *fitting comfortably* — which is the direction +that flatters the recommendation. `tools/dep-weight.py` has exactly this +control (`unlocatable crate measures zero (so the guard fires)`); the probe +scripts copied the measurement function and not the guard. + +The `svg-emit` row in the survey's table reads `0` marginal for reasons +that are correct, but a reader cannot distinguish that 0 from a harness +that stopped. **A zero that means "costs nothing" and a zero that means +"measured nothing" are printed identically.** That is HDN, in the survey's +own instrument, in the row carrying the recommendation. + +--- + +## Verdict + +**Not approvable as written.** C1, C2, C3 and C6 each require a change to +the survey, not a clarification. C4 and C5 require controls that must land +in ADR-0007 as conditions on the implementation. + +The recommendation may well survive — none of these establishes that a +windowed toolkit is affordable, only that the survey's argument for why it +is not was overstated and partly unmeasured. But the survey as written +argues from a false interval claim, an inconsistently applied metric rule, +an internal contradiction, and a zero that cannot be distinguished from a +dead harness. diff --git a/history/260802-render-port-research.md b/history/260802-render-port-research.md new file mode 100644 index 0000000..2e875c1 --- /dev/null +++ b/history/260802-render-port-research.md @@ -0,0 +1,75 @@ +# 260802 — how CB-RES-0006 was conducted + +Unpolished trail for `research/CB-RES-0006-render-port.md`, per +InnerLoop §Step 2 documentation requirement. + +## What was measured vs cited + +**Everything quoted is measured.** No line count in the survey comes from +a README, a crates.io page, or memory. Method: a scratch cargo crate per +candidate, `cargo add`, then `cargo tree --edges normal --prefix none` +over the resolved graph, then `\n` counted in every `.rs` file of the +vendored source under `~/.cargo/registry/src/*/`. + +That method is copied out of `tools/dep-weight.py` rather than +re-invented, because the survey's whole purpose is to subtract candidate +sizes from a budget produced by that tool, and two methods produce two +numbers that cannot be subtracted. + +Scripts: `$CLAUDE_JOB_DIR/tmp/survey/{measure,breakdown,gap}.py`. +Deliberately not committed — a throwaway probe promoted to a repo tool is +how the second-use rule gets broken by accident. If a later pass needs +these numbers refreshed, it should re-derive them, and the fact that it +costs ten minutes is a feature. + +## Dead ends and corrections during the survey + +**First reading was on the wrong axis.** The initial run reported headline +totals: `egui+eframe` 2,946,121, `ratatui+crossterm` 1,146,363, +`macroquad` 480,729. Those are the numbers a survey normally quotes and +they are the wrong ones — AM-4a charges what a candidate *adds* to a graph +that already holds 23 crates. Recomputed as marginal against the real +base. For `macroquad` the difference is negligible (480,729 → 480,501); +for `egui+eframe` it is 163,272 lines of overlap. + +**A network 403 that was not a network failure.** `curl` to crates.io +returned 403; the sandbox looked like it was blocking egress. It was a +missing user-agent — crates.io rejects requests without one. Two further +probes (sparse index, static.crates.io) returned 200. Worth recording +because "the environment is blocking me" is a conclusion that ends +surveys, and it was wrong here by one header. + +**Checked whether `cargo tree` was including non-host targets.** The +`ratatui` figure was dominated by `linux-raw-sys` (479,901) and looked +like it might be an all-platform artifact — a Windows-bindings inflation +of a Linux measurement. Re-ran with `--target x86_64-unknown-linux-gnu`: +identical, 1,146,363 both ways. The number is real, and `ratatui` really +does cost more than a 2D game framework. + +**The proc-macro finding was not what the survey set out to look for.** It +came from asking why `syn` was the largest single entry in a +"shipped-runtime" figure. `--edges normal,no-proc-macro` answered it: +89,048 lines, 36.2%, none of which reaches a binary. + +Order matters here, and it went the right way by luck rather than design: +the candidate measurements were complete *before* the instrument defect +was found, so the recommendation could be checked against the corrected +number instead of being derived from it. Had it gone the other way the +survey would have been unusable — a budget correction discovered while +looking for headroom is not evidence, whatever it says. + +## What the survey did not do + +- **It did not sample the region between 0 and 480,501 lines.** The + candidate list was assembled from what one reaches for — modern GPU-era + Rust GUI stacks — and every one of them landed two orders of magnitude + over. The conclusion "no windowed toolkit fits" was therefore drawn from + a sample that contained no small windowed toolkits. This is the gap the + challenge (T02) attacks, and it is a real one. +- **It did not run any candidate.** No candidate was built, drawn with, or + benchmarked. Per InnerLoop §Step 1 the comparisons here are dependency + cost only; nothing in the survey claims one toolkit draws better than + another, and no `better` verdict is available on that axis. +- **It did not measure the browser.** The HTML option is scored zero on + AM-4a because AM-4a measures vendored Rust. What the browser costs on + any other axis is unmeasured and the survey says so in §5. diff --git a/history/260802-render-port-response.md b/history/260802-render-port-response.md new file mode 100644 index 0000000..741b836 --- /dev/null +++ b/history/260802-render-port-response.md @@ -0,0 +1,193 @@ +# 260802 — response to the challenge on CB-RES-0006 + +One round, per InnerLoop §Step 2. Four challenges conceded, two answered +with evidence, and one of the concessions turned up something larger than +this pass. + +--- + +## C1 — the unmeasured interval — **conceded, and the framing withdrawn** + +The challenge is right and the survey's rhetoric was wrong. "Two orders of +magnitude" described the candidates I picked, and I picked them from what a +2026 Rust developer reaches for, which is a list of GPU-era stacks. The +real floor for a windowed toolkit is `fltk` at **140,079** marginal lines — +**1.5×** the corrected headroom, not 128×. + +The C1 table is folded into the survey as measured data, including +`tiny-skia` at 83,956, which fits inside 92,798 and disproves the interval +claim by itself. + +**So dependency cost alone does not decide this.** The survey's argument as +written is withdrawn. What replaces it is an allocation argument, and it is +stronger: + +INTENT stage 2 names `wgpu` explicitly. That is a **1,741,979-line** +marginal cost, already measured, already unavoidable, and it arrives one +stage from now. Against that, spending 1.5× the entire remaining budget on +a stage-1 windowing toolkit that stage 2 immediately discards is the wrong +allocation — not because it does not fit, but because it buys a thing with +a known expiry using money already committed elsewhere. + +That argument does not need AM-4a's target to be exactly 250,000. It only +needs stage 2's bill to be larger than stage 1's benefit, which is +measured, and true by a factor of twelve. + +## C1b — what the concession exposed: **AM-4a cannot survive stage 2** + +Following C1 honestly produces a finding neither the survey nor the +challenge was looking for. + +| | marginal lines | +|---|---:| +| AM-4a target, total | 250,000 | +| current shipped-runtime (corrected, proc-macro excluded) | 157,202 | +| headroom | 92,798 | +| `wgpu` + `winit`, which INTENT stage 2 names by name | **1,741,979** | + +Stage 2 exceeds the entire AM-4a target by **7×**, and exceeds the +remaining headroom by **19×**. No sequencing, no feature-gating and no +metric correction closes a gap that size. **AM-4a as targeted is +incompatible with INTENT as written**, and has been since both were +written; nothing in this pass caused it. + +That is a maintainer's decision, not mine — either the target moves, or the +port stays out of the AM-4a configuration by an argued rule, or stage 2 +changes. It is recorded here and carried to the evidence file, and it is +deliberately **not** resolved by this pass, because a pass that discovers a +budget conflict and also decides it has reviewed nothing. + +It does, however, retire one bad reason for the recommendation. "HTML, +because we cannot afford anything else" is false. "HTML, because stage 1 +should not spend stage 2's money on something stage 2 throws away" is true. + +## C2 — the metric is gameable by relocation — **conceded; one rule, stated** + +The challenge is right that the survey scored the browser at zero on an +axis chosen to produce zero, and right that the same move already makes +`sdl2` and `fltk` look cheap in the survey's own table. I had not noticed, +because I had measured no bindings. + +The rule, which belongs in AM-4's definition and not in this pass's +reasoning, applied consistently to all three: + +> **AM-4 counts third-party code the project causes to be acquired.** It +> does not count runtimes the user already has independently of us — the +> kernel, the system C library, the shell, the terminal, a web browser. It +> *does* count a library our build or install instructions cause to be +> fetched, pinned, or linked, whether or not its source is Rust. + +Under that rule, consistently: + +- a **browser** is not counted — nothing we ship or instruct causes it to + be acquired; +- **`sdl2`/`fltk`** are counted at more than their Rust binding, not less — + they oblige a user to install a `-dev` package they would not otherwise + have, so the survey's 191,973 and 140,079 **understate** them; +- and the rule makes the metric *harder* to satisfy by relocation, not + easier, which is the direction a rule proposed by the party it benefits + has to go before it is worth anything. + +This makes the HTML row honest rather than merely favourable. It also +means the survey's table understates two candidates, and that correction is +folded in. + +## C3 — the port contradicts the second-use rule — **conceded entirely** + +Recommendations 1 and 4 cannot both stand, and 4 is the one INTENT +supports. `CommitWindow` is already in this repo with a delete-by date for +exactly this mistake. + +**`cb-render-api` is withdrawn from this pass.** `cb-render-html` ships +against the existing `Project` trait, which is a real interface with real +implementations rather than a speculative one. The port gets declared at +stage 2, when `wgpu` supplies the second concrete use and the interface can +be derived from two implementations instead of imagined from one. + +This also removes `cb-render-null`: a null implementation of an interface +that does not exist yet is a null implementation of nothing. + +**And it changes the pass's tier.** The structural trigger for tier L was +"creates a new capability port." This pass no longer creates one. That is +recorded rather than acted on — the declaration and its roll stand, and the +pass runs at the weight it declared. Re-rolling a tier because review made +the work smaller would make the tier a function of the outcome, which is +the one thing a tier declaration must not be. **The CHAOS calibration +window gets a second entry from this**, and it is a more interesting one +than the first: tier L at full weight deleted its own structural trigger. + +## C4 — the loopback server — **conceded; controls carried to ADR-0007** + +The challenge is right that "loopback only" is not a boundary against a +browser that also reads the internet, and right that "a page it emitted +itself" is an assumption no code checks. All four controls are adopted as +conditions on the implementation, and the fourth is the load-bearing one: + +1. an unguessable token minted per process, required on every request; +2. `Origin` / `Sec-Fetch-Site` checked, rejected by default; +3. bound explicitly to `127.0.0.1`; +4. **a test that a request without the token is refused** — and, per + M-D1-MUT, a mutation that removes the check and turns that test red. + +Controls 1–3 without 4 are three claims and no evidence, which is the +class this project keeps finding in its own work. + +## C5 — the gate stops at the language boundary — **conceded; both controls adopted** + +Naming a challenge in the survey and calling it pressed was not an answer, +and the challenge says so correctly. + +1. **JavaScript may not construct commands.** The page reports raw pointer + facts; Rust decides what command they mean. JS becomes input transport, + and the decision becomes testable in Rust against synthetic events. +2. **The coverage gate crosses the boundary.** The HTML counterpart to + `every_view_field_is_classified` asserts over the *parsed emitted + document* — the token is in the document, or the build fails. Asserting + over the Rust that emits the document would reproduce CB-WP-0011's + original defect one layer up. + +## C6 — the zeros — **partly answered, partly conceded** + +**Answered:** the candidate measurements did carry the positive control the +challenge says they lacked. `measure.py` tracks an `unlocated` list per +candidate and it is empty for all seven — verified in `results.json`, not +asserted. So no candidate figure is a silently-stopped harness. + +**Conceded, twice over:** + +- `gap.py`, which produced the C1 table, copied the measurement function + **without** the guard. The C1 numbers therefore have no positive control. + They are load-bearing now, since they are what withdrew the survey's main + argument, so they must be re-measured under the guard before ADR-0007 + cites them. +- The survey's HTML row is **0 by construction, not by measurement** — + there is no crate to fail to locate. The challenge is right that a + reader cannot tell that zero from a dead one, and the survey printed them + identically in a table whose other rows are measurements. It is relabelled + rather than left to be read as a measured figure. + +The general observation is worth keeping: a probe script that copies a +tool's measurement function and not its controls is how a positive control +gets lost, and it happened here inside one pass, in a repo whose whole +discipline is positive controls. + +--- + +## What changed in the survey + +- The "two orders of magnitude" framing is **withdrawn**; the real floor is + `fltk` at 1.5× corrected headroom. +- The C1 candidates are folded in as measured rows, re-measured under the + positive control. +- The recommendation's justification changes from *affordability* to + *allocation against stage 2's known 1,741,979-line bill*. +- The AM-4 acquisition rule is stated, and it **raises** the cost of two + candidates rather than lowering the cost of the recommended one. +- **`cb-render-api` and `cb-render-null` are withdrawn from this pass.** +- The HTML row is relabelled *0 by construction*. +- C4 and C5's six controls become conditions in ADR-0007. +- **AM-4a's incompatibility with stage 2 is raised as a maintainer + decision** and explicitly not decided here. + +**Approvable after these changes.** The recommendation stands; the argument +that reached it does not, and has been replaced. diff --git a/research/CB-RES-0006-render-port.md b/research/CB-RES-0006-render-port.md new file mode 100644 index 0000000..5dc30f5 --- /dev/null +++ b/research/CB-RES-0006-render-port.md @@ -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 1–3 without 4 are three claims and no evidence. Control 6 exists +because asserting over the emitting code would reproduce CB-WP-0011's +original defect one layer up. + +### The honest summary + +Two of this survey's three arguments did not survive review. The budget +constraint that appeared to force the decision was overstated by 36% by a +mis-instrumented metric, and *then* overstated again by a candidate list +that contained no cheap windowed toolkits — the real floor is 1.5× the +corrected headroom, not 128×. The port the survey proposed to build +contradicted the rule the same survey cited. + +What survives is narrower and better founded: stage 1 should not spend +stage 2's money on a toolkit stage 2 discards, and it should not canonize +an interface it can only implement once. The recommendation is unchanged. +The reasoning that reached it has been replaced, which is what the review +step is for. diff --git a/tools/cb-play/Cargo.toml b/tools/cb-play/Cargo.toml index da170fd..3bafd06 100644 --- a/tools/cb-play/Cargo.toml +++ b/tools/cb-play/Cargo.toml @@ -7,6 +7,7 @@ license-file.workspace = true [dependencies] cb-kernel.workspace = true cb-events.workspace = true +cb-render-html.workspace = true cb-game-runtime = { workspace = true, features = ["scenarios"] } games-ground = { workspace = true, features = ["scenarios"] } serde_json.workspace = true diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs new file mode 100644 index 0000000..f53fdd4 --- /dev/null +++ b/tools/cb-play/src/hotseat.rs @@ -0,0 +1,373 @@ +//! Hot-seat play in a browser (ADR-0007, INTENT stage 1). +//! +//! One loopback listener, one tab, seats taking turns. The server blocks +//! inside [`Policy::choose`] until the page reports a pointer fact that +//! resolves to a legal command — so the game loop is the same +//! `games_ground::bot::play` loop the CLI and the bots run through, and a +//! browser seat is indistinguishable from a CLI seat to the driver. +//! +//! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this +//! module is the socket and the turn-taking. It deliberately holds no +//! game logic beyond "which seat is being asked" — [`cb_render_html`] +//! decides what a drag means, and the aggregate decides whether the +//! result is legal. + +use std::cell::RefCell; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::rc::Rc; + +use cb_game_runtime::{Project, Viewer}; +use cb_kernel::PlayerId; +use cb_render_html::{document, resolve, Guard, PointerFact, Request}; +use games_ground::bot::{Choice, Policy}; +use games_ground::{GroundCommand, GroundState}; + +/// The shared listener. One per process; every human seat borrows it. +pub struct Server { + listener: TcpListener, + guard: Guard, + log: RefCell>, +} + +impl Server { + pub fn bind(port: u16) -> Result { + let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("local_addr: {e}"))? + .port(); + Ok(Self { + listener, + guard: Guard::mint(port), + log: RefCell::new(Vec::new()), + }) + } + + /// The URL to open, token and all. Printed once at start; the page + /// cannot mint one for itself. + pub fn url(&self) -> String { + self.guard.page_url() + } + + /// What was refused, for the evidence a session leaves behind. + pub fn refusals(&self) -> Vec { + self.log.borrow().clone() + } + + /// Serve until the page reports something that resolves to a move. + /// + /// Requests that fail a control are answered and the loop continues — + /// a refused request must not end someone's game, and must not be + /// silently indistinguishable from one that did nothing. + pub fn next_choice( + &self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Result { + let view = state.project(Viewer::Player(seat)); + loop { + let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?; + let raw = match read_request(&mut stream) { + Ok(r) => r, + Err(e) => { + respond(&mut stream, 400, "text/plain", &e); + continue; + } + }; + let req = match Request::parse(&raw) { + Ok(r) => r, + Err(refusal) => { + self.log.borrow_mut().push(refusal.to_string()); + respond(&mut stream, 400, "text/plain", &refusal.to_string()); + continue; + } + }; + if let Err(refusal) = self.guard.admit(&req) { + self.log.borrow_mut().push(refusal.to_string()); + respond(&mut stream, 403, "text/plain", &refusal.to_string()); + continue; + } + + match (req.method.as_str(), req.path.as_str()) { + ("GET", "/") => { + let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass); + respond(&mut stream, 200, "text/html; charset=utf-8", &page); + } + ("POST", "/command") => { + let fact = match PointerFact::parse(&req.body) { + Ok(f) => f, + Err(e) => { + respond(&mut stream, 400, "text/plain", &e); + continue; + } + }; + if may_pass && fact.down == "pass" && fact.up == "pass" { + respond(&mut stream, 200, "text/plain", "ok: passed"); + return Ok(Choice::Pass); + } + match resolve(&fact, legal, seat) { + Ok(i) => { + respond(&mut stream, 200, "text/plain", "ok"); + return Ok(Choice::Command(i)); + } + // Not an error: the player dragged somewhere that + // means nothing. Say so and keep the turn. + Err(why) => respond(&mut stream, 200, "text/plain", &why), + } + } + _ => respond(&mut stream, 404, "text/plain", "no such thing here"), + } + } + } +} + +/// One seat's view of the shared server. +pub struct SeatPolicy { + server: Rc, + failure: Rc>>, +} + +impl SeatPolicy { + pub fn new(server: Rc, failure: Rc>>) -> Self { + Self { server, failure } + } +} + +impl Policy for SeatPolicy { + fn name(&self) -> &'static str { + "browser" + } + + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Choice { + match self.server.next_choice(state, seat, legal, may_pass) { + Ok(c) => c, + Err(e) => { + // Same shape as the CLI's out-of-input path: record the + // real reason and return something the driver will + // reject, rather than inventing a move. + *self.failure.borrow_mut() = Some(e); + Choice::Command(usize::MAX) + } + } + } +} + +fn read_request(stream: &mut TcpStream) -> Result { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + // Once the head is in, read exactly the declared body and stop — + // otherwise a keep-alive connection blocks here forever. + if let Some(head_end) = find(&buf, b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let want: usize = head + .split("\r\n") + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse().ok())? + }) + .unwrap_or(0); + if buf.len() >= head_end + 4 + want { + break; + } + } + if buf.len() > 64 * 1024 { + return Err("request too large".to_string()); + } + } + String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string()) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 403 => "Forbidden", + _ => "Not Found", + }; + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\ + Content-Length: {}\r\nConnection: close\r\n\ + X-Content-Type-Options: nosniff\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + use games_ground::Action; + + /// One client thread issuing requests **in order**, each on its own + /// `Connection: close` socket and each fully read before the next is + /// sent. Two concurrent clients would race the server's `accept`, and + /// a test whose outcome depends on which socket wins is worse than no + /// test. + fn converse(port: u16, requests: Vec) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + requests + .into_iter() + .map(|raw| { + let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); + s.write_all(raw.as_bytes()).expect("write"); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + out + }) + .collect() + }) + } + + fn get(token: &str) -> String { + format!( + "GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Sec-Fetch-Site: same-origin\r\n\r\n" + ) + } + + fn post(token: &str, body: &str) -> String { + format!( + "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn ground_only() -> Vec { + vec![GroundCommand::SelectAction { + action: Action::Ground, + target: None, + problem: None, + }] + } + + fn state() -> GroundState { + ::setup( + &cb_game_runtime::Setup { + players: 3, + preset: "standard-3p".to_string(), + patch: Default::default(), + }, + 7, + ) + .expect("setup") + } + + /// The whole loop, with a real socket and no browser: the page is + /// served, a pointer fact is posted, and the seat's choice comes back. + #[test] + fn a_drag_posted_over_the_socket_becomes_a_choice() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![get(&token), post(&token, "down=action-ground&up=table")], + ); + + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("200 OK"), "{}", replies[0]); + assert!(replies[0].contains("GROUND"), "the page was not the table"); + assert!( + replies[0].contains("id=\"action-ground\""), + "the offered action was not on the page" + ); + assert!(replies[1].contains("200 OK")); + assert!(server.refusals().is_empty()); + } + + /// ADR-0007 control 1, end to end rather than in the unit: a page + /// without the token gets nothing, and the seat keeps its turn. + /// + /// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this + /// goes red — the token-less request is served the table. Run + /// 2026-08-02. + #[test] + fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![ + "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n" + .to_string(), + post(&token, "down=action-ground&up=table"), + ], + ); + + // The turn survives the refusal and is decided by the good request. + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]); + assert!( + !replies[0].contains("GROUND"), + "the table leaked to a token-less request" + ); + assert!(replies[1].contains("200 OK")); + assert_eq!( + server.refusals(), + vec!["refused: no session token".to_string()] + ); + } + + /// A drag that means nothing must not end the turn, and must say so. + #[test] + fn a_meaningless_drag_keeps_the_turn() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![ + post(&token, "down=seat-1&up=seat-2"), + post(&token, "down=action-ground&up=table"), + ], + ); + + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("200 OK")); + assert!( + replies[0].contains("not a legal move"), + "a meaningless drag must be told it meant nothing: {}", + replies[0] + ); + assert!(replies[1].contains("200 OK")); + } +} diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs index 6b2466d..9e23f53 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -761,6 +761,7 @@ mod tests { bot: "greedy".into(), replay_dir: Some(dir.clone()), record: Some(dir.join("session.yaml")), + serve: None, }; let mut sink: Vec = Vec::new(); let summary = diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs index e90066d..15fae45 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -9,6 +9,7 @@ //! success — a session that ends without an outcome is a failure, and //! saying otherwise is how a stalled game passes for a played one. +mod hotseat; mod inspect; mod table; @@ -16,7 +17,7 @@ use table::Config; const USAGE: &str = "\ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] - [--replay DIR] [--all-bots] [--record FILE] + [--replay DIR] [--all-bots] [--record FILE] [--serve PORT] cb-play --inspect PATH [--as SEAT|spectator] play: @@ -27,6 +28,10 @@ play: --bot KIND policy for every other seat: greedy (default) or random --replay DIR write a .cbreplay bundle of the finished game to DIR --record FILE write the finished game as a scenario YAML + --serve PORT play human seats in a browser on 127.0.0.1:PORT instead + of on the terminal; 0 lets the OS pick. Prints a URL + carrying a per-process token — without it the page is + refused (ADR-0007). inspect a recorded game — a .cbreplay bundle directory or a scenario YAML: --inspect PATH render the table after every recorded step @@ -104,6 +109,15 @@ fn parse_args(argv: &[String]) -> Result { config.replay_dir = Some(value(i, argv, flag)?.into()); i += 2; } + "--serve" => { + play_flags.push(flag.into()); + config.serve = Some( + value(i, argv, flag)? + .parse() + .map_err(|e| format!("--serve: {e}"))?, + ); + i += 2; + } "--all-bots" => { all_bots = true; play_flags.push(flag.into()); @@ -245,6 +259,7 @@ mod tests { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, }; let script = "0\n".repeat(400); let mut out: Vec = Vec::new(); @@ -283,6 +298,7 @@ mod tests { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, }; let mut out: Vec = Vec::new(); table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game"); @@ -344,6 +360,7 @@ mod tests { bot: "random".into(), replay_dir: None, record: None, + serve: None, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game"); @@ -403,6 +420,7 @@ mod tests { bot: "greedy".into(), replay_dir: Some(dir.clone()), record: Some(dir.join("session.yaml")), + serve: None, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("game"); diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 66b4a39..41a0937 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -32,6 +32,9 @@ pub struct Config { /// Where to write the finished game as a scenario file. A session /// somebody played becomes a regression test. pub record: Option, + /// Serve human seats in a browser instead of on the terminal + /// (ADR-0007). `Some(0)` lets the OS pick the port. + pub serve: Option, } impl Default for Config { @@ -43,6 +46,7 @@ impl Default for Config { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, } } } @@ -213,10 +217,28 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( // Human seats and bot seats fill one policy vector; the driver does // not know which is which, which is the point. let failure: Failure = Default::default(); + // ADR-0007: a browser seat and a CLI seat are both just a Policy, so + // the driver cannot tell them apart — which is the property that lets + // a browser game replay as a scenario like any other. + let server = match config.serve { + Some(port) => { + let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?); + let _ = writeln!(out.borrow_mut(), " open {}", s.url()); + let _ = out.borrow_mut().flush(); + Some(s) + } + None => None, + }; let mut policies: Vec> = Vec::new(); for seat in 0..config.players { if config.human_seats.contains(&seat) { - policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))); + match &server { + Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new( + s.clone(), + failure.clone(), + ))), + None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))), + } } else { policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?); } @@ -248,6 +270,20 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( game.commands, &end_hash[..12] ); + // ADR-0007 control 1 leaves evidence rather than only a 403: a + // session that was probed says so, so a player finds out from the + // transcript rather than from nothing at all. + if let Some(s) = &server { + let refusals = s.refusals(); + if refusals.is_empty() { + let _ = writeln!(w, " no requests were refused this session"); + } else { + let _ = writeln!(w, " {} request(s) refused:", refusals.len()); + for r in &refusals { + let _ = writeln!(w, " {r}"); + } + } + } let _ = w.flush(); drop(w); diff --git a/workplans/CB-WP-0012-render-port.md b/workplans/CB-WP-0012-render-port.md new file mode 100644 index 0000000..d562a7c --- /dev/null +++ b/workplans/CB-WP-0012-render-port.md @@ -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.