diff --git a/Cargo.lock b/Cargo.lock index 86e13e2..5a6ecd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,21 +81,11 @@ 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 52875e2..9952ba4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "crates/cb-kernel", "crates/cb-events", "crates/cb-game-runtime", - "crates/cb-render-html", "games/ground", "tools/cb-sim", "tools/cb-play", @@ -20,7 +19,6 @@ 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 722e0d3..44608b8 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 | done | — | workplans/CB-WP-0011-inspectable-table.md | +| workplan | CB-WP-0011 | todo | — | 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,11 +86,6 @@ | task | CB-WP-0010-T01 | done | — | workplans/CB-WP-0010-consolidation.md | | task | CB-WP-0010-T02 | done | — | workplans/CB-WP-0010-consolidation.md | | task | CB-WP-0010-T03 | done | — | workplans/CB-WP-0010-consolidation.md | -| task | CB-WP-0011-T01 | 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 | +| 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 | diff --git a/crates/cb-render-html/Cargo.toml b/crates/cb-render-html/Cargo.toml deleted file mode 100644 index 4435a33..0000000 --- a/crates/cb-render-html/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[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 deleted file mode 100644 index 503f09b..0000000 --- a/crates/cb-render-html/src/doc.rs +++ /dev/null @@ -1,544 +0,0 @@ -//! 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 deleted file mode 100644 index 8e2e604..0000000 --- a/crates/cb-render-html/src/input.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! 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 deleted file mode 100644 index d910f7e..0000000 --- a/crates/cb-render-html/src/lib.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! `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 deleted file mode 100644 index dfe464d..0000000 --- a/crates/cb-render-html/src/serve.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! 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 deleted file mode 100644 index d252e07..0000000 --- a/crates/cb-render-html/src/testfix.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! 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 deleted file mode 100644 index 264d44b..0000000 --- a/decisions/ADR-0007-render-html-not-a-port.md +++ /dev/null @@ -1,173 +0,0 @@ -# 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 deleted file mode 100644 index faba35e..0000000 --- a/evidence/CB-EV-0010-render-port.md +++ /dev/null @@ -1,216 +0,0 @@ -# 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 bcb3fd3..29b43be 100644 --- a/gates.toml +++ b/gates.toml @@ -122,7 +122,6 @@ 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 deleted file mode 100644 index 6c3e7cd..0000000 --- a/history/260802-render-port-challenge.md +++ /dev/null @@ -1,204 +0,0 @@ -# 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 deleted file mode 100644 index 2e875c1..0000000 --- a/history/260802-render-port-research.md +++ /dev/null @@ -1,75 +0,0 @@ -# 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 deleted file mode 100644 index 741b836..0000000 --- a/history/260802-render-port-response.md +++ /dev/null @@ -1,193 +0,0 @@ -# 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 deleted file mode 100644 index 5dc30f5..0000000 --- a/research/CB-RES-0006-render-port.md +++ /dev/null @@ -1,320 +0,0 @@ -# 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 3bafd06..da170fd 100644 --- a/tools/cb-play/Cargo.toml +++ b/tools/cb-play/Cargo.toml @@ -7,7 +7,6 @@ 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 deleted file mode 100644 index f53fdd4..0000000 --- a/tools/cb-play/src/hotseat.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! 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 9e23f53..6b2466d 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -761,7 +761,6 @@ 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 15fae45..e90066d 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -9,7 +9,6 @@ //! 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; @@ -17,7 +16,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] [--serve PORT] + [--replay DIR] [--all-bots] [--record FILE] cb-play --inspect PATH [--as SEAT|spectator] play: @@ -28,10 +27,6 @@ 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 @@ -109,15 +104,6 @@ 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()); @@ -259,7 +245,6 @@ mod tests { bot: "greedy".into(), replay_dir: None, record: None, - serve: None, }; let script = "0\n".repeat(400); let mut out: Vec = Vec::new(); @@ -298,7 +283,6 @@ 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"); @@ -360,7 +344,6 @@ 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"); @@ -420,7 +403,6 @@ 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 41a0937..66b4a39 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -32,9 +32,6 @@ 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 { @@ -46,7 +43,6 @@ impl Default for Config { bot: "greedy".into(), replay_dir: None, record: None, - serve: None, } } } @@ -217,28 +213,10 @@ 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) { - 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()))), - } + policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))); } else { policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?); } @@ -270,20 +248,6 @@ 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 deleted file mode 100644 index d562a7c..0000000 --- a/workplans/CB-WP-0012-render-port.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -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.